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

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 (32) hide show
  1. package/lib/components/Catalog/CatalogEntity/CatalogEntityGraph/CatalogEntityRelationsGraph.lazy.d.ts +1 -1
  2. package/lib/components/Catalog/CatalogFilter/CatalogFilterCheckboxes.js +30 -11
  3. package/lib/components/Dropdown/DropdownMenu.d.ts +1 -0
  4. package/lib/components/Dropdown/DropdownMenu.js +1 -1
  5. package/lib/components/Feedback/Reasons.js +3 -2
  6. package/lib/components/Filter/FilterCheckboxes.js +30 -11
  7. package/lib/components/Image/Image.js +24 -4
  8. package/lib/components/Segmented/Segmented.js +23 -3
  9. package/lib/components/Select/Select.js +10 -3
  10. package/lib/components/Select/SelectInput.d.ts +1 -0
  11. package/lib/components/Select/SelectInput.js +3 -3
  12. package/lib/core/hooks/index.d.ts +1 -0
  13. package/lib/core/hooks/index.js +1 -0
  14. package/lib/core/hooks/use-tab-list.d.ts +31 -0
  15. package/lib/core/hooks/use-tab-list.js +49 -0
  16. package/lib/core/utils/Dynamic.d.ts +8 -1
  17. package/lib/core/utils/Dynamic.js +34 -3
  18. package/package.json +2 -2
  19. package/src/components/Catalog/CatalogFilter/CatalogFilterCheckboxes.tsx +37 -22
  20. package/src/components/Dropdown/DropdownMenu.tsx +7 -1
  21. package/src/components/Feedback/Reasons.tsx +3 -2
  22. package/src/components/Filter/FilterCheckboxes.tsx +33 -18
  23. package/src/components/Image/Image.tsx +26 -5
  24. package/src/components/Segmented/Segmented.tsx +25 -16
  25. package/src/components/Select/Select.tsx +12 -6
  26. package/src/components/Select/SelectInput.tsx +8 -10
  27. package/src/core/hooks/__mocks__/use-theme-hooks.ts +1 -0
  28. package/src/core/hooks/index.ts +1 -0
  29. package/src/core/hooks/use-tab-list.ts +63 -0
  30. package/src/core/utils/Dynamic.tsx +67 -35
  31. package/src/icons/CheckboxIcon/CheckboxIcon.tsx +1 -0
  32. package/src/icons/ChevronSortIcon/ChevronSortIcon.tsx +1 -0
@@ -1,2 +1,2 @@
1
1
  import type { CatalogEntityRelationsGraphProps } from '../../../../components/Catalog/CatalogEntity/CatalogEntityGraph/CatalogEntityRelationsGraph';
2
- export declare const CatalogEntityRelationsGraph: import("react").ComponentType<CatalogEntityRelationsGraphProps>;
2
+ export declare const CatalogEntityRelationsGraph: import("../../../../core/utils").DynamicComponent<CatalogEntityRelationsGraphProps>;
@@ -101,17 +101,20 @@ function CatalogFilterCheckboxes({ filter, filterValuesCasing, showCounter = tru
101
101
  react_1.default.createElement(FilterInput_1.FilterInput, { value: searchTerm, onChange: setSearchTerm })),
102
102
  react_1.default.createElement(FilterOptions_1.FilterOptions, null, filteredOptions.map(({ value, count }) => {
103
103
  const id = 'filter--' + filter.property + '--' + value;
104
- return (react_1.default.createElement(FilterCheckboxOption, { key: id, role: "link", onClick: () => {
105
- filter.toggleOption(value);
106
- telemetry.sendCatalogEntitiesFilterCheckboxToggledMessage([
107
- Object.assign(Object.assign({}, (0, utils_1.getBaseDataAttributes)('catalogEntitiesFilterCheckboxId', 'checkbox')), { category: filter.property, value: value }),
108
- ]);
109
- } },
110
- react_1.default.createElement(CheckboxIcon_1.CheckboxIcon, { checked: filter.selectedOptions instanceof Set
111
- ? filter.selectedOptions.has(value) ||
112
- filter.selectedOptions.has(value === null || value === void 0 ? void 0 : value.toLowerCase())
113
- : false }),
114
- react_1.default.createElement(FilterOptionLabel_1.FilterOptionLabel, { "data-translation-key": value }, highlightText((0, utils_1.changeTextCasing)(translate(value), filterValuesCasing) || '')),
104
+ const checked = filter.selectedOptions instanceof Set
105
+ ? filter.selectedOptions.has(value) ||
106
+ filter.selectedOptions.has(value === null || value === void 0 ? void 0 : value.toLowerCase())
107
+ : false;
108
+ return (react_1.default.createElement(FilterCheckboxOption, { key: id },
109
+ react_1.default.createElement(CheckboxIcon_1.CheckboxIcon, { checked: checked }),
110
+ react_1.default.createElement(FilterOptionLabel_1.FilterOptionLabel, { "data-translation-key": value },
111
+ react_1.default.createElement(FilterCheckboxInput, { checked: checked, onChange: () => {
112
+ filter.toggleOption(value);
113
+ telemetry.sendCatalogEntitiesFilterCheckboxToggledMessage([
114
+ Object.assign(Object.assign({}, (0, utils_1.getBaseDataAttributes)('catalogEntitiesFilterCheckboxId', 'checkbox')), { category: filter.property, value: value }),
115
+ ]);
116
+ } }),
117
+ highlightText((0, utils_1.changeTextCasing)(translate(value), filterValuesCasing) || '')),
115
118
  showCounter && react_1.default.createElement(CounterTag_1.CounterTag, { borderless: true }, count)));
116
119
  })))));
117
120
  }
@@ -147,6 +150,22 @@ const HighlightedText = styled_components_1.default.span `
147
150
  `;
148
151
  const FilterCheckboxOption = (0, styled_components_1.default)(FilterOption_1.FilterOption) `
149
152
  padding-left: var(--filter-option-checkbox-padding-left);
153
+
154
+ &:has(input:focus-visible) {
155
+ outline: 1px solid var(--button-border-color-focused);
156
+ outline-offset: -1px;
157
+ }
158
+ `;
159
+ const FilterCheckboxInput = styled_components_1.default.input.attrs({ type: 'checkbox' }) `
160
+ position: absolute;
161
+ width: 1px;
162
+ height: 1px;
163
+ margin: -1px;
164
+ padding: 0;
165
+ overflow: hidden;
166
+ clip: rect(0 0 0 0);
167
+ white-space: nowrap;
168
+ border: 0;
150
169
  `;
151
170
  const FilterSearchWrapper = styled_components_1.default.div `
152
171
  padding-bottom: var(--spacing-xs);
@@ -2,6 +2,7 @@ import React from 'react';
2
2
  import type { ReactNode, JSX } from 'react';
3
3
  import type { DropdownMenuItemProps } from '../../components/Dropdown/DropdownMenuItem';
4
4
  export type DropdownMenuProps = {
5
+ id?: string;
5
6
  className?: string;
6
7
  role?: string;
7
8
  footer?: React.ReactNode;
@@ -15,7 +15,7 @@ function DropdownMenu(props) {
15
15
  if ('items' in props) {
16
16
  content = props.items.map((item, idx) => (react_1.default.createElement(DropdownMenuItem_1.DropdownMenuItem, Object.assign({ key: idx }, item), item.content)));
17
17
  }
18
- return (react_1.default.createElement(DropdownMenuWrapper, { "data-component-name": "Dropdown/DropdownMenu", "data-testid": "dropdown-menu", className: props.className, role: props.role || 'menu' },
18
+ return (react_1.default.createElement(DropdownMenuWrapper, { "data-component-name": "Dropdown/DropdownMenu", "data-testid": "dropdown-menu", id: props.id, className: props.className, role: props.role || 'menu' },
19
19
  content,
20
20
  props.footer || null));
21
21
  }
@@ -43,6 +43,7 @@ const hooks_1 = require("../../core/hooks");
43
43
  function Reasons({ settings, onChange, className }) {
44
44
  const { label, component = 'checkbox', items = [] } = settings;
45
45
  const [checkedState, setCheckedState] = React.useState(new Array(items.length).fill(false));
46
+ const idPrefix = React.useId();
46
47
  const { useTranslate } = (0, hooks_1.useThemeHooks)();
47
48
  const { translate } = useTranslate();
48
49
  if (!items.length) {
@@ -59,8 +60,8 @@ function Reasons({ settings, onChange, className }) {
59
60
  React.createElement(Label, null, label ||
60
61
  translate('feedback.settings.reasons.label', 'Which statement describes your thoughts about this page?')),
61
62
  items.map((reason, idx) => (React.createElement(OptionWrapper, { key: reason },
62
- React.createElement("input", { type: component, value: reason, checked: checkedState[idx], name: "reasons", onChange: () => handleOptionChange(idx) }),
63
- React.createElement("label", { "data-translation-key": `feedback.settings.reasons.items.${idx + 1}`, id: reason, onClick: () => handleOptionChange(idx) }, translate(`feedback.settings.reasons.items.${idx + 1}`, reason)))))));
63
+ React.createElement("input", { id: `${idPrefix}-${idx}`, type: component, value: reason, checked: checkedState[idx], name: "reasons", onChange: () => handleOptionChange(idx) }),
64
+ React.createElement("label", { "data-translation-key": `feedback.settings.reasons.items.${idx + 1}`, htmlFor: `${idPrefix}-${idx}` }, translate(`feedback.settings.reasons.items.${idx + 1}`, reason)))))));
64
65
  }
65
66
  const ReasonsWrapper = styled_components_1.default.div `
66
67
  font-family: var(--feedback-font-family);
@@ -22,22 +22,41 @@ function FilterCheckboxes({ filter, filterValuesCasing, showCounter = true, }) {
22
22
  react_1.default.createElement(FilterTitle_1.FilterTitle, { "data-translation-key": filter.titleTranslationKey }, translate(filter.titleTranslationKey, filter.title)),
23
23
  react_1.default.createElement(FilterOptions_1.FilterOptions, null, (filter.filteredOptions || filter.options).map(({ value, count }) => {
24
24
  const id = 'filter--' + filter.property + '--' + value;
25
- return (react_1.default.createElement(FilterCheckboxOption, { key: id, role: "link", onClick: () => {
26
- filter.toggleOption(value);
27
- telemetry.sendFilterCheckboxToggledMessage([
28
- (0, utils_1.getBaseDataAttributes)('filterId', 'checkbox'),
29
- ]);
30
- } },
31
- react_1.default.createElement(CheckboxIcon_1.CheckboxIcon, { checked: filter.selectedOptions instanceof Set
32
- ? filter.selectedOptions.has(value) ||
33
- filter.selectedOptions.has(value === null || value === void 0 ? void 0 : value.toLowerCase())
34
- : false }),
35
- react_1.default.createElement(FilterOptionLabel_1.FilterOptionLabel, { "data-translation-key": value }, (0, utils_1.changeTextCasing)(translate(value), filterValuesCasing)),
25
+ const checked = filter.selectedOptions instanceof Set
26
+ ? filter.selectedOptions.has(value) ||
27
+ filter.selectedOptions.has(value === null || value === void 0 ? void 0 : value.toLowerCase())
28
+ : false;
29
+ return (react_1.default.createElement(FilterCheckboxOption, { key: id },
30
+ react_1.default.createElement(CheckboxIcon_1.CheckboxIcon, { checked: checked }),
31
+ react_1.default.createElement(FilterOptionLabel_1.FilterOptionLabel, { "data-translation-key": value },
32
+ react_1.default.createElement(FilterCheckboxInput, { checked: checked, onChange: () => {
33
+ filter.toggleOption(value);
34
+ telemetry.sendFilterCheckboxToggledMessage([
35
+ (0, utils_1.getBaseDataAttributes)('filterId', 'checkbox'),
36
+ ]);
37
+ } }),
38
+ (0, utils_1.changeTextCasing)(translate(value), filterValuesCasing)),
36
39
  showCounter && react_1.default.createElement(CounterTag_1.CounterTag, { borderless: true }, count)));
37
40
  }))));
38
41
  }
39
42
  const FilterCheckboxOption = (0, styled_components_1.default)(FilterOption_1.FilterOption) `
40
43
  padding-left: var(--filter-option-checkbox-padding-left);
44
+
45
+ &:has(input:focus-visible) {
46
+ outline: 1px solid var(--button-border-color-focused);
47
+ outline-offset: -1px;
48
+ }
49
+ `;
50
+ const FilterCheckboxInput = styled_components_1.default.input.attrs({ type: 'checkbox' }) `
51
+ position: absolute;
52
+ width: 1px;
53
+ height: 1px;
54
+ margin: -1px;
55
+ padding: 0;
56
+ overflow: hidden;
57
+ clip: rect(0 0 0 0);
58
+ white-space: nowrap;
59
+ border: 0;
41
60
  `;
42
61
  const FilterCheckboxesWrapper = styled_components_1.default.div `
43
62
  display: flex;
@@ -43,6 +43,7 @@ const Button_1 = require("../../components/Button/Button");
43
43
  function Image(props) {
44
44
  const { src, srcSet, images: galleryImages, alt, className, width, height, border, caption, framed, style, withLightbox, lightboxStyle, } = props;
45
45
  const lightboxContainerRef = (0, react_1.useRef)(null);
46
+ const lightboxTriggerRef = (0, react_1.useRef)(null);
46
47
  const [lightboxImage, setLightboxImage] = (0, react_1.useState)(undefined);
47
48
  const parsedSourceSetMap = (0, react_1.useMemo)(() => {
48
49
  return srcSet ? (0, utils_1.parseSrcSet)(srcSet) : new Map();
@@ -53,28 +54,47 @@ function Image(props) {
53
54
  setLightboxImage(undefined);
54
55
  }
55
56
  }, []);
56
- const handleImageClick = (0, react_1.useCallback)((src) => {
57
+ const handleImageClick = (0, react_1.useCallback)((src, trigger) => {
57
58
  if (!withLightbox || lightboxImage) {
58
59
  return;
59
60
  }
61
+ lightboxTriggerRef.current = trigger;
60
62
  setLightboxImage(src);
61
63
  }, [withLightbox, lightboxImage]);
64
+ const getLightboxTriggerProps = (imageSrc) => withLightbox
65
+ ? {
66
+ role: 'button',
67
+ tabIndex: 0,
68
+ 'aria-label': alt ? undefined : 'Open image',
69
+ onClick: (e) => handleImageClick(imageSrc, e.currentTarget),
70
+ onKeyDown: (e) => {
71
+ if (e.key === 'Enter' || e.key === ' ') {
72
+ e.preventDefault();
73
+ handleImageClick(imageSrc, e.currentTarget);
74
+ }
75
+ },
76
+ }
77
+ : {};
62
78
  const handleCloseLightbox = (0, react_1.useCallback)(() => {
63
79
  setLightboxImage(undefined);
64
80
  }, []);
65
81
  (0, hooks_1.useModalScrollLock)(!!lightboxImage);
66
82
  (0, react_1.useEffect)(() => {
67
- var _a;
83
+ var _a, _b;
68
84
  if (lightboxImage) {
69
85
  (_a = lightboxContainerRef.current) === null || _a === void 0 ? void 0 : _a.focus();
70
86
  }
87
+ else {
88
+ (_b = lightboxTriggerRef.current) === null || _b === void 0 ? void 0 : _b.focus();
89
+ lightboxTriggerRef.current = null;
90
+ }
71
91
  }, [lightboxImage]);
72
92
  const combinedStyles = Object.assign(Object.assign(Object.assign({}, (withLightbox && { cursor: 'pointer' })), (border && { border })), (typeof style === 'string' ? (0, utils_1.parseStyleString)(style) : style));
73
93
  const lightboxOverlayStyles = typeof lightboxStyle === 'string' ? (0, utils_1.parseStyleString)(lightboxStyle) : lightboxStyle;
74
- const images = src ? (react_1.default.createElement("img", { src: src, alt: alt, className: className, width: width, height: height, style: combinedStyles, onClick: () => handleImageClick(src) })) : (Array.from(parsedSourceSetMap).map(([key, value]) => (react_1.default.createElement(ColorModeAwareImage, { key: key, $colorMode: key, src: value, alt: alt, className: className, width: width, height: height, $withLightbox: withLightbox, style: combinedStyles, onClick: () => handleImageClick(value) }))));
94
+ const images = src ? (react_1.default.createElement("img", Object.assign({ src: src, alt: alt, className: className, width: width, height: height, style: combinedStyles }, getLightboxTriggerProps(src)))) : (Array.from(parsedSourceSetMap).map(([key, value]) => (react_1.default.createElement(ColorModeAwareImage, Object.assign({ key: key, $colorMode: key, src: value, alt: alt, className: className, width: width, height: height, $withLightbox: withLightbox, style: combinedStyles }, getLightboxTriggerProps(value))))));
75
95
  const hasGallery = Array.isArray(galleryImages) && galleryImages.length > 0;
76
96
  const visibleGalleryImages = hasGallery ? galleryImages.slice(0, 3) : [];
77
- const gallery = hasGallery ? (react_1.default.createElement(GalleryRow, null, visibleGalleryImages.map((galleryImageSrc, index) => (react_1.default.createElement("img", { key: `${galleryImageSrc}-${index}`, src: galleryImageSrc, alt: alt, style: withLightbox ? { cursor: 'pointer' } : undefined, onClick: () => handleImageClick(galleryImageSrc) }))))) : null;
97
+ const gallery = hasGallery ? (react_1.default.createElement(GalleryRow, null, visibleGalleryImages.map((galleryImageSrc, index) => (react_1.default.createElement("img", Object.assign({ key: `${galleryImageSrc}-${index}`, src: galleryImageSrc, alt: alt, style: withLightbox ? { cursor: 'pointer' } : undefined }, getLightboxTriggerProps(galleryImageSrc))))))) : null;
78
98
  const hasCaption = Boolean(caption);
79
99
  const hasWrapper = Boolean(framed || hasCaption || hasGallery);
80
100
  const wrapperContent = hasGallery ? gallery : images;
@@ -32,15 +32,35 @@ var __importStar = (this && this.__importStar) || (function () {
32
32
  return result;
33
33
  };
34
34
  })();
35
+ var __rest = (this && this.__rest) || function (s, e) {
36
+ var t = {};
37
+ for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
38
+ t[p] = s[p];
39
+ if (s != null && typeof Object.getOwnPropertySymbols === "function")
40
+ for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
41
+ if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
42
+ t[p[i]] = s[p[i]];
43
+ }
44
+ return t;
45
+ };
35
46
  Object.defineProperty(exports, "__esModule", { value: true });
36
47
  exports.Segmented = void 0;
37
48
  const react_1 = __importStar(require("react"));
38
49
  const styled_components_1 = __importStar(require("styled-components"));
39
50
  const typed_memo_1 = require("../../core/hoc/typed-memo");
51
+ const hooks_1 = require("../../core/hooks");
40
52
  function SegmentedComponent({ options, onChange, value, className = '', size = 'regular' }, ref) {
41
- return (react_1.default.createElement(SegmentedGroup, { ref: ref, "data-component-name": "Segmented/Segmented", className: `tag-grey ${size} ${className}`, role: "tablist", $size: size }, options.map((opt, index) => (react_1.default.createElement(react_1.default.Fragment, { key: index },
42
- opt.divider,
43
- react_1.default.createElement(SegmentedItem, { key: opt.label, role: "tab", title: opt.label, onClick: () => onChange(opt), $isActive: value == opt.value, $size: size }, opt.element || opt.label))))));
53
+ const { tabListProps, getTabProps } = (0, hooks_1.useTabList)({
54
+ count: options.length,
55
+ activeIndex: options.findIndex((opt) => value == opt.value),
56
+ onChange: (index) => onChange(options[index]),
57
+ });
58
+ return (react_1.default.createElement(SegmentedGroup, Object.assign({ ref: ref, "data-component-name": "Segmented/Segmented", className: `tag-grey ${size} ${className}` }, tabListProps, { $size: size }), options.map((opt, index) => {
59
+ const _a = getTabProps(index), { 'aria-controls': _noPanel } = _a, tabProps = __rest(_a, ['aria-controls']);
60
+ return (react_1.default.createElement(react_1.default.Fragment, { key: index },
61
+ opt.divider,
62
+ react_1.default.createElement(SegmentedItem, Object.assign({ key: opt.label }, tabProps, { title: opt.label, $isActive: value == opt.value, $size: size }), opt.element || opt.label)));
63
+ })));
44
64
  }
45
65
  exports.Segmented = (0, typed_memo_1.typedMemo)((0, react_1.forwardRef)(SegmentedComponent));
46
66
  const SegmentedGroup = styled_components_1.default.div `
@@ -68,6 +68,7 @@ function Select(props) {
68
68
  const [filteredOptions, setFilteredOptions] = (0, react_1.useState)(options);
69
69
  const [stickyInputValue, setStickyInputValue] = (0, react_1.useState)(placeholder || '');
70
70
  const inputId = (0, react_1.useId)();
71
+ const listboxId = `${inputId}-listbox`;
71
72
  (0, hooks_1.useOutsideClick)(selectRef, () => {
72
73
  setDropdownActive(false);
73
74
  });
@@ -169,14 +170,20 @@ function Select(props) {
169
170
  return !!selectedOptions.find((selectOption) => selectOption.value === option.value || selectOption.value === option.label);
170
171
  };
171
172
  const renderDefaultInput = (inputRef) => {
172
- return (react_1.default.createElement(SelectInput_1.SelectInput, { id: inputId, listboxId: inputId, selectedOptions: selectedOptions, searchValue: searchValue, placeholder: placeholder, stickyValue: stickyInputValue, multiple: multiple, searchable: searchable, clearable: clearable, customIcon: icon, inputRef: inputRef, onlyIcon: onlyIcon, clearHandler: clearHandler, searchHandler: searchHandler, inputBlurHandler: inputBlurHandler, inputFocusHandler: inputFocusHandler, clickHandler: inputClickHandler }));
173
+ return (react_1.default.createElement(SelectInput_1.SelectInput, { id: inputId, listboxId: listboxId, expanded: dropdownActive, selectedOptions: selectedOptions, searchValue: searchValue, placeholder: placeholder, stickyValue: stickyInputValue, multiple: multiple, searchable: searchable, clearable: clearable, customIcon: icon, inputRef: inputRef, onlyIcon: onlyIcon, clearHandler: clearHandler, searchHandler: searchHandler, inputBlurHandler: inputBlurHandler, inputFocusHandler: inputFocusHandler, clickHandler: inputClickHandler }));
173
174
  };
174
175
  return (react_1.default.createElement(exports.SelectWrapper, Object.assign({ "data-component-name": "Select/Select", ref: selectRef }, dataAttributes, { disabled: disabled, "data-testid": dataTestId, className: className, onKeyDown: (e) => {
175
176
  if (e.key === 'Enter' && document.activeElement === selectRef.current) {
176
177
  e.preventDefault();
177
178
  setDropdownActive(!dropdownActive);
178
179
  }
179
- }, tabIndex: 0, "aria-haspopup": "listbox", "aria-expanded": dropdownActive, "aria-label": "Select option", onFocus: (e) => {
180
+ }, tabIndex: searchable ? undefined : 0 }, (!searchable && {
181
+ role: 'combobox',
182
+ 'aria-haspopup': 'listbox',
183
+ 'aria-expanded': dropdownActive,
184
+ 'aria-controls': listboxId,
185
+ 'aria-label': 'Select option',
186
+ }), { onFocus: (e) => {
180
187
  var _a;
181
188
  if (e.target === selectRef.current) {
182
189
  (_a = selectInputRef.current) === null || _a === void 0 ? void 0 : _a.focus();
@@ -185,7 +192,7 @@ function Select(props) {
185
192
  e.preventDefault();
186
193
  } }),
187
194
  react_1.default.createElement(SelectDropdown, { closeOnClick: !multiple, withArrow: withArrow, trigger: renderInput ? renderInput() : renderDefaultInput(selectInputRef), triggerEvent: triggerEvent, placement: placement, alignment: alignment, active: !renderInput ? dropdownActive : undefined },
188
- react_1.default.createElement(SelectDropdownMenu, { role: "listbox", footer: footer }, filteredOptions.length === 0 ? (react_1.default.createElement(DropdownMenuItem_1.DropdownMenuItem, { disabled: true }, translate('select.noResults', 'No results'))) : (filteredOptions.map((option, index) => {
195
+ react_1.default.createElement(SelectDropdownMenu, { id: listboxId, role: "listbox", footer: footer }, filteredOptions.length === 0 ? (react_1.default.createElement(DropdownMenuItem_1.DropdownMenuItem, { disabled: true }, translate('select.noResults', 'No results'))) : (filteredOptions.map((option, index) => {
189
196
  return (react_1.default.createElement(react_1.Fragment, { key: index },
190
197
  react_1.default.createElement(DropdownMenuItem_1.DropdownMenuItem, { onAction: () => selectHandler(option), prefix: !hideCheckmarkIcon &&
191
198
  (checkmarkIconPosition ? checkmarkIconPosition === 'start' : false) &&
@@ -13,6 +13,7 @@ type SelectInputProps<T> = {
13
13
  placeholder?: string;
14
14
  multiple?: boolean;
15
15
  searchable?: boolean;
16
+ expanded?: boolean;
16
17
  clearable?: boolean;
17
18
  inputRef?: React.ForwardedRef<HTMLInputElement>;
18
19
  clearHandler?: (value?: any) => void;
@@ -44,7 +44,7 @@ const Tag_1 = require("../../components/Tag/Tag");
44
44
  const CloseIcon_1 = require("../../icons/CloseIcon/CloseIcon");
45
45
  const Button_1 = require("../../components/Button/Button");
46
46
  function SelectInput(props) {
47
- const { id, listboxId, onlyIcon, icon, customIcon, selectedOptions, placeholder, stickyValue, multiple, searchable, clearable, clearHandler, searchHandler, clickHandler, searchValue, inputBlurHandler, inputFocusHandler, inputRef: forwardedInputRef, } = props;
47
+ const { id, listboxId, onlyIcon, icon, customIcon, selectedOptions, placeholder, stickyValue, multiple, searchable, expanded, clearable, clearHandler, searchHandler, clickHandler, searchValue, inputBlurHandler, inputFocusHandler, inputRef: forwardedInputRef, } = props;
48
48
  const inputRef = (0, react_1.useRef)(null);
49
49
  /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
50
50
  const onChangeHandler = (e) => {
@@ -95,14 +95,14 @@ function SelectInput(props) {
95
95
  else {
96
96
  forwardedInputRef.current = input;
97
97
  }
98
- }, $width: multiple ? (!searchValue && selectedOptions.length ? '10px' : 'auto') : '100%', tabIndex: 0, onFocus: (e) => {
98
+ }, $width: multiple ? (!searchValue && selectedOptions.length ? '10px' : 'auto') : '100%', tabIndex: 0, role: "combobox", "aria-expanded": !!expanded, "aria-controls": listboxId, "aria-autocomplete": "list", "aria-label": "Select option", onFocus: (e) => {
99
99
  e.stopPropagation();
100
100
  }, onMouseDown: (e) => {
101
101
  e.preventDefault();
102
102
  } }));
103
103
  const simpleValue = selectedOptions.length ? (selectedOptions[0].label || selectedOptions[0].element || selectedOptions[0].value) : (react_1.default.createElement(SelectInternalInputPlaceholder, null, placeholder));
104
104
  const multipleValues = selectedOptions.length ? (selectTags) : (react_1.default.createElement(SelectInternalInputPlaceholder, null, placeholder));
105
- return (react_1.default.createElement(exports.SelectInputWrapper, { id: id, onFocus: onFocusHandler, onClick: onClickHandler, role: "button", "aria-haspopup": "listbox", "aria-controls": listboxId, "aria-owns": listboxId, "aria-label": "Select option" },
105
+ return (react_1.default.createElement(exports.SelectInputWrapper, { id: id, onFocus: onFocusHandler, onClick: onClickHandler },
106
106
  !onlyIcon && (react_1.default.createElement(react_1.default.Fragment, null,
107
107
  react_1.default.createElement(SelectInputValue, null, multiple ? (searchable ? (react_1.default.createElement(react_1.default.Fragment, null,
108
108
  selectTags,
@@ -21,6 +21,7 @@ export * from './menu/use-mobile-menu-levels';
21
21
  export * from './menu/use-collapse';
22
22
  export * from './feedback/use-report-dialog';
23
23
  export * from './use-tabs';
24
+ export * from './use-tab-list';
24
25
  export * from './use-focus-trap';
25
26
  export * from './use-consent-dialog';
26
27
  export * from './use-consent-telemetry';
@@ -37,6 +37,7 @@ __exportStar(require("./menu/use-mobile-menu-levels"), exports);
37
37
  __exportStar(require("./menu/use-collapse"), exports);
38
38
  __exportStar(require("./feedback/use-report-dialog"), exports);
39
39
  __exportStar(require("./use-tabs"), exports);
40
+ __exportStar(require("./use-tab-list"), exports);
40
41
  __exportStar(require("./use-focus-trap"), exports);
41
42
  __exportStar(require("./use-consent-dialog"), exports);
42
43
  __exportStar(require("./use-consent-telemetry"), exports);
@@ -0,0 +1,31 @@
1
+ import type { KeyboardEvent } from 'react';
2
+ type UseTabListProps = {
3
+ count: number;
4
+ activeIndex: number;
5
+ onChange: (index: number) => void;
6
+ };
7
+ /**
8
+ * Props for a WAI-ARIA tab list with automatic activation: only the active tab is a tab stop,
9
+ * and Left/Right/Home/End select and focus another tab.
10
+ */
11
+ export declare function useTabList({ count, activeIndex, onChange }: UseTabListProps): {
12
+ tabListProps: {
13
+ role: string;
14
+ };
15
+ getTabProps: (index: number) => {
16
+ id: string;
17
+ role: string;
18
+ 'aria-selected': boolean;
19
+ 'aria-controls': string;
20
+ tabIndex: number;
21
+ ref: (element: HTMLElement | null) => void;
22
+ onClick: () => void;
23
+ onKeyDown: (event: KeyboardEvent) => void;
24
+ };
25
+ getTabPanelProps: (index: number) => {
26
+ id: string;
27
+ role: string;
28
+ 'aria-labelledby': string;
29
+ };
30
+ };
31
+ export {};
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.useTabList = useTabList;
4
+ const react_1 = require("react");
5
+ /**
6
+ * Props for a WAI-ARIA tab list with automatic activation: only the active tab is a tab stop,
7
+ * and Left/Right/Home/End select and focus another tab.
8
+ */
9
+ function useTabList({ count, activeIndex, onChange }) {
10
+ const idPrefix = (0, react_1.useId)();
11
+ const focusableIndex = activeIndex >= 0 ? activeIndex : 0;
12
+ const tabRefs = (0, react_1.useRef)([]);
13
+ const select = (0, react_1.useCallback)((index) => {
14
+ var _a;
15
+ onChange(index);
16
+ (_a = tabRefs.current[index]) === null || _a === void 0 ? void 0 : _a.focus();
17
+ }, [onChange]);
18
+ const onKeyDown = (0, react_1.useCallback)((event, index) => {
19
+ const target = {
20
+ ArrowRight: (index + 1) % count,
21
+ ArrowLeft: (index - 1 + count) % count,
22
+ Home: 0,
23
+ End: count - 1,
24
+ }[event.key];
25
+ if (target === undefined)
26
+ return;
27
+ event.preventDefault();
28
+ select(target);
29
+ }, [count, select]);
30
+ const getTabProps = (index) => ({
31
+ id: `${idPrefix}-tab-${index}`,
32
+ role: 'tab',
33
+ 'aria-selected': index === activeIndex,
34
+ 'aria-controls': `${idPrefix}-panel-${index}`,
35
+ tabIndex: index === focusableIndex ? 0 : -1,
36
+ ref: (element) => {
37
+ tabRefs.current[index] = element;
38
+ },
39
+ onClick: () => select(index),
40
+ onKeyDown: (event) => onKeyDown(event, index),
41
+ });
42
+ const getTabPanelProps = (index) => ({
43
+ id: `${idPrefix}-panel-${index}`,
44
+ role: 'tabpanel',
45
+ 'aria-labelledby': `${idPrefix}-tab-${index}`,
46
+ });
47
+ return { tabListProps: { role: 'tablist' }, getTabProps, getTabPanelProps };
48
+ }
49
+ //# sourceMappingURL=use-tab-list.js.map
@@ -3,7 +3,14 @@ interface DynamicOptions<TProps extends Record<string, unknown>> {
3
3
  ssr?: boolean;
4
4
  loading?: React.ComponentType<TProps>;
5
5
  }
6
+ /**
7
+ * A component from `dynamic()`. After `preload()` resolves, the component renders without a
8
+ * loading state. On the server, `preload()` does not load a client-only component (`ssr: false`).
9
+ */
10
+ export type DynamicComponent<TProps> = React.ComponentType<TProps> & {
11
+ preload(): Promise<void>;
12
+ };
6
13
  export declare function dynamic<TProps extends Record<string, unknown>>(factory: () => Promise<React.ComponentType<TProps> | {
7
14
  default: React.ComponentType<TProps>;
8
- } | Record<string, unknown>>, { ssr, loading }?: DynamicOptions<TProps>): React.ComponentType<TProps>;
15
+ } | Record<string, unknown>>, { ssr, loading }?: DynamicOptions<TProps>): DynamicComponent<TProps>;
9
16
  export {};
@@ -1,4 +1,13 @@
1
1
  "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
2
11
  var __importDefault = (this && this.__importDefault) || function (mod) {
3
12
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
13
  };
@@ -6,7 +15,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
15
  exports.dynamic = dynamic;
7
16
  const react_1 = __importDefault(require("react"));
8
17
  function dynamic(factory, { ssr = true, loading } = {}) {
9
- const Component = react_1.default.lazy(() => factory().then((mod) => {
18
+ let loaded;
19
+ let pending;
20
+ const load = () => (pending !== null && pending !== void 0 ? pending : (pending = factory()
21
+ .then((mod) => {
10
22
  // Case 1: factory() => import('./SomeModule') - direct import
11
23
  // Case 2: factory() => import('./SomeModule').then(mod => mod.default) - extract default
12
24
  if (typeof mod === 'function') {
@@ -38,10 +50,21 @@ function dynamic(factory, { ssr = true, loading } = {}) {
38
50
  }
39
51
  }
40
52
  throw new Error('Dynamic import did not return a valid React component');
41
- }));
53
+ })
54
+ .then((module) => {
55
+ loaded = module.default;
56
+ return module;
57
+ }, (error) => {
58
+ // A failed load is not kept, so the next preload tries again.
59
+ pending = undefined;
60
+ throw error;
61
+ })));
62
+ const LazyComponent = react_1.default.lazy(load);
42
63
  const LoadingComponent = loading || (() => null);
43
- return (props) => {
64
+ const DynamicComponent = (props) => {
44
65
  const [hasMounted, setHasMounted] = react_1.default.useState(false);
66
+ // Fixed per instance: a change of the element type would remount the component.
67
+ const [Component] = react_1.default.useState(() => loaded !== null && loaded !== void 0 ? loaded : LazyComponent);
45
68
  react_1.default.useEffect(() => {
46
69
  setHasMounted(true);
47
70
  }, []);
@@ -55,5 +78,13 @@ function dynamic(factory, { ssr = true, loading } = {}) {
55
78
  return (react_1.default.createElement(react_1.default.Suspense, { fallback: react_1.default.createElement(LoadingComponent, Object.assign({}, props)) },
56
79
  react_1.default.createElement(Component, Object.assign({}, props))));
57
80
  };
81
+ return Object.assign(DynamicComponent, {
82
+ preload() {
83
+ return __awaiter(this, void 0, void 0, function* () {
84
+ if (ssr || typeof window !== 'undefined')
85
+ yield load();
86
+ });
87
+ },
88
+ });
58
89
  }
59
90
  //# sourceMappingURL=Dynamic.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redocly/theme",
3
- "version": "0.70.0-next.4",
3
+ "version": "0.70.0-next.5",
4
4
  "description": "Shared UI components lib",
5
5
  "keywords": [
6
6
  "theme",
@@ -71,7 +71,7 @@
71
71
  "webpack": "5.105.2"
72
72
  },
73
73
  "dependencies": {
74
- "@redocly/config": "0.61.0",
74
+ "@redocly/config": "0.61.1",
75
75
  "@tanstack/react-query": "5.62.3",
76
76
  "@tanstack/react-virtual": "3.13.0",
77
77
  "@xyflow/react": "12.8.3",
@@ -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`
@@ -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,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>
@@ -5,34 +5,43 @@ import type { ForwardedRef, ReactElement } from 'react';
5
5
  import type { SegmentedProps } from '@redocly/theme/core/types';
6
6
 
7
7
  import { typedMemo } from '@redocly/theme/core/hoc/typed-memo';
8
+ import { useTabList } from '@redocly/theme/core/hooks';
8
9
 
9
10
  function SegmentedComponent<T>(
10
11
  { options, onChange, value, className = '', size = 'regular' }: SegmentedProps<T>,
11
12
  ref?: ForwardedRef<HTMLDivElement>,
12
13
  ): ReactElement {
14
+ const { tabListProps, getTabProps } = useTabList({
15
+ count: options.length,
16
+ activeIndex: options.findIndex((opt) => value == opt.value),
17
+ onChange: (index) => onChange(options[index]),
18
+ });
19
+
13
20
  return (
14
21
  <SegmentedGroup
15
22
  ref={ref}
16
23
  data-component-name="Segmented/Segmented"
17
24
  className={`tag-grey ${size} ${className}`}
18
- role="tablist"
25
+ {...tabListProps}
19
26
  $size={size}
20
27
  >
21
- {options.map((opt, index) => (
22
- <React.Fragment key={index}>
23
- {opt.divider}
24
- <SegmentedItem
25
- key={opt.label}
26
- role="tab"
27
- title={opt.label}
28
- onClick={() => onChange(opt)}
29
- $isActive={value == opt.value}
30
- $size={size}
31
- >
32
- {opt.element || opt.label}
33
- </SegmentedItem>
34
- </React.Fragment>
35
- ))}
28
+ {options.map((opt, index) => {
29
+ const { 'aria-controls': _noPanel, ...tabProps } = getTabProps(index);
30
+ return (
31
+ <React.Fragment key={index}>
32
+ {opt.divider}
33
+ <SegmentedItem
34
+ key={opt.label}
35
+ {...tabProps}
36
+ title={opt.label}
37
+ $isActive={value == opt.value}
38
+ $size={size}
39
+ >
40
+ {opt.element || opt.label}
41
+ </SegmentedItem>
42
+ </React.Fragment>
43
+ );
44
+ })}
36
45
  </SegmentedGroup>
37
46
  );
38
47
  }
@@ -63,6 +63,7 @@ export function Select<T>(props: SelectProps<T>): JSX.Element {
63
63
  const [filteredOptions, setFilteredOptions] = useState<SelectOption<T>[]>(options);
64
64
  const [stickyInputValue, setStickyInputValue] = useState<string>(placeholder || '');
65
65
  const inputId = useId();
66
+ const listboxId = `${inputId}-listbox`;
66
67
 
67
68
  useOutsideClick(selectRef, () => {
68
69
  setDropdownActive(false);
@@ -189,7 +190,8 @@ export function Select<T>(props: SelectProps<T>): JSX.Element {
189
190
  return (
190
191
  <SelectInput
191
192
  id={inputId}
192
- listboxId={inputId}
193
+ listboxId={listboxId}
194
+ expanded={dropdownActive}
193
195
  selectedOptions={selectedOptions}
194
196
  searchValue={searchValue}
195
197
  placeholder={placeholder}
@@ -223,10 +225,14 @@ export function Select<T>(props: SelectProps<T>): JSX.Element {
223
225
  setDropdownActive(!dropdownActive);
224
226
  }
225
227
  }}
226
- tabIndex={0}
227
- aria-haspopup="listbox"
228
- aria-expanded={dropdownActive}
229
- aria-label="Select option"
228
+ tabIndex={searchable ? undefined : 0}
229
+ {...(!searchable && {
230
+ role: 'combobox',
231
+ 'aria-haspopup': 'listbox' as const,
232
+ 'aria-expanded': dropdownActive,
233
+ 'aria-controls': listboxId,
234
+ 'aria-label': 'Select option',
235
+ })}
230
236
  onFocus={(e) => {
231
237
  if (e.target === selectRef.current) {
232
238
  selectInputRef.current?.focus();
@@ -245,7 +251,7 @@ export function Select<T>(props: SelectProps<T>): JSX.Element {
245
251
  alignment={alignment}
246
252
  active={!renderInput ? dropdownActive : undefined}
247
253
  >
248
- <SelectDropdownMenu role="listbox" footer={footer}>
254
+ <SelectDropdownMenu id={listboxId} role="listbox" footer={footer}>
249
255
  {filteredOptions.length === 0 ? (
250
256
  <DropdownMenuItem disabled>
251
257
  {translate('select.noResults', 'No results')}
@@ -22,6 +22,7 @@ type SelectInputProps<T> = {
22
22
  placeholder?: string;
23
23
  multiple?: boolean;
24
24
  searchable?: boolean;
25
+ expanded?: boolean;
25
26
  clearable?: boolean;
26
27
  inputRef?: React.ForwardedRef<HTMLInputElement>;
27
28
  /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
@@ -48,6 +49,7 @@ export function SelectInput<T>(props: SelectInputProps<T>): React.ReactNode {
48
49
  stickyValue,
49
50
  multiple,
50
51
  searchable,
52
+ expanded,
51
53
  clearable,
52
54
  clearHandler,
53
55
  searchHandler,
@@ -134,6 +136,11 @@ export function SelectInput<T>(props: SelectInputProps<T>): React.ReactNode {
134
136
  }}
135
137
  $width={multiple ? (!searchValue && selectedOptions.length ? '10px' : 'auto') : '100%'}
136
138
  tabIndex={0}
139
+ role="combobox"
140
+ aria-expanded={!!expanded}
141
+ aria-controls={listboxId}
142
+ aria-autocomplete="list"
143
+ aria-label="Select option"
137
144
  onFocus={(e) => {
138
145
  e.stopPropagation();
139
146
  }}
@@ -156,16 +163,7 @@ export function SelectInput<T>(props: SelectInputProps<T>): React.ReactNode {
156
163
  );
157
164
 
158
165
  return (
159
- <SelectInputWrapper
160
- id={id}
161
- onFocus={onFocusHandler}
162
- onClick={onClickHandler}
163
- role="button"
164
- aria-haspopup="listbox"
165
- aria-controls={listboxId}
166
- aria-owns={listboxId}
167
- aria-label="Select option"
168
- >
166
+ <SelectInputWrapper id={id} onFocus={onFocusHandler} onClick={onClickHandler}>
169
167
  {!onlyIcon && (
170
168
  <>
171
169
  <SelectInputValue>
@@ -20,6 +20,7 @@ export const useThemeHooks = vi.fn(() => ({
20
20
  sendPageActionsButtonClickedMessage: vi.fn(),
21
21
  sendColorModeSwitchedMessage: vi.fn(),
22
22
  sendConsentChangedMessage: vi.fn(),
23
+ sendFilterCheckboxToggledMessage: vi.fn(),
23
24
  })),
24
25
  useBreadcrumbs: vi.fn().mockReturnValue({ breadcrumbs: [], siblings: undefined }),
25
26
  useBanner: vi.fn(() => ({
@@ -21,6 +21,7 @@ export * from './menu/use-mobile-menu-levels';
21
21
  export * from './menu/use-collapse';
22
22
  export * from './feedback/use-report-dialog';
23
23
  export * from './use-tabs';
24
+ export * from './use-tab-list';
24
25
  export * from './use-focus-trap';
25
26
  export * from './use-consent-dialog';
26
27
  export * from './use-consent-telemetry';
@@ -0,0 +1,63 @@
1
+ import { useCallback, useId, useRef } from 'react';
2
+
3
+ import type { KeyboardEvent } from 'react';
4
+
5
+ type UseTabListProps = {
6
+ count: number;
7
+ activeIndex: number;
8
+ onChange: (index: number) => void;
9
+ };
10
+
11
+ /**
12
+ * Props for a WAI-ARIA tab list with automatic activation: only the active tab is a tab stop,
13
+ * and Left/Right/Home/End select and focus another tab.
14
+ */
15
+ export function useTabList({ count, activeIndex, onChange }: UseTabListProps) {
16
+ const idPrefix = useId();
17
+ const focusableIndex = activeIndex >= 0 ? activeIndex : 0;
18
+ const tabRefs = useRef<(HTMLElement | null)[]>([]);
19
+
20
+ const select = useCallback(
21
+ (index: number) => {
22
+ onChange(index);
23
+ tabRefs.current[index]?.focus();
24
+ },
25
+ [onChange],
26
+ );
27
+
28
+ const onKeyDown = useCallback(
29
+ (event: KeyboardEvent, index: number) => {
30
+ const target = (<Record<string, number>>{
31
+ ArrowRight: (index + 1) % count,
32
+ ArrowLeft: (index - 1 + count) % count,
33
+ Home: 0,
34
+ End: count - 1,
35
+ })[event.key];
36
+ if (target === undefined) return;
37
+ event.preventDefault();
38
+ select(target);
39
+ },
40
+ [count, select],
41
+ );
42
+
43
+ const getTabProps = (index: number) => ({
44
+ id: `${idPrefix}-tab-${index}`,
45
+ role: 'tab',
46
+ 'aria-selected': index === activeIndex,
47
+ 'aria-controls': `${idPrefix}-panel-${index}`,
48
+ tabIndex: index === focusableIndex ? 0 : -1,
49
+ ref: (element: HTMLElement | null) => {
50
+ tabRefs.current[index] = element;
51
+ },
52
+ onClick: () => select(index),
53
+ onKeyDown: (event: KeyboardEvent) => onKeyDown(event, index),
54
+ });
55
+
56
+ const getTabPanelProps = (index: number) => ({
57
+ id: `${idPrefix}-panel-${index}`,
58
+ role: 'tabpanel',
59
+ 'aria-labelledby': `${idPrefix}-tab-${index}`,
60
+ });
61
+
62
+ return { tabListProps: { role: 'tablist' }, getTabProps, getTabPanelProps };
63
+ }
@@ -5,60 +5,86 @@ interface DynamicOptions<TProps extends Record<string, unknown>> {
5
5
  loading?: React.ComponentType<TProps>;
6
6
  }
7
7
 
8
+ /**
9
+ * A component from `dynamic()`. After `preload()` resolves, the component renders without a
10
+ * loading state. On the server, `preload()` does not load a client-only component (`ssr: false`).
11
+ */
12
+ export type DynamicComponent<TProps> = React.ComponentType<TProps> & {
13
+ preload(): Promise<void>;
14
+ };
15
+
8
16
  export function dynamic<TProps extends Record<string, unknown>>(
9
17
  factory: () => Promise<
10
18
  React.ComponentType<TProps> | { default: React.ComponentType<TProps> } | Record<string, unknown>
11
19
  >,
12
20
  { ssr = true, loading }: DynamicOptions<TProps> = {},
13
- ): React.ComponentType<TProps> {
14
- const Component = React.lazy(() =>
15
- factory().then((mod) => {
16
- // Case 1: factory() => import('./SomeModule') - direct import
17
- // Case 2: factory() => import('./SomeModule').then(mod => mod.default) - extract default
18
- if (typeof mod === 'function') {
19
- return { default: mod };
20
- }
21
-
22
- // Case 3: factory() => import('./SomeModule').then(mod => ({ default: mod.default })) - manual wrap
23
- // Case 4: factory() => import('./SomeModule').then(mod => ({ default: mod.Named })) - manual wrap named
24
- if (mod && typeof mod === 'object' && 'default' in mod) {
25
- const defaultExport = mod.default;
26
-
27
- // Handle both function components and React component objects (e.g., memo/forwardRef)
28
- if (typeof defaultExport === 'function') {
29
- return { default: defaultExport as React.ComponentType<TProps> };
21
+ ): DynamicComponent<TProps> {
22
+ let loaded: React.ComponentType<TProps> | undefined;
23
+ let pending: Promise<{ default: React.ComponentType<TProps> }> | undefined;
24
+
25
+ const load = () =>
26
+ (pending ??= factory()
27
+ .then((mod) => {
28
+ // Case 1: factory() => import('./SomeModule') - direct import
29
+ // Case 2: factory() => import('./SomeModule').then(mod => mod.default) - extract default
30
+ if (typeof mod === 'function') {
31
+ return { default: mod };
30
32
  }
31
33
 
32
- if (defaultExport && typeof defaultExport === 'object' && '$$typeof' in defaultExport) {
33
- return { default: defaultExport as React.ComponentType<TProps> };
34
+ // Case 3: factory() => import('./SomeModule').then(mod => ({ default: mod.default })) - manual wrap
35
+ // Case 4: factory() => import('./SomeModule').then(mod => ({ default: mod.Named })) - manual wrap named
36
+ if (mod && typeof mod === 'object' && 'default' in mod) {
37
+ const defaultExport = mod.default;
38
+
39
+ // Handle both function components and React component objects (e.g., memo/forwardRef)
40
+ if (typeof defaultExport === 'function') {
41
+ return { default: defaultExport as React.ComponentType<TProps> };
42
+ }
43
+
44
+ if (defaultExport && typeof defaultExport === 'object' && '$$typeof' in defaultExport) {
45
+ return { default: defaultExport as React.ComponentType<TProps> };
46
+ }
34
47
  }
35
- }
36
48
 
37
- // Case 5: factory() => import('./SomeModule').then(mod => mod.Named) - find named export
38
- if (mod && typeof mod === 'object') {
39
- const modObj = mod as Record<string, unknown>;
49
+ // Case 5: factory() => import('./SomeModule').then(mod => mod.Named) - find named export
50
+ if (mod && typeof mod === 'object') {
51
+ const modObj = mod as Record<string, unknown>;
40
52
 
41
- for (const key in modObj) {
42
- const value = modObj[key];
53
+ for (const key in modObj) {
54
+ const value = modObj[key];
43
55
 
44
- if (typeof value === 'function') {
45
- return { default: value as React.ComponentType<TProps> };
46
- }
56
+ if (typeof value === 'function') {
57
+ return { default: value as React.ComponentType<TProps> };
58
+ }
47
59
 
48
- if (value && typeof value === 'object' && '$$typeof' in value) {
49
- return { default: value as React.ComponentType<TProps> };
60
+ if (value && typeof value === 'object' && '$$typeof' in value) {
61
+ return { default: value as React.ComponentType<TProps> };
62
+ }
50
63
  }
51
64
  }
52
- }
53
65
 
54
- throw new Error('Dynamic import did not return a valid React component');
55
- }),
56
- );
66
+ throw new Error('Dynamic import did not return a valid React component');
67
+ })
68
+ .then(
69
+ (module) => {
70
+ loaded = module.default;
71
+ return module;
72
+ },
73
+ (error: unknown) => {
74
+ // A failed load is not kept, so the next preload tries again.
75
+ pending = undefined;
76
+ throw error;
77
+ },
78
+ ));
79
+
80
+ const LazyComponent = React.lazy(load);
57
81
 
58
82
  const LoadingComponent: React.ComponentType<TProps> = loading || (() => null);
59
83
 
60
- return (props: TProps) => {
84
+ const DynamicComponent = (props: TProps) => {
61
85
  const [hasMounted, setHasMounted] = React.useState(false);
86
+ // Fixed per instance: a change of the element type would remount the component.
87
+ const [Component] = React.useState(() => loaded ?? LazyComponent);
62
88
 
63
89
  React.useEffect(() => {
64
90
  setHasMounted(true);
@@ -82,4 +108,10 @@ export function dynamic<TProps extends Record<string, unknown>>(
82
108
  </React.Suspense>
83
109
  );
84
110
  };
111
+
112
+ return Object.assign(DynamicComponent, {
113
+ async preload() {
114
+ if (ssr || typeof window !== 'undefined') await load();
115
+ },
116
+ });
85
117
  }
@@ -11,6 +11,7 @@ const Icon = ({
11
11
  checked?: boolean;
12
12
  onClick?: () => void;
13
13
  }) => (
14
+ // oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- the control that renders the icon handles the keyboard
14
15
  <span className={className} onClick={onClick}>
15
16
  {checked ? (
16
17
  <svg
@@ -6,6 +6,7 @@ import type { IconProps } from '@redocly/theme/icons/types';
6
6
  import { getCssColorVariable } from '@redocly/theme/core/utils';
7
7
 
8
8
  const Icon = ({ className, onClick }: IconProps & { onClick?: () => void }) => (
9
+ // oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- the control that renders the icon handles the keyboard
9
10
  <svg
10
11
  className={className}
11
12
  onClick={onClick}