@redocly/theme 0.70.0-next.2 → 0.70.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 (82) hide show
  1. package/lib/components/CodeBlock/CodeBlockControls.js +3 -5
  2. package/lib/components/Consent/Consent.d.ts +2 -0
  3. package/lib/components/Consent/Consent.js +17 -0
  4. package/lib/components/Consent/ConsentBanner.d.ts +2 -0
  5. package/lib/components/Consent/ConsentBanner.js +120 -0
  6. package/lib/components/Consent/ConsentDialog.d.ts +2 -0
  7. package/lib/components/Consent/ConsentDialog.js +128 -0
  8. package/lib/components/Consent/ConsentSettingsLink.d.ts +2 -0
  9. package/lib/components/Consent/ConsentSettingsLink.js +35 -0
  10. package/lib/components/Consent/variables.d.ts +1 -0
  11. package/lib/components/Consent/variables.js +31 -0
  12. package/lib/components/Footer/Footer.js +4 -1
  13. package/lib/components/JsonViewer/Helpers.d.ts +10 -2
  14. package/lib/components/JsonViewer/Helpers.js +29 -33
  15. package/lib/components/JsonViewer/JsonViewer.js +15 -18
  16. package/lib/components/Markdown/styles/base-table.js +14 -1
  17. package/lib/components/Markdown/variables.js +1 -0
  18. package/lib/components/Product/utils.js +1 -1
  19. package/lib/components/Search/SearchAiMessage.js +1 -1
  20. package/lib/components/Search/SearchAiResponse.js +1 -0
  21. package/lib/core/constants/consent.d.ts +2 -0
  22. package/lib/core/constants/consent.js +30 -0
  23. package/lib/core/constants/index.d.ts +1 -0
  24. package/lib/core/constants/index.js +1 -0
  25. package/lib/core/hooks/index.d.ts +2 -0
  26. package/lib/core/hooks/index.js +2 -0
  27. package/lib/core/hooks/use-consent-dialog.d.ts +12 -0
  28. package/lib/core/hooks/use-consent-dialog.js +36 -0
  29. package/lib/core/hooks/use-consent-telemetry.d.ts +1 -0
  30. package/lib/core/hooks/use-consent-telemetry.js +42 -0
  31. package/lib/core/hooks/use-theme-hooks.js +14 -0
  32. package/lib/core/styles/global.js +42 -40
  33. package/lib/core/types/consent.d.ts +54 -0
  34. package/lib/core/types/consent.js +3 -0
  35. package/lib/core/types/hooks.d.ts +2 -0
  36. package/lib/core/types/index.d.ts +1 -0
  37. package/lib/core/types/l10n.d.ts +1 -1
  38. package/lib/core/utils/consent-settings-link-placement.d.ts +3 -0
  39. package/lib/core/utils/consent-settings-link-placement.js +15 -0
  40. package/lib/core/utils/index.d.ts +1 -0
  41. package/lib/core/utils/index.js +1 -0
  42. package/lib/core/utils/mcp.js +1 -1
  43. package/lib/core/utils/menu.js +6 -5
  44. package/lib/core/utils/urls.js +3 -1
  45. package/lib/icons/ExpandCollapseIcon/ExpandCollapseIcon.d.ts +29 -0
  46. package/lib/icons/ExpandCollapseIcon/ExpandCollapseIcon.js +81 -0
  47. package/lib/index.d.ts +1 -0
  48. package/lib/index.js +1 -0
  49. package/package.json +3 -3
  50. package/src/components/CodeBlock/CodeBlockControls.tsx +4 -7
  51. package/src/components/Consent/Consent.tsx +18 -0
  52. package/src/components/Consent/ConsentBanner.tsx +110 -0
  53. package/src/components/Consent/ConsentDialog.tsx +210 -0
  54. package/src/components/Consent/ConsentSettingsLink.tsx +44 -0
  55. package/src/components/Consent/variables.ts +28 -0
  56. package/src/components/Footer/Footer.tsx +2 -0
  57. package/src/components/JsonViewer/Helpers.tsx +46 -51
  58. package/src/components/JsonViewer/JsonViewer.tsx +23 -25
  59. package/src/components/Markdown/styles/base-table.ts +14 -1
  60. package/src/components/Markdown/variables.ts +1 -0
  61. package/src/components/Product/utils.ts +1 -1
  62. package/src/components/Search/SearchAiMessage.tsx +1 -1
  63. package/src/components/Search/SearchAiResponse.tsx +1 -0
  64. package/src/core/constants/consent.ts +29 -0
  65. package/src/core/constants/index.ts +1 -0
  66. package/src/core/hooks/__mocks__/use-theme-hooks.ts +15 -0
  67. package/src/core/hooks/index.ts +2 -0
  68. package/src/core/hooks/use-consent-dialog.ts +51 -0
  69. package/src/core/hooks/use-consent-telemetry.ts +56 -0
  70. package/src/core/hooks/use-theme-hooks.ts +14 -0
  71. package/src/core/styles/global.ts +2 -0
  72. package/src/core/types/consent.ts +51 -0
  73. package/src/core/types/hooks.ts +2 -0
  74. package/src/core/types/index.ts +1 -0
  75. package/src/core/types/l10n.ts +22 -1
  76. package/src/core/utils/consent-settings-link-placement.ts +18 -0
  77. package/src/core/utils/index.ts +1 -0
  78. package/src/core/utils/mcp.ts +1 -1
  79. package/src/core/utils/menu.ts +5 -6
  80. package/src/core/utils/urls.ts +3 -1
  81. package/src/icons/ExpandCollapseIcon/ExpandCollapseIcon.tsx +56 -0
  82. package/src/index.ts +1 -0
@@ -7,40 +7,50 @@ import { Button } from '@redocly/theme/components/Button/Button';
7
7
  import { ShowMoreToggleButton } from '@redocly/theme/components/Button/ShowMoreToggleButton';
8
8
  import { ChevronDownIcon } from '@redocly/theme/icons/ChevronDownIcon/ChevronDownIcon';
9
9
 
10
+ export type JsonFold = {
11
+ isExpanded: (path: string, level: number) => boolean;
12
+ setExpanded: (path: string, expanded: boolean) => void;
13
+ };
14
+
15
+ type Foldable = [path: string, level: number];
16
+
17
+ export function isFoldable(value: unknown): value is Record<string, unknown> {
18
+ return (
19
+ typeof value === 'object' &&
20
+ value !== null &&
21
+ Object.values(value).some((item) => item !== undefined)
22
+ );
23
+ }
24
+
25
+ export function collectFoldables(value: unknown, path = '', level = 1): Foldable[] {
26
+ if (!isFoldable(value)) return [];
27
+ return Object.entries(value).flatMap(([key, child]): Foldable[] => {
28
+ const childPath = `${path}/${key}`;
29
+ return isFoldable(child)
30
+ ? [[childPath, level], ...collectFoldables(child, childPath, level + 1)]
31
+ : [];
32
+ });
33
+ }
34
+
10
35
  export function JsonValue(props: {
11
36
  /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
12
37
  value: any;
13
38
  level: number;
39
+ path: string;
14
40
  standalone?: boolean; // if true, will render the opening and closing punctuation (when it's an item inside array or top-level)
15
- defaultExpandLevel: number;
16
- expandAllSignal: boolean | undefined;
41
+ fold: JsonFold;
17
42
  maxStringLength?: number;
18
43
  }): React.ReactNode {
19
- const { standalone, defaultExpandLevel, level, value, expandAllSignal, maxStringLength } = props;
44
+ const { standalone, fold, level, path, value, maxStringLength } = props;
20
45
  const indent = ' '.repeat(level * 2);
21
-
22
- const [isExpandedState, setExpanded] = React.useState(
23
- expandAllSignal ?? level < defaultExpandLevel,
24
- );
25
- const isExpanded = expandAllSignal !== undefined && level > 0 ? expandAllSignal : isExpandedState;
26
- const [prevExpandAllSignal, setPrevExpandAllSignal] = React.useState(expandAllSignal);
27
- const [prevLevel, setPrevLevel] = React.useState(level);
28
-
29
- if (prevExpandAllSignal !== expandAllSignal || prevLevel !== level) {
30
- setPrevExpandAllSignal(expandAllSignal);
31
- setPrevLevel(level);
32
- if (expandAllSignal !== undefined && !(expandAllSignal === false && level === 0)) {
33
- setExpanded(expandAllSignal);
34
- }
35
- }
46
+ const isExpanded = fold.isExpanded(path, level);
36
47
 
37
48
  const keys: string[] =
38
49
  (value && Object.keys(value).filter((key) => value[key] !== undefined)) || []; // filter out undefined properties
39
50
 
40
51
  const valueType = typeof value;
41
- const isPrimitive = (valueType !== 'object' && value !== null) || keys.length === 0;
42
52
 
43
- if (isPrimitive) {
53
+ if (!isFoldable(value)) {
44
54
  const isOversizedString =
45
55
  typeof value === 'string' && maxStringLength !== undefined && value.length > maxStringLength;
46
56
  return (
@@ -64,9 +74,9 @@ export function JsonValue(props: {
64
74
  <JsonValue
65
75
  value={item}
66
76
  level={level + 1}
77
+ path={`${path}/${index}`}
67
78
  standalone={true}
68
- defaultExpandLevel={defaultExpandLevel}
69
- expandAllSignal={expandAllSignal}
79
+ fold={fold}
70
80
  maxStringLength={maxStringLength}
71
81
  />
72
82
  {index < value.length - 1 ? ',\n' : null}
@@ -78,8 +88,8 @@ export function JsonValue(props: {
78
88
  name={key}
79
89
  value={value[key]}
80
90
  level={level + 1}
81
- defaultExpandLevel={defaultExpandLevel}
82
- expandAllSignal={expandAllSignal}
91
+ path={`${path}/${key}`}
92
+ fold={fold}
83
93
  maxStringLength={maxStringLength}
84
94
  />
85
95
  {index < keys.length - 1 ? ',\n' : null}
@@ -90,7 +100,7 @@ export function JsonValue(props: {
90
100
  <>
91
101
  {indent}
92
102
  {level > 0 ? (
93
- <ExpandIcon onClick={() => setExpanded((v) => !v)} isExpanded={isExpanded} />
103
+ <ExpandIcon onClick={() => fold.setExpanded(path, !isExpanded)} isExpanded={isExpanded} />
94
104
  ) : null}
95
105
  {openingPunctuation}
96
106
  {isExpanded ? '\n' : null}
@@ -119,33 +129,18 @@ function JsonKeyValue(props: {
119
129
  /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
120
130
  value: any;
121
131
  level: number;
122
- defaultExpandLevel: number;
123
- expandAllSignal: boolean | undefined;
132
+ path: string;
133
+ fold: JsonFold;
124
134
  maxStringLength?: number;
125
135
  }): React.ReactNode {
126
- const { defaultExpandLevel, level, name, value, expandAllSignal, maxStringLength } = props;
136
+ const { fold, level, name, path, value, maxStringLength } = props;
127
137
  const indent = ' '.repeat(level * 2);
128
- const keys: string[] =
129
- (value && Object.keys(value).filter((key) => value[key] !== undefined)) || []; // filter out undefined properties
130
-
131
- const isCollapsible = typeof value === 'object' && value !== null && keys.length > 0;
132
- const [isExpandedState, setExpanded] = React.useState(
133
- expandAllSignal ?? level < defaultExpandLevel,
134
- );
135
- const isExpanded = expandAllSignal !== undefined ? expandAllSignal : isExpandedState;
136
- const [prevExpandAllSignal, setPrevExpandAllSignal] = React.useState(expandAllSignal);
137
-
138
- if (prevExpandAllSignal !== expandAllSignal) {
139
- setPrevExpandAllSignal(expandAllSignal);
140
- if (expandAllSignal !== undefined) {
141
- setExpanded(expandAllSignal);
142
- }
143
- }
138
+ const isExpanded = fold.isExpanded(path, level);
144
139
 
145
140
  const openingPunctuation = Array.isArray(value) ? '[' : '{';
146
141
  const closingPunctuation = Array.isArray(value) ? ']' : '}';
147
142
 
148
- const valueElement = isCollapsible ? (
143
+ const valueElement = isFoldable(value) ? (
149
144
  <>
150
145
  {openingPunctuation}
151
146
  {isExpanded ? (
@@ -154,8 +149,8 @@ function JsonKeyValue(props: {
154
149
  <JsonValue
155
150
  value={value}
156
151
  level={level}
157
- defaultExpandLevel={defaultExpandLevel}
158
- expandAllSignal={expandAllSignal}
152
+ path={path}
153
+ fold={fold}
159
154
  maxStringLength={maxStringLength}
160
155
  />
161
156
  {'\n'}
@@ -170,8 +165,8 @@ function JsonKeyValue(props: {
170
165
  <JsonValue
171
166
  value={value}
172
167
  level={level}
173
- defaultExpandLevel={defaultExpandLevel}
174
- expandAllSignal={expandAllSignal}
168
+ path={path}
169
+ fold={fold}
175
170
  maxStringLength={maxStringLength}
176
171
  />
177
172
  );
@@ -179,8 +174,8 @@ function JsonKeyValue(props: {
179
174
  return (
180
175
  <>
181
176
  {indent}
182
- {isCollapsible ? (
183
- <ExpandIcon onClick={() => setExpanded((v) => !v)} isExpanded={isExpanded} />
177
+ {isFoldable(value) ? (
178
+ <ExpandIcon onClick={() => fold.setExpanded(path, !isExpanded)} isExpanded={isExpanded} />
184
179
  ) : null}
185
180
  <span className="property token string">{`"${name}"`}</span>: {valueElement}
186
181
  </>
@@ -5,7 +5,8 @@ import type { JSX } from 'react';
5
5
  import type { CodeBlockControlsProps } from '@redocly/theme/components/CodeBlock/CodeBlockControls';
6
6
 
7
7
  import { CodeBlock } from '@redocly/theme/components/CodeBlock/CodeBlock';
8
- import { JsonValue } from '@redocly/theme/components/JsonViewer/Helpers';
8
+ import { collectFoldables, JsonValue } from '@redocly/theme/components/JsonViewer/Helpers';
9
+ import type { JsonFold } from '@redocly/theme/components/JsonViewer/Helpers';
9
10
 
10
11
  export type PanelType = 'request' | 'responses' | 'request-samples' | 'response-samples';
11
12
 
@@ -33,25 +34,20 @@ function JsonComponent({
33
34
  controls = {},
34
35
  maxStringLength,
35
36
  }: JsonProps): JSX.Element {
36
- const showFoldingButtons =
37
- data && Object.values(data).some((value) => typeof value === 'object' && value !== null);
37
+ const defaultExpandLevel = Math.max(1, expandLevel);
38
+ const foldables = React.useMemo(() => collectFoldables(data), [data]);
39
+ const [expandedPaths, setExpandedPaths] = React.useState<Record<string, boolean>>({});
38
40
 
39
- const [expandAllSignal, setExpandAllSignal] = React.useState<boolean | undefined>(undefined);
40
-
41
- const expandAll = () => {
42
- setExpandAllSignal(true);
43
- onPanelToggle?.(true);
44
- setTimeout(() => {
45
- setExpandAllSignal(undefined);
46
- });
41
+ const fold: JsonFold = {
42
+ isExpanded: (path, level) => expandedPaths[path] ?? level < defaultExpandLevel,
43
+ setExpanded: (path, expanded) => setExpandedPaths((prev) => ({ ...prev, [path]: expanded })),
47
44
  };
45
+ const hasFoldables = foldables.length > 0;
46
+ const allExpanded = foldables.every(([path, level]) => fold.isExpanded(path, level));
48
47
 
49
- const collapseAll = () => {
50
- setExpandAllSignal(false);
51
- onPanelToggle?.(false);
52
- setTimeout(() => {
53
- setExpandAllSignal(undefined);
54
- });
48
+ const toggleAll = () => {
49
+ onPanelToggle?.(!allExpanded);
50
+ setExpandedPaths(Object.fromEntries(foldables.map(([path]) => [path, !allExpanded])));
55
51
  };
56
52
 
57
53
  const source = JSON.stringify(data, null, 2);
@@ -73,12 +69,14 @@ function JsonComponent({
73
69
  controls: controls && {
74
70
  ...controls,
75
71
  copy: { ...controls.copy, data, onClick: onCopyClick, handleOutside: true },
76
- expand: showFoldingButtons
77
- ? { ...controls.expand, onClick: expandAll }
78
- : undefined,
79
- collapse: showFoldingButtons
80
- ? { ...controls.collapse, onClick: collapseAll }
81
- : undefined,
72
+ expand:
73
+ hasFoldables && !allExpanded
74
+ ? { ...controls.expand, onClick: toggleAll }
75
+ : undefined,
76
+ collapse:
77
+ hasFoldables && allExpanded
78
+ ? { ...controls.collapse, onClick: toggleAll }
79
+ : undefined,
82
80
  },
83
81
  }
84
82
  : undefined
@@ -89,9 +87,9 @@ function JsonComponent({
89
87
  <JsonValue
90
88
  value={data}
91
89
  level={0}
90
+ path=""
92
91
  standalone={true}
93
- expandAllSignal={expandAllSignal}
94
- defaultExpandLevel={Math.max(1, expandLevel)}
92
+ fold={fold}
95
93
  maxStringLength={maxStringLength}
96
94
  />
97
95
  </FoldingWrap>
@@ -22,7 +22,7 @@ export const markdownBaseTableCss = css`
22
22
  color: var(--md-table-cell-text-color);
23
23
  font-size: var(--md-table-font-size);
24
24
  line-height: var(--md-table-line-height);
25
- vertical-align: top;
25
+ vertical-align: var(--md-table-cell-vertical-align);
26
26
  }
27
27
 
28
28
  thead td,
@@ -71,5 +71,18 @@ export const markdownBaseTableCss = css`
71
71
  th[align='right'] {
72
72
  text-align: right;
73
73
  }
74
+
75
+ th[valign='top'],
76
+ td[valign='top'] {
77
+ vertical-align: top;
78
+ }
79
+ th[valign='middle'],
80
+ td[valign='middle'] {
81
+ vertical-align: middle;
82
+ }
83
+ th[valign='bottom'],
84
+ td[valign='bottom'] {
85
+ vertical-align: bottom;
86
+ }
74
87
  }
75
88
  `;
@@ -51,6 +51,7 @@ export const markdown = css`
51
51
  --md-table-cell-text-color: var(--text-color-secondary); // @presenter Color
52
52
  --md-table-cell-padding: var(--spacing-xs) var(--spacing-sm); // @presenter Spacing
53
53
  --md-table-footer-cell-padding: var(--spacing-xxs) var(--spacing-sm); // @presenter Spacing
54
+ --md-table-cell-vertical-align: top;
54
55
 
55
56
  --md-table-head-bg-color: var(--md-table-bg-color); // @presenter Color
56
57
  --md-table-head-text-color: var(--text-color-primary); // @presenter Color
@@ -2,5 +2,5 @@ export function getProductClassName(productName: string): string {
2
2
  return `product-${productName
3
3
  .toLowerCase()
4
4
  .replace(/[^a-z0-9]+/g, '-')
5
- .replace(/^-+|-+$/g, '')}`;
5
+ .replace(/^-+|(?<!-)-+$/g, '')}`;
6
6
  }
@@ -347,7 +347,7 @@ const ResourceTagsWrapper = styled.div`
347
347
  gap: var(--search-ai-resource-tags-gap);
348
348
  `;
349
349
 
350
- const ResourceTag = styled(Tag)`
350
+ const ResourceTag = styled(Tag).attrs({ textTransform: 'none' })`
351
351
  .tag-default {
352
352
  --tag-color: var(--search-ai-resource-tag-text-color);
353
353
  max-width: 100%;
@@ -212,6 +212,7 @@ const ResourceTags = styled.div`
212
212
 
213
213
  const ResourceTag = styled(Tag).attrs({
214
214
  className: 'tag-resource',
215
+ textTransform: 'none',
215
216
  })`
216
217
  .tag-default {
217
218
  --tag-color: --search-ai-resource-tag-text-color;
@@ -0,0 +1,29 @@
1
+ import type { ConsentCategory } from '../types/consent';
2
+
3
+ export const CONSENT_CATEGORIES: readonly ConsentCategory[] = [
4
+ {
5
+ id: 'necessary',
6
+ locked: true,
7
+ titleTranslationKey: 'consent.category.necessary.title',
8
+ descriptionTranslationKey: 'consent.category.necessary.description',
9
+ titleDefault: 'Necessary',
10
+ descriptionDefault:
11
+ 'Needed for the site to work: your consent choice, your color mode, and anonymous usage data.',
12
+ },
13
+ {
14
+ id: 'analytics',
15
+ locked: false,
16
+ titleTranslationKey: 'consent.category.analytics.title',
17
+ descriptionTranslationKey: 'consent.category.analytics.description',
18
+ titleDefault: 'Analytics',
19
+ descriptionDefault: 'Helps us understand which pages are useful.',
20
+ },
21
+ {
22
+ id: 'marketing',
23
+ locked: false,
24
+ titleTranslationKey: 'consent.category.marketing.title',
25
+ descriptionTranslationKey: 'consent.category.marketing.description',
26
+ titleDefault: 'Marketing',
27
+ descriptionDefault: 'Lets us measure which campaigns bring visitors to this site.',
28
+ },
29
+ ];
@@ -10,3 +10,4 @@ export * from './mcp';
10
10
  export * from './feedback';
11
11
  export * from './heading';
12
12
  export * from './markdown';
13
+ export * from './consent';
@@ -19,12 +19,27 @@ export const useThemeHooks = vi.fn(() => ({
19
19
  sendMcpConnectionSnippetCopiedMessage: vi.fn(),
20
20
  sendPageActionsButtonClickedMessage: vi.fn(),
21
21
  sendColorModeSwitchedMessage: vi.fn(),
22
+ sendConsentChangedMessage: vi.fn(),
22
23
  })),
23
24
  useBreadcrumbs: vi.fn().mockReturnValue({ breadcrumbs: [], siblings: undefined }),
24
25
  useBanner: vi.fn(() => ({
25
26
  banner: undefined,
26
27
  dismissBanner: vi.fn(),
27
28
  })),
29
+ useConsent: vi.fn(() => ({
30
+ enabled: false,
31
+ config: undefined,
32
+ mode: undefined,
33
+ state: undefined,
34
+ decided: false,
35
+ gpc: false,
36
+ isDialogOpen: false,
37
+ openDialog: vi.fn(),
38
+ closeDialog: vi.fn(),
39
+ accept: vi.fn(),
40
+ reject: vi.fn(),
41
+ save: vi.fn(),
42
+ })),
28
43
  usePageSharedData: vi.fn().mockReturnValue({}),
29
44
  useCatalogClassic: vi.fn(() => ({
30
45
  groups: [],
@@ -22,6 +22,8 @@ export * from './menu/use-collapse';
22
22
  export * from './feedback/use-report-dialog';
23
23
  export * from './use-tabs';
24
24
  export * from './use-focus-trap';
25
+ export * from './use-consent-dialog';
26
+ export * from './use-consent-telemetry';
25
27
  export * from './use-language-picker';
26
28
  export * from './use-product-picker';
27
29
  export * from './search/use-search-dialog';
@@ -0,0 +1,51 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+
3
+ import type { RefObject } from 'react';
4
+ import type { ConsentCategory, ConsentCategoryId, UseConsentResult } from '../types/consent';
5
+
6
+ import { CONSENT_CATEGORIES } from '../constants/consent';
7
+ import { useFocusTrap } from './use-focus-trap';
8
+
9
+ export type ConsentDraft = Record<Exclude<ConsentCategoryId, 'necessary'>, boolean>;
10
+
11
+ export type UseConsentDialogResult = {
12
+ draft: ConsentDraft;
13
+ setCategory: (id: keyof ConsentDraft, value: boolean) => void;
14
+ dialogRef: RefObject<HTMLDivElement | null>;
15
+ close: () => void;
16
+ save: () => void;
17
+ categories: readonly ConsentCategory[];
18
+ };
19
+
20
+ export function useConsentDialog(consent: UseConsentResult): UseConsentDialogResult {
21
+ const [draft, setDraft] = useState<ConsentDraft>(() => ({
22
+ analytics: consent.state?.analytics ?? consent.mode === 'opt-out',
23
+ marketing: (consent.state?.marketing ?? consent.mode === 'opt-out') && !consent.gpc,
24
+ }));
25
+ const dialogRef = useRef<HTMLDivElement | null>(null);
26
+ const close = consent.closeDialog;
27
+ const closeRef = useRef(close);
28
+ closeRef.current = close;
29
+
30
+ useEffect(() => {
31
+ const previous = document.activeElement as HTMLElement | null;
32
+ const onKeyDown = (event: KeyboardEvent) => {
33
+ if (event.key === 'Escape') closeRef.current();
34
+ };
35
+ document.addEventListener('keydown', onKeyDown);
36
+ return () => {
37
+ document.removeEventListener('keydown', onKeyDown);
38
+ previous?.focus?.();
39
+ };
40
+ }, []);
41
+
42
+ useFocusTrap(dialogRef);
43
+
44
+ const setCategory = (id: keyof ConsentDraft, value: boolean) => {
45
+ setDraft((current) => ({ ...current, [id]: value }));
46
+ };
47
+
48
+ const save = () => consent.save(draft);
49
+
50
+ return { draft, setCategory, dialogRef, close, save, categories: CONSENT_CATEGORIES };
51
+ }
@@ -0,0 +1,56 @@
1
+ import { useEffect, useRef } from 'react';
2
+
3
+ import type { ConsentRuntime } from '../types/consent';
4
+
5
+ import { useThemeHooks } from './use-theme-hooks';
6
+ import { getBaseDataAttributes } from '../utils/telemetry/get-base-data-attributes';
7
+
8
+ type RealmOwnedRuntime = ConsentRuntime & {
9
+ mode: 'opt-in' | 'opt-out';
10
+ configuredMode: 'auto' | 'opt-in' | 'opt-out';
11
+ region: { source: 'timezone' | 'config' | 'none'; timeZone: string | null };
12
+ };
13
+
14
+ function isRealmOwned(runtime: ConsentRuntime): runtime is RealmOwnedRuntime {
15
+ return runtime.region.source !== 'provider';
16
+ }
17
+
18
+ function getConsentRuntime(): ConsentRuntime | undefined {
19
+ if (typeof window === 'undefined') return undefined;
20
+ return (window as Window & { redocly?: { consent?: ConsentRuntime } }).redocly?.consent;
21
+ }
22
+
23
+ // Sends one consent.changed event per visitor choice in the modes Realm owns.
24
+ export function useConsentTelemetry(): void {
25
+ const { useTelemetry } = useThemeHooks();
26
+ const telemetry = useTelemetry();
27
+ const telemetryRef = useRef(telemetry);
28
+ telemetryRef.current = telemetry;
29
+
30
+ useEffect(() => {
31
+ const found = getConsentRuntime();
32
+ const runtime = found && isRealmOwned(found) ? found : null;
33
+ if (!runtime) return;
34
+ return runtime.on('change', (state) => {
35
+ const choice =
36
+ state.analytics && state.marketing
37
+ ? 'accept'
38
+ : !state.analytics && !state.marketing
39
+ ? 'reject'
40
+ : 'partial';
41
+ const { origin, pathname, search, hash } = window.location;
42
+ const uri = `${origin}${pathname}${search}${hash}`;
43
+ telemetryRef.current.sendConsentChangedMessage([
44
+ {
45
+ ...getBaseDataAttributes('consent', 'consent'),
46
+ choice,
47
+ mode: runtime.mode,
48
+ configuredMode: runtime.configuredMode,
49
+ regionSource: runtime.region.source,
50
+ gpc: runtime.gpc,
51
+ },
52
+ getBaseDataAttributes(uri, 'page', uri),
53
+ ]);
54
+ });
55
+ }, []);
56
+ }
@@ -19,6 +19,20 @@ const fallbacks = {
19
19
  useOtelTelemetry: () => ({ send: () => {} }),
20
20
  useBreadcrumbs: () => ({ breadcrumbs: [], siblings: undefined }),
21
21
  useBanner: () => ({ banner: undefined, dismissBanner: () => {} }),
22
+ useConsent: () => ({
23
+ enabled: false,
24
+ config: undefined,
25
+ mode: undefined,
26
+ state: undefined,
27
+ decided: false,
28
+ gpc: false,
29
+ isDialogOpen: false,
30
+ openDialog: () => {},
31
+ closeDialog: () => {},
32
+ accept: () => {},
33
+ reject: () => {},
34
+ save: () => {},
35
+ }),
22
36
  useCodeHighlight: () => ({ highlight: (rawContent: string) => rawContent }),
23
37
  useUserMenu: () => ({}),
24
38
  usePageData: () => null,
@@ -28,6 +28,7 @@ import { code } from '@redocly/theme/components/CodeBlock/variables';
28
28
  import { productPicker } from '@redocly/theme/components/Product/variables';
29
29
  import { markdown } from '@redocly/theme/components/Markdown/variables';
30
30
  import { banner } from '@redocly/theme/components/Banner/variables';
31
+ import { consent } from '@redocly/theme/components/Consent/variables';
31
32
  import { markdownAccordion } from '@redocly/theme/markdoc/components/Accordion/variables';
32
33
  import { markdownTabs } from '@redocly/theme/markdoc/components/Tabs/variables';
33
34
  import { markdownNumberedList } from '@redocly/theme/markdoc/components/NumberedList/variables';
@@ -1336,6 +1337,7 @@ export const styles = css<{ palette?: string }>`
1336
1337
  ${badges}
1337
1338
  ${banner}
1338
1339
  ${borders}
1340
+ ${consent}
1339
1341
  ${breadcrumbs}
1340
1342
  ${button}
1341
1343
  ${aiAssistantButton}
@@ -0,0 +1,51 @@
1
+ import type { ConsentConfig } from '@redocly/config';
2
+ import type { TranslationKey } from './l10n';
3
+
4
+ export type ConsentCategoryId = 'necessary' | 'analytics' | 'marketing';
5
+
6
+ export type ConsentCategory = {
7
+ id: ConsentCategoryId;
8
+ locked: boolean;
9
+ titleTranslationKey: TranslationKey;
10
+ descriptionTranslationKey: TranslationKey;
11
+ titleDefault: string;
12
+ descriptionDefault: string;
13
+ };
14
+
15
+ export type ConsentState = { necessary: true; analytics: boolean; marketing: boolean };
16
+
17
+ export type ConsentChoice = { analytics?: boolean; marketing?: boolean };
18
+
19
+ export type ConsentProvider = 'onetrust' | 'cookiebot' | 'trustarc' | 'tcf';
20
+
21
+ export type ConsentRuntime = {
22
+ configuredMode: 'auto' | 'opt-in' | 'opt-out' | ConsentProvider;
23
+ mode: 'opt-in' | 'opt-out' | ConsentProvider;
24
+ state: ConsentState;
25
+ decided: boolean;
26
+ gpc: boolean;
27
+ dialogOpen: boolean;
28
+ region: { source: 'timezone' | 'config' | 'none' | 'provider'; timeZone: string | null };
29
+ accept: () => void;
30
+ reject: () => void;
31
+ save: (next: ConsentChoice) => void;
32
+ open: () => void;
33
+ close: () => void;
34
+ reload: () => void;
35
+ on: (event: 'change' | 'open' | 'close', fn: (state: ConsentState) => void) => () => void;
36
+ };
37
+
38
+ export type UseConsentResult = {
39
+ enabled: boolean;
40
+ config: ConsentConfig | undefined;
41
+ mode: ConsentRuntime['mode'] | undefined;
42
+ state: ConsentState | undefined;
43
+ decided: boolean;
44
+ gpc: boolean;
45
+ isDialogOpen: boolean;
46
+ openDialog: () => void;
47
+ closeDialog: () => void;
48
+ accept: () => void;
49
+ reject: () => void;
50
+ save: (next: ConsentChoice) => void;
51
+ };
@@ -40,6 +40,7 @@ import type {
40
40
  import type { SubmitFeedbackParams } from './feedback';
41
41
  import type { TFunction } from './l10n';
42
42
  import type { BreadcrumbItem } from './breadcrumb';
43
+ import type { UseConsentResult } from './consent';
43
44
 
44
45
  import type { AiSearchError } from '../constants/search';
45
46
 
@@ -90,6 +91,7 @@ export type ThemeHooks = {
90
91
  banner: ResolvedBannerConfig | undefined;
91
92
  dismissBanner: (content: string) => void;
92
93
  };
94
+ useConsent: () => UseConsentResult;
93
95
  useSearch: (
94
96
  product?: string,
95
97
  autoSearchDisabled?: boolean,
@@ -1,5 +1,6 @@
1
1
  export type * from './breadcrumb';
2
2
  export type * from './catalog';
3
+ export type * from './consent';
3
4
  export type * from './l10n';
4
5
  export type * from './mcp';
5
6
  export type * from './feedback';
@@ -513,7 +513,28 @@ export type TranslationKey =
513
513
  | 'diagram.zoomOut'
514
514
  | 'diagram.reset'
515
515
  | 'diagram.close'
516
- | 'diagram.viewer';
516
+ | 'diagram.viewer'
517
+ | 'consent.title'
518
+ | 'consent.message'
519
+ | 'consent.privacyPolicy'
520
+ | 'consent.customize'
521
+ | 'consent.rejectAll'
522
+ | 'consent.acceptAll'
523
+ | 'consent.acceptAllCookies'
524
+ | 'consent.savePreferences'
525
+ | 'consent.dialog.title'
526
+ | 'consent.dialog.description'
527
+ | 'consent.dialog.close'
528
+ | 'consent.alwaysOn'
529
+ | 'consent.alwaysOff'
530
+ | 'consent.gpcNotice'
531
+ | 'consent.category.necessary.title'
532
+ | 'consent.category.necessary.description'
533
+ | 'consent.category.analytics.title'
534
+ | 'consent.category.analytics.description'
535
+ | 'consent.category.marketing.title'
536
+ | 'consent.category.marketing.description'
537
+ | 'consent.settingsLink';
517
538
 
518
539
  export type Locale = { code: string; name: string };
519
540
 
@@ -0,0 +1,18 @@
1
+ import type { ConsentConfig, RedoclyConfig } from '@redocly/config';
2
+
3
+ export type SettingsLinkPlacement = 'footer' | 'none';
4
+
5
+ export function resolveSettingsLinkPlacement(
6
+ consent: ConsentConfig | undefined,
7
+ footer: RedoclyConfig['footer'] | undefined,
8
+ ): SettingsLinkPlacement {
9
+ const choice = consent?.settingsLink ?? 'auto';
10
+ if (choice !== 'auto') {
11
+ return choice;
12
+ }
13
+ const footerShown =
14
+ Boolean(footer) &&
15
+ !footer?.hide &&
16
+ (Boolean(footer?.copyrightText) || (Array.isArray(footer?.items) && footer.items.length > 0));
17
+ return footerShown ? 'footer' : 'none';
18
+ }