@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.
@@ -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
+ }