@octanejs/base-ui 0.1.15 → 0.1.17
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/package.json +5 -5
- package/src/context-menu.ts +317 -0
- package/src/index.ts +3 -0
- package/src/menu.ts +3387 -0
- package/src/menubar.ts +160 -0
- package/src/popover.ts +7 -3
- package/src/utils/ContextMenuRootContext.ts +44 -0
- package/src/utils/MenubarContext.ts +38 -0
- package/src/utils/RequestQueue.ts +132 -0
- package/src/utils/TimeoutManager.ts +32 -0
- package/src/utils/composite/grid-utils.ts +437 -0
- package/src/utils/composite/gridNavigation.ts +127 -0
- package/src/utils/composite/list-utils.ts +56 -28
- package/src/utils/composite/useCompositeRoot.ts +11 -13
- package/src/utils/constants.ts +22 -0
- package/src/utils/createChangeEventDetails.ts +3 -0
- package/src/utils/findRootOwnerId.ts +19 -0
- package/src/utils/floating/gridNavigation.ts +54 -0
- package/src/utils/floating/useListNavigation.ts +1018 -0
- package/src/utils/floating/useTypeahead.ts +287 -0
- package/src/utils/getPseudoElementBounds.ts +54 -0
- package/src/utils/useMixedToggleClickHandler.ts +77 -0
- package/src/utils/useOpenInteractionType.ts +37 -1
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// Ported from .base-ui/packages/react/src/floating-ui-react/hooks/useTypeahead.ts (v1.6.0). Matches
|
|
2
|
+
// list items as the user types, the way a native `<select>` does — usually paired with
|
|
3
|
+
// `useListNavigation`. Returns an `ElementProps` bag whose `onKeyDown`/`onBlur` the reference and
|
|
4
|
+
// the floating element both merge.
|
|
5
|
+
//
|
|
6
|
+
// octane adaptations: handlers receive the NATIVE event, so `event.nativeEvent` collapses to
|
|
7
|
+
// `event`; `useIsoLayoutEffect` is octane's `useLayoutEffect`; every composed hook threads an
|
|
8
|
+
// explicit slot.
|
|
9
|
+
//
|
|
10
|
+
// SLOT: plain-`.ts` hook; the trailing arg is the caller's slot.
|
|
11
|
+
import { useLayoutEffect, useMemo, useRef } from 'octane';
|
|
12
|
+
|
|
13
|
+
import { S, splitSlot, subSlot } from '../../internal';
|
|
14
|
+
import { EMPTY_ARRAY } from '../empty';
|
|
15
|
+
import { useStableCallback } from '../useStableCallback';
|
|
16
|
+
import { useTimeout } from '../useTimeout';
|
|
17
|
+
import { isListIndexDisabled, stopEvent, type DisabledIndices } from '../composite/list-utils';
|
|
18
|
+
import { isElementVisible } from './composite';
|
|
19
|
+
import { contains } from './element';
|
|
20
|
+
import type { ElementProps, FloatingContext, FloatingRootContext } from './types';
|
|
21
|
+
|
|
22
|
+
export interface UseTypeaheadProps {
|
|
23
|
+
/**
|
|
24
|
+
* A ref which contains an array of strings whose indices match the HTML
|
|
25
|
+
* elements of the list.
|
|
26
|
+
* @default empty list
|
|
27
|
+
*/
|
|
28
|
+
listRef: { current: Array<string | null> };
|
|
29
|
+
/**
|
|
30
|
+
* The index of the active (focused or highlighted) item in the list.
|
|
31
|
+
* @default null
|
|
32
|
+
*/
|
|
33
|
+
activeIndex: number | null;
|
|
34
|
+
/**
|
|
35
|
+
* Callback invoked with the matching index if found as the user types.
|
|
36
|
+
*/
|
|
37
|
+
onMatch?: ((index: number) => void) | undefined;
|
|
38
|
+
/**
|
|
39
|
+
* Optional list of item elements that correspond to `listRef` indices.
|
|
40
|
+
* When an element exists for an index, typeahead skips it if it is hidden by
|
|
41
|
+
* `display: none`, `visibility: hidden|collapse`, or other
|
|
42
|
+
* browser-reported visibility checks.
|
|
43
|
+
*/
|
|
44
|
+
elementsRef?: { current: Array<HTMLElement | null> } | undefined;
|
|
45
|
+
/**
|
|
46
|
+
* Indices that are disabled, either as an array or a predicate (the same shape as
|
|
47
|
+
* `useListNavigation`'s `disabledIndices`). Disabled items are skipped while matching,
|
|
48
|
+
* so a single keypress advances to the next selectable item (matching native `<select>`
|
|
49
|
+
* and arrow-key navigation). The disabled check doesn't read `elementsRef`, so consumers
|
|
50
|
+
* whose items stay mounted-but-hidden while closed can still skip disabled items without
|
|
51
|
+
* passing `elementsRef`.
|
|
52
|
+
*/
|
|
53
|
+
disabledIndices?: DisabledIndices | undefined;
|
|
54
|
+
/**
|
|
55
|
+
* Callback invoked with the current typing activity as the user types.
|
|
56
|
+
*/
|
|
57
|
+
onTyping?: ((isTyping: boolean) => void) | undefined;
|
|
58
|
+
/**
|
|
59
|
+
* Whether the hook is enabled, including all internal effects and event
|
|
60
|
+
* handlers.
|
|
61
|
+
* @default true
|
|
62
|
+
*/
|
|
63
|
+
enabled?: boolean | undefined;
|
|
64
|
+
/**
|
|
65
|
+
* The number of milliseconds to wait before resetting the typed string.
|
|
66
|
+
* @default 750
|
|
67
|
+
*/
|
|
68
|
+
resetMs?: number | undefined;
|
|
69
|
+
/**
|
|
70
|
+
* The index of the selected item in the list, if available.
|
|
71
|
+
* @default null
|
|
72
|
+
*/
|
|
73
|
+
selectedIndex?: number | null | undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Provides a matching callback that can be used to focus an item as the user
|
|
78
|
+
* types, often used in tandem with `useListNavigation()`.
|
|
79
|
+
* @see https://floating-ui.com/docs/useTypeahead
|
|
80
|
+
*/
|
|
81
|
+
export function useTypeahead(...args: any[]): ElementProps {
|
|
82
|
+
const [user, slotArg] = splitSlot(args);
|
|
83
|
+
const slot = slotArg ?? S('useTypeahead');
|
|
84
|
+
const context = user[0] as FloatingRootContext | FloatingContext;
|
|
85
|
+
const props = user[1] as UseTypeaheadProps;
|
|
86
|
+
|
|
87
|
+
const {
|
|
88
|
+
listRef,
|
|
89
|
+
elementsRef,
|
|
90
|
+
activeIndex,
|
|
91
|
+
onMatch: onMatchProp,
|
|
92
|
+
disabledIndices,
|
|
93
|
+
onTyping,
|
|
94
|
+
enabled = true,
|
|
95
|
+
resetMs = 750,
|
|
96
|
+
selectedIndex = null,
|
|
97
|
+
} = props;
|
|
98
|
+
|
|
99
|
+
const store = (context && 'rootStore' in context ? context.rootStore : context) as any;
|
|
100
|
+
|
|
101
|
+
const open = store.useState('open', subSlot(slot, 'open'));
|
|
102
|
+
|
|
103
|
+
const timeout = useTimeout(subSlot(slot, 'to'));
|
|
104
|
+
const stringRef = useRef('', subSlot(slot, 'str'));
|
|
105
|
+
const prevIndexRef = useRef<number | null>(
|
|
106
|
+
selectedIndex ?? activeIndex ?? -1,
|
|
107
|
+
subSlot(slot, 'prev'),
|
|
108
|
+
);
|
|
109
|
+
const matchIndexRef = useRef<number | null>(null, subSlot(slot, 'match'));
|
|
110
|
+
|
|
111
|
+
const onKeyDown = useStableCallback(
|
|
112
|
+
(event: KeyboardEvent) => {
|
|
113
|
+
function isVisible(index: number) {
|
|
114
|
+
const element = elementsRef?.current[index];
|
|
115
|
+
return !element || isElementVisible(element);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function isItemAvailable(index: number) {
|
|
119
|
+
if (!isVisible(index)) {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
// Visibility is handled above; pass an empty element list so `isListIndexDisabled`
|
|
123
|
+
// resolves only the explicit `disabledIndices` (array/predicate) and skips its own
|
|
124
|
+
// visibility/attribute fallbacks. Consumers that don't opt in keep matching every
|
|
125
|
+
// visible item.
|
|
126
|
+
return disabledIndices == null || !isListIndexDisabled(EMPTY_ARRAY, index, disabledIndices);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function getMatchingIndex(list: Array<string | null>, string: string, startIndex = 0) {
|
|
130
|
+
if (list.length === 0) {
|
|
131
|
+
return -1;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const normalizedStartIndex = ((startIndex % list.length) + list.length) % list.length;
|
|
135
|
+
const lowerString = string.toLowerCase();
|
|
136
|
+
|
|
137
|
+
for (let offset = 0; offset < list.length; offset += 1) {
|
|
138
|
+
const index = (normalizedStartIndex + offset) % list.length;
|
|
139
|
+
const text = list[index];
|
|
140
|
+
if (!text?.toLowerCase().startsWith(lowerString) || !isItemAvailable(index)) {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
return index;
|
|
144
|
+
}
|
|
145
|
+
return -1;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const listContent = listRef.current;
|
|
149
|
+
|
|
150
|
+
if (stringRef.current.length > 0 && event.key === ' ') {
|
|
151
|
+
// Space should continue the in-progress typeahead session.
|
|
152
|
+
stopEvent(event);
|
|
153
|
+
onTyping?.(true);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (stringRef.current.length > 0 && stringRef.current[0] !== ' ') {
|
|
157
|
+
if (getMatchingIndex(listContent, stringRef.current) === -1 && event.key !== ' ') {
|
|
158
|
+
onTyping?.(false);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (
|
|
163
|
+
listContent == null ||
|
|
164
|
+
// Character key.
|
|
165
|
+
event.key.length !== 1 ||
|
|
166
|
+
// Modifier key.
|
|
167
|
+
event.ctrlKey ||
|
|
168
|
+
event.metaKey ||
|
|
169
|
+
event.altKey
|
|
170
|
+
) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (open && event.key !== ' ') {
|
|
175
|
+
stopEvent(event);
|
|
176
|
+
onTyping?.(true);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Capture whether this is a new typing session before mutating the string.
|
|
180
|
+
const isNewSession = stringRef.current === '';
|
|
181
|
+
if (isNewSession) {
|
|
182
|
+
prevIndexRef.current = selectedIndex ?? activeIndex ?? -1;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Bail out if the list contains a word like "llama" or "aaron". TODO:
|
|
186
|
+
// allow it in this case, too. Unavailable items are skipped while matching, so
|
|
187
|
+
// they must be ignored here as well — otherwise a hidden or disabled double-letter
|
|
188
|
+
// label would block rapid cycling through the available items.
|
|
189
|
+
const allowRapidSuccessionOfFirstLetter = listContent.every((text, index) =>
|
|
190
|
+
text && isItemAvailable(index) ? text[0]?.toLowerCase() !== text[1]?.toLowerCase() : true,
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
// Allows the user to cycle through items that start with the same letter
|
|
194
|
+
// in rapid succession.
|
|
195
|
+
if (allowRapidSuccessionOfFirstLetter && stringRef.current === event.key) {
|
|
196
|
+
stringRef.current = '';
|
|
197
|
+
prevIndexRef.current = matchIndexRef.current;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
stringRef.current += event.key;
|
|
201
|
+
timeout.start(resetMs, () => {
|
|
202
|
+
stringRef.current = '';
|
|
203
|
+
prevIndexRef.current = matchIndexRef.current;
|
|
204
|
+
onTyping?.(false);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// Compute the starting index for this search.
|
|
208
|
+
// If this is a new typing session (string is empty), base it on the current
|
|
209
|
+
// selection/active item; otherwise continue from the last matched index.
|
|
210
|
+
const prevIndex = isNewSession ? (selectedIndex ?? activeIndex ?? -1) : prevIndexRef.current;
|
|
211
|
+
const startIndex = (prevIndex ?? 0) + 1;
|
|
212
|
+
|
|
213
|
+
const index = getMatchingIndex(listContent, stringRef.current, startIndex);
|
|
214
|
+
|
|
215
|
+
if (index !== -1) {
|
|
216
|
+
onMatchProp?.(index);
|
|
217
|
+
matchIndexRef.current = index;
|
|
218
|
+
} else if (event.key !== ' ') {
|
|
219
|
+
stringRef.current = '';
|
|
220
|
+
onTyping?.(false);
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
subSlot(slot, 'okd'),
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
const onBlur = useStableCallback(
|
|
227
|
+
(event: FocusEvent) => {
|
|
228
|
+
const next = event.relatedTarget as Element | null;
|
|
229
|
+
const currentDomReferenceElement = store.select('domReferenceElement');
|
|
230
|
+
const currentFloatingElement = store.select('floatingElement');
|
|
231
|
+
const withinComposite =
|
|
232
|
+
contains(currentDomReferenceElement, next) || contains(currentFloatingElement, next);
|
|
233
|
+
|
|
234
|
+
// Keep the session if focus moves within the composite (reference <-> floating).
|
|
235
|
+
if (withinComposite) {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// End the current typing session when focus leaves the composite entirely.
|
|
240
|
+
timeout.clear();
|
|
241
|
+
stringRef.current = '';
|
|
242
|
+
prevIndexRef.current = matchIndexRef.current;
|
|
243
|
+
onTyping?.(false);
|
|
244
|
+
},
|
|
245
|
+
subSlot(slot, 'ob'),
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
useLayoutEffect(
|
|
249
|
+
() => {
|
|
250
|
+
if (!open && selectedIndex !== null) {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
timeout.clear();
|
|
255
|
+
matchIndexRef.current = null;
|
|
256
|
+
|
|
257
|
+
if (stringRef.current !== '') {
|
|
258
|
+
stringRef.current = '';
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
[open, selectedIndex, timeout],
|
|
262
|
+
subSlot(slot, 'e:reset'),
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
useLayoutEffect(
|
|
266
|
+
() => {
|
|
267
|
+
// Sync arrow key navigation but not typeahead navigation.
|
|
268
|
+
if (open && stringRef.current === '') {
|
|
269
|
+
prevIndexRef.current = selectedIndex ?? activeIndex ?? -1;
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
[open, selectedIndex, activeIndex],
|
|
273
|
+
subSlot(slot, 'e:sync'),
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
const sharedProps = useMemo(
|
|
277
|
+
() => ({ onKeyDown, onBlur }),
|
|
278
|
+
[onKeyDown, onBlur],
|
|
279
|
+
subSlot(slot, 'shared'),
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
return useMemo(
|
|
283
|
+
() => (enabled ? { reference: sharedProps, floating: sharedProps } : {}),
|
|
284
|
+
[enabled, sharedProps],
|
|
285
|
+
subSlot(slot, 'out'),
|
|
286
|
+
);
|
|
287
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Ported from .base-ui/packages/react/src/utils/getPseudoElementBounds.ts (v1.6.0). Returns an
|
|
2
|
+
// element's bounding box widened to cover its `::before`/`::after` pseudo-elements, so hit-testing
|
|
3
|
+
// (safe-polygon / mouse-tracking) accounts for decorations drawn outside the box.
|
|
4
|
+
//
|
|
5
|
+
// Pure — no renderer API is involved, so this is a straight transcription.
|
|
6
|
+
import { ownerWindow } from './owner';
|
|
7
|
+
import { platform } from './platform';
|
|
8
|
+
|
|
9
|
+
interface ElementBounds {
|
|
10
|
+
left: number;
|
|
11
|
+
right: number;
|
|
12
|
+
top: number;
|
|
13
|
+
bottom: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function getPseudoElementBounds(element: HTMLElement): ElementBounds {
|
|
17
|
+
const elementRect = element.getBoundingClientRect();
|
|
18
|
+
const win = ownerWindow(element);
|
|
19
|
+
|
|
20
|
+
// Avoid "Not implemented: window.getComputedStyle(elt, pseudoElt)" in jsdom.
|
|
21
|
+
if (platform.env.jsdom) {
|
|
22
|
+
return elementRect;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const beforeStyles = win.getComputedStyle(element, '::before');
|
|
26
|
+
const afterStyles = win.getComputedStyle(element, '::after');
|
|
27
|
+
|
|
28
|
+
const hasPseudoElements = beforeStyles.content !== 'none' || afterStyles.content !== 'none';
|
|
29
|
+
|
|
30
|
+
if (!hasPseudoElements) {
|
|
31
|
+
return elementRect;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Get dimensions of pseudo-elements
|
|
35
|
+
const beforeWidth = parseFloat(beforeStyles.width) || 0;
|
|
36
|
+
const beforeHeight = parseFloat(beforeStyles.height) || 0;
|
|
37
|
+
const afterWidth = parseFloat(afterStyles.width) || 0;
|
|
38
|
+
const afterHeight = parseFloat(afterStyles.height) || 0;
|
|
39
|
+
|
|
40
|
+
// Calculate max dimensions including pseudo-elements
|
|
41
|
+
const totalWidth = Math.max(elementRect.width, beforeWidth, afterWidth);
|
|
42
|
+
const totalHeight = Math.max(elementRect.height, beforeHeight, afterHeight);
|
|
43
|
+
|
|
44
|
+
// Calculate the differences to extend the bounds
|
|
45
|
+
const widthDiff = totalWidth - elementRect.width;
|
|
46
|
+
const heightDiff = totalHeight - elementRect.height;
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
left: elementRect.left - widthDiff / 2,
|
|
50
|
+
right: elementRect.right + widthDiff / 2,
|
|
51
|
+
top: elementRect.top - heightDiff / 2,
|
|
52
|
+
bottom: elementRect.bottom + heightDiff / 2,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Ported from .base-ui/packages/react/src/utils/useMixedToggleClickHandler.ts (v1.6.0),
|
|
2
|
+
// octane-adapted (slot-threaded; native events). Returns `click`/`mousedown` handlers for a trigger
|
|
3
|
+
// whose popup is toggled by two different events — opened on mousedown, closed on click — so the
|
|
4
|
+
// click that follows the opening mousedown does not immediately close the popup again.
|
|
5
|
+
//
|
|
6
|
+
// octane adaptations: events are NATIVE (not synthetic), so the handlers take the native event
|
|
7
|
+
// and `preventBaseUIHandler` is the shim `mergeProps`/`makeEventPreventable` attaches to it.
|
|
8
|
+
// Upstream's empty `UseMixedToggleClickHandlerState` interface is a docs-pipeline artifact with no
|
|
9
|
+
// members, so it is dropped.
|
|
10
|
+
//
|
|
11
|
+
// SLOT: plain-`.ts` hook; the trailing arg is the caller's slot.
|
|
12
|
+
import { useMemo, useRef } from 'octane';
|
|
13
|
+
|
|
14
|
+
import { S, splitSlot, subSlot } from '../internal';
|
|
15
|
+
import { EMPTY_OBJECT } from './empty';
|
|
16
|
+
import { ownerDocument } from './owner';
|
|
17
|
+
|
|
18
|
+
export interface UseMixedToggleClickHandlerParameters {
|
|
19
|
+
/**
|
|
20
|
+
* Whether the mixed toggle click handler is enabled.
|
|
21
|
+
* @default true
|
|
22
|
+
*/
|
|
23
|
+
enabled?: boolean | undefined;
|
|
24
|
+
/**
|
|
25
|
+
* Determines what action is performed on mousedown.
|
|
26
|
+
*/
|
|
27
|
+
mouseDownAction: 'open' | 'close';
|
|
28
|
+
/**
|
|
29
|
+
* The current open state of the popup.
|
|
30
|
+
*/
|
|
31
|
+
open: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface UseMixedToggleClickHandlerReturnValue {
|
|
35
|
+
onMouseDown?: (event: any) => void;
|
|
36
|
+
onClick?: (event: any) => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function useMixedToggleClickHandler(...args: any[]): UseMixedToggleClickHandlerReturnValue {
|
|
40
|
+
const [user, slotArg] = splitSlot(args);
|
|
41
|
+
const slot = slotArg ?? S('useMixedToggleClickHandler');
|
|
42
|
+
const { enabled = true, mouseDownAction, open } = user[0] as UseMixedToggleClickHandlerParameters;
|
|
43
|
+
|
|
44
|
+
const ignoreClickRef = useRef(false, subSlot(slot, 'ignore'));
|
|
45
|
+
|
|
46
|
+
return useMemo(
|
|
47
|
+
() => {
|
|
48
|
+
if (!enabled) {
|
|
49
|
+
return EMPTY_OBJECT;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
onMouseDown: (event: any) => {
|
|
54
|
+
if ((mouseDownAction === 'open' && !open) || (mouseDownAction === 'close' && open)) {
|
|
55
|
+
ignoreClickRef.current = true;
|
|
56
|
+
|
|
57
|
+
ownerDocument(event.currentTarget as Element).addEventListener(
|
|
58
|
+
'click',
|
|
59
|
+
() => {
|
|
60
|
+
ignoreClickRef.current = false;
|
|
61
|
+
},
|
|
62
|
+
{ once: true },
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
onClick: (event: any) => {
|
|
67
|
+
if (ignoreClickRef.current) {
|
|
68
|
+
ignoreClickRef.current = false;
|
|
69
|
+
event.preventBaseUIHandler();
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
[enabled, mouseDownAction, open],
|
|
75
|
+
subSlot(slot, 'm'),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// Ported from .base-ui/packages/react/src/utils/useOpenInteractionType.ts (v1.6.0), octane-adapted
|
|
2
2
|
// (slot-threaded; native events). Records the interaction type (mouse/touch/pen/keyboard) that
|
|
3
3
|
// opened a popup, so trigger-owned focus can behave correctly.
|
|
4
|
-
import { useMemo } from 'octane';
|
|
4
|
+
import { useMemo, useState } from 'octane';
|
|
5
5
|
|
|
6
6
|
import { subSlot } from '../internal';
|
|
7
7
|
import { useStableCallback } from './useStableCallback';
|
|
8
8
|
import { useEnhancedClickHandler, type InteractionType } from './useEnhancedClickHandler';
|
|
9
|
+
import { useValueChanged } from './useValueChanged';
|
|
9
10
|
import { platform } from './platform';
|
|
10
11
|
|
|
11
12
|
export function useOpenMethodTriggerProps(
|
|
@@ -30,3 +31,38 @@ export function useOpenMethodTriggerProps(
|
|
|
30
31
|
|
|
31
32
|
return useMemo(() => ({ onClick, onPointerDown }), [onClick, onPointerDown], subSlot(slot, 'm'));
|
|
32
33
|
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Determines the interaction type (keyboard, mouse, touch, etc.) that opened the component, and
|
|
37
|
+
* returns it alongside the trigger props that record it. Resets to `null` once the popup closes.
|
|
38
|
+
*
|
|
39
|
+
* Used by roots that own the open method locally (Menu) rather than delegating it to each trigger
|
|
40
|
+
* (Popover, which calls `useOpenMethodTriggerProps` directly and stores the result in its store).
|
|
41
|
+
*/
|
|
42
|
+
export function useOpenInteractionType(
|
|
43
|
+
open: boolean,
|
|
44
|
+
slot: symbol | undefined,
|
|
45
|
+
): {
|
|
46
|
+
openMethod: InteractionType | null;
|
|
47
|
+
triggerProps: { onClick: (event: any) => void; onPointerDown: (event: any) => void };
|
|
48
|
+
} {
|
|
49
|
+
const [openMethod, setOpenMethod] = useState<InteractionType | null>(null, subSlot(slot, 's'));
|
|
50
|
+
|
|
51
|
+
const triggerProps = useOpenMethodTriggerProps(open, setOpenMethod, subSlot(slot, 'tp'));
|
|
52
|
+
|
|
53
|
+
useValueChanged(
|
|
54
|
+
open,
|
|
55
|
+
(previousOpen: boolean) => {
|
|
56
|
+
if (previousOpen && !open) {
|
|
57
|
+
setOpenMethod(null);
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
subSlot(slot, 'vc'),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
return useMemo(
|
|
64
|
+
() => ({ openMethod, triggerProps }),
|
|
65
|
+
[openMethod, triggerProps],
|
|
66
|
+
subSlot(slot, 'r'),
|
|
67
|
+
);
|
|
68
|
+
}
|