@redocly/theme 0.69.0-next.1 → 0.69.0-next.3

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 (52) hide show
  1. package/lib/components/Badge/Badge.d.ts +3 -1
  2. package/lib/components/Badge/Badge.js +7 -2
  3. package/lib/components/Buttons/ConnectMCPButton.js +43 -14
  4. package/lib/components/MCP/McpConnectionTabs.d.ts +7 -0
  5. package/lib/components/MCP/McpConnectionTabs.js +151 -0
  6. package/lib/core/constants/mcp.d.ts +2 -0
  7. package/lib/core/constants/mcp.js +8 -1
  8. package/lib/core/hooks/use-active-heading.js +59 -15
  9. package/lib/core/hooks/use-active-section-id.js +3 -1
  10. package/lib/core/hooks/use-connect-mcp-button.d.ts +1 -1
  11. package/lib/core/hooks/use-connect-mcp-button.js +16 -13
  12. package/lib/core/styles/global.js +6 -1
  13. package/lib/core/types/catalog.d.ts +2 -0
  14. package/lib/core/types/l10n.d.ts +1 -1
  15. package/lib/core/types/mcp.d.ts +1 -1
  16. package/lib/core/utils/mcp.d.ts +5 -0
  17. package/lib/core/utils/mcp.js +35 -0
  18. package/lib/icons/CodexIcon/CodexIcon.d.ts +25 -0
  19. package/lib/icons/CodexIcon/CodexIcon.js +22 -0
  20. package/lib/layouts/McpNotAllowed.js +5 -3
  21. package/lib/markdoc/components/Cards/Card.d.ts +8 -1
  22. package/lib/markdoc/components/Cards/Card.js +55 -16
  23. package/lib/markdoc/components/Cards/CardBadge.d.ts +9 -0
  24. package/lib/markdoc/components/Cards/CardBadge.js +30 -0
  25. package/lib/markdoc/components/Cards/CardIcon.d.ts +5 -4
  26. package/lib/markdoc/components/Cards/CardIcon.js +6 -3
  27. package/lib/markdoc/components/Cards/variables.dark.js +32 -0
  28. package/lib/markdoc/components/Cards/variables.js +76 -4
  29. package/lib/markdoc/tags/card.js +14 -0
  30. package/lib/markdoc/tags/connect-mcp.js +2 -1
  31. package/package.json +2 -2
  32. package/src/components/Badge/Badge.tsx +16 -2
  33. package/src/components/Buttons/ConnectMCPButton.tsx +44 -14
  34. package/src/components/MCP/McpConnectionTabs.tsx +263 -0
  35. package/src/core/constants/mcp.ts +10 -0
  36. package/src/core/hooks/use-active-heading.ts +79 -15
  37. package/src/core/hooks/use-active-section-id.ts +3 -1
  38. package/src/core/hooks/use-connect-mcp-button.ts +23 -15
  39. package/src/core/styles/global.ts +6 -1
  40. package/src/core/types/catalog.ts +3 -0
  41. package/src/core/types/l10n.ts +17 -0
  42. package/src/core/types/mcp.ts +1 -1
  43. package/src/core/utils/mcp.ts +39 -0
  44. package/src/icons/CodexIcon/CodexIcon.tsx +23 -0
  45. package/src/layouts/McpNotAllowed.tsx +4 -2
  46. package/src/markdoc/components/Cards/Card.tsx +79 -14
  47. package/src/markdoc/components/Cards/CardBadge.tsx +39 -0
  48. package/src/markdoc/components/Cards/CardIcon.tsx +16 -5
  49. package/src/markdoc/components/Cards/variables.dark.ts +32 -0
  50. package/src/markdoc/components/Cards/variables.ts +76 -4
  51. package/src/markdoc/tags/card.ts +14 -0
  52. package/src/markdoc/tags/connect-mcp.ts +3 -1
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.connectMcp = void 0;
4
+ const constants_1 = require("../../core/constants");
4
5
  exports.connectMcp = {
5
6
  schema: {
6
7
  render: 'ConnectMCP',
@@ -17,7 +18,7 @@ exports.connectMcp = {
17
18
  },
18
19
  options: {
19
20
  type: Array,
20
- default: ['cursor', 'vscode', 'copy'],
21
+ default: constants_1.DEFAULT_MCP_OPTIONS,
21
22
  },
22
23
  },
23
24
  selfClosing: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redocly/theme",
3
- "version": "0.69.0-next.1",
3
+ "version": "0.69.0-next.3",
4
4
  "description": "Shared UI components lib",
5
5
  "keywords": [
6
6
  "theme",
@@ -68,7 +68,7 @@
68
68
  "vitest": "4.1.10",
69
69
  "vitest-when": "0.6.2",
70
70
  "webpack": "5.105.2",
71
- "@redocly/realm-asyncapi-sdk": "0.15.0-next.0"
71
+ "@redocly/realm-asyncapi-sdk": "0.15.0-next.2"
72
72
  },
73
73
  "dependencies": {
74
74
  "@tanstack/react-query": "5.62.3",
@@ -11,9 +11,18 @@ export type BadgeProps = PropsWithChildren<{
11
11
  key?: string;
12
12
  className?: string;
13
13
  icon?: string;
14
+ /** Inline SVG content for the icon */
15
+ iconRawContent?: string;
14
16
  }>;
15
17
 
16
- export function Badge({ icon, children, deprecated, color, ...props }: BadgeProps): JSX.Element {
18
+ export function Badge({
19
+ icon,
20
+ iconRawContent,
21
+ children,
22
+ deprecated,
23
+ color,
24
+ ...props
25
+ }: BadgeProps): JSX.Element {
17
26
  return (
18
27
  <BadgeComponent
19
28
  {...props}
@@ -21,7 +30,7 @@ export function Badge({ icon, children, deprecated, color, ...props }: BadgeProp
21
30
  $deprecated={deprecated}
22
31
  data-component-name="Badge/Badge"
23
32
  >
24
- {icon ? <BadgeIcon icon={icon} /> : null}
33
+ {icon || iconRawContent ? <BadgeIcon icon={icon} rawContent={iconRawContent} /> : null}
25
34
  {children}
26
35
  </BadgeComponent>
27
36
  );
@@ -54,4 +63,9 @@ const BadgeIcon = styled(GenericIcon)`
54
63
  margin-right: var(--spacing-xxs);
55
64
  flex-shrink: 0;
56
65
  vertical-align: middle;
66
+
67
+ /* For inline SVG to align properly */
68
+ svg {
69
+ display: block;
70
+ }
57
71
  `;
@@ -9,11 +9,14 @@ import { DropdownMenuItem } from '@redocly/theme/components/Dropdown/DropdownMen
9
9
  import { Button } from '@redocly/theme/components/Button/Button';
10
10
  import { CursorIcon } from '@redocly/theme/icons/CursorIcon/CursorIcon';
11
11
  import { VSCodeIcon } from '@redocly/theme/icons/VSCodeIcon/VSCodeIcon';
12
+ import { ClaudeIcon } from '@redocly/theme/icons/ClaudeIcon/ClaudeIcon';
13
+ import { CodexIcon } from '@redocly/theme/icons/CodexIcon/CodexIcon';
12
14
  import { CopyIcon } from '@redocly/theme/icons/CopyIcon/CopyIcon';
13
15
  import { CheckmarkFilledIcon } from '@redocly/theme/icons/CheckmarkFilledIcon/CheckmarkFilledIcon';
14
16
  import { ChevronDownIcon } from '@redocly/theme/icons/ChevronDownIcon/ChevronDownIcon';
15
17
  import { ChevronUpIcon } from '@redocly/theme/icons/ChevronUpIcon/ChevronUpIcon';
16
18
  import { useConnectMCPButton, useOutsideClick, useThemeHooks } from '@redocly/theme/core/hooks';
19
+ import { DEFAULT_MCP_OPTIONS } from '@redocly/theme/core/constants';
17
20
 
18
21
  type MenuOption = {
19
22
  key: MCPOption;
@@ -41,6 +44,22 @@ const MENU_OPTIONS: MenuOption[] = [
41
44
  descriptionTranslationKey: 'page.actions.connectMcp.vscodeDescription',
42
45
  descriptionDefault: 'Install MCP server on VS Code',
43
46
  },
47
+ {
48
+ key: 'claude-code',
49
+ icon: ClaudeIcon,
50
+ titleTranslationKey: 'page.actions.connectMcp.claudeCode',
51
+ titleDefault: 'Connect to Claude Code',
52
+ descriptionTranslationKey: 'page.actions.connectMcp.claudeCodeDescription',
53
+ descriptionDefault: 'Copy the Claude Code CLI command',
54
+ },
55
+ {
56
+ key: 'codex',
57
+ icon: CodexIcon,
58
+ titleTranslationKey: 'page.actions.connectMcp.codex',
59
+ titleDefault: 'Connect to Codex CLI',
60
+ descriptionTranslationKey: 'page.actions.connectMcp.codexDescription',
61
+ descriptionDefault: 'Copy the Codex CLI command',
62
+ },
44
63
  {
45
64
  key: 'copy',
46
65
  icon: CopyIcon,
@@ -51,6 +70,9 @@ const MENU_OPTIONS: MenuOption[] = [
51
70
  },
52
71
  ];
53
72
 
73
+ // Copy actions keep the dropdown open so the copied checkmark is visible.
74
+ const COPY_OPTIONS: MCPOption[] = ['claude-code', 'codex', 'copy'];
75
+
54
76
  export type ConnectMCPButtonProps = {
55
77
  placement?: 'top' | 'bottom';
56
78
  alignment?: 'start' | 'end';
@@ -60,14 +82,14 @@ export type ConnectMCPButtonProps = {
60
82
  export function ConnectMCPButton({
61
83
  placement = 'bottom',
62
84
  alignment = 'end',
63
- options = ['cursor', 'vscode', 'copy'],
85
+ options = DEFAULT_MCP_OPTIONS,
64
86
  }: ConnectMCPButtonProps): JSX.Element | null {
65
87
  const { useTranslate } = useThemeHooks();
66
88
  const { translate } = useTranslate();
67
89
  const [isOpen, setIsOpen] = useState(false);
68
90
  const wrapperRef = useRef<HTMLDivElement>(null);
69
91
 
70
- const { isCopied, visibleOptions, handleAction } = useConnectMCPButton({ options });
92
+ const { copiedOption, visibleOptions, handleAction } = useConnectMCPButton({ options });
71
93
 
72
94
  useOutsideClick(wrapperRef, () => setIsOpen(false));
73
95
 
@@ -78,13 +100,13 @@ export function ConnectMCPButton({
78
100
  if (!primaryOption) return null;
79
101
 
80
102
  const PrimaryIcon = primaryOption.icon;
81
- // When copy is the primary action there is no dropdown item to show the copied state, so the
82
- // primary button itself must reflect it.
83
- const showPrimaryCopied = primaryOption.key === 'copy' && isCopied;
103
+ const showPrimaryCopied = copiedOption === primaryOption.key;
84
104
 
85
105
  const handleMenuAction = (key: MCPOption) => {
86
106
  handleAction(key);
87
- setIsOpen(false);
107
+ if (!COPY_OPTIONS.includes(key)) {
108
+ setIsOpen(false);
109
+ }
88
110
  };
89
111
 
90
112
  return (
@@ -121,7 +143,7 @@ export function ConnectMCPButton({
121
143
  <DropdownMenu>
122
144
  {dropdownOptions.map((option) => {
123
145
  const Icon = option.icon;
124
- const showCheckmark = option.key === 'copy' && isCopied;
146
+ const showCheckmark = copiedOption === option.key;
125
147
 
126
148
  return (
127
149
  <DropdownMenuItem key={option.key} onAction={() => handleMenuAction(option.key)}>
@@ -162,17 +184,25 @@ const SplitButtonGroup = styled.div`
162
184
  align-items: stretch;
163
185
  `;
164
186
 
187
+ /* Doubled selectors outrank the base button class in any style injection order,
188
+ and margin: 0 keeps themed button margins from splitting the group apart. */
165
189
  const PrimaryButton = styled(Button)`
166
- --button-gap: var(--connect-mcp-button-gap);
167
- border-top-right-radius: 0;
168
- border-bottom-right-radius: 0;
190
+ && {
191
+ --button-gap: var(--connect-mcp-button-gap);
192
+ margin: 0;
193
+ border-top-right-radius: 0;
194
+ border-bottom-right-radius: 0;
195
+ }
169
196
  `;
170
197
 
171
198
  const ChevronButton = styled(Button)`
172
- border-top-left-radius: 0;
173
- border-bottom-left-radius: 0;
174
- border-left: none;
175
- padding-inline: var(--connect-mcp-button-chevron-padding-inline);
199
+ && {
200
+ margin: 0;
201
+ border-top-left-radius: 0;
202
+ border-bottom-left-radius: 0;
203
+ border-left: none;
204
+ padding-inline: var(--connect-mcp-button-chevron-padding-inline);
205
+ }
176
206
  `;
177
207
 
178
208
  const DropdownWrapper = styled.div<{ $placement?: string; $alignment?: string }>`
@@ -0,0 +1,263 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import styled from 'styled-components';
3
+
4
+ import type { JSX, ReactNode } from 'react';
5
+ import type { TFunction } from '@redocly/theme/core/types';
6
+
7
+ import { useMCPConfig, useThemeHooks } from '@redocly/theme/core/hooks';
8
+ import {
9
+ generateClaudeCodeCommand,
10
+ generateClaudeDesktopConfig,
11
+ generateCodexCommand,
12
+ generateMcpInstallPrompt,
13
+ } from '@redocly/theme/core/utils';
14
+ import { CodeBlock } from '@redocly/theme/components/CodeBlock/CodeBlock';
15
+ import { ClaudeIcon } from '@redocly/theme/icons/ClaudeIcon/ClaudeIcon';
16
+ import { ChatGptIcon } from '@redocly/theme/icons/ChatGptIcon/ChatGptIcon';
17
+ import { Tabs, TabsSize } from '@redocly/theme/markdoc/components/Tabs/Tabs';
18
+ import { TabItemFragment } from '@redocly/theme/markdoc/components/Tabs/TabItemFragment';
19
+ import { NumberedList } from '@redocly/theme/markdoc/components/NumberedList/NumberedList';
20
+ import { NumberedItem } from '@redocly/theme/markdoc/components/NumberedList/NumberedItem';
21
+
22
+ export type McpConnectionTab = 'claude' | 'chatgpt' | 'json';
23
+
24
+ export type McpConnectionTabsProps = {
25
+ options?: McpConnectionTab[];
26
+ className?: string;
27
+ };
28
+
29
+ const DEFAULT_TAB_OPTIONS: McpConnectionTab[] = ['claude', 'chatgpt', 'json'];
30
+
31
+ const TAB_LABELS: Record<
32
+ McpConnectionTab,
33
+ { translationKey: string; defaultLabel: string; icon?: React.ComponentType }
34
+ > = {
35
+ claude: {
36
+ translationKey: 'page.mcpNotAllowed.tabs.claude',
37
+ defaultLabel: 'Claude',
38
+ icon: ClaudeIcon,
39
+ },
40
+ chatgpt: {
41
+ translationKey: 'page.mcpNotAllowed.tabs.chatgpt',
42
+ defaultLabel: 'ChatGPT',
43
+ icon: ChatGptIcon,
44
+ },
45
+ json: { translationKey: 'page.mcpNotAllowed.tabs.json', defaultLabel: 'JSON' },
46
+ };
47
+
48
+ type TabClient = { headline: string; content: ReactNode };
49
+
50
+ // The report control is docs feedback, not useful on the connection page.
51
+ const CODE_BLOCK_HEADER = { controls: { copy: {}, report: { hidden: true } } };
52
+
53
+ export function McpConnectionTabs({
54
+ options = DEFAULT_TAB_OPTIONS,
55
+ className,
56
+ }: McpConnectionTabsProps): JSX.Element | null {
57
+ const { useTranslate } = useThemeHooks();
58
+ const { translate } = useTranslate();
59
+ const { serverName, serverUrl, isMcpDisabled } = useMCPConfig();
60
+ // The SSR markup of error pages is cached with the build-time origin, so the
61
+ // tabs remount after hydration to pick up the real browser origin.
62
+ const [hydrated, setHydrated] = useState(false);
63
+
64
+ useEffect(() => setHydrated(true), []);
65
+
66
+ if (isMcpDisabled) {
67
+ return null;
68
+ }
69
+
70
+ const connection = { serverName, url: serverUrl };
71
+
72
+ return (
73
+ <McpConnectionTabsWrapper
74
+ key={hydrated ? 'client' : 'ssr'}
75
+ data-component-name="MCP/McpConnectionTabs"
76
+ data-testid="mcp-connection-tabs"
77
+ className={className}
78
+ >
79
+ <Tabs size={TabsSize.SMALL}>
80
+ {options.map((option) => {
81
+ const { translationKey, defaultLabel, icon: TabIcon } = TAB_LABELS[option];
82
+
83
+ return (
84
+ <TabItemFragment
85
+ key={option}
86
+ label={translate(translationKey, defaultLabel)}
87
+ icon={TabIcon ? <TabIcon /> : undefined}
88
+ >
89
+ {renderTabContent(option, connection, translate)}
90
+ </TabItemFragment>
91
+ );
92
+ })}
93
+ </Tabs>
94
+ </McpConnectionTabsWrapper>
95
+ );
96
+ }
97
+
98
+ function renderTabContent(
99
+ option: McpConnectionTab,
100
+ connection: { serverName: string; url: string },
101
+ translate: TFunction,
102
+ ): ReactNode {
103
+ const terminalHint = (
104
+ <TabHint>
105
+ {translate('page.mcpNotAllowed.snippets.terminal.hint', 'Run this command in your terminal:')}
106
+ </TabHint>
107
+ );
108
+
109
+ const urlBlock = <CodeBlock lang="text" source={connection.url} header={CODE_BLOCK_HEADER} />;
110
+
111
+ const assistantPromptClient: TabClient = {
112
+ headline: translate('page.mcpNotAllowed.snippets.prompt.headline', 'Prompt'),
113
+ content: (
114
+ <>
115
+ <TabHint>
116
+ {translate(
117
+ 'page.mcpNotAllowed.snippets.prompt.hint',
118
+ 'Or paste this prompt into the chat to have it add the server for you:',
119
+ )}
120
+ </TabHint>
121
+ <CodeBlock
122
+ lang="text"
123
+ source={generateMcpInstallPrompt(connection)}
124
+ header={CODE_BLOCK_HEADER}
125
+ wrapContents
126
+ />
127
+ </>
128
+ ),
129
+ };
130
+
131
+ switch (option) {
132
+ case 'claude':
133
+ return renderClientList([
134
+ {
135
+ headline: translate('page.mcpNotAllowed.snippets.claudeCode.headline', 'Claude Code'),
136
+ content: (
137
+ <>
138
+ {terminalHint}
139
+ <CodeBlock
140
+ lang="shell"
141
+ source={generateClaudeCodeCommand(connection)}
142
+ header={CODE_BLOCK_HEADER}
143
+ wrapContents
144
+ />
145
+ </>
146
+ ),
147
+ },
148
+ {
149
+ headline: translate(
150
+ 'page.mcpNotAllowed.snippets.claudeDesktop.headline',
151
+ 'Claude Desktop',
152
+ ),
153
+ content: (
154
+ <>
155
+ <TabHint>
156
+ {translate(
157
+ 'page.mcpNotAllowed.snippets.claudeDesktop.instruction',
158
+ 'In Claude Desktop, open Settings → Developer → Edit Config, add this to the configuration file, and restart Claude Desktop:',
159
+ )}
160
+ </TabHint>
161
+ <CodeBlock
162
+ lang="json"
163
+ source={generateClaudeDesktopConfig(connection)}
164
+ header={CODE_BLOCK_HEADER}
165
+ />
166
+ </>
167
+ ),
168
+ },
169
+ ]);
170
+ case 'chatgpt':
171
+ return renderClientList([
172
+ {
173
+ headline: translate('page.mcpNotAllowed.snippets.codexCli.headline', 'Codex CLI'),
174
+ content: (
175
+ <>
176
+ {terminalHint}
177
+ <CodeBlock
178
+ lang="shell"
179
+ source={generateCodexCommand(connection)}
180
+ header={CODE_BLOCK_HEADER}
181
+ wrapContents
182
+ />
183
+ </>
184
+ ),
185
+ },
186
+ {
187
+ headline: translate(
188
+ 'page.mcpNotAllowed.snippets.chatgptApp.headline',
189
+ 'ChatGPT desktop app',
190
+ ),
191
+ content: (
192
+ <>
193
+ <TabHint>
194
+ {translate(
195
+ 'page.mcpNotAllowed.snippets.chatgptApp.instruction',
196
+ 'In the ChatGPT desktop app, go to Settings → Plugins → MCPs and add a server with the "Streamable HTTP" type and this URL (restart the app afterwards):',
197
+ )}
198
+ </TabHint>
199
+ {urlBlock}
200
+ </>
201
+ ),
202
+ },
203
+ assistantPromptClient,
204
+ ]);
205
+ case 'json':
206
+ return (
207
+ <>
208
+ <TabHint>
209
+ {translate(
210
+ 'page.mcpNotAllowed.snippets.json.hint',
211
+ 'Add this server entry to your MCP client configuration file:',
212
+ )}
213
+ </TabHint>
214
+ <CodeBlock
215
+ lang="json"
216
+ source={JSON.stringify(
217
+ { [connection.serverName]: { url: connection.url, description: 'MCP Server' } },
218
+ null,
219
+ 2,
220
+ )}
221
+ header={CODE_BLOCK_HEADER}
222
+ />
223
+ </>
224
+ );
225
+ }
226
+ }
227
+
228
+ function renderClientList(clients: TabClient[]): ReactNode {
229
+ return (
230
+ <NumberedList>
231
+ {clients.map((client, index) => (
232
+ <NumberedItem
233
+ key={index}
234
+ type="dot"
235
+ size="small"
236
+ headline={client.headline}
237
+ index={index + 1}
238
+ isLast={index === clients.length - 1}
239
+ >
240
+ {client.content}
241
+ </NumberedItem>
242
+ ))}
243
+ </NumberedList>
244
+ );
245
+ }
246
+
247
+ const McpConnectionTabsWrapper = styled.div`
248
+ --md-numbered-list-margin: 0 0 var(--spacing-sm);
249
+
250
+ font-size: var(--page-mcp-405-snippets-font-size);
251
+ line-height: var(--page-mcp-405-snippets-line-height);
252
+ text-align: left;
253
+
254
+ /* The copy control floats over the code, so wrapped lines need room to clear it. */
255
+ pre[data-testid='source-code'] {
256
+ padding-right: var(--page-mcp-405-snippets-code-padding-right);
257
+ }
258
+ `;
259
+
260
+ const TabHint = styled.p`
261
+ margin: 0 0 var(--spacing-xs);
262
+ color: var(--page-mcp-405-snippets-hint-text-color);
263
+ `;
@@ -1 +1,11 @@
1
+ import type { MCPOption } from '../types/mcp';
2
+
1
3
  export const DEFAULT_MCP_SERVER_NAME = 'mcp-server';
4
+
5
+ export const DEFAULT_MCP_OPTIONS: MCPOption[] = [
6
+ 'cursor',
7
+ 'vscode',
8
+ 'claude-code',
9
+ 'codex',
10
+ 'copy',
11
+ ];
@@ -8,6 +8,15 @@ type HeadingEntry = {
8
8
  [key: string]: IntersectionObserverEntry;
9
9
  };
10
10
 
11
+ type ClickScroll = {
12
+ target: HTMLElement;
13
+ scrollMargin: number;
14
+ closestDistance: number;
15
+ reached: boolean;
16
+ };
17
+
18
+ const TARGET_TOLERANCE = 2;
19
+
11
20
  export function useActiveHeading(
12
21
  contentElement: HTMLDivElement | null,
13
22
  displayedHeadings?: Array<{ id: string; depth: number } | null>,
@@ -17,11 +26,14 @@ export function useActiveHeading(
17
26
  const headingElementsRef = useRef<HeadingEntry>({});
18
27
  const displayedHeadingsRef = useRef(displayedHeadings);
19
28
  const lockObserver = useRef<boolean>(false);
29
+ const clickScroll = useRef<ClickScroll | null>(null);
20
30
  const location = useLocation();
21
31
 
22
32
  useEffect(() => {
23
33
  setHeading(undefined);
24
34
  headingElementsRef.current = {};
35
+ clickScroll.current = null;
36
+ lockObserver.current = false;
25
37
  }, [location.pathname]);
26
38
 
27
39
  useEffect(() => {
@@ -133,10 +145,6 @@ export function useActiveHeading(
133
145
 
134
146
  const intersectionCallback = useCallback(
135
147
  (entries: IntersectionObserverEntry[]) => {
136
- if (lockObserver.current) {
137
- return;
138
- }
139
-
140
148
  headingElementsRef.current = entries.reduce(
141
149
  (map: HeadingEntry, entry: IntersectionObserverEntry) => {
142
150
  map[entry.target.id] = entry;
@@ -145,6 +153,10 @@ export function useActiveHeading(
145
153
  headingElementsRef.current,
146
154
  );
147
155
 
156
+ if (lockObserver.current) {
157
+ return;
158
+ }
159
+
148
160
  if (isTopOfPage()) {
149
161
  const newHeading = headingElements[0]?.id || undefined;
150
162
  setHeading(newHeading);
@@ -202,22 +214,71 @@ export function useActiveHeading(
202
214
  [getIndexFromId, headingElements, getTriggerOffset, findActiveHeaderByVirtualPosition],
203
215
  );
204
216
 
217
+ const releaseClickScroll = useCallback(() => {
218
+ clickScroll.current = null;
219
+ lockObserver.current = false;
220
+ }, []);
221
+
222
+ const trackClickScroll = useCallback(() => {
223
+ const clickScrollState = clickScroll.current;
224
+ if (!clickScrollState) {
225
+ return;
226
+ }
227
+
228
+ const { top } = clickScrollState.target.getBoundingClientRect();
229
+
230
+ if (clickScrollState.reached) {
231
+ if (top >= 0 && top <= getTriggerOffset()) {
232
+ return;
233
+ }
234
+ } else {
235
+ const distance = Math.abs(top - clickScrollState.scrollMargin);
236
+
237
+ if (distance <= TARGET_TOLERANCE) {
238
+ clickScrollState.reached = true;
239
+ return;
240
+ }
241
+
242
+ if (distance <= clickScrollState.closestDistance + TARGET_TOLERANCE) {
243
+ clickScrollState.closestDistance = Math.min(clickScrollState.closestDistance, distance);
244
+ return;
245
+ }
246
+ }
247
+
248
+ releaseClickScroll();
249
+ intersectionCallback([]);
250
+ }, [releaseClickScroll, intersectionCallback, getTriggerOffset]);
251
+
205
252
  const handleHeadingClick = useCallback(
206
253
  (headingId: string) => {
207
- lockObserver.current = true;
254
+ releaseClickScroll();
208
255
  setHeading(headingId);
209
256
 
210
- const headingElement = headingElements.find((el) => el.id === headingId);
211
- if (headingElement && headingElement.scrollIntoView) {
212
- headingElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
257
+ const target =
258
+ document.getElementById(headingId) || headingElements.find((el) => el.id === headingId);
259
+
260
+ if (!target?.scrollIntoView) {
261
+ return;
262
+ }
263
+
264
+ target.scrollIntoView({ behavior: 'smooth', block: 'start' });
265
+
266
+ if (!target.getClientRects().length) {
267
+ return;
213
268
  }
214
269
 
215
- setTimeout(() => {
216
- headingElementsRef.current = {};
217
- lockObserver.current = false;
218
- }, 300);
270
+ const scrollMargin = parseFloat(getComputedStyle(target).scrollMarginTop) || 0;
271
+ const distance = Math.abs(target.getBoundingClientRect().top - scrollMargin);
272
+
273
+ lockObserver.current = true;
274
+ clickScroll.current = {
275
+ target,
276
+ scrollMargin,
277
+ closestDistance: distance,
278
+ reached: distance <= TARGET_TOLERANCE,
279
+ };
219
280
  },
220
- [headingElements],
281
+ [headingElements, releaseClickScroll],
221
282
  );
222
283
 
223
284
  useEffect(() => {
@@ -268,7 +329,10 @@ export function useActiveHeading(
268
329
  });
269
330
 
270
331
  const handleScroll = () => {
271
- if (lockObserver.current) return;
332
+ if (clickScroll.current) {
333
+ trackClickScroll();
334
+ return;
335
+ }
272
336
 
273
337
  if (isTopOfPage()) {
274
338
  setHeading(headingElements[0]?.id || undefined);
@@ -281,7 +345,7 @@ export function useActiveHeading(
281
345
  observer.disconnect();
282
346
  window.removeEventListener('scroll', handleScroll);
283
347
  };
284
- }, [headingElements, intersectionCallback, getTriggerOffset]);
348
+ }, [headingElements, intersectionCallback, getTriggerOffset, trackClickScroll]);
285
349
 
286
350
  return { heading, handleHeadingClick };
287
351
  }
@@ -60,11 +60,13 @@ export function useActiveSectionId(
60
60
  }
61
61
 
62
62
  window.addEventListener('scroll', scrollListener, { capture: false });
63
- setTimeout(() => {
63
+ const initialRead = setTimeout(() => {
64
64
  scrollListener();
65
65
  }, 10);
66
66
 
67
67
  return () => {
68
+ clearTimeout(initialRead);
69
+ scrollListener.cancel();
68
70
  window.removeEventListener('scroll', scrollListener);
69
71
  };
70
72
  }, [location, heightOffset, scrollListener]);