cursedbelt 5.91.0 → 5.92.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 (87) hide show
  1. package/dist/react/activity-bar/activityBar.css +5 -0
  2. package/dist/react/components/Autocomplete.d.ts +11 -2
  3. package/dist/react/components/Autocomplete.d.ts.map +1 -1
  4. package/dist/react/components/Autocomplete.js +7 -6
  5. package/dist/react/components/Autocomplete.js.map +1 -1
  6. package/dist/react/components/FileDropZone.d.ts.map +1 -1
  7. package/dist/react/components/FileDropZone.js +12 -76
  8. package/dist/react/components/FileDropZone.js.map +1 -1
  9. package/dist/react/components/TagsInput.d.ts +46 -4
  10. package/dist/react/components/TagsInput.d.ts.map +1 -1
  11. package/dist/react/components/TagsInput.js +92 -39
  12. package/dist/react/components/TagsInput.js.map +1 -1
  13. package/dist/react/components/TagsInputSuggesting.d.ts +8 -0
  14. package/dist/react/components/TagsInputSuggesting.d.ts.map +1 -0
  15. package/dist/react/components/TagsInputSuggesting.js +48 -0
  16. package/dist/react/components/TagsInputSuggesting.js.map +1 -0
  17. package/dist/react/components/dropTransfer.d.ts +53 -0
  18. package/dist/react/components/dropTransfer.d.ts.map +1 -0
  19. package/dist/react/components/dropTransfer.js +116 -0
  20. package/dist/react/components/dropTransfer.js.map +1 -0
  21. package/dist/react/components/suggestionList.d.ts +13 -0
  22. package/dist/react/components/suggestionList.d.ts.map +1 -0
  23. package/dist/react/components/suggestionList.js +19 -0
  24. package/dist/react/components/suggestionList.js.map +1 -0
  25. package/dist/react/index.d.ts +1 -0
  26. package/dist/react/index.d.ts.map +1 -1
  27. package/dist/react/index.js +1 -0
  28. package/dist/react/index.js.map +1 -1
  29. package/dist/react/media-gallery/MediaMetaEditor.d.ts +4 -4
  30. package/dist/react/optimistic/optimistic.d.ts +11 -2
  31. package/dist/react/optimistic/optimistic.d.ts.map +1 -1
  32. package/dist/react/optimistic/optimistic.js +4 -2
  33. package/dist/react/optimistic/optimistic.js.map +1 -1
  34. package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
  35. package/dist/styles-areas/analytics.css +14 -19
  36. package/dist/styles-areas/auth.css +3 -4
  37. package/dist/styles-areas/canvas-hud.css +3 -3
  38. package/dist/styles-areas/chat.css +10 -11
  39. package/dist/styles-areas/code-editor.css +2 -2
  40. package/dist/styles-areas/core.css +60 -54
  41. package/dist/styles-areas/data-table.css +13 -17
  42. package/dist/styles-areas/disk-usage.css +6 -7
  43. package/dist/styles-areas/emoji.css +2 -3
  44. package/dist/styles-areas/fields.css +15 -20
  45. package/dist/styles-areas/master-detail.css +3 -4
  46. package/dist/styles-areas/master-lock.css +1 -2
  47. package/dist/styles-areas/media-gallery.css +8 -10
  48. package/dist/styles-areas/rich-text.css +3 -3
  49. package/dist/styles-areas/stats.css +13 -17
  50. package/dist/styles-areas/wizard.css +6 -11
  51. package/package.json +7 -1
  52. package/scripts/guardrails-baseline.json +1 -1
  53. package/scripts/guardrailsEnforce.spec.ts +13 -0
  54. package/scripts/guardrailsEnforce.ts +5 -3
  55. package/scripts/styleAreas.ts +3 -0
  56. package/src/react/activity-bar/activityBar.css +5 -0
  57. package/src/react/components/Autocomplete.spec.tsx +55 -1
  58. package/src/react/components/Autocomplete.tsx +18 -43
  59. package/src/react/components/FileDropZone.tsx +12 -83
  60. package/src/react/components/TagsInput.spec.tsx +129 -14
  61. package/src/react/components/TagsInput.tsx +153 -85
  62. package/src/react/components/TagsInputSuggesting.tsx +94 -0
  63. package/src/react/components/dropTransfer.spec.ts +76 -0
  64. package/src/react/components/dropTransfer.ts +133 -0
  65. package/src/react/components/suggestionList.tsx +66 -0
  66. package/src/react/disk-usage/DiskUsage.spec.tsx +3 -0
  67. package/src/react/index.ts +1 -0
  68. package/src/react/lib/floatingStyles.spec.ts +2 -1
  69. package/src/react/media-gallery/MediaMetaEditor.tsx +4 -4
  70. package/src/react/optimistic/optimistic.spec.tsx +41 -0
  71. package/src/react/optimistic/optimistic.ts +13 -4
  72. package/src/styles-areas/analytics.css +14 -19
  73. package/src/styles-areas/auth.css +3 -4
  74. package/src/styles-areas/canvas-hud.css +3 -3
  75. package/src/styles-areas/chat.css +10 -11
  76. package/src/styles-areas/code-editor.css +2 -2
  77. package/src/styles-areas/core.css +60 -54
  78. package/src/styles-areas/data-table.css +13 -17
  79. package/src/styles-areas/disk-usage.css +6 -7
  80. package/src/styles-areas/emoji.css +2 -3
  81. package/src/styles-areas/fields.css +15 -20
  82. package/src/styles-areas/master-detail.css +3 -4
  83. package/src/styles-areas/master-lock.css +1 -2
  84. package/src/styles-areas/media-gallery.css +8 -10
  85. package/src/styles-areas/rich-text.css +3 -3
  86. package/src/styles-areas/stats.css +13 -17
  87. package/src/styles-areas/wizard.css +6 -11
@@ -18,19 +18,14 @@
18
18
  * after an app's responsive variant (task 2124). An app that imports every area gets exactly
19
19
  * what `cursedbelt/styles-utilities.css` emits — proved rule by rule in
20
20
  * src/stylesAreas.spec.ts, which is also what fails when this file is stale.
21
- * 42 candidate(s).
21
+ * 25 candidate(s).
22
22
  *
23
23
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
24
24
  * `bun run build` runs. The map of what is in which area, and why the areas are
25
25
  * import closures rather than a hand-written list, is scripts/styleAreas.ts.
26
26
  */
27
- @source inline("[&_svg]:size-3 border-field-error collapse data-[ending-style]:opacity-0");
28
- @source inline("data-[ending-style]:scale-95 data-[highlighted]:bg-accent/10");
29
- @source inline("data-[highlighted]:text-accent-strong data-[starting-style]:opacity-0");
30
- @source inline("data-[starting-style]:scale-95 disabled:bg-transparent disabled:opacity-100 duration-100");
31
- @source inline("focus-visible:ring-field-ring/40 focus-within:border-field-ring focus-within:ring-2");
32
- @source inline("focus-within:ring-field-ring/30 h-105 has-[[aria-invalid=true]]:border-field-error");
33
- @source inline("hover:border-field-ring hover:text-warning max-h-64 min-h-20 min-w-24");
34
- @source inline("min-w-[var(--anchor-width)] mr-1.5 mr-2 pl-1.5 pl-2 pr-1 pr-16 pr-3 resize-none resize-y");
35
- @source inline("ring-field-error/30 self-stretch size-7 sm:inline text-field-placeholder text-xl");
36
- @source inline("transition-[transform,opacity] w-36 w-88");
27
+ @source inline("border-field-error collapse disabled:bg-transparent disabled:opacity-100");
28
+ @source inline("focus-visible:ring-field-ring/40 h-105 has-[[aria-invalid=true]]:border-field-error");
29
+ @source inline("hover:border-field-ring hover:text-warning min-h-20 mr-1.5 mr-2 pl-1.5 pr-16 pr-3 resize-none");
30
+ @source inline("resize-y ring-field-error/30 self-stretch size-7 sm:inline text-field-placeholder text-xl w-36");
31
+ @source inline("w-88");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt",
3
- "version": "5.91.0",
3
+ "version": "5.92.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.",
@@ -528,6 +528,12 @@
528
528
  "source": "./src/react/components/Modal.tsx",
529
529
  "import": "./dist/react/components/Modal.js"
530
530
  },
531
+ "./react/autocomplete": {
532
+ "types": "./dist/react/components/Autocomplete.d.ts",
533
+ "bun": "./src/react/components/Autocomplete.tsx",
534
+ "source": "./src/react/components/Autocomplete.tsx",
535
+ "import": "./dist/react/components/Autocomplete.js"
536
+ },
531
537
  "./react/multi-select-field": {
532
538
  "types": "./dist/react/components/MultiSelectField.d.ts",
533
539
  "bun": "./src/react/components/MultiSelectField.tsx",
@@ -24,7 +24,7 @@
24
24
  "no-inline-style": 73,
25
25
  "no-arbitrary-length": 0,
26
26
  "opt-out-count": 272,
27
- "opt-out-needs-cause": 254
27
+ "opt-out-needs-cause": 253
28
28
  },
29
29
  "cursedshell": {
30
30
  "no-raw-action-button": 0,
@@ -1063,6 +1063,19 @@ describe('no-native-select — the OS draws its list (owner, 2026-10-10)', () =>
1063
1063
  expect(check('no-native-select', `<selection-bar />`)).toHaveLength(0);
1064
1064
  });
1065
1065
 
1066
+ // forge task 3602, the owner 2026-10-23: *"When I click the add a tag dropdown, the dropdown does
1067
+ // not look styled … I was in dark mode and it popped up light."* That was an `<input list>`.
1068
+ test('a <datalist> is the same OS list, and counts too (forge task 3602)', () => {
1069
+ const source = `<input list="known-tags" value={tag} onChange={(e) => setTag(e.target.value)} />
1070
+ <datalist id="known-tags">
1071
+ {tags.map((t) => <option key={t} value={t} />)}
1072
+ </datalist>`;
1073
+ expect(check('no-native-select', source)).toHaveLength(1);
1074
+ expect(check('no-native-select', `<datalist id={\`ft-record-\${category}\`}>`)).toHaveLength(1);
1075
+ expect(check('no-native-select', `// was a <datalist> until 3602\n<AutocompleteField suggestions={tags} />`)).toHaveLength(0);
1076
+ expect(check('no-native-select', `<datalistish />`)).toHaveLength(0);
1077
+ });
1078
+
1066
1079
  test('the styled controls, and a comment that names it, are not one', () => {
1067
1080
  expect(check('no-native-select', `<SelectField value={sort} options={SORTS} />`)).toHaveLength(0);
1068
1081
  expect(check('no-native-select', `// was a <NativeSelect> until 2026-10-10\n<PropertyPicker name="Type" />`)).toHaveLength(0);
@@ -2092,7 +2092,9 @@ const noSelectAsActionMenu: Rule = {
2092
2092
  // even the kit's frame (forge task 3097: desk had eight under `no-raw-inputs`, which no count of
2093
2093
  // NativeSelect could see) — in its two-sided ratchet, so the number only falls:
2094
2094
  // `SelectField` / `ComboboxField` for a field, `PropertyPicker` for a record's property pill,
2095
- // `ChoiceMenu` to choose-and-act. A use that is genuinely right — a form on a phone where the OS
2095
+ // `ChoiceMenu` to choose-and-act. A `<datalist>` counts too (forge task 3602, the owner 2026-10-23:
2096
+ // a tag field's suggestions *"popped up light"* in dark mode) — `<input list>`'s dropdown is the
2097
+ // same OS list; `AutocompleteField` or `TagsInput` suggest in the page. A use that is genuinely right — a form on a phone where the OS
2096
2098
  // wheel IS the better control — says so on the line above:
2097
2099
  // `// guardrails-ignore no-native-select: <why>`.
2098
2100
  const NATIVE_SELECT_IGNORE_RE = /guardrails-ignore\s+no-native-select\s*:/;
@@ -2100,7 +2102,7 @@ const NATIVE_SELECT_IGNORE_RE = /guardrails-ignore\s+no-native-select\s*:/;
2100
2102
  const noNativeSelect: Rule = {
2101
2103
  name: "no-native-select",
2102
2104
  description:
2103
- "Owner, 2026-10-10: unstyled dropdowns break the app's cohesiveness. A `NativeSelect`'s (or a raw `<select>`'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>`.",
2105
+ "Owner, 2026-10-10: unstyled dropdowns break the app's cohesiveness. A `NativeSelect`'s (or a raw `<select>`'s, or a `<datalist>`'s) list is drawn by the OS, outside the page, where no stylesheet reaches it. Use `SelectField` / `ComboboxField` for a form field, `AutocompleteField` / `TagsInput` for suggestions as you type, `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>`.",
2104
2106
  check(filePath, source) {
2105
2107
  const unix = filePath.replace(/\\/g, "/");
2106
2108
  // The kit is held to its own law (forge task 3063: its DataTable filter builder and
@@ -2112,7 +2114,7 @@ const noNativeSelect: Rule = {
2112
2114
  const scannable = blankComments(source);
2113
2115
  const lines = source.split("\n");
2114
2116
  const out: Violation[] = [];
2115
- for (const match of scannable.matchAll(/<(?:NativeSelect|select)\b/g)) {
2117
+ for (const match of scannable.matchAll(/<(?:NativeSelect|select|datalist)\b/g)) {
2116
2118
  const line = lineOf(source, match.index ?? 0);
2117
2119
  if (NATIVE_SELECT_IGNORE_RE.test(lines[line - 1] ?? "") || NATIVE_SELECT_IGNORE_RE.test(blockAbove(lines, line))) continue;
2118
2120
  out.push({ file: filePath, line, snippet: lines[line - 1]?.trim() ?? "" });
@@ -181,6 +181,9 @@ export const AREAS: readonly Area[] = [
181
181
  'components/IconButton.tsx',
182
182
  'components/InfoHint.tsx',
183
183
  'components/InfoHintImpl.tsx',
184
+ // TagsInput `import()`s it (forge task 3614) from seven areas' closures (lazyImportFault) — named
185
+ // here once, so whichever area reaches TagsInput, its suggestion list is styled.
186
+ 'components/TagsInputSuggesting.tsx',
184
187
  'components/Input.tsx',
185
188
  'components/ListPanel.tsx',
186
189
  'components/Menu.tsx',
@@ -264,6 +264,11 @@
264
264
  .cbab-slot {
265
265
  inline-size: 44px;
266
266
  block-size: 44px;
267
+ }
268
+
269
+ /* 12px centers the rail's OWN 20px glyph. A slot holds the consumer's control, which
270
+ `max-inline-size: 100%` would squeeze to that 20px — it gets the whole 44 instead. */
271
+ .cbab-btn {
267
272
  padding-inline: 12px;
268
273
  }
269
274
  }
@@ -1,6 +1,6 @@
1
1
  import { afterEach, describe, expect, mock, test } from 'bun:test';
2
2
  import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react';
3
- import { useState } from 'react';
3
+ import { createRef, useState } from 'react';
4
4
  import { AutocompleteField } from './Autocomplete.js';
5
5
 
6
6
  afterEach(cleanup);
@@ -79,4 +79,58 @@ describe('AutocompleteField', () => {
79
79
  // Suggestions should be visible in the popup (portaled to document.body)
80
80
  expect(getByText('Apple')).not.toBeNull();
81
81
  });
82
+
83
+ // 🔴 Seen on music-stage, 2026-10-23: "zz" still offered every folder — the list drew the whole
84
+ // static array, never Base UI's filtered one (forge task 3602).
85
+ test('static suggestions narrow to what contains the typed text, in any case', () => {
86
+ const { container, queryByText } = render(<Controlled initial='AP' />);
87
+ const input = container.querySelector('input') as HTMLInputElement;
88
+ act(() => {
89
+ fireEvent.keyDown(input, { key: 'ArrowDown' });
90
+ });
91
+ expect(queryByText('Apple')).not.toBeNull();
92
+ expect(queryByText('Apricot')).not.toBeNull();
93
+ expect(queryByText('Banana')).toBeNull();
94
+ expect(queryByText('Cherry')).toBeNull();
95
+ });
96
+
97
+ test('inputRef is the input, so a form can focus the field it names as missing', () => {
98
+ const ref = createRef<HTMLInputElement>();
99
+ const { container } = render(<AutocompleteField inputRef={ref} suggestions={SUGGESTIONS} />);
100
+ expect(ref.current).toBe(container.querySelector('input'));
101
+ });
102
+
103
+ test('a field that is more than its query brings its own match', () => {
104
+ // A comma list: each suggestion is the whole next value, and only the LAST entry is the query.
105
+ const last = (text: string) => text.slice(text.lastIndexOf(',') + 1).trim().toLowerCase();
106
+ const { container, queryByText } = render(
107
+ <AutocompleteField
108
+ value='rock, an'
109
+ suggestions={['rock, trance', 'rock, ambient', 'rock, jazz']}
110
+ filter={(item, query) => last(item).includes(last(query))}
111
+ />,
112
+ );
113
+ act(() => {
114
+ fireEvent.keyDown(container.querySelector('input') as HTMLInputElement, { key: 'ArrowDown' });
115
+ });
116
+ expect(queryByText('rock, trance')).not.toBeNull();
117
+ expect(queryByText('rock, ambient')).toBeNull();
118
+ expect(queryByText('rock, jazz')).toBeNull();
119
+ });
120
+
121
+ test('loadSuggestions answers are drawn as given — the loader already searched', async () => {
122
+ const loadSuggestions = mock(async () => ['Zebra', 'Yak']);
123
+ function Async() {
124
+ const [value, setValue] = useState('qq');
125
+ return <AutocompleteField value={value} onChange={setValue} loadSuggestions={loadSuggestions} />;
126
+ }
127
+ const { container, queryByText } = render(<Async />);
128
+ await waitFor(() => expect(loadSuggestions).toHaveBeenCalled(), { timeout: 1000 });
129
+ const input = container.querySelector('input') as HTMLInputElement;
130
+ await act(async () => {
131
+ fireEvent.keyDown(input, { key: 'ArrowDown' });
132
+ });
133
+ await waitFor(() => expect(queryByText('Zebra')).not.toBeNull(), { timeout: 1000 });
134
+ expect(queryByText('Yak')).not.toBeNull();
135
+ });
82
136
  });
@@ -2,12 +2,11 @@
2
2
 
3
3
  import { Autocomplete } from '@base-ui/react/autocomplete';
4
4
  import { ChevronDown, Loader2 } from 'lucide-react';
5
- import { type InputHTMLAttributes, type ReactNode, useCallback, useEffect, useState } from 'react';
5
+ import { type InputHTMLAttributes, type ReactNode, type Ref, useCallback, useEffect, useState } from 'react';
6
6
  import { useDebouncedCallback } from '../hooks/useDebouncedCallback.js';
7
7
  import { cn } from '../lib/cn.js';
8
8
  import { type ControlSize, fieldControlClass } from '../lib/field.js';
9
- import { FLOATING_COLLISION_PADDING,
10
- FLOATING_CONTENT_BASE, FLOATING_ITEM_BASE } from '../lib/floatingStyles.js';
9
+ import { SuggestionList } from './suggestionList.js';
11
10
 
12
11
  export interface AutocompleteProps<S = string>
13
12
  extends Omit<
@@ -30,6 +29,13 @@ export interface AutocompleteProps<S = string>
30
29
  onBlur?: () => void;
31
30
  /** Static suggestion items shown in the dropdown. */
32
31
  suggestions?: S[];
32
+ /**
33
+ * Whether a static suggestion matches what is typed. Default: its text CONTAINS the typed text,
34
+ * any case (Base UI's own match). A field whose value is more than the query — a comma list
35
+ * where each suggestion completes the LAST entry — passes its own. `null` shows them as given.
36
+ * Never applied to {@link loadSuggestions}: the loader's answer already is the search.
37
+ */
38
+ filter?: ((item: S, query: string) => boolean) | null;
33
39
  /** Async fetch — called on every keystroke (debounced 250 ms).
34
40
  * When provided, replaces the static `suggestions`. */
35
41
  loadSuggestions?: (query: string) => Promise<S[]>;
@@ -54,6 +60,8 @@ export interface AutocompleteProps<S = string>
54
60
  /** Height on the shared control-height scale (`--control-h-*`, task cbux-41).
55
61
  * Default `md`. */
56
62
  size?: ControlSize;
63
+ /** The input element — to focus it from a form that names what is missing. */
64
+ inputRef?: Ref<HTMLInputElement>;
57
65
  }
58
66
 
59
67
  const DEBOUNCE_MS = 250;
@@ -74,6 +82,7 @@ export function AutocompleteField<S = string>({
74
82
  onChange,
75
83
  onBlur,
76
84
  suggestions = [],
85
+ filter,
77
86
  loadSuggestions,
78
87
  itemToString = (item) => String(item),
79
88
  renderSuggestion,
@@ -85,6 +94,7 @@ export function AutocompleteField<S = string>({
85
94
  'aria-describedby': ariaDescribedBy,
86
95
  className,
87
96
  size = 'md',
97
+ inputRef,
88
98
  ...rest
89
99
  }: AutocompleteProps<S>) {
90
100
  const isAsync = Boolean(loadSuggestions);
@@ -127,11 +137,15 @@ export function AutocompleteField<S = string>({
127
137
  itemToStringValue={itemToString}
128
138
  disabled={disabled}
129
139
  mode='list'
140
+ // Static suggestions narrow to what is typed (by default Base UI's own match: contains, any
141
+ // case, accents folded). A loader's answer is already the search — drawn as it came.
142
+ filter={isAsync ? null : filter}
130
143
  autoHighlight={false}
131
144
  >
132
145
  <Autocomplete.InputGroup className='relative flex items-center'>
133
146
  <Autocomplete.Input
134
147
  {...rest}
148
+ ref={inputRef}
135
149
  id={id}
136
150
  name={name}
137
151
  aria-invalid={ariaInvalid}
@@ -153,46 +167,7 @@ export function AutocompleteField<S = string>({
153
167
  </span>
154
168
  </Autocomplete.InputGroup>
155
169
 
156
- <Autocomplete.Portal>
157
- {/* 🔴 The z-index goes on the POSITIONER, not only the popup. Base UI's positioner is
158
- the element that is `position: absolute` (with a transform); the popup inside it is
159
- `position: static`, so the popup's own `z-[var(--z-popover)]` does nothing and the
160
- list painted UNDER any sibling with a z-index — measured 2026-09-22 in the
161
- binary-server inspector, a sticky table header (z-index 1) covering the second option
162
- of an open MultiSelectField. */}
163
- <Autocomplete.Positioner
164
- sideOffset={4}
165
- collisionPadding={FLOATING_COLLISION_PADDING}
166
- className='z-[var(--z-popover)]'
167
- >
168
- <Autocomplete.Popup
169
- className={cn(
170
- FLOATING_CONTENT_BASE,
171
- // guardrails-ignore no-raw-overflow: floating popover panel outside the page's ScrollArea, height-capped by max-h-64 — no keyboard-inset concern.
172
- 'max-h-64 min-w-[var(--anchor-width)] overflow-y-auto p-0 py-1',
173
- 'transition-[transform,opacity] duration-100',
174
- 'data-[starting-style]:scale-95 data-[starting-style]:opacity-0',
175
- 'data-[ending-style]:scale-95 data-[ending-style]:opacity-0',
176
- )}
177
- >
178
- <Autocomplete.List>
179
- {activeSuggestions.map((s, index) => (
180
- <Autocomplete.Item
181
- key={itemToString(s)}
182
- value={s}
183
- index={index}
184
- className={cn(
185
- FLOATING_ITEM_BASE,
186
- 'data-[highlighted]:bg-accent/10 data-[highlighted]:text-accent-strong',
187
- )}
188
- >
189
- {renderSuggestion ? renderSuggestion(s) : itemToString(s)}
190
- </Autocomplete.Item>
191
- ))}
192
- </Autocomplete.List>
193
- </Autocomplete.Popup>
194
- </Autocomplete.Positioner>
195
- </Autocomplete.Portal>
170
+ <SuggestionList itemToString={itemToString} renderSuggestion={renderSuggestion} />
196
171
  </Autocomplete.Root>
197
172
  );
198
173
  }
@@ -2,6 +2,7 @@ import { Upload, X } from 'lucide-react';
2
2
  import { type DragEvent, type ReactNode, useId, useRef, useState } from 'react';
3
3
  import { cn } from '../lib/cn.js';
4
4
  import { Button } from './Button.js';
5
+ import { isHiddenName, matchesAccept, walkDrop } from './dropTransfer.js';
5
6
  import { Input } from './Input.js';
6
7
 
7
8
  export interface FileDropZoneProps {
@@ -51,102 +52,30 @@ export interface FileDropZoneProps {
51
52
  className?: string;
52
53
  }
53
54
 
54
- /** The slice of the File and Directory Entries API a drop needs — every browser ships it. */
55
- interface DropEntry {
56
- isFile: boolean;
57
- isDirectory: boolean;
58
- name: string;
59
- file?: (ok: (file: File) => void, fail: (error: unknown) => void) => void;
60
- createReader?: () => {
61
- readEntries: (ok: (entries: DropEntry[]) => void, fail: (error: unknown) => void) => void;
62
- };
63
- }
64
-
65
- /** What a folder can hold before walking it stops — a drop, not a disk scan. */
66
- const MAX_FOLDER_FILES = 5000;
67
-
68
- const isHidden = (name: string) => name.startsWith('.');
69
-
70
- /**
71
- * Every file under `entry`, depth-first. A directory's reader answers in batches until empty.
72
- *
73
- * Each file carries its path from the dropped folder in `webkitRelativePath` (`Album/CD1/01.mp3`),
74
- * exactly as a folder PICKER's files do natively — so an app reading provenance (music defaults
75
- * its "where these came from" field to the top folder) gets the same answer from both gestures.
76
- * A drop leaves it `''` otherwise, and that difference is what kept music on its own walk.
77
- */
78
- async function filesUnder(entry: DropEntry, out: File[], prefix = ''): Promise<void> {
79
- if (out.length >= MAX_FOLDER_FILES || isHidden(entry.name)) return;
80
- if (entry.isFile && entry.file) {
81
- const read = entry.file.bind(entry);
82
- const file = await new Promise<File>((ok, fail) => read(ok, fail));
83
- Object.defineProperty(file, 'webkitRelativePath', { value: `${prefix}${file.name}`, configurable: true });
84
- out.push(file);
85
- return;
86
- }
87
- if (!entry.isDirectory || !entry.createReader) return;
88
- const reader = entry.createReader();
89
- while (out.length < MAX_FOLDER_FILES) {
90
- const batch = await new Promise<DropEntry[]>((ok, fail) => reader.readEntries(ok, fail));
91
- if (batch.length === 0) return;
92
- for (const child of batch) await filesUnder(child, out, `${prefix}${entry.name}/`);
93
- }
94
- }
95
-
96
55
  /**
97
56
  * A drop's files, with every dropped FOLDER replaced by the accepted files inside it — see the
98
57
  * FOLDER section above. `null` when nothing in the drop is a folder, so the ordinary path stays
99
- * exactly `dataTransfer.files`.
100
- *
101
- * 🔴 The entries are taken SYNCHRONOUSLY, before the first `await`: a browser empties the
102
- * `DataTransfer` once the drop event returns.
58
+ * exactly `dataTransfer.files`. The walk itself is {@link walkDrop}, shared with every app's own
59
+ * drop target through {@link filesFromDataTransfer}.
103
60
  */
104
61
  function folderAwareDrop(
105
62
  transfer: DataTransfer,
106
63
  accept: string | undefined,
107
64
  ): Promise<{ loose: File[]; fromFolders: File[]; emptyFolders: File[] }> | null {
108
- const items = transfer.items ? [...transfer.items] : [];
109
- const entries = items.map((item) =>
110
- item.kind === 'file' && typeof item.webkitGetAsEntry === 'function'
111
- ? (item.webkitGetAsEntry() as DropEntry | null)
112
- : null,
113
- );
114
- if (!entries.some((entry) => entry?.isDirectory)) return null;
115
- const looseFiles = [...transfer.files];
116
- return (async () => {
117
- const loose: File[] = [];
65
+ const walking = walkDrop(transfer);
66
+ if (!walking) return null;
67
+ return walking.then(({ loose, folders }) => {
118
68
  const fromFolders: File[] = [];
119
69
  const emptyFolders: File[] = [];
120
- for (const [i, entry] of entries.entries()) {
121
- if (!entry?.isDirectory) {
122
- const file = looseFiles[i] ?? (entry ? await new Promise<File | null>((ok) => entry.file?.(ok, () => ok(null))) : null);
123
- if (file) loose.push(file);
124
- continue;
125
- }
126
- const inside: File[] = [];
127
- try {
128
- await filesUnder(entry, inside);
129
- } catch {
130
- // An unreadable folder yields what was read before the error — and, if that is nothing, a refusal below.
131
- }
132
- const kept = inside.filter((f) => matchesAccept(f, accept));
70
+ for (const folder of folders) {
71
+ // A folder that yields nothing acceptable is a refusal under its own name, never silence.
72
+ const kept = folder.files.filter((f) => matchesAccept(f, accept));
133
73
  if (kept.length > 0) fromFolders.push(...kept);
134
- else emptyFolders.push(new File([], entry.name));
74
+ else emptyFolders.push(new File([], folder.name));
135
75
  }
136
76
  return { loose, fromFolders, emptyFolders };
137
- })();
138
- }
139
-
140
- const matchesAccept = (file: File, accept?: string): boolean => {
141
- if (!accept) return true;
142
- return accept.split(',').some((raw) => {
143
- const rule = raw.trim().toLowerCase();
144
- if (!rule) return false;
145
- if (rule.startsWith('.')) return file.name.toLowerCase().endsWith(rule);
146
- if (rule.endsWith('/*')) return file.type.toLowerCase().startsWith(rule.slice(0, -1));
147
- return file.type.toLowerCase() === rule;
148
77
  });
149
- };
78
+ }
150
79
 
151
80
  /**
152
81
  * A folder PICKER's files, judged the way a dropped folder's are: what `accept` turns away is
@@ -156,7 +85,7 @@ const matchesAccept = (file: File, accept?: string): boolean => {
156
85
  */
157
86
  function folderPick(list: FileList | null, accept: string | undefined): { fromFolders: File[]; emptyFolders: File[] } {
158
87
  const all = list ? [...list] : [];
159
- const kept = all.filter((f) => !f.webkitRelativePath.split('/').some(isHidden) && matchesAccept(f, accept));
88
+ const kept = all.filter((f) => !f.webkitRelativePath.split('/').some(isHiddenName) && matchesAccept(f, accept));
160
89
  if (kept.length > 0 || all.length === 0) return { fromFolders: kept, emptyFolders: [] };
161
90
  const name = all[0]?.webkitRelativePath.split('/')[0] || 'That folder';
162
91
  return { fromFolders: [], emptyFolders: [new File([], name)] };
@@ -1,7 +1,9 @@
1
- import { afterEach, describe, expect, test } from 'bun:test';
2
- import { cleanup, fireEvent, render } from '@testing-library/react';
1
+ import { afterEach, beforeAll, describe, expect, test } from 'bun:test';
2
+ import { readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react';
3
5
  import { useState } from 'react';
4
- import { TagsInput } from './TagsInput.js';
6
+ import { preloadSuggestingTagsInput, TagsInput } from './TagsInput.js';
5
7
 
6
8
  afterEach(cleanup);
7
9
 
@@ -80,17 +82,76 @@ describe('TagsInput', () => {
80
82
  });
81
83
  });
82
84
 
85
+ /**
86
+ * 🔴 Never a `<datalist>` (forge task 3602). The owner, 2026-10-23: *"the dropdown does not look
87
+ * styled … I was in dark mode and it popped up light"* — the browser draws a datalist outside the
88
+ * page. The list is the kit's own (Base UI Autocomplete, portaled), carrying the popover tokens.
89
+ */
90
+ /**
91
+ * A BYTE contract as much as a behavioral one (forge task 3614): the suggesting field is Base UI's
92
+ * Autocomplete, ~38 kB gzip, and 5.92.0 rooted every TagsInput in it — station's FolderSizesView,
93
+ * whose rules fields suggest nothing, went 7.15 → 44.32 kB gzip. A field without `suggestions`
94
+ * must not carry that engine; one with them fetches it, and loses nothing typed meanwhile.
95
+ *
96
+ * 🔴 These run BEFORE the `suggestions` block preloads the engine, so the first render here is
97
+ * the plain field the hand-over starts from.
98
+ */
99
+ describe('the suggesting field is loaded only when asked for', () => {
100
+ test('TagsInput.tsx imports no Base UI, and reaches the suggesting field dynamically', () => {
101
+ const src = readFileSync(join(import.meta.dir, 'TagsInput.tsx'), 'utf8');
102
+ expect(src).not.toMatch(/from\s*['"]@base-ui\//);
103
+ expect(src).not.toMatch(/from\s*['"]\.\/(TagsInputSuggesting|suggestionList)(\.js)?['"]/);
104
+ expect(src).toContain("import('./TagsInputSuggesting.js')");
105
+ });
106
+
107
+ test('a draft typed and the focus held before the engine arrives are still there after', async () => {
108
+ const { container } = render(
109
+ <TagsInput value={[]} onChange={() => undefined} suggestions={['banking', 'tax']} />,
110
+ );
111
+ const plain = container.querySelector('input') as HTMLInputElement;
112
+ expect(plain.getAttribute('role'), 'the engine was already loaded — this test must run first').toBeNull();
113
+ act(() => plain.focus());
114
+ fireEvent.change(plain, { target: { value: 'ba' } });
115
+ // Where the focus is in the commit that swaps the inputs, not some polls later: a mutation
116
+ // record is a microtask after that commit, before any passive effect (a later task), so a
117
+ // hand-over in `useEffect` reads <body> here every time, not only under a loaded gate.
118
+ let focusAtSwap: Element | null | undefined;
119
+ const swap = new MutationObserver(() => {
120
+ if (focusAtSwap === undefined && container.querySelector('input[role="combobox"]')) {
121
+ focusAtSwap = document.activeElement;
122
+ }
123
+ });
124
+ swap.observe(container, { childList: true, subtree: true, attributes: true });
125
+ await waitFor(() => expect(container.querySelector('input')?.getAttribute('role')).toBe('combobox'));
126
+ swap.disconnect();
127
+ const suggesting = container.querySelector('input') as HTMLInputElement;
128
+ expect(suggesting.value).toBe('ba');
129
+ expect(focusAtSwap).toBe(suggesting);
130
+ expect(document.activeElement).toBe(suggesting);
131
+ });
132
+ });
133
+
83
134
  describe('suggestions', () => {
84
- test('offers a datalist of suggestions, minus what is already committed', () => {
135
+ // Every case below draws the list, so the engine is in hand before the first render.
136
+ beforeAll(() => preloadSuggestingTagsInput());
137
+
138
+ const openList = (input: HTMLInputElement) =>
139
+ act(() => {
140
+ fireEvent.keyDown(input, { key: 'ArrowDown' });
141
+ });
142
+ const offeredNow = () => [...document.querySelectorAll('[role="option"]')].map((o) => o.textContent);
143
+
144
+ test('drawn in the page with the kit tokens, never a datalist — minus what is already committed', () => {
85
145
  const { container } = render(
86
146
  <TagsInput value={['work']} onChange={() => undefined} suggestions={['work', 'bank']} />,
87
147
  );
88
148
  const input = container.querySelector('input') as HTMLInputElement;
89
- const list = container.querySelector('datalist') as HTMLDataListElement;
90
- expect(list).not.toBeNull();
91
- expect(input.getAttribute('list')).toBe(list.id);
92
- const offered = [...list.querySelectorAll('option')].map((o) => o.getAttribute('value'));
93
- expect(offered).toEqual(['bank']);
149
+ expect(document.querySelector('datalist')).toBeNull();
150
+ expect(input.getAttribute('list')).toBeNull();
151
+ openList(input);
152
+ expect(offeredNow()).toEqual(['bank']);
153
+ const popup = document.querySelector('[role="option"]')?.closest('.bg-popover');
154
+ expect(popup, 'the list does not carry the popover tokens').not.toBeNull();
94
155
  });
95
156
 
96
157
  test('🔴 suppression is case-insensitive — "Bank" committed hides the suggestion "bank"', () => {
@@ -100,16 +161,70 @@ describe('suggestions', () => {
100
161
  const { container } = render(
101
162
  <TagsInput value={['Bank']} onChange={() => undefined} suggestions={['bank', 'tax']} />,
102
163
  );
103
- const offered = [...container.querySelectorAll('datalist option')].map((o) =>
104
- o.getAttribute('value'),
164
+ openList(container.querySelector('input') as HTMLInputElement);
165
+ expect(offeredNow()).toEqual(['tax']);
166
+ });
167
+
168
+ test('narrows to what is typed, in any case', () => {
169
+ const { container } = render(
170
+ <TagsInput value={[]} onChange={() => undefined} suggestions={['Banking', 'tax', 'bank']} />,
105
171
  );
106
- expect(offered).toEqual(['tax']);
172
+ const input = container.querySelector('input') as HTMLInputElement;
173
+ fireEvent.change(input, { target: { value: 'BAN' } });
174
+ openList(input);
175
+ expect(offeredNow()).toEqual(['Banking', 'bank']);
176
+ });
177
+
178
+ test('picking a suggestion commits it as a tag — not the half-typed draft', () => {
179
+ function WithSuggestions() {
180
+ const [value, setValue] = useState<string[]>([]);
181
+ return (
182
+ <div>
183
+ <TagsInput value={value} onChange={setValue} suggestions={['banking', 'tax']} />
184
+ <output data-json>{JSON.stringify(value)}</output>
185
+ </div>
186
+ );
187
+ }
188
+ const { container } = render(<WithSuggestions />);
189
+ const input = container.querySelector('input') as HTMLInputElement;
190
+ fireEvent.change(input, { target: { value: 'ba' } });
191
+ openList(input);
192
+ act(() => {
193
+ fireEvent.click(document.querySelector('[role="option"]') as HTMLElement);
194
+ });
195
+ expect(json(container)).toEqual(['banking']);
196
+ expect(input.value).toBe('');
197
+ });
198
+
199
+ test('Enter on a highlighted suggestion commits the suggestion', () => {
200
+ function WithSuggestions() {
201
+ const [value, setValue] = useState<string[]>([]);
202
+ return (
203
+ <div>
204
+ <TagsInput value={value} onChange={setValue} suggestions={['banking', 'tax']} />
205
+ <output data-json>{JSON.stringify(value)}</output>
206
+ </div>
207
+ );
208
+ }
209
+ const { container } = render(<WithSuggestions />);
210
+ const input = container.querySelector('input') as HTMLInputElement;
211
+ fireEvent.change(input, { target: { value: 'ba' } });
212
+ openList(input);
213
+ act(() => {
214
+ fireEvent.keyDown(input, { key: 'ArrowDown' });
215
+ });
216
+ act(() => {
217
+ fireEvent.keyDown(input, { key: 'Enter' });
218
+ });
219
+ expect(json(container)).toEqual(['banking']);
107
220
  });
108
221
 
109
- test('no suggestions → no datalist and no dangling list attribute', () => {
222
+ test('no suggestions → no list and no dangling list attribute', () => {
110
223
  const { container } = render(<TagsInput value={[]} onChange={() => undefined} />);
111
- expect(container.querySelector('datalist')).toBeNull();
224
+ expect(document.querySelector('datalist')).toBeNull();
112
225
  const input = container.querySelector('input') as HTMLInputElement;
113
226
  expect(input.getAttribute('list')).toBeNull();
227
+ openList(input);
228
+ expect(offeredNow()).toEqual([]);
114
229
  });
115
230
  });