@tanishqkancharla/maui 0.0.43 → 0.0.45
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/dist/components/Button.d.ts +9 -0
- package/dist/components/Button.js +32 -3
- package/dist/components/TabBar.d.ts +18 -0
- package/dist/components/TabBar.js +177 -0
- package/dist/maui.d.ts +2 -1
- package/dist/maui.js +1 -0
- package/dist/tokens/shadow.d.ts +2 -2
- package/dist/tokens/shadow.js +2 -2
- package/dist/tokens/sizing.d.ts +4 -0
- package/dist/tokens/sizing.js +1 -0
- package/package.json +1 -1
- package/skills/maui/SKILL.md +1 -0
- package/skills/maui/references/components/buttons.md +3 -1
- package/skills/maui/references/components/tab-bar.md +34 -0
- package/src/apps/JsxEditor/JsxEditor.tsx +1 -1
- package/src/apps/JsxEditor/catalog.ts +15 -0
- package/src/apps/JsxEditor/lint.test.ts +13 -0
- package/src/components/Button.tsx +42 -2
- package/src/components/TabBar.tsx +289 -0
- package/src/maui.ts +6 -0
- package/src/pages/ButtonsPage.tsx +21 -0
- package/src/pages/Maui.tsx +2 -0
- package/src/pages/TabBarPage.tsx +90 -0
- package/src/tokens/shadow.ts +2 -2
- package/src/tokens/sizing.ts +1 -0
|
@@ -2,6 +2,13 @@ import React from "react";
|
|
|
2
2
|
import { type ButtonProps as RACButtonProps } from "react-aria-components";
|
|
3
3
|
import { type ColorName } from "../tokens/colors";
|
|
4
4
|
export type ButtonVariant = "default" | "quiet" | "primary";
|
|
5
|
+
export type ButtonSize = "default" | "sm";
|
|
6
|
+
/** Internal style primitives for controls that compose Button visuals. */
|
|
7
|
+
export declare const buttonStyles: {
|
|
8
|
+
readonly base: import("purse-styles").StyleElement;
|
|
9
|
+
readonly quiet: import("purse-styles").StyleElement;
|
|
10
|
+
readonly text: import("purse-styles").StyleElement;
|
|
11
|
+
};
|
|
5
12
|
/** Opaque hex or `rgb()`. Palette names stay ColorName (`"blue"` is Radix, not CSS `blue`). */
|
|
6
13
|
export type ButtonCssColor = `#${string}` | `rgb(${string}`;
|
|
7
14
|
export type ButtonVariantColor = ColorName | ButtonCssColor;
|
|
@@ -14,6 +21,7 @@ type ButtonData = {
|
|
|
14
21
|
export type ButtonProps = Omit<RACButtonProps, "children" | "className"> & {
|
|
15
22
|
children: React.ReactNode;
|
|
16
23
|
className?: string;
|
|
24
|
+
size?: ButtonSize;
|
|
17
25
|
variant?: ButtonVariant;
|
|
18
26
|
variantColor?: ButtonVariantColor;
|
|
19
27
|
};
|
|
@@ -24,6 +32,7 @@ export declare function useButton(props: Pick<ButtonProps, "onClick" | "onFocus"
|
|
|
24
32
|
export declare const Button: React.ForwardRefExoticComponent<Omit<RACButtonProps, "children" | "className"> & {
|
|
25
33
|
children: React.ReactNode;
|
|
26
34
|
className?: string;
|
|
35
|
+
size?: ButtonSize;
|
|
27
36
|
variant?: ButtonVariant;
|
|
28
37
|
variantColor?: ButtonVariantColor;
|
|
29
38
|
} & React.RefAttributes<HTMLButtonElement>>;
|
|
@@ -10,7 +10,7 @@ import { colors } from "../tokens/colors";
|
|
|
10
10
|
import { focusRing } from "../tokens/focusRing";
|
|
11
11
|
import { radius } from "../tokens/radius";
|
|
12
12
|
import { shadowVars } from "../tokens/shadow";
|
|
13
|
-
import { controlSize } from "../tokens/sizing";
|
|
13
|
+
import { controlSize, iconSizeValues } from "../tokens/sizing";
|
|
14
14
|
import { spacing } from "../tokens/spacing";
|
|
15
15
|
import { text } from "../tokens/text";
|
|
16
16
|
import { memoize } from "../utils/memoize";
|
|
@@ -64,6 +64,29 @@ const buttonTextClass = style({
|
|
|
64
64
|
minWidth: 0,
|
|
65
65
|
textBox: "trim-both cap alphabetic",
|
|
66
66
|
});
|
|
67
|
+
const smallButtonClass = style(text({ size: "2xs", fontWeight: 400, color: "highContrast" }), {
|
|
68
|
+
height: controlSize.smHeight,
|
|
69
|
+
gap: spacing.value(1),
|
|
70
|
+
paddingInline: spacing.value(4),
|
|
71
|
+
paddingBlock: spacing.value(2),
|
|
72
|
+
"& > svg": {
|
|
73
|
+
width: iconSizeValues.xs,
|
|
74
|
+
height: iconSizeValues.xs,
|
|
75
|
+
},
|
|
76
|
+
"&:has(> svg)": {
|
|
77
|
+
paddingBlock: spacing.value(2),
|
|
78
|
+
},
|
|
79
|
+
"&:has(> svg:first-child:not(:only-child))": {
|
|
80
|
+
paddingInlineStart: spacing.value(3),
|
|
81
|
+
},
|
|
82
|
+
"&:has(> svg:last-child:not(:only-child))": {
|
|
83
|
+
paddingInlineEnd: spacing.value(3),
|
|
84
|
+
},
|
|
85
|
+
"&:has(> svg:only-child)": {
|
|
86
|
+
width: controlSize.smHeight,
|
|
87
|
+
paddingInline: 0,
|
|
88
|
+
},
|
|
89
|
+
});
|
|
67
90
|
const buttonClass = style(buttonBaseClass, {
|
|
68
91
|
backgroundColor: backgroundColor.element,
|
|
69
92
|
"&:hover:not(:disabled)": {
|
|
@@ -84,6 +107,12 @@ const quietClass = memoize((color, hoverColor, wash) => style(buttonBaseClass, f
|
|
|
84
107
|
"&:disabled": disabledQuiet,
|
|
85
108
|
}));
|
|
86
109
|
const quietButtonClass = quietClass(colors.gray[11], colors.gray[12], colors.grayAlpha[9]);
|
|
110
|
+
/** Internal style primitives for controls that compose Button visuals. */
|
|
111
|
+
export const buttonStyles = {
|
|
112
|
+
base: buttonBaseClass,
|
|
113
|
+
quiet: quietButtonClass,
|
|
114
|
+
text: buttonTextClass,
|
|
115
|
+
};
|
|
87
116
|
const darkTextOnSolid = new Set([
|
|
88
117
|
"amber",
|
|
89
118
|
"lime",
|
|
@@ -169,9 +198,9 @@ export function useButton(props, forwardedRef) {
|
|
|
169
198
|
];
|
|
170
199
|
}
|
|
171
200
|
export const Button = React.forwardRef(function Button(props, forwardedRef) {
|
|
172
|
-
const { children, className: classNameProp, onClick, onFocus, type = "button", variant = "default", variantColor, isDisabled, ...buttonProps } = props;
|
|
201
|
+
const { children, className: classNameProp, onClick, onFocus, type = "button", size = "default", variant = "default", variantColor, isDisabled, ...buttonProps } = props;
|
|
173
202
|
const [, attributes] = useButton({ onClick, onFocus }, forwardedRef);
|
|
174
|
-
const className = useStyles(buttonVariantClass(variant, variantColor));
|
|
203
|
+
const className = useStyles(buttonVariantClass(variant, variantColor), size === "sm" && smallButtonClass);
|
|
175
204
|
const textClassName = useStyles(buttonTextClass);
|
|
176
205
|
const mergedClassName = [className, classNameProp].filter(Boolean).join(" ");
|
|
177
206
|
return (_jsx(RACButton, { ...buttonProps, ref: attributes.ref, type: type, isDisabled: isDisabled, className: mergedClassName, onClick: attributes.onClick, onFocus: attributes.onFocus, children: renderButtonChildren(children, textClassName) }));
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
export type TabBarItem = {
|
|
3
|
+
id: string;
|
|
4
|
+
label: string;
|
|
5
|
+
icon?: React.ReactNode;
|
|
6
|
+
isClosable?: boolean;
|
|
7
|
+
};
|
|
8
|
+
export type TabBarProps = {
|
|
9
|
+
items: readonly TabBarItem[];
|
|
10
|
+
selectedId: string;
|
|
11
|
+
onSelectionChange: (id: string) => void;
|
|
12
|
+
onClose?: (id: string) => void;
|
|
13
|
+
onAdd?: () => void;
|
|
14
|
+
addLabel?: string;
|
|
15
|
+
"aria-label": string;
|
|
16
|
+
className?: string;
|
|
17
|
+
};
|
|
18
|
+
export declare function TabBar({ items, selectedId, onSelectionChange, onClose, onAdd, addLabel, "aria-label": ariaLabel, className, }: TabBarProps): React.JSX.Element;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React, { useLayoutEffect, useRef } from "react";
|
|
3
|
+
import { useLocale } from "react-aria";
|
|
4
|
+
import { Button as RACButton } from "react-aria-components";
|
|
5
|
+
import { style, useStyles } from "purse-styles";
|
|
6
|
+
import { backgroundColor, surfaceMixPercent, surfaceWash, } from "../tokens/background";
|
|
7
|
+
import { colors } from "../tokens/colors";
|
|
8
|
+
import { controlSize } from "../tokens/sizing";
|
|
9
|
+
import { spacing } from "../tokens/spacing";
|
|
10
|
+
import { Button, buttonStyles } from "./Button";
|
|
11
|
+
import { Icons } from "./Icons";
|
|
12
|
+
const tabBarClass = style({
|
|
13
|
+
display: "flex",
|
|
14
|
+
alignItems: "center",
|
|
15
|
+
height: `calc(${controlSize.height} + ${spacing.value(3)} * 2)`,
|
|
16
|
+
minWidth: 0,
|
|
17
|
+
paddingInline: spacing.value(4),
|
|
18
|
+
backgroundColor: backgroundColor.app,
|
|
19
|
+
borderBottom: `1px solid ${colors.gray[6]}`,
|
|
20
|
+
});
|
|
21
|
+
const tabListClass = style({
|
|
22
|
+
display: "flex",
|
|
23
|
+
alignItems: "center",
|
|
24
|
+
flex: "0 1 auto",
|
|
25
|
+
minWidth: 0,
|
|
26
|
+
paddingBlock: spacing.value(3),
|
|
27
|
+
overflowX: "auto",
|
|
28
|
+
overflowY: "hidden",
|
|
29
|
+
scrollbarWidth: "none",
|
|
30
|
+
"&::-webkit-scrollbar": {
|
|
31
|
+
display: "none",
|
|
32
|
+
},
|
|
33
|
+
"& > div:has(button[data-hovered], button[aria-pressed='true']) + span": {
|
|
34
|
+
backgroundColor: "transparent",
|
|
35
|
+
},
|
|
36
|
+
"& > span:has(+ div button[data-hovered]), & > span:has(+ div button[aria-pressed='true'])": {
|
|
37
|
+
backgroundColor: "transparent",
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
const quietTabHover = {
|
|
41
|
+
color: colors.gray[12],
|
|
42
|
+
backgroundColor: surfaceWash(colors.grayAlpha[9], surfaceMixPercent.hover),
|
|
43
|
+
};
|
|
44
|
+
const tabClass = style({
|
|
45
|
+
position: "relative",
|
|
46
|
+
display: "flex",
|
|
47
|
+
alignItems: "center",
|
|
48
|
+
flex: "0 1 140px",
|
|
49
|
+
width: "140px",
|
|
50
|
+
minWidth: "56px",
|
|
51
|
+
maxWidth: "140px",
|
|
52
|
+
"&:not([data-selected='true']) > [data-close-control]": {
|
|
53
|
+
opacity: 0,
|
|
54
|
+
pointerEvents: "none",
|
|
55
|
+
},
|
|
56
|
+
"&:not([data-selected='true']):hover > [data-close-control]": {
|
|
57
|
+
opacity: 1,
|
|
58
|
+
pointerEvents: "auto",
|
|
59
|
+
},
|
|
60
|
+
"&:not([data-selected='true']):has(> button:first-child[data-hovered]) > [data-close-control], &:not([data-selected='true']):has(> [data-close-control][data-hovered]) > [data-close-control]": {
|
|
61
|
+
opacity: 1,
|
|
62
|
+
pointerEvents: "auto",
|
|
63
|
+
},
|
|
64
|
+
"&:not([data-selected='true']):hover > button:first-child": quietTabHover,
|
|
65
|
+
"&:not([data-selected='true']):has(> button:first-child[data-hovered]) > button:first-child, &:not([data-selected='true']):has(> [data-close-control][data-hovered]) > button:first-child": quietTabHover,
|
|
66
|
+
"&[data-selected='true'] > [data-close-control]:hover, &[data-selected='true'] > [data-close-control][data-hovered]": {
|
|
67
|
+
backgroundColor: backgroundColor.elementHover,
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
const tabButtonClass = style({
|
|
71
|
+
width: "100%",
|
|
72
|
+
minWidth: 0,
|
|
73
|
+
justifyContent: "flex-start",
|
|
74
|
+
"& > span": {
|
|
75
|
+
minWidth: 0,
|
|
76
|
+
overflow: "hidden",
|
|
77
|
+
textOverflow: "ellipsis",
|
|
78
|
+
textBox: "normal",
|
|
79
|
+
whiteSpace: "nowrap",
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
const selectedTabButtonClass = style({
|
|
83
|
+
backgroundColor: backgroundColor.element,
|
|
84
|
+
});
|
|
85
|
+
const closeButtonClass = style({
|
|
86
|
+
position: "absolute",
|
|
87
|
+
top: "50%",
|
|
88
|
+
insetInlineEnd: spacing.value(1),
|
|
89
|
+
zIndex: 1,
|
|
90
|
+
width: controlSize.smHeight,
|
|
91
|
+
minWidth: controlSize.smHeight,
|
|
92
|
+
maxWidth: controlSize.smHeight,
|
|
93
|
+
flexShrink: 0,
|
|
94
|
+
transform: "translateY(-50%)",
|
|
95
|
+
});
|
|
96
|
+
const separatorClass = style({
|
|
97
|
+
flex: "0 0 auto",
|
|
98
|
+
width: "0.5px",
|
|
99
|
+
height: "14px",
|
|
100
|
+
backgroundColor: colors.gray[6],
|
|
101
|
+
});
|
|
102
|
+
const addButtonClass = style({
|
|
103
|
+
flex: "0 0 auto",
|
|
104
|
+
marginInlineStart: spacing.value(2),
|
|
105
|
+
});
|
|
106
|
+
export function TabBar({ items, selectedId, onSelectionChange, onClose, onAdd, addLabel = "New tab", "aria-label": ariaLabel, className, }) {
|
|
107
|
+
const { direction } = useLocale();
|
|
108
|
+
const buttons = useRef(new Map());
|
|
109
|
+
const focusSelectedAfterClose = useRef(false);
|
|
110
|
+
const tabBarClassName = useStyles(tabBarClass);
|
|
111
|
+
const tabListClassName = useStyles(tabListClass);
|
|
112
|
+
const tabClassName = useStyles(tabClass);
|
|
113
|
+
const selectedTabButtonClassName = useStyles(buttonStyles.base, selectedTabButtonClass, tabButtonClass);
|
|
114
|
+
const quietTabButtonClassName = useStyles(buttonStyles.quiet, tabButtonClass);
|
|
115
|
+
const tabTextClassName = useStyles(buttonStyles.text);
|
|
116
|
+
const closeButtonClassName = useStyles(closeButtonClass);
|
|
117
|
+
const separatorClassName = useStyles(separatorClass);
|
|
118
|
+
const addButtonClassName = useStyles(addButtonClass);
|
|
119
|
+
useLayoutEffect(() => {
|
|
120
|
+
if (!focusSelectedAfterClose.current)
|
|
121
|
+
return;
|
|
122
|
+
focusSelectedAfterClose.current = false;
|
|
123
|
+
buttons.current.get(selectedId)?.focus();
|
|
124
|
+
}, [items, selectedId]);
|
|
125
|
+
function closeTab(id) {
|
|
126
|
+
if (!onClose)
|
|
127
|
+
return;
|
|
128
|
+
focusSelectedAfterClose.current = true;
|
|
129
|
+
onClose(id);
|
|
130
|
+
}
|
|
131
|
+
function selectAt(index) {
|
|
132
|
+
const item = items[index];
|
|
133
|
+
if (!item)
|
|
134
|
+
return;
|
|
135
|
+
onSelectionChange(item.id);
|
|
136
|
+
buttons.current.get(item.id)?.focus();
|
|
137
|
+
}
|
|
138
|
+
function onTabKeyDown(event, index, item) {
|
|
139
|
+
let nextIndex;
|
|
140
|
+
if (event.key === "Home")
|
|
141
|
+
nextIndex = 0;
|
|
142
|
+
if (event.key === "End")
|
|
143
|
+
nextIndex = items.length - 1;
|
|
144
|
+
if (event.key === "ArrowRight") {
|
|
145
|
+
nextIndex =
|
|
146
|
+
(index + (direction === "rtl" ? -1 : 1) + items.length) % items.length;
|
|
147
|
+
}
|
|
148
|
+
if (event.key === "ArrowLeft") {
|
|
149
|
+
nextIndex =
|
|
150
|
+
(index + (direction === "rtl" ? 1 : -1) + items.length) % items.length;
|
|
151
|
+
}
|
|
152
|
+
if ((event.key === "Delete" || event.key === "Backspace") &&
|
|
153
|
+
item.id === selectedId &&
|
|
154
|
+
item.isClosable &&
|
|
155
|
+
onClose) {
|
|
156
|
+
event.preventDefault();
|
|
157
|
+
closeTab(item.id);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (nextIndex === undefined)
|
|
161
|
+
return;
|
|
162
|
+
event.preventDefault();
|
|
163
|
+
selectAt(nextIndex);
|
|
164
|
+
}
|
|
165
|
+
return (_jsxs("div", { className: [tabBarClassName, className].filter(Boolean).join(" "), children: [_jsx("div", { className: tabListClassName, role: "toolbar", "aria-label": ariaLabel, children: items.map((item, index) => {
|
|
166
|
+
const selected = item.id === selectedId;
|
|
167
|
+
const reserveClose = Boolean(item.isClosable && onClose);
|
|
168
|
+
return (_jsxs(React.Fragment, { children: [_jsxs("div", { className: tabClassName, "data-selected": selected ? "true" : undefined, children: [_jsxs(RACButton, { ref: (element) => {
|
|
169
|
+
if (element)
|
|
170
|
+
buttons.current.set(item.id, element);
|
|
171
|
+
else
|
|
172
|
+
buttons.current.delete(item.id);
|
|
173
|
+
}, "aria-pressed": selected, excludeFromTabOrder: !selected, className: selected
|
|
174
|
+
? selectedTabButtonClassName
|
|
175
|
+
: quietTabButtonClassName, style: reserveClose ? { paddingInlineEnd: "32px" } : undefined, onPress: () => onSelectionChange(item.id), onKeyDown: (event) => onTabKeyDown(event, index, item), children: [item.icon, _jsx("span", { className: tabTextClassName, children: item.label })] }), reserveClose && (_jsx(Button, { size: "sm", variant: "quiet", "aria-label": `Close ${item.label}`, "data-close-control": true, excludeFromTabOrder: !selected, className: closeButtonClassName, onPress: () => closeTab(item.id), children: _jsx(Icons.Close, {}) }))] }), index < items.length - 1 && (_jsx("span", { className: separatorClassName, "aria-hidden": "true" }))] }, item.id));
|
|
176
|
+
}) }), onAdd && (_jsx(Button, { size: "sm", variant: "quiet", "aria-label": addLabel, className: addButtonClassName, onPress: onAdd, children: _jsx(Icons.Plus, {}) }))] }));
|
|
177
|
+
}
|
package/dist/maui.d.ts
CHANGED
|
@@ -20,7 +20,8 @@ export { prose, proseRhythm, proseHtml, proseStreamingMarkers, type ProseSize, }
|
|
|
20
20
|
export { visuallyHidden } from "./tokens/visuallyHidden";
|
|
21
21
|
export { Avatar } from "./components/Avatar";
|
|
22
22
|
export { Badge } from "./components/Badge";
|
|
23
|
-
export { Button, useButton, type ButtonCssColor, type ButtonProps, type ButtonVariant, type ButtonVariantColor, } from "./components/Button";
|
|
23
|
+
export { Button, useButton, type ButtonCssColor, type ButtonProps, type ButtonSize, type ButtonVariant, type ButtonVariantColor, } from "./components/Button";
|
|
24
|
+
export { TabBar, type TabBarItem, type TabBarProps, } from "./components/TabBar";
|
|
24
25
|
export { Checkbox } from "./components/Checkbox";
|
|
25
26
|
export { Switch } from "./components/Switch";
|
|
26
27
|
export { Slider } from "./components/Slider";
|
package/dist/maui.js
CHANGED
|
@@ -24,6 +24,7 @@ export { visuallyHidden } from "./tokens/visuallyHidden";
|
|
|
24
24
|
export { Avatar } from "./components/Avatar";
|
|
25
25
|
export { Badge } from "./components/Badge";
|
|
26
26
|
export { Button, useButton, } from "./components/Button";
|
|
27
|
+
export { TabBar, } from "./components/TabBar";
|
|
27
28
|
export { Checkbox } from "./components/Checkbox";
|
|
28
29
|
export { Switch } from "./components/Switch";
|
|
29
30
|
export { Slider } from "./components/Slider";
|
package/dist/tokens/shadow.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export declare const shadowVars: import("purse-styles").VariableGroup<{
|
|
2
2
|
readonly control: {
|
|
3
|
-
readonly default: "inset 0 1px #fff8, inset 0 0 1px 1px #fff8, 0 .5px 1px #00000018, 0 0
|
|
4
|
-
readonly ":root[data-theme=\"dark\"]": "inset 0 1px #ffffff08,
|
|
3
|
+
readonly default: "inset 0 1px #fff8, inset 0 0 1px 1px #fff8, 0 .5px 1px #00000018, 0 0 0 .5px rgb(0 0 0 / 8%), 0 0 4px -1px #0001";
|
|
4
|
+
readonly ":root[data-theme=\"dark\"]": "inset 0 1px #ffffff08, 0 0 0 .5px rgb(255 255 255 / 8%), 0 1px .5px #00000018, 0 0 3px -1px #000a";
|
|
5
5
|
};
|
|
6
6
|
readonly subtle: `rgba(var(--${string}), 0.08) 0px 0px 0px 1px, rgba(0, 0, 0, var(--${string})) 0px 1px 1px -0.5px, rgba(0, 0, 0, var(--${string})) 0px 3px 3px -1.5px`;
|
|
7
7
|
readonly medium: `rgba(var(--${string}), 0.08) 0px 0px 0px 1px, rgba(0, 0, 0, var(--${string})) 0px 1px 1px -0.5px, rgba(0, 0, 0, var(--${string})) 0px 3px 3px -1.5px, rgba(0, 0, 0, var(--${string})) 0px 6px 6px -3px`;
|
package/dist/tokens/shadow.js
CHANGED
|
@@ -14,8 +14,8 @@ const rgb = shadowParams.foregroundRgb;
|
|
|
14
14
|
const blur = shadowParams.blurOpacity;
|
|
15
15
|
export const shadowVars = defineVars({
|
|
16
16
|
control: {
|
|
17
|
-
default: `inset 0 1px #fff8, inset 0 0 1px 1px #fff8, 0 .5px 1px #00000018, 0 0
|
|
18
|
-
[DARK_THEME]: `inset 0 1px #ffffff08,
|
|
17
|
+
default: `inset 0 1px #fff8, inset 0 0 1px 1px #fff8, 0 .5px 1px #00000018, 0 0 0 .5px rgb(0 0 0 / 8%), 0 0 4px -1px #0001`,
|
|
18
|
+
[DARK_THEME]: `inset 0 1px #ffffff08, 0 0 0 .5px rgb(255 255 255 / 8%), 0 1px .5px #00000018, 0 0 3px -1px #000a`,
|
|
19
19
|
},
|
|
20
20
|
subtle: `rgba(${rgb}, 0.08) 0px 0px 0px 1px, rgba(0, 0, 0, ${blur}) 0px 1px 1px -0.5px, rgba(0, 0, 0, ${blur}) 0px 3px 3px -1.5px`,
|
|
21
21
|
medium: `rgba(${rgb}, 0.08) 0px 0px 0px 1px, rgba(0, 0, 0, ${blur}) 0px 1px 1px -0.5px, rgba(0, 0, 0, ${blur}) 0px 3px 3px -1.5px, rgba(0, 0, 0, ${blur}) 0px 6px 6px -3px`,
|
package/dist/tokens/sizing.d.ts
CHANGED
|
@@ -13,6 +13,10 @@ export declare const controlSize: import("purse-styles").VariableGroup<{
|
|
|
13
13
|
readonly default: "28px";
|
|
14
14
|
readonly ":root[data-scale=\"large\"]": "40px";
|
|
15
15
|
};
|
|
16
|
+
readonly smHeight: {
|
|
17
|
+
readonly default: "24px";
|
|
18
|
+
readonly ":root[data-scale=\"large\"]": "40px";
|
|
19
|
+
};
|
|
16
20
|
readonly minTarget: {
|
|
17
21
|
readonly default: "0px";
|
|
18
22
|
readonly ":root[data-scale=\"large\"]": "40px";
|
package/dist/tokens/sizing.js
CHANGED
|
@@ -16,6 +16,7 @@ export const iconSizeValues = defineVars({
|
|
|
16
16
|
/** Shared control height and minimum touch target. */
|
|
17
17
|
export const controlSize = defineVars({
|
|
18
18
|
height: { default: "28px", [LARGE_SCALE]: "40px" },
|
|
19
|
+
smHeight: { default: "24px", [LARGE_SCALE]: "40px" },
|
|
19
20
|
minTarget: { default: "0px", [LARGE_SCALE]: "40px" },
|
|
20
21
|
});
|
|
21
22
|
/** Reading measure for long-form columns. */
|
package/package.json
CHANGED
package/skills/maui/SKILL.md
CHANGED
|
@@ -23,6 +23,7 @@ Read the reference for the work you are doing:
|
|
|
23
23
|
| Avatar | [avatar.md](references/components/avatar.md) |
|
|
24
24
|
| Badge | [badge.md](references/components/badge.md) |
|
|
25
25
|
| Buttons | [buttons.md](references/components/buttons.md) (`Button`, `Overlay`, `Dialog`) |
|
|
26
|
+
| Tab bar | [tab-bar.md](references/components/tab-bar.md) |
|
|
26
27
|
| Drawer | [drawer.md](references/components/drawer.md) |
|
|
27
28
|
| Prose | [prose.md](references/components/prose.md) (`Prose`, `H1`–`H4`, `P`, lists, `Label`, `Link`) |
|
|
28
29
|
| Editor / MarkdownEditor | [editor.md](references/components/editor.md) (optional TipTap peers; separate imports) |
|
|
@@ -4,7 +4,7 @@ Gallery: `/components/buttons`. Import from `"maui"`. Overlay and Dialog live on
|
|
|
4
4
|
|
|
5
5
|
## `Button`
|
|
6
6
|
|
|
7
|
-
Styled React Aria `Button` (default `type="button"`). Consumes `ButtonContext`, so it works as a `MenuTrigger`, `Select`, `DialogTrigger`, `ComboBox`, or `DatePicker` child.
|
|
7
|
+
Styled React Aria `Button` (default `type="button"`). Consumes `ButtonContext`, so it works as a `MenuTrigger`, `Select`, `DialogTrigger`, `ComboBox`, or `DatePicker` child. Default height 28px, `shadow.control`, `radius` 4px.
|
|
8
8
|
|
|
9
9
|
```tsx
|
|
10
10
|
<Button>Save</Button>
|
|
@@ -12,6 +12,7 @@ Styled React Aria `Button` (default `type="button"`). Consumes `ButtonContext`,
|
|
|
12
12
|
<Button variant="quiet" variantColor="accent">Quiet accent</Button>
|
|
13
13
|
<Button variant="primary" variantColor="blue">Create</Button>
|
|
14
14
|
<Button variant="primary" variantColor="#6366f1">Indigo</Button>
|
|
15
|
+
<Button size="sm">Compact</Button>
|
|
15
16
|
<Button isDisabled>Wait</Button>
|
|
16
17
|
<Button aria-label="Search"><Search size="sm" /></Button>
|
|
17
18
|
<Button>
|
|
@@ -22,6 +23,7 @@ Styled React Aria `Button` (default `type="button"`). Consumes `ButtonContext`,
|
|
|
22
23
|
|
|
23
24
|
| Prop | Notes |
|
|
24
25
|
| --- | --- |
|
|
26
|
+
| `size` | `"default"` (28px) \| `"sm"` (24px with 10px text and 14px icons). Large/touch scale keeps both at a 40px target |
|
|
25
27
|
| `variant` | `"default"` (raised element) \| `"quiet"` (no shadow, transparent) \| `"primary"` (solid fill) |
|
|
26
28
|
| `variantColor` | Palette name or opaque `#hex` / `rgb(...)`. Primary default is `"accent"`. Quiet ignores color unless set; when set, tints the label with no fill |
|
|
27
29
|
| `isDisabled` | React Aria name. Maps to native `disabled`. **No `disabled` React prop** |
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Tab bar
|
|
2
|
+
|
|
3
|
+
Gallery: `/components/tab-bar`. Import from `"maui"`.
|
|
4
|
+
|
|
5
|
+
`TabBar` is a controlled, horizontally scrollable workspace tab toolbar. The selected tab uses Maui’s default outlined `Button` treatment. Unselected tabs are quiet and separated by hairlines. Optional icons, closing, and adding are built in.
|
|
6
|
+
|
|
7
|
+
```tsx
|
|
8
|
+
const [selectedId, setSelectedId] = useState("changes")
|
|
9
|
+
|
|
10
|
+
<TabBar
|
|
11
|
+
aria-label="Workspace tabs"
|
|
12
|
+
items={[
|
|
13
|
+
{ id: "changes", label: "Changes", icon: <FileText /> },
|
|
14
|
+
{ id: "pull-request", label: "PR #34", icon: <GitPullRequest />, isClosable: true },
|
|
15
|
+
]}
|
|
16
|
+
selectedId={selectedId}
|
|
17
|
+
onSelectionChange={setSelectedId}
|
|
18
|
+
onClose={(id) => closeTab(id)}
|
|
19
|
+
onAdd={() => addTab()}
|
|
20
|
+
addLabel="New session"
|
|
21
|
+
/>
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
| Prop | Notes |
|
|
25
|
+
| --- | --- |
|
|
26
|
+
| `items` | `{ id, label, icon?, isClosable? }[]` |
|
|
27
|
+
| `selectedId` | Controlled selected item ID |
|
|
28
|
+
| `onSelectionChange` | Called when a tab is pressed or selected with the keyboard |
|
|
29
|
+
| `onClose` | Enables a close control for every item where `isClosable` is true. Selected tabs show it persistently; unselected tabs reveal it on hover |
|
|
30
|
+
| `onAdd` | Shows an icon-only add button |
|
|
31
|
+
| `addLabel` | Accessible add-button label; defaults to `"New tab"` |
|
|
32
|
+
| `aria-label` | Required accessible label for the tab toolbar |
|
|
33
|
+
|
|
34
|
+
Left/Right arrows wrap through tabs (and respect RTL). Home/End select the first/last tab. Delete or Backspace calls `onClose` for a selected closable tab.
|
|
@@ -349,7 +349,7 @@ const paneClass = style(border([], "outline"), radius.lg, {
|
|
|
349
349
|
minHeight: 0,
|
|
350
350
|
minWidth: 0,
|
|
351
351
|
overflow: "hidden",
|
|
352
|
-
backgroundColor: backgroundColor.
|
|
352
|
+
backgroundColor: backgroundColor.app,
|
|
353
353
|
})
|
|
354
354
|
|
|
355
355
|
const paneHeaderClass = style(
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
TableRow,
|
|
35
35
|
} from "../../components/Table"
|
|
36
36
|
import { Text } from "../../components/Text"
|
|
37
|
+
import { TabBar } from "../../components/TabBar"
|
|
37
38
|
import { Thinking } from "../../components/Thinking"
|
|
38
39
|
import { Crossfade } from "../../components/Crossfade"
|
|
39
40
|
import { LoadingScreen } from "../../components/LoadingScreen"
|
|
@@ -248,6 +249,7 @@ export const catalog: CatalogComponent[] = [
|
|
|
248
249
|
info: "Button. default is raised, quiet is borderless, primary fills with accent 9. Works as a MenuTrigger child.",
|
|
249
250
|
html: true,
|
|
250
251
|
attributes: [
|
|
252
|
+
{ name: "size", values: ["default", "sm"] },
|
|
251
253
|
{ name: "variant", values: ["default", "quiet", "primary"] },
|
|
252
254
|
{
|
|
253
255
|
name: "variantColor",
|
|
@@ -259,6 +261,18 @@ export const catalog: CatalogComponent[] = [
|
|
|
259
261
|
{ name: "isDisabled", boolean: true },
|
|
260
262
|
],
|
|
261
263
|
},
|
|
264
|
+
{
|
|
265
|
+
name: "TabBar",
|
|
266
|
+
info: "Controlled workspace tab bar. Selected tab uses the default outlined button; quiet tabs have separators.",
|
|
267
|
+
html: true,
|
|
268
|
+
attributes: [
|
|
269
|
+
{ name: "items", object: true },
|
|
270
|
+
{ name: "selectedId" },
|
|
271
|
+
{ name: "aria-label" },
|
|
272
|
+
{ name: "addLabel" },
|
|
273
|
+
{ name: "className" },
|
|
274
|
+
],
|
|
275
|
+
},
|
|
262
276
|
{
|
|
263
277
|
name: "Drawer",
|
|
264
278
|
info: "Start/end edge modal overlay. RAC isOpen / onOpenChange. Swipe toward the edge, tap scrim, or Escape to dismiss. Not a Sidebar prop.",
|
|
@@ -534,6 +548,7 @@ export const previewScope: Record<string, unknown> = {
|
|
|
534
548
|
TableHeader,
|
|
535
549
|
TableRow,
|
|
536
550
|
Text,
|
|
551
|
+
TabBar,
|
|
537
552
|
Thinking,
|
|
538
553
|
Crossfade,
|
|
539
554
|
LoadingScreen,
|
|
@@ -23,6 +23,13 @@ describe("collectJsxDiagnosticsFromSource", () => {
|
|
|
23
23
|
expect(diagnostics).toEqual([])
|
|
24
24
|
})
|
|
25
25
|
|
|
26
|
+
test("accepts a small Button", () => {
|
|
27
|
+
const diagnostics = collectJsxDiagnosticsFromSource(
|
|
28
|
+
`<Button size="sm">Compact</Button>`,
|
|
29
|
+
)
|
|
30
|
+
expect(diagnostics).toEqual([])
|
|
31
|
+
})
|
|
32
|
+
|
|
26
33
|
test("accepts a braced string hex variantColor", () => {
|
|
27
34
|
const diagnostics = collectJsxDiagnosticsFromSource(
|
|
28
35
|
`<Button variant="primary" variantColor={"#1A73E8"}>Connect</Button>`,
|
|
@@ -213,6 +220,12 @@ describe("JSX editor catalog", () => {
|
|
|
213
220
|
).toBe(true)
|
|
214
221
|
})
|
|
215
222
|
|
|
223
|
+
test("catalog exposes TabBar", async () => {
|
|
224
|
+
const { catalog, previewScope } = await import("./catalog")
|
|
225
|
+
expect(catalog.some((entry) => entry.name === "TabBar")).toBe(true)
|
|
226
|
+
expect(previewScope).toHaveProperty("TabBar")
|
|
227
|
+
})
|
|
228
|
+
|
|
216
229
|
test("accepts Crossfade without direction", () => {
|
|
217
230
|
const diagnostics = collectJsxDiagnosticsFromSource(
|
|
218
231
|
`<Crossfade contentKey="inbox"><Text>Inbox</Text></Crossfade>`,
|
|
@@ -16,12 +16,13 @@ import { colors, type ColorName, type ColorScale } from "../tokens/colors"
|
|
|
16
16
|
import { focusRing } from "../tokens/focusRing"
|
|
17
17
|
import { radius } from "../tokens/radius"
|
|
18
18
|
import { shadowVars } from "../tokens/shadow"
|
|
19
|
-
import { controlSize } from "../tokens/sizing"
|
|
19
|
+
import { controlSize, iconSizeValues } from "../tokens/sizing"
|
|
20
20
|
import { spacing } from "../tokens/spacing"
|
|
21
21
|
import { text } from "../tokens/text"
|
|
22
22
|
import { memoize } from "../utils/memoize"
|
|
23
23
|
|
|
24
24
|
export type ButtonVariant = "default" | "quiet" | "primary"
|
|
25
|
+
export type ButtonSize = "default" | "sm"
|
|
25
26
|
|
|
26
27
|
const buttonShadow = shadowVars.control
|
|
27
28
|
|
|
@@ -84,6 +85,33 @@ const buttonTextClass = style({
|
|
|
84
85
|
textBox: "trim-both cap alphabetic",
|
|
85
86
|
})
|
|
86
87
|
|
|
88
|
+
const smallButtonClass = style(
|
|
89
|
+
text({ size: "2xs", fontWeight: 400, color: "highContrast" }),
|
|
90
|
+
{
|
|
91
|
+
height: controlSize.smHeight,
|
|
92
|
+
gap: spacing.value(1),
|
|
93
|
+
paddingInline: spacing.value(4),
|
|
94
|
+
paddingBlock: spacing.value(2),
|
|
95
|
+
"& > svg": {
|
|
96
|
+
width: iconSizeValues.xs,
|
|
97
|
+
height: iconSizeValues.xs,
|
|
98
|
+
},
|
|
99
|
+
"&:has(> svg)": {
|
|
100
|
+
paddingBlock: spacing.value(2),
|
|
101
|
+
},
|
|
102
|
+
"&:has(> svg:first-child:not(:only-child))": {
|
|
103
|
+
paddingInlineStart: spacing.value(3),
|
|
104
|
+
},
|
|
105
|
+
"&:has(> svg:last-child:not(:only-child))": {
|
|
106
|
+
paddingInlineEnd: spacing.value(3),
|
|
107
|
+
},
|
|
108
|
+
"&:has(> svg:only-child)": {
|
|
109
|
+
width: controlSize.smHeight,
|
|
110
|
+
paddingInline: 0,
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
)
|
|
114
|
+
|
|
87
115
|
const buttonClass = style(buttonBaseClass, {
|
|
88
116
|
backgroundColor: backgroundColor.element,
|
|
89
117
|
"&:hover:not(:disabled)": {
|
|
@@ -114,6 +142,13 @@ const quietButtonClass = quietClass(
|
|
|
114
142
|
colors.grayAlpha[9],
|
|
115
143
|
)
|
|
116
144
|
|
|
145
|
+
/** Internal style primitives for controls that compose Button visuals. */
|
|
146
|
+
export const buttonStyles = {
|
|
147
|
+
base: buttonBaseClass,
|
|
148
|
+
quiet: quietButtonClass,
|
|
149
|
+
text: buttonTextClass,
|
|
150
|
+
} as const
|
|
151
|
+
|
|
117
152
|
const darkTextOnSolid: ReadonlySet<ColorName> = new Set([
|
|
118
153
|
"amber",
|
|
119
154
|
"lime",
|
|
@@ -225,6 +260,7 @@ type ButtonData = {
|
|
|
225
260
|
export type ButtonProps = Omit<RACButtonProps, "children" | "className"> & {
|
|
226
261
|
children: React.ReactNode
|
|
227
262
|
className?: string
|
|
263
|
+
size?: ButtonSize
|
|
228
264
|
variant?: ButtonVariant
|
|
229
265
|
variantColor?: ButtonVariantColor
|
|
230
266
|
}
|
|
@@ -274,13 +310,17 @@ export const Button = React.forwardRef(function Button(
|
|
|
274
310
|
onClick,
|
|
275
311
|
onFocus,
|
|
276
312
|
type = "button",
|
|
313
|
+
size = "default",
|
|
277
314
|
variant = "default",
|
|
278
315
|
variantColor,
|
|
279
316
|
isDisabled,
|
|
280
317
|
...buttonProps
|
|
281
318
|
} = props
|
|
282
319
|
const [, attributes] = useButton({ onClick, onFocus }, forwardedRef)
|
|
283
|
-
const className = useStyles(
|
|
320
|
+
const className = useStyles(
|
|
321
|
+
buttonVariantClass(variant, variantColor),
|
|
322
|
+
size === "sm" && smallButtonClass,
|
|
323
|
+
)
|
|
284
324
|
const textClassName = useStyles(buttonTextClass)
|
|
285
325
|
const mergedClassName = [className, classNameProp].filter(Boolean).join(" ")
|
|
286
326
|
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import React, { useLayoutEffect, useRef } from "react"
|
|
2
|
+
import { useLocale } from "react-aria"
|
|
3
|
+
import { Button as RACButton } from "react-aria-components"
|
|
4
|
+
import { style, useStyles } from "purse-styles"
|
|
5
|
+
import {
|
|
6
|
+
backgroundColor,
|
|
7
|
+
surfaceMixPercent,
|
|
8
|
+
surfaceWash,
|
|
9
|
+
} from "../tokens/background"
|
|
10
|
+
import { colors } from "../tokens/colors"
|
|
11
|
+
import { controlSize } from "../tokens/sizing"
|
|
12
|
+
import { spacing } from "../tokens/spacing"
|
|
13
|
+
import { Button, buttonStyles } from "./Button"
|
|
14
|
+
import { Icons } from "./Icons"
|
|
15
|
+
|
|
16
|
+
export type TabBarItem = {
|
|
17
|
+
id: string
|
|
18
|
+
label: string
|
|
19
|
+
icon?: React.ReactNode
|
|
20
|
+
isClosable?: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type TabBarProps = {
|
|
24
|
+
items: readonly TabBarItem[]
|
|
25
|
+
selectedId: string
|
|
26
|
+
onSelectionChange: (id: string) => void
|
|
27
|
+
onClose?: (id: string) => void
|
|
28
|
+
onAdd?: () => void
|
|
29
|
+
addLabel?: string
|
|
30
|
+
"aria-label": string
|
|
31
|
+
className?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const tabBarClass = style({
|
|
35
|
+
display: "flex",
|
|
36
|
+
alignItems: "center",
|
|
37
|
+
height: `calc(${controlSize.height} + ${spacing.value(3)} * 2)`,
|
|
38
|
+
minWidth: 0,
|
|
39
|
+
paddingInline: spacing.value(4),
|
|
40
|
+
backgroundColor: backgroundColor.app,
|
|
41
|
+
borderBottom: `1px solid ${colors.gray[6]}`,
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const tabListClass = style({
|
|
45
|
+
display: "flex",
|
|
46
|
+
alignItems: "center",
|
|
47
|
+
flex: "0 1 auto",
|
|
48
|
+
minWidth: 0,
|
|
49
|
+
paddingBlock: spacing.value(3),
|
|
50
|
+
overflowX: "auto",
|
|
51
|
+
overflowY: "hidden",
|
|
52
|
+
scrollbarWidth: "none",
|
|
53
|
+
"&::-webkit-scrollbar": {
|
|
54
|
+
display: "none",
|
|
55
|
+
},
|
|
56
|
+
"& > div:has(button[data-hovered], button[aria-pressed='true']) + span": {
|
|
57
|
+
backgroundColor: "transparent",
|
|
58
|
+
},
|
|
59
|
+
"& > span:has(+ div button[data-hovered]), & > span:has(+ div button[aria-pressed='true'])": {
|
|
60
|
+
backgroundColor: "transparent",
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const quietTabHover = {
|
|
65
|
+
color: colors.gray[12],
|
|
66
|
+
backgroundColor: surfaceWash(
|
|
67
|
+
colors.grayAlpha[9],
|
|
68
|
+
surfaceMixPercent.hover,
|
|
69
|
+
),
|
|
70
|
+
} as const
|
|
71
|
+
|
|
72
|
+
const tabClass = style({
|
|
73
|
+
position: "relative",
|
|
74
|
+
display: "flex",
|
|
75
|
+
alignItems: "center",
|
|
76
|
+
flex: "0 1 140px",
|
|
77
|
+
width: "140px",
|
|
78
|
+
minWidth: "56px",
|
|
79
|
+
maxWidth: "140px",
|
|
80
|
+
"&:not([data-selected='true']) > [data-close-control]": {
|
|
81
|
+
opacity: 0,
|
|
82
|
+
pointerEvents: "none",
|
|
83
|
+
},
|
|
84
|
+
"&:not([data-selected='true']):hover > [data-close-control]": {
|
|
85
|
+
opacity: 1,
|
|
86
|
+
pointerEvents: "auto",
|
|
87
|
+
},
|
|
88
|
+
"&:not([data-selected='true']):has(> button:first-child[data-hovered]) > [data-close-control], &:not([data-selected='true']):has(> [data-close-control][data-hovered]) > [data-close-control]":
|
|
89
|
+
{
|
|
90
|
+
opacity: 1,
|
|
91
|
+
pointerEvents: "auto",
|
|
92
|
+
},
|
|
93
|
+
"&:not([data-selected='true']):hover > button:first-child": quietTabHover,
|
|
94
|
+
"&:not([data-selected='true']):has(> button:first-child[data-hovered]) > button:first-child, &:not([data-selected='true']):has(> [data-close-control][data-hovered]) > button:first-child":
|
|
95
|
+
quietTabHover,
|
|
96
|
+
"&[data-selected='true'] > [data-close-control]:hover, &[data-selected='true'] > [data-close-control][data-hovered]":
|
|
97
|
+
{
|
|
98
|
+
backgroundColor: backgroundColor.elementHover,
|
|
99
|
+
},
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
const tabButtonClass = style({
|
|
103
|
+
width: "100%",
|
|
104
|
+
minWidth: 0,
|
|
105
|
+
justifyContent: "flex-start",
|
|
106
|
+
"& > span": {
|
|
107
|
+
minWidth: 0,
|
|
108
|
+
overflow: "hidden",
|
|
109
|
+
textOverflow: "ellipsis",
|
|
110
|
+
textBox: "normal",
|
|
111
|
+
whiteSpace: "nowrap",
|
|
112
|
+
},
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
const selectedTabButtonClass = style({
|
|
116
|
+
backgroundColor: backgroundColor.element,
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
const closeButtonClass = style({
|
|
120
|
+
position: "absolute",
|
|
121
|
+
top: "50%",
|
|
122
|
+
insetInlineEnd: spacing.value(1),
|
|
123
|
+
zIndex: 1,
|
|
124
|
+
width: controlSize.smHeight,
|
|
125
|
+
minWidth: controlSize.smHeight,
|
|
126
|
+
maxWidth: controlSize.smHeight,
|
|
127
|
+
flexShrink: 0,
|
|
128
|
+
transform: "translateY(-50%)",
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
const separatorClass = style({
|
|
132
|
+
flex: "0 0 auto",
|
|
133
|
+
width: "0.5px",
|
|
134
|
+
height: "14px",
|
|
135
|
+
backgroundColor: colors.gray[6],
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
const addButtonClass = style({
|
|
139
|
+
flex: "0 0 auto",
|
|
140
|
+
marginInlineStart: spacing.value(2),
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
export function TabBar({
|
|
144
|
+
items,
|
|
145
|
+
selectedId,
|
|
146
|
+
onSelectionChange,
|
|
147
|
+
onClose,
|
|
148
|
+
onAdd,
|
|
149
|
+
addLabel = "New tab",
|
|
150
|
+
"aria-label": ariaLabel,
|
|
151
|
+
className,
|
|
152
|
+
}: TabBarProps) {
|
|
153
|
+
const { direction } = useLocale()
|
|
154
|
+
const buttons = useRef(new Map<string, HTMLButtonElement>())
|
|
155
|
+
const focusSelectedAfterClose = useRef(false)
|
|
156
|
+
const tabBarClassName = useStyles(tabBarClass)
|
|
157
|
+
const tabListClassName = useStyles(tabListClass)
|
|
158
|
+
const tabClassName = useStyles(tabClass)
|
|
159
|
+
const selectedTabButtonClassName = useStyles(
|
|
160
|
+
buttonStyles.base,
|
|
161
|
+
selectedTabButtonClass,
|
|
162
|
+
tabButtonClass,
|
|
163
|
+
)
|
|
164
|
+
const quietTabButtonClassName = useStyles(
|
|
165
|
+
buttonStyles.quiet,
|
|
166
|
+
tabButtonClass,
|
|
167
|
+
)
|
|
168
|
+
const tabTextClassName = useStyles(buttonStyles.text)
|
|
169
|
+
const closeButtonClassName = useStyles(closeButtonClass)
|
|
170
|
+
const separatorClassName = useStyles(separatorClass)
|
|
171
|
+
const addButtonClassName = useStyles(addButtonClass)
|
|
172
|
+
|
|
173
|
+
useLayoutEffect(() => {
|
|
174
|
+
if (!focusSelectedAfterClose.current) return
|
|
175
|
+
focusSelectedAfterClose.current = false
|
|
176
|
+
buttons.current.get(selectedId)?.focus()
|
|
177
|
+
}, [items, selectedId])
|
|
178
|
+
|
|
179
|
+
function closeTab(id: string) {
|
|
180
|
+
if (!onClose) return
|
|
181
|
+
focusSelectedAfterClose.current = true
|
|
182
|
+
onClose(id)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function selectAt(index: number) {
|
|
186
|
+
const item = items[index]
|
|
187
|
+
if (!item) return
|
|
188
|
+
onSelectionChange(item.id)
|
|
189
|
+
buttons.current.get(item.id)?.focus()
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function onTabKeyDown(
|
|
193
|
+
event: React.KeyboardEvent<HTMLButtonElement>,
|
|
194
|
+
index: number,
|
|
195
|
+
item: TabBarItem,
|
|
196
|
+
) {
|
|
197
|
+
let nextIndex: number | undefined
|
|
198
|
+
if (event.key === "Home") nextIndex = 0
|
|
199
|
+
if (event.key === "End") nextIndex = items.length - 1
|
|
200
|
+
if (event.key === "ArrowRight") {
|
|
201
|
+
nextIndex =
|
|
202
|
+
(index + (direction === "rtl" ? -1 : 1) + items.length) % items.length
|
|
203
|
+
}
|
|
204
|
+
if (event.key === "ArrowLeft") {
|
|
205
|
+
nextIndex =
|
|
206
|
+
(index + (direction === "rtl" ? 1 : -1) + items.length) % items.length
|
|
207
|
+
}
|
|
208
|
+
if (
|
|
209
|
+
(event.key === "Delete" || event.key === "Backspace") &&
|
|
210
|
+
item.id === selectedId &&
|
|
211
|
+
item.isClosable &&
|
|
212
|
+
onClose
|
|
213
|
+
) {
|
|
214
|
+
event.preventDefault()
|
|
215
|
+
closeTab(item.id)
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
if (nextIndex === undefined) return
|
|
219
|
+
event.preventDefault()
|
|
220
|
+
selectAt(nextIndex)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return (
|
|
224
|
+
<div className={[tabBarClassName, className].filter(Boolean).join(" ")}>
|
|
225
|
+
<div className={tabListClassName} role="toolbar" aria-label={ariaLabel}>
|
|
226
|
+
{items.map((item, index) => {
|
|
227
|
+
const selected = item.id === selectedId
|
|
228
|
+
const reserveClose = Boolean(item.isClosable && onClose)
|
|
229
|
+
|
|
230
|
+
return (
|
|
231
|
+
<React.Fragment key={item.id}>
|
|
232
|
+
<div
|
|
233
|
+
className={tabClassName}
|
|
234
|
+
data-selected={selected ? "true" : undefined}
|
|
235
|
+
>
|
|
236
|
+
<RACButton
|
|
237
|
+
ref={(element) => {
|
|
238
|
+
if (element) buttons.current.set(item.id, element)
|
|
239
|
+
else buttons.current.delete(item.id)
|
|
240
|
+
}}
|
|
241
|
+
aria-pressed={selected}
|
|
242
|
+
excludeFromTabOrder={!selected}
|
|
243
|
+
className={
|
|
244
|
+
selected
|
|
245
|
+
? selectedTabButtonClassName
|
|
246
|
+
: quietTabButtonClassName
|
|
247
|
+
}
|
|
248
|
+
style={reserveClose ? { paddingInlineEnd: "32px" } : undefined}
|
|
249
|
+
onPress={() => onSelectionChange(item.id)}
|
|
250
|
+
onKeyDown={(event) => onTabKeyDown(event, index, item)}
|
|
251
|
+
>
|
|
252
|
+
{item.icon}
|
|
253
|
+
<span className={tabTextClassName}>{item.label}</span>
|
|
254
|
+
</RACButton>
|
|
255
|
+
{reserveClose && (
|
|
256
|
+
<Button
|
|
257
|
+
size="sm"
|
|
258
|
+
variant="quiet"
|
|
259
|
+
aria-label={`Close ${item.label}`}
|
|
260
|
+
data-close-control
|
|
261
|
+
excludeFromTabOrder={!selected}
|
|
262
|
+
className={closeButtonClassName}
|
|
263
|
+
onPress={() => closeTab(item.id)}
|
|
264
|
+
>
|
|
265
|
+
<Icons.Close />
|
|
266
|
+
</Button>
|
|
267
|
+
)}
|
|
268
|
+
</div>
|
|
269
|
+
{index < items.length - 1 && (
|
|
270
|
+
<span className={separatorClassName} aria-hidden="true" />
|
|
271
|
+
)}
|
|
272
|
+
</React.Fragment>
|
|
273
|
+
)
|
|
274
|
+
})}
|
|
275
|
+
</div>
|
|
276
|
+
{onAdd && (
|
|
277
|
+
<Button
|
|
278
|
+
size="sm"
|
|
279
|
+
variant="quiet"
|
|
280
|
+
aria-label={addLabel}
|
|
281
|
+
className={addButtonClassName}
|
|
282
|
+
onPress={onAdd}
|
|
283
|
+
>
|
|
284
|
+
<Icons.Plus />
|
|
285
|
+
</Button>
|
|
286
|
+
)}
|
|
287
|
+
</div>
|
|
288
|
+
)
|
|
289
|
+
}
|
package/src/maui.ts
CHANGED
|
@@ -61,9 +61,15 @@ export {
|
|
|
61
61
|
useButton,
|
|
62
62
|
type ButtonCssColor,
|
|
63
63
|
type ButtonProps,
|
|
64
|
+
type ButtonSize,
|
|
64
65
|
type ButtonVariant,
|
|
65
66
|
type ButtonVariantColor,
|
|
66
67
|
} from "./components/Button"
|
|
68
|
+
export {
|
|
69
|
+
TabBar,
|
|
70
|
+
type TabBarItem,
|
|
71
|
+
type TabBarProps,
|
|
72
|
+
} from "./components/TabBar"
|
|
67
73
|
export { Checkbox } from "./components/Checkbox"
|
|
68
74
|
export { Switch } from "./components/Switch"
|
|
69
75
|
export { Slider } from "./components/Slider"
|
|
@@ -25,6 +25,27 @@ export function ButtonsPage() {
|
|
|
25
25
|
<Button onClick={() => setOverlayOpen(true)}>Open Overlay</Button>
|
|
26
26
|
</Flex>
|
|
27
27
|
|
|
28
|
+
<H3>Small</H3>
|
|
29
|
+
<P>
|
|
30
|
+
<Code>size="sm"</Code> creates a compact button for dense interfaces.
|
|
31
|
+
</P>
|
|
32
|
+
<Flex row alignItems="center" gap={4} style={{ flexWrap: "wrap" }}>
|
|
33
|
+
<Button size="sm">Button</Button>
|
|
34
|
+
<Button size="sm" variant="primary">
|
|
35
|
+
Save
|
|
36
|
+
</Button>
|
|
37
|
+
<Button size="sm" variant="quiet">
|
|
38
|
+
Quiet
|
|
39
|
+
</Button>
|
|
40
|
+
<Button size="sm">
|
|
41
|
+
<Plus />
|
|
42
|
+
Create
|
|
43
|
+
</Button>
|
|
44
|
+
<Button size="sm" aria-label="Search">
|
|
45
|
+
<Search />
|
|
46
|
+
</Button>
|
|
47
|
+
</Flex>
|
|
48
|
+
|
|
28
49
|
<H3>Disabled</H3>
|
|
29
50
|
<P>
|
|
30
51
|
<Code>isDisabled</Code> is React Aria’s disabled prop. It sets the
|
package/src/pages/Maui.tsx
CHANGED
|
@@ -69,6 +69,7 @@ import { SpacingTokenPage } from "./SpacingTokenPage"
|
|
|
69
69
|
import { TextPage } from "./TextPage"
|
|
70
70
|
import { TextTokenPage } from "./TextTokenPage"
|
|
71
71
|
import { TablePage } from "./TablePage"
|
|
72
|
+
import { TabBarPage } from "./TabBarPage"
|
|
72
73
|
import { TooltipPage } from "./TooltipPage"
|
|
73
74
|
|
|
74
75
|
export function Maui() {
|
|
@@ -139,6 +140,7 @@ const navigation: NavEntry[] = [
|
|
|
139
140
|
{ label: "Avatar", path: "/components/avatar", page: AvatarPage },
|
|
140
141
|
{ label: "Badge", path: "/components/badge", page: BadgePage },
|
|
141
142
|
{ label: "Buttons", path: "/components/buttons", page: ButtonsPage },
|
|
143
|
+
{ label: "Tab bar", path: "/components/tab-bar", page: TabBarPage },
|
|
142
144
|
{ label: "Drawer", path: "/components/drawer", page: DrawerPage },
|
|
143
145
|
{
|
|
144
146
|
label: "Prose",
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { useRef, useState } from "react"
|
|
2
|
+
import { TabBar, type TabBarItem } from "../components/TabBar"
|
|
3
|
+
import { Code } from "../components/Code"
|
|
4
|
+
import { Icons } from "../components/Icons"
|
|
5
|
+
import { Prose } from "../components/Prose"
|
|
6
|
+
import { H2, P } from "../components/Typography"
|
|
7
|
+
import { Panel } from "./Panel"
|
|
8
|
+
|
|
9
|
+
const initialTabs: TabBarItem[] = [
|
|
10
|
+
{
|
|
11
|
+
id: "changes",
|
|
12
|
+
label: "Changes",
|
|
13
|
+
icon: <Icons.FileText />,
|
|
14
|
+
isClosable: true,
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
id: "pr-339",
|
|
18
|
+
label: "PR #339",
|
|
19
|
+
icon: <Icons.GitPullRequest />,
|
|
20
|
+
isClosable: true,
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: "desktop",
|
|
24
|
+
label: "Desktop",
|
|
25
|
+
icon: <Icons.World />,
|
|
26
|
+
isClosable: true,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: "pr-34",
|
|
30
|
+
label: "PR #34",
|
|
31
|
+
icon: <Icons.GitPullRequest />,
|
|
32
|
+
isClosable: true,
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: "github",
|
|
36
|
+
label: "Use sandbox",
|
|
37
|
+
icon: <Icons.Github />,
|
|
38
|
+
isClosable: true,
|
|
39
|
+
},
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
export function TabBarPage() {
|
|
43
|
+
const [tabs, setTabs] = useState(initialTabs)
|
|
44
|
+
const [selectedId, setSelectedId] = useState("pr-34")
|
|
45
|
+
const nextTabId = useRef(1)
|
|
46
|
+
|
|
47
|
+
function closeTab(id: string) {
|
|
48
|
+
setTabs((current) => {
|
|
49
|
+
const index = current.findIndex((tab) => tab.id === id)
|
|
50
|
+
const next = current.filter((tab) => tab.id !== id)
|
|
51
|
+
if (id === selectedId && next.length > 0) {
|
|
52
|
+
setSelectedId(next[Math.min(index, next.length - 1)]!.id)
|
|
53
|
+
}
|
|
54
|
+
return next
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function addTab() {
|
|
59
|
+
const id = `new-${nextTabId.current++}`
|
|
60
|
+
setTabs((current) => [
|
|
61
|
+
...current,
|
|
62
|
+
{ id, label: "New session", isClosable: true },
|
|
63
|
+
])
|
|
64
|
+
setSelectedId(id)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<Prose style={{ maxWidth: "1200px", paddingBottom: "32px" }}>
|
|
69
|
+
<H2>Tab bar</H2>
|
|
70
|
+
<P>
|
|
71
|
+
A horizontally scrollable workspace tab bar. The selected tab uses the
|
|
72
|
+
default outlined <Code>Button</Code> treatment; quiet tabs are separated
|
|
73
|
+
by hairlines. Arrow keys, Home, and End move selection. Delete or
|
|
74
|
+
Backspace closes a selected closable tab.
|
|
75
|
+
</P>
|
|
76
|
+
<Panel style={{ padding: 0, overflow: "hidden" }}>
|
|
77
|
+
<TabBar
|
|
78
|
+
aria-label="Workspace tabs"
|
|
79
|
+
items={tabs}
|
|
80
|
+
selectedId={selectedId}
|
|
81
|
+
onSelectionChange={setSelectedId}
|
|
82
|
+
onClose={closeTab}
|
|
83
|
+
onAdd={addTab}
|
|
84
|
+
addLabel="New session"
|
|
85
|
+
/>
|
|
86
|
+
<div style={{ minHeight: "120px" }} />
|
|
87
|
+
</Panel>
|
|
88
|
+
</Prose>
|
|
89
|
+
)
|
|
90
|
+
}
|
package/src/tokens/shadow.ts
CHANGED
|
@@ -17,8 +17,8 @@ const blur = shadowParams.blurOpacity
|
|
|
17
17
|
|
|
18
18
|
export const shadowVars = defineVars({
|
|
19
19
|
control: {
|
|
20
|
-
default: `inset 0 1px #fff8, inset 0 0 1px 1px #fff8, 0 .5px 1px #00000018, 0 0
|
|
21
|
-
[DARK_THEME]: `inset 0 1px #ffffff08,
|
|
20
|
+
default: `inset 0 1px #fff8, inset 0 0 1px 1px #fff8, 0 .5px 1px #00000018, 0 0 0 .5px rgb(0 0 0 / 8%), 0 0 4px -1px #0001`,
|
|
21
|
+
[DARK_THEME]: `inset 0 1px #ffffff08, 0 0 0 .5px rgb(255 255 255 / 8%), 0 1px .5px #00000018, 0 0 3px -1px #000a`,
|
|
22
22
|
},
|
|
23
23
|
subtle: `rgba(${rgb}, 0.08) 0px 0px 0px 1px, rgba(0, 0, 0, ${blur}) 0px 1px 1px -0.5px, rgba(0, 0, 0, ${blur}) 0px 3px 3px -1.5px`,
|
|
24
24
|
medium: `rgba(${rgb}, 0.08) 0px 0px 0px 1px, rgba(0, 0, 0, ${blur}) 0px 1px 1px -0.5px, rgba(0, 0, 0, ${blur}) 0px 3px 3px -1.5px, rgba(0, 0, 0, ${blur}) 0px 6px 6px -3px`,
|
package/src/tokens/sizing.ts
CHANGED
|
@@ -22,6 +22,7 @@ export const iconSizeValues: Record<IconSize, string> = defineVars({
|
|
|
22
22
|
/** Shared control height and minimum touch target. */
|
|
23
23
|
export const controlSize = defineVars({
|
|
24
24
|
height: { default: "28px", [LARGE_SCALE]: "40px" },
|
|
25
|
+
smHeight: { default: "24px", [LARGE_SCALE]: "40px" },
|
|
25
26
|
minTarget: { default: "0px", [LARGE_SCALE]: "40px" },
|
|
26
27
|
})
|
|
27
28
|
|