diff --git a/src/arts/sium/index.ts b/src/arts/sium/index.ts index abb5bc2d6..45e4b08c3 100644 --- a/src/arts/sium/index.ts +++ b/src/arts/sium/index.ts @@ -116,7 +116,25 @@ export { saturation, lightness, brightness, - alpha + alpha, + cssValue, + parseCssValue, + formatCssValue, + isCssValue, + CSS_UNITS, + CSS_LENGTH_UNITS, + CSS_ANGLE_UNITS, + CSS_TIME_UNITS, + CSS_RESOLUTION_UNITS, + CSS_FREQUENCY_UNITS, + CSS_FLEX_UNITS +} from './types'; +export type { + CssUnit, + CssValueParts, + CssDimension, + CssKeyword, + CssValueOptions } from './types'; // langs diff --git a/src/arts/sium/langs/codes.ts b/src/arts/sium/langs/codes.ts index 9e2dc3b6b..a80c97979 100644 --- a/src/arts/sium/langs/codes.ts +++ b/src/arts/sium/langs/codes.ts @@ -22,6 +22,7 @@ export const ISSUE_CODES = [ 'email', 'url', 'integer', + 'css_value', 'custom', 'range_order', // combinators diff --git a/src/arts/sium/langs/langs.ts b/src/arts/sium/langs/langs.ts index d4845b574..16d80fcf1 100644 --- a/src/arts/sium/langs/langs.ts +++ b/src/arts/sium/langs/langs.ts @@ -102,6 +102,10 @@ export const siumLangs = { es: 'Debe ser un entero (se recibió {{actual}})', en: 'Must be an integer (received {{actual}})' }, + css_value: { + es: 'Debe ser un valor CSS válido ({{units}})', + en: 'Must be a valid CSS value ({{units}})' + }, custom: { es: 'Valor no válido', en: 'Invalid value' diff --git a/src/arts/sium/test/css.test.ts b/src/arts/sium/test/css.test.ts new file mode 100644 index 000000000..4cab1901a --- /dev/null +++ b/src/arts/sium/test/css.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest'; +import { pipe, string } from '../core'; +import { + cssValue, + parseCssValue, + formatCssValue, + isCssValue, + CSS_UNITS +} from '../types/css'; + +describe('parseCssValue()', () => { + it('parses a px dimension', () => { + expect(parseCssValue('12px')).toEqual({ kind: 'dimension', value: 12, unit: 'px' }); + }); + + it('parses decimals and leading-dot', () => { + expect(parseCssValue('1.5rem')).toEqual({ kind: 'dimension', value: 1.5, unit: 'rem' }); + expect(parseCssValue('.5em')).toEqual({ kind: 'dimension', value: 0.5, unit: 'em' }); + }); + + it('parses percentage', () => { + expect(parseCssValue('50%')).toEqual({ kind: 'dimension', value: 50, unit: '%' }); + }); + + it('trims surrounding whitespace', () => { + expect(parseCssValue(' 24px ')).toEqual({ kind: 'dimension', value: 24, unit: 'px' }); + }); + + it('matches units case-insensitively and canonicalizes', () => { + expect(parseCssValue('12PX')).toEqual({ kind: 'dimension', value: 12, unit: 'px' }); + }); + + it('accepts a bare zero as unitless', () => { + expect(parseCssValue('0')).toEqual({ kind: 'dimension', value: 0, unit: '' }); + }); + + it('rejects a bare non-zero number (unit required)', () => { + expect(parseCssValue('12')).toBeNull(); + }); + + it('rejects negatives by default, accepts with allowNegative', () => { + expect(parseCssValue('-8px')).toBeNull(); + expect(parseCssValue('-8px', { allowNegative: true })).toEqual({ + kind: 'dimension', + value: -8, + unit: 'px' + }); + }); + + it('rejects a unit outside the whitelist', () => { + expect(parseCssValue('50%', { units: ['px', 'rem'] })).toBeNull(); + expect(parseCssValue('12px', { units: ['px', 'rem'] })).toEqual({ + kind: 'dimension', + value: 12, + unit: 'px' + }); + }); + + it('parses opt-in keywords case-insensitively', () => { + expect(parseCssValue('auto', { keywords: ['auto'] })).toEqual({ + kind: 'keyword', + keyword: 'auto' + }); + expect(parseCssValue('AUTO', { keywords: ['auto'] })).toEqual({ + kind: 'keyword', + keyword: 'auto' + }); + }); + + it('rejects keywords that were not opted in', () => { + expect(parseCssValue('auto')).toBeNull(); + }); + + it('rejects malformed input', () => { + expect(parseCssValue('abc')).toBeNull(); + expect(parseCssValue('12.px')).toBeNull(); + expect(parseCssValue('')).toBeNull(); + expect(parseCssValue(42)).toBeNull(); + }); +}); + +describe('formatCssValue()', () => { + it('serializes a dimension', () => { + expect(formatCssValue({ kind: 'dimension', value: 13, unit: 'px' })).toBe('13px'); + expect(formatCssValue({ kind: 'dimension', value: 1.5, unit: 'rem' })).toBe('1.5rem'); + }); + + it('serializes a keyword', () => { + expect(formatCssValue({ kind: 'keyword', keyword: 'auto' })).toBe('auto'); + }); + + it('round-trips parse → format for the standard units', () => { + for (const sample of ['12px', '1.5rem', '50%', '100vh', '2ch']) { + const parsed = parseCssValue(sample); + expect(parsed).not.toBeNull(); + expect(formatCssValue(parsed!)).toBe(sample); + } + }); +}); + +describe('isCssValue()', () => { + it('mirrors parseCssValue success', () => { + expect(isCssValue('12px')).toBe(true); + expect(isCssValue('12')).toBe(false); + expect(isCssValue('50%', { units: ['px'] })).toBe(false); + }); + + it('exposes the full unit vocabulary', () => { + expect(CSS_UNITS).toContain('px'); + expect(CSS_UNITS).toContain('rem'); + expect(CSS_UNITS).toContain('%'); + expect(CSS_UNITS).toContain('vh'); + expect(CSS_UNITS).toContain('fr'); + }); +}); + +describe('cssValue() refine', () => { + it('passes a valid value through', () => { + const schema = pipe(string(), cssValue({ units: ['px', 'rem'] })); + expect(schema.decodeSync('12px')).toBe('12px'); + }); + + it('rejects a disallowed unit with the css_value code', async () => { + const schema = pipe(string(), cssValue({ units: ['px', 'rem'] })); + const result = await schema.validate('50%'); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.issues[0].code).toBe('css_value'); + expect(result.issues[0].message).toBe( + '#?sium.errors.css_value|Must be a valid CSS value ({{units}})' + ); + } + }); + + it('honors allowNegative', async () => { + const schema = pipe(string(), cssValue({ units: ['px'], allowNegative: true })); + expect(schema.decodeSync('-8px')).toBe('-8px'); + }); + + it('honors keywords', () => { + const schema = pipe(string(), cssValue({ units: ['px'], keywords: ['auto'] })); + expect(schema.decodeSync('auto')).toBe('auto'); + }); +}); diff --git a/src/arts/sium/test/langs.test.ts b/src/arts/sium/test/langs.test.ts index 075bc0ed1..ecd4493b2 100644 --- a/src/arts/sium/test/langs.test.ts +++ b/src/arts/sium/test/langs.test.ts @@ -18,6 +18,7 @@ describe('ISSUE_CODES', () => { 'email', 'url', 'integer', + 'css_value', 'custom', 'range_order', 'unknown_keys', @@ -26,8 +27,8 @@ describe('ISSUE_CODES', () => { 'discriminated_unknown_value' ] as const; - it('contains exactly the 18 codes in declaration order, no duplicates', () => { - expect(ISSUE_CODES).toHaveLength(18); + it('contains exactly the 19 codes in declaration order, no duplicates', () => { + expect(ISSUE_CODES).toHaveLength(19); expect([...ISSUE_CODES]).toEqual(expectedCodes); expect(new Set(ISSUE_CODES).size).toBe(ISSUE_CODES.length); }); @@ -47,6 +48,7 @@ describe('ISSUE_CODES', () => { | 'email' | 'url' | 'integer' + | 'css_value' | 'custom' | 'range_order' | 'unknown_keys' @@ -61,9 +63,9 @@ describe('ISSUE_CODES', () => { describe('siumLangs schema', () => { const errors = siumLangs.errors; - it('has exactly 18 keys — one per IssueCode', () => { + it('has exactly 19 keys — one per IssueCode', () => { const keys = Object.keys(errors); - expect(keys).toHaveLength(18); + expect(keys).toHaveLength(19); for (const code of ISSUE_CODES) { expect(errors).toHaveProperty(code); } diff --git a/src/arts/sium/types/css.ts b/src/arts/sium/types/css.ts new file mode 100644 index 000000000..b9708d496 --- /dev/null +++ b/src/arts/sium/types/css.ts @@ -0,0 +1,195 @@ +import { refine } from '../core'; +import type { Step } from '../core'; + +/** + * CSS value validation + parsing. + * + * Richer counterpart of the non-negative `cssLength()` refine in + * `core/refines.ts`: this module knows the full CSS unit vocabulary, parses a + * value string into its numeric + unit parts (needed to increment/decrement a + * value while preserving its unit), supports a configurable unit whitelist, + * opt-in keywords (`auto`, `inherit`, …) and optional negative values. + * + * The pure helpers (`parseCssValue`, `formatCssValue`, `isCssValue`) carry no + * sium dependency so any consumer can reuse them; `cssValue()` wraps them as a + * Standard-Schema refine. + */ + +// ── Unit vocabulary ────────────────────────────────────────────────────────── + +/** Absolute + relative + viewport + container length units, plus `%`. */ +export const CSS_LENGTH_UNITS = [ + 'px', + 'rem', + 'em', + '%', + 'ch', + 'ex', + 'cap', + 'ic', + 'lh', + 'rlh', + 'vw', + 'vh', + 'vmin', + 'vmax', + 'vi', + 'vb', + 'svw', + 'svh', + 'lvw', + 'lvh', + 'dvw', + 'dvh', + 'cqw', + 'cqh', + 'cqi', + 'cqb', + 'cqmin', + 'cqmax', + 'cm', + 'mm', + 'in', + 'pt', + 'pc', + 'q' +] as const; + +/** Angle units (gradients, transforms). */ +export const CSS_ANGLE_UNITS = ['deg', 'grad', 'rad', 'turn'] as const; + +/** Time units (transitions, animations). */ +export const CSS_TIME_UNITS = ['s', 'ms'] as const; + +/** Resolution units (media queries, image-set). */ +export const CSS_RESOLUTION_UNITS = ['dpi', 'dpcm', 'dppx', 'x'] as const; + +/** Frequency units. */ +export const CSS_FREQUENCY_UNITS = ['hz', 'khz'] as const; + +/** Flexible length unit (grid). */ +export const CSS_FLEX_UNITS = ['fr'] as const; + +/** The full canonical CSS unit set. */ +export const CSS_UNITS = [ + ...CSS_LENGTH_UNITS, + ...CSS_ANGLE_UNITS, + ...CSS_TIME_UNITS, + ...CSS_RESOLUTION_UNITS, + ...CSS_FREQUENCY_UNITS, + ...CSS_FLEX_UNITS +] as const; + +export type CssUnit = (typeof CSS_UNITS)[number]; + +// ── Parsed shape ───────────────────────────────────────────────────────────── + +/** A numeric CSS value with its unit (unit `''` only for a bare `0`). */ +export interface CssDimension { + kind: 'dimension'; + value: number; + unit: string; +} + +/** A keyword CSS value (`auto`, `inherit`, …). */ +export interface CssKeyword { + kind: 'keyword'; + keyword: string; +} + +export type CssValueParts = CssDimension | CssKeyword; + +export interface CssValueOptions { + /** Allowed units (whitelist). Defaults to every CSS unit. */ + units?: readonly string[]; + /** Allowed bare keywords (e.g. `['auto', 'inherit']`). Default: none. */ + keywords?: readonly string[]; + /** Allow a leading minus sign (e.g. `-8px`). Default: `false`. */ + allowNegative?: boolean; +} + +// Leading number: optional sign, integer/decimal/leading-dot, optional exponent. +const LEADING_NUMBER_REGEX = /^[+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:e[+-]?\d+)?/i; + +/** + * Parses a CSS value string into its numeric + unit parts (or a keyword). + * Returns `null` when the input is not a valid CSS value under `opts`. + * + * Strict by design: + * - A non-zero number with no unit is rejected (`'12'` → `null`); only a bare + * `'0'` is a valid unitless length. + * - A unit outside `opts.units` is rejected. Parse against `CSS_UNITS` first to + * distinguish "well-formed but disallowed unit" from "malformed". + * + * @example + * parseCssValue('12px') // { kind:'dimension', value:12, unit:'px' } + * parseCssValue('1.5rem') // { kind:'dimension', value:1.5, unit:'rem' } + * parseCssValue('-8px', { allowNegative:true}) // { kind:'dimension', value:-8, unit:'px' } + * parseCssValue('auto', { keywords:['auto'] }) // { kind:'keyword', keyword:'auto' } + * parseCssValue('12', ) // null (bare non-zero number) + * parseCssValue('50%', { units:['px'] }) // null (unit not allowed) + */ +export function parseCssValue(input: unknown, opts: CssValueOptions = {}): CssValueParts | null { + if (typeof input !== 'string') return null; + const raw = input.trim(); + if (raw === '') return null; + + const allowedUnits = opts.units ?? CSS_UNITS; + const keywords = opts.keywords ?? []; + const allowNegative = opts.allowNegative ?? false; + + // Keyword match (case-insensitive). Keywords never carry a number. + const lowered = raw.toLowerCase(); + const keyword = keywords.find((k) => k.toLowerCase() === lowered); + if (keyword) return { kind: 'keyword', keyword }; + + const match = LEADING_NUMBER_REGEX.exec(raw); + if (!match) return null; + + const numText = match[0]; + const value = Number(numText); + if (!Number.isFinite(value)) return null; + if (!allowNegative && value < 0) return null; + + const unit = raw.slice(numText.length).trim(); + + if (unit === '') { + // Only a bare zero is a valid unitless length. + return value === 0 ? { kind: 'dimension', value: 0, unit: '' } : null; + } + + const canonicalUnit = allowedUnits.find((u) => u.toLowerCase() === unit.toLowerCase()); + if (canonicalUnit === undefined) return null; + + return { kind: 'dimension', value, unit: canonicalUnit }; +} + +/** Serializes parsed parts back to a canonical CSS string (`{value}{unit}`). */ +export function formatCssValue(parts: CssValueParts): string { + if (parts.kind === 'keyword') return parts.keyword; + return `${parts.value}${parts.unit}`; +} + +/** Sync predicate — true when `input` is a valid CSS value under `opts`. */ +export function isCssValue(input: unknown, opts: CssValueOptions = {}): boolean { + return parseCssValue(input, opts) !== null; +} + +/** + * Creates a CSS-value string refine. Compose as `pipe(string(), cssValue(opts))`. + * Supports a configurable unit whitelist, opt-in keywords and negatives. + * + * @example + * ```ts + * const schema = pipe(string(), cssValue({ units: ['px', 'rem'], allowNegative: true })); + * ``` + */ +export function cssValue(opts: CssValueOptions = {}): Step { + const units = (opts.units ?? CSS_UNITS).join(', '); + const keywords = (opts.keywords ?? []).join(', '); + return refine((value) => isCssValue(value, opts), { + code: 'css_value', + message: '#?sium.errors.css_value|Must be a valid CSS value ({{units}})', + params: { units, keywords } + }); +} diff --git a/src/arts/sium/types/index.ts b/src/arts/sium/types/index.ts index ab10240e8..c046e8e81 100644 --- a/src/arts/sium/types/index.ts +++ b/src/arts/sium/types/index.ts @@ -11,3 +11,17 @@ export { brightness, alpha } from './color'; +export { + cssValue, + parseCssValue, + formatCssValue, + isCssValue, + CSS_UNITS, + CSS_LENGTH_UNITS, + CSS_ANGLE_UNITS, + CSS_TIME_UNITS, + CSS_RESOLUTION_UNITS, + CSS_FREQUENCY_UNITS, + CSS_FLEX_UNITS +} from './css'; +export type { CssUnit, CssValueParts, CssDimension, CssKeyword, CssValueOptions } from './css'; diff --git a/src/uix/eidos/components/css-field/README.md b/src/uix/eidos/components/css-field/README.md new file mode 100644 index 000000000..51c043972 --- /dev/null +++ b/src/uix/eidos/components/css-field/README.md @@ -0,0 +1,53 @@ +# CssField (eidos) + +Visual wrapper over Soma's `css-field` spinbutton. Adds only chrome — `size`, +`variant`, `color` — and projects them as `data-*` for the recipe. Parsing, +validation, the unit whitelist, scrubbing, keyboard and `Field` integration are +owned by Soma. + +```svelte + + − + + + + +``` + +## Props (visual) + +| Prop | Type | Default | +| --- | --- | --- | +| `size` | `ResponsiveProp<'xs' \| 'sm' \| 'md' \| 'lg' \| 'xl'>` | `'md'` | +| `variant` | `'surface' \| 'outline' \| 'ghost'` (`ControlVariant`) | `'surface'` | +| `color` | `ColorRole` (8 roles) | `'primary'` | + +All Soma `Provider` props pass through (`value`, `allowedUnits`, `keywords`, `validate`, `min`/`max`, `step`, …). + +## Recipe + +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. + +## Baseline + +No Air baseline — new component. Recipe cloned from `number-field`'s with token renames. + +## Comparativa + +See `src/uix/soma/components/css-field/README.md` for the cross-library feature table. +Visually it is a single-row control matching the input family (DateField / NumberField / +ColorField): border + control buttons + optional scrubber, 5 sizes × 3 variants × 8 colors. + +## Decisiones + +- **Subset**: `size` `xs..xl` (text-input family), `variant` = `ControlVariant`, `color` = 8 roles. +- **Fixed control row height** — triggers + scrubber inherit the resolved input height. +- No component-specific visual state beyond the Soma data-attrs (`data-invalid`, `data-focused`, `data-scrubbing`). + +## Gaps + +- A units-suffix affordance (showing the unit separately) is not provided — the unit lives + inside the input text. _diferir_. diff --git a/src/uix/eidos/components/css-field/css-field-decrement-trigger.svelte b/src/uix/eidos/components/css-field/css-field-decrement-trigger.svelte new file mode 100644 index 000000000..166a7f01c --- /dev/null +++ b/src/uix/eidos/components/css-field/css-field-decrement-trigger.svelte @@ -0,0 +1,10 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/css-field/css-field-increment-trigger.svelte b/src/uix/eidos/components/css-field/css-field-increment-trigger.svelte new file mode 100644 index 000000000..320f9dfc1 --- /dev/null +++ b/src/uix/eidos/components/css-field/css-field-increment-trigger.svelte @@ -0,0 +1,10 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/css-field/css-field-input.svelte b/src/uix/eidos/components/css-field/css-field-input.svelte new file mode 100644 index 000000000..4685afc1e --- /dev/null +++ b/src/uix/eidos/components/css-field/css-field-input.svelte @@ -0,0 +1,10 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/css-field/css-field-scrubber.svelte b/src/uix/eidos/components/css-field/css-field-scrubber.svelte new file mode 100644 index 000000000..581e3599e --- /dev/null +++ b/src/uix/eidos/components/css-field/css-field-scrubber.svelte @@ -0,0 +1,10 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/css-field/css-field.css b/src/uix/eidos/components/css-field/css-field.css new file mode 100644 index 000000000..f5548c732 --- /dev/null +++ b/src/uix/eidos/components/css-field/css-field.css @@ -0,0 +1,214 @@ +[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); + --_css-field-accent-border: var(--color-primary-border); + --_css-field-accent-track: var(--color-primary-track); + --_css-field-accent-text: var(--color-primary-text); + + 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-color='secondary'] { + --_css-field-accent-border: var(--color-secondary-border); + --_css-field-accent-track: var(--color-secondary-track); + --_css-field-accent-text: var(--color-secondary-text); +} + +[data-css-field][data-color='neutral'] { + --_css-field-accent-border: var(--color-neutral-border); + --_css-field-accent-track: var(--color-neutral-track); + --_css-field-accent-text: var(--color-neutral-text); +} + +[data-css-field][data-color='affirm'] { + --_css-field-accent-border: var(--color-affirm-border); + --_css-field-accent-track: var(--color-affirm-track); + --_css-field-accent-text: var(--color-affirm-text); +} + +[data-css-field][data-color='fulfill'] { + --_css-field-accent-border: var(--color-fulfill-border); + --_css-field-accent-track: var(--color-fulfill-track); + --_css-field-accent-text: var(--color-fulfill-text); +} + +[data-css-field][data-color='risk'] { + --_css-field-accent-border: var(--color-risk-border); + --_css-field-accent-track: var(--color-risk-track); + --_css-field-accent-text: var(--color-risk-text); +} + +[data-css-field][data-color='threat'] { + --_css-field-accent-border: var(--color-threat-border); + --_css-field-accent-track: var(--color-threat-track); + --_css-field-accent-text: var(--color-threat-text); +} + +[data-css-field][data-color='loss'] { + --_css-field-accent-border: var(--color-loss-border); + --_css-field-accent-track: var(--color-loss-track); + --_css-field-accent-text: var(--color-loss-text); +} + +[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; +} + +/* Explicit keyboard focus ring on the input itself. Ensures clear focus + feedback even before the Provider's [data-focused] attribute is applied. */ +[data-css-field-input]:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--_css-field-accent-border); +} + +[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); +} + +@media (prefers-reduced-motion: reduce) { + [data-css-field], + [data-css-field-increment-trigger], + [data-css-field-decrement-trigger], + [data-css-field-scrubber] { + transition: none; + } +} diff --git a/src/uix/eidos/components/css-field/css-field.svelte b/src/uix/eidos/components/css-field/css-field.svelte new file mode 100644 index 000000000..21d6a53b4 --- /dev/null +++ b/src/uix/eidos/components/css-field/css-field.svelte @@ -0,0 +1,32 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/css-field/index.ts b/src/uix/eidos/components/css-field/index.ts new file mode 100644 index 000000000..9cc5b3d1d --- /dev/null +++ b/src/uix/eidos/components/css-field/index.ts @@ -0,0 +1,44 @@ +// CssField — eidos compound API, disciplined option C. +// +// import { CssField } from '$uix/eidos/components/css-field'; +// +// +// - +// +// + +// +import CssFieldComponent from './css-field.svelte'; +import Input from './css-field-input.svelte'; +import IncrementTrigger from './css-field-increment-trigger.svelte'; +import DecrementTrigger from './css-field-decrement-trigger.svelte'; +import Scrubber from './css-field-scrubber.svelte'; + +type CssFieldNamespace = typeof CssFieldComponent & { + Input: typeof Input; + IncrementTrigger: typeof IncrementTrigger; + DecrementTrigger: typeof DecrementTrigger; + Scrubber: typeof Scrubber; +}; + +const CssField = CssFieldComponent as CssFieldNamespace; +CssField.Input = Input; +CssField.IncrementTrigger = IncrementTrigger; +CssField.DecrementTrigger = DecrementTrigger; +CssField.Scrubber = Scrubber; + +export { CssField }; + +export default CssField; + +export type { + CssUnit, + CssFieldValidate, + CssFieldProps, + CssFieldInputProps as InputProps, + CssFieldIncrementTriggerProps as IncrementTriggerProps, + CssFieldDecrementTriggerProps as DecrementTriggerProps, + CssFieldScrubberProps as ScrubberProps, + CssFieldSize, + CssFieldVariant, + CssFieldColor +} from './types'; diff --git a/src/uix/eidos/components/css-field/types.ts b/src/uix/eidos/components/css-field/types.ts new file mode 100644 index 000000000..479e6e4b1 --- /dev/null +++ b/src/uix/eidos/components/css-field/types.ts @@ -0,0 +1,36 @@ +import type { + CssUnit, + CssFieldValidate, + DecrementTriggerProps, + IncrementTriggerProps, + InputProps, + ProviderProps, + ScrubberProps +} from '$soma/components/css-field'; +import type { ColorRole, ControlVariant, ResponsiveProp, Size } from '$uix/eidos/lib/types'; + +export type CssFieldSize = Extract; +export type CssFieldVariant = ControlVariant; +export type CssFieldColor = ColorRole; + +/** + * Props for the eidos ``. + * + * Extends Soma's CSS-value spinbutton with visual sizing only. Parsing, + * validation, unit whitelist, scrubbing, keyboard interaction and field + * integration are owned by Soma. + */ +export type CssFieldProps = ProviderProps & { + /** Visual size. Accepts a responsive map resolved by ActiveEidos. @default 'md' */ + size?: ResponsiveProp; + /** Surface treatment for the input control. @default 'surface' */ + variant?: CssFieldVariant; + /** Accent palette for focus and scrubber active state. @default 'primary' */ + color?: CssFieldColor; +}; + +export type CssFieldInputProps = InputProps; +export type CssFieldIncrementTriggerProps = IncrementTriggerProps; +export type CssFieldDecrementTriggerProps = DecrementTriggerProps; +export type CssFieldScrubberProps = ScrubberProps; +export type { CssUnit, CssFieldValidate }; diff --git a/src/uix/eidos/generated/base.css b/src/uix/eidos/generated/base.css index af8fb871d..b83c12e8b 100644 --- a/src/uix/eidos/generated/base.css +++ b/src/uix/eidos/generated/base.css @@ -1262,6 +1262,52 @@ --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: 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-focus-shadow: 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; --date-field-stack-gap: var(--space-1-5); --date-field-height-xs: var(--control-height-xs); --date-field-height-sm: var(--control-height-sm); diff --git a/src/uix/eidos/lib/recipes/base.ts b/src/uix/eidos/lib/recipes/base.ts index 57d067483..afe232b9a 100644 --- a/src/uix/eidos/lib/recipes/base.ts +++ b/src/uix/eidos/lib/recipes/base.ts @@ -1092,6 +1092,56 @@ 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': + '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)', + 'control-focus-shadow': + '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' + }, 'date-field': { 'stack-gap': 'var(--space-1-5)', 'height-xs': 'var(--control-height-xs)', diff --git a/src/uix/morfo/components/css-field.ts b/src/uix/morfo/components/css-field.ts new file mode 100644 index 000000000..2b7097dda --- /dev/null +++ b/src/uix/morfo/components/css-field.ts @@ -0,0 +1,178 @@ +import type { Morfo } from '../types'; +import { v } from '../types'; + +export const cssFieldMorfo = { + name: 'CssField', + kebab: 'css-field', + scope: ['soma', 'sema'], + texts: { + label: '#?components.css-field.label|CSS Value', + invalid: '#?components.css-field.invalid|Invalid CSS value' + }, + apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/spinbutton/', + events: [ + { + name: 'commit-set', + semantic: { + family: 'commit', + verb: 'set', + target: v.partRef('provider'), + intent: 'neutral', + sequence: 'post' + } + }, + { + name: 'handle-pick', + semantic: { + family: 'handle', + verb: 'pick', + target: v.partRef('scrubber'), + sequence: 'pre', + channels: ['sound', 'haptic'] + } + }, + { + name: 'handle-drag-scrub', + semantic: { + family: 'handle', + verb: 'drag', + target: v.partRef('scrubber'), + sequence: 'coincident', + channels: ['haptic'] + } + }, + { + // Book §6.2 — signal.risk = untilFix. Typing a value whose unit is not + // in `values` (or otherwise unparseable) keeps the warning visible until + // the user fixes the input. Provider clears via `runtime.clearTarget(input)` + // before each commit/blur validation pass — the next valid value is the fix. + name: 'signal-warn-invalid', + semantic: { + family: 'signal', + verb: 'warn', + target: v.partRef('input'), + intent: 'risk', + sequence: 'post', + persistence: 'untilFix' + }, + a11ySemantic: { + requiresPersistentTrace: true, + reducedMotionFallback: 'text' + } + } + ], + parts: [ + { + name: 'Provider', + kebab: 'provider', + archetype: 'provider', + kind: 'public', + defaultElement: 'div', + optional: false, + data: [ + { 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' }, + { attr: 'data-invalid', value: v.propRef('invalid'), severity: 'optional' }, + { attr: 'data-focused', value: v.propRef('focused'), severity: 'optional' }, + { attr: 'data-scrubbing', value: v.propRef('scrubbing'), severity: 'optional' } + ], + aria: [] + }, + { + name: 'Input', + kebab: 'input', + archetype: 'input', + kind: 'public', + defaultElement: 'input', + role: 'spinbutton', + optional: false, + data: [ + { attr: 'data-disabled', severity: 'optional' }, + { attr: 'data-readonly', severity: 'optional' }, + { attr: 'data-invalid', severity: 'optional' } + ], + aria: [ + { attr: 'aria-valuenow', value: v.propRef('value'), severity: 'optional' }, + { attr: 'aria-valuetext', value: v.propRef('value'), severity: 'recommended' }, + { attr: 'aria-required', value: v.propRef('required'), severity: 'optional' }, + { attr: 'aria-disabled', value: v.propRef('disabled'), severity: 'optional' }, + { attr: 'aria-readonly', value: v.propRef('readonly'), severity: 'optional' }, + { attr: 'aria-invalid', value: v.propRef('invalid'), severity: 'optional' } + ], + keyboard: [ + { key: 'ArrowUp', action: 'increment' }, + { key: 'ArrowDown', action: 'decrement' }, + { key: 'Shift+ArrowUp', action: 'increment-large' }, + { key: 'Shift+ArrowDown', action: 'decrement-large' }, + { key: 'PageUp', action: 'increment-large' }, + { key: 'PageDown', action: 'decrement-large' }, + { key: 'Home', action: 'first-item' }, + { key: 'End', action: 'last-item' }, + { key: 'Enter', action: 'commit' } + ] + }, + { + name: 'IncrementTrigger', + kebab: 'increment-trigger', + archetype: 'trigger', + kind: 'public', + defaultElement: 'button', + role: 'button', + optional: true, + data: [{ attr: 'data-disabled', severity: 'optional' }], + aria: [ + { attr: 'type', value: v.literal('button') }, + { + attr: 'aria-label', + value: v.commonRef('buttons.increment', 'Increment'), + severity: 'recommended' + }, + { + attr: 'aria-disabled', + value: v.literal('true'), + severity: 'optional', + condition: { when: 'prop-truthy', prop: 'disabled' } + } + ] + }, + { + name: 'DecrementTrigger', + kebab: 'decrement-trigger', + archetype: 'trigger', + kind: 'public', + defaultElement: 'button', + role: 'button', + optional: true, + data: [{ attr: 'data-disabled', severity: 'optional' }], + aria: [ + { attr: 'type', value: v.literal('button') }, + { + attr: 'aria-label', + value: v.commonRef('buttons.decrement', 'Decrement'), + severity: 'recommended' + }, + { + attr: 'aria-disabled', + value: v.literal('true'), + severity: 'optional', + condition: { when: 'prop-truthy', prop: 'disabled' } + } + ] + }, + { + name: 'Scrubber', + kebab: 'scrubber', + archetype: 'thumb', + kind: 'public', + defaultElement: 'div', + role: 'presentation', + optional: true, + data: [ + { attr: 'data-scrubbing', severity: 'optional' }, + { attr: 'data-disabled', severity: 'optional' } + ], + aria: [{ attr: 'aria-hidden', value: v.literal('true') }] + } + ] +} as const satisfies Morfo; diff --git a/src/uix/sema/components/css-field.ts b/src/uix/sema/components/css-field.ts new file mode 100644 index 000000000..b4097f62a --- /dev/null +++ b/src/uix/sema/components/css-field.ts @@ -0,0 +1,52 @@ +import { semaSelector } from '$uix/morfo'; +import { cssFieldMorfo } from '$uix/morfo/components/css-field'; +import { sound, soundTuning } from '../sounds'; +import type { Sema } from '../sema-map'; + +/** + * CssField perceptual defaults. + * + * Commit covers keyboard, trigger, wheel, scrub-release and blur/Enter value + * commits. The scrubber behaves like a direct-manipulation handle (audible + * pickup, haptic-only movement). A disallowed unit / unparseable value emits a + * descending warn cue (untilFix). + */ + +const onProvider = (matchers?: Parameters>[2]) => + semaSelector(cssFieldMorfo, 'provider', matchers); + +const onInput = (matchers?: Parameters>[2]) => + semaSelector(cssFieldMorfo, 'input', matchers); + +const onScrubber = (matchers?: Parameters>[2]) => + semaSelector(cssFieldMorfo, 'scrubber', matchers); + +export const cssFieldSema: Sema = { + name: 'css-field', + cascade: [ + { + selector: onProvider({ eventName: 'commit-set' }), + sound: soundTuning('form.commit.subtle'), + haptic: { kind: 'tap' } + }, + { + selector: onScrubber({ eventName: 'handle-pick' }), + channels: ['sound', 'haptic'], + sound: sound('handle.pickup.air', { gain: 0.1 }), + haptic: { kind: 'tick', intensity: 0.18, duration: 6 } + }, + { + selector: onScrubber({ eventName: 'handle-drag-scrub' }), + channels: ['haptic'], + haptic: { kind: 'tick', intensity: 0.08, duration: 4 } + }, + { + selector: onInput({ eventName: 'signal-warn-invalid' }), + sound: soundTuning('form.commit.soft', { + contour: 'descending', + pitch: { op: 'add', value: -120 } + }), + haptic: { kind: 'warning' } + } + ] +}; diff --git a/src/uix/sema/components/index.ts b/src/uix/sema/components/index.ts index 8e55b5cb6..ed3b6f693 100644 --- a/src/uix/sema/components/index.ts +++ b/src/uix/sema/components/index.ts @@ -15,6 +15,7 @@ export { collapsibleSema } from './collapsible'; export { colorPickerSema } from './color-picker'; export { comboboxSema } from './combobox'; export { contextMenuSema } from './context-menu'; +export { cssFieldSema } from './css-field'; export { dateFieldSema } from './date-field'; export { dialogSema } from './dialog'; export { drawerSema } from './drawer'; diff --git a/src/uix/soma/components/css-field/README.md b/src/uix/soma/components/css-field/README.md new file mode 100644 index 000000000..331a5064e --- /dev/null +++ b/src/uix/soma/components/css-field/README.md @@ -0,0 +1,141 @@ +# CssField (soma) + +Headless spinbutton for **CSS dimension values** (`12px`, `1.5rem`, `50%`, `-8px`, +`auto`, …). Same behavior surface as `number-field` — keyboard stepping, optional +increment/decrement triggers, pointer scrubber, wheel, form integration, `Field` +inheritance — but it parses and validates a value+unit string instead of a number. + +Implements the WAI-ARIA [Spinbutton](https://www.w3.org/WAI/ARIA/apg/patterns/spinbutton/) pattern. + +## Anatomy + +```svelte + + − + + + + + +``` + +`Input` is required; `IncrementTrigger`, `DecrementTrigger` and `Scrubber` are optional. + +## Validation — built-in functions OR delegated to sium + +The field never uses the sium **engine** internally. By default it validates with the +pure sium **functions** (`parseCssValue` / `isCssValue` from `$sium`) against the +`allowedUnits` whitelist + `keywords` + `allowNegative`. A consumer can **delegate** validation +by passing `validate`: + +```svelte + + + + + v.endsWith('px') || 'Only px allowed'} bind:value /> + + + +``` + +Parsing (`parseCssValue` → `{ value, unit }`) always runs regardless of validator — it +feeds increment/decrement/scrub, which step the numeric component and preserve the unit. + +An invalid value (disallowed unit / unparseable / failed delegate) is **non-blocking**: +the text is kept, `data-invalid` is set, a `signal-warn-invalid` cue fires (`untilFix`), +and the bound `value` stays at the last valid string. The next valid commit clears it. + +## Props (Provider) + +| Prop | Type | Default | Description | +| --- | --- | --- | --- | +| `value` | `string` (bindable) | `undefined` | Current CSS value, e.g. `"12px"`. | +| `allowedUnits` | `CssUnit[]` | all units | Allowed-units whitelist; first = default unit. | +| `keywords` | `string[]` | `[]` | Opt-in bare keywords (`auto`, `inherit`…). | +| `allowNegative` | `boolean` | `true` | Allow negative values. | +| `validate` | `CssFieldValidate` | — | Predicate function **or** sium schema. | +| `min` / `max` | `number` | — | Numeric-component bounds (unit-agnostic). | +| `step` | `number` | per-unit | Step amount (px:1, rem/em:0.1, else 1). | +| `largeStep` | `number` | `step*10` | Shift+Arrow / PageUp-Down amount. | +| `allowMouseWheel` | `boolean` | `false` | Wheel steps when focused. | +| `clampOnBlur` | `boolean` | `true` | Clamp numeric to min/max on blur. | +| `dir` | `'ltr' \| 'rtl'` | prefs | Direction (mirrors scrubber). | +| `disabled` / `readonly` / `required` / `invalid` | `boolean` | `false` | Form state. | +| `name` | `string` | — | Hidden form input name. | +| `onValueChange` | `OnChangeFn` | — | On every change. | +| `onValueCommit` | `OnChangeFn` | — | On blur/Enter with a valid value. | +| `onInvalid` | `(value: string) => void` | — | With the rejected text. | + +**Scrubber**: `direction?: 'horizontal' \| 'vertical'` (default `'horizontal'`), `sensitivity?: number` (default `5`). + +## Data attributes + +- Provider: `data-css-field`, `data-disabled`, `data-readonly`, `data-required`, `data-invalid`, `data-focused`, `data-scrubbing` +- Input: `data-css-field-input`, `data-disabled`, `data-readonly`, `data-invalid` +- Triggers: `data-css-field-increment-trigger` / `data-css-field-decrement-trigger`, `data-disabled` +- Scrubber: `data-css-field-scrubber`, `data-scrubbing`, `data-disabled` + +## Keyboard (Input) + +| Key | Action | +| --- | --- | +| ↑ / ↓ | Increment / decrement by `step` | +| Shift+↑ / Shift+↓, PageUp / PageDown | Increment / decrement by `largeStep` | +| Home / End | Set to `min` / `max` (keeps unit) | +| Enter | Commit the typed value | + +## Sema events + +| Event | Family · Verb | Target | Intent | Sequence | When | +| --- | --- | --- | --- | --- | --- | +| `commit-set` | commit · set | provider | neutral | post | Value committed (keyboard/trigger/wheel/blur/Enter/scrub-release) | +| `handle-pick` | handle · pick | scrubber | — | pre | Scrub pickup | +| `handle-drag-scrub` | handle · drag | scrubber | — | coincident | Scrub movement (haptic only) | +| `signal-warn-invalid` | signal · warn | input | risk | post | Invalid value typed (`persistence: untilFix`) | + +Pack: `src/uix/sema/components/css-field.ts`. + +## Field integration + +Inside ``, inherits `disabled` / `readonly` / `required` / `invalid`, registers its +input id into `field.inputId` (direct assignment, A30) and joins `helper`/`error` ids into +`aria-describedby`. + +## Baseline + +No Air baseline — new component. Behavior surface cloned from `number-field`; validation +helpers added to `$sium` (`parseCssValue`, `formatCssValue`, `isCssValue`, `cssValue`). + +## Comparativa + +| Feature | Radix | Ark UI | React Aria | CssField | +| --- | --- | --- | --- | --- | +| Numeric stepper | NumberField | NumberInput | NumberField | ✓ (numeric component) | +| Unit-aware value | ✗ | ✗ | ✗ | ✓ (`allowedUnits` whitelist) | +| Increment/decrement triggers | ✓ | ✓ | ✓ | ✓ (optional) | +| Pointer scrubber | ✗ | ✗ | ✗ | ✓ (optional) | +| Pluggable validation | ✗ | partial | partial | ✓ (function or sium schema) | + +No reference lib ships a unit-aware CSS value field; the closest analogs are numeric +spinbuttons. The unit whitelist + scrubber + delegated validation are the differentiators. + +## Decisiones + +- **`value` is a string** (`"12px"`) — natural for CSS authoring; parsed internally. +- **`allowedUnits` = unit whitelist**. First entry is the default unit. +- **sium functions, not the engine**, by default; the engine is only used when a sium schema + is delegated via `validate`. +- **Invalid is non-blocking** (`untilFix`) rather than rejecting keystrokes — multi-character + units make character-level rejection hostile. +- **Negatives allowed by default** (margins/positions). + +## Gaps (out of scope) + +- `calc(...)` expressions — deferred (loose validation only would be needed). _diferir_ +- Per-unit conversion for `min`/`max` (bounds are unit-agnostic numeric) — _descartar_. +- Color values — separate `color-field` already covers that domain — _descartar_. + +## Subset + +- `color`: the 8 canonical roles (eidos layer). +- `intent`: only `risk` is emitted (the warn cue); the field carries no evaluative intent otherwise. diff --git a/src/uix/soma/components/css-field/components/css-field-decrement-trigger.svelte b/src/uix/soma/components/css-field/components/css-field-decrement-trigger.svelte new file mode 100644 index 000000000..bd44bc0dc --- /dev/null +++ b/src/uix/soma/components/css-field/components/css-field-decrement-trigger.svelte @@ -0,0 +1,33 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/soma/components/css-field/components/css-field-increment-trigger.svelte b/src/uix/soma/components/css-field/components/css-field-increment-trigger.svelte new file mode 100644 index 000000000..3476c68c5 --- /dev/null +++ b/src/uix/soma/components/css-field/components/css-field-increment-trigger.svelte @@ -0,0 +1,33 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/soma/components/css-field/components/css-field-input.svelte b/src/uix/soma/components/css-field/components/css-field-input.svelte new file mode 100644 index 000000000..fa3a52215 --- /dev/null +++ b/src/uix/soma/components/css-field/components/css-field-input.svelte @@ -0,0 +1,33 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/soma/components/css-field/components/css-field-scrubber.svelte b/src/uix/soma/components/css-field/components/css-field-scrubber.svelte new file mode 100644 index 000000000..df295eb6d --- /dev/null +++ b/src/uix/soma/components/css-field/components/css-field-scrubber.svelte @@ -0,0 +1,37 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
{@render children?.()}
+{/if} diff --git a/src/uix/soma/components/css-field/components/css-field.svelte b/src/uix/soma/components/css-field/components/css-field.svelte new file mode 100644 index 000000000..783ba4f2c --- /dev/null +++ b/src/uix/soma/components/css-field/components/css-field.svelte @@ -0,0 +1,87 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
{@render children?.()}
+{/if} +{#if name} + +{/if} diff --git a/src/uix/soma/components/css-field/css-field-provider.svelte.test.ts b/src/uix/soma/components/css-field/css-field-provider.svelte.test.ts new file mode 100644 index 000000000..e2c12a089 --- /dev/null +++ b/src/uix/soma/components/css-field/css-field-provider.svelte.test.ts @@ -0,0 +1,285 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createActiveDom } from '$adom'; +import { createActiveTimers } from '$timer/active-timers.svelte'; +import { state } from '$libs/reactive'; +import type { Morfo } from '$uix/morfo'; +import { pipe, string, cssValue } from '$sium'; +import { Soma } from '$soma/core/soma.svelte'; +import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte'; +import { FieldProvider } from '../field'; +import type { CssFieldValidate } from './types'; + +import { + CssFieldInputProvider, + CssFieldProvider, + CssFieldScrubberProvider +} from './css-field-provider.svelte'; + +function withEffectRoot(fn: () => T): { result: T; cleanup: () => void } { + let result!: T; + const cleanup = $effect.root(() => { + result = fn(); + }); + return { result, cleanup }; +} + +function installSomaHarness() { + const dom = createActiveDom(); + const timers = createActiveTimers(); + const disposeDom = dom.dispose.bind(dom); + dom.dispose = () => { + timers.dispose(); + disposeDom(); + }; + const soma = { + dom, + uix: { timers }, + langs: { + getLocale: () => 'en-US', + ts: (key: string) => key + }, + prefs: { + getDir: () => 'ltr' + }, + runtime: (morfo: Morfo, sources: Omit) => + createSomaRuntime(morfo, { + dom, + translate: (key) => key, + ...sources + }) + } as unknown as Soma; + + vi.spyOn(Soma, 'require').mockReturnValue(soma); + vi.spyOn(FieldProvider, 'get').mockReturnValue(undefined); + vi.spyOn(CssFieldProvider.ctx, 'set').mockImplementation((value) => value); + + return { dom }; +} + +function createCssFieldOpts() { + return { + id: state('css-field-root'), + ref: state(null), + value: state(undefined), + allowedUnits: state(undefined), + keywords: state(undefined), + allowNegative: state(true), + validate: state(undefined), + min: state(undefined), + max: state(undefined), + step: state(undefined), + largeStep: state(undefined), + dir: state<'ltr' | 'rtl' | undefined>(undefined), + clampOnBlur: state(true), + allowMouseWheel: state(false), + disabled: state(false), + readonly: state(false), + required: state(false), + invalid: state(false), + name: state(undefined), + onValueCommit: state<((value: string) => void) | undefined>(undefined), + onInvalid: state<((value: string) => void) | undefined>(undefined) + }; +} + +function typeAndBlur(provider: CssFieldProvider, text: string) { + provider.handleFocus({} as never); + const input = document.createElement('input'); + input.value = text; + provider.handleInput({ currentTarget: input } as never); + provider.handleBlur({} as never); +} + +describe('CssFieldProvider', () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + document.body.innerHTML = ''; + }); + + it('parses and commits a value+unit on blur', () => { + const { dom } = installSomaHarness(); + const opts = createCssFieldOpts(); + const onCommit = vi.fn(); + opts.onValueCommit.current = onCommit; + + const { result: provider, cleanup } = withEffectRoot(() => CssFieldProvider.create(opts)); + typeAndBlur(provider, '2rem'); + + expect(opts.value.current).toBe('2rem'); + expect(onCommit).toHaveBeenCalledWith('2rem'); + + cleanup(); + dom.dispose(); + }); + + it('increments preserving the unit, with a per-unit default step', () => { + const { dom } = installSomaHarness(); + const opts = createCssFieldOpts(); + const { result: provider, cleanup } = withEffectRoot(() => CssFieldProvider.create(opts)); + + opts.value.current = '16px'; + provider.handleKeydown({ key: 'ArrowUp', shiftKey: false, preventDefault: vi.fn() } as never); + expect(opts.value.current).toBe('17px'); + + opts.value.current = '1rem'; + provider.handleKeydown({ key: 'ArrowUp', shiftKey: false, preventDefault: vi.fn() } as never); + expect(opts.value.current).toBe('1.1rem'); + + cleanup(); + dom.dispose(); + }); + + it('attaches the default unit to a bare number on commit', () => { + const { dom } = installSomaHarness(); + const opts = createCssFieldOpts(); + opts.allowedUnits.current = ['px']; + + const { result: provider, cleanup } = withEffectRoot(() => CssFieldProvider.create(opts)); + typeAndBlur(provider, '12'); + + expect(opts.value.current).toBe('12px'); + + cleanup(); + dom.dispose(); + }); + + it('marks invalid (untilFix) and keeps the last value on a disallowed unit', () => { + const { dom } = installSomaHarness(); + const opts = createCssFieldOpts(); + const onInvalid = vi.fn(); + opts.allowedUnits.current = ['px']; + opts.value.current = '8px'; + opts.onInvalid.current = onInvalid; + + const { result: provider, cleanup } = withEffectRoot(() => CssFieldProvider.create(opts)); + typeAndBlur(provider, '5rem'); + + expect(provider.internalInvalid).toBe(true); + expect(provider.isInvalid).toBe(true); + expect(opts.value.current).toBe('8px'); + expect(onInvalid).toHaveBeenCalledWith('5rem'); + + cleanup(); + dom.dispose(); + }); + + it('accepts opt-in keywords', () => { + const { dom } = installSomaHarness(); + const opts = createCssFieldOpts(); + opts.allowedUnits.current = ['px', '%']; + opts.keywords.current = ['auto']; + + const { result: provider, cleanup } = withEffectRoot(() => CssFieldProvider.create(opts)); + typeAndBlur(provider, 'auto'); + + expect(opts.value.current).toBe('auto'); + expect(provider.internalInvalid).toBe(false); + + cleanup(); + dom.dispose(); + }); + + it('clamps the numeric component to min/max on blur', () => { + const { dom } = installSomaHarness(); + const opts = createCssFieldOpts(); + opts.min.current = 0; + opts.max.current = 10; + + const { result: provider, cleanup } = withEffectRoot(() => CssFieldProvider.create(opts)); + typeAndBlur(provider, '20px'); + + expect(opts.value.current).toBe('10px'); + + cleanup(); + dom.dispose(); + }); + + it('delegates validation to a sium schema when provided', () => { + const { dom } = installSomaHarness(); + const opts = createCssFieldOpts(); + opts.value.current = '4px'; + opts.validate.current = pipe(string(), cssValue({ units: ['px'] })) as never; + + const { result: provider, cleanup } = withEffectRoot(() => CssFieldProvider.create(opts)); + + typeAndBlur(provider, '4rem'); + expect(provider.internalInvalid).toBe(true); + expect(opts.value.current).toBe('4px'); + + typeAndBlur(provider, '6px'); + expect(provider.internalInvalid).toBe(false); + expect(opts.value.current).toBe('6px'); + + cleanup(); + dom.dispose(); + }); + + it('scrubs the numeric component preserving the unit', () => { + const { dom } = installSomaHarness(); + const opts = createCssFieldOpts(); + const onCommit = vi.fn(); + opts.value.current = '10px'; + opts.step.current = 2; + opts.dir.current = 'ltr'; + opts.onValueCommit.current = onCommit; + + const { result: provider, cleanup } = withEffectRoot(() => CssFieldProvider.create(opts)); + vi.spyOn(CssFieldProvider.ctx, 'get').mockReturnValue(provider); + + const scrubber = CssFieldScrubberProvider.create({ + id: state('css-field-scrubber'), + ref: state(null), + direction: state<'horizontal' | 'vertical'>('horizontal'), + sensitivity: state(5) + }); + const target = { setPointerCapture: vi.fn() }; + + scrubber.onpointerdown({ button: 0, clientX: 0, clientY: 0, preventDefault: vi.fn() } as never); + scrubber.onpointermove({ + clientX: 15, + clientY: 0, + pointerId: 1, + currentTarget: target, + preventDefault: vi.fn() + } as never); + + expect(provider.scrubbing).toBe(true); + expect(opts.value.current).toBe('16px'); + + scrubber.onpointerup(); + expect(onCommit).toHaveBeenCalledWith('16px'); + + cleanup(); + dom.dispose(); + }); + + it('exposes spinbutton input props (valuenow numeric, valuetext full string)', () => { + const { dom } = installSomaHarness(); + const opts = createCssFieldOpts(); + opts.value.current = '24px'; + opts.required.current = true; + + const { result: provider, cleanup } = withEffectRoot(() => CssFieldProvider.create(opts)); + vi.spyOn(CssFieldProvider.ctx, 'get').mockReturnValue(provider); + + const input = CssFieldInputProvider.create({ + id: state('css-field-input'), + ref: state(null) + }); + + expect(input.props).toMatchObject({ + role: 'spinbutton', + value: '24px', + 'aria-valuenow': 24, + 'aria-valuetext': '24px', + 'aria-required': 'true' + }); + + cleanup(); + dom.dispose(); + }); +}); 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 new file mode 100644 index 000000000..e6a212827 --- /dev/null +++ b/src/uix/soma/components/css-field/css-field-provider.svelte.ts @@ -0,0 +1,834 @@ +import { watch } from 'runed'; +import { context, type WithRefOpts } from '../../provider'; +import { boolToEmptyStrOrUndef, boolToStr, isHTMLElement } from '$adom'; +import { readableActive, type Active, type ActiveProps, type StateProps } from '$libs/reactive'; +import type { + OnChangeFn, + Direction, + SomaKeyboardEvent, + SomaFocusEvent, + SomaInputEvent +} from '../../types'; +import { KEYS } from '../../keyboard'; +import { Soma } from '../../core/soma.svelte'; +import { CSS_FIELD_LANGS } from './langs'; +import { FieldProvider } from '../field'; +import type { CssFieldValidate } from './types'; +// Pure sium FUNCTIONS only — never the schema engine. The field validates with +// these by default; a sium schema can be delegated via the `validate` prop. +import { parseCssValue, formatCssValue } from '$sium'; + +import { cssFieldMorfo } from '../../../morfo/components/css-field'; +import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte'; +import type { TimerHandle, TimerScheduler } from '$libs/timer'; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function getDecimalPrecision(n: number): number { + const str = String(n); + const dot = str.indexOf('.'); + return dot >= 0 ? str.length - dot - 1 : 0; +} + +/** Default per-unit step when `step` is not supplied. */ +const DEFAULT_STEP_BY_UNIT: Record = { rem: 0.1, em: 0.1 }; +function defaultStepFor(unit: string): number { + return DEFAULT_STEP_BY_UNIT[unit] ?? 1; +} + +const BARE_NUMBER_REGEX = /^[+-]?(?:\d+(?:\.\d+)?|\.\d+)$/; + +const SPIN_PRESS_DELAY_MS = 400; +const SPIN_PRESS_INTERVAL_MS = 100; + +class SpinPressRepeater { + private delayTimer: TimerHandle | null = null; + private intervalTimer: TimerHandle | null = null; + private handledPointerPress = false; + + constructor( + private readonly timers: TimerScheduler, + private readonly key: string, + private readonly isDisabled: () => boolean, + private readonly step: () => void + ) {} + + start(): void { + this.stop(); + if (this.isDisabled()) return; + + this.handledPointerPress = true; + this.step(); + this.delayTimer = this.timers.schedule( + `${this.key}:delay`, + SPIN_PRESS_DELAY_MS, + () => { + this.intervalTimer = this.timers.interval( + `${this.key}:interval`, + SPIN_PRESS_INTERVAL_MS, + () => { + if (this.isDisabled()) { + this.stop(); + return; + } + this.step(); + }, + { replace: true, awaitTask: false } + ); + }, + { replace: true } + ); + } + + stop(): void { + if (this.delayTimer) { + this.delayTimer.cancel(); + this.delayTimer = null; + } + if (this.intervalTimer) { + this.intervalTimer.cancel(); + this.intervalTimer = null; + } + } + + consumePointerClick(): boolean { + if (!this.handledPointerPress) return false; + this.handledPointerPress = false; + return true; + } +} + +function createSpinPressRepeater( + timers: TimerScheduler, + key: string, + isDisabled: () => boolean, + step: () => void +): SpinPressRepeater { + return new SpinPressRepeater(timers, key, isDisabled, step); +} + +// ── Root ───────────────────────────────────────────────────────────────────── + +interface CssFieldOpts + extends + WithRefOpts, + StateProps<{ value: string | undefined }>, + ActiveProps<{ + allowedUnits: readonly string[] | undefined; + keywords: readonly string[] | undefined; + allowNegative: boolean; + validate: CssFieldValidate | undefined; + min: number | undefined; + max: number | undefined; + step: number | undefined; + largeStep: number | undefined; + dir: Direction | undefined; + clampOnBlur: boolean; + allowMouseWheel: boolean; + disabled: boolean; + readonly: boolean; + required: boolean; + invalid: boolean; + name: string | undefined; + onValueCommit: OnChangeFn | undefined; + onInvalid: ((value: string) => void) | undefined; + }> {} + +export class CssFieldProvider { + readonly opts: CssFieldOpts; + readonly runtimePart: SomaRuntimePart; + readonly soma: Soma; + readonly runtime: SomaRuntime; + static readonly ctx = context('CssField'); + static get(): CssFieldProvider | undefined { + return this.ctx.getOr(undefined) as CssFieldProvider | undefined; + } + static require(): CssFieldProvider { + return this.ctx.get(); + } + + static create(opts: CssFieldOpts) { + return new CssFieldProvider(opts); + } + + /** Optional parent Field — inherits disabled/readonly/required/invalid + input id wiring. */ + readonly field = FieldProvider.get(); + + // Input display value (the editable text = the canonical CSS string). + inputValue = $state(''); + focused = $state(false); + + // Validity owned by the field (unparseable / disallowed unit / failed delegate). + internalInvalid = $state(false); + + // Scrub state + scrubbing = $state(false); + private pendingCommit: { value: string | undefined; clamp: boolean } | undefined; + + /** Resolved direction: prop → Soma.prefs.getDir() → 'ltr'. */ + readonly resolvedDir: Active; + + private constructor(opts: CssFieldOpts) { + this.opts = opts; + this.soma = Soma.require(); + this.runtime = this.soma.runtime(cssFieldMorfo, { + props: { + disabled: () => this.isDisabled, + readonly: () => this.isReadonly, + required: () => this.isRequired, + invalid: () => this.isInvalid, + focused: () => this.focused, + scrubbing: () => this.scrubbing + }, + events: { + 'commit-set': () => this.applyPendingCommit() + } + }); + this.runtimePart = this.runtime.part('provider', { + id: opts.id, + ref: opts.ref, + owner: this, + context: CssFieldProvider.ctx, + syncAttrs: true + }); + + this.resolvedDir = readableActive(() => opts.dir.current ?? this.soma.prefs.getDir() ?? 'ltr'); + + // Sync the visible text with external value changes while not editing. + watch.pre( + () => this.opts.value.current, + (value) => { + if (this.focused) return; + this.inputValue = value ?? ''; + } + ); + } + + // ── Computed ────────────────────────────────────────────────────────────── + + readonly isDisabled = $derived.by( + () => this.opts.disabled.current || (this.field?.isDisabled ?? false) + ); + readonly isReadonly = $derived.by( + () => this.opts.readonly.current || (this.field?.isReadonly ?? false) + ); + readonly isRequired = $derived.by( + () => this.opts.required.current || (this.field?.isRequired ?? false) + ); + readonly isInvalid = $derived.by( + () => + this.opts.invalid.current || (this.field?.isInvalid ?? false) || this.internalInvalid + ); + + readonly dir = $derived.by(() => this.resolvedDir.current); + + readonly min = $derived.by(() => this.opts.min.current); + readonly max = $derived.by(() => this.opts.max.current); + + /** Parse options derived from the field's unit whitelist + keywords + sign. */ + readonly parseOptions = $derived.by(() => ({ + units: this.opts.allowedUnits.current, + keywords: this.opts.keywords.current ?? [], + allowNegative: this.opts.allowNegative.current + })); + + /** Default unit applied to a bare number / increment-from-empty. */ + readonly defaultUnit = $derived.by(() => this.opts.allowedUnits.current?.[0] ?? 'px'); + + /** The current value parsed into its numeric + unit parts (or keyword / null). */ + readonly parsed = $derived.by(() => parseCssValue(this.opts.value.current, this.parseOptions)); + + /** The unit currently in effect (for stepping/scrubbing). */ + readonly resolvedUnit = $derived.by(() => { + const p = this.parsed; + return p && p.kind === 'dimension' && p.unit ? p.unit : this.defaultUnit; + }); + + readonly step = $derived.by(() => this.opts.step.current ?? defaultStepFor(this.resolvedUnit)); + readonly largeStep = $derived.by(() => this.opts.largeStep.current ?? this.step * 10); + + readonly atMin = $derived.by(() => { + const p = this.parsed; + return this.min !== undefined && p?.kind === 'dimension' && p.value <= this.min; + }); + readonly atMax = $derived.by(() => { + const p = this.parsed; + return this.max !== undefined && p?.kind === 'dimension' && p.value >= this.max; + }); + + // ── Validity (ambivalent: built-in functions OR delegated sium schema) ───── + + /** Built-in or delegated validity for a canonical value string. */ + private checkValidity(value: string): { valid: boolean; message?: string } { + const validate = this.opts.validate.current; + if (validate) { + if (typeof validate === 'function') { + const r = validate(value); + if (r === true || r === undefined) return { valid: true }; + if (r === false) return { valid: false }; + return { valid: false, message: r }; + } + const r = validate.validateSync(value); + return r.ok ? { valid: true } : { valid: false, message: r.issues?.[0]?.message }; + } + // Built-in: pure sium functions, no engine. + return { valid: parseCssValue(value, this.parseOptions) !== null }; + } + + /** Normalize a raw input to a canonical CSS string and validate it. */ + private normalizeAndValidate( + raw: string + ): { ok: true; value: string } | { ok: false; message?: string } { + let canonical = raw.trim(); + const parts = parseCssValue(canonical, this.parseOptions); + if (parts) { + canonical = formatCssValue(parts); + } else if (BARE_NUMBER_REGEX.test(canonical)) { + // Bare number → attach the default unit (e.g. "12" → "12px"). + const n = Number(canonical); + if (Number.isFinite(n) && (this.opts.allowNegative.current || n >= 0)) { + canonical = formatCssValue({ kind: 'dimension', value: n, unit: this.defaultUnit }); + } + } + const check = this.checkValidity(canonical); + if (!check.valid) return { ok: false, message: check.message }; + return { ok: true, value: canonical }; + } + + // ── Number helpers (operate on the numeric component) ────────────────────── + + clampNumber(n: number): number { + let v = n; + if (this.min !== undefined) v = Math.max(this.min, v); + if (this.max !== undefined) v = Math.min(this.max, v); + return v; + } + + roundNumber(n: number, step: number): number { + const precision = getDecimalPrecision(step); + const factor = Math.pow(10, precision); + return Math.round(n * factor) / factor; + } + + /** Build a canonical value string from a number + the active unit. */ + private toValue(n: number, unit: string): string { + return formatCssValue({ kind: 'dimension', value: n, unit: unit || this.defaultUnit }); + } + + // ── Commit / invalid ─────────────────────────────────────────────────────── + + private applyCommit(value: string | undefined, clamp: boolean): void { + if (value === undefined) { + this.opts.value.current = undefined; + this.inputValue = ''; + this.clearInvalid(); + return; + } + let finalStr = value; + const parts = parseCssValue(value, this.parseOptions); + if (clamp && parts && parts.kind === 'dimension') { + const clamped = this.clampNumber(parts.value); + if (clamped !== parts.value) finalStr = formatCssValue({ ...parts, value: clamped }); + } + this.opts.value.current = finalStr; + this.opts.onValueCommit.current?.(finalStr); + this.inputValue = finalStr; + this.clearInvalid(); + } + + private applyPendingCommit(): void { + const pending = this.pendingCommit; + this.pendingCommit = undefined; + if (!pending) return; + this.applyCommit(pending.value, pending.clamp); + } + + commit( + value: string | undefined, + opts: { clamp?: boolean; fallbackTarget?: HTMLElement } = {} + ): void { + this.pendingCommit = { value, clamp: opts.clamp ?? true }; + void this.runtime.trigger( + 'commit-set', + opts.fallbackTarget ? { fallbackTarget: opts.fallbackTarget } : undefined + ); + } + + /** Book §6.2 untilFix — mark invalid + emit a warn signal until the next valid commit. */ + private markInvalid(raw: string, message?: string): void { + this.internalInvalid = true; + this.opts.onInvalid.current?.(raw); + const input = this.runtime.partRef('input'); + if (input) this.runtime.clearTarget(input); + const text = + message && message.startsWith('#?') + ? this.soma.langs.ts(message) + : (message ?? this.soma.langs.ts(CSS_FIELD_LANGS.INVALID)); + void this.runtime.trigger('signal-warn-invalid', { + ...(input ? { fallbackTarget: input } : {}), + message: text + }); + } + + private clearInvalid(): void { + if (!this.internalInvalid) return; + this.internalInvalid = false; + const input = this.runtime.partRef('input'); + if (input) this.runtime.clearTarget(input); + } + + // ── Actions ────────────────────────────────────────────────────────────── + + increment(by: number = this.step): void { + if (this.isDisabled || this.isReadonly) return; + const p = this.parsed; + const baseValue = p && p.kind === 'dimension' ? p.value : (this.min ?? 0); + const unit = p && p.kind === 'dimension' && p.unit ? p.unit : this.defaultUnit; + const next = this.clampNumber(this.roundNumber(baseValue + by, by)); + this.commit(this.toValue(next, unit)); + } + + decrement(by: number = this.step): void { + if (this.isDisabled || this.isReadonly) return; + const p = this.parsed; + const baseValue = p && p.kind === 'dimension' ? p.value : (this.min ?? 0); + const unit = p && p.kind === 'dimension' && p.unit ? p.unit : this.defaultUnit; + const next = this.clampNumber(this.roundNumber(baseValue - by, by)); + this.commit(this.toValue(next, unit)); + } + + // ── Event handlers ─────────────────────────────────────────────────────── + + readonly handleFocus = (_e: SomaFocusEvent): void => { + this.focused = true; + this.inputValue = this.opts.value.current ?? ''; + }; + + readonly handleBlur = (_e: SomaFocusEvent): void => { + this.focused = false; + const raw = this.inputValue.trim(); + if (raw === '') { + this.commit(undefined); + return; + } + const res = this.normalizeAndValidate(raw); + if (!res.ok) { + this.markInvalid(raw, res.message); + return; + } + this.commit(res.value, { clamp: this.opts.clampOnBlur.current }); + }; + + readonly handleInput = (e: SomaInputEvent): void => { + // Accept all text while typing — validity is decided on commit (untilFix). + this.inputValue = e.currentTarget.value; + }; + + readonly handleWheel = (e: WheelEvent): void => { + if (!this.opts.allowMouseWheel.current || this.isDisabled || this.isReadonly) return; + if (!isHTMLElement(e.currentTarget)) return; + if (this.soma.dom.activeElement(e.currentTarget) !== e.currentTarget) return; + e.preventDefault(); + if (e.deltaY < 0) { + this.increment(); + } else if (e.deltaY > 0) { + this.decrement(); + } + }; + + readonly handleKeydown = (e: SomaKeyboardEvent): void => { + if (this.isDisabled || this.isReadonly) return; + + switch (e.key) { + case KEYS.ARROW_UP: + e.preventDefault(); + this.increment(e.shiftKey ? this.largeStep : this.step); + break; + case KEYS.ARROW_DOWN: + e.preventDefault(); + this.decrement(e.shiftKey ? this.largeStep : this.step); + break; + case KEYS.PAGE_UP: + e.preventDefault(); + this.increment(this.largeStep); + break; + case KEYS.PAGE_DOWN: + e.preventDefault(); + this.decrement(this.largeStep); + break; + case KEYS.HOME: + if (this.min !== undefined) { + e.preventDefault(); + this.commit(this.toValue(this.min, this.resolvedUnit)); + } + break; + case KEYS.END: + if (this.max !== undefined) { + e.preventDefault(); + this.commit(this.toValue(this.max, this.resolvedUnit)); + } + break; + case KEYS.ENTER: { + e.preventDefault(); + const raw = e.currentTarget.value.trim(); + if (raw === '') { + this.commit(undefined); + break; + } + const res = this.normalizeAndValidate(raw); + if (!res.ok) this.markInvalid(raw, res.message); + else this.commit(res.value, { clamp: this.opts.clampOnBlur.current }); + break; + } + } + }; + + readonly props = $derived.by(() => + this.runtimePart.assert({ + ...this.runtimePart.props, + dir: this.dir + } as const) + ); +} + +// ── Input ──────────────────────────────────────────────────────────────────── + +interface CssFieldInputOpts extends WithRefOpts {} + +export class CssFieldInputProvider { + readonly opts: CssFieldInputOpts; + readonly runtimePart: SomaRuntimePart; + static create(opts: CssFieldInputOpts) { + return new CssFieldInputProvider(opts); + } + + readonly provider: CssFieldProvider; + + private constructor(opts: CssFieldInputOpts) { + this.opts = opts; + this.provider = CssFieldProvider.require(); + this.runtimePart = this.provider.runtime.part('input', { + id: opts.id, + ref: opts.ref, + owner: this + }); + // Register this input id so a parent Field.Label `for=` targets it. + if (this.provider.field) { + this.provider.field.inputId.current = opts.id.current; + } + } + + readonly describedBy = $derived.by(() => { + const field = this.provider.field; + if (!field) return undefined; + const ids = [field.helperId.current, field.isInvalid ? field.errorId.current : ''].filter( + Boolean + ); + return ids.length > 0 ? ids.join(' ') : undefined; + }); + + readonly props = $derived.by(() => { + const p = this.provider.parsed; + return this.runtimePart.assert({ + ...this.runtimePart.props, + role: 'spinbutton' as const, + inputmode: 'text' as const, + autocomplete: 'off' as const, + autocorrect: 'off' as const, + spellcheck: false, + dir: this.provider.dir, + value: this.provider.inputValue, + 'aria-valuenow': p && p.kind === 'dimension' ? p.value : undefined, + 'aria-valuemin': this.provider.opts.min.current, + 'aria-valuemax': this.provider.opts.max.current, + 'aria-valuetext': this.provider.opts.value.current || undefined, + 'aria-required': boolToStr(this.provider.isRequired), + 'aria-disabled': boolToStr(this.provider.isDisabled), + 'aria-readonly': boolToStr(this.provider.isReadonly), + 'aria-invalid': boolToStr(this.provider.isInvalid), + 'aria-describedby': this.describedBy, + disabled: this.provider.isDisabled || undefined, + readonly: this.provider.isReadonly || undefined, + required: this.provider.isRequired || undefined, + name: this.provider.opts.name.current || undefined, + 'data-disabled': boolToEmptyStrOrUndef(this.provider.isDisabled), + 'data-readonly': boolToEmptyStrOrUndef(this.provider.isReadonly), + 'data-invalid': boolToEmptyStrOrUndef(this.provider.isInvalid), + onfocus: this.provider.handleFocus, + onblur: this.provider.handleBlur, + oninput: this.provider.handleInput, + onkeydown: this.provider.handleKeydown, + onwheel: this.provider.handleWheel + } as const); + }); +} + +// ── IncrementTrigger ───────────────────────────────────────────────────────── + +interface CssFieldIncrementTriggerOpts extends WithRefOpts {} + +export class CssFieldIncrementTriggerProvider { + readonly opts: CssFieldIncrementTriggerOpts; + readonly runtimePart: SomaRuntimePart; + static create(opts: CssFieldIncrementTriggerOpts) { + return new CssFieldIncrementTriggerProvider(opts); + } + + readonly provider: CssFieldProvider; + readonly repeater: SpinPressRepeater; + + private constructor(opts: CssFieldIncrementTriggerOpts) { + this.opts = opts; + this.provider = CssFieldProvider.require(); + this.repeater = createSpinPressRepeater( + this.provider.soma.uix.timers, + `css-field:${opts.id.current}:increment`, + () => this.isDisabled, + () => this.provider.increment() + ); + this.runtimePart = this.provider.runtime.part('increment-trigger', { + id: opts.id, + ref: opts.ref, + owner: this + }); + } + + readonly isDisabled = $derived.by(() => this.provider.isDisabled || this.provider.atMax); + + readonly handleClick = () => { + if (this.repeater.consumePointerClick()) return; + this.provider.increment(); + }; + + readonly handlePointerDown = (e: PointerEvent) => { + if (e.button !== 0) return; + e.preventDefault(); + this.repeater.start(); + }; + + readonly stopPress = () => { + this.repeater.stop(); + }; + + readonly props = $derived.by(() => + this.runtimePart.assert({ + ...this.runtimePart.props, + type: 'button' as const, + tabindex: -1, + disabled: this.isDisabled || undefined, + 'aria-label': this.provider.soma.langs.ts(CSS_FIELD_LANGS.INCREMENT), + 'aria-disabled': boolToStr(this.isDisabled), + 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled), + onclick: this.handleClick, + onpointerdown: this.handlePointerDown, + onpointerup: this.stopPress, + onpointerleave: this.stopPress, + onpointercancel: this.stopPress, + onlostpointercapture: this.stopPress + } as const) + ); +} + +// ── DecrementTrigger ───────────────────────────────────────────────────────── + +interface CssFieldDecrementTriggerOpts extends WithRefOpts {} + +export class CssFieldDecrementTriggerProvider { + readonly opts: CssFieldDecrementTriggerOpts; + readonly runtimePart: SomaRuntimePart; + static create(opts: CssFieldDecrementTriggerOpts) { + return new CssFieldDecrementTriggerProvider(opts); + } + + readonly provider: CssFieldProvider; + readonly repeater: SpinPressRepeater; + + private constructor(opts: CssFieldDecrementTriggerOpts) { + this.opts = opts; + this.provider = CssFieldProvider.require(); + this.repeater = createSpinPressRepeater( + this.provider.soma.uix.timers, + `css-field:${opts.id.current}:decrement`, + () => this.isDisabled, + () => this.provider.decrement() + ); + this.runtimePart = this.provider.runtime.part('decrement-trigger', { + id: opts.id, + ref: opts.ref, + owner: this + }); + } + + readonly isDisabled = $derived.by(() => this.provider.isDisabled || this.provider.atMin); + + readonly handleClick = () => { + if (this.repeater.consumePointerClick()) return; + this.provider.decrement(); + }; + + readonly handlePointerDown = (e: PointerEvent) => { + if (e.button !== 0) return; + e.preventDefault(); + this.repeater.start(); + }; + + readonly stopPress = () => { + this.repeater.stop(); + }; + + readonly props = $derived.by(() => + this.runtimePart.assert({ + ...this.runtimePart.props, + type: 'button' as const, + tabindex: -1, + disabled: this.isDisabled || undefined, + 'aria-label': this.provider.soma.langs.ts(CSS_FIELD_LANGS.DECREMENT), + 'aria-disabled': boolToStr(this.isDisabled), + 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled), + onclick: this.handleClick, + onpointerdown: this.handlePointerDown, + onpointerup: this.stopPress, + onpointerleave: this.stopPress, + onpointercancel: this.stopPress, + onlostpointercapture: this.stopPress + } as const) + ); +} + +// ── Scrubber ───────────────────────────────────────────────────────────────── + +interface CssFieldScrubberOpts + extends + WithRefOpts, + ActiveProps<{ + direction: 'horizontal' | 'vertical'; + sensitivity: number; + }> {} + +export class CssFieldScrubberProvider { + readonly opts: CssFieldScrubberOpts; + readonly runtimePart: SomaRuntimePart; + static create(opts: CssFieldScrubberOpts) { + return new CssFieldScrubberProvider(opts); + } + + readonly provider: CssFieldProvider; + private startX = 0; + private startY = 0; + private startValue = 0; + private startUnit = 'px'; + private scrubStep = 1; + private pointerDown = false; + private captured = false; + private static readonly MOVE_BUFFER = 5; + + private constructor(opts: CssFieldScrubberOpts) { + this.opts = opts; + this.provider = CssFieldProvider.require(); + this.runtimePart = this.provider.runtime.part('scrubber', { + id: opts.id, + ref: opts.ref, + owner: this + }); + } + + private get isDisabled(): boolean { + return this.provider.isDisabled || this.provider.isReadonly; + } + + readonly onpointerdown = (e: PointerEvent) => { + if (this.isDisabled || e.button !== 0) return; + e.preventDefault(); + + this.pointerDown = true; + this.captured = false; + this.startX = e.clientX; + this.startY = e.clientY; + const p = this.provider.parsed; + this.startValue = p && p.kind === 'dimension' ? p.value : (this.provider.min ?? 0); + this.startUnit = p && p.kind === 'dimension' && p.unit ? p.unit : this.provider.defaultUnit; + this.scrubStep = this.provider.step; + void this.provider.runtime.trigger('handle-pick', { + fallbackTarget: e.currentTarget as HTMLElement + }); + }; + + readonly onpointermove = (e: PointerEvent) => { + if (!this.pointerDown) return; + + if (!this.captured) { + const dx = e.clientX - this.startX; + const dy = e.clientY - this.startY; + if (Math.sqrt(dx * dx + dy * dy) < CssFieldScrubberProvider.MOVE_BUFFER) return; + + this.captured = true; + this.provider.scrubbing = true; + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + } + + e.preventDefault(); + void this.provider.runtime.trigger('handle-drag-scrub', { + fallbackTarget: e.currentTarget as HTMLElement + }); + + const sensitivity = this.opts.sensitivity.current; + const isHorizontal = this.opts.direction.current === 'horizontal'; + + // In RTL, horizontal scrub is mirrored (drag right → decrement). + const isRtlHorizontal = isHorizontal && this.provider.dir === 'rtl'; + const rawDelta = isHorizontal ? e.clientX - this.startX : -(e.clientY - this.startY); + const delta = isRtlHorizontal ? -rawDelta : rawDelta; + + const steps = Math.round(delta / sensitivity); + const newValue = this.startValue + steps * this.scrubStep; + const clamped = this.provider.clampNumber(this.provider.roundNumber(newValue, this.scrubStep)); + // During scrub, update value (onValueChange) — commit (onValueCommit) on release. + const next = formatCssValue({ kind: 'dimension', value: clamped, unit: this.startUnit }); + this.provider.opts.value.current = next; + this.provider.inputValue = next; + }; + + readonly onpointerup = (e?: PointerEvent) => { + this.pointerDown = false; + if (!this.provider.scrubbing) return; + this.captured = false; + this.provider.scrubbing = false; + this.provider.commit(this.provider.opts.value.current, { + fallbackTarget: e?.currentTarget as HTMLElement | undefined + }); + }; + + readonly onlostpointercapture = (e?: PointerEvent) => { + this.pointerDown = false; + this.captured = false; + if (this.provider.scrubbing) { + this.provider.scrubbing = false; + this.provider.commit(this.provider.opts.value.current, { + fallbackTarget: e?.currentTarget as HTMLElement | undefined + }); + } + }; + + readonly props = $derived.by(() => + this.runtimePart.assert({ + ...this.runtimePart.props, + role: 'presentation' as const, + 'aria-hidden': true as const, + 'data-scrubbing': boolToEmptyStrOrUndef(this.provider.scrubbing), + 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled), + style: { + cursor: this.isDisabled + ? 'default' + : this.opts.direction.current === 'horizontal' + ? 'ew-resize' + : 'ns-resize', + 'user-select': this.provider.scrubbing ? 'none' : undefined, + 'touch-action': 'none' + }, + onpointerdown: this.onpointerdown, + onpointermove: this.onpointermove, + onpointerup: this.onpointerup, + onlostpointercapture: this.onlostpointercapture + } as const) + ); +} diff --git a/src/uix/soma/components/css-field/exports.ts b/src/uix/soma/components/css-field/exports.ts new file mode 100644 index 000000000..92d5a5553 --- /dev/null +++ b/src/uix/soma/components/css-field/exports.ts @@ -0,0 +1,15 @@ +export { default as Provider } from './components/css-field.svelte'; +export { default as Input } from './components/css-field-input.svelte'; +export { default as IncrementTrigger } from './components/css-field-increment-trigger.svelte'; +export { default as DecrementTrigger } from './components/css-field-decrement-trigger.svelte'; +export { default as Scrubber } from './components/css-field-scrubber.svelte'; + +export type { + CssUnit, + CssFieldValidate, + CssFieldProps as ProviderProps, + CssFieldInputProps as InputProps, + CssFieldIncrementTriggerProps as IncrementTriggerProps, + CssFieldDecrementTriggerProps as DecrementTriggerProps, + CssFieldScrubberProps as ScrubberProps +} from './types'; diff --git a/src/uix/soma/components/css-field/index.ts b/src/uix/soma/components/css-field/index.ts new file mode 100644 index 000000000..8570a426a --- /dev/null +++ b/src/uix/soma/components/css-field/index.ts @@ -0,0 +1 @@ +export * from './exports'; diff --git a/src/uix/soma/components/css-field/langs.ts b/src/uix/soma/components/css-field/langs.ts new file mode 100644 index 000000000..933446550 --- /dev/null +++ b/src/uix/soma/components/css-field/langs.ts @@ -0,0 +1,6 @@ +/** Idlangref constants for the CssField component. */ +export const CSS_FIELD_LANGS = { + INCREMENT: '#?common.buttons.increment|Increment', + DECREMENT: '#?common.buttons.decrement|Decrement', + INVALID: '#?components.css-field.invalid|Invalid CSS value' +} as const; diff --git a/src/uix/soma/components/css-field/types.ts b/src/uix/soma/components/css-field/types.ts new file mode 100644 index 000000000..029ae8d2a --- /dev/null +++ b/src/uix/soma/components/css-field/types.ts @@ -0,0 +1,115 @@ +import type { WithChild, Without, OnChangeFn, Direction } from '../../types'; +import type { + PrimitiveDivAttributes, + PrimitiveButtonAttributes, + PrimitiveInputAttributes +} from '../../types'; +import type { CssUnit } from '$sium'; + +export type { CssUnit }; + +/** + * A sium schema the field can delegate validation to. Any sium schema exposes a + * synchronous `validateSync`. Pass e.g. `pipe(string(), cssValue({ units: ['px'] }))`. + */ +export interface CssFieldSchemaValidator { + validateSync(value: unknown): + | { ok: true } + | { ok: false; issues: ReadonlyArray<{ message: string }> }; +} + +/** + * Validation strategy for a CssField. + * - A predicate function: `true`/`void` = valid, `false` = invalid, `string` = invalid + message. + * - A sium schema: the field calls `validateSync` and uses the first issue message. + * + * When omitted, the field validates with the built-in pure functions + * (`isCssValue` against `allowedUnits` / `keywords` / `allowNegative`). + */ +export type CssFieldValidate = + | ((value: string) => boolean | string | void) + | CssFieldSchemaValidator; + +export type CssFieldProps = WithChild<{ + /** Unique identifier. Auto-generated if omitted. */ + id?: string; + /** Current CSS value string, e.g. `"12px"`. Bindable. @default undefined */ + value?: string; + /** + * Allowed CSS units (whitelist) for this field, e.g. `['px', 'rem', '%']`. + * The first entry is the default unit applied to a bare number or when + * incrementing from empty. Omit to allow every CSS unit. + * + * NOTE: `allowedUnits` constrains the *units*, it is NOT the bound `value`. + */ + allowedUnits?: CssUnit[]; + /** Opt-in bare keywords accepted as values, e.g. `['auto', 'inherit']`. @default [] */ + keywords?: string[]; + /** Allow negative values (e.g. `-8px`). @default true */ + allowNegative?: boolean; + /** + * Validation strategy. A predicate function OR a sium schema to delegate to. + * When omitted, the field validates with the built-in pure functions. + * @see CssFieldValidate + */ + validate?: CssFieldValidate; + /** Minimum numeric component (unit-agnostic). */ + min?: number; + /** Maximum numeric component (unit-agnostic). */ + max?: number; + /** Increment/decrement amount. Defaults per-unit (px:1, rem/em:0.1, else 1). */ + step?: number; + /** Large step for Shift+Arrow or PageUp/PageDown. @default step * 10 */ + largeStep?: number; + /** Allow mouse wheel to step the value when focused. @default false */ + allowMouseWheel?: boolean; + /** Clamp the numeric component to min/max on blur. @default true */ + clampOnBlur?: boolean; + /** Text direction. Resolved via: prop → Soma.prefs.getDir() → 'ltr'. */ + dir?: Direction; + /** Disables all interaction. @default false */ + disabled?: boolean; + /** Prevents value changes but keeps focusable. @default false */ + readonly?: boolean; + /** Marks as required for form validation. @default false */ + required?: boolean; + /** Marks as invalid for form validation (combined with internal validity). @default false */ + invalid?: boolean; + /** Form submission field name. */ + name?: string; + /** Callback fired when value changes. */ + onValueChange?: OnChangeFn; + /** Callback fired when value is committed (blur or Enter). Only fires with a valid value. */ + onValueCommit?: OnChangeFn; + /** Callback fired with the rejected text when a typed value fails validation. */ + onInvalid?: (value: string) => void; +}> & + Without; + +export type CssFieldInputProps = WithChild<{ + /** Unique identifier. Auto-generated if omitted. */ + id?: string; +}> & + Without; + +export type CssFieldIncrementTriggerProps = WithChild<{ + /** Unique identifier. Auto-generated if omitted. */ + id?: string; +}> & + Without; + +export type CssFieldDecrementTriggerProps = WithChild<{ + /** Unique identifier. Auto-generated if omitted. */ + id?: string; +}> & + Without; + +export type CssFieldScrubberProps = WithChild<{ + /** Unique identifier. Auto-generated if omitted. */ + id?: string; + /** Drag direction. @default 'horizontal' */ + direction?: 'horizontal' | 'vertical'; + /** Pixels per step during scrub. @default 5 */ + sensitivity?: number; +}> & + Without; diff --git a/src/uix/soma/components/index.ts b/src/uix/soma/components/index.ts index 5ef71f51f..797b29968 100644 --- a/src/uix/soma/components/index.ts +++ b/src/uix/soma/components/index.ts @@ -13,6 +13,7 @@ export * as ColorPicker from './color-picker'; export * as Combobox from './combobox'; export * as Command from './command'; export * as ContextMenu from './context-menu'; +export * as CssField from './css-field'; export * as DateField from './date-field'; export * as DatePicker from './date-picker'; export * as DateRangeField from './date-range-field'; diff --git a/web/routes/uix/+layout@.svelte b/web/routes/uix/+layout@.svelte index 84dd88032..f12c527ab 100644 --- a/web/routes/uix/+layout@.svelte +++ b/web/routes/uix/+layout@.svelte @@ -34,6 +34,7 @@ import { calendarSema } from '$uix/sema/components/calendar'; import { colorPickerSema } from '$uix/sema/components/color-picker'; import { comboboxSema } from '$uix/sema/components/combobox'; + import { cssFieldSema } from '$uix/sema/components/css-field'; import { dateFieldSema } from '$uix/sema/components/date-field'; import { Popover } from '$uix/eidos/components/popover'; import '@/uix/eidos/index.css'; @@ -69,6 +70,7 @@ checkboxSema, colorPickerSema, comboboxSema, + cssFieldSema, dateFieldSema, dialogSema, drawerSema, @@ -152,6 +154,7 @@ { slug: '/uix/components/editable', label: 'Editable' }, { slug: '/uix/components/words', label: 'Words' }, { slug: '/uix/components/number-field', label: 'Number field' }, + { slug: '/uix/components/css-field', label: 'CSS field' }, { slug: '/uix/components/search-field', label: 'Search field' }, { slug: '/uix/components/password-field', label: 'Password field' }, { slug: '/uix/components/textarea', label: 'Text area' }, diff --git a/web/routes/uix/components/css-field/+page.svelte b/web/routes/uix/components/css-field/+page.svelte new file mode 100644 index 000000000..060bec7e0 --- /dev/null +++ b/web/routes/uix/components/css-field/+page.svelte @@ -0,0 +1,688 @@ + + +
+
+
Forms · CSS value field
+

CssField

+

+ Spinbutton for CSS dimension values (px, rem, %, …) with a unit whitelist, optional + increment/decrement triggers and a pointer scrubber. Soma parses and validates via the pure + sium functions — or delegates to a sium schema; Eidos adds visual size. +

+
+ parts{compiled.parts.order.length} + events{events.length} + keyboard9 + sizes5 +
+
+ +
+
+ (lastInvalid = v)} + onValueCommit={() => (lastInvalid = null)} + > + − + + + + drag + +
+
+ trace + {#if trace.length === 0} + type a value, increment, press Enter or drag the scrubber to see events + {:else} + {#each trace.slice(0, 3) as entry} + + {entry.event} + · {entry.family}{entry.intent ? ' · ' + entry.intent : ''} + + {fmtTime(entry.at)} + {/each} + {/if} + + value + {displayValue} + {#if lastInvalid !== null} + invalid + {lastInvalid} + {/if} + +
+
+ +
+ + + + + + +
+ + {#if tab === 'live'} +
+

Controls

+

+ soma owns parse/validate/step model. + eidos owns size. +

+ +
+ soma props · value model +
+
+ + + + + + + + + {#each ['allowNegative', 'allowMouseWheel', 'clampOnBlur', 'disabled', 'readonly', 'required', 'invalid'] as flag} + + {/each} +
+ +
Presets · canonical scenarios
+
+
+ {#each PRESETS as p (p.label)} + + {/each} +
+
+ +
+ eidos props · visual treatment +
+
+ + + +
+ +
+
+ soma + headless · CSS-value spinbutton + svelte +
+
{somaSnippet}
+
+
+
+ eidos + visual · size, variant and intent color + svelte +
+
{eidosSnippet}
+
+
+ {/if} + + {#if tab === 'api'} +
+

API + reference comparison

+
+ + + + + + + + + + + + + + +
FeatureRadixArkReact AriaUIXDecision
Numeric stepperNumberFieldNumberInputNumberField✅Steps the numeric component.
Unit-aware value❌❌❌✅allowedUnits whitelist; unit preserved on step.
Increment/decrement triggers✅✅✅✅Optional, hold-to-repeat.
Pointer scrubber❌❌❌✅Optional, RTL-aware.
Pluggable validation❌partialpartial✅Built-in fns or delegate to a sium schema.
Field integration✅✅✅✅Inherits disabled/readonly/required/invalid.
+
+
+ + + + + + + + + + + + + + +
PropOwnerDefaultNotes
valuesomaundefinedBindable CSS string ("12px").
allowedUnitssomaall unitsAllowed-units whitelist; 1st = default.
keywordssoma[]Opt-in bare keywords.
allowNegativesomatrueAllow a leading minus.
validatesomabuilt-inPredicate fn or sium schema.
min / maxsoma—Numeric-component bounds.
step / largeStepsomaper-unit / step*10Stepping amounts.
sizeeidosmdxs | sm | md | lg | xl.
varianteidossurfacesurface | outline | ghost.
coloreidosprimaryAccent palette.
+
+
+ {/if} + + {#if tab === 'morfo'} +
+

Morfo contract

+
+ + + + {#each partsList as part} + + + + + + + + {/each} + +
PartMarkerElementRoleOptional
{part.kebab}[{part.marker}]<{part.defaultElement}>{part.role ?? '—'}{part.optional ? 'yes' : 'no'}
+
+
Keyboard
+
+ + + + {#each cssFieldMorfo.parts[1].keyboard ?? [] as key} + + {/each} + +
KeyAction
{key.key}{key.action}
+
+
+ {/if} + + {#if tab === 'sema'} +
+

+ sema · events + perceptual signature +

+

+ Value commits resolve through commit-set. The scrubber emits pickup + drag + cues; an out-of-whitelist unit fires signal-warn-invalid (untilFix). +

+
+ + + + {#each events as action} + + + + + + + + + + {/each} + +
NameFamilyVerbIntentSequenceTargetPlay
{action.name}{action.semantic.family}{action.semantic.verb ?? '—'}{action.semantic.intent ?? '—'}{action.semantic.sequence ?? 'pre'}{action.target} + +
+
+
+ {/if} + + {#if tab === 'recipe'} +
+

Eidos recipe

+

+ Selectors live in src/uix/eidos/components/css-field/css-field.css and consume + Soma focus/scrubbing/invalid/disabled attrs plus Eidos data-size. +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SelectorOwnerPurpose
[data-css-field]morfoRoot spinbutton composition shell.
[data-css-field][data-size][data-variant][data-color]eidosVisual scale, input treatment and intent palette.
[data-css-field][data-focused], [data-css-field][data-invalid]morfoFocus ring + invalid border surfaced by the headless layer.
[data-css-field-increment-trigger] / + [data-css-field-decrement-trigger]morfoStepper affordances.
[data-css-field-scrubber][data-scrubbing]morfoDrag scrubbing affordance and active state.
+
+
+ {/if} + + {#if tab === 'a11y'} +
+

Accessibility

+
+ + + + + + + + + +
ConcernContract
RoleInput uses role="spinbutton".
ValueInput exposes aria-valuenow (numeric part) + aria-valuetext (full string).
KeyboardArrows, PageUp/PageDown, Home/End and Enter are declared.
Invalidaria-invalid + a live signal-warn-invalid cue on a disallowed unit.
TriggersIncrement/decrement controls get translated labels.
+
+
+ {/if} +