layered-ui-rails 0.8.0 → 0.10.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 +4 -1
- data/.claude/skills/layered-ui-rails/references/CONTROLLERS.md +10 -0
- data/.claude/skills/layered-ui-rails/references/CSS.md +22 -2
- data/.claude/skills/layered-ui-rails/references/HELPERS.md +52 -7
- data/CHANGELOG.md +26 -1
- data/README.md +36 -36
- data/app/assets/images/layered_ui/icon_home.svg +3 -0
- data/app/assets/tailwind/layered/ui/styles.css +113 -35
- data/app/helpers/layered/ui/navigation_helper.rb +118 -15
- data/app/helpers/layered/ui/table_helper.rb +12 -2
- data/app/helpers/layered/ui/title_bar_helper.rb +52 -0
- data/app/javascript/layered_ui/controllers/l_ui/navigation_controller.js +29 -0
- data/app/javascript/layered_ui/controllers/l_ui/navigation_section_controller.js +48 -0
- data/app/javascript/layered_ui/controllers/l_ui/panel_controller.js +19 -8
- data/app/javascript/layered_ui/index.js +2 -0
- data/app/javascript/layered_ui/utilities/scroll_lock.js +29 -0
- data/app/views/layered/ui/managed_resource/_form.html.erb +1 -1
- data/config/importmap.rb +2 -0
- data/lib/layered/ui/engine.rb +1 -0
- data/lib/layered/ui/version.rb +1 -1
- metadata +7 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b9fe544e3cfcfc260bba58a205ada774ffcb1154b48b86fb406d25c189da5b77
|
|
4
|
+
data.tar.gz: 8d66cf1556700d9c26867ab59aa2d989bfd17f924ea1b7ec7be9cc3feb4626c5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5a2aa85d87740cdf751526101c052155edf16f04136fbb164a70d30a1535bd1b68f82ccec6b333f7c001d8a8a7174868cd5f594d1613d0dd470d5f876bf28d2f
|
|
7
|
+
data.tar.gz: 33f34e0b13bd3ba0494722ffe41142cbfb3c94cd0da47a6a79bc423a9926928e332586def1e1d669b5c806f6f4fb828da14778197f0349d55e79c626071ec9c2
|
|
@@ -103,9 +103,11 @@ Quick reference:
|
|
|
103
103
|
|
|
104
104
|
| Helper | Purpose |
|
|
105
105
|
|---|---|
|
|
106
|
-
| `l_ui_navigation_item(label, path,
|
|
106
|
+
| `l_ui_navigation_item(label, path, ...)` | Sidebar nav link (supports `icon:`, `match: :starts_with`, `expandable:`) |
|
|
107
|
+
| `l_ui_navigation_section(heading = nil, ...)` | Group nav items; supports `collapsible:`, `storage_key:`, `separated:` |
|
|
107
108
|
| `l_ui_breadcrumbs(&block)` | Breadcrumb nav wrapper |
|
|
108
109
|
| `l_ui_breadcrumb_item(label, path = nil)` | Individual breadcrumb |
|
|
110
|
+
| `l_ui_title_bar(title:, breadcrumbs: [], actions: nil, &block)` | Responsive page title bar with breadcrumbs and actions |
|
|
109
111
|
| `l_ui_pagy(pagy)` | Styled pagination (requires pagy gem) |
|
|
110
112
|
| `l_ui_search_form(query, url:, fields:, ...)` | Search form (requires ransack gem) |
|
|
111
113
|
| `l_ui_sort_link(query, attribute, label = nil, ...)` | Sortable table header (requires ransack gem) |
|
|
@@ -141,6 +143,7 @@ All controllers use the `l-ui--` namespace and are auto-registered via importmap
|
|
|
141
143
|
|---|---|---|
|
|
142
144
|
| Theme | `l-ui--theme` | Dark/light mode toggle with localStorage |
|
|
143
145
|
| Navigation | `l-ui--navigation` | Responsive sidebar with backdrop |
|
|
146
|
+
| Navigation section | `l-ui--navigation-section` | Collapsible nav section with localStorage persistence |
|
|
144
147
|
| Panel | `l-ui--panel` | Resizable side panel (Cmd/Ctrl+I toggle) |
|
|
145
148
|
| Panel button | `l-ui--panel-button` | Draggable floating action button |
|
|
146
149
|
| Panel resize | `l-ui--panel-resize` | Panel width drag handle |
|
|
@@ -28,9 +28,19 @@ Responsive sidebar navigation with backdrop overlay on mobile.
|
|
|
28
28
|
**Targets:** `navigation`, `backdrop`, `toggleButton`, `openIcon`, `closeIcon`
|
|
29
29
|
**Actions:** `toggle`, `close`
|
|
30
30
|
**Keyboard:** Escape to close
|
|
31
|
+
**Behaviour:** Locks body scroll while the mobile overlay is open
|
|
31
32
|
|
|
32
33
|
The layout wires this up automatically. Navigation items are populated via `content_for :l_ui_navigation_items`.
|
|
33
34
|
|
|
35
|
+
## Navigation section (`l-ui--navigation-section`)
|
|
36
|
+
|
|
37
|
+
Collapsible navigation section with optional localStorage persistence. Wired up automatically by `l_ui_navigation_section` when `collapsible: true`.
|
|
38
|
+
|
|
39
|
+
**Targets:** `toggle`, `panel`
|
|
40
|
+
**Values:** `storageKey` (String, localStorage key), `forceOpen` (Boolean)
|
|
41
|
+
**Actions:** `toggle`
|
|
42
|
+
**Behaviour:** The server renders the section's default open/closed state (and forces it open when it contains the active item). On `connect`, if `forceOpen` is set the stored preference is ignored; otherwise the controller restores the user's stored preference. Clicking the toggle always works (so a force-opened section can still be collapsed) and writes the new state to localStorage.
|
|
43
|
+
|
|
34
44
|
## Modal (`l-ui--modal`)
|
|
35
45
|
|
|
36
46
|
Native `<dialog>` wrapper with focus trap, scroll lock, and focus restoration.
|
|
@@ -170,6 +170,15 @@ WCAG 2.2 AA table pattern:
|
|
|
170
170
|
.l-ui-breadcrumbs__link Breadcrumb link
|
|
171
171
|
```
|
|
172
172
|
|
|
173
|
+
## Title bar
|
|
174
|
+
|
|
175
|
+
```
|
|
176
|
+
.l-ui-title-bar Title bar wrapper used with .l-ui-container--spread
|
|
177
|
+
.l-ui-title-bar__content Breadcrumbs and title column
|
|
178
|
+
.l-ui-title-bar__title Page title
|
|
179
|
+
.l-ui-title-bar__actions Action area
|
|
180
|
+
```
|
|
181
|
+
|
|
173
182
|
## Pagination
|
|
174
183
|
|
|
175
184
|
```
|
|
@@ -190,8 +199,18 @@ WCAG 2.2 AA table pattern:
|
|
|
190
199
|
.l-ui-navigation Nav flexbox
|
|
191
200
|
.l-ui-navigation__links Nav links list
|
|
192
201
|
.l-ui-navigation__item Nav item
|
|
193
|
-
.l-ui-navigation__item--active Active nav item (
|
|
194
|
-
.l-ui-
|
|
202
|
+
.l-ui-navigation__item--active Active nav item (highlighted bg)
|
|
203
|
+
.l-ui-navigation__item-icon Icon image inside a nav item
|
|
204
|
+
.l-ui-navigation__item-icon-slot Wrapper for caller-supplied icon HTML (e.g. icon fonts)
|
|
205
|
+
.l-ui-navigation__item-label Label span inside a nav item
|
|
206
|
+
.l-ui-navigation__section Section group (li)
|
|
207
|
+
.l-ui-navigation__section--has-heading Section that has a visible heading
|
|
208
|
+
.l-ui-navigation__section--separated Section with top border separator
|
|
209
|
+
.l-ui-navigation__section--collapsible Section with toggle button heading
|
|
210
|
+
.l-ui-navigation__section-heading Plain section label (small, uppercase, muted)
|
|
211
|
+
.l-ui-navigation__section-toggle Collapsible section heading (item-row sized button)
|
|
212
|
+
.l-ui-navigation__section-chevron Toggle chevron (rotates when closed)
|
|
213
|
+
.l-ui-navigation__section-items Section body (ul, hidden when closed)
|
|
195
214
|
.l-ui-navigation__user User info section
|
|
196
215
|
.l-ui-navigation__user-name User name text
|
|
197
216
|
.l-ui-navigation__user-email User email text
|
|
@@ -275,6 +294,7 @@ WCAG 2.2 AA table pattern:
|
|
|
275
294
|
.l-ui-sr-only Visually hidden, screen reader only
|
|
276
295
|
.l-ui-skip-link Accessibility skip link
|
|
277
296
|
.l-ui-list Styled list
|
|
297
|
+
.l-ui-hr Horizontal rule with theme border and vertical spacing
|
|
278
298
|
.l-ui-container--grid 1-col mobile, 2-col desktop grid
|
|
279
299
|
.l-ui-container--spread Flex row with space-between
|
|
280
300
|
.l-ui-container--pagy Pagination wrapper
|
|
@@ -5,19 +5,39 @@ All helpers are prefixed `l_ui_` and are available in all views automatically.
|
|
|
5
5
|
## Navigation
|
|
6
6
|
|
|
7
7
|
```ruby
|
|
8
|
-
l_ui_navigation_item(label, path, active: nil,
|
|
8
|
+
l_ui_navigation_item(label, path, active: nil, match: :exact, icon: nil, icon_path: nil, icon_html: nil)
|
|
9
|
+
l_ui_navigation_section(heading = nil, icon: nil, icon_path: nil, icon_html: nil, collapsible: false, expanded: true, storage_key: nil, separated: false, &block)
|
|
9
10
|
```
|
|
10
11
|
|
|
12
|
+
`l_ui_navigation_item`:
|
|
11
13
|
- `label` (String) - link text
|
|
12
14
|
- `path` (String) - URL
|
|
13
|
-
- `active` (Boolean, optional) - force active
|
|
14
|
-
-
|
|
15
|
+
- `active` (Boolean, optional) - force the active style; defaults to a match against the current request path. Does not affect `aria-current`, which is set only when `current_page?(path)` is true
|
|
16
|
+
- `match` (Symbol) - `:exact` (default) uses `current_page?`; `:starts_with` activates when the request path begins with `path` (use for parents whose sub-routes should keep the parent highlighted)
|
|
17
|
+
- `icon` (String, optional) - icon name; `"home"` ships with the gem. For others, supply the SVG in the host app at `app/assets/images/layered_ui/icon_NAME.svg`
|
|
18
|
+
- `icon_path` (String, optional) - explicit asset path; takes precedence over `icon:`
|
|
19
|
+
- `icon_html` (ActiveSupport::SafeBuffer, optional) - pre-rendered icon markup for icon-font libraries (e.g. `tag.i(class: "fa-solid fa-house")`); takes precedence over `icon:` and `icon_path:`. Must be already html-safe - plain strings will be escaped. Never pass user-controlled input
|
|
20
|
+
|
|
21
|
+
`l_ui_navigation_section`:
|
|
22
|
+
- `heading` (String, optional) - non-clickable section heading; omit for an unlabelled group. To expose the parent route of a section, add an "Overview" item inside the block
|
|
23
|
+
- `icon` (String, optional) - host-app icon name next to the heading; resolves the same way as `l_ui_navigation_item`'s `icon:`
|
|
24
|
+
- `icon_path` (String, optional) - explicit asset path next to the heading; takes precedence over `icon:`
|
|
25
|
+
- `icon_html` (String, optional) - pre-rendered icon markup; takes precedence over `icon:` and `icon_path:`
|
|
26
|
+
- `collapsible` (Boolean) - when `true` and a heading is given, renders a toggle button with a chevron and wires `aria-controls` to the panel
|
|
27
|
+
- `expanded` (Boolean) - default open state for collapsible sections; a section that contains an active descendant always opens
|
|
28
|
+
- `storage_key` (String, optional) - localStorage key to persist the open/closed preference
|
|
29
|
+
- `separated` (Boolean) - draw a horizontal rule above the section, useful for footer-style groups
|
|
30
|
+
- `&block` - section body, typically `l_ui_navigation_item`s
|
|
15
31
|
|
|
16
32
|
```erb
|
|
17
33
|
<% content_for :l_ui_navigation_items do %>
|
|
18
|
-
<%=
|
|
19
|
-
|
|
20
|
-
|
|
34
|
+
<%= l_ui_navigation_section do %>
|
|
35
|
+
<%= l_ui_navigation_item("Home", root_path, icon: "home") %>
|
|
36
|
+
<% end %>
|
|
37
|
+
|
|
38
|
+
<%= l_ui_navigation_section("Products", collapsible: true, storage_key: "products") do %>
|
|
39
|
+
<%= l_ui_navigation_item("Overview", products_path) %>
|
|
40
|
+
<%= l_ui_navigation_item("Electronics", electronics_path, match: :starts_with) %>
|
|
21
41
|
<%= l_ui_navigation_item("Clothing", clothing_path) %>
|
|
22
42
|
<% end %>
|
|
23
43
|
<% end %>
|
|
@@ -41,6 +61,29 @@ l_ui_breadcrumb_item(label, path = nil)
|
|
|
41
61
|
<% end %>
|
|
42
62
|
```
|
|
43
63
|
|
|
64
|
+
## Title bar
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
l_ui_title_bar(title:, breadcrumbs: [], actions: nil, &block)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
- `title` (String) - page title rendered as the `<h1>`
|
|
71
|
+
- `breadcrumbs` (Array, optional) - breadcrumb items as `[label, path]` arrays or `{ label:, path: }` hashes
|
|
72
|
+
- `actions` (String|Array, optional) - HTML-safe action content; omit when using a block
|
|
73
|
+
- `&block` - optional action markup, usually buttons or links
|
|
74
|
+
|
|
75
|
+
```erb
|
|
76
|
+
<%= l_ui_title_bar(
|
|
77
|
+
title: "Users",
|
|
78
|
+
breadcrumbs: [
|
|
79
|
+
["Home", root_path],
|
|
80
|
+
["Admin", admin_path]
|
|
81
|
+
]
|
|
82
|
+
) do %>
|
|
83
|
+
<%= link_to "New user", new_user_path, class: "l-ui-button--primary" %>
|
|
84
|
+
<% end %>
|
|
85
|
+
```
|
|
86
|
+
|
|
44
87
|
## Pagination (requires pagy gem)
|
|
45
88
|
|
|
46
89
|
```ruby
|
|
@@ -124,7 +167,8 @@ Returns a `<th>` element with sort link and ARIA sort attributes.
|
|
|
124
167
|
|
|
125
168
|
```ruby
|
|
126
169
|
l_ui_table(records, columns:, caption: nil, actions: nil,
|
|
127
|
-
actions_label: "Actions", query: nil, url: nil,
|
|
170
|
+
actions_label: "Actions", query: nil, url: nil,
|
|
171
|
+
turbo_frame: nil, row_id: nil)
|
|
128
172
|
```
|
|
129
173
|
|
|
130
174
|
- `records` (ActiveRecord::Relation or Array) - the collection to render
|
|
@@ -135,6 +179,7 @@ l_ui_table(records, columns:, caption: nil, actions: nil,
|
|
|
135
179
|
- `query` (Ransack::Search, optional) - enables sortable column headers
|
|
136
180
|
- `url` (String, optional) - sort link URL (passed to `l_ui_sort_link`)
|
|
137
181
|
- `turbo_frame` (String, optional) - turbo frame target for sort links
|
|
182
|
+
- `row_id` (Proc, optional) - receives (record), returns the `<tr>` id. Defaults to `dom_id(record)` for records that respond to `to_key` (ActiveRecord). Return `nil` to omit the id.
|
|
138
183
|
|
|
139
184
|
Column options:
|
|
140
185
|
- `attribute` (Symbol) - used for label generation and sort links
|
data/CHANGELOG.md
CHANGED
|
@@ -2,7 +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
|
-
## [
|
|
5
|
+
## [0.10.0] - 2026-05-03
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Nested navigation: `l_ui_navigation_section` helper for collapsible sidebar sections, backed by an `l-ui--navigation-section` Stimulus controller and `l-ui-navigation__section`/`__toggle`/`__items` styles
|
|
10
|
+
- Optional `icon:` / `icon_path:` / `icon_html:` arguments on `l_ui_navigation_item` and `l_ui_navigation_section` to render an icon alongside the label; ships with a default `icon_home.svg` as an example
|
|
11
|
+
- `row_id:` option on `l_ui_table` to set a stable DOM id on each row (useful for Turbo Stream targeting)
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- **Breaking:** Minimum Ruby version raised to 3.3.0 (Ruby 3.2 is EOL). CI matrix now tests against Ruby 3.3, 3.4, and 4.0
|
|
16
|
+
|
|
17
|
+
## [0.9.0] - 2026-04-26
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- `l_ui_title_bar` helper for page title bars with optional actions
|
|
22
|
+
- `l-ui-hr` class for styled horizontal rules
|
|
23
|
+
- `color-scheme` CSS property set to match the active theme so native form controls (date pickers, scrollbars, etc.) render with matching light/dark styling
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- Navigation and panel controllers share an extracted `scroll_lock` utility module
|
|
28
|
+
- Checkbox and radio inputs use a pointer cursor
|
|
29
|
+
- Scroll-to-bottom button is centred and has a faint shadow
|
|
30
|
+
- Form submit label shortened from "Save changes" to "Save"
|
|
6
31
|
|
|
7
32
|
## [0.8.0] - 2026-04-24
|
|
8
33
|
|
data/README.md
CHANGED
|
@@ -31,31 +31,22 @@ An open source, Rails 8+ engine that provides WCAG 2.2 AA compliant design token
|
|
|
31
31
|
</tr>
|
|
32
32
|
</table>
|
|
33
33
|
|
|
34
|
-
##
|
|
35
|
-
|
|
36
|
-
Add to your Gemfile and install:
|
|
37
|
-
|
|
38
|
-
```bash
|
|
39
|
-
bundle add layered-ui-rails
|
|
40
|
-
bin/rails generate layered:ui:install
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
The install generator will:
|
|
44
|
-
- Copy `layered_ui.css` to `app/assets/tailwind/`
|
|
45
|
-
- Add `@import "./layered_ui";` to your `application.css`
|
|
46
|
-
- Add `import "layered_ui"` to your `application.js`
|
|
47
|
-
|
|
48
|
-
Then update your application layout to render the engine layout. Place any `content_for` blocks **above** the render call - the engine layout reads them when it renders, so they must be defined first:
|
|
34
|
+
## Features
|
|
49
35
|
|
|
50
|
-
|
|
51
|
-
|
|
36
|
+
- **Dark/light theme** - system preference detection with localStorage persistence and manual toggle
|
|
37
|
+
- **Responsive layout** - header, sidebar navigation, main content area, and optional resizable panel
|
|
38
|
+
- **WCAG 2.2 AA compliant** - skip links, focus indicators, ARIA attributes, and 4.5:1 contrast ratios
|
|
39
|
+
- **Components** - buttons, forms, surfaces, tables, tabs, notices, badges, conversations, modals, and pagination
|
|
40
|
+
- **Optional integrations** - Devise authentication and Pagy pagination with styled views
|
|
41
|
+
- **Customisable branding** - Override the default logos and icons and colors
|
|
42
|
+
- **Google Lighthouse** - `layered-ui-rails` scores a [perfect 100](https://github.com/layered-ai-public/layered-ui-rails/raw/refs/heads/main/test/dummy/app/assets/images/lighthouse.webp) across all four Google Lighthouse categories - performance, accessibility, best practices, and SEO
|
|
52
43
|
|
|
53
|
-
|
|
54
|
-
<%= l_ui_navigation_item "Home", root_path %>
|
|
55
|
-
<% end %>
|
|
44
|
+
## Requirements
|
|
56
45
|
|
|
57
|
-
|
|
58
|
-
|
|
46
|
+
- Ruby on Rails >= 8.0
|
|
47
|
+
- Tailwind CSS Rails >= 4.0
|
|
48
|
+
- Importmap Rails >= 2.0
|
|
49
|
+
- Stimulus Rails >= 1.0
|
|
59
50
|
|
|
60
51
|
## Agent skill
|
|
61
52
|
|
|
@@ -75,22 +66,31 @@ bin/rails generate layered:ui:install_agent_skill
|
|
|
75
66
|
curl -fsSL https://raw.githubusercontent.com/layered-ai-public/layered-ui-rails/main/install-skill.sh | sh
|
|
76
67
|
```
|
|
77
68
|
|
|
78
|
-
##
|
|
69
|
+
## Installation
|
|
79
70
|
|
|
80
|
-
|
|
81
|
-
- Tailwind CSS Rails >= 4.0
|
|
82
|
-
- Importmap Rails >= 2.0
|
|
83
|
-
- Stimulus Rails >= 1.0
|
|
71
|
+
Add to your Gemfile and install:
|
|
84
72
|
|
|
85
|
-
|
|
73
|
+
```bash
|
|
74
|
+
bundle add layered-ui-rails
|
|
75
|
+
bin/rails generate layered:ui:install
|
|
76
|
+
```
|
|
86
77
|
|
|
87
|
-
|
|
88
|
-
-
|
|
89
|
-
-
|
|
90
|
-
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
78
|
+
The install generator will:
|
|
79
|
+
- Copy `layered_ui.css` to `app/assets/tailwind/`
|
|
80
|
+
- Add `@import "./layered_ui";` to your `application.css`
|
|
81
|
+
- Add `import "layered_ui"` to your `application.js`
|
|
82
|
+
|
|
83
|
+
Then update your application layout to render the engine layout. Place any `content_for` blocks **above** the render call - the engine layout reads them when it renders, so they must be defined first:
|
|
84
|
+
|
|
85
|
+
```erb
|
|
86
|
+
<% content_for :l_ui_body_class, "l-ui-body--always-show-navigation" %>
|
|
87
|
+
|
|
88
|
+
<% content_for :l_ui_navigation_items do %>
|
|
89
|
+
<%= l_ui_navigation_item "Home", root_path %>
|
|
90
|
+
<% end %>
|
|
91
|
+
|
|
92
|
+
<%= render template: "layouts/layered_ui/application" %>
|
|
93
|
+
```
|
|
94
94
|
|
|
95
95
|
## Customising theme tokens
|
|
96
96
|
|
|
@@ -223,4 +223,4 @@ Copyright 2026 LAYERED AI LIMITED (UK company number: 17056830). See [NOTICE](NO
|
|
|
223
223
|
|
|
224
224
|
## Trademarks
|
|
225
225
|
|
|
226
|
-
The source code is fully open, but the layered.ai name, logo, and brand assets are trademarks of LAYERED AI LIMITED. The Apache 2.0 license does not grant rights to use the layered.ai branding. Forks and redistributions must use a distinct name. See [TRADEMARK.md](TRADEMARK.md) for the full policy.
|
|
226
|
+
The source code is fully open, but the layered.ai name, logo, and brand assets are trademarks of LAYERED AI LIMITED. The Apache 2.0 license does not grant rights to use the layered.ai branding. Forks and redistributions must use a distinct name. See [TRADEMARK.md](TRADEMARK.md) for the full policy.
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
<svg fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
|
|
3
|
+
</svg>
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
|
|
48
48
|
@layer base {
|
|
49
49
|
:root {
|
|
50
|
+
color-scheme: light;
|
|
50
51
|
/* Tier 1 - Accent */
|
|
51
52
|
--accent: oklch(0.2044 0 0);
|
|
52
53
|
--accent-foreground: oklch(1 0 0);
|
|
@@ -76,6 +77,7 @@
|
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
.dark {
|
|
80
|
+
color-scheme: dark;
|
|
79
81
|
/* Tier 1 - Accent */
|
|
80
82
|
--accent: oklch(1 0 0);
|
|
81
83
|
--accent-foreground: oklch(0.2044 0 0);
|
|
@@ -294,6 +296,13 @@
|
|
|
294
296
|
space-y-1;
|
|
295
297
|
}
|
|
296
298
|
|
|
299
|
+
/* Horizontal rule */
|
|
300
|
+
|
|
301
|
+
.l-ui-hr {
|
|
302
|
+
@apply my-4
|
|
303
|
+
border-0 border-t border-border;
|
|
304
|
+
}
|
|
305
|
+
|
|
297
306
|
/* Markdown */
|
|
298
307
|
|
|
299
308
|
.l-ui-markdown > *:first-child {
|
|
@@ -635,8 +644,8 @@
|
|
|
635
644
|
}
|
|
636
645
|
|
|
637
646
|
@utility navigation__item {
|
|
638
|
-
@apply flex items-center
|
|
639
|
-
w-full min-h-[
|
|
647
|
+
@apply flex items-center gap-3
|
|
648
|
+
w-full min-h-[40px] px-3
|
|
640
649
|
text-sm text-foreground-muted font-medium
|
|
641
650
|
rounded-sm
|
|
642
651
|
focus-ring
|
|
@@ -644,50 +653,96 @@
|
|
|
644
653
|
}
|
|
645
654
|
|
|
646
655
|
.l-ui-navigation__item {
|
|
647
|
-
@apply navigation__item
|
|
656
|
+
@apply navigation__item;
|
|
648
657
|
}
|
|
649
658
|
|
|
650
659
|
.l-ui-navigation__item--active {
|
|
651
660
|
@apply navigation__item
|
|
652
|
-
|
|
653
|
-
text-
|
|
661
|
+
bg-surface-highlighted
|
|
662
|
+
text-foreground;
|
|
663
|
+
}
|
|
654
664
|
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
665
|
+
.l-ui-navigation__item-icon {
|
|
666
|
+
@apply w-4 h-4 shrink-0
|
|
667
|
+
dark:invert;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
.l-ui-navigation__item-icon-slot {
|
|
671
|
+
@apply inline-flex items-center justify-center
|
|
672
|
+
w-4 h-4 shrink-0;
|
|
673
|
+
}
|
|
660
674
|
|
|
661
|
-
|
|
662
|
-
|
|
675
|
+
.l-ui-navigation__item-label {
|
|
676
|
+
@apply truncate;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
.l-ui-navigation__section {
|
|
680
|
+
@apply flex flex-col;
|
|
681
|
+
|
|
682
|
+
& + .l-ui-navigation__section {
|
|
683
|
+
@apply mt-2;
|
|
684
|
+
}
|
|
663
685
|
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
686
|
+
& + .l-ui-navigation__section--separated {
|
|
687
|
+
@apply mt-3 pt-3
|
|
688
|
+
border-t border-border;
|
|
667
689
|
}
|
|
668
690
|
}
|
|
669
691
|
|
|
670
|
-
.l-ui-
|
|
692
|
+
.l-ui-navigation__section-heading {
|
|
693
|
+
@apply flex items-center gap-2
|
|
694
|
+
w-full px-3 pt-4 pb-1
|
|
695
|
+
text-xs text-foreground-muted font-medium uppercase tracking-wider;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
.l-ui-navigation__section-toggle {
|
|
699
|
+
@apply flex items-center gap-3
|
|
700
|
+
w-full min-h-[40px] px-3
|
|
701
|
+
text-sm text-foreground-muted font-medium
|
|
702
|
+
rounded-sm
|
|
703
|
+
focus-ring
|
|
704
|
+
cursor-pointer
|
|
705
|
+
transition-colors;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
.l-ui-navigation__section-chevron {
|
|
709
|
+
@apply ml-auto inline-block
|
|
710
|
+
w-3 h-3
|
|
711
|
+
transition-transform duration-150;
|
|
712
|
+
|
|
713
|
+
background-color: currentColor;
|
|
714
|
+
-webkit-mask-image: url('layered_ui/icon_chevron_down.svg');
|
|
715
|
+
mask-image: url('layered_ui/icon_chevron_down.svg');
|
|
716
|
+
-webkit-mask-repeat: no-repeat;
|
|
717
|
+
mask-repeat: no-repeat;
|
|
718
|
+
-webkit-mask-position: center;
|
|
719
|
+
mask-position: center;
|
|
720
|
+
-webkit-mask-size: contain;
|
|
721
|
+
mask-size: contain;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
.l-ui-navigation__section-toggle[aria-expanded="false"] .l-ui-navigation__section-chevron {
|
|
725
|
+
@apply -rotate-90;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
.l-ui-navigation__section-items {
|
|
671
729
|
@apply flex flex-col
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
@apply ml-auto
|
|
683
|
-
text-2xl font-bold;
|
|
684
|
-
}
|
|
685
|
-
}
|
|
730
|
+
gap-2;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
.l-ui-navigation__section-items[hidden] {
|
|
734
|
+
display: none;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
.l-ui-navigation__section--has-heading .l-ui-navigation__section-items {
|
|
738
|
+
@apply mt-2 ml-5 pl-3
|
|
739
|
+
border-l border-border;
|
|
686
740
|
}
|
|
687
741
|
|
|
688
742
|
.l-ui-navigation__links {
|
|
689
743
|
@apply flex flex-col flex-1
|
|
690
|
-
|
|
744
|
+
gap-0.5
|
|
745
|
+
px-3 py-3 min-h-0
|
|
691
746
|
overflow-y-auto;
|
|
692
747
|
}
|
|
693
748
|
|
|
@@ -740,6 +795,26 @@
|
|
|
740
795
|
focus-ring rounded-sm;
|
|
741
796
|
}
|
|
742
797
|
|
|
798
|
+
/* Title bar */
|
|
799
|
+
|
|
800
|
+
.l-ui-title-bar {
|
|
801
|
+
@apply w-full;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
.l-ui-title-bar__content {
|
|
805
|
+
@apply min-w-0;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
.l-ui-title-bar__title {
|
|
809
|
+
@apply mt-0;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
.l-ui-title-bar__actions {
|
|
813
|
+
@apply flex flex-wrap items-center justify-end
|
|
814
|
+
gap-2
|
|
815
|
+
shrink-0;
|
|
816
|
+
}
|
|
817
|
+
|
|
743
818
|
/* Buttons */
|
|
744
819
|
|
|
745
820
|
@utility button {
|
|
@@ -1017,7 +1092,8 @@ pre.l-ui-surface {
|
|
|
1017
1092
|
@apply w-6 h-6
|
|
1018
1093
|
accent-foreground
|
|
1019
1094
|
focus-ring
|
|
1020
|
-
rounded-sm
|
|
1095
|
+
rounded-sm
|
|
1096
|
+
cursor-pointer;
|
|
1021
1097
|
}
|
|
1022
1098
|
|
|
1023
1099
|
/* Search */
|
|
@@ -1124,11 +1200,12 @@ pre.l-ui-surface {
|
|
|
1124
1200
|
@apply w-6 h-6
|
|
1125
1201
|
mr-2
|
|
1126
1202
|
accent-foreground
|
|
1127
|
-
focus-ring
|
|
1203
|
+
focus-ring
|
|
1204
|
+
cursor-pointer;
|
|
1128
1205
|
}
|
|
1129
1206
|
|
|
1130
1207
|
.l-ui-radio__label {
|
|
1131
|
-
@apply text-sm;
|
|
1208
|
+
@apply text-sm cursor-pointer;
|
|
1132
1209
|
}
|
|
1133
1210
|
|
|
1134
1211
|
/* Tabs */
|
|
@@ -1659,10 +1736,11 @@ pre.l-ui-surface {
|
|
|
1659
1736
|
.l-ui-scroll-to-bottom {
|
|
1660
1737
|
@apply
|
|
1661
1738
|
sticky bottom-2 flex items-center justify-center
|
|
1662
|
-
|
|
1739
|
+
mx-auto -mt-9 h-9 w-9
|
|
1663
1740
|
rounded-full
|
|
1664
1741
|
cursor-pointer
|
|
1665
1742
|
bg-button-primary-bg text-button-primary-icon
|
|
1743
|
+
shadow-sm
|
|
1666
1744
|
focus-ring
|
|
1667
1745
|
opacity-0 pointer-events-none
|
|
1668
1746
|
transition-opacity duration-200;
|
|
@@ -1,24 +1,127 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
|
|
1
3
|
module Layered
|
|
2
4
|
module Ui
|
|
3
5
|
module NavigationHelper
|
|
4
|
-
def l_ui_navigation_item(label, path, active: nil,
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
6
|
+
def l_ui_navigation_item(label, path, active: nil, match: :exact, icon: nil, icon_path: nil, icon_html: nil)
|
|
7
|
+
is_active = active.nil? ? l_ui_navigation_item_self_active?(path, match) : active
|
|
8
|
+
|
|
9
|
+
css_class = is_active ? "l-ui-navigation__item--active" : "l-ui-navigation__item"
|
|
10
|
+
options = { class: css_class }
|
|
11
|
+
options["aria-current"] = "page" if current_page?(path)
|
|
12
|
+
|
|
13
|
+
l_ui_navigation_register_active if is_active
|
|
14
|
+
|
|
15
|
+
link_content = l_ui_navigation_item_inner(label, icon, icon_path, icon_html)
|
|
16
|
+
|
|
17
|
+
content_tag(:li) { link_to(link_content, path, options) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def l_ui_navigation_section(heading = nil, icon: nil, icon_path: nil, icon_html: nil, collapsible: false, expanded: true, storage_key: nil, separated: false, &block)
|
|
21
|
+
stack = (Thread.current[:l_ui_navigation_section_stack] ||= [])
|
|
22
|
+
frame = { active: false }
|
|
23
|
+
stack.push(frame)
|
|
24
|
+
begin
|
|
25
|
+
children = block_given? ? capture(&block) : "".html_safe
|
|
26
|
+
ensure
|
|
27
|
+
stack.pop
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
has_active_descendant = frame[:active]
|
|
31
|
+
|
|
32
|
+
if (parent = stack.last) && has_active_descendant
|
|
33
|
+
parent[:active] = true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
is_open = has_active_descendant || expanded
|
|
37
|
+
is_collapsible = collapsible && heading.present?
|
|
38
|
+
panel_id = is_collapsible ? "l-ui-navigation-section-#{SecureRandom.hex(4)}" : nil
|
|
39
|
+
|
|
40
|
+
section_classes = ["l-ui-navigation__section"]
|
|
41
|
+
section_classes << "l-ui-navigation__section--has-heading" if heading.present?
|
|
42
|
+
section_classes << "l-ui-navigation__section--collapsible" if is_collapsible
|
|
43
|
+
section_classes << "l-ui-navigation__section--separated" if separated
|
|
44
|
+
|
|
45
|
+
data = {}
|
|
46
|
+
if is_collapsible
|
|
47
|
+
data["controller"] = "l-ui--navigation-section"
|
|
48
|
+
data["l-ui--navigation-section-storage-key-value"] = storage_key.to_s if storage_key.present?
|
|
49
|
+
data["l-ui--navigation-section-force-open-value"] = "true" if has_active_descendant
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
content_tag(:li, class: section_classes.join(" "), data: data) do
|
|
53
|
+
parts = []
|
|
54
|
+
|
|
55
|
+
if heading.present?
|
|
56
|
+
heading_inner = l_ui_navigation_item_inner(heading, icon, icon_path, icon_html)
|
|
57
|
+
|
|
58
|
+
if is_collapsible
|
|
59
|
+
chevron = content_tag(:span, "", class: "l-ui-navigation__section-chevron", "aria-hidden": "true")
|
|
60
|
+
parts << content_tag(:button,
|
|
61
|
+
heading_inner + chevron,
|
|
62
|
+
type: "button",
|
|
63
|
+
class: "l-ui-navigation__section-toggle",
|
|
64
|
+
"aria-expanded": is_open.to_s,
|
|
65
|
+
"aria-controls": panel_id,
|
|
66
|
+
data: { "l-ui--navigation-section-target": "toggle", action: "click->l-ui--navigation-section#toggle" }
|
|
67
|
+
)
|
|
68
|
+
else
|
|
69
|
+
parts << content_tag(:div, heading_inner, class: "l-ui-navigation__section-heading")
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
panel_attrs = { class: "l-ui-navigation__section-items", role: "list" }
|
|
74
|
+
if is_collapsible
|
|
75
|
+
panel_attrs[:id] = panel_id
|
|
76
|
+
panel_attrs[:data] = { "l-ui--navigation-section-target": "panel" }
|
|
77
|
+
panel_attrs[:hidden] = true unless is_open
|
|
14
78
|
end
|
|
79
|
+
parts << content_tag(:ul, children, **panel_attrs)
|
|
80
|
+
|
|
81
|
+
safe_join(parts)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def l_ui_navigation_register_active
|
|
88
|
+
stack = Thread.current[:l_ui_navigation_section_stack]
|
|
89
|
+
return unless stack
|
|
90
|
+
stack.each { |frame| frame[:active] = true }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def l_ui_navigation_item_self_active?(path, match)
|
|
94
|
+
case match
|
|
95
|
+
when :starts_with
|
|
96
|
+
normalized = path.to_s.chomp("/")
|
|
97
|
+
return current_page?(path) if normalized.empty?
|
|
98
|
+
request.path == path || request.path.start_with?("#{normalized}/")
|
|
99
|
+
else
|
|
100
|
+
current_page?(path)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def l_ui_navigation_item_inner(label, icon, icon_path, icon_html)
|
|
105
|
+
parts = []
|
|
106
|
+
|
|
107
|
+
if icon_html.present?
|
|
108
|
+
parts << content_tag(:span, icon_html, class: "l-ui-navigation__item-icon-slot", aria: { hidden: true })
|
|
15
109
|
else
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
110
|
+
resolved_icon_path =
|
|
111
|
+
if icon_path.present?
|
|
112
|
+
icon_path
|
|
113
|
+
elsif icon.present?
|
|
114
|
+
"layered_ui/icon_#{icon}.svg"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
if resolved_icon_path
|
|
118
|
+
parts << image_tag(resolved_icon_path, alt: "", class: "l-ui-navigation__item-icon", aria: { hidden: true })
|
|
119
|
+
end
|
|
21
120
|
end
|
|
121
|
+
|
|
122
|
+
parts << content_tag(:span, label, class: "l-ui-navigation__item-label")
|
|
123
|
+
|
|
124
|
+
safe_join(parts)
|
|
22
125
|
end
|
|
23
126
|
end
|
|
24
127
|
end
|
|
@@ -33,7 +33,12 @@ module Layered
|
|
|
33
33
|
#
|
|
34
34
|
# Pass +query:+ (a Ransack search object) and +turbo_frame:+ to enable
|
|
35
35
|
# sortable column headers via +l_ui_sort_link+.
|
|
36
|
-
|
|
36
|
+
#
|
|
37
|
+
# Each <tr> is given +id: dom_id(record)+ when the record responds to
|
|
38
|
+
# +to_key+ (i.e. ActiveRecord), so individual rows can be targeted by
|
|
39
|
+
# Turbo Streams. Pass +row_id:+ as a proc to override (return +nil+ to
|
|
40
|
+
# omit the id).
|
|
41
|
+
def l_ui_table(records, columns:, caption: nil, actions: nil, actions_label: "Actions", query: nil, url: nil, turbo_frame: nil, row_id: nil)
|
|
37
42
|
columns = normalise_table_columns(columns)
|
|
38
43
|
col_count = columns.size + (actions ? 1 : 0)
|
|
39
44
|
|
|
@@ -58,7 +63,7 @@ module Layered
|
|
|
58
63
|
end
|
|
59
64
|
else
|
|
60
65
|
safe_join(records.map do |record|
|
|
61
|
-
tag.tr do
|
|
66
|
+
tag.tr(id: table_row_id(record, row_id)) do
|
|
62
67
|
cells = columns.map do |col|
|
|
63
68
|
table_cell(record, col)
|
|
64
69
|
end
|
|
@@ -95,6 +100,11 @@ module Layered
|
|
|
95
100
|
end
|
|
96
101
|
end
|
|
97
102
|
|
|
103
|
+
def table_row_id(record, row_id)
|
|
104
|
+
return row_id.call(record) if row_id
|
|
105
|
+
ActionView::RecordIdentifier.dom_id(record) if record.respond_to?(:to_key) && record.to_key
|
|
106
|
+
end
|
|
107
|
+
|
|
98
108
|
def table_cell(record, col)
|
|
99
109
|
value = col[:render].call(record)
|
|
100
110
|
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
module Layered
|
|
2
|
+
module Ui
|
|
3
|
+
module TitleBarHelper
|
|
4
|
+
include Layered::Ui::BreadcrumbsHelper
|
|
5
|
+
|
|
6
|
+
def l_ui_title_bar(title:, breadcrumbs: [], actions: nil, &block)
|
|
7
|
+
action_content = block_given? ? capture(&block) : actions
|
|
8
|
+
|
|
9
|
+
content_tag(:header, class: "l-ui-title-bar l-ui-container--spread") do
|
|
10
|
+
safe_join([
|
|
11
|
+
content_tag(:div, class: "l-ui-title-bar__content") do
|
|
12
|
+
safe_join([
|
|
13
|
+
l_ui_title_bar_breadcrumbs(breadcrumbs),
|
|
14
|
+
content_tag(:h1, title, class: "l-ui-title-bar__title")
|
|
15
|
+
].compact)
|
|
16
|
+
end,
|
|
17
|
+
l_ui_title_bar_actions(action_content)
|
|
18
|
+
].compact)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def l_ui_title_bar_breadcrumbs(breadcrumbs)
|
|
25
|
+
return if breadcrumbs.blank?
|
|
26
|
+
|
|
27
|
+
l_ui_breadcrumbs do
|
|
28
|
+
safe_join(breadcrumbs.map { |breadcrumb| l_ui_title_bar_breadcrumb_item(breadcrumb) })
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def l_ui_title_bar_breadcrumb_item(breadcrumb)
|
|
33
|
+
case breadcrumb
|
|
34
|
+
when Hash
|
|
35
|
+
l_ui_breadcrumb_item(breadcrumb.fetch(:label), breadcrumb[:path])
|
|
36
|
+
when Array
|
|
37
|
+
l_ui_breadcrumb_item(breadcrumb[0], breadcrumb[1])
|
|
38
|
+
else
|
|
39
|
+
l_ui_breadcrumb_item(breadcrumb)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def l_ui_title_bar_actions(action_content)
|
|
44
|
+
return if action_content.blank?
|
|
45
|
+
|
|
46
|
+
content = action_content.is_a?(Array) ? safe_join(action_content) : action_content
|
|
47
|
+
|
|
48
|
+
content_tag(:div, content, class: "l-ui-title-bar__actions")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
2
|
import { announce, clearAnnounceTimeout } from "layered_ui/utilities/announce"
|
|
3
3
|
import { isMobile } from "layered_ui/utilities/layout"
|
|
4
|
+
import { lockBodyScroll, unlockBodyScroll } from "layered_ui/utilities/scroll_lock"
|
|
4
5
|
|
|
5
6
|
export default class extends Controller {
|
|
6
7
|
static targets = ["navigation", "backdrop", "toggleButton", "openIcon", "closeIcon"]
|
|
@@ -8,6 +9,7 @@ export default class extends Controller {
|
|
|
8
9
|
connect() {
|
|
9
10
|
this.previousActiveElement = null
|
|
10
11
|
this.isOpen = false
|
|
12
|
+
this.isScrollLocked = false
|
|
11
13
|
this._resizeFrame = null
|
|
12
14
|
this.boundHandleResize = () => {
|
|
13
15
|
if (this._resizeFrame) return
|
|
@@ -54,6 +56,7 @@ export default class extends Controller {
|
|
|
54
56
|
this.navigationTarget.classList.add("open")
|
|
55
57
|
this.backdropTarget.classList.add("open")
|
|
56
58
|
this.setNavigationInteractivity(true)
|
|
59
|
+
this.updateScrollLock()
|
|
57
60
|
|
|
58
61
|
// Update ARIA attributes and swap icons
|
|
59
62
|
if (this.hasToggleButtonTarget) {
|
|
@@ -89,6 +92,7 @@ export default class extends Controller {
|
|
|
89
92
|
this.navigationTarget.classList.remove("open")
|
|
90
93
|
this.backdropTarget.classList.remove("open")
|
|
91
94
|
this.setNavigationInteractivity(false)
|
|
95
|
+
this.unlockScroll()
|
|
92
96
|
|
|
93
97
|
// Update ARIA attributes and swap icons
|
|
94
98
|
if (this.hasToggleButtonTarget) {
|
|
@@ -116,6 +120,7 @@ export default class extends Controller {
|
|
|
116
120
|
clearAnnounceTimeout(this)
|
|
117
121
|
cancelAnimationFrame(this._resizeFrame)
|
|
118
122
|
window.removeEventListener("resize", this.boundHandleResize)
|
|
123
|
+
this.unlockScroll()
|
|
119
124
|
this.previousActiveElement = null
|
|
120
125
|
}
|
|
121
126
|
|
|
@@ -125,6 +130,7 @@ export default class extends Controller {
|
|
|
125
130
|
// In overlay mode (default), always respect isOpen state regardless of viewport
|
|
126
131
|
if (isMobile() || !this.alwaysShow) {
|
|
127
132
|
this.setNavigationInteractivity(this.isOpen)
|
|
133
|
+
this.updateScrollLock()
|
|
128
134
|
return
|
|
129
135
|
}
|
|
130
136
|
|
|
@@ -133,6 +139,7 @@ export default class extends Controller {
|
|
|
133
139
|
this.navigationTarget.classList.remove("open")
|
|
134
140
|
this.backdropTarget.classList.remove("open")
|
|
135
141
|
this.setNavigationInteractivity(true)
|
|
142
|
+
this.unlockScroll()
|
|
136
143
|
|
|
137
144
|
if (this.hasToggleButtonTarget) {
|
|
138
145
|
this.toggleButtonTarget.setAttribute("aria-expanded", "false")
|
|
@@ -157,6 +164,28 @@ export default class extends Controller {
|
|
|
157
164
|
this.navigationTarget.removeAttribute("aria-hidden")
|
|
158
165
|
}
|
|
159
166
|
|
|
167
|
+
updateScrollLock() {
|
|
168
|
+
if (this.isOpen && isMobile()) {
|
|
169
|
+
this.lockScroll()
|
|
170
|
+
} else {
|
|
171
|
+
this.unlockScroll()
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
lockScroll() {
|
|
176
|
+
if (this.isScrollLocked) return
|
|
177
|
+
|
|
178
|
+
lockBodyScroll()
|
|
179
|
+
this.isScrollLocked = true
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
unlockScroll() {
|
|
183
|
+
if (!this.isScrollLocked) return
|
|
184
|
+
|
|
185
|
+
unlockBodyScroll()
|
|
186
|
+
this.isScrollLocked = false
|
|
187
|
+
}
|
|
188
|
+
|
|
160
189
|
get alwaysShow() {
|
|
161
190
|
return this.element.classList.contains("l-ui-body--always-show-navigation")
|
|
162
191
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
const STORAGE_PREFIX = "l-ui-navigation-section:"
|
|
4
|
+
|
|
5
|
+
export default class extends Controller {
|
|
6
|
+
static targets = ["toggle", "panel"]
|
|
7
|
+
static values = {
|
|
8
|
+
storageKey: String,
|
|
9
|
+
forceOpen: Boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
connect() {
|
|
13
|
+
if (this.forceOpenValue) return
|
|
14
|
+
if (!this.hasStorageKeyValue || !this.storageKeyValue) return
|
|
15
|
+
let stored
|
|
16
|
+
try {
|
|
17
|
+
stored = window.localStorage.getItem(STORAGE_PREFIX + this.storageKeyValue)
|
|
18
|
+
} catch (_e) {
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
|
+
if (stored === null) return
|
|
22
|
+
|
|
23
|
+
const isOpen = stored === "true"
|
|
24
|
+
this.toggleTarget.setAttribute("aria-expanded", isOpen ? "true" : "false")
|
|
25
|
+
if (isOpen) {
|
|
26
|
+
this.panelTarget.removeAttribute("hidden")
|
|
27
|
+
} else {
|
|
28
|
+
this.panelTarget.setAttribute("hidden", "")
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
toggle() {
|
|
33
|
+
const isOpen = this.toggleTarget.getAttribute("aria-expanded") !== "true"
|
|
34
|
+
this.toggleTarget.setAttribute("aria-expanded", isOpen ? "true" : "false")
|
|
35
|
+
if (isOpen) {
|
|
36
|
+
this.panelTarget.removeAttribute("hidden")
|
|
37
|
+
} else {
|
|
38
|
+
this.panelTarget.setAttribute("hidden", "")
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!this.hasStorageKeyValue || !this.storageKeyValue) return
|
|
42
|
+
try {
|
|
43
|
+
window.localStorage.setItem(STORAGE_PREFIX + this.storageKeyValue, isOpen ? "true" : "false")
|
|
44
|
+
} catch (_e) {
|
|
45
|
+
// ignore
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -2,6 +2,7 @@ import { Controller } from "@hotwired/stimulus"
|
|
|
2
2
|
import { announce, clearAnnounceTimeout } from "layered_ui/utilities/announce"
|
|
3
3
|
import { storageGet, storageSet } from "layered_ui/utilities/storage"
|
|
4
4
|
import { isMobile } from "layered_ui/utilities/layout"
|
|
5
|
+
import { lockBodyScroll, unlockBodyScroll } from "layered_ui/utilities/scroll_lock"
|
|
5
6
|
|
|
6
7
|
export default class extends Controller {
|
|
7
8
|
static targets = ["container", "hideButton", "actionButton"]
|
|
@@ -9,6 +10,7 @@ export default class extends Controller {
|
|
|
9
10
|
connect() {
|
|
10
11
|
this.previousActiveElement = null
|
|
11
12
|
this.isOpen = false
|
|
13
|
+
this.isScrollLocked = false
|
|
12
14
|
this.boundKeyboardShortcut = this.handleKeyboardShortcut.bind(this)
|
|
13
15
|
this.boundCloseOnNavigate = this.closeOnMobileNavigate.bind(this)
|
|
14
16
|
const page = document.querySelector(".l-ui-page")
|
|
@@ -34,6 +36,7 @@ export default class extends Controller {
|
|
|
34
36
|
clearAnnounceTimeout(this)
|
|
35
37
|
document.removeEventListener('keydown', this.boundKeyboardShortcut)
|
|
36
38
|
document.removeEventListener('turbo:visit', this.boundCloseOnNavigate)
|
|
39
|
+
this.unlockScroll()
|
|
37
40
|
this.previousActiveElement = null
|
|
38
41
|
}
|
|
39
42
|
|
|
@@ -90,9 +93,7 @@ export default class extends Controller {
|
|
|
90
93
|
if (isMobile()) {
|
|
91
94
|
const main = document.querySelector("main")
|
|
92
95
|
if (main) main.setAttribute("inert", "")
|
|
93
|
-
this.
|
|
94
|
-
document.body.style.top = `-${this.savedScrollY}px`
|
|
95
|
-
document.body.classList.add("l-ui-scroll-lock")
|
|
96
|
+
this.lockScroll()
|
|
96
97
|
}
|
|
97
98
|
|
|
98
99
|
storageSet("panelOpen", "true")
|
|
@@ -124,11 +125,7 @@ export default class extends Controller {
|
|
|
124
125
|
|
|
125
126
|
const main = document.querySelector("main")
|
|
126
127
|
if (main) main.removeAttribute("inert")
|
|
127
|
-
|
|
128
|
-
document.body.style.top = ""
|
|
129
|
-
if (this.savedScrollY !== undefined) {
|
|
130
|
-
window.scrollTo(0, this.savedScrollY)
|
|
131
|
-
}
|
|
128
|
+
this.unlockScroll()
|
|
132
129
|
|
|
133
130
|
storageSet("panelOpen", "false")
|
|
134
131
|
this.updatePageMargin()
|
|
@@ -161,4 +158,18 @@ export default class extends Controller {
|
|
|
161
158
|
page.style.marginRight = ""
|
|
162
159
|
}
|
|
163
160
|
}
|
|
161
|
+
|
|
162
|
+
lockScroll() {
|
|
163
|
+
if (this.isScrollLocked) return
|
|
164
|
+
|
|
165
|
+
lockBodyScroll()
|
|
166
|
+
this.isScrollLocked = true
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
unlockScroll() {
|
|
170
|
+
if (!this.isScrollLocked) return
|
|
171
|
+
|
|
172
|
+
unlockBodyScroll()
|
|
173
|
+
this.isScrollLocked = false
|
|
174
|
+
}
|
|
164
175
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { application } from "controllers/application"
|
|
2
2
|
import ThemeController from "layered_ui/controllers/l_ui/theme_controller"
|
|
3
3
|
import NavigationController from "layered_ui/controllers/l_ui/navigation_controller"
|
|
4
|
+
import NavigationSectionController from "layered_ui/controllers/l_ui/navigation_section_controller"
|
|
4
5
|
import PanelController from "layered_ui/controllers/l_ui/panel_controller"
|
|
5
6
|
import PanelResizeController from "layered_ui/controllers/l_ui/panel_resize_controller"
|
|
6
7
|
import PanelButtonController from "layered_ui/controllers/l_ui/panel_button_controller"
|
|
@@ -11,6 +12,7 @@ import TabsController from "layered_ui/controllers/l_ui/tabs_controller"
|
|
|
11
12
|
application.register("l-ui--search-form", SearchFormController)
|
|
12
13
|
application.register("l-ui--theme", ThemeController)
|
|
13
14
|
application.register("l-ui--navigation", NavigationController)
|
|
15
|
+
application.register("l-ui--navigation-section", NavigationSectionController)
|
|
14
16
|
application.register("l-ui--panel", PanelController)
|
|
15
17
|
application.register("l-ui--panel-resize", PanelResizeController)
|
|
16
18
|
application.register("l-ui--panel-button", PanelButtonController)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
let lockCount = 0
|
|
2
|
+
let savedScrollY = null
|
|
3
|
+
|
|
4
|
+
export function lockBodyScroll() {
|
|
5
|
+
if (lockCount === 0) {
|
|
6
|
+
savedScrollY = window.scrollY
|
|
7
|
+
document.body.style.top = `-${savedScrollY}px`
|
|
8
|
+
document.body.classList.add("l-ui-scroll-lock")
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
lockCount++
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function unlockBodyScroll() {
|
|
15
|
+
if (lockCount === 0) return
|
|
16
|
+
|
|
17
|
+
lockCount--
|
|
18
|
+
|
|
19
|
+
if (lockCount === 0) {
|
|
20
|
+
document.body.classList.remove("l-ui-scroll-lock")
|
|
21
|
+
document.body.style.top = ""
|
|
22
|
+
|
|
23
|
+
if (savedScrollY !== null) {
|
|
24
|
+
window.scrollTo(0, savedScrollY)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
savedScrollY = null
|
|
28
|
+
}
|
|
29
|
+
}
|
data/config/importmap.rb
CHANGED
|
@@ -3,11 +3,13 @@ pin "layered_ui", to: "layered_ui/index.js"
|
|
|
3
3
|
# Utilities
|
|
4
4
|
pin "layered_ui/utilities/announce", to: "layered_ui/utilities/announce.js"
|
|
5
5
|
pin "layered_ui/utilities/layout", to: "layered_ui/utilities/layout.js"
|
|
6
|
+
pin "layered_ui/utilities/scroll_lock", to: "layered_ui/utilities/scroll_lock.js"
|
|
6
7
|
pin "layered_ui/utilities/storage", to: "layered_ui/utilities/storage.js"
|
|
7
8
|
|
|
8
9
|
# Controllers
|
|
9
10
|
pin "layered_ui/controllers/l_ui/modal_controller", to: "layered_ui/controllers/l_ui/modal_controller.js"
|
|
10
11
|
pin "layered_ui/controllers/l_ui/navigation_controller", to: "layered_ui/controllers/l_ui/navigation_controller.js"
|
|
12
|
+
pin "layered_ui/controllers/l_ui/navigation_section_controller", to: "layered_ui/controllers/l_ui/navigation_section_controller.js"
|
|
11
13
|
pin "layered_ui/controllers/l_ui/panel_controller", to: "layered_ui/controllers/l_ui/panel_controller.js"
|
|
12
14
|
pin "layered_ui/controllers/l_ui/panel_resize_controller", to: "layered_ui/controllers/l_ui/panel_resize_controller.js"
|
|
13
15
|
pin "layered_ui/controllers/l_ui/panel_button_controller", to: "layered_ui/controllers/l_ui/panel_button_controller.js"
|
data/lib/layered/ui/engine.rb
CHANGED
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.10.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- layered.ai
|
|
@@ -196,6 +196,7 @@ files:
|
|
|
196
196
|
- app/assets/images/layered_ui/icon_github.svg
|
|
197
197
|
- app/assets/images/layered_ui/icon_globe.svg
|
|
198
198
|
- app/assets/images/layered_ui/icon_hamburger.svg
|
|
199
|
+
- app/assets/images/layered_ui/icon_home.svg
|
|
199
200
|
- app/assets/images/layered_ui/icon_light.svg
|
|
200
201
|
- app/assets/images/layered_ui/icon_linkedin.svg
|
|
201
202
|
- app/assets/images/layered_ui/icon_mail.svg
|
|
@@ -215,8 +216,10 @@ files:
|
|
|
215
216
|
- app/helpers/layered/ui/pagy_helper.rb
|
|
216
217
|
- app/helpers/layered/ui/ransack_helper.rb
|
|
217
218
|
- app/helpers/layered/ui/table_helper.rb
|
|
219
|
+
- app/helpers/layered/ui/title_bar_helper.rb
|
|
218
220
|
- app/javascript/layered_ui/controllers/l_ui/modal_controller.js
|
|
219
221
|
- app/javascript/layered_ui/controllers/l_ui/navigation_controller.js
|
|
222
|
+
- app/javascript/layered_ui/controllers/l_ui/navigation_section_controller.js
|
|
220
223
|
- app/javascript/layered_ui/controllers/l_ui/panel_button_controller.js
|
|
221
224
|
- app/javascript/layered_ui/controllers/l_ui/panel_controller.js
|
|
222
225
|
- app/javascript/layered_ui/controllers/l_ui/panel_resize_controller.js
|
|
@@ -226,6 +229,7 @@ files:
|
|
|
226
229
|
- app/javascript/layered_ui/index.js
|
|
227
230
|
- app/javascript/layered_ui/utilities/announce.js
|
|
228
231
|
- app/javascript/layered_ui/utilities/layout.js
|
|
232
|
+
- app/javascript/layered_ui/utilities/scroll_lock.js
|
|
229
233
|
- app/javascript/layered_ui/utilities/storage.js
|
|
230
234
|
- app/views/devise/confirmations/new.html.erb
|
|
231
235
|
- app/views/devise/mailer/confirmation_instructions.html.erb
|
|
@@ -271,7 +275,7 @@ metadata:
|
|
|
271
275
|
source_code_uri: https://github.com/layered-ai-public/layered-ui-rails
|
|
272
276
|
bug_tracker_uri: https://github.com/layered-ai-public/layered-ui-rails/issues
|
|
273
277
|
changelog_uri: https://github.com/layered-ai-public/layered-ui-rails/blob/main/CHANGELOG.md
|
|
274
|
-
documentation_uri: https://
|
|
278
|
+
documentation_uri: https://layered-ui-rails.layered.ai/
|
|
275
279
|
discord_uri: https://discord.gg/aCGqz9Bx
|
|
276
280
|
rubygems_mfa_required: 'true'
|
|
277
281
|
post_install_message: |
|
|
@@ -293,7 +297,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
293
297
|
requirements:
|
|
294
298
|
- - ">="
|
|
295
299
|
- !ruby/object:Gem::Version
|
|
296
|
-
version: 3.
|
|
300
|
+
version: 3.3.0
|
|
297
301
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
298
302
|
requirements:
|
|
299
303
|
- - ">="
|