@astryxdesign/core 0.1.0 → 0.1.1-canary.a514b99

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 (78) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/dist/Chat/ChatLayoutScrollButton.d.ts.map +1 -1
  3. package/dist/Chat/ChatLayoutScrollButton.js +5 -1
  4. package/dist/ContextMenu/ContextMenu.js +2 -2
  5. package/dist/DropdownMenu/DropdownMenu.js +2 -2
  6. package/dist/DropdownMenu/{renderXDSDropdownItems.d.ts → renderDropdownItems.d.ts} +3 -3
  7. package/dist/DropdownMenu/renderDropdownItems.d.ts.map +1 -0
  8. package/dist/DropdownMenu/{renderXDSDropdownItems.js → renderDropdownItems.js} +2 -2
  9. package/dist/Layout/Layout.d.ts +10 -1
  10. package/dist/Layout/Layout.d.ts.map +1 -1
  11. package/dist/Layout/Layout.js +5 -1
  12. package/dist/Outline/Outline.d.ts +3 -2
  13. package/dist/Outline/Outline.d.ts.map +1 -1
  14. package/dist/Outline/Outline.js +23 -4
  15. package/dist/Outline/useScrollSpy.d.ts +14 -1
  16. package/dist/Outline/useScrollSpy.d.ts.map +1 -1
  17. package/dist/Outline/useScrollSpy.js +161 -50
  18. package/dist/Resizable/useResizable.d.ts.map +1 -1
  19. package/dist/Resizable/useResizable.js +1 -5
  20. package/dist/Selector/Selector.d.ts.map +1 -1
  21. package/dist/Selector/Selector.js +1 -1
  22. package/dist/ToggleButton/ToggleButton.d.ts +10 -3
  23. package/dist/ToggleButton/ToggleButton.d.ts.map +1 -1
  24. package/dist/ToggleButton/ToggleButton.js +64 -18
  25. package/dist/theme/Theme.js +1 -1
  26. package/dist/theme/defineTheme.d.ts +1 -1
  27. package/dist/theme/defineTheme.d.ts.map +1 -1
  28. package/dist/theme/defineTheme.js +1 -1
  29. package/dist/theme/index.d.ts +1 -1
  30. package/dist/theme/index.d.ts.map +1 -1
  31. package/dist/theme/index.js +1 -1
  32. package/dist/theme/syntax/defineSyntaxTheme.js +1 -1
  33. package/dist/theme/tokens.d.ts +1 -1
  34. package/dist/theme/tokens.js +4 -4
  35. package/dist/theme/useTheme.d.ts +2 -2
  36. package/dist/utils/dateParser.d.ts.map +1 -1
  37. package/dist/utils/dateParser.js +15 -2
  38. package/package.json +2 -2
  39. package/src/Chat/ChatLayoutScrollButton.tsx +7 -1
  40. package/src/Collapsible/useCollapsible.doc.mjs +2 -2
  41. package/src/ContextMenu/ContextMenu.tsx +2 -2
  42. package/src/DateInput/DateInput.test.tsx +68 -20
  43. package/src/Divider/Divider.doc.mjs +1 -1
  44. package/src/DropdownMenu/DropdownMenu.tsx +2 -2
  45. package/src/DropdownMenu/{renderXDSDropdownItems.tsx → renderDropdownItems.tsx} +2 -2
  46. package/src/FormLayout/FormLayout.doc.mjs +3 -3
  47. package/src/Icon/Icon.doc.mjs +4 -4
  48. package/src/Item/Item.doc.mjs +2 -2
  49. package/src/Layout/Layout.doc.mjs +2 -1
  50. package/src/Layout/Layout.tsx +15 -1
  51. package/src/Layout/__tests__/childrenAsContent.test.tsx +59 -0
  52. package/src/Link/Link.doc.mjs +3 -3
  53. package/src/Link/LinkProvider.doc.mjs +3 -3
  54. package/src/Markdown/Markdown.doc.mjs +4 -4
  55. package/src/Outline/Outline.doc.mjs +1 -1
  56. package/src/Outline/Outline.test.tsx +76 -38
  57. package/src/Outline/Outline.tsx +23 -4
  58. package/src/Outline/useScrollSpy.ts +196 -63
  59. package/src/Resizable/Resizable.doc.mjs +2 -2
  60. package/src/Resizable/useResizable.ts +1 -7
  61. package/src/Selector/Selector.tsx +5 -6
  62. package/src/Table/Table.doc.mjs +3 -3
  63. package/src/ToggleButton/ToggleButton.doc.mjs +2 -2
  64. package/src/ToggleButton/ToggleButton.test.tsx +148 -6
  65. package/src/ToggleButton/ToggleButton.tsx +83 -20
  66. package/src/hooks/useEntryAnimation.doc.mjs +3 -3
  67. package/src/hooks/useMediaQuery.doc.mjs +2 -2
  68. package/src/hooks/useStreamingText.doc.mjs +3 -3
  69. package/src/theme/Theme.doc.mjs +2 -2
  70. package/src/theme/Theme.tsx +1 -1
  71. package/src/theme/defineTheme.ts +1 -1
  72. package/src/theme/index.ts +1 -1
  73. package/src/theme/syntax/defineSyntaxTheme.ts +1 -1
  74. package/src/theme/tokens.ts +4 -4
  75. package/src/theme/useTheme.ts +2 -2
  76. package/src/utils/dateParser.test.ts +26 -0
  77. package/src/utils/dateParser.ts +16 -2
  78. package/dist/DropdownMenu/renderXDSDropdownItems.d.ts.map +0 -1
@@ -20,7 +20,7 @@ export const docs = {
20
20
  name: 'children',
21
21
  type: 'ReactNode',
22
22
  description:
23
- 'Form fields to arrange. Accepts XDS inputs (TextInput, Selector, etc.) and Field-wrapped custom controls.',
23
+ 'Form fields to arrange. Accepts Astryx inputs (TextInput, Selector, etc.) and Field-wrapped custom controls.',
24
24
  },
25
25
  {
26
26
  name: 'xstyle',
@@ -68,7 +68,7 @@ export const docsZh = {
68
68
  name: 'children',
69
69
  type: 'ReactNode',
70
70
  description:
71
- '要排列的表单字段。接受 XDS 输入组件(TextInput、Selector 等)和 Field 包装的自定义控件。',
71
+ '要排列的表单字段。接受 Astryx 输入组件(TextInput、Selector 等)和 Field 包装的自定义控件。',
72
72
  },
73
73
  {
74
74
  name: 'xstyle',
@@ -122,7 +122,7 @@ export const docsDense = {
122
122
  },
123
123
  propDescriptions: {
124
124
  direction: 'Field arrangement. Vertical stacks top-to-bottom, horizontal arranges left-to-right w/ equal flex-grow, horizontal-labels uses CSS Grid w/ labels left of inputs (collapses <=480px).',
125
- children: 'Form fields to arrange. Accepts XDS inputs + Field-wrapped custom controls.',
125
+ children: 'Form fields to arrange. Accepts Astryx inputs + Field-wrapped custom controls.',
126
126
  xstyle: 'StyleX styles for layout customization. Must be stylex.create() value.',
127
127
  },
128
128
  };
@@ -17,7 +17,7 @@ export const docs = {
17
17
  {
18
18
  name: 'color',
19
19
  type: "'primary' | 'secondary' | 'tertiary' | 'disabled' | 'accent' | 'success' | 'error' | 'warning' | 'inherit'",
20
- description: 'Color variant mapped to XDS icon color tokens.',
20
+ description: 'Color variant mapped to Astryx icon color tokens.',
21
21
  default: "'inherit'",
22
22
  },
23
23
  {
@@ -62,7 +62,7 @@ export const docsZh = {
62
62
  {
63
63
  name: 'color',
64
64
  type: "'primary' | 'secondary' | 'tertiary' | 'disabled' | 'accent' | 'success' | 'error' | 'warning' | 'inherit'",
65
- description: '映射到 XDS 图标颜色令牌的颜色变体。',
65
+ description: '映射到 Astryx 图标颜色令牌的颜色变体。',
66
66
  default: "'inherit'",
67
67
  },
68
68
  {
@@ -96,7 +96,7 @@ export const docsZh = {
96
96
  /** @type {import('../docs-types').TranslationDoc} */
97
97
  export const docsDense = {
98
98
  description:
99
- 'Renders icons w/ XDS design system colors + sizes. Supports direct SVG icon components + semantic icon names that adapt to active theme.',
99
+ 'Renders icons w/ Astryx design system colors + sizes. Supports direct SVG icon components + semantic icon names that adapt to active theme.',
100
100
  usage: {
101
101
  description: 'Icons are small visual symbols that represent actions, objects, or concepts. They improve scannability and reinforce meaning alongside text. Supports both direct SVG components and semantic icon names that adapt to the active theme.',
102
102
  bestPractices: [
@@ -113,7 +113,7 @@ export const docsDense = {
113
113
  },
114
114
  propDescriptions: {
115
115
  icon: 'Semantic icon name or SVG component. Valid names: close, chevronDown, chevronLeft, chevronRight, check, success, error, warning, info, calendar, clock, externalLink, menu, moreHorizontal, search, arrowUp, arrowDown, arrowsUpDown, funnel, eyeSlash, viewColumns, copy, checkDouble, wrench, stop, microphone. For others, pass an SVG component.',
116
- color: 'Color variant mapped to XDS icon color tokens.',
116
+ color: 'Color variant mapped to Astryx icon color tokens.',
117
117
  size: 'Icon size.',
118
118
  },
119
119
  };
@@ -51,7 +51,7 @@ export const docs = {
51
51
  ],
52
52
  usage: {
53
53
  description:
54
- 'A single, flexible item primitive that unifies the "start content + label + description + end content" pattern across XDS. Use it wherever you need a structured row: dropdown menus, selectors, contact lists, notifications, file browsers, and activity feeds.',
54
+ 'A single, flexible item primitive that unifies the "start content + label + description + end content" pattern across Astryx. Use it wherever you need a structured row: dropdown menus, selectors, contact lists, notifications, file browsers, and activity feeds.',
55
55
  bestPractices: [
56
56
  {guidance: true, description: 'Use named slots (startContent, label, description, endContent) for the common layout. These cover the 80% case.'},
57
57
  {guidance: true, description: 'Use density="compact" for menus and dense lists, "balanced" for standard rows, and "spacious" for roomier layouts.'},
@@ -104,7 +104,7 @@ export const docsZh = {
104
104
  ],
105
105
  usage: {
106
106
  description:
107
- '通用项目原语,统一 XDS 中 "起始内容 + 标签 + 描述 + 结束内容" 的布局模式。适用于下拉菜单、选择器、联系人列表、通知、文件浏览器和活动流等场景。',
107
+ '通用项目原语,统一 Astryx 中 "起始内容 + 标签 + 描述 + 结束内容" 的布局模式。适用于下拉菜单、选择器、联系人列表、通知、文件浏览器和活动流等场景。',
108
108
  bestPractices: [
109
109
  {guidance: true, description: '使用命名插槽(startContent、label、description、endContent)处理常见布局。'},
110
110
  {guidance: true, description: '菜单和密集列表使用 density="compact",标准行使用 "balanced",宽松布局使用 "spacious"。'},
@@ -29,7 +29,8 @@ export const docs = {
29
29
  {
30
30
  name: 'content',
31
31
  type: 'ReactNode',
32
- description: 'Main content area (center).',
32
+ description:
33
+ 'Main content area (center). Children passed to `<Layout>` render here too — `<Layout>{main}</Layout>` is shorthand for `<Layout content={main} />`.',
33
34
  slotElements: [
34
35
  {
35
36
  __element: 'LayoutContent',
@@ -157,6 +157,16 @@ export interface LayoutProps extends Omit<BaseProps, 'content'> {
157
157
  * Inline styles to apply to the root element.
158
158
  */
159
159
  style?: React.CSSProperties;
160
+
161
+ /**
162
+ * Children are a shorthand for the `content` slot:
163
+ * `<Layout>{main}</Layout>` is equivalent to `<Layout content={main} />`.
164
+ * The surrounding zones (`header`/`start`/`end`/`footer`) stay explicit
165
+ * props. If both `content` and `children` are provided, `content` wins.
166
+ * Accepting children keeps the natural `<Layout>…</Layout>` form from
167
+ * rendering a blank shell.
168
+ */
169
+ children?: ReactNode;
160
170
  }
161
171
 
162
172
  /**
@@ -222,6 +232,7 @@ function AreaProvider({
222
232
  * ```
223
233
  */
224
234
  export function Layout({
235
+ children,
225
236
  content,
226
237
  contentWidth,
227
238
  defaultHasDividers,
@@ -237,6 +248,9 @@ export function Layout({
237
248
  style,
238
249
  }: LayoutProps) {
239
250
  const isFill = height === 'fill';
251
+ // Children are a shorthand for the content slot; an explicit `content` prop
252
+ // wins when both are provided.
253
+ const resolvedContent = content ?? children;
240
254
 
241
255
  const dividerCtxValue = useMemo(
242
256
  () => (defaultHasDividers != null ? {defaultHasDividers} : null),
@@ -287,7 +301,7 @@ export function Layout({
287
301
  )}>
288
302
  <AreaProvider area="start">{start}</AreaProvider>
289
303
  <div {...stylex.props(...stackItem({size: 'fill'}))}>
290
- <AreaProvider area="content">{content}</AreaProvider>
304
+ <AreaProvider area="content">{resolvedContent}</AreaProvider>
291
305
  </div>
292
306
  <AreaProvider area="end">{end}</AreaProvider>
293
307
  </div>
@@ -0,0 +1,59 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file childrenAsContent.test.tsx
5
+ * @input Uses vitest, @testing-library/react
6
+ * @output Verifies Layout renders children as a shorthand for the content slot
7
+ * (so the natural `<Layout>…</Layout>` form never renders a blank shell), with
8
+ * an explicit `content` prop taking precedence.
9
+ */
10
+
11
+ import {describe, it, expect} from 'vitest';
12
+ import {render, screen} from '@testing-library/react';
13
+ import {Layout} from '../Layout';
14
+ import {LayoutContent} from '../LayoutContent';
15
+
16
+ describe('Layout children-as-content', () => {
17
+ it('renders nested children in the content slot', () => {
18
+ render(
19
+ <Layout>
20
+ <LayoutContent>
21
+ <span data-testid="body">Body</span>
22
+ </LayoutContent>
23
+ </Layout>,
24
+ );
25
+ expect(screen.getByTestId('body')).toBeInTheDocument();
26
+ });
27
+
28
+ it('renders bare children (no LayoutContent wrapper) too', () => {
29
+ render(
30
+ <Layout>
31
+ <span data-testid="bare">Bare</span>
32
+ </Layout>,
33
+ );
34
+ expect(screen.getByTestId('bare')).toBeInTheDocument();
35
+ });
36
+
37
+ it('lets an explicit content prop win over children', () => {
38
+ render(
39
+ <Layout content={<span data-testid="slot">Slot</span>}>
40
+ <span data-testid="child">Child</span>
41
+ </Layout>,
42
+ );
43
+ expect(screen.getByTestId('slot')).toBeInTheDocument();
44
+ expect(screen.queryByTestId('child')).not.toBeInTheDocument();
45
+ });
46
+
47
+ it('still supports the canonical slot-only API', () => {
48
+ render(
49
+ <Layout
50
+ content={
51
+ <LayoutContent>
52
+ <span data-testid="canon">Canonical</span>
53
+ </LayoutContent>
54
+ }
55
+ />,
56
+ );
57
+ expect(screen.getByTestId('canon')).toBeInTheDocument();
58
+ });
59
+ });
@@ -99,7 +99,7 @@ export const docs = {
99
99
  isHiddenFromOverview: true,
100
100
  displayName: 'Link Provider',
101
101
  description:
102
- 'Provider that sets the default link component for all XDS link-rendering components in the subtree. ' +
102
+ 'Provider that sets the default link component for all Astryx link-rendering components in the subtree. ' +
103
103
  'Wrap your app root to replace native <a> elements with your framework router (Next.js Link, React Router Link, etc.).',
104
104
  props: [
105
105
  {
@@ -226,7 +226,7 @@ export const docsZh = {
226
226
  isHiddenFromOverview: true,
227
227
  displayName: 'Link Provider',
228
228
  description:
229
- '为子树中所有 XDS 链接组件设置默认链接组件的 Provider。',
229
+ '为子树中所有 Astryx 链接组件设置默认链接组件的 Provider。',
230
230
  props: [
231
231
  {
232
232
  name: 'component',
@@ -310,7 +310,7 @@ export const docsDense = {
310
310
  isHiddenFromOverview: true,
311
311
  displayName: 'Link Provider',
312
312
  description:
313
- 'Provider setting default link component for all XDS links in subtree.',
313
+ 'Provider setting default link component for all Astryx links in subtree.',
314
314
  propDescriptions: {
315
315
  component: 'Component for all link elements',
316
316
  children: 'Subtree',
@@ -10,7 +10,7 @@ export const docs = {
10
10
  isHiddenFromOverview: true,
11
11
  keywords: ['link', 'provider', 'router', 'nextjs', 'client-side-routing'],
12
12
  usage: {
13
- description: 'Wraps your app to replace the default <a> tag with a framework-specific link component (e.g. Next.js Link) for client-side routing across all XDS components.',
13
+ description: 'Wraps your app to replace the default <a> tag with a framework-specific link component (e.g. Next.js Link) for client-side routing across all Astryx components.',
14
14
  },
15
15
  props: [
16
16
  {name: 'component', type: 'LinkComponentType', required: true, description: 'Link component to use for all link elements in the subtree (e.g. Next.js Link).'},
@@ -20,9 +20,9 @@ export const docs = {
20
20
 
21
21
  /** @type {import('../docs-types').TranslationDoc} */
22
22
  export const docsDense = {
23
- description: 'Wraps app to replace default <a> tag w/ framework-specific link component (e.g. Next.js Link) for client-side routing across all XDS components.',
23
+ description: 'Wraps app to replace default <a> tag w/ framework-specific link component (e.g. Next.js Link) for client-side routing across all Astryx components.',
24
24
  usage: {
25
- description: 'Wraps app to replace default <a> tag w/ framework-specific link component (e.g. Next.js Link) for client-side routing across all XDS components.',
25
+ description: 'Wraps app to replace default <a> tag w/ framework-specific link component (e.g. Next.js Link) for client-side routing across all Astryx components.',
26
26
  },
27
27
  propDescriptions: {
28
28
  component: 'link component for all link elements in subtree (e.g. Next.js Link)',
@@ -131,7 +131,7 @@ export const docs = {
131
131
  },
132
132
  usage: {
133
133
  description:
134
- 'Renders a markdown string as XDS-styled components. Use Markdown for user-generated content, AI responses, and documentation; it handles headings, lists, tables, code blocks, and citations with consistent styling.',
134
+ 'Renders a markdown string as Astryx-styled components. Use Markdown for user-generated content, AI responses, and documentation; it handles headings, lists, tables, code blocks, and citations with consistent styling.',
135
135
  bestPractices: [
136
136
  { guidance: true, description: 'Set headingLevelStart to match the page hierarchy, e.g. start at 3 if the markdown sits inside an h2 section.' },
137
137
  { guidance: true, description: 'Use contentWidth to keep prose at a readable line length in wide layouts.' },
@@ -294,7 +294,7 @@ export const docsZh = {
294
294
  },
295
295
  usage: {
296
296
  description:
297
- 'Renders a markdown string as XDS-styled components. Use Markdown for user-generated content, AI responses, and documentation; it handles headings, lists, tables, code blocks, and citations with consistent styling.',
297
+ 'Renders a markdown string as Astryx-styled components. Use Markdown for user-generated content, AI responses, and documentation; it handles headings, lists, tables, code blocks, and citations with consistent styling.',
298
298
  bestPractices: [
299
299
  { guidance: true, description: 'Set headingLevelStart to match the page hierarchy, e.g. start at 3 if the markdown sits inside an h2 section.' },
300
300
  { guidance: true, description: 'Use contentWidth to keep prose at a readable line length in wide layouts.' },
@@ -306,10 +306,10 @@ export const docsZh = {
306
306
 
307
307
  export const docsDense = {
308
308
  description:
309
- 'Renders markdown string as XDS-styled components. Use for user-generated content, AI responses, docs. Headings, lists, tables, code, citations w/ consistent styling.',
309
+ 'Renders markdown string as Astryx-styled components. Use for user-generated content, AI responses, docs. Headings, lists, tables, code, citations w/ consistent styling.',
310
310
  usage: {
311
311
  description:
312
- 'Renders a markdown string as XDS-styled components. Use Markdown for user-generated content, AI responses, and documentation; it handles headings, lists, tables, code blocks, and citations with consistent styling.',
312
+ 'Renders a markdown string as Astryx-styled components. Use Markdown for user-generated content, AI responses, and documentation; it handles headings, lists, tables, code blocks, and citations with consistent styling.',
313
313
  bestPractices: [
314
314
  { guidance: true, description: 'Set headingLevelStart to match the page hierarchy, e.g. start at 3 if the markdown sits inside an h2 section.' },
315
315
  { guidance: true, description: 'Use contentWidth to keep prose at a readable line length in wide layouts.' },
@@ -228,7 +228,7 @@ export const docsZh = {
228
228
  /** @type {import('../docs-types').TranslationDoc} */
229
229
  export const docsDense = {
230
230
  description:
231
- 'Document outline/table-of-contents nav with sliding indicator track. Flat items array {id,label,level}; anchor links; density variant (default/compact); uncontrolled scroll-spy via IntersectionObserver topmost-visible-heading; controlled with activeId; smooth-scroll on click.',
231
+ 'Document outline/table-of-contents nav with sliding indicator track. Flat items array {id,label,level}; anchor links; density variant (default/compact); uncontrolled scroll-spy by scroll position (last heading past its scroll-margin-top line; first item at top, last at bottom); controlled with activeId; smooth-scroll on click that pins the active item until the next manual scroll.',
232
232
  usage: {
233
233
  description:
234
234
  'A table-of-contents sidebar for documentation pages, help centers, wikis, and long settings pages. Use it for navigation within a single page, not for app routes.',
@@ -37,7 +37,13 @@ describe('parseOutlineFromMarkdown', () => {
37
37
  parseOutlineFromMarkdown(
38
38
  '## **Install** `@astryxdesign/core`\n\n```\n# Not a heading\n```',
39
39
  ),
40
- ).toEqual([{id: 'install-astryxdesign-core', label: 'Install @astryxdesign/core', level: 2}]);
40
+ ).toEqual([
41
+ {
42
+ id: 'install-astryxdesign-core',
43
+ label: 'Install @astryxdesign/core',
44
+ level: 2,
45
+ },
46
+ ]);
41
47
  });
42
48
 
43
49
  it('deduplicates generated ids', () => {
@@ -87,7 +93,7 @@ describe('Outline', () => {
87
93
  ).not.toHaveAttribute('aria-current');
88
94
  });
89
95
 
90
- it('smooth-scrolls and reports active id on click', async () => {
96
+ it('smooth-scrolls and defers the indicator until the scroll settles when uncontrolled', async () => {
91
97
  const user = userEvent.setup();
92
98
  const target = document.createElement('h2');
93
99
  target.id = 'install';
@@ -101,6 +107,47 @@ describe('Outline', () => {
101
107
  behavior: 'smooth',
102
108
  block: 'start',
103
109
  });
110
+ // Uncontrolled: the indicator is deferred during the programmatic scroll,
111
+ // so it has not moved to the clicked item yet.
112
+ expect(
113
+ screen.getByRole('link', {name: 'Installation'}),
114
+ ).not.toHaveAttribute('aria-current', 'true');
115
+
116
+ // When the scroll settles, the indicator lands on the clicked item.
117
+ act(() => {
118
+ window.dispatchEvent(new Event('scrollend'));
119
+ });
120
+ expect(onActiveIdChange).toHaveBeenCalledWith('install');
121
+ expect(screen.getByRole('link', {name: 'Installation'})).toHaveAttribute(
122
+ 'aria-current',
123
+ 'true',
124
+ );
125
+
126
+ document.body.removeChild(target);
127
+ });
128
+
129
+ it('reports active id on click when controlled', async () => {
130
+ const user = userEvent.setup();
131
+ const target = document.createElement('h2');
132
+ target.id = 'install';
133
+ document.body.appendChild(target);
134
+ const onActiveIdChange = vi.fn();
135
+
136
+ render(
137
+ <Outline
138
+ items={items}
139
+ activeId="intro"
140
+ onActiveIdChange={onActiveIdChange}
141
+ />,
142
+ );
143
+ await user.click(screen.getByRole('link', {name: 'Installation'}));
144
+
145
+ expect(target.scrollIntoView).toHaveBeenCalledWith({
146
+ behavior: 'smooth',
147
+ block: 'start',
148
+ });
149
+ // Controlled: there is no built-in scroll-spy, so the consumer owns the
150
+ // active state and must be notified on click.
104
151
  expect(onActiveIdChange).toHaveBeenCalledWith('install');
105
152
 
106
153
  document.body.removeChild(target);
@@ -122,11 +169,7 @@ describe('Outline', () => {
122
169
 
123
170
  it('renders with density="compact"', () => {
124
171
  render(
125
- <Outline
126
- items={items}
127
- density="compact"
128
- data-testid="outline-compact"
129
- />,
172
+ <Outline items={items} density="compact" data-testid="outline-compact" />,
130
173
  );
131
174
  expect(screen.getByTestId('outline-compact').className).toContain(
132
175
  'compact',
@@ -201,50 +244,45 @@ describe('Outline', () => {
201
244
  ).not.toHaveAttribute('aria-current');
202
245
  });
203
246
 
204
- it('updates uncontrolled active id from IntersectionObserver', () => {
205
- let observerCallback: IntersectionObserverCallback | undefined;
206
-
207
- class MockIntersectionObserver {
208
- observe = vi.fn();
209
- disconnect = vi.fn();
210
-
211
- constructor(callback: IntersectionObserverCallback) {
212
- observerCallback = callback;
213
- }
214
- }
215
-
216
- vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
217
-
247
+ it('updates uncontrolled active id from scroll position', () => {
218
248
  const intro = document.createElement('h2');
219
249
  intro.id = 'intro';
250
+ const install = document.createElement('h3');
251
+ install.id = 'install';
220
252
  const api = document.createElement('h3');
221
253
  api.id = 'api';
222
- document.body.append(intro, api);
254
+ document.body.append(intro, install, api);
223
255
 
224
- const onActiveIdChange = vi.fn();
225
- render(<Outline items={items} onActiveIdChange={onActiveIdChange} />);
256
+ // Not at the bottom of the page.
257
+ Object.defineProperty(document.documentElement, 'scrollHeight', {
258
+ value: 4000,
259
+ configurable: true,
260
+ });
226
261
 
227
- const entry: IntersectionObserverEntry = {
228
- target: api,
229
- isIntersecting: true,
230
- boundingClientRect: {top: 12} as DOMRectReadOnly,
231
- intersectionRatio: 1,
232
- intersectionRect: {} as DOMRectReadOnly,
233
- rootBounds: null,
234
- time: 0,
235
- };
262
+ // intro + install have scrolled above the activation line (top <= 0);
263
+ // api is still below it, so install is the last passed heading.
264
+ vi.spyOn(intro, 'getBoundingClientRect').mockReturnValue({
265
+ top: -200,
266
+ } as DOMRect);
267
+ vi.spyOn(install, 'getBoundingClientRect').mockReturnValue({
268
+ top: -10,
269
+ } as DOMRect);
270
+ vi.spyOn(api, 'getBoundingClientRect').mockReturnValue({
271
+ top: 400,
272
+ } as DOMRect);
236
273
 
237
- act(() => {
238
- observerCallback?.([entry], {} as IntersectionObserver);
239
- });
274
+ const onActiveIdChange = vi.fn();
275
+ // The hook resolves the active id from scroll position on mount.
276
+ render(<Outline items={items} onActiveIdChange={onActiveIdChange} />);
240
277
 
241
- expect(screen.getByRole('link', {name: 'API'})).toHaveAttribute(
278
+ expect(screen.getByRole('link', {name: 'Installation'})).toHaveAttribute(
242
279
  'aria-current',
243
280
  'true',
244
281
  );
245
- expect(onActiveIdChange).toHaveBeenCalledWith('api');
282
+ expect(onActiveIdChange).toHaveBeenCalledWith('install');
246
283
 
247
284
  document.body.removeChild(intro);
285
+ document.body.removeChild(install);
248
286
  document.body.removeChild(api);
249
287
  });
250
288
  });
@@ -217,8 +217,9 @@ function getIndentStyle(level: number) {
217
217
  * indentation based on each heading level. Features a sliding indicator
218
218
  * track that animates to the active item.
219
219
  *
220
- * When `activeId` is omitted, it observes heading elements by id and marks
221
- * the topmost visible heading active.
220
+ * When `activeId` is omitted, it tracks scroll position and marks the last
221
+ * heading whose top has passed its activation line (its scroll-margin-top)
222
+ * active — defaulting to the first item at the top and the last at the bottom.
222
223
  *
223
224
  * @example
224
225
  * ```
@@ -246,7 +247,12 @@ export function Outline({
246
247
  }: OutlineProps) {
247
248
  const rootRef = useRef<HTMLElement | null>(null);
248
249
  const LinkComponent = useLinkComponent();
249
- const [resolvedActiveId, setActiveId] = useScrollSpy({
250
+ const isControlled = activeId !== undefined;
251
+ const {
252
+ activeId: resolvedActiveId,
253
+ setActiveId,
254
+ lockActiveId,
255
+ } = useScrollSpy({
250
256
  activeId,
251
257
  items,
252
258
  onActiveIdChange,
@@ -256,8 +262,9 @@ export function Outline({
256
262
  const handleClick =
257
263
  (id: string) => (event: React.MouseEvent<HTMLElement>) => {
258
264
  const target = document.getElementById(id);
259
- setActiveId(id);
260
265
 
266
+ // Let the browser handle modified clicks (open in new tab, etc.) and
267
+ // missing targets without touching the active state.
261
268
  if (
262
269
  target == null ||
263
270
  event.defaultPrevented ||
@@ -271,6 +278,18 @@ export function Outline({
271
278
 
272
279
  event.preventDefault();
273
280
  window.history.pushState(null, '', `#${id}`);
281
+
282
+ // Move the indicator to the clicked item in a single step. Controlled
283
+ // consumers own the active state (notify only); uncontrolled mode pins
284
+ // the active id and suppresses scroll-spy until the next manual scroll,
285
+ // so the click is honored — even for short/last sections — and the
286
+ // indicator doesn't chase the smooth scroll through other sections.
287
+ if (isControlled) {
288
+ setActiveId(id);
289
+ } else {
290
+ lockActiveId(id);
291
+ }
292
+
274
293
  target.scrollIntoView({behavior: 'smooth', block: 'start'});
275
294
  };
276
295