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:
Andreas Reinhold / reini
2026-09-14 14:50:27 +02:00
co-authored by Claude Opus 5
parent 6be9f35c98
commit b677e60876
13 changed files with 724 additions and 3 deletions
+147
View File
@@ -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),
]);
}
}