@noya-app/noya-designsystem 0.1.94 → 0.1.95
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/.turbo/turbo-build.log +9 -9
- package/CHANGELOG.md +10 -0
- package/dist/index.css +1 -1
- package/dist/index.d.mts +158 -93
- package/dist/index.d.ts +158 -93
- package/dist/index.js +1307 -1034
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1231 -962
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/SharedDragProvider.test.ts +72 -29
- package/src/__tests__/fuzzyScorer.test.ts +49 -1
- package/src/components/Button.tsx +1 -1
- package/src/components/Combobox.tsx +23 -3
- package/src/components/ComboboxMenu.tsx +32 -15
- package/src/components/ContextMenu.tsx +26 -7
- package/src/components/DimensionInput.tsx +12 -2
- package/src/components/DropdownMenu.tsx +23 -8
- package/src/components/InputField.tsx +1 -0
- package/src/components/InspectorContainer.tsx +16 -1
- package/src/components/List.tsx +25 -3
- package/src/components/Popover.tsx +13 -1
- package/src/components/Section.tsx +35 -11
- package/src/components/SegmentedControl.tsx +14 -4
- package/src/components/Toolbar.tsx +16 -1
- package/src/components/ToolbarItems.tsx +322 -0
- package/src/components/Tooltip.tsx +8 -1
- package/src/components/__tests__/ToolbarItems.test.tsx +205 -0
- package/src/components/internal/Menu.tsx +4 -1
- package/src/components/internal/TextInput.tsx +43 -2
- package/src/components/internal/__tests__/TextInput.test.tsx +77 -0
- package/src/components/listView/ListViewEditableRowTitle.tsx +37 -1
- package/src/components/listView/ListViewRow.tsx +3 -0
- package/src/components/listView/__tests__/ListViewEditableRowTitle.test.tsx +69 -0
- package/src/components/sorting/Sortable.tsx +4 -1
- package/src/index.tsx +0 -1
- package/src/utils/combobox.ts +22 -5
- package/src/utils/fuzzyScorer.ts +71 -0
- package/tailwind.config.ts +7 -0
- package/src/components/InspectorPrimitives.tsx +0 -188
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noya-app/noya-designsystem",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.95",
|
|
4
4
|
"main": "./dist/index.js",
|
|
5
5
|
"module": "./dist/index.mjs",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -29,11 +29,11 @@
|
|
|
29
29
|
"@dnd-kit/core": "6.3.1",
|
|
30
30
|
"@dnd-kit/sortable": "10.0.0",
|
|
31
31
|
"@floating-ui/react": "^0.27.0",
|
|
32
|
-
"@noya-app/noya-colorpicker": "0.1.
|
|
32
|
+
"@noya-app/noya-colorpicker": "0.1.37",
|
|
33
33
|
"@noya-app/noya-utils": "0.1.10",
|
|
34
34
|
"@noya-app/noya-geometry": "0.1.19",
|
|
35
|
-
"@noya-app/noya-icons": "0.1.
|
|
36
|
-
"@noya-app/noya-keymap": "0.1.
|
|
35
|
+
"@noya-app/noya-icons": "0.1.21",
|
|
36
|
+
"@noya-app/noya-keymap": "0.1.5",
|
|
37
37
|
"@noya-app/noya-tailwind-config": "0.1.10",
|
|
38
38
|
"@radix-ui/react-compose-refs": "^1.0.0",
|
|
39
39
|
"@radix-ui/primitive": "^1.0.1",
|
|
@@ -2,38 +2,81 @@ import type { PointerSensorProps } from "@dnd-kit/core";
|
|
|
2
2
|
import { describe, expect, test } from "bun:test";
|
|
3
3
|
import { ContinuousPointerSensor } from "../components/sorting/SharedDragProvider";
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
5
|
+
/**
|
|
6
|
+
* When a dnd-kit pointer sensor activates, it adds a document-level CAPTURE
|
|
7
|
+
* listener that calls stopPropagation on every "click", and detach() only
|
|
8
|
+
* removes it on a 50ms setTimeout. Waiting that window out keeps the
|
|
9
|
+
* suppression from leaking into whatever test dispatches clicks next (it
|
|
10
|
+
* silently breaks fireEvent.click across suite boundaries otherwise).
|
|
11
|
+
*/
|
|
12
|
+
function waitForDndKitDocumentListenerCleanup() {
|
|
13
|
+
return new Promise((resolve) => setTimeout(resolve, 60));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function simulateDrag(events: string[]) {
|
|
17
|
+
const target = document.createElement("div");
|
|
18
|
+
document.body.appendChild(target);
|
|
19
|
+
const pointerDown = new PointerEvent("pointerdown", {
|
|
20
|
+
clientX: 10,
|
|
21
|
+
clientY: 20,
|
|
22
|
+
});
|
|
23
|
+
target.dispatchEvent(pointerDown);
|
|
14
24
|
|
|
25
|
+
const props = {
|
|
26
|
+
active: "item",
|
|
27
|
+
activeNode: {},
|
|
28
|
+
context: { current: {} },
|
|
29
|
+
event: pointerDown,
|
|
30
|
+
options: { activationConstraint: { distance: 4 } },
|
|
31
|
+
onAbort: () => {},
|
|
32
|
+
onPending: () => {},
|
|
33
|
+
onStart: () => {
|
|
34
|
+
events.push("start");
|
|
35
|
+
},
|
|
36
|
+
onMove: ({ x, y }: { x: number; y: number }) => {
|
|
37
|
+
events.push(`move:${x},${y}`);
|
|
38
|
+
},
|
|
39
|
+
onEnd: () => {
|
|
40
|
+
events.push("end");
|
|
41
|
+
},
|
|
42
|
+
onCancel: () => {},
|
|
43
|
+
} as unknown as PointerSensorProps;
|
|
44
|
+
|
|
45
|
+
new ContinuousPointerSensor(props);
|
|
46
|
+
document.dispatchEvent(
|
|
47
|
+
new PointerEvent("pointermove", { clientX: 30, clientY: 40 })
|
|
48
|
+
);
|
|
49
|
+
document.dispatchEvent(new PointerEvent("pointerup"));
|
|
50
|
+
target.remove();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("ContinuousPointerSensor", () => {
|
|
54
|
+
test("forwards the movement that activates a distance-constrained drag", async () => {
|
|
15
55
|
const events: string[] = [];
|
|
16
|
-
|
|
17
|
-
active: "item",
|
|
18
|
-
activeNode: {},
|
|
19
|
-
context: { current: {} },
|
|
20
|
-
event: pointerDown,
|
|
21
|
-
options: { activationConstraint: { distance: 4 } },
|
|
22
|
-
onAbort: () => {},
|
|
23
|
-
onPending: () => {},
|
|
24
|
-
onStart: () => events.push("start"),
|
|
25
|
-
onMove: ({ x, y }) => events.push(`move:${x},${y}`),
|
|
26
|
-
onEnd: () => events.push("end"),
|
|
27
|
-
onCancel: () => {},
|
|
28
|
-
} as PointerSensorProps;
|
|
29
|
-
|
|
30
|
-
new ContinuousPointerSensor(props);
|
|
31
|
-
document.dispatchEvent(
|
|
32
|
-
new PointerEvent("pointermove", { clientX: 30, clientY: 40 })
|
|
33
|
-
);
|
|
34
|
-
document.dispatchEvent(new PointerEvent("pointerup"));
|
|
56
|
+
simulateDrag(events);
|
|
35
57
|
|
|
36
58
|
expect(events).toEqual(["start", "move:30,40", "end"]);
|
|
37
|
-
|
|
59
|
+
|
|
60
|
+
await waitForDndKitDocumentListenerCleanup();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("clicks propagate again after the drag's deferred cleanup", async () => {
|
|
64
|
+
simulateDrag([]);
|
|
65
|
+
await waitForDndKitDocumentListenerCleanup();
|
|
66
|
+
|
|
67
|
+
// Regression guard: an activated drag suppresses click propagation at the
|
|
68
|
+
// document level; after the deferred cleanup, clicks must bubble again.
|
|
69
|
+
const parent = document.createElement("div");
|
|
70
|
+
const button = document.createElement("button");
|
|
71
|
+
parent.appendChild(button);
|
|
72
|
+
document.body.appendChild(parent);
|
|
73
|
+
let bubbled = 0;
|
|
74
|
+
parent.addEventListener("click", () => {
|
|
75
|
+
bubbled++;
|
|
76
|
+
});
|
|
77
|
+
button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
78
|
+
parent.remove();
|
|
79
|
+
|
|
80
|
+
expect(bubbled).toBe(1);
|
|
38
81
|
});
|
|
39
82
|
});
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
fuzzyFilter,
|
|
4
|
+
fuzzyScore,
|
|
5
|
+
fuzzyTokenize,
|
|
6
|
+
wordPrefixFilter,
|
|
7
|
+
} from "../utils/fuzzyScorer";
|
|
3
8
|
|
|
4
9
|
test("prefix match", () => {
|
|
5
10
|
const result = fuzzyScore({ item: "font-bold", query: "font" });
|
|
@@ -83,3 +88,46 @@ test("tokenizes non-contiguous match", () => {
|
|
|
83
88
|
{ type: "text", text: "old" },
|
|
84
89
|
]);
|
|
85
90
|
});
|
|
91
|
+
|
|
92
|
+
test("wordPrefixFilter ranks exact > prefix > word-start and drops mid-word substrings", () => {
|
|
93
|
+
const families = [
|
|
94
|
+
"Almendra Display",
|
|
95
|
+
"Big Shoulders Display",
|
|
96
|
+
"DM Serif Display",
|
|
97
|
+
"Gilda Display",
|
|
98
|
+
"Play",
|
|
99
|
+
"Playball",
|
|
100
|
+
"Playfair Display",
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
const result = wordPrefixFilter({ items: families, query: "Play" });
|
|
104
|
+
const names = result.map(({ index }) => families[index]);
|
|
105
|
+
|
|
106
|
+
// Mid-word substring matches ("* Display") are excluded entirely because
|
|
107
|
+
// word-boundary matches exist.
|
|
108
|
+
expect(names).toEqual(["Play", "Playball", "Playfair Display"]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("wordPrefixFilter matches at interior word starts with highlight ranges", () => {
|
|
112
|
+
const families = ["DM Serif Display", "Sans Serif", "Monoserif"];
|
|
113
|
+
|
|
114
|
+
const result = wordPrefixFilter({ items: families, query: "serif" });
|
|
115
|
+
const names = result.map(({ index }) => families[index]);
|
|
116
|
+
|
|
117
|
+
expect(names).toEqual(["DM Serif Display", "Sans Serif"]);
|
|
118
|
+
expect(result[0].labelMatch).toEqual([{ start: 3, end: 8 }]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("wordPrefixFilter falls back to substrings only when nothing matches a word boundary", () => {
|
|
122
|
+
const families = ["Almendra Display", "Gilda Display", "Play"];
|
|
123
|
+
|
|
124
|
+
const result = wordPrefixFilter({ items: families, query: "lay" });
|
|
125
|
+
const names = result.map(({ index }) => families[index]);
|
|
126
|
+
|
|
127
|
+
expect(names).toEqual(["Almendra Display", "Gilda Display", "Play"]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("wordPrefixFilter with empty query returns all items unscored", () => {
|
|
131
|
+
const result = wordPrefixFilter({ items: ["A", "B"], query: " " });
|
|
132
|
+
expect(result.map(({ index }) => index)).toEqual([0, 1]);
|
|
133
|
+
});
|
|
@@ -103,7 +103,7 @@ export const Button = forwardRef(function Button(
|
|
|
103
103
|
const colorScheme = colorSchemeProp ?? "default";
|
|
104
104
|
|
|
105
105
|
const baseClassName = cx(
|
|
106
|
-
"n-no-underline n-leading-[1] n-relative n-outline-none n-select-none n-text-left n-rounded n-flex n-items-center n-justify-center [&>*]:n-pointer-events-none n-[-webkit-app-region:no-drag] focus:n-ring-2 focus:n-ring-primary focus:n-shadow-[0_0_0_1px_var(--n-popover-background)_inset] n-transition-all",
|
|
106
|
+
"n-no-underline n-leading-[1] n-relative n-outline-none n-select-none n-text-left n-rounded n-flex n-items-center n-justify-center [&>*]:n-pointer-events-none n-[-webkit-app-region:no-drag] focus-visible:n-ring-2 focus-visible:n-ring-primary focus-visible:n-shadow-[0_0_0_1px_var(--n-popover-background)_inset] n-transition-all",
|
|
107
107
|
variant === "ghost" ? "n-border" : "n-border-0",
|
|
108
108
|
disabled ? "n-opacity-25" : "",
|
|
109
109
|
!className?.includes("flex") ? "n-flex-none" : "",
|
|
@@ -14,7 +14,11 @@ import React, {
|
|
|
14
14
|
} from "react";
|
|
15
15
|
import { useLabel } from "../hooks/useLabel";
|
|
16
16
|
import { cx } from "../utils/classNames";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
ComboboxFilterStrategy,
|
|
19
|
+
ComboboxState,
|
|
20
|
+
useComboboxState,
|
|
21
|
+
} from "../utils/combobox";
|
|
18
22
|
import { ActivityIndicator } from "./ActivityIndicator";
|
|
19
23
|
import { ComboboxMenu } from "./ComboboxMenu";
|
|
20
24
|
import { renderIcon } from "./Icons";
|
|
@@ -48,6 +52,16 @@ export type BaseComboboxProps<T extends string> = {
|
|
|
48
52
|
tabBehavior?: "autocomplete" | "select";
|
|
49
53
|
openMenuBehavior?: "showAllItems" | "showFilteredItems";
|
|
50
54
|
iconPosition?: "left" | "right";
|
|
55
|
+
/** Wraps each menu item's label (receives the default highlighted content) */
|
|
56
|
+
renderItemContent?: (
|
|
57
|
+
item: ScoredMenuItem<T>,
|
|
58
|
+
defaultContent: React.ReactNode
|
|
59
|
+
) => React.ReactNode;
|
|
60
|
+
/**
|
|
61
|
+
* How filter text matches item titles ("fuzzy" default; "wordPrefix" for
|
|
62
|
+
* name lists). Fixed at mount.
|
|
63
|
+
*/
|
|
64
|
+
filterStrategy?: ComboboxFilterStrategy;
|
|
51
65
|
};
|
|
52
66
|
|
|
53
67
|
export type StringModeOnlyProps = {
|
|
@@ -105,6 +119,8 @@ export const Combobox = memoGeneric(
|
|
|
105
119
|
onFocus,
|
|
106
120
|
onDeleteWhenEmpty,
|
|
107
121
|
iconPosition = "right",
|
|
122
|
+
renderItemContent,
|
|
123
|
+
filterStrategy = "fuzzy",
|
|
108
124
|
...rest
|
|
109
125
|
}: ComboboxProps<T>,
|
|
110
126
|
forwardedRef: ForwardedRef<ComboboxRef<T>>
|
|
@@ -170,7 +186,8 @@ export const Combobox = memoGeneric(
|
|
|
170
186
|
new ComboboxState(
|
|
171
187
|
items,
|
|
172
188
|
initialValueString,
|
|
173
|
-
props.mode === "string" && (props.allowArbitraryValues ?? false)
|
|
189
|
+
props.mode === "string" && (props.allowArbitraryValues ?? false),
|
|
190
|
+
filterStrategy
|
|
174
191
|
)
|
|
175
192
|
);
|
|
176
193
|
const state = useComboboxState(comboboxState);
|
|
@@ -525,7 +542,9 @@ export const Combobox = memoGeneric(
|
|
|
525
542
|
menuItemsCount,
|
|
526
543
|
sectionHeaderCount,
|
|
527
544
|
tallMenuItemsCount,
|
|
528
|
-
|
|
545
|
+
// Whole rows only: a half-cut last row reads as a rendering bug
|
|
546
|
+
// rather than a scrollable list.
|
|
547
|
+
maxBaseRows: 10,
|
|
529
548
|
});
|
|
530
549
|
|
|
531
550
|
const adjustedListSize = { width, height: computedHeight };
|
|
@@ -546,6 +565,7 @@ export const Combobox = memoGeneric(
|
|
|
546
565
|
onHoverIndex={handleIndexChange}
|
|
547
566
|
listSize={adjustedListSize}
|
|
548
567
|
iconPosition={iconPosition}
|
|
568
|
+
renderItemContent={renderItemContent}
|
|
549
569
|
style={{
|
|
550
570
|
flex: `0 0 ${computedHeight}px`,
|
|
551
571
|
}}
|
|
@@ -34,6 +34,15 @@ interface ComboboxMenuProps<T extends string> {
|
|
|
34
34
|
itemHeight?: number;
|
|
35
35
|
/** Optional per-item height resolver */
|
|
36
36
|
getRowHeightForItem?: (item: ScoredMenuItem<T>, index: number) => number;
|
|
37
|
+
/**
|
|
38
|
+
* Wraps an item's rendered label (e.g. to style a font family's row in its
|
|
39
|
+
* own typeface). Receives the default label content, which includes the
|
|
40
|
+
* fuzzy-match highlighting.
|
|
41
|
+
*/
|
|
42
|
+
renderItemContent?: (
|
|
43
|
+
item: ScoredMenuItem<T>,
|
|
44
|
+
defaultContent: React.ReactNode
|
|
45
|
+
) => React.ReactNode;
|
|
37
46
|
}
|
|
38
47
|
|
|
39
48
|
export const ComboboxMenu = memoGeneric(
|
|
@@ -49,6 +58,7 @@ export const ComboboxMenu = memoGeneric(
|
|
|
49
58
|
iconPosition = "right",
|
|
50
59
|
itemHeight,
|
|
51
60
|
getRowHeightForItem,
|
|
61
|
+
renderItemContent,
|
|
52
62
|
}: ComboboxMenuProps<T>,
|
|
53
63
|
forwardedRef: ForwardedRef<IVirtualizedList>
|
|
54
64
|
) {
|
|
@@ -127,21 +137,28 @@ export const ComboboxMenu = memoGeneric(
|
|
|
127
137
|
</>
|
|
128
138
|
)}
|
|
129
139
|
<div className="n-flex n-flex-col n-min-w-0 n-flex-1">
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
<
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
140
|
+
{(() => {
|
|
141
|
+
const defaultContent = (
|
|
142
|
+
<div className="n-min-w-0 n-truncate">
|
|
143
|
+
{tokens.map((token, j) => (
|
|
144
|
+
<span
|
|
145
|
+
key={`${item.value}-token-${j}`}
|
|
146
|
+
className={cx(
|
|
147
|
+
token.type === "match"
|
|
148
|
+
? "n-font-bold"
|
|
149
|
+
: "n-font-normal",
|
|
150
|
+
"n-whitespace-pre n-truncate"
|
|
151
|
+
)}
|
|
152
|
+
>
|
|
153
|
+
{token.text}
|
|
154
|
+
</span>
|
|
155
|
+
))}
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
return renderItemContent
|
|
159
|
+
? renderItemContent(item, defaultContent)
|
|
160
|
+
: defaultContent;
|
|
161
|
+
})()}
|
|
145
162
|
{tooltip && (
|
|
146
163
|
<Small color="textSubtle" className="n-truncate">
|
|
147
164
|
{tooltip}
|
|
@@ -26,6 +26,11 @@ import {
|
|
|
26
26
|
import { Spacer } from "./Spacer";
|
|
27
27
|
import { cloneElementWithMergedProps } from "../utils/mergeProps";
|
|
28
28
|
|
|
29
|
+
/** Carries the root menu's contentStyle into submenu popups (separate portals). */
|
|
30
|
+
const ContextMenuContentStyleContext = React.createContext<
|
|
31
|
+
React.CSSProperties | undefined
|
|
32
|
+
>(undefined);
|
|
33
|
+
|
|
29
34
|
export interface MenuItemProps<T extends string> {
|
|
30
35
|
value?: T;
|
|
31
36
|
children: ReactNode;
|
|
@@ -114,6 +119,9 @@ function SubmenuItem<T extends string>({
|
|
|
114
119
|
portalScopeId: string | undefined;
|
|
115
120
|
}) {
|
|
116
121
|
const [open, setOpen] = useState(false);
|
|
122
|
+
const inheritedContentStyle = React.useContext(
|
|
123
|
+
ContextMenuContentStyleContext
|
|
124
|
+
);
|
|
117
125
|
|
|
118
126
|
const submenuHasCheckedItem = item.items.some(
|
|
119
127
|
(subItem) => isSelectableMenuItem(subItem) && subItem.checked
|
|
@@ -151,7 +159,10 @@ function SubmenuItem<T extends string>({
|
|
|
151
159
|
alignOffset={-5}
|
|
152
160
|
className="n-z-menu n-outline-none"
|
|
153
161
|
>
|
|
154
|
-
<BaseContextMenu.Popup
|
|
162
|
+
<BaseContextMenu.Popup
|
|
163
|
+
className={styles.contentStyle}
|
|
164
|
+
style={inheritedContentStyle}
|
|
165
|
+
>
|
|
155
166
|
<ContextMenuViewport
|
|
156
167
|
items={item.items}
|
|
157
168
|
onSelect={onSelect}
|
|
@@ -253,6 +264,8 @@ export interface MenuProps<T extends string> {
|
|
|
253
264
|
onSelect: (value: T) => void;
|
|
254
265
|
shouldBindKeyboardShortcuts?: boolean;
|
|
255
266
|
onOpenChange?: (open: boolean) => void;
|
|
267
|
+
/** Extra styles (e.g. CSS variable overrides) for the menu surface. */
|
|
268
|
+
contentStyle?: React.CSSProperties;
|
|
256
269
|
}
|
|
257
270
|
|
|
258
271
|
function ContextMenuRoot<T extends string>({
|
|
@@ -261,6 +274,7 @@ function ContextMenuRoot<T extends string>({
|
|
|
261
274
|
onSelect,
|
|
262
275
|
shouldBindKeyboardShortcuts,
|
|
263
276
|
onOpenChange,
|
|
277
|
+
contentStyle,
|
|
264
278
|
}: MenuProps<T>) {
|
|
265
279
|
const keymap = useMemo(
|
|
266
280
|
() =>
|
|
@@ -305,12 +319,17 @@ function ContextMenuRoot<T extends string>({
|
|
|
305
319
|
{...portalScopeProps(portalScopeId)}
|
|
306
320
|
className="n-z-menu n-outline-none"
|
|
307
321
|
>
|
|
308
|
-
<BaseContextMenu.Popup
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
322
|
+
<BaseContextMenu.Popup
|
|
323
|
+
className={styles.contentStyle}
|
|
324
|
+
style={contentStyle}
|
|
325
|
+
>
|
|
326
|
+
<ContextMenuContentStyleContext.Provider value={contentStyle}>
|
|
327
|
+
<ContextMenuViewport
|
|
328
|
+
items={items}
|
|
329
|
+
onSelect={handleSelectItem}
|
|
330
|
+
hasCheckedItem={hasCheckedItem}
|
|
331
|
+
/>
|
|
332
|
+
</ContextMenuContentStyleContext.Provider>
|
|
314
333
|
</BaseContextMenu.Popup>
|
|
315
334
|
</BaseContextMenu.Positioner>
|
|
316
335
|
</BaseContextMenu.Portal>
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { round } from "@noya-app/noya-utils";
|
|
4
4
|
import * as React from "react";
|
|
5
5
|
import { InputField } from "./InputField";
|
|
6
|
+
import { Label } from "./Label";
|
|
6
7
|
import { LabeledField } from "./LabeledField";
|
|
7
8
|
|
|
8
9
|
export type SetNumberMode = "replace" | "adjust";
|
|
@@ -30,6 +31,8 @@ interface Props {
|
|
|
30
31
|
placeholder?: string;
|
|
31
32
|
disabled?: boolean;
|
|
32
33
|
trigger?: "change" | "submit";
|
|
34
|
+
/** Unit suffix rendered inside the field's right edge (e.g. "px", "%"). */
|
|
35
|
+
suffix?: string;
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
export const DimensionInput = React.memo(function DimensionInput({
|
|
@@ -38,9 +41,11 @@ export const DimensionInput = React.memo(function DimensionInput({
|
|
|
38
41
|
onSetValue,
|
|
39
42
|
label,
|
|
40
43
|
size,
|
|
41
|
-
|
|
44
|
+
// Figma shows "Mixed" in numeric fields with differing values
|
|
45
|
+
placeholder = "Mixed",
|
|
42
46
|
disabled,
|
|
43
47
|
trigger = "submit",
|
|
48
|
+
suffix,
|
|
44
49
|
}: Props) {
|
|
45
50
|
const handleNudgeValue = React.useCallback(
|
|
46
51
|
(value: number) => onSetValue(value, "adjust"),
|
|
@@ -54,12 +59,17 @@ export const DimensionInput = React.memo(function DimensionInput({
|
|
|
54
59
|
|
|
55
60
|
return (
|
|
56
61
|
<LabeledField label={label} labelType="inset">
|
|
57
|
-
<InputField.Root
|
|
62
|
+
<InputField.Root
|
|
63
|
+
id={id}
|
|
64
|
+
width={size}
|
|
65
|
+
end={suffix ? <Label>{suffix}</Label> : undefined}
|
|
66
|
+
>
|
|
58
67
|
<InputField.NumberInput
|
|
59
68
|
value={value === undefined ? value : round(value, 2)}
|
|
60
69
|
placeholder={value === undefined ? placeholder : undefined}
|
|
61
70
|
onNudge={handleNudgeValue}
|
|
62
71
|
disabled={disabled}
|
|
72
|
+
style={suffix ? { paddingRight: 4 } : undefined}
|
|
63
73
|
{...(trigger === "change"
|
|
64
74
|
? { onChange: handleSetValue }
|
|
65
75
|
: { onSubmit: handleSetValue })}
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
} from "../contexts/PortalScopeContext";
|
|
22
22
|
import { cx } from "../utils/classNames";
|
|
23
23
|
import { Button, ButtonProps } from "./Button";
|
|
24
|
+
import { IconButton } from "./IconButton";
|
|
24
25
|
import { MenuProps } from "./ContextMenu";
|
|
25
26
|
import { Icon, IconName } from "./Icons";
|
|
26
27
|
import {
|
|
@@ -37,6 +38,14 @@ import {
|
|
|
37
38
|
} from "./internal/Menu";
|
|
38
39
|
import { Spacer } from "./Spacer";
|
|
39
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Carries the root menu's contentStyle into submenu popups (they render in
|
|
43
|
+
* separate portals, so CSS variable overrides don't cascade to them).
|
|
44
|
+
*/
|
|
45
|
+
const MenuContentStyleContext = React.createContext<
|
|
46
|
+
React.CSSProperties | undefined
|
|
47
|
+
>(undefined);
|
|
48
|
+
|
|
40
49
|
/* ----------------------------------------------------------------------------
|
|
41
50
|
* Root
|
|
42
51
|
* ------------------------------------------------------------------------- */
|
|
@@ -134,6 +143,7 @@ function SubmenuItem<T extends string>({
|
|
|
134
143
|
portalScopeId: string | undefined;
|
|
135
144
|
}) {
|
|
136
145
|
const [open, setOpen] = useState(false);
|
|
146
|
+
const inheritedContentStyle = React.useContext(MenuContentStyleContext);
|
|
137
147
|
|
|
138
148
|
const submenuHasCheckedItem = item.items.some(
|
|
139
149
|
(subItem) => isSelectableMenuItem(subItem) && subItem.checked
|
|
@@ -176,7 +186,10 @@ function SubmenuItem<T extends string>({
|
|
|
176
186
|
alignOffset={-5}
|
|
177
187
|
className="n-z-menu n-outline-none"
|
|
178
188
|
>
|
|
179
|
-
<Menu.Popup
|
|
189
|
+
<Menu.Popup
|
|
190
|
+
className={styles.contentStyle}
|
|
191
|
+
style={inheritedContentStyle}
|
|
192
|
+
>
|
|
180
193
|
<MenuViewport
|
|
181
194
|
items={item.items}
|
|
182
195
|
onSelect={onSelect}
|
|
@@ -347,7 +360,7 @@ const DropdownMenuRoot = forwardRefGeneric(function DropdownMenuRoot<
|
|
|
347
360
|
const isNativeButton =
|
|
348
361
|
React.isValidElement(childContent) &&
|
|
349
362
|
(childContent.type === "button" ||
|
|
350
|
-
(childContent.type === Button &&
|
|
363
|
+
((childContent.type === Button || childContent.type === IconButton) &&
|
|
351
364
|
(childContent.props as ButtonProps).as !== "a"));
|
|
352
365
|
|
|
353
366
|
return (
|
|
@@ -403,12 +416,14 @@ const DropdownMenuRoot = forwardRefGeneric(function DropdownMenuRoot<
|
|
|
403
416
|
className="n-z-menu n-outline-none"
|
|
404
417
|
>
|
|
405
418
|
<Menu.Popup className={styles.contentStyle} style={contentStyles}>
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
419
|
+
<MenuContentStyleContext.Provider value={contentStyle}>
|
|
420
|
+
{items.length === 0 ? emptyState : null}
|
|
421
|
+
<MenuViewport
|
|
422
|
+
items={items}
|
|
423
|
+
onSelect={onSelect}
|
|
424
|
+
hasCheckedItem={hasCheckedItem}
|
|
425
|
+
/>
|
|
426
|
+
</MenuContentStyleContext.Provider>
|
|
412
427
|
</Menu.Popup>
|
|
413
428
|
</Menu.Positioner>
|
|
414
429
|
</Menu.Portal>
|
|
@@ -12,6 +12,7 @@ export const InspectorContainer = memo(
|
|
|
12
12
|
header,
|
|
13
13
|
children,
|
|
14
14
|
fallback,
|
|
15
|
+
footer,
|
|
15
16
|
showDividers,
|
|
16
17
|
id,
|
|
17
18
|
className,
|
|
@@ -20,6 +21,9 @@ export const InspectorContainer = memo(
|
|
|
20
21
|
header?: React.ReactNode;
|
|
21
22
|
children?: React.ReactNode;
|
|
22
23
|
fallback?: React.ReactNode;
|
|
24
|
+
/** Pinned to the bottom edge when content is short; scrolls after the
|
|
25
|
+
* content when it overflows (pushed, not sticky). */
|
|
26
|
+
footer?: React.ReactNode;
|
|
23
27
|
showDividers?: boolean;
|
|
24
28
|
id?: string;
|
|
25
29
|
className?: string;
|
|
@@ -43,10 +47,21 @@ export const InspectorContainer = memo(
|
|
|
43
47
|
{header}
|
|
44
48
|
{children ? (
|
|
45
49
|
<ScrollArea className="n-flex-1 n-min-h-0">
|
|
46
|
-
<div
|
|
50
|
+
<div
|
|
51
|
+
className="n-flex n-flex-col n-relative"
|
|
52
|
+
// Fill the viewport so a footer can be pushed to the bottom
|
|
53
|
+
// edge when the content is short.
|
|
54
|
+
style={footer != null ? { minHeight: "100%" } : undefined}
|
|
55
|
+
>
|
|
47
56
|
{showDividers
|
|
48
57
|
? withSeparatorElements(children, <Divider />)
|
|
49
58
|
: children}
|
|
59
|
+
{footer != null && (
|
|
60
|
+
<>
|
|
61
|
+
<div className="n-flex-1" />
|
|
62
|
+
{footer}
|
|
63
|
+
</>
|
|
64
|
+
)}
|
|
50
65
|
</div>
|
|
51
66
|
</ScrollArea>
|
|
52
67
|
) : fallback ? (
|
package/src/components/List.tsx
CHANGED
|
@@ -295,9 +295,24 @@ export const List = memoGeneric(
|
|
|
295
295
|
}}
|
|
296
296
|
onKeyDown={(e: React.KeyboardEvent) => {
|
|
297
297
|
if (e.key !== "Enter" && e.key !== " ") return;
|
|
298
|
+
// A key already claimed by a global shortcut (e.g. a
|
|
299
|
+
// capture-phase keymap handler) must never fall through to
|
|
300
|
+
// row selection/rename.
|
|
301
|
+
if (e.nativeEvent.defaultPrevented) return;
|
|
298
302
|
e.preventDefault();
|
|
303
|
+
// Fully consume the key so global shortcut handlers never
|
|
304
|
+
// double-handle the same press.
|
|
305
|
+
e.stopPropagation();
|
|
299
306
|
handleSelect(id, e);
|
|
300
|
-
|
|
307
|
+
// Rename is strictly the plain Enter press — chorded Enter
|
|
308
|
+
// (or any other shortcut) must never open the row editor.
|
|
309
|
+
if (
|
|
310
|
+
e.key === "Enter" &&
|
|
311
|
+
!e.metaKey &&
|
|
312
|
+
!e.ctrlKey &&
|
|
313
|
+
!e.altKey &&
|
|
314
|
+
!e.shiftKey
|
|
315
|
+
) {
|
|
301
316
|
setRenamingId(id);
|
|
302
317
|
}
|
|
303
318
|
}}
|
|
@@ -324,7 +339,9 @@ export const List = memoGeneric(
|
|
|
324
339
|
{thumbnail && (
|
|
325
340
|
<div
|
|
326
341
|
className={cx(
|
|
327
|
-
|
|
342
|
+
// outline-none: focusable-by-click (tabIndex -1)
|
|
343
|
+
// wrapper must never show the UA focus outline.
|
|
344
|
+
"n-overflow-hidden n-flex-none n-flex n-items-center n-justify-center n-outline-none",
|
|
328
345
|
thumbnailSize === "auto" && thumbnailSizeMap[size],
|
|
329
346
|
isSelected && "n-text-selected-list-item-text",
|
|
330
347
|
roundedMap[size]
|
|
@@ -337,7 +354,12 @@ export const List = memoGeneric(
|
|
|
337
354
|
<div className="n-flex n-flex-col n-flex-1 n-w-0">
|
|
338
355
|
<div className="n-flex n-items-center n-min-h-input-height n-flex-1 n-gap-2">
|
|
339
356
|
<div
|
|
340
|
-
|
|
357
|
+
// outline-none: clicking the title focuses this
|
|
358
|
+
// tabIndex -1 wrapper, and a later keydown can promote
|
|
359
|
+
// it to :focus-visible — without this, that painted the
|
|
360
|
+
// UA's black outline around the row title (reading as a
|
|
361
|
+
// stuck rename editor). Focus styling lives on the row.
|
|
362
|
+
className="n-flex-1 n-w-0 n-flex n-items-center n-outline-none"
|
|
341
363
|
tabIndex={-1}
|
|
342
364
|
>
|
|
343
365
|
{isRenaming ? (
|