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

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

Powered by TurnKey Linux.