Keyboard nav

This commit is contained in:
Diego Imbert
2026-05-25 11:36:02 +02:00
parent 202ed311fa
commit 5b9409a404
7 changed files with 160 additions and 32 deletions
+52 -6
View File
@@ -71,6 +71,7 @@
"svelte-carousel": "^1.0.25",
"svelte-exmarkdown": "^5.0.0",
"svelte-infinite-loading": "^1.4.0",
"tabbable": "^6.4.0",
"tailwind-merge": "^1.13.2",
"unist-util-visit": "^5.0.0",
"vscode": "npm:@codingame/monaco-vscode-extension-api@=25.0.0",
@@ -846,6 +847,7 @@
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -857,6 +859,7 @@
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -867,6 +870,7 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -1356,6 +1360,7 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -1504,6 +1509,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1520,6 +1526,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1536,6 +1543,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1552,6 +1560,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1568,6 +1577,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1584,6 +1594,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1600,6 +1611,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1616,6 +1628,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1632,6 +1645,7 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1648,6 +1662,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1664,6 +1679,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1680,6 +1696,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1696,6 +1713,7 @@
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -1714,6 +1732,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1730,6 +1749,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2035,6 +2055,7 @@
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -6810,7 +6831,7 @@
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"devOptional": true,
"dev": true,
"license": "MIT",
"bin": {
"jiti": "bin/jiti.js"
@@ -7309,6 +7330,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7329,6 +7351,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7349,6 +7372,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7369,6 +7393,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7389,6 +7414,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7409,6 +7435,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7429,6 +7456,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7449,6 +7477,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7469,6 +7498,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7489,6 +7519,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7509,6 +7540,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -12077,6 +12109,21 @@
}
}
},
"node_modules/svelte-check/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/svelte-eslint-parser": {
"version": "0.43.0",
"resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.43.0.tgz",
@@ -12346,10 +12393,9 @@
}
},
"node_modules/tabbable": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
"integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==",
"dev": true,
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
"integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==",
"license": "MIT"
},
"node_modules/table": {
@@ -12807,7 +12853,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
+2 -1
View File
@@ -125,7 +125,6 @@
"lru-cache": "^11.1.0",
"lucide-svelte": "^0.540.0",
"mdast-util-find-and-replace": "^3.0.2",
"unist-util-visit": "^5.0.0",
"minimatch": "^10.0.1",
"monaco-editor": "npm:@codingame/monaco-vscode-editor-api@=25.0.0",
"monaco-languageclient": "10.6.0",
@@ -145,7 +144,9 @@
"svelte-carousel": "^1.0.25",
"svelte-exmarkdown": "^5.0.0",
"svelte-infinite-loading": "^1.4.0",
"tabbable": "^6.4.0",
"tailwind-merge": "^1.13.2",
"unist-util-visit": "^5.0.0",
"vscode": "npm:@codingame/monaco-vscode-extension-api@=25.0.0",
"vscode-languageclient": "~9.0.1",
"vscode-uri": "~3.1.0",
@@ -0,0 +1,59 @@
import { tabbable } from 'tabbable'
import type { Attachment } from 'svelte/attachments'
export interface ArrowTabNavOptions {
/** Which arrow-key pair walks the tab order. Default `'y'` (Up/Down). */
axis?: 'x' | 'y'
/** Use this to layer custom keys (Enter, Escape …) */
onKeyDown?: (e: KeyboardEvent) => void
}
/**
* Map one axis of arrow keys to next/previous in Tab order, scoped to
* the attached element. Wraps around at the ends. Bails when the
* keypress originates inside a text input / textarea / contenteditable
* so the caret can still move with the arrows.
*
* <div {@attach arrowTabNav()}>…</div>
* <div {@attach arrowTabNav({ axis: 'x' })}>…</div>
* <div {@attach arrowTabNav({ onKeyDown: (e) => { … } })}>…</div>
*/
export function arrowTabNav(opts: ArrowTabNavOptions = {}): Attachment<HTMLElement> {
const axis = opts.axis ?? 'y'
const nextKey = axis === 'y' ? 'ArrowDown' : 'ArrowRight'
const prevKey = axis === 'y' ? 'ArrowUp' : 'ArrowLeft'
return (node) => {
const handler = (e: KeyboardEvent) => {
// Run the consumer's handler first so they can preventDefault
// or do their own thing before we react to arrows.
opts.onKeyDown?.(e)
if (e.key !== nextKey && e.key !== prevKey) return
// Let editable fields keep their native caret behavior.
const t = e.target as HTMLElement | null
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) {
return
}
const items = tabbable(node)
if (items.length === 0) return
const active = document.activeElement as HTMLElement | null
const i = active ? items.indexOf(active) : -1
const dir = e.key === nextKey ? 1 : -1
// No tabbable currently focused: jump to the first (next) or last (prev).
const next =
i === -1
? items[dir === 1 ? 0 : items.length - 1]
: items[(i + dir + items.length) % items.length]
e.preventDefault()
next?.focus()
}
node.addEventListener('keydown', handler)
return () => node.removeEventListener('keydown', handler)
}
}
@@ -0,0 +1,22 @@
import { tabbable } from 'tabbable'
/**
* Build an Enter-key handler for `arrowTabNav`'s `onKeyDown` (or any
* `keydown` listener): activate whatever is focused (so its `onClick`
* fires), then move focus to the first tabbable inside the container
* returned by `getNext`. preventDefault suppresses the browser's own
* Enter→click so we don't double-fire.
*
* {@attach arrowTabNav({ onKeyDown: selectAndAdvanceTo(() => nextEl) })}
*/
export function selectAndAdvanceTo(getNext: () => HTMLElement | undefined) {
return (e: KeyboardEvent) => {
if (e.key !== 'Enter') return
const t = e.target as HTMLElement | null
if (!t || t.tagName === 'TEXTAREA' || t.isContentEditable) return
e.preventDefault()
t.click()
const next = getNext()
if (next) tabbable(next)[0]?.focus()
}
}
@@ -75,64 +75,55 @@
id: 'schedule',
label: 'On schedule',
description: 'Cron-driven pipeline script',
icon: Clock,
pickLanguage: true
icon: Clock
},
{
id: 'webhook',
label: 'On webhook',
description: 'Triggered by an HTTP webhook',
icon: Webhook,
pickLanguage: true
icon: Webhook
},
{
id: 'email',
label: 'On email',
description: 'Triggered by incoming email',
icon: Mail,
pickLanguage: true
icon: Mail
},
{
id: 'kafka',
label: 'On Kafka',
description: 'Triggered by a Kafka message',
icon: Zap,
pickLanguage: true
icon: Zap
},
{
id: 'mqtt',
label: 'On MQTT',
description: 'Triggered by an MQTT message',
icon: Radio,
pickLanguage: true
icon: Radio
},
{
id: 'nats',
label: 'On NATS',
description: 'Triggered by a NATS message',
icon: MessageSquare,
pickLanguage: true
icon: MessageSquare
},
{
id: 'postgres',
label: 'On Postgres',
description: 'Triggered by a Postgres event',
icon: Database,
pickLanguage: true
icon: Database
},
{
id: 'sqs',
label: 'On SQS',
description: 'Triggered by an SQS message',
icon: Send,
pickLanguage: true
icon: Send
},
{
id: 'gcp',
label: 'On GCP Pub/Sub',
description: 'Triggered by a Pub/Sub message',
icon: CloudCog,
pickLanguage: true
icon: CloudCog
}
]}
languages={PIPELINE_LANGUAGES as any}
@@ -170,8 +170,7 @@
id: 'pipeline_script',
label: 'Add downstream pipeline script',
description: 'Triggered when this asset changes',
icon: Code2,
pickLanguage: true
icon: Code2
}
]}
languages={PIPELINE_LANGUAGES as any}
@@ -8,10 +8,6 @@
label: string
description?: string
icon?: ComponentType
// When pickLanguage is true, the right panel shows the language
// picker (and after a language is chosen, a path-entry stage).
// Otherwise onSelect is called directly with no language / path.
pickLanguage?: boolean
}
export type PipelineInsertPick = {
@@ -43,6 +39,8 @@
import Label from '$lib/components/Label.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { CornerDownLeft } from 'lucide-svelte'
import { arrowTabNav } from '$lib/attachments/arrowTabNav'
import { selectAndAdvanceTo } from '$lib/attachments/selectAndAdvanceTo'
interface Props {
kinds: PipelineInsertKind[]
@@ -73,6 +71,11 @@
})
let selected = $state(buildEmptySelected())
// Refs to the column containers so Enter inside one column can hand
// focus off to the first tabbable in the next column.
let languageEl: HTMLElement | undefined
let outputEl: HTMLElement | undefined
let compatibleKinds = $derived.by<PipelineOutputKind[]>(() => {
if (!selected.language) return []
return compatibleOutputKinds(selected.language)
@@ -148,7 +151,10 @@
</Popover>
{#snippet topSection()}
<div class={twMerge('flex flex-col gap-1 p-2 w-56 shrink-0 overflow-auto')}>
<div
class={twMerge('flex flex-col gap-1 p-2 w-56 shrink-0 overflow-auto')}
{@attach arrowTabNav({ onKeyDown: selectAndAdvanceTo(() => languageEl) })}
>
<div class="text-2xs font-normal text-secondary ml-2 mb-1">Trigger</div>
{#each kinds as k}
{@const isSelected = selected.triggerId == k.id}
@@ -186,10 +192,12 @@
</div>
<div
bind:this={languageEl}
class={twMerge(
'flex flex-col gap-1 p-2 overflow-auto transition-opacity w-48',
selected.triggerId ? '' : 'opacity-20'
)}
{@attach arrowTabNav({ onKeyDown: selectAndAdvanceTo(() => outputEl) })}
>
<div class="text-2xs font-normal text-secondary ml-2 mb-1">Language</div>
{#each languages as l}
@@ -214,10 +222,12 @@
</div>
<div
bind:this={outputEl}
class={twMerge(
'flex flex-col gap-1 p-2 grow w-56 overflow-auto transition-opacity',
selected.triggerId && selected.language ? '' : 'opacity-20'
)}
{@attach arrowTabNav()}
>
<div class="text-2xs font-normal text-secondary ml-2 mb-1">Output asset</div>
{#each visibleOutputKinds.length ? visibleOutputKinds : PIPELINE_OUTPUT_KINDS as k}