@joewinke/jatui 0.33.0 → 0.34.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 +15 -0
- package/dist/actions/reviewKeys.d.ts +159 -0
- package/dist/actions/reviewKeys.js +353 -0
- package/dist/components/review/MediaZoomLightbox.svelte +628 -0
- package/dist/components/review/MediaZoomLightbox.svelte.d.ts +56 -0
- package/dist/components/review/ReviewFilterBar.svelte +296 -0
- package/dist/components/review/ReviewFilterBar.svelte.d.ts +45 -0
- package/dist/components/review/ReviewNote.svelte +213 -0
- package/dist/components/review/ReviewNote.svelte.d.ts +37 -0
- package/dist/components/review/ReviewProgress.svelte +99 -0
- package/dist/components/review/ReviewProgress.svelte.d.ts +32 -0
- package/dist/components/review/ReviewShortcuts.svelte +80 -0
- package/dist/components/review/ReviewShortcuts.svelte.d.ts +15 -0
- package/dist/components/review/RoundHistory.svelte +151 -0
- package/dist/components/review/RoundHistory.svelte.d.ts +35 -0
- package/dist/components/review/VerdictControl.svelte +163 -0
- package/dist/components/review/VerdictControl.svelte.d.ts +23 -0
- package/dist/components/review/index.d.ts +24 -0
- package/dist/components/review/index.js +23 -0
- package/dist/components/review/mediaZoom.d.ts +115 -0
- package/dist/components/review/mediaZoom.js +161 -0
- package/dist/components/review/reviewSummary.d.ts +101 -0
- package/dist/components/review/reviewSummary.js +122 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +12 -0
- package/package.json +6 -1
package/CLAUDE.md
CHANGED
|
@@ -97,6 +97,21 @@ Requires peer `@xyflow/svelte` (install it — the IDE's build breaks on `import
|
|
|
97
97
|
|-----------|---------|-----------|
|
|
98
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?` |
|
|
99
99
|
|
|
100
|
+
### Review (also subpath: `jatui/review`)
|
|
101
|
+
|
|
102
|
+
Item-by-item review surfaces (MOS review-ux, design mos-z8vgx). One `VerdictOption[]` array (`{id, label, tone, key?, opensNote?}`) feeds the buttons, the note and the keys. Root `jatui` exports the components, the keyboard layer, the summary helpers and all types; `jatui/review` adds the zoom/pan math and `TONE_BG`/`TONE_TEXT`. Live: `/showcase/review` (all eight, light + dark).
|
|
103
|
+
|
|
104
|
+
| Component | Purpose | Key Props |
|
|
105
|
+
|-----------|---------|-----------|
|
|
106
|
+
| `VerdictControl` | Always-labelled verdict row: repeat press is a no-op, explicit Undo, radio arrows + local one-key hotkeys | `options`, `value`, `onVerdict`, `onUndo`, `size?`, `label?`, `hotkeys?` |
|
|
107
|
+
| `ReviewNote` | Optional note that opens + focuses when the verdict changes to an `opensNote` one; Esc returns focus to the `[data-item-id]` card; Saving… / Saved ✓ / Retry from async `onSave`. Exports `NOTE_HINT`. | `value`/`open` (bindable), `verdict`, `options`, `round?`, `onSave?` |
|
|
108
|
+
| `ReviewShortcuts` | `?` help modal whose rows come from the SAME `ReviewKeysOptions` the handler uses (only wired actions show) | `open` (bindable), `config`, `extraRows?`, `footnote?` |
|
|
109
|
+
| `reviewKeys` / `createReviewKeys` | The review keyboard layer over listNav: j/k/gg/G/f move, verdict keys, `u` undo, `n` note, Enter open, `[`/`]` rounds, 1–9 views, `?` help. `global: false` scopes it to the node. | `verdicts`, `onVerdict`, `onUndo`, `onNote`, `onOpen`, `needsYou`, `onHelp`, `global?` |
|
|
110
|
+
| `RoundHistory` | Locked earlier rounds behind "▸ N earlier"; newest first; select a line to read its note; pairs with a version switcher | `rounds`, `verdicts?`, `selected`/`open` (bindable), `onSelect?` |
|
|
111
|
+
| `ReviewProgress` | Segmented progress bar + "3 of 5 decided · …" sentence | `verdicts`, `counts`, `total`, `retest?`, `size?` |
|
|
112
|
+
| `ReviewFilterBar` | All · Needs you · Undecided · per-verdict · Retest chips (from `buildReviewFilters`), optional sort/group menus and Shortcuts button | `filters`, `active` (bindable), `sortOptions?`, `groupOptions?`, `onHelp?` |
|
|
113
|
+
| `MediaZoomLightbox` | Image + video lightbox for judging: wheel/key zoom + drag pan, view → item paging, play/speed/frame-step, `footer` snippet for the verdict | `items: MediaZoomView[]`, `onClose`, `onNextItem?`, `onPrevItem?`, `title?`, `position?`, `footer?`, `fps?` |
|
|
114
|
+
|
|
100
115
|
### Media
|
|
101
116
|
|
|
102
117
|
| Component | Purpose | Key Props |
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* reviewKeys — the one keyboard map for "accept, change or reject each item"
|
|
3
|
+
* review surfaces (mos-z8vgx proposal, "One keyboard map").
|
|
4
|
+
*
|
|
5
|
+
* Table-driven: REVIEW_KEY_TABLE is the single definition of every key, what
|
|
6
|
+
* it does and how it reads in the help modal. The handler dispatches FROM it,
|
|
7
|
+
* and `reviewKeyRows()` renders the help rows FROM it (ReviewShortcuts), so
|
|
8
|
+
* the docs can never drift from the behaviour. A surface only wires the
|
|
9
|
+
* handlers it supports; an unwired action's keys pass through untouched and
|
|
10
|
+
* its row is left out of the help.
|
|
11
|
+
*
|
|
12
|
+
* j / k, ↓ / ↑, gg / G, counts next / previous / first / last item (listNav)
|
|
13
|
+
* a x r b (from `verdicts`) cast that verdict on the current item
|
|
14
|
+
* u undo the current-round verdict
|
|
15
|
+
* n open / close the note
|
|
16
|
+
* e edit the proposed text
|
|
17
|
+
* Enter open big (lightbox / editor / test link)
|
|
18
|
+
* [ / ] older / newer round
|
|
19
|
+
* 1–9 pick a view (only when `onView` is wired —
|
|
20
|
+
* digits are listNav counts otherwise)
|
|
21
|
+
* f jump to the next item that needs you
|
|
22
|
+
* (retest first, then undecided)
|
|
23
|
+
* ? shortcuts
|
|
24
|
+
*
|
|
25
|
+
* Items are elements carrying `data-item-id` (the same hook ReviewNote's Esc
|
|
26
|
+
* returns focus to). Cards should be `tabindex="0"`: moving with j/k moves
|
|
27
|
+
* REAL focus onto the card (`domFocus`, default on), and a card focused by
|
|
28
|
+
* Tab or click becomes the cursor, so the keys always act on what the
|
|
29
|
+
* reviewer sees as focused.
|
|
30
|
+
*
|
|
31
|
+
* Never fires while typing (input/textarea/select/contenteditable), during
|
|
32
|
+
* IME composition, with Ctrl/Meta/Alt held, or for an event a nested control
|
|
33
|
+
* already handled (`defaultPrevented` — VerdictControl's own hotkeys).
|
|
34
|
+
*
|
|
35
|
+
* <div use:reviewKeys={{
|
|
36
|
+
* verdicts: OPTIONS,
|
|
37
|
+
* onVerdict: (id, v) => setVerdict(id, v),
|
|
38
|
+
* onUndo: (id) => undo(id),
|
|
39
|
+
* onNote: (id) => toggleNote(id),
|
|
40
|
+
* onOpen: (id) => openZoom(id),
|
|
41
|
+
* needsYou: (id) => (retest.has(id) ? 'retest' : verdicts[id] ? false : 'undecided'),
|
|
42
|
+
* onHelp: () => (helpOpen = true),
|
|
43
|
+
* }}>
|
|
44
|
+
* {#each items as it (it.id)}<article data-item-id={it.id} tabindex="0">…</article>{/each}
|
|
45
|
+
* </div>
|
|
46
|
+
* <ReviewShortcuts bind:open={helpOpen} config={sameConfig} />
|
|
47
|
+
*/
|
|
48
|
+
import type { Action } from 'svelte/action';
|
|
49
|
+
import { type ListNavController } from './listNav';
|
|
50
|
+
/** Verdict colour; maps to the filled DaisyUI button colour inside VerdictControl. */
|
|
51
|
+
export type VerdictTone = 'success' | 'warning' | 'error' | 'info' | 'neutral';
|
|
52
|
+
/**
|
|
53
|
+
* One verdict a reviewer can cast. The SAME array feeds VerdictControl (the
|
|
54
|
+
* buttons), ReviewNote (`opensNote`) and reviewKeys (`key`), so a block
|
|
55
|
+
* declares its vocabulary once.
|
|
56
|
+
*/
|
|
57
|
+
export interface VerdictOption {
|
|
58
|
+
id: string;
|
|
59
|
+
/** Always-visible button label ("Approve", "Needs work", "Fail"…). */
|
|
60
|
+
label: string;
|
|
61
|
+
tone: VerdictTone;
|
|
62
|
+
/**
|
|
63
|
+
* One-key hotkey. Use the shared slots: `a` positive, `x` changes / fail,
|
|
64
|
+
* `r` reject, `b` blocked.
|
|
65
|
+
*/
|
|
66
|
+
key?: string;
|
|
67
|
+
/** Choosing this verdict opens + focuses the item's note (ReviewNote). */
|
|
68
|
+
opensNote?: boolean;
|
|
69
|
+
}
|
|
70
|
+
/** The kind of answer `needsYou` gives about an item. */
|
|
71
|
+
export type NeedsYou = 'retest' | 'undecided' | false | null | undefined;
|
|
72
|
+
export type ReviewKeyAction = 'move' | 'verdict' | 'undo' | 'note' | 'edit' | 'open' | 'round' | 'view' | 'needsYou' | 'help';
|
|
73
|
+
export interface ReviewKeyEntry {
|
|
74
|
+
action: ReviewKeyAction;
|
|
75
|
+
/** `KeyboardEvent.key` values that trigger it (empty for the verdict slot). */
|
|
76
|
+
keys: readonly string[];
|
|
77
|
+
/** Keycaps as shown in the help modal. */
|
|
78
|
+
display: readonly string[];
|
|
79
|
+
description: string;
|
|
80
|
+
/** Help-modal section. */
|
|
81
|
+
group: 'Move' | 'Decide' | 'Item' | 'Help';
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The key table. Order is help-modal order. The `verdict` row is a slot:
|
|
85
|
+
* its keys come from the surface's `verdicts[].key`, one help row each.
|
|
86
|
+
*/
|
|
87
|
+
export declare const REVIEW_KEY_TABLE: readonly ReviewKeyEntry[];
|
|
88
|
+
export interface ReviewKeysOptions {
|
|
89
|
+
/** The surface's verdicts; each one with a `key` becomes a hotkey. */
|
|
90
|
+
verdicts?: readonly VerdictOption[];
|
|
91
|
+
onVerdict?: (itemId: string, verdictId: string) => void;
|
|
92
|
+
onUndo?: (itemId: string) => void;
|
|
93
|
+
onNote?: (itemId: string) => void;
|
|
94
|
+
onEdit?: (itemId: string) => void;
|
|
95
|
+
onOpen?: (itemId: string, el: HTMLElement | null) => void;
|
|
96
|
+
/** `[` → -1 (older round), `]` → +1 (newer round). */
|
|
97
|
+
onRound?: (itemId: string, delta: -1 | 1) => void;
|
|
98
|
+
/** 1–9 → index 0–8. Wiring this claims the digits (no listNav counts). */
|
|
99
|
+
onView?: (itemId: string, index: number) => void;
|
|
100
|
+
/** `f` support: does this item need the reviewer, and why? */
|
|
101
|
+
needsYou?: (itemId: string) => NeedsYou;
|
|
102
|
+
onHelp?: () => void;
|
|
103
|
+
/** Fired when the cursor moves (null when cleared). */
|
|
104
|
+
onFocusChange?: (itemId: string | null, el: HTMLElement | null) => void;
|
|
105
|
+
/** Escape with nothing half-typed. Default: clear the cursor. */
|
|
106
|
+
onEscape?: () => void;
|
|
107
|
+
/** Items in visual order. Action default: `[data-item-id]` under the node. */
|
|
108
|
+
getItems?: () => HTMLElement[] | ArrayLike<HTMLElement>;
|
|
109
|
+
itemSelector?: string;
|
|
110
|
+
/** Default: `el.dataset.itemId`. */
|
|
111
|
+
getItemId?: (el: HTMLElement) => string | null | undefined;
|
|
112
|
+
/** Move real DOM focus onto the card as the cursor moves. Default true. */
|
|
113
|
+
domFocus?: boolean;
|
|
114
|
+
wraparound?: boolean;
|
|
115
|
+
focusedClass?: string;
|
|
116
|
+
/** false → every key no-ops. Default true. */
|
|
117
|
+
enabled?: boolean;
|
|
118
|
+
/** Action-only: listen on window (default) or only on the node. */
|
|
119
|
+
global?: boolean;
|
|
120
|
+
}
|
|
121
|
+
/** One help row, as ReviewShortcuts renders it. */
|
|
122
|
+
export interface ReviewKeyRow {
|
|
123
|
+
keys: string[];
|
|
124
|
+
description: string;
|
|
125
|
+
group: ReviewKeyEntry['group'];
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* The help rows for a configuration, straight from REVIEW_KEY_TABLE: only
|
|
129
|
+
* wired actions, verdict slot expanded to one row per keyed verdict.
|
|
130
|
+
*/
|
|
131
|
+
export declare function reviewKeyRows(o: ReviewKeysOptions): ReviewKeyRow[];
|
|
132
|
+
/** Which table action a key resolves to under this configuration, if any. */
|
|
133
|
+
export declare function resolveReviewKey(key: string, o: ReviewKeysOptions): {
|
|
134
|
+
action: ReviewKeyAction;
|
|
135
|
+
verdictId?: string;
|
|
136
|
+
} | null;
|
|
137
|
+
export interface ReviewKeysController {
|
|
138
|
+
/** True when the event was handled (preventDefault was called). */
|
|
139
|
+
handleKeydown(e: KeyboardEvent): boolean;
|
|
140
|
+
/** Id of the current item (focused card, else the j/k cursor). */
|
|
141
|
+
currentId(): string | null;
|
|
142
|
+
/** Move the cursor (and focus) to the item with this id. */
|
|
143
|
+
focusItem(id: string): void;
|
|
144
|
+
/** `f`: jump to the next item that needs you. False when there is none. */
|
|
145
|
+
nextNeedsYou(): boolean;
|
|
146
|
+
/** Re-apply the cursor after the item list changed. */
|
|
147
|
+
refresh(): void;
|
|
148
|
+
clear(): void;
|
|
149
|
+
/** Replace the options (the action's `update`). */
|
|
150
|
+
update(next: ReviewKeysOptions): void;
|
|
151
|
+
readonly nav: ListNavController;
|
|
152
|
+
}
|
|
153
|
+
/** Imperative form — use when you already own the keydown listener. */
|
|
154
|
+
export declare function createReviewKeys(options: ReviewKeysOptions): ReviewKeysController;
|
|
155
|
+
/**
|
|
156
|
+
* Svelte action. Items default to `[data-item-id]` inside the node; listens
|
|
157
|
+
* on window unless `global: false`.
|
|
158
|
+
*/
|
|
159
|
+
export declare const reviewKeys: Action<HTMLElement, ReviewKeysOptions | undefined>;
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* reviewKeys — the one keyboard map for "accept, change or reject each item"
|
|
3
|
+
* review surfaces (mos-z8vgx proposal, "One keyboard map").
|
|
4
|
+
*
|
|
5
|
+
* Table-driven: REVIEW_KEY_TABLE is the single definition of every key, what
|
|
6
|
+
* it does and how it reads in the help modal. The handler dispatches FROM it,
|
|
7
|
+
* and `reviewKeyRows()` renders the help rows FROM it (ReviewShortcuts), so
|
|
8
|
+
* the docs can never drift from the behaviour. A surface only wires the
|
|
9
|
+
* handlers it supports; an unwired action's keys pass through untouched and
|
|
10
|
+
* its row is left out of the help.
|
|
11
|
+
*
|
|
12
|
+
* j / k, ↓ / ↑, gg / G, counts next / previous / first / last item (listNav)
|
|
13
|
+
* a x r b (from `verdicts`) cast that verdict on the current item
|
|
14
|
+
* u undo the current-round verdict
|
|
15
|
+
* n open / close the note
|
|
16
|
+
* e edit the proposed text
|
|
17
|
+
* Enter open big (lightbox / editor / test link)
|
|
18
|
+
* [ / ] older / newer round
|
|
19
|
+
* 1–9 pick a view (only when `onView` is wired —
|
|
20
|
+
* digits are listNav counts otherwise)
|
|
21
|
+
* f jump to the next item that needs you
|
|
22
|
+
* (retest first, then undecided)
|
|
23
|
+
* ? shortcuts
|
|
24
|
+
*
|
|
25
|
+
* Items are elements carrying `data-item-id` (the same hook ReviewNote's Esc
|
|
26
|
+
* returns focus to). Cards should be `tabindex="0"`: moving with j/k moves
|
|
27
|
+
* REAL focus onto the card (`domFocus`, default on), and a card focused by
|
|
28
|
+
* Tab or click becomes the cursor, so the keys always act on what the
|
|
29
|
+
* reviewer sees as focused.
|
|
30
|
+
*
|
|
31
|
+
* Never fires while typing (input/textarea/select/contenteditable), during
|
|
32
|
+
* IME composition, with Ctrl/Meta/Alt held, or for an event a nested control
|
|
33
|
+
* already handled (`defaultPrevented` — VerdictControl's own hotkeys).
|
|
34
|
+
*
|
|
35
|
+
* <div use:reviewKeys={{
|
|
36
|
+
* verdicts: OPTIONS,
|
|
37
|
+
* onVerdict: (id, v) => setVerdict(id, v),
|
|
38
|
+
* onUndo: (id) => undo(id),
|
|
39
|
+
* onNote: (id) => toggleNote(id),
|
|
40
|
+
* onOpen: (id) => openZoom(id),
|
|
41
|
+
* needsYou: (id) => (retest.has(id) ? 'retest' : verdicts[id] ? false : 'undecided'),
|
|
42
|
+
* onHelp: () => (helpOpen = true),
|
|
43
|
+
* }}>
|
|
44
|
+
* {#each items as it (it.id)}<article data-item-id={it.id} tabindex="0">…</article>{/each}
|
|
45
|
+
* </div>
|
|
46
|
+
* <ReviewShortcuts bind:open={helpOpen} config={sameConfig} />
|
|
47
|
+
*/
|
|
48
|
+
import { createListNav } from './listNav';
|
|
49
|
+
import { isTypingTarget } from '../utils/keyboardShortcutRegistry';
|
|
50
|
+
/**
|
|
51
|
+
* The key table. Order is help-modal order. The `verdict` row is a slot:
|
|
52
|
+
* its keys come from the surface's `verdicts[].key`, one help row each.
|
|
53
|
+
*/
|
|
54
|
+
export const REVIEW_KEY_TABLE = [
|
|
55
|
+
{
|
|
56
|
+
action: 'move',
|
|
57
|
+
keys: [],
|
|
58
|
+
display: ['j', 'k'],
|
|
59
|
+
description: 'Next / previous item (↓ ↑ too; a number first moves that many)',
|
|
60
|
+
group: 'Move'
|
|
61
|
+
},
|
|
62
|
+
{ action: 'move', keys: [], display: ['gg', 'G'], description: 'First / last item', group: 'Move' },
|
|
63
|
+
{ action: 'needsYou', keys: ['f'], display: ['f'], description: 'Next item that needs you (retest first, then undecided)', group: 'Move' },
|
|
64
|
+
{ action: 'verdict', keys: [], display: [], description: '', group: 'Decide' },
|
|
65
|
+
{ action: 'undo', keys: ['u'], display: ['u'], description: 'Undo this round’s verdict (the note stays)', group: 'Decide' },
|
|
66
|
+
{ action: 'note', keys: ['n'], display: ['n'], description: 'Open or close the note', group: 'Decide' },
|
|
67
|
+
{ action: 'edit', keys: ['e'], display: ['e'], description: 'Edit the proposed text', group: 'Item' },
|
|
68
|
+
{ action: 'open', keys: ['Enter'], display: ['Enter'], description: 'Open big', group: 'Item' },
|
|
69
|
+
{ action: 'round', keys: ['[', ']'], display: ['[', ']'], description: 'Older / newer round', group: 'Item' },
|
|
70
|
+
{ action: 'view', keys: ['1', '2', '3', '4', '5', '6', '7', '8', '9'], display: ['1–9'], description: 'Pick a view', group: 'Item' },
|
|
71
|
+
{ action: 'help', keys: ['?'], display: ['?'], description: 'Shortcuts', group: 'Help' }
|
|
72
|
+
];
|
|
73
|
+
/** Is `entry` wired for this configuration? */
|
|
74
|
+
function wired(entry, o) {
|
|
75
|
+
switch (entry.action) {
|
|
76
|
+
case 'move':
|
|
77
|
+
return true;
|
|
78
|
+
case 'verdict':
|
|
79
|
+
return !!o.onVerdict;
|
|
80
|
+
case 'undo':
|
|
81
|
+
return !!o.onUndo;
|
|
82
|
+
case 'note':
|
|
83
|
+
return !!o.onNote;
|
|
84
|
+
case 'edit':
|
|
85
|
+
return !!o.onEdit;
|
|
86
|
+
case 'open':
|
|
87
|
+
return !!o.onOpen;
|
|
88
|
+
case 'round':
|
|
89
|
+
return !!o.onRound;
|
|
90
|
+
case 'view':
|
|
91
|
+
return !!o.onView;
|
|
92
|
+
case 'needsYou':
|
|
93
|
+
return !!o.needsYou;
|
|
94
|
+
case 'help':
|
|
95
|
+
return !!o.onHelp;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The help rows for a configuration, straight from REVIEW_KEY_TABLE: only
|
|
100
|
+
* wired actions, verdict slot expanded to one row per keyed verdict.
|
|
101
|
+
*/
|
|
102
|
+
export function reviewKeyRows(o) {
|
|
103
|
+
const rows = [];
|
|
104
|
+
for (const entry of REVIEW_KEY_TABLE) {
|
|
105
|
+
if (!wired(entry, o))
|
|
106
|
+
continue;
|
|
107
|
+
if (entry.action === 'verdict') {
|
|
108
|
+
for (const v of o.verdicts ?? []) {
|
|
109
|
+
if (v.key)
|
|
110
|
+
rows.push({ keys: [v.key], description: v.label, group: entry.group });
|
|
111
|
+
}
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
rows.push({ keys: [...entry.display], description: entry.description, group: entry.group });
|
|
115
|
+
}
|
|
116
|
+
return rows;
|
|
117
|
+
}
|
|
118
|
+
/** Which table action a key resolves to under this configuration, if any. */
|
|
119
|
+
export function resolveReviewKey(key, o) {
|
|
120
|
+
if (o.onVerdict) {
|
|
121
|
+
const v = o.verdicts?.find((opt) => opt.key === key);
|
|
122
|
+
if (v)
|
|
123
|
+
return { action: 'verdict', verdictId: v.id };
|
|
124
|
+
}
|
|
125
|
+
for (const entry of REVIEW_KEY_TABLE) {
|
|
126
|
+
if (entry.action === 'verdict' || entry.action === 'move')
|
|
127
|
+
continue;
|
|
128
|
+
if (entry.keys.includes(key) && wired(entry, o))
|
|
129
|
+
return { action: entry.action };
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
/** Controls that own Enter by real DOM focus (mirrors listNav's focus honesty). */
|
|
134
|
+
const ACTIVATABLE = 'a[href], button, summary, [role="button"], [role="radio"]';
|
|
135
|
+
/** Imperative form — use when you already own the keydown listener. */
|
|
136
|
+
export function createReviewKeys(options) {
|
|
137
|
+
let opts = options;
|
|
138
|
+
const itemId = (el) => {
|
|
139
|
+
const raw = opts.getItemId ? opts.getItemId(el) : el.dataset.itemId;
|
|
140
|
+
return raw == null ? null : String(raw);
|
|
141
|
+
};
|
|
142
|
+
const items = () => {
|
|
143
|
+
const got = opts.getItems?.() ?? [];
|
|
144
|
+
return Array.isArray(got) ? got : Array.from(got);
|
|
145
|
+
};
|
|
146
|
+
const nav = createListNav({
|
|
147
|
+
getItems: () => items(),
|
|
148
|
+
getItemId: (el) => itemId(el),
|
|
149
|
+
get wraparound() {
|
|
150
|
+
return opts.wraparound;
|
|
151
|
+
},
|
|
152
|
+
get focusedClass() {
|
|
153
|
+
return opts.focusedClass;
|
|
154
|
+
},
|
|
155
|
+
// Escape only reaches listNav's default (clear) when no handler is given.
|
|
156
|
+
get onEscape() {
|
|
157
|
+
return opts.onEscape;
|
|
158
|
+
},
|
|
159
|
+
onFocusChange: (el, _i, id) => {
|
|
160
|
+
if (el && opts.domFocus !== false && el !== document.activeElement && !el.contains(document.activeElement)) {
|
|
161
|
+
el.focus({ preventScroll: true });
|
|
162
|
+
}
|
|
163
|
+
opts.onFocusChange?.(id, el);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
/** The item holding real focus, if any. */
|
|
167
|
+
function focusedCard() {
|
|
168
|
+
if (typeof document === 'undefined')
|
|
169
|
+
return null;
|
|
170
|
+
const active = document.activeElement;
|
|
171
|
+
if (!(active instanceof HTMLElement))
|
|
172
|
+
return null;
|
|
173
|
+
const list = items();
|
|
174
|
+
return list.find((el) => el === active || el.contains(active)) ?? null;
|
|
175
|
+
}
|
|
176
|
+
/** Make the listNav cursor follow real focus (Tab, click, ReviewNote's Esc). */
|
|
177
|
+
function syncCursor() {
|
|
178
|
+
const card = focusedCard();
|
|
179
|
+
if (!card)
|
|
180
|
+
return;
|
|
181
|
+
const id = itemId(card);
|
|
182
|
+
if (id === null || id === nav.focusedId())
|
|
183
|
+
return;
|
|
184
|
+
const idx = items().indexOf(card);
|
|
185
|
+
if (idx >= 0)
|
|
186
|
+
nav.focus(idx);
|
|
187
|
+
}
|
|
188
|
+
function currentId() {
|
|
189
|
+
const card = focusedCard();
|
|
190
|
+
if (card)
|
|
191
|
+
return itemId(card);
|
|
192
|
+
return nav.focusedId();
|
|
193
|
+
}
|
|
194
|
+
function currentEl(id) {
|
|
195
|
+
return items().find((el) => itemId(el) === id) ?? null;
|
|
196
|
+
}
|
|
197
|
+
function focusItem(id) {
|
|
198
|
+
const idx = items().findIndex((el) => itemId(el) === id);
|
|
199
|
+
if (idx >= 0)
|
|
200
|
+
nav.focus(idx);
|
|
201
|
+
}
|
|
202
|
+
function nextNeedsYou() {
|
|
203
|
+
const ask = opts.needsYou;
|
|
204
|
+
if (!ask)
|
|
205
|
+
return false;
|
|
206
|
+
const ids = items()
|
|
207
|
+
.map(itemId)
|
|
208
|
+
.filter((x) => x !== null);
|
|
209
|
+
if (ids.length === 0)
|
|
210
|
+
return false;
|
|
211
|
+
const cur = currentId();
|
|
212
|
+
const start = cur === null ? -1 : ids.indexOf(cur);
|
|
213
|
+
// Walk once from just after the cursor, wrapping; retest wins over undecided.
|
|
214
|
+
let firstUndecided = null;
|
|
215
|
+
for (let step = 1; step <= ids.length; step++) {
|
|
216
|
+
const id = ids[(start + step + ids.length) % ids.length];
|
|
217
|
+
const why = ask(id);
|
|
218
|
+
if (why === 'retest') {
|
|
219
|
+
focusItem(id);
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
if (why === 'undecided' && firstUndecided === null)
|
|
223
|
+
firstUndecided = id;
|
|
224
|
+
}
|
|
225
|
+
if (firstUndecided === null)
|
|
226
|
+
return false;
|
|
227
|
+
focusItem(firstUndecided);
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* A table key ends a half-typed listNav motion (`2a` must not leave a
|
|
232
|
+
* pending count for the next j). listNav drops pending state on any key it
|
|
233
|
+
* doesn't own, so hand it one.
|
|
234
|
+
*/
|
|
235
|
+
function dropPendingMotion() {
|
|
236
|
+
if (typeof KeyboardEvent === 'undefined')
|
|
237
|
+
return;
|
|
238
|
+
nav.handleKeydown(new KeyboardEvent('keydown', { key: 'Unidentified' }));
|
|
239
|
+
}
|
|
240
|
+
function handleKeydown(e) {
|
|
241
|
+
if (opts.enabled === false)
|
|
242
|
+
return false;
|
|
243
|
+
if (e.defaultPrevented || e.isComposing)
|
|
244
|
+
return false;
|
|
245
|
+
if (isTypingTarget(e.target))
|
|
246
|
+
return false;
|
|
247
|
+
syncCursor();
|
|
248
|
+
if (!e.ctrlKey && !e.metaKey && !e.altKey) {
|
|
249
|
+
const hit = resolveReviewKey(e.key, opts);
|
|
250
|
+
if (hit) {
|
|
251
|
+
// `?` and `f` don't need a current item; everything else does.
|
|
252
|
+
if (hit.action === 'help') {
|
|
253
|
+
e.preventDefault();
|
|
254
|
+
dropPendingMotion();
|
|
255
|
+
opts.onHelp?.();
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
if (hit.action === 'needsYou') {
|
|
259
|
+
e.preventDefault();
|
|
260
|
+
dropPendingMotion();
|
|
261
|
+
nextNeedsYou();
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
const id = currentId();
|
|
265
|
+
if (id === null)
|
|
266
|
+
return false;
|
|
267
|
+
if (hit.action === 'open') {
|
|
268
|
+
// Focus honesty: Enter on a real control inside the card presses it.
|
|
269
|
+
const card = currentEl(id);
|
|
270
|
+
if (e.target instanceof HTMLElement && e.target !== card && e.target.matches(ACTIVATABLE))
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
e.preventDefault();
|
|
274
|
+
dropPendingMotion();
|
|
275
|
+
switch (hit.action) {
|
|
276
|
+
case 'verdict':
|
|
277
|
+
opts.onVerdict?.(id, hit.verdictId);
|
|
278
|
+
break;
|
|
279
|
+
case 'undo':
|
|
280
|
+
opts.onUndo?.(id);
|
|
281
|
+
break;
|
|
282
|
+
case 'note':
|
|
283
|
+
opts.onNote?.(id);
|
|
284
|
+
break;
|
|
285
|
+
case 'edit':
|
|
286
|
+
opts.onEdit?.(id);
|
|
287
|
+
break;
|
|
288
|
+
case 'open':
|
|
289
|
+
opts.onOpen?.(id, currentEl(id));
|
|
290
|
+
break;
|
|
291
|
+
case 'round':
|
|
292
|
+
opts.onRound?.(id, e.key === '[' ? -1 : 1);
|
|
293
|
+
break;
|
|
294
|
+
case 'view':
|
|
295
|
+
opts.onView?.(id, Number(e.key) - 1);
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
// Enter is the table's `open`; Space keeps its native meaning (scroll /
|
|
302
|
+
// press) instead of listNav's select.
|
|
303
|
+
if (e.key === ' ' || e.key === 'Enter')
|
|
304
|
+
return false;
|
|
305
|
+
return nav.handleKeydown(e);
|
|
306
|
+
}
|
|
307
|
+
return {
|
|
308
|
+
handleKeydown,
|
|
309
|
+
currentId,
|
|
310
|
+
focusItem,
|
|
311
|
+
nextNeedsYou,
|
|
312
|
+
refresh: () => nav.refresh(),
|
|
313
|
+
clear: () => nav.clear(),
|
|
314
|
+
update(next) {
|
|
315
|
+
opts = next;
|
|
316
|
+
nav.refresh();
|
|
317
|
+
},
|
|
318
|
+
nav
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Svelte action. Items default to `[data-item-id]` inside the node; listens
|
|
323
|
+
* on window unless `global: false`.
|
|
324
|
+
*/
|
|
325
|
+
export const reviewKeys = (node, initial = {}) => {
|
|
326
|
+
const withItems = (o) => o.getItems
|
|
327
|
+
? o
|
|
328
|
+
: {
|
|
329
|
+
...o,
|
|
330
|
+
getItems: () => Array.from(node.querySelectorAll(o.itemSelector ?? '[data-item-id]'))
|
|
331
|
+
};
|
|
332
|
+
let current = initial;
|
|
333
|
+
const ctl = createReviewKeys(withItems(current));
|
|
334
|
+
const onKeydown = (e) => ctl.handleKeydown(e);
|
|
335
|
+
let target = current.global === false ? node : window;
|
|
336
|
+
target.addEventListener('keydown', onKeydown);
|
|
337
|
+
return {
|
|
338
|
+
update(next = {}) {
|
|
339
|
+
const wasGlobal = current.global !== false;
|
|
340
|
+
current = next;
|
|
341
|
+
ctl.update(withItems(next));
|
|
342
|
+
if (wasGlobal !== (next.global !== false)) {
|
|
343
|
+
target.removeEventListener('keydown', onKeydown);
|
|
344
|
+
target = next.global === false ? node : window;
|
|
345
|
+
target.addEventListener('keydown', onKeydown);
|
|
346
|
+
}
|
|
347
|
+
},
|
|
348
|
+
destroy() {
|
|
349
|
+
target.removeEventListener('keydown', onKeydown);
|
|
350
|
+
ctl.clear();
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
};
|