@astryxdesign/core 0.4.4 → 0.4.5-canary.1fdecb1

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 (275) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/BottomSheet/BottomSheet.d.ts +11 -2
  3. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  4. package/dist/BottomSheet/BottomSheet.js +4 -0
  5. package/dist/BottomSheet/BottomSheetPanel.d.ts +5 -2
  6. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
  7. package/dist/BottomSheet/BottomSheetPanel.js +65 -15
  8. package/dist/BottomSheet/index.d.ts +1 -1
  9. package/dist/BottomSheet/index.d.ts.map +1 -1
  10. package/dist/BottomSheet/snapOffsets.d.ts +57 -20
  11. package/dist/BottomSheet/snapOffsets.d.ts.map +1 -1
  12. package/dist/BottomSheet/snapOffsets.js +106 -30
  13. package/dist/BottomSheet/useSheetGestures.d.ts +13 -8
  14. package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -1
  15. package/dist/BottomSheet/useSheetGestures.js +160 -64
  16. package/dist/Calendar/Calendar.d.ts +17 -0
  17. package/dist/Calendar/Calendar.d.ts.map +1 -1
  18. package/dist/Calendar/Calendar.js +34 -3
  19. package/dist/Calendar/getStandaloneShortWeekdayNames.d.ts +14 -0
  20. package/dist/Calendar/getStandaloneShortWeekdayNames.d.ts.map +1 -0
  21. package/dist/Calendar/getStandaloneShortWeekdayNames.js +26 -0
  22. package/dist/Calendar/hooks/useCalendarConstraints.d.ts +22 -1
  23. package/dist/Calendar/hooks/useCalendarConstraints.d.ts.map +1 -1
  24. package/dist/Calendar/hooks/useCalendarConstraints.js +26 -4
  25. package/dist/Calendar/hooks/useCalendarDays.d.ts.map +1 -1
  26. package/dist/Calendar/hooks/useCalendarDays.js +14 -10
  27. package/dist/Calendar/standaloneShortWeekdayNames.generated.d.ts +47 -0
  28. package/dist/Calendar/standaloneShortWeekdayNames.generated.d.ts.map +1 -0
  29. package/dist/Calendar/standaloneShortWeekdayNames.generated.js +40 -0
  30. package/dist/Center/Center.d.ts +23 -1
  31. package/dist/Center/Center.d.ts.map +1 -1
  32. package/dist/Center/Center.js +12 -6
  33. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  34. package/dist/CheckboxInput/CheckboxInput.js +9 -0
  35. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  36. package/dist/ComplexSelector/ComplexSelector.js +6 -1
  37. package/dist/DateInput/DateInput.d.ts.map +1 -1
  38. package/dist/DateInput/DateInput.js +6 -1
  39. package/dist/DateRangeInput/DateRangeInput.d.ts +23 -1
  40. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  41. package/dist/DateRangeInput/DateRangeInput.js +38 -3
  42. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  43. package/dist/DateTimeInput/DateTimeInput.js +7 -2
  44. package/dist/Dialog/Dialog.d.ts.map +1 -1
  45. package/dist/Dialog/Dialog.js +3 -3
  46. package/dist/Field/FieldLabel.d.ts +2 -1
  47. package/dist/Field/FieldLabel.d.ts.map +1 -1
  48. package/dist/Field/FieldLabel.js +21 -3
  49. package/dist/Field/InputClearButton.d.ts +2 -1
  50. package/dist/Field/InputClearButton.d.ts.map +1 -1
  51. package/dist/Field/InputClearButton.js +6 -1
  52. package/dist/FormLayout/FormLayout.d.ts +24 -2
  53. package/dist/FormLayout/FormLayout.d.ts.map +1 -1
  54. package/dist/FormLayout/FormLayout.js +7 -2
  55. package/dist/FormLayout/FormLayoutContext.d.ts +15 -2
  56. package/dist/FormLayout/FormLayoutContext.d.ts.map +1 -1
  57. package/dist/FormLayout/FormLayoutContext.js +19 -4
  58. package/dist/FormLayout/index.d.ts +1 -1
  59. package/dist/FormLayout/index.d.ts.map +1 -1
  60. package/dist/Item/Item.d.ts +9 -1
  61. package/dist/Item/Item.d.ts.map +1 -1
  62. package/dist/Item/Item.js +45 -7
  63. package/dist/Layer/useLayer.d.ts.map +1 -1
  64. package/dist/Layer/useLayer.js +3 -2
  65. package/dist/Layout/container.stylex.d.ts +4 -2
  66. package/dist/Layout/container.stylex.d.ts.map +1 -1
  67. package/dist/Layout/container.stylex.js +19 -11
  68. package/dist/Layout/index.d.ts +1 -0
  69. package/dist/Layout/index.d.ts.map +1 -1
  70. package/dist/Layout/index.js +4 -0
  71. package/dist/Layout/padding.stylex.d.ts +315 -12
  72. package/dist/Layout/padding.stylex.d.ts.map +1 -1
  73. package/dist/Layout/padding.stylex.js +389 -12
  74. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  75. package/dist/Lightbox/Lightbox.js +2 -1
  76. package/dist/Markdown/Markdown.d.ts +8 -0
  77. package/dist/Markdown/Markdown.d.ts.map +1 -1
  78. package/dist/Markdown/Markdown.js +31 -3
  79. package/dist/Markdown/parser.d.ts +14 -2
  80. package/dist/Markdown/parser.d.ts.map +1 -1
  81. package/dist/Markdown/parser.js +50 -2
  82. package/dist/MobileNav/MobileNav.d.ts +1 -0
  83. package/dist/MobileNav/MobileNav.d.ts.map +1 -1
  84. package/dist/MobileNav/MobileNav.js +24 -6
  85. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  86. package/dist/MultiSelector/MultiSelector.js +6 -1
  87. package/dist/NumberInput/NumberInput.d.ts.map +1 -1
  88. package/dist/NumberInput/NumberInput.js +36 -3
  89. package/dist/Outline/parseOutlineFromMarkdown.d.ts +2 -0
  90. package/dist/Outline/parseOutlineFromMarkdown.d.ts.map +1 -1
  91. package/dist/Outline/parseOutlineFromMarkdown.js +5 -31
  92. package/dist/RadioList/RadioList.d.ts.map +1 -1
  93. package/dist/RadioList/RadioList.js +12 -1
  94. package/dist/Resizable/ResizeHandle.d.ts.map +1 -1
  95. package/dist/Resizable/ResizeHandle.js +3 -3
  96. package/dist/Section/Section.d.ts +36 -1
  97. package/dist/Section/Section.d.ts.map +1 -1
  98. package/dist/Section/Section.js +12 -2
  99. package/dist/Selector/Selector.d.ts +26 -1
  100. package/dist/Selector/Selector.d.ts.map +1 -1
  101. package/dist/Selector/Selector.js +93 -15
  102. package/dist/Selector/SelectorOption.d.ts +14 -1
  103. package/dist/Selector/SelectorOption.d.ts.map +1 -1
  104. package/dist/Selector/SelectorOption.js +6 -0
  105. package/dist/Selector/SelectorRowLayoutContext.d.ts +8 -0
  106. package/dist/Selector/SelectorRowLayoutContext.d.ts.map +1 -0
  107. package/dist/Selector/SelectorRowLayoutContext.js +27 -0
  108. package/dist/Selector/types.d.ts +1 -0
  109. package/dist/Selector/types.d.ts.map +1 -1
  110. package/dist/Slider/Slider.d.ts.map +1 -1
  111. package/dist/Slider/Slider.js +2 -2
  112. package/dist/Stack/Stack.d.ts +23 -1
  113. package/dist/Stack/Stack.d.ts.map +1 -1
  114. package/dist/Stack/Stack.js +12 -6
  115. package/dist/StatusDot/StatusDot.d.ts +43 -3
  116. package/dist/StatusDot/StatusDot.d.ts.map +1 -1
  117. package/dist/StatusDot/StatusDot.js +42 -5
  118. package/dist/Switch/Switch.d.ts.map +1 -1
  119. package/dist/Switch/Switch.js +9 -0
  120. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
  121. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +7 -2
  122. package/dist/TextArea/TextArea.d.ts.map +1 -1
  123. package/dist/TextArea/TextArea.js +6 -1
  124. package/dist/TextInput/TextInput.d.ts.map +1 -1
  125. package/dist/TextInput/TextInput.js +6 -1
  126. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  127. package/dist/TimeInput/TimeInput.js +6 -1
  128. package/dist/astryx.css +40 -21
  129. package/dist/hooks/scrollbarGutter.d.ts +28 -0
  130. package/dist/hooks/scrollbarGutter.d.ts.map +1 -0
  131. package/dist/hooks/scrollbarGutter.js +103 -0
  132. package/dist/hooks/useResolvedRequired.d.ts +19 -0
  133. package/dist/hooks/useResolvedRequired.d.ts.map +1 -0
  134. package/dist/hooks/useResolvedRequired.js +40 -0
  135. package/dist/hooks/useScrollLock.d.ts +4 -0
  136. package/dist/hooks/useScrollLock.d.ts.map +1 -1
  137. package/dist/hooks/useScrollLock.js +13 -1
  138. package/dist/theme/derivedVarRegistry.d.ts.map +1 -1
  139. package/dist/theme/derivedVarRegistry.js +7 -0
  140. package/dist/theme/generateThemeRules.d.ts.map +1 -1
  141. package/dist/theme/generateThemeRules.js +37 -4
  142. package/dist/utils/plainDate.d.ts +6 -0
  143. package/dist/utils/plainDate.d.ts.map +1 -1
  144. package/dist/utils/plainDate.js +12 -0
  145. package/locales/af-ZA.json +958 -0
  146. package/locales/ar-SA.json +998 -0
  147. package/locales/ca-ES.json +974 -0
  148. package/locales/cs-CZ.json +990 -0
  149. package/locales/da-DK.json +966 -0
  150. package/locales/de-DE.json +966 -0
  151. package/locales/el-GR.json +990 -0
  152. package/locales/en.json +8 -0
  153. package/locales/es-ES.json +978 -0
  154. package/locales/fi-FI.json +990 -0
  155. package/locales/fr-FR.json +928 -0
  156. package/locales/he-IL.json +990 -0
  157. package/locales/hu-HU.json +990 -0
  158. package/locales/it-IT.json +978 -0
  159. package/locales/ja-JP.json +998 -0
  160. package/locales/ko-KR.json +990 -0
  161. package/locales/nl-NL.json +954 -0
  162. package/locales/no-NO.json +978 -0
  163. package/locales/pl-PL.json +982 -0
  164. package/locales/pseudo.json +6 -0
  165. package/locales/pt-BR.json +982 -0
  166. package/locales/pt-PT.json +982 -0
  167. package/locales/ro-RO.json +982 -0
  168. package/locales/ru-RU.json +990 -0
  169. package/locales/sr-SP.json +990 -0
  170. package/locales/sv-SE.json +986 -0
  171. package/locales/tr-TR.json +990 -0
  172. package/locales/uk-UA.json +990 -0
  173. package/locales/vi-VN.json +986 -0
  174. package/locales/zh-CN.json +998 -0
  175. package/locales/zh-TW.json +998 -0
  176. package/package.json +3 -3
  177. package/src/BottomSheet/BottomSheet.doc.mjs +36 -3
  178. package/src/BottomSheet/BottomSheet.test.tsx +177 -2
  179. package/src/BottomSheet/BottomSheet.tsx +16 -2
  180. package/src/BottomSheet/BottomSheetPanel.test.tsx +41 -0
  181. package/src/BottomSheet/BottomSheetPanel.tsx +95 -15
  182. package/src/BottomSheet/index.ts +5 -1
  183. package/src/BottomSheet/snapOffsets.test.ts +114 -47
  184. package/src/BottomSheet/snapOffsets.ts +119 -33
  185. package/src/BottomSheet/useSheetGestures.test.ts +113 -15
  186. package/src/BottomSheet/useSheetGestures.ts +196 -100
  187. package/src/Calendar/Calendar.doc.mjs +16 -0
  188. package/src/Calendar/Calendar.test.tsx +137 -0
  189. package/src/Calendar/Calendar.tsx +65 -2
  190. package/src/Calendar/getStandaloneShortWeekdayNames.test.ts +54 -0
  191. package/src/Calendar/getStandaloneShortWeekdayNames.ts +38 -0
  192. package/src/Calendar/hooks/useCalendarConstraints.ts +54 -3
  193. package/src/Calendar/hooks/useCalendarDays.ts +13 -10
  194. package/src/Calendar/standaloneShortWeekdayNames.generated.ts +50 -0
  195. package/src/Center/Center.doc.mjs +48 -0
  196. package/src/Center/Center.test.tsx +158 -0
  197. package/src/Center/Center.tsx +50 -9
  198. package/src/CheckboxInput/CheckboxInput.tsx +6 -0
  199. package/src/ComplexSelector/ComplexSelector.tsx +3 -1
  200. package/src/DateInput/DateInput.tsx +3 -1
  201. package/src/DateRangeInput/DateRangeInput.doc.mjs +16 -0
  202. package/src/DateRangeInput/DateRangeInput.test.tsx +81 -1
  203. package/src/DateRangeInput/DateRangeInput.tsx +72 -1
  204. package/src/DateTimeInput/DateTimeInput.tsx +4 -2
  205. package/src/Dialog/Dialog.test.tsx +24 -0
  206. package/src/Dialog/Dialog.tsx +3 -0
  207. package/src/Field/Field.doc.mjs +1 -0
  208. package/src/Field/FieldLabel.tsx +20 -4
  209. package/src/Field/InputClearButton.test.tsx +25 -0
  210. package/src/Field/InputClearButton.tsx +4 -1
  211. package/src/FormLayout/FormLayout.doc.mjs +13 -0
  212. package/src/FormLayout/FormLayout.test.tsx +181 -4
  213. package/src/FormLayout/FormLayout.tsx +33 -2
  214. package/src/FormLayout/FormLayoutContext.ts +22 -7
  215. package/src/FormLayout/index.ts +1 -1
  216. package/src/Item/Item.doc.mjs +1 -0
  217. package/src/Item/Item.test.tsx +50 -0
  218. package/src/Item/Item.tsx +40 -1
  219. package/src/Layer/useLayer.tsx +13 -2
  220. package/src/Layout/Layout.doc.mjs +18 -0
  221. package/src/Layout/container.stylex.ts +11 -3
  222. package/src/Layout/index.ts +4 -0
  223. package/src/Layout/overlayPaddingReset.test.tsx +222 -0
  224. package/src/Layout/padding.stylex.ts +192 -12
  225. package/src/Lightbox/Lightbox.tsx +2 -1
  226. package/src/Markdown/Markdown.doc.mjs +3 -0
  227. package/src/Markdown/Markdown.test.tsx +78 -0
  228. package/src/Markdown/Markdown.tsx +41 -1
  229. package/src/Markdown/parser.ts +60 -2
  230. package/src/MobileNav/MobileNav.tsx +61 -5
  231. package/src/MobileNav/MobileNavEntryAnimation.test.tsx +212 -0
  232. package/src/MobileNav/MobileNavScrollbarGutter.test.tsx +109 -0
  233. package/src/MultiSelector/MultiSelector.tsx +3 -1
  234. package/src/NumberInput/NumberInput.doc.mjs +24 -0
  235. package/src/NumberInput/NumberInput.test.tsx +138 -0
  236. package/src/NumberInput/NumberInput.tsx +43 -5
  237. package/src/Outline/Outline.doc.mjs +2 -0
  238. package/src/Outline/parseOutlineFromMarkdown.ts +10 -40
  239. package/src/RadioList/RadioList.tsx +9 -1
  240. package/src/Resizable/ResizeHandle.test.tsx +22 -0
  241. package/src/Resizable/ResizeHandle.tsx +9 -3
  242. package/src/Section/Section.doc.mjs +55 -0
  243. package/src/Section/Section.test.tsx +201 -4
  244. package/src/Section/Section.tsx +69 -0
  245. package/src/Selector/Selector.doc.mjs +16 -2
  246. package/src/Selector/Selector.test.tsx +524 -0
  247. package/src/Selector/Selector.tsx +161 -17
  248. package/src/Selector/SelectorOption.doc.mjs +7 -0
  249. package/src/Selector/SelectorOption.tsx +21 -0
  250. package/src/Selector/SelectorRowLayoutContext.ts +32 -0
  251. package/src/Selector/types.ts +4 -4
  252. package/src/Slider/Slider.tsx +10 -0
  253. package/src/Stack/Stack.doc.mjs +72 -0
  254. package/src/Stack/Stack.test.tsx +156 -0
  255. package/src/Stack/Stack.tsx +50 -8
  256. package/src/StatusDot/StatusDot.doc.mjs +13 -0
  257. package/src/StatusDot/StatusDot.test.tsx +115 -8
  258. package/src/StatusDot/StatusDot.tsx +74 -9
  259. package/src/Switch/Switch.tsx +6 -0
  260. package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +20 -2
  261. package/src/TextArea/TextArea.tsx +3 -1
  262. package/src/TextInput/TextInput.tsx +3 -1
  263. package/src/TimeInput/TimeInput.tsx +3 -1
  264. package/src/hooks/scrollbarGutter.test.ts +167 -0
  265. package/src/hooks/scrollbarGutter.ts +121 -0
  266. package/src/hooks/useResolvedRequired.ts +42 -0
  267. package/src/hooks/useScrollLock.doc.mjs +4 -4
  268. package/src/hooks/useScrollLock.test.ts +73 -0
  269. package/src/hooks/useScrollLock.ts +14 -0
  270. package/src/theme/derivedVarRegistry.test.ts +14 -3
  271. package/src/theme/derivedVarRegistry.ts +4 -0
  272. package/src/theme/generateThemeRules.test.ts +79 -0
  273. package/src/theme/generateThemeRules.ts +49 -4
  274. package/src/utils/plainDate.test.ts +50 -0
  275. package/src/utils/plainDate.ts +12 -0
@@ -169,6 +169,7 @@ export const docs = {
169
169
  { guidance: true, description: 'Set headingLevelStart to match the page hierarchy, e.g. start at 3 if the markdown sits inside an h2 section.' },
170
170
  { guidance: true, description: 'Use contentWidth to keep prose at a readable line length in wide layouts.' },
171
171
  { guidance: true, description: 'Use inlinePlugins for custom shorthand patterns like issue refs, diff refs, and mentions instead of preprocessing the markdown string.' },
172
+ { guidance: true, description: 'Pair with Outline and useOutlineFromMarkdown for section navigation: headings render generated id attributes that match the outline item ids, so hash links scroll to their target.' },
172
173
  { guidance: false, description: 'Use Markdown for hand-authored layouts; use Text and Heading directly when you control the content.' },
173
174
  ],
174
175
  },
@@ -381,6 +382,7 @@ export const docsZh = {
381
382
  { guidance: true, description: 'Set headingLevelStart to match the page hierarchy, e.g. start at 3 if the markdown sits inside an h2 section.' },
382
383
  { guidance: true, description: 'Use contentWidth to keep prose at a readable line length in wide layouts.' },
383
384
  { guidance: true, description: 'Use inlinePlugins for custom shorthand patterns like issue refs, diff refs, and mentions instead of preprocessing the markdown string.' },
385
+ { guidance: true, description: 'Pair with Outline and useOutlineFromMarkdown for section navigation: headings render generated id attributes that match the outline item ids, so hash links scroll to their target.' },
384
386
  { guidance: false, description: 'Use Markdown for hand-authored layouts; use Text and Heading directly when you control the content.' },
385
387
  ],
386
388
  },
@@ -396,6 +398,7 @@ export const docsDense = {
396
398
  { guidance: true, description: 'Set headingLevelStart to match the page hierarchy, e.g. start at 3 if the markdown sits inside an h2 section.' },
397
399
  { guidance: true, description: 'Use contentWidth to keep prose at a readable line length in wide layouts.' },
398
400
  { guidance: true, description: 'Use inlinePlugins for custom shorthand patterns (issue refs, diff refs, mentions) instead of preprocessing the markdown string.' },
401
+ { guidance: true, description: 'Headings render id attributes matching useOutlineFromMarkdown ids; pair with Outline for hash navigation.' },
399
402
  { guidance: false, description: 'Use Markdown for hand-authored layouts; use Text and Heading directly when you control the content.' },
400
403
  ],
401
404
  },
@@ -5,6 +5,7 @@ import {render, screen, fireEvent} from '@testing-library/react';
5
5
  import type {ReactNode} from 'react';
6
6
  import {Markdown} from './Markdown';
7
7
  import type {MarkdownInlinePlugin} from './Markdown';
8
+ import {parseOutlineFromMarkdown} from '../Outline/parseOutlineFromMarkdown';
8
9
 
9
10
  describe('Markdown', () => {
10
11
  it('renders with role="document"', () => {
@@ -23,6 +24,83 @@ describe('Markdown', () => {
23
24
  expect(screen.getByText('Heading 2').tagName).toBe('H2');
24
25
  });
25
26
 
27
+ describe('heading ids', () => {
28
+ // Outline's documented contract: an outline item id "should match the
29
+ // target heading element id". Markdown renders the ids that
30
+ // useOutlineFromMarkdown derives, so hash navigation resolves.
31
+ it('renders generated id attributes on headings', () => {
32
+ render(<Markdown>{'# Overview\n\ncontent\n\n# Installation'}</Markdown>);
33
+ expect(screen.getByText('Overview')).toHaveAttribute('id', 'overview');
34
+ expect(screen.getByText('Installation')).toHaveAttribute(
35
+ 'id',
36
+ 'installation',
37
+ );
38
+ });
39
+
40
+ it('disambiguates duplicate headings with numeric suffixes', () => {
41
+ render(<Markdown>{'# Setup\n\n# Setup\n\n# Setup'}</Markdown>);
42
+ const ids = screen.getAllByText('Setup').map(el => el.id);
43
+ expect(ids).toEqual(['setup', 'setup-1', 'setup-2']);
44
+ });
45
+
46
+ it('renders ids matching parseOutlineFromMarkdown for the same source', () => {
47
+ // Parity invariant: every id the outline derives must resolve to a
48
+ // rendered heading with that exact id — including slugified formatting,
49
+ // duplicate numbering, the empty-slug fallback, and code-fence decoys.
50
+ const source = [
51
+ '# **Bold** and _italic_ text',
52
+ '## Setup',
53
+ '## Setup',
54
+ '### !!!',
55
+ '```',
56
+ '# not a heading',
57
+ '```',
58
+ '## The `useState` hook',
59
+ ].join('\n\n');
60
+ const {container} = render(<Markdown>{source}</Markdown>);
61
+ const outline = parseOutlineFromMarkdown(source);
62
+ expect(outline.length).toBe(5);
63
+ for (const item of outline) {
64
+ const target = container.querySelector(`[id="${item.id}"]`);
65
+ expect(target, `no rendered heading with id "${item.id}"`).not.toBe(
66
+ null,
67
+ );
68
+ expect(target!.tagName).toMatch(/^H[1-6]$/);
69
+ expect(target!.textContent?.trim()).toBe(item.label);
70
+ }
71
+ });
72
+
73
+ it('passes the generated id to a custom heading component', () => {
74
+ const received: (string | undefined)[] = [];
75
+ render(
76
+ <Markdown
77
+ components={{
78
+ heading: ({children, id}: {children: ReactNode; id?: string}) => {
79
+ received.push(id);
80
+ return <h2 id={id}>{children}</h2>;
81
+ },
82
+ }}>
83
+ {'# Overview\n\n# Overview'}
84
+ </Markdown>,
85
+ );
86
+ expect(received).toEqual(['overview', 'overview-1']);
87
+ });
88
+
89
+ it('does not assign ids to headings nested inside blockquotes', () => {
90
+ // parseOutlineFromMarkdown only lists top-level headings. If nested
91
+ // headings consumed slugs too, duplicate numbering would drift and
92
+ // outline links would land on the wrong heading.
93
+ const source = '> # Quoted\n\n# Quoted';
94
+ const {container} = render(<Markdown>{source}</Markdown>);
95
+ const outline = parseOutlineFromMarkdown(source);
96
+ expect(outline.map(i => i.id)).toEqual(['quoted']);
97
+ const [nested, topLevel] = screen.getAllByText('Quoted');
98
+ expect(container.querySelector('blockquote')).toContainElement(nested);
99
+ expect(nested).not.toHaveAttribute('id');
100
+ expect(topLevel).toHaveAttribute('id', 'quoted');
101
+ });
102
+ });
103
+
26
104
  it('renders paragraphs as block <div> (never <p>) for composition safety', () => {
27
105
  render(<Markdown>{'Hello world'}</Markdown>);
28
106
  // Markdown paragraphs render as <div> so block-level inline content
@@ -53,6 +53,9 @@ import {
53
53
  parseMarkdownIncremental,
54
54
  createIncrementalState,
55
55
  trimStreamingArtifacts,
56
+ inlineText,
57
+ slugify,
58
+ uniqueSlug,
56
59
  } from './parser';
57
60
  import type {BlockNode, InlineNode, IncrementalState} from './parser';
58
61
  import {themeProps} from '../utils/themeProps';
@@ -108,6 +111,14 @@ export interface MarkdownComponents {
108
111
  heading?: React.ComponentType<{
109
112
  level: 1 | 2 | 3 | 4 | 5 | 6;
110
113
  children: React.ReactNode;
114
+ /**
115
+ * Generated slug for this heading, matching the ids produced by
116
+ * useOutlineFromMarkdown / parseOutlineFromMarkdown. Render it as the
117
+ * element's `id` to keep Outline hash navigation working. Undefined for
118
+ * headings nested inside blockquotes or list items (the outline only
119
+ * lists top-level headings).
120
+ */
121
+ id?: string;
111
122
  }>;
112
123
  paragraph?: React.ComponentType<{children: React.ReactNode}>;
113
124
  image?: React.ComponentType<{src: string; alt: string}>;
@@ -1045,6 +1056,7 @@ function renderBlock(
1045
1056
  inlinePlugins: MarkdownInlinePlugin[] | undefined,
1046
1057
  components: Partial<MarkdownComponents> | undefined,
1047
1058
  t: TranslatorFn,
1059
+ headingIdMap?: ReadonlyMap<BlockNode, string>,
1048
1060
  ): SyncReactNode {
1049
1061
  const blockAlignMargin = BLOCK_ALIGN_MARGIN[contentAlign];
1050
1062
  const blockAlignStyle =
@@ -1071,10 +1083,15 @@ function renderBlock(
1071
1083
  components,
1072
1084
  ),
1073
1085
  );
1086
+ // Only top-level headings get an id: the map is built from the same
1087
+ // traversal parseOutlineFromMarkdown uses (which skips headings nested
1088
+ // in blockquotes / list items), so rendered ids and outline ids stay
1089
+ // identical — including duplicate-slug numbering.
1090
+ const headingId = headingIdMap?.get(node);
1074
1091
  const HeadingComp = components?.heading;
1075
1092
  if (HeadingComp) {
1076
1093
  return (
1077
- <HeadingComp key={index} level={level}>
1094
+ <HeadingComp key={index} level={level} id={headingId}>
1078
1095
  {headingChildren}
1079
1096
  </HeadingComp>
1080
1097
  );
@@ -1083,6 +1100,7 @@ function renderBlock(
1083
1100
  return (
1084
1101
  <Tag
1085
1102
  key={index}
1103
+ id={headingId}
1086
1104
  {...mergeProps(
1087
1105
  themeProps('markdown-heading', {density, level}),
1088
1106
  stylex.props(
@@ -1652,6 +1670,27 @@ export function Markdown({
1652
1670
  return parseMarkdown(children, parseOptions);
1653
1671
  }, [display, smoothedText, children, isStreaming, parseOptions]);
1654
1672
 
1673
+ // Assign each top-level heading the slug that parseOutlineFromMarkdown
1674
+ // would derive for it, so Outline hash links built from the same source
1675
+ // always find a matching DOM id. Mirrors that function's traversal exactly:
1676
+ // top-level blocks only, one shared duplicate-numbering sequence.
1677
+ // NOTE: must stay above the `display === 'inline'` early return below —
1678
+ // hooks cannot be conditional.
1679
+ const headingIdMap = useMemo(() => {
1680
+ if (display === 'inline' || blocks.length === 0) {
1681
+ return undefined;
1682
+ }
1683
+ const map = new Map<BlockNode, string>();
1684
+ const counts = new Map<string, number>();
1685
+ for (const block of blocks) {
1686
+ if (block.type === 'heading') {
1687
+ const label = inlineText(block.children).trim();
1688
+ map.set(block, uniqueSlug(slugify(label), counts));
1689
+ }
1690
+ }
1691
+ return map;
1692
+ }, [display, blocks]);
1693
+
1655
1694
  const inlineNodes = useMemo(() => {
1656
1695
  if (display !== 'inline') {
1657
1696
  return [];
@@ -1765,6 +1804,7 @@ export function Markdown({
1765
1804
  inlinePlugins,
1766
1805
  components,
1767
1806
  t,
1807
+ headingIdMap,
1768
1808
  ),
1769
1809
  )}
1770
1810
  </div>
@@ -3,8 +3,10 @@
3
3
  /**
4
4
  * @file parser.ts
5
5
  * @input Markdown string
6
- * @output Array of MarkdownNode AST nodes
7
- * @position Core parser; consumed by Markdown.tsx
6
+ * @output Array of MarkdownNode AST nodes; heading slug helpers
7
+ * (inlineText, slugify, uniqueSlug) shared by Markdown rendering and
8
+ * Outline's parseOutlineFromMarkdown
9
+ * @position Core parser; consumed by Markdown.tsx and Outline
8
10
  */
9
11
 
10
12
  // ---------------------------------------------------------------------------
@@ -1815,3 +1817,59 @@ export function parseMarkdownIncremental(
1815
1817
 
1816
1818
  return mergeSettledBlocks(settledBlocks, unsettledBlocks);
1817
1819
  }
1820
+
1821
+ // ---------------------------------------------------------------------------
1822
+ // Heading slugs
1823
+ // ---------------------------------------------------------------------------
1824
+ // Single source of truth for the heading id contract: Markdown renders these
1825
+ // slugs as `id` attributes on h1–h6, and Outline's parseOutlineFromMarkdown
1826
+ // derives its item ids from the same functions, so outline hash links always
1827
+ // resolve to a rendered heading by construction.
1828
+
1829
+ /** Flatten inline nodes into their plain text content. */
1830
+ export function inlineText(nodes: InlineNode[]): string {
1831
+ return nodes
1832
+ .map(node => {
1833
+ switch (node.type) {
1834
+ case 'text':
1835
+ case 'code':
1836
+ return node.content;
1837
+ case 'bold':
1838
+ case 'italic':
1839
+ case 'strikethrough':
1840
+ case 'link':
1841
+ return inlineText(node.children);
1842
+ case 'image':
1843
+ return node.alt;
1844
+ case 'citation':
1845
+ case 'break':
1846
+ return '';
1847
+ }
1848
+ })
1849
+ .join('');
1850
+ }
1851
+
1852
+ /** Turn heading text into a URL-safe slug (lowercase, hyphen-separated). */
1853
+ export function slugify(value: string): string {
1854
+ return value
1855
+ .trim()
1856
+ .toLowerCase()
1857
+ .replace(/['"]/g, '')
1858
+ .replace(/[^a-z0-9]+/g, '-')
1859
+ .replace(/^-+|-+$/g, '');
1860
+ }
1861
+
1862
+ /**
1863
+ * Disambiguate repeated slugs with a numeric suffix (`setup`, `setup-1`, …).
1864
+ * Empty slugs fall back to `section`. The caller owns the counts map so one
1865
+ * document shares a single numbering sequence.
1866
+ */
1867
+ export function uniqueSlug(
1868
+ baseSlug: string,
1869
+ counts: Map<string, number>,
1870
+ ): string {
1871
+ const fallbackSlug = baseSlug || 'section';
1872
+ const count = counts.get(fallbackSlug) ?? 0;
1873
+ counts.set(fallbackSlug, count + 1);
1874
+ return count === 0 ? fallbackSlug : `${fallbackSlug}-${count}`;
1875
+ }
@@ -23,6 +23,7 @@
23
23
  *
24
24
  * SYNC: When modified, update these files to stay in sync:
25
25
  * - /packages/core/src/MobileNav/index.ts (exports if types change)
26
+ * - /packages/core/src/hooks/scrollbarGutter.ts (shared scroll-lock gutter)
26
27
  * - /packages/cli/assets/templates/blocks/components/MobileNav/ (showcase blocks)
27
28
  */
28
29
 
@@ -47,7 +48,12 @@ import {Button} from '../Button';
47
48
  import {Icon} from '../Icon';
48
49
  import {Heading} from '../Heading/Heading';
49
50
  import {useAppShellMobile} from '../AppShell/AppShellMobileContext';
51
+ import {
52
+ holdScrollbarGutter,
53
+ type ScrollbarGutterHold,
54
+ } from '../hooks/scrollbarGutter';
50
55
  import {mergeProps, mergeRefs, composeEventHandlers} from '../utils';
56
+ import {overlayPaddingReset} from '../Layout/padding.stylex';
51
57
  import type {BaseProps} from '../BaseProps';
52
58
  import {themeProps} from '../utils/themeProps';
53
59
  import {useTranslator} from '../i18n';
@@ -70,7 +76,16 @@ const styles = stylex.create({
70
76
  width: '100vw',
71
77
  height: '100dvh',
72
78
  backgroundColor: 'transparent',
73
- overflow: 'hidden',
79
+ // `clip`, not `hidden`. Both clip the off-screen drawer, but `hidden` makes
80
+ // the dialog a SCROLL CONTAINER, and a scroll container in the top layer
81
+ // whose subtree holds another scroller (the drawer's content area) does not
82
+ // paint a @starting-style entry transition for its descendants in Chromium:
83
+ // the transition ticks in the CSSOM while every painted frame shows the
84
+ // end value, so the drawer appears fully open. `clip` clips without
85
+ // creating a scroll container and the slide-in paints normally. The dialog
86
+ // never scrolls anyway — its child is absolutely positioned — so nothing
87
+ // depended on it being a scroll container.
88
+ overflow: 'clip',
74
89
  overscrollBehavior: 'contain',
75
90
  // Prevent touch gestures (pull-to-refresh, background scroll) passing through
76
91
  touchAction: 'none',
@@ -112,7 +127,14 @@ const styles = stylex.create({
112
127
  },
113
128
  backdropOpen: {
114
129
  '::backdrop': {
115
- opacity: 1,
130
+ // The ::backdrop only exists once showModal() has put the dialog in the
131
+ // top layer, so its first rendered frame already has the open opacity.
132
+ // Without a starting style there is no earlier value to transition from
133
+ // and the scrim snaps in — @starting-style supplies that value.
134
+ opacity: {
135
+ default: 1,
136
+ '@starting-style': 0,
137
+ },
116
138
  },
117
139
  },
118
140
  drawer: {
@@ -143,7 +165,17 @@ const styles = stylex.create({
143
165
  },
144
166
  },
145
167
  drawerStartOpen: {
146
- transform: 'translateX(0)',
168
+ // The whole dialog is `display: none` while closed, so the drawer is not
169
+ // rendered and the open transform is the only value it has ever had — a
170
+ // transition needs a previous value to run from. @starting-style gives the
171
+ // first rendered frame the off-screen transform, so the slide-in plays.
172
+ transform: {
173
+ default: 'translateX(0)',
174
+ '@starting-style': {
175
+ default: 'translateX(-100%)',
176
+ ':is([dir="rtl"] *)': 'translateX(100%)',
177
+ },
178
+ },
147
179
  },
148
180
  drawerEnd: {
149
181
  insetInlineEnd: 0,
@@ -156,7 +188,14 @@ const styles = stylex.create({
156
188
  },
157
189
  },
158
190
  drawerEndOpen: {
159
- transform: 'translateX(0)',
191
+ // See drawerStartOpen — same starting style, mirrored edge.
192
+ transform: {
193
+ default: 'translateX(0)',
194
+ '@starting-style': {
195
+ default: 'translateX(100%)',
196
+ ':is([dir="rtl"] *)': 'translateX(-100%)',
197
+ },
198
+ },
160
199
  },
161
200
  header: {
162
201
  display: 'flex',
@@ -396,6 +435,15 @@ export function MobileNav({
396
435
 
397
436
  const dialogRef = useRef<HTMLDialogElement>(null);
398
437
  const closeTimeoutRef = useRef<ReturnType<typeof setTimeout>>(null);
438
+ const gutterRef = useRef<ScrollbarGutterHold | null>(null);
439
+
440
+ // Gives back the gutter held open in place of the hidden scrollbar.
441
+ const releaseGutter = useCallback(() => {
442
+ if (gutterRef.current) {
443
+ gutterRef.current.release();
444
+ gutterRef.current = null;
445
+ }
446
+ }, []);
399
447
  // Resolved side — computed from trigger position when side='auto'
400
448
  const [resolvedSide, setResolvedSide] = useState<'start' | 'end'>(
401
449
  side === 'auto' ? 'end' : side,
@@ -438,6 +486,10 @@ export function MobileNav({
438
486
  }
439
487
 
440
488
  if (isOpen) {
489
+ // Taken first: every mutation below is one that can hide the scrollbar,
490
+ // and the gutter has to be measured while it is still there.
491
+ gutterRef.current ??= holdScrollbarGutter(document.documentElement);
492
+
441
493
  if (!dialog.open) {
442
494
  dialog.showModal();
443
495
  }
@@ -445,8 +497,10 @@ export function MobileNav({
445
497
  // overflow: clip avoids creating a scroll container (unlike hidden),
446
498
  // so there's no scroll bounce and no need to save/restore scroll position.
447
499
  document.documentElement.style.overflow = 'clip';
500
+ gutterRef.current.settle();
448
501
  } else if (dialog.open) {
449
502
  document.documentElement.style.overflow = '';
503
+ releaseGutter();
450
504
 
451
505
  closeTimeoutRef.current = setTimeout(() => {
452
506
  dialog.close();
@@ -459,8 +513,9 @@ export function MobileNav({
459
513
  closeTimeoutRef.current = null;
460
514
  }
461
515
  document.documentElement.style.overflow = '';
516
+ releaseGutter();
462
517
  };
463
- }, [isOpen]);
518
+ }, [isOpen, releaseGutter]);
464
519
 
465
520
  // Close the native dialog on unmount if it's still open. Inside AppShell the
466
521
  // drawer is mounted in an <Activity> that switches to mode="hidden" when the
@@ -512,6 +567,7 @@ export function MobileNav({
512
567
  themeProps('mobile-nav', {side: resolvedSide}),
513
568
  stylex.props(
514
569
  styles.dialog,
570
+ overlayPaddingReset.reset,
515
571
  isOpen && styles.open,
516
572
  styles.backdrop,
517
573
  isOpen && styles.backdropOpen,
@@ -0,0 +1,212 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file MobileNavEntryAnimation.test.tsx
5
+ * @input Uses vitest, @testing-library/react, the TypeScript compiler API
6
+ * @output Regression tests keeping the drawer's slide-in and scrim fade-in
7
+ * @position Testing; guards the open path of MobileNav.tsx
8
+ *
9
+ * The drawer used to open with no animation at all while closing smoothly.
10
+ *
11
+ * The dialog is `display: none` until `isOpen`, so nothing inside it is
12
+ * rendered while closed. React commits the open `display` and the open
13
+ * `transform` in the same pass, which means the first frame the drawer is ever
14
+ * rendered in already holds the open transform: a transition has no earlier
15
+ * value to run from and the drawer simply appears. Closing looked fine because
16
+ * both values exist by then — `display` stays in the transition with
17
+ * `allow-discrete`, so the element is still rendered as the transform animates
18
+ * out.
19
+ *
20
+ * `@starting-style` supplies that before-change style — the off-screen
21
+ * transform for the drawer, transparent for the `::backdrop`.
22
+ *
23
+ * That alone is NOT enough, and the second half is the part that is easy to
24
+ * undo by accident. The dialog used `overflow: hidden`, which makes it a
25
+ * SCROLL CONTAINER. A scroll container in the top layer whose subtree holds
26
+ * another scroller — here the drawer's own content area — does not paint a
27
+ * `@starting-style` entry transition for its descendants in Chromium: the
28
+ * transition ticks in the CSSOM (`getComputedStyle` interpolates perfectly)
29
+ * while every painted frame shows the end value. Measuring the CSSOM says
30
+ * "animating"; the screen says "snapped". `overflow: clip` clips the
31
+ * off-screen drawer exactly as `hidden` did without creating a scroll
32
+ * container, and the slide-in paints.
33
+ *
34
+ * Reproduced minimally: dialog `overflow: hidden` + a scrolling child inside
35
+ * the drawer snaps; either one alone animates.
36
+ *
37
+ * Note on scope: jsdom has no top layer, no transitions, no `@starting-style`
38
+ * evaluation and no compositor, so none of this is observable here. These
39
+ * tests pin the three declarations the behaviour rests on. Verified in
40
+ * Chromium by screencasting painted frames (not computed style) at both edges,
41
+ * LTR and RTL: open and close each paint ~60-100 distinct intermediate
42
+ * positions, and reduced motion collapses to a 10ms transition.
43
+ */
44
+
45
+ import {describe, it, expect, beforeAll} from 'vitest';
46
+ import {render, screen} from '@testing-library/react';
47
+ import {readFileSync} from 'node:fs';
48
+ import {join} from 'node:path';
49
+ import ts from 'typescript';
50
+ import {MobileNav} from './MobileNav';
51
+
52
+ // jsdom doesn't implement showModal/close on <dialog>, so we mock them
53
+ beforeAll(() => {
54
+ HTMLDialogElement.prototype.showModal =
55
+ HTMLDialogElement.prototype.showModal ||
56
+ function (this: HTMLDialogElement) {
57
+ this.setAttribute('open', '');
58
+ };
59
+ HTMLDialogElement.prototype.close =
60
+ HTMLDialogElement.prototype.close ||
61
+ function (this: HTMLDialogElement) {
62
+ this.removeAttribute('open');
63
+ };
64
+ });
65
+
66
+ /** Every CSS rule StyleX has injected into the document, as text. */
67
+ function injectedRules(): string[] {
68
+ const rules: string[] = [];
69
+ for (const sheet of Array.from(document.styleSheets)) {
70
+ let cssRules: CSSRuleList;
71
+ try {
72
+ cssRules = sheet.cssRules;
73
+ } catch {
74
+ continue;
75
+ }
76
+ for (const rule of Array.from(cssRules)) {
77
+ rules.push(rule.cssText);
78
+ }
79
+ }
80
+ return rules;
81
+ }
82
+
83
+ describe('MobileNav scrim fades in', () => {
84
+ it('gives the ::backdrop a transparent starting style', () => {
85
+ render(
86
+ <MobileNav isOpen onOpenChange={() => {}} data-testid="mobile-nav">
87
+ <span>Nav content</span>
88
+ </MobileNav>,
89
+ );
90
+
91
+ const classes = Array.from(screen.getByTestId('mobile-nav').classList);
92
+ // Without this the scrim is opaque in the frame the dialog enters the top
93
+ // layer, so it has nothing to fade from and snaps in behind the drawer.
94
+ const startingStyleForThisDialog = injectedRules().filter(
95
+ rule =>
96
+ rule.startsWith('@starting-style') &&
97
+ rule.includes('::backdrop') &&
98
+ classes.some(cls => rule.includes(`.${cls}`)),
99
+ );
100
+
101
+ expect(startingStyleForThisDialog).toHaveLength(1);
102
+ expect(startingStyleForThisDialog[0]).toMatch(/opacity:\s*0/);
103
+ });
104
+ });
105
+
106
+ // =============================================================================
107
+ // Drawer slide-in — asserted on the style definition
108
+ // =============================================================================
109
+
110
+ const SOURCE = readFileSync(join(__dirname, 'MobileNav.tsx'), 'utf8');
111
+
112
+ /**
113
+ * The object literal a `styles.<name>` key is defined with, e.g. the value of
114
+ * `drawerStartOpen` inside the file's `stylex.create({...})` call.
115
+ */
116
+ function styleDefinition(name: string): ts.ObjectLiteralExpression {
117
+ const file = ts.createSourceFile(
118
+ 'MobileNav.tsx',
119
+ SOURCE,
120
+ ts.ScriptTarget.Latest,
121
+ true,
122
+ ts.ScriptKind.TSX,
123
+ );
124
+
125
+ let found: ts.ObjectLiteralExpression | undefined;
126
+ const visit = (node: ts.Node) => {
127
+ if (
128
+ ts.isPropertyAssignment(node) &&
129
+ ts.isIdentifier(node.name) &&
130
+ node.name.text === name &&
131
+ ts.isObjectLiteralExpression(node.initializer)
132
+ ) {
133
+ found = node.initializer;
134
+ }
135
+ ts.forEachChild(node, visit);
136
+ };
137
+ visit(file);
138
+
139
+ if (!found) {
140
+ throw new Error(`No style named "${name}" in MobileNav.tsx`);
141
+ }
142
+ return found;
143
+ }
144
+
145
+ /** The value of one property of a style definition, as source text. */
146
+ function property(style: ts.ObjectLiteralExpression, key: string): string {
147
+ const prop = style.properties.find(
148
+ p =>
149
+ ts.isPropertyAssignment(p) &&
150
+ (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) &&
151
+ p.name.text === key,
152
+ );
153
+
154
+ if (!prop) {
155
+ throw new Error(`No "${key}" in ${style.getText().slice(0, 40)}…`);
156
+ }
157
+ return (prop as ts.PropertyAssignment).initializer.getText();
158
+ }
159
+
160
+ describe('MobileNav dialog does not become a scroll container', () => {
161
+ it('clips the off-screen drawer with `clip`, not `hidden`', () => {
162
+ // This is the half of the fix with no visible declaration of its own
163
+ // purpose: `hidden` looks like a pure clipping choice and clips exactly as
164
+ // well, so it is an easy "harmless tidy-up" to make. It is not harmless —
165
+ // it makes the dialog a scroll container, and the entry animation then
166
+ // ticks in the CSSOM without ever painting. See the file header.
167
+ expect(property(styleDefinition('dialog'), 'overflow')).toBe("'clip'");
168
+ });
169
+ });
170
+
171
+ describe.each([
172
+ {
173
+ name: 'drawerStartOpen',
174
+ offscreen: 'translateX(-100%)',
175
+ offscreenRtl: 'translateX(100%)',
176
+ },
177
+ {
178
+ name: 'drawerEndOpen',
179
+ offscreen: 'translateX(100%)',
180
+ offscreenRtl: 'translateX(-100%)',
181
+ },
182
+ ])('MobileNav drawer slides in ($name)', ({name, offscreen, offscreenRtl}) => {
183
+ it('opens to the on-screen transform', () => {
184
+ expect(property(styleDefinition(name), 'transform')).toContain(
185
+ 'translateX(0)',
186
+ );
187
+ });
188
+
189
+ it('starts off-screen so the open transform has something to run from', () => {
190
+ const transform = property(styleDefinition(name), 'transform');
191
+
192
+ // The whole point: the first rendered frame needs the closed transform.
193
+ // Drop this and the drawer is simply there, fully open, on frame one —
194
+ // while the close still animates, which is what made the bug look like a
195
+ // missing entry animation rather than a missing starting style.
196
+ expect(transform).toContain('@starting-style');
197
+ expect(transform).toContain(offscreen);
198
+ // Mirrored, like the closed styles it has to match: sliding in from the
199
+ // wrong edge in RTL is as broken as not sliding at all.
200
+ expect(transform).toContain(offscreenRtl);
201
+ });
202
+
203
+ it('starts from the same edge the closed style parks it at', () => {
204
+ const closed = property(
205
+ styleDefinition(name.replace('Open', '')),
206
+ 'transform',
207
+ );
208
+
209
+ expect(closed).toContain(offscreen);
210
+ expect(closed).toContain(offscreenRtl);
211
+ });
212
+ });