diff --git a/resources/css/components.css b/resources/css/components.css index a4ed3014..62c6cbb3 100644 --- a/resources/css/components.css +++ b/resources/css/components.css @@ -21,6 +21,7 @@ @import './components/split-button.css'; @import './components/fab.css'; @import './components/menu-separator.css'; +@import './components/menu-group.css'; /* Inputs, selection and data */ @import './components/form.css'; diff --git a/resources/css/components/menu-group.css b/resources/css/components/menu-group.css new file mode 100644 index 00000000..e75a7369 --- /dev/null +++ b/resources/css/components/menu-group.css @@ -0,0 +1,49 @@ +/* + * : a labelled or gapped cluster of items in an `` + * (menu-group.blade.php). + * + * Without `gap`, a plain 4px of vertical padding around the cluster, 0 at either end of the list + * (the popover's own padding already gives that room). `data-md-gap` draws M3 Expressive's + * "Grouped" layout instead: items 2px apart (`SegmentedMenuTokens.SegmentedGap`, androidx Compose + * Material 3, Apache-2.0), clusters themselves 8px apart โ€” the same 8px `` + * keeps above and below its line, so a menu is the same height whichever it uses + * (docs/reference/m3/components-actions-communication-containment.md ยง Menus โ†’ Specification). + * + * The label is label-large in on-surface-variant, `aria-hidden` beside the `role="group"` + * `aria-label` a screen reader reads instead (`SegmentedMenuTokens.GroupLabelTextFont`). + */ + +@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility; + +@layer material.components { + [data-md-menu-group]:not([data-md-gap]) { + padding-block: var(--md-sys-measurement-space50); + } + + [data-md-menu-group]:not([data-md-gap]):first-child { + padding-block-start: 0; + } + + [data-md-menu-group]:not([data-md-gap]):last-child { + padding-block-end: 0; + } + + [data-md-menu-group][data-md-gap]:not(:first-child) { + margin-block-start: var(--md-sys-measurement-space100); + } + + [data-md-menu-group-label] { + padding-inline: var(--md-sys-measurement-space200); + padding-block: var(--md-sys-measurement-space100) var(--md-sys-measurement-space50); + 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-menu-group-items] { + display: flex; + flex-direction: column; + gap: var(--md-sys-measurement-space25); + } +} diff --git a/resources/views/components/menu-group.blade.php b/resources/views/components/menu-group.blade.php index a7686837..83d3158c 100644 --- a/resources/views/components/menu-group.blade.php +++ b/resources/views/components/menu-group.blade.php @@ -16,7 +16,9 @@ list round and each cluster reads as one block. Clusters stand 8px apart, the same 8px a separator keeps above and below its line, so a menu is the same height whichever it uses. SegmentedMenuTokens' own GroupShape is 8dp where the library's list ends are 12dp; the - library's shape wins, so a cluster's ends and a list's ends match. --}} + library's shape wins, so a cluster's ends and a list's ends match. + + Drawn by resources/css/components/menu-group.css from `data-md-gap`. --}} @props([ 'label' => null, @@ -24,24 +26,21 @@ ]) @php - $attributes = $attributes - ->class([ - 'py-1 first:pt-0 last:pb-0' => ! $gap, - 'not-first:mt-2' => $gap, - ]) - ->merge(array_filter([ - 'role' => 'group', - 'aria-label' => $label, - ], fn ($value): bool => filled($value))); + $attributes = $attributes->merge(array_filter([ + 'data-md-menu-group' => true, + 'data-md-gap' => $gap ? true : null, + 'role' => 'group', + 'aria-label' => filled($label) ? $label : null, + ], fn ($value): bool => $value !== null)); @endphp
@if (filled($label)) - + @endif @if ($gap) -
{{ $slot }}
+
{{ $slot }}
@else {{ $slot }} @endif diff --git a/tests/Feature/Components/ActionStylesheetsTest.php b/tests/Feature/Components/ActionStylesheetsTest.php index 540bc0a3..34aa35b3 100644 --- a/tests/Feature/Components/ActionStylesheetsTest.php +++ b/tests/Feature/Components/ActionStylesheetsTest.php @@ -20,6 +20,7 @@ dataset('action components', [ 'split-button', 'fab', 'menu-separator', + 'menu-group', ]); it('draws the component from a stylesheet shaped like every package stylesheet', function (string $name) { diff --git a/tests/Feature/Components/MenuTest.php b/tests/Feature/Components/MenuTest.php index da4a7cf7..58b9cce0 100644 --- a/tests/Feature/Components/MenuTest.php +++ b/tests/Feature/Components/MenuTest.php @@ -72,7 +72,11 @@ it('gives an item M3\'s 48px row and 16px sides, and the separator its 8px', fun expect((string) $this->blade(''))->toContain('min-h-12')->toContain('px-4') ->and(trim((string) $this->blade('')))->toBe('
') ->and(ComponentStylesheet::read('menu-separator')->declarations('[data-md-menu-separator]'))->toBe(['block-size' => '1px', 'margin-block' => 'var(--md-sys-measurement-space100)', 'margin-inline' => 'var(--md-sys-measurement-space200)', 'border-width' => '0', 'background-color' => 'var(--md-sys-color-outline-variant)']) - ->and((string) $this->blade(''))->toContain('px-4 pt-2 pb-1'); + ->and((string) $this->blade(''))->toContain('data-md-menu-group-label') + ->and(ComponentStylesheet::read('menu-group')->declarations('[data-md-menu-group-label]'))->toMatchArray([ + 'padding-inline' => 'var(--md-sys-measurement-space200)', + 'padding-block' => 'var(--md-sys-measurement-space100) var(--md-sys-measurement-space50)', + ]); }); it('marks the page an item leads to, and carries a badge', function () { @@ -108,21 +112,24 @@ it('separates and labels groups', function () { it('separates clusters by a gap instead of a divider when asked', function () { $html = (string) $this->blade(''); + $css = ComponentStylesheet::read('menu-group'); // M3 Expressive's grouped layout: 2px between the items of a cluster, 8px between clusters โ€” // the same 8px the divider keeps above and below its line. expect($html) ->toContain('role="group"') - ->toContain('not-first:mt-2') - ->toContain('space-y-0.5') + ->toContain('data-md-gap') + ->toContain('data-md-menu-group-items') ->not->toContain('aria-label') - ->not->toContain('py-1 first:pt-0') + ->and($css->declarations('[data-md-menu-group][data-md-gap]:not(:first-child)'))->toBe(['margin-block-start' => 'var(--md-sys-measurement-space100)']) + ->and($css->declarations('[data-md-menu-group-items]'))->toMatchArray(['gap' => 'var(--md-sys-measurement-space25)']) ->and((string) $this->blade('')) ->toContain('aria-label="Then"') - ->toContain('space-y-0.5') + ->toContain('data-md-menu-group-items') ->and((string) $this->blade('')) - ->toContain('py-1 first:pt-0 last:pb-0') - ->not->toContain('space-y-0.5'); + ->not->toContain('data-md-gap') + ->not->toContain('data-md-menu-group-items') + ->and($css->declarations('[data-md-menu-group]:not([data-md-gap])'))->toBe(['padding-block' => 'var(--md-sys-measurement-space50)']); }); it('adds icon-class to the leading icon, over its own colour but not over disabled', function () {