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_chars has 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_combobox accepts. 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

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)}"
  options = 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, options, 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, options, 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