@octanejs/base-ui 0.1.1 → 0.1.2
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/README.md +7 -1
- package/package.json +12 -4
- package/src/alert-dialog.ts +47 -0
- package/src/checkbox.ts +7 -16
- package/src/dialog.ts +859 -0
- package/src/field.ts +4 -30
- package/src/index.ts +3 -0
- package/src/number-field.ts +8 -45
- package/src/popover.ts +1205 -0
- package/src/radio.ts +3 -24
- package/src/slider.ts +4 -5
- package/src/switch.ts +4 -30
- package/src/utils/InternalBackdrop.ts +28 -0
- package/src/utils/adaptiveOriginMiddleware.ts +82 -0
- package/src/utils/closePart.ts +61 -0
- package/src/utils/constants.ts +9 -0
- package/src/utils/createChangeEventDetails.ts +7 -0
- package/src/utils/dom.ts +9 -0
- package/src/utils/empty.ts +7 -0
- package/src/utils/floating/FloatingFocusManager.ts +833 -0
- package/src/utils/floating/FloatingPortal.ts +268 -0
- package/src/utils/floating/FloatingRootStore.ts +127 -0
- package/src/utils/floating/FloatingTree.ts +70 -0
- package/src/utils/floating/FloatingTreeStore.ts +22 -0
- package/src/utils/floating/FocusGuard.ts +34 -0
- package/src/utils/floating/composite.ts +20 -0
- package/src/utils/floating/constants.ts +8 -0
- package/src/utils/floating/createAttribute.ts +4 -0
- package/src/utils/floating/createEventEmitter.ts +20 -0
- package/src/utils/floating/element.ts +54 -0
- package/src/utils/floating/enqueueFocus.ts +38 -0
- package/src/utils/floating/event.ts +53 -0
- package/src/utils/floating/getEmptyRootContext.ts +19 -0
- package/src/utils/floating/markOthers.ts +197 -0
- package/src/utils/floating/nodes.ts +31 -0
- package/src/utils/floating/tabbable.ts +260 -0
- package/src/utils/floating/types.ts +57 -0
- package/src/utils/floating/useClick.ts +174 -0
- package/src/utils/floating/useDismiss.ts +641 -0
- package/src/utils/floating/useFloating.ts +198 -0
- package/src/utils/floating/useFloatingRootContext.ts +81 -0
- package/src/utils/floating/useHoverFloatingInteraction.ts +12 -0
- package/src/utils/floating/useHoverReferenceInteraction.ts +17 -0
- package/src/utils/floating/useSyncedFloatingRootContext.ts +93 -0
- package/src/utils/getDisabledMountTransitionStyles.ts +12 -0
- package/src/utils/hideMiddleware.ts +22 -0
- package/src/utils/inertValue.ts +5 -0
- package/src/utils/mergeCleanups.ts +13 -0
- package/src/utils/platform.ts +46 -2
- package/src/utils/popupStateMapping.ts +48 -0
- package/src/utils/popups/index.ts +4 -0
- package/src/utils/popups/popupStoreUtils.ts +484 -0
- package/src/utils/popups/popupTriggerMap.ts +62 -0
- package/src/utils/popups/store.ts +147 -0
- package/src/utils/popups/useTriggerFocusGuards.ts +73 -0
- package/src/utils/store/ReactStore.ts +168 -0
- package/src/utils/store/Store.ts +84 -0
- package/src/utils/store/createSelector.ts +66 -0
- package/src/utils/store/useStore.ts +49 -0
- package/src/utils/useAnchorPositioning.ts +581 -0
- package/src/utils/useAnchoredPopupScrollLock.ts +50 -0
- package/src/utils/useAnimationFrame.ts +28 -5
- package/src/utils/useEnhancedClickHandler.ts +47 -0
- package/src/utils/useOnFirstRender.ts +11 -0
- package/src/utils/useOpenInteractionType.ts +32 -0
- package/src/utils/usePositioner.ts +48 -0
- package/src/utils/useScrollLock.ts +253 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// Ported from .base-ui/packages/react/src/floating-ui-react/components/FloatingPortal.tsx (v1.6.0),
|
|
2
|
+
// octane-adapted: forwardRef → ref-as-prop; hooks thread slots; `ReactDOM.createPortal` → octane
|
|
3
|
+
// `createPortal`; native events. Renders a styleable portal-node div (`<div id data-base-ui-portal>`)
|
|
4
|
+
// into the container + portals children into it; adds tab-order focus guards for non-modal popups.
|
|
5
|
+
import {
|
|
6
|
+
createContext,
|
|
7
|
+
createElement,
|
|
8
|
+
createPortal,
|
|
9
|
+
useContext,
|
|
10
|
+
useState,
|
|
11
|
+
useRef,
|
|
12
|
+
useMemo,
|
|
13
|
+
useEffect,
|
|
14
|
+
useLayoutEffect,
|
|
15
|
+
useId,
|
|
16
|
+
} from 'octane';
|
|
17
|
+
import { isNode } from '@floating-ui/utils/dom';
|
|
18
|
+
|
|
19
|
+
import { S, subSlot } from '../../internal';
|
|
20
|
+
import { useStableCallback } from '../useStableCallback';
|
|
21
|
+
import { addEventListener } from '../addEventListener';
|
|
22
|
+
import { mergeCleanups } from '../mergeCleanups';
|
|
23
|
+
import { EMPTY_OBJECT } from '../empty';
|
|
24
|
+
import { useRenderElement } from '../useRenderElement';
|
|
25
|
+
import { createAttribute } from './createAttribute';
|
|
26
|
+
import { FocusGuard } from './FocusGuard';
|
|
27
|
+
import {
|
|
28
|
+
enableFocusInside,
|
|
29
|
+
disableFocusInside,
|
|
30
|
+
getPreviousTabbable,
|
|
31
|
+
getNextTabbable,
|
|
32
|
+
isOutsideEvent,
|
|
33
|
+
} from './tabbable';
|
|
34
|
+
import { createChangeEventDetails, REASONS } from '../createChangeEventDetails';
|
|
35
|
+
|
|
36
|
+
const ownerVisuallyHidden = {
|
|
37
|
+
clipPath: 'inset(50%)',
|
|
38
|
+
position: 'fixed',
|
|
39
|
+
top: 0,
|
|
40
|
+
left: 0,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
type FocusManagerState = null | {
|
|
44
|
+
modal: boolean;
|
|
45
|
+
open: boolean;
|
|
46
|
+
onOpenChange: (open: boolean, data?: { reason?: string; event?: Event }) => void;
|
|
47
|
+
domReference: Element | null;
|
|
48
|
+
closeOnFocusOut: boolean;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
interface PortalContextValue {
|
|
52
|
+
portalNode: HTMLElement | null;
|
|
53
|
+
setFocusManagerState: (v: FocusManagerState) => void;
|
|
54
|
+
beforeInsideRef: { current: HTMLSpanElement | null };
|
|
55
|
+
afterInsideRef: { current: HTMLSpanElement | null };
|
|
56
|
+
beforeOutsideRef: { current: HTMLSpanElement | null };
|
|
57
|
+
afterOutsideRef: { current: HTMLSpanElement | null };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const PortalContext = createContext<PortalContextValue | null>(null);
|
|
61
|
+
|
|
62
|
+
export const usePortalContext = () => useContext(PortalContext);
|
|
63
|
+
|
|
64
|
+
const attr = createAttribute('portal');
|
|
65
|
+
|
|
66
|
+
export function useFloatingPortalNode(userProps: any, slot: symbol | undefined): any {
|
|
67
|
+
const { ref, container: containerProp, componentProps = EMPTY_OBJECT, elementProps } = userProps;
|
|
68
|
+
|
|
69
|
+
// Raw `useId` (no `base-ui-` prefix), matching Base UI's `@base-ui/utils/useId`.
|
|
70
|
+
const uniqueId = useId(subSlot(slot, 'id'));
|
|
71
|
+
const portalContext = usePortalContext();
|
|
72
|
+
const parentPortalNode = portalContext?.portalNode;
|
|
73
|
+
|
|
74
|
+
const [containerElement, setContainerElement] = useState<Element | null>(
|
|
75
|
+
null,
|
|
76
|
+
subSlot(slot, 'cel'),
|
|
77
|
+
);
|
|
78
|
+
const [portalNode, setPortalNode] = useState<HTMLElement | null>(null, subSlot(slot, 'pn'));
|
|
79
|
+
const setPortalNodeRef = useStableCallback(
|
|
80
|
+
(node: HTMLElement | null) => {
|
|
81
|
+
if (node !== null) {
|
|
82
|
+
setPortalNode(node);
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
subSlot(slot, 'pnr'),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const containerRef = useRef<Element | null>(null, subSlot(slot, 'cref'));
|
|
89
|
+
|
|
90
|
+
useLayoutEffect(
|
|
91
|
+
() => {
|
|
92
|
+
if (containerProp === null) {
|
|
93
|
+
if (containerRef.current) {
|
|
94
|
+
containerRef.current = null;
|
|
95
|
+
setPortalNode(null);
|
|
96
|
+
setContainerElement(null);
|
|
97
|
+
}
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (uniqueId == null) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const resolvedContainer =
|
|
104
|
+
(containerProp && (isNode(containerProp) ? containerProp : containerProp.current)) ??
|
|
105
|
+
parentPortalNode ??
|
|
106
|
+
document.body;
|
|
107
|
+
if (resolvedContainer == null) {
|
|
108
|
+
if (containerRef.current) {
|
|
109
|
+
containerRef.current = null;
|
|
110
|
+
setPortalNode(null);
|
|
111
|
+
setContainerElement(null);
|
|
112
|
+
}
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (containerRef.current !== resolvedContainer) {
|
|
116
|
+
containerRef.current = resolvedContainer;
|
|
117
|
+
setPortalNode(null);
|
|
118
|
+
setContainerElement(resolvedContainer);
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
[containerProp, parentPortalNode, uniqueId],
|
|
122
|
+
subSlot(slot, 'e:container'),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const portalElement = useRenderElement(
|
|
126
|
+
'div',
|
|
127
|
+
componentProps,
|
|
128
|
+
{
|
|
129
|
+
ref: [ref, setPortalNodeRef],
|
|
130
|
+
props: [{ id: uniqueId, [attr]: '' }, elementProps],
|
|
131
|
+
},
|
|
132
|
+
subSlot(slot, 're'),
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const portalSubtree =
|
|
136
|
+
containerElement && portalElement ? createPortal(portalElement, containerElement) : null;
|
|
137
|
+
|
|
138
|
+
return { portalNode, portalSubtree };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function FloatingPortal(componentProps: any): any {
|
|
142
|
+
const slot = S('FloatingPortal');
|
|
143
|
+
const { render, className, style, children, container, renderGuards, ref, ...elementProps } =
|
|
144
|
+
componentProps;
|
|
145
|
+
|
|
146
|
+
const { portalNode, portalSubtree } = useFloatingPortalNode(
|
|
147
|
+
{ container, ref, componentProps: { render, className, style }, elementProps },
|
|
148
|
+
subSlot(slot, 'node'),
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const beforeOutsideRef = useRef<HTMLSpanElement | null>(null, subSlot(slot, 'bo'));
|
|
152
|
+
const afterOutsideRef = useRef<HTMLSpanElement | null>(null, subSlot(slot, 'ao'));
|
|
153
|
+
const beforeInsideRef = useRef<HTMLSpanElement | null>(null, subSlot(slot, 'bi'));
|
|
154
|
+
const afterInsideRef = useRef<HTMLSpanElement | null>(null, subSlot(slot, 'ai'));
|
|
155
|
+
|
|
156
|
+
const [focusManagerState, setFocusManagerState] = useState<FocusManagerState>(
|
|
157
|
+
null,
|
|
158
|
+
subSlot(slot, 'fms'),
|
|
159
|
+
);
|
|
160
|
+
const focusInsideDisabledRef = useRef(false, subSlot(slot, 'fid'));
|
|
161
|
+
|
|
162
|
+
const modal = focusManagerState?.modal;
|
|
163
|
+
const open = focusManagerState?.open;
|
|
164
|
+
|
|
165
|
+
const shouldRenderGuards =
|
|
166
|
+
typeof renderGuards === 'boolean'
|
|
167
|
+
? renderGuards
|
|
168
|
+
: !!focusManagerState && !focusManagerState.modal && focusManagerState.open && !!portalNode;
|
|
169
|
+
|
|
170
|
+
useEffect(
|
|
171
|
+
() => {
|
|
172
|
+
if (!portalNode || modal) {
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
function onFocus(event: FocusEvent) {
|
|
176
|
+
if (portalNode && event.relatedTarget && isOutsideEvent(event)) {
|
|
177
|
+
if (event.type === 'focusin') {
|
|
178
|
+
if (focusInsideDisabledRef.current) {
|
|
179
|
+
enableFocusInside(portalNode);
|
|
180
|
+
focusInsideDisabledRef.current = false;
|
|
181
|
+
}
|
|
182
|
+
} else {
|
|
183
|
+
disableFocusInside(portalNode);
|
|
184
|
+
focusInsideDisabledRef.current = true;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return mergeCleanups(
|
|
189
|
+
addEventListener(portalNode, 'focusin', onFocus as EventListener, true),
|
|
190
|
+
addEventListener(portalNode, 'focusout', onFocus as EventListener, true),
|
|
191
|
+
);
|
|
192
|
+
},
|
|
193
|
+
[portalNode, modal],
|
|
194
|
+
subSlot(slot, 'e:tab'),
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
useLayoutEffect(
|
|
198
|
+
() => {
|
|
199
|
+
if (!portalNode || open !== true || !focusInsideDisabledRef.current) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
enableFocusInside(portalNode);
|
|
203
|
+
focusInsideDisabledRef.current = false;
|
|
204
|
+
},
|
|
205
|
+
[open, portalNode],
|
|
206
|
+
subSlot(slot, 'e:enable'),
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
const portalContextValue = useMemo(
|
|
210
|
+
() => ({
|
|
211
|
+
beforeOutsideRef,
|
|
212
|
+
afterOutsideRef,
|
|
213
|
+
beforeInsideRef,
|
|
214
|
+
afterInsideRef,
|
|
215
|
+
portalNode,
|
|
216
|
+
setFocusManagerState,
|
|
217
|
+
}),
|
|
218
|
+
[portalNode],
|
|
219
|
+
subSlot(slot, 'ctx'),
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
return [
|
|
223
|
+
portalSubtree,
|
|
224
|
+
createElement(PortalContext.Provider, {
|
|
225
|
+
value: portalContextValue,
|
|
226
|
+
children: [
|
|
227
|
+
shouldRenderGuards && portalNode
|
|
228
|
+
? createElement(FocusGuard, {
|
|
229
|
+
'data-type': 'outside',
|
|
230
|
+
ref: beforeOutsideRef,
|
|
231
|
+
onFocus(event: any) {
|
|
232
|
+
if (isOutsideEvent(event, portalNode)) {
|
|
233
|
+
beforeInsideRef.current?.focus();
|
|
234
|
+
} else {
|
|
235
|
+
const domReference = focusManagerState ? focusManagerState.domReference : null;
|
|
236
|
+
getPreviousTabbable(domReference)?.focus();
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
})
|
|
240
|
+
: null,
|
|
241
|
+
shouldRenderGuards && portalNode
|
|
242
|
+
? createElement('span', { 'aria-owns': portalNode.id, style: ownerVisuallyHidden })
|
|
243
|
+
: null,
|
|
244
|
+
portalNode ? createPortal(children, portalNode) : null,
|
|
245
|
+
shouldRenderGuards && portalNode
|
|
246
|
+
? createElement(FocusGuard, {
|
|
247
|
+
'data-type': 'outside',
|
|
248
|
+
ref: afterOutsideRef,
|
|
249
|
+
onFocus(event: any) {
|
|
250
|
+
if (isOutsideEvent(event, portalNode)) {
|
|
251
|
+
afterInsideRef.current?.focus();
|
|
252
|
+
} else {
|
|
253
|
+
const domReference = focusManagerState ? focusManagerState.domReference : null;
|
|
254
|
+
getNextTabbable(domReference)?.focus();
|
|
255
|
+
if (focusManagerState?.closeOnFocusOut) {
|
|
256
|
+
focusManagerState?.onOpenChange(
|
|
257
|
+
false,
|
|
258
|
+
createChangeEventDetails(REASONS.focusOut, event) as any,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
})
|
|
264
|
+
: null,
|
|
265
|
+
],
|
|
266
|
+
}),
|
|
267
|
+
];
|
|
268
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Ported from .base-ui/packages/react/src/floating-ui-react/components/FloatingRootStore.ts
|
|
2
|
+
// (v1.6.0). The Store-based root context every Base UI popup's floating interactions read
|
|
3
|
+
// (`store.useState('open')`, `store.context.dataRef`, …) — Base UI's 1.6.0 replacement for
|
|
4
|
+
// upstream `@floating-ui/react`'s emitter-only root context. Extends the octane-adapted
|
|
5
|
+
// `ReactStore`; pure otherwise.
|
|
6
|
+
import { createSelector } from '../store/createSelector';
|
|
7
|
+
import { ReactStore } from '../store/ReactStore';
|
|
8
|
+
import type { TransitionStatus } from '../useTransitionStatus';
|
|
9
|
+
import type { PopupTriggerMap } from '../popups/popupTriggerMap';
|
|
10
|
+
import { createEventEmitter } from './createEventEmitter';
|
|
11
|
+
import { isClickLikeEvent } from './event';
|
|
12
|
+
import type { FloatingEvents, ContextData, ReferenceType } from './types';
|
|
13
|
+
|
|
14
|
+
interface BaseUIChangeEventDetails {
|
|
15
|
+
reason: string;
|
|
16
|
+
event: Event;
|
|
17
|
+
trigger?: Element | undefined;
|
|
18
|
+
[key: string]: any;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface FloatingUIOpenChangeDetails {
|
|
22
|
+
open: boolean;
|
|
23
|
+
reason: string;
|
|
24
|
+
nativeEvent: Event | undefined;
|
|
25
|
+
nested: boolean;
|
|
26
|
+
triggerElement?: Element | undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface FloatingRootState {
|
|
30
|
+
open: boolean;
|
|
31
|
+
transitionStatus: TransitionStatus | undefined;
|
|
32
|
+
domReferenceElement: Element | null;
|
|
33
|
+
referenceElement: ReferenceType | null;
|
|
34
|
+
floatingElement: HTMLElement | null;
|
|
35
|
+
positionReference: ReferenceType | null;
|
|
36
|
+
floatingId: string | undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface FloatingRootStoreContext {
|
|
40
|
+
onOpenChange: ((open: boolean, eventDetails: BaseUIChangeEventDetails) => void) | undefined;
|
|
41
|
+
readonly dataRef: { current: ContextData };
|
|
42
|
+
readonly events: FloatingEvents;
|
|
43
|
+
nested: boolean;
|
|
44
|
+
readonly triggerElements: PopupTriggerMap;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const selectors = {
|
|
48
|
+
open: createSelector((state: FloatingRootState) => state.open),
|
|
49
|
+
transitionStatus: createSelector((state: FloatingRootState) => state.transitionStatus),
|
|
50
|
+
domReferenceElement: createSelector((state: FloatingRootState) => state.domReferenceElement),
|
|
51
|
+
referenceElement: createSelector(
|
|
52
|
+
(state: FloatingRootState) => state.positionReference ?? state.referenceElement,
|
|
53
|
+
),
|
|
54
|
+
floatingElement: createSelector((state: FloatingRootState) => state.floatingElement),
|
|
55
|
+
floatingId: createSelector((state: FloatingRootState) => state.floatingId),
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
interface FloatingRootStoreOptions {
|
|
59
|
+
open: boolean;
|
|
60
|
+
transitionStatus: TransitionStatus | undefined;
|
|
61
|
+
referenceElement: ReferenceType | null;
|
|
62
|
+
floatingElement: HTMLElement | null;
|
|
63
|
+
triggerElements: PopupTriggerMap;
|
|
64
|
+
floatingId: string | undefined;
|
|
65
|
+
syncOnly: boolean;
|
|
66
|
+
nested: boolean;
|
|
67
|
+
onOpenChange: ((open: boolean, eventDetails: BaseUIChangeEventDetails) => void) | undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class FloatingRootStore extends ReactStore<
|
|
71
|
+
Readonly<FloatingRootState>,
|
|
72
|
+
FloatingRootStoreContext,
|
|
73
|
+
typeof selectors
|
|
74
|
+
> {
|
|
75
|
+
private readonly syncOnly: boolean;
|
|
76
|
+
|
|
77
|
+
constructor(options: FloatingRootStoreOptions) {
|
|
78
|
+
const { syncOnly, nested, onOpenChange, triggerElements, ...initialState } = options;
|
|
79
|
+
|
|
80
|
+
super(
|
|
81
|
+
{
|
|
82
|
+
...initialState,
|
|
83
|
+
positionReference: initialState.referenceElement,
|
|
84
|
+
domReferenceElement: initialState.referenceElement as Element | null,
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
onOpenChange,
|
|
88
|
+
dataRef: { current: {} },
|
|
89
|
+
events: createEventEmitter(),
|
|
90
|
+
nested,
|
|
91
|
+
triggerElements,
|
|
92
|
+
},
|
|
93
|
+
selectors,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
this.syncOnly = syncOnly;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
syncOpenEvent = (newOpen: boolean, event: Event | undefined) => {
|
|
100
|
+
if (!newOpen || !this.state.open || (event != null && isClickLikeEvent(event))) {
|
|
101
|
+
this.context.dataRef.current.openEvent = newOpen ? event : undefined;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
dispatchOpenChange = (newOpen: boolean, eventDetails: BaseUIChangeEventDetails) => {
|
|
106
|
+
this.syncOpenEvent(newOpen, eventDetails.event);
|
|
107
|
+
|
|
108
|
+
const details: FloatingUIOpenChangeDetails = {
|
|
109
|
+
open: newOpen,
|
|
110
|
+
reason: eventDetails.reason,
|
|
111
|
+
nativeEvent: eventDetails.event,
|
|
112
|
+
nested: this.context.nested,
|
|
113
|
+
triggerElement: eventDetails.trigger,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
this.context.events.emit('openchange', details);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
setOpen = (newOpen: boolean, eventDetails: BaseUIChangeEventDetails) => {
|
|
120
|
+
if (this.syncOnly) {
|
|
121
|
+
this.context.onOpenChange?.(newOpen, eventDetails);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
this.dispatchOpenChange(newOpen, eventDetails);
|
|
125
|
+
this.context.onOpenChange?.(newOpen, eventDetails);
|
|
126
|
+
};
|
|
127
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Ported from .base-ui/packages/react/src/floating-ui-react/components/FloatingTree.tsx (v1.6.0),
|
|
2
|
+
// octane-adapted: `React.createContext` → octane `createContext`; components return octane elements
|
|
3
|
+
// via `createElement`; hooks thread an explicit slot (`useId` → `useBaseUiId`, `useRefWithInit` →
|
|
4
|
+
// the binding's). Provides parent/child popup relationships (nested dismiss, nested hover) when
|
|
5
|
+
// popups aren't DOM-nested.
|
|
6
|
+
import { createContext, createElement, useContext, useMemo } from 'octane';
|
|
7
|
+
|
|
8
|
+
import { S, subSlot } from '../../internal';
|
|
9
|
+
import { useBaseUiId } from '../useBaseUiId';
|
|
10
|
+
import { useRefWithInit } from '../useRefWithInit';
|
|
11
|
+
import { useLayoutEffect } from 'octane';
|
|
12
|
+
import { FloatingTreeStore } from './FloatingTreeStore';
|
|
13
|
+
import type { FloatingNodeType, FloatingTreeType } from './types';
|
|
14
|
+
|
|
15
|
+
const FloatingNodeContext = createContext<FloatingNodeType | null>(null);
|
|
16
|
+
const FloatingTreeContext = createContext<FloatingTreeType | null>(null);
|
|
17
|
+
|
|
18
|
+
export const useFloatingParentNodeId = (): string | null =>
|
|
19
|
+
useContext(FloatingNodeContext)?.id || null;
|
|
20
|
+
|
|
21
|
+
export const useFloatingTree = (externalTree?: FloatingTreeStore): FloatingTreeType | null => {
|
|
22
|
+
const contextTree = useContext(FloatingTreeContext) as FloatingTreeType | null;
|
|
23
|
+
return externalTree ?? contextTree;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function useFloatingNodeId(
|
|
27
|
+
externalTree: FloatingTreeStore | undefined,
|
|
28
|
+
slot: symbol | undefined,
|
|
29
|
+
): string | undefined {
|
|
30
|
+
const id = useBaseUiId(undefined, subSlot(slot, 'id'));
|
|
31
|
+
const tree = useFloatingTree(externalTree);
|
|
32
|
+
const parentId = useFloatingParentNodeId();
|
|
33
|
+
|
|
34
|
+
useLayoutEffect(
|
|
35
|
+
() => {
|
|
36
|
+
if (!id) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
const node = { id, parentId };
|
|
40
|
+
tree?.addNode(node);
|
|
41
|
+
return () => {
|
|
42
|
+
tree?.removeNode(node);
|
|
43
|
+
};
|
|
44
|
+
},
|
|
45
|
+
[tree, id, parentId],
|
|
46
|
+
subSlot(slot, 'e'),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
return id;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function FloatingNode(props: { children?: any; id: string | undefined }): any {
|
|
53
|
+
const slot = S('FloatingNode');
|
|
54
|
+
const { children, id } = props;
|
|
55
|
+
const parentId = useFloatingParentNodeId();
|
|
56
|
+
const value = useMemo(() => ({ id, parentId }), [id, parentId], subSlot(slot, 'v'));
|
|
57
|
+
return createElement(FloatingNodeContext.Provider, { value, children });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function FloatingTree(props: { children?: any; externalTree?: FloatingTreeStore }): any {
|
|
61
|
+
const slot = S('FloatingTree');
|
|
62
|
+
const { children, externalTree } = props;
|
|
63
|
+
const tree = useRefWithInit<FloatingTreeStore>(
|
|
64
|
+
() => externalTree ?? new FloatingTreeStore(),
|
|
65
|
+
subSlot(slot, 'tree'),
|
|
66
|
+
).current;
|
|
67
|
+
return createElement(FloatingTreeContext.Provider, { value: tree, children });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export { FloatingTreeStore };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Ported verbatim from .base-ui/packages/react/src/floating-ui-react/components/FloatingTreeStore.ts.
|
|
2
|
+
// Backing store for `FloatingTree` — a flat list of nodes + an event bus for parent/child popup
|
|
3
|
+
// communication (nested dismiss, nested hover). Pure.
|
|
4
|
+
import type { FloatingNodeType, FloatingEvents } from './types';
|
|
5
|
+
import { createEventEmitter } from './createEventEmitter';
|
|
6
|
+
|
|
7
|
+
export class FloatingTreeStore {
|
|
8
|
+
readonly nodesRef: { current: Array<FloatingNodeType> } = { current: [] };
|
|
9
|
+
|
|
10
|
+
readonly events: FloatingEvents = createEventEmitter();
|
|
11
|
+
|
|
12
|
+
addNode(node: FloatingNodeType) {
|
|
13
|
+
this.nodesRef.current.push(node);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
removeNode(node: FloatingNodeType) {
|
|
17
|
+
const index = this.nodesRef.current.findIndex((n) => n === node);
|
|
18
|
+
if (index !== -1) {
|
|
19
|
+
this.nodesRef.current.splice(index, 1);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Ported from .base-ui/packages/react/src/utils/FocusGuard.tsx. A visually-hidden, tabbable `<span>`
|
|
2
|
+
// the focus manager + portal place around the popup to catch tab-out. octane: `createElement`,
|
|
3
|
+
// ref-as-prop; `role="button"` only under VoiceOver+WebKit (inert in jsdom).
|
|
4
|
+
import { createElement, useState, useLayoutEffect } from 'octane';
|
|
5
|
+
|
|
6
|
+
import { S, subSlot } from '../../internal';
|
|
7
|
+
import { platform } from '../platform';
|
|
8
|
+
import { visuallyHidden } from '../visuallyHidden';
|
|
9
|
+
|
|
10
|
+
export function FocusGuard(props: any): any {
|
|
11
|
+
const slot = S('FocusGuard');
|
|
12
|
+
const { ref, ...rest } = props;
|
|
13
|
+
const [role, setRole] = useState<'button' | undefined>(undefined, subSlot(slot, 'role'));
|
|
14
|
+
|
|
15
|
+
useLayoutEffect(
|
|
16
|
+
() => {
|
|
17
|
+
if (platform.screenReader.voiceOver && platform.engine.webkit) {
|
|
18
|
+
setRole('button');
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
[],
|
|
22
|
+
subSlot(slot, 'e'),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
return createElement('span', {
|
|
26
|
+
...rest,
|
|
27
|
+
ref,
|
|
28
|
+
style: visuallyHidden,
|
|
29
|
+
'aria-hidden': role ? undefined : true,
|
|
30
|
+
tabIndex: 0,
|
|
31
|
+
role,
|
|
32
|
+
['data-base-ui-focus-guard']: '',
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Ported from .base-ui/packages/react/src/floating-ui-react/utils/composite.ts — the visibility
|
|
2
|
+
// helpers the tabbable walk needs.
|
|
3
|
+
import { getComputedStyle } from '@floating-ui/utils/dom';
|
|
4
|
+
|
|
5
|
+
export function isHiddenByStyles(styles: CSSStyleDeclaration): boolean {
|
|
6
|
+
return styles.visibility === 'hidden' || styles.visibility === 'collapse';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function isElementVisible(
|
|
10
|
+
element: Element | null,
|
|
11
|
+
styles: CSSStyleDeclaration | null = element ? getComputedStyle(element) : null,
|
|
12
|
+
): boolean {
|
|
13
|
+
if (!element || !element.isConnected || !styles || isHiddenByStyles(styles)) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
if (typeof (element as any).checkVisibility === 'function') {
|
|
17
|
+
return (element as any).checkVisibility();
|
|
18
|
+
}
|
|
19
|
+
return styles.display !== 'none' && styles.display !== 'contents';
|
|
20
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Ported from .base-ui/packages/react/src/floating-ui-react/utils/constants.ts. Marks a popup as
|
|
2
|
+
// programmatically focusable (the focus manager + composite navigation look for it).
|
|
3
|
+
export const FOCUSABLE_ATTRIBUTE = 'data-base-ui-focusable';
|
|
4
|
+
export const CLICK_TRIGGER_IDENTIFIER = 'data-base-ui-click-trigger';
|
|
5
|
+
|
|
6
|
+
export const TYPEABLE_SELECTOR =
|
|
7
|
+
"input:not([type='hidden']):not([disabled])," +
|
|
8
|
+
"[contenteditable]:not([contenteditable='false']),textarea:not([disabled])";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Ported verbatim from .base-ui/packages/react/src/floating-ui-react/utils/createEventEmitter.ts.
|
|
2
|
+
import type { FloatingEvents } from './types';
|
|
3
|
+
|
|
4
|
+
export function createEventEmitter(): FloatingEvents {
|
|
5
|
+
const map = new Map<string, Set<(data: any) => void>>();
|
|
6
|
+
return {
|
|
7
|
+
emit(event: string, data: any) {
|
|
8
|
+
map.get(event)?.forEach((listener) => listener(data));
|
|
9
|
+
},
|
|
10
|
+
on(event: string, listener: (data: any) => void) {
|
|
11
|
+
if (!map.has(event)) {
|
|
12
|
+
map.set(event, new Set());
|
|
13
|
+
}
|
|
14
|
+
map.get(event)!.add(listener);
|
|
15
|
+
},
|
|
16
|
+
off(event: string, listener: (data: any) => void) {
|
|
17
|
+
map.get(event)?.delete(listener);
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Ported from .base-ui/packages/react/src/floating-ui-react/utils/element.ts (v1.6.0) — the subset
|
|
2
|
+
// used so far. `getTarget` is reused from the composite list utils.
|
|
3
|
+
import { isHTMLElement } from '../dom';
|
|
4
|
+
import { TYPEABLE_SELECTOR, FOCUSABLE_ATTRIBUTE } from './constants';
|
|
5
|
+
|
|
6
|
+
export { getTarget } from '../composite/list-utils';
|
|
7
|
+
export { contains } from '../contains';
|
|
8
|
+
|
|
9
|
+
// Ported from .base-ui/…/internals/shadowDom.ts — the deepest active element across shadow roots.
|
|
10
|
+
export function activeElement(doc: Document): Element | null {
|
|
11
|
+
let element = doc.activeElement;
|
|
12
|
+
while (element?.shadowRoot?.activeElement != null) {
|
|
13
|
+
element = element.shadowRoot.activeElement;
|
|
14
|
+
}
|
|
15
|
+
return element;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function isTypeableElement(element: unknown): boolean {
|
|
19
|
+
return isHTMLElement(element) && element.matches(TYPEABLE_SELECTOR);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isTypeableCombobox(element: Element | null): boolean {
|
|
23
|
+
if (!element) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
return element.getAttribute('role') === 'combobox' && isTypeableElement(element);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The floating element may be a positioning wrapper; focus is managed on the child carrying the
|
|
30
|
+
// focusable marker (or the element itself).
|
|
31
|
+
export function getFloatingFocusElement(
|
|
32
|
+
floatingElement: HTMLElement | null | undefined,
|
|
33
|
+
): HTMLElement | null {
|
|
34
|
+
if (!floatingElement) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
return floatingElement.hasAttribute(FOCUSABLE_ATTRIBUTE)
|
|
38
|
+
? floatingElement
|
|
39
|
+
: (floatingElement.querySelector<HTMLElement>(`[${FOCUSABLE_ATTRIBUTE}]`) ?? floatingElement);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function isEventTargetWithin(event: Event, node: Node | null | undefined): boolean {
|
|
43
|
+
if (node == null) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
if ('composedPath' in event) {
|
|
47
|
+
return event.composedPath().includes(node);
|
|
48
|
+
}
|
|
49
|
+
return (event as Event).target != null && node.contains((event as Event).target as Node);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function isRootElement(element: Element): boolean {
|
|
53
|
+
return element.matches('html,body');
|
|
54
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Ported verbatim from .base-ui/packages/react/src/floating-ui-react/utils/enqueueFocus.ts.
|
|
2
|
+
// Focuses an element on the next frame (or synchronously), superseding any pending focus.
|
|
3
|
+
import { NOOP } from '../empty';
|
|
4
|
+
import type { FocusableElement } from './tabbable';
|
|
5
|
+
|
|
6
|
+
interface Options {
|
|
7
|
+
preventScroll?: boolean | undefined;
|
|
8
|
+
sync?: boolean | undefined;
|
|
9
|
+
shouldFocus?: (() => boolean) | undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let rafId = 0;
|
|
13
|
+
export function enqueueFocus(el: FocusableElement | null, options: Options = {}): () => void {
|
|
14
|
+
const { preventScroll = false, sync = false, shouldFocus } = options;
|
|
15
|
+
|
|
16
|
+
cancelAnimationFrame(rafId);
|
|
17
|
+
|
|
18
|
+
function exec() {
|
|
19
|
+
if (shouldFocus && !shouldFocus()) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
el?.focus({ preventScroll });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (sync) {
|
|
26
|
+
exec();
|
|
27
|
+
return NOOP;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const currentRafId = requestAnimationFrame(exec);
|
|
31
|
+
rafId = currentRafId;
|
|
32
|
+
return () => {
|
|
33
|
+
if (rafId === currentRafId) {
|
|
34
|
+
cancelAnimationFrame(currentRafId);
|
|
35
|
+
rafId = 0;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|