diff --git a/src/uix/eidos/components/editable/editable-hidden-input.svelte b/src/uix/eidos/components/editable/editable-hidden-input.svelte new file mode 100644 index 000000000..ddd7d1f41 --- /dev/null +++ b/src/uix/eidos/components/editable/editable-hidden-input.svelte @@ -0,0 +1,8 @@ + + + diff --git a/src/uix/eidos/components/editable/editable.css b/src/uix/eidos/components/editable/editable.css index f206983b6..14f766c01 100644 --- a/src/uix/eidos/components/editable/editable.css +++ b/src/uix/eidos/components/editable/editable.css @@ -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], diff --git a/src/uix/eidos/components/editable/editable.svelte b/src/uix/eidos/components/editable/editable.svelte index 27311098b..9e318314b 100644 --- a/src/uix/eidos/components/editable/editable.svelte +++ b/src/uix/eidos/components/editable/editable.svelte @@ -7,7 +7,7 @@ size = 'md', variant = 'surface', color = 'primary', - value = $bindable(''), + value = $bindable(), editing = $bindable(false), children: bodyContent, ...rest diff --git a/src/uix/eidos/components/editable/index.ts b/src/uix/eidos/components/editable/index.ts index 08395791c..102099363 100644 --- a/src/uix/eidos/components/editable/index.ts +++ b/src/uix/eidos/components/editable/index.ts @@ -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, diff --git a/src/uix/eidos/components/editable/types.ts b/src/uix/eidos/components/editable/types.ts index 8b023c64a..7f7a7a303 100644 --- a/src/uix/eidos/components/editable/types.ts +++ b/src/uix/eidos/components/editable/types.ts @@ -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; diff --git a/src/uix/morfo/components/editable.ts b/src/uix/morfo/components/editable.ts index f797eedb2..769579f99 100644 --- a/src/uix/morfo/components/editable.ts +++ b/src/uix/morfo/components/editable.ts @@ -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', diff --git a/src/uix/soma/components/editable/components/editable-hidden-input.svelte b/src/uix/soma/components/editable/components/editable-hidden-input.svelte new file mode 100644 index 000000000..e970fb9fe --- /dev/null +++ b/src/uix/soma/components/editable/components/editable-hidden-input.svelte @@ -0,0 +1,32 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/soma/components/editable/components/editable.svelte b/src/uix/soma/components/editable/components/editable.svelte index 594fde0c7..14bc38f53 100644 --- a/src/uix/soma/components/editable/components/editable.svelte +++ b/src/uix/soma/components/editable/components/editable.svelte @@ -1,4 +1,5 @@ +{#snippet hiddenInput()} + {#if name} + + {/if} +{/snippet} + {#if child} {@render child({ props: mergedProps })} + {@render hiddenInput()} {:else}
{@render children?.()} + {@render hiddenInput()}
{/if} diff --git a/src/uix/soma/components/editable/editable-provider.svelte.test.ts b/src/uix/soma/components/editable/editable-provider.svelte.test.ts index 8866105b8..4f908f865 100644 --- a/src/uix/soma/components/editable/editable-provider.svelte.test.ts +++ b/src/uix/soma/components/editable/editable-provider.svelte.test.ts @@ -68,6 +68,9 @@ function editableOpts(root = document.createElement('div')) { maxLength: state(20), disabled: state(false), readonly: state(false), + invalid: state(false), + required: state(false), + name: state(''), onValueCommit: state(vi.fn()), onValueRevert: state(vi.fn()) }; diff --git a/src/uix/soma/components/editable/editable-provider.svelte.ts b/src/uix/soma/components/editable/editable-provider.svelte.ts index 1707edcb2..361391572 100644 --- a/src/uix/soma/components/editable/editable-provider.svelte.ts +++ b/src/uix/soma/components/editable/editable-provider.svelte.ts @@ -31,6 +31,9 @@ interface EditableOpts maxLength: number | undefined; disabled: boolean; readonly: boolean; + invalid: boolean; + required: boolean; + name: string; onValueCommit: OnChangeFn; 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 {} diff --git a/src/uix/soma/components/editable/exports.ts b/src/uix/soma/components/editable/exports.ts index 900039206..ae7e5fa75 100644 --- a/src/uix/soma/components/editable/exports.ts +++ b/src/uix/soma/components/editable/exports.ts @@ -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 diff --git a/src/uix/soma/components/editable/types.ts b/src/uix/soma/components/editable/types.ts index 325cc5b87..e0c818c0d 100644 --- a/src/uix/soma/components/editable/types.ts +++ b/src/uix/soma/components/editable/types.ts @@ -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; + +export type EditableHiddenInputProps = WithChild<{ + /** Unique identifier. Auto-generated if omitted. */ + id?: string; +}> & + Without; diff --git a/web/routes/uix/components/editable/+page.svelte b/web/routes/uix/components/editable/+page.svelte index 743cbedf3..61aee7afa 100644 --- a/web/routes/uix/components/editable/+page.svelte +++ b/web/routes/uix/components/editable/+page.svelte @@ -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('md'); let variant = $state('surface'); let color = $state('primary'); let lastCommit = $state(''); + // Form integration demo + let formName = $state('project_name'); + let formValue = $state('Form initial'); + let lastSubmitted = $state | null>(null); + + function onFormSubmit(e: SubmitEvent) { + e.preventDefault(); + const form = e.currentTarget as HTMLFormElement; + const data = new FormData(form); + const obj: Record = {}; + 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([]); let stageRef = $state(null); @@ -138,7 +159,7 @@ - +
@@ -174,6 +195,8 @@ {maxLength} {disabled} {readonly} + {invalid} + {required} {size} {variant} {color} @@ -293,7 +316,7 @@ maxLength - {#each ['selectOnFocus', 'autoResize', 'disabled', 'readonly'] as key} + {#each ['selectOnFocus', 'autoResize', 'disabled', 'readonly', 'invalid', 'required'] as key} @@ -355,6 +384,67 @@
+
Form integration
+

+ Set a name to auto-render a hidden input. The Editable then participates in + native <form> submission like any text input. +

+
+ + + + + + + + Edit + Save + Cancel + + + + {#if lastSubmitted} +
+ FormData + {#each Object.entries(lastSubmitted) as [k, v]} + {k} = {JSON.stringify(v)} + {/each} +
+ {/if} +
+ +
defaultValue (uncontrolled)
+

+ When value is not bound, defaultValue seeds the initial draft. + Subsequent edits stay internal — the consumer never sees them unless they bind + value or wire onValueCommit. +

+ (defaultValueCommit = v)} + > + + + + + + Edit + Save + Cancel + + +

+ Last commit: {defaultValueCommit || '-'} +

+
soma