@astryxdesign/core 0.7.2-canary.caec7ca → 0.7.2-canary.d150f78
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/CheckboxInput/CheckboxInput.d.ts +1 -1
- package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
- package/dist/CheckboxInput/CheckboxInput.js +3 -1
- package/dist/Dialog/Dialog.d.ts.map +1 -1
- package/dist/Dialog/Dialog.js +20 -8
- package/dist/List/List.d.ts +46 -19
- package/dist/List/List.d.ts.map +1 -1
- package/dist/List/List.js +28 -10
- package/dist/List/ListContext.d.ts +8 -3
- package/dist/List/ListContext.d.ts.map +1 -1
- package/dist/List/ListContext.js +7 -2
- package/dist/List/ListItem.d.ts +4 -3
- package/dist/List/ListItem.d.ts.map +1 -1
- package/dist/List/ListItem.js +19 -8
- package/dist/List/index.d.ts +1 -1
- package/dist/List/index.d.ts.map +1 -1
- package/dist/Markdown/Markdown.d.ts.map +1 -1
- package/dist/Markdown/Markdown.js +8 -1
- package/dist/astryx.css +5 -2
- package/dist/utils/plainDate.d.ts.map +1 -1
- package/dist/utils/plainDate.js +15 -9
- package/package.json +3 -3
- package/src/CheckboxInput/CheckboxInput.test.tsx +34 -0
- package/src/CheckboxInput/CheckboxInput.tsx +3 -1
- package/src/Dialog/Dialog.test.tsx +46 -0
- package/src/Dialog/Dialog.tsx +20 -7
- package/src/Dialog/__tests__/Dialog.a11y.known-failures.ts +0 -14
- package/src/Item/Item.doc.mjs +1 -1
- package/src/Item/Item.tsx +1 -1
- package/src/List/List.doc.mjs +14 -1
- package/src/List/List.spec.md +129 -77
- package/src/List/List.test.tsx +144 -94
- package/src/List/List.tsx +74 -18
- package/src/List/ListContext.tsx +9 -3
- package/src/List/ListItem.tsx +46 -26
- package/src/List/index.ts +1 -1
- package/src/Markdown/Markdown.tsx +14 -1
- package/src/TabList/TabList.spec.md +57 -44
- package/src/theme/derivedVarRegistry.test.ts +3 -0
- package/src/utils/plainDate.test.ts +37 -1
- package/src/utils/plainDate.ts +15 -5
package/dist/utils/plainDate.js
CHANGED
|
@@ -202,15 +202,21 @@ export function plainDateSetEndOfWeekExclusive(pd, weekStartsOn) {
|
|
|
202
202
|
return plainDateAddDays(plainDateSetStartOfWeek(pd, weekStartsOn), 7);
|
|
203
203
|
}
|
|
204
204
|
export function plainDateGetWeekNumber(pd) {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
205
|
+
// ISO week number: ceil(day-of-year of the week's Thursday / 7). Computed
|
|
206
|
+
// entirely on UTC anchors — the same DST immunity plainDateDiffDays relies
|
|
207
|
+
// on — because mixing local-time instants leaks the host zone's DST offset
|
|
208
|
+
// into the day count. When Jan 1 is a Friday (so every Thursday's day-of-year
|
|
209
|
+
// is an exact multiple of 7), a one-hour offset from a zone whose DST ends
|
|
210
|
+
// after New Year rounds a whole week up: Sydney, Auckland and Santiago users
|
|
211
|
+
// saw week 15 for the week that is ISO week 14. utcTime keeps years 1-99
|
|
212
|
+
// as given, like the other Calendar date helpers.
|
|
213
|
+
const msPerDay = 24 * 60 * 60 * 1000;
|
|
214
|
+
const utcMidnight = utcTime(pd.year, pd.month - 1, pd.day);
|
|
215
|
+
const dayNum = new Date(utcMidnight).getUTCDay() || 7;
|
|
216
|
+
const thursday = utcMidnight + (4 - dayNum) * msPerDay;
|
|
217
|
+
const yearStart = utcTime(new Date(thursday).getUTCFullYear(), 0, 1);
|
|
218
|
+
const dayOfYear = (thursday - yearStart) / msPerDay + 1;
|
|
219
|
+
return Math.ceil(dayOfYear / 7);
|
|
214
220
|
}
|
|
215
221
|
|
|
216
222
|
// e.g. "Wednesday, May 21, 2026" (locale-dependent)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astryxdesign/core",
|
|
3
|
-
"version": "0.7.2-canary.
|
|
3
|
+
"version": "0.7.2-canary.d150f78",
|
|
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",
|
|
@@ -792,8 +792,8 @@
|
|
|
792
792
|
"react-dom": ">=19.0.0"
|
|
793
793
|
},
|
|
794
794
|
"devDependencies": {
|
|
795
|
-
"@astryxdesign/a11y-spec": "0.7.2-canary.
|
|
796
|
-
"@astryxdesign/cli": "0.7.2-canary.
|
|
795
|
+
"@astryxdesign/a11y-spec": "0.7.2-canary.d150f78",
|
|
796
|
+
"@astryxdesign/cli": "0.7.2-canary.d150f78",
|
|
797
797
|
"@babel/cli": "^7.29.7",
|
|
798
798
|
"@babel/core": "^7.29.7",
|
|
799
799
|
"@babel/preset-react": "^7.29.7",
|
|
@@ -209,6 +209,40 @@ describe('CheckboxInput', () => {
|
|
|
209
209
|
);
|
|
210
210
|
});
|
|
211
211
|
|
|
212
|
+
it('preserves a caller-provided id on the input and its label', () => {
|
|
213
|
+
render(
|
|
214
|
+
<CheckboxInput
|
|
215
|
+
id="accept-terms"
|
|
216
|
+
label="Accept terms"
|
|
217
|
+
value={false}
|
|
218
|
+
onChange={() => {}}
|
|
219
|
+
/>,
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
const checkbox = screen.getByRole('checkbox');
|
|
223
|
+
expect(checkbox).toHaveAttribute('id', 'accept-terms');
|
|
224
|
+
expect(screen.getByText('Accept terms').closest('label')).toHaveAttribute(
|
|
225
|
+
'for',
|
|
226
|
+
'accept-terms',
|
|
227
|
+
);
|
|
228
|
+
expect(screen.getByLabelText('Accept terms')).toBe(checkbox);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('generates an id the label resolves when the caller passes an empty id', () => {
|
|
232
|
+
render(
|
|
233
|
+
<CheckboxInput
|
|
234
|
+
id=""
|
|
235
|
+
label="Accept terms"
|
|
236
|
+
value={false}
|
|
237
|
+
onChange={() => {}}
|
|
238
|
+
/>,
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
const checkbox = screen.getByRole('checkbox');
|
|
242
|
+
expect(checkbox.id).not.toBe('');
|
|
243
|
+
expect(screen.getByLabelText('Accept terms')).toBe(checkbox);
|
|
244
|
+
});
|
|
245
|
+
|
|
212
246
|
it('forwards arbitrary data-* attributes to the input', () => {
|
|
213
247
|
render(
|
|
214
248
|
<CheckboxInput
|
|
@@ -311,6 +311,7 @@ const dynamicWidthStyles = stylex.create({
|
|
|
311
311
|
* ```
|
|
312
312
|
*/
|
|
313
313
|
export function CheckboxInput({
|
|
314
|
+
id: idProp,
|
|
314
315
|
label,
|
|
315
316
|
isLabelHidden = false,
|
|
316
317
|
description,
|
|
@@ -340,7 +341,8 @@ export function CheckboxInput({
|
|
|
340
341
|
// The row is the pressable: the overlay's pressed arm reads the row's
|
|
341
342
|
// scope marker, the way the indicator's hover tint does.
|
|
342
343
|
const pressable = usePressFeedback();
|
|
343
|
-
const
|
|
344
|
+
const generatedId = useId();
|
|
345
|
+
const id = idProp || generatedId;
|
|
344
346
|
const descriptionID = useId();
|
|
345
347
|
const statusMessageID = useId();
|
|
346
348
|
// Announce the effective required state (form default included) while the
|
|
@@ -110,6 +110,52 @@ beforeEach(() => {
|
|
|
110
110
|
});
|
|
111
111
|
|
|
112
112
|
describe('Dialog', () => {
|
|
113
|
+
it('restores focus to the opener when content mounts in the open commit', () => {
|
|
114
|
+
function Harness({
|
|
115
|
+
open,
|
|
116
|
+
onOpenChange,
|
|
117
|
+
}: {
|
|
118
|
+
open: boolean;
|
|
119
|
+
onOpenChange: (v: boolean) => void;
|
|
120
|
+
}) {
|
|
121
|
+
return (
|
|
122
|
+
<>
|
|
123
|
+
<button type="button" onClick={() => onOpenChange(true)}>
|
|
124
|
+
Open
|
|
125
|
+
</button>
|
|
126
|
+
<Dialog isOpen={open} onOpenChange={onOpenChange}>
|
|
127
|
+
{open ? (
|
|
128
|
+
<div>
|
|
129
|
+
<DialogHeader title="Review" />
|
|
130
|
+
<p>Body</p>
|
|
131
|
+
</div>
|
|
132
|
+
) : null}
|
|
133
|
+
</Dialog>
|
|
134
|
+
</>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let open = false;
|
|
139
|
+
const setOpen = (v: boolean) => {
|
|
140
|
+
open = v;
|
|
141
|
+
};
|
|
142
|
+
const harness = render(<Harness open={open} onOpenChange={setOpen} />);
|
|
143
|
+
const opener = screen.getByRole('button', {name: 'Open'});
|
|
144
|
+
opener.focus();
|
|
145
|
+
|
|
146
|
+
fireEvent.click(opener);
|
|
147
|
+
harness.rerender(<Harness open={open} onOpenChange={setOpen} />);
|
|
148
|
+
|
|
149
|
+
// The freshly mounted header title is the focused screen reader anchor
|
|
150
|
+
// while the dialog is open...
|
|
151
|
+
expect(document.activeElement).toBe(screen.getByText('Review'));
|
|
152
|
+
|
|
153
|
+
// ...but closing restores the element that opened the dialog, not the
|
|
154
|
+
// (now unmounted) title.
|
|
155
|
+
harness.rerender(<Harness open={false} onOpenChange={setOpen} />);
|
|
156
|
+
expect(document.activeElement).toBe(opener);
|
|
157
|
+
});
|
|
158
|
+
|
|
113
159
|
it('renders when isOpen is true', () => {
|
|
114
160
|
render(
|
|
115
161
|
<Dialog isOpen={true} onOpenChange={() => {}}>
|
package/src/Dialog/Dialog.tsx
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
useCallback,
|
|
33
33
|
useEffect,
|
|
34
34
|
useId,
|
|
35
|
+
useLayoutEffect,
|
|
35
36
|
useMemo,
|
|
36
37
|
useRef,
|
|
37
38
|
type ReactNode,
|
|
@@ -505,12 +506,26 @@ export function Dialog({
|
|
|
505
506
|
|
|
506
507
|
const mergedDialogRef = useMergedRefs(ref, attachDialog);
|
|
507
508
|
|
|
508
|
-
// Capture the element that was focused when the dialog opened,
|
|
509
|
-
//
|
|
509
|
+
// Capture the element that was focused when the dialog opened, for
|
|
510
|
+
// directional animation origin and focus restoration on close.
|
|
510
511
|
const triggerElementRef = useRef<HTMLElement | null>(null);
|
|
511
512
|
|
|
512
513
|
useInteractionModalityTracking();
|
|
513
514
|
|
|
515
|
+
// This runs in a layout effect so the capture lands before any descendant's
|
|
516
|
+
// passive mount effect: all layout effects run before any passive effect,
|
|
517
|
+
// while a child's passive effect runs before this parent's passive effect.
|
|
518
|
+
// Content that mounts in the open commit — e.g. a DialogHeader focusing its
|
|
519
|
+
// own title on mount — would otherwise move document.activeElement inside
|
|
520
|
+
// the dialog before the capture ran, and closing would then restore focus
|
|
521
|
+
// to a detached node, stranding the user's focus on <body>.
|
|
522
|
+
useLayoutEffect(() => {
|
|
523
|
+
if (isInline || !isOpen) {
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
triggerElementRef.current = document.activeElement as HTMLElement | null;
|
|
527
|
+
}, [isOpen, isInline]);
|
|
528
|
+
|
|
514
529
|
// Derive dismissal behavior from purpose
|
|
515
530
|
const allowEscape = purpose !== 'required';
|
|
516
531
|
const allowBackdropClick = purpose === 'info';
|
|
@@ -526,11 +541,9 @@ export function Dialog({
|
|
|
526
541
|
}
|
|
527
542
|
|
|
528
543
|
if (isOpen) {
|
|
529
|
-
//
|
|
530
|
-
//
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
// Set directional CSS custom properties before opening
|
|
544
|
+
// Set directional CSS custom properties before opening. The trigger was
|
|
545
|
+
// captured by the layout effect above, before any descendant effect
|
|
546
|
+
// could move focus.
|
|
534
547
|
const trigger = triggerElementRef.current;
|
|
535
548
|
if (trigger && trigger !== document.body) {
|
|
536
549
|
const dir = getDialogDirection(trigger);
|
|
@@ -25,18 +25,4 @@ export const DIALOG_MODAL_KNOWN_FAILURES: ReadonlyArray<KnownFailure> = [
|
|
|
25
25
|
reason:
|
|
26
26
|
'This migration records the existing focus-order violation without changing the overlapping Dialog implementation path.',
|
|
27
27
|
},
|
|
28
|
-
{
|
|
29
|
-
expectation: 'modal-dialog.focus.restored',
|
|
30
|
-
binding: 'Dialog',
|
|
31
|
-
state: 'conditional-content-focus-restoration',
|
|
32
|
-
evidenceLayer: 'real-browser',
|
|
33
|
-
failureEquals:
|
|
34
|
-
'closing the modal dialog did not return focus to its still-available invoker',
|
|
35
|
-
standardsReference:
|
|
36
|
-
'Astryx component:Dialog FR4 — closing a native Dialog must return focus to its still-connected invoker when it can receive focus.',
|
|
37
|
-
userImpact:
|
|
38
|
-
'When focus-moving content mounts only while the dialog is open, closing can leave the user at the document body instead of the control that opened the task.',
|
|
39
|
-
reason:
|
|
40
|
-
'This migration records the existing restoration violation without changing the overlapping Dialog implementation path.',
|
|
41
|
-
},
|
|
42
28
|
];
|
package/src/Item/Item.doc.mjs
CHANGED
|
@@ -49,7 +49,7 @@ export const docs = {
|
|
|
49
49
|
{
|
|
50
50
|
name: '--_item-inset-inline',
|
|
51
51
|
description:
|
|
52
|
-
'Inline inset of the row. Item derives its paddingInline from this variable, and List reads it to
|
|
52
|
+
'Inline inset of the row. Item derives its paddingInline from this variable, and List reads it to place rows for its edge treatments (edge="flush", "outset", and "bleed"). Set paddingInline on `item` in a theme and both stay in sync.',
|
|
53
53
|
default: 'var(--spacing-2) (var(--spacing-3) for density="spacious")',
|
|
54
54
|
private: true,
|
|
55
55
|
},
|
package/src/Item/Item.tsx
CHANGED
|
@@ -342,7 +342,7 @@ const styles = stylex.create({
|
|
|
342
342
|
gap: spacingVars['--spacing-2'],
|
|
343
343
|
// The inline inset is published as --_item-inset-inline and the padding
|
|
344
344
|
// derives from it, so consumers that need to compensate for the inset
|
|
345
|
-
// (List's
|
|
345
|
+
// (List's edge treatments) read the var instead of mirroring the values.
|
|
346
346
|
// Themes that set paddingInline on `item` also feed this var via the
|
|
347
347
|
// derived var registry, keeping padding and compensation in sync.
|
|
348
348
|
'--_item-inset-inline': spacingVars['--spacing-2'],
|
package/src/List/List.doc.mjs
CHANGED
|
@@ -41,11 +41,18 @@ export const docs = {
|
|
|
41
41
|
description: 'Show dividers between items.',
|
|
42
42
|
default: 'false',
|
|
43
43
|
},
|
|
44
|
+
{
|
|
45
|
+
name: 'edge',
|
|
46
|
+
type: "'padded' | 'flush' | 'outset' | 'bleed'",
|
|
47
|
+
description:
|
|
48
|
+
"Where each row's text, dividers, and hover and selection backgrounds sit, relative to the list's edges and the container around it. 'padded' keeps text inside each row's padding, so it starts a little in from a heading above the list and backgrounds have room around it. 'flush' drops the rows' side padding: text starts on the heading's line, and dividers and backgrounds stop at the row's content, with no room beyond it on either side. It works anywhere and suits static rows. 'outset' puts text on the heading's line and reaches dividers and backgrounds a little past it into the container's padding; it suits interactive rows in a container. 'bleed' puts text on the heading's line and reaches dividers and backgrounds to the container's edges with square corners, like Table rows in a Card. A row's first content is what lines up with the heading: its marker, icon, or avatar when it has one, otherwise its label. 'outset' and 'bleed' reach into the padding of the nearest container that shares its padding: a Card, Section, Dialog, or BottomSheet, or a transparent container such as a Layout region with explicit padding or a padded ScrollableArea. Place the List directly in that container's padding: padding that Stack, Center, or your own styles add in between is not counted. Drawers and the automatic padding of LayoutContent and LayoutPanel aren't supported yet. With no such container, including in a Popover, they look like 'padded'. In a container with less padding than the rows' own, text starts a little after the heading's line. A theme's inline padding on `item` replaces the row padding of every value, including the zero padding of flush.",
|
|
49
|
+
default: "'padded'",
|
|
50
|
+
},
|
|
44
51
|
{
|
|
45
52
|
name: 'edgeCompensation',
|
|
46
53
|
type: "'inline'",
|
|
47
54
|
description:
|
|
48
|
-
"
|
|
55
|
+
"Deprecated: use edge=\"outset\", which this sets. Still works; edge wins when both are set. `astryx upgrade` rewrites existing usage.",
|
|
49
56
|
},
|
|
50
57
|
{
|
|
51
58
|
name: 'header',
|
|
@@ -89,6 +96,8 @@ export const docs = {
|
|
|
89
96
|
bestPractices: [
|
|
90
97
|
{ guidance: true, description: 'Provide a header to label the list and give context to screen readers.' },
|
|
91
98
|
{ guidance: true, description: 'Use start and end content slots to add icons, avatars, or badges to each item.' },
|
|
99
|
+
{ guidance: true, description: 'Set edge to line row text up with a heading above the list: flush for static rows, outset or bleed for rows in a Card, Section, or Dialog.' },
|
|
100
|
+
{ guidance: true, description: 'To line text up with a heading and keep room around the highlight where there is no Card, such as a sidebar, put the heading and the list in a Section with variant="transparent" and set edge="outset".' },
|
|
92
101
|
{ guidance: false, description: 'Place interactive elements inside an interactive list item; it creates nested click targets and confusing focus behavior.' },
|
|
93
102
|
{ guidance: false, description: 'Use a list for a single item or for laying out unrelated content; lists imply a meaningful collection.' },
|
|
94
103
|
{ guidance: false, description: 'Mix clickable and non-clickable items in the same list without clear visual distinction.' },
|
|
@@ -109,6 +118,8 @@ export const docsZh = {
|
|
|
109
118
|
bestPractices: [
|
|
110
119
|
{ guidance: true, description: 'Provide a header to label the list and give context to screen readers.' },
|
|
111
120
|
{ guidance: true, description: 'Use start and end content slots to add icons, avatars, or badges to each item.' },
|
|
121
|
+
{ guidance: true, description: 'Set edge to line row text up with a heading above the list: flush for static rows, outset or bleed for rows in a Card, Section, or Dialog.' },
|
|
122
|
+
{ guidance: true, description: 'To line text up with a heading and keep room around the highlight where there is no Card, such as a sidebar, put the heading and the list in a Section with variant="transparent" and set edge="outset".' },
|
|
112
123
|
{ guidance: false, description: 'Place interactive elements inside an interactive list item; it creates nested click targets and confusing focus behavior.' },
|
|
113
124
|
{ guidance: false, description: 'Use a list for a single item or for laying out unrelated content; lists imply a meaningful collection.' },
|
|
114
125
|
{ guidance: false, description: 'Mix clickable and non-clickable items in the same list without clear visual distinction.' },
|
|
@@ -132,6 +143,8 @@ export const docsDense = {
|
|
|
132
143
|
bestPractices: [
|
|
133
144
|
{ guidance: true, description: 'Provide a header to label the list and give context to screen readers.' },
|
|
134
145
|
{ guidance: true, description: 'Use start and end content slots to add icons, avatars, or badges to each item.' },
|
|
146
|
+
{ guidance: true, description: 'Set edge to line row text up with a heading above the list: flush for static rows, outset or bleed for rows in a Card, Section, or Dialog.' },
|
|
147
|
+
{ guidance: true, description: 'To line text up with a heading and keep room around the highlight where there is no Card, such as a sidebar, put the heading and the list in a Section with variant="transparent" and set edge="outset".' },
|
|
135
148
|
{ guidance: false, description: 'Place interactive elements inside an interactive list item; it creates nested click targets and confusing focus behavior.' },
|
|
136
149
|
{ guidance: false, description: 'Use a list for a single item or for laying out unrelated content; lists imply a meaningful collection.' },
|
|
137
150
|
{ guidance: false, description: 'Mix clickable and non-clickable items in the same list without clear visual distinction.' },
|