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); --_editable-font-size: var(--editable-font-size-xl);
} }
[data-editable] {
--_editable-accent-solid-hover: var(--color-primary-solid-hover);
}
[data-editable][data-color='secondary'] { [data-editable][data-color='secondary'] {
--_editable-accent-solid: var(--editable-color-secondary-solid); --_editable-accent-solid: var(--editable-color-secondary-solid);
--_editable-accent-track: var(--editable-color-secondary-track); --_editable-accent-track: var(--editable-color-secondary-track);
--_editable-accent-border: var(--editable-color-secondary-border); --_editable-accent-border: var(--editable-color-secondary-border);
--_editable-accent-text: var(--editable-color-secondary-text); --_editable-accent-text: var(--editable-color-secondary-text);
--_editable-accent-solid-hover: var(--color-secondary-solid-hover);
} }
[data-editable][data-color='neutral'] { [data-editable][data-color='neutral'] {
@ -63,6 +68,7 @@
--_editable-accent-track: var(--editable-color-neutral-track); --_editable-accent-track: var(--editable-color-neutral-track);
--_editable-accent-border: var(--editable-color-neutral-border); --_editable-accent-border: var(--editable-color-neutral-border);
--_editable-accent-text: var(--editable-color-neutral-text); --_editable-accent-text: var(--editable-color-neutral-text);
--_editable-accent-solid-hover: var(--color-neutral-solid-hover);
} }
[data-editable][data-color='affirm'] { [data-editable][data-color='affirm'] {
@ -70,6 +76,7 @@
--_editable-accent-track: var(--editable-color-affirm-track); --_editable-accent-track: var(--editable-color-affirm-track);
--_editable-accent-border: var(--editable-color-affirm-border); --_editable-accent-border: var(--editable-color-affirm-border);
--_editable-accent-text: var(--editable-color-affirm-text); --_editable-accent-text: var(--editable-color-affirm-text);
--_editable-accent-solid-hover: var(--color-affirm-solid-hover);
} }
[data-editable][data-color='risk'] { [data-editable][data-color='risk'] {
@ -77,6 +84,7 @@
--_editable-accent-track: var(--editable-color-risk-track); --_editable-accent-track: var(--editable-color-risk-track);
--_editable-accent-border: var(--editable-color-risk-border); --_editable-accent-border: var(--editable-color-risk-border);
--_editable-accent-text: var(--editable-color-risk-text); --_editable-accent-text: var(--editable-color-risk-text);
--_editable-accent-solid-hover: var(--color-risk-solid-hover);
} }
[data-editable][data-color='threat'] { [data-editable][data-color='threat'] {
@ -84,6 +92,7 @@
--_editable-accent-track: var(--editable-color-threat-track); --_editable-accent-track: var(--editable-color-threat-track);
--_editable-accent-border: var(--editable-color-threat-border); --_editable-accent-border: var(--editable-color-threat-border);
--_editable-accent-text: var(--editable-color-threat-text); --_editable-accent-text: var(--editable-color-threat-text);
--_editable-accent-solid-hover: var(--color-threat-solid-hover);
} }
[data-editable-area] { [data-editable-area] {
@ -169,6 +178,22 @@
box-shadow: var(--editable-focus-shadow); 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-preview][data-placeholder-shown],
[data-editable-input]::placeholder { [data-editable-input]::placeholder {
color: var(--editable-placeholder-color); color: var(--editable-placeholder-color);
@ -211,7 +236,8 @@
} }
[data-editable-submit-trigger]:hover:not(:disabled) { [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, [data-editable-edit-trigger]:focus-visible,
@ -241,10 +267,24 @@
[data-editable-edit-trigger][data-disabled], [data-editable-edit-trigger][data-disabled],
[data-editable-submit-trigger][data-disabled], [data-editable-submit-trigger][data-disabled],
[data-editable-cancel-trigger][data-disabled] { [data-editable-cancel-trigger][data-disabled] {
cursor: default; cursor: not-allowed;
opacity: var(--editable-disabled-opacity); 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) { @media (prefers-reduced-motion: reduce) {
[data-editable-preview], [data-editable-preview],
[data-editable-input], [data-editable-input],

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

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

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

@ -8,6 +8,7 @@ export const editableMorfo = {
texts: { texts: {
label: '#?components.editable.label|Editable' label: '#?components.editable.label|Editable'
}, },
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/textbox/',
events: [ events: [
{ {
name: 'shift-enter-mode', name: 'shift-enter-mode',
@ -51,9 +52,24 @@ export const editableMorfo = {
{ attr: 'data-editing', value: v.propRef('editing'), severity: 'optional' }, { attr: 'data-editing', value: v.propRef('editing'), severity: 'optional' },
{ attr: 'data-empty', value: v.propRef('empty'), severity: 'optional' }, { attr: 'data-empty', value: v.propRef('empty'), severity: 'optional' },
{ attr: 'data-disabled', value: v.propRef('disabled'), 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', name: 'Area',
@ -108,7 +124,8 @@ export const editableMorfo = {
optional: false, optional: false,
data: [ data: [
{ attr: 'data-disabled', severity: 'optional' }, { attr: 'data-disabled', severity: 'optional' },
{ attr: 'data-readonly', severity: 'optional' } { attr: 'data-readonly', severity: 'optional' },
{ attr: 'data-invalid', severity: 'optional' }
], ],
aria: [ aria: [
{ {
@ -116,6 +133,12 @@ export const editableMorfo = {
value: v.literal('true'), value: v.literal('true'),
severity: 'optional', severity: 'optional',
condition: { when: 'prop-truthy', prop: 'disabled' } condition: { when: 'prop-truthy', prop: 'disabled' }
},
{
attr: 'aria-invalid',
value: v.literal('true'),
severity: 'optional',
condition: { when: 'prop-truthy', prop: 'invalid' }
} }
], ],
keyboard: [ keyboard: [
@ -123,6 +146,16 @@ export const editableMorfo = {
{ key: 'Escape', action: 'cancel' } { 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', name: 'EditTrigger',
kebab: 'edit-trigger', 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"> <script lang="ts">
import { untrack } from 'svelte';
import { import {
readableActive, readableActive,
writableActive writableActive
@ -7,6 +8,7 @@
import { createId } from '../../../id'; import { createId } from '../../../id';
import { EditableProvider } from '../editable-provider.svelte'; import { EditableProvider } from '../editable-provider.svelte';
import type { EditableProps } from '../types'; import type { EditableProps } from '../types';
import EditableHiddenInput from './editable-hidden-input.svelte';
const uid = $props.id(); const uid = $props.id();
@ -14,7 +16,17 @@
let { let {
ref = $bindable(null), ref = $bindable(null),
id = createId(uid, 'editable'), 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), editing = $bindable(false),
placeholder = '', placeholder = '',
activationMode = 'focus', activationMode = 'focus',
@ -24,6 +36,9 @@
maxLength, maxLength,
disabled = false, disabled = false,
readonly: readonlyProp = false, readonly: readonlyProp = false,
invalid = false,
required = false,
name = '',
onValueChange = () => {}, onValueChange = () => {},
onValueCommit = () => {}, onValueCommit = () => {},
onValueRevert = () => {}, onValueRevert = () => {},
@ -33,6 +48,12 @@
...restProps ...restProps
}: EditableProps = $props(); }: 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({ const state = EditableProvider.create({
id: readableActive(() => id), id: readableActive(() => id),
ref: writableActive( ref: writableActive(
@ -40,7 +61,7 @@
(v) => (ref = v) (v) => (ref = v)
), ),
value: writableActive( value: writableActive(
() => value, () => value ?? '',
(v) => { (v) => {
value = v; value = v;
onValueChange(v); onValueChange(v);
@ -61,6 +82,9 @@
maxLength: readableActive(() => maxLength), maxLength: readableActive(() => maxLength),
disabled: readableActive(() => disabled), disabled: readableActive(() => disabled),
readonly: readableActive(() => readonlyProp), readonly: readableActive(() => readonlyProp),
invalid: readableActive(() => invalid),
required: readableActive(() => required),
name: readableActive(() => name),
onValueCommit: readableActive(() => onValueCommit), onValueCommit: readableActive(() => onValueCommit),
onValueRevert: readableActive(() => onValueRevert) onValueRevert: readableActive(() => onValueRevert)
}); });
@ -68,10 +92,18 @@
const mergedProps = $derived(mergeProps(restProps, state.props)); const mergedProps = $derived(mergeProps(restProps, state.props));
</script> </script>
{#snippet hiddenInput()}
{#if name}
<EditableHiddenInput />
{/if}
{/snippet}
{#if child} {#if child}
{@render child({ props: mergedProps })} {@render child({ props: mergedProps })}
{@render hiddenInput()}
{:else} {:else}
<div {...mergedProps}> <div {...mergedProps}>
{@render children?.()} {@render children?.()}
{@render hiddenInput()}
</div> </div>
{/if} {/if}

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

@ -31,6 +31,9 @@ interface EditableOpts
maxLength: number | undefined; maxLength: number | undefined;
disabled: boolean; disabled: boolean;
readonly: boolean; readonly: boolean;
invalid: boolean;
required: boolean;
name: string;
onValueCommit: OnChangeFn<string>; onValueCommit: OnChangeFn<string>;
onValueRevert: () => void; onValueRevert: () => void;
}> {} }> {}
@ -65,7 +68,9 @@ export class EditableProvider {
editing: () => this.isEditing, editing: () => this.isEditing,
empty: () => this.isEmpty, empty: () => this.isEmpty,
disabled: () => this.opts.disabled.current, 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', { this.runtimePart = this.runtime.part('provider', {
@ -149,7 +154,11 @@ export class EditableProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ 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) } as const)
); );
} }
@ -356,8 +365,10 @@ export class EditableInputProvider {
disabled: this.provider.opts.disabled.current || undefined, disabled: this.provider.opts.disabled.current || undefined,
readonly: this.provider.opts.readonly.current || undefined, readonly: this.provider.opts.readonly.current || undefined,
'aria-disabled': this.provider.opts.disabled.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-disabled': boolToEmptyStrOrUndef(this.provider.opts.disabled.current),
'data-readonly': boolToEmptyStrOrUndef(this.provider.opts.readonly.current), 'data-readonly': boolToEmptyStrOrUndef(this.provider.opts.readonly.current),
'data-invalid': boolToEmptyStrOrUndef(this.provider.opts.invalid.current),
style: this.provider.opts.autoResize.current style: this.provider.opts.autoResize.current
? { ? {
'field-sizing': 'content', '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 ──────────────────────────────────────────────────────────── // ── CancelTrigger ────────────────────────────────────────────────────────────
interface EditableCancelTriggerOpts extends WithRefOpts {} 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 Control } from './components/editable-control.svelte';
export { default as Preview } from './components/editable-preview.svelte'; export { default as Preview } from './components/editable-preview.svelte';
export { default as Input } from './components/editable-input.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 EditTrigger } from './components/editable-edit-trigger.svelte';
export { default as SubmitTrigger } from './components/editable-submit-trigger.svelte'; export { default as SubmitTrigger } from './components/editable-submit-trigger.svelte';
export { default as CancelTrigger } from './components/editable-cancel-trigger.svelte'; export { default as CancelTrigger } from './components/editable-cancel-trigger.svelte';
@ -15,6 +16,7 @@ export type {
EditableControlProps as ControlProps, EditableControlProps as ControlProps,
EditablePreviewProps as PreviewProps, EditablePreviewProps as PreviewProps,
EditableInputProps as InputProps, EditableInputProps as InputProps,
EditableHiddenInputProps as HiddenInputProps,
EditableEditTriggerProps as EditTriggerProps, EditableEditTriggerProps as EditTriggerProps,
EditableSubmitTriggerProps as SubmitTriggerProps, EditableSubmitTriggerProps as SubmitTriggerProps,
EditableCancelTriggerProps as CancelTriggerProps EditableCancelTriggerProps as CancelTriggerProps

@ -13,10 +13,22 @@ export type EditableProps = WithChild<{
id?: string; id?: string;
/** Current committed value. Bindable. @default '' */ /** Current committed value. Bindable. @default '' */
value?: string; 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 */ /** Whether in edit mode. Bindable. @default false */
editing?: boolean; editing?: boolean;
/** Placeholder text when value is empty. @default '' */ /** Placeholder text when value is empty. @default '' */
placeholder?: string; 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. * How edit mode is activated.
* 'focus': focus the preview to start editing. * 'focus': focus the preview to start editing.
@ -97,3 +109,9 @@ export type EditableCancelTriggerProps = WithChild<{
id?: string; id?: string;
}> & }> &
Without<PrimitiveButtonAttributes, {}>; 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 maxLength = $state(48);
let disabled = $state(params.get('perm.disabled') === 'true'); let disabled = $state(params.get('perm.disabled') === 'true');
let readonly = $state(params.get('perm.readonly') === 'true'); let readonly = $state(params.get('perm.readonly') === 'true');
let invalid = $state(false);
let required = $state(false);
let size = $state<EditableSize>('md'); let size = $state<EditableSize>('md');
let variant = $state<EditableVariant>('surface'); let variant = $state<EditableVariant>('surface');
let color = $state<EditableColor>('primary'); let color = $state<EditableColor>('primary');
let lastCommit = $state(''); 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 }; type TraceEntry = { event: string; family: string; intent?: string; at: number };
let trace = $state<TraceEntry[]>([]); let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null); let stageRef = $state<HTMLElement | null>(null);
@ -138,7 +159,7 @@
</script> </script>
<svelte:head> <svelte:head>
<meta name="perm-axes" content="disabled,readonly" /> <meta name="perm-axes" content="disabled,readonly,invalid,required" />
</svelte:head> </svelte:head>
<div data-uix-canvas-inner> <div data-uix-canvas-inner>
@ -174,6 +195,8 @@
{maxLength} {maxLength}
{disabled} {disabled}
{readonly} {readonly}
{invalid}
{required}
{size} {size}
{variant} {variant}
{color} {color}
@ -293,7 +316,7 @@
<span data-uix-control-label>maxLength</span> <span data-uix-control-label>maxLength</span>
<input type="number" min="1" max="96" bind:value={maxLength} /> <input type="number" min="1" max="96" bind:value={maxLength} />
</label> </label>
{#each ['selectOnFocus', 'autoResize', 'disabled', 'readonly'] as key} {#each ['selectOnFocus', 'autoResize', 'disabled', 'readonly', 'invalid', 'required'] as key}
<label data-uix-control> <label data-uix-control>
<span data-uix-control-label>{key}</span> <span data-uix-control-label>{key}</span>
<span data-uix-switch> <span data-uix-switch>
@ -306,9 +329,15 @@
{:else if key === 'disabled'} {:else if key === 'disabled'}
<input type="checkbox" bind:checked={disabled} /> <input type="checkbox" bind:checked={disabled} />
<span data-uix-switch-label>{disabled ? 'on' : 'off'}</span> <span data-uix-switch-label>{disabled ? 'on' : 'off'}</span>
{:else} {:else if key === 'readonly'}
<input type="checkbox" bind:checked={readonly} /> <input type="checkbox" bind:checked={readonly} />
<span data-uix-switch-label>{readonly ? 'on' : 'off'}</span> <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} {/if}
</span> </span>
</label> </label>
@ -355,6 +384,67 @@
</label> </label>
</div> </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>
<div data-uix-code-head> <div data-uix-code-head>
<span data-uix-layer-badge="soma">soma</span> <span data-uix-layer-badge="soma">soma</span>

Loading…
Cancel
Save

Powered by TurnKey Linux.