fix table keyboard nav

This commit is contained in:
Brent Schroeter 2025-11-02 20:26:33 +00:00
parent b12127d220
commit 55c58158cc
4 changed files with 356 additions and 198 deletions

View file

@ -42,31 +42,25 @@ $table-border-color: #ccc;
display: flex; display: flex;
height: 2.25rem; height: 2.25rem;
} }
&__cell {
align-items: stretch;
border: solid 1px $table-border-color;
border-left: none;
border-top: none;
display: flex;
flex: none;
padding: 0;
&--insertable {
border-style: dashed;
}
}
&__inserter {
align-items: stretch;
display: flex;
grid-area: inserter;
justify-items: flex-start;
margin-bottom: 2rem;
}
} }
.lens-cell { .lens-cell {
align-items: stretch;
border: solid 1px $table-border-color;
border-left: none;
border-top: none;
display: flex;
flex: none;
padding: 0;
&:focus {
outline: none;
}
&--insertable {
border-style: dashed;
}
&__container { &__container {
align-items: center; align-items: center;
display: flex; display: flex;
@ -75,6 +69,11 @@ $table-border-color: #ccc;
width: 100%; width: 100%;
&--selected { &--selected {
outline: 1px solid #37f;
outline-offset: -1px;
}
&--cursor {
outline: 3px solid #37f; outline: 3px solid #37f;
outline-offset: -2px; outline-offset: -2px;
} }
@ -176,11 +175,26 @@ $table-border-color: #ccc;
} }
.lens-inserter { .lens-inserter {
grid-area: inserter;
margin-bottom: 2rem;
&__help {
font-size: 1rem;
font-weight: lighter;
margin: 8px;
opacity: 0.5;
}
&__main {
align-items: stretch;
display: flex;
justify-items: flex-start;
}
&__rows { &__rows {
.lens-table__cell { .lens-cell {
border: dashed 1px $table-border-color; border: dashed 1px $table-border-color;
border-left: none; border-left: none;
border-top: none;
&:last-child { &:last-child {
border-right: none; border-right: none;

View file

@ -7,6 +7,8 @@
} from "./editor-state.svelte"; } from "./editor-state.svelte";
import { type FieldInfo } from "./field.svelte"; import { type FieldInfo } from "./field.svelte";
const BLUR_DEBOUNCE_MS = 100;
type Props = { type Props = {
/** /**
* For use cases in which the user may select between multiple datum types, * For use cases in which the user may select between multiple datum types,
@ -17,7 +19,22 @@
field_info: FieldInfo; field_info: FieldInfo;
on_change?(value?: Datum): void; 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;
value?: Datum; value?: Datum;
}; };
@ -25,7 +42,11 @@
let { let {
assignable_fields = [], assignable_fields = [],
field_info = $bindable(), field_info = $bindable(),
on_blur,
on_cancel_edit,
on_change, on_change,
on_focus,
on_restore_focus,
value = $bindable(), value = $bindable(),
}: Props = $props(); }: Props = $props();
@ -35,6 +56,7 @@
>(); >();
let type_selector_popover_element = $state<HTMLDivElement | undefined>(); let type_selector_popover_element = $state<HTMLDivElement | undefined>();
let text_input_element = $state<HTMLInputElement | undefined>(); let text_input_element = $state<HTMLInputElement | undefined>();
let blur_timeout = $state<number | undefined>();
$effect(() => { $effect(() => {
if (value) { if (value) {
@ -46,6 +68,23 @@
text_input_element?.focus(); text_input_element?.focus();
} }
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);
}
}
function handle_input() { function handle_input() {
if (!editor_state) { if (!editor_state) {
console.warn("preconditions for handle_input() not met"); console.warn("preconditions for handle_input() not met");
@ -55,7 +94,6 @@
editor_state, editor_state,
field_info.field.presentation, field_info.field.presentation,
); );
console.log(value);
on_change?.(value); on_change?.(value);
} }
@ -68,16 +106,35 @@
type_selector_popover_element?.hidePopover(); type_selector_popover_element?.hidePopover();
type_selector_menu_button_element?.focus(); type_selector_menu_button_element?.focus();
} }
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,
};
</script> </script>
<div <div
class="datum-editor__container" class="datum-editor__container"
class:datum-editor__container--incomplete={!value} class:datum-editor__container--incomplete={!value}
onblur={handle_blur}
onfocus={handle_focus}
> >
{#if editor_state} {#if editor_state}
{#if assignable_fields?.length > 0} {#if assignable_fields?.length > 0}
<div class="datum-editor__type-selector"> <div class="datum-editor__type-selector">
<button <button
{...interactive_handlers}
bind:this={type_selector_menu_button_element} bind:this={type_selector_menu_button_element}
class="datum-editor__type-selector-menu-button" class="datum-editor__type-selector-menu-button"
onclick={handle_type_selector_menu_button_click} onclick={handle_type_selector_menu_button_click}
@ -88,10 +145,13 @@
<div <div
bind:this={type_selector_popover_element} bind:this={type_selector_popover_element}
class="datum-editor__type-selector-popover" class="datum-editor__type-selector-popover"
onblur={handle_blur}
onfocus={handle_focus}
popover="auto" popover="auto"
> >
{#each assignable_fields as assignable_field_info} {#each assignable_fields as assignable_field_info}
<button <button
{...interactive_handlers}
onclick={() => onclick={() =>
handle_type_selector_field_button_click(assignable_field_info)} handle_type_selector_field_button_click(assignable_field_info)}
type="button" type="button"
@ -103,6 +163,7 @@
</div> </div>
{/if} {/if}
<button <button
{...interactive_handlers}
type="button" type="button"
class="datum-editor__null-control" class="datum-editor__null-control"
class:datum-editor__null-control--disabled={editor_state.text_value !== class:datum-editor__null-control--disabled={editor_state.text_value !==
@ -125,6 +186,7 @@
</button> </button>
{#if field_info.field.presentation.t === "Dropdown" || field_info.field.presentation.t === "Text" || field_info.field.presentation.t === "Uuid"} {#if field_info.field.presentation.t === "Dropdown" || field_info.field.presentation.t === "Text" || field_info.field.presentation.t === "Uuid"}
<input <input
{...interactive_handlers}
bind:this={text_input_element} bind:this={text_input_element}
value={editor_state.text_value} value={editor_state.text_value}
oninput={({ currentTarget }) => { oninput={({ currentTarget }) => {
@ -142,8 +204,16 @@
type="text" type="text"
/> />
{:else if field_info.field.presentation.t === "Timestamp"} {:else if field_info.field.presentation.t === "Timestamp"}
<input value={editor_state.date_value} type="date" /> <input
<input value={editor_state.time_value} type="time" /> {...interactive_handlers}
value={editor_state.date_value}
type="date"
/>
<input
{...interactive_handlers}
value={editor_state.time_value}
type="time"
/>
{/if} {/if}
<div class="datum-editor__helpers" tabindex="-1"> <div class="datum-editor__helpers" tabindex="-1">
{#if field_info.field.presentation.t === "Dropdown"} {#if field_info.field.presentation.t === "Dropdown"}
@ -160,6 +230,7 @@
aria-selected={dropdown_option.value === value?.c} aria-selected={dropdown_option.value === value?.c}
> >
<button <button
{...interactive_handlers}
class="datum-editor__dropdown-option-button" class="datum-editor__dropdown-option-button"
onclick={() => { onclick={() => {
if (!editor_state || !text_input_element) { if (!editor_state || !text_input_element) {

View file

@ -0,0 +1,131 @@
<script lang="ts">
import { type Datum } from "./datum.svelte";
import { type FieldInfo, type Coords } from "./field.svelte";
type Props = {
coords: Coords;
cursor: boolean;
field: FieldInfo;
onbecomecursor?(focus: () => unknown): unknown;
ondblclick?(ev: MouseEvent, coords: Coords): unknown;
onfocus?(ev: FocusEvent, coords: Coords): unknown;
onkeydown?(ev: KeyboardEvent, coords: Coords): unknown;
onmousedown?(ev: MouseEvent, coords: Coords): unknown;
selected: boolean;
table_region: "main" | "inserter";
value: Datum;
};
let {
coords,
cursor,
field,
onbecomecursor,
ondblclick,
onfocus,
onkeydown,
onmousedown,
selected,
table_region,
value,
}: Props = $props();
let cell_element = $state<HTMLDivElement | undefined>();
let invalid_value = $derived(
field.not_null && !field.has_default && value.c === undefined,
);
let null_value_class = $derived(
table_region === "inserter" && field.has_default
? "ti-sparkles"
: "ti-cube-3d-sphere-off",
);
$effect(() => {
if (cursor) {
onbecomecursor?.(() => cell_element?.focus());
cell_element?.focus();
}
});
</script>
<div
aria-colindex={coords[0]}
aria-rowindex={coords[1]}
aria-selected={selected}
bind:this={cell_element}
class="lens-cell"
onmousedown={(ev) => onmousedown?.(ev, coords)}
ondblclick={(ev) => ondblclick?.(ev, coords)}
onfocus={(ev) => onfocus?.(ev, coords)}
onkeydown={(ev) => onkeydown?.(ev, coords)}
role="gridcell"
style:width={`${field.field.table_width_px}px`}
tabindex={selected ? 0 : -1}
>
<div
class="lens-cell__container"
class:lens-cell__container--cursor={cursor}
class:lens-cell__container--selected={selected}
>
{#if field.field.presentation.t === "Dropdown"}
{#if value.t === "Text"}
<div
class="lens-cell__content lens-cell__content--dropdown"
class:lens-cell__content--null={value.c === undefined}
>
{#if value.c === undefined}
<i class={["ti", null_value_class]}></i>
{:else}
<!-- FIXME: validate or escape dropdown_option.color -->
<div
class={[
"dropdown-option-badge",
`dropdown-option-badge--${
field.field.presentation.c.options
.find((option) => option.value === value.c)
?.color.toLocaleLowerCase("en-US") ?? "grey"
}`,
]}
>
{value.c}
</div>
{/if}
</div>
{:else}
UNKNOWN
{/if}
{:else if field.field.presentation.t === "Text"}
<div
class="lens-cell__content lens-cell__content--text"
class:lens-cell__content--null={value.c === undefined}
>
{#if value.c === undefined}
<i class={["ti", null_value_class]}></i>
{:else}
{value.c}
{/if}
</div>
{:else if field.field.presentation.t === "Uuid"}
<div
class="lens-cell__content lens-cell__content--uuid"
class:lens-cell__content--null={value.c === undefined}
>
{#if value.c === undefined}
<i class={["ti", null_value_class]}></i>
{:else}
{value.c}
{/if}
</div>
{:else}
<div class="lens-cell__content lens-cell__content--unknown">
<div>UNKNOWN</div>
</div>
{/if}
{#if invalid_value}
<div class="lens-cell__notice">
<i class="ti ti-alert-circle"></i>
</div>
{/if}
</div>
</div>

View file

@ -23,6 +23,7 @@
import FieldAdder from "./field-adder.svelte"; import FieldAdder from "./field-adder.svelte";
import FieldHeader from "./field-header.svelte"; import FieldHeader from "./field-header.svelte";
import { get_empty_datum_for } from "./presentation.svelte"; import { get_empty_datum_for } from "./presentation.svelte";
import TableCell from "./table-cell.svelte";
type Props = { type Props = {
columns?: { columns?: {
@ -77,7 +78,7 @@
reverted: [], reverted: [],
}); });
let datum_editor = $state<DatumEditor | undefined>(); let datum_editor = $state<DatumEditor | undefined>();
let table_element = $state<HTMLDivElement | undefined>(); let focus_cursor = $state<(() => unknown) | undefined>();
let inserter_rows = $state<Row[]>([]); let inserter_rows = $state<Row[]>([]);
let lazy_data = $state<LazyData | undefined>(); let lazy_data = $state<LazyData | undefined>();
@ -313,67 +314,55 @@
}); });
// Reset editor input value // Reset editor input value
set_selections(selections); set_selections(selections);
table_element?.focus();
} }
// -------- Event Handlers: Both Tables -------- // // -------- Event Handlers -------- //
function handle_table_keydown(ev: KeyboardEvent) { function handle_table_keydown(ev: KeyboardEvent) {
if (lazy_data) { if (!lazy_data) {
const arrow_direction = arrow_key_direction(ev.key); console.warn("preconditions for handle_table_keydown() not met");
if (arrow_direction) { return;
move_selection(arrow_direction); }
ev.preventDefault(); const arrow_direction = arrow_key_direction(ev.key);
if (arrow_direction) {
move_selection(arrow_direction);
ev.preventDefault();
} else if (/^[a-zA-Z0-9`~!@#$%^&*()_=+[\]{}\\|;:'",<.>/?-]$/.test(ev.key)) {
const sel = selections[0];
if (sel) {
editor_value = get_empty_datum_for(
lazy_data.fields[sel.coords[1]].field.presentation,
);
datum_editor?.focus();
} }
if (ev.key === "Enter") { } else if (ev.key === "Enter") {
if (ev.shiftKey) { if (ev.shiftKey) {
if (selections[0]?.region === "main") { if (selections[0]?.region === "main") {
set_selections([ set_selections([
{ {
region: "inserter", region: "inserter",
coords: [0, selections[0]?.coords[1] ?? 0], coords: [0, selections[0]?.coords[1] ?? 0],
}, },
]); ]);
} else {
inserter_rows = [
...inserter_rows,
{
key: inserter_rows.length,
data: lazy_data.fields.map(({ field: { presentation } }) =>
get_empty_datum_for(presentation),
),
},
];
}
} else { } else {
datum_editor?.focus(); inserter_rows = [
...inserter_rows,
{
key: inserter_rows.length,
data: lazy_data.fields.map(({ field: { presentation } }) =>
get_empty_datum_for(presentation),
),
},
];
} }
} else {
datum_editor?.focus();
} }
} }
} }
// -------- Event Handlers: Main Table -------- // function handle_restore_focus() {
focus_cursor?.();
function handle_main_cell_click(ev: MouseEvent, coords: Coords) {
if (ev.metaKey || ev.ctrlKey) {
// TODO
// selections = [...selections.filter((prev) => !coords_eq(prev, coords)), coords];
// editor_input_value = "";
} else {
set_selections([{ region: "main", coords }]);
}
}
// -------- Event Handlers: Inserter Table -------- //
function handle_inserter_cell_click(ev: MouseEvent, coords: Coords) {
if (ev.metaKey || ev.ctrlKey) {
// TODO
// selections = [...selections.filter((prev) => !coords_eq(prev, coords)), coords];
// editor_input_value = "";
} else {
set_selections([{ region: "inserter", coords }]);
}
} }
// -------- Initial API Fetch -------- // // -------- Initial API Fetch -------- //
@ -402,6 +391,14 @@
), ),
}, },
]; ];
if (lazy_data.rows.length > 0 && lazy_data.fields.length > 0) {
set_selections([
{
region: "main",
coords: [0, 0],
},
]);
}
})().catch(console.error); })().catch(console.error);
setInterval(tick_delta_queue, 500); setInterval(tick_delta_queue, 500);
@ -420,97 +417,25 @@
{#each rows as row, row_index} {#each rows as row, row_index}
<div class="lens-table__row" role="row"> <div class="lens-table__row" role="row">
{#each lazy_data.fields as field, field_index} {#each lazy_data.fields as field, field_index}
{@const cell_data = row.data[field_index]} <TableCell
{@const cell_coords: Coords = [row_index, field_index]} coords={[row_index, field_index]}
{@const cell_selected = selections.some( cursor={selections[0]?.region === region_name &&
(sel) => coords_eq(selections[0].coords, [row_index, field_index])}
sel.region === region_name && coords_eq(sel.coords, cell_coords), {field}
)} onbecomecursor={(focus) => {
{@const null_value_class = focus_cursor = focus;
region_name === "inserter" && field.has_default
? "ti-sparkles"
: "ti-cube-3d-sphere-off"}
{@const invalid_value =
field.not_null && !field.has_default && cell_data.c === undefined}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
aria-colindex={field_index}
aria-rowindex={row_index}
aria-selected={cell_selected}
class="lens-table__cell"
onmousedown={(ev) => on_cell_click(ev, cell_coords)}
ondblclick={() => {
datum_editor?.focus();
}} }}
role="gridcell" ondblclick={() => datum_editor?.focus()}
style:width={`${field.field.table_width_px}px`} onkeydown={(ev) => handle_table_keydown(ev)}
tabindex="-1" onmousedown={on_cell_click}
> selected={selections.some(
<div (sel) =>
class="lens-cell__container" sel.region === region_name &&
class:lens-cell__container--selected={cell_selected} coords_eq(sel.coords, [row_index, field_index]),
> )}
{#if field.field.presentation.t === "Dropdown"} table_region={region_name}
{#if cell_data.t === "Text"} value={row.data[field_index]}
<div />
class="lens-cell__content lens-cell__content--dropdown"
class:lens-cell__content--null={cell_data.c === undefined}
>
{#if cell_data.c === undefined}
<i class="ti {null_value_class}"></i>
{:else}
<!-- FIXME: validate or escape dropdown_option.color -->
<div
class={[
"dropdown-option-badge",
`dropdown-option-badge--${
field.field.presentation.c.options
.find((option) => option.value === cell_data.c)
?.color.toLocaleLowerCase("en-US") ?? "grey"
}`,
]}
>
{cell_data.c}
</div>
{/if}
</div>
{:else}
UNKNOWN
{/if}
{:else if field.field.presentation.t === "Text"}
<div
class="lens-cell__content lens-cell__content--text"
class:lens-cell__content--null={cell_data.c === undefined}
>
{#if cell_data.c === undefined}
<i class="ti {null_value_class}"></i>
{:else}
{cell_data.c}
{/if}
</div>
{:else if field.field.presentation.t === "Uuid"}
<div
class="lens-cell__content lens-cell__content--uuid"
class:lens-cell__content--null={cell_data.c === undefined}
>
{#if cell_data.c === undefined}
<i class="ti {null_value_class}"></i>
{:else}
{cell_data.c}
{/if}
</div>
{:else}
<div class="lens-cell__content lens-cell__content--unknown">
<div>UNKNOWN</div>
</div>
{/if}
{#if invalid_value}
<div class="lens-cell__notice">
<i class="ti ti-alert-circle"></i>
</div>
{/if}
</div>
</div>
{/each} {/each}
</div> </div>
{/each} {/each}
@ -519,16 +444,7 @@
<div class="lens-grid"> <div class="lens-grid">
{#if lazy_data} {#if lazy_data}
<div <div class="lens-table" role="grid">
bind:this={table_element}
class="lens-table"
onfocus={() => {
try_queue_delta();
}}
onkeydown={handle_table_keydown}
role="grid"
tabindex="0"
>
<div class={["lens-table__headers"]}> <div class={["lens-table__headers"]}>
{#each lazy_data.fields as _, field_index} {#each lazy_data.fields as _, field_index}
<FieldHeader <FieldHeader
@ -544,29 +460,52 @@
{@render table_region({ {@render table_region({
region_name: "main", region_name: "main",
rows: lazy_data.rows, rows: lazy_data.rows,
on_cell_click: handle_main_cell_click, on_cell_click: (ev: MouseEvent, coords: Coords) => {
if (ev.metaKey || ev.ctrlKey) {
// TODO
// selections = [...selections.filter((prev) => !coords_eq(prev, coords)), coords];
// editor_input_value = "";
} else {
set_selections([{ region: "main", coords }]);
}
},
})} })}
</div> </div>
<form method="post" action="insert"> <form method="post" action="insert">
<div class="lens-table__inserter"> <div class="lens-inserter">
<div class="lens-inserter__rows"> <h3 class="lens-inserter__help">
{@render table_region({ Insert rows &mdash; press "shift + enter" to jump here or add a row
region_name: "inserter", </h3>
rows: inserter_rows, <div class="lens-inserter__main">
on_cell_click: handle_inserter_cell_click, <div class="lens-inserter__rows">
})} {@render table_region({
region_name: "inserter",
rows: inserter_rows,
on_cell_click: (ev: MouseEvent, coords: Coords) => {
if (ev.metaKey || ev.ctrlKey) {
// TODO
// selections = [...selections.filter((prev) => !coords_eq(prev, coords)), coords];
// editor_input_value = "";
} else {
set_selections([{ region: "inserter", coords }]);
}
},
})}
</div>
<button
aria-label="Insert rows"
class="lens-inserter__submit"
onkeydown={(ev) => {
// Prevent keypress (e.g. pressing Enter on the button to submit
// it) from triggering a table interaction.
ev.stopPropagation();
}}
title="Insert rows"
type="submit"
>
<i class="ti ti-upload"></i>
</button>
</div> </div>
<button
class="lens-inserter__submit"
onkeydown={(ev) => {
// Prevent keypress (e.g. pressing Enter on the button to submit
// it) from triggering a table interaction.
ev.stopPropagation();
}}
type="submit"
>
<i class="ti ti-upload"></i>
</button>
</div> </div>
{#each inserter_rows as row} {#each inserter_rows as row}
{#each lazy_data.fields as field, field_index} {#each lazy_data.fields as field, field_index}
@ -585,9 +524,12 @@
bind:this={datum_editor} bind:this={datum_editor}
bind:value={editor_value} bind:value={editor_value}
field_info={lazy_data.fields[selections[0].coords[1]]} field_info={lazy_data.fields[selections[0].coords[1]]}
on_blur={() => try_queue_delta()}
on_cancel_edit={cancel_edit}
on_change={() => { on_change={() => {
try_sync_edit_to_cells(); try_sync_edit_to_cells();
}} }}
on_restore_focus={handle_restore_focus}
/> />
{/if} {/if}
</div> </div>