From f90695cedd02c7f97e908b7277bc43170a166eb7 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Sun, 13 Sep 2026 08:26:54 +0200 Subject: [PATCH] Add app bars, toolbars, tabs and the account and theme controls 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) Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy --- .../livewire-material-development/SKILL.md | 57 ++++++ resources/css/components/app-bar.css | 188 ++++++++++++++++++ resources/css/components/tabs.css | 150 ++++++++++++++ resources/css/components/toolbar.css | 96 +++++++++ resources/css/material.css | 3 + resources/js/app-bar.js | 60 ++++++ resources/js/material.js | 3 + resources/js/tabs.js | 56 ++++++ resources/js/toolbar.js | 39 ++++ .../views/components/account-menu.blade.php | 79 ++++++++ resources/views/components/app-bar.blade.php | 77 +++++++ .../views/components/section-nav.blade.php | 81 ++++++++ resources/views/components/tab.blade.php | 18 ++ resources/views/components/tabs.blade.php | 90 +++++++++ .../views/components/theme-toggle.blade.php | 76 +++++++ resources/views/components/toolbar.blade.php | 51 +++++ resources/views/showcase/index.blade.php | 1 + resources/views/showcase/layout.blade.php | 2 +- .../views/showcase/sections/bars.blade.php | 144 ++++++++++++++ tests/Browser/BarsTest.php | 146 ++++++++++++++ tests/Feature/Components/AppBarTest.php | 105 ++++++++++ tests/Feature/Components/TabsTest.php | 82 ++++++++ 22 files changed, 1603 insertions(+), 1 deletion(-) create mode 100644 resources/css/components/app-bar.css create mode 100644 resources/css/components/tabs.css create mode 100644 resources/css/components/toolbar.css create mode 100644 resources/js/app-bar.js create mode 100644 resources/js/tabs.js create mode 100644 resources/js/toolbar.js create mode 100644 resources/views/components/account-menu.blade.php create mode 100644 resources/views/components/app-bar.blade.php create mode 100644 resources/views/components/section-nav.blade.php create mode 100644 resources/views/components/tab.blade.php create mode 100644 resources/views/components/tabs.blade.php create mode 100644 resources/views/components/theme-toggle.blade.php create mode 100644 resources/views/components/toolbar.blade.php create mode 100644 resources/views/showcase/sections/bars.blade.php create mode 100644 tests/Browser/BarsTest.php create mode 100644 tests/Feature/Components/AppBarTest.php create mode 100644 tests/Feature/Components/TabsTest.php diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 9b2833d4..3e8d3910 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -492,6 +492,63 @@ M3 search bar that opens into a search view: docked under the bar from `sm`, ful The docked view overlaps what is under it; never place a search inside an element with `overflow-hidden` (a card), which clips it. +### `` + +M3 Expressive top app bar, sticky by default (`:sticky="false"` to scroll away), turning surface-container once content scrolls under it. `variant`: `small` (default), `center`, `medium` and `large` (a big title that collapses into the row as the page scrolls — CSS sticky, no layout shift), `search` (put an `` in the slot). Props: `title`, `subtitle`, `heading` (`h1` default). Slots: `navigation` (leading icon button), `actions` (trailing icon buttons, avatar). + +```blade + + + + +``` + +A collapsing bar needs the window to scroll: no ancestor with `overflow-hidden`/`overflow-auto` (`overflow-x-clip` is fine). + +### `` + +M3 Expressive toolbar, `role="toolbar"` (arrow keys move between controls). `variant`: `floating` (default pill at elevation 3; `vibrant`, `vertical`) or `docked` (full-width surface-container bar). `place`: `bottom` or `end` to fix it over the page; `fab` slot sets a FAB beside a floating toolbar; `label` names it. + +```blade + + + + + +``` + +### ``, `` + +M3 tabs with a server-rendered tablist (arrow keys, Home/End, disabled tabs skipped, the indicator moves in a view transition). `tabs`: `['name', 'label', 'icon', 'badge', 'disabled']`; panels are `` in the slot. Bind with `wire:model` (entangled), or `selected` / `x-model` without Livewire. `variant` `primary` (default) or `secondary`; `stacked` (icon over label), `scrollable`. Give two identical tab sets on one page distinct `id`s. + +```blade + + + + +``` + +### `` + +Navigation between the sections of one area (settings, admin): secondary tabs as links from `sm` (wrapping onto a grid rather than scrolling), a menu picker below. `items`: `['title', 'url', 'icon', 'active', 'badge']` — current when `active` or its `url` is the request's. `label`, `no-wire-navigate`. + +### `` + +An avatar that opens a menu: `name`, `email`, `avatar` (image URL or initials; default the name's initials), items in the slot, a theme item (`:theme="false"` to drop it), and a `footer` slot for signing out. `label`, `position`. + +```blade + + + +
@csrf +
+
+``` + +### `` + +Switches `$store.theme`: `mode="toggle"` (default, light/dark icon button), `cycle` (light → dark → system), `picker` (segmented buttons for settings pages). Every toggle on a page shares the store. + ## Testing the design ```php diff --git a/resources/css/components/app-bar.css b/resources/css/components/app-bar.css new file mode 100644 index 00000000..9de2c3d0 --- /dev/null +++ b/resources/css/components/app-bar.css @@ -0,0 +1,188 @@ +/* + * M3 Expressive's top app bars (AppBarTokens, AppBarSmallTokens, AppBarMediumFlexibleTokens, + * AppBarLargeFlexibleTokens, androidx Compose Material 3, Apache-2.0). + * + * small 64px, title-large title (label-medium subtitle), 4px from the edges, the title 16px + * from the start or straight after the navigation icon + * center the same, the title centred + * medium 112px (136px with a subtitle): the 64px row of icons over a headline-medium title + * large 120px (152px with a subtitle): a display-small title + * search the row holds a search bar + * + * The container is the surface, and surface-container once content scrolls under it. A medium or + * large bar collapses into the small one without script moving anything: the bar is sticky at a + * negative top, so it scrolls up until only 64px of it remain, while its row of icons is sticky at + * 0 inside it and stays put — the big title scrolls away under the row. Script only says when that + * has happened (resources/js/app-bar.js), so the small title can fade in and the colour change. + */ + +@layer components { + [data-app-bar] { + --app-bar-row: 4rem; + --app-bar-height: var(--app-bar-row); + + z-index: 20; + display: block; + min-height: var(--app-bar-height); + padding-top: env(safe-area-inset-top); + background-color: var(--md-sys-color-surface); + color: var(--md-sys-color-on-surface); + transition: background-color var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default); + } + + [data-app-bar][data-variant="medium"] { + --app-bar-height: 7rem; + } + + [data-app-bar][data-variant="medium"][data-subtitled] { + --app-bar-height: 8.5rem; + } + + [data-app-bar][data-variant="large"] { + --app-bar-height: 7.5rem; + } + + [data-app-bar][data-variant="large"][data-subtitled] { + --app-bar-height: 9.5rem; + } + + [data-app-bar][data-sticky] { + position: sticky; + top: 0; + } + + /* `--app-bar-measured` is the bar's real height (a title can wrap), set by app-bar.js. */ + [data-app-bar][data-sticky]:is([data-variant="medium"], [data-variant="large"]) { + top: calc(var(--app-bar-row) - var(--app-bar-measured, var(--app-bar-height))); + } + + [data-app-bar][data-scrolled] { + background-color: var(--md-sys-color-surface-container); + } + + [data-app-bar-row] { + position: sticky; + top: 0; + z-index: 1; + display: flex; + align-items: center; + gap: 0; + min-height: var(--app-bar-row); + padding-inline: 0.25rem; + background-color: inherit; + } + + [data-app-bar-leading], + [data-app-bar-trailing] { + display: flex; + flex: none; + align-items: center; + color: var(--md-sys-color-on-surface-variant); + } + + [data-app-bar-leading] { + color: var(--md-sys-color-on-surface); + } + + [data-app-bar-trailing] { + margin-inline-start: auto; + } + + [data-app-bar-headline] { + display: flex; + min-width: 0; + flex: 1 1 0%; + flex-direction: column; + justify-content: center; + padding-inline: 0.75rem; + } + + [data-app-bar-row]:has([data-app-bar-leading]) [data-app-bar-headline] { + padding-inline-start: 0.25rem; + } + + [data-app-bar-title] { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font: var(--md-sys-typescale-title-lg); + letter-spacing: var(--md-sys-typescale-title-lg-tracking); + } + + [data-app-bar-subtitle] { + overflow: hidden; + color: var(--md-sys-color-on-surface-variant); + text-overflow: ellipsis; + white-space: nowrap; + font: var(--md-sys-typescale-label-md); + letter-spacing: var(--md-sys-typescale-label-md-tracking); + } + + /* Centred: the headline spans the row and centres its text between whatever is at either end. */ + [data-app-bar][data-variant="center"] [data-app-bar-headline] { + position: absolute; + inset-inline: 3.5rem; + top: 0; + bottom: 0; + align-items: center; + text-align: center; + pointer-events: none; + } + + /* The search bar fills the row. */ + [data-app-bar-search] { + min-width: 0; + flex: 1 1 0%; + padding-inline: 0.25rem; + } + + /* Medium and large: the row's title is the collapsed one, shown once the big one has gone. */ + [data-app-bar]:is([data-variant="medium"], [data-variant="large"]) [data-app-bar-row] [data-app-bar-headline] { + opacity: 0; + transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast); + } + + [data-app-bar][data-collapsed] [data-app-bar-row] [data-app-bar-headline] { + opacity: 1; + } + + /* Heights from the tokens: the title (and subtitle) lines and the space under them fill what the + row leaves — 36 + 12px medium, 36 + 20 + 16px with a subtitle; 44 + 12px large, 44 + 24 + 20px. */ + [data-app-bar-expanded] { + display: flex; + min-height: calc(var(--app-bar-height) - var(--app-bar-row)); + flex-direction: column; + justify-content: flex-end; + padding-inline: 1rem; + padding-bottom: 0.75rem; + } + + [data-app-bar][data-subtitled] [data-app-bar-expanded] { + padding-bottom: 1rem; + } + + [data-app-bar][data-variant="large"][data-subtitled] [data-app-bar-expanded] { + padding-bottom: 1.25rem; + } + + [data-app-bar-expanded] [data-app-bar-title] { + white-space: normal; + font: var(--md-sys-typescale-headline-md); + letter-spacing: var(--md-sys-typescale-headline-md-tracking); + } + + [data-app-bar-expanded] [data-app-bar-subtitle] { + font: var(--md-sys-typescale-label-lg); + letter-spacing: var(--md-sys-typescale-label-lg-tracking); + } + + [data-app-bar][data-variant="large"] [data-app-bar-expanded] [data-app-bar-title] { + font: var(--md-sys-typescale-display-sm); + letter-spacing: var(--md-sys-typescale-display-sm-tracking); + } + + [data-app-bar][data-variant="large"] [data-app-bar-expanded] [data-app-bar-subtitle] { + font: var(--md-sys-typescale-title-md); + letter-spacing: var(--md-sys-typescale-title-md-tracking); + } +} diff --git a/resources/css/components/tabs.css b/resources/css/components/tabs.css new file mode 100644 index 00000000..9afcef52 --- /dev/null +++ b/resources/css/components/tabs.css @@ -0,0 +1,150 @@ +/* + * M3's tabs (PrimaryNavigationTabTokens, SecondaryNavigationTabTokens, androidx Compose Material 3, + * Apache-2.0): a 48px tablist on the surface over an outline-variant divider (64px when a primary + * tab stacks its icon over its label), title-small labels in on-surface-variant, the chosen tab's + * in primary (primary tabs) or on-surface (secondary tabs). + * + * The active indicator is drawn in every tab and shown under the chosen one, so it is right before + * Alpine starts and after a morph: 3px with rounded top corners under the content of a primary tab + * (at least 24px wide), 2px across the whole of a secondary one. When the choice changes, a view + * transition moves it from the old tab to the new (resources/js/tabs.js), as M3's slides. + */ + +@layer components { + [data-tabs-bar] { + position: relative; + display: flex; + overflow-x: auto; + overscroll-behavior-x: contain; + border-bottom: 1px solid var(--md-sys-color-outline-variant); + scrollbar-width: thin; + } + + /* A section nav's links sit in list items that share the width. */ + [data-tabs-bar] > li { + flex: 1 1 0%; + } + + [data-tabs-bar] > li > [data-tab] { + flex: 1 1 0%; + } + + [data-tab] { + position: relative; + display: flex; + flex: 1 1 0%; + min-width: 5.625rem; + height: 3rem; + align-items: center; + justify-content: center; + padding-inline: 1rem; + color: var(--md-sys-color-on-surface-variant); + cursor: pointer; + outline: none; + font: var(--md-sys-typescale-title-sm); + letter-spacing: var(--md-sys-typescale-title-sm-tracking); + white-space: nowrap; + transition: color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast); + } + + [data-tabs-bar][data-scrollable] [data-tab] { + flex: none; + } + + [data-tabs-bar][data-stacked] [data-tab] { + height: 4rem; + } + + [data-tab]::before { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast); + } + + @media (hover: hover) { + [data-tab]:hover { + color: var(--md-sys-color-on-surface); + } + + [data-tab]:hover::before { + background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 8%, transparent); + } + } + + [data-tab]:focus-visible::before, + [data-tab]:active::before { + background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent); + } + + [data-tab]:focus-visible { + color: var(--md-sys-color-on-surface); + outline: 3px solid var(--md-sys-color-secondary); + outline-offset: -3px; + } + + [data-tab][aria-selected="true"] { + color: var(--md-sys-color-primary); + } + + [data-tabs-bar][data-variant="secondary"] [data-tab]:is([aria-selected="true"], [aria-current="page"]) { + color: var(--md-sys-color-on-surface); + } + + [data-tab]:disabled { + color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent); + cursor: default; + } + + [data-tab]:disabled::before { + background-color: transparent; + } + + [data-tab-content] { + position: relative; + display: inline-flex; + height: 100%; + min-width: 1.5rem; + align-items: center; + justify-content: center; + gap: 0.5rem; + } + + [data-tabs-bar][data-stacked] [data-tab-content] { + flex-direction: column; + gap: 0.125rem; + } + + [data-tab-indicator] { + position: absolute; + inset-inline: 0; + bottom: 0; + height: 3px; + border-radius: 3px 3px 0 0; + background-color: var(--md-sys-color-primary); + opacity: 0; + pointer-events: none; + view-transition-class: material-tab-indicator; + } + + /* A secondary tab's indicator spans the tab, not its content. */ + [data-tabs-bar][data-variant="secondary"] [data-tab-content] { + position: static; + } + + [data-tabs-bar][data-variant="secondary"] [data-tab-indicator] { + height: 2px; + border-radius: 0; + } + + [data-tab]:is([aria-selected="true"], [aria-current="page"]) [data-tab-indicator] { + opacity: 1; + view-transition-name: var(--tabs-indicator); + } +} + +::view-transition-group(*.material-tab-indicator) { + animation-duration: var(--md-sys-motion-spatial-default-duration); + animation-timing-function: var(--md-sys-motion-spatial-default); +} diff --git a/resources/css/components/toolbar.css b/resources/css/components/toolbar.css new file mode 100644 index 00000000..0265806f --- /dev/null +++ b/resources/css/components/toolbar.css @@ -0,0 +1,96 @@ +/* + * M3 Expressive's toolbars (DockedToolbarTokens, FloatingToolbarTokens, androidx Compose Material 3, + * Apache-2.0). + * + * docked a 64px bar across the bottom of the screen in surface-container, square, its + * controls spread out 4 to 32px apart between 16px ends + * floating a 64px pill in surface-container (or primary-container, `vibrant`) at elevation 3, + * 8px at its ends and 4px between controls; `vertical` stands it on end + * + * Unlayered on purpose: a vibrant toolbar recolours the icon buttons inside it, which draw their + * ink as utilities, and a rule in any layer loses to a utility. + */ + +[data-toolbar] { + display: flex; + align-items: center; + gap: 0.25rem; +} + +[data-toolbar][data-variant="docked"] { + width: 100%; + min-height: calc(4rem + env(safe-area-inset-bottom)); + justify-content: center; + column-gap: clamp(0.25rem, 4vw, 2rem); + padding-inline: 1rem; + padding-bottom: env(safe-area-inset-bottom); + background-color: var(--md-sys-color-surface-container); + color: var(--md-sys-color-on-surface-variant); +} + +[data-toolbar][data-variant="floating"] { + display: inline-flex; + height: 4rem; + padding-inline: 0.5rem; + border-radius: var(--md-sys-shape-corner-full); + background-color: var(--md-sys-color-surface-container); + color: var(--md-sys-color-on-surface-variant); + box-shadow: var(--md-sys-elevation-3); +} + +[data-toolbar][data-variant="floating"][data-vertical] { + width: 4rem; + height: auto; + flex-direction: column; + padding-block: 0.5rem; + padding-inline: 0; +} + +[data-toolbar][data-vibrant] { + background-color: var(--md-sys-color-primary-container); + color: var(--md-sys-color-on-primary-container); +} + +[data-toolbar][data-vibrant] [data-icon-button]:not([aria-pressed="true"]) { + color: var(--md-sys-color-on-primary-container); +} + +[data-toolbar][data-vibrant] [data-icon-button][aria-pressed="true"] { + background-color: var(--md-sys-color-surface-container); + color: var(--md-sys-color-on-surface); +} + +/* A floating toolbar and its FAB, side by side (or stacked, vertical). */ +[data-toolbar-group] { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +[data-toolbar-group][data-vertical] { + flex-direction: column; +} + +/* Placed over the page: centred above the bottom edge, or centred against the end edge. */ +[data-toolbar-place="bottom"] { + position: fixed; + bottom: calc(1rem + env(safe-area-inset-bottom)); + left: 50%; + z-index: 30; + translate: -50% 0; +} + +[data-toolbar-place="end"] { + position: fixed; + top: 50%; + inset-inline-end: calc(1rem + env(safe-area-inset-right)); + z-index: 30; + translate: 0 -50%; +} + +[data-toolbar][data-variant="docked"][data-toolbar-place="bottom"] { + inset-inline: 0; + bottom: 0; + left: 0; + translate: none; +} diff --git a/resources/css/material.css b/resources/css/material.css index dd6385ba..7c2f9101 100644 --- a/resources/css/material.css +++ b/resources/css/material.css @@ -26,6 +26,9 @@ @import './components/menu.css'; @import './components/selection.css'; @import './components/search.css'; +@import './components/tabs.css'; +@import './components/app-bar.css'; +@import './components/toolbar.css'; @layer base { html { diff --git a/resources/js/app-bar.js b/resources/js/app-bar.js new file mode 100644 index 00000000..16f25e21 --- /dev/null +++ b/resources/js/app-bar.js @@ -0,0 +1,60 @@ +/** + * `materialAppBar`: says when content is under `` (`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 + }, + })) +}) diff --git a/resources/js/material.js b/resources/js/material.js index 6b6d1cb7..770f394a 100644 --- a/resources/js/material.js +++ b/resources/js/material.js @@ -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' diff --git a/resources/js/tabs.js b/resources/js/tabs.js new file mode 100644 index 00000000..e27dd670 --- /dev/null +++ b/resources/js/tabs.js @@ -0,0 +1,56 @@ +/** + * `materialTabs`: the behaviour of `` — 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) + } + }, + })) +}) diff --git a/resources/js/toolbar.js b/resources/js/toolbar.js new file mode 100644 index 00000000..539ca5d5 --- /dev/null +++ b/resources/js/toolbar.js @@ -0,0 +1,39 @@ +/** + * `materialToolbar`: the arrow keys move focus between the controls of an ``, 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() + } + }, + })) +}) diff --git a/resources/views/components/account-menu.blade.php b/resources/views/components/account-menu.blade.php new file mode 100644 index 00000000..f8a31661 --- /dev/null +++ b/resources/views/components/account-menu.blade.php @@ -0,0 +1,79 @@ +{{-- Who is signed in, and what belongs to them rather than to a page: an avatar that opens a menu. + + `name` and `email` head the menu; `avatar` is an image URL or, by default, the initials of the + name. The slot holds the items (``: settings, billing…), a theme item follows unless + `:theme="false"`, and the `footer` slot comes last, after a separator — the place for signing + out, furthest from the thumb: + + + + +
+ @csrf + + +
+
+ + `label` names the button and the menu ("Account"). `position` as on `` (`bottom-end`). --}} + +@props([ + 'name' => null, + 'email' => null, + 'avatar' => null, + 'label' => null, + 'theme' => true, + 'position' => 'bottom-end', +]) + +@php + $label ??= __('Account'); + $image = filled($avatar) && (str_contains((string) $avatar, '/') || str_contains((string) $avatar, '.')); + $initials = $image ? null : ($avatar ?? \Illuminate\Support\Str::of((string) $name)->explode(' ')->filter()->take(2)->map(fn (string $word): string => mb_strtoupper(mb_substr($word, 0, 1)))->join('')); +@endphp + + + + + + + @if (filled($name) || filled($email)) +
+ @if (filled($name)) +

{{ $name }}

+ @endif + @if (filled($email)) +

{{ $email }}

+ @endif +
+ + + @endif + + {{ $slot }} + + @if ($theme) + + {{ __('Theme') }} + + @endif + + @isset($footer) + + + {{ $footer }} + @endisset +
diff --git a/resources/views/components/app-bar.blade.php b/resources/views/components/app-bar.blade.php new file mode 100644 index 00000000..6e23d100 --- /dev/null +++ b/resources/views/components/app-bar.blade.php @@ -0,0 +1,77 @@ +{{-- M3 Expressive's top app bar: the title of the screen, a navigation button and its actions. + + `variant`: `small` (the default, 64px), `center` (the title centred), `medium` and `large` (a + big title under the row that collapses into the small one as the page scrolls), and `search` + (a search bar fills the row: put an `` in the slot). `title` and `subtitle`; the + title is an `

` unless `heading` names another element. Slots: `navigation` (the leading + icon button: back, or the menu) and `actions` (trailing icon buttons, an avatar). + + Sticky at the top by default (`:sticky="false"` for one that scrolls away), under the top safe + area; the surface turns surface-container once content scrolls under it + (resources/css/components/app-bar.css, resources/js/app-bar.js). A collapsing bar needs the + window to be what scrolls: an ancestor with `overflow: hidden` or `auto` would stop it + sticking (`overflow-x: clip` is fine). --}} + +@props([ + 'title' => null, + 'subtitle' => null, + 'variant' => 'small', + 'sticky' => true, + 'heading' => 'h1', +]) + +@php + $variant = in_array($variant, ['small', 'center', 'medium', 'large', 'search'], true) ? $variant : 'small'; + $flexible = in_array($variant, ['medium', 'large'], true); + $heading = in_array($heading, ['h1', 'h2', 'h3', 'div', 'p'], true) ? $heading : 'h1'; +@endphp + +
+
+ @isset($navigation) +
{{ $navigation }}
+ @endisset + + @if ($variant === 'search') +
{{ $slot }}
+ @elseif (filled($title)) + + @endif + + @isset($actions) +
{{ $actions }}
+ @endisset +
+ + @if ($flexible && filled($title)) +
+ <{{ $heading }} data-app-bar-title>{{ $title }} + @if (filled($subtitle)) +

{{ $subtitle }}

+ @endif +
+ @endif + + @if ($variant !== 'search' && $slot->hasActualContent()) + {{ $slot }} + @endif +
diff --git a/resources/views/components/section-nav.blade.php b/resources/views/components/section-nav.blade.php new file mode 100644 index 00000000..dec22c62 --- /dev/null +++ b/resources/views/components/section-nav.blade.php @@ -0,0 +1,81 @@ +{{-- The navigation inside one area of an app — the sections of settings, of an admin — which is a + single destination in the app's own navigation. + + `items`, a list of `['title' => …, 'url' => …]` with an optional `icon`, `active` and `badge` + (an item is current when `active` is true, or when its `url` is the request's). From `sm` they + are M3's secondary tabs as links, the current one underlined; below `sm`, where a row of them + never fits, a button naming the current section opens a menu of all of them. The same list is + rendered for both, and CSS shows one. + + A row too long for its column wraps onto a grid rather than scrolling: below `xl` five or six + sections go 3 + 3 and seven or more go four to a row — tabs that scroll hid the last sections on + a tablet. `label` names the navigation ("Sections"). Links use `wire:navigate` unless + `no-wire-navigate`. --}} + +@props([ + 'items' => [], + 'label' => null, + 'noWireNavigate' => false, +]) + +@php + $label ??= __('Sections'); + $isCurrent = fn (array $item): bool => ($item['active'] ?? false) || (filled($item['url'] ?? null) && url()->current() === url($item['url'])); + $current = collect($items)->first($isCurrent) ?? ($items[0] ?? null); + + $layout = match (true) { + count($items) < 5 => 'sm:flex', + count($items) < 7 => 'sm:grid sm:grid-cols-3 xl:flex', + default => 'sm:grid sm:grid-cols-4 xl:flex', + }; +@endphp + +
class(['min-w-0']) }} data-section-nav> + @if ($current) +
+ + + + + + @foreach ($items as $item) + + @endforeach + +
+ @endif + + +
diff --git a/resources/views/components/tab.blade.php b/resources/views/components/tab.blade.php new file mode 100644 index 00000000..6bc0f1e0 --- /dev/null +++ b/resources/views/components/tab.blade.php @@ -0,0 +1,18 @@ +{{-- One tab's panel, inside ``, which draws the bar from its own `tabs` list. Shown while + its `name` is the chosen one; every panel is rendered, so switching never waits on the server. + `class` lands on the panel (it has `pt-6` above its content by default). --}} + +@props([ + 'name', +]) + +
class(['pt-6 outline-none']) }} +> + {{ $slot }} +
diff --git a/resources/views/components/tabs.blade.php b/resources/views/components/tabs.blade.php new file mode 100644 index 00000000..a30417fb --- /dev/null +++ b/resources/views/components/tabs.blade.php @@ -0,0 +1,90 @@ +{{-- M3's tabs: the sections of one thing, with the chosen one marked by an active indicator. + + The bar is rendered on the server from `tabs`, a list of `['name' => …, 'label' => …]` with an + optional `icon`, `badge` and `disabled` — so it arrives with the page, never a moment after it. + The panels are `` in the slot; every panel is rendered, so switching never waits + on the server. + + The chosen tab is the Livewire property in `wire:model` (entangled; `wire:model.live` tells the + server at once), or, without it, `selected` (the first tab by default) and `x-model`. The bar is + a real tablist (resources/js/tabs.js). `variant` is `primary` (the default: the indicator under + the label, in primary) or `secondary` (under the whole tab, for tabs inside a section); + `stacked` puts a primary tab's icon over its label (64px); `scrollable` lets tabs keep their + own width and scroll sideways instead of sharing the width equally + (resources/css/components/tabs.css). --}} + +@props([ + 'tabs' => [], + 'variant' => 'primary', + 'selected' => null, + 'stacked' => false, + 'scrollable' => false, +]) + +@php + $model = $attributes->wire('model')->value() ?: null; + $variant = $variant === 'secondary' ? 'secondary' : 'primary'; + $id = $attributes->get('id') ?? 'tabs-'.substr(md5($model.'|'.implode('|', array_column($tabs, 'name'))), 0, 8); + + $initial = $selected ?? (collect($tabs)->first(fn (array $tab): bool => ! ($tab['disabled'] ?? false))['name'] ?? null); + if ($model !== null && ($component = \Livewire\Livewire::current()) !== null && filled(data_get($component, $model))) { + $initial = data_get($component, $model); + } +@endphp + +
whereDoesntStartWith('wire:model')->except(['id'])->class(['min-w-0']) }} +> +
+ @foreach ($tabs as $tab) + @php($isInitial = (string) $tab['name'] === (string) $initial) + + + @endforeach +
+ + {{ $slot }} +
diff --git a/resources/views/components/theme-toggle.blade.php b/resources/views/components/theme-toggle.blade.php new file mode 100644 index 00000000..1c82552a --- /dev/null +++ b/resources/views/components/theme-toggle.blade.php @@ -0,0 +1,76 @@ +{{-- Switches the colour theme through `$store.theme` (resources/js/theme.js), so every toggle on a + page reads and writes the one choice and none needs an id. + + `mode`: + - `toggle` (the default): an icon button between light and dark. The icon is where it takes + you — a sun while the page is dark, a moon while it is light — and it is a toggle button, + "Dark theme", pressed while dark. + - `cycle`: an icon button that steps light → dark → system, showing the choice it is on. + - `picker`: M3's segmented buttons for the three choices, for a settings page. + + The theme is the visitor's, known only in the browser, so the parts that depend on it wait for + Alpine (`x-cloak`) rather than render a guess. `class` lands on the button or the group. --}} + +@props([ + 'mode' => 'toggle', +]) + +@php + $mode = in_array($mode, ['toggle', 'cycle', 'picker'], true) ? $mode : 'toggle'; +@endphp + +@if ($mode === 'picker') +
class(['inline-flex h-10 rounded-corner-full border border-outline']) }} + > + @foreach (['light' => ['Light', 'light_mode'], 'dark' => ['Dark', 'dark_mode'], 'system' => ['System', 'brightness_auto']] as $choice => [$text, $icon]) + + @endforeach +
+@else + +@endif diff --git a/resources/views/components/toolbar.blade.php b/resources/views/components/toolbar.blade.php new file mode 100644 index 00000000..c0451eb3 --- /dev/null +++ b/resources/views/components/toolbar.blade.php @@ -0,0 +1,51 @@ +{{-- M3 Expressive's toolbar: a set of actions for the current page, in a bar of its own. + + `variant`: `floating` (the default: a pill at elevation 3; `vibrant` in primary-container, + `vertical` stands it on end) or `docked` (a full-width bar in surface-container, for the bottom + of a screen). `place` puts it over the page: `bottom` (centred above the bottom edge; a docked + toolbar spans it) or `end` (centred against the end edge, for a vertical one); without it the + toolbar sits where it is written. A `fab` slot sets an `` beside a floating toolbar. + `label` names it for screen readers. + + Put `` controls in the slot (`:selected` for toggles). It is a + `role="toolbar"`: the arrow keys move between its controls (resources/js/toolbar.js, + resources/css/components/toolbar.css). --}} + +@props([ + 'variant' => 'floating', + 'vibrant' => false, + 'vertical' => false, + 'place' => null, + 'label' => null, +]) + +@php + $variant = $variant === 'docked' ? 'docked' : 'floating'; + $vertical = $vertical && $variant === 'floating'; + $place = in_array($place, ['bottom', 'end'], true) ? $place : null; + $grouped = isset($fab) && $variant === 'floating'; +@endphp + +@if ($grouped) +
+@endif + +
+ {{ $slot }} +
+ +@if ($grouped) + {{ $fab }} +
+@endif diff --git a/resources/views/showcase/index.blade.php b/resources/views/showcase/index.blade.php index 84e7023d..1bc39933 100644 --- a/resources/views/showcase/index.blade.php +++ b/resources/views/showcase/index.blade.php @@ -21,5 +21,6 @@ @include('livewire-material::showcase.sections.fields') @include('livewire-material::showcase.sections.chips') @include('livewire-material::showcase.sections.sliders') + @include('livewire-material::showcase.sections.bars') @endsection diff --git a/resources/views/showcase/layout.blade.php b/resources/views/showcase/layout.blade.php index 5728194f..3b148936 100644 --- a/resources/views/showcase/layout.blade.php +++ b/resources/views/showcase/layout.blade.php @@ -18,7 +18,7 @@ Livewire Material diff --git a/resources/views/showcase/sections/bars.blade.php b/resources/views/showcase/sections/bars.blade.php new file mode 100644 index 00000000..81467dd5 --- /dev/null +++ b/resources/views/showcase/sections/bars.blade.php @@ -0,0 +1,144 @@ +@php + $examples = [ + 'Top app bars' => <<<'BLADE' +
+
+ + + + + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + +
+ +
+ + + + + AR + + +
+
+ BLADE, + 'Toolbars' => <<<'BLADE' +
+ + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+
+ BLADE, + 'Primary tabs' => <<<'BLADE' +
+ + Three files, 248 MB. + Anna, Ben and Chiara. + Downloaded twice today. + + + + Everything shared with you. + Only photos. + Only videos. + +
+ BLADE, + 'Secondary and scrollable tabs' => <<<'BLADE' +
+ + An overview of the account. + Passwords and two-factor sign-in. + Plan and invoices. + + +
+ + January's shares. + +
+
+ BLADE, + 'Section navigation, account menu and theme toggles' => <<<'BLADE' +
+ + +
+ + + + + + + + + + +
+
+ BLADE, + ]; +@endphp + +
+

App bars, toolbars and tabs

+ +

+ <x-app-bar>, <x-toolbar>, <x-tabs>, <x-tab>, <x-section-nav>, <x-account-menu> and <x-theme-toggle>. +

+ + @foreach ($examples as $title => $code) + + @endforeach +
diff --git a/tests/Browser/BarsTest.php b/tests/Browser/BarsTest.php new file mode 100644 index 00000000..976c76ca --- /dev/null +++ b/tests/Browser/BarsTest.php @@ -0,0 +1,146 @@ + + + + + + +
+

tab: {{ $tab }}

+ + + Three files. + Anna and Ben. + Two downloads. + + + + + + + + + + + + + +
+
+ + BLADE; + } +} + +function barsProbe() +{ + Livewire::component('bars-probe', BarsProbe::class); + + Route::middleware('web')->get('/bars-probe', fn () => Blade::render(<<<'BLADE' + + + + + @vite(config('livewire-material.showcase.vite')) + @livewireStyles + + + + @livewireScripts + + + BLADE)); + + return visit('/bars-probe')->waitForEvent('networkidle') + ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'"); +} + +it('moves between tabs with the arrow keys, skipping disabled ones, and tells Livewire', function () { + $page = barsProbe() + ->click('#share-people') + ->assertSeeIn('#tab', 'people') + ->assertSee('Anna and Ben.') + ->assertAttribute('#share-people', 'tabindex', '0') + ->assertAttribute('#share-files', 'tabindex', '-1'); + + $page->keys('#share-people', 'ArrowRight') + ->assertSeeIn('#tab', 'activity') + ->assertScript("document.activeElement.id === 'share-activity'") + ->assertScript("getComputedStyle(document.querySelector('#share-activity [data-tab-indicator]')).opacity === '1'"); + + $page->keys('#share-activity', 'Home') + ->assertSeeIn('#tab', 'files') + ->assertScript("document.activeElement.id === 'share-files'"); + + $page->keys('#share-files', 'ArrowLeft') + ->assertSeeIn('#tab', 'activity'); +}); + +it('collapses a medium app bar into its row as the page scrolls, and fades its small title in', function () { + $headline = "getComputedStyle(document.querySelector('#bar [data-app-bar-row] [data-app-bar-headline]')).opacity"; + + $page = barsProbe() + ->assertScript("! document.querySelector('#bar').hasAttribute('data-collapsed')") + ->assertScript("{$headline} === '0'"); + + $page->script('window.scrollTo(0, 600)'); + + $page->assertScript("document.querySelector('#bar').hasAttribute('data-collapsed') && document.querySelector('#bar').hasAttribute('data-scrolled')") + ->assertScript("Math.round(document.querySelector('#bar [data-app-bar-row]').getBoundingClientRect().top) === 0") + ->assertScript("document.querySelector('#bar').getBoundingClientRect().bottom <= 65") + ->assertScript("{$headline} === '1'"); + + $page->script('window.scrollTo(0, 0)'); + + $page->assertScript("! document.querySelector('#bar').hasAttribute('data-collapsed')"); +}); + +it('moves focus along a toolbar with the arrow keys', function () { + barsProbe() + ->keys('#bold', 'ArrowRight') + ->assertScript("document.activeElement.id === 'italic'") + ->keys('#italic', 'End') + ->assertScript("document.activeElement.id === 'underline'") + ->keys('#underline', 'ArrowRight') + ->assertScript("document.activeElement.id === 'bold'"); +}); + +it('switches the theme from a toggle, a cycle and a picker', function () { + $page = barsProbe() + ->click('[data-theme-option="light"]') + ->assertScript("document.documentElement.dataset.theme === 'light'") + ->assertAttribute('#toggle', 'aria-pressed', 'false'); + + $page->click('#toggle') + ->assertScript("document.documentElement.dataset.theme === 'dark'") + ->assertAttribute('#toggle', 'aria-pressed', 'true') + ->assertAttribute('[data-theme-option="dark"]', 'aria-checked', 'true'); + + $page->click('#cycle') + ->assertScript("document.documentElement.dataset.themeChoice === 'system'") + ->click('#cycle') + ->assertScript("document.documentElement.dataset.themeChoice === 'light'"); +}); + +it('turns section tabs into a picker on a phone', function () { + barsProbe() + ->resize(400, 800) + ->assertScript("getComputedStyle(document.querySelector('[data-section-nav] nav')).display === 'none'") + ->click('[data-section-picker] button') + ->assertScript("document.querySelector('[data-section-picker] [popover]').matches(':popover-open')") + ->assertAttribute('[data-section-picker] [role="menuitemcheckbox"][href="#profile"]', 'aria-checked', 'true') + ->assertAttribute('[data-section-picker] [role="menuitemcheckbox"][href="#security"]', 'aria-checked', 'false'); +}); diff --git a/tests/Feature/Components/AppBarTest.php b/tests/Feature/Components/AppBarTest.php new file mode 100644 index 00000000..f14191c3 --- /dev/null +++ b/tests/Feature/Components/AppBarTest.php @@ -0,0 +1,105 @@ +blade(<<<'BLADE' + + + + + BLADE); + + expect($html) + ->toContain('toContain('x-data="materialAppBar"') + ->toContain('data-variant="small"') + ->toContain('data-sticky') + ->toContain('data-subtitled') + ->toContain('

Shares

') + ->toContain('

12 active

') + ->toContain('
') + ->toContain('
'); +}); + +it('gives a medium or large bar a collapsed title in its row and the heading under it', function () { + $html = (string) $this->blade(''); + + expect($html) + ->toContain('data-variant="large"') + ->not->toContain('data-sticky') + ->toContain('