@astryxdesign/core 0.4.6 → 0.4.7-canary.e5e0d81

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.
Files changed (37) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/BottomSheet/BottomSheet.d.ts +1 -0
  3. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  4. package/dist/BottomSheet/BottomSheet.js +37 -11
  5. package/dist/BottomSheet/BottomSheetEdgeTint.d.ts +6 -0
  6. package/dist/BottomSheet/BottomSheetEdgeTint.d.ts.map +1 -0
  7. package/dist/BottomSheet/BottomSheetEdgeTint.js +62 -0
  8. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
  9. package/dist/BottomSheet/BottomSheetPanel.js +1 -0
  10. package/dist/BottomSheet/BottomSheetSwitcher.d.ts +1 -0
  11. package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -1
  12. package/dist/BottomSheet/BottomSheetSwitcher.js +10 -4
  13. package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -1
  14. package/dist/BottomSheet/useSheetGestures.js +23 -5
  15. package/dist/TabList/Tab.d.ts.map +1 -1
  16. package/dist/TabList/Tab.js +5 -1
  17. package/dist/astryx.css +4 -0
  18. package/dist/hooks/useListFocus.d.ts +5 -2
  19. package/dist/hooks/useListFocus.d.ts.map +1 -1
  20. package/dist/hooks/useListFocus.js +12 -6
  21. package/package.json +3 -3
  22. package/src/Avatar/Avatar.doc.mjs +2 -1
  23. package/src/BottomSheet/BottomSheet.tsx +19 -0
  24. package/src/BottomSheet/BottomSheetEdgeTint.test.tsx +225 -0
  25. package/src/BottomSheet/BottomSheetEdgeTint.tsx +82 -0
  26. package/src/BottomSheet/BottomSheetPanel.test.tsx +66 -0
  27. package/src/BottomSheet/BottomSheetPanel.tsx +19 -0
  28. package/src/BottomSheet/BottomSheetSwitcher.tsx +13 -0
  29. package/src/BottomSheet/useSheetGestures.test.ts +27 -0
  30. package/src/BottomSheet/useSheetGestures.ts +25 -5
  31. package/src/Popover/Popover.test.tsx +27 -1
  32. package/src/TabList/Tab.tsx +5 -1
  33. package/src/TabList/TabList.test.tsx +21 -4
  34. package/src/hooks/useListFocus.doc.mjs +2 -2
  35. package/src/hooks/useListFocus.test.tsx +65 -3
  36. package/src/hooks/useListFocus.ts +15 -7
  37. package/src/theme/MediaTheme.doc.mjs +5 -5
@@ -2,7 +2,8 @@
2
2
 
3
3
  /**
4
4
  * @file Popover.test.tsx
5
- * @input Uses vitest, @testing-library/react, Popover component
5
+ * @input Uses vitest, @testing-library/react, Popover, Dialog,
6
+ * SegmentedControl
6
7
  * @output Unit tests for Popover component behavior
7
8
  * @position Testing; validates Popover.tsx implementation
8
9
  *
@@ -14,6 +15,7 @@ import {render, screen, fireEvent} from '@testing-library/react';
14
15
  import React, {useRef} from 'react';
15
16
  import {Popover} from './Popover';
16
17
  import {Dialog} from '../Dialog';
18
+ import {SegmentedControl, SegmentedControlItem} from '../SegmentedControl';
17
19
 
18
20
  // Store original matches to restore later
19
21
  const originalMatches = HTMLElement.prototype.matches;
@@ -306,6 +308,30 @@ describe('Popover', () => {
306
308
  expect(trigger).toHaveAttribute('aria-expanded', 'false');
307
309
  });
308
310
 
311
+ it('dismisses on Escape pressed inside a roving-focus list', () => {
312
+ render(
313
+ <Popover
314
+ content={
315
+ <SegmentedControl value="grid" onChange={() => {}} label="View">
316
+ <SegmentedControlItem value="grid" label="Grid" />
317
+ <SegmentedControlItem value="list" label="List" />
318
+ </SegmentedControl>
319
+ }
320
+ label="Test">
321
+ <button type="button">Open</button>
322
+ </Popover>,
323
+ );
324
+ const trigger = screen.getByRole('button', {name: 'Open'});
325
+ fireEvent.click(trigger);
326
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
327
+
328
+ // From the segment, so the list's own key handler runs first. jsdom has
329
+ // no popover display, so the open content still reads as hidden.
330
+ const segment = screen.getByRole('radio', {name: 'Grid', hidden: true});
331
+ fireEvent.keyDown(segment, {key: 'Escape'});
332
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
333
+ });
334
+
309
335
  it('stays open on Escape when hasEscapeDismiss is false', () => {
310
336
  render(
311
337
  <Popover
@@ -260,7 +260,11 @@ export function Tab({
260
260
  ...(isLabelHidden ? {'aria-label': label} : {}),
261
261
  [EDGE_COMP_ATTR]: '',
262
262
  'data-tab-value': value,
263
- 'aria-current': isSelected ? ('page' as const) : undefined,
263
+ // Generic `true` ("the current item within a set"), not `page`: the strip
264
+ // switches views in place at least as often as it navigates, and claiming
265
+ // "current page" when no page changed is a false statement to a screen
266
+ // reader. Stays truthful for the `href` case too, just less specific.
267
+ 'aria-current': isSelected ? ('true' as const) : undefined,
264
268
  // Roving tabindex: the tab strip is a single Tab stop. The selected tab is
265
269
  // the tabbable one; the rest are reachable via arrow keys (handled by
266
270
  // TabList's onKeyDown). When no tab is selected, TabList's repair effect
@@ -106,7 +106,7 @@ describe('TabList', () => {
106
106
  );
107
107
  });
108
108
 
109
- it('marks selected tab with aria-current', () => {
109
+ it('marks selected tab with a generic aria-current, not "page"', () => {
110
110
  render(
111
111
  <TabList value="home" onChange={() => {}}>
112
112
  <Tab value="home" label="Home" />
@@ -116,13 +116,30 @@ describe('TabList', () => {
116
116
 
117
117
  expect(screen.getByRole('button', {name: 'Home'})).toHaveAttribute(
118
118
  'aria-current',
119
- 'page',
119
+ 'true',
120
120
  );
121
121
  expect(screen.getByRole('button', {name: 'Settings'})).not.toHaveAttribute(
122
122
  'aria-current',
123
123
  );
124
124
  });
125
125
 
126
+ it('marks a selected link tab with the same generic aria-current', () => {
127
+ render(
128
+ <TabList value="home" onChange={() => {}}>
129
+ <Tab value="home" label="Home" href="/home" />
130
+ <Tab value="settings" label="Settings" href="/settings" />
131
+ </TabList>,
132
+ );
133
+
134
+ expect(screen.getByRole('link', {name: 'Home'})).toHaveAttribute(
135
+ 'aria-current',
136
+ 'true',
137
+ );
138
+ expect(screen.getByRole('link', {name: 'Settings'})).not.toHaveAttribute(
139
+ 'aria-current',
140
+ );
141
+ });
142
+
126
143
  it('calls onChange when a tab is clicked', async () => {
127
144
  const user = userEvent.setup();
128
145
  const handleChange = vi.fn();
@@ -148,7 +165,7 @@ describe('TabList', () => {
148
165
 
149
166
  expect(screen.getByRole('button', {name: 'Home'})).toHaveAttribute(
150
167
  'aria-current',
151
- 'page',
168
+ 'true',
152
169
  );
153
170
 
154
171
  rerender(
@@ -163,7 +180,7 @@ describe('TabList', () => {
163
180
  );
164
181
  expect(screen.getByRole('button', {name: 'Settings'})).toHaveAttribute(
165
182
  'aria-current',
166
- 'page',
183
+ 'true',
167
184
  );
168
185
  });
169
186
 
@@ -36,7 +36,7 @@ export const docs = {
36
36
  {
37
37
  name: 'options.onEscape',
38
38
  type: '() => void',
39
- description: 'Callback when Escape key is pressed (e.g., close menu).',
39
+ description: 'Callback when Escape key is pressed (e.g., close menu). Supplying it also consumes the key (preventDefault); without it Escape passes through to the surrounding layer.',
40
40
  required: false,
41
41
  },
42
42
  {
@@ -144,7 +144,7 @@ export const docsDense = {
144
144
  'options.itemSelector': 'selector for focusable items in list.',
145
145
  'options.boundarySelector': "boundary selector for lists that contain nested lists (e.g. submenu flyouts); scopes items + key handling to this level.",
146
146
  'options.wrap': 'whether arrow navigation wraps around at ends.',
147
- 'options.onEscape': 'callback when Escape key pressed (e.g. close menu).',
147
+ 'options.onEscape': 'callback when Escape key pressed (e.g. close menu). Also consumes the key; without it Escape passes through to the surrounding layer.',
148
148
  'options.orientation': "navigation orientation. 'horizontal' uses ArrowLeft/ArrowRight, 'vertical' uses ArrowUp/ArrowDown, 'both' accepts all four arrows.",
149
149
  'options.hasHomeEnd': 'whether Home/End jump to first/last enabled item.',
150
150
  'options.isRtl': 'ArrowLeft/ArrowRight swap for horizontal nav (RTL). default: auto-detect from container computed direction; explicit boolean wins.',
@@ -3,14 +3,14 @@
3
3
  /**
4
4
  * @file useListFocus.test.tsx
5
5
  * @input Uses vitest, @testing-library/react, useListFocus hook
6
- * @output Unit tests for useListFocus disabled-item skipping, navigation, and
7
- * RTL auto-detection
6
+ * @output Unit tests for useListFocus disabled-item skipping, navigation,
7
+ * Escape consumption, and RTL auto-detection
8
8
  * @position Testing; validates useListFocus.ts keyboard navigation
9
9
  *
10
10
  * SYNC: When useListFocus.ts changes, update tests to match new behavior
11
11
  */
12
12
 
13
- import {describe, it, expect} from 'vitest';
13
+ import {describe, it, expect, vi} from 'vitest';
14
14
  import type {KeyboardEvent as ReactKeyboardEvent} from 'react';
15
15
  import {render, screen, fireEvent} from '@testing-library/react';
16
16
  import {useListFocus} from './useListFocus';
@@ -525,3 +525,65 @@ describe('useListFocus boundarySelector (nested lists)', () => {
525
525
  expect(innerProbe).toHaveAttribute('data-owns', 'false');
526
526
  });
527
527
  });
528
+
529
+ // A list inside a host that dismisses on Escape. The host's guard mirrors
530
+ // `useFocusTrap`: it acts only on a key no inner handler has consumed.
531
+ function EscapeHost({
532
+ onEscape,
533
+ onHostEscape,
534
+ }: {
535
+ onEscape?: () => void;
536
+ onHostEscape: () => void;
537
+ }) {
538
+ const {listRef, handleKeyDown} = useListFocus<HTMLDivElement>({onEscape});
539
+ return (
540
+ <div
541
+ data-testid="host"
542
+ onKeyDown={e => {
543
+ if (e.key === 'Escape' && !e.defaultPrevented) {
544
+ onHostEscape();
545
+ }
546
+ }}>
547
+ <div ref={listRef} role="menu" onKeyDown={handleKeyDown}>
548
+ <div role="menuitem" tabIndex={-1} data-testid="One">
549
+ One
550
+ </div>
551
+ <div role="menuitem" tabIndex={-1} data-testid="Two">
552
+ Two
553
+ </div>
554
+ </div>
555
+ </div>
556
+ );
557
+ }
558
+
559
+ describe('useListFocus Escape', () => {
560
+ it('leaves Escape to the host when no onEscape is supplied', () => {
561
+ const onHostEscape = vi.fn();
562
+ render(<EscapeHost onHostEscape={onHostEscape} />);
563
+
564
+ fireEvent.keyDown(screen.getByRole('menu'), {key: 'Escape'});
565
+ expect(onHostEscape).toHaveBeenCalledTimes(1);
566
+ });
567
+
568
+ it('consumes Escape and runs onEscape when one is supplied', () => {
569
+ const onEscape = vi.fn();
570
+ const onHostEscape = vi.fn();
571
+ render(<EscapeHost onEscape={onEscape} onHostEscape={onHostEscape} />);
572
+
573
+ fireEvent.keyDown(screen.getByRole('menu'), {key: 'Escape'});
574
+ expect(onEscape).toHaveBeenCalledTimes(1);
575
+ expect(onHostEscape).not.toHaveBeenCalled();
576
+ });
577
+
578
+ it('still consumes arrow keys with no onEscape (page-scroll suppression)', () => {
579
+ render(<EscapeHost onHostEscape={() => {}} />);
580
+ screen.getByTestId('One').focus();
581
+
582
+ // fireEvent returns false when a handler cancelled the event.
583
+ const wasCancelled = !fireEvent.keyDown(screen.getByRole('menu'), {
584
+ key: 'ArrowDown',
585
+ });
586
+ expect(wasCancelled).toBe(true);
587
+ expect(screen.getByTestId('Two')).toHaveFocus();
588
+ });
589
+ });
@@ -14,6 +14,8 @@
14
14
  *
15
15
  * SYNC: When modified, update:
16
16
  * - /packages/core/src/hooks/index.ts
17
+ * - /packages/core/src/hooks/useListFocus.doc.mjs
18
+ * - /packages/core/src/hooks/useListFocus.test.tsx
17
19
  */
18
20
 
19
21
  import {useCallback, useRef} from 'react';
@@ -64,7 +66,9 @@ export interface UseListFocusOptions {
64
66
  wrap?: boolean;
65
67
 
66
68
  /**
67
- * Callback when Escape key is pressed.
69
+ * Callback when Escape key is pressed. Supplying it also makes the list
70
+ * consume the key (`preventDefault`); without it Escape passes through to
71
+ * the surrounding layer.
68
72
  */
69
73
  onEscape?: () => void;
70
74
 
@@ -277,7 +281,8 @@ function shouldDeferToCaret(target: EventTarget | null, key: string): boolean {
277
281
  * - ArrowUp/ArrowLeft: Move to previous item (wraps to last)
278
282
  * - Home: Move to first item
279
283
  * - End: Move to last item
280
- * - Escape: Custom callback (e.g., close menu)
284
+ * - Escape: runs `onEscape` and consumes the key. With no `onEscape` the key
285
+ * is left alone, so a surrounding layer can still dismiss on it.
281
286
  *
282
287
  * By default the hook only *moves* focus and leaves `tabindex` management to
283
288
  * the caller. Opt into {@link UseListFocusOptions.hasRovingTabIndex} for a hook
@@ -557,12 +562,15 @@ export function useListFocus<T extends HTMLElement = HTMLElement>(
557
562
  return;
558
563
  }
559
564
 
560
- // Escape is handled regardless of orientation. Preserve the historical
561
- // behavior of always consuming Escape here (preventDefault) so consumers
562
- // that relied on it are unaffected.
565
+ // Escape is handled regardless of orientation, but only *consumed* when
566
+ // a handler asked for it: a list with no dismissal to perform must leave
567
+ // the key to whatever host layer does have one, and those defer to
568
+ // `defaultPrevented` (see `useFocusTrap`) or to the native popover.
563
569
  if (e.key === 'Escape') {
564
- e.preventDefault();
565
- onEscape?.();
570
+ if (onEscape) {
571
+ e.preventDefault();
572
+ onEscape();
573
+ }
566
574
  return;
567
575
  }
568
576
 
@@ -68,7 +68,7 @@ export const docs = {
68
68
  {
69
69
  guidance: true,
70
70
  description:
71
- 'Prefer mode="auto" when the surface color comes from a theme token. A token named "inverted" is not guaranteed to be inverted, and auto measures what was actually painted instead of trusting the name — including deciding that a surface needs no media context at all.',
71
+ 'Prefer mode="auto" when the surface color comes from a theme token. A token named "inverted" is not guaranteed to be inverted, and auto measures what was actually painted instead of trusting the name. It can even decide that a surface needs no media context at all.',
72
72
  },
73
73
  {
74
74
  guidance: true,
@@ -93,14 +93,14 @@ export const docs = {
93
93
  type: "'dark' | 'light' | 'auto' | 'off'",
94
94
  required: true,
95
95
  description:
96
- 'Surface luminance context: dark for content over dark backgrounds (light text, white-tinted interactions), light for content over light backgrounds (dark text, black-tinted interactions), auto to decide from the painted surface — no media context when the ambient text already reads on the surface (3:1), otherwise the side that reads better — and off to turn it off explicitly. The element renders either way, so a surface can switch contexts without remounting children.',
96
+ 'Surface luminance context: dark for content over dark backgrounds (light text, white-tinted interactions), light for content over light backgrounds (dark text, black-tinted interactions), auto to decide from the painted surface (no media context when the ambient text already reads on the surface at 3:1, otherwise the side that reads better), and off to turn it off explicitly. The element renders either way, so a surface can switch contexts without remounting children.',
97
97
  },
98
98
  {
99
99
  name: 'fallback',
100
100
  type: "'dark' | 'light'",
101
101
  default: "'dark'",
102
102
  description:
103
- 'Which side auto uses when the surface cannot be measured: during SSR, on the first client frame, and whenever the backdrop is not knowable from CSS — most often a background-image, whose pixels need sampling (useImageMode) rather than a computed style. Ignored unless mode is auto.',
103
+ 'Which side auto uses when the surface cannot be measured: during SSR, on the first client frame, and whenever the backdrop is not knowable from CSS, most often a background-image, whose pixels need sampling (useImageMode) rather than a computed style. Ignored unless mode is auto.',
104
104
  },
105
105
  {
106
106
  name: 'children',
@@ -126,7 +126,7 @@ export const docsDense = {
126
126
  {
127
127
  guidance: true,
128
128
  description:
129
- 'Prefer mode="auto" when surface color comes from a theme token — a token named "inverted" is not guaranteed to be; auto measures what was painted.',
129
+ 'Prefer mode="auto" when surface color comes from a theme token; a token named "inverted" is not guaranteed to be; auto measures what was painted.',
130
130
  },
131
131
  {
132
132
  guidance: true,
@@ -148,6 +148,6 @@ export const docsDense = {
148
148
  propDescriptions: {
149
149
  mode: 'surface luminance context: dark for content over dark backgrounds (light text, white-tinted interactions), light for content over light backgrounds (dark text, black-tinted interactions), auto to decide from painted surface (none if ambient text already reads at 3:1, else better-reading side), off to turn off explicitly (element still renders, so children never remount)',
150
150
  fallback:
151
- 'side auto uses when surface is unmeasurable (SSR, first frame, background-image — those need useImageMode sampling); ignored unless mode is auto',
151
+ 'side auto uses when surface is unmeasurable (SSR, first frame, background-image, which needs useImageMode sampling); ignored unless mode is auto',
152
152
  },
153
153
  };