Add chips, choices and search
tests / lint (push) Successful in 1m4s
tests / feature (8.4) (push) Successful in 1m8s
tests / feature (8.5) (push) Successful in 1m8s
tests / browser (chrome, chromium) (push) Successful in 3m6s
tests / browser (firefox, firefox) (push) Successful in 3m45s
tests / browser (safari, webkit) (push) Successful in 5m0s
tests / lint (push) Successful in 1m4s
tests / feature (8.4) (push) Successful in 1m8s
tests / feature (8.5) (push) Successful in 1m8s
tests / browser (chrome, chromium) (push) Successful in 3m6s
tests / browser (firefox, firefox) (push) Successful in 3m45s
tests / browser (safari, webkit) (push) Successful in 5m0s
M3's assist, filter, input and suggestion chips with chip sets; choices as filter chips or a searchable combobox whose list is an anchored popover; and the search bar that opens into a docked or full-screen search view. 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
7f92f1cb29
commit
0fee2a0952
@@ -361,6 +361,42 @@ An M3 bottom sheet, bound like `<x-modal>`: modal by default (scrim, inert page,
|
|||||||
|
|
||||||
A row of items that change size between M3's keylines as it scrolls (native scroll snap; items are masked, content keeps its size). `<x-carousel>`: `layout` (`multi-browse` default, `hero`, `uncontained`, `full-screen`), `item-width` (px or any CSS length; the large size multi-browse aims for, the fixed size uncontained keeps, the cap for hero; 186 by default), `height` (205px), `padding` (px at the ends, 0), `centered` (hero), `label` (the region's name, "Carousel" by default), `controls` (previous/next buttons: default fine pointers only, `true` always, `false` never). `<x-carousel-item>`: slot is an `<img>` (fills and crops) or an element sized `size-full`; `label` overlays a line of text. A focusable `region` of `slide` groups named "n of m"; arrow keys move one item while the row has focus, Home/End to the ends. Works after a Livewire morph, in RTL and under reduced motion. Give items a `wire:key` in a loop.
|
A row of items that change size between M3's keylines as it scrolls (native scroll snap; items are masked, content keeps its size). `<x-carousel>`: `layout` (`multi-browse` default, `hero`, `uncontained`, `full-screen`), `item-width` (px or any CSS length; the large size multi-browse aims for, the fixed size uncontained keeps, the cap for hero; 186 by default), `height` (205px), `padding` (px at the ends, 0), `centered` (hero), `label` (the region's name, "Carousel" by default), `controls` (previous/next buttons: default fine pointers only, `true` always, `false` never). `<x-carousel-item>`: slot is an `<img>` (fills and crops) or an element sized `size-full`; `label` overlays a line of text. A focusable `region` of `slide` groups named "n of m"; arrow keys move one item while the row has focus, Home/End to the ends. Works after a Livewire morph, in RTL and under reduced motion. Give items a `wire:key` in a loop.
|
||||||
|
|
||||||
|
### `<x-chip>`
|
||||||
|
|
||||||
|
One component for M3's four chips, picked by `type`:
|
||||||
|
|
||||||
|
| `type` | What it is | Element |
|
||||||
|
|---|---|---|
|
||||||
|
| `assist` (default) | an action | `<button>`, or `<a>` with `link` |
|
||||||
|
| `filter` | a toggle | a native checkbox under the chip with `wire:model`, `x-model` or `name`; otherwise a `<button aria-pressed>` whose `selected` you own |
|
||||||
|
| `input` | something a person entered | its own button only with `wire:click`, `x-on:click`, `link` or `selected`; a remove button with `removable` |
|
||||||
|
| `suggestion` | a suggested reply or query | `<button>` |
|
||||||
|
|
||||||
|
Props: `label` / slot, `icon`, `icon-right`, `elevated` (not on input chips), `disabled`, `link`, `external`, `no-wire-navigate`, `selected` (filter and input), `name` / `value` (a filter checkbox; an input chip's hidden input, `value` defaulting to the label), `avatar` (input: image URL or initials), `removable`, `remove` (input: an Alpine expression), `tooltip`. On a filter checkbox and an input chip, `class`, `style` and `wire:key` stay on the chip and every other attribute goes to the control inside.
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-chip label="Add to calendar" icon="event" wire:click="addToCalendar" />
|
||||||
|
|
||||||
|
<x-chip-set label="File types" hint="Show only these" error-field="kinds">
|
||||||
|
@foreach ($kindOptions as $kind => $name)
|
||||||
|
<x-chip type="filter" :label="$name" :value="$kind" wire:model.live="kinds" wire:key="kind-{{ $kind }}" />
|
||||||
|
@endforeach
|
||||||
|
</x-chip-set>
|
||||||
|
|
||||||
|
<x-chip type="filter" label="Starred" icon="star" :selected="$starredOnly" wire:click="$toggle('starredOnly')" />
|
||||||
|
|
||||||
|
@foreach ($recipients as $recipient)
|
||||||
|
<x-chip type="input" :label="$recipient->email" :avatar="$recipient->initials" removable wire:remove="removeRecipient({{ $recipient->id }})" wire:key="recipient-{{ $recipient->id }}" />
|
||||||
|
@endforeach
|
||||||
|
```
|
||||||
|
|
||||||
|
- A multi-select set binds `wire:model` on every chip, each with its own `value`, to an array property; a boolean property needs no `value`. The chips render checked as the property already says.
|
||||||
|
- A removable input chip removes through `wire:remove` (it becomes the remove button's `wire:click`), `remove` (Alpine), or, with neither, takes itself off the page. Backspace or Delete on a focused chip removes it and moves focus to the previous or next chip; the remove button is named "Remove <label>". Give each one a `wire:key`.
|
||||||
|
|
||||||
|
### `<x-chip-set>`
|
||||||
|
|
||||||
|
A row of chips 8px apart that wraps, as `role="group"`: `label` (shown, and names the group; otherwise pass `aria-label`), `hint`, `error-field` (a validation message for that property or its items replaces the hint), `scroll` (one line that scrolls sideways, fading the edge it can still scroll towards).
|
||||||
|
|
||||||
### `<x-form>`
|
### `<x-form>`
|
||||||
|
|
||||||
A one-column grid of fields with an `actions` slot at the foot (the slot takes its own `class`); `separator` draws a divider above the actions.
|
A one-column grid of fields with an `actions` slot at the foot (the slot takes its own `class`); `separator` draws a divider above the actions.
|
||||||
@@ -400,6 +436,35 @@ M3 selection controls on native inputs; the whole row is the label.
|
|||||||
<x-toggle label="Notify me on download" wire:model.live="notify" right />
|
<x-toggle label="Notify me on download" wire:model.live="notify" right />
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `<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.
|
||||||
|
|
||||||
|
- Default: filter chips, every option on screen — `single` for choice chips.
|
||||||
|
- `searchable`: a text field that filters a menu as you type (single value; arrow keys, Enter, Escape); `icon`, `variant`, `placeholder`. Its list is a popover, so it is never clipped by a card.
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-choices label="Days you are free" wire:model.live="days" :options="$weekdays" />
|
||||||
|
<x-choices label="Time zone" wire:model="timezone" :options="$timezones" searchable icon="public" />
|
||||||
|
```
|
||||||
|
|
||||||
|
Bind with `wire:model` (entangled) or, without Livewire, `x-model`. The options are baked into its Alpine state: when they change on the server, give it a `wire:key` that changes with them.
|
||||||
|
|
||||||
|
### `<x-search>`
|
||||||
|
|
||||||
|
M3 search bar that opens into a search view: docked under the bar from `sm`, full screen with a back arrow below (`docked` keeps it docked). Bind the input like any other and render the results in the slot; `empty` is shown when the slot renders nothing. Choosing a result (a link or button) closes the view; ArrowDown walks the results, Escape closes. Props: `placeholder` ("Search"), `label`, `icon`; `trailing` slot (avatar, icon buttons).
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-search wire:model.live.debounce.300ms="query" placeholder="Search shares">
|
||||||
|
@foreach ($this->results as $share)
|
||||||
|
<x-list-item :title="$share->name" :description="$share->size" link="{{ route('shares.show', $share) }}" wire:key="result-{{ $share->id }}" />
|
||||||
|
@endforeach
|
||||||
|
<x-slot:empty>No shares match.</x-slot:empty>
|
||||||
|
</x-search>
|
||||||
|
```
|
||||||
|
|
||||||
|
The docked view overlaps what is under it; never place a search inside an element with `overflow-hidden` (a card), which clips it.
|
||||||
|
|
||||||
## Testing the design
|
## Testing the design
|
||||||
|
|
||||||
```php
|
```php
|
||||||
|
|||||||
@@ -221,6 +221,11 @@
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A combobox's arrow turns over while its list is open, as a select's does. */
|
||||||
|
.field:has(.field-control[aria-expanded="true"]) .field-arrow {
|
||||||
|
rotate: 180deg;
|
||||||
|
}
|
||||||
|
|
||||||
.field-arrow {
|
.field-arrow {
|
||||||
transition: rotate var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
|
transition: rotate var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,25 @@
|
|||||||
box-shadow: var(--md-sys-elevation-2);
|
box-shadow: var(--md-sys-elevation-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* As a popover (`<x-choices searchable>`), it hangs under its field, as wide, and goes above only
|
||||||
|
when there is no room below. */
|
||||||
|
.field-menu[popover] {
|
||||||
|
inset: auto;
|
||||||
|
top: anchor(bottom);
|
||||||
|
left: anchor(left);
|
||||||
|
width: anchor-size(width);
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
border: 0;
|
||||||
|
padding-inline: 0;
|
||||||
|
color: var(--md-sys-color-on-surface);
|
||||||
|
position-try-fallbacks: flip-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-option[aria-disabled="true"] {
|
||||||
|
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.field-option {
|
.field-option {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
/*
|
||||||
|
* M3's search: a search bar that opens into a search view (SearchBarTokens, SearchViewTokens,
|
||||||
|
* androidx Compose Material 3, Apache-2.0).
|
||||||
|
*
|
||||||
|
* The bar is a 56px pill in surface-container-high: a leading search icon, the input in
|
||||||
|
* body-large, a clear button once something is typed, and whatever the caller trails it with (an
|
||||||
|
* avatar, an icon button). Focus opens the view. On a medium or wider window the view is docked:
|
||||||
|
* the bar grows down into an extra-large-cornered container at elevation 3 that holds the
|
||||||
|
* results. On a compact window it takes the whole screen, and the search icon turns into a back
|
||||||
|
* arrow. `docked` keeps it docked at every width.
|
||||||
|
*
|
||||||
|
* [data-search] the root; data-open, data-full-screen
|
||||||
|
* [data-search-bar] the pill, above the view
|
||||||
|
* [data-search-leading], [data-search-input], [data-search-clear], [data-search-trailing]
|
||||||
|
* [data-search-view] the container behind the bar
|
||||||
|
* [data-search-results]
|
||||||
|
*/
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
[data-search] {
|
||||||
|
--search-height: 3.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-search][data-open] {
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-search-bar] {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
height: var(--search-height);
|
||||||
|
padding-inline: 0.25rem;
|
||||||
|
border-radius: var(--md-sys-shape-corner-full);
|
||||||
|
background-color: var(--md-sys-color-surface-container-high);
|
||||||
|
color: var(--md-sys-color-on-surface);
|
||||||
|
cursor: text;
|
||||||
|
transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
[data-search]:not([data-open]) [data-search-bar]:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 8%, var(--md-sys-color-surface-container-high));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-search][data-open] [data-search-bar] {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-search-leading],
|
||||||
|
[data-search-clear] {
|
||||||
|
display: grid;
|
||||||
|
flex: none;
|
||||||
|
place-items: center;
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
border-radius: var(--md-sys-shape-corner-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-search-leading] {
|
||||||
|
color: var(--md-sys-color-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-search-clear] {
|
||||||
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-search-bar] button {
|
||||||
|
outline: none;
|
||||||
|
transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
[data-search-bar] button:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) 8%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-search-bar] button:focus-visible {
|
||||||
|
background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) 10%, transparent);
|
||||||
|
outline: 3px solid var(--md-sys-color-secondary);
|
||||||
|
outline-offset: -3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-search-input] {
|
||||||
|
flex: 1 1 0%;
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
padding-inline: 0.25rem;
|
||||||
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--md-sys-color-on-surface);
|
||||||
|
caret-color: var(--md-sys-color-primary);
|
||||||
|
outline: none;
|
||||||
|
font: var(--md-sys-typescale-body-lg);
|
||||||
|
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-search-input]::placeholder {
|
||||||
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-search-input]::-webkit-search-cancel-button,
|
||||||
|
[data-search-input]::-webkit-search-decoration {
|
||||||
|
appearance: none;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-search-bar]:has([data-search-input]:placeholder-shown) [data-search-clear] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-search-trailing] {
|
||||||
|
display: flex;
|
||||||
|
flex: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding-inline-end: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-search][data-open] [data-search-trailing] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-search-view] {
|
||||||
|
position: absolute;
|
||||||
|
inset-inline: 0;
|
||||||
|
top: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: min(40rem, 70dvh);
|
||||||
|
padding-top: var(--search-height);
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--md-sys-shape-corner-xl);
|
||||||
|
background-color: var(--md-sys-color-surface-container-high);
|
||||||
|
box-shadow: var(--md-sys-elevation-3);
|
||||||
|
transform-origin: top;
|
||||||
|
transition-property: opacity, scale, display;
|
||||||
|
transition-duration: var(--md-sys-motion-spatial-fast-duration);
|
||||||
|
transition-timing-function: var(--md-sys-motion-spatial-fast);
|
||||||
|
transition-behavior: allow-discrete;
|
||||||
|
}
|
||||||
|
|
||||||
|
@starting-style {
|
||||||
|
[data-search-view] {
|
||||||
|
opacity: 0;
|
||||||
|
scale: 1 0.9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-search-results] {
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
border-top: 1px solid var(--md-sys-color-outline);
|
||||||
|
padding-block: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Full screen, on a compact window. */
|
||||||
|
[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);
|
||||||
|
padding-inline: 0.25rem;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-search][data-full-screen] [data-search-view] {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
max-height: none;
|
||||||
|
padding-top: calc(4.5rem + env(safe-area-inset-top));
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
@import './components/field.css';
|
@import './components/field.css';
|
||||||
@import './components/menu.css';
|
@import './components/menu.css';
|
||||||
@import './components/selection.css';
|
@import './components/selection.css';
|
||||||
|
@import './components/search.css';
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
html {
|
html {
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* `materialChip`: a removable `<x-chip type="input">`. `materialChipSet`: a scrolling `<x-chip-set>`.
|
||||||
|
*
|
||||||
|
* Removing always goes through the chip's remove button, so a Backspace or Delete runs exactly
|
||||||
|
* what a press on it runs — its `wire:click` (from `wire:remove`), its Alpine expression, or the
|
||||||
|
* chip taking itself off the page. Before that, focus moves to the chip before it (Backspace) or
|
||||||
|
* after it (Delete), because a focused element that disappears leaves focus on the body.
|
||||||
|
*
|
||||||
|
* A scrolling set marks the edges it can still scroll towards (`data-scroll-start`,
|
||||||
|
* `data-scroll-end`), and the set fades those edges. Its row carries `wire:ignore.self`, so a
|
||||||
|
* Livewire morph keeps the marks.
|
||||||
|
*/
|
||||||
|
const CONTROLS = 'button:not(:disabled), a[href]:not([tabindex="-1"]), input:not(:disabled):not([type="hidden"])'
|
||||||
|
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
window.Alpine.data('materialChip', () => ({
|
||||||
|
init() {
|
||||||
|
const button = this.removeButton()
|
||||||
|
const label = this.$root.querySelector('[data-chip-label]')
|
||||||
|
|
||||||
|
// A chip rendered by x-for has no label on the server: its button carries the translated
|
||||||
|
// words with a `:label` placeholder, filled in once Alpine has written the label.
|
||||||
|
if (button?.dataset.chipRemove && label) {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
const text = label.textContent.trim()
|
||||||
|
|
||||||
|
if (text !== '' && !button.hasAttribute('aria-label')) {
|
||||||
|
button.setAttribute('aria-label', button.dataset.chipRemove.replace(':label', text))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
removeButton() {
|
||||||
|
return this.$root.querySelector('[data-chip-remove]')
|
||||||
|
},
|
||||||
|
|
||||||
|
removeOnKey(event) {
|
||||||
|
if ((event.key !== 'Backspace' && event.key !== 'Delete') || event.ctrlKey || event.metaKey || event.altKey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const button = this.removeButton()
|
||||||
|
|
||||||
|
if (!button || button.disabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
this.handOffFocus(event.key === 'Backspace')
|
||||||
|
button.click()
|
||||||
|
},
|
||||||
|
|
||||||
|
removeChip() {
|
||||||
|
if (this.$root.contains(document.activeElement)) {
|
||||||
|
this.handOffFocus(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handOffFocus(backwards) {
|
||||||
|
const chip = this.$root
|
||||||
|
const set = chip.closest('[data-chip-set]') ?? chip.parentElement
|
||||||
|
const chips = [...(set?.querySelectorAll('[data-chip]') ?? [])]
|
||||||
|
const index = chips.indexOf(chip)
|
||||||
|
const before = chips.slice(0, index).reverse()
|
||||||
|
const after = chips.slice(index + 1)
|
||||||
|
const onRemove = document.activeElement?.hasAttribute('data-chip-remove')
|
||||||
|
|
||||||
|
for (const other of backwards ? [...before, ...after] : [...after, ...before]) {
|
||||||
|
const target = (onRemove && other.querySelector('[data-chip-remove]:not(:disabled)')) || (other.matches(CONTROLS) ? other : other.querySelector(CONTROLS))
|
||||||
|
|
||||||
|
if (target) {
|
||||||
|
target.focus()
|
||||||
|
this.holdFocus(target, set)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Livewire morphs without lookahead: removing a chip re-creates every keyed chip after it,
|
||||||
|
// and the focused one goes with them. Until the person moves focus themselves (or ten
|
||||||
|
// seconds pass), whenever the set changes and focus has fallen away, focus the same
|
||||||
|
// control again — the element itself, or the chip with its `wire:key` after a morph.
|
||||||
|
holdFocus(target, set) {
|
||||||
|
const key = target.closest('[data-chip]')?.getAttribute('wire:key')
|
||||||
|
const onRemove = target.hasAttribute('data-chip-remove')
|
||||||
|
|
||||||
|
const current = () => {
|
||||||
|
if (target.isConnected || !key) {
|
||||||
|
return target.isConnected ? target : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const chip = [...set.querySelectorAll('[data-chip]')].find((other) => other.getAttribute('wire:key') === key)
|
||||||
|
|
||||||
|
return chip && (onRemove ? chip.querySelector('[data-chip-remove]') : chip.matches(CONTROLS) ? chip : chip.querySelector(CONTROLS))
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
const active = document.activeElement
|
||||||
|
const control = current()
|
||||||
|
|
||||||
|
if (!control || (active && active !== document.body && active !== control)) {
|
||||||
|
stop()
|
||||||
|
} else if (active !== control) {
|
||||||
|
control.focus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const timer = setTimeout(() => stop(), 10000)
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
observer.disconnect()
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
|
||||||
|
observer.observe(set, { childList: true, subtree: true })
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
window.Alpine.data('materialChipSet', () => ({
|
||||||
|
observers: [],
|
||||||
|
|
||||||
|
init() {
|
||||||
|
const row = this.$el
|
||||||
|
|
||||||
|
const mark = () => {
|
||||||
|
// scrollLeft runs negative towards the end in a right-to-left row.
|
||||||
|
const travelled = Math.abs(row.scrollLeft)
|
||||||
|
const room = row.scrollWidth - row.clientWidth
|
||||||
|
|
||||||
|
row.toggleAttribute('data-scroll-start', travelled > 1)
|
||||||
|
row.toggleAttribute('data-scroll-end', room - travelled > 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A filter chip's check changes its width without resizing the row.
|
||||||
|
for (const type of ['scroll', 'transitionend']) {
|
||||||
|
row.addEventListener(type, mark, { passive: true })
|
||||||
|
this.observers.push(() => row.removeEventListener(type, mark))
|
||||||
|
}
|
||||||
|
|
||||||
|
const resize = new ResizeObserver(mark)
|
||||||
|
resize.observe(row)
|
||||||
|
this.observers.push(() => resize.disconnect())
|
||||||
|
|
||||||
|
const mutation = new MutationObserver(mark)
|
||||||
|
mutation.observe(row, { childList: true, subtree: true })
|
||||||
|
this.observers.push(() => mutation.disconnect())
|
||||||
|
|
||||||
|
mark()
|
||||||
|
},
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.observers.forEach((stop) => stop())
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
})
|
||||||
@@ -17,4 +17,6 @@ import './progress.js'
|
|||||||
import './list-rows.js'
|
import './list-rows.js'
|
||||||
import './bottom-sheet.js'
|
import './bottom-sheet.js'
|
||||||
import './carousel.js'
|
import './carousel.js'
|
||||||
|
import './chips.js'
|
||||||
import './field.js'
|
import './field.js'
|
||||||
|
import './search.js'
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* `materialSearch`: the behaviour of `<x-search>` — a search bar that opens into a search view.
|
||||||
|
*
|
||||||
|
* Focus or a press on the bar opens the view; Escape, the back arrow, a press outside, focus
|
||||||
|
* leaving the search, or choosing a result closes it. ArrowDown from the input moves into the
|
||||||
|
* results and the arrow keys walk them; ArrowUp past the first result returns to the input. On a
|
||||||
|
* compact window (below `sm`) the view is full screen and modal: focus stays in it and the page
|
||||||
|
* behind does not scroll. The results themselves are the caller's, rendered by Livewire into the
|
||||||
|
* view as the query changes.
|
||||||
|
*/
|
||||||
|
const COMPACT = '(max-width: 39.99rem)'
|
||||||
|
|
||||||
|
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||||
|
|
||||||
|
const CHOOSES = 'a[href], button:not([disabled]), [data-list-open]'
|
||||||
|
|
||||||
|
// A close hands focus back to the input: Escape does, and so does the full-screen view's focus trap
|
||||||
|
// when it lets go, a moment later. Focus arriving this soon after a close is that, not someone
|
||||||
|
// coming to search.
|
||||||
|
const RETURN_GUARD_MS = 250
|
||||||
|
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
window.Alpine.data('materialSearch', (docked = false) => ({
|
||||||
|
open: false,
|
||||||
|
compact: false,
|
||||||
|
closedAt: -Infinity,
|
||||||
|
|
||||||
|
init() {
|
||||||
|
const query = window.matchMedia(COMPACT)
|
||||||
|
|
||||||
|
this.compact = query.matches
|
||||||
|
query.addEventListener('change', (event) => (this.compact = event.matches))
|
||||||
|
},
|
||||||
|
|
||||||
|
get fullScreen() {
|
||||||
|
return this.open && this.compact && !docked
|
||||||
|
},
|
||||||
|
|
||||||
|
show() {
|
||||||
|
this.open = true
|
||||||
|
},
|
||||||
|
|
||||||
|
focused() {
|
||||||
|
if (performance.now() - this.closedAt > RETURN_GUARD_MS) {
|
||||||
|
this.show()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
close(refocus = false) {
|
||||||
|
this.open = false
|
||||||
|
this.closedAt = performance.now()
|
||||||
|
|
||||||
|
if (refocus) {
|
||||||
|
this.$refs.input.focus()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
const input = this.$refs.input
|
||||||
|
|
||||||
|
input.value = ''
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||||
|
input.focus()
|
||||||
|
},
|
||||||
|
|
||||||
|
results() {
|
||||||
|
return [...this.$refs.view.querySelectorAll(FOCUSABLE)].filter((element) => element.getClientRects().length > 0)
|
||||||
|
},
|
||||||
|
|
||||||
|
step(delta) {
|
||||||
|
const results = this.results()
|
||||||
|
const index = results.indexOf(document.activeElement)
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index === -1) {
|
||||||
|
results[delta > 0 ? 0 : results.length - 1].focus()
|
||||||
|
} else if (index + delta < 0) {
|
||||||
|
this.$refs.input.focus()
|
||||||
|
} else {
|
||||||
|
results[Math.min(index + delta, results.length - 1)].focus()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
choose(event) {
|
||||||
|
if (event.target.closest(CHOOSES)) {
|
||||||
|
this.close()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
leave(event) {
|
||||||
|
if (event.relatedTarget && !this.$root.contains(event.relatedTarget)) {
|
||||||
|
this.close()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
})
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
{{-- A set of M3 chips: a row that wraps, 8px apart, named for screen readers as a group.
|
||||||
|
|
||||||
|
<x-chip-set label="File types" hint="Show only these" error-field="kinds">
|
||||||
|
<x-chip type="filter" label="Photos" value="photos" wire:model.live="kinds" />
|
||||||
|
<x-chip type="filter" label="Documents" value="documents" wire:model.live="kinds" />
|
||||||
|
</x-chip-set>
|
||||||
|
|
||||||
|
`label` is shown above the row and names the group; without it, pass `aria-label`. `hint`
|
||||||
|
sits under it, and a validation message for `error-field` (the property the chips bind, and
|
||||||
|
its items: `kinds` and `kinds.*`) replaces the hint.
|
||||||
|
|
||||||
|
`scroll` keeps the chips on one line that scrolls sideways, as M3 lays chips out on a narrow
|
||||||
|
screen: the edge it can still scroll towards fades (resources/js/chips.js), and a chip reached
|
||||||
|
with Tab scrolls clear of the fade. Removing a focused input chip moves focus within the set. --}}
|
||||||
|
|
||||||
|
@props([
|
||||||
|
'label' => null,
|
||||||
|
'hint' => null,
|
||||||
|
'errorField' => null,
|
||||||
|
'scroll' => false,
|
||||||
|
])
|
||||||
|
|
||||||
|
@php
|
||||||
|
$key = \Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10));
|
||||||
|
$messages = filled($errorField) && isset($errors)
|
||||||
|
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($errorField), $errors->get($errorField.'.*')])))
|
||||||
|
: [];
|
||||||
|
$describedBy = $messages !== [] || filled($hint) ? "material-chip-set-{$key}-hint" : null;
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
@if (filled($label)) aria-labelledby="material-chip-set-{{ $key }}-label" @endif
|
||||||
|
@if ($describedBy) aria-describedby="{{ $describedBy }}" @endif
|
||||||
|
{{ $attributes->class('min-w-0') }}
|
||||||
|
>
|
||||||
|
@if (filled($label))
|
||||||
|
<p id="material-chip-set-{{ $key }}-label" class="mb-2 type-label-lg text-on-surface-variant">{{ $label }}</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($scroll)
|
||||||
|
<div
|
||||||
|
data-chip-set
|
||||||
|
x-data="materialChipSet"
|
||||||
|
wire:ignore.self
|
||||||
|
class="-mx-1.5 -my-2 flex scroll-px-6 gap-2 overflow-x-auto px-1.5 py-2 [scrollbar-width:none] [--chip-fade-end:0px] [--chip-fade-start:0px] data-scroll-end:[--chip-fade-end:1.5rem] data-scroll-start:[--chip-fade-start:1.5rem] [mask-image:linear-gradient(to_right,transparent,#000_var(--chip-fade-start),#000_calc(100%_-_var(--chip-fade-end)),transparent)] rtl:[mask-image:linear-gradient(to_left,transparent,#000_var(--chip-fade-start),#000_calc(100%_-_var(--chip-fade-end)),transparent)]"
|
||||||
|
>
|
||||||
|
{{ $slot }}
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div data-chip-set class="flex flex-wrap gap-2">
|
||||||
|
{{ $slot }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($messages !== [])
|
||||||
|
<div id="{{ $describedBy }}">
|
||||||
|
@foreach ($messages as $message)
|
||||||
|
<p class="mt-1 type-body-sm text-error">{{ $message }}</p>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@elseif (filled($hint))
|
||||||
|
<p id="{{ $describedBy }}" class="mt-1 type-body-sm text-on-surface-variant">{{ $hint }}</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
{{-- An M3 chip — assist, filter, input or suggestion — in one component, picked by `type`.
|
||||||
|
|
||||||
|
<x-chip label="Add to calendar" icon="event" wire:click="addToCalendar" />
|
||||||
|
<x-chip type="filter" label="Photos" value="photos" wire:model.live="kinds" />
|
||||||
|
<x-chip type="input" label="anna@example.com" avatar="AM" removable wire:remove="removeRecipient(3)" wire:key="recipient-3" />
|
||||||
|
<x-chip type="suggestion" label="Sounds good" wire:click="reply('Sounds good')" />
|
||||||
|
|
||||||
|
`assist` (the default) is an action: a button, or an anchor with `link` (`wire:navigate` unless
|
||||||
|
`external` or `no-wire-navigate`; `disabled` works on a link too, as `aria-disabled`). Its icons
|
||||||
|
are primary. `suggestion` is the same in on-surface-variant, for a suggested reply or query.
|
||||||
|
|
||||||
|
`filter` is a toggle. With `wire:model`, `x-model` or `name` it is a native checkbox under the
|
||||||
|
chip, so Livewire and Alpine bind it as they bind any checkbox — to a boolean, or to an array
|
||||||
|
with `value` for a multi-select set — and a form submits it; `selected` is then only the
|
||||||
|
initial state without a model. Without any of them it is a toggle button whose `selected`
|
||||||
|
(`aria-pressed`) the caller owns, flipped by its own `wire:click`. Selected, it takes
|
||||||
|
secondary-container and a check that grows in at the start; with an `icon`, the check takes the
|
||||||
|
icon's place.
|
||||||
|
|
||||||
|
`input` is something a person entered: `avatar` (an image URL, or initials) or `icon` at the
|
||||||
|
start, `selected`, and `removable` for a trailing remove button named "Remove <label>". Removing
|
||||||
|
calls `wire:remove` (a Livewire action) or `remove` (an Alpine expression); with neither, the
|
||||||
|
chip takes itself off the page, hidden `name`/`value` input and all. Backspace or Delete on a
|
||||||
|
focused chip removes it too, and focus moves to the previous (Backspace) or next (Delete) chip.
|
||||||
|
The chip is a button only when it has something of its own to do (`wire:click`, `x-on:click`,
|
||||||
|
`link`, `selected`); otherwise the remove button is its one stop. Give a removable chip in a
|
||||||
|
Livewire loop a `wire:key`.
|
||||||
|
|
||||||
|
`elevated` draws assist, filter and suggestion chips on surface-container-low at elevation 1
|
||||||
|
instead of the outline; input chips are flat only, as in M3. `tooltip` attaches a plain tooltip.
|
||||||
|
|
||||||
|
Attributes: on a filter checkbox and an input chip, `class`, `style` and `wire:key` stay on the
|
||||||
|
chip and every other attribute goes to the control inside (the checkbox, or the chip's button).
|
||||||
|
|
||||||
|
Values from androidx Compose Material 3 (Chip.kt, AssistChipTokens, FilterChipTokens,
|
||||||
|
InputChipTokens, SuggestionChipTokens at androidx/androidx 27cf9a7, Apache-2.0): 32px tall,
|
||||||
|
small corner, label-large, 18px icons, 24px avatar; 16px padding beside a label, 8px beside an
|
||||||
|
icon, 8px between (an input chip: 12px, 8px, 4px beside an avatar). The check grows in on the
|
||||||
|
fast spatial spring and fades in on the slow effects one; it shrinks on default effects and
|
||||||
|
fades on fast effects, as AnimatingChipContent does. --}}
|
||||||
|
|
||||||
|
@props([
|
||||||
|
'type' => 'assist',
|
||||||
|
'label' => null,
|
||||||
|
'icon' => null,
|
||||||
|
'iconRight' => null,
|
||||||
|
'avatar' => null,
|
||||||
|
'elevated' => false,
|
||||||
|
'selected' => null,
|
||||||
|
'removable' => false,
|
||||||
|
'remove' => null,
|
||||||
|
'link' => null,
|
||||||
|
'external' => false,
|
||||||
|
'noWireNavigate' => false,
|
||||||
|
'disabled' => false,
|
||||||
|
'name' => null,
|
||||||
|
'value' => null,
|
||||||
|
'tooltip' => null,
|
||||||
|
])
|
||||||
|
|
||||||
|
@php
|
||||||
|
$kind = in_array($type, ['assist', 'filter', 'input', 'suggestion'], true) ? $type : 'assist';
|
||||||
|
$text = $label ?? trim(strip_tags((string) $slot));
|
||||||
|
$isLink = filled($link) && $kind !== 'filter';
|
||||||
|
$inert = $disabled && $isLink;
|
||||||
|
$raised = $elevated && $kind !== 'input';
|
||||||
|
$initials = filled($avatar) && ! str_contains((string) $avatar, '/') && ! str_contains((string) $avatar, '.');
|
||||||
|
$anchor = $tooltip !== null ? '--material-chip-'.\Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10)) : null;
|
||||||
|
|
||||||
|
// A filter chip is a native checkbox whenever something binds or submits it.
|
||||||
|
$checkbox = $kind === 'filter' && ($attributes->whereStartsWith(['wire:model', 'x-model'])->isNotEmpty() || filled($name));
|
||||||
|
$wireModel = $attributes->whereStartsWith('wire:model')->first();
|
||||||
|
$checked = (bool) $selected;
|
||||||
|
|
||||||
|
// Rendered as the bound property already says, so nothing flips (and animates) once Livewire starts.
|
||||||
|
if ($checkbox && $wireModel !== null && ($component = \Livewire\Livewire::current()) !== null) {
|
||||||
|
$bound = data_get($component, $wireModel);
|
||||||
|
$checked = is_array($bound)
|
||||||
|
? in_array((string) $value, array_map(fn ($item): string => is_scalar($item) ? (string) $item : '', $bound), true)
|
||||||
|
: (bool) $bound;
|
||||||
|
}
|
||||||
|
|
||||||
|
$wireRemove = $attributes->get('wire:remove');
|
||||||
|
$attributes = $attributes->except(['wire:remove']);
|
||||||
|
|
||||||
|
$outer = ['class', 'style', 'wire:key'];
|
||||||
|
|
||||||
|
// Compose draws the 1px border inside the chip; a CSS border takes room, so the padding
|
||||||
|
// beside it is 1px short of the token (7px for 8, 15px for 16).
|
||||||
|
|
||||||
|
$base = 'group/chip inline-flex h-8 max-w-full shrink-0 select-none items-center whitespace-nowrap rounded-corner-sm border type-label-lg transition-[background-color,border-color,color,box-shadow] duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast';
|
||||||
|
// Below 48px the touch target reaches past the chip, as M3 requires.
|
||||||
|
$target = 'after:absolute after:inset-x-0 after:top-1/2 after:h-12 after:-translate-y-1/2';
|
||||||
|
|
||||||
|
// Every colour class written out whole, so Tailwind compiles it. Selected is a state the browser
|
||||||
|
// owns on a filter chip (a checked checkbox, or aria-pressed), so it is written as a variant.
|
||||||
|
$colours = match (true) {
|
||||||
|
$kind === 'filter' && $raised && $disabled => 'border-transparent bg-on-surface/12 text-on-surface/38',
|
||||||
|
$kind === 'filter' && $raised => 'border-transparent bg-surface-container-low text-on-surface-variant shadow-elevation-1 hover:shadow-elevation-2 has-checked:bg-secondary-container has-checked:text-on-secondary-container aria-pressed:bg-secondary-container aria-pressed:text-on-secondary-container',
|
||||||
|
$kind === 'filter' && $disabled => 'border-on-surface/12 text-on-surface/38 has-checked:border-transparent has-checked:bg-on-surface/12 aria-pressed:border-transparent aria-pressed:bg-on-surface/12',
|
||||||
|
$kind === 'filter' => 'border-outline-variant text-on-surface-variant focus-visible:border-on-surface-variant has-focus-visible:border-on-surface-variant has-checked:border-transparent has-checked:bg-secondary-container has-checked:text-on-secondary-container hover:has-checked:shadow-elevation-1 aria-pressed:border-transparent aria-pressed:bg-secondary-container aria-pressed:text-on-secondary-container hover:aria-pressed:shadow-elevation-1',
|
||||||
|
$kind === 'input' && $disabled => $selected ? 'border-transparent bg-on-surface/12 text-on-surface/38' : 'border-on-surface/12 text-on-surface/38',
|
||||||
|
$kind === 'input' => $selected ? 'border-transparent bg-secondary-container text-on-secondary-container' : 'border-outline-variant text-on-surface-variant has-[[data-chip-action]:focus-visible]:border-on-surface-variant',
|
||||||
|
$raised && $disabled => 'border-transparent bg-on-surface/12 text-on-surface/38',
|
||||||
|
$raised => $kind === 'suggestion'
|
||||||
|
? 'border-transparent bg-surface-container-low text-on-surface-variant shadow-elevation-1 hover:shadow-elevation-2'
|
||||||
|
: 'border-transparent bg-surface-container-low text-on-surface shadow-elevation-1 hover:shadow-elevation-2',
|
||||||
|
$disabled => 'border-on-surface/12 text-on-surface/38',
|
||||||
|
default => $kind === 'suggestion'
|
||||||
|
? 'border-outline-variant text-on-surface-variant focus-visible:border-on-surface-variant'
|
||||||
|
: 'border-outline-variant text-on-surface focus-visible:border-on-surface',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Leading icons: primary on assist, suggestion and filter chips; on-surface-variant on an input
|
||||||
|
// chip, primary while it is hovered, focused or pressed, and primary on a selected one.
|
||||||
|
$clicks = $attributes->whereStartsWith(['wire:click', 'x-on:click', '@click'])->isNotEmpty();
|
||||||
|
$leadingInk = match (true) {
|
||||||
|
$disabled => null,
|
||||||
|
$kind !== 'input', (bool) $selected => 'text-primary',
|
||||||
|
$isLink || $clicks => 'transition-colors duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast group-hover/chip:text-primary group-active/chip:text-primary group-has-focus-visible/chip:text-primary',
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
$trailingInk = in_array($kind, ['assist', 'suggestion'], true) && ! $disabled ? 'text-primary' : null;
|
||||||
|
|
||||||
|
$check = 'size-4.5 opacity-0 transition-opacity duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast group-has-checked/chip:opacity-100 group-has-checked/chip:duration-(--md-sys-motion-effects-slow-duration) group-has-checked/chip:ease-effects-slow group-aria-pressed/chip:opacity-100 group-aria-pressed/chip:duration-(--md-sys-motion-effects-slow-duration) group-aria-pressed/chip:ease-effects-slow';
|
||||||
|
|
||||||
|
if ($kind === 'input') {
|
||||||
|
$actionTag = match (true) {
|
||||||
|
$isLink => 'a',
|
||||||
|
$clicks || $selected !== null => 'button',
|
||||||
|
default => 'span',
|
||||||
|
};
|
||||||
|
$interactive = $actionTag !== 'span';
|
||||||
|
$removeExpression = implode('; ', array_filter([
|
||||||
|
'removeChip($event)',
|
||||||
|
filled($remove) ? $remove : null,
|
||||||
|
blank($remove) && blank($wireRemove) ? '$root.remove()' : null,
|
||||||
|
]));
|
||||||
|
|
||||||
|
$container = $attributes->only($outer)
|
||||||
|
->class([
|
||||||
|
$base,
|
||||||
|
'relative isolate',
|
||||||
|
$colours,
|
||||||
|
'has-[[data-chip-action]:focus-visible]:outline-3 has-[[data-chip-action]:focus-visible]:outline-offset-2 has-[[data-chip-action]:focus-visible]:outline-secondary',
|
||||||
|
])
|
||||||
|
->merge(array_filter(['style' => $anchor ? "anchor-name: {$anchor}" : null]));
|
||||||
|
|
||||||
|
$action = $attributes->except($outer)
|
||||||
|
->class([
|
||||||
|
'flex h-full min-w-0 items-center outline-none',
|
||||||
|
match (true) {
|
||||||
|
filled($avatar) => 'ps-0.75',
|
||||||
|
filled($icon) => 'ps-1.75',
|
||||||
|
default => 'ps-2.75',
|
||||||
|
},
|
||||||
|
$removable ? 'pe-2' : 'pe-2.75',
|
||||||
|
'cursor-pointer before:absolute before:-inset-px before:-z-10 before:rounded-corner-sm before:bg-current before:opacity-0 before:transition-opacity before:duration-(--md-sys-motion-effects-fast-duration) before:ease-effects-fast hover:before:opacity-8 focus-visible:before:opacity-10 active:before:opacity-10' => $interactive && ! $disabled,
|
||||||
|
$target => $interactive,
|
||||||
|
'cursor-not-allowed' => $interactive && $disabled,
|
||||||
|
'pointer-events-none' => $inert,
|
||||||
|
])
|
||||||
|
->merge(array_filter([
|
||||||
|
'data-chip-action' => $interactive ? true : null,
|
||||||
|
'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' => $actionTag === 'button' ? 'button' : null,
|
||||||
|
'disabled' => $actionTag === 'button' && $disabled ? true : null,
|
||||||
|
'aria-pressed' => $actionTag === 'button' && $selected !== null ? ($selected ? 'true' : 'false') : null,
|
||||||
|
], fn ($attribute): bool => $attribute !== null));
|
||||||
|
} else {
|
||||||
|
$tag = match (true) {
|
||||||
|
$checkbox => 'label',
|
||||||
|
$isLink => 'a',
|
||||||
|
default => 'button',
|
||||||
|
};
|
||||||
|
$control = $checkbox
|
||||||
|
? $attributes->except($outer)->merge(array_filter([
|
||||||
|
'type' => 'checkbox',
|
||||||
|
'name' => $name,
|
||||||
|
'value' => $value,
|
||||||
|
'checked' => $checked ? true : null,
|
||||||
|
'disabled' => $disabled ? true : null,
|
||||||
|
], fn ($attribute): bool => $attribute !== null))->class('peer sr-only')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$container = ($checkbox ? $attributes->only($outer) : $attributes)
|
||||||
|
->class([
|
||||||
|
$base,
|
||||||
|
'state-layer relative',
|
||||||
|
'focus-ring' => ! $checkbox,
|
||||||
|
'has-focus-visible:outline-3 has-focus-visible:outline-offset-2 has-focus-visible:outline-secondary has-focus-visible:before:opacity-10' => $checkbox,
|
||||||
|
$kind === 'filter' || filled($icon) ? 'ps-1.75' : 'ps-3.75',
|
||||||
|
$iconRight ? 'pe-1.75' : 'pe-3.75',
|
||||||
|
$colours,
|
||||||
|
$target,
|
||||||
|
'cursor-pointer' => ! $disabled,
|
||||||
|
'cursor-not-allowed' => $disabled && ! $isLink,
|
||||||
|
'pointer-events-none' => $inert,
|
||||||
|
])
|
||||||
|
->merge(array_filter([
|
||||||
|
'data-chip' => $kind,
|
||||||
|
'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' => $tag === 'button' ? 'button' : null,
|
||||||
|
'disabled' => $tag === 'button' && $disabled ? true : null,
|
||||||
|
'name' => $tag === 'button' ? $name : null,
|
||||||
|
'value' => $tag === 'button' ? $value : null,
|
||||||
|
'aria-pressed' => $kind === 'filter' && ! $checkbox ? ($selected ? 'true' : 'false') : null,
|
||||||
|
'style' => $anchor ? "anchor-name: {$anchor}" : null,
|
||||||
|
], fn ($attribute): bool => $attribute !== null));
|
||||||
|
}
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@if ($kind === 'input')
|
||||||
|
<span data-chip="input" @if ($removable) x-data="materialChip" x-on:keydown="removeOnKey($event)" @endif {{ $container }}>
|
||||||
|
<{{ $actionTag }} {{ $action }}>
|
||||||
|
@if ($avatar)
|
||||||
|
@if ($initials)
|
||||||
|
<span aria-hidden="true" @class(['me-2 grid size-6 shrink-0 place-items-center rounded-corner-full bg-primary-container type-label-sm text-on-primary-container', 'opacity-38' => $disabled])>{{ $avatar }}</span>
|
||||||
|
@else
|
||||||
|
<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])" />
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<span data-chip-label class="truncate">{{ $label ?? $slot }}</span>
|
||||||
|
</{{ $actionTag }}>
|
||||||
|
|
||||||
|
@if ($removable)
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@if (filled($text)) data-chip-remove aria-label="{{ __('Remove :label', ['label' => $text]) }}" @else data-chip-remove="{{ __('Remove :label') }}" @endif
|
||||||
|
@if ($wireRemove) wire:click="{{ $wireRemove }}" @endif
|
||||||
|
x-on:click="{{ $removeExpression }}"
|
||||||
|
@disabled($disabled)
|
||||||
|
@class([
|
||||||
|
'relative me-1.75 grid size-4.5 shrink-0 place-items-center rounded-corner-full',
|
||||||
|
'cursor-pointer focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-secondary' => ! $disabled,
|
||||||
|
'before:absolute before:-inset-0.75 before:rounded-corner-full before:bg-current before:opacity-0 before:transition-opacity before:duration-(--md-sys-motion-effects-fast-duration) before:ease-effects-fast hover:before:opacity-8 focus-visible:before:opacity-10 active:before:opacity-10' => ! $disabled,
|
||||||
|
'after:absolute after:-inset-x-2 after:-inset-y-3.75',
|
||||||
|
'cursor-not-allowed' => $disabled,
|
||||||
|
])
|
||||||
|
>
|
||||||
|
<x-icon name="close" class="size-4.5" />
|
||||||
|
</button>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if (filled($name))
|
||||||
|
<input type="hidden" name="{{ $name }}" value="{{ $value ?? $text }}" />
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($tooltip !== null)
|
||||||
|
<x-tooltip :text="$tooltip" :anchor="$anchor" />
|
||||||
|
@endif
|
||||||
|
</span>
|
||||||
|
@else
|
||||||
|
<{{ $tag }} {{ $container }}>
|
||||||
|
@if ($checkbox)
|
||||||
|
<input {{ $control }} />
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($kind === 'filter')
|
||||||
|
{{-- The check grows in from nothing; beside an icon it takes the icon's place. --}}
|
||||||
|
<span @class([
|
||||||
|
'me-2 grid shrink-0 overflow-hidden *:col-start-1 *:row-start-1',
|
||||||
|
'size-4.5' => filled($icon),
|
||||||
|
'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])" />
|
||||||
|
@endif
|
||||||
|
<x-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])" />
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<span class="truncate">{{ $label ?? $slot }}</span>
|
||||||
|
|
||||||
|
@if ($iconRight)
|
||||||
|
<x-icon :name="$iconRight" :class="\Illuminate\Support\Arr::toCssClasses(['ms-2 size-4.5', $trailingInk])" />
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($tooltip !== null)
|
||||||
|
<x-tooltip :text="$tooltip" :anchor="$anchor" />
|
||||||
|
@endif
|
||||||
|
</{{ $tag }}>
|
||||||
|
@endif
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
{{-- Choosing from a list: M3's filter chips, or a searchable field with a menu.
|
||||||
|
|
||||||
|
Two shapes for two jobs, both on `options` (`['id' => …, 'name' => …]`, read through
|
||||||
|
`option-value` and `option-label`), `label`, `hint` and `single`:
|
||||||
|
|
||||||
|
- **Chips**, the default. Every option is on screen and a press turns it on or off — "the days
|
||||||
|
you are free" is seven filter chips, not a dropdown opened seven times. `single` makes them
|
||||||
|
choice chips, one on at a time.
|
||||||
|
- **`searchable`**, for a list too long to lay out — four hundred time zones. A text field
|
||||||
|
that filters as you type, with the matches in M3's menu under it: the arrow keys move,
|
||||||
|
Enter chooses, Escape puts the field back. One value only. The list is a popover in the top
|
||||||
|
layer, placed by CSS anchor positioning, so a card's clipping never cuts it off. `icon`,
|
||||||
|
`variant` and `placeholder` are the field's.
|
||||||
|
|
||||||
|
The selection lives in Alpine, entangled with the `wire:model` property (live with
|
||||||
|
`wire:model.live`), rather than in native checkboxes, which would send every value back as a
|
||||||
|
string: a list of integers stays a list of integers. Without `wire:model` it works on its own
|
||||||
|
and binds with `x-model`. Errors for the property and its items replace the hint. When the
|
||||||
|
options change on the server, give the component a `wire:key` that changes with them: the
|
||||||
|
options are baked into its Alpine state. --}}
|
||||||
|
|
||||||
|
@props([
|
||||||
|
'label' => null,
|
||||||
|
'hint' => null,
|
||||||
|
'icon' => null,
|
||||||
|
'options' => [],
|
||||||
|
'optionValue' => 'id',
|
||||||
|
'optionLabel' => 'name',
|
||||||
|
'single' => false,
|
||||||
|
'searchable' => false,
|
||||||
|
'variant' => null,
|
||||||
|
'placeholder' => null,
|
||||||
|
'value' => null,
|
||||||
|
])
|
||||||
|
|
||||||
|
@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.'.*')])))
|
||||||
|
: [];
|
||||||
|
$choices = collect($options)->map(fn ($option): array => [
|
||||||
|
'value' => data_get($option, $optionValue),
|
||||||
|
'label' => (string) data_get($option, $optionLabel),
|
||||||
|
'disabled' => (bool) data_get($option, 'disabled', false),
|
||||||
|
])->values()->all();
|
||||||
|
$single = $single || $searchable;
|
||||||
|
|
||||||
|
// Rendered as the bound property already says, so nothing flips once Alpine starts.
|
||||||
|
$current = $value;
|
||||||
|
if ($model !== null && ($component = \Livewire\Livewire::current()) !== null) {
|
||||||
|
$current = data_get($component, $model);
|
||||||
|
}
|
||||||
|
$isSelected = fn ($candidate): bool => $single
|
||||||
|
? $current !== null && (string) $current === (string) $candidate
|
||||||
|
: in_array((string) $candidate, array_map('strval', array_filter((array) $current, 'is_scalar')), true);
|
||||||
|
|
||||||
|
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|choices'), 0, 12);
|
||||||
|
$anchor = '--material-choices-'.\Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10));
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@if ($searchable)
|
||||||
|
<div
|
||||||
|
{{ $attributes->only(['class', 'wire:key', 'x-model']) }}
|
||||||
|
x-data="{
|
||||||
|
@if ($model !== null) value: @entangle($attributes->wire('model')), @else value: @js($current), @endif
|
||||||
|
options: @js($choices),
|
||||||
|
query: '',
|
||||||
|
open: false,
|
||||||
|
active: 0,
|
||||||
|
selecting: false,
|
||||||
|
get selectedLabel() {
|
||||||
|
return this.options.find((option) => option.value === this.value)?.label ?? '';
|
||||||
|
},
|
||||||
|
get filtered() {
|
||||||
|
const query = this.query.trim().toLowerCase();
|
||||||
|
|
||||||
|
return query === '' || this.query === this.selectedLabel
|
||||||
|
? this.options
|
||||||
|
: this.options.filter((option) => option.label.toLowerCase().includes(query));
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.query = this.selectedLabel;
|
||||||
|
this.$watch('value', () => { if (! this.open) this.query = this.selectedLabel; });
|
||||||
|
this.$watch('open', (open) => {
|
||||||
|
const list = this.$refs.list;
|
||||||
|
if (open && ! list.matches(':popover-open')) list.showPopover();
|
||||||
|
if (! open && list.matches(':popover-open')) list.hidePopover();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
show() {
|
||||||
|
this.open = true;
|
||||||
|
this.active = Math.max(0, this.filtered.findIndex((option) => option.value === this.value));
|
||||||
|
this.$nextTick(() => this.reveal());
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
this.open = false;
|
||||||
|
this.query = this.selectedLabel;
|
||||||
|
},
|
||||||
|
move(step) {
|
||||||
|
if (! this.open) return this.show();
|
||||||
|
if (this.filtered.length === 0) return;
|
||||||
|
this.active = (this.active + step + this.filtered.length) % this.filtered.length;
|
||||||
|
this.$nextTick(() => this.reveal());
|
||||||
|
},
|
||||||
|
choose(option) {
|
||||||
|
if (! option || option.disabled) return;
|
||||||
|
this.value = option.value;
|
||||||
|
this.query = option.label;
|
||||||
|
this.open = false;
|
||||||
|
},
|
||||||
|
reveal() {
|
||||||
|
this.$refs.list.querySelector('[data-active]')?.scrollIntoView({ block: 'nearest' });
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
@if ($model === null) x-modelable="value" @endif
|
||||||
|
>
|
||||||
|
<div style="anchor-name: {{ $anchor }}">
|
||||||
|
<x-field :$id :$label :$hint :$messages :$icon :$variant>
|
||||||
|
<input
|
||||||
|
id="{{ $id }}"
|
||||||
|
type="text"
|
||||||
|
role="combobox"
|
||||||
|
autocomplete="off"
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-controls="{{ $id }}-list"
|
||||||
|
aria-expanded="false"
|
||||||
|
x-bind:aria-expanded="open.toString()"
|
||||||
|
x-bind:aria-activedescendant="open && filtered[active] ? '{{ $id }}-option-' + active : null"
|
||||||
|
@if ($messages !== []) aria-invalid="true" @endif
|
||||||
|
@if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif
|
||||||
|
placeholder="{{ filled($placeholder) ? $placeholder : ' ' }}"
|
||||||
|
class="field-control"
|
||||||
|
x-model="query"
|
||||||
|
x-on:focus="show(); $nextTick(() => $el.select())"
|
||||||
|
x-on:pointerdown="selecting = document.activeElement !== $el"
|
||||||
|
x-on:click="if (selecting) { $el.select(); selecting = false; } if (! open) show();"
|
||||||
|
x-on:input="open = true; active = 0"
|
||||||
|
x-on:keydown.arrow-down.prevent="move(1)"
|
||||||
|
x-on:keydown.arrow-up.prevent="move(-1)"
|
||||||
|
x-on:keydown.enter.prevent="choose(filtered[active])"
|
||||||
|
x-on:keydown.escape="close()"
|
||||||
|
x-on:keydown.tab="close()"
|
||||||
|
x-on:blur="close()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<x-slot:trailing>
|
||||||
|
<x-icon name="arrow_drop_down" class="field-trailing field-arrow size-(--field-icon)" />
|
||||||
|
</x-slot:trailing>
|
||||||
|
</x-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul
|
||||||
|
id="{{ $id }}-list"
|
||||||
|
role="listbox"
|
||||||
|
popover="manual"
|
||||||
|
x-ref="list"
|
||||||
|
@if (filled($label)) aria-label="{{ $label }}" @endif
|
||||||
|
style="position-anchor: {{ $anchor }}"
|
||||||
|
class="field-menu"
|
||||||
|
>
|
||||||
|
<template x-for="(option, index) in filtered" :key="option.value">
|
||||||
|
<li
|
||||||
|
role="option"
|
||||||
|
x-bind:id="'{{ $id }}-option-' + index"
|
||||||
|
x-bind:aria-selected="(option.value === value).toString()"
|
||||||
|
x-bind:aria-disabled="option.disabled ? 'true' : null"
|
||||||
|
x-bind:data-active="index === active ? '' : null"
|
||||||
|
x-on:mousedown.prevent="choose(option)"
|
||||||
|
x-on:mousemove="active = index"
|
||||||
|
class="field-option"
|
||||||
|
>
|
||||||
|
<span x-text="option.label"></span>
|
||||||
|
<x-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>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div
|
||||||
|
{{ $attributes->only(['class', 'wire:key', 'x-model']) }}
|
||||||
|
x-data="{
|
||||||
|
@if ($model !== null) selection: @entangle($attributes->wire('model')), @else selection: @js($current ?? ($single ? null : [])), @endif
|
||||||
|
options: @js(array_column($choices, 'value')),
|
||||||
|
selected(index) {
|
||||||
|
const value = this.options[index];
|
||||||
|
|
||||||
|
return @js($single) ? this.selection === value : (this.selection ?? []).includes(value);
|
||||||
|
},
|
||||||
|
toggle(index) {
|
||||||
|
const value = this.options[index];
|
||||||
|
|
||||||
|
if (@js($single)) {
|
||||||
|
this.selection = value;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = this.selection ?? [];
|
||||||
|
this.selection = current.includes(value) ? current.filter((each) => each !== value) : [...current, value];
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
@if ($model === null) x-modelable="selection" @endif
|
||||||
|
>
|
||||||
|
<x-chip-set :$label :$hint :error-field="$model">
|
||||||
|
@foreach ($choices as $index => $choice)
|
||||||
|
<x-chip
|
||||||
|
type="filter"
|
||||||
|
:label="$choice['label']"
|
||||||
|
:selected="$isSelected($choice['value'])"
|
||||||
|
:disabled="$choice['disabled']"
|
||||||
|
x-bind:aria-pressed="selected({{ $index }}).toString()"
|
||||||
|
x-on:click="toggle({{ $index }})"
|
||||||
|
/>
|
||||||
|
@endforeach
|
||||||
|
</x-chip-set>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
{{-- M3's search: a search bar that opens into a search view with the results.
|
||||||
|
|
||||||
|
Bind the input like any other (`wire:model.live.debounce.300ms="query"`) and render the
|
||||||
|
results in the slot, from a Livewire property or computed property that follows the query;
|
||||||
|
`empty` is shown instead when the slot renders nothing (say, "No shares match"). Results are
|
||||||
|
usually `<x-list-item>`s with a `link`, or buttons: choosing one closes the view.
|
||||||
|
|
||||||
|
Docked under the bar from `sm`, full screen below it with a back arrow (resources/css/
|
||||||
|
components/search.css); `docked` keeps it docked at every width. `placeholder` ("Search"),
|
||||||
|
`label` (the input's name when it differs from the placeholder), leading `icon` (`search`), and
|
||||||
|
a `trailing` slot for an avatar or icon buttons in the bar. Every other attribute reaches the
|
||||||
|
`<input type="search">`. --}}
|
||||||
|
|
||||||
|
@props([
|
||||||
|
'placeholder' => null,
|
||||||
|
'label' => null,
|
||||||
|
'icon' => 'search',
|
||||||
|
'docked' => false,
|
||||||
|
])
|
||||||
|
|
||||||
|
@php
|
||||||
|
$model = $attributes->wire('model')->value() ?: null;
|
||||||
|
$placeholder ??= __('Search');
|
||||||
|
$id = $attributes->get('id') ?? 'material-search-'.substr(md5($model.'|'.$placeholder), 0, 10);
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div
|
||||||
|
x-data="materialSearch({{ $docked ? 'true' : 'false' }})"
|
||||||
|
x-on:keydown.escape="if (open) { $event.stopPropagation(); close(true); }"
|
||||||
|
x-on:focusout="leave($event)"
|
||||||
|
x-on:pointerdown.outside="close()"
|
||||||
|
x-trap.noscroll="fullScreen"
|
||||||
|
x-bind:data-open="open ? '' : null"
|
||||||
|
x-bind:data-full-screen="fullScreen ? '' : null"
|
||||||
|
data-search
|
||||||
|
{{ $attributes->only(['class', 'wire:key'])->class(['relative']) }}
|
||||||
|
>
|
||||||
|
<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" />
|
||||||
|
</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" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<input
|
||||||
|
{{ $attributes->except(['class', 'wire:key', 'id', 'placeholder', 'type']) }}
|
||||||
|
x-ref="input"
|
||||||
|
id="{{ $id }}"
|
||||||
|
type="search"
|
||||||
|
autocomplete="off"
|
||||||
|
enterkeyhint="search"
|
||||||
|
placeholder="{{ $placeholder }}"
|
||||||
|
aria-label="{{ $label ?? $placeholder }}"
|
||||||
|
aria-controls="{{ $id }}-view"
|
||||||
|
aria-expanded="false"
|
||||||
|
x-bind:aria-expanded="open.toString()"
|
||||||
|
x-on:focus="focused()"
|
||||||
|
x-on:click="show()"
|
||||||
|
x-on:input="show()"
|
||||||
|
x-on:keydown.arrow-down.prevent="show(); $nextTick(() => step(1))"
|
||||||
|
data-search-input
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button type="button" data-search-clear x-on:click="clear()" aria-label="{{ __('Clear') }}">
|
||||||
|
<x-icon name="close" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@isset($trailing)
|
||||||
|
<span data-search-trailing>{{ $trailing }}</span>
|
||||||
|
@endisset
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
x-ref="view"
|
||||||
|
id="{{ $id }}-view"
|
||||||
|
data-search-view
|
||||||
|
x-cloak
|
||||||
|
x-show="open"
|
||||||
|
x-on:keydown.arrow-down.prevent="step(1)"
|
||||||
|
x-on:keydown.arrow-up.prevent="step(-1)"
|
||||||
|
x-on:click="choose($event)"
|
||||||
|
>
|
||||||
|
@if ($slot->hasActualContent())
|
||||||
|
<div data-search-results>{{ $slot }}</div>
|
||||||
|
@elseif (isset($empty))
|
||||||
|
<div data-search-results><p class="px-4 py-3 type-body-md text-on-surface-variant">{{ $empty }}</p></div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -19,5 +19,6 @@
|
|||||||
@include('livewire-material::showcase.sections.containment')
|
@include('livewire-material::showcase.sections.containment')
|
||||||
@include('livewire-material::showcase.sections.carousel')
|
@include('livewire-material::showcase.sections.carousel')
|
||||||
@include('livewire-material::showcase.sections.fields')
|
@include('livewire-material::showcase.sections.fields')
|
||||||
|
@include('livewire-material::showcase.sections.chips')
|
||||||
</main>
|
</main>
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<a href="{{ route('livewire-material.showcase') }}" class="shrink-0 type-title-lg max-sm:hidden">Livewire Material</a>
|
<a href="{{ route('livewire-material.showcase') }}" class="shrink-0 type-title-lg max-sm:hidden">Livewire Material</a>
|
||||||
|
|
||||||
<nav class="-my-2 flex min-w-0 flex-1 gap-x-4 overflow-x-auto py-2 whitespace-nowrap type-label-lg text-on-surface-variant [scrollbar-width:none]" aria-label="Sections">
|
<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'] as $anchor => $section)
|
@foreach (['colour' => 'Colour', 'type' => 'Type', 'shape' => 'Shape', 'elevation' => 'Elevation', 'motion' => 'Motion', 'icons' => 'Icons', 'buttons' => 'Buttons', 'menus' => 'Menus', 'communication' => 'Communication', 'progress' => 'Progress', 'containment' => 'Containment', 'carousel' => 'Carousel', 'fields' => 'Fields', 'chips' => 'Chips'] as $anchor => $section)
|
||||||
<a href="#{{ $anchor }}" class="rounded-corner-xs hover:text-on-surface focus-ring">{{ $section }}</a>
|
<a href="#{{ $anchor }}" class="rounded-corner-xs hover:text-on-surface focus-ring">{{ $section }}</a>
|
||||||
@endforeach
|
@endforeach
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
@php
|
||||||
|
$examples = [
|
||||||
|
'Assist chips' => <<<'BLADE'
|
||||||
|
<div x-data class="flex flex-wrap items-center gap-2">
|
||||||
|
<x-chip label="Add to calendar" icon="event" x-on:click="materialToast('Added to your calendar')" />
|
||||||
|
<x-chip label="Directions" icon="directions" link="#chips" />
|
||||||
|
<x-chip label="Share" icon="share" elevated tooltip="Share this file" />
|
||||||
|
<x-chip label="Material 3 guidelines" icon-right="open_in_new" link="https://m3.material.io/components/chips" external />
|
||||||
|
<x-chip label="Unavailable" icon="block" disabled />
|
||||||
|
<x-chip label="Unavailable" icon="block" elevated disabled />
|
||||||
|
</div>
|
||||||
|
BLADE,
|
||||||
|
'Filter chips' => <<<'BLADE'
|
||||||
|
<div x-data="{ kinds: ['photos'] }" class="w-full space-y-6">
|
||||||
|
<x-chip-set label="File types" hint="Show only these kinds of file">
|
||||||
|
<x-chip type="filter" label="Photos" value="photos" x-model="kinds" />
|
||||||
|
<x-chip type="filter" label="Documents" value="documents" x-model="kinds" />
|
||||||
|
<x-chip type="filter" label="Archives" value="archives" x-model="kinds" />
|
||||||
|
<x-chip type="filter" label="Video" value="video" x-model="kinds" disabled />
|
||||||
|
</x-chip-set>
|
||||||
|
|
||||||
|
<x-chip-set label="Elevated, with icons">
|
||||||
|
<x-chip type="filter" label="Starred" icon="star" name="starred" :selected="true" elevated />
|
||||||
|
<x-chip type="filter" label="Shared with me" icon="group" name="shared" elevated />
|
||||||
|
<x-chip type="filter" label="Expiring" name="expiring" icon-right="arrow_drop_down" />
|
||||||
|
<x-chip type="filter" label="Archived" name="archived" :selected="true" disabled />
|
||||||
|
</x-chip-set>
|
||||||
|
</div>
|
||||||
|
BLADE,
|
||||||
|
'Input chips' => <<<'BLADE'
|
||||||
|
<div x-data="{ recipients: ['anna@example.com', 'ben@example.com', 'chiara@example.com'] }" class="w-full space-y-6">
|
||||||
|
<x-chip-set label="Recipients" hint="Backspace or Delete removes the focused chip">
|
||||||
|
<template x-for="recipient in recipients" :key="recipient">
|
||||||
|
<x-chip type="input" icon="mail" removable remove="recipients = recipients.filter((other) => other !== recipient)"><span x-text="recipient"></span></x-chip>
|
||||||
|
</template>
|
||||||
|
</x-chip-set>
|
||||||
|
|
||||||
|
<x-chip-set label="People">
|
||||||
|
<x-chip type="input" label="Anna Müller" avatar="AM" removable />
|
||||||
|
<x-chip type="input" label="Ben Keller" avatar="BK" :selected="true" removable />
|
||||||
|
<x-chip type="input" label="contract.pdf" icon="picture_as_pdf" x-on:click="materialToast('contract.pdf')" />
|
||||||
|
<x-chip type="input" label="Chiara Rossi" avatar="CR" removable disabled />
|
||||||
|
</x-chip-set>
|
||||||
|
</div>
|
||||||
|
BLADE,
|
||||||
|
'Suggestion chips' => <<<'BLADE'
|
||||||
|
<x-chip-set label="Suggested replies">
|
||||||
|
<x-chip type="suggestion" label="Sounds good" />
|
||||||
|
<x-chip type="suggestion" label="Can we talk tomorrow?" />
|
||||||
|
<x-chip type="suggestion" label="Send the files" icon="attach_file" elevated />
|
||||||
|
<x-chip type="suggestion" label="Not now" disabled />
|
||||||
|
</x-chip-set>
|
||||||
|
BLADE,
|
||||||
|
'A set that scrolls' => <<<'BLADE'
|
||||||
|
<div class="w-full max-w-sm">
|
||||||
|
<x-chip-set label="Sort and filter" scroll>
|
||||||
|
<x-chip type="filter" label="Newest" name="sort[]" value="newest" :selected="true" />
|
||||||
|
<x-chip type="filter" label="Largest" name="sort[]" value="largest" />
|
||||||
|
<x-chip type="filter" label="Expiring soon" name="sort[]" value="expiring" />
|
||||||
|
<x-chip type="filter" label="Password protected" name="sort[]" value="protected" />
|
||||||
|
<x-chip type="filter" label="Downloaded" name="sort[]" value="downloaded" />
|
||||||
|
<x-chip type="filter" label="Never opened" name="sort[]" value="unopened" />
|
||||||
|
</x-chip-set>
|
||||||
|
</div>
|
||||||
|
BLADE,
|
||||||
|
];
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<section id="chips" class="scroll-mt-24 space-y-6">
|
||||||
|
<h2 class="type-headline-md">Chips</h2>
|
||||||
|
|
||||||
|
<p class="max-w-3xl type-body-md text-on-surface-variant">
|
||||||
|
<code><x-chip></code> — assist, filter, input and suggestion — and <code><x-chip-set></code>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@foreach ($examples as $title => $code)
|
||||||
|
<x-showcase::example :$title :$code />
|
||||||
|
@endforeach
|
||||||
|
</section>
|
||||||
@@ -98,6 +98,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
BLADE,
|
BLADE,
|
||||||
|
'Choices' => <<<'BLADE'
|
||||||
|
<div class="grid w-full gap-6 md:grid-cols-2">
|
||||||
|
<div class="grid content-start gap-6">
|
||||||
|
<x-choices label="Days you are free" hint="Filter chips: any number" :value="[1, 3]" :options="[['id' => 1, 'name' => 'Mon'], ['id' => 2, 'name' => 'Tue'], ['id' => 3, 'name' => 'Wed'], ['id' => 4, 'name' => 'Thu'], ['id' => 5, 'name' => 'Fri'], ['id' => 6, 'name' => 'Sat', 'disabled' => true]]" />
|
||||||
|
<x-choices label="Expires after" single :value="24" :options="[['id' => 1, 'name' => '1 hour'], ['id' => 24, 'name' => '1 day'], ['id' => 168, 'name' => '7 days']]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid content-start gap-4">
|
||||||
|
<x-choices label="Time zone" searchable icon="public" value="Europe/Zurich" :options="collect(timezone_identifiers_list())->map(fn ($zone) => ['id' => $zone, 'name' => str_replace('_', ' ', $zone)])->all()" />
|
||||||
|
<x-choices label="Language" searchable variant="filled" placeholder="Type a language" :options="[['id' => 'de', 'name' => 'Deutsch'], ['id' => 'en', 'name' => 'English'], ['id' => 'fr', 'name' => 'Français'], ['id' => 'it', 'name' => 'Italiano'], ['id' => 'rm', 'name' => 'Rumantsch', 'disabled' => true]]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
BLADE,
|
||||||
|
'Search' => <<<'BLADE'
|
||||||
|
<div class="grid w-full gap-6 md:grid-cols-2">
|
||||||
|
<div x-data="{ query: '' }">
|
||||||
|
<x-search x-model="query" placeholder="Search shares">
|
||||||
|
<x-list>
|
||||||
|
<x-list-item title="holiday-photos.zip" description="248 MB · 3 days left" icon="folder_zip" link="#fields" x-show="'holiday-photos.zip'.includes(query.toLowerCase())" />
|
||||||
|
<x-list-item title="contract.pdf" description="1.2 MB · 1 hour left" icon="picture_as_pdf" link="#fields" x-show="'contract.pdf'.includes(query.toLowerCase())" />
|
||||||
|
<x-list-item title="design-review.mp4" description="3.4 GB · 7 days left" icon="movie" link="#fields" x-show="'design-review.mp4'.includes(query.toLowerCase())" />
|
||||||
|
</x-list>
|
||||||
|
<x-slot:trailing>
|
||||||
|
<span class="grid size-8 place-items-center rounded-corner-full bg-primary-container type-label-lg text-on-primary-container">AR</span>
|
||||||
|
</x-slot:trailing>
|
||||||
|
</x-search>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<x-search placeholder="Search in settings" docked>
|
||||||
|
<x-slot:empty>Type to search your settings.</x-slot:empty>
|
||||||
|
</x-search>
|
||||||
|
</div>
|
||||||
|
BLADE,
|
||||||
'A form' => <<<'BLADE'
|
'A form' => <<<'BLADE'
|
||||||
<x-card variant="outlined" class="w-full max-w-xl">
|
<x-card variant="outlined" class="w-full max-w-xl">
|
||||||
<x-form x-on:submit.prevent="materialToast('Share created', { type: 'success' })" separator>
|
<x-form x-on:submit.prevent="materialToast('Share created', { type: 'success' })" separator>
|
||||||
@@ -121,7 +154,7 @@
|
|||||||
|
|
||||||
<p class="max-w-3xl type-body-md text-on-surface-variant">
|
<p class="max-w-3xl type-body-md text-on-surface-variant">
|
||||||
<code><x-input></code>, <code><x-password></code>, <code><x-textarea></code>, <code><x-select></code>, <code><x-file></code>
|
<code><x-input></code>, <code><x-password></code>, <code><x-textarea></code>, <code><x-select></code>, <code><x-file></code>
|
||||||
(all on <code><x-field></code>, outlined or filled), <code><x-checkbox></code>, <code><x-radio></code>, <code><x-toggle></code> and <code><x-form></code>.
|
(all on <code><x-field></code>, outlined or filled), <code><x-checkbox></code>, <code><x-radio></code>, <code><x-toggle></code>, <code><x-choices></code>, <code><x-search></code> and <code><x-form></code>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@foreach ($examples as $title => $code)
|
@foreach ($examples as $title => $code)
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Blade;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
class ChipProbe extends Component
|
||||||
|
{
|
||||||
|
/** @var list<string> */
|
||||||
|
public array $kinds = ['photos'];
|
||||||
|
|
||||||
|
/** @var list<string> */
|
||||||
|
public array $tags = ['php', 'js', 'css'];
|
||||||
|
|
||||||
|
public function onlyVideo(): void
|
||||||
|
{
|
||||||
|
$this->kinds = ['video'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeTag(string $tag): void
|
||||||
|
{
|
||||||
|
$this->tags = array_values(array_diff($this->tags, [$tag]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): string
|
||||||
|
{
|
||||||
|
return <<<'BLADE'
|
||||||
|
<div class="space-y-4 p-4">
|
||||||
|
<p>kinds: <span id="kinds">{{ implode(',', $kinds) }}</span></p>
|
||||||
|
|
||||||
|
<x-chip-set label="Kinds">
|
||||||
|
@foreach (['photos' => 'Photos', 'documents' => 'Documents', 'video' => 'Video'] as $value => $name)
|
||||||
|
<x-chip type="filter" :label="$name" :value="$value" wire:model.live="kinds" wire:key="kind-{{ $value }}" />
|
||||||
|
@endforeach
|
||||||
|
</x-chip-set>
|
||||||
|
|
||||||
|
<x-button label="Only video" wire:click="onlyVideo" />
|
||||||
|
|
||||||
|
<p>tags: <span id="tags">{{ implode(',', $tags) }}</span></p>
|
||||||
|
|
||||||
|
<x-chip-set label="Tags">
|
||||||
|
@foreach ($tags as $tag)
|
||||||
|
<x-chip type="input" :label="$tag" removable wire:remove="removeTag('{{ $tag }}')" wire:key="tag-{{ $tag }}" />
|
||||||
|
@endforeach
|
||||||
|
</x-chip-set>
|
||||||
|
</div>
|
||||||
|
BLADE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function chipProbe()
|
||||||
|
{
|
||||||
|
Livewire::component('chip-probe', ChipProbe::class);
|
||||||
|
|
||||||
|
Route::middleware('web')->get('/chip-probe', fn () => Blade::render(<<<'BLADE'
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<x-theme-script />
|
||||||
|
@vite(config('livewire-material.showcase.vite'))
|
||||||
|
@livewireStyles
|
||||||
|
</head>
|
||||||
|
<body class="bg-surface">
|
||||||
|
<livewire:chip-probe />
|
||||||
|
@livewireScripts
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
BLADE));
|
||||||
|
|
||||||
|
return visit('/chip-probe')->waitForEvent('networkidle')
|
||||||
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function chipShowcase()
|
||||||
|
{
|
||||||
|
return visit('/material')->waitForEvent('networkidle')
|
||||||
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The script for a showcase filter chip's checkbox, found by its value.
|
||||||
|
*/
|
||||||
|
function filterInput(string $value): string
|
||||||
|
{
|
||||||
|
return "document.querySelector('#chips input[type=\"checkbox\"][value=\"{$value}\"]')";
|
||||||
|
}
|
||||||
|
|
||||||
|
it('toggles a filter chip with a click and with Space, and grows its check in', function () {
|
||||||
|
$check = fn (string $value): string => filterInput($value).".parentElement.querySelector('[data-chip-check]').parentElement.getBoundingClientRect().width";
|
||||||
|
|
||||||
|
$page = chipShowcase()->assertNoJavaScriptErrors();
|
||||||
|
|
||||||
|
$page->click('#chips label:has-text("Documents")')
|
||||||
|
->assertScript(filterInput('documents').'.checked')
|
||||||
|
->assertScript("Math.round({$check('documents')}) === 18")
|
||||||
|
->assertScript('getComputedStyle('.filterInput('documents').".parentElement).backgroundColor !== 'rgba(0, 0, 0, 0)'")
|
||||||
|
->assertScript("window.eval(\"Alpine.\$data(document.querySelector('#chips input[value=documents]')).kinds.join(',')\") === 'photos,documents'");
|
||||||
|
|
||||||
|
// A key press first, so the browser is in keyboard modality; focused directly, not tabbed to,
|
||||||
|
// because WebKit leaves form controls out of the Tab order unless full keyboard access is on.
|
||||||
|
$page->keys('body', 'Tab');
|
||||||
|
$page->script(filterInput('archives').'.focus()');
|
||||||
|
$page->keys(':focus', 'Space')
|
||||||
|
->assertScript(filterInput('archives').'.checked')
|
||||||
|
->assertScript("Math.round({$check('archives')}) === 18");
|
||||||
|
|
||||||
|
$page->keys(':focus', 'Space')
|
||||||
|
->assertScript('! '.filterInput('archives').'.checked')
|
||||||
|
->assertScript("Math.round({$check('archives')}) === 0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('binds filter chips to a Livewire array, and a server change reaches them after a morph', function () {
|
||||||
|
$input = fn (string $value): string => "document.querySelector('input[value=\"{$value}\"]')";
|
||||||
|
|
||||||
|
$page = chipProbe()->assertNoJavaScriptErrors();
|
||||||
|
|
||||||
|
$page->assertScript("{$input('photos')}.checked && ! {$input('documents')}.checked");
|
||||||
|
|
||||||
|
$page->click('label:has-text("Documents")')
|
||||||
|
->assertSeeIn('#kinds', 'photos,documents');
|
||||||
|
|
||||||
|
$page->click('button:has-text("Only video")')
|
||||||
|
->assertSeeIn('#kinds', 'video')
|
||||||
|
->assertScript("! {$input('photos')}.checked && ! {$input('documents')}.checked && {$input('video')}.checked")
|
||||||
|
->assertScript("getComputedStyle({$input('video')}.parentElement).backgroundColor !== 'rgba(0, 0, 0, 0)'")
|
||||||
|
->assertScript("getComputedStyle({$input('photos')}.parentElement).backgroundColor === 'rgba(0, 0, 0, 0)'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes a Livewire input chip with its button and with Delete, focusing the chip after it', function () {
|
||||||
|
$page = chipProbe();
|
||||||
|
|
||||||
|
$page->click('button[aria-label="Remove js"]')
|
||||||
|
->assertSeeIn('#tags', 'php,css')
|
||||||
|
->assertScript("document.querySelector('button[aria-label=\"Remove js\"]') === null");
|
||||||
|
|
||||||
|
$page->script("document.querySelector('button[aria-label=\"Remove php\"]').focus()");
|
||||||
|
|
||||||
|
$page->keys(':focus', 'Delete')
|
||||||
|
->assertScript("document.querySelector('#tags').textContent === 'css'")
|
||||||
|
->assertScript("document.querySelector('button[aria-label=\"Remove php\"]') === null")
|
||||||
|
->assertScript("document.activeElement.getAttribute('aria-label') === 'Remove css'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes an Alpine input chip with Backspace, focusing the chip before it', function () {
|
||||||
|
$remove = fn (string $who): string => "document.querySelector('#chips button[aria-label=\"Remove {$who}@example.com\"]')";
|
||||||
|
|
||||||
|
$page = chipShowcase();
|
||||||
|
|
||||||
|
// Named in the browser: the chips come from x-for, so the server never knew their labels.
|
||||||
|
$page->assertScript("{$remove('ben')} !== null");
|
||||||
|
$page->script("{$remove('ben')}.focus()");
|
||||||
|
|
||||||
|
$page->keys(':focus', 'Backspace')
|
||||||
|
->assertScript("{$remove('ben')} === null")
|
||||||
|
->assertScript("{$remove('anna')} !== null && {$remove('chiara')} !== null")
|
||||||
|
->assertScript("document.activeElement.getAttribute('aria-label') === 'Remove anna@example.com'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scrolls a chip set sideways, fading the edge it can still scroll towards', function () {
|
||||||
|
$row = "document.querySelector('#chips [data-chip-set][x-data]')";
|
||||||
|
|
||||||
|
$page = chipShowcase();
|
||||||
|
|
||||||
|
$page->assertScript("{$row}.scrollWidth > {$row}.clientWidth")
|
||||||
|
->assertScript("{$row}.hasAttribute('data-scroll-end') && ! {$row}.hasAttribute('data-scroll-start')")
|
||||||
|
->assertScript("getComputedStyle({$row}).flexWrap === 'nowrap'");
|
||||||
|
|
||||||
|
$page->script("{$row}.querySelector('input[value=\"unopened\"]').focus()");
|
||||||
|
|
||||||
|
$page->assertScript("Math.abs({$row}.scrollLeft) > 0")
|
||||||
|
->assertScript("{$row}.hasAttribute('data-scroll-start')")
|
||||||
|
->assertScript("getComputedStyle({$row}).getPropertyValue('--chip-fade-start').trim() === '1.5rem'");
|
||||||
|
});
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Blade;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
class PickProbe extends Component
|
||||||
|
{
|
||||||
|
/** @var list<int> */
|
||||||
|
public array $days = [2];
|
||||||
|
|
||||||
|
public ?string $zone = 'Europe/Zurich';
|
||||||
|
|
||||||
|
public string $query = '';
|
||||||
|
|
||||||
|
public function render(): string
|
||||||
|
{
|
||||||
|
return <<<'BLADE'
|
||||||
|
<div class="grid max-w-md gap-6 p-4">
|
||||||
|
<p>days: <span id="days">{{ json_encode($days) }}</span></p>
|
||||||
|
<p>zone: <span id="zone">{{ $zone }}</span></p>
|
||||||
|
<p>query: <span id="query">{{ $query }}</span></p>
|
||||||
|
|
||||||
|
<x-choices label="Days" wire:model.live="days" :options="[['id' => 1, 'name' => 'Mon'], ['id' => 2, 'name' => 'Tue'], ['id' => 3, 'name' => 'Wed']]" />
|
||||||
|
|
||||||
|
<div id="clip" class="h-24 overflow-hidden rounded-corner-md bg-surface-container p-2">
|
||||||
|
<x-choices id="zone-field" label="Time zone" searchable wire:model.live="zone" :options="[['id' => 'Europe/Berlin', 'name' => 'Berlin'], ['id' => 'Europe/Zurich', 'name' => 'Zurich'], ['id' => 'UTC', 'name' => 'UTC', 'disabled' => true]]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<x-search id="find" wire:model.live="query" placeholder="Search files">
|
||||||
|
@foreach (array_filter(['holiday.zip', 'contract.pdf', 'review.mp4'], fn ($file) => $query === '' || str_contains($file, $query)) as $file)
|
||||||
|
<button type="button" wire:key="result-{{ $file }}" class="block w-full px-4 py-3 text-start">{{ $file }}</button>
|
||||||
|
@endforeach
|
||||||
|
<x-slot:empty>No files match.</x-slot:empty>
|
||||||
|
</x-search>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
BLADE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickProbe()
|
||||||
|
{
|
||||||
|
Livewire::component('pick-probe', PickProbe::class);
|
||||||
|
|
||||||
|
Route::middleware('web')->get('/pick-probe', fn () => Blade::render(<<<'BLADE'
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<x-theme-script />
|
||||||
|
@vite(config('livewire-material.showcase.vite'))
|
||||||
|
@livewireStyles
|
||||||
|
</head>
|
||||||
|
<body class="bg-surface">
|
||||||
|
<livewire:pick-probe />
|
||||||
|
@livewireScripts
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
BLADE));
|
||||||
|
|
||||||
|
return visit('/pick-probe')->waitForEvent('networkidle')
|
||||||
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||||
|
}
|
||||||
|
|
||||||
|
it('keeps the values of filter chips as the property\'s own type', function () {
|
||||||
|
pickProbe()
|
||||||
|
->assertAttribute('button[aria-pressed="true"]', 'aria-pressed', 'true')
|
||||||
|
->click('button:has-text("Mon")')
|
||||||
|
->assertSeeIn('#days', '[2,1]')
|
||||||
|
->click('button:has-text("Tue")')
|
||||||
|
->assertSeeIn('#days', '[1]')
|
||||||
|
->assertScript("[...document.querySelectorAll('[aria-pressed=\"true\"]')].map((chip) => chip.textContent.trim()).join() === 'Mon'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters a searchable choice as it is typed and chooses from the keyboard', function () {
|
||||||
|
$list = "document.querySelector('#zone-field-list')";
|
||||||
|
|
||||||
|
$page = pickProbe()
|
||||||
|
->assertValue('#zone-field', 'Zurich')
|
||||||
|
->click('#zone-field')
|
||||||
|
->assertScript("{$list}.matches(':popover-open')")
|
||||||
|
->assertAttribute('#zone-field', 'aria-expanded', 'true');
|
||||||
|
|
||||||
|
$page->type('#zone-field', 'ber')
|
||||||
|
->assertScript("{$list}.querySelectorAll('[role=\"option\"]').length === 1");
|
||||||
|
|
||||||
|
$page->keys('#zone-field', 'Enter')
|
||||||
|
->assertSeeIn('#zone', 'Europe/Berlin')
|
||||||
|
->assertValue('#zone-field', 'Berlin')
|
||||||
|
->assertScript("! {$list}.matches(':popover-open')");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts a searchable choice back on Escape and never chooses a disabled option', function () {
|
||||||
|
$page = pickProbe()->click('#zone-field')->type('#zone-field', 'ber');
|
||||||
|
|
||||||
|
$page->keys('#zone-field', 'Escape')
|
||||||
|
->assertValue('#zone-field', 'Zurich')
|
||||||
|
->assertSeeIn('#zone', 'Europe/Zurich');
|
||||||
|
|
||||||
|
$page->click('#zone-field')->type('#zone-field', 'UTC')->keys('#zone-field', 'Enter')
|
||||||
|
->assertSeeIn('#zone', 'Europe/Zurich');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens a searchable choice\'s list above a container that clips', function () {
|
||||||
|
pickProbe()
|
||||||
|
->click('#zone-field')
|
||||||
|
->assertScript(<<<'JS'
|
||||||
|
(() => {
|
||||||
|
const list = document.querySelector('#zone-field-list');
|
||||||
|
const clip = document.querySelector('#clip').getBoundingClientRect();
|
||||||
|
const box = list.getBoundingClientRect();
|
||||||
|
const probe = document.elementFromPoint(box.left + box.width / 2, box.bottom - 8);
|
||||||
|
|
||||||
|
return box.bottom > clip.bottom && list.contains(probe);
|
||||||
|
})()
|
||||||
|
JS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the search view with the results Livewire renders for the query', function () {
|
||||||
|
$view = "document.querySelector('#find-view')";
|
||||||
|
|
||||||
|
$page = pickProbe()
|
||||||
|
->click('#find')
|
||||||
|
->assertScript("getComputedStyle({$view}).display !== 'none'")
|
||||||
|
->assertAttribute('#find', 'aria-expanded', 'true');
|
||||||
|
|
||||||
|
$page->type('#find', 'con')
|
||||||
|
->assertSeeIn('#query', 'con')
|
||||||
|
->assertScript("[...{$view}.querySelectorAll('button')].map((button) => button.textContent.trim()).join() === 'contract.pdf'");
|
||||||
|
|
||||||
|
$page->keys('#find', 'ArrowDown')
|
||||||
|
->assertScript("document.activeElement.textContent.trim() === 'contract.pdf'");
|
||||||
|
|
||||||
|
$page->keys(':focus', 'Escape')
|
||||||
|
->assertScript("getComputedStyle({$view}).display === 'none'")
|
||||||
|
->assertScript("document.activeElement.id === 'find'")
|
||||||
|
->assertAttribute('#find', 'aria-expanded', 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says so when nothing matches, and closes on a press outside or a chosen result', function () {
|
||||||
|
$view = "document.querySelector('#find-view')";
|
||||||
|
|
||||||
|
$page = pickProbe()
|
||||||
|
->click('#find')
|
||||||
|
->type('#find', 'zzz')
|
||||||
|
->assertSeeIn('#find-view', 'No files match.');
|
||||||
|
|
||||||
|
// The open view covers what is under it, so the press lands above it.
|
||||||
|
$page->click('#days')
|
||||||
|
->assertScript("getComputedStyle({$view}).display === 'none'");
|
||||||
|
|
||||||
|
$page->click('#find')
|
||||||
|
->clear('#find')
|
||||||
|
->assertScript("{$view}.querySelectorAll('button').length === 3")
|
||||||
|
->click('#find-view button:has-text("review.mp4")')
|
||||||
|
->assertScript("getComputedStyle({$view}).display === 'none'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes the whole screen on a compact window, with a back arrow', function () {
|
||||||
|
$page = pickProbe()->resize(400, 800);
|
||||||
|
|
||||||
|
$page->click('#find')
|
||||||
|
->assertScript("document.querySelector('[data-search]').hasAttribute('data-full-screen')")
|
||||||
|
->assertScript("(() => { const box = document.querySelector('#find-view').getBoundingClientRect(); return box.top === 0 && box.width === 400; })()")
|
||||||
|
->click('[data-search-back]')
|
||||||
|
->assertScript("! document.querySelector('[data-search]').hasAttribute('data-open')");
|
||||||
|
});
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The opening tag of the first element in the rendered Blade that matches `$tag`.
|
||||||
|
*/
|
||||||
|
function chipTag(string $blade, string $tag = '[a-z]+'): string
|
||||||
|
{
|
||||||
|
preg_match('/<(?:'.$tag.')\b[^>]*>/s', (string) test()->blade($blade), $matches);
|
||||||
|
|
||||||
|
return $matches[0] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
it('is an outlined assist chip, a button, by default', function () {
|
||||||
|
$html = (string) $this->blade('<x-chip label="Add to calendar" icon="event" icon-right="arrow_drop_down" />');
|
||||||
|
|
||||||
|
expect(chipTag('<x-chip label="Add to calendar" icon="event" />', 'button'))
|
||||||
|
->toContain('data-chip="assist"')
|
||||||
|
->toContain('type="button"')
|
||||||
|
->toContain('h-8')
|
||||||
|
->toContain('rounded-corner-sm')
|
||||||
|
->toContain('type-label-lg')
|
||||||
|
->toContain('border-outline-variant text-on-surface')
|
||||||
|
->toContain('ps-1.75 pe-3.75')
|
||||||
|
->and($html)
|
||||||
|
->toContain('me-2 size-4.5 text-primary')
|
||||||
|
->toContain('ms-2 size-4.5 text-primary')
|
||||||
|
->toContain('Add to calendar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pads a chip without icons by 16px on both sides', function () {
|
||||||
|
expect(chipTag('<x-chip label="Go" />', 'button'))->toContain('ps-3.75 pe-3.75');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to an assist chip for a type it does not know', function () {
|
||||||
|
expect(chipTag('<x-chip type="choice" label="Go" />', 'button'))->toContain('data-chip="assist"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws an elevated chip on surface-container-low at elevation 1', function () {
|
||||||
|
expect(chipTag('<x-chip label="Share" elevated />', 'button'))
|
||||||
|
->toContain('border-transparent bg-surface-container-low text-on-surface shadow-elevation-1 hover:shadow-elevation-2')
|
||||||
|
->not->toContain('border-outline-variant');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('greys a disabled chip, flat or elevated', function () {
|
||||||
|
expect(chipTag('<x-chip label="Share" icon="share" disabled />', 'button'))
|
||||||
|
->toContain('disabled')
|
||||||
|
->toContain('border-on-surface/12 text-on-surface/38')
|
||||||
|
->and(chipTag('<x-chip label="Share" elevated disabled />', 'button'))
|
||||||
|
->toContain('bg-on-surface/12 text-on-surface/38')
|
||||||
|
->and((string) $this->blade('<x-chip label="Share" icon="share" disabled />'))
|
||||||
|
->not->toContain('text-primary');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links with wire:navigate, and a disabled link leaves the tab order', function () {
|
||||||
|
expect(chipTag('<x-chip label="Directions" link="/directions" />', 'a'))
|
||||||
|
->toContain('href="/directions"')
|
||||||
|
->toContain('wire:navigate')
|
||||||
|
->not->toContain('type=')
|
||||||
|
->and(chipTag('<x-chip label="Guide" link="https://m3.material.io" external />', 'a'))
|
||||||
|
->toContain('target="_blank"')
|
||||||
|
->toContain('rel="noopener"')
|
||||||
|
->not->toContain('wire:navigate')
|
||||||
|
->and(chipTag('<x-chip label="Directions" link="/directions" disabled />', 'a'))
|
||||||
|
->toContain('aria-disabled="true"')
|
||||||
|
->toContain('tabindex="-1"')
|
||||||
|
->toContain('pointer-events-none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws a suggestion chip in on-surface-variant', function () {
|
||||||
|
expect(chipTag('<x-chip type="suggestion" label="Sounds good" />', 'button'))
|
||||||
|
->toContain('data-chip="suggestion"')
|
||||||
|
->toContain('border-outline-variant text-on-surface-variant')
|
||||||
|
->and(chipTag('<x-chip type="suggestion" label="Sounds good" elevated />', 'button'))
|
||||||
|
->toContain('bg-surface-container-low text-on-surface-variant shadow-elevation-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attaches a plain tooltip', function () {
|
||||||
|
$html = (string) $this->blade('<x-chip label="Share" tooltip="Share this file" />');
|
||||||
|
|
||||||
|
expect($html)
|
||||||
|
->toContain('popover="manual"')
|
||||||
|
->toContain('Share this file')
|
||||||
|
->toMatch('/<button[^>]*style="anchor-name: --material-chip-[a-z0-9]{10}"/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('makes a filter chip without a model a toggle button the caller owns', function () {
|
||||||
|
$html = (string) $this->blade('<x-chip type="filter" label="Starred" :selected="true" wire:click="toggleStarred" />');
|
||||||
|
|
||||||
|
expect(chipTag('<x-chip type="filter" label="Starred" :selected="true" wire:click="toggleStarred" />', 'button'))
|
||||||
|
->toContain('data-chip="filter"')
|
||||||
|
->toContain('aria-pressed="true"')
|
||||||
|
->toContain('wire:click="toggleStarred"')
|
||||||
|
->toContain('aria-pressed:bg-secondary-container aria-pressed:text-on-secondary-container')
|
||||||
|
->and(chipTag('<x-chip type="filter" label="Starred" />', 'button'))
|
||||||
|
->toContain('aria-pressed="false"')
|
||||||
|
->and($html)
|
||||||
|
->not->toContain('type="checkbox"')
|
||||||
|
->toContain('group-aria-pressed/chip:w-4.5')
|
||||||
|
->toContain('data-chip-check');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('makes a bound filter chip a native checkbox under the chip', function () {
|
||||||
|
$html = (string) $this->blade('<x-chip type="filter" label="Photos" value="photos" x-model="kinds" class="me-4" wire:key="photos" />');
|
||||||
|
|
||||||
|
expect(chipTag('<x-chip type="filter" label="Photos" value="photos" x-model="kinds" class="me-4" wire:key="photos" />', 'label'))
|
||||||
|
->toContain('data-chip="filter"')
|
||||||
|
->toContain('me-4')
|
||||||
|
->toContain('wire:key="photos"')
|
||||||
|
->toContain('has-checked:border-transparent has-checked:bg-secondary-container has-checked:text-on-secondary-container')
|
||||||
|
->toContain('has-focus-visible:outline-3')
|
||||||
|
->not->toContain('x-model')
|
||||||
|
->and(chipTag('<x-chip type="filter" label="Photos" value="photos" x-model="kinds" class="me-4" />', 'input'))
|
||||||
|
->toContain('type="checkbox"')
|
||||||
|
->toContain('value="photos"')
|
||||||
|
->toContain('x-model="kinds"')
|
||||||
|
->toContain('sr-only')
|
||||||
|
->not->toContain('me-4')
|
||||||
|
->not->toContain('checked')
|
||||||
|
->and($html)
|
||||||
|
->toContain('group-has-checked/chip:w-4.5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submits a named filter chip, checked when selected, and greys a disabled one', function () {
|
||||||
|
expect(chipTag('<x-chip type="filter" label="Starred" name="starred" :selected="true" />', 'input'))
|
||||||
|
->toContain('name="starred"')
|
||||||
|
->toContain('checked')
|
||||||
|
->and(chipTag('<x-chip type="filter" label="Starred" name="starred" disabled />', 'input'))
|
||||||
|
->toContain('disabled')
|
||||||
|
->and(chipTag('<x-chip type="filter" label="Starred" name="starred" disabled />', 'label'))
|
||||||
|
->toContain('border-on-surface/12 text-on-surface/38 has-checked:border-transparent has-checked:bg-on-surface/12')
|
||||||
|
->toContain('cursor-not-allowed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('swaps a filter chip\'s icon for the check when selected', function () {
|
||||||
|
$html = (string) $this->blade('<x-chip type="filter" label="Starred" icon="star" name="starred" elevated />');
|
||||||
|
|
||||||
|
expect($html)
|
||||||
|
->toContain('group-has-checked/chip:opacity-0')
|
||||||
|
->toContain('text-primary')
|
||||||
|
->toContain('bg-surface-container-low text-on-surface-variant shadow-elevation-1')
|
||||||
|
->not->toContain('w-0')
|
||||||
|
->and(substr_count($html, '<svg'))->toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws an input chip with an avatar, an icon or neither', function () {
|
||||||
|
expect(chipTag('<x-chip type="input" label="Anna" avatar="AM" />', 'span'))
|
||||||
|
->toContain('data-chip="input"')
|
||||||
|
->toContain('border-outline-variant text-on-surface-variant')
|
||||||
|
->not->toContain('x-data')
|
||||||
|
->and((string) $this->blade('<x-chip type="input" label="Anna" avatar="AM" />'))
|
||||||
|
->toContain('ps-0.75 pe-2.75')
|
||||||
|
->toContain('size-6 shrink-0 place-items-center rounded-corner-full bg-primary-container')
|
||||||
|
->toContain('>AM</span>')
|
||||||
|
->not->toContain('<button')
|
||||||
|
->and((string) $this->blade('<x-chip type="input" label="Anna" avatar="/avatars/anna.jpg" />'))
|
||||||
|
->toContain('<img src="/avatars/anna.jpg" alt=""')
|
||||||
|
->and((string) $this->blade('<x-chip type="input" label="report.pdf" icon="picture_as_pdf" />'))
|
||||||
|
->toContain('ps-1.75 pe-2.75');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('makes an input chip a button when it has its own action, and selects it', function () {
|
||||||
|
$html = (string) $this->blade('<x-chip type="input" label="Anna" icon="person" :selected="true" wire:click="open(1)" />');
|
||||||
|
|
||||||
|
expect(chipTag('<x-chip type="input" label="Anna" icon="person" :selected="true" wire:click="open(1)" />', 'button'))
|
||||||
|
->toContain('data-chip-action')
|
||||||
|
->toContain('aria-pressed="true"')
|
||||||
|
->toContain('wire:click="open(1)"')
|
||||||
|
->and(chipTag('<x-chip type="input" label="Anna" :selected="true" />', 'span'))
|
||||||
|
->toContain('border-transparent bg-secondary-container text-on-secondary-container')
|
||||||
|
->and($html)
|
||||||
|
->toContain('me-2 size-4.5 text-primary');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives a removable input chip a named remove button that calls wire:remove', function () {
|
||||||
|
$html = (string) $this->blade('<x-chip type="input" label="anna@example.com" removable wire:remove="removeRecipient(3)" name="to[]" value="3" />');
|
||||||
|
|
||||||
|
expect(chipTag('<x-chip type="input" label="anna@example.com" removable wire:remove="removeRecipient(3)" />', 'span'))
|
||||||
|
->toContain('x-data="materialChip"')
|
||||||
|
->toContain('x-on:keydown="removeOnKey($event)"')
|
||||||
|
->not->toContain('wire:remove')
|
||||||
|
->and($html)
|
||||||
|
->toContain('data-chip-remove aria-label="Remove anna@example.com"')
|
||||||
|
->toContain('wire:click="removeRecipient(3)"')
|
||||||
|
->toContain('x-on:click="removeChip($event)"')
|
||||||
|
->toContain('<input type="hidden" name="to[]" value="3" />')
|
||||||
|
->toContain('ps-2.75 pe-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs an Alpine remove expression, or takes the chip off the page with neither', function () {
|
||||||
|
expect((string) $this->blade('<x-chip type="input" label="php" removable remove="tags.splice(0, 1)" />'))
|
||||||
|
->toContain('x-on:click="removeChip($event); tags.splice(0, 1)"')
|
||||||
|
->not->toContain('wire:click')
|
||||||
|
->and((string) $this->blade('<x-chip type="input" label="php" removable />'))
|
||||||
|
->toContain('x-on:click="removeChip($event); $root.remove()"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a client-rendered chip\'s remove button to be named in the browser', function () {
|
||||||
|
expect((string) $this->blade('<x-chip type="input" removable remove="tags.pop()"><span x-text="tag"></span></x-chip>'))
|
||||||
|
->toContain('data-chip-remove="Remove :label"')
|
||||||
|
->not->toContain('aria-label=');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables a removable input chip and its remove button', function () {
|
||||||
|
$html = (string) $this->blade('<x-chip type="input" label="php" removable disabled />');
|
||||||
|
|
||||||
|
expect($html)
|
||||||
|
->toContain('border-on-surface/12 text-on-surface/38')
|
||||||
|
->toMatch('/<button[^>]*data-chip-remove[^>]*disabled/s');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('groups chips in a wrapping row named by its label, with a hint', function () {
|
||||||
|
$html = (string) $this->blade('<x-chip-set label="File types" hint="Show only these"><x-chip label="A" /></x-chip-set>');
|
||||||
|
|
||||||
|
preg_match('/aria-labelledby="([^"]+)"/', $html, $labelledBy);
|
||||||
|
preg_match('/aria-describedby="([^"]+)"/', $html, $describedBy);
|
||||||
|
|
||||||
|
expect($html)
|
||||||
|
->toContain('role="group"')
|
||||||
|
->toContain('data-chip-set class="flex flex-wrap gap-2"')
|
||||||
|
->toContain("id=\"{$labelledBy[1]}\" class=\"mb-2 type-label-lg text-on-surface-variant\">File types</p>")
|
||||||
|
->toContain("id=\"{$describedBy[1]}\" class=\"mt-1 type-body-sm text-on-surface-variant\">Show only these</p>")
|
||||||
|
->not->toContain('materialChipSet');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scrolls a chip set on one line with fading edges', function () {
|
||||||
|
expect((string) $this->blade('<x-chip-set aria-label="Sort" scroll><x-chip label="A" /></x-chip-set>'))
|
||||||
|
->toContain('aria-label="Sort"')
|
||||||
|
->toContain('x-data="materialChipSet"')
|
||||||
|
->toContain('wire:ignore.self')
|
||||||
|
->toContain('overflow-x-auto')
|
||||||
|
->toContain('data-scroll-start:[--chip-fade-start:1.5rem]')
|
||||||
|
->not->toContain('flex-wrap');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('binds filter chips to a Livewire array and shows the set\'s validation message in place of its hint', function () {
|
||||||
|
$component = new class extends Component
|
||||||
|
{
|
||||||
|
/** @var list<string> */
|
||||||
|
public array $kinds = ['photos'];
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
$this->validate(['kinds' => 'max:1'], ['kinds.max' => 'Pick one kind.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): string
|
||||||
|
{
|
||||||
|
return <<<'BLADE'
|
||||||
|
<div>
|
||||||
|
<x-chip-set label="Kinds" hint="Show only these" error-field="kinds">
|
||||||
|
<x-chip type="filter" label="Photos" value="photos" wire:model.live="kinds" />
|
||||||
|
<x-chip type="filter" label="Documents" value="documents" wire:model.live="kinds" />
|
||||||
|
</x-chip-set>
|
||||||
|
</div>
|
||||||
|
BLADE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Livewire::test($component)
|
||||||
|
->assertSeeHtml('wire:model.live="kinds"')
|
||||||
|
->assertSeeHtml('value="photos" checked')
|
||||||
|
->assertDontSeeHtml('value="documents" checked')
|
||||||
|
->assertSee('Show only these')
|
||||||
|
->set('kinds', ['photos', 'documents'])
|
||||||
|
->assertSeeHtml('value="documents" checked')
|
||||||
|
->call('save')
|
||||||
|
->assertSee('Pick one kind.')
|
||||||
|
->assertDontSee('Show only these');
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
it('lays every option out as a filter chip', function () {
|
||||||
|
$html = (string) $this->blade('<x-choices label="Days" hint="Pick any" :value="[2]" :options="[[\'id\' => 1, \'name\' => \'Mon\'], [\'id\' => 2, \'name\' => \'Tue\'], [\'id\' => 3, \'name\' => \'Wed\', \'disabled\' => true]]" />');
|
||||||
|
|
||||||
|
expect($html)
|
||||||
|
->toContain('role="group"')
|
||||||
|
->toContain('Days')
|
||||||
|
->toContain('Pick any')
|
||||||
|
->toContain('x-modelable="selection"')
|
||||||
|
->toContain('x-on:click="toggle(0)"')
|
||||||
|
->toContain('x-bind:aria-pressed="selected(2).toString()"')
|
||||||
|
->and(substr_count($html, 'aria-pressed="true"'))->toBe(1)
|
||||||
|
->and(substr_count($html, 'aria-pressed="false"'))->toBe(2)
|
||||||
|
->and($html)->toContain('disabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('entangles the selection with its Livewire property and renders it as the property says', function () {
|
||||||
|
Livewire::component('choices-probe', new class extends Component
|
||||||
|
{
|
||||||
|
/** @var list<int> */
|
||||||
|
public array $days = [1, 3];
|
||||||
|
|
||||||
|
public function render(): string
|
||||||
|
{
|
||||||
|
return '<div><x-choices wire:model.live="days" :options="[[\'id\' => 1, \'name\' => \'Mon\'], [\'id\' => 2, \'name\' => \'Tue\'], [\'id\' => 3, \'name\' => \'Wed\']]" /></div>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$html = Livewire::test('choices-probe')->html();
|
||||||
|
|
||||||
|
expect($html)
|
||||||
|
->toContain(".entangle('days').live")
|
||||||
|
->not->toContain('x-modelable')
|
||||||
|
->and(substr_count($html, 'aria-pressed="true"'))->toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws a searchable combobox with a popover list', function () {
|
||||||
|
$html = (string) $this->blade('<x-choices id="zone" label="Time zone" searchable icon="public" value="Europe/Zurich" :options="[[\'id\' => \'Europe/Zurich\', \'name\' => \'Zurich\']]" />');
|
||||||
|
|
||||||
|
expect($html)
|
||||||
|
->toContain('role="combobox"')
|
||||||
|
->toContain('aria-controls="zone-list"')
|
||||||
|
->toContain('aria-expanded="false"')
|
||||||
|
->toContain('<ul')
|
||||||
|
->toContain('id="zone-list"')
|
||||||
|
->toContain('role="listbox"')
|
||||||
|
->toContain('popover="manual"')
|
||||||
|
->toMatch('/anchor-name: (--material-choices-[a-z0-9]{10})/')
|
||||||
|
->toContain('x-modelable="value"')
|
||||||
|
->toContain('field-arrow')
|
||||||
|
->toContain('Nothing matches');
|
||||||
|
|
||||||
|
preg_match('/anchor-name: (--material-choices-[a-z0-9]{10})/', $html, $anchor);
|
||||||
|
|
||||||
|
expect($html)->toContain("position-anchor: {$anchor[1]}");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the errors for the property and its items', function () {
|
||||||
|
Livewire::component('choices-errors', new class extends Component
|
||||||
|
{
|
||||||
|
/** @var list<int> */
|
||||||
|
public array $days = [];
|
||||||
|
|
||||||
|
public ?string $zone = null;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->addError('days.0', 'Mon is not a working day.');
|
||||||
|
$this->addError('zone', 'Choose a time zone.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): string
|
||||||
|
{
|
||||||
|
return <<<'BLADE'
|
||||||
|
<div>
|
||||||
|
<x-choices wire:model="days" :options="[['id' => 1, 'name' => 'Mon']]" />
|
||||||
|
<x-choices wire:model="zone" searchable :options="[['id' => 'UTC', 'name' => 'UTC']]" />
|
||||||
|
</div>
|
||||||
|
BLADE;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(Livewire::test('choices-errors')->html())
|
||||||
|
->toContain('Mon is not a working day.')
|
||||||
|
->toContain('Choose a time zone.')
|
||||||
|
->toContain('aria-invalid="true"');
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
it('draws a search bar whose input controls the view', function () {
|
||||||
|
$html = (string) $this->blade('<x-search id="find" placeholder="Search shares" wire:model.live.debounce.300ms="query" />');
|
||||||
|
|
||||||
|
expect($html)
|
||||||
|
->toContain('data-search')
|
||||||
|
->toContain('role="search"')
|
||||||
|
->toContain('type="search"')
|
||||||
|
->toContain('placeholder="Search shares"')
|
||||||
|
->toContain('aria-label="Search shares"')
|
||||||
|
->toContain('aria-controls="find-view"')
|
||||||
|
->toContain('aria-expanded="false"')
|
||||||
|
->toContain('wire:model.live.debounce.300ms="query"')
|
||||||
|
->toContain('id="find-view"')
|
||||||
|
->toContain('data-search-clear')
|
||||||
|
->toContain('aria-label="Back"')
|
||||||
|
->toContain('materialSearch(false)')
|
||||||
|
->not->toContain('data-search-results');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the results, or what to say when there are none', function () {
|
||||||
|
expect((string) $this->blade('<x-search><a href="/shares/1">holiday.zip</a></x-search>'))
|
||||||
|
->toContain('data-search-results')
|
||||||
|
->toContain('<a href="/shares/1">holiday.zip</a>')
|
||||||
|
->and((string) $this->blade('<x-search><x-slot:empty>No shares match.</x-slot:empty></x-search>'))
|
||||||
|
->toContain('No shares match.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the input, trails the bar, and stays docked on request', function () {
|
||||||
|
$html = (string) $this->blade('<x-search label="Search your shares" icon="travel_explore" docked><x-slot:trailing><button>AR</button></x-slot:trailing></x-search>');
|
||||||
|
|
||||||
|
expect($html)
|
||||||
|
->toContain('placeholder="Search"')
|
||||||
|
->toContain('aria-label="Search your shares"')
|
||||||
|
->toContain('data-search-trailing')
|
||||||
|
->toContain('<button>AR</button>')
|
||||||
|
->toContain('materialSearch(true)');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user