Draw the connected choice group without Tailwind
Plan step 36. <x-group> renders data-md-group with its size, variant, shape, multiple and inline, a data-md-button-group row and data-md-group-segment labels over its radios or checkboxes. group.css draws the segments as M3's buttons with the toggle button's colours from :has(:checked), the disabled treatment from :has(:disabled), the focus ring from :has(:focus-visible) and the 48px target at xs and sm. The connected shapes move into button-group.css (the corner scale, square ends, the pressed and selected inner corners), which group.css imports; <x-button-group> adopts them in its own rewrite. The group's render tests move to GroupTest; AppBarTest (navigation group) follows the new legend and row hooks of theme-toggle's rows. 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
4ad257034e
commit
b8c6d411db
@@ -16,6 +16,8 @@
|
||||
@import './components/tooltip.css';
|
||||
@import './components/badge.css';
|
||||
@import './components/button.css';
|
||||
@import './components/group.css';
|
||||
@import './components/button-group.css';
|
||||
|
||||
/* Inputs, selection and data */
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* <x-button-group>'s shapes: the corners a button takes from its place among its siblings. The
|
||||
* connected form is also what `<x-group>` draws its segments with, which is why its stylesheet
|
||||
* imports this one.
|
||||
*
|
||||
* Connected (`data-md-button-group="connected"`): 2px apart, the outer corners full, the inner
|
||||
* corners small, smaller still while pressed, and a selected segment (`aria-pressed`, or a checked
|
||||
* input inside it) fully round — ConnectedButtonGroupXSmall…XLargeTokens and the M3 Expressive
|
||||
* connected button group spec: inner corners 4/8/8/16/20px, pressed 2/4/4/12/16px. At the two
|
||||
* smallest sizes a segment is 32px or 40px tall, and M3 names both numbers it must not go below:
|
||||
* "XS and S connected button groups have a 48dp target area and a 48dp minimum width".
|
||||
*
|
||||
* Shape (`data-md-shape="square"`, M3's "Default shape | Round, square" configuration):
|
||||
* `--md-button-group-outer` is what the two ends of a group round to — half the height for a round
|
||||
* group, and for a square one the corner its inner edges take, which is the square table M3
|
||||
* publishes for connected groups (4/8/8/16/20dp by size). Selection morphs the other way: M3 has a
|
||||
* toggle inside a group "swap shape square/round on selection", so a selected segment in a square
|
||||
* group turns round, which is why the selected rule keeps `--md-button-group-full`.
|
||||
*
|
||||
* "Full" is half the size's height (16/20/28/48/68px), never `--md-sys-shape-corner-full`'s
|
||||
* 9999px. One element mixes full outer corners with small inner ones, and when a box's radii add
|
||||
* up to more than its side, CSS scales every radius by the same factor: a 9999px corner beside an
|
||||
* 8px one shrank the 8px one to a hundredth of a pixel, so the inner corners drew square.
|
||||
*
|
||||
* <x-button>'s own corner rules carry no specificity, so these reshape the buttons in a group
|
||||
* whatever selector they use; a pressed or selected segment changes one custom property and the
|
||||
* outer corners stay put.
|
||||
*/
|
||||
|
||||
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
||||
|
||||
@layer material.components {
|
||||
[data-md-button-group] {
|
||||
--md-button-group-inner: var(--md-sys-shape-corner-sm);
|
||||
--md-button-group-inner-pressed: var(--md-sys-shape-corner-xs);
|
||||
--md-button-group-full: 20px;
|
||||
--md-button-group-outer: var(--md-button-group-full);
|
||||
}
|
||||
|
||||
[data-md-button-group][data-md-size='xs'] {
|
||||
--md-button-group-inner: var(--md-sys-shape-corner-xs);
|
||||
--md-button-group-inner-pressed: 2px;
|
||||
--md-button-group-full: 16px;
|
||||
}
|
||||
|
||||
[data-md-button-group][data-md-size='md'] {
|
||||
--md-button-group-full: 28px;
|
||||
}
|
||||
|
||||
[data-md-button-group][data-md-size='lg'] {
|
||||
--md-button-group-inner: var(--md-sys-shape-corner-lg);
|
||||
--md-button-group-inner-pressed: var(--md-sys-shape-corner-md);
|
||||
--md-button-group-full: 48px;
|
||||
}
|
||||
|
||||
[data-md-button-group][data-md-size='xl'] {
|
||||
--md-button-group-inner: var(--md-sys-shape-corner-lg-increased);
|
||||
--md-button-group-inner-pressed: var(--md-sys-shape-corner-lg);
|
||||
--md-button-group-full: 68px;
|
||||
}
|
||||
|
||||
/* A square group's ends take the corner its inner edges take. */
|
||||
[data-md-button-group][data-md-shape='square'] {
|
||||
--md-button-group-outer: var(--md-button-group-inner);
|
||||
}
|
||||
|
||||
[data-md-button-group='connected'] {
|
||||
gap: var(--md-sys-measurement-space25);
|
||||
}
|
||||
|
||||
[data-md-button-group='connected']:is([data-md-size='xs'], [data-md-size='sm']) > * {
|
||||
min-inline-size: var(--md-sys-measurement-space600);
|
||||
}
|
||||
|
||||
[data-md-button-group='connected'] > * {
|
||||
--md-button-group-corner: var(--md-button-group-inner);
|
||||
|
||||
border-start-start-radius: var(--md-button-group-corner);
|
||||
border-end-start-radius: var(--md-button-group-corner);
|
||||
border-start-end-radius: var(--md-button-group-corner);
|
||||
border-end-end-radius: var(--md-button-group-corner);
|
||||
}
|
||||
|
||||
[data-md-button-group='connected'] > :active {
|
||||
--md-button-group-corner: var(--md-button-group-inner-pressed);
|
||||
}
|
||||
|
||||
[data-md-button-group='connected'] > :is([aria-pressed='true'], :has(:checked)) {
|
||||
--md-button-group-corner: var(--md-button-group-full);
|
||||
}
|
||||
|
||||
[data-md-button-group='connected'] > :first-child {
|
||||
border-start-start-radius: var(--md-button-group-outer);
|
||||
border-end-start-radius: var(--md-button-group-outer);
|
||||
}
|
||||
|
||||
[data-md-button-group='connected'] > :last-child {
|
||||
border-start-end-radius: var(--md-button-group-outer);
|
||||
border-end-end-radius: var(--md-button-group-outer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
/*
|
||||
* <x-group>: a choice of a few options drawn as M3 Expressive's connected button group, the
|
||||
* successor of the segmented button — native radios (checkboxes with `data-md-multiple`) under
|
||||
* segments that share the row, or hug their labels with `data-md-inline`.
|
||||
*
|
||||
* The segments are M3's buttons at the group's `data-md-size` (Button*Tokens: 32/40/56/96/136px
|
||||
* tall, 16/16/24/48/64px either side, 8/8/8/12/16px between icon and label, label-large, label-
|
||||
* large, title-medium, headline-small, headline-large), with the connected shape from
|
||||
* button-group.css: 2px apart, small inner corners, the chosen segment fully round. At `xs` and
|
||||
* `sm` a segment carries M3's 48px target and a 48px minimum width, which M3 asks for by name.
|
||||
*
|
||||
* `data-md-variant` is the toggle button's colours: `tonal` (secondary-container, chosen in
|
||||
* secondary), `filled` (surface-container behind on-surface-variant, chosen in primary) or
|
||||
* `outlined` (an outline-variant edge, chosen in inverse-surface). A checked input picks the
|
||||
* chosen colours (`:has(:checked)`), a disabled one the disabled treatment — on-surface at 10%
|
||||
* behind 38% text — and keyboard focus on the input draws M3's focus ring around its segment.
|
||||
* Corners move on the fast spatial spring and colours on the fast effects spring beside it.
|
||||
*
|
||||
* The legend is label-large and the hint body-small, both in on-surface-variant; a validation
|
||||
* message takes the hint's place in error. `hint-class` lands on the hint, where a caller's class
|
||||
* outranks the colour here.
|
||||
*/
|
||||
|
||||
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
||||
|
||||
@import './icon.css';
|
||||
@import './button-group.css';
|
||||
|
||||
@layer material.components {
|
||||
[data-md-group] {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
[data-md-group-legend] {
|
||||
margin-block-end: var(--md-sys-measurement-space100);
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
font: var(--md-sys-typescale-label-lg);
|
||||
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
|
||||
font-variation-settings: normal;
|
||||
}
|
||||
|
||||
[data-md-group] > [data-md-button-group] {
|
||||
display: flex;
|
||||
align-items: normal;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
[data-md-group][data-md-inline] > [data-md-button-group] {
|
||||
inline-size: fit-content;
|
||||
}
|
||||
|
||||
[data-md-group-segment] {
|
||||
--md-group-container: var(--md-sys-color-secondary-container);
|
||||
--md-group-label: var(--md-sys-color-on-secondary-container);
|
||||
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
display: flex;
|
||||
flex: 1 1 0%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-inline-size: 0;
|
||||
background-color: var(--md-group-container);
|
||||
color: var(--md-group-label);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
transition-property: border-radius, padding, margin, bottom, background-color, color, box-shadow;
|
||||
transition-duration:
|
||||
var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-duration),
|
||||
var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-duration),
|
||||
var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-effects-fast-duration);
|
||||
transition-timing-function:
|
||||
var(--md-sys-motion-spatial-fast), var(--md-sys-motion-spatial-fast),
|
||||
var(--md-sys-motion-spatial-fast), var(--md-sys-motion-spatial-fast),
|
||||
var(--md-sys-motion-effects-fast), var(--md-sys-motion-effects-fast), var(--md-sys-motion-effects-fast);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
border-radius: inherit;
|
||||
background-color: currentColor;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover::before {
|
||||
opacity: var(--md-sys-state-hover-state-layer-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
&:has(:focus-visible) {
|
||||
outline: 3px solid var(--md-sys-color-secondary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:active::before {
|
||||
opacity: var(--md-sys-state-pressed-state-layer-opacity);
|
||||
}
|
||||
|
||||
/* The radio or checkbox stays in the page, for the form, the keyboard and a screen reader. */
|
||||
& > input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-md-group][data-md-inline] [data-md-group-segment] {
|
||||
flex: initial;
|
||||
}
|
||||
|
||||
[data-md-group]:is([data-md-size='xs'], [data-md-size='sm']) [data-md-group-segment] {
|
||||
min-inline-size: var(--md-sys-measurement-space600);
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: var(--md-sys-measurement-space600);
|
||||
min-height: var(--md-sys-measurement-space600);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
[data-md-group][data-md-size='xs'] [data-md-group-segment] {
|
||||
gap: var(--md-sys-measurement-space100);
|
||||
block-size: 32px;
|
||||
padding-inline: var(--md-sys-measurement-space200);
|
||||
font: var(--md-sys-typescale-label-lg);
|
||||
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
|
||||
font-variation-settings: normal;
|
||||
}
|
||||
|
||||
[data-md-group][data-md-size='sm'] [data-md-group-segment] {
|
||||
gap: var(--md-sys-measurement-space100);
|
||||
block-size: 40px;
|
||||
padding-inline: var(--md-sys-measurement-space200);
|
||||
font: var(--md-sys-typescale-label-lg);
|
||||
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
|
||||
font-variation-settings: normal;
|
||||
}
|
||||
|
||||
[data-md-group][data-md-size='md'] [data-md-group-segment] {
|
||||
gap: var(--md-sys-measurement-space100);
|
||||
block-size: 56px;
|
||||
padding-inline: var(--md-sys-measurement-space300);
|
||||
font: var(--md-sys-typescale-title-md);
|
||||
letter-spacing: var(--md-sys-typescale-title-md-tracking);
|
||||
font-variation-settings: normal;
|
||||
}
|
||||
|
||||
[data-md-group][data-md-size='lg'] [data-md-group-segment] {
|
||||
gap: 12px;
|
||||
block-size: 96px;
|
||||
padding-inline: var(--md-sys-measurement-space600);
|
||||
font: var(--md-sys-typescale-headline-sm);
|
||||
letter-spacing: var(--md-sys-typescale-headline-sm-tracking);
|
||||
font-variation-settings: normal;
|
||||
}
|
||||
|
||||
[data-md-group][data-md-size='xl'] [data-md-group-segment] {
|
||||
gap: var(--md-sys-measurement-space200);
|
||||
block-size: 136px;
|
||||
padding-inline: var(--md-sys-measurement-space800);
|
||||
font: var(--md-sys-typescale-headline-lg);
|
||||
letter-spacing: var(--md-sys-typescale-headline-lg-tracking);
|
||||
font-variation-settings: normal;
|
||||
}
|
||||
|
||||
[data-md-group-segment]:has(:checked) {
|
||||
--md-group-container: var(--md-sys-color-secondary);
|
||||
--md-group-label: var(--md-sys-color-on-secondary);
|
||||
}
|
||||
|
||||
[data-md-group][data-md-variant='filled'] [data-md-group-segment] {
|
||||
--md-group-container: var(--md-sys-color-surface-container);
|
||||
--md-group-label: var(--md-sys-color-on-surface-variant);
|
||||
|
||||
&:has(:checked) {
|
||||
--md-group-container: var(--md-sys-color-primary);
|
||||
--md-group-label: var(--md-sys-color-on-primary);
|
||||
}
|
||||
}
|
||||
|
||||
[data-md-group][data-md-variant='outlined'] [data-md-group-segment] {
|
||||
--md-group-container: transparent;
|
||||
--md-group-label: var(--md-sys-color-on-surface-variant);
|
||||
|
||||
border: 1px solid var(--md-sys-color-outline-variant);
|
||||
|
||||
&:has(:checked) {
|
||||
--md-group-container: var(--md-sys-color-inverse-surface);
|
||||
--md-group-label: var(--md-sys-color-inverse-on-surface);
|
||||
|
||||
border-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
[data-md-group-segment]:has(:disabled) {
|
||||
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent);
|
||||
color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
[data-md-group-label] {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-md-group-hint],
|
||||
[data-md-group-error] {
|
||||
margin-block-start: var(--md-sys-measurement-space50);
|
||||
font: var(--md-sys-typescale-body-sm);
|
||||
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
|
||||
font-variation-settings: normal;
|
||||
}
|
||||
|
||||
[data-md-group-hint] {
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
}
|
||||
|
||||
[data-md-group-error] {
|
||||
color: var(--md-sys-color-error);
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,8 @@
|
||||
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. 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`).
|
||||
segment keeps a 48px 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.
|
||||
|
||||
`shape` is M3's "Default shape | Round, square" configuration: `square` squares the two ends
|
||||
of the group to the corner its inner edges take (M3's square table, 4/8/8/16/20dp by size),
|
||||
@@ -25,9 +24,11 @@
|
||||
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. --}}
|
||||
(`hint-class="md-ink-warning"` for a hint that warns), because a caller's class outranks the
|
||||
package's layer.
|
||||
|
||||
Drawn by resources/css/components/group.css, with the connected shapes of
|
||||
resources/css/components/button-group.css. --}}
|
||||
|
||||
@props([
|
||||
'label' => null,
|
||||
@@ -52,76 +53,54 @@
|
||||
$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';
|
||||
$variant = in_array($variant, ['tonal', 'filled', 'outlined'], true) ? $variant : 'tonal';
|
||||
// M3's "Default shape | Round, square": a square group's ends take the corner its inner edges
|
||||
// take; the chosen segment still rounds fully, which is the cue selection has always carried.
|
||||
$shape = $shape === 'square' ? 'square' : 'round';
|
||||
// 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-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',
|
||||
'xl' => 'h-34 gap-4 px-16 type-headline-lg',
|
||||
][$size];
|
||||
// A 20px glyph comes from M3's 20px cut, which the icon component picks from the size.
|
||||
$iconSize = ['xs' => 20, 'sm' => 20, 'md' => 24, 'lg' => 32, 'xl' => 40][$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];
|
||||
// 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',
|
||||
'outlined' => 'border border-outline-variant text-on-surface-variant has-checked:border-transparent has-checked:bg-inverse-surface has-checked:text-inverse-on-surface',
|
||||
default => 'bg-secondary-container text-on-secondary-container has-checked:bg-secondary has-checked:text-on-secondary',
|
||||
};
|
||||
$root = $attributes->only(['class', 'wire:key'])->merge(array_filter([
|
||||
'data-md-group' => true,
|
||||
'data-md-size' => $size,
|
||||
'data-md-variant' => $variant,
|
||||
'data-md-shape' => $shape,
|
||||
'data-md-multiple' => $multiple ? true : null,
|
||||
'data-md-inline' => $inline ? true : null,
|
||||
], fn ($value): bool => $value !== null));
|
||||
@endphp
|
||||
|
||||
<fieldset {{ $attributes->only(['class', 'wire:key'])->class('min-w-0') }}>
|
||||
<fieldset {{ $root }}>
|
||||
@if (filled($label))
|
||||
<legend class="mb-2 type-label-lg text-on-surface-variant">{{ $label }}</legend>
|
||||
<legend data-md-group-legend>{{ $label }}</legend>
|
||||
@endif
|
||||
|
||||
<div data-button-group="connected" data-size="{{ $size }}" data-shape="{{ $shape }}" @class(['flex gap-0.5', 'w-full' => ! $inline, 'w-fit' => $inline])>
|
||||
<div data-md-button-group="connected" data-md-size="{{ $size }}" data-md-shape="{{ $shape }}">
|
||||
@foreach ($options as $option)
|
||||
<label @class([
|
||||
'state-layer relative flex cursor-pointer select-none items-center justify-center whitespace-nowrap',
|
||||
'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',
|
||||
'has-disabled:cursor-not-allowed has-disabled:bg-on-surface/10 has-disabled:text-on-surface/38',
|
||||
])>
|
||||
<label data-md-group-segment>
|
||||
<input
|
||||
{{ $attributes->whereStartsWith(['wire:model', 'x-model']) }}
|
||||
type="{{ $multiple ? 'checkbox' : 'radio' }}"
|
||||
name="{{ $multiple ? $name.'[]' : $name }}"
|
||||
value="{{ data_get($option, $optionValue) }}"
|
||||
@disabled(data_get($option, 'disabled'))
|
||||
class="peer sr-only"
|
||||
/>
|
||||
|
||||
@if (filled(data_get($option, $optionIcon)))
|
||||
<x-livewire-material::icon :name="data_get($option, $optionIcon)" :optical="$optical" :class="$iconSize" />
|
||||
<x-livewire-material::icon :name="data_get($option, $optionIcon)" :size="$iconSize" />
|
||||
@endif
|
||||
|
||||
<span class="truncate">{{ data_get($option, $optionLabel) }}</span>
|
||||
<span data-md-group-label>{{ data_get($option, $optionLabel) }}</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@if ($messages !== [])
|
||||
@foreach ($messages as $message)
|
||||
<p class="mt-1 type-body-sm text-error">{{ $message }}</p>
|
||||
<p data-md-group-error>{{ $message }}</p>
|
||||
@endforeach
|
||||
@elseif (filled($hint))
|
||||
<p class="{{ $hintClasses }}">{{ $hint }}</p>
|
||||
<p data-md-group-hint @if (filled($hintClass)) class="{{ $hintClass }}" @endif>{{ $hint }}</p>
|
||||
@endif
|
||||
</fieldset>
|
||||
|
||||
@@ -15,6 +15,7 @@ dataset('action components', [
|
||||
'tooltip',
|
||||
'badge',
|
||||
'button',
|
||||
'group',
|
||||
]);
|
||||
|
||||
it('draws the component from a stylesheet shaped like every package stylesheet', function (string $name) {
|
||||
@@ -48,7 +49,7 @@ it('writes no class list into the view', function (string $name) {
|
||||
$view = File::get(__DIR__."/../../../resources/views/components/{$name}.blade.php");
|
||||
|
||||
// A class the caller hands in (`hint-class`, `icon-class`, a slot's attributes) passes through.
|
||||
expect($view)->not->toMatch('/\bclass="(?!\{\{ \$)|@class\(|->class\(|toCssClasses|(?:x-bind)?:class=/');
|
||||
expect($view)->not->toMatch('/(?<![\w-])class="(?!\{\{ \$)|@class\(|->class\(|toCssClasses|(?:x-bind)?:class=/');
|
||||
})->with('action components');
|
||||
|
||||
it('takes its values from the tokens and its breakpoints in px', function (string $name) {
|
||||
|
||||
@@ -200,7 +200,7 @@ it('offers M3\'s three contrast levels, marking the one in force', function () {
|
||||
->toContain('data-theme-toggle="contrast"')
|
||||
->toContain('aria-label="Contrast"')
|
||||
// A connected button group over native radios, not the deprecated segmented button (N-04).
|
||||
->toContain('data-button-group="connected"')
|
||||
->toContain('data-md-button-group="connected"')
|
||||
->toContain('name="material-contrast"')
|
||||
->toContain('value="standard"')
|
||||
->toContain('value="medium"')
|
||||
@@ -216,7 +216,7 @@ it('names a theme row for a screen reader and legends it on request', function (
|
||||
->not->toContain('<legend')
|
||||
->and((string) $this->blade('<x-theme-toggle mode="picker" label="Appearance" />'))
|
||||
->toContain('aria-label="Appearance"')
|
||||
->toContain('<legend class="mb-2 type-label-lg text-on-surface-variant">Appearance</legend>');
|
||||
->toContain('<legend data-md-group-legend>Appearance</legend>');
|
||||
});
|
||||
|
||||
it('switches the theme through the store in three shapes', function () {
|
||||
|
||||
@@ -20,7 +20,7 @@ it('connects buttons 2px apart', function () {
|
||||
->toContain('gap-0.5');
|
||||
});
|
||||
|
||||
it('squares a group, and a choice drawn as one', function () {
|
||||
it('squares a group', function () {
|
||||
// M3 lists "Default shape | Round, square" as a button-group configuration; the shape covers
|
||||
// every button in the group, so it need not be written on each one.
|
||||
expect((string) $this->blade('<x-button-group shape="square"><x-button label="Day" /></x-button-group>'))
|
||||
@@ -29,10 +29,6 @@ it('squares a group, and a choice drawn as one', function () {
|
||||
->toContain('data-button-group="connected"')
|
||||
->toContain('data-shape="square"')
|
||||
->and((string) $this->blade('<x-button-group><x-button label="Day" /></x-button-group>'))
|
||||
->toContain('data-shape="round"')
|
||||
->and((string) $this->blade('<x-group shape="square" name="x" :options="[[\'id\' => \'a\', \'name\' => \'A\']]" />'))
|
||||
->toContain('data-shape="square"')
|
||||
->and((string) $this->blade('<x-group name="x" :options="[[\'id\' => \'a\', \'name\' => \'A\']]" />'))
|
||||
->toContain('data-shape="round"');
|
||||
});
|
||||
|
||||
@@ -92,85 +88,3 @@ it('marks icon buttons, which keep their width when a group is pressed', functio
|
||||
expect((string) $this->blade('<x-button icon="format_bold" aria-label="Bold" />'))->toContain('data-md-icon-button')
|
||||
->and((string) $this->blade('<x-button label="Bold" />'))->not->toContain('data-md-icon-button');
|
||||
});
|
||||
|
||||
it('draws a choice as connected radios', function () {
|
||||
$html = (string) $this->blade(<<<'BLADE'
|
||||
<x-group label="Theme" name="theme" x-model="theme" :options="[
|
||||
['id' => 'light', 'name' => 'Light', 'icon' => 'light_mode'],
|
||||
['id' => 'dark', 'name' => 'Dark', 'disabled' => true],
|
||||
]" />
|
||||
BLADE);
|
||||
|
||||
expect($html)
|
||||
->toContain('<legend class="mb-2 type-label-lg text-on-surface-variant">Theme</legend>')
|
||||
->toContain('data-button-group="connected"')
|
||||
->toContain('type="radio"')
|
||||
->toContain('name="theme"')
|
||||
->toContain('x-model="theme"')
|
||||
->toContain('value="light"')
|
||||
->toContain('has-checked:bg-secondary has-checked:text-on-secondary')
|
||||
->toContain('disabled')
|
||||
->and(substr_count($html, '<svg'))->toBe(1);
|
||||
});
|
||||
|
||||
it('keeps a 48px target and a 48px minimum width on the smallest connected segments', function () {
|
||||
$options = [['id' => 'a', 'name' => 'A']];
|
||||
|
||||
expect((string) $this->blade('<x-group size="sm" name="x" :$options />', ['options' => $options]))
|
||||
->toContain('touch-target min-w-12')
|
||||
->and((string) $this->blade('<x-group size="md" name="x" :$options />', ['options' => $options]))
|
||||
->not->toContain('touch-target')
|
||||
->toContain('min-w-0');
|
||||
});
|
||||
|
||||
it('draws several choices as checkboxes', function () {
|
||||
expect((string) $this->blade('<x-group name="days" multiple variant="outlined" :options="[[\'id\' => \'mon\', \'name\' => \'Mon\']]" />'))
|
||||
->toContain('type="checkbox"')
|
||||
->toContain('name="days[]"')
|
||||
->toContain('has-checked:bg-inverse-surface');
|
||||
});
|
||||
|
||||
it('binds to a Livewire property and shows its validation message', function () {
|
||||
$component = new class extends Component
|
||||
{
|
||||
public string $theme = 'dark';
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$this->validate(['theme' => 'in:light'], ['theme.in' => 'Pick light.']);
|
||||
}
|
||||
|
||||
public function render(): string
|
||||
{
|
||||
return <<<'BLADE'
|
||||
<div>
|
||||
<x-group wire:model="theme" hint="How it looks" :options="[['id' => 'light', 'name' => 'Light'], ['id' => 'dark', 'name' => 'Dark']]" />
|
||||
</div>
|
||||
BLADE;
|
||||
}
|
||||
};
|
||||
|
||||
Livewire::test($component)
|
||||
->assertSeeHtml('wire:model="theme"')
|
||||
->assertSee('How it looks')
|
||||
->set('theme', 'light')
|
||||
->assertSet('theme', 'light')
|
||||
->set('theme', 'dark')
|
||||
->call('save')
|
||||
->assertSee('Pick light.')
|
||||
->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');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
use Livewire\Component;
|
||||
use Livewire\Livewire;
|
||||
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
|
||||
|
||||
it('draws a choice as connected radios', function () {
|
||||
$html = (string) $this->blade(<<<'BLADE'
|
||||
<x-group label="Theme" name="theme" x-model="theme" :options="[
|
||||
['id' => 'light', 'name' => 'Light', 'icon' => 'light_mode'],
|
||||
['id' => 'dark', 'name' => 'Dark', 'disabled' => true],
|
||||
]" />
|
||||
BLADE);
|
||||
|
||||
expect($html)
|
||||
->toMatch('/<fieldset data-md-group="data-md-group" data-md-size="sm" data-md-variant="tonal" data-md-shape="round">/')
|
||||
->toContain('<legend data-md-group-legend>Theme</legend>')
|
||||
->toContain('<div data-md-button-group="connected" data-md-size="sm" data-md-shape="round">')
|
||||
->toContain('<label data-md-group-segment>')
|
||||
->toContain('type="radio"')
|
||||
->toContain('name="theme"')
|
||||
->toContain('x-model="theme"')
|
||||
->toContain('value="light"')
|
||||
->toContain('disabled')
|
||||
->toContain('<span data-md-group-label>Light</span>')
|
||||
->not->toContain('class=')
|
||||
->and(substr_count($html, '<svg'))->toBe(1);
|
||||
});
|
||||
|
||||
it('squares a choice\'s ends, and still rounds the chosen segment', function () {
|
||||
$options = [['id' => 'a', 'name' => 'A']];
|
||||
|
||||
expect((string) $this->blade('<x-group shape="square" name="x" :$options />', ['options' => $options]))->toContain('data-md-button-group="connected" data-md-size="sm" data-md-shape="square"')
|
||||
->and((string) $this->blade('<x-group name="x" :$options />', ['options' => $options]))->toContain('data-md-shape="round"')
|
||||
->and(ComponentStylesheet::read('button-group')->declarations("[data-md-button-group][data-md-shape='square']"))->toBe(['--md-button-group-outer' => 'var(--md-button-group-inner)'])
|
||||
->and(ComponentStylesheet::read('button-group')->declarations("[data-md-button-group='connected'] > :is([aria-pressed='true'], :has(:checked))"))->toBe(['--md-button-group-corner' => 'var(--md-button-group-full)'])
|
||||
->and(ComponentStylesheet::read('group')->imports())->toBe(['./icon.css', './button-group.css']);
|
||||
});
|
||||
|
||||
it('keeps a 48px target and a 48px minimum width on the smallest connected segments', function () {
|
||||
$css = ComponentStylesheet::read('group');
|
||||
|
||||
expect($css->declarations("[data-md-group]:is([data-md-size='xs'], [data-md-size='sm']) [data-md-group-segment]"))->toBe(['min-inline-size' => 'var(--md-sys-measurement-space600)'])
|
||||
->and($css->declarations("[data-md-group]:is([data-md-size='xs'], [data-md-size='sm']) [data-md-group-segment]::after"))->toMatchArray([
|
||||
'min-width' => 'var(--md-sys-measurement-space600)',
|
||||
'min-height' => 'var(--md-sys-measurement-space600)',
|
||||
])
|
||||
->and($css->declarations('[data-md-group-segment]'))->toMatchArray(['min-inline-size' => '0'])
|
||||
->and(ComponentStylesheet::read('button-group')->declarations("[data-md-button-group='connected']:is([data-md-size='xs'], [data-md-size='sm']) > *"))->toBe(['min-inline-size' => 'var(--md-sys-measurement-space600)']);
|
||||
});
|
||||
|
||||
it('sizes its segments as M3\'s buttons', function (string $size, string $height, string $padding, string $type, int $icon) {
|
||||
$html = (string) $this->blade("<x-group size=\"{$size}\" name=\"x\" :options=\"[['id' => 'a', 'name' => 'A', 'icon' => 'home']]\" />");
|
||||
|
||||
expect($html)->toContain("data-md-size=\"{$size}\"")->toContain("--md-icon-size: {$icon}px")
|
||||
->and(ComponentStylesheet::read('group')->declarations("[data-md-group][data-md-size='{$size}'] [data-md-group-segment]"))->toMatchArray([
|
||||
'block-size' => $height,
|
||||
'padding-inline' => $padding,
|
||||
'font' => "var(--md-sys-typescale-{$type})",
|
||||
]);
|
||||
})->with([
|
||||
'xs' => ['xs', '32px', 'var(--md-sys-measurement-space200)', 'label-lg', 20],
|
||||
'sm' => ['sm', '40px', 'var(--md-sys-measurement-space200)', 'label-lg', 20],
|
||||
'md' => ['md', '56px', 'var(--md-sys-measurement-space300)', 'title-md', 24],
|
||||
'lg' => ['lg', '96px', 'var(--md-sys-measurement-space600)', 'headline-sm', 32],
|
||||
'xl' => ['xl', '136px', 'var(--md-sys-measurement-space800)', 'headline-lg', 40],
|
||||
]);
|
||||
|
||||
it('draws several choices as checkboxes, in the toggle button\'s colours', function () {
|
||||
$css = ComponentStylesheet::read('group');
|
||||
|
||||
expect((string) $this->blade('<x-group name="days" multiple variant="outlined" :options="[[\'id\' => \'mon\', \'name\' => \'Mon\']]" />'))
|
||||
->toContain('type="checkbox"')
|
||||
->toContain('name="days[]"')
|
||||
->toContain('data-md-variant="outlined"')
|
||||
->toContain('data-md-multiple')
|
||||
->and($css->declarations('[data-md-group-segment]:has(:checked)'))->toBe(['--md-group-container' => 'var(--md-sys-color-secondary)', '--md-group-label' => 'var(--md-sys-color-on-secondary)'])
|
||||
->and($css->declarations("[data-md-group][data-md-variant='filled'] [data-md-group-segment]:has(:checked)"))->toBe(['--md-group-container' => 'var(--md-sys-color-primary)', '--md-group-label' => 'var(--md-sys-color-on-primary)'])
|
||||
->and($css->declarations("[data-md-group][data-md-variant='outlined'] [data-md-group-segment]:has(:checked)"))->toBe(['--md-group-container' => 'var(--md-sys-color-inverse-surface)', '--md-group-label' => 'var(--md-sys-color-inverse-on-surface)', 'border-color' => 'transparent'])
|
||||
->and($css->declarations('[data-md-group-segment]:has(:disabled)'))->toMatchArray(['cursor' => 'not-allowed'])
|
||||
->and($css->declarations('[data-md-group-segment]:has(:focus-visible)'))->toBe(['outline' => '3px solid var(--md-sys-color-secondary)', 'outline-offset' => '2px'])
|
||||
->and((string) $this->blade('<x-group name="x" variant="glass" :options="[]" />'))->toContain('data-md-variant="tonal"');
|
||||
});
|
||||
|
||||
it('binds to a Livewire property and shows its validation message', function () {
|
||||
$component = new class extends Component
|
||||
{
|
||||
public string $theme = 'dark';
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$this->validate(['theme' => 'in:light'], ['theme.in' => 'Pick light.']);
|
||||
}
|
||||
|
||||
public function render(): string
|
||||
{
|
||||
return <<<'BLADE'
|
||||
<div>
|
||||
<x-group wire:model="theme" hint="How it looks" :options="[['id' => 'light', 'name' => 'Light'], ['id' => 'dark', 'name' => 'Dark']]" />
|
||||
</div>
|
||||
BLADE;
|
||||
}
|
||||
};
|
||||
|
||||
Livewire::test($component)
|
||||
->assertSeeHtml('wire:model="theme"')
|
||||
->assertSee('How it looks')
|
||||
->set('theme', 'light')
|
||||
->assertSet('theme', 'light')
|
||||
->set('theme', 'dark')
|
||||
->call('save')
|
||||
->assertSee('Pick light.')
|
||||
->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 data-md-group-hint class="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 data-md-group-hint >No elevation data here</p>')
|
||||
->and(ComponentStylesheet::read('group')->declarations('[data-md-group-hint]'))->toBe(['color' => 'var(--md-sys-color-on-surface-variant)']);
|
||||
|
||||
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 data-md-group-error>Pick a terrain.</p>')
|
||||
->not->toContain('No elevation data here')
|
||||
->not->toContain('text-warning');
|
||||
});
|
||||
Reference in New Issue
Block a user