cursedbelt 5.78.0 → 5.79.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/dist/react/canvas-hud/HudControls.d.ts +8 -2
- package/dist/react/canvas-hud/HudControls.d.ts.map +1 -1
- package/dist/react/canvas-hud/HudControls.js +6 -3
- package/dist/react/canvas-hud/HudControls.js.map +1 -1
- package/dist/react/components/AppShell.d.ts +14 -0
- package/dist/react/components/AppShell.d.ts.map +1 -1
- package/dist/react/components/AppShell.js +7 -31
- package/dist/react/components/AppShell.js.map +1 -1
- package/dist/react/components/ChipFilterStrip.d.ts.map +1 -1
- package/dist/react/components/ChipFilterStrip.js +2 -18
- package/dist/react/components/ChipFilterStrip.js.map +1 -1
- package/dist/react/components/KanbanBoard.d.ts +55 -0
- package/dist/react/components/KanbanBoard.d.ts.map +1 -0
- package/dist/react/components/KanbanBoard.js +89 -0
- package/dist/react/components/KanbanBoard.js.map +1 -0
- package/dist/react/components/ResponsiveWorkbench.d.ts +14 -1
- package/dist/react/components/ResponsiveWorkbench.d.ts.map +1 -1
- package/dist/react/components/ResponsiveWorkbench.js +33 -4
- package/dist/react/components/ResponsiveWorkbench.js.map +1 -1
- package/dist/react/components/SelectField.d.ts +18 -48
- package/dist/react/components/SelectField.d.ts.map +1 -1
- package/dist/react/components/SelectField.js +88 -11
- package/dist/react/components/SelectField.js.map +1 -1
- package/dist/react/components/SelectFieldImpl.d.ts +7 -0
- package/dist/react/components/SelectFieldImpl.d.ts.map +1 -0
- package/dist/react/components/SelectFieldImpl.js +26 -0
- package/dist/react/components/SelectFieldImpl.js.map +1 -0
- package/dist/react/components/filterChip.d.ts +8 -0
- package/dist/react/components/filterChip.d.ts.map +1 -0
- package/dist/react/components/filterChip.js +20 -0
- package/dist/react/components/filterChip.js.map +1 -0
- package/dist/react/hooks/pointerDnd.d.ts +37 -3
- package/dist/react/hooks/pointerDnd.d.ts.map +1 -1
- package/dist/react/hooks/pointerDnd.js +101 -9
- package/dist/react/hooks/pointerDnd.js.map +1 -1
- package/dist/react/hooks/pragmaticDnd.d.ts +4 -1
- package/dist/react/hooks/pragmaticDnd.d.ts.map +1 -1
- package/dist/react/hooks/pragmaticDnd.js +9 -3
- package/dist/react/hooks/pragmaticDnd.js.map +1 -1
- package/dist/react/hooks/usePublishedBottomChrome.d.ts +12 -0
- package/dist/react/hooks/usePublishedBottomChrome.d.ts.map +1 -0
- package/dist/react/hooks/usePublishedBottomChrome.js +31 -0
- package/dist/react/hooks/usePublishedBottomChrome.js.map +1 -0
- package/dist/react/index.d.ts +3 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +3 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/lib/selectField.d.ts +72 -0
- package/dist/react/lib/selectField.d.ts.map +1 -0
- package/dist/react/lib/selectField.js +23 -0
- package/dist/react/lib/selectField.js.map +1 -0
- package/dist/react/media/VideoChapterEditor.d.ts.map +1 -1
- package/dist/react/media/VideoChapterEditor.js +2 -1
- package/dist/react/media/VideoChapterEditor.js.map +1 -1
- package/dist/react/media/VideoCutEditor.d.ts.map +1 -1
- package/dist/react/media/VideoCutEditor.js +2 -1
- package/dist/react/media/VideoCutEditor.js.map +1 -1
- package/dist/react/media/denseGallery.css +2 -0
- package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
- package/dist/react/media-gallery/MediaGallery.js +6 -7
- package/dist/react/media-gallery/MediaGallery.js.map +1 -1
- package/dist/react/media-gallery/MediaMetaEditor.d.ts.map +1 -1
- package/dist/react/media-gallery/MediaMetaEditor.js +2 -1
- package/dist/react/media-gallery/MediaMetaEditor.js.map +1 -1
- package/dist/react/nav/CommandBar.d.ts.map +1 -1
- package/dist/react/nav/CommandBar.js +2 -1
- package/dist/react/nav/CommandBar.js.map +1 -1
- package/dist/react/palette/CommandPalette.d.ts +15 -1
- package/dist/react/palette/CommandPalette.d.ts.map +1 -1
- package/dist/react/palette/CommandPalette.js +58 -8
- package/dist/react/palette/CommandPalette.js.map +1 -1
- package/dist/react/palette/Kbd.d.ts +15 -0
- package/dist/react/palette/Kbd.d.ts.map +1 -0
- package/dist/react/palette/Kbd.js +17 -0
- package/dist/react/palette/Kbd.js.map +1 -0
- package/dist/react/palette/ShortcutSheet.d.ts +30 -0
- package/dist/react/palette/ShortcutSheet.d.ts.map +1 -0
- package/dist/react/palette/ShortcutSheet.js +15 -0
- package/dist/react/palette/ShortcutSheet.js.map +1 -0
- package/dist/react/palette/commandSources.d.ts +14 -0
- package/dist/react/palette/commandSources.d.ts.map +1 -1
- package/dist/react/palette/commandSources.js +11 -1
- package/dist/react/palette/commandSources.js.map +1 -1
- package/dist/react/palette/index.d.ts +4 -2
- package/dist/react/palette/index.d.ts.map +1 -1
- package/dist/react/palette/index.js +2 -0
- package/dist/react/palette/index.js.map +1 -1
- package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
- package/dist/styles-areas/canvas-hud.css +2 -2
- package/dist/styles-areas/kanban.css +31 -0
- package/dist/styles-areas/palette.css +3 -3
- package/dist/styles-areas/workbench.css +15 -13
- package/dist/styles-utilities.css +42 -0
- package/package.json +3 -2
- package/scripts/guardrails-baseline.json +2 -1
- package/scripts/guardrailsEnforce.spec.ts +62 -0
- package/scripts/guardrailsEnforce.ts +77 -6
- package/scripts/styleAreas.ts +2 -0
- package/src/react/canvas-hud/HudControls.spec.tsx +11 -0
- package/src/react/canvas-hud/HudControls.tsx +33 -9
- package/src/react/components/AppShell.spec.tsx +40 -0
- package/src/react/components/AppShell.tsx +25 -27
- package/src/react/components/ChipFilterStrip.tsx +2 -38
- package/src/react/components/KanbanBoard.spec.tsx +89 -0
- package/src/react/components/KanbanBoard.tsx +270 -0
- package/src/react/components/ResponsiveWorkbench.spec.tsx +57 -0
- package/src/react/components/ResponsiveWorkbench.tsx +69 -4
- package/src/react/components/SelectField.spec.tsx +117 -0
- package/src/react/components/SelectField.tsx +131 -166
- package/src/react/components/SelectFieldImpl.tsx +140 -0
- package/src/react/components/filterChip.tsx +43 -0
- package/src/react/hooks/pointerDnd.spec.ts +89 -3
- package/src/react/hooks/pointerDnd.ts +103 -8
- package/src/react/hooks/pragmaticDnd.ts +13 -2
- package/src/react/hooks/usePublishedBottomChrome.ts +30 -0
- package/src/react/hooks/useScrollMemory.spec.tsx +7 -5
- package/src/react/index.ts +3 -0
- package/src/react/lib/floatingStyles.spec.ts +1 -1
- package/src/react/lib/selectField.ts +96 -0
- package/src/react/media/VideoChapterEditor.tsx +3 -2
- package/src/react/media/VideoCutEditor.tsx +3 -2
- package/src/react/media/denseGallery.css +2 -0
- package/src/react/media-gallery/MediaGallery.spec.tsx +15 -11
- package/src/react/media-gallery/MediaGallery.tsx +16 -18
- package/src/react/media-gallery/MediaMetaEditor.tsx +2 -1
- package/src/react/nav/CommandBar.tsx +2 -3
- package/src/react/palette/CommandPalette.spec.tsx +73 -0
- package/src/react/palette/CommandPalette.tsx +153 -13
- package/src/react/palette/Kbd.tsx +46 -0
- package/src/react/palette/ShortcutSheet.spec.tsx +58 -0
- package/src/react/palette/ShortcutSheet.tsx +110 -0
- package/src/react/palette/commandSources.tsx +30 -1
- package/src/react/palette/index.ts +9 -0
- package/src/styles-areas/canvas-hud.css +2 -2
- package/src/styles-areas/kanban.css +31 -0
- package/src/styles-areas/palette.css +3 -3
- package/src/styles-areas/workbench.css +15 -13
- package/src/styles-utilities.css +42 -0
|
@@ -6,6 +6,7 @@ import { FieldLabel } from '../components/FieldLabel.js';
|
|
|
6
6
|
import { InfoHint } from '../components/InfoHint.js';
|
|
7
7
|
import { Modal } from '../components/Modal.js';
|
|
8
8
|
import { TagsInput } from '../components/TagsInput.js';
|
|
9
|
+
import { Kbd } from '../palette/Kbd.js';
|
|
9
10
|
import {
|
|
10
11
|
type MediaMetaChange,
|
|
11
12
|
cleanTags,
|
|
@@ -156,7 +157,7 @@ export function MediaMetaEditor({
|
|
|
156
157
|
<>
|
|
157
158
|
Tags are how this file is found again: each one becomes a chip above the
|
|
158
159
|
grid that filters the album down to the things wearing it. Type and press{' '}
|
|
159
|
-
<
|
|
160
|
+
<Kbd>Enter</Kbd>, or pick one already in use.
|
|
160
161
|
</>
|
|
161
162
|
}
|
|
162
163
|
>
|
|
@@ -2,6 +2,7 @@ import { Plus, Search } from 'lucide-react';
|
|
|
2
2
|
import type { ReactNode } from 'react';
|
|
3
3
|
import { Button } from '../components/Button.js';
|
|
4
4
|
import { cn } from '../lib/cn.js';
|
|
5
|
+
import { Kbd } from '../palette/Kbd.js';
|
|
5
6
|
|
|
6
7
|
export interface CommandBarProps {
|
|
7
8
|
/**
|
|
@@ -93,9 +94,7 @@ export function CommandBar({
|
|
|
93
94
|
{commandPlaceholder}
|
|
94
95
|
</span>
|
|
95
96
|
{commandHint != null && !compact && (
|
|
96
|
-
<
|
|
97
|
-
{commandHint}
|
|
98
|
-
</kbd>
|
|
97
|
+
<Kbd className='hidden shrink-0 items-center sm:inline-flex'>{commandHint}</Kbd>
|
|
99
98
|
)}
|
|
100
99
|
</Button>
|
|
101
100
|
)}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { afterEach, describe, expect, mock, test } from 'bun:test';
|
|
2
2
|
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
3
3
|
import type { GateFn, NavGate, NavigationModel } from 'cursedbelt-core/navigation';
|
|
4
|
+
import { formatHotkey, isApplePlatform } from 'cursedbelt-core/palette/hotkeys';
|
|
4
5
|
import { shortcutRegistry } from 'cursedbelt-core/palette/shortcut-registry';
|
|
5
6
|
import type { SearchResultGroup } from '../nav/SearchResults.js';
|
|
6
7
|
import { CommandPalette } from './CommandPalette.js';
|
|
@@ -451,3 +452,75 @@ describe('contributed sources — recent items first (OVL-8)', () => {
|
|
|
451
452
|
localStorage.removeItem(PALETTE_RECENT_STORAGE_KEY);
|
|
452
453
|
});
|
|
453
454
|
});
|
|
455
|
+
|
|
456
|
+
describe('a row chord and the hint footer (the vault omnibar, forge task 3031)', () => {
|
|
457
|
+
const mod = { metaKey: isApplePlatform(), ctrlKey: !isApplePlatform() };
|
|
458
|
+
const setup = (overrides: { hints?: { keys: string; label: string; onSelect?: () => void }[] } = {}) => {
|
|
459
|
+
const copied: string[] = [];
|
|
460
|
+
const onOpenChange = mock((_open: boolean) => {});
|
|
461
|
+
const sources = [
|
|
462
|
+
{
|
|
463
|
+
group: 'Entries',
|
|
464
|
+
items: [
|
|
465
|
+
{ id: 'a', label: 'Alpha', onSelect: () => {}, actions: [{ keys: '$mod+c', label: 'Copy secret', run: () => copied.push('a') }] },
|
|
466
|
+
{ id: 'b', label: 'Beta', onSelect: () => {} },
|
|
467
|
+
{ id: 'c', label: 'Gamma', onSelect: () => {}, actions: [{ keys: '$mod+c', label: 'Copy secret', run: () => copied.push('c') }] },
|
|
468
|
+
],
|
|
469
|
+
},
|
|
470
|
+
];
|
|
471
|
+
render(
|
|
472
|
+
<CommandPalette
|
|
473
|
+
model={model}
|
|
474
|
+
onNavigate={() => {}}
|
|
475
|
+
shortcuts={[]}
|
|
476
|
+
sources={sources}
|
|
477
|
+
open
|
|
478
|
+
onOpenChange={onOpenChange}
|
|
479
|
+
hints={'hints' in overrides ? overrides.hints : []}
|
|
480
|
+
/>,
|
|
481
|
+
);
|
|
482
|
+
const input = screen.getByRole('combobox');
|
|
483
|
+
return { copied, onOpenChange, input };
|
|
484
|
+
};
|
|
485
|
+
const footerActions = () =>
|
|
486
|
+
Array.from(document.querySelectorAll('[data-cb-palette-action]')).map((el) => el.textContent);
|
|
487
|
+
|
|
488
|
+
test('the chord runs on the HIGHLIGHTED row only, and closes the palette', () => {
|
|
489
|
+
localStorage.removeItem(PALETTE_RECENT_STORAGE_KEY);
|
|
490
|
+
const { copied, onOpenChange, input } = setup();
|
|
491
|
+
// Alpha is highlighted first: the footer names its chord.
|
|
492
|
+
expect(footerActions()).toEqual([`${formatHotkey('$mod+c')}Copy secret`]);
|
|
493
|
+
fireEvent.keyDown(input, { key: 'ArrowDown' });
|
|
494
|
+
// Beta has no chord: the footer drops it, and the chord does nothing.
|
|
495
|
+
expect(footerActions()).toEqual([]);
|
|
496
|
+
fireEvent.keyDown(input, { key: 'c', ...mod });
|
|
497
|
+
expect(copied).toEqual([]);
|
|
498
|
+
expect(onOpenChange).not.toHaveBeenCalled();
|
|
499
|
+
fireEvent.keyDown(input, { key: 'ArrowDown' });
|
|
500
|
+
fireEvent.keyDown(input, { key: 'c', ...mod });
|
|
501
|
+
expect(copied).toEqual(['c']);
|
|
502
|
+
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
503
|
+
localStorage.removeItem(PALETTE_RECENT_STORAGE_KEY);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test('⌘C over SELECTED query text is the input’s copy, never the row’s', () => {
|
|
507
|
+
const { copied, input } = setup();
|
|
508
|
+
fireEvent.change(input, { target: { value: 'al' } });
|
|
509
|
+
(input as HTMLInputElement).setSelectionRange(0, 2);
|
|
510
|
+
fireEvent.keyDown(input, { key: 'c', ...mod });
|
|
511
|
+
expect(copied).toEqual([]);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test('a hint with onSelect is a button that closes the palette and runs', () => {
|
|
515
|
+
const opened: string[] = [];
|
|
516
|
+
const { onOpenChange } = setup({ hints: [{ keys: '?', label: 'Shortcuts', onSelect: () => opened.push('sheet') }] });
|
|
517
|
+
fireEvent.click(screen.getByRole('button', { name: /Shortcuts/ }));
|
|
518
|
+
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
519
|
+
expect(opened).toEqual(['sheet']);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
test('no hints, no footer — the default every other palette keeps', () => {
|
|
523
|
+
setup({ hints: undefined });
|
|
524
|
+
expect(document.querySelector('[data-cb-palette-hints]')).toBeNull();
|
|
525
|
+
});
|
|
526
|
+
});
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import * as Dialog from '@radix-ui/react-dialog';
|
|
15
15
|
import { Pin, Plus } from 'lucide-react';
|
|
16
|
-
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
16
|
+
import { type KeyboardEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
17
17
|
import {
|
|
18
18
|
allowAllGate,
|
|
19
19
|
deriveSearchCatalogue,
|
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
type SearchCatalogueEntry,
|
|
25
25
|
} from 'cursedbelt-core/navigation';
|
|
26
26
|
import { fuzzyRankItems } from 'cursedbelt-core/palette/fuzzy-rank';
|
|
27
|
-
import {
|
|
27
|
+
import { matchHotkey } from 'cursedbelt-core/palette/hotkeys';
|
|
28
28
|
import type { ShortcutEntry } from 'cursedbelt-core/palette/shortcut-registry';
|
|
29
29
|
import { Button } from '../components/Button.js';
|
|
30
30
|
import { ScrollArea } from '../components/ScrollArea.js';
|
|
@@ -43,7 +43,14 @@ import {
|
|
|
43
43
|
CommandItem,
|
|
44
44
|
CommandList,
|
|
45
45
|
} from './Command.js';
|
|
46
|
-
import {
|
|
46
|
+
import {
|
|
47
|
+
type CommandItemAction,
|
|
48
|
+
type CommandSource,
|
|
49
|
+
type CommandSourceItem,
|
|
50
|
+
recentFirst,
|
|
51
|
+
rememberCommandItem,
|
|
52
|
+
} from './commandSources.js';
|
|
53
|
+
import { Kbd } from './Kbd.js';
|
|
47
54
|
import { useShortcutRegistry } from './useShortcutRegistry.js';
|
|
48
55
|
import { TAP_TARGET_HEIGHT } from '../lib/tapTarget.js';
|
|
49
56
|
|
|
@@ -106,6 +113,13 @@ export interface CommandPaletteProps {
|
|
|
106
113
|
*/
|
|
107
114
|
sources?: readonly CommandSource[];
|
|
108
115
|
|
|
116
|
+
/**
|
|
117
|
+
* A key-hint row along the palette's foot: ↑↓ Navigate · ↵ Open, the highlighted row's own
|
|
118
|
+
* chords (its `actions` — "⌘C Copy secret"), then these. A hint with `onSelect` is a button
|
|
119
|
+
* (the vault's "? Shortcuts" opens its sheet). Omit for no footer, the default.
|
|
120
|
+
*/
|
|
121
|
+
hints?: readonly CommandPaletteHint[];
|
|
122
|
+
|
|
109
123
|
/** Resolve model icon *names* to nodes (the app wires its icon set, e.g. lucide). */
|
|
110
124
|
resolveIcon?: IconResolver;
|
|
111
125
|
/** Max fuzzy page results. Default 30. */
|
|
@@ -116,6 +130,24 @@ export interface CommandPaletteProps {
|
|
|
116
130
|
className?: string;
|
|
117
131
|
}
|
|
118
132
|
|
|
133
|
+
/** One entry in the palette's foot (see {@link CommandPaletteProps.hints}). */
|
|
134
|
+
export interface CommandPaletteHint {
|
|
135
|
+
/** A binding (`'?'`, `'$mod+/'`), drawn as a key cap. */
|
|
136
|
+
keys: string;
|
|
137
|
+
label: string;
|
|
138
|
+
/** Makes the hint a button. The palette closes first. */
|
|
139
|
+
onSelect?: () => void;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The row a chord acts on: its actions, and what to remember it as when one runs. */
|
|
143
|
+
interface RowActions {
|
|
144
|
+
group: string;
|
|
145
|
+
id: string;
|
|
146
|
+
actions: readonly CommandItemAction[];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const NO_ROW: RowActions | undefined = undefined;
|
|
150
|
+
|
|
119
151
|
/** A page hit grouped under its owning domain, preserving uFuzzy rank order. */
|
|
120
152
|
interface PageGroup {
|
|
121
153
|
id: string;
|
|
@@ -152,6 +184,7 @@ export function CommandPalette({
|
|
|
152
184
|
searchDebounceMs = 300,
|
|
153
185
|
places = false,
|
|
154
186
|
sources,
|
|
187
|
+
hints,
|
|
155
188
|
resolveIcon,
|
|
156
189
|
pagesLimit = 30,
|
|
157
190
|
placeholder = 'Search pages, run a command…',
|
|
@@ -178,10 +211,17 @@ export function CommandPalette({
|
|
|
178
211
|
onQueryChangeRef.current?.(debouncedQuery);
|
|
179
212
|
}, [debouncedQuery]);
|
|
180
213
|
|
|
214
|
+
// The highlighted row's cmdk value — controlled, so a row's chords (`actions`) and the footer's
|
|
215
|
+
// hints can follow the highlight.
|
|
216
|
+
const [selected, setSelected] = useState('');
|
|
217
|
+
|
|
181
218
|
// Reset the query each time the palette closes so it reopens cold (the Radix Dialog
|
|
182
219
|
// unmounts its content, but this component stays mounted and holds the state).
|
|
183
220
|
useEffect(() => {
|
|
184
|
-
if (!open)
|
|
221
|
+
if (!open) {
|
|
222
|
+
setQuery('');
|
|
223
|
+
setSelected('');
|
|
224
|
+
}
|
|
185
225
|
}, [open]);
|
|
186
226
|
|
|
187
227
|
// ── Derivations off the registry ──────────────────────────────────────────
|
|
@@ -274,6 +314,36 @@ export function CommandPalette({
|
|
|
274
314
|
.filter((group) => group.items.length > 0);
|
|
275
315
|
}, [sources, isCold, trimmed, open]);
|
|
276
316
|
|
|
317
|
+
/** Every contributed row with chords, by its cmdk value (see `SourceGroups`). */
|
|
318
|
+
const rowActions = useMemo(() => {
|
|
319
|
+
const map = new Map<string, RowActions>();
|
|
320
|
+
for (const group of sourceGroups) {
|
|
321
|
+
for (const item of group.items) {
|
|
322
|
+
if (item.actions?.length) {
|
|
323
|
+
map.set(sourceValue(group.key, item.id), { group: group.label, id: item.id, actions: item.actions });
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return map;
|
|
328
|
+
}, [sourceGroups]);
|
|
329
|
+
const selectedRow = rowActions.get(selected) ?? NO_ROW;
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* A highlighted row's chord runs its action and closes the palette. 🔴 A chord the input itself
|
|
333
|
+
* answers stays the input's: ⌘C over SELECTED query text copies that text, never the row.
|
|
334
|
+
*/
|
|
335
|
+
const onPaletteKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
336
|
+
if (!selectedRow || event.defaultPrevented) return;
|
|
337
|
+
const action = selectedRow.actions.find((a) => matchHotkey(event.nativeEvent, a.keys));
|
|
338
|
+
if (!action) return;
|
|
339
|
+
const field = event.target as Partial<HTMLInputElement>;
|
|
340
|
+
if (typeof field.selectionStart === 'number' && field.selectionStart !== field.selectionEnd) return;
|
|
341
|
+
event.preventDefault();
|
|
342
|
+
rememberCommandItem(selectedRow.group, selectedRow.id);
|
|
343
|
+
action.run();
|
|
344
|
+
onOpenChange(false);
|
|
345
|
+
};
|
|
346
|
+
|
|
277
347
|
// ── Actions ───────────────────────────────────────────────────────────────
|
|
278
348
|
const runSource = (group: string, item: CommandSourceItem) => {
|
|
279
349
|
rememberCommandItem(group, item.id);
|
|
@@ -325,6 +395,9 @@ export function CommandPalette({
|
|
|
325
395
|
<Command
|
|
326
396
|
shouldFilter={false}
|
|
327
397
|
loop
|
|
398
|
+
value={selected}
|
|
399
|
+
onValueChange={setSelected}
|
|
400
|
+
onKeyDown={onPaletteKeyDown}
|
|
328
401
|
label='Command palette'
|
|
329
402
|
className='rounded-xl border border-border shadow-[var(--shadow-overlay)]'
|
|
330
403
|
>
|
|
@@ -337,9 +410,7 @@ export function CommandPalette({
|
|
|
337
410
|
trailing={
|
|
338
411
|
<>
|
|
339
412
|
{showSpinner && <Spinner size='sm' />}
|
|
340
|
-
<
|
|
341
|
-
{formatHotkey(TOGGLE_BINDING)}
|
|
342
|
-
</kbd>
|
|
413
|
+
<Kbd keys={TOGGLE_BINDING} className='hidden sm:inline-block' />
|
|
343
414
|
</>
|
|
344
415
|
}
|
|
345
416
|
/>
|
|
@@ -440,6 +511,16 @@ export function CommandPalette({
|
|
|
440
511
|
</>
|
|
441
512
|
)}
|
|
442
513
|
</CommandList>
|
|
514
|
+
{hints && (
|
|
515
|
+
<PaletteHints
|
|
516
|
+
actions={selectedRow?.actions}
|
|
517
|
+
hints={hints}
|
|
518
|
+
onHint={(hint) => {
|
|
519
|
+
onOpenChange(false);
|
|
520
|
+
hint.onSelect?.();
|
|
521
|
+
}}
|
|
522
|
+
/>
|
|
523
|
+
)}
|
|
443
524
|
</Command>
|
|
444
525
|
</Dialog.Content>
|
|
445
526
|
</Dialog.Portal>
|
|
@@ -541,7 +622,7 @@ function SourceGroups({
|
|
|
541
622
|
{groups.map((group) => (
|
|
542
623
|
<CommandGroup key={`source-${group.key}`} heading={group.label}>
|
|
543
624
|
{group.items.map((item) => (
|
|
544
|
-
<CommandItem key={`${group.key}:${item.id}`} value={
|
|
625
|
+
<CommandItem key={`${group.key}:${item.id}`} value={sourceValue(group.key, item.id)} onSelect={() => onRun(group.label, item)}>
|
|
545
626
|
<ItemBody
|
|
546
627
|
icon={item.icon}
|
|
547
628
|
label={item.label}
|
|
@@ -556,6 +637,69 @@ function SourceGroups({
|
|
|
556
637
|
);
|
|
557
638
|
}
|
|
558
639
|
|
|
640
|
+
/** A contributed row's cmdk value — the key its chords are found by. */
|
|
641
|
+
const sourceValue = (groupKey: string, id: string) => `source:${groupKey}:${id}`;
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* The foot: how to move and open, the highlighted row's chords, then the app's hints. Keys are a
|
|
645
|
+
* pointer's business, so a phone does not get the row (the ⌘K badge above hides the same way).
|
|
646
|
+
*/
|
|
647
|
+
function PaletteHints({
|
|
648
|
+
actions,
|
|
649
|
+
hints,
|
|
650
|
+
onHint,
|
|
651
|
+
}: {
|
|
652
|
+
actions: readonly CommandItemAction[] | undefined;
|
|
653
|
+
hints: readonly CommandPaletteHint[];
|
|
654
|
+
onHint: (hint: CommandPaletteHint) => void;
|
|
655
|
+
}) {
|
|
656
|
+
return (
|
|
657
|
+
<div
|
|
658
|
+
data-cb-palette-hints=''
|
|
659
|
+
className='hidden w-full shrink-0 items-center gap-4 border-t border-border px-3 py-2 text-caption text-muted-foreground sm:inline-flex'
|
|
660
|
+
>
|
|
661
|
+
<span className='flex items-center gap-1.5'>
|
|
662
|
+
<Kbd>↑↓</Kbd>
|
|
663
|
+
Navigate
|
|
664
|
+
</span>
|
|
665
|
+
<span className='flex items-center gap-1.5'>
|
|
666
|
+
<Kbd>↵</Kbd>
|
|
667
|
+
Open
|
|
668
|
+
</span>
|
|
669
|
+
{actions?.map((action) => (
|
|
670
|
+
<span key={action.keys} data-cb-palette-action='' className='flex items-center gap-1.5'>
|
|
671
|
+
<Kbd keys={action.keys} />
|
|
672
|
+
{action.label}
|
|
673
|
+
</span>
|
|
674
|
+
))}
|
|
675
|
+
{hints.length > 0 && (
|
|
676
|
+
<span className='ml-auto flex items-center gap-2'>
|
|
677
|
+
{hints.map((hint) =>
|
|
678
|
+
hint.onSelect ? (
|
|
679
|
+
<Button
|
|
680
|
+
key={hint.keys}
|
|
681
|
+
variant='ghost'
|
|
682
|
+
size='sm'
|
|
683
|
+
tooltip={hint.label}
|
|
684
|
+
onClick={() => onHint(hint)}
|
|
685
|
+
className='h-7 gap-1.5 px-1.5 text-caption font-normal text-muted-foreground'
|
|
686
|
+
>
|
|
687
|
+
<Kbd keys={hint.keys} />
|
|
688
|
+
{hint.label}
|
|
689
|
+
</Button>
|
|
690
|
+
) : (
|
|
691
|
+
<span key={hint.keys} className='flex items-center gap-1.5'>
|
|
692
|
+
<Kbd keys={hint.keys} />
|
|
693
|
+
{hint.label}
|
|
694
|
+
</span>
|
|
695
|
+
),
|
|
696
|
+
)}
|
|
697
|
+
</span>
|
|
698
|
+
)}
|
|
699
|
+
</div>
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
|
|
559
703
|
/** Shared row layout: optional leading icon, a label + sub stack, optional trailing node. */
|
|
560
704
|
function ItemBody({
|
|
561
705
|
icon,
|
|
@@ -591,11 +735,7 @@ function ItemBody({
|
|
|
591
735
|
|
|
592
736
|
/** A keyboard-shortcut hint, formatted for the current platform (⌘K / Ctrl K). */
|
|
593
737
|
function KeyHint({ keys }: { keys: string }) {
|
|
594
|
-
return
|
|
595
|
-
<kbd className='rounded border border-border bg-muted px-1.5 py-0.5 font-sans text-caption font-medium text-muted-foreground'>
|
|
596
|
-
{formatHotkey(keys)}
|
|
597
|
-
</kbd>
|
|
598
|
-
);
|
|
738
|
+
return <Kbd keys={keys} />;
|
|
599
739
|
}
|
|
600
740
|
|
|
601
741
|
/**
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// A key cap — the one drawing of a keyboard key in the kit. The palette's ⌘K badge, its rows'
|
|
2
|
+
// shortcut hints, its footer and the `ShortcutSheet` all use it, so a key reads the same
|
|
3
|
+
// everywhere. Pass `keys` for a binding (`'$mod+k'` → ⌘K on a Mac, Ctrl K elsewhere) or
|
|
4
|
+
// children for a literal (`↑↓`). `platform` pins the notation when the reader chose one (family's
|
|
5
|
+
// Mac / Windows keys setting) instead of the browser's guess; 'both' draws the Mac cap with the
|
|
6
|
+
// Windows one beside it, in parentheses, where the two differ.
|
|
7
|
+
|
|
8
|
+
import type { ReactNode } from 'react';
|
|
9
|
+
import { formatHotkey } from 'cursedbelt-core/palette/hotkeys';
|
|
10
|
+
import { cn } from '../lib/cn.js';
|
|
11
|
+
|
|
12
|
+
/** Whose notation a binding is drawn in: ⌘⌥⇧ glyphs, or Ctrl / Alt / Shift words. */
|
|
13
|
+
export type KbdPlatform = 'mac' | 'windows';
|
|
14
|
+
|
|
15
|
+
export interface KbdProps {
|
|
16
|
+
/** A binding in the `useHotkeys` format, formatted for this platform. */
|
|
17
|
+
keys?: string;
|
|
18
|
+
/** Draw `keys` in this platform's notation; omitted, the browser's own. */
|
|
19
|
+
platform?: KbdPlatform | 'both';
|
|
20
|
+
/** A literal cap, when there is no binding to format. */
|
|
21
|
+
children?: ReactNode;
|
|
22
|
+
className?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const CAP =
|
|
26
|
+
'rounded border border-border bg-muted px-1.5 py-0.5 font-sans text-caption font-medium text-muted-foreground';
|
|
27
|
+
|
|
28
|
+
/** One key cap, in the platform's notation. */
|
|
29
|
+
export function Kbd({ keys, platform, children, className }: KbdProps) {
|
|
30
|
+
if (keys !== undefined && platform === 'both') {
|
|
31
|
+
const mac = formatHotkey(keys, true);
|
|
32
|
+
const windows = formatHotkey(keys, false);
|
|
33
|
+
if (mac !== windows) {
|
|
34
|
+
return (
|
|
35
|
+
<span className={cn('inline-flex items-center gap-1', className)}>
|
|
36
|
+
<kbd className={CAP}>{mac}</kbd>
|
|
37
|
+
<span className='text-caption text-muted-foreground'>
|
|
38
|
+
(<kbd className={CAP}>{windows}</kbd>)
|
|
39
|
+
</span>
|
|
40
|
+
</span>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const apple = platform === 'mac' ? true : platform === 'windows' ? false : undefined;
|
|
45
|
+
return <kbd className={cn(CAP, className)}>{keys !== undefined ? formatHotkey(keys, apple) : children}</kbd>;
|
|
46
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { afterEach, describe, expect, mock, test } from 'bun:test';
|
|
2
|
+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
3
|
+
import { formatHotkey } from 'cursedbelt-core/palette/hotkeys';
|
|
4
|
+
import { ShortcutSheet } from './ShortcutSheet.js';
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup);
|
|
7
|
+
|
|
8
|
+
describe('ShortcutSheet', () => {
|
|
9
|
+
test('draws each group, each action, and every binding that reaches it in the platform notation', () => {
|
|
10
|
+
render(
|
|
11
|
+
<ShortcutSheet
|
|
12
|
+
open
|
|
13
|
+
onClose={() => {}}
|
|
14
|
+
groups={[
|
|
15
|
+
{ heading: 'Anywhere', items: [{ label: 'Search', keys: ['/', '$mod+k'] }] },
|
|
16
|
+
{ heading: 'An open entry', items: [{ label: 'Tags', keys: 'shift+t', where: 'in an open entry' }] },
|
|
17
|
+
]}
|
|
18
|
+
/>,
|
|
19
|
+
);
|
|
20
|
+
expect(screen.getByRole('region', { name: 'Anywhere' })).toBeTruthy();
|
|
21
|
+
const rows = Array.from(document.querySelectorAll('[data-cb-shortcut]'));
|
|
22
|
+
expect(rows).toHaveLength(2);
|
|
23
|
+
const caps = (row: Element) => Array.from(row.querySelectorAll('kbd')).map((k) => k.textContent);
|
|
24
|
+
expect(caps(rows[0]!)).toEqual(['/', formatHotkey('$mod+k')]);
|
|
25
|
+
expect(rows[0]!.textContent).toContain('or');
|
|
26
|
+
expect(caps(rows[1]!)).toEqual([formatHotkey('shift+t')]);
|
|
27
|
+
expect(rows[1]!.textContent).toContain('in an open entry');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('platform pins the notation: mac, windows, or both — the Windows chord in parentheses only where it differs', () => {
|
|
31
|
+
const groups = [{ heading: 'Keys', items: [{ label: 'Find', keys: '$mod+k' }, { label: 'Center', keys: 'c' }] }];
|
|
32
|
+
const caps = () =>
|
|
33
|
+
Array.from(document.querySelectorAll('[data-cb-shortcut]')).map((row) =>
|
|
34
|
+
Array.from(row.querySelectorAll('kbd')).map((k) => k.textContent),
|
|
35
|
+
);
|
|
36
|
+
const { rerender } = render(<ShortcutSheet open onClose={() => {}} groups={groups} platform='mac' />);
|
|
37
|
+
expect(caps()).toEqual([['⌘K'], ['C']]);
|
|
38
|
+
rerender(<ShortcutSheet open onClose={() => {}} groups={groups} platform='windows' />);
|
|
39
|
+
expect(caps()).toEqual([['Ctrl K'], ['C']]);
|
|
40
|
+
rerender(<ShortcutSheet open onClose={() => {}} groups={groups} platform='both' />);
|
|
41
|
+
expect(caps()).toEqual([['⌘K', 'Ctrl K'], ['C']]);
|
|
42
|
+
expect(document.querySelector('[data-cb-shortcut]')!.textContent).toContain('(Ctrl K)');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('literal caps draw as written, for a key the grammar cannot spell', () => {
|
|
46
|
+
render(
|
|
47
|
+
<ShortcutSheet open onClose={() => {}} groups={[{ heading: 'Move', items: [{ label: 'Up and down', caps: ['↑', '↓'] }] }]} />,
|
|
48
|
+
);
|
|
49
|
+
expect(Array.from(document.querySelectorAll('[data-cb-shortcut] kbd')).map((k) => k.textContent)).toEqual(['↑', '↓']);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('Done closes it', () => {
|
|
53
|
+
const onClose = mock(() => {});
|
|
54
|
+
render(<ShortcutSheet open onClose={onClose} groups={[]} />);
|
|
55
|
+
fireEvent.click(screen.getByRole('button', { name: 'Done' }));
|
|
56
|
+
expect(onClose).toHaveBeenCalled();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// The `?` sheet — every key an app answers, on one dialog (Linear's and GitHub's `?`). A key that
|
|
2
|
+
// is not written down is a key nobody finds, so an app lists its map here once and the sheet
|
|
3
|
+
// draws it: grouped, the action on the left, its caps on the right in the platform's notation.
|
|
4
|
+
//
|
|
5
|
+
// `platform` is for an app that lets its reader choose the notation (family's Mac / Windows / both
|
|
6
|
+
// setting) — `Kbd`'s own, so 'both' draws the Mac caps with the Windows chord beside them, in
|
|
7
|
+
// parentheses, where the two differ. A key the binding grammar cannot spell ("↑ ↓", "Space (hold)") is `caps`.
|
|
8
|
+
//
|
|
9
|
+
// The app owns `open` and binds `?` itself (outside a field), because only the app knows what
|
|
10
|
+
// counts as typing on its screens. The list is data, so a test can hold it to the handlers.
|
|
11
|
+
|
|
12
|
+
import type { ReactNode } from 'react';
|
|
13
|
+
import { ActionBar } from '../components/ActionBar.js';
|
|
14
|
+
import { Button } from '../components/Button.js';
|
|
15
|
+
import { Modal } from '../components/Modal.js';
|
|
16
|
+
import { Kbd, type KbdProps } from './Kbd.js';
|
|
17
|
+
|
|
18
|
+
export interface ShortcutSheetItem {
|
|
19
|
+
/** What the key does ("Copy the secret"). */
|
|
20
|
+
label: string;
|
|
21
|
+
/** One binding (`'$mod+c'`), or several that do the same thing (`['/', '$mod+k']`). */
|
|
22
|
+
keys?: string | readonly string[];
|
|
23
|
+
/** Literal caps, drawn as written, for a key the binding grammar cannot spell (`['↑', '↓']`).
|
|
24
|
+
* Wins over `keys`. */
|
|
25
|
+
caps?: readonly string[];
|
|
26
|
+
/** Where it works, when that is narrower than its group ("in an open entry"). */
|
|
27
|
+
where?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ShortcutSheetGroup {
|
|
31
|
+
heading: string;
|
|
32
|
+
items: readonly ShortcutSheetItem[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ShortcutSheetProps {
|
|
36
|
+
open: boolean;
|
|
37
|
+
onClose: () => void;
|
|
38
|
+
groups: readonly ShortcutSheetGroup[];
|
|
39
|
+
title?: ReactNode;
|
|
40
|
+
subtitle?: ReactNode;
|
|
41
|
+
/** The notation every binding is drawn in; omitted, the browser's own. 'both' adds the
|
|
42
|
+
* Windows chord in parentheses wherever it differs from the Mac's. */
|
|
43
|
+
platform?: KbdProps['platform'];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The keyboard map as a dialog. */
|
|
47
|
+
export function ShortcutSheet({
|
|
48
|
+
open,
|
|
49
|
+
onClose,
|
|
50
|
+
groups,
|
|
51
|
+
title = 'Keyboard shortcuts',
|
|
52
|
+
subtitle = 'Keys work anywhere outside a text field.',
|
|
53
|
+
platform,
|
|
54
|
+
}: ShortcutSheetProps) {
|
|
55
|
+
return (
|
|
56
|
+
<Modal
|
|
57
|
+
open={open}
|
|
58
|
+
onClose={onClose}
|
|
59
|
+
size='md'
|
|
60
|
+
title={title}
|
|
61
|
+
subtitle={subtitle}
|
|
62
|
+
footer={
|
|
63
|
+
<ActionBar
|
|
64
|
+
primary={
|
|
65
|
+
<Button variant='primary' tooltip='Close the shortcut list (Esc)' onClick={onClose}>
|
|
66
|
+
Done
|
|
67
|
+
</Button>
|
|
68
|
+
}
|
|
69
|
+
/>
|
|
70
|
+
}
|
|
71
|
+
>
|
|
72
|
+
<div data-cb-shortcut-sheet='' className='flex flex-col gap-6'>
|
|
73
|
+
{groups.map((group) => (
|
|
74
|
+
<section key={group.heading} aria-label={group.heading} className='flex flex-col gap-1'>
|
|
75
|
+
<h3 className='m-0 text-xs font-semibold uppercase tracking-wide text-muted-foreground'>
|
|
76
|
+
{group.heading}
|
|
77
|
+
</h3>
|
|
78
|
+
<ul className='m-0 flex flex-col p-0'>
|
|
79
|
+
{group.items.map((item) => {
|
|
80
|
+
const bindings = item.keys === undefined ? [] : typeof item.keys === 'string' ? [item.keys] : item.keys;
|
|
81
|
+
return (
|
|
82
|
+
<li
|
|
83
|
+
key={item.label}
|
|
84
|
+
data-cb-shortcut=''
|
|
85
|
+
className='flex items-center justify-between gap-4 border-b border-border py-2 last:border-b-0'
|
|
86
|
+
>
|
|
87
|
+
<span className='min-w-0 text-sm'>
|
|
88
|
+
{item.label}
|
|
89
|
+
{item.where && <span className='text-muted-foreground'> · {item.where}</span>}
|
|
90
|
+
</span>
|
|
91
|
+
<span className='flex shrink-0 items-center gap-1.5 text-caption text-muted-foreground'>
|
|
92
|
+
{item.caps
|
|
93
|
+
? item.caps.map((cap) => <Kbd key={cap}>{cap}</Kbd>)
|
|
94
|
+
: bindings.map((binding, index) => (
|
|
95
|
+
<span key={binding} className='flex items-center gap-1.5'>
|
|
96
|
+
{index > 0 && <span>or</span>}
|
|
97
|
+
<Kbd keys={binding} platform={platform} />
|
|
98
|
+
</span>
|
|
99
|
+
))}
|
|
100
|
+
</span>
|
|
101
|
+
</li>
|
|
102
|
+
);
|
|
103
|
+
})}
|
|
104
|
+
</ul>
|
|
105
|
+
</section>
|
|
106
|
+
))}
|
|
107
|
+
</div>
|
|
108
|
+
</Modal>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
@@ -21,6 +21,19 @@ import {
|
|
|
21
21
|
} from 'react';
|
|
22
22
|
import { devWarnOnce } from '../lib/devWarn.js';
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* A second verb on a row, run by a chord while the row is highlighted — the vault's ⌘C copies an
|
|
26
|
+
* entry's secret without opening it. The palette closes after it runs, and its footer names the
|
|
27
|
+
* highlighted row's actions so the chord is discoverable where it works.
|
|
28
|
+
*/
|
|
29
|
+
export interface CommandItemAction {
|
|
30
|
+
/** The chord, in the `useHotkeys` format (`'$mod+c'`). */
|
|
31
|
+
keys: string;
|
|
32
|
+
/** What it does, in the footer ("Copy secret"). */
|
|
33
|
+
label: string;
|
|
34
|
+
run: () => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
24
37
|
export interface CommandSourceItem {
|
|
25
38
|
/** Stable within its source. */
|
|
26
39
|
id: string;
|
|
@@ -36,6 +49,8 @@ export interface CommandSourceItem {
|
|
|
36
49
|
onSelect?: () => void;
|
|
37
50
|
/** Or go somewhere — handed to the palette's `onNavigate`. */
|
|
38
51
|
path?: string;
|
|
52
|
+
/** Chords that act on this row without selecting it (see {@link CommandItemAction}). */
|
|
53
|
+
actions?: readonly CommandItemAction[];
|
|
39
54
|
}
|
|
40
55
|
|
|
41
56
|
export interface CommandSource {
|
|
@@ -97,7 +112,13 @@ export function useCommandSources(): CommandSource[] {
|
|
|
97
112
|
|
|
98
113
|
/** What a row SHOWS — an entry is replaced only when this changes. */
|
|
99
114
|
const signature = (source: CommandSource): string =>
|
|
100
|
-
[
|
|
115
|
+
[
|
|
116
|
+
source.group,
|
|
117
|
+
...source.items.map(
|
|
118
|
+
(i) =>
|
|
119
|
+
`${i.id}\u0001${i.label}\u0001${i.sub ?? ''}\u0001${i.path ?? ''}\u0001${(i.actions ?? []).map((a) => `${a.keys}:${a.label}`).join(',')}`,
|
|
120
|
+
),
|
|
121
|
+
].join(
|
|
101
122
|
'\u0002',
|
|
102
123
|
);
|
|
103
124
|
|
|
@@ -135,6 +156,14 @@ export function useCommandSource(source: CommandSource): void {
|
|
|
135
156
|
onSelect: item.onSelect
|
|
136
157
|
? () => latest.current.items.find((i) => i.id === item.id)?.onSelect?.()
|
|
137
158
|
: undefined,
|
|
159
|
+
actions: item.actions?.map((action) => ({
|
|
160
|
+
...action,
|
|
161
|
+
run: () =>
|
|
162
|
+
latest.current.items
|
|
163
|
+
.find((i) => i.id === item.id)
|
|
164
|
+
?.actions?.find((a) => a.keys === action.keys)
|
|
165
|
+
?.run(),
|
|
166
|
+
})),
|
|
138
167
|
})),
|
|
139
168
|
});
|
|
140
169
|
}, [store, key, sig]);
|