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.
svelte-kit-vice/src/uix/soma/components/editable/editable-provider.svelte.ts

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)
);
}

Powered by TurnKey Linux.