@astryxdesign/core 0.1.0 → 0.1.1-canary.129bf0e

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 (155) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +68 -0
  3. package/dist/AvatarGroup/AvatarGroupOverflow.d.ts +1 -1
  4. package/dist/AvatarGroup/AvatarGroupOverflow.d.ts.map +1 -1
  5. package/dist/AvatarGroup/AvatarGroupOverflow.js +4 -1
  6. package/dist/Banner/Banner.d.ts +7 -0
  7. package/dist/Banner/Banner.d.ts.map +1 -1
  8. package/dist/Banner/Banner.js +9 -2
  9. package/dist/Button/Button.d.ts.map +1 -1
  10. package/dist/Button/Button.js +2 -0
  11. package/dist/Chat/ChatLayoutScrollButton.d.ts.map +1 -1
  12. package/dist/Chat/ChatLayoutScrollButton.js +5 -1
  13. package/dist/ContextMenu/ContextMenu.js +2 -2
  14. package/dist/DropdownMenu/DropdownMenu.js +2 -2
  15. package/dist/DropdownMenu/{renderXDSDropdownItems.d.ts → renderDropdownItems.d.ts} +3 -3
  16. package/dist/DropdownMenu/renderDropdownItems.d.ts.map +1 -0
  17. package/dist/DropdownMenu/{renderXDSDropdownItems.js → renderDropdownItems.js} +2 -2
  18. package/dist/EmptyState/EmptyState.d.ts.map +1 -1
  19. package/dist/EmptyState/EmptyState.js +7 -1
  20. package/dist/HoverCard/HoverCard.d.ts +2 -2
  21. package/dist/HoverCard/HoverCard.d.ts.map +1 -1
  22. package/dist/HoverCard/HoverCard.js +18 -6
  23. package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
  24. package/dist/HoverCard/useHoverCard.js +6 -3
  25. package/dist/Layer/useLayer.d.ts +13 -0
  26. package/dist/Layer/useLayer.d.ts.map +1 -1
  27. package/dist/Layer/useLayer.js +7 -2
  28. package/dist/Layout/Layout.d.ts +10 -1
  29. package/dist/Layout/Layout.d.ts.map +1 -1
  30. package/dist/Layout/Layout.js +5 -1
  31. package/dist/Markdown/Markdown.d.ts.map +1 -1
  32. package/dist/Markdown/Markdown.js +13 -3
  33. package/dist/MobileNav/MobileNav.d.ts.map +1 -1
  34. package/dist/MobileNav/MobileNav.js +13 -0
  35. package/dist/Outline/Outline.d.ts +3 -2
  36. package/dist/Outline/Outline.d.ts.map +1 -1
  37. package/dist/Outline/Outline.js +23 -4
  38. package/dist/Outline/useScrollSpy.d.ts +14 -1
  39. package/dist/Outline/useScrollSpy.d.ts.map +1 -1
  40. package/dist/Outline/useScrollSpy.js +161 -50
  41. package/dist/Pagination/Pagination.d.ts.map +1 -1
  42. package/dist/Pagination/Pagination.js +31 -27
  43. package/dist/Resizable/useResizable.d.ts.map +1 -1
  44. package/dist/Resizable/useResizable.js +1 -5
  45. package/dist/Selector/Selector.d.ts.map +1 -1
  46. package/dist/Selector/Selector.js +1 -1
  47. package/dist/Table/BaseTable.d.ts.map +1 -1
  48. package/dist/Table/BaseTable.js +26 -8
  49. package/dist/Table/Table.d.ts.map +1 -1
  50. package/dist/Table/Table.js +30 -7
  51. package/dist/Table/index.d.ts +3 -1
  52. package/dist/Table/index.d.ts.map +1 -1
  53. package/dist/Table/index.js +1 -0
  54. package/dist/Table/plugins/stickyColumns/index.d.ts +3 -0
  55. package/dist/Table/plugins/stickyColumns/index.d.ts.map +1 -0
  56. package/dist/Table/plugins/stickyColumns/index.js +3 -0
  57. package/dist/Table/plugins/stickyColumns/useTableStickyColumns.d.ts +25 -0
  58. package/dist/Table/plugins/stickyColumns/useTableStickyColumns.d.ts.map +1 -0
  59. package/dist/Table/plugins/stickyColumns/useTableStickyColumns.js +376 -0
  60. package/dist/Table/types.d.ts +90 -5
  61. package/dist/Table/types.d.ts.map +1 -1
  62. package/dist/Table/useBaseTablePlugins.d.ts.map +1 -1
  63. package/dist/Table/useBaseTablePlugins.js +1 -1
  64. package/dist/ToggleButton/ToggleButton.d.ts +10 -3
  65. package/dist/ToggleButton/ToggleButton.d.ts.map +1 -1
  66. package/dist/ToggleButton/ToggleButton.js +64 -18
  67. package/dist/astryx.css +11 -0
  68. package/dist/astryx.umd.js +147 -0
  69. package/dist/astryx.umd.js.map +7 -0
  70. package/dist/theme/Theme.js +1 -1
  71. package/dist/theme/defineTheme.d.ts +1 -1
  72. package/dist/theme/defineTheme.d.ts.map +1 -1
  73. package/dist/theme/defineTheme.js +1 -1
  74. package/dist/theme/index.d.ts +1 -1
  75. package/dist/theme/index.d.ts.map +1 -1
  76. package/dist/theme/index.js +1 -1
  77. package/dist/theme/syntax/defineSyntaxTheme.js +1 -1
  78. package/dist/theme/tokens.d.ts +1 -1
  79. package/dist/theme/tokens.js +4 -4
  80. package/dist/theme/useTheme.d.ts +2 -2
  81. package/dist/utils/dateParser.d.ts.map +1 -1
  82. package/dist/utils/dateParser.js +15 -2
  83. package/package.json +7 -3
  84. package/src/AvatarGroup/AvatarGroupOverflow.tsx +3 -0
  85. package/src/Banner/Banner.test.tsx +16 -7
  86. package/src/Banner/Banner.tsx +9 -2
  87. package/src/Button/Button.test.tsx +26 -11
  88. package/src/Button/Button.tsx +2 -0
  89. package/src/Chat/ChatLayoutScrollButton.tsx +7 -1
  90. package/src/Collapsible/useCollapsible.doc.mjs +2 -2
  91. package/src/ContextMenu/ContextMenu.tsx +2 -2
  92. package/src/DateInput/DateInput.test.tsx +68 -20
  93. package/src/Divider/Divider.doc.mjs +1 -1
  94. package/src/DropdownMenu/DropdownMenu.tsx +2 -2
  95. package/src/DropdownMenu/{renderXDSDropdownItems.tsx → renderDropdownItems.tsx} +2 -2
  96. package/src/EmptyState/EmptyState.test.tsx +4 -2
  97. package/src/EmptyState/EmptyState.tsx +6 -2
  98. package/src/FormLayout/FormLayout.doc.mjs +3 -3
  99. package/src/HoverCard/HoverCard.doc.mjs +3 -0
  100. package/src/HoverCard/HoverCard.test.tsx +178 -2
  101. package/src/HoverCard/HoverCard.tsx +20 -16
  102. package/src/HoverCard/useHoverCard.tsx +12 -10
  103. package/src/Icon/Icon.doc.mjs +4 -4
  104. package/src/Item/Item.doc.mjs +2 -2
  105. package/src/Layer/useLayer.doc.mjs +7 -2
  106. package/src/Layer/useLayer.tsx +19 -2
  107. package/src/Layout/Layout.doc.mjs +2 -1
  108. package/src/Layout/Layout.tsx +15 -1
  109. package/src/Layout/__tests__/childrenAsContent.test.tsx +59 -0
  110. package/src/Lightbox/Lightbox.doc.mjs +0 -2
  111. package/src/Link/Link.doc.mjs +3 -3
  112. package/src/Link/LinkProvider.doc.mjs +3 -3
  113. package/src/Markdown/Markdown.doc.mjs +6 -4
  114. package/src/Markdown/Markdown.test.tsx +17 -26
  115. package/src/Markdown/Markdown.tsx +16 -6
  116. package/src/MobileNav/MobileNav.doc.mjs +8 -8
  117. package/src/MobileNav/MobileNav.tsx +13 -0
  118. package/src/MobileNav/MobileNavReopen.test.tsx +118 -0
  119. package/src/Outline/Outline.doc.mjs +1 -1
  120. package/src/Outline/Outline.test.tsx +76 -38
  121. package/src/Outline/Outline.tsx +23 -4
  122. package/src/Outline/useScrollSpy.ts +196 -63
  123. package/src/Pagination/Pagination.test.tsx +137 -13
  124. package/src/Pagination/Pagination.tsx +33 -28
  125. package/src/Resizable/Resizable.doc.mjs +3 -3
  126. package/src/Resizable/useResizable.ts +1 -7
  127. package/src/Selector/Selector.doc.mjs +4 -0
  128. package/src/Selector/Selector.tsx +5 -6
  129. package/src/Skeleton/Skeleton.doc.mjs +11 -1
  130. package/src/Table/BaseTable.tsx +50 -24
  131. package/src/Table/Table.doc.mjs +3 -3
  132. package/src/Table/Table.tsx +22 -1
  133. package/src/Table/index.ts +3 -0
  134. package/src/Table/plugins/stickyColumns/index.ts +4 -0
  135. package/src/Table/plugins/stickyColumns/useTableStickyColumns.test.tsx +163 -0
  136. package/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx +414 -0
  137. package/src/Table/types.ts +96 -4
  138. package/src/Table/useBaseTablePlugins.ts +1 -0
  139. package/src/ToggleButton/ToggleButton.doc.mjs +2 -2
  140. package/src/ToggleButton/ToggleButton.test.tsx +148 -6
  141. package/src/ToggleButton/ToggleButton.tsx +83 -20
  142. package/src/Toolbar/Toolbar.doc.mjs +1 -1
  143. package/src/hooks/useEntryAnimation.doc.mjs +3 -3
  144. package/src/hooks/useMediaQuery.doc.mjs +2 -2
  145. package/src/hooks/useStreamingText.doc.mjs +3 -3
  146. package/src/theme/Theme.doc.mjs +2 -2
  147. package/src/theme/Theme.tsx +1 -1
  148. package/src/theme/defineTheme.ts +1 -1
  149. package/src/theme/index.ts +1 -1
  150. package/src/theme/syntax/defineSyntaxTheme.ts +1 -1
  151. package/src/theme/tokens.ts +4 -4
  152. package/src/theme/useTheme.ts +2 -2
  153. package/src/utils/dateParser.test.ts +26 -0
  154. package/src/utils/dateParser.ts +16 -2
  155. package/dist/DropdownMenu/renderXDSDropdownItems.d.ts.map +0 -1
@@ -30,7 +30,6 @@ export const docs = {
30
30
  name: 'index',
31
31
  type: 'number',
32
32
  description: 'Current index in gallery mode (when media is an array).',
33
- default: '0',
34
33
  },
35
34
  {
36
35
  name: 'onIndexChange',
@@ -94,7 +93,6 @@ export const docsZh = {
94
93
  name: 'index',
95
94
  type: 'number',
96
95
  description: '画廊模式中当前索引。',
97
- default: '0',
98
96
  },
99
97
  {
100
98
  name: 'onIndexChange',
@@ -99,7 +99,7 @@ export const docs = {
99
99
  isHiddenFromOverview: true,
100
100
  displayName: 'Link Provider',
101
101
  description:
102
- 'Provider that sets the default link component for all XDS link-rendering components in the subtree. ' +
102
+ 'Provider that sets the default link component for all Astryx link-rendering components in the subtree. ' +
103
103
  'Wrap your app root to replace native <a> elements with your framework router (Next.js Link, React Router Link, etc.).',
104
104
  props: [
105
105
  {
@@ -226,7 +226,7 @@ export const docsZh = {
226
226
  isHiddenFromOverview: true,
227
227
  displayName: 'Link Provider',
228
228
  description:
229
- '为子树中所有 XDS 链接组件设置默认链接组件的 Provider。',
229
+ '为子树中所有 Astryx 链接组件设置默认链接组件的 Provider。',
230
230
  props: [
231
231
  {
232
232
  name: 'component',
@@ -310,7 +310,7 @@ export const docsDense = {
310
310
  isHiddenFromOverview: true,
311
311
  displayName: 'Link Provider',
312
312
  description:
313
- 'Provider setting default link component for all XDS links in subtree.',
313
+ 'Provider setting default link component for all Astryx links in subtree.',
314
314
  propDescriptions: {
315
315
  component: 'Component for all link elements',
316
316
  children: 'Subtree',
@@ -10,7 +10,7 @@ export const docs = {
10
10
  isHiddenFromOverview: true,
11
11
  keywords: ['link', 'provider', 'router', 'nextjs', 'client-side-routing'],
12
12
  usage: {
13
- description: 'Wraps your app to replace the default <a> tag with a framework-specific link component (e.g. Next.js Link) for client-side routing across all XDS components.',
13
+ description: 'Wraps your app to replace the default <a> tag with a framework-specific link component (e.g. Next.js Link) for client-side routing across all Astryx components.',
14
14
  },
15
15
  props: [
16
16
  {name: 'component', type: 'LinkComponentType', required: true, description: 'Link component to use for all link elements in the subtree (e.g. Next.js Link).'},
@@ -20,9 +20,9 @@ export const docs = {
20
20
 
21
21
  /** @type {import('../docs-types').TranslationDoc} */
22
22
  export const docsDense = {
23
- description: 'Wraps app to replace default <a> tag w/ framework-specific link component (e.g. Next.js Link) for client-side routing across all XDS components.',
23
+ description: 'Wraps app to replace default <a> tag w/ framework-specific link component (e.g. Next.js Link) for client-side routing across all Astryx components.',
24
24
  usage: {
25
- description: 'Wraps app to replace default <a> tag w/ framework-specific link component (e.g. Next.js Link) for client-side routing across all XDS components.',
25
+ description: 'Wraps app to replace default <a> tag w/ framework-specific link component (e.g. Next.js Link) for client-side routing across all Astryx components.',
26
26
  },
27
27
  propDescriptions: {
28
28
  component: 'link component for all link elements in subtree (e.g. Next.js Link)',
@@ -75,6 +75,7 @@ export const docs = {
75
75
  type: 'number | string',
76
76
  description:
77
77
  'Max width for prose content (paragraphs, headings, lists, blockquotes). Tables and code blocks are unconstrained and can expand to the full container width. Use for readable line lengths in wide layouts.',
78
+ default: '680',
78
79
  },
79
80
  {
80
81
  name: 'contentAlign',
@@ -131,7 +132,7 @@ export const docs = {
131
132
  },
132
133
  usage: {
133
134
  description:
134
- 'Renders a markdown string as XDS-styled components. Use Markdown for user-generated content, AI responses, and documentation; it handles headings, lists, tables, code blocks, and citations with consistent styling.',
135
+ 'Renders a markdown string as Astryx-styled components. Use Markdown for user-generated content, AI responses, and documentation; it handles headings, lists, tables, code blocks, and citations with consistent styling.',
135
136
  bestPractices: [
136
137
  { guidance: true, description: 'Set headingLevelStart to match the page hierarchy, e.g. start at 3 if the markdown sits inside an h2 section.' },
137
138
  { guidance: true, description: 'Use contentWidth to keep prose at a readable line length in wide layouts.' },
@@ -245,6 +246,7 @@ export const docsZh = {
245
246
  type: 'number | string',
246
247
  description:
247
248
  '正文内容的最大宽度(段落、标题、列表、引用块)。表格和代码块不受限制,可扩展到完整容器宽度。用于在宽布局中保持可读行长。',
249
+ default: '680',
248
250
  },
249
251
  {
250
252
  name: 'contentAlign',
@@ -294,7 +296,7 @@ export const docsZh = {
294
296
  },
295
297
  usage: {
296
298
  description:
297
- 'Renders a markdown string as XDS-styled components. Use Markdown for user-generated content, AI responses, and documentation; it handles headings, lists, tables, code blocks, and citations with consistent styling.',
299
+ 'Renders a markdown string as Astryx-styled components. Use Markdown for user-generated content, AI responses, and documentation; it handles headings, lists, tables, code blocks, and citations with consistent styling.',
298
300
  bestPractices: [
299
301
  { guidance: true, description: 'Set headingLevelStart to match the page hierarchy, e.g. start at 3 if the markdown sits inside an h2 section.' },
300
302
  { guidance: true, description: 'Use contentWidth to keep prose at a readable line length in wide layouts.' },
@@ -306,10 +308,10 @@ export const docsZh = {
306
308
 
307
309
  export const docsDense = {
308
310
  description:
309
- 'Renders markdown string as XDS-styled components. Use for user-generated content, AI responses, docs. Headings, lists, tables, code, citations w/ consistent styling.',
311
+ 'Renders markdown string as Astryx-styled components. Use for user-generated content, AI responses, docs. Headings, lists, tables, code, citations w/ consistent styling.',
310
312
  usage: {
311
313
  description:
312
- 'Renders a markdown string as XDS-styled components. Use Markdown for user-generated content, AI responses, and documentation; it handles headings, lists, tables, code blocks, and citations with consistent styling.',
314
+ 'Renders a markdown string as Astryx-styled components. Use Markdown for user-generated content, AI responses, and documentation; it handles headings, lists, tables, code blocks, and citations with consistent styling.',
313
315
  bestPractices: [
314
316
  { guidance: true, description: 'Set headingLevelStart to match the page hierarchy, e.g. start at 3 if the markdown sits inside an h2 section.' },
315
317
  { guidance: true, description: 'Use contentWidth to keep prose at a readable line length in wide layouts.' },
@@ -22,9 +22,16 @@ describe('Markdown', () => {
22
22
  expect(screen.getByText('Heading 2').tagName).toBe('H2');
23
23
  });
24
24
 
25
- it('renders paragraphs', () => {
25
+ it('renders paragraphs as block <div> (never <p>) for composition safety', () => {
26
26
  render(<Markdown>{'Hello world'}</Markdown>);
27
- expect(screen.getByText('Hello world').tagName).toBe('P');
27
+ // Markdown paragraphs render as <div> so block-level inline content
28
+ // (images, custom inline components) never trips the phrasing-content
29
+ // trap that a <p> would impose. role="paragraph" re-exposes the paragraph
30
+ // role to assistive tech without the <p> hazard. Consumers who want a real
31
+ // <p> element can pass `components={{paragraph: 'p'}}`.
32
+ const para = screen.getByText('Hello world');
33
+ expect(para.tagName).toBe('DIV');
34
+ expect(para).toHaveAttribute('role', 'paragraph');
28
35
  });
29
36
 
30
37
  it('renders inline display without block wrappers', () => {
@@ -201,9 +208,7 @@ describe('Markdown', () => {
201
208
  });
202
209
 
203
210
  it('shows streaming cursor when isStreaming is true', () => {
204
- const {container} = render(
205
- <Markdown isStreaming>{'Hello'}</Markdown>,
206
- );
211
+ const {container} = render(<Markdown isStreaming>{'Hello'}</Markdown>);
207
212
  // Streaming mode parses incrementally but no cursor element
208
213
  expect(container.querySelector('[role="document"]')).toBeInTheDocument();
209
214
  });
@@ -243,9 +248,7 @@ describe('Markdown', () => {
243
248
 
244
249
  it('sanitizes data: URLs in images', () => {
245
250
  const {container} = render(
246
- <Markdown>
247
- {'![xss](data:text/html,<script>alert(1)</script>)'}
248
- </Markdown>,
251
+ <Markdown>{'![xss](data:text/html,<script>alert(1)</script>)'}</Markdown>,
249
252
  );
250
253
  const img = container.querySelector('img');
251
254
  expect(img).toBeNull();
@@ -435,9 +438,7 @@ describe('inlinePlugins', () => {
435
438
  },
436
439
  };
437
440
  const {container} = render(
438
- <Markdown inlinePlugins={[plugin]}>
439
- {'See TAG:important here'}
440
- </Markdown>,
441
+ <Markdown inlinePlugins={[plugin]}>{'See TAG:important here'}</Markdown>,
441
442
  );
442
443
  const tag = container.querySelector('[data-testid="tag-match"]');
443
444
  expect(tag).toBeInTheDocument();
@@ -446,9 +447,7 @@ describe('inlinePlugins', () => {
446
447
 
447
448
  it('renders identically when no inlinePlugins are provided', () => {
448
449
  const withPlugins = render(
449
- <Markdown inlinePlugins={[]}>
450
- {'Hello **world** and `code`'}
451
- </Markdown>,
450
+ <Markdown inlinePlugins={[]}>{'Hello **world** and `code`'}</Markdown>,
452
451
  );
453
452
  const withoutPlugins = render(
454
453
  <Markdown>{'Hello **world** and `code`'}</Markdown>,
@@ -481,9 +480,7 @@ describe('inlinePlugins', () => {
481
480
 
482
481
  it('renders bare https URLs as links when autolink="gfm"', () => {
483
482
  const {container} = render(
484
- <Markdown autolink="gfm">
485
- {'see https://example.com here'}
486
- </Markdown>,
483
+ <Markdown autolink="gfm">{'see https://example.com here'}</Markdown>,
487
484
  );
488
485
  const link = container.querySelector('a');
489
486
  expect(link).not.toBeNull();
@@ -503,9 +500,7 @@ describe('inlinePlugins', () => {
503
500
 
504
501
  it('renders bare emails with mailto: href', () => {
505
502
  const {container} = render(
506
- <Markdown autolink="gfm">
507
- {'ping user@example.com please'}
508
- </Markdown>,
503
+ <Markdown autolink="gfm">{'ping user@example.com please'}</Markdown>,
509
504
  );
510
505
  const link = container.querySelector('a');
511
506
  expect(link).not.toBeNull();
@@ -515,9 +510,7 @@ describe('inlinePlugins', () => {
515
510
 
516
511
  it('does not autolink URLs inside code spans', () => {
517
512
  const {container} = render(
518
- <Markdown autolink="gfm">
519
- {'try `https://example.com` here'}
520
- </Markdown>,
513
+ <Markdown autolink="gfm">{'try `https://example.com` here'}</Markdown>,
521
514
  );
522
515
  expect(container.querySelector('a')).toBeNull();
523
516
  expect(container.querySelector('code')).not.toBeNull();
@@ -525,9 +518,7 @@ describe('inlinePlugins', () => {
525
518
 
526
519
  it('does not autolink URLs inside code blocks', () => {
527
520
  const {container} = render(
528
- <Markdown autolink="gfm">
529
- {'```\nhttps://example.com\n```'}
530
- </Markdown>,
521
+ <Markdown autolink="gfm">{'```\nhttps://example.com\n```'}</Markdown>,
531
522
  );
532
523
  expect(container.querySelector('a')).toBeNull();
533
524
  expect(container.querySelector('pre')).not.toBeNull();
@@ -1113,9 +1113,19 @@ function renderBlock(
1113
1113
  if (ParagraphComp) {
1114
1114
  return <ParagraphComp key={index}>{paraChildren}</ParagraphComp>;
1115
1115
  }
1116
+ // Markdown paragraphs render as <div>, not <p>: inline content can
1117
+ // include block-level nodes (images, custom inline components), and a
1118
+ // <p> would reparent them, desyncing SSR markup from the hydrated DOM.
1119
+ // Block spacing comes from token-based StyleX margins, so the rendered
1120
+ // appearance is unchanged. role="paragraph" re-exposes the paragraph
1121
+ // role in the accessibility tree (a pure ARIA hint — it does not trigger
1122
+ // the parser's block-child reparenting) so prose semantics are preserved
1123
+ // without the <p> composition hazard. Consumers who want a real <p>
1124
+ // element can still pass components={{paragraph: 'p'}}.
1116
1125
  return (
1117
- <p
1126
+ <div
1118
1127
  key={index}
1128
+ role="paragraph"
1119
1129
  {...stylex.props(
1120
1130
  spacing,
1121
1131
  contentWidthValue != null
@@ -1128,7 +1138,7 @@ function renderBlock(
1128
1138
  isLast && styles.noMarginBlockEnd,
1129
1139
  )}>
1130
1140
  {paraChildren}
1131
- </p>
1141
+ </div>
1132
1142
  );
1133
1143
  }
1134
1144
  case 'codeblock': {
@@ -1487,7 +1497,7 @@ function renderBlock(
1487
1497
  const safeSrc = sanitizeUrl(node.src);
1488
1498
  if (safeSrc == null) {
1489
1499
  return (
1490
- <p
1500
+ <div
1491
1501
  key={index}
1492
1502
  {...stylex.props(
1493
1503
  spacing,
@@ -1495,11 +1505,11 @@ function renderBlock(
1495
1505
  isLast && styles.noMarginBlockEnd,
1496
1506
  )}>
1497
1507
  [{node.alt}]
1498
- </p>
1508
+ </div>
1499
1509
  );
1500
1510
  }
1501
1511
  return (
1502
- <p
1512
+ <div
1503
1513
  key={index}
1504
1514
  {...stylex.props(
1505
1515
  spacing,
@@ -1507,7 +1517,7 @@ function renderBlock(
1507
1517
  isLast && styles.noMarginBlockEnd,
1508
1518
  )}>
1509
1519
  <img src={safeSrc} alt={node.alt} {...stylex.props(styles.image)} />
1510
- </p>
1520
+ </div>
1511
1521
  );
1512
1522
  }
1513
1523
  }
@@ -43,14 +43,14 @@ export const docs = {
43
43
  type: 'number',
44
44
  description:
45
45
  'Drawer width in pixels. Capped at 85vw to prevent overflow on small screens.',
46
- default: '280',
46
+ default: '320',
47
47
  },
48
48
  {
49
49
  name: 'side',
50
- type: "'start' | 'end'",
50
+ type: "'start' | 'end' | 'auto'",
51
51
  description:
52
- 'Which side the drawer slides from. Start is left in LTR, right in RTL.',
53
- default: "'start'",
52
+ 'Which side the drawer slides from. Start is left in LTR, right in RTL. Auto picks a side based on the trigger position.',
53
+ default: "'auto'",
54
54
  },
55
55
  ],
56
56
  },
@@ -124,14 +124,14 @@ export const docsZh = {
124
124
  type: 'number',
125
125
  description:
126
126
  '抽屉宽度(像素)。上限为 85vw 以防止在小屏幕上溢出。',
127
- default: '280',
127
+ default: '320',
128
128
  },
129
129
  {
130
130
  name: 'side',
131
- type: "'start' | 'end'",
131
+ type: "'start' | 'end' | 'auto'",
132
132
  description:
133
- '抽屉滑出的方向。在 LTR 布局中 start 为左侧,在 RTL 布局中为右侧。',
134
- default: "'start'",
133
+ '抽屉滑出的方向。在 LTR 布局中 start 为左侧,在 RTL 布局中为右侧。auto 根据触发元素的位置自动选择方向。',
134
+ default: "'auto'",
135
135
  },
136
136
  ],
137
137
  theming: {
@@ -363,8 +363,21 @@ export function MobileNav({
363
363
  return () => {
364
364
  if (closeTimeoutRef.current) {
365
365
  clearTimeout(closeTimeoutRef.current);
366
+ closeTimeoutRef.current = null;
366
367
  }
367
368
  document.documentElement.style.overflow = '';
369
+ // Close the native dialog on teardown if it's still open. Inside AppShell
370
+ // the drawer is mounted in an <Activity> that switches to mode="hidden"
371
+ // when the drawer closes; React then runs this cleanup (with a stale
372
+ // isOpen) instead of re-running the effect with isOpen=false, so the
373
+ // close branch above never fires. If we leave the <dialog> `open` here,
374
+ // showModal() is skipped on the next open (the dialog is already open in
375
+ // the hidden tree) and the drawer can never be re-opened. Closing it
376
+ // unconditionally on teardown keeps the native dialog state in sync so a
377
+ // subsequent open cleanly calls showModal() again.
378
+ if (dialog.open) {
379
+ dialog.close();
380
+ }
368
381
  };
369
382
  }, [isOpen, side]);
370
383
 
@@ -0,0 +1,118 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file MobileNavReopen.test.tsx
5
+ * @input Uses vitest, @testing-library/react, AppShell + SideNav
6
+ * @output Regression test for mobile hamburger nav re-open after close
7
+ * @position Testing; validates the OOTB AppShell mobile drawer toggle cycle
8
+ *
9
+ * Repro for: mobile hamburger nav can be opened and closed once, but cannot
10
+ * be re-opened after closing.
11
+ */
12
+
13
+ import {
14
+ describe,
15
+ it,
16
+ expect,
17
+ vi,
18
+ beforeAll,
19
+ beforeEach,
20
+ afterEach,
21
+ } from 'vitest';
22
+ import {render, screen, fireEvent, act} from '@testing-library/react';
23
+ import {AppShell} from '../AppShell/AppShell';
24
+ import {SideNav, SideNavItem, SideNavSection} from '../SideNav';
25
+
26
+ beforeAll(() => {
27
+ HTMLDialogElement.prototype.showModal =
28
+ HTMLDialogElement.prototype.showModal ||
29
+ function (this: HTMLDialogElement) {
30
+ this.setAttribute('open', '');
31
+ };
32
+ HTMLDialogElement.prototype.close =
33
+ HTMLDialogElement.prototype.close ||
34
+ function (this: HTMLDialogElement) {
35
+ this.removeAttribute('open');
36
+ };
37
+ });
38
+
39
+ class MockResizeObserver {
40
+ observe() {}
41
+ unobserve() {}
42
+ disconnect() {}
43
+ }
44
+ vi.stubGlobal('ResizeObserver', MockResizeObserver);
45
+
46
+ function createMockMatchMedia(matches: boolean) {
47
+ return {
48
+ matches,
49
+ media: '',
50
+ onchange: null,
51
+ addEventListener: vi.fn(),
52
+ removeEventListener: vi.fn(),
53
+ addListener: vi.fn(),
54
+ removeListener: vi.fn(),
55
+ dispatchEvent: vi.fn(),
56
+ };
57
+ }
58
+
59
+ beforeEach(() => {
60
+ vi.stubGlobal(
61
+ 'matchMedia',
62
+ vi.fn().mockReturnValue(createMockMatchMedia(true)),
63
+ );
64
+ });
65
+
66
+ afterEach(() => {
67
+ vi.restoreAllMocks();
68
+ });
69
+
70
+ function TestShell() {
71
+ return (
72
+ <AppShell
73
+ sideNav={
74
+ <SideNav>
75
+ <SideNavSection title="Test" isHeaderHidden>
76
+ <SideNavItem label="Home" />
77
+ </SideNavSection>
78
+ </SideNav>
79
+ }
80
+ mobileNav={{breakpoint: 'md'}}>
81
+ <div>Content</div>
82
+ </AppShell>
83
+ );
84
+ }
85
+
86
+ describe('Mobile nav re-open after close (uncontrolled OOTB)', () => {
87
+ it('can be opened, closed, then opened again', () => {
88
+ vi.useFakeTimers();
89
+ try {
90
+ render(<TestShell />);
91
+
92
+ const getDialog = () => screen.getAllByRole('dialog', {hidden: true})[0];
93
+ const openToggle = () =>
94
+ screen.getByRole('button', {name: /open navigation/i});
95
+
96
+ // 1. Open
97
+ fireEvent.click(openToggle());
98
+ expect(getDialog()).toHaveAttribute('open');
99
+
100
+ // 2. Close via the drawer's close button
101
+ fireEvent.click(screen.getByRole('button', {name: /close navigation/i}));
102
+ // Flush the delayed dialog.close() (slide-out transition)
103
+ act(() => {
104
+ vi.advanceTimersByTime(300);
105
+ });
106
+ expect(getDialog()).not.toHaveAttribute('open');
107
+
108
+ // 3. Open AGAIN — this is the bug: it should re-open
109
+ fireEvent.click(openToggle());
110
+ act(() => {
111
+ vi.advanceTimersByTime(300);
112
+ });
113
+ expect(getDialog()).toHaveAttribute('open');
114
+ } finally {
115
+ vi.useRealTimers();
116
+ }
117
+ });
118
+ });
@@ -228,7 +228,7 @@ export const docsZh = {
228
228
  /** @type {import('../docs-types').TranslationDoc} */
229
229
  export const docsDense = {
230
230
  description:
231
- 'Document outline/table-of-contents nav with sliding indicator track. Flat items array {id,label,level}; anchor links; density variant (default/compact); uncontrolled scroll-spy via IntersectionObserver topmost-visible-heading; controlled with activeId; smooth-scroll on click.',
231
+ 'Document outline/table-of-contents nav with sliding indicator track. Flat items array {id,label,level}; anchor links; density variant (default/compact); uncontrolled scroll-spy by scroll position (last heading past its scroll-margin-top line; first item at top, last at bottom); controlled with activeId; smooth-scroll on click that pins the active item until the next manual scroll.',
232
232
  usage: {
233
233
  description:
234
234
  'A table-of-contents sidebar for documentation pages, help centers, wikis, and long settings pages. Use it for navigation within a single page, not for app routes.',
@@ -37,7 +37,13 @@ describe('parseOutlineFromMarkdown', () => {
37
37
  parseOutlineFromMarkdown(
38
38
  '## **Install** `@astryxdesign/core`\n\n```\n# Not a heading\n```',
39
39
  ),
40
- ).toEqual([{id: 'install-astryxdesign-core', label: 'Install @astryxdesign/core', level: 2}]);
40
+ ).toEqual([
41
+ {
42
+ id: 'install-astryxdesign-core',
43
+ label: 'Install @astryxdesign/core',
44
+ level: 2,
45
+ },
46
+ ]);
41
47
  });
42
48
 
43
49
  it('deduplicates generated ids', () => {
@@ -87,7 +93,7 @@ describe('Outline', () => {
87
93
  ).not.toHaveAttribute('aria-current');
88
94
  });
89
95
 
90
- it('smooth-scrolls and reports active id on click', async () => {
96
+ it('smooth-scrolls and defers the indicator until the scroll settles when uncontrolled', async () => {
91
97
  const user = userEvent.setup();
92
98
  const target = document.createElement('h2');
93
99
  target.id = 'install';
@@ -101,6 +107,47 @@ describe('Outline', () => {
101
107
  behavior: 'smooth',
102
108
  block: 'start',
103
109
  });
110
+ // Uncontrolled: the indicator is deferred during the programmatic scroll,
111
+ // so it has not moved to the clicked item yet.
112
+ expect(
113
+ screen.getByRole('link', {name: 'Installation'}),
114
+ ).not.toHaveAttribute('aria-current', 'true');
115
+
116
+ // When the scroll settles, the indicator lands on the clicked item.
117
+ act(() => {
118
+ window.dispatchEvent(new Event('scrollend'));
119
+ });
120
+ expect(onActiveIdChange).toHaveBeenCalledWith('install');
121
+ expect(screen.getByRole('link', {name: 'Installation'})).toHaveAttribute(
122
+ 'aria-current',
123
+ 'true',
124
+ );
125
+
126
+ document.body.removeChild(target);
127
+ });
128
+
129
+ it('reports active id on click when controlled', async () => {
130
+ const user = userEvent.setup();
131
+ const target = document.createElement('h2');
132
+ target.id = 'install';
133
+ document.body.appendChild(target);
134
+ const onActiveIdChange = vi.fn();
135
+
136
+ render(
137
+ <Outline
138
+ items={items}
139
+ activeId="intro"
140
+ onActiveIdChange={onActiveIdChange}
141
+ />,
142
+ );
143
+ await user.click(screen.getByRole('link', {name: 'Installation'}));
144
+
145
+ expect(target.scrollIntoView).toHaveBeenCalledWith({
146
+ behavior: 'smooth',
147
+ block: 'start',
148
+ });
149
+ // Controlled: there is no built-in scroll-spy, so the consumer owns the
150
+ // active state and must be notified on click.
104
151
  expect(onActiveIdChange).toHaveBeenCalledWith('install');
105
152
 
106
153
  document.body.removeChild(target);
@@ -122,11 +169,7 @@ describe('Outline', () => {
122
169
 
123
170
  it('renders with density="compact"', () => {
124
171
  render(
125
- <Outline
126
- items={items}
127
- density="compact"
128
- data-testid="outline-compact"
129
- />,
172
+ <Outline items={items} density="compact" data-testid="outline-compact" />,
130
173
  );
131
174
  expect(screen.getByTestId('outline-compact').className).toContain(
132
175
  'compact',
@@ -201,50 +244,45 @@ describe('Outline', () => {
201
244
  ).not.toHaveAttribute('aria-current');
202
245
  });
203
246
 
204
- it('updates uncontrolled active id from IntersectionObserver', () => {
205
- let observerCallback: IntersectionObserverCallback | undefined;
206
-
207
- class MockIntersectionObserver {
208
- observe = vi.fn();
209
- disconnect = vi.fn();
210
-
211
- constructor(callback: IntersectionObserverCallback) {
212
- observerCallback = callback;
213
- }
214
- }
215
-
216
- vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
217
-
247
+ it('updates uncontrolled active id from scroll position', () => {
218
248
  const intro = document.createElement('h2');
219
249
  intro.id = 'intro';
250
+ const install = document.createElement('h3');
251
+ install.id = 'install';
220
252
  const api = document.createElement('h3');
221
253
  api.id = 'api';
222
- document.body.append(intro, api);
254
+ document.body.append(intro, install, api);
223
255
 
224
- const onActiveIdChange = vi.fn();
225
- render(<Outline items={items} onActiveIdChange={onActiveIdChange} />);
256
+ // Not at the bottom of the page.
257
+ Object.defineProperty(document.documentElement, 'scrollHeight', {
258
+ value: 4000,
259
+ configurable: true,
260
+ });
226
261
 
227
- const entry: IntersectionObserverEntry = {
228
- target: api,
229
- isIntersecting: true,
230
- boundingClientRect: {top: 12} as DOMRectReadOnly,
231
- intersectionRatio: 1,
232
- intersectionRect: {} as DOMRectReadOnly,
233
- rootBounds: null,
234
- time: 0,
235
- };
262
+ // intro + install have scrolled above the activation line (top <= 0);
263
+ // api is still below it, so install is the last passed heading.
264
+ vi.spyOn(intro, 'getBoundingClientRect').mockReturnValue({
265
+ top: -200,
266
+ } as DOMRect);
267
+ vi.spyOn(install, 'getBoundingClientRect').mockReturnValue({
268
+ top: -10,
269
+ } as DOMRect);
270
+ vi.spyOn(api, 'getBoundingClientRect').mockReturnValue({
271
+ top: 400,
272
+ } as DOMRect);
236
273
 
237
- act(() => {
238
- observerCallback?.([entry], {} as IntersectionObserver);
239
- });
274
+ const onActiveIdChange = vi.fn();
275
+ // The hook resolves the active id from scroll position on mount.
276
+ render(<Outline items={items} onActiveIdChange={onActiveIdChange} />);
240
277
 
241
- expect(screen.getByRole('link', {name: 'API'})).toHaveAttribute(
278
+ expect(screen.getByRole('link', {name: 'Installation'})).toHaveAttribute(
242
279
  'aria-current',
243
280
  'true',
244
281
  );
245
- expect(onActiveIdChange).toHaveBeenCalledWith('api');
282
+ expect(onActiveIdChange).toHaveBeenCalledWith('install');
246
283
 
247
284
  document.body.removeChild(intro);
285
+ document.body.removeChild(install);
248
286
  document.body.removeChild(api);
249
287
  });
250
288
  });