layered-ui-rails 0.22.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 +4 -4
- data/.claude/skills/layered-ui-rails/SKILL.md +2 -1
- data/.claude/skills/layered-ui-rails/references/CONTROLLERS.md +24 -0
- data/.claude/skills/layered-ui-rails/references/CSS.md +29 -0
- data/.claude/skills/layered-ui-rails/references/HELPERS.md +89 -4
- data/CHANGELOG.md +10 -0
- data/README.md +40 -1
- data/app/assets/tailwind/layered_ui/engine.css +219 -0
- data/app/controllers/concerns/layered/ui/combobox_options.rb +199 -0
- data/app/helpers/layered/ui/authentication_helper.rb +19 -0
- data/app/helpers/layered/ui/combobox_helper.rb +569 -0
- data/app/javascript/layered_ui/controllers/l_ui/combobox_controller.js +815 -0
- data/app/javascript/layered_ui/index.js +2 -0
- data/app/views/layouts/layered_ui/_authentication.html.erb +4 -4
- data/app/views/layouts/layered_ui/_navigation.html.erb +2 -2
- data/config/importmap.rb +1 -0
- data/lib/layered/ui/engine.rb +1 -0
- data/lib/layered/ui/version.rb +1 -1
- data/lib/layered/ui.rb +1 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2346c306d3b5e355c1f61a51254d79701fe3a9ad2e8bc5817aa7311b0e024db0
|
|
4
|
+
data.tar.gz: b01fdcd1dca34dd339ea4183933b5b6fcded78d3a2ee2c4c371ed92cd18e3214
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
@@ -132,6 +132,30 @@ Features:
|
|
|
132
132
|
- Flips to the opposite side if the preferred placement would overflow the viewport
|
|
133
133
|
- `data-l-ui--popover-open-value="true"` opens the popover on connect without a flash of unpositioned content
|
|
134
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.
|
|
158
|
+
|
|
135
159
|
## Panel (`l-ui--panel`)
|
|
136
160
|
|
|
137
161
|
Resizable side panel. Full-width overlay on mobile, docked sidebar on desktop.
|
|
@@ -289,6 +289,35 @@ Interactive tag - e.g. an email recipient or an active filter. A badge is a stat
|
|
|
289
289
|
|
|
290
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
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.
|
|
320
|
+
|
|
292
321
|
## Tabs
|
|
293
322
|
|
|
294
323
|
```
|
|
@@ -408,6 +408,87 @@ Text, button, and link segments wrap their content in `<span class="l-ui-tag__la
|
|
|
408
408
|
|
|
409
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.
|
|
410
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
|
+
|
|
411
492
|
## Header
|
|
412
493
|
|
|
413
494
|
```ruby
|
|
@@ -430,16 +511,20 @@ Use these when overriding the header actions group with `:l_ui_header_actions` t
|
|
|
430
511
|
## Authentication
|
|
431
512
|
|
|
432
513
|
```ruby
|
|
433
|
-
l_ui_user_signed_in?
|
|
434
|
-
l_ui_current_user
|
|
435
|
-
l_ui_devise_installed?
|
|
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
|
|
436
520
|
```
|
|
437
521
|
|
|
438
|
-
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`):
|
|
439
523
|
|
|
440
524
|
```ruby
|
|
441
525
|
# config/initializers/layered_ui.rb
|
|
442
526
|
Layered::Ui.current_user_method = :current_member
|
|
527
|
+
Layered::Ui.devise_scope = :member
|
|
443
528
|
```
|
|
444
529
|
|
|
445
530
|
## Shared partials
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
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
|
+
|
|
5
15
|
## [0.22.0] - 2026-07-10
|
|
6
16
|
|
|
7
17
|
### 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)**.
|
|
@@ -1986,6 +1986,225 @@ pre.l-ui-surface {
|
|
|
1986
1986
|
cursor-pointer;
|
|
1987
1987
|
}
|
|
1988
1988
|
|
|
1989
|
+
/* Combobox */
|
|
1990
|
+
|
|
1991
|
+
/* A token select: a text input with type-ahead filtering whose selections
|
|
1992
|
+
become tags. The control is styled to read as a single form field, so it
|
|
1993
|
+
borrows the field's border, radius and metrics while laying its tokens and
|
|
1994
|
+
input out as a wrapping flex row. */
|
|
1995
|
+
|
|
1996
|
+
.l-ui-combobox {
|
|
1997
|
+
@apply relative
|
|
1998
|
+
w-full;
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
/* The listbox and its filtered-out options are hidden with the `hidden`
|
|
2002
|
+
attribute rather than a class, so their `display` must not be set by the
|
|
2003
|
+
rules below - these components sit outside `@layer`, which would otherwise
|
|
2004
|
+
beat Preflight's `[hidden]` rule. */
|
|
2005
|
+
.l-ui-combobox [hidden] {
|
|
2006
|
+
display: none !important;
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
.l-ui-combobox__control {
|
|
2010
|
+
@apply flex flex-wrap items-center
|
|
2011
|
+
w-full min-h-[44px]
|
|
2012
|
+
mt-2 p-1.5 gap-1.5
|
|
2013
|
+
/* text-base at mobile; text-sm at md+ */
|
|
2014
|
+
text-base font-inter text-foreground
|
|
2015
|
+
bg-background
|
|
2016
|
+
border border-border-control rounded-sm
|
|
2017
|
+
cursor-text;
|
|
2018
|
+
|
|
2019
|
+
@media (min-width: 768px) {
|
|
2020
|
+
@apply text-sm;
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
/* Focus lives on the input inside the control, so the ring is drawn on the
|
|
2025
|
+
control to match the outline of a plain form field. */
|
|
2026
|
+
.l-ui-combobox__control:focus-within {
|
|
2027
|
+
@apply ring-2 ring-ring ring-offset-2 ring-offset-background outline-none;
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
.field_with_errors .l-ui-combobox__control {
|
|
2031
|
+
@apply bg-error-bg/10
|
|
2032
|
+
border-2 border-danger;
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
.l-ui-combobox__control--disabled {
|
|
2036
|
+
@apply opacity-60
|
|
2037
|
+
cursor-default;
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
.l-ui-combobox__tokens {
|
|
2041
|
+
@apply flex flex-wrap items-center
|
|
2042
|
+
gap-1.5;
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
/* Without this the control's gap would leave a blank inset before the input
|
|
2046
|
+
while nothing is selected. */
|
|
2047
|
+
.l-ui-combobox__tokens:empty {
|
|
2048
|
+
@apply hidden;
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
.l-ui-combobox__token {
|
|
2052
|
+
@apply cursor-default;
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
/* The grab cursor covers the label; the controls inside keep their own
|
|
2056
|
+
pointer, so each part of the token still reads as what it does. */
|
|
2057
|
+
.l-ui-combobox__token--draggable {
|
|
2058
|
+
@apply cursor-grab;
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
.l-ui-combobox__token--dragging {
|
|
2062
|
+
@apply opacity-50
|
|
2063
|
+
cursor-grabbing;
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
/* Decorative drag handle. Sits at the leading edge, where the convention puts
|
|
2067
|
+
it, and takes the label segment's left padding so the token doesn't widen. */
|
|
2068
|
+
.l-ui-combobox__grip {
|
|
2069
|
+
@apply flex shrink-0 items-center
|
|
2070
|
+
pl-2
|
|
2071
|
+
text-foreground-muted;
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
.l-ui-combobox__grip + .l-ui-tag__text {
|
|
2075
|
+
@apply pl-1.5;
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
.l-ui-combobox__move {
|
|
2079
|
+
@apply px-1;
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
.l-ui-combobox__move:disabled {
|
|
2083
|
+
@apply opacity-40
|
|
2084
|
+
cursor-default;
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
/* The min-height matches a token's (l-ui-tag), so the control is the same
|
|
2088
|
+
height whether or not anything is selected - without it the shorter line box
|
|
2089
|
+
of an empty input makes the field grow as the first token lands. */
|
|
2090
|
+
.l-ui-combobox__input {
|
|
2091
|
+
@apply flex-1
|
|
2092
|
+
min-w-[8rem] min-h-8
|
|
2093
|
+
px-1.5 py-1
|
|
2094
|
+
text-inherit
|
|
2095
|
+
bg-transparent
|
|
2096
|
+
border-0
|
|
2097
|
+
outline-none;
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
.l-ui-combobox__listbox {
|
|
2101
|
+
@apply absolute right-0 left-0 z-30
|
|
2102
|
+
max-h-64
|
|
2103
|
+
mt-1 p-1
|
|
2104
|
+
overflow-y-auto
|
|
2105
|
+
bg-background
|
|
2106
|
+
border border-border rounded-sm
|
|
2107
|
+
shadow-lg;
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
.l-ui-combobox__option {
|
|
2111
|
+
@apply flex items-center justify-between
|
|
2112
|
+
gap-2 px-3 py-2.5
|
|
2113
|
+
text-sm font-inter text-foreground
|
|
2114
|
+
hover:bg-surface
|
|
2115
|
+
rounded-sm
|
|
2116
|
+
transition-colors
|
|
2117
|
+
cursor-pointer;
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
/* The highlighted option follows aria-activedescendant, not DOM focus, which
|
|
2121
|
+
stays in the input while the list is browsed. It takes the stronger of the
|
|
2122
|
+
two surfaces so a keyboard highlight stays legible under a stray pointer. */
|
|
2123
|
+
.l-ui-combobox__option--active {
|
|
2124
|
+
@apply bg-surface-highlighted;
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
/* Reserved rather than removed, so options don't shift as they are chosen.
|
|
2128
|
+
The accent pair is used both ways round, so the tick keeps its contrast in
|
|
2129
|
+
either theme. */
|
|
2130
|
+
.l-ui-combobox__option-check {
|
|
2131
|
+
@apply flex shrink-0 items-center justify-center
|
|
2132
|
+
w-5 h-5
|
|
2133
|
+
text-accent-foreground
|
|
2134
|
+
bg-accent
|
|
2135
|
+
rounded-full
|
|
2136
|
+
invisible;
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
/* Sized well inside the circle so the tick has room to breathe. It is drawn as
|
|
2140
|
+
a stroked path rather than a filled one, so the weight stays readable at
|
|
2141
|
+
this size - see the stroke-width on the icon itself. */
|
|
2142
|
+
.l-ui-combobox__option-check-icon {
|
|
2143
|
+
@apply w-2.5 h-2.5;
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
.l-ui-combobox__option[aria-selected="true"] .l-ui-combobox__option-check {
|
|
2147
|
+
@apply visible;
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
.l-ui-combobox__option--create {
|
|
2151
|
+
@apply font-medium;
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
.l-ui-combobox__empty {
|
|
2155
|
+
@apply px-3 py-2.5
|
|
2156
|
+
text-sm font-inter text-foreground-muted;
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
/* Stands in for a "searching" message: a request in flight is a state, and a
|
|
2160
|
+
message that comes and goes within a keystroke cannot be read. The ring keeps
|
|
2161
|
+
its shape without the animation, so it still reads as busy where motion is
|
|
2162
|
+
turned down. */
|
|
2163
|
+
.l-ui-combobox__spinner {
|
|
2164
|
+
@apply block shrink-0
|
|
2165
|
+
w-4 h-4
|
|
2166
|
+
border-2 border-border border-t-foreground-muted rounded-full
|
|
2167
|
+
animate-spin
|
|
2168
|
+
motion-reduce:animate-none;
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
/* Sits at the trailing edge of the control, where a search field puts its
|
|
2172
|
+
progress. */
|
|
2173
|
+
.l-ui-combobox__busy {
|
|
2174
|
+
@apply flex shrink-0 items-center
|
|
2175
|
+
pr-1.5;
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
/* What the list has to say about itself - a search in flight, a failure, or a
|
|
2179
|
+
truncated result set. Sticks to the foot of the scrolling list, since a note
|
|
2180
|
+
saying the results were cut short is no use below the fold, and is set apart
|
|
2181
|
+
from the options above it because it is not something that can be chosen. */
|
|
2182
|
+
.l-ui-combobox__notice {
|
|
2183
|
+
@apply sticky bottom-0 flex items-center
|
|
2184
|
+
gap-2 px-3 py-2
|
|
2185
|
+
text-xs font-inter text-foreground-muted
|
|
2186
|
+
bg-background;
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
/* Smaller than the control's, so a page being fetched reads as a detail of the
|
|
2190
|
+
list rather than competing with it. */
|
|
2191
|
+
.l-ui-combobox__notice .l-ui-combobox__spinner {
|
|
2192
|
+
@apply w-3 h-3;
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
/* The list's own bottom padding would sit below the sticky notice, leaving a gap
|
|
2196
|
+
for the options to scroll through, so it is dropped while the notice is
|
|
2197
|
+
showing - the notice brings its own padding. */
|
|
2198
|
+
.l-ui-combobox__listbox:has(.l-ui-combobox__notice:not([hidden])) {
|
|
2199
|
+
@apply pb-0;
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
/* The rule only earns its place once there is a list above it to separate. */
|
|
2203
|
+
.l-ui-combobox__option ~ .l-ui-combobox__notice {
|
|
2204
|
+
@apply mt-1 pt-2
|
|
2205
|
+
border-t border-border;
|
|
2206
|
+
}
|
|
2207
|
+
|
|
1989
2208
|
/* Panel */
|
|
1990
2209
|
|
|
1991
2210
|
.l-ui-panel__button {
|