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

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 (176) hide show
  1. package/CHANGELOG.md +39 -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 +56 -13
  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/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  31. package/dist/CheckboxInput/CheckboxInput.js +9 -0
  32. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  33. package/dist/ComplexSelector/ComplexSelector.js +6 -1
  34. package/dist/DateInput/DateInput.d.ts.map +1 -1
  35. package/dist/DateInput/DateInput.js +6 -1
  36. package/dist/DateRangeInput/DateRangeInput.d.ts +23 -1
  37. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  38. package/dist/DateRangeInput/DateRangeInput.js +38 -3
  39. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  40. package/dist/DateTimeInput/DateTimeInput.js +7 -2
  41. package/dist/Field/FieldLabel.d.ts +2 -1
  42. package/dist/Field/FieldLabel.d.ts.map +1 -1
  43. package/dist/Field/FieldLabel.js +21 -3
  44. package/dist/Field/InputClearButton.d.ts +2 -1
  45. package/dist/Field/InputClearButton.d.ts.map +1 -1
  46. package/dist/Field/InputClearButton.js +6 -1
  47. package/dist/FormLayout/FormLayout.d.ts +24 -2
  48. package/dist/FormLayout/FormLayout.d.ts.map +1 -1
  49. package/dist/FormLayout/FormLayout.js +7 -2
  50. package/dist/FormLayout/FormLayoutContext.d.ts +15 -2
  51. package/dist/FormLayout/FormLayoutContext.d.ts.map +1 -1
  52. package/dist/FormLayout/FormLayoutContext.js +19 -4
  53. package/dist/FormLayout/index.d.ts +1 -1
  54. package/dist/FormLayout/index.d.ts.map +1 -1
  55. package/dist/Markdown/Markdown.d.ts +8 -0
  56. package/dist/Markdown/Markdown.d.ts.map +1 -1
  57. package/dist/Markdown/Markdown.js +31 -3
  58. package/dist/Markdown/parser.d.ts +14 -2
  59. package/dist/Markdown/parser.d.ts.map +1 -1
  60. package/dist/Markdown/parser.js +50 -2
  61. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  62. package/dist/MultiSelector/MultiSelector.js +6 -1
  63. package/dist/NumberInput/NumberInput.d.ts.map +1 -1
  64. package/dist/NumberInput/NumberInput.js +6 -1
  65. package/dist/Outline/parseOutlineFromMarkdown.d.ts +2 -0
  66. package/dist/Outline/parseOutlineFromMarkdown.d.ts.map +1 -1
  67. package/dist/Outline/parseOutlineFromMarkdown.js +5 -31
  68. package/dist/RadioList/RadioList.d.ts.map +1 -1
  69. package/dist/RadioList/RadioList.js +12 -1
  70. package/dist/Selector/Selector.d.ts.map +1 -1
  71. package/dist/Selector/Selector.js +6 -1
  72. package/dist/StatusDot/StatusDot.d.ts +43 -3
  73. package/dist/StatusDot/StatusDot.d.ts.map +1 -1
  74. package/dist/StatusDot/StatusDot.js +42 -5
  75. package/dist/Switch/Switch.d.ts.map +1 -1
  76. package/dist/Switch/Switch.js +9 -0
  77. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
  78. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +7 -2
  79. package/dist/TextArea/TextArea.d.ts.map +1 -1
  80. package/dist/TextArea/TextArea.js +6 -1
  81. package/dist/TextInput/TextInput.d.ts.map +1 -1
  82. package/dist/TextInput/TextInput.js +6 -1
  83. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  84. package/dist/TimeInput/TimeInput.js +6 -1
  85. package/dist/hooks/useResolvedRequired.d.ts +19 -0
  86. package/dist/hooks/useResolvedRequired.d.ts.map +1 -0
  87. package/dist/hooks/useResolvedRequired.js +40 -0
  88. package/dist/utils/plainDate.d.ts +6 -0
  89. package/dist/utils/plainDate.d.ts.map +1 -1
  90. package/dist/utils/plainDate.js +12 -0
  91. package/locales/af-ZA.json +958 -0
  92. package/locales/ar-SA.json +998 -0
  93. package/locales/ca-ES.json +974 -0
  94. package/locales/cs-CZ.json +990 -0
  95. package/locales/da-DK.json +966 -0
  96. package/locales/de-DE.json +966 -0
  97. package/locales/el-GR.json +990 -0
  98. package/locales/en.json +4 -0
  99. package/locales/es-ES.json +978 -0
  100. package/locales/fi-FI.json +990 -0
  101. package/locales/fr-FR.json +928 -0
  102. package/locales/he-IL.json +990 -0
  103. package/locales/hu-HU.json +990 -0
  104. package/locales/it-IT.json +978 -0
  105. package/locales/ja-JP.json +998 -0
  106. package/locales/ko-KR.json +990 -0
  107. package/locales/nl-NL.json +954 -0
  108. package/locales/no-NO.json +978 -0
  109. package/locales/pl-PL.json +982 -0
  110. package/locales/pseudo.json +3 -0
  111. package/locales/pt-BR.json +982 -0
  112. package/locales/pt-PT.json +982 -0
  113. package/locales/ro-RO.json +982 -0
  114. package/locales/ru-RU.json +990 -0
  115. package/locales/sr-SP.json +990 -0
  116. package/locales/sv-SE.json +986 -0
  117. package/locales/tr-TR.json +990 -0
  118. package/locales/uk-UA.json +990 -0
  119. package/locales/vi-VN.json +986 -0
  120. package/locales/zh-CN.json +998 -0
  121. package/locales/zh-TW.json +998 -0
  122. package/package.json +3 -3
  123. package/src/BottomSheet/BottomSheet.doc.mjs +36 -3
  124. package/src/BottomSheet/BottomSheet.test.tsx +153 -2
  125. package/src/BottomSheet/BottomSheet.tsx +16 -2
  126. package/src/BottomSheet/BottomSheetPanel.tsx +67 -13
  127. package/src/BottomSheet/index.ts +5 -1
  128. package/src/BottomSheet/snapOffsets.test.ts +114 -47
  129. package/src/BottomSheet/snapOffsets.ts +119 -33
  130. package/src/BottomSheet/useSheetGestures.test.ts +113 -15
  131. package/src/BottomSheet/useSheetGestures.ts +196 -100
  132. package/src/Calendar/Calendar.doc.mjs +16 -0
  133. package/src/Calendar/Calendar.test.tsx +137 -0
  134. package/src/Calendar/Calendar.tsx +65 -2
  135. package/src/Calendar/getStandaloneShortWeekdayNames.test.ts +54 -0
  136. package/src/Calendar/getStandaloneShortWeekdayNames.ts +38 -0
  137. package/src/Calendar/hooks/useCalendarConstraints.ts +54 -3
  138. package/src/Calendar/hooks/useCalendarDays.ts +13 -10
  139. package/src/Calendar/standaloneShortWeekdayNames.generated.ts +50 -0
  140. package/src/CheckboxInput/CheckboxInput.tsx +6 -0
  141. package/src/ComplexSelector/ComplexSelector.tsx +3 -1
  142. package/src/DateInput/DateInput.tsx +3 -1
  143. package/src/DateRangeInput/DateRangeInput.doc.mjs +16 -0
  144. package/src/DateRangeInput/DateRangeInput.test.tsx +81 -1
  145. package/src/DateRangeInput/DateRangeInput.tsx +72 -1
  146. package/src/DateTimeInput/DateTimeInput.tsx +4 -2
  147. package/src/Field/Field.doc.mjs +1 -0
  148. package/src/Field/FieldLabel.tsx +20 -4
  149. package/src/Field/InputClearButton.test.tsx +25 -0
  150. package/src/Field/InputClearButton.tsx +4 -1
  151. package/src/FormLayout/FormLayout.doc.mjs +13 -0
  152. package/src/FormLayout/FormLayout.test.tsx +181 -4
  153. package/src/FormLayout/FormLayout.tsx +33 -2
  154. package/src/FormLayout/FormLayoutContext.ts +22 -7
  155. package/src/FormLayout/index.ts +1 -1
  156. package/src/Markdown/Markdown.doc.mjs +3 -0
  157. package/src/Markdown/Markdown.test.tsx +78 -0
  158. package/src/Markdown/Markdown.tsx +41 -1
  159. package/src/Markdown/parser.ts +60 -2
  160. package/src/MultiSelector/MultiSelector.tsx +3 -1
  161. package/src/NumberInput/NumberInput.tsx +3 -1
  162. package/src/Outline/Outline.doc.mjs +2 -0
  163. package/src/Outline/parseOutlineFromMarkdown.ts +10 -40
  164. package/src/RadioList/RadioList.tsx +9 -1
  165. package/src/Selector/Selector.tsx +3 -1
  166. package/src/StatusDot/StatusDot.doc.mjs +13 -0
  167. package/src/StatusDot/StatusDot.test.tsx +115 -8
  168. package/src/StatusDot/StatusDot.tsx +74 -9
  169. package/src/Switch/Switch.tsx +6 -0
  170. package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +20 -2
  171. package/src/TextArea/TextArea.tsx +3 -1
  172. package/src/TextInput/TextInput.tsx +3 -1
  173. package/src/TimeInput/TimeInput.tsx +3 -1
  174. package/src/hooks/useResolvedRequired.ts +42 -0
  175. package/src/utils/plainDate.test.ts +50 -0
  176. package/src/utils/plainDate.ts +12 -0
@@ -10,7 +10,10 @@
10
10
  *
11
11
  * SYNC: When modified, update these files to stay in sync:
12
12
  * - /packages/core/src/FormLayout/FormLayout.test.tsx (tests for new/changed behavior)
13
+ * - /packages/core/src/FormLayout/FormLayoutContext.ts (context value shape)
14
+ * - /packages/core/src/FormLayout/FormLayout.doc.mjs (props table)
13
15
  * - /packages/core/src/FormLayout/index.ts (exports if types change)
16
+ * - /packages/core/src/Field/FieldLabel.tsx (defaultOptionality indicator resolution)
14
17
  * - /apps/storybook/stories/FormLayout.stories.tsx (storybook stories)
15
18
  * - /packages/cli/assets/templates/blocks/components/FormLayout/ (showcase blocks)
16
19
  */
@@ -19,7 +22,11 @@ import {useMemo, type ReactNode} from 'react';
19
22
  import type {BaseProps} from '../BaseProps';
20
23
  import * as stylex from '@stylexjs/stylex';
21
24
  import {spacingVars} from '../theme/tokens.stylex';
22
- import {FormLayoutContext, type FormLayoutDirection} from './FormLayoutContext';
25
+ import {
26
+ FormLayoutContext,
27
+ type FormLayoutDirection,
28
+ type FormOptionality,
29
+ } from './FormLayoutContext';
23
30
  import {mergeProps} from '../utils';
24
31
  import {themeProps} from '../utils/themeProps';
25
32
 
@@ -82,6 +89,26 @@ export interface FormLayoutProps extends BaseProps<HTMLDivElement> {
82
89
  * @default 'vertical'
83
90
  */
84
91
  direction?: FormLayoutDirection;
92
+
93
+ /**
94
+ * Which state the form treats as its default, so only the *exception*
95
+ * carries a visible optional/required indicator. It also resolves each
96
+ * field's `aria-required` so the unmarked majority is still announced
97
+ * correctly — but only `aria-required`, never the native `required`
98
+ * attribute, so a layout default can't switch on browser validation.
99
+ *
100
+ * - `'optional'` — fields read as optional; only a field with `isRequired`
101
+ * shows an indicator (the "required" one).
102
+ * - `'required'` — fields read as required; only a field with `isOptional`
103
+ * shows an indicator (the "optional" one). Fields without `isOptional`
104
+ * expose `aria-required` even though they show no indicator.
105
+ * - unset — today's behavior: `isRequired` and `isOptional` each show their
106
+ * own indicator independently.
107
+ *
108
+ * A field that merely restates the default (e.g. `isOptional` under
109
+ * `'optional'`) shows nothing. An inner `FormLayout` shadows an outer one.
110
+ */
111
+ defaultOptionality?: FormOptionality;
85
112
  }
86
113
 
87
114
  // =============================================================================
@@ -109,13 +136,17 @@ export interface FormLayoutProps extends BaseProps<HTMLDivElement> {
109
136
  export function FormLayout({
110
137
  children,
111
138
  direction = 'vertical',
139
+ defaultOptionality,
112
140
  xstyle,
113
141
  className,
114
142
  style,
115
143
  ref,
116
144
  ...props
117
145
  }: FormLayoutProps) {
118
- const contextValue = useMemo(() => ({direction}), [direction]);
146
+ const contextValue = useMemo(
147
+ () => ({direction, defaultOptionality}),
148
+ [direction, defaultOptionality],
149
+ );
119
150
 
120
151
  return (
121
152
  <FormLayoutContext value={contextValue}>
@@ -5,10 +5,13 @@
5
5
  /**
6
6
  * @file FormLayoutContext.ts
7
7
  * @input Uses React createContext
8
- * @output Exports FormLayoutContext and FormLayoutDirection type
9
- * @position Context for form layout direction detection
8
+ * @output Exports FormLayoutContext, FormLayoutDirection, and FormOptionality types
9
+ * @position Context for form layout direction + default-optionality detection
10
10
  *
11
11
  * SYNC: When modified, update these files to stay in sync:
12
+ * - /packages/core/src/FormLayout/FormLayout.tsx (prop + context value)
13
+ * - /packages/core/src/Field/FieldLabel.tsx (indicator resolution)
14
+ * - /packages/core/src/FormLayout/index.ts (exports if types change)
12
15
  */
13
16
 
14
17
  import {createContext} from 'react';
@@ -22,15 +25,27 @@ import {createContext} from 'react';
22
25
  * of their inputs (settings/admin panel pattern).
23
26
  */
24
27
  export type FormLayoutDirection =
25
- | 'vertical'
26
- | 'horizontal'
27
- | 'horizontal-labels';
28
+ 'vertical' | 'horizontal' | 'horizontal-labels';
28
29
 
29
30
  /**
30
- * Context for detecting which form layout direction a component is rendered in.
31
- * Children can use this to adapt their rendering based on the parent layout.
31
+ * Which state a form treats as its default, so only the *exception* carries a
32
+ * visible optional/required indicator.
33
+ *
34
+ * - `'optional'` — fields are optional unless a field opts into `isRequired`;
35
+ * only required fields show an indicator.
36
+ * - `'required'` — fields are required unless a field opts into `isOptional`;
37
+ * only optional fields show an indicator.
38
+ */
39
+ export type FormOptionality = 'optional' | 'required';
40
+
41
+ /**
42
+ * Context for detecting which form layout a component is rendered in. Children
43
+ * can use this to adapt their rendering based on the parent layout — direction
44
+ * for spatial arrangement, and `defaultOptionality` so a field can suppress the
45
+ * indicator that merely restates the form-wide default.
32
46
  */
33
47
  export const FormLayoutContext = createContext<{
34
48
  direction: FormLayoutDirection;
49
+ defaultOptionality?: FormOptionality;
35
50
  }>({direction: 'vertical'});
36
51
  FormLayoutContext.displayName = 'FormLayoutContext';
@@ -13,5 +13,5 @@
13
13
 
14
14
  export {FormLayout} from './FormLayout';
15
15
  export type {FormLayoutProps} from './FormLayout';
16
- export type {FormLayoutDirection} from './FormLayoutContext';
16
+ export type {FormLayoutDirection, FormOptionality} from './FormLayoutContext';
17
17
  export {FormLayoutContext} from './FormLayoutContext';
@@ -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
+ }
@@ -73,6 +73,7 @@ import {
73
73
  import {useMultiCombobox} from './hooks';
74
74
  import {getInputARIA, isImeKeyEvent, mergeProps} from '../utils';
75
75
  import {useAnnounce} from '../hooks/useAnnounce';
76
+ import {useResolvedRequired} from '../hooks/useResolvedRequired';
76
77
  import type {BaseProps} from '../BaseProps';
77
78
  import type {SizeValue} from '../utils/types';
78
79
  import {useSize} from '../SizeContext/SizeContext';
@@ -706,6 +707,7 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
706
707
  style,
707
708
  }: MultiSelectorProps<T>) {
708
709
  const t = useTranslator();
710
+ const isEffectivelyRequired = useResolvedRequired({isRequired, isOptional});
709
711
  const placeholder =
710
712
  placeholderFromProps ?? t('@astryx.multiSelector.selectPlaceholder');
711
713
  const selectAllLabel =
@@ -1526,7 +1528,7 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
1526
1528
  }
1527
1529
  aria-describedby={ariaDescribedBy}
1528
1530
  aria-labelledby={ariaLabelledBy}
1529
- aria-required={isRequired ? 'true' : undefined}
1531
+ aria-required={isEffectivelyRequired ? 'true' : undefined}
1530
1532
  aria-invalid={status?.type === 'error' ? 'true' : undefined}
1531
1533
  aria-busy={isBusy || undefined}
1532
1534
  // With a disabledMessage the trigger keeps focusability via
@@ -53,6 +53,7 @@ import {getInputARIA} from '../utils';
53
53
  import {useSize} from '../SizeContext/SizeContext';
54
54
  import {useInputContainer} from '../hooks/useInputContainer';
55
55
  import {useInputStatusIcon} from '../hooks/useInputStatusIcon';
56
+ import {useResolvedRequired} from '../hooks/useResolvedRequired';
56
57
  import {useInputGroup} from '../InputGroup/InputGroupContext';
57
58
 
58
59
  const styles = stylex.create({
@@ -559,6 +560,7 @@ export function NumberInput({
559
560
  ...rest
560
561
  }: NumberInputProps) {
561
562
  const t = useTranslator();
563
+ const isEffectivelyRequired = useResolvedRequired({isRequired, isOptional});
562
564
  const size = useSize(sizeProp, 'md');
563
565
  const id = useId();
564
566
  const inputLabelID = useId();
@@ -927,7 +929,7 @@ export function NumberInput({
927
929
  value == null || !formatValue ? undefined : formattedValue
928
930
  }
929
931
  aria-describedby={ariaDescribedBy}
930
- aria-required={isRequired === true ? 'true' : undefined}
932
+ aria-required={isEffectivelyRequired ? 'true' : undefined}
931
933
  aria-invalid={
932
934
  status?.type === 'error' || !isInputValid ? 'true' : undefined
933
935
  }
@@ -166,6 +166,8 @@ import {Outline, useOutlineFromMarkdown} from '@astryxdesign/core/Outline';
166
166
 
167
167
  function MarkdownOutline({markdown}) {
168
168
  // Derives {id, label, level} items from headings in the source.
169
+ // The Markdown component renders the same generated ids on its
170
+ // headings, so outline links scroll without any extra wiring.
169
171
  const items = useOutlineFromMarkdown(markdown);
170
172
  return <Outline items={items} />;
171
173
  }
@@ -2,7 +2,8 @@
2
2
 
3
3
  /**
4
4
  * @file parseOutlineFromMarkdown.ts
5
- * @input Uses Markdown parser internals and OutlineItem type
5
+ * @input Uses Markdown parser internals (parseMarkdown + heading slug
6
+ * helpers) and OutlineItem type
6
7
  * @output Exports parseOutlineFromMarkdown for extracting heading outlines from Markdown
7
8
  * @position Pure utility; consumed by useOutlineFromMarkdown and public exports
8
9
  *
@@ -11,52 +12,21 @@
11
12
  * - /packages/core/src/Outline/index.ts
12
13
  */
13
14
 
14
- import {parseMarkdown, type InlineNode} from '../Markdown/parser';
15
+ import {
16
+ parseMarkdown,
17
+ inlineText,
18
+ slugify,
19
+ uniqueSlug,
20
+ } from '../Markdown/parser';
15
21
  import type {OutlineItem} from './types';
16
22
 
17
- function inlineText(nodes: InlineNode[]): string {
18
- return nodes
19
- .map(node => {
20
- switch (node.type) {
21
- case 'text':
22
- case 'code':
23
- return node.content;
24
- case 'bold':
25
- case 'italic':
26
- case 'strikethrough':
27
- case 'link':
28
- return inlineText(node.children);
29
- case 'image':
30
- return node.alt;
31
- case 'citation':
32
- case 'break':
33
- return '';
34
- }
35
- })
36
- .join('');
37
- }
38
-
39
- function slugify(value: string): string {
40
- return value
41
- .trim()
42
- .toLowerCase()
43
- .replace(/['"]/g, '')
44
- .replace(/[^a-z0-9]+/g, '-')
45
- .replace(/^-+|-+$/g, '');
46
- }
47
-
48
- function uniqueSlug(baseSlug: string, counts: Map<string, number>): string {
49
- const fallbackSlug = baseSlug || 'section';
50
- const count = counts.get(fallbackSlug) ?? 0;
51
- counts.set(fallbackSlug, count + 1);
52
- return count === 0 ? fallbackSlug : `${fallbackSlug}-${count}`;
53
- }
54
-
55
23
  /**
56
24
  * Extract heading items from a Markdown string.
57
25
  *
58
26
  * Uses Markdown's parser so fenced code blocks, tables, lists, and inline
59
27
  * formatting are interpreted consistently with rendered Markdown output.
28
+ * Ids come from the parser's shared slug helpers, so they always match the
29
+ * `id` attributes Markdown renders on its headings.
60
30
  */
61
31
  export function parseOutlineFromMarkdown(markdown: string): OutlineItem[] {
62
32
  const counts = new Map<string, number>();
@@ -29,6 +29,7 @@ import {spacingVars} from '../theme/tokens.stylex';
29
29
  import {Field} from '../Field/Field';
30
30
  import type {InputStatus} from '../Field/types';
31
31
  import {useTooltip} from '../Tooltip';
32
+ import {useResolvedRequired} from '../hooks/useResolvedRequired';
32
33
  import {mergeProps} from '../utils';
33
34
  import type {BaseProps} from '../BaseProps';
34
35
  import type {SizeValue} from '../utils/types';
@@ -218,6 +219,13 @@ export function RadioList({
218
219
 
219
220
  const groupRef = useRef<HTMLDivElement>(null);
220
221
 
222
+ // The radiogroup exposes the *effective* required state so a form-wide
223
+ // `defaultOptionality="required"` is announced even when the group carries no
224
+ // visible indicator. Individual radios keep their native `required` bound to
225
+ // the explicit `isRequired` (via context), so a layout default never switches
226
+ // on browser validation for the group.
227
+ const isEffectivelyRequired = useResolvedRequired({isRequired, isOptional});
228
+
221
229
  // Disabled-reason tooltip. Applies to the whole-group disabled state. Disabled
222
230
  // controls swallow pointer events, so the tooltip listeners attach to the
223
231
  // radiogroup container and the radios stay perceivable via aria-disabled
@@ -387,7 +395,7 @@ export function RadioList({
387
395
  .join(' ') || undefined
388
396
  }
389
397
  aria-invalid={status?.type === 'error' ? true : undefined}
390
- aria-required={isRequired || undefined}
398
+ aria-required={isEffectivelyRequired || undefined}
391
399
  {...mergeProps(
392
400
  themeProps('radio-list', {orientation, size}),
393
401
  stylex.props(
@@ -71,6 +71,7 @@ import {
71
71
  } from './utils';
72
72
  import {useCombobox, useSelectedItemOffset} from './hooks';
73
73
  import {useTypeahead} from '../hooks/useTypeahead';
74
+ import {useResolvedRequired} from '../hooks/useResolvedRequired';
74
75
  import {SelectorOption} from './SelectorOption';
75
76
  import {getInputARIA, isImeKeyEvent, mergeProps} from '../utils';
76
77
  import {useSize} from '../SizeContext/SizeContext';
@@ -699,6 +700,7 @@ export function Selector<T extends SelectorOptionType>(
699
700
  hasClear: hasClearProp,
700
701
  ...rest
701
702
  } = props as SelectorPropsClearable<T>;
703
+ const isEffectivelyRequired = useResolvedRequired({isRequired, isOptional});
702
704
  const placeholder = placeholderFromProps ?? t('@astryx.selector.placeholder');
703
705
  const searchPlaceholder =
704
706
  searchPlaceholderFromProps ?? t('@astryx.selector.searchPlaceholder');
@@ -1345,7 +1347,7 @@ export function Selector<T extends SelectorOptionType>(
1345
1347
  }
1346
1348
  aria-describedby={ariaDescribedBy}
1347
1349
  aria-labelledby={ariaLabelledBy}
1348
- aria-required={isRequired ? 'true' : undefined}
1350
+ aria-required={isEffectivelyRequired ? 'true' : undefined}
1349
1351
  aria-invalid={status?.type === 'error' ? 'true' : undefined}
1350
1352
  aria-busy={isBusy || undefined}
1351
1353
  // With a disabledMessage the trigger keeps focusability via
@@ -33,6 +33,12 @@ export const docs = {
33
33
  description:
34
34
  'Tooltip text shown on hover to explain the status meaning.',
35
35
  },
36
+ {
37
+ name: 'icon',
38
+ type: 'ReactNode',
39
+ description:
40
+ 'Optional icon rendered centered inside the dot, painted in currentColor (the variant\'s ink). Gives the status a non-color mark, so use a different icon per status. Booleans and empty strings are ignored, so `cond && <Icon />` is safe. Same contract as AvatarStatusDot.',
41
+ },
36
42
  {
37
43
  name: 'xstyle',
38
44
  type: 'StyleXStyles',
@@ -84,6 +90,12 @@ export const docsZh = {
84
90
  '启用脉冲动画;尊重 prefers-reduced-motion: reduce 设置。',
85
91
  default: 'false',
86
92
  },
93
+ {
94
+ name: 'icon',
95
+ type: 'ReactNode',
96
+ description:
97
+ '可选图标,居中渲染于圆点内,以 currentColor(变体的前景色)着色。为状态提供非颜色标记,请为每个状态使用不同图标。布尔值和空字符串会被忽略,因此 `cond && <Icon />` 是安全的。与 AvatarStatusDot 的契约一致。',
98
+ },
87
99
  {
88
100
  name: 'xstyle',
89
101
  type: 'StyleXStyles',
@@ -132,6 +144,7 @@ export const docsDense = {
132
144
  label: 'Accessible label via aria-label.',
133
145
  isPulsing: 'Pulse animation; respects prefers-reduced-motion: reduce.',
134
146
  tooltip: 'Tooltip text on hover to explain status meaning.',
147
+ icon: 'Optional ReactNode rendered centered in the dot (currentColor ink); a non-color mark for the status, use a different icon per status. Booleans/empty strings ignored (safe for cond && <Icon/>). Same contract as AvatarStatusDot.',
135
148
  xstyle: 'StyleX layout styles; must be stylex.create() value.',
136
149
  },
137
150
  };