@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/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 ? (
|
|
@@ -31,6 +31,8 @@ interface Props {
|
|
|
31
31
|
style?: React.CSSProperties;
|
|
32
32
|
portalContainer?: HTMLElement | null;
|
|
33
33
|
side?: "top" | "bottom" | "left" | "right";
|
|
34
|
+
/** Cross-axis alignment against the anchor. @default "center" */
|
|
35
|
+
align?: "start" | "center" | "end";
|
|
34
36
|
initialFocus?: React.ComponentProps<
|
|
35
37
|
typeof PopoverPrimitive.Popup
|
|
36
38
|
>["initialFocus"];
|
|
@@ -39,6 +41,13 @@ interface Props {
|
|
|
39
41
|
* Return false to prevent the popover from closing.
|
|
40
42
|
*/
|
|
41
43
|
shouldCloseOnInteractOutside?: (event: PointerEvent) => boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Whether the trigger ultimately renders a native `<button>`. Pass false
|
|
46
|
+
* for non-button triggers (e.g. an input container) so Base UI doesn't log
|
|
47
|
+
* a nativeButton mismatch console error.
|
|
48
|
+
* @default true
|
|
49
|
+
*/
|
|
50
|
+
nativeButtonTrigger?: boolean;
|
|
42
51
|
}
|
|
43
52
|
|
|
44
53
|
export const popoverStyle = {
|
|
@@ -54,6 +63,7 @@ export function Popover({
|
|
|
54
63
|
closable,
|
|
55
64
|
open,
|
|
56
65
|
side,
|
|
66
|
+
align = "center",
|
|
57
67
|
sideOffset = 4,
|
|
58
68
|
showArrow = true,
|
|
59
69
|
onOpenChange,
|
|
@@ -63,6 +73,7 @@ export function Popover({
|
|
|
63
73
|
style,
|
|
64
74
|
initialFocus,
|
|
65
75
|
shouldCloseOnInteractOutside,
|
|
76
|
+
nativeButtonTrigger = true,
|
|
66
77
|
}: Props) {
|
|
67
78
|
const defaultId = useId();
|
|
68
79
|
const portalScopeId = usePortalScopeId();
|
|
@@ -91,6 +102,7 @@ export function Popover({
|
|
|
91
102
|
return (
|
|
92
103
|
<PopoverPrimitive.Root open={isOpen} onOpenChange={handleOpenChange}>
|
|
93
104
|
<PopoverPrimitive.Trigger
|
|
105
|
+
nativeButton={nativeButtonTrigger}
|
|
94
106
|
render={(triggerProps) => {
|
|
95
107
|
const element = React.isValidElement(trigger) ? (
|
|
96
108
|
trigger
|
|
@@ -122,7 +134,7 @@ export function Popover({
|
|
|
122
134
|
{...portalScopeProps(portalScopeId)}
|
|
123
135
|
anchor={anchorElement}
|
|
124
136
|
side={side}
|
|
125
|
-
align=
|
|
137
|
+
align={align}
|
|
126
138
|
sideOffset={sideOffset}
|
|
127
139
|
collisionPadding={8}
|
|
128
140
|
arrowPadding={2}
|
|
@@ -6,7 +6,6 @@ import {
|
|
|
6
6
|
IconName,
|
|
7
7
|
IndentContext,
|
|
8
8
|
INPUT_HEIGHT,
|
|
9
|
-
InspectorPrimitives,
|
|
10
9
|
renderIcon,
|
|
11
10
|
Spacer,
|
|
12
11
|
Text,
|
|
@@ -14,6 +13,37 @@ import {
|
|
|
14
13
|
} from "@noya-app/noya-designsystem";
|
|
15
14
|
import { clientStorage, usePersistentStateString } from "@noya-app/react-utils";
|
|
16
15
|
import React, { CSSProperties, memo, useMemo } from "react";
|
|
16
|
+
import { cx } from "../utils/classNames";
|
|
17
|
+
|
|
18
|
+
// Local copies of the bare section-header shells (the shared set moved to
|
|
19
|
+
// @noya-app/inspector as `FieldLayout`, which this package cannot depend on
|
|
20
|
+
// without a cycle). Keep the class lists in sync with FieldLayout.
|
|
21
|
+
const SectionHeaderShell = ({
|
|
22
|
+
className,
|
|
23
|
+
...props
|
|
24
|
+
}: React.HTMLAttributes<HTMLDivElement>) => (
|
|
25
|
+
<div className={cx(`n-flex n-items-center`, className)} {...props} />
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const TitleShell = ({
|
|
29
|
+
className,
|
|
30
|
+
$textStyle,
|
|
31
|
+
...props
|
|
32
|
+
}: React.HTMLAttributes<HTMLDivElement> & {
|
|
33
|
+
$textStyle?: "small" | "heading5" | "heading4" | "heading3";
|
|
34
|
+
}) => (
|
|
35
|
+
<div
|
|
36
|
+
className={cx(
|
|
37
|
+
`n-flex n-select-none ${
|
|
38
|
+
$textStyle
|
|
39
|
+
? `${textStyles[$textStyle]} n-text-text`
|
|
40
|
+
: "n-font-sans n-text-label n-text-text-muted n-font-semibold"
|
|
41
|
+
} `,
|
|
42
|
+
className
|
|
43
|
+
)}
|
|
44
|
+
{...props}
|
|
45
|
+
/>
|
|
46
|
+
);
|
|
17
47
|
|
|
18
48
|
const titleIconStyle: CSSProperties = {
|
|
19
49
|
marginRight: "6px",
|
|
@@ -57,19 +87,13 @@ export function SectionHeader({
|
|
|
57
87
|
);
|
|
58
88
|
|
|
59
89
|
const titleElement = title ? (
|
|
60
|
-
<
|
|
61
|
-
$textStyle={titleTextStyle}
|
|
62
|
-
className={titleClassName}
|
|
63
|
-
>
|
|
90
|
+
<TitleShell $textStyle={titleTextStyle} className={titleClassName}>
|
|
64
91
|
{title}
|
|
65
|
-
</
|
|
92
|
+
</TitleShell>
|
|
66
93
|
) : null;
|
|
67
94
|
|
|
68
95
|
return (
|
|
69
|
-
<
|
|
70
|
-
style={headerStyle}
|
|
71
|
-
className={className}
|
|
72
|
-
>
|
|
96
|
+
<SectionHeaderShell style={headerStyle} className={className}>
|
|
73
97
|
{titleIcon && (
|
|
74
98
|
<div style={titleIconStyle} className={textStyles[titleTextStyle]}>
|
|
75
99
|
{"\u200B"}
|
|
@@ -98,7 +122,7 @@ export function SectionHeader({
|
|
|
98
122
|
</Text>
|
|
99
123
|
)}
|
|
100
124
|
{right}
|
|
101
|
-
</
|
|
125
|
+
</SectionHeaderShell>
|
|
102
126
|
);
|
|
103
127
|
}
|
|
104
128
|
|
|
@@ -30,7 +30,11 @@ const EXPANSION_THRESHOLD_PX = 20;
|
|
|
30
30
|
// Debounce delay (ms) before re-enabling indicator transition after resize stops
|
|
31
31
|
const RESIZE_SETTLE_MS = 150;
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
/**
|
|
34
|
+
* "primaryTint" is Figma-UI3-style: a light primary-tinted active segment
|
|
35
|
+
* with a primary-colored glyph, instead of the solid primary fill.
|
|
36
|
+
*/
|
|
37
|
+
type SegmentedControlColorScheme = "primary" | "secondary" | "primaryTint";
|
|
34
38
|
type SegmentedControlVariant = "default" | "tabs" | "buttons";
|
|
35
39
|
|
|
36
40
|
type SegmentedControlContextValue = {
|
|
@@ -187,12 +191,16 @@ const SegmentedControlItem = forwardRef(function SegmentedControlItem<
|
|
|
187
191
|
? // default variant: only text color, background handled by sliding indicator
|
|
188
192
|
colorScheme === "secondary" || colorScheme === "primary"
|
|
189
193
|
? "data-[pressed]:n-text-white"
|
|
190
|
-
: "
|
|
194
|
+
: colorScheme === "primaryTint"
|
|
195
|
+
? "data-[pressed]:n-text-primary"
|
|
196
|
+
: "data-[pressed]:n-text-segmented-control-item-active-text"
|
|
191
197
|
: colorScheme === "secondary"
|
|
192
198
|
? "data-[pressed]:n-bg-secondary data-[pressed]:n-text-white"
|
|
193
199
|
: colorScheme === "primary"
|
|
194
200
|
? "data-[pressed]:n-bg-primary data-[pressed]:n-text-white"
|
|
195
|
-
:
|
|
201
|
+
: colorScheme === "primaryTint"
|
|
202
|
+
? "data-[pressed]:n-bg-primary-pastel data-[pressed]:n-text-primary"
|
|
203
|
+
: "data-[pressed]:n-bg-segmented-control-item-active-background data-[pressed]:n-text-segmented-control-item-active-text",
|
|
196
204
|
"focus-visible:n-z-interactable",
|
|
197
205
|
// checked default (all colorSchemes) - hide separators near selected
|
|
198
206
|
variant === "default" &&
|
|
@@ -485,7 +493,9 @@ export const SegmentedControl = memoGeneric(function SegmentedControl<
|
|
|
485
493
|
? "n-bg-secondary"
|
|
486
494
|
: colorScheme === "primary"
|
|
487
495
|
? "n-bg-primary"
|
|
488
|
-
: "
|
|
496
|
+
: colorScheme === "primaryTint"
|
|
497
|
+
? "n-bg-primary-pastel"
|
|
498
|
+
: "n-bg-segmented-control-item-active-background";
|
|
489
499
|
|
|
490
500
|
return (
|
|
491
501
|
<SegmentedControlContext.Provider value={contextValue}>
|
package/src/components/Toast.tsx
CHANGED
|
@@ -39,7 +39,7 @@ function ToastContent({ toast }: { toast: ToastObject }) {
|
|
|
39
39
|
return (
|
|
40
40
|
<ToastPrimitive.Root
|
|
41
41
|
toast={toast}
|
|
42
|
-
className="n-relative n-flex n-items-start n-gap-2 n-rounded n-text-sm n-bg-popover-background n-shadow-popover n-text-text-muted n-px-3 n-py-2.5 n-pr-10"
|
|
42
|
+
className="n-pointer-events-auto n-relative n-flex n-items-start n-gap-2 n-rounded n-text-sm n-bg-popover-background n-shadow-popover n-text-text-muted n-px-3 n-py-2.5 n-pr-10"
|
|
43
43
|
>
|
|
44
44
|
<div className="n-flex n-flex-col n-gap-0.5 n-min-w-0">
|
|
45
45
|
{toast.data?.title && (
|
|
@@ -87,7 +87,7 @@ function ToastProviderInner({ children }: { children: React.ReactNode }) {
|
|
|
87
87
|
return (
|
|
88
88
|
<ToastContext.Provider value={{ showToast }}>
|
|
89
89
|
{children}
|
|
90
|
-
<ToastPrimitive.Viewport className="n-fixed n-bottom-0 n-right-0 n-flex n-flex-col n-p-5 n-gap-2.5 n-min-w-[200px] n-max-w-[100vw] n-m-0 n-list-none n-z-[2147483647] n-outline-none">
|
|
90
|
+
<ToastPrimitive.Viewport className="n-pointer-events-none n-fixed n-bottom-0 n-right-0 n-flex n-flex-col n-p-5 n-gap-2.5 n-min-w-[200px] n-max-w-[100vw] n-m-0 n-list-none n-z-[2147483647] n-outline-none">
|
|
91
91
|
{toastManager.toasts.map((toast) => (
|
|
92
92
|
<ToastContent key={toast.id} toast={toast} />
|
|
93
93
|
))}
|
|
@@ -3,6 +3,16 @@
|
|
|
3
3
|
import React, { forwardRef, HTMLAttributes } from "react";
|
|
4
4
|
import { cx } from "../utils/classNames";
|
|
5
5
|
import { toolbarTransparentStyle } from "./BaseToolbar";
|
|
6
|
+
import { ToolbarItems } from "./ToolbarItems";
|
|
7
|
+
|
|
8
|
+
export type {
|
|
9
|
+
ToolbarGroupItem,
|
|
10
|
+
ToolbarItem,
|
|
11
|
+
ToolbarItemsProps,
|
|
12
|
+
ToolbarPopoverItem,
|
|
13
|
+
ToolbarSeparatorItem,
|
|
14
|
+
} from "./ToolbarItems";
|
|
15
|
+
export { ToolbarItems } from "./ToolbarItems";
|
|
6
16
|
|
|
7
17
|
export type ToolbarSize = "small" | "medium";
|
|
8
18
|
|
|
@@ -14,7 +24,7 @@ export interface ToolbarProps extends HTMLAttributes<HTMLDivElement> {
|
|
|
14
24
|
size?: ToolbarSize;
|
|
15
25
|
}
|
|
16
26
|
|
|
17
|
-
|
|
27
|
+
const ToolbarRoot = forwardRef<HTMLDivElement, ToolbarProps>(
|
|
18
28
|
function Toolbar(
|
|
19
29
|
{ children, className, role = "toolbar", size = "medium", style, ...props },
|
|
20
30
|
ref
|
|
@@ -36,3 +46,8 @@ export const Toolbar = forwardRef<HTMLDivElement, ToolbarProps>(
|
|
|
36
46
|
);
|
|
37
47
|
}
|
|
38
48
|
);
|
|
49
|
+
|
|
50
|
+
/** The floating toolbar surface. `Toolbar.Items` renders a declarative
|
|
51
|
+
* `ToolbarItem[]` (icon buttons, separators, split-button groups, popovers)
|
|
52
|
+
* inside it. */
|
|
53
|
+
export const Toolbar = Object.assign(ToolbarRoot, { Items: ToolbarItems });
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { ReactNode, useEffect, useState } from "react";
|
|
4
|
+
import { cx } from "../utils/classNames";
|
|
5
|
+
import { ToolbarShortcut } from "./AppToolbar";
|
|
6
|
+
import { DividerVertical } from "./Divider";
|
|
7
|
+
import { DropdownMenu } from "./DropdownMenu";
|
|
8
|
+
import { renderIcon } from "./Icons";
|
|
9
|
+
import { MenuItemIcon, SelectableMenuItem } from "./internal/Menu";
|
|
10
|
+
import { Popover } from "./Popover";
|
|
11
|
+
import { Tooltip } from "./Tooltip";
|
|
12
|
+
|
|
13
|
+
export type ToolbarSeparatorItem = { type: "separator" };
|
|
14
|
+
|
|
15
|
+
/** A split-button slot: the primary button activates the group's
|
|
16
|
+
* representative tool and the adjacent chevron opens a flyout menu listing
|
|
17
|
+
* every item in the group. */
|
|
18
|
+
export type ToolbarGroupItem<T extends string> = {
|
|
19
|
+
type: "group";
|
|
20
|
+
id: string;
|
|
21
|
+
items: SelectableMenuItem<T>[];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** A button (icon or text) that opens a Popover with arbitrary content. */
|
|
25
|
+
export type ToolbarPopoverItem = {
|
|
26
|
+
type: "popover";
|
|
27
|
+
id: string;
|
|
28
|
+
title: ReactNode;
|
|
29
|
+
icon?: MenuItemIcon;
|
|
30
|
+
content: ReactNode | ((close: () => void) => ReactNode);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A declarative item for `ToolbarItems` (`Toolbar.Items`). Selectable items
|
|
35
|
+
* reuse the menu system's `SelectableMenuItem`, so the same definitions can
|
|
36
|
+
* feed a toolbar, a dropdown, or a context menu.
|
|
37
|
+
*/
|
|
38
|
+
export type ToolbarItem<T extends string> =
|
|
39
|
+
| SelectableMenuItem<T>
|
|
40
|
+
| ToolbarSeparatorItem
|
|
41
|
+
| ToolbarGroupItem<T>
|
|
42
|
+
| ToolbarPopoverItem;
|
|
43
|
+
|
|
44
|
+
type PopupSide = "top" | "bottom" | "left" | "right";
|
|
45
|
+
|
|
46
|
+
export interface ToolbarItemsProps<T extends string> {
|
|
47
|
+
items: ToolbarItem<T>[];
|
|
48
|
+
onSelect: (value: T) => void;
|
|
49
|
+
/**
|
|
50
|
+
* Side the group flyouts and popovers open on. Bottom-of-screen toolbars
|
|
51
|
+
* want "top".
|
|
52
|
+
* @default "top"
|
|
53
|
+
*/
|
|
54
|
+
side?: PopupSide;
|
|
55
|
+
/** @default "top" */
|
|
56
|
+
tooltipSide?: PopupSide;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const POPUP_OFFSET = 10;
|
|
60
|
+
|
|
61
|
+
function labelForTitle(title: ReactNode): string | undefined {
|
|
62
|
+
return typeof title === "string" ? title : undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Square icon button styled by the standard light tokens: transparent on the
|
|
66
|
+
* toolbar surface, `input-background-light` on hover, and the solid primary
|
|
67
|
+
* square with a white glyph when checked (the design system's active-tool
|
|
68
|
+
* treatment). */
|
|
69
|
+
function ToolbarItemButton<T extends string>({
|
|
70
|
+
item,
|
|
71
|
+
tooltipSide,
|
|
72
|
+
onClick,
|
|
73
|
+
}: {
|
|
74
|
+
item: SelectableMenuItem<T>;
|
|
75
|
+
tooltipSide: PopupSide;
|
|
76
|
+
onClick: () => void;
|
|
77
|
+
}) {
|
|
78
|
+
const label = labelForTitle(item.title);
|
|
79
|
+
|
|
80
|
+
const button = (
|
|
81
|
+
<button
|
|
82
|
+
type="button"
|
|
83
|
+
aria-label={label}
|
|
84
|
+
aria-pressed={item.checked ?? false}
|
|
85
|
+
disabled={item.disabled}
|
|
86
|
+
onClick={onClick}
|
|
87
|
+
className={cx(
|
|
88
|
+
"n-flex n-h-input-height n-flex-none n-cursor-default n-items-center n-justify-center n-rounded n-border-0 n-outline-none n-transition-colors focus-visible:n-ring-2 focus-visible:n-ring-primary",
|
|
89
|
+
// Icon items are square at the design system's floating-toolbar
|
|
90
|
+
// control size (27px, see Button variant="floating" and
|
|
91
|
+
// INPUT_HEIGHT); text titles get auto width with the same padding
|
|
92
|
+
// and type style as the popover text trigger.
|
|
93
|
+
item.icon
|
|
94
|
+
? "n-w-input-height n-p-0"
|
|
95
|
+
: "n-px-2 n-py-0 n-font-sans n-text-[12px] n-font-medium",
|
|
96
|
+
item.checked
|
|
97
|
+
? "n-bg-primary n-text-white"
|
|
98
|
+
: "n-bg-transparent n-text-text hover:n-bg-input-background-light",
|
|
99
|
+
item.disabled && "n-pointer-events-none n-opacity-30"
|
|
100
|
+
)}
|
|
101
|
+
>
|
|
102
|
+
{item.icon ? renderIcon(item.icon) : item.title}
|
|
103
|
+
</button>
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
if (item.disabled) return button;
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<Tooltip
|
|
110
|
+
side={tooltipSide}
|
|
111
|
+
sideOffset={POPUP_OFFSET}
|
|
112
|
+
content={
|
|
113
|
+
item.tooltip ??
|
|
114
|
+
(item.shortcut ? (
|
|
115
|
+
<ToolbarShortcut shortcut={item.shortcut} label={item.title} />
|
|
116
|
+
) : (
|
|
117
|
+
item.title
|
|
118
|
+
))
|
|
119
|
+
}
|
|
120
|
+
>
|
|
121
|
+
{button}
|
|
122
|
+
</Tooltip>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Returns a plain <button> element (not a component) so DropdownMenu's
|
|
127
|
+
* native-button trigger detection works. */
|
|
128
|
+
function renderGroupChevron({
|
|
129
|
+
label,
|
|
130
|
+
open,
|
|
131
|
+
disabled,
|
|
132
|
+
}: {
|
|
133
|
+
label: string;
|
|
134
|
+
open: boolean;
|
|
135
|
+
disabled?: boolean;
|
|
136
|
+
}) {
|
|
137
|
+
return (
|
|
138
|
+
<button
|
|
139
|
+
type="button"
|
|
140
|
+
aria-label={label}
|
|
141
|
+
disabled={disabled}
|
|
142
|
+
className={cx(
|
|
143
|
+
"n--ml-0.5 n-flex n-h-input-height n-w-3.5 n-flex-none n-cursor-default n-items-center n-justify-center n-rounded n-border-0 n-p-0 n-outline-none n-transition-colors focus-visible:n-ring-2 focus-visible:n-ring-primary",
|
|
144
|
+
open
|
|
145
|
+
? "n-bg-input-background-light n-text-text"
|
|
146
|
+
: "n-bg-transparent n-text-text-muted hover:n-bg-input-background-light hover:n-text-text",
|
|
147
|
+
disabled && "n-pointer-events-none n-opacity-30"
|
|
148
|
+
)}
|
|
149
|
+
>
|
|
150
|
+
{renderIcon("DropdownChevronIcon")}
|
|
151
|
+
</button>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The split-button group: primary button shows the representative item
|
|
156
|
+
* (checked child > last-used child > first child) and the narrow chevron
|
|
157
|
+
* opens the group's items in a dropdown flyout. */
|
|
158
|
+
function ToolbarItemGroup<T extends string>({
|
|
159
|
+
group,
|
|
160
|
+
onSelect,
|
|
161
|
+
side,
|
|
162
|
+
tooltipSide,
|
|
163
|
+
}: {
|
|
164
|
+
group: ToolbarGroupItem<T>;
|
|
165
|
+
onSelect: (value: T) => void;
|
|
166
|
+
side: PopupSide;
|
|
167
|
+
tooltipSide: PopupSide;
|
|
168
|
+
}) {
|
|
169
|
+
const { items } = group;
|
|
170
|
+
const checkedItem = items.find((item) => item.checked);
|
|
171
|
+
const [lastUsed, setLastUsed] = useState<T | undefined>(undefined);
|
|
172
|
+
|
|
173
|
+
const checkedValue = checkedItem?.value;
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
if (checkedValue !== undefined) setLastUsed(checkedValue);
|
|
176
|
+
}, [checkedValue]);
|
|
177
|
+
|
|
178
|
+
const representative =
|
|
179
|
+
checkedItem ?? items.find((item) => item.value === lastUsed) ?? items[0];
|
|
180
|
+
|
|
181
|
+
if (!representative) return null;
|
|
182
|
+
|
|
183
|
+
const allDisabled = items.every((item) => item.disabled);
|
|
184
|
+
const label = labelForTitle(representative.title);
|
|
185
|
+
const chevronLabel = `${label ?? "More"} tools`;
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
<div className="n-flex n-items-center">
|
|
189
|
+
<ToolbarItemButton
|
|
190
|
+
item={{ ...representative, checked: checkedItem !== undefined }}
|
|
191
|
+
tooltipSide={tooltipSide}
|
|
192
|
+
onClick={() => {
|
|
193
|
+
setLastUsed(representative.value);
|
|
194
|
+
onSelect(representative.value);
|
|
195
|
+
}}
|
|
196
|
+
/>
|
|
197
|
+
{allDisabled ? (
|
|
198
|
+
// Not wrapped in DropdownMenu (whose trigger would re-enable the
|
|
199
|
+
// native button) — a fully disabled group has nothing to open.
|
|
200
|
+
renderGroupChevron({
|
|
201
|
+
label: chevronLabel,
|
|
202
|
+
open: false,
|
|
203
|
+
disabled: true,
|
|
204
|
+
})
|
|
205
|
+
) : (
|
|
206
|
+
<DropdownMenu
|
|
207
|
+
items={items}
|
|
208
|
+
side={side}
|
|
209
|
+
sideOffset={POPUP_OFFSET + 4}
|
|
210
|
+
// Pin the flyout's left edge to the group instead of letting it
|
|
211
|
+
// float centered under the narrow chevron (27px button + 2px gap).
|
|
212
|
+
align="start"
|
|
213
|
+
alignOffset={-29}
|
|
214
|
+
shouldBindKeyboardShortcuts={false}
|
|
215
|
+
onSelect={(value) => {
|
|
216
|
+
setLastUsed(value);
|
|
217
|
+
onSelect(value);
|
|
218
|
+
}}
|
|
219
|
+
>
|
|
220
|
+
{({ open }) =>
|
|
221
|
+
renderGroupChevron({
|
|
222
|
+
label: chevronLabel,
|
|
223
|
+
open,
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
</DropdownMenu>
|
|
227
|
+
)}
|
|
228
|
+
</div>
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function ToolbarItemPopover({
|
|
233
|
+
item,
|
|
234
|
+
side,
|
|
235
|
+
}: {
|
|
236
|
+
item: ToolbarPopoverItem;
|
|
237
|
+
side: PopupSide;
|
|
238
|
+
}) {
|
|
239
|
+
const [open, setOpen] = useState(false);
|
|
240
|
+
const label = labelForTitle(item.title);
|
|
241
|
+
|
|
242
|
+
const trigger = (
|
|
243
|
+
<button
|
|
244
|
+
type="button"
|
|
245
|
+
aria-label={label}
|
|
246
|
+
className={cx(
|
|
247
|
+
"n-flex n-h-input-height n-flex-none n-cursor-default n-items-center n-justify-center n-rounded n-border-0 n-outline-none n-transition-colors focus-visible:n-ring-2 focus-visible:n-ring-primary",
|
|
248
|
+
item.icon
|
|
249
|
+
? "n-w-input-height n-p-0"
|
|
250
|
+
: "n-px-2 n-font-sans n-text-[12px] n-font-medium",
|
|
251
|
+
open
|
|
252
|
+
? "n-bg-input-background-light n-text-text"
|
|
253
|
+
: "n-bg-transparent n-text-text hover:n-bg-input-background-light"
|
|
254
|
+
)}
|
|
255
|
+
>
|
|
256
|
+
{item.icon ? renderIcon(item.icon) : item.title}
|
|
257
|
+
</button>
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
return (
|
|
261
|
+
<Popover
|
|
262
|
+
open={open}
|
|
263
|
+
onOpenChange={setOpen}
|
|
264
|
+
side={side}
|
|
265
|
+
showArrow={false}
|
|
266
|
+
sideOffset={POPUP_OFFSET}
|
|
267
|
+
trigger={trigger}
|
|
268
|
+
>
|
|
269
|
+
{typeof item.content === "function"
|
|
270
|
+
? item.content(() => setOpen(false))
|
|
271
|
+
: item.content}
|
|
272
|
+
</Popover>
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Renders a flat list of `ToolbarItem`s inside a `Toolbar`, styled entirely
|
|
278
|
+
* by the standard (light) theme tokens. Also available as `Toolbar.Items`.
|
|
279
|
+
*/
|
|
280
|
+
export function ToolbarItems<T extends string>({
|
|
281
|
+
items,
|
|
282
|
+
onSelect,
|
|
283
|
+
side = "top",
|
|
284
|
+
tooltipSide = "top",
|
|
285
|
+
}: ToolbarItemsProps<T>) {
|
|
286
|
+
return (
|
|
287
|
+
<>
|
|
288
|
+
{items.map((item, index) => {
|
|
289
|
+
if (item.type === "separator") {
|
|
290
|
+
return <DividerVertical key={`separator-${index}`} />;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (item.type === "group") {
|
|
294
|
+
return (
|
|
295
|
+
<ToolbarItemGroup
|
|
296
|
+
key={item.id}
|
|
297
|
+
group={item}
|
|
298
|
+
onSelect={onSelect}
|
|
299
|
+
side={side}
|
|
300
|
+
tooltipSide={tooltipSide}
|
|
301
|
+
/>
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (item.type === "popover") {
|
|
306
|
+
return (
|
|
307
|
+
<ToolbarItemPopover key={item.id} item={item} side={side} />
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return (
|
|
312
|
+
<ToolbarItemButton
|
|
313
|
+
key={item.value}
|
|
314
|
+
item={item}
|
|
315
|
+
tooltipSide={tooltipSide}
|
|
316
|
+
onClick={() => onSelect(item.value)}
|
|
317
|
+
/>
|
|
318
|
+
);
|
|
319
|
+
})}
|
|
320
|
+
</>
|
|
321
|
+
);
|
|
322
|
+
}
|
|
@@ -15,6 +15,9 @@ interface Props {
|
|
|
15
15
|
sideOffset?: number;
|
|
16
16
|
side?: "top" | "bottom" | "left" | "right";
|
|
17
17
|
portalContainer?: HTMLElement | null;
|
|
18
|
+
/** Overrides the popup chrome entirely (e.g. a dark chip over a dark
|
|
19
|
+
* toolbar). The default light surface is used when omitted. */
|
|
20
|
+
contentClassName?: string;
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
export const Tooltip = React.memo(function Tooltip({
|
|
@@ -23,6 +26,7 @@ export const Tooltip = React.memo(function Tooltip({
|
|
|
23
26
|
sideOffset = 2,
|
|
24
27
|
side = "bottom",
|
|
25
28
|
portalContainer,
|
|
29
|
+
contentClassName,
|
|
26
30
|
}: Props) {
|
|
27
31
|
const portalScopeId = usePortalScopeId();
|
|
28
32
|
|
|
@@ -44,7 +48,10 @@ export const Tooltip = React.memo(function Tooltip({
|
|
|
44
48
|
className="n-z-menu"
|
|
45
49
|
>
|
|
46
50
|
<TooltipPrimitive.Popup
|
|
47
|
-
className={
|
|
51
|
+
className={
|
|
52
|
+
contentClassName ??
|
|
53
|
+
`${textStyles.small} n-text-text n-rounded-sm n-p-2 n-bg-popover-background n-shadow-tooltip n-z-menu`
|
|
54
|
+
}
|
|
48
55
|
>
|
|
49
56
|
{content}
|
|
50
57
|
</TooltipPrimitive.Popup>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { getCheckedMenuItemAccessibilityProps } from "../DropdownMenu";
|
|
3
|
+
|
|
4
|
+
describe("DropdownMenu checked items", () => {
|
|
5
|
+
test("uses checkbox menu semantics when checked state is provided", () => {
|
|
6
|
+
expect(getCheckedMenuItemAccessibilityProps(false)).toEqual({
|
|
7
|
+
role: "menuitemcheckbox",
|
|
8
|
+
"aria-checked": false,
|
|
9
|
+
});
|
|
10
|
+
expect(getCheckedMenuItemAccessibilityProps(true)).toEqual({
|
|
11
|
+
role: "menuitemcheckbox",
|
|
12
|
+
"aria-checked": true,
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("leaves ordinary menu items unchanged", () => {
|
|
17
|
+
expect(getCheckedMenuItemAccessibilityProps(undefined)).toEqual({});
|
|
18
|
+
});
|
|
19
|
+
});
|