@tanishqkancharla/maui 0.0.66 → 0.0.70
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 +1 -1
- package/dist/components/Button.js +14 -4
- package/dist/components/Dialog.js +4 -0
- package/dist/components/Editor.js +3 -3
- package/dist/components/Input.js +0 -6
- package/dist/components/Select.js +1 -1
- package/dist/components/Slider.js +1 -1
- package/dist/tokens/prose.d.ts +4 -4
- package/dist/tokens/prose.js +16 -24
- package/package.json +1 -1
- package/skills/maui/references/components/buttons.md +6 -3
- package/skills/maui/references/components/form-controls.md +2 -2
- package/skills/maui/references/design_conventions.md +12 -0
- package/src/apps/JsxEditor/catalog.ts +2 -2
- package/src/apps/JsxEditor/lint.test.ts +10 -3
- package/src/components/Button.tsx +27 -6
- package/src/components/Dialog.tsx +4 -0
- package/src/components/Editor.tsx +7 -3
- package/src/components/Input.tsx +0 -6
- package/src/components/Select.tsx +1 -1
- package/src/components/Slider.tsx +1 -1
- package/src/pages/ButtonsPage.tsx +30 -1
- package/src/tokens/prose.ts +19 -28
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { type ButtonProps as RACButtonProps } from "react-aria-components";
|
|
3
3
|
import { type ColorName } from "../tokens/colors";
|
|
4
|
-
export type ButtonVariant = "default" | "quiet" | "primary";
|
|
4
|
+
export type ButtonVariant = "default" | "quiet" | "primary" | "text";
|
|
5
5
|
export type ButtonSize = "default" | "sm";
|
|
6
6
|
/**
|
|
7
7
|
* @internal Shared shell under every Button variant (no fill, no hover).
|
|
@@ -132,6 +132,11 @@ const buttonClass = style(buttonBaseClass, {
|
|
|
132
132
|
backgroundColor: backgroundColor.elementHover,
|
|
133
133
|
},
|
|
134
134
|
});
|
|
135
|
+
const textButtonSpacingClass = style({
|
|
136
|
+
"&, &:has(> svg), &:has([data-button-content] > svg), &:has(> svg:first-child:not(:only-child)), &:has([data-button-content] > svg:first-child:not(:only-child)), &:has(> svg:last-child:not(:only-child)), &:has([data-button-content] > svg:last-child:not(:only-child)), &:has(> svg:only-child), &:has([data-button-content] > svg:only-child), &[data-pending]": {
|
|
137
|
+
padding: 0,
|
|
138
|
+
},
|
|
139
|
+
});
|
|
135
140
|
function paletteAlpha(name) {
|
|
136
141
|
return colors[`${name}Alpha`];
|
|
137
142
|
}
|
|
@@ -141,7 +146,9 @@ const quietClass = memoize((color, hoverColor, wash) => style(buttonBaseClass, f
|
|
|
141
146
|
boxShadow: "none",
|
|
142
147
|
"&:hover:not(:disabled):not([data-pending])": {
|
|
143
148
|
color: hoverColor,
|
|
144
|
-
backgroundColor:
|
|
149
|
+
backgroundColor: wash
|
|
150
|
+
? surfaceWash(wash, surfaceMixPercent.hover)
|
|
151
|
+
: "transparent",
|
|
145
152
|
},
|
|
146
153
|
"&:disabled": disabledQuiet,
|
|
147
154
|
}));
|
|
@@ -214,13 +221,13 @@ const coloredButtonClass = memoize((variant, color) => {
|
|
|
214
221
|
// No alpha scale for a custom fill; approximate step A6 with the fill at 35%.
|
|
215
222
|
return primaryClass(fill, onSolidText(fill), fillShade(fill, 0, 0.35));
|
|
216
223
|
}
|
|
217
|
-
return quietClass(fill, darkerFill(fill), fill);
|
|
224
|
+
return quietClass(fill, darkerFill(fill), variant === "quiet" ? fill : undefined);
|
|
218
225
|
}
|
|
219
226
|
const scale = paletteFill(color);
|
|
220
227
|
if (variant === "primary") {
|
|
221
228
|
return primaryClass(scale[9], darkTextOnSolid.has(color) ? scale[12] : "white", paletteAlpha(color)[6]);
|
|
222
229
|
}
|
|
223
|
-
return quietClass(scale[11], scale[12], paletteAlpha(color)[9]);
|
|
230
|
+
return quietClass(scale[11], scale[12], variant === "quiet" ? paletteAlpha(color)[9] : undefined);
|
|
224
231
|
});
|
|
225
232
|
export function useButton(props, forwardedRef) {
|
|
226
233
|
const ref = useObjectRef(forwardedRef);
|
|
@@ -243,7 +250,7 @@ export function useButton(props, forwardedRef) {
|
|
|
243
250
|
}
|
|
244
251
|
/** `root` goes on the RAC `Button` (native `:hover` / `:focus-visible` / `:disabled`; icons are direct `> svg` children); `label` goes on a `<span>` wrapping each text run. */
|
|
245
252
|
export const buttonStyles = {
|
|
246
|
-
root: memoize(({ variant = "default", size = "default", variantColor, } = {}) => style(buttonVariantClass(variant, variantColor), size === "sm" && smallButtonClass)),
|
|
253
|
+
root: memoize(({ variant = "default", size = "default", variantColor, } = {}) => style(buttonVariantClass(variant, variantColor), size === "sm" && smallButtonClass, variant === "text" && textButtonSpacingClass)),
|
|
247
254
|
label: buttonTextClass,
|
|
248
255
|
};
|
|
249
256
|
export const Button = React.forwardRef(function Button(props, forwardedRef) {
|
|
@@ -282,6 +289,9 @@ function buttonVariantClass(variant, variantColor) {
|
|
|
282
289
|
if (variant === "primary") {
|
|
283
290
|
return coloredButtonClass("primary", variantColor ?? "accent");
|
|
284
291
|
}
|
|
292
|
+
if (variant === "text") {
|
|
293
|
+
return coloredButtonClass("text", variantColor ?? "gray");
|
|
294
|
+
}
|
|
285
295
|
if (variant === "quiet" && variantColor) {
|
|
286
296
|
return coloredButtonClass("quiet", variantColor);
|
|
287
297
|
}
|
|
@@ -73,10 +73,14 @@ const dialog = style({
|
|
|
73
73
|
const title = style(text({ size: "lg", fontWeight: 600 }), {
|
|
74
74
|
margin: 0,
|
|
75
75
|
});
|
|
76
|
+
// A scrolling box clips on both axes. The inset, offset by a negative margin,
|
|
77
|
+
// keeps the rings and focus glow of full-width fields visible without moving them.
|
|
76
78
|
const body = style({
|
|
77
79
|
minWidth: 0,
|
|
78
80
|
minHeight: 0,
|
|
79
81
|
overflowY: "auto",
|
|
82
|
+
margin: `calc(-1 * ${spacing.value(3)})`,
|
|
83
|
+
padding: spacing.value(3),
|
|
80
84
|
});
|
|
81
85
|
const actions = style({
|
|
82
86
|
display: "flex",
|
|
@@ -2,7 +2,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import { EditorContent } from "@tiptap/react";
|
|
3
3
|
import { style, useStyles } from "purse-styles";
|
|
4
4
|
import { colors } from "../tokens/colors";
|
|
5
|
-
import { proseHtml } from "../tokens/prose";
|
|
5
|
+
import { proseHtml, proseRhythm } from "../tokens/prose";
|
|
6
6
|
import { cls } from "../utils/cls";
|
|
7
7
|
/**
|
|
8
8
|
* Maui-styled TipTap surface. Create the editor with TipTap's useEditor;
|
|
@@ -11,7 +11,7 @@ import { cls } from "../utils/cls";
|
|
|
11
11
|
* through TipTap's editorProps.attributes.
|
|
12
12
|
*/
|
|
13
13
|
export function Editor({ size = "md", className, ...props }) {
|
|
14
|
-
const surfaceClassName = useStyles(editorClass, proseHtml(size));
|
|
14
|
+
const surfaceClassName = useStyles(editorClass, proseHtml(size), proseRhythm(size, "& > .ProseMirror"));
|
|
15
15
|
return (_jsx(EditorContent, { ...props, className: cls("maui-editor-prose", surfaceClassName, className) }));
|
|
16
16
|
}
|
|
17
17
|
const editorClass = style({
|
|
@@ -23,7 +23,7 @@ const editorClass = style({
|
|
|
23
23
|
// proseHtml styles descendants; its block rhythm belongs on the document.
|
|
24
24
|
display: "flex",
|
|
25
25
|
flexDirection: "column",
|
|
26
|
-
gap:
|
|
26
|
+
gap: 0,
|
|
27
27
|
},
|
|
28
28
|
"& .ProseMirror p.is-editor-empty:first-child::before": {
|
|
29
29
|
color: colors.gray[9],
|
package/dist/components/Input.js
CHANGED
|
@@ -24,9 +24,6 @@ const inputClass = style(inputText, focusRing("&:focus-visible", shadowVars.cont
|
|
|
24
24
|
color: colors.gray[12],
|
|
25
25
|
border: "none",
|
|
26
26
|
background: backgroundColor.element,
|
|
27
|
-
"&:hover:not(:disabled)": {
|
|
28
|
-
background: backgroundColor.elementHover,
|
|
29
|
-
},
|
|
30
27
|
"&:disabled": {
|
|
31
28
|
color: colors.gray[8],
|
|
32
29
|
background: colors.gray[2],
|
|
@@ -228,9 +225,6 @@ const numberInputClass = style(text({ size: "sm", fontWeight: 400, color: "highC
|
|
|
228
225
|
border: "none",
|
|
229
226
|
outline: "none",
|
|
230
227
|
background: "transparent",
|
|
231
|
-
"&:hover:not(:disabled)": {
|
|
232
|
-
background: backgroundColor.elementHover,
|
|
233
|
-
},
|
|
234
228
|
"&::placeholder": {
|
|
235
229
|
fontStyle: "italic",
|
|
236
230
|
color: colors.gray[8],
|
|
@@ -28,7 +28,7 @@ const trigger = style(text({ size: "sm", fontWeight: 400, color: "highContrast"
|
|
|
28
28
|
border: "none",
|
|
29
29
|
textAlign: "left",
|
|
30
30
|
background: backgroundColor.element,
|
|
31
|
-
"
|
|
31
|
+
"&[data-pressed]": {
|
|
32
32
|
background: backgroundColor.elementHover,
|
|
33
33
|
},
|
|
34
34
|
"&[aria-expanded='true']": {
|
|
@@ -44,7 +44,7 @@ const thumb = style(focusRing("&[data-focus-visible]", shadowVars.control), {
|
|
|
44
44
|
width: sliderSize.thumb,
|
|
45
45
|
height: sliderSize.thumb,
|
|
46
46
|
borderRadius: "100%",
|
|
47
|
-
background:
|
|
47
|
+
background: "#ffffff",
|
|
48
48
|
boxShadow: shadowVars.control,
|
|
49
49
|
"&[data-dragging]": {
|
|
50
50
|
background: colors.accent[11],
|
package/dist/tokens/prose.d.ts
CHANGED
|
@@ -4,15 +4,15 @@
|
|
|
4
4
|
* rhythm tuned for sustained reading. The `Prose` container picks a size and
|
|
5
5
|
* typography components inside it opt into the matching prose treatment.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
7
|
+
* Type sizes and heading spacing are ported from the Tailwind Typography plugin
|
|
8
8
|
* (https://github.com/tailwindlabs/tailwindcss-typography): `sm` -> 14px base,
|
|
9
9
|
* `md` -> Tailwind's `base` (16px), `lg` -> 18px. Tailwind's em-relative values
|
|
10
10
|
* are resolved to px here so they compose with Maui's px-based tokens.
|
|
11
11
|
*
|
|
12
12
|
* On the Large platform scale (coarse pointers) block font sizes and line
|
|
13
13
|
* heights step up ~1.2x — the same idea as the `text` tokens — so reading
|
|
14
|
-
* content grows with the rest of the UI on touch devices.
|
|
15
|
-
*
|
|
14
|
+
* content grows with the rest of the UI on touch devices. Paragraph gaps stay
|
|
15
|
+
* at 10px on both scales.
|
|
16
16
|
*/
|
|
17
17
|
export type ProseSize = "sm" | "md" | "lg";
|
|
18
18
|
export declare const prose: (size: ProseSize) => {
|
|
@@ -32,7 +32,7 @@ export declare const prose: (size: ProseSize) => {
|
|
|
32
32
|
* `marginBottom` becomes the "hug" gap after a heading; heading `marginTop`
|
|
33
33
|
* becomes the larger break before it.
|
|
34
34
|
*/
|
|
35
|
-
export declare const proseRhythm: (size: ProseSize) => import("purse-styles").StyleElement;
|
|
35
|
+
export declare const proseRhythm: (size: ProseSize, selector?: unknown) => import("purse-styles").StyleElement;
|
|
36
36
|
/**
|
|
37
37
|
* Element styles + vertical rhythm for HTML rendered outside React typography
|
|
38
38
|
* components (TipTap's ProseMirror tree, Streamdown markdown output).
|
package/dist/tokens/prose.js
CHANGED
|
@@ -10,7 +10,6 @@ const scaledPx = (normal, large) => defineVars({
|
|
|
10
10
|
}).px;
|
|
11
11
|
const metrics = {
|
|
12
12
|
sm: {
|
|
13
|
-
blockGap: 16,
|
|
14
13
|
paragraph: { fontSize: scaledPx(14, 17), lineHeight: scaledPx(24, 29) },
|
|
15
14
|
h1: {
|
|
16
15
|
fontSize: scaledPx(30, 36),
|
|
@@ -36,11 +35,10 @@ const metrics = {
|
|
|
36
35
|
marginTop: 20,
|
|
37
36
|
marginBottom: 8,
|
|
38
37
|
},
|
|
39
|
-
listPadding:
|
|
38
|
+
listPadding: 20,
|
|
40
39
|
listItemGap: 4,
|
|
41
40
|
},
|
|
42
41
|
md: {
|
|
43
|
-
blockGap: 20,
|
|
44
42
|
paragraph: { fontSize: scaledPx(16, 19), lineHeight: scaledPx(28, 34) },
|
|
45
43
|
h1: {
|
|
46
44
|
fontSize: scaledPx(36, 43),
|
|
@@ -66,11 +64,10 @@ const metrics = {
|
|
|
66
64
|
marginTop: 24,
|
|
67
65
|
marginBottom: 8,
|
|
68
66
|
},
|
|
69
|
-
listPadding:
|
|
67
|
+
listPadding: 24,
|
|
70
68
|
listItemGap: 8,
|
|
71
69
|
},
|
|
72
70
|
lg: {
|
|
73
|
-
blockGap: 24,
|
|
74
71
|
paragraph: { fontSize: scaledPx(18, 22), lineHeight: scaledPx(32, 38) },
|
|
75
72
|
h1: {
|
|
76
73
|
fontSize: scaledPx(48, 58),
|
|
@@ -96,16 +93,11 @@ const metrics = {
|
|
|
96
93
|
marginTop: 32,
|
|
97
94
|
marginBottom: 8,
|
|
98
95
|
},
|
|
99
|
-
listPadding:
|
|
96
|
+
listPadding: 26,
|
|
100
97
|
listItemGap: 12,
|
|
101
98
|
},
|
|
102
99
|
};
|
|
103
|
-
|
|
104
|
-
const blockGaps = defineVars({
|
|
105
|
-
sm: { default: `${metrics.sm.blockGap}px`, [LARGE_SCALE]: "12px" },
|
|
106
|
-
md: { default: `${metrics.md.blockGap}px`, [LARGE_SCALE]: "16px" },
|
|
107
|
-
lg: { default: `${metrics.lg.blockGap}px`, [LARGE_SCALE]: "20px" },
|
|
108
|
-
});
|
|
100
|
+
const blockGap = "10px";
|
|
109
101
|
// Heading weights follow Tailwind's descending scale, capped at Maui's
|
|
110
102
|
// heaviest weight (700).
|
|
111
103
|
const headingColor = colors.gray[12];
|
|
@@ -163,23 +155,23 @@ const notHeading = ":not(h1):not(h2):not(h3):not(h4)";
|
|
|
163
155
|
* `marginBottom` becomes the "hug" gap after a heading; heading `marginTop`
|
|
164
156
|
* becomes the larger break before it.
|
|
165
157
|
*/
|
|
166
|
-
export const proseRhythm = memoize((size) => {
|
|
158
|
+
export const proseRhythm = memoize((size, selector = "&") => {
|
|
167
159
|
const m = metrics[size];
|
|
168
160
|
return style({
|
|
169
|
-
|
|
170
|
-
marginTop:
|
|
161
|
+
[`${selector} > * + *`]: {
|
|
162
|
+
marginTop: blockGap,
|
|
171
163
|
},
|
|
172
164
|
// Gap after a heading: hug it to the content that follows.
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
165
|
+
[`${selector} > h1 + *`]: { marginTop: px(m.h1.marginBottom) },
|
|
166
|
+
[`${selector} > h2 + *`]: { marginTop: px(m.h2.marginBottom) },
|
|
167
|
+
[`${selector} > h3 + *`]: { marginTop: px(m.h3.marginBottom) },
|
|
168
|
+
[`${selector} > h4 + *`]: { marginTop: px(m.h4.marginBottom) },
|
|
177
169
|
// Break before a heading (only when it opens a new section, i.e. it
|
|
178
170
|
// isn't already hugging a preceding heading).
|
|
179
|
-
[
|
|
180
|
-
[
|
|
181
|
-
[
|
|
182
|
-
[
|
|
171
|
+
[`${selector} > ${notHeading} + h1`]: { marginTop: px(m.h1.marginTop) },
|
|
172
|
+
[`${selector} > ${notHeading} + h2`]: { marginTop: px(m.h2.marginTop) },
|
|
173
|
+
[`${selector} > ${notHeading} + h3`]: { marginTop: px(m.h3.marginTop) },
|
|
174
|
+
[`${selector} > ${notHeading} + h4`]: { marginTop: px(m.h4.marginTop) },
|
|
183
175
|
});
|
|
184
176
|
});
|
|
185
177
|
/**
|
|
@@ -194,7 +186,7 @@ export const proseHtml = memoize((size) => {
|
|
|
194
186
|
return style({
|
|
195
187
|
display: "flex",
|
|
196
188
|
flexDirection: "column",
|
|
197
|
-
gap:
|
|
189
|
+
gap: blockGap,
|
|
198
190
|
"& p": {
|
|
199
191
|
...block(m.paragraph),
|
|
200
192
|
fontWeight: 400,
|
package/package.json
CHANGED
|
@@ -10,6 +10,7 @@ Styled React Aria `Button` (default `type="button"`). Consumes `ButtonContext`,
|
|
|
10
10
|
<Button>Save</Button>
|
|
11
11
|
<Button variant="quiet">Cancel</Button>
|
|
12
12
|
<Button variant="quiet" variantColor="accent">Quiet accent</Button>
|
|
13
|
+
<Button variant="text">Text action</Button>
|
|
13
14
|
<Button variant="primary" variantColor="blue">Create</Button>
|
|
14
15
|
<Button variant="primary" variantColor="#6366f1">Indigo</Button>
|
|
15
16
|
<Button size="sm">Compact</Button>
|
|
@@ -25,8 +26,8 @@ Styled React Aria `Button` (default `type="button"`). Consumes `ButtonContext`,
|
|
|
25
26
|
| Prop | Notes |
|
|
26
27
|
| --- | --- |
|
|
27
28
|
| `size` | `"default"` (28px) \| `"sm"` (24px with 10px text and 14px icons). Large/touch scale keeps both at a 40px target |
|
|
28
|
-
| `variant` | `"default"` (raised element) \| `"quiet"` (
|
|
29
|
-
| `variantColor` | Palette name or opaque `#hex` / `rgb(...)`. Primary
|
|
29
|
+
| `variant` | `"default"` (raised element) \| `"quiet"` (transparent, hover wash) \| `"text"` (transparent, color-only hover) \| `"primary"` (solid fill) |
|
|
30
|
+
| `variantColor` | Palette name or opaque `#hex` / `rgb(...)`. Primary defaults to `"accent"`; quiet and text default to gray and tint the label/icon when set |
|
|
30
31
|
| `isDisabled` | React Aria name. Maps to native `disabled`. **No `disabled` React prop** |
|
|
31
32
|
| `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
33
|
| `pendingLabel` | Optional visible string replacing children while pending (e.g. `"Saving…"`). Omit to keep children; provide it to replace an icon with progress text |
|
|
@@ -37,6 +38,8 @@ Primary: fill step 9, light text (`onAccent`, or step 12 on amber/lime/mint/sky/
|
|
|
37
38
|
|
|
38
39
|
While a menu or overlay is open, the trigger keeps the active fill via RAC `data-pressed` / `aria-expanded` (same tokens as `:active`: `elementActive`, or primary step 10).
|
|
39
40
|
|
|
41
|
+
Text: zero padding in both sizes, including icons and pending content; retains the control height and focus ring. No background or resting shadow. Only the label/icon color changes on hover (palette step 11 → 12, or a darker custom color). Disabled and pending suppress hover changes.
|
|
42
|
+
|
|
40
43
|
`useButton(props)` is exported for custom focus-tracked buttons; prefer `Button`.
|
|
41
44
|
|
|
42
45
|
## `Overlay`
|
|
@@ -93,5 +96,5 @@ Edge-anchored mobile nav is [`Drawer`](drawer.md), not this Dialog and not Overl
|
|
|
93
96
|
- `modal({ size })` — RAC `Modal`; `size` is `"sm"` (default) | `"md"` | `"lg"`.
|
|
94
97
|
- `dialog` — RAC `Dialog`.
|
|
95
98
|
- `title` — RAC `Heading slot="title"`.
|
|
96
|
-
- `body` — scrollable `div` inside the dialog.
|
|
99
|
+
- `body` — scrollable `div` inside the dialog. A 6px inset, offset by a negative margin, keeps full-width field rings and focus glows unclipped.
|
|
97
100
|
- `actions` — button row `div` inside the dialog.
|
|
@@ -64,7 +64,7 @@ Controlled: `selected` + `onChange`. **`label` is required.**
|
|
|
64
64
|
|
|
65
65
|
**TextField** — `textFieldStyles`
|
|
66
66
|
- `root` — field wrapper around the input and error.
|
|
67
|
-
- `input({ size, keyboardHint })` — RAC `Input`; `:
|
|
67
|
+
- `input({ size, keyboardHint })` — RAC `Input`; `:disabled`, `aria-invalid`, `:focus-visible`, `::placeholder`.
|
|
68
68
|
- `frame` — relative wrapper around the input when a keyboard hint is shown.
|
|
69
69
|
- `keyboardHint` — Maui `Kbd variant="quiet"` inside `frame`.
|
|
70
70
|
- `error` — RAC `FieldError`.
|
|
@@ -76,7 +76,7 @@ Controlled: `selected` + `onChange`. **`label` is required.**
|
|
|
76
76
|
|
|
77
77
|
**NumberField** — `numberFieldStyles`
|
|
78
78
|
- `root` — RAC `Group`; `:has(:focus-visible)`, `:has(input:disabled)`, `:has(input[aria-invalid='true'])`; styles the stepper `button`s inside a `.number-stepper` wrapper, so it needs that markup.
|
|
79
|
-
- `input` — RAC `Input`; `:
|
|
79
|
+
- `input` — RAC `Input`; `:disabled`, `::placeholder`.
|
|
80
80
|
|
|
81
81
|
**QuietTextField** — `quietTextFieldStyles`
|
|
82
82
|
- `input({ keyboardHint })` — RAC `Input`; `:hover`, `:disabled`, `aria-invalid`, `:focus-visible`, `::placeholder`.
|
|
@@ -47,6 +47,18 @@ Buttons and form-control surfaces use `shadow.control` by default. For compound
|
|
|
47
47
|
|
|
48
48
|
`focusRing()` applies a theme-aware Radix blue shadow with a `blueAlpha[8]` hard edge and `blueAlpha[5]` glow. Do not add a competing outline or box shadow for keyboard focus.
|
|
49
49
|
|
|
50
|
+
**Control safe area.** Input and form-control shadows paint outside the component's layout box; reserve room for the resting, hovered, and keyboard-focused states, even when hover does not change the background. The focus treatment includes a 1px ring and a 6px blur. Use `spacing.padding(4)` (9px) as a practical safe inset between controls and clipping edges, and leave gaps between neighboring controls. Keep overflow visible on immediate wrappers; when a panel, rounded container, or scroll viewport must clip, put the safe inset inside that clipping boundary, not outside it as margin. Check the first and last controls at scroll edges, including checkbox, radio, switch, and slider focus states. Do not remove shadows or focus rings to fit a tight layout.
|
|
51
|
+
|
|
52
|
+
```tsx
|
|
53
|
+
// Keep the control's visual overflow inside the scrolling boundary.
|
|
54
|
+
<div style={{ overflow: "auto" }}>
|
|
55
|
+
<Flex column padding={4} gap={4}>
|
|
56
|
+
<TextField aria-label="Name" />
|
|
57
|
+
<Select aria-label="Plan">{/* options */}</Select>
|
|
58
|
+
</Flex>
|
|
59
|
+
</div>
|
|
60
|
+
```
|
|
61
|
+
|
|
50
62
|
## Layout utilities
|
|
51
63
|
|
|
52
64
|
`Flex` and `Gap` take spacing scale steps (`1 | 2 | 3 | 4 | 6 | 8 | 12 | 16`), not raw pixels. For example, `<Flex row gap={4} padding={4}>` has a 9px gap and 9px inset.
|
|
@@ -264,11 +264,11 @@ export const catalog: CatalogComponent[] = [
|
|
|
264
264
|
},
|
|
265
265
|
{
|
|
266
266
|
name: "Button",
|
|
267
|
-
info: "Button. default is raised, quiet
|
|
267
|
+
info: "Button. default is raised, quiet adds a hover wash, text changes only color on hover, primary fills with accent 9. Works as a MenuTrigger child.",
|
|
268
268
|
html: true,
|
|
269
269
|
attributes: [
|
|
270
270
|
{ name: "size", values: ["default", "sm"] },
|
|
271
|
-
{ name: "variant", values: ["default", "quiet", "primary"] },
|
|
271
|
+
{ name: "variant", values: ["default", "quiet", "primary", "text"] },
|
|
272
272
|
{
|
|
273
273
|
name: "variantColor",
|
|
274
274
|
info: "Palette name (colors[name], primary step 9) or an opaque hex / rgb() fill. Quiet stays transparent; color tints the label. Hover mixes Alpha[9] at 6%/9%.",
|
|
@@ -23,6 +23,13 @@ describe("collectJsxDiagnosticsFromSource", () => {
|
|
|
23
23
|
expect(diagnostics).toEqual([])
|
|
24
24
|
})
|
|
25
25
|
|
|
26
|
+
test("accepts a text Button", () => {
|
|
27
|
+
const diagnostics = collectJsxDiagnosticsFromSource(
|
|
28
|
+
`<Button variant="text" variantColor="blue">Connect</Button>`,
|
|
29
|
+
)
|
|
30
|
+
expect(diagnostics).toEqual([])
|
|
31
|
+
})
|
|
32
|
+
|
|
26
33
|
test("accepts a small Button", () => {
|
|
27
34
|
const diagnostics = collectJsxDiagnosticsFromSource(
|
|
28
35
|
`<Button size="sm">Compact</Button>`,
|
|
@@ -54,7 +61,7 @@ describe("collectJsxDiagnosticsFromSource", () => {
|
|
|
54
61
|
)
|
|
55
62
|
expect(diagnostics).toHaveLength(1)
|
|
56
63
|
expect(diagnostics[0]?.message).toBe(
|
|
57
|
-
`Type '"solid"' is not assignable to type '"default" | "quiet" | "primary"'.`,
|
|
64
|
+
`Type '"solid"' is not assignable to type '"default" | "quiet" | "primary" | "text"'.`,
|
|
58
65
|
)
|
|
59
66
|
})
|
|
60
67
|
|
|
@@ -66,9 +73,9 @@ describe("collectJsxDiagnosticsFromSource", () => {
|
|
|
66
73
|
</Flex>`,
|
|
67
74
|
)
|
|
68
75
|
expect(formatTypeErrorBanners(diagnostics)).toEqual([
|
|
69
|
-
`Line 2: Type '"solid"' is not assignable to type '"default" | "quiet" | "primary"'.`,
|
|
76
|
+
`Line 2: Type '"solid"' is not assignable to type '"default" | "quiet" | "primary" | "text"'.`,
|
|
70
77
|
`Line 2: Property 'disabled' does not exist on Button.`,
|
|
71
|
-
`Line 3: Type '"ghost"' is not assignable to type '"default" | "quiet" | "primary"'.`,
|
|
78
|
+
`Line 3: Type '"ghost"' is not assignable to type '"default" | "quiet" | "primary" | "text"'.`,
|
|
72
79
|
])
|
|
73
80
|
})
|
|
74
81
|
|
|
@@ -30,7 +30,7 @@ import { spacing } from "../tokens/spacing"
|
|
|
30
30
|
import { text } from "../tokens/text"
|
|
31
31
|
import { memoize } from "../utils/memoize"
|
|
32
32
|
|
|
33
|
-
export type ButtonVariant = "default" | "quiet" | "primary"
|
|
33
|
+
export type ButtonVariant = "default" | "quiet" | "primary" | "text"
|
|
34
34
|
export type ButtonSize = "default" | "sm"
|
|
35
35
|
|
|
36
36
|
const buttonShadow = shadowVars.control
|
|
@@ -173,18 +173,27 @@ const buttonClass = style(buttonBaseClass, {
|
|
|
173
173
|
},
|
|
174
174
|
})
|
|
175
175
|
|
|
176
|
+
const textButtonSpacingClass = style({
|
|
177
|
+
"&, &:has(> svg), &:has([data-button-content] > svg), &:has(> svg:first-child:not(:only-child)), &:has([data-button-content] > svg:first-child:not(:only-child)), &:has(> svg:last-child:not(:only-child)), &:has([data-button-content] > svg:last-child:not(:only-child)), &:has(> svg:only-child), &:has([data-button-content] > svg:only-child), &[data-pending]":
|
|
178
|
+
{
|
|
179
|
+
padding: 0,
|
|
180
|
+
},
|
|
181
|
+
})
|
|
182
|
+
|
|
176
183
|
function paletteAlpha(name: ColorName): ColorScale {
|
|
177
184
|
return colors[`${name}Alpha`]
|
|
178
185
|
}
|
|
179
186
|
|
|
180
|
-
const quietClass = memoize((color: string, hoverColor: string, wash
|
|
187
|
+
const quietClass = memoize((color: string, hoverColor: string, wash?: string) =>
|
|
181
188
|
style(buttonBaseClass, focusRing("&:focus-visible"), {
|
|
182
189
|
color,
|
|
183
190
|
backgroundColor: "transparent",
|
|
184
191
|
boxShadow: "none",
|
|
185
192
|
"&:hover:not(:disabled):not([data-pending])": {
|
|
186
193
|
color: hoverColor,
|
|
187
|
-
backgroundColor:
|
|
194
|
+
backgroundColor: wash
|
|
195
|
+
? surfaceWash(wash, surfaceMixPercent.hover)
|
|
196
|
+
: "transparent",
|
|
188
197
|
},
|
|
189
198
|
"&:disabled": disabledQuiet,
|
|
190
199
|
}),
|
|
@@ -275,7 +284,7 @@ const primaryClass = memoize(
|
|
|
275
284
|
)
|
|
276
285
|
|
|
277
286
|
const coloredButtonClass = memoize(
|
|
278
|
-
(variant: "primary" | "quiet", color: ButtonVariantColor) => {
|
|
287
|
+
(variant: "primary" | "quiet" | "text", color: ButtonVariantColor) => {
|
|
279
288
|
if (isCssColor(color)) {
|
|
280
289
|
const fill = opaqueColor(color)
|
|
281
290
|
if (variant === "primary") {
|
|
@@ -283,7 +292,11 @@ const coloredButtonClass = memoize(
|
|
|
283
292
|
return primaryClass(fill, onSolidText(fill), fillShade(fill, 0, 0.35))
|
|
284
293
|
}
|
|
285
294
|
|
|
286
|
-
return quietClass(
|
|
295
|
+
return quietClass(
|
|
296
|
+
fill,
|
|
297
|
+
darkerFill(fill),
|
|
298
|
+
variant === "quiet" ? fill : undefined,
|
|
299
|
+
)
|
|
287
300
|
}
|
|
288
301
|
|
|
289
302
|
const scale = paletteFill(color)
|
|
@@ -295,7 +308,11 @@ const coloredButtonClass = memoize(
|
|
|
295
308
|
)
|
|
296
309
|
}
|
|
297
310
|
|
|
298
|
-
return quietClass(
|
|
311
|
+
return quietClass(
|
|
312
|
+
scale[11],
|
|
313
|
+
scale[12],
|
|
314
|
+
variant === "quiet" ? paletteAlpha(color)[9] : undefined,
|
|
315
|
+
)
|
|
299
316
|
},
|
|
300
317
|
)
|
|
301
318
|
|
|
@@ -365,6 +382,7 @@ export const buttonStyles = {
|
|
|
365
382
|
style(
|
|
366
383
|
buttonVariantClass(variant, variantColor),
|
|
367
384
|
size === "sm" && smallButtonClass,
|
|
385
|
+
variant === "text" && textButtonSpacingClass,
|
|
368
386
|
),
|
|
369
387
|
),
|
|
370
388
|
label: buttonTextClass,
|
|
@@ -510,6 +528,9 @@ function buttonVariantClass(
|
|
|
510
528
|
if (variant === "primary") {
|
|
511
529
|
return coloredButtonClass("primary", variantColor ?? "accent")
|
|
512
530
|
}
|
|
531
|
+
if (variant === "text") {
|
|
532
|
+
return coloredButtonClass("text", variantColor ?? "gray")
|
|
533
|
+
}
|
|
513
534
|
if (variant === "quiet" && variantColor) {
|
|
514
535
|
return coloredButtonClass("quiet", variantColor)
|
|
515
536
|
}
|
|
@@ -115,10 +115,14 @@ const title = style(text({ size: "lg", fontWeight: 600 }), {
|
|
|
115
115
|
margin: 0,
|
|
116
116
|
})
|
|
117
117
|
|
|
118
|
+
// A scrolling box clips on both axes. The inset, offset by a negative margin,
|
|
119
|
+
// keeps the rings and focus glow of full-width fields visible without moving them.
|
|
118
120
|
const body = style({
|
|
119
121
|
minWidth: 0,
|
|
120
122
|
minHeight: 0,
|
|
121
123
|
overflowY: "auto",
|
|
124
|
+
margin: `calc(-1 * ${spacing.value(3)})`,
|
|
125
|
+
padding: spacing.value(3),
|
|
122
126
|
})
|
|
123
127
|
|
|
124
128
|
const actions = style({
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { EditorContent, type EditorContentProps } from "@tiptap/react"
|
|
2
2
|
import { style, useStyles } from "purse-styles"
|
|
3
3
|
import { colors } from "../tokens/colors"
|
|
4
|
-
import { proseHtml, type ProseSize } from "../tokens/prose"
|
|
4
|
+
import { proseHtml, proseRhythm, type ProseSize } from "../tokens/prose"
|
|
5
5
|
import { cls } from "../utils/cls"
|
|
6
6
|
|
|
7
7
|
export type EditorProps = Omit<EditorContentProps, "size"> & {
|
|
@@ -15,7 +15,11 @@ export type EditorProps = Omit<EditorContentProps, "size"> & {
|
|
|
15
15
|
* through TipTap's editorProps.attributes.
|
|
16
16
|
*/
|
|
17
17
|
export function Editor({ size = "md", className, ...props }: EditorProps) {
|
|
18
|
-
const surfaceClassName = useStyles(
|
|
18
|
+
const surfaceClassName = useStyles(
|
|
19
|
+
editorClass,
|
|
20
|
+
proseHtml(size),
|
|
21
|
+
proseRhythm(size, "& > .ProseMirror"),
|
|
22
|
+
)
|
|
19
23
|
return (
|
|
20
24
|
<EditorContent
|
|
21
25
|
{...props}
|
|
@@ -33,7 +37,7 @@ const editorClass = style({
|
|
|
33
37
|
// proseHtml styles descendants; its block rhythm belongs on the document.
|
|
34
38
|
display: "flex",
|
|
35
39
|
flexDirection: "column",
|
|
36
|
-
gap:
|
|
40
|
+
gap: 0,
|
|
37
41
|
},
|
|
38
42
|
"& .ProseMirror p.is-editor-empty:first-child::before": {
|
|
39
43
|
color: colors.gray[9],
|
package/src/components/Input.tsx
CHANGED
|
@@ -46,9 +46,6 @@ const inputClass = style(
|
|
|
46
46
|
color: colors.gray[12],
|
|
47
47
|
border: "none",
|
|
48
48
|
background: backgroundColor.element,
|
|
49
|
-
"&:hover:not(:disabled)": {
|
|
50
|
-
background: backgroundColor.elementHover,
|
|
51
|
-
},
|
|
52
49
|
"&:disabled": {
|
|
53
50
|
color: colors.gray[8],
|
|
54
51
|
background: colors.gray[2],
|
|
@@ -376,9 +373,6 @@ const numberInputClass = style(
|
|
|
376
373
|
border: "none",
|
|
377
374
|
outline: "none",
|
|
378
375
|
background: "transparent",
|
|
379
|
-
"&:hover:not(:disabled)": {
|
|
380
|
-
background: backgroundColor.elementHover,
|
|
381
|
-
},
|
|
382
376
|
"&::placeholder": {
|
|
383
377
|
fontStyle: "italic",
|
|
384
378
|
color: colors.gray[8],
|
|
@@ -63,7 +63,7 @@ const thumb = style(focusRing("&[data-focus-visible]", shadowVars.control), {
|
|
|
63
63
|
width: sliderSize.thumb,
|
|
64
64
|
height: sliderSize.thumb,
|
|
65
65
|
borderRadius: "100%",
|
|
66
|
-
background:
|
|
66
|
+
background: "#ffffff",
|
|
67
67
|
boxShadow: shadowVars.control,
|
|
68
68
|
"&[data-dragging]": {
|
|
69
69
|
background: colors.accent[11],
|
|
@@ -35,7 +35,7 @@ export function ButtonsPage() {
|
|
|
35
35
|
return (
|
|
36
36
|
<Prose style={{ marginBottom: "32px" }}>
|
|
37
37
|
<H2>Buttons</H2>
|
|
38
|
-
<H3>
|
|
38
|
+
<H3>Default</H3>
|
|
39
39
|
<Flex row alignItems="center" gap={4}>
|
|
40
40
|
<Button>Button</Button>
|
|
41
41
|
<Button onClick={() => setDialogOpen(true)}>Open Dialog</Button>
|
|
@@ -221,6 +221,35 @@ export function ButtonsPage() {
|
|
|
221
221
|
</Button>
|
|
222
222
|
</Flex>
|
|
223
223
|
|
|
224
|
+
<H3>Text</H3>
|
|
225
|
+
<P>
|
|
226
|
+
<Code>variant="text"</Code> shows only the label and icon, with no
|
|
227
|
+
padding, background, or shadow. Hover changes only their color. Use{" "}
|
|
228
|
+
<Code>variantColor</Code> to tint them.
|
|
229
|
+
</P>
|
|
230
|
+
<Flex row alignItems="center" gap={4} style={{ flexWrap: "wrap" }}>
|
|
231
|
+
<Button variant="text">Text button</Button>
|
|
232
|
+
<Button variant="text" variantColor="blue">
|
|
233
|
+
<Plus />
|
|
234
|
+
Create
|
|
235
|
+
</Button>
|
|
236
|
+
<Button variant="text" aria-label="More text actions">
|
|
237
|
+
<DotsHorizontal />
|
|
238
|
+
</Button>
|
|
239
|
+
<Button variant="text" variantColor="#6366f1">
|
|
240
|
+
Custom color
|
|
241
|
+
</Button>
|
|
242
|
+
<Button variant="text" size="sm">
|
|
243
|
+
Small text
|
|
244
|
+
</Button>
|
|
245
|
+
<Button variant="text" isDisabled>
|
|
246
|
+
Disabled text
|
|
247
|
+
</Button>
|
|
248
|
+
<Button variant="text" isPending>
|
|
249
|
+
Pending text
|
|
250
|
+
</Button>
|
|
251
|
+
</Flex>
|
|
252
|
+
|
|
224
253
|
<H3>Icons with text</H3>
|
|
225
254
|
<Flex row alignItems="center" gap={4}>
|
|
226
255
|
<Button>
|
package/src/tokens/prose.ts
CHANGED
|
@@ -11,15 +11,15 @@ import { fontFamily, monoFontStyle } from "./text"
|
|
|
11
11
|
* rhythm tuned for sustained reading. The `Prose` container picks a size and
|
|
12
12
|
* typography components inside it opt into the matching prose treatment.
|
|
13
13
|
*
|
|
14
|
-
*
|
|
14
|
+
* Type sizes and heading spacing are ported from the Tailwind Typography plugin
|
|
15
15
|
* (https://github.com/tailwindlabs/tailwindcss-typography): `sm` -> 14px base,
|
|
16
16
|
* `md` -> Tailwind's `base` (16px), `lg` -> 18px. Tailwind's em-relative values
|
|
17
17
|
* are resolved to px here so they compose with Maui's px-based tokens.
|
|
18
18
|
*
|
|
19
19
|
* On the Large platform scale (coarse pointers) block font sizes and line
|
|
20
20
|
* heights step up ~1.2x — the same idea as the `text` tokens — so reading
|
|
21
|
-
* content grows with the rest of the UI on touch devices.
|
|
22
|
-
*
|
|
21
|
+
* content grows with the rest of the UI on touch devices. Paragraph gaps stay
|
|
22
|
+
* at 10px on both scales.
|
|
23
23
|
*/
|
|
24
24
|
export type ProseSize = "sm" | "md" | "lg"
|
|
25
25
|
|
|
@@ -37,7 +37,6 @@ type HeadingMetric = BlockMetric & {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
type ProseMetric = {
|
|
40
|
-
blockGap: number
|
|
41
40
|
paragraph: BlockMetric
|
|
42
41
|
h1: HeadingMetric
|
|
43
42
|
h2: HeadingMetric
|
|
@@ -55,7 +54,6 @@ const scaledPx = (normal: number, large: number) =>
|
|
|
55
54
|
|
|
56
55
|
const metrics: Record<ProseSize, ProseMetric> = {
|
|
57
56
|
sm: {
|
|
58
|
-
blockGap: 16,
|
|
59
57
|
paragraph: { fontSize: scaledPx(14, 17), lineHeight: scaledPx(24, 29) },
|
|
60
58
|
h1: {
|
|
61
59
|
fontSize: scaledPx(30, 36),
|
|
@@ -81,11 +79,10 @@ const metrics: Record<ProseSize, ProseMetric> = {
|
|
|
81
79
|
marginTop: 20,
|
|
82
80
|
marginBottom: 8,
|
|
83
81
|
},
|
|
84
|
-
listPadding:
|
|
82
|
+
listPadding: 20,
|
|
85
83
|
listItemGap: 4,
|
|
86
84
|
},
|
|
87
85
|
md: {
|
|
88
|
-
blockGap: 20,
|
|
89
86
|
paragraph: { fontSize: scaledPx(16, 19), lineHeight: scaledPx(28, 34) },
|
|
90
87
|
h1: {
|
|
91
88
|
fontSize: scaledPx(36, 43),
|
|
@@ -111,11 +108,10 @@ const metrics: Record<ProseSize, ProseMetric> = {
|
|
|
111
108
|
marginTop: 24,
|
|
112
109
|
marginBottom: 8,
|
|
113
110
|
},
|
|
114
|
-
listPadding:
|
|
111
|
+
listPadding: 24,
|
|
115
112
|
listItemGap: 8,
|
|
116
113
|
},
|
|
117
114
|
lg: {
|
|
118
|
-
blockGap: 24,
|
|
119
115
|
paragraph: { fontSize: scaledPx(18, 22), lineHeight: scaledPx(32, 38) },
|
|
120
116
|
h1: {
|
|
121
117
|
fontSize: scaledPx(48, 58),
|
|
@@ -141,17 +137,12 @@ const metrics: Record<ProseSize, ProseMetric> = {
|
|
|
141
137
|
marginTop: 32,
|
|
142
138
|
marginBottom: 8,
|
|
143
139
|
},
|
|
144
|
-
listPadding:
|
|
140
|
+
listPadding: 26,
|
|
145
141
|
listItemGap: 12,
|
|
146
142
|
},
|
|
147
143
|
}
|
|
148
144
|
|
|
149
|
-
|
|
150
|
-
const blockGaps = defineVars({
|
|
151
|
-
sm: { default: `${metrics.sm.blockGap}px`, [LARGE_SCALE]: "12px" },
|
|
152
|
-
md: { default: `${metrics.md.blockGap}px`, [LARGE_SCALE]: "16px" },
|
|
153
|
-
lg: { default: `${metrics.lg.blockGap}px`, [LARGE_SCALE]: "20px" },
|
|
154
|
-
})
|
|
145
|
+
const blockGap = "10px"
|
|
155
146
|
|
|
156
147
|
// Heading weights follow Tailwind's descending scale, capped at Maui's
|
|
157
148
|
// heaviest weight (700).
|
|
@@ -216,24 +207,24 @@ const notHeading = ":not(h1):not(h2):not(h3):not(h4)"
|
|
|
216
207
|
* `marginBottom` becomes the "hug" gap after a heading; heading `marginTop`
|
|
217
208
|
* becomes the larger break before it.
|
|
218
209
|
*/
|
|
219
|
-
export const proseRhythm = memoize((size: ProseSize) => {
|
|
210
|
+
export const proseRhythm = memoize((size: ProseSize, selector = "&") => {
|
|
220
211
|
const m = metrics[size]
|
|
221
212
|
|
|
222
213
|
return style({
|
|
223
|
-
|
|
224
|
-
marginTop:
|
|
214
|
+
[`${selector} > * + *`]: {
|
|
215
|
+
marginTop: blockGap,
|
|
225
216
|
},
|
|
226
217
|
// Gap after a heading: hug it to the content that follows.
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
218
|
+
[`${selector} > h1 + *`]: { marginTop: px(m.h1.marginBottom) },
|
|
219
|
+
[`${selector} > h2 + *`]: { marginTop: px(m.h2.marginBottom) },
|
|
220
|
+
[`${selector} > h3 + *`]: { marginTop: px(m.h3.marginBottom) },
|
|
221
|
+
[`${selector} > h4 + *`]: { marginTop: px(m.h4.marginBottom) },
|
|
231
222
|
// Break before a heading (only when it opens a new section, i.e. it
|
|
232
223
|
// isn't already hugging a preceding heading).
|
|
233
|
-
[
|
|
234
|
-
[
|
|
235
|
-
[
|
|
236
|
-
[
|
|
224
|
+
[`${selector} > ${notHeading} + h1`]: { marginTop: px(m.h1.marginTop) },
|
|
225
|
+
[`${selector} > ${notHeading} + h2`]: { marginTop: px(m.h2.marginTop) },
|
|
226
|
+
[`${selector} > ${notHeading} + h3`]: { marginTop: px(m.h3.marginTop) },
|
|
227
|
+
[`${selector} > ${notHeading} + h4`]: { marginTop: px(m.h4.marginTop) },
|
|
237
228
|
})
|
|
238
229
|
})
|
|
239
230
|
|
|
@@ -250,7 +241,7 @@ export const proseHtml = memoize((size: ProseSize) => {
|
|
|
250
241
|
return style({
|
|
251
242
|
display: "flex",
|
|
252
243
|
flexDirection: "column",
|
|
253
|
-
gap:
|
|
244
|
+
gap: blockGap,
|
|
254
245
|
"& p": {
|
|
255
246
|
...block(m.paragraph),
|
|
256
247
|
fontWeight: 400,
|