Skip to content

Commit 5827eff

Browse files
0.9.0 (#61)
1 parent 2998c97 commit 5827eff

46 files changed

Lines changed: 624 additions & 171 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/skills/layered-ui-rails/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ Quick reference:
106106
| `l_ui_navigation_item(label, path, active: nil, &block)` | Sidebar nav link with optional nesting |
107107
| `l_ui_breadcrumbs(&block)` | Breadcrumb nav wrapper |
108108
| `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 |
109110
| `l_ui_pagy(pagy)` | Styled pagination (requires pagy gem) |
110111
| `l_ui_search_form(query, url:, fields:, ...)` | Search form (requires ransack gem) |
111112
| `l_ui_sort_link(query, attribute, label = nil, ...)` | Sortable table header (requires ransack gem) |

.claude/skills/layered-ui-rails/references/CONTROLLERS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Responsive sidebar navigation with backdrop overlay on mobile.
2828
**Targets:** `navigation`, `backdrop`, `toggleButton`, `openIcon`, `closeIcon`
2929
**Actions:** `toggle`, `close`
3030
**Keyboard:** Escape to close
31+
**Behaviour:** Locks body scroll while the mobile overlay is open
3132

3233
The layout wires this up automatically. Navigation items are populated via `content_for :l_ui_navigation_items`.
3334

.claude/skills/layered-ui-rails/references/CSS.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,15 @@ WCAG 2.2 AA table pattern:
170170
.l-ui-breadcrumbs__link Breadcrumb link
171171
```
172172

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+
173182
## Pagination
174183

175184
```
@@ -275,6 +284,7 @@ WCAG 2.2 AA table pattern:
275284
.l-ui-sr-only Visually hidden, screen reader only
276285
.l-ui-skip-link Accessibility skip link
277286
.l-ui-list Styled list
287+
.l-ui-hr Horizontal rule with theme border and vertical spacing
278288
.l-ui-container--grid 1-col mobile, 2-col desktop grid
279289
.l-ui-container--spread Flex row with space-between
280290
.l-ui-container--pagy Pagination wrapper

.claude/skills/layered-ui-rails/references/HELPERS.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,29 @@ l_ui_breadcrumb_item(label, path = nil)
4141
<% end %>
4242
```
4343

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+
4467
## Pagination (requires pagy gem)
4568

4669
```ruby

app/assets/tailwind/layered/ui/styles.css

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747

4848
@layer base {
4949
:root {
50+
color-scheme: light;
5051
/* Tier 1 - Accent */
5152
--accent: oklch(0.2044 0 0);
5253
--accent-foreground: oklch(1 0 0);
@@ -76,6 +77,7 @@
7677
}
7778

7879
.dark {
80+
color-scheme: dark;
7981
/* Tier 1 - Accent */
8082
--accent: oklch(1 0 0);
8183
--accent-foreground: oklch(0.2044 0 0);
@@ -294,6 +296,13 @@
294296
space-y-1;
295297
}
296298

299+
/* Horizontal rule */
300+
301+
.l-ui-hr {
302+
@apply my-4
303+
border-0 border-t border-border;
304+
}
305+
297306
/* Markdown */
298307

299308
.l-ui-markdown > *:first-child {
@@ -740,6 +749,26 @@
740749
focus-ring rounded-sm;
741750
}
742751

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+
743772
/* Buttons */
744773

745774
@utility button {
@@ -1017,7 +1046,8 @@ pre.l-ui-surface {
10171046
@apply w-6 h-6
10181047
accent-foreground
10191048
focus-ring
1020-
rounded-sm;
1049+
rounded-sm
1050+
cursor-pointer;
10211051
}
10221052

10231053
/* Search */
@@ -1124,11 +1154,12 @@ pre.l-ui-surface {
11241154
@apply w-6 h-6
11251155
mr-2
11261156
accent-foreground
1127-
focus-ring;
1157+
focus-ring
1158+
cursor-pointer;
11281159
}
11291160

11301161
.l-ui-radio__label {
1131-
@apply text-sm;
1162+
@apply text-sm cursor-pointer;
11321163
}
11331164

11341165
/* Tabs */
@@ -1659,10 +1690,11 @@ pre.l-ui-surface {
16591690
.l-ui-scroll-to-bottom {
16601691
@apply
16611692
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
16631694
rounded-full
16641695
cursor-pointer
16651696
bg-button-primary-bg text-button-primary-icon
1697+
shadow-sm
16661698
focus-ring
16671699
opacity-0 pointer-events-none
16681700
transition-opacity duration-200;
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

app/javascript/layered_ui/controllers/l_ui/navigation_controller.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import { Controller } from "@hotwired/stimulus"
22
import { announce, clearAnnounceTimeout } from "layered_ui/utilities/announce"
33
import { isMobile } from "layered_ui/utilities/layout"
4+
import { lockBodyScroll, unlockBodyScroll } from "layered_ui/utilities/scroll_lock"
45

56
export default class extends Controller {
67
static targets = ["navigation", "backdrop", "toggleButton", "openIcon", "closeIcon"]
78

89
connect() {
910
this.previousActiveElement = null
1011
this.isOpen = false
12+
this.isScrollLocked = false
1113
this._resizeFrame = null
1214
this.boundHandleResize = () => {
1315
if (this._resizeFrame) return
@@ -54,6 +56,7 @@ export default class extends Controller {
5456
this.navigationTarget.classList.add("open")
5557
this.backdropTarget.classList.add("open")
5658
this.setNavigationInteractivity(true)
59+
this.updateScrollLock()
5760

5861
// Update ARIA attributes and swap icons
5962
if (this.hasToggleButtonTarget) {
@@ -89,6 +92,7 @@ export default class extends Controller {
8992
this.navigationTarget.classList.remove("open")
9093
this.backdropTarget.classList.remove("open")
9194
this.setNavigationInteractivity(false)
95+
this.unlockScroll()
9296

9397
// Update ARIA attributes and swap icons
9498
if (this.hasToggleButtonTarget) {
@@ -116,6 +120,7 @@ export default class extends Controller {
116120
clearAnnounceTimeout(this)
117121
cancelAnimationFrame(this._resizeFrame)
118122
window.removeEventListener("resize", this.boundHandleResize)
123+
this.unlockScroll()
119124
this.previousActiveElement = null
120125
}
121126

@@ -125,6 +130,7 @@ export default class extends Controller {
125130
// In overlay mode (default), always respect isOpen state regardless of viewport
126131
if (isMobile() || !this.alwaysShow) {
127132
this.setNavigationInteractivity(this.isOpen)
133+
this.updateScrollLock()
128134
return
129135
}
130136

@@ -133,6 +139,7 @@ export default class extends Controller {
133139
this.navigationTarget.classList.remove("open")
134140
this.backdropTarget.classList.remove("open")
135141
this.setNavigationInteractivity(true)
142+
this.unlockScroll()
136143

137144
if (this.hasToggleButtonTarget) {
138145
this.toggleButtonTarget.setAttribute("aria-expanded", "false")
@@ -157,6 +164,28 @@ export default class extends Controller {
157164
this.navigationTarget.removeAttribute("aria-hidden")
158165
}
159166

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+
160189
get alwaysShow() {
161190
return this.element.classList.contains("l-ui-body--always-show-navigation")
162191
}

app/javascript/layered_ui/controllers/l_ui/panel_controller.js

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ import { Controller } from "@hotwired/stimulus"
22
import { announce, clearAnnounceTimeout } from "layered_ui/utilities/announce"
33
import { storageGet, storageSet } from "layered_ui/utilities/storage"
44
import { isMobile } from "layered_ui/utilities/layout"
5+
import { lockBodyScroll, unlockBodyScroll } from "layered_ui/utilities/scroll_lock"
56

67
export default class extends Controller {
78
static targets = ["container", "hideButton", "actionButton"]
89

910
connect() {
1011
this.previousActiveElement = null
1112
this.isOpen = false
13+
this.isScrollLocked = false
1214
this.boundKeyboardShortcut = this.handleKeyboardShortcut.bind(this)
1315
this.boundCloseOnNavigate = this.closeOnMobileNavigate.bind(this)
1416
const page = document.querySelector(".l-ui-page")
@@ -34,6 +36,7 @@ export default class extends Controller {
3436
clearAnnounceTimeout(this)
3537
document.removeEventListener('keydown', this.boundKeyboardShortcut)
3638
document.removeEventListener('turbo:visit', this.boundCloseOnNavigate)
39+
this.unlockScroll()
3740
this.previousActiveElement = null
3841
}
3942

@@ -90,9 +93,7 @@ export default class extends Controller {
9093
if (isMobile()) {
9194
const main = document.querySelector("main")
9295
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()
9697
}
9798

9899
storageSet("panelOpen", "true")
@@ -124,11 +125,7 @@ export default class extends Controller {
124125

125126
const main = document.querySelector("main")
126127
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()
132129

133130
storageSet("panelOpen", "false")
134131
this.updatePageMargin()
@@ -161,4 +158,18 @@ export default class extends Controller {
161158
page.style.marginRight = ""
162159
}
163160
}
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+
}
164175
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

0 commit comments

Comments
 (0)