@botiverse/raft-ui 0.95.3 → 0.96.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.
Files changed (35) hide show
  1. package/CHANGELOG.md +135 -98
  2. package/dist/components/app-link-card/app-link-card.d.mts +17 -1
  3. package/dist/components/app-link-card/app-link-card.mjs +70 -2
  4. package/dist/components/app-link-card/index.d.mts +2 -2
  5. package/dist/components/combobox/combobox.mjs +1 -1
  6. package/dist/components/combobox-select/combobox-select.mjs +1 -1
  7. package/dist/components/context-menu/context-menu.mjs +1 -1
  8. package/dist/components/dialog/dialog-sheet-context.mjs +4 -2
  9. package/dist/components/dialog/dialog-sheet.mjs +9 -1
  10. package/dist/components/dialog/dialog.mjs +22 -10
  11. package/dist/components/drawer/drawer.d.mts +1 -1
  12. package/dist/components/drawer/drawer.mjs +17 -4
  13. package/dist/components/dropdown-menu/dropdown-menu.mjs +1 -1
  14. package/dist/components/lightbox/lightbox.d.mts +1 -1
  15. package/dist/components/lightbox/lightbox.mjs +11 -9
  16. package/dist/components/notification-center/notification-center.mjs +1 -1
  17. package/dist/components/popover/popover.mjs +1 -1
  18. package/dist/components/preview-card/preview-card.mjs +1 -1
  19. package/dist/components/select/select.mjs +1 -1
  20. package/dist/components/tooltip/tooltip.mjs +1 -1
  21. package/dist/lib/fill-contrast.mjs +63 -0
  22. package/dist/lib/frame-insets.mjs +56 -12
  23. package/dist/lib/overlay-depth.d.mts +5 -0
  24. package/dist/lib/overlay-depth.mjs +30 -11
  25. package/dist/lib/popup-boundary.d.mts +24 -0
  26. package/dist/lib/popup-boundary.mjs +256 -0
  27. package/dist/lint/no-raw-z-index.mjs +4 -4
  28. package/dist/lint/prefer-scroll-area.d.mts +60 -19
  29. package/dist/lint/prefer-scroll-area.mjs +393 -38
  30. package/dist/wip-exports.d.mts +6 -5
  31. package/dist/wip-exports.mjs +5 -4
  32. package/dist/wip.d.mts +3 -2
  33. package/package.json +1 -1
  34. package/skills/raft-ui-guide/SKILL.md +3 -1
  35. package/skills/raft-ui-guide/rules/layout.md +1 -0
@@ -1,4 +1,5 @@
1
- import { useMemo, useState } from "react";
1
+ import { sheetPopupBoundary, usePopupSheet, visualViewportShift } from "./popup-boundary.mjs";
2
+ import { useLayoutEffect, useMemo, useState } from "react";
2
3
  // src/lib/frame-insets.ts
3
4
  const NO_FRAME_INSETS = Object.freeze({
4
5
  top: 0,
@@ -127,15 +128,20 @@ function portalsToBody(container) {
127
128
  return element == null || element === document.body || element === document.documentElement;
128
129
  }
129
130
  /**
130
- * The window inset by the frame insets, as a collision boundary rect. Its size
131
- * is read each time Base UI places the popup (it copies the rect's fields), so
132
- * it follows the window when it resizes while the popup is open.
131
+ * The window inset by the frame insets, as a collision boundary rect. Its
132
+ * fields are read each time Base UI places the popup (it copies them), so it
133
+ * follows the window when it resizes while the popup is open, and WebKit's
134
+ * visual viewport when it pans (visualViewportShift).
133
135
  */
134
136
  function frameCollisionBoundary(insets) {
135
137
  const root = () => document.documentElement;
136
138
  return {
137
- x: insets.left,
138
- y: insets.top,
139
+ get x() {
140
+ return insets.left + visualViewportShift().x;
141
+ },
142
+ get y() {
143
+ return insets.top + visualViewportShift().y;
144
+ },
139
145
  get width() {
140
146
  return Math.max(0, root().clientWidth - insets.left - insets.right);
141
147
  },
@@ -191,16 +197,54 @@ function frameCollision(padding, boundary, insets, gap, container) {
191
197
  * inset, and `gap` (8px) inside each inset, the insets as the boundary
192
198
  * (frameCollision). A `collisionPadding` the caller passes wins as it is, and
193
199
  * a `collisionBoundary` the caller passes is kept. Call it from a part inside
194
- * the popup's portal, which mounts each time the popup opens.
200
+ * the popup's portal, which mounts each time the popup opens, and spread the
201
+ * result on the positioner.
202
+ *
203
+ * In a sheet (a phone's DialogSheetContent, a Drawer, an app's own under
204
+ * SheetPopupBoundary) a popup with no `collisionBoundary` of its own takes the
205
+ * sheet's: the area under its head, decided for this popup each time it is
206
+ * placed (sheetPopupBoundary), whatever its portal container. Pass the
207
+ * positioner's own `anchor` as `anchor`: the sheet then decides by it, not by
208
+ * the trigger it would look up in the page. Outside a sheet nothing changes,
209
+ * and no `ref` is returned.
195
210
  */
196
- function usePopupCollision(collisionPadding, collisionBoundary, defaultPadding, container, gap = 8) {
211
+ function usePopupCollision(collisionPadding, collisionBoundary, defaultPadding, container, gap = 8, anchor) {
197
212
  const insets = useFrameInsets(container);
198
- return useMemo(() => collisionPadding === void 0 ? frameCollision(defaultPadding, collisionBoundary, insets, gap, container) : {
199
- collisionPadding,
200
- collisionBoundary
213
+ const sheet = usePopupSheet();
214
+ const [positioner] = useState(() => ({ current: null }));
215
+ const [latest] = useState(() => {
216
+ let current = anchor;
217
+ return {
218
+ read: () => current,
219
+ set: (next) => {
220
+ current = next;
221
+ }
222
+ };
223
+ });
224
+ useLayoutEffect(() => {
225
+ latest.set(anchor);
226
+ }, [latest, anchor]);
227
+ const sheetBoundary = useMemo(() => sheet && collisionBoundary === void 0 ? sheetPopupBoundary(sheet, () => positioner.current, latest.read) : void 0, [
228
+ sheet,
229
+ collisionBoundary,
230
+ positioner,
231
+ latest
232
+ ]);
233
+ const boundary = collisionBoundary ?? sheetBoundary;
234
+ return useMemo(() => {
235
+ const collision = collisionPadding === void 0 ? frameCollision(defaultPadding, boundary, insets, gap, container) : {
236
+ collisionPadding,
237
+ collisionBoundary: boundary
238
+ };
239
+ return sheetBoundary ? {
240
+ ...collision,
241
+ ref: positioner
242
+ } : collision;
201
243
  }, [
202
244
  collisionPadding,
203
- collisionBoundary,
245
+ boundary,
246
+ sheetBoundary,
247
+ positioner,
204
248
  defaultPadding,
205
249
  insets,
206
250
  gap,
@@ -24,6 +24,11 @@ export type OverlayLayerProviderProps = {
24
24
  * `useParentOverlayKind` inside it to pick its own scrim. Modal overlays only:
25
25
  * a non-modal one passes `modal={false}`, adds nothing, and must not call
26
26
  * `useParentOverlayKind` (it would read the layer below as its own).
27
+ *
28
+ * It is a surface of its own, modal or not, so what is inside starts outside
29
+ * any sheet around it: a popup opened in it isn't held inside a sheet it was
30
+ * opened from (dialog.md, "Popups in a sheet"). A sheet of the app's own puts
31
+ * its `SheetPopupBoundary` inside this provider.
27
32
  */
28
33
  export declare function OverlayLayerProvider({ kind, modal, children }: OverlayLayerProviderProps): import("react").JSX.Element;
29
34
  /**
@@ -1,8 +1,27 @@
1
+ import { PopupSheetReset } from "./popup-boundary.mjs";
1
2
  import { createContext, useContext, useMemo } from "react";
2
3
  import { jsx } from "react/jsx-runtime";
3
4
  // src/lib/overlay-depth.tsx
4
5
  const OverlayStackContext = createContext([]);
5
6
  /**
7
+ * Adds a modal surface to the stack for everything under it. raft-ui's own
8
+ * roots use this one: a root also holds its trigger, which is still in the
9
+ * surface it is written in, so it must not start outside that sheet as
10
+ * `OverlayLayerProvider` does.
11
+ */
12
+ function OverlayLayer({ kind, modal, children }) {
13
+ const stack = useContext(OverlayStackContext);
14
+ const value = useMemo(() => modal ? [...stack, kind] : stack, [
15
+ stack,
16
+ kind,
17
+ modal
18
+ ]);
19
+ return /* @__PURE__ */ jsx(OverlayStackContext.Provider, {
20
+ value,
21
+ children
22
+ });
23
+ }
24
+ /**
6
25
  * Tells everything under it that a modal surface is open, so a raft-ui
7
26
  * Dialog, AlertDialog or Drawer opened from inside draws the lighter nested
8
27
  * scrim (design/foundations/elevation.md#stacked-overlays). raft-ui's own
@@ -11,17 +30,17 @@ const OverlayStackContext = createContext([]);
11
30
  * `useParentOverlayKind` inside it to pick its own scrim. Modal overlays only:
12
31
  * a non-modal one passes `modal={false}`, adds nothing, and must not call
13
32
  * `useParentOverlayKind` (it would read the layer below as its own).
33
+ *
34
+ * It is a surface of its own, modal or not, so what is inside starts outside
35
+ * any sheet around it: a popup opened in it isn't held inside a sheet it was
36
+ * opened from (dialog.md, "Popups in a sheet"). A sheet of the app's own puts
37
+ * its `SheetPopupBoundary` inside this provider.
14
38
  */
15
39
  function OverlayLayerProvider({ kind, modal, children }) {
16
- const stack = useContext(OverlayStackContext);
17
- const value = useMemo(() => modal ? [...stack, kind] : stack, [
18
- stack,
40
+ return /* @__PURE__ */ jsx(OverlayLayer, {
19
41
  kind,
20
- modal
21
- ]);
22
- return /* @__PURE__ */ jsx(OverlayStackContext.Provider, {
23
- value,
24
- children
42
+ modal,
43
+ children: /* @__PURE__ */ jsx(PopupSheetReset, { children })
25
44
  });
26
45
  }
27
46
  /**
@@ -44,15 +63,15 @@ function useParentOverlayKind() {
44
63
  * children be a render function (`({ payload }) => …`), so keep that shape.
45
64
  */
46
65
  function withOverlayLayer(kind, modal, children) {
47
- if (typeof children === "function") return (...args) => /* @__PURE__ */ jsx(OverlayLayerProvider, {
66
+ if (typeof children === "function") return (...args) => /* @__PURE__ */ jsx(OverlayLayer, {
48
67
  kind,
49
68
  modal,
50
69
  children: children(...args)
51
70
  });
52
- return /* @__PURE__ */ jsx(OverlayLayerProvider, {
71
+ return /* @__PURE__ */ jsx(OverlayLayer, {
53
72
  kind,
54
73
  modal,
55
74
  children
56
75
  });
57
76
  }
58
- export { OverlayLayerProvider, useParentOverlayKind, withOverlayLayer };
77
+ export { OverlayLayer, OverlayLayerProvider, useParentOverlayKind, withOverlayLayer };
@@ -0,0 +1,24 @@
1
+ import { ReactNode, RefObject } from "react";
2
+ export type SheetPopupBoundaryProps = {
3
+ /** The sheet's head: popups stay under its bottom edge. */
4
+ head: RefObject<Element | null>;
5
+ /**
6
+ * The sheet's surface: popups stay above its bottom edge.
7
+ * @default The head's parent element.
8
+ */
9
+ surface?: RefObject<Element | null>;
10
+ children?: ReactNode;
11
+ };
12
+ /**
13
+ * For a sheet an app draws itself (its own surface, a head that stays put and
14
+ * a body that scrolls): every raft-ui popup opened under it (Select, Combobox,
15
+ * ComboboxSelect, DropdownMenu, ContextMenu, Popover, PreviewCard, Tooltip,
16
+ * NotificationCenter) flips and sizes inside the sheet, under its head
17
+ * (sheetPopupBoundary says when it doesn't). Wrap the sheet's body in it, or
18
+ * the head and the body: a popup anchored in the head is left alone either
19
+ * way. Put it inside the sheet's own `OverlayLayerProvider`, which starts
20
+ * without one. raft-ui's own sheets, `DialogSheetContent` on a phone and
21
+ * `Drawer`, do this by themselves, and a `collisionBoundary` passed to a popup
22
+ * still wins. It renders no element.
23
+ */
24
+ export declare function SheetPopupBoundary({ head, surface, children }: SheetPopupBoundaryProps): import("react").JSX.Element;
@@ -0,0 +1,256 @@
1
+ import { createContext, useContext, useMemo } from "react";
2
+ import { jsx } from "react/jsx-runtime";
3
+ // src/lib/popup-boundary.tsx
4
+ /** WebKit, by floating-ui's own test (`isWebKit` in @floating-ui/utils). */
5
+ function isWebKit() {
6
+ return typeof CSS !== "undefined" && CSS.supports?.("-webkit-backdrop-filter", "none") === true;
7
+ }
8
+ const NOT_NONE = (value) => !!value && value !== "none";
9
+ const WILL_CHANGE = /transform|translate|scale|rotate|perspective|filter/;
10
+ const CONTAIN = /paint|layout|strict|content/;
11
+ /**
12
+ * Whether floating-ui takes the window as `popup`'s offset parent: it is
13
+ * `position: fixed` and no ancestor is a containing block for fixed boxes
14
+ * (`getOffsetParent` and `isContainingBlock` in @floating-ui 1.8, as they
15
+ * read in WebKit, where a filter doesn't count). With no popup to look at,
16
+ * yes: a raft-ui popup is fixed and portaled beside its dialog or to the body.
17
+ */
18
+ function offsetParentIsWindow(popup) {
19
+ if (!popup) return true;
20
+ if (getComputedStyle(popup).position !== "fixed") return false;
21
+ for (let node = popup.parentElement; node; node = node.parentElement) {
22
+ const css = getComputedStyle(node);
23
+ if (NOT_NONE(css.transform) || NOT_NONE(css.translate) || NOT_NONE(css.scale) || NOT_NONE(css.rotate) || NOT_NONE(css.perspective) || WILL_CHANGE.test(css.willChange || "") || CONTAIN.test(css.contain || "")) return false;
24
+ }
25
+ return true;
26
+ }
27
+ /**
28
+ * How far to move a boundary rect so floating-ui (@floating-ui/dom 1.8, under
29
+ * Base UI) reads it where the window draws it: 0 outside WebKit, and in WebKit
30
+ * the visual viewport's offset, twice for a fixed popup whose offset parent is
31
+ * the window and once for any other.
32
+ *
33
+ * In WebKit (floating-ui's own test: `-webkit-backdrop-filter` is supported)
34
+ * the visual viewport has an offset while the page is panned under it: the
35
+ * on-screen keyboard is up on iOS, or the page is pinch-zoomed. floating-ui
36
+ * SUBTRACTS that offset from a boundary given as a rect
37
+ * (`getClientRectFromClippingAncestor`), whatever the popup. For a
38
+ * `position: fixed` popup whose offset parent is the window, which every
39
+ * raft-ui popup is by default, it also ADDS the offset to the trigger's rect
40
+ * (`getBoundingClientRect` with `shouldAddVisualOffsets`). A rect in window
41
+ * coordinates therefore lands twice the offset away from the trigger it
42
+ * bounds: with the keyboard up, a list bounded under a sheet's head ran under
43
+ * the head again. For a popup with `positionMethod="absolute"`, or one
44
+ * portaled into an element that is a containing block for fixed boxes (a
45
+ * drawer's popup, which is transformed), the trigger gets no offset and the
46
+ * rect is off by once. An element as the boundary gets no offset at all, so it
47
+ * is no way around this.
48
+ *
49
+ * `popup` is the positioner being placed; without one the popup is taken to be
50
+ * fixed to the window.
51
+ */
52
+ function visualViewportShift(popup) {
53
+ if (typeof window === "undefined") return {
54
+ x: 0,
55
+ y: 0
56
+ };
57
+ const viewport = window.visualViewport;
58
+ if (!viewport || !isWebKit()) return {
59
+ x: 0,
60
+ y: 0
61
+ };
62
+ if (viewport.offsetLeft === 0 && viewport.offsetTop === 0) return {
63
+ x: 0,
64
+ y: 0
65
+ };
66
+ const times = offsetParentIsWindow(popup) ? 2 : 1;
67
+ return {
68
+ x: times * viewport.offsetLeft,
69
+ y: times * viewport.offsetTop
70
+ };
71
+ }
72
+ /**
73
+ * The element an open popup is anchored to, found from its positioner, or
74
+ * `null` when it can't be told. Base UI keeps this in each primitive's own
75
+ * store, so it is read from the DOM Base UI renders:
76
+ *
77
+ * - a menu, a submenu, a popover, a select or a combobox: the open trigger
78
+ * (`data-popup-open`) whose `aria-controls` names an element inside the
79
+ * positioner;
80
+ * - a tooltip or a preview card, which nothing names: the open trigger of its
81
+ * kind, when exactly one is open.
82
+ *
83
+ * A context menu is anchored to a point and has none. An `anchor` passed to
84
+ * the popup isn't read here: sheetPopupBoundary takes it before this lookup.
85
+ */
86
+ function findPopupAnchor(positioner) {
87
+ if (!positioner) return null;
88
+ const root = positioner.ownerDocument;
89
+ for (const trigger of root.querySelectorAll("[data-popup-open][aria-controls]")) {
90
+ const controlled = root.getElementById(trigger.getAttribute("aria-controls") ?? "");
91
+ if (controlled && positioner.contains(controlled)) return trigger;
92
+ }
93
+ const unnamed = positioner.querySelector("[data-slot=\"tooltip-content\"]") ? "[data-base-ui-tooltip-trigger][data-popup-open]" : positioner.querySelector("[data-slot=\"preview-card-content\"]") ? "[data-slot=\"preview-card-trigger\"][data-popup-open]" : null;
94
+ if (!unnamed) return null;
95
+ const open = root.querySelectorAll(unnamed);
96
+ return open.length === 1 ? open[0] : null;
97
+ }
98
+ const UNBOUNDED = 1e6;
99
+ const isElement = (value) => typeof value.nodeType === "number";
100
+ /** The element or virtual element an `anchor` prop stands for now, or `null`: an empty ref, or none passed. */
101
+ function resolvePopupAnchor(anchor) {
102
+ const value = typeof anchor === "function" ? anchor() : anchor;
103
+ if (!value) return null;
104
+ if (!isElement(value) && "current" in value) return value.current;
105
+ return value;
106
+ }
107
+ /** Runs `clear` before the next frame is drawn, or as soon as the caller is done where there are no frames. */
108
+ function nextFrame(clear) {
109
+ if (typeof requestAnimationFrame === "function") requestAnimationFrame(clear);
110
+ else queueMicrotask(clear);
111
+ }
112
+ /**
113
+ * The part of a sheet a popup opened in it may cover: from the bottom of the
114
+ * sheet's head to the bottom of what is visible of the sheet (its own bottom
115
+ * edge, or the visual viewport's when the on-screen keyboard covers it), the
116
+ * window's full width. A list bounded by it flips and shrinks under the head
117
+ * instead of opening over it and past the sheet's top edge.
118
+ *
119
+ * Decided for each popup, each time Base UI places it (it copies the rect's
120
+ * fields, which are getters), so the rect follows the sheet as the keyboard
121
+ * resizes it. The sheet bounds nothing, and the popup keeps to the window as
122
+ * outside a sheet, when:
123
+ *
124
+ * - the popup's anchor is inside the head (a tooltip or a menu on a head
125
+ * button belongs above or over the head, not under it);
126
+ * - neither side of the anchor has `POPUP_ROOM_MIN_PX` of the area: a list
127
+ * held there would show a row or two, so it opens over the head instead;
128
+ * - the sheet has no head, or nothing visible under it.
129
+ *
130
+ * The anchor is the popup's own `anchor` prop when `anchor` returns one that
131
+ * resolves to something (resolvePopupAnchor), and the trigger found in the
132
+ * page otherwise (findPopupAnchor). A virtual anchor has no element, so it is
133
+ * inside the head when the middle of its rect is. A popup whose anchor can't
134
+ * be found stays bounded.
135
+ *
136
+ * Floating-ui reads `y` and `height` one after the other, several times in
137
+ * one placement (once for each of flip, shift, size and hide). The two reads
138
+ * share one measurement: it is kept until the caller yields (a microtask), so
139
+ * a sheet that resized or a keyboard that came up is measured again by the
140
+ * next read. The trigger found in the page is kept longer, until the next
141
+ * frame, for the same positioner and while it is still in the page and open:
142
+ * which element a popup is anchored to doesn't change with layout, and finding
143
+ * it is a query over the whole document. A lookup that found nothing isn't
144
+ * kept.
145
+ *
146
+ * The rect is moved by `visualViewportShift`, so it is in the coordinates
147
+ * floating-ui reads it in, not the window's.
148
+ */
149
+ function sheetPopupBoundary(sheet, popup = () => null, anchor = () => void 0) {
150
+ let found = null;
151
+ const lookUp = (positioner) => {
152
+ if (found && found.positioner === positioner && found.anchor.isConnected && found.anchor.hasAttribute("data-popup-open")) return found.anchor;
153
+ found = null;
154
+ const trigger = findPopupAnchor(positioner);
155
+ if (trigger && positioner) {
156
+ found = {
157
+ positioner,
158
+ anchor: trigger
159
+ };
160
+ nextFrame(() => {
161
+ found = null;
162
+ });
163
+ }
164
+ return trigger;
165
+ };
166
+ const measure = () => {
167
+ const head = sheet.head();
168
+ const headBox = head?.getBoundingClientRect();
169
+ const surfaceBox = sheet.surface()?.getBoundingClientRect();
170
+ if (!head || !headBox || !surfaceBox) return null;
171
+ const viewport = window.visualViewport;
172
+ const top = headBox.bottom;
173
+ const bottom = viewport ? Math.min(surfaceBox.bottom, viewport.offsetTop + viewport.height) : surfaceBox.bottom;
174
+ if (bottom <= top) return null;
175
+ const positioner = popup();
176
+ const target = resolvePopupAnchor(anchor()) ?? lookUp(positioner);
177
+ if (target) {
178
+ const anchorBox = target.getBoundingClientRect();
179
+ if (isElement(target)) {
180
+ if (head.contains(target)) return null;
181
+ } else {
182
+ const x = (anchorBox.left + anchorBox.right) / 2;
183
+ const y = (anchorBox.top + anchorBox.bottom) / 2;
184
+ if (x >= headBox.left && x <= headBox.right && y >= headBox.top && y <= headBox.bottom) return null;
185
+ }
186
+ const above = Math.min(anchorBox.top, bottom) - top;
187
+ const below = bottom - Math.max(anchorBox.bottom, top);
188
+ if (Math.max(above, below) < 150) return null;
189
+ }
190
+ return {
191
+ top: top + visualViewportShift(positioner).y,
192
+ height: bottom - top
193
+ };
194
+ };
195
+ let measured = null;
196
+ const read = () => {
197
+ if (!measured) {
198
+ measured = { value: measure() };
199
+ queueMicrotask(() => {
200
+ measured = null;
201
+ });
202
+ }
203
+ return measured.value;
204
+ };
205
+ return {
206
+ x: -1e6,
207
+ width: 2 * UNBOUNDED,
208
+ get y() {
209
+ return read()?.top ?? -1e6;
210
+ },
211
+ get height() {
212
+ return read()?.height ?? 2 * UNBOUNDED;
213
+ }
214
+ };
215
+ }
216
+ /**
217
+ * The sheet around a popup, or `undefined` outside one. Set by a phone sheet
218
+ * (`DialogSheetContent`), a `Drawer` and `SheetPopupBoundary`; every modal
219
+ * surface (a dialog, an alert dialog, a drawer, a lightbox, an app's own under
220
+ * `OverlayLayerProvider`) and every sheet's head start without one.
221
+ */
222
+ const PopupSheetContext = createContext(void 0);
223
+ /** The sheet a popup is in, whose area it takes when its caller passes no `collisionBoundary`. */
224
+ function usePopupSheet() {
225
+ return useContext(PopupSheetContext);
226
+ }
227
+ /** Starts its children outside any sheet: a new surface, or a sheet's head. */
228
+ function PopupSheetReset({ children }) {
229
+ return /* @__PURE__ */ jsx(PopupSheetContext.Provider, {
230
+ value: void 0,
231
+ children
232
+ });
233
+ }
234
+ /**
235
+ * For a sheet an app draws itself (its own surface, a head that stays put and
236
+ * a body that scrolls): every raft-ui popup opened under it (Select, Combobox,
237
+ * ComboboxSelect, DropdownMenu, ContextMenu, Popover, PreviewCard, Tooltip,
238
+ * NotificationCenter) flips and sizes inside the sheet, under its head
239
+ * (sheetPopupBoundary says when it doesn't). Wrap the sheet's body in it, or
240
+ * the head and the body: a popup anchored in the head is left alone either
241
+ * way. Put it inside the sheet's own `OverlayLayerProvider`, which starts
242
+ * without one. raft-ui's own sheets, `DialogSheetContent` on a phone and
243
+ * `Drawer`, do this by themselves, and a `collisionBoundary` passed to a popup
244
+ * still wins. It renders no element.
245
+ */
246
+ function SheetPopupBoundary({ head, surface, children }) {
247
+ const sheet = useMemo(() => ({
248
+ head: () => head.current,
249
+ surface: () => surface ? surface.current : head.current?.parentElement
250
+ }), [head, surface]);
251
+ return /* @__PURE__ */ jsx(PopupSheetContext.Provider, {
252
+ value: sheet,
253
+ children
254
+ });
255
+ }
256
+ export { PopupSheetContext, PopupSheetReset, SheetPopupBoundary, findPopupAnchor, resolvePopupAnchor, sheetPopupBoundary, usePopupSheet, visualViewportShift };
@@ -9,10 +9,10 @@ import { classContext, constDeclarator, propertyKey, stringValue } from "./prefe
9
9
  * Reported:
10
10
  * - a Tailwind z-index class with a number or an arbitrary value (`z-10`,
11
11
  * `-z-1`, `z-[60]`, `z-(--my-z)`, `[z-index:5]`, with any variant prefix or
12
- * `!`) in a string or template literal that is a class, found as
13
- * `raft-ui/prefer-scroll-area` finds one (a `className`, `class` or
14
- * `*ClassName` attribute or property, an argument of `cn`, `tv` and the
15
- * like, or a `const` of the same file used as a class). `z-auto` and the
12
+ * `!`) in a string or template literal that is a class: a `className`,
13
+ * `class` or `*ClassName` attribute or property, an argument of `cn`, `tv`
14
+ * and the like, or a `const` of the same file used as a class (not the
15
+ * wider set `raft-ui/prefer-scroll-area` reads). `z-auto` and the
16
16
  * named layers (`z-raised`, `max-md:z-chrome`) are not reported;
17
17
  * - a `zIndex` property whose value is a number or a numeric string
18
18
  * (`style={{ zIndex: 10 }}`, `const s = { zIndex: open ? 20 : "5" }`), a
@@ -5,28 +5,56 @@
5
5
  *
6
6
  * Reported:
7
7
  * - a native-scrolling Tailwind class (`overflow`, `overflow-x` or `overflow-y`
8
- * set to auto or scroll, with any variant prefix, `!`, or as an arbitrary
9
- * property) in a string or template literal that is a class: the value of a
10
- * `className`, `class` or `*ClassName` JSX attribute, or an argument of
11
- * `cn`, `clsx`, `cx`, `tv`, `cva`, `twMerge`, `twJoin` or `classNames`, or
12
- * a `className`, `class` or `*ClassName` property (a recipe slot's option,
13
- * `content({ className: "…" })`), through conditionals, `&&`, arrays,
14
- * objects (keys and values) and `+`;
8
+ * set to auto or scroll, with any variant prefix, `!`, an arbitrary value
9
+ * `overflow-y-[scroll]`, `overflow-[hidden_auto]` or an arbitrary property
10
+ * `[overflow:overlay]`) in a string or template literal that is a class:
11
+ * - the value of a JSX attribute with a class name (`className`, `class`,
12
+ * `classNames`, `*ClassName`, `*ClassNames`, `*Class`), a property with
13
+ * such a name (a recipe slot's option, `content({ className: "…" })`) or
14
+ * the default of a parameter with one (`{ className = "…" }`), through
15
+ * conditionals, `&&`, arrays, objects, members, `+`, a method called on
16
+ * it (`[…].join(" ")`), a function that returns it (Base UI's
17
+ * `className={(state) => …}`) and one call that isn't a class helper
18
+ * (`className={wrap("…")}`);
19
+ * - an argument of a class helper (`cn`, `clsx`, `cx`, `tv`, `cva`,
20
+ * `twMerge`, `twJoin`, `classNames`), through the same, except a call
21
+ * that isn't a class helper: what `t("…")` takes inside `cn(…)` is its
22
+ * own, in a class attribute or outside one;
23
+ * - an argument of the call a JSX spread spreads (`{...guard("…")}`), but
24
+ * not under a key that isn't a class name, nor in a function;
15
25
  * - such a string in a `const` of the same file that is used as a class
16
- * (`const log = "max-h-64 …"`, then `className={log}` or
17
- * `cn(log)`), wherever in the file it is declared;
18
- * - an inline `style={{ overflow | overflowX | overflowY: "auto" | "scroll" }}`.
26
+ * (`const log = "max-h-64 …"`, then `className={log}` or `cn(log)`),
27
+ * wherever in the file it is declared: a string, a list joined into one, a
28
+ * const built from another (`const b = \`p-2 ${a}\``), or an object or
29
+ * variant map, of which only the members read as a class count
30
+ * (`cls.body`), all of them when it is read by a computed key
31
+ * (`VARIANTS[kind]`) or used whole;
32
+ * - a `style` whose `overflow`, `overflowX` or `overflowY` has `auto`,
33
+ * `scroll` or `overlay` in it (`"hidden auto"` too): in a `style` attribute,
34
+ * under a `style` key (`{...{ style: { … } }}`), or an object in a `const`
35
+ * of the same file that a `style` uses (`style={s}`, `style={styles.pre}`).
19
36
  *
20
- * Allowed with a `raft-ui: native-scroll <reason>` comment, line or block, on
21
- * the line above the reported string (or at the end of its line): it covers
22
- * the string that starts on that line only, not the rest of the call or the
23
- * attribute it is in, so a class added to a multi-line `cn(...)` later is
24
- * still reported. For an inline style, the line of its property. A comment
25
- * without a reason doesn't count.
37
+ * The message follows the direction (down, sideways, both ways) and the
38
+ * element the hit is on: a `<textarea>` gets ScrollAreaTextarea; `<pre>`,
39
+ * `<html>`, `<body>` and `<iframe>` get no replacement, only the comment; a
40
+ * `classNames` object, whose classes go to another component's elements,
41
+ * gets `useScrollAreaContainer`; a ScrollArea's own class is to be removed.
26
42
  *
27
- * It reads the file as written, with no scope or type information: a const is
28
- * matched by its name in the same file, and a class in a `let`, an import, a
29
- * member (`styles.log`) or a function's return isn't seen.
43
+ * Allowed with a `raft-ui: native-scroll <reason>` comment, line or block:
44
+ * on a line of its own right above the reported string, or at the end of
45
+ * the string's own line. It covers the string that starts on that line only,
46
+ * not the rest of the call or the attribute it is in, so a class added to a
47
+ * multi-line `cn(...)` later is still reported; a comment at the end of a
48
+ * line doesn't reach the next line. For a style, the line of its property. A
49
+ * comment without a reason doesn't count.
50
+ *
51
+ * A const is resolved by scope where the linter gives one (a parameter of
52
+ * the same name is not the const), and by its name in the file otherwise.
53
+ *
54
+ * Known misses: a class added by script (`el.classList.add(…)`,
55
+ * `el.className = …`), a style set by script (`el.style.overflowY = "auto"`),
56
+ * a const or object imported from another file, a `let`, a string a function
57
+ * returns to a const, and CSS files.
30
58
  */
31
59
  export interface Position {
32
60
  line: number;
@@ -51,9 +79,22 @@ export interface Comment {
51
79
  };
52
80
  range?: [number, number];
53
81
  }
82
+ /** The part of a scope this rule reads, to find the declaration a name refers to. */
83
+ export interface Scope {
84
+ set?: Map<string, {
85
+ defs?: {
86
+ type: string;
87
+ node: Node;
88
+ parent?: Node | null;
89
+ }[];
90
+ }>;
91
+ upper?: Scope | null;
92
+ }
54
93
  export interface SourceCode {
94
+ text?: string;
55
95
  getAllComments(): Comment[];
56
96
  getLocFromIndex(index: number): Position;
97
+ getScope?(node: Node): Scope | null;
57
98
  }
58
99
  export interface RuleContext {
59
100
  sourceCode?: SourceCode;