Global support for enter tab and escape #71

Closed
opened 2026-04-08 00:15:00 +00:00 by maxtkc · 0 comments
Owner

Summary

Extend showModal in modal-utils.ts to accept explicit enterAction and escapeAction indices that wire up keyboard shortcuts directly in the utility — replacing the ad-hoc dismissable flag and the one-off Enter handler in showFromURLModal. Every call site is updated to declare its intent explicitly. The bespoke showAtlasSearchModal is migrated into showModal using a close callback passed to onMount. Modals with no sensible keyboard shortcut (DB error, schema migration) are the documented exception.

Relevant context

  • src/modules/modal-utils.ts — core utility, 85 lines. Current dismissable: true adds X button + Escape-to-close. No Enter support. onMount?: () => void has no way to close the modal programmatically.
  • src/modules/about-modal.ts — 1 call, dismissable: true, 1 action (Close).
  • src/modules/ui.ts — 2 calls: showURLErrorModal (dismissable: true, 1 action) and showFromURLModal (dismissable: true, 2 actions; has a manual onMount Enter handler that queries .modal-action .btn-primary — this is the hack to remove).
  • src/modules/interaction-handler.ts — 1 call: New Stop modal (dismissable: true, 2 actions). Currently Escape only closes the modal but does NOT call setMapMode(NAVIGATE). The new escapeAction wiring fixes this bug as a side effect, since escapeAction triggers the Cancel onClick which calls setMapMode(NAVIGATE).
  • src/modules/page-content-renderer.ts — 1 call: Stop-has-visits confirmation (no dismissable, 2 actions).
  • src/modules/database-fallback-manager.ts — 2 calls: DB Error (no dismissable, dynamically-built actions — exception); DB Reset (dismissable: true, 2 actions).
  • src/modules/gtfs-database.ts — 1 call: schema migration (no dismissable, 2 actions — exception, user must act).
  • src/modules/atlas-search.ts — custom modal, not using showModal. Returns Promise<string | null>. Has Cancel button + backdrop click, no Escape. Needs migration.

Phase 1 — Overhaul modal-utils.ts API

Goal: Replace dismissable with explicit enterAction/escapeAction, add close to onMount, and consolidate the button-triggering logic into a shared helper.

  • Remove dismissable?: boolean from the options interface
  • Add enterAction?: number — index of the action triggered by Enter (skipped when focused element is <button> or <textarea>)
  • Add escapeAction?: number — index of the action triggered by Escape; also controls whether the X button is rendered
  • Change onMount?: () => void to onMount?: (close: () => void) => void — existing callers that ignore the argument continue to work in TypeScript (functions may ignore extra parameters)
  • Extract a triggerAction(idx: number): Promise<void> inner helper that replicates the current button-click flow: disable all buttons → await actions[idx].onClick() → if keepOpen=true re-enable, else call close()
  • Replace the existing button[data-idx] click listeners with calls to triggerAction(idx)
  • X button: render <button data-dismiss>✕</button> when escapeAction !== undefined; on click, call triggerAction(escapeAction)
  • Keydown handler (always registered on document, cleaned up in close()):
    • Escape → if escapeAction !== undefined, e.preventDefault(), call triggerAction(escapeAction)
    • Enter → if enterAction !== undefined and e.target is not HTMLButtonElement and not HTMLTextAreaElement, e.preventDefault(), call triggerAction(enterAction)
  • Pass close to onMount call: options.onMount?.(close)
  • Gotcha: close() called directly from onMount (e.g. atlas search selecting a result) must remove the keydown listener too — ensure close() always does full cleanup regardless of how it's invoked

Phase 2 — Update all showModal call sites

Goal: Wire up Enter/Escape at every call site, remove dismissable, remove the manual Enter hack.

  • about-modal.ts: replace dismissable: true with enterAction: 0, escapeAction: 0 (Close at index 0)
  • ui.tsshowURLErrorModal: replace dismissable: true with enterAction: 0, escapeAction: 0 (Close at index 0)
  • ui.tsshowFromURLModal: replace dismissable: true with enterAction: 1, escapeAction: 0 (Load=1, Cancel=0); delete the input.addEventListener('keydown', ...) block inside onMount that manually clicks .modal-action .btn-primary
  • interaction-handler.ts — New Stop: replace dismissable: true with enterAction: 1, escapeAction: 0 (Create Stop=1, Cancel=0)
  • page-content-renderer.ts — Stop-has-visits: add enterAction: 1, escapeAction: 0 (Delete=1, Cancel=0); no dismissable was set before
  • database-fallback-manager.ts — DB Error: no change (exception — no enterAction, no escapeAction)
  • database-fallback-manager.ts — DB Reset: replace dismissable: true with enterAction: 0, escapeAction: 1 (Reset=0, Cancel=1)
  • gtfs-database.ts — schema migration: add enterAction: 0 (Export & Continue=0), no escapeAction (exception — user must act)

Phase 3 — Migrate showAtlasSearchModal to showModal

Goal: Replace the bespoke DOM modal in atlas-search.ts with a showModal call, getting Escape for free via escapeAction and result-selection via close.

  • Change the function to async and introduce let selectedUrl: string | null = null in the closure
  • Call showModal with:
    • title: 'From TransitLand Atlas'
    • body: the search input + results container HTML (identical markup to current)
    • actions: [{ label: 'Cancel', onClick: () => {} }]
    • escapeAction: 0 (Escape = Cancel)
    • No enterAction — Enter in the search input should continue filtering, not confirm; selection is click-only
    • onMount: (close) => { ... } — move all current post-appendChild logic here; change const onSelect = (url) => cleanup(url) to const onSelect = (url: string) => { selectedUrl = url; close(); }
  • Return selectedUrl after await showModal(...)
  • Remove the now-unnecessary backdrop click handler (modal.addEventListener('click', ...)) — showModal doesn't add one, and Escape covers keyboard dismiss
  • Remove the cleanup helper and resolve call — replaced by close from onMount
  • Gotcha: showModal appends to document.body and removes on close — remove the manual document.body.appendChild(modal) and document.body.removeChild(modal) calls from the rewritten function

Original Issue

Right now, when I hit tab, because of the way we update (basically refresh) the focus gets lost so it doesn't work.

Also, in modals, escape and enter should work universally for the "go ahead" and "go back" actions.

## Summary Extend `showModal` in `modal-utils.ts` to accept explicit `enterAction` and `escapeAction` indices that wire up keyboard shortcuts directly in the utility — replacing the ad-hoc `dismissable` flag and the one-off Enter handler in `showFromURLModal`. Every call site is updated to declare its intent explicitly. The bespoke `showAtlasSearchModal` is migrated into `showModal` using a `close` callback passed to `onMount`. Modals with no sensible keyboard shortcut (DB error, schema migration) are the documented exception. ## Relevant context - **`src/modules/modal-utils.ts`** — core utility, 85 lines. Current `dismissable: true` adds X button + Escape-to-close. No Enter support. `onMount?: () => void` has no way to close the modal programmatically. - **`src/modules/about-modal.ts`** — 1 call, `dismissable: true`, 1 action (Close). - **`src/modules/ui.ts`** — 2 calls: `showURLErrorModal` (`dismissable: true`, 1 action) and `showFromURLModal` (`dismissable: true`, 2 actions; has a manual `onMount` Enter handler that queries `.modal-action .btn-primary` — this is the hack to remove). - **`src/modules/interaction-handler.ts`** — 1 call: New Stop modal (`dismissable: true`, 2 actions). Currently Escape only closes the modal but does NOT call `setMapMode(NAVIGATE)`. The new escapeAction wiring fixes this bug as a side effect, since escapeAction triggers the Cancel onClick which calls `setMapMode(NAVIGATE)`. - **`src/modules/page-content-renderer.ts`** — 1 call: Stop-has-visits confirmation (no `dismissable`, 2 actions). - **`src/modules/database-fallback-manager.ts`** — 2 calls: DB Error (no `dismissable`, dynamically-built actions — exception); DB Reset (`dismissable: true`, 2 actions). - **`src/modules/gtfs-database.ts`** — 1 call: schema migration (no `dismissable`, 2 actions — exception, user must act). - **`src/modules/atlas-search.ts`** — custom modal, not using `showModal`. Returns `Promise<string | null>`. Has Cancel button + backdrop click, no Escape. Needs migration. ## Phase 1 — Overhaul `modal-utils.ts` API Goal: Replace `dismissable` with explicit `enterAction`/`escapeAction`, add `close` to `onMount`, and consolidate the button-triggering logic into a shared helper. - [x] Remove `dismissable?: boolean` from the options interface - [x] Add `enterAction?: number` — index of the action triggered by Enter (skipped when focused element is `<button>` or `<textarea>`) - [x] Add `escapeAction?: number` — index of the action triggered by Escape; also controls whether the X button is rendered - [x] Change `onMount?: () => void` to `onMount?: (close: () => void) => void` — existing callers that ignore the argument continue to work in TypeScript (functions may ignore extra parameters) - [x] Extract a `triggerAction(idx: number): Promise<void>` inner helper that replicates the current button-click flow: disable all buttons → await `actions[idx].onClick()` → if `keepOpen=true` re-enable, else call `close()` - [x] Replace the existing `button[data-idx]` click listeners with calls to `triggerAction(idx)` - [x] X button: render `<button data-dismiss>✕</button>` when `escapeAction !== undefined`; on click, call `triggerAction(escapeAction)` - [x] Keydown handler (always registered on `document`, cleaned up in `close()`): - `Escape` → if `escapeAction !== undefined`, `e.preventDefault()`, call `triggerAction(escapeAction)` - `Enter` → if `enterAction !== undefined` and `e.target` is not `HTMLButtonElement` and not `HTMLTextAreaElement`, `e.preventDefault()`, call `triggerAction(enterAction)` - [x] Pass `close` to `onMount` call: `options.onMount?.(close)` - [x] Gotcha: `close()` called directly from `onMount` (e.g. atlas search selecting a result) must remove the keydown listener too — ensure `close()` always does full cleanup regardless of how it's invoked ## Phase 2 — Update all `showModal` call sites Goal: Wire up Enter/Escape at every call site, remove `dismissable`, remove the manual Enter hack. - [x] `about-modal.ts`: replace `dismissable: true` with `enterAction: 0, escapeAction: 0` (Close at index 0) - [x] `ui.ts` — `showURLErrorModal`: replace `dismissable: true` with `enterAction: 0, escapeAction: 0` (Close at index 0) - [x] `ui.ts` — `showFromURLModal`: replace `dismissable: true` with `enterAction: 1, escapeAction: 0` (Load=1, Cancel=0); delete the `input.addEventListener('keydown', ...)` block inside `onMount` that manually clicks `.modal-action .btn-primary` - [x] `interaction-handler.ts` — New Stop: replace `dismissable: true` with `enterAction: 1, escapeAction: 0` (Create Stop=1, Cancel=0) - [x] `page-content-renderer.ts` — Stop-has-visits: add `enterAction: 1, escapeAction: 0` (Delete=1, Cancel=0); no `dismissable` was set before - [x] `database-fallback-manager.ts` — DB Error: no change (exception — no `enterAction`, no `escapeAction`) - [x] `database-fallback-manager.ts` — DB Reset: replace `dismissable: true` with `enterAction: 0, escapeAction: 1` (Reset=0, Cancel=1) - [x] `gtfs-database.ts` — schema migration: add `enterAction: 0` (Export & Continue=0), no `escapeAction` (exception — user must act) ## Phase 3 — Migrate `showAtlasSearchModal` to `showModal` Goal: Replace the bespoke DOM modal in `atlas-search.ts` with a `showModal` call, getting Escape for free via `escapeAction` and result-selection via `close`. - [x] Change the function to `async` and introduce `let selectedUrl: string | null = null` in the closure - [x] Call `showModal` with: - `title: 'From TransitLand Atlas'` - `body`: the search input + results container HTML (identical markup to current) - `actions: [{ label: 'Cancel', onClick: () => {} }]` - `escapeAction: 0` (Escape = Cancel) - No `enterAction` — Enter in the search input should continue filtering, not confirm; selection is click-only - `onMount: (close) => { ... }` — move all current post-`appendChild` logic here; change `const onSelect = (url) => cleanup(url)` to `const onSelect = (url: string) => { selectedUrl = url; close(); }` - [x] Return `selectedUrl` after `await showModal(...)` - [x] Remove the now-unnecessary backdrop click handler (`modal.addEventListener('click', ...)`) — showModal doesn't add one, and Escape covers keyboard dismiss - [x] Remove the `cleanup` helper and `resolve` call — replaced by `close` from `onMount` - [x] Gotcha: `showModal` appends to `document.body` and removes on close — remove the manual `document.body.appendChild(modal)` and `document.body.removeChild(modal)` calls from the rewritten function --- ## Original Issue Right now, when I hit tab, because of the way we update (basically refresh) the focus gets lost so it doesn't work. Also, in modals, escape and enter should work universally for the "go ahead" and "go back" actions.
maxtkc self-assigned this 2026-04-08 00:15:00 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
gtfs.zone/coloring-book#71
No description provided.