diff --git a/resources/css/components.css b/resources/css/components.css index 540d128e..b0c6f689 100644 --- a/resources/css/components.css +++ b/resources/css/components.css @@ -12,6 +12,14 @@ @import './components/shape.css'; /* Actions and communication */ +@import './components/loading.css'; +@import './components/tooltip.css'; +@import './components/badge.css'; +@import './components/button.css'; +@import './components/group.css'; +@import './components/button-group.css'; +@import './components/split-button.css'; +@import './components/fab.css'; /* Inputs, selection and data */ @import './components/form.css'; diff --git a/resources/css/components/actions.css b/resources/css/components/actions.css index 0f96f924..c85ae555 100644 --- a/resources/css/components/actions.css +++ b/resources/css/components/actions.css @@ -84,65 +84,3 @@ translate: 0 0.5rem; opacity: 0; } - -/* - * An extended FAB that collapses to a FAB while the page scrolls down (``, resources/js/fab.js sets `data-collapsed`). M3's extended FAB page: it - * "can collapse to a FAB on scroll-down and re-expand to extended on scroll-up — when switching - * between FAB↔extended FAB, shape changes, the icon moves left, and the text label fades in/out". - * - * `width: auto` cannot transition, so the label sits in a one-column grid whose track closes to - * zero; that track is what animates, and the button lays itself out around it every frame. The - * gap and the minimum width go with it, all on the default spatial spring — the size of morph - * `state-transition-default` is for — while the label fades on the effects spring. The collapsed - * sizes are the FAB's own (FabBaseline/Medium/LargeTokens: 56/80/96px). The extended FAB's - * padding already centres the glyph at `sm` (16px + 24px + 16px) and `md` (26 + 28 + 26); at - * `lg` its 28px is 4px short of the 32px that centres a 32px glyph in 96px, so that one moves. - * Corners do not change: each size's FAB and extended FAB share one. - * - * The transition list repeats the FAB's own shadow and colour transitions, which this rule - * replaces. Under reduced motion tokens/motion.css takes every duration to zero, so it swaps. - */ -[data-fab-collapsible] { - --fab-collapsed-size: 3.5rem; - - transition-property: min-inline-size, padding-inline, gap, box-shadow, background-color, color; - transition-duration: - var(--md-sys-motion-spatial-default-duration), var(--md-sys-motion-spatial-default-duration), var(--md-sys-motion-spatial-default-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-default), var(--md-sys-motion-spatial-default), var(--md-sys-motion-spatial-default), - var(--md-sys-motion-effects-fast), var(--md-sys-motion-effects-fast), var(--md-sys-motion-effects-fast); -} - -[data-fab-collapsible][data-fab-size='md'] { --fab-collapsed-size: 5rem; } -[data-fab-collapsible][data-fab-size='lg'] { --fab-collapsed-size: 6rem; } - -[data-fab-label] { - display: grid; - grid-template-columns: 1fr; - transition-property: grid-template-columns, opacity; - transition-duration: var(--md-sys-motion-spatial-default-duration), var(--md-sys-motion-effects-default-duration); - transition-timing-function: var(--md-sys-motion-spatial-default), var(--md-sys-motion-effects-default); -} - -[data-fab-label] > span { - overflow: hidden; - white-space: nowrap; -} - -[data-fab-collapsible][data-collapsed] { - min-inline-size: var(--fab-collapsed-size); - gap: 0; -} - -[data-fab-collapsible][data-fab-size='lg'][data-collapsed] { - padding-inline: 2rem; -} - -/* The label stays in the accessibility tree — clipped and transparent, never `display: none` — so - the collapsed FAB keeps the name the extended one had. */ -[data-fab-collapsible][data-collapsed] [data-fab-label] { - grid-template-columns: 0fr; - opacity: 0; -} diff --git a/resources/css/components/badge.css b/resources/css/components/badge.css new file mode 100644 index 00000000..83f52f6e --- /dev/null +++ b/resources/css/components/badge.css @@ -0,0 +1,143 @@ +/* + * : M3's small and large badges, and the library's status label. + * + * BadgeTokens (androidx Compose Material 3, Apache-2.0): the small badge is a 6px dot + * (`data-md-dot`), the large badge 16px tall and at least 16px wide with 4px either side of a + * `label-small` count in tabular figures, both fully round and `error` by default. A floating + * badge sits on M3's anchor geometry at the top-end corner of its `position: relative` parent: the + * dot flush in the corner, the count 2px above it with its start 12px in from the parent's end. + * + * The status label (`data-md-variant`) is not an M3 badge: 24px tall, small corner, 8px either + * side, 4px between an icon and its word, `label-medium`. `tonal` draws it in the colour's + * container, `solid` in the colour itself, `outline` as a 1px `outline` edge around + * `on-surface-variant` text — `outline` rather than `outline-variant`, because M3 asks a badge for + * 3:1 against what is behind it and `outline-variant` is the decorative role dividers use. + * + * `data-md-color` picks the pair of roles; `neutral` has no hue of its own (`on-surface-variant` + * and `surface`, or `surface-container-high` behind `on-surface-variant` text), and `plain` sets + * no colour at all, so the caller's CSS paints it. + */ + +@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility; + +@layer material.components { + [data-md-badge] { + --md-badge-color: var(--md-sys-color-error); + --md-badge-on-color: var(--md-sys-color-on-error); + --md-badge-container: var(--md-sys-color-error-container); + --md-badge-on-container: var(--md-sys-color-on-error-container); + + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + white-space: nowrap; + } + + [data-md-badge][data-md-dot] { + inline-size: 6px; + block-size: 6px; + border-radius: var(--md-sys-shape-corner-full); + } + + [data-md-badge]:not([data-md-dot], [data-md-variant]) { + min-inline-size: 16px; + block-size: 16px; + border-radius: var(--md-sys-shape-corner-full); + padding-inline: var(--md-sys-measurement-space50); + font: var(--md-sys-typescale-label-sm); + letter-spacing: var(--md-sys-typescale-label-sm-tracking); + font-variation-settings: normal; + font-variant-numeric: tabular-nums; + } + + [data-md-badge][data-md-variant] { + gap: var(--md-sys-measurement-space50); + block-size: 24px; + border-radius: var(--md-sys-shape-corner-sm); + padding-inline: var(--md-sys-measurement-space100); + font: var(--md-sys-typescale-label-md); + letter-spacing: var(--md-sys-typescale-label-md-tracking); + font-variation-settings: normal; + } + + [data-md-badge][data-md-variant='outline'] { + border: 1px solid; + } + + [data-md-badge][data-md-color='primary'] { + --md-badge-color: var(--md-sys-color-primary); + --md-badge-on-color: var(--md-sys-color-on-primary); + --md-badge-container: var(--md-sys-color-primary-container); + --md-badge-on-container: var(--md-sys-color-on-primary-container); + } + + [data-md-badge][data-md-color='secondary'] { + --md-badge-color: var(--md-sys-color-secondary); + --md-badge-on-color: var(--md-sys-color-on-secondary); + --md-badge-container: var(--md-sys-color-secondary-container); + --md-badge-on-container: var(--md-sys-color-on-secondary-container); + } + + [data-md-badge][data-md-color='tertiary'] { + --md-badge-color: var(--md-sys-color-tertiary); + --md-badge-on-color: var(--md-sys-color-on-tertiary); + --md-badge-container: var(--md-sys-color-tertiary-container); + --md-badge-on-container: var(--md-sys-color-on-tertiary-container); + } + + [data-md-badge][data-md-color='success'] { + --md-badge-color: var(--md-sys-color-success); + --md-badge-on-color: var(--md-sys-color-on-success); + --md-badge-container: var(--md-sys-color-success-container); + --md-badge-on-container: var(--md-sys-color-on-success-container); + } + + [data-md-badge][data-md-color='warning'] { + --md-badge-color: var(--md-sys-color-warning); + --md-badge-on-color: var(--md-sys-color-on-warning); + --md-badge-container: var(--md-sys-color-warning-container); + --md-badge-on-container: var(--md-sys-color-on-warning-container); + } + + [data-md-badge][data-md-color='info'] { + --md-badge-color: var(--md-sys-color-info); + --md-badge-on-color: var(--md-sys-color-on-info); + --md-badge-container: var(--md-sys-color-info-container); + --md-badge-on-container: var(--md-sys-color-on-info-container); + } + + [data-md-badge][data-md-color='neutral'] { + --md-badge-color: var(--md-sys-color-on-surface-variant); + --md-badge-on-color: var(--md-sys-color-surface); + --md-badge-container: var(--md-sys-color-surface-container-high); + --md-badge-on-container: var(--md-sys-color-on-surface-variant); + } + + [data-md-badge]:not([data-md-color='plain']) { + background-color: var(--md-badge-color); + color: var(--md-badge-on-color); + + &[data-md-variant='tonal'] { + background-color: var(--md-badge-container); + color: var(--md-badge-on-container); + } + + &[data-md-variant='outline'] { + border-color: var(--md-sys-color-outline); + background-color: transparent; + color: var(--md-sys-color-on-surface-variant); + } + } + + [data-md-badge][data-md-floating] { + position: absolute; + inset-block-start: calc(-1 * var(--md-sys-measurement-space25)); + inset-inline-start: calc(100% - 12px); + } + + [data-md-badge][data-md-floating][data-md-dot] { + inset-block-start: 0; + inset-inline: auto 0; + } +} diff --git a/resources/css/components/button-group.css b/resources/css/components/button-group.css new file mode 100644 index 00000000..6a6f2745 --- /dev/null +++ b/resources/css/components/button-group.css @@ -0,0 +1,169 @@ +/* + * : related buttons in a row, and the shapes 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. A group never wraps: M3's rule, and the press expansion only + * reaches a neighbour on the same line anyway. + * + * Standard (`data-md-button-group="standard"`): the buttons stand apart — 18/12/8/8/8px by size + * (ButtonGroupXSmall…XLargeTokens.BetweenSpace) — and pressing a label button widens it and + * narrows its neighbours by the same amount, so the row keeps its length: Expressive's press + * expansion (ButtonGroupDefaults.ExpandedRatio is 15%; this uses a fixed step per size, 4/6/8/16/ + * 20px, on the button's own padding of 16/16/24/48/64px). Icon buttons keep their width. A square + * standard group has no inner edges, so it hands its buttons the square corner scale + * `` draws (12/12/16/28/28px, 8/8/12/16/16px under the finger), and a + * selected button in it turns round. + * + * 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-square: var(--md-sys-shape-corner-md); + --md-button-group-square-pressed: var(--md-sys-shape-corner-sm); + --md-button-group-outer: var(--md-button-group-full); + --md-button-group-pad: var(--md-sys-measurement-space200); + --md-button-group-grow: 6px; + + display: inline-flex; + align-items: center; + } + + [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; + --md-button-group-grow: 4px; + } + + [data-md-button-group][data-md-size='md'] { + --md-button-group-full: 28px; + --md-button-group-square: var(--md-sys-shape-corner-lg); + --md-button-group-square-pressed: var(--md-sys-shape-corner-md); + --md-button-group-pad: var(--md-sys-measurement-space300); + --md-button-group-grow: 8px; + } + + [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; + --md-button-group-square: var(--md-sys-shape-corner-xl); + --md-button-group-square-pressed: var(--md-sys-shape-corner-lg); + --md-button-group-pad: var(--md-sys-measurement-space600); + --md-button-group-grow: 16px; + } + + [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; + --md-button-group-square: var(--md-sys-shape-corner-xl); + --md-button-group-square-pressed: var(--md-sys-shape-corner-lg); + --md-button-group-pad: var(--md-sys-measurement-space800); + --md-button-group-grow: 20px; + } + + [data-md-button-group='standard'] { + gap: var(--md-sys-measurement-space100); + } + + [data-md-button-group='standard'][data-md-size='xs'] { + gap: 18px; + } + + [data-md-button-group='standard'][data-md-size='sm'] { + gap: 12px; + } + + /* A square standard group hands its buttons the square corner scale; a selected one rounds. */ + [data-md-button-group='standard'][data-md-shape='square'] > * { + border-radius: var(--md-button-group-square); + } + + [data-md-button-group='standard'][data-md-shape='square'] > :active:not(:disabled, [aria-disabled='true']) { + border-radius: var(--md-button-group-square-pressed); + } + + [data-md-button-group='standard'][data-md-shape='square'] > [aria-pressed='true'] { + border-radius: var(--md-button-group-full); + } + + /* Press expansion: the pressed label button grows, the label buttons beside it give way. */ + [data-md-button-group='standard'] > :not([data-md-icon-button]):active:not(:disabled, [aria-disabled='true']) { + padding-inline: calc(var(--md-button-group-pad) + var(--md-button-group-grow)); + } + + [data-md-button-group='standard'] > :not([data-md-icon-button]):has(+ :not([data-md-icon-button]):active:not(:disabled, [aria-disabled='true'])) { + padding-inline-end: calc(var(--md-button-group-pad) - var(--md-button-group-grow)); + } + + [data-md-button-group='standard'] > :not([data-md-icon-button]):active:not(:disabled, [aria-disabled='true']) + :not([data-md-icon-button]) { + padding-inline-start: calc(var(--md-button-group-pad) - var(--md-button-group-grow)); + } + + /* 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/button.css b/resources/css/components/button.css new file mode 100644 index 00000000..8fdbc7b7 --- /dev/null +++ b/resources/css/components/button.css @@ -0,0 +1,452 @@ +/* + * : M3 Expressive's buttons — label button, icon button and toggle — and the page's + * create action that is a FAB on a compact window. + * + * Geometry from androidx Compose Material 3's tokens (Apache-2.0), per `data-md-size`: + * + * size height label padding gap type icon (label / icon-only) outline square pressed + * xs 32px 16px 8px label-lg 20 / 20px 1px md sm + * sm 40px 16px 8px label-lg 20 / 24px 1px md sm + * md 56px 24px 8px title-md 24 / 24px 1px lg md + * lg 96px 48px 12px headline-sm 32 / 32px 2px xl lg + * xl 136px 64px 16px headline-lg 40 / 40px 3px xl lg + * + * (ButtonXSmall…XLargeTokens; the xs padding is the 16px Phase D settled on, ACT-09.) An icon button + * (`data-md-icon-button`) is as tall and `narrow`, `default` or `wide` across by + * XSmall…XLargeIconButtonTokens: 28/32/40, 32/40/52, 48/56/72, 64/96/128 and 104/136/184px. The + * views size the glyphs through ``; `--md-button-icon` carries the same size for the + * loading indicator that stands in for the glyph while the button's action runs. + * + * Shape: round (`--md-sys-shape-corner-full`) by default; `data-md-shape="square"` and a selected + * button take the size's square corner, a selected square icon button turns round, and every one + * squares off to the pressed corner under the finger. Those corner rules are wrapped in `:where()`, + * so they carry no specificity at all: a group's stylesheet (button-group.css, split-button.css) + * reshapes the buttons inside it with any selector, without a specificity fight. + * + * Colour: `data-md-color` picks the roles a variant draws with; the variants set + * `--md-button-container`, `--md-button-label`, `--md-button-outline` and `--md-button-elevation`, + * which one rule paints, so the disabled treatment (on-surface at 10% behind 38% text, + * FilledButtonTokens.DisabledContainerOpacity and DisabledLabelTextOpacity) replaces them with a + * plain declaration. Primary's tonal button is secondary-container and its standard icon button + * on-surface-variant, as M3 draws them; the text button's label is primary, as Compose draws it, + * not the token's on-surface-variant, which its own source marks as wrong; a selected text button + * takes the tonal container (the library's deliberate deviations, docs/audits/m3-alignment). + * + * Motion: corners, padding, margins and `bottom` on the fast spatial spring, colour and shadow on + * the fast effects spring beside it — a colour must never overshoot (tokens/motion.css). + * + * The state layer, focus ring and 48px target (xs and sm) are the declarations of the foundation's + * `md-state-layer`, `md-focus-ring` and `md-touch-target` (foundation/interaction.css), keyed on + * the button itself. + * + * `data-md-compact-fab`: below `medium` (600px) the button is an extended FAB in the thumb zone — + * fixed 16px from the end, 56px tall with 16px padding, the large corner, `title-md`, + * primary-container at elevation 3 (ExtendedFabPrimaryTokens) — lifted 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. From `medium` it is the button it was written as. + * `data-md-responsive` on the label hides it below `expanded` (840px). + */ + +@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility; + +@import './icon.css'; +@import './loading.css'; +@import './tooltip.css'; + +@layer material.components { + [data-md-button] { + --md-button-color: var(--md-sys-color-primary); + --md-button-on-color: var(--md-sys-color-on-primary); + --md-button-tone: var(--md-sys-color-secondary-container); + --md-button-on-tone: var(--md-sys-color-on-secondary-container); + --md-button-tone-selected: var(--md-sys-color-secondary); + --md-button-on-tone-selected: var(--md-sys-color-on-secondary); + --md-button-quiet: var(--md-sys-color-on-surface-variant); + + --md-button-container: transparent; + --md-button-label: var(--md-button-color); + --md-button-outline: transparent; + --md-button-elevation: none; + + position: relative; + isolation: isolate; + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + block-size: var(--md-button-height); + background-color: var(--md-button-container); + color: var(--md-button-label); + box-shadow: var(--md-button-elevation); + white-space: nowrap; + cursor: pointer; + -webkit-user-select: none; + user-select: none; + outline: 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); + } + } + + &:focus-visible { + outline: 3px solid var(--md-sys-color-secondary); + outline-offset: 2px; + } + + &:focus-visible::before { + opacity: var(--md-sys-state-focus-state-layer-opacity); + } + + &:active::before { + opacity: var(--md-sys-state-pressed-state-layer-opacity); + } + } + + /* Sizes. */ + [data-md-button][data-md-size='xs'] { + --md-button-height: 32px; + --md-button-padding: var(--md-sys-measurement-space200); + --md-button-gap: var(--md-sys-measurement-space100); + --md-button-icon: 20px; + --md-button-outline-width: 1px; + --md-button-square: var(--md-sys-shape-corner-md); + --md-button-pressed: var(--md-sys-shape-corner-sm); + --md-button-narrow: 28px; + --md-button-default: 32px; + --md-button-wide: 40px; + } + + [data-md-button][data-md-size='sm'] { + --md-button-height: 40px; + --md-button-padding: var(--md-sys-measurement-space200); + --md-button-gap: var(--md-sys-measurement-space100); + --md-button-icon: 20px; + --md-button-outline-width: 1px; + --md-button-square: var(--md-sys-shape-corner-md); + --md-button-pressed: var(--md-sys-shape-corner-sm); + --md-button-narrow: 32px; + --md-button-default: 40px; + --md-button-wide: 52px; + } + + [data-md-button][data-md-size='sm'][data-md-icon-button] { + --md-button-icon: 24px; + } + + [data-md-button][data-md-size='md'] { + --md-button-height: 56px; + --md-button-padding: var(--md-sys-measurement-space300); + --md-button-gap: var(--md-sys-measurement-space100); + --md-button-icon: 24px; + --md-button-outline-width: 1px; + --md-button-square: var(--md-sys-shape-corner-lg); + --md-button-pressed: var(--md-sys-shape-corner-md); + --md-button-narrow: 48px; + --md-button-default: 56px; + --md-button-wide: 72px; + } + + [data-md-button][data-md-size='lg'] { + --md-button-height: 96px; + --md-button-padding: var(--md-sys-measurement-space600); + --md-button-gap: 12px; + --md-button-icon: 32px; + --md-button-outline-width: 2px; + --md-button-square: var(--md-sys-shape-corner-xl); + --md-button-pressed: var(--md-sys-shape-corner-lg); + --md-button-narrow: 64px; + --md-button-default: 96px; + --md-button-wide: 128px; + } + + [data-md-button][data-md-size='xl'] { + --md-button-height: 136px; + --md-button-padding: var(--md-sys-measurement-space800); + --md-button-gap: var(--md-sys-measurement-space200); + --md-button-icon: 40px; + --md-button-outline-width: 3px; + --md-button-square: var(--md-sys-shape-corner-xl); + --md-button-pressed: var(--md-sys-shape-corner-lg); + --md-button-narrow: 104px; + --md-button-default: 136px; + --md-button-wide: 184px; + } + + [data-md-button]:not([data-md-icon-button]) { + gap: var(--md-button-gap); + padding-inline: var(--md-button-padding); + } + + [data-md-button]:not([data-md-icon-button]):is([data-md-size='xs'], [data-md-size='sm']) { + font: var(--md-sys-typescale-label-lg); + letter-spacing: var(--md-sys-typescale-label-lg-tracking); + font-variation-settings: normal; + } + + [data-md-button]:not([data-md-icon-button])[data-md-size='md'] { + font: var(--md-sys-typescale-title-md); + letter-spacing: var(--md-sys-typescale-title-md-tracking); + font-variation-settings: normal; + } + + [data-md-button]:not([data-md-icon-button])[data-md-size='lg'] { + font: var(--md-sys-typescale-headline-sm); + letter-spacing: var(--md-sys-typescale-headline-sm-tracking); + font-variation-settings: normal; + } + + [data-md-button]:not([data-md-icon-button])[data-md-size='xl'] { + font: var(--md-sys-typescale-headline-lg); + letter-spacing: var(--md-sys-typescale-headline-lg-tracking); + font-variation-settings: normal; + } + + [data-md-button][data-md-icon-button] { + --md-button-width: var(--md-button-default); + + inline-size: var(--md-button-width); + + &[data-md-width='narrow'] { + --md-button-width: var(--md-button-narrow); + } + + &[data-md-width='wide'] { + --md-button-width: var(--md-button-wide); + } + } + + /* M3's minimum target, past a button drawn smaller than 48px. */ + [data-md-button]:is([data-md-size='xs'], [data-md-size='sm'])::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%); + } + + /* Shape, without specificity: a group reshapes the buttons in it. */ + :where([data-md-button]) { + border-radius: var(--md-sys-shape-corner-full); + } + + :where([data-md-button]):where([data-md-shape='square'], [data-md-selected='true']) { + border-radius: var(--md-button-square); + } + + :where([data-md-button]):where([data-md-icon-button][data-md-shape='square'][data-md-selected='true']) { + border-radius: var(--md-sys-shape-corner-full); + } + + :where([data-md-button]):where(:active) { + border-radius: var(--md-button-pressed); + } + + /* Colour roles. */ + [data-md-button][data-md-color='secondary'] { + --md-button-color: var(--md-sys-color-secondary); + --md-button-on-color: var(--md-sys-color-on-secondary); + --md-button-tone: var(--md-sys-color-secondary-container); + --md-button-on-tone: var(--md-sys-color-on-secondary-container); + } + + [data-md-button][data-md-color='tertiary'] { + --md-button-color: var(--md-sys-color-tertiary); + --md-button-on-color: var(--md-sys-color-on-tertiary); + --md-button-tone: var(--md-sys-color-tertiary-container); + --md-button-on-tone: var(--md-sys-color-on-tertiary-container); + } + + [data-md-button][data-md-color='error'] { + --md-button-color: var(--md-sys-color-error); + --md-button-on-color: var(--md-sys-color-on-error); + --md-button-tone: var(--md-sys-color-error-container); + --md-button-on-tone: var(--md-sys-color-on-error-container); + } + + [data-md-button][data-md-color='success'] { + --md-button-color: var(--md-sys-color-success); + --md-button-on-color: var(--md-sys-color-on-success); + --md-button-tone: var(--md-sys-color-success-container); + --md-button-on-tone: var(--md-sys-color-on-success-container); + } + + [data-md-button][data-md-color='warning'] { + --md-button-color: var(--md-sys-color-warning); + --md-button-on-color: var(--md-sys-color-on-warning); + --md-button-tone: var(--md-sys-color-warning-container); + --md-button-on-tone: var(--md-sys-color-on-warning-container); + } + + [data-md-button][data-md-color='info'] { + --md-button-color: var(--md-sys-color-info); + --md-button-on-color: var(--md-sys-color-on-info); + --md-button-tone: var(--md-sys-color-info-container); + --md-button-on-tone: var(--md-sys-color-on-info-container); + } + + /* Every colour but primary selects into the colour itself and is quiet in it. */ + [data-md-button]:not([data-md-color='primary']) { + --md-button-tone-selected: var(--md-button-color); + --md-button-on-tone-selected: var(--md-button-on-color); + --md-button-quiet: var(--md-button-color); + } + + /* Variants. */ + [data-md-button][data-md-variant='filled'] { + --md-button-container: var(--md-button-color); + --md-button-label: var(--md-button-on-color); + + &[data-md-selected='false'] { + --md-button-container: var(--md-sys-color-surface-container); + --md-button-label: var(--md-sys-color-on-surface-variant); + } + } + + [data-md-button][data-md-variant='tonal'] { + --md-button-container: var(--md-button-tone); + --md-button-label: var(--md-button-on-tone); + + &[data-md-selected='true'] { + --md-button-container: var(--md-button-tone-selected); + --md-button-label: var(--md-button-on-tone-selected); + } + } + + [data-md-button][data-md-variant='outlined'] { + --md-button-label: var(--md-button-quiet); + --md-button-outline: var(--md-sys-color-outline-variant); + + border: var(--md-button-outline-width) solid var(--md-button-outline); + + &[data-md-selected='true'] { + --md-button-container: var(--md-sys-color-inverse-surface); + --md-button-label: var(--md-sys-color-inverse-on-surface); + --md-button-outline: transparent; + } + } + + [data-md-button][data-md-variant='elevated'] { + --md-button-container: var(--md-sys-color-surface-container-low); + --md-button-elevation: var(--md-sys-elevation-1); + + &[data-md-selected='true'] { + --md-button-container: var(--md-button-color); + --md-button-label: var(--md-button-on-color); + } + } + + [data-md-button][data-md-variant='text'][data-md-icon-button]:not([data-md-selected='true']) { + --md-button-label: var(--md-button-quiet); + } + + [data-md-button][data-md-variant='text']:not([data-md-icon-button])[data-md-selected='true'] { + --md-button-container: var(--md-button-tone); + --md-button-label: var(--md-button-on-tone); + } + + @media (hover: hover) { + [data-md-button]:is([data-md-variant='filled'], [data-md-variant='tonal']):hover { + --md-button-elevation: var(--md-sys-elevation-1); + } + + [data-md-button][data-md-variant='elevated']:hover { + --md-button-elevation: var(--md-sys-elevation-2); + } + } + + /* Disabled: no state layer, no shadow, M3's disabled colours over whatever the variant set. */ + [data-md-button]:is(:disabled, [aria-disabled='true']) { + box-shadow: none; + color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent); + + &::before { + display: none; + } + } + + [data-md-button]:is([data-md-variant='filled'], [data-md-variant='tonal'], [data-md-variant='elevated'], [data-md-variant='outlined'][data-md-selected='true']):is(:disabled, [aria-disabled='true']) { + background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent); + } + + [data-md-button]:disabled { + cursor: not-allowed; + } + + [data-md-button][aria-disabled='true'] { + pointer-events: none; + } + + /* The glyph's place, and the loading indicator that stands in for it. */ + [data-md-button-icon] { + display: contents; + } + + [data-md-button-spinner] { + align-items: center; + justify-content: center; + } + + [data-md-button-spinner] > [data-md-loading] { + inline-size: var(--md-button-icon); + block-size: var(--md-button-icon); + color: currentColor; + } + + @media (width < 840px) { + [data-md-button] > [data-md-responsive] { + display: none; + } + } + + @media (width < 600px) { + [data-md-button][data-md-compact-fab] { + --md-button-height: 56px; + --md-button-padding: var(--md-sys-measurement-space200); + --md-button-gap: var(--md-sys-measurement-space100); + + position: fixed; + inset-inline-end: var(--md-sys-measurement-space200); + bottom: calc(var(--material-bottom-bar, 0px) + var(--material-snackbar-height, 0px) + var(--md-sys-measurement-space200)); + z-index: 30; + border-radius: var(--md-sys-shape-corner-lg); + background-color: var(--md-sys-color-primary-container); + color: var(--md-sys-color-on-primary-container); + box-shadow: var(--md-sys-elevation-3); + font: var(--md-sys-typescale-title-md); + letter-spacing: var(--md-sys-typescale-title-md-tracking); + font-variation-settings: normal; + } + + [data-md-button][data-md-compact-fab] [data-md-icon] { + inline-size: 24px; + block-size: 24px; + } + } +} diff --git a/resources/css/components/fab.css b/resources/css/components/fab.css new file mode 100644 index 00000000..da57d3f4 --- /dev/null +++ b/resources/css/components/fab.css @@ -0,0 +1,207 @@ +/* + * : M3 Expressive's floating action button and extended FAB. + * + * FabBaseline/Medium/LargeTokens and ExtendedFab*Tokens (androidx Compose Material 3, Apache-2.0), + * per `data-md-size`: + * + * size FAB corner glyph extended: min width gap padding type + * sm 56px lg (16px) 24px 80px 8px 16px title-md + * md 80px lg-increased 28px 80px 16px 26px title-lg + * lg 96px xl (28px) 32px 96px 20px 28px headline-sm + * + * A FAB and its extended form share the height and the corner. `data-md-color` is `primary`, + * `secondary` or `tertiary`, drawn in its container (`data-md-variant="container"`) or in the + * colour itself (`filled`), at elevation 3, 4 under a hovering pointer, the shadow and colours on + * the fast effects spring. The state layer and focus ring are the declarations of the foundation's + * `md-state-layer` and `md-focus-ring`. `data-md-fab` on the root is also the hook a place uses to + * draw a nested FAB its own way: a navigation rail or a docked toolbar flattens it to elevation 0. + * + * `data-md-collapse-on-scroll` is M3's extended FAB that "can collapse to a FAB on scroll-down and + * re-expand to extended on scroll-up — when switching between FAB↔extended FAB, shape changes, the + * icon moves left, and the text label fades in/out" (resources/js/fab.js sets + * `data-md-collapsed`). `width: auto` cannot transition, so the label sits in a one-column grid + * whose track closes to zero; that track is what animates, and the button lays itself out around + * it every frame. The gap and the minimum width go with it, all on the default spatial spring, + * while the label fades on the effects spring. The collapsed width is the FAB's own size. The + * extended padding already centres the glyph at `sm` (16 + 24 + 16) and `md` (26 + 28 + 26); at + * `lg` its 28px is 4px short of the 32px that centres a 32px glyph in 96px, so that one moves. The + * label stays in the accessibility tree — clipped and transparent, never `display: none` — so the + * collapsed FAB keeps the name the extended one had. Under reduced motion tokens/motion.css takes + * every duration to zero, so it swaps. + */ + +@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility; + +@import './icon.css'; +@import './tooltip.css'; + +@layer material.components { + [data-md-fab] { + --md-fab-color: var(--md-sys-color-primary); + --md-fab-on-color: var(--md-sys-color-on-primary); + --md-fab-container: var(--md-sys-color-primary-container); + --md-fab-on-container: var(--md-sys-color-on-primary-container); + + position: relative; + isolation: isolate; + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + block-size: var(--md-fab-size); + border-radius: var(--md-fab-corner); + background-color: var(--md-fab-container); + color: var(--md-fab-on-container); + box-shadow: var(--md-sys-elevation-3); + white-space: nowrap; + cursor: pointer; + -webkit-user-select: none; + user-select: none; + outline: none; + transition-property: box-shadow, background-color, color; + transition-duration: var(--md-sys-motion-effects-fast-duration); + transition-timing-function: 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 { + box-shadow: var(--md-sys-elevation-4); + } + + &:hover::before { + opacity: var(--md-sys-state-hover-state-layer-opacity); + } + } + + &:focus-visible { + outline: 3px solid var(--md-sys-color-secondary); + outline-offset: 2px; + } + + &:focus-visible::before { + opacity: var(--md-sys-state-focus-state-layer-opacity); + } + + &:active::before { + opacity: var(--md-sys-state-pressed-state-layer-opacity); + } + } + + [data-md-fab][data-md-size='sm'] { + --md-fab-size: 56px; + --md-fab-corner: var(--md-sys-shape-corner-lg); + --md-fab-extended-min: 80px; + } + + [data-md-fab][data-md-size='md'] { + --md-fab-size: 80px; + --md-fab-corner: var(--md-sys-shape-corner-lg-increased); + --md-fab-extended-min: 80px; + } + + [data-md-fab][data-md-size='lg'] { + --md-fab-size: 96px; + --md-fab-corner: var(--md-sys-shape-corner-xl); + --md-fab-extended-min: 96px; + } + + [data-md-fab][data-md-color='secondary'] { + --md-fab-color: var(--md-sys-color-secondary); + --md-fab-on-color: var(--md-sys-color-on-secondary); + --md-fab-container: var(--md-sys-color-secondary-container); + --md-fab-on-container: var(--md-sys-color-on-secondary-container); + } + + [data-md-fab][data-md-color='tertiary'] { + --md-fab-color: var(--md-sys-color-tertiary); + --md-fab-on-color: var(--md-sys-color-on-tertiary); + --md-fab-container: var(--md-sys-color-tertiary-container); + --md-fab-on-container: var(--md-sys-color-on-tertiary-container); + } + + [data-md-fab][data-md-variant='filled'] { + background-color: var(--md-fab-color); + color: var(--md-fab-on-color); + } + + [data-md-fab]:not([data-md-extended]) { + inline-size: var(--md-fab-size); + } + + [data-md-fab][data-md-extended] { + min-inline-size: var(--md-fab-extended-min); + } + + [data-md-fab][data-md-extended][data-md-size='sm'] { + gap: var(--md-sys-measurement-space100); + padding-inline: var(--md-sys-measurement-space200); + font: var(--md-sys-typescale-title-md); + letter-spacing: var(--md-sys-typescale-title-md-tracking); + font-variation-settings: normal; + } + + [data-md-fab][data-md-extended][data-md-size='md'] { + gap: var(--md-sys-measurement-space200); + padding-inline: 26px; + font: var(--md-sys-typescale-title-lg); + letter-spacing: var(--md-sys-typescale-title-lg-tracking); + font-variation-settings: normal; + } + + [data-md-fab][data-md-extended][data-md-size='lg'] { + gap: 20px; + padding-inline: 28px; + font: var(--md-sys-typescale-headline-sm); + letter-spacing: var(--md-sys-typescale-headline-sm-tracking); + font-variation-settings: normal; + } + + /* The extended FAB that collapses to a FAB while the page scrolls down. */ + [data-md-fab][data-md-collapse-on-scroll] { + transition-property: min-inline-size, padding-inline, gap, box-shadow, background-color, color; + transition-duration: + var(--md-sys-motion-spatial-default-duration), var(--md-sys-motion-spatial-default-duration), var(--md-sys-motion-spatial-default-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-default), var(--md-sys-motion-spatial-default), var(--md-sys-motion-spatial-default), + var(--md-sys-motion-effects-fast), var(--md-sys-motion-effects-fast), var(--md-sys-motion-effects-fast); + } + + [data-md-fab-label] { + display: grid; + grid-template-columns: 1fr; + transition-property: grid-template-columns, opacity; + transition-duration: var(--md-sys-motion-spatial-default-duration), var(--md-sys-motion-effects-default-duration); + transition-timing-function: var(--md-sys-motion-spatial-default), var(--md-sys-motion-effects-default); + + & > span { + overflow: hidden; + white-space: nowrap; + } + } + + [data-md-fab][data-md-collapse-on-scroll][data-md-collapsed] { + min-inline-size: var(--md-fab-size); + gap: 0; + + & [data-md-fab-label] { + grid-template-columns: 0fr; + opacity: 0; + } + } + + [data-md-fab][data-md-collapse-on-scroll][data-md-size='lg'][data-md-collapsed] { + padding-inline: var(--md-sys-measurement-space400); + } +} 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/css/components/groups.css b/resources/css/components/groups.css deleted file mode 100644 index de21ac79..00000000 --- a/resources/css/components/groups.css +++ /dev/null @@ -1,164 +0,0 @@ -/* - * Button groups and split buttons: shapes that depend on a button's place among its siblings. - * - * Unlayered on purpose. Each draws its own corners and padding as utilities, and a - * rule in any @layer loses to a utility whatever its specificity; the group has to win. - * - * Standard group (``): pressing a label button widens it and narrows its - * neighbours by the same amount, so the row keeps its length — Expressive's press expansion - * (ButtonGroupDefaults.ExpandedRatio is 15%; this uses a fixed step per size). Icon buttons keep - * their width. - * - * Connected group (``, ``): 2px apart, the outer corners - * full, the inner corners small, smaller still while pressed, and a selected segment fully round - * (ConnectedButtonGroupSmallTokens and the M3 Expressive connected button group spec). - * - * Shape (`shape="square"`, M3's "Default shape | Round, square" configuration): `--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 same corner its inner edges take, which is the square table M3 publishes for connected - * groups (4/8/8/16/20dp by size). A square *standard* group has no inner edges, so it hands its - * buttons the square corner scale `` draws (`--group-square`, and - * `--group-square-pressed` under the finger) instead of asking the caller to write it on each - * one. Selection morphs the other way in both: M3 has a toggle inside a group "swap shape - * square/round on selection", so a selected button in a square group turns round, which is why - * the selected rules keep `--group-full` rather than `--group-outer`. - * - * Split button (``): the same idea for two halves, on its own corner scale — - * SplitButton*Tokens gives the inner corner 4/4/4/8/12px at rest and *grows* it to 8/12/12/20/20px - * under the finger, the opposite of a connected group, so `--split-inner*` is separate from - * `--group-inner*`. The trailing half turns round and its chevron turns over while its menu is - * open, on M3's standard scheme rather than the expressive springs (the site: "the trailing - * button uses the standard motion scheme, not expressive, when rotating"), and the chevron sits - * a little towards the leading half so the pair reads as one control (`--split-nudge`: - * 1/1/2/3/6px). `--split-icon` is the trailing icon's own size, 22/22/26/38/50px, which the - * icon-button table does not know about. - * - * "Full" here is half the size's height (`--group-full`), 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. - */ - -[data-button-group] { - --group-inner: var(--md-sys-shape-corner-sm); - --group-inner-pressed: var(--md-sys-shape-corner-xs); - --group-full: 1.25rem; - --group-square: var(--md-sys-shape-corner-md); - --group-square-pressed: var(--md-sys-shape-corner-sm); - --group-outer: var(--group-full); -} - -[data-button-group][data-size='xs'] { --group-pad: 1rem; --group-grow: 4px; --group-inner: var(--md-sys-shape-corner-xs); --group-inner-pressed: 2px; --group-full: 1rem; } -[data-button-group][data-size='sm'] { --group-pad: 1rem; --group-grow: 6px; --group-full: 1.25rem; } -[data-button-group][data-size='md'] { --group-pad: 1.5rem; --group-grow: 8px; --group-full: 1.75rem; --group-square: var(--md-sys-shape-corner-lg); --group-square-pressed: var(--md-sys-shape-corner-md); } -[data-button-group][data-size='lg'] { --group-pad: 3rem; --group-grow: 16px; --group-inner: var(--md-sys-shape-corner-lg); --group-inner-pressed: var(--md-sys-shape-corner-md); --group-full: 3rem; --group-square: var(--md-sys-shape-corner-xl); --group-square-pressed: var(--md-sys-shape-corner-lg); } -[data-button-group][data-size='xl'] { --group-pad: 4rem; --group-grow: 20px; --group-inner: var(--md-sys-shape-corner-lg-increased); --group-inner-pressed: var(--md-sys-shape-corner-lg); --group-full: 4.25rem; --group-square: var(--md-sys-shape-corner-xl); --group-square-pressed: var(--md-sys-shape-corner-lg); } - -/* A square group's ends take the corner its inner edges take — M3's square table for connected - groups — and a square standard group hands its buttons the button's own square scale. */ -[data-button-group][data-shape='square'] { --group-outer: var(--group-inner); } - -[data-button-group='standard'][data-shape='square'] > * { - border-radius: var(--group-square); -} - -[data-button-group='standard'][data-shape='square'] > :active:not(:disabled, [aria-disabled='true']) { - border-radius: var(--group-square-pressed); -} - -[data-button-group='standard'][data-shape='square'] > [aria-pressed='true'] { - border-radius: var(--group-full); -} - -[data-button-group='standard'] > :not([data-icon-button]):active:not(:disabled, [aria-disabled='true']) { - padding-inline: calc(var(--group-pad) + var(--group-grow)); -} - -[data-button-group='standard'] > :not([data-icon-button]):has(+ :not([data-icon-button]):active:not(:disabled, [aria-disabled='true'])) { - padding-inline-end: calc(var(--group-pad) - var(--group-grow)); -} - -[data-button-group='standard'] > :not([data-icon-button]):active:not(:disabled, [aria-disabled='true']) + :not([data-icon-button]) { - padding-inline-start: calc(var(--group-pad) - var(--group-grow)); -} - -[data-button-group='split'] { --split-inner: var(--md-sys-shape-corner-xs); --split-inner-pressed: var(--md-sys-shape-corner-md); --split-icon: 22px; --split-nudge: 1px; } -[data-button-group='split'][data-size='xs'] { --split-inner-pressed: var(--md-sys-shape-corner-sm); } -[data-button-group='split'][data-size='md'] { --split-icon: 26px; --split-nudge: 2px; } -[data-button-group='split'][data-size='lg'] { --split-inner: var(--md-sys-shape-corner-sm); --split-inner-pressed: var(--md-sys-shape-corner-lg-increased); --split-icon: 38px; --split-nudge: 3px; } -[data-button-group='split'][data-size='xl'] { --split-inner: var(--md-sys-shape-corner-md); --split-inner-pressed: var(--md-sys-shape-corner-lg-increased); --split-icon: 50px; --split-nudge: 6px; } - -/* - * A connected segment at the two smallest sizes is 32px or 40px tall, and an icon-only one is as - * narrow as it is tall. M3 names both numbers and tells you not to reduce them: "XS and S - * connected button groups have a 48dp target area and a 48dp minimum width". The target is the - * `touch-target` utility on the segment itself; the width is here, where a group can reach the - * ``s a caller put inside it. - */ -[data-button-group='connected'][data-size='xs'] > *, -[data-button-group='connected'][data-size='sm'] > * { - min-inline-size: 3rem; -} - -/* - * Connected segments and split halves take their inner corner from `--group-corner`, so pressing - * or selecting changes one variable and the rounded outer corners stay put. - */ -[data-button-group='connected'] > *, -[data-split] { - border-start-start-radius: var(--group-corner); - border-end-start-radius: var(--group-corner); - border-start-end-radius: var(--group-corner); - border-end-end-radius: var(--group-corner); -} - -[data-button-group='connected'] > * { - --group-corner: var(--group-inner); -} - -[data-button-group='connected'] > :active { - --group-corner: var(--group-inner-pressed); -} - -[data-split] { - --group-corner: var(--split-inner); -} - -[data-split]:active { - --group-corner: var(--split-inner-pressed); -} - -[data-button-group='connected'] > :is([aria-pressed='true'], :has(:checked)), -[data-split='trailing'][aria-expanded='true'] { - --group-corner: var(--group-full); -} - -[data-button-group='connected'] > :first-child, -[data-split='leading'] { - border-start-start-radius: var(--group-outer); - border-end-start-radius: var(--group-outer); -} - -[data-button-group='connected'] > :last-child, -[data-split='trailing'] { - border-start-end-radius: var(--group-outer); - border-end-end-radius: var(--group-outer); -} - -/* - * The trailing chevron: its own size, and `--split-nudge` off centre towards the leading half — - * equal and opposite margins move it by exactly that much inside a centred flex row, in whichever - * direction the writing mode runs. - */ -[data-split='trailing'] svg { - width: var(--split-icon); - height: var(--split-icon); - margin-inline-start: calc(-1 * var(--split-nudge)); - margin-inline-end: var(--split-nudge); - transition: rotate var(--md-sys-motion-duration-short) var(--md-sys-motion-easing-standard); -} - -[data-split='trailing'][aria-expanded='true'] svg { - rotate: 180deg; -} diff --git a/resources/css/components/loading.css b/resources/css/components/loading.css new file mode 100644 index 00000000..45bbc47f --- /dev/null +++ b/resources/css/components/loading.css @@ -0,0 +1,53 @@ +/* + * : M3 Expressive's loading indicator, a shape that morphs through seven Expressive + * shapes while it turns, for a wait with no known length. + * + * The drawing is resources/svg/loading-indicator/, ported from androidx Compose Material 3's + * LoadingIndicator in bin/loading-indicator.mjs (Apache-2.0): LoadingIndicatorTokens' 38px + * indicator in a 48px container, which is the size here unless the caller's CSS sizes it. It is + * drawn in the text colour, `primary` (ActiveIndicatorColor) unless the caller colours it; + * `contained` puts it on a `primary-container` circle in `on-primary-container` + * (ContainedContainerColor, ContainedIndicatorColor), for a spinner over content. + * + * The animated drawing is SMIL, so it runs without script. Under `prefers-reduced-motion` the + * still drawing of the same shape takes its place: the view renders both, and this file shows one. + */ + +@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility; + +@layer material.components { + [data-md-loading] { + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + inline-size: 48px; + block-size: 48px; + color: var(--md-sys-color-primary); + + & > svg { + inline-size: 100%; + block-size: 100%; + } + + & > [data-md-loading-still] { + display: none; + } + + @media (prefers-reduced-motion: reduce) { + & > [data-md-loading-animated] { + display: none; + } + + & > [data-md-loading-still] { + display: block; + } + } + } + + [data-md-loading][data-md-contained] { + border-radius: var(--md-sys-shape-corner-full); + background-color: var(--md-sys-color-primary-container); + color: var(--md-sys-color-on-primary-container); + } +} diff --git a/resources/css/components/navigation.css b/resources/css/components/navigation.css index 8b9b5287..dae4acc5 100644 --- a/resources/css/components/navigation.css +++ b/resources/css/components/navigation.css @@ -806,8 +806,8 @@ * within another component, such as the navigation rail, the FAB's resting elevation should be * level 0"). Unlayered on purpose: the FAB draws its shadow with a utility, and a rule in any * layer loses to a utility — the same reason toolbar.css gives for its vibrant recolouring. */ -[data-navigation-rail-header] [data-fab], -[data-navigation-rail-header] [data-fab]:hover { +[data-navigation-rail-header] [data-md-fab], +[data-navigation-rail-header] [data-md-fab]:hover { box-shadow: none; } @@ -821,7 +821,7 @@ * same reason as the rule above: `gap-2` and `min-w-20` are utilities, and a layer would lose to * them. The cap is there because a transition needs a length to travel to; a rail FAB's label is a * word or two. */ -[data-navigation-rail-header] [data-fab] > span { +[data-navigation-rail-header] [data-md-fab] > span { max-width: 16rem; overflow: hidden; white-space: nowrap; @@ -835,7 +835,7 @@ } } -[data-navigation-rail-header] [data-fab] { +[data-navigation-rail-header] [data-md-fab] { @variant rail-collapsed { min-width: 0; aspect-ratio: 1; diff --git a/resources/css/components/split-button.css b/resources/css/components/split-button.css new file mode 100644 index 00000000..4ff426bf --- /dev/null +++ b/resources/css/components/split-button.css @@ -0,0 +1,115 @@ +/* + * : an action and a menu of its alternatives, as two ``s 2px apart. + * + * The halves share one shape (SplitButtonXSmall…XLargeTokens, androidx Compose Material 3, + * Apache-2.0): outer corners full — half the height, 16/20/28/48/68px, never 9999px, which would + * scale the small inner corners away — and inner corners of 4/4/4/8/12px that *grow* under the + * finger to 8/12/12/20/20px, the opposite of a connected button group. The trailing half turns + * fully round while its menu is open (`aria-expanded`). ``'s own corner rules carry no + * specificity, so these win. + * + * At the two smallest sizes the leading half keeps less room on its inner side (10px at `xs`, + * 12px at `sm`) and the trailing half is 48px wide. The trailing chevron has its own size, + * 22/22/26/38/50px, which the icon-button table does not know about, and sits + * `--md-split-button-nudge` (1/1/2/3/6px) towards the leading half so the pair reads as one + * control: equal and opposite inline margins move it by exactly that much inside a centred flex + * row, in whichever direction the writing mode runs. It turns over while the menu is open, on M3's + * standard scheme rather than the expressive springs (the site: "the trailing button uses the + * standard motion scheme, not expressive, when rotating"). + */ + +@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility; + +@import './button.css'; + +@layer material.components { + [data-md-split-button] { + --md-split-button-inner: var(--md-sys-shape-corner-xs); + --md-split-button-inner-pressed: var(--md-sys-shape-corner-md); + --md-split-button-full: 20px; + --md-split-button-icon: 22px; + --md-split-button-nudge: 1px; + + display: inline-flex; + align-items: center; + gap: var(--md-sys-measurement-space25); + } + + [data-md-split-button][data-md-size='xs'] { + --md-split-button-inner-pressed: var(--md-sys-shape-corner-sm); + --md-split-button-full: 16px; + } + + [data-md-split-button][data-md-size='md'] { + --md-split-button-full: 28px; + --md-split-button-icon: 26px; + --md-split-button-nudge: 2px; + } + + [data-md-split-button][data-md-size='lg'] { + --md-split-button-inner: var(--md-sys-shape-corner-sm); + --md-split-button-inner-pressed: var(--md-sys-shape-corner-lg-increased); + --md-split-button-full: 48px; + --md-split-button-icon: 38px; + --md-split-button-nudge: 3px; + } + + [data-md-split-button][data-md-size='xl'] { + --md-split-button-inner: var(--md-sys-shape-corner-md); + --md-split-button-inner-pressed: var(--md-sys-shape-corner-lg-increased); + --md-split-button-full: 68px; + --md-split-button-icon: 50px; + --md-split-button-nudge: 6px; + } + + [data-md-split-button] [data-md-split] { + --md-split-button-corner: var(--md-split-button-inner); + + border-start-start-radius: var(--md-split-button-corner); + border-end-start-radius: var(--md-split-button-corner); + border-start-end-radius: var(--md-split-button-corner); + border-end-end-radius: var(--md-split-button-corner); + } + + [data-md-split-button] [data-md-split]:active { + --md-split-button-corner: var(--md-split-button-inner-pressed); + } + + [data-md-split-button] [data-md-split='trailing'][aria-expanded='true'] { + --md-split-button-corner: var(--md-split-button-full); + } + + [data-md-split-button] [data-md-split='leading'] { + border-start-start-radius: var(--md-split-button-full); + border-end-start-radius: var(--md-split-button-full); + } + + [data-md-split-button] [data-md-split='trailing'] { + border-start-end-radius: var(--md-split-button-full); + border-end-end-radius: var(--md-split-button-full); + } + + [data-md-split-button][data-md-size='xs'] [data-md-split='leading'] { + padding-inline-end: var(--md-sys-measurement-space125); + } + + [data-md-split-button][data-md-size='sm'] [data-md-split='leading'] { + padding-inline-end: 12px; + } + + [data-md-split-button]:is([data-md-size='xs'], [data-md-size='sm']) [data-md-split='trailing'] { + inline-size: var(--md-sys-measurement-space600); + } + + [data-md-split-button] [data-md-split='trailing'] [data-md-icon] { + inline-size: var(--md-split-button-icon); + block-size: var(--md-split-button-icon); + margin-inline-start: calc(-1 * var(--md-split-button-nudge)); + margin-inline-end: var(--md-split-button-nudge); + transition: rotate var(--md-sys-motion-duration-short) var(--md-sys-motion-easing-standard); + } + + [data-md-split-button] [data-md-split='trailing'][aria-expanded='true'] [data-md-icon] { + rotate: 180deg; + } +} diff --git a/resources/css/components/toolbar.css b/resources/css/components/toolbar.css index 5f3dab5f..8cdc6eea 100644 --- a/resources/css/components/toolbar.css +++ b/resources/css/components/toolbar.css @@ -60,11 +60,11 @@ color: var(--md-sys-color-on-primary-container); } -[data-toolbar][data-vibrant] [data-icon-button]:not([aria-pressed="true"]) { +[data-toolbar][data-vibrant] [data-md-icon-button]:not([aria-pressed="true"]) { color: var(--md-sys-color-on-primary-container); } -[data-toolbar][data-vibrant] [data-icon-button][aria-pressed="true"] { +[data-toolbar][data-vibrant] [data-md-icon-button][aria-pressed="true"] { background-color: var(--md-sys-color-surface-container); color: var(--md-sys-color-on-surface); } @@ -72,7 +72,7 @@ /* M3's colour list for a standard toolbar ends "Standard button (Primary)", as the vibrant list ends "Standard button (On primary container)" — the row above. An icon button carries its own ink as a utility, so the container's `color` never reaches it and this rule has to. */ -[data-toolbar]:not([data-vibrant]) [data-icon-button]:not([aria-pressed="true"]) { +[data-toolbar]:not([data-vibrant]) [data-md-icon-button]:not([aria-pressed="true"]) { color: var(--md-sys-color-primary); } @@ -89,8 +89,8 @@ margin-inline-start: auto; } -[data-toolbar-fab] [data-fab], -[data-toolbar-fab] [data-fab]:hover { +[data-toolbar-fab] [data-md-fab], +[data-toolbar-fab] [data-md-fab]:hover { box-shadow: none; } diff --git a/resources/css/components/tooltip.css b/resources/css/components/tooltip.css new file mode 100644 index 00000000..31398847 --- /dev/null +++ b/resources/css/components/tooltip.css @@ -0,0 +1,80 @@ +/* + * : M3's plain tooltip, a short label for a control in the inverse surface. + * + * The bubble is a `popover="manual"` in the top layer, so no `overflow: hidden` parent clips it, + * placed by CSS anchor positioning on its `data-md-side` and flipping to the other side where the + * window has no room. PlainTooltipTokens and TooltipDefaults (androidx Compose Material 3, + * Apache-2.0): `inverse-surface` container with the extra-small corner, `body-small` supporting + * text in `inverse-on-surface`, 8px either side and 4px above and below, 200px wide at most, 4px + * from its anchor. It never takes the pointer, so it can stand over the control it names. + * + * It fades in and out on the fast effects spring; `allow-discrete` keeps `display` and `overlay` + * alive until the fade out has been seen, and `@starting-style` gives the fade in a start. + * resources/js/tooltip.js shows and hides it. + */ + +@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility; + +@layer material.components { + [data-md-tooltip-anchor] { + display: inline-flex; + } + + [data-md-tooltip] { + inset: auto; + margin: 0; + max-inline-size: 200px; + overflow: visible; + border-width: 0; + border-radius: var(--md-sys-shape-corner-xs); + padding-block: var(--md-sys-measurement-space50); + padding-inline: var(--md-sys-measurement-space100); + background-color: var(--md-sys-color-inverse-surface); + color: var(--md-sys-color-inverse-on-surface); + font: var(--md-sys-typescale-body-sm); + letter-spacing: var(--md-sys-typescale-body-sm-tracking); + font-variation-settings: normal; + text-align: center; + white-space: normal; + pointer-events: none; + opacity: 0; + transition-property: opacity, display, overlay; + transition-duration: var(--md-sys-motion-effects-fast-duration); + transition-timing-function: var(--md-sys-motion-effects-fast); + transition-behavior: allow-discrete; + + &:popover-open { + opacity: 1; + + @starting-style { + opacity: 0; + } + } + } + + [data-md-tooltip]:is([data-md-side='top'], [data-md-side='bottom']) { + margin-block: var(--md-sys-measurement-space50); + position-try-fallbacks: flip-block; + } + + [data-md-tooltip]:is([data-md-side='left'], [data-md-side='right']) { + margin-inline: var(--md-sys-measurement-space50); + position-try-fallbacks: flip-inline; + } + + [data-md-tooltip][data-md-side='top'] { + position-area: top; + } + + [data-md-tooltip][data-md-side='bottom'] { + position-area: bottom; + } + + [data-md-tooltip][data-md-side='left'] { + position-area: left; + } + + [data-md-tooltip][data-md-side='right'] { + position-area: right; + } +} diff --git a/resources/css/tailwind.css b/resources/css/tailwind.css index 0ce235c8..b4c358e8 100644 --- a/resources/css/tailwind.css +++ b/resources/css/tailwind.css @@ -18,7 +18,6 @@ @import './tokens/utilities.css'; /* Actions and communication — leaving in step 36 */ @import './components/actions.css'; -@import './components/groups.css'; @import './components/menu.css'; /* Inputs, selection and data — leaving in step 36 */ diff --git a/resources/views/components/badge.blade.php b/resources/views/components/badge.blade.php index 2f1a04ef..d0b1b11d 100644 --- a/resources/views/components/badge.blade.php +++ b/resources/views/components/badge.blade.php @@ -7,10 +7,10 @@ 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: + corner of a `position: relative` parent, on M3's anchor geometry — the dot flush in the corner, + the count 2px above it — an icon or an icon button: - + The status label is not an M3 badge but every app needs one: `tonal` draws the value in the colour's container ("Expired" in error-container), `solid` in the colour itself (a label that @@ -21,13 +21,17 @@ count (the ink an outline badge already writes in), surface-container-high with 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: ``. + caller's CSS paints it. The value is `value`, or the slot, which renders as HTML — an icon beside the word: - ` Pro`. With neither it is a dot. + ` Pro`. 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. --}} + that includes it ("Notifications, 4 new"), or pass `label` here. + + Drawn by resources/css/components/badge.css from `data-md-dot` (the small badge), + `data-md-variant` (a status label: `solid`, `tonal` or `outline`, the first of them that is + set, in that order), `data-md-color` and `data-md-floating`. --}} @props([ 'value' => null, @@ -54,38 +58,23 @@ $markup = false; } - $filled = [ - 'primary' => 'bg-primary text-on-primary', 'secondary' => 'bg-secondary text-on-secondary', 'tertiary' => 'bg-tertiary text-on-tertiary', - 'error' => 'bg-error text-on-error', 'success' => 'bg-success text-on-success', 'warning' => 'bg-warning text-on-warning', 'info' => 'bg-info text-on-info', - 'neutral' => 'bg-on-surface-variant text-surface', - ]; - $container = [ - 'primary' => 'bg-primary-container text-on-primary-container', 'secondary' => 'bg-secondary-container text-on-secondary-container', 'tertiary' => 'bg-tertiary-container text-on-tertiary-container', - 'error' => 'bg-error-container text-on-error-container', 'success' => 'bg-success-container text-on-success-container', 'warning' => 'bg-warning-container text-on-warning-container', 'info' => 'bg-info-container text-on-info-container', - 'neutral' => 'bg-surface-container-high text-on-surface-variant', - ]; - $paint = match (true) { - $color === 'plain' => '', - ! $status, $solid => $filled[$color], - $tonal => $container[$color], - default => 'border-outline text-on-surface-variant', + // `solid` wins over `tonal`, and either over `outline`, as the three have always resolved. + $variant = match (true) { + ! $status => null, + (bool) $solid => 'solid', + (bool) $tonal => 'tonal', + default => 'outline', }; - $attributes = $attributes - ->class([ - 'inline-flex shrink-0 items-center justify-center whitespace-nowrap', - 'size-1.5 rounded-corner-full' => $dot, - 'h-4 min-w-4 rounded-corner-full px-1 type-label-sm tabular-nums' => ! $dot && ! $status, - 'h-6 gap-1 rounded-corner-sm px-2 type-label-md' => $status, - 'border' => $outline && ! $tonal && ! $solid && ! $dot, - $paint => $paint !== '', - 'absolute top-0 end-0' => $floating && $dot, - 'absolute -top-0.5 start-[calc(100%-0.75rem)]' => $floating && ! $dot, - ]) - ->merge(array_filter([ - 'aria-label' => $label, - 'aria-hidden' => $label === null && ! $status ? 'true' : null, - ])); + $attributes = $attributes->merge(array_filter([ + 'data-md-badge' => true, + 'data-md-dot' => $dot ? true : null, + 'data-md-variant' => $variant, + 'data-md-color' => $color, + 'data-md-floating' => $floating ? true : null, + 'aria-label' => $label, + 'aria-hidden' => $label === null && ! $status ? 'true' : null, + ], fn ($value): bool => $value !== null)); @endphp @unless ($dot)@if ($markup){{ $slot }}@else{{ $text }}@endif@endunless diff --git a/resources/views/components/button-group.blade.php b/resources/views/components/button-group.blade.php index 3f5b5a4e..b3c8cdb7 100644 --- a/resources/views/components/button-group.blade.php +++ b/resources/views/components/button-group.blade.php @@ -45,7 +45,7 @@ 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. --}} + spacing, shapes and press expansion are resources/css/components/button-group.css. --}} @props([ 'connected' => false, @@ -71,12 +71,12 @@
class([ - 'inline-flex items-center', - 'gap-0.5' => $connected, - 'gap-[18px]' => ! $connected && $size === 'xs', - 'gap-3' => ! $connected && $size === 'sm', - 'gap-2' => ! $connected && in_array($size, ['md', 'lg', 'xl'], true), - ]) }} + {{ $attributes }} > {{ $slot }}
diff --git a/resources/views/components/button.blade.php b/resources/views/components/button.blade.php index 4e8fab32..8a0f8c4a 100644 --- a/resources/views/components/button.blade.php +++ b/resources/views/components/button.blade.php @@ -9,9 +9,8 @@ `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, while the colours cross on the fast effects spring beside it - (`state-transition-fast`, resources/css/components/actions.css: a colour must never - overshoot). + fast spatial spring, while the colours cross on the fast effects spring beside it (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 @@ -39,8 +38,9 @@ (`--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. --}} + The view renders the props as `data-md-*` attributes and resources/css/components/button.css + draws them, in the package's components layer: a caller's class or unlayered CSS outranks + every rule there, `hidden` included. --}} @props([ 'label' => null, @@ -68,7 +68,6 @@ 'fab' => false, 'disabled' => false, 'type' => 'button', - 'corners' => null, ]) @php @@ -86,7 +85,7 @@ }; $size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'sm'; - $square = $shape === 'square'; + $shape = $shape === 'square' ? 'square' : 'round'; $width = in_array($width, ['narrow', 'default', 'wide'], true) ? $width : 'default'; $iconOnly = filled($icon) && blank($label) && $slot->isEmpty(); $isLink = filled($link); @@ -107,134 +106,61 @@ default => null, }; - // Every colour class written out whole, so Tailwind compiles it. - $ink = [ - 'primary' => 'text-primary', 'secondary' => 'text-secondary', 'tertiary' => 'text-tertiary', - 'error' => 'text-error', 'success' => 'text-success', 'warning' => 'text-warning', 'info' => 'text-info', - ]; - $fill = [ - 'primary' => 'bg-primary text-on-primary', 'secondary' => 'bg-secondary text-on-secondary', - 'tertiary' => 'bg-tertiary text-on-tertiary', 'error' => 'bg-error text-on-error', - 'success' => 'bg-success text-on-success', 'warning' => 'bg-warning text-on-warning', 'info' => 'bg-info text-on-info', - ]; - $container = [ - 'primary' => 'bg-secondary-container text-on-secondary-container', 'secondary' => 'bg-secondary-container text-on-secondary-container', - 'tertiary' => 'bg-tertiary-container text-on-tertiary-container', 'error' => 'bg-error-container text-on-error-container', - 'success' => 'bg-success-container text-on-success-container', 'warning' => 'bg-warning-container text-on-warning-container', - 'info' => 'bg-info-container text-on-info-container', - ]; - $containerSelected = ['primary' => 'bg-secondary text-on-secondary'] + $fill; - $quietInk = ['primary' => 'text-on-surface-variant'] + $ink; - - $colours = match ($variant) { - 'filled' => $selected === false ? 'bg-surface-container text-on-surface-variant' : $fill[$color], - 'tonal' => $selected === true ? $containerSelected[$color] : $container[$color], - 'outlined' => $selected === true ? 'bg-inverse-surface text-inverse-on-surface border-transparent' : 'border-outline-variant '.$quietInk[$color], - 'elevated' => $selected === true ? $fill[$color] : 'bg-surface-container-low '.$ink[$color], - 'text' => match (true) { - $iconOnly => $selected === true ? $ink[$color] : $quietInk[$color], - default => $selected === true ? $container[$color] : $ink[$color], - }, - }; - - $squareCorners = ['xs' => 'rounded-corner-md', 'sm' => 'rounded-corner-md', 'md' => 'rounded-corner-lg', 'lg' => 'rounded-corner-xl', 'xl' => 'rounded-corner-xl']; - $pressed = ['xs' => 'active:rounded-corner-sm', 'sm' => 'active:rounded-corner-sm', 'md' => 'active:rounded-corner-md', 'lg' => 'active:rounded-corner-lg', 'xl' => 'active:rounded-corner-lg']; - - $corner = match (true) { - $iconOnly && $selected === true => $square ? 'rounded-corner-full' : $squareCorners[$size], - $selected === true, $square => $squareCorners[$size], - default => 'rounded-corner-full', - }; - - $dimensions = $iconOnly - ? [ - 'xs' => ['narrow' => 'h-8 w-7', 'default' => 'size-8', 'wide' => 'h-8 w-10'], - 'sm' => ['narrow' => 'h-10 w-8', 'default' => 'size-10', 'wide' => 'h-10 w-13'], - 'md' => ['narrow' => 'h-14 w-12', 'default' => 'size-14', 'wide' => 'h-14 w-18'], - 'lg' => ['narrow' => 'h-24 w-16', 'default' => 'size-24', 'wide' => 'h-24 w-32'], - 'xl' => ['narrow' => 'h-34 w-26', 'default' => 'size-34', 'wide' => 'h-34 w-46'], - ][$size][$width] - : [ - '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]; - + // The glyph's size in px (button.css draws the loading indicator at the same size). M3 draws a + // symbol from its 20px cut below 24px, which the icon component picks from the size. $iconSize = $iconOnly - ? ['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', - 'state-transition-fast', - $dimensions, - // A composite component (a split button's halves) passes the corners its place needs. - $corners ?? $corner.' '.$pressed[$size], - $colours, - $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 (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)+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, - ]; + ? ['xs' => 20, 'sm' => 24, 'md' => 24, 'lg' => 32, 'xl' => 40][$size] + : ['xs' => 20, 'sm' => 20, 'md' => 24, 'lg' => 32, 'xl' => 40][$size]; $tag = $isLink ? 'a' : 'button'; - $attributes = $attributes - ->class($classes) - ->merge(array_filter([ - 'href' => $isLink ? $link : null, - 'target' => $isLink && $external ? '_blank' : null, - 'rel' => $isLink && $external ? 'noopener' : null, - 'wire:navigate' => $isLink && ! $external && ! $noWireNavigate && ! $attributes->has('wire:navigate') ? true : null, - 'aria-disabled' => $inert ? 'true' : null, - 'tabindex' => $inert ? '-1' : null, - 'type' => $isLink ? null : $type, - 'disabled' => ! $isLink && $disabled ? true : null, - 'aria-label' => $iconOnly && ! $attributes->has('aria-label') ? ($label ?? $tip) : null, - // A link is not a toggle: ARIA defines aria-pressed for buttons only. A selected link keeps - // the selected look; `aria-current` is the caller's to set (`:aria-current="'page'"`). - 'aria-pressed' => $selected === null || $isLink ? null : ($selected ? 'true' : 'false'), - 'data-icon-button' => $iconOnly ? true : null, - 'wire:loading.attr' => $spinnerTarget ? 'disabled' : null, - 'wire:target' => $spinnerTarget, - 'style' => $anchor ? "anchor-name: {$anchor}" : null, - ], fn ($value): bool => $value !== null)); + $attributes = $attributes->merge(array_filter([ + 'data-md-button' => true, + 'data-md-variant' => $variant, + 'data-md-color' => $color, + 'data-md-size' => $size, + 'data-md-shape' => $shape, + 'data-md-icon-button' => $iconOnly ? true : null, + 'data-md-width' => $iconOnly ? $width : null, + 'data-md-selected' => $selected === null ? null : ($selected ? 'true' : 'false'), + 'data-md-compact-fab' => $fab ? true : null, + 'href' => $isLink ? $link : null, + 'target' => $isLink && $external ? '_blank' : null, + 'rel' => $isLink && $external ? 'noopener' : null, + 'wire:navigate' => $isLink && ! $external && ! $noWireNavigate && ! $attributes->has('wire:navigate') ? true : null, + 'aria-disabled' => $inert ? 'true' : null, + 'tabindex' => $inert ? '-1' : null, + 'type' => $isLink ? null : $type, + 'disabled' => ! $isLink && $disabled ? true : null, + 'aria-label' => $iconOnly && ! $attributes->has('aria-label') ? ($label ?? $tip) : null, + // A link is not a toggle: ARIA defines aria-pressed for buttons only. A selected link keeps + // the selected look; `aria-current` is the caller's to set (`:aria-current="'page'"`). + 'aria-pressed' => $selected === null || $isLink ? null : ($selected ? 'true' : 'false'), + 'wire:loading.attr' => $spinnerTarget ? 'disabled' : null, + 'wire:target' => $spinnerTarget, + 'style' => $anchor ? "anchor-name: {$anchor}" : null, + ], fn ($value): bool => $value !== null)); @endphp <{{ $tag }} {{ $attributes }}> @if ($spinnerTarget) - - + + @endif @if ($icon) - - + + @endif @unless ($iconOnly) - $responsive])>{{ $label ?? $slot }} + {{ $label ?? $slot }} @endunless @if ($iconRight) - + @endif @if ($tip !== null) diff --git a/resources/views/components/fab.blade.php b/resources/views/components/fab.blade.php index c9aefaea..c635cfa1 100644 --- a/resources/views/components/fab.blade.php +++ b/resources/views/components/fab.blade.php @@ -6,8 +6,8 @@ drawn in its container, or in the colour itself with `variant="filled"`. It does not place itself: put it where the layout wants it, e.g. - `
`. For a - create action that is a FAB on a phone and a header button above, use ``. + `
`. + For a create action that is a FAB on a phone and a header button above, use ``. 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"). @@ -17,19 +17,19 @@ 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 + `data-md-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. `collapse-on-scroll` is M3's extended FAB that "can collapse to a FAB on scroll-down and re-expand to extended on scroll-up": while the window scrolls down it shrinks to the FAB of its size, and it extends again when the page scrolls back up or reaches the top. The label - closes and fades while the width follows it on the spatial spring (resources/css/components/ - actions.css); under reduced motion the two swap outright. The label stays in the page, - clipped rather than removed, so the collapsed FAB keeps its accessible name. It needs an - `icon` — a FAB with no glyph is no FAB — and does nothing on a plain FAB. + closes and fades while the width follows it on the spatial spring; under reduced motion the + two swap outright. The label stays in the page, clipped rather than removed, so the collapsed + FAB keeps its accessible name. It needs an `icon` — a FAB with no glyph is no FAB — and does + nothing on a plain FAB. Sizes, corners and elevation from FabBaseline/Medium/LargeTokens and ExtendedFab*Tokens - (androidx Compose Material 3, Apache-2.0). --}} + (androidx Compose Material 3, Apache-2.0), drawn by resources/css/components/fab.css. --}} @props([ 'icon' => null, @@ -47,53 +47,42 @@ @php $size = in_array($size, ['sm', 'md', 'lg'], true) ? $size : 'sm'; $color = in_array($color, ['primary', 'secondary', 'tertiary'], true) ? $color : 'primary'; + $variant = $variant === 'filled' ? 'filled' : 'container'; $extended = filled($label) || $slot->isNotEmpty(); $collapsing = $collapseOnScroll && $extended && filled($icon); $isLink = filled($link); - $colours = $variant === 'filled' - ? ['primary' => 'bg-primary text-on-primary', 'secondary' => 'bg-secondary text-on-secondary', 'tertiary' => 'bg-tertiary text-on-tertiary'][$color] - : ['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-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]; + $iconSize = ['sm' => 24, 'md' => 28, 'lg' => 32][$size]; $anchor = $tooltip !== null ? '--material-fab-'.\Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10)) : null; $tag = $isLink ? 'a' : 'button'; - $attributes = $attributes - ->class([ - 'state-layer focus-ring inline-flex shrink-0 cursor-pointer select-none items-center justify-center whitespace-nowrap', - '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, - ]) - ->merge(array_filter([ - 'href' => $isLink ? $link : null, - 'target' => $isLink && $external ? '_blank' : null, - 'rel' => $isLink && $external ? 'noopener' : null, - 'wire:navigate' => $isLink && ! $external && ! $attributes->has('wire:navigate') ? true : null, - 'type' => $isLink ? null : $type, - // The hook a place uses to draw a nested FAB its own way: a rail flattens it to 0dp. - 'data-fab' => true, - 'x-data' => $collapsing ? 'materialFab' : null, - 'x-bind:data-collapsed' => $collapsing ? "collapsed ? '' : null" : null, - 'data-fab-collapsible' => $collapsing ? true : null, - 'data-fab-size' => $collapsing ? $size : null, - 'aria-label' => ! $extended && ! $attributes->has('aria-label') ? $tooltip : null, - 'style' => $anchor ? "anchor-name: {$anchor}" : null, - ], fn ($value): bool => $value !== null)); + $attributes = $attributes->merge(array_filter([ + // The hook a place uses to draw a nested FAB its own way: a rail flattens it to 0dp. + 'data-md-fab' => true, + 'data-md-size' => $size, + 'data-md-color' => $color, + 'data-md-variant' => $variant, + 'data-md-extended' => $extended ? true : null, + 'data-md-collapse-on-scroll' => $collapsing ? true : null, + 'href' => $isLink ? $link : null, + 'target' => $isLink && $external ? '_blank' : null, + 'rel' => $isLink && $external ? 'noopener' : null, + 'wire:navigate' => $isLink && ! $external && ! $attributes->has('wire:navigate') ? true : null, + 'type' => $isLink ? null : $type, + 'x-data' => $collapsing ? 'materialFab' : null, + 'x-bind:data-md-collapsed' => $collapsing ? "collapsed ? '' : null" : null, + 'aria-label' => ! $extended && ! $attributes->has('aria-label') ? $tooltip : null, + 'style' => $anchor ? "anchor-name: {$anchor}" : null, + ], fn ($value): bool => $value !== null)); @endphp <{{ $tag }} {{ $attributes }}> @if ($icon) - + @endif @if ($collapsing) - {{ $label ?? $slot }} + {{ $label ?? $slot }} @elseif ($extended) {{ $label ?? $slot }} @endif 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/resources/views/components/loading.blade.php b/resources/views/components/loading.blade.php index e6c925ab..c105ad22 100644 --- a/resources/views/components/loading.blade.php +++ b/resources/views/components/loading.blade.php @@ -1,16 +1,17 @@ {{-- M3 Expressive's loading indicator: a shape that morphs through seven Expressive shapes while it turns, for a wait that has no known length. - In the text colour — primary unless the caller colours it — and 48px unless sized. `contained` - puts it on a primary-container circle, for a spinner over content. It is a `progressbar` - named `label` ("Loading" by default); pass `:label="false"` where something else already - says what is happening, as a button's own spinner does. + In the text colour — primary unless the caller colours it — and 48px unless the caller's CSS + sizes it. `contained` puts it on a primary-container circle, for a spinner over content. It + is a `progressbar` named `label` ("Loading" by default); pass `:label="false"` where something + else already says what is happening, as a button's own spinner does. Pure SVG with SMIL, so it animates in every engine without script. Under `prefers-reduced-motion` the shape rests. The geometry is androidx Compose Material 3's LoadingIndicator and Morph, ported in bin/loading-indicator.mjs (Apache-2.0): LoadingIndicatorTokens' 38px indicator in a 48px container, a morph every 650ms on a spring, - a quarter turn per morph and a full turn every 4666ms. --}} + a quarter turn per morph and a full turn every 4666ms. Drawn by + resources/css/components/loading.css. --}} @props([ 'contained' => false, @@ -18,26 +19,19 @@ ]) @php - $sized = preg_match('/(^|\s)(size|w|h)-/', (string) $attributes->get('class')) === 1; - $coloured = preg_match('/(^|\s)text-(?!(xs|sm|base|lg|xl|[2-9]xl|left|center|right|start|end)(\s|$))/', (string) $attributes->get('class')) === 1; $decorative = $label === false; $label = $decorative ? null : ($label ?? __('Loading')); - $attributes = $attributes - ->class([ - 'inline-flex shrink-0 items-center justify-center', - 'size-12' => ! $sized, - 'rounded-corner-full bg-primary-container text-on-primary-container' => $contained, - 'text-primary' => ! $contained && ! $coloured, - ]) - ->merge(array_filter([ - 'role' => $decorative ? null : 'progressbar', - 'aria-label' => $label, - 'aria-hidden' => $decorative ? 'true' : null, - ])); + $attributes = $attributes->merge(array_filter([ + 'data-md-loading' => true, + 'data-md-contained' => $contained ? true : null, + 'role' => $decorative ? null : 'progressbar', + 'aria-label' => $label, + 'aria-hidden' => $decorative ? 'true' : null, + ], fn ($value): bool => $value !== null)); @endphp - {{ \NoNameWeb\LivewireMaterial\Support\SvgFile::loadingIndicator(true, new \Illuminate\View\ComponentAttributeBag(['class' => 'size-full motion-reduce:hidden', 'aria-hidden' => 'true', 'focusable' => 'false'])) }} - {{ \NoNameWeb\LivewireMaterial\Support\SvgFile::loadingIndicator(false, new \Illuminate\View\ComponentAttributeBag(['class' => 'hidden size-full motion-reduce:block', 'aria-hidden' => 'true', 'focusable' => 'false'])) }} + {{ \NoNameWeb\LivewireMaterial\Support\SvgFile::loadingIndicator(true, new \Illuminate\View\ComponentAttributeBag(['data-md-loading-animated' => true, 'aria-hidden' => 'true', 'focusable' => 'false'])) }} + {{ \NoNameWeb\LivewireMaterial\Support\SvgFile::loadingIndicator(false, new \Illuminate\View\ComponentAttributeBag(['data-md-loading-still' => true, 'aria-hidden' => 'true', 'focusable' => 'false'])) }} diff --git a/resources/views/components/split-button.blade.php b/resources/views/components/split-button.blade.php index ff430b88..9db1dd49 100644 --- a/resources/views/components/split-button.blade.php +++ b/resources/views/components/split-button.blade.php @@ -5,16 +5,16 @@ - Attributes (wire:click, spinner…) go to the leading button; the slot is the menu. `variant` is - `filled` (the default), `tonal`, `outlined` or `elevated` — a text split button does not exist - in M3 — with `color` and `size` as on ``. The halves sit 2px apart; the trailing - one rounds fully and turns its chevron over while the menu is open. `menu-label` names the - trailing button and the menu for screen readers. + Attributes (wire:click, spinner…) go to the leading button, a `class` to the pair; the slot is + the menu. `variant` is `filled` (the default), `tonal`, `outlined` or `elevated` — a text split + button does not exist in M3 — with `color` and `size` as on ``. The halves sit 2px + apart; the trailing one rounds fully and turns its chevron over while the menu is open. + `menu-label` names the trailing button and the menu for screen readers. 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. 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 + wide there. resources/css/components/split-button.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. --}} @@ -35,12 +35,9 @@ $variant = in_array($variant, ['filled', 'tonal', 'outlined', 'elevated'], true) ? $variant : 'filled'; $size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'sm'; $menuLabel ??= __('More options'); - - $leadingPadding = ['xs' => 'pe-2.5', 'sm' => 'pe-3', 'md' => '', 'lg' => '', 'xl' => ''][$size]; - $trailingWidth = ['xs' => 'w-12', 'sm' => 'w-12', 'md' => '', 'lg' => '', 'xl' => ''][$size]; @endphp -
only('class')->class('inline-flex items-center gap-0.5') }}> +
only('class') }}> except('class') }} :label="$label" @@ -50,9 +47,7 @@ :size="$size" :disabled="$disabled" :spinner="$spinner" - corners="" - data-split="leading" - :class="$leadingPadding" + data-md-split="leading" /> @@ -64,9 +59,7 @@ :color="$color" :size="$size" :disabled="$disabled" - corners="" - data-split="trailing" - :class="$trailingWidth" + data-md-split="trailing" /> diff --git a/resources/views/components/theme-toggle.blade.php b/resources/views/components/theme-toggle.blade.php index 206762a8..5ae93846 100644 --- a/resources/views/components/theme-toggle.blade.php +++ b/resources/views/components/theme-toggle.blade.php @@ -84,7 +84,7 @@ '); @@ -7,22 +9,49 @@ it('wraps a trigger and anchors the bubble to it', function () { expect($anchor)->not->toBeEmpty() ->and($html) - ->toContain('') - ->toContain('popover="manual"') - ->toContain('x-data="materialTooltip"') - ->toContain("position-anchor: {$anchor[1]}") - ->toContain('bg-inverse-surface') - ->toContain('[position-area:top]'); + ->toMatch('/\s*