From b8c6d411db854f0e11ae06ead0c963824a2c1c35 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 14:56:59 +0200 Subject: [PATCH] Draw the connected choice group without Tailwind Plan step 36. 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; 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) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- resources/css/components.css | 2 + resources/css/components/button-group.css | 101 ++++++++ resources/css/components/group.css | 241 ++++++++++++++++++ resources/views/components/group.blade.php | 73 ++---- .../Components/ActionStylesheetsTest.php | 3 +- tests/Feature/Components/AppBarTest.php | 4 +- tests/Feature/Components/ButtonGroupTest.php | 88 +------ tests/Feature/Components/GroupTest.php | 129 ++++++++++ 8 files changed, 504 insertions(+), 137 deletions(-) create mode 100644 resources/css/components/button-group.css create mode 100644 resources/css/components/group.css create mode 100644 tests/Feature/Components/GroupTest.php diff --git a/resources/css/components.css b/resources/css/components.css index ca6f3e08..993e1a0d 100644 --- a/resources/css/components.css +++ b/resources/css/components.css @@ -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 */ diff --git a/resources/css/components/button-group.css b/resources/css/components/button-group.css new file mode 100644 index 00000000..460c0534 --- /dev/null +++ b/resources/css/components/button-group.css @@ -0,0 +1,101 @@ +/* + * 's shapes: the corners a button takes from its place among its siblings. The + * connected form is also what `` 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. + * + * '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); + } +} diff --git a/resources/css/components/group.css b/resources/css/components/group.css new file mode 100644 index 00000000..ad4b7cb3 --- /dev/null +++ b/resources/css/components/group.css @@ -0,0 +1,241 @@ +/* + * : 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); + } +} diff --git a/resources/views/components/group.blade.php b/resources/views/components/group.blade.php index c64e453e..f87815a8 100644 --- a/resources/views/components/group.blade.php +++ b/resources/views/components/group.blade.php @@ -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 ``: 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 -
only(['class', 'wire:key'])->class('min-w-0') }}> +
@if (filled($label)) - {{ $label }} + {{ $label }} @endif -
! $inline, 'w-fit' => $inline])> +
@foreach ($options as $option) -
@if ($messages !== []) @foreach ($messages as $message) -

{{ $message }}

+

{{ $message }}

@endforeach @elseif (filled($hint)) -

{{ $hint }}

+

{{ $hint }}

@endif
diff --git a/tests/Feature/Components/ActionStylesheetsTest.php b/tests/Feature/Components/ActionStylesheetsTest.php index 1226cd75..be7bd627 100644 --- a/tests/Feature/Components/ActionStylesheetsTest.php +++ b/tests/Feature/Components/ActionStylesheetsTest.php @@ -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('/(?class\(|toCssClasses|(?:x-bind)?:class=/'); })->with('action components'); it('takes its values from the tokens and its breakpoints in px', function (string $name) { diff --git a/tests/Feature/Components/AppBarTest.php b/tests/Feature/Components/AppBarTest.php index d78f37f2..00f89dfa 100644 --- a/tests/Feature/Components/AppBarTest.php +++ b/tests/Feature/Components/AppBarTest.php @@ -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('and((string) $this->blade('')) ->toContain('aria-label="Appearance"') - ->toContain('Appearance'); + ->toContain('Appearance'); }); it('switches the theme through the store in three shapes', function () { diff --git a/tests/Feature/Components/ButtonGroupTest.php b/tests/Feature/Components/ButtonGroupTest.php index 6fe36c22..b59d2cda 100644 --- a/tests/Feature/Components/ButtonGroupTest.php +++ b/tests/Feature/Components/ButtonGroupTest.php @@ -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('')) @@ -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('')) - ->toContain('data-shape="round"') - ->and((string) $this->blade('')) - ->toContain('data-shape="square"') - ->and((string) $this->blade('')) ->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(''))->toContain('data-md-icon-button') ->and((string) $this->blade(''))->not->toContain('data-md-icon-button'); }); - -it('draws a choice as connected radios', function () { - $html = (string) $this->blade(<<<'BLADE' - - BLADE); - - expect($html) - ->toContain('Theme') - ->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, '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('', ['options' => $options])) - ->toContain('touch-target min-w-12') - ->and((string) $this->blade('', ['options' => $options])) - ->not->toContain('touch-target') - ->toContain('min-w-0'); -}); - -it('draws several choices as checkboxes', function () { - expect((string) $this->blade('')) - ->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' -
- -
- 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('', ['options' => $options])) - ->toContain('

No elevation data here

') - ->and((string) $this->blade('', ['options' => $options])) - ->toContain('

No elevation data here

'); - - expect((string) $this->withViewErrors(['terrain' => 'Pick a terrain.'])->blade('', ['options' => $options])) - ->toContain('

Pick a terrain.

') - ->not->toContain('No elevation data here') - ->not->toContain('text-warning'); -}); diff --git a/tests/Feature/Components/GroupTest.php b/tests/Feature/Components/GroupTest.php new file mode 100644 index 00000000..294b4ca9 --- /dev/null +++ b/tests/Feature/Components/GroupTest.php @@ -0,0 +1,129 @@ +blade(<<<'BLADE' + + BLADE); + + expect($html) + ->toMatch('/
/') + ->toContain('Theme') + ->toContain('
') + ->toContain('