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
@@ -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.
### `<x-app-bar>`
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 `<x-search>` in the slot). Props: `title`, `subtitle`, `heading` (`h1` default). Slots: `navigation` (leading icon button), `actions` (trailing icon buttons, avatar).
```blade
<x-app-bar variant="medium" title="Recipients" subtitle="3 people">
<x-slot:navigation><x-button icon="arrow_back" tooltip="Back" :link="route('shares.index')" /></x-slot:navigation>
<x-slot:actions><x-button icon="person_add" tooltip="Add recipient" wire:click="add" /></x-slot:actions>
</x-app-bar>
```
A collapsing bar needs the window to scroll: no ancestor with `overflow-hidden`/`overflow-auto` (`overflow-x-clip` is fine).
### `<x-toolbar>`
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
<x-toolbar label="Selection" place="bottom" vibrant>
<x-button icon="download" tooltip="Download" wire:click="download" />
<x-button icon="delete" tooltip="Delete" wire:click="delete" />
<x-slot:fab><x-fab icon="add" tooltip="New share" /></x-slot:fab>
</x-toolbar>
```
### `<x-tabs>`, `<x-tab>`
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 `<x-tab name>` 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
<x-tabs wire:model.live="tab" :tabs="[['name' => 'files', 'label' => 'Files'], ['name' => 'people', 'label' => 'People', 'badge' => $pending]]">
<x-tab name="files">…</x-tab>
<x-tab name="people">…</x-tab>
</x-tabs>
```
### `<x-section-nav>`
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`.
### `<x-account-menu>`
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
<x-account-menu :name="auth()->user()->name" :email="auth()->user()->email">
<x-menu-item label="Settings" icon="settings" :link="route('settings')" />
<x-slot:footer>
<form method="POST" action="{{ route('logout') }}">@csrf<x-menu-item label="Sign out" icon="logout" type="submit" /></form>
</x-slot:footer>
</x-account-menu>
```
### `<x-theme-toggle>`
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
+188
View File
@@ -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);
}
}
+150
View File
@@ -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);
}
+96
View File
@@ -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;
}
+3
View File
@@ -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 {
+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()
}
},
}))
})
@@ -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 (`<x-menu-item>`: 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:
<x-account-menu :name="$user->name" :email="$user->email">
<x-menu-item label="Settings" icon="settings" :link="route('settings')" />
<x-slot:footer>
<form method="POST" action="{{ route('logout') }}">
@csrf
<x-menu-item label="Sign out" icon="logout" type="submit" />
</form>
</x-slot:footer>
</x-account-menu>
`label` names the button and the menu ("Account"). `position` as on `<x-menu>` (`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
<x-menu :$label :$position {{ $attributes }}>
<x-slot:trigger>
<button
type="button"
aria-label="{{ $label }}"
data-account-menu
class="focus-ring inline-flex size-10 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-corner-full bg-primary-container type-label-lg text-on-primary-container"
>
@if ($image)
<img src="{{ $avatar }}" alt="" class="size-full object-cover" />
@elseif (filled($initials))
{{ $initials }}
@else
<x-icon name="person" />
@endif
</button>
</x-slot:trigger>
@if (filled($name) || filled($email))
<div class="px-3 pt-2 pb-3">
@if (filled($name))
<p class="truncate type-title-sm text-on-surface">{{ $name }}</p>
@endif
@if (filled($email))
<p class="truncate type-body-sm text-on-surface-variant">{{ $email }}</p>
@endif
</div>
<x-menu-separator />
@endif
{{ $slot }}
@if ($theme)
<x-menu-item icon="contrast" x-on:click="$store.theme.toggle()" data-account-theme>
<span x-text="$store.theme.resolved === 'dark' ? @js(__('Light theme')) : @js(__('Dark theme'))">{{ __('Theme') }}</span>
</x-menu-item>
@endif
@isset($footer)
<x-menu-separator />
{{ $footer }}
@endisset
</x-menu>
@@ -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 `<x-search>` in the slot). `title` and `subtitle`; the
title is an `<h1>` 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
<header
x-data="materialAppBar"
x-bind:data-scrolled="scrolled ? '' : null"
x-bind:data-collapsed="collapsed ? '' : null"
x-bind:style="measured"
data-app-bar
data-variant="{{ $variant }}"
@if ($sticky) data-sticky @endif
@if (filled($subtitle)) data-subtitled @endif
{{ $attributes }}
>
<div data-app-bar-row>
@isset($navigation)
<div data-app-bar-leading>{{ $navigation }}</div>
@endisset
@if ($variant === 'search')
<div data-app-bar-search>{{ $slot }}</div>
@elseif (filled($title))
<div data-app-bar-headline @if ($flexible) aria-hidden="true" @endif>
@if ($flexible)
<span data-app-bar-title>{{ $title }}</span>
@else
<{{ $heading }} data-app-bar-title>{{ $title }}</{{ $heading }}>
@if (filled($subtitle))
<p data-app-bar-subtitle>{{ $subtitle }}</p>
@endif
@endif
</div>
@endif
@isset($actions)
<div data-app-bar-trailing>{{ $actions }}</div>
@endisset
</div>
@if ($flexible && filled($title))
<div data-app-bar-expanded>
<{{ $heading }} data-app-bar-title>{{ $title }}</{{ $heading }}>
@if (filled($subtitle))
<p data-app-bar-subtitle>{{ $subtitle }}</p>
@endif
</div>
@endif
@if ($variant !== 'search' && $slot->hasActualContent())
{{ $slot }}
@endif
</header>
@@ -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
<div {{ $attributes->class(['min-w-0']) }} data-section-nav>
@if ($current)
<div class="sm:hidden" data-section-picker>
<x-menu :$label position="bottom-start">
<x-slot:trigger>
<button type="button" class="focus-ring flex h-12 w-[min(20rem,calc(100vw-2rem))] cursor-pointer items-center gap-3 rounded-corner-xs border border-outline px-4 text-start type-body-lg text-on-surface">
@isset($current['icon'])
<x-icon :name="$current['icon']" class="size-5 text-on-surface-variant" />
@endisset
<span class="min-w-0 flex-1 truncate">{{ $current['title'] }}</span>
<x-icon name="arrow_drop_down" class="size-6 text-on-surface-variant" />
</button>
</x-slot:trigger>
@foreach ($items as $item)
<x-menu-item :label="$item['title']" :icon="$item['icon'] ?? null" :link="$item['url']" :selected="$isCurrent($item)" :no-wire-navigate="$noWireNavigate" />
@endforeach
</x-menu>
</div>
@endif
<nav aria-label="{{ $label }}" class="max-sm:hidden">
<ul data-tabs-bar data-variant="secondary" class="{{ $layout }}">
@foreach ($items as $item)
@php($on = $isCurrent($item))
<li class="flex min-w-0">
<a
href="{{ $item['url'] }}"
data-tab
@if ($on) aria-current="page" @endif
@unless ($noWireNavigate) wire:navigate @endunless
>
<span data-tab-content>
@isset($item['icon'])
<x-icon :name="$item['icon']" :filled="$on" class="size-5" />
@endisset
<span class="truncate">{{ $item['title'] }}</span>
@if (filled($item['badge'] ?? null))
<x-badge :value="$item['badge']" />
@endif
<span data-tab-indicator aria-hidden="true"></span>
</span>
</a>
</li>
@endforeach
</ul>
</nav>
</div>
+18
View File
@@ -0,0 +1,18 @@
{{-- One tab's panel, inside `<x-tabs>`, 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',
])
<div
x-show="selected === @js($name)"
role="tabpanel"
x-bind:id="tabsId + '-' + @js($name) + '-panel'"
x-bind:aria-labelledby="tabsId + '-' + @js($name)"
tabindex="0"
{{ $attributes->class(['pt-6 outline-none']) }}
>
{{ $slot }}
</div>
+90
View File
@@ -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 `<x-tab name="…">` 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
<div
x-data="{
@if ($model !== null) selected: @entangle($attributes->wire('model')), @else selected: @js($initial), @endif
tabsId: @js($id),
...materialTabs(),
}"
@if ($model === null) x-modelable="selected" @endif
{{ $attributes->whereDoesntStartWith('wire:model')->except(['id'])->class(['min-w-0']) }}
>
<div
x-ref="bar"
role="tablist"
data-tabs-bar
data-variant="{{ $variant }}"
@if ($stacked && $variant === 'primary') data-stacked @endif
@if ($scrollable) data-scrollable @endif
style="--tabs-indicator: {{ $id }}-indicator"
x-on:keydown.arrow-right.prevent="step(1)"
x-on:keydown.arrow-left.prevent="step(-1)"
x-on:keydown.home.prevent="edge(false)"
x-on:keydown.end.prevent="edge(true)"
>
@foreach ($tabs as $tab)
@php($isInitial = (string) $tab['name'] === (string) $initial)
<button
type="button"
role="tab"
id="{{ $id }}-{{ $tab['name'] }}"
data-tab="{{ $tab['name'] }}"
aria-controls="{{ $id }}-{{ $tab['name'] }}-panel"
aria-selected="{{ $isInitial ? 'true' : 'false' }}"
tabindex="{{ $isInitial ? '0' : '-1' }}"
x-bind:aria-selected="(selected === @js($tab['name'])).toString()"
x-bind:tabindex="selected === @js($tab['name']) ? 0 : -1"
x-on:click="choose(@js($tab['name']))"
@disabled($tab['disabled'] ?? false)
>
<span data-tab-content>
@isset($tab['icon'])
<x-icon :name="$tab['icon']" class="size-6" />
@endisset
<span class="inline-flex items-center gap-1.5">
{{ $tab['label'] }}
@if (filled($tab['badge'] ?? null))
<x-badge :value="$tab['badge']" />
@endif
</span>
<span data-tab-indicator aria-hidden="true"></span>
</span>
</button>
@endforeach
</div>
{{ $slot }}
</div>
@@ -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')
<div
role="radiogroup"
aria-label="{{ __('Theme') }}"
x-data
data-theme-toggle="picker"
{{ $attributes->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])
<button
type="button"
role="radio"
aria-checked="false"
x-bind:aria-checked="($store.theme.choice === '{{ $choice }}').toString()"
x-bind:tabindex="$store.theme.choice === '{{ $choice }}' ? 0 : -1"
x-on:click="$store.theme.set('{{ $choice }}')"
x-on:keydown.arrow-right.prevent="$el.nextElementSibling?.click(); $el.nextElementSibling?.focus();"
x-on:keydown.arrow-left.prevent="$el.previousElementSibling?.click(); $el.previousElementSibling?.focus();"
data-theme-option="{{ $choice }}"
class="state-layer focus-ring inline-flex min-w-0 flex-1 cursor-pointer items-center justify-center gap-2 border-outline px-3 type-label-lg text-on-surface not-first:border-s first:rounded-s-corner-full last:rounded-e-corner-full aria-checked:bg-secondary-container aria-checked:text-on-secondary-container"
>
<x-icon name="check" class="hidden size-4.5 in-aria-checked:block" />
<x-icon :name="$icon" class="size-4.5 in-aria-checked:hidden" />
{{ __($text) }}
</button>
@endforeach
</div>
@else
<button
type="button"
x-data
data-icon-button
data-theme-toggle="{{ $mode }}"
@if ($mode === 'cycle')
aria-label="{{ __('Theme') }}"
x-bind:aria-label="{ light: @js(__('Theme: light')), dark: @js(__('Theme: dark')), system: @js(__('Theme: system')) }[$store.theme.choice]"
x-on:click="$store.theme.set({ light: 'dark', dark: 'system', system: 'light' }[$store.theme.choice])"
@else
aria-label="{{ __('Dark theme') }}"
aria-pressed="false"
x-bind:aria-pressed="($store.theme.resolved === 'dark').toString()"
x-on:click="$store.theme.toggle()"
@endif
{{ $attributes->class(['state-layer focus-ring inline-flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-corner-full text-on-surface-variant transition-[border-radius] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast active:rounded-corner-sm']) }}
>
@if ($mode === 'cycle')
<x-icon name="light_mode" x-cloak x-show="$store.theme.choice === 'light'" />
<x-icon name="dark_mode" x-cloak x-show="$store.theme.choice === 'dark'" />
<x-icon name="brightness_auto" x-cloak x-show="$store.theme.choice === 'system'" />
@else
<x-icon name="light_mode" x-cloak x-show="$store.theme.resolved === 'dark'" />
<x-icon name="dark_mode" x-cloak x-show="$store.theme.resolved !== 'dark'" />
@endif
</button>
@endif
@@ -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 `<x-fab>` beside a floating toolbar.
`label` names it for screen readers.
Put `<x-button icon="…" tooltip="…" />` 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)
<div data-toolbar-group @if ($vertical) data-vertical @endif @if ($place) data-toolbar-place="{{ $place }}" @endif>
@endif
<div
role="toolbar"
x-data="materialToolbar({{ $vertical ? 'true' : 'false' }})"
x-on:keydown="move($event)"
data-toolbar
data-variant="{{ $variant }}"
@if ($vertical) data-vertical aria-orientation="vertical" @endif
@if ($vibrant && $variant === 'floating') data-vibrant @endif
@if ($place && ! $grouped) data-toolbar-place="{{ $place }}" @endif
@if (filled($label)) aria-label="{{ $label }}" @endif
{{ $attributes }}
>
{{ $slot }}
</div>
@if ($grouped)
{{ $fab }}
</div>
@endif
+1
View File
@@ -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')
</main>
@endsection
+1 -1
View File
@@ -18,7 +18,7 @@
<a href="{{ route('livewire-material.showcase') }}" class="shrink-0 type-title-lg max-sm:hidden">Livewire Material</a>
<nav class="-my-2 flex min-w-0 flex-1 gap-x-4 overflow-x-auto py-2 whitespace-nowrap type-label-lg text-on-surface-variant [scrollbar-width:none]" aria-label="Sections">
@foreach (['colour' => 'Colour', 'type' => 'Type', 'shape' => 'Shape', 'elevation' => 'Elevation', 'motion' => 'Motion', 'icons' => 'Icons', 'buttons' => 'Buttons', 'menus' => 'Menus', 'communication' => 'Communication', 'progress' => 'Progress', 'containment' => 'Containment', 'carousel' => 'Carousel', 'fields' => 'Fields', 'chips' => 'Chips', 'sliders' => 'Sliders'] as $anchor => $section)
@foreach (['colour' => 'Colour', 'type' => 'Type', 'shape' => 'Shape', 'elevation' => 'Elevation', 'motion' => 'Motion', 'icons' => 'Icons', 'buttons' => 'Buttons', 'menus' => 'Menus', 'communication' => 'Communication', 'progress' => 'Progress', 'containment' => 'Containment', 'carousel' => 'Carousel', 'fields' => 'Fields', 'chips' => 'Chips', 'sliders' => 'Sliders', 'bars' => 'Bars'] as $anchor => $section)
<a href="#{{ $anchor }}" class="rounded-corner-xs hover:text-on-surface focus-ring">{{ $section }}</a>
@endforeach
</nav>
@@ -0,0 +1,144 @@
@php
$examples = [
'Top app bars' => <<<'BLADE'
<div class="grid w-full gap-4 [&>*]:overflow-hidden [&>*]:rounded-corner-lg [&>*]:border [&>*]:border-outline-variant">
<div>
<x-app-bar title="Shares" :sticky="false">
<x-slot:navigation><x-button icon="menu" tooltip="Open navigation" /></x-slot:navigation>
<x-slot:actions>
<x-button icon="search" tooltip="Search" />
<x-button icon="more_vert" tooltip="More" />
</x-slot:actions>
</x-app-bar>
</div>
<div>
<x-app-bar variant="center" title="holiday-photos.zip" subtitle="248 MB · expires in 3 days" :sticky="false">
<x-slot:navigation><x-button icon="arrow_back" tooltip="Back" /></x-slot:navigation>
<x-slot:actions><x-button icon="share" tooltip="Share" /></x-slot:actions>
</x-app-bar>
</div>
<div>
<x-app-bar variant="medium" title="Recipients" subtitle="3 people" :sticky="false">
<x-slot:navigation><x-button icon="arrow_back" tooltip="Back" /></x-slot:navigation>
<x-slot:actions><x-button icon="person_add" tooltip="Add recipient" /></x-slot:actions>
</x-app-bar>
</div>
<div>
<x-app-bar variant="large" title="Settings" :sticky="false">
<x-slot:navigation><x-button icon="arrow_back" tooltip="Back" /></x-slot:navigation>
</x-app-bar>
</div>
<div class="!overflow-visible">
<x-app-bar variant="search" :sticky="false" class="rounded-corner-lg">
<x-slot:navigation><x-button icon="menu" tooltip="Open navigation" /></x-slot:navigation>
<x-search placeholder="Search shares" docked />
<x-slot:actions>
<span class="mx-2 grid size-8 place-items-center rounded-corner-full bg-primary-container type-label-lg text-on-primary-container">AR</span>
</x-slot:actions>
</x-app-bar>
</div>
</div>
BLADE,
'Toolbars' => <<<'BLADE'
<div class="flex w-full flex-wrap items-center gap-6">
<x-toolbar label="Formatting">
<x-button icon="format_bold" tooltip="Bold" :selected="true" />
<x-button icon="format_italic" tooltip="Italic" :selected="false" />
<x-button icon="format_underlined" tooltip="Underline" :selected="false" />
<x-button icon="link" tooltip="Link" />
</x-toolbar>
<x-toolbar label="Share actions" vibrant>
<x-button icon="content_copy" tooltip="Copy link" />
<x-button icon="qr_code_2" tooltip="QR code" />
<x-button icon="mail" tooltip="Email" />
<x-slot:fab><x-fab icon="add" tooltip="New share" color="secondary" /></x-slot:fab>
</x-toolbar>
<x-toolbar label="View" vertical>
<x-button icon="grid_view" tooltip-right="Grid" :selected="true" />
<x-button icon="view_list" tooltip-right="List" :selected="false" />
<x-button icon="zoom_in" tooltip-right="Zoom in" />
</x-toolbar>
<div class="w-full overflow-hidden rounded-corner-lg border border-outline-variant">
<x-toolbar variant="docked" label="Selection">
<x-button icon="download" tooltip="Download" />
<x-button icon="share" tooltip="Share" />
<x-button icon="drive_file_move" tooltip="Move" />
<x-button icon="delete" tooltip="Delete" />
</x-toolbar>
</div>
</div>
BLADE,
'Primary tabs' => <<<'BLADE'
<div class="w-full space-y-8">
<x-tabs :tabs="[['name' => 'files', 'label' => 'Files'], ['name' => 'recipients', 'label' => 'Recipients', 'badge' => 3], ['name' => 'activity', 'label' => 'Activity'], ['name' => 'settings', 'label' => 'Settings', 'disabled' => true]]">
<x-tab name="files">Three files, 248 MB.</x-tab>
<x-tab name="recipients">Anna, Ben and Chiara.</x-tab>
<x-tab name="activity">Downloaded twice today.</x-tab>
</x-tabs>
<x-tabs stacked selected="photos" :tabs="[['name' => 'all', 'label' => 'All', 'icon' => 'folder'], ['name' => 'photos', 'label' => 'Photos', 'icon' => 'photo'], ['name' => 'videos', 'label' => 'Videos', 'icon' => 'movie']]">
<x-tab name="all">Everything shared with you.</x-tab>
<x-tab name="photos">Only photos.</x-tab>
<x-tab name="videos">Only videos.</x-tab>
</x-tabs>
</div>
BLADE,
'Secondary and scrollable tabs' => <<<'BLADE'
<div class="w-full space-y-8">
<x-tabs variant="secondary" :tabs="[['name' => 'overview', 'label' => 'Overview'], ['name' => 'security', 'label' => 'Security', 'icon' => 'lock'], ['name' => 'billing', 'label' => 'Billing']]">
<x-tab name="overview">An overview of the account.</x-tab>
<x-tab name="security">Passwords and two-factor sign-in.</x-tab>
<x-tab name="billing">Plan and invoices.</x-tab>
</x-tabs>
<div class="max-w-sm">
<x-tabs scrollable :tabs="collect(['January', 'February', 'March', 'April', 'May', 'June', 'July'])->map(fn ($month) => ['name' => strtolower($month), 'label' => $month])->all()">
<x-tab name="january">January's shares.</x-tab>
</x-tabs>
</div>
</div>
BLADE,
'Section navigation, account menu and theme toggles' => <<<'BLADE'
<div class="w-full space-y-8">
<x-section-nav :items="[
['title' => 'Profile', 'icon' => 'person', 'url' => '#bars', 'active' => true],
['title' => 'Security', 'icon' => 'lock', 'url' => '#bars'],
['title' => 'Notifications', 'icon' => 'notifications', 'url' => '#bars', 'badge' => 2],
['title' => 'Billing', 'icon' => 'credit_card', 'url' => '#bars'],
]" no-wire-navigate />
<div class="flex flex-wrap items-center gap-4">
<x-account-menu name="Andreas Reinhold" email="andreas@example.com">
<x-menu-item label="Settings" icon="settings" link="#bars" no-wire-navigate />
<x-slot:footer>
<x-menu-item label="Sign out" icon="logout" />
</x-slot:footer>
</x-account-menu>
<x-theme-toggle />
<x-theme-toggle mode="cycle" />
<x-theme-toggle mode="picker" />
</div>
</div>
BLADE,
];
@endphp
<section id="bars" class="scroll-mt-24 space-y-6">
<h2 class="type-headline-md">App bars, toolbars and tabs</h2>
<p class="max-w-3xl type-body-md text-on-surface-variant">
<code>&lt;x-app-bar&gt;</code>, <code>&lt;x-toolbar&gt;</code>, <code>&lt;x-tabs&gt;</code>, <code>&lt;x-tab&gt;</code>, <code>&lt;x-section-nav&gt;</code>, <code>&lt;x-account-menu&gt;</code> and <code>&lt;x-theme-toggle&gt;</code>.
</p>
@foreach ($examples as $title => $code)
<x-showcase::example :$title :$code />
@endforeach
</section>
+146
View File
@@ -0,0 +1,146 @@
<?php
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route;
use Livewire\Component;
use Livewire\Livewire;
class BarsProbe extends Component
{
public string $tab = 'files';
public function render(): string
{
return <<<'BLADE'
<div>
<x-app-bar variant="medium" title="Recipients" id="bar">
<x-slot:navigation><x-button icon="arrow_back" tooltip="Back" /></x-slot:navigation>
<x-slot:actions><x-theme-toggle id="toggle" /></x-slot:actions>
</x-app-bar>
<div class="space-y-6 p-4">
<p>tab: <span id="tab">{{ $tab }}</span></p>
<x-tabs id="share" wire:model.live="tab" :tabs="[['name' => 'files', 'label' => 'Files'], ['name' => 'people', 'label' => 'People'], ['name' => 'locked', 'label' => 'Locked', 'disabled' => true], ['name' => 'activity', 'label' => 'Activity']]">
<x-tab name="files">Three files.</x-tab>
<x-tab name="people">Anna and Ben.</x-tab>
<x-tab name="activity">Two downloads.</x-tab>
</x-tabs>
<x-toolbar label="Formatting">
<button type="button" id="bold">B</button>
<button type="button" id="italic">I</button>
<button type="button" id="underline">U</button>
</x-toolbar>
<x-theme-toggle mode="cycle" id="cycle" />
<x-theme-toggle mode="picker" />
<x-section-nav :items="[['title' => 'Profile', 'url' => '#profile', 'active' => true], ['title' => 'Security', 'url' => '#security']]" no-wire-navigate />
<div style="height: 200vh"></div>
</div>
</div>
BLADE;
}
}
function barsProbe()
{
Livewire::component('bars-probe', BarsProbe::class);
Route::middleware('web')->get('/bars-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body class="bg-surface">
<livewire:bars-probe />
@livewireScripts
</body>
</html>
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');
});
+105
View File
@@ -0,0 +1,105 @@
<?php
it('draws a small, sticky app bar with a heading, navigation and actions', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-app-bar title="Shares" subtitle="12 active">
<x-slot:navigation><button>menu</button></x-slot:navigation>
<x-slot:actions><button>search</button></x-slot:actions>
</x-app-bar>
BLADE);
expect($html)
->toContain('<header')
->toContain('x-data="materialAppBar"')
->toContain('data-variant="small"')
->toContain('data-sticky')
->toContain('data-subtitled')
->toContain('<h1 data-app-bar-title>Shares</h1>')
->toContain('<p data-app-bar-subtitle>12 active</p>')
->toContain('<div data-app-bar-leading><button>menu</button></div>')
->toContain('<div data-app-bar-trailing><button>search</button></div>');
});
it('gives a medium or large bar a collapsed title in its row and the heading under it', function () {
$html = (string) $this->blade('<x-app-bar variant="large" title="Settings" heading="h2" :sticky="false" />');
expect($html)
->toContain('data-variant="large"')
->not->toContain('data-sticky')
->toContain('<div data-app-bar-headline aria-hidden="true" >')
->toContain('<span data-app-bar-title>Settings</span>')
->toContain('<div data-app-bar-expanded>')
->toContain('<h2 data-app-bar-title>Settings</h2>');
});
it('puts a search bar in the row of a search app bar', function () {
expect((string) $this->blade('<x-app-bar variant="search"><x-search placeholder="Search shares" /></x-app-bar>'))
->toContain('data-app-bar-search')
->toContain('placeholder="Search shares"');
});
it('falls back to a small bar and an h1 for unknown values', function () {
expect((string) $this->blade('<x-app-bar variant="huge" heading="section" title="Shares" />'))
->toContain('data-variant="small"')
->toContain('<h1 data-app-bar-title>');
});
it('draws floating and docked toolbars', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-toolbar label="Formatting" vibrant vertical place="end">
<button>B</button>
<x-slot:fab><button>+</button></x-slot:fab>
</x-toolbar>
BLADE);
expect($html)
->toContain('<div data-toolbar-group data-vertical data-toolbar-place="end" >')
->toContain('role="toolbar"')
->toContain('materialToolbar(true)')
->toContain('data-variant="floating"')
->toContain('aria-orientation="vertical"')
->toContain('data-vibrant')
->toContain('aria-label="Formatting"')
->toContain('<button>+</button>')
->and((string) $this->blade('<x-toolbar variant="docked" vibrant vertical place="bottom"><button>B</button></x-toolbar>'))
->toContain('data-variant="docked"')
->not->toContain('data-vibrant')
->not->toContain('data-vertical')
->toContain('data-toolbar-place="bottom"')
->not->toContain('data-toolbar-group');
});
it('switches the theme through the store in three shapes', function () {
expect((string) $this->blade('<x-theme-toggle />'))
->toContain('data-theme-toggle="toggle"')
->toContain('aria-label="Dark theme"')
->toContain('$store.theme.toggle()')
->and((string) $this->blade('<x-theme-toggle mode="cycle" />'))
->toContain('data-theme-toggle="cycle"')
->toContain("{ light: 'dark', dark: 'system', system: 'light' }")
->and((string) $this->blade('<x-theme-toggle mode="picker" />'))
->toContain('role="radiogroup"')
->toContain('data-theme-option="system"')
->toContain("\$store.theme.set('dark')");
});
it('opens an account menu from the initials of a name', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-account-menu name="Anna Maria Keller" email="anna@example.com">
<x-menu-item label="Settings" />
<x-slot:footer><x-menu-item label="Sign out" /></x-slot:footer>
</x-account-menu>
BLADE);
expect($html)
->toContain('aria-label="Account"')
->toContain('data-account-menu')
->toMatch('/>\s*AM\s*<\/button>/')
->toContain('anna@example.com')
->toContain('Settings')
->toContain('data-account-theme')
->toContain('Sign out')
->and((string) $this->blade('<x-account-menu avatar="/anna.jpg" :theme="false" />'))
->toContain('<img src="/anna.jpg"')
->not->toContain('data-account-theme');
});
+82
View File
@@ -0,0 +1,82 @@
<?php
use Livewire\Component;
use Livewire\Livewire;
it('renders the tablist on the server with the first enabled tab chosen', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-tabs id="share" :tabs="[['name' => 'files', 'label' => 'Files', 'disabled' => true], ['name' => 'people', 'label' => 'People', 'icon' => 'group', 'badge' => 3]]">
<x-tab name="people">Anna</x-tab>
</x-tabs>
BLADE);
expect($html)
->toContain('role="tablist"')
->toContain('data-variant="primary"')
->toContain('id="share-people"')
->toContain('aria-controls="share-people-panel"')
->toMatch('/data-tab="people"\s+aria-controls="share-people-panel"\s+aria-selected="true"\s+tabindex="0"/')
->toMatch('/data-tab="files"\s+aria-controls="share-files-panel"\s+aria-selected="false"\s+tabindex="-1"/')
->toContain('disabled')
->toContain('data-tab-indicator')
->toContain('--tabs-indicator: share-indicator')
->toContain('x-modelable="selected"')
->toContain('role="tabpanel"')
->toContain('>3</span>');
});
it('takes a variant, stacks icons and scrolls on request', function () {
expect((string) $this->blade('<x-tabs variant="secondary" stacked scrollable :tabs="[[\'name\' => \'a\', \'label\' => \'A\']]" />'))
->toContain('data-variant="secondary"')
->not->toContain('data-stacked')
->toContain('data-scrollable')
->and((string) $this->blade('<x-tabs stacked selected="b" :tabs="[[\'name\' => \'a\', \'label\' => \'A\'], [\'name\' => \'b\', \'label\' => \'B\']]" />'))
->toContain('data-stacked')
->toMatch('/data-tab="b"\s+aria-controls="[^"]+"\s+aria-selected="true"/');
});
it('entangles the chosen tab with Livewire and renders the one the property names', function () {
Livewire::component('tabs-probe', new class extends Component
{
public string $tab = 'people';
public function render(): string
{
return '<div><x-tabs wire:model.live="tab" :tabs="[[\'name\' => \'files\', \'label\' => \'Files\'], [\'name\' => \'people\', \'label\' => \'People\']]" /></div>';
}
});
expect(Livewire::test('tabs-probe')->html())
->toContain(".entangle('tab').live")
->not->toContain('x-modelable')
->toMatch('/data-tab="people"\s+aria-controls="[^"]+"\s+aria-selected="true"/');
});
it('draws section navigation as secondary tabs and a picker', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-section-nav label="Settings" :items="[
['title' => 'Profile', 'icon' => 'person', 'url' => '/settings/profile'],
['title' => 'Security', 'icon' => 'lock', 'url' => '/settings/security', 'active' => true, 'badge' => 1],
]" />
BLADE);
expect($html)
->toContain('aria-label="Settings"')
->toContain('data-section-picker')
->toContain('data-variant="secondary"')
->toContain('sm:flex')
->toMatch('/href="\/settings\/security"\s+data-tab\s+aria-current="page"\s+wire:navigate/')
->not->toMatch('/href="\/settings\/profile"\s+data-tab\s+aria-current/')
->toContain('role="menuitemcheckbox"');
});
it('marks the section whose url is the request\'s, and wraps many sections onto a grid', function () {
$this->get('/');
$items = collect(range(1, 7))->map(fn (int $n): array => ['title' => "S{$n}", 'url' => $n === 3 ? url('/') : "/s/{$n}"])->all();
expect((string) $this->blade('<x-section-nav :items="$items" no-wire-navigate />', ['items' => $items]))
->toContain('sm:grid sm:grid-cols-4 xl:flex')
->toMatch('/data-tab\s+aria-current="page"\s*>\s*<span data-tab-content>\s*<span class="truncate">S3/')
->not->toContain('wire:navigate');
});