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 +4 -4
- data/.claude/skills/layered-ui-rails/SKILL.md +10 -2
- data/.claude/skills/layered-ui-rails/references/CSS.md +5 -0
- data/.claude/skills/layered-ui-rails/references/HELPERS.md +20 -4
- data/CHANGELOG.md +26 -0
- data/app/assets/tailwind/layered_ui/engine.css +46 -4
- data/app/helpers/layered/ui/authentication_helper.rb +11 -0
- data/app/helpers/layered/ui/combobox_helper.rb +23 -8
- data/app/helpers/layered/ui/form_helper.rb +80 -1
- data/app/views/devise/confirmations/new.html.erb +2 -2
- data/app/views/devise/passwords/edit.html.erb +5 -5
- data/app/views/devise/passwords/new.html.erb +2 -2
- data/app/views/devise/registrations/edit.html.erb +86 -0
- data/app/views/devise/registrations/new.html.erb +7 -7
- data/app/views/devise/sessions/new.html.erb +3 -3
- data/app/views/devise/shared/_links.html.erb +9 -5
- data/app/views/devise/unlocks/new.html.erb +2 -2
- data/app/views/layered/ui/managed_resource/_field.html.erb +2 -2
- data/app/views/layered/ui/managed_resource/_field_input.html.erb +18 -3
- data/app/views/layouts/layered_ui/_navigation.html.erb +22 -14
- data/app/views/layouts/layered_ui/_user_name_and_email.html.erb +15 -0
- data/lib/layered/ui/engine.rb +8 -0
- data/lib/layered/ui/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d892f7efaed58d161179cc5478eaf0961b4b31c2e822aaba4063143343aeac31
|
|
4
|
+
data.tar.gz: 28e37164714d1a354b255a10a3833069b94f6202480082b29e70fcc23a485ffa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d2d3c833c03dd9c56d88bf578fb69f27ddf0eda03baf31ebb0953e6de0bb9b5ae1248d444e77c362d18e4288aad6adbae28a4959f95c68a392b373417c23040d
|
|
7
|
+
data.tar.gz: 661964c07f3d03e1d4ee937a9f4b3f3d456491fadb9ee67778cee063d9eda535fbf02a276bcfe1a3c0a1b67579ece03afda66a5e1d98c90ebe6b232de6c05731
|
|
@@ -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
|
|
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`
|
|
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-[
|
|
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-[
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
218
|
+
"collection. Pass it with its label, e.g. selected: [[\"Label\", " \
|
|
219
|
+
"#{value.inspect}]] - " +
|
|
220
|
+
(url ? "a remote collection cannot be searched for its label. " :
|
|
221
|
+
"a scoped or paginated collection will not contain a value the record " \
|
|
222
|
+
"already has. ") +
|
|
223
|
+
"Or pass create: true (with create_name:) to allow values outside the collection."
|
|
210
224
|
end
|
|
211
225
|
|
|
212
226
|
{
|
|
@@ -309,8 +323,9 @@ module Layered
|
|
|
309
323
|
end
|
|
310
324
|
end
|
|
311
325
|
|
|
312
|
-
def l_ui_combobox_control(id, tokens, value_name:, placeholder:, hint:, reorder:, disabled:, required:,
|
|
313
|
-
|
|
326
|
+
def l_ui_combobox_control(id, tokens, value_name:, placeholder:, hint:, reorder:, disabled:, required:,
|
|
327
|
+
url: nil, describedby: nil)
|
|
328
|
+
described_by = [("#{id}-hint" if hint), "#{id}-instructions", describedby.presence].compact.join(" ")
|
|
314
329
|
|
|
315
330
|
tag.div(class: class_names("l-ui-combobox__control", "l-ui-combobox__control--disabled" => disabled),
|
|
316
331
|
data: { "l-ui--combobox-target" => "control", action: "click->l-ui--combobox#focusInput" }) do
|
|
@@ -5,7 +5,7 @@ module Layered
|
|
|
5
5
|
string text email password number tel url search
|
|
6
6
|
date datetime time month week
|
|
7
7
|
color range file
|
|
8
|
-
select checkbox hidden
|
|
8
|
+
select checkbox hidden combobox
|
|
9
9
|
].freeze
|
|
10
10
|
|
|
11
11
|
# Renders a complete form with all fields, error summary,
|
|
@@ -38,11 +38,51 @@ module Layered
|
|
|
38
38
|
"Provide collection: [['Label', value], ...] or collection: -> { Model.pluck(:name, :id) }"
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
+
if as == :combobox && config[:collection].nil? && config[:url].nil?
|
|
42
|
+
raise ArgumentError,
|
|
43
|
+
"Field :#{attribute} is declared as :combobox but has no :collection or :url. " \
|
|
44
|
+
"Provide collection: [['Label', value], ...] to filter in the browser, " \
|
|
45
|
+
"or url: to fetch options from an endpoint as the user types"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
if as == :combobox && config[:url] && !config.key?(:selected) &&
|
|
49
|
+
l_ui_field_value(record, attribute).present?
|
|
50
|
+
raise ArgumentError,
|
|
51
|
+
"Field :#{attribute} is a remote :combobox with a value already set, but no :selected. " \
|
|
52
|
+
"A remote collection cannot be searched for the label of an existing value, " \
|
|
53
|
+
"so pass it with its label, e.g. selected: [[record.user.name, record.user_id]]"
|
|
54
|
+
end
|
|
55
|
+
|
|
41
56
|
label = config[:label] || attribute.to_s.humanize
|
|
42
57
|
|
|
43
58
|
extras = config.except(:attribute, :as, :label, :required, :hint,
|
|
44
59
|
:collection, :placeholder, :prompt, :include_blank)
|
|
45
60
|
|
|
61
|
+
if as == :combobox
|
|
62
|
+
unusable = {
|
|
63
|
+
prompt: "Use placeholder: instead, which a combobox shows until something is selected.",
|
|
64
|
+
include_blank: "A combobox with no selection is already blank, so there is nothing to set."
|
|
65
|
+
}
|
|
66
|
+
unusable.each do |key, advice|
|
|
67
|
+
next unless config.key?(key)
|
|
68
|
+
|
|
69
|
+
raise ArgumentError,
|
|
70
|
+
"Field :#{attribute} is a :combobox and cannot take :#{key}. #{advice}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
extras[:multiple] = l_ui_field_multiple?(record, attribute) unless config.key?(:multiple)
|
|
74
|
+
|
|
75
|
+
allowed = ComboboxHelper::COMBOBOX_OPTIONS + [:class]
|
|
76
|
+
unknown = extras.keys - allowed
|
|
77
|
+
if unknown.any?
|
|
78
|
+
raise ArgumentError,
|
|
79
|
+
"Field :#{attribute} is a :combobox and cannot take " \
|
|
80
|
+
"#{unknown.map { |key| ":#{key}" }.join(', ')}. Unlike the other field types, a " \
|
|
81
|
+
"combobox takes only its own options (#{allowed.map { |key| ":#{key}" }.join(', ')}); " \
|
|
82
|
+
"extra HTML attributes belong on container:"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
46
86
|
{
|
|
47
87
|
attribute: attribute,
|
|
48
88
|
as: as,
|
|
@@ -57,6 +97,12 @@ module Layered
|
|
|
57
97
|
}
|
|
58
98
|
end
|
|
59
99
|
|
|
100
|
+
# The record's current value for a field, used as the default selection
|
|
101
|
+
# for a :combobox field when +selected:+ is not given.
|
|
102
|
+
def l_ui_field_value(record, attribute)
|
|
103
|
+
record.public_send(attribute) if record.respond_to?(attribute)
|
|
104
|
+
end
|
|
105
|
+
|
|
60
106
|
def l_ui_field_error_id(record, attribute)
|
|
61
107
|
"#{record.model_name.param_key}_#{attribute}_error"
|
|
62
108
|
end
|
|
@@ -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,
|
|
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),
|
|
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,
|
|
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",
|
|
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",
|
|
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,
|
|
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",
|
|
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,
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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,
|
|
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",
|
|
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",
|
|
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
|
-
<%-
|
|
14
|
-
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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",
|
|
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 =
|
|
11
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
<%=
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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>
|
data/lib/layered/ui/engine.rb
CHANGED
|
@@ -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
|
data/lib/layered/ui/version.rb
CHANGED
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.
|
|
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
|