2026-01-14 19:37:27 +00:00
|
|
|
<!--
|
|
|
|
|
@component
|
|
|
|
|
Provides user interface for specifying arbitrary scalar `Datum` values, for
|
|
|
|
|
example within the `<TableViewer />` or `<ExpressionEditor />`.
|
|
|
|
|
-->
|
|
|
|
|
|
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";
|
2026-01-14 19:37:27 +00:00
|
|
|
import { type Presentation } from "./presentation.svelte";
|
2025-08-24 23:24:01 -07:00
|
|
|
|
2025-11-02 20:26:33 +00:00
|
|
|
const BLUR_DEBOUNCE_MS = 100;
|
|
|
|
|
|
2025-08-24 23:24:01 -07:00
|
|
|
type Props = {
|
2026-01-14 19:37:27 +00:00
|
|
|
current_presentation: Presentation;
|
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
|
|
|
|
2026-01-14 19:37:27 +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 available presentations.
|
|
|
|
|
*/
|
|
|
|
|
potential_presentations?: Presentation[];
|
|
|
|
|
|
2025-10-16 06:40:11 +00:00
|
|
|
value?: Datum;
|
2025-08-24 23:24:01 -07:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let {
|
2026-01-14 19:37:27 +00:00
|
|
|
current_presentation = $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,
|
2026-01-14 19:37:27 +00:00
|
|
|
potential_presentations = [],
|
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-11-11 03:36:31 +00:00
|
|
|
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() {
|
2025-11-11 03:36:31 +00:00
|
|
|
if (
|
2026-01-14 19:37:27 +00:00
|
|
|
current_presentation.t === "Dropdown" ||
|
|
|
|
|
current_presentation.t === "Numeric" ||
|
|
|
|
|
current_presentation.t === "Text" ||
|
|
|
|
|
current_presentation.t === "Uuid"
|
2025-11-11 03:36:31 +00:00
|
|
|
) {
|
|
|
|
|
text_input_element?.focus();
|
2026-01-14 19:37:27 +00:00
|
|
|
} else if (current_presentation.t === "Timestamp") {
|
2025-11-11 03:36:31 +00:00
|
|
|
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;
|
|
|
|
|
}
|
2026-01-14 19:37:27 +00:00
|
|
|
value = datum_from_editor_state(editor_state, current_presentation);
|
2025-10-16 06:40:11 +00:00
|
|
|
on_change?.(value);
|
|
|
|
|
}
|
2025-08-24 23:24:01 -07:00
|
|
|
|
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?.();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-14 19:37:27 +00:00
|
|
|
// These handlers must be passed to interactive children to correctly handle
|
|
|
|
|
// focus/blur behavior, particularly when the `<DatumEditor />` is a child of
|
|
|
|
|
// `<TableViewer />`.
|
2025-11-02 20:26:33 +00:00
|
|
|
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}
|
2026-01-14 19:37:27 +00:00
|
|
|
{#if potential_presentations?.length > 0}
|
2025-10-16 06:40:11 +00:00
|
|
|
<div class="datum-editor__type-selector">
|
2026-01-14 19:37:27 +00:00
|
|
|
<select
|
2025-11-02 20:26:33 +00:00
|
|
|
{...interactive_handlers}
|
2026-01-14 19:37:27 +00:00
|
|
|
oninput={(ev) => {
|
|
|
|
|
current_presentation =
|
|
|
|
|
potential_presentations[parseInt(ev.currentTarget.value)];
|
|
|
|
|
}}
|
2025-10-16 06:40:11 +00:00
|
|
|
>
|
2026-01-14 19:37:27 +00:00
|
|
|
{#each potential_presentations as presentation, i}
|
|
|
|
|
<option value={`${i}`}>{presentation.t}</option>
|
2025-10-16 06:40:11 +00:00
|
|
|
{/each}
|
2026-01-14 19:37:27 +00:00
|
|
|
</select>
|
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>
|
2026-01-14 19:37:27 +00:00
|
|
|
{#if ["Dropdown", "Numeric", "Text", "Uuid"].includes(current_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}
|
2025-10-21 18:58:09 +00:00
|
|
|
oninput={({ currentTarget }) => {
|
|
|
|
|
if (!editor_state) {
|
|
|
|
|
console.warn("text input oninput() preconditions not met");
|
|
|
|
|
return;
|
2025-10-16 06:40:11 +00:00
|
|
|
}
|
2025-10-21 18:58:09 +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"
|
|
|
|
|
/>
|
2026-01-14 19:37:27 +00:00
|
|
|
{:else if current_presentation.t === "Timestamp"}
|
2025-11-11 03:36:31 +00:00
|
|
|
<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}
|
2025-10-21 18:58:09 +00:00
|
|
|
<div class="datum-editor__helpers" tabindex="-1">
|
2026-01-14 19:37:27 +00:00
|
|
|
{#if current_presentation.t === "Dropdown"}
|
2025-10-21 18:58:09 +00:00
|
|
|
<!-- TODO: This is an awkward way to implement a keyboard-navigable listbox. -->
|
|
|
|
|
<menu class="datum-editor__dropdown-options">
|
2026-01-14 19:37:27 +00:00
|
|
|
{#each current_presentation.c.options as dropdown_option}
|
2025-10-21 18:58:09 +00:00
|
|
|
<!-- 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}
|
2025-10-21 18:58:09 +00:00
|
|
|
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>
|