New 4-layer UIX component for CSS dimension values (px/rem/%/vh/…) modeled on number-field: allowedUnits whitelist, optional increment/decrement triggers, pointer scrubber, keyboard stepping, wheel, Field integration. Validation is ambivalent — built-in pure sium functions by default, or delegate to a sium schema via the validate prop (untilFix feedback on invalid units). sium: parseCssValue/formatCssValue/isCssValue/cssValue + CSS unit vocabulary in arts/sium/types/css.ts; css_value issue code + es/en messages; tests. morfo/soma/sema/eidos: css-field across all layers; recipe tokens + interactive demo + sidebar link + READMEs. Verified: check 0 errors; sium 395, soma css-field 9, morfo+sema 214 tests; morfo:check + eidos-lint clean; browser render/behavior/sema-trace confirmed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>active-uix
parent
e277fd0547
commit
7ff1738a12
@ -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');
|
||||
});
|
||||
});
|
||||
@ -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<string, string> {
|
||||
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 }
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
import * as CssField from '$soma/components/css-field';
|
||||
import type { CssFieldDecrementTriggerProps } from './types';
|
||||
|
||||
let { children, ...rest }: CssFieldDecrementTriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<CssField.DecrementTrigger {...rest}>
|
||||
{@render children?.()}
|
||||
</CssField.DecrementTrigger>
|
||||
@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
import * as CssField from '$soma/components/css-field';
|
||||
import type { CssFieldIncrementTriggerProps } from './types';
|
||||
|
||||
let { children, ...rest }: CssFieldIncrementTriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<CssField.IncrementTrigger {...rest}>
|
||||
{@render children?.()}
|
||||
</CssField.IncrementTrigger>
|
||||
@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
import * as CssField from '$soma/components/css-field';
|
||||
import type { CssFieldInputProps } from './types';
|
||||
|
||||
let { children, ...rest }: CssFieldInputProps = $props();
|
||||
</script>
|
||||
|
||||
<CssField.Input {...rest}>
|
||||
{@render children?.()}
|
||||
</CssField.Input>
|
||||
@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
import * as CssField from '$soma/components/css-field';
|
||||
import type { CssFieldScrubberProps } from './types';
|
||||
|
||||
let { children, ...rest }: CssFieldScrubberProps = $props();
|
||||
</script>
|
||||
|
||||
<CssField.Scrubber {...rest}>
|
||||
{@render children?.()}
|
||||
</CssField.Scrubber>
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import './css-field.css';
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
/**
|
||||
* Eidos `<CssField>` — visual wrapper over Soma's CSS-value spinbutton.
|
||||
* Soma owns parsing, validation, min/max, scrub and keyboard behavior.
|
||||
*/
|
||||
import * as CssField from '$soma/components/css-field';
|
||||
import type { CssFieldProps } from './types';
|
||||
|
||||
let {
|
||||
size = 'md',
|
||||
variant = 'surface',
|
||||
color = 'primary',
|
||||
value = $bindable(),
|
||||
children,
|
||||
...rest
|
||||
}: CssFieldProps = $props();
|
||||
|
||||
const eidos = ActiveEidos.require();
|
||||
const resolvedSize = $derived(eidos.resolve(size, 'md'));
|
||||
</script>
|
||||
|
||||
<CssField.Provider
|
||||
{...rest}
|
||||
bind:value
|
||||
data-size={resolvedSize}
|
||||
data-variant={variant}
|
||||
data-color={color}
|
||||
>
|
||||
{@render children?.()}
|
||||
</CssField.Provider>
|
||||
@ -0,0 +1,44 @@
|
||||
// CssField — eidos compound API, disciplined option C.
|
||||
//
|
||||
// import { CssField } from '$uix/eidos/components/css-field';
|
||||
//
|
||||
// <CssField bind:value allowedUnits={['px', 'rem', '%']}>
|
||||
// <CssField.DecrementTrigger>-</CssField.DecrementTrigger>
|
||||
// <CssField.Input />
|
||||
// <CssField.IncrementTrigger>+</CssField.IncrementTrigger>
|
||||
// </CssField>
|
||||
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';
|
||||
@ -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<Size, 'xs' | 'sm' | 'md' | 'lg' | 'xl'>;
|
||||
export type CssFieldVariant = ControlVariant;
|
||||
export type CssFieldColor = ColorRole;
|
||||
|
||||
/**
|
||||
* Props for the eidos `<CssField>`.
|
||||
*
|
||||
* 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<CssFieldSize>;
|
||||
/** 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 };
|
||||
@ -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;
|
||||
@ -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<typeof semaSelector<typeof cssFieldMorfo>>[2]) =>
|
||||
semaSelector(cssFieldMorfo, 'provider', matchers);
|
||||
|
||||
const onInput = (matchers?: Parameters<typeof semaSelector<typeof cssFieldMorfo>>[2]) =>
|
||||
semaSelector(cssFieldMorfo, 'input', matchers);
|
||||
|
||||
const onScrubber = (matchers?: Parameters<typeof semaSelector<typeof cssFieldMorfo>>[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' }
|
||||
}
|
||||
]
|
||||
};
|
||||
@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '$active-uix/id';
|
||||
import { CssFieldDecrementTriggerProvider } from '../css-field-provider.svelte';
|
||||
import type { CssFieldDecrementTriggerProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'css-field-decrement'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: CssFieldDecrementTriggerProps = $props();
|
||||
|
||||
const state = CssFieldDecrementTriggerProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<button {...mergedProps}>{@render children?.()}</button>
|
||||
{/if}
|
||||
@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '$active-uix/id';
|
||||
import { CssFieldIncrementTriggerProvider } from '../css-field-provider.svelte';
|
||||
import type { CssFieldIncrementTriggerProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'css-field-increment'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: CssFieldIncrementTriggerProps = $props();
|
||||
|
||||
const state = CssFieldIncrementTriggerProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<button {...mergedProps}>{@render children?.()}</button>
|
||||
{/if}
|
||||
@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '$active-uix/id';
|
||||
import { CssFieldInputProvider } from '../css-field-provider.svelte';
|
||||
import type { CssFieldInputProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'css-field-input'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: CssFieldInputProps = $props();
|
||||
|
||||
const state = CssFieldInputProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<input {...mergedProps} />
|
||||
{/if}
|
||||
@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '$active-uix/id';
|
||||
import { CssFieldScrubberProvider } from '../css-field-provider.svelte';
|
||||
import type { CssFieldScrubberProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'css-field-scrubber'),
|
||||
direction = 'horizontal',
|
||||
sensitivity = 5,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: CssFieldScrubberProps = $props();
|
||||
|
||||
const state = CssFieldScrubberProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
direction: readableActive(() => direction),
|
||||
sensitivity: readableActive(() => sensitivity)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>{@render children?.()}</div>
|
||||
{/if}
|
||||
@ -0,0 +1,87 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '$active-uix/id';
|
||||
import { CssFieldProvider } from '../css-field-provider.svelte';
|
||||
import type { CssFieldProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'css-field'),
|
||||
value = $bindable(),
|
||||
allowedUnits,
|
||||
keywords,
|
||||
allowNegative = true,
|
||||
validate,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
largeStep,
|
||||
allowMouseWheel = false,
|
||||
clampOnBlur = true,
|
||||
dir,
|
||||
disabled = false,
|
||||
readonly: readonlyProp = false,
|
||||
required = false,
|
||||
invalid = false,
|
||||
name,
|
||||
onValueChange,
|
||||
onValueCommit,
|
||||
onInvalid,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: CssFieldProps = $props();
|
||||
|
||||
const state = CssFieldProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
value: writableActive(
|
||||
() => value,
|
||||
(v) => {
|
||||
value = v;
|
||||
onValueChange?.(v);
|
||||
}
|
||||
),
|
||||
allowedUnits: readableActive(() => allowedUnits),
|
||||
keywords: readableActive(() => keywords),
|
||||
allowNegative: readableActive(() => allowNegative),
|
||||
validate: readableActive(() => validate),
|
||||
min: readableActive(() => min),
|
||||
max: readableActive(() => max),
|
||||
step: readableActive(() => step),
|
||||
largeStep: readableActive(() => largeStep),
|
||||
dir: readableActive(() => dir),
|
||||
clampOnBlur: readableActive(() => clampOnBlur),
|
||||
allowMouseWheel: readableActive(() => allowMouseWheel),
|
||||
disabled: readableActive(() => disabled),
|
||||
readonly: readableActive(() => readonlyProp),
|
||||
required: readableActive(() => required),
|
||||
invalid: readableActive(() => invalid),
|
||||
name: readableActive(() => name),
|
||||
onValueCommit: readableActive(() => onValueCommit),
|
||||
onInvalid: readableActive(() => onInvalid)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>{@render children?.()}</div>
|
||||
{/if}
|
||||
{#if name}
|
||||
<input
|
||||
type="hidden"
|
||||
{name}
|
||||
value={value ?? ''}
|
||||
disabled={disabled || undefined}
|
||||
required={required || undefined}
|
||||
/>
|
||||
{/if}
|
||||
@ -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<T>(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<SomaRuntimeSources, 'dom' | 'eventEngine'>) =>
|
||||
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<HTMLElement | null>(null),
|
||||
value: state<string | undefined>(undefined),
|
||||
allowedUnits: state<readonly string[] | undefined>(undefined),
|
||||
keywords: state<readonly string[] | undefined>(undefined),
|
||||
allowNegative: state(true),
|
||||
validate: state<CssFieldValidate | undefined>(undefined),
|
||||
min: state<number | undefined>(undefined),
|
||||
max: state<number | undefined>(undefined),
|
||||
step: state<number | undefined>(undefined),
|
||||
largeStep: state<number | undefined>(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<string | undefined>(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<HTMLElement | null>(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<HTMLElement | null>(null)
|
||||
});
|
||||
|
||||
expect(input.props).toMatchObject({
|
||||
role: 'spinbutton',
|
||||
value: '24px',
|
||||
'aria-valuenow': 24,
|
||||
'aria-valuetext': '24px',
|
||||
'aria-required': 'true'
|
||||
});
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
});
|
||||
@ -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<string, number> = { 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<string> | 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<CssFieldProvider>('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<Direction>;
|
||||
|
||||
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<HTMLInputElement>): void => {
|
||||
this.focused = true;
|
||||
this.inputValue = this.opts.value.current ?? '';
|
||||
};
|
||||
|
||||
readonly handleBlur = (_e: SomaFocusEvent<HTMLInputElement>): 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<HTMLInputElement>): 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<HTMLInputElement>): 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)
|
||||
);
|
||||
}
|
||||
@ -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';
|
||||
@ -0,0 +1 @@
|
||||
export * from './exports';
|
||||
@ -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;
|
||||
@ -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<string | undefined>;
|
||||
/** Callback fired when value is committed (blur or Enter). Only fires with a valid value. */
|
||||
onValueCommit?: OnChangeFn<string>;
|
||||
/** Callback fired with the rejected text when a typed value fails validation. */
|
||||
onInvalid?: (value: string) => void;
|
||||
}> &
|
||||
Without<PrimitiveDivAttributes, {}>;
|
||||
|
||||
export type CssFieldInputProps = WithChild<{
|
||||
/** Unique identifier. Auto-generated if omitted. */
|
||||
id?: string;
|
||||
}> &
|
||||
Without<PrimitiveInputAttributes, {}>;
|
||||
|
||||
export type CssFieldIncrementTriggerProps = WithChild<{
|
||||
/** Unique identifier. Auto-generated if omitted. */
|
||||
id?: string;
|
||||
}> &
|
||||
Without<PrimitiveButtonAttributes, {}>;
|
||||
|
||||
export type CssFieldDecrementTriggerProps = WithChild<{
|
||||
/** Unique identifier. Auto-generated if omitted. */
|
||||
id?: string;
|
||||
}> &
|
||||
Without<PrimitiveButtonAttributes, {}>;
|
||||
|
||||
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<PrimitiveDivAttributes, {}>;
|
||||
Loading…
Reference in new issue