layered-ui-rails 0.9.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 +3 -1
- data/.claude/skills/layered-ui-rails/references/CONTROLLERS.md +9 -0
- data/.claude/skills/layered-ui-rails/references/CSS.md +12 -2
- data/.claude/skills/layered-ui-rails/references/HELPERS.md +29 -7
- data/CHANGELOG.md +12 -0
- data/README.md +36 -36
- data/app/assets/images/layered_ui/icon_home.svg +3 -0
- data/app/assets/tailwind/layered/ui/styles.css +77 -31
- data/app/helpers/layered/ui/navigation_helper.rb +118 -15
- data/app/helpers/layered/ui/table_helper.rb +12 -2
- data/app/javascript/layered_ui/controllers/l_ui/navigation_section_controller.js +48 -0
- data/app/javascript/layered_ui/index.js +2 -0
- data/config/importmap.rb +1 -0
- data/lib/layered/ui/version.rb +1 -1
- metadata +5 -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,7 +103,8 @@ 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 |
|
|
109
110
|
| `l_ui_title_bar(title:, breadcrumbs: [], actions: nil, &block)` | Responsive page title bar with breadcrumbs and actions |
|
|
@@ -142,6 +143,7 @@ All controllers use the `l-ui--` namespace and are auto-registered via importmap
|
|
|
142
143
|
|---|---|---|
|
|
143
144
|
| Theme | `l-ui--theme` | Dark/light mode toggle with localStorage |
|
|
144
145
|
| Navigation | `l-ui--navigation` | Responsive sidebar with backdrop |
|
|
146
|
+
| Navigation section | `l-ui--navigation-section` | Collapsible nav section with localStorage persistence |
|
|
145
147
|
| Panel | `l-ui--panel` | Resizable side panel (Cmd/Ctrl+I toggle) |
|
|
146
148
|
| Panel button | `l-ui--panel-button` | Draggable floating action button |
|
|
147
149
|
| Panel resize | `l-ui--panel-resize` | Panel width drag handle |
|
|
@@ -32,6 +32,15 @@ Responsive sidebar navigation with backdrop overlay on mobile.
|
|
|
32
32
|
|
|
33
33
|
The layout wires this up automatically. Navigation items are populated via `content_for :l_ui_navigation_items`.
|
|
34
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
|
+
|
|
35
44
|
## Modal (`l-ui--modal`)
|
|
36
45
|
|
|
37
46
|
Native `<dialog>` wrapper with focus trap, scroll lock, and focus restoration.
|
|
@@ -199,8 +199,18 @@ WCAG 2.2 AA table pattern:
|
|
|
199
199
|
.l-ui-navigation Nav flexbox
|
|
200
200
|
.l-ui-navigation__links Nav links list
|
|
201
201
|
.l-ui-navigation__item Nav item
|
|
202
|
-
.l-ui-navigation__item--active Active nav item (
|
|
203
|
-
.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)
|
|
204
214
|
.l-ui-navigation__user User info section
|
|
205
215
|
.l-ui-navigation__user-name User name text
|
|
206
216
|
.l-ui-navigation__user-email User email text
|
|
@@ -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 %>
|
|
@@ -147,7 +167,8 @@ Returns a `<th>` element with sort link and ARIA sort attributes.
|
|
|
147
167
|
|
|
148
168
|
```ruby
|
|
149
169
|
l_ui_table(records, columns:, caption: nil, actions: nil,
|
|
150
|
-
actions_label: "Actions", query: nil, url: nil,
|
|
170
|
+
actions_label: "Actions", query: nil, url: nil,
|
|
171
|
+
turbo_frame: nil, row_id: nil)
|
|
151
172
|
```
|
|
152
173
|
|
|
153
174
|
- `records` (ActiveRecord::Relation or Array) - the collection to render
|
|
@@ -158,6 +179,7 @@ l_ui_table(records, columns:, caption: nil, actions: nil,
|
|
|
158
179
|
- `query` (Ransack::Search, optional) - enables sortable column headers
|
|
159
180
|
- `url` (String, optional) - sort link URL (passed to `l_ui_sort_link`)
|
|
160
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.
|
|
161
183
|
|
|
162
184
|
Column options:
|
|
163
185
|
- `attribute` (Symbol) - used for label generation and sort links
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
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.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
|
+
|
|
5
17
|
## [0.9.0] - 2026-04-26
|
|
6
18
|
|
|
7
19
|
### Added
|
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>
|
|
@@ -644,8 +644,8 @@
|
|
|
644
644
|
}
|
|
645
645
|
|
|
646
646
|
@utility navigation__item {
|
|
647
|
-
@apply flex items-center
|
|
648
|
-
w-full min-h-[
|
|
647
|
+
@apply flex items-center gap-3
|
|
648
|
+
w-full min-h-[40px] px-3
|
|
649
649
|
text-sm text-foreground-muted font-medium
|
|
650
650
|
rounded-sm
|
|
651
651
|
focus-ring
|
|
@@ -653,50 +653,96 @@
|
|
|
653
653
|
}
|
|
654
654
|
|
|
655
655
|
.l-ui-navigation__item {
|
|
656
|
-
@apply navigation__item
|
|
656
|
+
@apply navigation__item;
|
|
657
657
|
}
|
|
658
658
|
|
|
659
659
|
.l-ui-navigation__item--active {
|
|
660
660
|
@apply navigation__item
|
|
661
|
-
|
|
662
|
-
text-
|
|
661
|
+
bg-surface-highlighted
|
|
662
|
+
text-foreground;
|
|
663
|
+
}
|
|
663
664
|
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
}
|
|
665
|
+
.l-ui-navigation__item-icon {
|
|
666
|
+
@apply w-4 h-4 shrink-0
|
|
667
|
+
dark:invert;
|
|
668
|
+
}
|
|
669
669
|
|
|
670
|
-
|
|
671
|
-
|
|
670
|
+
.l-ui-navigation__item-icon-slot {
|
|
671
|
+
@apply inline-flex items-center justify-center
|
|
672
|
+
w-4 h-4 shrink-0;
|
|
673
|
+
}
|
|
672
674
|
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
+
}
|
|
685
|
+
|
|
686
|
+
& + .l-ui-navigation__section--separated {
|
|
687
|
+
@apply mt-3 pt-3
|
|
688
|
+
border-t border-border;
|
|
676
689
|
}
|
|
677
690
|
}
|
|
678
691
|
|
|
679
|
-
.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 {
|
|
680
729
|
@apply flex flex-col
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
@apply ml-auto
|
|
692
|
-
text-2xl font-bold;
|
|
693
|
-
}
|
|
694
|
-
}
|
|
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;
|
|
695
740
|
}
|
|
696
741
|
|
|
697
742
|
.l-ui-navigation__links {
|
|
698
743
|
@apply flex flex-col flex-1
|
|
699
|
-
|
|
744
|
+
gap-0.5
|
|
745
|
+
px-3 py-3 min-h-0
|
|
700
746
|
overflow-y-auto;
|
|
701
747
|
}
|
|
702
748
|
|
|
@@ -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,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
|
+
}
|
|
@@ -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)
|
data/config/importmap.rb
CHANGED
|
@@ -9,6 +9,7 @@ pin "layered_ui/utilities/storage", to: "layered_ui/utilities/storage.js"
|
|
|
9
9
|
# Controllers
|
|
10
10
|
pin "layered_ui/controllers/l_ui/modal_controller", to: "layered_ui/controllers/l_ui/modal_controller.js"
|
|
11
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"
|
|
12
13
|
pin "layered_ui/controllers/l_ui/panel_controller", to: "layered_ui/controllers/l_ui/panel_controller.js"
|
|
13
14
|
pin "layered_ui/controllers/l_ui/panel_resize_controller", to: "layered_ui/controllers/l_ui/panel_resize_controller.js"
|
|
14
15
|
pin "layered_ui/controllers/l_ui/panel_button_controller", to: "layered_ui/controllers/l_ui/panel_button_controller.js"
|
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
|
|
@@ -218,6 +219,7 @@ files:
|
|
|
218
219
|
- app/helpers/layered/ui/title_bar_helper.rb
|
|
219
220
|
- app/javascript/layered_ui/controllers/l_ui/modal_controller.js
|
|
220
221
|
- app/javascript/layered_ui/controllers/l_ui/navigation_controller.js
|
|
222
|
+
- app/javascript/layered_ui/controllers/l_ui/navigation_section_controller.js
|
|
221
223
|
- app/javascript/layered_ui/controllers/l_ui/panel_button_controller.js
|
|
222
224
|
- app/javascript/layered_ui/controllers/l_ui/panel_controller.js
|
|
223
225
|
- app/javascript/layered_ui/controllers/l_ui/panel_resize_controller.js
|
|
@@ -273,7 +275,7 @@ metadata:
|
|
|
273
275
|
source_code_uri: https://github.com/layered-ai-public/layered-ui-rails
|
|
274
276
|
bug_tracker_uri: https://github.com/layered-ai-public/layered-ui-rails/issues
|
|
275
277
|
changelog_uri: https://github.com/layered-ai-public/layered-ui-rails/blob/main/CHANGELOG.md
|
|
276
|
-
documentation_uri: https://
|
|
278
|
+
documentation_uri: https://layered-ui-rails.layered.ai/
|
|
277
279
|
discord_uri: https://discord.gg/aCGqz9Bx
|
|
278
280
|
rubygems_mfa_required: 'true'
|
|
279
281
|
post_install_message: |
|
|
@@ -295,7 +297,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
295
297
|
requirements:
|
|
296
298
|
- - ">="
|
|
297
299
|
- !ruby/object:Gem::Version
|
|
298
|
-
version: 3.
|
|
300
|
+
version: 3.3.0
|
|
299
301
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
300
302
|
requirements:
|
|
301
303
|
- - ">="
|