Add app bars, toolbars, tabs and the account and theme controls
tests / browser (chrome, chromium) (push) Successful in 3m34s
tests / browser (safari, webkit) (push) Successful in 6m2s
tests / lint (push) Successful in 59s
tests / feature (8.4) (push) Successful in 1m6s
tests / feature (8.5) (push) Successful in 1m9s
tests / browser (firefox, firefox) (push) Successful in 4m20s

M3 Expressive top app bars (small, centered, medium and large flexible,
search) that collapse with CSS sticky, docked and floating toolbars,
primary and secondary tabs with a view-transition indicator, section
navigation, the account menu and theme toggles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
This commit is contained in:
Andreas Reinhold / reini
2026-09-13 08:26:54 +02:00
co-authored by Claude Opus 5
parent bf00e4c40a
commit f90695cedd
22 changed files with 1603 additions and 1 deletions
+60
View File
@@ -0,0 +1,60 @@
/**
* `materialAppBar`: says when content is under `<x-app-bar>` (`scrolled`) and, for a medium or
* large bar, when it has collapsed to its row (`collapsed`). The collapsing itself is CSS
* (resources/css/components/app-bar.css); this only reads the bar's position and height, once per
* frame while the page scrolls or the bar resizes — its height, because a long title wraps and the
* bar must stick where exactly its row is left showing.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('materialAppBar', () => ({
scrolled: false,
collapsed: false,
height: null,
frame: null,
init() {
this.measure = this.measure.bind(this)
this.schedule = () => {
this.frame ??= requestAnimationFrame(this.measure)
}
this.resizes = new ResizeObserver(this.schedule)
this.resizes.observe(this.$root)
window.addEventListener('scroll', this.schedule, { passive: true })
this.measure()
},
destroy() {
this.resizes.disconnect()
window.removeEventListener('scroll', this.schedule)
cancelAnimationFrame(this.frame)
},
// Bound as the bar's style, so a Livewire morph keeps it.
get measured() {
return this.height === null ? {} : { '--app-bar-measured': `${this.height}px` }
},
measure() {
this.frame = null
const bar = this.$root
this.height = bar.offsetHeight
const style = getComputedStyle(bar)
if (style.position !== 'sticky') {
this.scrolled = this.collapsed = false
return
}
const top = bar.getBoundingClientRect().top
const stuckAt = parseFloat(style.top) || 0
const stuck = window.scrollY > 0 && top <= stuckAt + 0.5
this.collapsed = stuck && stuckAt < 0
this.scrolled = stuck
},
}))
})
+3
View File
@@ -21,3 +21,6 @@ import './chips.js'
import './field.js'
import './search.js'
import './slider.js'
import './tabs.js'
import './app-bar.js'
import './toolbar.js'
+56
View File
@@ -0,0 +1,56 @@
/**
* `materialTabs`: the behaviour of `<x-tabs>` — WAI-ARIA's tabs with automatic activation.
*
* The arrow keys move along the tablist (mirrored on a right-to-left page), Home and End jump to
* its ends, disabled tabs are passed over, focus follows the choice, and only the chosen tab is in
* the Tab order. A change of tab runs in a view transition, which moves the active indicator from
* the old tab to the new one and cross-fades the panels — unless the visitor prefers reduced motion
* or the browser has no view transitions, when it simply changes.
*/
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)')
document.addEventListener('alpine:init', () => {
window.Alpine.data('materialTabs', () => ({
tabs() {
return [...this.$refs.bar.querySelectorAll('[role="tab"]:not(:disabled)')]
},
choose(name, focus = false) {
const apply = () => {
this.selected = name
return this.$nextTick()
}
if (this.selected === name || reducedMotion.matches || !document.startViewTransition) {
apply()
} else {
document.startViewTransition(apply)
}
if (focus) {
this.$refs.bar.querySelector(`[data-tab="${CSS.escape(name)}"]`)?.focus()
}
},
step(by) {
const tabs = this.tabs()
const rtl = getComputedStyle(this.$refs.bar).direction === 'rtl'
const at = tabs.findIndex((tab) => tab.dataset.tab === this.selected)
const next = tabs[(at + (rtl ? -by : by) + tabs.length) % tabs.length]
if (next) {
this.choose(next.dataset.tab, true)
}
},
edge(last) {
const tabs = this.tabs()
const tab = last ? tabs.at(-1) : tabs[0]
if (tab) {
this.choose(tab.dataset.tab, true)
}
},
}))
})
+39
View File
@@ -0,0 +1,39 @@
/**
* `materialToolbar`: the arrow keys move focus between the controls of an `<x-toolbar>`, as
* WAI-ARIA's toolbar pattern has them (left and right, or up and down when it is vertical; mirrored
* on a right-to-left page; Home and End to its ends). Every control stays in the Tab order, so a
* control added by a Livewire render is reachable without any bookkeeping.
*/
const CONTROLS = 'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
document.addEventListener('alpine:init', () => {
window.Alpine.data('materialToolbar', (vertical = false) => ({
move(event) {
const keys = vertical ? { ArrowUp: -1, ArrowDown: 1 } : { ArrowLeft: -1, ArrowRight: 1 }
const controls = [...this.$root.querySelectorAll(CONTROLS)].filter((control) => control.getClientRects().length > 0)
const at = controls.indexOf(document.activeElement)
if (at === -1 || controls.length === 0) {
return
}
let next = null
if (event.key === 'Home') {
next = controls[0]
} else if (event.key === 'End') {
next = controls.at(-1)
} else if (event.key in keys) {
const rtl = !vertical && getComputedStyle(this.$root).direction === 'rtl'
const by = keys[event.key] * (rtl ? -1 : 1)
next = controls[(at + by + controls.length) % controls.length]
}
if (next) {
event.preventDefault()
next.focus()
}
},
}))
})