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}
{key}
@@ -306,9 +329,15 @@
{:else if key === 'disabled'}
{disabled ? 'on' : 'off'}
- {:else}
+ {:else if key === 'readonly'}
{readonly ? 'on' : 'off'}
+ {:else if key === 'invalid'}
+
+ {invalid ? 'on' : 'off'}
+ {:else}
+
+ {required ? 'on' : 'off'}
{/if}
@@ -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.
+
+
+
+ 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 || '-'}
+
+