From bd9d924577a14a8578ae906025c1e3edc64fbd4e Mon Sep 17 00:00:00 2001 From: dev Date: Thu, 11 Jun 2026 19:10:04 +0200 Subject: [PATCH] feat(eidos): themeable stepper glyphs + css-field compact steppers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NumberField and CssField steppers gain a `stacked` compact layout and themeable default glyphs. - Glyphs render from recipe tokens via `:empty::before` (split plus/minus, stacked up/down triangles); passing children overrides per instance, a theme overrides the token. 4 tokens per component, unicode-escaped. - CssField gains the `steppers` prop (split | stacked); its stacked layout mirrors NumberField (self-contained — the split layout left untouched). - NumberField split: square buttons flush to the field edge with an inner divider and higher hover/focus contrast (control-radius 0). - Stacked rows use minmax(0,1fr) + a height-proportional glyph so the two arrows stay symmetric and uncut at xs. - Demos: steppers controls + stepper-layout / sizes / themed-glyph showcases; code snippets use the themed defaults. - Docs: THEMING section 33, number-field and css-field READMEs. Note: also sweeps in the pre-existing palabras WIP already in the tree. Co-Authored-By: Claude Opus 4.8 --- src/uix/eidos/THEMING.md | 28 ++ src/uix/eidos/components/css-field/README.md | 12 + .../eidos/components/css-field/css-field.css | 87 ++++ .../components/css-field/css-field.svelte | 2 + src/uix/eidos/components/css-field/index.ts | 3 +- src/uix/eidos/components/css-field/types.ts | 10 + .../eidos/components/number-field/README.md | 20 + .../eidos/components/number-field/index.ts | 3 +- .../components/number-field/number-field.css | 121 ++++- .../number-field/number-field.svelte | 2 + .../eidos/components/number-field/types.ts | 11 + .../palabras/palabras-handle.svelte | 355 +++------------ .../components/palabras/palabras-panel.svelte | 275 +++++++++-- .../palabras/palabras-spacing.svelte | 168 +++++++ .../palabras/palabras-typography.svelte | 428 ++++++++++++++++++ .../eidos/components/palabras/palabras.css | 140 +++++- .../eidos/components/palabras/panel-schema.ts | 77 +++- src/uix/eidos/components/palabras/types.ts | 32 +- src/uix/eidos/generated/base.css | 16 +- src/uix/eidos/lib/recipes/base.ts | 28 +- .../words/engine/operations/table-ops.ts | 9 +- .../words/engine/operations/visual.ts | 9 +- .../soma/components/words/engine/render.ts | 7 + src/uix/soma/components/words/engine/types.ts | 14 + .../soma/components/words/engine/validate.ts | 42 ++ .../uix/components/css-field/+page.svelte | 83 +++- .../uix/components/number-field/+page.svelte | 149 +++++- 27 files changed, 1772 insertions(+), 359 deletions(-) create mode 100644 src/uix/eidos/components/palabras/palabras-spacing.svelte create mode 100644 src/uix/eidos/components/palabras/palabras-typography.svelte diff --git a/src/uix/eidos/THEMING.md b/src/uix/eidos/THEMING.md index 40a05d2ba..eb55447ca 100644 --- a/src/uix/eidos/THEMING.md +++ b/src/uix/eidos/THEMING.md @@ -61,6 +61,7 @@ 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) --- @@ -2463,6 +2464,33 @@ 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 + +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`). + +Cuatro tokens por componente, dos por layout: + +| 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í). + +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. + --- **Ú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 f31d9dfdb..80fc1ea6e 100644 --- a/src/uix/eidos/components/css-field/README.md +++ b/src/uix/eidos/components/css-field/README.md @@ -20,6 +20,12 @@ owned by Soma. | `size` | `ResponsiveProp<'xs' \| 'sm' \| 'md' \| 'lg' \| 'xl'>` | `'md'` | | `variant` | `'surface' \| 'outline' \| 'ghost'` (`ControlVariant`) | `'surface'` | | `color` | `ColorRole` (8 roles) | `'primary'` | +| `steppers` | `'split' \| 'stacked'` | `'split'` | + +`steppers` — `split` keeps the decrement/increment at the two ends (the default); +`stacked` collapses them into one compact column on the inline-end side (increment +on top, decrement on the bottom), which flips to the inline-start under RTL. The +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`, …). @@ -31,6 +37,12 @@ Tokens live under the `css-field` recipe key in `src/uix/eidos/lib/recipes/base. `number-field`. `[data-invalid]` tints the border with `--color-risk-border`; `[data-focused]` / `[data-scrubbing]` raise the focus ring. +**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` / +`-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. diff --git a/src/uix/eidos/components/css-field/css-field.css b/src/uix/eidos/components/css-field/css-field.css index 53ec2d073..e057959e1 100644 --- a/src/uix/eidos/components/css-field/css-field.css +++ b/src/uix/eidos/components/css-field/css-field.css @@ -156,6 +156,93 @@ 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], diff --git a/src/uix/eidos/components/css-field/css-field.svelte b/src/uix/eidos/components/css-field/css-field.svelte index 21d6a53b4..7e7f197a9 100644 --- a/src/uix/eidos/components/css-field/css-field.svelte +++ b/src/uix/eidos/components/css-field/css-field.svelte @@ -12,6 +12,7 @@ size = 'md', variant = 'surface', color = 'primary', + steppers = 'split', value = $bindable(), children, ...rest @@ -27,6 +28,7 @@ data-size={resolvedSize} data-variant={variant} data-color={color} + data-steppers={steppers} > {@render children?.()} diff --git a/src/uix/eidos/components/css-field/index.ts b/src/uix/eidos/components/css-field/index.ts index 9cc5b3d1d..84542386a 100644 --- a/src/uix/eidos/components/css-field/index.ts +++ b/src/uix/eidos/components/css-field/index.ts @@ -40,5 +40,6 @@ export type { CssFieldScrubberProps as ScrubberProps, CssFieldSize, CssFieldVariant, - CssFieldColor + CssFieldColor, + CssFieldSteppers } from './types'; diff --git a/src/uix/eidos/components/css-field/types.ts b/src/uix/eidos/components/css-field/types.ts index 479e6e4b1..5e688edda 100644 --- a/src/uix/eidos/components/css-field/types.ts +++ b/src/uix/eidos/components/css-field/types.ts @@ -13,6 +13,14 @@ export type CssFieldSize = Extract; export type CssFieldVariant = ControlVariant; export type CssFieldColor = ColorRole; +/** + * Stepper layout. + * - `split` — decrement and increment at the two ends, input between them. + * - `stacked` — both triggers in one compact column on the inline-end side + * (right in LTR, left in RTL): increment on top, decrement on the bottom. + */ +export type CssFieldSteppers = 'split' | 'stacked'; + /** * Props for the eidos ``. * @@ -27,6 +35,8 @@ export type CssFieldProps = ProviderProps & { variant?: CssFieldVariant; /** Accent palette for focus and scrubber active state. @default 'primary' */ color?: CssFieldColor; + /** Stepper layout: `split` ends or `stacked` compact column. @default 'split' */ + steppers?: CssFieldSteppers; }; export type CssFieldInputProps = InputProps; diff --git a/src/uix/eidos/components/number-field/README.md b/src/uix/eidos/components/number-field/README.md index dd599d7b3..71590cdf6 100644 --- a/src/uix/eidos/components/number-field/README.md +++ b/src/uix/eidos/components/number-field/README.md @@ -107,6 +107,26 @@ redondeado sin clamp en blur. No existe todavia `onValueInvalid`. Decision: suficiente para esta fase. `onValueInvalid` y validacion sin clamp se documentan como gap Soma futuro. +### Steppers (`split` | `stacked`) + +`steppers` es perilla eidos (no morfo/soma — Soma sólo modela el spinbutton). + +- **`split`** (default) — decrement pegado al borde inline-start, increment al + inline-end, input entre ambos. Los botones son cuadrados, FLUSH al borde del + field (margen negativo que cancela el padding inline) con divisor en su lado + interior; `order` + propiedades lógicas hacen que el arreglo se invierta solo + bajo RTL. +- **`stacked`** — ambos en una columna compacta en el lado inline-end (increment + arriba, decrement abajo), un grid 2×2 que se espeja bajo RTL. Cada flecha llena + media altura del field vía `align-items: stretch` — su altura es función de + `size` (xs..xl). Sin scrubber (no cabe). + +**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` +(split, `+`/`−`) y los hermanos `…-glyph-stacked` (`▲`/`▼`). Pasar children a un +trigger overridea el token para esa instancia. + ## Gaps | Gap | Disposición | Detalle | diff --git a/src/uix/eidos/components/number-field/index.ts b/src/uix/eidos/components/number-field/index.ts index a623c6a0a..a9a662891 100644 --- a/src/uix/eidos/components/number-field/index.ts +++ b/src/uix/eidos/components/number-field/index.ts @@ -39,5 +39,6 @@ export type { NumberFieldScrubberProps as ScrubberProps, NumberFieldSize, NumberFieldVariant, - NumberFieldColor + NumberFieldColor, + NumberFieldSteppers } from './types'; diff --git a/src/uix/eidos/components/number-field/number-field.css b/src/uix/eidos/components/number-field/number-field.css index c5eb1f2e6..6fb582167 100644 --- a/src/uix/eidos/components/number-field/number-field.css +++ b/src/uix/eidos/components/number-field/number-field.css @@ -6,7 +6,6 @@ display: inline-flex; align-items: center; - gap: var(--_number-field-gap); inline-size: 100%; min-inline-size: 0; block-size: var(--_number-field-height); @@ -14,6 +13,10 @@ 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); @@ -78,7 +81,11 @@ [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; @@ -88,6 +95,28 @@ 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; } @@ -141,6 +170,8 @@ [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); } @@ -157,6 +188,94 @@ 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], diff --git a/src/uix/eidos/components/number-field/number-field.svelte b/src/uix/eidos/components/number-field/number-field.svelte index 94ff0f537..a235c1aa0 100644 --- a/src/uix/eidos/components/number-field/number-field.svelte +++ b/src/uix/eidos/components/number-field/number-field.svelte @@ -12,6 +12,7 @@ size = 'md', variant = 'surface', color = 'primary', + steppers = 'split', value = $bindable(), children, ...rest @@ -27,6 +28,7 @@ data-size={resolvedSize} data-variant={variant} data-color={color} + data-steppers={steppers} > {@render children?.()} diff --git a/src/uix/eidos/components/number-field/types.ts b/src/uix/eidos/components/number-field/types.ts index 28a836f00..9d176eb31 100644 --- a/src/uix/eidos/components/number-field/types.ts +++ b/src/uix/eidos/components/number-field/types.ts @@ -12,6 +12,15 @@ export type NumberFieldSize = Extract; export type NumberFieldVariant = ControlVariant; export type NumberFieldColor = ColorRole; +/** + * Stepper layout. + * - `split` — decrement flush to the inline-start edge, increment flush to the + * inline-end edge, input between them (the classic stepper). + * - `stacked` — both triggers in one compact column on the inline-end side + * (right in LTR, left in RTL): increment on top, decrement on the bottom. + */ +export type NumberFieldSteppers = 'split' | 'stacked'; + /** * Props for the eidos ``. * @@ -25,6 +34,8 @@ export type NumberFieldProps = ProviderProps & { variant?: NumberFieldVariant; /** Accent palette for focus and scrubber active state. @default 'primary' */ color?: NumberFieldColor; + /** Stepper layout: `split` ends or `stacked` compact column. @default 'split' */ + steppers?: NumberFieldSteppers; }; export type NumberFieldInputProps = InputProps; diff --git a/src/uix/eidos/components/palabras/palabras-handle.svelte b/src/uix/eidos/components/palabras/palabras-handle.svelte index 11024bc98..fba75f9eb 100644 --- a/src/uix/eidos/components/palabras/palabras-handle.svelte +++ b/src/uix/eidos/components/palabras/palabras-handle.svelte @@ -1,31 +1,21 @@ {#if grip} - + - - - 1 ? '' : undefined} - data-words-external-tool - aria-label="Acciones del bloque" - draggable={grip.draggable} - onpointerdown={onTriggerPointerDown} - ondragstart={onDragStart} - > - {#snippet icon()}{/snippet} - Acciones del bloque - - - - - - - - Propiedades - - - - - - Insertar bloque - - - {#each insertTypes as bt (bt.id)} - {@const Icon = bt.icon} - doInsertType(bt)}> - - {bt.label} - - {/each} - - - - - Mover arriba - - = blockCount - 1} - > - - Mover abajo - - - - - Eliminar bloque - - {#if inColumn} - - - - Seleccionar columnas - - - - Eliminar columna - - {/if} - - - + + {/if} diff --git a/src/uix/eidos/components/palabras/palabras-panel.svelte b/src/uix/eidos/components/palabras/palabras-panel.svelte index c57b99c03..6440bdd66 100644 --- a/src/uix/eidos/components/palabras/palabras-panel.svelte +++ b/src/uix/eidos/components/palabras/palabras-panel.svelte @@ -11,20 +11,29 @@ * `setBlockVisualAtPath` (style, `field.visual`). The `textarea` field edits * the block's TEXT (its inline `children`). Controls are FRAMEWORK components. */ - import { sanitizeWordsUrl, type ProviderSnippetProps } from '$soma/components/words'; + import { + sanitizeWordsUrl, + type ProviderSnippetProps, + type WordsSpacing + } from '$soma/components/words'; import { CssField } from '$uix/eidos/components/css-field'; import { Switch } from '$uix/eidos/components/switch'; import { Slider } from '$uix/eidos/components/slider'; import { NumberField } from '$uix/eidos/components/number-field'; import { Tabs } from '$uix/eidos/components/tabs'; + import { Accordion } from '$uix/eidos/components/accordion'; import { TextArea } from '$uix/eidos/components/textarea'; import { Field } from '$uix/eidos/components/field'; import { Button } from '$uix/eidos/components/button'; - import { Trash2, Upload } from '$uix/eidos/components/icon'; - import { getPanelSchema } from './panel-schema'; - import { blockTypeIcon } from './palabras-block-types'; + import { DropdownMenu } from '$uix/eidos/components/dropdown-menu'; + import { Trash2, Upload, ArrowBigUp, ArrowBigDown, LayersPlus } from '$uix/eidos/components/icon'; + import { getPanelSchema, PALABRAS_FONTS } from './panel-schema'; + import { blockTypeIcon, insertableBlockTypes, blockToInsert } from './palabras-block-types'; + import type { PalabrasBlockType } from './palabras-block-types'; import PalabrasSegmented from './palabras-segmented.svelte'; import PalabrasColorRow from './palabras-color-row.svelte'; + import PalabrasSpacing from './palabras-spacing.svelte'; + import PalabrasTypography from './palabras-typography.svelte'; import { pickImageFile } from '$uix/eidos/components/words/words-image-file'; import { FloatPanel } from '$uix/eidos/components/float-panel'; import { deleteBlockAndReanchor } from './palabras-block-actions'; @@ -64,31 +73,30 @@ return content.querySelector(`[data-words-id="${CSS.escape(localId)}"]`) as HTMLElement | null; }); - // FloatPanel is a FREE window once open (drag + resize). FloatPanel seeds the - // position from `anchor` on the first open; when the active block CHANGES while - // the panel stays open, re-anchor next to the new block (right, centred on its - // mid-height, viewport-clamped) so the panel follows the selection. A drag - // within one block is preserved — we only reposition when the block id changes. - // Cleared on close so the next open re-seeds from the anchor. + // FloatPanel is a FREE window once open (drag + resize). We OWN its position: + // on open — and whenever the active block changes — we anchor it to the RIGHT of + // the block, centred on its mid-height, viewport-clamped. A drag within one block + // is preserved (we only reposition when the block id changes). + // + // NEVER clear `panelPos` while the panel is mounted. Setting it to `undefined` on + // close snapped the FloatPanel to 0,0 (left edge) for the whole exit animation — + // the panel visibly "redrew on the left" then faded. On close we only re-arm the + // anchoring (posBlock = null) and leave the last position so the close animates + // in place; the next open re-anchors from the block. const PANEL_W = 288; const PANEL_H = 380; let panelPos = $state<{ x: number; y: number } | undefined>(undefined); let posBlock: string | null = null; $effect(() => { if (!open) { - panelPos = undefined; - posBlock = null; + posBlock = null; // re-arm anchoring for the next open; keep panelPos (no close-jump) return; } const el = anchorEl; const win = content?.ownerDocument?.defaultView; const id = api.activeBlockId; if (!el || !win || !id) return; - if (posBlock === null) { - posBlock = id; // first open — FloatPanel's own anchor seed positions it - return; - } - if (id === posBlock) return; // same block — don't fight a drag + if (id === posBlock) return; // already anchored for this block — don't fight a drag posBlock = id; const r = el.getBoundingClientRect(); const gap = 16; @@ -102,10 +110,26 @@ // Three fixed tabs (Contenido open by default). Their fields come from the // block's schema; Diseño / Avanzado may be empty for now. const contenidoFields = $derived(schema?.contenido ?? []); - const disenoFields = $derived(schema?.diseño ?? []); + const disenoSections = $derived(schema?.diseño ?? []); const avanzadoFields = $derived(schema?.avanzado ?? []); let tab = $state('contenido'); + // Diseño accordion open state. Seeded from each section's `defaultCollapsed` + // (Disposición opens by default); re-seeded when the block TYPE changes so a new + // block gets its defaults, while preserving the user's open/close within a type. + let designOpen = $state([]); + let designSeedType: string | undefined; + $effect(() => { + const type = loc?.node?.type; + if (type === designSeedType) return; + designSeedType = type; + // Single-accordion: open ONLY the first non-collapsed section (Disposición). + designOpen = disenoSections + .filter((s) => !s.defaultCollapsed) + .slice(0, 1) + .map((s) => s.title); + }); + /** Plain text of the block's inline `children` — for the Texto textarea. */ function blockText(): string { const ch = (loc?.node as { children?: readonly unknown[] } | undefined)?.children; @@ -158,7 +182,10 @@ /** Write a field's value, routing by `field.visual`: a STYLE override goes to * `setBlockVisualAtPath` (the engine's STYLE_KEYS), a STRUCTURAL prop to * `updateBlockAtPath`. The single panel now hosts both kinds. */ - function commit(field: PalabrasFieldDef, value: string | number | boolean | undefined) { + function commit( + field: PalabrasFieldDef, + value: string | number | boolean | WordsSpacing | undefined + ) { const path = loc?.path; if (!path) return; if (field.visual) { @@ -167,9 +194,45 @@ api.applyCommand({ type: 'updateBlockAtPath', blockPath: path, patch: { [field.key]: value } }); } } + /** Apply a MULTI-key visual patch (the typography control writes several STYLE_KEYS + * at once). `undefined` values clear their prop back to the block default. */ + function commitVisualPatch(patch: Record) { + const path = loc?.path; + if (!path) return; + api.applyCommand({ type: 'setBlockVisualAtPath', blockPath: path, visual: patch }); + } + /** The active block's typographic visual subset — fed to . */ + function typoValue() { + const n = loc?.node as + | { + fontFamily?: string; + fontStyle?: 'normal' | 'italic' | 'oblique'; + strikethrough?: boolean; + fontSize?: number; + fontWeight?: number; + lineHeight?: number; + letterSpacing?: string; + wordSpacing?: string; + textWrap?: 'wrap' | 'nowrap' | 'balance' | 'pretty'; + maxWidth?: string; + tabularNumbers?: boolean; + } + | undefined; + return n ?? {}; + } function activeEl(): HTMLElement | null { return (content?.querySelector('[data-words-active]') as HTMLElement | null) ?? null; } + /** The active block's DEFAULT font-size (px) — its COMPUTED size, which (while + * `fontSize` isn't overridden) is the heading level's own size. Feeds the Tamaño + * control so it rests at the selected H's size instead of an empty `auto`. */ + function blockDefaultPx(): number | undefined { + void api.html; // re-read when the document (and the active block's level) changes + const el = activeEl(); + if (!el) return undefined; + const fs = parseFloat(getComputedStyle(el).fontSize); + return Number.isFinite(fs) ? Math.round(fs) : undefined; + } /** Display colour for a `color` field: the stored override hex, else the block's * COMPUTED colour (so the swatch reflects the effective value, like Apariencia did). */ function effectiveColor(field: PalabrasFieldDef): string | undefined { @@ -180,14 +243,30 @@ const cs = getComputedStyle(el); return field.key === 'background' ? cs.backgroundColor : cs.color; } + /** Active pill value (string). The custom pill (e.g. `HC`) wins when its + * `customWhen` prop is overridden on the block; otherwise the stored value. */ + function pillsActive(field: PalabrasFieldDef): string | undefined { + if (field.customOption != null && field.customWhen && loc?.node?.[field.customWhen] != null) { + return String(field.customOption); + } + const v = effective(field); + return v == null ? undefined : String(v); + } function onPills(field: PalabrasFieldDef, v: string | undefined) { // Deselecting (re-click the active item) clears the prop → restores the default. if (v == null) { commit(field, undefined); return; } + // The custom (HC) pill is an INDICATOR — picking it is a no-op. + if (field.customOption != null && v === String(field.customOption)) return; const opt = field.options?.find((o) => String(o.value) === v); - if (opt) commit(field, opt.value); + if (opt) { + commit(field, opt.value); + // Picking a REAL option clears the override that drives the custom pill + // (choosing H2 drops the size override → back to H2's own default size). + if (field.customWhen) commitVisualPatch({ [field.customWhen]: undefined }); + } } function onNumberChange(field: PalabrasFieldDef, v: number | undefined) { // clearWhenZero (width / height): empty or ≤0 clears the prop. Otherwise store @@ -241,16 +320,57 @@ // Close the panel on delete — the block it was inspecting is gone. onClose(); } + + // ── Footer actions (move / insert) — path-aware, the same the gutter grip used. + // They act on the ACTIVE block (`loc`), so the panel is now the single home for + // every per-block action (delete stays in the header). Index / count come from + // the active block element's siblings, matching how the grip computed them. + const sibInfo = $derived.by(() => { + const el = anchorEl; + if (!el?.parentElement) return { index: -1, count: 0 }; + const sibs = Array.from(el.parentElement.children).filter( + (c): c is HTMLElement => c instanceof HTMLElement && c.hasAttribute('data-words-id') + ); + return { index: sibs.indexOf(el), count: sibs.length }; + }); + const canMoveUp = $derived(sibInfo.index > 0); + const canMoveDown = $derived(sibInfo.index >= 0 && sibInfo.index < sibInfo.count - 1); + + function doMoveUp() { + if (!loc || !canMoveUp) return; + api.applyCommand({ type: 'moveBlockAtPath', blockPath: loc.path, direction: 'up' }); + } + function doMoveDown() { + if (!loc || !canMoveDown) return; + api.applyCommand({ type: 'moveBlockAtPath', blockPath: loc.path, direction: 'down' }); + } + + // Insertable types (columns are a top-level layout primitive — never offered when + // the active block is nested). Insert a fresh block as a sibling RIGHT AFTER the + // active one, then follow it (its id is assigned up front; the engine keeps it). + const insertTypes = $derived( + loc && loc.path.length > 1 + ? insertableBlockTypes().filter((t) => t.id !== 'columns') + : insertableBlockTypes() + ); + function doInsertType(bt: PalabrasBlockType) { + if (!loc) return; + const path = loc.path; + const blockPath = [...path.slice(0, -1), path[path.length - 1] + 1]; + const id = crypto.randomUUID(); + api.applyCommand({ type: 'insertBlockAtPath', blockPath, block: { ...blockToInsert(bt), id } }); + const parentSegs = (api.activeBlockId ?? '').split('/').slice(0, -1); + api.setActiveBlock([...parentSegs, id].join('/')); + } {#snippet fieldControl(field: PalabrasFieldDef)} {@const disabled = field.gatedBy ? !!valueOf(field.gatedBy) : false} {#if field.type === 'pills'} - {@const pv = effective(field)}
{field.label} {field.label}