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;
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 {
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 {
align-items: center;
display: flex;
@ -75,6 +69,11 @@ $table-border-color: #ccc;
width: 100%;
&--selected {
outline: 1px solid #37f;
outline-offset: -1px;
}
&--cursor {
outline: 3px solid #37f;
outline-offset: -2px;
}
@ -176,11 +175,26 @@ $table-border-color: #ccc;
}
.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 {
.lens-table__cell {
.lens-cell {
border: dashed 1px $table-border-color;
border-left: none;
border-top: none;
&:last-child {
border-right: none;

View file

@ -7,6 +7,8 @@
} from "./editor-state.svelte";
import { type FieldInfo } from "./field.svelte";
const BLUR_DEBOUNCE_MS = 100;
type Props = {
/**
* For use cases in which the user may select between multiple datum types,
@ -17,7 +19,22 @@
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;
};
@ -25,7 +42,11 @@
let {
assignable_fields = [],
field_info = $bindable(),
on_blur,
on_cancel_edit,
on_change,
on_focus,
on_restore_focus,
value = $bindable(),
}: Props = $props();
@ -35,6 +56,7 @@
>();
let type_selector_popover_element = $state<HTMLDivElement | undefined>();
let text_input_element = $state<HTMLInputElement | undefined>();
let blur_timeout = $state<number | undefined>();
$effect(() => {
if (value) {
@ -46,6 +68,23 @@
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() {
if (!editor_state) {
console.warn("preconditions for handle_input() not met");
@ -55,7 +94,6 @@
editor_state,
field_info.field.presentation,
);
console.log(value);
on_change?.(value);
}
@ -68,16 +106,35 @@
type_selector_popover_element?.hidePopover();
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>
<div
class="datum-editor__container"
class:datum-editor__container--incomplete={!value}
onblur={handle_blur}
onfocus={handle_focus}
>
{#if editor_state}
{#if assignable_fields?.length > 0}
<div class="datum-editor__type-selector">
<button
{...interactive_handlers}
bind:this={type_selector_menu_button_element}
class="datum-editor__type-selector-menu-button"
onclick={handle_type_selector_menu_button_click}
@ -88,10 +145,13 @@
<div
bind:this={type_selector_popover_element}
class="datum-editor__type-selector-popover"
onblur={handle_blur}
onfocus={handle_focus}
popover="auto"
>
{#each assignable_fields as assignable_field_info}
<button
{...interactive_handlers}
onclick={() =>
handle_type_selector_field_button_click(assignable_field_info)}
type="button"
@ -103,6 +163,7 @@
</div>
{/if}
<button
{...interactive_handlers}
type="button"
class="datum-editor__null-control"
class:datum-editor__null-control--disabled={editor_state.text_value !==
@ -125,6 +186,7 @@
</button>
{#if field_info.field.presentation.t === "Dropdown" || field_info.field.presentation.t === "Text" || field_info.field.presentation.t === "Uuid"}
<input
{...interactive_handlers}
bind:this={text_input_element}
value={editor_state.text_value}
oninput={({ currentTarget }) => {
@ -142,8 +204,16 @@
type="text"
/>
{:else if field_info.field.presentation.t === "Timestamp"}
<input value={editor_state.date_value} type="date" />
<input value={editor_state.time_value} type="time" />
<input
{...interactive_handlers}
value={editor_state.date_value}
type="date"
/>
<input
{...interactive_handlers}
value={editor_state.time_value}
type="time"
/>
{/if}
<div class="datum-editor__helpers" tabindex="-1">
{#if field_info.field.presentation.t === "Dropdown"}
@ -160,6 +230,7 @@
aria-selected={dropdown_option.value === value?.c}
>
<button
{...interactive_handlers}
class="datum-editor__dropdown-option-button"
onclick={() => {
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 FieldHeader from "./field-header.svelte";
import { get_empty_datum_for } from "./presentation.svelte";
import TableCell from "./table-cell.svelte";
type Props = {
columns?: {
@ -77,7 +78,7 @@
reverted: [],
});
let datum_editor = $state<DatumEditor | undefined>();
let table_element = $state<HTMLDivElement | undefined>();
let focus_cursor = $state<(() => unknown) | undefined>();
let inserter_rows = $state<Row[]>([]);
let lazy_data = $state<LazyData | undefined>();
@ -313,67 +314,55 @@
});
// Reset editor input value
set_selections(selections);
table_element?.focus();
}
// -------- Event Handlers: Both Tables -------- //
// -------- Event Handlers -------- //
function handle_table_keydown(ev: KeyboardEvent) {
if (lazy_data) {
const arrow_direction = arrow_key_direction(ev.key);
if (arrow_direction) {
move_selection(arrow_direction);
ev.preventDefault();
if (!lazy_data) {
console.warn("preconditions for handle_table_keydown() not met");
return;
}
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") {
if (ev.shiftKey) {
if (selections[0]?.region === "main") {
set_selections([
{
region: "inserter",
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 if (ev.key === "Enter") {
if (ev.shiftKey) {
if (selections[0]?.region === "main") {
set_selections([
{
region: "inserter",
coords: [0, selections[0]?.coords[1] ?? 0],
},
]);
} 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_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 }]);
}
function handle_restore_focus() {
focus_cursor?.();
}
// -------- 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);
setInterval(tick_delta_queue, 500);
@ -420,97 +417,25 @@
{#each rows as row, row_index}
<div class="lens-table__row" role="row">
{#each lazy_data.fields as field, field_index}
{@const cell_data = row.data[field_index]}
{@const cell_coords: Coords = [row_index, field_index]}
{@const cell_selected = selections.some(
(sel) =>
sel.region === region_name && coords_eq(sel.coords, cell_coords),
)}
{@const null_value_class =
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();
<TableCell
coords={[row_index, field_index]}
cursor={selections[0]?.region === region_name &&
coords_eq(selections[0].coords, [row_index, field_index])}
{field}
onbecomecursor={(focus) => {
focus_cursor = focus;
}}
role="gridcell"
style:width={`${field.field.table_width_px}px`}
tabindex="-1"
>
<div
class="lens-cell__container"
class:lens-cell__container--selected={cell_selected}
>
{#if field.field.presentation.t === "Dropdown"}
{#if cell_data.t === "Text"}
<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>
ondblclick={() => datum_editor?.focus()}
onkeydown={(ev) => handle_table_keydown(ev)}
onmousedown={on_cell_click}
selected={selections.some(
(sel) =>
sel.region === region_name &&
coords_eq(sel.coords, [row_index, field_index]),
)}
table_region={region_name}
value={row.data[field_index]}
/>
{/each}
</div>
{/each}
@ -519,16 +444,7 @@
<div class="lens-grid">
{#if lazy_data}
<div
bind:this={table_element}
class="lens-table"
onfocus={() => {
try_queue_delta();
}}
onkeydown={handle_table_keydown}
role="grid"
tabindex="0"
>
<div class="lens-table" role="grid">
<div class={["lens-table__headers"]}>
{#each lazy_data.fields as _, field_index}
<FieldHeader
@ -544,29 +460,52 @@
{@render table_region({
region_name: "main",
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>
<form method="post" action="insert">
<div class="lens-table__inserter">
<div class="lens-inserter__rows">
{@render table_region({
region_name: "inserter",
rows: inserter_rows,
on_cell_click: handle_inserter_cell_click,
})}
<div class="lens-inserter">
<h3 class="lens-inserter__help">
Insert rows &mdash; press "shift + enter" to jump here or add a row
</h3>
<div class="lens-inserter__main">
<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>
<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>
{#each inserter_rows as row}
{#each lazy_data.fields as field, field_index}
@ -585,9 +524,12 @@
bind:this={datum_editor}
bind:value={editor_value}
field_info={lazy_data.fields[selections[0].coords[1]]}
on_blur={() => try_queue_delta()}
on_cancel_edit={cancel_edit}
on_change={() => {
try_sync_edit_to_cells();
}}
on_restore_focus={handle_restore_focus}
/>
{/if}
</div>