Merge branch 'worktree-agent-a6b15d4e17dad5970'
This commit is contained in:
@@ -5,9 +5,15 @@
|
||||
text from `description` or the slot, and an `actions` slot for one or two text buttons.
|
||||
`color` (alias `tone`): `info` (the default), `success`, `warning`, `error`, `primary`,
|
||||
`secondary`, `tertiary`, or `neutral` for surface-container-high. `icon` replaces the state's
|
||||
icon; `:icon="false"` drops it. `dismissible` adds a close button that hides it in the browser.
|
||||
icon; `:icon="false"` drops it. `dismissible` adds a close button that hides it in the browser,
|
||||
40px drawn with the 48px target M3 asks for.
|
||||
|
||||
Errors and warnings are `role="alert"` and announced at once; the rest are `role="status"`. --}}
|
||||
It is a `role="status"`, whatever its colour: an alert is usually part of the page as it
|
||||
renders, and `role="alert"` is an assertive live region that some screen readers announce over
|
||||
the page title on load — and that a Livewire morph re-announces. `assertive` opts into
|
||||
`role="alert"` for the case the prop is named after: a notice put on screen in answer to
|
||||
something the person just did. M3 publishes no banner; the nearest thing it does publish, the
|
||||
snackbar, says polite and never assertive. --}}
|
||||
|
||||
@props([
|
||||
'title' => null,
|
||||
@@ -16,6 +22,7 @@
|
||||
'tone' => null,
|
||||
'icon' => null,
|
||||
'dismissible' => false,
|
||||
'assertive' => false,
|
||||
])
|
||||
|
||||
@php
|
||||
@@ -35,7 +42,7 @@
|
||||
@endphp
|
||||
|
||||
<div
|
||||
role="{{ in_array($color, ['error', 'warning'], true) ? 'alert' : 'status' }}"
|
||||
role="{{ $assertive ? 'alert' : 'status' }}"
|
||||
@if ($dismissible) x-data="{ shown: true }" x-show="shown" x-transition.opacity @endif
|
||||
{{ $attributes->class(['flex items-start gap-3 rounded-corner-md p-4', $colours]) }}
|
||||
>
|
||||
@@ -58,8 +65,8 @@
|
||||
</div>
|
||||
|
||||
@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" />
|
||||
<button type="button" class="state-layer focus-ring touch-target -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" optical="20" class="size-5" />
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -4,8 +4,11 @@
|
||||
- `<x-badge />` — the small badge, a 6px dot: something new, no number.
|
||||
- `<x-badge value="3" />` — the large badge, 16px tall, label-small: a count. `max` caps what is
|
||||
shown ("99+").
|
||||
Both are `error` by default, as M3 draws them. `floating` pins one to the top-end corner of a
|
||||
`relative` parent — an icon or an icon button:
|
||||
Both are `error` by default, as M3 draws them, and an `outline` status label draws its edge in
|
||||
`outline`, not `outline-variant`: M3 asks a badge for 3:1 against what is behind it, and
|
||||
`outline-variant` is the decorative role dividers use. `floating` pins one to the top-end
|
||||
corner of a `relative` parent, on M3's anchor geometry — the dot flush in the corner, the
|
||||
count 2px above it — an icon or an icon button:
|
||||
|
||||
<span class="relative inline-flex"><x-icon name="notifications" /><x-badge value="4" floating /></span>
|
||||
|
||||
@@ -16,12 +19,12 @@
|
||||
and two without a hue of their own:
|
||||
- `neutral` — neutral ink on every variant: on-surface-variant with surface text as a dot or
|
||||
count (the ink an outline badge already writes in), surface-container-high with
|
||||
on-surface-variant text when `tonal`, the outline-variant edge when `outline`.
|
||||
on-surface-variant text when `tonal`, the outline edge when `outline`.
|
||||
- `plain` — no background, text or border colour at all, only shape, size and type, so the
|
||||
caller's classes paint it: `<x-badge value="Run" tonal color="plain" class="bg-tertiary-container text-on-tertiary-container" />`.
|
||||
|
||||
The value is `value`, or the slot, which renders as HTML — an icon beside the word:
|
||||
`<x-badge tonal><x-icon name="bolt" class="size-3" /> Pro</x-badge>`. With neither it is a dot.
|
||||
`<x-badge tonal><x-icon name="bolt" optical="20" class="size-3" /> Pro</x-badge>`. With neither it is a dot.
|
||||
|
||||
A count or dot says nothing to a screen reader on its own: give the icon's control a label
|
||||
that includes it ("Notifications, 4 new"), or pass `label` here. --}}
|
||||
@@ -65,7 +68,7 @@
|
||||
$color === 'plain' => '',
|
||||
! $status, $solid => $filled[$color],
|
||||
$tonal => $container[$color],
|
||||
default => 'border-outline-variant text-on-surface-variant',
|
||||
default => 'border-outline text-on-surface-variant',
|
||||
};
|
||||
|
||||
$attributes = $attributes
|
||||
@@ -76,8 +79,8 @@
|
||||
'h-6 gap-1 rounded-corner-sm px-2 type-label-md' => $status,
|
||||
'border' => $outline && ! $tonal && ! $solid && ! $dot,
|
||||
$paint => $paint !== '',
|
||||
'absolute top-0.5 end-0.5' => $floating && $dot,
|
||||
'absolute -top-1 start-[calc(100%-0.75rem)]' => $floating && ! $dot,
|
||||
'absolute top-0 end-0' => $floating && $dot,
|
||||
'absolute -top-0.5 start-[calc(100%-0.75rem)]' => $floating && ! $dot,
|
||||
])
|
||||
->merge(array_filter([
|
||||
'aria-label' => $label,
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
the size of the buttons inside, so the spacing and corners match. For a choice bound to a
|
||||
property, `<x-group>` draws a connected group of radios.
|
||||
|
||||
A group never wraps to a second line — M3's rule, and the press expansion only reaches a
|
||||
neighbour on the same line anyway. Where a row is too long for its window the answer is a
|
||||
smaller `size` or fewer buttons, or two groups stacked.
|
||||
|
||||
Spacing from the M3 Expressive spec (ButtonGroupSmallTokens: 12px at the small size). The
|
||||
shapes are resources/css/components/groups.css. --}}
|
||||
|
||||
@@ -32,7 +36,6 @@
|
||||
{{ $attributes->class([
|
||||
'inline-flex items-center',
|
||||
'gap-0.5' => $connected,
|
||||
'flex-wrap' => ! $connected,
|
||||
'gap-[18px]' => ! $connected && $size === 'xs',
|
||||
'gap-3' => ! $connected && $size === 'sm',
|
||||
'gap-2' => ! $connected && in_array($size, ['md', 'lg', 'xl'], true),
|
||||
|
||||
@@ -9,13 +9,18 @@
|
||||
`size` is Expressive's scale — `xs` 32px, `sm` 40px (the default), `md` 56px, `lg` 96px,
|
||||
`xl` 136px — each with its own padding, type style and icon size. `shape` is `round` (the
|
||||
default) or `square`; either one squares off towards a smaller corner while pressed, on the
|
||||
fast spatial spring.
|
||||
fast spatial spring, while the colours cross on the fast effects spring beside it
|
||||
(`state-transition-fast`, resources/css/components/actions.css: a colour must never
|
||||
overshoot).
|
||||
|
||||
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
|
||||
@@ -24,11 +29,15 @@
|
||||
Behaviour kept from maryUI: `link` renders an anchor with `wire:navigate` (unless `external`
|
||||
or `no-wire-navigate`); `disabled` works on a link too, as `aria-disabled`; `spinner` shows
|
||||
the loading indicator while the button's own `wire:click` runs (or the action named by a
|
||||
string); `responsive` hides the label below `expanded` (840px, where M3 lets buttons reposition);
|
||||
string), drawn in the button's own ink — M3 asks an indicator embedded in another component
|
||||
to take that component's label colour, so it stays visible on a filled or tonal container;
|
||||
`responsive` hides the label below `expanded` (840px, where M3 lets buttons reposition);
|
||||
`tooltip`, `tooltip-left`, `tooltip-right`
|
||||
and `tooltip-bottom` attach a plain tooltip. `fab` is a page's create action: an extended FAB
|
||||
pinned in the thumb zone on a compact window (below `medium`, 600px), a filled button from
|
||||
there — one element either way.
|
||||
there — one element either way. It lifts clear of a bottom bar and of a snackbar on screen
|
||||
(`--material-bottom-bar`, `--material-snackbar-height`), because M3 puts a snackbar above a
|
||||
FAB and never over one.
|
||||
|
||||
Never pass `hidden`, a display or a position class: the button is `inline-flex` and
|
||||
`relative`, and whichever Tailwind emits last wins. Wrap it instead. --}}
|
||||
@@ -146,7 +155,7 @@
|
||||
'xl' => ['narrow' => 'h-34 w-26', 'default' => 'size-34', 'wide' => 'h-34 w-46'],
|
||||
][$size][$width]
|
||||
: [
|
||||
'xs' => 'h-8 gap-2 px-3 type-label-lg',
|
||||
'xs' => 'h-8 gap-2 px-4 type-label-lg',
|
||||
'sm' => 'h-10 gap-2 px-4 type-label-lg',
|
||||
'md' => 'h-14 gap-2 px-6 type-title-md',
|
||||
'lg' => 'h-24 gap-3 px-12 type-headline-sm',
|
||||
@@ -157,12 +166,16 @@
|
||||
? ['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);
|
||||
|
||||
$classes = [
|
||||
'group/button state-layer focus-ring inline-flex shrink-0 cursor-pointer select-none items-center justify-center whitespace-nowrap',
|
||||
'transition-[border-radius,background-color,color,box-shadow,padding,margin] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast',
|
||||
'state-transition-fast',
|
||||
$dimensions,
|
||||
// A composite component (a split button's halves) passes the corners its place needs.
|
||||
$corners ?? $corner.' '.$pressed[$size],
|
||||
@@ -170,13 +183,13 @@
|
||||
$outline => $variant === 'outlined',
|
||||
'hover:shadow-elevation-1' => in_array($variant, ['filled', 'tonal'], true),
|
||||
'shadow-elevation-1 hover:shadow-elevation-2' => $variant === 'elevated',
|
||||
// Below 48px the touch target reaches past the button, as M3 requires.
|
||||
'after:absolute after:top-1/2 after:left-1/2 after:size-full after:min-h-12 after:min-w-12 after:-translate-x-1/2 after:-translate-y-1/2' => in_array($size, ['xs', 'sm'], true),
|
||||
// Below 48px the touch target reaches past the button, as M3 requires (tokens/state.css).
|
||||
'touch-target' => in_array($size, ['xs', 'sm'], true),
|
||||
'disabled:cursor-not-allowed disabled:shadow-none aria-disabled:pointer-events-none aria-disabled:shadow-none',
|
||||
'disabled:bg-on-surface/10 disabled:text-on-surface/38 aria-disabled:bg-on-surface/10 aria-disabled:text-on-surface/38' => $contained,
|
||||
'disabled:text-on-surface/38 aria-disabled:text-on-surface/38' => ! $contained,
|
||||
// The FAB, on a compact window only: an extended FAB in the thumb zone, clear of a bottom bar the layout declares.
|
||||
'max-medium:fixed max-medium:end-4 max-medium:bottom-[calc(var(--material-bottom-bar,0px)+1rem)] max-medium:z-30 max-medium:h-14 max-medium:gap-2 max-medium:px-4 max-medium:rounded-corner-lg max-medium:type-title-md max-medium:bg-primary-container max-medium:text-on-primary-container max-medium:shadow-elevation-3' => $fab,
|
||||
'max-medium:fixed max-medium:end-4 max-medium:bottom-[calc(var(--material-bottom-bar,0px)+var(--material-snackbar-height,0px)+1rem)] max-medium:z-30 max-medium:h-14 max-medium:gap-2 max-medium:px-4 max-medium:rounded-corner-lg max-medium:type-title-md max-medium:bg-primary-container max-medium:text-on-primary-container max-medium:shadow-elevation-3' => $fab,
|
||||
];
|
||||
|
||||
$tag = $isLink ? 'a' : 'button';
|
||||
@@ -206,13 +219,13 @@
|
||||
<{{ $tag }} {{ $attributes }}>
|
||||
@if ($spinnerTarget)
|
||||
<span wire:loading.flex wire:target="{{ $spinnerTarget }}" class="items-center justify-center">
|
||||
<x-livewire-material::loading :class="$iconSize" :label="false" />
|
||||
<x-livewire-material::loading :class="$iconSize.' text-current'" :label="false" />
|
||||
</span>
|
||||
@endif
|
||||
|
||||
@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
|
||||
|
||||
@@ -221,7 +234,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)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{{-- An M3 Expressive FAB menu: a FAB that opens into a short list of related actions.
|
||||
|
||||
<div class="fixed end-4 bottom-4">
|
||||
<div class="fixed end-4 bottom-4 large:end-6 large:bottom-6">
|
||||
<x-fab-menu label="New">
|
||||
<x-fab-menu-item label="Upload files" icon="upload_file" wire:click="uploadFiles" />
|
||||
<x-fab-menu-item label="Upload a folder" icon="drive_folder_upload" wire:click="uploadFolder" />
|
||||
@@ -10,11 +10,18 @@
|
||||
Two to six items. The FAB (`icon`, `add` by default, in `color`'s container) turns into a
|
||||
round close button in the colour itself while the list is open above it, end-aligned; the
|
||||
list is a `popover="auto"` menu with the menu keyboard of `<x-menu>`. `label` names the FAB
|
||||
for screen readers. Give the items the same `color`. Like `<x-menu>`'s, the list is keyed for
|
||||
Livewire, so it stays open through a render of the component around it.
|
||||
and the menu for screen readers, and defaults to "Toggle menu" — the icons inside the button
|
||||
are aria-hidden, so without it the trigger has no name at all. Give the items the same
|
||||
`color`. Like `<x-menu>`'s, the list is keyed for Livewire, so it stays open through a render
|
||||
of the component around it.
|
||||
|
||||
It does not place itself. M3's margin from the edge of the window is 16dp, and 24dp from the
|
||||
`large` class up: `fixed end-4 bottom-4 large:end-6 large:bottom-6`. A window too short for
|
||||
the list scrolls it; the items rise into place as it opens and sink back as it closes.
|
||||
|
||||
FabMenuBaselineTokens (androidx Compose Material 3, Apache-2.0): 56px items, 4px apart, 8px
|
||||
above the close button. --}}
|
||||
above the close button. The FAB's corner and colours morph on the two default springs at once
|
||||
(`state-transition-default`, resources/css/components/actions.css). --}}
|
||||
|
||||
@props([
|
||||
'icon' => 'add',
|
||||
@@ -24,6 +31,10 @@
|
||||
])
|
||||
|
||||
@php
|
||||
// The trigger's only content is two icons, which are aria-hidden, so without a name it is an
|
||||
// unnamed button. M3 asks the FAB's label to say what menu it opens; "Toggle menu" is its own
|
||||
// fallback wording for the close button.
|
||||
$label = filled($label) ? $label : __('Toggle menu');
|
||||
$color = in_array($color, ['primary', 'secondary', 'tertiary'], true) ? $color : 'primary';
|
||||
$key = \Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10));
|
||||
$anchor = "--material-fab-menu-{$key}";
|
||||
@@ -43,15 +54,15 @@
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@if ($label) aria-label="{{ $label }}" @endif
|
||||
aria-label="{{ $label }}"
|
||||
@class([
|
||||
'group/fab state-layer focus-ring inline-flex size-14 shrink-0 cursor-pointer items-center justify-center rounded-corner-lg shadow-elevation-3 hover:shadow-elevation-4',
|
||||
'transition-[border-radius,background-color,color,box-shadow] duration-(--md-sys-motion-spatial-default-duration) ease-spatial-default aria-expanded:rounded-corner-full',
|
||||
'state-transition-default aria-expanded:rounded-corner-full',
|
||||
$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>
|
||||
|
||||
@@ -61,17 +72,23 @@
|
||||
id="material-fab-menu-{{ $key }}"
|
||||
popover="auto"
|
||||
role="menu"
|
||||
@if ($label) aria-label="{{ $label }}" @endif
|
||||
data-fab-menu
|
||||
aria-label="{{ $label }}"
|
||||
tabindex="-1"
|
||||
style="position-anchor: {{ $anchor }}"
|
||||
x-on:keydown="navigate($event)"
|
||||
x-on:click="activate($event)"
|
||||
@class([
|
||||
'm-0 flex-col gap-1 overflow-visible border-0 bg-transparent p-0 open:flex [inset:auto]',
|
||||
'mb-2 items-end [position-area:top_span-left]' => $position === 'top-end',
|
||||
'mb-2 items-start [position-area:top_span-right]' => $position === 'top-start',
|
||||
'mt-2 items-end [position-area:bottom_span-left]' => $position === 'bottom-end',
|
||||
'mt-2 items-start [position-area:bottom_span-right]' => $position === 'bottom-start',
|
||||
'm-0 flex-col gap-1 border-0 bg-transparent p-2 open:flex [inset:auto]',
|
||||
// A landscape phone has no room for six 56px items: the list scrolls, the FAB below it
|
||||
// does not. The 8px padding is the gap M3 leaves above the FAB and the room the items'
|
||||
// elevation needs inside the scroll box; the negative margins put it back where it was.
|
||||
'max-h-[calc(100dvh-8rem)] overflow-y-auto',
|
||||
'transition-[display,overlay] transition-discrete duration-(--md-sys-motion-spatial-fast-duration)',
|
||||
'-mx-2 -mt-2 items-end [position-area:top_span-left]' => $position === 'top-end',
|
||||
'-mx-2 -mt-2 items-start [position-area:top_span-right]' => $position === 'top-start',
|
||||
'-mx-2 -mb-2 items-end [position-area:bottom_span-left]' => $position === 'bottom-end',
|
||||
'-mx-2 -mb-2 items-start [position-area:bottom_span-right]' => $position === 'bottom-start',
|
||||
])
|
||||
>
|
||||
{{ $slot }}
|
||||
|
||||
@@ -9,6 +9,17 @@
|
||||
`<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").
|
||||
|
||||
There is no `disabled`: M3 says never to disable a FAB — "if its action is unavailable,
|
||||
remove the FAB entirely instead" — so hide it (`@if`), or leave it out of the render, rather
|
||||
than greying it out. A form-submit FAB that must not be pressed twice takes the caller's own
|
||||
`wire:loading.attr="disabled"`.
|
||||
|
||||
`data-fab` marks the root, so a place a FAB sits in can draw it its own way: a navigation
|
||||
rail flattens a nested FAB to elevation 0.
|
||||
|
||||
Sizes, corners and elevation from FabBaseline/Medium/LargeTokens and ExtendedFab*Tokens
|
||||
(androidx Compose Material 3, Apache-2.0). --}}
|
||||
|
||||
@@ -21,7 +32,6 @@
|
||||
'link' => null,
|
||||
'external' => false,
|
||||
'tooltip' => null,
|
||||
'disabled' => false,
|
||||
'type' => 'button',
|
||||
])
|
||||
|
||||
@@ -36,7 +46,7 @@
|
||||
: ['primary' => 'bg-primary-container text-on-primary-container', 'secondary' => 'bg-secondary-container text-on-secondary-container', 'tertiary' => 'bg-tertiary-container text-on-tertiary-container'][$color];
|
||||
|
||||
$dimensions = $extended
|
||||
? ['sm' => 'h-14 min-w-14 gap-2 px-4 rounded-corner-lg type-title-md', 'md' => 'h-20 min-w-20 gap-3 px-[26px] rounded-corner-lg-increased type-title-lg', 'lg' => 'h-24 min-w-24 gap-4 px-7 rounded-corner-xl type-headline-sm'][$size]
|
||||
? ['sm' => 'h-14 min-w-20 gap-2 px-4 rounded-corner-lg type-title-md', 'md' => 'h-20 min-w-20 gap-4 px-[26px] rounded-corner-lg-increased type-title-lg', 'lg' => 'h-24 min-w-24 gap-5 px-7 rounded-corner-xl type-headline-sm'][$size]
|
||||
: ['sm' => 'size-14 rounded-corner-lg', 'md' => 'size-20 rounded-corner-lg-increased', 'lg' => 'size-24 rounded-corner-xl'][$size];
|
||||
|
||||
$iconSize = ['sm' => 'size-6', 'md' => 'size-7', 'lg' => 'size-8'][$size];
|
||||
@@ -49,7 +59,6 @@
|
||||
'shadow-elevation-3 hover:shadow-elevation-4 transition-[box-shadow,background-color,color] duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast',
|
||||
$dimensions,
|
||||
$colours,
|
||||
'disabled:cursor-not-allowed disabled:bg-on-surface/10 disabled:text-on-surface/38 disabled:shadow-none',
|
||||
])
|
||||
->merge(array_filter([
|
||||
'href' => $isLink ? $link : null,
|
||||
@@ -57,7 +66,8 @@
|
||||
'rel' => $isLink && $external ? 'noopener' : null,
|
||||
'wire:navigate' => $isLink && ! $external && ! $attributes->has('wire:navigate') ? true : null,
|
||||
'type' => $isLink ? null : $type,
|
||||
'disabled' => ! $isLink && $disabled ? true : null,
|
||||
// The hook a place uses to draw a nested FAB its own way: a rail flattens it to 0dp.
|
||||
'data-fab' => true,
|
||||
'aria-label' => ! $extended && ! $attributes->has('aria-label') ? $tooltip : null,
|
||||
'style' => $anchor ? "anchor-name: {$anchor}" : null,
|
||||
], fn ($value): bool => $value !== null));
|
||||
@@ -65,7 +75,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)
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
bind as on any input, the arrow keys move the choice, and a screen reader announces a group.
|
||||
The chosen segment rounds fully and takes the selected colour; `variant` is `tonal` (the
|
||||
default), `filled` or `outlined`, as for toggle buttons. The segments share the row unless
|
||||
`inline`. An option with `'disabled' => true` greys its own segment.
|
||||
`inline`. An option with `'disabled' => true` greys its own segment. At `xs` and `sm` a
|
||||
segment carries `touch-target` and a 48px minimum width, which M3 asks for by name and tells
|
||||
you never to reduce. Corners move on the spatial spring and colours on the effects one
|
||||
(`state-transition-fast`).
|
||||
|
||||
ReStride's props, kept: `label`, `hint`, `hint-class`, `name` (needed with `x-model`, which
|
||||
names no property), `options`, `option-value`, `option-label`; plus `option-icon`, `size`,
|
||||
@@ -43,9 +46,12 @@
|
||||
$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';
|
||||
// M3: an xs or sm connected segment keeps a 48px target and a 48px minimum width, whatever
|
||||
// its 32px/40px container measures. From md the segment is wider than that on its own.
|
||||
$small = in_array($size, ['xs', 'sm'], true);
|
||||
|
||||
$segment = [
|
||||
'xs' => 'h-8 gap-2 px-3 type-label-lg',
|
||||
'xs' => 'h-8 gap-2 px-4 type-label-lg',
|
||||
'sm' => 'h-10 gap-2 px-4 type-label-lg',
|
||||
'md' => 'h-14 gap-2 px-6 type-title-md',
|
||||
'lg' => 'h-24 gap-3 px-12 type-headline-sm',
|
||||
@@ -57,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',
|
||||
@@ -74,8 +82,9 @@
|
||||
@foreach ($options as $option)
|
||||
<label @class([
|
||||
'state-layer relative flex cursor-pointer select-none items-center justify-center whitespace-nowrap',
|
||||
'transition-[border-radius,background-color,color] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast',
|
||||
'min-w-0 flex-1' => ! $inline,
|
||||
'state-transition-fast',
|
||||
'flex-1' => ! $inline,
|
||||
$small ? 'touch-target min-w-12' : 'min-w-0',
|
||||
$segment,
|
||||
$colours,
|
||||
'has-focus-visible:outline-3 has-focus-visible:outline-offset-2 has-focus-visible:outline-secondary',
|
||||
@@ -91,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>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
@props(['label'])
|
||||
|
||||
<div role="group" aria-label="{{ $label }}" {{ $attributes->class('py-1 first:pt-0 last:pb-0') }}>
|
||||
<div aria-hidden="true" class="px-3 pt-2 pb-1 type-label-lg text-on-surface-variant">{{ $label }}</div>
|
||||
<div aria-hidden="true" class="px-4 pt-2 pb-1 type-label-lg text-on-surface-variant">{{ $label }}</div>
|
||||
|
||||
{{ $slot }}
|
||||
</div>
|
||||
|
||||
@@ -4,11 +4,13 @@
|
||||
under the label and a `shortcut` at the end (M3's trailing supporting text: "⌘C"). `link`
|
||||
makes it an anchor, with `wire:navigate` unless `external` or `no-wire-navigate`. `selected`
|
||||
(true or false) makes it a `menuitemcheckbox` with `aria-checked`; a selected item takes
|
||||
Expressive's selected shape and tertiary-container. `current` is for a menu of places rather
|
||||
Expressive's selected shape and tertiary-container, and a `check` at its end unless it has an
|
||||
`icon-right` of its own — M3 asks for a cue beyond the colour and the shape. `current` is for a menu of places rather
|
||||
than choices — a section picker — and marks the page you are on: `aria-current="page"`, the
|
||||
selected shape in secondary-container, the colour M3 gives the navigation indicator. `badge`
|
||||
draws `<x-badge>` at the end of the row: `true` for a dot, or a count. `disabled` keeps it in the list, out of
|
||||
reach. `keep-open` leaves the menu open when it is activated — for a choice the person may
|
||||
draws `<x-badge>` at the end of the row: `true` for a dot, or a count. `disabled` keeps it in the list, reachable by the
|
||||
keyboard but not selectable — M3 keeps a disabled item focusable so a person can find out it
|
||||
is there. `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
|
||||
@@ -17,8 +19,12 @@
|
||||
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
|
||||
12px at the ends of the list. --}}
|
||||
48px tall, body-large label, 20px icons, 16px either side (SegmentedMenuTokens' Expressive
|
||||
spacing), 4px corners that open to 12px at the ends of the list — the corner on the spatial
|
||||
spring and the colour on the effects one (`state-transition-fast`). The row is 48px rather
|
||||
than SegmentedMenuTokens' 44px: M3's menu page publishes 48dp and asks for ≥48×48 targets
|
||||
inside an item's slots, and the library's own select and choices menus are 48px, so the more
|
||||
binding source wins over the more specific one. --}}
|
||||
|
||||
@props([
|
||||
'label' => null,
|
||||
@@ -43,9 +49,9 @@
|
||||
|
||||
$attributes = $attributes
|
||||
->class([
|
||||
'group/item state-layer flex w-full min-h-11 cursor-pointer items-center gap-3 px-3 text-start outline-none',
|
||||
'group/item state-layer flex w-full min-h-12 cursor-pointer items-center gap-3 px-4 text-start outline-none',
|
||||
'rounded-corner-xs first:rounded-t-corner-md last:rounded-b-corner-md',
|
||||
'transition-[border-radius,background-color] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast',
|
||||
'state-transition-fast',
|
||||
'focus-visible:outline-3 focus-visible:-outline-offset-3 focus-visible:outline-secondary',
|
||||
'py-2' => filled($description),
|
||||
'rounded-corner-md bg-tertiary-container text-on-tertiary-container' => $selected === true,
|
||||
@@ -84,7 +90,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">
|
||||
@@ -103,6 +109,9 @@
|
||||
@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" />
|
||||
@elseif ($selected === true)
|
||||
{{-- The third cue M3 recommends, so a chosen item is not told by colour and shape alone. --}}
|
||||
<x-livewire-material::icon name="check" optical="20" :class="'size-5 '.$iconInk" />
|
||||
@endif
|
||||
</{{ $tag }}>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
{{-- A line between groups of items in an `<x-menu>`. --}}
|
||||
{{-- A line between groups of items in an `<x-menu>`: 1px, with M3's 8px above and below it, inset
|
||||
to the 16px the items keep either side. --}}
|
||||
|
||||
<hr role="separator" {{ $attributes->class('mx-3 my-1 h-px border-0 bg-outline-variant') }} />
|
||||
<hr role="separator" {{ $attributes->class('mx-4 my-2 h-px border-0 bg-outline-variant') }} />
|
||||
|
||||
@@ -32,6 +32,14 @@
|
||||
of the loop iteration around it, which would give every child component after the menu the
|
||||
same key.
|
||||
|
||||
A menu too long for the window scrolls, as M3 asks, rather than running off the edge of the
|
||||
top layer where nothing can reach it: 18rem at most, and less on a short window. The arrow
|
||||
keys, Home, End and typeahead bring the item they move to into view, and a disabled item is
|
||||
among them: M3 keeps one reachable so a person can find out that it exists.
|
||||
|
||||
It opens by growing out of the corner nearest its trigger and fades as it goes
|
||||
(`popover-transition`), which is the transition M3 asks to tie a menu to what opened it.
|
||||
|
||||
The container is Expressive's standard menu (surface-container-low, 16px corner, elevation
|
||||
2), or `vibrant` in tertiary-container — StandardMenuTokens and VibrantMenuTokens from
|
||||
androidx Compose Material 3 (Apache-2.0). --}}
|
||||
@@ -67,15 +75,15 @@
|
||||
x-on:keydown="navigate($event)"
|
||||
x-on:click="activate($event)"
|
||||
@class([
|
||||
'm-0 min-w-28 max-w-70 overflow-visible border-0 p-1 rounded-corner-lg shadow-elevation-2 [inset:auto]',
|
||||
'm-0 min-w-28 max-w-70 max-h-[min(18rem,calc(100dvh-2rem))] overflow-y-auto border-0 p-1 rounded-corner-lg shadow-elevation-2 [inset:auto]',
|
||||
'my-1 [position-try-fallbacks:flip-block,flip-inline,flip-block_flip-inline]',
|
||||
'opacity-0 transition-[opacity,translate,display,overlay] transition-discrete duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast open:opacity-100 starting:open:opacity-0',
|
||||
'popover-transition',
|
||||
'bg-surface-container-low text-on-surface' => ! $vibrant,
|
||||
'bg-tertiary-container text-on-tertiary-container' => $vibrant,
|
||||
'[position-area:bottom_span-right]' => $position === 'bottom-start',
|
||||
'[position-area:bottom_span-left]' => $position === 'bottom-end',
|
||||
'[position-area:top_span-right]' => $position === 'top-start',
|
||||
'[position-area:top_span-left]' => $position === 'top-end',
|
||||
'origin-top [position-area:bottom_span-right]' => $position === 'bottom-start',
|
||||
'origin-top [position-area:bottom_span-left]' => $position === 'bottom-end',
|
||||
'origin-bottom [position-area:top_span-right]' => $position === 'top-start',
|
||||
'origin-bottom [position-area:top_span-left]' => $position === 'top-end',
|
||||
])
|
||||
>
|
||||
{{ $slot }}
|
||||
|
||||
@@ -15,6 +15,12 @@
|
||||
action's `wire:click` too — and resources/js/rich-tooltip.js keeps showing and hiding the
|
||||
element on the page rather than one the morph took away.
|
||||
|
||||
resources/js/rich-tooltip.js points the trigger at the bubble with `aria-describedby`, and a
|
||||
`persistent` one also carries `aria-haspopup="dialog"` and `aria-expanded`: the explanation is
|
||||
the whole point of a rich tooltip, and without the association a screen reader reads only the
|
||||
trigger's own label. The trigger arrives in a slot, so only script can find the element in it
|
||||
that takes the focus.
|
||||
|
||||
RichTooltipTokens (androidx Compose Material 3, Apache-2.0): title-small subhead and body-medium
|
||||
text in on-surface-variant, label-large actions in primary. --}}
|
||||
|
||||
|
||||
@@ -13,7 +13,11 @@
|
||||
|
||||
Padding from SplitButton*Tokens (androidx Compose Material 3, Apache-2.0): the leading button
|
||||
keeps less room on its inner side at the two smallest sizes, and the trailing button is 48px
|
||||
wide there. The corners are resources/css/components/groups.css. --}}
|
||||
wide there. resources/css/components/groups.css carries the rest of the spec, which is all
|
||||
geometry: the inner corners (4/4/4/8/12px, growing under the finger, not shrinking as a
|
||||
connected group's do), the trailing icon's own size (22/22/26/38/50px, which the icon-button
|
||||
table has no entry for), the 1/1/2/3/6px nudge that sits the chevron towards the leading half,
|
||||
and the standard motion scheme M3 asks for while it turns over. --}}
|
||||
|
||||
@props([
|
||||
'label' => null,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -7,32 +7,50 @@
|
||||
dispatches from a Livewire component — and for `window.materialToast(title, options)` from
|
||||
JavaScript (`{ type, description, timeout, sticky, action: { label, handler, event } }`).
|
||||
Toasts queue and show in turn, each for its `timeout` (4s by default; M3 asks for 4–10s),
|
||||
paused while the pointer or focus is on it. A toast with an action or no timeout gets a close
|
||||
button. Pressing the action closes the snackbar, calls `handler` and dispatches `event` (a
|
||||
name) on `window`; both may be given.
|
||||
paused while the pointer or focus is on it. A toast with an `action` has no timeout at all,
|
||||
as M3 requires — it waits to be read and acted on — unless the caller writes a `timeout` out.
|
||||
A toast with an action or no timeout gets a close button. Pressing the action closes the
|
||||
snackbar, calls `handler` and dispatches `event` (a name) on `window`; both may be given.
|
||||
|
||||
`sticky: true` keeps a toast until it is dismissed or its action pressed, without holding up
|
||||
the queue: a toast that arrives meanwhile shows in its place, and the sticky one comes back
|
||||
once the queue is empty. One is kept at a time; a newer sticky toast replaces it.
|
||||
|
||||
The live region is the host itself, not the snackbar: a region must be in the page before its
|
||||
contents change for a screen reader to announce them reliably, and the snackbar comes and
|
||||
goes. It is `aria-live="polite" aria-atomic="true"`, as M3 asks for a snackbar (never
|
||||
assertive); a `type` picks the region's role — `alert` for an error or a warning, `status`
|
||||
otherwise — and the explicit `aria-live` keeps even those polite.
|
||||
|
||||
Hooks for tests and styling: `data-toast` on the snackbar on screen, `data-toast-action` on its
|
||||
action button.
|
||||
|
||||
`@persist` keeps the host across wire:navigate, so a toast dispatched with `redirectTo` is
|
||||
still on screen when the next page arrives.
|
||||
|
||||
There is no state icon: M3 says to avoid one in a snackbar ("use a dialog instead if an icon
|
||||
feels necessary"), and both lines of the message are plain inverse-on-surface — M3 gives the
|
||||
supporting text no fourth colour and tells the two lines apart by position. The 40px action
|
||||
and close buttons carry `touch-target`, which reaches M3's 48px without growing the container.
|
||||
Escape dismisses a snackbar that holds the focus.
|
||||
|
||||
M3's snackbar (SnackbarTokens, androidx Compose Material 3, Apache-2.0): inverse surface,
|
||||
body-medium text, a label-large action in inverse-primary, extra-small corners, elevation 3,
|
||||
48px for one line. A type draws its state icon in the inverse state colour. `position`:
|
||||
`bottom` (centred, the default) or `bottom-start`. It lifts above a bottom bar through
|
||||
`--material-bottom-bar`. A compact window (below `medium`, 600px) gets the full-width snackbar;
|
||||
from `medium` it hugs its line length instead, as M3 asks. --}}
|
||||
48px for one line. `position`: `bottom` (centred, the default) or `bottom-start`. It lifts
|
||||
above a bottom bar through `--material-bottom-bar`, and publishes its own height as
|
||||
`--material-snackbar-height` so a FAB can lift clear of it — M3: a snackbar appears above a
|
||||
FAB, never in front of or behind one. A compact window (below `medium`, 600px) gets the
|
||||
full-width snackbar; from `medium` it hugs its line length instead, as M3 asks. --}}
|
||||
|
||||
@props(['position' => 'bottom'])
|
||||
|
||||
@persist('material-toast')
|
||||
<div
|
||||
x-data="materialSnackbar"
|
||||
x-bind:role="current && (current.type === 'error' || current.type === 'warning') ? 'alert' : 'status'"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
{{ $attributes->class([
|
||||
'pointer-events-none fixed inset-x-4 z-50 flex bottom-[calc(var(--material-bottom-bar,0px)+1rem)]',
|
||||
'justify-center' => $position !== 'bottom-start',
|
||||
@@ -43,40 +61,24 @@
|
||||
<div
|
||||
x-bind:key="current.id"
|
||||
data-toast
|
||||
x-bind:role="current.type === 'error' || current.type === 'warning' ? 'alert' : 'status'"
|
||||
aria-live="polite"
|
||||
x-on:mouseenter="pause()"
|
||||
x-on:mouseleave="resume()"
|
||||
x-on:focusin="pause()"
|
||||
x-on:focusout="resume()"
|
||||
class="pointer-events-auto flex min-h-12 w-full max-w-[min(100%,36rem)] items-center gap-3 rounded-corner-xs bg-inverse-surface py-1.5 ps-4 pe-2 text-inverse-on-surface shadow-elevation-3 transition-[translate,opacity] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast starting:translate-y-4 starting:opacity-0 medium:w-auto medium:min-w-86"
|
||||
>
|
||||
<template x-if="icon(current.type)">
|
||||
<span class="flex shrink-0" x-bind:class="{
|
||||
'text-inverse-success': current.type === 'success',
|
||||
'text-inverse-error': current.type === 'error',
|
||||
'text-inverse-warning': current.type === 'warning',
|
||||
'text-inverse-info': current.type === 'info',
|
||||
}">
|
||||
<span x-show="current.type === 'success'"><x-livewire-material::icon name="check_circle" filled class="size-6" /></span>
|
||||
<span x-show="current.type === 'error'"><x-livewire-material::icon name="error" filled class="size-6" /></span>
|
||||
<span x-show="current.type === 'warning'"><x-livewire-material::icon name="warning" filled class="size-6" /></span>
|
||||
<span x-show="current.type === 'info'"><x-livewire-material::icon name="info" filled class="size-6" /></span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div class="min-w-0 flex-1 py-1.5">
|
||||
<p class="type-body-md" x-text="current.title"></p>
|
||||
<p class="type-body-md opacity-80" x-show="current.description" x-text="current.description"></p>
|
||||
<p class="type-body-md" x-show="current.description" x-text="current.description"></p>
|
||||
</div>
|
||||
|
||||
<template x-if="current.action">
|
||||
<button type="button" data-toast-action class="state-layer focus-ring h-10 shrink-0 rounded-corner-full px-3 type-label-lg text-inverse-primary" x-text="current.action.label" x-on:click="act()"></button>
|
||||
<button type="button" data-toast-action class="state-layer focus-ring touch-target h-10 shrink-0 rounded-corner-full px-3 type-label-lg text-inverse-primary" x-text="current.action.label" x-on:click="act()"></button>
|
||||
</template>
|
||||
|
||||
<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" />
|
||||
<button type="button" class="state-layer focus-ring touch-target 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" optical="20" class="size-5" />
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
|
||||
<x-tooltip text="Copy link"><button …>…</button></x-tooltip>
|
||||
|
||||
It shows after a short hover on a pointer that can hover, at once on keyboard focus, and
|
||||
hides on leave, blur, press and Escape. A phone has no hover, so a control there carries its
|
||||
It shows after a short hover on a pointer that can hover, at once on keyboard focus, and hides
|
||||
at once on a press or Escape; leaving or blurring lets it stand for M3's 1.5s. Only one
|
||||
tooltip is on screen at a time, as M3 asks. A phone has no hover, so a control there carries its
|
||||
words. The bubble is a `popover="manual"` in the top layer — never clipped by an
|
||||
`overflow-hidden` parent, never widening a scroll container — placed by CSS anchor
|
||||
positioning on `side` (`top`, `bottom`, `left`, `right`), flipping when there is no room.
|
||||
|
||||
Reference in New Issue
Block a user