cursedbelt 5.74.0 → 5.75.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.
Files changed (52) hide show
  1. package/dist/react/components/Page.d.ts.map +1 -1
  2. package/dist/react/components/Page.js +10 -1
  3. package/dist/react/components/Page.js.map +1 -1
  4. package/dist/react/components/PropertyPicker.d.ts +75 -0
  5. package/dist/react/components/PropertyPicker.d.ts.map +1 -0
  6. package/dist/react/components/PropertyPicker.js +108 -0
  7. package/dist/react/components/PropertyPicker.js.map +1 -0
  8. package/dist/react/components/TooltipImpl.d.ts.map +1 -1
  9. package/dist/react/components/TooltipImpl.js +22 -3
  10. package/dist/react/components/TooltipImpl.js.map +1 -1
  11. package/dist/react/index.d.ts +1 -0
  12. package/dist/react/index.d.ts.map +1 -1
  13. package/dist/react/index.js +1 -0
  14. package/dist/react/index.js.map +1 -1
  15. package/dist/react/media/DenseGalleryShell.d.ts.map +1 -1
  16. package/dist/react/media/DenseGalleryShell.js +73 -9
  17. package/dist/react/media/DenseGalleryShell.js.map +1 -1
  18. package/dist/react/media/HoverScrubVideoThumb.d.ts +11 -9
  19. package/dist/react/media/HoverScrubVideoThumb.d.ts.map +1 -1
  20. package/dist/react/media/HoverScrubVideoThumb.js +98 -25
  21. package/dist/react/media/HoverScrubVideoThumb.js.map +1 -1
  22. package/dist/react/media/denseGallery.css +103 -7
  23. package/dist/react/media-gallery/GalleryTable.js +7 -9
  24. package/dist/react/media-gallery/GalleryTable.js.map +1 -1
  25. package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
  26. package/dist/react/media-gallery/MediaGallery.js +19 -7
  27. package/dist/react/media-gallery/MediaGallery.js.map +1 -1
  28. package/dist/react/media-gallery/playbackPreferences.d.ts +2 -2
  29. package/dist/react/media-gallery/playbackPreferences.js +2 -2
  30. package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
  31. package/dist/styles-areas/core.css +8 -6
  32. package/dist/styles-areas/palette.css +8 -7
  33. package/dist/styles-utilities.css +17 -0
  34. package/package.json +1 -1
  35. package/scripts/guardrailsEnforce.spec.ts +21 -0
  36. package/scripts/guardrailsEnforce.ts +41 -0
  37. package/scripts/styleAreas.ts +1 -1
  38. package/src/react/components/Page.tsx +11 -1
  39. package/src/react/components/PropertyPicker.spec.tsx +105 -0
  40. package/src/react/components/PropertyPicker.tsx +254 -0
  41. package/src/react/components/TooltipImpl.tsx +21 -1
  42. package/src/react/index.ts +8 -0
  43. package/src/react/media/DenseGalleryShell.tsx +90 -6
  44. package/src/react/media/HoverScrubVideoThumb.spec.tsx +82 -0
  45. package/src/react/media/HoverScrubVideoThumb.tsx +116 -28
  46. package/src/react/media/denseGallery.css +103 -7
  47. package/src/react/media-gallery/GalleryTable.tsx +7 -9
  48. package/src/react/media-gallery/MediaGallery.tsx +18 -7
  49. package/src/react/media-gallery/playbackPreferences.ts +2 -2
  50. package/src/styles-areas/core.css +8 -6
  51. package/src/styles-areas/palette.css +8 -7
  52. package/src/styles-utilities.css +17 -0
@@ -1045,6 +1045,27 @@ describe('no-select-as-action-menu', () => {
1045
1045
  });
1046
1046
  });
1047
1047
 
1048
+ describe('no-native-select — the OS draws its list (owner, 2026-10-10)', () => {
1049
+ test('flags every NativeSelect in an app, a real form field included', () => {
1050
+ const source = `<NativeSelect value={type} onChange={(e) => setType(e.target.value)}>
1051
+ {TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
1052
+ </NativeSelect>
1053
+ <NativeSelect value={kind} size="sm" onChange={pick} />`;
1054
+ expect(check('no-native-select', source)).toHaveLength(2);
1055
+ });
1056
+
1057
+ test('the styled controls, and a comment that names it, are not one', () => {
1058
+ expect(check('no-native-select', `<SelectField value={sort} options={SORTS} />`)).toHaveLength(0);
1059
+ expect(check('no-native-select', `// was a <NativeSelect> until 2026-10-10\n<PropertyPicker name="Type" />`)).toHaveLength(0);
1060
+ });
1061
+
1062
+ test('a use that says why the OS picker is right is allowed', () => {
1063
+ const source = `{/* guardrails-ignore no-native-select: a phone-first form — the OS wheel is the better control */}
1064
+ <NativeSelect value={relation} onChange={pick} />`;
1065
+ expect(check('no-native-select', source)).toHaveLength(0);
1066
+ });
1067
+ });
1068
+
1048
1069
  describe('no-native-dialog', () => {
1049
1070
  test('flags each of the three native dialogs', () => {
1050
1071
  expect(check('no-native-dialog', `if (!window.confirm("sure?")) return;`)).toHaveLength(1);
@@ -113,6 +113,11 @@
113
113
  // Semantic classes are never matched — `text-xs` IS 12px. A non-text glyph takes
114
114
  // the per-line opt-out.
115
115
  //
116
+ // no-native-select — owner, 2026-10-10: a `NativeSelect`'s list is drawn by the OS, where no
117
+ // stylesheet reaches it, so it breaks the app's cohesion. `SelectField` / `ComboboxField` /
118
+ // `PropertyPicker` / `ChoiceMenu` instead; a use where the OS picker is genuinely better says
119
+ // why with `// guardrails-ignore no-native-select: <why>` on the line above. Ratcheted per app.
120
+ //
116
121
  // no-native-dialog — DESIGN.md core mandate: no `window.confirm` / `window.prompt` /
117
122
  // `window.alert`. The browser draws them outside the page, so no stylesheet reaches
118
123
  // them; they block the main thread; a browser can SUPPRESS a repeat dialog, after
@@ -2022,6 +2027,41 @@ const noSelectAsActionMenu: Rule = {
2022
2027
  },
2023
2028
  };
2024
2029
 
2030
+ // ── no-native-select ────────────────────────────────────────────────────────
2031
+ // The owner, 2026-10-10, on vault's entry form: *"I saw we don't style the dropdowns and that is a
2032
+ // violation of our design requirements to have bad styling or missing styling break the app's
2033
+ // cohesiveness like that. cb should have components that handle this … we should not let anything
2034
+ // like that by us anymore."* The dropdowns were `NativeSelect`: its LIST is drawn by the OS — on
2035
+ // macOS Chrome a grey system menu no stylesheet reaches — so every app that used one had a control
2036
+ // that was not the kit's. `no-select-as-action-menu` caught only the action-menu shape of it.
2037
+ //
2038
+ // Every app now counts its `<NativeSelect` uses in its two-sided ratchet, so the number only falls:
2039
+ // `SelectField` / `ComboboxField` for a field, `PropertyPicker` for a record's property pill,
2040
+ // `ChoiceMenu` to choose-and-act. A use that is genuinely right — a form on a phone where the OS
2041
+ // wheel IS the better control — says so on the line above:
2042
+ // `// guardrails-ignore no-native-select: <why>`.
2043
+ const NATIVE_SELECT_IGNORE_RE = /guardrails-ignore\s+no-native-select\s*:/;
2044
+
2045
+ const noNativeSelect: Rule = {
2046
+ name: "no-native-select",
2047
+ description:
2048
+ "Owner, 2026-10-10: unstyled dropdowns break the app's cohesiveness. A `NativeSelect`'s list is drawn by the OS, outside the page, where no stylesheet reaches it. Use `SelectField` / `ComboboxField` for a form field, `PropertyPicker` for a record's property (Type, Folder, Tags), or `ChoiceMenu` to choose-and-act. Where the OS picker is the better control, say why on the line above: `// guardrails-ignore no-native-select: <why>`.",
2049
+ check(filePath, source) {
2050
+ const unix = filePath.replace(/\\/g, "/");
2051
+ if (/(^|\/)(?:packages|libs)\/cursedbelt\/src\/react\/components\//.test(unix)) return [];
2052
+ if (/\.(?:spec|test)\.[tj]sx?$/.test(unix)) return [];
2053
+ const scannable = blankComments(source);
2054
+ const lines = source.split("\n");
2055
+ const out: Violation[] = [];
2056
+ for (const match of scannable.matchAll(/<NativeSelect\b/g)) {
2057
+ const line = lineOf(source, match.index ?? 0);
2058
+ if (NATIVE_SELECT_IGNORE_RE.test(lines[line - 1] ?? "") || NATIVE_SELECT_IGNORE_RE.test(blockAbove(lines, line))) continue;
2059
+ out.push({ file: filePath, line, snippet: lines[line - 1]?.trim() ?? "" });
2060
+ }
2061
+ return out;
2062
+ },
2063
+ };
2064
+
2025
2065
  // ── no-native-dialog ────────────────────────────────────────────────────────
2026
2066
  // `window.confirm` / `window.prompt` / `window.alert` — and their bare global
2027
2067
  // forms — are the browser's own chrome, and cursedbelt has shipped styled,
@@ -2925,6 +2965,7 @@ export const RULES: Record<string, Rule> = {
2925
2965
  [choiceNeedsEmptyState.name]: choiceNeedsEmptyState,
2926
2966
  [noSelectAsActionMenu.name]: noSelectAsActionMenu,
2927
2967
  [noNativeDialog.name]: noNativeDialog,
2968
+ [noNativeSelect.name]: noNativeSelect,
2928
2969
  [noFastTooltip.name]: noFastTooltip,
2929
2970
  [noRawPassword.name]: noRawPassword,
2930
2971
  [noRawNav.name]: noRawNav,
@@ -349,7 +349,7 @@ export const AREAS: readonly Area[] = [
349
349
  'components/AddItemsMenu.tsx',
350
350
  ],
351
351
  },
352
- { name: 'palette', why: 'the ⌘K command palette and its hotkey/source hooks', roots: ['palette'] },
352
+ { name: 'palette', why: 'the ⌘K command palette and its hotkey/source hooks, and the PropertyPicker built on its Command list', roots: ['palette', 'components/PropertyPicker.tsx'] },
353
353
  { name: 'pdf-viewer', why: 'the lazy PDF viewer (an optional peer already)', roots: ['pdf-viewer'] },
354
354
  {
355
355
  name: 'place-bar',
@@ -130,6 +130,11 @@ export interface PageHeaderProps {
130
130
  className?: string;
131
131
  }
132
132
 
133
+ /** Under a phone held sideways (`orientation: landscape` and at most 500px tall) — literal
134
+ * class strings, because Tailwind's scanner reads them out of the source. */
135
+ const SHORT_LANDSCAPE_PAD = '[@media(orientation:landscape)_and_(max-height:500px)]:py-1.5';
136
+ const SHORT_LANDSCAPE_TITLE = '[@media(orientation:landscape)_and_(max-height:500px)]:text-h3';
137
+
133
138
  /**
134
139
  * The page title row: an `<h1>` (fluid `text-h1`) + optional subtitle, with metric
135
140
  * pills (`meta`) beside the title and optional actions pushed to the trailing edge.
@@ -157,6 +162,11 @@ export function PageHeader({
157
162
  // pushing off-viewport (phone-overflow bug class).
158
163
  'flex-wrap gap-y-2 border-b border-border',
159
164
  dense ? 'px-0 py-2' : inContainer ? 'px-0 pb-4' : 'px-4 py-4 sm:px-6 lg:px-8',
165
+ // 🔴 A phone on its side is ~340px tall under Safari's bars, and a roomy page header
166
+ // took a quarter of it. Owner, 2026-10-10, collections on an iPhone: *"landscape view
167
+ // has the scrollable content area too short … the outer rows should be much shorter."*
168
+ // One tight line there: the same title, at h3 scale (below).
169
+ !dense && SHORT_LANDSCAPE_PAD,
160
170
  sticky && 'sticky top-0 z-20 bg-background/90 backdrop-blur-md',
161
171
  className,
162
172
  )}
@@ -166,7 +176,7 @@ export function PageHeader({
166
176
  <Heading
167
177
  className={cn(
168
178
  'font-bold tracking-tight text-foreground',
169
- dense ? 'text-h3' : 'text-h1',
179
+ dense ? 'text-h3' : cn('text-h1', SHORT_LANDSCAPE_TITLE),
170
180
  !titleOnMobile && 'hidden sm:block',
171
181
  )}
172
182
  >
@@ -0,0 +1,105 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
3
+ import { useRef, useState } from 'react';
4
+ import { Input } from './Input.js';
5
+ import { matchesShortcut, PropertyPicker } from './PropertyPicker.js';
6
+
7
+ afterEach(cleanup);
8
+
9
+ const TYPES = [
10
+ { value: 'login', label: 'Login' },
11
+ { value: 'apiKey', label: 'API key' },
12
+ { value: 'envFile', label: 'Env file' },
13
+ ];
14
+
15
+ function Single({ onCreate }: { onCreate?: (q: string) => void }) {
16
+ const [value, setValue] = useState<string | null>('login');
17
+ const scope = useRef<HTMLDivElement>(null);
18
+ return (
19
+ <div ref={scope} data-testid='scope'>
20
+ <PropertyPicker name='Type' options={TYPES} value={value} onChange={setValue} onCreate={onCreate} shortcut='t' shortcutScope={scope} />
21
+ <Input aria-label='Name' />
22
+ <output data-testid='value'>{value}</output>
23
+ </div>
24
+ );
25
+ }
26
+
27
+ function Multi() {
28
+ const [value, setValue] = useState<string[]>(['prod']);
29
+ const [options, setOptions] = useState([
30
+ { value: 'prod', label: 'prod' },
31
+ { value: 'k8s', label: 'k8s' },
32
+ ]);
33
+ return (
34
+ <>
35
+ <PropertyPicker
36
+ multiple
37
+ name='Tags'
38
+ options={options}
39
+ value={value}
40
+ onChange={setValue}
41
+ onCreate={(q) => {
42
+ setOptions((o) => [...o, { value: q, label: q }]);
43
+ setValue((v) => [...v, q]);
44
+ }}
45
+ />
46
+ <output data-testid='value'>{value.join(',')}</output>
47
+ </>
48
+ );
49
+ }
50
+
51
+ const pill = (name: RegExp) => screen.getByRole('button', { name });
52
+ const search = (name: string) => screen.getByRole('combobox', { name: new RegExp(`Search ${name}`, 'i') });
53
+
54
+ describe('PropertyPicker', () => {
55
+ test('the pill shows the value; choosing a row sets it and closes the list', () => {
56
+ render(<Single />);
57
+ expect(pill(/^Type: Login$/)).toBeTruthy();
58
+ fireEvent.click(pill(/^Type:/));
59
+ fireEvent.click(screen.getByRole('option', { name: /API key/ }));
60
+ expect(screen.getByTestId('value').textContent).toBe('apiKey');
61
+ expect(screen.queryByRole('combobox', { name: /Search type/i })).toBeNull();
62
+ });
63
+
64
+ test('typing filters the list — the search is the point once it is long', () => {
65
+ render(<Single />);
66
+ fireEvent.click(pill(/^Type:/));
67
+ fireEvent.change(search('type'), { target: { value: 'env' } });
68
+ expect(screen.getByRole('option', { name: /Env file/ })).toBeTruthy();
69
+ expect(screen.queryByRole('option', { name: /^Login/ })).toBeNull();
70
+ });
71
+
72
+ test('a multiple picker toggles and stays open, and Enter on an unmatched query creates it', () => {
73
+ render(<Multi />);
74
+ fireEvent.click(pill(/^Tags:/));
75
+ fireEvent.click(screen.getByRole('option', { name: /k8s/ }));
76
+ expect(screen.getByTestId('value').textContent).toBe('prod,k8s');
77
+ fireEvent.click(screen.getByRole('option', { name: /prod/ }));
78
+ expect(screen.getByTestId('value').textContent).toBe('k8s');
79
+ const box = search('tags');
80
+ fireEvent.change(box, { target: { value: 'mcp' } });
81
+ fireEvent.keyDown(box, { key: 'Enter' });
82
+ expect(screen.getByTestId('value').textContent).toBe('k8s,mcp');
83
+ expect(search('tags')).toBeTruthy();
84
+ });
85
+
86
+ test('its one-key shortcut opens it from inside the scope, never while typing in a field', () => {
87
+ render(<Single />);
88
+ act(() => {
89
+ fireEvent.keyDown(screen.getByLabelText('Name'), { key: 't' });
90
+ });
91
+ expect(screen.queryByRole('combobox', { name: /Search type/i })).toBeNull();
92
+ act(() => {
93
+ fireEvent.keyDown(screen.getByTestId('scope'), { key: 't' });
94
+ });
95
+ expect(search('type')).toBeTruthy();
96
+ });
97
+
98
+ test('matchesShortcut: case is Shift, and a chord is never a bare key', () => {
99
+ const at = { target: null } as const;
100
+ expect(matchesShortcut({ ...at, key: 't', shiftKey: false, metaKey: false, ctrlKey: false, altKey: false }, 't')).toBe(true);
101
+ expect(matchesShortcut({ ...at, key: 'T', shiftKey: true, metaKey: false, ctrlKey: false, altKey: false }, 't')).toBe(false);
102
+ expect(matchesShortcut({ ...at, key: 'T', shiftKey: true, metaKey: false, ctrlKey: false, altKey: false }, 'T')).toBe(true);
103
+ expect(matchesShortcut({ ...at, key: 't', shiftKey: false, metaKey: true, ctrlKey: false, altKey: false }, 't')).toBe(false);
104
+ });
105
+ });
@@ -0,0 +1,254 @@
1
+ import { Check, Plus } from 'lucide-react';
2
+ import { type ReactNode, type RefObject, useEffect, useMemo, useState } from 'react';
3
+ import { cn } from '../lib/cn.js';
4
+ import {
5
+ Command,
6
+ CommandEmpty,
7
+ CommandGroup,
8
+ CommandInput,
9
+ CommandItem,
10
+ CommandList,
11
+ } from '../palette/Command.js';
12
+ import { Button } from './Button.js';
13
+ import { Popover } from './Popover.js';
14
+
15
+ export interface PropertyOption {
16
+ /** What {@link PropertyPickerProps.onChange} hands back. */
17
+ value: string;
18
+ /** The row's text, and what the search matches. */
19
+ label: string;
20
+ /** A glyph before the label — a type icon, a folder, a swatch. */
21
+ icon?: ReactNode;
22
+ /** A short right-aligned note: a count, a parent path. Searched too. */
23
+ hint?: string;
24
+ disabled?: boolean;
25
+ }
26
+
27
+ interface PropertyPickerBase {
28
+ /**
29
+ * What the property IS, in sentence case — `Type`, `Folder`, `Tags`. The pill's
30
+ * accessible name, its tooltip, and the search box's placeholder all derive from it.
31
+ */
32
+ name: string;
33
+ options: readonly PropertyOption[];
34
+ /** The pill's glyph when the value has none of its own. */
35
+ icon?: ReactNode;
36
+ /** What the pill says with nothing chosen. Default `No <name>`. */
37
+ emptyLabel?: string;
38
+ /**
39
+ * Offer to make a value from what was typed: `Create “k8s”`. Called with the trimmed
40
+ * query; a single picker then has it chosen, a multiple one adds it.
41
+ */
42
+ onCreate?: (query: string) => void;
43
+ /**
44
+ * One key that opens this picker — `t`, `f`, `T` (Shift+t) — while focus is inside
45
+ * {@link shortcutScope} and not in a text field. Shown in the tooltip. Linear's issue
46
+ * panel is the model: the property menus are a keystroke away, never a mouse trip.
47
+ */
48
+ shortcut?: string;
49
+ /** Where {@link shortcut} listens. Without one the key is only advertised, never bound. */
50
+ shortcutScope?: RefObject<HTMLElement | null>;
51
+ disabled?: boolean;
52
+ className?: string;
53
+ }
54
+
55
+ export interface PropertyPickerSingleProps extends PropertyPickerBase {
56
+ multiple?: false;
57
+ value: string | null;
58
+ onChange: (value: string) => void;
59
+ }
60
+
61
+ export interface PropertyPickerMultiProps extends PropertyPickerBase {
62
+ multiple: true;
63
+ value: readonly string[];
64
+ onChange: (value: string[]) => void;
65
+ }
66
+
67
+ export type PropertyPickerProps = PropertyPickerSingleProps | PropertyPickerMultiProps;
68
+
69
+ /** Is this keydown a bare press of `shortcut` (`T` meaning Shift+t), outside any text entry? */
70
+ export function matchesShortcut(e: Pick<KeyboardEvent, 'key' | 'shiftKey' | 'metaKey' | 'ctrlKey' | 'altKey' | 'target'>, shortcut: string): boolean {
71
+ if (e.metaKey || e.ctrlKey || e.altKey) return false;
72
+ const wantShift = shortcut.length === 1 && shortcut !== shortcut.toLowerCase();
73
+ if (e.key.toLowerCase() !== shortcut.toLowerCase() || e.shiftKey !== wantShift) return false;
74
+ const t = e.target as HTMLElement | null;
75
+ if (!t || typeof t.closest !== 'function') return true;
76
+ return !t.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"], [role="textbox"]');
77
+ }
78
+
79
+ /** The key as a person reads it in a tooltip: `T`, `⇧T`. */
80
+ function shortcutLabel(shortcut: string): string {
81
+ return shortcut !== shortcut.toLowerCase() ? `⇧${shortcut.toUpperCase()}` : shortcut.toUpperCase();
82
+ }
83
+
84
+ /**
85
+ * **A property pill** — the Type / Folder / Tags row at the top of a record, Linear's issue
86
+ * panel and Obsidian's properties as one cursedbelt control. The pill shows the value; a
87
+ * click (or its one-key {@link PropertyPickerProps.shortcut}) opens a searchable list in a
88
+ * {@link Popover}; arrows and Enter choose; a `multiple` picker stays open and toggles.
89
+ *
90
+ * ── Why this exists ─────────────────────────────────────────────────────────────
91
+ * The owner, 2026-10-10, on vault's entry form: *"I saw we don't style the dropdowns and that
92
+ * is a violation of our design requirements … cb should have components that handle this."*
93
+ * Its Type, Folder and field-kind controls were {@link NativeSelect}s, whose list the OS
94
+ * draws — on macOS a grey system menu no stylesheet reaches — beside a tree picker and a
95
+ * chip input that each looked different again. Three controls for one job, one of them not
96
+ * ours to style. This is the one control for "set a property of this record", drawn
97
+ * entirely by the kit, searchable the moment the list is long enough to need it.
98
+ *
99
+ * ── Which control to reach for ──────────────────────────────────────────────────
100
+ * - A property of the record being edited, in a header row → this.
101
+ * - A labelled field in a form column → {@link SelectField} / {@link ComboboxField}.
102
+ * - Choose-and-act-now (File under…) → {@link ChoiceMenu}.
103
+ */
104
+ export function PropertyPicker(props: PropertyPickerProps) {
105
+ const {
106
+ name,
107
+ options,
108
+ icon,
109
+ emptyLabel,
110
+ onCreate,
111
+ shortcut,
112
+ shortcutScope,
113
+ disabled,
114
+ className,
115
+ } = props;
116
+ const [open, setOpen] = useState(false);
117
+ const [query, setQuery] = useState('');
118
+ const chosen = useMemo(
119
+ () => new Set(props.multiple ? props.value : props.value === null ? [] : [props.value]),
120
+ [props.multiple, props.value],
121
+ );
122
+
123
+ useEffect(() => {
124
+ const scope = shortcutScope?.current;
125
+ if (!shortcut || !scope || disabled) return;
126
+ const onKey = (e: KeyboardEvent) => {
127
+ if (e.defaultPrevented || !matchesShortcut(e, shortcut)) return;
128
+ e.preventDefault();
129
+ setOpen(true);
130
+ };
131
+ scope.addEventListener('keydown', onKey);
132
+ return () => scope.removeEventListener('keydown', onKey);
133
+ }, [shortcut, shortcutScope, disabled]);
134
+
135
+ const setOpenAndReset = (next: boolean) => {
136
+ setOpen(next);
137
+ if (!next) setQuery('');
138
+ };
139
+
140
+ const choose = (value: string) => {
141
+ if (props.multiple) {
142
+ const next = chosen.has(value) ? props.value.filter((v) => v !== value) : [...props.value, value];
143
+ props.onChange(next);
144
+ setQuery('');
145
+ } else {
146
+ props.onChange(value);
147
+ setOpenAndReset(false);
148
+ }
149
+ };
150
+
151
+ const typed = query.trim();
152
+ const exact = options.some((o) => o.label.toLowerCase() === typed.toLowerCase());
153
+ const create = () => {
154
+ if (!onCreate || typed === '') return;
155
+ onCreate(typed);
156
+ if (props.multiple) setQuery('');
157
+ else setOpenAndReset(false);
158
+ };
159
+
160
+ const byValue = new Map(options.map((o) => [o.value, o]));
161
+ const picked = [...chosen].map((v) => byValue.get(v) ?? { value: v, label: v });
162
+ const first = picked[0];
163
+ const face: ReactNode =
164
+ picked.length === 0 ? (
165
+ <span className='text-muted-foreground'>{emptyLabel ?? `No ${name.toLowerCase()}`}</span>
166
+ ) : props.multiple ? (
167
+ <span className='truncate'>{picked.map((o) => o.label).join(', ')}</span>
168
+ ) : (
169
+ <span className='truncate'>{first?.label}</span>
170
+ );
171
+ const glyph = !props.multiple && first && 'icon' in first && first.icon ? first.icon : icon;
172
+ const tip = shortcut ? `${name} — press ${shortcutLabel(shortcut)}` : name;
173
+
174
+ return (
175
+ <Popover
176
+ open={open}
177
+ onOpenChange={setOpenAndReset}
178
+ align='start'
179
+ unstyled
180
+ label={name}
181
+ trigger={
182
+ <Button
183
+ type='button'
184
+ variant='ghost'
185
+ size='sm'
186
+ disabled={disabled}
187
+ tooltip={tip}
188
+ aria-label={`${name}: ${picked.length === 0 ? 'none' : picked.map((o) => o.label).join(', ')}`}
189
+ data-cb-property={name}
190
+ className={cn(
191
+ 'max-w-64 gap-1.5 rounded-full border border-border px-2.5 font-normal',
192
+ '[&_svg]:size-3.5 [&_svg]:shrink-0 [&_svg]:text-muted-foreground',
193
+ className,
194
+ )}
195
+ >
196
+ {glyph}
197
+ {face}
198
+ </Button>
199
+ }
200
+ >
201
+ <Command
202
+ className='w-72 rounded-lg border border-border shadow-popover'
203
+ // cmdk names its input from this (`aria-labelledby`), over any `aria-label` on it.
204
+ label={`Search ${name.toLowerCase()}`}
205
+ loop
206
+ >
207
+ <CommandInput
208
+ value={query}
209
+ onValueChange={setQuery}
210
+ placeholder={`${props.multiple ? 'Add' : 'Set'} ${name.toLowerCase()}…`}
211
+ onKeyDown={(e) => {
212
+ // Enter on a query nothing matches makes it, the way Linear's label menu does.
213
+ if (e.key === 'Enter' && onCreate && typed !== '' && !exact) {
214
+ const anyMatch = options.some((o) => `${o.label} ${o.hint ?? ''}`.toLowerCase().includes(typed.toLowerCase()));
215
+ if (!anyMatch) {
216
+ e.preventDefault();
217
+ create();
218
+ }
219
+ }
220
+ }}
221
+ />
222
+ <CommandList maxHeightClassName='max-h-72'>
223
+ <CommandEmpty>{onCreate && typed !== '' ? 'Nothing matches — Enter creates it.' : 'Nothing matches.'}</CommandEmpty>
224
+ <CommandGroup>
225
+ {options.map((o) => (
226
+ <CommandItem
227
+ key={o.value}
228
+ value={`${o.label} ${o.hint ?? ''} ${o.value}`}
229
+ disabled={o.disabled}
230
+ onSelect={() => choose(o.value)}
231
+ data-checked={chosen.has(o.value) ? 'true' : undefined}
232
+ >
233
+ {o.icon ? <span className='flex size-4 shrink-0 items-center justify-center text-muted-foreground [&_svg]:size-4'>{o.icon}</span> : null}
234
+ <span className='min-w-0 flex-1 truncate'>{o.label}</span>
235
+ {o.hint ? <span className='shrink-0 truncate text-xs text-muted-foreground'>{o.hint}</span> : null}
236
+ <Check aria-hidden className={cn('size-4 shrink-0 text-foreground', chosen.has(o.value) ? 'opacity-100' : 'opacity-0')} />
237
+ </CommandItem>
238
+ ))}
239
+ </CommandGroup>
240
+ {onCreate && typed !== '' && !exact ? (
241
+ <CommandGroup>
242
+ <CommandItem value={`__create__ ${typed}`} onSelect={create} forceMount>
243
+ <Plus aria-hidden className='size-4 shrink-0 text-muted-foreground' />
244
+ <span className='min-w-0 flex-1 truncate'>
245
+ Create “{typed}”
246
+ </span>
247
+ </CommandItem>
248
+ </CommandGroup>
249
+ ) : null}
250
+ </CommandList>
251
+ </Command>
252
+ </Popover>
253
+ );
254
+ }
@@ -57,6 +57,8 @@ export default function TooltipImpl({
57
57
  const container = useFloatingContainer();
58
58
  const delay = useHoverHintDelay();
59
59
  const [open, setOpen] = useState(false);
60
+ /** Closed by an Escape: the bubble leaves with no exit animation — see the Escape effect. */
61
+ const [escaped, setEscaped] = useState(false);
60
62
  const resolvedSide = placement ?? side ?? 'top';
61
63
  const triggerRef = useRef<HTMLElement | null>(null);
62
64
  const wasFocusedRef = useRef(false);
@@ -247,15 +249,32 @@ export default function TooltipImpl({
247
249
  * update flushes its passive effects too, which is where the layer takes itself off Radix's
248
250
  * stack). By the time Radix asks "who is topmost" the hint is gone, and the same keypress
249
251
  * closes what is under it. A hint over nothing costs nothing: Escape still reaches the page.
252
+ *
253
+ * 🔴 …and it leaves WITHOUT its exit animation (`escaped`). Radix's Presence keeps a closing
254
+ * bubble mounted until its fade-out ends, and a mounted bubble is still the topmost layer — so
255
+ * in a real browser the Escape was swallowed anyway; jsdom runs no animation, which is how the
256
+ * spec above stayed green. Measured in vault (forge task 3016.1, 2026-10-10): close the Type
257
+ * picker with Escape, focus lands back on its pill, the pill's hint opens, and the next Escape
258
+ * closed only the hint while the entry sheet stayed up. With `animation: none` Presence unmounts
259
+ * inside the same `flushSync`. `e2e/overlays.spec.ts` proves it in Chromium.
250
260
  */
251
261
  useEffect(() => {
252
262
  if (!open) return;
253
263
  const onKeyDown = (event: KeyboardEvent) => {
254
- if (event.key === 'Escape') flushSync(() => setOpen(false));
264
+ if (event.key === 'Escape') {
265
+ flushSync(() => {
266
+ setEscaped(true);
267
+ setOpen(false);
268
+ });
269
+ }
255
270
  };
256
271
  window.addEventListener('keydown', onKeyDown, true);
257
272
  return () => window.removeEventListener('keydown', onKeyDown, true);
258
273
  }, [open]);
274
+ // The next opening animates again.
275
+ useEffect(() => {
276
+ if (open) setEscaped(false);
277
+ }, [open]);
259
278
 
260
279
  const bubble = (
261
280
  <RTooltip.Portal container={container}>
@@ -285,6 +304,7 @@ export default function TooltipImpl({
285
304
  // modal has body pointer events disabled).
286
305
  'z-50 rounded-md border border-border bg-popover px-2 py-1 text-xs text-popover-foreground shadow-[var(--shadow-overlay)]',
287
306
  TOOLTIP_MOTION,
307
+ escaped && 'animate-none!',
288
308
  multiline || maxWidth != null ? 'max-w-xs' : 'whitespace-nowrap',
289
309
  className,
290
310
  )}
@@ -518,6 +518,14 @@ export { TruncatedText, type TruncatedTextProps } from './components/TruncatedTe
518
518
  export { UnstyledButton, type UnstyledButtonProps } from './components/UnstyledButton.js';
519
519
  export { PluginCard, type PluginCardProps } from './components/PluginCard.js';
520
520
  export { Popover, PopoverClose, type PopoverPoint, type PopoverProps } from './components/Popover.js';
521
+ export {
522
+ matchesShortcut,
523
+ PropertyPicker,
524
+ type PropertyOption,
525
+ type PropertyPickerMultiProps,
526
+ type PropertyPickerProps,
527
+ type PropertyPickerSingleProps,
528
+ } from './components/PropertyPicker.js';
521
529
  export { FloatingContainer } from './lib/floatingContainer.js';
522
530
  export {
523
531
  Progress,