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.
458 lines
14 KiB
458 lines
14 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';
|
|
|
|
export type ActivationMode = 'focus' | 'click' | 'dblclick' | 'none';
|
|
export type SubmitMode = 'enter' | 'blur' | 'both' | 'none';
|
|
|
|
// ── 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;
|
|
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, {});
|
|
this.runtimePart = this.runtime.part('provider', {
|
|
id: opts.id,
|
|
ref: opts.ref,
|
|
owner: this,
|
|
context: EditableProvider.ctx
|
|
});
|
|
|
|
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() {
|
|
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;
|
|
|
|
// 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() {
|
|
if (!this.isEditing) return;
|
|
|
|
this.opts.value.current = this.draft;
|
|
this.opts.editing.current = false;
|
|
this.opts.onValueCommit.current(this.draft);
|
|
}
|
|
|
|
cancel() {
|
|
if (!this.isEditing) return;
|
|
|
|
this.draft = this.opts.value.current;
|
|
this.opts.editing.current = false;
|
|
this.opts.onValueRevert.current();
|
|
}
|
|
|
|
readonly props = $derived.by(() =>
|
|
this.runtimePart.assert({
|
|
...this.runtimePart.props,
|
|
'data-editing': boolToEmptyStrOrUndef(this.isEditing),
|
|
'data-empty': boolToEmptyStrOrUndef(this.isEmpty),
|
|
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current),
|
|
'data-readonly': boolToEmptyStrOrUndef(this.opts.readonly.current)
|
|
} 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
|
|
});
|
|
}
|
|
|
|
readonly props = $derived.by(() =>
|
|
this.runtimePart.assert({
|
|
...this.runtimePart.props,
|
|
'data-editing': boolToEmptyStrOrUndef(this.provider.isEditing),
|
|
'data-disabled': boolToEmptyStrOrUndef(this.provider.opts.disabled.current),
|
|
'data-readonly': boolToEmptyStrOrUndef(this.provider.opts.readonly.current)
|
|
} 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();
|
|
}
|
|
|
|
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({
|
|
...this.runtimePart.props,
|
|
role: 'textbox' as const,
|
|
'aria-readonly': true as const,
|
|
'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();
|
|
}
|
|
} else if (e.key === KEYS.ESCAPE) {
|
|
e.preventDefault();
|
|
this.provider.cancel();
|
|
}
|
|
};
|
|
|
|
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();
|
|
} else {
|
|
this.provider.cancel();
|
|
}
|
|
};
|
|
|
|
readonly props = $derived.by(() =>
|
|
this.runtimePart.assert({
|
|
...this.runtimePart.props,
|
|
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,
|
|
'aria-disabled': this.provider.opts.disabled.current || undefined,
|
|
'data-disabled': boolToEmptyStrOrUndef(this.provider.opts.disabled.current),
|
|
'data-readonly': boolToEmptyStrOrUndef(this.provider.opts.readonly.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;
|
|
|
|
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();
|
|
};
|
|
|
|
readonly props = $derived.by(() =>
|
|
this.runtimePart.assert({
|
|
...this.runtimePart.props,
|
|
type: 'button' as const,
|
|
hidden: this.provider.isEditing || undefined,
|
|
'aria-label': this.provider.soma.langs.ts(EDITABLE_LANGS.EDIT),
|
|
'data-disabled': boolToEmptyStrOrUndef(this.provider.opts.disabled.current),
|
|
'data-readonly': boolToEmptyStrOrUndef(this.provider.opts.readonly.current),
|
|
disabled:
|
|
this.provider.opts.disabled.current || this.provider.opts.readonly.current || 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;
|
|
|
|
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();
|
|
};
|
|
|
|
readonly props = $derived.by(() =>
|
|
this.runtimePart.assert({
|
|
...this.runtimePart.props,
|
|
type: 'button' as const,
|
|
hidden: !this.provider.isEditing || undefined,
|
|
'aria-label': this.provider.soma.langs.ts(EDITABLE_LANGS.SUBMIT),
|
|
'data-disabled': boolToEmptyStrOrUndef(this.provider.opts.disabled.current),
|
|
'data-readonly': boolToEmptyStrOrUndef(this.provider.opts.readonly.current),
|
|
disabled:
|
|
this.provider.opts.disabled.current || this.provider.opts.readonly.current || undefined,
|
|
onclick: this.onclick
|
|
} 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;
|
|
|
|
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();
|
|
};
|
|
|
|
readonly props = $derived.by(() =>
|
|
this.runtimePart.assert({
|
|
...this.runtimePart.props,
|
|
type: 'button' as const,
|
|
hidden: !this.provider.isEditing || undefined,
|
|
'aria-label': this.provider.soma.langs.ts(EDITABLE_LANGS.CANCEL),
|
|
'data-disabled': boolToEmptyStrOrUndef(this.provider.opts.disabled.current),
|
|
'data-readonly': boolToEmptyStrOrUndef(this.provider.opts.readonly.current),
|
|
disabled: this.provider.opts.disabled.current || undefined,
|
|
onclick: this.onclick
|
|
} as const)
|
|
);
|
|
}
|