Add hint-class to the group and icon-class to menu items
<x-group hint-class> adds classes to the hint, as the text fields' hint-class does, and a validation message still replaces the hint. <x-menu-item icon-class> adds classes to the leading icon, for an icon whose colour means something of its own, such as a sport's glyph. A colour class there has to win over the component's own colour, and which of two colour utilities wins depends on the order Tailwind emits them (text-error comes before text-on-surface-variant). So when either prop is given, the component's own colour is written with a :where() variant that carries no specificity, as the fields' hint colour sits in the components layer. A disabled item's icon stays disabled. Without the props the markup is unchanged. The skill now also lists the fields' hint-class, which it had left out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
This commit is contained in:
co-authored by
Claude Opus 5
parent
a83d7f62ea
commit
ab7317faae
@@ -244,7 +244,7 @@ M3's plain tooltip, standalone around any trigger: `<x-tooltip text="Copy link"
|
|||||||
</x-menu>
|
</x-menu>
|
||||||
```
|
```
|
||||||
|
|
||||||
`<x-menu>`: `trigger` slot (its first button or link becomes the menu button, and the menu hangs on that button — a `position: fixed` trigger such as `<x-button fab>` carries it along, and a menu with no room flips to the other side, end or both), `label`, `position` (`bottom-start` default, `bottom-end`, `top-start`, `top-end`), `vibrant`. `<x-menu-item>`: `label`, `icon`, `icon-right`, `description`, `shortcut`, `link`, `external`, `selected` (makes it a `menuitemcheckbox`), `disabled`, `keep-open`. Choosing an item closes the menu unless `keep-open`. Keyboard: arrows, Home, End, a letter, Escape (focus returns to the trigger), Tab.
|
`<x-menu>`: `trigger` slot (its first button or link becomes the menu button, and the menu hangs on that button — a `position: fixed` trigger such as `<x-button fab>` carries it along, and a menu with no room flips to the other side, end or both), `label`, `position` (`bottom-start` default, `bottom-end`, `top-start`, `top-end`), `vibrant`. `<x-menu-item>`: `label`, `icon`, `icon-class` (classes for the leading icon; a colour there paints it, a selected item's too, but not a disabled one's — `icon-class="text-sport-run"`), `icon-right`, `description`, `shortcut`, `link`, `external`, `selected` (makes it a `menuitemcheckbox`), `disabled`, `keep-open`. Choosing an item closes the menu unless `keep-open`. Keyboard: arrows, Home, End, a letter, Escape (focus returns to the trigger), Tab.
|
||||||
|
|
||||||
### `<x-button-group>`
|
### `<x-button-group>`
|
||||||
|
|
||||||
@@ -262,7 +262,7 @@ A choice between a few options as a connected button group of native radios (che
|
|||||||
]" hint="Recipients lose access after that" />
|
]" hint="Recipients lose access after that" />
|
||||||
```
|
```
|
||||||
|
|
||||||
Props: `label`, `hint`, `name` (required with `x-model`), `options`, `option-value` (`id`), `option-label` (`name`), `option-icon` (`icon`), `size`, `variant` (`tonal`, `filled`, `outlined`), `multiple`, `inline` (intrinsic width instead of sharing the row). A validation error for the bound property replaces the hint.
|
Props: `label`, `hint`, `hint-class` (classes for the hint, as on the fields; a colour there paints it), `name` (required with `x-model`), `options`, `option-value` (`id`), `option-label` (`name`), `option-icon` (`icon`), `size`, `variant` (`tonal`, `filled`, `outlined`), `multiple`, `inline` (intrinsic width instead of sharing the row). A validation error for the bound property replaces the hint.
|
||||||
|
|
||||||
### `<x-split-button>`
|
### `<x-split-button>`
|
||||||
|
|
||||||
@@ -535,7 +535,7 @@ A one-column grid of fields with an `actions` slot at the foot (the slot takes i
|
|||||||
|
|
||||||
### `<x-field>`, `<x-input>`, `<x-password>`, `<x-textarea>`, `<x-select>`, `<x-file>`
|
### `<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, 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.
|
M3 text fields. `variant`: `outlined` or `filled`; without it, `config('livewire-material.fields.variant')` (`outlined`). All take `label`, `hint`, `variant` (and all but `<x-file>` a `hint-class`, classes added to the hint: `hint-class="text-warning"`), and read their errors from the bag under the `wire:model` name, or the `name` in a plain form (`photos[]` → `photos`, `address[city]` → `address.city`); the error replaces the hint and sets `aria-invalid`. `class` lands on the field's outer element (margins, widths); every other attribute (`wire:model`, `type`, `required`, `readonly`, `autocomplete`) reaches the control. Never pass `placeholder` expecting it to show while a label rests in the field: it shows once the field has focus.
|
||||||
|
|
||||||
- `<x-input>`: `icon`, `icon-right`, `prefix`, `suffix`, `clearable`, `copyable` (copies the value, confirms with a snackbar), `size` (`sm` 40px, `xs` 32px — for unlabelled toolbar controls; give them `aria-label`), `mono`.
|
- `<x-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`.
|
- `<x-password>`: a reveal button; `icon`, `size`.
|
||||||
|
|||||||
@@ -12,13 +12,19 @@
|
|||||||
default), `filled` or `outlined`, as for toggle buttons. The segments share the row unless
|
default), `filled` or `outlined`, as for toggle buttons. The segments share the row unless
|
||||||
`inline`. An option with `'disabled' => true` greys its own segment.
|
`inline`. An option with `'disabled' => true` greys its own segment.
|
||||||
|
|
||||||
ReStride's props, kept: `label`, `hint`, `name` (needed with `x-model`, which names no
|
ReStride's props, kept: `label`, `hint`, `hint-class`, `name` (needed with `x-model`, which
|
||||||
property), `options`, `option-value`, `option-label`; plus `option-icon`, `size`, `variant`,
|
names no property), `options`, `option-value`, `option-label`; plus `option-icon`, `size`,
|
||||||
`multiple`, `inline`. A validation message for the bound property replaces the hint. --}}
|
`variant`, `multiple`, `inline`. A validation message for the bound property replaces the hint.
|
||||||
|
|
||||||
|
`hint-class` adds classes to the hint, as on `<x-field>`: a colour there paints it
|
||||||
|
(`hint-class="text-warning"` for a hint that warns). The hint's own colour then carries no
|
||||||
|
specificity, as the field's does in the components layer, because which of two colour
|
||||||
|
utilities wins depends on the order Tailwind emits them. --}}
|
||||||
|
|
||||||
@props([
|
@props([
|
||||||
'label' => null,
|
'label' => null,
|
||||||
'hint' => null,
|
'hint' => null,
|
||||||
|
'hintClass' => null,
|
||||||
'name' => null,
|
'name' => null,
|
||||||
'options' => [],
|
'options' => [],
|
||||||
'optionValue' => 'id',
|
'optionValue' => 'id',
|
||||||
@@ -46,6 +52,10 @@
|
|||||||
'xl' => 'h-34 gap-4 px-16 type-headline-lg',
|
'xl' => 'h-34 gap-4 px-16 type-headline-lg',
|
||||||
][$size];
|
][$size];
|
||||||
|
|
||||||
|
$hintClasses = filled($hintClass)
|
||||||
|
? \Illuminate\Support\Arr::toCssClasses(['mt-1 type-body-sm [:where(&)]:text-on-surface-variant', $hintClass])
|
||||||
|
: 'mt-1 type-body-sm text-on-surface-variant';
|
||||||
|
|
||||||
$iconSize = ['xs' => 'size-5', 'sm' => 'size-5', 'md' => 'size-6', 'lg' => 'size-8', 'xl' => 'size-10'][$size];
|
$iconSize = ['xs' => 'size-5', 'sm' => 'size-5', 'md' => 'size-6', 'lg' => 'size-8', 'xl' => 'size-10'][$size];
|
||||||
|
|
||||||
$colours = match ($variant) {
|
$colours = match ($variant) {
|
||||||
@@ -94,6 +104,6 @@
|
|||||||
<p class="mt-1 type-body-sm text-error">{{ $message }}</p>
|
<p class="mt-1 type-body-sm text-error">{{ $message }}</p>
|
||||||
@endforeach
|
@endforeach
|
||||||
@elseif (filled($hint))
|
@elseif (filled($hint))
|
||||||
<p class="mt-1 type-body-sm text-on-surface-variant">{{ $hint }}</p>
|
<p class="{{ $hintClasses }}">{{ $hint }}</p>
|
||||||
@endif
|
@endif
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
{{-- One item in an `<x-menu>`: an action, a link, or a choice.
|
{{-- One item in an `<x-menu>`: an action, a link, or a choice.
|
||||||
|
|
||||||
`label`, a leading `icon`, an `icon-right`, a `description` under the label and a
|
`label`, a leading `icon` (`icon-class` adds classes to it), an `icon-right`, a `description`
|
||||||
`shortcut` at the end (M3's trailing supporting text: "⌘C"). `link` makes it an anchor, with
|
under the label and a `shortcut` at the end (M3's trailing supporting text: "⌘C"). `link`
|
||||||
`wire:navigate` unless `external` or `no-wire-navigate`. `selected` (true or false) makes it a
|
makes it an anchor, with `wire:navigate` unless `external` or `no-wire-navigate`. `selected`
|
||||||
`menuitemcheckbox` with `aria-checked`; a selected item takes Expressive's selected shape and
|
(true or false) makes it a `menuitemcheckbox` with `aria-checked`; a selected item takes
|
||||||
tertiary-container. `disabled` keeps it in the list, out of reach. `keep-open` leaves the menu
|
Expressive's selected shape and tertiary-container. `disabled` keeps it in the list, out of
|
||||||
open when it is activated — for a choice the person may want to change twice.
|
reach. `keep-open` leaves the menu open when it is activated — for a choice the person may
|
||||||
|
want to change twice.
|
||||||
|
|
||||||
|
`icon-class` is for an icon whose colour means something of its own, a sport's glyph in the
|
||||||
|
sport's colour (`icon-class="text-sport-run"`). A colour there paints the icon, a selected
|
||||||
|
item's too: the icon's own colour then carries no specificity, because which of two colour
|
||||||
|
utilities wins depends on the order Tailwind emits them. A disabled item's icon stays
|
||||||
|
disabled.
|
||||||
|
|
||||||
44px tall (SegmentedMenuTokens.Item), body-large label, 20px icons, 4px corners that open to
|
44px tall (SegmentedMenuTokens.Item), body-large label, 20px icons, 4px corners that open to
|
||||||
12px at the ends of the list. --}}
|
12px at the ends of the list. --}}
|
||||||
@@ -13,6 +20,7 @@
|
|||||||
@props([
|
@props([
|
||||||
'label' => null,
|
'label' => null,
|
||||||
'icon' => null,
|
'icon' => null,
|
||||||
|
'iconClass' => null,
|
||||||
'iconRight' => null,
|
'iconRight' => null,
|
||||||
'description' => null,
|
'description' => null,
|
||||||
'shortcut' => null,
|
'shortcut' => null,
|
||||||
@@ -56,11 +64,18 @@
|
|||||||
$selected === true => 'text-on-tertiary-container',
|
$selected === true => 'text-on-tertiary-container',
|
||||||
default => 'text-on-surface-variant',
|
default => 'text-on-surface-variant',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
$leadingIcon = match (true) {
|
||||||
|
blank($iconClass) => 'size-5 '.$iconInk,
|
||||||
|
$disabled => \Illuminate\Support\Arr::toCssClasses(['size-5', $iconClass, 'text-on-surface/38!']),
|
||||||
|
$selected === true => \Illuminate\Support\Arr::toCssClasses(['size-5 [:where(&)]:text-on-tertiary-container', $iconClass]),
|
||||||
|
default => \Illuminate\Support\Arr::toCssClasses(['size-5 [:where(&)]:text-on-surface-variant', $iconClass]),
|
||||||
|
};
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<{{ $tag }} {{ $attributes }}>
|
<{{ $tag }} {{ $attributes }}>
|
||||||
@if ($icon)
|
@if ($icon)
|
||||||
<x-livewire-material::icon :name="$icon" :filled="$selected === true" :class="'size-5 '.$iconInk" />
|
<x-livewire-material::icon :name="$icon" :filled="$selected === true" :class="$leadingIcon" />
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<span class="min-w-0 flex-1">
|
<span class="min-w-0 flex-1">
|
||||||
|
|||||||
@@ -84,7 +84,7 @@
|
|||||||
['id' => 'system', 'name' => 'System', 'icon' => 'computer'],
|
['id' => 'system', 'name' => 'System', 'icon' => 'computer'],
|
||||||
]" />
|
]" />
|
||||||
|
|
||||||
<x-group label="Days" name="showcase-days" x-model="days" multiple variant="outlined" hint="Choose any" :options="[
|
<x-group label="Days" name="showcase-days" x-model="days" multiple variant="outlined" hint="Thursday is fully booked" hint-class="text-warning" :options="[
|
||||||
['id' => 'mon', 'name' => 'Mon'],
|
['id' => 'mon', 'name' => 'Mon'],
|
||||||
['id' => 'tue', 'name' => 'Tue'],
|
['id' => 'tue', 'name' => 'Tue'],
|
||||||
['id' => 'wed', 'name' => 'Wed'],
|
['id' => 'wed', 'name' => 'Wed'],
|
||||||
|
|||||||
@@ -36,6 +36,18 @@
|
|||||||
<x-menu-item label="Upload a folder" icon="drive_folder_upload" />
|
<x-menu-item label="Upload a folder" icon="drive_folder_upload" />
|
||||||
</x-menu>
|
</x-menu>
|
||||||
BLADE,
|
BLADE,
|
||||||
|
'Icons in their own colour' => <<<'BLADE'
|
||||||
|
<x-menu label="New plan">
|
||||||
|
<x-slot:trigger>
|
||||||
|
<x-button label="New plan" icon="add" variant="filled" />
|
||||||
|
</x-slot:trigger>
|
||||||
|
|
||||||
|
<x-menu-item label="Running" icon="directions_run" icon-class="text-tertiary" />
|
||||||
|
<x-menu-item label="Cycling" icon="directions_bike" icon-class="text-secondary" />
|
||||||
|
<x-menu-item label="Swimming" icon="pool" icon-class="text-info" />
|
||||||
|
<x-menu-item label="Rowing" icon="rowing" icon-class="text-tertiary" disabled />
|
||||||
|
</x-menu>
|
||||||
|
BLADE,
|
||||||
];
|
];
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
|
|||||||
@@ -221,3 +221,33 @@ it('hangs a menu on its menu button, even when the button is fixed to a corner o
|
|||||||
->assertScript("(() => { const names = getComputedStyle(document.querySelector('[data-test=\"more\"]')).getPropertyValue('anchor-name'); return names.includes('--material-button-') && names.includes('--material-menu-'); })()")
|
->assertScript("(() => { const names = getComputedStyle(document.querySelector('[data-test=\"more\"]')).getPropertyValue('anchor-name'); return names.includes('--material-button-') && names.includes('--material-menu-'); })()")
|
||||||
->assertScript('(({ control, menu }) => menu.top >= control.bottom && menu.top - control.bottom <= 16 && Math.abs(menu.left - control.left) <= 16)('.menuAgainst('more', 'Share actions').')');
|
->assertScript('(({ control, menu }) => menu.top >= control.bottom && menu.top - control.bottom <= 16 && Math.abs(menu.left - control.left) <= 16)('.menuAgainst('more', 'Share actions').')');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('paints a group\'s hint and a menu item\'s icon in the colour their classes name', function () {
|
||||||
|
Route::middleware('web')->get('/colour-class-probe', fn () => Blade::render(<<<'BLADE'
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<x-theme-script />
|
||||||
|
@vite(config('livewire-material.showcase.vite'))
|
||||||
|
</head>
|
||||||
|
<body class="bg-surface">
|
||||||
|
<span id="error-ink" class="text-error">Reference</span>
|
||||||
|
<div id="terrain">
|
||||||
|
<x-group name="terrain" hint="No elevation data here" hint-class="text-error" :options="[['id' => 'flat', 'name' => 'Flat']]" />
|
||||||
|
</div>
|
||||||
|
<x-menu-item id="run" label="Running plan" icon="directions_run" icon-class="text-error" />
|
||||||
|
<x-menu-item id="chosen" label="Cycling plan" icon="directions_bike" icon-class="text-error" :selected="true" />
|
||||||
|
<x-menu-item id="off" label="Swimming plan" icon="pool" icon-class="text-error" disabled />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
BLADE));
|
||||||
|
|
||||||
|
$ink = fn (string $element): string => "getComputedStyle({$element}).color";
|
||||||
|
$error = $ink("document.querySelector('#error-ink')");
|
||||||
|
|
||||||
|
visit('/colour-class-probe')->waitForEvent('networkidle')
|
||||||
|
->assertScript($ink("document.querySelector('#terrain p')")." === {$error}")
|
||||||
|
->assertScript($ink("document.querySelector('#run svg')")." === {$error}")
|
||||||
|
->assertScript($ink("document.querySelector('#chosen svg')")." === {$error}")
|
||||||
|
->assertScript($ink("document.querySelector('#off svg')")." !== {$error}");
|
||||||
|
});
|
||||||
|
|||||||
@@ -83,3 +83,17 @@ it('binds to a Livewire property and shows its validation message', function ()
|
|||||||
->assertSee('Pick light.')
|
->assertSee('Pick light.')
|
||||||
->assertDontSee('How it looks');
|
->assertDontSee('How it looks');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('adds hint-class to the hint, which a validation message still replaces', function () {
|
||||||
|
$options = [['id' => 'flat', 'name' => 'Flat'], ['id' => 'hilly', 'name' => 'Hilly']];
|
||||||
|
|
||||||
|
expect((string) $this->blade('<x-group wire:model="terrain" hint="No elevation data here" hint-class="text-warning" :$options />', ['options' => $options]))
|
||||||
|
->toContain('<p class="mt-1 type-body-sm [:where(&)]:text-on-surface-variant text-warning">No elevation data here</p>')
|
||||||
|
->and((string) $this->blade('<x-group wire:model="terrain" hint="No elevation data here" :$options />', ['options' => $options]))
|
||||||
|
->toContain('<p class="mt-1 type-body-sm text-on-surface-variant">No elevation data here</p>');
|
||||||
|
|
||||||
|
expect((string) $this->withViewErrors(['terrain' => 'Pick a terrain.'])->blade('<x-group wire:model="terrain" hint="No elevation data here" hint-class="text-warning" :$options />', ['options' => $options]))
|
||||||
|
->toContain('<p class="mt-1 type-body-sm text-error">Pick a terrain.</p>')
|
||||||
|
->not->toContain('No elevation data here')
|
||||||
|
->not->toContain('text-warning');
|
||||||
|
});
|
||||||
|
|||||||
@@ -69,3 +69,18 @@ it('separates and labels groups', function () {
|
|||||||
$this->blade('<x-menu-group label="Sort by"><x-menu-item label="Newest" /></x-menu-group>')
|
$this->blade('<x-menu-group label="Sort by"><x-menu-item label="Newest" /></x-menu-group>')
|
||||||
->assertSee('role="group" aria-label="Sort by"', false);
|
->assertSee('role="group" aria-label="Sort by"', false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('adds icon-class to the leading icon, over its own colour but not over disabled', function () {
|
||||||
|
$leading = fn (string $html): string => preg_match('/<svg[^>]*class="([^"]*)"/', $html, $icon) ? $icon[1] : '';
|
||||||
|
|
||||||
|
expect($leading((string) $this->blade('<x-menu-item label="Running plan" icon="directions_run" icon-class="text-sport-run" icon-right="chevron_right" />')))
|
||||||
|
->toBe('shrink-0 size-5 [:where(&)]:text-on-surface-variant text-sport-run')
|
||||||
|
->and($leading((string) $this->blade('<x-menu-item label="Running plan" icon="directions_run" icon-class="text-sport-run" :selected="true" />')))
|
||||||
|
->toBe('shrink-0 size-5 [:where(&)]:text-on-tertiary-container text-sport-run')
|
||||||
|
->and($leading((string) $this->blade('<x-menu-item label="Running plan" icon="directions_run" icon-class="text-sport-run" disabled />')))
|
||||||
|
->toBe('shrink-0 size-5 text-sport-run text-on-surface/38!')
|
||||||
|
->and($leading((string) $this->blade('<x-menu-item label="Running plan" icon="directions_run" />')))
|
||||||
|
->toBe('shrink-0 size-5 text-on-surface-variant')
|
||||||
|
->and((string) $this->blade('<x-menu-item label="Next" icon-right="chevron_right" icon-class="text-sport-run" />'))
|
||||||
|
->not->toContain('text-sport-run');
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user