feat(editable): invalid + required + form + Control + better hover/disabled + defaultValue

Audit-driven improvements (7 items) to Editable:

- E1 invalid + required props
  - morfo: Provider gains data-required/data-invalid + aria-required/aria-invalid;
    Input emits data-invalid + aria-invalid.
  - soma: opts include `invalid` and `required`; runtime registration + provider
    props derivation emits the data-attrs.
  - eidos css: risk-border on Input + Control when [data-invalid].

- E2 APG: added `apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/textbox/'` to
  morfo — Editable is essentially a textbox swap.

- E3 form integration: new HiddenInput part (morfo + soma + eidos). The Provider
  wrapper auto-renders `<EditableHiddenInput>` when `name` is set, so consumers
  don't have to compose it manually. Submits value as native form data.

- E4 Control re-export: Eidos namespace now exposes `Editable.Control`,
  matching the soma surface.

- E7 submit hover: replaced `filter: brightness(1.04)` with
  `--_editable-accent-solid-hover` per color variant. Consistent with the
  other triggers' bg+border transition pattern.

- E9 disabled state: added `cursor: not-allowed` + `color: content-disabled`
  on Preview/Input/triggers, alongside the existing opacity. Clearer
  feedback than opacity alone.

- E10 defaultValue: new prop seeds `value` once via untrack when `value`
  is uncontrolled. Same pattern as the recent select.defaultValue.

Demo at /uix/components/editable: switches for invalid/required/disabled,
a Form integration section with native form submit + FormData panel,
and a defaultValue uncontrolled example.

Verified: svelte-check --threshold error → 0 errors / 0 warnings / 4578
files. Soma tests: 3/3 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 5 months ago
parent 75136e6de5
commit 7bc17485dc

@ -0,0 +1,8 @@
<script lang="ts">
import * as Editable from '$soma/components/editable';
import type { EditableHiddenInputProps } from './types';
let { ...rest }: EditableHiddenInputProps = $props();
</script>
<Editable.HiddenInput {...rest} />

@ -51,11 +51,16 @@
--_editable-font-size: var(--editable-font-size-xl);
}
[data-editable] {
--_editable-accent-solid-hover: var(--color-primary-solid-hover);
}
[data-editable][data-color='secondary'] {
--_editable-accent-solid: var(--editable-color-secondary-solid);
--_editable-accent-track: var(--editable-color-secondary-track);
--_editable-accent-border: var(--editable-color-secondary-border);
--_editable-accent-text: var(--editable-color-secondary-text);
--_editable-accent-solid-hover: var(--color-secondary-solid-hover);
}
[data-editable][data-color='neutral'] {
@ -63,6 +68,7 @@
--_editable-accent-track: var(--editable-color-neutral-track);
--_editable-accent-border: var(--editable-color-neutral-border);
--_editable-accent-text: var(--editable-color-neutral-text);
--_editable-accent-solid-hover: var(--color-neutral-solid-hover);
}
[data-editable][data-color='affirm'] {
@ -70,6 +76,7 @@
--_editable-accent-track: var(--editable-color-affirm-track);
--_editable-accent-border: var(--editable-color-affirm-border);
--_editable-accent-text: var(--editable-color-affirm-text);
--_editable-accent-solid-hover: var(--color-affirm-solid-hover);
}
[data-editable][data-color='risk'] {
@ -77,6 +84,7 @@
--_editable-accent-track: var(--editable-color-risk-track);
--_editable-accent-border: var(--editable-color-risk-border);
--_editable-accent-text: var(--editable-color-risk-text);
--_editable-accent-solid-hover: var(--color-risk-solid-hover);
}
[data-editable][data-color='threat'] {
@ -84,6 +92,7 @@
--_editable-accent-track: var(--editable-color-threat-track);
--_editable-accent-border: var(--editable-color-threat-border);
--_editable-accent-text: var(--editable-color-threat-text);
--_editable-accent-solid-hover: var(--color-threat-solid-hover);
}
[data-editable-area] {
@ -169,6 +178,22 @@
box-shadow: var(--editable-focus-shadow);
}
/* Invalid state: mirror field.css risk-border treatment on Input + Control. */
[data-editable][data-invalid] [data-editable-input],
[data-editable-input][data-invalid] {
border-color: var(--editable-color-risk-border);
}
[data-editable][data-invalid] [data-editable-control],
[data-editable-control][data-invalid] {
border-color: var(--editable-color-risk-border);
}
[data-editable][data-invalid] [data-editable-preview]:focus-visible,
[data-editable][data-invalid] [data-editable-input]:focus-visible {
border-color: var(--editable-color-risk-border);
}
[data-editable-preview][data-placeholder-shown],
[data-editable-input]::placeholder {
color: var(--editable-placeholder-color);
@ -211,7 +236,8 @@
}
[data-editable-submit-trigger]:hover:not(:disabled) {
filter: brightness(var(--editable-submit-hover-brightness));
background: var(--_editable-accent-solid-hover);
border-color: var(--_editable-accent-solid-hover);
}
[data-editable-edit-trigger]:focus-visible,
@ -241,10 +267,24 @@
[data-editable-edit-trigger][data-disabled],
[data-editable-submit-trigger][data-disabled],
[data-editable-cancel-trigger][data-disabled] {
cursor: default;
cursor: not-allowed;
opacity: var(--editable-disabled-opacity);
}
[data-editable][data-disabled] [data-editable-preview],
[data-editable][data-disabled] [data-editable-input],
[data-editable][data-disabled] [data-editable-edit-trigger],
[data-editable][data-disabled] [data-editable-submit-trigger],
[data-editable][data-disabled] [data-editable-cancel-trigger],
[data-editable-preview][data-disabled],
[data-editable-input][data-disabled],
[data-editable-edit-trigger][data-disabled],
[data-editable-submit-trigger][data-disabled],
[data-editable-cancel-trigger][data-disabled] {
color: var(--color-content-disabled, var(--color-content-muted));
cursor: not-allowed;
}
@media (prefers-reduced-motion: reduce) {
[data-editable-preview],
[data-editable-input],

@ -7,7 +7,7 @@
size = 'md',
variant = 'surface',
color = 'primary',
value = $bindable(''),
value = $bindable(),
editing = $bindable(false),
children: bodyContent,
...rest

@ -3,6 +3,7 @@ import Area from './editable-area.svelte';
import Control from './editable-control.svelte';
import Preview from './editable-preview.svelte';
import Input from './editable-input.svelte';
import HiddenInput from './editable-hidden-input.svelte';
import EditTrigger from './editable-edit-trigger.svelte';
import SubmitTrigger from './editable-submit-trigger.svelte';
import CancelTrigger from './editable-cancel-trigger.svelte';
@ -12,6 +13,7 @@ type EditableNamespace = typeof EditableComponent & {
Control: typeof Control;
Preview: typeof Preview;
Input: typeof Input;
HiddenInput: typeof HiddenInput;
EditTrigger: typeof EditTrigger;
SubmitTrigger: typeof SubmitTrigger;
CancelTrigger: typeof CancelTrigger;
@ -22,6 +24,7 @@ Editable.Area = Area;
Editable.Control = Control;
Editable.Preview = Preview;
Editable.Input = Input;
Editable.HiddenInput = HiddenInput;
Editable.EditTrigger = EditTrigger;
Editable.SubmitTrigger = SubmitTrigger;
Editable.CancelTrigger = CancelTrigger;
@ -36,6 +39,7 @@ export type {
EditableControlProps as ControlProps,
EditablePreviewProps as PreviewProps,
EditableInputProps as InputProps,
EditableHiddenInputProps as HiddenInputProps,
EditableEditTriggerProps as EditTriggerProps,
EditableSubmitTriggerProps as SubmitTriggerProps,
EditableCancelTriggerProps as CancelTriggerProps,

@ -3,6 +3,7 @@ import type {
CancelTriggerProps,
ControlProps,
EditTriggerProps,
HiddenInputProps,
InputProps,
PreviewProps,
ProviderProps,
@ -27,6 +28,7 @@ export type EditableAreaProps = AreaProps;
export type EditableControlProps = ControlProps;
export type EditablePreviewProps = PreviewProps;
export type EditableInputProps = InputProps;
export type EditableHiddenInputProps = HiddenInputProps;
export type EditableEditTriggerProps = EditTriggerProps;
export type EditableSubmitTriggerProps = SubmitTriggerProps;
export type EditableCancelTriggerProps = CancelTriggerProps;

@ -8,6 +8,7 @@ export const editableMorfo = {
texts: {
label: '#?components.editable.label|Editable'
},
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/textbox/',
events: [
{
name: 'shift-enter-mode',
@ -51,9 +52,24 @@ export const editableMorfo = {
{ attr: 'data-editing', value: v.propRef('editing'), severity: 'optional' },
{ attr: 'data-empty', value: v.propRef('empty'), severity: 'optional' },
{ attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' },
{ attr: 'data-readonly', value: v.propRef('readonly'), 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' }
],
aria: []
aria: [
{
attr: 'aria-required',
value: v.literal('true'),
severity: 'optional',
condition: { when: 'prop-truthy', prop: 'required' }
},
{
attr: 'aria-invalid',
value: v.literal('true'),
severity: 'optional',
condition: { when: 'prop-truthy', prop: 'invalid' }
}
]
},
{
name: 'Area',
@ -108,7 +124,8 @@ export const editableMorfo = {
optional: false,
data: [
{ attr: 'data-disabled', severity: 'optional' },
{ attr: 'data-readonly', severity: 'optional' }
{ attr: 'data-readonly', severity: 'optional' },
{ attr: 'data-invalid', severity: 'optional' }
],
aria: [
{
@ -116,6 +133,12 @@ export const editableMorfo = {
value: v.literal('true'),
severity: 'optional',
condition: { when: 'prop-truthy', prop: 'disabled' }
},
{
attr: 'aria-invalid',
value: v.literal('true'),
severity: 'optional',
condition: { when: 'prop-truthy', prop: 'invalid' }
}
],
keyboard: [
@ -123,6 +146,16 @@ export const editableMorfo = {
{ key: 'Escape', action: 'cancel' }
]
},
{
name: 'HiddenInput',
kebab: 'hidden-input',
archetype: 'input',
kind: 'public',
defaultElement: 'input',
optional: true,
data: [],
aria: [{ attr: 'aria-hidden', value: v.literal('true') }]
},
{
name: 'EditTrigger',
kebab: 'edit-trigger',

@ -0,0 +1,32 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { EditableHiddenInputProvider } from '../editable-provider.svelte';
import type { EditableHiddenInputProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'editable-hidden-input'),
child,
...restProps
}: EditableHiddenInputProps = $props();
const state = EditableHiddenInputProvider.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}

@ -1,4 +1,5 @@
<script lang="ts">
import { untrack } from 'svelte';
import {
readableActive,
writableActive
@ -7,6 +8,7 @@
import { createId } from '../../../id';
import { EditableProvider } from '../editable-provider.svelte';
import type { EditableProps } from '../types';
import EditableHiddenInput from './editable-hidden-input.svelte';
const uid = $props.id();
@ -14,7 +16,17 @@
let {
ref = $bindable(null),
id = createId(uid, 'editable'),
value = $bindable(''),
/**
* Bindable committed value. When provided, takes precedence over
* `defaultValue`. Use this for controlled usage.
*/
value = $bindable(),
/**
* Initial value used only when `value` is undefined. Read once at mount
* via `untrack`; later mutations are the consumer's responsibility via
* `value`.
*/
defaultValue,
editing = $bindable(false),
placeholder = '',
activationMode = 'focus',
@ -24,6 +36,9 @@
maxLength,
disabled = false,
readonly: readonlyProp = false,
invalid = false,
required = false,
name = '',
onValueChange = () => {},
onValueCommit = () => {},
onValueRevert = () => {},
@ -33,6 +48,12 @@
...restProps
}: EditableProps = $props();
// Seed `value` from `defaultValue` when uncontrolled. Read once at init
// via untrack — defaultValue is intentionally not reactive.
if (value === undefined) {
value = untrack(() => defaultValue ?? '');
}
const state = EditableProvider.create({
id: readableActive(() => id),
ref: writableActive(
@ -40,7 +61,7 @@
(v) => (ref = v)
),
value: writableActive(
() => value,
() => value ?? '',
(v) => {
value = v;
onValueChange(v);
@ -61,6 +82,9 @@
maxLength: readableActive(() => maxLength),
disabled: readableActive(() => disabled),
readonly: readableActive(() => readonlyProp),
invalid: readableActive(() => invalid),
required: readableActive(() => required),
name: readableActive(() => name),
onValueCommit: readableActive(() => onValueCommit),
onValueRevert: readableActive(() => onValueRevert)
});
@ -68,10 +92,18 @@
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#snippet hiddenInput()}
{#if name}
<EditableHiddenInput />
{/if}
{/snippet}
{#if child}
{@render child({ props: mergedProps })}
{@render hiddenInput()}
{:else}
<div {...mergedProps}>
{@render children?.()}
{@render hiddenInput()}
</div>
{/if}

@ -68,6 +68,9 @@ function editableOpts(root = document.createElement('div')) {
maxLength: state<number | undefined>(20),
disabled: state(false),
readonly: state(false),
invalid: state(false),
required: state(false),
name: state(''),
onValueCommit: state(vi.fn()),
onValueRevert: state(vi.fn())
};

@ -31,6 +31,9 @@ interface EditableOpts
maxLength: number | undefined;
disabled: boolean;
readonly: boolean;
invalid: boolean;
required: boolean;
name: string;
onValueCommit: OnChangeFn<string>;
onValueRevert: () => void;
}> {}
@ -65,7 +68,9 @@ export class EditableProvider {
editing: () => this.isEditing,
empty: () => this.isEmpty,
disabled: () => this.opts.disabled.current,
readonly: () => this.opts.readonly.current
readonly: () => this.opts.readonly.current,
invalid: () => this.opts.invalid.current,
required: () => this.opts.required.current
}
});
this.runtimePart = this.runtime.part('provider', {
@ -149,7 +154,11 @@ export class EditableProvider {
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props
...this.runtimePart.props,
'data-required': boolToEmptyStrOrUndef(this.opts.required.current),
'data-invalid': boolToEmptyStrOrUndef(this.opts.invalid.current),
'aria-required': this.opts.required.current || undefined,
'aria-invalid': this.opts.invalid.current || undefined
} as const)
);
}
@ -356,8 +365,10 @@ export class EditableInputProvider {
disabled: this.provider.opts.disabled.current || undefined,
readonly: this.provider.opts.readonly.current || undefined,
'aria-disabled': this.provider.opts.disabled.current || undefined,
'aria-invalid': this.provider.opts.invalid.current || undefined,
'data-disabled': boolToEmptyStrOrUndef(this.provider.opts.disabled.current),
'data-readonly': boolToEmptyStrOrUndef(this.provider.opts.readonly.current),
'data-invalid': boolToEmptyStrOrUndef(this.provider.opts.invalid.current),
style: this.provider.opts.autoResize.current
? {
'field-sizing': 'content',
@ -465,6 +476,43 @@ export class EditableSubmitTriggerProvider {
);
}
// ── HiddenInput ──────────────────────────────────────────────────────────────
interface EditableHiddenInputOpts extends WithRefOpts {}
export class EditableHiddenInputProvider {
readonly opts: EditableHiddenInputOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: EditableHiddenInputOpts) {
return new EditableHiddenInputProvider(opts);
}
readonly provider: EditableProvider;
private constructor(opts: EditableHiddenInputOpts) {
this.opts = opts;
this.provider = EditableProvider.require();
this.runtimePart = this.provider.runtime.part('hidden-input', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
type: 'hidden' as const,
name: this.provider.opts.name.current || undefined,
value: this.provider.opts.value.current,
required: this.provider.opts.required.current || undefined,
disabled: this.provider.opts.disabled.current || undefined,
'aria-hidden': 'true' as const,
tabindex: -1
} as const)
);
}
// ── CancelTrigger ────────────────────────────────────────────────────────────
interface EditableCancelTriggerOpts extends WithRefOpts {}

@ -3,6 +3,7 @@ export { default as Area } from './components/editable-area.svelte';
export { default as Control } from './components/editable-control.svelte';
export { default as Preview } from './components/editable-preview.svelte';
export { default as Input } from './components/editable-input.svelte';
export { default as HiddenInput } from './components/editable-hidden-input.svelte';
export { default as EditTrigger } from './components/editable-edit-trigger.svelte';
export { default as SubmitTrigger } from './components/editable-submit-trigger.svelte';
export { default as CancelTrigger } from './components/editable-cancel-trigger.svelte';
@ -15,6 +16,7 @@ export type {
EditableControlProps as ControlProps,
EditablePreviewProps as PreviewProps,
EditableInputProps as InputProps,
EditableHiddenInputProps as HiddenInputProps,
EditableEditTriggerProps as EditTriggerProps,
EditableSubmitTriggerProps as SubmitTriggerProps,
EditableCancelTriggerProps as CancelTriggerProps

@ -13,10 +13,22 @@ export type EditableProps = WithChild<{
id?: string;
/** Current committed value. Bindable. @default '' */
value?: string;
/**
* Initial value used only when `value` is undefined. Equivalent to
* React-style "uncontrolled with initial value". Read once at mount;
* later mutations are the consumer's responsibility via `value`.
*/
defaultValue?: string;
/** Whether in edit mode. Bindable. @default false */
editing?: boolean;
/** Placeholder text when value is empty. @default '' */
placeholder?: string;
/** Form field name. When set, a hidden input is auto-rendered for native form submission. */
name?: string;
/** Marks the field as required. Mirrors the HTML `required` attribute on the hidden input. @default false */
required?: boolean;
/** Marks the field as invalid for accessibility + styling. @default false */
invalid?: boolean;
/**
* How edit mode is activated.
* 'focus': focus the preview to start editing.
@ -97,3 +109,9 @@ export type EditableCancelTriggerProps = WithChild<{
id?: string;
}> &
Without<PrimitiveButtonAttributes, {}>;
export type EditableHiddenInputProps = WithChild<{
/** Unique identifier. Auto-generated if omitted. */
id?: string;
}> &
Without<PrimitiveInputAttributes, {}>;

@ -29,11 +29,32 @@
let maxLength = $state(48);
let disabled = $state(params.get('perm.disabled') === 'true');
let readonly = $state(params.get('perm.readonly') === 'true');
let invalid = $state(false);
let required = $state(false);
let size = $state<EditableSize>('md');
let variant = $state<EditableVariant>('surface');
let color = $state<EditableColor>('primary');
let lastCommit = $state('');
// Form integration demo
let formName = $state('project_name');
let formValue = $state('Form initial');
let lastSubmitted = $state<Record<string, string> | null>(null);
function onFormSubmit(e: SubmitEvent) {
e.preventDefault();
const form = e.currentTarget as HTMLFormElement;
const data = new FormData(form);
const obj: Record<string, string> = {};
for (const [k, v] of data.entries()) {
obj[k] = typeof v === 'string' ? v : '[file]';
}
lastSubmitted = obj;
}
// defaultValue demo (uncontrolled)
let defaultValueCommit = $state('');
type TraceEntry = { event: string; family: string; intent?: string; at: number };
let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null);
@ -138,7 +159,7 @@
</script>
<svelte:head>
<meta name="perm-axes" content="disabled,readonly" />
<meta name="perm-axes" content="disabled,readonly,invalid,required" />
</svelte:head>
<div data-uix-canvas-inner>
@ -174,6 +195,8 @@
{maxLength}
{disabled}
{readonly}
{invalid}
{required}
{size}
{variant}
{color}
@ -293,7 +316,7 @@
<span data-uix-control-label>maxLength</span>
<input type="number" min="1" max="96" bind:value={maxLength} />
</label>
{#each ['selectOnFocus', 'autoResize', 'disabled', 'readonly'] as key}
{#each ['selectOnFocus', 'autoResize', 'disabled', 'readonly', 'invalid', 'required'] as key}
<label data-uix-control>
<span data-uix-control-label>{key}</span>
<span data-uix-switch>
@ -306,9 +329,15 @@
{:else if key === 'disabled'}
<input type="checkbox" bind:checked={disabled} />
<span data-uix-switch-label>{disabled ? 'on' : 'off'}</span>
{:else}
{:else if key === 'readonly'}
<input type="checkbox" bind:checked={readonly} />
<span data-uix-switch-label>{readonly ? 'on' : 'off'}</span>
{:else if key === 'invalid'}
<input type="checkbox" bind:checked={invalid} />
<span data-uix-switch-label>{invalid ? 'on' : 'off'}</span>
{:else}
<input type="checkbox" bind:checked={required} />
<span data-uix-switch-label>{required ? 'on' : 'off'}</span>
{/if}
</span>
</label>
@ -355,6 +384,67 @@
</label>
</div>
<div data-uix-subsection-head>Form integration</div>
<p data-uix-section-desc>
Set a <code>name</code> to auto-render a hidden input. The Editable then participates in
native <code>&lt;form&gt;</code> submission like any text input.
</p>
<form
onsubmit={onFormSubmit}
style="display: flex; flex-direction: column; gap: var(--uix-space-2); align-items: flex-start;"
>
<label data-uix-control style="max-width: 240px;">
<span data-uix-control-label>name</span>
<input bind:value={formName} />
</label>
<Editable bind:value={formValue} name={formName} activationMode="click" submitMode="both">
<Editable.Area>
<Editable.Preview />
<Editable.Input />
</Editable.Area>
<Editable.Control>
<Editable.EditTrigger>Edit</Editable.EditTrigger>
<Editable.SubmitTrigger>Save</Editable.SubmitTrigger>
<Editable.CancelTrigger>Cancel</Editable.CancelTrigger>
</Editable.Control>
</Editable>
<button data-uix-play type="submit">Submit form</button>
{#if lastSubmitted}
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>FormData</span>
{#each Object.entries(lastSubmitted) as [k, v]}
<span><code>{k}</code> = <code>{JSON.stringify(v)}</code></span>
{/each}
</div>
{/if}
</form>
<div data-uix-subsection-head>defaultValue (uncontrolled)</div>
<p data-uix-section-desc>
When <code>value</code> is not bound, <code>defaultValue</code> seeds the initial draft.
Subsequent edits stay internal — the consumer never sees them unless they bind
<code>value</code> or wire <code>onValueCommit</code>.
</p>
<Editable
defaultValue="Seeded by defaultValue"
activationMode="click"
submitMode="both"
onValueCommit={(v) => (defaultValueCommit = v)}
>
<Editable.Area>
<Editable.Preview />
<Editable.Input />
</Editable.Area>
<Editable.Control>
<Editable.EditTrigger>Edit</Editable.EditTrigger>
<Editable.SubmitTrigger>Save</Editable.SubmitTrigger>
<Editable.CancelTrigger>Cancel</Editable.CancelTrigger>
</Editable.Control>
</Editable>
<p data-uix-section-desc>
Last commit: <code>{defaultValueCommit || '-'}</code>
</p>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="soma">soma</span>

Loading…
Cancel
Save

Powered by TurnKey Linux.