layered-ui-rails 0.8.0 → 0.9.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: d5abac205810e228d971ffa9c13f2c069c56be3f143c0f25612f02c2d8b6e598
4
- data.tar.gz: 3dbb48d42029cce15cb92dcbd6cc014046032cb7449f145e63854b4898936d14
3
+ metadata.gz: ff66e126597c535133ec15b8fa13fd0efeecfe319b9a60d383b051ce111234e1
4
+ data.tar.gz: 25872e652c050f87c8752a43b8f9184ee3f98f888a7b30086efa4531c329ed1a
5
5
  SHA512:
6
- metadata.gz: e39b4bbc08bb0f198320dccf181de35003e0ab2927eabe3666e0f39743e652a3e9ca45dc295d40b3ce22e1a85381392d86bd202dda9d905f1b500f2c837fdb6d
7
- data.tar.gz: 8efb4d5a510c8ef52541b0d9f1286e13dce5b8d64c90d7f984e4d4eccfc7153f91eafa83c5beadf54911b2a8a405e5b8c54383ea3884dc2ed6c0169c70f283ca
6
+ metadata.gz: 22e3a324fb57843766fc6b243071b305508d88dc8ca00dd428cce23eebb7f965cab8be974fcaeef365e74ba142bff59d28f2b8b9680ff50ab3853c94d1806079
7
+ data.tar.gz: d0e60d24e524aad6516e2e070db67e31d7eb049d63334f36d99463d2cd2cb392ce8e863070f1952d5387096f5bc5ec06c172346ba57c3fe2b4a78ceeb6cbe4fc
@@ -106,6 +106,7 @@ Quick reference:
106
106
  | `l_ui_navigation_item(label, path, active: nil, &block)` | Sidebar nav link with optional nesting |
107
107
  | `l_ui_breadcrumbs(&block)` | Breadcrumb nav wrapper |
108
108
  | `l_ui_breadcrumb_item(label, path = nil)` | Individual breadcrumb |
109
+ | `l_ui_title_bar(title:, breadcrumbs: [], actions: nil, &block)` | Responsive page title bar with breadcrumbs and actions |
109
110
  | `l_ui_pagy(pagy)` | Styled pagination (requires pagy gem) |
110
111
  | `l_ui_search_form(query, url:, fields:, ...)` | Search form (requires ransack gem) |
111
112
  | `l_ui_sort_link(query, attribute, label = nil, ...)` | Sortable table header (requires ransack gem) |
@@ -28,6 +28,7 @@ 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
 
@@ -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
  ```
@@ -275,6 +284,7 @@ WCAG 2.2 AA table pattern:
275
284
  .l-ui-sr-only Visually hidden, screen reader only
276
285
  .l-ui-skip-link Accessibility skip link
277
286
  .l-ui-list Styled list
287
+ .l-ui-hr Horizontal rule with theme border and vertical spacing
278
288
  .l-ui-container--grid 1-col mobile, 2-col desktop grid
279
289
  .l-ui-container--spread Flex row with space-between
280
290
  .l-ui-container--pagy Pagination wrapper
@@ -41,6 +41,29 @@ l_ui_breadcrumb_item(label, path = nil)
41
41
  <% end %>
42
42
  ```
43
43
 
44
+ ## Title bar
45
+
46
+ ```ruby
47
+ l_ui_title_bar(title:, breadcrumbs: [], actions: nil, &block)
48
+ ```
49
+
50
+ - `title` (String) - page title rendered as the `<h1>`
51
+ - `breadcrumbs` (Array, optional) - breadcrumb items as `[label, path]` arrays or `{ label:, path: }` hashes
52
+ - `actions` (String|Array, optional) - HTML-safe action content; omit when using a block
53
+ - `&block` - optional action markup, usually buttons or links
54
+
55
+ ```erb
56
+ <%= l_ui_title_bar(
57
+ title: "Users",
58
+ breadcrumbs: [
59
+ ["Home", root_path],
60
+ ["Admin", admin_path]
61
+ ]
62
+ ) do %>
63
+ <%= link_to "New user", new_user_path, class: "l-ui-button--primary" %>
64
+ <% end %>
65
+ ```
66
+
44
67
  ## Pagination (requires pagy gem)
45
68
 
46
69
  ```ruby
data/CHANGELOG.md CHANGED
@@ -2,7 +2,20 @@
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
- ## [Unreleased]
5
+ ## [0.9.0] - 2026-04-26
6
+
7
+ ### Added
8
+
9
+ - `l_ui_title_bar` helper for page title bars with optional actions
10
+ - `l-ui-hr` class for styled horizontal rules
11
+ - `color-scheme` CSS property set to match the active theme so native form controls (date pickers, scrollbars, etc.) render with matching light/dark styling
12
+
13
+ ### Changed
14
+
15
+ - Navigation and panel controllers share an extracted `scroll_lock` utility module
16
+ - Checkbox and radio inputs use a pointer cursor
17
+ - Scroll-to-bottom button is centred and has a faint shadow
18
+ - Form submit label shortened from "Save changes" to "Save"
6
19
 
7
20
  ## [0.8.0] - 2026-04-24
8
21
 
@@ -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 {
@@ -740,6 +749,26 @@
740
749
  focus-ring rounded-sm;
741
750
  }
742
751
 
752
+ /* Title bar */
753
+
754
+ .l-ui-title-bar {
755
+ @apply w-full;
756
+ }
757
+
758
+ .l-ui-title-bar__content {
759
+ @apply min-w-0;
760
+ }
761
+
762
+ .l-ui-title-bar__title {
763
+ @apply mt-0;
764
+ }
765
+
766
+ .l-ui-title-bar__actions {
767
+ @apply flex flex-wrap items-center justify-end
768
+ gap-2
769
+ shrink-0;
770
+ }
771
+
743
772
  /* Buttons */
744
773
 
745
774
  @utility button {
@@ -1017,7 +1046,8 @@ pre.l-ui-surface {
1017
1046
  @apply w-6 h-6
1018
1047
  accent-foreground
1019
1048
  focus-ring
1020
- rounded-sm;
1049
+ rounded-sm
1050
+ cursor-pointer;
1021
1051
  }
1022
1052
 
1023
1053
  /* Search */
@@ -1124,11 +1154,12 @@ pre.l-ui-surface {
1124
1154
  @apply w-6 h-6
1125
1155
  mr-2
1126
1156
  accent-foreground
1127
- focus-ring;
1157
+ focus-ring
1158
+ cursor-pointer;
1128
1159
  }
1129
1160
 
1130
1161
  .l-ui-radio__label {
1131
- @apply text-sm;
1162
+ @apply text-sm cursor-pointer;
1132
1163
  }
1133
1164
 
1134
1165
  /* Tabs */
@@ -1659,10 +1690,11 @@ pre.l-ui-surface {
1659
1690
  .l-ui-scroll-to-bottom {
1660
1691
  @apply
1661
1692
  sticky bottom-2 flex items-center justify-center
1662
- ml-auto mr-0 -mt-9 h-9 w-9
1693
+ mx-auto -mt-9 h-9 w-9
1663
1694
  rounded-full
1664
1695
  cursor-pointer
1665
1696
  bg-button-primary-bg text-button-primary-icon
1697
+ shadow-sm
1666
1698
  focus-ring
1667
1699
  opacity-0 pointer-events-none
1668
1700
  transition-opacity duration-200;
@@ -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
  }
@@ -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.savedScrollY = window.scrollY
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
- document.body.classList.remove("l-ui-scroll-lock")
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
  }
@@ -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
+ }
@@ -10,7 +10,7 @@
10
10
  <% end %>
11
11
 
12
12
  <div class="l-ui-form__actions">
13
- <%= f.submit(record.new_record? ? "Create" : "Save changes",
13
+ <%= f.submit(record.new_record? ? "Create" : "Save",
14
14
  class: "l-ui-button l-ui-button--primary") %>
15
15
  </div>
16
16
  <% end %>
data/config/importmap.rb CHANGED
@@ -3,6 +3,7 @@ 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
@@ -31,6 +31,7 @@ module Layered
31
31
  helper Layered::Ui::NavigationHelper
32
32
  helper Layered::Ui::PagyHelper
33
33
  helper Layered::Ui::TableHelper
34
+ helper Layered::Ui::TitleBarHelper
34
35
  helper Layered::Ui::FormHelper
35
36
  helper Layered::Ui::RansackHelper
36
37
  end
@@ -1,5 +1,5 @@
1
1
  module Layered
2
2
  module Ui
3
- VERSION = "0.8.0"
3
+ VERSION = "0.9.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.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - layered.ai
@@ -215,6 +215,7 @@ files:
215
215
  - app/helpers/layered/ui/pagy_helper.rb
216
216
  - app/helpers/layered/ui/ransack_helper.rb
217
217
  - app/helpers/layered/ui/table_helper.rb
218
+ - app/helpers/layered/ui/title_bar_helper.rb
218
219
  - app/javascript/layered_ui/controllers/l_ui/modal_controller.js
219
220
  - app/javascript/layered_ui/controllers/l_ui/navigation_controller.js
220
221
  - app/javascript/layered_ui/controllers/l_ui/panel_button_controller.js
@@ -226,6 +227,7 @@ files:
226
227
  - app/javascript/layered_ui/index.js
227
228
  - app/javascript/layered_ui/utilities/announce.js
228
229
  - app/javascript/layered_ui/utilities/layout.js
230
+ - app/javascript/layered_ui/utilities/scroll_lock.js
229
231
  - app/javascript/layered_ui/utilities/storage.js
230
232
  - app/views/devise/confirmations/new.html.erb
231
233
  - app/views/devise/mailer/confirmation_instructions.html.erb