@astryxdesign/core 0.7.3-canary.b74cf21 → 0.7.3-canary.ca48bb7
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/BottomSheet/BottomSheet.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheet.js +8 -4
- package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheetSwitcher.js +7 -0
- package/dist/Calendar/Calendar.js +3 -2
- package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
- package/dist/CheckboxInput/CheckboxInput.js +19 -24
- package/dist/ClickableCard/ClickableCard.d.ts +2 -2
- package/dist/ClickableCard/ClickableCard.d.ts.map +1 -1
- package/dist/ClickableCard/ClickableCard.js +11 -9
- package/dist/CodeBlock/CodeBlock.d.ts.map +1 -1
- package/dist/CodeBlock/CodeBlock.js +11 -1
- package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
- package/dist/ComplexSelector/ComplexSelector.js +23 -5
- package/dist/DateInput/DateInput.d.ts +4 -4
- package/dist/DateInput/DateInput.d.ts.map +1 -1
- package/dist/DateInput/DateInput.js +27 -20
- package/dist/DateInput/NativeDateField.d.ts.map +1 -1
- package/dist/DateInput/NativeDateField.js +14 -8
- package/dist/DateInput/TouchDateField.d.ts.map +1 -1
- package/dist/DateInput/TouchDateField.js +14 -10
- package/dist/DateRangeInput/DateRangeInput.d.ts +5 -5
- package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
- package/dist/DateRangeInput/DateRangeInput.js +27 -18
- package/dist/Dialog/Dialog.d.ts.map +1 -1
- package/dist/Dialog/Dialog.js +9 -4
- package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenu.js +52 -10
- package/dist/DropdownMenu/DropdownMenuContext.d.ts +34 -1
- package/dist/DropdownMenu/DropdownMenuContext.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuContext.js +15 -0
- package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts +21 -2
- package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuSubMenu.js +375 -42
- package/dist/DropdownMenu/menuRegionStops.d.ts +2 -2
- package/dist/DropdownMenu/menuRegionStops.js +2 -2
- package/dist/FileInput/FileInput.d.ts.map +1 -1
- package/dist/FileInput/FileInput.js +14 -12
- package/dist/Link/Link.d.ts +2 -3
- package/dist/Link/Link.d.ts.map +1 -1
- package/dist/Link/Link.js +11 -19
- package/dist/Markdown/Markdown.d.ts.map +1 -1
- package/dist/Markdown/Markdown.js +48 -15
- package/dist/MultiSelector/MultiSelector.d.ts +4 -4
- package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
- package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.d.ts +2 -0
- package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.d.ts.map +1 -0
- package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.js +48 -0
- package/dist/MultiSelector/MultiSelector.js +25 -18
- package/dist/Pagination/Pagination.d.ts.map +1 -1
- package/dist/Pagination/Pagination.js +5 -2
- package/dist/Popover/Popover.d.ts.map +1 -1
- package/dist/Popover/Popover.js +8 -4
- package/dist/Selector/Selector.d.ts +4 -4
- package/dist/Selector/Selector.d.ts.map +1 -1
- package/dist/Selector/Selector.js +23 -14
- package/dist/Slider/Slider.d.ts.map +1 -1
- package/dist/Slider/Slider.js +7 -14
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +25 -17
- package/dist/Switch/switch.markers.stylex.d.ts +2 -0
- package/dist/Switch/switch.markers.stylex.d.ts.map +1 -1
- package/dist/Switch/switch.markers.stylex.js +6 -0
- package/dist/TimeInput/TimeInput.d.ts +2 -1
- package/dist/TimeInput/TimeInput.d.ts.map +1 -1
- package/dist/Timestamp/Timestamp.d.ts.map +1 -1
- package/dist/Timestamp/Timestamp.js +41 -8
- package/dist/Token/Token.d.ts.map +1 -1
- package/dist/Token/Token.js +8 -3
- package/dist/Tooltip/Tooltip.d.ts.map +1 -1
- package/dist/Tooltip/Tooltip.js +36 -14
- package/dist/TopNav/TopNav.d.ts.map +1 -1
- package/dist/TopNav/TopNav.js +2 -2
- package/dist/TopNav/TopNavHeading.d.ts.map +1 -1
- package/dist/TopNav/TopNavHeading.js +1 -0
- package/dist/TreeList/TreeListItem.d.ts.map +1 -1
- package/dist/TreeList/TreeListItem.js +8 -3
- package/dist/astryx.css +7 -2
- package/dist/hooks/useGridFocus.d.ts.map +1 -1
- package/dist/hooks/useGridFocus.js +10 -1
- package/dist/hooks/useMenuPress.d.ts +11 -1
- package/dist/hooks/useMenuPress.d.ts.map +1 -1
- package/dist/hooks/useMenuPress.js +43 -5
- package/dist/utils/disabledInteraction.d.ts +21 -0
- package/dist/utils/disabledInteraction.d.ts.map +1 -0
- package/dist/utils/disabledInteraction.js +33 -0
- package/dist/utils/timeParser.d.ts +4 -0
- package/dist/utils/timeParser.d.ts.map +1 -1
- package/dist/utils/timeParser.js +9 -2
- package/package.json +3 -3
- package/src/Banner/Banner.spec.md +37 -32
- package/src/BottomSheet/BottomSheet.spec.md +26 -53
- package/src/BottomSheet/BottomSheet.test.tsx +29 -0
- package/src/BottomSheet/BottomSheet.tsx +8 -4
- package/src/BottomSheet/BottomSheetSwitcher.spec.md +45 -48
- package/src/BottomSheet/BottomSheetSwitcher.test.tsx +26 -0
- package/src/BottomSheet/BottomSheetSwitcher.tsx +6 -0
- package/src/Calendar/Calendar.test.tsx +68 -24
- package/src/Calendar/Calendar.tsx +2 -1
- package/src/CheckboxInput/CheckboxInput.test.tsx +44 -5
- package/src/CheckboxInput/CheckboxInput.tsx +16 -26
- package/src/CheckboxInput/__tests__/Checkbox.a11y.states.ts +3 -3
- package/src/CheckboxList/CheckboxList.test.tsx +9 -6
- package/src/ClickableCard/ClickableCard.doc.mjs +2 -2
- package/src/ClickableCard/ClickableCard.test.tsx +5 -4
- package/src/ClickableCard/ClickableCard.tsx +9 -9
- package/src/CodeBlock/CodeBlock.highlightStyles.test.tsx +87 -0
- package/src/CodeBlock/CodeBlock.tsx +11 -1
- package/src/ComplexSelector/ComplexSelector.focusableDisabled.test.tsx +76 -0
- package/src/ComplexSelector/ComplexSelector.tsx +21 -4
- package/src/ContextMenu/ContextMenu.test.tsx +41 -0
- package/src/DateInput/DateInput.doc.mjs +9 -8
- package/src/DateInput/DateInput.test.tsx +88 -13
- package/src/DateInput/DateInput.tsx +49 -21
- package/src/DateInput/DateInputTouch.test.tsx +22 -1
- package/src/DateInput/NativeDateField.test.tsx +29 -3
- package/src/DateInput/NativeDateField.tsx +20 -7
- package/src/DateInput/TouchDateField.tsx +24 -9
- package/src/DateRangeInput/DateRangeInput.doc.mjs +6 -5
- package/src/DateRangeInput/DateRangeInput.test.tsx +82 -16
- package/src/DateRangeInput/DateRangeInput.tsx +40 -20
- package/src/Dialog/Dialog.test.tsx +39 -0
- package/src/Dialog/Dialog.tsx +9 -4
- package/src/DropdownMenu/DropdownMenu.spec.md +33 -2
- package/src/DropdownMenu/DropdownMenu.tsx +79 -8
- package/src/DropdownMenu/DropdownMenuContext.public.test.ts +22 -0
- package/src/DropdownMenu/DropdownMenuContext.tsx +39 -1
- package/src/DropdownMenu/DropdownMenuSubMenu.doc.mjs +16 -1
- package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +623 -1
- package/src/DropdownMenu/DropdownMenuSubMenu.tsx +556 -99
- package/src/DropdownMenu/__tests__/SubMenuHeaderFooter.a11y.chromium.spec.ts +267 -0
- package/src/DropdownMenu/menuRegionStops.ts +2 -2
- package/src/DropdownMenu/modules/useMenuPress.spec.md +9 -0
- package/src/FieldStatus/FieldStatus.spec.md +3 -2
- package/src/FileInput/FileInput.test.tsx +30 -7
- package/src/FileInput/FileInput.tsx +21 -11
- package/src/Link/Link.test.tsx +21 -7
- package/src/Link/Link.tsx +16 -24
- package/src/Link/__tests__/Link.a11y.states.ts +1 -1
- package/src/Markdown/Markdown.rerender.test.tsx +143 -0
- package/src/Markdown/Markdown.tsx +89 -35
- package/src/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.ts +71 -0
- package/src/MultiSelector/MultiSelector.doc.mjs +5 -5
- package/src/MultiSelector/MultiSelector.focusableDisabled.test.tsx +121 -0
- package/src/MultiSelector/MultiSelector.test.tsx +49 -7
- package/src/MultiSelector/MultiSelector.tsx +53 -29
- package/src/Pagination/Pagination.test.tsx +5 -1
- package/src/Pagination/Pagination.tsx +3 -1
- package/src/Popover/Popover.test.tsx +15 -0
- package/src/Popover/Popover.tsx +19 -8
- package/src/PowerSearch/PowerSearchFilterEditor.test.tsx +3 -2
- package/src/PowerSearch/PowerSearchToken.test.tsx +7 -5
- package/src/Selector/Selector.doc.mjs +4 -4
- package/src/Selector/Selector.focusableDisabled.test.tsx +127 -0
- package/src/Selector/Selector.test.tsx +48 -6
- package/src/Selector/Selector.tsx +51 -21
- package/src/SideNav/SideNav.test.tsx +35 -0
- package/src/Slider/Slider.test.tsx +3 -3
- package/src/Slider/Slider.tsx +8 -15
- package/src/Switch/Switch.test.tsx +85 -8
- package/src/Switch/Switch.tsx +26 -16
- package/src/Switch/__tests__/Switch.a11y.states.ts +2 -2
- package/src/Switch/switch.markers.stylex.ts +4 -0
- package/src/Table/plugins/selection/useTableSelection.test.tsx +5 -3
- package/src/TimeInput/TimeInput.doc.mjs +5 -3
- package/src/TimeInput/TimeInput.test.tsx +20 -0
- package/src/TimeInput/TimeInput.tsx +2 -1
- package/src/Timestamp/Timestamp.doc.mjs +1 -1
- package/src/Timestamp/Timestamp.test.tsx +168 -0
- package/src/Timestamp/Timestamp.tsx +62 -11
- package/src/Token/Token.test.tsx +26 -4
- package/src/Token/Token.tsx +6 -2
- package/src/Tooltip/Tooltip.test.tsx +51 -6
- package/src/Tooltip/Tooltip.tsx +41 -18
- package/src/Tooltip/__tests__/Tooltip.a11y.chromium.spec.ts +129 -0
- package/src/Tooltip/__tests__/Tooltip.a11y.renders.tsx +86 -0
- package/src/Tooltip/__tests__/Tooltip.a11y.states.ts +132 -0
- package/src/Tooltip/__tests__/Tooltip.a11y.test.tsx +126 -0
- package/src/TopNav/TopNav.tsx +11 -1
- package/src/TopNav/TopNavHeading.tsx +1 -0
- package/src/TopNav/__tests__/TopNavMobileLongHeading.a11y.chromium.spec.ts +74 -0
- package/src/TreeList/TreeList.test.tsx +28 -0
- package/src/TreeList/TreeListItem.tsx +6 -2
- package/src/__tests__/DisabledTriggerFocus.a11y.chromium.spec.ts +86 -0
- package/src/hooks/useGridFocus.ts +16 -1
- package/src/hooks/useMenuPress.ts +45 -7
- package/src/utils/disabledInteraction.ts +40 -0
- package/src/utils/timeParser.test.ts +11 -0
- package/src/utils/timeParser.ts +9 -2
|
@@ -17,7 +17,14 @@
|
|
|
17
17
|
* - /packages/cli/assets/templates/blocks/components/Timestamp/ (showcase blocks)
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
lazy,
|
|
22
|
+
Suspense,
|
|
23
|
+
useEffect,
|
|
24
|
+
useRef,
|
|
25
|
+
useState,
|
|
26
|
+
useSyncExternalStore,
|
|
27
|
+
} from 'react';
|
|
21
28
|
import * as stylex from '@stylexjs/stylex';
|
|
22
29
|
import {Text} from '../Text';
|
|
23
30
|
import type {TextType, TextSize, TextColor, TextWeight} from '../theme/types';
|
|
@@ -215,6 +222,13 @@ function getLiveInterval(diffSeconds: number): number {
|
|
|
215
222
|
return 300_000; // every 5 minutes
|
|
216
223
|
}
|
|
217
224
|
|
|
225
|
+
// Whether this render may read the current time. Server renders (including
|
|
226
|
+
// static prerenders) and the hydration pass read `false`, so the markup never
|
|
227
|
+
// depends on when it was rendered; client renders read `true`.
|
|
228
|
+
const subscribeToNothing = () => () => {};
|
|
229
|
+
const canReadClockOnClient = () => true;
|
|
230
|
+
const canReadClockOnServer = () => false;
|
|
231
|
+
|
|
218
232
|
/** Whether a format is non-relative (i.e. shows a fixed date/time). */
|
|
219
233
|
function isAbsoluteFormat(
|
|
220
234
|
format: TimestampFormat,
|
|
@@ -279,7 +293,29 @@ export function Timestamp({
|
|
|
279
293
|
const locale = useLocale();
|
|
280
294
|
const timeRef = useRef<HTMLTimeElement>(null);
|
|
281
295
|
const mergedTimeRef = useMergedRefs(ref, timeRef);
|
|
282
|
-
|
|
296
|
+
// Only relative and auto formats depend on the current time. Absolute
|
|
297
|
+
// formats never read the clock, so they render identically on the server,
|
|
298
|
+
// in a static prerender, and on the client. A clock-dependent format reads
|
|
299
|
+
// it on the client only: server renders and the hydration pass show the
|
|
300
|
+
// full date and time in UTC, and the client switches to the relative phrase
|
|
301
|
+
// right after hydration.
|
|
302
|
+
const needsClock = !isAbsoluteFormat(format);
|
|
303
|
+
const canReadClock = useSyncExternalStore(
|
|
304
|
+
subscribeToNothing,
|
|
305
|
+
canReadClockOnClient,
|
|
306
|
+
canReadClockOnServer,
|
|
307
|
+
);
|
|
308
|
+
const [now, setNow] = useState<Date | null>(() =>
|
|
309
|
+
needsClock && canReadClock ? new Date() : null,
|
|
310
|
+
);
|
|
311
|
+
// Read the clock during render once it may be read: after hydration (the
|
|
312
|
+
// store re-renders with its client value) and when `format` switches from
|
|
313
|
+
// an absolute value to a clock-dependent one, so the placeholder never
|
|
314
|
+
// commits on the client.
|
|
315
|
+
if (needsClock && canReadClock && now === null) {
|
|
316
|
+
// eslint-disable-next-line @eslint-react/purity -- seeds clock state once, like the initializer; never runs on the server or while hydrating
|
|
317
|
+
setNow(new Date());
|
|
318
|
+
}
|
|
283
319
|
|
|
284
320
|
const date = parseValue(value);
|
|
285
321
|
// An unparseable value (a malformed date string, or a NaN timestamp from
|
|
@@ -289,25 +325,40 @@ export function Timestamp({
|
|
|
289
325
|
const isValidDate = !Number.isNaN(date.getTime());
|
|
290
326
|
const isoString = isValidDate ? date.toISOString() : '';
|
|
291
327
|
|
|
292
|
-
// Determine effective format
|
|
293
|
-
|
|
294
|
-
const
|
|
295
|
-
|
|
328
|
+
// Determine effective format. Until the clock is read, a clock-dependent
|
|
329
|
+
// format renders as the full date and time.
|
|
330
|
+
const isClockPending = needsClock && now === null;
|
|
331
|
+
const diffSeconds =
|
|
332
|
+
now === null ? 0 : Math.round((now.getTime() - date.getTime()) / 1000);
|
|
333
|
+
const effectiveFormat: TimestampFormat = isClockPending
|
|
334
|
+
? 'date_time'
|
|
335
|
+
: format === 'auto'
|
|
296
336
|
? Math.abs(diffSeconds) <= autoThreshold
|
|
297
337
|
? 'relative'
|
|
298
338
|
: 'date_time'
|
|
299
339
|
: format;
|
|
300
340
|
|
|
301
|
-
// Format the display text
|
|
302
|
-
//
|
|
341
|
+
// Format the display text in the viewer's own zone; only the tooltip names
|
|
342
|
+
// others. The one exception is the pre-hydration placeholder, written in
|
|
343
|
+
// UTC with its label so every zone produces the same markup.
|
|
303
344
|
const displayText = !isValidDate
|
|
304
345
|
? ''
|
|
305
346
|
: effectiveFormat === 'relative'
|
|
306
|
-
? formatRelativeTime(date, now, locale, 'long')
|
|
347
|
+
? formatRelativeTime(date, now ?? date, locale, 'long')
|
|
307
348
|
: effectiveFormat === 'relative_short'
|
|
308
|
-
? formatRelativeTime(date, now, locale, 'narrow')
|
|
349
|
+
? formatRelativeTime(date, now ?? date, locale, 'narrow')
|
|
309
350
|
: isAbsoluteFormat(effectiveFormat)
|
|
310
|
-
? formatInstant(
|
|
351
|
+
? formatInstant(
|
|
352
|
+
date,
|
|
353
|
+
effectiveFormat,
|
|
354
|
+
locale,
|
|
355
|
+
// The pre-hydration placeholder is written in UTC, labelled, so
|
|
356
|
+
// the server and the hydrating client produce the same text in
|
|
357
|
+
// every time zone.
|
|
358
|
+
isClockPending
|
|
359
|
+
? {timeZone: 'UTC', isTimezoneShown: true}
|
|
360
|
+
: {isTimezoneShown},
|
|
361
|
+
)
|
|
311
362
|
: '';
|
|
312
363
|
|
|
313
364
|
// Full absolute text for the tooltip (visible — keeps the compact timezone
|
package/src/Token/Token.test.tsx
CHANGED
|
@@ -134,8 +134,10 @@ describe('Token', () => {
|
|
|
134
134
|
/>,
|
|
135
135
|
);
|
|
136
136
|
const button = screen.getByRole('button', {name: 'Disabled'});
|
|
137
|
-
expect(button).
|
|
137
|
+
expect(button).not.toHaveAttribute('disabled');
|
|
138
|
+
expect(button).toHaveAttribute('aria-disabled', 'true');
|
|
138
139
|
expect(button.tagName).toBe('BUTTON');
|
|
140
|
+
fireEvent.click(button);
|
|
139
141
|
// Container click is also disabled
|
|
140
142
|
const container = screen.getByTestId('token');
|
|
141
143
|
fireEvent.click(container);
|
|
@@ -254,14 +256,34 @@ describe('Token accessibility', () => {
|
|
|
254
256
|
).toBeInTheDocument();
|
|
255
257
|
});
|
|
256
258
|
|
|
257
|
-
it('
|
|
259
|
+
it('keeps both buttons focusable and inert when isDisabled is true', async () => {
|
|
260
|
+
const user = userEvent.setup();
|
|
261
|
+
const handleClick = vi.fn();
|
|
262
|
+
const handleRemove = vi.fn();
|
|
258
263
|
render(
|
|
259
|
-
<Token
|
|
264
|
+
<Token
|
|
265
|
+
label="Token"
|
|
266
|
+
onClick={handleClick}
|
|
267
|
+
onRemove={handleRemove}
|
|
268
|
+
isDisabled
|
|
269
|
+
/>,
|
|
260
270
|
);
|
|
261
271
|
const buttons = screen.getAllByRole('button');
|
|
272
|
+
expect(buttons).toHaveLength(2);
|
|
262
273
|
for (const button of buttons) {
|
|
263
|
-
expect(button).
|
|
274
|
+
expect(button).not.toHaveAttribute('disabled');
|
|
275
|
+
expect(button).toHaveAttribute('aria-disabled', 'true');
|
|
276
|
+
// A wrapping Popover listens for clicks on the button itself.
|
|
277
|
+
const nativeClick = vi.fn();
|
|
278
|
+
button.addEventListener('click', nativeClick);
|
|
279
|
+
await user.tab();
|
|
280
|
+
expect(button).toHaveFocus();
|
|
281
|
+
await user.keyboard('{Enter} ');
|
|
282
|
+
expect(nativeClick).not.toHaveBeenCalled();
|
|
283
|
+
fireEvent.click(button);
|
|
264
284
|
}
|
|
285
|
+
expect(handleClick).not.toHaveBeenCalled();
|
|
286
|
+
expect(handleRemove).not.toHaveBeenCalled();
|
|
265
287
|
});
|
|
266
288
|
|
|
267
289
|
it('does not fire onClick or onRemove when disabled', () => {
|
package/src/Token/Token.tsx
CHANGED
|
@@ -41,6 +41,7 @@ import {interactionOverlayStyles} from '../utils/interactionOverlay.stylex';
|
|
|
41
41
|
import {usePressFeedback} from '../hooks/usePressFeedback';
|
|
42
42
|
import {useTranslator} from '../i18n';
|
|
43
43
|
import type {TokenColorMap} from './index';
|
|
44
|
+
import {disabledInteractionProps} from '../utils/disabledInteraction';
|
|
44
45
|
|
|
45
46
|
// =============================================================================
|
|
46
47
|
// Types
|
|
@@ -321,7 +322,9 @@ export function Token({
|
|
|
321
322
|
e.stopPropagation();
|
|
322
323
|
onRemove(e);
|
|
323
324
|
}}
|
|
324
|
-
disabled
|
|
325
|
+
// Focusable while disabled (spec:AST-085 FR1), but inert (FR2).
|
|
326
|
+
aria-disabled={isDisabled || undefined}
|
|
327
|
+
{...(isDisabled ? disabledInteractionProps() : null)}
|
|
325
328
|
{...focusOutlineProps.focusVisible(styles.removeButton)}>
|
|
326
329
|
<Icon icon="close" size="xsm" color="inherit" />
|
|
327
330
|
</button>
|
|
@@ -455,7 +458,8 @@ export function Token({
|
|
|
455
458
|
<button
|
|
456
459
|
type="button"
|
|
457
460
|
onClick={effectiveOnClick}
|
|
458
|
-
disabled={isDisabled}
|
|
461
|
+
aria-disabled={isDisabled || undefined}
|
|
462
|
+
{...(isDisabled ? disabledInteractionProps() : null)}
|
|
459
463
|
{...stylex.props(styles.invisibleButton)}>
|
|
460
464
|
<span
|
|
461
465
|
{...stylex.props(
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
fireEvent,
|
|
26
26
|
waitFor,
|
|
27
27
|
} from '@testing-library/react';
|
|
28
|
+
import {useRef} from 'react';
|
|
28
29
|
import {Tooltip} from './Tooltip';
|
|
29
30
|
import {useTooltip} from './useTooltip';
|
|
30
31
|
import {__resetInteractionModalityForTest} from '../utils/interactionModality';
|
|
@@ -84,17 +85,15 @@ describe('Tooltip', () => {
|
|
|
84
85
|
expect(screen.getByRole('button', {name: 'Trigger'})).toBeInTheDocument();
|
|
85
86
|
});
|
|
86
87
|
|
|
87
|
-
it('
|
|
88
|
+
it('renders the content prop inside the tooltip layer', () => {
|
|
88
89
|
render(
|
|
89
90
|
<Tooltip content="Tooltip text">
|
|
90
91
|
<button type="button">Trigger</button>
|
|
91
92
|
</Tooltip>,
|
|
92
93
|
);
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const trigger = screen.getByRole('button', {name: 'Trigger'});
|
|
97
|
-
expect(trigger.getAttribute('aria-describedby')).toBe(layer.id);
|
|
94
|
+
expect(screen.getByRole('tooltip', {hidden: true})).toHaveTextContent(
|
|
95
|
+
'Tooltip text',
|
|
96
|
+
);
|
|
98
97
|
});
|
|
99
98
|
|
|
100
99
|
it('calls onOpenChange(true) when shown via hover', async () => {
|
|
@@ -604,3 +603,49 @@ describe('Tooltip', () => {
|
|
|
604
603
|
});
|
|
605
604
|
});
|
|
606
605
|
});
|
|
606
|
+
|
|
607
|
+
describe('Tooltip sibling mode (anchorRef)', () => {
|
|
608
|
+
// SideNavItem's collapsed rail tooltip renders the Tooltip inside the very
|
|
609
|
+
// element anchorRef points at. React attaches an ancestor's ref after its
|
|
610
|
+
// descendants' layout effects, so on mount the tooltip's effect used to see
|
|
611
|
+
// a null anchor and never retried (#7692).
|
|
612
|
+
it('attaches to an anchor it is rendered inside of', async () => {
|
|
613
|
+
const onOpenChange = vi.fn();
|
|
614
|
+
function AnchorInside() {
|
|
615
|
+
const anchorRef = useRef<HTMLDivElement>(null);
|
|
616
|
+
return (
|
|
617
|
+
<div ref={anchorRef}>
|
|
618
|
+
<button type="button">Inner</button>
|
|
619
|
+
<Tooltip
|
|
620
|
+
content="Sibling tooltip"
|
|
621
|
+
anchorRef={anchorRef}
|
|
622
|
+
delay={0}
|
|
623
|
+
onOpenChange={onOpenChange}
|
|
624
|
+
/>
|
|
625
|
+
</div>
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const {unmount} = render(<AnchorInside />);
|
|
630
|
+
const anchor = screen.getByRole('button', {name: 'Inner'})
|
|
631
|
+
.parentElement as HTMLElement;
|
|
632
|
+
|
|
633
|
+
// The anchor is described by the tooltip once the commit has attached refs.
|
|
634
|
+
await waitFor(() => {
|
|
635
|
+
expect(anchor.getAttribute('aria-describedby')).not.toBe('');
|
|
636
|
+
});
|
|
637
|
+
const layer = screen.getByRole('tooltip', {hidden: true});
|
|
638
|
+
expect(anchor.getAttribute('aria-describedby')).toBe(layer.id);
|
|
639
|
+
|
|
640
|
+
// Hovering the anchor opens the tooltip: the interaction listeners were
|
|
641
|
+
// attached to the late anchor too.
|
|
642
|
+
fireEvent.mouseEnter(anchor);
|
|
643
|
+
await waitFor(() => {
|
|
644
|
+
expect(onOpenChange).toHaveBeenCalledWith(true);
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
// Unmounting detaches: the aria-describedby the tooltip added is removed.
|
|
648
|
+
unmount();
|
|
649
|
+
expect(anchor.getAttribute('aria-describedby')).toBe(null);
|
|
650
|
+
});
|
|
651
|
+
});
|
package/src/Tooltip/Tooltip.tsx
CHANGED
|
@@ -242,28 +242,51 @@ export function Tooltip({
|
|
|
242
242
|
return;
|
|
243
243
|
}
|
|
244
244
|
|
|
245
|
+
const attach = (el: HTMLElement) => {
|
|
246
|
+
// Use combined ref for position + interaction
|
|
247
|
+
tooltip.ref(el);
|
|
248
|
+
|
|
249
|
+
// Set aria-describedby, merging with existing
|
|
250
|
+
const existingDescribedBy = el.getAttribute('aria-describedby');
|
|
251
|
+
el.setAttribute(
|
|
252
|
+
'aria-describedby',
|
|
253
|
+
mergeIds(existingDescribedBy, tooltip.describedBy) ?? '',
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
return () => {
|
|
257
|
+
tooltip.ref(null);
|
|
258
|
+
if (existingDescribedBy) {
|
|
259
|
+
el.setAttribute('aria-describedby', existingDescribedBy);
|
|
260
|
+
} else {
|
|
261
|
+
el.removeAttribute('aria-describedby');
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
};
|
|
265
|
+
|
|
245
266
|
const el = anchorRef.current;
|
|
246
|
-
if (
|
|
247
|
-
return;
|
|
267
|
+
if (el) {
|
|
268
|
+
return attach(el);
|
|
248
269
|
}
|
|
249
270
|
|
|
250
|
-
//
|
|
251
|
-
tooltip
|
|
252
|
-
|
|
253
|
-
//
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
return () => {
|
|
261
|
-
tooltip.ref(null);
|
|
262
|
-
if (existingDescribedBy) {
|
|
263
|
-
el.setAttribute('aria-describedby', existingDescribedBy);
|
|
264
|
-
} else {
|
|
265
|
-
el.removeAttribute('aria-describedby');
|
|
271
|
+
// React attaches an ancestor's ref after its descendants' layout effects,
|
|
272
|
+
// so a tooltip rendered inside its own anchor (SideNavItem's collapsed
|
|
273
|
+
// rail tooltip) still sees null here on mount, and this effect's deps
|
|
274
|
+
// never change to re-run it. Re-check once the commit has attached all
|
|
275
|
+
// refs; if the anchor is still gone, this tooltip has no anchor, as before.
|
|
276
|
+
let detach: (() => void) | undefined;
|
|
277
|
+
let cancelled = false;
|
|
278
|
+
queueMicrotask(() => {
|
|
279
|
+
if (cancelled) {
|
|
280
|
+
return;
|
|
266
281
|
}
|
|
282
|
+
const el = anchorRef.current;
|
|
283
|
+
if (el) {
|
|
284
|
+
detach = attach(el);
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
return () => {
|
|
288
|
+
cancelled = true;
|
|
289
|
+
detach?.();
|
|
267
290
|
};
|
|
268
291
|
}, [anchorRef, tooltip.ref, tooltip.describedBy]);
|
|
269
292
|
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Tooltip.a11y.chromium.spec.ts
|
|
5
|
+
* @input Built tooltip stories and TOOLTIP_PATTERN in real Chromium
|
|
6
|
+
* @output Browser evidence for every tooltip expectation across Tooltip's and useTooltip's representative states
|
|
7
|
+
* @position Chromium lane of the Tooltip binding
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
expect,
|
|
12
|
+
test,
|
|
13
|
+
type CDPSession,
|
|
14
|
+
type Locator,
|
|
15
|
+
type Page,
|
|
16
|
+
} from '@playwright/test';
|
|
17
|
+
import {
|
|
18
|
+
TOOLTIP_PATTERN,
|
|
19
|
+
blockingResults,
|
|
20
|
+
checkAccessibilitySpec,
|
|
21
|
+
formatFailures,
|
|
22
|
+
neverExercised,
|
|
23
|
+
summarize,
|
|
24
|
+
type BindingResult,
|
|
25
|
+
} from '@astryxdesign/a11y-spec';
|
|
26
|
+
import {
|
|
27
|
+
createChromiumHarness,
|
|
28
|
+
holdMotionStill,
|
|
29
|
+
} from '@astryxdesign/a11y-spec/chromium';
|
|
30
|
+
import {
|
|
31
|
+
DEFAULT_STORYBOOK_DIR,
|
|
32
|
+
serveStorybook,
|
|
33
|
+
type StaticServer,
|
|
34
|
+
} from '@astryxdesign/a11y-spec/storybook';
|
|
35
|
+
import {
|
|
36
|
+
TOOLTIP_BINDING_STATES,
|
|
37
|
+
type TooltipBindingState,
|
|
38
|
+
} from './Tooltip.a11y.states';
|
|
39
|
+
|
|
40
|
+
let storybook: StaticServer;
|
|
41
|
+
|
|
42
|
+
test.beforeAll(async () => {
|
|
43
|
+
storybook = await serveStorybook(
|
|
44
|
+
process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test.afterAll(async () => {
|
|
49
|
+
await storybook?.close();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
function storyUrl(storyId: string): string {
|
|
53
|
+
return `${storybook.origin}/iframe.html?id=${storyId}&viewMode=story`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function mountState(
|
|
57
|
+
page: Page,
|
|
58
|
+
state: TooltipBindingState,
|
|
59
|
+
): Promise<{subject: Locator; related: Record<string, Locator>}> {
|
|
60
|
+
await page.goto(storyUrl(state.storyId), {waitUntil: 'load'});
|
|
61
|
+
await holdMotionStill(page);
|
|
62
|
+
const root = page.locator('#storybook-root');
|
|
63
|
+
const subject =
|
|
64
|
+
state.triggerRole == null
|
|
65
|
+
? root.getByText(state.triggerText, {exact: true})
|
|
66
|
+
: root.getByRole(state.triggerRole, {
|
|
67
|
+
name: state.triggerText,
|
|
68
|
+
exact: true,
|
|
69
|
+
});
|
|
70
|
+
await expect(subject).toBeVisible();
|
|
71
|
+
return {
|
|
72
|
+
subject,
|
|
73
|
+
related: {
|
|
74
|
+
// The layer may mount the tooltip outside the story root, and it stays
|
|
75
|
+
// in the page while hidden, so it is found page-wide, hidden included.
|
|
76
|
+
tooltip: page.getByRole('tooltip', {includeHidden: true}),
|
|
77
|
+
before: root.getByRole('button', {
|
|
78
|
+
name: 'Before the trigger',
|
|
79
|
+
exact: true,
|
|
80
|
+
}),
|
|
81
|
+
after: root.getByRole('button', {
|
|
82
|
+
name: 'After the trigger',
|
|
83
|
+
exact: true,
|
|
84
|
+
}),
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function runState(
|
|
90
|
+
page: Page,
|
|
91
|
+
cdp: CDPSession,
|
|
92
|
+
state: TooltipBindingState,
|
|
93
|
+
): Promise<BindingResult> {
|
|
94
|
+
return checkAccessibilitySpec({
|
|
95
|
+
spec: TOOLTIP_PATTERN,
|
|
96
|
+
binding: 'Tooltip',
|
|
97
|
+
state: state.id,
|
|
98
|
+
facts: state.facts,
|
|
99
|
+
mount: async () => {
|
|
100
|
+
const {subject, related} = await mountState(page, state);
|
|
101
|
+
return createChromiumHarness({page, cdp, subject, related});
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
test('every tooltip expectation is exercised by a Tooltip state', async ({
|
|
107
|
+
page,
|
|
108
|
+
}) => {
|
|
109
|
+
test.setTimeout(3 * 60 * 1000);
|
|
110
|
+
const cdp = await page.context().newCDPSession(page);
|
|
111
|
+
const results: BindingResult[] = [];
|
|
112
|
+
for (const state of TOOLTIP_BINDING_STATES) {
|
|
113
|
+
results.push(await runState(page, cdp, state));
|
|
114
|
+
}
|
|
115
|
+
expect(neverExercised(results)).toEqual([]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
for (const state of TOOLTIP_BINDING_STATES) {
|
|
119
|
+
test(`Tooltip [${state.id}] — ${state.summary}`, async ({page}) => {
|
|
120
|
+
test.setTimeout(2 * 60 * 1000);
|
|
121
|
+
const cdp = await page.context().newCDPSession(page);
|
|
122
|
+
const result = await runState(page, cdp, state);
|
|
123
|
+
const report = summarize(TOOLTIP_PATTERN, [result]);
|
|
124
|
+
// Required rows block (AST-020 FR9); advisory rows report.
|
|
125
|
+
expect(formatFailures(blockingResults([result]))).toBe('');
|
|
126
|
+
expect(report.unrunLayers).toEqual([]);
|
|
127
|
+
expect(report.counts.unexpectedPass).toBe(0);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
'use client';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @file Tooltip.a11y.renders.tsx
|
|
7
|
+
* @input One tooltip binding state from Tooltip.a11y.states.ts
|
|
8
|
+
* @output The React tree every lane renders for that state (the jsdom test directly, the story through Storybook): a page button before the trigger, the trigger and its tooltip, and a page button after it
|
|
9
|
+
* @position Shared fixture for the Tooltip and useTooltip binding
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {useRef, type ReactElement} from 'react';
|
|
13
|
+
import {Button} from '../../Button/Button';
|
|
14
|
+
import {isRenderable} from '../../utils/isRenderable';
|
|
15
|
+
import {Tooltip} from '../Tooltip';
|
|
16
|
+
import {useTooltip} from '../useTooltip';
|
|
17
|
+
import type {TooltipBindingState} from './Tooltip.a11y.states';
|
|
18
|
+
|
|
19
|
+
const HINT_ID = 'tooltip-a11y-hint';
|
|
20
|
+
|
|
21
|
+
type TriggerState = Pick<
|
|
22
|
+
TooltipBindingState,
|
|
23
|
+
'attachment' | 'triggerText' | 'content' | 'hint'
|
|
24
|
+
>;
|
|
25
|
+
|
|
26
|
+
/** A caller button the tooltip attaches to through `anchorRef`. */
|
|
27
|
+
function AnchorRefTrigger({state}: {state: TriggerState}): ReactElement {
|
|
28
|
+
const anchorRef = useRef<HTMLButtonElement>(null);
|
|
29
|
+
return (
|
|
30
|
+
<>
|
|
31
|
+
<button
|
|
32
|
+
ref={anchorRef}
|
|
33
|
+
type="button"
|
|
34
|
+
aria-describedby={state.hint == null ? undefined : HINT_ID}>
|
|
35
|
+
{state.triggerText}
|
|
36
|
+
</button>
|
|
37
|
+
<Tooltip anchorRef={anchorRef} content={state.content} />
|
|
38
|
+
</>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A caller button wired by hand through `useTooltip`. */
|
|
43
|
+
function HookTrigger({state}: {state: TriggerState}): ReactElement {
|
|
44
|
+
const tooltip = useTooltip();
|
|
45
|
+
return (
|
|
46
|
+
<>
|
|
47
|
+
<button
|
|
48
|
+
ref={tooltip.ref}
|
|
49
|
+
type="button"
|
|
50
|
+
aria-describedby={tooltip.describedBy}>
|
|
51
|
+
{state.triggerText}
|
|
52
|
+
</button>
|
|
53
|
+
{tooltip.renderTooltip(state.content)}
|
|
54
|
+
</>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function Trigger({state}: {state: TriggerState}): ReactElement {
|
|
59
|
+
switch (state.attachment) {
|
|
60
|
+
case 'wrapped-element':
|
|
61
|
+
return (
|
|
62
|
+
<Tooltip content={state.content}>
|
|
63
|
+
<Button label={state.triggerText} />
|
|
64
|
+
</Tooltip>
|
|
65
|
+
);
|
|
66
|
+
case 'text':
|
|
67
|
+
return <Tooltip content={state.content}>{state.triggerText}</Tooltip>;
|
|
68
|
+
case 'anchor-ref':
|
|
69
|
+
return <AnchorRefTrigger state={state} />;
|
|
70
|
+
case 'hook':
|
|
71
|
+
return <HookTrigger state={state} />;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function renderTooltipState(state: TriggerState): ReactElement {
|
|
76
|
+
return (
|
|
77
|
+
<div style={{padding: 64}}>
|
|
78
|
+
<button type="button">Before the trigger</button>{' '}
|
|
79
|
+
<Trigger state={state} />
|
|
80
|
+
{isRenderable(state.hint) ? (
|
|
81
|
+
<span id={HINT_ID}> {state.hint}</span>
|
|
82
|
+
) : null}{' '}
|
|
83
|
+
<button type="button">After the trigger</button>
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|