layered-ui-rails 0.21.0 → 0.23.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: 9ced93c0df2346914e675fcdf3dd10657a079d3dcc046271dfb6bb24e6ff2c8c
4
- data.tar.gz: 5e64064b0fdb591d0621118350bf5c1b181c88de7799a02a9b7f2e7e628f5ad0
3
+ metadata.gz: 2346c306d3b5e355c1f61a51254d79701fe3a9ad2e8bc5817aa7311b0e024db0
4
+ data.tar.gz: b01fdcd1dca34dd339ea4183933b5b6fcded78d3a2ee2c4c371ed92cd18e3214
5
5
  SHA512:
6
- metadata.gz: 9408f35f4b9b2d2a57d0227ec06cbf55a8f8c9ac692d97e1a0a6e9765f0b369bf9732aea5d8742b4c454c1cc555e258303abb2e57f878c352db18b5416fb454b
7
- data.tar.gz: e24b88f24d9f70192bdfa75166f287da9bb01165c6af436b25efc812e7d83e1c5df2cd915896a75aa5c26a4c699c2fbcd3ac6661a0f8f0888d94d8366daac241
6
+ metadata.gz: 29160e5b7d0dea492dd8cb2591b3797a564e2bef6bdabe7fa355ad79cde4dc516cf8537eef1c6298852a22dacf74126b5405bdbcd6fe63dd358adc37779e693e
7
+ data.tar.gz: 8ef704e93632b9f1d60eb4f521f82c7b55e072ef507cea2267fbccdd6ecd76980e43bf6720c3ce0449df38dcfc3753d970167cf5a6b80af85cb868fb5a1169e1
@@ -252,7 +252,7 @@ The panel toggle button uses an inline SVG that inherits `currentColor`. Recolor
252
252
 
253
253
  ## Optional integrations
254
254
 
255
- - **Devise** - auto-detected. Provides styled auth views, header login/register buttons, sidebar user info and logout. Setup: `bundle add devise`, run `devise:install` and `devise User` generators, add `devise_for :users` to routes. Configure `Layered::Ui.current_user_method` if not using `:current_user`. Helpers: `l_ui_devise_installed?`, `l_ui_user_signed_in?`.
255
+ - **Devise** - auto-detected. Provides styled auth views, header login/register buttons, sidebar user info and logout. Setup: `bundle add devise`, run `devise:install` and `devise User` generators, add `devise_for :users` to routes. Configure `Layered::Ui.current_user_method` if not using `:current_user`, and `Layered::Ui.devise_scope` if the Devise mapping isn't `:user`. Helpers: `l_ui_devise_installed?`, `l_ui_user_signed_in?`.
256
256
  - **Pagy** - auto-detected. Use `l_ui_pagy(@pagy)` for styled pagination.
257
257
  - **Ransack** - auto-detected. Use `l_ui_search_form` and `l_ui_sort_link` for styled search and sortable tables.
258
258
 
@@ -261,6 +261,7 @@ The panel toggle button uses an inline SVG that inherits `currentColor`. Recolor
261
261
  ```ruby
262
262
  # config/initializers/layered_ui.rb
263
263
  Layered::Ui.current_user_method = :current_member # default: :current_user
264
+ Layered::Ui.devise_scope = :member # default: :user
264
265
  ```
265
266
 
266
267
  ## Common issues
@@ -111,7 +111,7 @@ Accessible tabbed interface with keyboard navigation.
111
111
  Positions a native `popover`-attribute element relative to its trigger, with auto-flip near viewport edges. Showing, hiding, light-dismiss (outside click), and Escape-to-close are all handled by the browser via the `popover` attribute - this controller only handles placement.
112
112
 
113
113
  **Targets:** `trigger`, `popover`
114
- **Values:** `placement` (String, default `"bottom"`; one of `"top"`, `"bottom"`, `"left"`, `"right"`), `align` (String, default `"start"`; `"start"` or `"end"` - which edge of the popover flushes with the trigger on the cross axis)
114
+ **Values:** `placement` (String, default `"bottom"`; one of `"top"`, `"bottom"`, `"left"`, `"right"`), `align` (String, default `"start"`; `"start"` or `"end"` - which edge of the popover flushes with the trigger on the cross axis), `open` (Boolean, default `false`; when true the popover opens as soon as the controller connects, shown and positioned in the same task so it never paints unpositioned - e.g. re-opening a filter popover after a form submission re-renders the page)
115
115
 
116
116
  ```html
117
117
  <div data-controller="l-ui--popover">
@@ -130,6 +130,31 @@ Positions a native `popover`-attribute element relative to its trigger, with aut
130
130
  Features:
131
131
  - Repositions on open, and while open on window resize/scroll
132
132
  - Flips to the opposite side if the preferred placement would overflow the viewport
133
+ - `data-l-ui--popover-open-value="true"` opens the popover on connect without a flash of unpositioned content
134
+
135
+ ## Combobox (`l-ui--combobox`)
136
+
137
+ Drives the token select rendered by `l_ui_combobox`: type-ahead filtering, selections held as tokens with their own hidden inputs, optional creation of values outside the collection, optional reordering, and optional remote options.
138
+
139
+ **Targets:** `control`, `tokens`, `token`, `input`, `listbox`, `option`, `empty`, `notice`, `noticeText`, `busy`, `moreSpinner`, `template`, `optionTemplate`, `status`
140
+ **Values:** `multiple` (Boolean, default `true`), `create` (Boolean, default `false`), `reorder` (Boolean, default `false`), `disabled` (Boolean, default `false`), `name` (String - parameter existing values post under), `createName` (String - parameter created values post under), `url` (String - endpoint searched as the user types; empty means filter in the browser), `minChars` (Number, default `0` - characters needed before a remote search runs), `text` (Object - the wording for the messages built here, from the helper's `text:` option; `%{name}` placeholders are substituted at use, and a key the caller emptied is left unsaid)
141
+ **Actions:** `focusInput`, `open`, `close`, `blur`, `scrolled`, `filter`, `keydown`, `selectOption`, `removeToken`, `moveTokenEarlier`, `moveTokenLater`, `dragstart`, `dragover`, `drop`, `dragend`
142
+
143
+ Features:
144
+ - Focus never leaves the input while the list is browsed; the highlight follows `aria-activedescendant`
145
+ - Down/Up/Home/End move the highlight, Enter chooses, Escape closes then clears, Backspace on the empty input removes the last token
146
+ - With nothing highlighted, Down enters the list at the first option and Up at the last, matching the ARIA combobox pattern; the highlight wraps at either end
147
+ - Choosing an already-selected option removes it again (multi select); single select replaces the token and closes
148
+ - In create mode, a term matching no option is offered as `Add "term"` and posts under `createName`
149
+ - Reorder mode disables the dead-end move control on the first and last token and moves focus so it is never lost; a disabled combobox is left alone, so its controls stay disabled
150
+ - Dragging is a pointer enhancement only, advertised by a decorative grip handle - the move buttons are the accessible path, as WCAG 2.2 SC 2.5.7 requires
151
+ - Selections, filter counts, and moves are announced through a local `role="status"` region
152
+ - With `url` set, each keystroke schedules a debounced fetch of `url?term=...`; the previous request is aborted and an overtaken response is dropped, so the list always answers what is currently typed. Leaving the field abandons a search in flight, so nothing spins beside a closed listbox and nothing is announced after focus has moved on. Responses replace the options wholesale, cloned from the `optionTemplate`, and the browser does no filtering of its own
153
+ - Further pages are appended as the end of a remote list is reached - within `LOAD_MORE_MARGIN` of its foot (the `scrolled` action, bound to the listbox), or on Down/End at the last option, so the keyboard is not capped at the first page. Down loads instead of wrapping while pages remain, and highlights the first newly loaded option; option ids run on across pages so `aria-activedescendant` can never point at a reused id
154
+ - The `notice` row carries only durable messages: how far into the matches the list has got, a failed request, and the character threshold. Each appended page is announced through the status region
155
+ - A request in flight shows a spinner rather than a message - `busy` (trailing the input) while searching, `moreSpinner` (in the notice row) while a page loads - after `SPINNER_DELAY`, so a fast response never flashes one. `aria-busy` goes on the listbox immediately, since it costs nothing and says the same thing to a screen reader
156
+
157
+ Use the `l_ui_combobox` helper (see HELPERS.md) rather than writing the markup by hand.
133
158
 
134
159
  ## Panel (`l-ui--panel`)
135
160
 
@@ -202,3 +227,20 @@ When multiple search forms exist on one page (each with a different `scope` valu
202
227
  &lt;%= l_ui_pagy(@users_pagy) %&gt;
203
228
  &lt;% end %&gt;
204
229
  ```
230
+
231
+ ## Scroll hint (`l-ui--scroll-hint`)
232
+
233
+ Fades the clipped edges of a horizontal scroller while more content is available in that direction, so it is obvious the region can be scrolled.
234
+
235
+ **Targets:** `scroller` (the scrollable element)
236
+ **Behaviour:** toggles `l-ui-scroll-hint--left`/`l-ui-scroll-hint--right` on the wrapper from the scroll position; updates on scroll and when the scroller or its content resizes. While the scroller overflows it is given `tabindex="0"` so keyboard users can scroll it (WCAG 2.1.1); the attribute is removed when it fits.
237
+
238
+ `l_ui_table` wires this up automatically. For hand-written tables (or any other horizontal scroller), wrap the scrolling element:
239
+
240
+ ```html
241
+ <div class="l-ui-scroll-hint" data-controller="l-ui--scroll-hint">
242
+ <div class="l-ui-table-container" data-l-ui--scroll-hint-target="scroller">
243
+ <table class="l-ui-table">...</table>
244
+ </div>
245
+ </div>
246
+ ```
@@ -204,6 +204,7 @@ Always combine the `l-ui-surface` base class with any modifiers (e.g. `l-ui-surf
204
204
 
205
205
  ```
206
206
  .l-ui-table Table element
207
+ .l-ui-table--floating-actions Modifier: pins action cells to the right-hand edge of the scroll container during horizontal scrolling (sticky; solid background with a faded leading edge)
207
208
  .l-ui-table__header <thead> row
208
209
  .l-ui-table__header-cell <th> cell
209
210
  .l-ui-table__header-cell--action Right-aligned action header
@@ -217,6 +218,9 @@ Always combine the `l-ui-surface` base class with any modifiers (e.g. `l-ui-surf
217
218
  .l-ui-table__action Action link/button (inside a __cell--action)
218
219
  .l-ui-table__action--danger Danger modifier (combine with l-ui-table__action)
219
220
  .l-ui-table-container Overflow wrapper for responsive tables
221
+ .l-ui-scroll-hint Wrapper around a horizontal scroller (e.g. l-ui-table-container); fades the clipped edge while more content is available (paired with the l-ui--scroll-hint controller)
222
+ .l-ui-scroll-hint--left Fade visible on the left edge (toggled by the controller)
223
+ .l-ui-scroll-hint--right Fade visible on the right edge (toggled by the controller)
220
224
  ```
221
225
 
222
226
  WCAG 2.2 AA table pattern:
@@ -274,14 +278,45 @@ Always combine the base block with a modifier, e.g. `<span class="l-ui-badge l-u
274
278
  Interactive tag - e.g. an email recipient or an active filter. A badge is a static styled span; a tag is a container with separately interactive segments.
275
279
 
276
280
  ```
281
+ .l-ui-tag-row Canonical container for a row of tags plus any trailing actions (e.g. a filter bar); wrapping flex row with gap
277
282
  .l-ui-tag Tag container (div); carries colour and shape but no padding - segments supply it
278
283
  .l-ui-tag--rounded Pill shape (matching l-ui-badge--rounded); default is the subtle badge radius
279
284
  .l-ui-tag__text Static label segment (span)
280
285
  .l-ui-tag__button Interactive label segment (button or a); focus ring + pointer
286
+ .l-ui-tag__label Inner wrapper (span) around a text/button segment's content; truncates long labels with an ellipsis
281
287
  .l-ui-tag__remove Trailing remove segment (button or a) holding a ✕ icon (e.g. l-ui-icon--xs); needs an aria-label
282
288
  ```
283
289
 
284
- A tag takes the same subtle corner radius as a badge by default; add `l-ui-tag--rounded` for a pill. Segments must be direct children in label-then-remove order; when a remove segment follows, the label segment automatically drops its right padding so the remove owns the gap and every part of the tag stays clickable. Prefer the `l_ui_tag` helper (see HELPERS.md), which also wires an optional popover.
290
+ A tag takes the same subtle corner radius as a badge by default; add `l-ui-tag--rounded` for a pill. Segments must be direct children in label-then-remove order; when a remove segment follows, the label segment automatically drops its right padding so the remove owns the gap and every part of the tag stays clickable. A tag never grows past its container; wrap label content in `l-ui-tag__label` so it truncates with an ellipsis instead of overflowing. Prefer the `l_ui_tag` helper (see HELPERS.md), which renders the label wrapper and wires an optional popover.
291
+
292
+ ## Combobox
293
+
294
+ Token select: a text input with type-ahead filtering whose selections become tags. Built on the ARIA combobox pattern (`role="combobox"` input + `role="listbox"` popup).
295
+
296
+ ```
297
+ .l-ui-combobox Wrapper (data-controller="l-ui--combobox"); position context for the listbox
298
+ .l-ui-combobox__control Field-styled wrapping flex row holding the tokens and the input; draws the focus ring via :focus-within
299
+ .l-ui-combobox__control--disabled Dimmed, non-interactive control
300
+ .l-ui-combobox__tokens <ul> of tokens inside the control (collapses when empty)
301
+ .l-ui-combobox__token A selection (<li>); combine with l-ui-tag, whose segments it reuses
302
+ .l-ui-combobox__token--draggable A token that can be dragged (reorder mode, not disabled); takes cursor-grab
303
+ .l-ui-combobox__token--dragging Applied to the token being dragged
304
+ .l-ui-combobox__grip Decorative drag handle at a draggable token's leading edge (aria-hidden)
305
+ .l-ui-combobox__move Move-earlier / move-later control inside a token (reorder mode)
306
+ .l-ui-combobox__input The combobox text input; borderless, grows to fill the control, and carries a token's min-height so the control doesn't grow as the first token lands
307
+ .l-ui-combobox__listbox Absolutely positioned option list (role="listbox"); toggled with the hidden attribute
308
+ .l-ui-combobox__option An option (role="option"); hover takes bg-surface
309
+ .l-ui-combobox__option--active The option pointed at by aria-activedescendant (highlight, not DOM focus); bg-surface-highlighted, so it stays legible under a stray pointer
310
+ .l-ui-combobox__option--create The "Add ..." option offered in create mode
311
+ .l-ui-combobox__option-check Accent-filled circle holding the tick, shown on options with aria-selected="true"; reserved space so options don't shift
312
+ .l-ui-combobox__option-check-icon The tick itself; sized well inside the circle and drawn with a stroke so its weight holds up at that size
313
+ .l-ui-combobox__empty "No matches" message
314
+ .l-ui-combobox__spinner Busy ring shown while a remote request is in flight; keeps its shape without the animation under prefers-reduced-motion
315
+ .l-ui-combobox__busy Wrapper holding the spinner at the control's trailing edge (hidden until a search is slow enough to report)
316
+ .l-ui-combobox__notice Presentational row for what the list has to say about itself - a remote search in flight, a failed one, or a truncated result set. Sticks to the foot of the scrolling list and is ruled off from the options above it; the listbox drops its bottom padding while the notice shows, so nothing scrolls through the gap
317
+ ```
318
+
319
+ The listbox and filtered-out options are hidden with the `hidden` attribute, so `.l-ui-combobox [hidden] { display: none !important }` restores Preflight's rule (these components sit outside `@layer`, which would otherwise beat it). Prefer the `l_ui_combobox` helper (see HELPERS.md), which renders the whole structure with its ARIA wiring.
285
320
 
286
321
  ## Tabs
287
322
 
@@ -197,8 +197,8 @@ Returns a `<th>` element with sort link and ARIA sort attributes.
197
197
 
198
198
  ```ruby
199
199
  l_ui_table(records, columns:, caption: nil, actions: nil,
200
- actions_label: "Actions", query: nil, url: nil,
201
- turbo_frame: nil, row_id: nil)
200
+ actions_label: "Actions", floating_actions: false,
201
+ query: nil, url: nil, turbo_frame: nil, row_id: nil)
202
202
  ```
203
203
 
204
204
  - `records` (ActiveRecord::Relation or Array) - the collection to render
@@ -206,11 +206,14 @@ l_ui_table(records, columns:, caption: nil, actions: nil,
206
206
  - `caption` (String, optional) - visually hidden table caption for accessibility
207
207
  - `actions` (Proc, optional) - receives (record), returns action cell content
208
208
  - `actions_label` (String) - header text for the actions column, default "Actions"
209
+ - `floating_actions` (Boolean) - pin the actions column to the right-hand edge of the scroll container while the table scrolls horizontally (adds `l-ui-table--floating-actions`), default false
209
210
  - `query` (Ransack::Search, optional) - enables sortable column headers
210
211
  - `url` (String, optional) - sort link URL (passed to `l_ui_sort_link`)
211
212
  - `turbo_frame` (String, optional) - turbo frame target for sort links
212
213
  - `row_id` (Proc, optional) - receives (record), returns the `<tr>` id. Defaults to `dom_id(record)` for records that respond to `to_key` (ActiveRecord). Return `nil` to omit the id.
213
214
 
215
+ The rendered table is wrapped in an `l-ui-scroll-hint` element wired to the `l-ui--scroll-hint` controller, which fades the clipped edge when the table overflows horizontally.
216
+
214
217
  Column options:
215
218
  - `attribute` (Symbol) - used for label generation and sort links
216
219
  - `label` (String, optional) - custom header text; defaults to humanised attribute
@@ -325,12 +328,13 @@ Calling `dialog.showModal()` directly is not supported - it bypasses the `l-ui--
325
328
  ## Popover
326
329
 
327
330
  ```ruby
328
- l_ui_popover(id: nil, placement: :bottom, align: :start, container: {}, &block)
331
+ l_ui_popover(id: nil, placement: :bottom, align: :start, open: false, container: {}, &block)
329
332
  ```
330
333
 
331
334
  - `id` (String, optional) - DOM id for the popover element; defaults to an auto-generated id
332
335
  - `placement` (Symbol, optional) - `:top`, `:bottom` (default), `:left`, or `:right`. Flips automatically if it would overflow the viewport
333
336
  - `align` (Symbol, optional) - `:start` (default) flushes the popover's leading edge with the trigger's; `:end` flushes its trailing edge instead. For example, `placement: :bottom, align: :end` hangs the popover down and to the left of a trigger at the right end of a row
337
+ - `open` (Boolean, optional) - when `true`, the popover opens as soon as its Stimulus controller connects, shown and positioned in the same task so it never paints unpositioned. Useful for re-opening a popover after a form submission re-renders the page (e.g. `open: params[:filtering].present?`). Defaults to `false`
334
338
  - `container` (Hash, optional) - extra HTML attributes for the wrapping `<div>` (e.g. `class:`)
335
339
  - `&block` - the block's content is the popover body; call `p.trigger(**options, &block)` inside it to render the trigger button
336
340
 
@@ -385,7 +389,9 @@ Builder methods:
385
389
  - `t.button(**opts, &block)` - button segment (`<button class="l-ui-tag__button">`); opens the tag's popover when one is declared
386
390
  - `t.link(url, **opts, &block)` - link segment, styled like a button segment
387
391
  - `t.remove(url = nil, **opts, &block)` - trailing remove segment (`l-ui-tag__remove`): a link when `url` is given, otherwise a button. Renders a ✕ icon unless the block supplies custom content. Pass `aria: { label: ... }` so it has an accessible name
388
- - `t.popover(id: nil, placement: :bottom, align: :start, &block)` - attaches a popover; the block is the popover body. Options match `l_ui_popover`
392
+ - `t.popover(id: nil, placement: :bottom, align: :start, open: false, &block)` - attaches a popover; the block is the popover body. Options match `l_ui_popover`
393
+
394
+ Text, button, and link segments wrap their content in `<span class="l-ui-tag__label">`, which truncates long labels with an ellipsis when the tag is squeezed (the tag itself never grows past its container).
389
395
 
390
396
  ```erb
391
397
  <%= l_ui_tag do |t| %>
@@ -402,6 +408,87 @@ Builder methods:
402
408
 
403
409
  When a popover is declared, the tag container itself becomes the `l-ui--popover` controller root and placement target - the popover aligns with the whole tag rather than the label button inside it - and button segments are wired to open it via `popovertarget`. A tag without a popover renders no Stimulus wiring.
404
410
 
411
+ ## Combobox
412
+
413
+ ```ruby
414
+ l_ui_combobox(name, collection: nil, form: nil, selected: nil, multiple: true,
415
+ create: false, create_name: nil, reorder: false, url: nil,
416
+ min_chars: 0, text: {}, id: nil, label: nil, hint: nil, placeholder: nil,
417
+ required: false, disabled: false, container: {})
418
+ ```
419
+
420
+ 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.
421
+
422
+ - `name` (String or Symbol) - the parameter, e.g. `"post[tag_ids]"`. A Symbol is resolved against `form:`
423
+ - `collection` (Array) - `["Label", value]` pairs, `{ label:, value: }` hashes, or plain strings. Required unless `url:` is given
424
+ - `form` (FormBuilder, optional) - derives parameter names from the builder for Symbol names
425
+ - `selected` (Array or value, optional) - selected values, or `["Label", value]` pairs / `{ label:, value: }` hashes where the label is not in `collection` (the only form a remote selection can take). A value with no label renders as a created token (requires `create:`)
426
+ - `multiple` (Boolean, default `true`) - multi select; `false` drops the `[]` suffix, replaces the token on choice, and closes the list
427
+ - `create` (Boolean, default `false`) - allow values outside the collection; requires `create_name:`
428
+ - `create_name` (String or Symbol) - parameter created values post under, keeping record IDs and free text unambiguous server-side
429
+ - `reorder` (Boolean, default `false`) - move controls plus mouse dragging (each token gains a decorative grip handle advertising the drag); parameters post in the displayed order
430
+ - `url` (String, optional) - endpoint searched as the user types; options come from it rather than from `collection:`
431
+ - `min_chars` (Integer, default `0`) - characters needed before a remote search runs; `0` searches as soon as the field is focused
432
+ - `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
433
+ - `label`, `hint`, `placeholder`, `required`, `disabled`, `id`, `container` - as for a normal field
434
+
435
+ ```erb
436
+ <%= form_with model: @post do |f| %>
437
+ <%= l_ui_combobox(:tag_ids, form: f,
438
+ label: "Tags",
439
+ collection: Tag.pluck(:name, :id),
440
+ selected: @post.tag_ids,
441
+ create: true,
442
+ create_name: :new_tag_names) %>
443
+ <% end %>
444
+
445
+ <%# post[tag_ids][] => ["", "7"] %>
446
+ <%# post[new_tag_names][] => ["urgent"] %>
447
+ ```
448
+
449
+ Each selection carries its own hidden input, so the control submits with an ordinary form post, and a blank value is always posted first - clearing every token submits an empty collection instead of omitting the parameter and leaving the association untouched. Selections are announced through a local `role="status"` region, and the keyboard help is bound to the input with `aria-describedby`. Reordering exposes move buttons as well as dragging, since WCAG 2.2 SC 2.5.7 requires a single-pointer alternative to a drag.
450
+
451
+ ### Remote options
452
+
453
+ With `url:`, options are fetched from that endpoint as the user types (debounced, with overtaken responses discarded) instead of being rendered up front and filtered in the browser. Matches load a page at a time, the next page appended as the end of the list is reached - by scrolling to its foot or by pressing Down on its last option, so the keyboard is not capped at the first page. A presentational note under the options (`class="l-ui-combobox__notice"`) says how far into the matches the list has got, or that they could not be loaded; a request in flight shows a spinner instead (trailing the field while searching, beside the note while a page loads), after a short delay so a quick answer never flashes one. Remote selections must be passed as `["Label", value]` pairs, since the browser has no collection to look a label up in.
454
+
455
+ ```ruby
456
+ Layered::Ui::ComboboxOptions # Controller concern building the JSON the endpoint answers with
457
+
458
+ l_ui_combobox_options(scope, label:, value: :id, search: nil, predicate: :cont,
459
+ combinator: :or, term: nil, page: nil, limit: 20)
460
+ ```
461
+
462
+ - `scope` (Relation or model class) - what the user is allowed to see; the endpoint stays an ordinary action in the host app, authorised as any index is
463
+ - `label` (Symbol or Proc) - attribute, or callable taking the record, used for the option's label
464
+ - `value` (Symbol or Proc, default `:id`) - attribute, or callable, used for the option's value
465
+ - `search` (Array, defaults to `label`) - attributes the term is matched against; required when `label:` is a callable, since a computed label gives the database nothing to search on. An attribute neither backend can search raises rather than quietly matching everything
466
+ - `predicate` (Symbol, default `:cont`) - Ransack predicate for the match; `:i_cont` ignores case. Without Ransack only `:cont` and `:i_cont` can be built, and any other raises rather than quietly matching on something else
467
+ - `combinator` (Symbol, default `:or`) - how the search attributes combine, `:or` or `:and`; honoured on both paths, and anything else raises
468
+ - `term`, `page` - default to `params[:term]` and `params[:page]`
469
+ - `limit` (Integer, default 20) - options per page
470
+
471
+ Ransack builds the predicate when it is available (so association attributes work) and Pagy takes the page; there is a plain `LIKE` plus `limit`/`offset` fallback, so neither gem is required. An unordered scope is ordered by the label so pages do not overlap.
472
+
473
+ ```erb
474
+ <%= l_ui_combobox(:author_id, form: f, multiple: false,
475
+ url: options_users_path, min_chars: 2,
476
+ selected: [[@post.author.name, @post.author_id]]) %>
477
+ ```
478
+
479
+ ```ruby
480
+ class UsersController < ApplicationController
481
+ include Layered::Ui::ComboboxOptions
482
+
483
+ def options
484
+ render json: l_ui_combobox_options(policy_scope(User), label: :name, search: [:name, :email])
485
+ end
486
+ end
487
+
488
+ # GET /users/options?term=ali&page=1
489
+ # { "options": [{ "label": "Alice Johnson", "value": "2" }], "page": 1, "pages": 1, "count": 1 }
490
+ ```
491
+
405
492
  ## Header
406
493
 
407
494
  ```ruby
@@ -424,16 +511,20 @@ Use these when overriding the header actions group with `:l_ui_header_actions` t
424
511
  ## Authentication
425
512
 
426
513
  ```ruby
427
- l_ui_user_signed_in? # Returns true if current user is present
428
- l_ui_current_user # Returns the current user object
429
- l_ui_devise_installed? # Returns true if Devise is loaded
514
+ l_ui_user_signed_in? # Returns true if current user is present
515
+ l_ui_current_user # Returns the current user object
516
+ l_ui_devise_installed? # Returns true if Devise is loaded
517
+ l_ui_new_registration_path # Devise registration path for the configured scope, or nil
518
+ l_ui_new_session_path # Devise sign-in path for the configured scope, or nil
519
+ l_ui_destroy_session_path # Devise sign-out path for the configured scope, or nil
430
520
  ```
431
521
 
432
- Configure the current user method:
522
+ Configure the current user method, and the Devise scope used to build the login/register/logout paths (`new_registration_path`, `new_session_path`, `destroy_session_path`):
433
523
 
434
524
  ```ruby
435
525
  # config/initializers/layered_ui.rb
436
526
  Layered::Ui.current_user_method = :current_member
527
+ Layered::Ui.devise_scope = :member
437
528
  ```
438
529
 
439
530
  ## Shared partials
data/CHANGELOG.md CHANGED
@@ -2,6 +2,26 @@
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.23.0] - 2026-08-22
6
+
7
+ ### Added
8
+
9
+ - Combobox: a token select (`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 (a `role="combobox"` input owning a `role="listbox"` popup, with the highlight following `aria-activedescendant` so focus stays in the input), with no third-party select library. The `l_ui_combobox` helper renders the whole control and its ARIA wiring; the new `l-ui--combobox` Stimulus controller drives it. Selections carry their own hidden inputs, so it submits with an ordinary form post.
10
+ - Combobox options: `multiple: false` for a one-of-many control; `create:` (with `create_name:`) to add a term that matches no option, posting new values under their own parameter so the server never has to guess whether a value is a record's ID or free text; `reorder:` for move controls and mouse dragging, the buttons being the accessible path WCAG 2.2 SC 2.5.7 requires; and `text:` to reword or translate every string the control shows, using Rails' own `%{name}` placeholders.
11
+ - Combobox remote options: pass `url:` and the options are fetched from that endpoint as the user types, for collections too large to send with the page. Requests are debounced and an overtaken response is discarded, `min_chars:` sets how much has to be typed first, and matches load a page at a time - reaching the end of the list appends the next page, by scrolling to its foot or by pressing Down on its last option, so the keyboard is not capped at the first page. A note under the options says how far into the matches the list has got; a request in flight shows a spinner after a short delay, so a quick answer never flashes one.
12
+ - `Layered::Ui::ComboboxOptions`: a controller concern building the JSON a remote combobox expects, so the endpoint is a couple of lines. `l_ui_combobox_options(scope, label:, search:)` reads `term` and `page` from the query string and answers with `{ options: [{ label:, value: }], page:, pages:, count: }`, searching with Ransack and paging with Pagy when they are available and falling back to a plain `LIKE` with `limit`/`offset` when they are not. Endpoints stay ordinary actions in the host app, scoped and authorised as any index is.
13
+ - `Layered::Ui.devise_scope`: the Devise mapping the header and sidebar link to, so an app that scopes authentication to something other than `:user` (`devise_for :members`) gets the right registration, session and logout paths. Defaults to `:user`.
14
+
15
+ ## [0.22.0] - 2026-07-10
16
+
17
+ ### Added
18
+
19
+ - Floating table actions: pass `floating_actions: true` to `l_ui_table` (or add `l-ui-table--floating-actions` to a hand-written table) to pin the actions column to the right-hand edge of the scroll container, so row actions stay visible while the table scrolls horizontally. The pinned cells sit on a solid background with a faded leading edge, and the container gains scroll padding so focused cells scroll clear of the pinned column.
20
+ - Scroll hint: the new `l-ui--scroll-hint` Stimulus controller and `l-ui-scroll-hint` wrapper fade the clipped edges of a horizontal scroller while more content is available in that direction. While the scroller overflows it is made keyboard-focusable so the region stays keyboard-scrollable. `l_ui_table` wraps every table in a scroll hint automatically; wrap hand-written tables (or any other horizontal scroller) yourself.
21
+ - Popover `open:` option: `l_ui_popover(open: true)` (and `t.popover(open: true)` on a tag) opens the popover as soon as its Stimulus controller connects, shown and positioned in the same task so it never paints unpositioned - useful for re-opening a filter popover after a form submission re-renders the page.
22
+ - `l-ui-tag-row`: the canonical container for a row of tags plus any trailing actions (e.g. a filter bar) - a wrapping flex row with a gap, so tags move onto new lines on narrow screens instead of overflowing.
23
+ - Tag truncation: a tag never grows past its container, and label segments wrap their content in the new `l-ui-tag__label` so long labels truncate with an ellipsis instead of overflowing. The `l_ui_tag` helper renders the wrapper automatically; add it yourself in hand-written markup.
24
+
5
25
  ## [0.21.0] - 2026-07-09
6
26
 
7
27
  ### Added
data/README.md CHANGED
@@ -31,12 +31,33 @@ An open source, Rails 8+ engine that provides WCAG 2.2 AA compliant design token
31
31
  </tr>
32
32
  </table>
33
33
 
34
+ ## Quick start
35
+
36
+ The fastest way to get going is [layered-foundation-rails](https://github.com/layered-ai-public/layered-foundation-rails) - an accessible Rails starter app with `layered-ui-rails` pre-installed and configured. It also includes additional skills and scripts to get you on your way.
37
+
38
+ Clone the complete starter app:
39
+
40
+ ```bash
41
+ git clone https://github.com/layered-ai-public/layered-foundation-rails.git myapp
42
+ cd myapp
43
+ bin/rails layered:foundation:setup
44
+ ```
45
+
46
+ Or apply it as a Rails template:
47
+
48
+ ```bash
49
+ rails new myapp --css tailwind \
50
+ -m https://raw.githubusercontent.com/layered-ai-public/layered-foundation-rails/main/template.rb
51
+ ```
52
+
53
+ Adding `layered-ui-rails` to an existing app? See [Installation](#installation).
54
+
34
55
  ## Features
35
56
 
36
57
  - **Dark/light theme** - system preference detection with localStorage persistence and manual toggle
37
58
  - **Responsive layout** - header, sidebar navigation, main content area, and optional resizable panel
38
59
  - **WCAG 2.2 AA compliant** - skip links, focus indicators, ARIA attributes, and 4.5:1 contrast ratios
39
- - **Components** - buttons, forms, surfaces, tables, tabs, notices, badges, tags, conversations, modals, popovers, and pagination
60
+ - **Components** - buttons, forms, comboboxes, surfaces, tables, tabs, notices, badges, tags, conversations, modals, popovers, and pagination
40
61
  - **Optional integrations** - Devise authentication and Pagy pagination with styled views
41
62
  - **Customisable branding** - Override the default logos and icons and colors
42
63
  - **Google Lighthouse** - `layered-ui-rails` scores a [perfect 100](https://github.com/layered-ai-public/layered-ui-rails/raw/refs/heads/main/test/dummy/app/assets/images/lighthouse.webp) across all four Google Lighthouse categories - performance, accessibility, best practices, and SEO
@@ -180,6 +201,24 @@ For per-request icons, set instance variables - the engine renders `<link>` and
180
201
 
181
202
  > **Security:** Rails HTML-escapes URL values, so XSS via attribute injection is mitigated. However, if values are tenant-controlled, validate that they are legitimate URLs - reject `javascript:` schemes and ensure values point to expected origins.
182
203
 
204
+ ## Authentication
205
+
206
+ The engine reads the signed-in user through `l_ui_current_user`, which delegates to your app's `current_user` helper by default. If your app uses a different helper (e.g. a Devise `Member` model, or Rails' built-in authentication generator with a custom name), configure it in an initializer:
207
+
208
+ ```ruby
209
+ # config/initializers/layered_ui.rb
210
+ Layered::Ui.current_user_method = :current_member
211
+ ```
212
+
213
+ If your Devise mapping isn't `:user` (e.g. `devise_for :members`), also set the scope so the header and sidebar link to the right registration, session, and logout paths:
214
+
215
+ ```ruby
216
+ # config/initializers/layered_ui.rb
217
+ Layered::Ui.devise_scope = :member
218
+ ```
219
+
220
+ Apps without authentication need no configuration - the user section of the navigation simply doesn't render. The sidebar shows the user's `name` and `email` when the model responds to those methods. Only one method is supported, so apps with multiple Devise scopes should pick the one to display.
221
+
183
222
  ## Documentation
184
223
 
185
224
  An online version of the documentation is available at **[layered-ui-rails.layered.ai](https://layered-ui-rails.layered.ai)**.