phonograph/svelte/src/datum-editor.svelte

290 lines
8.7 KiB
Svelte
Raw Normal View History

2025-08-24 23:24:01 -07:00
<script lang="ts">
2025-10-16 06:40:11 +00:00
import type { Datum } from "./datum.svelte";
import {
datum_from_editor_state,
editor_state_from_datum,
type EditorState,
} from "./editor-state.svelte";
2025-08-24 23:24:01 -07:00
import { type FieldInfo } from "./field.svelte";
2025-11-02 20:26:33 +00:00
const BLUR_DEBOUNCE_MS = 100;
2025-08-24 23:24:01 -07:00
type Props = {
2025-10-16 06:40:11 +00:00
/**
* For use cases in which the user may select between multiple datum types,
* such as when embedded in an expression editor, this array represents the
* permissible set of field parameters.
*/
2025-08-24 23:24:01 -07:00
assignable_fields?: ReadonlyArray<FieldInfo>;
2025-10-16 06:40:11 +00:00
2025-08-24 23:24:01 -07:00
field_info: FieldInfo;
2025-10-16 06:40:11 +00:00
2025-11-02 20:26:33 +00:00
on_blur?(ev: FocusEvent): unknown;
on_cancel_edit?(): unknown;
on_change?(value?: Datum): unknown;
on_focus?(ev: FocusEvent): unknown;
/**
* In addition to `on_blur()`, this callback is invoked when the component
* blurs *itself*, for example when a user presses "Enter" or "Escape", as
* opposed to blurring in response to a user focusing another element. This
* typically indicates that the parent component should restore focus to a
* previously focused table cell.
*/
on_restore_focus?(): unknown;
2025-10-16 06:40:11 +00:00
value?: Datum;
2025-08-24 23:24:01 -07:00
};
let {
assignable_fields = [],
field_info = $bindable(),
2025-11-02 20:26:33 +00:00
on_blur,
on_cancel_edit,
2025-10-16 06:40:11 +00:00
on_change,
2025-11-02 20:26:33 +00:00
on_focus,
on_restore_focus,
2025-10-16 06:40:11 +00:00
value = $bindable(),
2025-08-24 23:24:01 -07:00
}: Props = $props();
2025-10-16 06:40:11 +00:00
let editor_state = $state<EditorState | undefined>();
2025-08-24 23:24:01 -07:00
let type_selector_menu_button_element = $state<
HTMLButtonElement | undefined
>();
let type_selector_popover_element = $state<HTMLDivElement | undefined>();
let date_input_element = $state<HTMLInputElement | undefined>();
2025-10-16 06:40:11 +00:00
let text_input_element = $state<HTMLInputElement | undefined>();
2025-11-02 20:26:33 +00:00
let blur_timeout = $state<number | undefined>();
2025-10-16 06:40:11 +00:00
$effect(() => {
if (value) {
editor_state = editor_state_from_datum(value);
}
});
export function focus() {
if (
field_info.field.presentation.t === "Dropdown" ||
field_info.field.presentation.t === "Numeric" ||
field_info.field.presentation.t === "Text" ||
field_info.field.presentation.t === "Uuid"
) {
text_input_element?.focus();
} else if (field_info.field.presentation.t === "Timestamp") {
date_input_element?.focus();
}
2025-10-16 06:40:11 +00:00
}
2025-11-02 20:26:33 +00:00
function handle_blur(ev: FocusEvent) {
// Propagating of blur events upwards is debounced, so that switching focus
// between elements does not cause spurious `on_blur()` calls.
if (blur_timeout !== undefined) {
clearTimeout(blur_timeout);
}
blur_timeout = setTimeout(() => on_blur?.(ev), BLUR_DEBOUNCE_MS);
}
function handle_focus(ev: FocusEvent) {
if (blur_timeout === undefined) {
on_focus?.(ev);
} else {
clearTimeout(blur_timeout);
}
}
2025-10-16 06:40:11 +00:00
function handle_input() {
if (!editor_state) {
console.warn("preconditions for handle_input() not met");
return;
}
value = datum_from_editor_state(
editor_state,
field_info.field.presentation,
);
on_change?.(value);
}
2025-08-24 23:24:01 -07:00
function handle_type_selector_menu_button_click() {
type_selector_popover_element?.togglePopover();
}
function handle_type_selector_field_button_click(value: FieldInfo) {
field_info = value;
type_selector_popover_element?.hidePopover();
type_selector_menu_button_element?.focus();
}
2025-11-02 20:26:33 +00:00
function handle_keydown(ev: KeyboardEvent) {
if (ev.key === "Enter") {
on_restore_focus?.();
} else if (ev.key === "Escape") {
// Cancel edit before blurring, or else the table will try to commit it.
on_cancel_edit?.();
on_restore_focus?.();
}
}
const interactive_handlers = {
onblur: handle_blur,
onfocus: handle_focus,
onkeydown: handle_keydown,
};
2025-08-24 23:24:01 -07:00
</script>
2025-10-16 06:40:11 +00:00
<div
class="datum-editor__container"
class:datum-editor__container--incomplete={!value}
2025-11-02 20:26:33 +00:00
onblur={handle_blur}
onfocus={handle_focus}
2025-10-16 06:40:11 +00:00
>
{#if editor_state}
{#if assignable_fields?.length > 0}
<div class="datum-editor__type-selector">
<button
2025-11-02 20:26:33 +00:00
{...interactive_handlers}
2025-10-16 06:40:11 +00:00
bind:this={type_selector_menu_button_element}
class="datum-editor__type-selector-menu-button"
onclick={handle_type_selector_menu_button_click}
type="button"
>
{field_info.field.presentation.t}
</button>
<div
bind:this={type_selector_popover_element}
class="datum-editor__type-selector-popover"
2025-11-02 20:26:33 +00:00
onblur={handle_blur}
onfocus={handle_focus}
2025-10-16 06:40:11 +00:00
popover="auto"
>
{#each assignable_fields as assignable_field_info}
<button
2025-11-02 20:26:33 +00:00
{...interactive_handlers}
2025-10-16 06:40:11 +00:00
onclick={() =>
handle_type_selector_field_button_click(assignable_field_info)}
type="button"
>
{assignable_field_info.field.presentation.t}
</button>
{/each}
</div>
2025-08-24 23:24:01 -07:00
</div>
2025-10-16 06:40:11 +00:00
{/if}
<button
2025-11-02 20:26:33 +00:00
{...interactive_handlers}
2025-10-16 06:40:11 +00:00
type="button"
class="datum-editor__null-control"
class:datum-editor__null-control--disabled={editor_state.text_value !==
""}
disabled={editor_state.text_value !== ""}
onclick={() => {
if (!editor_state) {
console.warn("null control onclick() preconditions not met");
return;
}
editor_state.is_null = !editor_state.is_null;
handle_input();
}}
>
{#if editor_state.is_null}
2025-10-24 18:21:40 +00:00
<i class="ti ti-cube-3d-sphere-off"></i>
2025-10-16 06:40:11 +00:00
{:else}
2025-10-24 18:21:40 +00:00
<i class="ti ti-cube"></i>
2025-10-16 06:40:11 +00:00
{/if}
</button>
2025-11-11 01:26:48 +00:00
{#if ["Dropdown", "Numeric", "Text", "Uuid"].includes(field_info.field.presentation.t)}
2025-10-16 06:40:11 +00:00
<input
2025-11-02 20:26:33 +00:00
{...interactive_handlers}
2025-10-16 06:40:11 +00:00
bind:this={text_input_element}
value={editor_state.text_value}
oninput={({ currentTarget }) => {
if (!editor_state) {
console.warn("text input oninput() preconditions not met");
return;
2025-10-16 06:40:11 +00:00
}
editor_state.text_value = currentTarget.value;
if (currentTarget.value !== "") {
editor_state.is_null = false;
}
handle_input();
2025-10-16 06:40:11 +00:00
}}
class="datum-editor__text-input"
type="text"
/>
2025-09-08 15:56:57 -07:00
{:else if field_info.field.presentation.t === "Timestamp"}
<div class="datum-editor__timestamp-inputs">
<input
{...interactive_handlers}
bind:this={date_input_element}
oninput={({ currentTarget }) => {
if (!editor_state) {
console.warn("date input oninput() preconditions not met");
return;
}
editor_state.date_value = currentTarget.value;
editor_state.is_null = false;
handle_input();
}}
value={editor_state.date_value}
type="date"
/>
<input
{...interactive_handlers}
oninput={({ currentTarget }) => {
if (!editor_state) {
console.warn("time input oninput() preconditions not met");
return;
}
editor_state.time_value = currentTarget.value;
editor_state.is_null = false;
handle_input();
}}
value={editor_state.time_value}
step="1"
type="time"
/>
</div>
2025-08-24 23:24:01 -07:00
{/if}
<div class="datum-editor__helpers" tabindex="-1">
{#if field_info.field.presentation.t === "Dropdown"}
<!-- TODO: This is an awkward way to implement a keyboard-navigable listbox. -->
<menu class="datum-editor__dropdown-options">
{#each field_info.field.presentation.c.options as dropdown_option}
<!-- FIXME: validate or escape dropdown_option.color -->
<li
class={[
"dropdown-option-badge",
`dropdown-option-badge--${dropdown_option.color.toLocaleLowerCase("en-US")}`,
]}
role="option"
aria-selected={dropdown_option.value === value?.c}
>
<button
2025-11-02 20:26:33 +00:00
{...interactive_handlers}
class="datum-editor__dropdown-option-button"
onclick={() => {
if (!editor_state || !text_input_element) {
console.warn(
"dropdown option onclick() preconditions not met",
);
return;
}
editor_state.text_value = dropdown_option.value;
editor_state.is_null = false;
text_input_element.focus();
handle_input();
}}
type="button"
>
{dropdown_option.value}
</button>
</li>
{/each}
</menu>
{/if}
</div>
2025-10-16 06:40:11 +00:00
{/if}
2025-08-24 23:24:01 -07:00
</div>