@astryxdesign/core 0.6.2-canary.bfee8a4 → 0.6.2-canary.d4cc232
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/CheckboxInput/CheckboxInput.d.ts.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/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.map +1 -1
- package/dist/Markdown/Markdown.js +63 -59
- package/dist/Markdown/ast.d.ts +124 -0
- package/dist/Markdown/ast.d.ts.map +1 -0
- package/dist/Markdown/ast.js +38 -0
- package/dist/Markdown/parser.d.ts +14 -8
- package/dist/Markdown/parser.d.ts.map +1 -1
- package/dist/Markdown/parser.js +352 -97
- package/dist/Outline/parseOutlineFromMarkdown.d.ts.map +1 -1
- package/dist/Outline/parseOutlineFromMarkdown.js +7 -7
- 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 +14 -8
- 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 +8 -0
- 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/package.json +3 -3
- 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/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.spec.md +159 -43
- package/src/Markdown/Markdown.test.tsx +11 -0
- package/src/Markdown/Markdown.tsx +106 -96
- package/src/Markdown/ast.test.ts +169 -0
- package/src/Markdown/ast.ts +231 -0
- package/src/Markdown/incremental.test.ts +12 -0
- package/src/Markdown/parser.ts +427 -118
- package/src/Outline/Outline.spec.md +1 -1
- package/src/Outline/modules/parseOutlineFromMarkdown.spec.md +142 -0
- package/src/Outline/parseOutlineFromMarkdown.ts +8 -13
- 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/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 +11 -0
- package/src/Slider/Slider.test.tsx +128 -0
- package/src/Slider/Slider.tsx +25 -7
- 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/utils/interactionOverlay.stylex.ts +20 -0
|
@@ -33,6 +33,7 @@ import {mergeProps, composeEventHandlers} from '../utils';
|
|
|
33
33
|
import type {BaseProps} from '../BaseProps';
|
|
34
34
|
import {themeProps} from '../utils/themeProps';
|
|
35
35
|
import {focusOutlineProps} from '../utils/focusOutline.stylex';
|
|
36
|
+
import {interactionOverlayStyles} from '../utils/interactionOverlay.stylex';
|
|
36
37
|
|
|
37
38
|
export interface SegmentedControlItemProps extends BaseProps<HTMLButtonElement> {
|
|
38
39
|
ref?: React.Ref<HTMLButtonElement>;
|
|
@@ -91,14 +92,6 @@ const styles = stylex.create({
|
|
|
91
92
|
transitionDuration: durationVars['--duration-fast'],
|
|
92
93
|
transitionTimingFunction: easeVars['--ease-standard'],
|
|
93
94
|
},
|
|
94
|
-
hover: {
|
|
95
|
-
backgroundColor: {
|
|
96
|
-
default: null,
|
|
97
|
-
':hover:where(:not(:disabled,[aria-disabled="true"]))': {
|
|
98
|
-
'@media (hover: hover)': colorVars['--color-overlay-hover'],
|
|
99
|
-
},
|
|
100
|
-
},
|
|
101
|
-
},
|
|
102
95
|
selected: {
|
|
103
96
|
// Forced colors (Windows High Contrast) strips the painted surface fill
|
|
104
97
|
// and box shadow, which would leave the selected segment with no state
|
|
@@ -248,7 +241,11 @@ export function SegmentedControlItem({
|
|
|
248
241
|
sizeStyles[size],
|
|
249
242
|
isFill && styles.fill,
|
|
250
243
|
isSelected && styles.selected,
|
|
251
|
-
|
|
244
|
+
// The shared hover and pressed overlay, on the segments a press can
|
|
245
|
+
// change: the selected segment keeps its raised surface as it is.
|
|
246
|
+
!isSelected &&
|
|
247
|
+
!isItemDisabled &&
|
|
248
|
+
interactionOverlayStyles.backgroundColor,
|
|
252
249
|
isItemDisabled && styles.disabled,
|
|
253
250
|
xstyle,
|
|
254
251
|
),
|
|
@@ -25,7 +25,7 @@ export const docs = {
|
|
|
25
25
|
{className: 'astryx-side-nav-section'},
|
|
26
26
|
],
|
|
27
27
|
},
|
|
28
|
-
description: 'Container with five zones: header, topContent, children (scrollable), footer, and footerIcons. Supports collapsible
|
|
28
|
+
description: 'Container with five zones: header, topContent, children (scrollable), footer, and footerIcons. Supports collapsible and resizable modes.',
|
|
29
29
|
props: [
|
|
30
30
|
{
|
|
31
31
|
name: 'header',
|
|
@@ -989,6 +989,16 @@ describe('SideNav resizable', () => {
|
|
|
989
989
|
).toBeInTheDocument();
|
|
990
990
|
});
|
|
991
991
|
|
|
992
|
+
it('clips the overlay drag handle within the resizable nav bounds', () => {
|
|
993
|
+
render(<SideNav resizable>Content</SideNav>);
|
|
994
|
+
const nav = screen.getByRole('navigation');
|
|
995
|
+
const container = nav.parentElement;
|
|
996
|
+
const handle = screen.getByTestId('astryx-sidenav-resize-handle');
|
|
997
|
+
|
|
998
|
+
expect(container).toContainElement(handle);
|
|
999
|
+
expect(getComputedStyle(container!).overflow).toBe('clip');
|
|
1000
|
+
});
|
|
1001
|
+
|
|
992
1002
|
it('does not render drag handle without resizable', () => {
|
|
993
1003
|
render(<SideNav>Content</SideNav>);
|
|
994
1004
|
expect(
|
package/src/SideNav/SideNav.tsx
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* Sidebar navigation container with five zones: header + topContent (sticky together),
|
|
12
12
|
* children (scrollable), footer, and footerIcons (sticky bottom).
|
|
13
13
|
*
|
|
14
|
-
* Supports optional resize via drag handle at the inline-end edge.
|
|
14
|
+
* Supports optional resize via a drag handle at the inline-end edge. The
|
|
15
|
+
* resizable wrapper clips handle overflow while keeping its focus ring inside.
|
|
15
16
|
*
|
|
16
17
|
* SYNC: When modified, update these files to stay in sync:
|
|
17
18
|
* - /packages/core/src/SideNav/SideNav.doc.mjs
|
|
@@ -25,7 +26,7 @@ import {useCallback, useImperativeHandle, useRef, type ReactNode} from 'react';
|
|
|
25
26
|
import type {BaseProps} from '../BaseProps';
|
|
26
27
|
import * as stylex from '@stylexjs/stylex';
|
|
27
28
|
import type {StyleXStyles} from '@stylexjs/stylex';
|
|
28
|
-
import {spacingVars} from '../theme/tokens.stylex';
|
|
29
|
+
import {focusVars, spacingVars} from '../theme/tokens.stylex';
|
|
29
30
|
import {mergeProps} from '../utils';
|
|
30
31
|
import {
|
|
31
32
|
SideNavCollapseContext,
|
|
@@ -241,6 +242,16 @@ const styles = stylex.create({
|
|
|
241
242
|
display: 'flex',
|
|
242
243
|
flexShrink: 0,
|
|
243
244
|
height: '100%',
|
|
245
|
+
overflow: 'clip',
|
|
246
|
+
},
|
|
247
|
+
resizableHandle: {
|
|
248
|
+
// Keep the shared ring inside the clipping boundary. The wrapper cannot
|
|
249
|
+
// use overflow-clip-margin: that paint allowance also contributes the
|
|
250
|
+
// overlay's half-pixel spill to AppShell's scrollable overflow.
|
|
251
|
+
outlineOffset: {
|
|
252
|
+
default: '0',
|
|
253
|
+
':focus-visible': `calc(0px - ${focusVars['--focus-outline-width']})`,
|
|
254
|
+
},
|
|
244
255
|
},
|
|
245
256
|
// Topbar mode — horizontal layout for mobile top bar
|
|
246
257
|
topbar: {
|
|
@@ -658,6 +669,7 @@ export function SideNav({
|
|
|
658
669
|
position="overlay"
|
|
659
670
|
pillPlacement="end"
|
|
660
671
|
isAlwaysVisible={false}
|
|
672
|
+
xstyle={styles.resizableHandle}
|
|
661
673
|
resizable={resizableHook.props}
|
|
662
674
|
label={t('@astryx.sideNav.resizeSidebar')}
|
|
663
675
|
/>
|
|
@@ -255,6 +255,17 @@ export const docs = {
|
|
|
255
255
|
],
|
|
256
256
|
},
|
|
257
257
|
usage: {
|
|
258
|
+
accessibility: [
|
|
259
|
+
{
|
|
260
|
+
name: 'Thumb',
|
|
261
|
+
category: 'Color contrast',
|
|
262
|
+
criterion: '1.4.11 Non-text Contrast',
|
|
263
|
+
requirement: '3:1',
|
|
264
|
+
states: ['Rest', 'Hover', 'Pointer down'],
|
|
265
|
+
description:
|
|
266
|
+
'The thumb must have at least 3:1 contrast with the track and the surface behind it. Pointer down is the whole drag: measure the thumb with the pressed overlay applied.',
|
|
267
|
+
},
|
|
268
|
+
],
|
|
258
269
|
anatomy,
|
|
259
270
|
description:
|
|
260
271
|
'A draggable control for selecting a numeric value or range within defined bounds. Supports single value and range selection, tick marks, custom value formatting, and vertical orientation. Use it when users need to explore a continuous range, such as volume, price, or percentage.',
|
|
@@ -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';
|
|
@@ -1101,3 +1102,130 @@ describe('Slider', () => {
|
|
|
1101
1102
|
});
|
|
1102
1103
|
});
|
|
1103
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,
|
|
@@ -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: [
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import {describe, it, expect, vi, beforeAll, afterAll} from 'vitest';
|
|
13
13
|
import {render, screen, fireEvent} from '@testing-library/react';
|
|
14
14
|
import userEvent from '@testing-library/user-event';
|
|
15
|
+
import {hasPressedArm} from '../__tests__/pressState';
|
|
15
16
|
import {TabList} from './TabList';
|
|
16
17
|
import type {TabListProps} from './TabList';
|
|
17
18
|
import {Tab} from './Tab';
|
|
@@ -1515,3 +1516,68 @@ describe('TabList ARIA pattern — any other role', () => {
|
|
|
1515
1516
|
);
|
|
1516
1517
|
});
|
|
1517
1518
|
});
|
|
1519
|
+
|
|
1520
|
+
describe('pressed state', () => {
|
|
1521
|
+
it('paints the pressed overlay on the tab surface while the tab is pressed', () => {
|
|
1522
|
+
render(
|
|
1523
|
+
<TabList value="home" onChange={() => {}}>
|
|
1524
|
+
<Tab value="home" label="Home" />
|
|
1525
|
+
<Tab value="settings" label="Settings" />
|
|
1526
|
+
</TabList>,
|
|
1527
|
+
);
|
|
1528
|
+
const tab = screen.getByRole('button', {name: 'Settings'});
|
|
1529
|
+
// The hover surface is a child layer read off the tab; the press paints
|
|
1530
|
+
// the same layer, so the assertion is on that layer.
|
|
1531
|
+
const surface = tab.querySelector('span[aria-hidden="true"]');
|
|
1532
|
+
if (surface == null) {
|
|
1533
|
+
throw new Error('the tab has no hover surface to press');
|
|
1534
|
+
}
|
|
1535
|
+
expect(hasPressedArm(surface)).toBe(true);
|
|
1536
|
+
});
|
|
1537
|
+
|
|
1538
|
+
it('does not activate a disabled button tab or call its handler', async () => {
|
|
1539
|
+
const user = userEvent.setup();
|
|
1540
|
+
const onChange = vi.fn();
|
|
1541
|
+
const onClick = vi.fn();
|
|
1542
|
+
render(
|
|
1543
|
+
<TabList value="home" onChange={onChange}>
|
|
1544
|
+
<Tab value="home" label="Home" />
|
|
1545
|
+
<Tab
|
|
1546
|
+
value="settings"
|
|
1547
|
+
label="Settings"
|
|
1548
|
+
aria-disabled="true"
|
|
1549
|
+
onClick={onClick}
|
|
1550
|
+
/>
|
|
1551
|
+
</TabList>,
|
|
1552
|
+
);
|
|
1553
|
+
const tab = screen.getByRole('button', {name: 'Settings'});
|
|
1554
|
+
await user.click(tab);
|
|
1555
|
+
expect(onClick).not.toHaveBeenCalled();
|
|
1556
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
1557
|
+
});
|
|
1558
|
+
|
|
1559
|
+
it('removes navigation from a disabled link tab', () => {
|
|
1560
|
+
const onChange = vi.fn();
|
|
1561
|
+
const {container} = render(
|
|
1562
|
+
<LinkProvider component={CustomLink}>
|
|
1563
|
+
<TabList value="home" onChange={onChange}>
|
|
1564
|
+
<Tab value="home" label="Home" />
|
|
1565
|
+
<Tab
|
|
1566
|
+
value="settings"
|
|
1567
|
+
label="Settings"
|
|
1568
|
+
href="/settings"
|
|
1569
|
+
aria-disabled="true"
|
|
1570
|
+
/>
|
|
1571
|
+
</TabList>
|
|
1572
|
+
</LinkProvider>,
|
|
1573
|
+
);
|
|
1574
|
+
const tab = container.querySelector('a');
|
|
1575
|
+
if (tab == null) {
|
|
1576
|
+
throw new Error('the disabled link tab did not render an anchor');
|
|
1577
|
+
}
|
|
1578
|
+
expect(tab).not.toHaveAttribute('data-custom-link');
|
|
1579
|
+
expect(tab).not.toHaveAttribute('href');
|
|
1580
|
+
fireEvent.click(tab);
|
|
1581
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
1582
|
+
});
|
|
1583
|
+
});
|
|
@@ -9,37 +9,4 @@
|
|
|
9
9
|
|
|
10
10
|
import type {KnownFailure} from '@astryxdesign/a11y-spec';
|
|
11
11
|
|
|
12
|
-
const
|
|
13
|
-
binding: 'Tab',
|
|
14
|
-
state: 'tab-disabled-unselected',
|
|
15
|
-
evidenceLayer: 'real-browser',
|
|
16
|
-
standardsReference:
|
|
17
|
-
'WCAG 2.2 4.1.2 Name, Role, Value; WAI-ARIA 1.2 aria-disabled',
|
|
18
|
-
reason:
|
|
19
|
-
'This migration records the existing behavior without changing the component interaction contract.',
|
|
20
|
-
} as const;
|
|
21
|
-
|
|
22
|
-
export const TABS_KNOWN_FAILURES: ReadonlyArray<KnownFailure> = [
|
|
23
|
-
{
|
|
24
|
-
...COMMON,
|
|
25
|
-
expectation: 'tabs.tab.unavailable-pointer-inert',
|
|
26
|
-
failureEquals:
|
|
27
|
-
'the tab is reported unavailable, but clicking it selected it',
|
|
28
|
-
userImpact:
|
|
29
|
-
'A pointer user can activate a tab that assistive technology reports as unavailable.',
|
|
30
|
-
},
|
|
31
|
-
{
|
|
32
|
-
...COMMON,
|
|
33
|
-
expectation: 'tabs.tab.unavailable-enter-inert',
|
|
34
|
-
failureEquals: 'the tab is reported unavailable, but Enter selected it',
|
|
35
|
-
userImpact:
|
|
36
|
-
'A keyboard user can activate a focusable tab with Enter after assistive technology reports it unavailable.',
|
|
37
|
-
},
|
|
38
|
-
{
|
|
39
|
-
...COMMON,
|
|
40
|
-
expectation: 'tabs.tab.unavailable-space-inert',
|
|
41
|
-
failureEquals: 'the tab is reported unavailable, but Space selected it',
|
|
42
|
-
userImpact:
|
|
43
|
-
'A keyboard user can activate a focusable tab with Space after assistive technology reports it unavailable.',
|
|
44
|
-
},
|
|
45
|
-
];
|
|
12
|
+
export const TABS_KNOWN_FAILURES: ReadonlyArray<KnownFailure> = [];
|