You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
571 lines
18 KiB
571 lines
18 KiB
import { context, type WithRefOpts } from '../../provider';
|
|
import { createAttrs } from '$uix/morfo';
|
|
import { boolToEmptyStrOrUndef } from '$adom';
|
|
import { state, type Active, type ActiveProps, type StateProps } from '$libs/reactive';
|
|
import type { OnChangeFn, SomaKeyboardEvent, SomaMouseEvent } from '../../types';
|
|
import { KEYS } from '../../keyboard';
|
|
import { Soma } from '../../core/soma.svelte';
|
|
import { EDITABLE_LANGS } from './langs';
|
|
|
|
import { editableMorfo } from '../../../morfo/components/editable';
|
|
|
|
const attrs = createAttrs(editableMorfo);
|
|
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
|
|
import type { ActivationMode, SubmitMode } from './types';
|
|
|
|
// ── Root ─────────────────────────────────────────────────────────────────────
|
|
|
|
interface EditableOpts
|
|
extends
|
|
WithRefOpts,
|
|
StateProps<{
|
|
value: string;
|
|
editing: boolean;
|
|
}>,
|
|
ActiveProps<{
|
|
placeholder: string;
|
|
activationMode: ActivationMode;
|
|
submitMode: SubmitMode;
|
|
selectOnFocus: boolean;
|
|
autoResize: boolean;
|
|
maxLength: number | undefined;
|
|
disabled: boolean;
|
|
readonly: boolean;
|
|
invalid: boolean;
|
|
required: boolean;
|
|
name: string;
|
|
onValueCommit: OnChangeFn<string>;
|
|
onValueRevert: () => void;
|
|
}> {}
|
|
|
|
export class EditableProvider {
|
|
readonly opts: EditableOpts;
|
|
readonly runtimePart: SomaRuntimePart;
|
|
readonly soma: Soma;
|
|
readonly runtime: SomaRuntime;
|
|
static readonly ctx = context<EditableProvider>('Editable');
|
|
static get(): EditableProvider | undefined {
|
|
return this.ctx.getOr(undefined) as EditableProvider | undefined;
|
|
}
|
|
static require(): EditableProvider {
|
|
return this.ctx.get();
|
|
}
|
|
|
|
static create(opts: EditableOpts) {
|
|
return new EditableProvider(opts);
|
|
}
|
|
|
|
// Draft value (in-progress edit)
|
|
draft = $state('');
|
|
inputRef = state<HTMLElement | null>(null);
|
|
private pendingFrame: number | null = null;
|
|
|
|
private constructor(opts: EditableOpts) {
|
|
this.opts = opts;
|
|
this.soma = Soma.require();
|
|
this.runtime = this.soma.runtime(editableMorfo, {
|
|
props: {
|
|
editing: () => this.isEditing,
|
|
empty: () => this.isEmpty,
|
|
disabled: () => this.opts.disabled.current,
|
|
readonly: () => this.opts.readonly.current,
|
|
invalid: () => this.opts.invalid.current,
|
|
required: () => this.opts.required.current
|
|
}
|
|
});
|
|
this.runtimePart = this.runtime.part('provider', {
|
|
id: opts.id,
|
|
ref: opts.ref,
|
|
owner: this,
|
|
context: EditableProvider.ctx,
|
|
syncAttrs: true
|
|
});
|
|
|
|
this.draft = opts.value.current;
|
|
|
|
// Cleanup pending animation frame on unmount
|
|
$effect(() => {
|
|
return () => {
|
|
if (this.pendingFrame !== null) {
|
|
this.soma.dom.cancelFrame(
|
|
this.pendingFrame,
|
|
this.inputRef.current ?? this.opts.ref.current
|
|
);
|
|
this.pendingFrame = null;
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
get isEditing(): boolean {
|
|
return this.opts.editing.current;
|
|
}
|
|
|
|
readonly isEmpty = $derived.by(() => !this.opts.value.current);
|
|
|
|
// ── Mode switching ───────────────────────────────────────────────────────
|
|
|
|
startEdit(target?: HTMLElement) {
|
|
if (this.opts.disabled.current || this.opts.readonly.current) return;
|
|
if (this.isEditing) return;
|
|
|
|
this.draft = this.opts.value.current;
|
|
this.opts.editing.current = true;
|
|
void this.runtime.trigger('shift-enter-mode', {
|
|
fallbackTarget: target ?? this.opts.ref.current ?? undefined
|
|
});
|
|
|
|
// Focus input on next tick
|
|
if (this.pendingFrame !== null) {
|
|
this.soma.dom.cancelFrame(this.pendingFrame, this.inputRef.current ?? this.opts.ref.current);
|
|
}
|
|
this.pendingFrame = this.soma.dom.requestFrame(() => {
|
|
this.pendingFrame = null;
|
|
const input = this.inputRef.current;
|
|
if (!input) return;
|
|
this.soma.dom.focus(input);
|
|
if (this.opts.selectOnFocus.current && 'select' in input) {
|
|
(input as HTMLInputElement).select();
|
|
}
|
|
}, this.inputRef.current ?? this.opts.ref.current);
|
|
}
|
|
|
|
submit(target?: HTMLElement) {
|
|
if (!this.isEditing) return;
|
|
|
|
this.opts.value.current = this.draft;
|
|
this.opts.editing.current = false;
|
|
this.opts.onValueCommit.current(this.draft);
|
|
void this.runtime.trigger('commit-save', {
|
|
fallbackTarget: target ?? this.opts.ref.current ?? undefined
|
|
});
|
|
}
|
|
|
|
cancel(target?: HTMLElement) {
|
|
if (!this.isEditing) return;
|
|
|
|
this.draft = this.opts.value.current;
|
|
this.opts.editing.current = false;
|
|
this.opts.onValueRevert.current();
|
|
void this.runtime.trigger('commit-cancel', {
|
|
fallbackTarget: target ?? this.opts.ref.current ?? undefined
|
|
});
|
|
}
|
|
|
|
readonly props = $derived.by(() =>
|
|
this.runtimePart.assert({
|
|
// syncAttrs writes the morfo attrs (data-editing/empty/disabled/readonly/
|
|
// required/invalid + aria-required/invalid) imperatively via dom.apply —
|
|
// the provider no longer re-declares them here.
|
|
...this.runtimePart.props
|
|
} as const)
|
|
);
|
|
}
|
|
|
|
// ── Area ─────────────────────────────────────────────────────────────────────
|
|
|
|
interface EditableAreaOpts extends WithRefOpts {}
|
|
|
|
export class EditableAreaProvider {
|
|
readonly opts: EditableAreaOpts;
|
|
readonly runtimePart: SomaRuntimePart;
|
|
static create(opts: EditableAreaOpts) {
|
|
return new EditableAreaProvider(opts);
|
|
}
|
|
|
|
readonly provider: EditableProvider;
|
|
|
|
private constructor(opts: EditableAreaOpts) {
|
|
this.opts = opts;
|
|
this.provider = EditableProvider.require();
|
|
this.runtimePart = this.provider.runtime.part('area', {
|
|
id: opts.id,
|
|
ref: opts.ref,
|
|
owner: this,
|
|
syncAttrs: true
|
|
});
|
|
}
|
|
|
|
readonly props = $derived.by(() =>
|
|
this.runtimePart.assert({
|
|
...this.runtimePart.props
|
|
} as const)
|
|
);
|
|
}
|
|
|
|
// ── Control ──────────────────────────────────────────────────────────────────
|
|
|
|
interface EditableControlOpts extends WithRefOpts {}
|
|
|
|
export class EditableControlProvider {
|
|
readonly opts: EditableControlOpts;
|
|
readonly runtimePart: SomaRuntimePart;
|
|
static create(opts: EditableControlOpts) {
|
|
return new EditableControlProvider(opts);
|
|
}
|
|
|
|
readonly provider: EditableProvider;
|
|
|
|
private constructor(opts: EditableControlOpts) {
|
|
this.opts = opts;
|
|
this.provider = EditableProvider.require();
|
|
this.runtimePart = this.provider.runtime.part('control', {
|
|
id: opts.id,
|
|
ref: opts.ref,
|
|
owner: this,
|
|
syncAttrs: true
|
|
});
|
|
}
|
|
|
|
readonly props = $derived.by(() =>
|
|
this.runtimePart.assert({
|
|
...this.runtimePart.props
|
|
} as const)
|
|
);
|
|
}
|
|
|
|
// ── Preview ──────────────────────────────────────────────────────────────────
|
|
|
|
interface EditablePreviewOpts extends WithRefOpts {}
|
|
|
|
export class EditablePreviewProvider {
|
|
readonly opts: EditablePreviewOpts;
|
|
readonly runtimePart: SomaRuntimePart;
|
|
static create(opts: EditablePreviewOpts) {
|
|
return new EditablePreviewProvider(opts);
|
|
}
|
|
|
|
readonly provider: EditableProvider;
|
|
|
|
private constructor(opts: EditablePreviewOpts) {
|
|
this.opts = opts;
|
|
this.provider = EditableProvider.require();
|
|
this.runtimePart = this.provider.runtime.part('preview', {
|
|
id: opts.id,
|
|
ref: opts.ref,
|
|
owner: this
|
|
});
|
|
}
|
|
|
|
readonly displayValue = $derived.by(
|
|
() => this.provider.opts.value.current || this.provider.opts.placeholder.current
|
|
);
|
|
|
|
readonly isPlaceholder = $derived.by(() => !this.provider.opts.value.current);
|
|
|
|
private handleActivation() {
|
|
this.provider.startEdit(this.opts.ref.current ?? undefined);
|
|
}
|
|
|
|
readonly onclick = (_e: SomaMouseEvent<HTMLElement>) => {
|
|
const mode = this.provider.opts.activationMode.current;
|
|
if (mode === 'click') this.handleActivation();
|
|
};
|
|
|
|
readonly ondblclick = (_e: MouseEvent) => {
|
|
const mode = this.provider.opts.activationMode.current;
|
|
if (mode === 'dblclick') this.handleActivation();
|
|
};
|
|
|
|
readonly onfocus = () => {
|
|
const mode = this.provider.opts.activationMode.current;
|
|
if (mode === 'focus') this.handleActivation();
|
|
};
|
|
|
|
readonly props = $derived.by(() =>
|
|
this.runtimePart.assert({
|
|
// Morfo-declared: role=textbox, aria-readonly=true. data-placeholder-
|
|
// shown/disabled/readonly are declared without a value (soma-owned).
|
|
...this.runtimePart.renderProps(),
|
|
// Soma override: aria-label is the placeholder, removed when empty
|
|
// (the morfo's raw propRef would emit '' instead of absent).
|
|
'aria-label': this.provider.opts.placeholder.current || undefined,
|
|
hidden: this.provider.isEditing || undefined,
|
|
tabindex: this.provider.opts.disabled.current ? undefined : 0,
|
|
'data-placeholder-shown': boolToEmptyStrOrUndef(this.isPlaceholder),
|
|
'data-disabled': boolToEmptyStrOrUndef(this.provider.opts.disabled.current),
|
|
'data-readonly': boolToEmptyStrOrUndef(this.provider.opts.readonly.current),
|
|
onclick: this.onclick,
|
|
ondblclick: this.ondblclick,
|
|
onfocus: this.onfocus
|
|
} as const)
|
|
);
|
|
}
|
|
|
|
// ── Input ────────────────────────────────────────────────────────────────────
|
|
|
|
interface EditableInputOpts extends WithRefOpts {}
|
|
|
|
export class EditableInputProvider {
|
|
readonly opts: EditableInputOpts;
|
|
readonly runtimePart: SomaRuntimePart;
|
|
static create(opts: EditableInputOpts) {
|
|
return new EditableInputProvider(opts);
|
|
}
|
|
|
|
readonly provider: EditableProvider;
|
|
|
|
private constructor(opts: EditableInputOpts) {
|
|
this.opts = opts;
|
|
this.provider = EditableProvider.require();
|
|
this.runtimePart = this.provider.runtime.part('input', {
|
|
id: opts.id,
|
|
ref: opts.ref,
|
|
owner: this,
|
|
onRefChange: (el) => {
|
|
this.provider.inputRef.current = el;
|
|
}
|
|
});
|
|
}
|
|
|
|
readonly oninput = (e: Event) => {
|
|
const target = e.currentTarget as HTMLInputElement | HTMLTextAreaElement;
|
|
this.provider.draft = target.value;
|
|
};
|
|
|
|
readonly onkeydown = (e: SomaKeyboardEvent<HTMLElement>) => {
|
|
const submitMode = this.provider.opts.submitMode.current;
|
|
|
|
if (e.key === KEYS.ENTER && !e.shiftKey) {
|
|
if (submitMode === 'enter' || submitMode === 'both') {
|
|
e.preventDefault();
|
|
this.provider.submit(e.currentTarget as HTMLElement);
|
|
}
|
|
} else if (e.key === KEYS.ESCAPE) {
|
|
e.preventDefault();
|
|
this.provider.cancel(e.currentTarget as HTMLElement);
|
|
}
|
|
};
|
|
|
|
readonly onblur = (e: FocusEvent) => {
|
|
// Don't blur-submit if focus moves to submit/cancel/edit trigger
|
|
const relatedTarget = e.relatedTarget as HTMLElement | null;
|
|
if (relatedTarget) {
|
|
const isControl =
|
|
relatedTarget.hasAttribute(attrs['submit-trigger']) ||
|
|
relatedTarget.hasAttribute(attrs['cancel-trigger']) ||
|
|
relatedTarget.hasAttribute(attrs['edit-trigger']);
|
|
if (isControl) return;
|
|
}
|
|
|
|
const submitMode = this.provider.opts.submitMode.current;
|
|
if (submitMode === 'blur' || submitMode === 'both') {
|
|
this.provider.submit(e.currentTarget as HTMLElement);
|
|
} else {
|
|
this.provider.cancel(e.currentTarget as HTMLElement);
|
|
}
|
|
};
|
|
|
|
readonly props = $derived.by(() =>
|
|
this.runtimePart.assert({
|
|
// Morfo-declared: aria-disabled/invalid (literal 'true' present-when-
|
|
// prop-truthy). data-disabled/readonly/invalid are declared without a
|
|
// value (soma-owned). Resolved against the component-level sources.
|
|
...this.runtimePart.renderProps(),
|
|
hidden: !this.provider.isEditing || undefined,
|
|
value: this.provider.draft,
|
|
placeholder: this.provider.opts.placeholder.current || undefined,
|
|
maxlength: this.provider.opts.maxLength.current ?? undefined,
|
|
disabled: this.provider.opts.disabled.current || undefined,
|
|
readonly: this.provider.opts.readonly.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',
|
|
'min-width': '2ch'
|
|
}
|
|
: undefined,
|
|
oninput: this.oninput,
|
|
onkeydown: this.onkeydown,
|
|
onblur: this.onblur
|
|
} as const)
|
|
);
|
|
}
|
|
|
|
// ── EditTrigger ──────────────────────────────────────────────────────────────
|
|
|
|
interface EditableEditTriggerOpts extends WithRefOpts {}
|
|
|
|
export class EditableEditTriggerProvider {
|
|
readonly opts: EditableEditTriggerOpts;
|
|
readonly runtimePart: SomaRuntimePart;
|
|
static create(opts: EditableEditTriggerOpts) {
|
|
return new EditableEditTriggerProvider(opts);
|
|
}
|
|
|
|
readonly provider: EditableProvider;
|
|
readonly isDisabled = $derived.by(
|
|
() =>
|
|
this.provider.opts.disabled.current ||
|
|
this.provider.opts.readonly.current ||
|
|
this.provider.isEditing
|
|
);
|
|
|
|
private constructor(opts: EditableEditTriggerOpts) {
|
|
this.opts = opts;
|
|
this.provider = EditableProvider.require();
|
|
this.runtimePart = this.provider.runtime.part('edit-trigger', {
|
|
id: opts.id,
|
|
ref: opts.ref,
|
|
owner: this
|
|
});
|
|
}
|
|
|
|
readonly onclick = (e: SomaMouseEvent<HTMLButtonElement>) => {
|
|
this.provider.startEdit(e.currentTarget);
|
|
};
|
|
|
|
readonly props = $derived.by(() =>
|
|
this.runtimePart.assert({
|
|
// Morfo-declared: type=button, data-editing ← editing. data-disabled/
|
|
// readonly are declared without a value (soma-owned).
|
|
...this.runtimePart.renderProps(),
|
|
// Soma override: the label is computed via the component's langs (the
|
|
// morfo's commonRef resolves to the same id but soma owns the lookup).
|
|
'aria-label': this.provider.soma.langs.ts(EDITABLE_LANGS.EDIT),
|
|
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
|
|
'data-readonly': boolToEmptyStrOrUndef(this.provider.opts.readonly.current),
|
|
disabled: this.isDisabled || undefined,
|
|
onclick: this.onclick
|
|
} as const)
|
|
);
|
|
}
|
|
|
|
// ── SubmitTrigger ────────────────────────────────────────────────────────────
|
|
|
|
interface EditableSubmitTriggerOpts extends WithRefOpts {}
|
|
|
|
export class EditableSubmitTriggerProvider {
|
|
readonly opts: EditableSubmitTriggerOpts;
|
|
readonly runtimePart: SomaRuntimePart;
|
|
static create(opts: EditableSubmitTriggerOpts) {
|
|
return new EditableSubmitTriggerProvider(opts);
|
|
}
|
|
|
|
readonly provider: EditableProvider;
|
|
readonly isDisabled = $derived.by(
|
|
() =>
|
|
this.provider.opts.disabled.current ||
|
|
this.provider.opts.readonly.current ||
|
|
!this.provider.isEditing
|
|
);
|
|
|
|
private constructor(opts: EditableSubmitTriggerOpts) {
|
|
this.opts = opts;
|
|
this.provider = EditableProvider.require();
|
|
this.runtimePart = this.provider.runtime.part('submit-trigger', {
|
|
id: opts.id,
|
|
ref: opts.ref,
|
|
owner: this
|
|
});
|
|
}
|
|
|
|
readonly onclick = (e: SomaMouseEvent<HTMLButtonElement>) => {
|
|
this.provider.submit(e.currentTarget);
|
|
};
|
|
|
|
readonly props = $derived.by(() =>
|
|
this.runtimePart.assert({
|
|
// Morfo-declared: type=button, data-editing ← editing. data-disabled/
|
|
// readonly are declared without a value (soma-owned).
|
|
...this.runtimePart.renderProps(),
|
|
// Soma override: the label is computed via the component's langs (the
|
|
// morfo's commonRef resolves to the same id but soma owns the lookup).
|
|
'aria-label': this.provider.soma.langs.ts(EDITABLE_LANGS.SUBMIT),
|
|
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
|
|
'data-readonly': boolToEmptyStrOrUndef(this.provider.opts.readonly.current),
|
|
disabled: this.isDisabled || undefined,
|
|
onclick: this.onclick
|
|
} as const)
|
|
);
|
|
}
|
|
|
|
// ── 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({
|
|
// Morfo-declared: aria-hidden=true. Native form attrs the morfo does
|
|
// not model stay soma-owned.
|
|
...this.runtimePart.renderProps(),
|
|
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,
|
|
tabindex: -1
|
|
} as const)
|
|
);
|
|
}
|
|
|
|
// ── CancelTrigger ────────────────────────────────────────────────────────────
|
|
|
|
interface EditableCancelTriggerOpts extends WithRefOpts {}
|
|
|
|
export class EditableCancelTriggerProvider {
|
|
readonly opts: EditableCancelTriggerOpts;
|
|
readonly runtimePart: SomaRuntimePart;
|
|
static create(opts: EditableCancelTriggerOpts) {
|
|
return new EditableCancelTriggerProvider(opts);
|
|
}
|
|
|
|
readonly provider: EditableProvider;
|
|
readonly isDisabled = $derived.by(
|
|
() =>
|
|
this.provider.opts.disabled.current ||
|
|
this.provider.opts.readonly.current ||
|
|
!this.provider.isEditing
|
|
);
|
|
|
|
private constructor(opts: EditableCancelTriggerOpts) {
|
|
this.opts = opts;
|
|
this.provider = EditableProvider.require();
|
|
this.runtimePart = this.provider.runtime.part('cancel-trigger', {
|
|
id: opts.id,
|
|
ref: opts.ref,
|
|
owner: this
|
|
});
|
|
}
|
|
|
|
readonly onclick = (e: SomaMouseEvent<HTMLButtonElement>) => {
|
|
this.provider.cancel(e.currentTarget);
|
|
};
|
|
|
|
readonly props = $derived.by(() =>
|
|
this.runtimePart.assert({
|
|
// Morfo-declared: type=button, data-editing ← editing. data-disabled/
|
|
// readonly are declared without a value (soma-owned).
|
|
...this.runtimePart.renderProps(),
|
|
// Soma override: the label is computed via the component's langs (the
|
|
// morfo's commonRef resolves to the same id but soma owns the lookup).
|
|
'aria-label': this.provider.soma.langs.ts(EDITABLE_LANGS.CANCEL),
|
|
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
|
|
'data-readonly': boolToEmptyStrOrUndef(this.provider.opts.readonly.current),
|
|
disabled: this.isDisabled || undefined,
|
|
onclick: this.onclick
|
|
} as const)
|
|
);
|
|
}
|