@astryxdesign/core 0.6.1 → 0.6.2-canary.0faf070
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 +33 -0
- package/dist/BottomSheet/BottomSheet.d.ts +1 -1
- package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheet.js +3 -1
- package/dist/BottomSheet/BottomSheetPanel.d.ts +7 -5
- package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheetPanel.js +56 -19
- package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
- package/dist/CheckboxInput/CheckboxInput.js +12 -2
- package/dist/Collapsible/Collapsible.d.ts.map +1 -1
- package/dist/Collapsible/Collapsible.js +6 -1
- package/dist/DateRangeInput/DateRangeInput.d.ts +3 -0
- package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
- package/dist/DateRangeInput/DateRangeInput.js +16 -9
- package/dist/Dialog/DialogHeader.d.ts +1 -1
- package/dist/Dialog/DialogHeader.d.ts.map +1 -1
- package/dist/Dialog/DialogHeader.js +10 -7
- package/dist/FileInput/FileInput.d.ts.map +1 -1
- package/dist/FileInput/FileInput.js +9 -3
- package/dist/Kbd/Kbd.d.ts +5 -3
- package/dist/Kbd/Kbd.d.ts.map +1 -1
- package/dist/Kbd/Kbd.js +36 -42
- package/dist/Link/Link.d.ts.map +1 -1
- package/dist/Link/Link.js +6 -2
- package/dist/Markdown/Markdown.d.ts +10 -2
- package/dist/Markdown/Markdown.d.ts.map +1 -1
- package/dist/Markdown/Markdown.js +58 -14
- package/dist/Markdown/index.d.ts +1 -1
- package/dist/Markdown/index.d.ts.map +1 -1
- package/dist/Markdown/parser.d.ts +126 -12
- package/dist/Markdown/parser.d.ts.map +1 -1
- package/dist/Markdown/parser.js +369 -34
- package/dist/Markdown/utils.d.ts +1 -1
- package/dist/Markdown/utils.d.ts.map +1 -1
- package/dist/PowerSearch/PowerSearchEditPopover.d.ts.map +1 -1
- package/dist/PowerSearch/PowerSearchEditPopover.js +46 -30
- package/dist/RadioList/RadioListItem.d.ts.map +1 -1
- package/dist/RadioList/RadioListItem.js +13 -1
- package/dist/SegmentedControl/SegmentedControlItem.d.ts.map +1 -1
- package/dist/SegmentedControl/SegmentedControlItem.js +5 -5
- package/dist/SideNav/SideNav.d.ts +2 -1
- package/dist/SideNav/SideNav.d.ts.map +1 -1
- package/dist/SideNav/SideNav.js +9 -3
- package/dist/Slider/Slider.d.ts.map +1 -1
- package/dist/Slider/Slider.js +19 -10
- package/dist/Spinner/Spinner.d.ts +1 -1
- package/dist/Spinner/Spinner.d.ts.map +1 -1
- package/dist/Spinner/Spinner.js +23 -15
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +11 -0
- package/dist/TabList/Tab.d.ts +1 -1
- package/dist/TabList/Tab.d.ts.map +1 -1
- package/dist/TabList/Tab.js +20 -7
- package/dist/ToggleButton/ToggleButton.d.ts +2 -1
- package/dist/ToggleButton/ToggleButton.d.ts.map +1 -1
- package/dist/ToggleButton/ToggleButton.js +7 -1
- package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
- package/dist/Typeahead/BaseTypeahead.js +15 -6
- package/dist/astryx.css +13 -2
- package/dist/hooks/scrollKeyboardDelegation.d.ts +3 -0
- package/dist/hooks/scrollKeyboardDelegation.d.ts.map +1 -0
- package/dist/hooks/scrollKeyboardDelegation.js +146 -0
- package/dist/hooks/useScrollableArea.d.ts +6 -2
- package/dist/hooks/useScrollableArea.d.ts.map +1 -1
- package/dist/hooks/useScrollableArea.js +17 -5
- package/dist/utils/interactionOverlay.stylex.d.ts +8 -0
- package/dist/utils/interactionOverlay.stylex.d.ts.map +1 -1
- package/dist/utils/interactionOverlay.stylex.js +9 -0
- package/locales/en.json +16 -0
- package/locales/pseudo.json +12 -0
- package/package.json +7 -5
- package/scripts/agent-doc-state.mjs +1 -1
- package/src/BottomSheet/BottomSheet.doc.mjs +8 -1
- package/src/BottomSheet/BottomSheet.spec.md +46 -20
- package/src/BottomSheet/BottomSheet.test.tsx +6 -3
- package/src/BottomSheet/BottomSheet.tsx +3 -1
- package/src/BottomSheet/BottomSheetKeyboard.test.tsx +195 -0
- package/src/BottomSheet/BottomSheetPanel.test.tsx +11 -1
- package/src/BottomSheet/BottomSheetPanel.tsx +49 -16
- package/src/BottomSheet/__tests__/BottomSheetKeyboard.a11y.browser.spec.ts +344 -0
- package/src/Button/__tests__/Button.a11y.chromium.spec.ts +17 -1
- package/src/Button/__tests__/Button.a11y.known-failures.ts +0 -29
- package/src/Button/__tests__/Button.a11y.renders.tsx +9 -2
- package/src/Button/__tests__/Button.a11y.states.ts +9 -0
- package/src/CheckboxInput/CheckboxInput.doc.mjs +11 -0
- package/src/CheckboxInput/CheckboxInput.test.tsx +34 -0
- package/src/CheckboxInput/CheckboxInput.tsx +21 -1
- package/src/ClickableCard/ClickableCard.test.tsx +102 -5
- package/src/Collapsible/Collapsible.doc.mjs +11 -0
- package/src/Collapsible/Collapsible.test.tsx +21 -0
- package/src/Collapsible/Collapsible.tsx +5 -0
- package/src/DateRangeInput/DateRangeInput.doc.mjs +35 -7
- package/src/DateRangeInput/DateRangeInput.spec.md +203 -0
- package/src/DateRangeInput/DateRangeInput.test.tsx +100 -4
- package/src/DateRangeInput/DateRangeInput.tsx +29 -20
- package/src/Dialog/Dialog.doc.mjs +3 -0
- package/src/Dialog/Dialog.spec.md +1 -1
- package/src/Dialog/DialogHeader.doc.mjs +38 -0
- package/src/Dialog/DialogHeader.test.tsx +49 -0
- package/src/Dialog/DialogHeader.tsx +23 -4
- package/src/Dialog/modules/DialogHeader.spec.md +152 -0
- package/src/FileInput/FileInput.doc.mjs +2 -0
- package/src/FileInput/FileInput.spec.md +199 -0
- package/src/FileInput/FileInput.test.tsx +14 -0
- package/src/FileInput/FileInput.tsx +13 -3
- package/src/Kbd/Kbd.doc.mjs +3 -3
- package/src/Kbd/Kbd.test.tsx +57 -1
- package/src/Kbd/Kbd.tsx +57 -37
- package/src/Link/Link.doc.mjs +11 -0
- package/src/Link/Link.test.tsx +24 -0
- package/src/Link/Link.tsx +5 -0
- package/src/Markdown/Markdown.doc.mjs +167 -42
- package/src/Markdown/Markdown.public.test.ts +157 -0
- package/src/Markdown/Markdown.spec.md +255 -71
- package/src/Markdown/Markdown.test.tsx +107 -3
- package/src/Markdown/Markdown.tsx +116 -35
- package/src/Markdown/incremental.test.ts +175 -7
- package/src/Markdown/index.ts +6 -0
- package/src/Markdown/parser.perf.test.ts +3 -1
- package/src/Markdown/parser.test.ts +122 -0
- package/src/Markdown/parser.ts +609 -81
- package/src/Markdown/utils.ts +6 -0
- package/src/Outline/Outline.spec.md +1 -1
- package/src/Outline/modules/parseOutlineFromMarkdown.spec.md +142 -0
- package/src/PowerSearch/PowerSearchEditPopover.test.tsx +150 -1
- package/src/PowerSearch/PowerSearchEditPopover.tsx +51 -28
- package/src/RadioList/RadioList.doc.mjs +11 -0
- package/src/RadioList/RadioList.test.tsx +32 -0
- package/src/RadioList/RadioListItem.tsx +25 -1
- package/src/ScrollableArea/modules/useScrollableArea.spec.md +50 -22
- package/src/SegmentedControl/SegmentedControl.doc.mjs +2 -2
- package/src/SegmentedControl/SegmentedControl.test.tsx +31 -0
- package/src/SegmentedControl/SegmentedControlItem.tsx +6 -9
- package/src/SideNav/SideNav.doc.mjs +1 -1
- package/src/SideNav/SideNav.test.tsx +10 -0
- package/src/SideNav/SideNav.tsx +14 -2
- package/src/Slider/Slider.doc.mjs +27 -0
- package/src/Slider/Slider.spec.md +61 -47
- package/src/Slider/Slider.test.tsx +146 -0
- package/src/Slider/Slider.tsx +37 -13
- package/src/Spinner/Spinner.doc.mjs +6 -3
- package/src/Spinner/Spinner.test.tsx +37 -0
- package/src/Spinner/Spinner.tsx +31 -14
- package/src/Switch/Switch.doc.mjs +11 -0
- package/src/Switch/Switch.test.tsx +16 -0
- package/src/Switch/Switch.tsx +28 -0
- package/src/TabList/Tab.tsx +35 -7
- package/src/TabList/TabList.doc.mjs +11 -0
- package/src/TabList/TabList.test.tsx +66 -0
- package/src/TabList/__tests__/Tabs.a11y.known-failures.ts +1 -34
- package/src/ToggleButton/ToggleButton.test.tsx +133 -0
- package/src/ToggleButton/ToggleButton.tsx +9 -2
- package/src/ToggleButton/__tests__/ToggleButton.a11y.chromium.spec.ts +209 -0
- package/src/Tokenizer/Tokenizer.spec.md +142 -75
- package/src/Typeahead/BaseTypeahead.spec.md +4 -3
- package/src/Typeahead/BaseTypeahead.tsx +15 -6
- package/src/Typeahead/Typeahead.test.tsx +53 -0
- package/src/__tests__/PressedState.a11y.chromium.spec.ts +813 -0
- package/src/__tests__/pressState.ts +93 -0
- package/src/hooks/scrollKeyboardDelegation.test.ts +155 -0
- package/src/hooks/scrollKeyboardDelegation.ts +233 -0
- package/src/hooks/useScrollableArea.doc.mjs +15 -3
- package/src/hooks/useScrollableArea.test.tsx +59 -1
- package/src/hooks/useScrollableArea.ts +34 -10
- package/src/theme/derivedVarRegistry.test.ts +6 -4
- package/src/utils/interactionOverlay.stylex.ts +20 -0
|
@@ -13,6 +13,7 @@ import {useState} from 'react';
|
|
|
13
13
|
import {describe, it, expect, vi, beforeEach} from 'vitest';
|
|
14
14
|
import {render, screen, act, fireEvent, waitFor} from '@testing-library/react';
|
|
15
15
|
import userEvent from '@testing-library/user-event';
|
|
16
|
+
import {declaresPressedOverlay} from '../__tests__/pressState';
|
|
16
17
|
import * as stylex from '@stylexjs/stylex';
|
|
17
18
|
import {Slider} from './Slider';
|
|
18
19
|
import {focusOutlineStyles} from '../utils/focusOutline.stylex';
|
|
@@ -137,6 +138,24 @@ describe('Slider', () => {
|
|
|
137
138
|
},
|
|
138
139
|
);
|
|
139
140
|
|
|
141
|
+
it('reflects orientation and disabled state on the interactive control target', () => {
|
|
142
|
+
render(
|
|
143
|
+
<Slider
|
|
144
|
+
label="Volume"
|
|
145
|
+
value={50}
|
|
146
|
+
orientation="vertical"
|
|
147
|
+
valueDisplay="text"
|
|
148
|
+
isDisabled
|
|
149
|
+
/>,
|
|
150
|
+
);
|
|
151
|
+
const control = screen.getByRole('slider').parentElement;
|
|
152
|
+
expect(control).not.toBeNull();
|
|
153
|
+
expect(control).toHaveClass('astryx-slider-control');
|
|
154
|
+
expect(control).toHaveAttribute('data-orientation', 'vertical');
|
|
155
|
+
expect(control).toHaveAttribute('data-disabled', 'disabled');
|
|
156
|
+
expect(control).not.toContainElement(screen.getByText('50'));
|
|
157
|
+
});
|
|
158
|
+
|
|
140
159
|
it('range mode sets correct aria values on both thumbs', () => {
|
|
141
160
|
render(
|
|
142
161
|
<Slider
|
|
@@ -1083,3 +1102,130 @@ describe('Slider', () => {
|
|
|
1083
1102
|
});
|
|
1084
1103
|
});
|
|
1085
1104
|
});
|
|
1105
|
+
|
|
1106
|
+
describe('pressed state', () => {
|
|
1107
|
+
it('paints the pressed overlay on the thumb for as long as it is dragged, from wherever the press lands', () => {
|
|
1108
|
+
render(
|
|
1109
|
+
<Slider label="Volume" value={50} min={0} max={100} onChange={vi.fn()} />,
|
|
1110
|
+
);
|
|
1111
|
+
const thumb = screen.getByRole('slider');
|
|
1112
|
+
const trackContainer = thumb.parentElement!;
|
|
1113
|
+
trackContainer.getBoundingClientRect = () => ({
|
|
1114
|
+
left: 0,
|
|
1115
|
+
top: 0,
|
|
1116
|
+
right: 200,
|
|
1117
|
+
bottom: 20,
|
|
1118
|
+
width: 200,
|
|
1119
|
+
height: 20,
|
|
1120
|
+
x: 0,
|
|
1121
|
+
y: 0,
|
|
1122
|
+
toJSON: () => {},
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
expect(declaresPressedOverlay(thumb)).toBe(false);
|
|
1126
|
+
// A slider is a drag: the press lands on the track, not the thumb, and
|
|
1127
|
+
// the thumb follows it. The pressed paint follows the drag.
|
|
1128
|
+
fireEvent.pointerDown(trackContainer, {
|
|
1129
|
+
clientX: 20,
|
|
1130
|
+
clientY: 10,
|
|
1131
|
+
pointerId: 1,
|
|
1132
|
+
});
|
|
1133
|
+
expect(declaresPressedOverlay(thumb)).toBe(true);
|
|
1134
|
+
fireEvent.pointerMove(trackContainer, {
|
|
1135
|
+
clientX: 120,
|
|
1136
|
+
clientY: 10,
|
|
1137
|
+
pointerId: 1,
|
|
1138
|
+
});
|
|
1139
|
+
expect(declaresPressedOverlay(thumb)).toBe(true);
|
|
1140
|
+
fireEvent.pointerUp(trackContainer, {
|
|
1141
|
+
clientX: 120,
|
|
1142
|
+
clientY: 10,
|
|
1143
|
+
pointerId: 1,
|
|
1144
|
+
});
|
|
1145
|
+
expect(declaresPressedOverlay(thumb)).toBe(false);
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
it('paints only the thumb being dragged in a range slider', () => {
|
|
1149
|
+
render(
|
|
1150
|
+
<Slider
|
|
1151
|
+
label="Price"
|
|
1152
|
+
value={[20, 80] as [number, number]}
|
|
1153
|
+
min={0}
|
|
1154
|
+
max={100}
|
|
1155
|
+
onChange={vi.fn()}
|
|
1156
|
+
/>,
|
|
1157
|
+
);
|
|
1158
|
+
const [start, end] = screen.getAllByRole('slider');
|
|
1159
|
+
// In range mode the control is the group; each thumb sits in its own
|
|
1160
|
+
// value-tooltip wrapper.
|
|
1161
|
+
const trackContainer = screen.getByRole('group');
|
|
1162
|
+
trackContainer.getBoundingClientRect = () => ({
|
|
1163
|
+
left: 0,
|
|
1164
|
+
top: 0,
|
|
1165
|
+
right: 200,
|
|
1166
|
+
bottom: 20,
|
|
1167
|
+
width: 200,
|
|
1168
|
+
height: 20,
|
|
1169
|
+
x: 0,
|
|
1170
|
+
y: 0,
|
|
1171
|
+
toJSON: () => {},
|
|
1172
|
+
});
|
|
1173
|
+
fireEvent.pointerDown(trackContainer, {
|
|
1174
|
+
clientX: 170,
|
|
1175
|
+
clientY: 10,
|
|
1176
|
+
pointerId: 1,
|
|
1177
|
+
});
|
|
1178
|
+
expect(declaresPressedOverlay(end)).toBe(true);
|
|
1179
|
+
expect(declaresPressedOverlay(start)).toBe(false);
|
|
1180
|
+
fireEvent.pointerUp(trackContainer, {
|
|
1181
|
+
clientX: 170,
|
|
1182
|
+
clientY: 10,
|
|
1183
|
+
pointerId: 1,
|
|
1184
|
+
});
|
|
1185
|
+
expect(declaresPressedOverlay(end)).toBe(false);
|
|
1186
|
+
});
|
|
1187
|
+
|
|
1188
|
+
it('keeps a direct press on the visible upper thumb when range values coincide', () => {
|
|
1189
|
+
render(
|
|
1190
|
+
<Slider
|
|
1191
|
+
label="Price"
|
|
1192
|
+
value={[50, 50] as [number, number]}
|
|
1193
|
+
min={0}
|
|
1194
|
+
max={100}
|
|
1195
|
+
onChange={vi.fn()}
|
|
1196
|
+
/>,
|
|
1197
|
+
);
|
|
1198
|
+
const [start, end] = screen.getAllByRole('slider');
|
|
1199
|
+
const trackContainer = screen.getByRole('group');
|
|
1200
|
+
trackContainer.getBoundingClientRect = () => ({
|
|
1201
|
+
left: 0,
|
|
1202
|
+
top: 0,
|
|
1203
|
+
right: 200,
|
|
1204
|
+
bottom: 20,
|
|
1205
|
+
width: 200,
|
|
1206
|
+
height: 20,
|
|
1207
|
+
x: 0,
|
|
1208
|
+
y: 0,
|
|
1209
|
+
toJSON: () => {},
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1212
|
+
fireEvent.pointerDown(end, {
|
|
1213
|
+
clientX: 100,
|
|
1214
|
+
clientY: 10,
|
|
1215
|
+
pointerId: 1,
|
|
1216
|
+
});
|
|
1217
|
+
expect(declaresPressedOverlay(end)).toBe(true);
|
|
1218
|
+
expect(declaresPressedOverlay(start)).toBe(false);
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
it('does not press a disabled slider', () => {
|
|
1222
|
+
render(<Slider label="Volume" value={50} isDisabled onChange={vi.fn()} />);
|
|
1223
|
+
const thumb = screen.getByRole('slider');
|
|
1224
|
+
fireEvent.pointerDown(thumb.parentElement!, {
|
|
1225
|
+
clientX: 20,
|
|
1226
|
+
clientY: 10,
|
|
1227
|
+
pointerId: 1,
|
|
1228
|
+
});
|
|
1229
|
+
expect(declaresPressedOverlay(thumb)).toBe(false);
|
|
1230
|
+
});
|
|
1231
|
+
});
|
package/src/Slider/Slider.tsx
CHANGED
|
@@ -288,6 +288,15 @@ const styles = stylex.create({
|
|
|
288
288
|
},
|
|
289
289
|
},
|
|
290
290
|
},
|
|
291
|
+
// Pressed: the system's pressed overlay over the thumb's fill for as long
|
|
292
|
+
// as the thumb is being dragged. A slider is a drag, not a tap — the finger
|
|
293
|
+
// lands anywhere on the track and the thumb follows it — so the pressed
|
|
294
|
+
// paint follows the drag state the pointer handlers already keep, on a
|
|
295
|
+
// mouse and on a finger alike, rather than `:active` on the thumb itself
|
|
296
|
+
// (which a press on the track never activates).
|
|
297
|
+
thumbPressed: {
|
|
298
|
+
backgroundImage: `linear-gradient(${colorVars['--color-overlay-pressed']}, ${colorVars['--color-overlay-pressed']})`,
|
|
299
|
+
},
|
|
291
300
|
thumbDisabled: {
|
|
292
301
|
backgroundColor: colorVars['--color-background-muted'],
|
|
293
302
|
cursor: 'default',
|
|
@@ -701,17 +710,25 @@ export function Slider({ref, ...props}: SliderProps) {
|
|
|
701
710
|
const newVal = markEl
|
|
702
711
|
? Number(markEl.dataset.markValue)
|
|
703
712
|
: getValueFromPosition(e.clientX, e.clientY);
|
|
704
|
-
const
|
|
713
|
+
const track = trackRef.current;
|
|
714
|
+
const thumbs = track?.querySelectorAll<HTMLElement>('[role="slider"]');
|
|
715
|
+
const pressedThumb = (e.target as Element).closest<HTMLElement>(
|
|
716
|
+
'[role="slider"]',
|
|
717
|
+
);
|
|
718
|
+
const pressedThumbIndex =
|
|
719
|
+
pressedThumb == null || thumbs == null
|
|
720
|
+
? -1
|
|
721
|
+
: Array.from(thumbs).indexOf(pressedThumb);
|
|
722
|
+
// A direct thumb press owns that thumb even when range values coincide.
|
|
723
|
+
// Track and mark presses still choose the nearest value.
|
|
724
|
+
const thumbIndex =
|
|
725
|
+
pressedThumbIndex >= 0 ? pressedThumbIndex : getClosestThumb(newVal);
|
|
705
726
|
draggingThumbRef.current = thumbIndex;
|
|
706
727
|
setDraggingThumb(thumbIndex);
|
|
707
728
|
updateValue(thumbIndex, newVal);
|
|
708
729
|
|
|
709
|
-
// Focus the
|
|
710
|
-
|
|
711
|
-
if (track) {
|
|
712
|
-
const thumbs = track.querySelectorAll<HTMLElement>('[role="slider"]');
|
|
713
|
-
thumbs[thumbIndex]?.focus();
|
|
714
|
-
}
|
|
730
|
+
// Focus the thumb that owns this drag.
|
|
731
|
+
thumbs?.[thumbIndex]?.focus();
|
|
715
732
|
// Also clear it explicitly: focusing an already-focused thumb fires no
|
|
716
733
|
// focus event, so a thumb the user had tabbed to would keep its ring
|
|
717
734
|
// through the drag.
|
|
@@ -908,6 +925,7 @@ export function Slider({ref, ...props}: SliderProps) {
|
|
|
908
925
|
? styles.thumbHorizontal
|
|
909
926
|
: rtlStyles.centerInline('50%'),
|
|
910
927
|
!isDisabled && styles.thumbHover,
|
|
928
|
+
!isDisabled && draggingThumb === thumbIndex && styles.thumbPressed,
|
|
911
929
|
!isDisabled &&
|
|
912
930
|
keyboardFocusThumb === thumbIndex &&
|
|
913
931
|
focusOutlineStyles.focusVisible,
|
|
@@ -1029,12 +1047,18 @@ export function Slider({ref, ...props}: SliderProps) {
|
|
|
1029
1047
|
onPointerMove={handlePointerMove}
|
|
1030
1048
|
onPointerUp={handlePointerUp}
|
|
1031
1049
|
onPointerCancel={handlePointerUp}
|
|
1032
|
-
{...
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
?
|
|
1036
|
-
|
|
1037
|
-
|
|
1050
|
+
{...mergeProps(
|
|
1051
|
+
themeProps('slider-control', {
|
|
1052
|
+
orientation,
|
|
1053
|
+
disabled: isDisabled ? 'disabled' : null,
|
|
1054
|
+
}),
|
|
1055
|
+
stylex.props(
|
|
1056
|
+
styles.trackContainer,
|
|
1057
|
+
isHorizontal
|
|
1058
|
+
? styles.trackContainerHorizontal
|
|
1059
|
+
: styles.trackContainerVertical,
|
|
1060
|
+
isDisabled && styles.trackContainerDisabled,
|
|
1061
|
+
),
|
|
1038
1062
|
)}>
|
|
1039
1063
|
{/* Background track */}
|
|
1040
1064
|
<div
|
|
@@ -30,7 +30,7 @@ export const docs = {
|
|
|
30
30
|
name: 'aria-label',
|
|
31
31
|
type: 'string',
|
|
32
32
|
description:
|
|
33
|
-
'Accessible name for screen readers. Defaults to label (if string) or "Loading".',
|
|
33
|
+
'Accessible name for screen readers. Defaults to label (if string) or a translated "Loading" from the i18n catalog.',
|
|
34
34
|
default: "'Loading'",
|
|
35
35
|
},
|
|
36
36
|
{
|
|
@@ -48,6 +48,7 @@ export const docs = {
|
|
|
48
48
|
{name: '--spinner-stroke-width', description: 'Stroke width of both circles the ring is drawn from: the moving arc and the track behind it. Set it per size alongside the diameter. One stroke width drives both, so 0 is honoured as a zero-width stroke and paints nothing at all rather than falling back to the default; for an arc with no track behind it, set --spinner-track-color to transparent instead.', default: '2px (sm), 3px (md), 3px (lg), 4px (xl)'},
|
|
49
49
|
{name: '--spinner-color', description: "Color of the moving arc. Defaults to the shade's token, so set it on a shade-variant target to retheme one shade (spinner: { 'shade:subtle': { '--spinner-color': 'var(--color-text-tertiary)' } }), or on the base target to retheme all four. Accepts any color notation, including var(), color-mix() and currentColor.", default: 'var(--color-accent) (default), var(--color-text-secondary) (subtle), var(--color-on-dark) (onMedia), currentColor (inherit)'},
|
|
50
50
|
{name: '--spinner-track-color', description: 'Color of the track the arc travels on. Set it to `transparent` for an arc with no track. The onMedia and inherit shades draw the track at reduced alpha (30%) so it reads against an arbitrary backdrop; that fade applies to a themed color too.', default: 'var(--color-track) (default, subtle), var(--color-on-dark) (onMedia), currentColor (inherit)'},
|
|
51
|
+
{name: '--spinner-arc-fraction', description: "Fraction of the ring the moving arc covers, as a plain number (not a percentage or angle). Set it on a size-variant target the same way as --spinner-diameter, e.g. spinner: { 'size:xl': { '--spinner-arc-fraction': '0.75' } } for a 270deg sweep. Only takes effect once the stylesheet loads; a render with no CSS (SSR, no-JS) always draws the default 135deg arc.", default: '0.375 (135deg), same for every size'},
|
|
51
52
|
],
|
|
52
53
|
},
|
|
53
54
|
usage: {
|
|
@@ -87,7 +88,7 @@ export const docsZh = {
|
|
|
87
88
|
{
|
|
88
89
|
name: 'aria-label',
|
|
89
90
|
type: 'string',
|
|
90
|
-
description: '屏幕阅读器的无障碍名称。默认为 label
|
|
91
|
+
description: '屏幕阅读器的无障碍名称。默认为 label(如果是字符串)或来自 i18n 词条的已翻译 "Loading"。',
|
|
91
92
|
default: "'Loading'",
|
|
92
93
|
},
|
|
93
94
|
{
|
|
@@ -106,6 +107,7 @@ export const docsZh = {
|
|
|
106
107
|
{name: '--spinner-stroke-width', description: '绘制环的两个圆——移动圆弧与其后的轨道——的描边宽度。与直径一起按尺寸设置。同一个描边宽度同时驱动两者,因此 0 会被采纳为零宽描边——什么都不绘制,而不会回退到默认值;若想要没有轨道的圆弧,请改将 --spinner-track-color 设为 transparent。', default: '2px (sm), 3px (md), 3px (lg), 4px (xl)'},
|
|
107
108
|
{name: '--spinner-color', description: "运动圆弧的颜色。默认取所在 shade 的令牌,因此可在 shade 变体目标上设置以重新定义单个 shade——spinner: { 'shade:subtle': { '--spinner-color': 'var(--color-text-tertiary)' } }——或在 base 目标上设置以覆盖全部四种。接受任意颜色写法,包括 var()、color-mix() 与 currentColor。", default: 'var(--color-accent)(default)、var(--color-text-secondary)(subtle)、var(--color-on-dark)(onMedia)、currentColor(inherit)'},
|
|
108
109
|
{name: '--spinner-track-color', description: '圆弧所在轨道的颜色。设为 `transparent` 可得到无轨道的圆弧。onMedia 与 inherit 两种 shade 会以降低的透明度(30%)绘制轨道,以便在任意背景上可辨;该淡化同样作用于主题化的颜色。', default: 'var(--color-track)(default、subtle)、var(--color-on-dark)(onMedia)、currentColor(inherit)'},
|
|
110
|
+
{name: '--spinner-arc-fraction', description: "运动圆弧覆盖圆环的比例,为纯数字(非百分比或角度)。在尺寸变体目标上设置,方式与 --spinner-diameter 相同,例如 spinner: { 'size:xl': { '--spinner-arc-fraction': '0.75' } } 可得到 270 度的圆弧。仅在样式表加载后生效;无 CSS 的渲染(SSR、无 JS)始终绘制默认的 135 度圆弧。", default: '0.375(135 度),各尺寸相同'},
|
|
109
111
|
],
|
|
110
112
|
},
|
|
111
113
|
usage: {
|
|
@@ -136,7 +138,8 @@ export const docsDense = {
|
|
|
136
138
|
size: 'Spinner size: ring diameter (10px, 14px, 18px, 28px).',
|
|
137
139
|
shade: 'Color shade for light or dark backgrounds.',
|
|
138
140
|
label: 'Visible content below spinner. String auto-sets aria-label.',
|
|
139
|
-
'aria-label':
|
|
141
|
+
'aria-label':
|
|
142
|
+
'A11y name for screen readers. Defaults to label or a translated "Loading".',
|
|
140
143
|
xstyle: 'StyleX styles for layout customization. Must be stylex.create() value, not inline style.',
|
|
141
144
|
},
|
|
142
145
|
};
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import {describe, it, expect, vi, afterEach} from 'vitest';
|
|
13
13
|
import {render, screen} from '@testing-library/react';
|
|
14
14
|
import {Spinner} from './Spinner';
|
|
15
|
+
import {InternationalizationProvider} from '../i18n';
|
|
15
16
|
import {defineTheme} from '../theme/defineTheme';
|
|
16
17
|
import {generateThemeCSS} from '../theme/generateThemeRules';
|
|
17
18
|
|
|
@@ -72,6 +73,34 @@ describe('Spinner', () => {
|
|
|
72
73
|
expect(spinner).toHaveAttribute('data-shade', 'inherit');
|
|
73
74
|
});
|
|
74
75
|
|
|
76
|
+
it('localizes the default assistive label through the i18n catalog', () => {
|
|
77
|
+
render(
|
|
78
|
+
<InternationalizationProvider
|
|
79
|
+
locale="fr"
|
|
80
|
+
overrides={{fr: {'@astryx.spinner.loading': 'Chargement'}}}>
|
|
81
|
+
<Spinner data-testid="spinner" />
|
|
82
|
+
</InternationalizationProvider>,
|
|
83
|
+
);
|
|
84
|
+
expect(screen.getByTestId('spinner')).toHaveAttribute(
|
|
85
|
+
'aria-label',
|
|
86
|
+
'Chargement',
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('keeps an explicit aria-label over the localized default', () => {
|
|
91
|
+
render(
|
|
92
|
+
<InternationalizationProvider
|
|
93
|
+
locale="fr"
|
|
94
|
+
overrides={{fr: {'@astryx.spinner.loading': 'Chargement'}}}>
|
|
95
|
+
<Spinner aria-label="Veuillez patienter" data-testid="spinner" />
|
|
96
|
+
</InternationalizationProvider>,
|
|
97
|
+
);
|
|
98
|
+
expect(screen.getByTestId('spinner')).toHaveAttribute(
|
|
99
|
+
'aria-label',
|
|
100
|
+
'Veuillez patienter',
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
75
104
|
it('does not duplicate a visible string label as aria-label', () => {
|
|
76
105
|
render(<Spinner label="Fetching data" data-testid="spinner" />);
|
|
77
106
|
const status = screen.getByRole('status');
|
|
@@ -204,6 +233,14 @@ describe('Spinner', () => {
|
|
|
204
233
|
'.astryx-spinner {\n --spinner-color: var(--color-brand);',
|
|
205
234
|
);
|
|
206
235
|
});
|
|
236
|
+
|
|
237
|
+
it('scopes a themed arc fraction to that size variant (#5819)', () => {
|
|
238
|
+
expect(
|
|
239
|
+
cssFor({spinner: {'size:xl': {'--spinner-arc-fraction': '0.75'}}}),
|
|
240
|
+
).toContain(
|
|
241
|
+
'.astryx-spinner[data-size="xl"] {\n --spinner-arc-fraction: 0.75;',
|
|
242
|
+
);
|
|
243
|
+
});
|
|
207
244
|
});
|
|
208
245
|
});
|
|
209
246
|
|
package/src/Spinner/Spinner.tsx
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* @file Spinner.tsx
|
|
7
|
-
* @input Uses React, StyleX, SVG rendering
|
|
7
|
+
* @input Uses React, i18n (useTranslator), StyleX, SVG rendering
|
|
8
8
|
* @output Exports Spinner component, SpinnerProps, SpinnerSize, SpinnerShade types
|
|
9
9
|
* @position Core implementation of spinner loading indicator
|
|
10
10
|
*
|
|
@@ -22,6 +22,7 @@ import {colorVars, durationVars, spacingVars} from '../theme/tokens.stylex';
|
|
|
22
22
|
import type {BaseProps} from '../BaseProps';
|
|
23
23
|
import {Text} from '../Text/Text';
|
|
24
24
|
import {mergeProps} from '../utils';
|
|
25
|
+
import {useTranslator} from '../i18n';
|
|
25
26
|
import {themeProps} from '../utils/themeProps';
|
|
26
27
|
|
|
27
28
|
// =============================================================================
|
|
@@ -29,20 +30,17 @@ import {themeProps} from '../utils/themeProps';
|
|
|
29
30
|
// =============================================================================
|
|
30
31
|
|
|
31
32
|
/**
|
|
32
|
-
*
|
|
33
|
-
* swept 135deg, not the 270deg its constant's comment claimed.
|
|
33
|
+
* Default fraction of the ring the moving arc covers. The canvas ring this
|
|
34
|
+
* replaces swept 135deg, not the 270deg its constant's comment claimed.
|
|
35
|
+
*
|
|
36
|
+
* Themeable via `--spinner-arc-fraction`, declared alongside the other public
|
|
37
|
+
* vars in `sizeStyles` below. Only the inline `strokeDasharray` attribute
|
|
38
|
+
* (the pre-stylesheet render — see its own comment) still reads this
|
|
39
|
+
* constant directly; the CSS side composes the dash from the live var.
|
|
34
40
|
*/
|
|
35
41
|
const ARC_FRACTION = 0.375;
|
|
36
42
|
|
|
37
|
-
/**
|
|
38
|
-
* The dash pattern, per unit of diameter: one arc, then the gap that closes
|
|
39
|
-
* the circle. The circumference is `pi x diameter`, so multiplying the
|
|
40
|
-
* resolved diameter by these two constants gives exactly the lengths the
|
|
41
|
-
* default render has always used, and scales them with a themed diameter.
|
|
42
|
-
*/
|
|
43
43
|
const PI = 3.141592653589793;
|
|
44
|
-
const ARC_DASH = PI * ARC_FRACTION;
|
|
45
|
-
const ARC_GAP = PI * (1 - ARC_FRACTION);
|
|
46
44
|
|
|
47
45
|
const SIZES = {
|
|
48
46
|
sm: {diameter: 10, border: 2},
|
|
@@ -322,10 +320,16 @@ const styles = stylex.create({
|
|
|
322
320
|
// of the circle — 87.398 against the 87.965 of pi x 28 — which shortens the
|
|
323
321
|
// default arc by 0.64% and moves the cap by half a pixel. Composing the
|
|
324
322
|
// lengths keeps the default byte-identical to what it drew before.
|
|
323
|
+
//
|
|
324
|
+
// The fraction itself also rides a public var (`--spinner-arc-fraction`),
|
|
325
|
+
// unregistered like the other three color/geometry public vars — it is
|
|
326
|
+
// read as a bare `<number>` multiplier here, never summed with a unitless
|
|
327
|
+
// `0` the way the registered length pair guards against, so it needs no
|
|
328
|
+
// registration.
|
|
325
329
|
arc: {
|
|
326
330
|
stroke: 'var(--spinner-color)',
|
|
327
331
|
transform: 'rotate(-90deg)',
|
|
328
|
-
strokeDasharray: `calc(var(${RESOLVED_DIAMETER}) * ${
|
|
332
|
+
strokeDasharray: `calc(var(${RESOLVED_DIAMETER}) * ${PI} * var(--spinner-arc-fraction)) calc(var(${RESOLVED_DIAMETER}) * ${PI} * (1 - var(--spinner-arc-fraction)))`,
|
|
329
333
|
},
|
|
330
334
|
track: {stroke: 'var(--spinner-track-color)'},
|
|
331
335
|
});
|
|
@@ -348,22 +352,30 @@ const styles = stylex.create({
|
|
|
348
352
|
// cost of its own — with nothing declaring the var,
|
|
349
353
|
// `theme-var-reachability.js` cannot find an element to check, so a documented
|
|
350
354
|
// var reads as unreachable.
|
|
355
|
+
// Arc fraction is not itself size-dependent, but it declares alongside the
|
|
356
|
+
// two vars that are, on the same per-size condition (`!hasLabel` gate at the
|
|
357
|
+
// call site) — it needs a home on whichever element carries the theme
|
|
358
|
+
// target, and this is the object already wired to be there.
|
|
351
359
|
const sizeStyles = stylex.create({
|
|
352
360
|
sm: {
|
|
353
361
|
'--spinner-diameter': `${SIZES.sm.diameter}px`,
|
|
354
362
|
'--spinner-stroke-width': `${SIZES.sm.border}px`,
|
|
363
|
+
'--spinner-arc-fraction': `${ARC_FRACTION}`,
|
|
355
364
|
},
|
|
356
365
|
md: {
|
|
357
366
|
'--spinner-diameter': `${SIZES.md.diameter}px`,
|
|
358
367
|
'--spinner-stroke-width': `${SIZES.md.border}px`,
|
|
368
|
+
'--spinner-arc-fraction': `${ARC_FRACTION}`,
|
|
359
369
|
},
|
|
360
370
|
lg: {
|
|
361
371
|
'--spinner-diameter': `${SIZES.lg.diameter}px`,
|
|
362
372
|
'--spinner-stroke-width': `${SIZES.lg.border}px`,
|
|
373
|
+
'--spinner-arc-fraction': `${ARC_FRACTION}`,
|
|
363
374
|
},
|
|
364
375
|
xl: {
|
|
365
376
|
'--spinner-diameter': `${SIZES.xl.diameter}px`,
|
|
366
377
|
'--spinner-stroke-width': `${SIZES.xl.border}px`,
|
|
378
|
+
'--spinner-arc-fraction': `${ARC_FRACTION}`,
|
|
367
379
|
},
|
|
368
380
|
});
|
|
369
381
|
|
|
@@ -484,6 +496,7 @@ export function Spinner({
|
|
|
484
496
|
const arcLength = circumference * ARC_FRACTION;
|
|
485
497
|
const hasLabel = label != null;
|
|
486
498
|
const labelId = useId();
|
|
499
|
+
const t = useTranslator();
|
|
487
500
|
|
|
488
501
|
// When a visible string label renders (and no explicit aria-label is set),
|
|
489
502
|
// name the status element from the visible Text via aria-labelledby instead
|
|
@@ -492,9 +505,13 @@ export function Spinner({
|
|
|
492
505
|
const namedByVisibleLabel =
|
|
493
506
|
hasLabel && typeof label === 'string' && ariaLabel == null;
|
|
494
507
|
|
|
495
|
-
// Resolve accessible name: explicit aria-label > string label >
|
|
508
|
+
// Resolve accessible name: explicit aria-label > string label > the
|
|
509
|
+
// localized default. The fallback is AT-facing text, so it goes through the
|
|
510
|
+
// translation runtime like visible text does.
|
|
496
511
|
const resolvedAriaLabel =
|
|
497
|
-
ariaLabel ??
|
|
512
|
+
ariaLabel ??
|
|
513
|
+
(typeof label === 'string' ? label : undefined) ??
|
|
514
|
+
t('@astryx.spinner.loading');
|
|
498
515
|
|
|
499
516
|
const spinner = (
|
|
500
517
|
<span
|
|
@@ -210,6 +210,17 @@ export const docs = {
|
|
|
210
210
|
],
|
|
211
211
|
},
|
|
212
212
|
usage: {
|
|
213
|
+
accessibility: [
|
|
214
|
+
{
|
|
215
|
+
name: 'Track and thumb',
|
|
216
|
+
category: 'Color contrast',
|
|
217
|
+
criterion: '1.4.11 Non-text Contrast',
|
|
218
|
+
requirement: '3:1',
|
|
219
|
+
states: ['Rest', 'Hover', 'Pointer down'],
|
|
220
|
+
description:
|
|
221
|
+
'The on and off tracks must each have at least 3:1 contrast with the surface behind them, and the thumb with its track. For Hover and Pointer down, measure the final colors after the tint and the pressed overlay are applied.',
|
|
222
|
+
},
|
|
223
|
+
],
|
|
213
224
|
anatomy,
|
|
214
225
|
description:
|
|
215
226
|
'A toggle control for on/off states that take effect immediately. Supports labels, descriptions, loading states, and validation. Use it for settings or preferences that apply instantly. For changes requiring a form submission, use a checkbox instead.',
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
getAllInjectedCss,
|
|
31
31
|
getForcedColorsRules,
|
|
32
32
|
} from '../__tests__/forcedColors';
|
|
33
|
+
import {hasPressedArm} from '../__tests__/pressState';
|
|
33
34
|
import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
|
|
34
35
|
|
|
35
36
|
afterEach(() => {
|
|
@@ -755,6 +756,21 @@ describe('Switch', () => {
|
|
|
755
756
|
});
|
|
756
757
|
});
|
|
757
758
|
|
|
759
|
+
describe('pressed state', () => {
|
|
760
|
+
it('paints the pressed overlay on the track and the thumb while the row is pressed', () => {
|
|
761
|
+
const {container} = render(
|
|
762
|
+
<Switch label="Notifications" value={false} onChange={() => {}} />,
|
|
763
|
+
);
|
|
764
|
+
const track = container.querySelector('.astryx-switch');
|
|
765
|
+
const thumb = container.querySelector('.astryx-switch-thumb');
|
|
766
|
+
if (track == null || thumb == null) {
|
|
767
|
+
throw new Error('the switch has no track or thumb to press');
|
|
768
|
+
}
|
|
769
|
+
expect(hasPressedArm(track)).toBe(true);
|
|
770
|
+
expect(hasPressedArm(thumb)).toBe(true);
|
|
771
|
+
});
|
|
772
|
+
});
|
|
773
|
+
|
|
758
774
|
describe('coarse pointer and RTL hit-target positioning', () => {
|
|
759
775
|
it.each([
|
|
760
776
|
['sm', 'ltr'],
|
package/src/Switch/Switch.tsx
CHANGED
|
@@ -122,6 +122,15 @@ const thumbOnSizeStyles = stylex.create({
|
|
|
122
122
|
},
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
+
// The pressed overlay, painted as a gradient layer OVER the track's and
|
|
126
|
+
// thumb's own fills so it composes with the on/off colors and the hover tint
|
|
127
|
+
// instead of replacing them. Same token Button's overlay paints with. The
|
|
128
|
+
// switch is a two-part control whose focusable input sits beside the track,
|
|
129
|
+
// so the press is read off the shared scope marker (the row), the way the
|
|
130
|
+
// hover tint already is: pressing the input, the track or the label all
|
|
131
|
+
// activate the row.
|
|
132
|
+
const pressedImage = `linear-gradient(${colorVars['--color-overlay-pressed']}, ${colorVars['--color-overlay-pressed']})`;
|
|
133
|
+
|
|
125
134
|
const labelWrapperSizeStyles = stylex.create({
|
|
126
135
|
sm: {
|
|
127
136
|
minHeight: 20,
|
|
@@ -207,6 +216,15 @@ const styles = stylex.create({
|
|
|
207
216
|
default: null,
|
|
208
217
|
'@media (forced-colors: active)': 'CanvasText',
|
|
209
218
|
},
|
|
219
|
+
// Pressed: the overlay rides on top of the on/off fill. Gated like the
|
|
220
|
+
// hover tint so forced colors keeps its system-color track.
|
|
221
|
+
backgroundImage: {
|
|
222
|
+
default: null,
|
|
223
|
+
[stylex.when.ancestor(':active', switchScope)]: {
|
|
224
|
+
default: null,
|
|
225
|
+
'@media (forced-colors: none)': pressedImage,
|
|
226
|
+
},
|
|
227
|
+
},
|
|
210
228
|
},
|
|
211
229
|
// The one ring in the system not drawn by focusOutlineStyles. The focusable
|
|
212
230
|
// input is a sibling of the track, so the condition has to reach the shared
|
|
@@ -278,6 +296,16 @@ const styles = stylex.create({
|
|
|
278
296
|
'@media (prefers-reduced-motion: reduce)': '0s',
|
|
279
297
|
},
|
|
280
298
|
transitionTimingFunction: easeVars['--ease-standard'],
|
|
299
|
+
// Pressed: the same overlay as the track, so the whole control darkens
|
|
300
|
+
// under the finger rather than the thumb standing out against a darker
|
|
301
|
+
// track.
|
|
302
|
+
backgroundImage: {
|
|
303
|
+
default: null,
|
|
304
|
+
[stylex.when.ancestor(':active', switchScope)]: {
|
|
305
|
+
default: null,
|
|
306
|
+
'@media (forced-colors: none)': pressedImage,
|
|
307
|
+
},
|
|
308
|
+
},
|
|
281
309
|
},
|
|
282
310
|
// The thumb fill lives on the on/off styles (not the shared thumb style)
|
|
283
311
|
// because forced colors needs a per-state system color: CanvasText on the
|
package/src/TabList/Tab.tsx
CHANGED
|
@@ -137,6 +137,15 @@ const styles = stylex.create({
|
|
|
137
137
|
[stylex.when.ancestor(':hover', tabScope)]: {
|
|
138
138
|
'@media (hover: hover)': colorVars['--color-overlay-hover'],
|
|
139
139
|
},
|
|
140
|
+
// Pressed: the same layer steps up to the pressed overlay, read off the
|
|
141
|
+
// tab the way the hover is, so a press paints on a finger too. Repeated
|
|
142
|
+
// inside the hover-capable branch so it outranks the hover rule there
|
|
143
|
+
// (a media-nested rule carries extra generated priority; see
|
|
144
|
+
// interactionOverlay.stylex.ts).
|
|
145
|
+
[stylex.when.ancestor(':active', tabScope)]: {
|
|
146
|
+
default: colorVars['--color-overlay-pressed'],
|
|
147
|
+
'@media (hover: hover)': colorVars['--color-overlay-pressed'],
|
|
148
|
+
},
|
|
140
149
|
},
|
|
141
150
|
transitionProperty: 'background-color',
|
|
142
151
|
transitionDuration: durationVars['--duration-fast'],
|
|
@@ -249,6 +258,7 @@ export function Tab({
|
|
|
249
258
|
xstyle,
|
|
250
259
|
className,
|
|
251
260
|
style,
|
|
261
|
+
onClick,
|
|
252
262
|
...restProps
|
|
253
263
|
}: TabProps) {
|
|
254
264
|
const tabListCtx = useTabListContext();
|
|
@@ -263,9 +273,23 @@ export function Tab({
|
|
|
263
273
|
const displayIcon = isSelected && selectedIcon ? selectedIcon : icon;
|
|
264
274
|
const hasVisibleLabel = !isLabelHidden && label !== '';
|
|
265
275
|
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
276
|
+
const isDisabled =
|
|
277
|
+
restProps['aria-disabled'] === true ||
|
|
278
|
+
restProps['aria-disabled'] === 'true';
|
|
279
|
+
|
|
280
|
+
const handleSelect = useCallback(
|
|
281
|
+
(event: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
|
|
282
|
+
if (isDisabled) {
|
|
283
|
+
event.preventDefault();
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
onClick?.(event as React.MouseEvent<HTMLButtonElement>);
|
|
287
|
+
if (!event.defaultPrevented) {
|
|
288
|
+
tabListCtx.onChange(value);
|
|
289
|
+
}
|
|
290
|
+
},
|
|
291
|
+
[isDisabled, onClick, tabListCtx, value],
|
|
292
|
+
);
|
|
269
293
|
|
|
270
294
|
useDevWarning(
|
|
271
295
|
'Tab',
|
|
@@ -339,7 +363,7 @@ export function Tab({
|
|
|
339
363
|
sizeStyles[size],
|
|
340
364
|
isSelected && styles.selected,
|
|
341
365
|
isFill && layoutStyles.fill,
|
|
342
|
-
tabScope,
|
|
366
|
+
!isDisabled && tabScope,
|
|
343
367
|
xstyle,
|
|
344
368
|
),
|
|
345
369
|
className,
|
|
@@ -382,10 +406,14 @@ export function Tab({
|
|
|
382
406
|
) : null;
|
|
383
407
|
|
|
384
408
|
if (isLink) {
|
|
409
|
+
// A disabled link tab must not reach a router component: some routers reject
|
|
410
|
+
// a missing destination, while others treat it as the current route. A
|
|
411
|
+
// plain anchor without href preserves the element shape but cannot navigate.
|
|
412
|
+
const LinkRoot = isDisabled ? 'a' : LinkComponent;
|
|
385
413
|
return (
|
|
386
|
-
<
|
|
414
|
+
<LinkRoot
|
|
387
415
|
ref={ref}
|
|
388
|
-
href={href}
|
|
416
|
+
href={isDisabled ? undefined : href}
|
|
389
417
|
onClick={handleSelect}
|
|
390
418
|
{...sharedProps}>
|
|
391
419
|
{hoverBgElement}
|
|
@@ -393,7 +421,7 @@ export function Tab({
|
|
|
393
421
|
{labelElement}
|
|
394
422
|
{endContentElement}
|
|
395
423
|
{indicatorElement}
|
|
396
|
-
</
|
|
424
|
+
</LinkRoot>
|
|
397
425
|
);
|
|
398
426
|
}
|
|
399
427
|
|
|
@@ -103,6 +103,17 @@ export const docs = {
|
|
|
103
103
|
{name: 'TabMenu'},
|
|
104
104
|
],
|
|
105
105
|
usage: {
|
|
106
|
+
accessibility: [
|
|
107
|
+
{
|
|
108
|
+
name: 'Tab label',
|
|
109
|
+
category: 'Color contrast',
|
|
110
|
+
criterion: '1.4.3 Contrast (Minimum)',
|
|
111
|
+
requirement: '4.5:1',
|
|
112
|
+
states: ['Rest', 'Hover', 'Pointer down', 'Selected'],
|
|
113
|
+
description:
|
|
114
|
+
'Each label must have at least 4.5:1 contrast with the tab surface behind it. For Hover and Pointer down, measure the final background after the overlay layer is applied.',
|
|
115
|
+
},
|
|
116
|
+
],
|
|
106
117
|
description:
|
|
107
118
|
'TabList provides tab-style navigation for organizing content into categorized sections. Use it to let users switch between related views without leaving the page, with overflow items handled by a built-in "more" menu.',
|
|
108
119
|
bestPractices: [
|