feat(fields): spin-field layer composes Field via collapsed control identity (S6)

Converts the shared `spin-field.css` layer (NumberField + CssField) to the
collapsed text-field pattern. Both morfos add `fieldRole('provider')` +
`fieldRole('control')` to the spin-field box (data-field + data-field-control
alongside the existing data-spin-field marker) and `fieldRole('input')` to the
bare input; both eidos wrappers import field.css.

spin-field.css drops its ENTIRE parallel control chrome (border/radius/bg/height/
padding/font/focus/invalid/disabled/variant/readonly + all `--spin-field-*` chrome
tokens) — field.css `[data-field-control]` owns it now. It keeps ONLY the stepper
affordances (increment/decrement triggers, scrubber, split + stacked layouts,
themeable glyphs), re-sourced off field's OWN resolved control tokens
(`--_field-control-height` / `--_field-control-padding-inline` / `--_field-control-gap`)
so the steppers always match the input row — no parallel size axis. Two box overrides
survive at `[data-spin-field][data-field-control]` (0,2,0, beats field.css regardless
of the global-vs-per-mount load order): `overflow: hidden` (edge steppers break out
of the padding then clip to the corners) and `gap: 0` (steppers butt flush via dividers).

Baseline unchanged (59). Verified in browser: number-field + css-field paint from
field.css (36px/border/radius/overflow), steppers 36px flush at the edges with
dividers, split layout intact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
menubar-v4-safe
dev 3 months ago
parent 81b7d460c0
commit 43666f7ee4

@ -1,4 +1,9 @@
<script lang="ts">
// CssField IS-A Field control (collapsed onto the spin-field box): field.css owns the
// box chrome / input reset / focus / invalid / disabled; spin-field.css (global @import)
// adds the steppers/scrubber. `spin-field.css` does NOT pull `field.css`, so it must be
// imported here for the chrome to resolve.
import '../field/field.css';
import './css-field.css';
import { ActiveEidos } from '$uix/eidos';
/**

@ -1,4 +1,9 @@
<script lang="ts">
// NumberField IS-A Field control (collapsed onto the spin-field box): field.css owns
// the box chrome / input reset / focus / invalid / disabled; spin-field.css (global
// @import) adds the steppers/scrubber. `spin-field.css` does NOT pull `field.css`, so
// it must be imported here for the chrome to resolve.
import '../field/field.css';
import './number-field.css';
import { ActiveEidos } from '$uix/eidos';
/**

@ -1,137 +1,42 @@
/* ── 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-padding-inline: var(--spin-field-padding-inline-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-padding-inline);
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. */
/* ── spin-field — shared stepper affordances over a Field control ─────────────
NumberField and CssField ARE Field controls (their box collapses `data-field` +
`data-field-control`, so field.css owns the whole box CHROME — border / radius /
bg / height / padding / font / hover / focus / invalid / disabled / variant — and
the bare-input reset). This shared layer paints ONLY what a stepper field ADDS:
the increment/decrement triggers, the pointer scrubber, and the split/stacked
layouts. They size off field's OWN resolved control tokens
(`--_field-control-height` / `--_field-control-padding-inline` / `--_field-control-gap`,
declared per-size on the collapsed box) so they always match the input row —
never a parallel `--spin-field-*` chrome that would alias the Field base.
Loaded via the foundation `@import` in `index.css` (no `spin-field.svelte`);
each field's eidos wrapper imports `field.css` so the chrome resolves. The box
overrides use the compound `[data-spin-field][data-field-control]` (0,2,0) so
they beat field.css's `[data-field-control]` (0,1,0) regardless of load order. */
/* Two spin-only box overrides: `overflow: hidden` (the edge steppers break OUT of
the control's inline padding via negative margin, then clip to the rounded
corners) and `gap: 0` (steppers butt flush against the input via their dividers;
the input carries the text spacing instead of a flex gap). */
[data-spin-field][data-field-control] {
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-padding-inline: var(--spin-field-padding-inline-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-padding-inline: var(--spin-field-padding-inline-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-padding-inline: var(--spin-field-padding-inline-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-padding-inline: var(--spin-field-padding-inline-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);
/* Focus ring as a flush outline (no offset → no gap) — survives forced-colors. */
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-spin-field][data-invalid] {
border-color: var(--spin-field-border-invalid);
}
/* Read-only — value visible, field reads non-editable (muted surface, no
text cursor), mirroring the date-field control. Steppers/scrubber keep
rendering; soma already ignores their input. */
[data-spin-field][data-readonly] {
background: var(--color-surface-muted);
}
[data-spin-field][data-readonly] [data-spin-field-input] {
cursor: default;
}
[data-spin-field][data-disabled] {
opacity: var(--spin-field-disabled-opacity);
gap: 0;
}
[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;
font-weight: var(--font-weight-medium);
outline: none;
transition: box-shadow var(--spin-field-transition-duration)
var(--spin-field-transition-ease);
/* Keep the text off the stepper dividers (the box gap is 0). */
padding-inline: var(--_field-control-gap);
}
/* 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. */
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 cancels the field's inline padding so they reach the border; the
divider sits on each trigger's INNER side (decrement → end, increment → start). */
[data-spin-field]:not([data-steppers='stacked']) [data-spin-field-decrement-trigger] {
order: 0;
margin-inline-start: calc(-1 * var(--_spin-field-padding-inline));
margin-inline-start: calc(-1 * var(--_field-control-padding-inline));
border-inline-end: var(--spin-field-control-border-width) solid
var(--spin-field-control-divider);
}
@ -140,19 +45,11 @@
}
[data-spin-field]:not([data-steppers='stacked']) [data-spin-field-increment-trigger] {
order: 3;
margin-inline-end: calc(-1 * var(--_spin-field-padding-inline));
margin-inline-end: calc(-1 * var(--_field-control-padding-inline));
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] {
@ -160,11 +57,11 @@
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);
/* Stepper buttons + scrubber take the field's resolved control height so they
always match the input row, at every size. */
inline-size: var(--_field-control-height);
block-size: var(--_field-control-height);
min-inline-size: var(--_field-control-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);
@ -242,50 +139,44 @@
}
/* ── 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'] {
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. */
[data-spin-field][data-steppers='stacked'][data-field-control] {
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. */
/* `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 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 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`. */
/* Grid default (field.css control centers for its flex row); stretch so each
arrow fills its half-height row, 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. */
/* The stepper column reaches the edge itself, so the box 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-padding-inline);
padding-inline: var(--_field-control-padding-inline);
}
[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);
inline-size: calc(var(--_field-control-height) * 0.62);
min-inline-size: calc(var(--_field-control-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);
border-inline-start: var(--spin-field-control-border-width) solid var(--spin-field-border);
/* Glyph scales with the field height (≈ a third), so it stays inside the
half-height cell at every size. */
font-size: calc(var(--_field-control-height) * 0.34);
line-height: 1;
}
@ -296,8 +187,7 @@
[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);
border-block-start: var(--spin-field-control-border-width) solid var(--spin-field-border);
}
/* No room for the pointer scrubber in the stacked layout. */
@ -306,7 +196,6 @@
}
@media (prefers-reduced-motion: reduce) {
[data-spin-field],
[data-spin-field-increment-trigger],
[data-spin-field-decrement-trigger],
[data-spin-field-scrubber] {

@ -1,5 +1,6 @@
import type { Morfo } from '../types';
import { v } from '../types';
import { fieldRole } from '../field-role';
export const cssFieldMorfo = {
name: 'CssField',
@ -71,9 +72,12 @@ 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).
// Structural identity: CssField IS a spin-field (shared stepper visual) AND
// IS-A Field control — one box collapses both Field roles (data-field +
// data-field-control) so field.css owns the box chrome, while spin-field.css
// adds the steppers/scrubber on top.
fieldRole('provider'),
fieldRole('control'),
{ 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' },
@ -93,6 +97,8 @@ export const cssFieldMorfo = {
role: 'spinbutton',
optional: false,
data: [
// Bare input: spin-field marker + Field input identity (field.css resets it).
fieldRole('input'),
{ attr: 'data-spin-field-input', value: v.literal(''), severity: 'required' },
{ attr: 'data-disabled', severity: 'optional' },
{ attr: 'data-readonly', severity: 'optional' },

@ -1,5 +1,6 @@
import type { Morfo } from '../types';
import { v } from '../types';
import { fieldRole } from '../field-role';
export const numberFieldMorfo = {
name: 'NumberField',
@ -51,9 +52,12 @@ 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).
// Structural identity: NumberField IS a spin-field (shared stepper visual)
// AND IS-A Field control — one box collapses both Field roles (data-field +
// data-field-control) so field.css owns the box chrome, while spin-field.css
// adds the steppers/scrubber on top.
fieldRole('provider'),
fieldRole('control'),
{ 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' },
@ -73,6 +77,8 @@ export const numberFieldMorfo = {
role: 'spinbutton',
optional: false,
data: [
// Bare input: spin-field marker + Field input identity (field.css resets it).
fieldRole('input'),
{ attr: 'data-spin-field-input', value: v.literal(''), severity: 'required' },
{ attr: 'data-disabled', severity: 'optional' },
{ attr: 'data-readonly', severity: 'optional' },

Loading…
Cancel
Save

Powered by TurnKey Linux.