@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 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-96a82ac5b3d7",
6
- "@domicile-desktop/system-apps": "0.0.0-alpha-96a82ac5b3d7",
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-96a82ac5b3d7"
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
- <BaseAutocomplete.Root<Suggestion<V>>
89
- autoHighlight={autoHighlight}
90
- disabled={disabled}
91
- items={suggestions}
92
- itemToStringValue={(suggestion) => suggestion.text}
93
- // `inline` skips base-ui's filtering; the caller already filtered
94
- // `suggestions`.
95
- mode="inline"
96
- onValueChange={(next) => {
97
- onValueChange?.(next);
98
- }}
99
- value={value}
100
- >
101
- {/* 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 */}
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
- {prefixButtons === undefined ? (
113
- <PrefixIconStack prefixIcon={prefixIcon} size={size} />
114
- ) : (
115
- <span className={affixStyles}>{prefixButtons}</span>
116
- )}
117
- <BaseAutocomplete.Input
118
- {...inputProps}
119
- className={inputStyles}
120
- data-control=""
121
- />
122
- {suffixButtons !== undefined && (
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
- <BaseAutocomplete.Popup className={popupStyles}>
134
- <BaseAutocomplete.Empty className={emptyStyles}>
135
- {emptyMessage}
136
- </BaseAutocomplete.Empty>
137
- <BaseAutocomplete.List className={listStyles}>
138
- {suggestions.map((suggestion, index) => (
139
- <BaseAutocomplete.Item
140
- className={itemStyles}
141
- index={index}
142
- key={index}
143
- onClick={() => {
144
- onSuggestionTaken?.(suggestion.value);
145
- }}
146
- value={suggestion}
147
- >
148
- {suggestion.icon !== undefined && (
149
- <span className={itemIconStyles}>{suggestion.icon}</span>
150
- )}
151
- <span className={itemLabelStyles}>{suggestion.label}</span>
152
- {suggestion.description !== undefined && (
153
- <span className={itemDescriptionStyles}>
154
- {suggestion.description}
155
- </span>
156
- )}
157
- </BaseAutocomplete.Item>
158
- ))}
159
- </BaseAutocomplete.List>
160
- </BaseAutocomplete.Popup>
161
- </BaseAutocomplete.Positioner>
162
- </BaseAutocomplete.Portal>
163
- </BaseAutocomplete.Root>
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
- <BaseMenu.SubmenuRoot {...rootProps}>
195
- <BaseMenu.SubmenuTrigger
196
- className={itemStyles}
197
- data-mnemonic={mnemonicKey(label, mnemonic)}
198
- disabled={disabled}
199
- >
200
- <Label icon={icon} mnemonic={mnemonic}>
201
- {label}
202
- </Label>
203
- <CaretRightIcon className={caretStyles} />
204
- </BaseMenu.SubmenuTrigger>
205
- <BaseMenu.Portal>
206
- <BaseMenu.Positioner className={positionerStyles}>
207
- <BaseMenu.Popup
208
- aria-label={label}
209
- className={popupStyles}
210
- onKeyDown={chooseByMnemonic}
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
- {children}
213
- </BaseMenu.Popup>
214
- </BaseMenu.Positioner>
215
- </BaseMenu.Portal>
216
- </BaseMenu.SubmenuRoot>
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
  >
@@ -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 trigger that opened it, so on a
76
- * desktop of several monitors it never spans two. Triggers elsewhere open it
77
- * through a `handle` from `createHandle()`, as `Popover.Trigger`.
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 [screen, setScreen] = useState<Element | undefined>(undefined);
106
+ const { attach, boundary } = usePopupBoundary(spansWindows);
103
107
  const attachTrigger = useCallback<RefCallback<HTMLElement>>(
104
108
  (element) => {
105
- setScreen(element?.closest("[data-screen]") ?? undefined);
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
- setScreen(details.trigger.closest("[data-screen]") ?? undefined);
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={screen}
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
- maxInlineSize: "min({spacing.88}, 90vw)",
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
+ };
@@ -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
- * the viewport. On a multi-monitor desktop the viewport spans every
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 wrapperRef = useRef<HTMLDivElement>(null);
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={wrapperRef}
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
  >
@@ -1,11 +1,12 @@
1
1
  import { Tooltip as BaseTooltip } from "@base-ui/react/tooltip";
2
- import type { ReactElement, ReactNode, RefCallback } from "react";
3
- import { useCallback, useContext, useState } from "react";
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 on its trigger's `<Screen>` region, as `Popover` does, and draws in
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 [screen, setScreen] = useState<Element | undefined>(undefined);
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={attachTrigger} render={trigger} />
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={screen}
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
- maxInlineSize: "min({spacing.88}, 90vw)",
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,