cursedbelt 5.74.1 → 5.76.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt",
3
- "version": "5.74.1",
3
+ "version": "5.76.0",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "The React design system of the cursedbelt split — components, styles, theme. cursedbelt-core below it; server tier in cursedbelt-server.",
@@ -1045,6 +1045,74 @@ 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
+
1069
+ describe('typed-field-outside-a-form — Enter submits (owner, 2026-10-11; forge task 3043)', () => {
1070
+ // vault's Settings before 3043: a password beside a click-only button, no <form> anywhere.
1071
+ const clickOnly = `<SecretInput value={pw} onChange={setPw} label="Master password" />
1072
+ <Input value={hint} onChange={(e) => setHint(e.target.value)} />
1073
+ <Button disabled={!pw} onClick={onSave}>Save</Button>`;
1074
+
1075
+ test('flags each typed field in a file where nothing handles Enter', () => {
1076
+ expect(check('typed-field-outside-a-form', clickOnly)).toHaveLength(2);
1077
+ expect(check('typed-field-outside-a-form', `<input value={v} onChange={set} />\n<OtpInput length={6} />`)).toHaveLength(2);
1078
+ });
1079
+
1080
+ test('a form, a submit or Enter handler, a self-submitting code or an autosave answers it', () => {
1081
+ for (const handled of [
1082
+ `<form className="contents" onSubmit={submit}>${clickOnly}</form>`,
1083
+ `${clickOnly}\n<Wizard onSubmit={finish} />`,
1084
+ `<SecretInput value={pw} onEnter={unlock} />`,
1085
+ `<OtpInput length={6} onComplete={verify} />`,
1086
+ `<Input onKeyDown={(e) => { if (e.key === "Enter") save(); }} />`,
1087
+ `const autosave = useAutosave<Draft>({ save });\n<Input value={title} />`,
1088
+ ]) {
1089
+ expect(check('typed-field-outside-a-form', handled)).toHaveLength(0);
1090
+ }
1091
+ });
1092
+
1093
+ test('a field with nothing to submit is not one, and a comment naming one is not one', () => {
1094
+ expect(check('typed-field-outside-a-form', `<Input type="search" value={q} onChange={setQ} />`)).toHaveLength(0);
1095
+ expect(check('typed-field-outside-a-form', `<input type="checkbox" checked={on} />\n<input type={"range"} />`)).toHaveLength(0);
1096
+ expect(check('typed-field-outside-a-form', `<Input value={url} readOnly />`)).toHaveLength(0);
1097
+ expect(check('typed-field-outside-a-form', `// a <Input> used to sit here\n<Button onClick={go}>Go</Button>`)).toHaveLength(0);
1098
+ // `readOnly={false}` is an editable field.
1099
+ expect(check('typed-field-outside-a-form', `<Input value={v} readOnly={false} />`)).toHaveLength(1);
1100
+ });
1101
+
1102
+ test('a field whose form is in its parent says so', () => {
1103
+ const source = `{/* guardrails-ignore typed-field-outside-a-form: rendered inside AccountForm's <form> */}
1104
+ <SecretInput value={pw} onChange={setPw} />`;
1105
+ expect(check('typed-field-outside-a-form', source)).toHaveLength(0);
1106
+ });
1107
+
1108
+ test('specs and the kit\'s own components are fixtures, not screens', () => {
1109
+ expect(RULES['typed-field-outside-a-form'].check('src/App.spec.tsx', clickOnly)).toHaveLength(0);
1110
+ expect(RULES['typed-field-outside-a-form'].check('libs/cursedbelt/src/react/components/SecretInput.tsx', clickOnly)).toHaveLength(0);
1111
+ expect(RULES['typed-field-outside-a-form'].check('src/server/page.ts', clickOnly)).toHaveLength(0);
1112
+ expect(APP_RULES).toContain('typed-field-outside-a-form');
1113
+ });
1114
+ });
1115
+
1048
1116
  describe('no-native-dialog', () => {
1049
1117
  test('flags each of the three native dialogs', () => {
1050
1118
  expect(check('no-native-dialog', `if (!window.confirm("sure?")) return;`)).toHaveLength(1);
@@ -1271,6 +1339,8 @@ describe('--all-app-rules (task 2219: a new belt rule reaches every app by itsel
1271
1339
  const rows: Record<string, number> = Object.fromEntries(APP_RULES.map((name) => [name, 0]));
1272
1340
  rows['no-hardcoded-colors'] = 2;
1273
1341
  rows['no-raw-inputs'] = 1;
1342
+ // The same <input>, with no form in its file: Enter has nothing to submit (forge task 3043).
1343
+ rows['typed-field-outside-a-form'] = 1;
1274
1344
  for (const [name, count] of Object.entries(overrides)) {
1275
1345
  if (count === undefined) delete rows[name];
1276
1346
  else rows[name] = count;
@@ -113,6 +113,17 @@
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
+ //
121
+ // typed-field-outside-a-form — owner, 2026-10-11: Enter submits. A typed field in a file with no
122
+ // `<form` / `onSubmit` / `onEnter` / `onComplete` / `"Enter"` handler is a field beside a
123
+ // click-only button. Search, checkbox-like and read-only inputs are not matched; a field whose
124
+ // form is in its parent says so with `// guardrails-ignore typed-field-outside-a-form: <why>`.
125
+ // Ratcheted per app.
126
+ //
116
127
  // no-native-dialog — DESIGN.md core mandate: no `window.confirm` / `window.prompt` /
117
128
  // `window.alert`. The browser draws them outside the page, so no stylesheet reaches
118
129
  // them; they block the main thread; a browser can SUPPRESS a repeat dialog, after
@@ -2022,6 +2033,97 @@ const noSelectAsActionMenu: Rule = {
2022
2033
  },
2023
2034
  };
2024
2035
 
2036
+ // ── no-native-select ────────────────────────────────────────────────────────
2037
+ // The owner, 2026-10-10, on vault's entry form: *"I saw we don't style the dropdowns and that is a
2038
+ // violation of our design requirements to have bad styling or missing styling break the app's
2039
+ // cohesiveness like that. cb should have components that handle this … we should not let anything
2040
+ // like that by us anymore."* The dropdowns were `NativeSelect`: its LIST is drawn by the OS — on
2041
+ // macOS Chrome a grey system menu no stylesheet reaches — so every app that used one had a control
2042
+ // that was not the kit's. `no-select-as-action-menu` caught only the action-menu shape of it.
2043
+ //
2044
+ // Every app now counts its `<NativeSelect` uses in its two-sided ratchet, so the number only falls:
2045
+ // `SelectField` / `ComboboxField` for a field, `PropertyPicker` for a record's property pill,
2046
+ // `ChoiceMenu` to choose-and-act. A use that is genuinely right — a form on a phone where the OS
2047
+ // wheel IS the better control — says so on the line above:
2048
+ // `// guardrails-ignore no-native-select: <why>`.
2049
+ const NATIVE_SELECT_IGNORE_RE = /guardrails-ignore\s+no-native-select\s*:/;
2050
+
2051
+ const noNativeSelect: Rule = {
2052
+ name: "no-native-select",
2053
+ description:
2054
+ "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>`.",
2055
+ check(filePath, source) {
2056
+ const unix = filePath.replace(/\\/g, "/");
2057
+ if (/(^|\/)(?:packages|libs)\/cursedbelt\/src\/react\/components\//.test(unix)) return [];
2058
+ if (/\.(?:spec|test)\.[tj]sx?$/.test(unix)) return [];
2059
+ const scannable = blankComments(source);
2060
+ const lines = source.split("\n");
2061
+ const out: Violation[] = [];
2062
+ for (const match of scannable.matchAll(/<NativeSelect\b/g)) {
2063
+ const line = lineOf(source, match.index ?? 0);
2064
+ if (NATIVE_SELECT_IGNORE_RE.test(lines[line - 1] ?? "") || NATIVE_SELECT_IGNORE_RE.test(blockAbove(lines, line))) continue;
2065
+ out.push({ file: filePath, line, snippet: lines[line - 1]?.trim() ?? "" });
2066
+ }
2067
+ return out;
2068
+ },
2069
+ };
2070
+
2071
+ // ── typed-field-outside-a-form ──────────────────────────────────────────────
2072
+ // The owner, 2026-10-11, on auth's emailed code: *"I entered an email code and pressed enter key
2073
+ // and nothing happened … Make sure other pages like the email password form let's hitting enter
2074
+ // submit it too as applicable."* Auth was fixed in auth e958057 and vault's Settings in forge task
2075
+ // 3043 — both the same shape: a typed field beside a click-only button, with no `<form>`, so Enter
2076
+ // (the browser's implicit submission) had nothing to submit.
2077
+ //
2078
+ // A file with a typed field — `<input>`, `<Input`, `<SecretInput`, `<OtpInput` — must handle Enter
2079
+ // SOMEWHERE: a `<form`, an `onSubmit=`, an `onEnter=`, an `onComplete=` (OtpInput submits itself
2080
+ // at its last digit), a key handler naming `"Enter"`, or a `useAutosave(` — an editor that saves
2081
+ // as you type has nothing to submit (vault's EntryEditor, forge task 3016.5). It is FILE-scoped on purpose: a field
2082
+ // whose form is in its parent file cannot be seen from here, so it would take an opt-out, and the
2083
+ // rule has to be quiet enough that an opt-out means something. Fields with nothing to submit are
2084
+ // not matched — `type="search"` (a live filter), a checkbox/radio/range/color/file/hidden input,
2085
+ // and a `readOnly` one. A field genuinely without an action says why on the line above:
2086
+ // `// guardrails-ignore typed-field-outside-a-form: <why>`. Ratcheted per app.
2087
+ const TYPED_FIELD_RE = /<(?:input|Input|SecretInput|OtpInput)\b/g;
2088
+ const ENTER_HANDLED_RE = /<form\b|\bonSubmit\s*=|\bonEnter\s*=|\bonComplete\s*=|["'`]Enter["'`]|\buseAutosave\s*(?:<[^>]*>\s*)?\(/;
2089
+ const NOTHING_TO_SUBMIT_RE =
2090
+ /\btype\s*=\s*(?:\{\s*)?["'`](?:search|checkbox|radio|range|color|file|hidden|button|submit|reset|image)["'`]|\breadOnly\b(?!\s*=\s*\{\s*false\s*\})/;
2091
+ const TYPED_FIELD_IGNORE_RE = /guardrails-ignore\s+typed-field-outside-a-form\s*:/;
2092
+
2093
+ /** The attribute text of the JSX tag opening at `start` — up to its `>`, skipping `{…}` expressions. */
2094
+ function tagText(source: string, start: number): string {
2095
+ let depth = 0;
2096
+ for (let i = start + 1; i < source.length && i < start + 2000; i++) {
2097
+ const c = source[i];
2098
+ if (c === "{") depth++;
2099
+ else if (c === "}") depth--;
2100
+ else if (c === ">" && depth === 0) return source.slice(start, i + 1);
2101
+ }
2102
+ return source.slice(start, start + 2000);
2103
+ }
2104
+
2105
+ const typedFieldOutsideAForm: Rule = {
2106
+ name: "typed-field-outside-a-form",
2107
+ description:
2108
+ "Owner, 2026-10-11: Enter must submit. A typed field (`<input>`, `Input`, `SecretInput`, `OtpInput`) in a file with no `<form`, `onSubmit`, `onEnter`, `onComplete` or `\"Enter\"` key handler is a field beside a click-only button, where Enter does nothing. Wrap the field and its button in `<form onSubmit>` (`className=\"contents\"` keeps a flex/grid parent's layout) and make the button `type=\"submit\"`. A field whose form is in its parent, or that truly has no action, says why on the line above: `// guardrails-ignore typed-field-outside-a-form: <why>`.",
2109
+ check(filePath, source) {
2110
+ const unix = filePath.replace(/\\/g, "/");
2111
+ if (/(^|\/)(?:packages|libs)\/cursedbelt\/src\/react\/components\//.test(unix)) return [];
2112
+ if (isTestSource(unix) || !/\.[jt]sx$/.test(unix)) return [];
2113
+ const scannable = blankComments(source);
2114
+ if (ENTER_HANDLED_RE.test(scannable)) return [];
2115
+ const lines = source.split("\n");
2116
+ const out: Violation[] = [];
2117
+ for (const match of scannable.matchAll(TYPED_FIELD_RE)) {
2118
+ if (NOTHING_TO_SUBMIT_RE.test(tagText(scannable, match.index ?? 0))) continue;
2119
+ const line = lineOf(source, match.index ?? 0);
2120
+ if (TYPED_FIELD_IGNORE_RE.test(lines[line - 1] ?? "") || TYPED_FIELD_IGNORE_RE.test(blockAbove(lines, line))) continue;
2121
+ out.push({ file: filePath, line, snippet: lines[line - 1]?.trim() ?? "" });
2122
+ }
2123
+ return out;
2124
+ },
2125
+ };
2126
+
2025
2127
  // ── no-native-dialog ────────────────────────────────────────────────────────
2026
2128
  // `window.confirm` / `window.prompt` / `window.alert` — and their bare global
2027
2129
  // forms — are the browser's own chrome, and cursedbelt has shipped styled,
@@ -2925,6 +3027,8 @@ export const RULES: Record<string, Rule> = {
2925
3027
  [choiceNeedsEmptyState.name]: choiceNeedsEmptyState,
2926
3028
  [noSelectAsActionMenu.name]: noSelectAsActionMenu,
2927
3029
  [noNativeDialog.name]: noNativeDialog,
3030
+ [noNativeSelect.name]: noNativeSelect,
3031
+ [typedFieldOutsideAForm.name]: typedFieldOutsideAForm,
2928
3032
  [noFastTooltip.name]: noFastTooltip,
2929
3033
  [noRawPassword.name]: noRawPassword,
2930
3034
  [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',
@@ -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,