@astryxdesign/core 0.1.1 → 0.1.2-canary.3893cd5

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 (169) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +119 -1
  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 +12 -2
  10. package/dist/Button/Button.d.ts.map +1 -1
  11. package/dist/Button/Button.js +51 -13
  12. package/dist/EmptyState/EmptyState.d.ts.map +1 -1
  13. package/dist/EmptyState/EmptyState.js +7 -1
  14. package/dist/HoverCard/HoverCard.d.ts +2 -2
  15. package/dist/HoverCard/HoverCard.d.ts.map +1 -1
  16. package/dist/HoverCard/HoverCard.js +18 -6
  17. package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
  18. package/dist/HoverCard/useHoverCard.js +7 -3
  19. package/dist/Kbd/Kbd.d.ts.map +1 -1
  20. package/dist/Kbd/Kbd.js +1 -1
  21. package/dist/Layer/anchorName.d.ts +20 -0
  22. package/dist/Layer/anchorName.d.ts.map +1 -0
  23. package/dist/Layer/anchorName.js +36 -0
  24. package/dist/Layer/useLayer.d.ts +19 -0
  25. package/dist/Layer/useLayer.d.ts.map +1 -1
  26. package/dist/Layer/useLayer.js +15 -6
  27. package/dist/Link/Link.d.ts +1 -1
  28. package/dist/Link/Link.js +2 -2
  29. package/dist/Link/LinkProvider.d.ts +1 -0
  30. package/dist/Link/LinkProvider.d.ts.map +1 -1
  31. package/dist/Link/LinkProvider.js +1 -0
  32. package/dist/Markdown/Markdown.d.ts.map +1 -1
  33. package/dist/Markdown/Markdown.js +13 -3
  34. package/dist/MobileNav/MobileNav.d.ts.map +1 -1
  35. package/dist/MobileNav/MobileNav.js +13 -0
  36. package/dist/Pagination/Pagination.d.ts.map +1 -1
  37. package/dist/Pagination/Pagination.js +31 -27
  38. package/dist/Slider/Slider.js +2 -2
  39. package/dist/Table/BaseTable.d.ts.map +1 -1
  40. package/dist/Table/BaseTable.js +27 -8
  41. package/dist/Table/Table.d.ts.map +1 -1
  42. package/dist/Table/Table.js +30 -7
  43. package/dist/Table/TableRow.d.ts +6 -1
  44. package/dist/Table/TableRow.d.ts.map +1 -1
  45. package/dist/Table/TableRow.js +15 -7
  46. package/dist/Table/index.d.ts +3 -1
  47. package/dist/Table/index.d.ts.map +1 -1
  48. package/dist/Table/index.js +1 -0
  49. package/dist/Table/plugins/stickyColumns/index.d.ts +3 -0
  50. package/dist/Table/plugins/stickyColumns/index.d.ts.map +1 -0
  51. package/dist/Table/plugins/stickyColumns/index.js +3 -0
  52. package/dist/Table/plugins/stickyColumns/useTableStickyColumns.d.ts +25 -0
  53. package/dist/Table/plugins/stickyColumns/useTableStickyColumns.d.ts.map +1 -0
  54. package/dist/Table/plugins/stickyColumns/useTableStickyColumns.js +369 -0
  55. package/dist/Table/types.d.ts +95 -5
  56. package/dist/Table/types.d.ts.map +1 -1
  57. package/dist/Table/useBaseTablePlugins.d.ts.map +1 -1
  58. package/dist/Table/useBaseTablePlugins.js +1 -1
  59. package/dist/Text/text.stylex.d.ts +2 -2
  60. package/dist/Text/text.stylex.js +2 -2
  61. package/dist/Timestamp/Timestamp.d.ts +1 -1
  62. package/dist/Timestamp/Timestamp.d.ts.map +1 -1
  63. package/dist/Timestamp/Timestamp.js +28 -3
  64. package/dist/ToggleButton/ToggleButton.d.ts +7 -9
  65. package/dist/ToggleButton/ToggleButton.d.ts.map +1 -1
  66. package/dist/ToggleButton/ToggleButton.js +34 -57
  67. package/dist/Tokenizer/Tokenizer.d.ts.map +1 -1
  68. package/dist/Tokenizer/Tokenizer.js +1 -1
  69. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  70. package/dist/Tooltip/useTooltip.js +1 -0
  71. package/dist/astryx.css +30 -1
  72. package/dist/astryx.umd.js +147 -0
  73. package/dist/astryx.umd.js.map +7 -0
  74. package/dist/theme/generateThemeRules.js +1 -1
  75. package/dist/theme/syntax/SyntaxTheme.d.ts +1 -1
  76. package/dist/theme/syntax/SyntaxTheme.js +1 -1
  77. package/dist/theme/syntax/defineSyntaxTheme.d.ts.map +1 -1
  78. package/dist/theme/syntax/defineSyntaxTheme.js +2 -2
  79. package/dist/theme/syntax/presets.d.ts +1 -1
  80. package/dist/theme/syntax/presets.d.ts.map +1 -1
  81. package/dist/theme/syntax/presets.js +1 -1
  82. package/dist/theme/syntax/tokens.d.ts +1 -1
  83. package/dist/theme/syntax/tokens.js +1 -1
  84. package/dist/theme/types.d.ts +1 -1
  85. package/package.json +6 -2
  86. package/src/AvatarGroup/AvatarGroupOverflow.tsx +3 -0
  87. package/src/Banner/Banner.doc.mjs +8 -0
  88. package/src/Banner/Banner.test.tsx +16 -7
  89. package/src/Banner/Banner.tsx +9 -2
  90. package/src/Button/Button.doc.mjs +6 -0
  91. package/src/Button/Button.test.tsx +105 -11
  92. package/src/Button/Button.tsx +73 -5
  93. package/src/Chat/ChatComposer.doc.mjs +3 -0
  94. package/src/Chat/ChatComposerDrawer.doc.mjs +12 -0
  95. package/src/CheckboxInput/CheckboxInput.doc.mjs +8 -0
  96. package/src/ClickableCard/ClickableCard.doc.mjs +15 -0
  97. package/src/Dialog/Dialog.doc.mjs +2 -2
  98. package/src/EmptyState/EmptyState.test.tsx +4 -2
  99. package/src/EmptyState/EmptyState.tsx +6 -2
  100. package/src/HoverCard/HoverCard.doc.mjs +3 -0
  101. package/src/HoverCard/HoverCard.test.tsx +189 -2
  102. package/src/HoverCard/HoverCard.tsx +20 -16
  103. package/src/HoverCard/useHoverCard.tsx +13 -10
  104. package/src/Icon/Icon.doc.mjs +8 -0
  105. package/src/Kbd/Kbd.tsx +2 -9
  106. package/src/Layer/anchorName.test.ts +146 -0
  107. package/src/Layer/anchorName.ts +46 -0
  108. package/src/Layer/useLayer.doc.mjs +7 -2
  109. package/src/Layer/useLayer.tsx +33 -9
  110. package/src/Layout/Layout.doc.mjs +1 -1
  111. package/src/Lightbox/Lightbox.doc.mjs +0 -2
  112. package/src/Link/Link.test.tsx +2 -2
  113. package/src/Link/Link.tsx +3 -3
  114. package/src/Link/LinkProvider.tsx +1 -0
  115. package/src/List/List.doc.mjs +7 -0
  116. package/src/Markdown/Markdown.doc.mjs +2 -0
  117. package/src/Markdown/Markdown.test.tsx +17 -26
  118. package/src/Markdown/Markdown.tsx +16 -6
  119. package/src/MobileNav/MobileNav.doc.mjs +8 -8
  120. package/src/MobileNav/MobileNav.tsx +13 -0
  121. package/src/MobileNav/MobileNavReopen.test.tsx +118 -0
  122. package/src/OverflowList/OverflowList.doc.mjs +17 -0
  123. package/src/Pagination/Pagination.test.tsx +137 -13
  124. package/src/Pagination/Pagination.tsx +33 -28
  125. package/src/ProgressBar/ProgressBar.test.tsx +13 -3
  126. package/src/Resizable/Resizable.doc.mjs +1 -1
  127. package/src/Section/Section.doc.mjs +11 -0
  128. package/src/SelectableCard/SelectableCard.doc.mjs +15 -0
  129. package/src/Selector/Selector.doc.mjs +4 -0
  130. package/src/Skeleton/Skeleton.doc.mjs +11 -1
  131. package/src/Slider/Slider.doc.mjs +4 -0
  132. package/src/Slider/Slider.test.tsx +3 -16
  133. package/src/Slider/Slider.tsx +1 -1
  134. package/src/TabList/TabList.doc.mjs +6 -0
  135. package/src/Table/BaseTable.tsx +51 -24
  136. package/src/Table/Table.doc.mjs +12 -0
  137. package/src/Table/Table.test.tsx +29 -30
  138. package/src/Table/Table.tsx +22 -1
  139. package/src/Table/TableRow.tsx +37 -7
  140. package/src/Table/index.ts +3 -0
  141. package/src/Table/plugins/stickyColumns/index.ts +4 -0
  142. package/src/Table/plugins/stickyColumns/useTableStickyColumns.test.tsx +163 -0
  143. package/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx +407 -0
  144. package/src/Table/types.ts +101 -4
  145. package/src/Table/useBaseTablePlugins.ts +1 -0
  146. package/src/Table/useTableColumnResize.doc.mjs +96 -0
  147. package/src/Table/useTableStickyColumns.doc.mjs +59 -0
  148. package/src/Text/Heading.doc.mjs +15 -2
  149. package/src/Text/Text.doc.mjs +7 -1
  150. package/src/Text/text.stylex.ts +2 -2
  151. package/src/Timestamp/Timestamp.doc.mjs +9 -0
  152. package/src/Timestamp/Timestamp.test.tsx +44 -18
  153. package/src/Timestamp/Timestamp.tsx +30 -10
  154. package/src/ToggleButton/ToggleButton.doc.mjs +9 -3
  155. package/src/ToggleButton/ToggleButton.test.tsx +39 -18
  156. package/src/ToggleButton/ToggleButton.tsx +46 -83
  157. package/src/Tokenizer/Tokenizer.tsx +4 -1
  158. package/src/Toolbar/Toolbar.doc.mjs +65 -5
  159. package/src/Tooltip/Tooltip.test.tsx +13 -0
  160. package/src/Tooltip/useTooltip.tsx +2 -3
  161. package/src/hooks/useImageMode.test.ts +133 -0
  162. package/src/theme/derivedVarRegistry.test.ts +4 -0
  163. package/src/theme/generateThemeRules.test.ts +7 -8
  164. package/src/theme/generateThemeRules.ts +1 -1
  165. package/src/theme/syntax/SyntaxTheme.tsx +1 -1
  166. package/src/theme/syntax/defineSyntaxTheme.ts +20 -11
  167. package/src/theme/syntax/presets.ts +5 -2
  168. package/src/theme/syntax/tokens.ts +1 -1
  169. package/src/theme/types.ts +1 -1
@@ -23,6 +23,7 @@ import React, {
23
23
  } from 'react';
24
24
  import * as stylex from '@stylexjs/stylex';
25
25
  import type {StyleXStyles} from '@stylexjs/stylex';
26
+ import {addAnchorName, removeAnchorName} from './anchorName';
26
27
  import {typographyVars} from '../theme/tokens.stylex';
27
28
 
28
29
  const styles = stylex.create({
@@ -65,6 +66,12 @@ export type LayerAlignment = 'start' | 'center' | 'end';
65
66
  export interface ContextRenderProps {
66
67
  placement?: LayerPlacement;
67
68
  alignment?: LayerAlignment;
69
+ /**
70
+ * ARIA role applied to the popover container (e.g. `'tooltip'`). Lets
71
+ * consumers complete the ARIA pattern and gives test tooling a stable,
72
+ * non-hashed selector for the layer.
73
+ */
74
+ role?: string;
68
75
  /**
69
76
  * StyleX styles for the popover container.
70
77
  */
@@ -79,6 +86,19 @@ export interface ContextRenderProps {
79
86
  * Merged after StyleX and anchor positioning styles.
80
87
  */
81
88
  style?: React.CSSProperties;
89
+ /**
90
+ * HTML tag to render the popover container as.
91
+ *
92
+ * Defaults to `'div'`. Pass `'span'` when the layer must render inline-safe
93
+ * markup — e.g. a `HoverCard` wrapping inline text inside a `<p>`. A `<span>`
94
+ * is phrasing content, so it stays put in the DOM tree instead of being
95
+ * reparented out of a paragraph by the HTML parser, which keeps server and
96
+ * client markup identical. The Popover API and CSS anchor positioning work
97
+ * the same on either tag.
98
+ *
99
+ * @default 'div'
100
+ */
101
+ as?: 'div' | 'span';
82
102
  }
83
103
 
84
104
  /**
@@ -311,16 +331,14 @@ export function useLayer(
311
331
  const ref: RefCallback<HTMLElement> | undefined =
312
332
  mode === 'context'
313
333
  ? (el: HTMLElement | null) => {
314
- // Cleanup previous element
315
- if (triggerRef.current) {
316
- (
317
- triggerRef.current.style as unknown as Record<string, string>
318
- ).anchorName = '';
334
+ // Remove only THIS layer's anchor name from the previous element so
335
+ // other layers sharing the same trigger keep their anchors.
336
+ if (triggerRef.current && triggerRef.current !== el) {
337
+ removeAnchorName(triggerRef.current, anchorId);
319
338
  }
320
339
 
321
340
  if (el) {
322
- (el.style as unknown as Record<string, string>).anchorName =
323
- anchorId;
341
+ addAnchorName(el, anchorId);
324
342
  }
325
343
 
326
344
  triggerRef.current = el;
@@ -366,9 +384,11 @@ export function useLayer(
366
384
  const {
367
385
  placement = 'above',
368
386
  alignment = 'center',
387
+ role,
369
388
  xstyle,
370
389
  className: extraClassName,
371
390
  style: extraStyle,
391
+ as: Container = 'div',
372
392
  } = props || {};
373
393
 
374
394
  // CSS anchor positioning (dynamic, not in StyleX)
@@ -383,15 +403,19 @@ export function useLayer(
383
403
  ? `${extraClassName} ${stylexResult.className ?? ''}`
384
404
  : stylexResult.className;
385
405
 
406
+ // Render as the requested tag. A `span` keeps the layer phrasing content
407
+ // so it is valid (and stays put on hydration) inside inline contexts like
408
+ // a `<p>`; `div` remains the default for block layers.
386
409
  return (
387
- <div
410
+ <Container
388
411
  ref={popoverRefCallback}
389
412
  id={id}
413
+ role={role}
390
414
  popover={lightDismiss ? 'auto' : 'manual'}
391
415
  className={combinedClassName}
392
416
  style={{...stylexResult.style, ...anchorStyle, ...extraStyle}}>
393
417
  {children}
394
- </div>
418
+ </Container>
395
419
  );
396
420
  },
397
421
  [anchorId, id, lightDismiss, popoverRefCallback],
@@ -30,7 +30,7 @@ export const docs = {
30
30
  name: 'content',
31
31
  type: 'ReactNode',
32
32
  description:
33
- 'Main content area (center). Children passed to `<Layout>` render here too — `<Layout>{main}</Layout>` is shorthand for `<Layout content={main} />`.',
33
+ 'Main content area (center). Children passed to `<Layout>` render here too: `<Layout>{main}</Layout>` is shorthand for `<Layout content={main} />`.',
34
34
  slotElements: [
35
35
  {
36
36
  __element: 'LayoutContent',
@@ -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,8 +99,8 @@ describe('Link', () => {
99
99
 
100
100
  it('renders with different color values', () => {
101
101
  const {rerender} = render(
102
- <Link href="/test" color="active">
103
- Active
102
+ <Link href="/test" color="accent">
103
+ Accent
104
104
  </Link>,
105
105
  );
106
106
  expect(screen.getByRole('link')).toBeInTheDocument();
package/src/Link/Link.tsx CHANGED
@@ -117,7 +117,7 @@ const linkColorStyles = stylex.create({
117
117
  placeholder: {
118
118
  color: colorVars['--color-text-secondary'],
119
119
  },
120
- active: {
120
+ accent: {
121
121
  color: colorVars['--color-text-accent'],
122
122
  },
123
123
  inherit: {
@@ -216,7 +216,7 @@ export interface LinkProps extends BaseProps<
216
216
  weight?: TextWeight;
217
217
  /**
218
218
  * Text color. Forwarded to Text.
219
- * @default 'active'
219
+ * @default 'accent'
220
220
  */
221
221
  color?: TextColor;
222
222
  /**
@@ -264,7 +264,7 @@ export function Link({
264
264
  type = 'body',
265
265
  size,
266
266
  weight,
267
- color = 'active',
267
+ color = 'accent',
268
268
  display = 'inline',
269
269
  maxLines = 0,
270
270
  children,
@@ -23,6 +23,7 @@
23
23
  * - /packages/core/src/Link/index.ts
24
24
  * - /packages/core/src/Link/Link.doc.mjs
25
25
  * - /packages/cli/templates/blocks/components/Link/ (showcase blocks)
26
+ * - /packages/cli/templates/blocks/components/LinkProvider/ (example blocks)
26
27
  */
27
28
 
28
29
  import {useMemo, type ReactNode} from 'react';
@@ -61,6 +61,13 @@ export const docs = {
61
61
  description: "List marker style. 'decimal' renders an <ol> element instead of <ul>.",
62
62
  default: "'none'",
63
63
  },
64
+ {
65
+ name: 'start',
66
+ type: 'number',
67
+ description:
68
+ "Starting number for ordered lists (listStyle='decimal'). Sets the CSS counter to begin at this value.",
69
+ default: '1',
70
+ },
64
71
  {
65
72
  name: 'xstyle',
66
73
  type: 'StyleXStyles',
@@ -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',
@@ -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',
@@ -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
+ });
@@ -18,6 +18,23 @@ export const docs = {
18
18
  'clamp',
19
19
  'responsive',
20
20
  ],
21
+ playground: {
22
+ // children is required; without seeded items the properties-tab preview
23
+ // renders an empty list. Provide a few items so the preview is meaningful.
24
+ // `observeParent` measures the (full-width) preview container instead of
25
+ // the list's own collapsed content box, so the row shows all items rather
26
+ // than collapsing to one.
27
+ defaults: {
28
+ behavior: 'observeParent',
29
+ children: [
30
+ {__element: 'Button', props: {label: 'Overview', variant: 'secondary'}},
31
+ {__element: 'Button', props: {label: 'Activity', variant: 'secondary'}},
32
+ {__element: 'Button', props: {label: 'Settings', variant: 'secondary'}},
33
+ {__element: 'Button', props: {label: 'Members', variant: 'secondary'}},
34
+ {__element: 'Button', props: {label: 'Billing', variant: 'secondary'}},
35
+ ],
36
+ },
37
+ },
21
38
  props: [
22
39
  {
23
40
  name: 'children',