@octanejs/floating-ui 0.1.8 → 0.1.10
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 +3 -3
- package/src/Composite.ts +118 -20
- package/src/FloatingArrow.ts +61 -4
- package/src/FloatingFocusManager.ts +127 -26
- package/src/FloatingList.ts +58 -13
- package/src/FloatingOverlay.ts +26 -3
- package/src/FloatingPortal.ts +100 -23
- package/src/context.ts +43 -15
- package/src/delayGroup.ts +66 -8
- package/src/index.ts +108 -0
- package/src/pubsub.ts +3 -1
- package/src/safePolygon.ts +13 -4
- package/src/transitions.ts +76 -10
- package/src/tree.ts +53 -11
- package/src/types.ts +337 -0
- package/src/useClick.ts +63 -13
- package/src/useClientPoint.ts +50 -10
- package/src/useDismiss.ts +86 -12
- package/src/useFloating.ts +46 -14
- package/src/useFocus.ts +33 -7
- package/src/useHover.ts +80 -19
- package/src/useId.ts +1 -1
- package/src/useInteractions.ts +12 -3
- package/src/useListNavigation.ts +162 -8
- package/src/useMergeRefs.ts +6 -1
- package/src/useRole.ts +41 -12
- package/src/useTypeahead.ts +75 -10
- package/src/utils/index.ts +159 -62
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@octanejs/floating-ui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -42,14 +42,14 @@
|
|
|
42
42
|
"tabbable": "^6.5.0"
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
45
|
-
"octane": "0.1.
|
|
45
|
+
"octane": "0.1.11"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@floating-ui/react": "0.27.19",
|
|
49
49
|
"react": "^19.2.0",
|
|
50
50
|
"react-dom": "^19.2.0",
|
|
51
51
|
"vitest": "^4.1.9",
|
|
52
|
-
"octane": "0.1.
|
|
52
|
+
"octane": "0.1.11"
|
|
53
53
|
},
|
|
54
54
|
"scripts": {
|
|
55
55
|
"test": "vitest run"
|
package/src/Composite.ts
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
// `render` prop (a function, an element to clone, or a default <div>); octane has no
|
|
5
5
|
// cloneElement, so it's implemented locally over createElement.
|
|
6
6
|
import { createContext, createElement, useContext, useMemo, useRef, useState } from 'octane';
|
|
7
|
+
import type { OctaneNode } from 'octane';
|
|
8
|
+
import type { OctaneElement } from 'octane/jsx-runtime';
|
|
9
|
+
import type { Dimensions } from '@floating-ui/dom';
|
|
7
10
|
|
|
8
11
|
import { FloatingList, useListItem } from './FloatingList';
|
|
9
12
|
import { S } from './internal';
|
|
@@ -19,7 +22,9 @@ import {
|
|
|
19
22
|
isIndexOutOfListBounds,
|
|
20
23
|
isListIndexDisabled,
|
|
21
24
|
useEffectEvent,
|
|
25
|
+
type DisabledIndices,
|
|
22
26
|
} from './utils';
|
|
27
|
+
import type { HTMLProps, MutableRefObject, RefCallback } from './types';
|
|
23
28
|
|
|
24
29
|
const ARROW_LEFT = 'ArrowLeft';
|
|
25
30
|
const ARROW_RIGHT = 'ArrowRight';
|
|
@@ -29,13 +34,22 @@ const horizontalKeys = [ARROW_LEFT, ARROW_RIGHT];
|
|
|
29
34
|
const verticalKeys = [ARROW_UP, ARROW_DOWN];
|
|
30
35
|
const allKeys = [...horizontalKeys, ...verticalKeys];
|
|
31
36
|
|
|
32
|
-
|
|
37
|
+
// Upstream's (unexported) `RenderProp`, in octane terms: an element descriptor
|
|
38
|
+
// to clone, or a factory receiving the computed HTML props.
|
|
39
|
+
type RenderProp = OctaneElement | ((props: HTMLProps<HTMLElement>) => OctaneNode);
|
|
40
|
+
|
|
41
|
+
type CompositeRef = MutableRefObject<HTMLElement | null> | RefCallback<HTMLElement> | null;
|
|
42
|
+
|
|
43
|
+
function cloneElement(el: OctaneElement, props: Record<string, unknown>): OctaneNode {
|
|
33
44
|
return createElement(el.type, { ...el.props, ...props });
|
|
34
45
|
}
|
|
35
46
|
|
|
36
|
-
function renderJsx(
|
|
47
|
+
function renderJsx(
|
|
48
|
+
render: RenderProp | undefined,
|
|
49
|
+
computedProps: Record<string, unknown>,
|
|
50
|
+
): OctaneNode {
|
|
37
51
|
if (typeof render === 'function') {
|
|
38
|
-
return render(computedProps);
|
|
52
|
+
return render(computedProps as HTMLProps<HTMLElement>);
|
|
39
53
|
}
|
|
40
54
|
if (render) {
|
|
41
55
|
return cloneElement(render, computedProps);
|
|
@@ -43,12 +57,79 @@ function renderJsx(render: any, computedProps: any): any {
|
|
|
43
57
|
return createElement('div', { ...computedProps });
|
|
44
58
|
}
|
|
45
59
|
|
|
46
|
-
|
|
60
|
+
interface CompositeContextValue {
|
|
61
|
+
activeIndex: number;
|
|
62
|
+
onNavigate(index: number): void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const CompositeContext = createContext<CompositeContextValue>({
|
|
47
66
|
activeIndex: 0,
|
|
48
67
|
onNavigate: () => {},
|
|
49
68
|
});
|
|
50
69
|
|
|
51
|
-
export
|
|
70
|
+
export interface CompositeProps {
|
|
71
|
+
/**
|
|
72
|
+
* Determines the element to render.
|
|
73
|
+
* @example
|
|
74
|
+
* ```jsx
|
|
75
|
+
* <Composite render={<ul />} />
|
|
76
|
+
* <Composite render={(htmlProps) => <ul {...htmlProps} />} />
|
|
77
|
+
* ```
|
|
78
|
+
*/
|
|
79
|
+
render?: RenderProp;
|
|
80
|
+
/**
|
|
81
|
+
* Determines the orientation of the composite.
|
|
82
|
+
*/
|
|
83
|
+
orientation?: 'horizontal' | 'vertical' | 'both';
|
|
84
|
+
/**
|
|
85
|
+
* Determines whether focus should loop around when navigating past the first
|
|
86
|
+
* or last item.
|
|
87
|
+
*/
|
|
88
|
+
loop?: boolean;
|
|
89
|
+
/**
|
|
90
|
+
* Whether the direction of the composite’s navigation is in RTL layout.
|
|
91
|
+
*/
|
|
92
|
+
rtl?: boolean;
|
|
93
|
+
/**
|
|
94
|
+
* Determines the number of columns there are in the composite
|
|
95
|
+
* (i.e. it’s a grid).
|
|
96
|
+
*/
|
|
97
|
+
cols?: number;
|
|
98
|
+
/**
|
|
99
|
+
* Determines which items are disabled. The `disabled` or `aria-disabled`
|
|
100
|
+
* attributes are used by default.
|
|
101
|
+
*/
|
|
102
|
+
disabledIndices?: DisabledIndices;
|
|
103
|
+
/**
|
|
104
|
+
* Determines which item is active. Used to externally control the active
|
|
105
|
+
* item.
|
|
106
|
+
*/
|
|
107
|
+
activeIndex?: number;
|
|
108
|
+
/**
|
|
109
|
+
* Called when the user navigates to a new item. Used to externally control
|
|
110
|
+
* the active item.
|
|
111
|
+
*/
|
|
112
|
+
onNavigate?(index: number): void;
|
|
113
|
+
/**
|
|
114
|
+
* Only for `cols > 1`, specify sizes for grid items.
|
|
115
|
+
* `{ width: 2, height: 2 }` means an item is 2 columns wide and 2 rows tall.
|
|
116
|
+
*/
|
|
117
|
+
itemSizes?: Dimensions[];
|
|
118
|
+
/**
|
|
119
|
+
* Only relevant for `cols > 1` and items with different sizes, specify if
|
|
120
|
+
* the grid is dense (as defined in the CSS spec for grid-auto-flow).
|
|
121
|
+
*/
|
|
122
|
+
dense?: boolean;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Creates a single tab stop whose items are navigated by arrow keys, which
|
|
127
|
+
* provides list navigation outside of floating element contexts.
|
|
128
|
+
* @see https://floating-ui.com/docs/Composite
|
|
129
|
+
*/
|
|
130
|
+
export function Composite(
|
|
131
|
+
props: CompositeProps & HTMLProps<HTMLElement> & { ref?: CompositeRef },
|
|
132
|
+
): OctaneNode {
|
|
52
133
|
const render = props.render;
|
|
53
134
|
const orientation = props.orientation ?? 'both';
|
|
54
135
|
const loop = props.loop ?? true;
|
|
@@ -81,16 +162,16 @@ export function Composite(props: any): any {
|
|
|
81
162
|
externalSetActiveIndex != null ? externalSetActiveIndex : internalSetActiveIndex,
|
|
82
163
|
S('Composite:nav'),
|
|
83
164
|
);
|
|
84
|
-
const elementsRef = useRef<
|
|
85
|
-
const renderElementProps = render && typeof render !== 'function' ? render.props : {};
|
|
86
|
-
const contextValue = useMemo(
|
|
165
|
+
const elementsRef = useRef<Array<HTMLElement | null>>([], S('Composite:els'));
|
|
166
|
+
const renderElementProps: any = render && typeof render !== 'function' ? render.props : {};
|
|
167
|
+
const contextValue = useMemo<CompositeContextValue>(
|
|
87
168
|
() => ({ activeIndex, onNavigate }),
|
|
88
169
|
[activeIndex, onNavigate],
|
|
89
170
|
S('Composite:ctx'),
|
|
90
171
|
);
|
|
91
172
|
const isGrid = cols > 1;
|
|
92
173
|
|
|
93
|
-
function handleKeyDown(event:
|
|
174
|
+
function handleKeyDown(event: KeyboardEvent) {
|
|
94
175
|
if (!allKeys.includes(event.key)) return;
|
|
95
176
|
let nextIndex = activeIndex;
|
|
96
177
|
const minIndex = getMinListIndex(elementsRef, disabledIndices);
|
|
@@ -105,7 +186,7 @@ export function Composite(props: any): any {
|
|
|
105
186
|
const minGridIndex = cellMap.findIndex(
|
|
106
187
|
(index) => index != null && !isListIndexDisabled(elementsRef, index, disabledIndices),
|
|
107
188
|
);
|
|
108
|
-
const maxGridIndex = cellMap.reduce(
|
|
189
|
+
const maxGridIndex = cellMap.reduce<number>(
|
|
109
190
|
(foundIndex, index, cellIndex) =>
|
|
110
191
|
index != null && !isListIndexDisabled(elementsRef, index, disabledIndices)
|
|
111
192
|
? cellIndex
|
|
@@ -129,7 +210,7 @@ export function Composite(props: any): any {
|
|
|
129
210
|
disabledIndices: getGridCellIndices(
|
|
130
211
|
[
|
|
131
212
|
...((typeof disabledIndices !== 'function' ? disabledIndices : null) ||
|
|
132
|
-
elementsRef.current.map((_
|
|
213
|
+
elementsRef.current.map((_, index) =>
|
|
133
214
|
isListIndexDisabled(elementsRef, index, disabledIndices) ? index : undefined,
|
|
134
215
|
)),
|
|
135
216
|
undefined,
|
|
@@ -156,19 +237,19 @@ export function Composite(props: any): any {
|
|
|
156
237
|
horizontal: [horizontalEndKey],
|
|
157
238
|
vertical: [ARROW_DOWN],
|
|
158
239
|
both: [horizontalEndKey, ARROW_DOWN],
|
|
159
|
-
}[orientation
|
|
240
|
+
}[orientation];
|
|
160
241
|
const toStartKeys = {
|
|
161
242
|
horizontal: [horizontalStartKey],
|
|
162
243
|
vertical: [ARROW_UP],
|
|
163
244
|
both: [horizontalStartKey, ARROW_UP],
|
|
164
|
-
}[orientation
|
|
245
|
+
}[orientation];
|
|
165
246
|
const preventedKeys = isGrid
|
|
166
247
|
? allKeys
|
|
167
248
|
: {
|
|
168
249
|
horizontal: horizontalKeys,
|
|
169
250
|
vertical: verticalKeys,
|
|
170
251
|
both: allKeys,
|
|
171
|
-
}[orientation
|
|
252
|
+
}[orientation];
|
|
172
253
|
if (nextIndex === activeIndex && [...toEndKeys, ...toStartKeys].includes(event.key)) {
|
|
173
254
|
if (loop && nextIndex === maxIndex && toEndKeys.includes(event.key)) {
|
|
174
255
|
nextIndex = minIndex;
|
|
@@ -197,8 +278,8 @@ export function Composite(props: any): any {
|
|
|
197
278
|
...renderElementProps,
|
|
198
279
|
ref: forwardedRef,
|
|
199
280
|
'aria-orientation': orientation === 'both' ? undefined : orientation,
|
|
200
|
-
onKeyDown(e:
|
|
201
|
-
domProps.onKeyDown?.(e);
|
|
281
|
+
onKeyDown(e: KeyboardEvent) {
|
|
282
|
+
domProps.onKeyDown?.(e as KeyboardEvent & { currentTarget: HTMLElement & EventTarget });
|
|
202
283
|
renderElementProps.onKeyDown?.(e);
|
|
203
284
|
handleKeyDown(e);
|
|
204
285
|
},
|
|
@@ -213,11 +294,28 @@ export function Composite(props: any): any {
|
|
|
213
294
|
});
|
|
214
295
|
}
|
|
215
296
|
|
|
216
|
-
export
|
|
297
|
+
export interface CompositeItemProps {
|
|
298
|
+
/**
|
|
299
|
+
* Determines the element to render.
|
|
300
|
+
* @example
|
|
301
|
+
* ```jsx
|
|
302
|
+
* <CompositeItem render={<li />} />
|
|
303
|
+
* <CompositeItem render={(htmlProps) => <li {...htmlProps} />} />
|
|
304
|
+
* ```
|
|
305
|
+
*/
|
|
306
|
+
render?: RenderProp;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* @see https://floating-ui.com/docs/Composite
|
|
311
|
+
*/
|
|
312
|
+
export function CompositeItem(
|
|
313
|
+
props: CompositeItemProps & HTMLProps<HTMLElement> & { ref?: CompositeRef },
|
|
314
|
+
): OctaneNode {
|
|
217
315
|
const render = props.render;
|
|
218
316
|
const forwardedRef = props.ref;
|
|
219
317
|
const { render: _r, ref: _ref, ...domProps } = props;
|
|
220
|
-
const renderElementProps = render && typeof render !== 'function' ? render.props : {};
|
|
318
|
+
const renderElementProps: any = render && typeof render !== 'function' ? render.props : {};
|
|
221
319
|
const { activeIndex, onNavigate } = useContext(CompositeContext);
|
|
222
320
|
const { ref, index } = useListItem(S('CompositeItem:listItem'));
|
|
223
321
|
const mergedRef = useMergeRefs(
|
|
@@ -231,8 +329,8 @@ export function CompositeItem(props: any): any {
|
|
|
231
329
|
ref: mergedRef,
|
|
232
330
|
tabIndex: isActive ? 0 : -1,
|
|
233
331
|
'data-active': isActive ? '' : undefined,
|
|
234
|
-
onFocus(e:
|
|
235
|
-
domProps.onFocus?.(e);
|
|
332
|
+
onFocus(e: FocusEvent) {
|
|
333
|
+
domProps.onFocus?.(e as FocusEvent & { currentTarget: HTMLElement & EventTarget });
|
|
236
334
|
renderElementProps.onFocus?.(e);
|
|
237
335
|
onNavigate(index);
|
|
238
336
|
},
|
package/src/FloatingArrow.ts
CHANGED
|
@@ -5,12 +5,69 @@
|
|
|
5
5
|
// verbatim, so SVG presentation attributes are written kebab-case here.
|
|
6
6
|
import { getComputedStyle } from '@floating-ui/utils/dom';
|
|
7
7
|
import { createElement, useState } from 'octane';
|
|
8
|
+
import type { OctaneNode } from 'octane';
|
|
8
9
|
|
|
9
10
|
import { S } from './internal';
|
|
10
11
|
import { useId } from './useId';
|
|
11
|
-
import { useModernLayoutEffect } from './utils';
|
|
12
|
+
import { useModernLayoutEffect, type CSSProperties } from './utils';
|
|
13
|
+
import type { FloatingContext, MutableRefObject, RefCallback } from './types';
|
|
12
14
|
|
|
13
|
-
|
|
15
|
+
// Upstream extends `React.ComponentPropsWithRef<'svg'>`. The port declares its
|
|
16
|
+
// OWN props strictly and leaves the remaining SVG attributes open (`unknown`
|
|
17
|
+
// index) so prop bags typed against other libraries' SVG surfaces (e.g.
|
|
18
|
+
// React's synthetic-event `SVGProps`) remain spreadable; the rest is forwarded
|
|
19
|
+
// verbatim to the `<svg>` element.
|
|
20
|
+
export interface FloatingArrowProps {
|
|
21
|
+
/**
|
|
22
|
+
* The floating context.
|
|
23
|
+
*/
|
|
24
|
+
context: Omit<FloatingContext, 'refs'> & { refs: any };
|
|
25
|
+
/**
|
|
26
|
+
* Width of the arrow.
|
|
27
|
+
* @default 14
|
|
28
|
+
*/
|
|
29
|
+
width?: number;
|
|
30
|
+
/**
|
|
31
|
+
* Height of the arrow.
|
|
32
|
+
* @default 7
|
|
33
|
+
*/
|
|
34
|
+
height?: number;
|
|
35
|
+
/**
|
|
36
|
+
* The corner radius (rounding) of the arrow tip.
|
|
37
|
+
* @default 0 (sharp)
|
|
38
|
+
*/
|
|
39
|
+
tipRadius?: number;
|
|
40
|
+
/**
|
|
41
|
+
* Forces a static offset over dynamic positioning under a certain condition.
|
|
42
|
+
* If the shift() middleware causes the popover to shift, this value will be
|
|
43
|
+
* ignored.
|
|
44
|
+
*/
|
|
45
|
+
staticOffset?: string | number | null;
|
|
46
|
+
/**
|
|
47
|
+
* Custom path string.
|
|
48
|
+
*/
|
|
49
|
+
d?: string;
|
|
50
|
+
/**
|
|
51
|
+
* Stroke (border) color of the arrow.
|
|
52
|
+
*/
|
|
53
|
+
stroke?: string;
|
|
54
|
+
/**
|
|
55
|
+
* Stroke (border) width of the arrow.
|
|
56
|
+
*/
|
|
57
|
+
strokeWidth?: number;
|
|
58
|
+
/** Object-form styles merged over the arrow's own positioning styles. */
|
|
59
|
+
style?: CSSProperties;
|
|
60
|
+
/** Ref-as-prop (octane has no forwardRef). */
|
|
61
|
+
ref?: MutableRefObject<SVGSVGElement | null> | RefCallback<SVGSVGElement> | null;
|
|
62
|
+
/** Remaining SVG attributes/handlers, forwarded to the `<svg>` element. */
|
|
63
|
+
[key: string]: unknown;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Renders a pointing arrow triangle.
|
|
68
|
+
* @see https://floating-ui.com/docs/FloatingArrow
|
|
69
|
+
*/
|
|
70
|
+
export function FloatingArrow(props: FloatingArrowProps): OctaneNode {
|
|
14
71
|
const ref = props.ref;
|
|
15
72
|
const context = props.context;
|
|
16
73
|
const placement = context.placement;
|
|
@@ -88,13 +145,13 @@ export function FloatingArrow(props: any): any {
|
|
|
88
145
|
(' L' + (width - svgX) + ',' + (height - svgY)) +
|
|
89
146
|
(' Q' + width / 2 + ',' + height + ' ' + svgX + ',' + (height - svgY)) +
|
|
90
147
|
' Z';
|
|
91
|
-
const rotation
|
|
148
|
+
const rotation = (
|
|
92
149
|
{
|
|
93
150
|
top: isCustomShape ? 'rotate(180deg)' : '',
|
|
94
151
|
left: isCustomShape ? 'rotate(90deg)' : 'rotate(-90deg)',
|
|
95
152
|
bottom: isCustomShape ? '' : 'rotate(180deg)',
|
|
96
153
|
right: isCustomShape ? 'rotate(-90deg)' : 'rotate(90deg)',
|
|
97
|
-
} as
|
|
154
|
+
} as Record<string, string>
|
|
98
155
|
)[side];
|
|
99
156
|
|
|
100
157
|
return createElement(
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { getNodeName, isHTMLElement, isShadowRoot } from '@floating-ui/utils/dom';
|
|
8
8
|
import { focusable, isTabbable, tabbable } from 'tabbable';
|
|
9
9
|
import { createElement, useEffect, useMemo, useRef } from 'octane';
|
|
10
|
+
import type { OctaneNode } from 'octane';
|
|
10
11
|
|
|
11
12
|
import { FocusGuard, usePortalContext } from './FloatingPortal';
|
|
12
13
|
import { S } from './internal';
|
|
@@ -33,9 +34,11 @@ import {
|
|
|
33
34
|
useEffectEvent,
|
|
34
35
|
useLatestRef,
|
|
35
36
|
useModernLayoutEffect,
|
|
37
|
+
type CSSProperties,
|
|
36
38
|
} from './utils';
|
|
39
|
+
import type { FloatingRootContext, HTMLProps, MutableRefObject, RefCallback } from './types';
|
|
37
40
|
|
|
38
|
-
const HIDDEN_STYLES:
|
|
41
|
+
const HIDDEN_STYLES: CSSProperties = {
|
|
39
42
|
border: 0,
|
|
40
43
|
clip: 'rect(0 0 0 0)',
|
|
41
44
|
height: '1px',
|
|
@@ -63,7 +66,7 @@ function getCounterMap(control: any) {
|
|
|
63
66
|
let uncontrolledElementsSet = new WeakSet<any>();
|
|
64
67
|
let markerMap: any = {};
|
|
65
68
|
let lockCount = 0;
|
|
66
|
-
export const supportsInert = () =>
|
|
69
|
+
export const supportsInert = (): boolean =>
|
|
67
70
|
typeof HTMLElement !== 'undefined' && 'inert' in HTMLElement.prototype;
|
|
68
71
|
function unwrapHost(node: any): any {
|
|
69
72
|
if (!node) {
|
|
@@ -180,13 +183,13 @@ function markOthers(avoidElements: any[], ariaHidden = false, inert = false): ()
|
|
|
180
183
|
|
|
181
184
|
// ── previously-focused element tracking (for return focus) ───────────────────
|
|
182
185
|
const LIST_LIMIT = 20;
|
|
183
|
-
let previouslyFocusedElements:
|
|
186
|
+
let previouslyFocusedElements: Array<WeakRef<Element>> = [];
|
|
184
187
|
function clearDisconnectedPreviouslyFocusedElements() {
|
|
185
188
|
previouslyFocusedElements = previouslyFocusedElements.filter(
|
|
186
189
|
(elementRef) => elementRef.deref()?.isConnected,
|
|
187
190
|
);
|
|
188
191
|
}
|
|
189
|
-
function addPreviouslyFocusedElement(element:
|
|
192
|
+
function addPreviouslyFocusedElement(element: Element | null | undefined) {
|
|
190
193
|
clearDisconnectedPreviouslyFocusedElements();
|
|
191
194
|
if (element && getNodeName(element) !== 'body') {
|
|
192
195
|
previouslyFocusedElements.push(new WeakRef(element));
|
|
@@ -195,18 +198,21 @@ function addPreviouslyFocusedElement(element: any) {
|
|
|
195
198
|
}
|
|
196
199
|
}
|
|
197
200
|
}
|
|
198
|
-
function getPreviouslyFocusedElement() {
|
|
201
|
+
function getPreviouslyFocusedElement(): Element | undefined {
|
|
199
202
|
clearDisconnectedPreviouslyFocusedElements();
|
|
200
203
|
return previouslyFocusedElements[previouslyFocusedElements.length - 1]?.deref();
|
|
201
204
|
}
|
|
202
|
-
function getFirstTabbableElement(container:
|
|
205
|
+
function getFirstTabbableElement(container: Element) {
|
|
203
206
|
const tabbableOptions = getTabbableOptions();
|
|
204
207
|
if (isTabbable(container, tabbableOptions)) {
|
|
205
208
|
return container;
|
|
206
209
|
}
|
|
207
210
|
return tabbable(container, tabbableOptions)[0] || container;
|
|
208
211
|
}
|
|
209
|
-
function handleTabIndex(
|
|
212
|
+
function handleTabIndex(
|
|
213
|
+
floatingFocusElement: HTMLElement,
|
|
214
|
+
orderRef: MutableRefObject<Array<'reference' | 'floating' | 'content'>>,
|
|
215
|
+
) {
|
|
210
216
|
if (
|
|
211
217
|
!orderRef.current.includes('floating') &&
|
|
212
218
|
!floatingFocusElement.getAttribute('role')?.includes('dialog')
|
|
@@ -215,7 +221,7 @@ function handleTabIndex(floatingFocusElement: any, orderRef: any) {
|
|
|
215
221
|
}
|
|
216
222
|
const options = getTabbableOptions();
|
|
217
223
|
const focusableElements = focusable(floatingFocusElement, options);
|
|
218
|
-
const tabbableContent = focusableElements.filter((element
|
|
224
|
+
const tabbableContent = focusableElements.filter((element) => {
|
|
219
225
|
const dataTabIndex = element.getAttribute('data-tabindex') || '';
|
|
220
226
|
return (
|
|
221
227
|
isTabbable(element, options) ||
|
|
@@ -237,10 +243,13 @@ function handleTabIndex(floatingFocusElement: any, orderRef: any) {
|
|
|
237
243
|
}
|
|
238
244
|
}
|
|
239
245
|
|
|
240
|
-
function useLiteMergeRefs(
|
|
246
|
+
function useLiteMergeRefs<T>(
|
|
247
|
+
refs: Array<MutableRefObject<T | null> | undefined>,
|
|
248
|
+
slot: symbol,
|
|
249
|
+
): (value: T | null) => void {
|
|
241
250
|
return useMemo(
|
|
242
251
|
() => {
|
|
243
|
-
return (value:
|
|
252
|
+
return (value: T | null) => {
|
|
244
253
|
refs.forEach((ref) => {
|
|
245
254
|
if (ref) {
|
|
246
255
|
ref.current = value;
|
|
@@ -253,7 +262,11 @@ function useLiteMergeRefs(refs: any[], slot: symbol): any {
|
|
|
253
262
|
);
|
|
254
263
|
}
|
|
255
264
|
|
|
256
|
-
export function VisuallyHiddenDismiss(
|
|
265
|
+
export function VisuallyHiddenDismiss(
|
|
266
|
+
props: HTMLProps<HTMLButtonElement> & {
|
|
267
|
+
ref?: MutableRefObject<HTMLButtonElement | null> | RefCallback<HTMLButtonElement> | null;
|
|
268
|
+
},
|
|
269
|
+
): OctaneNode {
|
|
257
270
|
return createElement('button', {
|
|
258
271
|
...props,
|
|
259
272
|
type: 'button',
|
|
@@ -263,7 +276,95 @@ export function VisuallyHiddenDismiss(props: any): any {
|
|
|
263
276
|
});
|
|
264
277
|
}
|
|
265
278
|
|
|
266
|
-
export
|
|
279
|
+
export interface FloatingFocusManagerProps {
|
|
280
|
+
children: OctaneNode;
|
|
281
|
+
/**
|
|
282
|
+
* The floating context returned from `useFloatingRootContext` (a full
|
|
283
|
+
* `FloatingContext` from `useFloating` is structurally compatible).
|
|
284
|
+
*/
|
|
285
|
+
context: FloatingRootContext;
|
|
286
|
+
/**
|
|
287
|
+
* Whether or not the focus manager should be disabled. Useful to delay focus
|
|
288
|
+
* management until after a transition completes or some other conditional
|
|
289
|
+
* state.
|
|
290
|
+
* @default false
|
|
291
|
+
*/
|
|
292
|
+
disabled?: boolean;
|
|
293
|
+
/**
|
|
294
|
+
* The order in which focus cycles.
|
|
295
|
+
* @default ['content']
|
|
296
|
+
*/
|
|
297
|
+
order?: Array<'reference' | 'floating' | 'content'>;
|
|
298
|
+
/**
|
|
299
|
+
* Which element to initially focus. Can be either a number (tabbable index as
|
|
300
|
+
* specified by the `order`) or a ref.
|
|
301
|
+
* @default 0
|
|
302
|
+
*/
|
|
303
|
+
initialFocus?: number | MutableRefObject<HTMLElement | null>;
|
|
304
|
+
/**
|
|
305
|
+
* Determines if the focus guards are rendered. If not, focus can escape into
|
|
306
|
+
* the address bar/console/browser UI, like in native dialogs.
|
|
307
|
+
* @default true
|
|
308
|
+
*/
|
|
309
|
+
guards?: boolean;
|
|
310
|
+
/**
|
|
311
|
+
* Determines if focus should be returned to the reference element once the
|
|
312
|
+
* floating element closes/unmounts (or if that is not available, the
|
|
313
|
+
* previously focused element). This prop is ignored if the floating element
|
|
314
|
+
* lost focus.
|
|
315
|
+
* It can be also set to a ref to explicitly control the element to return focus to.
|
|
316
|
+
* @default true
|
|
317
|
+
*/
|
|
318
|
+
returnFocus?: boolean | MutableRefObject<HTMLElement | null>;
|
|
319
|
+
/**
|
|
320
|
+
* Determines if focus should be restored to the nearest tabbable element if
|
|
321
|
+
* focus inside the floating element is lost (such as due to the removal of
|
|
322
|
+
* the currently focused element from the DOM).
|
|
323
|
+
* @default false
|
|
324
|
+
*/
|
|
325
|
+
restoreFocus?: boolean;
|
|
326
|
+
/**
|
|
327
|
+
* Determines if focus is “modal”, meaning focus is fully trapped inside the
|
|
328
|
+
* floating element and outside content cannot be accessed. This includes
|
|
329
|
+
* screen reader virtual cursors.
|
|
330
|
+
* @default true
|
|
331
|
+
*/
|
|
332
|
+
modal?: boolean;
|
|
333
|
+
/**
|
|
334
|
+
* If your focus management is modal and there is no explicit close button
|
|
335
|
+
* available, you can use this prop to render a visually-hidden dismiss
|
|
336
|
+
* button at the start and end of the floating element. This allows
|
|
337
|
+
* touch-based screen readers to escape the floating element due to lack of
|
|
338
|
+
* an `esc` key.
|
|
339
|
+
* @default undefined
|
|
340
|
+
*/
|
|
341
|
+
visuallyHiddenDismiss?: boolean | string;
|
|
342
|
+
/**
|
|
343
|
+
* Determines whether `focusout` event listeners that control whether the
|
|
344
|
+
* floating element should be closed if the focus moves outside of it are
|
|
345
|
+
* attached to the reference and floating elements. This affects non-modal
|
|
346
|
+
* focus management.
|
|
347
|
+
* @default true
|
|
348
|
+
*/
|
|
349
|
+
closeOnFocusOut?: boolean;
|
|
350
|
+
/**
|
|
351
|
+
* Determines whether outside elements are `inert` when `modal` is enabled.
|
|
352
|
+
* This enables pointer modality without a backdrop.
|
|
353
|
+
* @default false
|
|
354
|
+
*/
|
|
355
|
+
outsideElementsInert?: boolean;
|
|
356
|
+
/**
|
|
357
|
+
* Returns a list of elements that should be considered part of the
|
|
358
|
+
* floating element.
|
|
359
|
+
*/
|
|
360
|
+
getInsideElements?: () => Element[];
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Provides focus management for the floating element.
|
|
365
|
+
* @see https://floating-ui.com/docs/FloatingFocusManager
|
|
366
|
+
*/
|
|
367
|
+
export function FloatingFocusManager(props: FloatingFocusManagerProps): OctaneNode {
|
|
267
368
|
const context = props.context;
|
|
268
369
|
const children = props.children;
|
|
269
370
|
const disabled = props.disabled ?? false;
|
|
@@ -301,8 +402,8 @@ export function FloatingFocusManager(props: any): any {
|
|
|
301
402
|
const returnFocusRef = useLatestRef(returnFocus, S('FFM:return'));
|
|
302
403
|
const tree = useFloatingTree();
|
|
303
404
|
const portalContext = usePortalContext();
|
|
304
|
-
const startDismissButtonRef = useRef<
|
|
305
|
-
const endDismissButtonRef = useRef<
|
|
405
|
+
const startDismissButtonRef = useRef<HTMLButtonElement | null>(null, S('FFM:startDismiss'));
|
|
406
|
+
const endDismissButtonRef = useRef<HTMLButtonElement | null>(null, S('FFM:endDismiss'));
|
|
306
407
|
const preventReturnFocusRef = useRef(false, S('FFM:preventReturn'));
|
|
307
408
|
const isPointerDownRef = useRef(false, S('FFM:pointerDown'));
|
|
308
409
|
const tabbableIndexRef = useRef(-1, S('FFM:tabbableIndex'));
|
|
@@ -313,10 +414,10 @@ export function FloatingFocusManager(props: any): any {
|
|
|
313
414
|
const getTabbableContent = useEffectEvent((container = floatingFocusElement) => {
|
|
314
415
|
return container ? tabbable(container, getTabbableOptions()) : [];
|
|
315
416
|
}, S('FFM:getContent'));
|
|
316
|
-
const getTabbableElements = useEffectEvent((container?:
|
|
417
|
+
const getTabbableElements = useEffectEvent((container?: HTMLElement) => {
|
|
317
418
|
const content = getTabbableContent(container);
|
|
318
419
|
return orderRef.current
|
|
319
|
-
.map((type
|
|
420
|
+
.map((type) => {
|
|
320
421
|
if (domReference && type === 'reference') {
|
|
321
422
|
return domReference;
|
|
322
423
|
}
|
|
@@ -333,7 +434,7 @@ export function FloatingFocusManager(props: any): any {
|
|
|
333
434
|
() => {
|
|
334
435
|
if (disabled) return;
|
|
335
436
|
if (!modal) return;
|
|
336
|
-
function onKeyDown(event:
|
|
437
|
+
function onKeyDown(event: KeyboardEvent) {
|
|
337
438
|
if (event.key === 'Tab') {
|
|
338
439
|
if (
|
|
339
440
|
contains(
|
|
@@ -388,7 +489,7 @@ export function FloatingFocusManager(props: any): any {
|
|
|
388
489
|
() => {
|
|
389
490
|
if (disabled) return;
|
|
390
491
|
if (!floating) return;
|
|
391
|
-
function handleFocusIn(event:
|
|
492
|
+
function handleFocusIn(event: FocusEvent) {
|
|
392
493
|
const target = getTarget(event);
|
|
393
494
|
const tabbableContent = getTabbableContent();
|
|
394
495
|
const tabbableIndex = tabbableContent.indexOf(target as any);
|
|
@@ -415,8 +516,8 @@ export function FloatingFocusManager(props: any): any {
|
|
|
415
516
|
isPointerDownRef.current = false;
|
|
416
517
|
});
|
|
417
518
|
}
|
|
418
|
-
function handleFocusOutside(event:
|
|
419
|
-
const relatedTarget = event.relatedTarget;
|
|
519
|
+
function handleFocusOutside(event: FocusEvent) {
|
|
520
|
+
const relatedTarget = event.relatedTarget as Element | null;
|
|
420
521
|
const currentTarget = event.currentTarget;
|
|
421
522
|
const target = getTarget(event);
|
|
422
523
|
queueMicrotask(() => {
|
|
@@ -526,8 +627,8 @@ export function FloatingFocusManager(props: any): any {
|
|
|
526
627
|
S('FFM:e:focusout'),
|
|
527
628
|
);
|
|
528
629
|
|
|
529
|
-
const beforeGuardRef = useRef<
|
|
530
|
-
const afterGuardRef = useRef<
|
|
630
|
+
const beforeGuardRef = useRef<HTMLSpanElement | null>(null, S('FFM:beforeGuard'));
|
|
631
|
+
const afterGuardRef = useRef<HTMLSpanElement | null>(null, S('FFM:afterGuard'));
|
|
531
632
|
const mergedBeforeGuardRef = useLiteMergeRefs(
|
|
532
633
|
[beforeGuardRef, portalContext?.beforeInsideRef],
|
|
533
634
|
S('FFM:mergeBefore'),
|
|
@@ -743,13 +844,13 @@ export function FloatingFocusManager(props: any): any {
|
|
|
743
844
|
S('FFM:e:tabIndex'),
|
|
744
845
|
);
|
|
745
846
|
|
|
746
|
-
function renderDismissButton(location:
|
|
847
|
+
function renderDismissButton(location: 'start' | 'end') {
|
|
747
848
|
if (disabled || !visuallyHiddenDismiss || !modal) {
|
|
748
849
|
return null;
|
|
749
850
|
}
|
|
750
851
|
return createElement(VisuallyHiddenDismiss, {
|
|
751
852
|
ref: location === 'start' ? startDismissButtonRef : endDismissButtonRef,
|
|
752
|
-
onClick: (event:
|
|
853
|
+
onClick: (event: MouseEvent) => onOpenChange(false, event),
|
|
753
854
|
children: typeof visuallyHiddenDismiss === 'string' ? visuallyHiddenDismiss : 'Dismiss',
|
|
754
855
|
});
|
|
755
856
|
}
|
|
@@ -765,7 +866,7 @@ export function FloatingFocusManager(props: any): any {
|
|
|
765
866
|
createElement(FocusGuard, {
|
|
766
867
|
'data-type': 'inside',
|
|
767
868
|
ref: mergedBeforeGuardRef,
|
|
768
|
-
onFocus: (event:
|
|
869
|
+
onFocus: (event: FocusEvent) => {
|
|
769
870
|
if (modal) {
|
|
770
871
|
const els = getTabbableElements();
|
|
771
872
|
enqueueFocus(order[0] === 'reference' ? els[0] : els[els.length - 1]);
|
|
@@ -790,7 +891,7 @@ export function FloatingFocusManager(props: any): any {
|
|
|
790
891
|
createElement(FocusGuard, {
|
|
791
892
|
'data-type': 'inside',
|
|
792
893
|
ref: mergedAfterGuardRef,
|
|
793
|
-
onFocus: (event:
|
|
894
|
+
onFocus: (event: FocusEvent) => {
|
|
794
895
|
if (modal) {
|
|
795
896
|
enqueueFocus(getTabbableElements()[0]);
|
|
796
897
|
} else if (
|