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/dist/react/components/PropertyPicker.d.ts +75 -0
- package/dist/react/components/PropertyPicker.d.ts.map +1 -0
- package/dist/react/components/PropertyPicker.js +108 -0
- package/dist/react/components/PropertyPicker.js.map +1 -0
- package/dist/react/components/TooltipImpl.d.ts.map +1 -1
- package/dist/react/components/TooltipImpl.js +22 -3
- package/dist/react/components/TooltipImpl.js.map +1 -1
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +1 -0
- package/dist/react/index.js.map +1 -1
- package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
- package/dist/styles-areas/core.css +2 -2
- package/dist/styles-areas/palette.css +8 -7
- package/dist/styles-utilities.css +9 -0
- package/package.json +1 -1
- package/scripts/guardrailsEnforce.spec.ts +70 -0
- package/scripts/guardrailsEnforce.ts +104 -0
- package/scripts/styleAreas.ts +1 -1
- package/src/react/components/PropertyPicker.spec.tsx +105 -0
- package/src/react/components/PropertyPicker.tsx +254 -0
- package/src/react/components/TooltipImpl.tsx +21 -1
- package/src/react/index.ts +8 -0
- package/src/styles-areas/core.css +2 -2
- package/src/styles-areas/palette.css +8 -7
- package/src/styles-utilities.css +9 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt",
|
|
3
|
-
"version": "5.
|
|
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,
|
package/scripts/styleAreas.ts
CHANGED
|
@@ -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')
|
|
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
|
)}
|
package/src/react/index.ts
CHANGED
|
@@ -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,
|