Add the stack layout component and the layout foundation
Plan step 35: <x-stack gap align>, the first of M3's layout components, with what they all share - src/Support/Layout.php reading the props into data-md-* attributes, the spacing-token gap and padding rules, the hide-below/hide-from rules in material.visibility, the stylesheet checks for resources/css/layout, the browser test file and the skill's Layout group. The Workbench puts the material layers above Tailwind's preflight, which would otherwise zero every layout padding and margin. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
co-authored by
Claude Opus 5
parent
6be9f35c98
commit
b677e60876
@@ -770,6 +770,23 @@ The docked view overlaps what is under it; never place a search inside an elemen
|
|||||||
</x-search>
|
</x-search>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
|
||||||
|
M3's layout vocabulary as components (M3 foundations § Layout: scaffold, bars, rails, panes, margins, spacers, and the canonical layouts). `<x-scaffold>` holds the bars, the rail and the FAB around the page; all content lives in panes, `<x-pane>`; two panes side by side are the canonical layouts `<x-list-detail>` and `<x-supporting-pane>`, and `<x-feed>` is the third; `<x-surface>` is a tonal region; inside a pane, `<x-stack>`, `<x-row>` and `<x-grid>` arrange. There is no "page" component in M3 — a page is a pane. Their stylesheets are `resources/css/layout/*.css`, all imported by `resources/css/layout.css`; the list-detail's focus handling is `resources/js/layout.js`, in `material.js`.
|
||||||
|
|
||||||
|
Breakpoints are M3's five, in px: compact below 600, `medium` 600, `expanded` 840, `large` 1200, `extra-large` 1600. Every layout component takes:
|
||||||
|
|
||||||
|
- `as`: the element, `div` unless the component says otherwise — `section`, `article`, `aside`, `main`, `nav`, `header`, `footer`, `ul`, `ol`, `li`, `dl`, `form`, `fieldset`, `figure`, `span`, `p`; anything else draws the default.
|
||||||
|
- `hide-below` / `hide-from`: `medium`, `expanded`, `large` or `extra-large`. Hidden on a window narrower than that breakpoint, or from it on, over the component's own `display` (the `material.visibility` layer). Nothing is below compact, so neither takes `compact`.
|
||||||
|
- `gap` and `padding` take only a spacing token's name, `space25` … `space900`; any other value is no gap or no padding.
|
||||||
|
- The caller's `class` and `style` land on the root untouched, and an application's own CSS outranks every package rule.
|
||||||
|
|
||||||
|
M3's margin — 16px on a compact window, 24px from `medium` — is drawn once: by the scaffold's content region, or by the outermost pane or canonical layout when there is no scaffold. A pane inside one of those draws none, and one on an `<x-surface>`, a new edge, draws it again.
|
||||||
|
|
||||||
|
#### `<x-stack>`
|
||||||
|
|
||||||
|
Children one under another inside a pane: `<x-stack gap="space200">…</x-stack>`. `align` across it: `stretch` (default), `start`, `center`, `end`.
|
||||||
|
|
||||||
### `<x-app-shell>`
|
### `<x-app-shell>`
|
||||||
|
|
||||||
The adaptive app shell, a whole layout's body: one navigation per M3 window size class, the page as `<main id="content" wire:transition.navigate>` behind a skip link, and the snackbar host (do not add another `<x-toast />`). It needs `<x-theme-script />` in `<head>`.
|
The adaptive app shell, a whole layout's body: one navigation per M3 window size class, the page as `<main id="content" wire:transition.navigate>` behind a skip link, and the snackbar host (do not add another `<x-toast />`). It needs `<x-theme-script />` in `<head>`.
|
||||||
|
|||||||
@@ -5,3 +5,5 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
||||||
|
|
||||||
|
@import './layout/stack.css';
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
/*
|
||||||
|
* Gap and padding, the two spacing props of the layout components, on M3's measurement scale.
|
||||||
|
*
|
||||||
|
* Both take only a token's name — `data-md-gap="space200"`, `data-md-padding="space300"` — and the
|
||||||
|
* value is that token, `--md-sys-measurement-space200` (docs/reference/m3/styles-supplement.md
|
||||||
|
* § Spacing; resources/css/tokens/spacing.css). A name the scale does not have renders as `none`
|
||||||
|
* (src/Support/Layout.php): no gap, and no padding.
|
||||||
|
*
|
||||||
|
* A gap is written to `--md-gap`, not to `gap` itself: the stack, row, grid and feed read it (a
|
||||||
|
* grid also counts it into a column's width), and each resets it on itself at zero specificity, so
|
||||||
|
* an arrangement nested in another never inherits its parent's gap. Padding goes on directly.
|
||||||
|
*
|
||||||
|
* In `material.layout`, under every component.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
||||||
|
|
||||||
|
@layer material.layout {
|
||||||
|
[data-md-gap='none'] {
|
||||||
|
--md-gap: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-md-gap='space25'] {
|
||||||
|
--md-gap: var(--md-sys-measurement-space25);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-md-gap='space50'] {
|
||||||
|
--md-gap: var(--md-sys-measurement-space50);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-md-gap='space75'] {
|
||||||
|
--md-gap: var(--md-sys-measurement-space75);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-md-gap='space100'] {
|
||||||
|
--md-gap: var(--md-sys-measurement-space100);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-md-gap='space125'] {
|
||||||
|
--md-gap: var(--md-sys-measurement-space125);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-md-gap='space200'] {
|
||||||
|
--md-gap: var(--md-sys-measurement-space200);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-md-gap='space300'] {
|
||||||
|
--md-gap: var(--md-sys-measurement-space300);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-md-gap='space400'] {
|
||||||
|
--md-gap: var(--md-sys-measurement-space400);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-md-gap='space500'] {
|
||||||
|
--md-gap: var(--md-sys-measurement-space500);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-md-gap='space600'] {
|
||||||
|
--md-gap: var(--md-sys-measurement-space600);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-md-gap='space700'] {
|
||||||
|
--md-gap: var(--md-sys-measurement-space700);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-md-gap='space800'] {
|
||||||
|
--md-gap: var(--md-sys-measurement-space800);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-md-gap='space900'] {
|
||||||
|
--md-gap: var(--md-sys-measurement-space900);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-md-padding='space25'] {
|
||||||
|
padding: var(--md-sys-measurement-space25);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-md-padding='space50'] {
|
||||||
|
padding: var(--md-sys-measurement-space50);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-md-padding='space75'] {
|
||||||
|
padding: var(--md-sys-measurement-space75);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-md-padding='space100'] {
|
||||||
|
padding: var(--md-sys-measurement-space100);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-md-padding='space125'] {
|
||||||
|
padding: var(--md-sys-measurement-space125);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-md-padding='space200'] {
|
||||||
|
padding: var(--md-sys-measurement-space200);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-md-padding='space300'] {
|
||||||
|
padding: var(--md-sys-measurement-space300);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-md-padding='space400'] {
|
||||||
|
padding: var(--md-sys-measurement-space400);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-md-padding='space500'] {
|
||||||
|
padding: var(--md-sys-measurement-space500);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-md-padding='space600'] {
|
||||||
|
padding: var(--md-sys-measurement-space600);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-md-padding='space700'] {
|
||||||
|
padding: var(--md-sys-measurement-space700);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-md-padding='space800'] {
|
||||||
|
padding: var(--md-sys-measurement-space800);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-md-padding='space900'] {
|
||||||
|
padding: var(--md-sys-measurement-space900);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
* <x-stack>: its children one under another, in a pane.
|
||||||
|
*
|
||||||
|
* A flex column, stretched across the stack's width unless `data-md-align` says `start`, `center`
|
||||||
|
* or `end`. The space between the children is `--md-gap` (spacing.css), one of M3's spacing
|
||||||
|
* tokens and none by default; M3 groups with proximity, so the gap is the grouping
|
||||||
|
* (docs/reference/m3/foundations.md § Layout → Grids & spacing). M3 defines no in-pane arrangement
|
||||||
|
* component; this is the neutral one, beside <x-row> and <x-grid>.
|
||||||
|
*
|
||||||
|
* In `material.layout`; `hide-below`/`hide-from` come from visibility.css.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
||||||
|
|
||||||
|
@import './spacing.css';
|
||||||
|
@import './visibility.css';
|
||||||
|
|
||||||
|
@layer material.layout {
|
||||||
|
:where([data-md-stack]) {
|
||||||
|
--md-gap: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-md-stack] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--md-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-md-stack][data-md-align='start'] {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-md-stack][data-md-align='center'] {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-md-stack][data-md-align='end'] {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/*
|
||||||
|
* `hide-below` and `hide-from`, the visibility props every layout component takes.
|
||||||
|
*
|
||||||
|
* The breakpoints are M3's (docs/reference/m3/foundations.md § Layout → Breakpoints): compact below
|
||||||
|
* 600px, medium 600, expanded 840, large 1200, extra-large 1600, in px — a dp is a CSS pixel, so a
|
||||||
|
* larger text size never moves them. `data-md-hide-below="expanded"` hides an element on a window
|
||||||
|
* narrower than 840px; `data-md-hide-from="expanded"` hides it from 840px on. There is nothing
|
||||||
|
* below compact and from compact is every width, so neither names it; `hidden` does that.
|
||||||
|
*
|
||||||
|
* In `material.visibility`, the last layer, so it beats the `display` of the component it sits on
|
||||||
|
* and of every other package rule. An application's own unlayered rule still wins, by design.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
||||||
|
|
||||||
|
@layer material.visibility {
|
||||||
|
@media (width < 600px) {
|
||||||
|
[data-md-hide-below='medium'] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width < 840px) {
|
||||||
|
[data-md-hide-below='expanded'] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width < 1200px) {
|
||||||
|
[data-md-hide-below='large'] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width < 1600px) {
|
||||||
|
[data-md-hide-below='extra-large'] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width >= 600px) {
|
||||||
|
[data-md-hide-from='medium'] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width >= 840px) {
|
||||||
|
[data-md-hide-from='expanded'] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width >= 1200px) {
|
||||||
|
[data-md-hide-from='large'] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width >= 1600px) {
|
||||||
|
[data-md-hide-from='extra-large'] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{{-- Children one under another, inside a pane.
|
||||||
|
|
||||||
|
<x-stack gap="space200">…</x-stack>
|
||||||
|
|
||||||
|
`gap` is a spacing token's name, `space25` … `space900` (docs/reference/m3/styles-supplement.md
|
||||||
|
§ Spacing), and none when left out or unknown. `align` places the children across the stack:
|
||||||
|
`stretch` (the default), `start`, `center` or `end`.
|
||||||
|
|
||||||
|
M3's layout has no arrangement component inside a pane — its "column" is a grid column — so
|
||||||
|
this is the package's neutral one, beside `<x-row>` and `<x-grid>`. It takes `as`,
|
||||||
|
`hide-below` and `hide-from` like every layout component, and the caller's `class` and
|
||||||
|
`style` land on it untouched. Drawn by resources/css/layout/stack.css. --}}
|
||||||
|
|
||||||
|
@props([
|
||||||
|
'as' => null,
|
||||||
|
'gap' => null,
|
||||||
|
'align' => null,
|
||||||
|
'hideBelow' => null,
|
||||||
|
'hideFrom' => null,
|
||||||
|
])
|
||||||
|
|
||||||
|
@php
|
||||||
|
$layout = \NoNameWeb\LivewireMaterial\Support\Layout::class;
|
||||||
|
$element = $layout::element($as);
|
||||||
|
|
||||||
|
$attributes = $attributes->merge(array_filter([
|
||||||
|
'data-md-stack' => true,
|
||||||
|
'data-md-gap' => $layout::spacing($gap),
|
||||||
|
'data-md-align' => $layout::choice($align, ['stretch', 'start', 'center', 'end']),
|
||||||
|
] + $layout::visibility($hideBelow, $hideFrom), fn ($value): bool => $value !== null));
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<{{ $element }} {{ $attributes }}>{{ $slot }}</{{ $element }}>
|
||||||
@@ -102,4 +102,18 @@
|
|||||||
content margin (<code>--material-margin</code>) change at 600, 840 and 1200px. A visitor who has chosen a rail
|
content margin (<code>--material-margin</code>) change at 600, 840 and 1200px. A visitor who has chosen a rail
|
||||||
width keeps it; until then the rail follows the class.
|
width keeps it; until then the rail follows the class.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<x-livewire-material::stack gap="space200" data-test="layout-components">
|
||||||
|
<h3 class="md-type-title-lg">The layout components</h3>
|
||||||
|
|
||||||
|
<p class="md-type-body-md md-ink-variant">
|
||||||
|
M3 describes a layout as a scaffold of bars and rails around panes, and names three canonical layouts. These are
|
||||||
|
those words as components, each taking <code>as</code>, <code>hide-below</code> and <code>hide-from</code>, and
|
||||||
|
only the spacing tokens for a gap or padding:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<x-livewire-material::stack as="ul" gap="space100" class="md-type-body-md">
|
||||||
|
<li><code><x-stack></code>, <code><x-row></code> and <code><x-grid></code> — arrangement inside a pane.</li>
|
||||||
|
</x-livewire-material::stack>
|
||||||
|
</x-livewire-material::stack>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace NoNameWeb\LivewireMaterial\Support;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the layout components share: M3's names for breakpoints and spacing, read from props into
|
||||||
|
* the `data-md-*` attributes resources/css/layout/*.css matches literally.
|
||||||
|
*
|
||||||
|
* Every reader takes whatever a view was given and returns a name from the fixed list or nothing,
|
||||||
|
* so an attribute only ever carries a value the stylesheet has a rule for. Breakpoints are M3's
|
||||||
|
* five, compact below 600px, then medium 600, expanded 840, large 1200 and extra-large 1600
|
||||||
|
* (docs/reference/m3/foundations.md § Layout → Breakpoints); spacing is M3's measurement scale,
|
||||||
|
* `space25` … `space900` (docs/reference/m3/styles-supplement.md § Spacing), the same thirteen
|
||||||
|
* tokens resources/css/tokens/spacing.css declares.
|
||||||
|
*/
|
||||||
|
class Layout
|
||||||
|
{
|
||||||
|
/** The breakpoints, each at its lower edge in px, smallest first. */
|
||||||
|
public const array BREAKPOINTS = [
|
||||||
|
'compact' => 0,
|
||||||
|
'medium' => 600,
|
||||||
|
'expanded' => 840,
|
||||||
|
'large' => 1200,
|
||||||
|
'extra-large' => 1600,
|
||||||
|
];
|
||||||
|
|
||||||
|
/** M3's spacing tokens, by the name after `--md-sys-measurement-`. */
|
||||||
|
public const array SPACING = [
|
||||||
|
'space25', 'space50', 'space75', 'space100', 'space125', 'space200', 'space300',
|
||||||
|
'space400', 'space500', 'space600', 'space700', 'space800', 'space900',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The elements a layout component can be drawn as: containers of flow content. A void element
|
||||||
|
* could hold no slot, and a form control or a link would carry its own semantics into a box.
|
||||||
|
*/
|
||||||
|
public const array ELEMENTS = [
|
||||||
|
'div', 'section', 'article', 'aside', 'main', 'nav', 'header', 'footer',
|
||||||
|
'ul', 'ol', 'li', 'dl', 'form', 'fieldset', 'figure', 'span', 'p',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A spacing token's name, `none` for anything else that was given, or null when nothing was.
|
||||||
|
* `none` is written out so an unknown gap on a component with a default (a feed's spacer)
|
||||||
|
* falls back to no gap rather than to that default.
|
||||||
|
*/
|
||||||
|
public static function spacing(mixed $value): ?string
|
||||||
|
{
|
||||||
|
if ($value === null || $value === false || $value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array($value, self::SPACING, true) ? $value : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A breakpoint a prop can hide or restack from: medium, expanded, large or extra-large. There
|
||||||
|
* is nothing below compact, and from compact is every width.
|
||||||
|
*/
|
||||||
|
public static function edge(mixed $value): ?string
|
||||||
|
{
|
||||||
|
return is_string($value) && $value !== 'compact' && array_key_exists($value, self::BREAKPOINTS) ? $value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The element to draw, or the component's default when `as` names none of ours.
|
||||||
|
*/
|
||||||
|
public static function element(mixed $as, string $default = 'div'): string
|
||||||
|
{
|
||||||
|
return in_array($as, self::ELEMENTS, true) ? $as : $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One of a fixed set of names, or the default.
|
||||||
|
*
|
||||||
|
* @param list<string> $allowed
|
||||||
|
*/
|
||||||
|
public static function choice(mixed $value, array $allowed, ?string $default = null): ?string
|
||||||
|
{
|
||||||
|
return in_array($value, $allowed, true) ? $value : $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A grid's columns at every breakpoint, as the inline custom properties its stylesheet reads.
|
||||||
|
*
|
||||||
|
* `columns` is one whole number for every breakpoint, or a map from breakpoint to number; a
|
||||||
|
* breakpoint left out takes the nearest smaller one's, and compact takes 1 when nothing is
|
||||||
|
* given for it. All five are written on every grid, so a grid inside another never inherits
|
||||||
|
* its parent's count through the cascade.
|
||||||
|
*
|
||||||
|
* @return array<string, int>
|
||||||
|
*/
|
||||||
|
public static function columns(mixed $columns): array
|
||||||
|
{
|
||||||
|
$given = match (true) {
|
||||||
|
is_array($columns) => $columns,
|
||||||
|
$columns === null => [],
|
||||||
|
default => ['compact' => $columns],
|
||||||
|
};
|
||||||
|
|
||||||
|
$filled = [];
|
||||||
|
$current = 1;
|
||||||
|
|
||||||
|
foreach (array_keys(self::BREAKPOINTS) as $breakpoint) {
|
||||||
|
$count = filter_var($given[$breakpoint] ?? null, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 24]]);
|
||||||
|
$current = $count === false ? $current : $count;
|
||||||
|
$filled[$breakpoint] = $current;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $filled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `style` those columns become: `--md-columns-compact: 1; …`.
|
||||||
|
*
|
||||||
|
* @param array<string, int> $columns
|
||||||
|
*/
|
||||||
|
public static function columnStyle(array $columns): string
|
||||||
|
{
|
||||||
|
return collect($columns)->map(fn (int $count, string $breakpoint): string => "--md-columns-{$breakpoint}: {$count};")->implode(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A minimum item width — `280px`, `18rem`, `20ch`, or a bare number read as px — or null.
|
||||||
|
*/
|
||||||
|
public static function length(mixed $value): ?string
|
||||||
|
{
|
||||||
|
if (is_int($value) || (is_string($value) && preg_match('/^\d+$/', $value) === 1)) {
|
||||||
|
return (int) $value > 0 ? ((int) $value).'px' : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_string($value) && preg_match('/^(?:\d+|\d*\.\d+)(?:px|rem|em|ch)$/', $value) === 1 ? $value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes every layout component shares: `hide-below` and `hide-from`.
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public static function visibility(mixed $hideBelow, mixed $hideFrom): array
|
||||||
|
{
|
||||||
|
return array_filter([
|
||||||
|
'data-md-hide-below' => self::edge($hideBelow),
|
||||||
|
'data-md-hide-from' => self::edge($hideFrom),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Blade;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The layout components at M3's breakpoints (plan step 35): each on a page of its own, measured at
|
||||||
|
* the pixel either side of the breakpoint it changes at. The window is resized after the page has
|
||||||
|
* loaded, which is what a person does; a media query answers at once, and a script listening to
|
||||||
|
* one (resources/js/layout.js) hears a change event.
|
||||||
|
*/
|
||||||
|
function layoutPage(string $body, int $width, int $height = 900, string $dir = 'ltr'): mixed
|
||||||
|
{
|
||||||
|
// Until the Workbench leaves Tailwind, its build turns `:dir(rtl)` into a list of `:lang()`s, so a
|
||||||
|
// right-to-left page also says which language it is in.
|
||||||
|
$lang = $dir === 'rtl' ? 'ar' : 'en';
|
||||||
|
$path = '/layout-probe/'.md5($body.$dir);
|
||||||
|
|
||||||
|
Route::middleware('web')->get($path, fn () => Blade::render(<<<BLADE
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html dir="{$dir}" lang="{$lang}">
|
||||||
|
<head>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<x-theme-script />
|
||||||
|
@vite(config('livewire-material.showcase.vite'))
|
||||||
|
@livewireStyles
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{$body}
|
||||||
|
@livewireScripts
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
BLADE));
|
||||||
|
|
||||||
|
return layoutReady(visit($path)->resize($width, $height));
|
||||||
|
}
|
||||||
|
|
||||||
|
function layoutReady(mixed $page): mixed
|
||||||
|
{
|
||||||
|
return $page->waitForEvent('networkidle')
|
||||||
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A computed style of the element matching the selector. */
|
||||||
|
function layoutStyle(string $selector, string $property): string
|
||||||
|
{
|
||||||
|
return "getComputedStyle(document.querySelector('{$selector}')).{$property}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A side of the element's box, rounded to the pixel. */
|
||||||
|
function layoutRect(string $selector, string $side): string
|
||||||
|
{
|
||||||
|
return "Math.round(document.querySelector('{$selector}').getBoundingClientRect().{$side})";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many column tracks a grid has. */
|
||||||
|
function layoutColumns(string $selector): string
|
||||||
|
{
|
||||||
|
return layoutStyle($selector, 'gridTemplateColumns').".split(' ').length";
|
||||||
|
}
|
||||||
|
|
||||||
|
it('spaces a stack with its token, never with its parent\'s, and hides it from a breakpoint over its own display', function () {
|
||||||
|
$body = '<x-stack id="outer" gap="space300"><x-stack id="inner"><p>One</p><p>Two</p></x-stack><p>Three</p></x-stack>'
|
||||||
|
.'<x-stack id="hidden-from" hide-from="medium"><p>Compact only</p></x-stack>';
|
||||||
|
|
||||||
|
layoutPage($body, 599)
|
||||||
|
->assertScript(layoutStyle('#outer', 'flexDirection')." === 'column'")
|
||||||
|
->assertScript(layoutStyle('#outer', 'rowGap')." === '24px'")
|
||||||
|
->assertScript(layoutStyle('#inner', 'rowGap')." === '0px'")
|
||||||
|
->assertScript(layoutStyle('#hidden-from', 'display')." === 'flex'");
|
||||||
|
|
||||||
|
layoutPage($body, 600)
|
||||||
|
->assertScript(layoutStyle('#hidden-from', 'display')." === 'none'");
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
it('stacks its children in a div with no gap and no alignment of its own', function () {
|
||||||
|
$root = layoutRoot((string) $this->blade('<x-stack><p>One</p><p>Two</p></x-stack>'));
|
||||||
|
|
||||||
|
expect($root)->toMatchArray(['<' => 'div', 'data-md-stack' => 'data-md-stack'])
|
||||||
|
->not->toHaveKeys(['data-md-gap', 'data-md-align', 'data-md-hide-below', 'data-md-hide-from']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('spaces its children with a spacing token only, and with none for anything else', function () {
|
||||||
|
expect(layoutRoot((string) $this->blade('<x-stack gap="space200" />'))['data-md-gap'])->toBe('space200')
|
||||||
|
->and(layoutRoot((string) $this->blade('<x-stack gap="16px" />'))['data-md-gap'])->toBe('none')
|
||||||
|
->and(layoutRoot((string) $this->blade('<x-stack gap="space1000" />'))['data-md-gap'])->toBe('none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aligns its children across it', function () {
|
||||||
|
foreach (['stretch', 'start', 'center', 'end'] as $align) {
|
||||||
|
expect(layoutRoot((string) $this->blade('<x-stack :align="$align" />', ['align' => $align]))['data-md-align'])->toBe($align);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(layoutRoot((string) $this->blade('<x-stack align="baseline" />')))->not->toHaveKey('data-md-align');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes the element, the visibility props and the caller\'s class and style', function () {
|
||||||
|
expect(layoutRoot((string) $this->blade('<x-stack as="ul" hide-below="medium" hide-from="large" class="steps" style="max-width: 40rem" />')))
|
||||||
|
->toMatchArray([
|
||||||
|
'<' => 'ul',
|
||||||
|
'data-md-hide-below' => 'medium',
|
||||||
|
'data-md-hide-from' => 'large',
|
||||||
|
'class' => 'steps',
|
||||||
|
'style' => 'max-width: 40rem;',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws the stack from a stylesheet in the layout layer, imported by layout.css', function () {
|
||||||
|
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/layout/stack.css');
|
||||||
|
|
||||||
|
expect($css)->toContain('@layer material.layout')
|
||||||
|
->toContain("[data-md-stack] {\n display: flex;\n flex-direction: column;\n gap: var(--md-gap);")
|
||||||
|
// A stack inside another never takes its parent's gap.
|
||||||
|
->toContain(":where([data-md-stack]) {\n --md-gap: 0px;")
|
||||||
|
->and((string) file_get_contents(__DIR__.'/../../../resources/css/layout.css'))->toContain("@import './layout/stack.css';");
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Illuminate\Support\Facades\File;
|
use Illuminate\Support\Facades\File;
|
||||||
|
use NoNameWeb\LivewireMaterial\Support\Layout;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The package's stylesheets are plain CSS in `material.*` layers, with no Tailwind in them. Every
|
* The package's stylesheets are plain CSS in `material.*` layers, with no Tailwind in them. Every
|
||||||
@@ -327,6 +328,148 @@ it('declares M3\'s spacing scale as the reference gives it', function () {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The layout components' stylesheets: layout.css and everything it imports.
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
function layoutStylesheets(): array
|
||||||
|
{
|
||||||
|
return stylesheetTree(stylesheetPath('layout.css'));
|
||||||
|
}
|
||||||
|
|
||||||
|
it('imports a stylesheet for every layout component from layout.css, each beside its view', function () {
|
||||||
|
$imports = stylesheetImports(stylesheetPath('layout.css'));
|
||||||
|
$reached = array_map(stylesheetName(...), layoutStylesheets());
|
||||||
|
|
||||||
|
$files = collect(File::files(stylesheetPath('layout')))->map(fn (SplFileInfo $file): string => $file->getBasename('.css'));
|
||||||
|
|
||||||
|
// Two files are shared by the components rather than being one: the spacing props and the
|
||||||
|
// visibility props.
|
||||||
|
$components = $files->diff(['spacing', 'visibility'])->values();
|
||||||
|
|
||||||
|
expect($components)->not->toBeEmpty()
|
||||||
|
->and($files->map(fn (string $name): string => "layout/{$name}.css")->diff($reached)->values()->all())->toBe([]);
|
||||||
|
|
||||||
|
foreach ($components as $name) {
|
||||||
|
expect(File::exists(__DIR__."/../../resources/views/components/{$name}.blade.php"))->toBeTrue("layout/{$name}.css has no component")
|
||||||
|
->and($imports)->toContain("./layout/{$name}.css");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens every layout stylesheet with a header, the layer statement and plain imports', function () {
|
||||||
|
foreach (layoutStylesheets() as $file) {
|
||||||
|
$name = stylesheetName($file);
|
||||||
|
$items = stylesheetItems(File::get($file));
|
||||||
|
|
||||||
|
expect(File::get($file))->toStartWith('/*', "{$name} has no header comment")
|
||||||
|
->and($items[0]['statement'] ?? null)->toBe(MATERIAL_LAYER_STATEMENT, "{$name} does not open with the layer statement");
|
||||||
|
|
||||||
|
$blocks = false;
|
||||||
|
|
||||||
|
foreach (array_slice($items, 1) as $item) {
|
||||||
|
if (isset($item['prelude'])) {
|
||||||
|
$blocks = true;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($blocks)->toBeFalse("{$name}: `{$item['statement']}` comes after a block")
|
||||||
|
->and($item['statement'])->toMatch('/^@import ([\'"])\.{1,2}\/[\w.\/-]+\.css\1;$/', "{$name}: `{$item['statement']}` is not a plain import");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps every layout rule in material.layout, and the visibility props in material.visibility', function () {
|
||||||
|
foreach (layoutStylesheets() as $file) {
|
||||||
|
$name = stylesheetName($file);
|
||||||
|
$css = File::get($file);
|
||||||
|
$layer = match ($name) {
|
||||||
|
'layout/visibility.css' => 'material.visibility',
|
||||||
|
default => 'material.layout',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(stylesheetWithoutComments($css))->not->toContain('!important', $name);
|
||||||
|
|
||||||
|
foreach (stylesheetItems($css) as $item) {
|
||||||
|
if (! isset($item['prelude'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($item['prelude'])->toBe("@layer {$layer}", "{$name} writes outside `@layer {$layer}`")
|
||||||
|
->and($item['body'])->not->toContain('@layer');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes no Tailwind in the layout stylesheets, and media queries only at M3\'s breakpoints, in px', function () {
|
||||||
|
$queries = 0;
|
||||||
|
|
||||||
|
foreach (layoutStylesheets() as $file) {
|
||||||
|
$name = stylesheetName($file);
|
||||||
|
$css = stylesheetWithoutComments(File::get($file));
|
||||||
|
|
||||||
|
expect($css)
|
||||||
|
->not->toMatch('/@(?:tailwind|theme|utility|variant|custom-variant|apply|source|config|plugin|reference)\b/', $name)
|
||||||
|
->not->toMatch('/--(?:theme|spacing|alpha)\(|\btheme\(|[\'"]tailwindcss[\'"]/', $name);
|
||||||
|
|
||||||
|
preg_match_all('/@media\b([^{]*)\{/', $css, $matches);
|
||||||
|
|
||||||
|
foreach ($matches[1] as $query) {
|
||||||
|
$queries++;
|
||||||
|
|
||||||
|
expect(trim($query))->toMatch('/^\(width (?:<|>=) (?:600|840|1200|1600)px\)$/', "{$name}: `@media {$query}` is not an M3 breakpoint in px");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($queries)->toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives every spacing token a gap and a padding, and the layout components read only those', function () {
|
||||||
|
$tokens = array_map(
|
||||||
|
fn (string $property): string => substr($property, strlen('--md-sys-measurement-')),
|
||||||
|
array_keys(stylesheetDeclarations(stylesheetBlock(File::get(stylesheetPath('tokens/spacing.css')), ':root'))),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(Layout::SPACING)->toBe($tokens);
|
||||||
|
|
||||||
|
$spacing = File::get(stylesheetPath('layout/spacing.css'));
|
||||||
|
|
||||||
|
expect(stylesheetDeclarations(stylesheetBlock($spacing, "[data-md-gap='none']")))->toBe(['--md-gap' => '0px']);
|
||||||
|
|
||||||
|
foreach ($tokens as $token) {
|
||||||
|
expect(stylesheetDeclarations(stylesheetBlock($spacing, "[data-md-gap='{$token}']")))->toBe(['--md-gap' => "var(--md-sys-measurement-{$token})"])
|
||||||
|
->and(stylesheetDeclarations(stylesheetBlock($spacing, "[data-md-padding='{$token}']")))->toBe(['padding' => "var(--md-sys-measurement-{$token})"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(collect(stylesheetItems(stylesheetBlock($spacing, '@layer material.layout')))->pluck('prelude')->all())
|
||||||
|
->toHaveCount(1 + 2 * count($tokens));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides an element below and from every breakpoint but compact, and nothing else', function () {
|
||||||
|
$visibility = stylesheetBlock(File::get(stylesheetPath('layout/visibility.css')), '@layer material.visibility');
|
||||||
|
|
||||||
|
$expected = [];
|
||||||
|
|
||||||
|
foreach (['medium' => 600, 'expanded' => 840, 'large' => 1200, 'extra-large' => 1600] as $breakpoint => $width) {
|
||||||
|
$expected["@media (width < {$width}px)"] = "[data-md-hide-below='{$breakpoint}']";
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['medium' => 600, 'expanded' => 840, 'large' => 1200, 'extra-large' => 1600] as $breakpoint => $width) {
|
||||||
|
$expected["@media (width >= {$width}px)"] = "[data-md-hide-from='{$breakpoint}']";
|
||||||
|
}
|
||||||
|
|
||||||
|
$actual = collect(stylesheetItems($visibility))->mapWithKeys(function (array $item): array {
|
||||||
|
$rule = stylesheetItems($item['body'])[0];
|
||||||
|
|
||||||
|
expect(stylesheetDeclarations($rule['body']))->toBe(['display' => 'none']);
|
||||||
|
|
||||||
|
return [$item['prelude'] => $rule['prelude']];
|
||||||
|
})->all();
|
||||||
|
|
||||||
|
expect($actual)->toBe($expected);
|
||||||
|
});
|
||||||
|
|
||||||
it('gives every md-type class the declarations of its type utility', function () {
|
it('gives every md-type class the declarations of its type utility', function () {
|
||||||
preg_match_all('/@utility (type-[\w-]+) \{\n(.*?)\n\}/s', File::get(stylesheetPath('tokens/utilities.css')), $matches, PREG_SET_ORDER);
|
preg_match_all('/@utility (type-[\w-]+) \{\n(.*?)\n\}/s', File::get(stylesheetPath('tokens/utilities.css')), $matches, PREG_SET_ORDER);
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,21 @@ use NoNameWeb\LivewireMaterial\Tests\TestCase;
|
|||||||
|
|
||||||
pest()->extend(TestCase::class)->in('Feature', 'Browser');
|
pest()->extend(TestCase::class)->in('Feature', 'Browser');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The element name (under `<`) and the attributes of the first tag in a rendered component, by
|
||||||
|
* name; a boolean attribute Blade renders as `name="name"`. For the layout components' render
|
||||||
|
* tests, which assert what their root carries.
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
function layoutRoot(string $html): array
|
||||||
|
{
|
||||||
|
preg_match('/^\s*<([a-z]+)\b([^>]*)>/s', $html, $tag);
|
||||||
|
preg_match_all('/([\w:.@-]+)(?:="([^"]*)")?/', $tag[2] ?? '', $pairs, PREG_SET_ORDER);
|
||||||
|
|
||||||
|
return ['<' => $tag[1] ?? ''] + collect($pairs)->mapWithKeys(fn (array $pair): array => [$pair[1] => $pair[2] ?? ''])->all();
|
||||||
|
}
|
||||||
|
|
||||||
// A shared CI runner is slower than a workstation: an animation or a smooth scroll can take longer
|
// A shared CI runner is slower than a workstation: an animation or a smooth scroll can take longer
|
||||||
// than the default five seconds to settle there. BROWSER_TIMEOUT (milliseconds) raises the limit.
|
// than the default five seconds to settle there. BROWSER_TIMEOUT (milliseconds) raises the limit.
|
||||||
if (($timeout = (int) getenv('BROWSER_TIMEOUT')) > 0) {
|
if (($timeout = (int) getenv('BROWSER_TIMEOUT')) > 0) {
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
/* The Workbench builds the package as an application would, with explicit sources so the plans
|
/* The Workbench builds the package as an application would, with explicit sources so the plans
|
||||||
in docs/ are not scanned for class names. The foundation comes before Tailwind, so the
|
in docs/ are not scanned for class names. The layer statement puts the `material` layers above
|
||||||
`material` layers are declared first and sit below Tailwind's; tailwind.css brings the
|
Tailwind's preflight (`base`) and below its `components` and `utilities`: a rewritten component's
|
||||||
components still written for Tailwind, without declaring the tokens a second time. */
|
padding, margin and border would otherwise lose to preflight's `* { padding: 0 }`, which the
|
||||||
|
material reset already carries, while every utility still outranks the package. tailwind.css
|
||||||
|
brings the components still written for Tailwind, without declaring the tokens a second time. */
|
||||||
|
@layer theme, base, material, components, utilities;
|
||||||
@import '../../../resources/css/foundation.css';
|
@import '../../../resources/css/foundation.css';
|
||||||
@import '../../../resources/css/layout.css';
|
@import '../../../resources/css/layout.css';
|
||||||
@import '../../../resources/css/components.css';
|
@import '../../../resources/css/components.css';
|
||||||
|
|||||||
Reference in New Issue
Block a user