layered-ui-rails 0.23.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2346c306d3b5e355c1f61a51254d79701fe3a9ad2e8bc5817aa7311b0e024db0
4
- data.tar.gz: b01fdcd1dca34dd339ea4183933b5b6fcded78d3a2ee2c4c371ed92cd18e3214
3
+ metadata.gz: d892f7efaed58d161179cc5478eaf0961b4b31c2e822aaba4063143343aeac31
4
+ data.tar.gz: 28e37164714d1a354b255a10a3833069b94f6202480082b29e70fcc23a485ffa
5
5
  SHA512:
6
- metadata.gz: 29160e5b7d0dea492dd8cb2591b3797a564e2bef6bdabe7fa355ad79cde4dc516cf8537eef1c6298852a22dacf74126b5405bdbcd6fe63dd358adc37779e693e
7
- data.tar.gz: 8ef704e93632b9f1d60eb4f521f82c7b55e072ef507cea2267fbccdd6ecd76980e43bf6720c3ce0449df38dcfc3753d970167cf5a6b80af85cb868fb5a1169e1
6
+ metadata.gz: d2d3c833c03dd9c56d88bf578fb69f27ddf0eda03baf31ebb0953e6de0bb9b5ae1248d444e77c362d18e4288aad6adbae28a4959f95c68a392b373417c23040d
7
+ data.tar.gz: 661964c07f3d03e1d4ee937a9f4b3f3d456491fadb9ee67778cee063d9eda535fbf02a276bcfe1a3c0a1b67579ece03afda66a5e1d98c90ebe6b232de6c05731
@@ -240,7 +240,7 @@ Override CSS custom properties after the engine import. Values are full CSS colo
240
240
  }
241
241
  ```
242
242
 
243
- Key tokens: `--accent`, `--accent-foreground`, `--background`, `--foreground`, `--foreground-muted`, `--border`, `--border-control`, `--surface`, `--surface-highlighted`, `--danger`, `--header-height`.
243
+ Key tokens: `--accent`, `--accent-foreground`, `--background`, `--foreground`, `--foreground-muted`, `--border`, `--border-control`, `--surface`, `--surface-highlighted`, `--danger`, `--header-height`, `--l-ui-navigation-width`.
244
244
 
245
245
  ## Asset overrides
246
246
 
@@ -250,9 +250,17 @@ Place files in `app/assets/images/layered_ui/` to replace engine defaults:
250
250
 
251
251
  The panel toggle button uses an inline SVG that inherits `currentColor`. Recolor it by overriding the `--button-primary-icon` Tier 2 token, or replace the image by setting both `@l_ui_panel_icon_light_url` and `@l_ui_panel_icon_dark_url` (per-request).
252
252
 
253
+ ## Ejecting a view
254
+
255
+ Any engine view can be replaced by copying it to the same path in the host application - `app/views/devise/registrations/edit.html.erb` overrides the Settings screen, `app/views/layouts/layered_ui/_navigation.html.erb` the sidebar, and so on. Application views resolve before engine views, so no configuration is needed.
256
+
257
+ Find the original to copy with `bundle info layered-ui-rails --path`. Unlike engine views, an ejected view lives in the host application, so it may use plain Tailwind utilities alongside the `l-ui-*` classes - the host's Tailwind build scans it.
258
+
259
+ Note that `bin/rails generate devise:views` copies Devise's own unstyled views into `app/views/devise/`, which overrides *every* styled view the engine provides, not just the one being changed. Eject the individual views instead.
260
+
253
261
  ## Optional integrations
254
262
 
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?`.
263
+ - **Devise** - auto-detected. Provides styled auth views (including an account settings screen at `registrations#edit`), header login/register buttons, and sidebar user info with a Settings/Logout menu. 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
264
  - **Pagy** - auto-detected. Use `l_ui_pagy(@pagy)` for styled pagination.
257
265
  - **Ransack** - auto-detected. Use `l_ui_search_form` and `l_ui_sort_link` for styled search and sortable tables.
258
266
 
@@ -398,6 +398,9 @@ The listbox and filtered-out options are hidden with the `hidden` attribute, so
398
398
  .l-ui-navigation__section-chevron Toggle chevron (rotates when closed)
399
399
  .l-ui-navigation__section-items Section body (ul, hidden when closed)
400
400
  .l-ui-navigation__user User info section
401
+ .l-ui-navigation__user-button Unpadded full-width account menu trigger (pair with .l-ui-button)
402
+ .l-ui-navigation__user-chevron Upward chevron inside the account menu trigger
403
+ .l-ui-navigation__user-popover Account menu popover sized to the sidebar's inner column
401
404
  .l-ui-navigation__user-name User name text
402
405
  .l-ui-navigation__user-email User email text
403
406
  ```
@@ -488,6 +491,7 @@ Always combine the base block with one size modifier (e.g. `class="l-ui-icon l-u
488
491
  ```
489
492
  .l-ui-mt-0/2/3/4/6/8 Margin top (fixed scale)
490
493
  .l-ui-mb-0 Margin bottom zero
494
+ .l-ui-mb-4 Margin bottom (1rem)
491
495
  .l-ui-sr-only Visually hidden, screen reader only
492
496
  .l-ui-skip-link Accessibility skip link
493
497
  .l-ui-list Styled list
@@ -533,6 +537,7 @@ Tier 2 - Full palette (override individually as needed):
533
537
  --error-text Error text
534
538
  --header-height Header height (default 63px)
535
539
  --l-ui-gutter Page/header horizontal + bottom padding (default 1rem)
540
+ --l-ui-navigation-width Sidebar navigation width (default 256px)
536
541
  --l-ui-contained-width Max width of contained content - header + .l-ui-page__contained (default 80rem)
537
542
  ```
538
543
 
@@ -257,14 +257,21 @@ Renders a complete form with all fields, error summary, and submit button via th
257
257
 
258
258
  Field options:
259
259
  - `attribute` (Symbol) - model attribute
260
- - `as` (Symbol, optional) - field type; auto-detected from column type. Supported: `:string`, `:text`, `:email`, `:password`, `:number`, `:tel`, `:url`, `:search`, `:date`, `:datetime`, `:time`, `:month`, `:week`, `:color`, `:range`, `:file`, `:select`, `:checkbox`, `:hidden`. Forms automatically become `multipart` when any field is `:file`.
260
+ - `as` (Symbol, optional) - field type; auto-detected from column type. Supported: `:string`, `:text`, `:email`, `:password`, `:number`, `:tel`, `:url`, `:search`, `:date`, `:datetime`, `:time`, `:month`, `:week`, `:color`, `:range`, `:file`, `:select`, `:combobox`, `:checkbox`, `:hidden`. Forms automatically become `multipart` when any field is `:file`.
261
261
  - `label` (String, optional) - custom label text; defaults to humanised attribute
262
262
  - `required` (Boolean, optional) - marks field as required; default false
263
263
  - `hint` (String, optional) - help text below the field
264
- - `collection` (Array, optional) - required for `:select` type; e.g. `[['Label', value], ...]`
264
+ - `collection` (Array, optional) - required for `:select`; for `:combobox` either this or `url:` is required; e.g. `[['Label', value], ...]`
265
265
  - `include_blank` (Boolean or String, optional) - for `:select` fields; defaults to `true`. Pass a string to use as the blank option's label, or `false` to omit it. Suppressed when `prompt:` is set
266
266
  - `prompt` (String, optional) - for `:select` fields; prompt text shown as the first option, only selectable when no value is set
267
267
  - `placeholder` (String, optional) - input placeholder text
268
+ - any other key passes through to the underlying field helper as an HTML attribute (`:combobox` excepted - see below)
269
+
270
+ A `:combobox` field renders its own label and hint, adds the field's error element to its `aria-describedby`, and defaults `selected:` to the record's current value. That, and its keyword-only signature, give three rules:
271
+
272
+ - It takes only `l_ui_combobox`'s own options - `url:`, `multiple:`, `create:`, `create_name:`, `reorder:`, `min_chars:`, `text:`, `selected:`, `disabled:`, `describedby:`, `container:`, `id:`. Every other key raises, `prompt:` and `include_blank:` included; extra HTML attributes go on `container:`.
273
+ - `multiple:` is inferred from the attribute rather than defaulting to `l_ui_combobox`'s `true`: an `_ids` attribute, an array column, or one already holding an array is multiple; anything else is single, so a scalar attribute posts a scalar rather than an array Active Record would cast to `nil`. Pass `multiple:` explicitly to override.
274
+ - Pass `selected: [[@post.user.name, @post.user_id]]` whenever the record's value might not be in the options - a `url:` collection never holds it, and nor does a scoped, paginated or filtered `collection:`. Without it, the field raises on an edit form.
268
275
 
269
276
  ```erb
270
277
  <%= l_ui_form(@post,
@@ -273,6 +280,8 @@ Field options:
273
280
  { attribute: :body, as: :text },
274
281
  { attribute: :category, as: :select, collection: Category.pluck(:name, :id) },
275
282
  { attribute: :published, as: :checkbox },
283
+ { attribute: :tag_ids, as: :combobox, collection: Tag.pluck(:name, :id) },
284
+ { attribute: :author_id, as: :combobox, url: options_users_path },
276
285
  ],
277
286
  url: posts_path) %>
278
287
  ```
@@ -283,6 +292,8 @@ Field options:
283
292
  l_ui_normalise_field(record, config) # Normalise a raw field config into canonical form
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
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
286
297
  ```
287
298
 
288
299
  ## Modal
@@ -414,7 +425,7 @@ When a popover is declared, the tag container itself becomes the `l-ui--popover`
414
425
  l_ui_combobox(name, collection: nil, form: nil, selected: nil, multiple: true,
415
426
  create: false, create_name: nil, reorder: false, url: nil,
416
427
  min_chars: 0, text: {}, id: nil, label: nil, hint: nil, placeholder: nil,
417
- required: false, disabled: false, container: {})
428
+ required: false, disabled: false, describedby: nil, container: {})
418
429
  ```
419
430
 
420
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.
@@ -430,8 +441,11 @@ Renders a token select (`class="l-ui-combobox"`): a text input with type-ahead f
430
441
  - `url` (String, optional) - endpoint searched as the user types; options come from it rather than from `collection:`
431
442
  - `min_chars` (Integer, default `0`) - characters needed before a remote search runs; `0` searches as soon as the field is focused
432
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)
433
445
  - `label`, `hint`, `placeholder`, `required`, `disabled`, `id`, `container` - as for a normal field
434
446
 
447
+ Also available as a field type in `l_ui_form`: `{ attribute: :tag_ids, as: :combobox, collection: ... }`.
448
+
435
449
  ```erb
436
450
  <%= form_with model: @post do |f| %>
437
451
  <%= l_ui_combobox(:tag_ids, form: f,
@@ -514,12 +528,14 @@ Use these when overriding the header actions group with `:l_ui_header_actions` t
514
528
  l_ui_user_signed_in? # Returns true if current user is present
515
529
  l_ui_current_user # Returns the current user object
516
530
  l_ui_devise_installed? # Returns true if Devise is loaded
531
+ l_ui_settings_screen? # Returns true on the Settings screen (Devise registrations#edit)
517
532
  l_ui_new_registration_path # Devise registration path for the configured scope, or nil
533
+ l_ui_edit_registration_path # Devise account settings path for the configured scope, or nil
518
534
  l_ui_new_session_path # Devise sign-in path for the configured scope, or nil
519
535
  l_ui_destroy_session_path # Devise sign-out path for the configured scope, or nil
520
536
  ```
521
537
 
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`):
538
+ Configure the current user method, and the Devise scope used to build the login/register/settings/logout paths (`new_registration_path`, `edit_registration_path`, `new_session_path`, `destroy_session_path`):
523
539
 
524
540
  ```ruby
525
541
  # config/initializers/layered_ui.rb
data/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
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
+
12
+ ## [0.24.0] - 2026-08-22
13
+
14
+ ### Added
15
+
16
+ - Devise account settings: Devise's `registrations#edit` is styled as a Settings screen at `/users/edit`, with the account details under an "Account" tab. A signed-in user can change their email (and their name, if the model has one), set a new password, or cancel their account - all of it confirmed with the current password, as Devise expects. If your user model has a `name` attribute, permit it for `:account_update` as well as `:sign_up`. Link to the screen with `l_ui_edit_registration_path`, and use `l_ui_settings_screen?` to keep application chrome that a layout otherwise strips from Devise views.
17
+ - Sidebar account menu: the signed-in user's name and email in the sidebar are now a menu button opening a popover holding Settings and Logout, replacing the standalone logout button. The menu is sized to the sidebar's inner column so it lines up with the name and email, and only the entries whose Devise routes exist are rendered.
18
+ - Ejecting a view: any engine view can be replaced by copying it to the matching path in your application - `app/views/devise/registrations/edit.html.erb` takes over the Settings screen, `app/views/layouts/layered_ui/_navigation.html.erb` the sidebar - with no configuration. The engine's views are still prepended so they beat Devise's own, but your `app/views` is prepended back on top, restoring the precedence Rails gives you by default. Note that `bin/rails generate devise:views` generates *every* Devise view, so it replaces the whole styled set rather than the one you meant to change. See `UPGRADING.md`.
19
+ - `--l-ui-navigation-width`: the sidebar navigation width (default 256px), previously hard-coded, now overridable alongside the other layout tokens.
20
+
21
+ ### Fixed
22
+
23
+ - Devise form fields are scoped to the mapping rather than the model, so an app whose mapping name differs from its model (`devise_for :members, class_name: "User"`) submits parameters under the name Devise looks for. Previously the fields were named after the model, and nothing the user typed reached the controller.
24
+ - Field hints on the Devise views carry an id and are referenced by their input's `aria-describedby` (assembled by the new `l_ui_field_describedby` helper), so a screen reader announces the password length requirement along with the field instead of leaving it unlinked.
25
+ - The "Need assistance?" heading on the Devise views is rendered only when at least one of the links beneath it is. Previously it appeared whenever the matching Devise modules were enabled, so a page whose only link was suppressed showed a heading over nothing.
26
+
27
+ ### Changed
28
+
29
+ - Logout has moved out of the sidebar into the account menu. A host that targeted the sidebar's `l-ui-button--outline-danger` logout button, in CSS or in a system test, will need to look inside `l-ui-navigation__user-popover` instead.
30
+
5
31
  ## [0.23.0] - 2026-08-22
6
32
 
7
33
  ### Added
@@ -105,6 +105,7 @@
105
105
  --l-ui-hero-image: url('layered_ui/hero_background_light.webp');
106
106
  --header-height: 63px;
107
107
  --l-ui-gutter: 1rem;
108
+ --l-ui-navigation-width: 256px;
108
109
  --l-ui-contained-width: 80rem;
109
110
  }
110
111
 
@@ -322,6 +323,10 @@
322
323
  @apply !mb-0;
323
324
  }
324
325
 
326
+ .l-ui-mb-4 {
327
+ @apply !mb-4;
328
+ }
329
+
325
330
  /* List */
326
331
 
327
332
  .l-ui-list {
@@ -518,7 +523,7 @@
518
523
  }
519
524
 
520
525
  .l-ui-body--always-show-navigation .l-ui-page--with-navigation {
521
- @apply md:ml-[256px];
526
+ @apply md:ml-[var(--l-ui-navigation-width)];
522
527
  }
523
528
 
524
529
  /* Zero the page's top gutter so the first section sits flush at the viewport top, behind the
@@ -990,7 +995,7 @@
990
995
  .l-ui-navigation-container {
991
996
  @apply fixed top-[calc(var(--header-height)+1px)] left-0 bottom-0
992
997
  z-50
993
- w-[256px]
998
+ w-[var(--l-ui-navigation-width)]
994
999
  bg-background
995
1000
  md:border-r md:border-border
996
1001
  -translate-x-full
@@ -1162,9 +1167,34 @@
1162
1167
  p-4;
1163
1168
  }
1164
1169
 
1170
+ /* Fill the sidebar's inner column: the navigation width less the gutters on
1171
+ .l-ui-navigation__user, so the menu lines up with the name, email, and trigger.
1172
+ max-w-none overrides .l-ui-popover's own max-w-xs. */
1173
+ .l-ui-navigation__user-popover .l-ui-popover {
1174
+ @apply w-[calc(var(--l-ui-navigation-width)-var(--l-ui-gutter)*2)] max-w-none;
1175
+ }
1176
+
1177
+ /* Points up: the menu opens above the trigger, which sits at the foot of the sidebar. */
1178
+ .l-ui-navigation__user-chevron {
1179
+ @apply inline-block
1180
+ w-3 h-3
1181
+ rotate-180;
1182
+
1183
+ background-color: currentColor;
1184
+ -webkit-mask-image: url('layered_ui/icon_chevron_down.svg');
1185
+ mask-image: url('layered_ui/icon_chevron_down.svg');
1186
+ -webkit-mask-repeat: no-repeat;
1187
+ mask-repeat: no-repeat;
1188
+ -webkit-mask-position: center;
1189
+ mask-position: center;
1190
+ -webkit-mask-size: contain;
1191
+ mask-size: contain;
1192
+ }
1193
+
1165
1194
  .l-ui-navigation__user-name-and-email {
1166
- @apply flex flex-col
1167
- gap-1;
1195
+ @apply flex flex-1 flex-col min-w-0
1196
+ gap-1
1197
+ break-words;
1168
1198
  }
1169
1199
 
1170
1200
  .l-ui-navigation__user-name {
@@ -1271,6 +1301,18 @@
1271
1301
  @apply md:hidden;
1272
1302
  }
1273
1303
 
1304
+ /* The whole navigation user block is the account menu trigger: pair with
1305
+ .l-ui-button. Name and email on the left, chevron on the right. Drops the
1306
+ button's own padding so its contents sit flush with .l-ui-navigation__user's
1307
+ gutter, which is where the popover's borders land. Defined here rather than
1308
+ with the other navigation classes so it follows .l-ui-button in the cascade. */
1309
+ .l-ui-navigation__user-button {
1310
+ @apply w-full h-auto justify-between
1311
+ gap-2 p-0
1312
+ text-left
1313
+ rounded-sm;
1314
+ }
1315
+
1274
1316
  .l-ui-button--icon {
1275
1317
  @apply min-w-[44px]
1276
1318
  p-2
@@ -14,10 +14,21 @@ module Layered
14
14
  l_ui_current_user.present?
15
15
  end
16
16
 
17
+ # True on the account settings screen (Devise's registrations#edit). Layouts
18
+ # that strip app chrome from Devise views want an exception for it, as it is
19
+ # part of the signed-in app rather than the authentication flow.
20
+ def l_ui_settings_screen?
21
+ controller_name == "registrations" && action_name == "edit"
22
+ end
23
+
17
24
  def l_ui_new_registration_path
18
25
  l_ui_devise_path(:new, :registration)
19
26
  end
20
27
 
28
+ def l_ui_edit_registration_path
29
+ l_ui_devise_path(:edit, :registration)
30
+ end
31
+
21
32
  def l_ui_new_session_path
22
33
  l_ui_devise_path(:new, :session)
23
34
  end
@@ -13,6 +13,15 @@ module Layered
13
13
  error: "The options could not be loaded.",
14
14
  more_error: "More options could not be loaded."
15
15
  }.freeze
16
+
17
+ # The keywords +l_ui_combobox+ accepts. Its signature is keyword-only, so
18
+ # a :combobox field config can only carry these (unlike the other field
19
+ # types, whose extras become HTML attributes on the input).
20
+ COMBOBOX_OPTIONS = %i[
21
+ collection url form selected multiple create create_name reorder
22
+ min_chars text id label hint placeholder required disabled
23
+ describedby container
24
+ ].freeze
16
25
  # Renders a token select: a text input with type-ahead filtering whose
17
26
  # selections become removable tokens, in the style of an email recipient
18
27
  # field. Built on the ARIA combobox pattern (an +input+ with
@@ -116,11 +125,13 @@ module Layered
116
125
  # placeholder: (String) Input placeholder. Defaults to nothing.
117
126
  # required: (Boolean) Marks the label and input as required.
118
127
  # disabled: (Boolean) Disables the input and every token control.
128
+ # describedby: (String) Extra element ids appended to the input's +aria-describedby+, for text
129
+ # rendered outside the control (a validation message, say).
119
130
  # container: (Hash) Extra HTML attributes for the wrapping <div>.
120
131
  def l_ui_combobox(name, collection: nil, form: nil, selected: nil, multiple: true,
121
132
  create: false, create_name: nil, reorder: false, url: nil,
122
133
  min_chars: 0, text: {}, id: nil, label: nil, hint: nil, placeholder: nil,
123
- required: false, disabled: false, container: {})
134
+ required: false, disabled: false, describedby: nil, container: {})
124
135
  if collection.nil? && url.nil?
125
136
  raise ArgumentError,
126
137
  "l_ui_combobox requires collection: (options filtered in the browser) or url: " \
@@ -157,7 +168,8 @@ module Layered
157
168
  (tag.p(hint, id: "#{id}-hint", class: "l-ui-form__hint") if hint),
158
169
  l_ui_combobox_control(id, tokens,
159
170
  value_name: value_name, placeholder: placeholder, hint: hint,
160
- reorder: reorder, disabled: disabled, required: required, url: url),
171
+ reorder: reorder, disabled: disabled, required: required, url: url,
172
+ describedby: describedby),
161
173
  l_ui_combobox_listbox(id, options, tokens, multiple: multiple, url: url, text: text),
162
174
  l_ui_combobox_template(reorder: reorder, disabled: disabled),
163
175
  (l_ui_combobox_option_template if url),
@@ -203,10 +215,12 @@ module Layered
203
215
  if label.nil? && !create
204
216
  raise ArgumentError,
205
217
  "l_ui_combobox was given selected value #{value.inspect}, which is not in the " \
206
- "collection. " +
207
- (url ? "Pass it as a [label, value] pair, since a remote collection cannot be " \
208
- "searched for its label. " : "") +
209
- "Pass create: true (with create_name:) to allow values outside it."
218
+ "collection. Pass it with its label, e.g. selected: [[\"Label\", " \
219
+ "#{value.inspect}]] - " +
220
+ (url ? "a remote collection cannot be searched for its label. " :
221
+ "a scoped or paginated collection will not contain a value the record " \
222
+ "already has. ") +
223
+ "Or pass create: true (with create_name:) to allow values outside the collection."
210
224
  end
211
225
 
212
226
  {
@@ -309,8 +323,9 @@ module Layered
309
323
  end
310
324
  end
311
325
 
312
- def l_ui_combobox_control(id, tokens, value_name:, placeholder:, hint:, reorder:, disabled:, required:, url: nil)
313
- described_by = [("#{id}-hint" if hint), "#{id}-instructions"].compact.join(" ")
326
+ def l_ui_combobox_control(id, tokens, value_name:, placeholder:, hint:, reorder:, disabled:, required:,
327
+ url: nil, describedby: nil)
328
+ described_by = [("#{id}-hint" if hint), "#{id}-instructions", describedby.presence].compact.join(" ")
314
329
 
315
330
  tag.div(class: class_names("l-ui-combobox__control", "l-ui-combobox__control--disabled" => disabled),
316
331
  data: { "l-ui--combobox-target" => "control", action: "click->l-ui--combobox#focusInput" }) do
@@ -5,7 +5,7 @@ module Layered
5
5
  string text email password number tel url search
6
6
  date datetime time month week
7
7
  color range file
8
- select checkbox hidden
8
+ select checkbox hidden combobox
9
9
  ].freeze
10
10
 
11
11
  # Renders a complete form with all fields, error summary,
@@ -38,11 +38,51 @@ module Layered
38
38
  "Provide collection: [['Label', value], ...] or collection: -> { Model.pluck(:name, :id) }"
39
39
  end
40
40
 
41
+ if as == :combobox && config[:collection].nil? && config[:url].nil?
42
+ raise ArgumentError,
43
+ "Field :#{attribute} is declared as :combobox but has no :collection or :url. " \
44
+ "Provide collection: [['Label', value], ...] to filter in the browser, " \
45
+ "or url: to fetch options from an endpoint as the user types"
46
+ end
47
+
48
+ if as == :combobox && config[:url] && !config.key?(:selected) &&
49
+ l_ui_field_value(record, attribute).present?
50
+ raise ArgumentError,
51
+ "Field :#{attribute} is a remote :combobox with a value already set, but no :selected. " \
52
+ "A remote collection cannot be searched for the label of an existing value, " \
53
+ "so pass it with its label, e.g. selected: [[record.user.name, record.user_id]]"
54
+ end
55
+
41
56
  label = config[:label] || attribute.to_s.humanize
42
57
 
43
58
  extras = config.except(:attribute, :as, :label, :required, :hint,
44
59
  :collection, :placeholder, :prompt, :include_blank)
45
60
 
61
+ if as == :combobox
62
+ unusable = {
63
+ prompt: "Use placeholder: instead, which a combobox shows until something is selected.",
64
+ include_blank: "A combobox with no selection is already blank, so there is nothing to set."
65
+ }
66
+ unusable.each do |key, advice|
67
+ next unless config.key?(key)
68
+
69
+ raise ArgumentError,
70
+ "Field :#{attribute} is a :combobox and cannot take :#{key}. #{advice}"
71
+ end
72
+
73
+ extras[:multiple] = l_ui_field_multiple?(record, attribute) unless config.key?(:multiple)
74
+
75
+ allowed = ComboboxHelper::COMBOBOX_OPTIONS + [:class]
76
+ unknown = extras.keys - allowed
77
+ if unknown.any?
78
+ raise ArgumentError,
79
+ "Field :#{attribute} is a :combobox and cannot take " \
80
+ "#{unknown.map { |key| ":#{key}" }.join(', ')}. Unlike the other field types, a " \
81
+ "combobox takes only its own options (#{allowed.map { |key| ":#{key}" }.join(', ')}); " \
82
+ "extra HTML attributes belong on container:"
83
+ end
84
+ end
85
+
46
86
  {
47
87
  attribute: attribute,
48
88
  as: as,
@@ -57,6 +97,12 @@ module Layered
57
97
  }
58
98
  end
59
99
 
100
+ # The record's current value for a field, used as the default selection
101
+ # for a :combobox field when +selected:+ is not given.
102
+ def l_ui_field_value(record, attribute)
103
+ record.public_send(attribute) if record.respond_to?(attribute)
104
+ end
105
+
60
106
  def l_ui_field_error_id(record, attribute)
61
107
  "#{record.model_name.param_key}_#{attribute}_error"
62
108
  end
@@ -65,8 +111,41 @@ module Layered
65
111
  "#{record.model_name.param_key}_#{attribute}_hint"
66
112
  end
67
113
 
114
+ # Space-joined ids for a field's aria-describedby: its hint (when the field
115
+ # has one) followed by its error message, which is always rendered.
116
+ def l_ui_field_describedby(record, attribute, hint: false)
117
+ ids = []
118
+ ids << l_ui_field_hint_id(record, attribute) if hint
119
+ ids << l_ui_field_error_id(record, attribute)
120
+ ids.join(" ")
121
+ end
122
+
68
123
  private
69
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
+
70
149
  def l_ui_field_type_for(model_class, attribute)
71
150
  return :string unless model_class.respond_to?(:columns_hash)
72
151
 
@@ -2,12 +2,12 @@
2
2
  <div class="l-ui-page__narrow">
3
3
  <h1>Resend confirmation instructions</h1>
4
4
 
5
- <%= form_with(model: resource, as: resource_name, url: confirmation_path(resource_name), method: :post, class: 'l-ui-form l-ui-mt-3') do |f| %>
5
+ <%= form_with(model: resource, scope: resource_name, url: confirmation_path(resource_name), method: :post, class: 'l-ui-form l-ui-mt-3') do |f| %>
6
6
  <%= render 'layered_ui/shared/form_errors', item: resource %>
7
7
 
8
8
  <div class="l-ui-form__group">
9
9
  <%= render 'layered_ui/shared/label', form: f, field: :email, required: true %>
10
- <%= f.email_field :email, autofocus: true, autocomplete: "email", class: "l-ui-form__field", value: (resource.pending_reconfirmation? ? resource.unconfirmed_email : resource.email), "aria-describedby": "#{resource_name}_email_error" %>
10
+ <%= f.email_field :email, autofocus: true, autocomplete: "email", class: "l-ui-form__field", value: (resource.pending_reconfirmation? ? resource.unconfirmed_email : resource.email), aria: { describedby: l_ui_field_describedby(resource, :email) } %>
11
11
  <%= render 'layered_ui/shared/field_error', object: resource, field: :email %>
12
12
  </div>
13
13
 
@@ -2,16 +2,16 @@
2
2
  <div class="l-ui-page__narrow">
3
3
  <h1>Change your password</h1>
4
4
 
5
- <%= form_with(model: resource, as: resource_name, url: password_path(resource_name), method: :put, class: 'l-ui-form l-ui-mt-3') do |f| %>
5
+ <%= form_with(model: resource, scope: resource_name, url: password_path(resource_name), method: :put, class: 'l-ui-form l-ui-mt-3') do |f| %>
6
6
  <%= render 'layered_ui/shared/form_errors', item: resource %>
7
7
  <%= f.hidden_field :reset_password_token %>
8
8
 
9
9
  <div class="l-ui-form__group">
10
10
  <%= render 'layered_ui/shared/label', form: f, field: :password, name: "New password", required: true %>
11
- <%= f.password_field :password, autofocus: true, autocomplete: "new-password", class: "l-ui-form__field", "aria-describedby": "#{resource_name}_password_error" %>
11
+ <%= f.password_field :password, autofocus: true, autocomplete: "new-password", class: "l-ui-form__field", aria: { describedby: l_ui_field_describedby(resource, :password, hint: @minimum_password_length.present?) } %>
12
12
  <%= render 'layered_ui/shared/field_error', object: resource, field: :password %>
13
13
  <% if @minimum_password_length %>
14
- <div class="l-ui-form__hint">
14
+ <div class="l-ui-form__hint" id="<%= l_ui_field_hint_id(resource, :password) %>">
15
15
  (<%= @minimum_password_length %> characters minimum)
16
16
  </div>
17
17
  <% end %>
@@ -19,10 +19,10 @@
19
19
 
20
20
  <div class="l-ui-form__group">
21
21
  <%= render 'layered_ui/shared/label', form: f, field: :password_confirmation, name: "Confirm new password", required: true %>
22
- <%= f.password_field :password_confirmation, autocomplete: "new-password", class: "l-ui-form__field", "aria-describedby": "#{resource_name}_password_confirmation_error" %>
22
+ <%= f.password_field :password_confirmation, autocomplete: "new-password", class: "l-ui-form__field", aria: { describedby: l_ui_field_describedby(resource, :password_confirmation, hint: @minimum_password_length.present?) } %>
23
23
  <%= render 'layered_ui/shared/field_error', object: resource, field: :password_confirmation %>
24
24
  <% if @minimum_password_length %>
25
- <div class="l-ui-form__hint">
25
+ <div class="l-ui-form__hint" id="<%= l_ui_field_hint_id(resource, :password_confirmation) %>">
26
26
  Please enter the same password again
27
27
  </div>
28
28
  <% end %>
@@ -2,12 +2,12 @@
2
2
  <div class="l-ui-page__narrow">
3
3
  <h1>Forgot your password?</h1>
4
4
 
5
- <%= form_with(model: resource, as: resource_name, url: password_path(resource_name), method: :post, class: 'l-ui-form l-ui-mt-3') do |f| %>
5
+ <%= form_with(model: resource, scope: resource_name, url: password_path(resource_name), method: :post, class: 'l-ui-form l-ui-mt-3') do |f| %>
6
6
  <%= render 'layered_ui/shared/form_errors', item: resource %>
7
7
 
8
8
  <div class="l-ui-form__group">
9
9
  <%= render 'layered_ui/shared/label', form: f, field: :email, required: true %>
10
- <%= f.email_field :email, autofocus: true, autocomplete: "email", class: "l-ui-form__field", "aria-describedby": "#{resource_name}_email_error" %>
10
+ <%= f.email_field :email, autofocus: true, autocomplete: "email", class: "l-ui-form__field", aria: { describedby: l_ui_field_describedby(resource, :email) } %>
11
11
  <%= render 'layered_ui/shared/field_error', object: resource, field: :email %>
12
12
  </div>
13
13
 
@@ -0,0 +1,86 @@
1
+ <h1>Settings</h1>
2
+
3
+ <div class="l-ui-tabs l-ui-mt-4" data-controller="l-ui--tabs">
4
+ <div class="l-ui-tabs__list" role="tablist" data-action="keydown->l-ui--tabs#keydown">
5
+ <button class="l-ui-tabs__tab l-ui-tabs__tab--active" role="tab"
6
+ data-action="l-ui--tabs#select"
7
+ data-l-ui--tabs-target="tab"
8
+ aria-selected="true" aria-controls="l-ui-settings-panel-account" id="l-ui-settings-tab-account">
9
+ Account
10
+ </button>
11
+ </div>
12
+
13
+ <div class="l-ui-tabs__panel" role="tabpanel"
14
+ data-l-ui--tabs-target="panel"
15
+ id="l-ui-settings-panel-account" aria-labelledby="l-ui-settings-tab-account">
16
+ <%= form_with(model: resource, scope: resource_name, url: registration_path(resource_name), method: :put, class: 'l-ui-form l-ui-mt-3') do |f| %>
17
+ <%= render 'layered_ui/shared/form_errors', item: resource %>
18
+
19
+ <% if resource.class.column_names.include?('name') %>
20
+ <div class="l-ui-form__group">
21
+ <%= render 'layered_ui/shared/label', form: f, field: :name, required: true %>
22
+ <%= f.text_field :name, autocomplete: "name", class: "l-ui-form__field", aria: { describedby: l_ui_field_describedby(resource, :name) } %>
23
+ <%= render 'layered_ui/shared/field_error', object: resource, field: :name %>
24
+ </div>
25
+ <% end %>
26
+
27
+ <div class="l-ui-form__group">
28
+ <%= render 'layered_ui/shared/label', form: f, field: :email, required: true %>
29
+ <%= f.email_field :email, autocomplete: "email", class: "l-ui-form__field", aria: { describedby: l_ui_field_describedby(resource, :email, hint: devise_mapping.confirmable? && resource.pending_reconfirmation?) } %>
30
+ <%= render 'layered_ui/shared/field_error', object: resource, field: :email %>
31
+
32
+ <% if devise_mapping.confirmable? && resource.pending_reconfirmation? %>
33
+ <div class="l-ui-form__hint" id="<%= l_ui_field_hint_id(resource, :email) %>">
34
+ Currently waiting confirmation for: <%= resource.unconfirmed_email %>
35
+ </div>
36
+ <% end %>
37
+ </div>
38
+
39
+ <h2 class="l-ui-mt-8 l-ui-mb-4 l-ui-heading--section">
40
+ Change your password
41
+ </h2>
42
+
43
+ <p>Leave these blank if you do not want to change your password.</p>
44
+
45
+ <div class="l-ui-form__group">
46
+ <%= render 'layered_ui/shared/label', form: f, field: :password, name: "New password", required: false %>
47
+ <%= f.password_field :password, autocomplete: "new-password", class: "l-ui-form__field", aria: { describedby: l_ui_field_describedby(resource, :password, hint: @minimum_password_length.present?) } %>
48
+ <%= render 'layered_ui/shared/field_error', object: resource, field: :password %>
49
+ <% if @minimum_password_length %>
50
+ <div class="l-ui-form__hint" id="<%= l_ui_field_hint_id(resource, :password) %>">
51
+ (<%= @minimum_password_length %> characters minimum)
52
+ </div>
53
+ <% end %>
54
+ </div>
55
+
56
+ <div class="l-ui-form__group">
57
+ <%= render 'layered_ui/shared/label', form: f, field: :password_confirmation, name: "Confirm new password", required: false %>
58
+ <%= f.password_field :password_confirmation, autocomplete: "new-password", class: "l-ui-form__field", aria: { describedby: l_ui_field_describedby(resource, :password_confirmation) } %>
59
+ <%= render 'layered_ui/shared/field_error', object: resource, field: :password_confirmation %>
60
+ </div>
61
+
62
+ <div class="l-ui-form__group l-ui-form__group--large-gap">
63
+ <%= render 'layered_ui/shared/label', form: f, field: :current_password, name: "Current password", required: true %>
64
+ <%= f.password_field :current_password, autocomplete: "current-password", class: "l-ui-form__field", aria: { describedby: l_ui_field_describedby(resource, :current_password, hint: true) } %>
65
+ <%= render 'layered_ui/shared/field_error', object: resource, field: :current_password %>
66
+ <div class="l-ui-form__hint" id="<%= l_ui_field_hint_id(resource, :current_password) %>">
67
+ We need your current password to confirm your changes
68
+ </div>
69
+ </div>
70
+
71
+ <div class="l-ui-form__actions">
72
+ <%= f.submit "Save changes", class: "l-ui-button l-ui-button--primary" %>
73
+ </div>
74
+ <% end %>
75
+
76
+ <h2 class="l-ui-mt-8 l-ui-mb-4 l-ui-heading--section">
77
+ Cancel your account
78
+ </h2>
79
+
80
+ <p>Cancelling your account is permanent and cannot be undone.</p>
81
+
82
+ <div class="l-ui-form__group">
83
+ <%= button_to "Cancel my account", registration_path(resource_name), method: :delete, class: "l-ui-button l-ui-button--outline-danger", form: { data: { turbo_confirm: "Are you sure? This cannot be undone." } } %>
84
+ </div>
85
+ </div>
86
+ </div>
@@ -2,29 +2,29 @@
2
2
  <div class="l-ui-page__narrow">
3
3
  <h1>Register</h1>
4
4
 
5
- <%= form_with(model: resource, as: resource_name, url: registration_path(resource_name), class: 'l-ui-form l-ui-mt-3') do |f| %>
5
+ <%= form_with(model: resource, scope: resource_name, url: registration_path(resource_name), class: 'l-ui-form l-ui-mt-3') do |f| %>
6
6
  <%= render 'layered_ui/shared/form_errors', item: resource %>
7
7
 
8
8
  <% if resource.class.column_names.include?('name') %>
9
9
  <div class="l-ui-form__group">
10
10
  <%= render 'layered_ui/shared/label', form: f, field: :name, required: true %>
11
- <%= f.text_field :name, autocomplete: "name", class: "l-ui-form__field", "aria-describedby": "#{resource_name}_name_error" %>
11
+ <%= f.text_field :name, autocomplete: "name", class: "l-ui-form__field", aria: { describedby: l_ui_field_describedby(resource, :name) } %>
12
12
  <%= render 'layered_ui/shared/field_error', object: resource, field: :name %>
13
13
  </div>
14
14
  <% end %>
15
15
 
16
16
  <div class="l-ui-form__group">
17
17
  <%= render 'layered_ui/shared/label', form: f, field: :email, required: true %>
18
- <%= f.email_field :email, autocomplete: "email", class: "l-ui-form__field", "aria-describedby": "#{resource_name}_email_error" %>
18
+ <%= f.email_field :email, autocomplete: "email", class: "l-ui-form__field", aria: { describedby: l_ui_field_describedby(resource, :email) } %>
19
19
  <%= render 'layered_ui/shared/field_error', object: resource, field: :email %>
20
20
  </div>
21
21
 
22
22
  <div class="l-ui-form__group">
23
23
  <%= render 'layered_ui/shared/label', form: f, field: :password, required: true %>
24
- <%= f.password_field :password, autocomplete: "new-password", class: "l-ui-form__field", "aria-describedby": "#{resource_name}_password_error" %>
24
+ <%= f.password_field :password, autocomplete: "new-password", class: "l-ui-form__field", aria: { describedby: l_ui_field_describedby(resource, :password, hint: @minimum_password_length.present?) } %>
25
25
  <%= render 'layered_ui/shared/field_error', object: resource, field: :password %>
26
26
  <% if @minimum_password_length %>
27
- <div class="l-ui-form__hint">
27
+ <div class="l-ui-form__hint" id="<%= l_ui_field_hint_id(resource, :password) %>">
28
28
  (<%= @minimum_password_length %> characters minimum)
29
29
  </div>
30
30
  <% end %>
@@ -32,10 +32,10 @@
32
32
 
33
33
  <div class="l-ui-form__group">
34
34
  <%= render 'layered_ui/shared/label', form: f, field: :password_confirmation, name: "Confirm password", required: true %>
35
- <%= f.password_field :password_confirmation, autocomplete: "new-password", class: "l-ui-form__field", "aria-describedby": "#{resource_name}_password_confirmation_error" %>
35
+ <%= f.password_field :password_confirmation, autocomplete: "new-password", class: "l-ui-form__field", aria: { describedby: l_ui_field_describedby(resource, :password_confirmation, hint: @minimum_password_length.present?) } %>
36
36
  <%= render 'layered_ui/shared/field_error', object: resource, field: :password_confirmation %>
37
37
  <% if @minimum_password_length %>
38
- <div class="l-ui-form__hint">
38
+ <div class="l-ui-form__hint" id="<%= l_ui_field_hint_id(resource, :password_confirmation) %>">
39
39
  Please enter the same password again
40
40
  </div>
41
41
  <% end %>
@@ -2,18 +2,18 @@
2
2
  <div class="l-ui-page__narrow">
3
3
  <h1>Login</h1>
4
4
 
5
- <%= form_with(model: resource, as: resource_name, url: session_path(resource_name), class: 'l-ui-form l-ui-mt-3') do |f| %>
5
+ <%= form_with(model: resource, scope: resource_name, url: session_path(resource_name), class: 'l-ui-form l-ui-mt-3') do |f| %>
6
6
  <%= render 'layered_ui/shared/form_errors', item: resource %>
7
7
 
8
8
  <div class="l-ui-form__group">
9
9
  <%= render 'layered_ui/shared/label', form: f, field: :email, required: true %>
10
- <%= f.email_field :email, autofocus: true, autocomplete: "email", class: "l-ui-form__field", "aria-describedby": "#{resource_name}_email_error" %>
10
+ <%= f.email_field :email, autofocus: true, autocomplete: "email", class: "l-ui-form__field", aria: { describedby: l_ui_field_describedby(resource, :email) } %>
11
11
  <%= render 'layered_ui/shared/field_error', object: resource, field: :email %>
12
12
  </div>
13
13
 
14
14
  <div class="l-ui-form__group">
15
15
  <%= render 'layered_ui/shared/label', form: f, field: :password, required: true %>
16
- <%= f.password_field :password, autocomplete: "current-password", class: "l-ui-form__field", "aria-describedby": "#{resource_name}_password_error" %>
16
+ <%= f.password_field :password, autocomplete: "current-password", class: "l-ui-form__field", aria: { describedby: l_ui_field_describedby(resource, :password) } %>
17
17
  <%= render 'layered_ui/shared/field_error', object: resource, field: :password %>
18
18
  </div>
19
19
 
@@ -10,21 +10,25 @@
10
10
  </p>
11
11
  <% end %>
12
12
 
13
- <%- if devise_mapping.recoverable? || devise_mapping.confirmable? || (devise_mapping.lockable? && resource_class.unlock_strategy_enabled?(:email)) %>
14
- <h2 class="l-ui-mt-6 l-ui-heading--section">
13
+ <%- show_password_reset = devise_mapping.recoverable? && controller_name != 'passwords' && controller_name != 'registrations' %>
14
+ <%- show_confirmation = devise_mapping.confirmable? && controller_name != 'confirmations' %>
15
+ <%- show_unlock = devise_mapping.lockable? && resource_class.unlock_strategy_enabled?(:email) && controller_name != 'unlocks' %>
16
+
17
+ <%- if show_password_reset || show_confirmation || show_unlock %>
18
+ <h2 class="l-ui-mt-6 l-ui-mb-4 l-ui-heading--section">
15
19
  Need assistance?
16
20
  </h2>
17
21
  <% end %>
18
22
 
19
- <%- if devise_mapping.recoverable? && controller_name != 'passwords' && controller_name != 'registrations' %>
23
+ <%- if show_password_reset %>
20
24
  <p><%= link_to "Reset your password", new_password_path(resource_name) %></p>
21
25
  <% end %>
22
26
 
23
- <%- if devise_mapping.confirmable? && controller_name != 'confirmations' %>
27
+ <%- if show_confirmation %>
24
28
  <p><%= link_to "Resend account confirmation email", new_confirmation_path(resource_name) %></p>
25
29
  <% end %>
26
30
 
27
- <%- if devise_mapping.lockable? && resource_class.unlock_strategy_enabled?(:email) && controller_name != 'unlocks' %>
31
+ <%- if show_unlock %>
28
32
  <p><%= link_to "Resend account unlock email", new_unlock_path(resource_name) %></p>
29
33
  <% end %>
30
34
 
@@ -2,12 +2,12 @@
2
2
  <div class="l-ui-page__narrow">
3
3
  <h1>Resend unlock instructions</h1>
4
4
 
5
- <%= form_with(model: resource, as: resource_name, url: unlock_path(resource_name), method: :post, class: 'l-ui-form l-ui-mt-3') do |f| %>
5
+ <%= form_with(model: resource, scope: resource_name, url: unlock_path(resource_name), method: :post, class: 'l-ui-form l-ui-mt-3') do |f| %>
6
6
  <%= render 'layered_ui/shared/form_errors', item: resource %>
7
7
 
8
8
  <div class="l-ui-form__group">
9
9
  <%= render 'layered_ui/shared/label', form: f, field: :email, required: true %>
10
- <%= f.email_field :email, autofocus: true, autocomplete: "email", class: "l-ui-form__field", "aria-describedby": "#{resource_name}_email_error" %>
10
+ <%= f.email_field :email, autofocus: true, autocomplete: "email", class: "l-ui-form__field", aria: { describedby: l_ui_field_describedby(resource, :email) } %>
11
11
  <%= render 'layered_ui/shared/field_error', object: resource, field: :email %>
12
12
  </div>
13
13
 
@@ -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>
@@ -7,9 +7,8 @@
7
7
  base_opts[:required] = true if config[:required]
8
8
 
9
9
  unless config[:as] == :hidden
10
- describedby = [l_ui_field_error_id(record, attribute)]
11
- describedby.unshift(l_ui_field_hint_id(record, attribute)) if config[:hint]
12
- base_opts["aria-describedby"] = describedby.join(" ")
10
+ base_opts["aria-describedby"] =
11
+ l_ui_field_describedby(record, attribute, hint: config[:hint].present?)
13
12
  end
14
13
 
15
14
  field_class = ->(base) { token_list(base, extra_class) }
@@ -36,6 +35,22 @@
36
35
  <%= form.select(attribute, choices, select_options,
37
36
  class: field_class.call("l-ui-select"), **base_opts, **extras) %>
38
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) %>
39
54
  <% elsif config[:as] == :checkbox %>
40
55
  <div class="l-ui-checkbox-container">
41
56
  <%= form.check_box(attribute, class: field_class.call("l-ui-checkbox"), **base_opts, **extras) %>
@@ -7,23 +7,31 @@
7
7
 
8
8
  <% if l_ui_user_signed_in? %>
9
9
  <div class="l-ui-navigation__user">
10
- <div class="l-ui-navigation__user-name-and-email">
11
- <% if l_ui_current_user.respond_to?(:name) %>
12
- <div class="l-ui-navigation__user-name">
13
- <%= l_ui_current_user.name %>
14
- </div>
15
- <% end %>
10
+ <% if l_ui_edit_registration_path || l_ui_destroy_session_path %>
11
+ <%= l_ui_popover(placement: :top, align: :end, container: { class: "l-ui-navigation__user-popover" }) do |p| %>
12
+ <% p.trigger(class: "l-ui-button l-ui-navigation__user-button") do %>
13
+ <%= render "layouts/layered_ui/user_name_and_email" %>
14
+
15
+ <span class="l-ui-sr-only">Account menu</span>
16
+ <span class="l-ui-navigation__user-chevron" aria-hidden="true"></span>
17
+ <% end %>
18
+
19
+ <div class="l-ui-popover__menu">
20
+ <% if l_ui_edit_registration_path %>
21
+ <%= link_to "Settings", l_ui_edit_registration_path, class: "l-ui-popover__menu-item" %>
22
+ <% end %>
23
+
24
+ <% if l_ui_edit_registration_path && l_ui_destroy_session_path %>
25
+ <hr class="l-ui-popover__menu-divider">
26
+ <% end %>
16
27
 
17
- <% if l_ui_current_user.respond_to?(:email) %>
18
- <div class="l-ui-navigation__user-email">
19
- <%= l_ui_current_user.email %>
28
+ <% if l_ui_destroy_session_path %>
29
+ <%= button_to "Logout", l_ui_destroy_session_path, method: :delete, class: "l-ui-popover__menu-item l-ui-popover__menu-item--danger" %>
30
+ <% end %>
20
31
  </div>
21
32
  <% end %>
22
- </div>
23
- <% if l_ui_destroy_session_path %>
24
- <div class="l-ui-mt-4">
25
- <%= button_to "Logout", l_ui_destroy_session_path, method: :delete, class: "l-ui-button l-ui-button--outline-danger l-ui-button--full" %>
26
- </div>
33
+ <% else %>
34
+ <%= render "layouts/layered_ui/user_name_and_email" %>
27
35
  <% end %>
28
36
  </div>
29
37
  <% end %>
@@ -0,0 +1,15 @@
1
+ <%# Spans throughout: this also renders inside the account menu trigger button, %>
2
+ <%# whose content model only allows phrasing content. %>
3
+ <span class="l-ui-navigation__user-name-and-email">
4
+ <% if l_ui_current_user.respond_to?(:name) %>
5
+ <span class="l-ui-navigation__user-name">
6
+ <%= l_ui_current_user.name %>
7
+ </span>
8
+ <% end %>
9
+
10
+ <% if l_ui_current_user.respond_to?(:email) %>
11
+ <span class="l-ui-navigation__user-email">
12
+ <%= l_ui_current_user.email %>
13
+ </span>
14
+ <% end %>
15
+ </span>
@@ -43,13 +43,21 @@ module Layered
43
43
  end
44
44
  end
45
45
 
46
+ # The engine's views are prepended so they beat views shipped by other
47
+ # engines (Devise's own, in particular, which would otherwise win or lose
48
+ # on engine load order alone). The host application's app/views is then
49
+ # prepended back on top, so an application can still eject any engine view
50
+ # by copying it to the matching path - the ordering Rails gives you by
51
+ # default, which a bare prepend would take away.
46
52
  initializer "layered-ui-rails.view_paths" do
47
53
  ActiveSupport.on_load(:action_controller) do
48
54
  prepend_view_path Engine.root.join("app/views")
55
+ prepend_view_path Rails.root.join("app/views")
49
56
  end
50
57
 
51
58
  ActiveSupport.on_load(:action_mailer) do
52
59
  prepend_view_path Engine.root.join("app/views")
60
+ prepend_view_path Rails.root.join("app/views")
53
61
  end
54
62
  end
55
63
  end
@@ -1,5 +1,5 @@
1
1
  module Layered
2
2
  module Ui
3
- VERSION = "0.23.0"
3
+ VERSION = "0.25.0"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: layered-ui-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.23.0
4
+ version: 0.25.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - layered.ai
@@ -254,6 +254,7 @@ files:
254
254
  - app/views/devise/mailer/unlock_instructions.html.erb
255
255
  - app/views/devise/passwords/edit.html.erb
256
256
  - app/views/devise/passwords/new.html.erb
257
+ - app/views/devise/registrations/edit.html.erb
257
258
  - app/views/devise/registrations/new.html.erb
258
259
  - app/views/devise/sessions/new.html.erb
259
260
  - app/views/devise/shared/_links.html.erb
@@ -273,6 +274,7 @@ files:
273
274
  - app/views/layouts/layered_ui/_notice.html.erb
274
275
  - app/views/layouts/layered_ui/_panel.html.erb
275
276
  - app/views/layouts/layered_ui/_theme_toggle.html.erb
277
+ - app/views/layouts/layered_ui/_user_name_and_email.html.erb
276
278
  - app/views/layouts/layered_ui/application.html.erb
277
279
  - config/importmap.rb
278
280
  - lib/generators/layered/ui/create_overrides_generator.rb