layered-ui-rails 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ffb58ab0d1712c81928bbf3685708f5571e98dd1af70484024219dc6a491f2cc
4
- data.tar.gz: 495f951c875f7572f38b77e6739fcbd49856160a0d84e96cd76f701c97d1262d
3
+ metadata.gz: d892f7efaed58d161179cc5478eaf0961b4b31c2e822aaba4063143343aeac31
4
+ data.tar.gz: 28e37164714d1a354b255a10a3833069b94f6202480082b29e70fcc23a485ffa
5
5
  SHA512:
6
- metadata.gz: 4f855f052b32a2b172a8bb390d1bd19680fccc568996a9b286c4edf9eec2665ad3e0c97b8ff686d760da9dd8f9306ef941cb1ed449f65b984ac01d139818230d
7
- data.tar.gz: a0abb3efe8343044f8581ad5f1c476f3a822c7225daf3ec5638d4a61cb685d5db3886f7f884d461ab685a4b8ff67f68894fca25324665219051ebd439a6a428a
6
+ metadata.gz: d2d3c833c03dd9c56d88bf578fb69f27ddf0eda03baf31ebb0953e6de0bb9b5ae1248d444e77c362d18e4288aad6adbae28a4959f95c68a392b373417c23040d
7
+ data.tar.gz: 661964c07f3d03e1d4ee937a9f4b3f3d456491fadb9ee67778cee063d9eda535fbf02a276bcfe1a3c0a1b67579ece03afda66a5e1d98c90ebe6b232de6c05731
@@ -257,14 +257,21 @@ Renders a complete form with all fields, error summary, and submit button via th
257
257
 
258
258
  Field options:
259
259
  - `attribute` (Symbol) - model attribute
260
- - `as` (Symbol, optional) - field type; auto-detected from column type. Supported: `:string`, `:text`, `:email`, `:password`, `:number`, `:tel`, `:url`, `:search`, `:date`, `:datetime`, `:time`, `:month`, `:week`, `:color`, `:range`, `:file`, `:select`, `:checkbox`, `:hidden`. Forms automatically become `multipart` when any field is `:file`.
260
+ - `as` (Symbol, optional) - field type; auto-detected from column type. Supported: `:string`, `:text`, `:email`, `:password`, `:number`, `:tel`, `:url`, `:search`, `:date`, `:datetime`, `:time`, `:month`, `:week`, `:color`, `:range`, `:file`, `:select`, `:combobox`, `:checkbox`, `:hidden`. Forms automatically become `multipart` when any field is `:file`.
261
261
  - `label` (String, optional) - custom label text; defaults to humanised attribute
262
262
  - `required` (Boolean, optional) - marks field as required; default false
263
263
  - `hint` (String, optional) - help text below the field
264
- - `collection` (Array, optional) - required for `:select` type; e.g. `[['Label', value], ...]`
264
+ - `collection` (Array, optional) - required for `:select`; for `:combobox` either this or `url:` is required; e.g. `[['Label', value], ...]`
265
265
  - `include_blank` (Boolean or String, optional) - for `:select` fields; defaults to `true`. Pass a string to use as the blank option's label, or `false` to omit it. Suppressed when `prompt:` is set
266
266
  - `prompt` (String, optional) - for `:select` fields; prompt text shown as the first option, only selectable when no value is set
267
267
  - `placeholder` (String, optional) - input placeholder text
268
+ - any other key passes through to the underlying field helper as an HTML attribute (`:combobox` excepted - see below)
269
+
270
+ A `:combobox` field renders its own label and hint, adds the field's error element to its `aria-describedby`, and defaults `selected:` to the record's current value. That, and its keyword-only signature, give three rules:
271
+
272
+ - It takes only `l_ui_combobox`'s own options - `url:`, `multiple:`, `create:`, `create_name:`, `reorder:`, `min_chars:`, `text:`, `selected:`, `disabled:`, `describedby:`, `container:`, `id:`. Every other key raises, `prompt:` and `include_blank:` included; extra HTML attributes go on `container:`.
273
+ - `multiple:` is inferred from the attribute rather than defaulting to `l_ui_combobox`'s `true`: an `_ids` attribute, an array column, or one already holding an array is multiple; anything else is single, so a scalar attribute posts a scalar rather than an array Active Record would cast to `nil`. Pass `multiple:` explicitly to override.
274
+ - Pass `selected: [[@post.user.name, @post.user_id]]` whenever the record's value might not be in the options - a `url:` collection never holds it, and nor does a scoped, paginated or filtered `collection:`. Without it, the field raises on an edit form.
268
275
 
269
276
  ```erb
270
277
  <%= l_ui_form(@post,
@@ -273,6 +280,8 @@ Field options:
273
280
  { attribute: :body, as: :text },
274
281
  { attribute: :category, as: :select, collection: Category.pluck(:name, :id) },
275
282
  { attribute: :published, as: :checkbox },
283
+ { attribute: :tag_ids, as: :combobox, collection: Tag.pluck(:name, :id) },
284
+ { attribute: :author_id, as: :combobox, url: options_users_path },
276
285
  ],
277
286
  url: posts_path) %>
278
287
  ```
@@ -284,6 +293,7 @@ l_ui_normalise_field(record, config) # Normalise a raw field config into canoni
284
293
  l_ui_field_error_id(record, attribute) # Error element ID for aria-describedby
285
294
  l_ui_field_hint_id(record, attribute) # Hint element ID for aria-describedby
286
295
  l_ui_field_describedby(record, attribute, hint: false) # Space-joined hint + error IDs for aria-describedby
296
+ l_ui_field_value(record, attribute) # Record's current value; default :combobox selection
287
297
  ```
288
298
 
289
299
  ## Modal
@@ -415,7 +425,7 @@ When a popover is declared, the tag container itself becomes the `l-ui--popover`
415
425
  l_ui_combobox(name, collection: nil, form: nil, selected: nil, multiple: true,
416
426
  create: false, create_name: nil, reorder: false, url: nil,
417
427
  min_chars: 0, text: {}, id: nil, label: nil, hint: nil, placeholder: nil,
418
- required: false, disabled: false, container: {})
428
+ required: false, disabled: false, describedby: nil, container: {})
419
429
  ```
420
430
 
421
431
  Renders a token select (`class="l-ui-combobox"`): a text input with type-ahead filtering whose selections become removable tags, in the style of an email recipient field. Built on the ARIA combobox pattern - no third-party select library.
@@ -431,8 +441,11 @@ Renders a token select (`class="l-ui-combobox"`): a text input with type-ahead f
431
441
  - `url` (String, optional) - endpoint searched as the user types; options come from it rather than from `collection:`
432
442
  - `min_chars` (Integer, default `0`) - characters needed before a remote search runs; `0` searches as soon as the field is focused
433
443
  - `text` (Hash, optional) - wording for every string the control shows, merged over `Layered::Ui::ComboboxHelper::COMBOBOX_TEXT`: `empty:` ("No matches"), `create:` ("Add “%{term}”"), `min_chars:` ("Type %{count} characters to search."), `progress:` ("Showing %{shown} of %{count} matches."), `error:`, `more_error:`. Placeholders use Rails' `%{name}` syntax; `progress: nil` drops the progress line; an unknown key raises
444
+ - `describedby` (String, optional) - extra element ids appended to the input's `aria-describedby`, for text rendered outside the control (a validation message, say)
434
445
  - `label`, `hint`, `placeholder`, `required`, `disabled`, `id`, `container` - as for a normal field
435
446
 
447
+ Also available as a field type in `l_ui_form`: `{ attribute: :tag_ids, as: :combobox, collection: ... }`.
448
+
436
449
  ```erb
437
450
  <%= form_with model: @post do |f| %>
438
451
  <%= l_ui_combobox(:tag_ids, form: f,
data/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. This project follows [Semantic Versioning](https://semver.org/).
4
4
 
5
+ ## [0.25.0] - 2026-08-23
6
+
7
+ ### Added
8
+
9
+ - `:combobox` as a form field type: a field declared `as: :combobox` renders through `l_ui_combobox`, so a form gets type-ahead filtering and token selections without dropping out to the helper by hand. The field renders its own label and hint, defaults its selection to the record's current value, and takes the field's error element into the input's `aria-describedby`. Whether the control is multiple is inferred from the attribute - an `_ids` writer, or an array column - so the same declaration posts the same shape on a new record and on an edit; pass `multiple:` to override it.
10
+ - Field options are checked as the field is normalised, so a key the combobox cannot take names itself instead of raising "unknown keyword" mid-render. `:prompt` and `:include_blank` (carried over from `as: :select`) raise too, pointing at `placeholder:` and noting that a combobox with no selection is already blank. A remote (`url:`) field cannot look up the label of a value the record already holds, so that case raises with a pointer to `selected:`.
11
+
5
12
  ## [0.24.0] - 2026-08-22
6
13
 
7
14
  ### Added
@@ -13,6 +13,15 @@ module Layered
13
13
  error: "The options could not be loaded.",
14
14
  more_error: "More options could not be loaded."
15
15
  }.freeze
16
+
17
+ # The keywords +l_ui_combobox+ accepts. Its signature is keyword-only, so
18
+ # a :combobox field config can only carry these (unlike the other field
19
+ # types, whose extras become HTML attributes on the input).
20
+ COMBOBOX_OPTIONS = %i[
21
+ collection url form selected multiple create create_name reorder
22
+ min_chars text id label hint placeholder required disabled
23
+ describedby container
24
+ ].freeze
16
25
  # Renders a token select: a text input with type-ahead filtering whose
17
26
  # selections become removable tokens, in the style of an email recipient
18
27
  # field. Built on the ARIA combobox pattern (an +input+ with
@@ -116,11 +125,13 @@ module Layered
116
125
  # placeholder: (String) Input placeholder. Defaults to nothing.
117
126
  # required: (Boolean) Marks the label and input as required.
118
127
  # disabled: (Boolean) Disables the input and every token control.
128
+ # describedby: (String) Extra element ids appended to the input's +aria-describedby+, for text
129
+ # rendered outside the control (a validation message, say).
119
130
  # container: (Hash) Extra HTML attributes for the wrapping <div>.
120
131
  def l_ui_combobox(name, collection: nil, form: nil, selected: nil, multiple: true,
121
132
  create: false, create_name: nil, reorder: false, url: nil,
122
133
  min_chars: 0, text: {}, id: nil, label: nil, hint: nil, placeholder: nil,
123
- required: false, disabled: false, container: {})
134
+ required: false, disabled: false, describedby: nil, container: {})
124
135
  if collection.nil? && url.nil?
125
136
  raise ArgumentError,
126
137
  "l_ui_combobox requires collection: (options filtered in the browser) or url: " \
@@ -157,7 +168,8 @@ module Layered
157
168
  (tag.p(hint, id: "#{id}-hint", class: "l-ui-form__hint") if hint),
158
169
  l_ui_combobox_control(id, tokens,
159
170
  value_name: value_name, placeholder: placeholder, hint: hint,
160
- reorder: reorder, disabled: disabled, required: required, url: url),
171
+ reorder: reorder, disabled: disabled, required: required, url: url,
172
+ describedby: describedby),
161
173
  l_ui_combobox_listbox(id, options, tokens, multiple: multiple, url: url, text: text),
162
174
  l_ui_combobox_template(reorder: reorder, disabled: disabled),
163
175
  (l_ui_combobox_option_template if url),
@@ -203,10 +215,12 @@ module Layered
203
215
  if label.nil? && !create
204
216
  raise ArgumentError,
205
217
  "l_ui_combobox was given selected value #{value.inspect}, which is not in the " \
206
- "collection. " +
207
- (url ? "Pass it as a [label, value] pair, since a remote collection cannot be " \
208
- "searched for its label. " : "") +
209
- "Pass create: true (with create_name:) to allow values outside it."
218
+ "collection. Pass it with its label, e.g. selected: [[\"Label\", " \
219
+ "#{value.inspect}]] - " +
220
+ (url ? "a remote collection cannot be searched for its label. " :
221
+ "a scoped or paginated collection will not contain a value the record " \
222
+ "already has. ") +
223
+ "Or pass create: true (with create_name:) to allow values outside the collection."
210
224
  end
211
225
 
212
226
  {
@@ -309,8 +323,9 @@ module Layered
309
323
  end
310
324
  end
311
325
 
312
- def l_ui_combobox_control(id, tokens, value_name:, placeholder:, hint:, reorder:, disabled:, required:, url: nil)
313
- described_by = [("#{id}-hint" if hint), "#{id}-instructions"].compact.join(" ")
326
+ def l_ui_combobox_control(id, tokens, value_name:, placeholder:, hint:, reorder:, disabled:, required:,
327
+ url: nil, describedby: nil)
328
+ described_by = [("#{id}-hint" if hint), "#{id}-instructions", describedby.presence].compact.join(" ")
314
329
 
315
330
  tag.div(class: class_names("l-ui-combobox__control", "l-ui-combobox__control--disabled" => disabled),
316
331
  data: { "l-ui--combobox-target" => "control", action: "click->l-ui--combobox#focusInput" }) do
@@ -5,7 +5,7 @@ module Layered
5
5
  string text email password number tel url search
6
6
  date datetime time month week
7
7
  color range file
8
- select checkbox hidden
8
+ select checkbox hidden combobox
9
9
  ].freeze
10
10
 
11
11
  # Renders a complete form with all fields, error summary,
@@ -38,11 +38,51 @@ module Layered
38
38
  "Provide collection: [['Label', value], ...] or collection: -> { Model.pluck(:name, :id) }"
39
39
  end
40
40
 
41
+ if as == :combobox && config[:collection].nil? && config[:url].nil?
42
+ raise ArgumentError,
43
+ "Field :#{attribute} is declared as :combobox but has no :collection or :url. " \
44
+ "Provide collection: [['Label', value], ...] to filter in the browser, " \
45
+ "or url: to fetch options from an endpoint as the user types"
46
+ end
47
+
48
+ if as == :combobox && config[:url] && !config.key?(:selected) &&
49
+ l_ui_field_value(record, attribute).present?
50
+ raise ArgumentError,
51
+ "Field :#{attribute} is a remote :combobox with a value already set, but no :selected. " \
52
+ "A remote collection cannot be searched for the label of an existing value, " \
53
+ "so pass it with its label, e.g. selected: [[record.user.name, record.user_id]]"
54
+ end
55
+
41
56
  label = config[:label] || attribute.to_s.humanize
42
57
 
43
58
  extras = config.except(:attribute, :as, :label, :required, :hint,
44
59
  :collection, :placeholder, :prompt, :include_blank)
45
60
 
61
+ if as == :combobox
62
+ unusable = {
63
+ prompt: "Use placeholder: instead, which a combobox shows until something is selected.",
64
+ include_blank: "A combobox with no selection is already blank, so there is nothing to set."
65
+ }
66
+ unusable.each do |key, advice|
67
+ next unless config.key?(key)
68
+
69
+ raise ArgumentError,
70
+ "Field :#{attribute} is a :combobox and cannot take :#{key}. #{advice}"
71
+ end
72
+
73
+ extras[:multiple] = l_ui_field_multiple?(record, attribute) unless config.key?(:multiple)
74
+
75
+ allowed = ComboboxHelper::COMBOBOX_OPTIONS + [:class]
76
+ unknown = extras.keys - allowed
77
+ if unknown.any?
78
+ raise ArgumentError,
79
+ "Field :#{attribute} is a :combobox and cannot take " \
80
+ "#{unknown.map { |key| ":#{key}" }.join(', ')}. Unlike the other field types, a " \
81
+ "combobox takes only its own options (#{allowed.map { |key| ":#{key}" }.join(', ')}); " \
82
+ "extra HTML attributes belong on container:"
83
+ end
84
+ end
85
+
46
86
  {
47
87
  attribute: attribute,
48
88
  as: as,
@@ -57,6 +97,12 @@ module Layered
57
97
  }
58
98
  end
59
99
 
100
+ # The record's current value for a field, used as the default selection
101
+ # for a :combobox field when +selected:+ is not given.
102
+ def l_ui_field_value(record, attribute)
103
+ record.public_send(attribute) if record.respond_to?(attribute)
104
+ end
105
+
60
106
  def l_ui_field_error_id(record, attribute)
61
107
  "#{record.model_name.param_key}_#{attribute}_error"
62
108
  end
@@ -76,6 +122,30 @@ module Layered
76
122
 
77
123
  private
78
124
 
125
+ # Whether a :combobox field holds several values. +l_ui_combobox+ defaults
126
+ # to a multiple select, but a form field is more often a single scalar
127
+ # attribute, which would silently cast an array of values to nil. So a
128
+ # field is multiple only when the attribute is plainly a collection: an
129
+ # +_ids+ association writer, an array column, or an attribute already
130
+ # holding an array. The column is asked before the value, so an array
131
+ # column with no default stays multiple on a new record, where its value
132
+ # is nil rather than an empty array.
133
+ def l_ui_field_multiple?(record, attribute)
134
+ return true if attribute.to_s.end_with?("_ids")
135
+ return true if l_ui_field_array_column?(record.class, attribute)
136
+
137
+ l_ui_field_value(record, attribute).is_a?(Array)
138
+ end
139
+
140
+ # Array columns are a PostgreSQL feature, so the flag is only carried by
141
+ # some adapters' columns.
142
+ def l_ui_field_array_column?(model_class, attribute)
143
+ return false unless model_class.respond_to?(:columns_hash)
144
+
145
+ column = model_class.columns_hash[attribute.to_s]
146
+ column.respond_to?(:array?) && column.array?
147
+ end
148
+
79
149
  def l_ui_field_type_for(model_class, attribute)
80
150
  return :string unless model_class.respond_to?(:columns_hash)
81
151
 
@@ -2,7 +2,7 @@
2
2
  <%= render "layered/ui/managed_resource/field_input", form: form, record: record, config: config %>
3
3
  <% else %>
4
4
  <div class="l-ui-form__group">
5
- <% unless config[:as] == :checkbox %>
5
+ <% unless config[:as] == :checkbox || config[:as] == :combobox %>
6
6
  <%= render "layered_ui/shared/label",
7
7
  form: form, field: config[:attribute],
8
8
  required: config[:required], name: config[:label] %>
@@ -10,7 +10,7 @@
10
10
 
11
11
  <%= render "layered/ui/managed_resource/field_input", form: form, record: record, config: config %>
12
12
 
13
- <% if config[:hint] %>
13
+ <% if config[:hint] && config[:as] != :combobox %>
14
14
  <p class="l-ui-form__hint" id="<%= l_ui_field_hint_id(record, config[:attribute]) %>">
15
15
  <%= config[:hint] %>
16
16
  </p>
@@ -35,6 +35,22 @@
35
35
  <%= form.select(attribute, choices, select_options,
36
36
  class: field_class.call("l-ui-select"), **base_opts, **extras) %>
37
37
  </div>
38
+ <% elsif config[:as] == :combobox %>
39
+ <%
40
+ collection = config[:collection]
41
+ choices = collection.respond_to?(:call) ? collection.call : collection
42
+ combobox_opts = extras
43
+ selected = combobox_opts.key?(:selected) ? combobox_opts.delete(:selected) : l_ui_field_value(record, attribute)
44
+ container = (combobox_opts.delete(:container) || {}).dup
45
+ container[:class] = token_list(container[:class], extra_class) if extra_class
46
+ describedby = [l_ui_field_error_id(record, attribute),
47
+ combobox_opts.delete(:describedby).presence].compact.join(" ")
48
+ %>
49
+ <%= l_ui_combobox(attribute, form: form, collection: choices, selected: selected,
50
+ label: config[:label], hint: config[:hint],
51
+ placeholder: config[:placeholder], required: config[:required],
52
+ describedby: describedby,
53
+ container: container, **combobox_opts) %>
38
54
  <% elsif config[:as] == :checkbox %>
39
55
  <div class="l-ui-checkbox-container">
40
56
  <%= form.check_box(attribute, class: field_class.call("l-ui-checkbox"), **base_opts, **extras) %>
@@ -1,5 +1,5 @@
1
1
  module Layered
2
2
  module Ui
3
- VERSION = "0.24.0"
3
+ VERSION = "0.25.0"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: layered-ui-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.24.0
4
+ version: 0.25.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - layered.ai