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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ff66e126597c535133ec15b8fa13fd0efeecfe319b9a60d383b051ce111234e1
4
- data.tar.gz: 25872e652c050f87c8752a43b8f9184ee3f98f888a7b30086efa4531c329ed1a
3
+ metadata.gz: b9fe544e3cfcfc260bba58a205ada774ffcb1154b48b86fb406d25c189da5b77
4
+ data.tar.gz: 8d66cf1556700d9c26867ab59aa2d989bfd17f924ea1b7ec7be9cc3feb4626c5
5
5
  SHA512:
6
- metadata.gz: 22e3a324fb57843766fc6b243071b305508d88dc8ca00dd428cce23eebb7f965cab8be974fcaeef365e74ba142bff59d28f2b8b9680ff50ab3853c94d1806079
7
- data.tar.gz: d0e60d24e524aad6516e2e070db67e31d7eb049d63334f36d99463d2cd2cb392ce8e863070f1952d5387096f5bc5ec06c172346ba57c3fe2b4a78ceeb6cbe4fc
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, active: nil, &block)` | Sidebar nav link with optional nesting |
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 (with arrow)
203
- .l-ui-navigation__secondary Nested nav list
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, &block)
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 state; defaults to `current_page?` check
14
- - `&block` - optional nested navigation items
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
- <%= l_ui_navigation_item("Home", root_path) %>
19
- <%= l_ui_navigation_item("Products", products_path, active: true) do %>
20
- <%= l_ui_navigation_item("Electronics", electronics_path) %>
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, turbo_frame: 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
- ## Getting started
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
- ```erb
51
- <% content_for :l_ui_body_class, "l-ui-body--always-show-navigation" %>
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
- <% content_for :l_ui_navigation_items do %>
54
- <%= l_ui_navigation_item "Home", root_path %>
55
- <% end %>
44
+ ## Requirements
56
45
 
57
- <%= render template: "layouts/layered_ui/application" %>
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
- ## Requirements
69
+ ## Installation
79
70
 
80
- - Ruby on Rails >= 8.0
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
- ## Features
73
+ ```bash
74
+ bundle add layered-ui-rails
75
+ bin/rails generate layered:ui:install
76
+ ```
86
77
 
87
- - **Dark/light theme** - system preference detection with localStorage persistence and manual toggle
88
- - **Responsive layout** - header, sidebar navigation, main content area, and optional resizable panel
89
- - **WCAG 2.2 AA compliant** - skip links, focus indicators, ARIA attributes, and 4.5:1 contrast ratios
90
- - **Components** - buttons, forms, surfaces, tables, tabs, notices, badges, conversations, modals, and pagination
91
- - **Optional integrations** - Devise authentication and Pagy pagination with styled views
92
- - **Customisable branding** - Override the default logos and icons and colors
93
- - **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
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-[44px]
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
- font-bold
662
- text-accent;
661
+ bg-surface-highlighted
662
+ text-foreground;
663
+ }
663
664
 
664
- &::after {
665
- content: "▸";
666
- @apply ml-auto
667
- text-2xl font-bold;
668
- }
665
+ .l-ui-navigation__item-icon {
666
+ @apply w-4 h-4 shrink-0
667
+ dark:invert;
668
+ }
669
669
 
670
- &:has(~ .l-ui-navigation__secondary .l-ui-navigation__item--active) {
671
- @apply pr-3;
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
- &::after {
674
- content: none;
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-navigation__secondary {
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
- pl-4
682
- border-l-4 border-border;
683
-
684
- .l-ui-navigation__item--active {
685
- @apply bg-transparent
686
- font-bold
687
- text-accent;
688
-
689
- &::after {
690
- content: "▸";
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
- px-4 py-2 min-h-0
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, &block)
5
- if block_given?
6
- children = capture(&block)
7
- is_active = active.nil? ? (current_page?(path) || children.include?("l-ui-navigation__item--active")) : active
8
- css_class = is_active ? "l-ui-navigation__item--active" : "l-ui-navigation__item"
9
- options = { class: css_class }
10
- options["aria-current"] = "page" if is_active && current_page?(path)
11
- content_tag(:li) do
12
- link_to(label, path, options) +
13
- content_tag(:ul, children, class: "l-ui-navigation__secondary", role: "list")
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
- is_active = active.nil? ? current_page?(path) : active
17
- css_class = is_active ? "l-ui-navigation__item--active" : "l-ui-navigation__item"
18
- options = { class: css_class }
19
- options["aria-current"] = "page" if is_active
20
- content_tag(:li) { link_to label, path, options }
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
- def l_ui_table(records, columns:, caption: nil, actions: nil, actions_label: "Actions", query: nil, url: nil, turbo_frame: nil)
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"
@@ -1,5 +1,5 @@
1
1
  module Layered
2
2
  module Ui
3
- VERSION = "0.9.0"
3
+ VERSION = "0.10.0"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: layered-ui-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.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://www.layered.ai
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.2.0
300
+ version: 3.3.0
299
301
  required_rubygems_version: !ruby/object:Gem::Requirement
300
302
  requirements:
301
303
  - - ">="