@homecode/ui 5.6.1 → 5.7.1

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 (54) hide show
  1. package/dist/esm/src/components/Autocomplete/Autocomplete.js +6 -3
  2. package/dist/esm/src/components/InputFile/InputFile.js +4 -1
  3. package/dist/esm/src/components/Popup/Popup.helpers.js +8 -1
  4. package/dist/esm/src/components/Popup/Popup.js +12 -6
  5. package/dist/esm/src/components/PromptComposer/PromptComposer.js +5 -1
  6. package/dist/esm/src/components/PromptComposer/promptComposerInsert.js +2 -1
  7. package/dist/esm/src/components/PromptComposer/usePromptComposerEditor.js +99 -21
  8. package/dist/esm/src/components/Select/Select.helpers.js +28 -1
  9. package/dist/esm/src/components/Select/Select.hooks.js +131 -0
  10. package/dist/esm/src/components/Select/Select2.js +213 -235
  11. package/dist/esm/src/components/Tooltip/Tooltip.styl.js +1 -1
  12. package/dist/esm/src/components/Tooltip/TooltipOverTrigger.js +4 -3
  13. package/dist/esm/src/components/Tooltip/tooltipTextStyles.js +17 -2
  14. package/dist/esm/src/tiptap/slash-mention/createSlashMentionExtension.js +51 -31
  15. package/dist/esm/src/tools/dom.js +6 -8
  16. package/dist/esm/src/tools/resizeObserver.js +1 -1
  17. package/dist/esm/types/src/components/Popup/Popup.d.ts +2 -1
  18. package/dist/esm/types/src/components/Popup/Popup.helpers.d.ts +1 -0
  19. package/dist/esm/types/src/components/Popup/Popup.types.d.ts +1 -0
  20. package/dist/esm/types/src/components/PromptComposer/PromptComposer.d.ts +4 -0
  21. package/dist/esm/types/src/components/PromptComposer/PromptComposer.types.d.ts +17 -3
  22. package/dist/esm/types/src/components/PromptComposer/index.d.ts +1 -1
  23. package/dist/esm/types/src/components/PromptComposer/usePromptComposerEditor.d.ts +6 -2
  24. package/dist/esm/types/src/components/Select/Select.helpers.d.ts +7 -1
  25. package/dist/esm/types/src/components/Select/Select.hooks.d.ts +55 -0
  26. package/dist/esm/types/src/components/Select/Select.types.d.ts +1 -1
  27. package/dist/esm/types/src/components/Select/Select2.test.d.ts +1 -0
  28. package/dist/esm/types/src/tiptap/slash-mention/createSlashMentionExtension.d.ts +1 -1
  29. package/dist/esm/types/src/tiptap/slash-mention/index.d.ts +1 -1
  30. package/dist/esm/types/src/tiptap/slash-mention/types.d.ts +12 -1
  31. package/graphify-out/.graphify_labels.json +18 -25
  32. package/graphify-out/2026-07-26/.graphify_labels.json +216 -0
  33. package/graphify-out/2026-07-26/GRAPH_REPORT.md +1036 -0
  34. package/graphify-out/2026-07-26/cost.json +12 -0
  35. package/graphify-out/2026-07-26/graph.json +71426 -0
  36. package/graphify-out/2026-07-26/manifest.json +2372 -0
  37. package/graphify-out/2026-07-27/.graphify_labels.json +214 -0
  38. package/graphify-out/2026-07-27/GRAPH_REPORT.md +1014 -0
  39. package/graphify-out/2026-07-27/cost.json +12 -0
  40. package/graphify-out/2026-07-27/graph.json +71778 -0
  41. package/graphify-out/2026-07-27/manifest.json +2372 -0
  42. package/graphify-out/2026-07-30/.graphify_labels.json +197 -0
  43. package/graphify-out/2026-07-30/GRAPH_REPORT.md +929 -0
  44. package/graphify-out/2026-07-30/cost.json +12 -0
  45. package/graphify-out/2026-07-30/graph.json +71798 -0
  46. package/graphify-out/2026-07-30/manifest.json +2372 -0
  47. package/graphify-out/GRAPH_REPORT.md +167 -249
  48. package/graphify-out/cache/ast/2690b69f91795ec634393c1fb8d9fd21ec09ec6759eaf6711e3b86c2a8edb385.json +1 -0
  49. package/graphify-out/cache/ast/2ae06c5acabf11eea845c93c2a8486317fbb18dccc1843ecd88077848af2ab29.json +1 -0
  50. package/graphify-out/cache/stat-index.json +1 -1
  51. package/graphify-out/graph.html +4 -4
  52. package/graphify-out/graph.json +3354 -2931
  53. package/graphify-out/manifest.json +47 -47
  54. package/package.json +1 -1
@@ -49,19 +49,22 @@ import '../GridLayout/GridLayout.styl.js';
49
49
  import '../Heading/Heading.js';
50
50
  import '../InputFile/InputFile.styl.js';
51
51
  import '../InputFile/Item/Item.styl.js';
52
- import '../../tiptap/slash-mention/createSlashMentionExtension.js';
52
+ import '@tiptap/core';
53
+ import '@tiptap/extension-mention';
54
+ import '@tiptap/pm/state';
55
+ import '@tiptap/react';
56
+ import { Menu } from '../Menu/Menu.js';
57
+ import '../../tiptap/slash-mention/SlashSuggestionList.styl.js';
53
58
  import '../../services/i18n.js';
54
59
  import '../Portal/Portal.js';
55
60
  import '../Paranja/Paranja.styl.js';
56
61
  import '../LightBox/LightBox.styl.js';
57
- import { Menu } from '../Menu/Menu.js';
58
62
  import '../Notifications/store.js';
59
63
  import '../Notifications/Notifications.styl.js';
60
64
  import '../PopupMenu/PopupMenu.styl.js';
61
65
  import '../Progress/Progress.styl.js';
62
66
  import '../PromptComposer/PromptComposer.js';
63
67
  import '@tiptap/extensions';
64
- import '@tiptap/react';
65
68
  import '@tiptap/starter-kit';
66
69
  import '../PromptComposer/PromptComposer.styl.js';
67
70
  import '../RadioGroup/RadioGroupContext.js';
@@ -63,7 +63,10 @@ import '@tiptap/extensions';
63
63
  import '@tiptap/react';
64
64
  import '@tiptap/starter-kit';
65
65
  import '../PromptComposer/PromptComposer.styl.js';
66
- import '../../tiptap/slash-mention/createSlashMentionExtension.js';
66
+ import '@tiptap/core';
67
+ import '@tiptap/extension-mention';
68
+ import '@tiptap/pm/state';
69
+ import '../../tiptap/slash-mention/SlashSuggestionList.styl.js';
67
70
  import '../RadioGroup/RadioGroupContext.js';
68
71
  import '../RadioButton/RadioButton.styl.js';
69
72
  import '../RadioGroup/RadioGroup.styl.js';
@@ -104,6 +104,13 @@ function fitRectToBoundary(rect, boundary, maxWidth) {
104
104
  maxWidth,
105
105
  };
106
106
  }
107
+ function isEditable(target) {
108
+ if (!(target instanceof HTMLElement))
109
+ return false;
110
+ if (target.isContentEditable)
111
+ return true;
112
+ return /^(input|textarea)$/i.test(target.tagName);
113
+ }
107
114
  const childs = {};
108
115
  let popupIds = 0;
109
116
  const getId = () => ++popupIds;
@@ -126,4 +133,4 @@ function unsetChild(rootId, id) {
126
133
  delete childs[rootId];
127
134
  }
128
135
 
129
- export { ZERO_BOUNDARY_FIT, childs, constrainAxisShift, domRectToEdges, edgesWidth, fitRectToBoundary, getId, getPopupId, intersectEdges, isLastChild, popupBoundaryEdges, popupBoundaryPadding, setChild, shiftMarginsIntoBounds, shrinkEdgesUniform, unsetChild, viewportClientRectEdges };
136
+ export { ZERO_BOUNDARY_FIT, childs, constrainAxisShift, domRectToEdges, edgesWidth, fitRectToBoundary, getId, getPopupId, intersectEdges, isEditable, isLastChild, popupBoundaryEdges, popupBoundaryPadding, setChild, shiftMarginsIntoBounds, shrinkEdgesUniform, unsetChild, viewportClientRectEdges };
@@ -1,5 +1,5 @@
1
1
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
2
- import { ZERO_BOUNDARY_FIT, getId, getPopupId, popupBoundaryEdges, popupBoundaryPadding, edgesWidth, fitRectToBoundary, isLastChild, unsetChild, childs, setChild } from './Popup.helpers.js';
2
+ import { ZERO_BOUNDARY_FIT, getId, getPopupId, popupBoundaryEdges, popupBoundaryPadding, edgesWidth, fitRectToBoundary, isLastChild, unsetChild, childs, isEditable, setChild } from './Popup.helpers.js';
3
3
  import { observe, unobserve } from '../../tools/resizeObserver.js';
4
4
  import { Component, createRef } from 'react';
5
5
  import { Paranja } from '../Paranja/Paranja.js';
@@ -32,6 +32,7 @@ class Popup extends Component {
32
32
  }
33
33
  };
34
34
  focused = false;
35
+ isOpening = false;
35
36
  pointerPressed = false;
36
37
  subscribedHoverControl = false;
37
38
  subscribedSizeChange = false;
@@ -329,7 +330,8 @@ class Popup extends Component {
329
330
  this.close();
330
331
  return;
331
332
  }
332
- if (this.focused && /Enter| /.test(e.key)) {
333
+ // Enter/Space belong to the trigger itself when it is a text field.
334
+ if (this.focused && /Enter| /.test(e.key) && !isEditable(e.target)) {
333
335
  e.stopPropagation();
334
336
  this.toggle();
335
337
  }
@@ -366,10 +368,13 @@ class Popup extends Component {
366
368
  onContainerResize = () => {
367
369
  this.updateBounds();
368
370
  };
369
- open = throttle(() => {
371
+ open = () => {
370
372
  const { rootPopupId } = this.state;
371
- if (this.state.isOpen)
373
+ // `isOpening` covers the gap until the state commits — throttling here used
374
+ // to swallow a re-open that came right after a close.
375
+ if (this.state.isOpen || this.isOpening)
372
376
  return;
377
+ this.isOpening = true;
373
378
  this.updateBounds();
374
379
  this.subscribeSizeChange();
375
380
  this.subscribeScroll();
@@ -380,8 +385,9 @@ class Popup extends Component {
380
385
  this.changeState(true, this.afterOpen);
381
386
  if (rootPopupId)
382
387
  setChild(rootPopupId, this.id);
383
- }, 100);
388
+ };
384
389
  close = () => {
390
+ this.isOpening = false;
385
391
  if (!this.state.isOpen)
386
392
  return;
387
393
  this.unsubscribeSizeChange();
@@ -390,7 +396,7 @@ class Popup extends Component {
390
396
  changeState(isOpen, callback) {
391
397
  const { animated, onOpen, onClose } = this.props;
392
398
  this.timers.clear();
393
- this.setState({ isOpen });
399
+ this.setState({ isOpen }, () => (this.isOpening = false));
394
400
  isOpen ? onOpen?.() : onClose?.();
395
401
  if (animated) {
396
402
  this.setState({ animating: true });
@@ -7,13 +7,17 @@ import S from './PromptComposer.styl.js';
7
7
  import { createPromptComposerHandle } from './promptComposerInsert.js';
8
8
  import { usePromptComposerEditor } from './usePromptComposerEditor.js';
9
9
 
10
- const PromptComposer = forwardRef(function PromptComposer({ disabled = false, placeholder, className, slashCommandItems, slashSuggestionPlacement, onSlashItemCommand, prefillMessage, attachmentsCount = 0, allowEnterSubmit = true, onSubmit, onChange, scrollProps = {}, }, ref) {
10
+ const PromptComposer = forwardRef(function PromptComposer({ disabled = false, placeholder, className, slashCommandItems, slashSuggestionPlacement, onSlashItemCommand, mentionConfigs, atMentionItems, atMentionGetItems, onAtItemCommand, prefillMessage, attachmentsCount = 0, allowEnterSubmit = true, onSubmit, onChange, scrollProps = {}, }, ref) {
11
11
  const { editor } = usePromptComposerEditor({
12
12
  disabled,
13
13
  placeholder,
14
14
  slashCommandItems,
15
15
  slashSuggestionPlacement,
16
16
  onSlashItemCommand,
17
+ mentionConfigs,
18
+ atMentionItems,
19
+ atMentionGetItems,
20
+ onAtItemCommand,
17
21
  prefillMessage,
18
22
  attachmentsCount,
19
23
  allowEnterSubmit,
@@ -20,8 +20,9 @@ function promptComposerChipHtml(text, className = PROMPT_COMPOSER_COMMAND_CHIP_C
20
20
  }
21
21
  function promptComposerMentionNode(input) {
22
22
  const { id, label = id, className = null, color = null, slashChar = '/', } = input;
23
+ const nodeType = slashChar === '/' ? 'mention' : `mention_${slashChar.charCodeAt(0)}`;
23
24
  return {
24
- type: 'mention',
25
+ type: nodeType,
25
26
  attrs: {
26
27
  id,
27
28
  label,
@@ -8,20 +8,78 @@ import { createSlashMentionExtension } from '../../tiptap/slash-mention/createSl
8
8
  import { safePromptComposerEditorText, promptComposerSafeEditorDom, syncPromptComposerHeight } from './PromptComposer.helpers.js';
9
9
  import { PROMPT_COMPOSER_EMPTY_DOC, promptComposerParagraphDoc } from './promptComposerDoc.js';
10
10
 
11
- function usePromptComposerEditor({ disabled, placeholder, slashCommandItems, slashSuggestionPlacement = 'above', onSlashItemCommand, prefillMessage, attachmentsCount = 0, allowEnterSubmit, onSubmit, onChange, }) {
12
- const slashOpenRef = useRef(false);
11
+ function buildMentionConfigs({ slashCommandItems, onSlashItemCommand, mentionConfigs, atMentionItems, atMentionGetItems, onAtItemCommand, }) {
12
+ const configs = [];
13
+ const slashItems = slashCommandItems ?? DEFAULT_CHAT_SLASH_ITEMS;
14
+ if (slashItems.length > 0) {
15
+ configs.push({
16
+ slashChar: '/',
17
+ items: slashItems,
18
+ onItemCommand: onSlashItemCommand,
19
+ });
20
+ }
21
+ if (mentionConfigs?.length) {
22
+ configs.push(...mentionConfigs);
23
+ }
24
+ if (atMentionGetItems || (atMentionItems && atMentionItems.length > 0)) {
25
+ configs.push({
26
+ slashChar: '@',
27
+ items: atMentionItems,
28
+ getItems: atMentionGetItems,
29
+ onItemCommand: onAtItemCommand,
30
+ });
31
+ }
32
+ // Deduplicate by slashChar (first wins for `/` from slashCommandItems).
33
+ const seen = new Set();
34
+ return configs.filter(cfg => {
35
+ if (seen.has(cfg.slashChar))
36
+ return false;
37
+ seen.add(cfg.slashChar);
38
+ return true;
39
+ });
40
+ }
41
+ function usePromptComposerEditor({ disabled, placeholder, slashCommandItems, slashSuggestionPlacement = 'above', onSlashItemCommand, mentionConfigs, atMentionItems, atMentionGetItems, onAtItemCommand, prefillMessage, attachmentsCount = 0, allowEnterSubmit, onSubmit, onChange, }) {
42
+ const suggestionOpenCountRef = useRef(0);
13
43
  const onSlashItemCommandRef = useRef(onSlashItemCommand);
14
44
  onSlashItemCommandRef.current = onSlashItemCommand;
45
+ const onAtItemCommandRef = useRef(onAtItemCommand);
46
+ onAtItemCommandRef.current = onAtItemCommand;
47
+ const mentionConfigsRef = useRef(mentionConfigs);
48
+ mentionConfigsRef.current = mentionConfigs;
15
49
  const onSubmitRef = useRef(onSubmit);
16
50
  onSubmitRef.current = onSubmit;
17
51
  const onChangeRef = useRef(onChange);
18
52
  onChangeRef.current = onChange;
53
+ const atMentionGetItemsRef = useRef(atMentionGetItems);
54
+ atMentionGetItemsRef.current = atMentionGetItems;
19
55
  const editorRef = useRef(null);
20
56
  const suggestionActiveUpdater = useCallback((active) => {
21
- slashOpenRef.current = active;
57
+ suggestionOpenCountRef.current = Math.max(0, suggestionOpenCountRef.current + (active ? 1 : -1));
22
58
  }, []);
23
59
  const slashItemsFingerprint = JSON.stringify(slashCommandItems ?? null);
24
- const slashItemsStable = useMemo(() => slashCommandItems ?? DEFAULT_CHAT_SLASH_ITEMS, [slashItemsFingerprint]);
60
+ const atItemsFingerprint = JSON.stringify(atMentionItems ?? null);
61
+ const mentionConfigsFingerprint = JSON.stringify((mentionConfigs ?? []).map(c => ({
62
+ slashChar: c.slashChar,
63
+ items: c.items ?? null,
64
+ hasGetItems: Boolean(c.getItems),
65
+ })));
66
+ const resolvedMentionConfigs = useMemo(() => buildMentionConfigs({
67
+ slashCommandItems,
68
+ onSlashItemCommand,
69
+ mentionConfigs,
70
+ atMentionItems,
71
+ atMentionGetItems,
72
+ onAtItemCommand,
73
+ }),
74
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- fingerprint keys
75
+ [
76
+ slashItemsFingerprint,
77
+ atItemsFingerprint,
78
+ mentionConfigsFingerprint,
79
+ Boolean(atMentionGetItems),
80
+ Boolean(onSlashItemCommand),
81
+ Boolean(onAtItemCommand),
82
+ ]);
25
83
  const placeholderText = useMemo(() => {
26
84
  if (placeholder === undefined || placeholder === null) {
27
85
  return 'Type a message...';
@@ -51,18 +109,38 @@ function usePromptComposerEditor({ disabled, placeholder, slashCommandItems, sla
51
109
  emptyNodeClass: S.promptComposerEmptyNode,
52
110
  }),
53
111
  ];
54
- if (slashItemsStable.length > 0) {
112
+ for (const cfg of resolvedMentionConfigs) {
113
+ const slashChar = cfg.slashChar;
55
114
  exts.push(createSlashMentionExtension({
56
- items: slashItemsStable,
115
+ items: cfg.items,
116
+ getItems: slashChar === '@' && atMentionGetItemsRef.current
117
+ ? query => atMentionGetItemsRef.current(query)
118
+ : cfg.getItems
119
+ ? query => {
120
+ const latest = mentionConfigsRef.current?.find(c => c.slashChar === slashChar);
121
+ return (latest?.getItems ?? cfg.getItems)(query);
122
+ }
123
+ : undefined,
124
+ slashChar,
125
+ pluginKey: cfg.pluginKey,
57
126
  suggestionPlacement: slashSuggestionPlacement,
58
127
  onSuggestionUiActiveChange: suggestionActiveUpdater,
59
- onItemCommand: ctx => onSlashItemCommandRef.current?.(ctx) === true,
128
+ onItemCommand: ctx => {
129
+ if (slashChar === '/') {
130
+ return onSlashItemCommandRef.current?.(ctx) === true;
131
+ }
132
+ if (slashChar === '@') {
133
+ return onAtItemCommandRef.current?.(ctx) === true;
134
+ }
135
+ const latest = mentionConfigsRef.current?.find(c => c.slashChar === slashChar);
136
+ return (latest?.onItemCommand ?? cfg.onItemCommand)?.(ctx) === true;
137
+ },
60
138
  }));
61
139
  }
62
140
  return exts;
63
141
  }, [
64
142
  placeholderText,
65
- slashItemsStable,
143
+ resolvedMentionConfigs,
66
144
  slashSuggestionPlacement,
67
145
  suggestionActiveUpdater,
68
146
  ]);
@@ -82,31 +160,31 @@ function usePromptComposerEditor({ disabled, placeholder, slashCommandItems, sla
82
160
  });
83
161
  }, []);
84
162
  const trimmedText = text.trim();
85
- const trimmedTextRef = useRef(trimmedText);
86
- trimmedTextRef.current = trimmedText;
87
163
  const attachmentsCountRef = useRef(attachmentsCount);
88
164
  attachmentsCountRef.current = attachmentsCount;
89
165
  const allowEnterSubmitRef = useRef(allowEnterSubmit);
90
166
  allowEnterSubmitRef.current = allowEnterSubmit;
91
167
  const handleEditorKeyDown = useCallback((_view, event) => {
92
- if (!(event.key === 'Enter' &&
93
- !event.shiftKey &&
94
- !event.metaKey &&
95
- !event.ctrlKey)) {
168
+ if (event.key !== 'Enter' || event.shiftKey)
96
169
  return false;
97
- }
98
170
  if (!allowEnterSubmitRef.current)
99
171
  return false;
100
- if (slashOpenRef.current)
172
+ if (suggestionOpenCountRef.current > 0)
101
173
  return false;
102
- if (!trimmedTextRef.current && attachmentsCountRef.current === 0) {
174
+ const activeEditor = editorRef.current;
175
+ if (!activeEditor)
176
+ return false;
177
+ const currentText = activeEditor.getText();
178
+ const hasNewlines = currentText.includes('\n');
179
+ const isModEnter = event.metaKey || event.ctrlKey;
180
+ // Single line: Enter submits. Multiline: Enter inserts a line; Cmd/Ctrl+Enter submits.
181
+ if (hasNewlines ? !isModEnter : isModEnter)
182
+ return false;
183
+ if (!currentText.trim() && attachmentsCountRef.current === 0) {
103
184
  return false;
104
185
  }
105
186
  event.preventDefault();
106
- const activeEditor = editorRef.current;
107
- if (!activeEditor)
108
- return true;
109
- onSubmitRef.current?.(activeEditor.getText(), activeEditor);
187
+ onSubmitRef.current?.(currentText, activeEditor);
110
188
  return true;
111
189
  }, []);
112
190
  const editor = useEditor({
@@ -6,6 +6,33 @@ function renderLabel({ label, render }) {
6
6
  return render(label);
7
7
  return label;
8
8
  }
9
+ /** Selection is always kept as a list of ids, whatever the `value` shape is. */
10
+ function toIds(value) {
11
+ if (value === null || value === undefined)
12
+ return [];
13
+ if (Array.isArray(value))
14
+ return value;
15
+ return [value];
16
+ }
17
+ /** Stable primitive for `useEffect` deps — `value` arrays are often recreated. */
18
+ function idsKey(ids) {
19
+ return ids.join('\u0000');
20
+ }
21
+ /**
22
+ * Script-agnostic normalization: `toLocaleLowerCase` respects locale casing rules
23
+ * and NFC keeps composed/decomposed forms comparable.
24
+ */
25
+ function normalize(text) {
26
+ return text.toLocaleLowerCase().normalize('NFC');
27
+ }
28
+ function matchesSearch(option, query) {
29
+ if (!query)
30
+ return true;
31
+ return normalize(option.label).includes(normalize(query));
32
+ }
33
+ function isSelectable(option) {
34
+ return Boolean(option) && !option.isGroupHeader;
35
+ }
9
36
  function buildOptionsTree(options, idsMap) {
10
37
  function buildOption(id) {
11
38
  const { items, childIds } = idsMap;
@@ -34,4 +61,4 @@ function mapById(options) {
34
61
  });
35
62
  }
36
63
 
37
- export { buildOptionsTree, isMultiple, mapById, renderLabel };
64
+ export { buildOptionsTree, idsKey, isMultiple, isSelectable, mapById, matchesSearch, renderLabel, toIds };
@@ -0,0 +1,131 @@
1
+ import { useState, useRef, useCallback, useMemo, useEffect } from 'react';
2
+ import useEvent from '../../hooks/useEvent.js';
3
+ import { idsKey, matchesSearch, isSelectable, mapById, toIds } from './Select.helpers.js';
4
+
5
+ /**
6
+ * Open state contract:
7
+ * - `isOpen` prop is a boolean -> fully controlled, the consumer must react to
8
+ * `onOpen` / `onClose` to change it.
9
+ * - `isOpen` prop is `undefined` -> Select owns the state.
10
+ */
11
+ function useSelectOpenState({ isOpen: isOpenProp, onOpen, onClose, }) {
12
+ const isControlled = typeof isOpenProp === 'boolean';
13
+ const [innerIsOpen, setInnerIsOpen] = useState(false);
14
+ const isOpen = isControlled ? isOpenProp : innerIsOpen;
15
+ // Kept in sync during render so that two `open()` calls within one event
16
+ // (Popup + Select both reacting to a focus) notify the consumer only once.
17
+ const isOpenRef = useRef(isOpen);
18
+ isOpenRef.current = isOpen;
19
+ const callbacks = useRef({ onOpen, onClose });
20
+ callbacks.current = { onOpen, onClose };
21
+ const open = useCallback(() => {
22
+ if (isOpenRef.current)
23
+ return;
24
+ isOpenRef.current = true;
25
+ if (!isControlled)
26
+ setInnerIsOpen(true);
27
+ callbacks.current.onOpen?.();
28
+ }, [isControlled]);
29
+ const close = useCallback(() => {
30
+ if (!isOpenRef.current)
31
+ return;
32
+ isOpenRef.current = false;
33
+ if (!isControlled)
34
+ setInnerIsOpen(false);
35
+ callbacks.current.onClose?.();
36
+ }, [isControlled]);
37
+ return { isOpen, open, close };
38
+ }
39
+ /**
40
+ * Selection contract mirrors the open state one: `value` present -> controlled,
41
+ * `value === undefined` -> Select owns the selection.
42
+ */
43
+ function useSelectedIds(value) {
44
+ const isControlled = value !== undefined;
45
+ const [innerIds, setInnerIds] = useState([]);
46
+ const propIds = toIds(value);
47
+ const propIdsKey = idsKey(propIds);
48
+ const selectedIds = useMemo(() => (isControlled ? propIds : innerIds), [isControlled, propIdsKey, innerIds]);
49
+ const setSelectedIds = useCallback((ids) => {
50
+ if (!isControlled)
51
+ setInnerIds(ids);
52
+ }, [isControlled]);
53
+ return { selectedIds, setSelectedIds };
54
+ }
55
+ /** Single source of truth for what the list shows and what can be navigated. */
56
+ function useSelectOptions({ options, additionalOptions, searchQuery, }) {
57
+ const allOptions = useMemo(() => additionalOptions.length ? [...additionalOptions, ...options] : options, [additionalOptions, options]);
58
+ const visibleOptions = useMemo(() => searchQuery
59
+ ? allOptions.filter(option => matchesSearch(option, searchQuery))
60
+ : allOptions, [allOptions, searchQuery]);
61
+ // Group headers are rendered but never focusable/selectable.
62
+ const navigableOptions = useMemo(() => visibleOptions.filter(isSelectable), [visibleOptions]);
63
+ const optionsById = useMemo(() => mapById(allOptions), [allOptions]);
64
+ return { allOptions, visibleOptions, navigableOptions, optionsById };
65
+ }
66
+ /**
67
+ * Keyboard cursor. It is stored as an option id and always resolved against the
68
+ * currently *visible* list, so filtering can never desync it from the rows.
69
+ */
70
+ function useKeyboardNav({ isOpen, navigableOptions, selectedIds, onSelect, }) {
71
+ const [focusedId, setFocusedId] = useState(null);
72
+ const focusedIndex = useMemo(() => navigableOptions.findIndex(({ id }) => id === focusedId), [navigableOptions, focusedId]);
73
+ const latest = useRef({ navigableOptions, focusedIndex, selectedIds, onSelect });
74
+ latest.current = { navigableOptions, focusedIndex, selectedIds, onSelect };
75
+ const focusAt = useCallback((index) => {
76
+ const { navigableOptions } = latest.current;
77
+ if (!navigableOptions.length) {
78
+ setFocusedId(null);
79
+ return;
80
+ }
81
+ const clamped = Math.min(Math.max(index, 0), navigableOptions.length - 1);
82
+ setFocusedId(navigableOptions[clamped].id);
83
+ }, []);
84
+ // On open put the cursor on the selected row, on close drop it.
85
+ useEffect(() => {
86
+ if (!isOpen) {
87
+ setFocusedId(null);
88
+ return;
89
+ }
90
+ const { navigableOptions, selectedIds } = latest.current;
91
+ const preferred = navigableOptions.find(({ id }) => selectedIds.includes(id)) ??
92
+ navigableOptions[0];
93
+ setFocusedId(preferred?.id ?? null);
94
+ }, [isOpen]);
95
+ // Keep the cursor on a row that still exists after the list changed.
96
+ useEffect(() => {
97
+ if (!isOpen)
98
+ return;
99
+ setFocusedId(current => current !== null && navigableOptions.some(({ id }) => id === current)
100
+ ? current
101
+ : (navigableOptions[0]?.id ?? null));
102
+ }, [isOpen, navigableOptions]);
103
+ const onKeyDown = useCallback((e) => {
104
+ const { key } = e;
105
+ const { navigableOptions, focusedIndex, onSelect } = latest.current;
106
+ if (!navigableOptions.length)
107
+ return;
108
+ if (key === 'ArrowUp') {
109
+ e.preventDefault();
110
+ focusAt(focusedIndex - 1);
111
+ return;
112
+ }
113
+ if (key === 'ArrowDown') {
114
+ e.preventDefault();
115
+ focusAt(focusedIndex + 1);
116
+ return;
117
+ }
118
+ if (key === 'Enter') {
119
+ const option = navigableOptions[focusedIndex];
120
+ if (!option)
121
+ return;
122
+ e.preventDefault();
123
+ e.stopPropagation();
124
+ onSelect(option.id);
125
+ }
126
+ }, [focusAt]);
127
+ useEvent({ event: 'keydown', isActive: isOpen, callback: onKeyDown });
128
+ return { focusedId, setFocusedId };
129
+ }
130
+
131
+ export { useKeyboardNav, useSelectOpenState, useSelectOptions, useSelectedIds };