Fill the FAB and icon-button glyphs, and cut small ones at 20
M3 asks a FAB's icon and a default (non-toggle) icon button's icon to be filled, never outlined; the toggle half of that rule was already right, so only the `$selected === null` case changed. Every glyph these components draw at 20px now passes `optical="20"` for the cut M3 draws at that size — button xs/sm, group, menu item, the alert, snackbar and FAB-menu close buttons, the stat's icon. Plan step 18, actions.md ACT-14, ACT-15. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
co-authored by
Claude Opus 5
parent
573d94fe6e
commit
429be9b64f
@@ -224,7 +224,7 @@ Label button, icon button, toggle and responsive FAB in one component.
|
||||
| `primary`, `danger`, `caution` | | shorthands: filled primary, filled error, filled warning |
|
||||
| `size` | `sm` | `xs` 32px, `sm` 40px, `md` 56px, `lg` 96px, `xl` 136px |
|
||||
| `shape` | `round` | or `square`; both square off further while pressed |
|
||||
| `icon`, `icon-right` | | Material Symbol names |
|
||||
| `icon`, `icon-right` | | Material Symbol names; drawn from M3's 20px cut where the glyph is 20px, and filled on a default icon button |
|
||||
| `width` | `default` | icon buttons only: `narrow`, `default`, `wide` |
|
||||
| `selected` | `null` | `true`/`false` makes it a toggle (`aria-pressed`, selected colours and shape) |
|
||||
| `link`, `external`, `no-wire-navigate` | | renders `<a>`, with `wire:navigate` unless external |
|
||||
@@ -291,7 +291,7 @@ Attributes go to the leading button; the slot is the menu. `variant` (`filled` d
|
||||
|
||||
### `<x-fab>`
|
||||
|
||||
`<x-fab icon="add" tooltip="New share" />` — `size` `sm` 56px (default), `md` 80px, `lg` 96px; with `label` it is an extended FAB. `color` `primary`/`secondary`/`tertiary`, drawn in the container, or `variant="filled"`. It does not position itself; wrap it (`<div class="fixed end-4 bottom-4 large:end-6 large:bottom-6">` — M3's 16dp margin, 24dp from `large`). `link`, `external`, `disabled`, `type`.
|
||||
`<x-fab icon="add" tooltip="New share" />` — `size` `sm` 56px (default), `md` 80px, `lg` 96px; with `label` it is an extended FAB. The glyph is filled, as M3 requires of a FAB. `color` `primary`/`secondary`/`tertiary`, drawn in the container, or `variant="filled"`. It does not position itself; wrap it (`<div class="fixed end-4 bottom-4 large:end-6 large:bottom-6">` — M3's 16dp margin, 24dp from `large`). `link`, `external`, `disabled`, `type`.
|
||||
|
||||
### `<x-fab-menu>`, `<x-fab-menu-item>`
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
|
||||
@if ($dismissible)
|
||||
<button type="button" class="state-layer focus-ring -m-2 inline-flex size-10 shrink-0 items-center justify-center rounded-corner-full" aria-label="{{ __('Dismiss') }}" x-on:click="shown = false">
|
||||
<x-livewire-material::icon name="close" class="size-5" />
|
||||
<x-livewire-material::icon name="close" optical="20" class="size-5" />
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -15,9 +15,12 @@
|
||||
|
||||
With an `icon` and no label it is an icon button: `width` is `narrow`, `default` or `wide`,
|
||||
`variant="text"` is M3's standard icon button, and the tooltip or label names it for screen
|
||||
readers. `selected` makes it a toggle: `true` or `false` sets `aria-pressed` (not on a `link`,
|
||||
which is no toggle — give it `aria-current` instead) and M3's selected colours, and a selected round button turns square (a selected square icon button
|
||||
turns round). Text buttons are not toggles in M3; a selected one takes the tonal container.
|
||||
readers. Its glyph is filled, as M3 asks of a default icon button. `selected` makes it a
|
||||
toggle: `true` or `false` sets `aria-pressed` (not on a `link`, which is no toggle — give it
|
||||
`aria-current` instead) and M3's selected colours, and a selected round button turns square
|
||||
(a selected square icon button turns round); a toggle reads unselected outlined and selected
|
||||
filled, which is the rest of the same rule. Text buttons are not toggles in M3; a selected
|
||||
one takes the tonal container.
|
||||
|
||||
Values from androidx Compose Material 3's tokens (Button*Tokens, *IconButtonTokens,
|
||||
Apache-2.0); the text button's label is primary, as Compose draws it, not the token's
|
||||
@@ -161,6 +164,10 @@
|
||||
? ['xs' => 'size-5', 'sm' => 'size-6', 'md' => 'size-6', 'lg' => 'size-8', 'xl' => 'size-10'][$size]
|
||||
: ['xs' => 'size-5', 'sm' => 'size-5', 'md' => 'size-6', 'lg' => 'size-8', 'xl' => 'size-10'][$size];
|
||||
|
||||
// M3 draws a Material Symbol from a 20px cut below 24px: heavier strokes, wider counters, so a
|
||||
// small glyph does not thin out. The cut follows the size the icon is drawn at, never the button's.
|
||||
$optical = $iconSize === 'size-5' ? '20' : '24';
|
||||
|
||||
$outline = ['xs' => 'border', 'sm' => 'border', 'md' => 'border', 'lg' => 'border-2', 'xl' => 'border-3'][$size];
|
||||
$contained = in_array($variant, ['filled', 'tonal', 'elevated'], true) || ($variant === 'outlined' && $selected === true);
|
||||
|
||||
@@ -216,7 +223,7 @@
|
||||
|
||||
@if ($icon)
|
||||
<span class="contents" @if ($spinnerTarget) wire:loading.remove wire:target="{{ $spinnerTarget }}" @endif>
|
||||
<x-livewire-material::icon :name="$icon" :filled="$selected === true" :class="\Illuminate\Support\Arr::toCssClasses([$iconSize, 'max-medium:size-6' => $fab])" />
|
||||
<x-livewire-material::icon :name="$icon" :optical="$optical" :filled="$selected === true || ($iconOnly && $selected === null)" :class="\Illuminate\Support\Arr::toCssClasses([$iconSize, 'max-medium:size-6' => $fab])" />
|
||||
</span>
|
||||
@endif
|
||||
|
||||
@@ -225,7 +232,7 @@
|
||||
@endunless
|
||||
|
||||
@if ($iconRight)
|
||||
<x-livewire-material::icon :name="$iconRight" :class="$iconSize" />
|
||||
<x-livewire-material::icon :name="$iconRight" :optical="$optical" :class="$iconSize" />
|
||||
@endif
|
||||
|
||||
@if ($tip !== null)
|
||||
|
||||
@@ -51,8 +51,8 @@
|
||||
$colours,
|
||||
])
|
||||
>
|
||||
<x-livewire-material::icon :name="$icon" class="size-6 group-aria-expanded/fab:hidden" />
|
||||
<x-livewire-material::icon name="close" class="hidden size-5 group-aria-expanded/fab:block" />
|
||||
<x-livewire-material::icon :name="$icon" filled class="size-6 group-aria-expanded/fab:hidden" />
|
||||
<x-livewire-material::icon name="close" filled optical="20" class="hidden size-5 group-aria-expanded/fab:block" />
|
||||
</button>
|
||||
</span>
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
`<div class="fixed end-4 bottom-4"><x-fab icon="add" tooltip="New share" /></div>`. For a
|
||||
create action that is a FAB on a phone and a header button above, use `<x-button fab>`.
|
||||
|
||||
The glyph is filled, not outlined: M3 says so twice, in the FAB's anatomy and again in its
|
||||
guidelines ("icon should be filled (not outlined) and unambiguous").
|
||||
|
||||
Sizes, corners and elevation from FabBaseline/Medium/LargeTokens and ExtendedFab*Tokens
|
||||
(androidx Compose Material 3, Apache-2.0). --}}
|
||||
|
||||
@@ -65,7 +68,7 @@
|
||||
|
||||
<{{ $tag }} {{ $attributes }}>
|
||||
@if ($icon)
|
||||
<x-livewire-material::icon :name="$icon" :class="$iconSize" />
|
||||
<x-livewire-material::icon :name="$icon" filled :class="$iconSize" />
|
||||
@endif
|
||||
|
||||
@if ($extended)
|
||||
|
||||
@@ -63,6 +63,8 @@
|
||||
: '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];
|
||||
// A 20px glyph comes from M3's 20px cut, which is drawn for that size rather than scaled to it.
|
||||
$optical = $iconSize === 'size-5' ? '20' : '24';
|
||||
|
||||
$colours = match ($variant) {
|
||||
'filled' => 'bg-surface-container text-on-surface-variant has-checked:bg-primary has-checked:text-on-primary',
|
||||
@@ -98,7 +100,7 @@
|
||||
/>
|
||||
|
||||
@if (filled(data_get($option, $optionIcon)))
|
||||
<x-livewire-material::icon :name="data_get($option, $optionIcon)" :class="$iconSize" />
|
||||
<x-livewire-material::icon :name="data_get($option, $optionIcon)" :optical="$optical" :class="$iconSize" />
|
||||
@endif
|
||||
|
||||
<span class="truncate">{{ data_get($option, $optionLabel) }}</span>
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
|
||||
<{{ $tag }} {{ $attributes }}>
|
||||
@if ($icon)
|
||||
<x-livewire-material::icon :name="$icon" :filled="$selected === true || $current" :class="$leadingIcon" />
|
||||
<x-livewire-material::icon :name="$icon" optical="20" :filled="$selected === true || $current" :class="$leadingIcon" />
|
||||
@endif
|
||||
|
||||
<span class="min-w-0 flex-1">
|
||||
@@ -104,6 +104,6 @@
|
||||
@endif
|
||||
|
||||
@if ($iconRight)
|
||||
<x-livewire-material::icon :name="$iconRight" :class="'size-5 '.$iconInk" />
|
||||
<x-livewire-material::icon :name="$iconRight" optical="20" :class="'size-5 '.$iconInk" />
|
||||
@endif
|
||||
</{{ $tag }}>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<div {{ $attributes->class('flex flex-col gap-1 rounded-corner-lg bg-surface-container p-4') }}>
|
||||
<div class="flex items-center gap-2 type-label-lg text-on-surface-variant">
|
||||
@if ($icon)
|
||||
<x-livewire-material::icon :name="$icon" class="size-5" />
|
||||
<x-livewire-material::icon :name="$icon" optical="20" class="size-5" />
|
||||
@endif
|
||||
<span>{{ $title }}</span>
|
||||
</div>
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
|
||||
<template x-if="current.action || ! current.timeout">
|
||||
<button type="button" class="state-layer focus-ring inline-flex size-10 shrink-0 items-center justify-center rounded-corner-full" aria-label="{{ __('Dismiss') }}" x-on:click="dismiss()">
|
||||
<x-livewire-material::icon name="close" class="size-5" />
|
||||
<x-livewire-material::icon name="close" optical="20" class="size-5" />
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -67,6 +67,23 @@ it('reaches a 48px touch target below the medium size', function () {
|
||||
->and(buttonClasses('<x-button label="Go" size="md" />'))->not->toContain('touch-target');
|
||||
});
|
||||
|
||||
it('fills a default icon button\'s glyph, and draws a 20px one from the 20px cut', function () {
|
||||
$geometry = function (string $file): string {
|
||||
$svg = trim((string) file_get_contents(__DIR__.'/../../../resources/svg/symbols/'.$file.'.svg'));
|
||||
|
||||
return substr($svg, (int) strpos($svg, '><') + 1);
|
||||
};
|
||||
|
||||
// A default icon button is filled, a toggle still reads outlined unselected, filled selected.
|
||||
expect((string) $this->blade('<x-button icon="favorite" aria-label="Keep" />'))->toContain($geometry('filled/favorite'))
|
||||
->and((string) $this->blade('<x-button icon="favorite" aria-label="Keep" :selected="false" />'))->toContain($geometry('outlined/favorite'))
|
||||
->and((string) $this->blade('<x-button icon="favorite" aria-label="Keep" :selected="true" />'))->toContain($geometry('filled/favorite'));
|
||||
|
||||
// 20px glyphs come from the 20px cut; 24px ones stay on the default.
|
||||
expect((string) $this->blade('<x-button label="Keep" icon="favorite" />'))->toContain($geometry('outlined-20/favorite'))
|
||||
->and((string) $this->blade('<x-button label="Keep" icon="favorite" size="md" />'))->toContain($geometry('outlined/favorite'));
|
||||
});
|
||||
|
||||
it('is an icon button named by its tooltip when it has no label', function () {
|
||||
$html = (string) $this->blade('<x-button icon="close" tooltip="Close menu" />');
|
||||
|
||||
|
||||
@@ -17,6 +17,14 @@ it('sizes a FAB at 56, 80 and 96px', function (string $size, string $classes, st
|
||||
'lg' => ['lg', 'size-24 rounded-corner-xl', 'size-8'],
|
||||
]);
|
||||
|
||||
it('fills the FAB\'s glyph, as M3 asks twice over', function () {
|
||||
$svg = trim((string) file_get_contents(__DIR__.'/../../../resources/svg/symbols/filled/add.svg'));
|
||||
$filled = substr($svg, (int) strpos($svg, '><') + 1);
|
||||
|
||||
expect((string) $this->blade('<x-fab icon="add" aria-label="New" />'))->toContain($filled)
|
||||
->and((string) $this->blade('<x-fab-menu label="New" />'))->toContain($filled);
|
||||
});
|
||||
|
||||
it('extends with a label', function () {
|
||||
expect((string) $this->blade('<x-fab icon="upload" label="Upload" size="md" color="tertiary" variant="filled" />'))
|
||||
->toContain('h-20 min-w-20 gap-3 px-[26px] rounded-corner-lg-increased type-title-lg')
|
||||
|
||||
Reference in New Issue
Block a user