@noya-app/noya-designsystem 0.1.94 → 0.1.96

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 (43) hide show
  1. package/.turbo/turbo-build.log +9 -9
  2. package/.turbo/turbo-typecheck.log +1 -0
  3. package/CHANGELOG.md +16 -0
  4. package/dist/index.css +1 -1
  5. package/dist/index.d.mts +158 -93
  6. package/dist/index.d.ts +158 -93
  7. package/dist/index.js +1307 -1034
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +1231 -962
  10. package/dist/index.mjs.map +1 -1
  11. package/package.json +6 -5
  12. package/src/__tests__/SharedDragProvider.test.ts +72 -29
  13. package/src/__tests__/fuzzyScorer.test.ts +49 -1
  14. package/src/components/Button.tsx +1 -1
  15. package/src/components/Combobox.tsx +23 -3
  16. package/src/components/ComboboxMenu.tsx +32 -15
  17. package/src/components/ContextMenu.tsx +26 -7
  18. package/src/components/DimensionInput.tsx +12 -2
  19. package/src/components/DropdownMenu.tsx +23 -8
  20. package/src/components/InputField.tsx +1 -0
  21. package/src/components/InspectorContainer.tsx +16 -1
  22. package/src/components/List.tsx +25 -3
  23. package/src/components/Popover.tsx +13 -1
  24. package/src/components/Section.tsx +35 -11
  25. package/src/components/SegmentedControl.tsx +14 -4
  26. package/src/components/Toolbar.tsx +16 -1
  27. package/src/components/ToolbarItems.tsx +322 -0
  28. package/src/components/Tooltip.tsx +8 -1
  29. package/src/components/__tests__/Card.test.tsx +2 -1
  30. package/src/components/__tests__/InputField.test.tsx +3 -1
  31. package/src/components/__tests__/ToolbarItems.test.tsx +205 -0
  32. package/src/components/internal/Menu.tsx +4 -1
  33. package/src/components/internal/TextInput.tsx +43 -2
  34. package/src/components/internal/__tests__/TextInput.test.tsx +79 -2
  35. package/src/components/listView/ListViewEditableRowTitle.tsx +37 -1
  36. package/src/components/listView/ListViewRow.tsx +3 -0
  37. package/src/components/listView/__tests__/ListViewEditableRowTitle.test.tsx +69 -0
  38. package/src/components/sorting/Sortable.tsx +4 -1
  39. package/src/index.tsx +0 -1
  40. package/src/utils/combobox.ts +22 -5
  41. package/src/utils/fuzzyScorer.ts +71 -0
  42. package/tailwind.config.ts +7 -0
  43. package/src/components/InspectorPrimitives.tsx +0 -188
@@ -0,0 +1,205 @@
1
+ import { afterEach, describe, expect, mock, test } from "bun:test";
2
+ import React from "react";
3
+ import type { ToolbarItem } from "../ToolbarItems";
4
+
5
+ // `@radix-ui/react-icons` has a nested React 18 copy in node_modules, which
6
+ // creates legacy React elements that React 19 refuses to render under
7
+ // `bun test` (app bundlers alias react, so this only affects tests).
8
+ const actualIcons = await import("@radix-ui/react-icons");
9
+
10
+ mock.module("@radix-ui/react-icons", () => ({
11
+ ...Object.fromEntries(
12
+ Object.keys(actualIcons).map((name) => [
13
+ name,
14
+ (props: React.SVGProps<SVGSVGElement>) => <svg {...props} />,
15
+ ])
16
+ ),
17
+ }));
18
+
19
+ const { cleanup, fireEvent, render } = await import("@testing-library/react");
20
+ const { Toolbar } = await import("../Toolbar");
21
+ const { ToolbarItems } = await import("../ToolbarItems");
22
+
23
+ afterEach(cleanup);
24
+
25
+ type Tool = "select" | "hand" | "rectangle" | "oval" | "text";
26
+
27
+ const makeItems = (checked?: Tool): ToolbarItem<Tool>[] => [
28
+ {
29
+ type: "group",
30
+ id: "move",
31
+ items: [
32
+ {
33
+ title: "Move",
34
+ value: "select",
35
+ icon: "CursorArrowIcon",
36
+ shortcut: "V",
37
+ checked: checked === "select",
38
+ },
39
+ {
40
+ title: "Hand",
41
+ value: "hand",
42
+ icon: "HandIcon2",
43
+ shortcut: "H",
44
+ checked: checked === "hand",
45
+ },
46
+ ],
47
+ },
48
+ {
49
+ type: "group",
50
+ id: "shape",
51
+ items: [
52
+ {
53
+ title: "Rectangle",
54
+ value: "rectangle",
55
+ icon: "SquareIcon",
56
+ shortcut: "R",
57
+ checked: checked === "rectangle",
58
+ },
59
+ {
60
+ title: "Ellipse",
61
+ value: "oval",
62
+ icon: "CircleIcon",
63
+ checked: checked === "oval",
64
+ },
65
+ ],
66
+ },
67
+ { type: "separator" },
68
+ {
69
+ title: "Text",
70
+ value: "text",
71
+ icon: "TextIcon",
72
+ shortcut: "T",
73
+ checked: checked === "text",
74
+ },
75
+ ];
76
+
77
+ describe("ToolbarItems", () => {
78
+ test("renders selectable items, groups, and separators", () => {
79
+ const { getByRole, container } = render(
80
+ <Toolbar aria-label="Tools" size="small">
81
+ <Toolbar.Items items={makeItems()} onSelect={() => {}} />
82
+ </Toolbar>
83
+ );
84
+
85
+ expect(getByRole("button", { name: "Text" })).toBeTruthy();
86
+ // Group slots show the representative (first child) plus a chevron
87
+ expect(getByRole("button", { name: "Move" })).toBeTruthy();
88
+ expect(getByRole("button", { name: "Move tools" })).toBeTruthy();
89
+ expect(getByRole("button", { name: "Rectangle" })).toBeTruthy();
90
+ // Separator renders the vertical divider
91
+ expect(container.querySelectorAll(".n-w-px.n-bg-divider").length).toBe(1);
92
+ });
93
+
94
+ test("checked item gets the primary active treatment", () => {
95
+ const { getByRole } = render(
96
+ <ToolbarItems items={makeItems("text")} onSelect={() => {}} />
97
+ );
98
+
99
+ const text = getByRole("button", { name: "Text" });
100
+ expect(text.getAttribute("aria-pressed")).toBe("true");
101
+ expect(text.classList.contains("n-bg-primary")).toBe(true);
102
+ expect(text.classList.contains("n-text-white")).toBe(true);
103
+
104
+ const move = getByRole("button", { name: "Move" });
105
+ expect(move.getAttribute("aria-pressed")).toBe("false");
106
+ expect(move.classList.contains("n-bg-primary")).toBe(false);
107
+ });
108
+
109
+ test("checked child becomes the group representative and marks the group active", () => {
110
+ const { getByRole, queryByRole } = render(
111
+ <ToolbarItems items={makeItems("hand")} onSelect={() => {}} />
112
+ );
113
+
114
+ const hand = getByRole("button", { name: "Hand" });
115
+ expect(hand.getAttribute("aria-pressed")).toBe("true");
116
+ expect(hand.classList.contains("n-bg-primary")).toBe(true);
117
+ // The unchecked sibling no longer occupies the slot
118
+ expect(queryByRole("button", { name: "Move" })).toBeNull();
119
+ });
120
+
121
+ test("group remembers the last-used child when the check moves elsewhere", () => {
122
+ const { getByRole, queryByRole, rerender } = render(
123
+ <ToolbarItems items={makeItems("oval")} onSelect={() => {}} />
124
+ );
125
+
126
+ expect(getByRole("button", { name: "Ellipse" })).toBeTruthy();
127
+
128
+ // Check moves to another group: the shape slot keeps showing Ellipse,
129
+ // now inactive, instead of resetting to Rectangle.
130
+ rerender(<ToolbarItems items={makeItems("select")} onSelect={() => {}} />);
131
+
132
+ const ellipse = getByRole("button", { name: "Ellipse" });
133
+ expect(ellipse.getAttribute("aria-pressed")).toBe("false");
134
+ expect(ellipse.classList.contains("n-bg-primary")).toBe(false);
135
+ expect(queryByRole("button", { name: "Rectangle" })).toBeNull();
136
+ });
137
+
138
+ test("selecting the group representative fires onSelect with its value", () => {
139
+ const onSelect = mock((_value: Tool) => {});
140
+ const { getByRole } = render(
141
+ <ToolbarItems items={makeItems()} onSelect={onSelect} />
142
+ );
143
+
144
+ fireEvent.click(getByRole("button", { name: "Rectangle" }));
145
+ expect(onSelect).toHaveBeenCalledWith("rectangle");
146
+
147
+ fireEvent.click(getByRole("button", { name: "Text" }));
148
+ expect(onSelect).toHaveBeenCalledWith("text");
149
+ });
150
+
151
+ test("disabled items render disabled and don't fire onSelect", () => {
152
+ const onSelect = mock((_value: Tool) => {});
153
+ const items: ToolbarItem<Tool>[] = [
154
+ {
155
+ title: "Text",
156
+ value: "text",
157
+ icon: "TextIcon",
158
+ disabled: true,
159
+ },
160
+ ];
161
+ const { getByRole } = render(
162
+ <ToolbarItems items={items} onSelect={onSelect} />
163
+ );
164
+
165
+ const text = getByRole("button", { name: "Text" }) as HTMLButtonElement;
166
+ expect(text.disabled).toBe(true);
167
+ expect(text.classList.contains("n-opacity-30")).toBe(true);
168
+ fireEvent.click(text);
169
+ expect(onSelect).not.toHaveBeenCalled();
170
+ });
171
+
172
+ test("fully disabled group disables its chevron", () => {
173
+ const items: ToolbarItem<Tool>[] = [
174
+ {
175
+ type: "group",
176
+ id: "shape",
177
+ items: [
178
+ {
179
+ title: "Rectangle",
180
+ value: "rectangle",
181
+ icon: "SquareIcon",
182
+ disabled: true,
183
+ },
184
+ {
185
+ title: "Ellipse",
186
+ value: "oval",
187
+ icon: "CircleIcon",
188
+ disabled: true,
189
+ },
190
+ ],
191
+ },
192
+ ];
193
+ const { getByRole } = render(
194
+ <ToolbarItems items={items} onSelect={() => {}} />
195
+ );
196
+
197
+ expect(
198
+ (getByRole("button", { name: "Rectangle" }) as HTMLButtonElement).disabled
199
+ ).toBe(true);
200
+ expect(
201
+ (getByRole("button", { name: "Rectangle tools" }) as HTMLButtonElement)
202
+ .disabled
203
+ ).toBe(true);
204
+ });
205
+ });
@@ -207,8 +207,11 @@ export const styles = {
207
207
  marginRight: -CHECKBOX_RIGHT_INSET,
208
208
  },
209
209
  },
210
+ // n-outline-none: the popup itself can take focus (e.g. a right-click
211
+ // context menu) and the browser's default focus outline reads as a stray
212
+ // black border on the light surface; keyboard focus is shown on items.
210
213
  contentStyle:
211
- "n-rounded n-bg-popover-background n-text-text n-shadow-popover n-z-menu n-py-1",
214
+ "n-rounded n-bg-popover-background n-text-text n-shadow-popover n-z-menu n-py-1 n-outline-none",
212
215
  };
213
216
 
214
217
  function getKeyboardShortcuts<T extends string>(
@@ -173,7 +173,14 @@ const SubmittableTextInput = forwardRef(function SubmittableTextInput(
173
173
 
174
174
  isSubmitTriggeredByEscapeKey.current = false;
175
175
 
176
- if (submissionValue === value && !allowSubmittingWithSameValue) return;
176
+ if (submissionValue === value && !allowSubmittingWithSameValue) {
177
+ // Nothing to commit — revert any in-progress edit so the displayed
178
+ // text matches the committed value (Escape-then-blur must not leave
179
+ // the abandoned text in the field, where the next blur would commit it).
180
+ userDidEdit.current = false;
181
+ setInternalValue(latestValue.current);
182
+ return;
183
+ }
177
184
 
178
185
  onSubmit(submissionValue);
179
186
 
@@ -200,12 +207,20 @@ const SubmittableTextInput = forwardRef(function SubmittableTextInput(
200
207
  } else if (event.key === "Escape") {
201
208
  isSubmitTriggeredByEscapeKey.current = true;
202
209
 
210
+ // An in-progress edit consumes the Escape: it only reverts the field.
211
+ // A second Escape (no pending edit) is left to outer handlers
212
+ // (popover close, canvas deselect...).
213
+ if (internalValue !== value) {
214
+ event.preventDefault();
215
+ event.stopPropagation();
216
+ }
217
+
203
218
  ref.current?.blur();
204
219
  } else {
205
220
  onKeyDown?.(event);
206
221
  }
207
222
  },
208
- [onKeyDown, handleSubmit]
223
+ [onKeyDown, handleSubmit, internalValue, value]
209
224
  );
210
225
 
211
226
  const handleChange = useCallback(
@@ -230,15 +245,39 @@ const SubmittableTextInput = forwardRef(function SubmittableTextInput(
230
245
  [onBlur, onFocusChange, handleSubmit, submitOnBlur]
231
246
  );
232
247
 
248
+ // Select the whole value on pointer-initiated focus so typing replaces it
249
+ // (Figma numeric-field behavior; Tab focus already selects natively). The
250
+ // select() runs in the focus handler, and the focusing click's mouseup gets
251
+ // a one-shot preventDefault so caret placement doesn't collapse the
252
+ // selection. Clicks in an already-focused field keep normal caret behavior.
253
+ const isFocusingFromPointer = useRef(false);
254
+
255
+ const handleMouseDown = useCallback((event: React.MouseEvent) => {
256
+ if (document.activeElement !== event.currentTarget) {
257
+ isFocusingFromPointer.current = true;
258
+ }
259
+ }, []);
260
+
233
261
  const handleFocusCapture = useCallback(
234
262
  (event: React.FocusEvent<HTMLInputElement>) => {
235
263
  onFocusCapture?.(event);
236
264
 
237
265
  onFocusChange?.(true);
266
+
267
+ if (isFocusingFromPointer.current) {
268
+ event.currentTarget.select();
269
+ }
238
270
  },
239
271
  [onFocusCapture, onFocusChange]
240
272
  );
241
273
 
274
+ const handleMouseUp = useCallback((event: React.MouseEvent) => {
275
+ if (!isFocusingFromPointer.current) return;
276
+
277
+ isFocusingFromPointer.current = false;
278
+ event.preventDefault();
279
+ }, []);
280
+
242
281
  const autoSubmitTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
243
282
  null
244
283
  );
@@ -276,6 +315,8 @@ const SubmittableTextInput = forwardRef(function SubmittableTextInput(
276
315
  onChange={handleChange}
277
316
  onBlur={handleBlur}
278
317
  onFocusCapture={handleFocusCapture}
318
+ onMouseDown={handleMouseDown}
319
+ onMouseUp={handleMouseUp}
279
320
  />
280
321
  );
281
322
  });
@@ -27,7 +27,7 @@ describe("submittable text input", () => {
27
27
  });
28
28
 
29
29
  test("change and blur, reset", () => {
30
- const handleSubmit = (value: string) => {};
30
+ const handleSubmit = (_value: string) => {};
31
31
 
32
32
  const { container, rerender } = render(
33
33
  <TextInput value="" onSubmit={handleSubmit} />
@@ -90,7 +90,7 @@ describe("submittable text input", () => {
90
90
  });
91
91
 
92
92
  test("change and submit, reset", () => {
93
- const handleSubmit = (value: string) => {};
93
+ const handleSubmit = (_value: string) => {};
94
94
 
95
95
  const { container, rerender } = render(
96
96
  <TextInput value="" onSubmit={handleSubmit} />
@@ -142,4 +142,81 @@ describe("submittable text input", () => {
142
142
 
143
143
  expect(handleSubmit).toBeCalledTimes(3);
144
144
  });
145
+
146
+ test("escape reverts an in-progress edit without submitting", () => {
147
+ const handleSubmit = mock(() => {});
148
+
149
+ const { container } = render(
150
+ <TextInput value="90" onSubmit={handleSubmit} />
151
+ );
152
+
153
+ const input = container.querySelector("input")!;
154
+ fireEvent.change(input, { target: { value: "555" } });
155
+ fireEvent.keyDown(input, { key: "Escape", code: "Escape" });
156
+ // Escape blurs the field; the blur must not commit the abandoned edit.
157
+ fireEvent.blur(input);
158
+
159
+ expect(handleSubmit).not.toBeCalled();
160
+ expect(input.value).toEqual("90");
161
+ });
162
+
163
+ test("blur after an escape-revert does not resurrect the abandoned edit", () => {
164
+ const handleSubmit = mock(() => {});
165
+
166
+ const { container } = render(
167
+ <TextInput value="90" onSubmit={handleSubmit} />
168
+ );
169
+
170
+ const input = container.querySelector("input")!;
171
+ fireEvent.change(input, { target: { value: "555" } });
172
+ fireEvent.keyDown(input, { key: "Escape", code: "Escape" });
173
+ fireEvent.blur(input);
174
+ // A later focus + blur cycle with no new edit stays a no-op.
175
+ fireEvent.focus(input);
176
+ fireEvent.blur(input);
177
+
178
+ expect(handleSubmit).not.toBeCalled();
179
+ expect(input.value).toEqual("90");
180
+ });
181
+
182
+ test("escape with an in-progress edit consumes the event; a clean escape propagates", () => {
183
+ const outerKeyDown = mock(() => {});
184
+
185
+ const { container } = render(
186
+ <div onKeyDown={outerKeyDown}>
187
+ <TextInput value="90" onSubmit={() => {}} />
188
+ </div>
189
+ );
190
+
191
+ const input = container.querySelector("input")!;
192
+
193
+ // No edit in progress: Escape is left to outer handlers (deselect,
194
+ // popover close...).
195
+ fireEvent.keyDown(input, { key: "Escape", code: "Escape" });
196
+ expect(outerKeyDown).toBeCalledTimes(1);
197
+
198
+ // Edit in progress: Escape only reverts the field.
199
+ fireEvent.change(input, { target: { value: "555" } });
200
+ fireEvent.keyDown(input, { key: "Escape", code: "Escape" });
201
+ expect(outerKeyDown).toBeCalledTimes(1);
202
+ });
203
+
204
+ test("pointer-initiated focus selects the whole value", () => {
205
+ const { container } = render(
206
+ <TextInput value="1440" onSubmit={() => {}} />
207
+ );
208
+
209
+ const input = container.querySelector("input")!;
210
+ fireEvent.mouseDown(input);
211
+ fireEvent.focus(input);
212
+
213
+ expect(input.selectionStart).toEqual(0);
214
+ expect(input.selectionEnd).toEqual(4);
215
+
216
+ // The mouseup that completes the focusing click must not collapse the
217
+ // selection (the handler preventDefaults exactly once).
218
+ fireEvent.mouseUp(input);
219
+ expect(input.selectionStart).toEqual(0);
220
+ expect(input.selectionEnd).toEqual(4);
221
+ });
145
222
  });
@@ -29,6 +29,42 @@ export const ListViewEditableRowTitle = memo(function ListViewEditableRowTitle({
29
29
  selectBeforeDot = false,
30
30
  }: EditableRowTitleProps) {
31
31
  const inputRef = useRef<HTMLInputElement | null>(null);
32
+ const submittedRef = useRef(false);
33
+
34
+ const handleSubmitEditing = (nextValue: string) => {
35
+ if (submittedRef.current) return;
36
+ submittedRef.current = true;
37
+ onSubmitEditing(nextValue);
38
+ };
39
+ const handleSubmitEditingRef = useRef(handleSubmitEditing);
40
+ handleSubmitEditingRef.current = handleSubmitEditing;
41
+
42
+ // Commit and close on any pointerdown outside the editor. Blur alone isn't
43
+ // enough: interactions that call preventDefault() on pointerdown (e.g. a
44
+ // canvas) never move focus, which would strand an open rename editor.
45
+ useLayoutEffect(() => {
46
+ const handlePointerDown = (event: PointerEvent) => {
47
+ const element = inputRef.current;
48
+ if (!element) return;
49
+ if (
50
+ event.target instanceof Node &&
51
+ (element === event.target || element.contains(event.target))
52
+ ) {
53
+ return;
54
+ }
55
+ handleSubmitEditingRef.current(element.value);
56
+ };
57
+
58
+ document.addEventListener("pointerdown", handlePointerDown, {
59
+ capture: true,
60
+ });
61
+
62
+ return () => {
63
+ document.removeEventListener("pointerdown", handlePointerDown, {
64
+ capture: true,
65
+ });
66
+ };
67
+ }, []);
32
68
 
33
69
  useLayoutEffect(() => {
34
70
  const element = inputRef.current;
@@ -65,7 +101,7 @@ export const ListViewEditableRowTitle = memo(function ListViewEditableRowTitle({
65
101
  ref={inputRef}
66
102
  value={value}
67
103
  placeholder={placeholder}
68
- onSubmit={onSubmitEditing}
104
+ onSubmit={handleSubmitEditing}
69
105
  allowSubmittingWithSameValue
70
106
  className={cx(
71
107
  "-n-mx-input-padding-x -n-my-1 n-bg-listview-editing-background",
@@ -105,6 +105,9 @@ const RowContainer = forwardRef<
105
105
  $clickable ? "n-cursor-pointer" : "n-cursor-default",
106
106
  $isSectionHeader && "n-bg-listview-raised-background",
107
107
  "n-flex n-flex-[0_0_auto] n-select-none n-items-center n-relative",
108
+ // Rows own their focus styling: the standard primary ring for
109
+ // keyboard focus instead of the UA default black outline.
110
+ "n-outline-none focus-visible:n-ring-1 focus-visible:n-ring-primary focus-visible:n-ring-inset",
108
111
  className
109
112
  )}
110
113
  style={{
@@ -0,0 +1,69 @@
1
+ import { cleanup, fireEvent, render } from "@testing-library/react";
2
+ import { afterEach, describe, expect, test } from "bun:test";
3
+ import React from "react";
4
+ import { ListViewEditableRowTitle } from "../ListViewEditableRowTitle";
5
+
6
+ afterEach(cleanup);
7
+
8
+ describe("ListViewEditableRowTitle", () => {
9
+ test("commits and closes on pointerdown outside the editor", () => {
10
+ // Regression: interactions that preventDefault() their pointerdown (e.g.
11
+ // a canvas) never blur the input, which used to strand an open rename
12
+ // editor across later interactions.
13
+ const submissions: string[] = [];
14
+
15
+ const { container } = render(
16
+ <div>
17
+ <ListViewEditableRowTitle
18
+ value="Rectangle"
19
+ autoFocus={false}
20
+ onSubmitEditing={(value) => submissions.push(value)}
21
+ />
22
+ <button>outside</button>
23
+ </div>
24
+ );
25
+
26
+ const outside = container.querySelector("button")!;
27
+ fireEvent.pointerDown(outside);
28
+
29
+ expect(submissions).toEqual(["Rectangle"]);
30
+ });
31
+
32
+ test("pointerdown inside the editor does not submit", () => {
33
+ const submissions: string[] = [];
34
+
35
+ const { container } = render(
36
+ <ListViewEditableRowTitle
37
+ value="Rectangle"
38
+ autoFocus={false}
39
+ onSubmitEditing={(value) => submissions.push(value)}
40
+ />
41
+ );
42
+
43
+ const input = container.querySelector("input")!;
44
+ fireEvent.pointerDown(input);
45
+
46
+ expect(submissions).toEqual([]);
47
+ });
48
+
49
+ test("submits at most once", () => {
50
+ const submissions: string[] = [];
51
+
52
+ const { container } = render(
53
+ <div>
54
+ <ListViewEditableRowTitle
55
+ value="Rectangle"
56
+ autoFocus={false}
57
+ onSubmitEditing={(value) => submissions.push(value)}
58
+ />
59
+ <button>outside</button>
60
+ </div>
61
+ );
62
+
63
+ const outside = container.querySelector("button")!;
64
+ fireEvent.pointerDown(outside);
65
+ fireEvent.pointerDown(outside);
66
+
67
+ expect(submissions).toEqual(["Rectangle"]);
68
+ });
69
+ });
@@ -186,7 +186,10 @@ function SortableItem<TElement extends HTMLElement>({
186
186
  ref,
187
187
  ...attributes,
188
188
  ...listeners,
189
- style: { opacity: isDragging ? 0.5 : 1 },
189
+ // Only set an inline opacity while dragging — an always-on inline
190
+ // `opacity: 1` would override class-based opacity styling on rows
191
+ // (e.g. dimmed hidden layers).
192
+ ...(isDragging ? { style: { opacity: 0.5 } } : {}),
190
193
  }),
191
194
  [ref, attributes, listeners, isDragging]
192
195
  );
package/src/index.tsx CHANGED
@@ -128,7 +128,6 @@ export { default as withSeparatorElements } from "./utils/withSeparatorElements"
128
128
 
129
129
  // Avoid dependency cycles
130
130
  export * from "./components/DimensionInput";
131
- export * as InspectorPrimitives from "./components/InspectorPrimitives";
132
131
 
133
132
  export * from "./components/BaseToolbar";
134
133
  export * from "./components/AppToolbar";
@@ -11,7 +11,15 @@ import {
11
11
  SectionHeaderMenuItem,
12
12
  SelectableMenuItem,
13
13
  } from "../components/internal/Menu";
14
- import { fuzzyFilter, IScoredItem } from "./fuzzyScorer";
14
+ import { fuzzyFilter, IScoredItem, wordPrefixFilter } from "./fuzzyScorer";
15
+
16
+ /**
17
+ * How a combobox matches its filter text against item titles:
18
+ * - "fuzzy": vscode-style fuzzy scoring (default)
19
+ * - "wordPrefix": word-boundary prefixes ranked exact > prefix > word-start,
20
+ * substring only as a fallback (right for name lists like font families)
21
+ */
22
+ export type ComboboxFilterStrategy = "fuzzy" | "wordPrefix";
15
23
 
16
24
  export type ComboboxStateSnapshot<T extends string> = {
17
25
  filter: string;
@@ -29,11 +37,15 @@ export class ComboboxState<T extends string> {
29
37
  private version = 0;
30
38
  private _cachedSnapshot: [number, ComboboxStateSnapshot<T>] | undefined;
31
39
 
40
+ private filterStrategy: ComboboxFilterStrategy;
41
+
32
42
  constructor(
33
43
  items: MenuItem<T>[],
34
44
  initialTitle: string,
35
- allowArbitraryValues: boolean
45
+ allowArbitraryValues: boolean,
46
+ filterStrategy: ComboboxFilterStrategy = "fuzzy"
36
47
  ) {
48
+ this.filterStrategy = filterStrategy;
37
49
  this.items = items;
38
50
  this.lastSubmittedValue = {
39
51
  filter: initialTitle,
@@ -122,7 +134,9 @@ export class ComboboxState<T extends string> {
122
134
  }
123
135
 
124
136
  private getFilteredItems(): ScoredMenuItem<T>[] {
125
- return filterWithGroupedSections(this.items, this.filter);
137
+ return filterWithGroupedSections(this.items, this.filter, {
138
+ filterStrategy: this.filterStrategy,
139
+ });
126
140
  }
127
141
 
128
142
  getSnapshot = (): ComboboxStateSnapshot<T> => {
@@ -300,8 +314,11 @@ export class ComboboxState<T extends string> {
300
314
 
301
315
  export function filterWithGroupedSections<T extends string>(
302
316
  items: MenuItem<T>[],
303
- query: string
317
+ query: string,
318
+ options: { filterStrategy?: ComboboxFilterStrategy } = {}
304
319
  ): ScoredMenuItem<T>[] {
320
+ const filterItems =
321
+ options.filterStrategy === "wordPrefix" ? wordPrefixFilter : fuzzyFilter;
305
322
  const sections = chunkBy(items, (_a, b) => isSelectableMenuItem(b));
306
323
  let result: ScoredMenuItem<T>[] = [];
307
324
  let currentIndex = 0;
@@ -344,7 +361,7 @@ export function filterWithGroupedSections<T extends string>(
344
361
  createExtraItem(item, index)
345
362
  );
346
363
  } else {
347
- const scoredItems = fuzzyFilter({
364
+ const scoredItems = filterItems({
348
365
  items: selectableItems.map((item) => {
349
366
  const title = item.title?.toString?.() ?? "";
350
367
  const tooltipText = getNodeText(item.tooltip);