@homecode/ui 5.6.1 → 5.7.0
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.
- package/dist/esm/src/components/Autocomplete/Autocomplete.js +6 -3
- package/dist/esm/src/components/InputFile/InputFile.js +4 -1
- package/dist/esm/src/components/Popup/Popup.helpers.js +8 -1
- package/dist/esm/src/components/Popup/Popup.js +12 -6
- package/dist/esm/src/components/PromptComposer/PromptComposer.js +5 -1
- package/dist/esm/src/components/PromptComposer/promptComposerInsert.js +2 -1
- package/dist/esm/src/components/PromptComposer/usePromptComposerEditor.js +99 -21
- package/dist/esm/src/components/Select/Select.helpers.js +28 -1
- package/dist/esm/src/components/Select/Select.hooks.js +131 -0
- package/dist/esm/src/components/Select/Select2.js +192 -235
- package/dist/esm/src/components/Tooltip/Tooltip.styl.js +1 -1
- package/dist/esm/src/components/Tooltip/TooltipOverTrigger.js +4 -3
- package/dist/esm/src/components/Tooltip/tooltipTextStyles.js +17 -2
- package/dist/esm/src/tiptap/slash-mention/createSlashMentionExtension.js +51 -31
- package/dist/esm/src/tools/dom.js +6 -8
- package/dist/esm/src/tools/resizeObserver.js +1 -1
- package/dist/esm/types/src/components/Popup/Popup.d.ts +2 -1
- package/dist/esm/types/src/components/Popup/Popup.helpers.d.ts +1 -0
- package/dist/esm/types/src/components/Popup/Popup.types.d.ts +1 -0
- package/dist/esm/types/src/components/PromptComposer/PromptComposer.d.ts +4 -0
- package/dist/esm/types/src/components/PromptComposer/PromptComposer.types.d.ts +17 -3
- package/dist/esm/types/src/components/PromptComposer/index.d.ts +1 -1
- package/dist/esm/types/src/components/PromptComposer/usePromptComposerEditor.d.ts +6 -2
- package/dist/esm/types/src/components/Select/Select.helpers.d.ts +7 -1
- package/dist/esm/types/src/components/Select/Select.hooks.d.ts +55 -0
- package/dist/esm/types/src/components/Select/Select.types.d.ts +1 -1
- package/dist/esm/types/src/components/Select/Select2.test.d.ts +1 -0
- package/dist/esm/types/src/tiptap/slash-mention/createSlashMentionExtension.d.ts +1 -1
- package/dist/esm/types/src/tiptap/slash-mention/index.d.ts +1 -1
- package/dist/esm/types/src/tiptap/slash-mention/types.d.ts +12 -1
- package/graphify-out/.graphify_labels.json +18 -25
- package/graphify-out/2026-07-26/.graphify_labels.json +216 -0
- package/graphify-out/2026-07-26/GRAPH_REPORT.md +1036 -0
- package/graphify-out/2026-07-26/cost.json +12 -0
- package/graphify-out/2026-07-26/graph.json +71426 -0
- package/graphify-out/2026-07-26/manifest.json +2372 -0
- package/graphify-out/2026-07-27/.graphify_labels.json +214 -0
- package/graphify-out/2026-07-27/GRAPH_REPORT.md +1014 -0
- package/graphify-out/2026-07-27/cost.json +12 -0
- package/graphify-out/2026-07-27/graph.json +71778 -0
- package/graphify-out/2026-07-27/manifest.json +2372 -0
- package/graphify-out/2026-07-30/.graphify_labels.json +197 -0
- package/graphify-out/2026-07-30/GRAPH_REPORT.md +929 -0
- package/graphify-out/2026-07-30/cost.json +12 -0
- package/graphify-out/2026-07-30/graph.json +71798 -0
- package/graphify-out/2026-07-30/manifest.json +2372 -0
- package/graphify-out/GRAPH_REPORT.md +167 -249
- package/graphify-out/cache/ast/2690b69f91795ec634393c1fb8d9fd21ec09ec6759eaf6711e3b86c2a8edb385.json +1 -0
- package/graphify-out/cache/ast/2ae06c5acabf11eea845c93c2a8486317fbb18dccc1843ecd88077848af2ab29.json +1 -0
- package/graphify-out/cache/stat-index.json +1 -1
- package/graphify-out/graph.html +4 -4
- package/graphify-out/graph.json +3354 -2931
- package/graphify-out/manifest.json +47 -47
- 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 '
|
|
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 '
|
|
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
|
-
|
|
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 =
|
|
371
|
+
open = () => {
|
|
370
372
|
const { rootPopupId } = this.state;
|
|
371
|
-
|
|
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
|
-
}
|
|
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:
|
|
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
|
|
12
|
-
const
|
|
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
|
-
|
|
57
|
+
suggestionOpenCountRef.current = Math.max(0, suggestionOpenCountRef.current + (active ? 1 : -1));
|
|
22
58
|
}, []);
|
|
23
59
|
const slashItemsFingerprint = JSON.stringify(slashCommandItems ?? null);
|
|
24
|
-
const
|
|
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
|
-
|
|
112
|
+
for (const cfg of resolvedMentionConfigs) {
|
|
113
|
+
const slashChar = cfg.slashChar;
|
|
55
114
|
exts.push(createSlashMentionExtension({
|
|
56
|
-
items:
|
|
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 =>
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
172
|
+
if (suggestionOpenCountRef.current > 0)
|
|
101
173
|
return false;
|
|
102
|
-
|
|
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
|
-
|
|
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 };
|