@bug-on/md3-react 3.0.1 → 3.0.3
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 +42 -42
- package/CHANGELOG.md +10 -0
- package/dist/index.css +107 -0
- package/dist/index.d.mts +1491 -1053
- package/dist/index.d.ts +1491 -1053
- package/dist/index.js +4457 -3156
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +4394 -3109
- package/dist/index.mjs.map +1 -1
- package/package.json +11 -6
- package/scripts/copy-assets.js +113 -8
- package/src/index.ts +66 -18
- package/src/test/button.test.tsx +1 -1
- package/src/ui/app-bar/app-bar.tokens.ts +5 -24
- package/src/ui/badge.tsx +2 -1
- package/src/ui/buttons/button/button-tokens.ts +118 -0
- package/src/ui/{button.test.tsx → buttons/button/button.test.tsx} +0 -21
- package/src/ui/buttons/button/button.tsx +381 -0
- package/src/ui/buttons/button/index.ts +3 -0
- package/src/ui/buttons/button/types.ts +90 -0
- package/src/ui/buttons/button-group/button-group-defaults.ts +95 -0
- package/src/ui/buttons/button-group/button-group-tokens.ts +20 -0
- package/src/ui/{button-group.test.tsx → buttons/button-group/button-group.test.tsx} +9 -10
- package/src/ui/buttons/button-group/button-group.tsx +699 -0
- package/src/ui/buttons/button-group/index.ts +8 -0
- package/src/ui/buttons/button-group/types.ts +77 -0
- package/src/ui/{fab.tsx → buttons/fabs/fab/fab.tsx} +6 -6
- package/src/ui/buttons/fabs/fab/index.ts +1 -0
- package/src/ui/{fab-menu.tsx → buttons/fabs/fab-menu/fab-menu.tsx} +7 -4
- package/src/ui/buttons/fabs/fab-menu/index.ts +1 -0
- package/src/ui/buttons/fabs/index.ts +2 -0
- package/src/ui/{icon-button.tsx → buttons/icon-button/icon-button.tsx} +6 -6
- package/src/ui/buttons/icon-button/index.ts +1 -0
- package/src/ui/buttons/index.ts +4 -0
- package/src/ui/code-block.tsx +1 -1
- package/src/ui/dialog.tsx +4 -7
- package/src/ui/drawer.tsx +4 -7
- package/src/ui/menu/menu-animations.ts +14 -20
- package/src/ui/menu/menu-tokens.ts +7 -5
- package/src/ui/menu/menu.test.tsx +9 -4
- package/src/ui/navigation-bar.test.tsx +111 -0
- package/src/ui/navigation-bar.tsx +464 -0
- package/src/ui/navigation-rail.test.tsx +5 -4
- package/src/ui/navigation-rail.tsx +32 -23
- package/src/ui/scroll-area.tsx +4 -0
- package/src/ui/search/search-view-fullscreen.tsx +1 -1
- package/src/ui/search/search.tokens.ts +9 -43
- package/src/ui/search/trailing-action.tsx +1 -1
- package/src/ui/shared/constants.ts +25 -27
- package/src/ui/shared/motion-tokens.ts +238 -0
- package/src/ui/snackbar/snackbar.tsx +4 -6
- package/src/ui/switch/switch.tsx +12 -18
- package/src/ui/text-field/text-field.tokens.ts +12 -12
- package/src/ui/text-field/text-field.tsx +31 -19
- package/src/ui/theme-provider/index.tsx +1 -5
- package/src/ui/toc.tsx +1 -1
- package/src/ui/toolbar/__snapshots__/bottom-docked-toolbar.test.tsx.snap +51 -0
- package/src/ui/toolbar/__snapshots__/floating-toolbar-with-fab.test.tsx.snap +113 -0
- package/src/ui/toolbar/__snapshots__/floating-toolbar.test.tsx.snap +169 -0
- package/src/ui/toolbar/bottom-docked-toolbar.test.tsx +114 -0
- package/src/ui/toolbar/docked-toolbar.tsx +186 -0
- package/src/ui/toolbar/floating-toolbar-with-fab.test.tsx +139 -0
- package/src/ui/toolbar/floating-toolbar-with-fab.tsx +199 -0
- package/src/ui/toolbar/floating-toolbar.test.tsx +230 -0
- package/src/ui/toolbar/floating-toolbar.tsx +344 -0
- package/src/ui/toolbar/index.ts +35 -0
- package/src/ui/toolbar/toolbar-colors.ts +37 -0
- package/src/ui/toolbar/toolbar-context.tsx +13 -0
- package/src/ui/toolbar/toolbar-divider.test.tsx +54 -0
- package/src/ui/toolbar/toolbar-divider.tsx +73 -0
- package/src/ui/toolbar/toolbar-icon-button.test.tsx +68 -0
- package/src/ui/toolbar/toolbar-icon-button.tsx +136 -0
- package/src/ui/toolbar/toolbar-scroll-behavior.ts +140 -0
- package/src/ui/toolbar/toolbar-tokens.ts +51 -0
- package/test-clip.html +31 -0
- package/test-shadow.html +5 -1
- package/test-width.html +34 -0
- package/src/ui/button-group.tsx +0 -350
- package/src/ui/button.tsx +0 -665
- package/test-render.tsx +0 -4
- package/test_output.txt +0 -164
- package/test_output_v2.txt +0 -5
- /package/src/ui/{fab.test.tsx → buttons/fabs/fab/fab.test.tsx} +0 -0
- /package/src/ui/{fab-menu.test.tsx → buttons/fabs/fab-menu/fab-menu.test.tsx} +0 -0
- /package/src/ui/{icon-button.test.tsx → buttons/icon-button/icon-button.test.tsx} +0 -0
- /package/src/ui/{Text.tsx → text.tsx} +0 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { AnimatePresence, m, type Variants } from "motion/react";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { cn } from "../../lib/utils";
|
|
4
|
+
|
|
5
|
+
import { SPRING_TRANSITION } from "../shared/constants";
|
|
6
|
+
import {
|
|
7
|
+
type FloatingToolbarColors,
|
|
8
|
+
standardFloatingToolbarColors,
|
|
9
|
+
} from "./toolbar-colors";
|
|
10
|
+
import { ToolbarContext } from "./toolbar-context";
|
|
11
|
+
import type { FloatingToolbarScrollBehavior } from "./toolbar-scroll-behavior";
|
|
12
|
+
import { FloatingToolbarTokens } from "./toolbar-tokens";
|
|
13
|
+
|
|
14
|
+
export interface FloatingToolbarProps {
|
|
15
|
+
/** Whether the toolbar is expanded, showing startContent and endContent */
|
|
16
|
+
expanded: boolean;
|
|
17
|
+
/** Orientation of the toolbar */
|
|
18
|
+
orientation?: "horizontal" | "vertical";
|
|
19
|
+
/** Color configuration */
|
|
20
|
+
colors?: FloatingToolbarColors;
|
|
21
|
+
/** Shape variant: full = pill shape (default), large = large rounded */
|
|
22
|
+
shape?: "full" | "large";
|
|
23
|
+
/** Padding inside toolbar container */
|
|
24
|
+
contentPadding?: React.CSSProperties | string;
|
|
25
|
+
/** Scroll behavior hook result */
|
|
26
|
+
scrollBehavior?: FloatingToolbarScrollBehavior;
|
|
27
|
+
/** Start content (shown when expanded, usually on the left/top) */
|
|
28
|
+
startContent?: React.ReactNode;
|
|
29
|
+
/** End content (shown when expanded, usually on the right/bottom) */
|
|
30
|
+
endContent?: React.ReactNode;
|
|
31
|
+
/** Main toolbar content (always visible) */
|
|
32
|
+
children: React.ReactNode;
|
|
33
|
+
/** Whether to disable the internal scroll translation (useful when parent handles it) */
|
|
34
|
+
disableScrollTranslation?: boolean;
|
|
35
|
+
/** Whether to disable layout animation (useful when nested inside FloatingToolbarWithFab) */
|
|
36
|
+
disableLayoutAnimation?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Gap (px) between items in the center children slot.
|
|
39
|
+
* Corresponds to MD3 `ContainerBetweenSpace` token (4dp default).
|
|
40
|
+
* @default 4
|
|
41
|
+
*/
|
|
42
|
+
itemGap?: number;
|
|
43
|
+
/** Alignment (justify-content) of the center children slot */
|
|
44
|
+
childrenAlignment?: "start" | "center" | "end";
|
|
45
|
+
/** Custom CSS class applied to each child item in the toolbar slots */
|
|
46
|
+
itemClassName?: string;
|
|
47
|
+
className?: string;
|
|
48
|
+
style?: React.CSSProperties;
|
|
49
|
+
"aria-label"?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// --- Module-level helpers ---
|
|
53
|
+
|
|
54
|
+
const ALIGNMENT_CLASS: Record<"start" | "center" | "end", string> = {
|
|
55
|
+
start: "justify-start",
|
|
56
|
+
center: "justify-center",
|
|
57
|
+
end: "justify-end",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function resolveScrollTranslation(
|
|
61
|
+
scrollBehavior: FloatingToolbarScrollBehavior | undefined,
|
|
62
|
+
disabled: boolean | undefined,
|
|
63
|
+
): { x: number | string; y: number | string } {
|
|
64
|
+
if (!scrollBehavior || disabled) return { x: 0, y: 0 };
|
|
65
|
+
const { offset, exitDirection } = scrollBehavior;
|
|
66
|
+
const percent = offset * -100;
|
|
67
|
+
switch (exitDirection) {
|
|
68
|
+
case "top":
|
|
69
|
+
return { y: `${-percent}%`, x: 0 };
|
|
70
|
+
case "bottom":
|
|
71
|
+
return { y: `${percent}%`, x: 0 };
|
|
72
|
+
case "start":
|
|
73
|
+
return { x: `${-percent}%`, y: 0 };
|
|
74
|
+
case "end":
|
|
75
|
+
return { x: `${percent}%`, y: 0 };
|
|
76
|
+
default:
|
|
77
|
+
return { x: 0, y: 0 };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function buildSlotVariants(
|
|
82
|
+
xHidden: string | number,
|
|
83
|
+
yHidden: string | number,
|
|
84
|
+
isHorizontal: boolean,
|
|
85
|
+
): Variants {
|
|
86
|
+
return {
|
|
87
|
+
hidden: {
|
|
88
|
+
x: xHidden,
|
|
89
|
+
y: yHidden,
|
|
90
|
+
opacity: 0,
|
|
91
|
+
scale: 0.9,
|
|
92
|
+
width: isHorizontal ? 0 : "auto",
|
|
93
|
+
height: isHorizontal ? "auto" : 0,
|
|
94
|
+
transition: SPRING_TRANSITION,
|
|
95
|
+
},
|
|
96
|
+
visible: {
|
|
97
|
+
x: 0,
|
|
98
|
+
y: 0,
|
|
99
|
+
opacity: 1,
|
|
100
|
+
scale: 1,
|
|
101
|
+
width: "auto",
|
|
102
|
+
height: "auto",
|
|
103
|
+
transition: SPRING_TRANSITION,
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function applyItemClassName(
|
|
109
|
+
nodes: React.ReactNode,
|
|
110
|
+
itemClassName: string,
|
|
111
|
+
): React.ReactNode {
|
|
112
|
+
return React.Children.map(nodes, (child) => {
|
|
113
|
+
if (!React.isValidElement(child)) return child;
|
|
114
|
+
const element = child as React.ReactElement<{ className?: string }>;
|
|
115
|
+
return React.cloneElement(element, {
|
|
116
|
+
className: cn(element.props.className, itemClassName),
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// --- Component ---
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Internal component for rendering the toolbar logic.
|
|
125
|
+
* Consumers should use HorizontalFloatingToolbar or VerticalFloatingToolbar.
|
|
126
|
+
*/
|
|
127
|
+
const FloatingToolbarBase = React.forwardRef<
|
|
128
|
+
HTMLDivElement,
|
|
129
|
+
FloatingToolbarProps
|
|
130
|
+
>(
|
|
131
|
+
(
|
|
132
|
+
{
|
|
133
|
+
expanded,
|
|
134
|
+
orientation = "horizontal",
|
|
135
|
+
colors = standardFloatingToolbarColors,
|
|
136
|
+
shape = "full",
|
|
137
|
+
contentPadding,
|
|
138
|
+
scrollBehavior,
|
|
139
|
+
startContent,
|
|
140
|
+
endContent,
|
|
141
|
+
children,
|
|
142
|
+
disableScrollTranslation,
|
|
143
|
+
disableLayoutAnimation,
|
|
144
|
+
itemGap = FloatingToolbarTokens.ContainerBetweenSpace,
|
|
145
|
+
childrenAlignment = "center",
|
|
146
|
+
itemClassName,
|
|
147
|
+
className,
|
|
148
|
+
style,
|
|
149
|
+
"aria-label": ariaLabel,
|
|
150
|
+
...props
|
|
151
|
+
},
|
|
152
|
+
ref,
|
|
153
|
+
) => {
|
|
154
|
+
const isHorizontal = orientation === "horizontal";
|
|
155
|
+
const isStringPadding = typeof contentPadding === "string";
|
|
156
|
+
|
|
157
|
+
const cssVars = {
|
|
158
|
+
"--toolbar-bg": colors.toolbarContainerColor,
|
|
159
|
+
"--toolbar-color": colors.toolbarContentColor,
|
|
160
|
+
"--toolbar-size": `${FloatingToolbarTokens.ContainerHeight}px`,
|
|
161
|
+
...(isStringPadding ? {} : contentPadding),
|
|
162
|
+
...style,
|
|
163
|
+
} as React.CSSProperties;
|
|
164
|
+
|
|
165
|
+
const isScrolledOff = scrollBehavior && scrollBehavior.offset < 0;
|
|
166
|
+
const shadowClass = expanded ? "shadow-md" : "shadow-sm";
|
|
167
|
+
const containerShapeClass =
|
|
168
|
+
shape === "full" ? "rounded-full" : "rounded-2xl";
|
|
169
|
+
|
|
170
|
+
const leadingVariants = buildSlotVariants(
|
|
171
|
+
isHorizontal ? "-100%" : 0,
|
|
172
|
+
isHorizontal ? 0 : "100%",
|
|
173
|
+
isHorizontal,
|
|
174
|
+
);
|
|
175
|
+
const trailingVariants = buildSlotVariants(
|
|
176
|
+
isHorizontal ? "100%" : 0,
|
|
177
|
+
isHorizontal ? 0 : "-100%",
|
|
178
|
+
isHorizontal,
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
const processNodes = (nodes: React.ReactNode) =>
|
|
182
|
+
itemClassName ? applyItemClassName(nodes, itemClassName) : nodes;
|
|
183
|
+
|
|
184
|
+
const scrollTranslation = resolveScrollTranslation(
|
|
185
|
+
scrollBehavior,
|
|
186
|
+
disableScrollTranslation,
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
return (
|
|
190
|
+
<ToolbarContext.Provider value={{ orientation }}>
|
|
191
|
+
<m.div
|
|
192
|
+
ref={ref}
|
|
193
|
+
initial={false}
|
|
194
|
+
animate={scrollTranslation}
|
|
195
|
+
transition={SPRING_TRANSITION}
|
|
196
|
+
>
|
|
197
|
+
<m.div
|
|
198
|
+
role="toolbar"
|
|
199
|
+
aria-label={ariaLabel || "Floating Toolbar"}
|
|
200
|
+
aria-orientation={orientation}
|
|
201
|
+
aria-expanded={expanded}
|
|
202
|
+
inert={isScrolledOff ? true : undefined}
|
|
203
|
+
{...(disableLayoutAnimation ? {} : { layout: "position" as const })}
|
|
204
|
+
transition={SPRING_TRANSITION}
|
|
205
|
+
style={{
|
|
206
|
+
...cssVars,
|
|
207
|
+
backgroundColor: "var(--toolbar-bg)",
|
|
208
|
+
color: "var(--toolbar-color)",
|
|
209
|
+
}}
|
|
210
|
+
className={cn(
|
|
211
|
+
"flex pointer-events-auto relative",
|
|
212
|
+
isHorizontal
|
|
213
|
+
? "max-w-[90vw] h-(--toolbar-size)"
|
|
214
|
+
: "max-h-[90vh] w-(--toolbar-size) h-fit",
|
|
215
|
+
containerShapeClass,
|
|
216
|
+
shadowClass,
|
|
217
|
+
className,
|
|
218
|
+
)}
|
|
219
|
+
{...props}
|
|
220
|
+
>
|
|
221
|
+
<div
|
|
222
|
+
className={cn(
|
|
223
|
+
"flex-1 w-full h-full rounded-[inherit] scrollbar-hide",
|
|
224
|
+
isHorizontal
|
|
225
|
+
? "overflow-x-auto overflow-y-visible"
|
|
226
|
+
: "overflow-y-auto overflow-x-visible",
|
|
227
|
+
)}
|
|
228
|
+
>
|
|
229
|
+
<div
|
|
230
|
+
className={cn(
|
|
231
|
+
"flex items-center gap-1 min-w-0 min-h-0",
|
|
232
|
+
isHorizontal ? "flex-row h-full" : "flex-col w-full",
|
|
233
|
+
isStringPadding
|
|
234
|
+
? contentPadding
|
|
235
|
+
: isHorizontal
|
|
236
|
+
? "px-4"
|
|
237
|
+
: "py-6",
|
|
238
|
+
)}
|
|
239
|
+
style={isStringPadding ? undefined : contentPadding}
|
|
240
|
+
>
|
|
241
|
+
<AnimatePresence initial={false}>
|
|
242
|
+
{expanded && startContent && (
|
|
243
|
+
<m.div
|
|
244
|
+
key="start"
|
|
245
|
+
variants={leadingVariants}
|
|
246
|
+
initial="hidden"
|
|
247
|
+
animate="visible"
|
|
248
|
+
exit="hidden"
|
|
249
|
+
className={cn(
|
|
250
|
+
"flex shrink-0 overflow-hidden",
|
|
251
|
+
isHorizontal ? "flex-row" : "flex-col",
|
|
252
|
+
)}
|
|
253
|
+
>
|
|
254
|
+
{processNodes(startContent)}
|
|
255
|
+
</m.div>
|
|
256
|
+
)}
|
|
257
|
+
</AnimatePresence>
|
|
258
|
+
|
|
259
|
+
<div
|
|
260
|
+
className={cn(
|
|
261
|
+
"flex flex-1 items-center min-w-0 min-h-0 h-full",
|
|
262
|
+
isHorizontal ? "flex-row" : "flex-col",
|
|
263
|
+
ALIGNMENT_CLASS[childrenAlignment],
|
|
264
|
+
)}
|
|
265
|
+
style={{ gap: itemGap }}
|
|
266
|
+
>
|
|
267
|
+
{processNodes(children)}
|
|
268
|
+
</div>
|
|
269
|
+
|
|
270
|
+
<AnimatePresence initial={false}>
|
|
271
|
+
{expanded && endContent && (
|
|
272
|
+
<m.div
|
|
273
|
+
key="end"
|
|
274
|
+
variants={trailingVariants}
|
|
275
|
+
initial="hidden"
|
|
276
|
+
animate="visible"
|
|
277
|
+
exit="hidden"
|
|
278
|
+
className={cn(
|
|
279
|
+
"flex shrink-0 overflow-hidden",
|
|
280
|
+
isHorizontal ? "flex-row" : "flex-col",
|
|
281
|
+
)}
|
|
282
|
+
>
|
|
283
|
+
{processNodes(endContent)}
|
|
284
|
+
</m.div>
|
|
285
|
+
)}
|
|
286
|
+
</AnimatePresence>
|
|
287
|
+
</div>
|
|
288
|
+
</div>
|
|
289
|
+
</m.div>
|
|
290
|
+
</m.div>
|
|
291
|
+
</ToolbarContext.Provider>
|
|
292
|
+
);
|
|
293
|
+
},
|
|
294
|
+
);
|
|
295
|
+
FloatingToolbarBase.displayName = "FloatingToolbarBase";
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* A horizontal floating toolbar that displays navigation and key actions.
|
|
299
|
+
* It can be positioned anywhere on the screen and floats over the rest of the content.
|
|
300
|
+
*
|
|
301
|
+
* @example
|
|
302
|
+
* ```tsx
|
|
303
|
+
* const [expanded, setExpanded] = useState(true);
|
|
304
|
+
* const scrollBehavior = useFloatingToolbarScrollBehavior({ exitDirection: 'bottom' });
|
|
305
|
+
*
|
|
306
|
+
* <HorizontalFloatingToolbar
|
|
307
|
+
* expanded={expanded}
|
|
308
|
+
* scrollBehavior={scrollBehavior}
|
|
309
|
+
* startContent={<IconButton icon="undo" />}
|
|
310
|
+
* endContent={<IconButton icon="redo" />}
|
|
311
|
+
* >
|
|
312
|
+
* <IconButton icon="bold" />
|
|
313
|
+
* <IconButton icon="italic" />
|
|
314
|
+
* </HorizontalFloatingToolbar>
|
|
315
|
+
* ```
|
|
316
|
+
*
|
|
317
|
+
* @see https://m3.material.io/components/toolbars/guidelines
|
|
318
|
+
*/
|
|
319
|
+
export const HorizontalFloatingToolbar = React.forwardRef<
|
|
320
|
+
HTMLDivElement,
|
|
321
|
+
Omit<FloatingToolbarProps, "orientation">
|
|
322
|
+
>((props, ref) => (
|
|
323
|
+
<FloatingToolbarBase ref={ref} orientation="horizontal" {...props} />
|
|
324
|
+
));
|
|
325
|
+
HorizontalFloatingToolbar.displayName = "HorizontalFloatingToolbar";
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* A vertical floating toolbar that displays navigation and key actions.
|
|
329
|
+
* It floats over the rest of the content.
|
|
330
|
+
*
|
|
331
|
+
* @example
|
|
332
|
+
* ```tsx
|
|
333
|
+
* <VerticalFloatingToolbar expanded={true}>
|
|
334
|
+
* <IconButton icon="bold" />
|
|
335
|
+
* </VerticalFloatingToolbar>
|
|
336
|
+
* ```
|
|
337
|
+
*/
|
|
338
|
+
export const VerticalFloatingToolbar = React.forwardRef<
|
|
339
|
+
HTMLDivElement,
|
|
340
|
+
Omit<FloatingToolbarProps, "orientation">
|
|
341
|
+
>((props, ref) => (
|
|
342
|
+
<FloatingToolbarBase ref={ref} orientation="vertical" {...props} />
|
|
343
|
+
));
|
|
344
|
+
VerticalFloatingToolbar.displayName = "VerticalFloatingToolbar";
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export {
|
|
2
|
+
BottomDockedToolbar,
|
|
3
|
+
type BottomDockedToolbarProps,
|
|
4
|
+
} from "./docked-toolbar";
|
|
5
|
+
export {
|
|
6
|
+
type FloatingToolbarProps,
|
|
7
|
+
HorizontalFloatingToolbar,
|
|
8
|
+
VerticalFloatingToolbar,
|
|
9
|
+
} from "./floating-toolbar";
|
|
10
|
+
export {
|
|
11
|
+
type FloatingToolbarWithFabProps,
|
|
12
|
+
HorizontalFloatingToolbarWithFab,
|
|
13
|
+
VerticalFloatingToolbarWithFab,
|
|
14
|
+
} from "./floating-toolbar-with-fab";
|
|
15
|
+
export {
|
|
16
|
+
type FloatingToolbarColors,
|
|
17
|
+
standardFloatingToolbarColors,
|
|
18
|
+
vibrantFloatingToolbarColors,
|
|
19
|
+
} from "./toolbar-colors";
|
|
20
|
+
export { ToolbarDivider, type ToolbarDividerProps } from "./toolbar-divider";
|
|
21
|
+
export {
|
|
22
|
+
ToolbarIconButton,
|
|
23
|
+
type ToolbarIconButtonProps,
|
|
24
|
+
type ToolbarIconButtonSize,
|
|
25
|
+
type ToolbarIconButtonVariant,
|
|
26
|
+
} from "./toolbar-icon-button";
|
|
27
|
+
export {
|
|
28
|
+
type FloatingToolbarScrollBehavior,
|
|
29
|
+
type UseFloatingToolbarScrollBehaviorOptions,
|
|
30
|
+
useFloatingToolbarScrollBehavior,
|
|
31
|
+
} from "./toolbar-scroll-behavior";
|
|
32
|
+
export {
|
|
33
|
+
ToolbarDividerTokens,
|
|
34
|
+
ToolbarIconButtonTokens,
|
|
35
|
+
} from "./toolbar-tokens";
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Color configuration types and standard values for the FloatingToolbar component.
|
|
3
|
+
* Maps MD3 design tokens to CSS custom properties used in Tailwind CSS.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface FloatingToolbarColors {
|
|
7
|
+
/** The background color of the toolbar container */
|
|
8
|
+
toolbarContainerColor?: string;
|
|
9
|
+
/** The color of the content (icons, text) inside the toolbar */
|
|
10
|
+
toolbarContentColor?: string;
|
|
11
|
+
/** The background color of the FAB container */
|
|
12
|
+
fabContainerColor?: string;
|
|
13
|
+
/** The color of the content inside the FAB */
|
|
14
|
+
fabContentColor?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Standard color configuration for the floating toolbar.
|
|
19
|
+
* Uses surface container for the toolbar and secondary container for the FAB.
|
|
20
|
+
*/
|
|
21
|
+
export const standardFloatingToolbarColors: FloatingToolbarColors = {
|
|
22
|
+
toolbarContainerColor: "var(--md-sys-color-surface-container)",
|
|
23
|
+
toolbarContentColor: "var(--md-sys-color-on-surface)",
|
|
24
|
+
fabContainerColor: "var(--md-sys-color-secondary-container)",
|
|
25
|
+
fabContentColor: "var(--md-sys-color-on-secondary-container)",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Vibrant color configuration for the floating toolbar.
|
|
30
|
+
* Uses primary container for the toolbar and tertiary container for the FAB.
|
|
31
|
+
*/
|
|
32
|
+
export const vibrantFloatingToolbarColors: FloatingToolbarColors = {
|
|
33
|
+
toolbarContainerColor: "var(--md-sys-color-primary-container)",
|
|
34
|
+
toolbarContentColor: "var(--md-sys-color-on-primary-container)",
|
|
35
|
+
fabContainerColor: "var(--md-sys-color-tertiary-container)",
|
|
36
|
+
fabContentColor: "var(--md-sys-color-on-tertiary-container)",
|
|
37
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
export interface ToolbarContextValue {
|
|
4
|
+
orientation: "horizontal" | "vertical";
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export const ToolbarContext = React.createContext<ToolbarContextValue | null>(
|
|
8
|
+
null,
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
export function useToolbarContext() {
|
|
12
|
+
return React.useContext(ToolbarContext);
|
|
13
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { ToolbarContext } from "./toolbar-context";
|
|
4
|
+
import { ToolbarDivider } from "./toolbar-divider";
|
|
5
|
+
|
|
6
|
+
describe("ToolbarDivider", () => {
|
|
7
|
+
it("renders as a semantic separator for horizontal toolbar (default)", () => {
|
|
8
|
+
render(<ToolbarDivider />);
|
|
9
|
+
const divider = screen.getByRole("separator");
|
|
10
|
+
expect(divider).toBeInTheDocument();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("renders vertical aria-orientation for horizontal toolbar", () => {
|
|
14
|
+
render(<ToolbarDivider orientation="horizontal" />);
|
|
15
|
+
const divider = screen.getByRole("separator");
|
|
16
|
+
expect(divider).toHaveAttribute("aria-orientation", "vertical");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("renders horizontal aria-orientation for vertical toolbar", () => {
|
|
20
|
+
render(<ToolbarDivider orientation="vertical" />);
|
|
21
|
+
const divider = screen.getByRole("separator");
|
|
22
|
+
expect(divider).toHaveAttribute("aria-orientation", "horizontal");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("applies width class for horizontal toolbar (vertical line)", () => {
|
|
26
|
+
const { container } = render(<ToolbarDivider orientation="horizontal" />);
|
|
27
|
+
const divider = container.firstChild as HTMLElement;
|
|
28
|
+
expect(divider.className).toMatch(/w-px/);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("applies height class for vertical toolbar (horizontal line)", () => {
|
|
32
|
+
const { container } = render(<ToolbarDivider orientation="vertical" />);
|
|
33
|
+
const divider = container.firstChild as HTMLElement;
|
|
34
|
+
expect(divider.className).toMatch(/h-px/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("applies custom className", () => {
|
|
38
|
+
const { container } = render(
|
|
39
|
+
<ToolbarDivider className="my-custom-class" />,
|
|
40
|
+
);
|
|
41
|
+
const divider = container.firstChild as HTMLElement;
|
|
42
|
+
expect(divider.className).toContain("my-custom-class");
|
|
43
|
+
});
|
|
44
|
+
it("consumes orientation from ToolbarContext", () => {
|
|
45
|
+
const { container } = render(
|
|
46
|
+
<ToolbarContext.Provider value={{ orientation: "vertical" }}>
|
|
47
|
+
<ToolbarDivider />
|
|
48
|
+
</ToolbarContext.Provider>,
|
|
49
|
+
);
|
|
50
|
+
const divider = container.firstChild as HTMLElement;
|
|
51
|
+
expect(divider).toHaveAttribute("aria-orientation", "horizontal");
|
|
52
|
+
expect(divider.className).toMatch(/h-px/);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file toolbar-divider.tsx
|
|
3
|
+
*
|
|
4
|
+
* A decorative divider for use inside MD3 Toolbar components.
|
|
5
|
+
* Renders as a thin line that visually separates groups of toolbar actions.
|
|
6
|
+
*
|
|
7
|
+
* @see https://m3.material.io/components/toolbars/guidelines (Anatomy → Flexibility & slots)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type * as React from "react";
|
|
11
|
+
import { cn } from "../../lib/utils";
|
|
12
|
+
import { useToolbarContext } from "./toolbar-context";
|
|
13
|
+
import { FloatingToolbarTokens, ToolbarDividerTokens } from "./toolbar-tokens";
|
|
14
|
+
|
|
15
|
+
export interface ToolbarDividerProps {
|
|
16
|
+
/**
|
|
17
|
+
* The toolbar's orientation context.
|
|
18
|
+
* - `"horizontal"` toolbar → renders a **vertical** divider line.
|
|
19
|
+
* - `"vertical"` toolbar → renders a **horizontal** divider line.
|
|
20
|
+
* @default "horizontal"
|
|
21
|
+
*/
|
|
22
|
+
orientation?: "horizontal" | "vertical";
|
|
23
|
+
className?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A thin decorative separator for grouping actions inside a Toolbar.
|
|
28
|
+
*
|
|
29
|
+
* Inherits its color from the toolbar's `--toolbar-color` CSS variable via
|
|
30
|
+
* `currentColor`, so it automatically adapts to both standard and vibrant
|
|
31
|
+
* color configurations.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```tsx
|
|
35
|
+
* <HorizontalFloatingToolbar expanded={true}>
|
|
36
|
+
* <IconButton aria-label="Bold"><BoldIcon /></IconButton>
|
|
37
|
+
* <IconButton aria-label="Italic"><ItalicIcon /></IconButton>
|
|
38
|
+
* <ToolbarDivider />
|
|
39
|
+
* <IconButton aria-label="Align left"><AlignLeftIcon /></IconButton>
|
|
40
|
+
* </HorizontalFloatingToolbar>
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export const ToolbarDivider: React.FC<ToolbarDividerProps> = ({
|
|
44
|
+
orientation: propsOrientation,
|
|
45
|
+
className,
|
|
46
|
+
}) => {
|
|
47
|
+
const context = useToolbarContext();
|
|
48
|
+
const orientation = propsOrientation || context?.orientation || "horizontal";
|
|
49
|
+
const isHorizontalToolbar = orientation === "horizontal";
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<hr
|
|
53
|
+
aria-orientation={isHorizontalToolbar ? "vertical" : "horizontal"}
|
|
54
|
+
className={cn(
|
|
55
|
+
"shrink-0 bg-current opacity-30 border-none",
|
|
56
|
+
isHorizontalToolbar
|
|
57
|
+
? "w-px h-8 self-center mx-2"
|
|
58
|
+
: "h-px w-8 self-center my-2",
|
|
59
|
+
className,
|
|
60
|
+
)}
|
|
61
|
+
style={{
|
|
62
|
+
width: isHorizontalToolbar
|
|
63
|
+
? `${ToolbarDividerTokens.Thickness}px`
|
|
64
|
+
: `${ToolbarDividerTokens.HeightRatio * FloatingToolbarTokens.ContainerHeight}px`,
|
|
65
|
+
height: isHorizontalToolbar
|
|
66
|
+
? `${ToolbarDividerTokens.HeightRatio * FloatingToolbarTokens.ContainerHeight}px`
|
|
67
|
+
: `${ToolbarDividerTokens.Thickness}px`,
|
|
68
|
+
}}
|
|
69
|
+
/>
|
|
70
|
+
);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
ToolbarDivider.displayName = "ToolbarDivider";
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { ToolbarIconButton } from "./toolbar-icon-button";
|
|
4
|
+
|
|
5
|
+
const FakeIcon = () => <svg data-testid="icon" />;
|
|
6
|
+
|
|
7
|
+
describe("ToolbarIconButton", () => {
|
|
8
|
+
it("renders a button with the given aria-label", () => {
|
|
9
|
+
render(
|
|
10
|
+
<ToolbarIconButton aria-label="Add">
|
|
11
|
+
<FakeIcon />
|
|
12
|
+
</ToolbarIconButton>,
|
|
13
|
+
);
|
|
14
|
+
expect(screen.getByRole("button", { name: "Add" })).toBeDefined();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("renders standard variant by default (no background)", () => {
|
|
18
|
+
const { container } = render(
|
|
19
|
+
<ToolbarIconButton aria-label="Share">
|
|
20
|
+
<FakeIcon />
|
|
21
|
+
</ToolbarIconButton>,
|
|
22
|
+
);
|
|
23
|
+
// Standard uses IconButton colorStyle="standard" — no filled bg class expected
|
|
24
|
+
const button = container.querySelector("button");
|
|
25
|
+
expect(button).toBeDefined();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("applies narrow width class for toolbarSize=narrow", () => {
|
|
29
|
+
const { container } = render(
|
|
30
|
+
<ToolbarIconButton aria-label="Back" toolbarSize="narrow">
|
|
31
|
+
<FakeIcon />
|
|
32
|
+
</ToolbarIconButton>,
|
|
33
|
+
);
|
|
34
|
+
const button = container.querySelector("button");
|
|
35
|
+
// className should contain the 40px width override
|
|
36
|
+
expect(button?.className).toMatch(/w-\[40px\]/);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("applies wide width class for toolbarSize=wide", () => {
|
|
40
|
+
const { container } = render(
|
|
41
|
+
<ToolbarIconButton aria-label="Add" toolbarSize="wide" emphasis="filled">
|
|
42
|
+
<FakeIcon />
|
|
43
|
+
</ToolbarIconButton>,
|
|
44
|
+
);
|
|
45
|
+
const button = container.querySelector("button");
|
|
46
|
+
expect(button?.className).toMatch(/w-\[64px\]/);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("applies 48px height class for touch target compliance", () => {
|
|
50
|
+
const { container } = render(
|
|
51
|
+
<ToolbarIconButton aria-label="Edit">
|
|
52
|
+
<FakeIcon />
|
|
53
|
+
</ToolbarIconButton>,
|
|
54
|
+
);
|
|
55
|
+
const button = container.querySelector("button");
|
|
56
|
+
expect(button?.className).toMatch(/h-\[48px\]/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("is disabled when disabled prop is passed", () => {
|
|
60
|
+
render(
|
|
61
|
+
<ToolbarIconButton aria-label="Disabled" disabled>
|
|
62
|
+
<FakeIcon />
|
|
63
|
+
</ToolbarIconButton>,
|
|
64
|
+
);
|
|
65
|
+
const button = screen.getByRole("button", { name: "Disabled" });
|
|
66
|
+
expect(button).toHaveAttribute("disabled");
|
|
67
|
+
});
|
|
68
|
+
});
|