Add buttons, menus and the rest of M3 Expressive's actions
tests / browser (firefox, firefox) (push) Successful in 1m54s
tests / browser (safari, webkit) (push) Successful in 2m17s
tests / lint (push) Successful in 59s
tests / feature (8.4) (push) Successful in 1m7s
tests / feature (8.5) (push) Successful in 1m0s
tests / browser (chrome, chromium) (push) Successful in 1m49s
tests / browser (firefox, firefox) (push) Successful in 1m54s
tests / browser (safari, webkit) (push) Successful in 2m17s
tests / lint (push) Successful in 59s
tests / feature (8.4) (push) Successful in 1m7s
tests / feature (8.5) (push) Successful in 1m0s
tests / browser (chrome, chromium) (push) Successful in 1m49s
<x-button> (label buttons, icon buttons and toggles in five sizes, with filled, tonal, outlined, elevated and text variants in any colour role), <x-tooltip>, <x-menu> with items, groups and separators, <x-button-group>, <x-group> as a connected button group, <x-split-button>, <x-fab>, <x-fab-menu> and <x-loading>. Sizes, colours and shapes come from androidx Compose Material 3's tokens; the loading indicator ports its Morph into SVG + SMIL. Menus follow WAI-ARIA's menu button pattern on popovers placed by CSS anchor positioning. Browser tests run in Chromium, Firefox and WebKit. The showcase fetches the icon names on demand: inlined, they tripped Pest's test server into HTTP 431s under Firefox. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
This commit is contained in:
co-authored by
Claude Opus 5
parent
b48e879254
commit
cd64f4f371
@@ -107,6 +107,112 @@ One of M3 Expressive's 35 shapes, filled in the text colour, `aria-hidden`, size
|
||||
|
||||
The theme decided before the first paint. Exactly once per layout, in `<head>`, before `@vite`. No props; configured in `config/livewire-material.php`.
|
||||
|
||||
### `<x-button>`
|
||||
|
||||
Label button, icon button, toggle and responsive FAB in one component.
|
||||
|
||||
| Prop | Default | |
|
||||
|---|---|---|
|
||||
| `label` / slot | | the words; without them and with an `icon` it is an icon button |
|
||||
| `variant` | `text` | `filled`, `tonal`, `outlined`, `elevated`, `text` |
|
||||
| `color` (alias `tone`) | `primary` | `primary`, `secondary`, `tertiary`, `error`, `success`, `warning`, `info` |
|
||||
| `primary`, `danger`, `caution` | | shorthands: filled primary, filled error, filled warning |
|
||||
| `size` | `sm` | `xs` 32px, `sm` 40px, `md` 56px, `lg` 96px, `xl` 136px |
|
||||
| `shape` | `round` | or `square`; both square off further while pressed |
|
||||
| `icon`, `icon-right` | | Material Symbol names |
|
||||
| `width` | `default` | icon buttons only: `narrow`, `default`, `wide` |
|
||||
| `selected` | `null` | `true`/`false` makes it a toggle (`aria-pressed`, selected colours and shape) |
|
||||
| `link`, `external`, `no-wire-navigate` | | renders `<a>`, with `wire:navigate` unless external |
|
||||
| `spinner` | | `true` shows the loading indicator while its `wire:click` runs; a string names the action |
|
||||
| `tooltip`, `tooltip-left`, `tooltip-right`, `tooltip-bottom` | | plain tooltip; also the icon button's accessible name |
|
||||
| `disabled`, `type`, `responsive`, `fab` | | `responsive` hides the label below `lg`; `fab` is an extended FAB below `sm`, a filled button above |
|
||||
|
||||
```blade
|
||||
<x-button label="Create link" icon="link" variant="filled" size="md" wire:click="create" spinner />
|
||||
<x-button icon="delete" tooltip="Delete share" wire:click="delete({{ $share->id }})" />
|
||||
<x-button icon="favorite" aria-label="Keep" variant="tonal" :selected="$kept" wire:click="toggleKeep" />
|
||||
```
|
||||
|
||||
### `<x-tooltip>`
|
||||
|
||||
M3's plain tooltip, standalone around any trigger: `<x-tooltip text="Copy link" side="bottom"><button>…</button></x-tooltip>`. `side`: `top` (default), `bottom`, `left`, `right`. Shows on hover (fine pointers) and keyboard focus; `aria-hidden`, so the trigger still needs its own accessible name. Buttons and FABs take a `tooltip` prop instead.
|
||||
|
||||
### `<x-menu>`, `<x-menu-item>`, `<x-menu-group>`, `<x-menu-separator>`
|
||||
|
||||
```blade
|
||||
<x-menu label="Share actions" position="bottom-end">
|
||||
<x-slot:trigger>
|
||||
<x-button icon="more_vert" tooltip="More" />
|
||||
</x-slot:trigger>
|
||||
|
||||
<x-menu-group label="Sort by">
|
||||
<x-menu-item label="Newest" :selected="$sort === 'newest'" wire:click="$set('sort', 'newest')" keep-open />
|
||||
</x-menu-group>
|
||||
<x-menu-separator />
|
||||
<x-menu-item label="Settings" icon="settings" link="{{ route('settings') }}" />
|
||||
<x-menu-item label="Delete" icon="delete" wire:click="delete" description="Recipients lose access" shortcut="⌘⌫" />
|
||||
</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-button-group>`
|
||||
|
||||
A row of `<x-button>`s: `<x-button-group label="View" size="md">…</x-button-group>`. `connected` sets them 2px apart with small inner corners (a selected toggle rounds fully). Pass the `size` of the buttons inside.
|
||||
|
||||
### `<x-group>`
|
||||
|
||||
A choice between a few options as a connected button group of native radios (checkboxes with `multiple`):
|
||||
|
||||
```blade
|
||||
<x-group label="Expires after" wire:model.live="expiry" :options="[
|
||||
['id' => '1h', 'name' => '1 hour'],
|
||||
['id' => '1d', 'name' => '1 day', 'icon' => 'today'],
|
||||
['id' => '7d', 'name' => '7 days', 'disabled' => true],
|
||||
]" 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.
|
||||
|
||||
### `<x-split-button>`
|
||||
|
||||
```blade
|
||||
<x-split-button label="Download all" icon="download" wire:click="downloadZip" menu-label="Download options">
|
||||
<x-menu-item label="Download files one by one" wire:click="downloadEach" />
|
||||
</x-split-button>
|
||||
```
|
||||
|
||||
Attributes go to the leading button; the slot is the menu. `variant` (`filled` default, `tonal`, `outlined`, `elevated`), `color`, `size`, `disabled`, `spinner`, `menu-label`, `position`.
|
||||
|
||||
### `<x-fab>`
|
||||
|
||||
`<x-fab icon="add" tooltip="New share" />` — `size` `sm` 56px (default), `md` 80px, `lg` 96px; with `label` it is an extended FAB. `color` `primary`/`secondary`/`tertiary`, drawn in the container, or `variant="filled"`. It does not position itself; wrap it (`<div class="fixed end-4 bottom-4">`). `link`, `external`, `disabled`, `type`.
|
||||
|
||||
### `<x-fab-menu>`, `<x-fab-menu-item>`
|
||||
|
||||
```blade
|
||||
<div class="fixed end-4 bottom-4">
|
||||
<x-fab-menu label="New">
|
||||
<x-fab-menu-item label="Upload files" icon="upload_file" wire:click="uploadFiles" />
|
||||
<x-fab-menu-item label="Paste text" icon="content_paste" link="{{ route('paste') }}" />
|
||||
</x-fab-menu>
|
||||
</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>`.
|
||||
|
||||
### `<x-loading>`
|
||||
|
||||
M3 Expressive's loading indicator — a shape morphing through seven Expressive shapes as it turns — for a wait of unknown length. 48px and `primary` unless sized or coloured by class; `contained` sets it on a primary-container circle. A `progressbar` named by `label` ("Loading"); `:label="false"` makes it decorative. It rests under reduced motion.
|
||||
|
||||
```blade
|
||||
<x-loading />
|
||||
<x-loading contained class="size-8" label="Uploading" />
|
||||
<div wire:loading.flex wire:target="upload"><x-loading /></div>
|
||||
```
|
||||
|
||||
`<x-button spinner>` shows a decorative one in place of its icon.
|
||||
|
||||
## Testing the design
|
||||
|
||||
```php
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* Button groups and split buttons: shapes that depend on a button's place among its siblings.
|
||||
*
|
||||
* Unlayered on purpose. Each <x-button> draws its own corners and padding as utilities, and a
|
||||
* rule in any @layer loses to a utility whatever its specificity; the group has to win.
|
||||
*
|
||||
* Standard group (`<x-button-group>`): pressing a label button widens it and narrows its
|
||||
* neighbours by the same amount, so the row keeps its length — Expressive's press expansion
|
||||
* (ButtonGroupDefaults.ExpandedRatio is 15%; this uses a fixed step per size). Icon buttons keep
|
||||
* their width.
|
||||
*
|
||||
* Connected group (`<x-button-group connected>`, `<x-group>`): 2px apart, the outer corners
|
||||
* full, the inner corners small, smaller still while pressed, and a selected segment fully round
|
||||
* (ConnectedButtonGroupSmallTokens and the M3 Expressive connected button group spec).
|
||||
*
|
||||
* 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).
|
||||
*/
|
||||
|
||||
[data-button-group] {
|
||||
--group-inner: var(--md-sys-shape-corner-sm);
|
||||
--group-inner-pressed: var(--md-sys-shape-corner-xs);
|
||||
}
|
||||
|
||||
[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='standard'] > :not([data-icon-button]):active:not(:disabled, [aria-disabled='true']) {
|
||||
padding-inline: calc(var(--group-pad) + var(--group-grow));
|
||||
}
|
||||
|
||||
[data-button-group='standard'] > :not([data-icon-button]):has(+ :not([data-icon-button]):active:not(:disabled, [aria-disabled='true'])) {
|
||||
padding-inline-end: calc(var(--group-pad) - var(--group-grow));
|
||||
}
|
||||
|
||||
[data-button-group='standard'] > :not([data-icon-button]):active:not(:disabled, [aria-disabled='true']) + :not([data-icon-button]) {
|
||||
padding-inline-start: calc(var(--group-pad) - var(--group-grow));
|
||||
}
|
||||
|
||||
/*
|
||||
* Connected segments and split halves take their inner corner from `--group-corner`, so pressing
|
||||
* or selecting changes one variable and the rounded outer corners stay put.
|
||||
*/
|
||||
[data-button-group='connected'] > *,
|
||||
[data-split] {
|
||||
--group-corner: var(--group-inner);
|
||||
border-start-start-radius: var(--group-corner);
|
||||
border-end-start-radius: var(--group-corner);
|
||||
border-start-end-radius: var(--group-corner);
|
||||
border-end-end-radius: var(--group-corner);
|
||||
}
|
||||
|
||||
[data-button-group='connected'] > :active,
|
||||
[data-split]:active {
|
||||
--group-corner: var(--group-inner-pressed);
|
||||
}
|
||||
|
||||
[data-button-group='connected'] > :is([aria-pressed='true'], :has(:checked)),
|
||||
[data-split='trailing'][aria-expanded='true'] {
|
||||
--group-corner: var(--md-sys-shape-corner-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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[data-split='trailing'] svg {
|
||||
transition: rotate var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast);
|
||||
}
|
||||
|
||||
[data-split='trailing'][aria-expanded='true'] svg {
|
||||
rotate: 180deg;
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
@import './tokens/font.css';
|
||||
@import './tokens/theme.css';
|
||||
@import './tokens/state.css';
|
||||
@import './components/groups.css';
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
|
||||
@@ -9,3 +9,5 @@
|
||||
*/
|
||||
import './theme.js'
|
||||
import './figure.js'
|
||||
import './tooltip.js'
|
||||
import './menu.js'
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* `materialMenu`: the behaviour of `<x-menu>` — WAI-ARIA's menu button pattern on a popover.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
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.
|
||||
const REOPEN_GUARD_MS = 250
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
window.Alpine.data('materialMenu', () => ({
|
||||
closedAt: -Infinity,
|
||||
returnFocus: true,
|
||||
listeners: [],
|
||||
|
||||
init() {
|
||||
const menu = this.$refs.menu
|
||||
|
||||
this.label()
|
||||
|
||||
// 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.
|
||||
this.listen(menu, 'toggle', (event) => {
|
||||
const opened = event.newState === 'open'
|
||||
|
||||
this.control()?.setAttribute('aria-expanded', String(opened))
|
||||
|
||||
if (opened) {
|
||||
return
|
||||
}
|
||||
|
||||
this.closedAt = performance.now()
|
||||
|
||||
if (this.returnFocus && menu.contains(document.activeElement)) {
|
||||
this.control()?.focus()
|
||||
}
|
||||
})
|
||||
|
||||
// A press outside closes the menu without pulling focus back to the trigger.
|
||||
this.listen(document, 'pointerdown', (event) => {
|
||||
if (!menu.contains(event.target) && !this.$refs.trigger.contains(event.target)) {
|
||||
this.returnFocus = false
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
control() {
|
||||
return this.$refs.trigger.querySelector('button, a[href], [tabindex]')
|
||||
},
|
||||
|
||||
label() {
|
||||
const control = this.control()
|
||||
|
||||
if (!control) {
|
||||
return
|
||||
}
|
||||
|
||||
control.setAttribute('aria-haspopup', 'menu')
|
||||
control.setAttribute('aria-controls', this.$refs.menu.id)
|
||||
control.setAttribute('aria-expanded', String(this.isOpen()))
|
||||
},
|
||||
|
||||
isOpen() {
|
||||
return this.$refs.menu.matches(':popover-open')
|
||||
},
|
||||
|
||||
open(focus = 'first') {
|
||||
this.label()
|
||||
|
||||
if (!this.isOpen()) {
|
||||
this.$refs.menu.showPopover()
|
||||
this.returnFocus = true
|
||||
}
|
||||
|
||||
this.control()?.setAttribute('aria-expanded', 'true')
|
||||
this.focusItem(focus)
|
||||
},
|
||||
|
||||
close() {
|
||||
if (this.isOpen()) {
|
||||
this.$refs.menu.hidePopover()
|
||||
}
|
||||
|
||||
this.control()?.setAttribute('aria-expanded', 'false')
|
||||
},
|
||||
|
||||
toggle(focus = 'first') {
|
||||
if (this.isOpen()) {
|
||||
this.close()
|
||||
} else if (performance.now() - this.closedAt > REOPEN_GUARD_MS) {
|
||||
this.open(focus)
|
||||
}
|
||||
},
|
||||
|
||||
items() {
|
||||
return [...this.$refs.menu.querySelectorAll(ITEMS)].filter((item) => item.getAttribute('aria-disabled') !== 'true')
|
||||
},
|
||||
|
||||
focusItem(which) {
|
||||
const items = this.items()
|
||||
|
||||
;(which === 'last' ? items.at(-1) : items[0])?.focus()
|
||||
},
|
||||
|
||||
navigate(event) {
|
||||
const items = this.items()
|
||||
const current = items.indexOf(document.activeElement)
|
||||
|
||||
const move = (index) => {
|
||||
event.preventDefault()
|
||||
items[(index + items.length) % items.length]?.focus()
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
return move(current + 1)
|
||||
case 'ArrowUp':
|
||||
return move(current < 0 ? items.length - 1 : current - 1)
|
||||
case 'Home':
|
||||
return move(0)
|
||||
case 'End':
|
||||
return move(items.length - 1)
|
||||
case 'Escape':
|
||||
this.returnFocus = true
|
||||
|
||||
return
|
||||
case 'Tab':
|
||||
this.returnFocus = false
|
||||
this.close()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Typeahead: a printable letter moves to the next item whose label starts with it.
|
||||
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
||||
const letter = event.key.toLowerCase()
|
||||
const ordered = [...items.slice(current + 1), ...items.slice(0, current + 1)]
|
||||
const match = ordered.find((item) => item.textContent.trim().toLowerCase().startsWith(letter))
|
||||
|
||||
if (match) {
|
||||
event.preventDefault()
|
||||
match.focus()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
activate(event) {
|
||||
const item = event.target.closest(ITEMS)
|
||||
|
||||
if (!item || item.getAttribute('aria-disabled') === 'true' || item.hasAttribute('data-keep-open')) {
|
||||
return
|
||||
}
|
||||
|
||||
this.close()
|
||||
},
|
||||
|
||||
listen(target, type, handler) {
|
||||
target.addEventListener(type, handler)
|
||||
this.listeners.push(() => target.removeEventListener(type, handler))
|
||||
},
|
||||
|
||||
destroy() {
|
||||
this.listeners.forEach((remove) => remove())
|
||||
},
|
||||
}))
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* `materialTooltip`: shows an `<x-tooltip>` popover for the control it belongs to.
|
||||
*
|
||||
* The trigger is the popover's parent element (the button, or the standalone wrapper). Hover
|
||||
* counts only for a pointer that can hover, after a short delay so a pointer passing over a
|
||||
* toolbar does not flash every label; keyboard focus shows it at once. A press, leaving, blur
|
||||
* and Escape hide it.
|
||||
*/
|
||||
const HOVER_DELAY_MS = 500
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
window.Alpine.data('materialTooltip', () => ({
|
||||
timer: null,
|
||||
listeners: [],
|
||||
|
||||
init() {
|
||||
const tip = this.$el
|
||||
const trigger = tip.parentElement
|
||||
|
||||
const open = () => tip.matches(':popover-open')
|
||||
|
||||
const show = (delay) => {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = setTimeout(() => {
|
||||
if (tip.isConnected && !open()) {
|
||||
tip.showPopover()
|
||||
}
|
||||
}, delay)
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
clearTimeout(this.timer)
|
||||
|
||||
if (open()) {
|
||||
tip.hidePopover()
|
||||
}
|
||||
}
|
||||
|
||||
this.listen(trigger, 'pointerenter', (event) => event.pointerType === 'mouse' && show(HOVER_DELAY_MS))
|
||||
this.listen(trigger, 'pointerleave', hide)
|
||||
this.listen(trigger, 'pointerdown', hide)
|
||||
this.listen(trigger, 'focusin', () => trigger.matches(':focus-within:has(:focus-visible), :focus-visible') && show(0))
|
||||
this.listen(trigger, 'focusout', hide)
|
||||
this.listen(document, 'keydown', (event) => event.key === 'Escape' && hide())
|
||||
},
|
||||
|
||||
listen(target, type, handler) {
|
||||
target.addEventListener(type, handler)
|
||||
this.listeners.push(() => target.removeEventListener(type, handler))
|
||||
},
|
||||
|
||||
destroy() {
|
||||
clearTimeout(this.timer)
|
||||
this.listeners.forEach((remove) => remove())
|
||||
},
|
||||
}))
|
||||
})
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="currentColor"><path d="M31.49 13.69c.31.23.71.35 1.13.32.98-.07 1.96-.15 2.95-.22 1.26-.1 2.19 1.16 1.71 2.34-.38.92-.75 1.84-1.12 2.76-.32.77-.03 1.66.68 2.09.84.52 1.68 1.04 2.52 1.56 1.08.66 1.09 2.23.01 2.9-.84.52-1.69 1.05-2.53 1.57-.71.44-1 1.33-.68 2.1.38.91.75 1.82 1.13 2.74.48 1.17-.44 2.44-1.7 2.35-.99-.08-1.98-.15-2.97-.22-.83-.06-1.59.49-1.78 1.3-.24.96-.47 1.92-.7 2.88-.3 1.23-1.79 1.72-2.76.9-.76-.64-1.52-1.28-2.27-1.92-.64-.54-1.57-.54-2.21 0-.75.64-1.5 1.28-2.26 1.92-.96.83-2.45.34-2.76-.89-.23-.96-.47-1.93-.71-2.89-.2-.81-.95-1.36-1.79-1.29-.98.07-1.96.15-2.95.22-1.26.1-2.19-1.16-1.71-2.34.38-.92.75-1.84 1.12-2.76.32-.77.03-1.66-.68-2.09-.84-.52-1.68-1.04-2.52-1.56-1.08-.66-1.09-2.23-.01-2.9.84-.52 1.69-1.05 2.53-1.57.71-.44 1-1.33.68-2.1-.38-.91-.75-1.82-1.13-2.74-.48-1.17.44-2.44 1.7-2.35.99.08 1.98.15 2.97.22.83.06 1.59-.49 1.78-1.3.24-.96.47-1.92.7-2.88.3-1.23 1.79-1.72 2.76-.9.76.64 1.52 1.28 2.27 1.92.64.54 1.57.54 2.21 0 .75-.64 1.5-1.28 2.26-1.92.96-.83 2.45-.34 2.76.89.23.96.47 1.93.71 2.89.1.4.34.74.66.97Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,42 @@
|
||||
{{-- An M3 Expressive button group: related `<x-button>`s in a row.
|
||||
|
||||
<x-button-group size="md" label="Text formatting">
|
||||
<x-button icon="format_bold" aria-label="Bold" variant="tonal" :selected="$bold" wire:click="toggleBold" />
|
||||
…
|
||||
</x-button-group>
|
||||
|
||||
Standard (the default): the buttons stand apart, and pressing a label button widens it while
|
||||
its neighbours give way. `connected`: 2px apart with small inner corners, the shape that
|
||||
replaced M3's segmented button; a selected (aria-pressed) button rounds fully. Give `size`
|
||||
the size of the buttons inside, so the spacing and corners match. For a choice bound to a
|
||||
property, `<x-group>` draws a connected group of radios.
|
||||
|
||||
Spacing from the M3 Expressive spec (ButtonGroupSmallTokens: 12px at the small size). The
|
||||
shapes are resources/css/components/groups.css. --}}
|
||||
|
||||
@props([
|
||||
'connected' => false,
|
||||
'size' => 'sm',
|
||||
'label' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
$size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'sm';
|
||||
@endphp
|
||||
|
||||
<div
|
||||
role="group"
|
||||
@if ($label) aria-label="{{ $label }}" @endif
|
||||
data-button-group="{{ $connected ? 'connected' : 'standard' }}"
|
||||
data-size="{{ $size }}"
|
||||
{{ $attributes->class([
|
||||
'inline-flex items-center',
|
||||
'gap-0.5' => $connected,
|
||||
'flex-wrap' => ! $connected,
|
||||
'gap-[18px]' => ! $connected && $size === 'xs',
|
||||
'gap-3' => ! $connected && $size === 'sm',
|
||||
'gap-2' => ! $connected && in_array($size, ['md', 'lg', 'xl'], true),
|
||||
]) }}
|
||||
>
|
||||
{{ $slot }}
|
||||
</div>
|
||||
@@ -0,0 +1,226 @@
|
||||
{{-- A Material 3 Expressive button — label button, icon button or toggle — in one component.
|
||||
|
||||
`variant` is M3's style: `filled`, `tonal`, `outlined`, `elevated` or `text` (the default).
|
||||
`color` is the role it is drawn in: `primary` (the default), `secondary`, `tertiary`, `error`,
|
||||
`success`, `warning`, `info`; `tone` is the same prop under ReStride's name. The shorthands
|
||||
`primary`, `danger` and `caution` mean filled primary, filled error and filled warning.
|
||||
Unknown values fall back to the defaults rather than rendering nothing.
|
||||
|
||||
`size` is Expressive's scale — `xs` 32px, `sm` 40px (the default), `md` 56px, `lg` 96px,
|
||||
`xl` 136px — each with its own padding, type style and icon size. `shape` is `round` (the
|
||||
default) or `square`; either one squares off towards a smaller corner while pressed, on the
|
||||
fast spatial spring.
|
||||
|
||||
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
|
||||
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,
|
||||
Apache-2.0); the text button's label is primary, as Compose draws it, not the token's
|
||||
on-surface-variant, which its own source marks as wrong.
|
||||
|
||||
Behaviour kept from maryUI: `link` renders an anchor with `wire:navigate` (unless `external`
|
||||
or `no-wire-navigate`); `disabled` works on a link too, as `aria-disabled`; `spinner` shows
|
||||
the loading indicator while the button's own `wire:click` runs (or the action named by a
|
||||
string); `responsive` hides the label below `lg`; `tooltip`, `tooltip-left`, `tooltip-right`
|
||||
and `tooltip-bottom` attach a plain tooltip. `fab` is a page's create action: an extended FAB
|
||||
pinned in the thumb zone below `sm`, a filled button above it — one element either way.
|
||||
|
||||
Never pass `hidden`, a display or a position class: the button is `inline-flex` and
|
||||
`relative`, and whichever Tailwind emits last wins. Wrap it instead. --}}
|
||||
|
||||
@props([
|
||||
'label' => null,
|
||||
'icon' => null,
|
||||
'iconRight' => null,
|
||||
'link' => null,
|
||||
'external' => false,
|
||||
'noWireNavigate' => false,
|
||||
'variant' => null,
|
||||
'color' => null,
|
||||
'tone' => null,
|
||||
'primary' => false,
|
||||
'danger' => false,
|
||||
'caution' => false,
|
||||
'size' => 'sm',
|
||||
'shape' => 'round',
|
||||
'width' => 'default',
|
||||
'selected' => null,
|
||||
'spinner' => null,
|
||||
'responsive' => false,
|
||||
'tooltip' => null,
|
||||
'tooltipLeft' => null,
|
||||
'tooltipRight' => null,
|
||||
'tooltipBottom' => null,
|
||||
'fab' => false,
|
||||
'disabled' => false,
|
||||
'type' => 'button',
|
||||
'corners' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
$variant = match (true) {
|
||||
in_array($variant, ['filled', 'tonal', 'outlined', 'text', 'elevated'], true) => $variant,
|
||||
$primary || $danger || $caution || $fab => 'filled',
|
||||
default => 'text',
|
||||
};
|
||||
|
||||
$color = match (true) {
|
||||
$danger => 'error',
|
||||
$caution => 'warning',
|
||||
in_array($color ?? $tone, ['primary', 'secondary', 'tertiary', 'error', 'success', 'warning', 'info'], true) => $color ?? $tone,
|
||||
default => 'primary',
|
||||
};
|
||||
|
||||
$size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'sm';
|
||||
$square = $shape === 'square';
|
||||
$width = in_array($width, ['narrow', 'default', 'wide'], true) ? $width : 'default';
|
||||
$iconOnly = filled($icon) && blank($label) && $slot->isEmpty();
|
||||
$isLink = filled($link);
|
||||
$inert = $disabled && $isLink;
|
||||
|
||||
$tip = $tooltip ?? $tooltipLeft ?? $tooltipRight ?? $tooltipBottom;
|
||||
$tipSide = match (true) {
|
||||
$tooltipLeft !== null => 'left',
|
||||
$tooltipRight !== null => 'right',
|
||||
$tooltipBottom !== null => 'bottom',
|
||||
default => 'top',
|
||||
};
|
||||
$anchor = $tip !== null ? '--material-button-'.\Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10)) : null;
|
||||
|
||||
$spinnerTarget = match (true) {
|
||||
$spinner === true, $spinner === 1, $spinner === '1' => $attributes->whereStartsWith('wire:click')->first(),
|
||||
is_string($spinner) && $spinner !== '' => $spinner,
|
||||
default => null,
|
||||
};
|
||||
|
||||
// Every colour class written out whole, so Tailwind compiles it.
|
||||
$ink = [
|
||||
'primary' => 'text-primary', 'secondary' => 'text-secondary', 'tertiary' => 'text-tertiary',
|
||||
'error' => 'text-error', 'success' => 'text-success', 'warning' => 'text-warning', 'info' => 'text-info',
|
||||
];
|
||||
$fill = [
|
||||
'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',
|
||||
];
|
||||
$container = [
|
||||
'primary' => 'bg-secondary-container text-on-secondary-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',
|
||||
];
|
||||
$containerSelected = ['primary' => 'bg-secondary text-on-secondary'] + $fill;
|
||||
$quietInk = ['primary' => 'text-on-surface-variant'] + $ink;
|
||||
|
||||
$colours = match ($variant) {
|
||||
'filled' => $selected === false ? 'bg-surface-container text-on-surface-variant' : $fill[$color],
|
||||
'tonal' => $selected === true ? $containerSelected[$color] : $container[$color],
|
||||
'outlined' => $selected === true ? 'bg-inverse-surface text-inverse-on-surface border-transparent' : 'border-outline-variant '.$quietInk[$color],
|
||||
'elevated' => $selected === true ? $fill[$color] : 'bg-surface-container-low '.$ink[$color],
|
||||
'text' => match (true) {
|
||||
$iconOnly => $selected === true ? $ink[$color] : $quietInk[$color],
|
||||
default => $selected === true ? $container[$color] : $ink[$color],
|
||||
},
|
||||
};
|
||||
|
||||
$squareCorners = ['xs' => 'rounded-corner-md', 'sm' => 'rounded-corner-md', 'md' => 'rounded-corner-lg', 'lg' => 'rounded-corner-xl', 'xl' => 'rounded-corner-xl'];
|
||||
$pressed = ['xs' => 'active:rounded-corner-sm', 'sm' => 'active:rounded-corner-sm', 'md' => 'active:rounded-corner-md', 'lg' => 'active:rounded-corner-lg', 'xl' => 'active:rounded-corner-lg'];
|
||||
|
||||
$corner = match (true) {
|
||||
$iconOnly && $selected === true => $square ? 'rounded-corner-full' : $squareCorners[$size],
|
||||
$selected === true, $square => $squareCorners[$size],
|
||||
default => 'rounded-corner-full',
|
||||
};
|
||||
|
||||
$dimensions = $iconOnly
|
||||
? [
|
||||
'xs' => ['narrow' => 'h-8 w-7', 'default' => 'size-8', 'wide' => 'h-8 w-10'],
|
||||
'sm' => ['narrow' => 'h-10 w-8', 'default' => 'size-10', 'wide' => 'h-10 w-13'],
|
||||
'md' => ['narrow' => 'h-14 w-12', 'default' => 'size-14', 'wide' => 'h-14 w-18'],
|
||||
'lg' => ['narrow' => 'h-24 w-16', 'default' => 'size-24', 'wide' => 'h-24 w-32'],
|
||||
'xl' => ['narrow' => 'h-34 w-26', 'default' => 'size-34', 'wide' => 'h-34 w-46'],
|
||||
][$size][$width]
|
||||
: [
|
||||
'xs' => 'h-8 gap-2 px-3 type-label-lg',
|
||||
'sm' => 'h-10 gap-2 px-4 type-label-lg',
|
||||
'md' => 'h-14 gap-2 px-6 type-title-md',
|
||||
'lg' => 'h-24 gap-3 px-12 type-headline-sm',
|
||||
'xl' => 'h-34 gap-4 px-16 type-headline-lg',
|
||||
][$size];
|
||||
|
||||
$iconSize = $iconOnly
|
||||
? ['xs' => 'size-5', 'sm' => 'size-6', 'md' => 'size-6', 'lg' => 'size-8', 'xl' => 'size-10'][$size]
|
||||
: ['xs' => 'size-5', 'sm' => 'size-5', 'md' => 'size-6', 'lg' => 'size-8', 'xl' => 'size-10'][$size];
|
||||
|
||||
$outline = ['xs' => 'border', 'sm' => 'border', 'md' => 'border', 'lg' => 'border-2', 'xl' => 'border-3'][$size];
|
||||
$contained = in_array($variant, ['filled', 'tonal', 'elevated'], true) || ($variant === 'outlined' && $selected === true);
|
||||
|
||||
$classes = [
|
||||
'group/button state-layer focus-ring inline-flex shrink-0 cursor-pointer select-none items-center justify-center whitespace-nowrap',
|
||||
'transition-[border-radius,background-color,color,box-shadow,padding,margin] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast',
|
||||
$dimensions,
|
||||
// A composite component (a split button's halves) passes the corners its place needs.
|
||||
$corners ?? $corner.' '.$pressed[$size],
|
||||
$colours,
|
||||
$outline => $variant === 'outlined',
|
||||
'hover:shadow-elevation-1' => in_array($variant, ['filled', 'tonal'], true),
|
||||
'shadow-elevation-1 hover:shadow-elevation-2' => $variant === 'elevated',
|
||||
// Below 48px the touch target reaches past the button, as M3 requires.
|
||||
'after:absolute after:top-1/2 after:left-1/2 after:size-full after:min-h-12 after:min-w-12 after:-translate-x-1/2 after:-translate-y-1/2' => in_array($size, ['xs', 'sm'], true),
|
||||
'disabled:cursor-not-allowed disabled:shadow-none aria-disabled:pointer-events-none aria-disabled:shadow-none',
|
||||
'disabled:bg-on-surface/10 disabled:text-on-surface/38 aria-disabled:bg-on-surface/10 aria-disabled:text-on-surface/38' => $contained,
|
||||
'disabled:text-on-surface/38 aria-disabled:text-on-surface/38' => ! $contained,
|
||||
// The FAB, below sm only: an extended FAB in the thumb zone, clear of a bottom bar the layout declares.
|
||||
'max-sm:fixed max-sm:end-4 max-sm:bottom-[calc(var(--material-bottom-bar,0px)+1rem)] max-sm:z-30 max-sm:h-14 max-sm:gap-2 max-sm:px-4 max-sm:rounded-corner-lg max-sm:type-title-md max-sm:bg-primary-container max-sm:text-on-primary-container max-sm:shadow-elevation-3' => $fab,
|
||||
];
|
||||
|
||||
$tag = $isLink ? 'a' : 'button';
|
||||
|
||||
$attributes = $attributes
|
||||
->class($classes)
|
||||
->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,
|
||||
'aria-disabled' => $inert ? 'true' : null,
|
||||
'tabindex' => $inert ? '-1' : null,
|
||||
'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'),
|
||||
'data-icon-button' => $iconOnly ? true : null,
|
||||
'wire:loading.attr' => $spinnerTarget ? 'disabled' : null,
|
||||
'wire:target' => $spinnerTarget,
|
||||
'style' => $anchor ? "anchor-name: {$anchor}" : null,
|
||||
], fn ($value): bool => $value !== null));
|
||||
@endphp
|
||||
|
||||
<{{ $tag }} {{ $attributes }}>
|
||||
@if ($spinnerTarget)
|
||||
<span wire:loading.flex wire:target="{{ $spinnerTarget }}" class="items-center justify-center">
|
||||
<x-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])" />
|
||||
</span>
|
||||
@endif
|
||||
|
||||
@unless ($iconOnly)
|
||||
<span @class(['max-lg:hidden' => $responsive])>{{ $label ?? $slot }}</span>
|
||||
@endunless
|
||||
|
||||
@if ($iconRight)
|
||||
<x-icon :name="$iconRight" :class="$iconSize" />
|
||||
@endif
|
||||
|
||||
@if ($tip !== null)
|
||||
<x-tooltip :text="$tip" :side="$tipSide" :anchor="$anchor" />
|
||||
@endif
|
||||
</{{ $tag }}>
|
||||
@@ -0,0 +1,47 @@
|
||||
{{-- One action in an `<x-fab-menu>`: a 56px pill with an icon and a label, rising into place as
|
||||
the menu opens. `link` makes it an anchor (with `wire:navigate` unless `external`); `color`
|
||||
should match the menu's. --}}
|
||||
|
||||
@props([
|
||||
'label' => null,
|
||||
'icon' => null,
|
||||
'color' => 'primary',
|
||||
'link' => null,
|
||||
'external' => false,
|
||||
])
|
||||
|
||||
@php
|
||||
$isLink = filled($link);
|
||||
$tag = $isLink ? 'a' : 'button';
|
||||
|
||||
$colours = [
|
||||
'primary' => 'bg-primary-container text-on-primary-container',
|
||||
'secondary' => 'bg-secondary-container text-on-secondary-container',
|
||||
'tertiary' => 'bg-tertiary-container text-on-tertiary-container',
|
||||
][in_array($color, ['primary', 'secondary', 'tertiary'], true) ? $color : 'primary'];
|
||||
|
||||
$attributes = $attributes
|
||||
->class([
|
||||
'state-layer inline-flex h-14 shrink-0 cursor-pointer items-center gap-2 rounded-corner-full px-6 whitespace-nowrap type-title-md shadow-elevation-3 outline-none',
|
||||
'focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-secondary',
|
||||
'transition-[translate,opacity] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast starting:translate-y-2 starting:opacity-0',
|
||||
$colours,
|
||||
])
|
||||
->merge(array_filter([
|
||||
'role' => 'menuitem',
|
||||
'tabindex' => '-1',
|
||||
'type' => $isLink ? null : 'button',
|
||||
'href' => $isLink ? $link : null,
|
||||
'target' => $isLink && $external ? '_blank' : null,
|
||||
'rel' => $isLink && $external ? 'noopener' : null,
|
||||
'wire:navigate' => $isLink && ! $external && ! $attributes->has('wire:navigate') ? true : null,
|
||||
], fn ($value): bool => $value !== null));
|
||||
@endphp
|
||||
|
||||
<{{ $tag }} {{ $attributes }}>
|
||||
@if ($icon)
|
||||
<x-icon :name="$icon" class="size-6" />
|
||||
@endif
|
||||
|
||||
<span>{{ $label ?? $slot }}</span>
|
||||
</{{ $tag }}>
|
||||
@@ -0,0 +1,77 @@
|
||||
{{-- An M3 Expressive FAB menu: a FAB that opens into a short list of related actions.
|
||||
|
||||
<div class="fixed end-4 bottom-4">
|
||||
<x-fab-menu label="New">
|
||||
<x-fab-menu-item label="Upload files" icon="upload_file" wire:click="uploadFiles" />
|
||||
<x-fab-menu-item label="Upload a folder" icon="drive_folder_upload" wire:click="uploadFolder" />
|
||||
</x-fab-menu>
|
||||
</div>
|
||||
|
||||
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`.
|
||||
|
||||
FabMenuBaselineTokens (androidx Compose Material 3, Apache-2.0): 56px items, 4px apart, 8px
|
||||
above the close button. --}}
|
||||
|
||||
@props([
|
||||
'icon' => 'add',
|
||||
'label' => null,
|
||||
'color' => 'primary',
|
||||
'position' => 'top-end',
|
||||
])
|
||||
|
||||
@php
|
||||
$color = in_array($color, ['primary', 'secondary', 'tertiary'], true) ? $color : 'primary';
|
||||
$key = \Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10));
|
||||
$anchor = "--material-fab-menu-{$key}";
|
||||
|
||||
$colours = [
|
||||
'primary' => 'bg-primary-container text-on-primary-container aria-expanded:bg-primary aria-expanded:text-on-primary',
|
||||
'secondary' => 'bg-secondary-container text-on-secondary-container aria-expanded:bg-secondary aria-expanded:text-on-secondary',
|
||||
'tertiary' => 'bg-tertiary-container text-on-tertiary-container aria-expanded:bg-tertiary aria-expanded:text-on-tertiary',
|
||||
][$color];
|
||||
@endphp
|
||||
|
||||
<div x-data="materialMenu" {{ $attributes->class('relative inline-flex') }}>
|
||||
<span x-ref="trigger" class="inline-flex" style="anchor-name: {{ $anchor }}"
|
||||
x-on:click="toggle('first')"
|
||||
x-on:keydown.down.prevent="open('first')"
|
||||
x-on:keydown.up.prevent="open('last')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@if ($label) aria-label="{{ $label }}" @endif
|
||||
@class([
|
||||
'group/fab state-layer focus-ring inline-flex size-14 shrink-0 cursor-pointer items-center justify-center rounded-corner-lg shadow-elevation-3 hover:shadow-elevation-4',
|
||||
'transition-[border-radius,background-color,color,box-shadow] duration-(--md-sys-motion-spatial-default-duration) ease-spatial-default aria-expanded:rounded-corner-full',
|
||||
$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" />
|
||||
</button>
|
||||
</span>
|
||||
|
||||
<div
|
||||
x-ref="menu"
|
||||
id="material-fab-menu-{{ $key }}"
|
||||
popover="auto"
|
||||
role="menu"
|
||||
@if ($label) aria-label="{{ $label }}" @endif
|
||||
tabindex="-1"
|
||||
style="position-anchor: {{ $anchor }}"
|
||||
x-on:keydown="navigate($event)"
|
||||
x-on:click="activate($event)"
|
||||
@class([
|
||||
'm-0 flex-col gap-1 overflow-visible border-0 bg-transparent p-0 open:flex [inset:auto]',
|
||||
'mb-2 items-end [position-area:top_span-left]' => $position === 'top-end',
|
||||
'mb-2 items-start [position-area:top_span-right]' => $position === 'top-start',
|
||||
'mt-2 items-end [position-area:bottom_span-left]' => $position === 'bottom-end',
|
||||
'mt-2 items-start [position-area:bottom_span-right]' => $position === 'bottom-start',
|
||||
])
|
||||
>
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,78 @@
|
||||
{{-- An M3 Expressive floating action button: the screen's primary action.
|
||||
|
||||
With only an `icon` it is a FAB — `size` `sm` 56px (the default; M3's deprecated small FAB is
|
||||
gone, so this is the baseline FAB), `md` 80px, `lg` 96px. With a `label` it is an extended
|
||||
FAB at the same three heights. `color` is `primary` (the default), `secondary` or `tertiary`,
|
||||
drawn in its container, or in the colour itself with `variant="filled"`.
|
||||
|
||||
It does not place itself: put it where the layout wants it, e.g.
|
||||
`<div class="fixed end-4 bottom-4"><x-fab icon="add" tooltip="New share" /></div>`. For a
|
||||
create action that is a FAB on a phone and a header button above, use `<x-button fab>`.
|
||||
|
||||
Sizes, corners and elevation from FabBaseline/Medium/LargeTokens and ExtendedFab*Tokens
|
||||
(androidx Compose Material 3, Apache-2.0). --}}
|
||||
|
||||
@props([
|
||||
'icon' => null,
|
||||
'label' => null,
|
||||
'size' => 'sm',
|
||||
'color' => 'primary',
|
||||
'variant' => 'container',
|
||||
'link' => null,
|
||||
'external' => false,
|
||||
'tooltip' => null,
|
||||
'disabled' => false,
|
||||
'type' => 'button',
|
||||
])
|
||||
|
||||
@php
|
||||
$size = in_array($size, ['sm', 'md', 'lg'], true) ? $size : 'sm';
|
||||
$color = in_array($color, ['primary', 'secondary', 'tertiary'], true) ? $color : 'primary';
|
||||
$extended = filled($label) || $slot->isNotEmpty();
|
||||
$isLink = filled($link);
|
||||
|
||||
$colours = $variant === 'filled'
|
||||
? ['primary' => 'bg-primary text-on-primary', 'secondary' => 'bg-secondary text-on-secondary', 'tertiary' => 'bg-tertiary text-on-tertiary'][$color]
|
||||
: ['primary' => 'bg-primary-container text-on-primary-container', 'secondary' => 'bg-secondary-container text-on-secondary-container', 'tertiary' => 'bg-tertiary-container text-on-tertiary-container'][$color];
|
||||
|
||||
$dimensions = $extended
|
||||
? ['sm' => 'h-14 min-w-14 gap-2 px-4 rounded-corner-lg type-title-md', 'md' => 'h-20 min-w-20 gap-3 px-[26px] rounded-corner-lg-increased type-title-lg', 'lg' => 'h-24 min-w-24 gap-4 px-7 rounded-corner-xl type-headline-sm'][$size]
|
||||
: ['sm' => 'size-14 rounded-corner-lg', 'md' => 'size-20 rounded-corner-lg-increased', 'lg' => 'size-24 rounded-corner-xl'][$size];
|
||||
|
||||
$iconSize = ['sm' => 'size-6', 'md' => 'size-7', 'lg' => 'size-8'][$size];
|
||||
$anchor = $tooltip !== null ? '--material-fab-'.\Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10)) : null;
|
||||
$tag = $isLink ? 'a' : 'button';
|
||||
|
||||
$attributes = $attributes
|
||||
->class([
|
||||
'state-layer focus-ring inline-flex shrink-0 cursor-pointer select-none items-center justify-center whitespace-nowrap',
|
||||
'shadow-elevation-3 hover:shadow-elevation-4 transition-[box-shadow,background-color,color] duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast',
|
||||
$dimensions,
|
||||
$colours,
|
||||
'disabled:cursor-not-allowed disabled:bg-on-surface/10 disabled:text-on-surface/38 disabled:shadow-none',
|
||||
])
|
||||
->merge(array_filter([
|
||||
'href' => $isLink ? $link : null,
|
||||
'target' => $isLink && $external ? '_blank' : null,
|
||||
'rel' => $isLink && $external ? 'noopener' : null,
|
||||
'wire:navigate' => $isLink && ! $external && ! $attributes->has('wire:navigate') ? true : null,
|
||||
'type' => $isLink ? null : $type,
|
||||
'disabled' => ! $isLink && $disabled ? true : null,
|
||||
'aria-label' => ! $extended && ! $attributes->has('aria-label') ? $tooltip : null,
|
||||
'style' => $anchor ? "anchor-name: {$anchor}" : null,
|
||||
], fn ($value): bool => $value !== null));
|
||||
@endphp
|
||||
|
||||
<{{ $tag }} {{ $attributes }}>
|
||||
@if ($icon)
|
||||
<x-icon :name="$icon" :class="$iconSize" />
|
||||
@endif
|
||||
|
||||
@if ($extended)
|
||||
<span>{{ $label ?? $slot }}</span>
|
||||
@endif
|
||||
|
||||
@if ($tooltip !== null)
|
||||
<x-tooltip :text="$tooltip" :anchor="$anchor" />
|
||||
@endif
|
||||
</{{ $tag }}>
|
||||
@@ -0,0 +1,97 @@
|
||||
{{-- A choice of a few options as an M3 Expressive connected button group — the successor of the
|
||||
segmented button.
|
||||
|
||||
<x-group label="Theme" wire:model.live="theme" :options="[
|
||||
['id' => 'light', 'name' => 'Light', 'icon' => 'light_mode'],
|
||||
['id' => 'dark', 'name' => 'Dark', 'icon' => 'dark_mode'],
|
||||
]" />
|
||||
|
||||
Native radios under the segments (checkboxes with `multiple`), so `wire:model` and `x-model`
|
||||
bind as on any input, the arrow keys move the choice, and a screen reader announces a group.
|
||||
The chosen segment rounds fully and takes the selected colour; `variant` is `tonal` (the
|
||||
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. --}}
|
||||
|
||||
@props([
|
||||
'label' => null,
|
||||
'hint' => null,
|
||||
'name' => null,
|
||||
'options' => [],
|
||||
'optionValue' => 'id',
|
||||
'optionLabel' => 'name',
|
||||
'optionIcon' => 'icon',
|
||||
'size' => 'sm',
|
||||
'variant' => 'tonal',
|
||||
'multiple' => false,
|
||||
'inline' => false,
|
||||
])
|
||||
|
||||
@php
|
||||
$model = $attributes->whereStartsWith('wire:model')->first();
|
||||
$name ??= $model;
|
||||
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : [];
|
||||
$size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'sm';
|
||||
|
||||
$segment = [
|
||||
'xs' => 'h-8 gap-2 px-3 type-label-lg',
|
||||
'sm' => 'h-10 gap-2 px-4 type-label-lg',
|
||||
'md' => 'h-14 gap-2 px-6 type-title-md',
|
||||
'lg' => 'h-24 gap-3 px-12 type-headline-sm',
|
||||
'xl' => 'h-34 gap-4 px-16 type-headline-lg',
|
||||
][$size];
|
||||
|
||||
$iconSize = ['xs' => 'size-5', 'sm' => 'size-5', 'md' => 'size-6', 'lg' => 'size-8', 'xl' => 'size-10'][$size];
|
||||
|
||||
$colours = match ($variant) {
|
||||
'filled' => 'bg-surface-container text-on-surface-variant has-checked:bg-primary has-checked:text-on-primary',
|
||||
'outlined' => 'border border-outline-variant text-on-surface-variant has-checked:border-transparent has-checked:bg-inverse-surface has-checked:text-inverse-on-surface',
|
||||
default => 'bg-secondary-container text-on-secondary-container has-checked:bg-secondary has-checked:text-on-secondary',
|
||||
};
|
||||
@endphp
|
||||
|
||||
<fieldset {{ $attributes->only(['class', 'wire:key'])->class('min-w-0') }}>
|
||||
@if (filled($label))
|
||||
<legend class="mb-2 type-label-lg text-on-surface-variant">{{ $label }}</legend>
|
||||
@endif
|
||||
|
||||
<div data-button-group="connected" data-size="{{ $size }}" @class(['flex gap-0.5', 'w-full' => ! $inline, 'w-fit' => $inline])>
|
||||
@foreach ($options as $option)
|
||||
<label @class([
|
||||
'state-layer relative flex cursor-pointer select-none items-center justify-center whitespace-nowrap',
|
||||
'transition-[border-radius,background-color,color] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast',
|
||||
'min-w-0 flex-1' => ! $inline,
|
||||
$segment,
|
||||
$colours,
|
||||
'has-focus-visible:outline-3 has-focus-visible:outline-offset-2 has-focus-visible:outline-secondary',
|
||||
'has-disabled:cursor-not-allowed has-disabled:bg-on-surface/10 has-disabled:text-on-surface/38',
|
||||
])>
|
||||
<input
|
||||
{{ $attributes->whereStartsWith(['wire:model', 'x-model']) }}
|
||||
type="{{ $multiple ? 'checkbox' : 'radio' }}"
|
||||
name="{{ $multiple ? $name.'[]' : $name }}"
|
||||
value="{{ data_get($option, $optionValue) }}"
|
||||
@disabled(data_get($option, 'disabled'))
|
||||
class="peer sr-only"
|
||||
/>
|
||||
|
||||
@if (filled(data_get($option, $optionIcon)))
|
||||
<x-icon :name="data_get($option, $optionIcon)" :class="$iconSize" />
|
||||
@endif
|
||||
|
||||
<span class="truncate">{{ data_get($option, $optionLabel) }}</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>
|
||||
@@ -0,0 +1,43 @@
|
||||
{{-- M3 Expressive's loading indicator: a shape that morphs through seven Expressive shapes while
|
||||
it turns, for a wait that has no known length.
|
||||
|
||||
In the text colour — primary unless the caller colours it — and 48px unless sized. `contained`
|
||||
puts it on a primary-container circle, for a spinner over content. It is a `progressbar`
|
||||
named `label` ("Loading" by default); pass `:label="false"` where something else already
|
||||
says what is happening, as a button's own spinner does.
|
||||
|
||||
Pure SVG with SMIL, so it animates in every engine without script. Under
|
||||
`prefers-reduced-motion` the shape rests. The geometry is androidx Compose Material 3's
|
||||
LoadingIndicator and Morph, ported in bin/loading-indicator.mjs (Apache-2.0):
|
||||
LoadingIndicatorTokens' 38px indicator in a 48px container, a morph every 650ms on a spring,
|
||||
a quarter turn per morph and a full turn every 4666ms. --}}
|
||||
|
||||
@props([
|
||||
'contained' => false,
|
||||
'label' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
$sized = preg_match('/(^|\s)(size|w|h)-/', (string) $attributes->get('class')) === 1;
|
||||
$coloured = preg_match('/(^|\s)text-(?!(xs|sm|base|lg|xl|[2-9]xl|left|center|right|start|end)(\s|$))/', (string) $attributes->get('class')) === 1;
|
||||
$decorative = $label === false;
|
||||
$label = $decorative ? null : ($label ?? __('Loading'));
|
||||
|
||||
$attributes = $attributes
|
||||
->class([
|
||||
'inline-flex shrink-0 items-center justify-center',
|
||||
'size-12' => ! $sized,
|
||||
'rounded-corner-full bg-primary-container text-on-primary-container' => $contained,
|
||||
'text-primary' => ! $contained && ! $coloured,
|
||||
])
|
||||
->merge(array_filter([
|
||||
'role' => $decorative ? null : 'progressbar',
|
||||
'aria-label' => $label,
|
||||
'aria-hidden' => $decorative ? 'true' : null,
|
||||
]));
|
||||
@endphp
|
||||
|
||||
<span {{ $attributes }}>
|
||||
{{ \NoNameWeb\LivewireMaterial\Support\SvgFile::loadingIndicator(true, new \Illuminate\View\ComponentAttributeBag(['class' => 'size-full motion-reduce:hidden', 'aria-hidden' => 'true', 'focusable' => 'false'])) }}
|
||||
{{ \NoNameWeb\LivewireMaterial\Support\SvgFile::loadingIndicator(false, new \Illuminate\View\ComponentAttributeBag(['class' => 'hidden size-full motion-reduce:block', 'aria-hidden' => 'true', 'focusable' => 'false'])) }}
|
||||
</span>
|
||||
@@ -0,0 +1,9 @@
|
||||
{{-- A labelled group of items in an `<x-menu>`: "Sort by", "Share with". --}}
|
||||
|
||||
@props(['label'])
|
||||
|
||||
<div role="group" aria-label="{{ $label }}" {{ $attributes->class('py-1 first:pt-0 last:pb-0') }}>
|
||||
<div aria-hidden="true" class="px-3 pt-2 pb-1 type-label-lg text-on-surface-variant">{{ $label }}</div>
|
||||
|
||||
{{ $slot }}
|
||||
</div>
|
||||
@@ -0,0 +1,81 @@
|
||||
{{-- 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.
|
||||
|
||||
44px tall (SegmentedMenuTokens.Item), body-large label, 20px icons, 4px corners that open to
|
||||
12px at the ends of the list. --}}
|
||||
|
||||
@props([
|
||||
'label' => null,
|
||||
'icon' => null,
|
||||
'iconRight' => null,
|
||||
'description' => null,
|
||||
'shortcut' => null,
|
||||
'link' => null,
|
||||
'external' => false,
|
||||
'noWireNavigate' => false,
|
||||
'selected' => null,
|
||||
'disabled' => false,
|
||||
'keepOpen' => false,
|
||||
])
|
||||
|
||||
@php
|
||||
$isLink = filled($link);
|
||||
$tag = $isLink ? 'a' : 'button';
|
||||
|
||||
$attributes = $attributes
|
||||
->class([
|
||||
'group/item state-layer flex w-full min-h-11 cursor-pointer items-center gap-3 px-3 text-start outline-none',
|
||||
'rounded-corner-xs first:rounded-t-corner-md last:rounded-b-corner-md',
|
||||
'transition-[border-radius,background-color] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast',
|
||||
'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,
|
||||
'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-disabled' => $disabled ? 'true' : null,
|
||||
'tabindex' => '-1',
|
||||
'type' => $isLink ? null : 'button',
|
||||
'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,
|
||||
'data-keep-open' => $keepOpen ? true : null,
|
||||
], fn ($value): bool => $value !== null));
|
||||
|
||||
$iconInk = match (true) {
|
||||
$disabled => 'text-on-surface/38',
|
||||
$selected === true => 'text-on-tertiary-container',
|
||||
default => 'text-on-surface-variant',
|
||||
};
|
||||
@endphp
|
||||
|
||||
<{{ $tag }} {{ $attributes }}>
|
||||
@if ($icon)
|
||||
<x-icon :name="$icon" :filled="$selected === true" :class="'size-5 '.$iconInk" />
|
||||
@endif
|
||||
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate type-body-lg">{{ $label ?? $slot }}</span>
|
||||
|
||||
@if ($description)
|
||||
<span @class(['block type-body-md', $iconInk])>{{ $description }}</span>
|
||||
@endif
|
||||
</span>
|
||||
|
||||
@if ($shortcut)
|
||||
<span @class(['shrink-0 type-label-sm', $iconInk])>{{ $shortcut }}</span>
|
||||
@endif
|
||||
|
||||
@if ($iconRight)
|
||||
<x-icon :name="$iconRight" :class="'size-5 '.$iconInk" />
|
||||
@endif
|
||||
</{{ $tag }}>
|
||||
@@ -0,0 +1,3 @@
|
||||
{{-- A line between groups of items in an `<x-menu>`. --}}
|
||||
|
||||
<hr role="separator" {{ $attributes->class('mx-3 my-1 h-px border-0 bg-outline-variant') }} />
|
||||
@@ -0,0 +1,68 @@
|
||||
{{-- An M3 Expressive menu: a list of actions that opens from a trigger.
|
||||
|
||||
<x-menu label="Share actions">
|
||||
<x-slot:trigger>
|
||||
<x-button icon="more_vert" tooltip="More" />
|
||||
</x-slot:trigger>
|
||||
|
||||
<x-menu-item label="Copy link" icon="content_copy" wire:click="copy" />
|
||||
<x-menu-separator />
|
||||
<x-menu-item label="Delete" icon="delete" wire:click="delete" />
|
||||
</x-menu>
|
||||
|
||||
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.
|
||||
|
||||
The container is Expressive's standard menu (surface-container-low, 16px corner, elevation
|
||||
2), or `vibrant` in tertiary-container — StandardMenuTokens and VibrantMenuTokens from
|
||||
androidx Compose Material 3 (Apache-2.0). --}}
|
||||
|
||||
@props([
|
||||
'label' => null,
|
||||
'position' => 'bottom-start',
|
||||
'vibrant' => false,
|
||||
])
|
||||
|
||||
@php
|
||||
$position = in_array($position, ['bottom-start', 'bottom-end', 'top-start', 'top-end'], true) ? $position : 'bottom-start';
|
||||
$key = \Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10));
|
||||
$anchor = "--material-menu-{$key}";
|
||||
@endphp
|
||||
|
||||
<div x-data="materialMenu" {{ $attributes->class('relative inline-flex') }}>
|
||||
<span x-ref="trigger" class="inline-flex" style="anchor-name: {{ $anchor }}"
|
||||
x-on:click="toggle('first')"
|
||||
x-on:keydown.down.prevent="open('first')"
|
||||
x-on:keydown.up.prevent="open('last')"
|
||||
>{{ $trigger }}</span>
|
||||
|
||||
<div
|
||||
x-ref="menu"
|
||||
id="material-menu-{{ $key }}"
|
||||
popover="auto"
|
||||
role="menu"
|
||||
@if ($label) aria-label="{{ $label }}" @endif
|
||||
tabindex="-1"
|
||||
style="position-anchor: {{ $anchor }}"
|
||||
x-on:keydown="navigate($event)"
|
||||
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]',
|
||||
'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,
|
||||
'[position-area:bottom_span-right]' => $position === 'bottom-start',
|
||||
'[position-area:bottom_span-left]' => $position === 'bottom-end',
|
||||
'[position-area:top_span-right]' => $position === 'top-start',
|
||||
'[position-area:top_span-left]' => $position === 'top-end',
|
||||
])
|
||||
>
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,71 @@
|
||||
{{-- An M3 Expressive split button: an action, and a menu of its alternatives.
|
||||
|
||||
<x-split-button label="Download all" icon="download" wire:click="downloadZip" menu-label="Download options">
|
||||
<x-menu-item label="Download as ZIP" wire:click="downloadZip" />
|
||||
<x-menu-item label="Download files one by one" wire:click="downloadEach" />
|
||||
</x-split-button>
|
||||
|
||||
Attributes (wire:click, spinner…) go to the leading button; the slot is the menu. `variant` is
|
||||
`filled` (the default), `tonal`, `outlined` or `elevated` — a text split button does not exist
|
||||
in M3 — with `color` and `size` as on `<x-button>`. The halves sit 2px apart; the trailing
|
||||
one rounds fully and turns its chevron over while the menu is open. `menu-label` names the
|
||||
trailing button and the menu for screen readers.
|
||||
|
||||
Padding from SplitButton*Tokens (androidx Compose Material 3, Apache-2.0): the leading button
|
||||
keeps less room on its inner side at the two smallest sizes, and the trailing button is 48px
|
||||
wide there. The corners are resources/css/components/groups.css. --}}
|
||||
|
||||
@props([
|
||||
'label' => null,
|
||||
'icon' => null,
|
||||
'variant' => 'filled',
|
||||
'color' => 'primary',
|
||||
'size' => 'sm',
|
||||
'disabled' => false,
|
||||
'spinner' => null,
|
||||
'menuLabel' => null,
|
||||
'position' => 'bottom-end',
|
||||
])
|
||||
|
||||
@php
|
||||
$variant = in_array($variant, ['filled', 'tonal', 'outlined', 'elevated'], true) ? $variant : 'filled';
|
||||
$size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'sm';
|
||||
$menuLabel ??= __('More options');
|
||||
|
||||
$leadingPadding = ['xs' => 'pe-2.5', 'sm' => 'pe-3', 'md' => '', 'lg' => '', 'xl' => ''][$size];
|
||||
$trailingWidth = ['xs' => 'w-12', 'sm' => 'w-12', 'md' => '', 'lg' => '', 'xl' => ''][$size];
|
||||
@endphp
|
||||
|
||||
<div data-button-group="split" data-size="{{ $size }}" {{ $attributes->only('class')->class('inline-flex items-center gap-0.5') }}>
|
||||
<x-button
|
||||
{{ $attributes->except('class') }}
|
||||
:label="$label"
|
||||
:icon="$icon"
|
||||
:variant="$variant"
|
||||
:color="$color"
|
||||
:size="$size"
|
||||
:disabled="$disabled"
|
||||
:spinner="$spinner"
|
||||
corners=""
|
||||
data-split="leading"
|
||||
:class="$leadingPadding"
|
||||
/>
|
||||
|
||||
<x-menu :position="$position" :label="$menuLabel">
|
||||
<x-slot:trigger>
|
||||
<x-button
|
||||
icon="keyboard_arrow_down"
|
||||
:aria-label="$menuLabel"
|
||||
:variant="$variant"
|
||||
:color="$color"
|
||||
:size="$size"
|
||||
:disabled="$disabled"
|
||||
corners=""
|
||||
data-split="trailing"
|
||||
:class="$trailingWidth"
|
||||
/>
|
||||
</x-slot:trigger>
|
||||
|
||||
{{ $slot }}
|
||||
</x-menu>
|
||||
</div>
|
||||
@@ -0,0 +1,51 @@
|
||||
{{-- An M3 plain tooltip: a short label for a control, in the inverse surface.
|
||||
|
||||
Two ways in. Inside a component that already names its own anchor — `<x-button tooltip="…">`
|
||||
passes `anchor` — it is a bare popover. Standalone, it wraps its trigger:
|
||||
|
||||
<x-tooltip text="Copy link"><button …>…</button></x-tooltip>
|
||||
|
||||
It shows after a short hover on a pointer that can hover, at once on keyboard focus, and
|
||||
hides on leave, blur, press and Escape. A phone has no hover, so a control there carries its
|
||||
words. The bubble is a `popover="manual"` in the top layer — never clipped by an
|
||||
`overflow-hidden` parent, never widening a scroll container — placed by CSS anchor
|
||||
positioning on `side` (`top`, `bottom`, `left`, `right`), flipping when there is no room.
|
||||
|
||||
It is aria-hidden: an icon button takes the same words as its aria-label, and a labelled
|
||||
control would otherwise read them twice. --}}
|
||||
|
||||
@props([
|
||||
'text',
|
||||
'side' => 'top',
|
||||
'anchor' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
$side = in_array($side, ['top', 'bottom', 'left', 'right'], true) ? $side : 'top';
|
||||
$standalone = $anchor === null;
|
||||
$anchor ??= '--material-tooltip-'.\Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10));
|
||||
@endphp
|
||||
|
||||
@if ($standalone)
|
||||
<span class="inline-flex" style="anchor-name: {{ $anchor }}">
|
||||
{{ $slot }}
|
||||
@endif
|
||||
|
||||
<span
|
||||
popover="manual"
|
||||
aria-hidden="true"
|
||||
x-data="materialTooltip"
|
||||
style="position-anchor: {{ $anchor }}"
|
||||
@class([
|
||||
'pointer-events-none m-0 max-w-50 overflow-visible border-0 rounded-corner-xs bg-inverse-surface px-2 py-1 text-center whitespace-normal type-body-sm text-inverse-on-surface [inset:auto]',
|
||||
'opacity-0 transition-[opacity,display,overlay] transition-discrete duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast open:opacity-100 starting:open:opacity-0',
|
||||
'my-1 [position-area:top] [position-try-fallbacks:flip-block]' => $side === 'top',
|
||||
'my-1 [position-area:bottom] [position-try-fallbacks:flip-block]' => $side === 'bottom',
|
||||
'mx-1 [position-area:left] [position-try-fallbacks:flip-inline]' => $side === 'left',
|
||||
'mx-1 [position-area:right] [position-try-fallbacks:flip-inline]' => $side === 'right',
|
||||
])
|
||||
>{{ $text }}</span>
|
||||
|
||||
@if ($standalone)
|
||||
</span>
|
||||
@endif
|
||||
@@ -0,0 +1,19 @@
|
||||
{{-- One showcase example: the Blade in `code`, rendered, with the source beside it — one string,
|
||||
so what is shown and what is written can never disagree. --}}
|
||||
|
||||
@props(['title' => null, 'code', 'stack' => false])
|
||||
|
||||
<div class="space-y-4 rounded-corner-lg bg-surface-container p-4">
|
||||
@if ($title)
|
||||
<h3 class="type-title-md">{{ $title }}</h3>
|
||||
@endif
|
||||
|
||||
<div @class(['flex flex-wrap items-center gap-4' => ! $stack, 'space-y-4' => $stack])>
|
||||
{!! \Illuminate\Support\Facades\Blade::render($code) !!}
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary class="w-fit cursor-pointer rounded-corner-xs type-label-md text-on-surface-variant focus-ring">Blade</summary>
|
||||
<pre class="mt-2 overflow-x-auto rounded-corner-sm bg-surface-container-highest p-3 type-body-sm"><code>{{ trim($code) }}</code></pre>
|
||||
</details>
|
||||
</div>
|
||||
@@ -12,5 +12,7 @@
|
||||
@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')
|
||||
</main>
|
||||
@endsection
|
||||
|
||||
@@ -18,12 +18,12 @@
|
||||
<a href="{{ route('livewire-material.showcase') }}" class="type-title-lg">Livewire Material</a>
|
||||
|
||||
<nav class="flex flex-wrap gap-x-4 gap-y-1 type-label-lg text-on-surface-variant" aria-label="Sections">
|
||||
@foreach (['colour' => 'Colour', 'type' => 'Type', 'shape' => 'Shape', 'elevation' => 'Elevation', 'motion' => 'Motion', 'icons' => 'Icons'] as $anchor => $section)
|
||||
@foreach (['colour' => 'Colour', 'type' => 'Type', 'shape' => 'Shape', 'elevation' => 'Elevation', 'motion' => 'Motion', 'icons' => 'Icons', 'buttons' => 'Buttons', 'menus' => 'Menus'] as $anchor => $section)
|
||||
<a href="#{{ $anchor }}" class="rounded-corner-xs hover:text-on-surface focus-ring">{{ $section }}</a>
|
||||
@endforeach
|
||||
</nav>
|
||||
|
||||
<div class="ms-auto flex rounded-corner-full border border-outline" role="group" aria-label="Theme" x-data>
|
||||
<div class="ms-auto flex 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"
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
@php
|
||||
$examples = [
|
||||
'Variants' => <<<'BLADE'
|
||||
<x-button label="Filled" variant="filled" />
|
||||
<x-button label="Tonal" variant="tonal" />
|
||||
<x-button label="Outlined" variant="outlined" />
|
||||
<x-button label="Elevated" variant="elevated" />
|
||||
<x-button label="Text" />
|
||||
BLADE,
|
||||
'Sizes, with an icon' => <<<'BLADE'
|
||||
<x-button label="Extra small" icon="upload" variant="filled" size="xs" />
|
||||
<x-button label="Small" icon="upload" variant="filled" />
|
||||
<x-button label="Medium" icon="upload" variant="filled" size="md" />
|
||||
<x-button label="Large" icon="upload" variant="filled" size="lg" />
|
||||
<x-button label="Extra large" icon="upload" variant="filled" size="xl" />
|
||||
BLADE,
|
||||
'Square, and pressed (hold the pointer down)' => <<<'BLADE'
|
||||
<x-button label="Round" variant="tonal" size="md" />
|
||||
<x-button label="Square" variant="tonal" size="md" shape="square" />
|
||||
BLADE,
|
||||
'Colours' => <<<'BLADE'
|
||||
<x-button label="Primary" primary />
|
||||
<x-button label="Secondary" variant="filled" color="secondary" />
|
||||
<x-button label="Tertiary" variant="filled" color="tertiary" />
|
||||
<x-button label="Delete" icon="delete" danger />
|
||||
<x-button label="Take down" caution />
|
||||
<x-button label="Success" variant="tonal" color="success" />
|
||||
<x-button label="Info" variant="outlined" color="info" />
|
||||
BLADE,
|
||||
'Toggle buttons' => <<<'BLADE'
|
||||
<x-button label="Filled" variant="filled" :selected="false" />
|
||||
<x-button label="Selected" variant="filled" :selected="true" />
|
||||
<x-button label="Tonal" variant="tonal" :selected="false" />
|
||||
<x-button label="Selected" variant="tonal" :selected="true" />
|
||||
<x-button label="Outlined" variant="outlined" :selected="false" />
|
||||
<x-button label="Selected" variant="outlined" :selected="true" />
|
||||
BLADE,
|
||||
'Icon buttons, and their tooltips' => <<<'BLADE'
|
||||
<x-button icon="favorite" tooltip="Standard" />
|
||||
<x-button icon="favorite" tooltip="Filled" variant="filled" />
|
||||
<x-button icon="favorite" tooltip="Tonal" variant="tonal" />
|
||||
<x-button icon="favorite" tooltip="Outlined" variant="outlined" />
|
||||
<x-button icon="favorite" tooltip="Selected" variant="filled" :selected="true" />
|
||||
<x-button icon="favorite" tooltip="Selected, square" variant="tonal" shape="square" :selected="true" />
|
||||
BLADE,
|
||||
'Icon button sizes and widths' => <<<'BLADE'
|
||||
<x-button icon="share" tooltip="XS narrow" variant="tonal" size="xs" width="narrow" />
|
||||
<x-button icon="share" tooltip="S" variant="tonal" />
|
||||
<x-button icon="share" tooltip="M wide" variant="tonal" size="md" width="wide" />
|
||||
<x-button icon="share" tooltip="L" variant="tonal" size="lg" />
|
||||
<x-button icon="share" tooltip="XL wide" variant="tonal" size="xl" width="wide" />
|
||||
BLADE,
|
||||
'Links and disabled' => <<<'BLADE'
|
||||
<x-button label="Opens a page" link="#buttons" variant="outlined" />
|
||||
<x-button label="Leaves the site" link="https://m3.material.io" external icon-right="open_in_new" />
|
||||
<x-button label="Disabled" variant="filled" disabled />
|
||||
<x-button label="Disabled link" link="#buttons" variant="tonal" disabled />
|
||||
<x-button icon="delete" tooltip="Disabled" variant="outlined" disabled />
|
||||
BLADE,
|
||||
'Loading indicator' => <<<'BLADE'
|
||||
<x-loading />
|
||||
<x-loading contained />
|
||||
<x-loading class="size-24 text-tertiary" label="Uploading" />
|
||||
<x-loading contained class="size-8" />
|
||||
BLADE,
|
||||
'Button groups: standard (press a button) and connected' => <<<'BLADE'
|
||||
<x-button-group label="Standard" size="md">
|
||||
<x-button label="Day" variant="tonal" size="md" />
|
||||
<x-button label="Week" variant="tonal" size="md" />
|
||||
<x-button label="Month" variant="tonal" size="md" />
|
||||
</x-button-group>
|
||||
|
||||
<x-button-group label="Formatting" connected>
|
||||
<x-button icon="format_bold" aria-label="Bold" variant="tonal" :selected="true" />
|
||||
<x-button icon="format_italic" aria-label="Italic" variant="tonal" :selected="false" />
|
||||
<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'
|
||||
<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'],
|
||||
['id' => 'dark', 'name' => 'Dark', 'icon' => 'dark_mode'],
|
||||
['id' => 'system', 'name' => 'System', 'icon' => 'computer'],
|
||||
]" />
|
||||
|
||||
<x-group label="Days" name="showcase-days" x-model="days" multiple variant="outlined" hint="Choose any" :options="[
|
||||
['id' => 'mon', 'name' => 'Mon'],
|
||||
['id' => 'tue', 'name' => 'Tue'],
|
||||
['id' => 'wed', 'name' => 'Wed'],
|
||||
['id' => 'thu', 'name' => 'Thu', 'disabled' => true],
|
||||
]" />
|
||||
</div>
|
||||
BLADE,
|
||||
'Split buttons' => <<<'BLADE'
|
||||
<x-split-button label="Download all" icon="download" menu-label="Download options">
|
||||
<x-menu-item label="As a ZIP" icon="folder_zip" />
|
||||
<x-menu-item label="One by one" icon="download" />
|
||||
</x-split-button>
|
||||
|
||||
<x-split-button label="Share" variant="tonal" size="md" menu-label="Share options">
|
||||
<x-menu-item label="Copy link" icon="content_copy" />
|
||||
<x-menu-item label="Email" icon="mail" />
|
||||
</x-split-button>
|
||||
|
||||
<x-split-button label="Save" variant="outlined" size="xs" menu-label="Save options">
|
||||
<x-menu-item label="Save as draft" />
|
||||
</x-split-button>
|
||||
BLADE,
|
||||
'FABs and extended FABs' => <<<'BLADE'
|
||||
<x-fab icon="add" tooltip="New share" />
|
||||
<x-fab icon="edit" size="md" color="secondary" tooltip="Edit" />
|
||||
<x-fab icon="upload" size="lg" color="tertiary" tooltip="Upload" />
|
||||
<x-fab icon="add" label="New share" />
|
||||
<x-fab icon="upload" label="Upload" size="md" variant="filled" />
|
||||
BLADE,
|
||||
'FAB menu' => <<<'BLADE'
|
||||
<div class="flex h-72 w-full items-end justify-end">
|
||||
<x-fab-menu label="New">
|
||||
<x-fab-menu-item label="Upload files" icon="upload_file" />
|
||||
<x-fab-menu-item label="Upload a folder" icon="drive_folder_upload" />
|
||||
<x-fab-menu-item label="Paste text" icon="content_paste" />
|
||||
</x-fab-menu>
|
||||
</div>
|
||||
BLADE,
|
||||
];
|
||||
@endphp
|
||||
|
||||
<section id="buttons" class="scroll-mt-24 space-y-6">
|
||||
<h2 class="type-headline-md">Buttons</h2>
|
||||
|
||||
<p class="max-w-3xl type-body-md text-on-surface-variant">
|
||||
<code><x-button></code>: label buttons, icon buttons and toggles in M3 Expressive's five sizes.
|
||||
</p>
|
||||
|
||||
@foreach ($examples as $title => $code)
|
||||
<x-showcase::example :$title :$code />
|
||||
@endforeach
|
||||
</section>
|
||||
@@ -2,16 +2,19 @@
|
||||
id="icons"
|
||||
class="scroll-mt-24 space-y-6"
|
||||
x-data="{
|
||||
names: {{ json_encode(\NoNameWeb\LivewireMaterial\Support\SvgFile::symbolNames()) }},
|
||||
base: '{{ rtrim(route('livewire-material.showcase', [], false), '/') }}/symbols',
|
||||
names: [],
|
||||
query: '',
|
||||
filled: false,
|
||||
async load() {
|
||||
this.names = await (await fetch('{{ route('livewire-material.symbols', [], false) }}')).json();
|
||||
},
|
||||
get matches() {
|
||||
const query = this.query.trim().toLowerCase().replaceAll(' ', '_');
|
||||
|
||||
return (query === '' ? this.names : this.names.filter((name) => name.includes(query))).slice(0, 120);
|
||||
},
|
||||
}"
|
||||
x-intersect.once="load()"
|
||||
>
|
||||
<h2 class="type-headline-md">Icons</h2>
|
||||
|
||||
@@ -37,7 +40,7 @@
|
||||
<span
|
||||
class="size-6 bg-current"
|
||||
x-bind:style="{
|
||||
mask: `url('${base}/${filled ? 'filled' : 'outlined'}/${name}.svg') center / contain no-repeat`,
|
||||
mask: `url('{{ rtrim(route('livewire-material.showcase', [], false), '/') }}/symbols/${filled ? 'filled' : 'outlined'}/${name}.svg') center / contain no-repeat`,
|
||||
}"
|
||||
></span>
|
||||
<code class="w-full break-all type-label-sm text-on-surface-variant" x-text="name"></code>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
@php
|
||||
$examples = [
|
||||
'A menu' => <<<'BLADE'
|
||||
<x-menu label="Share actions">
|
||||
<x-slot:trigger>
|
||||
<x-button icon="more_vert" tooltip="More" />
|
||||
</x-slot:trigger>
|
||||
|
||||
<x-menu-item label="Copy link" icon="content_copy" shortcut="⌘C" />
|
||||
<x-menu-item label="Download" icon="download" />
|
||||
<x-menu-item label="Rename" icon="edit" description="Change what recipients see" />
|
||||
<x-menu-separator />
|
||||
<x-menu-item label="Delete" icon="delete" />
|
||||
</x-menu>
|
||||
|
||||
<x-menu label="Sort" position="bottom-end">
|
||||
<x-slot:trigger>
|
||||
<x-button label="Sort" icon-right="arrow_drop_down" variant="outlined" />
|
||||
</x-slot:trigger>
|
||||
|
||||
<x-menu-group label="Sort by">
|
||||
<x-menu-item label="Newest" :selected="true" keep-open />
|
||||
<x-menu-item label="Largest" :selected="false" keep-open />
|
||||
<x-menu-item label="Expiring soon" :selected="false" keep-open />
|
||||
</x-menu-group>
|
||||
<x-menu-separator />
|
||||
<x-menu-item label="Reset" icon="restart_alt" disabled />
|
||||
</x-menu>
|
||||
|
||||
<x-menu label="Vibrant" vibrant>
|
||||
<x-slot:trigger>
|
||||
<x-button label="Vibrant" variant="tonal" color="tertiary" />
|
||||
</x-slot:trigger>
|
||||
|
||||
<x-menu-item label="Upload files" icon="upload_file" />
|
||||
<x-menu-item label="Upload a folder" icon="drive_folder_upload" />
|
||||
</x-menu>
|
||||
BLADE,
|
||||
];
|
||||
@endphp
|
||||
|
||||
<section id="menus" class="scroll-mt-24 space-y-6">
|
||||
<h2 class="type-headline-md">Menus</h2>
|
||||
|
||||
<p class="max-w-3xl type-body-md text-on-surface-variant">
|
||||
<code><x-menu></code> with <code><x-menu-item></code>, <code><x-menu-group></code> and <code><x-menu-separator></code>.
|
||||
Open one with the keyboard too: arrows, Home, End, a letter, Escape.
|
||||
</p>
|
||||
|
||||
@foreach ($examples as $title => $code)
|
||||
<x-showcase::example :$title :$code />
|
||||
@endforeach
|
||||
</section>
|
||||
Reference in New Issue
Block a user