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 +4 -4
- data/.claude/skills/layered-ui-rails/references/HELPERS.md +16 -3
- data/CHANGELOG.md +7 -0
- data/app/helpers/layered/ui/combobox_helper.rb +23 -8
- data/app/helpers/layered/ui/form_helper.rb +71 -1
- data/app/views/layered/ui/managed_resource/_field.html.erb +2 -2
- data/app/views/layered/ui/managed_resource/_field_input.html.erb +16 -0
- data/lib/layered/ui/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d892f7efaed58d161179cc5478eaf0961b4b31c2e822aaba4063143343aeac31
|
|
4
|
+
data.tar.gz: 28e37164714d1a354b255a10a3833069b94f6202480082b29e70fcc23a485ffa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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`
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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:,
|
|
313
|
-
|
|
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) %>
|
data/lib/layered/ui/version.rb
CHANGED