@octanejs/base-ui 0.1.14 → 0.1.15

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,101 @@
1
+ // Ported from .base-ui/packages/react/src/unstable-use-media-query/index.ts (v1.6.0).
2
+ // Subscribes to a CSS media query via `window.matchMedia`, with SSR escape hatches.
3
+ //
4
+ // octane adaptation: octane ships `useSyncExternalStore` natively, so Base UI's
5
+ // `use-sync-external-store/shim` import is dropped; the dev-only `useDebugValue` call is
6
+ // dropped with the rest of the dev-warning surface.
7
+ //
8
+ // SLOT: plain-`.ts` hook; the trailing arg is the caller's slot.
9
+ import { useCallback, useMemo, useSyncExternalStore } from 'octane';
10
+
11
+ import { S, splitSlot, subSlot } from './internal';
12
+ import { addEventListener } from './utils/addEventListener';
13
+
14
+ export interface UseMediaQueryOptions {
15
+ /**
16
+ * As `window.matchMedia()` is unavailable on the server, it returns a default match
17
+ * during the first mount.
18
+ * @default false
19
+ */
20
+ defaultMatches?: boolean | undefined;
21
+ /**
22
+ * You can provide your own implementation of matchMedia. This can be used for handling
23
+ * an iframe content window.
24
+ */
25
+ matchMedia?: typeof window.matchMedia | undefined;
26
+ /**
27
+ * To perform the server-side hydration, the hook needs to render twice. A first time
28
+ * with `defaultMatches`, the value of the server, and a second time with the resolved
29
+ * value. This double pass rendering cycle comes with a drawback: it's slower. You can
30
+ * set this option to `true` if you use the returned value **only** client-side.
31
+ * @default false
32
+ */
33
+ noSsr?: boolean | undefined;
34
+ /** Your own implementation of `matchMedia`, used when rendering server-side. */
35
+ ssrMatchMedia?: ((query: string) => { matches: boolean }) | undefined;
36
+ }
37
+
38
+ export function useMediaQuery(query: string, options?: UseMediaQueryOptions): boolean;
39
+ export function useMediaQuery(...args: any[]): boolean {
40
+ const [user, slotArg] = splitSlot(args);
41
+ const slot = slotArg ?? S('useMediaQuery');
42
+
43
+ const rawQuery = user[0] as string;
44
+ const options = (user[1] as UseMediaQueryOptions | undefined) ?? {};
45
+
46
+ // Wait for jsdom to support the match media feature. All the browsers Base UI supports
47
+ // have this built-in; this defensive check is here for simplicity.
48
+ const supportMatchMedia =
49
+ typeof window !== 'undefined' && typeof window.matchMedia !== 'undefined';
50
+
51
+ const normalizedQuery = rawQuery.replace(/^@media( ?)/m, '');
52
+
53
+ const {
54
+ defaultMatches = false,
55
+ matchMedia = supportMatchMedia ? window.matchMedia : null,
56
+ ssrMatchMedia = null,
57
+ noSsr = false,
58
+ } = options;
59
+
60
+ const getDefaultSnapshot = useCallback(
61
+ () => defaultMatches,
62
+ [defaultMatches],
63
+ subSlot(slot, 'def'),
64
+ );
65
+
66
+ const getServerSnapshot = useMemo(
67
+ () => {
68
+ if (noSsr && matchMedia) {
69
+ return () => matchMedia(normalizedQuery).matches;
70
+ }
71
+ if (ssrMatchMedia !== null) {
72
+ const { matches } = ssrMatchMedia(normalizedQuery);
73
+ return () => matches;
74
+ }
75
+ return getDefaultSnapshot;
76
+ },
77
+ [getDefaultSnapshot, normalizedQuery, ssrMatchMedia, noSsr, matchMedia],
78
+ subSlot(slot, 'srv'),
79
+ );
80
+
81
+ const [getSnapshot, subscribe] = useMemo(
82
+ () => {
83
+ if (matchMedia === null) {
84
+ return [getDefaultSnapshot, () => () => {}] as const;
85
+ }
86
+ const mediaQueryList = matchMedia(normalizedQuery);
87
+ return [
88
+ () => mediaQueryList.matches,
89
+ (notify: () => void) => addEventListener(mediaQueryList, 'change', notify),
90
+ ] as const;
91
+ },
92
+ [getDefaultSnapshot, matchMedia, normalizedQuery],
93
+ subSlot(slot, 'sub'),
94
+ );
95
+
96
+ return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot, subSlot(slot, 'store'));
97
+ }
98
+
99
+ export namespace useMediaQuery {
100
+ export type Options = UseMediaQueryOptions;
101
+ }
@@ -0,0 +1,21 @@
1
+ // Ported from .base-ui/packages/react/src/internals/csp-context/CSPContext.tsx (v1.6.0).
2
+ // Carries the Content Security Policy configuration for components that need inline
3
+ // `<style>` tags — the `nonce` to stamp on them, and whether to skip them entirely.
4
+ import { createContext, useContext } from 'octane';
5
+
6
+ export interface CSPContextValue {
7
+ nonce?: string | undefined;
8
+ disableStyleElements?: boolean | undefined;
9
+ }
10
+
11
+ export const CSPContext = createContext<CSPContextValue | undefined>(undefined);
12
+
13
+ const DEFAULT_CSP_CONTEXT_VALUE: CSPContextValue = {
14
+ disableStyleElements: false,
15
+ };
16
+
17
+ export function useCSPContext(...args: any[]): CSPContextValue {
18
+ // `useContext` is slot-exempt in octane, so the caller's slot (if any) is ignored.
19
+ void args;
20
+ return useContext(CSPContext) ?? DEFAULT_CSP_CONTEXT_VALUE;
21
+ }
@@ -0,0 +1,29 @@
1
+ // Ported from .base-ui/packages/react/src/utils/FloatingPortalLite.tsx (v1.6.0). `FloatingPortal`
2
+ // also carries the tabbable/focus-guard machinery; components that don't need it (Tooltip,
3
+ // PreviewCard, Toast) use this thinner variant, which only creates the portal node and renders the
4
+ // children into it.
5
+ //
6
+ // octane adaptations: forwardRef → ref-as-prop; `createElement` instead of JSX; `ReactDOM`'s
7
+ // `createPortal` → octane's.
8
+ import { createElement, createPortal, Fragment } from 'octane';
9
+
10
+ import { S, subSlot } from '../internal';
11
+ import { useFloatingPortalNode } from './floating/FloatingPortal';
12
+
13
+ export function FloatingPortalLite(componentProps: any): any {
14
+ const slot = S('FloatingPortalLite');
15
+ const { children, container, className, render, style, ref, ...elementProps } = componentProps;
16
+
17
+ const { portalNode, portalSubtree } = useFloatingPortalNode(
18
+ { container, ref, componentProps: { render, className, style }, elementProps },
19
+ subSlot(slot, 'node'),
20
+ );
21
+
22
+ if (!portalSubtree && !portalNode) {
23
+ return null;
24
+ }
25
+
26
+ return createElement(Fragment, {
27
+ children: [portalSubtree, portalNode ? createPortal(children, portalNode) : null],
28
+ });
29
+ }
@@ -22,6 +22,7 @@ export const REASONS = {
22
22
  scrub: 'scrub',
23
23
  trackPress: 'track-press',
24
24
  drag: 'drag',
25
+ disabled: 'disabled',
25
26
  triggerHover: 'trigger-hover',
26
27
  triggerFocus: 'trigger-focus',
27
28
  outsidePress: 'outside-press',
@@ -0,0 +1,280 @@
1
+ // Ported from .base-ui/packages/react/src/floating-ui-react/components/FloatingDelayGroup.tsx
2
+ // (v1.6.0). A group of tooltips that share one hover delay: once any member has opened, the others
3
+ // open instantly until the group's `timeoutMs` window lapses. The delay lives in a ref so changing
4
+ // it does not re-render unrelated consumers.
5
+ //
6
+ // octane adaptations: `createContext`/`createElement` instead of JSX; `useIsoLayoutEffect` →
7
+ // `useLayoutEffect`; every composed hook threads an explicit slot.
8
+ import {
9
+ createContext,
10
+ createElement,
11
+ useContext,
12
+ useLayoutEffect,
13
+ useMemo,
14
+ useRef,
15
+ useState,
16
+ } from 'octane';
17
+
18
+ import { S, splitSlot, subSlot } from '../../internal';
19
+ import { Timeout, useTimeout } from '../useTimeout';
20
+ import { createChangeEventDetails, REASONS } from '../createChangeEventDetails';
21
+ import { getDelay } from './useHoverShared';
22
+ import type { HoverDelay } from './useHoverShared';
23
+
24
+ interface DelayGroupContextValue {
25
+ hasProvider: boolean;
26
+ timeoutMs: number;
27
+ delayRef: { current: HoverDelay };
28
+ initialDelayRef: { current: HoverDelay };
29
+ timeout: Timeout;
30
+ currentIdRef: { current: any };
31
+ currentContextRef: {
32
+ current: {
33
+ onOpenChange: (open: boolean, eventDetails: any) => void;
34
+ setIsInstantPhase: (value: boolean) => void;
35
+ } | null;
36
+ };
37
+ }
38
+
39
+ const DEFAULT_CONTEXT: DelayGroupContextValue = {
40
+ hasProvider: false,
41
+ timeoutMs: 0,
42
+ delayRef: { current: 0 },
43
+ initialDelayRef: { current: 0 },
44
+ timeout: new Timeout(),
45
+ currentIdRef: { current: null },
46
+ currentContextRef: { current: null },
47
+ };
48
+
49
+ const FloatingDelayGroupContext = createContext<DelayGroupContextValue>(DEFAULT_CONTEXT);
50
+
51
+ function resetDelayRef(
52
+ delayRef: { current: HoverDelay },
53
+ initialDelayRef: { current: HoverDelay },
54
+ ): void {
55
+ delayRef.current = initialDelayRef.current;
56
+ }
57
+
58
+ export interface FloatingDelayGroupProps {
59
+ children?: any;
60
+ /** The delay to use for the group when it's not in the instant phase. */
61
+ delay: HoverDelay;
62
+ /**
63
+ * An explicit timeout for the group, representing when grouping logic stops being active
64
+ * after the close delay completes. Useful when there is no close delay at all.
65
+ */
66
+ timeoutMs?: number | undefined;
67
+ }
68
+
69
+ export function FloatingDelayGroup(props: FloatingDelayGroupProps): any {
70
+ const slot = S('FloatingDelayGroup');
71
+ const { children, delay, timeoutMs = 0 } = props;
72
+
73
+ const delayRef = useRef<HoverDelay>(delay, subSlot(slot, 'delay'));
74
+ const initialDelayRef = useRef<HoverDelay>(delay, subSlot(slot, 'initial'));
75
+ const currentIdRef = useRef<string | null>(null, subSlot(slot, 'id'));
76
+ const currentContextRef = useRef<any>(null, subSlot(slot, 'ctxRef'));
77
+ const timeout = useTimeout(subSlot(slot, 'to'));
78
+
79
+ useLayoutEffect(
80
+ () => {
81
+ initialDelayRef.current = delay;
82
+
83
+ if (!currentIdRef.current) {
84
+ delayRef.current = delay;
85
+ return;
86
+ }
87
+
88
+ // A member is already open: keep the (zeroed) open delay, adopt the new close delay.
89
+ delayRef.current = {
90
+ open: getDelay(delayRef.current, 'open'),
91
+ close: getDelay(delay, 'close'),
92
+ } as HoverDelay;
93
+ },
94
+ [delay, currentIdRef, delayRef, initialDelayRef],
95
+ subSlot(slot, 'l:delay'),
96
+ );
97
+
98
+ const contextValue = useMemo(
99
+ () => ({
100
+ hasProvider: true,
101
+ delayRef,
102
+ initialDelayRef,
103
+ currentIdRef,
104
+ timeoutMs,
105
+ currentContextRef,
106
+ timeout,
107
+ }),
108
+ [timeoutMs, timeout],
109
+ subSlot(slot, 'ctx'),
110
+ );
111
+
112
+ return createElement(FloatingDelayGroupContext.Provider, { value: contextValue, children });
113
+ }
114
+
115
+ export interface UseDelayGroupReturn {
116
+ delayRef: { current: HoverDelay };
117
+ isInstantPhase: boolean;
118
+ hasProvider: boolean;
119
+ }
120
+
121
+ export function useDelayGroup(...args: any[]): UseDelayGroupReturn {
122
+ const [user, slotArg] = splitSlot(args);
123
+ const slot = slotArg ?? S('useDelayGroup');
124
+ const context = user[0] as any;
125
+ const options = (user[1] as { open: boolean } | undefined) ?? { open: false };
126
+
127
+ const { open } = options;
128
+
129
+ const store = context != null && 'rootStore' in context ? context.rootStore : context;
130
+ const floatingId = store.useState('floatingId', subSlot(slot, 's:id'));
131
+
132
+ const groupContext = useContext(FloatingDelayGroupContext);
133
+ const {
134
+ currentIdRef,
135
+ delayRef,
136
+ timeoutMs,
137
+ initialDelayRef,
138
+ currentContextRef,
139
+ hasProvider,
140
+ timeout,
141
+ } = groupContext;
142
+
143
+ const [isInstantPhase, setIsInstantPhase] = useState(false, subSlot(slot, 'instant'));
144
+ const openRef = useRef(open, subSlot(slot, 'open'));
145
+ const isUnmountedRef = useRef(false, subSlot(slot, 'unmounted'));
146
+
147
+ useLayoutEffect(
148
+ () => {
149
+ openRef.current = open;
150
+ },
151
+ [open],
152
+ subSlot(slot, 'l:openRef'),
153
+ );
154
+
155
+ useLayoutEffect(
156
+ () => {
157
+ return () => {
158
+ isUnmountedRef.current = true;
159
+ };
160
+ },
161
+ [],
162
+ subSlot(slot, 'l:unmount'),
163
+ );
164
+
165
+ useLayoutEffect(
166
+ () => {
167
+ function unset() {
168
+ if (!isUnmountedRef.current) {
169
+ setIsInstantPhase(false);
170
+ }
171
+ currentContextRef.current?.setIsInstantPhase(false);
172
+ currentIdRef.current = null;
173
+ currentContextRef.current = null;
174
+ delayRef.current = initialDelayRef.current;
175
+ timeout.clear();
176
+ }
177
+
178
+ if (!currentIdRef.current) {
179
+ return undefined;
180
+ }
181
+
182
+ if (!open && currentIdRef.current === floatingId) {
183
+ setIsInstantPhase(false);
184
+
185
+ if (timeoutMs) {
186
+ const closingId = floatingId;
187
+ timeout.start(timeoutMs, () => {
188
+ // If another tooltip has taken over the group, skip resetting.
189
+ if (
190
+ store.select('open') ||
191
+ (currentIdRef.current && currentIdRef.current !== closingId)
192
+ ) {
193
+ return;
194
+ }
195
+ unset();
196
+ });
197
+ return () => {
198
+ if (openRef.current || currentIdRef.current !== closingId) {
199
+ timeout.clear();
200
+ }
201
+ };
202
+ }
203
+
204
+ unset();
205
+ }
206
+
207
+ return undefined;
208
+ },
209
+ [
210
+ open,
211
+ floatingId,
212
+ currentIdRef,
213
+ delayRef,
214
+ timeoutMs,
215
+ initialDelayRef,
216
+ currentContextRef,
217
+ timeout,
218
+ store,
219
+ ],
220
+ subSlot(slot, 'l:close'),
221
+ );
222
+
223
+ useLayoutEffect(
224
+ () => {
225
+ if (!open) {
226
+ return;
227
+ }
228
+
229
+ const prevContext = currentContextRef.current;
230
+ const prevId = currentIdRef.current;
231
+
232
+ // A new tooltip is opening, so cancel any pending timeout that would reset the group's
233
+ // delay back to the initial value.
234
+ timeout.clear();
235
+ currentContextRef.current = { onOpenChange: store.setOpen, setIsInstantPhase };
236
+ currentIdRef.current = floatingId;
237
+ delayRef.current = {
238
+ open: 0,
239
+ close: getDelay(initialDelayRef.current, 'close'),
240
+ } as HoverDelay;
241
+
242
+ if (prevId !== null && prevId !== floatingId) {
243
+ setIsInstantPhase(true);
244
+ prevContext?.setIsInstantPhase(true);
245
+ prevContext?.onOpenChange(false, createChangeEventDetails(REASONS.none));
246
+ } else {
247
+ setIsInstantPhase(false);
248
+ prevContext?.setIsInstantPhase(false);
249
+ }
250
+ },
251
+ [open, floatingId, store, currentIdRef, delayRef, initialDelayRef, currentContextRef, timeout],
252
+ subSlot(slot, 'l:open'),
253
+ );
254
+
255
+ useLayoutEffect(
256
+ () => {
257
+ return () => {
258
+ if (currentIdRef.current === floatingId) {
259
+ currentContextRef.current = null;
260
+
261
+ if (!openRef.current) {
262
+ return;
263
+ }
264
+
265
+ currentIdRef.current = null;
266
+ resetDelayRef(delayRef, initialDelayRef);
267
+ timeout.clear();
268
+ }
269
+ };
270
+ },
271
+ [currentContextRef, currentIdRef, delayRef, floatingId, initialDelayRef, timeout],
272
+ subSlot(slot, 'l:teardown'),
273
+ );
274
+
275
+ return useMemo(
276
+ () => ({ hasProvider, delayRef, isInstantPhase }),
277
+ [hasProvider, delayRef, isInstantPhase],
278
+ subSlot(slot, 'out'),
279
+ );
280
+ }
@@ -1,10 +1,44 @@
1
1
  // Ported from .base-ui/packages/react/src/floating-ui-react/utils/element.ts (v1.6.0) — the subset
2
2
  // used so far. `getTarget` is reused from the composite list utils.
3
- import { isHTMLElement } from '../dom';
3
+ import { isElement, isHTMLElement } from '../dom';
4
+ import type { PopupTriggerMap } from '../popups/popupTriggerMap';
4
5
  import { TYPEABLE_SELECTOR, FOCUSABLE_ATTRIBUTE } from './constants';
5
6
 
7
+ import { contains } from '../contains';
8
+
6
9
  export { getTarget } from '../composite/list-utils';
7
- export { contains } from '../contains';
10
+ export { contains };
11
+
12
+ // Whether the target sits on (or inside) one of this popup's triggers, and that trigger is not
13
+ // marked disabled. Hover uses it to keep a popup open while the cursor crosses between triggers.
14
+ export function isTargetInsideEnabledTrigger(
15
+ target: EventTarget | null,
16
+ triggerElements: PopupTriggerMap,
17
+ ): boolean {
18
+ if (!isElement(target)) {
19
+ return false;
20
+ }
21
+ const targetElement = target as Element;
22
+ if (triggerElements.hasElement(targetElement)) {
23
+ return !targetElement.hasAttribute('data-trigger-disabled');
24
+ }
25
+ for (const [, trigger] of triggerElements.entries()) {
26
+ if (contains(trigger, targetElement)) {
27
+ return !trigger.hasAttribute('data-trigger-disabled');
28
+ }
29
+ }
30
+ return false;
31
+ }
32
+
33
+ // Whether the element (or an ancestor) is something the user can interact with directly — used to
34
+ // decide whether a pointerdown inside a popup counts as a click-like open.
35
+ export function isInteractiveElement(element: Element | null): boolean {
36
+ return (
37
+ element?.closest(
38
+ `button,a[href],[role="button"],select,[tabindex]:not([tabindex="-1"]),${TYPEABLE_SELECTOR}`,
39
+ ) != null
40
+ );
41
+ }
8
42
 
9
43
  // Ported from .base-ui/…/internals/shadowDom.ts — the deepest active element across shadow roots.
10
44
  export function activeElement(doc: Document): Element | null {