@domicile-desktop/component-library 0.0.0-alpha-96a82ac5b3d7 → 0.0.0-alpha-e01fbd8cc446
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/package.json +4 -3
- package/src/Autocomplete/Autocomplete.tsx +82 -76
- package/src/ContextMenu/ContextMenu.tsx +34 -25
- package/src/Field/FieldErrorPopover.tsx +3 -0
- package/src/Popover/Popover.tsx +17 -12
- package/src/PopupBoundary/PopupBoundary.tsx +50 -0
- package/src/Select/Select.tsx +7 -5
- package/src/Tooltip/Tooltip.tsx +13 -11
package/README.md
CHANGED
|
@@ -37,6 +37,7 @@ Each export is `@domicile-desktop/component-library/<name>`.
|
|
|
37
37
|
| `PortalDialogs` | Every dialog applications ask for through the desktop portal. Mount one per shell with `host={domicile}`; it refuses kinds it cannot draw. `omitScreenCasts` leaves screen casts out of its indicator. See [PORTALS.md](/docs/PORTALS.md). |
|
|
38
38
|
| `source-name` | `sourceName`: how a screen cast's source is named. |
|
|
39
39
|
| `Popover` | Non-modal panel anchored to the control that opened it. |
|
|
40
|
+
| `PopupBoundary` | Keeps every popup inside it (tooltips, popovers, menus, lists) within a box, such as a window's frame. Outside one, popups stay on their `Screen`. `Popover` and `Tooltip` take `spansWindows` to leave it. |
|
|
40
41
|
| `Provider` | base-ui `DirectionProvider` wrapper. Every app roots its tree in it. |
|
|
41
42
|
| `Screen` | Renders its children once per selected display. |
|
|
42
43
|
| `DisplayProvider` | Supplies the host's displays to `Screen`. |
|
package/package.json
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
"dependencies": {
|
|
3
3
|
"@base-ui/react": "^1.8.0",
|
|
4
4
|
"@cprussin/option-result": "^2.0.1",
|
|
5
|
-
"@domicile-desktop/sdk": "0.0.0-alpha-
|
|
6
|
-
"@domicile-desktop/system-apps": "0.0.0-alpha-
|
|
5
|
+
"@domicile-desktop/sdk": "0.0.0-alpha-e01fbd8cc446",
|
|
6
|
+
"@domicile-desktop/system-apps": "0.0.0-alpha-e01fbd8cc446",
|
|
7
7
|
"@pandacss/dev": "^1.12.1",
|
|
8
8
|
"@phosphor-icons/react": "^2.1.10"
|
|
9
9
|
},
|
|
@@ -60,6 +60,7 @@
|
|
|
60
60
|
"./media-theme-source": "./src/ThemeSwitch/media-theme-source.ts",
|
|
61
61
|
"./Notice": "./src/Notice/Notice.tsx",
|
|
62
62
|
"./Popover": "./src/Popover/Popover.tsx",
|
|
63
|
+
"./PopupBoundary": "./src/PopupBoundary/PopupBoundary.tsx",
|
|
63
64
|
"./PortalDialogs": "./src/PortalDialogs/PortalDialogs.tsx",
|
|
64
65
|
"./Provider": "./src/Provider/Provider.tsx",
|
|
65
66
|
"./pandacss-preset": "./src/pandacss-preset.ts",
|
|
@@ -119,5 +120,5 @@
|
|
|
119
120
|
"test:unit": "bun test"
|
|
120
121
|
},
|
|
121
122
|
"type": "module",
|
|
122
|
-
"version": "0.0.0-alpha-
|
|
123
|
+
"version": "0.0.0-alpha-e01fbd8cc446"
|
|
123
124
|
}
|
|
@@ -8,6 +8,7 @@ import { focusControlOnMouseDown } from "../_control/focus";
|
|
|
8
8
|
import { PrefixIconStack } from "../_control/PrefixIconStack";
|
|
9
9
|
import { wrapperBase } from "../_control/wrapperBase";
|
|
10
10
|
import type { ExtendProps } from "../extend-props";
|
|
11
|
+
import { usePopupBoundary } from "../PopupBoundary/PopupBoundary";
|
|
11
12
|
|
|
12
13
|
export { SIZES, type Size } from "../control-sizes";
|
|
13
14
|
|
|
@@ -84,84 +85,89 @@ export const Autocomplete = <V,>({
|
|
|
84
85
|
value,
|
|
85
86
|
width,
|
|
86
87
|
...inputProps
|
|
87
|
-
}: Props<V>) =>
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
{/* biome-ignore lint/a11y/noStaticElementInteractions: focuses the field when the wrapper's padding is clicked; mirrors native <label> behavior without a second label polluting the accessible name */}
|
|
103
|
-
<div
|
|
104
|
-
className={cx(
|
|
105
|
-
control({ size }),
|
|
106
|
-
wrapperBase,
|
|
107
|
-
wrapperStyles({ rounded, size }),
|
|
108
|
-
)}
|
|
109
|
-
onMouseDown={focusControlOnMouseDown}
|
|
110
|
-
style={controlSizingStyle({ width })}
|
|
88
|
+
}: Props<V>) => {
|
|
89
|
+
const { attach, boundary } = usePopupBoundary(false);
|
|
90
|
+
return (
|
|
91
|
+
<BaseAutocomplete.Root<Suggestion<V>>
|
|
92
|
+
autoHighlight={autoHighlight}
|
|
93
|
+
disabled={disabled}
|
|
94
|
+
items={suggestions}
|
|
95
|
+
itemToStringValue={(suggestion) => suggestion.text}
|
|
96
|
+
// `inline` skips base-ui's filtering; the caller already filtered
|
|
97
|
+
// `suggestions`.
|
|
98
|
+
mode="inline"
|
|
99
|
+
onValueChange={(next) => {
|
|
100
|
+
onValueChange?.(next);
|
|
101
|
+
}}
|
|
102
|
+
value={value}
|
|
111
103
|
>
|
|
112
|
-
{
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
<span className={affixStyles}>{suffixButtons}</span>
|
|
124
|
-
)}
|
|
125
|
-
</div>
|
|
126
|
-
<BaseAutocomplete.Portal>
|
|
127
|
-
<BaseAutocomplete.Positioner
|
|
128
|
-
align="start"
|
|
129
|
-
className={positionerStyles}
|
|
130
|
-
side="bottom"
|
|
131
|
-
sideOffset={6}
|
|
104
|
+
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: focuses the field when the wrapper's padding is clicked; mirrors native <label> behavior without a second label polluting the accessible name */}
|
|
105
|
+
{/* biome-ignore lint/a11y/noStaticElementInteractions: focuses the field when the wrapper's padding is clicked; mirrors native <label> behavior without a second label polluting the accessible name */}
|
|
106
|
+
<div
|
|
107
|
+
className={cx(
|
|
108
|
+
control({ size }),
|
|
109
|
+
wrapperBase,
|
|
110
|
+
wrapperStyles({ rounded, size }),
|
|
111
|
+
)}
|
|
112
|
+
onMouseDown={focusControlOnMouseDown}
|
|
113
|
+
ref={attach}
|
|
114
|
+
style={controlSizingStyle({ width })}
|
|
132
115
|
>
|
|
133
|
-
|
|
134
|
-
<
|
|
135
|
-
|
|
136
|
-
</
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
116
|
+
{prefixButtons === undefined ? (
|
|
117
|
+
<PrefixIconStack prefixIcon={prefixIcon} size={size} />
|
|
118
|
+
) : (
|
|
119
|
+
<span className={affixStyles}>{prefixButtons}</span>
|
|
120
|
+
)}
|
|
121
|
+
<BaseAutocomplete.Input
|
|
122
|
+
{...inputProps}
|
|
123
|
+
className={inputStyles}
|
|
124
|
+
data-control=""
|
|
125
|
+
/>
|
|
126
|
+
{suffixButtons !== undefined && (
|
|
127
|
+
<span className={affixStyles}>{suffixButtons}</span>
|
|
128
|
+
)}
|
|
129
|
+
</div>
|
|
130
|
+
<BaseAutocomplete.Portal>
|
|
131
|
+
<BaseAutocomplete.Positioner
|
|
132
|
+
align="start"
|
|
133
|
+
className={positionerStyles}
|
|
134
|
+
collisionBoundary={boundary}
|
|
135
|
+
side="bottom"
|
|
136
|
+
sideOffset={6}
|
|
137
|
+
>
|
|
138
|
+
<BaseAutocomplete.Popup className={popupStyles}>
|
|
139
|
+
<BaseAutocomplete.Empty className={emptyStyles}>
|
|
140
|
+
{emptyMessage}
|
|
141
|
+
</BaseAutocomplete.Empty>
|
|
142
|
+
<BaseAutocomplete.List className={listStyles}>
|
|
143
|
+
{suggestions.map((suggestion, index) => (
|
|
144
|
+
<BaseAutocomplete.Item
|
|
145
|
+
className={itemStyles}
|
|
146
|
+
index={index}
|
|
147
|
+
key={index}
|
|
148
|
+
onClick={() => {
|
|
149
|
+
onSuggestionTaken?.(suggestion.value);
|
|
150
|
+
}}
|
|
151
|
+
value={suggestion}
|
|
152
|
+
>
|
|
153
|
+
{suggestion.icon !== undefined && (
|
|
154
|
+
<span className={itemIconStyles}>{suggestion.icon}</span>
|
|
155
|
+
)}
|
|
156
|
+
<span className={itemLabelStyles}>{suggestion.label}</span>
|
|
157
|
+
{suggestion.description !== undefined && (
|
|
158
|
+
<span className={itemDescriptionStyles}>
|
|
159
|
+
{suggestion.description}
|
|
160
|
+
</span>
|
|
161
|
+
)}
|
|
162
|
+
</BaseAutocomplete.Item>
|
|
163
|
+
))}
|
|
164
|
+
</BaseAutocomplete.List>
|
|
165
|
+
</BaseAutocomplete.Popup>
|
|
166
|
+
</BaseAutocomplete.Positioner>
|
|
167
|
+
</BaseAutocomplete.Portal>
|
|
168
|
+
</BaseAutocomplete.Root>
|
|
169
|
+
);
|
|
170
|
+
};
|
|
165
171
|
|
|
166
172
|
const inputStyles = css({
|
|
167
173
|
"&::placeholder": { color: "muted" },
|
|
@@ -8,6 +8,7 @@ import type { KeyboardEvent, ReactNode } from "react";
|
|
|
8
8
|
import { useMemo } from "react";
|
|
9
9
|
import { css } from "../../styled-system/css";
|
|
10
10
|
import type { ExtendProps } from "../extend-props";
|
|
11
|
+
import { usePopupBoundary } from "../PopupBoundary/PopupBoundary";
|
|
11
12
|
|
|
12
13
|
/** A point in the viewport, in CSS pixels. */
|
|
13
14
|
export type Point = { x: number; y: number };
|
|
@@ -29,7 +30,7 @@ type Props = ExtendProps<
|
|
|
29
30
|
* The caller sets `open` and `at`, and hears it close (a choice, Escape or an
|
|
30
31
|
* outside press) through `onOpenChange`. The press may not be in this
|
|
31
32
|
* document, as with a click in a `<webview>`. The menu takes the keyboard while
|
|
32
|
-
* open.
|
|
33
|
+
* open. It stays inside its `PopupBoundary`, such as its window.
|
|
33
34
|
*
|
|
34
35
|
* A letter chooses the first enabled item in the open menu or submenu whose
|
|
35
36
|
* mnemonic it is, and opens a submenu. Other letters go to base-ui's
|
|
@@ -45,6 +46,7 @@ const ContextMenuComponent = ({ at, children, label, ...rootProps }: Props) => {
|
|
|
45
46
|
}),
|
|
46
47
|
[at.x, at.y],
|
|
47
48
|
);
|
|
49
|
+
const { boundary } = usePopupBoundary(false);
|
|
48
50
|
return (
|
|
49
51
|
// The context menu's root, not `Menu.Root`: a `Menu.Root` with no trigger
|
|
50
52
|
// closes when one of its submenus opens.
|
|
@@ -54,6 +56,7 @@ const ContextMenuComponent = ({ at, children, label, ...rootProps }: Props) => {
|
|
|
54
56
|
align="start"
|
|
55
57
|
anchor={anchor}
|
|
56
58
|
className={positionerStyles}
|
|
59
|
+
collisionBoundary={boundary}
|
|
57
60
|
side="bottom"
|
|
58
61
|
sideOffset={0}
|
|
59
62
|
>
|
|
@@ -190,31 +193,37 @@ const Submenu = ({
|
|
|
190
193
|
label,
|
|
191
194
|
mnemonic,
|
|
192
195
|
...rootProps
|
|
193
|
-
}: SubmenuProps) =>
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
className={
|
|
210
|
-
|
|
196
|
+
}: SubmenuProps) => {
|
|
197
|
+
const { boundary } = usePopupBoundary(false);
|
|
198
|
+
return (
|
|
199
|
+
<BaseMenu.SubmenuRoot {...rootProps}>
|
|
200
|
+
<BaseMenu.SubmenuTrigger
|
|
201
|
+
className={itemStyles}
|
|
202
|
+
data-mnemonic={mnemonicKey(label, mnemonic)}
|
|
203
|
+
disabled={disabled}
|
|
204
|
+
>
|
|
205
|
+
<Label icon={icon} mnemonic={mnemonic}>
|
|
206
|
+
{label}
|
|
207
|
+
</Label>
|
|
208
|
+
<CaretRightIcon className={caretStyles} />
|
|
209
|
+
</BaseMenu.SubmenuTrigger>
|
|
210
|
+
<BaseMenu.Portal>
|
|
211
|
+
<BaseMenu.Positioner
|
|
212
|
+
className={positionerStyles}
|
|
213
|
+
collisionBoundary={boundary}
|
|
211
214
|
>
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
215
|
+
<BaseMenu.Popup
|
|
216
|
+
aria-label={label}
|
|
217
|
+
className={popupStyles}
|
|
218
|
+
onKeyDown={chooseByMnemonic}
|
|
219
|
+
>
|
|
220
|
+
{children}
|
|
221
|
+
</BaseMenu.Popup>
|
|
222
|
+
</BaseMenu.Positioner>
|
|
223
|
+
</BaseMenu.Portal>
|
|
224
|
+
</BaseMenu.SubmenuRoot>
|
|
225
|
+
);
|
|
226
|
+
};
|
|
218
227
|
|
|
219
228
|
/** A line between groups of items. */
|
|
220
229
|
const Separator = () => <BaseMenu.Separator className={separatorStyles} />;
|
|
@@ -4,6 +4,7 @@ import type { ReactNode } from "react";
|
|
|
4
4
|
import { useEffect, useState } from "react";
|
|
5
5
|
|
|
6
6
|
import { css } from "../../styled-system/css";
|
|
7
|
+
import { usePopupBoundary } from "../PopupBoundary/PopupBoundary";
|
|
7
8
|
|
|
8
9
|
type Props = {
|
|
9
10
|
anchor: HTMLElement | undefined;
|
|
@@ -23,6 +24,7 @@ export const FieldErrorPopover = ({ anchor, error }: Props) => {
|
|
|
23
24
|
setShownError(error);
|
|
24
25
|
}
|
|
25
26
|
}, [error]);
|
|
27
|
+
const { boundary } = usePopupBoundary(false);
|
|
26
28
|
return (
|
|
27
29
|
<BasePopover.Root open={error !== undefined}>
|
|
28
30
|
<BasePopover.Portal>
|
|
@@ -34,6 +36,7 @@ export const FieldErrorPopover = ({ anchor, error }: Props) => {
|
|
|
34
36
|
fallbackAxisSide: "none",
|
|
35
37
|
side: "none",
|
|
36
38
|
}}
|
|
39
|
+
collisionBoundary={boundary}
|
|
37
40
|
side="right"
|
|
38
41
|
sideOffset={8}
|
|
39
42
|
>
|
package/src/Popover/Popover.tsx
CHANGED
|
@@ -6,12 +6,12 @@ import {
|
|
|
6
6
|
useContext,
|
|
7
7
|
useEffect,
|
|
8
8
|
useRef,
|
|
9
|
-
useState,
|
|
10
9
|
} from "react";
|
|
11
10
|
import { css, cva } from "../../styled-system/css";
|
|
12
11
|
import { flex } from "../../styled-system/patterns";
|
|
13
12
|
import { useStableRef } from "../_control/useStableRef";
|
|
14
13
|
import type { ExtendProps } from "../extend-props";
|
|
14
|
+
import { usePopupBoundary } from "../PopupBoundary/PopupBoundary";
|
|
15
15
|
import { Tooltip } from "../Tooltip/Tooltip";
|
|
16
16
|
|
|
17
17
|
export const { createHandle } = BasePopover;
|
|
@@ -51,6 +51,8 @@ type Props = ExtendProps<
|
|
|
51
51
|
*/
|
|
52
52
|
keepMounted?: boolean | undefined;
|
|
53
53
|
side?: Side | undefined;
|
|
54
|
+
/** Let the panel leave its `PopupBoundary`, such as its window, for its screen. */
|
|
55
|
+
spansWindows?: boolean | undefined;
|
|
54
56
|
title?: ReactNode | undefined;
|
|
55
57
|
/**
|
|
56
58
|
* `overPhoto` is a translucent pill with white text, for panels opened
|
|
@@ -72,9 +74,10 @@ type Props = ExtendProps<
|
|
|
72
74
|
* A non-modal panel anchored to its trigger, for detail the trigger has no
|
|
73
75
|
* room for. Use `ModalDialog` when the user must respond first.
|
|
74
76
|
*
|
|
75
|
-
* Stays on the `<Screen>` region of the
|
|
76
|
-
*
|
|
77
|
-
* through a `handle` from `createHandle()`, as
|
|
77
|
+
* Stays inside its `PopupBoundary`, else on the `<Screen>` region of the
|
|
78
|
+
* trigger that opened it, so it never spans two windows or two monitors.
|
|
79
|
+
* Triggers elsewhere open it through a `handle` from `createHandle()`, as
|
|
80
|
+
* `Popover.Trigger`.
|
|
78
81
|
*
|
|
79
82
|
* Closes on an outside press, Escape, or focus leaving it. The focus check
|
|
80
83
|
* catches presses inside out-of-process frames, which the document can't
|
|
@@ -88,6 +91,7 @@ const PopoverComponent = ({
|
|
|
88
91
|
keepMounted = false,
|
|
89
92
|
onOpenChange,
|
|
90
93
|
side = "bottom",
|
|
94
|
+
spansWindows = false,
|
|
91
95
|
title,
|
|
92
96
|
tone = "card",
|
|
93
97
|
tooltip,
|
|
@@ -99,13 +103,13 @@ const PopoverComponent = ({
|
|
|
99
103
|
const actions = actionsRef ?? ownActions;
|
|
100
104
|
const [popupRef, setPopupRef] = useStableRef<HTMLDivElement>();
|
|
101
105
|
const [triggerRef, setTriggerRef] = useStableRef<HTMLElement>();
|
|
102
|
-
const
|
|
106
|
+
const { attach, boundary } = usePopupBoundary(spansWindows);
|
|
103
107
|
const attachTrigger = useCallback<RefCallback<HTMLElement>>(
|
|
104
108
|
(element) => {
|
|
105
|
-
|
|
109
|
+
attach(element);
|
|
106
110
|
return setTriggerRef(element);
|
|
107
111
|
},
|
|
108
|
-
[setTriggerRef],
|
|
112
|
+
[attach, setTriggerRef],
|
|
109
113
|
);
|
|
110
114
|
// The trigger that opened it, which may be outside this component.
|
|
111
115
|
const opener = useRef<Element | undefined>(undefined);
|
|
@@ -117,7 +121,7 @@ const PopoverComponent = ({
|
|
|
117
121
|
onOpenChange={(open, details) => {
|
|
118
122
|
if (open && details.trigger !== undefined) {
|
|
119
123
|
opener.current = details.trigger;
|
|
120
|
-
|
|
124
|
+
attach(details.trigger);
|
|
121
125
|
}
|
|
122
126
|
onOpenChange?.(open, details);
|
|
123
127
|
}}
|
|
@@ -135,7 +139,7 @@ const PopoverComponent = ({
|
|
|
135
139
|
<BasePopover.Positioner
|
|
136
140
|
align={align}
|
|
137
141
|
className={positionerStyles}
|
|
138
|
-
collisionBoundary={
|
|
142
|
+
collisionBoundary={boundary}
|
|
139
143
|
onFocus={(event) => {
|
|
140
144
|
within.current = event.nativeEvent;
|
|
141
145
|
}}
|
|
@@ -194,7 +198,7 @@ const popupStyles = flex({
|
|
|
194
198
|
},
|
|
195
199
|
// Clips content to the panel's rounded corners.
|
|
196
200
|
"&[data-flush]": {
|
|
197
|
-
maxInlineSize: "90vw",
|
|
201
|
+
maxInlineSize: "min(90vw, var(--available-width))",
|
|
198
202
|
overflow: "hidden",
|
|
199
203
|
paddingBlock: 0,
|
|
200
204
|
paddingInline: 0,
|
|
@@ -219,7 +223,7 @@ const popupStyles = flex({
|
|
|
219
223
|
textShadow: "textOverPhoto",
|
|
220
224
|
},
|
|
221
225
|
"&[data-wide]": {
|
|
222
|
-
maxInlineSize: "min(30rem, 90vw)",
|
|
226
|
+
maxInlineSize: "min(30rem, 90vw, var(--available-width))",
|
|
223
227
|
},
|
|
224
228
|
backgroundColor: "card",
|
|
225
229
|
border: "1px solid {colors.border}",
|
|
@@ -227,7 +231,8 @@ const popupStyles = flex({
|
|
|
227
231
|
boxShadow: "lifted",
|
|
228
232
|
color: "foreground",
|
|
229
233
|
direction: "column",
|
|
230
|
-
|
|
234
|
+
// `--available-width` is the room its `PopupBoundary` leaves it.
|
|
235
|
+
maxInlineSize: "min({spacing.88}, 90vw, var(--available-width))",
|
|
231
236
|
opacity: 1,
|
|
232
237
|
outlineStyle: "none",
|
|
233
238
|
paddingBlock: 3,
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ReactNode, RefCallback } from "react";
|
|
2
|
+
import { createContext, useCallback, useContext, useState } from "react";
|
|
3
|
+
|
|
4
|
+
/** A box in page pixels. */
|
|
5
|
+
export type Box = { height: number; width: number; x: number; y: number };
|
|
6
|
+
|
|
7
|
+
type Props = {
|
|
8
|
+
/** The box popups inside stay within, such as a window's frame. */
|
|
9
|
+
box: Box | undefined;
|
|
10
|
+
children: ReactNode;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Keeps every popup opened inside it within `box`: tooltips, popovers, menus
|
|
15
|
+
* and lists. A shell wraps each window in one, so a popup never spans two
|
|
16
|
+
* windows. Outside any, a popup stays on its trigger's `<Screen>` region.
|
|
17
|
+
*
|
|
18
|
+
* The innermost one wins. A popup passed `spansWindows` leaves it for its
|
|
19
|
+
* trigger's screen.
|
|
20
|
+
*/
|
|
21
|
+
export const PopupBoundary = ({ box, children }: Props) => (
|
|
22
|
+
<Bounds value={box}>{children}</Bounds>
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const Bounds = createContext<Box | undefined>(undefined);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Where a popup stays: the nearest `PopupBoundary`, else its trigger's
|
|
29
|
+
* `<Screen>` region, else the viewport. `spansWindows` skips the
|
|
30
|
+
* `PopupBoundary`.
|
|
31
|
+
*
|
|
32
|
+
* `attach` takes the trigger, from a ref or from whichever trigger opened the
|
|
33
|
+
* popup.
|
|
34
|
+
*/
|
|
35
|
+
export const usePopupBoundary = (
|
|
36
|
+
spansWindows: boolean,
|
|
37
|
+
): {
|
|
38
|
+
attach: (trigger: Element | null) => void;
|
|
39
|
+
boundary: Box | Element | undefined;
|
|
40
|
+
} => {
|
|
41
|
+
const box = useContext(Bounds);
|
|
42
|
+
const [screen, setScreen] = useState<Element | undefined>(undefined);
|
|
43
|
+
const attach = useCallback<RefCallback<Element>>((trigger) => {
|
|
44
|
+
setScreen(trigger?.closest("[data-screen]") ?? undefined);
|
|
45
|
+
}, []);
|
|
46
|
+
return {
|
|
47
|
+
attach,
|
|
48
|
+
boundary: box === undefined || spansWindows ? screen : box,
|
|
49
|
+
};
|
|
50
|
+
};
|
package/src/Select/Select.tsx
CHANGED
|
@@ -9,8 +9,10 @@ import { control } from "../../styled-system/recipes";
|
|
|
9
9
|
import { controlSizingStyle } from "../_control/controlSizingStyle";
|
|
10
10
|
import { focusControlOnMouseDown } from "../_control/focus";
|
|
11
11
|
import { PrefixIconStack } from "../_control/PrefixIconStack";
|
|
12
|
+
import { useStableRef } from "../_control/useStableRef";
|
|
12
13
|
import { wrapperBase } from "../_control/wrapperBase";
|
|
13
14
|
import type { ExtendProps } from "../extend-props";
|
|
15
|
+
import { usePopupBoundary } from "../PopupBoundary/PopupBoundary";
|
|
14
16
|
|
|
15
17
|
export { SIZES, type Size } from "../control-sizes";
|
|
16
18
|
|
|
@@ -33,8 +35,7 @@ export type SelectOption<V> = {
|
|
|
33
35
|
type CommonProps<V> = Partial<ControlVariant> & {
|
|
34
36
|
/**
|
|
35
37
|
* Area the list stays inside, shifting and narrowing to fit. Defaults to
|
|
36
|
-
*
|
|
37
|
-
* monitor, so a panel passes its own span to keep lists on its monitor.
|
|
38
|
+
* its `PopupBoundary`, else its `<Screen>` region, as `Popover` does.
|
|
38
39
|
*/
|
|
39
40
|
boundary?: Boundary | undefined;
|
|
40
41
|
/**
|
|
@@ -97,7 +98,8 @@ export const Select = <V,>({
|
|
|
97
98
|
>(undefined);
|
|
98
99
|
// The popup anchors to the wrapper, not the trigger, so `--anchor-width`
|
|
99
100
|
// is the visible field's width.
|
|
100
|
-
const
|
|
101
|
+
const { attach, boundary: around } = usePopupBoundary(false);
|
|
102
|
+
const [wrapperRef, setWrapperRef] = useStableRef<HTMLDivElement>(attach);
|
|
101
103
|
return (
|
|
102
104
|
<BaseSelect.Root<V>
|
|
103
105
|
defaultValue={defaultValue}
|
|
@@ -160,7 +162,7 @@ export const Select = <V,>({
|
|
|
160
162
|
}
|
|
161
163
|
onClick={openTriggerOnWrapperClick}
|
|
162
164
|
onMouseDown={focusControlOnMouseDown}
|
|
163
|
-
ref={
|
|
165
|
+
ref={setWrapperRef}
|
|
164
166
|
style={controlSizingStyle({ width })}
|
|
165
167
|
>
|
|
166
168
|
<PrefixIconStack prefixIcon={prefixIcon} size={size} />
|
|
@@ -184,7 +186,7 @@ export const Select = <V,>({
|
|
|
184
186
|
alignItemWithTrigger={false}
|
|
185
187
|
anchor={wrapperRef}
|
|
186
188
|
className={positionerStyles}
|
|
187
|
-
collisionBoundary={boundary}
|
|
189
|
+
collisionBoundary={boundary ?? around}
|
|
188
190
|
side="bottom"
|
|
189
191
|
sideOffset={6}
|
|
190
192
|
>
|
package/src/Tooltip/Tooltip.tsx
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { Tooltip as BaseTooltip } from "@base-ui/react/tooltip";
|
|
2
|
-
import type { ReactElement, ReactNode
|
|
3
|
-
import {
|
|
2
|
+
import type { ReactElement, ReactNode } from "react";
|
|
3
|
+
import { useContext } from "react";
|
|
4
4
|
|
|
5
5
|
import { css } from "../../styled-system/css";
|
|
6
6
|
import type { ExtendProps } from "../extend-props";
|
|
7
7
|
import type { Alignment, Side, Tone } from "../Popover/Popover";
|
|
8
8
|
import { PopoverContainer } from "../Popover/Popover";
|
|
9
|
+
import { usePopupBoundary } from "../PopupBoundary/PopupBoundary";
|
|
9
10
|
|
|
10
11
|
type Props = ExtendProps<
|
|
11
12
|
typeof BaseTooltip.Root,
|
|
@@ -15,6 +16,8 @@ type Props = ExtendProps<
|
|
|
15
16
|
/** How long the pointer rests on the trigger before it opens, in ms. */
|
|
16
17
|
delay?: number | undefined;
|
|
17
18
|
side?: Side | undefined;
|
|
19
|
+
/** Let it leave its `PopupBoundary`, such as its window, for its screen. */
|
|
20
|
+
spansWindows?: boolean | undefined;
|
|
18
21
|
/**
|
|
19
22
|
* `overPhoto` is a translucent pill with white text, for tooltips on a
|
|
20
23
|
* bar drawn over the wallpaper.
|
|
@@ -29,31 +32,29 @@ type Props = ExtendProps<
|
|
|
29
32
|
* is on, its trigger. For detail the trigger has no room for, such as a cut
|
|
30
33
|
* title. Use `Popover` for anything the user acts on.
|
|
31
34
|
*
|
|
32
|
-
* Stays
|
|
33
|
-
* the same container.
|
|
35
|
+
* Stays inside its `PopupBoundary` or its trigger's `<Screen>` region, as
|
|
36
|
+
* `Popover` does, and draws in the same container.
|
|
34
37
|
*/
|
|
35
38
|
export const Tooltip = ({
|
|
36
39
|
align = "center",
|
|
37
40
|
content,
|
|
38
41
|
delay,
|
|
39
42
|
side = "bottom",
|
|
43
|
+
spansWindows = false,
|
|
40
44
|
tone = "card",
|
|
41
45
|
trigger,
|
|
42
46
|
...rootProps
|
|
43
47
|
}: Props) => {
|
|
44
|
-
const
|
|
45
|
-
const attachTrigger = useCallback<RefCallback<HTMLElement>>((element) => {
|
|
46
|
-
setScreen(element?.closest("[data-screen]") ?? undefined);
|
|
47
|
-
}, []);
|
|
48
|
+
const { attach, boundary } = usePopupBoundary(spansWindows);
|
|
48
49
|
const container = useContext(PopoverContainer);
|
|
49
50
|
return (
|
|
50
51
|
<BaseTooltip.Root {...rootProps}>
|
|
51
|
-
<BaseTooltip.Trigger delay={delay} ref={
|
|
52
|
+
<BaseTooltip.Trigger delay={delay} ref={attach} render={trigger} />
|
|
52
53
|
<BaseTooltip.Portal container={container}>
|
|
53
54
|
<BaseTooltip.Positioner
|
|
54
55
|
align={align}
|
|
55
56
|
className={positionerStyles}
|
|
56
|
-
collisionBoundary={
|
|
57
|
+
collisionBoundary={boundary}
|
|
57
58
|
side={side}
|
|
58
59
|
sideOffset={6}
|
|
59
60
|
>
|
|
@@ -93,7 +94,8 @@ const popupStyles = css({
|
|
|
93
94
|
color: "foreground",
|
|
94
95
|
fontSize: "xs",
|
|
95
96
|
lineHeight: "normal",
|
|
96
|
-
|
|
97
|
+
// `--available-width` is the room its `PopupBoundary` leaves it.
|
|
98
|
+
maxInlineSize: "min({spacing.88}, 90vw, var(--available-width))",
|
|
97
99
|
opacity: 1,
|
|
98
100
|
paddingBlock: 1.5,
|
|
99
101
|
paddingInline: 2.5,
|