@astryxdesign/core 0.4.7-canary.d895769 → 0.4.7-canary.dd94f7f
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/AspectRatio/AspectRatio.d.ts +24 -0
- package/dist/AspectRatio/AspectRatio.d.ts.map +1 -1
- package/dist/AspectRatio/AspectRatio.js +23 -4
- package/dist/ButtonGroup/ButtonGroup.d.ts.map +1 -1
- package/dist/ButtonGroup/ButtonGroup.js +9 -1
- package/dist/Table/plugins/pagination/useTablePagination.d.ts +3 -0
- package/dist/Table/plugins/pagination/useTablePagination.d.ts.map +1 -1
- package/dist/Table/plugins/pagination/useTablePagination.js +21 -4
- package/dist/astryx.css +2 -0
- package/locales/en.json +9 -1
- package/locales/pseudo.json +6 -0
- package/package.json +2 -2
- package/src/AspectRatio/AspectRatio.doc.mjs +6 -3
- package/src/AspectRatio/AspectRatio.test.tsx +57 -10
- package/src/AspectRatio/AspectRatio.tsx +40 -1
- package/src/ButtonGroup/ButtonGroup.doc.mjs +7 -1
- package/src/ButtonGroup/ButtonGroup.test.tsx +185 -1
- package/src/ButtonGroup/ButtonGroup.tsx +8 -1
- package/src/Table/plugins/pagination/useTablePagination.test.tsx +34 -4
- package/src/Table/plugins/pagination/useTablePagination.tsx +35 -3
- package/src/Table/useTablePagination.doc.mjs +7 -0
- package/src/__tests__/apiContractDrift.test.tsx +0 -80
- package/src/__tests__/fieldContract.test.tsx +0 -24
- package/src/__tests__/inputWidthContract.test.tsx +0 -69
- package/src/__tests__/structuralComponentContract.test.tsx +0 -39
|
@@ -34,6 +34,25 @@ export interface AspectRatioProps extends BaseProps<HTMLDivElement> {
|
|
|
34
34
|
ref?: React.Ref<HTMLDivElement>;
|
|
35
35
|
/**
|
|
36
36
|
* The aspect ratio as width/height (e.g., 16/9 = 1.777..., 4/3 = 1.333..., 1 for square).
|
|
37
|
+
*
|
|
38
|
+
* The ratio is emitted as a class-level declaration — never an inline
|
|
39
|
+
* style — so it can be made responsive:
|
|
40
|
+
* - StyleX consumers pass an `aspect-ratio` rule via `xstyle`, including
|
|
41
|
+
* under `@media`/`@container` conditions.
|
|
42
|
+
* - Plain-CSS/Tailwind consumers override `aspect-ratio` from their own
|
|
43
|
+
* (unlayered) rules: the compiled component styles live in the
|
|
44
|
+
* `astryx-base` cascade layer, and unlayered CSS wins over layered
|
|
45
|
+
* rules regardless of specificity.
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```
|
|
49
|
+
* // 3:1 hero that becomes 3:2 when its container stacks
|
|
50
|
+
* <AspectRatio ratio={3 / 1} className="gallery-hero">…</AspectRatio>
|
|
51
|
+
*
|
|
52
|
+
* // CSS: @container gallery (max-width: 720px) {
|
|
53
|
+
* // .gallery-hero { aspect-ratio: 3 / 2; }
|
|
54
|
+
* // }
|
|
55
|
+
* ```
|
|
37
56
|
*/
|
|
38
57
|
ratio: number;
|
|
39
58
|
/**
|
|
@@ -102,6 +121,11 @@ export interface AspectRatioProps extends BaseProps<HTMLDivElement> {
|
|
|
102
121
|
* `ratio={1}` or an oval at other ratios. Both shapes respect the provided
|
|
103
122
|
* `ratio`.
|
|
104
123
|
*
|
|
124
|
+
* The ratio is a class-level declaration (not an inline style), so it can be
|
|
125
|
+
* overridden responsively — via `xstyle` for StyleX consumers, or with a
|
|
126
|
+
* plain unlayered `aspect-ratio` rule for plain-CSS consumers (component
|
|
127
|
+
* styles live in the `astryx-base` cascade layer, which unlayered CSS beats).
|
|
128
|
+
*
|
|
105
129
|
* Sizing: the box takes its width from its container and derives its height
|
|
106
130
|
* from the ratio, so it needs an ancestor with a definite width. Two
|
|
107
131
|
* consequences are worth knowing. Constraining only the height (`height` or
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AspectRatio.d.ts","sourceRoot":"","sources":["../../src/AspectRatio/AspectRatio.tsx"],"names":[],"mappings":"AAEA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,OAAO,CAAC;AAErC,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAI5C;;;;;GAKG;AACH,MAAM,MAAM,gBAAgB,GAAG,WAAW,GAAG,SAAS,CAAC;AAEvD;;;;;;;GAOG;AACH,MAAM,MAAM,cAAc,GAAG,OAAO,GAAG,SAAS,GAAG,QAAQ,CAAC;AAE5D,MAAM,WAAW,gBAAiB,SAAQ,SAAS,CAAC,cAAc,CAAC;IACjE,wCAAwC;IACxC,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC;IAChC
|
|
1
|
+
{"version":3,"file":"AspectRatio.d.ts","sourceRoot":"","sources":["../../src/AspectRatio/AspectRatio.tsx"],"names":[],"mappings":"AAEA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,OAAO,CAAC;AAErC,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAI5C;;;;;GAKG;AACH,MAAM,MAAM,gBAAgB,GAAG,WAAW,GAAG,SAAS,CAAC;AAEvD;;;;;;;GAOG;AACH,MAAM,MAAM,cAAc,GAAG,OAAO,GAAG,SAAS,GAAG,QAAQ,CAAC;AAE5D,MAAM,WAAW,gBAAiB,SAAQ,SAAS,CAAC,cAAc,CAAC;IACjE,wCAAwC;IACxC,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC;IAChC;;;;;;;;;;;;;;;;;;;;;OAqBG;IACH,KAAK,EAAE,MAAM,CAAC;IAEd;;;;;;;;;;;;;;;OAeG;IACH,KAAK,CAAC,EAAE,gBAAgB,CAAC;IAEzB;;;;;;;;;;;;;;;;;;;;;;OAsBG;IACH,GAAG,CAAC,EAAE,cAAc,CAAC;IAErB;;;;;;;OAOG;IACH,QAAQ,EAAE,SAAS,CAAC;CACrB;AAgDD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AACH,wBAAgB,WAAW,CAAC,EAC1B,KAAK,EACL,KAAmB,EACnB,GAAG,EACH,QAAQ,EACR,MAAM,EACN,SAAS,EACT,KAAK,EACL,GAAG,EACH,GAAG,KAAK,EACT,EAAE,gBAAgB,+BA4BlB;yBAtCe,WAAW"}
|
|
@@ -49,6 +49,23 @@ const styles = {
|
|
|
49
49
|
}
|
|
50
50
|
};
|
|
51
51
|
|
|
52
|
+
// The ratio compiles to a CSS variable + a class-level declaration
|
|
53
|
+
// (aspect-ratio: var(--x)) instead of a raw inline style, so consumer
|
|
54
|
+
// overrides — `xstyle` rules, including ones inside @media/@container
|
|
55
|
+
// queries — can still win. A raw inline `aspect-ratio` would beat any class
|
|
56
|
+
// (same rationale as Grid's dynamic track values). Non-StyleX consumers
|
|
57
|
+
// don't need a dedicated hook: the compiled declaration sits in the
|
|
58
|
+
// `astryx-base` cascade layer, so any unlayered consumer rule wins
|
|
59
|
+
// regardless of specificity.
|
|
60
|
+
const dynamicStyles = {
|
|
61
|
+
ratio: ratio => [{
|
|
62
|
+
kOBAk4: ratio != null ? "x10y9f9r" : ratio,
|
|
63
|
+
$$css: true
|
|
64
|
+
}, {
|
|
65
|
+
"--x-aspectRatio": ratio != null ? ratio : undefined
|
|
66
|
+
}]
|
|
67
|
+
};
|
|
68
|
+
|
|
52
69
|
/**
|
|
53
70
|
* AspectRatio component for maintaining a specific aspect ratio for its children.
|
|
54
71
|
*
|
|
@@ -64,6 +81,11 @@ const styles = {
|
|
|
64
81
|
* `ratio={1}` or an oval at other ratios. Both shapes respect the provided
|
|
65
82
|
* `ratio`.
|
|
66
83
|
*
|
|
84
|
+
* The ratio is a class-level declaration (not an inline style), so it can be
|
|
85
|
+
* overridden responsively — via `xstyle` for StyleX consumers, or with a
|
|
86
|
+
* plain unlayered `aspect-ratio` rule for plain-CSS consumers (component
|
|
87
|
+
* styles live in the `astryx-base` cascade layer, which unlayered CSS beats).
|
|
88
|
+
*
|
|
67
89
|
* Sizing: the box takes its width from its container and derives its height
|
|
68
90
|
* from the ratio, so it needs an ancestor with a definite width. Two
|
|
69
91
|
* consequences are worth knowing. Constraining only the height (`height` or
|
|
@@ -102,10 +124,7 @@ export function AspectRatio({
|
|
|
102
124
|
ref: ref,
|
|
103
125
|
...mergeProps(themeProps('aspect-ratio', {
|
|
104
126
|
shape
|
|
105
|
-
}), stylex.props(styles.container, shape === 'ellipse' && styles.ellipse, xstyle), className,
|
|
106
|
-
...style,
|
|
107
|
-
aspectRatio: ratio
|
|
108
|
-
}),
|
|
127
|
+
}), stylex.props(styles.container, dynamicStyles.ratio(ratio), shape === 'ellipse' && styles.ellipse, xstyle), className, style),
|
|
109
128
|
...props,
|
|
110
129
|
children: /*#__PURE__*/_jsx("div", {
|
|
111
130
|
"data-astryx-aspect-ratio-override": fit,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ButtonGroup.d.ts","sourceRoot":"","sources":["../../src/ButtonGroup/ButtonGroup.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EAAU,KAAK,SAAS,EAAC,MAAM,OAAO,CAAC;AAE9C,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,WAAW,CAAC;AAE1C,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,gBAAgB,CAAC;AAI9C,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAE5C,OAAO,KAAK,EAAC,sBAAsB,EAAC,MAAM,sBAAsB,CAAC;AAOjE,MAAM,WAAW,gBAAiB,SAAQ,SAAS,CAAC,cAAc,CAAC;IACjE,yCAAyC;IACzC,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC;IAEhC;;OAEG;IACH,QAAQ,EAAE,SAAS,CAAC;IAEpB;;OAEG;IACH,KAAK,EAAE,MAAM,CAAC;IAEd;;;OAGG;IACH,WAAW,CAAC,EAAE,sBAAsB,CAAC;IAErC;;;;OAIG;IACH,IAAI,CAAC,EAAE,UAAU,CAAC;IAElB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,SAAS,CAAC;IAEtB;;;OAGG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IAErB;;OAEG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAwCD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,wBAAgB,WAAW,CAAC,EAC1B,QAAQ,EACR,KAAK,EACL,WAA0B,EAC1B,IAAI,EAAE,QAAQ,EACd,UAAkB,EAClB,SAAkB,EAClB,MAAM,EACN,SAAS,EACT,KAAK,EACL,GAAG,EACH,aAAa,EAAE,MAAM,EACrB,SAAS,EACT,GAAG,KAAK,EACT,EAAE,gBAAgB,GAAG,SAAS,
|
|
1
|
+
{"version":3,"file":"ButtonGroup.d.ts","sourceRoot":"","sources":["../../src/ButtonGroup/ButtonGroup.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EAAU,KAAK,SAAS,EAAC,MAAM,OAAO,CAAC;AAE9C,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,WAAW,CAAC;AAE1C,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,gBAAgB,CAAC;AAI9C,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAE5C,OAAO,KAAK,EAAC,sBAAsB,EAAC,MAAM,sBAAsB,CAAC;AAOjE,MAAM,WAAW,gBAAiB,SAAQ,SAAS,CAAC,cAAc,CAAC;IACjE,yCAAyC;IACzC,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC;IAEhC;;OAEG;IACH,QAAQ,EAAE,SAAS,CAAC;IAEpB;;OAEG;IACH,KAAK,EAAE,MAAM,CAAC;IAEd;;;OAGG;IACH,WAAW,CAAC,EAAE,sBAAsB,CAAC;IAErC;;;;OAIG;IACH,IAAI,CAAC,EAAE,UAAU,CAAC;IAElB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,SAAS,CAAC;IAEtB;;;OAGG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IAErB;;OAEG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAwCD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,wBAAgB,WAAW,CAAC,EAC1B,QAAQ,EACR,KAAK,EACL,WAA0B,EAC1B,IAAI,EAAE,QAAQ,EACd,UAAkB,EAClB,SAAkB,EAClB,MAAM,EACN,SAAS,EACT,KAAK,EACL,GAAG,EACH,aAAa,EAAE,MAAM,EACrB,SAAS,EACT,GAAG,KAAK,EACT,EAAE,gBAAgB,GAAG,SAAS,CA+C9B;yBA7De,WAAW"}
|
|
@@ -131,6 +131,13 @@ export function ButtonGroup({
|
|
|
131
131
|
handleKeyDown
|
|
132
132
|
} = useListFocus({
|
|
133
133
|
itemSelector: 'button, [tabindex="0"]',
|
|
134
|
+
// A member's layer renders inline inside the group (useLayer does not
|
|
135
|
+
// portal it), so a key pressed in an open DropdownMenu bubbles here. The
|
|
136
|
+
// boundary is the group's own root or any layer, whichever is nearer to
|
|
137
|
+
// the target. Naming the group's own root is what keeps a group rendered
|
|
138
|
+
// inside a Popover or Toast working: bailing on any [popover] ancestor
|
|
139
|
+
// instead would swallow every arrow key it owns.
|
|
140
|
+
boundarySelector: '[role="group"], [popover]',
|
|
134
141
|
orientation
|
|
135
142
|
});
|
|
136
143
|
const contextValue = useMemo(() => ({
|
|
@@ -146,7 +153,8 @@ export function ButtonGroup({
|
|
|
146
153
|
...props,
|
|
147
154
|
...mergeProps(themeProps('button-group', {
|
|
148
155
|
size,
|
|
149
|
-
orientation
|
|
156
|
+
orientation,
|
|
157
|
+
elevation
|
|
150
158
|
}), stylex.props(styles.group, orientation === 'vertical' && styles.vertical, elevationStyles[elevation], xstyle), className, style),
|
|
151
159
|
role: "group",
|
|
152
160
|
"aria-label": label,
|
|
@@ -88,6 +88,9 @@ export interface UseTablePaginationConfig {
|
|
|
88
88
|
align?: 'start' | 'center' | 'end';
|
|
89
89
|
/**
|
|
90
90
|
* Accessible label for the pagination nav landmark.
|
|
91
|
+
* When `position` is 'both', the two navs get distinct names by suffixing
|
|
92
|
+
* this label — "{label} (top)" above and "{label} (bottom)" below — so
|
|
93
|
+
* same-type landmarks stay distinguishable to assistive technology.
|
|
91
94
|
* @default 'Table pagination'
|
|
92
95
|
*/
|
|
93
96
|
label?: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useTablePagination.d.ts","sourceRoot":"","sources":["../../../../src/Table/plugins/pagination/useTablePagination.tsx"],"names":[],"mappings":"AAuBA,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,aAAa,CAAC;AAgC7C;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,WAAW,wBAAwB;IAGvC,qCAAqC;IACrC,IAAI,EAAE,MAAM,CAAC;IAEb,sEAAsE;IACtE,YAAY,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAIrC;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB;;OAEG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB;;;;OAIG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAIlB;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAElB;;;OAGG;IACH,gBAAgB,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IAE9C;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;IAI3B;;;;OAIG;IACH,OAAO,CAAC,EAAE,OAAO,GAAG,OAAO,GAAG,SAAS,GAAG,MAAM,GAAG,MAAM,CAAC;IAE1D;;;OAGG;IACH,IAAI,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;IAEnB;;;;;;;;OAQG;IACH,QAAQ,CAAC,EAAE,OAAO,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,CAAC;IAE/C;;;;;;;OAOG;IACH,KAAK,CAAC,EAAE,OAAO,GAAG,QAAQ,GAAG,KAAK,CAAC;IAInC
|
|
1
|
+
{"version":3,"file":"useTablePagination.d.ts","sourceRoot":"","sources":["../../../../src/Table/plugins/pagination/useTablePagination.tsx"],"names":[],"mappings":"AAuBA,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,aAAa,CAAC;AAgC7C;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,WAAW,wBAAwB;IAGvC,qCAAqC;IACrC,IAAI,EAAE,MAAM,CAAC;IAEb,sEAAsE;IACtE,YAAY,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAIrC;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB;;OAEG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB;;;;OAIG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAIlB;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAElB;;;OAGG;IACH,gBAAgB,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IAE9C;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;IAI3B;;;;OAIG;IACH,OAAO,CAAC,EAAE,OAAO,GAAG,OAAO,GAAG,SAAS,GAAG,MAAM,GAAG,MAAM,CAAC;IAE1D;;;OAGG;IACH,IAAI,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;IAEnB;;;;;;;;OAQG;IACH,QAAQ,CAAC,EAAE,OAAO,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,CAAC;IAE/C;;;;;;;OAOG;IACH,KAAK,CAAC,EAAE,OAAO,GAAG,QAAQ,GAAG,KAAK,CAAC;IAInC;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAMD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,kBAAkB,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAClE,MAAM,EAAE,wBAAwB,GAC/B,WAAW,CAAC,CAAC,CAAC,CAgIhB"}
|
|
@@ -116,6 +116,14 @@ export function useTablePagination(config) {
|
|
|
116
116
|
label: labelFromProps
|
|
117
117
|
} = config;
|
|
118
118
|
const label = labelFromProps ?? t('@astryx.table.pagination.label');
|
|
119
|
+
// position='both' renders two nav landmarks; suffix the base label so each
|
|
120
|
+
// gets a unique accessible name (axe landmark-unique).
|
|
121
|
+
const labelAbove = t('@astryx.table.pagination.labelAbove', {
|
|
122
|
+
label
|
|
123
|
+
});
|
|
124
|
+
const labelBelow = t('@astryx.table.pagination.labelBelow', {
|
|
125
|
+
label
|
|
126
|
+
});
|
|
119
127
|
|
|
120
128
|
// Same guard as Pagination itself: 0/NaN/negative pageSize would produce an
|
|
121
129
|
// Infinity/NaN totalPages here, which bypasses Pagination's own coercion
|
|
@@ -143,12 +151,16 @@ export function useTablePagination(config) {
|
|
|
143
151
|
const configRef = useRef({
|
|
144
152
|
paginationProps,
|
|
145
153
|
position,
|
|
146
|
-
align
|
|
154
|
+
align,
|
|
155
|
+
labelAbove,
|
|
156
|
+
labelBelow
|
|
147
157
|
});
|
|
148
158
|
configRef.current = {
|
|
149
159
|
paginationProps,
|
|
150
160
|
position,
|
|
151
|
-
align
|
|
161
|
+
align,
|
|
162
|
+
labelAbove,
|
|
163
|
+
labelBelow
|
|
152
164
|
};
|
|
153
165
|
|
|
154
166
|
// Stable plugin object \u2014 created once, reads config via ref.
|
|
@@ -157,7 +169,9 @@ export function useTablePagination(config) {
|
|
|
157
169
|
const {
|
|
158
170
|
position: pos,
|
|
159
171
|
paginationProps: props,
|
|
160
|
-
align: a
|
|
172
|
+
align: a,
|
|
173
|
+
labelAbove: above,
|
|
174
|
+
labelBelow: below
|
|
161
175
|
} = configRef.current;
|
|
162
176
|
if (pos === 'none') {
|
|
163
177
|
return children;
|
|
@@ -172,7 +186,10 @@ export function useTablePagination(config) {
|
|
|
172
186
|
const makeWrapper = side => /*#__PURE__*/_jsx("div", {
|
|
173
187
|
...stylex.props(styles.wrapper, side === 'below' && styles.marginTop, side === 'above' && styles.marginBottom, a === 'center' && styles.alignCenter, a === 'end' && styles.alignEnd, a === 'start' && styles.alignStart),
|
|
174
188
|
children: /*#__PURE__*/_jsx(Pagination, {
|
|
175
|
-
...props
|
|
189
|
+
...props,
|
|
190
|
+
// Two navs with the same name are indistinguishable landmarks;
|
|
191
|
+
// give each side a unique name when both render.
|
|
192
|
+
label: pos === 'both' ? side === 'above' ? above : below : props.label
|
|
176
193
|
})
|
|
177
194
|
});
|
|
178
195
|
return /*#__PURE__*/_jsxs(_Fragment, {
|
package/dist/astryx.css
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
@property --x-126w13d { syntax: "*"; inherits: false;}
|
|
12
12
|
@property --x-4drmnv { syntax: "*"; inherits: false;}
|
|
13
13
|
@property --x-animationDelay { syntax: "*"; inherits: false;}
|
|
14
|
+
@property --x-aspectRatio { syntax: "*"; inherits: false;}
|
|
14
15
|
@property --x-backgroundColor { syntax: "*"; inherits: false;}
|
|
15
16
|
@property --x-borderWidth { syntax: "*"; inherits: false;}
|
|
16
17
|
@property --x-bottom { syntax: "*"; inherits: false;}
|
|
@@ -616,6 +617,7 @@
|
|
|
616
617
|
.xjyslct:not(#\#):not(#\#):not(#\#){appearance:none}
|
|
617
618
|
.x1plog1:not(#\#):not(#\#):not(#\#){aspect-ratio:1}
|
|
618
619
|
.xioom0i:not(#\#):not(#\#):not(#\#){aspect-ratio:var(--button-icon-only-aspect)}
|
|
620
|
+
.x10y9f9r:not(#\#):not(#\#):not(#\#){aspect-ratio:var(--x-aspectRatio)}
|
|
619
621
|
.xvn2z4z:not(#\#):not(#\#):not(#\#){backdrop-filter:blur(12px)}
|
|
620
622
|
.xlp1x4z:not(#\#):not(#\#):not(#\#){backface-visibility:hidden}
|
|
621
623
|
.xx83zyx:not(#\#):not(#\#):not(#\#){background-clip:padding-box}
|
package/locales/en.json
CHANGED
|
@@ -813,7 +813,15 @@
|
|
|
813
813
|
},
|
|
814
814
|
"@astryx.table.pagination.label": {
|
|
815
815
|
"defaultMessage": "Table pagination",
|
|
816
|
-
"description": "Screen-reader-only accessible name for
|
|
816
|
+
"description": "Screen-reader-only accessible name for a table's pagination `<nav>` (rendered below and/or above the table per the plugin's position config). Kept separate from `@astryx.pagination.label` so translations may diverge. When position='both', this string is interpolated as the {label} value into `@astryx.table.pagination.labelAbove`/`labelBelow` — keep it compatible with those suffix templates."
|
|
817
|
+
},
|
|
818
|
+
"@astryx.table.pagination.labelAbove": {
|
|
819
|
+
"defaultMessage": "{label} (top)",
|
|
820
|
+
"description": "Accessible name for the pagination `<nav>` rendered above the table when position='both' renders two navs. `label` is the resolved base label (default \"Table pagination\" or consumer-supplied); the suffix keeps the two same-type landmarks distinguishable."
|
|
821
|
+
},
|
|
822
|
+
"@astryx.table.pagination.labelBelow": {
|
|
823
|
+
"defaultMessage": "{label} (bottom)",
|
|
824
|
+
"description": "Accessible name for the pagination `<nav>` rendered below the table when position='both' renders two navs. `label` is the resolved base label (default \"Table pagination\" or consumer-supplied); the suffix keeps the two same-type landmarks distinguishable."
|
|
817
825
|
},
|
|
818
826
|
"@astryx.timeInput.placeholder": {
|
|
819
827
|
"defaultMessage": "Select a time",
|
package/locales/pseudo.json
CHANGED
|
@@ -611,6 +611,12 @@
|
|
|
611
611
|
"@astryx.table.pagination.label": {
|
|
612
612
|
"defaultMessage": "⟦Ţàƀłé þàĝíñàţíóñ⟧"
|
|
613
613
|
},
|
|
614
|
+
"@astryx.table.pagination.labelAbove": {
|
|
615
|
+
"defaultMessage": "⟦{label} (ţóþ)⟧"
|
|
616
|
+
},
|
|
617
|
+
"@astryx.table.pagination.labelBelow": {
|
|
618
|
+
"defaultMessage": "⟦{label} (ƀóţţóɱ)⟧"
|
|
619
|
+
},
|
|
614
620
|
"@astryx.timeInput.placeholder": {
|
|
615
621
|
"defaultMessage": "⟦Šéłéçţ à ţíɱé⟧"
|
|
616
622
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astryxdesign/core",
|
|
3
|
-
"version": "0.4.7-canary.
|
|
3
|
+
"version": "0.4.7-canary.dd94f7f",
|
|
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.4.7-canary.
|
|
664
|
+
"@astryxdesign/cli": "0.4.7-canary.dd94f7f",
|
|
665
665
|
"@babel/cli": "^8.0.4",
|
|
666
666
|
"@babel/core": "^7.29.7",
|
|
667
667
|
"@babel/preset-react": "^8.0.1",
|
|
@@ -16,6 +16,7 @@ export const docs = {
|
|
|
16
16
|
{guidance: true, description: 'Use `fit="cover"` for images and video so the component sizes the child; the child should not repeat `width`/`height`/`objectFit` styles.'},
|
|
17
17
|
{guidance: true, description: 'Pass one child. With `fit` set, every direct child is stretched to fill the box, so put an overlay or caption inside a single wrapper child rather than passing it as a second child.'},
|
|
18
18
|
{guidance: true, description: 'Describe media children with `alt`, or `alt=""` when the image is decorative. AspectRatio adds no role and no accessible name of its own, so the child carries the whole accessible description.'},
|
|
19
|
+
{guidance: true, description: 'For a breakpoint-dependent ratio, override the ratio responsively: pass an `aspectRatio` rule via `xstyle` (StyleX), or override `aspect-ratio` from your own unlayered CSS under a `@media`/`@container` rule — component styles live in the `astryx-base` cascade layer, so unlayered consumer CSS wins. Give an `xstyle` rule a `default` branch alongside the conditional one (`{default: 3, \'@container ...\': \'3 / 2\'}`) — with only the conditional branch the ratio is unset outside the query and the box collapses.'},
|
|
19
20
|
{guidance: false, description: 'Use for general layout containers; use standard layout components instead.'},
|
|
20
21
|
{guidance: false, description: 'Nest AspectRatio containers; one level is sufficient.'},
|
|
21
22
|
{guidance: false, description: 'Constrain the height on its own. The width comes from the container, so a `height` or `maxHeight` by itself clamps the box off ratio; pair it with `width: "auto"` to size from the height instead.'},
|
|
@@ -30,7 +31,7 @@ export const docs = {
|
|
|
30
31
|
{
|
|
31
32
|
name: 'ratio',
|
|
32
33
|
type: 'number',
|
|
33
|
-
description: 'Aspect ratio as width/height (e.g. 16/9, 1).',
|
|
34
|
+
description: 'Aspect ratio as width/height (e.g. 16/9, 1). Emitted as a class-level declaration (never inline), so `xstyle` rules or unlayered consumer CSS can override it responsively.',
|
|
34
35
|
required: true,
|
|
35
36
|
},
|
|
36
37
|
{
|
|
@@ -83,6 +84,7 @@ export const docsZh = {
|
|
|
83
84
|
{guidance: true, description: 'Use `fit="cover"` for images and video so the component sizes the child; the child should not repeat `width`/`height`/`objectFit` styles.'},
|
|
84
85
|
{guidance: true, description: 'Pass one child. With `fit` set, every direct child is stretched to fill the box, so put an overlay or caption inside a single wrapper child rather than passing it as a second child.'},
|
|
85
86
|
{guidance: true, description: 'Describe media children with `alt`, or `alt=""` when the image is decorative. AspectRatio adds no role and no accessible name of its own, so the child carries the whole accessible description.'},
|
|
87
|
+
{guidance: true, description: 'For a breakpoint-dependent ratio, override the ratio responsively: pass an `aspectRatio` rule via `xstyle` (StyleX), or override `aspect-ratio` from your own unlayered CSS under a `@media`/`@container` rule — component styles live in the `astryx-base` cascade layer, so unlayered consumer CSS wins. Give an `xstyle` rule a `default` branch alongside the conditional one (`{default: 3, \'@container ...\': \'3 / 2\'}`) — with only the conditional branch the ratio is unset outside the query and the box collapses.'},
|
|
86
88
|
{guidance: false, description: 'Use for general layout containers; use standard layout components instead.'},
|
|
87
89
|
{guidance: false, description: 'Nest AspectRatio containers; one level is sufficient.'},
|
|
88
90
|
{guidance: false, description: 'Constrain the height on its own. The width comes from the container, so a `height` or `maxHeight` by itself clamps the box off ratio; pair it with `width: "auto"` to size from the height instead.'},
|
|
@@ -94,7 +96,7 @@ export const docsZh = {
|
|
|
94
96
|
],
|
|
95
97
|
},
|
|
96
98
|
props: [
|
|
97
|
-
{name: 'ratio', type: 'number', description: '宽高比,以宽/高表示(例如 16/9、1
|
|
99
|
+
{name: 'ratio', type: 'number', description: '宽高比,以宽/高表示(例如 16/9、1)。以类级声明输出(非内联样式),可通过 `xstyle` 规则或未分层的(unlayered)消费者 CSS 做响应式覆盖。', required: true},
|
|
98
100
|
{
|
|
99
101
|
name: 'shape',
|
|
100
102
|
type: "'rectangle' | 'ellipse'",
|
|
@@ -133,6 +135,7 @@ export const docsDense = {
|
|
|
133
135
|
{guidance: true, description: 'Use `fit="cover"` for images and video so the component sizes the child; the child should not repeat `width`/`height`/`objectFit` styles.'},
|
|
134
136
|
{guidance: true, description: 'Pass one child. With `fit` set, every direct child is stretched to fill the box, so put an overlay or caption inside a single wrapper child rather than passing it as a second child.'},
|
|
135
137
|
{guidance: true, description: 'Describe media children with `alt`, or `alt=""` when the image is decorative. AspectRatio adds no role and no accessible name of its own, so the child carries the whole accessible description.'},
|
|
138
|
+
{guidance: true, description: 'For a breakpoint-dependent ratio, override the ratio responsively: pass an `aspectRatio` rule via `xstyle` (StyleX), or override `aspect-ratio` from your own unlayered CSS under a `@media`/`@container` rule — component styles live in the `astryx-base` cascade layer, so unlayered consumer CSS wins. Give an `xstyle` rule a `default` branch alongside the conditional one (`{default: 3, \'@container ...\': \'3 / 2\'}`) — with only the conditional branch the ratio is unset outside the query and the box collapses.'},
|
|
136
139
|
{guidance: false, description: 'Use for general layout containers; use standard layout components instead.'},
|
|
137
140
|
{guidance: false, description: 'Nest AspectRatio containers; one level is sufficient.'},
|
|
138
141
|
{guidance: false, description: 'Constrain the height on its own. The width comes from the container, so a `height` or `maxHeight` by itself clamps the box off ratio; pair it with `width: "auto"` to size from the height instead.'},
|
|
@@ -144,7 +147,7 @@ export const docsDense = {
|
|
|
144
147
|
],
|
|
145
148
|
},
|
|
146
149
|
propDescriptions: {
|
|
147
|
-
ratio: 'width/height ratio (e.g. 16/9, 1)',
|
|
150
|
+
ratio: 'width/height ratio (e.g. 16/9, 1); class-level declaration (never inline) so xstyle/unlayered-CSS @container overrides win',
|
|
148
151
|
shape: "container shape: 'rectangle' (default) | 'ellipse' (circle at ratio 1)",
|
|
149
152
|
fit: "child layout: 'cover' fill+crop | 'contain' fill+letterbox | 'center' natural size; omitted = child styles itself",
|
|
150
153
|
children: 'content positioned absolutely to fill container',
|
|
@@ -13,6 +13,20 @@ import {describe, it, expect, vi, beforeAll} from 'vitest';
|
|
|
13
13
|
import {render, screen} from '@testing-library/react';
|
|
14
14
|
import {AspectRatio} from './AspectRatio';
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* The ratio compiles to a class-level `aspect-ratio: var(--x-aspectRatio)`
|
|
18
|
+
* declaration with the value carried by a CSS variable (so xstyle/@media
|
|
19
|
+
* and unlayered consumer CSS overrides can win). `--x-aspectRatio` is the
|
|
20
|
+
* debug-mode variable name emitted for the `aspectRatio` property.
|
|
21
|
+
*/
|
|
22
|
+
function ratioVar(el: HTMLElement): string {
|
|
23
|
+
return el.style.getPropertyValue('--x-aspectRatio');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function ratioValue(ratio: number): string {
|
|
27
|
+
return String(ratio);
|
|
28
|
+
}
|
|
29
|
+
|
|
16
30
|
describe('AspectRatio', () => {
|
|
17
31
|
it('renders with correct aspect ratio', () => {
|
|
18
32
|
render(
|
|
@@ -22,7 +36,7 @@ describe('AspectRatio', () => {
|
|
|
22
36
|
);
|
|
23
37
|
const element = screen.getByTestId('aspect-ratio');
|
|
24
38
|
expect(element).toBeInTheDocument();
|
|
25
|
-
expect(element
|
|
39
|
+
expect(ratioVar(element)).toBe(ratioValue(16 / 9));
|
|
26
40
|
});
|
|
27
41
|
|
|
28
42
|
it('children fill the container', () => {
|
|
@@ -47,7 +61,7 @@ describe('AspectRatio', () => {
|
|
|
47
61
|
</AspectRatio>,
|
|
48
62
|
);
|
|
49
63
|
const element = screen.getByTestId('aspect-ratio');
|
|
50
|
-
expect(element
|
|
64
|
+
expect(ratioVar(element)).toBe(ratioValue(ratio));
|
|
51
65
|
});
|
|
52
66
|
|
|
53
67
|
it('renders with 4:3 ratio', () => {
|
|
@@ -58,7 +72,7 @@ describe('AspectRatio', () => {
|
|
|
58
72
|
</AspectRatio>,
|
|
59
73
|
);
|
|
60
74
|
const element = screen.getByTestId('aspect-ratio');
|
|
61
|
-
expect(element
|
|
75
|
+
expect(ratioVar(element)).toBe(ratioValue(ratio));
|
|
62
76
|
});
|
|
63
77
|
|
|
64
78
|
it('renders with 1:1 square ratio', () => {
|
|
@@ -68,7 +82,7 @@ describe('AspectRatio', () => {
|
|
|
68
82
|
</AspectRatio>,
|
|
69
83
|
);
|
|
70
84
|
const element = screen.getByTestId('aspect-ratio');
|
|
71
|
-
expect(element
|
|
85
|
+
expect(ratioVar(element)).toBe(ratioValue(1));
|
|
72
86
|
});
|
|
73
87
|
|
|
74
88
|
it('renders with 21:9 ultrawide ratio', () => {
|
|
@@ -79,7 +93,7 @@ describe('AspectRatio', () => {
|
|
|
79
93
|
</AspectRatio>,
|
|
80
94
|
);
|
|
81
95
|
const element = screen.getByTestId('aspect-ratio');
|
|
82
|
-
expect(element
|
|
96
|
+
expect(ratioVar(element)).toBe(ratioValue(ratio));
|
|
83
97
|
});
|
|
84
98
|
|
|
85
99
|
it('renders an ellipse that respects the ratio (circle at 1:1)', () => {
|
|
@@ -89,7 +103,7 @@ describe('AspectRatio', () => {
|
|
|
89
103
|
</AspectRatio>,
|
|
90
104
|
);
|
|
91
105
|
const element = screen.getByTestId('aspect-ratio');
|
|
92
|
-
expect(element
|
|
106
|
+
expect(ratioVar(element)).toBe(ratioValue(1));
|
|
93
107
|
expect(element.className).toContain('ellipse');
|
|
94
108
|
});
|
|
95
109
|
|
|
@@ -101,7 +115,7 @@ describe('AspectRatio', () => {
|
|
|
101
115
|
);
|
|
102
116
|
const element = screen.getByTestId('aspect-ratio');
|
|
103
117
|
// Ratio is preserved — the ellipse does not force 1:1.
|
|
104
|
-
expect(element
|
|
118
|
+
expect(ratioVar(element)).toBe(ratioValue(16 / 9));
|
|
105
119
|
expect(element.className).toContain('ellipse');
|
|
106
120
|
});
|
|
107
121
|
|
|
@@ -112,7 +126,7 @@ describe('AspectRatio', () => {
|
|
|
112
126
|
</AspectRatio>,
|
|
113
127
|
);
|
|
114
128
|
const element = screen.getByTestId('aspect-ratio');
|
|
115
|
-
expect(element
|
|
129
|
+
expect(ratioVar(element)).toBe(ratioValue(1));
|
|
116
130
|
expect(element.className).toContain('rectangle');
|
|
117
131
|
// No ellipse border-radius when shape is the default rectangle
|
|
118
132
|
expect(element.style.borderRadius).toBe('');
|
|
@@ -177,6 +191,38 @@ describe('AspectRatio', () => {
|
|
|
177
191
|
expect(video).toBeInTheDocument();
|
|
178
192
|
});
|
|
179
193
|
|
|
194
|
+
describe('responsive ratio', () => {
|
|
195
|
+
it('emits the ratio as a class-level declaration, not an inline style', () => {
|
|
196
|
+
render(
|
|
197
|
+
<AspectRatio ratio={3 / 1} data-testid="aspect-ratio">
|
|
198
|
+
<div>Hero</div>
|
|
199
|
+
</AspectRatio>,
|
|
200
|
+
);
|
|
201
|
+
const element = screen.getByTestId('aspect-ratio');
|
|
202
|
+
// No inline aspect-ratio — the declaration lives in a class reading a
|
|
203
|
+
// CSS variable, so @media/@container rules (via xstyle, or via
|
|
204
|
+
// unlayered consumer CSS beating the astryx-base layer) can override
|
|
205
|
+
// it.
|
|
206
|
+
expect(element.style.aspectRatio).toBe('');
|
|
207
|
+
expect(ratioVar(element)).toBe(ratioValue(3 / 1));
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('lets a consumer inline style win over the class-level ratio', () => {
|
|
211
|
+
render(
|
|
212
|
+
<AspectRatio
|
|
213
|
+
ratio={3 / 1}
|
|
214
|
+
style={{aspectRatio: '3 / 2'}}
|
|
215
|
+
data-testid="aspect-ratio">
|
|
216
|
+
<div>Hero</div>
|
|
217
|
+
</AspectRatio>,
|
|
218
|
+
);
|
|
219
|
+
const element = screen.getByTestId('aspect-ratio');
|
|
220
|
+
// The consumer's style prop passes through untouched (it is no longer
|
|
221
|
+
// merged with a component-owned inline aspect-ratio).
|
|
222
|
+
expect(element.style.aspectRatio).toBe('3 / 2');
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
180
226
|
describe('fit', () => {
|
|
181
227
|
it('marks the child\'s direct parent with data-astryx-aspect-ratio-override for fit="cover"', () => {
|
|
182
228
|
render(
|
|
@@ -277,7 +323,7 @@ describe('AspectRatio', () => {
|
|
|
277
323
|
});
|
|
278
324
|
|
|
279
325
|
describe('style merging', () => {
|
|
280
|
-
it('keeps the consumer style and
|
|
326
|
+
it('keeps the consumer style and lets an inline ratio win', () => {
|
|
281
327
|
render(
|
|
282
328
|
<AspectRatio
|
|
283
329
|
ratio={16 / 9}
|
|
@@ -288,7 +334,8 @@ describe('AspectRatio', () => {
|
|
|
288
334
|
);
|
|
289
335
|
const element = screen.getByTestId('aspect-ratio');
|
|
290
336
|
expect(element.style.opacity).toBe('0.5');
|
|
291
|
-
expect(element.style.aspectRatio).toBe(
|
|
337
|
+
expect(element.style.aspectRatio).toBe('3 / 1');
|
|
338
|
+
expect(ratioVar(element)).toBe(ratioValue(16 / 9));
|
|
292
339
|
});
|
|
293
340
|
|
|
294
341
|
it('keeps a consumer className beside the theme target', () => {
|
|
@@ -43,6 +43,25 @@ export interface AspectRatioProps extends BaseProps<HTMLDivElement> {
|
|
|
43
43
|
ref?: React.Ref<HTMLDivElement>;
|
|
44
44
|
/**
|
|
45
45
|
* The aspect ratio as width/height (e.g., 16/9 = 1.777..., 4/3 = 1.333..., 1 for square).
|
|
46
|
+
*
|
|
47
|
+
* The ratio is emitted as a class-level declaration — never an inline
|
|
48
|
+
* style — so it can be made responsive:
|
|
49
|
+
* - StyleX consumers pass an `aspect-ratio` rule via `xstyle`, including
|
|
50
|
+
* under `@media`/`@container` conditions.
|
|
51
|
+
* - Plain-CSS/Tailwind consumers override `aspect-ratio` from their own
|
|
52
|
+
* (unlayered) rules: the compiled component styles live in the
|
|
53
|
+
* `astryx-base` cascade layer, and unlayered CSS wins over layered
|
|
54
|
+
* rules regardless of specificity.
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* ```
|
|
58
|
+
* // 3:1 hero that becomes 3:2 when its container stacks
|
|
59
|
+
* <AspectRatio ratio={3 / 1} className="gallery-hero">…</AspectRatio>
|
|
60
|
+
*
|
|
61
|
+
* // CSS: @container gallery (max-width: 720px) {
|
|
62
|
+
* // .gallery-hero { aspect-ratio: 3 / 2; }
|
|
63
|
+
* // }
|
|
64
|
+
* ```
|
|
46
65
|
*/
|
|
47
66
|
ratio: number;
|
|
48
67
|
|
|
@@ -132,6 +151,20 @@ const styles = stylex.create({
|
|
|
132
151
|
},
|
|
133
152
|
});
|
|
134
153
|
|
|
154
|
+
// The ratio compiles to a CSS variable + a class-level declaration
|
|
155
|
+
// (aspect-ratio: var(--x)) instead of a raw inline style, so consumer
|
|
156
|
+
// overrides — `xstyle` rules, including ones inside @media/@container
|
|
157
|
+
// queries — can still win. A raw inline `aspect-ratio` would beat any class
|
|
158
|
+
// (same rationale as Grid's dynamic track values). Non-StyleX consumers
|
|
159
|
+
// don't need a dedicated hook: the compiled declaration sits in the
|
|
160
|
+
// `astryx-base` cascade layer, so any unlayered consumer rule wins
|
|
161
|
+
// regardless of specificity.
|
|
162
|
+
const dynamicStyles = stylex.create({
|
|
163
|
+
ratio: (ratio: number) => ({
|
|
164
|
+
aspectRatio: ratio,
|
|
165
|
+
}),
|
|
166
|
+
});
|
|
167
|
+
|
|
135
168
|
/**
|
|
136
169
|
* AspectRatio component for maintaining a specific aspect ratio for its children.
|
|
137
170
|
*
|
|
@@ -147,6 +180,11 @@ const styles = stylex.create({
|
|
|
147
180
|
* `ratio={1}` or an oval at other ratios. Both shapes respect the provided
|
|
148
181
|
* `ratio`.
|
|
149
182
|
*
|
|
183
|
+
* The ratio is a class-level declaration (not an inline style), so it can be
|
|
184
|
+
* overridden responsively — via `xstyle` for StyleX consumers, or with a
|
|
185
|
+
* plain unlayered `aspect-ratio` rule for plain-CSS consumers (component
|
|
186
|
+
* styles live in the `astryx-base` cascade layer, which unlayered CSS beats).
|
|
187
|
+
*
|
|
150
188
|
* Sizing: the box takes its width from its container and derives its height
|
|
151
189
|
* from the ratio, so it needs an ancestor with a definite width. Two
|
|
152
190
|
* consequences are worth knowing. Constraining only the height (`height` or
|
|
@@ -188,11 +226,12 @@ export function AspectRatio({
|
|
|
188
226
|
themeProps('aspect-ratio', {shape}),
|
|
189
227
|
stylex.props(
|
|
190
228
|
styles.container,
|
|
229
|
+
dynamicStyles.ratio(ratio),
|
|
191
230
|
shape === 'ellipse' && styles.ellipse,
|
|
192
231
|
xstyle,
|
|
193
232
|
),
|
|
194
233
|
className,
|
|
195
|
-
|
|
234
|
+
style,
|
|
196
235
|
)}
|
|
197
236
|
{...props}>
|
|
198
237
|
{/* The marker attribute carries the fit value so the reset.css child
|
|
@@ -15,7 +15,7 @@ export const docs = {
|
|
|
15
15
|
},
|
|
16
16
|
theming: {
|
|
17
17
|
targets: [
|
|
18
|
-
{className: 'astryx-button-group', visualProps: ['size', 'orientation']},
|
|
18
|
+
{className: 'astryx-button-group', visualProps: ['size', 'orientation', 'elevation']},
|
|
19
19
|
],
|
|
20
20
|
},
|
|
21
21
|
components: [
|
|
@@ -46,6 +46,8 @@ export const docs = {
|
|
|
46
46
|
{guidance: false, description: "Don't mix wildly different actions. A Save button next to a Delete button in the same group is confusing."},
|
|
47
47
|
{guidance: false, description: "Don't use ButtonGroup for navigation. Use SegmentedControl or TabList for switching between views."},
|
|
48
48
|
{guidance: false, description: "Don't nest ButtonGroups. If you need multiple groups, place them side by side with a gap."},
|
|
49
|
+
{guidance: true, description: 'Name the group for what its buttons act on. The label is the group\u2019s accessible name and a screen reader reads it before each member.'},
|
|
50
|
+
{guidance: false, description: "Don't disable the group to show that an action is in flight. A disabled member drops focus, so a keyboard user loses their place; leave the group enabled and show progress on the button that started the work."},
|
|
49
51
|
],
|
|
50
52
|
anatomy: [
|
|
51
53
|
{name: 'Button', required: true, description: 'One or more Button or IconButton children that form the connected group.'},
|
|
@@ -84,6 +86,8 @@ export const docsZh = {
|
|
|
84
86
|
{guidance: false, description: '不要混合差异很大的操作,将保存按钮和删除按钮放在同一组中会令人困惑。'},
|
|
85
87
|
{guidance: false, description: '不要使用 ButtonGroup 进行导航,使用 SegmentedControl 或 TabList 切换视图。'},
|
|
86
88
|
{guidance: false, description: '不要嵌套 ButtonGroup。如需多个组,请并排放置并留有间隔。'},
|
|
89
|
+
{guidance: true, description: '按钮组的标签应说明这些按钮作用于什么。该标签是按钮组的无障碍名称,屏幕阅读器会在每个成员之前朗读它。'},
|
|
90
|
+
{guidance: false, description: '不要用禁用整个按钮组来表示操作进行中。禁用的成员会失去焦点,键盘用户会丢失位置;请保持按钮组可用,并在发起操作的按钮上显示进度。'},
|
|
87
91
|
],
|
|
88
92
|
anatomy: [
|
|
89
93
|
{name: '按钮', required: true, description: '一个或多个 Button 或 IconButton 子元素,形成连接的组。'},
|
|
@@ -105,6 +109,8 @@ export const docsDense = {
|
|
|
105
109
|
{guidance: false, description: "Don't mix unrelated actions in one group."},
|
|
106
110
|
{guidance: false, description: "Don't use for navigation. Use SegmentedControl or TabList."},
|
|
107
111
|
{guidance: false, description: "Don't nest ButtonGroups."},
|
|
112
|
+
{guidance: true, description: 'Label the group for what its buttons act on; it is the accessible name.'},
|
|
113
|
+
{guidance: false, description: "Don't disable the group for an in-flight action; a disabled member drops focus."},
|
|
108
114
|
],
|
|
109
115
|
},
|
|
110
116
|
components: [
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import {describe, it, expect} from 'vitest';
|
|
15
|
-
import {render, screen} from '@testing-library/react';
|
|
15
|
+
import {render, screen, fireEvent} from '@testing-library/react';
|
|
16
|
+
import userEvent from '@testing-library/user-event';
|
|
16
17
|
import {transformSync} from '@babel/core';
|
|
17
18
|
import stylexBabelPlugin from '@stylexjs/babel-plugin';
|
|
18
19
|
import {readFileSync} from 'node:fs';
|
|
@@ -611,5 +612,188 @@ describe('ButtonGroup', () => {
|
|
|
611
612
|
none.querySelector('[role="group"]')!.className,
|
|
612
613
|
);
|
|
613
614
|
});
|
|
615
|
+
|
|
616
|
+
it('exposes the elevation to a theme as a data attribute', () => {
|
|
617
|
+
render(
|
|
618
|
+
<ButtonGroup label="Actions" elevation="high">
|
|
619
|
+
<Button label="One" />
|
|
620
|
+
</ButtonGroup>,
|
|
621
|
+
);
|
|
622
|
+
|
|
623
|
+
expect(screen.getByRole('group')).toHaveAttribute(
|
|
624
|
+
'data-elevation',
|
|
625
|
+
'high',
|
|
626
|
+
);
|
|
627
|
+
});
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
// ===========================================================================
|
|
631
|
+
// Keyboard navigation
|
|
632
|
+
//
|
|
633
|
+
// The group wires useListFocus, so arrow keys move focus between members.
|
|
634
|
+
// The last two tests cover the boundary: a member can own a layer, whose
|
|
635
|
+
// keys belong to that layer, and the group itself can sit inside a layer,
|
|
636
|
+
// whose keys are still the group's own. Both must hold at once.
|
|
637
|
+
// ===========================================================================
|
|
638
|
+
describe('keyboard navigation', () => {
|
|
639
|
+
const clipboard = (
|
|
640
|
+
<ButtonGroup label="Actions">
|
|
641
|
+
<Button label="Copy" />
|
|
642
|
+
<Button label="Cut" />
|
|
643
|
+
<Button label="Paste" />
|
|
644
|
+
</ButtonGroup>
|
|
645
|
+
);
|
|
646
|
+
|
|
647
|
+
it('moves focus between members with the arrow keys, wrapping at the end', async () => {
|
|
648
|
+
const user = userEvent.setup();
|
|
649
|
+
render(clipboard);
|
|
650
|
+
|
|
651
|
+
screen.getByRole('button', {name: 'Copy'}).focus();
|
|
652
|
+
|
|
653
|
+
await user.keyboard('{ArrowRight}');
|
|
654
|
+
expect(screen.getByRole('button', {name: 'Cut'})).toHaveFocus();
|
|
655
|
+
|
|
656
|
+
await user.keyboard('{ArrowRight}{ArrowRight}');
|
|
657
|
+
expect(screen.getByRole('button', {name: 'Copy'})).toHaveFocus();
|
|
658
|
+
|
|
659
|
+
await user.keyboard('{ArrowLeft}');
|
|
660
|
+
expect(screen.getByRole('button', {name: 'Paste'})).toHaveFocus();
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
it('jumps to the first and last member with Home and End', async () => {
|
|
664
|
+
const user = userEvent.setup();
|
|
665
|
+
render(clipboard);
|
|
666
|
+
|
|
667
|
+
screen.getByRole('button', {name: 'Cut'}).focus();
|
|
668
|
+
|
|
669
|
+
await user.keyboard('{End}');
|
|
670
|
+
expect(screen.getByRole('button', {name: 'Paste'})).toHaveFocus();
|
|
671
|
+
|
|
672
|
+
await user.keyboard('{Home}');
|
|
673
|
+
expect(screen.getByRole('button', {name: 'Copy'})).toHaveFocus();
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
it('uses the vertical arrows when the group is vertical', async () => {
|
|
677
|
+
const user = userEvent.setup();
|
|
678
|
+
render(
|
|
679
|
+
<ButtonGroup label="Actions" orientation="vertical">
|
|
680
|
+
<Button label="Copy" />
|
|
681
|
+
<Button label="Cut" />
|
|
682
|
+
</ButtonGroup>,
|
|
683
|
+
);
|
|
684
|
+
|
|
685
|
+
screen.getByRole('button', {name: 'Copy'}).focus();
|
|
686
|
+
|
|
687
|
+
await user.keyboard('{ArrowDown}');
|
|
688
|
+
expect(screen.getByRole('button', {name: 'Cut'})).toHaveFocus();
|
|
689
|
+
|
|
690
|
+
await user.keyboard('{ArrowUp}');
|
|
691
|
+
expect(screen.getByRole('button', {name: 'Copy'})).toHaveFocus();
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
it('follows visual direction in RTL', async () => {
|
|
695
|
+
const user = userEvent.setup();
|
|
696
|
+
// dir sits on the group because jsdom resolves computed direction from
|
|
697
|
+
// the element's own attribute, not from an ancestor.
|
|
698
|
+
render(
|
|
699
|
+
<ButtonGroup label="Actions" dir="rtl">
|
|
700
|
+
<Button label="Copy" />
|
|
701
|
+
<Button label="Cut" />
|
|
702
|
+
<Button label="Paste" />
|
|
703
|
+
</ButtonGroup>,
|
|
704
|
+
);
|
|
705
|
+
screen.getByRole('button', {name: 'Copy'}).focus();
|
|
706
|
+
|
|
707
|
+
await user.keyboard('{ArrowLeft}');
|
|
708
|
+
expect(screen.getByRole('button', {name: 'Cut'})).toHaveFocus();
|
|
709
|
+
|
|
710
|
+
await user.keyboard('{ArrowRight}');
|
|
711
|
+
expect(screen.getByRole('button', {name: 'Copy'})).toHaveFocus();
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
it('skips a disabled member', async () => {
|
|
715
|
+
const user = userEvent.setup();
|
|
716
|
+
render(
|
|
717
|
+
<ButtonGroup label="Actions">
|
|
718
|
+
<Button label="Copy" />
|
|
719
|
+
<Button label="Cut" isDisabled />
|
|
720
|
+
<Button label="Paste" />
|
|
721
|
+
</ButtonGroup>,
|
|
722
|
+
);
|
|
723
|
+
|
|
724
|
+
screen.getByRole('button', {name: 'Copy'}).focus();
|
|
725
|
+
|
|
726
|
+
await user.keyboard('{ArrowRight}');
|
|
727
|
+
expect(screen.getByRole('button', {name: 'Paste'})).toHaveFocus();
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
it('leaves arrow keys to a member\u2019s open menu', async () => {
|
|
731
|
+
const user = userEvent.setup();
|
|
732
|
+
render(
|
|
733
|
+
<ButtonGroup label="Approve action">
|
|
734
|
+
<Button label="Allow once" />
|
|
735
|
+
<DropdownMenu
|
|
736
|
+
hasChevron={false}
|
|
737
|
+
button={{label: 'Allow options', isIconOnly: true}}
|
|
738
|
+
items={[{label: 'Allow for 30 minutes'}, {label: 'Always allow'}]}
|
|
739
|
+
/>
|
|
740
|
+
</ButtonGroup>,
|
|
741
|
+
);
|
|
742
|
+
|
|
743
|
+
await user.click(screen.getByRole('button', {name: /Allow options/}));
|
|
744
|
+
const item = screen.getByRole('menuitem', {
|
|
745
|
+
name: 'Allow for 30 minutes',
|
|
746
|
+
hidden: true,
|
|
747
|
+
});
|
|
748
|
+
item.focus();
|
|
749
|
+
|
|
750
|
+
// Assert positively: the menu item keeps focus. `.not.toHaveFocus()` on
|
|
751
|
+
// the group's buttons would also pass if the group had thrown focus
|
|
752
|
+
// somewhere else entirely.
|
|
753
|
+
fireEvent.keyDown(item, {key: 'ArrowRight'});
|
|
754
|
+
expect(item).toHaveFocus();
|
|
755
|
+
|
|
756
|
+
fireEvent.keyDown(item, {key: 'ArrowLeft'});
|
|
757
|
+
expect(item).toHaveFocus();
|
|
758
|
+
|
|
759
|
+
// End belongs to the menu's own list focus, so it lands on the last menu
|
|
760
|
+
// item rather than the group's last button.
|
|
761
|
+
fireEvent.keyDown(item, {key: 'End'});
|
|
762
|
+
expect(
|
|
763
|
+
screen.getByRole('menuitem', {name: 'Always allow', hidden: true}),
|
|
764
|
+
).toHaveFocus();
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
it('still moves between its own members when the group sits inside a popover', async () => {
|
|
768
|
+
const user = userEvent.setup();
|
|
769
|
+
// A group rendered inside a Popover, ContextMenu, HoverCard or Toast has
|
|
770
|
+
// a `[popover]` ancestor, because that is how useLayer mounts a layer.
|
|
771
|
+
// A bare `popover` div is the same DOM fact without the layer harness.
|
|
772
|
+
render(
|
|
773
|
+
<div popover="manual">
|
|
774
|
+
<ButtonGroup label="Actions">
|
|
775
|
+
<Button label="Copy" />
|
|
776
|
+
<Button label="Cut" />
|
|
777
|
+
<Button label="Paste" />
|
|
778
|
+
</ButtonGroup>
|
|
779
|
+
</div>,
|
|
780
|
+
);
|
|
781
|
+
|
|
782
|
+
// A closed popover is display:none in jsdom, so its contents read as
|
|
783
|
+
// inaccessible; focus and key handling still work on them.
|
|
784
|
+
const button = (name: string) =>
|
|
785
|
+
screen.getByRole('button', {name, hidden: true});
|
|
786
|
+
|
|
787
|
+
button('Copy').focus();
|
|
788
|
+
|
|
789
|
+
await user.keyboard('{ArrowRight}');
|
|
790
|
+
expect(button('Cut')).toHaveFocus();
|
|
791
|
+
|
|
792
|
+
await user.keyboard('{End}');
|
|
793
|
+
expect(button('Paste')).toHaveFocus();
|
|
794
|
+
|
|
795
|
+
await user.keyboard('{Home}');
|
|
796
|
+
expect(button('Copy')).toHaveFocus();
|
|
797
|
+
});
|
|
614
798
|
});
|
|
615
799
|
});
|
|
@@ -172,6 +172,13 @@ export function ButtonGroup({
|
|
|
172
172
|
|
|
173
173
|
const {listRef, handleKeyDown} = useListFocus<HTMLDivElement>({
|
|
174
174
|
itemSelector: 'button, [tabindex="0"]',
|
|
175
|
+
// A member's layer renders inline inside the group (useLayer does not
|
|
176
|
+
// portal it), so a key pressed in an open DropdownMenu bubbles here. The
|
|
177
|
+
// boundary is the group's own root or any layer, whichever is nearer to
|
|
178
|
+
// the target. Naming the group's own root is what keeps a group rendered
|
|
179
|
+
// inside a Popover or Toast working: bailing on any [popover] ancestor
|
|
180
|
+
// instead would swallow every arrow key it owns.
|
|
181
|
+
boundarySelector: '[role="group"], [popover]',
|
|
175
182
|
orientation,
|
|
176
183
|
});
|
|
177
184
|
|
|
@@ -187,7 +194,7 @@ export function ButtonGroup({
|
|
|
187
194
|
ref={mergeRefs(ref, listRef)}
|
|
188
195
|
{...props}
|
|
189
196
|
{...mergeProps(
|
|
190
|
-
themeProps('button-group', {size, orientation}),
|
|
197
|
+
themeProps('button-group', {size, orientation, elevation}),
|
|
191
198
|
stylex.props(
|
|
192
199
|
styles.group,
|
|
193
200
|
orientation === 'vertical' && styles.vertical,
|
|
@@ -229,7 +229,7 @@ describe('useTablePagination', () => {
|
|
|
229
229
|
).toBeTruthy();
|
|
230
230
|
});
|
|
231
231
|
|
|
232
|
-
it('transformTableContext renders Pagination above and below', () => {
|
|
232
|
+
it('transformTableContext renders distinctly named Pagination above and below', () => {
|
|
233
233
|
render(
|
|
234
234
|
<PaginatedTable
|
|
235
235
|
data={generateItems(30)}
|
|
@@ -237,10 +237,40 @@ describe('useTablePagination', () => {
|
|
|
237
237
|
position="both"
|
|
238
238
|
/>,
|
|
239
239
|
);
|
|
240
|
-
const
|
|
241
|
-
|
|
240
|
+
const table = screen.getByRole('table');
|
|
241
|
+
// Each nav landmark gets a unique accessible name (axe landmark-unique)
|
|
242
|
+
const topNav = screen.getByRole('navigation', {
|
|
243
|
+
name: 'Table pagination (top)',
|
|
244
|
+
});
|
|
245
|
+
const bottomNav = screen.getByRole('navigation', {
|
|
246
|
+
name: 'Table pagination (bottom)',
|
|
242
247
|
});
|
|
243
|
-
|
|
248
|
+
// Top nav comes before the table, bottom nav after it in DOM order
|
|
249
|
+
expect(
|
|
250
|
+
table.compareDocumentPosition(topNav) &
|
|
251
|
+
Node.DOCUMENT_POSITION_PRECEDING,
|
|
252
|
+
).toBeTruthy();
|
|
253
|
+
expect(
|
|
254
|
+
table.compareDocumentPosition(bottomNav) &
|
|
255
|
+
Node.DOCUMENT_POSITION_FOLLOWING,
|
|
256
|
+
).toBeTruthy();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('position="both" interpolates a consumer label into distinct nav names', () => {
|
|
260
|
+
render(
|
|
261
|
+
<PaginatedTable
|
|
262
|
+
data={generateItems(30)}
|
|
263
|
+
pageSize={10}
|
|
264
|
+
position="both"
|
|
265
|
+
label="Users table"
|
|
266
|
+
/>,
|
|
267
|
+
);
|
|
268
|
+
expect(
|
|
269
|
+
screen.getByRole('navigation', {name: 'Users table (top)'}),
|
|
270
|
+
).toBeInTheDocument();
|
|
271
|
+
expect(
|
|
272
|
+
screen.getByRole('navigation', {name: 'Users table (bottom)'}),
|
|
273
|
+
).toBeInTheDocument();
|
|
244
274
|
});
|
|
245
275
|
|
|
246
276
|
it('transformTableContext does not render Pagination when position is none', () => {
|
|
@@ -164,6 +164,9 @@ export interface UseTablePaginationConfig {
|
|
|
164
164
|
|
|
165
165
|
/**
|
|
166
166
|
* Accessible label for the pagination nav landmark.
|
|
167
|
+
* When `position` is 'both', the two navs get distinct names by suffixing
|
|
168
|
+
* this label — "{label} (top)" above and "{label} (bottom)" below — so
|
|
169
|
+
* same-type landmarks stay distinguishable to assistive technology.
|
|
167
170
|
* @default 'Table pagination'
|
|
168
171
|
*/
|
|
169
172
|
label?: string;
|
|
@@ -211,6 +214,10 @@ export function useTablePagination<T extends Record<string, unknown>>(
|
|
|
211
214
|
label: labelFromProps,
|
|
212
215
|
} = config;
|
|
213
216
|
const label = labelFromProps ?? t('@astryx.table.pagination.label');
|
|
217
|
+
// position='both' renders two nav landmarks; suffix the base label so each
|
|
218
|
+
// gets a unique accessible name (axe landmark-unique).
|
|
219
|
+
const labelAbove = t('@astryx.table.pagination.labelAbove', {label});
|
|
220
|
+
const labelBelow = t('@astryx.table.pagination.labelBelow', {label});
|
|
214
221
|
|
|
215
222
|
// Same guard as Pagination itself: 0/NaN/negative pageSize would produce an
|
|
216
223
|
// Infinity/NaN totalPages here, which bypasses Pagination's own coercion
|
|
@@ -240,8 +247,20 @@ export function useTablePagination<T extends Record<string, unknown>>(
|
|
|
240
247
|
|
|
241
248
|
// Keep current config in a ref so the plugin can read the latest values
|
|
242
249
|
// without needing to recreate the plugin object on every change.
|
|
243
|
-
const configRef = useRef({
|
|
244
|
-
|
|
250
|
+
const configRef = useRef({
|
|
251
|
+
paginationProps,
|
|
252
|
+
position,
|
|
253
|
+
align,
|
|
254
|
+
labelAbove,
|
|
255
|
+
labelBelow,
|
|
256
|
+
});
|
|
257
|
+
configRef.current = {
|
|
258
|
+
paginationProps,
|
|
259
|
+
position,
|
|
260
|
+
align,
|
|
261
|
+
labelAbove,
|
|
262
|
+
labelBelow,
|
|
263
|
+
};
|
|
245
264
|
|
|
246
265
|
// Stable plugin object \u2014 created once, reads config via ref.
|
|
247
266
|
return useMemo(
|
|
@@ -251,6 +270,8 @@ export function useTablePagination<T extends Record<string, unknown>>(
|
|
|
251
270
|
position: pos,
|
|
252
271
|
paginationProps: props,
|
|
253
272
|
align: a,
|
|
273
|
+
labelAbove: above,
|
|
274
|
+
labelBelow: below,
|
|
254
275
|
} = configRef.current;
|
|
255
276
|
if (pos === 'none') {
|
|
256
277
|
return children;
|
|
@@ -277,7 +298,18 @@ export function useTablePagination<T extends Record<string, unknown>>(
|
|
|
277
298
|
a === 'end' && styles.alignEnd,
|
|
278
299
|
a === 'start' && styles.alignStart,
|
|
279
300
|
)}>
|
|
280
|
-
<Pagination
|
|
301
|
+
<Pagination
|
|
302
|
+
{...props}
|
|
303
|
+
// Two navs with the same name are indistinguishable landmarks;
|
|
304
|
+
// give each side a unique name when both render.
|
|
305
|
+
label={
|
|
306
|
+
pos === 'both'
|
|
307
|
+
? side === 'above'
|
|
308
|
+
? above
|
|
309
|
+
: below
|
|
310
|
+
: props.label
|
|
311
|
+
}
|
|
312
|
+
/>
|
|
281
313
|
</div>
|
|
282
314
|
);
|
|
283
315
|
|
|
@@ -72,6 +72,12 @@ export const docs = {
|
|
|
72
72
|
description: 'Horizontal alignment of the pagination controls.',
|
|
73
73
|
default: "'center'",
|
|
74
74
|
},
|
|
75
|
+
{
|
|
76
|
+
name: 'label',
|
|
77
|
+
type: 'string',
|
|
78
|
+
description: "Accessible name for the pagination nav landmark. With position='both' the two navs get distinct names — the label suffixed as '(top)' and '(bottom)' — so same-type landmarks stay unique (axe landmark-unique).",
|
|
79
|
+
default: "'Table pagination'",
|
|
80
|
+
},
|
|
75
81
|
],
|
|
76
82
|
};
|
|
77
83
|
|
|
@@ -129,5 +135,6 @@ export const docsDense = {
|
|
|
129
135
|
pageSize: 'Items per page. Default 10.',
|
|
130
136
|
variant: "Pagination variant: 'pages'|'count'|'compact'|'dots'|'none'.",
|
|
131
137
|
position: "Render position: 'below'|'above'|'both'|'none'.",
|
|
138
|
+
label: "Accessible name for the nav landmark; position='both' emits '<label> (top)' / '<label> (bottom)'.",
|
|
132
139
|
},
|
|
133
140
|
};
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
-
|
|
3
|
-
import {describe, it, expect} from 'vitest';
|
|
4
|
-
|
|
5
|
-
import {docs as ButtonDocs} from '../Button/Button.doc.mjs';
|
|
6
|
-
import {docs as ContextMenuDocs} from '../ContextMenu/ContextMenu.doc.mjs';
|
|
7
|
-
import {docs as DialogDocs} from '../Dialog/Dialog.doc.mjs';
|
|
8
|
-
import {docs as LinkDocs} from '../Link/Link.doc.mjs';
|
|
9
|
-
import {docs as MoreMenuDocs} from '../MoreMenu/MoreMenu.doc.mjs';
|
|
10
|
-
import {docs as NumberInputDocs} from '../NumberInput/NumberInput.doc.mjs';
|
|
11
|
-
import {docs as PowerSearchDocs} from '../PowerSearch/PowerSearch.doc.mjs';
|
|
12
|
-
import {docs as SelectorDocs} from '../Selector/Selector.doc.mjs';
|
|
13
|
-
import {docs as TextInputDocs} from '../TextInput/TextInput.doc.mjs';
|
|
14
|
-
import {docs as ToastDocs} from '../Toast/Toast.doc.mjs';
|
|
15
|
-
import {docs as TokenizerDocs} from '../Tokenizer/Tokenizer.doc.mjs';
|
|
16
|
-
|
|
17
|
-
// `docs` is the ComponentDoc union (single vs multi component); narrow it
|
|
18
|
-
// for structural access to either the top-level props or the first
|
|
19
|
-
// sub-component's props.
|
|
20
|
-
function getProps(docs: unknown): {name: string}[] {
|
|
21
|
-
const doc = docs as {
|
|
22
|
-
props?: {name: string}[];
|
|
23
|
-
components?: {props?: {name: string}[]}[];
|
|
24
|
-
};
|
|
25
|
-
return doc.props || doc.components?.[0]?.props || [];
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
describe('API Contract Drift Audit (#4163)', () => {
|
|
29
|
-
it('documents Button link mode props', () => {
|
|
30
|
-
const props = getProps(ButtonDocs).map(p => p.name);
|
|
31
|
-
expect(props).toContain('href');
|
|
32
|
-
expect(props).toContain('as');
|
|
33
|
-
expect(props).toContain('target');
|
|
34
|
-
expect(props).toContain('rel');
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it('documents Toast onDismiss callback', () => {
|
|
38
|
-
const props = getProps(ToastDocs).map(p => p.name);
|
|
39
|
-
expect(props).toContain('onDismiss');
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it('documents ContextMenu and MoreMenu onOpenChange callback', () => {
|
|
43
|
-
const contextProps = getProps(ContextMenuDocs).map(p => p.name);
|
|
44
|
-
const moreProps = getProps(MoreMenuDocs).map(p => p.name);
|
|
45
|
-
expect(contextProps).toContain('onOpenChange');
|
|
46
|
-
expect(moreProps).toContain('onOpenChange');
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it('documents Selector startIcon and isLoading props', () => {
|
|
50
|
-
const props = getProps(SelectorDocs).map(p => p.name);
|
|
51
|
-
expect(props).toContain('startIcon');
|
|
52
|
-
expect(props).toContain('isLoading');
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it('documents TextInput and NumberInput onEnter / onKeyDown callbacks', () => {
|
|
56
|
-
const textProps = getProps(TextInputDocs).map(p => p.name);
|
|
57
|
-
const numProps = getProps(NumberInputDocs).map(p => p.name);
|
|
58
|
-
expect(textProps).toContain('onEnter');
|
|
59
|
-
expect(textProps).toContain('onKeyDown');
|
|
60
|
-
expect(numProps).toContain('onEnter');
|
|
61
|
-
expect(numProps).toContain('onKeyDown');
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it('documents PowerSearch and Tokenizer onFocus / onBlur callbacks', () => {
|
|
65
|
-
const powerProps = getProps(PowerSearchDocs).map(p => p.name);
|
|
66
|
-
const tokProps = getProps(TokenizerDocs).map(p => p.name);
|
|
67
|
-
expect(powerProps).toContain('onFocus');
|
|
68
|
-
expect(powerProps).toContain('onBlur');
|
|
69
|
-
expect(tokProps).toContain('onFocus');
|
|
70
|
-
expect(tokProps).toContain('onBlur');
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it('documents Link download / referrerPolicy and Dialog padding props', () => {
|
|
74
|
-
const linkProps = getProps(LinkDocs).map(p => p.name);
|
|
75
|
-
const dialogProps = getProps(DialogDocs).map(p => p.name);
|
|
76
|
-
expect(linkProps).toContain('download');
|
|
77
|
-
expect(linkProps).toContain('referrerPolicy');
|
|
78
|
-
expect(dialogProps).toContain('padding');
|
|
79
|
-
});
|
|
80
|
-
});
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
-
|
|
3
|
-
import {describe, it, expect} from 'vitest';
|
|
4
|
-
|
|
5
|
-
import {docs as FieldDocs} from '../Field/Field.doc.mjs';
|
|
6
|
-
|
|
7
|
-
// `docs` is the ComponentDoc union (single vs multi component); narrow it
|
|
8
|
-
// for structural access to either the top-level props or the first
|
|
9
|
-
// sub-component's props.
|
|
10
|
-
function getProps(docs: unknown): {name: string}[] {
|
|
11
|
-
const doc = docs as {
|
|
12
|
-
props?: {name: string}[];
|
|
13
|
-
components?: {props?: {name: string}[]}[];
|
|
14
|
-
};
|
|
15
|
-
return doc.props || doc.components?.[0]?.props || [];
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
describe('Field Component API Contract Drift (#4163)', () => {
|
|
19
|
-
it('documents Field labelID and isGroupLabel props', () => {
|
|
20
|
-
const props = getProps(FieldDocs).map(p => p.name);
|
|
21
|
-
expect(props).toContain('labelID');
|
|
22
|
-
expect(props).toContain('isGroupLabel');
|
|
23
|
-
});
|
|
24
|
-
});
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
-
|
|
3
|
-
import {describe, it, expect} from 'vitest';
|
|
4
|
-
|
|
5
|
-
import {docs as CheckboxInputDocs} from '../CheckboxInput/CheckboxInput.doc.mjs';
|
|
6
|
-
import {docs as CheckboxListDocs} from '../CheckboxList/CheckboxList.doc.mjs';
|
|
7
|
-
import {docs as DateInputDocs} from '../DateInput/DateInput.doc.mjs';
|
|
8
|
-
import {docs as DateRangeInputDocs} from '../DateRangeInput/DateRangeInput.doc.mjs';
|
|
9
|
-
import {docs as DateTimeInputDocs} from '../DateTimeInput/DateTimeInput.doc.mjs';
|
|
10
|
-
import {docs as FileInputDocs} from '../FileInput/FileInput.doc.mjs';
|
|
11
|
-
import {docs as MultiSelectorDocs} from '../MultiSelector/MultiSelector.doc.mjs';
|
|
12
|
-
import {docs as NumberInputDocs} from '../NumberInput/NumberInput.doc.mjs';
|
|
13
|
-
import {docs as RadioListDocs} from '../RadioList/RadioList.doc.mjs';
|
|
14
|
-
import {docs as SelectorDocs} from '../Selector/Selector.doc.mjs';
|
|
15
|
-
import {docs as SliderDocs} from '../Slider/Slider.doc.mjs';
|
|
16
|
-
import {docs as SwitchDocs} from '../Switch/Switch.doc.mjs';
|
|
17
|
-
import {docs as TextAreaDocs} from '../TextArea/TextArea.doc.mjs';
|
|
18
|
-
import {docs as TextInputDocs} from '../TextInput/TextInput.doc.mjs';
|
|
19
|
-
import {docs as TimeInputDocs} from '../TimeInput/TimeInput.doc.mjs';
|
|
20
|
-
import {docs as TokenizerDocs} from '../Tokenizer/Tokenizer.doc.mjs';
|
|
21
|
-
import {docs as TypeaheadDocs} from '../Typeahead/Typeahead.doc.mjs';
|
|
22
|
-
|
|
23
|
-
const inputDocsSuite = [
|
|
24
|
-
{name: 'CheckboxInput', docs: CheckboxInputDocs},
|
|
25
|
-
{name: 'CheckboxList', docs: CheckboxListDocs},
|
|
26
|
-
{name: 'DateInput', docs: DateInputDocs},
|
|
27
|
-
{name: 'DateRangeInput', docs: DateRangeInputDocs},
|
|
28
|
-
{name: 'DateTimeInput', docs: DateTimeInputDocs},
|
|
29
|
-
{name: 'FileInput', docs: FileInputDocs},
|
|
30
|
-
{name: 'MultiSelector', docs: MultiSelectorDocs},
|
|
31
|
-
{name: 'NumberInput', docs: NumberInputDocs},
|
|
32
|
-
{name: 'RadioList', docs: RadioListDocs},
|
|
33
|
-
{name: 'Selector', docs: SelectorDocs},
|
|
34
|
-
{name: 'Slider', docs: SliderDocs},
|
|
35
|
-
{name: 'Switch', docs: SwitchDocs},
|
|
36
|
-
{name: 'TextArea', docs: TextAreaDocs},
|
|
37
|
-
{name: 'TextInput', docs: TextInputDocs},
|
|
38
|
-
{name: 'TimeInput', docs: TimeInputDocs},
|
|
39
|
-
{name: 'Tokenizer', docs: TokenizerDocs},
|
|
40
|
-
{name: 'Typeahead', docs: TypeaheadDocs},
|
|
41
|
-
];
|
|
42
|
-
|
|
43
|
-
describe('Input Family width Prop Contract (#4163)', () => {
|
|
44
|
-
inputDocsSuite.forEach(({name, docs}) => {
|
|
45
|
-
it(`documents the width prop for ${name}`, () => {
|
|
46
|
-
// `docs` is the ComponentDoc union (single vs multi component); narrow it
|
|
47
|
-
// for structural access to either the top-level props or the first
|
|
48
|
-
// sub-component's props.
|
|
49
|
-
const doc = docs as {
|
|
50
|
-
props?: {name: string; type: string}[];
|
|
51
|
-
components?: {props?: {name: string; type: string}[]}[];
|
|
52
|
-
};
|
|
53
|
-
const propsList = (doc.props || doc.components?.[0]?.props) as {
|
|
54
|
-
name: string;
|
|
55
|
-
type: string;
|
|
56
|
-
}[];
|
|
57
|
-
expect(
|
|
58
|
-
propsList,
|
|
59
|
-
`${name}.doc.mjs must expose a props array`,
|
|
60
|
-
).toBeDefined();
|
|
61
|
-
const widthProp = propsList.find(p => p.name === 'width');
|
|
62
|
-
expect(
|
|
63
|
-
widthProp,
|
|
64
|
-
`${name}.doc.mjs must document the width prop`,
|
|
65
|
-
).toBeDefined();
|
|
66
|
-
expect(widthProp?.type).toBe('SizeValue');
|
|
67
|
-
});
|
|
68
|
-
});
|
|
69
|
-
});
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
-
|
|
3
|
-
import {describe, it, expect} from 'vitest';
|
|
4
|
-
|
|
5
|
-
import {docs as CodeBlockDocs} from '../CodeBlock/CodeBlock.doc.mjs';
|
|
6
|
-
import {docs as StackDocs} from '../Stack/Stack.doc.mjs';
|
|
7
|
-
import {docs as ToolbarDocs} from '../Toolbar/Toolbar.doc.mjs';
|
|
8
|
-
|
|
9
|
-
// `docs` is the ComponentDoc union (single vs multi component); narrow it
|
|
10
|
-
// for structural access to either the top-level props or the first
|
|
11
|
-
// sub-component's props.
|
|
12
|
-
function getProps(docs: unknown): {name: string}[] {
|
|
13
|
-
const doc = docs as {
|
|
14
|
-
props?: {name: string}[];
|
|
15
|
-
components?: {props?: {name: string}[]}[];
|
|
16
|
-
};
|
|
17
|
-
return doc.props || doc.components?.[0]?.props || [];
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
describe('Structural Component API Contract Drift (#4163)', () => {
|
|
21
|
-
it('documents CodeBlock highlightMode prop', () => {
|
|
22
|
-
const props = getProps(CodeBlockDocs).map(p => p.name);
|
|
23
|
-
expect(props).toContain('highlightMode');
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it('documents Toolbar dividers prop', () => {
|
|
27
|
-
const props = getProps(ToolbarDocs).map(p => p.name);
|
|
28
|
-
expect(props).toContain('dividers');
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it('documents Stack direction and base props', () => {
|
|
32
|
-
const props = getProps(StackDocs).map(p => p.name);
|
|
33
|
-
expect(props).toContain('direction');
|
|
34
|
-
expect(props).toContain('gap');
|
|
35
|
-
expect(props).toContain('padding');
|
|
36
|
-
expect(props).toContain('width');
|
|
37
|
-
expect(props).toContain('height');
|
|
38
|
-
});
|
|
39
|
-
});
|