@astryxdesign/core 0.4.4 → 0.4.5-canary.1dc3e5d
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/CHANGELOG.md +39 -0
- package/dist/BottomSheet/BottomSheet.d.ts +11 -2
- package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheet.js +4 -0
- package/dist/BottomSheet/BottomSheetPanel.d.ts +5 -2
- package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheetPanel.js +56 -13
- package/dist/BottomSheet/index.d.ts +1 -1
- package/dist/BottomSheet/index.d.ts.map +1 -1
- package/dist/BottomSheet/snapOffsets.d.ts +57 -20
- package/dist/BottomSheet/snapOffsets.d.ts.map +1 -1
- package/dist/BottomSheet/snapOffsets.js +106 -30
- package/dist/BottomSheet/useSheetGestures.d.ts +13 -8
- package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -1
- package/dist/BottomSheet/useSheetGestures.js +160 -64
- package/dist/Calendar/Calendar.d.ts +17 -0
- package/dist/Calendar/Calendar.d.ts.map +1 -1
- package/dist/Calendar/Calendar.js +34 -3
- package/dist/Calendar/getStandaloneShortWeekdayNames.d.ts +14 -0
- package/dist/Calendar/getStandaloneShortWeekdayNames.d.ts.map +1 -0
- package/dist/Calendar/getStandaloneShortWeekdayNames.js +26 -0
- package/dist/Calendar/hooks/useCalendarConstraints.d.ts +22 -1
- package/dist/Calendar/hooks/useCalendarConstraints.d.ts.map +1 -1
- package/dist/Calendar/hooks/useCalendarConstraints.js +26 -4
- package/dist/Calendar/hooks/useCalendarDays.d.ts.map +1 -1
- package/dist/Calendar/hooks/useCalendarDays.js +14 -10
- package/dist/Calendar/standaloneShortWeekdayNames.generated.d.ts +47 -0
- package/dist/Calendar/standaloneShortWeekdayNames.generated.d.ts.map +1 -0
- package/dist/Calendar/standaloneShortWeekdayNames.generated.js +40 -0
- package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
- package/dist/CheckboxInput/CheckboxInput.js +9 -0
- package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
- package/dist/ComplexSelector/ComplexSelector.js +6 -1
- package/dist/DateInput/DateInput.d.ts.map +1 -1
- package/dist/DateInput/DateInput.js +6 -1
- package/dist/DateRangeInput/DateRangeInput.d.ts +23 -1
- package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
- package/dist/DateRangeInput/DateRangeInput.js +38 -3
- package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
- package/dist/DateTimeInput/DateTimeInput.js +7 -2
- package/dist/Field/FieldLabel.d.ts +2 -1
- package/dist/Field/FieldLabel.d.ts.map +1 -1
- package/dist/Field/FieldLabel.js +21 -3
- package/dist/Field/InputClearButton.d.ts +2 -1
- package/dist/Field/InputClearButton.d.ts.map +1 -1
- package/dist/Field/InputClearButton.js +6 -1
- package/dist/FormLayout/FormLayout.d.ts +24 -2
- package/dist/FormLayout/FormLayout.d.ts.map +1 -1
- package/dist/FormLayout/FormLayout.js +7 -2
- package/dist/FormLayout/FormLayoutContext.d.ts +15 -2
- package/dist/FormLayout/FormLayoutContext.d.ts.map +1 -1
- package/dist/FormLayout/FormLayoutContext.js +19 -4
- package/dist/FormLayout/index.d.ts +1 -1
- package/dist/FormLayout/index.d.ts.map +1 -1
- package/dist/Markdown/Markdown.d.ts +8 -0
- package/dist/Markdown/Markdown.d.ts.map +1 -1
- package/dist/Markdown/Markdown.js +31 -3
- package/dist/Markdown/parser.d.ts +14 -2
- package/dist/Markdown/parser.d.ts.map +1 -1
- package/dist/Markdown/parser.js +50 -2
- package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
- package/dist/MultiSelector/MultiSelector.js +6 -1
- package/dist/NumberInput/NumberInput.d.ts.map +1 -1
- package/dist/NumberInput/NumberInput.js +6 -1
- package/dist/Outline/parseOutlineFromMarkdown.d.ts +2 -0
- package/dist/Outline/parseOutlineFromMarkdown.d.ts.map +1 -1
- package/dist/Outline/parseOutlineFromMarkdown.js +5 -31
- package/dist/RadioList/RadioList.d.ts.map +1 -1
- package/dist/RadioList/RadioList.js +12 -1
- package/dist/Selector/Selector.d.ts.map +1 -1
- package/dist/Selector/Selector.js +6 -1
- package/dist/StatusDot/StatusDot.d.ts +43 -3
- package/dist/StatusDot/StatusDot.d.ts.map +1 -1
- package/dist/StatusDot/StatusDot.js +42 -5
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +9 -0
- package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
- package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +7 -2
- package/dist/TextArea/TextArea.d.ts.map +1 -1
- package/dist/TextArea/TextArea.js +6 -1
- package/dist/TextInput/TextInput.d.ts.map +1 -1
- package/dist/TextInput/TextInput.js +6 -1
- package/dist/TimeInput/TimeInput.d.ts.map +1 -1
- package/dist/TimeInput/TimeInput.js +6 -1
- package/dist/hooks/useResolvedRequired.d.ts +19 -0
- package/dist/hooks/useResolvedRequired.d.ts.map +1 -0
- package/dist/hooks/useResolvedRequired.js +40 -0
- package/dist/utils/plainDate.d.ts +6 -0
- package/dist/utils/plainDate.d.ts.map +1 -1
- package/dist/utils/plainDate.js +12 -0
- package/locales/af-ZA.json +958 -0
- package/locales/ar-SA.json +998 -0
- package/locales/ca-ES.json +974 -0
- package/locales/cs-CZ.json +990 -0
- package/locales/da-DK.json +966 -0
- package/locales/de-DE.json +966 -0
- package/locales/el-GR.json +990 -0
- package/locales/en.json +4 -0
- package/locales/es-ES.json +978 -0
- package/locales/fi-FI.json +990 -0
- package/locales/fr-FR.json +928 -0
- package/locales/he-IL.json +990 -0
- package/locales/hu-HU.json +990 -0
- package/locales/it-IT.json +978 -0
- package/locales/ja-JP.json +998 -0
- package/locales/ko-KR.json +990 -0
- package/locales/nl-NL.json +954 -0
- package/locales/no-NO.json +978 -0
- package/locales/pl-PL.json +982 -0
- package/locales/pseudo.json +3 -0
- package/locales/pt-BR.json +982 -0
- package/locales/pt-PT.json +982 -0
- package/locales/ro-RO.json +982 -0
- package/locales/ru-RU.json +990 -0
- package/locales/sr-SP.json +990 -0
- package/locales/sv-SE.json +986 -0
- package/locales/tr-TR.json +990 -0
- package/locales/uk-UA.json +990 -0
- package/locales/vi-VN.json +986 -0
- package/locales/zh-CN.json +998 -0
- package/locales/zh-TW.json +998 -0
- package/package.json +3 -3
- package/src/BottomSheet/BottomSheet.doc.mjs +36 -3
- package/src/BottomSheet/BottomSheet.test.tsx +153 -2
- package/src/BottomSheet/BottomSheet.tsx +16 -2
- package/src/BottomSheet/BottomSheetPanel.tsx +67 -13
- package/src/BottomSheet/index.ts +5 -1
- package/src/BottomSheet/snapOffsets.test.ts +114 -47
- package/src/BottomSheet/snapOffsets.ts +119 -33
- package/src/BottomSheet/useSheetGestures.test.ts +113 -15
- package/src/BottomSheet/useSheetGestures.ts +196 -100
- package/src/Calendar/Calendar.doc.mjs +16 -0
- package/src/Calendar/Calendar.test.tsx +137 -0
- package/src/Calendar/Calendar.tsx +65 -2
- package/src/Calendar/getStandaloneShortWeekdayNames.test.ts +54 -0
- package/src/Calendar/getStandaloneShortWeekdayNames.ts +38 -0
- package/src/Calendar/hooks/useCalendarConstraints.ts +54 -3
- package/src/Calendar/hooks/useCalendarDays.ts +13 -10
- package/src/Calendar/standaloneShortWeekdayNames.generated.ts +50 -0
- package/src/CheckboxInput/CheckboxInput.tsx +6 -0
- package/src/ComplexSelector/ComplexSelector.tsx +3 -1
- package/src/DateInput/DateInput.tsx +3 -1
- package/src/DateRangeInput/DateRangeInput.doc.mjs +16 -0
- package/src/DateRangeInput/DateRangeInput.test.tsx +81 -1
- package/src/DateRangeInput/DateRangeInput.tsx +72 -1
- package/src/DateTimeInput/DateTimeInput.tsx +4 -2
- package/src/Field/Field.doc.mjs +1 -0
- package/src/Field/FieldLabel.tsx +20 -4
- package/src/Field/InputClearButton.test.tsx +25 -0
- package/src/Field/InputClearButton.tsx +4 -1
- package/src/FormLayout/FormLayout.doc.mjs +13 -0
- package/src/FormLayout/FormLayout.test.tsx +181 -4
- package/src/FormLayout/FormLayout.tsx +33 -2
- package/src/FormLayout/FormLayoutContext.ts +22 -7
- package/src/FormLayout/index.ts +1 -1
- package/src/Markdown/Markdown.doc.mjs +3 -0
- package/src/Markdown/Markdown.test.tsx +78 -0
- package/src/Markdown/Markdown.tsx +41 -1
- package/src/Markdown/parser.ts +60 -2
- package/src/MultiSelector/MultiSelector.tsx +3 -1
- package/src/NumberInput/NumberInput.tsx +3 -1
- package/src/Outline/Outline.doc.mjs +2 -0
- package/src/Outline/parseOutlineFromMarkdown.ts +10 -40
- package/src/RadioList/RadioList.tsx +9 -1
- package/src/Selector/Selector.tsx +3 -1
- package/src/StatusDot/StatusDot.doc.mjs +13 -0
- package/src/StatusDot/StatusDot.test.tsx +115 -8
- package/src/StatusDot/StatusDot.tsx +74 -9
- package/src/Switch/Switch.tsx +6 -0
- package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +20 -2
- package/src/TextArea/TextArea.tsx +3 -1
- package/src/TextInput/TextInput.tsx +3 -1
- package/src/TimeInput/TimeInput.tsx +3 -1
- package/src/hooks/useResolvedRequired.ts +42 -0
- package/src/utils/plainDate.test.ts +50 -0
- package/src/utils/plainDate.ts +12 -0
|
@@ -2,8 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
import {describe, it, expect} from 'vitest';
|
|
4
4
|
import {render, screen} from '@testing-library/react';
|
|
5
|
+
import * as stylex from '@stylexjs/stylex';
|
|
6
|
+
import {colorVars} from '../theme/tokens.stylex';
|
|
5
7
|
import {StatusDot} from './StatusDot';
|
|
6
8
|
|
|
9
|
+
/** Renders a StatusDot and returns its root element. */
|
|
10
|
+
function renderDot(props: React.ComponentProps<typeof StatusDot>) {
|
|
11
|
+
render(<StatusDot {...props} />);
|
|
12
|
+
return screen.getByRole('img', {name: props.label});
|
|
13
|
+
}
|
|
14
|
+
|
|
7
15
|
describe('StatusDot', () => {
|
|
8
16
|
it('renders with role="img" and aria-label', () => {
|
|
9
17
|
render(<StatusDot variant="success" label="Online" />);
|
|
@@ -27,9 +35,7 @@ describe('StatusDot', () => {
|
|
|
27
35
|
] as const;
|
|
28
36
|
|
|
29
37
|
for (const variant of variants) {
|
|
30
|
-
const {unmount} = render(
|
|
31
|
-
<StatusDot variant={variant} label={variant} />,
|
|
32
|
-
);
|
|
38
|
+
const {unmount} = render(<StatusDot variant={variant} label={variant} />);
|
|
33
39
|
expect(screen.getByRole('img', {name: variant})).toBeInTheDocument();
|
|
34
40
|
unmount();
|
|
35
41
|
}
|
|
@@ -49,11 +55,7 @@ describe('StatusDot', () => {
|
|
|
49
55
|
|
|
50
56
|
it('supports data-testid', () => {
|
|
51
57
|
render(
|
|
52
|
-
<StatusDot
|
|
53
|
-
variant="success"
|
|
54
|
-
label="Online"
|
|
55
|
-
data-testid="status-dot"
|
|
56
|
-
/>,
|
|
58
|
+
<StatusDot variant="success" label="Online" data-testid="status-dot" />,
|
|
57
59
|
);
|
|
58
60
|
expect(screen.getByTestId('status-dot')).toBeInTheDocument();
|
|
59
61
|
});
|
|
@@ -81,4 +83,109 @@ describe('StatusDot', () => {
|
|
|
81
83
|
const dot = screen.getByRole('img', {name: 'Online'});
|
|
82
84
|
expect(dot).toBeInTheDocument();
|
|
83
85
|
});
|
|
86
|
+
|
|
87
|
+
it('renders every variant as a plain childless dot by default (design review #4373)', () => {
|
|
88
|
+
// The dot is deliberately a colour-only signal by default — no built-in
|
|
89
|
+
// per-variant glyph. Making the status accessible in context (label,
|
|
90
|
+
// icon, or an accessible alternative) is the builder's responsibility;
|
|
91
|
+
// see the usage guidance.
|
|
92
|
+
const variants = [
|
|
93
|
+
'success',
|
|
94
|
+
'warning',
|
|
95
|
+
'error',
|
|
96
|
+
'accent',
|
|
97
|
+
'neutral',
|
|
98
|
+
] as const;
|
|
99
|
+
for (const variant of variants) {
|
|
100
|
+
const dot = renderDot({variant, label: `plain-${variant}`});
|
|
101
|
+
expect(dot.childElementCount, variant).toBe(0);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('custom icon (parity with AvatarStatusDot)', () => {
|
|
106
|
+
const ICON_TESTID = 'custom-icon';
|
|
107
|
+
function Icon() {
|
|
108
|
+
return <svg data-testid={ICON_TESTID} />;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
it('renders a provided icon inside the dot', () => {
|
|
112
|
+
renderDot({variant: 'success', label: 'Verified', icon: <Icon />});
|
|
113
|
+
expect(screen.getByTestId(ICON_TESTID)).toBeInTheDocument();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('hides the icon wrapper from assistive tech (the label carries the status)', () => {
|
|
117
|
+
renderDot({variant: 'success', label: 'Verified', icon: <Icon />});
|
|
118
|
+
const iconEl = screen.getByTestId(ICON_TESTID);
|
|
119
|
+
expect(iconEl.closest('[aria-hidden="true"]')).not.toBeNull();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it.each([false, true, null, undefined, ''])(
|
|
123
|
+
'ignores %s and keeps the plain dot (safe for `cond && <Icon />`)',
|
|
124
|
+
value => {
|
|
125
|
+
const dot = renderDot({
|
|
126
|
+
variant: 'error',
|
|
127
|
+
label: 'Offline',
|
|
128
|
+
icon: value,
|
|
129
|
+
});
|
|
130
|
+
expect(dot.childElementCount).toBe(0);
|
|
131
|
+
},
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
it('keeps the accessible name on the dot when an icon renders', () => {
|
|
135
|
+
const dot = renderDot({
|
|
136
|
+
variant: 'success',
|
|
137
|
+
label: 'Verified',
|
|
138
|
+
icon: <Icon />,
|
|
139
|
+
});
|
|
140
|
+
expect(dot).toHaveAttribute('role', 'img');
|
|
141
|
+
expect(dot).toHaveAttribute('aria-label', 'Verified');
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('variant ink (a passed icon paints from currentColor)', () => {
|
|
146
|
+
it('pairs the warning plate with the dedicated dark on-warning ink', () => {
|
|
147
|
+
// The regression this guards: a light surface ink on the yellow warning
|
|
148
|
+
// plate lands near 2:1, while `--color-on-warning` is the fixed dark
|
|
149
|
+
// ink (~9.6:1). An icon inherits the ink via `currentColor`, so the
|
|
150
|
+
// pairing is what keeps custom icons legible.
|
|
151
|
+
const probe = stylex.create({
|
|
152
|
+
warning: {
|
|
153
|
+
backgroundColor: colorVars['--color-warning'],
|
|
154
|
+
color: colorVars['--color-on-warning'],
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
const dot = renderDot({variant: 'warning', label: 'Degraded'});
|
|
158
|
+
const atomicClasses = (stylex.props(probe.warning).className ?? '')
|
|
159
|
+
.split(' ')
|
|
160
|
+
// Dev mode prepends a per-file `File__style.key` debug class that
|
|
161
|
+
// legitimately differs between the probe and the component.
|
|
162
|
+
.filter(cls => !cls.includes('__'));
|
|
163
|
+
expect(atomicClasses.length).toBeGreaterThan(0);
|
|
164
|
+
for (const cls of atomicClasses) {
|
|
165
|
+
expect(dot.className).toContain(cls);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe('accessible name (label reaches AT without hover)', () => {
|
|
171
|
+
it('exposes the status label as the accessible name for every variant, without a tooltip', () => {
|
|
172
|
+
const variants = [
|
|
173
|
+
'success',
|
|
174
|
+
'warning',
|
|
175
|
+
'error',
|
|
176
|
+
'accent',
|
|
177
|
+
'neutral',
|
|
178
|
+
] as const;
|
|
179
|
+
for (const variant of variants) {
|
|
180
|
+
const {unmount} = render(
|
|
181
|
+
<StatusDot variant={variant} label={`Status: ${variant}`} />,
|
|
182
|
+
);
|
|
183
|
+
const dot = screen.getByRole('img', {name: `Status: ${variant}`});
|
|
184
|
+
expect(dot).toHaveAttribute('aria-label', `Status: ${variant}`);
|
|
185
|
+
// The name must not depend on hover or focus.
|
|
186
|
+
expect(dot.getAttribute('tabindex')).toBeNull();
|
|
187
|
+
unmount();
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
});
|
|
84
191
|
});
|
|
@@ -16,11 +16,13 @@
|
|
|
16
16
|
* - /packages/cli/assets/templates/blocks/components/StatusDot/ (showcase blocks)
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
import type {ReactNode} from 'react';
|
|
20
|
+
|
|
19
21
|
import * as stylex from '@stylexjs/stylex';
|
|
20
22
|
import {colorVars} from '../theme/tokens.stylex';
|
|
21
23
|
import type {BaseProps} from '../BaseProps';
|
|
22
24
|
import {Tooltip} from '../Tooltip/Tooltip';
|
|
23
|
-
import {mergeProps} from '../utils';
|
|
25
|
+
import {isRenderable, mergeProps} from '../utils';
|
|
24
26
|
import {themeProps} from '../utils/themeProps';
|
|
25
27
|
import type {StatusDotVariantMap} from './index';
|
|
26
28
|
|
|
@@ -33,16 +35,21 @@ const pulseKeyframes = stylex.keyframes({
|
|
|
33
35
|
'100%': {opacity: 1},
|
|
34
36
|
});
|
|
35
37
|
|
|
38
|
+
/** Fixed dot size in px. A user-supplied icon is drawn into this same field. */
|
|
39
|
+
const DOT_SIZE = 8;
|
|
40
|
+
|
|
36
41
|
/**
|
|
37
42
|
* Base styles
|
|
38
43
|
*/
|
|
39
44
|
const styles = stylex.create({
|
|
40
45
|
base: {
|
|
41
|
-
display: 'inline-
|
|
46
|
+
display: 'inline-flex',
|
|
47
|
+
alignItems: 'center',
|
|
48
|
+
justifyContent: 'center',
|
|
42
49
|
borderRadius: '50%',
|
|
43
50
|
flexShrink: 0,
|
|
44
|
-
width:
|
|
45
|
-
height:
|
|
51
|
+
width: `${DOT_SIZE}px`,
|
|
52
|
+
height: `${DOT_SIZE}px`,
|
|
46
53
|
},
|
|
47
54
|
pulsing: {
|
|
48
55
|
animationName: pulseKeyframes,
|
|
@@ -55,26 +62,49 @@ const styles = stylex.create({
|
|
|
55
62
|
animationName: 'none',
|
|
56
63
|
},
|
|
57
64
|
},
|
|
65
|
+
// A user-supplied icon fills the whole 8px field and paints from
|
|
66
|
+
// `currentColor` (the variant's ink).
|
|
67
|
+
icon: {
|
|
68
|
+
display: 'flex',
|
|
69
|
+
alignItems: 'center',
|
|
70
|
+
justifyContent: 'center',
|
|
71
|
+
lineHeight: 0,
|
|
72
|
+
width: `${DOT_SIZE}px`,
|
|
73
|
+
height: `${DOT_SIZE}px`,
|
|
74
|
+
},
|
|
58
75
|
});
|
|
59
76
|
|
|
60
77
|
/**
|
|
61
|
-
* Variant styles mapping to theme color tokens
|
|
78
|
+
* Variant styles mapping to theme color tokens.
|
|
79
|
+
*
|
|
80
|
+
* Each variant sets both the plate colour and an ink colour: a user-supplied
|
|
81
|
+
* `icon` paints from `currentColor`, so plate and ink can never drift out of
|
|
82
|
+
* contrast (same contract as AvatarStatusDot). The ink is each plate's
|
|
83
|
+
* dedicated `--color-on-*` pairing (the Badge precedent), not the surface
|
|
84
|
+
* colour: on-warning is a fixed dark ink, which keeps an icon legible on the
|
|
85
|
+
* yellow plate (~9.6:1) where a light surface ink lands near 2:1. Neutral has
|
|
86
|
+
* no `--color-on-*` token; its mid-grey plate takes the surface ink.
|
|
62
87
|
*/
|
|
63
88
|
const variants = stylex.create({
|
|
64
89
|
success: {
|
|
65
90
|
backgroundColor: colorVars['--color-success'],
|
|
91
|
+
color: colorVars['--color-on-success'],
|
|
66
92
|
},
|
|
67
93
|
warning: {
|
|
68
94
|
backgroundColor: colorVars['--color-warning'],
|
|
95
|
+
color: colorVars['--color-on-warning'],
|
|
69
96
|
},
|
|
70
97
|
error: {
|
|
71
98
|
backgroundColor: colorVars['--color-error'],
|
|
99
|
+
color: colorVars['--color-on-error'],
|
|
72
100
|
},
|
|
73
101
|
accent: {
|
|
74
102
|
backgroundColor: colorVars['--color-accent'],
|
|
103
|
+
color: colorVars['--color-on-accent'],
|
|
75
104
|
},
|
|
76
105
|
neutral: {
|
|
77
106
|
backgroundColor: colorVars['--color-icon-secondary'],
|
|
107
|
+
color: colorVars['--color-background-surface'],
|
|
78
108
|
},
|
|
79
109
|
});
|
|
80
110
|
|
|
@@ -92,7 +122,8 @@ export interface StatusDotProps extends BaseProps<HTMLSpanElement> {
|
|
|
92
122
|
*/
|
|
93
123
|
variant: StatusDotVariant;
|
|
94
124
|
/**
|
|
95
|
-
* Accessible label describing the status. Required for a11y
|
|
125
|
+
* Accessible label describing the status. Required for a11y — it is the
|
|
126
|
+
* dot's `aria-label`, so the status reaches screen readers without hover.
|
|
96
127
|
*/
|
|
97
128
|
label: string;
|
|
98
129
|
/**
|
|
@@ -106,12 +137,36 @@ export interface StatusDotProps extends BaseProps<HTMLSpanElement> {
|
|
|
106
137
|
* When omitted, no tooltip is rendered.
|
|
107
138
|
*/
|
|
108
139
|
tooltip?: string;
|
|
140
|
+
/**
|
|
141
|
+
* Optional icon to render centered inside the dot. Accepts any ReactNode
|
|
142
|
+
* (typically an SVG icon), painted in the dot's `currentColor` ink. Same
|
|
143
|
+
* contract as `AvatarStatusDot`'s `icon`.
|
|
144
|
+
*
|
|
145
|
+
* By default the dot is a colour-only signal; an icon gives the status a
|
|
146
|
+
* non-colour mark, so use a different icon per status — the same icon on
|
|
147
|
+
* every variant leaves the statuses distinguishable by colour alone
|
|
148
|
+
* (WCAG 2.1 SC 1.4.1). Booleans and empty strings are ignored (safe for
|
|
149
|
+
* `cond && <Icon />`).
|
|
150
|
+
*
|
|
151
|
+
* @example
|
|
152
|
+
* ```
|
|
153
|
+
* <StatusDot variant="success" label="Verified" icon={<CheckIcon />} />
|
|
154
|
+
* ```
|
|
155
|
+
*/
|
|
156
|
+
icon?: ReactNode;
|
|
109
157
|
}
|
|
110
158
|
|
|
111
159
|
/**
|
|
112
160
|
* A small colored dot indicator for status display (online/offline, severity, etc).
|
|
113
161
|
*
|
|
114
|
-
* Fixed 8px size.
|
|
162
|
+
* Fixed 8px size. By default the dot conveys status by colour only, which is
|
|
163
|
+
* not accessible in isolation (WCAG 2.1 SC 1.4.1) — treat it as a signal the
|
|
164
|
+
* surrounding UI must make accessible: use it as a binary present/absent
|
|
165
|
+
* signal, pair it with a visible label, pass `icon` so the status carries a
|
|
166
|
+
* non-colour mark, or convey the status accessibly elsewhere. See the usage
|
|
167
|
+
* guidance in the component docs.
|
|
168
|
+
*
|
|
169
|
+
* Renders as a non-focusable `<span>` with `role="img"` and
|
|
115
170
|
* `aria-label` for accessibility. Styles use Astryx theme tokens via StyleX.
|
|
116
171
|
* Wrap your app in `<Theme>` to apply a theme.
|
|
117
172
|
*
|
|
@@ -121,6 +176,7 @@ export interface StatusDotProps extends BaseProps<HTMLSpanElement> {
|
|
|
121
176
|
* <StatusDot variant="error" label="Offline" />
|
|
122
177
|
* <StatusDot variant="success" label="Live" isPulsing />
|
|
123
178
|
* <StatusDot variant="success" label="Online" tooltip="Online" />
|
|
179
|
+
* <StatusDot variant="success" label="Verified" icon={<CheckIcon />} />
|
|
124
180
|
* ```
|
|
125
181
|
*/
|
|
126
182
|
export function StatusDot({
|
|
@@ -128,12 +184,16 @@ export function StatusDot({
|
|
|
128
184
|
label,
|
|
129
185
|
isPulsing = false,
|
|
130
186
|
tooltip,
|
|
187
|
+
icon,
|
|
131
188
|
xstyle,
|
|
132
189
|
className,
|
|
133
190
|
style,
|
|
134
191
|
ref,
|
|
135
192
|
...props
|
|
136
193
|
}: StatusDotProps) {
|
|
194
|
+
// Booleans and empty renders don't count (safe for `cond && <Icon />`) —
|
|
195
|
+
// same contract as AvatarStatusDot.
|
|
196
|
+
const showsIcon = isRenderable(icon);
|
|
137
197
|
const dot = (
|
|
138
198
|
<span
|
|
139
199
|
ref={ref}
|
|
@@ -151,8 +211,13 @@ export function StatusDot({
|
|
|
151
211
|
className,
|
|
152
212
|
style,
|
|
153
213
|
)}
|
|
154
|
-
{...props}
|
|
155
|
-
|
|
214
|
+
{...props}>
|
|
215
|
+
{showsIcon && (
|
|
216
|
+
<span aria-hidden="true" {...stylex.props(styles.icon)}>
|
|
217
|
+
{icon}
|
|
218
|
+
</span>
|
|
219
|
+
)}
|
|
220
|
+
</span>
|
|
156
221
|
);
|
|
157
222
|
|
|
158
223
|
if (tooltip) {
|
package/src/Switch/Switch.tsx
CHANGED
|
@@ -47,6 +47,7 @@ import type {BaseProps} from '../BaseProps';
|
|
|
47
47
|
import type {SizeValue} from '../utils/types';
|
|
48
48
|
import {themeProps} from '../utils/themeProps';
|
|
49
49
|
import {VisuallyHidden} from '../VisuallyHidden';
|
|
50
|
+
import {useResolvedRequired} from '../hooks/useResolvedRequired';
|
|
50
51
|
|
|
51
52
|
const wrapperSizeStyles = stylex.create({
|
|
52
53
|
sm: {
|
|
@@ -503,6 +504,10 @@ export function Switch({
|
|
|
503
504
|
const id = useId();
|
|
504
505
|
const descriptionID = useId();
|
|
505
506
|
const statusMessageID = useId();
|
|
507
|
+
// Announce the effective required state (form default included) while the
|
|
508
|
+
// native `required` stays bound to the explicit `isRequired` so a layout
|
|
509
|
+
// default never switches on browser validation.
|
|
510
|
+
const isEffectivelyRequired = useResolvedRequired({isRequired, isOptional});
|
|
506
511
|
|
|
507
512
|
const [, startTransition] = useTransition();
|
|
508
513
|
const [optimisticValue, setOptimisticValue] = useOptimistic(value);
|
|
@@ -559,6 +564,7 @@ export function Switch({
|
|
|
559
564
|
aria-disabled={showsDisabledMessage ? 'true' : undefined}
|
|
560
565
|
form={showsDisabledMessage ? '' : undefined}
|
|
561
566
|
required={isRequired}
|
|
567
|
+
aria-required={isEffectivelyRequired ? 'true' : undefined}
|
|
562
568
|
onChange={e => {
|
|
563
569
|
if (isDisabled || isBusy) {
|
|
564
570
|
return;
|
|
@@ -23,6 +23,7 @@ import {spacingVars, colorVars, radiusVars} from '../../../theme/tokens.stylex';
|
|
|
23
23
|
import {Icon} from '../../../Icon';
|
|
24
24
|
import {resolveContextActions} from '../../tableContextMenu';
|
|
25
25
|
import {useTranslator} from '../../../i18n';
|
|
26
|
+
import {rtlStyles} from '../../../utils';
|
|
26
27
|
import type {
|
|
27
28
|
TablePlugin,
|
|
28
29
|
TableColumn,
|
|
@@ -92,8 +93,20 @@ const expansionStyles = stylex.create({
|
|
|
92
93
|
color: colorVars['--color-icon-primary'],
|
|
93
94
|
},
|
|
94
95
|
},
|
|
96
|
+
// The RTL mirror is folded into each state's transform rather than living
|
|
97
|
+
// on a parent span, matching TreeListItem's chevron (both are `transform`,
|
|
98
|
+
// so on one element the later value would win).
|
|
95
99
|
chevronExpanded: {
|
|
96
|
-
transform:
|
|
100
|
+
transform: {
|
|
101
|
+
default: 'rotate(90deg)',
|
|
102
|
+
':is([dir="rtl"] *)': 'scaleX(-1) rotate(90deg)',
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
chevronCollapsed: {
|
|
106
|
+
transform: {
|
|
107
|
+
default: 'rotate(0deg)',
|
|
108
|
+
':is([dir="rtl"] *)': 'scaleX(-1) rotate(0deg)',
|
|
109
|
+
},
|
|
97
110
|
},
|
|
98
111
|
expandedRow: {
|
|
99
112
|
backgroundColor: colorVars['--color-background-muted'],
|
|
@@ -121,7 +134,9 @@ function ExpansionChevron({
|
|
|
121
134
|
type="button"
|
|
122
135
|
{...stylex.props(
|
|
123
136
|
expansionStyles.chevronButton,
|
|
124
|
-
isExpanded
|
|
137
|
+
isExpanded
|
|
138
|
+
? expansionStyles.chevronExpanded
|
|
139
|
+
: expansionStyles.chevronCollapsed,
|
|
125
140
|
)}
|
|
126
141
|
onClick={e => {
|
|
127
142
|
e.stopPropagation();
|
|
@@ -249,6 +264,9 @@ export function useTableRowExpansion<T extends Record<string, unknown>>(
|
|
|
249
264
|
icon={isExpanded ? 'chevronDown' : 'chevronRight'}
|
|
250
265
|
size="xsm"
|
|
251
266
|
aria-hidden
|
|
267
|
+
// chevronDown needs no mirroring; chevronRight (collapsed,
|
|
268
|
+
// pointing toward the reveal direction) does.
|
|
269
|
+
xstyle={!isExpanded && rtlStyles.mirror}
|
|
252
270
|
/>
|
|
253
271
|
),
|
|
254
272
|
onSelect: () => onToggle(key),
|
|
@@ -52,6 +52,7 @@ import type {SizeValue} from '../utils/types';
|
|
|
52
52
|
import {useInputContainer} from '../hooks/useInputContainer';
|
|
53
53
|
import {useInputStatusIcon} from '../hooks/useInputStatusIcon';
|
|
54
54
|
import {useAnnounce} from '../hooks/useAnnounce';
|
|
55
|
+
import {useResolvedRequired} from '../hooks/useResolvedRequired';
|
|
55
56
|
import {useSize} from '../SizeContext/SizeContext';
|
|
56
57
|
import {themeProps} from '../utils/themeProps';
|
|
57
58
|
import {characterCount} from '../utils/characters';
|
|
@@ -394,6 +395,7 @@ export function TextArea({
|
|
|
394
395
|
}: TextAreaProps) {
|
|
395
396
|
const size = useSize(sizeProp, 'md');
|
|
396
397
|
const t = useTranslator();
|
|
398
|
+
const isEffectivelyRequired = useResolvedRequired({isRequired, isOptional});
|
|
397
399
|
const announce = useAnnounce();
|
|
398
400
|
const id = useId();
|
|
399
401
|
const descriptionID = useId();
|
|
@@ -598,7 +600,7 @@ export function TextArea({
|
|
|
598
600
|
autoFocus={hasAutoFocus}
|
|
599
601
|
data-autofocus={hasAutoFocus || undefined}
|
|
600
602
|
aria-describedby={ariaDescribedBy}
|
|
601
|
-
aria-required={
|
|
603
|
+
aria-required={isEffectivelyRequired ? 'true' : undefined}
|
|
602
604
|
aria-invalid={
|
|
603
605
|
status?.type === 'error' ||
|
|
604
606
|
(maxLength != null && valueLength > maxLength)
|
|
@@ -101,6 +101,7 @@ import {mergeProps, mergeRefs} from '../utils';
|
|
|
101
101
|
import {useSize} from '../SizeContext/SizeContext';
|
|
102
102
|
import {useInputContainer} from '../hooks/useInputContainer';
|
|
103
103
|
import {useInputStatusIcon} from '../hooks/useInputStatusIcon';
|
|
104
|
+
import {useResolvedRequired} from '../hooks/useResolvedRequired';
|
|
104
105
|
import {useInputGroup} from '../InputGroup/InputGroupContext';
|
|
105
106
|
import type {BaseProps} from '../BaseProps';
|
|
106
107
|
import type {SizeValue} from '../utils/types';
|
|
@@ -301,6 +302,7 @@ export function TextInput({
|
|
|
301
302
|
...rest
|
|
302
303
|
}: TextInputProps) {
|
|
303
304
|
const t = useTranslator();
|
|
305
|
+
const isEffectivelyRequired = useResolvedRequired({isRequired, isOptional});
|
|
304
306
|
const size = useSize(sizeProp, 'md');
|
|
305
307
|
|
|
306
308
|
const id = useId();
|
|
@@ -445,7 +447,7 @@ export function TextInput({
|
|
|
445
447
|
autoFocus={hasAutoFocus}
|
|
446
448
|
data-autofocus={hasAutoFocus || undefined}
|
|
447
449
|
aria-describedby={ariaDescribedBy}
|
|
448
|
-
aria-required={
|
|
450
|
+
aria-required={isEffectivelyRequired ? 'true' : undefined}
|
|
449
451
|
aria-invalid={status?.type === 'error' ? 'true' : undefined}
|
|
450
452
|
aria-busy={isBusy || undefined}
|
|
451
453
|
aria-labelledby={ariaLabelledBy}
|
|
@@ -67,6 +67,7 @@ import {useSize} from '../SizeContext/SizeContext';
|
|
|
67
67
|
import {useAnnounce} from '../hooks/useAnnounce';
|
|
68
68
|
import {useInputContainer} from '../hooks/useInputContainer';
|
|
69
69
|
import {useInputStatusIcon} from '../hooks/useInputStatusIcon';
|
|
70
|
+
import {useResolvedRequired} from '../hooks/useResolvedRequired';
|
|
70
71
|
import {useInputGroup} from '../InputGroup/InputGroupContext';
|
|
71
72
|
import {groupStyles} from '../InputGroup/groupStyles';
|
|
72
73
|
import {useTooltip} from '../Tooltip';
|
|
@@ -347,6 +348,7 @@ export function TimeInput({
|
|
|
347
348
|
ref,
|
|
348
349
|
}: TimeInputProps) {
|
|
349
350
|
const t = useTranslator();
|
|
351
|
+
const isEffectivelyRequired = useResolvedRequired({isRequired, isOptional});
|
|
350
352
|
const placeholder =
|
|
351
353
|
placeholderFromProps ?? t('@astryx.timeInput.placeholder');
|
|
352
354
|
const size = useSize(sizeProp, 'md');
|
|
@@ -664,7 +666,7 @@ export function TimeInput({
|
|
|
664
666
|
autoFocus={hasAutoFocus}
|
|
665
667
|
data-autofocus={hasAutoFocus || undefined}
|
|
666
668
|
aria-describedby={ariaDescribedBy}
|
|
667
|
-
aria-required={
|
|
669
|
+
aria-required={isEffectivelyRequired ? 'true' : undefined}
|
|
668
670
|
aria-invalid={
|
|
669
671
|
status?.type === 'error' || !isInputValid ? 'true' : undefined
|
|
670
672
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
'use client';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @file useResolvedRequired.ts
|
|
7
|
+
* @input Uses React, FormLayoutContext
|
|
8
|
+
* @output Exports useResolvedRequired
|
|
9
|
+
* @position Resolves a field's effective aria-required against the form default
|
|
10
|
+
*
|
|
11
|
+
* SYNC: When modified, update these files to stay in sync:
|
|
12
|
+
* - /packages/core/src/FormLayout/FormLayoutContext.ts (defaultOptionality source)
|
|
13
|
+
* - /packages/core/src/FormLayout/FormLayout.tsx (prop doc: aria-required behavior)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {use} from 'react';
|
|
17
|
+
import {FormLayoutContext} from '../FormLayout/FormLayoutContext';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Resolve a field's effective *required* state for `aria-required`, honoring a
|
|
21
|
+
* surrounding `FormLayout`'s `defaultOptionality`.
|
|
22
|
+
*
|
|
23
|
+
* Under `defaultOptionality="required"` a field is required unless it opts out
|
|
24
|
+
* with `isOptional`. The visible indicator is suppressed for the unmarked
|
|
25
|
+
* majority, so those fields must still expose `aria-required` — otherwise a
|
|
26
|
+
* sighted user reads them as required (form-wide default, no indicator) while a
|
|
27
|
+
* screen reader hears "not required". This closes that mismatch.
|
|
28
|
+
*
|
|
29
|
+
* Semantics only: this drives `aria-required`, never the native `required`
|
|
30
|
+
* attribute, so a layout-level default can't silently switch on browser
|
|
31
|
+
* validation bubbles. `isOptional` takes precedence, matching `FieldLabel`.
|
|
32
|
+
*/
|
|
33
|
+
export function useResolvedRequired({
|
|
34
|
+
isRequired = false,
|
|
35
|
+
isOptional = false,
|
|
36
|
+
}: {
|
|
37
|
+
isRequired?: boolean;
|
|
38
|
+
isOptional?: boolean;
|
|
39
|
+
}): boolean {
|
|
40
|
+
const {defaultOptionality} = use(FormLayoutContext);
|
|
41
|
+
return !isOptional && (isRequired || defaultOptionality === 'required');
|
|
42
|
+
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
plainDateDayOfWeek,
|
|
14
14
|
plainDateAddMonths,
|
|
15
15
|
plainDateAddDays,
|
|
16
|
+
plainDateDiffDays,
|
|
16
17
|
plainDateToInstant,
|
|
17
18
|
plainDateFromInstant,
|
|
18
19
|
plainDateIsBefore,
|
|
@@ -312,6 +313,55 @@ describe('plainDateAddDays', () => {
|
|
|
312
313
|
});
|
|
313
314
|
});
|
|
314
315
|
|
|
316
|
+
describe('plainDateDiffDays', () => {
|
|
317
|
+
it('counts whole days forward', () => {
|
|
318
|
+
expect(
|
|
319
|
+
plainDateDiffDays(
|
|
320
|
+
{year: 2026, month: 1, day: 10},
|
|
321
|
+
{year: 2026, month: 1, day: 17},
|
|
322
|
+
),
|
|
323
|
+
).toBe(7);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('is negative when the second date is earlier', () => {
|
|
327
|
+
expect(
|
|
328
|
+
plainDateDiffDays(
|
|
329
|
+
{year: 2026, month: 1, day: 10},
|
|
330
|
+
{year: 2026, month: 1, day: 3},
|
|
331
|
+
),
|
|
332
|
+
).toBe(-7);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('is zero for the same day', () => {
|
|
336
|
+
expect(
|
|
337
|
+
plainDateDiffDays(
|
|
338
|
+
{year: 2026, month: 6, day: 15},
|
|
339
|
+
{year: 2026, month: 6, day: 15},
|
|
340
|
+
),
|
|
341
|
+
).toBe(0);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('counts across month and year boundaries', () => {
|
|
345
|
+
expect(
|
|
346
|
+
plainDateDiffDays(
|
|
347
|
+
{year: 2025, month: 12, day: 30},
|
|
348
|
+
{year: 2026, month: 1, day: 2},
|
|
349
|
+
),
|
|
350
|
+
).toBe(3);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('ignores DST — every calendar day counts once across a spring-forward', () => {
|
|
354
|
+
// US DST 2026 begins Mar 8. The gap Mar 7 → Mar 9 is two calendar days
|
|
355
|
+
// even though one of them is 23 hours long.
|
|
356
|
+
expect(
|
|
357
|
+
plainDateDiffDays(
|
|
358
|
+
{year: 2026, month: 3, day: 7},
|
|
359
|
+
{year: 2026, month: 3, day: 9},
|
|
360
|
+
),
|
|
361
|
+
).toBe(2);
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
315
365
|
describe('plainDateToInstant / plainDateFromInstant', () => {
|
|
316
366
|
it('converts a PlainDate to midnight in the requested timezone', () => {
|
|
317
367
|
expect(
|
package/src/utils/plainDate.ts
CHANGED
|
@@ -88,6 +88,18 @@ export function plainDateAddDays(pd: PlainDate, n: number): PlainDate {
|
|
|
88
88
|
return plainDateFromDate(d);
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Whole calendar days from `a` to `b`, ignoring time of day. Positive when
|
|
93
|
+
* `b` is after `a`, negative when before. Uses UTC midnight so DST shifts
|
|
94
|
+
* never add or drop a day.
|
|
95
|
+
*/
|
|
96
|
+
export function plainDateDiffDays(a: PlainDate, b: PlainDate): number {
|
|
97
|
+
const msPerDay = 24 * 60 * 60 * 1000;
|
|
98
|
+
const aUTC = Date.UTC(a.year, a.month - 1, a.day);
|
|
99
|
+
const bUTC = Date.UTC(b.year, b.month - 1, b.day);
|
|
100
|
+
return Math.round((bUTC - aUTC) / msPerDay);
|
|
101
|
+
}
|
|
102
|
+
|
|
91
103
|
/**
|
|
92
104
|
* The wall-clock fields an instant reads as in a given zone.
|
|
93
105
|
*
|