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('