@tanishqkancharla/maui 0.0.63 → 0.0.66
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/README.md +17 -1
- package/dist/components/ActivityIndicator.d.ts +3 -1
- package/dist/components/ActivityIndicator.js +5 -1
- package/dist/components/Button.d.ts +4 -0
- package/dist/components/Button.js +80 -18
- package/dist/maui.d.ts +1 -0
- package/dist/maui.js +1 -0
- package/dist/utils/shiki.js +2 -2
- package/package.json +12 -13
- package/skills/maui/SKILL.md +5 -1
- package/skills/maui/references/components/activity-indicator.md +4 -5
- package/skills/maui/references/components/buttons.md +3 -0
- package/skills/maui/references/components/code.md +1 -1
- package/skills/maui/references/components/menu.md +22 -0
- package/skills/maui/references/interactions/pressable.md +25 -0
- package/src/apps/JsxEditor/catalog.ts +9 -1
- package/src/apps/JsxEditor/lint.test.ts +14 -0
- package/src/components/ActivityIndicator.test.tsx +30 -0
- package/src/components/ActivityIndicator.tsx +7 -2
- package/src/components/Button.test.tsx +46 -0
- package/src/components/Button.tsx +173 -23
- package/src/maui.ts +1 -0
- package/src/pages/ActivityIndicatorPage.tsx +22 -9
- package/src/pages/ButtonsPage.tsx +61 -4
- package/src/pages/MenuPage.tsx +34 -1
- package/src/patterns/AssistantMessage.tsx +0 -2
- package/src/utils/shiki.ts +2 -2
package/README.md
CHANGED
|
@@ -41,6 +41,8 @@ function App() {
|
|
|
41
41
|
|
|
42
42
|
Put the exported `themeFoucScript` in a classic inline `<script>` in `<head>` so `data-theme` is correct on first paint.
|
|
43
43
|
|
|
44
|
+
`CodeBlock` uses Shiki's JavaScript regex engine with Maui themes and lazy-loaded languages. No highlighting configuration is required.
|
|
45
|
+
|
|
44
46
|
## Develop
|
|
45
47
|
|
|
46
48
|
```bash
|
|
@@ -63,7 +65,21 @@ Vite serves the gallery at [http://localhost:5173/](http://localhost:5173/).
|
|
|
63
65
|
|
|
64
66
|
The default package export is compiled `dist/` JavaScript and declarations. TypeScript source remains available through the `source` export condition and `maui/src`. Patterns, demo apps, and the gallery `Panel` preview frame are reference implementations, not package components.
|
|
65
67
|
|
|
66
|
-
|
|
68
|
+
Push directly to `main`; PRs are not required. Each push prepares the next version of `@tanishqkancharla/maui`, creates a `vX.Y.Z` tag, and stages the verified tarball on npm with provenance. **Staging does not make the version public.** Add `[skip release]` to the push's head commit message to skip staging. Manual `vX.Y.Z` tag pushes also stage a release when the tag matches `package.json`.
|
|
69
|
+
|
|
70
|
+
To publish, review the version in npm's **Staged Packages** tab and approve it with 2FA. With npm 11.15.0 or newer, you can also use `npm stage list @tanishqkancharla/maui`, `npm stage view <stage-id>`, `npm stage download <stage-id>`, and `npm stage approve <stage-id>`. Approve versions in increasing order so an older approval does not move `latest` backward. Git tags reserve version numbers even while approval is pending or after a staged version is rejected; the next branch release uses a new version. A failed staging upload can be retried by manually running the workflow against its existing `vX.Y.Z` tag.
|
|
71
|
+
|
|
72
|
+
### Required npm security settings
|
|
73
|
+
|
|
74
|
+
In the package's npm settings:
|
|
75
|
+
|
|
76
|
+
1. Configure the GitHub trusted publisher for `tanishqkancharla/maui`, workflow filename `publish.yml`, with **stage-only** permission. Do not allow direct `npm publish` or dist-tag management. Audit all trusted publisher entries, not just this workflow's entry. If npm requires replacing an existing connection, copy its repository/workflow details, replace it with a stage-only connection, and complete the first staged upload within two days before the new connection expires.
|
|
77
|
+
2. Set **Publishing access** to **Require two-factor authentication and disallow bypass 2fa tokens (recommended)**. Revoke unused automation tokens.
|
|
78
|
+
3. Keep 2FA enabled on maintainer accounts. CI must never approve its own staged release.
|
|
79
|
+
|
|
80
|
+
These npm-side restrictions are essential: changing the workflow command alone does not prevent a modified workflow from attempting a direct publish. See npm's [staged publishing](https://docs.npmjs.com/staged-publishing) and [trusted publisher](https://docs.npmjs.com/trusted-publishers) documentation.
|
|
81
|
+
|
|
82
|
+
`npm run release` also stages rather than publishing directly and requires npm 11.15.0 or newer. The gallery still deploys independently to [maui.tanishqkancharla.dev](https://maui.tanishqkancharla.dev) through Vercel on `main`; npm approval does not gate that deployment.
|
|
67
83
|
|
|
68
84
|
Agent conventions for consuming Maui: [`skills/maui`](./skills/maui/SKILL.md).
|
|
69
85
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { type IconSize } from "../tokens/sizing";
|
|
1
2
|
export type ActivityIndicatorVariant = "primary" | "accent" | "muted";
|
|
2
3
|
export type ActivityIndicatorProps = {
|
|
3
|
-
size
|
|
4
|
+
/** Icon t-shirt size, or a custom CSS length. Defaults to 1em. */
|
|
5
|
+
size?: IconSize | (string & {});
|
|
4
6
|
variant?: ActivityIndicatorVariant;
|
|
5
7
|
className?: string;
|
|
6
8
|
"aria-label"?: string;
|
|
@@ -2,6 +2,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import { useEffect, useRef } from "react";
|
|
3
3
|
import { style, useStyles } from "purse-styles";
|
|
4
4
|
import { colors } from "../tokens/colors";
|
|
5
|
+
import { iconSizeValues } from "../tokens/sizing";
|
|
5
6
|
import { cls } from "../utils/cls";
|
|
6
7
|
// Geometry is in a 24×24 viewBox. Spokes end this far from the center.
|
|
7
8
|
const VIEWBOX_CENTER = 12;
|
|
@@ -28,6 +29,9 @@ const spokes = Array.from({ length: SPOKE_COUNT }, (_, index) => {
|
|
|
28
29
|
export function ActivityIndicator({ size = "1em", variant = "accent", className, "aria-label": ariaLabel = "Loading", }) {
|
|
29
30
|
const svgRef = useRef(null);
|
|
30
31
|
const rootClassName = useStyles(rootClass, variantStyles[variant]);
|
|
32
|
+
const dimension = Object.hasOwn(iconSizeValues, size)
|
|
33
|
+
? iconSizeValues[size]
|
|
34
|
+
: size;
|
|
31
35
|
useEffect(() => {
|
|
32
36
|
const animation = svgRef.current?.animate([{ transform: "rotate(0deg)" }, { transform: "rotate(360deg)" }], {
|
|
33
37
|
duration: ROTATION_MS,
|
|
@@ -36,7 +40,7 @@ export function ActivityIndicator({ size = "1em", variant = "accent", className,
|
|
|
36
40
|
});
|
|
37
41
|
return () => animation?.cancel();
|
|
38
42
|
}, []);
|
|
39
|
-
return (_jsx("span", { role: "status", "aria-label": ariaLabel, className: cls(rootClassName, className), style: { width:
|
|
43
|
+
return (_jsx("span", { role: "status", "aria-label": ariaLabel, className: cls(rootClassName, className), style: { width: dimension, height: dimension }, children: _jsx("svg", { ref: svgRef, "aria-hidden": "true", focusable: "false", viewBox: "0 0 24 24", width: "100%", height: "100%", children: spokes.map(({ angle, opacity }) => (_jsx("line", { x1: VIEWBOX_CENTER, y1: SPOKE_TOP, x2: VIEWBOX_CENTER, y2: SPOKE_TOP + SPOKE_LENGTH, stroke: "currentColor", strokeWidth: SPOKE_WIDTH, strokeLinecap: "round", opacity: opacity, transform: `rotate(${angle} ${VIEWBOX_CENTER} ${VIEWBOX_CENTER})` }, angle))) }) }));
|
|
40
44
|
}
|
|
41
45
|
const variantStyles = {
|
|
42
46
|
primary: style({
|
|
@@ -24,6 +24,8 @@ export type ButtonProps = Omit<RACButtonProps, "children" | "className"> & {
|
|
|
24
24
|
size?: ButtonSize;
|
|
25
25
|
variant?: ButtonVariant;
|
|
26
26
|
variantColor?: ButtonVariantColor;
|
|
27
|
+
/** Visible text beside the indicator while pending. Defaults to children. */
|
|
28
|
+
pendingLabel?: string;
|
|
27
29
|
};
|
|
28
30
|
export declare function useButton(props: Pick<ButtonProps, "onClick" | "onFocus">, forwardedRef?: React.ForwardedRef<HTMLButtonElement>): [
|
|
29
31
|
ButtonData,
|
|
@@ -40,5 +42,7 @@ export declare const Button: React.ForwardRefExoticComponent<Omit<RACButtonProps
|
|
|
40
42
|
size?: ButtonSize;
|
|
41
43
|
variant?: ButtonVariant;
|
|
42
44
|
variantColor?: ButtonVariantColor;
|
|
45
|
+
/** Visible text beside the indicator while pending. Defaults to children. */
|
|
46
|
+
pendingLabel?: string;
|
|
43
47
|
} & React.RefAttributes<HTMLButtonElement>>;
|
|
44
48
|
export {};
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import React, { useCallback } from "react";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React, { useCallback, useLayoutEffect, useState } from "react";
|
|
3
|
+
import { AnimatePresence, motion, useIsPresent, useReducedMotion, } from "motion/react";
|
|
3
4
|
import { useId, useObjectRef } from "react-aria";
|
|
4
|
-
import { Button as RACButton, } from "react-aria-components";
|
|
5
|
+
import { Button as RACButton, ProgressBar, } from "react-aria-components";
|
|
5
6
|
import { style, useStyles } from "purse-styles";
|
|
7
|
+
import { ActivityIndicator } from "./ActivityIndicator";
|
|
6
8
|
import { useFocus } from "../hooks/useFocus";
|
|
7
9
|
import { useRefCurrent } from "../hooks/useRefCurrent";
|
|
8
10
|
import { backgroundColor, surfaceMixPercent, surfaceWash, } from "../tokens/background";
|
|
@@ -15,6 +17,7 @@ import { spacing } from "../tokens/spacing";
|
|
|
15
17
|
import { text } from "../tokens/text";
|
|
16
18
|
import { memoize } from "../utils/memoize";
|
|
17
19
|
const buttonShadow = shadowVars.control;
|
|
20
|
+
const pendingTransition = { duration: 0.15, ease: "easeInOut" };
|
|
18
21
|
const disabledRaised = {
|
|
19
22
|
cursor: "default",
|
|
20
23
|
opacity: 1,
|
|
@@ -43,21 +46,28 @@ const buttonBaseClass = style(text({ size: "xs", fontWeight: 500, color: "highCo
|
|
|
43
46
|
textOverflow: "ellipsis",
|
|
44
47
|
overflow: "hidden",
|
|
45
48
|
whiteSpace: "nowrap",
|
|
46
|
-
|
|
49
|
+
transition: `padding-inline-start ${pendingTransition.duration}s ease-in-out`,
|
|
50
|
+
"@media (prefers-reduced-motion: reduce)": {
|
|
51
|
+
transition: "none",
|
|
52
|
+
},
|
|
53
|
+
"& > svg, & [data-button-content] > svg": {
|
|
47
54
|
flexShrink: 0,
|
|
48
55
|
},
|
|
49
|
-
"&:has(> svg)": {
|
|
56
|
+
"&:has(> svg), &:has([data-button-content] > svg)": {
|
|
50
57
|
paddingBlock: spacing.value(3),
|
|
51
58
|
},
|
|
52
|
-
"&:has(> svg:first-child:not(:only-child))": {
|
|
59
|
+
"&:has(> svg:first-child:not(:only-child)), &:has([data-button-content] > svg:first-child:not(:only-child))": {
|
|
53
60
|
paddingInlineStart: spacing.value(4),
|
|
54
61
|
},
|
|
55
|
-
"&:has(> svg:last-child:not(:only-child))": {
|
|
62
|
+
"&:has(> svg:last-child:not(:only-child)), &:has([data-button-content] > svg:last-child:not(:only-child))": {
|
|
56
63
|
paddingInlineEnd: spacing.value(4),
|
|
57
64
|
},
|
|
58
|
-
"&:has(> svg:only-child)": {
|
|
65
|
+
"&:has(> svg:only-child), &:has([data-button-content] > svg:only-child)": {
|
|
59
66
|
paddingInline: spacing.value(3),
|
|
60
67
|
},
|
|
68
|
+
"&[data-pending]": {
|
|
69
|
+
paddingInlineStart: `calc((${controlSize.height} - ${iconSizeValues.xs}) / 2)`,
|
|
70
|
+
},
|
|
61
71
|
"&:disabled": disabledRaised,
|
|
62
72
|
});
|
|
63
73
|
const buttonTextClass = style({
|
|
@@ -65,32 +75,60 @@ const buttonTextClass = style({
|
|
|
65
75
|
minWidth: 0,
|
|
66
76
|
textBox: "trim-both cap alphabetic",
|
|
67
77
|
});
|
|
78
|
+
const pendingIndicatorClass = style({
|
|
79
|
+
display: "inline-flex",
|
|
80
|
+
flexShrink: 0,
|
|
81
|
+
"& > span": { display: "inline-flex" },
|
|
82
|
+
});
|
|
83
|
+
const contentRootClass = style({
|
|
84
|
+
position: "relative",
|
|
85
|
+
display: "inline-flex",
|
|
86
|
+
alignItems: "center",
|
|
87
|
+
justifyContent: "center",
|
|
88
|
+
gap: "inherit",
|
|
89
|
+
minWidth: 0,
|
|
90
|
+
});
|
|
91
|
+
const contentLayerClass = style({
|
|
92
|
+
display: "inline-flex",
|
|
93
|
+
alignItems: "center",
|
|
94
|
+
gap: "inherit",
|
|
95
|
+
flexShrink: 0,
|
|
96
|
+
"&[inert]": {
|
|
97
|
+
position: "absolute",
|
|
98
|
+
insetBlock: 0,
|
|
99
|
+
insetInlineStart: 0,
|
|
100
|
+
width: "max-content",
|
|
101
|
+
},
|
|
102
|
+
});
|
|
68
103
|
const smallButtonClass = style(text({ size: "2xs", fontWeight: 500, color: "highContrast" }), {
|
|
69
104
|
height: controlSize.smHeight,
|
|
70
105
|
gap: spacing.value(1),
|
|
71
106
|
paddingInline: spacing.value(4),
|
|
72
107
|
paddingBlock: spacing.value(2),
|
|
73
|
-
"& > svg": {
|
|
108
|
+
"& > svg, & [data-button-content] > svg": {
|
|
74
109
|
width: iconSizeValues.xs,
|
|
75
110
|
height: iconSizeValues.xs,
|
|
76
111
|
},
|
|
77
|
-
"&:has(> svg)": {
|
|
112
|
+
"&:has(> svg), &:has([data-button-content] > svg)": {
|
|
78
113
|
paddingBlock: spacing.value(2),
|
|
79
114
|
},
|
|
80
|
-
"&:has(> svg:first-child:not(:only-child))": {
|
|
115
|
+
"&:has(> svg:first-child:not(:only-child)), &:has([data-button-content] > svg:first-child:not(:only-child))": {
|
|
81
116
|
paddingInlineStart: spacing.value(3),
|
|
82
117
|
},
|
|
83
|
-
"&:has(> svg:last-child:not(:only-child))": {
|
|
118
|
+
"&:has(> svg:last-child:not(:only-child)), &:has([data-button-content] > svg:last-child:not(:only-child))": {
|
|
84
119
|
paddingInlineEnd: spacing.value(3),
|
|
85
120
|
},
|
|
86
|
-
"&:has(> svg:only-child)": {
|
|
121
|
+
"&:has(> svg:only-child), &:has([data-button-content] > svg:only-child)": {
|
|
87
122
|
width: controlSize.smHeight,
|
|
88
123
|
paddingInline: 0,
|
|
89
124
|
},
|
|
125
|
+
"&[data-pending]": {
|
|
126
|
+
paddingInlineStart: `calc((${controlSize.smHeight} - ${iconSizeValues["2xs"]}) / 2)`,
|
|
127
|
+
},
|
|
90
128
|
});
|
|
91
129
|
const buttonClass = style(buttonBaseClass, {
|
|
92
130
|
backgroundColor: backgroundColor.element,
|
|
93
|
-
"&:hover:not(:disabled)": {
|
|
131
|
+
"&:hover:not(:disabled):not([data-pending])": {
|
|
94
132
|
backgroundColor: backgroundColor.elementHover,
|
|
95
133
|
},
|
|
96
134
|
});
|
|
@@ -101,7 +139,7 @@ const quietClass = memoize((color, hoverColor, wash) => style(buttonBaseClass, f
|
|
|
101
139
|
color,
|
|
102
140
|
backgroundColor: "transparent",
|
|
103
141
|
boxShadow: "none",
|
|
104
|
-
"&:hover:not(:disabled)": {
|
|
142
|
+
"&:hover:not(:disabled):not([data-pending])": {
|
|
105
143
|
color: hoverColor,
|
|
106
144
|
backgroundColor: surfaceWash(wash, surfaceMixPercent.hover),
|
|
107
145
|
},
|
|
@@ -163,7 +201,7 @@ const primaryClass = memoize((fill, textColor, lightEdge) => {
|
|
|
163
201
|
backgroundColor: fill,
|
|
164
202
|
backgroundImage: gradient,
|
|
165
203
|
boxShadow: primaryShadow,
|
|
166
|
-
"&:hover:not(:disabled)": {
|
|
204
|
+
"&:hover:not(:disabled):not([data-pending])": {
|
|
167
205
|
backgroundImage: `${primaryHoverSheen}, ${gradient}`,
|
|
168
206
|
},
|
|
169
207
|
"&:disabled": disabledRaised,
|
|
@@ -209,13 +247,37 @@ export const buttonStyles = {
|
|
|
209
247
|
label: buttonTextClass,
|
|
210
248
|
};
|
|
211
249
|
export const Button = React.forwardRef(function Button(props, forwardedRef) {
|
|
212
|
-
const { children, className: classNameProp, onClick, onFocus, type = "button", size = "default", variant = "default", variantColor, isDisabled, ...buttonProps } = props;
|
|
250
|
+
const { children, className: classNameProp, onClick, onFocus, type = "button", size = "default", variant = "default", variantColor, isDisabled, pendingLabel, ...buttonProps } = props;
|
|
213
251
|
const [, attributes] = useButton({ onClick, onFocus }, forwardedRef);
|
|
214
252
|
const className = useStyles(buttonStyles.root({ variant, size, variantColor }));
|
|
215
253
|
const textClassName = useStyles(buttonStyles.label);
|
|
254
|
+
const indicatorClassName = useStyles(pendingIndicatorClass);
|
|
216
255
|
const mergedClassName = [className, classNameProp].filter(Boolean).join(" ");
|
|
217
|
-
return (_jsx(RACButton, { ...buttonProps, ref: attributes.ref, type: type, isDisabled: isDisabled, className: mergedClassName, onClick: attributes.onClick, onFocus: attributes.onFocus, children: renderButtonChildren(children, textClassName) }));
|
|
256
|
+
return (_jsx(RACButton, { ...buttonProps, ref: attributes.ref, type: type, isDisabled: isDisabled, className: mergedClassName, onClick: attributes.onClick, onFocus: attributes.onFocus, children: ({ isPending }) => (_jsxs(ButtonContents, { isPending: isPending, children: [isPending && (_jsx(ProgressBar, { isIndeterminate: true, "aria-label": "Pending", className: indicatorClassName, children: _jsx("span", { "aria-hidden": "true", children: _jsx(ActivityIndicator, { variant: "primary", size: size === "sm" ? "2xs" : "xs" }) }) })), renderButtonChildren(isPending ? (pendingLabel ?? children) : children, textClassName)] })) }));
|
|
218
257
|
});
|
|
258
|
+
function ButtonContents({ isPending, children, }) {
|
|
259
|
+
const [width, setWidth] = useState();
|
|
260
|
+
const reduceMotion = useReducedMotion();
|
|
261
|
+
const className = useStyles(contentRootClass);
|
|
262
|
+
const transition = reduceMotion ? { duration: 0 } : pendingTransition;
|
|
263
|
+
return (_jsx(motion.span, { className: className, initial: false, animate: { width: width ?? "auto" }, transition: transition, children: _jsx(AnimatePresence, { initial: false, children: _jsx(ButtonContentLayer, { onResize: setWidth, transition: transition, children: children }, isPending ? "pending" : "ready") }) }));
|
|
264
|
+
}
|
|
265
|
+
function ButtonContentLayer({ children, onResize, transition, }) {
|
|
266
|
+
const ref = React.useRef(null);
|
|
267
|
+
const isPresent = useIsPresent();
|
|
268
|
+
const className = useStyles(contentLayerClass);
|
|
269
|
+
useLayoutEffect(() => {
|
|
270
|
+
const node = ref.current;
|
|
271
|
+
if (!node || !isPresent)
|
|
272
|
+
return;
|
|
273
|
+
const measure = () => onResize(node.getBoundingClientRect().width);
|
|
274
|
+
measure();
|
|
275
|
+
const observer = new ResizeObserver(measure);
|
|
276
|
+
observer.observe(node);
|
|
277
|
+
return () => observer.disconnect();
|
|
278
|
+
}, [isPresent, onResize, ref]);
|
|
279
|
+
return (_jsx(motion.span, { ref: ref, className: className, "data-button-content": isPresent ? "" : undefined, "aria-hidden": !isPresent || undefined, inert: !isPresent || undefined, initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: transition, children: children }));
|
|
280
|
+
}
|
|
219
281
|
function buttonVariantClass(variant, variantColor) {
|
|
220
282
|
if (variant === "primary") {
|
|
221
283
|
return coloredButtonClass("primary", variantColor ?? "accent");
|
package/dist/maui.d.ts
CHANGED
|
@@ -43,6 +43,7 @@ export { LoadingScreen, type LoadingScreenProps, } from "./components/LoadingScr
|
|
|
43
43
|
export { Icons, type IconProps } from "./components/Icons";
|
|
44
44
|
export * from "./icons/root";
|
|
45
45
|
export { ListBox, ListBoxItem, listBoxStyles, type ListBoxProps, type ListBoxItemProps, } from "./components/ListBox";
|
|
46
|
+
export { Pressable } from "react-aria-components";
|
|
46
47
|
export { MenuTrigger, Menu, MenuItem, menuStyles, type MenuTriggerProps, type MenuProps, type MenuItemProps, } from "./components/Menu";
|
|
47
48
|
export { FuzzyString } from "./components/FuzzyString";
|
|
48
49
|
export { Flex, Gap, Spacer, Divider, type FlexShadow, type FlexRadius, type FlexBorder, type FlexAlignItems, type FlexJustifyContent, type FlexBackground, } from "./components/Utils";
|
package/dist/maui.js
CHANGED
|
@@ -46,6 +46,7 @@ export { LoadingScreen, } from "./components/LoadingScreen";
|
|
|
46
46
|
export { Icons } from "./components/Icons";
|
|
47
47
|
export * from "./icons/root";
|
|
48
48
|
export { ListBox, ListBoxItem, listBoxStyles, } from "./components/ListBox";
|
|
49
|
+
export { Pressable } from "react-aria-components";
|
|
49
50
|
export { MenuTrigger, Menu, MenuItem, menuStyles, } from "./components/Menu";
|
|
50
51
|
export { FuzzyString } from "./components/FuzzyString";
|
|
51
52
|
export { Flex, Gap, Spacer, Divider, } from "./components/Utils";
|
package/dist/utils/shiki.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHighlighterCore } from "shiki/core";
|
|
2
|
-
import {
|
|
2
|
+
import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
|
|
3
3
|
import { mauiShikiThemeDark, mauiShikiThemeLight } from "./mauiShikiTheme";
|
|
4
4
|
/** Canonical Shiki language ids loaded into the highlighter. */
|
|
5
5
|
export const supportedCodeLangs = [
|
|
@@ -36,7 +36,7 @@ function getHighlighter() {
|
|
|
36
36
|
import("@shikijs/langs/json"),
|
|
37
37
|
import("@shikijs/langs/bash"),
|
|
38
38
|
],
|
|
39
|
-
engine:
|
|
39
|
+
engine: createJavaScriptRegexEngine(),
|
|
40
40
|
});
|
|
41
41
|
}
|
|
42
42
|
return highlighterPromise;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tanishqkancharla/maui",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.66",
|
|
4
4
|
"description": "Maui design system",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/maui.js",
|
|
@@ -74,32 +74,32 @@
|
|
|
74
74
|
"test": "vitest",
|
|
75
75
|
"mcp:browser": "node .cursor/libretto-browser-mcp.mjs",
|
|
76
76
|
"mcp:agentation": "node .cursor/agentation-mcp.mjs",
|
|
77
|
-
"release": "npm publish --access public --provenance"
|
|
77
|
+
"release": "npm stage publish --access public --provenance"
|
|
78
78
|
},
|
|
79
79
|
"author": "Tanishq Kancharla",
|
|
80
80
|
"license": "MIT",
|
|
81
81
|
"devDependencies": {
|
|
82
82
|
"@base-ui/react": "^1.9.0",
|
|
83
|
-
"@modelcontextprotocol/sdk": "^1.32.
|
|
83
|
+
"@modelcontextprotocol/sdk": "^1.32.2",
|
|
84
84
|
"@resvg/resvg-js": "^2.6.2",
|
|
85
85
|
"@silk-hq/components": "^0.10.1",
|
|
86
|
-
"@tiptap/core": "^3.32.
|
|
87
|
-
"@tiptap/extension-placeholder": "^3.32.
|
|
88
|
-
"@tiptap/markdown": "^3.32.
|
|
89
|
-
"@tiptap/pm": "^3.32.
|
|
90
|
-
"@tiptap/react": "^3.32.
|
|
91
|
-
"@tiptap/starter-kit": "^3.32.
|
|
86
|
+
"@tiptap/core": "^3.32.1",
|
|
87
|
+
"@tiptap/extension-placeholder": "^3.32.1",
|
|
88
|
+
"@tiptap/markdown": "^3.32.1",
|
|
89
|
+
"@tiptap/pm": "^3.32.1",
|
|
90
|
+
"@tiptap/react": "^3.32.1",
|
|
91
|
+
"@tiptap/starter-kit": "^3.32.1",
|
|
92
92
|
"@types/lodash": "^4.17.27",
|
|
93
|
-
"@types/node": "^26.6.
|
|
93
|
+
"@types/node": "^26.6.6",
|
|
94
94
|
"@types/react": "^19.3.0",
|
|
95
95
|
"@types/react-dom": "^19.3.0",
|
|
96
96
|
"@vitejs/plugin-react": "^6.1.2",
|
|
97
97
|
"agentation": "^3.1.2",
|
|
98
98
|
"agentation-mcp": "^1.3.2",
|
|
99
|
-
"dialkit": "^2.0
|
|
99
|
+
"dialkit": "^2.1.0",
|
|
100
100
|
"esbuild": "^0.28.2",
|
|
101
101
|
"libretto-browser-tools": "^0.1.2",
|
|
102
|
-
"motion": "^14.
|
|
102
|
+
"motion": "^14.8.0",
|
|
103
103
|
"process": "^0.11.10",
|
|
104
104
|
"satori": "^0.63.2",
|
|
105
105
|
"serve": "^14.2.6",
|
|
@@ -119,7 +119,6 @@
|
|
|
119
119
|
"@fontsource/commit-mono": "^5.3.0",
|
|
120
120
|
"@lezer/highlight": "^1.2.5",
|
|
121
121
|
"@radix-ui/colors": "^3.0.0",
|
|
122
|
-
"@streamdown/code": "^2.0.0",
|
|
123
122
|
"@types/inline-style-prefixer": "^5.0.3",
|
|
124
123
|
"@uiw/react-codemirror": "^4.25.12",
|
|
125
124
|
"beautiful-mermaid": "^1.1.3",
|
package/skills/maui/SKILL.md
CHANGED
|
@@ -16,6 +16,10 @@ Use Maui components. When one can't do what you need, build from the matching `r
|
|
|
16
16
|
| --------------------------------------------- | --------------------------------- |
|
|
17
17
|
| Tokens, `purse-styles`, motion, layout, theme | [tokens.md](references/tokens.md) |
|
|
18
18
|
|
|
19
|
+
## Interactions
|
|
20
|
+
|
|
21
|
+
- [Pressable](references/interactions/pressable.md) — custom press handling and trigger targets.
|
|
22
|
+
|
|
19
23
|
## Components
|
|
20
24
|
|
|
21
25
|
| Page | Reference |
|
|
@@ -37,7 +41,7 @@ Use Maui components. When one can't do what you need, build from the matching `r
|
|
|
37
41
|
| Select | [select.md](references/components/select.md) |
|
|
38
42
|
| List box | [list-box.md](references/components/list-box.md) |
|
|
39
43
|
| Table | [table.md](references/components/table.md) |
|
|
40
|
-
| Menu
|
|
44
|
+
| Menu / context menu | [menu.md](references/components/menu.md) |
|
|
41
45
|
| Tooltip | [tooltip.md](references/components/tooltip.md) |
|
|
42
46
|
| Layout utilities | [layout-utilities.md](references/components/layout-utilities.md) |
|
|
43
47
|
| FuzzyString | [fuzzy-string.md](references/components/fuzzy-string.md) |
|
|
@@ -2,15 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
Gallery: `/components/activity-indicator`. Import from `"maui"`.
|
|
4
4
|
|
|
5
|
-
iOS-style spinner: eight spokes with a fading tail that rotate in 45° steps. `size`
|
|
5
|
+
iOS-style spinner: eight spokes with a fading tail that rotate in 45° steps. `size` accepts the icon t-shirt scale (`"2xs"`–`"xl"`, including large-scale sizing) or a CSS length; default `1em`. `variant`: `"accent"` (default) | `"primary"` (currentColor) | `"muted"`. `aria-label` defaults to `"Loading"`.
|
|
6
6
|
|
|
7
7
|
On an accent fill, such as a primary button, pass `variant="primary"` so the spinner takes the button text color.
|
|
8
8
|
|
|
9
9
|
Use it for loading: fetching, saving, syncing, and other indeterminate waits. When waiting on LLM inference, use [Thinking](thinking.md) instead. The `Spinner` icon is a static glyph, not this component.
|
|
10
10
|
|
|
11
11
|
```tsx
|
|
12
|
-
<
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
</Button>
|
|
12
|
+
<ActivityIndicator size="xs" aria-label="Syncing" />
|
|
13
|
+
<ActivityIndicator size="37px" />
|
|
14
|
+
<Button variant="primary" isPending pendingLabel="Saving…">Save</Button>
|
|
16
15
|
```
|
|
@@ -14,6 +14,7 @@ Styled React Aria `Button` (default `type="button"`). Consumes `ButtonContext`,
|
|
|
14
14
|
<Button variant="primary" variantColor="#6366f1">Indigo</Button>
|
|
15
15
|
<Button size="sm">Compact</Button>
|
|
16
16
|
<Button isDisabled>Wait</Button>
|
|
17
|
+
<Button isPending={isSaving} pendingLabel="Saving…">Save</Button>
|
|
17
18
|
<Button aria-label="Search"><Search size="sm" /></Button>
|
|
18
19
|
<Button>
|
|
19
20
|
<Plus size="sm" />
|
|
@@ -27,6 +28,8 @@ Styled React Aria `Button` (default `type="button"`). Consumes `ButtonContext`,
|
|
|
27
28
|
| `variant` | `"default"` (raised element) \| `"quiet"` (no shadow, transparent) \| `"primary"` (solid fill) |
|
|
28
29
|
| `variantColor` | Palette name or opaque `#hex` / `rgb(...)`. Primary default is `"accent"`. Quiet ignores color unless set; when set, tints the label with no fill |
|
|
29
30
|
| `isDisabled` | React Aria name. Maps to native `disabled`. **No `disabled` React prop** |
|
|
31
|
+
| `isPending` | Shows a current-color indicator beside visible content: `xs` (14px) by default, `2xs` (12px) for small buttons, with equal leading/top/bottom insets. Crossfades content and resizes over 150ms in both directions; reduced motion switches instantly. Blocks activation immediately and suppresses hover fill, retaining focusability and variant color. Controlled by the caller, not by returned promises |
|
|
32
|
+
| `pendingLabel` | Optional visible string replacing children while pending (e.g. `"Saving…"`). Omit to keep children; provide it to replace an icon with progress text |
|
|
30
33
|
| `onClick` | Still supported. React Aria also exposes `onPress` |
|
|
31
34
|
| `children` | Text is wrapped for cap-height trim; SVG icons sit beside text. Icon-only needs `aria-label` |
|
|
32
35
|
|
|
@@ -8,7 +8,7 @@ Gallery: `/components/code`. Import from `"maui"`.
|
|
|
8
8
|
<CodeBlock lang="ts">{`const n = 1`}</CodeBlock>
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
`CodeBlock` highlights with Shiki using the resolved theme. `lang` is required. Unsupported langs / pending highlight render a plain `<pre>`.
|
|
11
|
+
`CodeBlock` highlights with Shiki's JavaScript engine using the resolved theme. `lang` is required. Unsupported langs / pending highlight render a plain `<pre>`.
|
|
12
12
|
|
|
13
13
|
## Styles
|
|
14
14
|
|
|
@@ -27,6 +27,28 @@ Exactly two children on `MenuTrigger`: **trigger**, then **menu**. Use Maui `But
|
|
|
27
27
|
|
|
28
28
|
`placement` defaults to `"bottom start"`. Popover uses `shadow.strong` via `CollectionPopover`.
|
|
29
29
|
|
|
30
|
+
## Context menus
|
|
31
|
+
|
|
32
|
+
Use `trigger="contextMenu"` with the same `Menu` and `MenuItem`. Wrap custom targets in [Pressable](../interactions/pressable.md); its interaction reference covers target requirements and ordinary menu triggers. Maui `Button` already integrates with React Aria and needs no `Pressable` wrapper.
|
|
33
|
+
|
|
34
|
+
```tsx
|
|
35
|
+
import { MenuTrigger, Menu, MenuItem, Pressable } from "@tanishqkancharla/maui"
|
|
36
|
+
|
|
37
|
+
<MenuTrigger trigger="contextMenu">
|
|
38
|
+
<Pressable>
|
|
39
|
+
<div role="button" tabIndex={0}>Report.pdf</div>
|
|
40
|
+
</Pressable>
|
|
41
|
+
<Menu aria-label="File actions" onAction={(key) => doAction(String(key))}>
|
|
42
|
+
<MenuItem id="rename">Rename</MenuItem>
|
|
43
|
+
<MenuItem id="delete">Delete</MenuItem>
|
|
44
|
+
</Menu>
|
|
45
|
+
</MenuTrigger>
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
React Aria handles right-click, touch long-press, and OS/screen-reader context-menu commands (for example Shift+F10 on Windows/Linux or Control+Enter on macOS). Keep a visible Actions button offering the same actions; context menus are an enhancement, not the only entry point. Preserve native context menus on editable content and links when users need browser actions.
|
|
49
|
+
|
|
50
|
+
The current popup still uses `CollectionPopover` defaults: a 6px offset and minimum width equal to the target width. Use a compact target when that sizing is appropriate. See [React Aria's context-menu documentation](https://react-aria.adobe.com/Menu#context-menu) for interaction details.
|
|
51
|
+
|
|
30
52
|
## Styles
|
|
31
53
|
|
|
32
54
|
**Menu** — `menuStyles`
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Pressable
|
|
2
|
+
|
|
3
|
+
Import from `"@tanishqkancharla/maui"`. Maui directly re-exports React Aria's `Pressable` as a shared interaction primitive, not a menu-specific component. Prefer Maui `Button` when you do not need a custom control.
|
|
4
|
+
|
|
5
|
+
## Press handling
|
|
6
|
+
|
|
7
|
+
`Pressable` adds normalized press and focus behavior to one custom element, without styling or an extra DOM wrapper. `onPress` handles mouse, touch, keyboard, and screen-reader activation; React Aria handles cancellation, including scrolling away during a touch interaction.
|
|
8
|
+
|
|
9
|
+
```tsx
|
|
10
|
+
import { Pressable } from "@tanishqkancharla/maui"
|
|
11
|
+
|
|
12
|
+
<Pressable onPress={handleAction}>
|
|
13
|
+
<button type="button">Custom control</button>
|
|
14
|
+
</Pressable>
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Custom triggers
|
|
18
|
+
|
|
19
|
+
Wrap a custom target in `Pressable` to connect it to React Aria triggers, including ordinary `MenuTrigger` dropdowns, context menus, and React Aria's `DialogTrigger`. Maui `Button` already handles trigger integration and needs no `Pressable` wrapper. See [Menu](../components/menu.md) for the context-menu recipe.
|
|
20
|
+
|
|
21
|
+
## Child requirements
|
|
22
|
+
|
|
23
|
+
- The single child must be a semantic interactive element or have an appropriate interactive ARIA role.
|
|
24
|
+
- Custom components must forward their ref and spread props onto that element.
|
|
25
|
+
- Apply your own styling with Maui tokens, including `focusRing()` for visible keyboard focus.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Pressable } from "react-aria-components"
|
|
1
2
|
import { Avatar } from "../../components/Avatar"
|
|
2
3
|
import { Badge } from "../../components/Badge"
|
|
3
4
|
import { Banner } from "../../components/Banner"
|
|
@@ -441,8 +442,9 @@ export const catalog: CatalogComponent[] = [
|
|
|
441
442
|
},
|
|
442
443
|
{
|
|
443
444
|
name: "MenuTrigger",
|
|
444
|
-
info: "Exactly two children: a Maui Button trigger, then Menu.
|
|
445
|
+
info: "Exactly two children: a Maui Button or Pressable custom trigger, then Menu. Use trigger=\"contextMenu\" for right-click actions.",
|
|
445
446
|
attributes: [
|
|
447
|
+
{ name: "trigger", values: ["press", "longPress", "contextMenu"] },
|
|
446
448
|
{
|
|
447
449
|
name: "placement",
|
|
448
450
|
values: [
|
|
@@ -458,6 +460,11 @@ export const catalog: CatalogComponent[] = [
|
|
|
458
460
|
},
|
|
459
461
|
],
|
|
460
462
|
},
|
|
463
|
+
{
|
|
464
|
+
name: "Pressable",
|
|
465
|
+
info: "React Aria behavior for one custom interactive child. Adds no DOM wrapper or styling; the child must forward props and ref.",
|
|
466
|
+
attributes: [{ name: "isDisabled", boolean: true }],
|
|
467
|
+
},
|
|
461
468
|
{
|
|
462
469
|
name: "Menu",
|
|
463
470
|
info: "Popover collection of actions. Second child of MenuTrigger.",
|
|
@@ -607,6 +614,7 @@ export const previewScope: Record<string, unknown> = {
|
|
|
607
614
|
Menu,
|
|
608
615
|
MenuItem,
|
|
609
616
|
MenuTrigger,
|
|
617
|
+
Pressable,
|
|
610
618
|
MermaidDiagram,
|
|
611
619
|
Overlay,
|
|
612
620
|
MarkdownEditor,
|
|
@@ -168,6 +168,20 @@ describe("collectJsxDiagnosticsFromSource", () => {
|
|
|
168
168
|
)
|
|
169
169
|
expect(diagnostics).toEqual([])
|
|
170
170
|
})
|
|
171
|
+
|
|
172
|
+
test("accepts context menus with a Pressable custom target", () => {
|
|
173
|
+
const diagnostics = collectJsxDiagnosticsFromSource(
|
|
174
|
+
`<MenuTrigger trigger="contextMenu">
|
|
175
|
+
<Pressable>
|
|
176
|
+
<div role="button" tabIndex={0}>Report.pdf</div>
|
|
177
|
+
</Pressable>
|
|
178
|
+
<Menu aria-label="File actions">
|
|
179
|
+
<MenuItem id="rename">Rename</MenuItem>
|
|
180
|
+
</Menu>
|
|
181
|
+
</MenuTrigger>`,
|
|
182
|
+
)
|
|
183
|
+
expect(diagnostics).toEqual([])
|
|
184
|
+
})
|
|
171
185
|
})
|
|
172
186
|
|
|
173
187
|
describe("JSX editor catalog", () => {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from "react-dom/server"
|
|
2
|
+
import { PurseProvider } from "purse-styles"
|
|
3
|
+
import { afterAll, beforeAll, expect, it, vi } from "vitest"
|
|
4
|
+
import { iconSizeValues } from "../tokens/sizing"
|
|
5
|
+
import { ActivityIndicator } from "./ActivityIndicator"
|
|
6
|
+
|
|
7
|
+
beforeAll(() => {
|
|
8
|
+
vi.stubGlobal("document", { createElement: () => ({}) })
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
afterAll(() => {
|
|
12
|
+
vi.unstubAllGlobals()
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it("uses icon scale tokens for t-shirt sizes while preserving CSS lengths and the default", () => {
|
|
16
|
+
for (const [size, expected] of [
|
|
17
|
+
["2xs", iconSizeValues["2xs"]],
|
|
18
|
+
["xs", iconSizeValues.xs],
|
|
19
|
+
["37px", "37px"],
|
|
20
|
+
[undefined, "1em"],
|
|
21
|
+
] as const) {
|
|
22
|
+
const html = renderToStaticMarkup(
|
|
23
|
+
<PurseProvider>
|
|
24
|
+
<ActivityIndicator size={size} />
|
|
25
|
+
</PurseProvider>,
|
|
26
|
+
)
|
|
27
|
+
expect(html).toContain(`width:${expected}`)
|
|
28
|
+
expect(html).toContain(`height:${expected}`)
|
|
29
|
+
}
|
|
30
|
+
})
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useEffect, useRef } from "react"
|
|
2
2
|
import { style, useStyles } from "purse-styles"
|
|
3
3
|
import { colors } from "../tokens/colors"
|
|
4
|
+
import { iconSizeValues, type IconSize } from "../tokens/sizing"
|
|
4
5
|
import { cls } from "../utils/cls"
|
|
5
6
|
|
|
6
7
|
// Geometry is in a 24×24 viewBox. Spokes end this far from the center.
|
|
@@ -27,7 +28,8 @@ const spokes = Array.from({ length: SPOKE_COUNT }, (_, index) => {
|
|
|
27
28
|
export type ActivityIndicatorVariant = "primary" | "accent" | "muted"
|
|
28
29
|
|
|
29
30
|
export type ActivityIndicatorProps = {
|
|
30
|
-
size
|
|
31
|
+
/** Icon t-shirt size, or a custom CSS length. Defaults to 1em. */
|
|
32
|
+
size?: IconSize | (string & {})
|
|
31
33
|
variant?: ActivityIndicatorVariant
|
|
32
34
|
className?: string
|
|
33
35
|
"aria-label"?: string
|
|
@@ -46,6 +48,9 @@ export function ActivityIndicator({
|
|
|
46
48
|
}: ActivityIndicatorProps) {
|
|
47
49
|
const svgRef = useRef<SVGSVGElement>(null)
|
|
48
50
|
const rootClassName = useStyles(rootClass, variantStyles[variant])
|
|
51
|
+
const dimension = Object.hasOwn(iconSizeValues, size)
|
|
52
|
+
? iconSizeValues[size as IconSize]
|
|
53
|
+
: size
|
|
49
54
|
|
|
50
55
|
useEffect(() => {
|
|
51
56
|
const animation = svgRef.current?.animate(
|
|
@@ -64,7 +69,7 @@ export function ActivityIndicator({
|
|
|
64
69
|
role="status"
|
|
65
70
|
aria-label={ariaLabel}
|
|
66
71
|
className={cls(rootClassName, className)}
|
|
67
|
-
style={{ width:
|
|
72
|
+
style={{ width: dimension, height: dimension }}
|
|
68
73
|
>
|
|
69
74
|
<svg
|
|
70
75
|
ref={svgRef}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from "react-dom/server"
|
|
2
|
+
import { ButtonContext } from "react-aria-components"
|
|
3
|
+
import { PurseProvider } from "purse-styles"
|
|
4
|
+
import { afterAll, beforeAll, expect, it, vi } from "vitest"
|
|
5
|
+
import { Button } from "./Button"
|
|
6
|
+
|
|
7
|
+
// Maui's client-only focus store has no server snapshot.
|
|
8
|
+
vi.mock("../hooks/useFocus", () => ({
|
|
9
|
+
useFocus: () => [false, {}],
|
|
10
|
+
}))
|
|
11
|
+
|
|
12
|
+
beforeAll(() => {
|
|
13
|
+
vi.stubGlobal("document", { createElement: () => ({}) })
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
afterAll(() => {
|
|
17
|
+
vi.unstubAllGlobals()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it("shows progress beside a visible label only while pending, including from context", () => {
|
|
21
|
+
const render = (isPending: boolean, pendingLabel?: string) =>
|
|
22
|
+
renderToStaticMarkup(
|
|
23
|
+
<PurseProvider>
|
|
24
|
+
<ButtonContext.Provider value={{ isPending }}>
|
|
25
|
+
<Button pendingLabel={pendingLabel}>Save changes</Button>
|
|
26
|
+
</ButtonContext.Provider>
|
|
27
|
+
</PurseProvider>,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
const pending = render(true, "Saving changes…")
|
|
31
|
+
expect(pending).toContain("Saving changes…")
|
|
32
|
+
expect(pending).not.toContain("Save changes")
|
|
33
|
+
expect(pending).toContain('role="progressbar"')
|
|
34
|
+
expect(pending).toContain('aria-label="Pending"')
|
|
35
|
+
expect(pending).toContain('aria-disabled="true"')
|
|
36
|
+
expect(pending).not.toMatch(/\sdisabled=/)
|
|
37
|
+
|
|
38
|
+
const fallback = render(true)
|
|
39
|
+
expect(fallback).toContain("Save changes")
|
|
40
|
+
expect(fallback).toContain('role="progressbar"')
|
|
41
|
+
|
|
42
|
+
const ready = render(false, "Saving changes…")
|
|
43
|
+
expect(ready).toContain("Save changes")
|
|
44
|
+
expect(ready).not.toContain("Saving changes…")
|
|
45
|
+
expect(ready).not.toContain('role="progressbar"')
|
|
46
|
+
})
|
|
@@ -1,10 +1,19 @@
|
|
|
1
|
-
import React, { useCallback } from "react"
|
|
1
|
+
import React, { useCallback, useLayoutEffect, useState } from "react"
|
|
2
|
+
import {
|
|
3
|
+
AnimatePresence,
|
|
4
|
+
motion,
|
|
5
|
+
useIsPresent,
|
|
6
|
+
useReducedMotion,
|
|
7
|
+
type Transition,
|
|
8
|
+
} from "motion/react"
|
|
2
9
|
import { useId, useObjectRef } from "react-aria"
|
|
3
10
|
import {
|
|
4
11
|
Button as RACButton,
|
|
12
|
+
ProgressBar,
|
|
5
13
|
type ButtonProps as RACButtonProps,
|
|
6
14
|
} from "react-aria-components"
|
|
7
15
|
import { style, useStyles } from "purse-styles"
|
|
16
|
+
import { ActivityIndicator } from "./ActivityIndicator"
|
|
8
17
|
import { useFocus } from "../hooks/useFocus"
|
|
9
18
|
import { useRefCurrent } from "../hooks/useRefCurrent"
|
|
10
19
|
import {
|
|
@@ -26,6 +35,8 @@ export type ButtonSize = "default" | "sm"
|
|
|
26
35
|
|
|
27
36
|
const buttonShadow = shadowVars.control
|
|
28
37
|
|
|
38
|
+
const pendingTransition = { duration: 0.15, ease: "easeInOut" } as const
|
|
39
|
+
|
|
29
40
|
const disabledRaised = {
|
|
30
41
|
cursor: "default",
|
|
31
42
|
opacity: 1,
|
|
@@ -61,21 +72,30 @@ const buttonBaseClass = style(
|
|
|
61
72
|
textOverflow: "ellipsis",
|
|
62
73
|
overflow: "hidden",
|
|
63
74
|
whiteSpace: "nowrap",
|
|
64
|
-
|
|
75
|
+
transition: `padding-inline-start ${pendingTransition.duration}s ease-in-out`,
|
|
76
|
+
"@media (prefers-reduced-motion: reduce)": {
|
|
77
|
+
transition: "none",
|
|
78
|
+
},
|
|
79
|
+
"& > svg, & [data-button-content] > svg": {
|
|
65
80
|
flexShrink: 0,
|
|
66
81
|
},
|
|
67
|
-
"&:has(> svg)": {
|
|
82
|
+
"&:has(> svg), &:has([data-button-content] > svg)": {
|
|
68
83
|
paddingBlock: spacing.value(3),
|
|
69
84
|
},
|
|
70
|
-
"&:has(> svg:first-child:not(:only-child))":
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
85
|
+
"&:has(> svg:first-child:not(:only-child)), &:has([data-button-content] > svg:first-child:not(:only-child))":
|
|
86
|
+
{
|
|
87
|
+
paddingInlineStart: spacing.value(4),
|
|
88
|
+
},
|
|
89
|
+
"&:has(> svg:last-child:not(:only-child)), &:has([data-button-content] > svg:last-child:not(:only-child))":
|
|
90
|
+
{
|
|
91
|
+
paddingInlineEnd: spacing.value(4),
|
|
92
|
+
},
|
|
93
|
+
"&:has(> svg:only-child), &:has([data-button-content] > svg:only-child)": {
|
|
77
94
|
paddingInline: spacing.value(3),
|
|
78
95
|
},
|
|
96
|
+
"&[data-pending]": {
|
|
97
|
+
paddingInlineStart: `calc((${controlSize.height} - ${iconSizeValues.xs}) / 2)`,
|
|
98
|
+
},
|
|
79
99
|
"&:disabled": disabledRaised,
|
|
80
100
|
},
|
|
81
101
|
)
|
|
@@ -86,6 +106,34 @@ const buttonTextClass = style({
|
|
|
86
106
|
textBox: "trim-both cap alphabetic",
|
|
87
107
|
})
|
|
88
108
|
|
|
109
|
+
const pendingIndicatorClass = style({
|
|
110
|
+
display: "inline-flex",
|
|
111
|
+
flexShrink: 0,
|
|
112
|
+
"& > span": { display: "inline-flex" },
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
const contentRootClass = style({
|
|
116
|
+
position: "relative",
|
|
117
|
+
display: "inline-flex",
|
|
118
|
+
alignItems: "center",
|
|
119
|
+
justifyContent: "center",
|
|
120
|
+
gap: "inherit",
|
|
121
|
+
minWidth: 0,
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
const contentLayerClass = style({
|
|
125
|
+
display: "inline-flex",
|
|
126
|
+
alignItems: "center",
|
|
127
|
+
gap: "inherit",
|
|
128
|
+
flexShrink: 0,
|
|
129
|
+
"&[inert]": {
|
|
130
|
+
position: "absolute",
|
|
131
|
+
insetBlock: 0,
|
|
132
|
+
insetInlineStart: 0,
|
|
133
|
+
width: "max-content",
|
|
134
|
+
},
|
|
135
|
+
})
|
|
136
|
+
|
|
89
137
|
const smallButtonClass = style(
|
|
90
138
|
text({ size: "2xs", fontWeight: 500, color: "highContrast" }),
|
|
91
139
|
{
|
|
@@ -93,29 +141,34 @@ const smallButtonClass = style(
|
|
|
93
141
|
gap: spacing.value(1),
|
|
94
142
|
paddingInline: spacing.value(4),
|
|
95
143
|
paddingBlock: spacing.value(2),
|
|
96
|
-
"& > svg": {
|
|
144
|
+
"& > svg, & [data-button-content] > svg": {
|
|
97
145
|
width: iconSizeValues.xs,
|
|
98
146
|
height: iconSizeValues.xs,
|
|
99
147
|
},
|
|
100
|
-
"&:has(> svg)": {
|
|
148
|
+
"&:has(> svg), &:has([data-button-content] > svg)": {
|
|
101
149
|
paddingBlock: spacing.value(2),
|
|
102
150
|
},
|
|
103
|
-
"&:has(> svg:first-child:not(:only-child))":
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
151
|
+
"&:has(> svg:first-child:not(:only-child)), &:has([data-button-content] > svg:first-child:not(:only-child))":
|
|
152
|
+
{
|
|
153
|
+
paddingInlineStart: spacing.value(3),
|
|
154
|
+
},
|
|
155
|
+
"&:has(> svg:last-child:not(:only-child)), &:has([data-button-content] > svg:last-child:not(:only-child))":
|
|
156
|
+
{
|
|
157
|
+
paddingInlineEnd: spacing.value(3),
|
|
158
|
+
},
|
|
159
|
+
"&:has(> svg:only-child), &:has([data-button-content] > svg:only-child)": {
|
|
110
160
|
width: controlSize.smHeight,
|
|
111
161
|
paddingInline: 0,
|
|
112
162
|
},
|
|
163
|
+
"&[data-pending]": {
|
|
164
|
+
paddingInlineStart: `calc((${controlSize.smHeight} - ${iconSizeValues["2xs"]}) / 2)`,
|
|
165
|
+
},
|
|
113
166
|
},
|
|
114
167
|
)
|
|
115
168
|
|
|
116
169
|
const buttonClass = style(buttonBaseClass, {
|
|
117
170
|
backgroundColor: backgroundColor.element,
|
|
118
|
-
"&:hover:not(:disabled)": {
|
|
171
|
+
"&:hover:not(:disabled):not([data-pending])": {
|
|
119
172
|
backgroundColor: backgroundColor.elementHover,
|
|
120
173
|
},
|
|
121
174
|
})
|
|
@@ -129,7 +182,7 @@ const quietClass = memoize((color: string, hoverColor: string, wash: string) =>
|
|
|
129
182
|
color,
|
|
130
183
|
backgroundColor: "transparent",
|
|
131
184
|
boxShadow: "none",
|
|
132
|
-
"&:hover:not(:disabled)": {
|
|
185
|
+
"&:hover:not(:disabled):not([data-pending])": {
|
|
133
186
|
color: hoverColor,
|
|
134
187
|
backgroundColor: surfaceWash(wash, surfaceMixPercent.hover),
|
|
135
188
|
},
|
|
@@ -213,7 +266,7 @@ const primaryClass = memoize(
|
|
|
213
266
|
backgroundColor: fill,
|
|
214
267
|
backgroundImage: gradient,
|
|
215
268
|
boxShadow: primaryShadow,
|
|
216
|
-
"&:hover:not(:disabled)": {
|
|
269
|
+
"&:hover:not(:disabled):not([data-pending])": {
|
|
217
270
|
backgroundImage: `${primaryHoverSheen}, ${gradient}`,
|
|
218
271
|
},
|
|
219
272
|
"&:disabled": disabledRaised,
|
|
@@ -262,6 +315,8 @@ export type ButtonProps = Omit<RACButtonProps, "children" | "className"> & {
|
|
|
262
315
|
size?: ButtonSize
|
|
263
316
|
variant?: ButtonVariant
|
|
264
317
|
variantColor?: ButtonVariantColor
|
|
318
|
+
/** Visible text beside the indicator while pending. Defaults to children. */
|
|
319
|
+
pendingLabel?: string
|
|
265
320
|
}
|
|
266
321
|
|
|
267
322
|
export function useButton(
|
|
@@ -329,6 +384,7 @@ export const Button = React.forwardRef(function Button(
|
|
|
329
384
|
variant = "default",
|
|
330
385
|
variantColor,
|
|
331
386
|
isDisabled,
|
|
387
|
+
pendingLabel,
|
|
332
388
|
...buttonProps
|
|
333
389
|
} = props
|
|
334
390
|
const [, attributes] = useButton({ onClick, onFocus }, forwardedRef)
|
|
@@ -336,6 +392,7 @@ export const Button = React.forwardRef(function Button(
|
|
|
336
392
|
buttonStyles.root({ variant, size, variantColor }),
|
|
337
393
|
)
|
|
338
394
|
const textClassName = useStyles(buttonStyles.label)
|
|
395
|
+
const indicatorClassName = useStyles(pendingIndicatorClass)
|
|
339
396
|
const mergedClassName = [className, classNameProp].filter(Boolean).join(" ")
|
|
340
397
|
|
|
341
398
|
return (
|
|
@@ -348,11 +405,104 @@ export const Button = React.forwardRef(function Button(
|
|
|
348
405
|
onClick={attributes.onClick}
|
|
349
406
|
onFocus={attributes.onFocus}
|
|
350
407
|
>
|
|
351
|
-
{
|
|
408
|
+
{({ isPending }) => (
|
|
409
|
+
<ButtonContents isPending={isPending}>
|
|
410
|
+
{isPending && (
|
|
411
|
+
<ProgressBar
|
|
412
|
+
isIndeterminate
|
|
413
|
+
aria-label="Pending"
|
|
414
|
+
className={indicatorClassName}
|
|
415
|
+
>
|
|
416
|
+
<span aria-hidden="true">
|
|
417
|
+
<ActivityIndicator
|
|
418
|
+
variant="primary"
|
|
419
|
+
size={size === "sm" ? "2xs" : "xs"}
|
|
420
|
+
/>
|
|
421
|
+
</span>
|
|
422
|
+
</ProgressBar>
|
|
423
|
+
)}
|
|
424
|
+
{renderButtonChildren(
|
|
425
|
+
isPending ? (pendingLabel ?? children) : children,
|
|
426
|
+
textClassName,
|
|
427
|
+
)}
|
|
428
|
+
</ButtonContents>
|
|
429
|
+
)}
|
|
352
430
|
</RACButton>
|
|
353
431
|
)
|
|
354
432
|
})
|
|
355
433
|
|
|
434
|
+
function ButtonContents({
|
|
435
|
+
isPending,
|
|
436
|
+
children,
|
|
437
|
+
}: {
|
|
438
|
+
isPending: boolean
|
|
439
|
+
children: React.ReactNode
|
|
440
|
+
}) {
|
|
441
|
+
const [width, setWidth] = useState<number>()
|
|
442
|
+
const reduceMotion = useReducedMotion()
|
|
443
|
+
const className = useStyles(contentRootClass)
|
|
444
|
+
const transition = reduceMotion ? { duration: 0 } : pendingTransition
|
|
445
|
+
|
|
446
|
+
return (
|
|
447
|
+
<motion.span
|
|
448
|
+
className={className}
|
|
449
|
+
initial={false}
|
|
450
|
+
animate={{ width: width ?? "auto" }}
|
|
451
|
+
transition={transition}
|
|
452
|
+
>
|
|
453
|
+
<AnimatePresence initial={false}>
|
|
454
|
+
<ButtonContentLayer
|
|
455
|
+
key={isPending ? "pending" : "ready"}
|
|
456
|
+
onResize={setWidth}
|
|
457
|
+
transition={transition}
|
|
458
|
+
>
|
|
459
|
+
{children}
|
|
460
|
+
</ButtonContentLayer>
|
|
461
|
+
</AnimatePresence>
|
|
462
|
+
</motion.span>
|
|
463
|
+
)
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function ButtonContentLayer({
|
|
467
|
+
children,
|
|
468
|
+
onResize,
|
|
469
|
+
transition,
|
|
470
|
+
}: {
|
|
471
|
+
children: React.ReactNode
|
|
472
|
+
onResize: (width: number) => void
|
|
473
|
+
transition: Transition
|
|
474
|
+
}) {
|
|
475
|
+
const ref = React.useRef<HTMLSpanElement>(null)
|
|
476
|
+
const isPresent = useIsPresent()
|
|
477
|
+
const className = useStyles(contentLayerClass)
|
|
478
|
+
|
|
479
|
+
useLayoutEffect(() => {
|
|
480
|
+
const node = ref.current
|
|
481
|
+
if (!node || !isPresent) return
|
|
482
|
+
const measure = () => onResize(node.getBoundingClientRect().width)
|
|
483
|
+
measure()
|
|
484
|
+
const observer = new ResizeObserver(measure)
|
|
485
|
+
observer.observe(node)
|
|
486
|
+
return () => observer.disconnect()
|
|
487
|
+
}, [isPresent, onResize, ref])
|
|
488
|
+
|
|
489
|
+
return (
|
|
490
|
+
<motion.span
|
|
491
|
+
ref={ref}
|
|
492
|
+
className={className}
|
|
493
|
+
data-button-content={isPresent ? "" : undefined}
|
|
494
|
+
aria-hidden={!isPresent || undefined}
|
|
495
|
+
inert={!isPresent || undefined}
|
|
496
|
+
initial={{ opacity: 0 }}
|
|
497
|
+
animate={{ opacity: 1 }}
|
|
498
|
+
exit={{ opacity: 0 }}
|
|
499
|
+
transition={transition}
|
|
500
|
+
>
|
|
501
|
+
{children}
|
|
502
|
+
</motion.span>
|
|
503
|
+
)
|
|
504
|
+
}
|
|
505
|
+
|
|
356
506
|
function buttonVariantClass(
|
|
357
507
|
variant: ButtonVariant,
|
|
358
508
|
variantColor: ButtonVariantColor | undefined,
|
package/src/maui.ts
CHANGED
|
@@ -2,7 +2,9 @@ import { style, useStyles } from "purse-styles"
|
|
|
2
2
|
import { Panel } from "./Panel"
|
|
3
3
|
import { ActivityIndicator } from "../components/ActivityIndicator"
|
|
4
4
|
import { Button } from "../components/Button"
|
|
5
|
+
import { Code } from "../components/Code"
|
|
5
6
|
import { Prose } from "../components/Prose"
|
|
7
|
+
import { Text } from "../components/Text"
|
|
6
8
|
import { H2, H3, P } from "../components/Typography"
|
|
7
9
|
import { flex } from "../tokens/layout"
|
|
8
10
|
import { text } from "../tokens/text"
|
|
@@ -40,22 +42,33 @@ export function ActivityIndicatorPage() {
|
|
|
40
42
|
</Panel>
|
|
41
43
|
|
|
42
44
|
<H3>Sizes</H3>
|
|
45
|
+
<P>
|
|
46
|
+
Use the same t-shirt sizes as icons, from <Code>2xs</Code> to{" "}
|
|
47
|
+
<Code>xl</Code>, or a custom CSS length. The default is 1em.
|
|
48
|
+
</P>
|
|
43
49
|
<Panel className={rowClassName}>
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
50
|
+
{(["2xs", "xs", "sm", "md", "lg", "xl"] as const).map((size) => (
|
|
51
|
+
<div className={columnClassName} key={size}>
|
|
52
|
+
<ActivityIndicator size={size} aria-label={`Loading, ${size}`} />
|
|
53
|
+
<Text size="xs" color="lowContrast">
|
|
54
|
+
{size}
|
|
55
|
+
</Text>
|
|
56
|
+
</div>
|
|
57
|
+
))}
|
|
58
|
+
<div className={columnClassName}>
|
|
59
|
+
<ActivityIndicator size="37px" aria-label="Loading, custom size" />
|
|
60
|
+
<Text size="xs" color="lowContrast">
|
|
61
|
+
37px
|
|
62
|
+
</Text>
|
|
63
|
+
</div>
|
|
47
64
|
</Panel>
|
|
48
65
|
|
|
49
66
|
<H3>In a button</H3>
|
|
50
67
|
<Panel className={rowClassName}>
|
|
51
|
-
<Button variant="primary">
|
|
52
|
-
<ActivityIndicator variant="primary" aria-label="Saving" />
|
|
68
|
+
<Button variant="primary" isPending>
|
|
53
69
|
Saving
|
|
54
70
|
</Button>
|
|
55
|
-
<Button>
|
|
56
|
-
<ActivityIndicator aria-label="Refreshing" />
|
|
57
|
-
Refreshing
|
|
58
|
-
</Button>
|
|
71
|
+
<Button isPending>Refreshing</Button>
|
|
59
72
|
</Panel>
|
|
60
73
|
</Prose>
|
|
61
74
|
)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState } from "react"
|
|
1
|
+
import { useEffect, useState } from "react"
|
|
2
2
|
import { Button } from "../components/Button"
|
|
3
3
|
import { Code } from "../components/Code"
|
|
4
4
|
import {
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
} from "../components/Dialog"
|
|
10
10
|
import { Overlay } from "../components/Overlay"
|
|
11
11
|
import { Prose } from "../components/Prose"
|
|
12
|
+
import { Text } from "../components/Text"
|
|
12
13
|
import { H2, H3, P } from "../components/Typography"
|
|
13
14
|
import { Flex } from "../components/Utils"
|
|
14
15
|
import { Archive, DotsHorizontal, Plus, Search } from "../icons"
|
|
@@ -19,6 +20,17 @@ import { borderColor } from "../tokens/borders"
|
|
|
19
20
|
export function ButtonsPage() {
|
|
20
21
|
const [dialogOpen, setDialogOpen] = useState(false)
|
|
21
22
|
const [overlayOpen, setOverlayOpen] = useState(false)
|
|
23
|
+
const [isSaving, setIsSaving] = useState(false)
|
|
24
|
+
const [saveCount, setSaveCount] = useState(0)
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (!isSaving) return
|
|
28
|
+
const timeout = setTimeout(() => {
|
|
29
|
+
setIsSaving(false)
|
|
30
|
+
setSaveCount((count) => count + 1)
|
|
31
|
+
}, 2000)
|
|
32
|
+
return () => clearTimeout(timeout)
|
|
33
|
+
}, [isSaving])
|
|
22
34
|
|
|
23
35
|
return (
|
|
24
36
|
<Prose style={{ marginBottom: "32px" }}>
|
|
@@ -80,6 +92,52 @@ export function ButtonsPage() {
|
|
|
80
92
|
</Button>
|
|
81
93
|
</Flex>
|
|
82
94
|
|
|
95
|
+
<H3>Pending</H3>
|
|
96
|
+
<P>
|
|
97
|
+
<Code>isPending</Code> shows an activity indicator beside the label,
|
|
98
|
+
keeps the button’s color and focus, and prevents repeat activation. Use{" "}
|
|
99
|
+
<Code>pendingLabel</Code> for visible progress text; otherwise the
|
|
100
|
+
original children stay visible.
|
|
101
|
+
</P>
|
|
102
|
+
<Flex row alignItems="center" gap={4} style={{ flexWrap: "wrap" }}>
|
|
103
|
+
<Button isPending>Submitting</Button>
|
|
104
|
+
<Button variant="primary" isPending pendingLabel="Saving…">
|
|
105
|
+
Save
|
|
106
|
+
</Button>
|
|
107
|
+
<Button variant="quiet" isPending pendingLabel="Syncing…">
|
|
108
|
+
Sync
|
|
109
|
+
</Button>
|
|
110
|
+
<Button
|
|
111
|
+
variant="primary"
|
|
112
|
+
variantColor="blue"
|
|
113
|
+
isPending
|
|
114
|
+
pendingLabel="Uploading…"
|
|
115
|
+
>
|
|
116
|
+
Upload
|
|
117
|
+
</Button>
|
|
118
|
+
<Button size="sm" isPending pendingLabel="Creating…">
|
|
119
|
+
<Plus />
|
|
120
|
+
Create
|
|
121
|
+
</Button>
|
|
122
|
+
</Flex>
|
|
123
|
+
<Flex row alignItems="center" gap={4} style={{ flexWrap: "wrap" }}>
|
|
124
|
+
<Button
|
|
125
|
+
variant="primary"
|
|
126
|
+
isPending={isSaving}
|
|
127
|
+
pendingLabel="Saving changes…"
|
|
128
|
+
onPress={() => setIsSaving(true)}
|
|
129
|
+
>
|
|
130
|
+
Save changes
|
|
131
|
+
</Button>
|
|
132
|
+
<Text size="sm" color="lowContrast" role="status">
|
|
133
|
+
{isSaving
|
|
134
|
+
? "Saving for two seconds…"
|
|
135
|
+
: saveCount > 0
|
|
136
|
+
? `Saved ${saveCount} ${saveCount === 1 ? "time" : "times"}. Try again.`
|
|
137
|
+
: "Click to try the pending state."}
|
|
138
|
+
</Text>
|
|
139
|
+
</Flex>
|
|
140
|
+
|
|
83
141
|
<H3>Primary</H3>
|
|
84
142
|
<P>
|
|
85
143
|
<Code>variant="primary"</Code> fills with step 9 of{" "}
|
|
@@ -140,9 +198,8 @@ export function ButtonsPage() {
|
|
|
140
198
|
<H3>Quiet</H3>
|
|
141
199
|
<P>
|
|
142
200
|
<Code>variant="quiet"</Code> has no fill. <Code>variantColor</Code>{" "}
|
|
143
|
-
tints the label and icon. Hover mixes <Code>grayAlpha[9]</Code> (or
|
|
144
|
-
|
|
145
|
-
wash.
|
|
201
|
+
tints the label and icon. Hover mixes <Code>grayAlpha[9]</Code> (or that
|
|
202
|
+
color’s alpha 9) into transparent using the shared 3.5% control wash.
|
|
146
203
|
</P>
|
|
147
204
|
<Flex row alignItems="center" gap={4} style={{ flexWrap: "wrap" }}>
|
|
148
205
|
<Button variant="quiet">Button</Button>
|
package/src/pages/MenuPage.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useState } from "react"
|
|
2
2
|
import { Button } from "../components/Button"
|
|
3
3
|
import { Code } from "../components/Code"
|
|
4
|
+
import { CodeBlock } from "../components/CodeBlock"
|
|
4
5
|
import { Menu, MenuItem, MenuTrigger } from "../components/Menu"
|
|
5
6
|
import { Prose } from "../components/Prose"
|
|
6
7
|
import { H2, H3, P } from "../components/Typography"
|
|
@@ -15,7 +16,8 @@ export function MenuPage() {
|
|
|
15
16
|
<H2>Menu</H2>
|
|
16
17
|
<P>
|
|
17
18
|
A triggered collection of actions that opens in a popover. The
|
|
18
|
-
trigger
|
|
19
|
+
trigger can be a Maui <Code>Button</Code> or a custom target wrapped
|
|
20
|
+
in <Code>Pressable</Code>.
|
|
19
21
|
</P>
|
|
20
22
|
<H3>Default</H3>
|
|
21
23
|
<MenuTrigger>
|
|
@@ -57,6 +59,37 @@ export function MenuPage() {
|
|
|
57
59
|
</MenuTrigger>
|
|
58
60
|
</Flex>
|
|
59
61
|
{lastAction ? <P>Last action: {lastAction}</P> : null}
|
|
62
|
+
<H3>Context menus and custom triggers</H3>
|
|
63
|
+
<P>
|
|
64
|
+
Use <Code>trigger="contextMenu"</Code> with the same <Code>Menu</Code>{" "}
|
|
65
|
+
and <Code>MenuItem</Code>. Maui directly re-exports React Aria’s{" "}
|
|
66
|
+
<Code>Pressable</Code> to connect a custom target to the trigger.
|
|
67
|
+
Maui <Code>Button</Code> already handles this wiring.
|
|
68
|
+
</P>
|
|
69
|
+
<CodeBlock lang="tsx">{`import { MenuTrigger, Menu, MenuItem, Pressable } from "@tanishqkancharla/maui"
|
|
70
|
+
|
|
71
|
+
<MenuTrigger trigger="contextMenu">
|
|
72
|
+
<Pressable>
|
|
73
|
+
<div role="button" tabIndex={0}>Report.pdf</div>
|
|
74
|
+
</Pressable>
|
|
75
|
+
<Menu aria-label="File actions" onAction={handleAction}>
|
|
76
|
+
<MenuItem id="rename">Rename</MenuItem>
|
|
77
|
+
<MenuItem id="delete">Delete</MenuItem>
|
|
78
|
+
</Menu>
|
|
79
|
+
</MenuTrigger>`}</CodeBlock>
|
|
80
|
+
<P>
|
|
81
|
+
<Code>Pressable</Code> adds behavior without styling or an extra DOM
|
|
82
|
+
wrapper. Its single child needs an interactive semantic element or
|
|
83
|
+
ARIA role. Custom components must forward their ref and spread props
|
|
84
|
+
onto that element; give custom targets a visible keyboard focus style.
|
|
85
|
+
</P>
|
|
86
|
+
<P>
|
|
87
|
+
React Aria handles right-click, touch long-press, and OS or screen-reader
|
|
88
|
+
context-menu commands. Keep a visible Actions button offering the same
|
|
89
|
+
actions, and preserve native browser menus on editable content and links
|
|
90
|
+
when needed. The popup currently keeps the dropdown’s 6px offset and
|
|
91
|
+
minimum width equal to the target width.
|
|
92
|
+
</P>
|
|
60
93
|
</Prose>
|
|
61
94
|
)
|
|
62
95
|
}
|
|
@@ -6,7 +6,6 @@ import {
|
|
|
6
6
|
defaultRehypePlugins,
|
|
7
7
|
useIsCodeFenceIncomplete,
|
|
8
8
|
} from "streamdown"
|
|
9
|
-
import { code } from "@streamdown/code"
|
|
10
9
|
import { style, useStyles } from "purse-styles"
|
|
11
10
|
import { CodeBlock } from "../components/CodeBlock"
|
|
12
11
|
import { proseContainerStyle } from "../components/Prose"
|
|
@@ -143,7 +142,6 @@ export function AssistantMessage({
|
|
|
143
142
|
<Streamdown
|
|
144
143
|
className={streamdownClassName}
|
|
145
144
|
components={streamdownComponents}
|
|
146
|
-
plugins={{ code }}
|
|
147
145
|
rehypePlugins={rehypePlugins}
|
|
148
146
|
// `animated` must stay stably enabled; only `isAnimating` toggles.
|
|
149
147
|
// Flipping `animated`/`mode` with the stream resets stagger state and
|
package/src/utils/shiki.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHighlighterCore } from "shiki/core"
|
|
2
|
-
import {
|
|
2
|
+
import { createJavaScriptRegexEngine } from "shiki/engine/javascript"
|
|
3
3
|
import type { HighlighterCore } from "shiki"
|
|
4
4
|
import type { ResolvedTheme } from "../theme/ThemeContext"
|
|
5
5
|
import { mauiShikiThemeDark, mauiShikiThemeLight } from "./mauiShikiTheme"
|
|
@@ -44,7 +44,7 @@ function getHighlighter() {
|
|
|
44
44
|
import("@shikijs/langs/json"),
|
|
45
45
|
import("@shikijs/langs/bash"),
|
|
46
46
|
],
|
|
47
|
-
engine:
|
|
47
|
+
engine: createJavaScriptRegexEngine(),
|
|
48
48
|
})
|
|
49
49
|
}
|
|
50
50
|
|