@atlaskit/top-layer 0.1.0
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/CHANGELOG.md +44 -0
- package/README.md +151 -0
- package/animations/package.json +17 -0
- package/arrow/package.json +17 -0
- package/create-close-event/package.json +17 -0
- package/dialog/package.json +17 -0
- package/dialog-scroll-lock/package.json +17 -0
- package/dist/cjs/animations/presets.js +199 -0
- package/dist/cjs/animations/types.js +5 -0
- package/dist/cjs/arrow/index.js +130 -0
- package/dist/cjs/arrow/types.js +5 -0
- package/dist/cjs/dialog/create-close-event.js +39 -0
- package/dist/cjs/dialog/dialog-content.compiled.css +14 -0
- package/dist/cjs/dialog/dialog-content.js +144 -0
- package/dist/cjs/dialog/index.js +19 -0
- package/dist/cjs/dialog/types.js +5 -0
- package/dist/cjs/dialog-scroll-lock/index.js +46 -0
- package/dist/cjs/entry-points/animations.js +36 -0
- package/dist/cjs/entry-points/arrow.js +12 -0
- package/dist/cjs/entry-points/create-close-event.js +19 -0
- package/dist/cjs/entry-points/dialog-scroll-lock.js +12 -0
- package/dist/cjs/entry-points/dialog.js +12 -0
- package/dist/cjs/entry-points/focus.js +24 -0
- package/dist/cjs/entry-points/placement-map.js +18 -0
- package/dist/cjs/entry-points/popover.js +12 -0
- package/dist/cjs/entry-points/popup-surface.js +12 -0
- package/dist/cjs/entry-points/popup.js +12 -0
- package/dist/cjs/entry-points/use-anchor-position.js +12 -0
- package/dist/cjs/entry-points/use-arrow-navigation.js +18 -0
- package/dist/cjs/entry-points/use-simple-light-dismiss.js +12 -0
- package/dist/cjs/focus/focus.js +115 -0
- package/dist/cjs/focus/index.js +24 -0
- package/dist/cjs/index.js +8 -0
- package/dist/cjs/internal/anchor-positioning-fallback.js +154 -0
- package/dist/cjs/internal/combine.js +29 -0
- package/dist/cjs/internal/ensure-preset-styles.js +34 -0
- package/dist/cjs/internal/reduced-motion.js +27 -0
- package/dist/cjs/internal/resolve-placement.js +24 -0
- package/dist/cjs/internal/role-types.js +66 -0
- package/dist/cjs/internal/set-style.js +42 -0
- package/dist/cjs/internal/slot.js +65 -0
- package/dist/cjs/internal/use-anchor-positioning.js +336 -0
- package/dist/cjs/internal/use-animated-visibility.js +169 -0
- package/dist/cjs/internal/use-animation-preset.js +24 -0
- package/dist/cjs/internal/use-focus-wrap.js +94 -0
- package/dist/cjs/internal/use-initial-focus.js +112 -0
- package/dist/cjs/internal/use-preset-styles.js +27 -0
- package/dist/cjs/placement-map/index.js +147 -0
- package/dist/cjs/popover/create-close-event.js +44 -0
- package/dist/cjs/popover/index.js +19 -0
- package/dist/cjs/popover/popover.compiled.css +13 -0
- package/dist/cjs/popover/popover.js +251 -0
- package/dist/cjs/popover/types.js +5 -0
- package/dist/cjs/popup/index.js +37 -0
- package/dist/cjs/popup/popup-content.js +201 -0
- package/dist/cjs/popup/popup-context.js +42 -0
- package/dist/cjs/popup/popup-surface.compiled.css +5 -0
- package/dist/cjs/popup/popup-surface.js +30 -0
- package/dist/cjs/popup/popup-trigger-function.js +74 -0
- package/dist/cjs/popup/popup-trigger.js +82 -0
- package/dist/cjs/popup/popup.js +70 -0
- package/dist/cjs/popup/types.js +5 -0
- package/dist/cjs/use-arrow-navigation/index.js +18 -0
- package/dist/cjs/use-arrow-navigation/use-arrow-navigation.js +245 -0
- package/dist/cjs/use-simple-light-dismiss/index.js +84 -0
- package/dist/es2019/animations/presets.js +375 -0
- package/dist/es2019/animations/types.js +1 -0
- package/dist/es2019/arrow/index.js +249 -0
- package/dist/es2019/arrow/types.js +1 -0
- package/dist/es2019/dialog/create-close-event.js +34 -0
- package/dist/es2019/dialog/dialog-content.compiled.css +14 -0
- package/dist/es2019/dialog/dialog-content.js +138 -0
- package/dist/es2019/dialog/index.js +2 -0
- package/dist/es2019/dialog/types.js +1 -0
- package/dist/es2019/dialog-scroll-lock/index.js +41 -0
- package/dist/es2019/entry-points/animations.js +1 -0
- package/dist/es2019/entry-points/arrow.js +1 -0
- package/dist/es2019/entry-points/create-close-event.js +2 -0
- package/dist/es2019/entry-points/dialog-scroll-lock.js +1 -0
- package/dist/es2019/entry-points/dialog.js +1 -0
- package/dist/es2019/entry-points/focus.js +1 -0
- package/dist/es2019/entry-points/placement-map.js +1 -0
- package/dist/es2019/entry-points/popover.js +1 -0
- package/dist/es2019/entry-points/popup-surface.js +1 -0
- package/dist/es2019/entry-points/popup.js +1 -0
- package/dist/es2019/entry-points/use-anchor-position.js +1 -0
- package/dist/es2019/entry-points/use-arrow-navigation.js +1 -0
- package/dist/es2019/entry-points/use-simple-light-dismiss.js +1 -0
- package/dist/es2019/focus/focus.js +107 -0
- package/dist/es2019/focus/index.js +1 -0
- package/dist/es2019/index.js +7 -0
- package/dist/es2019/internal/anchor-positioning-fallback.js +153 -0
- package/dist/es2019/internal/combine.js +18 -0
- package/dist/es2019/internal/ensure-preset-styles.js +29 -0
- package/dist/es2019/internal/reduced-motion.js +21 -0
- package/dist/es2019/internal/resolve-placement.js +19 -0
- package/dist/es2019/internal/role-types.js +61 -0
- package/dist/es2019/internal/set-style.js +39 -0
- package/dist/es2019/internal/slot.js +57 -0
- package/dist/es2019/internal/use-anchor-positioning.js +330 -0
- package/dist/es2019/internal/use-animated-visibility.js +159 -0
- package/dist/es2019/internal/use-animation-preset.js +18 -0
- package/dist/es2019/internal/use-focus-wrap.js +90 -0
- package/dist/es2019/internal/use-initial-focus.js +109 -0
- package/dist/es2019/internal/use-preset-styles.js +22 -0
- package/dist/es2019/placement-map/index.js +142 -0
- package/dist/es2019/popover/create-close-event.js +39 -0
- package/dist/es2019/popover/index.js +2 -0
- package/dist/es2019/popover/popover.compiled.css +13 -0
- package/dist/es2019/popover/popover.js +240 -0
- package/dist/es2019/popover/types.js +1 -0
- package/dist/es2019/popup/index.js +33 -0
- package/dist/es2019/popup/popup-content.js +192 -0
- package/dist/es2019/popup/popup-context.js +34 -0
- package/dist/es2019/popup/popup-surface.compiled.css +5 -0
- package/dist/es2019/popup/popup-surface.js +24 -0
- package/dist/es2019/popup/popup-trigger-function.js +69 -0
- package/dist/es2019/popup/popup-trigger.js +76 -0
- package/dist/es2019/popup/popup.js +56 -0
- package/dist/es2019/popup/types.js +1 -0
- package/dist/es2019/use-arrow-navigation/index.js +1 -0
- package/dist/es2019/use-arrow-navigation/use-arrow-navigation.js +238 -0
- package/dist/es2019/use-simple-light-dismiss/index.js +79 -0
- package/dist/esm/animations/presets.js +190 -0
- package/dist/esm/animations/types.js +1 -0
- package/dist/esm/arrow/index.js +123 -0
- package/dist/esm/arrow/types.js +1 -0
- package/dist/esm/dialog/create-close-event.js +33 -0
- package/dist/esm/dialog/dialog-content.compiled.css +14 -0
- package/dist/esm/dialog/dialog-content.js +135 -0
- package/dist/esm/dialog/index.js +2 -0
- package/dist/esm/dialog/types.js +1 -0
- package/dist/esm/dialog-scroll-lock/index.js +41 -0
- package/dist/esm/entry-points/animations.js +1 -0
- package/dist/esm/entry-points/arrow.js +1 -0
- package/dist/esm/entry-points/create-close-event.js +2 -0
- package/dist/esm/entry-points/dialog-scroll-lock.js +1 -0
- package/dist/esm/entry-points/dialog.js +1 -0
- package/dist/esm/entry-points/focus.js +1 -0
- package/dist/esm/entry-points/placement-map.js +1 -0
- package/dist/esm/entry-points/popover.js +1 -0
- package/dist/esm/entry-points/popup-surface.js +1 -0
- package/dist/esm/entry-points/popup.js +1 -0
- package/dist/esm/entry-points/use-anchor-position.js +1 -0
- package/dist/esm/entry-points/use-arrow-navigation.js +1 -0
- package/dist/esm/entry-points/use-simple-light-dismiss.js +1 -0
- package/dist/esm/focus/focus.js +107 -0
- package/dist/esm/focus/index.js +1 -0
- package/dist/esm/index.js +7 -0
- package/dist/esm/internal/anchor-positioning-fallback.js +147 -0
- package/dist/esm/internal/combine.js +23 -0
- package/dist/esm/internal/ensure-preset-styles.js +28 -0
- package/dist/esm/internal/reduced-motion.js +21 -0
- package/dist/esm/internal/resolve-placement.js +18 -0
- package/dist/esm/internal/role-types.js +60 -0
- package/dist/esm/internal/set-style.js +36 -0
- package/dist/esm/internal/slot.js +59 -0
- package/dist/esm/internal/use-anchor-positioning.js +323 -0
- package/dist/esm/internal/use-animated-visibility.js +162 -0
- package/dist/esm/internal/use-animation-preset.js +18 -0
- package/dist/esm/internal/use-focus-wrap.js +89 -0
- package/dist/esm/internal/use-initial-focus.js +107 -0
- package/dist/esm/internal/use-preset-styles.js +21 -0
- package/dist/esm/placement-map/index.js +141 -0
- package/dist/esm/popover/create-close-event.js +38 -0
- package/dist/esm/popover/index.js +2 -0
- package/dist/esm/popover/popover.compiled.css +13 -0
- package/dist/esm/popover/popover.js +242 -0
- package/dist/esm/popover/types.js +1 -0
- package/dist/esm/popup/index.js +33 -0
- package/dist/esm/popup/popup-content.js +192 -0
- package/dist/esm/popup/popup-context.js +34 -0
- package/dist/esm/popup/popup-surface.compiled.css +5 -0
- package/dist/esm/popup/popup-surface.js +23 -0
- package/dist/esm/popup/popup-trigger-function.js +69 -0
- package/dist/esm/popup/popup-trigger.js +74 -0
- package/dist/esm/popup/popup.js +61 -0
- package/dist/esm/popup/types.js +1 -0
- package/dist/esm/use-arrow-navigation/index.js +1 -0
- package/dist/esm/use-arrow-navigation/use-arrow-navigation.js +238 -0
- package/dist/esm/use-simple-light-dismiss/index.js +78 -0
- package/dist/types/animations/presets.d.ts +94 -0
- package/dist/types/animations/types.d.ts +42 -0
- package/dist/types/arrow/index.d.ts +24 -0
- package/dist/types/arrow/types.d.ts +29 -0
- package/dist/types/dialog/create-close-event.d.ts +24 -0
- package/dist/types/dialog/dialog-content.d.ts +20 -0
- package/dist/types/dialog/index.d.ts +3 -0
- package/dist/types/dialog/types.d.ts +92 -0
- package/dist/types/dialog-scroll-lock/index.d.ts +24 -0
- package/dist/types/entry-points/animations.d.ts +2 -0
- package/dist/types/entry-points/arrow.d.ts +2 -0
- package/dist/types/entry-points/create-close-event.d.ts +4 -0
- package/dist/types/entry-points/dialog-scroll-lock.d.ts +1 -0
- package/dist/types/entry-points/dialog.d.ts +2 -0
- package/dist/types/entry-points/focus.d.ts +2 -0
- package/dist/types/entry-points/placement-map.d.ts +2 -0
- package/dist/types/entry-points/popover.d.ts +2 -0
- package/dist/types/entry-points/popup-surface.d.ts +2 -0
- package/dist/types/entry-points/popup.d.ts +2 -0
- package/dist/types/entry-points/use-anchor-position.d.ts +1 -0
- package/dist/types/entry-points/use-arrow-navigation.d.ts +2 -0
- package/dist/types/entry-points/use-simple-light-dismiss.d.ts +2 -0
- package/dist/types/focus/focus.d.ts +42 -0
- package/dist/types/focus/index.d.ts +2 -0
- package/dist/types/index.d.ts +0 -0
- package/dist/types/internal/anchor-positioning-fallback.d.ts +24 -0
- package/dist/types/internal/combine.d.ts +14 -0
- package/dist/types/internal/ensure-preset-styles.d.ts +6 -0
- package/dist/types/internal/reduced-motion.d.ts +14 -0
- package/dist/types/internal/resolve-placement.d.ts +16 -0
- package/dist/types/internal/role-types.d.ts +62 -0
- package/dist/types/internal/set-style.d.ts +28 -0
- package/dist/types/internal/slot.d.ts +29 -0
- package/dist/types/internal/use-anchor-positioning.d.ts +95 -0
- package/dist/types/internal/use-animated-visibility.d.ts +74 -0
- package/dist/types/internal/use-animation-preset.d.ts +11 -0
- package/dist/types/internal/use-focus-wrap.d.ts +20 -0
- package/dist/types/internal/use-initial-focus.d.ts +21 -0
- package/dist/types/internal/use-preset-styles.d.ts +15 -0
- package/dist/types/placement-map/index.d.ts +55 -0
- package/dist/types/popover/create-close-event.d.ts +26 -0
- package/dist/types/popover/index.d.ts +3 -0
- package/dist/types/popover/popover.d.ts +22 -0
- package/dist/types/popover/types.d.ts +153 -0
- package/dist/types/popup/index.d.ts +34 -0
- package/dist/types/popup/popup-content.d.ts +14 -0
- package/dist/types/popup/popup-context.d.ts +96 -0
- package/dist/types/popup/popup-surface.d.ts +16 -0
- package/dist/types/popup/popup-trigger-function.d.ts +108 -0
- package/dist/types/popup/popup-trigger.d.ts +23 -0
- package/dist/types/popup/popup.d.ts +14 -0
- package/dist/types/popup/types.d.ts +289 -0
- package/dist/types/use-arrow-navigation/index.d.ts +2 -0
- package/dist/types/use-arrow-navigation/use-arrow-navigation.d.ts +136 -0
- package/dist/types/use-simple-light-dismiss/index.d.ts +45 -0
- package/dist/types-ts4.5/animations/presets.d.ts +94 -0
- package/dist/types-ts4.5/animations/types.d.ts +42 -0
- package/dist/types-ts4.5/arrow/index.d.ts +24 -0
- package/dist/types-ts4.5/arrow/types.d.ts +29 -0
- package/dist/types-ts4.5/dialog/create-close-event.d.ts +24 -0
- package/dist/types-ts4.5/dialog/dialog-content.d.ts +20 -0
- package/dist/types-ts4.5/dialog/index.d.ts +3 -0
- package/dist/types-ts4.5/dialog/types.d.ts +92 -0
- package/dist/types-ts4.5/dialog-scroll-lock/index.d.ts +24 -0
- package/dist/types-ts4.5/entry-points/animations.d.ts +2 -0
- package/dist/types-ts4.5/entry-points/arrow.d.ts +2 -0
- package/dist/types-ts4.5/entry-points/create-close-event.d.ts +4 -0
- package/dist/types-ts4.5/entry-points/dialog-scroll-lock.d.ts +1 -0
- package/dist/types-ts4.5/entry-points/dialog.d.ts +2 -0
- package/dist/types-ts4.5/entry-points/focus.d.ts +2 -0
- package/dist/types-ts4.5/entry-points/placement-map.d.ts +2 -0
- package/dist/types-ts4.5/entry-points/popover.d.ts +2 -0
- package/dist/types-ts4.5/entry-points/popup-surface.d.ts +2 -0
- package/dist/types-ts4.5/entry-points/popup.d.ts +2 -0
- package/dist/types-ts4.5/entry-points/use-anchor-position.d.ts +1 -0
- package/dist/types-ts4.5/entry-points/use-arrow-navigation.d.ts +2 -0
- package/dist/types-ts4.5/entry-points/use-simple-light-dismiss.d.ts +2 -0
- package/dist/types-ts4.5/focus/focus.d.ts +42 -0
- package/dist/types-ts4.5/focus/index.d.ts +2 -0
- package/dist/types-ts4.5/index.d.ts +0 -0
- package/dist/types-ts4.5/internal/anchor-positioning-fallback.d.ts +24 -0
- package/dist/types-ts4.5/internal/combine.d.ts +14 -0
- package/dist/types-ts4.5/internal/ensure-preset-styles.d.ts +6 -0
- package/dist/types-ts4.5/internal/reduced-motion.d.ts +14 -0
- package/dist/types-ts4.5/internal/resolve-placement.d.ts +16 -0
- package/dist/types-ts4.5/internal/role-types.d.ts +62 -0
- package/dist/types-ts4.5/internal/set-style.d.ts +28 -0
- package/dist/types-ts4.5/internal/slot.d.ts +29 -0
- package/dist/types-ts4.5/internal/use-anchor-positioning.d.ts +95 -0
- package/dist/types-ts4.5/internal/use-animated-visibility.d.ts +74 -0
- package/dist/types-ts4.5/internal/use-animation-preset.d.ts +11 -0
- package/dist/types-ts4.5/internal/use-focus-wrap.d.ts +20 -0
- package/dist/types-ts4.5/internal/use-initial-focus.d.ts +21 -0
- package/dist/types-ts4.5/internal/use-preset-styles.d.ts +15 -0
- package/dist/types-ts4.5/placement-map/index.d.ts +55 -0
- package/dist/types-ts4.5/popover/create-close-event.d.ts +26 -0
- package/dist/types-ts4.5/popover/index.d.ts +3 -0
- package/dist/types-ts4.5/popover/popover.d.ts +22 -0
- package/dist/types-ts4.5/popover/types.d.ts +153 -0
- package/dist/types-ts4.5/popup/index.d.ts +34 -0
- package/dist/types-ts4.5/popup/popup-content.d.ts +14 -0
- package/dist/types-ts4.5/popup/popup-context.d.ts +96 -0
- package/dist/types-ts4.5/popup/popup-surface.d.ts +16 -0
- package/dist/types-ts4.5/popup/popup-trigger-function.d.ts +108 -0
- package/dist/types-ts4.5/popup/popup-trigger.d.ts +23 -0
- package/dist/types-ts4.5/popup/popup.d.ts +14 -0
- package/dist/types-ts4.5/popup/types.d.ts +289 -0
- package/dist/types-ts4.5/use-arrow-navigation/index.d.ts +2 -0
- package/dist/types-ts4.5/use-arrow-navigation/use-arrow-navigation.d.ts +136 -0
- package/dist/types-ts4.5/use-simple-light-dismiss/index.d.ts +45 -0
- package/focus/package.json +17 -0
- package/package.json +95 -0
- package/placement-map/package.json +17 -0
- package/popover/package.json +17 -0
- package/popup/package.json +17 -0
- package/popup-surface/package.json +17 -0
- package/use-anchor-position/package.json +17 -0
- package/use-arrow-navigation/package.json +17 -0
- package/use-simple-light-dismiss/package.json +17 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { useId, useLayoutEffect } from 'react';
|
|
2
|
+
import { bind } from 'bind-event-listener';
|
|
3
|
+
import rafSchedule from 'raf-schd';
|
|
4
|
+
import once from '@atlaskit/ds-lib/once';
|
|
5
|
+
import { computeFallbackPosition } from './anchor-positioning-fallback';
|
|
6
|
+
import { combine } from './combine';
|
|
7
|
+
import { getPlacement } from './resolve-placement';
|
|
8
|
+
import { setStyle } from './set-style';
|
|
9
|
+
export { getPlacement } from './resolve-placement';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Resolves placement options to CSS `position-area` syntax.
|
|
13
|
+
*
|
|
14
|
+
* The `align` field represents the *visual* alignment:
|
|
15
|
+
* - `align: 'start'` means "aligned to the trigger's start edge" (left in LTR for block axis)
|
|
16
|
+
* - `align: 'end'` means "aligned to the trigger's end edge"
|
|
17
|
+
* - `align: 'center'` means centered (single-axis position-area)
|
|
18
|
+
*
|
|
19
|
+
* CSS `position-area` uses `span-*` keywords which indicate the *expansion direction*.
|
|
20
|
+
* Visual start-alignment requires `span-{cross-axis}-end` (expand toward end = start-aligned).
|
|
21
|
+
* This function handles the inversion internally.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* placementToPositionArea({ placement: { axis: 'block', edge: 'end', align: 'center' } }) // → 'block-end'
|
|
25
|
+
* placementToPositionArea({ placement: { axis: 'block', edge: 'end', align: 'start' } }) // → 'block-end span-inline-end'
|
|
26
|
+
* placementToPositionArea({ placement: { axis: 'block', edge: 'end', align: 'end' } }) // → 'block-end span-inline-start'
|
|
27
|
+
*/
|
|
28
|
+
export function placementToPositionArea({
|
|
29
|
+
placement
|
|
30
|
+
}) {
|
|
31
|
+
const {
|
|
32
|
+
axis,
|
|
33
|
+
edge,
|
|
34
|
+
align
|
|
35
|
+
} = getPlacement({
|
|
36
|
+
placement
|
|
37
|
+
});
|
|
38
|
+
const edgeValue = `${axis}-${edge}`;
|
|
39
|
+
if (align === 'center') {
|
|
40
|
+
return edgeValue;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Cross-axis name: block ↔ inline
|
|
44
|
+
const crossAxis = axis === 'block' ? 'inline' : 'block';
|
|
45
|
+
// Invert align → CSS span direction: visual 'start' → span toward 'end'
|
|
46
|
+
const spanDir = align === 'start' ? 'end' : 'start';
|
|
47
|
+
return `${edgeValue} span-${crossAxis}-${spanDir}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Returns `position-try-fallbacks` value based on placement.
|
|
52
|
+
*
|
|
53
|
+
* For centered placements, includes cross-axis aligned position-area
|
|
54
|
+
* values so the browser can shift the popover along the cross-axis
|
|
55
|
+
* when the centered position would overflow the viewport edge.
|
|
56
|
+
*
|
|
57
|
+
* Fallback order for centered placements (e.g. `block-end`):
|
|
58
|
+
* 1. Same edge, start-aligned (shift for near-edge overflow)
|
|
59
|
+
* 2. Same edge, end-aligned (shift for far-edge overflow)
|
|
60
|
+
* 3. Flipped edge, centered
|
|
61
|
+
* 4. Flipped edge, start-aligned
|
|
62
|
+
* 5. Flipped edge, end-aligned
|
|
63
|
+
*
|
|
64
|
+
* For aligned placements (start/end), includes the same-edge centered
|
|
65
|
+
* position as fallbacks so the browser can try centering when the aligned
|
|
66
|
+
* position would overflow, before flipping to the opposite edge.
|
|
67
|
+
*/
|
|
68
|
+
export function placementToTryFallbacks({
|
|
69
|
+
placement
|
|
70
|
+
}) {
|
|
71
|
+
const {
|
|
72
|
+
axis,
|
|
73
|
+
edge,
|
|
74
|
+
align
|
|
75
|
+
} = getPlacement({
|
|
76
|
+
placement
|
|
77
|
+
});
|
|
78
|
+
const crossAxis = axis === 'block' ? 'inline' : 'block';
|
|
79
|
+
const flippedEdge = edge === 'start' ? 'end' : 'start';
|
|
80
|
+
if (align !== 'center') {
|
|
81
|
+
const sameEdge = `${axis}-${edge}`;
|
|
82
|
+
const oppositeEdge = `${axis}-${flippedEdge}`;
|
|
83
|
+
const flipKeyword = axis === 'block' ? 'flip-block' : 'flip-inline';
|
|
84
|
+
// Legacy Popper fallbacks for e.g. bottom-start: [bottom, bottom-end, top-start, top, top-end, auto].
|
|
85
|
+
// Include same-edge centered so the browser can try center when start/end would overflow.
|
|
86
|
+
return [`${sameEdge} span-${crossAxis}-${align === 'start' ? 'end' : 'start'}`, sameEdge, `${sameEdge} span-${crossAxis}-${align === 'start' ? 'start' : 'end'}`, flipKeyword, `${oppositeEdge} span-${crossAxis}-${align === 'start' ? 'end' : 'start'}`, oppositeEdge, `${oppositeEdge} span-${crossAxis}-${align === 'start' ? 'start' : 'end'}`].join(', ');
|
|
87
|
+
}
|
|
88
|
+
const sameEdge = `${axis}-${edge}`;
|
|
89
|
+
const oppositeEdge = `${axis}-${flippedEdge}`;
|
|
90
|
+
const flipKeyword = axis === 'block' ? 'flip-block' : 'flip-inline';
|
|
91
|
+
return [`${sameEdge} span-${crossAxis}-end`, `${sameEdge} span-${crossAxis}-start`, flipKeyword, `${oppositeEdge} span-${crossAxis}-end`, `${oppositeEdge} span-${crossAxis}-start`].join(', ');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Detects whether the browser supports CSS Anchor Positioning.
|
|
96
|
+
* Uses `once()` for lazy evaluation — safe for SSR and avoids hydration mismatches.
|
|
97
|
+
*/
|
|
98
|
+
const supportsAnchorPositioning = once(() => {
|
|
99
|
+
if (typeof window === 'undefined' || typeof CSS === 'undefined' || typeof CSS.supports !== 'function') {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
return CSS.supports('anchor-name', '--a');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Default gap between the popover and its trigger (in pixels).
|
|
107
|
+
* Maps to `space.100` (8px) in the design token scale.
|
|
108
|
+
*/
|
|
109
|
+
const DEFAULT_OFFSET = 8;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Returns the CSS margin declaration that creates a gap between the
|
|
113
|
+
* popover and its trigger on the side facing the anchor.
|
|
114
|
+
*
|
|
115
|
+
* For example, a `block-end` placement (popover below) gets
|
|
116
|
+
* `margin-block-start: 8px` to push it away from the trigger's bottom edge.
|
|
117
|
+
*/
|
|
118
|
+
function edgeMargin({
|
|
119
|
+
placement,
|
|
120
|
+
offset
|
|
121
|
+
}) {
|
|
122
|
+
const {
|
|
123
|
+
axis,
|
|
124
|
+
edge
|
|
125
|
+
} = placement;
|
|
126
|
+
const value = `${offset}px`;
|
|
127
|
+
if (axis === 'block' && edge === 'end') {
|
|
128
|
+
return {
|
|
129
|
+
property: 'margin-block-start',
|
|
130
|
+
value
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
if (axis === 'block' && edge === 'start') {
|
|
134
|
+
return {
|
|
135
|
+
property: 'margin-block-end',
|
|
136
|
+
value
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
if (axis === 'inline' && edge === 'end') {
|
|
140
|
+
return {
|
|
141
|
+
property: 'margin-inline-start',
|
|
142
|
+
value
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
if (axis === 'inline' && edge === 'start') {
|
|
146
|
+
return {
|
|
147
|
+
property: 'margin-inline-end',
|
|
148
|
+
value
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
property: 'margin-block-start',
|
|
153
|
+
value
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Hook that positions an element relative to an anchor element using
|
|
159
|
+
* CSS Anchor Positioning (with a JS fallback for older browsers).
|
|
160
|
+
*
|
|
161
|
+
* This hook is the positioning primitive — it has no knowledge of popovers,
|
|
162
|
+
* visibility, or animation. Compose it with `Popover` for anchor-positioned
|
|
163
|
+
* top-layer content.
|
|
164
|
+
*
|
|
165
|
+
* When CSS Anchor Positioning is supported, it sets CSS properties
|
|
166
|
+
* (`anchor-name`, `position-anchor`, `position-area`, `position-try-fallbacks`)
|
|
167
|
+
* directly on the elements via `el.style.setProperty()`.
|
|
168
|
+
*
|
|
169
|
+
* When not supported, it falls back to JavaScript-based positioning using
|
|
170
|
+
* `position: fixed` with measured coordinates, re-running on scroll (capture)
|
|
171
|
+
* and resize events.
|
|
172
|
+
*/
|
|
173
|
+
export function useAnchorPosition({
|
|
174
|
+
anchorRef,
|
|
175
|
+
popoverRef,
|
|
176
|
+
placement,
|
|
177
|
+
offset = DEFAULT_OFFSET,
|
|
178
|
+
forceFallbackPositioning = false,
|
|
179
|
+
arrow
|
|
180
|
+
}) {
|
|
181
|
+
const id = useId();
|
|
182
|
+
const anchorName = `--anchor-${id.replace(/:/g, '')}`;
|
|
183
|
+
useLayoutEffect(() => {
|
|
184
|
+
const trigger = anchorRef.current;
|
|
185
|
+
const popover = popoverRef.current;
|
|
186
|
+
if (!trigger || !popover) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const resolvedPlacement = getPlacement({
|
|
190
|
+
placement
|
|
191
|
+
});
|
|
192
|
+
const shouldForceFallback = Boolean(forceFallbackPositioning);
|
|
193
|
+
if (supportsAnchorPositioning() && !shouldForceFallback) {
|
|
194
|
+
const gap = edgeMargin({
|
|
195
|
+
placement: resolvedPlacement,
|
|
196
|
+
offset
|
|
197
|
+
});
|
|
198
|
+
const popoverStyles = [{
|
|
199
|
+
property: 'position-anchor',
|
|
200
|
+
value: anchorName
|
|
201
|
+
}, {
|
|
202
|
+
property: 'position-area',
|
|
203
|
+
value: placementToPositionArea({
|
|
204
|
+
placement: resolvedPlacement
|
|
205
|
+
})
|
|
206
|
+
}, {
|
|
207
|
+
property: 'position-try-fallbacks',
|
|
208
|
+
value: arrow ? arrow.getTryFallbacks({
|
|
209
|
+
placement: resolvedPlacement
|
|
210
|
+
}) : placementToTryFallbacks({
|
|
211
|
+
placement: resolvedPlacement
|
|
212
|
+
})
|
|
213
|
+
},
|
|
214
|
+
// Reset browser default popover positioning that conflicts
|
|
215
|
+
// with anchor positioning (UA: `inset: 0; margin: auto;`)
|
|
216
|
+
{
|
|
217
|
+
property: 'margin',
|
|
218
|
+
value: '0'
|
|
219
|
+
}, {
|
|
220
|
+
property: 'inset',
|
|
221
|
+
value: 'auto'
|
|
222
|
+
},
|
|
223
|
+
// Gap between the popover and its trigger
|
|
224
|
+
{
|
|
225
|
+
property: gap.property,
|
|
226
|
+
value: gap.value
|
|
227
|
+
}];
|
|
228
|
+
if (arrow) {
|
|
229
|
+
// Set the arrow size variable used by the injected arrow CSS
|
|
230
|
+
// and the @position-try rules for margin values.
|
|
231
|
+
popoverStyles.push({
|
|
232
|
+
property: '--ds-arrow-size',
|
|
233
|
+
value: `${offset}px`
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
const undoPositioning = combine(setStyle({
|
|
237
|
+
el: trigger,
|
|
238
|
+
styles: [{
|
|
239
|
+
property: 'anchor-name',
|
|
240
|
+
value: anchorName
|
|
241
|
+
}]
|
|
242
|
+
}), setStyle({
|
|
243
|
+
el: popover,
|
|
244
|
+
styles: popoverStyles
|
|
245
|
+
}));
|
|
246
|
+
return undoPositioning;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ── JS fallback ──
|
|
250
|
+
// The popover is already in the browser's top layer via popover="auto",
|
|
251
|
+
// so it does not need position:fixed. We only reset the UA defaults
|
|
252
|
+
// (inset: 0; margin: auto) and set top/left based on measurements.
|
|
253
|
+
const cleanupBaseStyles = setStyle({
|
|
254
|
+
el: popover,
|
|
255
|
+
styles: [{
|
|
256
|
+
property: 'margin',
|
|
257
|
+
value: '0'
|
|
258
|
+
}, {
|
|
259
|
+
property: 'inset',
|
|
260
|
+
value: 'auto'
|
|
261
|
+
}]
|
|
262
|
+
});
|
|
263
|
+
function update() {
|
|
264
|
+
if (!trigger || !popover) {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
const triggerRect = trigger.getBoundingClientRect();
|
|
268
|
+
const viewport = {
|
|
269
|
+
width: window.innerWidth,
|
|
270
|
+
height: window.innerHeight
|
|
271
|
+
};
|
|
272
|
+
const {
|
|
273
|
+
top,
|
|
274
|
+
left
|
|
275
|
+
} = computeFallbackPosition({
|
|
276
|
+
triggerRect,
|
|
277
|
+
popoverEl: popover,
|
|
278
|
+
placement: resolvedPlacement,
|
|
279
|
+
viewport,
|
|
280
|
+
offset
|
|
281
|
+
});
|
|
282
|
+
popover.style.setProperty('top', `${top}px`);
|
|
283
|
+
popover.style.setProperty('left', `${left}px`);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Throttle scroll/resize updates to one per animation frame
|
|
287
|
+
const scheduledUpdate = rafSchedule(update);
|
|
288
|
+
let pendingFrame = null;
|
|
289
|
+
const undoPositioning = combine(cleanupBaseStyles,
|
|
290
|
+
// Position when the popover transitions to visible.
|
|
291
|
+
// The popover starts hidden (`display: none` per the popover
|
|
292
|
+
// UA stylesheet), so `offsetWidth`/`offsetHeight` are 0 until
|
|
293
|
+
// `showPopover()` is called. We defer positioning to the next
|
|
294
|
+
// animation frame because some browsers (Firefox) fire the
|
|
295
|
+
// `toggle` event synchronously during `showPopover()`, before
|
|
296
|
+
// the element has been laid out — so dimensions are still 0.
|
|
297
|
+
// Waiting one frame guarantees layout has run and measurements
|
|
298
|
+
// are accurate.
|
|
299
|
+
bind(popover, {
|
|
300
|
+
type: 'toggle',
|
|
301
|
+
listener: event => {
|
|
302
|
+
const toggleEvent = event;
|
|
303
|
+
if (toggleEvent.newState === 'open') {
|
|
304
|
+
pendingFrame = requestAnimationFrame(update);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}), bind(window, {
|
|
308
|
+
type: 'scroll',
|
|
309
|
+
listener: scheduledUpdate,
|
|
310
|
+
options: {
|
|
311
|
+
capture: true,
|
|
312
|
+
passive: true
|
|
313
|
+
}
|
|
314
|
+
}), bind(window, {
|
|
315
|
+
type: 'resize',
|
|
316
|
+
listener: scheduledUpdate,
|
|
317
|
+
options: {
|
|
318
|
+
passive: true
|
|
319
|
+
}
|
|
320
|
+
}), () => {
|
|
321
|
+
scheduledUpdate.cancel();
|
|
322
|
+
if (pendingFrame !== null) {
|
|
323
|
+
cancelAnimationFrame(pendingFrame);
|
|
324
|
+
}
|
|
325
|
+
popover.style.removeProperty('top');
|
|
326
|
+
popover.style.removeProperty('left');
|
|
327
|
+
});
|
|
328
|
+
return undoPositioning;
|
|
329
|
+
}, [anchorRef, popoverRef, placement, anchorName, offset, forceFallbackPositioning, arrow]);
|
|
330
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { bind } from 'bind-event-listener';
|
|
3
|
+
import { prefersReducedMotion } from './reduced-motion';
|
|
4
|
+
import { usePresetStyles } from './use-preset-styles';
|
|
5
|
+
/**
|
|
6
|
+
* Manages the children mount/unmount lifecycle around CSS exit transitions.
|
|
7
|
+
*
|
|
8
|
+
* Used by both `Popover` and `Dialog` to share the same animation lifecycle
|
|
9
|
+
* logic. Those components own their own show/hide mechanisms (showPopover /
|
|
10
|
+
* hidePopover vs showModal / close) and event handling — this hook only
|
|
11
|
+
* manages the relationship between `isOpen` and when children are rendered.
|
|
12
|
+
*
|
|
13
|
+
* ## Problem
|
|
14
|
+
*
|
|
15
|
+
* We want to delay the unmount of children until a CSS exit transition has
|
|
16
|
+
* finished playing. If we unmount children the moment `isOpen` becomes
|
|
17
|
+
* `false`, the exit animation is never visible — the content just disappears.
|
|
18
|
+
*
|
|
19
|
+
* ## How it works
|
|
20
|
+
*
|
|
21
|
+
* `showChildren` tracks whether the consumer's children should be in the DOM.
|
|
22
|
+
* It does not mirror `isOpen` exactly — it intentionally lags behind during
|
|
23
|
+
* exit animations:
|
|
24
|
+
*
|
|
25
|
+
* ```
|
|
26
|
+
* isOpen: true ──────────────── false
|
|
27
|
+
* showChildren: true ──────────────── true ─── (exit animation) ─── false
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* Two close paths exist:
|
|
31
|
+
*
|
|
32
|
+
* 1. **Animated close** (`willAnimate === true`):
|
|
33
|
+
* `isOpen` goes `false` → `showChildren` stays `true` → the CSS exit
|
|
34
|
+
* transition plays → `transitionend` fires → `showChildren` → `false`
|
|
35
|
+
* → `onExitFinish` fires. A timeout fallback ensures we still unmount
|
|
36
|
+
* if `transitionend` never fires (e.g. browser quirks, zero-duration).
|
|
37
|
+
*
|
|
38
|
+
* 2. **Non-animated close** (`willAnimate === false`):
|
|
39
|
+
* `isOpen` goes `false` → `showChildren` → `false` synchronously during
|
|
40
|
+
* the same render → `onExitFinish` fires in a follow-up effect.
|
|
41
|
+
*/
|
|
42
|
+
export function useAnimatedVisibility({
|
|
43
|
+
isOpen,
|
|
44
|
+
animate,
|
|
45
|
+
elementRef,
|
|
46
|
+
onExitFinish
|
|
47
|
+
}) {
|
|
48
|
+
// Resolve the animation preset and inject its CSS (idempotent).
|
|
49
|
+
// Returns `null` when `animate` is falsy.
|
|
50
|
+
const preset = usePresetStyles({
|
|
51
|
+
preset: animate
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Whether we should play CSS transitions. False when there is no preset
|
|
55
|
+
// or the user has enabled the "prefers reduced motion" OS setting.
|
|
56
|
+
const willAnimate = Boolean(animate) && !prefersReducedMotion();
|
|
57
|
+
|
|
58
|
+
// ── showChildren state ──
|
|
59
|
+
//
|
|
60
|
+
// This is the core of the hook. `showChildren` controls whether the
|
|
61
|
+
// consumer's children are in the DOM. The two `if` blocks below are
|
|
62
|
+
// intentionally written as synchronous state updates during render
|
|
63
|
+
// (React supports this pattern — setState during render is treated as
|
|
64
|
+
// a synchronous re-render before the browser paints).
|
|
65
|
+
//
|
|
66
|
+
// Mount path: isOpen went true but showChildren is still false → mount.
|
|
67
|
+
// Unmount path: isOpen went false, showChildren is still true, and we
|
|
68
|
+
// are NOT animating → unmount immediately (no transition
|
|
69
|
+
// to wait for). If we ARE animating, showChildren stays
|
|
70
|
+
// true here and the effect below will set it to false
|
|
71
|
+
// after `transitionend`.
|
|
72
|
+
const [showChildren, setShowChildren] = useState(isOpen);
|
|
73
|
+
if (isOpen && !showChildren) {
|
|
74
|
+
setShowChildren(true);
|
|
75
|
+
}
|
|
76
|
+
if (!isOpen && showChildren && !willAnimate) {
|
|
77
|
+
setShowChildren(false);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Keep a stable ref to the latest `onExitFinish` so the effect and
|
|
81
|
+
// timeout closures below always call the current callback without
|
|
82
|
+
// needing `onExitFinish` in dependency arrays (which would reset
|
|
83
|
+
// the timeout on every new closure).
|
|
84
|
+
const onExitFinishRef = useRef(onExitFinish);
|
|
85
|
+
onExitFinishRef.current = onExitFinish;
|
|
86
|
+
|
|
87
|
+
// ── Animated exit effect ──
|
|
88
|
+
//
|
|
89
|
+
// Runs when all three conditions are true:
|
|
90
|
+
// • `isOpen` is false (consumer wants to close)
|
|
91
|
+
// • `showChildren` is true (children are still mounted for exit anim)
|
|
92
|
+
// • `willAnimate` is true (we have a preset and reduced motion is off)
|
|
93
|
+
//
|
|
94
|
+
// Listens for `transitionend` on the element to know when the CSS exit
|
|
95
|
+
// transition has finished, then unmounts children and fires onExitFinish.
|
|
96
|
+
//
|
|
97
|
+
// Fallback timeout: `transitionend` can fail to fire in edge cases
|
|
98
|
+
// (e.g. element removed from top layer by browser, zero-duration
|
|
99
|
+
// transition, interrupted animation). The timeout is set to the
|
|
100
|
+
// preset's exit duration + 50ms buffer to guarantee we always unmount.
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
var _preset$exitDurationM;
|
|
103
|
+
// Guard: only run during the "exiting" window
|
|
104
|
+
if (isOpen || !showChildren || !willAnimate) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const el = elementRef.current;
|
|
108
|
+
if (!el) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
function finish() {
|
|
112
|
+
var _onExitFinishRef$curr;
|
|
113
|
+
clearTimeout(fallbackId);
|
|
114
|
+
setShowChildren(false);
|
|
115
|
+
(_onExitFinishRef$curr = onExitFinishRef.current) === null || _onExitFinishRef$curr === void 0 ? void 0 : _onExitFinishRef$curr.call(onExitFinishRef);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Fallback: unmount even if transitionend never fires
|
|
119
|
+
const fallbackId = setTimeout(finish, ((_preset$exitDurationM = preset === null || preset === void 0 ? void 0 : preset.exitDurationMs) !== null && _preset$exitDurationM !== void 0 ? _preset$exitDurationM : 0) + 50);
|
|
120
|
+
|
|
121
|
+
// Primary: listen for the CSS transition to complete
|
|
122
|
+
const unbind = bind(el, {
|
|
123
|
+
type: 'transitionend',
|
|
124
|
+
listener: finish,
|
|
125
|
+
options: {
|
|
126
|
+
once: true
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// Cleanup on re-render or unmount: cancel pending timeout and listener
|
|
131
|
+
return () => {
|
|
132
|
+
clearTimeout(fallbackId);
|
|
133
|
+
unbind();
|
|
134
|
+
};
|
|
135
|
+
}, [isOpen, showChildren, willAnimate, preset === null || preset === void 0 ? void 0 : preset.exitDurationMs, elementRef]);
|
|
136
|
+
|
|
137
|
+
// ── Non-animated exit callback ──
|
|
138
|
+
//
|
|
139
|
+
// When there is no animation, children unmount synchronously (handled
|
|
140
|
+
// by the `if` block above). But `onExitFinish` still needs to fire —
|
|
141
|
+
// we do that in a follow-up effect so the consumer can react to it.
|
|
142
|
+
//
|
|
143
|
+
// Uses a previous-value ref (`wasOpenRef`) to detect the exact
|
|
144
|
+
// true→false transition of `isOpen`, avoiding false positives on
|
|
145
|
+
// mount or re-renders where `isOpen` was already false.
|
|
146
|
+
const wasOpenRef = useRef(isOpen);
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
const wasOpen = wasOpenRef.current;
|
|
149
|
+
wasOpenRef.current = isOpen;
|
|
150
|
+
if (wasOpen && !isOpen && !willAnimate) {
|
|
151
|
+
var _onExitFinishRef$curr2;
|
|
152
|
+
(_onExitFinishRef$curr2 = onExitFinishRef.current) === null || _onExitFinishRef$curr2 === void 0 ? void 0 : _onExitFinishRef$curr2.call(onExitFinishRef);
|
|
153
|
+
}
|
|
154
|
+
}, [isOpen, willAnimate]);
|
|
155
|
+
return {
|
|
156
|
+
showChildren,
|
|
157
|
+
preset
|
|
158
|
+
};
|
|
159
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useInsertionEffect } from 'react';
|
|
2
|
+
import { ensurePresetStyles } from './ensure-preset-styles';
|
|
3
|
+
/**
|
|
4
|
+
* Normalizes the `animate` prop to a preset (or null) and ensures its CSS
|
|
5
|
+
* is injected once into document.head. Used by PopupContent, Popover, and
|
|
6
|
+
* DialogContent so they don't duplicate this logic.
|
|
7
|
+
*/
|
|
8
|
+
export function useAnimationPreset(animate) {
|
|
9
|
+
const preset = animate != null && animate !== false ? animate : null;
|
|
10
|
+
useInsertionEffect(() => {
|
|
11
|
+
if (preset) {
|
|
12
|
+
ensurePresetStyles({
|
|
13
|
+
preset
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
}, [preset]);
|
|
17
|
+
return preset;
|
|
18
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import { bind } from 'bind-event-listener';
|
|
3
|
+
import { getFirstFocusable, getLastFocusable, getNextFocusable } from '../focus/focus';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Roles that require focus wrapping per WAI-ARIA APG.
|
|
7
|
+
*
|
|
8
|
+
* - `dialog`: https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/
|
|
9
|
+
* - `alertdialog`: same keyboard pattern as dialog
|
|
10
|
+
*
|
|
11
|
+
* `menu` is intentionally excluded. Menus are composite widgets
|
|
12
|
+
* (https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_general_within)
|
|
13
|
+
* that manage their own internal keyboard navigation via arrow keys,
|
|
14
|
+
* not Tab. Tab/Shift+Tab should move focus OUT of a menu entirely.
|
|
15
|
+
*
|
|
16
|
+
* Menu keyboard behavior (arrow keys, Home/End, type-ahead, Enter/Space)
|
|
17
|
+
* is context-dependent — it varies by orientation, nesting depth, item
|
|
18
|
+
* type, and control pattern (menu button vs menubar). This makes it
|
|
19
|
+
* impossible to implement generically here. The consumer component
|
|
20
|
+
* (e.g. `dropdown-menu`) owns all menu keyboard navigation.
|
|
21
|
+
*
|
|
22
|
+
* See: notes/outputs/menu-keyboard-decision.md
|
|
23
|
+
*/
|
|
24
|
+
function roleRequiresFocusWrap(role) {
|
|
25
|
+
return role === 'dialog' || role === 'alertdialog';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Wraps Tab/Shift+Tab focus within a popover element when its role
|
|
30
|
+
* semantically requires it (dialog, alertdialog).
|
|
31
|
+
*
|
|
32
|
+
* While the popover is open, Tab and Shift+Tab are intercepted
|
|
33
|
+
* (via `preventDefault`) and remapped to cycle through focusable
|
|
34
|
+
* elements within the container using wrapping navigation.
|
|
35
|
+
*
|
|
36
|
+
* Light dismiss (Escape, click outside) continues to work natively
|
|
37
|
+
* via `popover="auto"` — this hook only intercepts Tab.
|
|
38
|
+
*
|
|
39
|
+
* Always call this hook unconditionally. The listener is only
|
|
40
|
+
* attached when the role requires focus wrapping, and is cleaned
|
|
41
|
+
* up and re-evaluated when the role changes.
|
|
42
|
+
*/
|
|
43
|
+
export function useFocusWrap({
|
|
44
|
+
elementRef,
|
|
45
|
+
role
|
|
46
|
+
}) {
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
const el = elementRef.current;
|
|
49
|
+
if (!el || !roleRequiresFocusWrap(role)) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const unbind = bind(el, {
|
|
53
|
+
type: 'keydown',
|
|
54
|
+
listener: event => {
|
|
55
|
+
if (event.key !== 'Tab') {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
event.preventDefault();
|
|
59
|
+
const direction = event.shiftKey ? 'backwards' : 'forwards';
|
|
60
|
+
|
|
61
|
+
// Try to move to the next/previous focusable element relative
|
|
62
|
+
// to the currently focused element within the container.
|
|
63
|
+
const next = getNextFocusable({
|
|
64
|
+
container: el,
|
|
65
|
+
direction
|
|
66
|
+
});
|
|
67
|
+
if (next) {
|
|
68
|
+
next.focus();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Nothing is currently focused inside the container (or the
|
|
73
|
+
// focused element isn't in the focusable list). Fall back to
|
|
74
|
+
// the first or last focusable element depending on direction.
|
|
75
|
+
const fallback = direction === 'forwards' ? getFirstFocusable({
|
|
76
|
+
container: el
|
|
77
|
+
}) : getLastFocusable({
|
|
78
|
+
container: el
|
|
79
|
+
});
|
|
80
|
+
if (fallback) {
|
|
81
|
+
fallback.focus();
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
options: {
|
|
85
|
+
capture: true
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
return unbind;
|
|
89
|
+
}, [elementRef, role]);
|
|
90
|
+
}
|