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.
- package/dist/react/activity-bar/activityBar.css +5 -0
- package/dist/react/components/Autocomplete.d.ts +11 -2
- package/dist/react/components/Autocomplete.d.ts.map +1 -1
- package/dist/react/components/Autocomplete.js +7 -6
- package/dist/react/components/Autocomplete.js.map +1 -1
- package/dist/react/components/FileDropZone.d.ts.map +1 -1
- package/dist/react/components/FileDropZone.js +12 -76
- package/dist/react/components/FileDropZone.js.map +1 -1
- package/dist/react/components/TagsInput.d.ts +46 -4
- package/dist/react/components/TagsInput.d.ts.map +1 -1
- package/dist/react/components/TagsInput.js +92 -39
- package/dist/react/components/TagsInput.js.map +1 -1
- package/dist/react/components/TagsInputSuggesting.d.ts +8 -0
- package/dist/react/components/TagsInputSuggesting.d.ts.map +1 -0
- package/dist/react/components/TagsInputSuggesting.js +48 -0
- package/dist/react/components/TagsInputSuggesting.js.map +1 -0
- package/dist/react/components/dropTransfer.d.ts +53 -0
- package/dist/react/components/dropTransfer.d.ts.map +1 -0
- package/dist/react/components/dropTransfer.js +116 -0
- package/dist/react/components/dropTransfer.js.map +1 -0
- package/dist/react/components/suggestionList.d.ts +13 -0
- package/dist/react/components/suggestionList.d.ts.map +1 -0
- package/dist/react/components/suggestionList.js +19 -0
- package/dist/react/components/suggestionList.js.map +1 -0
- 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/react/media-gallery/MediaMetaEditor.d.ts +4 -4
- package/dist/react/optimistic/optimistic.d.ts +11 -2
- package/dist/react/optimistic/optimistic.d.ts.map +1 -1
- package/dist/react/optimistic/optimistic.js +4 -2
- package/dist/react/optimistic/optimistic.js.map +1 -1
- package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
- package/dist/styles-areas/analytics.css +14 -19
- package/dist/styles-areas/auth.css +3 -4
- package/dist/styles-areas/canvas-hud.css +3 -3
- package/dist/styles-areas/chat.css +10 -11
- package/dist/styles-areas/code-editor.css +2 -2
- package/dist/styles-areas/core.css +60 -54
- package/dist/styles-areas/data-table.css +13 -17
- package/dist/styles-areas/disk-usage.css +6 -7
- package/dist/styles-areas/emoji.css +2 -3
- package/dist/styles-areas/fields.css +15 -20
- package/dist/styles-areas/master-detail.css +3 -4
- package/dist/styles-areas/master-lock.css +1 -2
- package/dist/styles-areas/media-gallery.css +8 -10
- package/dist/styles-areas/rich-text.css +3 -3
- package/dist/styles-areas/stats.css +13 -17
- package/dist/styles-areas/wizard.css +6 -11
- package/package.json +7 -1
- package/scripts/guardrails-baseline.json +1 -1
- package/scripts/guardrailsEnforce.spec.ts +13 -0
- package/scripts/guardrailsEnforce.ts +5 -3
- package/scripts/styleAreas.ts +3 -0
- package/src/react/activity-bar/activityBar.css +5 -0
- package/src/react/components/Autocomplete.spec.tsx +55 -1
- package/src/react/components/Autocomplete.tsx +18 -43
- package/src/react/components/FileDropZone.tsx +12 -83
- package/src/react/components/TagsInput.spec.tsx +129 -14
- package/src/react/components/TagsInput.tsx +153 -85
- package/src/react/components/TagsInputSuggesting.tsx +94 -0
- package/src/react/components/dropTransfer.spec.ts +76 -0
- package/src/react/components/dropTransfer.ts +133 -0
- package/src/react/components/suggestionList.tsx +66 -0
- package/src/react/disk-usage/DiskUsage.spec.tsx +3 -0
- package/src/react/index.ts +1 -0
- package/src/react/lib/floatingStyles.spec.ts +2 -1
- package/src/react/media-gallery/MediaMetaEditor.tsx +4 -4
- package/src/react/optimistic/optimistic.spec.tsx +41 -0
- package/src/react/optimistic/optimistic.ts +13 -4
- package/src/styles-areas/analytics.css +14 -19
- package/src/styles-areas/auth.css +3 -4
- package/src/styles-areas/canvas-hud.css +3 -3
- package/src/styles-areas/chat.css +10 -11
- package/src/styles-areas/code-editor.css +2 -2
- package/src/styles-areas/core.css +60 -54
- package/src/styles-areas/data-table.css +13 -17
- package/src/styles-areas/disk-usage.css +6 -7
- package/src/styles-areas/emoji.css +2 -3
- package/src/styles-areas/fields.css +15 -20
- package/src/styles-areas/master-detail.css +3 -4
- package/src/styles-areas/master-lock.css +1 -2
- package/src/styles-areas/media-gallery.css +8 -10
- package/src/styles-areas/rich-text.css +3 -3
- package/src/styles-areas/stats.css +13 -17
- package/src/styles-areas/wizard.css +6 -11
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { X } from 'lucide-react';
|
|
2
|
-
import { type KeyboardEvent,
|
|
2
|
+
import { type ComponentType, type KeyboardEvent, useEffect, useRef, useState } from 'react';
|
|
3
3
|
import { cn } from '../lib/cn.js';
|
|
4
4
|
import { CONTROL_MIN_HEIGHT_CLASS, type ControlSize } from '../lib/field.js';
|
|
5
5
|
import { IconButton } from './IconButton.js';
|
|
@@ -16,9 +16,14 @@ export interface TagsInputProps {
|
|
|
16
16
|
maxTags?: number;
|
|
17
17
|
/**
|
|
18
18
|
* Tags to offer as you type — the vocabulary already in use, so a re-tag matches
|
|
19
|
-
* the first spelling instead of minting "Bank"/"bank"/"banking" three ways.
|
|
20
|
-
*
|
|
21
|
-
* tags already committed on this value are not re-offered. Omit for none
|
|
19
|
+
* the first spelling instead of minting "Bank"/"bank"/"banking" three ways. Drawn
|
|
20
|
+
* in the page by the kit's own list (Base UI Autocomplete), narrowed to what is
|
|
21
|
+
* typed; tags already committed on this value are not re-offered. Omit for none —
|
|
22
|
+
* and a field that omits it never loads that list's engine (see {@link TagsInput}).
|
|
23
|
+
*
|
|
24
|
+
* 🔴 Never a `<datalist>`: the browser draws its list outside the page, where no
|
|
25
|
+
* stylesheet reaches it — it popped up light in dark mode (owner, 2026-10-23,
|
|
26
|
+
* forge task 3602).
|
|
22
27
|
*/
|
|
23
28
|
suggestions?: readonly string[];
|
|
24
29
|
id?: string;
|
|
@@ -45,14 +50,142 @@ function mergeTags(current: string[], additions: string[]): string[] {
|
|
|
45
50
|
return next;
|
|
46
51
|
}
|
|
47
52
|
|
|
53
|
+
/** What the suggesting field takes beyond the public props: the draft lives in {@link TagsInput},
|
|
54
|
+
* so a word typed before the list's engine arrived is still there after it does. */
|
|
55
|
+
export interface TagsFieldProps extends TagsInputProps {
|
|
56
|
+
draft: string;
|
|
57
|
+
setDraft: (draft: string) => void;
|
|
58
|
+
/** Focus the input on mount — the plain field had it when the engine arrived. */
|
|
59
|
+
initialFocus?: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The editing both fields share: commit, remove, the comma/Enter/Backspace keys, a pasted list. */
|
|
63
|
+
export function useTagEditing({ value, onChange, draft, setDraft }: Pick<TagsFieldProps, 'value' | 'onChange' | 'draft' | 'setDraft'>) {
|
|
64
|
+
const commit = (raw: string) => {
|
|
65
|
+
const next = mergeTags(value, [raw]);
|
|
66
|
+
if (next.length !== value.length) onChange(next);
|
|
67
|
+
setDraft('');
|
|
68
|
+
};
|
|
69
|
+
const removeAt = (index: number) => onChange(value.filter((_, i) => i !== index));
|
|
70
|
+
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
|
71
|
+
if (e.key === 'Enter' || e.key === ',') {
|
|
72
|
+
e.preventDefault();
|
|
73
|
+
if (draft.trim()) commit(draft);
|
|
74
|
+
} else if (e.key === 'Backspace' && draft === '' && value.length > 0) {
|
|
75
|
+
removeAt(value.length - 1);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
/** A typed or pasted value: every comma commits what is before it, the rest stays the draft. */
|
|
79
|
+
const type = (v: string) => {
|
|
80
|
+
if (!v.includes(',')) return setDraft(v);
|
|
81
|
+
const parts = v.split(',');
|
|
82
|
+
const last = parts.pop() ?? '';
|
|
83
|
+
const merged = mergeTags(value, parts);
|
|
84
|
+
if (merged.length !== value.length) onChange(merged);
|
|
85
|
+
setDraft(last);
|
|
86
|
+
};
|
|
87
|
+
return { commit, removeAt, onKeyDown, type };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The chip row's frame — an Input-shaped container reading the field tokens. */
|
|
91
|
+
export function tagsRowClass(size: ControlSize, disabled: boolean | undefined, className: string | undefined) {
|
|
92
|
+
return cn(
|
|
93
|
+
// `py-1`, not `py-1.5`: the empty row is a 20px line + 8px + 2px border, under the min-height, so it
|
|
94
|
+
// sits at exactly the control height beside an Input; `py-1.5` made it 38px next to a 36px one (FND-4).
|
|
95
|
+
// guardrails-ignore control-height-tokens: min-height chip-wrapping row (not fixed) — py- clears wrapped lines
|
|
96
|
+
'flex w-full flex-wrap items-center gap-1.5 rounded-field border border-field-border bg-field-bg px-2 py-1 transition focus-within:border-field-ring focus-within:ring-2 focus-within:ring-field-ring/30 aria-[invalid=true]:border-field-error',
|
|
97
|
+
CONTROL_MIN_HEIGHT_CLASS[size],
|
|
98
|
+
disabled && 'cursor-not-allowed opacity-50',
|
|
99
|
+
className,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export const TAGS_INPUT_CLASS =
|
|
104
|
+
'min-w-24 flex-1 bg-transparent py-0.5 text-base text-field-fg outline-none placeholder:text-field-placeholder sm:pointer-fine:text-sm';
|
|
105
|
+
|
|
106
|
+
/** The committed tags as removable chips. */
|
|
107
|
+
// no-attribute-forward: a list of chips with no element of its own — the row it sits in takes the caller's attributes
|
|
108
|
+
export function TagChips({ value, disabled, onRemove }: { value: string[]; disabled?: boolean | undefined; onRemove: (index: number) => void }) {
|
|
109
|
+
return value.map((tag, i) => (
|
|
110
|
+
<span key={tag} className='inline-flex items-center gap-1 rounded bg-muted py-0.5 pl-2 pr-1 text-sm text-foreground'>
|
|
111
|
+
{tag}
|
|
112
|
+
<IconButton
|
|
113
|
+
label={`Remove ${tag}`}
|
|
114
|
+
disabled={disabled}
|
|
115
|
+
onClick={() => onRemove(i)}
|
|
116
|
+
className='h-auto min-h-0 w-auto size-4 rounded-sm p-0 text-muted-foreground hover:bg-border hover:text-foreground [&_svg]:size-3'
|
|
117
|
+
>
|
|
118
|
+
<X aria-hidden />
|
|
119
|
+
</IconButton>
|
|
120
|
+
</span>
|
|
121
|
+
));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
type SuggestingField = ComponentType<TagsFieldProps>;
|
|
125
|
+
|
|
126
|
+
let loadedSuggesting: SuggestingField | null = null;
|
|
127
|
+
let pendingSuggesting: Promise<SuggestingField> | null = null;
|
|
128
|
+
|
|
129
|
+
/** Fetch the suggesting field (Base UI Autocomplete) once per page. */
|
|
130
|
+
export function preloadSuggestingTagsInput(): Promise<SuggestingField> {
|
|
131
|
+
if (!pendingSuggesting) {
|
|
132
|
+
pendingSuggesting = import('./TagsInputSuggesting.js').then((m) => {
|
|
133
|
+
loadedSuggesting = m.default;
|
|
134
|
+
return m.default;
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return pendingSuggesting;
|
|
138
|
+
}
|
|
139
|
+
|
|
48
140
|
/**
|
|
49
141
|
* Comma / Enter-delimited tag input: pill chips inside an Input-shaped container that
|
|
50
142
|
* reads the same field-style tokens (border / ring / invalid). Controlled —
|
|
51
143
|
* `value: string[]`, `onChange(next)`. Type + Enter or comma to add (paste
|
|
52
144
|
* "a,b,c" splits into three); the ✕ on a chip or Backspace on an empty input removes
|
|
53
145
|
* one. Replaces the ad-hoc CommaTagsInput. The registry's `tags` control.
|
|
146
|
+
*
|
|
147
|
+
* ── Why the suggesting field is a dynamic `import()` ─────────────────────────
|
|
148
|
+
* 🔴 Suggestions are drawn by Base UI's Autocomplete, ~38 kB gzip. 5.92.0 rooted EVERY TagsInput
|
|
149
|
+
* in it, so a field that suggests nothing — station's disk-usage rules, every form's `tags`
|
|
150
|
+
* control — carried it: station's FolderSizesView chunk went 7.15 → 44.32 kB gzip (forge task
|
|
151
|
+
* 3614). A field given `suggestions` fetches it on mount (the same move as `InfoHint`); until it
|
|
152
|
+
* arrives — and for good, without `suggestions` — this file's plain input is the field, holding
|
|
153
|
+
* the draft and the focus it hands over.
|
|
54
154
|
*/
|
|
55
|
-
export function TagsInput({
|
|
155
|
+
export function TagsInput(props: TagsInputProps) {
|
|
156
|
+
const wantsList = props.suggestions !== undefined;
|
|
157
|
+
// `() => loadedSuggesting`: a component IS a function, and `useState(fn)` would call it.
|
|
158
|
+
const [Suggesting, setSuggesting] = useState<SuggestingField | null>(() => (wantsList ? loadedSuggesting : null));
|
|
159
|
+
const [draft, setDraft] = useState('');
|
|
160
|
+
const focused = useRef(false);
|
|
161
|
+
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
if (!wantsList || Suggesting) return;
|
|
164
|
+
let alive = true;
|
|
165
|
+
void preloadSuggestingTagsInput().then((c) => {
|
|
166
|
+
if (alive) setSuggesting(() => c);
|
|
167
|
+
});
|
|
168
|
+
return () => {
|
|
169
|
+
alive = false;
|
|
170
|
+
};
|
|
171
|
+
}, [wantsList, Suggesting]);
|
|
172
|
+
|
|
173
|
+
if (wantsList && Suggesting) {
|
|
174
|
+
return <Suggesting {...props} draft={draft} setDraft={setDraft} initialFocus={focused.current} />;
|
|
175
|
+
}
|
|
176
|
+
return (
|
|
177
|
+
<PlainTagsField
|
|
178
|
+
{...props}
|
|
179
|
+
draft={draft}
|
|
180
|
+
setDraft={setDraft}
|
|
181
|
+
onFocusChange={(on) => {
|
|
182
|
+
focused.current = on;
|
|
183
|
+
}}
|
|
184
|
+
/>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function PlainTagsField({
|
|
56
189
|
value,
|
|
57
190
|
onChange,
|
|
58
191
|
onBlur,
|
|
@@ -61,112 +194,47 @@ export function TagsInput({
|
|
|
61
194
|
maxTags,
|
|
62
195
|
id,
|
|
63
196
|
name,
|
|
64
|
-
suggestions,
|
|
197
|
+
suggestions: _suggestions,
|
|
65
198
|
'aria-invalid': ariaInvalid,
|
|
66
199
|
'aria-label': ariaLabel,
|
|
67
200
|
'aria-describedby': ariaDescribedBy,
|
|
68
201
|
className,
|
|
69
202
|
size = 'md',
|
|
203
|
+
draft,
|
|
204
|
+
setDraft,
|
|
205
|
+
onFocusChange,
|
|
70
206
|
...rest
|
|
71
|
-
}:
|
|
72
|
-
const
|
|
73
|
-
const listId = useId();
|
|
207
|
+
}: TagsFieldProps & { onFocusChange: (focused: boolean) => void }) {
|
|
208
|
+
const { commit, removeAt, onKeyDown, type } = useTagEditing({ value, onChange, draft, setDraft });
|
|
74
209
|
const atMax = maxTags != null && value.length >= maxTags;
|
|
75
|
-
// Case-insensitive: a committed "Bank" must suppress the suggestion "bank" —
|
|
76
|
-
// offering it back commits nothing (mergeTags dedupes case-sensitively, so it
|
|
77
|
-
// WOULD land as a second chip) and reads as the field suggesting a duplicate.
|
|
78
|
-
const committed = new Set(value.map((tag) => tag.toLowerCase()));
|
|
79
|
-
const offered = (suggestions ?? []).filter((tag) => !committed.has(tag.toLowerCase()));
|
|
80
|
-
|
|
81
|
-
const commit = (raw: string) => {
|
|
82
|
-
const next = mergeTags(value, [raw]);
|
|
83
|
-
if (next.length !== value.length) onChange(next);
|
|
84
|
-
setDraft('');
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
const removeAt = (index: number) => onChange(value.filter((_, i) => i !== index));
|
|
88
|
-
|
|
89
|
-
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
|
90
|
-
if (e.key === 'Enter' || e.key === ',') {
|
|
91
|
-
e.preventDefault();
|
|
92
|
-
if (draft.trim()) commit(draft);
|
|
93
|
-
} else if (e.key === 'Backspace' && draft === '' && value.length > 0) {
|
|
94
|
-
removeAt(value.length - 1);
|
|
95
|
-
}
|
|
96
|
-
};
|
|
97
|
-
|
|
98
210
|
return (
|
|
99
|
-
<div
|
|
100
|
-
{
|
|
101
|
-
aria-invalid={ariaInvalid}
|
|
102
|
-
className={cn(
|
|
103
|
-
// `py-1`, not `py-1.5`: the empty row is a 20px line + 8px + 2px border, under the min-height, so it
|
|
104
|
-
// sits at exactly the control height beside an Input; `py-1.5` made it 38px next to a 36px one (FND-4).
|
|
105
|
-
// guardrails-ignore control-height-tokens: min-height chip-wrapping row (not fixed) — py- clears wrapped lines
|
|
106
|
-
'flex w-full flex-wrap items-center gap-1.5 rounded-field border border-field-border bg-field-bg px-2 py-1 transition focus-within:border-field-ring focus-within:ring-2 focus-within:ring-field-ring/30 aria-[invalid=true]:border-field-error',
|
|
107
|
-
CONTROL_MIN_HEIGHT_CLASS[size],
|
|
108
|
-
disabled && 'cursor-not-allowed opacity-50',
|
|
109
|
-
className,
|
|
110
|
-
)}
|
|
111
|
-
>
|
|
112
|
-
{value.map((tag, i) => (
|
|
113
|
-
<span
|
|
114
|
-
key={tag}
|
|
115
|
-
className='inline-flex items-center gap-1 rounded bg-muted py-0.5 pl-2 pr-1 text-sm text-foreground'
|
|
116
|
-
>
|
|
117
|
-
{tag}
|
|
118
|
-
<IconButton
|
|
119
|
-
label={`Remove ${tag}`}
|
|
120
|
-
disabled={disabled}
|
|
121
|
-
onClick={() => removeAt(i)}
|
|
122
|
-
className='h-auto min-h-0 w-auto size-4 rounded-sm p-0 text-muted-foreground hover:bg-border hover:text-foreground [&_svg]:size-3'
|
|
123
|
-
>
|
|
124
|
-
<X aria-hidden />
|
|
125
|
-
</IconButton>
|
|
126
|
-
</span>
|
|
127
|
-
))}
|
|
211
|
+
<div {...rest} aria-invalid={ariaInvalid} className={tagsRowClass(size, disabled, className)}>
|
|
212
|
+
<TagChips value={value} disabled={disabled} onRemove={removeAt} />
|
|
128
213
|
{!atMax ? (
|
|
129
214
|
<input
|
|
215
|
+
// guardrails-ignore no-raw-inputs: perf — the chip row is the field's frame, so its draft
|
|
216
|
+
// input is borderless; the Field-layer Input draws a second frame, and Base UI's (38 kB)
|
|
217
|
+
// is what this path exists to skip (forge task 3614).
|
|
130
218
|
id={id}
|
|
131
219
|
name={name}
|
|
132
|
-
// guardrails-ignore no-raw-inputs: this IS the TagsInput Field-family
|
|
133
|
-
// primitive — wraps the raw draft-tag text input it exposes.
|
|
134
|
-
type='text'
|
|
135
220
|
value={draft}
|
|
221
|
+
onChange={(e) => type(e.target.value)}
|
|
136
222
|
disabled={disabled}
|
|
137
223
|
placeholder={value.length === 0 ? placeholder : undefined}
|
|
138
224
|
aria-invalid={ariaInvalid}
|
|
139
225
|
aria-label={ariaLabel}
|
|
140
226
|
aria-describedby={ariaDescribedBy}
|
|
141
227
|
autoComplete='off'
|
|
142
|
-
list={offered.length > 0 ? listId : undefined}
|
|
143
|
-
onChange={(e) => {
|
|
144
|
-
const v = e.target.value;
|
|
145
|
-
if (v.includes(',')) {
|
|
146
|
-
const parts = v.split(',');
|
|
147
|
-
const last = parts.pop() ?? '';
|
|
148
|
-
const merged = mergeTags(value, parts);
|
|
149
|
-
if (merged.length !== value.length) onChange(merged);
|
|
150
|
-
setDraft(last);
|
|
151
|
-
} else {
|
|
152
|
-
setDraft(v);
|
|
153
|
-
}
|
|
154
|
-
}}
|
|
155
228
|
onKeyDown={onKeyDown}
|
|
229
|
+
onFocus={() => onFocusChange(true)}
|
|
156
230
|
onBlur={() => {
|
|
231
|
+
onFocusChange(false);
|
|
157
232
|
if (draft.trim()) commit(draft);
|
|
158
233
|
onBlur?.();
|
|
159
234
|
}}
|
|
160
|
-
className=
|
|
235
|
+
className={TAGS_INPUT_CLASS}
|
|
161
236
|
/>
|
|
162
237
|
) : null}
|
|
163
|
-
{offered.length > 0 ? (
|
|
164
|
-
<datalist id={listId}>
|
|
165
|
-
{offered.map((tag) => (
|
|
166
|
-
<option key={tag} value={tag} />
|
|
167
|
-
))}
|
|
168
|
-
</datalist>
|
|
169
|
-
) : null}
|
|
170
238
|
</div>
|
|
171
239
|
);
|
|
172
240
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { Autocomplete } from '@base-ui/react/autocomplete';
|
|
2
|
+
import { useLayoutEffect, useRef } from 'react';
|
|
3
|
+
import { SuggestionList } from './suggestionList.js';
|
|
4
|
+
import { TAGS_INPUT_CLASS, TagChips, type TagsFieldProps, tagsRowClass, useTagEditing } from './TagsInput.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A `TagsInput` that suggests — the chip row as an Autocomplete input group, its list the kit's
|
|
8
|
+
* `SuggestionList`. Loaded by `TagsInput.tsx` with a dynamic `import()` only when a field is given
|
|
9
|
+
* `suggestions`; that file says why.
|
|
10
|
+
*/
|
|
11
|
+
export default function TagsInputSuggesting({
|
|
12
|
+
value,
|
|
13
|
+
onChange,
|
|
14
|
+
onBlur,
|
|
15
|
+
placeholder = 'Add a tag…',
|
|
16
|
+
disabled,
|
|
17
|
+
maxTags,
|
|
18
|
+
id,
|
|
19
|
+
name,
|
|
20
|
+
suggestions,
|
|
21
|
+
'aria-invalid': ariaInvalid,
|
|
22
|
+
'aria-label': ariaLabel,
|
|
23
|
+
'aria-describedby': ariaDescribedBy,
|
|
24
|
+
className,
|
|
25
|
+
size = 'md',
|
|
26
|
+
draft,
|
|
27
|
+
setDraft,
|
|
28
|
+
initialFocus,
|
|
29
|
+
...rest
|
|
30
|
+
}: TagsFieldProps) {
|
|
31
|
+
const { commit, removeAt, onKeyDown, type } = useTagEditing({ value, onChange, draft, setDraft });
|
|
32
|
+
// Which suggestion the keyboard or pointer is on: Enter then picks IT, not the half-typed draft.
|
|
33
|
+
const highlighted = useRef<string | undefined>(undefined);
|
|
34
|
+
const input = useRef<HTMLInputElement>(null);
|
|
35
|
+
const atMax = maxTags != null && value.length >= maxTags;
|
|
36
|
+
// Case-insensitive: a committed "Bank" must suppress the suggestion "bank" —
|
|
37
|
+
// offering it back commits nothing (mergeTags dedupes case-sensitively, so it
|
|
38
|
+
// WOULD land as a second chip) and reads as the field suggesting a duplicate.
|
|
39
|
+
const committed = new Set(value.map((tag) => tag.toLowerCase()));
|
|
40
|
+
const offered = (suggestions ?? []).filter((tag) => !committed.has(tag.toLowerCase()));
|
|
41
|
+
|
|
42
|
+
// Mount-only: the plain field had the focus when this arrived, so the caret stays in the field.
|
|
43
|
+
// 🔴 A LAYOUT effect: it runs inside the commit that swaps the inputs. A passive one ran a task
|
|
44
|
+
// later, and a keystroke in between landed on <body> — the publish gate caught that gap under load.
|
|
45
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: a one-time hand-over, not a sync.
|
|
46
|
+
useLayoutEffect(() => {
|
|
47
|
+
if (initialFocus) input.current?.focus();
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<Autocomplete.Root
|
|
52
|
+
value={draft}
|
|
53
|
+
onValueChange={(v, details) => {
|
|
54
|
+
// Picking a suggestion (a press, or Enter on the highlighted one) commits it as a tag.
|
|
55
|
+
if (details.reason === 'item-press') commit(v);
|
|
56
|
+
else type(v);
|
|
57
|
+
}}
|
|
58
|
+
items={offered}
|
|
59
|
+
disabled={disabled}
|
|
60
|
+
mode='list'
|
|
61
|
+
autoHighlight={false}
|
|
62
|
+
onItemHighlighted={(item) => {
|
|
63
|
+
highlighted.current = item;
|
|
64
|
+
}}
|
|
65
|
+
>
|
|
66
|
+
{/* The whole chip row is the input group, so the list opens under the field, not the caret. */}
|
|
67
|
+
<Autocomplete.InputGroup {...rest} aria-invalid={ariaInvalid} className={tagsRowClass(size, disabled, className)}>
|
|
68
|
+
<TagChips value={value} disabled={disabled} onRemove={removeAt} />
|
|
69
|
+
{!atMax ? (
|
|
70
|
+
<Autocomplete.Input
|
|
71
|
+
ref={input}
|
|
72
|
+
id={id}
|
|
73
|
+
name={name}
|
|
74
|
+
placeholder={value.length === 0 ? placeholder : undefined}
|
|
75
|
+
aria-invalid={ariaInvalid}
|
|
76
|
+
aria-label={ariaLabel}
|
|
77
|
+
aria-describedby={ariaDescribedBy}
|
|
78
|
+
autoComplete='off'
|
|
79
|
+
onKeyDown={(e) => {
|
|
80
|
+
if (e.key === 'Enter' && highlighted.current !== undefined) return; // the list commits the pick
|
|
81
|
+
onKeyDown(e);
|
|
82
|
+
}}
|
|
83
|
+
onBlur={() => {
|
|
84
|
+
if (draft.trim()) commit(draft);
|
|
85
|
+
onBlur?.();
|
|
86
|
+
}}
|
|
87
|
+
className={TAGS_INPUT_CLASS}
|
|
88
|
+
/>
|
|
89
|
+
) : null}
|
|
90
|
+
</Autocomplete.InputGroup>
|
|
91
|
+
{offered.length > 0 ? <SuggestionList<string> /> : null}
|
|
92
|
+
</Autocomplete.Root>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { filesFromDataTransfer } from './dropTransfer.js';
|
|
3
|
+
|
|
4
|
+
type Entry = {
|
|
5
|
+
isFile: boolean;
|
|
6
|
+
isDirectory: boolean;
|
|
7
|
+
name: string;
|
|
8
|
+
file?: (ok: (f: File) => void) => void;
|
|
9
|
+
createReader?: () => { readEntries: (ok: (e: Entry[]) => void) => void };
|
|
10
|
+
};
|
|
11
|
+
const file = (name: string, type: string) => new File(['x'], name, { type });
|
|
12
|
+
const fileEntry = (f: File): Entry => ({ isFile: true, isDirectory: false, name: f.name, file: (ok) => ok(f) });
|
|
13
|
+
/** A directory whose reader answers in batches of two, then empty — as browsers batch. */
|
|
14
|
+
const dirEntry = (name: string, children: Entry[]): Entry => ({
|
|
15
|
+
isFile: false,
|
|
16
|
+
isDirectory: true,
|
|
17
|
+
name,
|
|
18
|
+
createReader: () => {
|
|
19
|
+
let at = 0;
|
|
20
|
+
return {
|
|
21
|
+
readEntries: (ok) => {
|
|
22
|
+
const batch = children.slice(at, at + 2);
|
|
23
|
+
at += 2;
|
|
24
|
+
ok(batch);
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
/** A DataTransfer whose item list empties once read past the first turn, as a browser's does. */
|
|
30
|
+
const transfer = (entries: Entry[], files: File[]) => {
|
|
31
|
+
let live = true;
|
|
32
|
+
queueMicrotask(() => (live = false));
|
|
33
|
+
return {
|
|
34
|
+
files,
|
|
35
|
+
get items() {
|
|
36
|
+
return live ? entries.map((entry) => ({ kind: 'file', webkitGetAsEntry: () => entry })) : [];
|
|
37
|
+
},
|
|
38
|
+
} as unknown as DataTransfer;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
describe('filesFromDataTransfer', () => {
|
|
42
|
+
test('walks a dropped folder: nested files, each with its path in the drop; hidden and turned-away skipped', async () => {
|
|
43
|
+
const album = dirEntry('Holiday', [
|
|
44
|
+
fileEntry(file('IMG_0001.JPG', 'image/jpeg')),
|
|
45
|
+
fileEntry(file('.DS_Store', '')),
|
|
46
|
+
fileEntry(file('notes.txt', 'text/plain')),
|
|
47
|
+
dirEntry('Day 2', [fileEntry(file('IMG_0002.JPG', 'image/jpeg')), fileEntry(file('IMG_0003.JPG', 'image/jpeg'))]),
|
|
48
|
+
dirEntry('.thumbnails', [fileEntry(file('t.jpg', 'image/jpeg'))]),
|
|
49
|
+
]);
|
|
50
|
+
const loose = file('portrait.png', 'image/png');
|
|
51
|
+
const got = await filesFromDataTransfer(
|
|
52
|
+
transfer([fileEntry(loose), album], [loose, file('Holiday', '')]),
|
|
53
|
+
'image/*',
|
|
54
|
+
);
|
|
55
|
+
expect(got.map((f) => f.webkitRelativePath || f.name)).toEqual([
|
|
56
|
+
'portrait.png',
|
|
57
|
+
'Holiday/IMG_0001.JPG',
|
|
58
|
+
'Holiday/Day 2/IMG_0002.JPG',
|
|
59
|
+
'Holiday/Day 2/IMG_0003.JPG',
|
|
60
|
+
]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('a drop with no folder is its files, filtered — no walk', async () => {
|
|
64
|
+
const got = await filesFromDataTransfer(
|
|
65
|
+
transfer([], [file('a.png', 'image/png'), file('b.txt', 'text/plain'), file('.hidden.png', 'image/png')]),
|
|
66
|
+
'image/*',
|
|
67
|
+
);
|
|
68
|
+
expect(got.map((f) => f.name)).toEqual(['a.png']);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('stops at the limit', async () => {
|
|
72
|
+
const many = Array.from({ length: 7 }, (_, i) => fileEntry(file(`${i}.jpg`, 'image/jpeg')));
|
|
73
|
+
const got = await filesFromDataTransfer(transfer([dirEntry('Big', many)], [file('Big', '')]), undefined, { limit: 3 });
|
|
74
|
+
expect(got).toHaveLength(3);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A drop's FILES, with every dropped FOLDER walked for what is inside it — the one walk
|
|
3
|
+
* {@link FileDropZone} and every app's own drop target share (forge task 3579's follow-up).
|
|
4
|
+
*
|
|
5
|
+
* `DataTransfer.files` lists a dropped folder as one zero-byte entry and nothing in it. The File
|
|
6
|
+
* and Directory Entries API (`webkitGetAsEntry`) is the only door every browser ships, and it has
|
|
7
|
+
* three traps each copy of this walk had to rediscover:
|
|
8
|
+
*
|
|
9
|
+
* - 🔴 the entries must be taken SYNCHRONOUSLY, before the first `await` — a browser empties the
|
|
10
|
+
* `DataTransfer` once the drop event returns, and a late walk finds nothing, silently;
|
|
11
|
+
* - a directory's reader answers in batches (Chrome: 100) until it answers EMPTY — one call
|
|
12
|
+
* truncates a folder of 1,000 photos to 100;
|
|
13
|
+
* - a dropped file's `webkitRelativePath` is `''`, so each file here gets its path inside the drop
|
|
14
|
+
* (`Album/CD1/01.mp3`), exactly as a folder PICKER's files carry natively — an app keying a
|
|
15
|
+
* duplicate check or provenance on it gets one answer from both gestures.
|
|
16
|
+
*
|
|
17
|
+
* Hidden entries (`.DS_Store`, `.thumbnails`) are never what somebody meant and are skipped.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** The slice of the File and Directory Entries API a drop needs — every browser ships it. */
|
|
21
|
+
interface DropEntry {
|
|
22
|
+
isFile: boolean;
|
|
23
|
+
isDirectory: boolean;
|
|
24
|
+
name: string;
|
|
25
|
+
file?: (ok: (file: File) => void, fail: (error: unknown) => void) => void;
|
|
26
|
+
createReader?: () => {
|
|
27
|
+
readEntries: (ok: (entries: DropEntry[]) => void, fail: (error: unknown) => void) => void;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** What one drop may yield by default — a drop, not a disk scan. */
|
|
32
|
+
export const MAX_DROP_FILES = 5000;
|
|
33
|
+
|
|
34
|
+
export const isHiddenName = (name: string): boolean => name.startsWith('.');
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Whether `file` passes an `<input accept>` filter — `.mp3`, `image/*`, `application/zip`, comma
|
|
38
|
+
* separated. A drop bypasses the native `accept`, so a drop target re-filters with this.
|
|
39
|
+
*/
|
|
40
|
+
export const matchesAccept = (file: File, accept?: string): boolean => {
|
|
41
|
+
if (!accept) return true;
|
|
42
|
+
return accept.split(',').some((raw) => {
|
|
43
|
+
const rule = raw.trim().toLowerCase();
|
|
44
|
+
if (!rule) return false;
|
|
45
|
+
if (rule.startsWith('.')) return file.name.toLowerCase().endsWith(rule);
|
|
46
|
+
if (rule.endsWith('/*')) return file.type.toLowerCase().startsWith(rule.slice(0, -1));
|
|
47
|
+
return file.type.toLowerCase() === rule;
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Every file under `entry`, depth-first, until `out` holds `limit`. */
|
|
52
|
+
async function filesUnder(entry: DropEntry, out: File[], limit: number, prefix = ''): Promise<void> {
|
|
53
|
+
if (out.length >= limit || isHiddenName(entry.name)) return;
|
|
54
|
+
if (entry.isFile && entry.file) {
|
|
55
|
+
const read = entry.file.bind(entry);
|
|
56
|
+
const file = await new Promise<File>((ok, fail) => read(ok, fail));
|
|
57
|
+
Object.defineProperty(file, 'webkitRelativePath', { value: `${prefix}${file.name}`, configurable: true });
|
|
58
|
+
out.push(file);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (!entry.isDirectory || !entry.createReader) return;
|
|
62
|
+
const reader = entry.createReader();
|
|
63
|
+
while (out.length < limit) {
|
|
64
|
+
const batch = await new Promise<DropEntry[]>((ok, fail) => reader.readEntries(ok, fail));
|
|
65
|
+
if (batch.length === 0) return;
|
|
66
|
+
for (const child of batch) await filesUnder(child, out, limit, `${prefix}${entry.name}/`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A drop, read: the loose files as dropped, and each dropped folder with the files found inside it. */
|
|
71
|
+
export interface DropWalk {
|
|
72
|
+
loose: File[];
|
|
73
|
+
folders: { name: string; files: File[] }[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Reads a drop that holds at least one folder — `null` when it holds none, so the ordinary path
|
|
78
|
+
* stays exactly `transfer.files`. Call it from the drop handler itself, before any `await`.
|
|
79
|
+
* Each folder yields up to `limit` files; an unreadable one yields what was read before the error.
|
|
80
|
+
*/
|
|
81
|
+
export function walkDrop(transfer: DataTransfer, limit = MAX_DROP_FILES): Promise<DropWalk> | null {
|
|
82
|
+
// Synchronous, and first: the item list is tied to the event.
|
|
83
|
+
const items = transfer.items ? [...transfer.items] : [];
|
|
84
|
+
const entries = items.map((item) =>
|
|
85
|
+
item.kind === 'file' && typeof item.webkitGetAsEntry === 'function'
|
|
86
|
+
? (item.webkitGetAsEntry() as DropEntry | null)
|
|
87
|
+
: null,
|
|
88
|
+
);
|
|
89
|
+
if (!entries.some((entry) => entry?.isDirectory)) return null;
|
|
90
|
+
const looseFiles = [...transfer.files];
|
|
91
|
+
return (async () => {
|
|
92
|
+
const walk: DropWalk = { loose: [], folders: [] };
|
|
93
|
+
for (const [i, entry] of entries.entries()) {
|
|
94
|
+
if (!entry?.isDirectory) {
|
|
95
|
+
const file =
|
|
96
|
+
looseFiles[i] ?? (entry ? await new Promise<File | null>((ok) => entry.file?.(ok, () => ok(null))) : null);
|
|
97
|
+
if (file) walk.loose.push(file);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const files: File[] = [];
|
|
101
|
+
try {
|
|
102
|
+
await filesUnder(entry, files, limit);
|
|
103
|
+
} catch {
|
|
104
|
+
// Keep what was read before the error; the caller decides what an empty folder means.
|
|
105
|
+
}
|
|
106
|
+
walk.folders.push({ name: entry.name, files });
|
|
107
|
+
}
|
|
108
|
+
return walk;
|
|
109
|
+
})();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Every file in a drop, folders walked — the loose files, then each folder's — with what `accept`
|
|
114
|
+
* turns away and hidden files skipped, at most `limit` in all. For a drop target that is not a
|
|
115
|
+
* {@link FileDropZone}: a portrait, an open album, a whole page.
|
|
116
|
+
*
|
|
117
|
+
* onDrop={(event) => { event.preventDefault(); void filesFromDataTransfer(event.dataTransfer, 'image/*').then(upload); }}
|
|
118
|
+
*
|
|
119
|
+
* 🔴 Call it from the drop handler itself, before any `await` (see the file header).
|
|
120
|
+
*/
|
|
121
|
+
export function filesFromDataTransfer(
|
|
122
|
+
transfer: DataTransfer,
|
|
123
|
+
accept?: string,
|
|
124
|
+
{ limit = MAX_DROP_FILES }: { limit?: number } = {},
|
|
125
|
+
): Promise<File[]> {
|
|
126
|
+
const keep = (file: File) => !isHiddenName(file.name) && matchesAccept(file, accept);
|
|
127
|
+
const walking = walkDrop(transfer, limit);
|
|
128
|
+
if (!walking) return Promise.resolve([...transfer.files].filter(keep).slice(0, limit));
|
|
129
|
+
return walking.then(({ loose, folders }) => {
|
|
130
|
+
const all = [...loose, ...folders.flatMap((folder) => folder.files)];
|
|
131
|
+
return all.filter(keep).slice(0, limit);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Autocomplete } from '@base-ui/react/autocomplete';
|
|
4
|
+
import type { ReactNode } from 'react';
|
|
5
|
+
import { cn } from '../lib/cn.js';
|
|
6
|
+
import { FLOATING_COLLISION_PADDING,
|
|
7
|
+
FLOATING_CONTENT_BASE, FLOATING_ITEM_BASE } from '../lib/floatingStyles.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The dropdown of an Autocomplete root — every kit field that suggests as you type draws its list
|
|
11
|
+
* with this one, so the list is the kit's in every theme (`TagsInput` as well as
|
|
12
|
+
* `AutocompleteField`). Kept out of every export: it is the inside of those two, not a part an app
|
|
13
|
+
* assembles. Never a `<datalist>`: the browser draws that outside the page, where no
|
|
14
|
+
* stylesheet reaches it — light in dark mode (owner, 2026-10-23; forge task 3602).
|
|
15
|
+
*/
|
|
16
|
+
// no-attribute-forward: its root is Base UI's Portal — no element of its own; the popup is the kit's, not the caller's
|
|
17
|
+
export function SuggestionList<S>({
|
|
18
|
+
itemToString = (item) => String(item),
|
|
19
|
+
renderSuggestion,
|
|
20
|
+
}: {
|
|
21
|
+
itemToString?: (item: S) => string;
|
|
22
|
+
renderSuggestion?: ((item: S) => ReactNode) | undefined;
|
|
23
|
+
}) {
|
|
24
|
+
return (
|
|
25
|
+
<Autocomplete.Portal>
|
|
26
|
+
{/* 🔴 The z-index goes on the POSITIONER, not only the popup. Base UI's positioner is
|
|
27
|
+
the element that is `position: absolute` (with a transform); the popup inside it is
|
|
28
|
+
`position: static`, so the popup's own `z-[var(--z-popover)]` does nothing and the
|
|
29
|
+
list painted UNDER any sibling with a z-index — measured 2026-09-22 in the
|
|
30
|
+
binary-server inspector, a sticky table header (z-index 1) covering the second option
|
|
31
|
+
of an open MultiSelectField. */}
|
|
32
|
+
<Autocomplete.Positioner
|
|
33
|
+
sideOffset={4}
|
|
34
|
+
collisionPadding={FLOATING_COLLISION_PADDING}
|
|
35
|
+
className='z-[var(--z-popover)]'
|
|
36
|
+
>
|
|
37
|
+
<Autocomplete.Popup
|
|
38
|
+
className={cn(
|
|
39
|
+
FLOATING_CONTENT_BASE,
|
|
40
|
+
// guardrails-ignore no-raw-overflow: floating popover panel outside the page's ScrollArea, height-capped by max-h-64 — no keyboard-inset concern.
|
|
41
|
+
'max-h-64 min-w-[var(--anchor-width)] overflow-y-auto p-0 py-1',
|
|
42
|
+
'transition-[transform,opacity] duration-100',
|
|
43
|
+
'data-[starting-style]:scale-95 data-[starting-style]:opacity-0',
|
|
44
|
+
'data-[ending-style]:scale-95 data-[ending-style]:opacity-0',
|
|
45
|
+
)}
|
|
46
|
+
>
|
|
47
|
+
{/* 🔴 The render function, never `items.map`: Base UI hands it the FILTERED items.
|
|
48
|
+
Mapping the array drew every suggestion whatever was typed — music-stage offered
|
|
49
|
+
every folder for "zz" (forge task 3602). */}
|
|
50
|
+
<Autocomplete.List>
|
|
51
|
+
{(s: S, index: number) => (
|
|
52
|
+
<Autocomplete.Item
|
|
53
|
+
key={itemToString(s)}
|
|
54
|
+
value={s}
|
|
55
|
+
index={index}
|
|
56
|
+
className={cn(FLOATING_ITEM_BASE, 'data-[highlighted]:bg-accent/10 data-[highlighted]:text-accent-strong')}
|
|
57
|
+
>
|
|
58
|
+
{renderSuggestion ? renderSuggestion(s) : itemToString(s)}
|
|
59
|
+
</Autocomplete.Item>
|
|
60
|
+
)}
|
|
61
|
+
</Autocomplete.List>
|
|
62
|
+
</Autocomplete.Popup>
|
|
63
|
+
</Autocomplete.Positioner>
|
|
64
|
+
</Autocomplete.Portal>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -188,6 +188,9 @@ describe('DiskUsageRulesEditor', () => {
|
|
|
188
188
|
fireEvent.click(save());
|
|
189
189
|
expect(onSave).not.toHaveBeenCalled();
|
|
190
190
|
|
|
191
|
+
// The rules fields suggest nothing, so each is TagsInput's plain draft input — never the
|
|
192
|
+
// Autocomplete combobox, whose engine a field without suggestions does not load (forge task 3614).
|
|
193
|
+
expect(screen.queryAllByRole('combobox')).toEqual([]);
|
|
191
194
|
const inputs = screen.getAllByRole('textbox');
|
|
192
195
|
fireEvent.change(inputs[1], { target: { value: 'huge-archive' } });
|
|
193
196
|
fireEvent.keyDown(inputs[1], { key: 'Enter' });
|