@noya-app/noya-designsystem 0.1.93 → 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 +10 -10
- package/CHANGELOG.md +20 -0
- package/dist/index.css +1 -1
- package/dist/index.d.mts +167 -95
- package/dist/index.d.ts +167 -95
- package/dist/index.js +12689 -12005
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +13912 -13233
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -5
- 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 +37 -9
- 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/Toast.tsx +2 -2
- 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__/DropdownMenu.test.ts +19 -0
- package/src/components/__tests__/Toast.test.tsx +138 -0
- 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",
|
|
@@ -25,15 +25,15 @@
|
|
|
25
25
|
"lint": "eslint . --max-warnings 0"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@base-ui/react": "1.
|
|
28
|
+
"@base-ui/react": "1.7.0",
|
|
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}
|
|
@@ -236,12 +249,16 @@ function MenuViewport<T extends string>({
|
|
|
236
249
|
}
|
|
237
250
|
return null;
|
|
238
251
|
|
|
239
|
-
default:
|
|
252
|
+
default: {
|
|
253
|
+
const accessibilityProps = getCheckedMenuItemAccessibilityProps(
|
|
254
|
+
item.checked
|
|
255
|
+
);
|
|
240
256
|
return (
|
|
241
257
|
<div key={item.value} className="n-px-1">
|
|
242
258
|
<Menu.Item
|
|
243
259
|
disabled={item.disabled}
|
|
244
260
|
className={itemStyle}
|
|
261
|
+
{...accessibilityProps}
|
|
245
262
|
onClick={() => {
|
|
246
263
|
if (onSelect && item.value) {
|
|
247
264
|
// Delay onSelect to allow menu to close and focus to settle
|
|
@@ -262,12 +279,21 @@ function MenuViewport<T extends string>({
|
|
|
262
279
|
</Menu.Item>
|
|
263
280
|
</div>
|
|
264
281
|
);
|
|
282
|
+
}
|
|
265
283
|
}
|
|
266
284
|
})}
|
|
267
285
|
</>
|
|
268
286
|
);
|
|
269
287
|
}
|
|
270
288
|
|
|
289
|
+
export function getCheckedMenuItemAccessibilityProps(
|
|
290
|
+
checked: boolean | undefined
|
|
291
|
+
) {
|
|
292
|
+
return checked === undefined
|
|
293
|
+
? {}
|
|
294
|
+
: ({ role: "menuitemcheckbox", "aria-checked": checked } as const);
|
|
295
|
+
}
|
|
296
|
+
|
|
271
297
|
const DropdownMenuRoot = forwardRefGeneric(function DropdownMenuRoot<
|
|
272
298
|
T extends string,
|
|
273
299
|
>(props: DropdownMenuProps<T>, forwardedRef: ForwardedRef<HTMLElement>) {
|
|
@@ -334,7 +360,7 @@ const DropdownMenuRoot = forwardRefGeneric(function DropdownMenuRoot<
|
|
|
334
360
|
const isNativeButton =
|
|
335
361
|
React.isValidElement(childContent) &&
|
|
336
362
|
(childContent.type === "button" ||
|
|
337
|
-
(childContent.type === Button &&
|
|
363
|
+
((childContent.type === Button || childContent.type === IconButton) &&
|
|
338
364
|
(childContent.props as ButtonProps).as !== "a"));
|
|
339
365
|
|
|
340
366
|
return (
|
|
@@ -390,12 +416,14 @@ const DropdownMenuRoot = forwardRefGeneric(function DropdownMenuRoot<
|
|
|
390
416
|
className="n-z-menu n-outline-none"
|
|
391
417
|
>
|
|
392
418
|
<Menu.Popup className={styles.contentStyle} style={contentStyles}>
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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>
|
|
399
427
|
</Menu.Popup>
|
|
400
428
|
</Menu.Positioner>
|
|
401
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 ? (
|