From 55c58158cc0d6a528da24e7f718c7ba6cad0d39d Mon Sep 17 00:00:00 2001 From: Brent Schroeter Date: Sun, 2 Nov 2025 20:26:33 +0000 Subject: [PATCH] fix table keyboard nav --- sass/viewer.scss | 62 +++--- svelte/src/datum-editor.svelte | 79 +++++++- svelte/src/table-cell.svelte | 131 +++++++++++++ svelte/src/table-viewer.webc.svelte | 282 +++++++++++----------------- 4 files changed, 356 insertions(+), 198 deletions(-) create mode 100644 svelte/src/table-cell.svelte diff --git a/sass/viewer.scss b/sass/viewer.scss index 017fb0c..028a35e 100644 --- a/sass/viewer.scss +++ b/sass/viewer.scss @@ -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; diff --git a/svelte/src/datum-editor.svelte b/svelte/src/datum-editor.svelte index ea79272..970cbd5 100644 --- a/svelte/src/datum-editor.svelte +++ b/svelte/src/datum-editor.svelte @@ -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(); let text_input_element = $state(); + let blur_timeout = $state(); $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, + };
{#if editor_state} {#if assignable_fields?.length > 0}
{/if}
- {#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}