@redocly/theme 0.54.0-next.0 → 0.54.0-next.2

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 (41) hide show
  1. package/lib/components/CodeBlock/CodeBlockContainer.js +4 -0
  2. package/lib/components/CodeBlock/variables.js +1 -0
  3. package/lib/components/Feedback/Mood.js +13 -2
  4. package/lib/components/Feedback/Rating.js +13 -2
  5. package/lib/components/Feedback/Scale.js +13 -2
  6. package/lib/components/Feedback/Sentiment.js +13 -2
  7. package/lib/components/Image/Image.d.ts +1 -0
  8. package/lib/components/Image/Image.js +66 -16
  9. package/lib/components/Search/SearchDialog.js +19 -9
  10. package/lib/components/Search/SearchInput.js +7 -9
  11. package/lib/core/constants/search.d.ts +1 -0
  12. package/lib/core/constants/search.js +2 -1
  13. package/lib/core/hooks/__mocks__/index.d.ts +1 -0
  14. package/lib/core/hooks/__mocks__/index.js +1 -0
  15. package/lib/core/hooks/__mocks__/use-input-key-commands.d.ts +3 -0
  16. package/lib/core/hooks/__mocks__/use-input-key-commands.js +7 -0
  17. package/lib/core/hooks/index.d.ts +1 -0
  18. package/lib/core/hooks/index.js +1 -0
  19. package/lib/core/hooks/menu/use-nested-menu.js +7 -1
  20. package/lib/core/hooks/search/use-recent-searches.js +48 -25
  21. package/lib/core/hooks/use-input-key-commands.d.ts +8 -0
  22. package/lib/core/hooks/use-input-key-commands.js +71 -0
  23. package/lib/markdoc/tags/img.js +3 -0
  24. package/package.json +2 -2
  25. package/src/components/CodeBlock/CodeBlockContainer.tsx +4 -0
  26. package/src/components/CodeBlock/variables.ts +1 -0
  27. package/src/components/Feedback/Mood.tsx +15 -2
  28. package/src/components/Feedback/Rating.tsx +15 -2
  29. package/src/components/Feedback/Scale.tsx +15 -2
  30. package/src/components/Feedback/Sentiment.tsx +15 -4
  31. package/src/components/Image/Image.tsx +72 -20
  32. package/src/components/Search/SearchDialog.tsx +83 -58
  33. package/src/components/Search/SearchInput.tsx +9 -12
  34. package/src/core/constants/search.ts +2 -0
  35. package/src/core/hooks/__mocks__/index.ts +1 -0
  36. package/src/core/hooks/__mocks__/use-input-key-commands.ts +3 -0
  37. package/src/core/hooks/index.ts +1 -0
  38. package/src/core/hooks/menu/use-nested-menu.ts +9 -1
  39. package/src/core/hooks/search/use-recent-searches.ts +57 -24
  40. package/src/core/hooks/use-input-key-commands.ts +98 -0
  41. package/src/markdoc/tags/img.ts +3 -0
@@ -37,3 +37,5 @@ export const AI_SEARCH_ERROR_CONFIG: Record<AiSearchError, AiSearchErrorConfig>
37
37
  [AiSearchError.EmptyResponse]: defaultErrorConfig,
38
38
  [AiSearchError.ErrorProcessingResponse]: defaultErrorConfig,
39
39
  } as const;
40
+
41
+ export const SEARCH_DEBOUNCE_TIME_MS = 300;
@@ -25,3 +25,4 @@ export * from '@redocly/theme/core/hooks/search/use-search-dialog';
25
25
  export * from '@redocly/theme/core/hooks/use-language-picker';
26
26
  export * from '@redocly/theme/core/hooks/__mocks__/use-element-size';
27
27
  export * from '@redocly/theme/core/hooks/__mocks__/use-time-ago';
28
+ export * from '@redocly/theme/core/hooks/__mocks__/use-input-key-commands';
@@ -0,0 +1,3 @@
1
+ export const useInputKeyCommands = jest.fn(() => ({
2
+ onKeyDown: jest.fn(),
3
+ }));
@@ -35,3 +35,4 @@ export * from '@redocly/theme/core/hooks/code-walkthrough/use-code-panel';
35
35
  export * from '@redocly/theme/core/hooks/code-walkthrough/use-renderable-files';
36
36
  export * from '@redocly/theme/core/hooks/use-element-size';
37
37
  export * from '@redocly/theme/core/hooks/use-time-ago';
38
+ export * from '@redocly/theme/core/hooks/use-input-key-commands';
@@ -37,8 +37,16 @@ export function useNestedMenu({ item, labelRef, nestedMenuRef }: NestedMenuProps
37
37
  });
38
38
 
39
39
  function scrollIfNeeded(el: Element, centerIfNeeded: boolean = false) {
40
+ const rect = el.getBoundingClientRect();
41
+ const isInViewport =
42
+ rect.top >= 0 &&
43
+ rect.left >= 0 &&
44
+ rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
45
+ rect.right <= (window.innerWidth || document.documentElement.clientWidth);
46
+
47
+ // Only scroll if element is in viewport to prevent page jumping
40
48
  // @ts-ignore
41
- if (typeof el.scrollIntoViewIfNeeded === 'function') {
49
+ if (isInViewport && typeof el.scrollIntoViewIfNeeded === 'function') {
42
50
  // @ts-ignore
43
51
  el.scrollIntoViewIfNeeded(centerIfNeeded);
44
52
  }
@@ -1,49 +1,82 @@
1
- import { useCallback, useState } from 'react';
1
+ import { useCallback, useSyncExternalStore } from 'react';
2
2
 
3
3
  import { isBrowser } from '@redocly/theme/core/utils';
4
4
 
5
5
  const RECENT_SEARCHES_KEY = 'recentSearches';
6
6
  const RECENT_SEARCHES_LIMIT = 5;
7
7
 
8
- function getRecentSearches() {
9
- if (!isBrowser()) return [];
8
+ const createRecentSearchesStore = () => {
9
+ const subscribers = new Set<() => void>();
10
+ let cachedSnapshot: string[];
10
11
 
11
- const recentSearchesStr = window.localStorage.getItem(RECENT_SEARCHES_KEY);
12
+ const getSnapshot = (): string[] => {
13
+ if (!isBrowser()) return [];
12
14
 
13
- if (!recentSearchesStr) return [];
15
+ if (cachedSnapshot) return cachedSnapshot;
14
16
 
15
- return JSON.parse(recentSearchesStr) as string[];
16
- }
17
+ try {
18
+ const stored = localStorage.getItem(RECENT_SEARCHES_KEY);
19
+ cachedSnapshot = stored ? JSON.parse(stored) : [];
20
+ return cachedSnapshot;
21
+ } catch (e) {
22
+ cachedSnapshot = [];
23
+ return cachedSnapshot;
24
+ }
25
+ };
17
26
 
18
- function updateRecentSearches(value: string, isAdd: boolean) {
19
- if (!isBrowser()) return [];
27
+ const updateItems = (value: string, isAdd: boolean) => {
28
+ if (!isBrowser()) return;
20
29
 
21
- const recentSearches = getRecentSearches();
30
+ const currentItems = getSnapshot();
31
+ const valueIndex = currentItems.indexOf(value);
22
32
 
23
- if (value === '') return recentSearches;
33
+ if (valueIndex !== -1) {
34
+ currentItems.splice(valueIndex, 1);
35
+ }
24
36
 
25
- const valueIndex = recentSearches.indexOf(value);
37
+ if (isAdd) {
38
+ currentItems.unshift(value);
39
+ }
26
40
 
27
- if (valueIndex !== -1) recentSearches.splice(valueIndex, 1);
28
- if (isAdd) recentSearches.unshift(value);
41
+ const limitedItems = currentItems.slice(0, RECENT_SEARCHES_LIMIT);
29
42
 
30
- localStorage?.setItem(
31
- RECENT_SEARCHES_KEY,
32
- JSON.stringify(recentSearches.slice(0, RECENT_SEARCHES_LIMIT)),
33
- );
43
+ localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(limitedItems));
44
+ cachedSnapshot = limitedItems;
45
+
46
+ subscribers.forEach((callback) => callback());
47
+ };
34
48
 
35
- return recentSearches;
36
- }
49
+ const subscribe = (callback: () => void) => {
50
+ subscribers.add(callback);
51
+ return () => subscribers.delete(callback);
52
+ };
37
53
 
38
- export const useRecentSearches = () => {
39
- const [items, setItems] = useState<string[]>(getRecentSearches());
54
+ return {
55
+ getSnapshot,
56
+ subscribe,
57
+ updateItems,
58
+ };
59
+ };
60
+
61
+ const recentSearchesStore = createRecentSearchesStore();
62
+
63
+ export const useRecentSearches = (): {
64
+ items: string[];
65
+ addSearchHistoryItem: (value: string) => void;
66
+ removeSearchHistoryItem: (value: string) => void;
67
+ } => {
68
+ const items = useSyncExternalStore(
69
+ recentSearchesStore.subscribe,
70
+ recentSearchesStore.getSnapshot,
71
+ () => [],
72
+ );
40
73
 
41
74
  const addSearchHistoryItem = useCallback((value: string) => {
42
- setItems(updateRecentSearches(value, true));
75
+ recentSearchesStore.updateItems(value, true);
43
76
  }, []);
44
77
 
45
78
  const removeSearchHistoryItem = useCallback((value: string) => {
46
- setItems(updateRecentSearches(value, false));
79
+ recentSearchesStore.updateItems(value, false);
47
80
  }, []);
48
81
 
49
82
  return { items, addSearchHistoryItem, removeSearchHistoryItem };
@@ -0,0 +1,98 @@
1
+ import { useCallback, useMemo } from 'react';
2
+
3
+ type Action = 'selectAll' | 'escape' | 'clear' | 'enter' | 'paste';
4
+ type ActionHandlers = {
5
+ [key in `on${Capitalize<Action>}`]?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
6
+ };
7
+ type KeyboardCommand = {
8
+ match: (event: React.KeyboardEvent<HTMLInputElement>) => boolean;
9
+ execute: (event: React.KeyboardEvent<HTMLInputElement>) => void;
10
+ };
11
+
12
+ export function useInputKeyCommands(actionHandlers?: ActionHandlers) {
13
+ // MacOS uses Command key instead of Ctrl
14
+ const ctrlKey = useMemo(() => (navigator.userAgent.includes('Mac') ? 'metaKey' : 'ctrlKey'), []);
15
+
16
+ const isSelectAll = useCallback(
17
+ (event: React.KeyboardEvent<HTMLInputElement>) => {
18
+ return event.key === 'a' && event[ctrlKey];
19
+ },
20
+ [ctrlKey],
21
+ );
22
+
23
+ const isPaste = useCallback(
24
+ (event: React.KeyboardEvent<HTMLInputElement>) => {
25
+ return event.key === 'v' && event[ctrlKey];
26
+ },
27
+ [ctrlKey],
28
+ );
29
+
30
+ const commands = useMemo<KeyboardCommand[]>(
31
+ () => [
32
+ {
33
+ match: (event) => event.key === 'Enter',
34
+ execute: (event) => actionHandlers?.onEnter?.(event),
35
+ },
36
+ {
37
+ match: (event) => event.key === 'Escape',
38
+ execute: (event) => {
39
+ actionHandlers?.onEscape?.(event);
40
+
41
+ if (event.currentTarget?.selectionStart !== event.currentTarget?.selectionEnd) {
42
+ event.stopPropagation();
43
+ removeSelection(event);
44
+ }
45
+ },
46
+ },
47
+ {
48
+ match: isSelectAll,
49
+ execute: (event) => actionHandlers?.onSelectAll?.(event),
50
+ },
51
+ {
52
+ match: isPaste,
53
+ execute: (event) => actionHandlers?.onPaste?.(event),
54
+ },
55
+ {
56
+ match: (event) => {
57
+ if (!event.currentTarget?.value) return false;
58
+
59
+ const selectionLength =
60
+ (event.currentTarget?.selectionEnd ?? 0) - (event.currentTarget?.selectionStart ?? 0);
61
+ const isFullValueSelected = event.currentTarget?.value.length === selectionLength;
62
+ const isModifyAction = isPrintableCharacter(event) || isPaste(event) || isDelete(event);
63
+
64
+ return isFullValueSelected && isModifyAction;
65
+ },
66
+
67
+ execute: (event) => actionHandlers?.onClear?.(event),
68
+ },
69
+ ],
70
+ [actionHandlers, isPaste, isSelectAll],
71
+ );
72
+
73
+ const onKeyDown = useCallback(
74
+ (event: React.KeyboardEvent<HTMLInputElement>) => {
75
+ for (const command of commands) {
76
+ if (command.match(event)) {
77
+ command.execute(event);
78
+ }
79
+ }
80
+ },
81
+ [commands],
82
+ );
83
+
84
+ return { onKeyDown };
85
+ }
86
+
87
+ function removeSelection(event: React.KeyboardEvent<HTMLInputElement>) {
88
+ const selectionEnd = event.currentTarget.selectionEnd ?? 0;
89
+ event.currentTarget.setSelectionRange(selectionEnd, selectionEnd);
90
+ }
91
+
92
+ function isPrintableCharacter(event: React.KeyboardEvent<HTMLInputElement>) {
93
+ return event.key.length === 1 && !event.ctrlKey && !event.metaKey;
94
+ }
95
+
96
+ function isDelete(event: React.KeyboardEvent<HTMLInputElement>) {
97
+ return event.key === 'Backspace' || event.key === 'Delete';
98
+ }
@@ -38,6 +38,9 @@ export const img: MarkdocSchemaWrapper = {
38
38
  style: {
39
39
  type: [Object, String],
40
40
  },
41
+ lightboxStyle: {
42
+ type: [Object, String],
43
+ },
41
44
  },
42
45
  validate: (node) => {
43
46
  if (!node.attributes.src && !node.attributes.srcSet) {