@joewinke/jatui 0.25.0 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +115 -2
- package/dist/actions/keyboardScope.d.ts +58 -0
- package/dist/actions/keyboardScope.js +110 -0
- package/dist/actions/listNav.d.ts +48 -3
- package/dist/actions/listNav.js +267 -77
- package/dist/actions/longpress.d.ts +37 -0
- package/dist/actions/longpress.js +105 -0
- package/dist/actions/stickToBottom.d.ts +66 -0
- package/dist/actions/stickToBottom.js +155 -0
- package/dist/actions/swipe.d.ts +64 -0
- package/dist/actions/swipe.js +179 -0
- package/dist/components/BottomSheet.svelte +280 -0
- package/dist/components/BottomSheet.svelte.d.ts +27 -0
- package/dist/components/ConfirmDialog.svelte +21 -0
- package/dist/components/ConfirmDialog.svelte.d.ts +11 -1
- package/dist/components/ConfirmModal.svelte +15 -0
- package/dist/components/ConfirmModal.svelte.d.ts +5 -0
- package/dist/components/Drawer.svelte +18 -1
- package/dist/components/Drawer.svelte.d.ts +9 -1
- package/dist/components/InputDialog.svelte +21 -0
- package/dist/components/InputDialog.svelte.d.ts +11 -1
- package/dist/components/KeyboardShortcutsOverlay.svelte +5 -0
- package/dist/components/Modal.svelte +50 -1
- package/dist/components/Modal.svelte.d.ts +8 -0
- package/dist/components/basic/Empty.svelte +173 -9
- package/dist/components/basic/Empty.svelte.d.ts +50 -1
- package/dist/components/basic/ErrorState.svelte +128 -0
- package/dist/components/basic/ErrorState.svelte.d.ts +46 -0
- package/dist/components/basic/JumpToLatest.svelte +60 -0
- package/dist/components/basic/JumpToLatest.svelte.d.ts +9 -0
- package/dist/components/basic/PageHeader.svelte +216 -0
- package/dist/components/basic/PageHeader.svelte.d.ts +52 -0
- package/dist/components/basic/ProgressButton.svelte +221 -0
- package/dist/components/basic/ProgressButton.svelte.d.ts +63 -0
- package/dist/components/graph/GraphApiBridge.svelte +36 -0
- package/dist/components/graph/GraphApiBridge.svelte.d.ts +9 -0
- package/dist/components/graph/GraphCanvas.svelte +212 -27
- package/dist/components/graph/GraphCanvas.svelte.d.ts +113 -19
- package/dist/components/graph/api.d.ts +52 -0
- package/dist/components/graph/api.js +66 -0
- package/dist/components/graph/index.d.ts +2 -0
- package/dist/components/graph/index.js +1 -0
- package/dist/components/graph/theme.js +13 -0
- package/dist/components/messaging/MessageItem.svelte +5 -0
- package/dist/components/messaging/MessageItem.svelte.d.ts +7 -0
- package/dist/components/table/DataTable.svelte +607 -72
- package/dist/components/table/DataTable.svelte.d.ts +23 -2
- package/dist/components/table/ManageColumnsDropdown.svelte +466 -0
- package/dist/components/table/ManageColumnsDropdown.svelte.d.ts +26 -0
- package/dist/components/table/columnStorage.d.ts +29 -0
- package/dist/components/table/columnStorage.js +61 -0
- package/dist/components/table/groupRows.d.ts +72 -0
- package/dist/components/table/groupRows.js +118 -0
- package/dist/components/table/index.d.ts +7 -1
- package/dist/components/table/index.js +3 -0
- package/dist/components/table/types.d.ts +207 -3
- package/dist/index.d.ts +25 -2
- package/dist/index.js +32 -1
- package/dist/utils/ansiToHtml.d.ts +83 -0
- package/dist/utils/ansiToHtml.js +543 -0
- package/dist/utils/keyboardShortcutRegistry.d.ts +332 -11
- package/dist/utils/keyboardShortcutRegistry.js +1070 -76
- package/dist/utils/pollBackoff.d.ts +83 -0
- package/dist/utils/pollBackoff.js +151 -0
- package/dist/utils/snapFlick.d.ts +59 -0
- package/dist/utils/snapFlick.js +66 -0
- package/dist/video/advisors/qr.d.ts +1 -1
- package/dist/virtual/createRowWindow.d.ts +75 -0
- package/dist/virtual/createRowWindow.js +129 -0
- package/dist/virtual/index.d.ts +4 -0
- package/dist/virtual/index.js +5 -0
- package/dist/virtual/rowWindowCore.d.ts +202 -0
- package/dist/virtual/rowWindowCore.js +510 -0
- package/package.json +11 -1
package/CLAUDE.md
CHANGED
|
@@ -56,16 +56,20 @@ Then import:
|
|
|
56
56
|
| Component | Purpose | Key Props |
|
|
57
57
|
|-----------|---------|-----------|
|
|
58
58
|
| `Button` | Styled button with variants | `variant`, `size`, `loading`, `disabled`, `onclick` |
|
|
59
|
+
| `ProgressButton` | Multi-phase async submit button (upstreamed from the JAT IDE): walks through the **named phases** of the work (`phases: [{key,label}]` in order) instead of a blank spinner — spinner + current phase label with shimmer, a fill bar that advances to `(index+1)/N`, disabled while busy, green check-flash on `success`. Caller owns the state machine (`phase` / `success`). Renders through the shared `Button`, so `class` composes with join patterns. | `phase?` (null = idle), `phases?: Phase[]`, `idleLabel?`, `success?`, `successLabel?`, `variant?`, `size?`, `disabled?`, `type?`, `class?`, `onclick?`, `children` |
|
|
59
60
|
| `Badge` | Status/label badge | `variant`, `size`, `children` (snippet) |
|
|
60
61
|
| `StatusBadge` | Status indicator with dot | `status: StatusValue`, `label` |
|
|
61
62
|
| `Card` | Content card container | `title`, `children` (snippet) |
|
|
62
|
-
| `Modal` | Dialog overlay. `zIndex` accepts `number` OR any CSS `z-index` string, including `"var(--z-lightbox)"`. | `open` (bindable), `title`, `children` (snippet), `zIndex?: string \| number` |
|
|
63
|
+
| `Modal` | Dialog overlay. `zIndex` accepts `number` OR any CSS `z-index` string, including `"var(--z-lightbox)"`. **Escape is owned by the dialog element itself** (not the window handler) — Modal focuses the dialog on open, so `trapKeys`' stopPropagation would otherwise swallow Esc before the window listener sees it; a Modal opened inside a host Drawer closes before the drawer. The component ships its own open-state `.modal-box` opacity rule, so it renders under DaisyUI 5 with no host global override. | `open` (bindable), `title`, `children` (snippet), `zIndex?: string \| number`, `trapKeys?`, `closeOnEscape?` |
|
|
63
64
|
| `ConfirmModal` | Yes/no confirmation dialog | `open` (bindable), `title`, `message`, `onConfirm`, `onCancel` |
|
|
64
65
|
| `Drawer` | Slide-out side panel | `open` (bindable), `side`, `children` (snippet) |
|
|
65
66
|
| `LoadingSpinner` | Spinner animation | `size`, `color` |
|
|
66
67
|
| `Sparkline` | Inline mini chart | `data: number[]`, `width`, `height`, `color` |
|
|
67
68
|
| `LazyImage` | Image with lazy loading + placeholder | `src`, `alt`, `placeholder` |
|
|
69
|
+
| `Empty` | Zero-state surface — v1 thin copy/children block still works; pass `title` for the v2 hero layout (upstreamed from the IDE's EmptyState): named `glyph` (11 curated Heroicons), `size` (sm/md/lg), `pulse` (reduced-motion gated, tinted with `--color-primary`), `icon` snippet override, CTA `actions` snippet, `description`, extra `children` below. | `title` (opts into v2), `description`, `glyph`, `size`, `pulse`, `icon`/`actions` (snippets), `copy`, `class`, `children` |
|
|
70
|
+
| `ErrorState` | Standard "this failed" surface (upstreamed from the IDE's ErrorState) — error-token themed icon, optional Retry button, extra `actions`, collapsible `details` block for raw error output. Centered card mirroring `Empty` v2. | `title`, `message`, `details`, `retryLabel`, `onRetry`, `size`, `actions` (snippet) |
|
|
68
71
|
| `HunkDiffView` | Side-by-side diff with line numbers, red/green backgrounds, strikethrough on removed lines, inline word-level highlights via the `diff` lib. Used for reviewing YAML/text changes per hunk. | `oldText: string \| null`, `newText: string \| null`, `unified?: boolean` |
|
|
72
|
+
| `JumpToLatest` | "Jump to latest" pill for streaming transcripts (pairs with `createStickToBottom`) — shows when the reader has scrolled away from the live edge, badges the unread arrival count, jumps + resumes following on click. Position inside a `relative` wrapper that also holds the scroll element. | `visible`, `unreadCount`, `label`, `onclick` |
|
|
69
73
|
| `ContextMenu` | Floating right-click (or programmatic) action menu, positioned via `use:floating` at a cursor point. Real `<button>`s, per-item `icon`/`variant`/`disabled`, dividers, click-outside + Escape dismiss, arrow-key/Home/End roving nav (focus starts on the first item). Items may declare `submit: SubmitConfig` to POST to a SvelteKit form action instead of (or in addition to) `onSelect` — a hidden `<form>` is built, requestSubmit()ed, then removed, so `use:enhance` fires exactly like a regular form submission. **Items also support nested `submenu: ContextMenuItem[]`** (right-side popover on hover/focus/ArrowRight, chevron indicator, ArrowLeft/Escape closes just the submenu — submenus open on parent-item focus **without stealing focus into the submenu**; ArrowRight is the explicit "enter the submenu" gesture) **and inline `content: Snippet`** (replaces the default icon+label row with an arbitrary widget — the wrapping button still closes the menu, so widgets that shouldn't must `event.stopPropagation()`). `autoFocus` (default true) can be set false to suppress auto-focus of the first item on open. | `open` (bindable), `x`, `y`, `items: ContextMenuItem[]`, `header?`, `zIndex?`, `autoFocus?`, `onClose?` |
|
|
70
74
|
| `ContextMenuTrigger` | Wraps content with the standard right-click + "⋯" touch affordance, backed by `ContextMenu`. Use instead of hand-rolling `oncontextmenu` + a kebab button per row/card. | `items: ContextMenuItem[]`, `children` (snippet), `header?`, `triggerLabel?`, `hideButton?`, `zIndex?`, `class?` |
|
|
71
75
|
| `Tooltip` | Component-shaped wrapper around the shared `use:tooltip` action — for cases where you can't easily add an attribute to the target element (wrap a chip / badge / icon in the tooltip instead). Warm/cool window + `aria-describedby` are shared with the action. Prefer `use:tooltip` directly when you own the element. | `content: string`, `placement?`, `delayMs?`, `disabled?`, `display?`, `children` (snippet) |
|
|
@@ -75,6 +79,23 @@ Then import:
|
|
|
75
79
|
| `BatchJobTable` | Generic queue runner (harvested from vid-gen's BatchGenerator) — `<BatchJobTable generics T>` over bindable `JobRow<T>[]`; host supplies async `run(data, {signal, attempt, reportProgress})`. Concurrency cap, optional per-run `processLimit` (quota budget), rolling-average ETA, per-row status badge / progress / elapsed / retry / skip / cancel / reorder — each cell a snippet override (`statusCell`, `progressCell`, `elapsedCell`, `actionsCell`), host data columns via `headers` + `cells`, extra toolbar content via `toolbar`. Uses `showToast` / `showConfirm` (mount `ToastHost` + `ConfirmDialog`). Exposes `start()`/`pause()`/`cancel()`/`retryFailed()`/`clearFinished()`. Pure scheduling helpers exported too (`nextJobsToStart`, `moveJob`, `summarizeJobs`, `toJobRows`). | `jobs` (bindable), `run`, `concurrencyLimit?` (3), `processLimit?`, `autoStart?`, `notify?`, `confirmDestructive?`, `onJobDone?`, `onJobFailed?`, `onBatchDone?` |
|
|
76
80
|
| `QuotaMeter` | "312 / 500 credits · resets in 4h 10m" bar — warning tint at `warnAt`, error at 100%. Presentational; the host owns the ledger. | `used`, `limit`, `resetsAt?`, `label?`, `unit?`, `warnAt?` (0.8), `size?` |
|
|
77
81
|
| `VariantMatrixPicker` | Category/option chip grid for building a combinatorial variant matrix (harvested from vid-gen's FrameVariantGenerator, presets stripped — caller-supplied `categories`). Each category tab shows a live selection count and an additive/multiplicative badge: multiplicative selections cartesian-multiply the total, additive ones stack flat onto every combination. Live combination count in the footer; algebra exported separately as pure functions (`generateCombinations`, `countCombinations`, `variantCategoryMode`, `findVariantOption`, `generateVariantFilename`). | `categories: VariantCategory[]`, `selected` (bindable `VariantSelection`), `forceMode?`, `showCount?` (true), `countLabel?`, `onChange?`, `class?` |
|
|
82
|
+
| `BottomSheet` | Mobile bottom sheet (upstreamed from the JAT IDE — jatui-rl7w6.2): drag handle with pointer capture, backdrop close, optional header. `snap='dual'` enables the inspector pattern — peek → expanded snap heights with flick-momentum release (fast flick ≥ 0.5 px/ms wins over position; flick down from peek dismisses) and `visualViewport` keyboard avoidance so content clears the soft keyboard. | `open` (bindable), `title?`, `zIndex?` (70), `fullHeight?`, `snap?` ('single'\|'dual'), `peekFraction?` (0.4), `expandedFraction?` (0.9), `onclose?`, `children` (snippet) |
|
|
83
|
+
|
|
84
|
+
### Tables (subpath: `jatui/table`)
|
|
85
|
+
|
|
86
|
+
Requires peer `@tanstack/table-core`. `import { DataTable, flexRender, columnResize } from 'jatui/table'`.
|
|
87
|
+
|
|
88
|
+
| Component | Purpose | Key Props |
|
|
89
|
+
|-----------|---------|-----------|
|
|
90
|
+
| `DataTable` | TanStack-backed table (generic over the row type): sorting, selection, client pagination, skeleton. Grid-mode hooks per jat `docs/design/datatable-virtualisation-group-rows.md` §3.3: `getRowId` (keys selection/expansion/api by id, renders `data-row-id`); controlled sort — `bind:sorting`, `initialSorting`, `manualSorting` (caller pre-sorts `data`), `onSortingChange(next, {field, shiftKey})`; `rowProps(row, item)` → `{class, attrs, onclick, oncontextmenu, ondragover, ondrop}` (`attrs` for `data-nav-id`, aria); `onRowClick(row, e)` (skips clicks on interactive children); `headerProps(columnId)`; `expandedRow` snippet `(row, {colspan})` — click/Enter toggles unless `isExpanded(row)` makes it controlled; `empty` snippet; `variant` `zebra`/`plain`/`unstyled` + `tableClass`; `bind:api` → `orderedRowIds()`, `scrollToRow(id)`. Sortable headers are keyboard-operable. **Grid mode** (all opt-in; default markup is snapshot-gated against 0.26.0): `groupBy: GroupLevel[]` renders `groupRows` header rows (`data-group-key` = `::`-joined path), `bind:collapsed` / `onCollapsedChange`, `groupLabel`, `groupHeader` snippet `(group, {toggle, colspan, collapsed})` renders the header CELLS; `stickyHeader` + `stickyGroupHeaders` (offset by the thead height and `--dt-group-row-h`); `maxHeight` makes the table its own scroller (else sticky clips horizontal overflow); `virtual={{ createRowWindow, estimateRowSize, overscan, threshold, scrollRoot }}` windows the body with spacer rows that keep zebra parity — pass `createRowWindow` from `jatui/virtual` so `jatui/table` never needs the virtual-core peer; read once at mount; `pinnedRowIds`; `ariaGrid` → `grid`/`treegrid`, `aria-selected`, `aria-level`, `aria-rowindex`/`rowcount` (windowed tables always get the rowindex pair); `ariaLabel`. One TanStack instance per mount, updated with `setOptions` — a selection or sort change re-renders no cell. | `data`, `columns`, `sortable`, `selectable`, `paginated`, `getRowId?`, `sorting?` (bindable), `rowProps?`, `onRowClick?`, `expandedRow?`, `empty?`, `variant?`, `api?` (bindable), `groupBy?`, `collapsed?` (bindable), `groupHeader?`, `stickyHeader?`, `stickyGroupHeaders?`, `maxHeight?`, `virtual?`, `pinnedRowIds?`, `ariaGrid?` |
|
|
91
|
+
|
|
92
|
+
### Graph (subpath: `jatui/graph`)
|
|
93
|
+
|
|
94
|
+
Requires peer `@xyflow/svelte` (install it — the IDE's build breaks on `import … from 'jatui/graph'` without it). `import { GraphCanvas, JatuiNode, type GraphCanvasApi } from 'jatui/graph'`.
|
|
95
|
+
|
|
96
|
+
| Component | Purpose | Key Props |
|
|
97
|
+
|-----------|---------|-----------|
|
|
98
|
+
| `GraphCanvas` | Svelte Flow wrapper with the DaisyUI → `--xy-*` theme bridge — read-only status graphs AND node editors. Props mirror `SvelteFlow`'s own names, so xyflow docs apply. Custom `nodeTypes` merge over `{ jatuiNode }` (**keep the object stable** — xyflow re-mounts every node when its identity changes); `edgeTypes`/`defaultEdgeOptions`; editing (`isValidConnection`, `onconnect`, `onbeforedelete`/`ondelete`, `deleteKey`, `snapToGrid`/`snapGrid`, `connectionMode`, key options, `zoomOnDoubleClick`); events forwarded verbatim (`onnodeclick`/`onNodeClick` alias, `onnodecontextmenu`, `onnodedragstart`/`stop`, `onedgeclick`, `onpaneclick`, `onselectionchange`, `onmoveend`). `bind:viewport` stays live even when seeded `undefined`. `children` renders INSIDE `<SvelteFlow>` (Panels, anything calling `useSvelteFlow()`). `bind:this` exposes `api: GraphCanvasApi` (`fitView`, `zoomTo` — centre-anchored, `getZoom`, `setCenter`, `screenToFlowPosition`, `getViewport`; all no-ops before init) plus the same methods (except `fitView`, whose name the prop owns) directly. The no-at-rest-shadow rule strips the node **wrapper** shadow inside `.graph-canvas` only — draw status glows on an inner element. xyflow has no `onnodedoubleclick`: handle `ondblclick` inside the node component. Use `$state.raw` for nodes/edges. | `nodes`/`edges` (bindable), `viewport` (bindable), `nodeTypes?`, `edgeTypes?`, `readonly?`, `fitView?`, `minimap?: boolean \| MiniMapProps`, `background?: false \| BackgroundProps`, `controls?`, `minZoom?`/`maxZoom?`, `children?` |
|
|
78
99
|
|
|
79
100
|
### Media
|
|
80
101
|
|
|
@@ -96,6 +117,20 @@ Then import:
|
|
|
96
117
|
|
|
97
118
|
**Types:** `VideoChapter`, `VideoSpeakerTurn`, `VideoTranscriptSegment`, `VideoBookmark`, `VideoWaveform`, `VideoSpeakerTalkTime`. `VideoWaveform.peaks` normalise against their own max, so 0–1, 0–99 and 0–255 arrays all work.
|
|
98
119
|
|
|
120
|
+
### Table (subpath: `jatui/table`)
|
|
121
|
+
|
|
122
|
+
`DataTable` (TanStack table-core), `flexRender`, `columnResize` and two column-management companions (upstreamed from the jat IDE, audit jat-1alfp U9):
|
|
123
|
+
|
|
124
|
+
| Component / util | Purpose | Key Props |
|
|
125
|
+
|-----------|---------|-----------|
|
|
126
|
+
| `ManageColumnsDropdown` | Show/hide + drag-reorder table columns, with a search filter and a last-visible-column lock (can't hide the final column). Panel renders with `position:fixed` viewport coordinates by default (`appendToBody`, SearchDropdown pattern) so it can never be clipped by an `overflow-x:auto`/`overflow:hidden` ancestor — the clipping bug the IDE copy still has (jat-hwt1h). Consumer owns the state: pass `columnOrder` + `hiddenColumns` and apply the `onReorder`/`onToggleVisibility` callbacks; `onReset` adds a "Reset to default" footer. | `columnOrder`, `hiddenColumns: Set<string>`, `getLabel`, `onReorder`, `onToggleVisibility`, `onReset?`, `label?`, `appendToBody?` (default true) |
|
|
127
|
+
| `columnStorage` | localStorage persistence for column settings — `saveColumnSettings(key, settings)` / `loadColumnSettings(key, allColumnIds)`. Load merges newly added column ids at the end and drops removed ones; returns null when nothing saved (or corrupt/absent localStorage — SSR-safe silent no-ops). | — |
|
|
128
|
+
|
|
129
|
+
```typescript
|
|
130
|
+
import type { ColumnSettings } from 'jatui/table'
|
|
131
|
+
import { saveColumnSettings, loadColumnSettings } from 'jatui/table'
|
|
132
|
+
```
|
|
133
|
+
|
|
99
134
|
### Meetings (subpath: `jatui/meet`)
|
|
100
135
|
|
|
101
136
|
Real-time video-meeting UI. `livekit-client` is an **OPTIONAL peer dep**, lazily
|
|
@@ -137,12 +172,30 @@ import { loadLiveKit, isLiveKitAvailable, LiveKitNotInstalledError } from 'jatui
|
|
|
137
172
|
|
|
138
173
|
| Component | Purpose | Key Props |
|
|
139
174
|
|-----------|---------|-----------|
|
|
175
|
+
| `PageHeader` | The standard route header (upstreamed from the JAT IDE): uppercase/tracked "instrument label" title + slots for a count chip, subtitle, leading `icon` snippet, right-aligned `actions` cluster, `meta` metrics strip and content below the header row. `sticky` pins it to the top of the scroll container; `size='lg'` for landing screens. All color via `--color-*` tokens; entrance animation reduced-motion gated (`animate`, default off). | `title`, `subtitle?`, `count?`, `size?` ('md'\|'lg'), `sticky?`, `animate?`, `icon?`/`actions?`/`meta?`/`children` (snippets) |
|
|
140
176
|
| `ResizablePanel` | Resizable container | `minWidth`, `maxWidth`, `defaultWidth` |
|
|
141
177
|
| `ResizableDivider` | Drag handle between panels | `onResize` |
|
|
142
178
|
| `InfiniteGrid` | Cursor-paged infinite-scroll grid (from chimaro). `loadMore(cursor, {signal})` resolves `{items, nextCursor, total?}` (`nextCursor: null` = end); cells render through the `renderItem(item, i)` snippet. Change `resetKey` to start over — the in-flight page is aborted and a late page for the old key is dropped. One request in flight; items de-duped by `getKey`; short pages auto-chain until the sentinel leaves the screen; distinct skeleton / empty / exhausted / error(+Retry) states; non-item rows are `overflow-anchor: none` so scroll anchoring can't runaway-load. Scroll root = `root` or nearest scrollable ancestor. Exposes `reset()`/`retry()`. Not virtualized. Pure core: `createInfiniteLoader`. | `loadMore`, `renderItem` (snippet), `getKey?`, `resetKey?`, `columns?` / `minItemWidth?`, `gap?`, `root?`, `loadAhead?` (400px), `skeleton?`/`empty?`/`end?`/`error?` (snippets), `emptyText?`, `endText?`, `onStateChange?` |
|
|
143
179
|
| `InfiniteSearchGrid` | Search box + `InfiniteGrid`. Query is debounced, then becomes the grid's `resetKey` (Enter commits now, Escape clears). Below `minQueryLength` no request is made and `prompt` shows. All other `InfiniteGrid` props pass through. | `search(query, cursor, {signal})`, `renderItem` (snippet), `query` (bindable), `debounceMs?` (300), `minQueryLength?` (0), `placeholder?`, `noResultsText?`/`noResults?`, `prompt?`/`promptText?`, `toolbar?` (snippet) |
|
|
144
180
|
| `SectionNav` | Sticky in-page section index for long single-column pages — vertical rail beside the content on xl+, wrapping chip row above it below xl. Built-in scroll-spy (omit `activeId` and it just works; `bind:activeId` to observe/control — the spy writes back to it): on every observer callback + rAF-throttled scroll/resize it measures ALL sections and activates the last one whose top is at/above the activation line (`activationRatio`, default 0.3 of the scroller height); at the bottom of the page the last section wins. Clicking an item activates it immediately and holds the spy off until the smooth scroll lands (`scrollend`, or 800ms after the last scroll event); reduced-motion scrolls instantly. Pure decision fn exported as `computeActiveSection(rects, viewport, scroll, ratio?)`. Amber dot per `dirtyIds` marks sections holding unsaved edits. `go()` scrolls AND moves focus so keyboard/SR users land where the click did. j/k/Enter via `listNav`. Layout contract: parent is a `flex flex-wrap gap-8 items-start` row, content column `flex-1 min-w-0`; sections need `id` + `scroll-mt-*`. | `sections: {id,label}[]`, `activeId?` (bindable), `dirtyIds?: Set<string>`, `ariaLabel?`, `spy?`, `activationRatio?` (0.3) |
|
|
145
181
|
|
|
182
|
+
**Windowing (subpath: `jatui/virtual`, optional peer `@tanstack/virtual-core`).** `createRowWindow(opts)` is headless variable-height windowing built on **spacer rows**, not absolute positioning, so it works inside a `<table>` and keeps `table-layout: fixed` and sticky `<thead>`s. It returns reactive `items` (`{index, key, start, size, end, gapBefore, sticky, pinned}`), `paddingTop`, `paddingBottom`, `totalSize` and `windowed`, plus `use:win.anchor` (on the `<tbody>` or list container, whose top is item 0's top) and `use:win.measure={item.key}` (on each item; a shared ResizeObserver re-measures it). Reading a row's height for the first time, or later re-measuring one above the viewport, shifts `scrollTop` by the delta so nothing jumps. The anchor gets `overflow-anchor: none` so native scroll anchoring doesn't correct the shift a second time. The options are reactive getters: `count`, `getKey`, `estimateSize`, `pinned()` (keys kept rendered, e.g. an open editor or exiting row), `stickyIndices()` (a flat list, or one array per level `[projectHdrs, epicHdrs]`; the active header of each level stays rendered, and an inner header never outlives its outer group), and `threshold` (render everything below N; pass it as a getter to make it reactive). Also `scrollRoot` (`'auto'` = the nearest ancestor that really scrolls, else the window; it re-resolves on resize), `overscan` (6), `scrollPaddingStart/End` and `initialViewportSize` (800; used for SSR and first paint). Methods: `scrollToIndex`/`scrollToKey(…, {align})` (queued until mount), `indexOf`, `remeasure()`. Render `item.gapBefore` as a spacer before the item; it is non-zero only when a pinned or sticky item sits outside the slice. Pure policy helpers are exported too (`extractWindowIndices`, `activeStickyIndices`, `layoutWindow`, `findScrollRoot`, `RowWindowEngine`). Demo: `/components/row-window`.
|
|
183
|
+
|
|
184
|
+
```svelte
|
|
185
|
+
<script>
|
|
186
|
+
import { createRowWindow } from 'jatui/virtual'
|
|
187
|
+
const win = createRowWindow({ count: () => rows.length, getKey: (i) => rows[i].id, estimateSize: () => 44 })
|
|
188
|
+
</script>
|
|
189
|
+
<tbody use:win.anchor>
|
|
190
|
+
<tr style:height="{win.paddingTop}px"></tr>
|
|
191
|
+
{#each win.items as it (it.key)}
|
|
192
|
+
{#if it.gapBefore}<tr style:height="{it.gapBefore}px"></tr>{/if}
|
|
193
|
+
<tr use:win.measure={it.key}>…rows[it.index]…</tr>
|
|
194
|
+
{/each}
|
|
195
|
+
<tr style:height="{win.paddingBottom}px"></tr>
|
|
196
|
+
</tbody>
|
|
197
|
+
```
|
|
198
|
+
|
|
146
199
|
### Audio & Voice
|
|
147
200
|
|
|
148
201
|
| Component | Purpose | Key Props |
|
|
@@ -229,6 +282,8 @@ const callbacks: AnnotationCallbacks = {
|
|
|
229
282
|
|
|
230
283
|
**Shortcut registry** (`createShortcutRegistry()`, or the lazy `getShortcutRegistry()` singleton) — the data source for the overlay, so apps stop hand-wiring a keydown handler plus a separate list for the overlay. Shortcuts register against a **context** and return an unregister fn (return it from `$effect`). Contexts form a stack: `global` is the bottom, `pushContext(name, { modal?, label? })` returns a pop fn, the top context shadows the same combo lower down, and a `modal` context suppresses everything beneath it (including `global`). Handled for you: no firing while typing in text inputs/textarea/select/contenteditable (opt in per shortcut with `allowInInput`), IME composition and `defaultPrevented` events are skipped, `mod` = ⌘ on macOS / Ctrl elsewhere, exact modifier matching (`?` doesn't need `shift`), and macOS Option combos fall back to `event.code`. Per shortcut: `enabled()`, `allowRepeat`, `preventDefault`, `group` (overlay section), `hidden`, `label` (keycap override).
|
|
231
284
|
|
|
285
|
+
**keyboardScope** (`use:keyboardScope({ 'mod+s': save, … })` or bare `createKeyboardScope(container)`) — the *container-local* complement to the registry's context stack (upstreamed from the JAT IDE): embed-safe shortcuts for a self-contained widget (pane, drawer, embedded panel) inside a busy host page. Ownership is decided from DOM engagement, not a pushed context: a shortcut fires when focus is inside the container, or when the target is body/html AND the most recent pointerdown/focusin (capture-phase) landed inside — the "engaged" heuristic, so a pane owns Delete/mod+Z right after a click without permanently stealing the keys. Never fires while typing in a text-entry element (shares the registry's `isTypingTarget`), even for modifier combos. `comboFor(event)` normalizes a keydown to the handler-map key (fixed `shift,alt,mod` order; `mod` = ⌘/Ctrl; backspace ≡ delete). Use the registry for app keymaps; use keyboardScope inside an embeddable widget that must own a few keys.
|
|
286
|
+
|
|
232
287
|
```svelte
|
|
233
288
|
<script>
|
|
234
289
|
import { createShortcutRegistry, KeyboardShortcutsOverlay, SIDEBAR_TOGGLE_KEY } from "jatui"
|
|
@@ -244,7 +299,15 @@ const callbacks: AnnotationCallbacks = {
|
|
|
244
299
|
<KeyboardShortcutsOverlay registry={shortcuts} />
|
|
245
300
|
```
|
|
246
301
|
|
|
247
|
-
**
|
|
302
|
+
**Rebinding.** Give a definition an `id` and its `key` (a string or an array of combos) becomes the *default*. The live binding is the user's override: pass `bindings: localStorageBindingStore('app.shortcuts', { migrate? })` (or any `BindingStore`) to `createShortcutRegistry`, then `registry.rebind(id, 'ctrl+shift+n' | [] | null, { onConflict: 'reject' | 'swap' | 'allow' })`, `resetBinding(id)`, `resetAllBindings()`. Stored entries that can't be parsed are never dropped silently: they are warned once and listed by `rejectedBindings()` as `{ id, raw, reason }`. Registered entries re-resolve on every change, so components never re-register. `declare([...])` catalogues ids before their component mounts, and `bindings()` / `binding(id)` list them for a settings page. `await registry.ready` for an async store. Combos are stored canonically. `normalizeCombo('Super+Up')` → `meta+arrowup`; it accepts the IDE's legacy spellings (`Command`/`Win`/`Super`, `Space`, `Up`…). `comboFromEvent(e)` records a binding from a keydown, and `validateCombo(k, { requireModifier })` checks one the user typed. Duplicate `id` in one context throws in dev.
|
|
303
|
+
|
|
304
|
+
**Conflicts.** `findConflicts(keys, { id?, context? })` lists what a binding would collide with, and `allConflicts({ minSeverity? })` lists every pair among registrations + declarations once (newer side as `source`) — run it with `minSeverity: 'error'` in a test over your catalogue. Kinds: `same-context` (error — one of them is dead), `shadows` / `shadowed-by` (warning — co-active contexts, the upper wins), `modal-blocked` (info — the upper context is modal), `reserved` (browser combos: `mod+t/w/n` error, copy/paste/save/find/… and `mod+b` warning; override with `reserved`, default `BROWSER_RESERVED_COMBOS`). Which contexts can be stacked comes from `contextGraph: { ctx: [contexts it sits above] }` (transitive; `global` is beneath all; without it, non-global contexts only meet `global`). Contexts are modal once pushed `{ modal: true }` or when listed in `modalContexts`. `rebind()` rejects any error-severity conflict (a reserved block even under `swap`), and a successful rebind returns the remaining `conflicts` for display.
|
|
305
|
+
|
|
306
|
+
**Beyond combos.** `attach()` listens for keydown, keyup and blur. `onRelease(e)` on a definition fires on the keyup of the same *physical* key (`event.code`) after it fired, whatever modifiers remain (push-to-talk); a blur drops pending releases without calling them. `onModifierChange({ alt, ctrl, shift, meta })` (registry option) reports held-modifier changes and resets on blur, so an Alt-held hint layer can't stick. `registerHandler({ context?, handle(e) → boolean, allowInInput?, preventDefault?, describe? })` adds a raw handler (count prefixes, `m<letter>` chords) that runs inside the context walk *after* that context's combos, newest first; returning false continues the walk, a modal suppresses it, it is never rebindable or conflict-checked, and `describe()` rows reach `toSections`. `isTypingTarget: (target, event) => boolean` replaces the typing test (compose with the exported `isTypingTarget`). `pushContext(name, { layer })` — frames sort by layer, then push order, so an app-level surface at `layer: 1` stays above routes that mount later. `pushContext(name, { modal: true, passthrough: ['global.sidebar', 'mod+\\'] })` lets listed ids/combos from beneath still fire (raw handlers never pass). `formatBinding(id)` → live keycap or null (for hints, tooltips, menu accelerators). `toSections({ includeDeclared: true })` also lists `declare()`d ids nothing has registered yet. `handleKeyup(e)` / `handleBlur()` dispatch by hand.
|
|
307
|
+
|
|
308
|
+
**Dialogs join the stack.** `Modal`, `Drawer`, `BottomSheet`, `ConfirmModal`, `ConfirmDialog` and `InputDialog` take `shortcutRegistry` (+ optional `shortcutContext` name); while open they push a modal context so page shortcuts don't fire behind them.
|
|
309
|
+
|
|
310
|
+
**Sidebar convention: `SIDEBAR_TOGGLE_KEY` = `mod+\`** (Ctrl+\ / ⌘\). Do NOT use Ctrl+B: Chrome reserves it (bookmarks bar). Also avoid Ctrl+T/W/N, which the browser keeps. The overlay owns `?` by default, so don't register it — but if a host app already has its own global `?` handler registered first, the overlay respects `event.defaultPrevented` and stays out of the way (one overlay per keypress). Helpers: `formatShortcut(def | "mod+k", platform?)` → `⌘K` / `Ctrl+K`, `isTypingTarget(el)`, `parseShortcutCombo`, `detectShortcutPlatform`.
|
|
248
311
|
|
|
249
312
|
### Maps (Google Maps)
|
|
250
313
|
|
|
@@ -291,6 +354,39 @@ if (await showConfirm({ title: "Delete?", body: warnBody, danger: true })) { ...
|
|
|
291
354
|
## Utilities
|
|
292
355
|
|
|
293
356
|
```typescript
|
|
357
|
+
// Streaming-transcript auto-scroll (upstreamed from the JAT IDE — jatui-rl7w6.3).
|
|
358
|
+
// Reader-aware follow: follows the live edge ONLY while the reader is at it;
|
|
359
|
+
// upward scroll/wheel/keys/selection suspend following; coalesces sticks per
|
|
360
|
+
// frame; surfaces an unread count for a JumpToLatest pill.
|
|
361
|
+
import { createStickToBottom, type StickState, type StickController } from "jatui"
|
|
362
|
+
import { JumpToLatest } from "jatui"
|
|
363
|
+
// const scroller = createStickToBottom({ onState: (s) => (stick = s) })
|
|
364
|
+
// <div use:scroller.action class="overflow-y-auto">…</div>
|
|
365
|
+
// <JumpToLatest visible={!stick.atBottom} unreadCount={stick.unread} onclick={scroller.jumpToLatest} />
|
|
366
|
+
// scroller.requestStick() // gated follow (streamed content)
|
|
367
|
+
// scroller.requestStick(true) // force (the reader's own send, errors)
|
|
368
|
+
|
|
369
|
+
// ANSI terminal output → HTML (upstreamed from the JAT IDE — jatui-rl7w6.3).
|
|
370
|
+
// SGR colors incl. 24-bit/256-color, bold/dim/italic/underline/reverse, WCAG
|
|
371
|
+
// contrast auto-foreground on backgrounds, URL linkification, ECMA-48 strict
|
|
372
|
+
// stripAnsi (text after bare two-byte escapes survives), incremental stateful
|
|
373
|
+
// parsing and per-line hierarchy classes for <pre>-based terminal surfaces.
|
|
374
|
+
import { ansiToHtml, ansiToHtmlStateful, ansiToHtmlWithLinks, ansiToHtmlWithHierarchy, linkifyUrls, stripAnsi, classifyLine, parseOutputLines, createDefaultAnsiState } from "jatui"
|
|
375
|
+
import type { AnsiState, ParsedLine } from "jatui"
|
|
376
|
+
|
|
377
|
+
// Mobile gesture kit (upstreamed from the JAT IDE — jatui-rl7w6.2).
|
|
378
|
+
// swipe: one-finger horizontal drag with deadzone/directional-ratio gating,
|
|
379
|
+
// rubber-banded offset emission and distance-or-velocity commit.
|
|
380
|
+
import { swipe } from "jatui" // use:swipe={{ onSwipeLeft, onSwipeRight, onOffsetChange, allowLeft, allowRight, enabled }}
|
|
381
|
+
import type { SwipeOptions } from "jatui"
|
|
382
|
+
// longpress: press-and-hold (touch, haptic where supported) + right-click (mouse)
|
|
383
|
+
// context affordance; cancels on movement, swallows the synthesized tap after firing.
|
|
384
|
+
import { longpress } from "jatui" // use:longpress={{ onLongPress(detail), duration? (450), moveTolerance? (10), enabled? }}
|
|
385
|
+
import type { LongPressOptions, LongPressDetail } from "jatui"
|
|
386
|
+
// snapFlick: pure snap-point + flick-momentum math behind BottomSheet dual mode.
|
|
387
|
+
import { resolveSnapTarget, keyboardInsetPx, clampSnapHeight, FLICK_VELOCITY_PX_MS } from "jatui"
|
|
388
|
+
import type { SnapName, SnapHeight, SnapInput, SnapResult } from "jatui"
|
|
389
|
+
|
|
294
390
|
// Video playback advisors (ported from extension-library — mos-5lzns.27).
|
|
295
391
|
// Pure functions: no DOM, no settings reads, never write playbackRate. The
|
|
296
392
|
// player's rate composer / resume-at-sentence / audio-graph tasks consume
|
|
@@ -314,6 +410,16 @@ import { pollOperation, PollTimeoutError } from "jatui"
|
|
|
314
410
|
// { intervalMs, timeoutMs, backoff, signal, onProgress }) — rejects on timeout / abort / thrown check
|
|
315
411
|
import { formatElapsed, rollingAverage, estimateRemainingMs } from "jatui"
|
|
316
412
|
|
|
413
|
+
// Recurring poll with coordinated exponential backoff (upstreamed from the JAT IDE — jatui-rl7w6.5).
|
|
414
|
+
import { createPollLoop } from "jatui"
|
|
415
|
+
// const poller = createPollLoop({ name: 'agents', intervalMs: 3000, maxIntervalMs: 60_000,
|
|
416
|
+
// tick: () => store.fetch() }) // resolve false / throw = failure → backoff; true/void = ok
|
|
417
|
+
// poller.start(); poller.stop(); poller.reset()
|
|
418
|
+
// Skips ticks while document.hidden (resumes the moment the tab is visible), never
|
|
419
|
+
// overlaps ticks, backs off exponentially with ±jitter capped at maxIntervalMs.
|
|
420
|
+
// Framework-free; the recurring-poll companion to pollOperation (one-shot).
|
|
421
|
+
import { computeBackoffDelayMs } from "jatui" // pure delay math (doubles, cap 2^6×, floored at intervalMs)
|
|
422
|
+
|
|
317
423
|
// Variant combination algebra (jatui-m4uz3.8) — pure functions behind VariantMatrixPicker.
|
|
318
424
|
import { generateCombinations, countCombinations, type VariantCategory, type VariantSelection } from "jatui"
|
|
319
425
|
// VariantCategory — { id, label, icon?, mode?: 'multiplicative' | 'additive', options: VariantOption[] }
|
|
@@ -321,6 +427,13 @@ import { generateCombinations, countCombinations, type VariantCategory, type Var
|
|
|
321
427
|
// VariantSelection — { [categoryId]: string[] } (selected option ids per category)
|
|
322
428
|
// generateCombinations(categories, selected, { forceMode? }) → [{ modifierIds, prompt, options }]
|
|
323
429
|
|
|
430
|
+
// Grouped render list (jatui-u6d4e.2) — pure, also on 'jatui/table'. Flattens filtered+sorted rows
|
|
431
|
+
// into header|row items for a table/row window; rows keep input order, groups ordered by level.compare.
|
|
432
|
+
// collapsed = Set of "::"-joined path keys ("jat::jat-vte6e"); showHeader=false renders rows ungrouped.
|
|
433
|
+
import { groupRows, groupPathKey, type GroupLevel, type GroupedItem } from "jatui"
|
|
434
|
+
// groupRows(rows, [{ key: t => t.project }, { key: t => t.epic }], { getRowId, collapsed, label? })
|
|
435
|
+
// → { kind:'group', key, path, depth, label, rows, count, collapsed } | { kind:'row', row, id, depth, groupPath, position }
|
|
436
|
+
|
|
324
437
|
// Date formatting
|
|
325
438
|
import { formatRelativeTime, formatDate, formatShortDate, formatDateTime } from "jatui"
|
|
326
439
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embed-safe keyboard shortcuts for a self-contained widget (pane, drawer,
|
|
3
|
+
* embedded panel) living inside a busy host page. Upstreamed from the JAT
|
|
4
|
+
* IDE (adoption audit U6, jatui-rl7w6.5; originally jat-nvxkt in JST).
|
|
5
|
+
*
|
|
6
|
+
* Modeled on wayflow's `dom/src/keyboard.ts` `createKeyboardScope`: a shortcut
|
|
7
|
+
* fires when focus is inside the container, OR when the event target is
|
|
8
|
+
* body/html (the state right after clicking empty space, or before anything
|
|
9
|
+
* has ever been focused) AND the most recent pointerdown/focusin landed
|
|
10
|
+
* inside the container. That "engaged" heuristic means a pane owns
|
|
11
|
+
* Delete/mod+Z immediately after a click, without permanently stealing
|
|
12
|
+
* those keys from the rest of the host page.
|
|
13
|
+
*
|
|
14
|
+
* Relationship to the shortcut registry (`createShortcutRegistry`): the
|
|
15
|
+
* registry resolves shortcuts against a *named context stack* for page-level
|
|
16
|
+
* keymaps and carries the `?` overlay. keyboardScope is the container-local
|
|
17
|
+
* complement — it decides ownership from DOM engagement (what was last
|
|
18
|
+
* clicked/focused), not from a pushed context, so a self-contained widget
|
|
19
|
+
* needs no cooperation from its host. Use the registry for app keymaps;
|
|
20
|
+
* use keyboardScope inside an embeddable widget that must own a few keys.
|
|
21
|
+
*
|
|
22
|
+
* Usage — Svelte action, handler map keyed by normalized combo:
|
|
23
|
+
*
|
|
24
|
+
* <div use:keyboardScope={{
|
|
25
|
+
* 'mod+s': () => save(),
|
|
26
|
+
* 'mod+e': () => toggleEdit(),
|
|
27
|
+
* 'escape': () => close(),
|
|
28
|
+
* }}>...</div>
|
|
29
|
+
*
|
|
30
|
+
* Usage — bare factory, for code that isn't a Svelte action (e.g. wiring the
|
|
31
|
+
* predicate into an existing keydown handler):
|
|
32
|
+
*
|
|
33
|
+
* const scope = createKeyboardScope(container)
|
|
34
|
+
* if (scope.isInScope(event.target)) { ... }
|
|
35
|
+
* scope.destroy()
|
|
36
|
+
*
|
|
37
|
+
* Combo strings combine modifiers in the fixed order shift, alt, mod, then
|
|
38
|
+
* the base key, joined by "+" (e.g. "shift+mod+z"). 'mod' resolves to Cmd on
|
|
39
|
+
* Mac, Ctrl elsewhere. Backspace normalizes to the same combo as Delete.
|
|
40
|
+
*
|
|
41
|
+
* Matching wayflow's own `interaction.ts` dispatcher, shortcuts never fire
|
|
42
|
+
* while the event target is a text-entry element (input/textarea/select
|
|
43
|
+
* /contenteditable — non-text input types like checkbox/button are exempt)
|
|
44
|
+
* — even for modifier combos — so typing in an input never triggers the
|
|
45
|
+
* widget's shortcuts.
|
|
46
|
+
*/
|
|
47
|
+
export interface KeyboardScope {
|
|
48
|
+
isInScope: (target: EventTarget | null) => boolean;
|
|
49
|
+
destroy: () => void;
|
|
50
|
+
}
|
|
51
|
+
export declare function createKeyboardScope(container: HTMLElement): KeyboardScope;
|
|
52
|
+
export type KeyboardScopeHandlers = Record<string, (event: KeyboardEvent) => void>;
|
|
53
|
+
/** Normalizes a keydown event into a combo string, e.g. "mod+z", "delete". */
|
|
54
|
+
export declare function comboFor(event: KeyboardEvent): string;
|
|
55
|
+
export declare function keyboardScope(node: HTMLElement, handlers: KeyboardScopeHandlers): {
|
|
56
|
+
update(next: KeyboardScopeHandlers): void;
|
|
57
|
+
destroy(): void;
|
|
58
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embed-safe keyboard shortcuts for a self-contained widget (pane, drawer,
|
|
3
|
+
* embedded panel) living inside a busy host page. Upstreamed from the JAT
|
|
4
|
+
* IDE (adoption audit U6, jatui-rl7w6.5; originally jat-nvxkt in JST).
|
|
5
|
+
*
|
|
6
|
+
* Modeled on wayflow's `dom/src/keyboard.ts` `createKeyboardScope`: a shortcut
|
|
7
|
+
* fires when focus is inside the container, OR when the event target is
|
|
8
|
+
* body/html (the state right after clicking empty space, or before anything
|
|
9
|
+
* has ever been focused) AND the most recent pointerdown/focusin landed
|
|
10
|
+
* inside the container. That "engaged" heuristic means a pane owns
|
|
11
|
+
* Delete/mod+Z immediately after a click, without permanently stealing
|
|
12
|
+
* those keys from the rest of the host page.
|
|
13
|
+
*
|
|
14
|
+
* Relationship to the shortcut registry (`createShortcutRegistry`): the
|
|
15
|
+
* registry resolves shortcuts against a *named context stack* for page-level
|
|
16
|
+
* keymaps and carries the `?` overlay. keyboardScope is the container-local
|
|
17
|
+
* complement — it decides ownership from DOM engagement (what was last
|
|
18
|
+
* clicked/focused), not from a pushed context, so a self-contained widget
|
|
19
|
+
* needs no cooperation from its host. Use the registry for app keymaps;
|
|
20
|
+
* use keyboardScope inside an embeddable widget that must own a few keys.
|
|
21
|
+
*
|
|
22
|
+
* Usage — Svelte action, handler map keyed by normalized combo:
|
|
23
|
+
*
|
|
24
|
+
* <div use:keyboardScope={{
|
|
25
|
+
* 'mod+s': () => save(),
|
|
26
|
+
* 'mod+e': () => toggleEdit(),
|
|
27
|
+
* 'escape': () => close(),
|
|
28
|
+
* }}>...</div>
|
|
29
|
+
*
|
|
30
|
+
* Usage — bare factory, for code that isn't a Svelte action (e.g. wiring the
|
|
31
|
+
* predicate into an existing keydown handler):
|
|
32
|
+
*
|
|
33
|
+
* const scope = createKeyboardScope(container)
|
|
34
|
+
* if (scope.isInScope(event.target)) { ... }
|
|
35
|
+
* scope.destroy()
|
|
36
|
+
*
|
|
37
|
+
* Combo strings combine modifiers in the fixed order shift, alt, mod, then
|
|
38
|
+
* the base key, joined by "+" (e.g. "shift+mod+z"). 'mod' resolves to Cmd on
|
|
39
|
+
* Mac, Ctrl elsewhere. Backspace normalizes to the same combo as Delete.
|
|
40
|
+
*
|
|
41
|
+
* Matching wayflow's own `interaction.ts` dispatcher, shortcuts never fire
|
|
42
|
+
* while the event target is a text-entry element (input/textarea/select
|
|
43
|
+
* /contenteditable — non-text input types like checkbox/button are exempt)
|
|
44
|
+
* — even for modifier combos — so typing in an input never triggers the
|
|
45
|
+
* widget's shortcuts.
|
|
46
|
+
*/
|
|
47
|
+
import { isTypingTarget } from '../utils/keyboardShortcutRegistry';
|
|
48
|
+
export function createKeyboardScope(container) {
|
|
49
|
+
const contains = (target) => target instanceof Node && container.contains(target);
|
|
50
|
+
// Capture phase so scoping is decided before any handler can stopPropagation.
|
|
51
|
+
let engaged = false;
|
|
52
|
+
const track = (event) => {
|
|
53
|
+
engaged = contains(event.target);
|
|
54
|
+
};
|
|
55
|
+
document.addEventListener('pointerdown', track, true);
|
|
56
|
+
document.addEventListener('focusin', track, true);
|
|
57
|
+
return {
|
|
58
|
+
isInScope: (target) => contains(target) ||
|
|
59
|
+
((target === document.body || target === document.documentElement) && engaged),
|
|
60
|
+
destroy() {
|
|
61
|
+
document.removeEventListener('pointerdown', track, true);
|
|
62
|
+
document.removeEventListener('focusin', track, true);
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const isMac = typeof navigator !== 'undefined' && /Mac/.test(navigator.platform ?? '');
|
|
67
|
+
const KEY_ALIASES = {
|
|
68
|
+
backspace: 'delete',
|
|
69
|
+
};
|
|
70
|
+
/** Normalizes a keydown event into a combo string, e.g. "mod+z", "delete". */
|
|
71
|
+
export function comboFor(event) {
|
|
72
|
+
const parts = [];
|
|
73
|
+
if (event.shiftKey)
|
|
74
|
+
parts.push('shift');
|
|
75
|
+
if (event.altKey)
|
|
76
|
+
parts.push('alt');
|
|
77
|
+
if (isMac ? event.metaKey : event.ctrlKey)
|
|
78
|
+
parts.push('mod');
|
|
79
|
+
const rawKey = event.key.toLowerCase();
|
|
80
|
+
parts.push(KEY_ALIASES[rawKey] ?? rawKey);
|
|
81
|
+
return parts.join('+');
|
|
82
|
+
}
|
|
83
|
+
export function keyboardScope(node, handlers) {
|
|
84
|
+
let current = handlers;
|
|
85
|
+
const scope = createKeyboardScope(node);
|
|
86
|
+
// Bound to document, not the node: the body/html-targeted branch of
|
|
87
|
+
// isInScope only ever sees events whose target IS document.body/.html,
|
|
88
|
+
// which never bubble through the container.
|
|
89
|
+
function onKeydown(event) {
|
|
90
|
+
if (!scope.isInScope(event.target))
|
|
91
|
+
return;
|
|
92
|
+
if (isTypingTarget(event.target))
|
|
93
|
+
return;
|
|
94
|
+
const handler = current[comboFor(event)];
|
|
95
|
+
if (!handler)
|
|
96
|
+
return;
|
|
97
|
+
event.preventDefault();
|
|
98
|
+
handler(event);
|
|
99
|
+
}
|
|
100
|
+
document.addEventListener('keydown', onKeydown);
|
|
101
|
+
return {
|
|
102
|
+
update(next) {
|
|
103
|
+
current = next;
|
|
104
|
+
},
|
|
105
|
+
destroy() {
|
|
106
|
+
document.removeEventListener('keydown', onKeydown);
|
|
107
|
+
scope.destroy();
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -61,6 +61,29 @@
|
|
|
61
61
|
* if (nav.handleKeydown(e)) return;
|
|
62
62
|
* // ...page-specific shortcuts
|
|
63
63
|
* }
|
|
64
|
+
*
|
|
65
|
+
* Windowed / virtualised lists (`getOrderedIds` + `revealItem`):
|
|
66
|
+
*
|
|
67
|
+
* A windowed list only renders the rows near the viewport, so a DOM query
|
|
68
|
+
* stops j/k at the window edge and `*` misses every unrendered row. Pass
|
|
69
|
+
* `getOrderedIds` and the controller navigates over that id list instead;
|
|
70
|
+
* the DOM query (or `getItems`) is then only used to find the elements that
|
|
71
|
+
* ARE rendered, so the focus/selection classes can be painted on them.
|
|
72
|
+
* When the cursor lands on an id with no rendered element, `revealItem(id)`
|
|
73
|
+
* is called to scroll it into the window; the focus class is painted once
|
|
74
|
+
* the row mounts (same frame if the reveal renders synchronously, otherwise
|
|
75
|
+
* on a following animation frame, or on the next `refresh()`).
|
|
76
|
+
*
|
|
77
|
+
* <div use:listNav={{
|
|
78
|
+
* getOrderedIds: () => table.orderedRowIds(),
|
|
79
|
+
* revealItem: (id) => table.scrollToRow(id),
|
|
80
|
+
* onSelect: (el) => openDetail(el.dataset.navId!),
|
|
81
|
+
* }}>
|
|
82
|
+
* <!-- only the windowed rows, each with data-nav-id -->
|
|
83
|
+
* </div>
|
|
84
|
+
*
|
|
85
|
+
* Enter/Space need a rendered element (`onSelect` receives it), so they
|
|
86
|
+
* pass through when the focused row has been scrolled out of the window.
|
|
64
87
|
*/
|
|
65
88
|
import type { Action } from 'svelte/action';
|
|
66
89
|
export interface ListNavOptions {
|
|
@@ -76,8 +99,13 @@ export interface ListNavOptions {
|
|
|
76
99
|
onSelect?: (item: HTMLElement, index: number) => void;
|
|
77
100
|
/** Fired when Escape is pressed. Default behaviour: clears focus. */
|
|
78
101
|
onEscape?: () => void;
|
|
79
|
-
/**
|
|
80
|
-
|
|
102
|
+
/**
|
|
103
|
+
* Fired whenever the focused index changes (including cleared focus). With
|
|
104
|
+
* {@link getOrderedIds}, `item` is null while the focused row is not
|
|
105
|
+
* rendered (index is still >= 0); it fires again with the element once the
|
|
106
|
+
* revealed row mounts. `id` is the focused item's id (null when cleared).
|
|
107
|
+
*/
|
|
108
|
+
onFocusChange?: (item: HTMLElement | null, index: number, id: string | null) => void;
|
|
81
109
|
/** When true, j at the last item wraps to first (and vice-versa). Default: true. */
|
|
82
110
|
wraparound?: boolean;
|
|
83
111
|
/** Class applied to the currently focused item. Default: `jk-focused`. */
|
|
@@ -100,6 +128,21 @@ export interface ListNavOptions {
|
|
|
100
128
|
* your items use a different attribute (e.g. `data-rule-nav-id`).
|
|
101
129
|
*/
|
|
102
130
|
getItemId?: (el: HTMLElement) => string | null | undefined;
|
|
131
|
+
/**
|
|
132
|
+
* Return every navigable item id in list order, rendered or not (e.g. a
|
|
133
|
+
* DataTable's `orderedRowIds()`). When given, it replaces the DOM query as
|
|
134
|
+
* the source of positions: j/k/G/counts/selection all range over these ids,
|
|
135
|
+
* and `getItems` / {@link itemSelector} only locate the rendered subset
|
|
136
|
+
* (matched by {@link getItemId}). Called on every navigation event.
|
|
137
|
+
*/
|
|
138
|
+
getOrderedIds?: () => readonly string[];
|
|
139
|
+
/**
|
|
140
|
+
* Bring an unrendered item into the rendered window (e.g. a DataTable's
|
|
141
|
+
* `scrollToRow(id)`). Called before an id from {@link getOrderedIds} is
|
|
142
|
+
* focused when no element for it is rendered. Rendered items are scrolled
|
|
143
|
+
* with `scrollIntoView` as usual.
|
|
144
|
+
*/
|
|
145
|
+
revealItem?: (id: string) => void;
|
|
103
146
|
}
|
|
104
147
|
export interface KeyboardShortcut {
|
|
105
148
|
key: string;
|
|
@@ -115,6 +158,8 @@ export interface ListNavController {
|
|
|
115
158
|
clear(): void;
|
|
116
159
|
/** Re-apply the focused class after items change (e.g. after a filter). */
|
|
117
160
|
refresh(): void;
|
|
161
|
+
/** Id of the focused item (null when nothing is focused or it has no id). */
|
|
162
|
+
focusedId(): string | null;
|
|
118
163
|
/** Read-only snapshot of selected item ids. */
|
|
119
164
|
selectedIds(): Set<string>;
|
|
120
165
|
/** Toggle selection. With no arg, toggles the focused item. */
|
|
@@ -123,7 +168,7 @@ export interface ListNavController {
|
|
|
123
168
|
setSelection(ids: Iterable<string>): void;
|
|
124
169
|
/** Drop the selection and exit visual mode. */
|
|
125
170
|
clearSelection(): void;
|
|
126
|
-
/**
|
|
171
|
+
/** Select every item (every id from `getOrderedIds` when given, else the rendered items). */
|
|
127
172
|
selectAll(): void;
|
|
128
173
|
/** True while visual-mode (V) is active. */
|
|
129
174
|
isSelecting(): boolean;
|