@botiverse/raft-ui 0.95.4 → 0.96.1

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 (31) hide show
  1. package/CHANGELOG.md +135 -98
  2. package/dist/components/combobox/combobox.mjs +1 -1
  3. package/dist/components/combobox-select/combobox-select.mjs +1 -1
  4. package/dist/components/context-menu/context-menu.mjs +1 -1
  5. package/dist/components/dialog/dialog-sheet-context.mjs +4 -2
  6. package/dist/components/dialog/dialog-sheet.mjs +9 -1
  7. package/dist/components/dialog/dialog.mjs +22 -10
  8. package/dist/components/drawer/drawer.d.mts +1 -1
  9. package/dist/components/drawer/drawer.mjs +17 -4
  10. package/dist/components/dropdown-menu/dropdown-menu.mjs +1 -1
  11. package/dist/components/lightbox/lightbox.d.mts +1 -1
  12. package/dist/components/lightbox/lightbox.mjs +11 -9
  13. package/dist/components/notification-center/notification-center.mjs +1 -1
  14. package/dist/components/popover/popover.mjs +1 -1
  15. package/dist/components/preview-card/preview-card.mjs +1 -1
  16. package/dist/components/select/select.mjs +1 -1
  17. package/dist/components/tooltip/tooltip.mjs +1 -1
  18. package/dist/lib/frame-insets.mjs +56 -12
  19. package/dist/lib/overlay-depth.d.mts +5 -0
  20. package/dist/lib/overlay-depth.mjs +30 -11
  21. package/dist/lib/popup-boundary.d.mts +24 -0
  22. package/dist/lib/popup-boundary.mjs +258 -0
  23. package/dist/lint/no-raw-z-index.mjs +4 -4
  24. package/dist/lint/prefer-scroll-area.d.mts +60 -19
  25. package/dist/lint/prefer-scroll-area.mjs +393 -38
  26. package/dist/wip-exports.d.mts +5 -4
  27. package/dist/wip-exports.mjs +4 -3
  28. package/dist/wip.d.mts +2 -1
  29. package/package.json +1 -1
  30. package/skills/raft-ui-guide/SKILL.md +3 -1
  31. package/skills/raft-ui-guide/rules/layout.md +1 -0
@@ -0,0 +1,258 @@
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
+ * - the sheet has no head, or nothing visible under it.
127
+ *
128
+ * While the anchor is in the sheet's body the area always bounds the popup,
129
+ * however little of it either side of the anchor has: the list takes the
130
+ * larger side and scrolls. On a small phone with the keyboard up a field in
131
+ * the middle of a sheet has about 130px each side; a short list there can be
132
+ * used, and one let over the head covers it, or, portaled into the sheet's
133
+ * scrolling body, is drawn under it, where its first rows can't be pressed.
134
+ *
135
+ * The anchor is the popup's own `anchor` prop when `anchor` returns one that
136
+ * resolves to something (resolvePopupAnchor), and the trigger found in the
137
+ * page otherwise (findPopupAnchor). A virtual anchor has no element, so it is
138
+ * inside the head when the middle of its rect is. A popup whose anchor can't
139
+ * be found stays bounded.
140
+ *
141
+ * Floating-ui reads `y` and `height` one after the other, several times in
142
+ * one placement (once for each of flip, shift, size and hide). The two reads
143
+ * share one measurement: it is kept until the caller yields (a microtask), so
144
+ * a sheet that resized or a keyboard that came up is measured again by the
145
+ * next read. The trigger found in the page is kept longer, until the next
146
+ * frame, for the same positioner and while it is still in the page and open:
147
+ * which element a popup is anchored to doesn't change with layout, and finding
148
+ * it is a query over the whole document. A lookup that found nothing isn't
149
+ * kept.
150
+ *
151
+ * The rect is moved by `visualViewportShift`, so it is in the coordinates
152
+ * floating-ui reads it in, not the window's.
153
+ */
154
+ function sheetPopupBoundary(sheet, popup = () => null, anchor = () => void 0) {
155
+ let found = null;
156
+ const lookUp = (positioner) => {
157
+ if (found && found.positioner === positioner && found.anchor.isConnected && found.anchor.hasAttribute("data-popup-open")) return found.anchor;
158
+ found = null;
159
+ const trigger = findPopupAnchor(positioner);
160
+ if (trigger && positioner) {
161
+ found = {
162
+ positioner,
163
+ anchor: trigger
164
+ };
165
+ nextFrame(() => {
166
+ found = null;
167
+ });
168
+ }
169
+ return trigger;
170
+ };
171
+ const measure = () => {
172
+ const head = sheet.head();
173
+ const headBox = head?.getBoundingClientRect();
174
+ const surfaceBox = sheet.surface()?.getBoundingClientRect();
175
+ if (!head || !headBox || !surfaceBox) return null;
176
+ const viewport = window.visualViewport;
177
+ const top = headBox.bottom;
178
+ const bottom = viewport ? Math.min(surfaceBox.bottom, viewport.offsetTop + viewport.height) : surfaceBox.bottom;
179
+ if (bottom <= top) return null;
180
+ const positioner = popup();
181
+ const target = resolvePopupAnchor(anchor()) ?? lookUp(positioner);
182
+ if (target) {
183
+ if (isElement(target)) {
184
+ if (head.contains(target)) return null;
185
+ } else {
186
+ const anchorBox = target.getBoundingClientRect();
187
+ const x = (anchorBox.left + anchorBox.right) / 2;
188
+ const y = (anchorBox.top + anchorBox.bottom) / 2;
189
+ if (x >= headBox.left && x <= headBox.right && y >= headBox.top && y <= headBox.bottom) return null;
190
+ }
191
+ }
192
+ return {
193
+ top: top + visualViewportShift(positioner).y,
194
+ height: bottom - top
195
+ };
196
+ };
197
+ let measured = null;
198
+ const read = () => {
199
+ if (!measured) {
200
+ measured = { value: measure() };
201
+ queueMicrotask(() => {
202
+ measured = null;
203
+ });
204
+ }
205
+ return measured.value;
206
+ };
207
+ return {
208
+ x: -1e6,
209
+ width: 2 * UNBOUNDED,
210
+ get y() {
211
+ return read()?.top ?? -1e6;
212
+ },
213
+ get height() {
214
+ return read()?.height ?? 2 * UNBOUNDED;
215
+ }
216
+ };
217
+ }
218
+ /**
219
+ * The sheet around a popup, or `undefined` outside one. Set by a phone sheet
220
+ * (`DialogSheetContent`), a `Drawer` and `SheetPopupBoundary`; every modal
221
+ * surface (a dialog, an alert dialog, a drawer, a lightbox, an app's own under
222
+ * `OverlayLayerProvider`) and every sheet's head start without one.
223
+ */
224
+ const PopupSheetContext = createContext(void 0);
225
+ /** The sheet a popup is in, whose area it takes when its caller passes no `collisionBoundary`. */
226
+ function usePopupSheet() {
227
+ return useContext(PopupSheetContext);
228
+ }
229
+ /** Starts its children outside any sheet: a new surface, or a sheet's head. */
230
+ function PopupSheetReset({ children }) {
231
+ return /* @__PURE__ */ jsx(PopupSheetContext.Provider, {
232
+ value: void 0,
233
+ children
234
+ });
235
+ }
236
+ /**
237
+ * For a sheet an app draws itself (its own surface, a head that stays put and
238
+ * a body that scrolls): every raft-ui popup opened under it (Select, Combobox,
239
+ * ComboboxSelect, DropdownMenu, ContextMenu, Popover, PreviewCard, Tooltip,
240
+ * NotificationCenter) flips and sizes inside the sheet, under its head
241
+ * (sheetPopupBoundary says when it doesn't). Wrap the sheet's body in it, or
242
+ * the head and the body: a popup anchored in the head is left alone either
243
+ * way. Put it inside the sheet's own `OverlayLayerProvider`, which starts
244
+ * without one. raft-ui's own sheets, `DialogSheetContent` on a phone and
245
+ * `Drawer`, do this by themselves, and a `collisionBoundary` passed to a popup
246
+ * still wins. It renders no element.
247
+ */
248
+ function SheetPopupBoundary({ head, surface, children }) {
249
+ const sheet = useMemo(() => ({
250
+ head: () => head.current,
251
+ surface: () => surface ? surface.current : head.current?.parentElement
252
+ }), [head, surface]);
253
+ return /* @__PURE__ */ jsx(PopupSheetContext.Provider, {
254
+ value: sheet,
255
+ children
256
+ });
257
+ }
258
+ 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;