@astryxdesign/core 0.4.3 → 0.4.4-canary.0e8af82
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 +60 -0
- package/README.md +79 -45
- package/dist/Banner/Banner.d.ts +3 -0
- package/dist/Banner/Banner.d.ts.map +1 -1
- package/dist/Banner/Banner.js +13 -3
- package/dist/BottomSheet/BottomSheet.d.ts +75 -0
- package/dist/BottomSheet/BottomSheet.d.ts.map +1 -0
- package/dist/BottomSheet/BottomSheet.js +396 -0
- package/dist/BottomSheet/BottomSheetPanel.d.ts +63 -0
- package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -0
- package/dist/BottomSheet/BottomSheetPanel.js +441 -0
- package/dist/BottomSheet/BottomSheetSwitcher.d.ts +61 -0
- package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -0
- package/dist/BottomSheet/BottomSheetSwitcher.js +410 -0
- package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts +21 -0
- package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts.map +1 -0
- package/dist/BottomSheet/BottomSheetSwitcherContext.js +13 -0
- package/dist/BottomSheet/index.d.ts +11 -0
- package/dist/BottomSheet/index.d.ts.map +1 -0
- package/dist/BottomSheet/index.js +12 -0
- package/dist/BottomSheet/snapOffsets.d.ts +109 -0
- package/dist/BottomSheet/snapOffsets.d.ts.map +1 -0
- package/dist/BottomSheet/snapOffsets.js +206 -0
- package/dist/BottomSheet/useMobileKeyboard.d.ts +35 -0
- package/dist/BottomSheet/useMobileKeyboard.d.ts.map +1 -0
- package/dist/BottomSheet/useMobileKeyboard.js +373 -0
- package/dist/BottomSheet/useSheetGestures.d.ts +185 -0
- package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -0
- package/dist/BottomSheet/useSheetGestures.js +1038 -0
- package/dist/Calendar/Calendar.d.ts +17 -0
- package/dist/Calendar/Calendar.d.ts.map +1 -1
- package/dist/Calendar/Calendar.js +25 -1
- 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/Chat/ChatComposerInput.d.ts.map +1 -1
- package/dist/Chat/ChatComposerInput.js +3 -4
- 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 +18 -7
- package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
- package/dist/ContextMenu/ContextMenu.js +4 -2
- package/dist/DateInput/DateInput.d.ts.map +1 -1
- package/dist/DateInput/DateInput.js +15 -2
- 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 +38 -3
- package/dist/Dialog/Dialog.d.ts.map +1 -1
- package/dist/Dialog/Dialog.js +2 -1
- 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/Lightbox/Lightbox.d.ts.map +1 -1
- package/dist/Lightbox/Lightbox.js +9 -3
- package/dist/MobileNav/MobileNav.d.ts +12 -0
- package/dist/MobileNav/MobileNav.d.ts.map +1 -1
- package/dist/MobileNav/MobileNav.js +102 -30
- package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
- package/dist/MultiSelector/MultiSelector.js +27 -8
- package/dist/NumberInput/NumberInput.d.ts.map +1 -1
- package/dist/NumberInput/NumberInput.js +15 -2
- package/dist/PowerSearch/PowerSearchEditPopover.js +1 -1
- 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 +20 -4
- 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 +14 -1
- 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 +14 -2
- package/dist/Tokenizer/Tokenizer.js +11 -5
- package/dist/Tooltip/useTooltip.d.ts.map +1 -1
- package/dist/Tooltip/useTooltip.js +4 -1
- package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
- package/dist/Typeahead/BaseTypeahead.js +5 -3
- package/dist/Typeahead/Typeahead.d.ts.map +1 -1
- package/dist/Typeahead/Typeahead.js +10 -1
- package/dist/astryx.css +21 -0
- package/dist/hooks/index.d.ts +8 -1
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +8 -1
- package/dist/hooks/useFocusTrap.d.ts +0 -11
- package/dist/hooks/useFocusTrap.d.ts.map +1 -1
- package/dist/hooks/useFocusTrap.js +1 -11
- package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
- package/dist/hooks/useInputStatusIcon.js +1 -0
- 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/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/theme/defineTheme.d.ts +22 -4
- package/dist/theme/defineTheme.d.ts.map +1 -1
- package/dist/theme/expandColorScale.d.ts +17 -2
- package/dist/theme/expandColorScale.d.ts.map +1 -1
- package/dist/theme/expandColorScale.js +63 -33
- package/dist/utils/ime.d.ts +5 -0
- package/dist/utils/ime.d.ts.map +1 -0
- package/dist/utils/ime.js +63 -0
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +2 -1
- 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 +52 -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 +39 -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 +9 -8
- package/src/Banner/Banner.doc.mjs +3 -3
- package/src/Banner/Banner.test.tsx +40 -0
- package/src/Banner/Banner.tsx +27 -2
- package/src/BottomSheet/BottomSheet.doc.mjs +299 -0
- package/src/BottomSheet/BottomSheet.test.tsx +2265 -0
- package/src/BottomSheet/BottomSheet.tsx +559 -0
- package/src/BottomSheet/BottomSheetPanel.test.tsx +240 -0
- package/src/BottomSheet/BottomSheetPanel.tsx +601 -0
- package/src/BottomSheet/BottomSheetSwitcher.doc.mjs +219 -0
- package/src/BottomSheet/BottomSheetSwitcher.test.tsx +850 -0
- package/src/BottomSheet/BottomSheetSwitcher.tsx +628 -0
- package/src/BottomSheet/BottomSheetSwitcherContext.ts +51 -0
- package/src/BottomSheet/index.ts +19 -0
- package/src/BottomSheet/snapOffsets.test.ts +254 -0
- package/src/BottomSheet/snapOffsets.ts +245 -0
- package/src/BottomSheet/useMobileKeyboard.ts +499 -0
- package/src/BottomSheet/useSheetGestures.test.ts +1112 -0
- package/src/BottomSheet/useSheetGestures.ts +1503 -0
- package/src/Calendar/Calendar.doc.mjs +16 -0
- package/src/Calendar/Calendar.test.tsx +96 -0
- package/src/Calendar/Calendar.tsx +55 -0
- package/src/Calendar/hooks/useCalendarConstraints.ts +54 -3
- package/src/Chat/ChatComposerInput.tsx +3 -4
- package/src/CheckboxInput/CheckboxInput.tsx +6 -0
- package/src/ComplexSelector/ComplexSelector.tsx +15 -6
- package/src/ContextMenu/ContextMenu.tsx +4 -2
- package/src/DateInput/DateInput.test.tsx +21 -0
- package/src/DateInput/DateInput.tsx +12 -2
- 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.doc.mjs +4 -0
- package/src/DateTimeInput/DateTimeInput.test.tsx +137 -0
- package/src/DateTimeInput/DateTimeInput.tsx +43 -4
- package/src/Dialog/Dialog.tsx +2 -1
- 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/FileInput/FileInput.test.tsx +53 -9
- 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/Lightbox/Lightbox.test.tsx +49 -28
- package/src/Lightbox/Lightbox.tsx +7 -3
- package/src/MobileNav/MobileNav.tsx +128 -37
- package/src/MobileNav/MobileNavCloseEdgeCases.test.tsx +426 -0
- package/src/MobileNav/MobileNavCloseTiming.test.ts +58 -0
- package/src/MobileNav/MobileNavCloseVisibility.test.tsx +172 -0
- package/src/MultiSelector/MultiSelector.test.tsx +195 -51
- package/src/MultiSelector/MultiSelector.tsx +25 -9
- package/src/NumberInput/NumberInput.test.tsx +18 -0
- package/src/NumberInput/NumberInput.tsx +12 -2
- package/src/PowerSearch/PowerSearchEditPopover.tsx +1 -1
- package/src/RadioList/RadioList.tsx +9 -1
- package/src/Selector/Selector.test.tsx +85 -26
- package/src/Selector/Selector.tsx +16 -5
- 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.test.tsx +16 -0
- package/src/Switch/Switch.tsx +13 -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.test.tsx +23 -0
- package/src/TimeInput/TimeInput.tsx +11 -1
- package/src/Tokenizer/Tokenizer.test.tsx +50 -32
- package/src/Tokenizer/Tokenizer.tsx +5 -5
- package/src/Tooltip/useTooltip.tsx +4 -1
- package/src/Typeahead/BaseTypeahead.tsx +10 -3
- package/src/Typeahead/Typeahead.test.tsx +116 -7
- package/src/Typeahead/Typeahead.tsx +10 -1
- package/src/__tests__/stubMatchMedia.test.ts +79 -0
- package/src/__tests__/stubMatchMedia.ts +95 -0
- package/src/hooks/index.ts +9 -5
- package/src/hooks/useFocusTrap.ts +1 -14
- package/src/hooks/useInputStatusIcon.test.tsx +19 -0
- package/src/hooks/useInputStatusIcon.tsx +7 -0
- package/src/hooks/useResolvedRequired.ts +42 -0
- package/src/index.ts +1 -0
- package/src/theme/defineTheme.ts +22 -4
- package/src/theme/expandColorScale.test.ts +138 -0
- package/src/theme/expandColorScale.ts +89 -44
- package/src/utils/ime.test.ts +32 -0
- package/src/utils/ime.ts +69 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/plainDate.test.ts +50 -0
- package/src/utils/plainDate.ts +12 -0
- package/dist/astryx.umd.js +0 -147
- package/dist/astryx.umd.js.map +0 -7
|
@@ -29,6 +29,7 @@ import {spacingVars} from '../theme/tokens.stylex';
|
|
|
29
29
|
import {Field} from '../Field/Field';
|
|
30
30
|
import type {InputStatus} from '../Field/types';
|
|
31
31
|
import {useTooltip} from '../Tooltip';
|
|
32
|
+
import {useResolvedRequired} from '../hooks/useResolvedRequired';
|
|
32
33
|
import {mergeProps} from '../utils';
|
|
33
34
|
import type {BaseProps} from '../BaseProps';
|
|
34
35
|
import type {SizeValue} from '../utils/types';
|
|
@@ -218,6 +219,13 @@ export function RadioList({
|
|
|
218
219
|
|
|
219
220
|
const groupRef = useRef<HTMLDivElement>(null);
|
|
220
221
|
|
|
222
|
+
// The radiogroup exposes the *effective* required state so a form-wide
|
|
223
|
+
// `defaultOptionality="required"` is announced even when the group carries no
|
|
224
|
+
// visible indicator. Individual radios keep their native `required` bound to
|
|
225
|
+
// the explicit `isRequired` (via context), so a layout default never switches
|
|
226
|
+
// on browser validation for the group.
|
|
227
|
+
const isEffectivelyRequired = useResolvedRequired({isRequired, isOptional});
|
|
228
|
+
|
|
221
229
|
// Disabled-reason tooltip. Applies to the whole-group disabled state. Disabled
|
|
222
230
|
// controls swallow pointer events, so the tooltip listeners attach to the
|
|
223
231
|
// radiogroup container and the radios stay perceivable via aria-disabled
|
|
@@ -387,7 +395,7 @@ export function RadioList({
|
|
|
387
395
|
.join(' ') || undefined
|
|
388
396
|
}
|
|
389
397
|
aria-invalid={status?.type === 'error' ? true : undefined}
|
|
390
|
-
aria-required={
|
|
398
|
+
aria-required={isEffectivelyRequired || undefined}
|
|
391
399
|
{...mergeProps(
|
|
392
400
|
themeProps('radio-list', {orientation, size}),
|
|
393
401
|
stylex.props(
|
|
@@ -26,6 +26,7 @@ import {RadioIndicator} from '../Indicator';
|
|
|
26
26
|
import {InputGroup, InputGroupText} from '../InputGroup';
|
|
27
27
|
import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
|
|
28
28
|
import {__resetInteractionModalityForTest} from '../utils/interactionModality';
|
|
29
|
+
import {InternationalizationProvider} from '../i18n';
|
|
29
30
|
import {defineTheme} from '../theme/defineTheme';
|
|
30
31
|
import {Theme} from '../theme/Theme';
|
|
31
32
|
import {generateThemeCSS} from '../theme/generateThemeRules';
|
|
@@ -1000,60 +1001,118 @@ describe('Selector', () => {
|
|
|
1000
1001
|
).toBeInTheDocument();
|
|
1001
1002
|
});
|
|
1002
1003
|
|
|
1004
|
+
it('does not select the highlighted option on a composing Enter (IME)', async () => {
|
|
1005
|
+
const user = userEvent.setup();
|
|
1006
|
+
const onChange = vi.fn();
|
|
1007
|
+
render(
|
|
1008
|
+
<Selector
|
|
1009
|
+
label="Fruit"
|
|
1010
|
+
options={OPTIONS}
|
|
1011
|
+
onChange={onChange}
|
|
1012
|
+
hasSearch
|
|
1013
|
+
/>,
|
|
1014
|
+
);
|
|
1015
|
+
await user.click(screen.getByRole('button', {name: 'Fruit'}));
|
|
1016
|
+
const search = screen.getByRole('combobox', h);
|
|
1017
|
+
// Filter to Banana and highlight it so an unguarded Enter would commit a
|
|
1018
|
+
// selection.
|
|
1019
|
+
await user.type(search, 'ban');
|
|
1020
|
+
await user.keyboard('{ArrowDown}');
|
|
1021
|
+
expect(search).toHaveAttribute('aria-activedescendant');
|
|
1022
|
+
|
|
1023
|
+
// The browser fires this composing keydown for the Enter that commits an
|
|
1024
|
+
// IME candidate (isComposing: true, or the legacy keyCode 229) before
|
|
1025
|
+
// compositionend writes the syllable. It must NOT be read as "select the
|
|
1026
|
+
// highlighted option".
|
|
1027
|
+
fireEvent.keyDown(search, {key: 'Enter', isComposing: true});
|
|
1028
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
1029
|
+
fireEvent.keyDown(search, {key: 'Enter', keyCode: 229});
|
|
1030
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
1031
|
+
|
|
1032
|
+
// A real, non-composing Enter still selects the highlighted option.
|
|
1033
|
+
fireEvent.keyDown(search, {key: 'Enter'});
|
|
1034
|
+
expect(onChange).toHaveBeenCalledWith('Banana');
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1003
1037
|
describe('result announcements', () => {
|
|
1004
1038
|
it('announces the match count politely while searching', async () => {
|
|
1005
1039
|
const user = userEvent.setup();
|
|
1006
1040
|
render(
|
|
1007
|
-
<
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1041
|
+
<InternationalizationProvider
|
|
1042
|
+
locale="fr"
|
|
1043
|
+
overrides={{
|
|
1044
|
+
fr: {
|
|
1045
|
+
'@astryx.selector.resultCount':
|
|
1046
|
+
'{count, number} {count, plural, one {résultat} other {résultats}}',
|
|
1047
|
+
},
|
|
1048
|
+
}}>
|
|
1049
|
+
<Selector
|
|
1050
|
+
label="Fruit"
|
|
1051
|
+
options={OPTIONS}
|
|
1052
|
+
value="Apple"
|
|
1053
|
+
onChange={() => {}}
|
|
1054
|
+
hasSearch
|
|
1055
|
+
/>
|
|
1056
|
+
</InternationalizationProvider>,
|
|
1014
1057
|
);
|
|
1015
1058
|
await user.click(screen.getByRole('button', {name: 'Fruit'}));
|
|
1016
1059
|
// "a" matches Apple and Banana.
|
|
1017
1060
|
await user.type(screen.getByRole('combobox', h), 'a');
|
|
1018
1061
|
await waitFor(() => {
|
|
1019
|
-
|
|
1062
|
+
// The plural branch of a message no catalog supplies.
|
|
1063
|
+
expect(politeRegion()?.textContent).toBe('2 résultats');
|
|
1020
1064
|
});
|
|
1021
1065
|
});
|
|
1022
1066
|
|
|
1023
1067
|
it('announces the singular form when one option matches', async () => {
|
|
1024
1068
|
const user = userEvent.setup();
|
|
1025
1069
|
render(
|
|
1026
|
-
<
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1070
|
+
<InternationalizationProvider
|
|
1071
|
+
locale="fr"
|
|
1072
|
+
overrides={{
|
|
1073
|
+
fr: {
|
|
1074
|
+
'@astryx.selector.resultCount':
|
|
1075
|
+
'{count, number} {count, plural, one {résultat} other {résultats}}',
|
|
1076
|
+
},
|
|
1077
|
+
}}>
|
|
1078
|
+
<Selector
|
|
1079
|
+
label="Fruit"
|
|
1080
|
+
options={OPTIONS}
|
|
1081
|
+
value="Apple"
|
|
1082
|
+
onChange={() => {}}
|
|
1083
|
+
hasSearch
|
|
1084
|
+
/>
|
|
1085
|
+
</InternationalizationProvider>,
|
|
1033
1086
|
);
|
|
1034
1087
|
await user.click(screen.getByRole('button', {name: 'Fruit'}));
|
|
1035
|
-
// "ban" matches only Banana.
|
|
1088
|
+
// "ban" matches only Banana. Exact, so "1 résultats" would fail.
|
|
1036
1089
|
await user.type(screen.getByRole('combobox', h), 'ban');
|
|
1037
1090
|
await waitFor(() => {
|
|
1038
|
-
expect(politeRegion()).
|
|
1091
|
+
expect(politeRegion()?.textContent).toBe('1 résultat');
|
|
1039
1092
|
});
|
|
1040
1093
|
});
|
|
1041
1094
|
|
|
1042
|
-
it('announces
|
|
1095
|
+
it('announces the empty-results message when nothing matches', async () => {
|
|
1043
1096
|
const user = userEvent.setup();
|
|
1044
1097
|
render(
|
|
1045
|
-
<
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1098
|
+
<InternationalizationProvider
|
|
1099
|
+
locale="fr"
|
|
1100
|
+
overrides={{
|
|
1101
|
+
fr: {'@astryx.selector.emptySearchResults': 'Aucun résultat'},
|
|
1102
|
+
}}>
|
|
1103
|
+
<Selector
|
|
1104
|
+
label="Fruit"
|
|
1105
|
+
options={OPTIONS}
|
|
1106
|
+
value="Apple"
|
|
1107
|
+
onChange={() => {}}
|
|
1108
|
+
hasSearch
|
|
1109
|
+
/>
|
|
1110
|
+
</InternationalizationProvider>,
|
|
1052
1111
|
);
|
|
1053
1112
|
await user.click(screen.getByRole('button', {name: 'Fruit'}));
|
|
1054
1113
|
await user.type(screen.getByRole('combobox', h), 'xyz');
|
|
1055
1114
|
await waitFor(() => {
|
|
1056
|
-
expect(politeRegion()).
|
|
1115
|
+
expect(politeRegion()?.textContent).toBe('Aucun résultat');
|
|
1057
1116
|
});
|
|
1058
1117
|
});
|
|
1059
1118
|
|
|
@@ -71,8 +71,9 @@ import {
|
|
|
71
71
|
} from './utils';
|
|
72
72
|
import {useCombobox, useSelectedItemOffset} from './hooks';
|
|
73
73
|
import {useTypeahead} from '../hooks/useTypeahead';
|
|
74
|
+
import {useResolvedRequired} from '../hooks/useResolvedRequired';
|
|
74
75
|
import {SelectorOption} from './SelectorOption';
|
|
75
|
-
import {getInputARIA, mergeProps} from '../utils';
|
|
76
|
+
import {getInputARIA, isImeKeyEvent, mergeProps} from '../utils';
|
|
76
77
|
import {useSize} from '../SizeContext/SizeContext';
|
|
77
78
|
import type {BaseProps} from '../BaseProps';
|
|
78
79
|
import type {SizeValue} from '../utils/types';
|
|
@@ -699,6 +700,7 @@ export function Selector<T extends SelectorOptionType>(
|
|
|
699
700
|
hasClear: hasClearProp,
|
|
700
701
|
...rest
|
|
701
702
|
} = props as SelectorPropsClearable<T>;
|
|
703
|
+
const isEffectivelyRequired = useResolvedRequired({isRequired, isOptional});
|
|
702
704
|
const placeholder = placeholderFromProps ?? t('@astryx.selector.placeholder');
|
|
703
705
|
const searchPlaceholder =
|
|
704
706
|
searchPlaceholderFromProps ?? t('@astryx.selector.searchPlaceholder');
|
|
@@ -844,11 +846,11 @@ export function Selector<T extends SelectorOptionType>(
|
|
|
844
846
|
const count = filterOptionsByQuery(selectableItems, nextQuery).length;
|
|
845
847
|
announce(
|
|
846
848
|
count === 0
|
|
847
|
-
? '
|
|
848
|
-
:
|
|
849
|
+
? t('@astryx.selector.emptySearchResults')
|
|
850
|
+
: t('@astryx.selector.resultCount', {count}),
|
|
849
851
|
);
|
|
850
852
|
},
|
|
851
|
-
[announce, selectableItems],
|
|
853
|
+
[announce, selectableItems, t],
|
|
852
854
|
);
|
|
853
855
|
|
|
854
856
|
// Calculate offset to position selected item over trigger. Explicit
|
|
@@ -1055,6 +1057,15 @@ export function Selector<T extends SelectorOptionType>(
|
|
|
1055
1057
|
}
|
|
1056
1058
|
}}
|
|
1057
1059
|
onKeyDown={e => {
|
|
1060
|
+
// An in-progress IME composition uses these same keys (Enter to
|
|
1061
|
+
// commit the candidate, Escape/Arrows to navigate the candidate
|
|
1062
|
+
// window); the composing keydown fires before compositionend, so
|
|
1063
|
+
// without this guard a Korean/Japanese/Chinese user committing a
|
|
1064
|
+
// syllable with Enter would instead select the highlighted option.
|
|
1065
|
+
// See utils/ime.ts.
|
|
1066
|
+
if (isImeKeyEvent(e.nativeEvent)) {
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1058
1069
|
// Arrow keys navigate options; Enter selects; Escape closes.
|
|
1059
1070
|
// Home/End are left to the input for caret movement (APG editable
|
|
1060
1071
|
// combobox); PageUp/PageDown are the sanctioned substitute for
|
|
@@ -1336,7 +1347,7 @@ export function Selector<T extends SelectorOptionType>(
|
|
|
1336
1347
|
}
|
|
1337
1348
|
aria-describedby={ariaDescribedBy}
|
|
1338
1349
|
aria-labelledby={ariaLabelledBy}
|
|
1339
|
-
aria-required={
|
|
1350
|
+
aria-required={isEffectivelyRequired ? 'true' : undefined}
|
|
1340
1351
|
aria-invalid={status?.type === 'error' ? 'true' : undefined}
|
|
1341
1352
|
aria-busy={isBusy || undefined}
|
|
1342
1353
|
// With a disabledMessage the trigger keeps focusability via
|
|
@@ -33,6 +33,12 @@ export const docs = {
|
|
|
33
33
|
description:
|
|
34
34
|
'Tooltip text shown on hover to explain the status meaning.',
|
|
35
35
|
},
|
|
36
|
+
{
|
|
37
|
+
name: 'icon',
|
|
38
|
+
type: 'ReactNode',
|
|
39
|
+
description:
|
|
40
|
+
'Optional icon rendered centered inside the dot, painted in currentColor (the variant\'s ink). Gives the status a non-color mark, so use a different icon per status. Booleans and empty strings are ignored, so `cond && <Icon />` is safe. Same contract as AvatarStatusDot.',
|
|
41
|
+
},
|
|
36
42
|
{
|
|
37
43
|
name: 'xstyle',
|
|
38
44
|
type: 'StyleXStyles',
|
|
@@ -84,6 +90,12 @@ export const docsZh = {
|
|
|
84
90
|
'启用脉冲动画;尊重 prefers-reduced-motion: reduce 设置。',
|
|
85
91
|
default: 'false',
|
|
86
92
|
},
|
|
93
|
+
{
|
|
94
|
+
name: 'icon',
|
|
95
|
+
type: 'ReactNode',
|
|
96
|
+
description:
|
|
97
|
+
'可选图标,居中渲染于圆点内,以 currentColor(变体的前景色)着色。为状态提供非颜色标记,请为每个状态使用不同图标。布尔值和空字符串会被忽略,因此 `cond && <Icon />` 是安全的。与 AvatarStatusDot 的契约一致。',
|
|
98
|
+
},
|
|
87
99
|
{
|
|
88
100
|
name: 'xstyle',
|
|
89
101
|
type: 'StyleXStyles',
|
|
@@ -132,6 +144,7 @@ export const docsDense = {
|
|
|
132
144
|
label: 'Accessible label via aria-label.',
|
|
133
145
|
isPulsing: 'Pulse animation; respects prefers-reduced-motion: reduce.',
|
|
134
146
|
tooltip: 'Tooltip text on hover to explain status meaning.',
|
|
147
|
+
icon: 'Optional ReactNode rendered centered in the dot (currentColor ink); a non-color mark for the status, use a different icon per status. Booleans/empty strings ignored (safe for cond && <Icon/>). Same contract as AvatarStatusDot.',
|
|
135
148
|
xstyle: 'StyleX layout styles; must be stylex.create() value.',
|
|
136
149
|
},
|
|
137
150
|
};
|
|
@@ -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) {
|
|
@@ -262,6 +262,22 @@ describe('Switch', () => {
|
|
|
262
262
|
expect(screen.getByLabelText('Toggle row')).toBeInTheDocument();
|
|
263
263
|
});
|
|
264
264
|
|
|
265
|
+
it('drops the label gap when isLabelHidden so the row is only as wide as the track', () => {
|
|
266
|
+
const {rerender} = render(
|
|
267
|
+
<Switch
|
|
268
|
+
label="Toggle row"
|
|
269
|
+
isLabelHidden
|
|
270
|
+
value={false}
|
|
271
|
+
onChange={() => {}}
|
|
272
|
+
/>,
|
|
273
|
+
);
|
|
274
|
+
const row = screen.getByRole('switch').parentElement!.parentElement!;
|
|
275
|
+
expect(getComputedStyle(row).gap).toMatch(/^0(px)?$/);
|
|
276
|
+
|
|
277
|
+
rerender(<Switch label="Toggle row" value={false} onChange={() => {}} />);
|
|
278
|
+
expect(getComputedStyle(row).gap).not.toMatch(/^0(px)?$/);
|
|
279
|
+
});
|
|
280
|
+
|
|
265
281
|
it('keeps description linked via aria-describedby when isLabelHidden', () => {
|
|
266
282
|
render(
|
|
267
283
|
<Switch
|
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: {
|
|
@@ -135,6 +136,12 @@ const styles = stylex.create({
|
|
|
135
136
|
alignItems: 'center',
|
|
136
137
|
gap: spacingVars['--spacing-2'],
|
|
137
138
|
},
|
|
139
|
+
// A hidden label is sr-only, so its wrapper is a zero-width flex item — the
|
|
140
|
+
// row gap would still be painted beside the track, making the field box
|
|
141
|
+
// wider than the control it contains. Matches CheckboxInput.
|
|
142
|
+
containerLabelHidden: {
|
|
143
|
+
gap: 0,
|
|
144
|
+
},
|
|
138
145
|
containerSpread: {
|
|
139
146
|
justifyContent: 'space-between',
|
|
140
147
|
width: '100%',
|
|
@@ -497,6 +504,10 @@ export function Switch({
|
|
|
497
504
|
const id = useId();
|
|
498
505
|
const descriptionID = useId();
|
|
499
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});
|
|
500
511
|
|
|
501
512
|
const [, startTransition] = useTransition();
|
|
502
513
|
const [optimisticValue, setOptimisticValue] = useOptimistic(value);
|
|
@@ -553,6 +564,7 @@ export function Switch({
|
|
|
553
564
|
aria-disabled={showsDisabledMessage ? 'true' : undefined}
|
|
554
565
|
form={showsDisabledMessage ? '' : undefined}
|
|
555
566
|
required={isRequired}
|
|
567
|
+
aria-required={isEffectivelyRequired ? 'true' : undefined}
|
|
556
568
|
onChange={e => {
|
|
557
569
|
if (isDisabled || isBusy) {
|
|
558
570
|
return;
|
|
@@ -655,6 +667,7 @@ export function Switch({
|
|
|
655
667
|
}}
|
|
656
668
|
{...stylex.props(
|
|
657
669
|
styles.container,
|
|
670
|
+
isLabelHidden && styles.containerLabelHidden,
|
|
658
671
|
labelSpacing === 'spread' && styles.containerSpread,
|
|
659
672
|
!isDisabled && switchScope,
|
|
660
673
|
)}>
|