@claralight-design/react 0.0.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.
- package/dist/index.d.ts +12 -0
- package/dist/index.js +12 -0
- package/dist/lib/anchored.d.ts +152 -0
- package/dist/lib/anchored.js +821 -0
- package/dist/lib/anchored.js.map +1 -0
- package/dist/lib/morph.js +484 -0
- package/dist/lib/morph.js.map +1 -0
- package/dist/lib/squircle.d.ts +95 -0
- package/dist/lib/squircle.js +220 -0
- package/dist/lib/squircle.js.map +1 -0
- package/dist/lib/utils.d.ts +30 -0
- package/dist/lib/utils.js +76 -0
- package/dist/lib/utils.js.map +1 -0
- package/dist/ui/button.d.ts +40 -0
- package/dist/ui/button.js +104 -0
- package/dist/ui/button.js.map +1 -0
- package/dist/ui/card.d.ts +54 -0
- package/dist/ui/card.js +82 -0
- package/dist/ui/card.js.map +1 -0
- package/dist/ui/dialog.d.ts +82 -0
- package/dist/ui/dialog.js +129 -0
- package/dist/ui/dialog.js.map +1 -0
- package/dist/ui/input.d.ts +34 -0
- package/dist/ui/input.js +60 -0
- package/dist/ui/input.js.map +1 -0
- package/dist/ui/popover.d.ts +67 -0
- package/dist/ui/popover.js +93 -0
- package/dist/ui/popover.js.map +1 -0
- package/dist/ui/scroll-area.d.ts +110 -0
- package/dist/ui/scroll-area.js +125 -0
- package/dist/ui/scroll-area.js.map +1 -0
- package/dist/ui/select.d.ts +113 -0
- package/dist/ui/select.js +213 -0
- package/dist/ui/select.js.map +1 -0
- package/dist/ui/tooltip.d.ts +108 -0
- package/dist/ui/tooltip.js +142 -0
- package/dist/ui/tooltip.js.map +1 -0
- package/package.json +72 -0
- package/styles/anchored.css +115 -0
- package/styles/base.css +309 -0
- package/styles/fonts/README.md +63 -0
- package/styles/index.css +28 -0
- package/styles/scroll-area.css +299 -0
- package/styles/theme.css +540 -0
- package/styles/tooltip.css +156 -0
|
@@ -0,0 +1,821 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { cn, tokenNumber } from "./utils.js";
|
|
3
|
+
import { composeRefs } from "./squircle.js";
|
|
4
|
+
import { generatePath, getLayoutSize, parseBorder } from "@lisse/core";
|
|
5
|
+
import { Fragment, cloneElement, isValidElement, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
6
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
7
|
+
//#region src/lib/anchored.tsx
|
|
8
|
+
/** The tail sits on the edge facing the anchor — opposite the resolved side. */
|
|
9
|
+
const OPPOSITE = {
|
|
10
|
+
top: "bottom",
|
|
11
|
+
bottom: "top",
|
|
12
|
+
left: "right",
|
|
13
|
+
right: "left"
|
|
14
|
+
};
|
|
15
|
+
/** Direction of travel along each edge, following the generated path's winding. */
|
|
16
|
+
const ALONG = {
|
|
17
|
+
top: {
|
|
18
|
+
x: 1,
|
|
19
|
+
y: 0
|
|
20
|
+
},
|
|
21
|
+
right: {
|
|
22
|
+
x: 0,
|
|
23
|
+
y: 1
|
|
24
|
+
},
|
|
25
|
+
bottom: {
|
|
26
|
+
x: -1,
|
|
27
|
+
y: 0
|
|
28
|
+
},
|
|
29
|
+
left: {
|
|
30
|
+
x: 0,
|
|
31
|
+
y: -1
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
/** Outward normal of each edge. */
|
|
35
|
+
const OUTWARD = {
|
|
36
|
+
top: {
|
|
37
|
+
x: 0,
|
|
38
|
+
y: -1
|
|
39
|
+
},
|
|
40
|
+
right: {
|
|
41
|
+
x: 1,
|
|
42
|
+
y: 0
|
|
43
|
+
},
|
|
44
|
+
bottom: {
|
|
45
|
+
x: 0,
|
|
46
|
+
y: 1
|
|
47
|
+
},
|
|
48
|
+
left: {
|
|
49
|
+
x: -1,
|
|
50
|
+
y: 0
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
const TAIL_BODY_CONTROL = -.84;
|
|
54
|
+
const TAIL_MID = -.5;
|
|
55
|
+
const TAIL_TIP_CONTROL = -.225;
|
|
56
|
+
const TAIL_SHOULDER_CONTROL = -2.6149999999999998 / 4;
|
|
57
|
+
const TAIL_MID_CONTROL = 2 * TAIL_MID - TAIL_SHOULDER_CONTROL;
|
|
58
|
+
/** Sub-pixel slack when deciding whether a point sits on an edge. */
|
|
59
|
+
const EPSILON = .01;
|
|
60
|
+
const round = (value) => Number(value.toFixed(4));
|
|
61
|
+
const format = (point) => `${round(point.x)} ${round(point.y)}`;
|
|
62
|
+
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
|
|
63
|
+
/**
|
|
64
|
+
* The surface's filled region and the strokes that trace its boundary.
|
|
65
|
+
*
|
|
66
|
+
* The tail is spliced into the body's own path whenever the anchored edge has
|
|
67
|
+
* a straight run long enough to hold its base. That is the case for every
|
|
68
|
+
* top and bottom overlay and for tall ones on the left or right, and it is the
|
|
69
|
+
* better construction: one continuous outline, stroked in one pass.
|
|
70
|
+
*
|
|
71
|
+
* A short edge has no straight run at all — the corners meet in the middle of
|
|
72
|
+
* it — and no amount of splicing can put a 24px base on it. There the surface
|
|
73
|
+
* becomes the union of the body and a tail whose base sinks below the body's
|
|
74
|
+
* outline, which is what Flutter draws too. The `clip-path` unions them by
|
|
75
|
+
* winding, and the two strokes are clipped against each other.
|
|
76
|
+
*
|
|
77
|
+
* Both constructions need to know where the corners let go of the edge, and
|
|
78
|
+
* that is read off the path Lisse emitted rather than derived from the radius —
|
|
79
|
+
* see `edgeRun`.
|
|
80
|
+
*/
|
|
81
|
+
function surfacePath(geometry) {
|
|
82
|
+
const edge = OPPOSITE[geometry.side];
|
|
83
|
+
const vertical = edge === "left" || edge === "right";
|
|
84
|
+
const inset = geometry.arrow ? geometry.extent : 0;
|
|
85
|
+
const bodyWidth = Math.max(0, geometry.width - (vertical ? inset : 0));
|
|
86
|
+
const bodyHeight = Math.max(0, geometry.height - (vertical ? 0 : inset));
|
|
87
|
+
const offset = {
|
|
88
|
+
x: edge === "left" ? inset : 0,
|
|
89
|
+
y: edge === "top" ? inset : 0
|
|
90
|
+
};
|
|
91
|
+
const body = segments(generatePath(bodyWidth, bodyHeight, {
|
|
92
|
+
radius: geometry.radius,
|
|
93
|
+
smoothing: geometry.smoothing
|
|
94
|
+
}));
|
|
95
|
+
const span = vertical ? bodyHeight : bodyWidth;
|
|
96
|
+
const depth = vertical ? bodyWidth : bodyHeight;
|
|
97
|
+
if (!geometry.arrow || span <= 0 || depth <= 0) {
|
|
98
|
+
const only = translate(body, offset);
|
|
99
|
+
const middle = span / 2;
|
|
100
|
+
return {
|
|
101
|
+
clip: only,
|
|
102
|
+
outline: [only],
|
|
103
|
+
origin: edgeOrigin(edge, geometry, middle),
|
|
104
|
+
center: middle,
|
|
105
|
+
range: {
|
|
106
|
+
min: middle,
|
|
107
|
+
max: middle
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const run = edgeRun(body, edge, bodyWidth, bodyHeight);
|
|
112
|
+
const length = run ? run.end - run.start : 0;
|
|
113
|
+
const halfWidth = fitTail(geometry.arrowWidth / 2, geometry.extent, span, run);
|
|
114
|
+
if (run && length >= geometry.arrowWidth) {
|
|
115
|
+
const range = {
|
|
116
|
+
min: run.start + halfWidth,
|
|
117
|
+
max: run.end - halfWidth
|
|
118
|
+
};
|
|
119
|
+
const center = clamp(geometry.center, range.min, range.max);
|
|
120
|
+
const spliced = translate(body, offset, {
|
|
121
|
+
edge,
|
|
122
|
+
tail: tail(edge, center, halfWidth, 0, geometry.extent, bodyWidth, bodyHeight, offset, false),
|
|
123
|
+
width: bodyWidth,
|
|
124
|
+
height: bodyHeight
|
|
125
|
+
});
|
|
126
|
+
return {
|
|
127
|
+
clip: spliced,
|
|
128
|
+
outline: [spliced],
|
|
129
|
+
origin: edgeOrigin(edge, geometry, center),
|
|
130
|
+
center,
|
|
131
|
+
range
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
const minimum = geometry.radius + halfWidth + 1;
|
|
135
|
+
const maximum = span - minimum;
|
|
136
|
+
const range = maximum < minimum ? {
|
|
137
|
+
min: span / 2,
|
|
138
|
+
max: span / 2
|
|
139
|
+
} : {
|
|
140
|
+
min: minimum,
|
|
141
|
+
max: maximum
|
|
142
|
+
};
|
|
143
|
+
const center = clamp(geometry.center, range.min, range.max);
|
|
144
|
+
const origin = edgeOrigin(edge, geometry, center);
|
|
145
|
+
const startCorner = run ? run.start : span / 2;
|
|
146
|
+
const endCorner = run ? span - run.end : span / 2;
|
|
147
|
+
const sink = Math.min(depth / 2, Math.max(sunkBase(center - halfWidth, startCorner), sunkBase(span - center - halfWidth, endCorner)) + 1);
|
|
148
|
+
if (halfWidth <= sink) {
|
|
149
|
+
const only = translate(body, offset);
|
|
150
|
+
return {
|
|
151
|
+
clip: only,
|
|
152
|
+
outline: [only],
|
|
153
|
+
origin,
|
|
154
|
+
center,
|
|
155
|
+
range
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
const bodyPath = translate(body, offset);
|
|
159
|
+
const tailPath = tail(edge, center, halfWidth, -sink, geometry.extent, bodyWidth, bodyHeight, offset, true);
|
|
160
|
+
return {
|
|
161
|
+
clip: `${bodyPath} ${tailPath}`,
|
|
162
|
+
outline: [bodyPath, tailPath],
|
|
163
|
+
origin,
|
|
164
|
+
center,
|
|
165
|
+
range
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* The widest half-tail this edge can carry.
|
|
170
|
+
*
|
|
171
|
+
* A tail wider than the edge's straight run has to sit partly on the corners,
|
|
172
|
+
* where the body's outline curves away beneath it — so the wider it gets, the
|
|
173
|
+
* deeper it has to sink and the less of it stays above the surface. Rather than
|
|
174
|
+
* bury a full-width tail and leave only its tip showing, narrow it until it
|
|
175
|
+
* sinks by no more than a quarter of its own height, which keeps the shape
|
|
176
|
+
* ClaraLight draws and simply scales it to the surface.
|
|
177
|
+
*/
|
|
178
|
+
function fitTail(halfWidth, extent, span, run) {
|
|
179
|
+
if (run && run.end - run.start >= halfWidth * 2) return halfWidth;
|
|
180
|
+
const corner = run ? Math.max(run.start, span - run.end) : span / 2;
|
|
181
|
+
const budget = extent / 4;
|
|
182
|
+
const reach = Math.sqrt(Math.max(0, 2 * corner * budget - budget * budget));
|
|
183
|
+
return Math.max(0, Math.min(halfWidth, Math.max(0, span / 2 - corner) + reach));
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* How deep the body's outline lies at a base end that has run `distance` past
|
|
187
|
+
* the start of the edge, approximating the corner as a circle of its own
|
|
188
|
+
* tangency length — exact at both ends of the corner and within a fraction of
|
|
189
|
+
* a pixel between them, which is all the join needs.
|
|
190
|
+
*/
|
|
191
|
+
function sunkBase(distance, corner) {
|
|
192
|
+
if (distance >= corner) return 0;
|
|
193
|
+
const into = clamp(corner - distance, 0, corner);
|
|
194
|
+
return corner - Math.sqrt(Math.max(0, corner * corner - into * into));
|
|
195
|
+
}
|
|
196
|
+
/** The tail's tip, in the surface's own coordinates: what the entrance grows from. */
|
|
197
|
+
function edgeOrigin(edge, geometry, center) {
|
|
198
|
+
switch (edge) {
|
|
199
|
+
case "top": return {
|
|
200
|
+
x: center,
|
|
201
|
+
y: 0
|
|
202
|
+
};
|
|
203
|
+
case "bottom": return {
|
|
204
|
+
x: center,
|
|
205
|
+
y: geometry.height
|
|
206
|
+
};
|
|
207
|
+
case "left": return {
|
|
208
|
+
x: 0,
|
|
209
|
+
y: center
|
|
210
|
+
};
|
|
211
|
+
case "right": return {
|
|
212
|
+
x: geometry.width,
|
|
213
|
+
y: center
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* The tail, entering and leaving along the edge's own direction of travel so it
|
|
219
|
+
* can be dropped straight into the body's path, or closed into a subpath of its
|
|
220
|
+
* own when the surface is a union.
|
|
221
|
+
*
|
|
222
|
+
* `base` and `tip` are the tail's two ends measured outwards from the body's
|
|
223
|
+
* edge, so a negative base is one that starts below the surface it grows from.
|
|
224
|
+
*/
|
|
225
|
+
function tail(edge, center, halfWidth, base, tip, bodyWidth, bodyHeight, offset, closed) {
|
|
226
|
+
const along = ALONG[edge];
|
|
227
|
+
const outward = OUTWARD[edge];
|
|
228
|
+
const start = {
|
|
229
|
+
x: (edge === "right" ? bodyWidth : edge === "left" ? 0 : center) + offset.x,
|
|
230
|
+
y: (edge === "bottom" ? bodyHeight : edge === "top" ? 0 : center) + offset.y
|
|
231
|
+
};
|
|
232
|
+
/** `cross` runs along the edge from the tail's centre, `out` from base to tip. */
|
|
233
|
+
const point = (cross, out) => {
|
|
234
|
+
const distance = base + out * (tip - base);
|
|
235
|
+
return {
|
|
236
|
+
x: start.x + along.x * cross * halfWidth + outward.x * distance,
|
|
237
|
+
y: start.y + along.y * cross * halfWidth + outward.y * distance
|
|
238
|
+
};
|
|
239
|
+
};
|
|
240
|
+
const curve = (c1, c2, end) => `C ${format(c1)} ${format(c2)} ${format(end)}`;
|
|
241
|
+
return [
|
|
242
|
+
`${closed ? "M" : "L"} ${format(point(-1, 0))}`,
|
|
243
|
+
curve(point(TAIL_BODY_CONTROL, 0), point(TAIL_SHOULDER_CONTROL, 0), point(TAIL_MID, .25)),
|
|
244
|
+
curve(point(TAIL_MID_CONTROL, .5), point(TAIL_TIP_CONTROL, 1), point(0, 1)),
|
|
245
|
+
curve(point(.225, 1), point(.34625000000000006, .5), point(.5, .25)),
|
|
246
|
+
curve(point(.6537499999999999, 0), point(.84, 0), point(1, 0)),
|
|
247
|
+
...closed ? ["Z"] : []
|
|
248
|
+
].join(" ");
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Lisse's output, split into commands that know where the pen was.
|
|
252
|
+
*
|
|
253
|
+
* Only `M` and `L` are absolute; corners are relative `c` and `a`, but they
|
|
254
|
+
* still advance the pen, and every one of them ends on its last coordinate
|
|
255
|
+
* pair — which is all the tracking needs.
|
|
256
|
+
*/
|
|
257
|
+
function segments(path) {
|
|
258
|
+
const commands = path.match(/[A-Za-z][^A-Za-z]*/g) ?? [];
|
|
259
|
+
const parsed = [];
|
|
260
|
+
let pen = {
|
|
261
|
+
x: 0,
|
|
262
|
+
y: 0
|
|
263
|
+
};
|
|
264
|
+
for (const command of commands) {
|
|
265
|
+
const type = command[0] ?? "";
|
|
266
|
+
const from = pen;
|
|
267
|
+
if (type === "M" || type === "L") {
|
|
268
|
+
const numbers = (command.slice(1).match(/-?\d*\.?\d+(?:e[-+]?\d+)?/gi) ?? []).map(Number);
|
|
269
|
+
pen = {
|
|
270
|
+
x: numbers[0] ?? 0,
|
|
271
|
+
y: numbers[1] ?? 0
|
|
272
|
+
};
|
|
273
|
+
} else if (type !== "Z" && type !== "z") {
|
|
274
|
+
const numbers = (command.slice(1).match(/-?\d*\.?\d+(?:e[-+]?\d+)?/gi) ?? []).map(Number);
|
|
275
|
+
pen = {
|
|
276
|
+
x: pen.x + (numbers.at(-2) ?? 0),
|
|
277
|
+
y: pen.y + (numbers.at(-1) ?? 0)
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
parsed.push({
|
|
281
|
+
type,
|
|
282
|
+
raw: command.trim(),
|
|
283
|
+
from,
|
|
284
|
+
to: pen
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
return parsed;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Where the corners let go of one edge, read off the body Lisse actually drew.
|
|
291
|
+
*
|
|
292
|
+
* Not derived from the radius, because `(1 + smoothing) * radius` is not what
|
|
293
|
+
* Lisse spends on a corner: it caps the radius to half the shorter side, caps
|
|
294
|
+
* the smoothing again to the corner's budget, and for a body as flat as a
|
|
295
|
+
* tooltip it switches construction altogether. Each of those makes the
|
|
296
|
+
* arithmetic wrong in a different direction — too long a corner sends a surface
|
|
297
|
+
* to the union branch that had room to splice, too short a one lets the tail's
|
|
298
|
+
* base reach into the corner, where the splice doubles back along the edge and
|
|
299
|
+
* the stroke runs over the curve twice.
|
|
300
|
+
*
|
|
301
|
+
* The emitted path already knows the answer. Every edge carries exactly one
|
|
302
|
+
* non-degenerate `L`, which is the run — the generator emits a zero-length `L`
|
|
303
|
+
* after each corner as well, so length is part of the test rather than position
|
|
304
|
+
* alone, and an edge whose corners meet has no run at all.
|
|
305
|
+
*/
|
|
306
|
+
function edgeRun(body, edge, width, height) {
|
|
307
|
+
const along = edge === "left" || edge === "right" ? "y" : "x";
|
|
308
|
+
for (const segment of body) {
|
|
309
|
+
if (segment.type !== "L") continue;
|
|
310
|
+
if (!onEdge(segment.from, edge, width, height)) continue;
|
|
311
|
+
if (!onEdge(segment.to, edge, width, height)) continue;
|
|
312
|
+
const start = Math.min(segment.from[along], segment.to[along]);
|
|
313
|
+
const end = Math.max(segment.from[along], segment.to[along]);
|
|
314
|
+
if (end - start > EPSILON) return {
|
|
315
|
+
start,
|
|
316
|
+
end
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Move the generated body into place and, when asked, insert the tail.
|
|
323
|
+
*
|
|
324
|
+
* Absolute commands are rewritten; relative corners come through as they were.
|
|
325
|
+
* The tail replaces the `L` that runs along the target edge, which is the same
|
|
326
|
+
* segment `edgeRun` measured.
|
|
327
|
+
*/
|
|
328
|
+
function translate(body, offset, splice) {
|
|
329
|
+
const output = [];
|
|
330
|
+
let inserted = false;
|
|
331
|
+
for (const segment of body) {
|
|
332
|
+
const { type, from, to } = segment;
|
|
333
|
+
if (type === "M" || type === "L") {
|
|
334
|
+
if (splice && !inserted && type === "L" && onEdge(from, splice.edge, splice.width, splice.height) && onEdge(to, splice.edge, splice.width, splice.height) && (Math.abs(to.x - from.x) > EPSILON || Math.abs(to.y - from.y) > EPSILON)) {
|
|
335
|
+
output.push(splice.tail);
|
|
336
|
+
inserted = true;
|
|
337
|
+
}
|
|
338
|
+
output.push(`${type} ${format({
|
|
339
|
+
x: to.x + offset.x,
|
|
340
|
+
y: to.y + offset.y
|
|
341
|
+
})}`);
|
|
342
|
+
} else if (type === "Z" || type === "z") output.push("Z");
|
|
343
|
+
else output.push(segment.raw);
|
|
344
|
+
}
|
|
345
|
+
return output.join(" ");
|
|
346
|
+
}
|
|
347
|
+
function onEdge(point, edge, width, height) {
|
|
348
|
+
switch (edge) {
|
|
349
|
+
case "top": return Math.abs(point.y) < EPSILON;
|
|
350
|
+
case "bottom": return Math.abs(point.y - height) < EPSILON;
|
|
351
|
+
case "left": return Math.abs(point.x) < EPSILON;
|
|
352
|
+
case "right": return Math.abs(point.x - width) < EPSILON;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
/** Big enough to cover any surface, so `evenodd` leaves everything outside a subpath. */
|
|
356
|
+
const COVER = "M -9999 -9999 H 9999 V 9999 H -9999 Z";
|
|
357
|
+
const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
358
|
+
const EMPTY = {
|
|
359
|
+
clip: "",
|
|
360
|
+
outline: [],
|
|
361
|
+
origin: null,
|
|
362
|
+
width: 0,
|
|
363
|
+
height: 0,
|
|
364
|
+
side: "top",
|
|
365
|
+
tailCenter: null
|
|
366
|
+
};
|
|
367
|
+
function AnchoredSurface({ radius, side, arrow = true, smoothing, wrapperClassName, className, asChild = false, children, ref: forwardedRef, style, tailMotion = "none", ...props }) {
|
|
368
|
+
const [element, setElement] = useState(null);
|
|
369
|
+
const nodes = useRef({
|
|
370
|
+
root: null,
|
|
371
|
+
svg: null,
|
|
372
|
+
clip: null,
|
|
373
|
+
strokes: [],
|
|
374
|
+
masks: []
|
|
375
|
+
});
|
|
376
|
+
const child = asChild ? getShapeChild(children) : void 0;
|
|
377
|
+
const childProps = child?.props;
|
|
378
|
+
const mergedRef = useMemo(() => composeRefs(setElement, forwardedRef, childProps?.ref), [forwardedRef, childProps?.ref]);
|
|
379
|
+
const mergedStyle = {
|
|
380
|
+
...style,
|
|
381
|
+
...childProps?.style
|
|
382
|
+
};
|
|
383
|
+
const appearance = useAppearance(element, nodes, {
|
|
384
|
+
radius,
|
|
385
|
+
side,
|
|
386
|
+
arrow,
|
|
387
|
+
smoothing,
|
|
388
|
+
tailMotion
|
|
389
|
+
});
|
|
390
|
+
const clipId = `cl-anchored-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
|
|
391
|
+
const shaped = appearance.clip.length > 0;
|
|
392
|
+
const shapeProps = {
|
|
393
|
+
...props,
|
|
394
|
+
ref: mergedRef,
|
|
395
|
+
"data-cl-anchored": radius,
|
|
396
|
+
style: {
|
|
397
|
+
...mergedStyle,
|
|
398
|
+
borderRadius: shaped ? void 0 : mergedStyle.borderRadius ?? `var(--radius-${radius})`,
|
|
399
|
+
clipPath: shaped ? `path("${appearance.clip}")` : void 0
|
|
400
|
+
},
|
|
401
|
+
className: cn("cl-anchored", arrow && "cl-anchored-arrow", className, childProps?.className)
|
|
402
|
+
};
|
|
403
|
+
const border = appearance.border;
|
|
404
|
+
const stroke = shaped && border && typeof border.color === "string" ? border : void 0;
|
|
405
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
406
|
+
ref: (node) => {
|
|
407
|
+
nodes.current.root = node;
|
|
408
|
+
},
|
|
409
|
+
className: cn("cl-anchored-root relative", wrapperClassName),
|
|
410
|
+
style: appearance.origin ? { transformOrigin: `${appearance.origin.x}px ${appearance.origin.y}px` } : void 0,
|
|
411
|
+
children: [child ? cloneElement(child, shapeProps) : /* @__PURE__ */ jsx("div", {
|
|
412
|
+
...shapeProps,
|
|
413
|
+
children
|
|
414
|
+
}), stroke ? /* @__PURE__ */ jsxs("svg", {
|
|
415
|
+
ref: (node) => {
|
|
416
|
+
nodes.current.svg = node;
|
|
417
|
+
},
|
|
418
|
+
"aria-hidden": "true",
|
|
419
|
+
className: "pointer-events-none absolute top-0 left-0",
|
|
420
|
+
width: appearance.width,
|
|
421
|
+
height: appearance.height,
|
|
422
|
+
children: [/* @__PURE__ */ jsxs("defs", { children: [/* @__PURE__ */ jsx("clipPath", {
|
|
423
|
+
id: clipId,
|
|
424
|
+
children: /* @__PURE__ */ jsx("path", {
|
|
425
|
+
ref: (node) => {
|
|
426
|
+
nodes.current.clip = node;
|
|
427
|
+
},
|
|
428
|
+
d: appearance.clip
|
|
429
|
+
})
|
|
430
|
+
}), appearance.outline.length > 1 ? appearance.outline.map((_, index) => /* @__PURE__ */ jsx("clipPath", {
|
|
431
|
+
id: `${clipId}-${index}`,
|
|
432
|
+
children: /* @__PURE__ */ jsx("path", {
|
|
433
|
+
ref: (node) => {
|
|
434
|
+
nodes.current.masks[index] = node;
|
|
435
|
+
},
|
|
436
|
+
clipRule: "evenodd",
|
|
437
|
+
d: `${COVER} ${appearance.outline[index === 0 ? 1 : 0]}`
|
|
438
|
+
})
|
|
439
|
+
}, index)) : null] }), /* @__PURE__ */ jsx("g", {
|
|
440
|
+
clipPath: `url(#${clipId})`,
|
|
441
|
+
children: appearance.outline.map((d, index) => /* @__PURE__ */ jsx("path", {
|
|
442
|
+
ref: (node) => {
|
|
443
|
+
nodes.current.strokes[index] = node;
|
|
444
|
+
},
|
|
445
|
+
d,
|
|
446
|
+
fill: "none",
|
|
447
|
+
stroke: stroke.color,
|
|
448
|
+
strokeOpacity: stroke.opacity,
|
|
449
|
+
strokeWidth: stroke.width * 2,
|
|
450
|
+
clipPath: appearance.outline.length > 1 ? `url(#${clipId}-${index})` : void 0
|
|
451
|
+
}, index))
|
|
452
|
+
})]
|
|
453
|
+
}) : null]
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
function getShapeChild(child) {
|
|
457
|
+
if (!isValidElement(child) || child.type === Fragment) throw new Error("AnchoredSurface: `asChild` expects exactly one non-Fragment React element.");
|
|
458
|
+
return child;
|
|
459
|
+
}
|
|
460
|
+
const BORDER_SIDES = [
|
|
461
|
+
"border-top-color",
|
|
462
|
+
"border-right-color",
|
|
463
|
+
"border-bottom-color",
|
|
464
|
+
"border-left-color"
|
|
465
|
+
];
|
|
466
|
+
/** Insets Base UI writes the positioner's place with, per axis. */
|
|
467
|
+
const ALONG_X = ["left", "right"];
|
|
468
|
+
const ALONG_Y = ["top", "bottom"];
|
|
469
|
+
/**
|
|
470
|
+
* Measure the surface and keep the fused path in sync.
|
|
471
|
+
*
|
|
472
|
+
* Every input is read from the shape's own computed style rather than from
|
|
473
|
+
* `documentElement`, so a locally scoped theme, a `rem` radius or an inline
|
|
474
|
+
* token override all work — the same reasoning as `Squircle`'s reader.
|
|
475
|
+
*
|
|
476
|
+
* There is no frame polling at rest. Four things move the tail, and each one has
|
|
477
|
+
* a signal: the surface resizing (`ResizeObserver`), Base UI flipping the side
|
|
478
|
+
* (`data-side`), Floating UI re-solving the arrow offset, which it writes as
|
|
479
|
+
* inline `left`/`top` on the probe, and the positioner being sent to a new
|
|
480
|
+
* anchor, which it writes as inline insets (`[data-cl-anchor-track]`). The last
|
|
481
|
+
* is what a shared tooltip changing trigger looks like, and the only one that
|
|
482
|
+
* starts the motion track.
|
|
483
|
+
*/
|
|
484
|
+
function useAppearance(element, nodes, inputs) {
|
|
485
|
+
const [appearance, setAppearance] = useState(EMPTY);
|
|
486
|
+
const latest = useRef(inputs);
|
|
487
|
+
latest.current = inputs;
|
|
488
|
+
/** What React last rendered, which is the DOM structure a frame may write into. */
|
|
489
|
+
const renderedRef = useRef(EMPTY);
|
|
490
|
+
const syncRef = useRef(null);
|
|
491
|
+
useIsomorphicLayoutEffect(() => {
|
|
492
|
+
if (!element) return;
|
|
493
|
+
const view = element.ownerDocument.defaultView;
|
|
494
|
+
if (!view) return;
|
|
495
|
+
const saved = /* @__PURE__ */ new Map();
|
|
496
|
+
let frame = null;
|
|
497
|
+
let measured = null;
|
|
498
|
+
/** Whether the surface has changed since it was last read. */
|
|
499
|
+
let stale = true;
|
|
500
|
+
/** What the DOM is showing, which a frame of the motion track may have written. */
|
|
501
|
+
let showing = EMPTY;
|
|
502
|
+
/** The tail centre the track is holding, or `null` when the tail is at rest. */
|
|
503
|
+
let leading = null;
|
|
504
|
+
/** How far along its edge the tail could reach, as of the last surface built. */
|
|
505
|
+
let reach = null;
|
|
506
|
+
let clock = 0;
|
|
507
|
+
let deadline = 0;
|
|
508
|
+
const restore = () => {
|
|
509
|
+
for (const [property, source] of saved) if (element.style.getPropertyValue(property) === "transparent") element.style.setProperty(property, source.value, source.priority);
|
|
510
|
+
saved.clear();
|
|
511
|
+
};
|
|
512
|
+
const same = (a, b) => a.clip === b.clip && a.width === b.width && a.height === b.height && a.side === b.side && a.tailCenter === b.tailCenter && a.origin?.x === b.origin?.x && a.origin?.y === b.origin?.y && a.outline.length === b.outline.length && a.outline.every((subpath, index) => subpath === b.outline[index]) && a.border?.color === b.border?.color && a.border?.opacity === b.border?.opacity && a.border?.width === b.border?.width;
|
|
513
|
+
const commit = (next) => {
|
|
514
|
+
renderedRef.current = next;
|
|
515
|
+
showing = next;
|
|
516
|
+
setAppearance((current) => same(current, next) ? current : next);
|
|
517
|
+
};
|
|
518
|
+
/** Whether an appearance renders the outline, and with how many subpaths. */
|
|
519
|
+
const structure = (value) => `${value.outline.length}:${value.clip.length > 0 && typeof value.border?.color === "string"}`;
|
|
520
|
+
/**
|
|
521
|
+
* Write one frame of the tail's slide straight into the DOM.
|
|
522
|
+
*
|
|
523
|
+
* Only the coordinates move while the tail travels, so the frames set
|
|
524
|
+
* attributes on what React already rendered and leave the reconciler out of
|
|
525
|
+
* the animation; the last frame commits. A frame that *would* change the
|
|
526
|
+
* structure — the surface becoming a union, or its outline appearing — goes
|
|
527
|
+
* through state instead, and the frame after it paints again.
|
|
528
|
+
*/
|
|
529
|
+
const paint = (next) => {
|
|
530
|
+
if (structure(next) !== structure(renderedRef.current)) {
|
|
531
|
+
commit(next);
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
showing = next;
|
|
535
|
+
const { root, svg, clip, strokes, masks } = nodes.current;
|
|
536
|
+
element.style.clipPath = `path("${next.clip}")`;
|
|
537
|
+
if (root && next.origin) root.style.transformOrigin = `${next.origin.x}px ${next.origin.y}px`;
|
|
538
|
+
svg?.setAttribute("width", String(next.width));
|
|
539
|
+
svg?.setAttribute("height", String(next.height));
|
|
540
|
+
clip?.setAttribute("d", next.clip);
|
|
541
|
+
for (const [index, subpath] of next.outline.entries()) {
|
|
542
|
+
strokes[index]?.setAttribute("d", subpath);
|
|
543
|
+
const other = next.outline[index === 0 ? 1 : 0];
|
|
544
|
+
if (other !== void 0) masks[index]?.setAttribute("d", `${COVER} ${other}`);
|
|
545
|
+
}
|
|
546
|
+
};
|
|
547
|
+
const measure = () => {
|
|
548
|
+
const { radius: token, side, arrow, smoothing } = latest.current;
|
|
549
|
+
restore();
|
|
550
|
+
const computed = view.getComputedStyle(element);
|
|
551
|
+
const { width, height } = getLayoutSize(element, computed);
|
|
552
|
+
const border = parseBorder(element, computed);
|
|
553
|
+
const number = (name) => Number.parseFloat(computed.getPropertyValue(name));
|
|
554
|
+
const duration = (name) => tokenNumber(computed.getPropertyValue(name));
|
|
555
|
+
const authored = element.style.borderTopLeftRadius;
|
|
556
|
+
element.style.borderTopLeftRadius = `var(--radius-${token})`;
|
|
557
|
+
const radius = Number.parseFloat(computed.borderTopLeftRadius);
|
|
558
|
+
element.style.borderTopLeftRadius = authored;
|
|
559
|
+
const resolvedSide = readSide(element) ?? side;
|
|
560
|
+
const tokenSmoothing = number("--cl-corner-smoothing");
|
|
561
|
+
const extent = number("--cl-arrow-extent");
|
|
562
|
+
const arrowWidth = number("--cl-arrow-width");
|
|
563
|
+
const drawArrow = arrow && Number.isFinite(extent) && Number.isFinite(arrowWidth);
|
|
564
|
+
const geometry = {
|
|
565
|
+
width,
|
|
566
|
+
height,
|
|
567
|
+
side: resolvedSide,
|
|
568
|
+
radius: Number.isFinite(radius) ? Math.max(0, radius) : 0,
|
|
569
|
+
smoothing: clamp(smoothing ?? (Number.isFinite(tokenSmoothing) ? tokenSmoothing : 0), 0, 1),
|
|
570
|
+
arrow: drawArrow,
|
|
571
|
+
extent: drawArrow ? extent : 0,
|
|
572
|
+
arrowWidth: drawArrow ? arrowWidth : 0,
|
|
573
|
+
center: drawArrow ? tailCenter(element, resolvedSide, width, height) : 0
|
|
574
|
+
};
|
|
575
|
+
if (border) for (const property of BORDER_SIDES) {
|
|
576
|
+
saved.set(property, {
|
|
577
|
+
value: element.style.getPropertyValue(property),
|
|
578
|
+
priority: element.style.getPropertyPriority(property)
|
|
579
|
+
});
|
|
580
|
+
element.style.setProperty(property, "transparent", "important");
|
|
581
|
+
}
|
|
582
|
+
stale = false;
|
|
583
|
+
return {
|
|
584
|
+
geometry,
|
|
585
|
+
border,
|
|
586
|
+
lead: duration("--cl-duration-tooltip-tail") ?? 60,
|
|
587
|
+
morph: duration("--cl-duration-tooltip-morph") ?? 180
|
|
588
|
+
};
|
|
589
|
+
};
|
|
590
|
+
const build = (source, center = source.geometry.center) => {
|
|
591
|
+
const geometry = {
|
|
592
|
+
...source.geometry,
|
|
593
|
+
center
|
|
594
|
+
};
|
|
595
|
+
const surface = geometry.width > 0 && geometry.height > 0 ? surfacePath(geometry) : void 0;
|
|
596
|
+
if (surface) reach = surface.range;
|
|
597
|
+
return {
|
|
598
|
+
width: geometry.width,
|
|
599
|
+
height: geometry.height,
|
|
600
|
+
side: geometry.side,
|
|
601
|
+
tailCenter: geometry.arrow ? surface?.center ?? null : null,
|
|
602
|
+
border: source.border,
|
|
603
|
+
clip: surface?.clip ?? "",
|
|
604
|
+
outline: surface?.outline ?? [],
|
|
605
|
+
origin: surface?.origin ?? null
|
|
606
|
+
};
|
|
607
|
+
};
|
|
608
|
+
/**
|
|
609
|
+
* How far the surface still has to travel along the tail's edge, in px.
|
|
610
|
+
*
|
|
611
|
+
* The morph is a CSS transition on the positioner's insets, so the browser is
|
|
612
|
+
* already holding both numbers this needs: the inline style is the inset Base
|
|
613
|
+
* UI asked for — the end of the journey, which a transition does not touch —
|
|
614
|
+
* and the computed style is how far along it is. Their difference is the
|
|
615
|
+
* travel left, read off the browser rather than predicted from a duration and
|
|
616
|
+
* a curve, which is what lets the tail survive a morph that is interrupted,
|
|
617
|
+
* re-aimed, or overtaken by the anchor moving.
|
|
618
|
+
*/
|
|
619
|
+
const remaining = (horizontal) => {
|
|
620
|
+
const track = element.closest("[data-cl-anchor-track]");
|
|
621
|
+
if (!track) return 0;
|
|
622
|
+
const computed = view.getComputedStyle(track);
|
|
623
|
+
for (const property of horizontal ? ALONG_X : ALONG_Y) {
|
|
624
|
+
const end = tokenNumber(track.style.getPropertyValue(property));
|
|
625
|
+
if (end === void 0) continue;
|
|
626
|
+
const now = tokenNumber(computed.getPropertyValue(property));
|
|
627
|
+
if (now === void 0) continue;
|
|
628
|
+
return property === "left" || property === "top" ? end - now : now - end;
|
|
629
|
+
}
|
|
630
|
+
return 0;
|
|
631
|
+
};
|
|
632
|
+
const stop = () => {
|
|
633
|
+
if (frame !== null) {
|
|
634
|
+
view.cancelAnimationFrame(frame);
|
|
635
|
+
frame = null;
|
|
636
|
+
}
|
|
637
|
+
leading = null;
|
|
638
|
+
};
|
|
639
|
+
/**
|
|
640
|
+
* The tail leads the surface to its new anchor, then waits for it there.
|
|
641
|
+
*
|
|
642
|
+
* Each frame asks for the centre that would keep the tail pointing at the
|
|
643
|
+
* anchor from where the surface currently is: its resting centre, plus the
|
|
644
|
+
* travel the surface has left. Early on that is far beyond the edge, so the
|
|
645
|
+
* ask is brought back to the end of the straight run *before* the follower
|
|
646
|
+
* sees it — the tail then slides to that limit over the time constant and
|
|
647
|
+
* rides there. Aiming the follower at the raw ask instead would land it on the
|
|
648
|
+
* limit in one step, because a first step of 60% of 180px is not a slide but a
|
|
649
|
+
* jump. Once the surface has closed enough of the distance for the ask to come
|
|
650
|
+
* back inside the run, the tail is already where it belongs and holds still on
|
|
651
|
+
* screen while the surface finishes arriving.
|
|
652
|
+
*
|
|
653
|
+
* The follow is a time constant rather than a duration, because there is no
|
|
654
|
+
* fixed distance to cover: the aim moves with the surface, and a new trigger
|
|
655
|
+
* can change it mid-flight. `--cl-duration-tooltip-tail` is the time to close
|
|
656
|
+
* 95% of a standing gap, which is three of those constants.
|
|
657
|
+
*
|
|
658
|
+
* Both halves of this movement are worth reading together when judging it: the
|
|
659
|
+
* surface travels on a CSS transition and the tail on this timer, so a
|
|
660
|
+
* DevTools playback rate slows one and not the other. Scale
|
|
661
|
+
* `--cl-duration-tooltip-morph` and `--cl-duration-tooltip-tail` by the same
|
|
662
|
+
* factor instead.
|
|
663
|
+
*/
|
|
664
|
+
const step = (now) => {
|
|
665
|
+
frame = null;
|
|
666
|
+
if (leading === null || latest.current.tailMotion !== "fast") return;
|
|
667
|
+
if (stale || measured === null) measured = measure();
|
|
668
|
+
const { geometry, lead } = measured;
|
|
669
|
+
const horizontal = geometry.side === "top" || geometry.side === "bottom";
|
|
670
|
+
const travel = remaining(horizontal);
|
|
671
|
+
const landed = Math.abs(travel) < .5;
|
|
672
|
+
const ask = geometry.center + travel;
|
|
673
|
+
const aim = reach ? clamp(ask, reach.min, reach.max) : ask;
|
|
674
|
+
const elapsed = clamp(now - clock, 0, 64);
|
|
675
|
+
clock = now;
|
|
676
|
+
const closed = 1 - Math.exp(-3 * elapsed / Math.max(1, lead));
|
|
677
|
+
const from = leading;
|
|
678
|
+
const next = build(measured, from + (aim - from) * closed);
|
|
679
|
+
paint(next);
|
|
680
|
+
leading = next.tailCenter ?? from;
|
|
681
|
+
if (landed && Math.abs(aim - leading) < .1 || now > deadline) {
|
|
682
|
+
stop();
|
|
683
|
+
commit(build(measured));
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
frame = view.requestAnimationFrame(step);
|
|
687
|
+
};
|
|
688
|
+
const start = (from, morph) => {
|
|
689
|
+
deadline = view.performance.now() + 2 * morph + 400;
|
|
690
|
+
if (frame !== null) return;
|
|
691
|
+
leading = from;
|
|
692
|
+
clock = view.performance.now();
|
|
693
|
+
frame = view.requestAnimationFrame(step);
|
|
694
|
+
};
|
|
695
|
+
/**
|
|
696
|
+
* Re-read the surface and put it on screen.
|
|
697
|
+
*
|
|
698
|
+
* `retarget` says the signal was the tail being sent somewhere new, which is
|
|
699
|
+
* the one that may start the motion track. While the track runs it owns the
|
|
700
|
+
* tail, so a pass of any kind only re-measures and repaints around it.
|
|
701
|
+
*/
|
|
702
|
+
const sync = (retarget) => {
|
|
703
|
+
measured = measure();
|
|
704
|
+
const { geometry, morph } = measured;
|
|
705
|
+
const flipped = showing.side !== geometry.side;
|
|
706
|
+
const reduced = view.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches ?? false;
|
|
707
|
+
if (flipped) stop();
|
|
708
|
+
if (retarget && !flipped && !reduced && latest.current.tailMotion === "fast" && geometry.arrow && showing.tailCenter !== null) start(showing.tailCenter, morph);
|
|
709
|
+
if (frame !== null && leading !== null) paint(build(measured, leading));
|
|
710
|
+
else commit(build(measured));
|
|
711
|
+
observer.takeRecords();
|
|
712
|
+
};
|
|
713
|
+
const observer = new view.MutationObserver(() => sync(true));
|
|
714
|
+
const resize = new view.ResizeObserver(() => {
|
|
715
|
+
stale = true;
|
|
716
|
+
if (frame === null) sync(false);
|
|
717
|
+
});
|
|
718
|
+
const cleanup = () => {
|
|
719
|
+
stop();
|
|
720
|
+
observer.disconnect();
|
|
721
|
+
resize.disconnect();
|
|
722
|
+
syncRef.current = null;
|
|
723
|
+
restore();
|
|
724
|
+
};
|
|
725
|
+
try {
|
|
726
|
+
syncRef.current = () => sync(false);
|
|
727
|
+
observer.observe(element, {
|
|
728
|
+
attributes: true,
|
|
729
|
+
attributeFilter: ["data-side"]
|
|
730
|
+
});
|
|
731
|
+
const probe = element.querySelector("[data-cl-anchor-probe]");
|
|
732
|
+
if (probe) observer.observe(probe, {
|
|
733
|
+
attributes: true,
|
|
734
|
+
attributeFilter: ["style"]
|
|
735
|
+
});
|
|
736
|
+
const track = element.closest("[data-cl-anchor-track]");
|
|
737
|
+
if (track) observer.observe(track, {
|
|
738
|
+
attributes: true,
|
|
739
|
+
attributeFilter: ["style"]
|
|
740
|
+
});
|
|
741
|
+
resize.observe(element);
|
|
742
|
+
sync(false);
|
|
743
|
+
return cleanup;
|
|
744
|
+
} catch (error) {
|
|
745
|
+
cleanup();
|
|
746
|
+
throw error;
|
|
747
|
+
}
|
|
748
|
+
}, [
|
|
749
|
+
element,
|
|
750
|
+
nodes,
|
|
751
|
+
inputs.arrow,
|
|
752
|
+
inputs.tailMotion
|
|
753
|
+
]);
|
|
754
|
+
useIsomorphicLayoutEffect(() => {
|
|
755
|
+
syncRef.current?.();
|
|
756
|
+
});
|
|
757
|
+
return appearance;
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* The leading number of a length or duration token, read from an element's own
|
|
761
|
+
* cascade — a local theme, a scoped override or a `rem` value all resolve.
|
|
762
|
+
*/
|
|
763
|
+
function themedNumber(element, name) {
|
|
764
|
+
const view = element?.ownerDocument.defaultView;
|
|
765
|
+
if (!element || !view) return void 0;
|
|
766
|
+
return tokenNumber(view.getComputedStyle(element).getPropertyValue(name));
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* `themedNumber` for the props Base UI cannot take as a callback.
|
|
770
|
+
*
|
|
771
|
+
* `sideOffset` accepts a function and is resolved during positioning, so it can
|
|
772
|
+
* read the token inline; `collisionPadding` and the tooltip delays are plain
|
|
773
|
+
* numbers, and reading them needs a mounted element. Running in a layout effect
|
|
774
|
+
* keeps the correction ahead of paint, so a first pass on Base UI's own default
|
|
775
|
+
* is never visible.
|
|
776
|
+
*/
|
|
777
|
+
function useThemedNumber(ref, name) {
|
|
778
|
+
const [value, setValue] = useState();
|
|
779
|
+
useIsomorphicLayoutEffect(() => {
|
|
780
|
+
const next = themedNumber(ref.current, name);
|
|
781
|
+
setValue((current) => current === next ? current : next);
|
|
782
|
+
});
|
|
783
|
+
return value;
|
|
784
|
+
}
|
|
785
|
+
function readSide(element) {
|
|
786
|
+
const side = element.dataset.side;
|
|
787
|
+
return side === "top" || side === "bottom" || side === "left" || side === "right" ? side : void 0;
|
|
788
|
+
}
|
|
789
|
+
/**
|
|
790
|
+
* Where the tail meets the body, from the surface's own origin.
|
|
791
|
+
*
|
|
792
|
+
* Read off Base UI's arrow element, which Floating UI has already solved and
|
|
793
|
+
* clamped against the anchor.
|
|
794
|
+
*
|
|
795
|
+
* Offset geometry rather than `getBoundingClientRect`, because this has to
|
|
796
|
+
* survive the entrance. The wrapper scales from zero, so on the first frame
|
|
797
|
+
* every rect inside it collapses to a point, and nothing can be recovered from
|
|
798
|
+
* the ratio — a couple of rects at 0 measure a tail at the surface's left edge.
|
|
799
|
+
* Offsets are layout space: a transform does not move them, and the measurement
|
|
800
|
+
* is therefore right the first time instead of being right later, which is the
|
|
801
|
+
* difference between a tail that follows the anchor and one that waits for the
|
|
802
|
+
* next resize.
|
|
803
|
+
*
|
|
804
|
+
* An absolutely positioned box is placed from its containing block's *padding*
|
|
805
|
+
* box, so a probe whose offset parent is the surface itself is short by that
|
|
806
|
+
* surface's own border — and the path is drawn in border-box space. The surface
|
|
807
|
+
* is the probe's containing block whenever it carries the frost, whose
|
|
808
|
+
* `backdrop-filter` establishes one; the wrapper is the alternative, and it has
|
|
809
|
+
* no insets of its own to correct for.
|
|
810
|
+
*/
|
|
811
|
+
function tailCenter(element, side, width, height) {
|
|
812
|
+
const probe = element.querySelector("[data-cl-anchor-probe]");
|
|
813
|
+
if (!probe) return (side === "left" || side === "right" ? height : width) / 2;
|
|
814
|
+
const horizontal = side === "top" || side === "bottom";
|
|
815
|
+
const border = probe.offsetParent === element ? horizontal ? element.clientLeft : element.clientTop : 0;
|
|
816
|
+
return horizontal ? probe.offsetLeft + border + probe.offsetWidth / 2 : probe.offsetTop + border + probe.offsetHeight / 2;
|
|
817
|
+
}
|
|
818
|
+
//#endregion
|
|
819
|
+
export { AnchoredSurface, surfacePath, themedNumber, tokenNumber, useThemedNumber };
|
|
820
|
+
|
|
821
|
+
//# sourceMappingURL=anchored.js.map
|