Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/skills/layered-ui-rails/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ Quick reference:
| `l_ui_navigation_item(label, path, active: nil, &block)` | Sidebar nav link with optional nesting |
| `l_ui_breadcrumbs(&block)` | Breadcrumb nav wrapper |
| `l_ui_breadcrumb_item(label, path = nil)` | Individual breadcrumb |
| `l_ui_title_bar(title:, breadcrumbs: [], actions: nil, &block)` | Responsive page title bar with breadcrumbs and actions |
| `l_ui_pagy(pagy)` | Styled pagination (requires pagy gem) |
| `l_ui_search_form(query, url:, fields:, ...)` | Search form (requires ransack gem) |
| `l_ui_sort_link(query, attribute, label = nil, ...)` | Sortable table header (requires ransack gem) |
Expand Down
1 change: 1 addition & 0 deletions .claude/skills/layered-ui-rails/references/CONTROLLERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ Responsive sidebar navigation with backdrop overlay on mobile.
**Targets:** `navigation`, `backdrop`, `toggleButton`, `openIcon`, `closeIcon`
**Actions:** `toggle`, `close`
**Keyboard:** Escape to close
**Behaviour:** Locks body scroll while the mobile overlay is open

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

Expand Down
10 changes: 10 additions & 0 deletions .claude/skills/layered-ui-rails/references/CSS.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,15 @@ WCAG 2.2 AA table pattern:
.l-ui-breadcrumbs__link Breadcrumb link
```

## Title bar

```
.l-ui-title-bar Title bar wrapper used with .l-ui-container--spread
.l-ui-title-bar__content Breadcrumbs and title column
.l-ui-title-bar__title Page title
.l-ui-title-bar__actions Action area
```

## Pagination

```
Expand Down Expand Up @@ -275,6 +284,7 @@ WCAG 2.2 AA table pattern:
.l-ui-sr-only Visually hidden, screen reader only
.l-ui-skip-link Accessibility skip link
.l-ui-list Styled list
.l-ui-hr Horizontal rule with theme border and vertical spacing
.l-ui-container--grid 1-col mobile, 2-col desktop grid
.l-ui-container--spread Flex row with space-between
.l-ui-container--pagy Pagination wrapper
Expand Down
23 changes: 23 additions & 0 deletions .claude/skills/layered-ui-rails/references/HELPERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,29 @@ l_ui_breadcrumb_item(label, path = nil)
<% end %>
```

## Title bar

```ruby
l_ui_title_bar(title:, breadcrumbs: [], actions: nil, &block)
```

- `title` (String) - page title rendered as the `<h1>`
- `breadcrumbs` (Array, optional) - breadcrumb items as `[label, path]` arrays or `{ label:, path: }` hashes
- `actions` (String|Array, optional) - HTML-safe action content; omit when using a block
- `&block` - optional action markup, usually buttons or links

```erb
<%= l_ui_title_bar(
title: "Users",
breadcrumbs: [
["Home", root_path],
["Admin", admin_path]
]
) do %>
<%= link_to "New user", new_user_path, class: "l-ui-button--primary" %>
<% end %>
```

## Pagination (requires pagy gem)

```ruby
Expand Down
40 changes: 36 additions & 4 deletions app/assets/tailwind/layered/ui/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@

@layer base {
:root {
color-scheme: light;
/* Tier 1 - Accent */
--accent: oklch(0.2044 0 0);
--accent-foreground: oklch(1 0 0);
Expand Down Expand Up @@ -76,6 +77,7 @@
}

.dark {
color-scheme: dark;
/* Tier 1 - Accent */
--accent: oklch(1 0 0);
--accent-foreground: oklch(0.2044 0 0);
Expand Down Expand Up @@ -294,6 +296,13 @@
space-y-1;
}

/* Horizontal rule */

.l-ui-hr {
@apply my-4
border-0 border-t border-border;
}

/* Markdown */

.l-ui-markdown > *:first-child {
Expand Down Expand Up @@ -740,6 +749,26 @@
focus-ring rounded-sm;
}

/* Title bar */

.l-ui-title-bar {
@apply w-full;
}

.l-ui-title-bar__content {
@apply min-w-0;
}

.l-ui-title-bar__title {
@apply mt-0;
}

.l-ui-title-bar__actions {
@apply flex flex-wrap items-center justify-end
gap-2
shrink-0;
}

/* Buttons */

@utility button {
Expand Down Expand Up @@ -1017,7 +1046,8 @@ pre.l-ui-surface {
@apply w-6 h-6
accent-foreground
focus-ring
rounded-sm;
rounded-sm
cursor-pointer;
}

/* Search */
Expand Down Expand Up @@ -1124,11 +1154,12 @@ pre.l-ui-surface {
@apply w-6 h-6
mr-2
accent-foreground
focus-ring;
focus-ring
cursor-pointer;
}

.l-ui-radio__label {
@apply text-sm;
@apply text-sm cursor-pointer;
}

/* Tabs */
Expand Down Expand Up @@ -1659,10 +1690,11 @@ pre.l-ui-surface {
.l-ui-scroll-to-bottom {
@apply
sticky bottom-2 flex items-center justify-center
ml-auto mr-0 -mt-9 h-9 w-9
mx-auto -mt-9 h-9 w-9
rounded-full
cursor-pointer
bg-button-primary-bg text-button-primary-icon
shadow-sm
focus-ring
opacity-0 pointer-events-none
transition-opacity duration-200;
Expand Down
52 changes: 52 additions & 0 deletions app/helpers/layered/ui/title_bar_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
module Layered
module Ui
module TitleBarHelper
include Layered::Ui::BreadcrumbsHelper

def l_ui_title_bar(title:, breadcrumbs: [], actions: nil, &block)
action_content = block_given? ? capture(&block) : actions

content_tag(:header, class: "l-ui-title-bar l-ui-container--spread") do
safe_join([
content_tag(:div, class: "l-ui-title-bar__content") do
safe_join([
l_ui_title_bar_breadcrumbs(breadcrumbs),
content_tag(:h1, title, class: "l-ui-title-bar__title")
].compact)
end,
l_ui_title_bar_actions(action_content)
].compact)
end
end

private

def l_ui_title_bar_breadcrumbs(breadcrumbs)
return if breadcrumbs.blank?

l_ui_breadcrumbs do
safe_join(breadcrumbs.map { |breadcrumb| l_ui_title_bar_breadcrumb_item(breadcrumb) })
end
end

def l_ui_title_bar_breadcrumb_item(breadcrumb)
case breadcrumb
when Hash
l_ui_breadcrumb_item(breadcrumb.fetch(:label), breadcrumb[:path])
when Array
l_ui_breadcrumb_item(breadcrumb[0], breadcrumb[1])
else
l_ui_breadcrumb_item(breadcrumb)
end
end

def l_ui_title_bar_actions(action_content)
return if action_content.blank?

content = action_content.is_a?(Array) ? safe_join(action_content) : action_content

content_tag(:div, content, class: "l-ui-title-bar__actions")
end
end
end
end
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { Controller } from "@hotwired/stimulus"
import { announce, clearAnnounceTimeout } from "layered_ui/utilities/announce"
import { isMobile } from "layered_ui/utilities/layout"
import { lockBodyScroll, unlockBodyScroll } from "layered_ui/utilities/scroll_lock"

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

connect() {
this.previousActiveElement = null
this.isOpen = false
this.isScrollLocked = false
this._resizeFrame = null
this.boundHandleResize = () => {
if (this._resizeFrame) return
Expand Down Expand Up @@ -54,6 +56,7 @@ export default class extends Controller {
this.navigationTarget.classList.add("open")
this.backdropTarget.classList.add("open")
this.setNavigationInteractivity(true)
this.updateScrollLock()

// Update ARIA attributes and swap icons
if (this.hasToggleButtonTarget) {
Expand Down Expand Up @@ -89,6 +92,7 @@ export default class extends Controller {
this.navigationTarget.classList.remove("open")
this.backdropTarget.classList.remove("open")
this.setNavigationInteractivity(false)
this.unlockScroll()

// Update ARIA attributes and swap icons
if (this.hasToggleButtonTarget) {
Expand Down Expand Up @@ -116,6 +120,7 @@ export default class extends Controller {
clearAnnounceTimeout(this)
cancelAnimationFrame(this._resizeFrame)
window.removeEventListener("resize", this.boundHandleResize)
this.unlockScroll()
this.previousActiveElement = null
}

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

Expand All @@ -133,6 +139,7 @@ export default class extends Controller {
this.navigationTarget.classList.remove("open")
this.backdropTarget.classList.remove("open")
this.setNavigationInteractivity(true)
this.unlockScroll()

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

updateScrollLock() {
if (this.isOpen && isMobile()) {
this.lockScroll()
} else {
this.unlockScroll()
}
}

lockScroll() {
if (this.isScrollLocked) return

lockBodyScroll()
this.isScrollLocked = true
}

unlockScroll() {
if (!this.isScrollLocked) return

unlockBodyScroll()
this.isScrollLocked = false
}

get alwaysShow() {
return this.element.classList.contains("l-ui-body--always-show-navigation")
}
Expand Down
27 changes: 19 additions & 8 deletions app/javascript/layered_ui/controllers/l_ui/panel_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import { Controller } from "@hotwired/stimulus"
import { announce, clearAnnounceTimeout } from "layered_ui/utilities/announce"
import { storageGet, storageSet } from "layered_ui/utilities/storage"
import { isMobile } from "layered_ui/utilities/layout"
import { lockBodyScroll, unlockBodyScroll } from "layered_ui/utilities/scroll_lock"

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

connect() {
this.previousActiveElement = null
this.isOpen = false
this.isScrollLocked = false
this.boundKeyboardShortcut = this.handleKeyboardShortcut.bind(this)
this.boundCloseOnNavigate = this.closeOnMobileNavigate.bind(this)
const page = document.querySelector(".l-ui-page")
Expand All @@ -34,6 +36,7 @@ export default class extends Controller {
clearAnnounceTimeout(this)
document.removeEventListener('keydown', this.boundKeyboardShortcut)
document.removeEventListener('turbo:visit', this.boundCloseOnNavigate)
this.unlockScroll()
this.previousActiveElement = null
}

Expand Down Expand Up @@ -90,9 +93,7 @@ export default class extends Controller {
if (isMobile()) {
const main = document.querySelector("main")
if (main) main.setAttribute("inert", "")
this.savedScrollY = window.scrollY
document.body.style.top = `-${this.savedScrollY}px`
document.body.classList.add("l-ui-scroll-lock")
this.lockScroll()
}

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

const main = document.querySelector("main")
if (main) main.removeAttribute("inert")
document.body.classList.remove("l-ui-scroll-lock")
document.body.style.top = ""
if (this.savedScrollY !== undefined) {
window.scrollTo(0, this.savedScrollY)
}
this.unlockScroll()

storageSet("panelOpen", "false")
this.updatePageMargin()
Expand Down Expand Up @@ -161,4 +158,18 @@ export default class extends Controller {
page.style.marginRight = ""
}
}

lockScroll() {
if (this.isScrollLocked) return

lockBodyScroll()
this.isScrollLocked = true
}

unlockScroll() {
if (!this.isScrollLocked) return

unlockBodyScroll()
this.isScrollLocked = false
}
}
29 changes: 29 additions & 0 deletions app/javascript/layered_ui/utilities/scroll_lock.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
let lockCount = 0
let savedScrollY = null

export function lockBodyScroll() {
if (lockCount === 0) {
savedScrollY = window.scrollY
document.body.style.top = `-${savedScrollY}px`
document.body.classList.add("l-ui-scroll-lock")
}

lockCount++
}

export function unlockBodyScroll() {
if (lockCount === 0) return

lockCount--

if (lockCount === 0) {
document.body.classList.remove("l-ui-scroll-lock")
document.body.style.top = ""

if (savedScrollY !== null) {
window.scrollTo(0, savedScrollY)
}

savedScrollY = null
}
}
Loading
Loading