38 Commits
Author SHA1 Message Date
Andreas Reinhold / reiniandClaude Opus 5 b1fc0c9cfa Keep a connected group's inner corners round
tests / feature (8.4) (push) Successful in 1m17s
tests / feature (8.5) (push) Successful in 1m21s
tests / browser (chrome, chromium) (push) Successful in 3m55s
tests / browser (firefox, firefox) (push) Successful in 5m34s
tests / lint (push) Successful in 1m5s
tests / browser (safari, webkit) (push) Successful in 5m54s
Connected segments and split halves wrote their full corners as
--md-sys-shape-corner-full, 9999px, beside 8px inner corners on the same
element. A box whose radii add up to more than its side has every radius
scaled by one factor, so the inner corners shrank to a hundredth of a pixel
and drew square. Full is now half the size's height (--group-full), which no
side can overflow. A browser test fails on any radius in a group or split
button larger than half its height.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 22:46:10 +02:00
Andreas Reinhold / reiniandClaude Opus 5 a980f5b0d0 Expect the section picker to mark its page, not a checked item
The browser test still looked for the menuitemcheckbox the picker drew before
it marked the current section with aria-current.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 22:41:51 +02:00
Andreas Reinhold / reiniandClaude Opus 5 a4bc046d1d Speak a shell destination's badge in words, and stop a null offset on PHP 8.5
<x-app-shell> passed no badge label to its rail and bar items, so a badge
like 0/3 reached a screen reader bare; a destination's badgeLabel now does.

The head script indexed the profiles with a null scheme when none are
configured, which PHP 8.5 reports as a deprecated null array offset on every
page with theme.meta on. It reads the default scheme without the lookup.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 22:20:37 +02:00
Andreas Reinhold / reiniandClaude Opus 5 433ddb2baa Keep aria-pressed off a selected link button
ARIA defines aria-pressed for buttons, not links, so a selected <x-button
link> keeps the selected look without announcing itself as a toggle; the
caller marks the page with aria-current.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 21:36:35 +02:00
Andreas Reinhold / reiniandClaude Opus 5 e6f4357a2a Add a solid status label to the badge
A status label came tonal (the colour's container) or outlined; a label that
has to stand out in the colour itself fell back to the filled count, which is
16px and hidden from screen readers. solid draws the label shape in the
colour's filled pair and is spoken like the others.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 21:08:55 +02:00
Andreas Reinhold / reiniandClaude Opus 5 ea529bf785 Let a list-detail pane close on Escape when asked
A drawer that is a pane from xl ignored Escape, whatever close-on-escape
said: the page beside a pane stays in use, so that is the right default. An
application whose pane is a transient detail (ReStride's activity and
workout panes) can now pass pane-close-on-escape and have Escape close it at
every width.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 20:31:33 +02:00
Andreas Reinhold / reiniandClaude Opus 5 869ccdf447 Mark a section picker's current page as the page, with its badge
The phone picker of <x-section-nav> drew the current section as a checked
menuitemcheckbox in tertiary-container, which a screen reader announces as a
choice rather than where you are, and it dropped the sections' badges, so an
open support count showed on the tabs but not on a phone. <x-menu-item> gains
current (aria-current="page", the selected shape in secondary-container,
the navigation indicator's colour) and badge (a dot or a count at the end of
the row), and the picker uses both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 19:56:54 +02:00
Andreas Reinhold / reiniandClaude Opus 5 65788d0c61 Keep a date picker's and an app bar's settings on their own component
materialDatepicker set its first day, format, min, max and year range in
init() without declaring them, and materialAppBar did the same with its
resize observer and scroll handler. Alpine writes an undeclared property to
the outermost x-data scope, so two pickers inside one page scope (ReStride's
plan setup wraps its form in x-data) shared the last one's min and first day,
and a second app bar would have taken the first one's observer. Both now
declare them; a browser test puts two pickers in an outer scope.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 19:17:11 +02:00
Andreas Reinhold / reiniandClaude Opus 5 8640d815c8 Keep open menus and rich tooltips open through a Livewire render
<x-menu>, <x-fab-menu> and <x-rich-tooltip> gave their popover an id
that is new with every render, and Livewire's morph matches an element
without a wire:key by its id. So every render of the component around
them swapped the popover for a closed copy: an open menu closed, its
listeners stayed behind on the old element (Escape or a press outside
then left aria-expanded="true" on the trigger and focus unreturned), a
keep-open item's wire:click closed its menu when the response came, and
a rich tooltip went on showing the detached bubble, so it never opened
again. <x-carousel>'s row had the same kind of id: after a render it
scrolled without its listeners, and its items stopped re-masking.

The popover, the bubble and the row now carry a wire:key, so the morph
patches them in place and changes the id and anchor name together with
the trigger's, as it already did for everything else. The key goes
through an attribute bag: Livewire compiles a wire:key written in a
template into the key of the loop iteration around it, which would have
given every child component after the menu in a row the same key.

A morph also removes the menu button's ARIA attributes, which only
script writes. menu.js now writes them again after every morph, so the
button of a menu that stays open, and a FAB menu's close look, still
say it is open, and aria-controls names the popover's new id.

A second press on an open menu's button opened it again, render or not:
the popover closes on the press, and the guard against the click that
follows was timed from the toggle event, which is queued and arrives
after that click. It is timed from beforetoggle now.

Browser tests with Livewire probes in Chromium, Firefox and WebKit, and
a render test for the keys and the keys of the child components after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 18:50:14 +02:00
Andreas Reinhold / reiniandClaude Opus 5 ab7317faae Add hint-class to the group and icon-class to menu items
<x-group hint-class> adds classes to the hint, as the text fields'
hint-class does, and a validation message still replaces the hint.
<x-menu-item icon-class> adds classes to the leading icon, for an icon
whose colour means something of its own, such as a sport's glyph.

A colour class there has to win over the component's own colour, and
which of two colour utilities wins depends on the order Tailwind emits
them (text-error comes before text-on-surface-variant). So when either
prop is given, the component's own colour is written with a :where()
variant that carries no specificity, as the fields' hint colour sits
in the components layer. A disabled item's icon stays disabled. Without
the props the markup is unchanged. The skill now also lists the fields'
hint-class, which it had left out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 18:18:39 +02:00
Andreas Reinhold / reiniandClaude Opus 5 a83d7f62ea Hang a menu on its menu button rather than the trigger's wrapper
<x-menu> named its CSS anchor on the <span> around the trigger slot.
A trigger taken out of the flow, such as <x-button fab> fixed to the
bottom corner of a phone's window, left that span behind as an empty
box where the page put it, and the menu opened there. menu.js now moves
the name onto the menu button, beside any name the button already
carries for its tooltip, and moves it again whenever a Livewire morph
puts the server's attributes and a fresh name back. The wrapper keeps
the name until Alpine starts, or when there is no menu button.

A menu that fits neither below nor above its start edge now also tries
the opposite side and end together, so a default-position menu on a
FAB in the bottom-right corner opens above it, end-aligned. Before, it
fell back to its base position and overflowed the window.

This also applies to <x-fab-menu>, <x-account-menu>, <x-split-button>
and the <x-section-nav> picker, which share menu.js.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 18:18:39 +02:00
Andreas Reinhold / reiniandClaude Opus 5 fb29169d44 Let the date picker take a first day of the week and a format
An application that lets each person choose when their week starts and
how dates are written needs the picker to follow that choice rather
than the locale. <x-datepicker> takes week-start, 0 (Sunday) to 6
(Saturday), and format, dd, MM and yyyy each once around one delimiter
(dd.MM.yyyy, dd/MM/yyyy, MM/dd/yyyy, yyyy-MM-dd). They replace what
Intl derives for firstDayOfWeek() and inputFormat(), so the field, the
server-rendered value, the typed-date reader (year first too), the
calendar's columns and weekday header, Home and End, the dialog's text
fields and ranges all follow them. Month and weekday names stay the
locale's, and wire:model still stores Y-m-d. Values that are neither
are ignored, and without the props nothing changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 18:18:39 +02:00
Andreas Reinhold / reiniandClaude Opus 5 fdb13313f7 Keep the current section lit while a Livewire component updates
<x-section-nav> and <x-app-shell> mark an item current when its URL is
the request's. While a Livewire component on the page re-renders, the
request is Livewire's update endpoint, so no section or destination
stayed lit after the morph. Both now compare with the page's URL from
Livewire::originalUrl() during a Livewire update request; a normal
request compares exactly as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 18:18:39 +02:00
Andreas Reinhold / reiniandClaude Opus 5 d77ecf5e2a Add an illustration slot to the empty state
An application with its own artwork for an empty collection puts it in
the `illustration` slot, which is drawn in place of the shape and icon.
The slot's attributes go on the element around it, so its class sets the
colour an SVG's currentColor takes. A slot holding only whitespace or
comments counts as empty (hasActualContent), and without the slot the
empty state renders as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 18:11:00 +02:00
Andreas Reinhold / reiniandClaude Opus 5 e63c0f684c Bind the collapse's open state through wire:model and x-model
An application keeps a disclosure's state in a Livewire property
(`<x-collapse wire:model="fineTuning">`) or an Alpine one. Either binding
now works both ways: toggling the details writes the property, and the
property changing, in an action or in Alpine, opens or closes it.

`wire:model` is entangled as the dialog and sheets entangle theirs, and
taken off the element. The server renders `open` from the property, so
the first paint matches it. `x-model` binds through `x-modelable`, with
`open` as the first paint until Alpine starts. The state is named
`collapseOpen` so it never hides an `open` that a dialog in the slot reads
from a scope around it.

Without a binding the element has no Alpine and renders as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 18:10:59 +02:00
Andreas Reinhold / reiniandClaude Opus 5 973cc0b68c Render badge slots as HTML and add neutral and plain badge colours
The slot was cast to a string and escaped, so an icon beside a badge's
word showed as literal markup. It renders as HTML now; `value` stays
escaped, and a slot holding only whitespace or comments is still a dot
(hasActualContent rather than isNotEmpty).

`color="neutral"` draws neutral ink on every variant: a dot or count in
on-surface-variant with surface text (the ink the outline badge already
writes in), a tonal label in surface-container-high with
on-surface-variant text, and an outline in outline-variant.

`color="plain"` emits no background, text or border colour in any
variant, keeping shape, size and type, so an application's own colour
classes paint it without racing the badge's.

Every existing colour renders the same classes as before, and an unknown
colour still falls back to error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 18:10:59 +02:00
Andreas Reinhold / reiniandClaude Opus 5 07e008d942 Add sticky toasts, action events and toast hooks
A sticky toast stays until it is dismissed or its action pressed, for a
question that must be answered ("A new version is ready" with Reload).
It is kept aside rather than queued, so it never holds up ordinary
toasts: one that arrives while it shows takes its place, and the sticky
toast comes back once the queue is empty. One is kept at a time; a newer
sticky toast replaces it. Every other toast keeps today's queue.

An action's `event` names a window event dispatched when it is pressed,
beside `handler`, for toasts whose detail cannot carry a function. The
snackbar now closes before either runs, so a toast they show is not the
one dismissed.

`data-toast` and `data-toast-action` give applications stable hooks for
their tests.

The queue now forgets a cleared timer in next(): a toast dismissed by a
click that neither hovered nor focused it first, as a screen reader
activates a button, left its timer running and cut the next toast short.

The showcase's snackbar buttons had no Alpine scope and did nothing; they
are wrapped in one, with a sticky example.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 18:10:59 +02:00
Andreas Reinhold / reiniandClaude Opus 5 53bb000425 Let an application replace the safe-area insets and dock on the bottom bar
Components read the device's safe-area insets straight from env(), which a
browser test cannot fake and an application drawing its own status strip
cannot extend. Every inset the package reads, in its CSS and its views, is
now var(--material-safe-top|bottom|left|right, env(safe-area-inset-…)):
unchanged while the variables are unset.

The app shell's --material-bottom-bar also adds
var(--material-bottom-extra, 0px), so an application that docks something on
the phone's navigation bar (an offline banner) sets its height once and the
snackbar, a fab button and the page's bottom padding clear it.

A guard test fails on any env(safe-area-inset-*) outside such a variable, and
AppShellTest's assertion on the bar height's class follows the new value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 18:03:20 +02:00
Andreas Reinhold / reiniandClaude Opus 5 bcf0d4f4e3 Keep the theme-color meta in step with the theme
An app installed as a PWA, and a mobile browser, colour their bar from
<meta name="theme-color">, which the package left alone: the bar kept the
server's one colour whatever theme the visitor chose.

With the new `theme.meta` option (off by default), <x-theme-script> sets the
content of every theme-color meta without a `media` attribute to the
resolved theme's surface, from the scheme data and for the profile in
<html data-scheme>, adding one to <head> when the page has none. A
MutationObserver on <html> follows every later change of data-theme or
data-scheme: $store.theme's set() and toggle(), an OS change while
`system`, a profile preview, an application's own script. wire:navigate's
head merge puts the next page's server-rendered meta in place, so it is
painted again as the page is swapped in and on livewire:navigated. Turned
off, the script is byte-for-byte what it was.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 17:59:34 +02:00
Andreas Reinhold / reiniandClaude Opus 5 3a7aa96ec7 Add a colour spec choice to material:scheme
The scheme script always generated with material-color-utilities' 2025 spec.
An application whose palette was generated with M3's original 2021 colour
(ReStride) could not reproduce it. The script now takes `spec` ('2025' by
default, or '2021') and refuses anything else, and the command takes
`--spec`, validated before Node runs.

Colour profiles may set their own `spec`, `success`, `warning` and `info`;
without them the command's options apply. The stylesheet header writes out
every option that differs from its default, so the recorded command
regenerates the file, and a profile whose spec differs from the header's
names it. Default output is byte-for-byte unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 17:52:50 +02:00
Andreas Reinhold / reiniandClaude Opus 5 db179e6b4e Compile the showcase's views with the showcase off
tests / lint (push) Successful in 1m6s
tests / feature (8.4) (push) Successful in 1m18s
tests / feature (8.5) (push) Successful in 1m17s
tests / browser (chrome, chromium) (push) Successful in 3m27s
tests / browser (firefox, firefox) (push) Successful in 4m57s
tests / browser (safari, webkit) (push) Successful in 5m29s
php artisan view:cache compiles every view in the package's namespace,
the showcase's pages included, but the showcase:: component prefix was
only registered while the showcase was enabled. In production, where it
is off, view:cache failed with "Unable to locate a class or view for
component [showcase::example]" and an application caching its views on
start would not boot. The prefix is now always registered; the routes
still are not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 17:40:33 +02:00
Andreas Reinhold / reiniandClaude Opus 5 8afe9bac6a Keep planning notes out of the repository
tests / lint (push) Successful in 1m1s
tests / feature (8.4) (push) Successful in 1m17s
tests / feature (8.5) (push) Successful in 1m19s
tests / browser (chrome, chromium) (push) Successful in 3m30s
tests / browser (firefox, firefox) (push) Successful in 4m57s
tests / browser (safari, webkit) (push) Successful in 5m23s
docs/plans is ignored from now on; the notes stay on the machine that
wrote them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 16:25:35 +02:00
Andreas Reinhold / reiniandClaude Opus 5 d805dbc407 Lay the scheme picker out as tiles and pass its attributes to the group
tests / lint (push) Successful in 1m6s
tests / feature (8.4) (push) Successful in 1m12s
tests / feature (8.5) (push) Successful in 1m16s
tests / browser (chrome, chromium) (push) Successful in 3m17s
tests / browser (firefox, firefox) (push) Successful in 4m43s
tests / browser (safari, webkit) (push) Successful in 5m22s
The check sat beside the name and truncated it at four columns; it now
sits in the tile's corner under the swatch dots. Attributes other than
the binding (data-test, class) reach the fieldset, and the dots' ring
follows the selected tile's colour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 14:39:26 +02:00
Andreas Reinhold / reiniandClaude Opus 5 fb42f004b1 Add colour profiles
An application lists named schemes in livewire-material.profiles, and
material:scheme without a seed generates them all into one stylesheet:
the default profile as the plain blocks, every profile under
<html data-scheme>, with descendant selectors so a nested data-theme
panel keeps the page's profile. The JSON keeps the 1.0 top level.

Scheme::resolveProfileUsing() lets the application name the active
profile; it is asked on every use and falls back to the default for an
unknown name or a resolver that throws. The head script writes it before
the first paint and keeps it through wire:navigate, and the mail theme
and the error pages' fallback draw it. <x-scheme-picker> chooses one and
previews it through $store.theme.previewScheme(); the showcase previews
the Workbench's profiles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 14:35:49 +02:00
Andreas Reinhold / reiniandClaude Opus 5 7798352cfc Compile unprefixed components to the same view on every machine
tests / lint (push) Successful in 1m3s
tests / feature (8.4) (push) Successful in 1m14s
tests / feature (8.5) (push) Successful in 1m14s
tests / browser (chrome, chromium) (push) Successful in 3m16s
tests / browser (firefox, firefox) (push) Successful in 4m36s
tests / browser (safari, webkit) (push) Successful in 5m8s
Without a prefix, Blade named the components' view namespace after the
folder's absolute path, while compiled views are named relative to the
application. A view compiled on a laptop and served from a container
sharing storage/framework/views (or from another release directory)
printed 'eb5f…::input' as text. The components now register under the
prefix livewire-material when none is configured, which unprefixed tags
still resolve through, and the path's namespace stays registered for
views compiled before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 11:16:52 +02:00
Andreas Reinhold / reiniandClaude Opus 5 cb3aa798a0 Keep a full-screen dialog's subtitle on a phone
tests / lint (push) Successful in 1m20s
tests / feature (8.4) (push) Successful in 1m13s
tests / feature (8.5) (push) Successful in 1m13s
tests / browser (chrome, chromium) (push) Successful in 3m17s
tests / browser (firefox, firefox) (push) Successful in 4m37s
tests / browser (safari, webkit) (push) Successful in 5m10s
The bar names the dialog there, so only the headline hides; the subtitle
used to disappear with it, and a subtitle without a title never showed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 10:57:36 +02:00
Andreas Reinhold / reiniandClaude Opus 5 648a4cfe0f Show a plain form field's errors under its name
tests / lint (push) Successful in 1m5s
tests / feature (8.4) (push) Successful in 1m9s
tests / feature (8.5) (push) Successful in 1m9s
tests / browser (chrome, chromium) (push) Successful in 3m19s
tests / browser (safari, webkit) (push) Successful in 5m0s
tests / browser (firefox, firefox) (push) Successful in 4m34s
Fields read their errors only under the wire:model name, so a Fortify
login form (name="email", no wire:model) never showed "These
credentials do not match". Without wire:model they now read the name,
turning brackets into dots.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 10:41:37 +02:00
Andreas Reinhold / reiniandClaude Opus 5 51bf034b74 Add a search to the showcase
tests / feature (8.5) (push) Successful in 1m14s
tests / lint (push) Successful in 1m1s
tests / feature (8.4) (push) Successful in 1m9s
tests / browser (chrome, chromium) (push) Successful in 3m22s
tests / browser (firefox, firefox) (push) Successful in 4m37s
tests / browser (safari, webkit) (push) Successful in 5m4s
A search app bar looks through every section, example and component,
opens the section, or the example at its anchor on its page, and is
reached from anywhere with / or Ctrl+K. Section pages now carry their
own heading.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 09:56:00 +02:00
Andreas Reinhold / reiniandClaude Opus 5 7ebcb0565f Send a carousel scroll on when it comes to rest short of its item
tests / lint (push) Has been cancelled
tests / feature (8.4) (push) Has been cancelled
tests / feature (8.5) (push) Has been cancelled
tests / browser (chrome, chromium) (push) Has been cancelled
tests / browser (firefox, firefox) (push) Has been cancelled
tests / browser (safari, webkit) (push) Has been cancelled
CI's WebKit twice left the carousel on its first item after Next. A
scroll started by the buttons or keys that comes to rest anywhere but
its item is now sent there once more; a scroll the person starts
themselves cancels that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 09:40:40 +02:00
Andreas Reinhold / reiniandClaude Opus 5 889f8a8aa1 Put the showcase in the app shell, with a page per section
tests / lint (push) Has been cancelled
tests / feature (8.4) (push) Has been cancelled
tests / feature (8.5) (push) Has been cancelled
tests / browser (chrome, chromium) (push) Has been cancelled
tests / browser (firefox, firefox) (push) Has been cancelled
tests / browser (safari, webkit) (push) Has been cancelled
The showcase is now an overview and one page per section behind a
grouped navigation rail, moved between with wire:navigate, instead of
one long page under a scrolling top bar. The switch exposed a WebKit
bug in the menu: inside a focusable region, WebKit moves focus out of
the closing popover before its toggle event, so Escape no longer
returned focus to the trigger. The menu now reads it on beforetoggle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 09:30:26 +02:00
Andreas Reinhold / reiniandClaude Opus 5 23218431bf Add a clear button to the date picker
`clearable` empties a date, or both ends of a range, closes an open
picker and hands focus back to the field, as the time picker's does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 09:30:26 +02:00
Andreas Reinhold / reiniandClaude Opus 5 eca6b8fb05 Give browser tests on CI more time and wait for Alpine in the theme test
tests / lint (push) Has been cancelled
tests / feature (8.4) (push) Has been cancelled
tests / feature (8.5) (push) Has been cancelled
tests / browser (chrome, chromium) (push) Has been cancelled
tests / browser (firefox, firefox) (push) Has been cancelled
tests / browser (safari, webkit) (push) Has been cancelled
CI's WebKit job failed twice on a slow runner: a carousel scroll that had
not settled within five seconds, and a press on the theme switch before
Alpine had revealed it. BROWSER_TIMEOUT raises the assertion timeout, and
the workflow sets it to fifteen seconds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 09:14:11 +02:00
Andreas Reinhold / reiniandClaude Opus 5 cbf874a5a6 Complete the showcase and record Phase 10
tests / lint (push) Has been cancelled
tests / feature (8.4) (push) Has been cancelled
tests / feature (8.5) (push) Has been cancelled
tests / browser (chrome, chromium) (push) Has been cancelled
tests / browser (firefox, firefox) (push) Has been cancelled
tests / browser (safari, webkit) (push) Has been cancelled
Shows the plain tooltip on its own, checks that every component appears
in the showcase, and records the namespace change and the fresh
application check in the plan.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 09:08:35 +02:00
Andreas Reinhold / reiniandClaude Opus 5 1e580c573e Resolve the package's own components through its namespace
Package views now write <x-livewire-material::button>, so a configured
prefix (which Blade spells <x-m::button>) and an application component
of the same name can no longer break or shadow them. The showcase
rewrites its examples to the configured prefix. Adds the README, and
waits for the adaptive rail to hear a resize before the navigation
tests press its menu button.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 09:05:24 +02:00
Andreas Reinhold / reiniandClaude Opus 5 8f174520eb Add M3 error pages and the Markdown mail theme
tests / feature (8.5) (push) Successful in 1m8s
tests / browser (safari, webkit) (push) Has been cancelled
tests / browser (firefox, firefox) (push) Has been cancelled
tests / lint (push) Successful in 1m5s
tests / feature (8.4) (push) Successful in 1m11s
tests / browser (chrome, chromium) (push) Failing after 6m9s
Error pages for 401 to 503 in an error-view root appended to view.paths,
with an inline fallback when the Vite build is missing, and a mail theme
rendered from the application's scheme JSON, with opt-in header and
message components. Completes Phase 9.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 08:55:58 +02:00
Andreas Reinhold / reiniandClaude Opus 5 ab692b66bb Add the navigation bar, navigation rail and app shell
M3 Expressive's flexible navigation bar, the collapsed, expanded and
modal navigation rail with its state applied before the first paint,
and an adaptive app shell composing them. The head script now restores
the theme and rail attributes that wire:navigate strips from <html>.
Completes Phase 8.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 08:54:51 +02:00
Andreas Reinhold / reiniandClaude Opus 5 f4be9848e2 Add the M3 date pickers
tests / lint (push) Successful in 1m5s
tests / feature (8.4) (push) Successful in 1m10s
tests / feature (8.5) (push) Successful in 1m7s
tests / browser (firefox, firefox) (push) Failing after 8m28s
tests / browser (chrome, chromium) (push) Failing after 5m58s
tests / browser (safari, webkit) (push) Failing after 7m24s
Docked, modal and modal input date pickers for single dates and ranges,
with locale month names, week start and typed format from Intl, min and
max, and the APG grid keyboard. Completes Phase 7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 08:48:56 +02:00
Andreas Reinhold / reiniandClaude Opus 5 b8239dd215 Add the M3 time picker
The dial and input time picker in a modal dialog, with the hour cycle
from the locale, steps, a min and max that may span midnight, and a
landscape layout, ported from Compose.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 08:47:42 +02:00
163 changed files with 13185 additions and 955 deletions
+2
View File
@@ -115,3 +115,5 @@ jobs:
- name: Run browser tests
run: vendor/bin/pest --testsuite=Browser --browser ${{ matrix.browser }}
env:
BROWSER_TIMEOUT: 15000
+3
View File
@@ -7,3 +7,6 @@
/workbench/public/hot
/tests/Browser/Screenshots
.DS_Store
# Planning notes stay local
/docs/plans
+14
View File
@@ -21,6 +21,20 @@ M3 Expressive carousel keylines (resources/js/carousel.js)
Ported from androidx Compose Material 3 (carousel/*.kt), as recorded at the top of the file.
Copyright The Android Open Source Project. Apache License 2.0.
M3 date pickers (resources/views/components/datepicker.blade.php, resources/js/datepicker.js,
resources/css/components/datepicker.css)
Token values and layout from androidx Compose Material 3 (DatePickerModalTokens,
DateInputModalTokens, DatePicker.kt, DateRangePicker.kt, DateInput.kt; the input-format
derivation ported from datePatternAsInputFormat), commit 27cf9a7d5788aa0f5f2d8b6699ce279560daf326,
and the docked picker's values from @material/web's md-comp-date-picker-docked tokens (v0_192).
Copyright The Android Open Source Project; Copyright Google LLC. Apache License 2.0.
M3 time picker (resources/js/timepicker.js, resources/css/components/timepicker.css)
Behaviour and geometry ported from androidx Compose Material 3 (TimePicker.kt,
TimePickerDialog.kt, tokens/TimePickerTokens.kt, tokens/TimeInputTokens.kt), as recorded at
the top of resources/js/timepicker.js.
Copyright The Android Open Source Project. Apache License 2.0.
Google Sans Flex (resources/fonts/google-sans-flex)
Copyright Google LLC. SIL Open Font License 1.1 (resources/fonts/google-sans-flex/OFL.txt).
Subset: Latin and Latin Extended, weight 400700, roundness 0100.
+170
View File
@@ -0,0 +1,170 @@
# Livewire Material
Material 3 Expressive components for Laravel and Livewire, built on Tailwind CSS 4.
- Anonymous Blade components for the current M3 Expressive catalogue: buttons and FABs, menus, chips, text fields, selection controls, sliders, pickers, dialogs and sheets, lists, cards, carousels, progress and loading indicators, snackbars, tabs, app bars, toolbars, navigation bars and rails, an adaptive app shell, data tables and pagination.
- A colour scheme generated from one seed colour with Google's colour science (`php artisan material:scheme`), light and dark, and a theme that is chosen before the first paint.
- The full Material Symbols Rounded set and the M3 Expressive shapes, drawn inline without an icon package.
- Error pages and a Markdown mail theme in the same scheme.
- A showcase of every component in the application's own scheme, a design guard for tests, and Laravel Boost guidelines and a skill for AI agents.
No JavaScript libraries beyond the Alpine that ships with Livewire. Browsers: Chrome 125+, Firefox 147+, Safari 18.4+.
## Requirements
PHP 8.4+, Laravel 13, Livewire 4, Tailwind CSS 4 with Vite, and Node (for `material:scheme`).
## Installation
The package is served from Gitea. Add the repository and require it:
```bash
composer config repositories.livewire-material vcs https://gitea.nonameweb.ch/noNameWEB/livewire-material.git
composer require nonameweb/livewire-material
```
### Stylesheet and script
The application's build imports from `vendor/`, so Composer packages must be installed before `npm run build` — in a Dockerfile, copy `composer.json`, run `composer install`, then build the assets.
```css
/* resources/css/app.css */
@import 'tailwindcss';
@import '../../vendor/nonameweb/livewire-material/resources/css/material.css';
@import './material-scheme.css';
@source '../../vendor/nonameweb/livewire-material/resources/views';
@source '../../vendor/nonameweb/livewire-material/src';
```
```js
// resources/js/app.js
import '../../vendor/nonameweb/livewire-material/resources/js/material.js'
```
Do not install Alpine separately; Livewire provides it.
### Layout
The theme script goes in `<head>`, before `@vite`, so the page paints in the visitor's theme:
```blade
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<x-theme-script />
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="bg-surface font-sans text-on-surface antialiased">
{{ $slot }}
<x-toast />
</body>
</html>
```
### Colour scheme
Generate the scheme from a seed colour. It writes `resources/css/material-scheme.css` (imported above) and `material-scheme.json` (read by the mail theme):
```bash
php artisan material:scheme "#4f46e5" --variant=tonal-spot
```
Variants: `tonal-spot`, `vibrant`, `expressive`, `neutral`, `fidelity`, `content`, `monochrome`, `rainbow`, `fruit-salad`. `--spec` is the colour spec: `2025` (M3 Expressive, the default) or `2021` (M3 as it first shipped, for a palette generated before Expressive). `--contrast` runs from -1 to 1; `--success`, `--warning` and `--info` seed the state colours. The stylesheet's header records the command that regenerates it; regenerate instead of editing the file.
#### Colour profiles
To let an installation switch between several schemes, list them as `profiles` in the config (name ⇒ `label`, `seed`, `variant`, and optionally `contrast`, `spec`, `success`, `warning`, `info`, which otherwise come from the command's options) and run `php artisan material:scheme` without a seed: every profile lands in the same stylesheet under `<html data-scheme>`. Tell the package which one is active — `Scheme::resolveProfileUsing(fn () => Setting::get('color_profile'))` in a service provider — and the head script, mails and error pages follow it. `<x-scheme-picker wire:model="colorProfile" />` lets someone choose, previewing each profile on the page.
### Configuration
```bash
php artisan vendor:publish --tag=livewire-material-config
```
- `prefix` — components are `<x-button>`, `<x-card>`… Set `'m'` when a name clashes with the application's own components, and they become `<x-m::button>`. `<x-livewire-material::button>` always works.
- `theme.default` (`light`, `dark` or `system`), `theme.storage_key`, `theme.legacy_keys` (an earlier toggle's localStorage keys, adopted once).
- `theme.meta` — keep `<meta name="theme-color">` (an installed web app's or a mobile browser's bar) on the resolved theme's `surface` and the active colour profile, before the first paint and after every change, `wire:navigate` included; one is added when the page has none (default `false`).
- `profiles`, `profile` — colour profiles and the default one (see Colour profiles).
- `fields.variant` — text fields `outlined` (default) or `filled`.
- `pagination` — draw Laravel's and Livewire's paginators in M3 (default `true`).
- `showcase.enabled`, `showcase.path`, `showcase.middleware`, `showcase.vite`.
- `node` — the Node binary for `material:scheme`.
## Usage
```blade
<x-card title="holiday-photos.zip" subtitle="248 MB · expires in 3 days" variant="outlined">
<x-slot:actions>
<x-button label="Copy link" icon="content_copy" wire:click="copy" />
<x-button label="Delete" danger wire:click="$set('confirming', true)" />
</x-slot:actions>
</x-card>
<x-modal wire:model="confirming" title="Delete this share?" icon="delete">
Recipients lose access at once.
<x-slot:actions>
<x-button label="Cancel" x-on:click="close()" />
<x-button label="Delete" danger wire:click="delete" />
</x-slot:actions>
</x-modal>
```
```php
use NoNameWeb\LivewireMaterial\Concerns\Toasts;
class Shares extends Component
{
use Toasts;
public function copy(): void
{
$this->success('Link copied');
}
}
```
Every component, prop and slot is documented in the Boost skill (`resources/boost/skills/livewire-material-development/SKILL.md`) and shown in the showcase.
## Showcase
While the application runs locally (or with `MATERIAL_SHOWCASE=true`), `/material` shows every token and component, in every variant, in the application's own scheme and theme: an overview, and a page per section behind a navigation rail (the package's own app shell), with a search over every section, example and component (press `/`).
## Testing the design
```php
use NoNameWeb\LivewireMaterial\Testing\DesignGuard;
it('uses only what compiles', function () {
expect(DesignGuard::scan([resource_path('views'), resource_path('js'), app_path()])
->forbidColours(['tertiary'])
->violations())->toBe([]);
});
```
The guard fails on maryUI tags, daisyUI classes, colours the theme does not declare, unknown Material Symbol names and Blade directives written inside component tags.
## AI agents
With [Laravel Boost](https://github.com/laravel/boost), `php artisan boost:install` (or `boost:update --discover`) picks up the package's guideline and the `livewire-material-development` skill.
## Developing the package
```bash
composer install && npm install
npm run build # the Workbench's assets (or `npm run dev` while working)
composer serve # the showcase at http://127.0.0.1:8000/material
vendor/bin/pest --testsuite=Feature
npx playwright install
vendor/bin/pest --testsuite=Browser --browser chrome # also firefox, safari
```
## Credits
Material Symbols, the M3 Expressive shapes, Google Sans Flex, material-color-utilities and Jetpack Compose Material 3's tokens and algorithms are Google's and the Android Open Source Project's; see `NOTICE`.
## License
MIT. See `LICENSE`.
+8 -3
View File
@@ -6,7 +6,8 @@
* nothing but `node`. (The published library imports without file extensions, which
* plain Node refuses, so it cannot be run unbundled anyway.)
*
* Input: one JSON argument — {seed, variant, contrast, success, warning, info}.
* Input: one JSON argument — {seed, variant, spec, contrast, success, warning, info}. `spec` is
* the colour spec, '2025' (M3 Expressive, the default) or '2021' (M3 as it first shipped).
* Output: JSON on stdout — {seed, variant, spec, contrast, light: {role: hex}, dark: {role: hex}}.
*/
import {
@@ -56,9 +57,12 @@ try {
const hex = /^#[0-9a-f]{6}$/i
const Scheme = VARIANTS[input.variant]
const SPECS = ['2021', '2025']
const spec = input.spec ?? '2025'
if (!hex.test(input.seed ?? '')) fail(`The seed must be a #rrggbb colour, "${input.seed}" given.`)
if (!Scheme) fail(`Unknown variant "${input.variant}". Use one of: ${Object.keys(VARIANTS).join(', ')}.`)
if (!SPECS.includes(spec)) fail(`Unknown spec "${spec}". Use one of: ${SPECS.join(', ')}.`)
for (const state of ['success', 'warning', 'info']) {
if (!hex.test(input[state] ?? '')) fail(`The ${state} colour must be a #rrggbb colour, "${input[state]}" given.`)
@@ -73,8 +77,9 @@ const colors = new MaterialDynamicColors()
function roles(isDark) {
// The 2025 spec is M3 Expressive's colour; the library falls back to 2021 for the
// variants the new spec does not define (fidelity, content, monochrome, …).
const scheme = new Scheme(source, isDark, contrast, '2025')
// variants the new spec does not define (fidelity, content, monochrome, …). 2021 is
// M3's original colour, for an application whose palette was generated with it.
const scheme = new Scheme(source, isDark, contrast, spec)
const out = {}
for (const color of colors.allColors) {
+87 -3
View File
@@ -8,8 +8,10 @@ return [
|--------------------------------------------------------------------------
|
| Every component is an anonymous Blade component. Without a prefix they are
| <x-button>, <x-card> and so on; set a prefix such as 'm-' when a name
| clashes with one of the application's own components (<x-m-button>).
| <x-button>, <x-card> and so on; set a prefix such as 'm' when a name
| clashes with one of the application's own components, and they become
| <x-m::button>, <x-m::card>. They are always <x-livewire-material::button>
| as well.
|
*/
@@ -26,12 +28,36 @@ return [
| localStorage under 'storage_key'; values found under 'legacy_keys' (an
| earlier theme toggle's key) are adopted once and then removed.
|
| 'meta' keeps <meta name="theme-color"> (the colour an installed web app
| or a mobile browser gives its bar) on the resolved theme's surface, and
| the active colour profile's: the head script sets it before the first
| paint, adds one when the page has none, and follows every later change,
| wire:navigate included. A theme-color meta with a `media` attribute is
| left alone.
|
*/
'theme' => [
'default' => 'system',
'storage_key' => 'material-theme',
'legacy_keys' => [],
'meta' => false,
],
/*
|--------------------------------------------------------------------------
| Navigation rail
|--------------------------------------------------------------------------
|
| Whether a collapsible navigation rail starts 'expanded' or 'collapsed'
| until the visitor toggles it. The head script applies the choice before
| the first paint, from localStorage under 'storage_key'.
|
*/
'rail' => [
'default' => 'expanded',
'storage_key' => 'material-rail',
],
/*
@@ -75,6 +101,63 @@ return [
'node' => env('MATERIAL_NODE', 'node'),
/*
|--------------------------------------------------------------------------
| Scheme data
|--------------------------------------------------------------------------
|
| The light and dark hexes `php artisan material:scheme` writes beside the
| stylesheet. The mail theme reads its colours here, and so does an error
| page when the build is missing; without the file both use the package's
| default scheme.
|
*/
'scheme' => resource_path('css/material-scheme.json'),
/*
|--------------------------------------------------------------------------
| Colour profiles
|--------------------------------------------------------------------------
|
| Named schemes an installation can switch between. Each one is a 'label',
| a 'seed' (#rrggbb), a 'variant' and an optional 'contrast'; 'spec'
| ('2025' or '2021') and the 'success', 'warning' and 'info' sources are
| optional too, taken from the command's options when left out. Without a
| seed, `php artisan material:scheme` generates every profile into one
| stylesheet keyed by <html data-scheme>. 'profile' names the default one
| (else the first); the application says which is active with
| Scheme::resolveProfileUsing(). Regenerate after changing either.
|
*/
'profiles' => [],
'profile' => null,
/*
|--------------------------------------------------------------------------
| Mail
|--------------------------------------------------------------------------
|
| Markdown mail takes the theme when `mail.markdown.theme` (MAIL_MARKDOWN_THEME)
| is 'livewire-material::mail.theme'. 'components' puts this package's mail
| header and message after the application's own mail components — or
| publish them with `vendor:publish --tag=livewire-material-mail` instead.
| 'logo' replaces the app name in that header with an image: an absolute
| 'src', with 'width' and 'height' in pixels, which Outlook sizes it by.
|
*/
'mail' => [
'components' => (bool) env('MATERIAL_MAIL_COMPONENTS', false),
'logo' => [
'src' => null,
'width' => null,
'height' => null,
],
],
/*
|--------------------------------------------------------------------------
| Showcase
@@ -82,7 +165,8 @@ return [
|
| Every component in every variant, rendered in the application's own
| scheme. Off unless the application runs locally. 'vite' names the entry
| points that import this package's CSS and JavaScript.
| points that import this package's CSS and JavaScript; the error pages
| load them too, showcase or not.
|
*/
-576
View File
@@ -1,576 +0,0 @@
# Livewire Material: a shared Material 3 Expressive component library
> Split on 2026-09-13 from SealShare's `docs/plans/livewire-material.md`, where the plan was
> made. SealShare's adoption (Phase 11) stays in SealShare's copy.
## Goal
ReStride has just left maryUI and daisyUI for **Material 3 Expressive on its own Blade
components** (`ReStride/docs/plans/material-expressive.md`, done 2026-09-10). Doing that
again, by hand, in every Laravel + Livewire app is what this package avoids.
**`nonameweb/livewire-material`** carries the whole current M3 Expressive component catalogue
as Blade components for Livewire, together with everything around them — colour scheme
generation, tokens, the theme script, Material Symbols, Google Sans Flex, motion, error pages,
a mail theme, a showcase, test helpers and AI guidelines. SealShare converts to it once it is
complete (`1.0.0`); ReStride adopts it later, in a plan of its own.
## Context
**Stacks.** SealShare: Laravel 13.31, Livewire 4.4, maryUI 2.9.10 (no prefix), daisyUI 5.7,
Tailwind 4.3, Pest 5.1, Octane on FrankenPHP, PHP 8.5; public on GitHub under MIT, image
published to `ghcr.io/surtic86/sealshare`. ReStride: same Laravel / Livewire / Tailwind / Pest,
private on `gitea.nonameweb.ch`, CI through Gitea act_runner.
**What ReStride already solved** (and the package generalises):
- 45 anonymous components in `resources/views/components/`, maryUI's names and props, M3
styling; class components only where PHP earns it.
- `resources/css/material/{color,shape,type,motion,elevation,field,menu,table,list}.css`;
`@theme` (never `inline`) mapping M3 roles to utilities; `--color-*: initial` so only tokens
compile; `:root` dark + `[data-theme=light]`, each with its own `color-scheme`.
- Material Symbols Rounded (400/0/24) as local SVGs registered as `blade-icons` sets
(`ms`/`msf`), `config/blade-icons.php` turning off blade-icons' own `<x-icon>`.
- Google Sans Flex, a 63 KB Latin subset (weight 400700, ROND 0100).
- Spring motion as CSS `linear()` curves; press shape-morph; Expressive shapes generated by
formula into `resources/svg/shapes`.
- `App\Livewire\Concerns\Toasts` (protected `success|error|warning|info`, dispatching a window
event) and a snackbar `<x-toast>`.
- ~30 component tests (`$this->blade()`), browser tests (Pest browser plugin), and guard tests
(`DesignLanguageTest`, `MaterialTokensTest`, `MaterialSymbolsTest`).
- The Livewire traps, recorded in `../ReStride/.ai/rules/ui.md`: `wire:ignore.self` on a
`showModal()` dialog; never pass `hidden` or a position to a component (wrap it); Blade
directives do not compile inside a component tag's attributes; `wire('model')->value()` is
`false`, not `null`, without `wire:model`; a field's states are CSS selectors (`:has()`),
marked with `data-invalid` / `data-floated` / `data-readonly` where the control cannot carry
them; the customizable select's traps (`../ReStride/.ai/rules/components-views-components.md`).
- Mail on M3 (`../ReStride/docs/plans/material-mail.md`): one theme CSS inlined onto bare
tags, light only, because `CssToInlineStyles` strips `@media`.
**What in ReStride is app-specific** and does not move: the doctrine (two button weights, no
tertiary / primary-container — enforced by its build), `restride-theme`, `$store.install` /
`$store.connection` in the shell, `--bottom-bar` set by its layout, sport / zone / route
tokens, `training-row`, `map-shell`, `map-chip`, `product-shot`, `star-rating`,
`share-button`, the unDraw repaint, the Ace code editor.
**Constraints found.**
- `gitea.nonameweb.ch` requires sign-in to view anything (`/explore/repos` → login, API 403).
A "public" repo there cannot be installed anonymously until that changes.
*(Resolved in step 1.)*
- Laravel's Markdown mail accepts a namespaced view as theme
(`Illuminate/Mail/Markdown.php:114`), so a package can render the theme CSS from data.
- Laravel **replaces** the `errors` view namespace at render time with
`config('view.paths')` + `/errors` and the framework's own
(`Illuminate/Foundation/Exceptions/RegisterErrorViewPaths.php`), so error views a package
adds with `addNamespace('errors', …)` are wiped; only a path in `view.paths` survives.
- M3 Expressive deprecates bottom app bar, navigation drawer, the original navigation bar,
segmented button, small FAB and medium/large top app bar (material-components-android docs).
- CSS anchor positioning is in Chrome 125+, Firefox 147+, Safari 18.4+ (flip via
`@position-try` from Safari 18.4).
- Boost loads a package's `resources/boost/guidelines/core.blade.php` and its skills on
`boost:install` / `boost:update --discover`.
## Decisions
- **A shared Composer package, SealShare its first consumer; ReStride adopts later in its own
plan** — SealShare's small surface proves the API; ReStride's three-day-old migration is not
put at risk.
- **Public repo on `gitea.nonameweb.ch`, MIT** — SealShare is public; its CI, Docker build,
manual install and fork PRs must install the package without credentials. SealShare
requires it through a `vcs` repository entry. Material Symbols (Apache-2.0), Google Sans
Flex (OFL) and material-web token values (Apache-2.0) are compatible; attributions ship in
`NOTICE`.
- **Prerequisite: Gitea allows anonymous reads** (`[service] REQUIRE_SIGNIN_VIEW = false`, or
`expensive` on 1.23+) — otherwise "public" is not installable.
- **Name `nonameweb/livewire-material`, namespace `NoNameWeb\LivewireMaterial`,
`config/livewire-material.php`, views `livewire-material::`, commands `material:*`** — says
what it is and what it is for.
- **Components unprefixed by default, prefix configurable** (maryUI's model, via
`Blade::anonymousComponentPath($path, $prefix)`) — ReStride's call sites keep their names;
SealShare's maryUI tags keep theirs. The provider sets `blade-icons.components.default` to
`null`, or blade-icons' class `<x-icon>` beats ours.
- **CSS and JS imported from `vendor/`** (`@import`, `@source`, `import`) — one dependency, one
version; consuming Dockerfiles install Composer packages before the Vite build.
`@source` covers the package's views and PHP, never its SVG folders.
- **The full Material Symbols Rounded set** (400 / 0 / 24, outlined and filled, 4,135 symbols,
5.3 MB), fetched by a maintenance script in the package repo, never at runtime — any name works
in any app. **`<x-icon>` reads the SVG files itself; blade-icons is not used for them** (decided
after Phase 1): blade-icons registers one Blade component per icon whenever the view factory
resolves, ~8,000 registrations per request here. blade-icons stays a dev dependency, only to
test that the provider still keeps its `<x-icon>` from shadowing ours in apps that have it.
- **Google Sans Flex bundled** (ReStride's subset) via `@font-face` in the package CSS; an app
overrides `--font-sans`.
- **Full M3, not ReStride's doctrine** — every variant, colour role (tertiary and
primary-container included) and container is available; each app enforces its own rules
through a configurable guard helper the package ships.
- **`variant` + `color` props** — `variant="filled|tonal|outlined|text|elevated"`,
`color="primary|secondary|tertiary|error|success|warning|info"`, default `text` in primary;
both validated against fixed lists, unknown values fall back to the default. Shorthands
kept: `primary` = filled primary, `danger` = filled error, `caution` = filled warning. The
same pattern for badge, alert, chip, icon button and FAB (`tone` stays an alias of `color`
where ReStride used it).
- **`php artisan material:scheme {seed} --variant=`** — runs Google's
`material-color-utilities` from a single prebundled Node script inside the package; writes
the app's `resources/css/material-scheme.css` (dark and light `--md-sys-color-*`, plus
`success`/`warning`/`info` custom colours, harmonisation off) and
`resources/css/material-scheme.json` (the light hexes, for the mail theme). Output is
committed.
- **Theme: a head script component, `light | dark | system`** — sets `data-theme` before paint;
CSS keys only on the attribute and never asks `prefers-color-scheme`; only the script reads
the OS (and follows its changes while `system`). `$store.theme` is the one state every
toggle shares. Config: `theme.default`, `theme.storage_key`, `theme.legacy_keys` (adopted
once, then removed).
- **The whole M3 Expressive catalogue, current components only** — the six deprecated ones are
skipped; their names alias where free (`<x-group>` renders a connected button group, FAB
`size="sm"` renders medium).
- **Plus the non-M3 pieces apps need** — data table, sort header, pagination views, file input,
password, stat, alert, empty state, collapse, section nav, account menu, theme toggle, an
adaptive app-shell composition, error pages, mail theme.
- **Modern browser floor, no third-party JS** — Chrome 125+, Firefox 147+, Safari 18.4+:
native `<dialog>`, Popover API, CSS anchor positioning, customizable `<select>` as a
progressive enhancement; Alpine (bundled with Livewire) for behaviour. Date and time
pickers, carousel and search are our own.
- **PHP ^8.4, Laravel ^13, Livewire ^4.** No request state in singletons (Octane).
- **Strings through `__()` with an `en` file**, publishable and overridable.
- **Accessibility: WAI-ARIA Authoring Practices patterns, WCAG 2.2 AA contrast, full keyboard.**
- **Showcase in the package, mounted twice** — by the Workbench for development and browser
tests, and opt-in inside an app (`showcase.enabled`, default only when `APP_ENV=local`) so
an app sees every component in its own scheme. Every variant, colour, size and state, a
light/dark/system switch, and the Blade snippet beside each example.
- **Tests: render tests for every component, browser tests against the Workbench showcase in
Chromium, Firefox and WebKit; no screenshot diffs.**
- **Error pages and a mail theme in the package** — the mail theme is the namespaced view
`livewire-material::mail.theme`, rendering CSS from the app's `material-scheme.json`, so
mail colours cannot drift from the app.
- **Boost guideline + `livewire-material-development` skill** in the package, and a test that
fails when a component is missing from the skill.
- **No version tags until the whole catalogue is done; then `1.0.0`** — the waves land on `main` untagged (decided 2026-09-13; replaces "`0.x` per wave").
- **Semantic ink and line utilities carried over from ReStride** (`text-body`, `text-meta`,
`text-quiet`, `border-structure`, `border-chrome`, `border-divider`) — cheap `@theme` names
ReStride's templates already use, derived from M3 roles.
## Out of scope
- ReStride adopting the package — its own plan, after `1.0.0`.
- A brand colour chosen at runtime (e.g. in SealShare's admin), dynamic colour, a PHP port of
the colour maths.
- M3 Expressive's deprecated components (bottom app bar, navigation drawer, original
navigation bar, segmented button, small FAB, medium/large top app bar).
- Screenshot / visual regression tests.
- Blaze optimisation of the components — a follow-up if rendering gets slow.
- ReStride-specific components and tokens (training row, maps, product shot, star rating, share
button, sport/zone/route tokens, unDraw repaint, Ace code editor).
- Publishing on Packagist — the `vcs` repository is enough; revisit if others adopt it.
- Translations beyond `en`.
- Browsers below the floor.
- Non-Livewire stacks (Inertia, React, Vue).
## Implementation steps
### Phase 0 — Prerequisites
1. **Gitea.** *Done 2026-09-13.* `GITEA__service__REQUIRE_SIGNIN_VIEW=false` in the Gitea
compose service (Gitea 1.26.4); `/explore/repos` and the API answer anonymously, and no
other repo or org is public. The package lives in the public org **`noNameWEB`**:
`https://gitea.nonameweb.ch/noNameWEB/livewire-material.git` (public, empty, `main`,
Actions on). SealShare's `vcs` repository entry uses that URL.
2. **Split the plan.** *Done 2026-09-13.* This file; SealShare keeps its adoption steps.
### Phase 1 — Package skeleton
3. **Repository.** `composer.json` (`nonameweb/livewire-material`, PSR-4
`NoNameWeb\LivewireMaterial\`, requires `php ^8.4`, `laravel/framework ^13`,
`livewire/livewire ^4`, `blade-ui-kit/blade-icons ^1.10`; dev: `orchestra/testbench`,
`pestphp/pest`, `pestphp/pest-plugin-laravel`, `pestphp/pest-plugin-browser`,
`laravel/pint`), `LICENSE` (MIT), `NOTICE`, `pint.json`, `phpunit.xml`, `.gitattributes`
excluding `workbench/`, `tests/`, `bin/`, `docs/` and the Node/Vite dev files from dist
archives.
4. **Service provider** `LivewireMaterialServiceProvider`: merge config; register the
anonymous component path with the configured prefix; `loadViewsFrom` (`livewire-material`),
`loadTranslationsFrom`; set `blade-icons.components.default` to `null` in `register()`
(before blade-icons boots and registers its component); register the icon and shape sets;
append the package's error-view root (a directory holding only `errors/`) to
`view.paths` after the app's own, so Laravel's namespace replacement keeps it and an app's
`resources/views/errors` still wins; publishable config, lang, error views; commands; the
showcase routes when enabled. No request state anywhere in the container.
5. **Config** `config/livewire-material.php`: `prefix`, `theme.default`, `theme.storage_key`,
`theme.legacy_keys`, `showcase.enabled` (`env('MATERIAL_SHOWCASE', app()->isLocal())`),
`showcase.path` (`material`), `showcase.middleware` (`['web']`).
6. **Workbench** (`orchestra/workbench`): a Laravel app with Livewire, Vite and the package
CSS/JS built, serving the showcase at `/`; `composer serve` starts it.
7. **CI on Gitea act_runner:** Pint, Pest render tests, browser tests in Chromium, Firefox and
WebKit (Playwright installed with deps), on pushes and pull requests.
8. **Boost resources skeleton:** `resources/boost/guidelines/core.blade.php` and
`resources/boost/skills/livewire-material-development/SKILL.md`; the drift test (every
component file under `resources/views/components` is named in the skill).
**Phase 1 is done (2026-09-13).** What changed from the steps above:
- **Testbench 11.2** runs the Workbench (`testbench.yaml`: providers listed, `start: /material`,
`MATERIAL_SHOWCASE=true`). Vite builds into `workbench/public/build`; `composer serve` links
that into the skeleton's `public/build` through the Workbench `sync` option, and
`tests/TestCase.php` points `public_path()` at `workbench/public` so browser tests find the
manifest without the command. Verified: `composer serve` answers `/material` and its CSS.
- **The showcase is a plain view with `@extends`**, not a component: `<x-livewire-material::…>`
resolves under `components/`, which would have made the layout a public component.
- **blade-icons' `components.default` is set in a `booting` callback**, not in `register()`:
`mergeConfigFrom` is shallow, so a nested key written before blade-icons merges its defaults
wipes the rest of its `components` array. The test fails with the call removed.
- **The showcase switch is tested by rebooting** with `MATERIAL_SHOWCASE` in the environment:
Testbench's per-test attributes do not reach Pest closures.
- **The Boost guideline is wrapped in `@verbatim`**: it is rendered as Blade, and a
`<x-mary-*>` in its prose would compile as a component tag.
- **`phpunit.xml` trap:** a comment containing a double hyphen (`--browser`) is invalid XML and
PHPUnit refuses the file; Pest then reports an unrelated `Pest\Plugins\Tia` error on shutdown.
- **CI** (`.github/workflows/tests.yml`): Pint, feature tests on PHP 8.4 and 8.5, browser tests
per engine — Pest's `--browser chrome|firefox|safari`, Playwright's `chromium|firefox|webkit`.
Locally only Chromium was run; Firefox and WebKit first run on CI.
- **`NOTICE` moves to Phase 2**, when the first third-party assets (Symbols, font, tokens,
colour utilities) arrive — a notice with nothing to attribute would be wrong.
- **The scheme script is not in `bin/`:** `bin/` is export-ignored (maintenance scripts only),
but applications run the scheme script, so it lives at `resources/node/scheme.mjs`.
- **Found for Phase 2:** blade-icons calls `Factory::registerComponents()` whenever the view
factory resolves, registering one Blade component per icon unless `components.disabled`
and without `icons:cache` it scans the folders first. With ~7,800 symbols that is per
request under PHP-FPM. Decided before step 13.
### Phase 2 — Foundation
9. **Scheme command.** `resources/node/scheme.mjs` built once in the package repo (esbuild bundle of
`@material/material-color-utilities`, committed, Apache-2.0 header); `material:scheme {seed}
{--variant=tonal-spot|vibrant|expressive|fidelity|content|neutral|monochrome}
{--success=} {--warning=} {--info=} {--output=}` runs it through `node` and writes
`resources/css/material-scheme.css` and `resources/css/material-scheme.json`. Fails with a
clear message when `node` is missing. A default scheme (M3 baseline `#6750A4`) ships inside
the package so a fresh install renders before the command is run.
10. **Tokens.** `resources/css/material.css` as the single entry, importing `tokens/shape.css`,
`type.css` (typescale utilities incl. emphasized), `motion.css` (six spring `linear()`
curves — spatial/effects × fast/default/slow — and reduced-motion overrides),
`elevation.css`, `state.css` (state layers, focus ring), and the `@theme` block mapping
**every** M3 role (primary/secondary/tertiary and their containers, surfaces, outlines,
inverse, error, custom success/warning/info with `inverse-*`) plus the semantic ink and
line utilities; `--color-*: initial` with white and black re-added. Each theme block
declares `color-scheme`. Values from material-web's token files, attributed.
11. **Font.** `resources/fonts/google-sans-flex/` (woff2 + OFL), `@font-face` and `--font-sans`
in the entry CSS.
12. **Theme.** `<x-theme-script />` (inline, before `@vite`): reads `storage_key`, adopts
`legacy_keys` (their values may be JSON-encoded — maryUI's `$persist` stores `"dark"`
with quotes), resolves `system` through `matchMedia`, writes `data-theme`; `$store.theme`
in `resources/js/material.js` with `set()` and a `matchMedia` listener while `system`.
13. **Icons.** `bin/fetch-symbols` (maintenance only) downloads Material Symbols Rounded
400/0/24 outlined and filled from `google/material-design-icons` into
`resources/svg/symbols/{outlined,filled}` with `fill="currentColor"` and no width/height;
`<x-icon name="…" filled>` resolves `ms`/`msf`; throws on an unknown name.
14. **Shapes.** The Expressive shape set generated by formula (ReStride's
`components/shape.blade.php` method) into `resources/svg/shapes`; `<x-shape>`.
15. **JS entry** `resources/js/material.js`: theme store, snackbar listener, `data-list-row`
rows, `x-figure` count-up directive, shared keyboard helpers. Imported by an app's `app.js`.
16. **`Toasts` concern** `NoNameWeb\LivewireMaterial\Concerns\Toasts`: protected
`success|error|warning|info(string $title, ?string $description = null, ?int $timeout = null, ?string $redirectTo = null)`,
dispatching a browser event and flashing across `redirectTo`.
17. **Guard helpers** (`NoNameWeb\LivewireMaterial\Testing\DesignGuard`, Pest-friendly):
scan given paths for maryUI tags, daisyUI component classes, colour utilities not declared
by the compiled tokens, unknown icon names, and app-configured banned roles/variants.
18. **Showcase shell**: layout, section navigation, theme switch, snippet renderer; a section
per foundation piece (colour roles in both themes, type scale, shapes, motion, icons
search).
**Phase 2 is done (2026-09-13).** What changed from the steps above:
- **Icons without blade-icons** (see Decisions): `NoNameWeb\LivewireMaterial\Support\SvgFile`
reads a symbol or shape file on first use and keeps it for the worker. An unknown name throws,
and a Heroicon-style name (`o-home`) throws with a pointer to the catalogue. `bin/fetch-symbols`
sparse-checks-out google/material-design-icons (5 GB, cloned without blobs) for the
`_24px` and `_fill1_24px` files: 4,135 symbols, byte-identical to ReStride's hand-picked ones.
The `@material-symbols/svg-400` npm package was rejected: its "rounded" SVGs are optical size 48.
- **All 35 M3 Expressive shapes**, ported from androidx `MaterialShapes.kt` and graphics-shapes
into `bin/shapes.mjs` (commit in its header), each fitted to 298 of a 100-unit box; eight had
control points outside the box and are split into more segments along the same outline.
- **The scheme script** lives at `resources/node/scheme.mjs` (93 KB, esbuild bundle of
material-color-utilities 0.4.0, spec 2025 — M3 Expressive's colour; variants the 2025 spec
does not define fall back to 2021). The library's extensionless imports mean it cannot run
unbundled. 65 roles per theme plus `inverse-{error,success,warning,info}`. Default state
sources: success `#22a06b`, warning `#e2a400`, info `#1d7afc`. `config('livewire-material.node')`
names the binary. The package's own default is `#6750A4`, tonal-spot, in `tokens/scheme.css`.
- **Colour utilities are `@theme inline`** — contrary to ReStride's rule that `inline` breaks
theme switching. Verified in Chromium: without `inline`, `--color-primary` resolves once on
`:root`, so a `data-theme="dark"` section inside a light page keeps light colours; with it the
utility reads `var(--md-sys-color-primary)` on the element and both the page toggle and nested
themes work. Only blocks whose values are variables are inline. A browser test fails without it.
- **Tailwind emits only the theme variables that are used**, and cannot see a class assembled in
Blade (`type-{{ $style }}`): the showcase lists every class literally.
- **`dark:`** is keyed on `data-theme`, so it follows the page's theme rather than the OS.
- **Theme script** writes nothing for a visitor who never chose (a later change of
`theme.default` still reaches them); only an adopted legacy value is stored. `data-theme-choice`
and `data-theme-key` on `<html>` hand the state to `$store.theme` (`choice`, `resolved`, `set`,
`toggle`, `value`).
- **Moved out of Phase 2:** the snackbar listener goes to Phase 4 with `<x-toast>`; `data-list-row`
rows (JS and CSS) and the list keyboard go to Phase 5 with `list` and `card`, which they style.
`Toasts` dispatches `toast` with a 4 s default (M3's snackbar range is 410 s; ReStride used 3 s).
- **`DesignGuard`**: `secondary` is a valid role now that the package exposes full M3, so it is no
longer flagged as a daisyUI colour; icon attributes are checked per component tag (`icon` and
`icon-right` on the same tag).
- **Pest browser trap:** a bare `html` selector is taken as text to search for and times out;
assert on `document.documentElement` through `assertScript`. `@name` targets `data-test`.
- **Showcase**: colour roles in both themes side by side, type, corners and shapes, elevation,
springs and `x-figure`, and an icon search drawing matches as CSS masks from a showcase-only
symbol route (relative URLs — an absolute one carries `APP_URL` and misses the dev server's port).
### Phase 3 — Actions
19. Primitives the actions need: `loading` (M3 Expressive loading indicator, contained and
not), plain `tooltip` (from a fine pointer only, not laid out while hidden), `menu` /
`menu-item` / `menu-separator` (Popover API + anchor positioning, Expressive vertical menu,
APG menu keyboard).
20. `button` — five variants × colours × sizes `xs|sm|md|lg|xl`, `icon`, `icon-right`,
`label`, `link` (+ `wire:navigate` unless `external` / `no-wire-navigate`), `spinner`,
`responsive`, `tooltip*`, `disabled` on links (`aria-disabled`), press shape-morph.
21. `icon-button` behaviour inside `button` (icon, no label): standard/filled/tonal/outlined,
`selected` toggle (`aria-pressed`), widths.
22. `button-group` (standard and connected; `<x-group>` alias with `wire:model` options),
`split-button`, `fab` (56px default — M3's deprecated small FAB is gone, so `sm` is the baseline FAB — 80px `md`, 96px `lg`), extended FAB, and the
responsive `fab` prop (extended FAB below `sm`, filled header button above — one element),
`fab-menu`.
**Phase 3 is done (2026-09-13).** What changed from the steps above:
- **Values from androidx Compose Material 3's generated tokens** (`Button*Tokens`,
`*IconButtonTokens`, `Fab*Tokens`, `ExtendedFab*Tokens`, `FabMenuBaselineTokens`,
`ButtonGroupSmallTokens`, `ConnectedButtonGroupSmallTokens`, `SplitButton*Tokens`,
`StandardMenuTokens`, `VibrantMenuTokens`, `SegmentedMenuTokens`, `PlainTooltipTokens`,
`LoadingIndicatorTokens`), cited in each component's header. Two tokens are wrong and Compose
overrides them in code, so the package does too: the text button's label is `primary`, not
`on-surface-variant`, and the extra-small button's padding is 12px, not 16px.
- **`<x-button>` is label button, icon button and toggle in one**, with `data-icon-button` on the
icon-only form. A selected round button squares off; a selected square icon button rounds. A
`corners` prop lets composite components (the split button) draw the corners themselves.
- **Group and split corners are unlayered CSS** (`resources/css/components/groups.css`): a
child button's corners are utilities, and anything in a `@layer` loses to a utility. Inner
corners ride a `--group-corner` variable so pressing and selecting change one value while the
rounded outer corners stay put. The standard group's press expansion is padding moved from the
neighbours to the pressed button (a fixed step per size); icon buttons keep their width.
- **`<x-group>` stays ReStride's native-radio design** (checkboxes with `multiple`), restyled as a
connected button group — `wire:model`, `x-model` and the arrow keys need no script.
- **Tooltips and menus are popovers placed by CSS anchor positioning**, with per-render anchor
names generated in Blade (a morph updates the trigger and the popover together). `popover`
elements must never get a `display` utility (`flex`), which beats the UA's `display: none` for
a closed popover; use `open:flex`.
- **Menu keyboard is WAI-ARIA's menu button**; `aria-expanded` and the first item's focus are set
synchronously in `open()`, because the popover `toggle` event is queued and a test (or a screen
reader) reading in between saw a shut menu. **Bug found by the browser tests:** the guard
against a light-dismiss press reopening the menu compared against `closedAt = 0`, so every click
in the first 250ms after page load was swallowed; it starts at `-Infinity` now, with a test.
- **Anonymous component trap:** every prop is a local variable, so a helper variable in `@php`
must not reuse a prop's name — a local `$corners` array silently replaced the `corners` prop.
- **The loading indicator is androidx's own geometry, in SVG + SMIL** (`bin/loading-indicator.mjs`,
`resources/svg/loading-indicator/`): `Morph` is ported, so each of the seven morphs is a path
whose control points SMIL can interpolate in every engine (CSS `d:` has no WebKit support).
The spring is two keySplines (<1% error), each morph's path shrinks under its successor at the
hand-over, and the per-morph quarter turn runs on its own 2.6s cycle so the 630° per shape
cycle never needs a reset. 21.7 KB. Two deliberate differences from Compose: the spring settles
inside the 650ms instead of snapping back from 9% past, and frames centre on exact curve bounds,
so there is no 0.10.18 unit jump at three hand-overs. Reduced motion shows `static.svg`.
- **Showcase examples are Blade strings rendered with `Blade::render()` beside their source**
(`<x-showcase::example>`, an anonymous component path registered only when the showcase is
enabled), so the snippet can never disagree with what is drawn.
- **Browser tests in three engines, locally too** (Playwright's Firefox and WebKit are installed):
- WebKit, like Safari on macOS, leaves buttons out of the Tab order; focus them directly.
- Firefox counts a scripted focus as `:focus-visible` only after a key press.
- Firefox flaked ~3 runs in 4 with **HTTP 431 from Pest's in-process Amp server** on
`livewire.js`, so Alpine never started. It appeared once the showcase inlined a 60 KB list of
symbol names; the icon search now fetches `symbols.json` on `x-intersect.once`, and the suite
passed 4 of 4. Keep showcase pages lean.
- A click that lands before Alpine starts does nothing; tests wait for `networkidle`, and the
showcase's theme switch is `x-cloak` so Playwright's click waits for it.
### Phase 4 — Communication
23. `badge` (dot, count, label; variant/colour), `progress` (linear, circular, **wavy**,
determinate and indeterminate), `toast` (M3 snackbar, action, timeout, stacked), rich
`tooltip`, `alert` (tinted container, icon, actions slot), `stat` (figure with
`x-figure`), `empty-state`.
**Phase 4 is done (2026-09-13).** What changed from the steps above:
- **`<x-progress>` was built by a separate agent** from Compose's `ProgressIndicator.kt` and
`WavyProgressIndicator.kt`: server-rendered SVG first frame, then an Alpine component that ports
the drawing and keyframes and animates only while something moves and it is on screen; the SVG
is `wire:ignore` and a MutationObserver on the root's `data-value` turns a morph or a `bind`
expression into motion. Flat circular indeterminate has no track, as in Compose.
- **`<x-toast>` listens from the moment `snackbar.js` loads**, not on its Alpine component, and holds
toasts until the host registers — a toast dispatched before Alpine starts is shown, not lost.
`@persist` keeps the host across `wire:navigate`.
- **`<x-badge>` is M3's dot and count** (`floating` pins it to an icon) **plus a status label**
(`tonal`, `outline`), which M3 lacks and every app needs. `<x-alert>`, `<x-stat>` and
`<x-empty-state>` are built from M3's parts; `<x-rich-tooltip>` is transient or `persistent`.
- **A commit went out broken and was fixed forward:** `648ad8e` staged `material.js` and the
showcase index while they held the progress agent's temporary lines, without its files.
**With agents in the same tree, stage by explicit path and read the diff of shared files first.**
- **Browser test lessons (all three engines, locally):**
- `waitForEvent('networkidle')` can return before a repeated visit has loaded in Firefox (an
empty document, then a page still streaming in). Every helper now also asserts
`document.readyState === 'complete'` and that Alpine and Livewire exist; assertions retry.
- Firefox runs Playwright's evaluate in a sandbox: an event built there has a `detail` the page
cannot read, and assignments to Alpine's reactive proxies do not trigger. Go through the page's
realm with `window.eval("…")` (or `Livewire.dispatch`).
- Pest retries a failing `assertScript`; a script that changes state must not be written so
that a retry starts from the changed state.
### Phase 5 — Containment
24. `card` (elevated, filled, outlined; `title`, `subtitle`, `actions` slot; clickable row
contract), `divider`, `list` / `list-item` (one-, two-, three-line; leading/trailing;
selectable), `modal` (native `<dialog>`, `showModal()`, `wire:ignore.self`, writes back
`false`/`null`, `fullscreen` below `sm`, basic dialog with icon/headline/actions),
`bottom-sheet` (modal and standard, drag handle), `drawer` (side sheet: modal and
standard; `pane` for list-detail from `xl`; `width` prop), `carousel` (multi-browse, uncontained, hero,
full-screen on CSS scroll-snap), `collapse`.
**Phase 5 is done (2026-09-13).** What changed from the steps above:
- **`<x-carousel>` was built by a separate agent in its own worktree**, porting Compose's keyline
maths (`Arrangement`, `Keylines`, `Strategy`, `KeylineSnapPosition`, androidx
`7ac433e44e797de53af85226797862687f37735f`; checked against 43 values from androidx's unit tests).
Items are laid out at the large size on a native scroll-snap row and masked each frame with a
`clip-path` inset; snap positions are `scroll-margin-inline-start`. Full-screen follows
material-components-android, which Compose lacks; one item per swipe is `scroll-snap-stop`, not
fling physics. Hooks are `data-material-carousel*`: the design guard rejects `carousel*` classes
as daisyUI's.
- **The drawer is the side sheet**, with `pane` for list-detail from `xl`, and the bottom sheet is its
own component (modal or `standard`) with drag-to-dismiss.
- **`<x-list-item>`'s trailing slot is `end`**: a slot named like the `trailing` prop replaced it.
- **The design guard also rejects Blade directives inside component tags** (`@class` on
`<x-icon>`), which reach the browser as text.
- **`x-trap.inert` hides the page with `aria-hidden`**, not `inert`; tests assert what it sets.
- **WebKit returns focus from a dialog only to an element that had focus**, and a click does not
focus a button there: tests open dialogs from the keyboard.
### Phase 6 — Text inputs and selection
25. `form`, `field` (the shared shell: **outlined and filled**, floating label via `:has()`,
notch, `hint` replaced by error, `aria-invalid` / `aria-describedby`, `data-*` state
marks), `input` (prefix/suffix, icons, `copyable` trailing button with a snackbar),
`password` (reveal toggle), `textarea` (auto-grow), `select` (native, customizable-select
enhancement with ReStride's traps), `checkbox` (incl. indeterminate), `radio`, `toggle`
(M3 switch, icons), `file` (native input inside the field, batch and per-file errors).
26. `chip` (assist, filter, input, suggestion), `choices` (filter chips, or a searchable
combobox with a menu when `searchable`), `slider` (standard, centered, range; native range
inputs, value label), `search` (search bar and search view, results through a Livewire
property).
**Phase 6 is done (2026-09-13).** What changed from the steps above:
- **The field family is ReStride's, extended with M3's filled text field** (`variant`, default from
`config('livewire-material.fields.variant')`): a surface-container-highest box, extra-small top
corners, the outline turned into an indicator line and the label floating inside. A textarea's
top offset is half margin, so text scrolled up in a full textarea disappears under the edge
instead of running through the label; it grows with `field-sizing: content` from `rows` to
`max-rows`, with a script fallback (`field.js`) that no current engine needs.
- **Checkbox, radio and switch are CSS on native inputs** (`components/selection.css`), hooked by
`data-checkbox`, `data-radio`, `data-switch`: `checkbox`, `radio` and `toggle` are daisyUI class
names the design guard rejects. The indeterminate checkbox is `data-indeterminate`, kept in step
with the property by a MutationObserver, since HTML has no attribute for it.
- **`<x-chip>` and `<x-slider>` were built by separate agents in their own worktrees.** Chips: one
component for four types; a filter chip is a native checkbox when bound and a toggle button
otherwise; removable input chips hand focus on by `wire:key` after a morph. Slider: native range
inputs under a drawing ported from Compose (`wire:ignore`); `.number` is added to the binding,
because a string sent and an integer returned made Livewire move the handle back mid-drag.
- **`<x-choices>` keeps typed values in Alpine** (filter chips as toggle buttons, or a searchable
combobox whose list is a `popover="manual"` placed by CSS anchor positioning, so a card's
`overflow-hidden` never clips it). Selecting the text on focus has to happen on the `click` that
follows the press: Chromium collapses a selection made in the focus handler.
- **`<x-search>` is the search bar and view**: docked from `sm`, full screen and trapped below it.
It closes on `pointerdown` outside, not `click` (the bar moves under the pointer as it goes full
screen, and the click then lands outside), and focus returned to the input within 250ms of a close
does not reopen it (Escape, and the trap letting go).
- **A slot rendered by a `@foreach` with no items is not empty** to `isNotEmpty()`; use
`hasActualContent()`.
### Phase 7 — Pickers
27. `datepicker` (docked, modal, modal input; `Intl` month/day names and week start from the
app locale; `min`/`max`; single and range; `wire:model` stores `Y-m-d`), `timepicker`
(dial and input; 12/24h from locale; stores `H:i`). APG grid keyboard for the calendar.
### Phase 8 — Navigation
28. `app-bar` (small, center-aligned, medium flexible, large flexible, search app bar; sticky,
scroll-elevation), `navigation-bar` (flexible), `navigation-rail` (collapsed, expanded,
modal; badges), `tabs` / `tab` (primary and secondary; server-rendered tablist, roving
tabindex), `toolbar` (docked and floating), `section-nav` (secondary tabs from `sm`, menu
picker below), `account-menu` (avatar trigger, slot for items, theme row), `theme-toggle`
(cycles or picks light/dark/system through `$store.theme`).
29. `app-shell` — a slot-based adaptive composition: app bar + navigation bar below `sm`, rail
`sm``lg`, expanded collapsible rail from `lg` (state in the store, applied before paint by
the theme script), content region with `wire:transition.navigate`, snackbar host. Nothing
app-specific inside; apps pass destinations and extra chrome as slots.
### Phase 9 — Data, pages, mail
30. `table` (`.data-table`, descendant selectors, fine-pointer density, `position: relative`),
`sort-header` (`sortBy` array shape, `aria-sort`), Livewire and Laravel pagination views
(current page in `secondary-container`, "Page 2 of 7" on a phone).
31. Error pages: a layout and `403, 404, 419, 429, 500, 503` in the package's error-view root
(wired through `view.paths` in step 4); publishable for per-app wording.
32. Mail theme: `livewire-material::mail.theme` renders CSS from the app's
`material-scheme.json` (falling back to the default scheme); `html/header` and
`html/message` overrides; typescale on bare tags; filled primary button.
### Phase 10 — `1.0.0`
33. Showcase complete (every component, variant, colour, size and state, both themes); the
in-app mount verified inside a fresh Laravel app; Boost guideline and skill complete
(drift test green); README (install, CSS/JS wiring, scheme, theme, prefix, showcase,
guard, Docker ordering note); tag `1.0.0`.
### Phase 11 — SealShare 2.0.0
Tracked in SealShare's `docs/plans/livewire-material.md`, after `1.0.0`.
## Testing
- **Render tests** (`tests/Feature/Components/*Test.php`, Testbench, `$this->blade()`), per
component: each `variant` × `color` renders its classes and falls back on an unknown value;
shorthands (`primary`, `danger`, `caution`, `tone`) map correctly; sizes; ARIA
(`aria-pressed`, `aria-expanded`, `aria-current`, `aria-invalid` + `aria-describedby`,
labelled dialogs, `aria-sort`); `link` adds `wire:navigate` unless `external`; `wire:model`
normalisation (`false``null`); prefix config renames the tags.
- **Foundation tests:** every `--md-sys-color-*` role defined in both theme blocks and
different between them; each block declares `color-scheme`; no `prefers-color-scheme` in
the CSS; colour blocks are `@theme inline`; the scheme command writes both files for a known seed
(snapshot of a few roles) and fails cleanly without Node; the theme script lands before
`@vite` and resolves `system`; `<x-icon>` throws on an unknown name; every shape fills the
100-unit box with `currentColor` only; `Toasts` dispatches and survives `redirectTo`;
`DesignGuard` catches each forbidden pattern on fixtures; the skill drift test; the mail
theme renders the JSON's hexes; a 404 renders the package's error view and an app's own
`errors/404.blade.php` still wins; the showcase route is 404 when disabled and 200 when
enabled; blade-icons' own `<x-icon>` is not registered.
- **Browser tests** against the Workbench showcase in Chromium, Firefox and WebKit: dialog
(open, Esc, morph survival, write-back), bottom and side sheets and the pane from `xl`,
menu and select (keyboard, anchoring, flip), tabs (arrows, Home/End), date and time pickers
(keyboard grid, locale, `wire:model` value), carousel (snap, keyboard), sliders, chips and
choices, search, snackbar timing and action, theme (light/dark/system, legacy adoption, OS
change followed), app shell at 393 / 768 / 1024 / 1512px, focus rings on keyboard focus,
and reduced motion leaving no running animations.
## Risks and open questions
- **Scope and time.** The whole catalogue (~45 components plus extras) comes before any app
uses it. Mitigation: each wave is reviewed in the showcase and green on CI before the next.
- **Accessibility is entirely ours** — menus, pickers, carousel, sheets. Mitigation: APG
patterns, native elements first, ARIA in render tests, keyboard in browser tests across
three engines.
- **Date and time pickers without a library** are the largest single components. Mitigation:
`Intl` for all locale data; the modal-input variant as the accessible baseline.
- **Full M3 invites inconsistency per app.** Mitigation: the configurable `DesignGuard`; each
app records its own rules.
- **Livewire morphing against Alpine state** inside components. Mitigation: ReStride's
recorded traps become package rules in the skill, with tests pinning each.
- **~8,300 SVGs** make dist archives and `vendor/` larger (5.3 MB). Mitigation: each file is read
only when drawn and kept per worker; `.gitattributes` keeps dev files out; `@source`
never scans the SVG folders.
- **Maintenance of a public package** is one person's job; breaking changes need semver
discipline once ReStride also depends on it.
- **Runner scope.** The package's CI needs an act_runner registered for the instance or the
`noNameWEB` org, not only for ReStride — check before the first push in Phase 1.
+2 -1
View File
@@ -5,6 +5,7 @@ This application uses `nonameweb/livewire-material`: Material 3 Expressive compo
- Components are anonymous Blade components, unprefixed unless `config/livewire-material.php` sets a `prefix`. Before writing or changing a view that uses them, activate the `livewire-material-development` skill for the props, slots and traps of each component.
- Never write maryUI tags (`<x-mary-*>`) or daisyUI classes (`btn`, `card`, `badge`, `bg-base-200`, `text-base-content`). They compile to nothing and fail silently.
- Every layout includes `<x-theme-script />` in `<head>` before `@vite`. The colour scheme is generated with `php artisan material:scheme` never edit `resources/css/material-scheme.css` by hand.
- Every layout includes `<x-theme-script />` in `<head>` before `@vite`. The colour scheme is generated with `php artisan material:scheme` never edit `resources/css/material-scheme.css` by hand. With colour profiles (`livewire-material.profiles`), run it without a seed after changing them; the active profile comes from `Scheme::resolveProfileUsing()`.
- While the application runs locally, every token and component renders in the application's own scheme at `/material` (the showcase).
- HTTP error pages and the Markdown mail theme come from the package. Change error wording by publishing `--tag=livewire-material-errors`; select the mail theme with `MAIL_MARKDOWN_THEME=livewire-material::mail.theme`.
@endverbatim
@@ -44,7 +44,36 @@ The scheme is generated, never hand-edited. Regenerate it with the seed and vari
php artisan material:scheme "#4f46e5" --variant=tonal-spot
```
Variants: `tonal-spot` (M3's default), `vibrant`, `expressive`, `neutral`, `fidelity`, `content`, `monochrome`, `rainbow`, `fruit-salad`. `--success`, `--warning` and `--info` set the source of the state colours; `--contrast` goes from -1 to 1. The command also writes `material-scheme.json` beside the stylesheet.
Variants: `tonal-spot` (M3's default), `vibrant`, `expressive`, `neutral`, `fidelity`, `content`, `monochrome`, `rainbow`, `fruit-salad`. `--spec` is the colour spec: `2025` (default, M3 Expressive) or `2021` (M3's original colour — keep it for a palette generated before Expressive; the library itself uses 2021 for variants 2025 does not define, and the header records the spec actually used). `--success`, `--warning` and `--info` set the source of the state colours; `--contrast` goes from -1 to 1. The header of the stylesheet records the whole command, every option that differs from its default included. The command also writes `material-scheme.json` beside the stylesheet.
### Colour profiles
An installation that switches between several schemes lists them in `config/livewire-material.php` and runs the command without a seed, which generates every profile into the same stylesheet, keyed by `<html data-scheme>`:
```php
'profiles' => [
'indigo' => ['label' => 'Indigo', 'seed' => '#4f46e5', 'variant' => 'vibrant'],
'teal' => ['label' => 'Teal', 'seed' => '#00897b', 'variant' => 'vibrant'],
],
'profile' => 'indigo', // the default; else the first
```
```bash
php artisan material:scheme
```
- Each profile: `seed`, and optionally `label` (default: the name as a headline), `variant` (default `tonal-spot`), `contrast` (default 0), `spec`, `success`, `warning`, `info` (for these four, without the key the command's `--spec`, `--success`, `--warning`, `--info` or their defaults apply).
- Names are lowercase letters, digits and dashes. Regenerate after changing the list; only generated profiles exist for the picker, the resolver and the stylesheet.
- The application says which profile is active, once, in a service provider. The closure runs every time a colour is drawn (head script, mail, error page), so it may read the database; a name that is not a generated profile, or a closure that throws, falls back to the default:
```php
use NoNameWeb\LivewireMaterial\Support\Scheme;
Scheme::resolveProfileUsing(fn (): ?string => Setting::get('color_profile'));
```
- `<x-theme-script>` writes the active profile to `<html data-scheme>` before the first paint; mails and error pages draw it too. `Scheme::profiles()` lists the generated profiles (name ⇒ label, light and dark roles) and `Scheme::profile()` names the active one — validate a stored choice with `Rule::in(array_keys(Scheme::profiles()))`.
- Choose with `<x-scheme-picker wire:model="colorProfile" />` (see Components). Never set `data-scheme` on an element inside the page expecting a different profile there: profiles key on `<html>`.
## Tokens
@@ -62,7 +91,15 @@ Tailwind's default palette is cleared: every colour class names an M3 role. `tex
## Theme
`config/livewire-material.php``theme.default` (`light`, `dark` or `system`), `theme.storage_key`, `theme.legacy_keys`. In Alpine, `$store.theme` holds `choice` (what the visitor picked), `resolved` (`light` or `dark`, what shows), `set('light'|'dark'|'system')` and `toggle()`; `x-model="$store.theme.value"` binds a control.
`config/livewire-material.php``theme.default` (`light`, `dark` or `system`), `theme.storage_key`, `theme.legacy_keys`, `theme.meta`. In Alpine, `$store.theme` holds `choice` (what the visitor picked), `resolved` (`light` or `dark`, what shows), `set('light'|'dark'|'system')` and `toggle()`; `x-model="$store.theme.value"` binds a control. With colour profiles it also holds `scheme` (the profile on screen) and `previewScheme(name)`, which shows another profile on this page without storing anything.
`theme.meta` (default `false`) keeps the browser's bar in the page's colour, for an installed web app: the head script sets the `content` of every `<meta name="theme-color">` without a `media` attribute to the resolved theme's `surface` — of the profile in `<html data-scheme>` — before the first paint, adding one to `<head>` when there is none. It follows every later change of `data-theme` or `data-scheme` (`$store.theme.set()`/`toggle()`, an OS change while `system`, `previewScheme()`), and paints the next page's meta after `wire:navigate`. A theme-color meta the layout renders itself goes before `<x-theme-script />` (after it, the script has already added one, and the page ends up with two), or is left out. A `media="(prefers-color-scheme: …)"` pair follows the OS instead of the visitor's choice: drop it when turning this on.
## Safe areas
Every component that meets the edge of the screen (app bar, navigation bar and rail, docked and placed toolbars, full-screen search, dialog and side sheet, bottom sheet, the skip link) keeps clear of a notch or home indicator through `var(--material-safe-top|bottom|left|right, env(safe-area-inset-…))`. The layout needs `viewport-fit=cover` in its viewport meta for the insets to be non-zero. Set a variable to replace the device's inset, on `<html>` or any ancestor: a browser test fakes a notch with `document.documentElement.style.setProperty('--material-safe-top', '47px')`, and an app that draws its own status strip adds its height.
`--material-bottom-extra` (default `0px`) is the height of anything the application docks on top of the phone's navigation bar in `<x-app-shell>` (an offline banner): the shell adds it to `--material-bottom-bar` (64px + the bottom inset), so the snackbar, a `fab` button and the page's bottom padding clear it too. Set it while the docked element shows, and remove it when it goes; place the docked element itself directly above the bar, at `bottom: calc(4rem + var(--material-safe-bottom, env(safe-area-inset-bottom)))`, below `sm` only.
## Toasts
@@ -85,6 +122,59 @@ class Settings extends Component
The methods are protected. They dispatch a `toast` browser event (`assertDispatched('toast', type: 'success', title: 'Settings saved')` in tests).
## Error pages
Laravel's HTTP error pages — 403, 404, 419, 429, 500, 503, and the framework's own 401 and 402 — render in M3 without setup. The provider appends the package's error views to `view.paths` after the application's, so a file in `resources/views/errors/` always wins.
- The pages load `config('livewire-material.showcase.vite')` and `<x-theme-script />`, so they use the app's scheme, font and theme. While the build is missing (a deploy in progress) they fall back to an inline stylesheet coloured from `resources/css/material-scheme.json`.
- `abort(403, 'Only the owner can open this share.')` and `abort(503, '…')` show the message as the sentence. Every other string goes through `__()`; translate them in `lang/{locale}.json`.
- To change wording or design, run `php artisan vendor:publish --tag=livewire-material-errors`, which copies the layout and pages to `resources/views/errors`. A page extends `errors::minimal` and sets `title`, `code`, `headline`, `message`, `shape` (an `<x-shape>` name) and optionally `actions`:
```blade
@extends('errors::minimal')
@section('title', __('Payment Required'))
@section('code', '402')
@section('shape', 'cookie-4')
@section('headline', __('Your plan has ended'))
@section('message', __('Choose a plan to keep using the app.'))
@section('actions')
<x-button :link="route('billing')" :label="__('Choose a plan')" variant="filled" size="md" no-wire-navigate />
@endsection
```
- Maintenance mode: `php artisan down --render="errors::503"`.
- The showcase previews each page at `/material/errors/{code}`.
## Mail
Markdown mail (notifications and `markdown:` mailables) wears M3 once the application selects the theme:
```dotenv
MAIL_MARKDOWN_THEME=livewire-material::mail.theme
```
or per mail: `(new MailMessage)->theme('livewire-material::mail.theme')`, or `public $theme = 'livewire-material::mail.theme';` on a mailable.
- Colours are the light scheme from `resources/css/material-scheme.json` (`livewire-material.scheme`), inlined as hexes; regenerate the scheme and mail follows. Without the file, the package's default scheme applies.
- Write the body as Markdown; the theme styles the bare tags (`#` headings, prose, lists, tables) with M3's typescale. `<x-mail::button :url="…">` is a filled pill in `primary`; `color` also takes `secondary`, `tertiary`, `error`, `success`, `warning` and `info`. `<x-mail::panel>` is a tinted container.
- There is no dark mail. Never put `@media` rules, CSS variables or `color-mix()` in mail CSS: the inliner strips media queries and mail clients resolve no variables.
- The package's mail header (the app name, or a logo) and message (with a replaceable footer) are opt-in: set `MATERIAL_MAIL_COMPONENTS=true`, or `php artisan vendor:publish --tag=livewire-material-mail` to copy them into `resources/views/vendor/mail`. For a logo set `livewire-material.mail.logo` to `['src' => 'https://example.com/logo.png', 'width' => 160, 'height' => 40]`: an absolute URL, with the image at twice those dimensions.
- With those components, a mail can replace the footer:
```blade
<x-mail::message>
Your export is ready.
<x-slot:footer>
© {{ date('Y') }} {{ config('app.name') }} · [Unsubscribe]({{ $unsubscribeUrl }})
</x-slot:footer>
</x-mail::message>
```
- The showcase renders a sample mail at `/material/mail`.
## Components
### `<x-icon>`
@@ -105,7 +195,7 @@ One of M3 Expressive's 35 shapes, filled in the text colour, `aria-hidden`, size
### `<x-theme-script>`
The theme decided before the first paint. Exactly once per layout, in `<head>`, before `@vite`. No props; configured in `config/livewire-material.php`.
The theme decided before the first paint. Exactly once per layout, in `<head>`, before `@vite`. No props; configured in `config/livewire-material.php`. With `theme.meta` on it also paints `<meta name="theme-color">` (see Theme); a layout's own theme-color meta goes before it.
### `<x-button>`
@@ -154,7 +244,7 @@ M3's plain tooltip, standalone around any trigger: `<x-tooltip text="Copy link"
</x-menu>
```
`<x-menu>`: `trigger` slot (its first button or link becomes the menu button), `label`, `position` (`bottom-start` default, `bottom-end`, `top-start`, `top-end`), `vibrant`. `<x-menu-item>`: `label`, `icon`, `icon-right`, `description`, `shortcut`, `link`, `external`, `selected` (makes it a `menuitemcheckbox`), `disabled`, `keep-open`. Choosing an item closes the menu unless `keep-open`. Keyboard: arrows, Home, End, a letter, Escape (focus returns to the trigger), Tab.
`<x-menu>`: `trigger` slot (its first button or link becomes the menu button, and the menu hangs on that button — a `position: fixed` trigger such as `<x-button fab>` carries it along, and a menu with no room flips to the other side, end or both), `label`, `position` (`bottom-start` default, `bottom-end`, `top-start`, `top-end`), `vibrant`. `<x-menu-item>`: `label`, `icon`, `icon-class` (classes for the leading icon; a colour there paints it, a selected item's too, but not a disabled one's — `icon-class="text-sport-run"`), `icon-right`, `description`, `shortcut`, `link`, `external`, `selected` (makes it a `menuitemcheckbox`), `current` (for a menu of places: marks the page you are on with `aria-current="page"` in secondary-container, never a checked choice), `badge` (`true` for a dot, or a count, at the end of the row), `disabled`, `keep-open`. Choosing an item closes the menu unless `keep-open`; a second press on the menu button closes it too. An open menu stays open while the Livewire component around it renders, a `keep-open` item's own `wire:click` included. Keyboard: arrows, Home, End, a letter, Escape (focus returns to the trigger), Tab.
### `<x-button-group>`
@@ -172,7 +262,7 @@ A choice between a few options as a connected button group of native radios (che
]" hint="Recipients lose access after that" />
```
Props: `label`, `hint`, `name` (required with `x-model`), `options`, `option-value` (`id`), `option-label` (`name`), `option-icon` (`icon`), `size`, `variant` (`tonal`, `filled`, `outlined`), `multiple`, `inline` (intrinsic width instead of sharing the row). A validation error for the bound property replaces the hint.
Props: `label`, `hint`, `hint-class` (classes for the hint, as on the fields; a colour there paints it), `name` (required with `x-model`), `options`, `option-value` (`id`), `option-label` (`name`), `option-icon` (`icon`), `size`, `variant` (`tonal`, `filled`, `outlined`), `multiple`, `inline` (intrinsic width instead of sharing the row). A validation error for the bound property replaces the hint.
### `<x-split-button>`
@@ -199,7 +289,7 @@ Attributes go to the leading button; the slot is the menu. `variant` (`filled` d
</div>
```
Two to six items open above the FAB, which turns into a close button. `<x-fab-menu>`: `icon` (`add`), `label`, `color`, `position` (`top-end` default). Give items the same `color`. Keyboard as `<x-menu>`.
Two to six items open above the FAB, which turns into a close button. `<x-fab-menu>`: `icon` (`add`), `label`, `color`, `position` (`top-end` default). Give items the same `color`. Keyboard, and staying open through a Livewire render, as `<x-menu>`.
### `<x-loading>`
@@ -223,6 +313,16 @@ materialToast('Share deleted', { type: 'success', description: null, timeout: 40
`type` (`success`, `error`, `warning`, `info`) adds the state icon; `timeout: 0` keeps it until dismissed; a toast with an action or no timeout gets a close button. Hover or focus pauses the timer.
- `action`: `label`, plus `handler` (a function) and/or `event` (a name). Pressing it closes the snackbar, calls `handler`, then dispatches `new CustomEvent(event)` on `window`; give both and both run. Use `event` where a function cannot travel, such as a toast built from JSON.
- `sticky: true` keeps a toast until it is dismissed or its action is pressed (any `timeout` is ignored), without holding the queue up: a toast dispatched meanwhile shows in its place, and the sticky one comes back once the queue is empty. One sticky toast is kept at a time; a newer one replaces it. Use it for a question that must be answered, not for news:
```js
window.dispatchEvent(new CustomEvent('toast', { detail: { type: 'info', title: 'A new version is ready', sticky: true, action: { label: 'Reload', event: 'app:update' } } }))
window.addEventListener('app:update', () => location.reload())
```
- Hooks: `data-toast` on the snackbar on screen, `data-toast-action` on its action button (`[data-toast]` is absent while nothing shows). Target these in tests, not classes.
### `<x-progress>`
M3 Expressive's progress indicator: linear (as wide as its container) or `circular` (40px, 48px wavy, unless a `size-*` class is passed), flat or `wavy`, determinate with a `value` or indeterminate without one.
@@ -253,7 +353,10 @@ A value the server changes animates after a morph (the SVG is `wire:ignore`; onl
### `<x-badge>`
- `<x-badge />` — M3's small badge, a dot. `<x-badge value="4" max="99" />` — M3's large badge, a count. Both `error` by default. `floating` pins it to the top-end corner of a `relative` parent: `<span class="relative inline-flex"><x-icon name="mail" /><x-badge value="4" floating /></span>`. A dot or count is `aria-hidden` unless it has a `label`; name the control instead ("Messages, 4 unread").
- `<x-badge value="Expired" tonal />`, `<x-badge value="Active" color="success" tonal />`, `<x-badge value="Pro" outline />` — a status label (not an M3 badge) in the colour's container or a neutral edge. `color` (alias `tone`): `error` default, `primary`, `secondary`, `tertiary`, `success`, `warning`, `info`.
- `<x-badge value="Expired" tonal />`, `<x-badge value="Active" color="success" tonal />`, `<x-badge value="Built in" color="primary" solid />`, `<x-badge value="Pro" outline />` — a status label (not an M3 badge) in the colour's container, in the colour itself (`solid`, for a label that has to stand out), or a neutral edge. `color` (alias `tone`): `error` default, `primary`, `secondary`, `tertiary`, `success`, `warning`, `info`, `neutral`, `plain`; an unknown colour is `error`.
- `color="neutral"` — neutral ink on every variant: a dot or count in on-surface-variant with surface text, `tonal` in surface-container-high with on-surface-variant text, `outline` in the outline-variant edge with on-surface-variant text.
- `color="plain"` — no background, text or border colour in any variant (shape, size and type stay), so the classes you pass paint it: `<x-badge value="Run" tonal color="plain" class="bg-tertiary-container text-on-tertiary-container" />`. Pass both a background and a text class; an `outline` badge's edge takes the text colour unless you pass a `border-*` colour.
- The value is `value` or the slot; the slot renders as HTML: `<x-badge tonal><x-icon name="bolt" class="size-3" /> Pro</x-badge>`. `value` is escaped. A slot that holds only whitespace or comments is still a dot.
### `<x-alert>`
@@ -279,7 +382,7 @@ A few lines of context around a trigger, with an optional `title` and `actions`
</x-rich-tooltip>
```
Shows on hover and keyboard focus; `persistent` opens it on press and keeps it until a press elsewhere or Escape (use it when there are actions). `side`: `bottom` (default), `top`, `left`, `right`.
Shows on hover and keyboard focus; `persistent` opens it on press and keeps it until a press elsewhere or Escape (use it when there are actions). An open bubble stays open while the Livewire component around it renders, its actions' `wire:click` included. `side`: `bottom` (default), `top`, `left`, `right`.
### `<x-stat>`
@@ -289,6 +392,15 @@ Shows on hover and keyboard focus; `persistent` opens it on press and keeps it u
"Nothing here yet": `icon` on an Expressive `shape` (`cookie-9` by default), `title`, `description` or slot, and an `actions` slot. Use it for an empty collection, not for a filter that matched nothing.
The `illustration` slot draws the application's own artwork in place of the shape and icon (`icon` and `shape` are then unused). Size the artwork yourself; the slot's attributes go on the element around it, so its `class` sets the colour `currentColor` takes. Mark decorative SVG `aria-hidden="true"`. A slot holding only whitespace or comments leaves the shape and icon.
```blade
<x-empty-state title="No routes yet" description="Draw one on the map.">
<x-slot:illustration class="text-primary"><svg class="size-32" viewBox="0 0 120 120" aria-hidden="true">…</svg></x-slot:illustration>
<x-slot:actions><x-button label="Draw a route" variant="filled" /></x-slot:actions>
</x-empty-state>
```
### `<x-card>`
`variant`: `filled` (default, surface-container-highest), `elevated`, `outlined`; medium corner. Props `title`, `subtitle`, `separator`; slots `figure` (full-bleed media), `menu` (top-end), `actions` (end-aligned). Do not pass `bg-*`; use `variant`.
@@ -324,6 +436,15 @@ A card or list item that opens something is a **row**: `data-list-row` on it and
A disclosure on native `<details>`: `<x-collapse title="Advanced" icon="tune" open variant="filled">…</x-collapse>` (`variant` `plain` or `filled`; `heading` slot for rich titles). Keeps its state through a morph.
Bind the open state to a boolean, both ways, with `wire:model` (any modifiers; `.live` sends each toggle at once) or `x-model`:
```blade
<x-collapse title="Fine-tuning" wire:model="fineTuning">…</x-collapse>
<div x-data="{ advanced: false }"><x-collapse title="Advanced" x-model="advanced">…</x-collapse></div>
```
Toggling writes the property; changing the property (in an action or in Alpine) opens or closes it. With `wire:model` the server renders it open or closed as the property is, so there is no flash, and `open` is ignored; with `x-model`, `open` is only the first paint until Alpine starts. The bound state is `collapseOpen` in the `<details>` scope. Without a binding there is no Alpine on it.
### `<x-modal>`
An M3 dialog on native `<dialog>`. Bind with `wire:model` to a flag or an id; closing (Escape, scrim, `close()`) writes back `false` or `null`. Without `wire:model` it uses `open` from the surrounding Alpine scope.
@@ -341,7 +462,7 @@ Props: `title`, `subtitle`, `icon` (centred hero icon), `separator`, `persistent
### `<x-drawer>`
An M3 side sheet, bound like `<x-modal>`; `close()` in scope. Props: `title`, `subtitle`, `separator`, `side` (`end` default, `start`), `width` (`25rem`), `with-close-button`, `close-on-escape` (default true), `without-backdrop-close`, `actions` slot. `pane` (with `pane-width`) turns it into a list-detail pane from `xl`: render it after the list inside `<div class="xl:flex xl:items-start xl:gap-6">`. Its body is a size container — lay out inside with `@md:` etc., not `sm:`.
An M3 side sheet, bound like `<x-modal>`; `close()` in scope. Props: `title`, `subtitle`, `separator`, `side` (`end` default, `start`), `width` (`25rem`), `with-close-button`, `close-on-escape` (default true), `without-backdrop-close`, `actions` slot. `pane` (with `pane-width`) turns it into a list-detail pane from `xl`: render it after the list inside `<div class="xl:flex xl:items-start xl:gap-6">`. Escape leaves a pane open unless `pane-close-on-escape`. Its body is a size container — lay out inside with `@md:` etc., not `sm:`.
### `<x-bottom-sheet>`
@@ -414,7 +535,7 @@ A one-column grid of fields with an `actions` slot at the foot (the slot takes i
### `<x-field>`, `<x-input>`, `<x-password>`, `<x-textarea>`, `<x-select>`, `<x-file>`
M3 text fields. `variant`: `outlined` or `filled`; without it, `config('livewire-material.fields.variant')` (`outlined`). All take `label`, `hint`, `variant`, and read their errors from the bag under the `wire:model` name (the error replaces the hint, sets `aria-invalid`). `class` lands on the field's outer element (margins, widths); every other attribute (`wire:model`, `type`, `required`, `readonly`, `autocomplete`) reaches the control. Never pass `placeholder` expecting it to show while a label rests in the field: it shows once the field has focus.
M3 text fields. `variant`: `outlined` or `filled`; without it, `config('livewire-material.fields.variant')` (`outlined`). All take `label`, `hint`, `variant` (and all but `<x-file>` a `hint-class`, classes added to the hint: `hint-class="text-warning"`), and read their errors from the bag under the `wire:model` name, or the `name` in a plain form (`photos[]``photos`, `address[city]``address.city`); the error replaces the hint and sets `aria-invalid`. `class` lands on the field's outer element (margins, widths); every other attribute (`wire:model`, `type`, `required`, `readonly`, `autocomplete`) reaches the control. Never pass `placeholder` expecting it to show while a label rests in the field: it shows once the field has focus.
- `<x-input>`: `icon`, `icon-right`, `prefix`, `suffix`, `clearable`, `copyable` (copies the value, confirms with a snackbar), `size` (`sm` 40px, `xs` 32px — for unlabelled toolbar controls; give them `aria-label`), `mono`.
- `<x-password>`: a reveal button; `icon`, `size`.
@@ -463,6 +584,53 @@ M3 Expressive's slider on native `<input type="range">`s (one per handle), so th
Other attributes go to the input(s). A `wire:model.live` slider sends while it is dragged, and a server render never moves a handle under the pointer (the drawing is `wire:ignore`); a value the server sets moves the handle after the morph. The binding gets `.number`, so values arrive as numbers. Its width is the container's unless a `w-*` class is passed.
### `<x-datepicker>`
M3 date pickers on a text field. `wire:model` stores `Y-m-d` strings (`x-model` without Livewire).
```blade
<x-datepicker label="Expires on" wire:model.live="expiresOn" :min="now()" :max="now()->addMonth()" />
<x-datepicker label="Birthday" mode="modal" wire:model="birthday" :max="now()" />
<x-datepicker label="Trip" range wire:model="trip" hint="Start and end" clearable />
<x-datepicker label="Race day" wire:model="raceDay" :week-start="$user->week_start" :format="$user->date_format" />
```
| Prop | Default | |
|---|---|---|
| `mode` | `docked` | `docked`: type a date (in the locale's numeric format) or pick one from a calendar under the field, which opens as a dialog below `sm`; `modal`: the field opens a calendar dialog; `input`: the dialog opens on a text field. Both dialogs switch between calendar and typing |
| `range` | `false` | binds one array property, `['start' => 'Y-m-d', 'end' => 'Y-m-d']` (either may be null); errors for `trip`, `trip.start` and `trip.end` show on the field |
| `min`, `max` | `null` | `Y-m-d` or a date; days outside are disabled and the keyboard stays inside |
| `label`, `hint`, `icon`, `variant`, `size` | | the field's |
| `value` | `null` | the initial value without `wire:model` |
| `name` | | adds hidden inputs with `Y-m-d` for a plain form post (`name[start]`, `name[end]` for a range) |
| `clearable` | `false` | a button that empties the field (both ends of a range) once it holds a date |
| `week-start` | `null` | the first day of the week, `0` (Sunday) to `6` (Saturday), instead of the locale's: the calendar's columns, weekday header and Home/End follow it. Anything else is ignored |
| `format` | `null` | the typed and displayed format instead of the locale's: `dd`, `MM` and `yyyy`, each once, around one delimiter (`.`, `/`, `-`) — `dd.MM.yyyy`, `dd/MM/yyyy`, `MM/dd/yyyy`, `yyyy-MM-dd`. The field, the dialog's text fields, a range and the error message follow it; `wire:model` still stores `Y-m-d`. Anything else is ignored |
Picking in the calendar is a draft; OK or Enter on a day keeps it, Cancel or Escape does not. A typed date is the value once it is whole and allowed; otherwise the field says why. Month and weekday names, the week's first day and the typed format follow `app()->getLocale()` (the last two unless `week-start` and `format` say otherwise — for a per-person setting). Keyboard: arrows, Home/End (week), PageUp/PageDown (month; with Shift, year), Space, Enter, Escape. `min` and `max` are read when the picker starts: when they change on the server, give the component a `wire:key` that changes with them. `required`, `disabled` and `readonly` reach the text field.
### `<x-timepicker>`
M3's time picker in a modal dialog, opened from a read-only text field (a press, Enter, Space, ArrowDown or its clock icon). The dial picks the hour, then the minutes, by press or drag; a 24-hour clock puts 1223 on the inner ring. The keyboard icon switches to two text fields. The arrow keys change the focused dial's value, Home and End go to the ends, Enter confirms; Escape, Cancel or the scrim close it unchanged and give focus back to the field. In a landscape window the dial lies on its side.
```blade
<x-timepicker label="Starts at" wire:model="startsAt" />
<x-timepicker label="Appointment" wire:model.live="appointmentAt" format="24" step="15" min="08:00" max="17:30" clearable />
```
| Prop | Default | |
|---|---|---|
| `wire:model` / `x-model` | | the time as `H:i`, null until chosen; `H:i:s` (a `time` column) is read and written back as `H:i`; nothing is written until OK |
| `format` | the locale's | `12` or `24`; otherwise the hour cycle of `locale` as `Intl.DateTimeFormat` reports it |
| `locale` | app locale | the hour cycle, and how the field writes the time |
| `step` | `1` | minutes between choices (a tap picks fives, or steps when five is not a multiple of the step) |
| `min`, `max` | | `H:i`, inclusive; `min` later than `max` spans midnight. Outside values are greyed out and refused in the picker — validate on the server as well |
| `clearable` | `false` | a button that empties the field |
| `name` | | posts the value from a hidden input |
| `label`, `hint`, `icon`, `variant`, `size` | | the field's; `required`, `disabled` and `placeholder` reach its input |
Errors under the `wire:model` name replace the hint. The dialog is `wire:ignore`: a Livewire render leaves an open picker open with its draft. Never name a Livewire property `$slot`: it renders empty in the component's view.
### `<x-choices>`
Choosing from a list, with typed values (an array of integers stays integers). `options` (`id`, `name`, `disabled`; `option-value`, `option-label`), `label`, `hint`, `single`. Errors for the property and its items replace the hint.
@@ -492,6 +660,84 @@ 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-shell>`
The adaptive app shell, a whole layout's body: a navigation bar below `sm`, a collapsed rail that opens as a modal to `lg`, an expanded rail the visitor can collapse from `lg`, the page as `<main id="content" wire:transition.navigate>` behind a skip link, and the snackbar host (do not add another `<x-toast />`). It needs `<x-theme-script />` in `<head>`.
```blade
<x-app-shell :destinations="[
['title' => 'Shares', 'icon' => 'folder_shared', 'url' => route('shares.index'), 'active' => request()->routeIs('shares.*'), 'badge' => $expiringCount],
['title' => 'Upload', 'icon' => 'upload', 'url' => route('upload')],
['title' => 'Users', 'icon' => 'group', 'url' => route('users'), 'section' => 'Admin', 'bar' => false],
]">
<x-slot:brand><a href="{{ route('home') }}" wire:navigate class="type-title-lg">SealShare</a></x-slot:brand>
<x-slot:rail-header>
<span class="rail-collapsed:hidden"><x-fab label="New share" icon="add" link="{{ route('upload') }}" /></span>
<span class="hidden rail-collapsed:inline-flex"><x-fab icon="add" tooltip-right="New share" link="{{ route('upload') }}" /></span>
</x-slot:rail-header>
<x-slot:rail-footer>
<x-navigation-rail-item label="Settings" icon="settings" link="{{ route('settings') }}" :active="request()->routeIs('settings')" />
</x-slot:rail-footer>
<x-slot:top>
{{-- the page's app bar; its menu button opens the modal rail on a phone --}}
<span class="sm:hidden"><x-button icon="menu" tooltip="Open navigation" x-on:click="$store.rail.show()" /></span>
</x-slot:top>
{{ $slot }}
</x-app-shell>
```
- `destinations`: `title`, `icon`, `url`; optional `active` (default: the URL is the page's, also during a Livewire update request), `badge` (`true` for a dot, or a count), `badgeLabel` (what a screen reader hears for the badge: "3 unread"), `section` (a heading in the rail, shown only while it is expanded; consecutive destinations with the same section are grouped), `bar` (default `true`; `false` keeps it out of the bottom bar — M3 wants three to five there), `navigate` (`false` for a full page load instead of `wire:navigate`).
- Slots, each rendered once: `brand` (beside the rail's menu button, expanded only), `rail-header` (a FAB), `rail-footer` (pinned to the foot of the rail), `actions` (a row of icon buttons at the very foot, stacked when collapsed), `top` (the app bar, above the page at every width), and the page. `label` names the landmarks ("Main"); `rail-width` is the expanded width (`16rem`).
- The rail is one element at every width: what is in it is also what a phone sees in the modal rail. Below `sm` nothing opens it but `$store.rail.show()`, so a page whose destinations are not all in the bar needs a menu button in its app bar (hidden from `sm`).
- Below `sm` the shell sets `--material-bottom-bar` (the bar, the bottom safe area and `--material-bottom-extra`), so the snackbar, a `fab` button and the page's bottom padding clear the bar; pad anything else you pin to the bottom with it. See Safe areas.
- The content region is `max-lg:overflow-x-clip`. Never make a page wrapper `overflow-x-hidden`: it turns the region into a scroll container and breaks every `sticky` inside.
### `<x-navigation-bar>`, `<x-navigation-bar-item>`
M3 Expressive's flexible navigation bar, for three to five destinations. It does not position itself; wrap it (`<x-app-shell>` does):
```blade
<div class="fixed inset-x-0 bottom-0 z-30 sm:hidden">
<x-navigation-bar>
<x-navigation-bar-item label="Shares" icon="folder_shared" link="{{ route('shares.index') }}" :active="request()->routeIs('shares.*')" badge="3" />
<x-navigation-bar-item label="Upload" icon="upload" link="{{ route('upload') }}" />
</x-navigation-bar>
</div>
```
64px in surface-container with the bottom safe area under it. Narrower than 600px the icon sits in a 56×32 indicator over the label; from 600px (the bar's own width) icon and label share a 40px pill and the items gather in the middle. `<x-navigation-bar>`: `label` ("Main"). `<x-navigation-bar-item>`: `label` / slot, `icon`, `link` (with `wire:navigate` unless `external` or `no-wire-navigate`; without a link it is a button), `active` (`aria-current="page"`, filled icon, secondary-container indicator), `badge` (`true` for a dot, a number for a count, 999+ at most), `badge-label` (what a screen reader hears instead of ", 3").
### `<x-navigation-rail>`, `<x-navigation-rail-item>`, `<x-navigation-rail-section>`
M3 Expressive's navigation rail: collapsed (96px, icon over label) or expanded (a 56px full-width pill, icon beside label, count at the end).
```blade
<div class="flex min-h-dvh">
<x-navigation-rail mode="collapsible">
<x-slot:brand><span class="type-title-lg">SealShare</span></x-slot:brand>
<x-slot:header><x-fab icon="add" tooltip-right="New share" /></x-slot:header>
<x-navigation-rail-item label="Shares" icon="folder_shared" link="{{ route('shares.index') }}" active badge="3" />
<x-navigation-rail-section label="Admin">
<x-navigation-rail-item label="Users" icon="group" link="{{ route('users') }}" />
</x-navigation-rail-section>
<x-slot:footer>
<x-navigation-rail-item label="Settings" icon="settings" link="{{ route('settings') }}" />
</x-slot:footer>
</x-navigation-rail>
<main class="min-w-0 flex-1">…</main>
</div>
```
- `mode`: `collapsed`, `expanded`, `collapsible` (default: expanded until its menu button collapses it; the choice is `$store.rail`, remembered and applied before the first paint), `modal` (collapsed in the layout; the menu button or `$store.rail.show()` opens it expanded over a scrim, focus held until Escape, the scrim or leaving the page), `adaptive` (`<x-app-shell>`'s: hidden and opened as a modal below `sm`, collapsed and opened as a modal to `lg`, collapsible from `lg`).
- Props: `label` ("Main"), `width` (expanded width, `16rem`, held between 220 and 360px), `menu` (the menu button; on by default for `collapsible`, `modal`, `adaptive`). Slots: `brand` (beside the menu button, expanded only), `header` (a FAB), the destinations (the only part that scrolls), `footer`. In a flex row the rail sticks to the top of the viewport.
- Anything inside a rail takes both shapes with the `rail-collapsed:` variant, true while that rail is drawn collapsed for whatever reason: `<span class="rail-collapsed:hidden">…expanded only…</span>`, `<span class="hidden rail-collapsed:inline-flex">…collapsed only…</span>`. Put the variant on a wrapper, never on a component. Nothing that shows while collapsed may be wider than 96px.
- `<x-navigation-rail-item>`: the same props as `<x-navigation-bar-item>`. `<x-navigation-rail-section label="…">`: a group with a heading that shows only while the rail is expanded; it names the group for screen readers either way.
- `$store.rail`: `collapsed`, `toggle()`, `collapse()`, `expand()` (the remembered choice), `open`, `show()`, `hide()` (the modal rail; closed on every `wire:navigate`). `config/livewire-material.php``rail.default` (`expanded` or `collapsed`) and `rail.storage_key` (`material-rail`).
### `<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).
@@ -530,7 +776,7 @@ M3 tabs with a server-rendered tablist (arrow keys, Home/End, disabled tabs skip
### `<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`.
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, whose items mark the current section as the page (`current`) and carry each section's badge. `items`: `['title', 'url', 'icon', 'active', 'badge']` — current when `active` or its `url` is the page's (during a Livewire update request, the page the component was rendered on, so the section stays lit when a component re-renders). `label`, `no-wire-navigate`.
### `<x-account-menu>`
@@ -549,6 +795,14 @@ An avatar that opens a menu: `name`, `email`, `avatar` (image URL or initials; d
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.
### `<x-scheme-picker>`
A choice of colour profile (see Colour profiles): a swatch per generated profile — its name and its primary, secondary and tertiary colour — over native radios. `wire:model` or `x-model` (with `name`) binds the chosen name; choosing previews it on the page at once; storing it is the application's. `label`, `hint`, `name`, `profiles` (default `Scheme::profiles()`). A validation error for the bound property replaces the hint. Without profiles it renders nothing.
```blade
<x-scheme-picker :label="__('Colour profile')" wire:model="colorProfile" :hint="__('Applies to every page after saving')" />
```
### `<x-table>`, `<x-sort-header>`
A data table: write plain `<thead>`, `<tr>`, `<th>`, `<td>` inside `<x-table>` (`size="xs"` for a dense one); cell utilities (`text-end`, `whitespace-nowrap`) always win. Scrolling is yours: wrap it in `<div class="overflow-x-auto">`. A row that opens something is `data-list-row` with one `data-list-open` control; a selected row is `aria-selected="true"`.
@@ -590,7 +844,7 @@ It fails on maryUI tags, daisyUI classes, colours the theme does not declare, un
## Conventions
- Components are anonymous Blade components: `<x-name>` without a prefix, or `<x-{prefix}name>` when `config('livewire-material.prefix')` is set.
- Components are anonymous Blade components: `<x-name>` without a prefix, or `<x-{prefix}::name>` when `config('livewire-material.prefix')` is set; `<x-livewire-material::name>` always works.
- Write class names out whole. Tailwind cannot compile `'text-'.$tone` or `type-{{ $size }}`, and the design guard cannot read them.
- The showcase at `/material` (local only, `MATERIAL_SHOWCASE=true` to force it) renders every token and component.
+1 -1
View File
@@ -24,7 +24,7 @@
z-index: 20;
display: block;
min-height: var(--app-bar-height);
padding-top: env(safe-area-inset-top);
padding-top: var(--material-safe-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);
+508
View File
@@ -0,0 +1,508 @@
/*
* M3's date pickers docked, modal and modal input (resources/views/components/datepicker.blade.php).
*
* One `<dialog>` wears both: docked it is a popover hung under the field by CSS anchor positioning
* (large corner), modal it is opened with `showModal()` over a scrim (extra-large corner, with M3's
* header: a title, the chosen date as the headline, and the switch between calendar and typing).
* Both are 360px of surface-container-high at elevation 3.
*
* Values from DatePickerModalTokens and DateInputModalTokens (androidx Compose Material 3, commit
* 27cf9a7d5788aa0f5f2d8b6699ce279560daf326) and the layout of DatePicker.kt, DateRangePicker.kt and
* DateInput.kt; the docked picker's from material-web's md-comp-date-picker-docked tokens (v0_192).
* Apache-2.0.
*
* [data-datepicker] the root: the field, its support line, the dialog
* [data-datepicker-support] the hint, a server error, or what is wrong with the typed date
* [data-datepicker-picker] the <dialog>; data-presentation="docked" | "modal"
* [data-datepicker-surface]
* [data-datepicker-header] modal only: title, headline, switch
* [data-datepicker-calendar]
* [data-datepicker-nav] month and year: one menu button and arrows (modal),
* or a month and a year stepper (docked, data-docked)
* [data-datepicker-grid] the weekdays and six weeks of [data-datepicker-day] cells:
* data-today, data-selected, data-start, data-end,
* data-between, data-outside, data-blank, aria-disabled
* [data-datepicker-years] the modal year grid
* [data-datepicker-menu] a docked month or year list of [data-datepicker-option]s
* [data-datepicker-entry] the text field(s) of the modal input
* [data-datepicker-actions] Cancel and OK
*
* Every state layer is the content colour at 8% on hover (only where a pointer can hover) and 10% on
* focus and press; keyboard focus adds the package's 3px secondary ring.
*/
@layer components {
/* ---- The support line under the field ------------------------------------------------------ */
/* The padding is on the lines, not the box, so a box with nothing to say takes no room. */
[data-datepicker-support] {
--datepicker-pad: 1rem;
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-sm);
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
}
[data-datepicker-support] > * {
padding: 0.25rem var(--datepicker-pad) 0;
}
[data-datepicker-support][data-size="sm"] {
--datepicker-pad: 0.75rem;
}
[data-datepicker-support][data-size="xs"] {
--datepicker-pad: 0.625rem;
}
[data-datepicker-error] {
color: var(--md-sys-color-error);
}
/* ---- The dialog ---------------------------------------------------------------------------- */
[data-datepicker-picker] {
inset: auto;
width: 22.5rem;
max-width: calc(100vw - 1rem);
max-height: calc(100dvh - 1rem);
margin: 0;
padding: 0;
overflow: visible;
border: 0;
background: transparent;
color: var(--md-sys-color-on-surface);
}
[data-datepicker-picker]:popover-open {
top: anchor(bottom);
left: anchor(left);
margin-block: 0.25rem;
position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
opacity: 1;
transition-property: opacity;
transition-duration: var(--md-sys-motion-effects-fast-duration);
transition-timing-function: var(--md-sys-motion-effects-fast);
}
@starting-style {
[data-datepicker-picker]:popover-open {
opacity: 0;
}
}
[data-datepicker-picker]:modal {
inset: 0;
margin: auto;
opacity: 1;
scale: 1;
transition-property: opacity, scale;
transition-duration: var(--md-sys-motion-spatial-fast-duration);
transition-timing-function: var(--md-sys-motion-spatial-fast);
}
@starting-style {
[data-datepicker-picker]:modal {
opacity: 0;
scale: 0.95;
}
}
[data-datepicker-picker]:modal::backdrop {
background-color: color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent);
}
[data-datepicker-surface] {
display: flex;
flex-direction: column;
max-height: inherit;
overflow-x: hidden;
overflow-y: auto;
border-radius: var(--md-sys-shape-corner-lg);
background-color: var(--md-sys-color-surface-container-high);
box-shadow: var(--md-sys-elevation-3);
outline: none;
}
[data-datepicker-picker][data-presentation="modal"] [data-datepicker-surface] {
border-radius: var(--md-sys-shape-corner-xl);
}
/* ---- The modal header ---------------------------------------------------------------------- */
[data-datepicker-header] {
display: flex;
flex-direction: column;
justify-content: space-between;
flex: none;
min-height: 7.5rem;
border-bottom: 1px solid var(--md-sys-color-outline-variant);
color: var(--md-sys-color-on-surface-variant);
}
[data-datepicker-header][data-range] {
min-height: 8rem;
}
[data-datepicker-title] {
padding: 1rem 0.75rem 0 1.5rem;
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
}
[data-datepicker-headline-row] {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0 0.75rem 0.75rem 1.5rem;
}
[data-datepicker-headline] {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font: var(--md-sys-typescale-headline-lg);
letter-spacing: var(--md-sys-typescale-headline-lg-tracking);
}
[data-datepicker-header][data-range] [data-datepicker-headline] {
font: var(--md-sys-typescale-title-lg);
letter-spacing: var(--md-sys-typescale-title-lg-tracking);
}
/* ---- Month and year navigation -------------------------------------------------------------- */
[data-datepicker-nav] {
display: flex;
flex: none;
align-items: center;
justify-content: space-between;
height: 3.5rem;
padding-inline: 0.75rem;
color: var(--md-sys-color-on-surface-variant);
}
[data-datepicker-nav][data-docked] {
height: 4rem;
padding-inline: 0.25rem;
}
[data-datepicker-stepper],
[data-datepicker-arrows] {
display: flex;
align-items: center;
}
/* While a year or month list is open the arrows keep their place but are gone, as androidx hides them. */
[data-datepicker-arrows][data-concealed] {
visibility: hidden;
}
[data-datepicker-menu-button] {
position: relative;
display: inline-flex;
align-items: center;
gap: 0.5rem;
height: 2.5rem;
padding-inline: 0.75rem 0.5rem;
border-radius: var(--md-sys-shape-corner-full);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
white-space: nowrap;
cursor: pointer;
outline: none;
isolation: isolate;
}
[data-datepicker-nav][data-docked] [data-datepicker-menu-button] {
padding-inline: 0.5rem 0.25rem;
gap: 0.25rem;
}
[data-datepicker-menu-arrow] {
transition: rotate var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
[data-datepicker-menu-button][aria-expanded="true"] [data-datepicker-menu-arrow] {
rotate: 180deg;
}
/* ---- State layers shared by days, years, menu buttons and list rows ------------------------ */
[data-datepicker-menu-button]::before,
[data-datepicker-day] > span::before,
[data-datepicker-year]::before,
[data-datepicker-option]::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
background-color: var(--datepicker-layer, currentColor);
opacity: 0;
pointer-events: none;
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
@media (hover: hover) {
[data-datepicker-menu-button]:hover::before,
[data-datepicker-day]:not([aria-disabled="true"], [data-blank]):hover > span::before,
[data-datepicker-year]:hover::before,
[data-datepicker-option]:not([aria-disabled="true"]):hover::before {
opacity: 0.08;
}
}
[data-datepicker-menu-button]:focus-visible::before,
[data-datepicker-menu-button]:active::before,
[data-datepicker-day]:focus-visible > span::before,
[data-datepicker-day]:not([aria-disabled="true"], [data-blank]):active > span::before,
[data-datepicker-year]:focus-visible::before,
[data-datepicker-year]:active::before,
[data-datepicker-option]:focus-visible::before,
[data-datepicker-option]:not([aria-disabled="true"]):active::before {
opacity: 0.1;
}
[data-datepicker-menu-button]:focus-visible,
[data-datepicker-day]:focus-visible > span,
[data-datepicker-year]:focus-visible {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
[data-datepicker-option]:focus-visible {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: -3px;
}
/* ---- The days ----------------------------------------------------------------------------- */
[data-datepicker-grid] {
width: calc(100% - 1.5rem);
margin-inline: 0.75rem;
border-collapse: collapse;
table-layout: fixed;
}
[data-datepicker-grid] th {
height: 3rem;
padding: 0;
color: var(--md-sys-color-on-surface);
font: var(--md-sys-typescale-body-lg);
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
text-align: center;
}
[data-datepicker-day] {
--datepicker-layer: var(--md-sys-color-on-surface-variant);
position: relative;
height: 3rem;
padding: 0;
color: var(--md-sys-color-on-surface);
font: var(--md-sys-typescale-body-lg);
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
text-align: center;
cursor: pointer;
outline: none;
isolation: isolate;
-webkit-tap-highlight-color: transparent;
}
[data-datepicker-day] > span {
position: relative;
display: grid;
place-items: center;
width: 2.5rem;
height: 2.5rem;
margin: auto;
border-radius: var(--md-sys-shape-corner-full);
transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
[data-datepicker-day][data-blank] {
cursor: default;
}
/* A docked picker shows the neighbouring months' days, quieter. */
[data-datepicker-day][data-outside] {
color: var(--md-sys-color-on-surface-variant);
}
[data-datepicker-day][data-today] {
--datepicker-layer: var(--md-sys-color-primary);
color: var(--md-sys-color-primary);
}
[data-datepicker-day][data-today] > span {
box-shadow: inset 0 0 0 1px var(--md-sys-color-primary);
}
/* A range's band: secondary-container behind the days between its ends, from the middle of each end. */
[data-datepicker-day][data-between]::before,
[data-datepicker-day][data-start]::before,
[data-datepicker-day][data-end]::before {
content: "";
position: absolute;
inset-block: 0.25rem;
inset-inline: 0;
z-index: -1;
background-color: var(--md-sys-color-secondary-container);
}
[data-datepicker-day][data-start]::before {
inset-inline-start: 50%;
}
[data-datepicker-day][data-end]::before {
inset-inline-end: 50%;
}
[data-datepicker-day][data-between] {
--datepicker-layer: var(--md-sys-color-on-secondary-container);
color: var(--md-sys-color-on-secondary-container);
}
[data-datepicker-day][data-selected] {
--datepicker-layer: var(--md-sys-color-on-primary);
color: var(--md-sys-color-on-primary);
}
[data-datepicker-day][data-selected] > span {
background-color: var(--md-sys-color-primary);
box-shadow: none;
}
[data-datepicker-day][aria-disabled="true"] {
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
cursor: default;
}
[data-datepicker-day][aria-disabled="true"][data-today] > span {
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
}
/* ---- The modal year grid ----------------------------------------------------------------- */
[data-datepicker-years] {
display: grid;
grid-template-columns: repeat(3, 1fr);
align-content: start;
justify-items: center;
row-gap: 1rem;
height: 20.9375rem;
padding: 0.5rem 0.75rem;
overflow-y: auto;
overscroll-behavior: contain;
border-bottom: 1px solid var(--md-sys-color-outline-variant);
}
[data-datepicker-year] {
--datepicker-layer: var(--md-sys-color-on-surface-variant);
position: relative;
width: 4.5rem;
height: 2.25rem;
border-radius: var(--md-sys-shape-corner-full);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-lg);
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
cursor: pointer;
outline: none;
isolation: isolate;
scroll-margin-block: 0.5rem;
}
[data-datepicker-year][data-current] {
--datepicker-layer: var(--md-sys-color-primary);
color: var(--md-sys-color-primary);
box-shadow: inset 0 0 0 1px var(--md-sys-color-primary);
}
[data-datepicker-year][aria-selected="true"] {
--datepicker-layer: var(--md-sys-color-on-primary);
background-color: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
box-shadow: none;
}
/* ---- The docked month and year lists ------------------------------------------------------ */
[data-datepicker-menu] {
height: 21.5rem;
padding-block: 0.5rem;
overflow-y: auto;
overscroll-behavior: contain;
border-top: 1px solid var(--md-sys-color-outline-variant);
}
[data-datepicker-option] {
--datepicker-layer: var(--md-sys-color-on-surface);
position: relative;
display: flex;
align-items: center;
gap: 1rem;
width: 100%;
height: 3rem;
padding-inline: 1rem;
color: var(--md-sys-color-on-surface);
font: var(--md-sys-typescale-body-lg);
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
text-align: start;
cursor: pointer;
outline: none;
isolation: isolate;
}
[data-datepicker-option][aria-selected="true"] {
background-color: var(--md-sys-color-surface-container-highest);
}
[data-datepicker-option][aria-disabled="true"] {
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
cursor: default;
}
[data-datepicker-check] {
color: var(--md-sys-color-on-surface);
}
[data-datepicker-option]:not([aria-selected="true"]) [data-datepicker-check] {
visibility: hidden;
}
/* ---- The modal input ------------------------------------------------------------------------ */
[data-datepicker-entry] {
padding: 0.625rem 1.5rem 1rem;
}
[data-datepicker-entry-fields][data-range] {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
/* ---- Cancel and OK ---------------------------------------------------------------------------- */
[data-datepicker-actions] {
display: flex;
flex: none;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
padding: 0.25rem 0.375rem 0.5rem 0.75rem;
}
}
+17 -10
View File
@@ -15,18 +15,25 @@
*
* Split button (`<x-split-button>`): the same idea for two halves; the trailing half turns
* round and its chevron turns over while its menu is open (SplitButton*Tokens).
*
* "Full" here is half the size's height (`--group-full`), never `--md-sys-shape-corner-full`'s
* 9999px. One element mixes full outer corners with small inner ones, and when a box's radii
* add up to more than its side, CSS scales every radius by the same factor: a 9999px corner
* beside an 8px one shrank the 8px one to a hundredth of a pixel, so the inner corners drew
* square.
*/
[data-button-group] {
--group-inner: var(--md-sys-shape-corner-sm);
--group-inner-pressed: var(--md-sys-shape-corner-xs);
--group-full: 1.25rem;
}
[data-button-group][data-size='xs'] { --group-pad: 0.75rem; --group-grow: 4px; --group-inner: var(--md-sys-shape-corner-xs); --group-inner-pressed: 2px; }
[data-button-group][data-size='sm'] { --group-pad: 1rem; --group-grow: 6px; }
[data-button-group][data-size='md'] { --group-pad: 1.5rem; --group-grow: 8px; }
[data-button-group][data-size='lg'] { --group-pad: 3rem; --group-grow: 16px; --group-inner: var(--md-sys-shape-corner-lg); --group-inner-pressed: var(--md-sys-shape-corner-md); }
[data-button-group][data-size='xl'] { --group-pad: 4rem; --group-grow: 20px; --group-inner: var(--md-sys-shape-corner-lg-increased); --group-inner-pressed: var(--md-sys-shape-corner-lg); }
[data-button-group][data-size='xs'] { --group-pad: 0.75rem; --group-grow: 4px; --group-inner: var(--md-sys-shape-corner-xs); --group-inner-pressed: 2px; --group-full: 1rem; }
[data-button-group][data-size='sm'] { --group-pad: 1rem; --group-grow: 6px; --group-full: 1.25rem; }
[data-button-group][data-size='md'] { --group-pad: 1.5rem; --group-grow: 8px; --group-full: 1.75rem; }
[data-button-group][data-size='lg'] { --group-pad: 3rem; --group-grow: 16px; --group-inner: var(--md-sys-shape-corner-lg); --group-inner-pressed: var(--md-sys-shape-corner-md); --group-full: 3rem; }
[data-button-group][data-size='xl'] { --group-pad: 4rem; --group-grow: 20px; --group-inner: var(--md-sys-shape-corner-lg-increased); --group-inner-pressed: var(--md-sys-shape-corner-lg); --group-full: 4.25rem; }
[data-button-group='standard'] > :not([data-icon-button]):active:not(:disabled, [aria-disabled='true']) {
padding-inline: calc(var(--group-pad) + var(--group-grow));
@@ -60,19 +67,19 @@
[data-button-group='connected'] > :is([aria-pressed='true'], :has(:checked)),
[data-split='trailing'][aria-expanded='true'] {
--group-corner: var(--md-sys-shape-corner-full);
--group-corner: var(--group-full);
}
[data-button-group='connected'] > :first-child,
[data-split='leading'] {
border-start-start-radius: var(--md-sys-shape-corner-full);
border-end-start-radius: var(--md-sys-shape-corner-full);
border-start-start-radius: var(--group-full);
border-end-start-radius: var(--group-full);
}
[data-button-group='connected'] > :last-child,
[data-split='trailing'] {
border-start-end-radius: var(--md-sys-shape-corner-full);
border-end-end-radius: var(--md-sys-shape-corner-full);
border-start-end-radius: var(--group-full);
border-end-end-radius: var(--group-full);
}
[data-split='trailing'] svg {
+584
View File
@@ -0,0 +1,584 @@
/*
* M3 Expressive navigation: the flexible navigation bar and the navigation rail collapsed,
* expanded and modal.
*
* Values from androidx Compose Material 3 (Apache-2.0) at androidx-main
* 27cf9a7d5788aa0f5f2d8b6699ce279560daf326: tokens/NavigationBarTokens.kt,
* NavigationBarVerticalItemTokens.kt, NavigationBarHorizontalItemTokens.kt,
* NavigationRailCollapsedTokens.kt, NavigationRailExpandedTokens.kt,
* NavigationRailBaselineItemTokens.kt, NavigationRailVerticalItemTokens.kt,
* NavigationRailHorizontalItemTokens.kt, NavigationRailColorTokens.kt, and the layout in
* ShortNavigationBar.kt, WideNavigationRail.kt and NavigationItem.kt.
*
* [data-navigation-bar] surface-container, 64px, the bottom safe area under it
* [data-navigation-bar-items] equal widths; centred from a 600px-wide bar
* [data-navigation-bar-item] data-active
* [data-navigation-pill] icon and label; the indicator itself from 600px
* [data-navigation-indicator] the 56×32 indicator around the icon below 600px
* [data-navigation-label]
*
* [data-navigation-rail="collapsed|expanded|collapsible|modal|adaptive"] data-open
* [data-navigation-rail-scrim] modal and adaptive rails
* [data-navigation-rail-panel] the <nav>
* [data-navigation-rail-header] menu button, brand, FAB never scrolls
* [data-navigation-rail-destinations] scrolls when the window is too short
* [data-navigation-rail-section] a heading (expanded only) and its items
* [data-navigation-rail-item] data-active; the full-width pill when expanded
* [data-navigation-indicator] the 56×32 indicator when collapsed
* [data-navigation-label]
* [data-navigation-rail-footer] never scrolls
*
* `rail-collapsed:` matches a rail, and everything in it, while it is drawn collapsed whatever
* made it so: its mode, the visitor's choice on <html data-rail> (set before the first paint by
* <x-theme-script>), or a window under `lg` for the adaptive rail. A rail item is written once
* and takes both shapes from it; so can anything an application puts in a rail
* (`<span class="rail-collapsed:hidden">`).
*/
@custom-variant rail-collapsed {
&:where([data-navigation-rail='collapsed'], [data-navigation-rail='collapsed'] *) {
@slot;
}
&:where([data-rail='collapsed'] [data-navigation-rail='collapsible'], [data-rail='collapsed'] [data-navigation-rail='collapsible'] *) {
@slot;
}
&:where([data-navigation-rail='modal']:not([data-open]), [data-navigation-rail='modal']:not([data-open]) *) {
@slot;
}
@media (width < 64rem) {
&:where([data-navigation-rail='adaptive']:not([data-open]), [data-navigation-rail='adaptive']:not([data-open]) *) {
@slot;
}
}
@media (width >= 64rem) {
&:where([data-rail='collapsed'] [data-navigation-rail='adaptive'], [data-rail='collapsed'] [data-navigation-rail='adaptive'] *) {
@slot;
}
}
}
@layer components {
/* ---------------------------------------------------------------- the navigation bar */
[data-navigation-bar] {
container-type: inline-size;
padding-inline: var(--material-safe-left, env(safe-area-inset-left)) var(--material-safe-right, env(safe-area-inset-right));
padding-bottom: var(--material-safe-bottom, env(safe-area-inset-bottom));
background-color: var(--md-sys-color-surface-container);
color: var(--md-sys-color-on-surface-variant);
}
[data-navigation-bar-items] {
display: flex;
min-height: 4rem;
margin-inline: auto;
}
[data-navigation-bar-item] {
--navigation-layer: 0;
position: relative;
display: flex;
flex: 1 1 0;
min-width: 0;
align-items: center;
justify-content: center;
padding-block: 0.375rem;
color: var(--md-sys-color-on-surface-variant);
text-decoration: none;
cursor: pointer;
outline: none;
-webkit-tap-highlight-color: transparent;
}
[data-navigation-bar-item] [data-navigation-pill] {
display: flex;
min-width: 0;
max-width: 100%;
flex-direction: column;
align-items: center;
gap: 0.25rem;
border-radius: var(--md-sys-shape-corner-full);
font: var(--md-sys-typescale-label-md);
letter-spacing: var(--md-sys-typescale-label-md-tracking);
}
[data-navigation-bar-item] [data-navigation-label] {
max-width: 100%;
padding-inline: 0.25rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-navigation-bar-item][data-active] {
color: var(--md-sys-color-secondary);
}
/* From 600dp (M3's medium window), icon and label side by side in a 40px indicator, and the
items centred with the padding ShortNavigationBar's Centered arrangement computes. */
@container (width >= 37.5rem) {
[data-navigation-bar-items] {
width: calc(10% * (var(--navigation-bar-count, 7) + 3));
min-width: fit-content;
max-width: 100%;
}
[data-navigation-bar-items]:has(> :last-child:nth-child(1)) { --navigation-bar-count: 1; }
[data-navigation-bar-items]:has(> :last-child:nth-child(2)) { --navigation-bar-count: 2; }
[data-navigation-bar-items]:has(> :last-child:nth-child(3)) { --navigation-bar-count: 3; }
[data-navigation-bar-items]:has(> :last-child:nth-child(4)) { --navigation-bar-count: 4; }
[data-navigation-bar-items]:has(> :last-child:nth-child(5)) { --navigation-bar-count: 5; }
[data-navigation-bar-items]:has(> :last-child:nth-child(6)) { --navigation-bar-count: 6; }
[data-navigation-bar-item] {
min-width: max-content;
}
[data-navigation-bar-item] [data-navigation-pill] {
position: relative;
isolation: isolate;
flex-direction: row;
height: 2.5rem;
padding-inline: 1rem;
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
}
[data-navigation-bar-item] [data-navigation-label] {
padding-inline: 0;
}
[data-navigation-bar-item][data-active] {
color: var(--md-sys-color-on-secondary-container);
}
[data-navigation-bar-item][data-active] [data-navigation-pill] {
background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
}
}
/* ---------------------------------------------------------------- the navigation rail */
[data-navigation-rail] {
--navigation-rail-expanded-width: clamp(13.75rem, var(--navigation-rail-width, 16rem), 22.5rem);
position: relative;
flex-shrink: 0;
width: var(--navigation-rail-expanded-width);
transition: width var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default);
@variant rail-collapsed {
width: 6rem;
}
}
/* A modal rail keeps its collapsed width in the layout while it is open over it, as Compose's
ModalWideNavigationRail does; the adaptive rail does below lg, and takes no room below sm. */
[data-navigation-rail='modal'] {
width: 6rem;
}
@media (width < 64rem) {
[data-navigation-rail='adaptive'] {
width: 6rem;
}
}
@media (width < 40rem) {
[data-navigation-rail='adaptive'] {
width: 0;
}
}
[data-navigation-rail-panel] {
position: sticky;
top: 0;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
max-height: 100dvh;
/* Clip, not hide: no scroll container, so the destinations below can still scroll and
nothing sticky breaks. What only an expanded rail draws a label, the brand is drawn
at once when the rail expands, while the width is still growing; the clip keeps it
from spilling over the page for those frames. */
overflow-x: clip;
padding-bottom: var(--material-safe-bottom, env(safe-area-inset-bottom));
background-color: var(--md-sys-color-surface);
color: var(--md-sys-color-on-surface);
transition:
width var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast),
background-color var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default);
}
/* Open: expanded over a scrim, in surface-container with a large corner at its inner edge. */
[data-navigation-rail][data-open] > [data-navigation-rail-panel] {
position: fixed;
inset-block: 0;
inset-inline-start: 0;
z-index: 50;
width: var(--navigation-rail-expanded-width);
max-width: calc(100vw - 3.5rem);
height: 100dvh;
max-height: none;
border-start-end-radius: var(--md-sys-shape-corner-lg);
border-end-end-radius: var(--md-sys-shape-corner-lg);
background-color: var(--md-sys-color-surface-container);
box-shadow: var(--md-sys-elevation-2);
}
/* Below sm there is no collapsed rail to grow out of: the open rail slides in from the edge,
on emphasized decelerate rather than a spring, which would overshoot and open a gap. */
@media (width < 40rem) {
[data-navigation-rail='adaptive'] > [data-navigation-rail-panel] {
position: fixed;
inset-block: 0;
inset-inline-start: 0;
z-index: 50;
display: none;
width: var(--navigation-rail-expanded-width);
max-width: calc(100vw - 3.5rem);
height: 100dvh;
max-height: none;
border-start-end-radius: var(--md-sys-shape-corner-lg);
border-end-end-radius: var(--md-sys-shape-corner-lg);
background-color: var(--md-sys-color-surface-container);
box-shadow: var(--md-sys-elevation-2);
translate: -100% 0;
transition:
translate var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-easing-emphasized-accelerate),
display var(--md-sys-motion-effects-default-duration) allow-discrete;
&:dir(rtl) {
translate: 100% 0;
}
}
[data-navigation-rail='adaptive'][data-open] > [data-navigation-rail-panel] {
display: flex;
translate: 0 0;
transition:
translate var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-easing-emphasized-decelerate),
display var(--md-sys-motion-spatial-default-duration) allow-discrete;
@starting-style {
translate: -100% 0;
}
&:dir(rtl) {
translate: 0 0;
@starting-style {
translate: 100% 0;
}
}
}
}
[data-navigation-rail-scrim] {
position: fixed;
inset: 0;
z-index: 40;
display: none;
background-color: color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent);
opacity: 0;
transition:
opacity var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default),
display var(--md-sys-motion-effects-default-duration) allow-discrete;
}
[data-navigation-rail][data-open] > [data-navigation-rail-scrim] {
display: block;
opacity: 1;
@starting-style {
opacity: 0;
}
}
/* 44px above the header (TopSpace), 40px under it (HeaderSpaceMinimum) 32 here and 8 as
the destinations' own padding, which keeps the first item's focus ring inside the scroller. */
[data-navigation-rail-header] {
display: flex;
flex-shrink: 0;
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
padding-top: calc(var(--material-safe-top, env(safe-area-inset-top)) + 2.75rem);
padding-bottom: 2rem;
}
[data-navigation-rail-panel] > [data-navigation-rail-destinations]:first-child {
padding-top: calc(var(--material-safe-top, env(safe-area-inset-top)) + 2.75rem);
}
[data-navigation-rail-destinations] {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding-block: 0.5rem;
scrollbar-width: thin;
}
[data-navigation-rail-destinations],
[data-navigation-rail-section],
[data-navigation-rail-footer] {
display: flex;
flex-direction: column;
@variant rail-collapsed {
gap: 0.25rem;
}
}
[data-navigation-rail-footer] {
flex-shrink: 0;
padding-block: 0.5rem 1rem;
}
[data-navigation-rail-heading] {
display: flex;
align-items: center;
min-height: 3rem;
padding-inline: 2.25rem 1.25rem;
overflow: hidden;
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-title-sm);
letter-spacing: var(--md-sys-typescale-title-sm-tracking);
white-space: nowrap;
text-overflow: ellipsis;
@variant rail-collapsed {
display: none;
}
}
[data-navigation-rail-section]:not(:first-child) {
@variant rail-collapsed {
margin-top: 0.75rem;
}
}
/* Expanded: a 56px full-width pill, icon and label 8px apart, label-large. */
[data-navigation-rail-item] {
--navigation-layer: 0;
position: relative;
isolation: isolate;
display: flex;
flex-shrink: 0;
align-items: center;
gap: 0.5rem;
height: 3.5rem;
margin-inline: 1.25rem;
padding-inline: 1rem;
border-radius: var(--md-sys-shape-corner-full);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
text-align: start;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
outline: none;
-webkit-tap-highlight-color: transparent;
/* Collapsed: the icon in its 56×32 indicator over a label-medium label, 64px tall. */
@variant rail-collapsed {
flex-direction: column;
justify-content: center;
gap: 0.25rem;
height: auto;
min-height: 4rem;
margin-inline: 0;
padding: 0.375rem 0.25rem;
border-radius: 0;
font: var(--md-sys-typescale-label-md);
letter-spacing: var(--md-sys-typescale-label-md-tracking);
white-space: normal;
}
}
[data-navigation-rail-item] [data-navigation-label] {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
@variant rail-collapsed {
flex: none;
display: -webkit-box;
max-width: 100%;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
text-align: center;
overflow-wrap: anywhere;
}
}
[data-navigation-rail-item][data-active] {
background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
color: var(--md-sys-color-on-secondary-container);
@variant rail-collapsed {
background-image: none;
color: var(--md-sys-color-secondary);
}
}
[data-navigation-rail-item] [data-navigation-indicator] {
position: relative;
isolation: isolate;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: var(--md-sys-shape-corner-full);
@variant rail-collapsed {
width: 3.5rem;
height: 2rem;
}
}
/* ---------------------------------------------------------------- both: indicator, states */
[data-navigation-bar-item] [data-navigation-indicator] {
position: relative;
isolation: isolate;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 3.5rem;
height: 2rem;
border-radius: var(--md-sys-shape-corner-full);
}
[data-navigation-bar-item][data-active] [data-navigation-indicator] {
color: var(--md-sys-color-on-secondary-container);
}
@container (width < 37.5rem) {
[data-navigation-bar-item][data-active] [data-navigation-indicator] {
background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
}
}
[data-navigation-rail-item][data-active] [data-navigation-indicator] {
@variant rail-collapsed {
background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
color: var(--md-sys-color-on-secondary-container);
}
}
/* The active indicator is a secondary-container fill painted as a background image, so it can
grow out of its centre without stretching the icon: when a page arrives through
wire:navigate, resources/js/navigation.js starts it at zero width for a moment, and it
springs open on the default spatial spring, as Compose's indicator does when the selection
changes (NavigationItem.kt). A full page load draws it at once; reduced motion zeroes it. */
[data-navigation-bar-item] :is([data-navigation-indicator], [data-navigation-pill]),
[data-navigation-rail-item],
[data-navigation-rail-item] [data-navigation-indicator] {
background-position: center;
background-repeat: no-repeat;
background-size: 100% 100%;
transition: background-size var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default);
}
/* The state layer covers only the indicator: the pill when the indicator holds the label
too, the 56×32 shape when it holds the icon alone. Hover only where a pointer can hover. */
@media (hover: hover) {
:is([data-navigation-bar-item], [data-navigation-rail-item]):hover {
--navigation-layer: 0.08;
}
}
:is([data-navigation-bar-item], [data-navigation-rail-item]):is(:focus-visible, :active) {
--navigation-layer: 0.1;
}
[data-navigation-bar-item] [data-navigation-indicator]::before,
[data-navigation-bar-item] [data-navigation-pill]::before,
[data-navigation-rail-item] [data-navigation-indicator]::before,
[data-navigation-rail-item]::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
background-color: var(--md-sys-color-on-surface);
opacity: var(--navigation-layer);
pointer-events: none;
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
@container (width < 37.5rem) {
[data-navigation-bar-item] [data-navigation-pill]::before {
display: none;
}
}
@container (width >= 37.5rem) {
[data-navigation-bar-item] [data-navigation-indicator]::before {
display: none;
}
}
/* The rail item's layer is on the item while expanded and on its indicator while collapsed. A
variant cannot follow a pseudo-element in a selector, so the item hands the layer on through
variables; each rule below outweighs its line in the list above. */
[data-navigation-rail-item] {
--navigation-item-layer: var(--navigation-layer);
--navigation-indicator-layer: 0;
@variant rail-collapsed {
--navigation-item-layer: 0;
--navigation-indicator-layer: var(--navigation-layer);
}
}
[data-navigation-rail-item][data-navigation-rail-item]::before {
background-color: var(--md-sys-color-on-secondary-container);
opacity: var(--navigation-item-layer);
}
[data-navigation-rail-item][data-navigation-rail-item] [data-navigation-indicator]::before {
background-color: var(--md-sys-color-on-secondary-container);
opacity: var(--navigation-indicator-layer);
}
/* M3's focus indicator, 3px of secondary 2px out, around the same shape. */
@container (width < 37.5rem) {
[data-navigation-bar-item]:focus-visible [data-navigation-indicator] {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
}
@container (width >= 37.5rem) {
[data-navigation-bar-item]:focus-visible [data-navigation-pill] {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
}
[data-navigation-rail-item]:focus-visible {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
@variant rail-collapsed {
outline: none;
}
}
[data-navigation-rail-item]:focus-visible [data-navigation-indicator] {
@variant rail-collapsed {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
}
}
+3 -3
View File
@@ -165,8 +165,8 @@
[data-search][data-full-screen] [data-search-bar] {
position: fixed;
inset: 0 0 auto;
height: calc(4.5rem + env(safe-area-inset-top));
padding-top: env(safe-area-inset-top);
height: calc(4.5rem + var(--material-safe-top, env(safe-area-inset-top)));
padding-top: var(--material-safe-top, env(safe-area-inset-top));
padding-inline: 0.25rem;
border-radius: 0;
}
@@ -175,7 +175,7 @@
position: fixed;
inset: 0;
max-height: none;
padding-top: calc(4.5rem + env(safe-area-inset-top));
padding-top: calc(4.5rem + var(--material-safe-top, env(safe-area-inset-top)));
border-radius: 0;
box-shadow: none;
}
+460
View File
@@ -0,0 +1,460 @@
/*
* M3's time picker (resources/views/components/timepicker.blade.php, resources/js/timepicker.js):
* the dial, the input variant, and the dialog around them.
*
* Sizes and roles are androidx Compose Material 3's (TimePickerTokens, TimeInputTokens, TimePicker.kt
* and TimePickerDialog.kt at commit 27cf9a7d5788aa0f5f2d8b6699ce279560daf326, Apache-2.0):
*
* [data-timepicker-surface] surface-container-high, extra-large corner, elevation 3; 24px in
* [data-timepicker-title] label-medium, on-surface-variant, 20px above the content
* [data-timepicker-picker] the dial variant
* [data-timepicker-display] the time selector: hour and minute boxes 96×80 (display-large,
* small corner; primary-container when selected, otherwise
* surface-container-highest), a 24px separator, and the period
* selector 52×80 beside them, 4px away
* [data-timepicker-dial] 256px, surface-container-highest; numbers in body-large at radius
* 101 (the 24-hour inner ring at 69), a primary selector a 2px line,
* an 8px centre and a 48px handle with the number under the handle
* in on-primary; 36px below the display, 24px above the actions
* [data-timepicker-inputs] the input variant: fields 96×72 in display-medium, "Hour" and
* "Minute" (or the error) below in body-small, the period 52×72
* [data-timepicker-actions] a 48px row: the mode toggle, then Cancel and OK
*
* The period selector is Compose's current one (ComposeMaterial3Flags.isUpdatedTimepickerToggleEnabled,
* on by default): two toggle buttons 4px apart, round and surface-container-lowest when off, a 12px
* corner and primary-container with a bold label when on, not the outlined pair of the tokens.
*
* In a landscape window the dial variant lies on its side, as Compose's HorizontalTimePicker does
* whenever the screen is wider than it is tall: the display with the period selector (216×38, 16px
* under it) on the start side, the dial 36px after it, the actions under both; and the dial shrinks
* to 238 or 200px when the window is short (ClockFaceSizeModifier). The input variant always stands.
*
* The selector's angle is a registered property, so a single transition turns the line, carries the
* handle round and moves the clip that inks the number under it, on the default spatial spring. The
* numbers cross-fade between hours and minutes on the default effects spring.
*/
@property --timepicker-angle {
syntax: '<angle>';
inherits: true;
initial-value: 0deg;
}
@layer components {
[data-timepicker-field] .field-control {
cursor: pointer;
caret-color: transparent;
}
[data-timepicker-dialog] {
max-width: calc(100vw - 2rem);
max-height: calc(100dvh - 2rem);
}
[data-timepicker-surface] {
display: grid;
grid-template-columns: max-content;
justify-content: center;
padding: 1.5rem;
border-radius: var(--md-sys-shape-corner-xl);
background-color: var(--md-sys-color-surface-container-high);
box-shadow: var(--md-sys-elevation-3);
color: var(--md-sys-color-on-surface);
}
[data-timepicker-title] {
padding-bottom: 1.25rem;
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-label-md);
letter-spacing: var(--md-sys-typescale-label-md-tracking);
}
[data-timepicker-picker] {
display: flex;
flex-direction: column;
align-items: center;
}
/* The mode is an attribute rather than x-show, which shows only on the next frame: focus moves
into the variant that was just switched to within the same tick. */
[data-timepicker-surface]:not([data-mode="input"]) :is([data-timepicker-typing], [data-timepicker-when="input"]),
[data-timepicker-surface][data-mode="input"] :is([data-timepicker-picker], [data-timepicker-when="dial"]) {
display: none;
}
[data-timepicker-when] {
display: contents;
}
/* ---- The time selector and the period selector ------------------------------------------ */
[data-timepicker-display] {
display: flex;
margin-bottom: 2.25rem;
}
[data-timepicker-numbers],
[data-timepicker-inputs] {
display: flex;
align-items: flex-start;
direction: ltr;
}
[data-timepicker-box] {
display: grid;
place-items: center;
width: 6rem;
height: 5rem;
border-radius: var(--md-sys-shape-corner-sm);
background-color: var(--md-sys-color-surface-container-highest);
color: var(--md-sys-color-on-surface);
font: var(--md-sys-typescale-display-lg);
letter-spacing: var(--md-sys-typescale-display-lg-tracking);
font-variant-numeric: tabular-nums;
cursor: pointer;
transition-property: background-color, color;
transition-duration: var(--md-sys-motion-effects-fast-duration);
transition-timing-function: var(--md-sys-motion-effects-fast);
}
[data-timepicker-box][aria-pressed="true"] {
background-color: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
}
[data-timepicker-separator] {
display: grid;
place-items: center;
width: 1.5rem;
height: 5rem;
color: var(--md-sys-color-on-surface);
font: var(--md-sys-typescale-display-lg);
translate: 0 -0.25rem;
user-select: none;
}
[data-timepicker-period] {
display: flex;
flex-direction: column;
gap: 0.25rem;
width: 3.25rem;
height: 5rem;
margin-inline-start: 0.25rem;
}
[data-timepicker-period] > button {
flex: 1 1 0%;
min-width: 0;
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-surface-container-lowest);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-title-md);
letter-spacing: var(--md-sys-typescale-title-md-tracking);
cursor: pointer;
transition-property: border-radius, background-color, color;
transition-duration: var(--md-sys-motion-spatial-fast-duration);
transition-timing-function: var(--md-sys-motion-spatial-fast);
}
[data-timepicker-period] > button:active,
[data-timepicker-period] > button[aria-pressed="true"] {
border-radius: 0.75rem;
}
[data-timepicker-period] > button[aria-pressed="true"] {
background-color: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
font-weight: var(--md-ref-typeface-weight-bold);
}
[data-timepicker-period] > button:disabled {
cursor: default;
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent);
}
/* ---- The dial ----------------------------------------------------------------------------- */
[data-timepicker-dial] {
--dial: 16rem;
--unit: calc(var(--dial) / 256);
--outer: calc(101 * var(--unit));
--inner: calc(69 * var(--unit));
--handle: calc(48 * var(--unit));
--reach: var(--outer);
position: relative;
flex: none;
width: var(--dial);
height: var(--dial);
margin-bottom: 1.5rem;
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-surface-container-highest);
color: var(--md-sys-color-on-surface);
font: var(--md-sys-typescale-body-lg);
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
cursor: pointer;
touch-action: none;
user-select: none;
-webkit-user-select: none;
-webkit-tap-highlight-color: transparent;
outline: none;
direction: ltr;
transition: --timepicker-angle var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default);
}
[data-timepicker-dial]:focus-visible {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
/* A drag keeps the handle under the pointer; the spring only settles it on release. */
[data-timepicker-dial][data-dragging] {
transition: none;
}
[data-timepicker-dial][data-inner] {
--reach: var(--inner);
}
[data-timepicker-labels],
[data-timepicker-ink],
[data-timepicker-set] {
position: absolute;
inset: 0;
}
[data-timepicker-set] {
transition-property: opacity, visibility;
transition-duration: var(--md-sys-motion-effects-default-duration);
transition-timing-function: var(--md-sys-motion-effects-default);
}
[data-timepicker-dial]:not([data-cycle="24"]) [data-timepicker-set="hour24"],
[data-timepicker-dial][data-cycle="24"] [data-timepicker-set="hour12"] {
display: none;
}
[data-timepicker-dial][data-view="minute"] :is([data-timepicker-set="hour12"], [data-timepicker-set="hour24"]),
[data-timepicker-dial]:not([data-view="minute"]) [data-timepicker-set="minute"] {
opacity: 0;
visibility: hidden;
}
[data-timepicker-set] > span {
--ring: var(--outer);
position: absolute;
left: calc(50% + var(--x) * var(--ring));
top: calc(50% + var(--y) * var(--ring));
display: grid;
place-items: center;
width: 3rem;
height: 3rem;
translate: -50% -50%;
border-radius: var(--md-sys-shape-corner-full);
font-variant-numeric: tabular-nums;
}
[data-timepicker-set] > span[data-inner] {
--ring: var(--inner);
}
[data-timepicker-set] > span[data-disabled] {
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
}
/* The selector: the line from the centre to the handle's edge, the centre dot, the handle. */
[data-timepicker-selector] {
position: absolute;
inset: 0;
pointer-events: none;
}
[data-timepicker-track] {
position: absolute;
left: calc(50% - 1px);
bottom: 50%;
width: 2px;
height: calc(var(--reach) - var(--handle) / 2);
background-color: var(--md-sys-color-primary);
transform-origin: 50% 100%;
rotate: var(--timepicker-angle);
}
[data-timepicker-centre],
[data-timepicker-handle] {
position: absolute;
translate: -50% -50%;
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-primary);
}
[data-timepicker-centre] {
left: 50%;
top: 50%;
width: 0.5rem;
height: 0.5rem;
}
[data-timepicker-handle] {
left: calc(50% + sin(var(--timepicker-angle)) * var(--reach));
top: calc(50% - cos(var(--timepicker-angle)) * var(--reach));
width: var(--handle);
height: var(--handle);
}
/* The numbers again, in on-primary, cut to the handle: what Compose draws with BlendMode.DstOver. */
[data-timepicker-ink] {
color: var(--md-sys-color-on-primary);
pointer-events: none;
clip-path: circle(calc(var(--handle) / 2) at calc(50% + sin(var(--timepicker-angle)) * var(--reach)) calc(50% - cos(var(--timepicker-angle)) * var(--reach)));
}
[data-timepicker-ink] [data-timepicker-set] > span[data-disabled] {
color: inherit;
}
/* ---- The input variant -------------------------------------------------------------------- */
[data-timepicker-inputs] [data-timepicker-separator] {
height: 4.5rem;
}
[data-timepicker-inputs] [data-timepicker-period] {
height: 4.5rem;
}
[data-timepicker-column] {
width: 6rem;
}
[data-timepicker-input] {
display: block;
width: 6rem;
height: 4.5rem;
padding: 0;
border: 0;
border-radius: var(--md-sys-shape-corner-sm);
outline: none;
background-color: var(--md-sys-color-surface-container-highest);
color: var(--md-sys-color-on-surface);
caret-color: var(--md-sys-color-primary);
font: var(--md-sys-typescale-display-md);
letter-spacing: var(--md-sys-typescale-display-md-tracking);
font-variant-numeric: tabular-nums;
text-align: center;
transition-property: background-color, color, box-shadow;
transition-duration: var(--md-sys-motion-effects-fast-duration);
transition-timing-function: var(--md-sys-motion-effects-fast);
}
[data-timepicker-input]:focus {
background-color: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
box-shadow: inset 0 0 0 2px var(--md-sys-color-primary);
}
[data-timepicker-input][aria-invalid="true"] {
background-color: var(--md-sys-color-error-container);
color: var(--md-sys-color-error);
caret-color: var(--md-sys-color-error);
box-shadow: inset 0 0 0 1px var(--md-sys-color-error);
}
[data-timepicker-input][aria-invalid="true"]:focus {
box-shadow: inset 0 0 0 2px var(--md-sys-color-error);
}
/* SupportingText: two lines' room, 7px under the field; the error in its place. */
[data-timepicker-support] {
min-height: 2rem;
padding-top: 0.4375rem;
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-sm);
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
}
[data-timepicker-support][data-error],
[data-timepicker-range-error] {
color: var(--md-sys-color-error);
}
[data-timepicker-range-error] {
max-width: 17rem;
padding-bottom: 0.5rem;
font: var(--md-sys-typescale-body-sm);
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
}
[data-timepicker-actions] {
display: flex;
align-items: center;
gap: 0.5rem;
min-height: 3rem;
}
[data-timepicker-actions] > [data-timepicker-spacer] {
flex: 1 1 0%;
}
/* ---- Landscape ---------------------------------------------------------------------------- */
@media (orientation: landscape) and (min-width: 37rem) {
[data-timepicker-surface][data-mode="dial"] {
grid-template-columns: 13.5rem auto;
grid-template-areas:
"display dial"
"actions actions";
column-gap: 2.25rem;
padding: 1rem 1.5rem 0.5rem;
}
[data-timepicker-surface][data-mode="dial"] [data-timepicker-title] {
grid-area: display;
align-self: start;
margin-top: 0.5rem;
padding: 0;
}
[data-timepicker-surface][data-mode="dial"] [data-timepicker-picker] {
display: contents;
}
[data-timepicker-surface][data-mode="dial"] [data-timepicker-display] {
grid-area: display;
flex-direction: column;
align-self: center;
margin: 0;
}
[data-timepicker-surface][data-mode="dial"] [data-timepicker-display] [data-timepicker-period] {
flex-direction: row;
width: 13.5rem;
height: 2.375rem;
margin: 1rem 0 0;
}
[data-timepicker-surface][data-mode="dial"] [data-timepicker-dial] {
grid-area: dial;
margin: 0;
}
[data-timepicker-surface][data-mode="dial"] [data-timepicker-actions] {
grid-area: actions;
margin-top: 0.25rem;
}
}
@media (orientation: landscape) and (min-width: 37rem) and (max-height: 22.75rem) {
[data-timepicker-surface][data-mode="dial"] [data-timepicker-dial] {
--dial: 14.875rem;
}
}
@media (orientation: landscape) and (min-width: 37rem) and (max-height: 21.625rem) {
[data-timepicker-surface][data-mode="dial"] [data-timepicker-dial] {
--dial: 12.5rem;
}
}
}
+4 -4
View File
@@ -19,11 +19,11 @@
[data-toolbar][data-variant="docked"] {
width: 100%;
min-height: calc(4rem + env(safe-area-inset-bottom));
min-height: calc(4rem + var(--material-safe-bottom, 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);
padding-bottom: var(--material-safe-bottom, env(safe-area-inset-bottom));
background-color: var(--md-sys-color-surface-container);
color: var(--md-sys-color-on-surface-variant);
}
@@ -74,7 +74,7 @@
/* 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));
bottom: calc(1rem + var(--material-safe-bottom, env(safe-area-inset-bottom)));
left: 50%;
z-index: 30;
translate: -50% 0;
@@ -83,7 +83,7 @@
[data-toolbar-place="end"] {
position: fixed;
top: 50%;
inset-inline-end: calc(1rem + env(safe-area-inset-right));
inset-inline-end: calc(1rem + var(--material-safe-right, env(safe-area-inset-right)));
z-index: 30;
translate: 0 -50%;
}
+3
View File
@@ -26,8 +26,11 @@
@import './components/menu.css';
@import './components/selection.css';
@import './components/search.css';
@import './components/datepicker.css';
@import './components/timepicker.css';
@import './components/tabs.css';
@import './components/app-bar.css';
@import './components/navigation.css';
@import './components/toolbar.css';
@import './components/table.css';
+4
View File
@@ -11,6 +11,10 @@ document.addEventListener('alpine:init', () => {
collapsed: false,
height: null,
frame: null,
// Set in init(). Declared here, or Alpine writes them to the outermost x-data scope,
// where a second app bar in the same page scope would take the first one's observer.
schedule: null,
resizes: null,
init() {
this.measure = this.measure.bind(this)
+38 -1
View File
@@ -69,6 +69,9 @@ const MEDIUM_ITEM_FLEX_PERCENTAGE = 0.1
/** A programmatic scroll still counts as where the carousel is going for this long. */
const TARGET_MS = 700
// How long the row must go without a scroll event to count as having come to rest.
const SETTLE_MS = 150
const ITEM = '[data-material-carousel-item]'
const INTERACTIVE = 'a[href], button, input, select, textarea, summary, [contenteditable], [tabindex]:not([tabindex="-1"])'
@@ -805,6 +808,7 @@ document.addEventListener('alpine:init', () => {
frame: null,
target: null,
targetAt: 0,
settle: null,
listeners: [],
mutations: null,
resizes: null,
@@ -817,7 +821,12 @@ document.addEventListener('alpine:init', () => {
state.reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)')
this.listen(scroller, 'scroll', () => this.schedule(), { passive: true })
this.listen(scroller, 'scroll', () => this.scrolled(), { passive: true })
// A scroll the person makes themselves is theirs to end wherever it ends.
for (const type of ['pointerdown', 'wheel', 'touchstart']) {
this.listen(scroller, type, () => (state.target = null), { passive: true })
}
this.listen(scroller, 'keydown', (event) => this.navigate(event))
this.listen(scroller, 'focusin', (event) => this.reveal(event))
this.listen(scroller, 'click', (event) => this.open(event))
@@ -913,6 +922,33 @@ document.addEventListener('alpine:init', () => {
state.frame ??= requestAnimationFrame(() => this.render())
},
/**
* Each scroll frame, and once the row comes to rest: a scroll the buttons or keys started
* must end on its item. WebKit on Linux can re-snap a smooth scroll to the item it left
* when the masks change the layout under it, so an arrival somewhere else is sent on
* again, once, at once.
*/
scrolled() {
this.schedule()
clearTimeout(state.settle)
state.settle = setTimeout(() => {
const target = state.target
if (target === null || performance.now() - state.targetAt > TARGET_MS * 3) {
return
}
state.target = null
const left = state.snaps[target]
if (left !== undefined && Math.abs(this.scrollOffset() - left) > 1) {
this.$refs.scroller.scrollTo({ left: state.rtl ? -left : left, behavior: 'instant' })
}
}, SETTLE_MS)
},
/** Carousel.kt's carouselItem layer block, for every item. */
render() {
cancelAnimationFrame(state.frame)
@@ -1076,6 +1112,7 @@ document.addEventListener('alpine:init', () => {
destroy() {
cancelAnimationFrame(state.frame)
clearTimeout(state.settle)
state.listeners.forEach((remove) => remove())
state.resizes?.disconnect()
state.mutations?.disconnect()
+866
View File
@@ -0,0 +1,866 @@
/**
* `materialDatepicker`: the behaviour of `<x-datepicker>` M3's docked, modal and modal-input
* date pickers on one `<dialog>`.
*
* The dialog is shown two ways. Docked, it is a `popover="manual"` placed under the field by CSS
* anchor positioning; modal (the `modal` and `input` modes, and a docked picker on a compact
* window) it is opened with `showModal()`. Everything a person sees in it is drawn by Alpine from
* the state below, and the dialog is `wire:ignore`, so a Livewire render never touches it while
* it is open.
*
* Dates are ISO strings (`2026-09-13`) throughout, computed in UTC so no time zone or daylight
* saving change can move a day; only "today" is read in the browser's own zone. Month and weekday
* names, the week's first day and the typed format come from `Intl` for the locale the server
* passes (the application's), unless the component names the first day (`weekStart`, 0 for Sunday
* to 6) or the format (`format`, such as `dd.MM.yyyy`); everything that reads `firstDay` and
* `format` below then follows those.
*
* The keyboard is WAI-ARIA's date picker dialog: arrows move a day or a week, Home and End go to
* the start and end of the week, PageUp and PageDown a month (with Shift a year), Space selects,
* Enter selects and confirms, Escape closes and hands focus back to the field. Focus never leaves
* the `min``max` span, so a disabled day cannot be chosen from the keyboard either.
*/
const COMPACT = '(max-width: 39.99rem)'
// CLDR's first day of the week by region, for an engine without `Intl.Locale#getWeekInfo`.
const WEEK_STARTS_SUNDAY = 'AG AS BD BR BS BT BW BZ CA CN CO DM DO ET GT GU HK HN ID IL IN JM JP KE KH KR LA MH MM MO MT MX MZ NI NP PA PE PH PK PR PT PY SA SG SV TH TT TW UM US VE VI WS YE ZA ZW'.split(' ')
const WEEK_STARTS_SATURDAY = 'AE AF BH DJ DZ EG IQ IR JO KW LY OM QA SD SY'.split(' ')
const YEARS_FROM = 1900
const YEARS_TO = 2100
function pad(number, length = 2) {
return String(number).padStart(length, '0')
}
/** A UTC timestamp for a calendar day; `setUTCFullYear` so years below 100 are not read as 19xx. */
function utc(year, month, day) {
const date = new Date(0)
date.setUTCFullYear(year, month - 1, day)
return date
}
function iso(date) {
return `${pad(date.getUTCFullYear(), 4)}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`
}
/** The ISO string, or null when it is not a real calendar day. */
function valid(value) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(typeof value === 'string' ? value : '')
if (!match) {
return null
}
const date = utc(+match[1], +match[2], +match[3])
return date.getUTCMonth() + 1 === +match[2] && date.getUTCDate() === +match[3] ? match[0] : null
}
function parts(value) {
return value.split('-').map(Number)
}
function addDays(value, days) {
const [year, month, day] = parts(value)
return iso(utc(year, month, day + days))
}
/** The same day in another month, or that month's last day when it is shorter. */
function addMonths(value, months) {
const [year, month, day] = parts(value)
const first = utc(year, month + months, 1)
const length = utc(first.getUTCFullYear(), first.getUTCMonth() + 2, 0).getUTCDate()
return iso(utc(first.getUTCFullYear(), first.getUTCMonth() + 1, Math.min(day, length)))
}
function localToday() {
const now = new Date()
return `${pad(now.getFullYear(), 4)}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
}
/** 0 for Sunday … 6 for Saturday: `weekStart` when it is one of those, otherwise the locale's. */
function firstDayOfWeek(locale, weekStart = null) {
if (Number.isInteger(weekStart) && weekStart >= 0 && weekStart <= 6) {
return weekStart
}
try {
const tag = new Intl.Locale(locale)
const info = typeof tag.getWeekInfo === 'function' ? tag.getWeekInfo() : tag.weekInfo
if (info?.firstDay) {
return info.firstDay % 7
}
const region = tag.maximize().region
if (WEEK_STARTS_SUNDAY.includes(region)) {
return 0
}
if (WEEK_STARTS_SATURDAY.includes(region)) {
return 6
}
} catch {
// An unknown tag falls through to Monday, ISO 8601's first day.
}
return 1
}
/**
* The typed format: the locale's short numeric date, reduced to `dd`, `MM` and `yyyy` and one
* delimiter androidx's `datePatternAsInputFormat`, fed from `formatToParts` because `Intl` has
* no pattern to give. `de` gives `dd.MM.yyyy`, `en-US` `MM/dd/yyyy`, `ja` `yyyy/MM/dd`. A `chosen`
* pattern of the same shape (each unit once, one delimiter) replaces the locale's.
*/
function inputFormat(locale, chosen = null) {
const units = /^(dd|MM|yyyy)([/\-.])(dd|MM|yyyy)\2(dd|MM|yyyy)$/.exec(typeof chosen === 'string' ? chosen : '')
if (units && new Set([units[1], units[3], units[4]]).size === 3) {
return formatOf(chosen)
}
let pattern = ''
try {
pattern = new Intl.DateTimeFormat(locale, { year: 'numeric', month: '2-digit', day: '2-digit', timeZone: 'UTC' })
.formatToParts(utc(2026, 11, 22))
.map((part) => ({ year: 'y', month: 'M', day: 'd', literal: part.value })[part.type] ?? '')
.join('')
.replace(/[^dMy/\-.]/g, '')
.replace(/d{1,2}/g, 'dd')
.replace(/M{1,2}/g, 'MM')
.replace(/y{1,4}/g, 'yyyy')
.replace('My', 'M/y')
.replace(/\.$/, '')
} catch {
pattern = ''
}
return formatOf(/^(?=.*dd)(?=.*MM)(?=.*yyyy)[dMy]+[/\-.][dMy]+[/\-.][dMy]+$/.test(pattern) ? pattern : 'yyyy-MM-dd')
}
/** A pattern, the placeholder it shows (`DD.MM.YYYY`) and the order its units are typed in (`dMy`). */
function formatOf(pattern) {
return {
pattern,
placeholder: pattern.toUpperCase(),
order: pattern.replace(/[^dMy]/g, '').replace('dd', 'd').replace('MM', 'M').replace('yyyy', 'y'),
}
}
function normaliseRange(value) {
return { start: valid(value?.start), end: valid(value?.end) }
}
document.addEventListener('alpine:init', () => {
window.Alpine.data('materialDatepicker', (config) => ({
value: config.value,
open: false,
presentation: 'docked',
typing: false,
view: 'days',
draft: null,
focused: null,
shown: null,
text: '',
fieldError: '',
entry: '',
entryEnd: '',
entryError: '',
today: localToday(),
compact: false,
refocus: true,
// Set in init() from the config. Declared here, or Alpine writes them to the outermost
// x-data scope, where every picker inside the same page scope would share the last one's.
firstDay: 0,
format: null,
numbers: null,
formats: {},
min: null,
max: null,
yearsFrom: null,
yearsTo: null,
init() {
const locale = config.locale || document.documentElement.lang || 'en'
const format = (options) => new Intl.DateTimeFormat(locale, { timeZone: 'UTC', ...options })
this.firstDay = firstDayOfWeek(locale, config.weekStart ?? null)
this.format = inputFormat(locale, config.format ?? null)
this.numbers = new Intl.NumberFormat(locale, { useGrouping: false })
this.formats = {
monthYear: format({ year: 'numeric', month: 'long' }),
month: format({ month: 'short' }),
monthLong: format({ month: 'long' }),
year: format({ year: 'numeric' }),
headline: format({ year: 'numeric', month: 'short', day: 'numeric' }),
long: format({ weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }),
weekdayNarrow: format({ weekday: 'narrow' }),
weekdayLong: format({ weekday: 'long' }),
}
this.min = valid(config.min)
this.max = valid(config.max)
// androidx's 19002100, narrowed to the years `min` and `max` leave open.
this.yearsFrom = this.min ? parts(this.min)[0] : Math.min(YEARS_FROM, this.max ? parts(this.max)[0] : YEARS_FROM)
this.yearsTo = this.max ? parts(this.max)[0] : Math.max(YEARS_TO, this.yearsFrom)
this.text = this.display(this.current())
this.moveTo(this.start() ?? this.today, false)
this.$watch('value', () => {
if (document.activeElement !== this.$refs.input) {
this.text = this.display(this.current())
this.fieldError = ''
}
})
const query = window.matchMedia(COMPACT)
this.compact = query.matches
query.addEventListener('change', (event) => (this.compact = event.matches))
},
// ---- Values --------------------------------------------------------------------------
/** The bound value, cleaned: an ISO string or null, or `{ start, end }` for a range. */
current() {
return config.range ? normaliseRange(this.value) : valid(this.value)
},
/** Writes only a change, so leaving a field untouched sends Livewire nothing. */
write(value) {
const next = config.range ? { start: value?.start ?? null, end: value?.end ?? null } : (value ?? null)
if (JSON.stringify(next) !== JSON.stringify(this.current())) {
this.value = next
}
},
/** A date as the field shows and takes it: `13.09.2026`. */
typed(value) {
if (!value) {
return ''
}
const [year, month, day] = parts(value)
return this.format.pattern.replace('yyyy', pad(year, 4)).replace('MM', pad(month)).replace('dd', pad(day))
},
display(value) {
if (!config.range) {
return this.typed(value)
}
if (!value.start && !value.end) {
return ''
}
return `${this.typed(value.start)} ${this.typed(value.end)}`
},
/**
* Reads typed digits in the locale's order: `13.9.2026`, `13/09/2026` and `13092026` are the
* same day in `de`. Returns an ISO string, or null for text that is not a date.
*/
parse(text) {
const groups = String(text ?? '').match(/\d+/g) ?? []
let values = groups
if (groups.length === 1 && groups[0].length === 8) {
let offset = 0
values = [...this.format.order].map((unit) => {
const length = unit === 'y' ? 4 : 2
const piece = groups[0].slice(offset, offset + length)
offset += length
return piece
})
}
if (values.length !== 3) {
return null
}
const units = Object.fromEntries([...this.format.order].map((unit, index) => [unit, values[index]]))
if (units.y.length !== 4 || units.M.length > 2 || units.d.length > 2) {
return null
}
return valid(`${units.y}-${pad(+units.M)}-${pad(+units.d)}`)
},
parseRange(text) {
const groups = String(text ?? '').match(/\d+/g) ?? []
if (groups.length === 2 && groups.every((group) => group.length === 8)) {
return { start: this.parse(groups[0]), end: this.parse(groups[1]) }
}
if (groups.length === 6) {
return { start: this.parse(groups.slice(0, 3).join(' ')), end: this.parse(groups.slice(3).join(' ')) }
}
return null
},
/** androidx's DateInputValidator: the pattern, then min and max, then the year range. */
problem(value) {
if (!value) {
return config.strings.pattern.replace(':pattern', this.format.placeholder)
}
if (this.disabled(value)) {
return config.strings.notAllowed.replace(':date', this.formats.headline.format(utc(...parts(value))))
}
const year = parts(value)[0]
if (year < this.yearsFrom || year > this.yearsTo) {
return config.strings.yearRange.replace(':start', this.numbers.format(this.yearsFrom)).replace(':end', this.numbers.format(this.yearsTo))
}
return ''
},
disabled(value) {
return (this.min !== null && value < this.min) || (this.max !== null && value > this.max)
},
clamp(value) {
if (this.min !== null && value < this.min) {
return this.min
}
return this.max !== null && value > this.max ? this.max : value
},
// ---- The field ----------------------------------------------------------------------
/** Typing into the docked field: a complete, allowed date is written at once. */
typeInField() {
const text = this.text.trim()
const value = config.range ? this.parseRange(text) : this.parse(text)
const complete = config.range ? value?.start && value?.end : value
if (!complete) {
return
}
const problem = this.fieldProblem(value)
if (problem === '') {
this.fieldError = ''
this.write(value)
this.follow(config.range ? value.start : value)
}
},
/** Leaving the field, or Enter: the text is the value, or it says what is wrong with it. */
commitField() {
const text = this.text.trim()
if (text === '') {
this.fieldError = ''
if (config.range ? this.current().start || this.current().end : this.current()) {
this.write(null)
}
return
}
const value = config.range ? this.parseRange(text) : this.parse(text)
const problem = this.fieldProblem(value)
this.fieldError = problem
if (problem === '') {
this.write(value)
this.text = this.display(config.range ? normaliseRange(value) : value)
}
},
/** The clear button: no date (no start and no end), closed, and focus back in the field. */
clear() {
if (this.open) {
this.cancel(false)
}
this.fieldError = ''
this.text = ''
this.write(null)
this.$refs.input.focus()
},
fieldProblem(value) {
if (!config.range) {
return this.problem(value)
}
if (!value) {
return config.strings.pattern.replace(':pattern', `${this.format.placeholder} ${this.format.placeholder}`)
}
return this.problem(value.start) || this.problem(value.end) || (value.start > value.end ? config.strings.invalidRange : '')
},
/** An open calendar follows a date typed into the field. */
follow(value) {
if (this.open && value) {
this.draft = this.current()
this.moveTo(value, false)
}
},
// ---- Opening and closing --------------------------------------------------------------
show(focusInside = true) {
if (this.open || config.disabled || config.readonly) {
return
}
const value = this.current()
this.today = localToday()
this.presentation = config.mode === 'docked' && !this.compact ? 'docked' : 'modal'
this.typing = config.mode === 'input'
this.view = 'days'
this.draft = value
this.moveTo(this.start() ?? this.today, false)
this.entryError = ''
this.fillEntry()
this.refocus = true
this.open = true
const dialog = this.$refs.dialog
if (this.presentation === 'docked') {
dialog.showPopover()
} else {
dialog.showModal()
}
if (focusInside || this.presentation === 'modal') {
this.settled(() => this.focusInside())
}
},
/**
* Runs a step once what it needs is on screen. `x-show` reveals an element a frame after the
* state changes (a timeout in a hidden tab), and a hidden element cannot take focus.
*/
settled(callback) {
this.$nextTick(() => (document.visibilityState === 'visible' ? requestAnimationFrame : setTimeout)(callback))
},
focusInside() {
if (this.typing) {
this.$refs.entry?.focus()
} else {
this.focusDay()
}
},
/** Closes without keeping what was picked. */
cancel(refocus = true) {
if (!this.open) {
return
}
this.refocus = refocus
this.close()
},
close() {
const dialog = this.$refs.dialog
this.open = false
this.view = 'days'
if (dialog.matches(':popover-open')) {
dialog.hidePopover()
}
if (dialog.open) {
dialog.close()
}
if (this.refocus) {
this.$refs.input.focus()
}
},
/** OK: the picked date (or typed one) becomes the value. */
confirm() {
if (this.typing && !this.takeEntry()) {
return
}
this.write(this.draft)
this.text = this.display(this.current())
this.fieldError = ''
this.refocus = true
this.close()
},
/** A press outside a docked picker, or focus leaving it, puts it away. */
leave(event) {
if (this.open && this.presentation === 'docked' && event.relatedTarget && !this.$root.contains(event.relatedTarget)) {
this.cancel(false)
}
},
// ---- Picking --------------------------------------------------------------------------
pick(value) {
if (!value || this.disabled(value)) {
return
}
this.focused = value
this.shown = value.slice(0, 8) + '01'
if (!config.range) {
this.draft = value
return
}
const { start, end } = this.draft ?? { start: null, end: null }
this.draft = start && !end && value >= start ? { start, end: value } : { start: value, end: null }
},
complete() {
return config.range ? Boolean(this.draft?.start && this.draft?.end) : Boolean(this.draft)
},
choose(cell) {
if (cell.blank || cell.disabled) {
return
}
this.pick(cell.value)
this.focusDay()
},
focusDay() {
this.settled(() => this.$refs.dialog.querySelector('[data-datepicker-day][tabindex="0"]')?.focus())
},
/** Focuses a day (inside min and max) and shows its month. */
moveTo(value, focus = true) {
this.focused = this.clamp(value)
this.shown = this.focused.slice(0, 8) + '01'
if (focus) {
this.focusDay()
}
},
/** The single date, or a range's start. */
start() {
const value = this.current()
return config.range ? value.start : value
},
gridKey(event) {
const rtl = getComputedStyle(this.$refs.dialog).direction === 'rtl'
const focused = this.focused
const column = (utc(...parts(focused)).getUTCDay() - this.firstDay + 7) % 7
const target = {
ArrowRight: () => addDays(focused, rtl ? -1 : 1),
ArrowLeft: () => addDays(focused, rtl ? 1 : -1),
ArrowDown: () => addDays(focused, 7),
ArrowUp: () => addDays(focused, -7),
Home: () => addDays(focused, -column),
End: () => addDays(focused, 6 - column),
PageUp: () => addMonths(focused, event.shiftKey ? -12 : -1),
PageDown: () => addMonths(focused, event.shiftKey ? 12 : 1),
}[event.key]
if (target) {
event.preventDefault()
this.moveTo(target())
return
}
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault()
this.pick(focused)
if (event.key === 'Enter' && this.complete()) {
this.confirm()
}
}
},
/** The previous or next month (or year) buttons; focus stays on the button. */
step(months) {
if (!this.canStep(months)) {
return
}
this.moveTo(addMonths(this.focused, months), false)
},
monthAllowed(year, month) {
const first = `${pad(year, 4)}-${pad(month)}-01`
const last = iso(utc(year, month + 1, 0))
return year >= this.yearsFrom && year <= this.yearsTo && !(this.max !== null && first > this.max) && !(this.min !== null && last < this.min)
},
canStep(months) {
const [year, month] = parts(addMonths(this.shown, months))
return this.monthAllowed(year, month)
},
// ---- Years and months ---------------------------------------------------------------
/** Opens the year grid (or a docked picker's month or year list), or goes back to the days. */
toggleView(view) {
this.view = this.view === view ? 'days' : view
if (this.view === 'days') {
this.focusDay()
return
}
this.settled(() => {
const list = [...this.$refs.dialog.querySelectorAll('[data-datepicker-list]')].find((each) => each.getClientRects().length > 0)
const option = list?.querySelector('[aria-selected="true"]') ?? list?.querySelector('[role="option"]')
option?.scrollIntoView({ block: 'center' })
option?.focus()
})
},
/** The focused day moves to the chosen year and month, kept inside min and max. */
showMonthOf(year, month) {
const [focusedYear, focusedMonth] = parts(this.focused)
this.moveTo(addMonths(this.focused, (year - focusedYear) * 12 + month - focusedMonth))
this.view = 'days'
},
/** Arrows, Home and End in a year or month list; the options are buttons, so Enter and Space are the browser's. */
listKey(event, columns = 1) {
const options = [...event.currentTarget.querySelectorAll('[role="option"]')]
const index = options.indexOf(document.activeElement)
const move = {
ArrowDown: columns,
ArrowUp: -columns,
ArrowRight: columns > 1 ? 1 : 0,
ArrowLeft: columns > 1 ? -1 : 0,
}[event.key]
let next = null
if (move) {
next = options[Math.min(options.length - 1, Math.max(0, index + move))]
} else if (event.key === 'Home') {
next = options[0]
} else if (event.key === 'End') {
next = options.at(-1)
}
if (next) {
event.preventDefault()
next.focus()
next.scrollIntoView({ block: 'nearest' })
}
},
// ---- Text entry in the dialog --------------------------------------------------------
fillEntry() {
if (config.range) {
this.entry = this.typed(this.draft?.start)
this.entryEnd = this.typed(this.draft?.end)
} else {
this.entry = this.typed(this.draft)
}
},
/** As it is typed, a whole and allowed date becomes the draft, so the headline follows it. */
typeEntry() {
const read = (text) => {
const value = this.parse(text)
return value && this.problem(value) === '' ? value : null
}
this.entryError = ''
this.draft = config.range ? { start: read(this.entry), end: read(this.entryEnd) } : read(this.entry)
},
/** Reads the dialog's text fields into the draft; false, with the reason shown, when it cannot. */
takeEntry() {
if (!config.range) {
if (this.entry.trim() === '') {
this.draft = null
this.entryError = ''
return true
}
const value = this.parse(this.entry)
this.entryError = this.problem(value)
if (this.entryError !== '') {
return false
}
this.draft = value
return true
}
const start = this.entry.trim() === '' ? null : this.parse(this.entry)
const end = this.entryEnd.trim() === '' ? null : this.parse(this.entryEnd)
this.entryError = (this.entry.trim() !== '' ? this.problem(start) : '')
|| (this.entryEnd.trim() !== '' ? this.problem(end) : '')
|| (start && end && start > end ? config.strings.invalidRange : '')
if (this.entryError !== '') {
return false
}
this.draft = { start, end }
return true
},
toggleTyping() {
if (this.typing) {
if (!this.takeEntry()) {
return
}
const start = config.range ? this.draft.start : this.draft
this.moveTo(start ?? this.focused, false)
} else {
this.fillEntry()
this.entryError = ''
}
this.typing = !this.typing
this.view = 'days'
this.settled(() => this.focusInside())
},
// ---- What the template draws ---------------------------------------------------------
get weekdays() {
return Array.from({ length: 7 }, (_, index) => {
// 1 January 2023 was a Sunday.
const date = utc(2023, 1, 1 + ((this.firstDay + index) % 7))
return { narrow: this.formats.weekdayNarrow.format(date), long: this.formats.weekdayLong.format(date) }
})
},
get weeks() {
const [year, month] = parts(this.shown)
const first = utc(year, month, 1)
const offset = (first.getUTCDay() - this.firstDay + 7) % 7
const range = config.range ? (this.draft ?? { start: null, end: null }) : null
const outside = this.presentation === 'docked'
return Array.from({ length: 6 }, (_, row) => Array.from({ length: 7 }, (_, column) => {
const date = utc(year, month, 1 - offset + row * 7 + column)
const value = iso(date)
const inMonth = date.getUTCMonth() + 1 === month
const start = range ? value === range.start : value === this.draft
const end = range ? value === range.end : false
return {
value,
blank: !inMonth && !outside,
outside: !inMonth,
label: this.numbers.format(date.getUTCDate()),
name: this.formats.long.format(date),
disabled: this.disabled(value),
today: value === this.today,
selected: start || end,
start: range !== null && start && Boolean(range.end) && range.end !== range.start,
end: range !== null && end && range.end !== range.start,
between: range !== null && Boolean(range.start && range.end) && value > range.start && value < range.end,
focused: inMonth && value === this.focused,
}
}))
},
get years() {
const shown = parts(this.shown)[0]
const current = parts(this.today)[0]
return Array.from({ length: this.yearsTo - this.yearsFrom + 1 }, (_, index) => {
const year = this.yearsFrom + index
return { value: year, label: this.numbers.format(year), selected: year === shown, current: year === current }
})
},
get months() {
const [year, shown] = parts(this.shown)
return Array.from({ length: 12 }, (_, index) => ({
value: index + 1,
label: this.formats.monthLong.format(utc(2023, index + 1, 1)),
selected: index + 1 === shown,
disabled: !this.monthAllowed(year, index + 1),
}))
},
get monthYear() {
return this.shown ? this.formats.monthYear.format(utc(...parts(this.shown))) : ''
},
get monthLabel() {
return this.shown ? this.formats.month.format(utc(...parts(this.shown))) : ''
},
get yearLabel() {
return this.shown ? this.formats.year.format(utc(...parts(this.shown))) : ''
},
/** M3's headline: the chosen date, or what the picker is for until there is one. */
get headline() {
const date = (value) => (value ? this.formats.headline.format(utc(...parts(value))) : null)
if (config.range) {
return `${date(this.draft?.start) ?? config.strings.start} ${date(this.draft?.end) ?? config.strings.end}`
}
return date(this.draft) ?? (this.typing ? config.strings.entered : config.strings.selected)
},
get placeholder() {
return config.range ? `${this.format.placeholder} ${this.format.placeholder}` : this.format.placeholder
},
get serialised() {
return config.range ? this.current() : this.current() ?? ''
},
}))
})
+3
View File
@@ -20,7 +20,10 @@ import './carousel.js'
import './chips.js'
import './field.js'
import './search.js'
import './datepicker.js'
import './timepicker.js'
import './slider.js'
import './tabs.js'
import './app-bar.js'
import './navigation.js'
import './toolbar.js'
+89 -8
View File
@@ -3,29 +3,71 @@
*
* The menu button is the trigger's first button or link. Its ARIA attributes are written by
* script, which a Livewire morph removes along with anything else the server did not render,
* so they are written again whenever the trigger is used.
* so they are written again whenever the trigger is used and after every morph an open menu
* lives through one (the popover is keyed), and its button must still say so.
*
* The popover hangs on the menu button by CSS anchor positioning. The server can only name the
* wrapper around the trigger slot, and a trigger taken out of the flow a `position: fixed` FAB
* in a corner of the window leaves that wrapper behind as an empty box where the page put it,
* so the menu opened there. Script moves the name onto the menu button, beside any name the button
* carries itself (a button's tooltip anchors on it too), and moves it again after every morph,
* which puts the server's attributes, and a fresh name, back.
*/
const ITEMS = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]'
// A popover="auto" closes on the press that lands on its trigger, and the click that follows
// would open it again. A close this recent is taken as that press.
// would open it again. A close this recent is taken as that press. It is timed from
// `beforetoggle`, which fires as the popover closes: `toggle` is queued, and arrives after that
// click.
const REOPEN_GUARD_MS = 250
document.addEventListener('alpine:init', () => {
window.Alpine.data('materialMenu', () => ({
closedAt: -Infinity,
anchored: null,
returnFocus: true,
focusWasInside: false,
listeners: [],
init() {
const menu = this.$refs.menu
this.label()
this.anchor()
// A morph rewrites the wrapper's style with this render's name and the button's without
// it, takes the button's ARIA attributes away and gives the popover a new id; the
// observer runs before the next frame is drawn, so an open menu never moves and its
// button never shows it shut.
const observer = new MutationObserver(() => {
this.anchor()
this.label()
})
observer.observe(this.$refs.trigger, {
attributes: true,
attributeFilter: ['style', 'aria-haspopup', 'aria-controls', 'aria-expanded'],
childList: true,
subtree: true,
})
observer.observe(menu, { attributes: true, attributeFilter: ['id'] })
this.listeners.push(() => observer.disconnect())
// Only closes the browser starts — Escape, a press outside — arrive here alone; open()
// and close() have already done their part, synchronously, because this event is
// queued and a screen reader or a test reading aria-expanded in between would be told
// the menu is shut.
// Whether focus was in the menu is read before it closes: once closed, a browser may
// already have handed focus to what had it before the menu opened (WebKit does, when
// that was a focusable region around the trigger).
this.listen(menu, 'beforetoggle', (event) => {
this.focusWasInside = event.newState === 'closed' && menu.contains(document.activeElement)
if (event.newState === 'closed') {
this.closedAt = performance.now()
}
})
this.listen(menu, 'toggle', (event) => {
const opened = event.newState === 'open'
@@ -35,11 +77,11 @@ document.addEventListener('alpine:init', () => {
return
}
this.closedAt = performance.now()
if (this.returnFocus && menu.contains(document.activeElement)) {
if (this.returnFocus && (this.focusWasInside || menu.contains(document.activeElement))) {
this.control()?.focus()
}
this.focusWasInside = false
})
// A press outside closes the menu without pulling focus back to the trigger.
@@ -54,6 +96,40 @@ document.addEventListener('alpine:init', () => {
return this.$refs.trigger.querySelector('button, a[href], [tabindex]')
},
/**
* Moves the anchor name the server gave the wrapper onto the menu button. The wrapper holds a
* name only as rendered this render's, which the popover's `position-anchor` matches so
* it is read there.
*/
anchor() {
const trigger = this.$refs.trigger
const control = this.control()
const rendered = trigger.style.getPropertyValue('anchor-name').trim()
const name = rendered.startsWith('--') ? rendered : this.anchored
// No menu button, or an engine without anchor positioning: the wrapper keeps the name.
if (!control || !name) {
return
}
const names = control.style
.getPropertyValue('anchor-name')
.split(',')
.map((each) => each.trim())
.filter((each) => each.startsWith('--'))
if (!names.includes(name)) {
control.style.setProperty('anchor-name', [...names.filter((each) => each !== this.anchored), name].join(', '))
}
this.anchored = name
if (rendered !== '') {
trigger.style.removeProperty('anchor-name')
}
},
/** Writes only what differs: the observer that calls this watches these same attributes. */
label() {
const control = this.control()
@@ -61,9 +137,13 @@ document.addEventListener('alpine:init', () => {
return
}
control.setAttribute('aria-haspopup', 'menu')
control.setAttribute('aria-controls', this.$refs.menu.id)
control.setAttribute('aria-expanded', String(this.isOpen()))
const attributes = { 'aria-haspopup': 'menu', 'aria-controls': this.$refs.menu.id, 'aria-expanded': String(this.isOpen()) }
for (const [name, value] of Object.entries(attributes)) {
if (control.getAttribute(name) !== value) {
control.setAttribute(name, value)
}
}
},
isOpen() {
@@ -72,6 +152,7 @@ document.addEventListener('alpine:init', () => {
open(focus = 'first') {
this.label()
this.anchor()
if (!this.isOpen()) {
this.$refs.menu.showPopover()
+141
View File
@@ -0,0 +1,141 @@
/**
* Navigation: the rail's state, shared by every rail and menu button on the page.
*
* `$store.rail.collapsed` is the visitor's choice for a collapsible rail, remembered in
* localStorage. <x-theme-script> has already applied it before the first paint as
* <html data-rail="expanded|collapsed">, which is what the stylesheet keys on (the
* `rail-collapsed:` variant); the store starts from that attribute and writes it back.
*
* `$store.rail.open` is the modal rail: on a window too narrow for an expanded rail, a menu
* button opens it over a scrim (`show()`), and Escape, the scrim or leaving the page closes it
* (`hide()`). It is never remembered.
*
* `materialNavigationRail` is one rail's view of the store for its `mode` see
* resources/views/components/navigation-rail.blade.php.
*/
const WIDE = '(min-width: 64rem)'
/*
* The active indicator grows out of its centre when a page arrives through wire:navigate. The
* new page's indicator is new markup, so the only way to animate it is a starting style and
* only while a navigation swaps the page in, or every full load would animate it too. The sheet
* is adopted as the navigation starts and dropped two frames after it ends; the transition itself
* is resources/css/components/navigation.css.
*/
const arriving = new CSSStyleSheet()
arriving.replaceSync(`@starting-style {
:is([data-navigation-bar-item], [data-navigation-rail-item])[data-active],
:is([data-navigation-bar-item], [data-navigation-rail-item])[data-active] :is([data-navigation-indicator], [data-navigation-pill]) {
background-size: 0% 100%;
}
}`)
document.addEventListener('livewire:navigating', () => {
if (!document.adoptedStyleSheets.includes(arriving)) {
document.adoptedStyleSheets = [...document.adoptedStyleSheets, arriving]
}
})
document.addEventListener('livewire:navigated', () => {
requestAnimationFrame(() => requestAnimationFrame(() => {
document.adoptedStyleSheets = document.adoptedStyleSheets.filter((sheet) => sheet !== arriving)
}))
})
document.addEventListener('alpine:init', () => {
const root = document.documentElement
window.Alpine.store('rail', {
collapsed: root.dataset.rail === 'collapsed',
open: false,
toggle() {
this.set(!this.collapsed)
},
collapse() {
this.set(true)
},
expand() {
this.set(false)
},
set(collapsed) {
this.collapsed = collapsed
root.dataset.rail = collapsed ? 'collapsed' : 'expanded'
try {
localStorage.setItem(root.dataset.railKey || 'material-rail', root.dataset.rail)
} catch {
// Blocked storage: the rail still toggles, it just will not remember.
}
},
show() {
this.open = true
},
hide() {
this.open = false
},
})
// A destination chosen in the modal rail leaves the page; the next one starts with it shut.
document.addEventListener('livewire:navigating', () => window.Alpine.store('rail').hide())
window.Alpine.data('materialNavigationRail', (mode) => ({
wide: mode === 'adaptive' ? window.matchMedia(WIDE).matches : false,
query: null,
onWidth: null,
init() {
if (mode !== 'adaptive') {
return
}
// From lg the adaptive rail is a standard, collapsible rail: a modal left open while
// the window widens is shut, or its focus trap would hold a page that has no scrim.
this.query = window.matchMedia(WIDE)
this.onWidth = (event) => {
this.wide = event.matches
if (event.matches) {
this.$store.rail.hide()
}
}
this.query.addEventListener('change', this.onWidth)
},
destroy() {
this.query?.removeEventListener('change', this.onWidth)
},
/** Whether this rail expands over a scrim rather than in the layout. */
get modal() {
return mode === 'modal' || (mode === 'adaptive' && !this.wide)
},
get open() {
return this.modal && this.$store.rail.open
},
get expanded() {
if (this.open || mode === 'expanded') {
return true
}
return (mode === 'collapsible' || (mode === 'adaptive' && this.wide)) && !this.$store.rail.collapsed
},
/** The rail's own menu button: open or close the modal, or collapse and expand in place. */
menu() {
if (this.modal) {
this.$store.rail.open ? this.$store.rail.hide() : this.$store.rail.show()
} else {
this.$store.rail.toggle()
}
},
}))
})
+43 -7
View File
@@ -4,6 +4,11 @@
* One snackbar at a time, as M3 shows them. Each waits its turn, stays for its timeout (paused
* while hovered or focused, so it is never pulled away from someone reading or reaching for its
* action) and is replaced by the next.
*
* A `sticky` toast ("A new version is ready" with a Reload action) stays until it is answered, but
* never holds the queue up: it is kept aside rather than queued, a toast that arrives while it shows
* takes its place, and it comes back once the queue is empty. Only one is kept a newer sticky
* toast replaces it. Dismissing it, or pressing its action, lets it go.
*/
const DEFAULT_TIMEOUT_MS = 4000
@@ -25,6 +30,7 @@ document.addEventListener('alpine:init', () => {
window.Alpine.data('materialSnackbar', () => ({
queue: [],
current: null,
sticky: null,
timer: null,
remaining: 0,
startedAt: 0,
@@ -44,24 +50,43 @@ document.addEventListener('alpine:init', () => {
// Livewire dispatches named arguments as the detail object; a positional dispatch
// arrives as an array whose first entry is that object.
const toast = Array.isArray(detail) ? detail[0] : detail
const sticky = toast.sticky === true
this.queue.push({
const entry = {
id: ++sequence,
type: toast.type ?? null,
title: toast.title ?? '',
description: toast.description ?? null,
timeout: toast.timeout === 0 || toast.timeout === null ? 0 : (toast.timeout ?? DEFAULT_TIMEOUT_MS),
timeout: sticky || toast.timeout === 0 || toast.timeout === null ? 0 : (toast.timeout ?? DEFAULT_TIMEOUT_MS),
action: toast.action ?? null,
})
sticky,
}
if (!this.current) {
if (sticky) {
const showing = !this.current || this.current === this.sticky
this.sticky = entry
if (showing) {
this.next()
}
return
}
this.queue.push(entry)
// A sticky toast steps aside for it, and comes back from next() once the queue is empty.
if (!this.current || this.current === this.sticky) {
this.next()
}
},
next() {
// Cleared and forgotten here, so a toast dismissed early never leaves its timer running
// to cut the next one short.
clearTimeout(this.timer)
this.current = this.queue.shift() ?? null
this.timer = null
this.current = this.queue.shift() ?? this.sticky
if (this.current?.timeout) {
this.remaining = this.current.timeout
@@ -92,13 +117,24 @@ document.addEventListener('alpine:init', () => {
},
dismiss() {
this.timer = null
if (this.current && this.current === this.sticky) {
this.sticky = null
}
this.next()
},
// Closed before the handler and the event run, so a toast either of them shows is not the
// one dismissed.
act() {
this.current?.action?.handler?.()
const action = this.current?.action
this.dismiss()
action?.handler?.()
if (typeof action?.event === 'string' && action.event !== '') {
window.dispatchEvent(new CustomEvent(action.event))
}
},
icon(type) {
+14
View File
@@ -7,6 +7,10 @@
*
* `value` is an accessor, so binding a control with `x-model="$store.theme.value"` goes
* through the same write as `set()` and `toggle()`: the attributes, then localStorage.
*
* `scheme` is the colour profile on screen (<html data-scheme>, which the server chose), and
* `previewScheme(name)` shows another one on this page without storing anything the
* application saves a choice itself, and the next full load draws what it saved.
*/
document.addEventListener('alpine:init', () => {
const root = document.documentElement
@@ -18,6 +22,7 @@ document.addEventListener('alpine:init', () => {
window.Alpine.store('theme', {
choice: choices.includes(root.dataset.themeChoice) ? root.dataset.themeChoice : 'system',
resolved: root.dataset.theme === 'dark' ? 'dark' : 'light',
scheme: root.dataset.scheme || null,
get value() {
return this.choice
@@ -48,6 +53,15 @@ document.addEventListener('alpine:init', () => {
toggle() {
this.set(this.resolved === 'dark' ? 'light' : 'dark')
},
previewScheme(name) {
if (typeof name !== 'string' || !/^[a-z0-9-]+$/.test(name)) {
return
}
this.scheme = name
root.dataset.scheme = name
},
})
// The head script repaints on an OS change while the choice is `system`; this keeps the
+684
View File
@@ -0,0 +1,684 @@
/**
* `materialTimepicker(value, config)`: the behaviour of `<x-timepicker>` a field that opens M3's
* time picker in a modal `<dialog>`, as a clock dial or as two text fields.
*
* The picker edits a draft, `hour` (023) and `minute`; only OK writes it to `value` as `H:i`.
* Cancel, Escape and a press on the scrim leave `value` alone, and every close hands focus back to
* the field. Whether the hours run 112 with AM and PM or 023 comes from `config.format` or, without
* one, from the locale (`Intl.DateTimeFormat(locale, { hour: 'numeric' })`'s hour cycle).
*
* The dial:
* - A press sets the value where it lands, and the selector turns there on the default spatial
* spring; a drag makes the handle follow the pointer and settles on the nearest value when
* released. Hours then move on to minutes, as Compose does after a tap (100ms later) or a drag.
* - On a 24-hour dial the inner ring holds 1223 and the outer 0011: a press nearer the centre than
* 74dp (scaled with the dial) is on the inner ring.
* - A tap picks minutes in fives (or in `step`s, when `step` is not a divisor of five); a drag in ones
* (or `step`s).
* - The dial is a slider for the keyboard: the arrows change the hour or minute it shows, Home and End
* go to the first and last allowed, Enter confirms. The keyboard never moves on to minutes by
* itself; the hour and minute boxes above the dial switch between them.
*
* `min` and `max` (`H:i`, inclusive; `min` later than `max` is a range across midnight) and `step`
* (minutes) limit what can be chosen: a tap on a number outside them does nothing, a drag and the
* arrows skip to the nearest allowed value, a period switch that would leave them lands on the
* nearest allowed time, and typed values outside them are errors. They are a
* convenience for the person choosing, not validation validate on the server too.
*
* The selector's angle is a registered custom property (`--timepicker-angle`, components/
* timepicker.css), so one CSS transition turns the line, moves the handle, and moves the clip that
* shows the number under the handle in on-primary, all on the same spring.
*
* ---------------------------------------------------------------------------------------
* Behaviour and geometry follow androidx Compose Material 3 (https://github.com/androidx/androidx),
* commit 27cf9a7d5788aa0f5f2d8b6699ce279560daf326:
*
* compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TimePicker.kt
* (AnalogTimePickerState.rotateTo, endValueForAnimation, moveSelector, onTap, ClockDialNode,
* selectorPos, TimeInputImpl, shouldSwitchFocusToMinute, the ring and distance constants)
*
* Copyright 2022-2026 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* ---------------------------------------------------------------------------------------
*/
/** ClockDialContainerSize: the dial's own coordinate space. */
const DIAL = 256
/** MaxDistance: nearer the centre than this, a 24-hour dial is on its inner ring. */
const INNER_REACH = 74
/** onTap's delay(100) before a tapped hour moves on to minutes. */
const MOVE_ON_MS = 100
/** How far a pointer moves before a press on the dial is a drag. */
const DRAG_SLOP = 4
const pad = (number) => String(number).padStart(2, '0')
/** "112" stays on one line in a 96px column: word joiners either side of a dash between numbers. */
const keepRange = (text) => text.replace(/(\d)\s*([-])\s*(\d)/g, '$1\u2060$2\u2060$3')
/** The shortest way round from one angle to another, in degrees. */
const turn = (from, to) => ((((to - from) % 360) + 540) % 360) - 180
function parse(value) {
const match = /^(\d{1,2}):(\d{2})/.exec(typeof value === 'string' ? value : '')
if (!match) {
return null
}
const hour = Number(match[1])
const minute = Number(match[2])
return hour < 24 && minute < 60 ? hour * 60 + minute : null
}
document.addEventListener('alpine:init', () => {
window.Alpine.data('materialTimepicker', (value = null, config = {}) => ({
value,
open: false,
mode: 'dial',
view: 'hour',
hour: 0,
minute: 0,
angle: 0,
dragging: false,
scrimPressed: false,
hourText: '',
minuteText: '',
attempted: false,
is24: false,
step: 1,
earliest: null,
latest: null,
moveOn: null,
formatter: null,
periods: ['AM', 'PM'],
init() {
const locale = config.locale || undefined
try {
this.is24 = config.format ? Number(config.format) === 24 : ['h23', 'h24'].includes(new Intl.DateTimeFormat(locale, { hour: 'numeric' }).resolvedOptions().hourCycle)
this.formatter = new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit', hourCycle: this.is24 ? 'h23' : 'h12' })
const dayPeriod = (hour) => new Intl.DateTimeFormat(locale, { hour: 'numeric', hourCycle: 'h12' }).formatToParts(new Date(2000, 0, 1, hour)).find((part) => part.type === 'dayPeriod')?.value
this.periods = [dayPeriod(9) ?? config.strings.am, dayPeriod(21) ?? config.strings.pm]
} catch {
this.is24 = Number(config.format) === 24
this.formatter = null
this.periods = [config.strings.am, config.strings.pm]
}
this.step = Math.min(Math.max(Math.trunc(Number(config.step) || 1), 1), 60)
this.earliest = parse(config.min)
this.latest = parse(config.max)
},
// ---- What is shown ---------------------------------------------------------------------
get display() {
const time = parse(this.value)
return time === null ? '' : this.format(time)
},
format(time) {
const date = new Date(2000, 0, 1, Math.floor(time / 60), time % 60)
return this.formatter ? this.formatter.format(date) : `${pad(date.getHours())}:${pad(date.getMinutes())}`
},
get isPm() {
return this.hour >= 12
},
get hourLabel() {
return this.is24 ? pad(this.hour) : pad(this.hour % 12 || 12)
},
get minuteLabel() {
return pad(this.minute)
},
/** selectorPos: a 24-hour dial's afternoon hours are on the inner ring. */
get inner() {
return this.is24 && this.view === 'hour' && this.hour >= 12
},
get valueText() {
const strings = config.strings
if (this.view === 'minute') {
return strings.minutes.replace(':minute', this.minute)
}
return this.is24 ? strings.hours.replace(':hour', this.hour) : `${strings.oclock.replace(':hour', this.hour % 12 || 12)} ${this.periods[this.isPm ? 1 : 0]}`
},
// ---- What may be chosen ----------------------------------------------------------------
inRange(time) {
const { earliest, latest } = this
if (earliest !== null && latest !== null && earliest > latest) {
return time >= earliest || time <= latest
}
return (earliest === null || time >= earliest) && (latest === null || time <= latest)
},
allowed(hour, minute) {
return minute % this.step === 0 && this.inRange(hour * 60 + minute)
},
hourAllowed(hour) {
for (let minute = 0; minute < 60; minute += this.step) {
if (this.allowed(hour, minute)) {
return true
}
}
return false
},
minuteAllowed(minute) {
return this.allowed(this.hour, minute)
},
periodAllowed(pm) {
for (let hour = pm ? 12 : 0; hour < (pm ? 24 : 12); hour++) {
if (this.hourAllowed(hour)) {
return true
}
}
return false
},
/** The allowed time nearest to one that is not, searching outwards a minute at a time. */
nearest(time) {
for (let distance = 0; distance <= 720; distance++) {
for (const candidate of [time - distance, time + distance]) {
const wrapped = (candidate + 1440) % 1440
if (this.allowed(Math.floor(wrapped / 60), wrapped % 60)) {
return wrapped
}
}
}
return null
},
/** The minute nearest to this one that the current hour allows. */
nearestMinute(minute) {
for (let distance = 0; distance <= 30; distance++) {
for (const candidate of [minute - distance, minute + distance]) {
const wrapped = (candidate + 60) % 60
if (this.minuteAllowed(wrapped)) {
return wrapped
}
}
}
return null
},
/** The hour nearest to this one on its own ring (period), then on the other. */
nearestHour(hour) {
const base = hour >= 12 ? 12 : 0
for (const ring of [base, 12 - base]) {
for (let distance = 0; distance <= 6; distance++) {
for (const candidate of [hour - distance, hour + distance]) {
const inRing = ring + ((((candidate - base) % 12) + 12) % 12)
if (this.hourAllowed(inRing)) {
return inRing
}
}
}
if (!this.is24) {
return null
}
}
return null
},
settle(time) {
const allowed = this.allowed(Math.floor(time / 60), time % 60) ? time : this.nearest(time)
if (allowed !== null) {
this.hour = Math.floor(allowed / 60)
this.minute = allowed % 60
}
},
/** An hour chosen: keep the minute if the new hour allows it, otherwise the nearest one it does. */
setHour(hour) {
this.hour = hour
if (!this.minuteAllowed(this.minute)) {
this.minute = this.nearestMinute(this.minute) ?? this.minute
}
},
// ---- The dialog ------------------------------------------------------------------------
show() {
if (this.open || this.$refs.input.disabled) {
return
}
const now = new Date()
const time = parse(this.value) ?? now.getHours() * 60 + Math.round(now.getMinutes() / this.step) * this.step
this.settle(time % 1440)
this.view = 'hour'
this.attempted = false
this.fillText()
this.angle = this.angleFor()
this.open = true
// After Alpine has drawn the draft, so nothing animates from the last time it was open.
this.$nextTick(() => {
this.$refs.dialog.showModal()
this.focusMode()
})
},
cancel() {
this.$refs.dialog.close()
},
confirm() {
if (this.mode === 'input' && !this.readText()) {
return
}
if (!this.allowed(this.hour, this.minute)) {
return
}
this.value = `${pad(this.hour)}:${pad(this.minute)}`
this.$refs.dialog.close()
},
closed() {
clearTimeout(this.moveOn)
this.open = false
this.dragging = false
this.$refs.input.focus({ preventScroll: true })
},
focusMode() {
if (this.mode === 'input') {
const field = this.view === 'minute' ? this.$refs.minuteInput : this.$refs.hourInput
field.focus()
field.select()
} else {
this.$refs.dial.focus({ preventScroll: true })
}
},
toggleMode() {
clearTimeout(this.moveOn)
if (this.mode === 'dial') {
this.mode = 'input'
this.attempted = false
this.fillText()
} else {
this.mode = 'dial'
this.angle = this.angleFor()
}
this.$nextTick(() => this.focusMode())
},
/** The hour or minute box above the dial. */
choose(view) {
clearTimeout(this.moveOn)
this.view = view
this.aim()
},
setPeriod(pm) {
if (this.isPm === pm || !this.periodAllowed(pm)) {
return
}
const hour = this.hour + (pm ? 12 : -12)
if (this.hourAllowed(hour)) {
this.setHour(hour)
} else {
const time = this.nearestInPeriod(pm, hour * 60 + this.minute)
this.hour = Math.floor(time / 60)
this.minute = time % 60
}
this.fillText()
this.aim()
},
/** The allowed time in a period nearest to `time` (periodAllowed has said there is one). */
nearestInPeriod(pm, time) {
let best = null
for (let candidate = pm ? 720 : 0; candidate < (pm ? 1440 : 720); candidate++) {
if (this.allowed(Math.floor(candidate / 60), candidate % 60) && (best === null || Math.abs(candidate - time) < Math.abs(best - time))) {
best = candidate
}
}
return best ?? time
},
// ---- The dial --------------------------------------------------------------------------
angleFor() {
return this.view === 'hour' ? (this.hour % 12) * 30 : this.minute * 6
},
/** endValueForAnimation: turn the short way round, so 11 to 1 never spins backwards. */
aim(degrees = this.angleFor()) {
this.angle += turn(this.angle, degrees)
},
press(event) {
if (event.button !== 0 || !event.isPrimary) {
return
}
event.preventDefault()
clearTimeout(this.moveOn)
const dial = this.$refs.dial
const startX = event.clientX
const startY = event.clientY
let dragged = false
dial.focus({ preventScroll: true })
try {
dial.setPointerCapture(event.pointerId)
} catch {
// A pointer the browser does not know (a synthetic event) cannot be captured.
}
const move = (moveEvent) => {
if (!dragged && Math.hypot(moveEvent.clientX - startX, moveEvent.clientY - startY) < DRAG_SLOP) {
return
}
dragged = true
this.dragging = true
this.pick(moveEvent, false)
}
const release = (upEvent, cancelled = false) => {
dial.removeEventListener('pointermove', move)
dial.removeEventListener('pointerup', release)
dial.removeEventListener('pointercancel', abandon)
this.dragging = false
if (cancelled) {
this.aim()
return
}
if (dragged) {
this.aim()
} else if (!this.pick(upEvent, true)) {
return
}
if (this.view === 'hour') {
this.moveOn = setTimeout(() => this.choose('minute'), dragged ? 0 : MOVE_ON_MS)
}
}
const abandon = (cancelEvent) => release(cancelEvent, true)
dial.addEventListener('pointermove', move)
dial.addEventListener('pointerup', release)
dial.addEventListener('pointercancel', abandon)
},
/**
* The value under the pointer. A tap turns the selector to the value it chose; a drag keeps the
* handle under the pointer (rotateTo without animation) until it is released.
*/
pick(event, tap) {
const box = this.$refs.dial.getBoundingClientRect()
const x = event.clientX - (box.left + box.width / 2)
const y = event.clientY - (box.top + box.height / 2)
const degrees = ((Math.atan2(x, -y) * 180) / Math.PI + 360) % 360
if (this.view === 'hour') {
let hour = Math.round(degrees / 30) % 12
if (this.is24 ? Math.hypot(x, y) < (INNER_REACH * box.width) / DIAL : this.isPm) {
hour += 12
}
// A tap on a number outside the limits does nothing; a drag settles on the nearest allowed.
hour = this.hourAllowed(hour) ? hour : tap ? null : this.nearestHour(hour)
if (hour === null) {
return false
}
this.setHour(hour)
} else {
const unit = tap && 5 % this.step === 0 ? 5 : this.step
let minute = (Math.round(degrees / 6 / unit) * unit) % 60
minute = this.minuteAllowed(minute) ? minute : tap ? null : this.nearestMinute(minute)
if (minute === null) {
return false
}
this.minute = minute
}
this.aim(tap ? this.angleFor() : degrees)
return true
},
key(event) {
const moves = { ArrowUp: 1, ArrowRight: 1, ArrowDown: -1, ArrowLeft: -1 }
if (event.key === 'Enter') {
event.preventDefault()
this.confirm()
} else if (event.key in moves) {
event.preventDefault()
this.nudge(moves[event.key])
} else if (event.key === 'Home' || event.key === 'End') {
event.preventDefault()
this.extreme(event.key === 'End')
}
},
nudge(direction) {
clearTimeout(this.moveOn)
if (this.view === 'hour') {
for (let offset = 1; offset <= 24; offset++) {
const hour = (((this.hour + direction * offset) % 24) + 24) % 24
if (this.hourAllowed(hour)) {
this.setHour(hour)
break
}
}
} else {
const aligned = Math.round(this.minute / this.step) * this.step
for (let offset = aligned === this.minute ? 1 : 0; offset <= 60; offset++) {
const minute = (((aligned + direction * offset * this.step) % 60) + 60) % 60
if (this.minuteAllowed(minute)) {
this.minute = minute
break
}
}
}
this.aim()
},
extreme(last) {
const values = this.view === 'hour'
? [...Array(24).keys()].filter((hour) => this.hourAllowed(hour))
: [...Array(60).keys()].filter((minute) => this.minuteAllowed(minute))
if (values.length === 0) {
return
}
if (this.view === 'hour') {
this.setHour(last ? values.at(-1) : values[0])
} else {
this.minute = last ? values.at(-1) : values[0]
}
this.aim()
},
// ---- The text fields -------------------------------------------------------------------
fillText() {
this.hourText = this.hourLabel
this.minuteText = this.minuteLabel
},
get hourTextValid() {
const number = Number(this.hourText)
return /^\d{1,2}$/.test(this.hourText) && (this.is24 ? number <= 23 : number >= 1 && number <= 12)
},
get minuteTextValid() {
return /^\d{1,2}$/.test(this.minuteText) && Number(this.minuteText) <= 59
},
get minuteTextOnStep() {
return !this.minuteTextValid || Number(this.minuteText) % this.step === 0
},
get hourError() {
if (this.hourTextValid || (this.hourText === '' && !this.attempted)) {
return null
}
return keepRange(this.is24 ? config.strings.hourError24 : config.strings.hourError12)
},
get minuteError() {
if (this.minuteText === '' && !this.attempted) {
return null
}
if (!this.minuteTextValid) {
return keepRange(config.strings.minuteError)
}
return this.minuteTextOnStep ? null : config.strings.stepError.replace(':step', this.step)
},
get rangeError() {
if (this.earliest === null && this.latest === null) {
return null
}
if (this.mode !== 'input' || !this.hourTextValid || !this.minuteTextValid || this.inRange(this.hour * 60 + this.minute)) {
return null
}
const strings = config.strings
if (this.earliest !== null && this.latest !== null) {
return strings.between.replace(':min', this.format(this.earliest)).replace(':max', this.format(this.latest))
}
return this.earliest !== null ? strings.after.replace(':min', this.format(this.earliest)) : strings.before.replace(':max', this.format(this.latest))
},
typeHour(event) {
const text = event.target.value.replace(/\D/g, '').slice(0, 2)
event.target.value = text
this.hourText = text
if (this.hourTextValid) {
const number = Number(text)
this.hour = this.is24 ? number : (number % 12) + (this.isPm ? 12 : 0)
}
// shouldSwitchFocusToMinute: two valid digits typed at the end move on to the minute.
if (event.inputType?.startsWith('insert') && text.length === 2 && this.hourTextValid && event.target.selectionStart === 2) {
this.view = 'minute'
this.$refs.minuteInput.focus()
this.$refs.minuteInput.select()
}
},
typeMinute(event) {
const text = event.target.value.replace(/\D/g, '').slice(0, 2)
event.target.value = text
this.minuteText = text
if (this.minuteTextValid) {
this.minute = Number(text)
}
},
/** Reads both fields for OK; on an error, says so and puts focus on the field to fix. */
readText() {
this.attempted = true
const invalid = !this.hourTextValid
? this.$refs.hourInput
: !this.minuteTextValid || !this.minuteTextOnStep
? this.$refs.minuteInput
: !this.inRange(this.hour * 60 + this.minute)
? this.$refs.hourInput
: null
if (invalid) {
invalid.focus()
invalid.select()
return false
}
return true
},
}))
})
File diff suppressed because one or more lines are too long
@@ -32,7 +32,7 @@
$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-livewire-material::menu :$label :$position {{ $attributes }}>
<x-slot:trigger>
<button
type="button"
@@ -45,7 +45,7 @@
@elseif (filled($initials))
{{ $initials }}
@else
<x-icon name="person" />
<x-livewire-material::icon name="person" />
@endif
</button>
</x-slot:trigger>
@@ -60,20 +60,20 @@
@endif
</div>
<x-menu-separator />
<x-livewire-material::menu-separator />
@endif
{{ $slot }}
@if ($theme)
<x-menu-item icon="contrast" x-on:click="$store.theme.toggle()" data-account-theme>
<x-livewire-material::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>
</x-livewire-material::menu-item>
@endif
@isset($footer)
<x-menu-separator />
<x-livewire-material::menu-separator />
{{ $footer }}
@endisset
</x-menu>
</x-livewire-material::menu>
+2 -2
View File
@@ -40,7 +40,7 @@
{{ $attributes->class(['flex items-start gap-3 rounded-corner-md p-4', $colours]) }}
>
@if ($icon)
<x-icon :name="$icon" filled class="size-6" />
<x-livewire-material::icon :name="$icon" filled class="size-6" />
@endif
<div class="min-w-0 flex-1 space-y-1 self-center">
@@ -59,7 +59,7 @@
@if ($dismissible)
<button type="button" class="state-layer focus-ring -m-2 inline-flex size-10 shrink-0 items-center justify-center rounded-corner-full" aria-label="{{ __('Dismiss') }}" x-on:click="shown = false">
<x-icon name="close" class="size-5" />
<x-livewire-material::icon name="close" class="size-5" />
</button>
@endif
</div>
@@ -0,0 +1,152 @@
{{-- The adaptive app shell: navigation that changes shape with the window, around the page.
<x-app-shell :destinations="[
['title' => 'Shares', 'icon' => 'folder_shared', 'url' => route('shares'), 'active' => request()->routeIs('shares*'), 'badge' => 3],
['title' => 'Upload', 'icon' => 'upload', 'url' => route('upload')],
['title' => 'Users', 'icon' => 'group', 'url' => route('users'), 'section' => 'Admin', 'bar' => false],
]">
<x-slot:brand><a href="/" wire:navigate class="type-title-lg">SealShare</a></x-slot:brand>
<x-slot:top>…the page's app bar…</x-slot:top>
…the page…
</x-app-shell>
- Below `sm`: a navigation bar with the destinations marked `bar`, pinned to the bottom.
Everything else is in the modal rail, which slides in when something calls
`$store.rail.show()` put a menu button in the app bar for it, hidden from `sm`:
`<span class="sm:hidden"><x-button icon="menu" tooltip="Open navigation" x-on:click="$store.rail.show()" /></span>`.
- `sm` to `lg`: the collapsed rail, whose menu button opens it expanded, as a modal.
- From `lg`: the expanded rail, collapsed and expanded again by its menu button; the choice is
remembered and applied before the first paint (`$store.rail`, <x-theme-script>).
`destinations` is a list of arrays: `title`, `icon` (a Material Symbol), `url`, and optionally
`active` (by default: the URL is the page's; during a Livewire update request, the page the
component was rendered on rather than the update endpoint), `badge` (`true` for a dot, or a count), `badgeLabel` (what a screen reader hears for
the badge instead: "3 unread"),
`section` (a heading the destination is grouped under in the rail; only an expanded rail shows
it), `bar` (`false` keeps it out of the bottom bar; M3 wants three to five there) and
`navigate` (`false` for a full page load instead of `wire:navigate`).
Slots, each rendered once: `brand` (beside the rail's menu button while it is expanded),
`rail-header` (under it: a FAB see `<x-navigation-rail>` for its two shapes), `rail-footer`
(at the foot of the rail: footer destinations, an account), `actions` (a row of icon buttons at
the very foot, stacked when the rail is collapsed: a theme toggle, sign out), `top` (the app
bar, above the page at every width) and the page itself. The rail is one element at every
width, so what is in it is also in the modal rail a phone opens. `label` names both navigation
landmarks ("Main"); `rail-width` is the expanded rail's width.
The page is `<main id="content">` with `wire:transition.navigate`, behind a skip link that is
the first thing a keyboard reaches. The snackbar host (`<x-toast />`) is part of the shell;
below `sm` it, and a `fab` button, sit above the bottom bar through `--material-bottom-bar`:
the bar's 64px, the bottom safe area (`--material-safe-bottom`, else the device's inset) and
`--material-bottom-extra` (0px unless the application docks something, an offline banner, on
top of the bar).
`max-lg:overflow-x-clip` on the content region is the backstop under every page, and it stays
`clip`: `overflow-x: hidden` would force `overflow-y` to `auto`, turn the region into a scroll
container and break every `position: sticky` inside it (an app bar, a list-detail pane). Below
`lg` only, so a wide window never clips what overhangs on purpose.
Nothing application-specific belongs in here: an app's destinations and chrome come in through
the props and slots. --}}
@props([
'destinations' => [],
'label' => null,
'railWidth' => '16rem',
])
@php
$label ??= __('Main');
$current = \Livewire\Livewire::isLivewireRequest() ? \Livewire\Livewire::originalUrl() : request()->url();
$items = collect($destinations)
->filter(fn ($item): bool => is_array($item) && filled($item['title'] ?? null))
->map(fn (array $item): array => [
'title' => (string) $item['title'],
'icon' => $item['icon'] ?? null,
'url' => $item['url'] ?? null,
'active' => (bool) ($item['active'] ?? (filled($item['url'] ?? null) && rtrim(url($item['url']), '/') === rtrim($current, '/'))),
'badge' => $item['badge'] ?? null,
'badgeLabel' => filled($item['badgeLabel'] ?? null) ? (string) $item['badgeLabel'] : null,
'section' => filled($item['section'] ?? null) ? (string) $item['section'] : null,
'bar' => ($item['bar'] ?? true) !== false,
'navigate' => ($item['navigate'] ?? true) !== false,
])
->values();
$barItems = $items->where('bar', true)->values();
// Consecutive destinations under the same heading form one group, in the order given.
$groups = $items->chunkWhile(fn (array $item, int $key, $chunk): bool => $item['section'] === $chunk->last()['section']);
@endphp
<div
data-app-shell
@class([
'min-h-dvh bg-surface text-on-surface sm:flex',
'max-sm:[--material-bottom-bar:calc(4rem+var(--material-safe-bottom,env(safe-area-inset-bottom))+var(--material-bottom-extra,0px))]' => $barItems->isNotEmpty(),
])
>
<a
href="#content"
data-skip-link
class="sr-only focus:not-sr-only focus:fixed focus:start-4 focus:top-[calc(var(--material-safe-top,env(safe-area-inset-top))+1rem)] focus:z-[60] focus:rounded-corner-full focus:bg-inverse-surface focus:px-4 focus:py-2 focus:type-label-lg focus:text-inverse-on-surface focus:shadow-elevation-3 focus:outline-none"
>{{ __('Skip to content') }}</a>
<x-livewire-material::navigation-rail mode="adaptive" :label="$label" :width="$railWidth">
@isset($brand)
<x-slot:brand>{{ $brand }}</x-slot:brand>
@endisset
@isset($railHeader)
<x-slot:header>{{ $railHeader }}</x-slot:header>
@endisset
@foreach ($groups as $group)
@if ($group->first()['section'] !== null)
<x-livewire-material::navigation-rail-section :label="$group->first()['section']">
@foreach ($group as $item)
<x-livewire-material::navigation-rail-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :badge-label="$item['badgeLabel']" :no-wire-navigate="! $item['navigate']" />
@endforeach
</x-livewire-material::navigation-rail-section>
@else
@foreach ($group as $item)
<x-livewire-material::navigation-rail-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :badge-label="$item['badgeLabel']" :no-wire-navigate="! $item['navigate']" />
@endforeach
@endif
@endforeach
@if (isset($railFooter) || isset($actions))
<x-slot:footer>
{{ $railFooter ?? '' }}
@isset($actions)
<div data-app-shell-actions class="flex items-center gap-1 px-5 pt-2 rail-collapsed:flex-col">
{{ $actions }}
</div>
@endisset
</x-slot:footer>
@endif
</x-livewire-material::navigation-rail>
<div class="flex min-w-0 flex-1 flex-col">
{{ $top ?? '' }}
<main id="content" tabindex="-1" wire:transition.navigate class="min-w-0 flex-1 outline-none max-lg:overflow-x-clip max-sm:pb-(--material-bottom-bar)">
{{ $slot }}
</main>
</div>
@if ($barItems->isNotEmpty())
<div data-app-shell-bar class="fixed inset-x-0 bottom-0 z-30 sm:hidden">
<x-livewire-material::navigation-bar :label="$label">
@foreach ($barItems as $item)
<x-livewire-material::navigation-bar-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :badge-label="$item['badgeLabel']" :no-wire-navigate="! $item['navigate']" />
@endforeach
</x-livewire-material::navigation-bar>
</div>
@endif
<x-livewire-material::toast />
</div>
+30 -9
View File
@@ -10,8 +10,18 @@
<span class="relative inline-flex"><x-icon name="notifications" /><x-badge value="4" floating /></span>
The status label is not an M3 badge but every app needs one: `tonal` draws the value in the
colour's container ("Expired" in error-container), `outline` in a neutral edge. `color` (alias
`tone`): `error` (the default), `primary`, `secondary`, `tertiary`, `success`, `warning`, `info`.
colour's container ("Expired" in error-container), `solid` in the colour itself (a label that
has to stand out, "Built in" in primary), `outline` in a neutral edge. `color` (alias
`tone`): `error` (the default), `primary`, `secondary`, `tertiary`, `success`, `warning`, `info`,
and two without a hue of their own:
- `neutral` neutral ink on every variant: on-surface-variant with surface text as a dot or
count (the ink an outline badge already writes in), surface-container-high with
on-surface-variant text when `tonal`, the outline-variant edge when `outline`.
- `plain` no background, text or border colour at all, only shape, size and type, so the
caller's classes paint it: `<x-badge value="Run" tonal color="plain" class="bg-tertiary-container text-on-tertiary-container" />`.
The value is `value`, or the slot, which renders as HTML an icon beside the word:
`<x-badge tonal><x-icon name="bolt" class="size-3" /> Pro</x-badge>`. With neither it is a dot.
A count or dot says nothing to a screen reader on its own: give the icon's control a label
that includes it ("Notifications, 4 new"), or pass `label` here. --}}
@@ -22,29 +32,41 @@
'color' => null,
'tone' => null,
'tonal' => false,
'solid' => false,
'outline' => false,
'floating' => false,
'label' => null,
])
@php
$color = in_array($color ?? $tone, ['primary', 'secondary', 'tertiary', 'error', 'success', 'warning', 'info'], true) ? ($color ?? $tone) : 'error';
$text = $value ?? ($slot->isNotEmpty() ? trim((string) $slot) : null);
$color = in_array($color ?? $tone, ['primary', 'secondary', 'tertiary', 'error', 'success', 'warning', 'info', 'neutral', 'plain'], true) ? ($color ?? $tone) : 'error';
// hasActualContent(): a slot holding only a comment, or an empty @foreach, is still a dot.
$markup = $value === null && $slot->hasActualContent();
$text = $value ?? ($markup ? trim((string) $slot) : null);
$dot = blank($text);
$status = ($tonal || $outline) && ! $dot;
$status = ($tonal || $solid || $outline) && ! $dot;
if (! $dot && $max !== null && is_numeric($text) && (int) $text > (int) $max) {
$text = $max.'+';
$markup = false;
}
$filled = [
'primary' => 'bg-primary text-on-primary', 'secondary' => 'bg-secondary text-on-secondary', 'tertiary' => 'bg-tertiary text-on-tertiary',
'error' => 'bg-error text-on-error', 'success' => 'bg-success text-on-success', 'warning' => 'bg-warning text-on-warning', 'info' => 'bg-info text-on-info',
'neutral' => 'bg-on-surface-variant text-surface',
];
$container = [
'primary' => 'bg-primary-container text-on-primary-container', 'secondary' => 'bg-secondary-container text-on-secondary-container', 'tertiary' => 'bg-tertiary-container text-on-tertiary-container',
'error' => 'bg-error-container text-on-error-container', 'success' => 'bg-success-container text-on-success-container', 'warning' => 'bg-warning-container text-on-warning-container', 'info' => 'bg-info-container text-on-info-container',
'neutral' => 'bg-surface-container-high text-on-surface-variant',
];
$paint = match (true) {
$color === 'plain' => '',
! $status, $solid => $filled[$color],
$tonal => $container[$color],
default => 'border-outline-variant text-on-surface-variant',
};
$attributes = $attributes
->class([
@@ -52,9 +74,8 @@
'size-1.5 rounded-corner-full' => $dot,
'h-4 min-w-4 rounded-corner-full px-1 type-label-sm tabular-nums' => ! $dot && ! $status,
'h-6 gap-1 rounded-corner-sm px-2 type-label-md' => $status,
$filled[$color] => ! $status,
$container[$color] => $tonal && ! $dot,
'border border-outline-variant text-on-surface-variant' => $outline && ! $tonal && ! $dot,
'border' => $outline && ! $tonal && ! $solid && ! $dot,
$paint => $paint !== '',
'absolute top-0.5 end-0.5' => $floating && $dot,
'absolute -top-1 start-[calc(100%-0.75rem)]' => $floating && ! $dot,
])
@@ -64,4 +85,4 @@
]));
@endphp
<span {{ $attributes }}>@unless ($dot){{ $text }}@endunless</span>
<span {{ $attributes }}>@unless ($dot)@if ($markup){{ $slot }}@else{{ $text }}@endif@endunless</span>
@@ -52,7 +52,7 @@
@if (filled($title)) aria-labelledby="{{ $id }}-title" @endif
style="--sheet-max-height: {{ $height }}"
{{ $attributes->whereDoesntStartWith('wire:model')->except(['id', 'class'])->class([
'fixed inset-x-0 bottom-0 z-50 mx-auto flex max-h-(--sheet-max-height) w-full max-w-160 touch-pan-y flex-col rounded-t-corner-xl bg-surface-container-low pb-[env(safe-area-inset-bottom)] text-on-surface shadow-elevation-1',
'fixed inset-x-0 bottom-0 z-50 mx-auto flex max-h-(--sheet-max-height) w-full max-w-160 touch-pan-y flex-col rounded-t-corner-xl bg-surface-container-low pb-[var(--material-safe-bottom,env(safe-area-inset-bottom))] text-on-surface shadow-elevation-1',
$attributes->get('class'),
]) }}
>
+9 -7
View File
@@ -13,8 +13,8 @@
With an `icon` and no label it is an icon button: `width` is `narrow`, `default` or `wide`,
`variant="text"` is M3's standard icon button, and the tooltip or label names it for screen
readers. `selected` makes it a toggle: `true` or `false` sets `aria-pressed` and M3's
selected colours, and a selected round button turns square (a selected square icon button
readers. `selected` makes it a toggle: `true` or `false` sets `aria-pressed` (not on a `link`,
which is no toggle give it `aria-current` instead) and M3's selected colours, and a selected round button turns square (a selected square icon button
turns round). Text buttons are not toggles in M3; a selected one takes the tonal container.
Values from androidx Compose Material 3's tokens (Button*Tokens, *IconButtonTokens,
@@ -191,7 +191,9 @@
'type' => $isLink ? null : $type,
'disabled' => ! $isLink && $disabled ? true : null,
'aria-label' => $iconOnly && ! $attributes->has('aria-label') ? ($label ?? $tip) : null,
'aria-pressed' => $selected === null ? null : ($selected ? 'true' : 'false'),
// A link is not a toggle: ARIA defines aria-pressed for buttons only. A selected link keeps
// the selected look; `aria-current` is the caller's to set (`:aria-current="'page'"`).
'aria-pressed' => $selected === null || $isLink ? null : ($selected ? 'true' : 'false'),
'data-icon-button' => $iconOnly ? true : null,
'wire:loading.attr' => $spinnerTarget ? 'disabled' : null,
'wire:target' => $spinnerTarget,
@@ -202,13 +204,13 @@
<{{ $tag }} {{ $attributes }}>
@if ($spinnerTarget)
<span wire:loading.flex wire:target="{{ $spinnerTarget }}" class="items-center justify-center">
<x-loading :class="$iconSize" :label="false" />
<x-livewire-material::loading :class="$iconSize" :label="false" />
</span>
@endif
@if ($icon)
<span class="contents" @if ($spinnerTarget) wire:loading.remove wire:target="{{ $spinnerTarget }}" @endif>
<x-icon :name="$icon" :filled="$selected === true" :class="\Illuminate\Support\Arr::toCssClasses([$iconSize, 'max-sm:size-6' => $fab])" />
<x-livewire-material::icon :name="$icon" :filled="$selected === true" :class="\Illuminate\Support\Arr::toCssClasses([$iconSize, 'max-sm:size-6' => $fab])" />
</span>
@endif
@@ -217,10 +219,10 @@
@endunless
@if ($iconRight)
<x-icon :name="$iconRight" :class="$iconSize" />
<x-livewire-material::icon :name="$iconRight" :class="$iconSize" />
@endif
@if ($tip !== null)
<x-tooltip :text="$tip" :side="$tipSide" :anchor="$anchor" />
<x-livewire-material::tooltip :text="$tip" :side="$tipSide" :anchor="$anchor" />
@endif
</{{ $tag }}>
+1 -1
View File
@@ -60,7 +60,7 @@
</div>
@if ($separator)
<x-divider class="mt-4" />
<x-livewire-material::divider class="mt-4" />
@endif
</div>
@endif
@@ -44,7 +44,9 @@
longer slides inside its mask. RTL mirrors the keylines, keys and buttons.
Re-measures itself when resized, when a Livewire morph resets its styles and when items
come and go. --}}
come and go. The row's id, which the buttons control, is new with every render; the row
carries a `wire:key` (see `<x-menu>`), so a morph patches it in place its scroll position
and listeners kept rather than swapping in a copy. --}}
@props([
'layout' => 'multi-browse',
@@ -116,6 +118,7 @@
<div
x-ref="scroller"
{{ new \Illuminate\View\ComponentAttributeBag(['wire:key' => 'material-carousel']) }}
id="{{ $scrollerId }}"
role="region"
aria-roledescription="{{ __('carousel') }}"
@@ -136,7 +139,7 @@
'hidden pointer-fine:flex' => $controls === null,
'flex' => $controls === true,
])>
<x-button
<x-livewire-material::button
icon="chevron_left"
variant="tonal"
:tooltip="__('Previous')"
@@ -145,7 +148,7 @@
x-on:click="previous()"
class="rtl:-scale-x-100"
/>
<x-button
<x-livewire-material::button
icon="chevron_right"
variant="tonal"
:tooltip="__('Next')"
@@ -18,7 +18,9 @@
@php
$model = $attributes->whereStartsWith('wire:model')->first();
$id = $attributes->get('id') ?? 'check-'.substr(md5($model.'|'.$label.'|'.$attributes->get('value')), 0, 12);
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : [];
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
@endphp
<div {{ $attributes->only(['class', 'wire:key'])->class(['min-w-0']) }}>
@@ -36,8 +38,8 @@
@if ($indeterminate) data-indeterminate @endif
@if ($messages !== []) aria-invalid="true" aria-describedby="{{ $id }}-support" @endif
/>
<x-icon name="check" class="size-4" data-check />
<x-icon name="remove" class="size-4" data-mixed />
<x-livewire-material::icon name="check" class="size-4" data-check />
<x-livewire-material::icon name="remove" class="size-4" data-mixed />
</span>
@if (filled($label) || filled($hint))
+8 -8
View File
@@ -230,7 +230,7 @@
<img src="{{ $avatar }}" alt="" @class(['me-2 size-6 shrink-0 rounded-corner-full object-cover', 'opacity-38' => $disabled]) />
@endif
@elseif ($icon)
<x-icon :name="$icon" :class="\Illuminate\Support\Arr::toCssClasses(['me-2 size-4.5', $leadingInk])" />
<x-livewire-material::icon :name="$icon" :class="\Illuminate\Support\Arr::toCssClasses(['me-2 size-4.5', $leadingInk])" />
@endif
<span data-chip-label class="truncate">{{ $label ?? $slot }}</span>
@@ -251,7 +251,7 @@
'cursor-not-allowed' => $disabled,
])
>
<x-icon name="close" class="size-4.5" />
<x-livewire-material::icon name="close" class="size-4.5" />
</button>
@endif
@@ -260,7 +260,7 @@
@endif
@if ($tooltip !== null)
<x-tooltip :text="$tooltip" :anchor="$anchor" />
<x-livewire-material::tooltip :text="$tooltip" :anchor="$anchor" />
@endif
</span>
@else
@@ -277,22 +277,22 @@
'h-4.5 w-0 transition-[width] duration-(--md-sys-motion-effects-default-duration) ease-effects-default group-has-checked/chip:w-4.5 group-has-checked/chip:duration-(--md-sys-motion-spatial-fast-duration) group-has-checked/chip:ease-spatial-fast group-aria-pressed/chip:w-4.5 group-aria-pressed/chip:duration-(--md-sys-motion-spatial-fast-duration) group-aria-pressed/chip:ease-spatial-fast' => blank($icon),
])>
@if ($icon)
<x-icon :name="$icon" :class="\Illuminate\Support\Arr::toCssClasses(['size-4.5 transition-opacity duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast group-has-checked/chip:opacity-0 group-aria-pressed/chip:opacity-0', $leadingInk])" />
<x-livewire-material::icon :name="$icon" :class="\Illuminate\Support\Arr::toCssClasses(['size-4.5 transition-opacity duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast group-has-checked/chip:opacity-0 group-aria-pressed/chip:opacity-0', $leadingInk])" />
@endif
<x-icon name="check" data-chip-check :class="$check" />
<x-livewire-material::icon name="check" data-chip-check :class="$check" />
</span>
@elseif ($icon)
<x-icon :name="$icon" :class="\Illuminate\Support\Arr::toCssClasses(['me-2 size-4.5', $leadingInk])" />
<x-livewire-material::icon :name="$icon" :class="\Illuminate\Support\Arr::toCssClasses(['me-2 size-4.5', $leadingInk])" />
@endif
<span class="truncate">{{ $label ?? $slot }}</span>
@if ($iconRight)
<x-icon :name="$iconRight" :class="\Illuminate\Support\Arr::toCssClasses(['ms-2 size-4.5', $trailingInk])" />
<x-livewire-material::icon :name="$iconRight" :class="\Illuminate\Support\Arr::toCssClasses(['ms-2 size-4.5', $trailingInk])" />
@endif
@if ($tooltip !== null)
<x-tooltip :text="$tooltip" :anchor="$anchor" />
<x-livewire-material::tooltip :text="$tooltip" :anchor="$anchor" />
@endif
</{{ $tag }}>
@endif
+11 -9
View File
@@ -35,8 +35,10 @@
@php
$model = $attributes->wire('model')->value() ?: null;
$messages = $model !== null && isset($errors)
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($model), $errors->get($model.'.*')])))
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors)
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($errorKey), $errors->get($errorKey.'.*')])))
: [];
$choices = collect($options)->map(fn ($option): array => [
'value' => data_get($option, $optionValue),
@@ -115,7 +117,7 @@
@if ($model === null) x-modelable="value" @endif
>
<div style="anchor-name: {{ $anchor }}">
<x-field :$id :$label :$hint :$messages :$icon :$variant>
<x-livewire-material::field :$id :$label :$hint :$messages :$icon :$variant>
<input
id="{{ $id }}"
type="text"
@@ -144,9 +146,9 @@
/>
<x-slot:trailing>
<x-icon name="arrow_drop_down" class="field-trailing field-arrow size-(--field-icon)" />
<x-livewire-material::icon name="arrow_drop_down" class="field-trailing field-arrow size-(--field-icon)" />
</x-slot:trailing>
</x-field>
</x-livewire-material::field>
</div>
<ul
@@ -170,7 +172,7 @@
class="field-option"
>
<span x-text="option.label"></span>
<x-icon name="check" class="field-check size-6" />
<x-livewire-material::icon name="check" class="field-check size-6" />
</li>
</template>
<li x-show="filtered.length === 0" class="px-4 py-3 type-body-md text-on-surface-variant">{{ __('Nothing matches') }}</li>
@@ -202,9 +204,9 @@
}"
@if ($model === null) x-modelable="selection" @endif
>
<x-chip-set :$label :$hint :error-field="$model">
<x-livewire-material::chip-set :$label :$hint :error-field="$model">
@foreach ($choices as $index => $choice)
<x-chip
<x-livewire-material::chip
type="filter"
:label="$choice['label']"
:selected="$isSelected($choice['value'])"
@@ -213,6 +215,6 @@
x-on:click="toggle({{ $index }})"
/>
@endforeach
</x-chip-set>
</x-livewire-material::chip-set>
</div>
@endif
+32 -5
View File
@@ -6,7 +6,18 @@
`heading` slot), an optional leading `icon`, a chevron that turns over on the spatial spring,
and where the browser supports animating `details` content (`interpolate-size`) a height
that eases open. `open` starts it open. `variant`: `plain` (the default, on the surface around
it) or `filled` (a surface-container tile with a large corner). --}}
it) or `filled` (a surface-container tile with a large corner).
Its open state can be bound, both ways:
- `wire:model` (any modifiers; `.live` tells the server at once) entangles it with a Livewire
boolean property. The server renders `open` from the property, so the first paint matches it
and `open` is ignored; toggling writes the property, and the property changing opens or
closes it.
- `x-model` binds an Alpine property through `x-modelable`; `open` is then only the first
paint, until Alpine starts and applies the property.
The state lives in `collapseOpen` on the `<details>`, a name kept clear of `open`, which a
dialog inside the slot may be reading from a scope around it. Without a binding there is no
Alpine on it at all. --}}
@props([
'title' => null,
@@ -15,10 +26,26 @@
'variant' => 'plain',
])
@php
$model = $attributes->wire('model')->value() ?: null;
$bound = $model !== null || count($attributes->whereStartsWith('x-model')->getAttributes()) > 0;
$expanded = (bool) $open;
if ($model !== null && ($component = \Livewire\Livewire::current()) !== null) {
$expanded = (bool) data_get($component, $model);
}
@endphp
<details
wire:ignore.self
@if ($open) open @endif
{{ $attributes->class([
@if ($bound)
x-data="{ collapseOpen: @if ($model !== null) @entangle($attributes->wire('model')) @else @js($expanded) @endif }"
@if ($model === null) x-modelable="collapseOpen" @endif
x-effect="$el.open = collapseOpen"
x-on:toggle="collapseOpen = $el.open"
@endif
@if ($expanded) open @endif
{{ $attributes->whereDoesntStartWith('wire:model')->class([
'group/collapse [interpolate-size:allow-keywords]',
'rounded-corner-lg bg-surface-container' => $variant === 'filled',
]) }}
@@ -28,12 +55,12 @@
'px-4' => $variant === 'filled',
])>
@if ($icon)
<x-icon :name="$icon" class="size-6 text-on-surface-variant" />
<x-livewire-material::icon :name="$icon" class="size-6 text-on-surface-variant" />
@endif
<span class="min-w-0 flex-1">{{ $heading ?? $title }}</span>
<x-icon name="expand_more" class="size-6 text-on-surface-variant transition-[rotate] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast group-open/collapse:rotate-180" />
<x-livewire-material::icon name="expand_more" class="size-6 text-on-surface-variant transition-[rotate] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast group-open/collapse:rotate-180" />
</summary>
<div @class(['pb-4 type-body-md text-on-surface-variant', 'px-4' => $variant === 'filled', 'pt-1'])>
@@ -0,0 +1,478 @@
{{-- A date field with M3's date pickers: docked under the field, modal, or modal text input.
`mode` picks M3's three:
- `docked` (the default): a text field that takes a typed date in the locale's numeric format
(`13.09.2026` in `de`, `09/13/2026` in `en-US`), with the calendar dropping open under it
on a press of the field, its calendar button, or ArrowDown. On a compact window (below
`sm`) the calendar opens as the modal picker instead, where a docked one would not fit.
- `modal`: the field only shows the date; pressing it (or Enter, Space, ArrowDown) opens the
calendar in a dialog, with a pencil to switch to typing.
- `input`: the same dialog, opened on its text field, with a calendar icon to switch back.
The calendar and the dialog's text field pick a draft; OK (or Enter on a day) makes it the
value, Cancel or Escape leaves the value alone and returns focus to the field. What is typed
into the docked field is the value as soon as it is a whole, allowed date.
`wire:model` stores `Y-m-d` strings (`x-model` without Livewire). `range` picks a start and an
end, bound as one array, `['start' => 'Y-m-d', 'end' => 'Y-m-d']` (either may be null)
one property rather than two, because a `wire:model` names one property, Livewire sends a
range as one update so `after_or_equal:period.start` validates against the end it came
with, and the errors for `period`, `period.start` and `period.end` all belong to this field.
`min` and `max` (`Y-m-d` or a date object) disable the days outside them and keep the
keyboard inside them. `label`, `hint`, `icon`, `variant` (`outlined`, `filled`) and `size`
are the field's; `clearable` adds a button that empties it (a date, or both ends of a range)
once it holds one; `name` adds hidden inputs carrying `Y-m-d` for a plain form post, and every
other attribute (`required`, `disabled`, `readonly`) reaches the text field. Errors under
the `wire:model` name replace the hint, and so does a typed date that cannot be read.
Month and weekday names, the first day of the week and the typed format come from `Intl` for
`app()->getLocale()` (resources/js/datepicker.js). An application that lets each person choose
overrides the last two: `week-start` is the first day of the week, 0 (Sunday) to 6 (Saturday),
and `format` the typed and displayed format, `dd`, `MM` and `yyyy` in any order around one
delimiter (`.`, `/` or `-`: `dd.MM.yyyy`, `MM/dd/yyyy`, `yyyy-MM-dd`). The field, the typed-date
reader, the calendar's columns and weekday header, Home and End, and the dialog's text fields
all follow them; the names stay the locale's, and `wire:model` still stores `Y-m-d`. A value
that is neither (`week-start="7"`, `format="d.M.yy"`) is ignored, as `null` is. Replaces
ReStride's flatpickr picker: its `config` becomes `min`, `max`, `range` and `mode`.
M3's date pickers (DatePickerModalTokens and DateInputModalTokens from androidx Compose
Material 3, androidx commit 27cf9a7d5788aa0f5f2d8b6699ce279560daf326, with the layout of
DatePicker.kt, DateRangePicker.kt and DateInput.kt; the docked picker's values from
material-web's md-comp-date-picker-docked tokens, v0_192, as Compose has none; all Apache-2.0):
surface-container-high at elevation 3, 360px wide, the extra-large corner (modal) or large
(docked), 40px days in 48px cells, today outlined in primary, the chosen day in primary and a
range's middle in secondary-container. The styles are resources/css/components/datepicker.css. --}}
@props([
'label' => null,
'hint' => null,
'icon' => null,
'size' => 'md',
'variant' => null,
'mode' => 'docked',
'range' => false,
'min' => null,
'max' => null,
'value' => null,
'clearable' => false,
'weekStart' => null,
'format' => null,
])
@php
$model = $attributes->wire('model')->value() ?: null;
$mode = in_array($mode, ['docked', 'modal', 'input'], true) ? $mode : 'docked';
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|datepicker'), 0, 12);
$anchor = '--material-datepicker-'.preg_replace('/[^A-Za-z0-9_-]/', '-', $id);
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors)
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($errorKey), $errors->get($errorKey.'.*')])))
: [];
$locale = str_replace('_', '-', app()->getLocale());
$weekStart = (is_int($weekStart) || is_string($weekStart)) && preg_match('/^[0-6]\z/', (string) $weekStart) === 1 ? (int) $weekStart : null;
$format = is_string($format) && preg_match('/^(dd|MM|yyyy)([.\/-])(dd|MM|yyyy)\2(dd|MM|yyyy)\z/', $format, $units) === 1 && count(array_unique([$units[1], $units[3], $units[4]])) === 3
? $format
: null;
$toIso = function (mixed $date): ?string {
if ($date instanceof \DateTimeInterface) {
return $date->format('Y-m-d');
}
if (is_string($date) && preg_match('/^(\d{4})-(\d{2})-(\d{2})(?:$|[T ])/', $date, $match) === 1 && checkdate((int) $match[2], (int) $match[3], (int) $match[1])) {
return substr($date, 0, 10);
}
return null;
};
// Rendered as the bound property already says, so the field does not change once Alpine starts.
$current = $value;
if ($model !== null && ($component = \Livewire\Livewire::current()) !== null) {
$current = data_get($component, $model);
}
$current = $range
? ['start' => $toIso(data_get($current, 'start')), 'end' => $toIso(data_get($current, 'end'))]
: $toIso($current);
// The typed format: `format`, or as resources/js/datepicker.js derives it, from ICU's short date when PHP has intl.
$pattern = $format ?? 'yyyy-MM-dd';
if ($format === null && class_exists(\IntlDateFormatter::class)) {
$short = (string) (new \IntlDateFormatter(str_replace('-', '_', $locale), \IntlDateFormatter::SHORT, \IntlDateFormatter::NONE))->getPattern();
$candidate = rtrim(str_replace('My', 'M/y', (string) preg_replace(['/[^dMy\/\-.]/', '/d{1,2}/', '/M{1,2}/', '/y{1,4}/'], ['', 'dd', 'MM', 'yyyy'], $short)), '.');
if (preg_match('/^(?=.*dd)(?=.*MM)(?=.*yyyy)[dMy]+[\/\-.][dMy]+[\/\-.][dMy]+$/', $candidate) === 1) {
$pattern = $candidate;
}
}
$typed = fn (?string $date): string => $date === null ? '' : strtr($pattern, ['yyyy' => substr($date, 0, 4), 'MM' => substr($date, 5, 2), 'dd' => substr($date, 8, 2)]);
$display = $range
? ($current['start'] === null && $current['end'] === null ? '' : $typed($current['start']).' '.$typed($current['end']))
: $typed($current);
$title = $range ? __('Select dates') : __('Select date');
$inputTitle = $range ? __('Enter dates') : __('Select date');
$fieldName = $attributes->get('name');
$invalid = $messages !== [];
$config = [
'locale' => $locale,
'mode' => $mode,
'range' => (bool) $range,
'min' => $toIso($min),
'max' => $toIso($max),
'weekStart' => $weekStart,
'format' => $format,
'disabled' => (bool) $attributes->get('disabled'),
'readonly' => (bool) $attributes->get('readonly'),
'strings' => [
'selected' => __('Selected date'),
'entered' => __('Entered date'),
'start' => __('Start date'),
'end' => __('End date'),
'pattern' => __('Date does not match expected pattern: :pattern'),
'yearRange' => __('Date out of expected year range :start - :end'),
'notAllowed' => __('Date not allowed: :date'),
'invalidRange' => __('Invalid date range input'),
],
];
@endphp
<div
{{ $attributes->only(['class', 'wire:key', 'x-model']) }}
x-data="materialDatepicker({
@if ($model !== null) value: @entangle($attributes->wire('model')), @else value: @js($current), @endif
...@js($config),
})"
@if ($model === null) x-modelable="value" @endif
x-on:focusout="leave($event)"
x-on:pointerdown.outside="open && presentation === 'docked' && cancel(false)"
data-datepicker
>
<div style="anchor-name: {{ $anchor }}">
<x-livewire-material::field
:$id
:$label
:$icon
:$size
:$variant
:data-invalid="$invalid ? '' : null"
:data-readonly="$attributes->get('readonly') ? '' : null"
x-bind:data-invalid="(fieldError !== '' || {{ $invalid ? 'true' : 'false' }}) ? '' : null"
>
<input
{{ $attributes->whereDoesntStartWith('wire:model')->except(['class', 'id', 'wire:key', 'x-model', 'name', 'value', 'placeholder', 'type']) }}
x-ref="input"
id="{{ $id }}"
type="text"
role="combobox"
aria-haspopup="dialog"
aria-controls="{{ $id }}-picker"
aria-expanded="false"
x-bind:aria-expanded="open.toString()"
aria-describedby="{{ $id }}-support"
@if ($invalid) aria-invalid="true" @endif
x-bind:aria-invalid="(fieldError !== '' || {{ $invalid ? 'true' : 'false' }}) ? 'true' : null"
autocomplete="off"
value="{{ $display }}"
placeholder=" "
class="field-control"
x-model="text"
@if ($mode === 'docked')
x-bind:placeholder="placeholder"
x-on:input="typeInField()"
x-on:change="commitField()"
x-on:keydown.enter="commitField()"
x-on:click="show(false)"
x-on:keydown.arrow-down.prevent="open ? focusInside() : show()"
x-on:keydown.escape="if (open) { $event.preventDefault(); $event.stopPropagation(); cancel(); }"
@else
readonly
x-on:click="show()"
x-on:keydown.enter.prevent="show()"
x-on:keydown.space.prevent="show()"
x-on:keydown.arrow-down.prevent="show()"
@endif
/>
<x-slot:trailing>
@if ($clearable)
<button
type="button"
class="field-trailing field-clear field-button"
aria-label="{{ __('Clear') }}"
x-on:click="clear()"
@disabled($attributes->get('disabled') || $attributes->get('readonly'))
data-field-clear
>
<x-livewire-material::icon name="close" class="size-(--field-icon)" />
</button>
@endif
<button
type="button"
class="field-trailing field-button"
aria-label="{{ $title }}"
aria-haspopup="dialog"
aria-controls="{{ $id }}-picker"
x-bind:aria-expanded="open.toString()"
x-on:click="open ? cancel() : show()"
@if ($mode !== 'docked') tabindex="-1" @endif
@disabled($attributes->get('disabled') || $attributes->get('readonly'))
data-datepicker-toggle
>
<x-livewire-material::icon name="calendar_today" class="size-(--field-icon)" />
</button>
</x-slot:trailing>
</x-livewire-material::field>
</div>
<div id="{{ $id }}-support" data-datepicker-support data-size="{{ in_array($size, ['sm', 'xs'], true) ? $size : 'md' }}">
<p x-cloak x-show="fieldError !== ''" x-text="fieldError" role="alert" data-datepicker-error></p>
@if ($invalid)
<div x-show="fieldError === ''" role="alert" data-datepicker-error>
@foreach ($messages as $message)
<p>{{ $message }}</p>
@endforeach
</div>
@elseif (filled($hint))
<p x-show="fieldError === ''">{{ $hint }}</p>
@endif
</div>
@if (filled($fieldName))
@if ($range)
<input type="hidden" name="{{ $fieldName }}[start]" value="{{ $current['start'] }}" x-bind:value="serialised.start ?? ''" />
<input type="hidden" name="{{ $fieldName }}[end]" value="{{ $current['end'] }}" x-bind:value="serialised.end ?? ''" />
@else
<input type="hidden" name="{{ $fieldName }}" value="{{ $current }}" x-bind:value="serialised" />
@endif
@endif
<dialog
wire:ignore
x-ref="dialog"
id="{{ $id }}-picker"
popover="manual"
aria-label="{{ $title }}"
style="position-anchor: {{ $anchor }}"
x-bind:data-presentation="presentation"
x-on:cancel.prevent="cancel()"
x-on:click.self="presentation === 'modal' && cancel()"
x-on:keydown.escape.prevent.stop="cancel()"
data-datepicker-picker
>
<div tabindex="-1" data-datepicker-surface>
<div data-datepicker-header x-show="presentation === 'modal'" @if ($range) data-range @endif>
<p data-datepicker-title x-text="typing ? @js($inputTitle) : @js($title)">{{ $title }}</p>
<div data-datepicker-headline-row>
<p data-datepicker-headline aria-live="polite" x-text="headline"></p>
<span x-show="! typing">
<x-livewire-material::button icon="edit" :tooltip="__('Switch to text input mode')" x-on:click="toggleTyping()" data-datepicker-switch />
</span>
<span x-show="typing">
<x-livewire-material::button icon="date_range" :tooltip="__('Switch to calendar input mode')" x-on:click="toggleTyping()" data-datepicker-switch />
</span>
</div>
</div>
<div x-show="! typing" data-datepicker-calendar>
<span id="{{ $id }}-month" class="sr-only" aria-live="polite" x-text="monthYear"></span>
<div data-datepicker-nav x-show="presentation === 'modal'">
<button
type="button"
data-datepicker-menu-button
x-on:click="toggleView('years')"
x-bind:aria-expanded="(view === 'years').toString()"
x-bind:aria-label="monthYear + ', ' + @js(__('Switch to selecting a year'))"
>
<span x-text="monthYear"></span>
<x-livewire-material::icon name="arrow_drop_down" class="size-4.5" data-datepicker-menu-arrow />
</button>
<span data-datepicker-arrows x-bind:data-concealed="view !== 'days' ? '' : null">
<x-livewire-material::button icon="chevron_left" :tooltip="__('Previous month')" x-on:click="step(-1)" x-bind:disabled="view !== 'days' || ! canStep(-1)" data-datepicker-previous />
<x-livewire-material::button icon="chevron_right" :tooltip="__('Next month')" x-on:click="step(1)" x-bind:disabled="view !== 'days' || ! canStep(1)" data-datepicker-next />
</span>
</div>
<div data-datepicker-nav data-docked x-show="presentation === 'docked'">
<span data-datepicker-stepper>
<span data-datepicker-arrows x-bind:data-concealed="view !== 'days' ? '' : null">
<x-livewire-material::button icon="chevron_left" :tooltip="__('Previous month')" x-on:click="step(-1)" x-bind:disabled="view !== 'days' || ! canStep(-1)" />
</span>
<button
type="button"
data-datepicker-menu-button
x-on:click="toggleView('months')"
x-bind:aria-expanded="(view === 'months').toString()"
x-bind:aria-label="monthLabel + ', ' + @js(__('Switch to selecting a month'))"
>
<span x-text="monthLabel"></span>
<x-livewire-material::icon name="arrow_drop_down" class="size-4.5" data-datepicker-menu-arrow />
</button>
<span data-datepicker-arrows x-bind:data-concealed="view !== 'days' ? '' : null">
<x-livewire-material::button icon="chevron_right" :tooltip="__('Next month')" x-on:click="step(1)" x-bind:disabled="view !== 'days' || ! canStep(1)" />
</span>
</span>
<span data-datepicker-stepper>
<span data-datepicker-arrows x-bind:data-concealed="view !== 'days' ? '' : null">
<x-livewire-material::button icon="chevron_left" :tooltip="__('Previous year')" x-on:click="step(-12)" x-bind:disabled="view !== 'days' || ! canStep(-12)" />
</span>
<button
type="button"
data-datepicker-menu-button
x-on:click="toggleView('years')"
x-bind:aria-expanded="(view === 'years').toString()"
x-bind:aria-label="yearLabel + ', ' + @js(__('Switch to selecting a year'))"
>
<span x-text="yearLabel"></span>
<x-livewire-material::icon name="arrow_drop_down" class="size-4.5" data-datepicker-menu-arrow />
</button>
<span data-datepicker-arrows x-bind:data-concealed="view !== 'days' ? '' : null">
<x-livewire-material::button icon="chevron_right" :tooltip="__('Next year')" x-on:click="step(12)" x-bind:disabled="view !== 'days' || ! canStep(12)" />
</span>
</span>
</div>
<table role="grid" aria-labelledby="{{ $id }}-month" x-show="view === 'days'" x-on:keydown="gridKey($event)" data-datepicker-grid>
<thead>
<tr>
<template x-for="weekday in weekdays" :key="weekday.long">
<th scope="col" x-bind:abbr="weekday.long" x-text="weekday.narrow"></th>
</template>
</tr>
</thead>
<tbody>
<template x-for="(week, row) in weeks" :key="row">
<tr>
<template x-for="(cell, column) in week" :key="column">
<td
role="gridcell"
x-bind:tabindex="cell.blank ? null : (cell.focused ? 0 : -1)"
x-bind:aria-label="cell.blank ? null : cell.name"
x-bind:aria-selected="! cell.blank && (cell.selected || cell.between) ? 'true' : null"
x-bind:aria-disabled="! cell.blank && cell.disabled ? 'true' : null"
x-bind:aria-current="! cell.blank && cell.today ? 'date' : null"
x-bind:data-value="cell.blank ? null : cell.value"
x-bind:data-blank="cell.blank ? '' : null"
x-bind:data-outside="cell.outside ? '' : null"
x-bind:data-today="cell.today ? '' : null"
x-bind:data-selected="cell.selected ? '' : null"
x-bind:data-start="cell.start ? '' : null"
x-bind:data-end="cell.end ? '' : null"
x-bind:data-between="cell.between ? '' : null"
x-on:click="choose(cell)"
data-datepicker-day
><span x-text="cell.blank ? '' : cell.label"></span></td>
</template>
</tr>
</template>
</tbody>
</table>
<div role="listbox" aria-label="{{ __('Years') }}" x-show="view === 'years' && presentation === 'modal'" x-on:keydown="listKey($event, 3)" data-datepicker-list data-datepicker-years>
<template x-for="year in years" :key="year.value">
<button
type="button"
role="option"
x-bind:aria-selected="year.selected.toString()"
x-bind:tabindex="year.selected ? 0 : -1"
x-bind:data-current="year.current ? '' : null"
x-on:click="showMonthOf(year.value, +shown.slice(5, 7))"
x-text="year.label"
data-datepicker-year
></button>
</template>
</div>
<div role="listbox" aria-label="{{ __('Months') }}" x-show="view === 'months' && presentation === 'docked'" x-on:keydown="listKey($event)" data-datepicker-list data-datepicker-menu>
<template x-for="month in months" :key="month.value">
<button
type="button"
role="option"
x-bind:aria-selected="month.selected.toString()"
x-bind:aria-disabled="month.disabled ? 'true' : null"
x-bind:tabindex="month.selected ? 0 : -1"
x-on:click="month.disabled || showMonthOf(+shown.slice(0, 4), month.value)"
data-datepicker-option
>
<x-livewire-material::icon name="check" data-datepicker-check />
<span x-text="month.label"></span>
</button>
</template>
</div>
<div role="listbox" aria-label="{{ __('Years') }}" x-show="view === 'years' && presentation === 'docked'" x-on:keydown="listKey($event)" data-datepicker-list data-datepicker-menu>
<template x-for="year in years" :key="year.value">
<button
type="button"
role="option"
x-bind:aria-selected="year.selected.toString()"
x-bind:tabindex="year.selected ? 0 : -1"
x-on:click="showMonthOf(year.value, +shown.slice(5, 7))"
data-datepicker-option
>
<x-livewire-material::icon name="check" data-datepicker-check />
<span x-text="year.label"></span>
</button>
</template>
</div>
</div>
<div x-show="typing" data-datepicker-entry>
<div @if ($range) data-range @endif data-datepicker-entry-fields>
<x-livewire-material::field id="{{ $id }}-entry" :label="$range ? __('Start date') : __('Date')" :$variant x-bind:data-invalid="entryError !== '' ? '' : null">
<input
id="{{ $id }}-entry"
type="text"
autocomplete="off"
placeholder=" "
x-bind:placeholder="format.placeholder"
x-bind:aria-invalid="entryError !== '' ? 'true' : null"
aria-describedby="{{ $id }}-entry-support"
x-ref="entry"
x-model="entry"
x-on:input="typeEntry()"
x-on:keydown.enter.prevent="confirm()"
class="field-control"
/>
</x-livewire-material::field>
@if ($range)
<x-livewire-material::field id="{{ $id }}-entry-end" :label="__('End date')" :$variant x-bind:data-invalid="entryError !== '' ? '' : null">
<input
id="{{ $id }}-entry-end"
type="text"
autocomplete="off"
placeholder=" "
x-bind:placeholder="format.placeholder"
x-bind:aria-invalid="entryError !== '' ? 'true' : null"
aria-describedby="{{ $id }}-entry-support"
x-model="entryEnd"
x-on:input="typeEntry()"
x-on:keydown.enter.prevent="confirm()"
class="field-control"
/>
</x-livewire-material::field>
@endif
</div>
<div id="{{ $id }}-entry-support" data-datepicker-support>
<p x-show="entryError !== ''" x-text="entryError" role="alert" data-datepicker-error></p>
</div>
</div>
<div x-show="view === 'days' || typing || presentation === 'modal'" data-datepicker-actions>
<x-livewire-material::button :label="__('Cancel')" x-on:click="cancel()" data-datepicker-cancel />
<x-livewire-material::button :label="__('OK')" x-on:click="confirm()" data-datepicker-confirm />
</div>
</div>
</dialog>
</div>
+10 -7
View File
@@ -15,7 +15,9 @@
As a pane (`pane`, from `xl`) nothing is covered: the page renders the drawer after its list in
an `xl:flex xl:items-start xl:gap-6` row, the drawer sticks under the top of the viewport, the
list stays usable and another row swaps what it shows no scrim, no trap, no inert page. While
closed it takes no room. `pane-width` sizes the pane (the sheet's width by default). The body is
closed it takes no room. `pane-width` sizes the pane (the sheet's width by default). Escape closes
the sheet but leaves a pane open, since the page beside it is still in use; `pane-close-on-escape`
closes the pane on Escape too, for a pane that is a transient detail. The body is
a size container, so its contents lay out by the room the sheet or pane actually has (`@md:`),
never by the viewport.
@@ -34,6 +36,7 @@
'width' => '25rem',
'pane' => false,
'paneWidth' => null,
'paneCloseOnEscape' => false,
])
@php
@@ -55,11 +58,11 @@
},
@endif
}"
@if ($closeOnEscape) x-on:keydown.window.escape="if (open && ! wide) close()" @endif
@if ($closeOnEscape) x-on:keydown.window.escape="{{ $paneCloseOnEscape ? 'if (open) close()' : 'if (open && ! wide) close()' }}" @endif
data-sheet="{{ $id }}"
@if ($pane)
x-bind:class="! open && 'xl:hidden'"
class="xl:sticky xl:top-[calc(env(safe-area-inset-top)+1.25rem)] xl:shrink-0 xl:self-start"
class="xl:sticky xl:top-[calc(var(--material-safe-top,env(safe-area-inset-top))+1.25rem)] xl:shrink-0 xl:self-start"
data-pane
@endif
>
@@ -90,11 +93,11 @@
@if (filled($title)) aria-labelledby="{{ $id }}-title" @endif
style="--sheet-width: {{ $width }}; --pane-width: {{ $paneWidth ?? $width }}"
{{ $attributes->whereDoesntStartWith('wire:model')->except(['id', 'class'])->class([
'fixed top-[env(safe-area-inset-top)] bottom-0 z-50 flex w-full flex-col overflow-y-auto bg-surface-container-low p-6 text-on-surface shadow-elevation-1',
'fixed top-[var(--material-safe-top,env(safe-area-inset-top))] bottom-0 z-50 flex w-full flex-col overflow-y-auto bg-surface-container-low p-6 text-on-surface shadow-elevation-1',
'end-0 sm:rounded-s-corner-lg' => ! $start,
'start-0 sm:rounded-e-corner-lg' => $start,
'sm:w-(--sheet-width) sm:max-w-[calc(100vw-4rem)]',
'xl:relative xl:top-0 xl:z-auto xl:max-h-[calc(100dvh-2.5rem-env(safe-area-inset-top))] xl:w-(--pane-width) xl:max-w-none xl:rounded-corner-lg xl:bg-surface-container xl:shadow-none' => $pane,
'xl:relative xl:top-0 xl:z-auto xl:max-h-[calc(100dvh-2.5rem-var(--material-safe-top,env(safe-area-inset-top)))] xl:w-(--pane-width) xl:max-w-none xl:rounded-corner-lg xl:bg-surface-container xl:shadow-none' => $pane,
$attributes->get('class'),
]) }}
>
@@ -113,13 +116,13 @@
@if ($withCloseButton)
<span class="-me-3 -mt-2 inline-flex shrink-0">
<x-button icon="close" :tooltip-left="__('Close')" x-on:click="close()" />
<x-livewire-material::button icon="close" :tooltip-left="__('Close')" x-on:click="close()" />
</span>
@endif
</div>
@if ($separator)
<x-divider class="mt-4" />
<x-livewire-material::divider class="mt-4" />
@endif
</div>
@endif
@@ -8,7 +8,17 @@
Not an M3 component; built from M3 Expressive's parts `shape` (any `<x-shape>` name,
`cookie-9` by default) in secondary-container behind the `icon` in on-secondary-container,
a title-large `title`, body-medium `description` or slot. For "your filter matched nothing"
use a plain line of text instead: a picture there is consolation for a typo. --}}
use a plain line of text instead: a picture there is consolation for a typo.
An application with its own artwork puts it in the `illustration` slot, which is drawn in place
of the shape and icon (both props are then unused). The slot sizes itself; its attributes go on
the element around it, so `class` can set the colour an SVG's `currentColor` takes:
<x-empty-state title="No routes yet">
<x-slot:illustration class="text-primary"><svg class="size-32" aria-hidden="true"></svg></x-slot:illustration>
</x-empty-state>
A slot holding only whitespace or comments counts as empty, and the shape and icon stay. --}}
@props([
'icon' => 'inbox',
@@ -18,10 +28,14 @@
])
<div {{ $attributes->class('flex flex-col items-center gap-4 px-4 py-10 text-center') }}>
<div class="relative grid size-28 place-items-center">
<x-shape :name="$shape" class="absolute inset-0 size-full text-secondary-container" />
<x-icon :name="$icon" class="relative size-12 text-on-secondary-container" />
</div>
@if (isset($illustration) && $illustration->hasActualContent())
<div {{ $illustration->attributes }}>{{ $illustration }}</div>
@else
<div class="relative grid size-28 place-items-center">
<x-livewire-material::shape :name="$shape" class="absolute inset-0 size-full text-secondary-container" />
<x-livewire-material::icon :name="$icon" class="relative size-12 text-on-secondary-container" />
</div>
@endif
@if ($title)
<h3 class="type-title-lg">{{ $title }}</h3>
@@ -40,7 +40,7 @@
<{{ $tag }} {{ $attributes }}>
@if ($icon)
<x-icon :name="$icon" class="size-6" />
<x-livewire-material::icon :name="$icon" class="size-6" />
@endif
<span>{{ $label ?? $slot }}</span>
@@ -10,7 +10,8 @@
Two to six items. The FAB (`icon`, `add` by default, in `color`'s container) turns into a
round close button in the colour itself while the list is open above it, end-aligned; the
list is a `popover="auto"` menu with the menu keyboard of `<x-menu>`. `label` names the FAB
for screen readers. Give the items the same `color`.
for screen readers. Give the items the same `color`. Like `<x-menu>`'s, the list is keyed for
Livewire, so it stays open through a render of the component around it.
FabMenuBaselineTokens (androidx Compose Material 3, Apache-2.0): 56px items, 4px apart, 8px
above the close button. --}}
@@ -49,13 +50,14 @@
$colours,
])
>
<x-icon :name="$icon" class="size-6 group-aria-expanded/fab:hidden" />
<x-icon name="close" class="hidden size-5 group-aria-expanded/fab:block" />
<x-livewire-material::icon :name="$icon" class="size-6 group-aria-expanded/fab:hidden" />
<x-livewire-material::icon name="close" class="hidden size-5 group-aria-expanded/fab:block" />
</button>
</span>
<div
x-ref="menu"
{{ new \Illuminate\View\ComponentAttributeBag(['wire:key' => 'material-fab-menu']) }}
id="material-fab-menu-{{ $key }}"
popover="auto"
role="menu"
+2 -2
View File
@@ -65,7 +65,7 @@
<{{ $tag }} {{ $attributes }}>
@if ($icon)
<x-icon :name="$icon" :class="$iconSize" />
<x-livewire-material::icon :name="$icon" :class="$iconSize" />
@endif
@if ($extended)
@@ -73,6 +73,6 @@
@endif
@if ($tooltip !== null)
<x-tooltip :text="$tooltip" :anchor="$anchor" />
<x-livewire-material::tooltip :text="$tooltip" :anchor="$anchor" />
@endif
</{{ $tag }}>
+1 -1
View File
@@ -46,7 +46,7 @@
>
<div class="field-box">
@if ($icon)
<x-icon :name="$icon" class="field-icon size-(--field-icon)" />
<x-livewire-material::icon :name="$icon" class="field-icon size-(--field-icon)" />
@endif
@if (filled($prefix))
+6 -4
View File
@@ -21,12 +21,14 @@
@php
$model = $attributes->whereStartsWith('wire:model')->first();
$id = $attributes->get('id') ?? 'field-'.substr(md5('file|'.$model.'|'.$label), 0, 12);
$messages = $model !== null && isset($errors)
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($model), $errors->get($model.'.*')])))
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors)
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($errorKey), $errors->get($errorKey.'.*')])))
: [];
@endphp
<x-field :$id :$label :$hint :$messages :$variant floated :class="$attributes->get('class')">
<x-livewire-material::field :$id :$label :$hint :$messages :$variant floated :class="$attributes->get('class')">
<input
{{ $attributes->except(['class', 'id', 'type']) }}
type="file"
@@ -35,4 +37,4 @@
@if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif
class="field-control"
/>
</x-field>
</x-livewire-material::field>
+1 -1
View File
@@ -16,7 +16,7 @@
@isset($actions)
@if ($separator)
<x-divider />
<x-livewire-material::divider />
@endif
<div {{ $actions->attributes->class(['flex flex-wrap items-center justify-end gap-2']) }}>
+18 -6
View File
@@ -12,13 +12,19 @@
default), `filled` or `outlined`, as for toggle buttons. The segments share the row unless
`inline`. An option with `'disabled' => true` greys its own segment.
ReStride's props, kept: `label`, `hint`, `name` (needed with `x-model`, which names no
property), `options`, `option-value`, `option-label`; plus `option-icon`, `size`, `variant`,
`multiple`, `inline`. A validation message for the bound property replaces the hint. --}}
ReStride's props, kept: `label`, `hint`, `hint-class`, `name` (needed with `x-model`, which
names no property), `options`, `option-value`, `option-label`; plus `option-icon`, `size`,
`variant`, `multiple`, `inline`. A validation message for the bound property replaces the hint.
`hint-class` adds classes to the hint, as on `<x-field>`: a colour there paints it
(`hint-class="text-warning"` for a hint that warns). The hint's own colour then carries no
specificity, as the field's does in the components layer, because which of two colour
utilities wins depends on the order Tailwind emits them. --}}
@props([
'label' => null,
'hint' => null,
'hintClass' => null,
'name' => null,
'options' => [],
'optionValue' => 'id',
@@ -33,7 +39,9 @@
@php
$model = $attributes->whereStartsWith('wire:model')->first();
$name ??= $model;
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : [];
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
$size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'sm';
$segment = [
@@ -44,6 +52,10 @@
'xl' => 'h-34 gap-4 px-16 type-headline-lg',
][$size];
$hintClasses = filled($hintClass)
? \Illuminate\Support\Arr::toCssClasses(['mt-1 type-body-sm [:where(&)]:text-on-surface-variant', $hintClass])
: 'mt-1 type-body-sm text-on-surface-variant';
$iconSize = ['xs' => 'size-5', 'sm' => 'size-5', 'md' => 'size-6', 'lg' => 'size-8', 'xl' => 'size-10'][$size];
$colours = match ($variant) {
@@ -79,7 +91,7 @@
/>
@if (filled(data_get($option, $optionIcon)))
<x-icon :name="data_get($option, $optionIcon)" :class="$iconSize" />
<x-livewire-material::icon :name="data_get($option, $optionIcon)" :class="$iconSize" />
@endif
<span class="truncate">{{ data_get($option, $optionLabel) }}</span>
@@ -92,6 +104,6 @@
<p class="mt-1 type-body-sm text-error">{{ $message }}</p>
@endforeach
@elseif (filled($hint))
<p class="mt-1 type-body-sm text-on-surface-variant">{{ $hint }}</p>
<p class="{{ $hintClasses }}">{{ $hint }}</p>
@endif
</fieldset>
+8 -6
View File
@@ -30,10 +30,12 @@
$model = $attributes->whereStartsWith('wire:model')->first();
$placeholder = filled($attributes->get('placeholder')) ? $attributes->get('placeholder') : ' ';
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|'.$placeholder), 0, 12);
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : [];
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
@endphp
<x-field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$prefix :$suffix :$size :$variant :$mono :class="$attributes->get('class')" :data-readonly="$attributes->get('readonly') ? '' : null">
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$prefix :$suffix :$size :$variant :$mono :class="$attributes->get('class')" :data-readonly="$attributes->get('readonly') ? '' : null">
<input
{{ $attributes->except(['class', 'id', 'placeholder'])->merge(['type' => 'text']) }}
id="{{ $id }}"
@@ -54,7 +56,7 @@
aria-label="{{ __('Clear') }}"
data-field-clear
>
<x-icon name="close" class="size-(--field-icon)" />
<x-livewire-material::icon name="close" class="size-(--field-icon)" />
</button>
@endif
@@ -67,13 +69,13 @@
aria-label="{{ __('Copy') }}"
data-field-copy
>
<x-icon name="content_copy" class="size-(--field-icon)" />
<x-livewire-material::icon name="content_copy" class="size-(--field-icon)" />
</button>
@endif
@if ($iconRight)
<x-icon :name="$iconRight" class="field-trailing size-(--field-icon)" />
<x-livewire-material::icon :name="$iconRight" class="field-trailing size-(--field-icon)" />
@endif
</x-slot:trailing>
@endif
</x-field>
</x-livewire-material::field>
@@ -61,7 +61,7 @@
@elseif ($image)
<img src="{{ $image }}" alt="" class="size-14 shrink-0 rounded-corner-sm object-cover" />
@elseif ($icon)
<x-icon :name="$icon" :class="\Illuminate\Support\Arr::toCssClasses(['size-6', 'text-on-surface-variant' => ! $selected && ! $disabled])" />
<x-livewire-material::icon :name="$icon" :class="\Illuminate\Support\Arr::toCssClasses(['size-6', 'text-on-surface-variant' => ! $selected && ! $disabled])" />
@endif
<div class="min-w-0 flex-1">
@@ -94,6 +94,6 @@
@endif
@if ($iconRight)
<x-icon :name="$iconRight" :class="\Illuminate\Support\Arr::toCssClasses(['size-6', 'text-on-surface-variant' => ! $selected && ! $disabled])" />
<x-livewire-material::icon :name="$iconRight" :class="\Illuminate\Support\Arr::toCssClasses(['size-6', 'text-on-surface-variant' => ! $selected && ! $disabled])" />
@endif
</div>
+35 -8
View File
@@ -1,11 +1,21 @@
{{-- One item in an `<x-menu>`: an action, a link, or a choice.
`label`, a leading `icon`, an `icon-right`, a `description` under the label and a
`shortcut` at the end (M3's trailing supporting text: "⌘C"). `link` makes it an anchor, with
`wire:navigate` unless `external` or `no-wire-navigate`. `selected` (true or false) makes it a
`menuitemcheckbox` with `aria-checked`; a selected item takes Expressive's selected shape and
tertiary-container. `disabled` keeps it in the list, out of reach. `keep-open` leaves the menu
open when it is activated for a choice the person may want to change twice.
`label`, a leading `icon` (`icon-class` adds classes to it), an `icon-right`, a `description`
under the label and a `shortcut` at the end (M3's trailing supporting text: "⌘C"). `link`
makes it an anchor, with `wire:navigate` unless `external` or `no-wire-navigate`. `selected`
(true or false) makes it a `menuitemcheckbox` with `aria-checked`; a selected item takes
Expressive's selected shape and tertiary-container. `current` is for a menu of places rather
than choices a section picker and marks the page you are on: `aria-current="page"`, the
selected shape in secondary-container, the colour M3 gives the navigation indicator. `badge`
draws `<x-badge>` at the end of the row: `true` for a dot, or a count. `disabled` keeps it in the list, out of
reach. `keep-open` leaves the menu open when it is activated for a choice the person may
want to change twice.
`icon-class` is for an icon whose colour means something of its own, a sport's glyph in the
sport's colour (`icon-class="text-sport-run"`). A colour there paints the icon, a selected
item's too: the icon's own colour then carries no specificity, because which of two colour
utilities wins depends on the order Tailwind emits them. A disabled item's icon stays
disabled.
44px tall (SegmentedMenuTokens.Item), body-large label, 20px icons, 4px corners that open to
12px at the ends of the list. --}}
@@ -13,6 +23,7 @@
@props([
'label' => null,
'icon' => null,
'iconClass' => null,
'iconRight' => null,
'description' => null,
'shortcut' => null,
@@ -20,6 +31,8 @@
'external' => false,
'noWireNavigate' => false,
'selected' => null,
'current' => false,
'badge' => null,
'disabled' => false,
'keepOpen' => false,
])
@@ -36,11 +49,13 @@
'focus-visible:outline-3 focus-visible:-outline-offset-3 focus-visible:outline-secondary',
'py-2' => filled($description),
'rounded-corner-md bg-tertiary-container text-on-tertiary-container' => $selected === true,
'rounded-corner-md bg-secondary-container text-on-secondary-container' => $current && $selected !== true,
'pointer-events-none text-on-surface/38' => $disabled,
])
->merge(array_filter([
'role' => $selected === null ? 'menuitem' : 'menuitemcheckbox',
'aria-checked' => $selected === null ? null : ($selected ? 'true' : 'false'),
'aria-current' => $current ? 'page' : null,
'aria-disabled' => $disabled ? 'true' : null,
'tabindex' => '-1',
'type' => $isLink ? null : 'button',
@@ -54,13 +69,22 @@
$iconInk = match (true) {
$disabled => 'text-on-surface/38',
$selected === true => 'text-on-tertiary-container',
$current => 'text-on-secondary-container',
default => 'text-on-surface-variant',
};
$leadingIcon = match (true) {
blank($iconClass) => 'size-5 '.$iconInk,
$disabled => \Illuminate\Support\Arr::toCssClasses(['size-5', $iconClass, 'text-on-surface/38!']),
$selected === true => \Illuminate\Support\Arr::toCssClasses(['size-5 [:where(&)]:text-on-tertiary-container', $iconClass]),
$current => \Illuminate\Support\Arr::toCssClasses(['size-5 [:where(&)]:text-on-secondary-container', $iconClass]),
default => \Illuminate\Support\Arr::toCssClasses(['size-5 [:where(&)]:text-on-surface-variant', $iconClass]),
};
@endphp
<{{ $tag }} {{ $attributes }}>
@if ($icon)
<x-icon :name="$icon" :filled="$selected === true" :class="'size-5 '.$iconInk" />
<x-livewire-material::icon :name="$icon" :filled="$selected === true || $current" :class="$leadingIcon" />
@endif
<span class="min-w-0 flex-1">
@@ -71,11 +95,14 @@
@endif
</span>
@if ($badge !== null && $badge !== false && $badge !== '')
<x-livewire-material::badge :value="$badge === true ? null : $badge" class="shrink-0" />
@endif
@if ($shortcut)
<span @class(['shrink-0 type-label-sm', $iconInk])>{{ $shortcut }}</span>
@endif
@if ($iconRight)
<x-icon :name="$iconRight" :class="'size-5 '.$iconInk" />
<x-livewire-material::icon :name="$iconRight" :class="'size-5 '.$iconInk" />
@endif
</{{ $tag }}>
+20 -5
View File
@@ -13,10 +13,24 @@
The trigger's first button or link becomes the menu button (aria-haspopup, aria-expanded,
aria-controls). The list is a `popover="auto"` in the top layer, placed by CSS anchor
positioning at `position` (`bottom-start`, `bottom-end`, `top-start`, `top-end`) and flipping
when there is no room; a click outside or Escape closes it. The keyboard is WAI-ARIA's menu
button: Enter, Space or ArrowDown open on the first item, ArrowUp on the last; arrows, Home,
End and typing a letter move between items; Tab closes; activating an item closes the menu
unless the item says `keep-open`, and Escape returns focus to the trigger.
when there is no room to the other side, the other end, or both, so a menu on a FAB in a
corner of the window opens back across it; a click outside or Escape closes it. The keyboard
is WAI-ARIA's menu button: Enter, Space or ArrowDown open on the first item, ArrowUp on the
last; arrows, Home, End and typing a letter move between items; Tab closes; activating an item
closes the menu unless the item says `keep-open`, and Escape returns focus to the trigger.
The anchor name is rendered on the wrapper around the trigger slot, the only element the
server can name, and resources/js/menu.js moves it onto the menu button itself: a trigger
that is `position: fixed` (`<x-button fab>` on a phone) leaves the wrapper behind as an empty
box where the page put it, and the menu opened there.
The id and the anchor name are new with every render. The popover carries a `wire:key`, which
a Livewire morph matches it by before the id, so a render of the component around an open
menu patches it in place still open, focus and listeners kept instead of swapping in a
closed copy; menu.js then writes the menu button's ARIA attributes again. The key goes
through an attribute bag: Livewire compiles a `wire:key` written in a template into the key
of the loop iteration around it, which would give every child component after the menu the
same key.
The container is Expressive's standard menu (surface-container-low, 16px corner, elevation
2), or `vibrant` in tertiary-container StandardMenuTokens and VibrantMenuTokens from
@@ -43,6 +57,7 @@
<div
x-ref="menu"
{{ new \Illuminate\View\ComponentAttributeBag(['wire:key' => 'material-menu']) }}
id="material-menu-{{ $key }}"
popover="auto"
role="menu"
@@ -53,7 +68,7 @@
x-on:click="activate($event)"
@class([
'm-0 min-w-28 max-w-70 overflow-visible border-0 p-1 rounded-corner-lg shadow-elevation-2 [inset:auto]',
'my-1 [position-try-fallbacks:flip-block,flip-inline]',
'my-1 [position-try-fallbacks:flip-block,flip-inline,flip-block_flip-inline]',
'opacity-0 transition-[opacity,translate,display,overlay] transition-discrete duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast open:opacity-100 starting:open:opacity-0',
'bg-surface-container-low text-on-surface' => ! $vibrant,
'bg-tertiary-container text-on-tertiary-container' => $vibrant,
+9 -8
View File
@@ -64,12 +64,12 @@
>
<div @class([
'flex max-h-[inherit] flex-col overflow-y-auto rounded-corner-xl bg-surface-container-high p-6 shadow-elevation-3',
'max-sm:h-full max-sm:rounded-none max-sm:p-0 max-sm:pt-[env(safe-area-inset-top)]' => $fullscreen,
'max-sm:h-full max-sm:rounded-none max-sm:p-0 max-sm:pt-[var(--material-safe-top,env(safe-area-inset-top))]' => $fullscreen,
$boxClass,
])>
@if ($fullscreen)
<div class="flex h-16 shrink-0 items-center gap-1 px-1 sm:hidden">
<x-button icon="close" :tooltip="__('Close')" x-on:click="close()" />
<x-livewire-material::button icon="close" :tooltip="__('Close')" x-on:click="close()" />
@if (filled($title))
<span class="truncate type-title-lg">{{ $title }}</span>
@@ -78,22 +78,23 @@
@endif
<div @class(['min-h-0 flex-1', 'max-sm:overflow-y-auto max-sm:px-6 max-sm:pb-6' => $fullscreen])>
@if (filled($title) || $icon)
<div @class(['mb-4', 'max-sm:hidden' => $fullscreen && ! $icon, 'text-center' => $icon])>
@if (filled($title) || filled($subtitle) || $icon)
{{-- A full-screen dialog's bar names it on a phone, so only the subtitle stays there. --}}
<div @class(['mb-4', 'max-sm:hidden' => $fullscreen && ! $icon && blank($subtitle), 'text-center' => $icon])>
@if ($icon)
<x-icon :name="$icon" class="mx-auto mb-4 size-6 text-secondary" />
<x-livewire-material::icon :name="$icon" class="mx-auto mb-4 size-6 text-secondary" />
@endif
@if (filled($title))
<h2 id="{{ $id }}-title" class="type-headline-sm">{{ $title }}</h2>
<h2 id="{{ $id }}-title" @class(['type-headline-sm', 'max-sm:hidden' => $fullscreen && ! $icon])>{{ $title }}</h2>
@endif
@if (filled($subtitle))
<p class="mt-4 type-body-md text-on-surface-variant">{{ $subtitle }}</p>
<p @class(['type-body-md text-on-surface-variant', 'mt-4' => filled($title) || $icon, 'max-sm:mt-0' => $fullscreen && ! $icon])>{{ $subtitle }}</p>
@endif
@if ($separator)
<x-divider class="mt-4" />
<x-livewire-material::divider class="mt-4" />
@endif
</div>
@endif
@@ -0,0 +1,69 @@
{{-- One destination in an `<x-navigation-bar>`.
<x-navigation-bar-item label="Inbox" icon="inbox" link="{{ route('inbox') }}" :active="request()->routeIs('inbox')" badge="12" />
`link` renders an anchor with `wire:navigate` (unless `external` or `no-wire-navigate`);
without one it is a button, for a destination a Livewire action switches to. `active` marks
the current destination: `aria-current="page"`, the filled icon in on-secondary-container on
the secondary-container indicator, and the label in secondary (on-secondary-container inside
the medium bar's pill).
`badge` puts M3's badge on the icon: `true` for the small dot, a number or a few characters
for the large one (999+ at most). Screen readers hear a count after the label (", 12");
`badge-label` replaces it with words ("12 unread") and gives a dot something to say.
Values from NavigationBarTokens.kt and NavigationBarVerticalItemTokens.kt (androidx Compose
Material 3, Apache-2.0); see `<x-navigation-bar>`. --}}
@props([
'label' => null,
'icon' => null,
'link' => null,
'external' => false,
'noWireNavigate' => false,
'active' => false,
'badge' => null,
'badgeLabel' => null,
])
@php
$isLink = filled($link);
$tag = $isLink ? 'a' : 'button';
$dot = $badge === true;
$count = ! $dot && $badge !== null && $badge !== false && $badge !== '';
$spoken = $badgeLabel ?? ($count ? $badge : null);
$attributes = $attributes->merge(array_filter([
'href' => $isLink ? $link : null,
'target' => $isLink && $external ? '_blank' : null,
'rel' => $isLink && $external ? 'noopener' : null,
'wire:navigate' => $isLink && ! $external && ! $noWireNavigate && ! $attributes->has('wire:navigate') ? true : null,
'type' => $isLink ? null : 'button',
'aria-current' => $active ? 'page' : null,
'data-active' => $active ? true : null,
], fn ($value): bool => $value !== null));
@endphp
<{{ $tag }} data-navigation-bar-item {{ $attributes }}>
<span data-navigation-pill>
<span data-navigation-indicator>
<span class="relative inline-flex">
@if ($icon)
<x-livewire-material::icon :name="$icon" :filled="$active" class="size-6" />
@endif
@if ($dot)
<x-livewire-material::badge floating />
@elseif ($count)
<x-livewire-material::badge :value="$badge" max="999" floating />
@endif
</span>
</span>
<span data-navigation-label>{{ $label ?? $slot }}</span>
</span>
@if ($spoken !== null)
<span class="sr-only">, {{ $spoken }}</span>
@endif
</{{ $tag }}>
@@ -0,0 +1,40 @@
{{-- M3 Expressive's flexible navigation bar: three to five destinations at the bottom of a
compact window.
<div class="fixed inset-x-0 bottom-0 z-30 sm:hidden">
<x-navigation-bar>
<x-navigation-bar-item label="Inbox" icon="inbox" link="/inbox" active badge="12" />
<x-navigation-bar-item label="Starred" icon="star" link="/starred" />
<x-navigation-bar-item label="Sent" icon="send" link="/sent" />
</x-navigation-bar>
</div>
The short bar, 64px tall in surface-container, with the bottom safe area added under it. On a
bar narrower than 600px (M3's compact window) each item is the icon in a 56×32 indicator over a
label-medium label, and the items share the width equally. From 600px (medium) icon and label
sit side by side in a 40px pill and the items gather in the middle, with the padding Compose's
Centered arrangement gives three to six items. Both follow the bar's own width (a container
query), so a bar in a narrow column keeps the compact items.
It does not position itself: wrap it in the element that pins it (`fixed inset-x-0 bottom-0`)
and hides it where a rail takes over. `<x-app-shell>` does both, and lifts the snackbar and a
`fab` button above it through `--material-bottom-bar`.
`label` names the landmark ("Main" by default).
Values from androidx Compose Material 3 (Apache-2.0), androidx-main
27cf9a7d5788aa0f5f2d8b6699ce279560daf326: NavigationBarTokens.kt,
NavigationBarVerticalItemTokens.kt, NavigationBarHorizontalItemTokens.kt and
ShortNavigationBar.kt, all under
https://github.com/androidx/androidx/tree/androidx-main/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3
the styles are resources/css/components/navigation.css. --}}
@props([
'label' => null,
])
<nav aria-label="{{ $label ?? __('Main') }}" data-navigation-bar {{ $attributes }}>
<div data-navigation-bar-items>
{{ $slot }}
</div>
</nav>
@@ -0,0 +1,74 @@
{{-- One destination in an `<x-navigation-rail>`, drawn in whichever shape the rail has.
<x-navigation-rail-item label="Inbox" icon="inbox" link="{{ route('inbox') }}" :active="request()->routeIs('inbox')" badge="12" />
Expanded, it is a 56px full-width pill: the icon, the label-large label beside it and a count
at its end. Collapsed, the icon sits in a 56×32 indicator over a label-medium label (two lines
at most), with the count on the icon. The current destination (`active`) is
`aria-current="page"` with the filled icon on secondary-container, its label in secondary when
collapsed.
`link` renders an anchor with `wire:navigate` (unless `external` or `no-wire-navigate`);
without one it is a button. `badge`: `true` for M3's small dot on the icon, a number or a few
characters for the large badge (999+ at most). Screen readers hear a count after the label
(", 12"); `badge-label` replaces it with words ("12 unread") and gives a dot something to say.
Values from NavigationRailVerticalItemTokens.kt, NavigationRailHorizontalItemTokens.kt,
NavigationRailBaselineItemTokens.kt and NavigationRailColorTokens.kt (androidx Compose
Material 3, Apache-2.0); see `<x-navigation-rail>`. Compose's expanded item hugs its label; the
package draws the full-width pill, which leaves room for the count at the end. --}}
@props([
'label' => null,
'icon' => null,
'link' => null,
'external' => false,
'noWireNavigate' => false,
'active' => false,
'badge' => null,
'badgeLabel' => null,
])
@php
$isLink = filled($link);
$tag = $isLink ? 'a' : 'button';
$dot = $badge === true;
$count = ! $dot && $badge !== null && $badge !== false && $badge !== '';
$spoken = $badgeLabel ?? ($count ? $badge : null);
$attributes = $attributes->merge(array_filter([
'href' => $isLink ? $link : null,
'target' => $isLink && $external ? '_blank' : null,
'rel' => $isLink && $external ? 'noopener' : null,
'wire:navigate' => $isLink && ! $external && ! $noWireNavigate && ! $attributes->has('wire:navigate') ? true : null,
'type' => $isLink ? null : 'button',
'aria-current' => $active ? 'page' : null,
'data-active' => $active ? true : null,
], fn ($value): bool => $value !== null));
@endphp
<{{ $tag }} data-navigation-rail-item {{ $attributes }}>
<span data-navigation-indicator>
<span class="relative inline-flex">
@if ($icon)
<x-livewire-material::icon :name="$icon" :filled="$active" class="size-6" />
@endif
@if ($dot)
<x-livewire-material::badge floating />
@elseif ($count)
<span class="hidden rail-collapsed:contents"><x-livewire-material::badge :value="$badge" max="999" floating /></span>
@endif
</span>
</span>
<span data-navigation-label>{{ $label ?? $slot }}</span>
@if ($count)
<span class="flex shrink-0 rail-collapsed:hidden"><x-livewire-material::badge :value="$badge" max="999" /></span>
@endif
@if ($spoken !== null)
<span class="sr-only">, {{ $spoken }}</span>
@endif
</{{ $tag }}>
@@ -0,0 +1,24 @@
{{-- A group of destinations in an `<x-navigation-rail>` under a heading.
<x-navigation-rail-section label="Labels">
<x-navigation-rail-item label="Travel" icon="label" link="/labels/travel" />
<x-navigation-rail-item label="Receipts" icon="label" link="/labels/receipts" />
</x-navigation-rail-section>
The heading (title-small, on-surface-variant, in line with the icons) shows only while the
rail is expanded, as M3 draws section headers; collapsed, a little space sets the group apart.
It names the group for screen readers either way. --}}
@props([
'label',
])
@php
$headingId = 'navigation-section-'.substr(md5((string) $label), 0, 10);
@endphp
<div role="group" aria-labelledby="{{ $headingId }}" data-navigation-rail-section {{ $attributes }}>
<p id="{{ $headingId }}" data-navigation-rail-heading>{{ $label }}</p>
{{ $slot }}
</div>
@@ -0,0 +1,135 @@
{{-- M3 Expressive's navigation rail: destinations down the start edge of a medium or wider
window, collapsed (96px, icon over label) or expanded (icon beside label in a full-width pill).
<div class="flex min-h-dvh">
<x-navigation-rail mode="collapsible">
<x-slot:brand><span class="type-title-lg">Mail</span></x-slot:brand>
<x-slot:header>
<x-fab icon="edit" tooltip-right="Compose" />
</x-slot:header>
<x-navigation-rail-item label="Inbox" icon="inbox" link="/inbox" active badge="12" />
<x-navigation-rail-section label="Labels">
<x-navigation-rail-item label="Travel" icon="label" link="/labels/travel" />
</x-navigation-rail-section>
<x-slot:footer>
<x-navigation-rail-item label="Settings" icon="settings" link="/settings" />
</x-slot:footer>
</x-navigation-rail>
<main class="min-w-0 flex-1"></main>
</div>
`mode` says what decides its width:
- `collapsed` always collapsed; `expanded` always expanded.
- `collapsible` (the default) the visitor's choice: expanded until the menu button collapses
it. The choice is `$store.rail`, remembered in localStorage and applied by <x-theme-script>
before the first paint (<html data-rail>), so the rail never paints wide and snaps shut.
- `modal` collapsed in the layout; the menu button (or `$store.rail.show()` from anywhere)
opens it expanded over a scrim, holding focus until Escape, the scrim, the menu button or
leaving the page closes it (Compose's ModalWideNavigationRail).
- `adaptive` what `<x-app-shell>` uses: below `sm` nothing until `$store.rail.show()` slides
it in as a modal; from `sm` collapsed, opening as a modal; from `lg` collapsible.
Slots: `brand` beside the menu button, only while expanded; `header` under it a FAB, drawn
as an extended FAB when expanded (`rail-collapsed:` below); the destinations in the default
slot, which alone scroll when the window is too short; `footer`, pinned to the foot. Header and
footer never scroll, so nothing in them is cut off by the scroller's edge.
Anything inside can take both shapes with the `rail-collapsed:` variant, which applies while
the rail is drawn collapsed for whatever reason:
`<span class="rail-collapsed:hidden"><x-fab label="Compose" icon="edit" /></span>`
`<span class="hidden rail-collapsed:inline-flex"><x-fab icon="edit" tooltip-right="Compose" /></span>`.
Nothing that shows while collapsed may be wider than 96px.
Props: `label` names the landmark ("Main"); `width` is the expanded width (`16rem`, held
between M3's 220 and 360dp); `menu` shows the menu button (by default for `collapsible`,
`modal` and `adaptive`). The rail does not scroll with the page: in a flex row it sticks to
the top of the viewport, as tall as the viewport at most.
Values from androidx Compose Material 3 (Apache-2.0), androidx-main
27cf9a7d5788aa0f5f2d8b6699ce279560daf326: NavigationRailCollapsedTokens.kt,
NavigationRailExpandedTokens.kt, NavigationRailBaselineItemTokens.kt and
WideNavigationRail.kt under
https://github.com/androidx/androidx/tree/androidx-main/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3
surface (surface-container and elevation 2 with a large inner corner when modal), 44px above
the header and 40px under it, 4px between collapsed items. The styles are
resources/css/components/navigation.css; the behaviour resources/js/navigation.js. --}}
@props([
'mode' => 'collapsible',
'label' => null,
'width' => '16rem',
'menu' => null,
])
@php
$mode = in_array($mode, ['collapsed', 'expanded', 'collapsible', 'modal', 'adaptive'], true) ? $mode : 'collapsible';
$interactive = in_array($mode, ['collapsible', 'modal', 'adaptive'], true);
$canOpen = in_array($mode, ['modal', 'adaptive'], true);
$menu ??= $interactive;
$collapsedAtFirst = in_array($mode, ['collapsed', 'modal'], true);
@endphp
<div
data-navigation-rail="{{ $mode }}"
@if ($interactive)
x-data="materialNavigationRail('{{ $mode }}')"
x-bind:data-open="open"
@endif
{{ $attributes->merge(['style' => "--navigation-rail-width: {$width}"]) }}
>
@if ($canOpen)
<div data-navigation-rail-scrim aria-hidden="true" x-on:click="$store.rail.hide()"></div>
@endif
<nav
data-navigation-rail-panel
aria-label="{{ $label ?? __('Main') }}"
@if ($canOpen)
x-trap.inert.noscroll="open"
x-on:keydown.escape.window="open && $store.rail.hide()"
@endif
>
@if ($menu || isset($brand) || isset($header))
<div data-navigation-rail-header>
@if ($menu || isset($brand))
<div @class(['flex w-full min-w-0 items-center gap-3 pe-5', 'ps-7' => $menu, 'ps-5' => ! $menu])>
@if ($menu)
<button
type="button"
data-navigation-rail-menu
aria-label="{{ $collapsedAtFirst ? __('Expand navigation') : __('Collapse navigation') }}"
aria-expanded="{{ $collapsedAtFirst ? 'false' : 'true' }}"
x-on:click="menu()"
x-bind:aria-label="expanded ? @js(__('Collapse navigation')) : @js(__('Expand navigation'))"
x-bind:aria-expanded="expanded.toString()"
class="state-layer focus-ring inline-flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-corner-full text-on-surface-variant after:absolute after:top-1/2 after:left-1/2 after:size-12 after:-translate-x-1/2 after:-translate-y-1/2"
>
<span class="contents rail-collapsed:hidden"><x-livewire-material::icon name="menu_open" class="size-6" /></span>
<span class="hidden rail-collapsed:contents"><x-livewire-material::icon name="menu" class="size-6" /></span>
</button>
@endif
@isset($brand)
<div class="min-w-0 flex-1 rail-collapsed:hidden">{{ $brand }}</div>
@endisset
</div>
@endif
@isset($header)
<div {{ $header->attributes->class(['flex w-full flex-col items-start gap-2 px-5']) }}>{{ $header }}</div>
@endisset
</div>
@endif
<div data-navigation-rail-destinations>
{{ $slot }}
</div>
@isset($footer)
<div data-navigation-rail-footer {{ $footer->attributes }}>{{ $footer }}</div>
@endisset
</nav>
</div>
@@ -17,10 +17,12 @@
@php
$model = $attributes->whereStartsWith('wire:model')->first();
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|password'), 0, 12);
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : [];
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
@endphp
<x-field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$size :$variant :class="$attributes->get('class')" x-data="{ shown: false }">
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$size :$variant :class="$attributes->get('class')" x-data="{ shown: false }">
<input
{{ $attributes->except(['class', 'id', 'type', 'placeholder']) }}
id="{{ $id }}"
@@ -43,8 +45,8 @@
class="field-trailing field-button"
data-field-reveal
>
<x-icon name="visibility" class="size-(--field-icon)" x-show="! shown" />
<x-icon name="visibility_off" class="size-(--field-icon)" x-show="shown" x-cloak />
<x-livewire-material::icon name="visibility" class="size-(--field-icon)" x-show="! shown" />
<x-livewire-material::icon name="visibility_off" class="size-(--field-icon)" x-show="shown" x-cloak />
</button>
</x-slot:trailing>
</x-field>
</x-livewire-material::field>
+3 -1
View File
@@ -23,7 +23,9 @@
$model = $attributes->whereStartsWith('wire:model')->first();
$name = $attributes->get('name') ?? $model ?? 'radio-'.substr(md5($label.'|'.json_encode($options)), 0, 12);
$id = 'radio-'.substr(md5($name.'|'.$label), 0, 12);
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : [];
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
@endphp
<fieldset
@@ -10,6 +10,11 @@
form M3 asks for when it has actions. The bubble is a popover in surface-container with a
medium corner and elevation 2, 312px at most, placed by anchor positioning on `side`.
The bubble's id and anchor name are new with every render; its `wire:key` (see `<x-menu>`)
lets a Livewire morph patch it in place, so an open bubble stays open through its own
action's `wire:click` too and resources/js/rich-tooltip.js keeps showing and hiding the
element on the page rather than one the morph took away.
RichTooltipTokens (androidx Compose Material 3, Apache-2.0): title-small subhead and body-medium
text in on-surface-variant, label-large actions in primary. --}}
@@ -35,6 +40,7 @@
<span
x-ref="bubble"
{{ new \Illuminate\View\ComponentAttributeBag(['wire:key' => 'material-rich-tooltip']) }}
id="material-rich-tooltip-{{ $key }}"
popover="{{ $persistent ? 'auto' : 'manual' }}"
role="{{ $persistent ? 'dialog' : 'tooltip' }}"
@@ -0,0 +1,79 @@
{{-- A choice of colour profile: one swatch per profile `material:scheme` generated from
`livewire-material.profiles`, each its name and its primary, secondary and tertiary colour.
<x-scheme-picker label="Colour profile" wire:model="colorProfile" hint="Applies to every page after saving" />
Native radios under the swatches, so `wire:model` and `x-model` bind as on any input and the
arrow keys move the choice. Choosing one shows it on the page at once
(`$store.theme.previewScheme`); storing it and telling `Scheme::resolveProfileUsing()` is
the application's. The dots are the only colours not drawn from tokens: they show other
profiles than the page's, so they are custom properties set inline from the scheme file's
checked hexes, light or dark with the page. Without profiles it renders nothing.
Props: `label`, `hint`, `name` (needed with `x-model`), `profiles` (default: every generated
profile). A validation message for the bound property replaces the hint. --}}
@props([
'label' => null,
'hint' => null,
'name' => null,
'profiles' => null,
])
@php
$profiles ??= \NoNameWeb\LivewireMaterial\Support\Scheme::profiles();
$model = $attributes->whereStartsWith('wire:model')->first();
$name ??= $model ?: 'scheme';
$errorKey = $model ?: (filled($attributes->get('name')) ? (string) $attributes->get('name') : null);
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
@endphp
@if ($profiles !== [])
<fieldset x-data data-scheme-picker {{ $attributes->whereDoesntStartWith(['wire:model', 'x-model'])->except('name')->class('min-w-0') }}>
@if (filled($label))
<legend class="mb-2 type-label-lg text-on-surface-variant">{{ $label }}</legend>
@endif
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4">
@foreach ($profiles as $profile => $scheme)
<label
data-scheme-option="{{ $profile }}"
class="state-layer flex min-w-0 cursor-pointer select-none flex-col items-start gap-2 rounded-corner-lg border border-outline-variant bg-surface-container-low p-3 text-on-surface transition-[background-color,border-color] duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast has-checked:border-transparent has-checked:bg-secondary-container has-checked:text-on-secondary-container has-focus-visible:outline-3 has-focus-visible:outline-offset-2 has-focus-visible:outline-secondary"
>
<input
{{ $attributes->whereStartsWith(['wire:model', 'x-model']) }}
type="radio"
name="{{ $name }}"
value="{{ $profile }}"
x-on:change="$store.theme.previewScheme($event.target.value)"
class="peer sr-only"
/>
<span class="flex shrink-0 -space-x-1.5" aria-hidden="true">
@foreach (['primary', 'secondary', 'tertiary'] as $role)
<span
style="--swatch-light: {{ $scheme['light'][$role] }}; --swatch-dark: {{ $scheme['dark'][$role] }}"
class="size-5 rounded-full bg-(--swatch-light) ring-2 ring-surface-container-low in-has-checked:ring-secondary-container dark:bg-(--swatch-dark)"
></span>
@endforeach
</span>
<span class="w-full truncate pe-6 type-label-lg">{{ __($scheme['label']) }}</span>
{{-- In the corner, so a long name keeps its room. The wrapper carries the position. --}}
<span class="pointer-events-none absolute end-2 top-2 hidden peer-checked:block">
<x-livewire-material::icon name="check" class="size-5" />
</span>
</label>
@endforeach
</div>
@if ($messages !== [])
@foreach ($messages as $message)
<p class="mt-1 type-body-sm text-error">{{ $message }}</p>
@endforeach
@elseif (filled($hint))
<p class="mt-1 type-body-sm text-on-surface-variant">{{ $hint }}</p>
@endif
</fieldset>
@endif
+3 -3
View File
@@ -37,11 +37,11 @@
>
<div data-search-bar role="search" x-on:click="if ($event.target === $el) $refs.input.focus()">
<span data-search-leading x-show="! fullScreen">
<x-icon :name="$icon" />
<x-livewire-material::icon :name="$icon" />
</span>
<button type="button" data-search-leading data-search-back x-show="fullScreen" x-cloak x-on:click="close()" aria-label="{{ __('Back') }}">
<x-icon name="arrow_back" />
<x-livewire-material::icon name="arrow_back" />
</button>
<input
@@ -64,7 +64,7 @@
/>
<button type="button" data-search-clear x-on:click="clear()" aria-label="{{ __('Clear') }}">
<x-icon name="close" />
<x-livewire-material::icon name="close" />
</button>
@isset($trailing)
@@ -2,11 +2,15 @@
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
(an item is current when `active` is true, or when its `url` is the page'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
never fits, a button naming the current section opens a menu of all of them, the current one
marked `aria-current="page"` and each with its badge. The same list is
rendered for both, and CSS shows one.
The page's URL is `Livewire::originalUrl()`: while a Livewire component on the page updates, the
request is Livewire's update endpoint, and comparing with it left no section lit.
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
@@ -20,7 +24,8 @@
@php
$label ??= __('Sections');
$isCurrent = fn (array $item): bool => ($item['active'] ?? false) || (filled($item['url'] ?? null) && url()->current() === url($item['url']));
$page = \Livewire\Livewire::originalUrl();
$isCurrent = fn (array $item): bool => ($item['active'] ?? false) || (filled($item['url'] ?? null) && $page === url($item['url']));
$current = collect($items)->first($isCurrent) ?? ($items[0] ?? null);
$layout = match (true) {
@@ -33,21 +38,21 @@
<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-livewire-material::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" />
<x-livewire-material::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" />
<x-livewire-material::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" />
<x-livewire-material::menu-item :label="$item['title']" :icon="$item['icon'] ?? null" :link="$item['url']" :current="$isCurrent($item)" :badge="$item['badge'] ?? null" :no-wire-navigate="$noWireNavigate" />
@endforeach
</x-menu>
</x-livewire-material::menu>
</div>
@endif
@@ -65,11 +70,11 @@
>
<span data-tab-content>
@isset($item['icon'])
<x-icon :name="$item['icon']" :filled="$on" class="size-5" />
<x-livewire-material::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']" />
<x-livewire-material::badge :value="$item['badge']" />
@endif
<span data-tab-indicator aria-hidden="true"></span>
</span>
+6 -4
View File
@@ -29,10 +29,12 @@
@php
$model = $attributes->whereStartsWith('wire:model')->first();
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|select'), 0, 12);
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : [];
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
@endphp
<x-field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$size :$variant floated :class="$attributes->get('class')">
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$size :$variant floated :class="$attributes->get('class')">
<select
{{ $attributes->except(['class', 'id']) }}
id="{{ $id }}"
@@ -52,6 +54,6 @@
</select>
<x-slot:trailing>
<x-icon name="arrow_drop_down" class="field-trailing field-arrow size-(--field-icon)" />
<x-livewire-material::icon name="arrow_drop_down" class="field-trailing field-arrow size-(--field-icon)" />
</x-slot:trailing>
</x-field>
</x-livewire-material::field>
+1 -1
View File
@@ -321,7 +321,7 @@
@class(['absolute top-1/2 flex -translate-y-1/2 rtl:-scale-x-100', $iconInk[$color], 'group-has-disabled/slider:text-on-surface/38'])
style="left: {{ $calc($iconAt) }}"
>
<x-icon :name="$icon" :class="$iconSize === 32 ? 'size-8' : 'size-6'" />
<x-livewire-material::icon :name="$icon" :class="$iconSize === 32 ? 'size-8' : 'size-6'" />
</span>
@endif
</div>
@@ -29,7 +29,7 @@
])
>
{{ $slot }}
<x-icon :name="$direction === 'desc' ? 'arrow_downward' : 'arrow_upward'" :class="\Illuminate\Support\Arr::toCssClasses([
<x-livewire-material::icon :name="$direction === 'desc' ? 'arrow_downward' : 'arrow_upward'" :class="\Illuminate\Support\Arr::toCssClasses([
'size-4 transition-opacity duration-(--md-sys-motion-effects-fast-duration)',
'opacity-0 group-hover/sort:opacity-60 group-focus-visible/sort:opacity-60' => ! $active,
])" />
@@ -37,7 +37,7 @@
@endphp
<div data-button-group="split" data-size="{{ $size }}" {{ $attributes->only('class')->class('inline-flex items-center gap-0.5') }}>
<x-button
<x-livewire-material::button
{{ $attributes->except('class') }}
:label="$label"
:icon="$icon"
@@ -51,9 +51,9 @@
:class="$leadingPadding"
/>
<x-menu :position="$position" :label="$menuLabel">
<x-livewire-material::menu :position="$position" :label="$menuLabel">
<x-slot:trigger>
<x-button
<x-livewire-material::button
icon="keyboard_arrow_down"
:aria-label="$menuLabel"
:variant="$variant"
@@ -67,5 +67,5 @@
</x-slot:trigger>
{{ $slot }}
</x-menu>
</x-livewire-material::menu>
</div>
+1 -1
View File
@@ -17,7 +17,7 @@
<div {{ $attributes->class('flex flex-col gap-1 rounded-corner-lg bg-surface-container p-4') }}>
<div class="flex items-center gap-2 type-label-lg text-on-surface-variant">
@if ($icon)
<x-icon :name="$icon" class="size-5" />
<x-livewire-material::icon :name="$icon" class="size-5" />
@endif
<span>{{ $title }}</span>
</div>
+2 -2
View File
@@ -72,12 +72,12 @@
>
<span data-tab-content>
@isset($tab['icon'])
<x-icon :name="$tab['icon']" class="size-6" />
<x-livewire-material::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']" />
<x-livewire-material::badge :value="$tab['badge']" />
@endif
</span>
<span data-tab-indicator aria-hidden="true"></span>
@@ -19,10 +19,12 @@
$model = $attributes->whereStartsWith('wire:model')->first();
$placeholder = filled($attributes->get('placeholder')) ? $attributes->get('placeholder') : ' ';
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|textarea'), 0, 12);
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : [];
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
@endphp
<x-field :$id :$label :$hint :hint-class="$hintClass" :$messages :$variant :class="$attributes->get('class')" :data-readonly="$attributes->get('readonly') ? '' : null">
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$variant :class="$attributes->get('class')" :data-readonly="$attributes->get('readonly') ? '' : null">
<textarea
{{ $attributes->except(['class', 'id', 'placeholder']) }}
id="{{ $id }}"
@@ -36,4 +38,4 @@
@if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif
class="field-control"
></textarea>
</x-field>
</x-livewire-material::field>
@@ -1,4 +1,5 @@
{{-- The theme, decided before the first paint. Include it in <head>, ahead of @vite.
{{-- The theme and the navigation rail's width, decided before the first paint. Include it in
<head>, ahead of @vite.
It reads the visitor's choice from localStorage (`livewire-material.theme.storage_key`):
`light`, `dark` or `system`, falling back to `theme.default`. `system` follows the
@@ -10,16 +11,58 @@
JSON-encoded (maryUI's `"dark"`) is adopted once and removed. Nothing is written for a
visitor who never chose, so changing `theme.default` later reaches them too.
<html> survives wire:navigate, so this only has to run on a full load. --}}
With colour profiles (`livewire-material.profiles`, generated by `material:scheme`), the
active one `Scheme::profile()`, which asks the application's resolver is written to
<html data-scheme>, which the generated stylesheet keys each profile on.
The rail rides along for the theme's reason: <html data-rail> is `expanded` or `collapsed`
(`livewire-material.rail.storage_key`, falling back to `rail.default`), and a collapsible
rail's width is CSS keyed on it (the `rail-collapsed:` variant). Set any later, a collapsed
rail would paint wide and snap shut on every load. `$store.rail` (resources/js/navigation.js)
changes it.
With `theme.meta` on, the browser's own chrome follows too: the `content` of every
<meta name="theme-color"> without a `media` attribute one is added to <head> when there is
none is the resolved theme's `surface`, from the scheme file (`Scheme`), for the profile in
<html data-scheme> (else the active one). A MutationObserver on <html> keeps it in step with
whatever changes `data-theme` or `data-scheme` afterwards: `$store.theme.set()` and `toggle()`,
an OS change while `system`, a profile preview, the application's own script. A layout's own
theme-color meta belongs before this script: one written after it is only painted on
DOMContentLoaded, beside the one added here. Off by default, and then none of it is emitted.
wire:navigate swaps the body, merges the head without running this again, and gives <html>
the next page's attributes which the server rendered without any of these, so Livewire
removes them. They are put back as the new page is swapped in (`onSwap`, in the same task,
before anything paints), so this only has to run on a full load. The head merge also puts the
next page's server-rendered theme-color meta in place of the painted one, so it is painted
again there, and once more on `livewire:navigated`. --}}
@php
$theme = config('livewire-material.theme');
$rail = config('livewire-material.rail');
$settings = [
'scheme' => \NoNameWeb\LivewireMaterial\Support\Scheme::profile(),
'default' => in_array($theme['default'] ?? null, ['light', 'dark', 'system'], true) ? $theme['default'] : 'system',
'key' => $theme['storage_key'] ?? 'material-theme',
'legacy' => array_values($theme['legacy_keys'] ?? []),
'rail' => [
'default' => ($rail['default'] ?? null) === 'collapsed' ? 'collapsed' : 'expanded',
'key' => $rail['storage_key'] ?? 'material-rail',
],
];
// Only the surfaces the meta can show: the active scheme's, and every profile's for a preview.
if ((bool) ($theme['meta'] ?? false)) {
$surfaces = fn (array $scheme): array => ['light' => $scheme['light']['surface'], 'dark' => $scheme['dark']['surface']];
$schemeProfiles = \NoNameWeb\LivewireMaterial\Support\Scheme::profiles();
$settings['meta'] = [
// PHP 8.5 deprecates a null array offset: without profiles the scheme is null.
...$surfaces(($settings['scheme'] !== null ? ($schemeProfiles[$settings['scheme']] ?? null) : null) ?? \NoNameWeb\LivewireMaterial\Support\Scheme::load()),
'profiles' => (object) collect($schemeProfiles)->map($surfaces)->all(),
];
}
@endphp
<script>
@@ -28,8 +71,15 @@
var media = window.matchMedia('(prefers-color-scheme: dark)');
var valid = function (value) { return value === 'light' || value === 'dark' || value === 'system'; };
var choice = settings.default;
var rail = settings.rail.default;
try {
var storedRail = localStorage.getItem(settings.rail.key);
if (storedRail === 'collapsed' || storedRail === 'expanded') {
rail = storedRail;
}
var stored = localStorage.getItem(settings.key);
if (valid(stored)) {
@@ -56,10 +106,67 @@
root.setAttribute('data-theme', current === 'system' ? (media.matches ? 'dark' : 'light') : current);
};
if (settings.scheme) {
root.setAttribute('data-scheme', settings.scheme);
}
root.setAttribute('data-theme-key', settings.key);
root.setAttribute('data-theme-choice', choice);
root.setAttribute('data-rail-key', settings.rail.key);
root.setAttribute('data-rail', rail);
apply();
media.addEventListener('change', apply);
@if (isset($settings['meta']))
var paintThemeColor = function () {
var theme = root.getAttribute('data-theme');
var scheme = root.getAttribute('data-scheme');
if ((theme !== 'light' && theme !== 'dark') || !document.head) {
return;
}
var colour = (Object.prototype.hasOwnProperty.call(settings.meta.profiles, scheme) ? settings.meta.profiles[scheme] : settings.meta)[theme];
var metas = document.head.querySelectorAll('meta[name="theme-color"]:not([media])');
if (metas.length === 0) {
var meta = document.createElement('meta');
meta.setAttribute('name', 'theme-color');
document.head.appendChild(meta);
metas = [meta];
}
Array.prototype.forEach.call(metas, function (meta) {
if (meta.getAttribute('content') !== colour) {
meta.setAttribute('content', colour);
}
});
};
paintThemeColor();
new MutationObserver(paintThemeColor).observe(root, { attributes: true, attributeFilter: ['data-theme', 'data-scheme'] });
document.addEventListener('DOMContentLoaded', paintThemeColor);
document.addEventListener('livewire:navigated', paintThemeColor);
@endif
document.addEventListener('livewire:navigating', function (event) {
var kept = ['data-scheme', 'data-theme', 'data-theme-choice', 'data-theme-key', 'data-rail', 'data-rail-key'].map(function (name) {
return [name, root.getAttribute(name)];
});
event.detail.onSwap(function () {
kept.forEach(function (attribute) {
if (attribute[1] !== null) {
root.setAttribute(attribute[0], attribute[1]);
}
});
@if (isset($settings['meta']))
paintThemeColor();
@endif
});
});
})(@json($settings));
</script>
@@ -40,8 +40,8 @@
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" />
<x-livewire-material::icon name="check" class="hidden size-4.5 in-aria-checked:block" />
<x-livewire-material::icon :name="$icon" class="size-4.5 in-aria-checked:hidden" />
{{ __($text) }}
</button>
@endforeach
@@ -65,12 +65,12 @@
{{ $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'" />
<x-livewire-material::icon name="light_mode" x-cloak x-show="$store.theme.choice === 'light'" />
<x-livewire-material::icon name="dark_mode" x-cloak x-show="$store.theme.choice === 'dark'" />
<x-livewire-material::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'" />
<x-livewire-material::icon name="light_mode" x-cloak x-show="$store.theme.resolved === 'dark'" />
<x-livewire-material::icon name="dark_mode" x-cloak x-show="$store.theme.resolved !== 'dark'" />
@endif
</button>
@endif
@@ -0,0 +1,364 @@
{{-- A time: M3's time picker, opened from a text field.
<x-timepicker label="Starts at" wire:model="startsAt" />
<x-timepicker label="Alarm" wire:model.live="alarm" format="24" step="5" min="06:00" max="22:00" clearable />
The field is read-only and opens the picker in a modal `<dialog>` on a press, Enter, Space or
ArrowDown (the clock icon at its end opens it too). The picker is M3's dial: the hour and minute
boxes on top, AM and PM beside them on a 12-hour clock, the clock face below. A press or a drag
on the dial picks the hour, then it moves on to the minutes; the arrow keys change the value on
the focused dial, Home and End go to the ends, Enter confirms, and Escape, Cancel or a press on
the scrim close it without a change, handing focus back to the field. The keyboard icon swaps
the dial for M3's input variant: two text fields, with the error under a field that holds
something impossible. In a landscape window the dial lies on its side, as Compose lays it out.
`wire:model` (or `x-model`) holds the time as `H:i`, and null until one is chosen; a value with
seconds (`09:30:00`, from a `time` column) is read, and written back as `H:i`. Nothing is written
until OK. The draft starts at the bound time, or now. Errors are read from the bag under the
`wire:model` name and replace the hint.
`format` is `12` or `24`; without it the hour cycle is the locale's (`locale`, the app locale by
default) as `Intl.DateTimeFormat` reports it, and the field shows the time as the locale writes
it. `step` is the minute step (a tap on the minute dial picks fives, or steps when five is not a
multiple of the step; a drag and the arrows move by the step). `min` and `max` (`H:i`, inclusive;
a `min` later than `max` spans midnight) grey out and skip what lies outside them and turn typed
values outside them into errors validate on the server as well. `clearable` adds a button that
empties the field; `name` posts the value from a hidden input. `label`, `hint`, `icon`, `variant`
and `size` are the field's; other attributes (`required`, `disabled`, `placeholder`) reach the
field's input.
From androidx Compose Material 3 at commit 27cf9a7d5788aa0f5f2d8b6699ce279560daf326 (Apache-2.0):
TimePickerTokens and TimeInputTokens a surface-container-high dialog with an extra-large
corner and elevation 3, a 256dp surface-container-highest dial with body-large numbers, a primary
selector with a 48dp handle, a 2dp line and an 8dp centre, 96×80 time selector boxes in
display-large (primary-container when selected), 96×72 time fields in display-medium and
TimePicker.kt and TimePickerDialog.kt for the layout, the 24-hour inner ring (1223, at 69dp;
0011 outside at 101dp, as Material Components for Android labels them too), the gestures, the
move on to minutes and the error texts. The period selector is Compose's current default
(`isUpdatedTimepickerToggleEnabled`): two separate shape-morphing toggle buttons in
primary-container, not the outlined pair its tokens still describe. The dial's numbers are
aria-hidden; the dial is a `slider` whose value text names the hour or minute. The dialog is
`wire:ignore`, so a Livewire render never closes an open picker or resets its draft. --}}
@props([
'label' => null,
'hint' => null,
'icon' => null,
'variant' => null,
'size' => 'md',
'format' => null,
'locale' => null,
'min' => null,
'max' => null,
'step' => 1,
'value' => null,
'name' => null,
'clearable' => false,
])
@php
$model = $attributes->wire('model')->value() ?: null;
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
$errorKey = $model ?? (filled($name) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $name) : null);
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
$id = $attributes->get('id') ?? 'timepicker-'.substr(md5($model.'|'.$label.'|'.$name.'|timepicker'), 0, 12);
$cycle = in_array((string) $format, ['12', '24'], true) ? (int) $format : null;
$locale = str_replace('_', '-', filled($locale) ? $locale : app()->getLocale());
$interval = is_numeric($step) && (int) $step >= 1 && (int) $step <= 60 ? (int) $step : 1;
$clock = fn (mixed $time): ?string => is_string($time) && preg_match('/^([01]?\d|2[0-3]):([0-5]\d)/', $time, $parts) === 1
? sprintf('%02d:%02d', $parts[1], $parts[2])
: null;
$earliest = $clock($min);
$latest = $clock($max);
// Rendered as the bound property already says, so the field is not empty until Alpine starts.
$current = $value;
if ($model !== null && ($component = \Livewire\Livewire::current()) !== null) {
$current = data_get($component, $model);
}
$current = $clock($current);
// The first frame writes the time as the browser will; ICU decides the hour cycle on both sides.
$display = '';
if ($current !== null) {
$time = \Carbon\CarbonImmutable::createFromFormat('!H:i', $current, 'UTC');
$shown = $cycle;
if (class_exists(\IntlDatePatternGenerator::class)) {
$generator = new \IntlDatePatternGenerator(str_replace('-', '_', $locale));
$shown ??= preg_match('/[hK]/', preg_replace("/'[^']*'/", '', (string) $generator->getBestPattern('j'))) === 1 ? 12 : 24;
$pattern = $generator->getBestPattern($shown === 12 ? 'hmm' : 'Hmm');
$display = (string) (new \IntlDateFormatter(str_replace('-', '_', $locale), \IntlDateFormatter::NONE, \IntlDateFormatter::NONE, 'UTC', null, $pattern))->format($time);
} else {
$display = $shown === 12 ? $time->format('g:i A') : $time->format('H:i');
}
}
$config = [
'locale' => $locale,
'format' => $cycle,
'min' => $earliest,
'max' => $latest,
'step' => $interval,
'strings' => [
'am' => __('AM'),
'pm' => __('PM'),
'oclock' => __(':hour o\'clock'),
'hours' => __(':hour hours'),
'minutes' => __(':minute minutes'),
'hourError12' => __('Hour must be 112'),
'hourError24' => __('Hour must be 023'),
'minuteError' => __('Minute must be 059'),
'stepError' => __('Minute must be a multiple of :step'),
'between' => __('Choose a time from :min to :max'),
'after' => __('Choose :min or later'),
'before' => __('Choose :max or earlier'),
],
];
$constrained = $earliest !== null || $latest !== null || $interval > 1;
// The dial's numbers, placed round the ring from twelve o'clock (TimePicker.kt's CircularLayout).
$spot = fn (int $index): string => sprintf('--x: %.4F; --y: %.4F', sin(deg2rad($index * 30)), -cos(deg2rad($index * 30)));
$sets = [
'hour12' => array_map(fn (int $index): array => ['value' => $index ?: 12, 'text' => $index ?: 12, 'index' => $index, 'inner' => false, 'allowed' => "hourAllowed({$index} + (isPm ? 12 : 0))"], range(0, 11)),
'hour24' => [
...array_map(fn (int $index): array => ['value' => $index, 'text' => $index === 0 ? '00' : $index, 'index' => $index, 'inner' => false, 'allowed' => "hourAllowed({$index})"], range(0, 11)),
...array_map(fn (int $index): array => ['value' => $index + 12, 'text' => $index + 12, 'index' => $index, 'inner' => true, 'allowed' => 'hourAllowed('.($index + 12).')'], range(0, 11)),
],
'minute' => array_map(fn (int $index): array => ['value' => $index * 5, 'text' => $index === 0 ? '00' : $index * 5, 'index' => $index, 'inner' => false, 'allowed' => 'minuteAllowed('.($index * 5).')'], range(0, 11)),
];
$inputAttributes = $attributes->whereDoesntStartWith(['wire:model', 'x-model'])->except(['class', 'id', 'wire:key', 'placeholder']);
$disabled = (bool) $attributes->get('disabled');
$described = $messages !== [] || filled($hint);
@endphp
<div
{{ $attributes->only(['class', 'wire:key', 'x-model']) }}
x-data="materialTimepicker(@if ($model !== null) @entangle($attributes->wire('model')) @else @js($current) @endif, @js($config))"
@if ($model === null) x-modelable="value" @endif
>
<x-livewire-material::field :$id :$label :$hint :$messages :$icon :$size :$variant data-timepicker-field>
<input
{{ $inputAttributes }}
id="{{ $id }}"
type="text"
readonly
value="{{ $display }}"
x-bind:value="display"
x-ref="input"
placeholder="{{ filled($attributes->get('placeholder')) ? $attributes->get('placeholder') : ' ' }}"
autocomplete="off"
aria-haspopup="dialog"
aria-controls="{{ $id }}-dialog"
aria-expanded="false"
x-bind:aria-expanded="open.toString()"
@if ($messages !== []) aria-invalid="true" @endif
@if ($described) aria-describedby="{{ $id }}-support" @endif
class="field-control"
x-on:click="show()"
x-on:keydown.enter.prevent="show()"
x-on:keydown.space.prevent="show()"
x-on:keydown.arrow-down.prevent="show()"
/>
<x-slot:trailing>
@if ($clearable)
<button
type="button"
x-on:click="value = null; $refs.input.focus()"
class="field-trailing field-clear field-button"
aria-label="{{ __('Clear') }}"
data-field-clear
@disabled($disabled)
>
<x-livewire-material::icon name="close" class="size-(--field-icon)" />
</button>
@endif
<button
type="button"
tabindex="-1"
x-on:click="show()"
class="field-trailing field-button"
aria-label="{{ __('Choose time') }}"
data-timepicker-open
@disabled($disabled)
>
<x-livewire-material::icon name="schedule" class="size-(--field-icon)" />
</button>
</x-slot:trailing>
</x-livewire-material::field>
@if (filled($name))
<input type="hidden" name="{{ $name }}" value="{{ $current }}" x-bind:value="value ?? ''" />
@endif
<dialog
id="{{ $id }}-dialog"
wire:ignore
x-ref="dialog"
aria-labelledby="{{ $id }}-title"
data-timepicker-dialog
x-on:close="closed()"
x-on:pointerdown="scrimPressed = $event.target === $el"
x-on:click.self="if (scrimPressed) cancel()"
class="m-auto overflow-visible bg-transparent p-0 text-on-surface backdrop:bg-scrim/32 opacity-100 scale-100 starting:opacity-0 starting:scale-95 transition-[opacity,scale] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast"
>
<div data-timepicker-surface x-bind:data-mode="mode" data-mode="dial">
<h2 id="{{ $id }}-title" data-timepicker-title x-text="mode === 'dial' ? @js(__('Select time')) : @js(__('Enter time'))">{{ __('Select time') }}</h2>
<div data-timepicker-picker>
<div data-timepicker-display>
<div data-timepicker-numbers>
<button
type="button"
data-timepicker-box="hour"
class="state-layer focus-ring"
aria-pressed="true"
x-bind:aria-pressed="(view === 'hour').toString()"
x-bind:aria-label="@js(__('Select hour')) + ', ' + hourLabel"
x-on:click="choose('hour')"
x-text="hourLabel"
></button>
<span data-timepicker-separator aria-hidden="true">:</span>
<button
type="button"
data-timepicker-box="minute"
class="state-layer focus-ring"
aria-pressed="false"
x-bind:aria-pressed="(view === 'minute').toString()"
x-bind:aria-label="@js(__('Select minutes')) + ', ' + minuteLabel"
x-on:click="choose('minute')"
x-text="minuteLabel"
></button>
</div>
<div data-timepicker-period role="group" aria-label="{{ __('Select AM or PM') }}" x-show="! is24">
@foreach ([false => 'am', true => 'pm'] as $pm => $period)
<button
type="button"
data-timepicker-period-option="{{ $period }}"
class="state-layer focus-ring"
x-bind:aria-pressed="({{ $pm ? '' : '! ' }}isPm).toString()"
x-bind:disabled="! periodAllowed({{ $pm ? 'true' : 'false' }})"
x-on:click="setPeriod({{ $pm ? 'true' : 'false' }})"
x-text="periods[{{ (int) $pm }}]"
>{{ $pm ? __('PM') : __('AM') }}</button>
@endforeach
</div>
</div>
<div
x-ref="dial"
data-timepicker-dial
role="slider"
tabindex="0"
aria-valuemin="0"
x-bind:aria-label="view === 'hour' ? @js(__('Select hour')) : @js(__('Select minutes'))"
x-bind:aria-valuemax="view === 'hour' ? 23 : 59"
x-bind:aria-valuenow="view === 'hour' ? hour : minute"
x-bind:aria-valuetext="valueText"
x-bind:data-view="view"
x-bind:data-cycle="is24 ? '24' : '12'"
x-bind:data-inner="inner ? '' : null"
x-bind:data-dragging="dragging ? '' : null"
x-bind:style="{ '--timepicker-angle': angle + 'deg' }"
x-on:pointerdown="press($event)"
x-on:keydown="key($event)"
>
@foreach (['labels', 'ink'] as $layer)
@if ($layer === 'ink')
<div data-timepicker-selector aria-hidden="true">
<span data-timepicker-track></span>
<span data-timepicker-centre></span>
<span data-timepicker-handle></span>
</div>
@endif
<div data-timepicker-{{ $layer }} aria-hidden="true">
@foreach ($sets as $set => $labels)
<div data-timepicker-set="{{ $set }}">
@foreach ($labels as $spotLabel)
<span
@if ($layer === 'labels') data-timepicker-label="{{ $set }}" data-value="{{ $spotLabel['value'] }}" @endif
@if ($spotLabel['inner']) data-inner @endif
@if ($constrained && $layer === 'labels') x-bind:data-disabled="{{ $spotLabel['allowed'] }} ? null : ''" @endif
style="{{ $spot($spotLabel['index']) }}"
>{{ $spotLabel['text'] }}</span>
@endforeach
</div>
@endforeach
</div>
@endforeach
</div>
</div>
<div data-timepicker-typing>
<div data-timepicker-inputs>
@foreach (['hour' => __('Hour'), 'minute' => __('Minute')] as $part => $partLabel)
@if ($part === 'minute')
<span data-timepicker-separator aria-hidden="true">:</span>
@endif
<div data-timepicker-column>
<input
id="{{ $id }}-{{ $part }}"
type="text"
inputmode="numeric"
maxlength="2"
autocomplete="off"
data-timepicker-input="{{ $part }}"
x-ref="{{ $part }}Input"
aria-label="{{ $partLabel }}"
aria-describedby="{{ $id }}-{{ $part }}-support"
x-bind:aria-invalid="{{ $part }}Error ? 'true' : null"
x-bind:value="{{ $part }}Text"
x-on:input="{{ $part === 'hour' ? 'typeHour' : 'typeMinute' }}($event)"
x-on:focus="view = '{{ $part }}'; $el.select()"
x-on:keydown.enter.prevent="confirm()"
/>
<p
id="{{ $id }}-{{ $part }}-support"
data-timepicker-support
aria-live="polite"
x-bind:data-error="{{ $part }}Error ? '' : null"
x-text="{{ $part }}Error ?? @js($partLabel)"
>{{ $partLabel }}</p>
</div>
@endforeach
<div data-timepicker-period role="group" aria-label="{{ __('Select AM or PM') }}" x-show="! is24">
@foreach ([false => 'am', true => 'pm'] as $pm => $period)
<button
type="button"
data-timepicker-period-option="{{ $period }}"
class="state-layer focus-ring"
x-bind:aria-pressed="({{ $pm ? '' : '! ' }}isPm).toString()"
x-bind:disabled="! periodAllowed({{ $pm ? 'true' : 'false' }})"
x-on:click="setPeriod({{ $pm ? 'true' : 'false' }})"
x-text="periods[{{ (int) $pm }}]"
>{{ $pm ? __('PM') : __('AM') }}</button>
@endforeach
</div>
</div>
<p data-timepicker-range-error role="alert" x-show="rangeError" x-text="rangeError"></p>
</div>
<div data-timepicker-actions>
<span data-timepicker-when="dial">
<x-livewire-material::button icon="keyboard" :tooltip="__('Switch to text input mode')" x-on:click="toggleMode()" data-timepicker-mode="input" />
</span>
<span data-timepicker-when="input">
<x-livewire-material::button icon="schedule" :tooltip="__('Switch to clock mode')" x-on:click="toggleMode()" data-timepicker-mode="dial" />
</span>
<span data-timepicker-spacer></span>
<x-livewire-material::button :label="__('Cancel')" x-on:click="cancel()" data-timepicker-cancel />
<x-livewire-material::button :label="__('OK')" x-on:click="confirm()" data-timepicker-confirm />
</div>
</div>
</dialog>
</div>
+19 -9
View File
@@ -5,9 +5,18 @@
It shows every `toast` browser event what `NoNameWeb\LivewireMaterial\Concerns\Toasts`
dispatches from a Livewire component and for `window.materialToast(title, options)` from
JavaScript (`{ type, description, timeout, action: { label, handler } }`). Toasts queue and
show in turn, each for its `timeout` (4s by default; M3 asks for 410s), paused while the
pointer or focus is on it. A toast with an action or no timeout gets a close button.
JavaScript (`{ type, description, timeout, sticky, action: { label, handler, event } }`).
Toasts queue and show in turn, each for its `timeout` (4s by default; M3 asks for 410s),
paused while the pointer or focus is on it. A toast with an action or no timeout gets a close
button. Pressing the action closes the snackbar, calls `handler` and dispatches `event` (a
name) on `window`; both may be given.
`sticky: true` keeps a toast until it is dismissed or its action pressed, without holding up
the queue: a toast that arrives meanwhile shows in its place, and the sticky one comes back
once the queue is empty. One is kept at a time; a newer sticky toast replaces it.
Hooks for tests and styling: `data-toast` on the snackbar on screen, `data-toast-action` on its
action button.
`@persist` keeps the host across wire:navigate, so a toast dispatched with `redirectTo` is
still on screen when the next page arrives.
@@ -32,6 +41,7 @@
<template x-if="current">
<div
x-bind:key="current.id"
data-toast
x-bind:role="current.type === 'error' || current.type === 'warning' ? 'alert' : 'status'"
aria-live="polite"
x-on:mouseenter="pause()"
@@ -47,10 +57,10 @@
'text-inverse-warning': current.type === 'warning',
'text-inverse-info': current.type === 'info',
}">
<span x-show="current.type === 'success'"><x-icon name="check_circle" filled class="size-6" /></span>
<span x-show="current.type === 'error'"><x-icon name="error" filled class="size-6" /></span>
<span x-show="current.type === 'warning'"><x-icon name="warning" filled class="size-6" /></span>
<span x-show="current.type === 'info'"><x-icon name="info" filled class="size-6" /></span>
<span x-show="current.type === 'success'"><x-livewire-material::icon name="check_circle" filled class="size-6" /></span>
<span x-show="current.type === 'error'"><x-livewire-material::icon name="error" filled class="size-6" /></span>
<span x-show="current.type === 'warning'"><x-livewire-material::icon name="warning" filled class="size-6" /></span>
<span x-show="current.type === 'info'"><x-livewire-material::icon name="info" filled class="size-6" /></span>
</span>
</template>
@@ -60,12 +70,12 @@
</div>
<template x-if="current.action">
<button type="button" class="state-layer focus-ring h-10 shrink-0 rounded-corner-full px-3 type-label-lg text-inverse-primary" x-text="current.action.label" x-on:click="act()"></button>
<button type="button" data-toast-action class="state-layer focus-ring h-10 shrink-0 rounded-corner-full px-3 type-label-lg text-inverse-primary" x-text="current.action.label" x-on:click="act()"></button>
</template>
<template x-if="current.action || ! current.timeout">
<button type="button" class="state-layer focus-ring inline-flex size-10 shrink-0 items-center justify-center rounded-corner-full" aria-label="{{ __('Dismiss') }}" x-on:click="dismiss()">
<x-icon name="close" class="size-5" />
<x-livewire-material::icon name="close" class="size-5" />
</button>
</template>
</div>
+2 -2
View File
@@ -36,8 +36,8 @@
/>
<span data-handle>
@if ($icons)
<x-icon name="check" class="size-4" data-on />
<x-icon name="close" class="size-4" data-off />
<x-livewire-material::icon name="check" class="size-4" data-on />
<x-livewire-material::icon name="close" class="size-4" data-off />
@endif
</span>
</span>
@@ -0,0 +1,10 @@
{{-- Forbidden. A message the app passed along (`abort(403, '…')`, a policy's `Response::deny('')`)
is the sentence, as in the framework's own page; otherwise a general one. --}}
@extends('errors::minimal')
@section('title', __('Forbidden'))
@section('code', '403')
@section('shape', 'gem')
@section('headline', __('You dont have access'))
@section('message', isset($exception) && $exception->getMessage() !== '' ? __($exception->getMessage()) : __('Your account isnt allowed to open this page.'))
@@ -0,0 +1,9 @@
{{-- Not found. --}}
@extends('errors::minimal')
@section('title', __('Not Found'))
@section('code', '404')
@section('shape', 'cookie-9')
@section('headline', __('Page not found'))
@section('message', __('The page youre looking for doesnt exist or has moved.'))
@@ -0,0 +1,16 @@
{{-- Page expired: the form's CSRF token outlived the session. Reloading the page the form was on
issues a new one, so that is the action a link to it, never a reload of this response,
which would post the stale form again. --}}
@extends('errors::minimal')
@section('title', __('Page Expired'))
@section('code', '419')
@section('shape', 'clover-4')
@section('headline', __('This page has expired'))
@section('message', __('It was open for a while. Refresh it, then try again.'))
@section('actions')
<x-livewire-material::button :link="url()->previous()" :label="__('Refresh the page')" variant="filled" size="md" no-wire-navigate />
<x-livewire-material::button :link="url('/')" :label="__('Go home')" variant="text" size="md" no-wire-navigate />
@endsection
@@ -0,0 +1,9 @@
{{-- Too many requests. --}}
@extends('errors::minimal')
@section('title', __('Too Many Requests'))
@section('code', '429')
@section('shape', 'cookie-12')
@section('headline', __('Slow down a little'))
@section('message', __('There were too many requests in a short time. Wait a moment, then try again.'))
@@ -0,0 +1,9 @@
{{-- Server error. Nothing from the exception is shown: its message is for the log, not the visitor. --}}
@extends('errors::minimal')
@section('title', __('Server Error'))
@section('code', '500')
@section('shape', 'soft-burst')
@section('headline', __('Something went wrong'))
@section('message', __('An error on our side stopped this page from loading. Please try again in a moment.'))
@@ -0,0 +1,22 @@
{{-- Service unavailable, usually maintenance mode. A message the app passed (`abort(503, '…')`) is
the sentence; maintenance mode's own "Service Unavailable" is not a message, so it is not.
`php artisan down --render="errors::503"` renders this once, in the console, with no
`$exception` and no request so the one action reloads whatever address the visitor is
on, in the browser, rather than a URL decided here. --}}
@extends('errors::minimal')
@php
$reason = isset($exception) ? $exception->getMessage() : '';
@endphp
@section('title', __('Service Unavailable'))
@section('code', '503')
@section('shape', 'puffy')
@section('headline', __('Well be right back'))
@section('message', $reason !== '' && $reason !== 'Service Unavailable' ? __($reason) : __('Were making some improvements. Please check back soon.'))
@section('actions')
<x-livewire-material::button :label="__('Try again')" variant="filled" size="md" onclick="location.reload()" />
@endsection
@@ -0,0 +1,83 @@
{{-- The layout every error page extends: this package's 403, 404, 419, 429, 500 and 503, and the
framework's own 401 and 402, which extend `errors::minimal` and find this file before the
framework's (the provider appends this folder's parent to `view.paths`).
Sections the framework layout's `title`, `code` and `message`, plus three of its own, so
either layout renders the other's pages:
`title` the browser tab, followed by the app name;
`code` the status, drawn in display type over the shape;
`headline` what happened, in a few words (without it, `message` is the headline);
`message` one sentence on what to do about it;
`shape` an M3 Expressive shape name (`cookie-7` by default), in primary-container;
`actions` the buttons; by default a filled Home and, when the visitor came from a page on
the way here, a text Back.
The app's own Vite entries (`livewire-material.showcase.vite`) bring its scheme, font and
utilities. But an error page is also what shows while a deploy has no build yet, so when
those tags cannot be made the page brings a small stylesheet of its own: the app's scheme
from its scheme data, drawn onto the `data-error-*` hooks. Keep the hooks when changing
the markup. The shape turns once a minute, unless the visitor asks for reduced motion. --}}
@php
$assets = \NoNameWeb\LivewireMaterial\Support\ErrorPage::assets();
$back = \NoNameWeb\LivewireMaterial\Support\ErrorPage::backUrl();
@endphp
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex" />
<title>@yield('title') · {{ config('app.name') }}</title>
<x-livewire-material::theme-script />
@if ($assets !== null)
{{ $assets }}
@else
<style data-error-fallback>{{ \NoNameWeb\LivewireMaterial\Support\ErrorPage::fallbackStyles() }}</style>
@endif
<style>
@keyframes material-error-turn { to { transform: rotate(1turn); } }
@media (prefers-reduced-motion: no-preference) { [data-error-shape] { animation: material-error-turn 60s linear infinite; } }
</style>
</head>
<body class="min-h-dvh bg-surface font-sans text-on-surface antialiased">
<main data-error-page class="mx-auto flex min-h-dvh max-w-xl flex-col items-center justify-center px-6 py-12 text-center">
<div data-error-art class="relative grid size-48 shrink-0 place-items-center sm:size-60">
<div data-error-shape class="absolute inset-0">
<x-livewire-material::shape :name="trim($__env->yieldContent('shape', 'cookie-7'))" class="size-full text-primary-container" />
</div>
<p data-error-code class="relative type-emphasized-display-lg text-on-primary-container tabular-nums">@yield('code')</p>
</div>
<h1 data-error-headline class="mt-10 type-headline-md text-balance sm:type-headline-lg">
@hasSection('headline')
@yield('headline')
@else
@yield('message')
@endif
</h1>
@hasSection('headline')
<p data-error-message class="mt-3 max-w-md type-body-lg text-balance text-on-surface-variant">@yield('message')</p>
@endif
<div data-error-actions class="mt-10 flex flex-wrap items-center justify-center gap-3">
@hasSection('actions')
@yield('actions')
@else
<x-livewire-material::button :link="url('/')" :label="__('Go home')" variant="filled" size="md" no-wire-navigate />
@if ($back !== null)
<x-livewire-material::button :link="$back" :label="__('Go back')" variant="text" size="md" no-wire-navigate />
@endif
@endif
</div>
</main>
</body>
</html>
@@ -0,0 +1,24 @@
{{-- The mail's masthead: the app name in title-lg, or the logo from
`livewire-material.mail.logo` (`src`, `width`, `height`).
Replaces the framework's header, which swaps the name for Laravel's own logo whenever the
app is still called "Laravel". A logo keeps the name as its alt text, for readers with
images off; its width and height are repeated as attributes because Outlook sizes an image
from those and ignores the stylesheet. Serve the file at twice those dimensions to stay
crisp, from an absolute URL a queued mail has no request to resolve a relative one. --}}
@props(['url', 'logo' => config('livewire-material.mail.logo')])
@php
$name = trim(strip_tags((string) $slot));
$src = is_array($logo) ? ($logo['src'] ?? null) : null;
@endphp
<tr>
<td class="header">
<a href="{{ $url }}" style="display: inline-block;">
@if (filled($src))
<img src="{{ $src }}" class="logo" alt="{{ $name }}" @if (filled($logo['width'] ?? null)) width="{{ $logo['width'] }}" @endif @if (filled($logo['height'] ?? null)) height="{{ $logo['height'] }}" @endif style="border: 0;">
@else
{{ $name }}
@endif
</a>
</td>
</tr>
@@ -0,0 +1,35 @@
{{-- Every Markdown mail passes through here. The framework's message, with the footer open to
the mail: a `<x-slot:footer>` inside `<x-mail::message>` replaces the default line for the
sender's address, a privacy link, or an unsubscribe link on mail that is not transactional.
Keep the Markdown below unindented: four spaces make a code block. --}}
<x-mail::layout>
{{-- Header --}}
<x-slot:header>
<x-mail::header :url="config('app.url')">
{{ config('app.name') }}
</x-mail::header>
</x-slot:header>
{{-- Body --}}
{!! $slot !!}
{{-- Subcopy --}}
@isset($subcopy)
<x-slot:subcopy>
<x-mail::subcopy>
{!! $subcopy !!}
</x-mail::subcopy>
</x-slot:subcopy>
@endisset
{{-- Footer --}}
<x-slot:footer>
<x-mail::footer>
@isset($footer)
{!! $footer !!}
@else
© {{ date('Y') }} {{ config('app.name') }}. {{ __('All rights reserved.') }}
@endisset
</x-mail::footer>
</x-slot:footer>
</x-mail::layout>
@@ -0,0 +1,32 @@
{{-- The plain-text twin of html/message.blade.php: the same footer slot, no markup. --}}
<x-mail::layout>
{{-- Header --}}
<x-slot:header>
<x-mail::header :url="config('app.url')">
{{ config('app.name') }}
</x-mail::header>
</x-slot:header>
{{-- Body --}}
{{ $slot }}
{{-- Subcopy --}}
@isset($subcopy)
<x-slot:subcopy>
<x-mail::subcopy>
{{ $subcopy }}
</x-mail::subcopy>
</x-slot:subcopy>
@endisset
{{-- Footer --}}
<x-slot:footer>
<x-mail::footer>
@isset($footer)
{{ $footer }}
@else
© {{ date('Y') }} {{ config('app.name') }}. @lang('All rights reserved.')
@endisset
</x-mail::footer>
</x-slot:footer>
</x-mail::layout>
+426
View File
@@ -0,0 +1,426 @@
{{-- The Markdown mail theme, as CSS rendered from the application's colour scheme.
Select it with `mail.markdown.theme` = `livewire-material::mail.theme` (MAIL_MARKDOWN_THEME),
or `$theme` / `->theme()` on one mailable or MailMessage. Laravel renders a namespaced theme
as a view (Illuminate\Mail\Markdown::render) and hands the result to CssToInlineStyles, which
writes it onto every element's `style` — so the colours are the app's light scheme, read
from `livewire-material.scheme` (Support\Scheme, falling back to the package's default), and
a regenerated scheme reaches the next mail without a copy to keep in step.
What a mail client can take, and so what this is:
- Hexes only. No custom properties, no `color-mix()`, no alpha: a client resolves none of
them, and Outlook drops an alpha channel.
- Light only. `Css\Processor::doCleanup()` strips every `@media` block from the theme before
inlining, so a `prefers-color-scheme` rule here would be deleted silently; it could only
live in a `<style>` in a published layout, and the framework layout declares
`color-scheme: light` in two meta tags besides. Clients that force dark invert the light
tones, which are mid-tone enough to survive it.
- M3's typescale on the bare tags CommonMark writes (`h1` headline-sm, `p` body-lg, a table
head title-sm), in px, with a system font stack: web fonts and
`font-variation-settings` reach too few clients to be worth an asset.
- No elevation. The card separates from the page by tone, as M3 surfaces do.
- The button is M3's filled button at the `md` size (56px, title-md label, full corners),
written as borders because padding on an `<a>` is not honoured everywhere. Outlook
Classic squares the corners; it is still a button. `x-mail::button`'s `color` takes any
role with a filled pair primary, secondary, tertiary, error, success, warning, info
and the framework's blue, green and red. --}}
@php
$role = \NoNameWeb\LivewireMaterial\Support\Scheme::light();
$page = $role['surface-container'];
$card = $role['surface-container-lowest'];
$tint = $role['surface-container'];
$buttons = [
'primary' => ['primary', 'on-primary'],
'secondary' => ['secondary', 'on-secondary'],
'tertiary' => ['tertiary', 'on-tertiary'],
'error' => ['error', 'on-error'],
'success' => ['success', 'on-success'],
'warning' => ['warning', 'on-warning'],
'info' => ['info', 'on-info'],
'blue' => ['primary', 'on-primary'],
'green' => ['success', 'on-success'],
'red' => ['error', 'on-error'],
];
@endphp
/* Base */
body,
body *:not(html):not(style):not(br):not(tr):not(code) {
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, Roboto, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
position: relative;
}
body {
-webkit-text-size-adjust: none;
background-color: {{ $page }};
color: {{ $role['on-surface-variant'] }};
height: 100%;
line-height: 1.5;
margin: 0;
padding: 0;
width: 100% !important;
}
p,
ul,
ol,
blockquote {
line-height: 1.5;
text-align: start;
}
a {
color: {{ $role['primary'] }};
}
a img {
border: none;
}
/* Typography: M3's typescale */
h1 {
color: {{ $role['on-surface'] }};
font-size: 24px;
font-weight: 400;
letter-spacing: 0;
line-height: 32px;
margin-top: 0;
text-align: start;
}
h2 {
color: {{ $role['on-surface'] }};
font-size: 22px;
font-weight: 400;
letter-spacing: 0;
line-height: 28px;
margin-top: 0;
text-align: start;
}
h3 {
color: {{ $role['on-surface'] }};
font-size: 16px;
font-weight: 500;
letter-spacing: 0.15px;
line-height: 24px;
margin-top: 0;
text-align: start;
}
p {
color: {{ $role['on-surface-variant'] }};
font-size: 16px;
font-weight: 400;
letter-spacing: 0.5px;
line-height: 24px;
margin-top: 0;
text-align: start;
}
ul,
ol {
color: {{ $role['on-surface-variant'] }};
font-size: 16px;
font-weight: 400;
letter-spacing: 0.5px;
line-height: 24px;
margin: 0 0 16px;
padding-left: 24px;
}
li {
margin: 0 0 4px;
}
/* A `->line()` per item makes a loose list, each item wrapped in a paragraph with a
paragraph's margin; without this it reads as numbered paragraphs, not one list. */
li p {
margin: 0;
}
/* Bold stays bold: a 500 would fall to 400 in the many system fonts without a medium weight. */
strong {
color: {{ $role['on-surface'] }};
}
blockquote {
border-left: 4px solid {{ $role['outline-variant'] }};
margin: 0 0 16px;
padding-left: 16px;
}
code {
background-color: {{ $tint }};
border-radius: 4px;
color: {{ $role['on-surface'] }};
font-size: 14px;
padding: 2px 4px;
}
hr {
border: 0;
border-top: 1px solid {{ $role['outline-variant'] }};
margin: 24px 0;
}
p.sub {
font-size: 14px;
letter-spacing: 0.25px;
line-height: 20px;
}
img {
max-width: 100%;
}
/* Layout */
.wrapper {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
background-color: {{ $page }};
margin: 0;
padding: 0;
width: 100%;
}
.content {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
margin: 0;
padding: 0;
width: 100%;
}
/* Header: the app name in title-lg, or the configured logo */
.header {
padding: 32px 0 24px;
text-align: center;
}
.header a {
color: {{ $role['on-surface'] }};
font-size: 22px;
font-weight: 500;
letter-spacing: 0;
line-height: 28px;
text-decoration: none;
}
.logo {
border: 0;
display: block;
height: auto;
margin: 0 auto;
}
/* Body: the card, separated from the page by tone alone */
.body {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
background-color: {{ $page }};
border: 0;
margin: 0;
padding: 0;
width: 100%;
}
.inner-body {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 570px;
background-color: {{ $card }};
border: 0;
border-radius: 24px;
margin: 0 auto;
padding: 0;
width: 570px;
}
/* A long URL breaks where it has to; `break-all`, the framework's value, breaks ordinary words too. */
.inner-body a {
overflow-wrap: anywhere;
}
.content-cell {
max-width: 100vw;
padding: 32px;
}
/* Subcopy */
.subcopy {
border-top: 1px solid {{ $role['outline-variant'] }};
margin-top: 24px;
padding-top: 24px;
}
.subcopy p {
font-size: 14px;
letter-spacing: 0.25px;
line-height: 20px;
}
/* Footer: body-sm */
.footer {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 570px;
margin: 0 auto;
padding: 0;
text-align: center;
width: 570px;
}
.footer p {
color: {{ $role['on-surface-variant'] }};
font-size: 12px;
font-weight: 400;
letter-spacing: 0.4px;
line-height: 16px;
text-align: center;
}
.footer a {
color: {{ $role['on-surface-variant'] }};
text-decoration: underline;
}
/* Tables: a title-sm head over an outline-variant rule, body-md cells, a rule between rows */
.table table {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
border-collapse: collapse;
margin: 24px auto;
width: 100%;
}
.table th {
border-bottom: 1px solid {{ $role['outline-variant'] }};
color: {{ $role['on-surface-variant'] }};
font-size: 14px;
font-weight: 500;
letter-spacing: 0.1px;
line-height: 20px;
margin: 0;
padding: 0 12px 12px;
text-align: start;
}
/* A Markdown table's column alignment arrives as an `align` attribute, which an inline
`text-align` would override; the head follows it here as the cells do by themselves. */
.table th[align="center"] {
text-align: center;
}
.table th[align="right"] {
text-align: right;
}
.table td {
border-bottom: 1px solid {{ $role['outline-variant'] }};
color: {{ $role['on-surface'] }};
font-size: 14px;
font-weight: 400;
font-variant-numeric: tabular-nums;
letter-spacing: 0.25px;
line-height: 20px;
margin: 0;
padding: 12px;
}
.table tr:last-child td {
border-bottom: 0;
}
/* Buttons: M3's filled button, md */
.action {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
float: unset;
margin: 32px auto;
padding: 0;
text-align: center;
width: 100%;
}
.button {
-webkit-text-size-adjust: none;
border-radius: 9999px;
display: inline-block;
font-size: 16px;
font-weight: 500;
letter-spacing: 0.15px;
line-height: 24px;
overflow: hidden;
text-decoration: none;
}
@foreach ($buttons as $name => [$container, $content])
.button-{{ $name }} {
background-color: {{ $role[$container] }};
border-bottom: 16px solid {{ $role[$container] }};
border-left: 24px solid {{ $role[$container] }};
border-right: 24px solid {{ $role[$container] }};
border-top: 16px solid {{ $role[$container] }};
color: {{ $role[$content] }};
}
@endforeach
/* Panel: a tinted container, no rule down its edge */
.panel {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
border-collapse: separate;
margin: 24px 0;
width: 100%;
}
.panel-content {
background-color: {{ $tint }};
border-radius: 16px;
color: {{ $role['on-surface'] }};
padding: 16px 20px;
}
.panel-content p {
color: {{ $role['on-surface'] }};
}
.panel-item {
padding: 0;
}
.panel-item p:last-of-type {
margin-bottom: 0;
padding-bottom: 0;
}
/* Utilities */
.break-all {
overflow-wrap: anywhere;
}
@@ -4,15 +4,15 @@
@if ($paginator->hasPages())
<nav role="navigation" aria-label="{{ __('Pagination Navigation') }}" data-pagination class="flex items-center justify-between gap-4">
@if ($paginator->onFirstPage())
<x-button variant="outlined" icon="chevron_left" :label="__('Previous')" disabled />
<x-livewire-material::button variant="outlined" icon="chevron_left" :label="__('Previous')" disabled />
@else
<x-button variant="outlined" icon="chevron_left" :label="__('Previous')" :link="$paginator->previousPageUrl()" no-wire-navigate rel="prev" />
<x-livewire-material::button variant="outlined" icon="chevron_left" :label="__('Previous')" :link="$paginator->previousPageUrl()" no-wire-navigate rel="prev" />
@endif
@if ($paginator->hasMorePages())
<x-button variant="outlined" icon-right="chevron_right" :label="__('Next')" :link="$paginator->nextPageUrl()" no-wire-navigate rel="next" />
<x-livewire-material::button variant="outlined" icon-right="chevron_right" :label="__('Next')" :link="$paginator->nextPageUrl()" no-wire-navigate rel="next" />
@else
<x-button variant="outlined" icon-right="chevron_right" :label="__('Next')" disabled />
<x-livewire-material::button variant="outlined" icon-right="chevron_right" :label="__('Next')" disabled />
@endif
</nav>
@endif
@@ -19,11 +19,11 @@
<div class="flex items-center gap-1">
@if ($paginator->onFirstPage())
<span class="{{ $dead }}" aria-disabled="true" aria-label="{{ __('Previous') }}">
<x-icon name="chevron_left" class="size-6 rtl:-scale-x-100" />
<x-livewire-material::icon name="chevron_left" class="size-6 rtl:-scale-x-100" />
</span>
@else
<a href="{{ $paginator->previousPageUrl() }}" rel="prev" class="{{ $live }}" aria-label="{{ __('Previous') }}">
<x-icon name="chevron_left" class="size-6 rtl:-scale-x-100" />
<x-livewire-material::icon name="chevron_left" class="size-6 rtl:-scale-x-100" />
</a>
@endif
@@ -45,11 +45,11 @@
@if ($paginator->hasMorePages())
<a href="{{ $paginator->nextPageUrl() }}" rel="next" class="{{ $live }}" aria-label="{{ __('Next') }}">
<x-icon name="chevron_right" class="size-6 rtl:-scale-x-100" />
<x-livewire-material::icon name="chevron_right" class="size-6 rtl:-scale-x-100" />
</a>
@else
<span class="{{ $dead }}" aria-disabled="true" aria-label="{{ __('Next') }}">
<x-icon name="chevron_right" class="size-6 rtl:-scale-x-100" />
<x-livewire-material::icon name="chevron_right" class="size-6 rtl:-scale-x-100" />
</span>
@endif
</div>
@@ -19,21 +19,21 @@
@if ($paginator->hasPages())
<nav role="navigation" aria-label="{{ __('Pagination Navigation') }}" data-pagination class="flex items-center justify-between gap-4">
@if ($paginator->onFirstPage())
<x-button variant="outlined" icon="chevron_left" :label="__('Previous')" disabled />
<x-livewire-material::button variant="outlined" icon="chevron_left" :label="__('Previous')" disabled />
@elseif ($cursor)
@php($previousCursor = $paginator->previousCursor() ?? $paginator->cursor())
<x-button variant="outlined" icon="chevron_left" :label="__('Previous')" wire:key="cursor-{{ $paginator->getCursorName() }}-{{ $previousCursor?->encode() }}" wire:click="setPage('{{ $previousCursor?->encode() }}', '{{ $paginator->getCursorName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" dusk="previousPage" />
<x-livewire-material::button variant="outlined" icon="chevron_left" :label="__('Previous')" wire:key="cursor-{{ $paginator->getCursorName() }}-{{ $previousCursor?->encode() }}" wire:click="setPage('{{ $previousCursor?->encode() }}', '{{ $paginator->getCursorName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" dusk="previousPage" />
@else
<x-button variant="outlined" icon="chevron_left" :label="__('Previous')" wire:click="previousPage('{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" dusk="previousPage" />
<x-livewire-material::button variant="outlined" icon="chevron_left" :label="__('Previous')" wire:click="previousPage('{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" dusk="previousPage" />
@endif
@if (! $paginator->hasMorePages())
<x-button variant="outlined" icon-right="chevron_right" :label="__('Next')" disabled />
<x-livewire-material::button variant="outlined" icon-right="chevron_right" :label="__('Next')" disabled />
@elseif ($cursor)
@php($nextCursor = $paginator->nextCursor() ?? $paginator->cursor())
<x-button variant="outlined" icon-right="chevron_right" :label="__('Next')" wire:key="cursor-{{ $paginator->getCursorName() }}-{{ $nextCursor?->encode() }}" wire:click="setPage('{{ $nextCursor?->encode() }}', '{{ $paginator->getCursorName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" dusk="nextPage" />
<x-livewire-material::button variant="outlined" icon-right="chevron_right" :label="__('Next')" wire:key="cursor-{{ $paginator->getCursorName() }}-{{ $nextCursor?->encode() }}" wire:click="setPage('{{ $nextCursor?->encode() }}', '{{ $paginator->getCursorName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" dusk="nextPage" />
@else
<x-button variant="outlined" icon-right="chevron_right" :label="__('Next')" wire:click="nextPage('{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" dusk="nextPage" />
<x-livewire-material::button variant="outlined" icon-right="chevron_right" :label="__('Next')" wire:click="nextPage('{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" dusk="nextPage" />
@endif
</nav>
@endif
@@ -35,11 +35,11 @@
<div class="flex items-center gap-1">
@if ($paginator->onFirstPage())
<span class="{{ $dead }}" aria-disabled="true" aria-label="{{ __('Previous') }}">
<x-icon name="chevron_left" class="size-6 rtl:-scale-x-100" />
<x-livewire-material::icon name="chevron_left" class="size-6 rtl:-scale-x-100" />
</span>
@else
<button type="button" wire:click="previousPage('{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" dusk="previousPage{{ $paginator->getPageName() == 'page' ? '' : '.'.$paginator->getPageName() }}" class="{{ $live }}" aria-label="{{ __('Previous') }}">
<x-icon name="chevron_left" class="size-6 rtl:-scale-x-100" />
<x-livewire-material::icon name="chevron_left" class="size-6 rtl:-scale-x-100" />
</button>
@endif
@@ -63,11 +63,11 @@
@if ($paginator->hasMorePages())
<button type="button" wire:click="nextPage('{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" dusk="nextPage{{ $paginator->getPageName() == 'page' ? '' : '.'.$paginator->getPageName() }}" class="{{ $live }}" aria-label="{{ __('Next') }}">
<x-icon name="chevron_right" class="size-6 rtl:-scale-x-100" />
<x-livewire-material::icon name="chevron_right" class="size-6 rtl:-scale-x-100" />
</button>
@else
<span class="{{ $dead }}" aria-disabled="true" aria-label="{{ __('Next') }}">
<x-icon name="chevron_right" class="size-6 rtl:-scale-x-100" />
<x-livewire-material::icon name="chevron_right" class="size-6 rtl:-scale-x-100" />
</span>
@endif
</div>
@@ -3,7 +3,22 @@
@props(['title' => null, 'code', 'stack' => false])
<div class="space-y-4 rounded-corner-lg bg-surface-container p-4">
@php
// Written as an application without a prefix writes it; under a configured prefix the tags are
// rewritten to it, so what renders and what is shown is what this application would write.
$prefix = config('livewire-material.prefix');
if (filled($prefix)) {
$names = collect(glob(\NoNameWeb\LivewireMaterial\LivewireMaterialServiceProvider::componentPath().'/*.blade.php'))
->map(fn (string $path): string => preg_quote(basename($path, '.blade.php'), '/'))
->sortByDesc(fn (string $name): int => strlen($name))
->implode('|');
$code = preg_replace('/<(\/?)x-('.$names.')(?=[\s\/>])/', '<$1x-'.$prefix.'::$2', $code);
}
@endphp
<div @if ($title) id="{{ \NoNameWeb\LivewireMaterial\Showcase\Sections::anchor($title) }}" @endif class="scroll-mt-24 space-y-4 rounded-corner-lg bg-surface-container p-4">
@if ($title)
<h3 class="type-title-md">{{ $title }}</h3>
@endif
+31 -22
View File
@@ -1,27 +1,36 @@
@extends('livewire-material::showcase.layout')
@section('content')
<main class="mx-auto max-w-6xl space-y-16 px-4 py-10">
<p class="max-w-3xl type-body-lg text-on-surface-variant">
Every token and component, in this application's own scheme. Components appear here as they land.
</p>
<div class="mx-auto w-full max-w-6xl space-y-12 px-4 pt-4 pb-16 sm:px-6">
<div class="max-w-3xl space-y-3">
<h1 class="type-headline-lg">Livewire Material</h1>
<p class="type-title-lg">Material 3 Expressive for Laravel and Livewire.</p>
<p class="type-body-lg text-on-surface-variant">
Every token and component, rendered in this application's own scheme and theme. Pick a section in the
navigation, search for one above (or press <kbd class="rounded-corner-xs bg-surface-container-highest px-1.5 type-label-md">/</kbd>), or start below.
</p>
</div>
@include('livewire-material::showcase.sections.colour')
@include('livewire-material::showcase.sections.type')
@include('livewire-material::showcase.sections.shape')
@include('livewire-material::showcase.sections.elevation')
@include('livewire-material::showcase.sections.motion')
@include('livewire-material::showcase.sections.icons')
@include('livewire-material::showcase.sections.buttons')
@include('livewire-material::showcase.sections.menus')
@include('livewire-material::showcase.sections.communication')
@include('livewire-material::showcase.sections.progress')
@include('livewire-material::showcase.sections.containment')
@include('livewire-material::showcase.sections.carousel')
@include('livewire-material::showcase.sections.fields')
@include('livewire-material::showcase.sections.chips')
@include('livewire-material::showcase.sections.sliders')
@include('livewire-material::showcase.sections.bars')
@include('livewire-material::showcase.sections.data')
</main>
@foreach (collect($sections)->groupBy('group', preserveKeys: true) as $group => $entries)
<section class="space-y-4" aria-labelledby="group-{{ \Illuminate\Support\Str::slug($group) }}">
<h2 id="group-{{ \Illuminate\Support\Str::slug($group) }}" class="type-title-lg">{{ $group }}</h2>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
@foreach ($entries as $key => $entry)
<x-livewire-material::card variant="outlined" data-list-row wire:key="section-{{ $key }}">
<div class="flex items-start gap-4">
<span class="grid size-12 shrink-0 place-items-center rounded-corner-lg bg-secondary-container text-on-secondary-container">
<x-livewire-material::icon :name="$entry['icon']" />
</span>
<div class="min-w-0">
<a href="{{ route('livewire-material.section', $key) }}" wire:navigate data-list-open class="type-title-md">{{ $entry['title'] }}</a>
<p class="mt-1 type-body-md text-on-surface-variant">{{ $entry['description'] }}</p>
</div>
</div>
</x-livewire-material::card>
@endforeach
</div>
</section>
@endforeach
</div>
@endsection
+160 -28
View File
@@ -1,47 +1,179 @@
{{-- The showcase's frame, and the package's own app shell at work: the rail groups every section,
collapses and expands from `lg`, and opens as a modal from the app bar's menu button on a phone.
Pages move with wire:navigate, so the rail keeps its place and the theme stays. --}}
@php
$sections ??= \NoNameWeb\LivewireMaterial\Showcase\Sections::all();
$section ??= null;
$title = $section !== null ? $sections[$section]['title'] : 'Livewire Material';
$destinations = [[
'title' => 'Overview',
'icon' => 'home',
'url' => route('livewire-material.showcase'),
'active' => $section === null,
'bar' => false,
]];
foreach ($sections as $key => $entry) {
$destinations[] = [
'title' => $entry['title'],
'icon' => $entry['icon'],
'url' => route('livewire-material.section', $key),
'active' => $key === $section,
'section' => $entry['group'],
'bar' => false,
];
}
@endphp
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="robots" content="noindex" />
<title>@yield('title', 'Livewire Material')</title>
<title>{{ $section !== null ? $title.' · Livewire Material' : 'Livewire Material' }}</title>
<x-theme-script />
<x-livewire-material::theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body class="min-h-screen bg-surface font-sans text-on-surface antialiased">
<header class="sticky top-0 z-10 border-b border-divider bg-surface-container">
<div class="mx-auto flex max-w-6xl items-center gap-x-6 px-4 py-3">
<a href="{{ route('livewire-material.showcase') }}" class="shrink-0 type-title-lg max-sm:hidden">Livewire Material</a>
<body class="bg-surface font-sans text-on-surface antialiased">
<x-livewire-material::app-shell :destinations="$destinations" label="Showcase" rail-width="17rem">
<x-slot:brand>
<a href="{{ route('livewire-material.showcase') }}" wire:navigate class="block truncate rounded-corner-xs type-title-lg focus-ring">Livewire Material</a>
</x-slot:brand>
<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', 'bars' => 'Bars', 'data' => 'Data'] as $anchor => $section)
<a href="#{{ $anchor }}" class="rounded-corner-xs hover:text-on-surface focus-ring">{{ $section }}</a>
@endforeach
</nav>
<x-slot:top>
<x-livewire-material::app-bar variant="search">
<x-slot:navigation>
<span class="sm:hidden"><x-livewire-material::button icon="menu" tooltip="Open navigation" x-data x-on:click="$store.rail.show()" data-test="showcase-menu" /></span>
</x-slot:navigation>
<div class="ms-auto flex shrink-0 rounded-corner-full border border-outline" role="group" aria-label="Theme" x-data x-cloak>
@foreach (['light' => 'Light', 'dark' => 'Dark', 'system' => 'System'] as $choice => $label)
<button
type="button"
data-test="theme-{{ $choice }}"
class="state-layer focus-ring px-4 py-1.5 type-label-lg first:rounded-s-corner-full last:rounded-e-corner-full"
x-on:click="$store.theme.set('{{ $choice }}')"
x-bind:class="$store.theme.choice === '{{ $choice }}' && 'bg-secondary-container text-on-secondary-container'"
x-bind:aria-pressed="($store.theme.choice === '{{ $choice }}').toString()"
>{{ $label }}</button>
@endforeach
</div>
</div>
</header>
{{-- The search looks through every section, example and component: the index is
fetched on first focus, and / or Ctrl+K (⌘K) reaches it from anywhere. Its names
keep clear of <x-search>'s own (`results`, `open`), which the slot also sees. --}}
<div
class="mx-auto w-full max-w-2xl"
x-data="{
query: '',
entries: null,
async load() {
this.entries ??= await (await fetch(@js(route('livewire-material.search', [], false)))).json();
},
get matches() {
const words = this.query.toLowerCase().split(/\s+/).filter(Boolean);
if (! this.entries || words.length === 0) return [];
const phrase = words.join(' ');
return this.entries
.map((entry) => {
const title = entry.title.toLowerCase().replace(/[<>]/g, '');
const text = `${title} ${entry.context} ${entry.kind} ${entry.keywords}`.toLowerCase();
if (! words.every((word) => text.includes(word))) return null;
const rank = title === phrase ? 0 : title.startsWith(phrase) ? 1 : title.includes(phrase) ? 2 : 3;
return { ...entry, rank };
})
.filter(Boolean)
.sort((a, b) => a.rank - b.rank)
.slice(0, 30);
},
go(event, result) {
if (! result || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || (event.button ?? 0) !== 0) return;
event.preventDefault();
const url = new URL(result.url, window.location.href);
const search = this.$root.querySelector('[data-search]');
this.query = '';
if (search) window.Alpine.$data(search).close();
if (url.pathname === window.location.pathname) {
window.location.hash = url.hash;
document.getElementById(url.hash.slice(1))?.scrollIntoView({ block: 'start' });
} else {
window.Livewire.navigate(url.pathname + url.hash);
}
},
shortcut(event) {
const typing = event.target.closest('input, textarea, select, [contenteditable]');
if ((event.key === '/' && ! typing) || (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey))) {
event.preventDefault();
document.getElementById('showcase-search').focus();
}
},
}"
x-on:keydown.window="shortcut($event)"
>
<x-livewire-material::search
id="showcase-search"
placeholder="Search components, examples and sections"
x-model="query"
x-on:focus.once="load()"
x-on:keydown.enter.prevent="go($event, matches[0])"
>
<p x-show="query.trim() === ''" class="px-4 py-3 type-body-md text-on-surface-variant">
Type a component (<code>datepicker</code>), an example or a section. Press <kbd class="rounded-corner-xs bg-surface-container-highest px-1.5 type-label-md">/</kbd> to search from anywhere.
</p>
<p x-cloak x-show="query.trim() !== '' && entries !== null && matches.length === 0" class="px-4 py-3 type-body-md text-on-surface-variant">
Nothing matches <span x-text="query.trim()"></span>.
</p>
<template x-for="result in matches" x-bind:key="result.url + result.title">
<a
x-bind:href="result.url"
x-on:click="go($event, result)"
class="state-layer focus-ring flex min-h-14 flex-col justify-center px-4 py-2 outline-none"
data-showcase-result
>
<span class="truncate type-body-lg text-on-surface" x-text="result.title"></span>
<span class="truncate type-body-sm text-on-surface-variant" x-text="`${result.kind} · ${result.context}`"></span>
</a>
</template>
</x-livewire-material::search>
</div>
@yield('content')
<x-slot:actions>
{{-- With colour profiles, a preview of each on this page; nothing is stored. --}}
@if (($profiles = \NoNameWeb\LivewireMaterial\Support\Scheme::profiles()) !== [])
<x-livewire-material::menu label="Colour profile" position="bottom-end" data-test="showcase-profiles">
<x-slot:trigger>
<x-livewire-material::button icon="palette" tooltip="Colour profile" />
</x-slot:trigger>
<x-toast />
@foreach ($profiles as $name => $profile)
<x-livewire-material::menu-item :label="$profile['label']" x-on:click="$store.theme.previewScheme('{{ $name }}')" data-scheme-preview="{{ $name }}" />
@endforeach
</x-livewire-material::menu>
@endif
<span class="ms-2 me-3 max-md:hidden"><x-livewire-material::theme-toggle mode="picker" /></span>
<span class="md:hidden"><x-livewire-material::theme-toggle mode="cycle" /></span>
</x-slot:actions>
</x-livewire-material::app-bar>
</x-slot:top>
@yield('content')
</x-livewire-material::app-shell>
@livewireScripts
{{-- wire:navigate keeps the URL's hash but not the scroll to it: a search result that opens an
example on another page lands on the example. --}}
<script data-navigate-once>
document.addEventListener('livewire:navigated', () => {
const target = () => (window.location.hash.length > 1 ? document.getElementById(decodeURIComponent(window.location.hash.slice(1))) : null);
const land = () => target()?.scrollIntoView({ block: 'start' });
// After the swap's own scroll to the top and the page transition, and once more when
// the page's components have settled their size.
requestAnimationFrame(() => requestAnimationFrame(land));
setTimeout(() => {
const box = target()?.getBoundingClientRect();
if (box && (box.top < 0 || box.top > window.innerHeight / 2)) {
land();
}
}, 400);
});
</script>
</body>
</html>
+40
View File
@@ -0,0 +1,40 @@
{{-- The showcase's sample Markdown mail: every part the theme styles heading, prose, bold,
a list, the button, a panel, a table, subcopy and the footer slot. Markdown, so it stays
unindented. --}}
<x-mail::message>
# Your export is ready
Hello Anna, the report you asked for on **Monday** has finished. It holds three files and stays available for seven days.
<x-mail::button :url="url('/')">
Download the export
</x-mail::button>
<x-mail::panel>
Files are deleted automatically once they expire. Download them before **20 September** to keep a copy.
</x-mail::panel>
<x-mail::table>
| File | Rows | Size |
|:-----|-----:|-----:|
| customers.csv | 1,204 | 184 KB |
| orders.csv | 8,930 | 1.2 MB |
| summary.pdf | | 96 KB |
</x-mail::table>
What happens next:
1. Open the export from the button above.
2. Check the summary before you share it.
Thanks,<br>
{{ config('app.name') }}
<x-slot:subcopy>
If the button does not work, paste this address into your browser: [{{ url('/') }}]({{ url('/') }})
</x-slot:subcopy>
<x-slot:footer>
© {{ date('Y') }} {{ config('app.name') }} · [Privacy]({{ url('/') }}) · [Unsubscribe]({{ url('/') }})
</x-slot:footer>
</x-mail::message>
@@ -0,0 +1,36 @@
{{-- One section of the showcase on a page of its own, with the way on to the next. The page's
heading names the section, so the section's own heading is only for a screen reader. --}}
@extends('livewire-material::showcase.layout')
@php
$keys = array_keys($sections);
$at = array_search($section, $keys, true);
$previous = $keys[$at - 1] ?? null;
$next = $keys[$at + 1] ?? null;
@endphp
@section('content')
<div class="mx-auto w-full max-w-6xl px-4 pt-4 pb-16 sm:px-6">
<header class="mb-6 space-y-1">
<p class="type-label-lg text-on-surface-variant">{{ $sections[$section]['group'] }}</p>
<h1 class="type-headline-lg">{{ $sections[$section]['title'] }}</h1>
</header>
<div class="space-y-16 [&>section>h2]:sr-only">
@include('livewire-material::showcase.sections.'.$section)
<nav aria-label="Sections" class="flex flex-wrap items-center justify-between gap-4 border-t border-divider pt-6">
@if ($previous)
<x-livewire-material::button variant="text" icon="arrow_back" :label="$sections[$previous]['title']" :link="route('livewire-material.section', $previous)" data-test="previous-section" />
@else
<x-livewire-material::button variant="text" icon="arrow_back" label="Overview" :link="route('livewire-material.showcase')" data-test="previous-section" />
@endif
@if ($next)
<x-livewire-material::button variant="tonal" icon-right="arrow_forward" :label="$sections[$next]['title']" :link="route('livewire-material.section', $next)" data-test="next-section" />
@endif
</nav>
</div>
</div>
@endsection
@@ -76,7 +76,7 @@
<x-button icon="format_underlined" aria-label="Underline" variant="tonal" :selected="false" />
</x-button-group>
BLADE,
'A choice as a connected group (<x-group>)' => <<<'BLADE'
'A choice as a connected group' => <<<'BLADE'
<div x-data="{ theme: 'system', days: ['mon'] }" class="grid w-full gap-6 md:grid-cols-2">
<x-group label="Theme" name="showcase-theme" x-model="theme" :options="[
['id' => 'light', 'name' => 'Light', 'icon' => 'light_mode'],
@@ -84,7 +84,7 @@
['id' => 'system', 'name' => 'System', 'icon' => 'computer'],
]" />
<x-group label="Days" name="showcase-days" x-model="days" multiple variant="outlined" hint="Choose any" :options="[
<x-group label="Days" name="showcase-days" x-model="days" multiple variant="outlined" hint="Thursday is fully booked" hint-class="text-warning" :options="[
['id' => 'mon', 'name' => 'Mon'],
['id' => 'tue', 'name' => 'Tue'],
['id' => 'wed', 'name' => 'Wed'],
@@ -10,6 +10,14 @@
'Surface' => ['bg-surface', 'bg-surface-dim', 'bg-surface-bright', 'bg-surface-container-lowest', 'bg-surface-container-low', 'bg-surface-container', 'bg-surface-container-high', 'bg-surface-container-highest', 'bg-on-surface', 'bg-on-surface-variant', 'bg-inverse-surface', 'bg-inverse-on-surface', 'bg-outline', 'bg-outline-variant'],
'Ink and lines' => ['bg-body', 'bg-meta', 'bg-quiet', 'bg-structure', 'bg-chrome', 'bg-divider'],
];
$examples = [
'Colour profiles' => <<<'BLADE'
<div x-data="{ profile: $store.theme.scheme }" class="w-full max-w-3xl">
<x-scheme-picker label="Colour profile" name="profile" x-model="profile" hint="Previews on this page; an application stores the choice and names it with Scheme::resolveProfileUsing()." />
</div>
BLADE,
];
@endphp
<section id="colour" class="scroll-mt-24 space-y-6">
@@ -20,6 +28,16 @@
<code>php artisan material:scheme</code>. Both themes side by side, whatever the page is showing.
</p>
<p class="max-w-3xl type-body-md text-on-surface-variant">
With colour profiles in <code>livewire-material.profiles</code>, the command generates each one under
<code>&lt;html data-scheme&gt;</code>, and <code>&lt;x-scheme-picker&gt;</code> chooses between them. Without
profiles the picker draws nothing.
</p>
@foreach ($examples as $title => $code)
<x-showcase::example :$title :$code />
@endforeach
<div class="grid gap-4 lg:grid-cols-2">
@foreach (['light', 'dark'] as $theme)
<div data-theme="{{ $theme }}" class="space-y-6 rounded-corner-lg bg-surface p-4 text-on-surface">
@@ -7,13 +7,29 @@
<x-badge value="Expired" tonal />
<x-badge value="Active" color="success" tonal />
<x-badge value="Password" color="info" tonal />
<x-badge value="Built in" color="primary" solid />
<x-badge value="Pro" outline />
<x-badge value="Draft" color="neutral" tonal />
<x-badge value="Archived" color="neutral" outline />
<span class="relative inline-flex"><x-icon name="chat" /><x-badge value="2" color="neutral" floating /></span>
<x-badge tonal color="tertiary"><x-icon name="bolt" filled class="size-3" /> Pro</x-badge>
<x-badge value="Beta" tonal color="plain" class="bg-primary-fixed text-on-primary-fixed" />
BLADE,
'Snackbars' => <<<'BLADE'
<x-button label="Saved" variant="tonal" x-on:click="materialToast('Settings saved', { type: 'success' })" />
<x-button label="With a description" variant="tonal" x-on:click="materialToast('Upload failed', { type: 'error', description: 'The file is larger than 4 GB.' })" />
<x-button label="With an action" variant="tonal" x-on:click="materialToast('Share deleted', { action: { label: 'Undo', handler: () => materialToast('Share restored', { type: 'info' }) } })" />
<x-button label="Until dismissed" variant="tonal" x-on:click="materialToast('Your storage is almost full', { type: 'warning', timeout: 0 })" />
<div x-data class="flex flex-wrap items-center gap-4">
<x-button label="Saved" variant="tonal" x-on:click="materialToast('Settings saved', { type: 'success' })" />
<x-button label="With a description" variant="tonal" x-on:click="materialToast('Upload failed', { type: 'error', description: 'The file is larger than 4 GB.' })" />
<x-button label="With an action" variant="tonal" x-on:click="materialToast('Share deleted', { action: { label: 'Undo', handler: () => materialToast('Share restored', { type: 'info' }) } })" />
<x-button label="Until dismissed" variant="tonal" x-on:click="materialToast('Your storage is almost full', { type: 'warning', timeout: 0 })" />
<x-button label="Sticky, with an event" variant="tonal" x-on:click="materialToast('A new version is ready', { type: 'info', sticky: true, action: { label: 'Reload', event: 'showcase:reload' } })" x-on:showcase:reload.window="materialToast('Reloading…')" />
</div>
BLADE,
'Plain tooltips' => <<<'BLADE'
<x-button icon="content_copy" tooltip="Copy link" />
<x-button icon="qr_code_2" tooltip-bottom="Show QR code" />
<x-tooltip text="Expires in 3 days" side="right">
<span tabindex="0" class="focus-ring rounded-corner-xs type-body-md text-on-surface-variant">holiday-photos.zip</span>
</x-tooltip>
BLADE,
'Rich tooltips' => <<<'BLADE'
<x-rich-tooltip title="Expiry" text="Recipients lose access after this time. Admins can change the longest time allowed.">
@@ -51,6 +67,22 @@
</x-slot:actions>
</x-empty-state>
BLADE,
'Empty state with an illustration' => <<<'BLADE'
<x-empty-state title="No routes yet" description="Draw a route on the map, or import one from a GPX file." class="w-full">
<x-slot:illustration class="text-primary">
<svg class="size-32" viewBox="0 0 120 120" fill="none" aria-hidden="true">
<circle cx="60" cy="60" r="56" class="fill-primary-container" />
<path d="M28 86C40 62 56 92 68 64S86 42 90 46" stroke="currentColor" stroke-width="5" stroke-linecap="round" stroke-dasharray="1 10" />
<circle cx="28" cy="86" r="7" fill="currentColor" />
<path d="M90 18a12 12 0 0 1 12 12c0 10-12 22-12 22S78 40 78 30a12 12 0 0 1 12-12Z" class="fill-tertiary" />
<circle cx="90" cy="30" r="4" class="fill-on-tertiary" />
</svg>
</x-slot:illustration>
<x-slot:actions>
<x-button label="Draw a route" icon="route" variant="filled" />
</x-slot:actions>
</x-empty-state>
BLADE,
];
@endphp
@@ -59,7 +91,7 @@
<p class="max-w-3xl type-body-md text-on-surface-variant">
<code>&lt;x-badge&gt;</code>, <code>&lt;x-toast&gt;</code> (the snackbar host, fed by the <code>Toasts</code> concern or <code>materialToast()</code>),
<code>&lt;x-rich-tooltip&gt;</code>, <code>&lt;x-alert&gt;</code>, <code>&lt;x-stat&gt;</code> and <code>&lt;x-empty-state&gt;</code>.
<code>&lt;x-tooltip&gt;</code>, <code>&lt;x-rich-tooltip&gt;</code>, <code>&lt;x-alert&gt;</code>, <code>&lt;x-stat&gt;</code> and <code>&lt;x-empty-state&gt;</code>.
</p>
@foreach ($examples as $title => $code)

Some files were not shown because too many files have changed in this diff Show More