Compare commits
5
Commits
889f8a8aa1
...
1.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7798352cfc | ||
|
|
cb3aa798a0 | ||
|
|
648a4cfe0f | ||
|
|
51bf034b74 | ||
|
|
7ebcb0565f |
@@ -124,7 +124,7 @@ Every component, prop and slot is documented in the Boost skill (`resources/boos
|
||||
|
||||
## Showcase
|
||||
|
||||
While the application runs locally (or with `MATERIAL_SHOWCASE=true`), `/material` shows every token and component, in every variant, in the application's own scheme and theme: an overview, and a page per section behind a navigation rail (the package's own app shell).
|
||||
While the application runs locally (or with `MATERIAL_SHOWCASE=true`), `/material` shows every token and component, in every variant, in the application's own scheme and theme: an overview, and a page per section behind a navigation rail (the package's own app shell), with a search over every section, example and component (press `/`).
|
||||
|
||||
## Testing the design
|
||||
|
||||
|
||||
@@ -594,6 +594,11 @@ pages and the mail theme by an agent in its own worktree. What changed from the
|
||||
wire:navigate. It turned up a WebKit bug in `<x-menu>`: inside a focusable region (the shell's
|
||||
`<main tabindex="-1">`), WebKit hands focus back to that region as the popover closes, so the
|
||||
menu now reads whether focus was inside on `beforetoggle` before returning it to the trigger.
|
||||
- **The showcase has a search** in a search app bar (`<x-search>`, `/` or Ctrl+K): an index of every
|
||||
section, every example (by its anchor) and every component, read from the section views and fetched
|
||||
as `/material/search.json` on first focus. Names in a slot's Alpine scope must keep clear of the
|
||||
component's own: `results` and `open` in `<x-search>`'s scope hid the page's. wire:navigate keeps a
|
||||
URL's hash but scrolls to the top, so the layout lands on the hash after the swap settles.
|
||||
- **Verified in a fresh Laravel 13.31 application** (`composer create-project`, the package from a
|
||||
path repository, the README's CSS, JS and layout, `material:scheme "#4f46e5"`, `npm run build`):
|
||||
a Livewire page with an app bar, tabs, a card, a form with validation, a date picker, a dialog and
|
||||
|
||||
@@ -467,7 +467,7 @@ A one-column grid of fields with an `actions` slot at the foot (the slot takes i
|
||||
|
||||
### `<x-field>`, `<x-input>`, `<x-password>`, `<x-textarea>`, `<x-select>`, `<x-file>`
|
||||
|
||||
M3 text fields. `variant`: `outlined` or `filled`; without it, `config('livewire-material.fields.variant')` (`outlined`). All take `label`, `hint`, `variant`, and read their errors from the bag under the `wire:model` name (the error replaces the hint, sets `aria-invalid`). `class` lands on the field's outer element (margins, widths); every other attribute (`wire:model`, `type`, `required`, `readonly`, `autocomplete`) reaches the control. Never pass `placeholder` expecting it to show while a label rests in the field: it shows once the field has focus.
|
||||
M3 text fields. `variant`: `outlined` or `filled`; without it, `config('livewire-material.fields.variant')` (`outlined`). All take `label`, `hint`, `variant`, and read their errors from the bag under the `wire:model` name, or the `name` in a plain form (`photos[]` → `photos`, `address[city]` → `address.city`); the error replaces the hint and sets `aria-invalid`. `class` lands on the field's outer element (margins, widths); every other attribute (`wire:model`, `type`, `required`, `readonly`, `autocomplete`) reaches the control. Never pass `placeholder` expecting it to show while a label rests in the field: it shows once the field has focus.
|
||||
|
||||
- `<x-input>`: `icon`, `icon-right`, `prefix`, `suffix`, `clearable`, `copyable` (copies the value, confirms with a snackbar), `size` (`sm` 40px, `xs` 32px — for unlabelled toolbar controls; give them `aria-label`), `mono`.
|
||||
- `<x-password>`: a reveal button; `icon`, `size`.
|
||||
|
||||
@@ -69,6 +69,9 @@ const MEDIUM_ITEM_FLEX_PERCENTAGE = 0.1
|
||||
/** A programmatic scroll still counts as where the carousel is going for this long. */
|
||||
const TARGET_MS = 700
|
||||
|
||||
// How long the row must go without a scroll event to count as having come to rest.
|
||||
const SETTLE_MS = 150
|
||||
|
||||
const ITEM = '[data-material-carousel-item]'
|
||||
const INTERACTIVE = 'a[href], button, input, select, textarea, summary, [contenteditable], [tabindex]:not([tabindex="-1"])'
|
||||
|
||||
@@ -805,6 +808,7 @@ document.addEventListener('alpine:init', () => {
|
||||
frame: null,
|
||||
target: null,
|
||||
targetAt: 0,
|
||||
settle: null,
|
||||
listeners: [],
|
||||
mutations: null,
|
||||
resizes: null,
|
||||
@@ -817,7 +821,12 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
state.reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
|
||||
this.listen(scroller, 'scroll', () => this.schedule(), { passive: true })
|
||||
this.listen(scroller, 'scroll', () => this.scrolled(), { passive: true })
|
||||
|
||||
// A scroll the person makes themselves is theirs to end wherever it ends.
|
||||
for (const type of ['pointerdown', 'wheel', 'touchstart']) {
|
||||
this.listen(scroller, type, () => (state.target = null), { passive: true })
|
||||
}
|
||||
this.listen(scroller, 'keydown', (event) => this.navigate(event))
|
||||
this.listen(scroller, 'focusin', (event) => this.reveal(event))
|
||||
this.listen(scroller, 'click', (event) => this.open(event))
|
||||
@@ -913,6 +922,33 @@ document.addEventListener('alpine:init', () => {
|
||||
state.frame ??= requestAnimationFrame(() => this.render())
|
||||
},
|
||||
|
||||
/**
|
||||
* Each scroll frame, and once the row comes to rest: a scroll the buttons or keys started
|
||||
* must end on its item. WebKit on Linux can re-snap a smooth scroll to the item it left
|
||||
* when the masks change the layout under it, so an arrival somewhere else is sent on
|
||||
* again, once, at once.
|
||||
*/
|
||||
scrolled() {
|
||||
this.schedule()
|
||||
|
||||
clearTimeout(state.settle)
|
||||
state.settle = setTimeout(() => {
|
||||
const target = state.target
|
||||
|
||||
if (target === null || performance.now() - state.targetAt > TARGET_MS * 3) {
|
||||
return
|
||||
}
|
||||
|
||||
state.target = null
|
||||
|
||||
const left = state.snaps[target]
|
||||
|
||||
if (left !== undefined && Math.abs(this.scrollOffset() - left) > 1) {
|
||||
this.$refs.scroller.scrollTo({ left: state.rtl ? -left : left, behavior: 'instant' })
|
||||
}
|
||||
}, SETTLE_MS)
|
||||
},
|
||||
|
||||
/** Carousel.kt's carouselItem layer block, for every item. */
|
||||
render() {
|
||||
cancelAnimationFrame(state.frame)
|
||||
@@ -1076,6 +1112,7 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
destroy() {
|
||||
cancelAnimationFrame(state.frame)
|
||||
clearTimeout(state.settle)
|
||||
state.listeners.forEach((remove) => remove())
|
||||
state.resizes?.disconnect()
|
||||
state.mutations?.disconnect()
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
@php
|
||||
$model = $attributes->whereStartsWith('wire:model')->first();
|
||||
$id = $attributes->get('id') ?? 'check-'.substr(md5($model.'|'.$label.'|'.$attributes->get('value')), 0, 12);
|
||||
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : [];
|
||||
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
|
||||
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
|
||||
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
|
||||
@endphp
|
||||
|
||||
<div {{ $attributes->only(['class', 'wire:key'])->class(['min-w-0']) }}>
|
||||
|
||||
@@ -35,8 +35,10 @@
|
||||
|
||||
@php
|
||||
$model = $attributes->wire('model')->value() ?: null;
|
||||
$messages = $model !== null && isset($errors)
|
||||
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($model), $errors->get($model.'.*')])))
|
||||
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
|
||||
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
|
||||
$messages = $errorKey !== null && isset($errors)
|
||||
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($errorKey), $errors->get($errorKey.'.*')])))
|
||||
: [];
|
||||
$choices = collect($options)->map(fn ($option): array => [
|
||||
'value' => data_get($option, $optionValue),
|
||||
|
||||
@@ -57,8 +57,10 @@
|
||||
$mode = in_array($mode, ['docked', 'modal', 'input'], true) ? $mode : 'docked';
|
||||
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|datepicker'), 0, 12);
|
||||
$anchor = '--material-datepicker-'.preg_replace('/[^A-Za-z0-9_-]/', '-', $id);
|
||||
$messages = $model !== null && isset($errors)
|
||||
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($model), $errors->get($model.'.*')])))
|
||||
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
|
||||
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
|
||||
$messages = $errorKey !== null && isset($errors)
|
||||
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($errorKey), $errors->get($errorKey.'.*')])))
|
||||
: [];
|
||||
$locale = str_replace('_', '-', app()->getLocale());
|
||||
|
||||
|
||||
@@ -21,8 +21,10 @@
|
||||
@php
|
||||
$model = $attributes->whereStartsWith('wire:model')->first();
|
||||
$id = $attributes->get('id') ?? 'field-'.substr(md5('file|'.$model.'|'.$label), 0, 12);
|
||||
$messages = $model !== null && isset($errors)
|
||||
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($model), $errors->get($model.'.*')])))
|
||||
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
|
||||
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
|
||||
$messages = $errorKey !== null && isset($errors)
|
||||
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($errorKey), $errors->get($errorKey.'.*')])))
|
||||
: [];
|
||||
@endphp
|
||||
|
||||
|
||||
@@ -33,7 +33,9 @@
|
||||
@php
|
||||
$model = $attributes->whereStartsWith('wire:model')->first();
|
||||
$name ??= $model;
|
||||
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : [];
|
||||
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
|
||||
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
|
||||
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
|
||||
$size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'sm';
|
||||
|
||||
$segment = [
|
||||
|
||||
@@ -30,7 +30,9 @@
|
||||
$model = $attributes->whereStartsWith('wire:model')->first();
|
||||
$placeholder = filled($attributes->get('placeholder')) ? $attributes->get('placeholder') : ' ';
|
||||
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|'.$placeholder), 0, 12);
|
||||
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : [];
|
||||
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
|
||||
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
|
||||
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
|
||||
@endphp
|
||||
|
||||
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$prefix :$suffix :$size :$variant :$mono :class="$attributes->get('class')" :data-readonly="$attributes->get('readonly') ? '' : null">
|
||||
|
||||
@@ -78,18 +78,19 @@
|
||||
@endif
|
||||
|
||||
<div @class(['min-h-0 flex-1', 'max-sm:overflow-y-auto max-sm:px-6 max-sm:pb-6' => $fullscreen])>
|
||||
@if (filled($title) || $icon)
|
||||
<div @class(['mb-4', 'max-sm:hidden' => $fullscreen && ! $icon, 'text-center' => $icon])>
|
||||
@if (filled($title) || filled($subtitle) || $icon)
|
||||
{{-- A full-screen dialog's bar names it on a phone, so only the subtitle stays there. --}}
|
||||
<div @class(['mb-4', 'max-sm:hidden' => $fullscreen && ! $icon && blank($subtitle), 'text-center' => $icon])>
|
||||
@if ($icon)
|
||||
<x-livewire-material::icon :name="$icon" class="mx-auto mb-4 size-6 text-secondary" />
|
||||
@endif
|
||||
|
||||
@if (filled($title))
|
||||
<h2 id="{{ $id }}-title" class="type-headline-sm">{{ $title }}</h2>
|
||||
<h2 id="{{ $id }}-title" @class(['type-headline-sm', 'max-sm:hidden' => $fullscreen && ! $icon])>{{ $title }}</h2>
|
||||
@endif
|
||||
|
||||
@if (filled($subtitle))
|
||||
<p class="mt-4 type-body-md text-on-surface-variant">{{ $subtitle }}</p>
|
||||
<p @class(['type-body-md text-on-surface-variant', 'mt-4' => filled($title) || $icon, 'max-sm:mt-0' => $fullscreen && ! $icon])>{{ $subtitle }}</p>
|
||||
@endif
|
||||
|
||||
@if ($separator)
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
@php
|
||||
$model = $attributes->whereStartsWith('wire:model')->first();
|
||||
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|password'), 0, 12);
|
||||
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : [];
|
||||
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
|
||||
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
|
||||
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
|
||||
@endphp
|
||||
|
||||
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$size :$variant :class="$attributes->get('class')" x-data="{ shown: false }">
|
||||
|
||||
@@ -23,7 +23,9 @@
|
||||
$model = $attributes->whereStartsWith('wire:model')->first();
|
||||
$name = $attributes->get('name') ?? $model ?? 'radio-'.substr(md5($label.'|'.json_encode($options)), 0, 12);
|
||||
$id = 'radio-'.substr(md5($name.'|'.$label), 0, 12);
|
||||
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : [];
|
||||
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
|
||||
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
|
||||
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
|
||||
@endphp
|
||||
|
||||
<fieldset
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
@php
|
||||
$model = $attributes->whereStartsWith('wire:model')->first();
|
||||
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|select'), 0, 12);
|
||||
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : [];
|
||||
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
|
||||
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
|
||||
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
|
||||
@endphp
|
||||
|
||||
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$size :$variant floated :class="$attributes->get('class')">
|
||||
|
||||
@@ -19,7 +19,9 @@
|
||||
$model = $attributes->whereStartsWith('wire:model')->first();
|
||||
$placeholder = filled($attributes->get('placeholder')) ? $attributes->get('placeholder') : ' ';
|
||||
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|textarea'), 0, 12);
|
||||
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : [];
|
||||
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
|
||||
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
|
||||
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
|
||||
@endphp
|
||||
|
||||
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$variant :class="$attributes->get('class')" :data-readonly="$attributes->get('readonly') ? '' : null">
|
||||
|
||||
@@ -58,7 +58,9 @@
|
||||
|
||||
@php
|
||||
$model = $attributes->wire('model')->value() ?: null;
|
||||
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : [];
|
||||
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
|
||||
$errorKey = $model ?? (filled($name) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $name) : null);
|
||||
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
|
||||
$id = $attributes->get('id') ?? 'timepicker-'.substr(md5($model.'|'.$label.'|'.$name.'|timepicker'), 0, 12);
|
||||
|
||||
$cycle = in_array((string) $format, ['12', '24'], true) ? (int) $format : null;
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
}
|
||||
@endphp
|
||||
|
||||
<div class="space-y-4 rounded-corner-lg bg-surface-container p-4">
|
||||
<div @if ($title) id="{{ \NoNameWeb\LivewireMaterial\Showcase\Sections::anchor($title) }}" @endif class="scroll-mt-24 space-y-4 rounded-corner-lg bg-surface-container p-4">
|
||||
@if ($title)
|
||||
<h3 class="type-title-md">{{ $title }}</h3>
|
||||
@endif
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
@extends('livewire-material::showcase.layout')
|
||||
|
||||
@section('content')
|
||||
<div class="mx-auto w-full max-w-6xl space-y-12 px-4 pt-2 pb-16 sm:px-6">
|
||||
<div class="mx-auto w-full max-w-6xl space-y-12 px-4 pt-4 pb-16 sm:px-6">
|
||||
<div class="max-w-3xl space-y-3">
|
||||
<p class="type-headline-sm">Material 3 Expressive for Laravel and Livewire.</p>
|
||||
<h1 class="type-headline-lg">Livewire Material</h1>
|
||||
<p class="type-title-lg">Material 3 Expressive for Laravel and Livewire.</p>
|
||||
<p class="type-body-lg text-on-surface-variant">
|
||||
Every token and component, rendered in this application's own scheme and theme. Pick a section in the
|
||||
navigation, or start below.
|
||||
navigation, search for one above (or press <kbd class="rounded-corner-xs bg-surface-container-highest px-1.5 type-label-md">/</kbd>), or start below.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -48,13 +48,91 @@
|
||||
</x-slot:brand>
|
||||
|
||||
<x-slot:top>
|
||||
<x-livewire-material::app-bar :title="$title">
|
||||
<x-livewire-material::app-bar variant="search">
|
||||
<x-slot:navigation>
|
||||
<span class="sm:hidden"><x-livewire-material::button icon="menu" tooltip="Open navigation" x-data x-on:click="$store.rail.show()" data-test="showcase-menu" /></span>
|
||||
</x-slot:navigation>
|
||||
|
||||
{{-- The search looks through every section, example and component: the index is
|
||||
fetched on first focus, and / or Ctrl+K (⌘K) reaches it from anywhere. Its names
|
||||
keep clear of <x-search>'s own (`results`, `open`), which the slot also sees. --}}
|
||||
<div
|
||||
class="mx-auto w-full max-w-2xl"
|
||||
x-data="{
|
||||
query: '',
|
||||
entries: null,
|
||||
async load() {
|
||||
this.entries ??= await (await fetch(@js(route('livewire-material.search', [], false)))).json();
|
||||
},
|
||||
get matches() {
|
||||
const words = this.query.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
if (! this.entries || words.length === 0) return [];
|
||||
const phrase = words.join(' ');
|
||||
return this.entries
|
||||
.map((entry) => {
|
||||
const title = entry.title.toLowerCase().replace(/[<>]/g, '');
|
||||
const text = `${title} ${entry.context} ${entry.kind} ${entry.keywords}`.toLowerCase();
|
||||
if (! words.every((word) => text.includes(word))) return null;
|
||||
const rank = title === phrase ? 0 : title.startsWith(phrase) ? 1 : title.includes(phrase) ? 2 : 3;
|
||||
return { ...entry, rank };
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.rank - b.rank)
|
||||
.slice(0, 30);
|
||||
},
|
||||
go(event, result) {
|
||||
if (! result || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || (event.button ?? 0) !== 0) return;
|
||||
event.preventDefault();
|
||||
const url = new URL(result.url, window.location.href);
|
||||
const search = this.$root.querySelector('[data-search]');
|
||||
this.query = '';
|
||||
if (search) window.Alpine.$data(search).close();
|
||||
if (url.pathname === window.location.pathname) {
|
||||
window.location.hash = url.hash;
|
||||
document.getElementById(url.hash.slice(1))?.scrollIntoView({ block: 'start' });
|
||||
} else {
|
||||
window.Livewire.navigate(url.pathname + url.hash);
|
||||
}
|
||||
},
|
||||
shortcut(event) {
|
||||
const typing = event.target.closest('input, textarea, select, [contenteditable]');
|
||||
if ((event.key === '/' && ! typing) || (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey))) {
|
||||
event.preventDefault();
|
||||
document.getElementById('showcase-search').focus();
|
||||
}
|
||||
},
|
||||
}"
|
||||
x-on:keydown.window="shortcut($event)"
|
||||
>
|
||||
<x-livewire-material::search
|
||||
id="showcase-search"
|
||||
placeholder="Search components, examples and sections"
|
||||
x-model="query"
|
||||
x-on:focus.once="load()"
|
||||
x-on:keydown.enter.prevent="go($event, matches[0])"
|
||||
>
|
||||
<p x-show="query.trim() === ''" class="px-4 py-3 type-body-md text-on-surface-variant">
|
||||
Type a component (<code>datepicker</code>), an example or a section. Press <kbd class="rounded-corner-xs bg-surface-container-highest px-1.5 type-label-md">/</kbd> to search from anywhere.
|
||||
</p>
|
||||
<p x-cloak x-show="query.trim() !== '' && entries !== null && matches.length === 0" class="px-4 py-3 type-body-md text-on-surface-variant">
|
||||
Nothing matches “<span x-text="query.trim()"></span>”.
|
||||
</p>
|
||||
<template x-for="result in matches" x-bind:key="result.url + result.title">
|
||||
<a
|
||||
x-bind:href="result.url"
|
||||
x-on:click="go($event, result)"
|
||||
class="state-layer focus-ring flex min-h-14 flex-col justify-center px-4 py-2 outline-none"
|
||||
data-showcase-result
|
||||
>
|
||||
<span class="truncate type-body-lg text-on-surface" x-text="result.title"></span>
|
||||
<span class="truncate type-body-sm text-on-surface-variant" x-text="`${result.kind} · ${result.context}`"></span>
|
||||
</a>
|
||||
</template>
|
||||
</x-livewire-material::search>
|
||||
</div>
|
||||
|
||||
<x-slot:actions>
|
||||
<span class="me-3 max-md:hidden"><x-livewire-material::theme-toggle mode="picker" /></span>
|
||||
<span class="ms-2 me-3 max-md:hidden"><x-livewire-material::theme-toggle mode="picker" /></span>
|
||||
<span class="md:hidden"><x-livewire-material::theme-toggle mode="cycle" /></span>
|
||||
</x-slot:actions>
|
||||
</x-livewire-material::app-bar>
|
||||
@@ -64,5 +142,25 @@
|
||||
</x-livewire-material::app-shell>
|
||||
|
||||
@livewireScripts
|
||||
|
||||
{{-- wire:navigate keeps the URL's hash but not the scroll to it: a search result that opens an
|
||||
example on another page lands on the example. --}}
|
||||
<script data-navigate-once>
|
||||
document.addEventListener('livewire:navigated', () => {
|
||||
const target = () => (window.location.hash.length > 1 ? document.getElementById(decodeURIComponent(window.location.hash.slice(1))) : null);
|
||||
const land = () => target()?.scrollIntoView({ block: 'start' });
|
||||
|
||||
// After the swap's own scroll to the top and the page transition, and once more when
|
||||
// the page's components have settled their size.
|
||||
requestAnimationFrame(() => requestAnimationFrame(land));
|
||||
setTimeout(() => {
|
||||
const box = target()?.getBoundingClientRect();
|
||||
|
||||
if (box && (box.top < 0 || box.top > window.innerHeight / 2)) {
|
||||
land();
|
||||
}
|
||||
}, 400);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{{-- One section of the showcase on a page of its own, with the way on to the next. The section's
|
||||
own heading repeats the app bar's title, so only a screen reader hears it. --}}
|
||||
{{-- One section of the showcase on a page of its own, with the way on to the next. The page's
|
||||
heading names the section, so the section's own heading is only for a screen reader. --}}
|
||||
|
||||
@extends('livewire-material::showcase.layout')
|
||||
|
||||
@@ -11,19 +11,26 @@
|
||||
@endphp
|
||||
|
||||
@section('content')
|
||||
<div class="mx-auto w-full max-w-6xl space-y-16 px-4 pt-2 pb-16 sm:px-6 [&>section>h2]:sr-only">
|
||||
@include('livewire-material::showcase.sections.'.$section)
|
||||
<div class="mx-auto w-full max-w-6xl px-4 pt-4 pb-16 sm:px-6">
|
||||
<header class="mb-6 space-y-1">
|
||||
<p class="type-label-lg text-on-surface-variant">{{ $sections[$section]['group'] }}</p>
|
||||
<h1 class="type-headline-lg">{{ $sections[$section]['title'] }}</h1>
|
||||
</header>
|
||||
|
||||
<nav aria-label="Sections" class="flex flex-wrap items-center justify-between gap-4 border-t border-divider pt-6">
|
||||
@if ($previous)
|
||||
<x-livewire-material::button variant="text" icon="arrow_back" :label="$sections[$previous]['title']" :link="route('livewire-material.section', $previous)" data-test="previous-section" />
|
||||
@else
|
||||
<x-livewire-material::button variant="text" icon="arrow_back" label="Overview" :link="route('livewire-material.showcase')" data-test="previous-section" />
|
||||
@endif
|
||||
<div class="space-y-16 [&>section>h2]:sr-only">
|
||||
@include('livewire-material::showcase.sections.'.$section)
|
||||
|
||||
@if ($next)
|
||||
<x-livewire-material::button variant="tonal" icon-right="arrow_forward" :label="$sections[$next]['title']" :link="route('livewire-material.section', $next)" data-test="next-section" />
|
||||
@endif
|
||||
</nav>
|
||||
<nav aria-label="Sections" class="flex flex-wrap items-center justify-between gap-4 border-t border-divider pt-6">
|
||||
@if ($previous)
|
||||
<x-livewire-material::button variant="text" icon="arrow_back" :label="$sections[$previous]['title']" :link="route('livewire-material.section', $previous)" data-test="previous-section" />
|
||||
@else
|
||||
<x-livewire-material::button variant="text" icon="arrow_back" label="Overview" :link="route('livewire-material.showcase')" data-test="previous-section" />
|
||||
@endif
|
||||
|
||||
@if ($next)
|
||||
<x-livewire-material::button variant="tonal" icon-right="arrow_forward" :label="$sections[$next]['title']" :link="route('livewire-material.section', $next)" data-test="next-section" />
|
||||
@endif
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@php
|
||||
$examples = [
|
||||
'docked' => <<<'BLADE'
|
||||
'Docked' => <<<'BLADE'
|
||||
<div class="grid w-full gap-6 md:grid-cols-2" x-data="{ expires: '{{ now()->addWeek()->format('Y-m-d') }}', starts: null }">
|
||||
<div class="grid content-start gap-4">
|
||||
<x-datepicker label="Expires on" clearable x-model="expires" hint="Type a date or pick one" />
|
||||
@@ -13,7 +13,7 @@
|
||||
</div>
|
||||
</div>
|
||||
BLADE,
|
||||
'modal and modal input' => <<<'BLADE'
|
||||
'Modal and modal input' => <<<'BLADE'
|
||||
<div class="grid w-full gap-6 md:grid-cols-2" x-data="{ birthday: '1990-05-17', delivery: null }">
|
||||
<div class="grid content-start gap-4">
|
||||
<x-datepicker label="Birthday" mode="modal" x-model="birthday" :max="now()" />
|
||||
@@ -26,7 +26,7 @@
|
||||
</div>
|
||||
</div>
|
||||
BLADE,
|
||||
'range' => <<<'BLADE'
|
||||
'Range' => <<<'BLADE'
|
||||
<div class="grid w-full gap-6 md:grid-cols-2" x-data="{ trip: { start: '{{ now()->addDays(3)->format('Y-m-d') }}', end: '{{ now()->addDays(9)->format('Y-m-d') }}' }, leave: { start: null, end: null } }">
|
||||
<div class="grid content-start gap-4">
|
||||
<x-datepicker label="Trip" range x-model="trip" />
|
||||
@@ -39,7 +39,7 @@
|
||||
</div>
|
||||
</div>
|
||||
BLADE,
|
||||
'limits, errors and states' => <<<'BLADE'
|
||||
'Limits, errors and states' => <<<'BLADE'
|
||||
<div class="grid w-full gap-6 md:grid-cols-2">
|
||||
<div class="grid content-start gap-4">
|
||||
<x-datepicker label="Within the next 30 days" :min="now()" :max="now()->addDays(30)" hint="Days outside are disabled" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@php
|
||||
$examples = [
|
||||
'Time pickers: 12 and 24 hours, outlined and filled' => <<<'BLADE'
|
||||
'12 and 24 hours, outlined and filled' => <<<'BLADE'
|
||||
<div class="grid w-full gap-6 md:grid-cols-2">
|
||||
<div class="grid content-start gap-4">
|
||||
<x-timepicker label="Meeting starts" value="09:30" hint="The locale's clock" />
|
||||
@@ -15,7 +15,7 @@
|
||||
</div>
|
||||
</div>
|
||||
BLADE,
|
||||
'Time pickers: steps and limits' => <<<'BLADE'
|
||||
'Steps and limits' => <<<'BLADE'
|
||||
<div class="grid w-full gap-6 md:grid-cols-2">
|
||||
<div class="grid content-start gap-4">
|
||||
<x-timepicker label="Appointment" value="10:00" format="24" step="15" min="08:00" max="17:30" hint="Quarter hours from 08:00 to 17:30" />
|
||||
|
||||
@@ -20,6 +20,8 @@ Route::get('errors/{code}', [ShowcasePageController::class, 'error'])
|
||||
->name('error');
|
||||
Route::get('mail', [ShowcasePageController::class, 'mail'])->name('mail');
|
||||
|
||||
Route::get('search.json', [ShowcaseController::class, 'search'])->name('search');
|
||||
|
||||
Route::get('{section}', [ShowcaseController::class, 'section'])
|
||||
->whereIn('section', array_keys(Sections::all()))
|
||||
->name('section');
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace NoNameWeb\LivewireMaterial\Http\Controllers;
|
||||
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use NoNameWeb\LivewireMaterial\Showcase\Sections;
|
||||
|
||||
/**
|
||||
@@ -22,4 +23,12 @@ class ShowcaseController
|
||||
'section' => $section,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The search's index, fetched when the search is first focused rather than written into every page.
|
||||
*/
|
||||
public function search(): JsonResponse
|
||||
{
|
||||
return response()->json(Sections::index());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace NoNameWeb\LivewireMaterial;
|
||||
use Illuminate\Contracts\View\Factory;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class LivewireMaterialServiceProvider extends ServiceProvider
|
||||
@@ -113,7 +114,15 @@ class LivewireMaterialServiceProvider extends ServiceProvider
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the components as anonymous Blade components under the configured prefix.
|
||||
* Register the components as anonymous Blade components under the configured prefix, or
|
||||
* under `livewire-material` without one.
|
||||
*
|
||||
* A compiled `<x-button>` names the view namespace Blade derives from the prefix, or from the
|
||||
* component folder's absolute path when there is none. That path differs between a laptop and
|
||||
* a container sharing `storage/framework/views`, or between release directories, while the
|
||||
* compiled file's name does not — so a view compiled on one showed `a1b2…::button` as text on
|
||||
* the other. A prefix does not stop an unprefixed tag resolving. The path's namespace stays
|
||||
* registered, so views compiled before this release still render until they are recompiled.
|
||||
*
|
||||
* The package's own views never go through this registration: they write
|
||||
* `<x-livewire-material::button>`, which resolves through the view namespace whatever the
|
||||
@@ -123,8 +132,10 @@ class LivewireMaterialServiceProvider extends ServiceProvider
|
||||
{
|
||||
Blade::anonymousComponentPath(
|
||||
static::componentPath(),
|
||||
filled(config('livewire-material.prefix')) ? config('livewire-material.prefix') : null,
|
||||
filled(config('livewire-material.prefix')) ? config('livewire-material.prefix') : 'livewire-material',
|
||||
);
|
||||
|
||||
View::addNamespace(hash('xxh128', static::componentPath()), static::componentPath());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,11 +2,80 @@
|
||||
|
||||
namespace NoNameWeb\LivewireMaterial\Showcase;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use NoNameWeb\LivewireMaterial\LivewireMaterialServiceProvider;
|
||||
|
||||
/**
|
||||
* The showcase's pages: one per section, grouped as the rail groups them.
|
||||
*/
|
||||
class Sections
|
||||
{
|
||||
/**
|
||||
* What the showcase's search looks through: every section, every example in it (linked to the
|
||||
* example's anchor), and every component, linked to the first section that introduces it.
|
||||
*
|
||||
* @return list<array{title: string, kind: string, context: string, url: string, keywords: string}>
|
||||
*/
|
||||
public static function index(): array
|
||||
{
|
||||
$sections = static::all();
|
||||
$components = collect(glob(LivewireMaterialServiceProvider::componentPath().'/*.blade.php'))
|
||||
->map(fn (string $path): string => basename($path, '.blade.php'))
|
||||
->all();
|
||||
$sources = collect($sections)->map(fn (array $section, string $key): string => (string) file_get_contents(static::path($key)));
|
||||
|
||||
$entries = [];
|
||||
$homes = [];
|
||||
|
||||
foreach ($sections as $key => $section) {
|
||||
$url = route('livewire-material.section', $key, false);
|
||||
|
||||
$entries[] = ['title' => $section['title'], 'kind' => 'Section', 'context' => $section['group'], 'url' => $url, 'keywords' => $section['description']];
|
||||
|
||||
preg_match_all("/^\\s*'((?:[^'\\\\]|\\\\.)*)'\\s*=>\\s*<<<'BLADE'/m", $sources[$key], $examples);
|
||||
|
||||
foreach ($examples[1] as $title) {
|
||||
$title = stripslashes($title);
|
||||
$entries[] = ['title' => $title, 'kind' => 'Example', 'context' => $section['title'], 'url' => $url.'#'.static::anchor($title), 'keywords' => ''];
|
||||
}
|
||||
}
|
||||
|
||||
// A section that names a component in its introduction is its home; otherwise the first that uses it.
|
||||
foreach (['/<x-([a-z0-9-]+)>/', '/<x-(?:livewire-material::)?([a-z0-9-]+)(?=[\\s\\/>])/'] as $pattern) {
|
||||
foreach ($sources as $key => $source) {
|
||||
preg_match_all($pattern, $source, $tags);
|
||||
|
||||
foreach ($tags[1] as $tag) {
|
||||
if (in_array($tag, $components, true)) {
|
||||
$homes[$tag] ??= $key;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($homes as $tag => $key) {
|
||||
$entries[] = ['title' => "<x-{$tag}>", 'kind' => 'Component', 'context' => $sections[$key]['title'], 'url' => route('livewire-material.section', $key, false), 'keywords' => str_replace('-', ' ', $tag)];
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* The anchor of an example on its section's page.
|
||||
*/
|
||||
public static function anchor(string $title): string
|
||||
{
|
||||
return 'example-'.Str::slug($title);
|
||||
}
|
||||
|
||||
/**
|
||||
* A section's view file.
|
||||
*/
|
||||
public static function path(string $key): string
|
||||
{
|
||||
return dirname(__DIR__, 2).'/resources/views/showcase/sections/'.$key.'.blade.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{title: string, icon: string, group: string, description: string}>
|
||||
*/
|
||||
|
||||
@@ -19,3 +19,37 @@ it('moves between sections through the rail and the next-section link, keeping t
|
||||
->assertScript("location.pathname.endsWith('/material/menus')")
|
||||
->assertNoJavaScriptErrors();
|
||||
});
|
||||
|
||||
it('finds a component from anywhere and opens its section', function () {
|
||||
$page = visit('/material/buttons')->waitForEvent('networkidle')
|
||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||
|
||||
$page->keys('#content', '/')
|
||||
->assertScript("document.activeElement.id === 'showcase-search'");
|
||||
|
||||
$page->type('#showcase-search', 'datepicker')
|
||||
->assertSeeIn('[data-search-view]', '<x-datepicker>');
|
||||
|
||||
$page->keys('#showcase-search', 'Enter')
|
||||
->assertScript("location.pathname.endsWith('/material/pickers')")
|
||||
->assertScript("document.title === 'Date pickers · Livewire Material'");
|
||||
});
|
||||
|
||||
it('opens an example on another page at the example', function () {
|
||||
$page = visit('/material')->waitForEvent('networkidle')
|
||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||
|
||||
$page->click('#showcase-search')
|
||||
->type('#showcase-search', 'split button')
|
||||
->click('[data-showcase-result]:has-text("Example")')
|
||||
->assertScript("location.pathname.endsWith('/material/buttons') && location.hash.startsWith('#example-')")
|
||||
->assertScript('(() => { const box = document.getElementById(decodeURIComponent(location.hash.slice(1))).getBoundingClientRect(); return box.top >= 0 && box.top < innerHeight; })()');
|
||||
});
|
||||
|
||||
it('says so when nothing matches', function () {
|
||||
visit('/material')->waitForEvent('networkidle')
|
||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'")
|
||||
->click('#showcase-search')
|
||||
->type('#showcase-search', 'zzzz')
|
||||
->assertSeeIn('[data-search-view]', 'Nothing matches');
|
||||
});
|
||||
|
||||
@@ -44,6 +44,17 @@ it('uses the surrounding Alpine scope without wire:model, and stays open when pe
|
||||
->toContain('aria-label="Close"');
|
||||
});
|
||||
|
||||
it('keeps a full-screen dialog\'s subtitle on a phone, where its bar carries the title', function () {
|
||||
$html = (string) $this->blade('<x-modal title="Enable 2FA" subtitle="Scan the code" fullscreen>Text</x-modal>');
|
||||
|
||||
expect($html)
|
||||
->toMatch('/<h2 id="[^"]+-title" class="type-headline-sm max-sm:hidden">/')
|
||||
->toContain('<p class="type-body-md text-on-surface-variant mt-4 max-sm:mt-0">Scan the code</p>')
|
||||
->not->toContain('<div class="mb-4 max-sm:hidden">')
|
||||
->and((string) $this->blade('<x-modal title="Help" fullscreen>Text</x-modal>'))->toContain('<div class="mb-4 max-sm:hidden">')
|
||||
->and((string) $this->blade('<x-modal subtitle="Only a subtitle">Text</x-modal>'))->toContain('<p class="type-body-md text-on-surface-variant">Only a subtitle</p>');
|
||||
});
|
||||
|
||||
it('slides a side sheet in from either edge, and is a pane from xl when asked', function () {
|
||||
expect((string) $this->blade('<x-drawer title="Details" with-close-button>Body</x-drawer>'))
|
||||
->toContain('x-trap.inert.noscroll="open && ! wide"')
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
it('shows the errors of a plain form field under its name', function (string $blade, string $key) {
|
||||
$html = (string) $this->withViewErrors([$key => ['Something is wrong here.']])->blade($blade);
|
||||
|
||||
expect($html)
|
||||
->toContain('Something is wrong here.')
|
||||
->toContain('aria-invalid="true"');
|
||||
})->with([
|
||||
'input' => ['<x-input name="email" label="Email" />', 'email'],
|
||||
'password' => ['<x-password name="password" label="Password" />', 'password'],
|
||||
'textarea' => ['<x-textarea name="message" label="Message" />', 'message'],
|
||||
'select' => ['<x-select name="hours" label="Expires" :options="[[\'id\' => 1, \'name\' => \'1 hour\']]" />', 'hours'],
|
||||
'checkbox' => ['<x-checkbox name="terms" label="Terms" />', 'terms'],
|
||||
'radio' => ['<x-radio name="audience" :options="[[\'id\' => \'a\', \'name\' => \'A\']]" />', 'audience'],
|
||||
'file with brackets' => ['<x-file name="photos[]" label="Photos" multiple />', 'photos'],
|
||||
'nested name' => ['<x-input name="address[city]" label="City" />', 'address.city'],
|
||||
'timepicker' => ['<x-timepicker name="starts_at" label="Starts at" />', 'starts_at'],
|
||||
]);
|
||||
|
||||
it('keeps reading the wire:model name when both are there', function () {
|
||||
expect((string) $this->withViewErrors(['form.email' => ['Taken.']])->blade('<x-input name="email" wire:model="form.email" label="Email" />'))
|
||||
->toContain('Taken.');
|
||||
});
|
||||
@@ -20,9 +20,23 @@ function packageComponentPaths(): Collection
|
||||
->values();
|
||||
}
|
||||
|
||||
it('registers the components without a prefix', function () {
|
||||
it('registers the components under its own name when no prefix is configured', function () {
|
||||
expect(packageComponentPaths())->toHaveCount(1)
|
||||
->and(packageComponentPaths()->first()['prefix'])->toBeNull();
|
||||
->and(packageComponentPaths()->first()['prefix'])->toBe('livewire-material')
|
||||
->and(Blade::render('<x-badge value="New" tonal />'))->toContain('New');
|
||||
});
|
||||
|
||||
it('compiles an unprefixed component to the same view on every machine', function () {
|
||||
$compiled = Blade::compileString('<x-input label="Name" />');
|
||||
|
||||
expect($compiled)->toContain("'view' => '".hash('xxh128', 'livewire-material')."::input'")
|
||||
->not->toContain(hash('xxh128', LivewireMaterialServiceProvider::componentPath()));
|
||||
});
|
||||
|
||||
it('still renders a view compiled when the namespace came from the component path', function () {
|
||||
$legacy = hash('xxh128', LivewireMaterialServiceProvider::componentPath());
|
||||
|
||||
expect(view()->exists($legacy.'::input'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('registers the components under a configured prefix', function () {
|
||||
|
||||
@@ -45,6 +45,26 @@ it('gives every section a page of its own, linked from the overview and the rail
|
||||
$this->get('/material/not-a-section')->assertNotFound();
|
||||
});
|
||||
|
||||
it('indexes every section, example and component for the search, each linking to a place that exists', function () {
|
||||
$index = collect($this->getJson('/material/search.json')->assertOk()->json());
|
||||
|
||||
expect($index->where('kind', 'Section')->pluck('title')->all())
|
||||
->toBe(collect(Sections::all())->pluck('title')->all())
|
||||
->and($index->where('kind', 'Component')->pluck('title')->all())
|
||||
->toContain('<x-datepicker>', '<x-button>', '<x-app-shell>')
|
||||
->and($index->firstWhere('title', '<x-datepicker>')['url'])->toBe('/material/pickers');
|
||||
|
||||
$index->where('kind', 'Example')
|
||||
->groupBy(fn (array $entry): string => strtok($entry['url'], '#'))
|
||||
->each(function ($examples, string $page): void {
|
||||
$html = $this->withoutVite()->get($page)->assertOk()->getContent();
|
||||
|
||||
foreach ($examples as $example) {
|
||||
expect($html)->toContain('id="'.substr($example['url'], strpos($example['url'], '#') + 1).'"');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('does not mount the showcase when disabled', function () {
|
||||
rebootWithShowcase(false);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user