Module: Layered::Ui::ComboboxHelper
- Defined in:
- app/helpers/layered/ui/combobox_helper.rb
Constant Summary collapse
- COMBOBOX_TEXT =
Every string the control can show, so a host app can reword or translate it at the call site. Placeholders use Rails' own
%{name}syntax; the ones whose values are only known in the browser are substituted there.:min_charshas no entry: its default is built from the threshold, so it can be counted properly. { empty: "No matches", create: "Add “%{term}”", progress: "Showing %{shown} of %{count} matches.", error: "The options could not be loaded.", more_error: "More options could not be loaded." }.freeze
- COMBOBOX_OPTIONS =
The keywords
l_ui_comboboxaccepts. Its signature is keyword-only, so a :combobox field config can only carry these (unlike the other field types, whose extras become HTML attributes on the input). %i[ collection url form selected multiple create create_name reorder min_chars text id label hint placeholder required disabled describedby container ].freeze
Instance Method Summary collapse
-
#l_ui_combobox(name, collection: nil, form: nil, selected: nil, multiple: true, create: false, create_name: nil, reorder: false, url: nil, min_chars: 0, text: {}, id: nil, label: nil, hint: nil, placeholder: nil, required: false, disabled: false, describedby: nil, container: {}) ⇒ Object
Renders a token select: a text input with type-ahead filtering whose selections become removable tokens, in the style of an email recipient field.
Instance Method Details
#l_ui_combobox(name, collection: nil, form: nil, selected: nil, multiple: true, create: false, create_name: nil, reorder: false, url: nil, min_chars: 0, text: {}, id: nil, label: nil, hint: nil, placeholder: nil, required: false, disabled: false, describedby: nil, container: {}) ⇒ Object
Renders a token select: a text input with type-ahead filtering whose
selections become removable tokens, in the style of an email recipient
field. Built on the ARIA combobox pattern (an input with
role="combobox" owning a listbox popup), so it works with the
keyboard and with screen readers - no third-party select library.
<%= l_ui_combobox("post[tag_ids]",
label: "Tags",
collection: Tag.pluck(:name, :id),
selected: @post.tag_ids) %>
Inside a form builder, pass the attribute as a Symbol with form: and
the parameter name is derived from the builder:
<%= form_with model: @post do |f| %>
<%= l_ui_combobox(:tag_ids, form: f, label: "Tags",
collection: Tag.pluck(:name, :id), selected: @post.tag_ids) %>
<% end %>
Selections post as hidden inputs named after the field, so the control needs no JSON round-trip on submit. A leading blank value is always posted, so clearing every token submits an empty collection rather than omitting the parameter (which would leave the association untouched).
Single select
Pass multiple: false for a one-of-many control. The parameter loses
its [] suffix, choosing an option replaces the current token, and the
listbox closes on selection.
Creating new values
With create: true, a term that matches no option can be added as a
new token. New tokens post under a separate parameter (+create_name:+,
which is then required) so the server never has to guess whether a
submitted value is an existing record's ID or a free-text label:
<%= l_ui_combobox(:tag_ids, form: f, create: true, create_name: :new_tag_names, ...) %>
# => post[tag_ids][] => ["", "7"]
# post[new_tag_names][] => ["urgent"]
On redisplay, pass previously created values in selected: alongside
the existing ones: any selected value absent from collection is
rendered as a new token.
Remote options
Pass url: and the options are fetched from that endpoint as the user
types, instead of being filtered in the browser - for collections too
large to render up front. The endpoint is sent term and page and
answers with JSON; Layered::Ui::ComboboxOptions builds that payload:
<%= l_ui_combobox(:author_id, form: f, multiple: false,
url: options_users_path, min_chars: 2,
selected: [[@post.author.name, @post.author_id]]) %>
collection: is then optional (any options given are shown until the
first response arrives). Because the browser holds no copy of the
collection, a selected value cannot be looked up for its label, so pass
remote selections as [label, value] pairs.
Further pages are appended as the user reaches the end of the list - by scrolling to its foot, or by pressing Down on its last option, so the keyboard is not capped at the first page. A request in flight shows a spinner rather than a message, after a delay, so a quick answer never flashes one.
Wording
Every string the control can show is in text:, so it can be reworded
or translated where it is used:
<%= l_ui_combobox(:author_id, form: f, url: options_users_path,
text: { empty: "Personne", progress: "%{shown} sur %{count}" }) %>
Reordering
With reorder: true each token gains a pair of move controls and can
be dragged with a mouse; the parameters are posted in the displayed
order. The move buttons are not decoration: dragging alone would fail
WCAG 2.2 SC 2.5.7, which requires a single-pointer alternative.
Options:
collection: (Array) Options as ["Label", value] pairs, {label:, value:} hashes, or plain strings.
url: (String) Endpoint searched as the user types. Options come from it rather than +collection:+.
form: (Builder) Form builder used to derive parameter names from Symbol names.
selected: (Array) Selected values, or ["Label", value] pairs / {label:, value:} hashes where the
label cannot be looked up in +collection+. Values outside it become new tokens.
multiple: (Boolean) Multi-select with many tokens (default), or single select.
create: (Boolean) Allow adding values that are not in the collection. Requires +create_name:+.
create_name: (String) Parameter name new values post under.
reorder: (Boolean) Show move controls and allow dragging tokens (default false).
min_chars: (Integer) Characters needed before a remote search runs (default 0, i.e. search on focus).
text: (Hash) Wording for the strings the control shows, merged over COMBOBOX_TEXT:
+:empty+, +:create+ (+%{term}+), +:min_chars+ (+%{count}+), +:progress+
(+%{shown}+, +%{count}+), +:error+ and +:more_error+. +progress: nil+ drops
the progress line.
label: (String) Renders an +l-ui-label+ bound to the input.
hint: (String) Renders an +l-ui-form__hint+ referenced by the input.
placeholder: (String) Input placeholder. Defaults to nothing.
required: (Boolean) Marks the label and input as required.
disabled: (Boolean) Disables the input and every token control.
describedby: (String) Extra element ids appended to the input's +aria-describedby+, for text
rendered outside the control (a validation message, say).
container: (Hash) Extra HTML attributes for the wrapping <div>.
131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 |
# File 'app/helpers/layered/ui/combobox_helper.rb', line 131 def l_ui_combobox(name, collection: nil, form: nil, selected: nil, multiple: true, create: false, create_name: nil, reorder: false, url: nil, min_chars: 0, text: {}, id: nil, label: nil, hint: nil, placeholder: nil, required: false, disabled: false, describedby: nil, container: {}) if collection.nil? && url.nil? raise ArgumentError, "l_ui_combobox requires collection: (options filtered in the browser) or url: " \ "(options fetched from an endpoint as the user types)" end if create && create_name.nil? raise ArgumentError, "l_ui_combobox requires create_name: when create: is set, so new values post " \ "under their own parameter (e.g. create_name: :new_tag_names)" end text = l_ui_combobox_text(text, min_chars: min_chars) id ||= "l-ui-combobox-#{SecureRandom.hex(4)}" = l_ui_combobox_option_pairs(collection) value_name = l_ui_combobox_param(name, form, multiple: multiple) create_value_name = create ? l_ui_combobox_param(create_name, form, multiple: multiple) : nil tokens = l_ui_combobox_tokens(selected, , multiple: multiple, create: create, url: url, value_name: value_name, create_value_name: create_value_name) container_attrs = container.deep_dup container_attrs[:class] = class_names("l-ui-combobox", container_attrs[:class]) container_attrs[:data] = l_ui_combobox_data(container_attrs[:data], multiple: multiple, create: create, reorder: reorder, disabled: disabled, value_name: value_name, create_value_name: create_value_name, url: url, min_chars: min_chars, text: text) tag.div(**container_attrs) do safe_join([ l_ui_combobox_label(label, id, required: required), (tag.p(hint, id: "#{id}-hint", class: "l-ui-form__hint") if hint), l_ui_combobox_control(id, tokens, value_name: value_name, placeholder: placeholder, hint: hint, reorder: reorder, disabled: disabled, required: required, url: url, describedby: describedby), l_ui_combobox_listbox(id, , tokens, multiple: multiple, url: url, text: text), l_ui_combobox_template(reorder: reorder, disabled: disabled), (l_ui_combobox_option_template if url), tag.span(l_ui_combobox_instructions(multiple: multiple, create: create, url: url), id: "#{id}-instructions", class: "l-ui-sr-only"), tag.div("", class: "l-ui-sr-only", role: "status", aria: { live: "polite" }, data: { "l-ui--combobox-target" => "status" }) ].compact) end end |