@astryxdesign/core 0.1.0-canary.f94dd07 → 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.
- package/CHANGELOG.md +66 -0
- package/dist/Chat/ChatLayoutScrollButton.d.ts.map +1 -1
- package/dist/Chat/ChatLayoutScrollButton.js +5 -1
- package/dist/ContextMenu/ContextMenu.js +2 -2
- package/dist/DropdownMenu/DropdownMenu.js +2 -2
- package/dist/DropdownMenu/{renderXDSDropdownItems.d.ts → renderDropdownItems.d.ts} +3 -3
- package/dist/DropdownMenu/renderDropdownItems.d.ts.map +1 -0
- package/dist/DropdownMenu/{renderXDSDropdownItems.js → renderDropdownItems.js} +2 -2
- package/dist/Layout/Layout.d.ts +10 -1
- package/dist/Layout/Layout.d.ts.map +1 -1
- package/dist/Layout/Layout.js +5 -1
- package/dist/Outline/Outline.d.ts +3 -2
- package/dist/Outline/Outline.d.ts.map +1 -1
- package/dist/Outline/Outline.js +23 -4
- package/dist/Outline/useScrollSpy.d.ts +14 -1
- package/dist/Outline/useScrollSpy.d.ts.map +1 -1
- package/dist/Outline/useScrollSpy.js +161 -50
- package/dist/Resizable/useResizable.d.ts.map +1 -1
- package/dist/Resizable/useResizable.js +1 -5
- package/dist/Selector/Selector.d.ts.map +1 -1
- package/dist/Selector/Selector.js +1 -1
- package/dist/ToggleButton/ToggleButton.d.ts +10 -3
- package/dist/ToggleButton/ToggleButton.d.ts.map +1 -1
- package/dist/ToggleButton/ToggleButton.js +64 -18
- package/dist/theme/defineTheme.d.ts +1 -1
- package/dist/theme/defineTheme.d.ts.map +1 -1
- package/dist/theme/defineTheme.js +1 -1
- package/dist/theme/index.d.ts +1 -1
- package/dist/theme/index.d.ts.map +1 -1
- package/dist/theme/index.js +1 -1
- package/dist/theme/tokens.d.ts +1 -1
- package/dist/theme/tokens.js +4 -4
- package/dist/theme/useTheme.d.ts +2 -2
- package/dist/utils/dateParser.d.ts.map +1 -1
- package/dist/utils/dateParser.js +15 -2
- package/package.json +2 -2
- package/src/Chat/ChatLayoutScrollButton.tsx +7 -1
- package/src/Collapsible/useCollapsible.doc.mjs +2 -2
- package/src/ContextMenu/ContextMenu.tsx +2 -2
- package/src/DateInput/DateInput.test.tsx +68 -20
- package/src/Divider/Divider.doc.mjs +1 -1
- package/src/DropdownMenu/DropdownMenu.tsx +2 -2
- package/src/DropdownMenu/{renderXDSDropdownItems.tsx → renderDropdownItems.tsx} +2 -2
- package/src/FormLayout/FormLayout.doc.mjs +3 -3
- package/src/Icon/Icon.doc.mjs +4 -4
- package/src/Item/Item.doc.mjs +2 -2
- package/src/Layout/Layout.doc.mjs +2 -1
- package/src/Layout/Layout.tsx +15 -1
- package/src/Layout/__tests__/childrenAsContent.test.tsx +59 -0
- package/src/Link/Link.doc.mjs +3 -3
- package/src/Link/LinkProvider.doc.mjs +3 -3
- package/src/Markdown/Markdown.doc.mjs +4 -4
- package/src/Outline/Outline.doc.mjs +1 -1
- package/src/Outline/Outline.test.tsx +76 -38
- package/src/Outline/Outline.tsx +23 -4
- package/src/Outline/useScrollSpy.ts +196 -63
- package/src/Resizable/Resizable.doc.mjs +2 -2
- package/src/Resizable/useResizable.ts +1 -7
- package/src/Selector/Selector.tsx +5 -6
- package/src/Table/Table.doc.mjs +3 -3
- package/src/ToggleButton/ToggleButton.doc.mjs +2 -2
- package/src/ToggleButton/ToggleButton.test.tsx +148 -6
- package/src/ToggleButton/ToggleButton.tsx +83 -20
- package/src/hooks/useEntryAnimation.doc.mjs +3 -3
- package/src/hooks/useMediaQuery.doc.mjs +2 -2
- package/src/hooks/useStreamingText.doc.mjs +3 -3
- package/src/theme/Theme.doc.mjs +2 -2
- package/src/theme/defineTheme.ts +1 -1
- package/src/theme/index.ts +1 -1
- package/src/theme/tokens.ts +4 -4
- package/src/theme/useTheme.ts +2 -2
- package/src/utils/dateParser.test.ts +26 -0
- package/src/utils/dateParser.ts +16 -2
- package/dist/DropdownMenu/renderXDSDropdownItems.d.ts.map +0 -1
package/src/Icon/Icon.doc.mjs
CHANGED
|
@@ -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
|
|
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: '映射到
|
|
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/
|
|
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
|
|
116
|
+
color: 'Color variant mapped to Astryx icon color tokens.',
|
|
117
117
|
size: 'Icon size.',
|
|
118
118
|
},
|
|
119
119
|
};
|
package/src/Item/Item.doc.mjs
CHANGED
|
@@ -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
|
|
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
|
-
'通用项目原语,统一
|
|
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:
|
|
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',
|
package/src/Layout/Layout.tsx
CHANGED
|
@@ -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">{
|
|
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
|
+
});
|
package/src/Link/Link.doc.mjs
CHANGED
|
@@ -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
|
|
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
|
-
'为子树中所有
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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([
|
|
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
|
|
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
|
|
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
|
-
|
|
225
|
-
|
|
256
|
+
// Not at the bottom of the page.
|
|
257
|
+
Object.defineProperty(document.documentElement, 'scrollHeight', {
|
|
258
|
+
value: 4000,
|
|
259
|
+
configurable: true,
|
|
260
|
+
});
|
|
226
261
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
238
|
-
|
|
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: '
|
|
278
|
+
expect(screen.getByRole('link', {name: 'Installation'})).toHaveAttribute(
|
|
242
279
|
'aria-current',
|
|
243
280
|
'true',
|
|
244
281
|
);
|
|
245
|
-
expect(onActiveIdChange).toHaveBeenCalledWith('
|
|
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
|
});
|
package/src/Outline/Outline.tsx
CHANGED
|
@@ -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
|
|
221
|
-
*
|
|
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
|
|
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
|
|