@redocly/theme 0.70.0-next.4 → 0.70.0-next.6

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 (113) hide show
  1. package/lib/components/Buttons/ConnectMCPButton.js +8 -13
  2. package/lib/components/Catalog/CatalogEntity/CatalogEntityGraph/CatalogEntityRelationsGraph.lazy.d.ts +1 -1
  3. package/lib/components/Catalog/CatalogEntity/CatalogEntityGraph/CatalogEntityRelationsGraph.lazy.js +1 -1
  4. package/lib/components/Catalog/CatalogFilter/CatalogFilterCheckboxes.js +30 -11
  5. package/lib/components/Catalog/CatalogFilter/CatalogFilterDateRange.js +2 -1
  6. package/lib/components/Dropdown/Dropdown.d.ts +4 -2
  7. package/lib/components/Dropdown/Dropdown.js +28 -8
  8. package/lib/components/Dropdown/DropdownMenu.d.ts +1 -0
  9. package/lib/components/Dropdown/DropdownMenu.js +1 -1
  10. package/lib/components/Feedback/Reasons.js +3 -2
  11. package/lib/components/Filter/FilterCheckboxes.js +30 -11
  12. package/lib/components/Filter/FilterDateRange.js +36 -2
  13. package/lib/components/Image/Image.js +24 -4
  14. package/lib/components/Search/SearchAiActivity.d.ts +10 -0
  15. package/lib/components/Search/SearchAiActivity.js +278 -0
  16. package/lib/components/Search/SearchAiDialog.js +5 -1
  17. package/lib/components/Search/SearchAiMessage.d.ts +2 -1
  18. package/lib/components/Search/SearchAiMessage.js +20 -108
  19. package/lib/components/Search/SearchItem.js +1 -8
  20. package/lib/components/Search/variables.js +15 -2
  21. package/lib/components/Segmented/Segmented.js +23 -3
  22. package/lib/components/Select/Select.js +10 -3
  23. package/lib/components/Select/SelectInput.d.ts +1 -0
  24. package/lib/components/Select/SelectInput.js +5 -11
  25. package/lib/components/SplitButton/SplitButton.js +37 -3
  26. package/lib/core/constants/mcp.d.ts +2 -2
  27. package/lib/core/constants/mcp.js +3 -8
  28. package/lib/core/constants/search.js +5 -1
  29. package/lib/core/hooks/index.d.ts +2 -0
  30. package/lib/core/hooks/index.js +2 -0
  31. package/lib/core/hooks/menu/use-collapse.js +1 -1
  32. package/lib/core/hooks/search/use-search-ai-activity.d.ts +11 -0
  33. package/lib/core/hooks/search/use-search-ai-activity.js +15 -0
  34. package/lib/core/hooks/use-connect-mcp-button.js +2 -1
  35. package/lib/core/hooks/use-full-height.d.ts +1 -1
  36. package/lib/core/hooks/use-full-height.js +8 -8
  37. package/lib/core/hooks/use-tab-list.d.ts +31 -0
  38. package/lib/core/hooks/use-tab-list.js +49 -0
  39. package/lib/core/styles/global.d.ts +2 -0
  40. package/lib/core/styles/global.js +80 -76
  41. package/lib/core/styles/index.d.ts +1 -0
  42. package/lib/core/styles/index.js +3 -1
  43. package/lib/core/styles/search-ai-appear.d.ts +1 -0
  44. package/lib/core/styles/search-ai-appear.js +18 -0
  45. package/lib/core/types/dropdown.d.ts +1 -0
  46. package/lib/core/types/dropdown.js +3 -0
  47. package/lib/core/types/index.d.ts +1 -0
  48. package/lib/core/types/l10n.d.ts +1 -1
  49. package/lib/core/types/mcp.d.ts +2 -1
  50. package/lib/core/types/search.d.ts +23 -0
  51. package/lib/core/utils/Dynamic.d.ts +8 -1
  52. package/lib/core/utils/Dynamic.js +34 -3
  53. package/lib/core/utils/assign-ref.d.ts +2 -0
  54. package/lib/core/utils/assign-ref.js +12 -0
  55. package/lib/core/utils/content-segments.d.ts +12 -1
  56. package/lib/core/utils/content-segments.js +90 -5
  57. package/lib/core/utils/dropdown.d.ts +2 -0
  58. package/lib/core/utils/dropdown.js +12 -0
  59. package/lib/core/utils/index.d.ts +2 -0
  60. package/lib/core/utils/index.js +2 -0
  61. package/lib/core/utils/mcp.d.ts +2 -1
  62. package/lib/core/utils/mcp.js +5 -0
  63. package/lib/core/utils/search-ai-activity.d.ts +4 -0
  64. package/lib/core/utils/search-ai-activity.js +83 -0
  65. package/lib/markdoc/tags/connect-mcp.js +15 -0
  66. package/package.json +2 -2
  67. package/src/components/Buttons/ConnectMCPButton.tsx +8 -14
  68. package/src/components/Catalog/CatalogEntity/CatalogEntityGraph/CatalogEntityRelationsGraph.lazy.tsx +3 -1
  69. package/src/components/Catalog/CatalogFilter/CatalogFilterCheckboxes.tsx +37 -22
  70. package/src/components/Catalog/CatalogFilter/CatalogFilterDateRange.tsx +3 -2
  71. package/src/components/Dropdown/Dropdown.tsx +28 -6
  72. package/src/components/Dropdown/DropdownMenu.tsx +7 -1
  73. package/src/components/Feedback/Reasons.tsx +3 -2
  74. package/src/components/Filter/FilterCheckboxes.tsx +33 -18
  75. package/src/components/Filter/FilterDateRange.tsx +3 -2
  76. package/src/components/Image/Image.tsx +26 -5
  77. package/src/components/Search/SearchAiActivity.tsx +318 -0
  78. package/src/components/Search/SearchAiDialog.tsx +4 -3
  79. package/src/components/Search/SearchAiMessage.tsx +27 -135
  80. package/src/components/Search/SearchItem.tsx +2 -9
  81. package/src/components/Search/variables.ts +15 -2
  82. package/src/components/Segmented/Segmented.tsx +25 -16
  83. package/src/components/Select/Select.tsx +12 -6
  84. package/src/components/Select/SelectInput.tsx +10 -18
  85. package/src/components/SplitButton/SplitButton.tsx +8 -2
  86. package/src/core/constants/mcp.ts +3 -9
  87. package/src/core/constants/search.ts +5 -1
  88. package/src/core/hooks/__mocks__/index.ts +1 -0
  89. package/src/core/hooks/__mocks__/use-theme-hooks.ts +1 -0
  90. package/src/core/hooks/index.ts +2 -0
  91. package/src/core/hooks/menu/use-collapse.ts +1 -1
  92. package/src/core/hooks/search/use-search-ai-activity.ts +31 -0
  93. package/src/core/hooks/use-connect-mcp-button.ts +7 -2
  94. package/src/core/hooks/use-full-height.ts +8 -8
  95. package/src/core/hooks/use-tab-list.ts +63 -0
  96. package/src/core/styles/global.ts +80 -75
  97. package/src/core/styles/index.ts +2 -1
  98. package/src/core/styles/search-ai-appear.ts +16 -0
  99. package/src/core/types/dropdown.ts +1 -0
  100. package/src/core/types/index.ts +1 -0
  101. package/src/core/types/l10n.ts +1 -1
  102. package/src/core/types/mcp.ts +3 -1
  103. package/src/core/types/search.ts +21 -0
  104. package/src/core/utils/Dynamic.tsx +67 -35
  105. package/src/core/utils/assign-ref.ts +9 -0
  106. package/src/core/utils/content-segments.ts +98 -6
  107. package/src/core/utils/dropdown.ts +14 -0
  108. package/src/core/utils/index.ts +2 -0
  109. package/src/core/utils/mcp.ts +7 -1
  110. package/src/core/utils/search-ai-activity.ts +110 -0
  111. package/src/icons/CheckboxIcon/CheckboxIcon.tsx +1 -0
  112. package/src/icons/ChevronSortIcon/ChevronSortIcon.tsx +1 -0
  113. package/src/markdoc/tags/connect-mcp.ts +16 -1
@@ -3,5 +3,7 @@ import type { CatalogEntityRelationsGraphProps } from '@redocly/theme/components
3
3
  import { dynamic } from '@redocly/theme/core/utils';
4
4
 
5
5
  export const CatalogEntityRelationsGraph = dynamic<CatalogEntityRelationsGraphProps>(() =>
6
- import('./CatalogEntityRelationsGraph').then((mod) => mod.CatalogEntityRelationsGraph),
6
+ import('@redocly/theme/components/Catalog/CatalogEntity/CatalogEntityGraph/CatalogEntityRelationsGraph').then(
7
+ (mod) => mod.CatalogEntityRelationsGraph,
8
+ ),
7
9
  );
@@ -96,30 +96,28 @@ export function CatalogFilterCheckboxes({
96
96
  <FilterOptions>
97
97
  {filteredOptions.map(({ value, count }) => {
98
98
  const id = 'filter--' + filter.property + '--' + value;
99
+ const checked =
100
+ filter.selectedOptions instanceof Set
101
+ ? filter.selectedOptions.has(value) ||
102
+ filter.selectedOptions.has(value?.toLowerCase())
103
+ : false;
99
104
  return (
100
- <FilterCheckboxOption
101
- key={id}
102
- role="link"
103
- onClick={() => {
104
- filter.toggleOption(value);
105
- telemetry.sendCatalogEntitiesFilterCheckboxToggledMessage([
106
- {
107
- ...getBaseDataAttributes('catalogEntitiesFilterCheckboxId', 'checkbox'),
108
- category: filter.property,
109
- value: value,
110
- },
111
- ]);
112
- }}
113
- >
114
- <CheckboxIcon
115
- checked={
116
- filter.selectedOptions instanceof Set
117
- ? filter.selectedOptions.has(value) ||
118
- filter.selectedOptions.has(value?.toLowerCase())
119
- : false
120
- }
121
- />
105
+ <FilterCheckboxOption key={id}>
106
+ <CheckboxIcon checked={checked} />
122
107
  <FilterOptionLabel data-translation-key={value}>
108
+ <FilterCheckboxInput
109
+ checked={checked}
110
+ onChange={() => {
111
+ filter.toggleOption(value);
112
+ telemetry.sendCatalogEntitiesFilterCheckboxToggledMessage([
113
+ {
114
+ ...getBaseDataAttributes('catalogEntitiesFilterCheckboxId', 'checkbox'),
115
+ category: filter.property,
116
+ value: value,
117
+ },
118
+ ]);
119
+ }}
120
+ />
123
121
  {highlightText(changeTextCasing(translate(value), filterValuesCasing) || '')}
124
122
  </FilterOptionLabel>
125
123
  {showCounter && <CounterTag borderless>{count}</CounterTag>}
@@ -168,6 +166,23 @@ const HighlightedText = styled.span`
168
166
 
169
167
  const FilterCheckboxOption = styled(FilterOption)`
170
168
  padding-left: var(--filter-option-checkbox-padding-left);
169
+
170
+ &:has(input:focus-visible) {
171
+ outline: 1px solid var(--button-border-color-focused);
172
+ outline-offset: -1px;
173
+ }
174
+ `;
175
+
176
+ const FilterCheckboxInput = styled.input.attrs({ type: 'checkbox' })`
177
+ position: absolute;
178
+ width: 1px;
179
+ height: 1px;
180
+ margin: -1px;
181
+ padding: 0;
182
+ overflow: hidden;
183
+ clip: rect(0 0 0 0);
184
+ white-space: nowrap;
185
+ border: 0;
171
186
  `;
172
187
 
173
188
  const FilterSearchWrapper = styled.div`
@@ -1,4 +1,4 @@
1
- import React, { useMemo } from 'react';
1
+ import React, { useMemo, useState } from 'react';
2
2
  import styled from 'styled-components';
3
3
 
4
4
  import type { JSX } from 'react';
@@ -19,6 +19,7 @@ export function CatalogFilterDateRange({
19
19
  filter,
20
20
  className,
21
21
  }: CatalogFilterDateRangeProps): JSX.Element {
22
+ const [today] = useState(() => new Date());
22
23
  const { useTranslate } = useThemeHooks();
23
24
  const { translate } = useTranslate();
24
25
 
@@ -60,7 +61,7 @@ export function CatalogFilterDateRange({
60
61
  monthPlaceholder="MM"
61
62
  yearPlaceholder="YYYY"
62
63
  value={from}
63
- maxDate={new Date()}
64
+ maxDate={today}
64
65
  onChange={(from) => {
65
66
  if (Array.isArray(from)) return;
66
67
  filter.selectOption({
@@ -11,9 +11,10 @@ import { createPortal } from 'react-dom';
11
11
  import styled, { css } from 'styled-components';
12
12
 
13
13
  import type { CSSProperties, ReactElement } from 'react';
14
- import type { RenderableChildren } from '@redocly/theme/core/types';
14
+ import type { DropdownAlignment, RenderableChildren } from '@redocly/theme/core/types';
15
15
 
16
16
  import { useOutsideClick, useControlledState } from '@redocly/theme/core/hooks';
17
+ import { fitDropdownAlignment } from '@redocly/theme/core/utils';
17
18
  import { ChevronDownIcon } from '@redocly/theme/icons/ChevronDownIcon/ChevronDownIcon';
18
19
  import { ChevronUpIcon } from '@redocly/theme/icons/ChevronUpIcon/ChevronUpIcon';
19
20
 
@@ -37,7 +38,7 @@ export type DropdownProps = {
37
38
  children: RenderableChildren<DropdownMenuContext>;
38
39
  triggerEvent?: 'click' | 'hover';
39
40
  placement?: 'top' | 'bottom';
40
- alignment?: 'start' | 'end';
41
+ alignment?: DropdownAlignment;
41
42
 
42
43
  active?: boolean;
43
44
  closeOnClick?: boolean;
@@ -46,6 +47,8 @@ export type DropdownProps = {
46
47
  className?: string;
47
48
  withArrow?: boolean;
48
49
  portalled?: boolean;
50
+ /** Element the menu lines up with when it is not the dropdown itself, e.g. a whole split button. */
51
+ alignmentAnchorRef?: React.RefObject<HTMLElement | null>;
49
52
 
50
53
  onClick?: (event: React.UIEvent) => void;
51
54
  onClose?: () => void;
@@ -66,6 +69,7 @@ export const Dropdown = forwardRef<HTMLDivElement, DropdownProps>(
66
69
  placement,
67
70
  alignment,
68
71
  portalled = false,
72
+ alignmentAnchorRef,
69
73
  onClick,
70
74
  onClose,
71
75
  onOpen,
@@ -76,6 +80,7 @@ export const Dropdown = forwardRef<HTMLDivElement, DropdownProps>(
76
80
  const menuRef = useRef<HTMLDivElement | null>(null);
77
81
  const [isOpen, setIsOpen] = useControlledState<boolean>(false, active);
78
82
  const [menuStyle, setMenuStyle] = useState<CSSProperties>({});
83
+ const [fittedAlignment, setFittedAlignment] = useState(alignment);
79
84
 
80
85
  const wrapperRef = (ref as React.RefObject<HTMLDivElement | null>) || dropdownRef;
81
86
 
@@ -115,7 +120,7 @@ export const Dropdown = forwardRef<HTMLDivElement, DropdownProps>(
115
120
  const updateMenuPosition = useCallback(() => {
116
121
  const wrapper = wrapperRef.current;
117
122
  if (!wrapper) return;
118
- const rect = wrapper.getBoundingClientRect();
123
+ const rect = (alignmentAnchorRef?.current ?? wrapper).getBoundingClientRect();
119
124
  const menuHeight = menuRef.current?.offsetHeight ?? 0;
120
125
  const fitsBelow = rect.bottom + menuHeight <= window.innerHeight;
121
126
  const fitsAbove = rect.top - menuHeight >= 0;
@@ -126,7 +131,7 @@ export const Dropdown = forwardRef<HTMLDivElement, DropdownProps>(
126
131
  } else {
127
132
  style.top = rect.bottom;
128
133
  }
129
- if (alignment === 'end') {
134
+ if (fitDropdownAlignment(rect, menuRef.current?.offsetWidth ?? 0, alignment) === 'end') {
130
135
  style.right = window.innerWidth - rect.right;
131
136
  } else {
132
137
  style.left = rect.left;
@@ -136,7 +141,24 @@ export const Dropdown = forwardRef<HTMLDivElement, DropdownProps>(
136
141
  style.zIndex = zIndex;
137
142
  }
138
143
  setMenuStyle(style);
139
- }, [placement, alignment, wrapperRef]);
144
+ }, [placement, alignment, wrapperRef, alignmentAnchorRef]);
145
+
146
+ /* The absolute-positioned menu is placed by CSS, so the side is measured once it is
147
+ shown and again on resize; a side that runs off the viewport flips to the other one. */
148
+ const updateFittedAlignment = useCallback(() => {
149
+ const wrapper = wrapperRef.current;
150
+ const menu = menuRef.current;
151
+ if (!wrapper || !menu) return;
152
+ const rect = (alignmentAnchorRef?.current ?? wrapper).getBoundingClientRect();
153
+ setFittedAlignment(fitDropdownAlignment(rect, menu.offsetWidth, alignment));
154
+ }, [alignment, wrapperRef, alignmentAnchorRef]);
155
+
156
+ useIsomorphicLayoutEffect(() => {
157
+ if (portalled || !isOpen) return;
158
+ updateFittedAlignment();
159
+ window.addEventListener('resize', updateFittedAlignment);
160
+ return () => window.removeEventListener('resize', updateFittedAlignment);
161
+ }, [portalled, isOpen, updateFittedAlignment]);
140
162
 
141
163
  useIsomorphicLayoutEffect(() => {
142
164
  if (!portalled || !isOpen) return;
@@ -172,7 +194,7 @@ export const Dropdown = forwardRef<HTMLDivElement, DropdownProps>(
172
194
  <ChildrenWrapper
173
195
  ref={menuRef}
174
196
  $placement={placement}
175
- $alignment={alignment}
197
+ $alignment={fittedAlignment}
176
198
  $isOpen={isOpen}
177
199
  $portalled={portalled}
178
200
  style={portalled ? menuStyle : undefined}
@@ -6,7 +6,12 @@ import type { DropdownMenuItemProps } from '@redocly/theme/components/Dropdown/D
6
6
 
7
7
  import { DropdownMenuItem } from '@redocly/theme/components/Dropdown/DropdownMenuItem';
8
8
 
9
- export type DropdownMenuProps = { className?: string; role?: string; footer?: React.ReactNode } & (
9
+ export type DropdownMenuProps = {
10
+ id?: string;
11
+ className?: string;
12
+ role?: string;
13
+ footer?: React.ReactNode;
14
+ } & (
10
15
  | {
11
16
  items: DropdownMenuItemProps[];
12
17
  }
@@ -32,6 +37,7 @@ export function DropdownMenu(props: DropdownMenuProps): JSX.Element {
32
37
  <DropdownMenuWrapper
33
38
  data-component-name="Dropdown/DropdownMenu"
34
39
  data-testid="dropdown-menu"
40
+ id={props.id}
35
41
  className={props.className}
36
42
  role={props.role || 'menu'}
37
43
  >
@@ -18,6 +18,7 @@ export type ReasonsProps = {
18
18
  export function Reasons({ settings, onChange, className }: ReasonsProps): JSX.Element | null {
19
19
  const { label, component = 'checkbox', items = [] } = settings;
20
20
  const [checkedState, setCheckedState] = React.useState(new Array(items.length).fill(false));
21
+ const idPrefix = React.useId();
21
22
  const { useTranslate } = useThemeHooks();
22
23
  const { translate } = useTranslate();
23
24
 
@@ -51,6 +52,7 @@ export function Reasons({ settings, onChange, className }: ReasonsProps): JSX.El
51
52
  {items.map((reason, idx) => (
52
53
  <OptionWrapper key={reason}>
53
54
  <input
55
+ id={`${idPrefix}-${idx}`}
54
56
  type={component}
55
57
  value={reason}
56
58
  checked={checkedState[idx]}
@@ -59,8 +61,7 @@ export function Reasons({ settings, onChange, className }: ReasonsProps): JSX.El
59
61
  />
60
62
  <label
61
63
  data-translation-key={`feedback.settings.reasons.items.${idx + 1}`}
62
- id={reason}
63
- onClick={() => handleOptionChange(idx)}
64
+ htmlFor={`${idPrefix}-${idx}`}
64
65
  >
65
66
  {translate(`feedback.settings.reasons.items.${idx + 1}`, reason)}
66
67
  </label>
@@ -30,26 +30,24 @@ export function FilterCheckboxes({
30
30
  <FilterOptions>
31
31
  {(filter.filteredOptions || filter.options).map(({ value, count }) => {
32
32
  const id = 'filter--' + filter.property + '--' + value;
33
+ const checked =
34
+ filter.selectedOptions instanceof Set
35
+ ? filter.selectedOptions.has(value) ||
36
+ filter.selectedOptions.has(value?.toLowerCase())
37
+ : false;
33
38
  return (
34
- <FilterCheckboxOption
35
- key={id}
36
- role="link"
37
- onClick={() => {
38
- filter.toggleOption(value);
39
- telemetry.sendFilterCheckboxToggledMessage([
40
- getBaseDataAttributes('filterId', 'checkbox'),
41
- ]);
42
- }}
43
- >
44
- <CheckboxIcon
45
- checked={
46
- filter.selectedOptions instanceof Set
47
- ? filter.selectedOptions.has(value) ||
48
- filter.selectedOptions.has(value?.toLowerCase())
49
- : false
50
- }
51
- />
39
+ <FilterCheckboxOption key={id}>
40
+ <CheckboxIcon checked={checked} />
52
41
  <FilterOptionLabel data-translation-key={value}>
42
+ <FilterCheckboxInput
43
+ checked={checked}
44
+ onChange={() => {
45
+ filter.toggleOption(value);
46
+ telemetry.sendFilterCheckboxToggledMessage([
47
+ getBaseDataAttributes('filterId', 'checkbox'),
48
+ ]);
49
+ }}
50
+ />
53
51
  {changeTextCasing(translate(value), filterValuesCasing)}
54
52
  </FilterOptionLabel>
55
53
  {showCounter && <CounterTag borderless>{count}</CounterTag>}
@@ -63,6 +61,23 @@ export function FilterCheckboxes({
63
61
 
64
62
  const FilterCheckboxOption = styled(FilterOption)`
65
63
  padding-left: var(--filter-option-checkbox-padding-left);
64
+
65
+ &:has(input:focus-visible) {
66
+ outline: 1px solid var(--button-border-color-focused);
67
+ outline-offset: -1px;
68
+ }
69
+ `;
70
+
71
+ const FilterCheckboxInput = styled.input.attrs({ type: 'checkbox' })`
72
+ position: absolute;
73
+ width: 1px;
74
+ height: 1px;
75
+ margin: -1px;
76
+ padding: 0;
77
+ overflow: hidden;
78
+ clip: rect(0 0 0 0);
79
+ white-space: nowrap;
80
+ border: 0;
66
81
  `;
67
82
 
68
83
  const FilterCheckboxesWrapper = styled.div`
@@ -1,4 +1,4 @@
1
- import React from 'react';
1
+ import React, { useState } from 'react';
2
2
  import styled from 'styled-components';
3
3
 
4
4
  import type { JSX } from 'react';
@@ -11,6 +11,7 @@ import { useThemeHooks } from '@redocly/theme/core/hooks';
11
11
  import { formatDateWithoutTimeZone } from '@redocly/theme/core/utils';
12
12
 
13
13
  export function FilterDateRange({ filter }: FilterProps): JSX.Element {
14
+ const [today] = useState(() => new Date());
14
15
  const { useTranslate } = useThemeHooks();
15
16
  const { translate } = useTranslate();
16
17
 
@@ -33,7 +34,7 @@ export function FilterDateRange({ filter }: FilterProps): JSX.Element {
33
34
  monthPlaceholder="MM"
34
35
  yearPlaceholder="YYYY"
35
36
  value={from}
36
- maxDate={new Date()}
37
+ maxDate={today}
37
38
  onChange={(from) => {
38
39
  if (Array.isArray(from)) return;
39
40
  filter.selectOption({
@@ -1,7 +1,7 @@
1
1
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import styled, { css } from 'styled-components';
3
3
 
4
- import type { KeyboardEvent, JSX } from 'react';
4
+ import type { KeyboardEvent, JSX, MouseEvent } from 'react';
5
5
 
6
6
  import { parseSrcSet, parseStyleString } from '@redocly/theme/core/utils';
7
7
  import { useModalScrollLock } from '@redocly/theme/core/hooks';
@@ -42,6 +42,7 @@ export function Image(props: ImageProps): JSX.Element {
42
42
  } = props;
43
43
 
44
44
  const lightboxContainerRef = useRef<HTMLDivElement>(null);
45
+ const lightboxTriggerRef = useRef<HTMLElement | null>(null);
45
46
  const [lightboxImage, setLightboxImage] = useState<string | undefined>(undefined);
46
47
  const parsedSourceSetMap = useMemo(() => {
47
48
  return srcSet ? parseSrcSet(srcSet) : new Map();
@@ -55,15 +56,32 @@ export function Image(props: ImageProps): JSX.Element {
55
56
  }, []);
56
57
 
57
58
  const handleImageClick = useCallback(
58
- (src: string) => {
59
+ (src: string, trigger: HTMLElement) => {
59
60
  if (!withLightbox || lightboxImage) {
60
61
  return;
61
62
  }
63
+ lightboxTriggerRef.current = trigger;
62
64
  setLightboxImage(src);
63
65
  },
64
66
  [withLightbox, lightboxImage],
65
67
  );
66
68
 
69
+ const getLightboxTriggerProps = (imageSrc: string) =>
70
+ withLightbox
71
+ ? {
72
+ role: 'button',
73
+ tabIndex: 0,
74
+ 'aria-label': alt ? undefined : 'Open image',
75
+ onClick: (e: MouseEvent<HTMLElement>) => handleImageClick(imageSrc, e.currentTarget),
76
+ onKeyDown: (e: KeyboardEvent<HTMLElement>) => {
77
+ if (e.key === 'Enter' || e.key === ' ') {
78
+ e.preventDefault();
79
+ handleImageClick(imageSrc, e.currentTarget);
80
+ }
81
+ },
82
+ }
83
+ : {};
84
+
67
85
  const handleCloseLightbox = useCallback(() => {
68
86
  setLightboxImage(undefined);
69
87
  }, []);
@@ -72,6 +90,9 @@ export function Image(props: ImageProps): JSX.Element {
72
90
  useEffect(() => {
73
91
  if (lightboxImage) {
74
92
  lightboxContainerRef.current?.focus();
93
+ } else {
94
+ lightboxTriggerRef.current?.focus();
95
+ lightboxTriggerRef.current = null;
75
96
  }
76
97
  }, [lightboxImage]);
77
98
 
@@ -92,7 +113,7 @@ export function Image(props: ImageProps): JSX.Element {
92
113
  width={width}
93
114
  height={height}
94
115
  style={combinedStyles}
95
- onClick={() => handleImageClick(src)}
116
+ {...getLightboxTriggerProps(src)}
96
117
  />
97
118
  ) : (
98
119
  Array.from(parsedSourceSetMap).map(([key, value]) => (
@@ -106,7 +127,7 @@ export function Image(props: ImageProps): JSX.Element {
106
127
  height={height}
107
128
  $withLightbox={withLightbox}
108
129
  style={combinedStyles}
109
- onClick={() => handleImageClick(value)}
130
+ {...getLightboxTriggerProps(value)}
110
131
  />
111
132
  ))
112
133
  );
@@ -122,7 +143,7 @@ export function Image(props: ImageProps): JSX.Element {
122
143
  src={galleryImageSrc}
123
144
  alt={alt}
124
145
  style={withLightbox ? { cursor: 'pointer' } : undefined}
125
- onClick={() => handleImageClick(galleryImageSrc)}
146
+ {...getLightboxTriggerProps(galleryImageSrc)}
126
147
  />
127
148
  ))}
128
149
  </GalleryRow>