@c9up/nebula 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/LICENSE +21 -0
- package/README.md +256 -0
- package/dist/adapters/css.d.ts +33 -0
- package/dist/adapters/css.js +67 -0
- package/dist/adapters/index.d.ts +16 -0
- package/dist/adapters/index.js +27 -0
- package/dist/adapters/tailwind.d.ts +25 -0
- package/dist/adapters/tailwind.js +122 -0
- package/dist/adapters/types.d.ts +49 -0
- package/dist/adapters/types.js +21 -0
- package/dist/adapters/unocss.d.ts +17 -0
- package/dist/adapters/unocss.js +107 -0
- package/dist/atoms/AspectRatio.d.ts +17 -0
- package/dist/atoms/AspectRatio.js +19 -0
- package/dist/atoms/Avatar.d.ts +23 -0
- package/dist/atoms/Avatar.js +46 -0
- package/dist/atoms/Badge.d.ts +25 -0
- package/dist/atoms/Badge.js +28 -0
- package/dist/atoms/Button.d.ts +58 -0
- package/dist/atoms/Button.js +67 -0
- package/dist/atoms/Checkbox.d.ts +41 -0
- package/dist/atoms/Checkbox.js +47 -0
- package/dist/atoms/Input.d.ts +36 -0
- package/dist/atoms/Input.js +50 -0
- package/dist/atoms/Kbd.d.ts +14 -0
- package/dist/atoms/Kbd.js +17 -0
- package/dist/atoms/Label.d.ts +24 -0
- package/dist/atoms/Label.js +26 -0
- package/dist/atoms/Marker.d.ts +42 -0
- package/dist/atoms/Marker.js +55 -0
- package/dist/atoms/NativeSelect.d.ts +43 -0
- package/dist/atoms/NativeSelect.js +81 -0
- package/dist/atoms/Progress.d.ts +21 -0
- package/dist/atoms/Progress.js +48 -0
- package/dist/atoms/ScrollArea.d.ts +28 -0
- package/dist/atoms/ScrollArea.js +37 -0
- package/dist/atoms/Separator.d.ts +16 -0
- package/dist/atoms/Separator.js +21 -0
- package/dist/atoms/Skeleton.d.ts +13 -0
- package/dist/atoms/Skeleton.js +18 -0
- package/dist/atoms/Slider.d.ts +30 -0
- package/dist/atoms/Slider.js +40 -0
- package/dist/atoms/Spinner.d.ts +17 -0
- package/dist/atoms/Spinner.js +20 -0
- package/dist/atoms/Switch.d.ts +24 -0
- package/dist/atoms/Switch.js +41 -0
- package/dist/atoms/Textarea.d.ts +28 -0
- package/dist/atoms/Textarea.js +36 -0
- package/dist/atoms/Toggle.d.ts +38 -0
- package/dist/atoms/Toggle.js +45 -0
- package/dist/atoms/index.d.ts +32 -0
- package/dist/atoms/index.js +32 -0
- package/dist/cli/add.d.ts +44 -0
- package/dist/cli/add.js +99 -0
- package/dist/cli/index.d.ts +10 -0
- package/dist/cli/index.js +10 -0
- package/dist/cli/registry.d.ts +31 -0
- package/dist/cli/registry.js +83 -0
- package/dist/config.d.ts +81 -0
- package/dist/config.js +43 -0
- package/dist/configure.d.ts +34 -0
- package/dist/configure.js +85 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.js +41 -0
- package/dist/lib/children.d.ts +28 -0
- package/dist/lib/children.js +27 -0
- package/dist/lib/cn.d.ts +13 -0
- package/dist/lib/cn.js +13 -0
- package/dist/lib/cva.d.ts +83 -0
- package/dist/lib/cva.js +178 -0
- package/dist/lib/icons.d.ts +44 -0
- package/dist/lib/icons.js +79 -0
- package/dist/lib/id.d.ts +31 -0
- package/dist/lib/id.js +41 -0
- package/dist/lib/index.d.ts +12 -0
- package/dist/lib/index.js +12 -0
- package/dist/lib/motion.d.ts +31 -0
- package/dist/lib/motion.js +39 -0
- package/dist/lib/props.d.ts +48 -0
- package/dist/lib/props.js +59 -0
- package/dist/lib/styled.d.ts +31 -0
- package/dist/lib/styled.js +39 -0
- package/dist/molecules/Accordion.d.ts +35 -0
- package/dist/molecules/Accordion.js +117 -0
- package/dist/molecules/Alert.d.ts +30 -0
- package/dist/molecules/Alert.js +35 -0
- package/dist/molecules/Attachment.d.ts +39 -0
- package/dist/molecules/Attachment.js +103 -0
- package/dist/molecules/Breadcrumb.d.ts +29 -0
- package/dist/molecules/Breadcrumb.js +73 -0
- package/dist/molecules/Bubble.d.ts +56 -0
- package/dist/molecules/Bubble.js +92 -0
- package/dist/molecules/ButtonGroup.d.ts +23 -0
- package/dist/molecules/ButtonGroup.js +30 -0
- package/dist/molecules/Card.d.ts +17 -0
- package/dist/molecules/Card.js +17 -0
- package/dist/molecules/Collapsible.d.ts +40 -0
- package/dist/molecules/Collapsible.js +71 -0
- package/dist/molecules/Empty.d.ts +16 -0
- package/dist/molecules/Empty.js +16 -0
- package/dist/molecules/Field.d.ts +54 -0
- package/dist/molecules/Field.js +82 -0
- package/dist/molecules/InputGroup.d.ts +33 -0
- package/dist/molecules/InputGroup.js +35 -0
- package/dist/molecules/InputOTP.d.ts +33 -0
- package/dist/molecules/InputOTP.js +140 -0
- package/dist/molecules/Item.d.ts +41 -0
- package/dist/molecules/Item.js +48 -0
- package/dist/molecules/Message.d.ts +43 -0
- package/dist/molecules/Message.js +82 -0
- package/dist/molecules/Pagination.d.ts +41 -0
- package/dist/molecules/Pagination.js +144 -0
- package/dist/molecules/RadioGroup.d.ts +32 -0
- package/dist/molecules/RadioGroup.js +65 -0
- package/dist/molecules/Resizable.d.ts +37 -0
- package/dist/molecules/Resizable.js +136 -0
- package/dist/molecules/Table.d.ts +37 -0
- package/dist/molecules/Table.js +78 -0
- package/dist/molecules/Tabs.d.ts +37 -0
- package/dist/molecules/Tabs.js +109 -0
- package/dist/molecules/ToggleGroup.d.ts +37 -0
- package/dist/molecules/ToggleGroup.js +68 -0
- package/dist/molecules/Typography.d.ts +26 -0
- package/dist/molecules/Typography.js +67 -0
- package/dist/molecules/index.d.ts +37 -0
- package/dist/molecules/index.js +37 -0
- package/dist/organisms/AlertDialog.d.ts +40 -0
- package/dist/organisms/AlertDialog.js +104 -0
- package/dist/organisms/Calendar.d.ts +96 -0
- package/dist/organisms/Calendar.js +353 -0
- package/dist/organisms/Carousel.d.ts +29 -0
- package/dist/organisms/Carousel.js +128 -0
- package/dist/organisms/Chart.d.ts +55 -0
- package/dist/organisms/Chart.js +189 -0
- package/dist/organisms/Combobox.d.ts +41 -0
- package/dist/organisms/Combobox.js +102 -0
- package/dist/organisms/Command.d.ts +46 -0
- package/dist/organisms/Command.js +171 -0
- package/dist/organisms/CommandDialog.d.ts +42 -0
- package/dist/organisms/CommandDialog.js +116 -0
- package/dist/organisms/ContextMenu.d.ts +29 -0
- package/dist/organisms/ContextMenu.js +102 -0
- package/dist/organisms/DataTable.d.ts +50 -0
- package/dist/organisms/DataTable.js +257 -0
- package/dist/organisms/DatePicker.d.ts +34 -0
- package/dist/organisms/DatePicker.js +115 -0
- package/dist/organisms/DateRangePicker.d.ts +34 -0
- package/dist/organisms/DateRangePicker.js +122 -0
- package/dist/organisms/Dialog.d.ts +50 -0
- package/dist/organisms/Dialog.js +116 -0
- package/dist/organisms/Drawer.d.ts +40 -0
- package/dist/organisms/Drawer.js +131 -0
- package/dist/organisms/DropdownMenu.d.ts +30 -0
- package/dist/organisms/DropdownMenu.js +91 -0
- package/dist/organisms/Form.d.ts +114 -0
- package/dist/organisms/Form.js +151 -0
- package/dist/organisms/HoverCard.d.ts +30 -0
- package/dist/organisms/HoverCard.js +68 -0
- package/dist/organisms/Menubar.d.ts +28 -0
- package/dist/organisms/Menubar.js +147 -0
- package/dist/organisms/MessageScroller.d.ts +36 -0
- package/dist/organisms/MessageScroller.js +124 -0
- package/dist/organisms/NavigationMenu.d.ts +41 -0
- package/dist/organisms/NavigationMenu.js +139 -0
- package/dist/organisms/Popover.d.ts +33 -0
- package/dist/organisms/Popover.js +58 -0
- package/dist/organisms/Questionnaire.d.ts +74 -0
- package/dist/organisms/Questionnaire.js +235 -0
- package/dist/organisms/Select.d.ts +47 -0
- package/dist/organisms/Select.js +250 -0
- package/dist/organisms/Sheet.d.ts +34 -0
- package/dist/organisms/Sheet.js +107 -0
- package/dist/organisms/Sidebar.d.ts +100 -0
- package/dist/organisms/Sidebar.js +228 -0
- package/dist/organisms/Toaster.d.ts +67 -0
- package/dist/organisms/Toaster.js +189 -0
- package/dist/organisms/Tooltip.d.ts +39 -0
- package/dist/organisms/Tooltip.js +94 -0
- package/dist/organisms/index.d.ts +40 -0
- package/dist/organisms/index.js +40 -0
- package/dist/primitives/controllable.d.ts +38 -0
- package/dist/primitives/controllable.js +43 -0
- package/dist/primitives/dismissable.d.ts +53 -0
- package/dist/primitives/dismissable.js +158 -0
- package/dist/primitives/floating.d.ts +116 -0
- package/dist/primitives/floating.js +289 -0
- package/dist/primitives/floatingSurface.d.ts +56 -0
- package/dist/primitives/floatingSurface.js +142 -0
- package/dist/primitives/focusTrap.d.ts +47 -0
- package/dist/primitives/focusTrap.js +101 -0
- package/dist/primitives/focusable.d.ts +53 -0
- package/dist/primitives/focusable.js +109 -0
- package/dist/primitives/index.d.ts +27 -0
- package/dist/primitives/index.js +27 -0
- package/dist/primitives/menu.d.ts +102 -0
- package/dist/primitives/menu.js +356 -0
- package/dist/primitives/modalSurface.d.ts +54 -0
- package/dist/primitives/modalSurface.js +146 -0
- package/dist/primitives/portal.d.ts +34 -0
- package/dist/primitives/portal.js +42 -0
- package/dist/primitives/presence.d.ts +45 -0
- package/dist/primitives/presence.js +152 -0
- package/dist/primitives/rovingFocus.d.ts +47 -0
- package/dist/primitives/rovingFocus.js +176 -0
- package/dist/primitives/scrollLock.d.ts +21 -0
- package/dist/primitives/scrollLock.js +75 -0
- package/dist/primitives/typeahead.d.ts +39 -0
- package/dist/primitives/typeahead.js +123 -0
- package/dist/templates/AppShell.d.ts +30 -0
- package/dist/templates/AppShell.js +42 -0
- package/dist/templates/AuthLayout.d.ts +23 -0
- package/dist/templates/AuthLayout.js +42 -0
- package/dist/templates/SettingsLayout.d.ts +27 -0
- package/dist/templates/SettingsLayout.js +42 -0
- package/dist/templates/index.d.ts +16 -0
- package/dist/templates/index.js +16 -0
- package/nebula.css +2 -0
- package/package.json +92 -0
- package/registry.json +1107 -0
- package/src/adapters/css.ts +75 -0
- package/src/adapters/index.ts +40 -0
- package/src/adapters/tailwind.ts +134 -0
- package/src/adapters/types.ts +53 -0
- package/src/adapters/unocss.ts +116 -0
- package/src/atoms/AspectRatio.ts +28 -0
- package/src/atoms/Avatar.ts +67 -0
- package/src/atoms/Badge.ts +46 -0
- package/src/atoms/Button.ts +96 -0
- package/src/atoms/Checkbox.ts +76 -0
- package/src/atoms/Input.ts +76 -0
- package/src/atoms/Kbd.ts +28 -0
- package/src/atoms/Label.ts +38 -0
- package/src/atoms/Marker.ts +79 -0
- package/src/atoms/NativeSelect.ts +115 -0
- package/src/atoms/Progress.ts +64 -0
- package/src/atoms/ScrollArea.ts +53 -0
- package/src/atoms/Separator.ts +36 -0
- package/src/atoms/Skeleton.ts +24 -0
- package/src/atoms/Slider.ts +65 -0
- package/src/atoms/Spinner.ts +28 -0
- package/src/atoms/Switch.ts +56 -0
- package/src/atoms/Textarea.ts +58 -0
- package/src/atoms/Toggle.ts +67 -0
- package/src/atoms/index.ts +62 -0
- package/src/cli/add.ts +126 -0
- package/src/cli/index.ts +22 -0
- package/src/cli/registry.ts +116 -0
- package/src/config.ts +102 -0
- package/src/configure.ts +127 -0
- package/src/index.ts +53 -0
- package/src/lib/children.ts +43 -0
- package/src/lib/cn.ts +14 -0
- package/src/lib/cva.ts +249 -0
- package/src/lib/icons.ts +138 -0
- package/src/lib/id.ts +44 -0
- package/src/lib/index.ts +21 -0
- package/src/lib/motion.ts +48 -0
- package/src/lib/props.ts +83 -0
- package/src/lib/styled.ts +60 -0
- package/src/molecules/Accordion.ts +161 -0
- package/src/molecules/Alert.ts +58 -0
- package/src/molecules/Attachment.ts +132 -0
- package/src/molecules/Breadcrumb.ts +107 -0
- package/src/molecules/Bubble.ts +134 -0
- package/src/molecules/ButtonGroup.ts +55 -0
- package/src/molecules/Card.ts +42 -0
- package/src/molecules/Collapsible.ts +89 -0
- package/src/molecules/Empty.ts +43 -0
- package/src/molecules/Field.ts +131 -0
- package/src/molecules/InputGroup.ts +62 -0
- package/src/molecules/InputOTP.ts +166 -0
- package/src/molecules/Item.ts +89 -0
- package/src/molecules/Message.ts +127 -0
- package/src/molecules/Pagination.ts +197 -0
- package/src/molecules/RadioGroup.ts +104 -0
- package/src/molecules/Resizable.ts +166 -0
- package/src/molecules/Table.ts +118 -0
- package/src/molecules/Tabs.ts +148 -0
- package/src/molecules/ToggleGroup.ts +109 -0
- package/src/molecules/Typography.ts +94 -0
- package/src/molecules/index.ts +144 -0
- package/src/organisms/AlertDialog.ts +133 -0
- package/src/organisms/Calendar.ts +470 -0
- package/src/organisms/Carousel.ts +161 -0
- package/src/organisms/Chart.ts +257 -0
- package/src/organisms/Combobox.ts +140 -0
- package/src/organisms/Command.ts +211 -0
- package/src/organisms/CommandDialog.ts +149 -0
- package/src/organisms/ContextMenu.ts +125 -0
- package/src/organisms/DataTable.ts +328 -0
- package/src/organisms/DatePicker.ts +141 -0
- package/src/organisms/DateRangePicker.ts +149 -0
- package/src/organisms/Dialog.ts +162 -0
- package/src/organisms/Drawer.ts +167 -0
- package/src/organisms/DropdownMenu.ts +112 -0
- package/src/organisms/Form.ts +217 -0
- package/src/organisms/HoverCard.ts +92 -0
- package/src/organisms/Menubar.ts +167 -0
- package/src/organisms/MessageScroller.ts +145 -0
- package/src/organisms/NavigationMenu.ts +184 -0
- package/src/organisms/Popover.ts +84 -0
- package/src/organisms/Questionnaire.ts +322 -0
- package/src/organisms/Select.ts +286 -0
- package/src/organisms/Sheet.ts +144 -0
- package/src/organisms/Sidebar.ts +336 -0
- package/src/organisms/Toaster.ts +246 -0
- package/src/organisms/Tooltip.ts +124 -0
- package/src/organisms/index.ts +134 -0
- package/src/primitives/controllable.ts +64 -0
- package/src/primitives/dismissable.ts +195 -0
- package/src/primitives/floating.ts +453 -0
- package/src/primitives/floatingSurface.ts +209 -0
- package/src/primitives/focusTrap.ts +132 -0
- package/src/primitives/focusable.ts +108 -0
- package/src/primitives/index.ts +88 -0
- package/src/primitives/menu.ts +491 -0
- package/src/primitives/modalSurface.ts +199 -0
- package/src/primitives/portal.ts +61 -0
- package/src/primitives/presence.ts +185 -0
- package/src/primitives/rovingFocus.ts +212 -0
- package/src/primitives/scrollLock.ts +79 -0
- package/src/primitives/typeahead.ts +156 -0
- package/src/templates/AppShell.ts +56 -0
- package/src/templates/AuthLayout.ts +66 -0
- package/src/templates/SettingsLayout.ts +63 -0
- package/src/templates/index.ts +21 -0
- package/theme.css +151 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Floating placement — where a popover, menu, select or tooltip goes.
|
|
4
|
+
*
|
|
5
|
+
* This is the piece Radix delegates to `@floating-ui/dom`. nebula computes it
|
|
6
|
+
* instead, because the alternative is a runtime dependency in a workspace
|
|
7
|
+
* whose stated position is that `cn` was worth reimplementing rather than
|
|
8
|
+
* pulling in clsx and tailwind-merge.
|
|
9
|
+
*
|
|
10
|
+
* Scope is chosen to make that defensible. Floating UI is a general middleware
|
|
11
|
+
* pipeline; this is the four behaviours the shadcn component set actually
|
|
12
|
+
* uses — `offset`, `flip`, `shift`, and arrow centring — plus the available
|
|
13
|
+
* height a scrollable menu needs for its `max-height`. No middleware
|
|
14
|
+
* abstraction, no virtual elements, no `autoPlacement`.
|
|
15
|
+
*
|
|
16
|
+
* The geometry lives in `resolvePosition`, a pure function over rectangles.
|
|
17
|
+
* Everything that touches the DOM — measuring, writing styles, watching for
|
|
18
|
+
* scroll — is in `autoPosition` around it. That split is what lets the flip
|
|
19
|
+
* and shift rules be tested exhaustively without a browser, which matters
|
|
20
|
+
* because they are where placement bugs actually live.
|
|
21
|
+
*
|
|
22
|
+
* Coordinates are viewport-relative and meant for `position: fixed`. Fixed
|
|
23
|
+
* positioning is what lets a portalled surface ignore every transformed or
|
|
24
|
+
* clipping ancestor between it and the root; an absolutely positioned popover
|
|
25
|
+
* has to find an offset parent, and gets it wrong the moment one appears.
|
|
26
|
+
*/
|
|
27
|
+
const DEFAULT_OFFSET = 4;
|
|
28
|
+
const DEFAULT_PADDING = 8;
|
|
29
|
+
/**
|
|
30
|
+
* Compute where the surface goes. Pure — no DOM, no side effects.
|
|
31
|
+
*
|
|
32
|
+
* Order matters and matches Floating UI's: offset, then flip, then shift.
|
|
33
|
+
* Shifting before flipping would slide a surface halfway off the anchor to
|
|
34
|
+
* make room on a side that should have been abandoned entirely.
|
|
35
|
+
*/
|
|
36
|
+
export function resolvePosition(anchor, floating, viewport, options = {}) {
|
|
37
|
+
const offset = options.offset ?? DEFAULT_OFFSET;
|
|
38
|
+
const padding = options.padding ?? DEFAULT_PADDING;
|
|
39
|
+
const requested = options.placement ?? "bottom";
|
|
40
|
+
const [physicalSide, physicalAlign] = splitPlacement(requested);
|
|
41
|
+
const [requestedSide, align] = options.rtl === true
|
|
42
|
+
? mirrorForRtl(physicalSide, physicalAlign)
|
|
43
|
+
: [physicalSide, physicalAlign];
|
|
44
|
+
const side = options.flip === false
|
|
45
|
+
? requestedSide
|
|
46
|
+
: bestSide(requestedSide, anchor, floating, viewport, offset, padding);
|
|
47
|
+
let { x, y } = coordsFor(side, align, anchor, floating, offset);
|
|
48
|
+
if (options.shift !== false) {
|
|
49
|
+
if (isVertical(side)) {
|
|
50
|
+
x = clamp(x, padding, viewport.width - floating.width - padding);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
y = clamp(y, padding, viewport.height - floating.height - padding);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const position = {
|
|
57
|
+
x,
|
|
58
|
+
y,
|
|
59
|
+
placement: align === "center" ? side : `${side}-${align}`,
|
|
60
|
+
side,
|
|
61
|
+
align,
|
|
62
|
+
availableHeight: spaceOn(side, anchor, viewport, offset, padding),
|
|
63
|
+
};
|
|
64
|
+
if (options.arrowSize === undefined)
|
|
65
|
+
return position;
|
|
66
|
+
return {
|
|
67
|
+
...position,
|
|
68
|
+
arrow: arrowOffset(side, anchor, floating, { x, y }, options.arrowSize, padding),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
// ─── geometry ────────────────────────────────────────────────────────
|
|
72
|
+
function splitPlacement(placement) {
|
|
73
|
+
const dash = placement.indexOf("-");
|
|
74
|
+
if (dash === -1)
|
|
75
|
+
return [toSide(placement), "center"];
|
|
76
|
+
return [toSide(placement.slice(0, dash)), toAlign(placement.slice(dash + 1))];
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Narrow a substring back to `Side` / `Align`.
|
|
80
|
+
*
|
|
81
|
+
* `splitPlacement` slices a string the type system already proved is a valid
|
|
82
|
+
* `Placement`, but slicing erases that. A guard restores it without a cast,
|
|
83
|
+
* and the fallback keeps a hand-built placement string from crashing the
|
|
84
|
+
* layout — it lands on the default instead.
|
|
85
|
+
*/
|
|
86
|
+
function toSide(value) {
|
|
87
|
+
return value === "top" || value === "right" || value === "left"
|
|
88
|
+
? value
|
|
89
|
+
: "bottom";
|
|
90
|
+
}
|
|
91
|
+
function toAlign(value) {
|
|
92
|
+
return value === "start" || value === "end" ? value : "center";
|
|
93
|
+
}
|
|
94
|
+
function isVertical(side) {
|
|
95
|
+
return side === "top" || side === "bottom";
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Flip a placement across the vertical axis.
|
|
99
|
+
*
|
|
100
|
+
* Which half moves depends on the side. For `left` / `right` the *side* is the
|
|
101
|
+
* horizontal thing, so it swaps and the alignment (top/bottom) is untouched.
|
|
102
|
+
* For `top` / `bottom` the side is unaffected and it is the *alignment* that
|
|
103
|
+
* refers to a horizontal edge, so that swaps instead. Mirroring both would put
|
|
104
|
+
* a `bottom-start` menu back where it started.
|
|
105
|
+
*/
|
|
106
|
+
function mirrorForRtl(side, align) {
|
|
107
|
+
if (!isVertical(side))
|
|
108
|
+
return [opposite(side), align];
|
|
109
|
+
if (align === "center")
|
|
110
|
+
return [side, align];
|
|
111
|
+
return [side, align === "start" ? "end" : "start"];
|
|
112
|
+
}
|
|
113
|
+
function opposite(side) {
|
|
114
|
+
if (side === "top")
|
|
115
|
+
return "bottom";
|
|
116
|
+
if (side === "bottom")
|
|
117
|
+
return "top";
|
|
118
|
+
if (side === "left")
|
|
119
|
+
return "right";
|
|
120
|
+
return "left";
|
|
121
|
+
}
|
|
122
|
+
/** Top-left corner of the surface for a given side and alignment. */
|
|
123
|
+
function coordsFor(side, align, anchor, floating, offset) {
|
|
124
|
+
if (isVertical(side)) {
|
|
125
|
+
const y = side === "bottom"
|
|
126
|
+
? anchor.y + anchor.height + offset
|
|
127
|
+
: anchor.y - floating.height - offset;
|
|
128
|
+
return {
|
|
129
|
+
x: alignedStart(anchor.x, anchor.width, floating.width, align),
|
|
130
|
+
y,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const x = side === "right"
|
|
134
|
+
? anchor.x + anchor.width + offset
|
|
135
|
+
: anchor.x - floating.width - offset;
|
|
136
|
+
return {
|
|
137
|
+
x,
|
|
138
|
+
y: alignedStart(anchor.y, anchor.height, floating.height, align),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/** Where the surface starts along the cross axis, for one alignment. */
|
|
142
|
+
function alignedStart(anchorStart, anchorSize, floatingSize, align) {
|
|
143
|
+
if (align === "start")
|
|
144
|
+
return anchorStart;
|
|
145
|
+
if (align === "end")
|
|
146
|
+
return anchorStart + anchorSize - floatingSize;
|
|
147
|
+
return anchorStart + (anchorSize - floatingSize) / 2;
|
|
148
|
+
}
|
|
149
|
+
/** Room between the anchor and the viewport edge on one side. */
|
|
150
|
+
function spaceOn(side, anchor, viewport, offset, padding) {
|
|
151
|
+
const raw = side === "top"
|
|
152
|
+
? anchor.y
|
|
153
|
+
: side === "bottom"
|
|
154
|
+
? viewport.height - (anchor.y + anchor.height)
|
|
155
|
+
: side === "left"
|
|
156
|
+
? anchor.x
|
|
157
|
+
: viewport.width - (anchor.x + anchor.width);
|
|
158
|
+
return Math.max(0, raw - offset - padding);
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Keep the requested side unless the opposite one is genuinely better.
|
|
162
|
+
*
|
|
163
|
+
* "Better" is not "has more room": flipping a menu that overflows by two
|
|
164
|
+
* pixels is more disruptive than letting it shift. So the opposite side has to
|
|
165
|
+
* both fit and beat the requested one. When neither fits, the roomier side
|
|
166
|
+
* wins and `availableHeight` lets the surface scroll inside it.
|
|
167
|
+
*/
|
|
168
|
+
function bestSide(requested, anchor, floating, viewport, offset, padding) {
|
|
169
|
+
const needed = isVertical(requested) ? floating.height : floating.width;
|
|
170
|
+
const here = spaceOn(requested, anchor, viewport, offset, padding);
|
|
171
|
+
if (here >= needed)
|
|
172
|
+
return requested;
|
|
173
|
+
const other = opposite(requested);
|
|
174
|
+
const there = spaceOn(other, anchor, viewport, offset, padding);
|
|
175
|
+
if (there >= needed)
|
|
176
|
+
return other;
|
|
177
|
+
return there > here ? other : requested;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Centre the arrow on the anchor, then pull it back off the corners.
|
|
181
|
+
*
|
|
182
|
+
* The arrow tracks the *anchor*, not the surface: once shift has slid the
|
|
183
|
+
* surface sideways, an arrow centred on the surface points at empty space.
|
|
184
|
+
* The clamp keeps it clear of the rounded corners, where it would otherwise
|
|
185
|
+
* poke out of the border radius.
|
|
186
|
+
*/
|
|
187
|
+
function arrowOffset(side, anchor, floating, coords, arrowSize, padding) {
|
|
188
|
+
if (isVertical(side)) {
|
|
189
|
+
const centre = anchor.x + anchor.width / 2 - coords.x - arrowSize / 2;
|
|
190
|
+
const limit = floating.width - arrowSize - padding;
|
|
191
|
+
return { x: clamp(centre, padding, Math.max(padding, limit)) };
|
|
192
|
+
}
|
|
193
|
+
const centre = anchor.y + anchor.height / 2 - coords.y - arrowSize / 2;
|
|
194
|
+
const limit = floating.height - arrowSize - padding;
|
|
195
|
+
return { y: clamp(centre, padding, Math.max(padding, limit)) };
|
|
196
|
+
}
|
|
197
|
+
function clamp(value, min, max) {
|
|
198
|
+
// `max` can fall below `min` when the surface is wider than the viewport.
|
|
199
|
+
// Pinning to `min` keeps the leading edge visible, which is the readable
|
|
200
|
+
// half; honouring `max` instead would push the start off screen.
|
|
201
|
+
if (max < min)
|
|
202
|
+
return min;
|
|
203
|
+
return Math.min(Math.max(value, min), max);
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Position `floating` against `anchor` and keep it there.
|
|
207
|
+
*
|
|
208
|
+
* Updates run on scroll (captured, so ancestor scroll containers are covered
|
|
209
|
+
* without walking the tree to find them) and on resize, plus whenever either
|
|
210
|
+
* element changes size where `ResizeObserver` exists. A menu whose content
|
|
211
|
+
* loads in and grows would otherwise stay positioned for its old height.
|
|
212
|
+
*/
|
|
213
|
+
export function autoPosition(anchor, floating, options = {}) {
|
|
214
|
+
function update() {
|
|
215
|
+
const anchorRect = anchor.getBoundingClientRect();
|
|
216
|
+
// Read per update, not once: a `dir` attribute can be toggled at runtime
|
|
217
|
+
// by a language switcher, and a surface open across that switch has to
|
|
218
|
+
// move with it.
|
|
219
|
+
const rtl = options.rtl ?? isRightToLeft(anchor);
|
|
220
|
+
if (options.matchWidth === true) {
|
|
221
|
+
floating.style.width = `${anchorRect.width}px`;
|
|
222
|
+
}
|
|
223
|
+
const floatingRect = floating.getBoundingClientRect();
|
|
224
|
+
const position = resolvePosition(anchorRect, floatingRect, { width: window.innerWidth, height: window.innerHeight }, { ...options, rtl });
|
|
225
|
+
floating.style.position = "fixed";
|
|
226
|
+
floating.style.left = `${Math.round(position.x)}px`;
|
|
227
|
+
floating.style.top = `${Math.round(position.y)}px`;
|
|
228
|
+
// Exposed as custom properties so the *stylesheet* decides what to do with
|
|
229
|
+
// them. shadcn's menus read the available height through
|
|
230
|
+
// `max-h-(--nebula-available-height)`, which keeps the scroll decision in
|
|
231
|
+
// CSS rather than hardcoding a pixel max-height from script.
|
|
232
|
+
floating.style.setProperty("--nebula-available-height", `${Math.round(position.availableHeight)}px`);
|
|
233
|
+
floating.style.setProperty("--nebula-anchor-width", `${Math.round(anchorRect.width)}px`);
|
|
234
|
+
floating.setAttribute("data-side", position.side);
|
|
235
|
+
floating.setAttribute("data-align", position.align);
|
|
236
|
+
const arrowEl = options.arrow?.();
|
|
237
|
+
if (arrowEl && position.arrow) {
|
|
238
|
+
const { x, y } = position.arrow;
|
|
239
|
+
arrowEl.style.position = "absolute";
|
|
240
|
+
arrowEl.style.left = x === undefined ? "" : `${Math.round(x)}px`;
|
|
241
|
+
arrowEl.style.top = y === undefined ? "" : `${Math.round(y)}px`;
|
|
242
|
+
}
|
|
243
|
+
options.onPositioned?.(position);
|
|
244
|
+
}
|
|
245
|
+
update();
|
|
246
|
+
const onScrollOrResize = () => update();
|
|
247
|
+
window.addEventListener("scroll", onScrollOrResize, true);
|
|
248
|
+
window.addEventListener("resize", onScrollOrResize);
|
|
249
|
+
const observer = observeSizes([anchor, floating], update);
|
|
250
|
+
let stopped = false;
|
|
251
|
+
return {
|
|
252
|
+
update,
|
|
253
|
+
stop() {
|
|
254
|
+
if (stopped)
|
|
255
|
+
return;
|
|
256
|
+
stopped = true;
|
|
257
|
+
window.removeEventListener("scroll", onScrollOrResize, true);
|
|
258
|
+
window.removeEventListener("resize", onScrollOrResize);
|
|
259
|
+
observer?.disconnect();
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Is this element laid out right to left?
|
|
265
|
+
*
|
|
266
|
+
* The computed style rather than the `dir` attribute, so an element inheriting
|
|
267
|
+
* the direction from `<html dir="rtl">` is answered correctly without every
|
|
268
|
+
* anchor having to carry the attribute itself.
|
|
269
|
+
*/
|
|
270
|
+
function isRightToLeft(element) {
|
|
271
|
+
if (typeof getComputedStyle !== "function")
|
|
272
|
+
return false;
|
|
273
|
+
return getComputedStyle(element).direction === "rtl";
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Watch elements for size changes, where the platform supports it.
|
|
277
|
+
*
|
|
278
|
+
* happy-dom (nebula's unit-test environment) has no `ResizeObserver`, and the
|
|
279
|
+
* scroll and resize listeners already cover the common cases, so its absence
|
|
280
|
+
* degrades rather than throws.
|
|
281
|
+
*/
|
|
282
|
+
function observeSizes(elements, onResize) {
|
|
283
|
+
if (typeof ResizeObserver !== "function")
|
|
284
|
+
return null;
|
|
285
|
+
const observer = new ResizeObserver(onResize);
|
|
286
|
+
for (const element of elements)
|
|
287
|
+
observer.observe(element);
|
|
288
|
+
return observer;
|
|
289
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Floating surface — the whole lifecycle of a popover-shaped overlay.
|
|
3
|
+
*
|
|
4
|
+
* Popover, Tooltip, HoverCard, DropdownMenu, ContextMenu, Menubar, Select and
|
|
5
|
+
* Combobox are the same machine with different contents and different opening
|
|
6
|
+
* gestures. That machine is: portal out of the tree, position against an
|
|
7
|
+
* anchor, register as a dismissable layer, optionally trap focus, and on close
|
|
8
|
+
* let the exit animation finish before removing anything.
|
|
9
|
+
*
|
|
10
|
+
* Five primitives in a fixed order, and the order is the part that is easy to
|
|
11
|
+
* get wrong. The layer must register *after* the content exists, or its
|
|
12
|
+
* containment check has nothing to test against and the opening click closes
|
|
13
|
+
* it again. The focus trap must run after the first position pass, or it
|
|
14
|
+
* focuses a surface still sitting at (0,0) and the browser scrolls to it.
|
|
15
|
+
*
|
|
16
|
+
* Written once here so eight components cannot each get that order slightly
|
|
17
|
+
* different.
|
|
18
|
+
*/
|
|
19
|
+
import { type TemplateResult } from "@c9up/aurora";
|
|
20
|
+
import { type DismissReason } from "./dismissable.js";
|
|
21
|
+
import { type Placement } from "./floating.js";
|
|
22
|
+
export interface FloatingSurfaceOptions {
|
|
23
|
+
/** What the surface is positioned against — usually the trigger. */
|
|
24
|
+
anchor: () => HTMLElement | null;
|
|
25
|
+
/** Should the surface be showing? Read reactively. */
|
|
26
|
+
open: () => boolean;
|
|
27
|
+
/** The surface wants to close. Flip whatever drives `open`. */
|
|
28
|
+
onClose: (reason: DismissReason) => void;
|
|
29
|
+
/** The surface's markup. Built fresh on each open. */
|
|
30
|
+
content: () => TemplateResult;
|
|
31
|
+
placement?: Placement;
|
|
32
|
+
offset?: number;
|
|
33
|
+
/** Size the surface to the anchor. Select and Combobox want this. */
|
|
34
|
+
matchWidth?: boolean;
|
|
35
|
+
/** Keep keyboard focus inside while open. */
|
|
36
|
+
trapFocus?: boolean;
|
|
37
|
+
/** Move focus into the surface on open. Implied by `trapFocus`. */
|
|
38
|
+
autoFocus?: boolean;
|
|
39
|
+
/** Where focus goes first. Defaults to the first focusable element. */
|
|
40
|
+
initialFocus?: (content: HTMLElement) => HTMLElement | null;
|
|
41
|
+
escapeKey?: boolean;
|
|
42
|
+
outsidePointer?: boolean;
|
|
43
|
+
/** Default `false` — a surface that opened on hover should not steal focus. */
|
|
44
|
+
outsideFocus?: boolean;
|
|
45
|
+
/** The surface is live and positioned. Wire item handlers here. */
|
|
46
|
+
onOpened?: (content: HTMLElement) => void;
|
|
47
|
+
/** The surface is about to be removed. */
|
|
48
|
+
onClosed?: () => void;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Wire a floating surface into the surrounding component.
|
|
52
|
+
*
|
|
53
|
+
* Call from a component setup. It registers its own mount and unmount hooks,
|
|
54
|
+
* so there is nothing to dispose by hand.
|
|
55
|
+
*/
|
|
56
|
+
export declare function floatingSurface(options: FloatingSurfaceOptions): void;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Floating surface — the whole lifecycle of a popover-shaped overlay.
|
|
4
|
+
*
|
|
5
|
+
* Popover, Tooltip, HoverCard, DropdownMenu, ContextMenu, Menubar, Select and
|
|
6
|
+
* Combobox are the same machine with different contents and different opening
|
|
7
|
+
* gestures. That machine is: portal out of the tree, position against an
|
|
8
|
+
* anchor, register as a dismissable layer, optionally trap focus, and on close
|
|
9
|
+
* let the exit animation finish before removing anything.
|
|
10
|
+
*
|
|
11
|
+
* Five primitives in a fixed order, and the order is the part that is easy to
|
|
12
|
+
* get wrong. The layer must register *after* the content exists, or its
|
|
13
|
+
* containment check has nothing to test against and the opening click closes
|
|
14
|
+
* it again. The focus trap must run after the first position pass, or it
|
|
15
|
+
* focuses a surface still sitting at (0,0) and the browser scrolls to it.
|
|
16
|
+
*
|
|
17
|
+
* Written once here so eight components cannot each get that order slightly
|
|
18
|
+
* different.
|
|
19
|
+
*/
|
|
20
|
+
import { effect, onMount } from "@c9up/aurora";
|
|
21
|
+
import { dismissable, } from "./dismissable.js";
|
|
22
|
+
import { autoPosition } from "./floating.js";
|
|
23
|
+
import { firstFocusable, focusSilently } from "./focusable.js";
|
|
24
|
+
import { focusTrap } from "./focusTrap.js";
|
|
25
|
+
import { portal } from "./portal.js";
|
|
26
|
+
import { onExitFinished } from "./presence.js";
|
|
27
|
+
/**
|
|
28
|
+
* Wire a floating surface into the surrounding component.
|
|
29
|
+
*
|
|
30
|
+
* Call from a component setup. It registers its own mount and unmount hooks,
|
|
31
|
+
* so there is nothing to dispose by hand.
|
|
32
|
+
*/
|
|
33
|
+
export function floatingSurface(options) {
|
|
34
|
+
let live = null;
|
|
35
|
+
function show() {
|
|
36
|
+
if (live !== null) {
|
|
37
|
+
// Already open and mid-exit: cancel the teardown and reuse the node
|
|
38
|
+
// rather than stacking a second copy on top of the one fading out.
|
|
39
|
+
live.cancelExit?.();
|
|
40
|
+
live.cancelExit = null;
|
|
41
|
+
live.element.setAttribute("data-state", "open");
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const anchor = options.anchor();
|
|
45
|
+
if (anchor === null)
|
|
46
|
+
return;
|
|
47
|
+
const mount = portal(options.content());
|
|
48
|
+
const element = mount.host.firstElementChild;
|
|
49
|
+
if (!(element instanceof HTMLElement)) {
|
|
50
|
+
mount.close();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
element.setAttribute("data-state", "open");
|
|
54
|
+
const position = autoPosition(anchor, element, {
|
|
55
|
+
placement: options.placement,
|
|
56
|
+
offset: options.offset,
|
|
57
|
+
matchWidth: options.matchWidth,
|
|
58
|
+
});
|
|
59
|
+
const layer = dismissable({
|
|
60
|
+
element: () => element,
|
|
61
|
+
exclude: () => [anchor],
|
|
62
|
+
onDismiss: options.onClose,
|
|
63
|
+
escapeKey: options.escapeKey,
|
|
64
|
+
outsidePointer: options.outsidePointer,
|
|
65
|
+
outsideFocus: options.outsideFocus ?? false,
|
|
66
|
+
});
|
|
67
|
+
let trap = null;
|
|
68
|
+
if (options.trapFocus === true) {
|
|
69
|
+
trap = focusTrap(element, {
|
|
70
|
+
initialFocus: () => options.initialFocus?.(element) ?? null,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
else if (options.autoFocus === true) {
|
|
74
|
+
focusSilently(options.initialFocus?.(element) ?? firstFocusable(element));
|
|
75
|
+
}
|
|
76
|
+
live = { mount, anchor, element, position, layer, trap, cancelExit: null };
|
|
77
|
+
options.onOpened?.(element);
|
|
78
|
+
}
|
|
79
|
+
function hide() {
|
|
80
|
+
const current = live;
|
|
81
|
+
if (current === null)
|
|
82
|
+
return;
|
|
83
|
+
// Detach behaviour first, then animate out. A surface that is fading
|
|
84
|
+
// should not still be answering Escape or repositioning itself.
|
|
85
|
+
current.layer.remove();
|
|
86
|
+
current.position.stop();
|
|
87
|
+
current.trap?.release();
|
|
88
|
+
returnFocusToAnchor(current.element, current.anchor);
|
|
89
|
+
current.element.setAttribute("data-state", "closed");
|
|
90
|
+
options.onClosed?.();
|
|
91
|
+
current.cancelExit = onExitFinished(current.element, () => {
|
|
92
|
+
current.mount.close();
|
|
93
|
+
if (live === current)
|
|
94
|
+
live = null;
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Hand focus back to the anchor when the surface held it.
|
|
99
|
+
*
|
|
100
|
+
* Dismissal by keyboard leaves focus inside a subtree that is about to be
|
|
101
|
+
* removed; without this it falls to `<body>` and the next Tab restarts at
|
|
102
|
+
* the top of the page. `dismissable` handles Escape from a *captured*
|
|
103
|
+
* document listener and stops propagation, so a component cannot do this in
|
|
104
|
+
* its own keydown handler — the event never reaches it.
|
|
105
|
+
*
|
|
106
|
+
* Guarded on containment, so a dismissal caused by clicking somewhere else
|
|
107
|
+
* does not steal focus back from wherever the user just put it. A trapped
|
|
108
|
+
* surface has already restored focus by the time this runs, and the guard
|
|
109
|
+
* makes it a no-op there too.
|
|
110
|
+
*/
|
|
111
|
+
function returnFocusToAnchor(surface, anchor) {
|
|
112
|
+
const active = document.activeElement;
|
|
113
|
+
if (!(active instanceof Node) || !surface.contains(active))
|
|
114
|
+
return;
|
|
115
|
+
focusSilently(anchor);
|
|
116
|
+
}
|
|
117
|
+
function teardown() {
|
|
118
|
+
const current = live;
|
|
119
|
+
if (current === null)
|
|
120
|
+
return;
|
|
121
|
+
live = null;
|
|
122
|
+
current.cancelExit?.();
|
|
123
|
+
current.layer.remove();
|
|
124
|
+
current.position.stop();
|
|
125
|
+
current.trap?.release();
|
|
126
|
+
current.mount.close();
|
|
127
|
+
}
|
|
128
|
+
onMount(() => {
|
|
129
|
+
const stop = effect(() => {
|
|
130
|
+
if (options.open())
|
|
131
|
+
show();
|
|
132
|
+
else
|
|
133
|
+
hide();
|
|
134
|
+
});
|
|
135
|
+
// The component is going away, so the exit animation has nobody left to
|
|
136
|
+
// play for — drop the node immediately rather than orphaning it in body.
|
|
137
|
+
return () => {
|
|
138
|
+
stop();
|
|
139
|
+
teardown();
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Focus trap — keyboard focus cannot leave a subtree while it is active.
|
|
3
|
+
*
|
|
4
|
+
* Required by every modal surface (Dialog, AlertDialog, Sheet, Drawer). The
|
|
5
|
+
* WAI-ARIA dialog pattern is specific about it: Tab from the last focusable
|
|
6
|
+
* element wraps to the first, Shift+Tab from the first wraps to the last, and
|
|
7
|
+
* focus returns to whatever opened the dialog when it closes.
|
|
8
|
+
*
|
|
9
|
+
* Two mechanisms, deliberately both:
|
|
10
|
+
*
|
|
11
|
+
* - A `keydown` handler implements the wrap. This is the path that runs for
|
|
12
|
+
* ordinary keyboard use, and it is the only one that can put focus on the
|
|
13
|
+
* *correct* end of the trap.
|
|
14
|
+
* - A `focusin` handler on the document catches everything else — a click on
|
|
15
|
+
* the page behind, a screen reader jumping by landmark, a browser find bar
|
|
16
|
+
* handing focus back somewhere else. It pulls focus back into the trap.
|
|
17
|
+
*
|
|
18
|
+
* The keydown handler alone leaks; the focusin handler alone produces the
|
|
19
|
+
* classic bug where Tab escapes and is yanked back a frame later, which reads
|
|
20
|
+
* as a flicker and confuses assistive technology.
|
|
21
|
+
*/
|
|
22
|
+
export interface FocusTrapOptions {
|
|
23
|
+
/**
|
|
24
|
+
* Where focus goes when the trap activates. Defaults to the first focusable
|
|
25
|
+
* descendant, then the container itself.
|
|
26
|
+
*/
|
|
27
|
+
initialFocus?: () => HTMLElement | null;
|
|
28
|
+
/**
|
|
29
|
+
* Where focus goes when it releases. Defaults to whatever was focused when
|
|
30
|
+
* the trap activated — which is nearly always the trigger.
|
|
31
|
+
*/
|
|
32
|
+
returnFocus?: () => HTMLElement | null;
|
|
33
|
+
/** Skip moving focus on activation. For surfaces that focus themselves. */
|
|
34
|
+
skipInitialFocus?: boolean;
|
|
35
|
+
}
|
|
36
|
+
export interface FocusTrap {
|
|
37
|
+
/** Tear the trap down and restore focus. Safe to call twice. */
|
|
38
|
+
release(): void;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Trap focus inside `container` until the returned handle is released.
|
|
42
|
+
*
|
|
43
|
+
* The container is made focusable with `tabindex="-1"` if it is not already,
|
|
44
|
+
* so an empty surface still has somewhere to put focus. Without it, focus
|
|
45
|
+
* would fall to `<body>` and the next Tab would land outside the trap.
|
|
46
|
+
*/
|
|
47
|
+
export declare function focusTrap(container: HTMLElement, options?: FocusTrapOptions): FocusTrap;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Focus trap — keyboard focus cannot leave a subtree while it is active.
|
|
4
|
+
*
|
|
5
|
+
* Required by every modal surface (Dialog, AlertDialog, Sheet, Drawer). The
|
|
6
|
+
* WAI-ARIA dialog pattern is specific about it: Tab from the last focusable
|
|
7
|
+
* element wraps to the first, Shift+Tab from the first wraps to the last, and
|
|
8
|
+
* focus returns to whatever opened the dialog when it closes.
|
|
9
|
+
*
|
|
10
|
+
* Two mechanisms, deliberately both:
|
|
11
|
+
*
|
|
12
|
+
* - A `keydown` handler implements the wrap. This is the path that runs for
|
|
13
|
+
* ordinary keyboard use, and it is the only one that can put focus on the
|
|
14
|
+
* *correct* end of the trap.
|
|
15
|
+
* - A `focusin` handler on the document catches everything else — a click on
|
|
16
|
+
* the page behind, a screen reader jumping by landmark, a browser find bar
|
|
17
|
+
* handing focus back somewhere else. It pulls focus back into the trap.
|
|
18
|
+
*
|
|
19
|
+
* The keydown handler alone leaks; the focusin handler alone produces the
|
|
20
|
+
* classic bug where Tab escapes and is yanked back a frame later, which reads
|
|
21
|
+
* as a flicker and confuses assistive technology.
|
|
22
|
+
*/
|
|
23
|
+
import { firstFocusable, focusableWithin, focusSilently } from "./focusable.js";
|
|
24
|
+
/**
|
|
25
|
+
* Trap focus inside `container` until the returned handle is released.
|
|
26
|
+
*
|
|
27
|
+
* The container is made focusable with `tabindex="-1"` if it is not already,
|
|
28
|
+
* so an empty surface still has somewhere to put focus. Without it, focus
|
|
29
|
+
* would fall to `<body>` and the next Tab would land outside the trap.
|
|
30
|
+
*/
|
|
31
|
+
export function focusTrap(container, options = {}) {
|
|
32
|
+
const previouslyFocused = activeElement();
|
|
33
|
+
if (!container.hasAttribute("tabindex")) {
|
|
34
|
+
container.setAttribute("tabindex", "-1");
|
|
35
|
+
}
|
|
36
|
+
if (!options.skipInitialFocus) {
|
|
37
|
+
const target = options.initialFocus?.() ?? firstFocusable(container) ?? container;
|
|
38
|
+
focusSilently(target);
|
|
39
|
+
}
|
|
40
|
+
function onKeyDown(event) {
|
|
41
|
+
if (event.key !== "Tab")
|
|
42
|
+
return;
|
|
43
|
+
const focusables = focusableWithin(container);
|
|
44
|
+
if (focusables.length === 0) {
|
|
45
|
+
// Nothing to cycle through: hold focus on the container so Tab cannot
|
|
46
|
+
// walk out into the page behind.
|
|
47
|
+
event.preventDefault();
|
|
48
|
+
focusSilently(container);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const first = focusables[0];
|
|
52
|
+
const last = focusables[focusables.length - 1];
|
|
53
|
+
const current = activeElement();
|
|
54
|
+
if (event.shiftKey && (current === first || current === container)) {
|
|
55
|
+
event.preventDefault();
|
|
56
|
+
focusSilently(last);
|
|
57
|
+
}
|
|
58
|
+
else if (!event.shiftKey && current === last) {
|
|
59
|
+
event.preventDefault();
|
|
60
|
+
focusSilently(first);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Pull focus back when it lands outside by any route other than Tab.
|
|
65
|
+
*
|
|
66
|
+
* Guarded on `contains`, and only ever moves focus *into* the trap, so it
|
|
67
|
+
* cannot fight a nested trap: an inner dialog's container is a descendant
|
|
68
|
+
* of the outer one, so the outer handler sees the focus as already inside.
|
|
69
|
+
*/
|
|
70
|
+
function onFocusIn(event) {
|
|
71
|
+
const target = event.target;
|
|
72
|
+
if (!(target instanceof Node))
|
|
73
|
+
return;
|
|
74
|
+
if (container.contains(target))
|
|
75
|
+
return;
|
|
76
|
+
focusSilently(firstFocusable(container) ?? container);
|
|
77
|
+
}
|
|
78
|
+
document.addEventListener("keydown", onKeyDown, true);
|
|
79
|
+
document.addEventListener("focusin", onFocusIn, true);
|
|
80
|
+
let released = false;
|
|
81
|
+
return {
|
|
82
|
+
release() {
|
|
83
|
+
if (released)
|
|
84
|
+
return;
|
|
85
|
+
released = true;
|
|
86
|
+
document.removeEventListener("keydown", onKeyDown, true);
|
|
87
|
+
document.removeEventListener("focusin", onFocusIn, true);
|
|
88
|
+
const target = options.returnFocus?.() ?? previouslyFocused;
|
|
89
|
+
// Only restore focus if it is still inside the trap. If something else
|
|
90
|
+
// has already claimed it — a second dialog, a toast action — stealing it
|
|
91
|
+
// back would be the wrong call.
|
|
92
|
+
if (container.contains(activeElement()))
|
|
93
|
+
focusSilently(target);
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/** The focused element, narrowed to something we can call `.focus()` on. */
|
|
98
|
+
function activeElement() {
|
|
99
|
+
const active = document.activeElement;
|
|
100
|
+
return active instanceof HTMLElement ? active : null;
|
|
101
|
+
}
|