@astryxdesign/core 0.5.0-canary.8985bb9 → 0.5.0-canary.955ecef
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/AlertDialog/AlertDialog.d.ts +1 -1
- package/dist/AlertDialog/AlertDialog.d.ts.map +1 -1
- package/dist/AlertDialog/AlertDialog.js +45 -20
- package/dist/Card/Card.d.ts +6 -9
- package/dist/Card/Card.d.ts.map +1 -1
- package/dist/Card/Card.js +6 -6
- package/package.json +2 -2
- package/src/AlertDialog/AlertDialog.doc.mjs +114 -30
- package/src/AlertDialog/AlertDialog.test.tsx +211 -2
- package/src/AlertDialog/AlertDialog.tsx +62 -20
- package/src/Card/Card.doc.mjs +10 -7
- package/src/Card/Card.test.tsx +44 -19
- package/src/Card/Card.tsx +12 -32
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file AlertDialog.tsx
|
|
3
|
-
* @input Uses React, Dialog, Layout, Heading, Text, Button
|
|
3
|
+
* @input Uses React, StyleX, Dialog, Layout, Heading, Text, Button
|
|
4
4
|
* @output Exports AlertDialog component, AlertDialogProps type
|
|
5
5
|
* @position Core implementation; consumed by index.ts, tested by AlertDialog.test.tsx
|
|
6
6
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AlertDialog.d.ts","sourceRoot":"","sources":["../../src/AlertDialog/AlertDialog.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAA2B,MAAM,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"AlertDialog.d.ts","sourceRoot":"","sources":["../../src/AlertDialog/AlertDialog.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAA2B,MAAM,OAAO,CAAC;AAShD,OAAO,EAAS,KAAK,aAAa,EAAC,MAAM,WAAW,CAAC;AACrD,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAkB5C,MAAM,WAAW,gBAAiB,SAAQ,SAAS,CAAC,iBAAiB,CAAC;IACpE,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC;IACnC;;OAEG;IACH,MAAM,EAAE,OAAO,CAAC;IAEhB;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IAEnB;;;OAGG;IACH,YAAY,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,OAAO,CAAC;IAE3C;;OAEG;IACH,KAAK,EAAE,MAAM,CAAC;IAEd;;OAEG;IACH,WAAW,EAAE,MAAM,CAAC;IAEpB;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IAErB;;OAEG;IACH,WAAW,EAAE,MAAM,CAAC;IAEpB;;;OAGG;IACH,aAAa,CAAC,EAAE,aAAa,CAAC;IAE9B;;OAEG;IACH,eAAe,CAAC,EAAE,OAAO,CAAC;IAE1B;;;OAGG;IACH,QAAQ,EAAE,MAAM,OAAO,CAAC;IAExB;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;CACzB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,WAAW,CAAC,EAC1B,GAAG,EACH,MAAM,EACN,QAAQ,EACR,YAAY,EACZ,KAAK,EACL,WAAW,EACX,WAAW,EAAE,oBAAoB,EACjC,WAAW,EACX,aAA6B,EAC7B,eAAe,EACf,QAAQ,EACR,KAAW,EACX,MAAM,EACN,SAAS,EACT,KAAK,EACL,aAAa,EAAE,MAAM,EACrB,GAAG,IAAI,EACR,EAAE,gBAAgB,qBA8FlB;yBAhHe,WAAW"}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* @file AlertDialog.tsx
|
|
7
|
-
* @input Uses React, Dialog, Layout, Heading, Text, Button
|
|
7
|
+
* @input Uses React, StyleX, Dialog, Layout, Heading, Text, Button
|
|
8
8
|
* @output Exports AlertDialog component, AlertDialogProps type
|
|
9
9
|
* @position Core implementation; consumed by index.ts, tested by AlertDialog.test.tsx
|
|
10
10
|
*
|
|
@@ -16,18 +16,32 @@
|
|
|
16
16
|
* - /packages/cli/assets/templates/blocks/components/AlertDialog/ (showcase blocks)
|
|
17
17
|
*/
|
|
18
18
|
import React, { useId, useCallback } from 'react';
|
|
19
|
+
import * as stylex from '@stylexjs/stylex';
|
|
19
20
|
import { Dialog } from "../Dialog/index.js";
|
|
20
21
|
import { Layout } from "../Layout/Layout.js";
|
|
21
22
|
import { LayoutContent } from "../Layout/LayoutContent.js";
|
|
22
23
|
import { LayoutFooter } from "../Layout/LayoutFooter.js";
|
|
23
|
-
import {
|
|
24
|
+
import { Stack } from "../Stack/index.js";
|
|
24
25
|
import { Heading } from "../Heading/Heading.js";
|
|
25
26
|
import { Text } from "../Text/Text.js";
|
|
26
27
|
import { Button } from "../Button/index.js";
|
|
27
28
|
import { mergeProps } from "../utils/index.js";
|
|
28
29
|
import { themeProps } from "../utils/themeProps.js";
|
|
29
30
|
import { useTranslator } from "../i18n/index.js";
|
|
31
|
+
import { useMediaQuery } from "../hooks/index.js";
|
|
30
32
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
33
|
+
const SMALL_SCREEN_QUERY = '(max-width: 640px)';
|
|
34
|
+
const styles = {
|
|
35
|
+
action: {
|
|
36
|
+
ks0D6T: "x193iq5w",
|
|
37
|
+
k7Eaqz: "xeuugli",
|
|
38
|
+
$$css: true
|
|
39
|
+
},
|
|
40
|
+
stackedAction: {
|
|
41
|
+
kzqmXN: "xh8yej3",
|
|
42
|
+
$$css: true
|
|
43
|
+
}
|
|
44
|
+
};
|
|
31
45
|
/**
|
|
32
46
|
* A confirmation dialog for destructive or irreversible actions.
|
|
33
47
|
*
|
|
@@ -77,9 +91,33 @@ export function AlertDialog({
|
|
|
77
91
|
const cancelLabel = cancelLabelFromProps ?? t('@astryx.alertDialog.cancel');
|
|
78
92
|
const titleId = useId();
|
|
79
93
|
const descriptionId = useId();
|
|
94
|
+
// Width stays delegated to Dialog: this query exists only so AlertDialog can
|
|
95
|
+
// keep narrow visual, DOM, and tab order aligned for its destructive/cancel
|
|
96
|
+
// semantics without coupling action order to pointer or hover capability.
|
|
97
|
+
const isSmallScreen = useMediaQuery(SMALL_SCREEN_QUERY);
|
|
80
98
|
const handleCancel = useCallback(() => {
|
|
81
99
|
onOpenChange(false);
|
|
82
100
|
}, [onOpenChange]);
|
|
101
|
+
const buttonActionStyle = [styles.action, isSmallScreen && styles.stackedAction];
|
|
102
|
+
const cancelButton = /*#__PURE__*/_jsx(Button, {
|
|
103
|
+
variant: "ghost",
|
|
104
|
+
label: cancelLabel,
|
|
105
|
+
onClick: handleCancel,
|
|
106
|
+
xstyle: buttonActionStyle
|
|
107
|
+
// Dialog focuses [data-autofocus] itself after showModal(), because
|
|
108
|
+
// React's autoFocus runs during commit while the dialog is invisible.
|
|
109
|
+
// Cancel is least destructive, so it remains the autofocus target even
|
|
110
|
+
// when small-screen visual order places the destructive action above it.
|
|
111
|
+
,
|
|
112
|
+
"data-autofocus": true
|
|
113
|
+
}, "cancel");
|
|
114
|
+
const actionButton = /*#__PURE__*/_jsx(Button, {
|
|
115
|
+
variant: actionVariant,
|
|
116
|
+
label: actionLabel,
|
|
117
|
+
onClick: onAction,
|
|
118
|
+
isLoading: isActionLoading,
|
|
119
|
+
xstyle: buttonActionStyle
|
|
120
|
+
}, "action");
|
|
83
121
|
return /*#__PURE__*/_jsx(Dialog, {
|
|
84
122
|
...rest,
|
|
85
123
|
ref: ref,
|
|
@@ -117,25 +155,12 @@ export function AlertDialog({
|
|
|
117
155
|
})]
|
|
118
156
|
}),
|
|
119
157
|
footer: /*#__PURE__*/_jsx(LayoutFooter, {
|
|
120
|
-
children: /*#__PURE__*/
|
|
158
|
+
children: /*#__PURE__*/_jsx(Stack, {
|
|
159
|
+
direction: isSmallScreen ? 'vertical' : 'horizontal',
|
|
121
160
|
gap: 2,
|
|
122
|
-
hAlign:
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
label: cancelLabel,
|
|
126
|
-
onClick: handleCancel
|
|
127
|
-
// Dialog focuses [data-autofocus] itself after showModal(),
|
|
128
|
-
// because React's autoFocus runs during commit while the
|
|
129
|
-
// dialog is still invisible. Cancel is the least destructive
|
|
130
|
-
// choice, so it is the one that should be preselected.
|
|
131
|
-
,
|
|
132
|
-
"data-autofocus": true
|
|
133
|
-
}), /*#__PURE__*/_jsx(Button, {
|
|
134
|
-
variant: actionVariant,
|
|
135
|
-
label: actionLabel,
|
|
136
|
-
onClick: onAction,
|
|
137
|
-
isLoading: isActionLoading
|
|
138
|
-
})]
|
|
161
|
+
hAlign: isSmallScreen ? 'stretch' : 'end',
|
|
162
|
+
wrap: isSmallScreen ? 'nowrap' : 'wrap',
|
|
163
|
+
children: isSmallScreen ? [actionButton, cancelButton] : [cancelButton, actionButton]
|
|
139
164
|
})
|
|
140
165
|
})
|
|
141
166
|
})
|
package/dist/Card/Card.d.ts
CHANGED
|
@@ -30,14 +30,6 @@ export type CardVariant = 'default' | 'transparent' | 'muted' | 'blue' | 'cyan'
|
|
|
30
30
|
export type { SizeValue } from '../utils/types';
|
|
31
31
|
export interface CardProps extends BaseProps<HTMLDivElement> {
|
|
32
32
|
ref?: React.Ref<HTMLDivElement>;
|
|
33
|
-
/**
|
|
34
|
-
* CSS class name(s) appended to the root element.
|
|
35
|
-
*/
|
|
36
|
-
className?: string;
|
|
37
|
-
/**
|
|
38
|
-
* Inline styles to apply to the root element.
|
|
39
|
-
*/
|
|
40
|
-
style?: React.CSSProperties;
|
|
41
33
|
/**
|
|
42
34
|
* Width of the card.
|
|
43
35
|
* Numbers are treated as pixels, strings are used as-is.
|
|
@@ -66,7 +58,12 @@ export interface CardProps extends BaseProps<HTMLDivElement> {
|
|
|
66
58
|
/**
|
|
67
59
|
* Internal padding of the card using the spacing scale.
|
|
68
60
|
* Accepts numeric spacing steps: 0, 0.5, 1, 1.5, 2, 3, 4, 5, 6, 8, 10.
|
|
69
|
-
*
|
|
61
|
+
*
|
|
62
|
+
* Omit it and the card takes the theme's card padding rather than a step, so
|
|
63
|
+
* passing a step is a decision to override the theme, not a way to restate
|
|
64
|
+
* the default.
|
|
65
|
+
*
|
|
66
|
+
* @default the theme's card padding (spacing step 4 with no theme)
|
|
70
67
|
*/
|
|
71
68
|
padding?: SpacingStep;
|
|
72
69
|
/**
|
package/dist/Card/Card.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Card.d.ts","sourceRoot":"","sources":["../../src/Card/Card.tsx"],"names":[],"mappings":"AAEA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"Card.d.ts","sourceRoot":"","sources":["../../src/Card/Card.tsx"],"names":[],"mappings":"AAEA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,OAAO,CAAC;AAWrC,OAAO,KAAK,EAAC,SAAS,EAAE,SAAS,EAAE,WAAW,EAAC,MAAM,gBAAgB,CAAC;AAEtE,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAO5C;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,WAAW,GACnB,SAAS,GACT,aAAa,GACb,OAAO,GACP,MAAM,GACN,MAAM,GACN,MAAM,GACN,OAAO,GACP,QAAQ,GACR,MAAM,GACN,QAAQ,GACR,KAAK,GACL,MAAM,GACN,QAAQ,CAAC;AA2Gb,YAAY,EAAC,SAAS,EAAC,MAAM,gBAAgB,CAAC;AAE9C,MAAM,WAAW,SAAU,SAAQ,SAAS,CAAC,cAAc,CAAC;IAC1D,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC;IAChC;;;OAGG;IACH,KAAK,CAAC,EAAE,SAAS,CAAC;IAElB;;;OAGG;IACH,MAAM,CAAC,EAAE,SAAS,CAAC;IAEnB;;;OAGG;IACH,QAAQ,CAAC,EAAE,SAAS,CAAC;IAErB;;;OAGG;IACH,SAAS,CAAC,EAAE,SAAS,CAAC;IAEtB;;;OAGG;IACH,QAAQ,CAAC,EAAE,SAAS,CAAC;IAErB;;;;;;;;;OASG;IACH,OAAO,CAAC,EAAE,WAAW,CAAC;IAEtB;;;;;;;OAOG;IACH,OAAO,CAAC,EAAE,WAAW,CAAC;IAEtB;;;;OAIG;IACH,SAAS,CAAC,EAAE,SAAS,CAAC;CACvB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,wBAAgB,IAAI,CAAC,EACnB,KAAK,EACL,MAAM,EACN,QAAQ,EACR,SAAS,EACT,QAAQ,EACR,OAAO,EACP,OAAmB,EACnB,SAAkB,EAClB,MAAM,EACN,SAAS,EACT,KAAK,EACL,GAAG,EACH,GAAG,KAAK,EACT,EAAE,SAAS,+BAgDX;yBA9De,IAAI"}
|
package/dist/Card/Card.js
CHANGED
|
@@ -17,7 +17,7 @@ import * as stylex from '@stylexjs/stylex';
|
|
|
17
17
|
import "../theme/tokens.stylex.js";
|
|
18
18
|
import { borderVars, colorVars, radiusVars, shadowVars } from "../theme/tokens.stylex.js";
|
|
19
19
|
import { container } from "../Layout/container.stylex.js";
|
|
20
|
-
import {
|
|
20
|
+
import { spacingStepToToken } from "../Layout/padding.stylex.js";
|
|
21
21
|
import { mergeProps } from "../utils/index.js";
|
|
22
22
|
import { themeProps } from "../utils/themeProps.js";
|
|
23
23
|
|
|
@@ -216,20 +216,20 @@ export function Card({
|
|
|
216
216
|
|
|
217
217
|
// When no explicit padding prop, use theme default (set via container tokens)
|
|
218
218
|
const useThemeDefault = padding == null;
|
|
219
|
-
const
|
|
220
|
-
const paddingToken = spacingStepToToken[effectivePadding];
|
|
219
|
+
const paddingToken = useThemeDefault ? undefined : spacingStepToToken[padding];
|
|
221
220
|
return /*#__PURE__*/_jsx("div", {
|
|
222
221
|
ref: ref,
|
|
223
222
|
...mergeProps(themeProps('card', {
|
|
224
|
-
variant
|
|
225
|
-
|
|
223
|
+
variant,
|
|
224
|
+
elevation
|
|
225
|
+
}), stylex.props(styles.card, variantStyles[variant], elevationStyles[elevation], hasFixedHeight && styles.scrollable, dynamicStyles.sizing(width ?? null, height ?? null, maxWidth ?? null, minHeight ?? null), ...container(paddingToken == null ? {
|
|
226
226
|
useThemeDefault: 'card'
|
|
227
227
|
} : {
|
|
228
228
|
paddingInnerX: paddingToken,
|
|
229
229
|
paddingInnerY: paddingToken,
|
|
230
230
|
paddingOuterX: paddingToken,
|
|
231
231
|
paddingOuterY: paddingToken
|
|
232
|
-
}),
|
|
232
|
+
}),
|
|
233
233
|
// Applied after the container padding so the border-inset calc wins;
|
|
234
234
|
// it reads the --container-padding-* vars set above.
|
|
235
235
|
variant === 'default' && styles.withBorder, xstyle), className, style),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astryxdesign/core",
|
|
3
|
-
"version": "0.5.0-canary.
|
|
3
|
+
"version": "0.5.0-canary.955ecef",
|
|
4
4
|
"displayName": "Astryx Core",
|
|
5
5
|
"description": "The component library. Accessible, themeable React components with built-in spacing, dark mode, and StyleX styling.",
|
|
6
6
|
"author": "Meta Open Source",
|
|
@@ -661,7 +661,7 @@
|
|
|
661
661
|
"react-dom": ">=19.0.0"
|
|
662
662
|
},
|
|
663
663
|
"devDependencies": {
|
|
664
|
-
"@astryxdesign/cli": "0.5.0-canary.
|
|
664
|
+
"@astryxdesign/cli": "0.5.0-canary.955ecef",
|
|
665
665
|
"@babel/cli": "^8.0.4",
|
|
666
666
|
"@babel/core": "^7.29.7",
|
|
667
667
|
"@babel/preset-react": "^8.0.1",
|
|
@@ -21,21 +21,75 @@ export const docs = {
|
|
|
21
21
|
],
|
|
22
22
|
usage: {
|
|
23
23
|
description:
|
|
24
|
-
'AlertDialog asks the user to confirm a destructive or irreversible action before it happens. Use it for things like deleting content, revoking access, or discarding unsaved changes.\n\nIt implements the WAI-ARIA APG [Alert Dialog pattern](https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/): `role="alertdialog"`, a title linked by `aria-labelledby`, a consequence description linked by `aria-describedby`, focus moved into the dialog on open and returned to the trigger on close, and no dismissal by clicking outside. Escape cancels.\n\nFor cases where you want to show an alert without managing open state, use the `useImperativeAlertDialog` hook: call `alert.show(options)` and render `alert.element` in your tree.',
|
|
24
|
+
'AlertDialog asks the user to confirm a destructive or irreversible action before it happens. Use it for things like deleting content, revoking access, or discarding unsaved changes.\n\nIt implements the WAI-ARIA APG [Alert Dialog pattern](https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/): `role="alertdialog"`, a title linked by `aria-labelledby`, a consequence description linked by `aria-describedby`, focus moved into the dialog on open and returned to the trigger on close, and no dismissal by clicking outside. Escape cancels.\n\nAlertDialog passes its requested width through to Dialog, which clamps the surface to the container and dynamic viewport with token gutters. Generic Dialog footers should wrap, but Dialog does not own action semantics or order; consumer composition controls that. AlertDialog owns its confirmation semantics: above 640px, actions render horizontally and may move onto another row; at 640px and below, the destructive action appears above Cancel and both buttons fill the footer width. Button labels retain their standard single-line behavior. The breakpoint follows available width, not pointer or hover capability. The body scrolls when block space is constrained.\n\nFor cases where you want to show an alert without managing open state, use the `useImperativeAlertDialog` hook: call `alert.show(options)` and render `alert.element` in your tree.',
|
|
25
25
|
bestPractices: [
|
|
26
|
-
{
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
{
|
|
26
|
+
{
|
|
27
|
+
guidance: true,
|
|
28
|
+
description:
|
|
29
|
+
'Make the action button label specific: "Delete project" is better than "OK" or "Confirm".',
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
guidance: true,
|
|
33
|
+
description:
|
|
34
|
+
'Describe what will happen in the description so the user knows the consequences before confirming.',
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
guidance: true,
|
|
38
|
+
description:
|
|
39
|
+
'Keep the cancel button as the least-destructive focus target. On narrow screens the destructive action is visually and structurally above Cancel, but Cancel still receives initial focus.',
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
guidance: true,
|
|
43
|
+
description:
|
|
44
|
+
'Use concise, specific action labels. Above 640px, complete buttons may move onto another row; at 640px and below, the destructive action appears above Cancel and both buttons fill the footer width.',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
guidance: false,
|
|
48
|
+
description:
|
|
49
|
+
'Use AlertDialog for non-destructive actions; use a standard Dialog instead.',
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
guidance: false,
|
|
53
|
+
description:
|
|
54
|
+
'Rely on color alone to signal danger; the action label itself should say what will happen.',
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
guidance: false,
|
|
58
|
+
description:
|
|
59
|
+
'Close the dialog from onAction before the work finishes; hold it open with isActionLoading and call onOpenChange(false) when the action settles.',
|
|
60
|
+
},
|
|
32
61
|
],
|
|
33
62
|
anatomy: [
|
|
34
|
-
{
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
63
|
+
{
|
|
64
|
+
name: 'Title',
|
|
65
|
+
required: true,
|
|
66
|
+
description:
|
|
67
|
+
'The question being asked. Renders as a level-2 heading and labels the dialog via aria-labelledby.',
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: 'Description',
|
|
71
|
+
required: true,
|
|
72
|
+
description:
|
|
73
|
+
'What will happen if the user confirms. Linked to the dialog via aria-describedby.',
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: 'Cancel button',
|
|
77
|
+
required: true,
|
|
78
|
+
description:
|
|
79
|
+
'Ghost button that dismisses without acting. Takes initial focus, and Escape does the same thing.',
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: 'Action button',
|
|
83
|
+
required: true,
|
|
84
|
+
description:
|
|
85
|
+
'The confirming action. Destructive by default; shows a spinner while isActionLoading is set.',
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: 'Backdrop',
|
|
89
|
+
required: true,
|
|
90
|
+
description:
|
|
91
|
+
'Overlay behind the dialog that blocks page interaction. Clicking it does not dismiss.',
|
|
92
|
+
},
|
|
39
93
|
],
|
|
40
94
|
},
|
|
41
95
|
// Intentionally a contained isInline preview, not playground.overlay: the
|
|
@@ -48,11 +102,13 @@ export const docs = {
|
|
|
48
102
|
isInline: true,
|
|
49
103
|
onOpenChange: undefined,
|
|
50
104
|
title: 'Delete item?',
|
|
51
|
-
description:
|
|
105
|
+
description:
|
|
106
|
+
'This action cannot be undone. The item and all its data will be permanently removed.',
|
|
52
107
|
actionLabel: 'Delete',
|
|
53
108
|
},
|
|
54
109
|
},
|
|
55
|
-
description:
|
|
110
|
+
description:
|
|
111
|
+
'A modal dialog that asks the user to confirm a destructive action.',
|
|
56
112
|
props: [
|
|
57
113
|
{
|
|
58
114
|
name: 'isOpen',
|
|
@@ -111,38 +167,66 @@ export const docs = {
|
|
|
111
167
|
name: 'width',
|
|
112
168
|
type: 'number | string',
|
|
113
169
|
default: '400',
|
|
114
|
-
description:
|
|
170
|
+
description:
|
|
171
|
+
'Requested dialog width. Dialog preserves this preferred width and clamps it to the container and dynamic viewport with token gutters.',
|
|
115
172
|
},
|
|
116
173
|
{
|
|
117
174
|
name: 'isInline',
|
|
118
175
|
type: 'boolean',
|
|
119
176
|
default: 'false',
|
|
120
|
-
description:
|
|
177
|
+
description:
|
|
178
|
+
'Renders alert dialog content inline without modal behavior. For documentation previews and showcases only. Not being a modal, the inline path renders role="group" instead of role="alertdialog".',
|
|
121
179
|
},
|
|
122
180
|
],
|
|
123
|
-
components: [
|
|
124
|
-
{name: 'useImperativeAlertDialog'},
|
|
125
|
-
],
|
|
181
|
+
components: [{name: 'useImperativeAlertDialog'}],
|
|
126
182
|
theming: {
|
|
127
|
-
targets: [
|
|
128
|
-
{className: 'astryx-alert-dialog'},
|
|
129
|
-
],
|
|
183
|
+
targets: [{className: 'astryx-alert-dialog'}],
|
|
130
184
|
},
|
|
131
185
|
};
|
|
132
186
|
|
|
133
187
|
/** @type {import('@astryxdesign/cli/authoring').ComponentTranslationDoc} */
|
|
134
188
|
export const docsDense = {
|
|
135
|
-
description:
|
|
189
|
+
description:
|
|
190
|
+
'Confirms destructive/irreversible action before it happens (delete, revoke access, discard unsaved changes).',
|
|
136
191
|
usage: {
|
|
137
192
|
description:
|
|
138
|
-
'AlertDialog confirms destructive/irreversible action (delete, revoke access, discard changes). Implements WAI-ARIA APG Alert Dialog pattern (https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/): role="alertdialog", aria-labelledby title, aria-describedby description, focus into dialog on open + back to trigger on close, no outside-click dismissal, Escape cancels. To show w/o managing open state, use useImperativeAlertDialog hook: call alert.show(options) + render alert.element in tree.',
|
|
193
|
+
'AlertDialog confirms destructive/irreversible action (delete, revoke access, discard changes). Implements WAI-ARIA APG Alert Dialog pattern (https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/): role="alertdialog", aria-labelledby title, aria-describedby description, focus into dialog on open + back to trigger on close, no outside-click dismissal, Escape cancels. Dialog preserves requested width + clamps it to container/dynamic viewport gutters. Generic Dialog footers wrap while consumers own action order. AlertDialog owns confirmation action layout: >640px horizontal buttons may move to another row; <=640px puts the destructive action above Cancel and makes both buttons full width regardless of pointer/hover capability. Button labels remain single-line. Body scrolls when height constrained. To show w/o managing open state, use useImperativeAlertDialog hook: call alert.show(options) + render alert.element in tree.',
|
|
139
194
|
bestPractices: [
|
|
140
|
-
{
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
{
|
|
195
|
+
{
|
|
196
|
+
guidance: true,
|
|
197
|
+
description:
|
|
198
|
+
'Make action button label specific: "Delete project" > "OK"/"Confirm".',
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
guidance: true,
|
|
202
|
+
description:
|
|
203
|
+
'Describe consequences in description so user knows outcome before confirming.',
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
guidance: true,
|
|
207
|
+
description:
|
|
208
|
+
'Keep cancel as initial focus: least destructive choice is preselected even when narrow visual/DOM order places it after the destructive action.',
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
guidance: true,
|
|
212
|
+
description:
|
|
213
|
+
'Use concise action labels; >640px complete buttons may move to another row, and <=640px puts the destructive action above Cancel with both buttons full width regardless of pointer type.',
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
guidance: false,
|
|
217
|
+
description:
|
|
218
|
+
'Use AlertDialog for non-destructive actions; use standard Dialog instead.',
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
guidance: false,
|
|
222
|
+
description:
|
|
223
|
+
'Rely on color alone for danger; the action label should say what happens.',
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
guidance: false,
|
|
227
|
+
description:
|
|
228
|
+
'Close from onAction before work finishes; hold open w/ isActionLoading, call onOpenChange(false) when it settles.',
|
|
229
|
+
},
|
|
146
230
|
],
|
|
147
231
|
},
|
|
148
232
|
};
|
|
@@ -9,12 +9,65 @@
|
|
|
9
9
|
* SYNC: When AlertDialog.tsx changes, update tests to match new behavior
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import {describe, it, expect, vi, beforeEach} from 'vitest';
|
|
13
|
-
import {
|
|
12
|
+
import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
|
|
13
|
+
import {
|
|
14
|
+
render,
|
|
15
|
+
screen,
|
|
16
|
+
fireEvent,
|
|
17
|
+
waitFor,
|
|
18
|
+
within,
|
|
19
|
+
} from '@testing-library/react';
|
|
14
20
|
import userEvent from '@testing-library/user-event';
|
|
15
21
|
import {AlertDialog} from './AlertDialog';
|
|
16
22
|
import {useImperativeAlertDialog} from './useImperativeAlertDialog';
|
|
17
23
|
|
|
24
|
+
function stubAlertDialogMedia({
|
|
25
|
+
smallScreen,
|
|
26
|
+
coarsePointer,
|
|
27
|
+
noHover,
|
|
28
|
+
reduceMotion = false,
|
|
29
|
+
}: {
|
|
30
|
+
smallScreen: boolean;
|
|
31
|
+
coarsePointer: boolean;
|
|
32
|
+
noHover: boolean;
|
|
33
|
+
reduceMotion?: boolean;
|
|
34
|
+
}): void {
|
|
35
|
+
vi.stubGlobal(
|
|
36
|
+
'matchMedia',
|
|
37
|
+
vi.fn((query: string) => {
|
|
38
|
+
let matches = false;
|
|
39
|
+
if (query.includes('max-width: 640px')) {
|
|
40
|
+
matches = smallScreen;
|
|
41
|
+
} else if (query.includes('pointer: coarse')) {
|
|
42
|
+
matches = coarsePointer;
|
|
43
|
+
} else if (query.includes('pointer: fine')) {
|
|
44
|
+
matches = !coarsePointer;
|
|
45
|
+
} else if (query.includes('hover: none')) {
|
|
46
|
+
matches = noHover;
|
|
47
|
+
} else if (query.includes('hover: hover')) {
|
|
48
|
+
matches = !noHover;
|
|
49
|
+
} else if (query.includes('prefers-reduced-motion: reduce')) {
|
|
50
|
+
matches = reduceMotion;
|
|
51
|
+
} else if (query.includes('prefers-reduced-motion: no-preference')) {
|
|
52
|
+
matches = !reduceMotion;
|
|
53
|
+
} else if (query.includes('prefers-reduced-motion')) {
|
|
54
|
+
matches = reduceMotion;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
matches,
|
|
59
|
+
media: query,
|
|
60
|
+
onchange: null,
|
|
61
|
+
addEventListener: vi.fn(),
|
|
62
|
+
removeEventListener: vi.fn(),
|
|
63
|
+
addListener: vi.fn(),
|
|
64
|
+
removeListener: vi.fn(),
|
|
65
|
+
dispatchEvent: vi.fn(),
|
|
66
|
+
};
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
18
71
|
beforeEach(() => {
|
|
19
72
|
HTMLDialogElement.prototype.showModal = vi.fn(function (
|
|
20
73
|
this: HTMLDialogElement,
|
|
@@ -26,6 +79,10 @@ beforeEach(() => {
|
|
|
26
79
|
});
|
|
27
80
|
});
|
|
28
81
|
|
|
82
|
+
afterEach(() => {
|
|
83
|
+
vi.unstubAllGlobals();
|
|
84
|
+
});
|
|
85
|
+
|
|
29
86
|
describe('AlertDialog', () => {
|
|
30
87
|
const defaultProps = {
|
|
31
88
|
isOpen: true,
|
|
@@ -109,6 +166,158 @@ describe('AlertDialog', () => {
|
|
|
109
166
|
expect(screen.getByRole('alertdialog')).toBeInTheDocument();
|
|
110
167
|
});
|
|
111
168
|
|
|
169
|
+
describe('responsive actions', () => {
|
|
170
|
+
const longCancelLabel = 'Keep this workspace and all of its dashboards';
|
|
171
|
+
const longActionLabel =
|
|
172
|
+
'Permanently delete this workspace and all dashboards';
|
|
173
|
+
|
|
174
|
+
it('preserves the preferred width and horizontal order above the small breakpoint', () => {
|
|
175
|
+
stubAlertDialogMedia({
|
|
176
|
+
smallScreen: false,
|
|
177
|
+
coarsePointer: false,
|
|
178
|
+
noHover: false,
|
|
179
|
+
});
|
|
180
|
+
render(<AlertDialog {...defaultProps} />);
|
|
181
|
+
|
|
182
|
+
expect(window.matchMedia).toHaveBeenCalledWith('(max-width: 640px)');
|
|
183
|
+
expect(window.matchMedia).not.toHaveBeenCalledWith('(pointer: coarse)');
|
|
184
|
+
expect(window.matchMedia).not.toHaveBeenCalledWith('(hover: none)');
|
|
185
|
+
const dialog = screen.getByRole('alertdialog');
|
|
186
|
+
const buttons = within(dialog).getAllByRole('button');
|
|
187
|
+
const footerStack = buttons[0].parentElement!;
|
|
188
|
+
expect(dialog.getAttribute('style')).toContain('--x-width: 400px');
|
|
189
|
+
expect(dialog.getAttribute('style')).toContain(
|
|
190
|
+
'--x-maxWidth: min(100%, calc(100dvw - var(--spacing-4) - var(--spacing-4)))',
|
|
191
|
+
);
|
|
192
|
+
expect(buttons.map(button => button.textContent)).toEqual([
|
|
193
|
+
'Cancel',
|
|
194
|
+
'Delete',
|
|
195
|
+
]);
|
|
196
|
+
expect(getComputedStyle(footerStack).flexWrap).toBe('wrap');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('keeps long action buttons within the wide horizontal footer without changing Button sizing', () => {
|
|
200
|
+
stubAlertDialogMedia({
|
|
201
|
+
smallScreen: false,
|
|
202
|
+
coarsePointer: false,
|
|
203
|
+
noHover: false,
|
|
204
|
+
});
|
|
205
|
+
render(
|
|
206
|
+
<AlertDialog
|
|
207
|
+
{...defaultProps}
|
|
208
|
+
cancelLabel={longCancelLabel}
|
|
209
|
+
actionLabel={longActionLabel}
|
|
210
|
+
/>,
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
const dialog = screen.getByRole('alertdialog');
|
|
214
|
+
const buttons = within(dialog).getAllByRole('button');
|
|
215
|
+
const footerStack = buttons[0].parentElement!;
|
|
216
|
+
expect(buttons.map(button => button.textContent)).toEqual([
|
|
217
|
+
longCancelLabel,
|
|
218
|
+
longActionLabel,
|
|
219
|
+
]);
|
|
220
|
+
expect(getComputedStyle(footerStack).flexWrap).toBe('wrap');
|
|
221
|
+
for (const button of buttons) {
|
|
222
|
+
const computed = getComputedStyle(button);
|
|
223
|
+
expect(computed.whiteSpace).toBe('nowrap');
|
|
224
|
+
expect(computed.height).toBe('var(--size-element-md)');
|
|
225
|
+
expect(computed.maxWidth).toBe('100%');
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('stacks long destructive action labels above cancel on a narrow fine-pointer screen', () => {
|
|
230
|
+
stubAlertDialogMedia({
|
|
231
|
+
smallScreen: true,
|
|
232
|
+
coarsePointer: false,
|
|
233
|
+
noHover: false,
|
|
234
|
+
});
|
|
235
|
+
render(
|
|
236
|
+
<AlertDialog
|
|
237
|
+
{...defaultProps}
|
|
238
|
+
cancelLabel={longCancelLabel}
|
|
239
|
+
actionLabel={longActionLabel}
|
|
240
|
+
/>,
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
expect(window.matchMedia).toHaveBeenCalledWith('(max-width: 640px)');
|
|
244
|
+
expect(window.matchMedia).not.toHaveBeenCalledWith('(pointer: coarse)');
|
|
245
|
+
expect(window.matchMedia).not.toHaveBeenCalledWith('(hover: none)');
|
|
246
|
+
const dialog = screen.getByRole('alertdialog');
|
|
247
|
+
const buttons = within(dialog).getAllByRole('button');
|
|
248
|
+
const footerStack = buttons[0].parentElement!;
|
|
249
|
+
expect(dialog.getAttribute('style')).toContain('--x-width: 400px');
|
|
250
|
+
expect(buttons.map(button => button.textContent)).toEqual([
|
|
251
|
+
longActionLabel,
|
|
252
|
+
longCancelLabel,
|
|
253
|
+
]);
|
|
254
|
+
expect(getComputedStyle(footerStack).flexDirection).toBe('column');
|
|
255
|
+
for (const button of buttons) {
|
|
256
|
+
const computed = getComputedStyle(button);
|
|
257
|
+
expect(computed.whiteSpace).toBe('nowrap');
|
|
258
|
+
expect(computed.width).toBe('100%');
|
|
259
|
+
expect(computed.maxWidth).toBe('100%');
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('uses the same stacked layout on a mobile touch screen', () => {
|
|
264
|
+
stubAlertDialogMedia({
|
|
265
|
+
smallScreen: true,
|
|
266
|
+
coarsePointer: true,
|
|
267
|
+
noHover: true,
|
|
268
|
+
});
|
|
269
|
+
render(
|
|
270
|
+
<AlertDialog
|
|
271
|
+
{...defaultProps}
|
|
272
|
+
cancelLabel="Keep this workspace"
|
|
273
|
+
actionLabel="Permanently delete workspace"
|
|
274
|
+
/>,
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
expect(window.matchMedia).toHaveBeenCalledWith('(max-width: 640px)');
|
|
278
|
+
expect(window.matchMedia).not.toHaveBeenCalledWith('(pointer: coarse)');
|
|
279
|
+
expect(window.matchMedia).not.toHaveBeenCalledWith('(hover: none)');
|
|
280
|
+
const dialog = screen.getByRole('alertdialog');
|
|
281
|
+
const buttons = within(dialog).getAllByRole('button');
|
|
282
|
+
expect(dialog.getAttribute('style')).toContain('--x-width: 400px');
|
|
283
|
+
expect(buttons.map(button => button.textContent)).toEqual([
|
|
284
|
+
'Permanently delete workspace',
|
|
285
|
+
'Keep this workspace',
|
|
286
|
+
]);
|
|
287
|
+
expect(getComputedStyle(buttons[0]).whiteSpace).toBe('nowrap');
|
|
288
|
+
expect(getComputedStyle(buttons[0]).width).toBe('100%');
|
|
289
|
+
expect(getComputedStyle(buttons[1]).whiteSpace).toBe('nowrap');
|
|
290
|
+
expect(getComputedStyle(buttons[1]).width).toBe('100%');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('keeps narrow visual, DOM, and tab order aligned while autofocus stays on cancel', async () => {
|
|
294
|
+
const user = userEvent.setup();
|
|
295
|
+
stubAlertDialogMedia({
|
|
296
|
+
smallScreen: true,
|
|
297
|
+
coarsePointer: false,
|
|
298
|
+
noHover: false,
|
|
299
|
+
});
|
|
300
|
+
render(
|
|
301
|
+
<AlertDialog
|
|
302
|
+
{...defaultProps}
|
|
303
|
+
cancelLabel={longCancelLabel}
|
|
304
|
+
actionLabel={longActionLabel}
|
|
305
|
+
/>,
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
const action = screen.getByRole('button', {name: longActionLabel});
|
|
309
|
+
const cancel = screen.getByRole('button', {name: longCancelLabel});
|
|
310
|
+
expect(
|
|
311
|
+
action.compareDocumentPosition(cancel) &
|
|
312
|
+
Node.DOCUMENT_POSITION_FOLLOWING,
|
|
313
|
+
).toBeTruthy();
|
|
314
|
+
expect(cancel).toHaveAttribute('data-autofocus');
|
|
315
|
+
action.focus();
|
|
316
|
+
await user.tab();
|
|
317
|
+
expect(cancel).toHaveFocus();
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
112
321
|
it('defaults cancel label to Cancel', () => {
|
|
113
322
|
render(<AlertDialog {...defaultProps} />);
|
|
114
323
|
expect(screen.getByText('Cancel')).toBeInTheDocument();
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* @file AlertDialog.tsx
|
|
7
|
-
* @input Uses React, Dialog, Layout, Heading, Text, Button
|
|
7
|
+
* @input Uses React, StyleX, Dialog, Layout, Heading, Text, Button
|
|
8
8
|
* @output Exports AlertDialog component, AlertDialogProps type
|
|
9
9
|
* @position Core implementation; consumed by index.ts, tested by AlertDialog.test.tsx
|
|
10
10
|
*
|
|
@@ -17,11 +17,12 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import React, {useId, useCallback} from 'react';
|
|
20
|
+
import * as stylex from '@stylexjs/stylex';
|
|
20
21
|
import {Dialog} from '../Dialog';
|
|
21
22
|
import {Layout} from '../Layout/Layout';
|
|
22
23
|
import {LayoutContent} from '../Layout/LayoutContent';
|
|
23
24
|
import {LayoutFooter} from '../Layout/LayoutFooter';
|
|
24
|
-
import {
|
|
25
|
+
import {Stack} from '../Stack';
|
|
25
26
|
import {Heading} from '../Heading/Heading';
|
|
26
27
|
import {Text} from '../Text/Text';
|
|
27
28
|
import {Button, type ButtonVariant} from '../Button';
|
|
@@ -29,6 +30,19 @@ import type {BaseProps} from '../BaseProps';
|
|
|
29
30
|
import {mergeProps} from '../utils';
|
|
30
31
|
import {themeProps} from '../utils/themeProps';
|
|
31
32
|
import {useTranslator} from '../i18n';
|
|
33
|
+
import {useMediaQuery} from '../hooks';
|
|
34
|
+
|
|
35
|
+
const SMALL_SCREEN_QUERY = '(max-width: 640px)';
|
|
36
|
+
|
|
37
|
+
const styles = stylex.create({
|
|
38
|
+
action: {
|
|
39
|
+
maxWidth: '100%',
|
|
40
|
+
minWidth: 0,
|
|
41
|
+
},
|
|
42
|
+
stackedAction: {
|
|
43
|
+
width: '100%',
|
|
44
|
+
},
|
|
45
|
+
});
|
|
32
46
|
|
|
33
47
|
export interface AlertDialogProps extends BaseProps<HTMLDialogElement> {
|
|
34
48
|
ref?: React.Ref<HTMLDialogElement>;
|
|
@@ -147,11 +161,44 @@ export function AlertDialog({
|
|
|
147
161
|
const cancelLabel = cancelLabelFromProps ?? t('@astryx.alertDialog.cancel');
|
|
148
162
|
const titleId = useId();
|
|
149
163
|
const descriptionId = useId();
|
|
164
|
+
// Width stays delegated to Dialog: this query exists only so AlertDialog can
|
|
165
|
+
// keep narrow visual, DOM, and tab order aligned for its destructive/cancel
|
|
166
|
+
// semantics without coupling action order to pointer or hover capability.
|
|
167
|
+
const isSmallScreen = useMediaQuery(SMALL_SCREEN_QUERY);
|
|
150
168
|
|
|
151
169
|
const handleCancel = useCallback(() => {
|
|
152
170
|
onOpenChange(false);
|
|
153
171
|
}, [onOpenChange]);
|
|
154
172
|
|
|
173
|
+
const buttonActionStyle = [
|
|
174
|
+
styles.action,
|
|
175
|
+
isSmallScreen && styles.stackedAction,
|
|
176
|
+
];
|
|
177
|
+
const cancelButton = (
|
|
178
|
+
<Button
|
|
179
|
+
key="cancel"
|
|
180
|
+
variant="ghost"
|
|
181
|
+
label={cancelLabel}
|
|
182
|
+
onClick={handleCancel}
|
|
183
|
+
xstyle={buttonActionStyle}
|
|
184
|
+
// Dialog focuses [data-autofocus] itself after showModal(), because
|
|
185
|
+
// React's autoFocus runs during commit while the dialog is invisible.
|
|
186
|
+
// Cancel is least destructive, so it remains the autofocus target even
|
|
187
|
+
// when small-screen visual order places the destructive action above it.
|
|
188
|
+
data-autofocus
|
|
189
|
+
/>
|
|
190
|
+
);
|
|
191
|
+
const actionButton = (
|
|
192
|
+
<Button
|
|
193
|
+
key="action"
|
|
194
|
+
variant={actionVariant}
|
|
195
|
+
label={actionLabel}
|
|
196
|
+
onClick={onAction}
|
|
197
|
+
isLoading={isActionLoading}
|
|
198
|
+
xstyle={buttonActionStyle}
|
|
199
|
+
/>
|
|
200
|
+
);
|
|
201
|
+
|
|
155
202
|
return (
|
|
156
203
|
<Dialog
|
|
157
204
|
{...rest}
|
|
@@ -185,24 +232,19 @@ export function AlertDialog({
|
|
|
185
232
|
}
|
|
186
233
|
footer={
|
|
187
234
|
<LayoutFooter>
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
label={actionLabel}
|
|
202
|
-
onClick={onAction}
|
|
203
|
-
isLoading={isActionLoading}
|
|
204
|
-
/>
|
|
205
|
-
</HStack>
|
|
235
|
+
{/* Generic Dialog footers should wrap, but their consumers own
|
|
236
|
+
action semantics and order. AlertDialog keeps this
|
|
237
|
+
destructive-above-Cancel narrow order because confirmation
|
|
238
|
+
actions have component-specific meaning here. */}
|
|
239
|
+
<Stack
|
|
240
|
+
direction={isSmallScreen ? 'vertical' : 'horizontal'}
|
|
241
|
+
gap={2}
|
|
242
|
+
hAlign={isSmallScreen ? 'stretch' : 'end'}
|
|
243
|
+
wrap={isSmallScreen ? 'nowrap' : 'wrap'}>
|
|
244
|
+
{isSmallScreen
|
|
245
|
+
? [actionButton, cancelButton]
|
|
246
|
+
: [cancelButton, actionButton]}
|
|
247
|
+
</Stack>
|
|
206
248
|
</LayoutFooter>
|
|
207
249
|
}
|
|
208
250
|
/>
|
package/src/Card/Card.doc.mjs
CHANGED
|
@@ -57,8 +57,9 @@ export const docs = {
|
|
|
57
57
|
{
|
|
58
58
|
name: 'padding',
|
|
59
59
|
type: '0 | 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 | 6 | 8 | 10',
|
|
60
|
-
description:
|
|
61
|
-
|
|
60
|
+
description:
|
|
61
|
+
"Internal padding using the spacing scale. Omit it and the card takes the theme's card padding rather than a step, so passing a step is a decision to override the theme, not a way to restate the default.",
|
|
62
|
+
default: "the theme's card padding (spacing step 4 with no theme)",
|
|
62
63
|
},
|
|
63
64
|
{
|
|
64
65
|
name: 'variant',
|
|
@@ -77,7 +78,6 @@ export const docs = {
|
|
|
77
78
|
],
|
|
78
79
|
playground: {
|
|
79
80
|
defaults: {
|
|
80
|
-
padding: 4,
|
|
81
81
|
children: {
|
|
82
82
|
__element: 'VStack',
|
|
83
83
|
props: {gap: 2},
|
|
@@ -91,7 +91,7 @@ export const docs = {
|
|
|
91
91
|
theming: {
|
|
92
92
|
container: true,
|
|
93
93
|
targets: [
|
|
94
|
-
{className: 'astryx-card', visualProps: ['variant']},
|
|
94
|
+
{className: 'astryx-card', visualProps: ['variant', 'elevation']},
|
|
95
95
|
],
|
|
96
96
|
vars: [
|
|
97
97
|
{name: '--_card-radius', description: 'Border radius of the card', default: 'var(--radius-container)', private: true},
|
|
@@ -135,16 +135,19 @@ export const docsZh = {
|
|
|
135
135
|
{name: 'maxWidth', type: 'SizeValue', description: '卡片最大宽度。'},
|
|
136
136
|
{name: 'minHeight', type: 'SizeValue', description: '卡片最小高度。'},
|
|
137
137
|
{name: 'children', type: 'ReactNode', description: '在卡片内部渲染的内容。'},
|
|
138
|
-
{name: 'padding', type: '0 | 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 | 6 | 8 | 10', description: '
|
|
138
|
+
{name: 'padding', type: '0 | 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 | 6 | 8 | 10', description: '使用间距比例的内边距。省略时,卡片采用主题的卡片内边距,而不是某个步进值;传入步进值意味着覆盖主题,而不是复述默认值。', default: "the theme's card padding (spacing step 4 with no theme)"},
|
|
139
|
+
{name: 'variant', type: "'default' | 'transparent' | 'muted' | 'blue' | 'cyan' | 'gray' | 'green' | 'orange' | 'pink' | 'purple' | 'red' | 'teal' | 'yellow'", description: '背景颜色变体。`default` 使用标准卡片背景;`transparent` 完全去掉背景;`muted` 使用弱化卡片的柔和背景。非语义变体使用对应的 `--color-background-<name>` 令牌。', default: "'default'"},
|
|
139
140
|
{name: 'elevation', type: "'none' | 'low' | 'med' | 'high'", description: '静止阴影深度。`none` 为扁平;`low`/`med`/`high` 对应阴影令牌比例。', default: "'none'"},
|
|
140
141
|
],
|
|
141
142
|
theming: {
|
|
142
143
|
container: true,
|
|
143
144
|
targets: [
|
|
144
|
-
{className: 'astryx-card', visualProps: ['variant']},
|
|
145
|
+
{className: 'astryx-card', visualProps: ['variant', 'elevation']},
|
|
145
146
|
],
|
|
146
147
|
vars: [
|
|
147
148
|
{name: '--_card-radius', description: 'Border radius of the card', default: 'var(--radius-container)', private: true},
|
|
149
|
+
{name: '--_card-elevation', description: 'Resting shadow of the card, set from the elevation prop.', default: '0 0 transparent', private: true},
|
|
150
|
+
{name: '--_card-ring', description: 'Inset ring drawn in the card box-shadow list.', default: '0 0 transparent', private: true},
|
|
148
151
|
],
|
|
149
152
|
derived: [
|
|
150
153
|
{property: 'borderRadius', vars: ['--_card-radius']},
|
|
@@ -178,7 +181,7 @@ export const docsDense = {
|
|
|
178
181
|
maxWidth: 'max card width',
|
|
179
182
|
minHeight: 'min card height',
|
|
180
183
|
children: 'content inside card',
|
|
181
|
-
padding:
|
|
184
|
+
padding: "internal padding via spacing scale; omitted = the theme's card padding, NOT a fixed step. Passing a step overrides the theme.",
|
|
182
185
|
variant: 'background color variant; `default` = standard card bg, `transparent` = no background at all, `muted` = muted bg for de-emphasised cards; non-semantic variants use the corresponding `--color-background-<name>` token',
|
|
183
186
|
elevation: 'resting shadow depth: none (flat) | low | med | high (shadow token scale). Raise only to float above content.',
|
|
184
187
|
},
|
package/src/Card/Card.test.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
2
|
|
|
3
|
+
import {createRef} from 'react';
|
|
3
4
|
import {describe, it, expect} from 'vitest';
|
|
4
5
|
import {render} from '@testing-library/react';
|
|
5
6
|
import {Card} from './Card';
|
|
@@ -10,17 +11,52 @@ describe('Card', () => {
|
|
|
10
11
|
expect(getByText('Hello')).toBeInTheDocument();
|
|
11
12
|
});
|
|
12
13
|
|
|
13
|
-
it('
|
|
14
|
-
const
|
|
15
|
-
const
|
|
14
|
+
it('forwards ref to the rendered element', () => {
|
|
15
|
+
const ref = createRef<HTMLDivElement>();
|
|
16
|
+
const {container} = render(<Card ref={ref}>C</Card>);
|
|
17
|
+
expect(ref.current).toBe(container.firstElementChild);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('spreads unknown props onto the same element that carries the theme target', () => {
|
|
21
|
+
const {getByTestId} = render(
|
|
22
|
+
<Card data-testid="card" id="promo" aria-label="Promotion">
|
|
23
|
+
C
|
|
24
|
+
</Card>,
|
|
25
|
+
);
|
|
26
|
+
const root = getByTestId('card');
|
|
27
|
+
expect(root).toHaveAttribute('id', 'promo');
|
|
28
|
+
expect(root).toHaveAttribute('aria-label', 'Promotion');
|
|
16
29
|
expect(root.className).toContain('astryx-card');
|
|
17
30
|
});
|
|
18
31
|
|
|
19
|
-
it('
|
|
20
|
-
const {container} = render(<Card
|
|
32
|
+
it('merges a consumer className instead of replacing the theme target', () => {
|
|
33
|
+
const {container} = render(<Card className="promo">C</Card>);
|
|
21
34
|
const root = container.firstElementChild!;
|
|
22
35
|
expect(root.className).toContain('astryx-card');
|
|
23
|
-
expect(root.className).toContain('
|
|
36
|
+
expect(root.className).toContain('promo');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('reflects variant as a theme data attribute', () => {
|
|
40
|
+
const {container} = render(<Card variant="muted">C</Card>);
|
|
41
|
+
expect(container.firstElementChild).toHaveAttribute(
|
|
42
|
+
'data-variant',
|
|
43
|
+
'muted',
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('reflects elevation as a theme data attribute', () => {
|
|
48
|
+
const {container} = render(<Card elevation="high">C</Card>);
|
|
49
|
+
expect(container.firstElementChild).toHaveAttribute(
|
|
50
|
+
'data-elevation',
|
|
51
|
+
'high',
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('defaults to the default variant at rest elevation', () => {
|
|
56
|
+
const {container} = render(<Card>C</Card>);
|
|
57
|
+
const root = container.firstElementChild!;
|
|
58
|
+
expect(root).toHaveAttribute('data-variant', 'default');
|
|
59
|
+
expect(root).toHaveAttribute('data-elevation', 'none');
|
|
24
60
|
});
|
|
25
61
|
|
|
26
62
|
it('applies a distinct class for each elevation level', () => {
|
|
@@ -28,18 +64,7 @@ describe('Card', () => {
|
|
|
28
64
|
const {container} = render(<Card elevation={elevation}>C</Card>);
|
|
29
65
|
return container.firstElementChild!.className;
|
|
30
66
|
};
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
const med = classFor('med');
|
|
34
|
-
const high = classFor('high');
|
|
35
|
-
expect(new Set([none, low, med, high]).size).toBe(4);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it('defaults to flat (elevation none) — same class as explicit none', () => {
|
|
39
|
-
const {container: defaultContainer} = render(<Card>C</Card>);
|
|
40
|
-
const {container: noneContainer} = render(<Card elevation="none">C</Card>);
|
|
41
|
-
expect(defaultContainer.firstElementChild!.className).toBe(
|
|
42
|
-
noneContainer.firstElementChild!.className,
|
|
43
|
-
);
|
|
67
|
+
const classes = (['none', 'low', 'med', 'high'] as const).map(classFor);
|
|
68
|
+
expect(new Set(classes).size).toBe(4);
|
|
44
69
|
});
|
|
45
70
|
});
|
package/src/Card/Card.tsx
CHANGED
|
@@ -23,13 +23,7 @@ import {
|
|
|
23
23
|
} from '../theme/tokens.stylex';
|
|
24
24
|
import {container} from '../Layout/container.stylex';
|
|
25
25
|
import type {SpacingToken} from '../Layout/container.stylex';
|
|
26
|
-
import {
|
|
27
|
-
paddingStyles,
|
|
28
|
-
containerPaddingInlineVarStyles,
|
|
29
|
-
containerPaddingBlockStartVarStyles,
|
|
30
|
-
containerPaddingBlockEndVarStyles,
|
|
31
|
-
spacingStepToToken,
|
|
32
|
-
} from '../Layout/padding.stylex';
|
|
26
|
+
import {spacingStepToToken} from '../Layout/padding.stylex';
|
|
33
27
|
import type {Elevation, SizeValue, SpacingStep} from '../utils/types';
|
|
34
28
|
import {mergeProps} from '../utils';
|
|
35
29
|
import type {BaseProps} from '../BaseProps';
|
|
@@ -176,14 +170,6 @@ export type {SizeValue} from '../utils/types';
|
|
|
176
170
|
|
|
177
171
|
export interface CardProps extends BaseProps<HTMLDivElement> {
|
|
178
172
|
ref?: React.Ref<HTMLDivElement>;
|
|
179
|
-
/**
|
|
180
|
-
* CSS class name(s) appended to the root element.
|
|
181
|
-
*/
|
|
182
|
-
className?: string;
|
|
183
|
-
/**
|
|
184
|
-
* Inline styles to apply to the root element.
|
|
185
|
-
*/
|
|
186
|
-
style?: React.CSSProperties;
|
|
187
173
|
/**
|
|
188
174
|
* Width of the card.
|
|
189
175
|
* Numbers are treated as pixels, strings are used as-is.
|
|
@@ -217,7 +203,12 @@ export interface CardProps extends BaseProps<HTMLDivElement> {
|
|
|
217
203
|
/**
|
|
218
204
|
* Internal padding of the card using the spacing scale.
|
|
219
205
|
* Accepts numeric spacing steps: 0, 0.5, 1, 1.5, 2, 3, 4, 5, 6, 8, 10.
|
|
220
|
-
*
|
|
206
|
+
*
|
|
207
|
+
* Omit it and the card takes the theme's card padding rather than a step, so
|
|
208
|
+
* passing a step is a decision to override the theme, not a way to restate
|
|
209
|
+
* the default.
|
|
210
|
+
*
|
|
211
|
+
* @default the theme's card padding (spacing step 4 with no theme)
|
|
221
212
|
*/
|
|
222
213
|
padding?: SpacingStep;
|
|
223
214
|
|
|
@@ -293,14 +284,15 @@ export function Card({
|
|
|
293
284
|
|
|
294
285
|
// When no explicit padding prop, use theme default (set via container tokens)
|
|
295
286
|
const useThemeDefault = padding == null;
|
|
296
|
-
const
|
|
297
|
-
|
|
287
|
+
const paddingToken = useThemeDefault
|
|
288
|
+
? undefined
|
|
289
|
+
: (spacingStepToToken[padding] as SpacingToken);
|
|
298
290
|
|
|
299
291
|
return (
|
|
300
292
|
<div
|
|
301
293
|
ref={ref}
|
|
302
294
|
{...mergeProps(
|
|
303
|
-
themeProps('card', {variant}),
|
|
295
|
+
themeProps('card', {variant, elevation}),
|
|
304
296
|
stylex.props(
|
|
305
297
|
styles.card,
|
|
306
298
|
variantStyles[variant],
|
|
@@ -313,7 +305,7 @@ export function Card({
|
|
|
313
305
|
minHeight ?? null,
|
|
314
306
|
),
|
|
315
307
|
...container(
|
|
316
|
-
|
|
308
|
+
paddingToken == null
|
|
317
309
|
? {useThemeDefault: 'card'}
|
|
318
310
|
: {
|
|
319
311
|
paddingInnerX: paddingToken,
|
|
@@ -322,18 +314,6 @@ export function Card({
|
|
|
322
314
|
paddingOuterY: paddingToken,
|
|
323
315
|
},
|
|
324
316
|
),
|
|
325
|
-
!useThemeDefault &&
|
|
326
|
-
effectivePadding !== 4 &&
|
|
327
|
-
paddingStyles[effectivePadding],
|
|
328
|
-
!useThemeDefault &&
|
|
329
|
-
effectivePadding !== 4 &&
|
|
330
|
-
containerPaddingInlineVarStyles[effectivePadding],
|
|
331
|
-
!useThemeDefault &&
|
|
332
|
-
effectivePadding !== 4 &&
|
|
333
|
-
containerPaddingBlockStartVarStyles[effectivePadding],
|
|
334
|
-
!useThemeDefault &&
|
|
335
|
-
effectivePadding !== 4 &&
|
|
336
|
-
containerPaddingBlockEndVarStyles[effectivePadding],
|
|
337
317
|
// Applied after the container padding so the border-inset calc wins;
|
|
338
318
|
// it reads the --container-padding-* vars set above.
|
|
339
319
|
variant === 'default' && styles.withBorder,
|