@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.
- package/CHANGELOG.md +40 -0
- package/dist/BottomSheet/BottomSheet.d.ts +11 -2
- package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheet.js +4 -0
- package/dist/BottomSheet/BottomSheetPanel.d.ts +5 -2
- package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheetPanel.js +65 -15
- package/dist/BottomSheet/index.d.ts +1 -1
- package/dist/BottomSheet/index.d.ts.map +1 -1
- package/dist/BottomSheet/snapOffsets.d.ts +57 -20
- package/dist/BottomSheet/snapOffsets.d.ts.map +1 -1
- package/dist/BottomSheet/snapOffsets.js +106 -30
- package/dist/BottomSheet/useSheetGestures.d.ts +13 -8
- package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -1
- package/dist/BottomSheet/useSheetGestures.js +160 -64
- package/dist/Calendar/Calendar.d.ts +17 -0
- package/dist/Calendar/Calendar.d.ts.map +1 -1
- package/dist/Calendar/Calendar.js +34 -3
- package/dist/Calendar/getStandaloneShortWeekdayNames.d.ts +14 -0
- package/dist/Calendar/getStandaloneShortWeekdayNames.d.ts.map +1 -0
- package/dist/Calendar/getStandaloneShortWeekdayNames.js +26 -0
- package/dist/Calendar/hooks/useCalendarConstraints.d.ts +22 -1
- package/dist/Calendar/hooks/useCalendarConstraints.d.ts.map +1 -1
- package/dist/Calendar/hooks/useCalendarConstraints.js +26 -4
- package/dist/Calendar/hooks/useCalendarDays.d.ts.map +1 -1
- package/dist/Calendar/hooks/useCalendarDays.js +14 -10
- package/dist/Calendar/standaloneShortWeekdayNames.generated.d.ts +47 -0
- package/dist/Calendar/standaloneShortWeekdayNames.generated.d.ts.map +1 -0
- package/dist/Calendar/standaloneShortWeekdayNames.generated.js +40 -0
- package/dist/Center/Center.d.ts +23 -1
- package/dist/Center/Center.d.ts.map +1 -1
- package/dist/Center/Center.js +12 -6
- package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
- package/dist/CheckboxInput/CheckboxInput.js +9 -0
- package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
- package/dist/ComplexSelector/ComplexSelector.js +6 -1
- package/dist/DateInput/DateInput.d.ts.map +1 -1
- package/dist/DateInput/DateInput.js +6 -1
- package/dist/DateRangeInput/DateRangeInput.d.ts +23 -1
- package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
- package/dist/DateRangeInput/DateRangeInput.js +38 -3
- package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
- package/dist/DateTimeInput/DateTimeInput.js +7 -2
- package/dist/Dialog/Dialog.d.ts.map +1 -1
- package/dist/Dialog/Dialog.js +3 -3
- package/dist/Field/FieldLabel.d.ts +2 -1
- package/dist/Field/FieldLabel.d.ts.map +1 -1
- package/dist/Field/FieldLabel.js +21 -3
- package/dist/Field/InputClearButton.d.ts +2 -1
- package/dist/Field/InputClearButton.d.ts.map +1 -1
- package/dist/Field/InputClearButton.js +6 -1
- package/dist/FormLayout/FormLayout.d.ts +24 -2
- package/dist/FormLayout/FormLayout.d.ts.map +1 -1
- package/dist/FormLayout/FormLayout.js +7 -2
- package/dist/FormLayout/FormLayoutContext.d.ts +15 -2
- package/dist/FormLayout/FormLayoutContext.d.ts.map +1 -1
- package/dist/FormLayout/FormLayoutContext.js +19 -4
- package/dist/FormLayout/index.d.ts +1 -1
- package/dist/FormLayout/index.d.ts.map +1 -1
- package/dist/Item/Item.d.ts +9 -1
- package/dist/Item/Item.d.ts.map +1 -1
- package/dist/Item/Item.js +45 -7
- package/dist/Layer/useLayer.d.ts.map +1 -1
- package/dist/Layer/useLayer.js +3 -2
- package/dist/Layout/container.stylex.d.ts +4 -2
- package/dist/Layout/container.stylex.d.ts.map +1 -1
- package/dist/Layout/container.stylex.js +19 -11
- package/dist/Layout/index.d.ts +1 -0
- package/dist/Layout/index.d.ts.map +1 -1
- package/dist/Layout/index.js +4 -0
- package/dist/Layout/padding.stylex.d.ts +315 -12
- package/dist/Layout/padding.stylex.d.ts.map +1 -1
- package/dist/Layout/padding.stylex.js +389 -12
- package/dist/Lightbox/Lightbox.d.ts.map +1 -1
- package/dist/Lightbox/Lightbox.js +2 -1
- package/dist/Markdown/Markdown.d.ts +8 -0
- package/dist/Markdown/Markdown.d.ts.map +1 -1
- package/dist/Markdown/Markdown.js +31 -3
- package/dist/Markdown/parser.d.ts +14 -2
- package/dist/Markdown/parser.d.ts.map +1 -1
- package/dist/Markdown/parser.js +50 -2
- package/dist/MobileNav/MobileNav.d.ts +1 -0
- package/dist/MobileNav/MobileNav.d.ts.map +1 -1
- package/dist/MobileNav/MobileNav.js +24 -6
- package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
- package/dist/MultiSelector/MultiSelector.js +6 -1
- package/dist/NumberInput/NumberInput.d.ts.map +1 -1
- package/dist/NumberInput/NumberInput.js +36 -3
- package/dist/Outline/parseOutlineFromMarkdown.d.ts +2 -0
- package/dist/Outline/parseOutlineFromMarkdown.d.ts.map +1 -1
- package/dist/Outline/parseOutlineFromMarkdown.js +5 -31
- package/dist/RadioList/RadioList.d.ts.map +1 -1
- package/dist/RadioList/RadioList.js +12 -1
- package/dist/Resizable/ResizeHandle.d.ts.map +1 -1
- package/dist/Resizable/ResizeHandle.js +3 -3
- package/dist/Section/Section.d.ts +36 -1
- package/dist/Section/Section.d.ts.map +1 -1
- package/dist/Section/Section.js +12 -2
- package/dist/Selector/Selector.d.ts +26 -1
- package/dist/Selector/Selector.d.ts.map +1 -1
- package/dist/Selector/Selector.js +93 -15
- package/dist/Selector/SelectorOption.d.ts +14 -1
- package/dist/Selector/SelectorOption.d.ts.map +1 -1
- package/dist/Selector/SelectorOption.js +6 -0
- package/dist/Selector/SelectorRowLayoutContext.d.ts +8 -0
- package/dist/Selector/SelectorRowLayoutContext.d.ts.map +1 -0
- package/dist/Selector/SelectorRowLayoutContext.js +27 -0
- package/dist/Selector/types.d.ts +1 -0
- package/dist/Selector/types.d.ts.map +1 -1
- package/dist/Slider/Slider.d.ts.map +1 -1
- package/dist/Slider/Slider.js +2 -2
- package/dist/Stack/Stack.d.ts +23 -1
- package/dist/Stack/Stack.d.ts.map +1 -1
- package/dist/Stack/Stack.js +12 -6
- package/dist/StatusDot/StatusDot.d.ts +43 -3
- package/dist/StatusDot/StatusDot.d.ts.map +1 -1
- package/dist/StatusDot/StatusDot.js +42 -5
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +9 -0
- package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
- package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +7 -2
- package/dist/TextArea/TextArea.d.ts.map +1 -1
- package/dist/TextArea/TextArea.js +6 -1
- package/dist/TextInput/TextInput.d.ts.map +1 -1
- package/dist/TextInput/TextInput.js +6 -1
- package/dist/TimeInput/TimeInput.d.ts.map +1 -1
- package/dist/TimeInput/TimeInput.js +6 -1
- package/dist/astryx.css +40 -21
- package/dist/hooks/scrollbarGutter.d.ts +28 -0
- package/dist/hooks/scrollbarGutter.d.ts.map +1 -0
- package/dist/hooks/scrollbarGutter.js +103 -0
- package/dist/hooks/useResolvedRequired.d.ts +19 -0
- package/dist/hooks/useResolvedRequired.d.ts.map +1 -0
- package/dist/hooks/useResolvedRequired.js +40 -0
- package/dist/hooks/useScrollLock.d.ts +4 -0
- package/dist/hooks/useScrollLock.d.ts.map +1 -1
- package/dist/hooks/useScrollLock.js +13 -1
- package/dist/theme/derivedVarRegistry.d.ts.map +1 -1
- package/dist/theme/derivedVarRegistry.js +7 -0
- package/dist/theme/generateThemeRules.d.ts.map +1 -1
- package/dist/theme/generateThemeRules.js +37 -4
- package/dist/utils/plainDate.d.ts +6 -0
- package/dist/utils/plainDate.d.ts.map +1 -1
- package/dist/utils/plainDate.js +12 -0
- package/locales/af-ZA.json +958 -0
- package/locales/ar-SA.json +998 -0
- package/locales/ca-ES.json +974 -0
- package/locales/cs-CZ.json +990 -0
- package/locales/da-DK.json +966 -0
- package/locales/de-DE.json +966 -0
- package/locales/el-GR.json +990 -0
- package/locales/en.json +8 -0
- package/locales/es-ES.json +978 -0
- package/locales/fi-FI.json +990 -0
- package/locales/fr-FR.json +928 -0
- package/locales/he-IL.json +990 -0
- package/locales/hu-HU.json +990 -0
- package/locales/it-IT.json +978 -0
- package/locales/ja-JP.json +998 -0
- package/locales/ko-KR.json +990 -0
- package/locales/nl-NL.json +954 -0
- package/locales/no-NO.json +978 -0
- package/locales/pl-PL.json +982 -0
- package/locales/pseudo.json +6 -0
- package/locales/pt-BR.json +982 -0
- package/locales/pt-PT.json +982 -0
- package/locales/ro-RO.json +982 -0
- package/locales/ru-RU.json +990 -0
- package/locales/sr-SP.json +990 -0
- package/locales/sv-SE.json +986 -0
- package/locales/tr-TR.json +990 -0
- package/locales/uk-UA.json +990 -0
- package/locales/vi-VN.json +986 -0
- package/locales/zh-CN.json +998 -0
- package/locales/zh-TW.json +998 -0
- package/package.json +3 -3
- package/src/BottomSheet/BottomSheet.doc.mjs +36 -3
- package/src/BottomSheet/BottomSheet.test.tsx +177 -2
- package/src/BottomSheet/BottomSheet.tsx +16 -2
- package/src/BottomSheet/BottomSheetPanel.test.tsx +41 -0
- package/src/BottomSheet/BottomSheetPanel.tsx +95 -15
- package/src/BottomSheet/index.ts +5 -1
- package/src/BottomSheet/snapOffsets.test.ts +114 -47
- package/src/BottomSheet/snapOffsets.ts +119 -33
- package/src/BottomSheet/useSheetGestures.test.ts +113 -15
- package/src/BottomSheet/useSheetGestures.ts +196 -100
- package/src/Calendar/Calendar.doc.mjs +16 -0
- package/src/Calendar/Calendar.test.tsx +137 -0
- package/src/Calendar/Calendar.tsx +65 -2
- package/src/Calendar/getStandaloneShortWeekdayNames.test.ts +54 -0
- package/src/Calendar/getStandaloneShortWeekdayNames.ts +38 -0
- package/src/Calendar/hooks/useCalendarConstraints.ts +54 -3
- package/src/Calendar/hooks/useCalendarDays.ts +13 -10
- package/src/Calendar/standaloneShortWeekdayNames.generated.ts +50 -0
- package/src/Center/Center.doc.mjs +48 -0
- package/src/Center/Center.test.tsx +158 -0
- package/src/Center/Center.tsx +50 -9
- package/src/CheckboxInput/CheckboxInput.tsx +6 -0
- package/src/ComplexSelector/ComplexSelector.tsx +3 -1
- package/src/DateInput/DateInput.tsx +3 -1
- package/src/DateRangeInput/DateRangeInput.doc.mjs +16 -0
- package/src/DateRangeInput/DateRangeInput.test.tsx +81 -1
- package/src/DateRangeInput/DateRangeInput.tsx +72 -1
- package/src/DateTimeInput/DateTimeInput.tsx +4 -2
- package/src/Dialog/Dialog.test.tsx +24 -0
- package/src/Dialog/Dialog.tsx +3 -0
- package/src/Field/Field.doc.mjs +1 -0
- package/src/Field/FieldLabel.tsx +20 -4
- package/src/Field/InputClearButton.test.tsx +25 -0
- package/src/Field/InputClearButton.tsx +4 -1
- package/src/FormLayout/FormLayout.doc.mjs +13 -0
- package/src/FormLayout/FormLayout.test.tsx +181 -4
- package/src/FormLayout/FormLayout.tsx +33 -2
- package/src/FormLayout/FormLayoutContext.ts +22 -7
- package/src/FormLayout/index.ts +1 -1
- package/src/Item/Item.doc.mjs +1 -0
- package/src/Item/Item.test.tsx +50 -0
- package/src/Item/Item.tsx +40 -1
- package/src/Layer/useLayer.tsx +13 -2
- package/src/Layout/Layout.doc.mjs +18 -0
- package/src/Layout/container.stylex.ts +11 -3
- package/src/Layout/index.ts +4 -0
- package/src/Layout/overlayPaddingReset.test.tsx +222 -0
- package/src/Layout/padding.stylex.ts +192 -12
- package/src/Lightbox/Lightbox.tsx +2 -1
- package/src/Markdown/Markdown.doc.mjs +3 -0
- package/src/Markdown/Markdown.test.tsx +78 -0
- package/src/Markdown/Markdown.tsx +41 -1
- package/src/Markdown/parser.ts +60 -2
- package/src/MobileNav/MobileNav.tsx +61 -5
- package/src/MobileNav/MobileNavEntryAnimation.test.tsx +212 -0
- package/src/MobileNav/MobileNavScrollbarGutter.test.tsx +109 -0
- package/src/MultiSelector/MultiSelector.tsx +3 -1
- package/src/NumberInput/NumberInput.doc.mjs +24 -0
- package/src/NumberInput/NumberInput.test.tsx +138 -0
- package/src/NumberInput/NumberInput.tsx +43 -5
- package/src/Outline/Outline.doc.mjs +2 -0
- package/src/Outline/parseOutlineFromMarkdown.ts +10 -40
- package/src/RadioList/RadioList.tsx +9 -1
- package/src/Resizable/ResizeHandle.test.tsx +22 -0
- package/src/Resizable/ResizeHandle.tsx +9 -3
- package/src/Section/Section.doc.mjs +55 -0
- package/src/Section/Section.test.tsx +201 -4
- package/src/Section/Section.tsx +69 -0
- package/src/Selector/Selector.doc.mjs +16 -2
- package/src/Selector/Selector.test.tsx +524 -0
- package/src/Selector/Selector.tsx +161 -17
- package/src/Selector/SelectorOption.doc.mjs +7 -0
- package/src/Selector/SelectorOption.tsx +21 -0
- package/src/Selector/SelectorRowLayoutContext.ts +32 -0
- package/src/Selector/types.ts +4 -4
- package/src/Slider/Slider.tsx +10 -0
- package/src/Stack/Stack.doc.mjs +72 -0
- package/src/Stack/Stack.test.tsx +156 -0
- package/src/Stack/Stack.tsx +50 -8
- package/src/StatusDot/StatusDot.doc.mjs +13 -0
- package/src/StatusDot/StatusDot.test.tsx +115 -8
- package/src/StatusDot/StatusDot.tsx +74 -9
- package/src/Switch/Switch.tsx +6 -0
- package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +20 -2
- package/src/TextArea/TextArea.tsx +3 -1
- package/src/TextInput/TextInput.tsx +3 -1
- package/src/TimeInput/TimeInput.tsx +3 -1
- package/src/hooks/scrollbarGutter.test.ts +167 -0
- package/src/hooks/scrollbarGutter.ts +121 -0
- package/src/hooks/useResolvedRequired.ts +42 -0
- package/src/hooks/useScrollLock.doc.mjs +4 -4
- package/src/hooks/useScrollLock.test.ts +73 -0
- package/src/hooks/useScrollLock.ts +14 -0
- package/src/theme/derivedVarRegistry.test.ts +14 -3
- package/src/theme/derivedVarRegistry.ts +4 -0
- package/src/theme/generateThemeRules.test.ts +79 -0
- package/src/theme/generateThemeRules.ts +49 -4
- package/src/utils/plainDate.test.ts +50 -0
- 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>
|
package/src/Markdown/parser.ts
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* @file parser.ts
|
|
5
5
|
* @input Markdown string
|
|
6
|
-
* @output Array of MarkdownNode AST nodes
|
|
7
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|