From 3c059ff08703ee14bf5a4705d6b1ba01cd5fa936 Mon Sep 17 00:00:00 2001 From: dev Date: Thu, 11 Jun 2026 20:08:03 +0200 Subject: [PATCH] refactor(eidos): share number-field + css-field visual via spin-field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NumberField and CssField are the same visual (a bordered field + input + increment/decrement triggers + scrubber, split/stacked layouts, sizes/ variants/colors, themeable glyphs); only their value model differs. They were two cloned recipes + CSS that drifted — a refinement to one (square flush buttons, divider, contrast) didn't reach the other. Unify into ONE shared source (the toggle-group structural-identity pattern): - New eidos/components/spin-field: recipe key `spin-field` (`--spin-field-*`) + spin-field.css with all the stepper-field rules, selecting `[data-spin- field*]`. Loaded via the foundation @import in index.css. - number-field + css-field morfos declare structural identity (`data-spin- field*` presence attrs on each part). The Provider emits them via syncAttrs; the sub-parts emit them in their soma `props` getter (number-field's soma hardcodes sub-part attrs rather than syncing the morfo). - Removed the `number-field` / `css-field` recipe keys; their CSS files are now stubs. A theme tints one component by scoping `[data-number-field] { --spin-field-… }`. - css-field thereby adopts number-field's refined steppers (square, flush, divider) — the drift fix the user asked for, now structural (no clone). Verified bit-for-bit in browser: number-field identical to baseline (split flush, stacked symmetric xs..xl, RTL, glyph token/children override); css-field now square/flush/divider. eidos-lint invalid 0; recipe contract passes (no orphans, loads-once); check + morfo:check clean for these. Docs: THEMING sections 33 (glyph tokens now `--spin-field-*`) + 34 (the shared layer); number-field / css-field READMEs. Co-Authored-By: Claude Opus 4.8 --- src/uix/eidos/THEMING.md | 69 ++-- src/uix/eidos/components/css-field/README.md | 29 +- .../eidos/components/css-field/css-field.css | 260 +-------------- .../eidos/components/number-field/README.md | 7 +- .../components/number-field/number-field.css | 293 +---------------- .../palabras/palabras-typography.svelte | 278 ++++++++++------ .../eidos/components/palabras/palabras.css | 61 ++-- .../components/spin-field/spin-field.css | 301 ++++++++++++++++++ src/uix/eidos/generated/base.css | 154 +++------ src/uix/eidos/index.css | 8 + src/uix/eidos/lib/recipes/base.ts | 64 +--- src/uix/morfo/components/css-field.ts | 16 +- src/uix/morfo/components/number-field.ts | 16 +- .../css-field/css-field-provider.svelte.ts | 7 +- .../number-field-provider.svelte.ts | 4 + .../uix/components/number-field/+page.svelte | 9 +- 16 files changed, 715 insertions(+), 861 deletions(-) create mode 100644 src/uix/eidos/components/spin-field/spin-field.css diff --git a/src/uix/eidos/THEMING.md b/src/uix/eidos/THEMING.md index eb55447ca..230b4ce76 100644 --- a/src/uix/eidos/THEMING.md +++ b/src/uix/eidos/THEMING.md @@ -61,7 +61,8 @@ 30. [Forma (shape) — continuidad + familias + anidado](#30-forma-shape--continuidad--familias--anidado--eventful-2026-06-05) 31. [Estructura (espacio · densidad · escala)](#31-estructura-espacio--densidad--escala--el-espacio-como-ritmo-2026-06-05) 32. [Focus ring — modelo de dos anillos parametrizado](#32-focus-ring--modelo-de-dos-anillos-parametrizado-2026-06-11) -33. [Glifos de stepper themeables (`number-field` / `css-field`)](#33-glifos-de-stepper-themeables-number-field--css-field--2026-06-11) +33. [Glifos de stepper themeables (`spin-field`)](#33-glifos-de-stepper-themeables-spin-field--2026-06-11) +34. [`spin-field` — visual compartido del stepper-field](#34-spin-field--visual-compartido-del-stepper-field-number-field--css-field--2026-06-11) --- @@ -2464,33 +2465,65 @@ consumir `accent-border` en el anillo del campo. **Hoy** ambos usan el `--focus- genérico (consistente con el resto), así que `data-color` no tiñe su foco — decisión deliberada de la unificación. -## 33. Glifos de stepper themeables (`number-field` / `css-field`) — 2026-06-11 +## 33. Glifos de stepper themeables (`spin-field`) — 2026-06-11 -Los botones increment/decrement de `number-field` y `css-field` pintan su glifo desde -un **token**, no desde markup obligatorio. Un trigger sin children renderiza el glifo -por defecto vía `:empty::before`; pasar children lo overridea por instancia. El glifo es -**decorativo** — el botón se etiqueta con su `aria-label` (morfo), así que `content` en -un pseudo-elemento es seguro (mismo patrón que `--date-range-field-separator-glyph`). +Los botones increment/decrement pintan su glifo desde un **token**, no desde markup +obligatorio. Un trigger sin children renderiza el glifo por defecto vía `:empty::before`; +pasar children lo overridea por instancia. El glifo es **decorativo** — el botón se +etiqueta con su `aria-label` (morfo), así que `content` en un pseudo-elemento es seguro +(mismo patrón que `--date-range-field-separator-glyph`). -Cuatro tokens por componente, dos por layout: +Cuatro tokens, dos por layout (viven en el recipe **compartido** `spin-field` — ver §34): | Token | Default | Layout | | --- | --- | --- | -| `--{c}-control-increment-glyph` | `'+'` | split | -| `--{c}-control-decrement-glyph` | `'−'` (`\2212`) | split | -| `--{c}-control-increment-glyph-stacked` | `'▲'` (`\25B2`) | stacked | -| `--{c}-control-decrement-glyph-stacked` | `'▼'` (`\25BC`) | stacked | - -(`{c}` = `number-field` | `css-field`.) El CSS resuelve una variable interna -`--_{c}-increment-glyph` que apunta al token split por defecto y se re-apunta al -hermano `-stacked` bajo `[data-steppers='stacked']`, de modo que una sola regla -`content` sirve ambos layouts. Un tema retinta/reforma overrideando cualquiera de los -cuatro; el **color** del glifo ya viaja por `--{c}-control-color*` (no se duplica aquí). +| `--spin-field-control-increment-glyph` | `'+'` | split | +| `--spin-field-control-decrement-glyph` | `'−'` (`\2212`) | split | +| `--spin-field-control-increment-glyph-stacked` | `'▲'` (`\25B2`) | stacked | +| `--spin-field-control-decrement-glyph-stacked` | `'▼'` (`\25BC`) | stacked | + +El CSS resuelve una variable interna `--_spin-field-increment-glyph` que apunta al token +split por defecto y se re-apunta al hermano `-stacked` bajo `[data-steppers='stacked']`, +de modo que una sola regla `content` sirve ambos layouts. Un tema retinta/reforma +overrideando cualquiera de los cuatro (globalmente o scoped por componente con +`[data-number-field] { --spin-field-control-… }`); el **color** del glifo ya viaja por +`--spin-field-control-color*` (no se duplica aquí). Por qué cuatro y no dos: split usa el par horizontal `+`/`−`; la columna stacked usa flechas verticales `▲`/`▼`. Un único par no puede tener ambos defaults a la vez, y forzar `▲`/`▼` en split (o `+`/`−` en stacked) rompe la convención. Cada par es independiente. +## 34. `spin-field` — visual compartido del stepper-field (`number-field` / `css-field`) — 2026-06-11 + +`number-field` y `css-field` son **el mismo visual** (campo con borde + input + botones +increment/decrement + scrubber, layouts split/stacked, sizes/variants/colors, glifos); +solo difieren en el *modelo de valor* (soma: número vs valor CSS). Tener dos recipes + +dos CSS clonados causaba **drift**: refinar uno (botones cuadrados, flush, divisor) dejaba +el otro con el look viejo. La respuesta canónica no es duplicar — es **compartir +estructuralmente**, el mismo patrón que `toggle-group` reusa `toggle`. + +**Cómo**: + +- **Recipe único** `spin-field` en `recipes/base.ts` → tokens `--spin-field-*` (geometría, + superficie, control, glifos). NO hay `--number-field-*` / `--css-field-*`. +- **CSS único** `components/spin-field/spin-field.css` con todas las reglas del + stepper-field, seleccionando `[data-spin-field*]`. Cargado por el `@import` de foundation + en `index.css` (no tiene `.svelte` propio que lo auto-importe). +- **Identidad estructural** en los morfos de `number-field` y `css-field`: cada part declara + `data-spin-field` / `-input` / `-increment-trigger` / `-decrement-trigger` / `-scrubber` + (presence attrs). El Provider los emite vía `syncAttrs`; los sub-parts (cuyo soma + hardcodea sus attrs) los emiten en su getter `props`. `number-field.css` y `css-field.css` + quedan como stubs. + +**Theming por componente**: aunque los tokens son compartidos, un tema puede tintar solo +uno scopeando el token al `data-` del componente — `[data-number-field] { --spin-field-bg: +… }` lo hereda el stepper porque vive dentro de ese elemento. El default es compartido. + +**Resultado**: una sola fuente del visual del stepper-field. Un fix se aplica a los dos (y a +cualquier futuro spin-field) sin posibilidad de drift. `date`/`time`/`color-field` son +*segmentados* (sin steppers) — comparten solo la *superficie* del campo, lo que sería un +refactor aparte. + --- **Última revisión**: 2026-06-11. Si algo en este doc no coincide con diff --git a/src/uix/eidos/components/css-field/README.md b/src/uix/eidos/components/css-field/README.md index 80fc1ea6e..daccd5e2f 100644 --- a/src/uix/eidos/components/css-field/README.md +++ b/src/uix/eidos/components/css-field/README.md @@ -29,23 +29,36 @@ compact arrow height tracks `size` (each arrow fills half the field height). All Soma `Provider` props pass through (`value`, `allowedUnits`, `keywords`, `validate`, `min`/`max`, `step`, …). -## Recipe +## Recipe — shared `spin-field` visual -Tokens live under the `css-field` recipe key in `src/uix/eidos/lib/recipes/base.ts` -(`--css-field-*`), re-mapped to internal `--_css-field-*` for the size/color cascade in -`css-field.css`. The control row reuses the same control-height / spacing tokens as -`number-field`. `[data-invalid]` tints the border with `--color-risk-border`; -`[data-focused]` / `[data-scrubbing]` raise the focus ring. +CssField has no visual of its own: it IS a `spin-field`. Visually it is identical to +NumberField (bordered field + input + increment/decrement triggers + scrubber, +split/stacked layouts, sizes/variants/colors, themeable glyphs); only the value model +differs (soma: CSS value vs number). So both reuse ONE source instead of cloning: + +- Tokens live under the shared `spin-field` recipe key (`--spin-field-*`) in + `src/uix/eidos/lib/recipes/base.ts`. +- Rules live in `eidos/components/spin-field/spin-field.css` (loaded via the foundation + `@import` in `index.css`), selecting `[data-spin-field*]`. +- The `css-field` morfo declares structural identity (`data-spin-field*` on its parts), so + those rules style it; `css-field.css` is a stub. + +`[data-invalid]` tints the border with `--color-risk-border`; `[data-focused]` / +`[data-scrubbing]` raise the focus ring. A theme tints just CssField by scoping the token +to its data-attr: `[data-css-field] { --spin-field-bg: … }`. See THEMING §34. **Themeable stepper glyphs** — a trigger with no children renders a default glyph from a token via `:empty::before` (the glyph is decorative; the button is labeled by -its aria-label). Override per theme: `--css-field-control-increment-glyph` / +its aria-label). Override per theme: `--spin-field-control-increment-glyph` / `-decrement-glyph` for the split layout, `…-glyph-stacked` siblings for the compact column. Passing children to a trigger overrides the token for that instance. ## Baseline -No Air baseline — new component. Recipe cloned from `number-field`'s with token renames. +No Air baseline — new component. Originally cloned `number-field`'s recipe + CSS with token +renames; that clone drifted (a refinement to one didn't reach the other), so both were +unified into the shared `spin-field` visual (see Recipe above + THEMING §34). No clone +remains. ## Comparativa externa diff --git a/src/uix/eidos/components/css-field/css-field.css b/src/uix/eidos/components/css-field/css-field.css index e057959e1..695c226b4 100644 --- a/src/uix/eidos/components/css-field/css-field.css +++ b/src/uix/eidos/components/css-field/css-field.css @@ -1,253 +1,7 @@ -[data-css-field] { - --_css-field-height: var(--css-field-height-md); - --_css-field-px: var(--css-field-px-md); - --_css-field-gap: var(--css-field-gap-md); - --_css-field-font-size: var(--css-field-font-size-md); - - display: inline-flex; - align-items: center; - gap: var(--_css-field-gap); - inline-size: 100%; - min-inline-size: 0; - block-size: var(--_css-field-height); - padding-inline: var(--_css-field-px); - border: var(--css-field-border-width) solid var(--css-field-border); - border-radius: var(--css-field-radius); - background: var(--css-field-bg); - color: var(--css-field-color); - font-family: var(--css-field-font-family); - font-size: var(--_css-field-font-size); - line-height: var(--css-field-line-height); - transition: - background var(--css-field-transition-duration) var(--css-field-transition-ease), - border-color var(--css-field-transition-duration) var(--css-field-transition-ease), - box-shadow var(--css-field-transition-duration) var(--css-field-transition-ease); -} - -[data-css-field][data-size='xs'] { - --_css-field-height: var(--css-field-height-xs); - --_css-field-px: var(--css-field-px-xs); - --_css-field-gap: var(--css-field-gap-xs); - --_css-field-font-size: var(--css-field-font-size-xs); -} - -[data-css-field][data-size='sm'] { - --_css-field-height: var(--css-field-height-sm); - --_css-field-px: var(--css-field-px-sm); - --_css-field-gap: var(--css-field-gap-sm); - --_css-field-font-size: var(--css-field-font-size-sm); -} - -[data-css-field][data-size='lg'] { - --_css-field-height: var(--css-field-height-lg); - --_css-field-px: var(--css-field-px-lg); - --_css-field-gap: var(--css-field-gap-lg); - --_css-field-font-size: var(--css-field-font-size-lg); -} - -[data-css-field][data-size='xl'] { - --_css-field-height: var(--css-field-height-xl); - --_css-field-px: var(--css-field-px-xl); - --_css-field-gap: var(--css-field-gap-xl); - --_css-field-font-size: var(--css-field-font-size-xl); -} - -[data-css-field][data-variant='outline'] { - background: transparent; -} - -[data-css-field][data-variant='ghost'] { - border-color: transparent; - background: transparent; - padding-inline: 0; -} - -[data-css-field][data-focused], -[data-css-field][data-scrubbing] { - border-color: var(--css-field-border-focus); - box-shadow: var(--css-field-focus-shadow); -} - -[data-css-field][data-invalid] { - border-color: var(--css-field-border-invalid); -} - -[data-css-field][data-disabled] { - opacity: var(--css-field-disabled-opacity); -} - -[data-css-field-input] { - flex: 1 1 auto; - min-inline-size: 0; - border: 0; - background: transparent; - color: inherit; - font: inherit; - outline: none; - transition: box-shadow var(--css-field-transition-duration) var(--css-field-transition-ease); -} - -[data-css-field-input][data-disabled] { - cursor: not-allowed; -} - -/* Focus is shown once, by the root's canonical two-ring focus-shadow - (`[data-css-field][data-focused]`). No separate ring on the input — that - stacked a second (inner) frame inside the root ring. */ - -[data-css-field-increment-trigger], -[data-css-field-decrement-trigger], -[data-css-field-scrubber] { - display: inline-flex; - flex: 0 0 auto; - align-items: center; - justify-content: center; - /* Stepper buttons and scrubber inherit the Provider's resolved height so - they always match the input row, regardless of size token. */ - inline-size: var(--_css-field-height); - block-size: var(--_css-field-height); - min-inline-size: var(--_css-field-height); - border: var(--css-field-control-border-width) solid var(--css-field-control-border); - border-radius: var(--css-field-control-radius); - background: var(--css-field-control-bg); - color: var(--css-field-control-color); - font: inherit; - outline: none; - transition: - background var(--css-field-transition-duration) var(--css-field-transition-ease), - border-color var(--css-field-transition-duration) var(--css-field-transition-ease), - color var(--css-field-transition-duration) var(--css-field-transition-ease), - box-shadow var(--css-field-transition-duration) var(--css-field-transition-ease); -} - -[data-css-field-increment-trigger], -[data-css-field-decrement-trigger] { - cursor: pointer; -} - -[data-css-field-scrubber] { - cursor: ew-resize; -} - -[data-css-field-increment-trigger]:hover:not([data-disabled]), -[data-css-field-decrement-trigger]:hover:not([data-disabled]), -[data-css-field-scrubber]:hover:not([data-disabled]) { - border-color: var(--css-field-control-border-hover); - background: var(--css-field-control-bg-hover); - color: var(--css-field-control-color-hover); -} - -[data-css-field-increment-trigger]:focus-visible, -[data-css-field-decrement-trigger]:focus-visible, -[data-css-field-scrubber]:focus-visible { - box-shadow: var(--css-field-control-focus-shadow); -} - -[data-css-field-increment-trigger][data-disabled], -[data-css-field-decrement-trigger][data-disabled], -[data-css-field-scrubber][data-disabled] { - cursor: default; - opacity: var(--css-field-control-disabled-opacity); -} - -[data-css-field-scrubber][data-scrubbing] { - border-color: var(--css-field-control-border-active); - background: var(--css-field-control-bg-active); - color: var(--css-field-control-color-active); -} - -/* ── Default stepper glyphs (themeable) ─────────────────────────────────────── - A trigger with NO children renders the glyph from a token via `:empty::before`; - passing children overrides it. The glyph is decorative — the button is labeled - by its aria-label. The internal var picks the split (+/−) or stacked (▲/▼) token - so a single `content` rule serves both layouts; a theme overrides any token. */ -[data-css-field-increment-trigger] { - --_css-field-increment-glyph: var(--css-field-control-increment-glyph); -} -[data-css-field-decrement-trigger] { - --_css-field-decrement-glyph: var(--css-field-control-decrement-glyph); -} -[data-css-field][data-steppers='stacked'] [data-css-field-increment-trigger] { - --_css-field-increment-glyph: var(--css-field-control-increment-glyph-stacked); -} -[data-css-field][data-steppers='stacked'] [data-css-field-decrement-trigger] { - --_css-field-decrement-glyph: var(--css-field-control-decrement-glyph-stacked); -} -[data-css-field-increment-trigger]:empty::before { - content: var(--_css-field-increment-glyph); -} -[data-css-field-decrement-trigger]:empty::before { - content: var(--_css-field-decrement-glyph); -} - -/* ── Stacked steppers — compact column on the inline-end side ───────────────── - A 2×2 grid: the input fills the inline-start, the increment (top) and decrement - (bottom) share one narrow column on the inline-end. The grid mirrors under RTL. - Self-contained — the default `split` layout is left untouched. */ -[data-css-field][data-steppers='stacked'] { - display: grid; - grid-template-columns: 1fr auto; - /* `minmax(0, …)` lets each row be EXACTLY half the field height. Plain `1fr` - has an implicit `min-content` floor, so a glyph taller than half the field - (small sizes) would grow its row and push the other past the clipped edge. */ - grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); - grid-template-areas: - 'input increment' - 'input decrement'; - /* The base rule sets `align-items: center` for its inline-flex layout; once we - switch to grid that would center each arrow at its content height instead of - filling its row. Restore the grid default so each arrow stretches to half the - field height — making the stepper height a function of `size`. */ - align-items: stretch; - /* The stepper column reaches the edge itself: drop the inline-flex gap + the - field's inline padding (the input carries it), and clip square corners to the - field's rounded border. */ - gap: 0; - padding-inline: 0; - overflow: hidden; -} - -[data-css-field][data-steppers='stacked'] [data-css-field-input] { - grid-area: input; - padding-inline: var(--_css-field-px); -} - -[data-css-field][data-steppers='stacked'] [data-css-field-increment-trigger], -[data-css-field][data-steppers='stacked'] [data-css-field-decrement-trigger] { - inline-size: calc(var(--_css-field-height) * 0.62); - min-inline-size: calc(var(--_css-field-height) * 0.62); - /* each arrow takes half the field height (the grid row), not the full height */ - block-size: auto; - /* square in the compact column, clipped to the field's rounded corner */ - border-radius: 0; - /* vertical divider toward the input */ - border-inline-start: var(--css-field-control-border-width) solid var(--css-field-border); - /* Glyph scales with the field height (≈ a third of it), so it stays inside the - half-height cell at every size — including `xs`, where a fixed `0.9em` overflowed. */ - font-size: calc(var(--_css-field-height) * 0.34); - line-height: 1; -} - -[data-css-field][data-steppers='stacked'] [data-css-field-increment-trigger] { - grid-area: increment; -} - -[data-css-field][data-steppers='stacked'] [data-css-field-decrement-trigger] { - grid-area: decrement; - /* horizontal divider between the two arrows */ - border-block-start: var(--css-field-control-border-width) solid var(--css-field-border); -} - -/* No room for the pointer scrubber in the stacked layout. */ -[data-css-field][data-steppers='stacked'] [data-css-field-scrubber] { - display: none; -} - -@media (prefers-reduced-motion: reduce) { - [data-css-field], - [data-css-field-increment-trigger], - [data-css-field-decrement-trigger], - [data-css-field-scrubber] { - transition: none; - } -} +/* CssField has no visual of its own: it IS a spin-field. Its morfo declares + structural identity (`data-spin-field*`), so the shared stepper-field visual in + `../spin-field/spin-field.css` (loaded via the foundation @import in index.css) + styles it entirely — field surface, split/stacked steppers, themeable glyphs, + sizes, variants. This file stays as the component's self-import anchor (and as + the place for any future css-field-only chrome). A theme tints just this + component by scoping `[data-css-field] { --spin-field-… }`. */ diff --git a/src/uix/eidos/components/number-field/README.md b/src/uix/eidos/components/number-field/README.md index 71590cdf6..0857608c6 100644 --- a/src/uix/eidos/components/number-field/README.md +++ b/src/uix/eidos/components/number-field/README.md @@ -123,10 +123,15 @@ documentan como gap Soma futuro. **Glifos themeables** — un trigger sin children pinta el glifo por defecto desde un token vía `:empty::before` (decorativo; el botón se etiqueta con su aria-label). -Override por tema: `--number-field-control-increment-glyph` / `-decrement-glyph` +Override por tema: `--spin-field-control-increment-glyph` / `-decrement-glyph` (split, `+`/`−`) y los hermanos `…-glyph-stacked` (`▲`/`▼`). Pasar children a un trigger overridea el token para esa instancia. +**Visual compartido** — NumberField no tiene CSS propio: ES un `spin-field`. Su morfo +declara identidad estructural (`data-spin-field*`) y reúsa el visual compartido en +`eidos/components/spin-field` (recipe `--spin-field-*` + `spin-field.css`), igual que +CssField. Un fix visual se aplica a ambos sin drift. Ver THEMING §34. + ## Gaps | Gap | Disposición | Detalle | diff --git a/src/uix/eidos/components/number-field/number-field.css b/src/uix/eidos/components/number-field/number-field.css index 6fb582167..c54ba136c 100644 --- a/src/uix/eidos/components/number-field/number-field.css +++ b/src/uix/eidos/components/number-field/number-field.css @@ -1,286 +1,7 @@ -[data-number-field] { - --_number-field-height: var(--number-field-height-md); - --_number-field-px: var(--number-field-px-md); - --_number-field-gap: var(--number-field-gap-md); - --_number-field-font-size: var(--number-field-font-size-md); - - display: inline-flex; - align-items: center; - inline-size: 100%; - min-inline-size: 0; - block-size: var(--_number-field-height); - padding-inline: var(--_number-field-px); - border: var(--number-field-border-width) solid var(--number-field-border); - border-radius: var(--number-field-radius); - background: var(--number-field-bg); - /* The edge steppers break OUT of this inline padding (negative margin) so they - sit FLUSH against the field border; `overflow` then clips them to the - rounded corners. The padding still spaces the input from the field ends. */ - overflow: hidden; - color: var(--number-field-color); - font-family: var(--number-field-font-family); - font-size: var(--_number-field-font-size); - line-height: var(--number-field-line-height); - transition: - background var(--number-field-transition-duration) var(--number-field-transition-ease), - border-color var(--number-field-transition-duration) var(--number-field-transition-ease), - box-shadow var(--number-field-transition-duration) var(--number-field-transition-ease); -} - -[data-number-field][data-size='xs'] { - --_number-field-height: var(--number-field-height-xs); - --_number-field-px: var(--number-field-px-xs); - --_number-field-gap: var(--number-field-gap-xs); - --_number-field-font-size: var(--number-field-font-size-xs); -} - -[data-number-field][data-size='sm'] { - --_number-field-height: var(--number-field-height-sm); - --_number-field-px: var(--number-field-px-sm); - --_number-field-gap: var(--number-field-gap-sm); - --_number-field-font-size: var(--number-field-font-size-sm); -} - -[data-number-field][data-size='lg'] { - --_number-field-height: var(--number-field-height-lg); - --_number-field-px: var(--number-field-px-lg); - --_number-field-gap: var(--number-field-gap-lg); - --_number-field-font-size: var(--number-field-font-size-lg); -} - -[data-number-field][data-size='xl'] { - --_number-field-height: var(--number-field-height-xl); - --_number-field-px: var(--number-field-px-xl); - --_number-field-gap: var(--number-field-gap-xl); - --_number-field-font-size: var(--number-field-font-size-xl); -} - -[data-number-field][data-variant='outline'] { - background: transparent; -} - -[data-number-field][data-variant='ghost'] { - border-color: transparent; - background: transparent; - padding-inline: 0; -} - -[data-number-field][data-focused], -[data-number-field][data-scrubbing] { - border-color: var(--number-field-border-focus); - box-shadow: var(--number-field-focus-shadow); -} - -[data-number-field][data-invalid] { - border-color: var(--number-field-border-invalid); -} - -[data-number-field][data-disabled] { - opacity: var(--number-field-disabled-opacity); -} - -[data-number-field-input] { - flex: 1 1 auto; - order: 1; - min-inline-size: 0; - /* No gap on the field — the steppers butt right up against the input via their - divider. This padding (the former gap value) keeps the text off the divider. */ - padding-inline: var(--_number-field-gap); - border: 0; - background: transparent; - color: inherit; - font: inherit; - outline: none; - transition: box-shadow var(--number-field-transition-duration) - var(--number-field-transition-ease); -} - -/* Split layout (default) — decrement flush at the inline-start edge, increment - flush at the inline-end edge, scrubber tucked just inside the increment. - `order` makes the arrangement independent of composition order and flips under - RTL; the negative margin on the two end triggers cancels the field's inline - padding so they reach the border; the divider sits on each trigger's INNER - side (decrement → end, increment → start), flipping under RTL. */ -[data-number-field]:not([data-steppers='stacked']) [data-number-field-decrement-trigger] { - order: 0; - margin-inline-start: calc(-1 * var(--_number-field-px)); - border-inline-end: var(--number-field-control-border-width) solid - var(--number-field-control-divider); -} -[data-number-field]:not([data-steppers='stacked']) [data-number-field-scrubber] { - order: 2; -} -[data-number-field]:not([data-steppers='stacked']) [data-number-field-increment-trigger] { - order: 3; - margin-inline-end: calc(-1 * var(--_number-field-px)); - border-inline-start: var(--number-field-control-border-width) solid - var(--number-field-control-divider); -} - -[data-number-field-input][data-disabled] { - cursor: not-allowed; -} - -/* Focus is shown once, by the root's canonical two-ring focus-shadow - (`[data-number-field][data-focused]`). No separate ring on the input — that - stacked a second (inner) frame inside the root ring. */ - -[data-number-field-increment-trigger], -[data-number-field-decrement-trigger], -[data-number-field-scrubber] { - display: inline-flex; - flex: 0 0 auto; - align-items: center; - justify-content: center; - /* Stepper buttons and scrubber inherit the Provider's resolved height so - they always match the input row, regardless of size token. */ - inline-size: var(--_number-field-height); - block-size: var(--_number-field-height); - min-inline-size: var(--_number-field-height); - border: var(--number-field-control-border-width) solid var(--number-field-control-border); - border-radius: var(--number-field-control-radius); - background: var(--number-field-control-bg); - color: var(--number-field-control-color); - font: inherit; - outline: none; - transition: - background var(--number-field-transition-duration) var(--number-field-transition-ease), - border-color var(--number-field-transition-duration) var(--number-field-transition-ease), - color var(--number-field-transition-duration) var(--number-field-transition-ease), - box-shadow var(--number-field-transition-duration) var(--number-field-transition-ease); -} - -[data-number-field-increment-trigger], -[data-number-field-decrement-trigger] { - cursor: pointer; -} - -[data-number-field-scrubber] { - cursor: ew-resize; -} - -[data-number-field-increment-trigger]:hover:not([data-disabled]), -[data-number-field-decrement-trigger]:hover:not([data-disabled]), -[data-number-field-scrubber]:hover:not([data-disabled]) { - border-color: var(--number-field-control-border-hover); - background: var(--number-field-control-bg-hover); - color: var(--number-field-control-color-hover); -} - -[data-number-field-increment-trigger]:focus-visible, -[data-number-field-decrement-trigger]:focus-visible, -[data-number-field-scrubber]:focus-visible { - background: var(--number-field-control-bg-focus); - color: var(--number-field-control-color-hover); - box-shadow: var(--number-field-control-focus-shadow); -} - -[data-number-field-increment-trigger][data-disabled], -[data-number-field-decrement-trigger][data-disabled], -[data-number-field-scrubber][data-disabled] { - cursor: default; - opacity: var(--number-field-control-disabled-opacity); -} - -[data-number-field-scrubber][data-scrubbing] { - border-color: var(--number-field-control-border-active); - background: var(--number-field-control-bg-active); - color: var(--number-field-control-color-active); -} - -/* ── Default stepper glyphs (themeable) ─────────────────────────────────────── - A trigger with NO children renders the glyph from a token via `:empty::before`; - passing children overrides it. The glyph is decorative — the button is labeled - by its aria-label. The internal var picks the split (+/−) or stacked (▲/▼) token - so a single `content` rule serves both layouts; a theme overrides any token. */ -[data-number-field-increment-trigger] { - --_number-field-increment-glyph: var(--number-field-control-increment-glyph); -} -[data-number-field-decrement-trigger] { - --_number-field-decrement-glyph: var(--number-field-control-decrement-glyph); -} -[data-number-field][data-steppers='stacked'] [data-number-field-increment-trigger] { - --_number-field-increment-glyph: var(--number-field-control-increment-glyph-stacked); -} -[data-number-field][data-steppers='stacked'] [data-number-field-decrement-trigger] { - --_number-field-decrement-glyph: var(--number-field-control-decrement-glyph-stacked); -} -[data-number-field-increment-trigger]:empty::before { - content: var(--_number-field-increment-glyph); -} -[data-number-field-decrement-trigger]:empty::before { - content: var(--_number-field-decrement-glyph); -} - -/* ── Stacked steppers — compact column on the inline-end side ───────────────── - A 2×2 grid: the input fills the inline-start, the increment (top) and - decrement (bottom) share one narrow column on the inline-end. The grid mirrors - under RTL, so the column moves to the inline-start (left) automatically. */ -[data-number-field][data-steppers='stacked'] { - display: grid; - grid-template-columns: 1fr auto; - /* `minmax(0, …)` lets each row be EXACTLY half the field height. Plain `1fr` - has an implicit `min-content` floor, so a glyph taller than half the field - (small sizes) would grow its row and push the other past the clipped edge — - the bug where decrement looked cut off and the two arrows were unequal. */ - grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); - grid-template-areas: - 'input increment' - 'input decrement'; - /* The base field rule sets `align-items: center` for its inline-flex layout; - once we switch to grid that would center each arrow at its content height - instead of filling its row. Restore the grid default so each arrow stretches - to half the field height — making the stepper height a function of `size`. */ - align-items: stretch; - /* The stepper column reaches the edge itself, so the field drops its inline - padding; the input carries it instead. */ - padding-inline: 0; -} - -[data-number-field][data-steppers='stacked'] [data-number-field-input] { - grid-area: input; - padding-inline: var(--_number-field-px); -} - -[data-number-field][data-steppers='stacked'] [data-number-field-increment-trigger], -[data-number-field][data-steppers='stacked'] [data-number-field-decrement-trigger] { - inline-size: calc(var(--_number-field-height) * 0.62); - min-inline-size: calc(var(--_number-field-height) * 0.62); - /* each arrow takes half the field height (the grid row), not the full height */ - block-size: auto; - /* the split layout's negative edge margins don't apply here */ - margin: 0; - /* vertical divider toward the input */ - border-inline-start: var(--number-field-control-border-width) solid - var(--number-field-border); - /* Glyph scales with the field height (≈ a third of it), so it stays inside the - half-height cell at every size — including `xs`, where a fixed `0.9em` was too - tall and overflowed. Scales up cleanly through `xl`. */ - font-size: calc(var(--_number-field-height) * 0.34); - line-height: 1; -} - -[data-number-field][data-steppers='stacked'] [data-number-field-increment-trigger] { - grid-area: increment; -} - -[data-number-field][data-steppers='stacked'] [data-number-field-decrement-trigger] { - grid-area: decrement; - /* horizontal divider between the two arrows */ - border-block-start: var(--number-field-control-border-width) solid - var(--number-field-border); -} - -/* No room for the pointer scrubber in the stacked layout. */ -[data-number-field][data-steppers='stacked'] [data-number-field-scrubber] { - display: none; -} - -@media (prefers-reduced-motion: reduce) { - [data-number-field], - [data-number-field-increment-trigger], - [data-number-field-decrement-trigger], - [data-number-field-scrubber] { - transition: none; - } -} +/* NumberField has no visual of its own: it IS a spin-field. Its morfo declares + structural identity (`data-spin-field*`), so the shared stepper-field visual in + `../spin-field/spin-field.css` (loaded via the foundation @import in index.css) + styles it entirely — field surface, split/stacked steppers, themeable glyphs, + sizes, variants. This file stays as the component's self-import anchor (and as + the place for any future number-field-only chrome). A theme tints just this + component by scoping `[data-number-field] { --spin-field-… }`. */ diff --git a/src/uix/eidos/components/palabras/palabras-typography.svelte b/src/uix/eidos/components/palabras/palabras-typography.svelte index 5692c77d5..792318612 100644 --- a/src/uix/eidos/components/palabras/palabras-typography.svelte +++ b/src/uix/eidos/components/palabras/palabras-typography.svelte @@ -19,6 +19,7 @@ import { Select } from '$uix/eidos/components/select'; import { Slider } from '$uix/eidos/components/slider'; import { CssField } from '$uix/eidos/components/css-field'; + import { NumberField } from '$uix/eidos/components/number-field'; import { Checkbox } from '$uix/eidos/components/checkbox'; import { Button } from '$uix/eidos/components/button'; import { Strikethrough } from '$uix/eidos/components/icon'; @@ -116,13 +117,23 @@ // Active preset / slider values, derived from the stored prop. A chip is active // only while the stored value EQUALS its scale step (string compare). - // Active preset = the stored value, or the THEME DEFAULT step while unset (so the - // theme's resting preset shows selected, never an empty toggle). - const leadingActive = $derived(value.lineHeight == null ? LEADING_DEFAULT : String(value.lineHeight)); - const trackingActive = $derived(value.letterSpacing ?? TRACKING_DEFAULT); + // Preset Select selection: the matching theme step (or the default while unset); + // empty when a MANUAL value doesn't land on a step (the slider/input own it then). + const leadingSel = $derived.by(() => { + const v = value.lineHeight == null ? LEADING_DEFAULT : String(value.lineHeight); + return LEADING.some((o) => o.value === v) ? [v] : []; + }); + const trackingSel = $derived.by(() => { + const v = value.letterSpacing ?? TRACKING_DEFAULT; + return TRACKING.some((o) => o.value === v) ? [v] : []; + }); // Tamaño shown = the override, else the block's default (the H level's own size). const sizeEffective = $derived(value.fontSize ?? defaultFontSize); + // Manual slider values (px / unitless / ch — same unit as each prop's input). const leadingSlider = $derived(value.lineHeight ?? Number(LEADING_DEFAULT)); + const trackingSlider = $derived(pxOf(value.letterSpacing)); + const measureSlider = $derived(chOf(value.maxWidth)); + const allFonts = $derived(fonts.flatMap((g) => g.fonts)); const fontValue = $derived(value.fontFamily ? [value.fontFamily] : []); const weightValue = $derived(value.fontWeight != null ? [String(value.fontWeight)] : []); const wrapValue = $derived(value.textWrap ? [value.textWrap] : []); @@ -156,8 +167,93 @@ function setLeading(n: number | undefined) { onPatch({ lineHeight: n && n > 0 ? n : undefined }); } + // Tracking / Medida manual sliders write px / ch (the CssField owns precise units). + function pxOf(s: string | undefined): number { + const m = s ? /^(-?\d*\.?\d+)px$/.exec(s) : null; + return m ? parseFloat(m[1]) : 0; + } + function chOf(s: string | undefined): number { + const m = s ? /^(\d*\.?\d+)ch$/.exec(s) : null; + return m ? parseFloat(m[1]) : 66; + } + function setLetterPx(n: number | undefined) { + onPatch({ letterSpacing: n ? `${n}px` : undefined }); + } + function setMeasureCh(n: number | undefined) { + onPatch({ maxWidth: n ? `${n}ch` : undefined }); + } + /** Visible trigger text for a Select — the matched option's LABEL. (Select.Value + * shows the raw VALUE while its menu/labels are unmounted, i.e. when the value is + * set programmatically rather than picked, so we render the label ourselves.) */ + function labelOf( + opts: readonly { value: string | number | boolean; label: string }[], + sel: string[] + ): string | undefined { + return opts.find((o) => String(o.value) === sel[0])?.label; + } + +{#snippet presetSelect( + sel: string[], + opts: readonly PalabrasFieldOption[], + onPick: (v: string | undefined) => void, + placeholder: string +)} + +{/snippet} + + +{#snippet sliderCtl( + val: number, + min: number, + max: number, + step: number, + onSlide: (n: number) => void, + label: string +)} + onSlide(v[0])} + aria-label={label} + > + + + +{/snippet} +
@@ -172,7 +268,9 @@ onValueChange={(v) => pickFamily(v[0])} > - + {labelOf(allFonts, fontValue) ?? 'Por defecto'} @@ -269,7 +367,9 @@ onValueChange={(v) => pickWeight(v[0])} > - + {labelOf(WEIGHTS, weightValue) ?? 'Por defecto'} @@ -291,53 +391,47 @@
- +
Interlineado - - setLeading(v[0])} - aria-label="Interlineado manual" - > - - - + {@render presetSelect(leadingSel, LEADING, pickLeading, 'Personalizado')} +
+ {@render sliderCtl(leadingSlider, 1, 2, 0.05, setLeading, 'Interlineado')} + setLeading(n)} + > + − + + + + +
- +
Tracking - setStr('letterSpacing', v)} - /> - setStr('letterSpacing', v)} - > - − - - + - + {@render presetSelect(trackingSel, TRACKING, (v) => setStr('letterSpacing', v), 'Personalizado')} +
+ {@render sliderCtl(trackingSlider, -10, 10, 0.5, setLetterPx, 'Tracking')} + setStr('letterSpacing', v)} + > + − + + + + +
@@ -357,7 +451,7 @@ - +
Medida setStr('maxWidth', v)} /> - setStr('maxWidth', v)} - > - − - - + - -
- - -
- Ajuste - +
+ {@render sliderCtl(measureSlider, 20, 120, 1, setMeasureCh, 'Medida')} + setStr('maxWidth', v)} + > + − + + + + +
- -
- Cifras tabulares - onPatch({ tabularNumbers: c || undefined })} - > - - + +
+
+ Ajuste + {@render presetSelect(wrapValue, WRAP_OPTS, pickWrap, 'Normal')} +
+
+ Cifras tabulares + onPatch({ tabularNumbers: c || undefined })} + > + + +
diff --git a/src/uix/eidos/components/palabras/palabras.css b/src/uix/eidos/components/palabras/palabras.css index 3dc76dc19..04e36e1c5 100644 --- a/src/uix/eidos/components/palabras/palabras.css +++ b/src/uix/eidos/components/palabras/palabras.css @@ -550,37 +550,56 @@ align-items: center; gap: var(--space-2); } -/* Tamaño — the slider grows; the px field is a fixed compact box beside it. */ +/* Slider | input pair — 50 / 50 (Tamaño, and the manual half of each scale). */ [data-palabras-type-pair-row] { - display: flex; - align-items: center; + display: grid; + grid-template-columns: 1fr 1fr; gap: var(--space-2); -} -[data-palabras-type-pair-row] > :first-child { - flex: 1 1 auto; + align-items: center; + /* The slider's own `min-inline-size: var(--slider-min-inline-size)` (12rem) would + keep its 1fr column from shrinking; zero the token (inherited by the slider) so + both cells reach a true 50 / 50. */ + --slider-min-inline-size: 0; +} +/* Grid items default to `min-width: auto` (their content min-size). The slider's + intrinsic min-width would otherwise eat the row and starve the input to a few px, + so let both cells shrink to a true 1fr (50 / 50). */ +[data-palabras-type-pair-row] > * { min-inline-size: 0; } -[data-palabras-type-pair-row] > :last-child { - flex: 0 0 4.5rem; -} -/* Scale controls (Interlineado / Tracking / Measure) — preset chips ABOVE the - manual input (same prop). The `attached` chip row can exceed the panel width; - let it scroll rather than break the layout. */ +/* Scale controls keep their preset row ABOVE the slider|input pair. Only Medida + still uses chips (Interlineado / Tracking are dropdowns now); its three steps fit + at the panel's default chip size, so no shrinking is needed. */ [data-palabras-type-scale] { display: flex; flex-direction: column; gap: var(--space-1-5); } -[data-palabras-type-scale] [data-toggle-group] { - max-inline-size: 100%; - overflow-x: auto; +/* Ajuste (select) + Cifras tabulares (checkbox) on one row, same height. */ +[data-palabras-type-inline-pair] { + display: flex; + align-items: center; + gap: var(--space-3); } -/* Canonical step NAMES are wider than numbers — pack the scale chips (xxs font + - slim padding) so all six leading steps fit without truncation. Beats the - panel-wide `[data-toggle-group-item]` 12px rule (0,4,0 > 0,3,0). */ -[data-palabras-panel][data-palabras-panel] [data-palabras-type-scale] [data-toggle-group-item] { - font-size: var(--font-size-xxs); - padding-inline: 2px; +[data-palabras-type-inline-pair] > [data-palabras-field]:first-child { + flex: 1 1 auto; + min-inline-size: 0; +} +[data-palabras-type-inline-pair] > [data-palabras-field]:last-child { + flex: 0 0 auto; +} +/* Trigger text we render ourselves (Select.Value shows the raw value while its menu + is unmounted). Fill the trigger, truncate, mute the placeholder. */ +[data-palabras-select-text] { + flex: 1 1 auto; + min-inline-size: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: start; +} +[data-palabras-select-text][data-empty] { + color: var(--color-content-muted); } /* Toggle pills (Nivel): the ToggleGroup recipe renders `xs` at 10px, but the diff --git a/src/uix/eidos/components/spin-field/spin-field.css b/src/uix/eidos/components/spin-field/spin-field.css new file mode 100644 index 000000000..7d7f4e773 --- /dev/null +++ b/src/uix/eidos/components/spin-field/spin-field.css @@ -0,0 +1,301 @@ +/* ── spin-field — shared stepper-field visual ───────────────────────────────── + The single source of the "field with increment/decrement spin controls" + visual. NumberField and CssField are the same component visually (a bordered + field + input + increment/decrement triggers + scrubber, split/stacked layouts, + sizes/variants/colors, themeable glyphs); only their value model differs (soma: + number vs CSS value). Rather than clone this CSS per component — which drifted — + both declare structural identity in their morfo (`data-spin-field*`, like the + toggle-group → toggle pattern) and reuse these rules + the `spin-field` recipe + tokens. A theme tints per component by scoping `[data-number-field] { --spin- + field-… }` (inherited by the steppers); the default is shared. + + Loaded via the foundation `@import` in `index.css` (no `spin-field.svelte` to + self-import it). `data-size` / `data-variant` / `data-color` / `data-steppers` + are set by each eidos wrapper on the same root element that carries + `data-spin-field`. */ +[data-spin-field] { + --_spin-field-height: var(--spin-field-height-md); + --_spin-field-px: var(--spin-field-px-md); + --_spin-field-gap: var(--spin-field-gap-md); + --_spin-field-font-size: var(--spin-field-font-size-md); + + display: inline-flex; + align-items: center; + inline-size: 100%; + min-inline-size: 0; + block-size: var(--_spin-field-height); + padding-inline: var(--_spin-field-px); + border: var(--spin-field-border-width) solid var(--spin-field-border); + border-radius: var(--spin-field-radius); + background: var(--spin-field-bg); + /* The edge steppers break OUT of this inline padding (negative margin) so they + sit FLUSH against the field border; `overflow` then clips them to the + rounded corners. The padding still spaces the input from the field ends. */ + overflow: hidden; + color: var(--spin-field-color); + font-family: var(--spin-field-font-family); + font-size: var(--_spin-field-font-size); + line-height: var(--spin-field-line-height); + transition: + background var(--spin-field-transition-duration) var(--spin-field-transition-ease), + border-color var(--spin-field-transition-duration) var(--spin-field-transition-ease), + box-shadow var(--spin-field-transition-duration) var(--spin-field-transition-ease); +} + +[data-spin-field][data-size='xs'] { + --_spin-field-height: var(--spin-field-height-xs); + --_spin-field-px: var(--spin-field-px-xs); + --_spin-field-gap: var(--spin-field-gap-xs); + --_spin-field-font-size: var(--spin-field-font-size-xs); +} + +[data-spin-field][data-size='sm'] { + --_spin-field-height: var(--spin-field-height-sm); + --_spin-field-px: var(--spin-field-px-sm); + --_spin-field-gap: var(--spin-field-gap-sm); + --_spin-field-font-size: var(--spin-field-font-size-sm); +} + +[data-spin-field][data-size='lg'] { + --_spin-field-height: var(--spin-field-height-lg); + --_spin-field-px: var(--spin-field-px-lg); + --_spin-field-gap: var(--spin-field-gap-lg); + --_spin-field-font-size: var(--spin-field-font-size-lg); +} + +[data-spin-field][data-size='xl'] { + --_spin-field-height: var(--spin-field-height-xl); + --_spin-field-px: var(--spin-field-px-xl); + --_spin-field-gap: var(--spin-field-gap-xl); + --_spin-field-font-size: var(--spin-field-font-size-xl); +} + +[data-spin-field][data-variant='outline'] { + background: transparent; +} + +[data-spin-field][data-variant='ghost'] { + border-color: transparent; + background: transparent; + padding-inline: 0; +} + +[data-spin-field][data-focused], +[data-spin-field][data-scrubbing] { + border-color: var(--spin-field-border-focus); + box-shadow: var(--spin-field-focus-shadow); +} + +[data-spin-field][data-invalid] { + border-color: var(--spin-field-border-invalid); +} + +[data-spin-field][data-disabled] { + opacity: var(--spin-field-disabled-opacity); +} + +[data-spin-field-input] { + flex: 1 1 auto; + order: 1; + min-inline-size: 0; + /* No gap on the field — the steppers butt right up against the input via their + divider. This padding (the former gap value) keeps the text off the divider. */ + padding-inline: var(--_spin-field-gap); + border: 0; + background: transparent; + color: inherit; + font: inherit; + outline: none; + transition: box-shadow var(--spin-field-transition-duration) + var(--spin-field-transition-ease); +} + +/* Split layout (default) — decrement flush at the inline-start edge, increment + flush at the inline-end edge, scrubber tucked just inside the increment. + `order` makes the arrangement independent of composition order and flips under + RTL; the negative margin on the two end triggers cancels the field's inline + padding so they reach the border; the divider sits on each trigger's INNER + side (decrement → end, increment → start), flipping under RTL. */ +[data-spin-field]:not([data-steppers='stacked']) [data-spin-field-decrement-trigger] { + order: 0; + margin-inline-start: calc(-1 * var(--_spin-field-px)); + border-inline-end: var(--spin-field-control-border-width) solid + var(--spin-field-control-divider); +} +[data-spin-field]:not([data-steppers='stacked']) [data-spin-field-scrubber] { + order: 2; +} +[data-spin-field]:not([data-steppers='stacked']) [data-spin-field-increment-trigger] { + order: 3; + margin-inline-end: calc(-1 * var(--_spin-field-px)); + border-inline-start: var(--spin-field-control-border-width) solid + var(--spin-field-control-divider); +} + +[data-spin-field-input][data-disabled] { + cursor: not-allowed; +} + +/* Focus is shown once, by the root's canonical two-ring focus-shadow + (`[data-spin-field][data-focused]`). No separate ring on the input — that + stacked a second (inner) frame inside the root ring. */ + +[data-spin-field-increment-trigger], +[data-spin-field-decrement-trigger], +[data-spin-field-scrubber] { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + /* Stepper buttons and scrubber inherit the Provider's resolved height so + they always match the input row, regardless of size token. */ + inline-size: var(--_spin-field-height); + block-size: var(--_spin-field-height); + min-inline-size: var(--_spin-field-height); + border: var(--spin-field-control-border-width) solid var(--spin-field-control-border); + border-radius: var(--spin-field-control-radius); + background: var(--spin-field-control-bg); + color: var(--spin-field-control-color); + font: inherit; + outline: none; + transition: + background var(--spin-field-transition-duration) var(--spin-field-transition-ease), + border-color var(--spin-field-transition-duration) var(--spin-field-transition-ease), + color var(--spin-field-transition-duration) var(--spin-field-transition-ease), + box-shadow var(--spin-field-transition-duration) var(--spin-field-transition-ease); +} + +[data-spin-field-increment-trigger], +[data-spin-field-decrement-trigger] { + cursor: pointer; +} + +[data-spin-field-scrubber] { + cursor: ew-resize; +} + +[data-spin-field-increment-trigger]:hover:not([data-disabled]), +[data-spin-field-decrement-trigger]:hover:not([data-disabled]), +[data-spin-field-scrubber]:hover:not([data-disabled]) { + border-color: var(--spin-field-control-border-hover); + background: var(--spin-field-control-bg-hover); + color: var(--spin-field-control-color-hover); +} + +[data-spin-field-increment-trigger]:focus-visible, +[data-spin-field-decrement-trigger]:focus-visible, +[data-spin-field-scrubber]:focus-visible { + background: var(--spin-field-control-bg-focus); + color: var(--spin-field-control-color-hover); + box-shadow: var(--spin-field-control-focus-shadow); +} + +[data-spin-field-increment-trigger][data-disabled], +[data-spin-field-decrement-trigger][data-disabled], +[data-spin-field-scrubber][data-disabled] { + cursor: default; + opacity: var(--spin-field-control-disabled-opacity); +} + +[data-spin-field-scrubber][data-scrubbing] { + border-color: var(--spin-field-control-border-active); + background: var(--spin-field-control-bg-active); + color: var(--spin-field-control-color-active); +} + +/* ── Default stepper glyphs (themeable) ─────────────────────────────────────── + A trigger with NO children renders the glyph from a token via `:empty::before`; + passing children overrides it. The glyph is decorative — the button is labeled + by its aria-label. The internal var picks the split (+/−) or stacked (▲/▼) token + so a single `content` rule serves both layouts; a theme overrides any token. */ +[data-spin-field-increment-trigger] { + --_spin-field-increment-glyph: var(--spin-field-control-increment-glyph); +} +[data-spin-field-decrement-trigger] { + --_spin-field-decrement-glyph: var(--spin-field-control-decrement-glyph); +} +[data-spin-field][data-steppers='stacked'] [data-spin-field-increment-trigger] { + --_spin-field-increment-glyph: var(--spin-field-control-increment-glyph-stacked); +} +[data-spin-field][data-steppers='stacked'] [data-spin-field-decrement-trigger] { + --_spin-field-decrement-glyph: var(--spin-field-control-decrement-glyph-stacked); +} +[data-spin-field-increment-trigger]:empty::before { + content: var(--_spin-field-increment-glyph); +} +[data-spin-field-decrement-trigger]:empty::before { + content: var(--_spin-field-decrement-glyph); +} + +/* ── Stacked steppers — compact column on the inline-end side ───────────────── + A 2×2 grid: the input fills the inline-start, the increment (top) and + decrement (bottom) share one narrow column on the inline-end. The grid mirrors + under RTL, so the column moves to the inline-start (left) automatically. */ +[data-spin-field][data-steppers='stacked'] { + display: grid; + grid-template-columns: 1fr auto; + /* `minmax(0, …)` lets each row be EXACTLY half the field height. Plain `1fr` + has an implicit `min-content` floor, so a glyph taller than half the field + (small sizes) would grow its row and push the other past the clipped edge — + the bug where decrement looked cut off and the two arrows were unequal. */ + grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); + grid-template-areas: + 'input increment' + 'input decrement'; + /* The base field rule sets `align-items: center` for its inline-flex layout; + once we switch to grid that would center each arrow at its content height + instead of filling its row. Restore the grid default so each arrow stretches + to half the field height — making the stepper height a function of `size`. */ + align-items: stretch; + /* The stepper column reaches the edge itself, so the field drops its inline + padding; the input carries it instead. */ + padding-inline: 0; +} + +[data-spin-field][data-steppers='stacked'] [data-spin-field-input] { + grid-area: input; + padding-inline: var(--_spin-field-px); +} + +[data-spin-field][data-steppers='stacked'] [data-spin-field-increment-trigger], +[data-spin-field][data-steppers='stacked'] [data-spin-field-decrement-trigger] { + inline-size: calc(var(--_spin-field-height) * 0.62); + min-inline-size: calc(var(--_spin-field-height) * 0.62); + /* each arrow takes half the field height (the grid row), not the full height */ + block-size: auto; + /* the split layout's negative edge margins don't apply here */ + margin: 0; + /* vertical divider toward the input */ + border-inline-start: var(--spin-field-control-border-width) solid + var(--spin-field-border); + /* Glyph scales with the field height (≈ a third of it), so it stays inside the + half-height cell at every size — including `xs`, where a fixed `0.9em` was too + tall and overflowed. Scales up cleanly through `xl`. */ + font-size: calc(var(--_spin-field-height) * 0.34); + line-height: 1; +} + +[data-spin-field][data-steppers='stacked'] [data-spin-field-increment-trigger] { + grid-area: increment; +} + +[data-spin-field][data-steppers='stacked'] [data-spin-field-decrement-trigger] { + grid-area: decrement; + /* horizontal divider between the two arrows */ + border-block-start: var(--spin-field-control-border-width) solid + var(--spin-field-border); +} + +/* No room for the pointer scrubber in the stacked layout. */ +[data-spin-field][data-steppers='stacked'] [data-spin-field-scrubber] { + display: none; +} + +@media (prefers-reduced-motion: reduce) { + [data-spin-field], + [data-spin-field-increment-trigger], + [data-spin-field-decrement-trigger], + [data-spin-field-scrubber] { + transition: none; + } +} diff --git a/src/uix/eidos/generated/base.css b/src/uix/eidos/generated/base.css index e104fa7e0..1c9616845 100644 --- a/src/uix/eidos/generated/base.css +++ b/src/uix/eidos/generated/base.css @@ -1218,108 +1218,58 @@ --meter-indicator-bg-above: var(--color-threat-solid); --meter-transition-duration: var(--duration-normal); --meter-transition-ease: var(--ease-default); - --number-field-height-xs: var(--control-height-xs); - --number-field-height-sm: var(--control-height-sm); - --number-field-height-md: var(--control-height-md); - --number-field-height-lg: var(--control-height-lg); - --number-field-height-xl: var(--control-height-xl); - --number-field-px-xs: var(--space-2); - --number-field-px-sm: var(--space-2-5); - --number-field-px-md: var(--space-3); - --number-field-px-lg: var(--space-3-5); - --number-field-px-xl: var(--space-4); - --number-field-gap-xs: var(--space-0-5); - --number-field-gap-sm: var(--space-1); - --number-field-gap-md: var(--space-1-5); - --number-field-gap-lg: var(--space-2); - --number-field-gap-xl: var(--space-2-5); - --number-field-font-family: var(--font-ui); - --number-field-font-size-xs: var(--font-size-xs); - --number-field-font-size-sm: var(--font-size-xs); - --number-field-font-size-md: var(--font-size-sm); - --number-field-font-size-lg: var(--font-size-sm); - --number-field-font-size-xl: var(--font-size-md); - --number-field-line-height: var(--leading-ui); - --number-field-radius: var(--radius-md); - --number-field-border-width: var(--border-width); - --number-field-border: var(--color-border-default); - --number-field-border-focus: var(--color-focus-ring); - --number-field-border-invalid: var(--color-risk-border); - --number-field-bg: var(--color-surface-default); - --number-field-color: var(--color-content-primary); - --number-field-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color); - --number-field-control-radius: 0; - --number-field-control-border-width: var(--border-width); - --number-field-control-border: transparent; - --number-field-control-divider: var(--color-border-default); - --number-field-control-border-hover: var(--color-border-strong); - --number-field-control-border-active: var(--color-primary-border); - --number-field-control-bg: transparent; - --number-field-control-bg-hover: var(--color-neutral-hover); - --number-field-control-bg-focus: var(--color-neutral-hover); - --number-field-control-bg-active: var(--color-primary-track); - --number-field-control-color: var(--color-content-secondary); - --number-field-control-color-hover: var(--color-content-primary); - --number-field-control-color-active: var(--color-primary-text); - --number-field-control-increment-glyph: '+'; - --number-field-control-decrement-glyph: '\2212'; - --number-field-control-increment-glyph-stacked: '\25B2'; - --number-field-control-decrement-glyph-stacked: '\25BC'; - --number-field-control-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color); - --number-field-control-disabled-opacity: 0.48; - --number-field-transition-duration: var(--duration-fast); - --number-field-transition-ease: var(--ease-default); - --number-field-disabled-opacity: 0.56; - --css-field-height-xs: var(--control-height-xs); - --css-field-height-sm: var(--control-height-sm); - --css-field-height-md: var(--control-height-md); - --css-field-height-lg: var(--control-height-lg); - --css-field-height-xl: var(--control-height-xl); - --css-field-px-xs: var(--space-2); - --css-field-px-sm: var(--space-2-5); - --css-field-px-md: var(--space-3); - --css-field-px-lg: var(--space-3-5); - --css-field-px-xl: var(--space-4); - --css-field-gap-xs: var(--space-0-5); - --css-field-gap-sm: var(--space-1); - --css-field-gap-md: var(--space-1-5); - --css-field-gap-lg: var(--space-2); - --css-field-gap-xl: var(--space-2-5); - --css-field-font-family: var(--font-ui); - --css-field-font-size-xs: var(--font-size-xs); - --css-field-font-size-sm: var(--font-size-xs); - --css-field-font-size-md: var(--font-size-sm); - --css-field-font-size-lg: var(--font-size-sm); - --css-field-font-size-xl: var(--font-size-md); - --css-field-line-height: var(--leading-ui); - --css-field-radius: var(--radius-md); - --css-field-border-width: var(--border-width); - --css-field-border: var(--color-border-default); - --css-field-border-focus: var(--color-focus-ring); - --css-field-border-invalid: var(--color-risk-border); - --css-field-bg: var(--color-surface-default); - --css-field-color: var(--color-content-primary); - --css-field-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color); - --css-field-control-radius: var(--radius-md); - --css-field-control-border-width: var(--border-width); - --css-field-control-border: transparent; - --css-field-control-border-hover: var(--color-border-subtle); - --css-field-control-border-active: var(--color-primary-border); - --css-field-control-bg: transparent; - --css-field-control-bg-hover: var(--color-surface-raised); - --css-field-control-bg-active: var(--color-primary-track); - --css-field-control-color: var(--color-content-secondary); - --css-field-control-color-hover: var(--color-content-primary); - --css-field-control-color-active: var(--color-primary-text); - --css-field-control-increment-glyph: '+'; - --css-field-control-decrement-glyph: '\2212'; - --css-field-control-increment-glyph-stacked: '\25B2'; - --css-field-control-decrement-glyph-stacked: '\25BC'; - --css-field-control-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color); - --css-field-control-disabled-opacity: 0.48; - --css-field-transition-duration: var(--duration-fast); - --css-field-transition-ease: var(--ease-default); - --css-field-disabled-opacity: 0.56; + --spin-field-height-xs: var(--control-height-xs); + --spin-field-height-sm: var(--control-height-sm); + --spin-field-height-md: var(--control-height-md); + --spin-field-height-lg: var(--control-height-lg); + --spin-field-height-xl: var(--control-height-xl); + --spin-field-px-xs: var(--space-2); + --spin-field-px-sm: var(--space-2-5); + --spin-field-px-md: var(--space-3); + --spin-field-px-lg: var(--space-3-5); + --spin-field-px-xl: var(--space-4); + --spin-field-gap-xs: var(--space-0-5); + --spin-field-gap-sm: var(--space-1); + --spin-field-gap-md: var(--space-1-5); + --spin-field-gap-lg: var(--space-2); + --spin-field-gap-xl: var(--space-2-5); + --spin-field-font-family: var(--font-ui); + --spin-field-font-size-xs: var(--font-size-xs); + --spin-field-font-size-sm: var(--font-size-xs); + --spin-field-font-size-md: var(--font-size-sm); + --spin-field-font-size-lg: var(--font-size-sm); + --spin-field-font-size-xl: var(--font-size-md); + --spin-field-line-height: var(--leading-ui); + --spin-field-radius: var(--radius-md); + --spin-field-border-width: var(--border-width); + --spin-field-border: var(--color-border-default); + --spin-field-border-focus: var(--color-focus-ring); + --spin-field-border-invalid: var(--color-risk-border); + --spin-field-bg: var(--color-surface-default); + --spin-field-color: var(--color-content-primary); + --spin-field-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color); + --spin-field-control-radius: 0; + --spin-field-control-border-width: var(--border-width); + --spin-field-control-border: transparent; + --spin-field-control-divider: var(--color-border-default); + --spin-field-control-border-hover: var(--color-border-strong); + --spin-field-control-border-active: var(--color-primary-border); + --spin-field-control-bg: transparent; + --spin-field-control-bg-hover: var(--color-neutral-hover); + --spin-field-control-bg-focus: var(--color-neutral-hover); + --spin-field-control-bg-active: var(--color-primary-track); + --spin-field-control-color: var(--color-content-secondary); + --spin-field-control-color-hover: var(--color-content-primary); + --spin-field-control-color-active: var(--color-primary-text); + --spin-field-control-increment-glyph: '+'; + --spin-field-control-decrement-glyph: '\2212'; + --spin-field-control-increment-glyph-stacked: '\25B2'; + --spin-field-control-decrement-glyph-stacked: '\25BC'; + --spin-field-control-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color); + --spin-field-control-disabled-opacity: 0.48; + --spin-field-transition-duration: var(--duration-fast); + --spin-field-transition-ease: var(--ease-default); + --spin-field-disabled-opacity: 0.56; --float-panel-bg: var(--color-surface-overlay); --float-panel-border: var(--color-border-default); --float-panel-border-width: var(--border-width); diff --git a/src/uix/eidos/index.css b/src/uix/eidos/index.css index 6fa0529f4..747d0174f 100644 --- a/src/uix/eidos/index.css +++ b/src/uix/eidos/index.css @@ -78,3 +78,11 @@ @import './components/section/section.css'; @import './components/aspect-ratio/aspect-ratio.css'; @import './components/auto-grid/auto-grid.css'; +/* + * spin-field — shared stepper-field visual reused by number-field + css-field + * via structural identity (`data-spin-field*`). It has no `.svelte` of its own + * to self-import its CSS, so (like the layout primitives) it loads here. The + * trade-off is that it ships even on pages with no spin-field; in exchange there + * is ONE source of the stepper-field visual instead of two clones that drift. + */ +@import './components/spin-field/spin-field.css'; diff --git a/src/uix/eidos/lib/recipes/base.ts b/src/uix/eidos/lib/recipes/base.ts index 58abfbdb5..b623418d2 100644 --- a/src/uix/eidos/lib/recipes/base.ts +++ b/src/uix/eidos/lib/recipes/base.ts @@ -1042,7 +1042,11 @@ export const THEME_BASE_RECIPE_TOKENS = { 'transition-duration': 'var(--duration-normal)', 'transition-ease': 'var(--ease-default)' }, - 'number-field': { + // spin-field — shared visual for the stepper-field family (number-field + + // css-field). Both reuse these tokens via structural identity (`data-spin- + // field*`) and the shared `spin-field.css`. A theme tints per component by + // scoping `[data-number-field] { --spin-field-… }`. See THEMING §34. + 'spin-field': { 'height-xs': 'var(--control-height-xs)', 'height-sm': 'var(--control-height-sm)', 'height-md': 'var(--control-height-md)', @@ -1106,64 +1110,6 @@ export const THEME_BASE_RECIPE_TOKENS = { 'transition-ease': 'var(--ease-default)', 'disabled-opacity': '0.56' }, - 'css-field': { - 'height-xs': 'var(--control-height-xs)', - 'height-sm': 'var(--control-height-sm)', - 'height-md': 'var(--control-height-md)', - 'height-lg': 'var(--control-height-lg)', - 'height-xl': 'var(--control-height-xl)', - 'px-xs': 'var(--space-2)', - 'px-sm': 'var(--space-2-5)', - 'px-md': 'var(--space-3)', - 'px-lg': 'var(--space-3-5)', - 'px-xl': 'var(--space-4)', - 'gap-xs': 'var(--space-0-5)', - 'gap-sm': 'var(--space-1)', - 'gap-md': 'var(--space-1-5)', - 'gap-lg': 'var(--space-2)', - 'gap-xl': 'var(--space-2-5)', - 'font-family': 'var(--font-ui)', - 'font-size-xs': 'var(--font-size-xs)', - 'font-size-sm': 'var(--font-size-xs)', - 'font-size-md': 'var(--font-size-sm)', - 'font-size-lg': 'var(--font-size-sm)', - 'font-size-xl': 'var(--font-size-md)', - 'line-height': 'var(--leading-ui)', - radius: 'var(--radius-md)', - 'border-width': 'var(--border-width)', - border: 'var(--color-border-default)', - 'border-focus': 'var(--color-focus-ring)', - 'border-invalid': 'var(--color-risk-border)', - bg: 'var(--color-surface-default)', - color: 'var(--color-content-primary)', - 'focus-shadow': - 'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)', - 'control-radius': 'var(--radius-md)', - 'control-border-width': 'var(--border-width)', - 'control-border': 'transparent', - 'control-border-hover': 'var(--color-border-subtle)', - 'control-border-active': 'var(--color-primary-border)', - 'control-bg': 'transparent', - 'control-bg-hover': 'var(--color-surface-raised)', - 'control-bg-active': 'var(--color-primary-track)', - 'control-color': 'var(--color-content-secondary)', - 'control-color-hover': 'var(--color-content-primary)', - 'control-color-active': 'var(--color-primary-text)', - // Default stepper glyphs — themeable, decorative (the buttons carry an - // aria-label). Rendered via `:empty::before`, so passing children to a - // trigger overrides them. Split uses +/−; the stacked column uses up/down - // triangles. A theme retints/reshapes by overriding any of the four. - 'control-increment-glyph': "'+'", - 'control-decrement-glyph': "'\\2212'", - 'control-increment-glyph-stacked': "'\\25B2'", - 'control-decrement-glyph-stacked': "'\\25BC'", - 'control-focus-shadow': - 'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)', - 'control-disabled-opacity': '0.48', - 'transition-duration': 'var(--duration-fast)', - 'transition-ease': 'var(--ease-default)', - 'disabled-opacity': '0.56' - }, 'float-panel': { bg: 'var(--color-surface-overlay)', border: 'var(--color-border-default)', diff --git a/src/uix/morfo/components/css-field.ts b/src/uix/morfo/components/css-field.ts index 2b7097dda..798fc767c 100644 --- a/src/uix/morfo/components/css-field.ts +++ b/src/uix/morfo/components/css-field.ts @@ -70,6 +70,10 @@ export const cssFieldMorfo = { defaultElement: 'div', optional: false, data: [ + // Structural identity: CssField IS a spin-field. These presence attrs + // let the shared spin-field eidos visual style it without cloning CSS + // per component (see eidos/components/spin-field). + { attr: 'data-spin-field', value: v.literal(''), severity: 'required' }, { attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' }, { attr: 'data-readonly', value: v.propRef('readonly'), severity: 'optional' }, { attr: 'data-required', value: v.propRef('required'), severity: 'optional' }, @@ -88,6 +92,7 @@ export const cssFieldMorfo = { role: 'spinbutton', optional: false, data: [ + { attr: 'data-spin-field-input', value: v.literal(''), severity: 'required' }, { attr: 'data-disabled', severity: 'optional' }, { attr: 'data-readonly', severity: 'optional' }, { attr: 'data-invalid', severity: 'optional' } @@ -120,7 +125,10 @@ export const cssFieldMorfo = { defaultElement: 'button', role: 'button', optional: true, - data: [{ attr: 'data-disabled', severity: 'optional' }], + data: [ + { attr: 'data-spin-field-increment-trigger', value: v.literal(''), severity: 'required' }, + { attr: 'data-disabled', severity: 'optional' } + ], aria: [ { attr: 'type', value: v.literal('button') }, { @@ -144,7 +152,10 @@ export const cssFieldMorfo = { defaultElement: 'button', role: 'button', optional: true, - data: [{ attr: 'data-disabled', severity: 'optional' }], + data: [ + { attr: 'data-spin-field-decrement-trigger', value: v.literal(''), severity: 'required' }, + { attr: 'data-disabled', severity: 'optional' } + ], aria: [ { attr: 'type', value: v.literal('button') }, { @@ -169,6 +180,7 @@ export const cssFieldMorfo = { role: 'presentation', optional: true, data: [ + { attr: 'data-spin-field-scrubber', value: v.literal(''), severity: 'required' }, { attr: 'data-scrubbing', severity: 'optional' }, { attr: 'data-disabled', severity: 'optional' } ], diff --git a/src/uix/morfo/components/number-field.ts b/src/uix/morfo/components/number-field.ts index 58c4f5b7d..9be9bf5e2 100644 --- a/src/uix/morfo/components/number-field.ts +++ b/src/uix/morfo/components/number-field.ts @@ -50,6 +50,10 @@ export const numberFieldMorfo = { defaultElement: 'div', optional: false, data: [ + // Structural identity: NumberField IS a spin-field. These presence + // attrs let the shared spin-field eidos visual style it without + // cloning CSS per component (see eidos/components/spin-field). + { attr: 'data-spin-field', value: v.literal(''), severity: 'required' }, { attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' }, { attr: 'data-readonly', value: v.propRef('readonly'), severity: 'optional' }, { attr: 'data-required', value: v.propRef('required'), severity: 'optional' }, @@ -68,6 +72,7 @@ export const numberFieldMorfo = { role: 'spinbutton', optional: false, data: [ + { attr: 'data-spin-field-input', value: v.literal(''), severity: 'required' }, { attr: 'data-disabled', severity: 'optional' }, { attr: 'data-readonly', severity: 'optional' }, { attr: 'data-invalid', severity: 'optional' } @@ -112,7 +117,10 @@ export const numberFieldMorfo = { defaultElement: 'button', role: 'button', optional: true, - data: [{ attr: 'data-disabled', severity: 'optional' }], + data: [ + { attr: 'data-spin-field-increment-trigger', value: v.literal(''), severity: 'required' }, + { attr: 'data-disabled', severity: 'optional' } + ], aria: [ { attr: 'type', value: v.literal('button') }, { @@ -136,7 +144,10 @@ export const numberFieldMorfo = { defaultElement: 'button', role: 'button', optional: true, - data: [{ attr: 'data-disabled', severity: 'optional' }], + data: [ + { attr: 'data-spin-field-decrement-trigger', value: v.literal(''), severity: 'required' }, + { attr: 'data-disabled', severity: 'optional' } + ], aria: [ { attr: 'type', value: v.literal('button') }, { @@ -161,6 +172,7 @@ export const numberFieldMorfo = { role: 'presentation', optional: true, data: [ + { attr: 'data-spin-field-scrubber', value: v.literal(''), severity: 'required' }, { attr: 'data-scrubbing', severity: 'optional' }, { attr: 'data-disabled', severity: 'optional' } ], diff --git a/src/uix/soma/components/css-field/css-field-provider.svelte.ts b/src/uix/soma/components/css-field/css-field-provider.svelte.ts index e6a212827..dfb2069eb 100644 --- a/src/uix/soma/components/css-field/css-field-provider.svelte.ts +++ b/src/uix/soma/components/css-field/css-field-provider.svelte.ts @@ -216,8 +216,7 @@ export class CssFieldProvider { () => this.opts.required.current || (this.field?.isRequired ?? false) ); readonly isInvalid = $derived.by( - () => - this.opts.invalid.current || (this.field?.isInvalid ?? false) || this.internalInvalid + () => this.opts.invalid.current || (this.field?.isInvalid ?? false) || this.internalInvalid ); readonly dir = $derived.by(() => this.resolvedDir.current); @@ -533,6 +532,7 @@ export class CssFieldInputProvider { ...this.runtimePart.props, role: 'spinbutton' as const, inputmode: 'text' as const, + 'data-spin-field-input': '', autocomplete: 'off' as const, autocorrect: 'off' as const, spellcheck: false, @@ -617,6 +617,7 @@ export class CssFieldIncrementTriggerProvider { tabindex: -1, disabled: this.isDisabled || undefined, 'aria-label': this.provider.soma.langs.ts(CSS_FIELD_LANGS.INCREMENT), + 'data-spin-field-increment-trigger': '', 'aria-disabled': boolToStr(this.isDisabled), 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled), onclick: this.handleClick, @@ -683,6 +684,7 @@ export class CssFieldDecrementTriggerProvider { tabindex: -1, disabled: this.isDisabled || undefined, 'aria-label': this.provider.soma.langs.ts(CSS_FIELD_LANGS.DECREMENT), + 'data-spin-field-decrement-trigger': '', 'aria-disabled': boolToStr(this.isDisabled), 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled), onclick: this.handleClick, @@ -814,6 +816,7 @@ export class CssFieldScrubberProvider { ...this.runtimePart.props, role: 'presentation' as const, 'aria-hidden': true as const, + 'data-spin-field-scrubber': '', 'data-scrubbing': boolToEmptyStrOrUndef(this.provider.scrubbing), 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled), style: { diff --git a/src/uix/soma/components/number-field/number-field-provider.svelte.ts b/src/uix/soma/components/number-field/number-field-provider.svelte.ts index 9d8267378..ab18294d5 100644 --- a/src/uix/soma/components/number-field/number-field-provider.svelte.ts +++ b/src/uix/soma/components/number-field/number-field-provider.svelte.ts @@ -594,6 +594,7 @@ export class NumberFieldInputProvider { ...this.runtimePart.props, role: 'spinbutton' as const, inputmode: 'decimal' as const, + 'data-spin-field-input': '', autocomplete: 'off' as const, autocorrect: 'off' as const, spellcheck: false, @@ -681,6 +682,7 @@ export class NumberFieldIncrementTriggerProvider { tabindex: -1, disabled: this.isDisabled || undefined, 'aria-label': this.provider.soma.langs.ts(NUMBER_FIELD_LANGS.INCREMENT), + 'data-spin-field-increment-trigger': '', 'aria-disabled': boolToStr(this.isDisabled), 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled), onclick: this.handleClick, @@ -747,6 +749,7 @@ export class NumberFieldDecrementTriggerProvider { tabindex: -1, disabled: this.isDisabled || undefined, 'aria-label': this.provider.soma.langs.ts(NUMBER_FIELD_LANGS.DECREMENT), + 'data-spin-field-decrement-trigger': '', 'aria-disabled': boolToStr(this.isDisabled), 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled), onclick: this.handleClick, @@ -883,6 +886,7 @@ export class NumberFieldScrubberProvider { ...this.runtimePart.props, role: 'presentation' as const, 'aria-hidden': true as const, + 'data-spin-field-scrubber': '', 'data-scrubbing': boolToEmptyStrOrUndef(this.provider.scrubbing), 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled), style: { diff --git a/web/routes/uix/components/number-field/+page.svelte b/web/routes/uix/components/number-field/+page.svelte index 8584f6cdf..dfdd7eb54 100644 --- a/web/routes/uix/components/number-field/+page.svelte +++ b/web/routes/uix/components/number-field/+page.svelte @@ -467,9 +467,10 @@

Triggers render a default glyph from a token, so a theme can restyle every stepper at - once — override --number-field-control-increment-glyph / - -decrement-glyph (and their -stacked siblings). Passing children - to a trigger overrides the token for that instance. + once — override --spin-field-control-increment-glyph / + -decrement-glyph (and their -stacked siblings). The tokens are + shared by NumberField and CssField (both are spin-fields); passing children to a trigger + overrides the token for that instance.