@juspay/svelte-ui-components 2.89.2 → 2.90.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/Menu/Menu.svelte
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { tick, onMount } from 'svelte';
|
|
3
3
|
import { SvelteMap } from 'svelte/reactivity';
|
|
4
4
|
import Img from '../Img/Img.svelte';
|
|
5
|
-
import type { MenuProperties, MenuItem } from './properties';
|
|
5
|
+
import type { MenuProperties, MenuItem, MenuPlacement } from './properties';
|
|
6
6
|
|
|
7
7
|
let {
|
|
8
8
|
items,
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
selectedValue = null,
|
|
17
17
|
role: menuRole = 'menu',
|
|
18
18
|
ariaLabel: menuAriaLabel,
|
|
19
|
-
id: menuId
|
|
19
|
+
id: menuId,
|
|
20
|
+
placement = 'bottom-left'
|
|
20
21
|
}: MenuProperties = $props();
|
|
21
22
|
|
|
22
23
|
let itemRole = $derived(menuRole === 'listbox' ? 'option' : 'menuitem');
|
|
@@ -28,6 +29,42 @@
|
|
|
28
29
|
let typeaheadQuery: string = $state('');
|
|
29
30
|
let typeaheadTimer: ReturnType<typeof setTimeout> | null = $state(null);
|
|
30
31
|
|
|
32
|
+
/** Fixed corner the dropdown is currently anchored to (resolved from `placement`). */
|
|
33
|
+
let resolvedPlacement: Exclude<MenuPlacement, 'auto'> = $state('bottom-left');
|
|
34
|
+
/** True while an `'auto'` open is measuring the hidden panel — suppresses paint. */
|
|
35
|
+
let measuringPlacement: boolean = $state(false);
|
|
36
|
+
|
|
37
|
+
/** Viewport padding the auto placement keeps between the panel and the edges. */
|
|
38
|
+
const AUTO_PLACEMENT_VIEWPORT_MARGIN = 8;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolves `'auto'` against the live geometry: the panel renders hidden at the
|
|
42
|
+
* default corner first, then flips right/up only when the default overflows
|
|
43
|
+
* the viewport and the opposite side actually has room for the panel.
|
|
44
|
+
*/
|
|
45
|
+
function resolveAutoPlacement(): Exclude<MenuPlacement, 'auto'> {
|
|
46
|
+
if (menuContainerEl === null || menuListEl === null) {
|
|
47
|
+
return 'bottom-left';
|
|
48
|
+
}
|
|
49
|
+
const containerRect = menuContainerEl.getBoundingClientRect();
|
|
50
|
+
const panelRect = menuListEl.getBoundingClientRect();
|
|
51
|
+
const viewportWidth = window.innerWidth;
|
|
52
|
+
const viewportHeight = window.innerHeight;
|
|
53
|
+
|
|
54
|
+
const overflowsRight =
|
|
55
|
+
containerRect.left + panelRect.width > viewportWidth - AUTO_PLACEMENT_VIEWPORT_MARGIN;
|
|
56
|
+
const fitsRightAnchored =
|
|
57
|
+
containerRect.right - panelRect.width >= AUTO_PLACEMENT_VIEWPORT_MARGIN;
|
|
58
|
+
const horizontal = overflowsRight && fitsRightAnchored ? 'right' : 'left';
|
|
59
|
+
|
|
60
|
+
const overflowsBottom =
|
|
61
|
+
containerRect.bottom + panelRect.height > viewportHeight - AUTO_PLACEMENT_VIEWPORT_MARGIN;
|
|
62
|
+
const fitsAbove = containerRect.top - panelRect.height >= AUTO_PLACEMENT_VIEWPORT_MARGIN;
|
|
63
|
+
const vertical = overflowsBottom && fitsAbove ? 'top' : 'bottom';
|
|
64
|
+
|
|
65
|
+
return `${vertical}-${horizontal}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
31
68
|
let selectableItems: MenuItem[] = $derived(
|
|
32
69
|
items.filter((item) => item.separator !== true && item.disabled !== true)
|
|
33
70
|
);
|
|
@@ -46,6 +83,15 @@
|
|
|
46
83
|
|
|
47
84
|
function openMenu(startIndex: number | null = null) {
|
|
48
85
|
open = true;
|
|
86
|
+
if (placement === 'auto') {
|
|
87
|
+
// Render the panel hidden at the default corner for one tick so it has
|
|
88
|
+
// real dimensions to measure, then anchor it to the resolved corner.
|
|
89
|
+
resolvedPlacement = 'bottom-left';
|
|
90
|
+
measuringPlacement = true;
|
|
91
|
+
} else {
|
|
92
|
+
resolvedPlacement = placement;
|
|
93
|
+
measuringPlacement = false;
|
|
94
|
+
}
|
|
49
95
|
// With a known selection, opening focuses the selected option (listbox
|
|
50
96
|
// convention) instead of always parking the focus highlight on item 0.
|
|
51
97
|
const selectedItem =
|
|
@@ -57,6 +103,10 @@
|
|
|
57
103
|
focusedIndex = initialIndex;
|
|
58
104
|
onopen?.();
|
|
59
105
|
tick().then(() => {
|
|
106
|
+
if (placement === 'auto') {
|
|
107
|
+
resolvedPlacement = resolveAutoPlacement();
|
|
108
|
+
measuringPlacement = false;
|
|
109
|
+
}
|
|
60
110
|
focusItem(initialIndex);
|
|
61
111
|
});
|
|
62
112
|
}
|
|
@@ -224,7 +274,8 @@
|
|
|
224
274
|
|
|
225
275
|
{#if open}
|
|
226
276
|
<div
|
|
227
|
-
class="menu-dropdown"
|
|
277
|
+
class="menu-dropdown menu-dropdown-{placement === 'auto' ? resolvedPlacement : placement}"
|
|
278
|
+
class:menu-dropdown-measuring={measuringPlacement}
|
|
228
279
|
bind:this={menuListEl}
|
|
229
280
|
role={menuRole}
|
|
230
281
|
id={menuId}
|
|
@@ -310,6 +361,33 @@
|
|
|
310
361
|
margin: var(--menu-margin, 4px 0);
|
|
311
362
|
}
|
|
312
363
|
|
|
364
|
+
/* Placement corners — `bottom-left` is the base rule above (and stays fully
|
|
365
|
+
driven by the --menu-dropdown-top/left consumer tokens); the other corners
|
|
366
|
+
override the anchoring sides. The chained selector outweighs the base rule
|
|
367
|
+
regardless of source order. */
|
|
368
|
+
.menu-dropdown.menu-dropdown-bottom-right {
|
|
369
|
+
left: auto;
|
|
370
|
+
right: 0;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
.menu-dropdown.menu-dropdown-top-left {
|
|
374
|
+
top: auto;
|
|
375
|
+
bottom: 100%;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
.menu-dropdown.menu-dropdown-top-right {
|
|
379
|
+
top: auto;
|
|
380
|
+
bottom: 100%;
|
|
381
|
+
left: auto;
|
|
382
|
+
right: 0;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/* One-tick measuring pass for placement="auto": the panel needs rendered
|
|
386
|
+
dimensions before the corner is chosen, without a visible flash. */
|
|
387
|
+
.menu-dropdown.menu-dropdown-measuring {
|
|
388
|
+
visibility: hidden;
|
|
389
|
+
}
|
|
390
|
+
|
|
313
391
|
.menu-item {
|
|
314
392
|
display: flex;
|
|
315
393
|
align-items: center;
|
|
@@ -8,6 +8,14 @@ export type MenuItem = {
|
|
|
8
8
|
separator?: boolean;
|
|
9
9
|
id?: string;
|
|
10
10
|
};
|
|
11
|
+
/**
|
|
12
|
+
* Corner of the trigger the dropdown anchors to. The four fixed corners map to
|
|
13
|
+
* static CSS anchoring; `'auto'` measures the rendered panel on every open and
|
|
14
|
+
* picks the corner that keeps it inside the viewport — right-anchoring when the
|
|
15
|
+
* panel would overflow the right edge, flipping above the trigger when there is
|
|
16
|
+
* not enough room below but enough above.
|
|
17
|
+
*/
|
|
18
|
+
export type MenuPlacement = 'bottom-left' | 'bottom-right' | 'top-left' | 'top-right' | 'auto';
|
|
11
19
|
export type MenuProperties = MandatoryMenuProperties & OptionalMenuProperties & MenuEventProperties;
|
|
12
20
|
export type MandatoryMenuProperties = {
|
|
13
21
|
items: MenuItem[];
|
|
@@ -25,6 +33,11 @@ export type OptionalMenuProperties = {
|
|
|
25
33
|
role?: 'menu' | 'listbox';
|
|
26
34
|
ariaLabel?: string;
|
|
27
35
|
id?: string;
|
|
36
|
+
/** Dropdown anchoring relative to the trigger. Defaults to `'bottom-left'`,
|
|
37
|
+
* which preserves the existing behavior (including the `--menu-dropdown-top`
|
|
38
|
+
* / `--menu-dropdown-left` consumer tokens). Fixed corners anchor statically;
|
|
39
|
+
* `'auto'` resolves the best-fitting corner against the viewport on open. */
|
|
40
|
+
placement?: MenuPlacement;
|
|
28
41
|
};
|
|
29
42
|
export type MenuEventProperties = {
|
|
30
43
|
onselect?: (item: MenuItem) => void;
|
|
@@ -10,7 +10,11 @@
|
|
|
10
10
|
import { computePieLayout } from '../_chart/geometry';
|
|
11
11
|
import { getColor } from '../_chart/colors';
|
|
12
12
|
import { formatNumber } from '../_chart/format';
|
|
13
|
+
import { measureText, readCssVarPx } from '../_chart/measure';
|
|
14
|
+
import { truncateToWidth, placedLabelRect, dropOverlapping } from '../_chart/labels';
|
|
15
|
+
import type { LabelRect } from '../_chart/labels';
|
|
13
16
|
import type { LegendItem } from '../_chart/types';
|
|
17
|
+
import { SvelteMap } from 'svelte/reactivity';
|
|
14
18
|
|
|
15
19
|
// ── Props ──────────────────────────────────────────────────────
|
|
16
20
|
|
|
@@ -153,6 +157,76 @@
|
|
|
153
157
|
data.map((d, i) => ({ label: d.label, color: d.color ?? getColor(i) }))
|
|
154
158
|
);
|
|
155
159
|
|
|
160
|
+
// ── Label engine ───────────────────────────────────────────────
|
|
161
|
+
// A crowded pie (many slices, long labels) used to render every label
|
|
162
|
+
// unconditionally at its mid-angle: stacked unreadable text that also ran
|
|
163
|
+
// past the chart box. Labels are now measured, truncated to the horizontal
|
|
164
|
+
// room the chart actually has, gated on the slice's arc length (inside
|
|
165
|
+
// position), and de-collided with larger slices winning. Dropped or
|
|
166
|
+
// truncated text stays available on the tooltip and aria-label.
|
|
167
|
+
let visibleSliceLabels = $derived.by(() => {
|
|
168
|
+
const visible = new SvelteMap<number, string>();
|
|
169
|
+
if ((!showLabels && !showValues) || chartWidth <= 0) {
|
|
170
|
+
return visible;
|
|
171
|
+
}
|
|
172
|
+
const font = {
|
|
173
|
+
size: containerEl ? readCssVarPx(containerEl, '--piechart-label-font-size', 12) : 12
|
|
174
|
+
};
|
|
175
|
+
const lineHeight = measureText('Ag', font).height;
|
|
176
|
+
|
|
177
|
+
type LabelCandidate = { index: number; value: number; text: string; rect: LabelRect };
|
|
178
|
+
const candidates: LabelCandidate[] = [];
|
|
179
|
+
for (const slice of slices) {
|
|
180
|
+
const parts: string[] = [];
|
|
181
|
+
if (showLabels) {
|
|
182
|
+
parts.push(slice.label);
|
|
183
|
+
}
|
|
184
|
+
if (showValues) {
|
|
185
|
+
parts.push(pctFormat(slice.value));
|
|
186
|
+
}
|
|
187
|
+
const raw = parts.join(' ').trim();
|
|
188
|
+
if (raw.length === 0) {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const absX = cx + slice.labelX;
|
|
192
|
+
const absY = cy + slice.labelY;
|
|
193
|
+
// text-anchor is middle, so the budget is twice the room to the nearer edge.
|
|
194
|
+
const budget = Math.max(0, Math.min(absX, chartWidth - absX) * 2 - 8);
|
|
195
|
+
const labelRadius = labelPosition === 'outside' ? outerR : (innerR + outerR) / 2;
|
|
196
|
+
const arcLength = (slice.endAngle - slice.startAngle) * labelRadius;
|
|
197
|
+
// An inside label sits ON its wedge — hide it when the wedge is thinner
|
|
198
|
+
// than one text line (outside labels rely on the collision pass instead).
|
|
199
|
+
if (labelPosition === 'inside' && arcLength < lineHeight) {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const text = truncateToWidth(raw, budget, font);
|
|
203
|
+
if (text === '') {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const size = measureText(text, font);
|
|
207
|
+
candidates.push({
|
|
208
|
+
index: slice.index,
|
|
209
|
+
value: slice.value,
|
|
210
|
+
text,
|
|
211
|
+
rect: placedLabelRect(
|
|
212
|
+
{ x: absX, y: absY, textAnchor: 'middle', dominantBaseline: 'middle' },
|
|
213
|
+
size
|
|
214
|
+
)
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Feed the greedy first-come collision pass in value order so the larger
|
|
219
|
+
// slice keeps its label whenever two collide.
|
|
220
|
+
const ordered = [...candidates].sort((a, b) => b.value - a.value);
|
|
221
|
+
const keptFlags = dropOverlapping(ordered.map((candidate) => candidate.rect));
|
|
222
|
+
ordered.forEach((candidate, orderedIndex) => {
|
|
223
|
+
if (keptFlags[orderedIndex]) {
|
|
224
|
+
visible.set(candidate.index, candidate.text);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
return visible;
|
|
228
|
+
});
|
|
229
|
+
|
|
156
230
|
let centerBoxSize = $derived(innerR > 0 ? Math.max(0, innerR * 1.3) : 0);
|
|
157
231
|
|
|
158
232
|
// The foreignObject for the center snippet is positioned relative to the <g>
|
|
@@ -243,19 +317,15 @@
|
|
|
243
317
|
onmouseleave={handleLeave}
|
|
244
318
|
onclick={() => onsliceclick?.({ index: slice.index, slice: data[slice.index] })}
|
|
245
319
|
/>
|
|
246
|
-
{#if
|
|
320
|
+
{#if visibleSliceLabels.has(slice.index)}
|
|
247
321
|
<text
|
|
248
322
|
class="slice-label"
|
|
249
323
|
class:label-outside={labelPosition === 'outside'}
|
|
250
324
|
x={slice.labelX}
|
|
251
325
|
y={slice.labelY}
|
|
252
326
|
text-anchor="middle"
|
|
253
|
-
dominant-baseline="middle"
|
|
327
|
+
dominant-baseline="middle">{visibleSliceLabels.get(slice.index)}</text
|
|
254
328
|
>
|
|
255
|
-
{#if showLabels}{slice.label}{/if}
|
|
256
|
-
{#if showValues}
|
|
257
|
-
{pctFormat(slice.value)}{/if}
|
|
258
|
-
</text>
|
|
259
329
|
{/if}
|
|
260
330
|
{/each}
|
|
261
331
|
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
import { computeSankeyLayout } from '../_chart/geometry';
|
|
6
6
|
import { getColor } from '../_chart/colors';
|
|
7
7
|
import { formatNumber } from '../_chart/format';
|
|
8
|
+
import { measureText, readCssVarPx } from '../_chart/measure';
|
|
9
|
+
import { truncateToWidth } from '../_chart/labels';
|
|
8
10
|
import { DEFAULT_CHART_CORNER_RADIUS, DEFAULT_CHART_MAX_HEIGHT } from '../_chart/types';
|
|
9
11
|
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
|
10
12
|
|
|
@@ -52,54 +54,20 @@
|
|
|
52
54
|
let format = $derived(valueFormat ?? formatNumber);
|
|
53
55
|
let isEmpty = $derived(nodes.length === 0);
|
|
54
56
|
const MARGIN = 40;
|
|
55
|
-
const LABEL_CHAR_PX = 7.2; // ≈ 0.6em at the 12px default label size
|
|
56
57
|
// A 12px label's rendered line box measures ~16px (≈1.33em) across common
|
|
57
58
|
// font stacks; two label centres closer than this overlap visibly.
|
|
58
59
|
const LABEL_LINE_PX = 16;
|
|
59
60
|
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
71
|
-
if (/[iljtfr.,:;'’()[\]!|]/.test(ch)) {
|
|
72
|
-
return 3.6;
|
|
73
|
-
}
|
|
74
|
-
if (ch === ' ') {
|
|
75
|
-
return 3.8;
|
|
76
|
-
}
|
|
77
|
-
return 6.6;
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
const estimateTextWidth = (text: string): number => {
|
|
81
|
-
let width = 0;
|
|
82
|
-
for (const ch of text) {
|
|
83
|
-
width += estimateCharWidth(ch);
|
|
84
|
-
}
|
|
85
|
-
return width;
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
// Trim `text` (appending an ellipsis) until its estimated width fits
|
|
89
|
-
// `available` px. Returns '' when even 3 chars + ellipsis cannot fit —
|
|
90
|
-
// callers hide the label and rely on the <title> tooltip instead.
|
|
91
|
-
const fitTextToWidth = (text: string, available: number): string => {
|
|
92
|
-
if (estimateTextWidth(text) <= available) {
|
|
93
|
-
return text;
|
|
94
|
-
}
|
|
95
|
-
for (let keep = text.length - 1; keep >= 3; keep--) {
|
|
96
|
-
const candidate = text.slice(0, keep) + '…';
|
|
97
|
-
if (estimateTextWidth(candidate) <= available) {
|
|
98
|
-
return candidate;
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
return '';
|
|
102
|
-
};
|
|
61
|
+
// Real text measurement via the shared canvas-backed helper (exact on the
|
|
62
|
+
// client, 0.6em/char heuristic under SSR/tests). Character estimates used
|
|
63
|
+
// to both over-reserve the right label gutter (dead canvas) and under-budget
|
|
64
|
+
// uppercase-heavy labels (text sliding under the next column's bars).
|
|
65
|
+
let labelFont = $derived({
|
|
66
|
+
size: containerEl ? readCssVarPx(containerEl, '--sankey-label-font-size', 12) : 12
|
|
67
|
+
});
|
|
68
|
+
let colLabelFont = $derived({
|
|
69
|
+
size: containerEl ? readCssVarPx(containerEl, '--sankey-col-label-font-size', 11) : 11
|
|
70
|
+
});
|
|
103
71
|
|
|
104
72
|
// Final-column labels render to the RIGHT of their node; the bare 40px margin is
|
|
105
73
|
// nowhere near enough for real funnel labels ("PARTIALLY_FAILED (1,234)"), so they
|
|
@@ -118,8 +86,8 @@
|
|
|
118
86
|
return 0;
|
|
119
87
|
}
|
|
120
88
|
const longestPx =
|
|
121
|
-
Math.max(...sinkLabels.map((label) =>
|
|
122
|
-
(showValues ?
|
|
89
|
+
Math.max(...sinkLabels.map((label) => measureText(label, labelFont).width)) +
|
|
90
|
+
(showValues ? measureText(' (999,999)', labelFont).width : 0);
|
|
123
91
|
const wanted = longestPx + 10 + dataLabelOffsetX;
|
|
124
92
|
// Cap the reservation so labels can never squeeze the diagram below 3/4 width,
|
|
125
93
|
// and floor at 0 — a negative dataLabelOffsetX must not inflate the plot
|
|
@@ -175,7 +143,7 @@
|
|
|
175
143
|
// column count grow; untruncated they collide into one unreadable run. Clip to
|
|
176
144
|
// the column pitch with an ellipsis — the full text stays on the <title>.
|
|
177
145
|
const truncateColumnLabel = (text: string): string => {
|
|
178
|
-
return
|
|
146
|
+
return truncateToWidth(text, Math.max(0, colWidth - 6), colLabelFont);
|
|
179
147
|
};
|
|
180
148
|
|
|
181
149
|
const truncateLabel = (text: string, column: number): string => {
|
|
@@ -194,7 +162,7 @@
|
|
|
194
162
|
: Math.max(0, colWidth - nodeWidth - 12 - dataLabelOffsetX);
|
|
195
163
|
// No usable room — hide the label rather than force text that would overflow;
|
|
196
164
|
// the full text is still reachable via the node's <title> on hover.
|
|
197
|
-
return
|
|
165
|
+
return truncateToWidth(text, available, labelFont);
|
|
198
166
|
};
|
|
199
167
|
|
|
200
168
|
// Vertical label de-collision: labels sit at each node's centre-y, so two
|
|
@@ -24,61 +24,23 @@ export const tooltip = (node, options) => {
|
|
|
24
24
|
let delayTimer = null;
|
|
25
25
|
const bubbleId = `sui-tooltip-${++tooltipIdCounter}`;
|
|
26
26
|
const OFFSET = 8; // px — matches --tooltip-offset default
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (pos === 'bottom') {
|
|
45
|
-
return {
|
|
46
|
-
top: rect.bottom + OFFSET,
|
|
47
|
-
left: rect.left + rect.width / 2,
|
|
48
|
-
transform: 'translate(-50%, 0)',
|
|
49
|
-
arrowTop: `-${arrowSize}px`,
|
|
50
|
-
arrowLeft: '50%',
|
|
51
|
-
arrowRight: '',
|
|
52
|
-
arrowTransform: 'translateX(-50%)',
|
|
53
|
-
arrowBorderWidth: `0 ${arrowSize}px ${arrowSize}px ${arrowSize}px`,
|
|
54
|
-
arrowBorderColor: `${t} ${t} ${bg} ${t}`
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
if (pos === 'left') {
|
|
58
|
-
return {
|
|
59
|
-
top: rect.top + rect.height / 2,
|
|
60
|
-
left: rect.left - OFFSET,
|
|
61
|
-
transform: 'translate(-100%, -50%)',
|
|
62
|
-
arrowTop: '50%',
|
|
63
|
-
arrowLeft: '100%',
|
|
64
|
-
arrowRight: '',
|
|
65
|
-
arrowTransform: 'translateY(-50%)',
|
|
66
|
-
arrowBorderWidth: `${arrowSize}px 0 ${arrowSize}px ${arrowSize}px`,
|
|
67
|
-
arrowBorderColor: `${t} ${t} ${t} ${bg}`
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
// right
|
|
71
|
-
return {
|
|
72
|
-
top: rect.top + rect.height / 2,
|
|
73
|
-
left: rect.right + OFFSET,
|
|
74
|
-
transform: 'translate(0, -50%)',
|
|
75
|
-
arrowTop: '50%',
|
|
76
|
-
arrowLeft: '',
|
|
77
|
-
arrowRight: `${arrowSize}px`,
|
|
78
|
-
arrowTransform: 'translateY(-50%)',
|
|
79
|
-
arrowBorderWidth: `${arrowSize}px ${arrowSize}px ${arrowSize}px 0`,
|
|
80
|
-
arrowBorderColor: `${t} ${bg} ${t} ${t}`
|
|
81
|
-
};
|
|
27
|
+
const EDGE_MARGIN = 8; // px — minimum air between the bubble and the viewport edge
|
|
28
|
+
const ARROW_INSET = 9; // px — arrow centre never closer than this to a bubble corner
|
|
29
|
+
const oppositeOf = (side) => {
|
|
30
|
+
if (side === 'top') {
|
|
31
|
+
return 'bottom';
|
|
32
|
+
}
|
|
33
|
+
if (side === 'bottom') {
|
|
34
|
+
return 'top';
|
|
35
|
+
}
|
|
36
|
+
if (side === 'left') {
|
|
37
|
+
return 'right';
|
|
38
|
+
}
|
|
39
|
+
return 'left';
|
|
40
|
+
};
|
|
41
|
+
// min > max (bubble wider/taller than the viewport) degrades to the raw value.
|
|
42
|
+
const clampValue = (value, min, max) => {
|
|
43
|
+
return max < min ? value : Math.min(Math.max(value, min), max);
|
|
82
44
|
};
|
|
83
45
|
/**
|
|
84
46
|
* Build the bubble and arrow elements and attach them to `document.body`.
|
|
@@ -127,25 +89,81 @@ export const tooltip = (node, options) => {
|
|
|
127
89
|
/**
|
|
128
90
|
* Compute and apply `top`/`left` fixed coordinates plus arrow styles based on the
|
|
129
91
|
* current bounding rect of the host element and the active `position` option.
|
|
92
|
+
*
|
|
93
|
+
* The bubble is measured after mounting and then (1) FLIPPED to the opposite
|
|
94
|
+
* side when the preferred side has no room but the opposite side does, and
|
|
95
|
+
* (2) CLAMPED so it never crosses the viewport edge — a tooltip on a trigger
|
|
96
|
+
* near the screen edge used to spill off-screen or cover the nav beneath it.
|
|
97
|
+
* The arrow is positioned in bubble-local pixels anchored to the TRIGGER
|
|
98
|
+
* centre, so it keeps pointing at the trigger even when the bubble shifts.
|
|
130
99
|
*/
|
|
131
100
|
const positionBubble = () => {
|
|
132
101
|
if (bubbleEl === null || arrowEl === null) {
|
|
133
102
|
return;
|
|
134
103
|
}
|
|
135
104
|
const rect = node.getBoundingClientRect();
|
|
136
|
-
const
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
105
|
+
const preferred = currentOptions.position ?? 'top';
|
|
106
|
+
const arrowSize = 5; // px — matches --tooltip-arrow-size default
|
|
107
|
+
const bg = 'var(--tooltip-arrow-color,var(--tooltip-background,#333333))';
|
|
108
|
+
const t = 'transparent';
|
|
109
|
+
// Stubbed DOMs (unit tests) report no dimensions; clamping then no-ops.
|
|
110
|
+
const bubbleWidth = bubbleEl.offsetWidth || 0;
|
|
111
|
+
const bubbleHeight = bubbleEl.offsetHeight || 0;
|
|
112
|
+
const viewportWidth = typeof window !== 'undefined' && window.innerWidth > 0
|
|
113
|
+
? window.innerWidth
|
|
114
|
+
: Number.POSITIVE_INFINITY;
|
|
115
|
+
const viewportHeight = typeof window !== 'undefined' && window.innerHeight > 0
|
|
116
|
+
? window.innerHeight
|
|
117
|
+
: Number.POSITIVE_INFINITY;
|
|
118
|
+
const fits = (side) => {
|
|
119
|
+
if (side === 'top') {
|
|
120
|
+
return rect.top - OFFSET - bubbleHeight >= EDGE_MARGIN;
|
|
121
|
+
}
|
|
122
|
+
if (side === 'bottom') {
|
|
123
|
+
return rect.bottom + OFFSET + bubbleHeight <= viewportHeight - EDGE_MARGIN;
|
|
124
|
+
}
|
|
125
|
+
if (side === 'left') {
|
|
126
|
+
return rect.left - OFFSET - bubbleWidth >= EDGE_MARGIN;
|
|
127
|
+
}
|
|
128
|
+
return rect.right + OFFSET + bubbleWidth <= viewportWidth - EDGE_MARGIN;
|
|
129
|
+
};
|
|
130
|
+
const side = !fits(preferred) && fits(oppositeOf(preferred)) ? oppositeOf(preferred) : preferred;
|
|
131
|
+
let top;
|
|
132
|
+
let left;
|
|
133
|
+
if (side === 'top' || side === 'bottom') {
|
|
134
|
+
top = side === 'top' ? rect.top - OFFSET - bubbleHeight : rect.bottom + OFFSET;
|
|
135
|
+
left = clampValue(rect.left + rect.width / 2 - bubbleWidth / 2, EDGE_MARGIN, viewportWidth - EDGE_MARGIN - bubbleWidth);
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
left = side === 'left' ? rect.left - OFFSET - bubbleWidth : rect.right + OFFSET;
|
|
139
|
+
top = clampValue(rect.top + rect.height / 2 - bubbleHeight / 2, EDGE_MARGIN, viewportHeight - EDGE_MARGIN - bubbleHeight);
|
|
140
|
+
}
|
|
141
|
+
bubbleEl.style.top = `${top}px`;
|
|
142
|
+
bubbleEl.style.left = `${left}px`;
|
|
143
|
+
bubbleEl.style.transform = 'none';
|
|
144
|
+
arrowEl.style.right = '';
|
|
145
|
+
if (side === 'top' || side === 'bottom') {
|
|
146
|
+
const arrowLeft = clampValue(rect.left + rect.width / 2 - left, ARROW_INSET, Math.max(ARROW_INSET, bubbleWidth - ARROW_INSET));
|
|
147
|
+
arrowEl.style.left = `${arrowLeft}px`;
|
|
148
|
+
arrowEl.style.top = side === 'top' ? '100%' : `-${arrowSize}px`;
|
|
149
|
+
arrowEl.style.transform = 'translateX(-50%)';
|
|
150
|
+
arrowEl.style.borderWidth =
|
|
151
|
+
side === 'top'
|
|
152
|
+
? `${arrowSize}px ${arrowSize}px 0 ${arrowSize}px`
|
|
153
|
+
: `0 ${arrowSize}px ${arrowSize}px ${arrowSize}px`;
|
|
154
|
+
arrowEl.style.borderColor = side === 'top' ? `${bg} ${t} ${t} ${t}` : `${t} ${t} ${bg} ${t}`;
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
const arrowTop = clampValue(rect.top + rect.height / 2 - top, ARROW_INSET, Math.max(ARROW_INSET, bubbleHeight - ARROW_INSET));
|
|
158
|
+
arrowEl.style.top = `${arrowTop}px`;
|
|
159
|
+
arrowEl.style.left = side === 'left' ? '100%' : `-${arrowSize}px`;
|
|
160
|
+
arrowEl.style.transform = 'translateY(-50%)';
|
|
161
|
+
arrowEl.style.borderWidth =
|
|
162
|
+
side === 'left'
|
|
163
|
+
? `${arrowSize}px 0 ${arrowSize}px ${arrowSize}px`
|
|
164
|
+
: `${arrowSize}px ${arrowSize}px ${arrowSize}px 0`;
|
|
165
|
+
arrowEl.style.borderColor = side === 'left' ? `${t} ${t} ${t} ${bg}` : `${t} ${bg} ${t} ${t}`;
|
|
166
|
+
}
|
|
149
167
|
};
|
|
150
168
|
const show = () => {
|
|
151
169
|
// Guard against overlapping events (e.g. mouseenter + focusin firing simultaneously,
|