@juspay/svelte-ui-components 2.62.0 → 2.63.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.
- package/dist/BarChart/BarChart.svelte +2 -5
- package/dist/Button/Button.svelte +5 -2
- package/dist/Button/properties.d.ts +7 -0
- package/dist/Card/Card.svelte +1 -1
- package/dist/DateRangePicker/DateRangePicker.svelte +32 -8
- package/dist/Gauge/Gauge.svelte +2 -1
- package/dist/Gauge/properties.d.ts +9 -0
- package/dist/SankeyChart/SankeyChart.svelte +1 -1
- package/dist/Select/Select.svelte +1 -1
- package/dist/Table/Table.svelte +10 -1
- package/dist/Tooltip/PortalContentRenderer.svelte +14 -0
- package/dist/Tooltip/PortalContentRenderer.svelte.d.ts +7 -0
- package/dist/Tooltip/Tooltip.svelte +180 -11
- package/dist/Tooltip/properties.d.ts +18 -0
- package/dist/Tooltip/tooltip-action.d.ts +5 -0
- package/dist/Tooltip/tooltip-action.js +229 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
|
@@ -413,10 +413,7 @@
|
|
|
413
413
|
return null;
|
|
414
414
|
}
|
|
415
415
|
const title = isMulti ? `${bar.dataPoint.label} — ${bar.seriesName}` : bar.dataPoint.label;
|
|
416
|
-
const displayValue =
|
|
417
|
-
isNormalized && bar.normalizedValue != null
|
|
418
|
-
? normalizedFormat(bar.normalizedValue)
|
|
419
|
-
: format(bar.dataPoint.value);
|
|
416
|
+
const displayValue = getDisplayValue(bar);
|
|
420
417
|
return {
|
|
421
418
|
title,
|
|
422
419
|
items: [{ label: bar.dataPoint.label, value: displayValue, color: bar.color }]
|
|
@@ -574,7 +571,7 @@
|
|
|
574
571
|
y2={isHoriz ? entry.bar.y : entry.bar.y + entry.bar.height}
|
|
575
572
|
gradientUnits="userSpaceOnUse"
|
|
576
573
|
>
|
|
577
|
-
{#each grad.stops as stop (stop.offset)}
|
|
574
|
+
{#each grad.stops as stop, stopIndex (`${stopIndex}-${stop.offset}`)}
|
|
578
575
|
<stop
|
|
579
576
|
offset="{stop.offset * 100}%"
|
|
580
577
|
stop-color={stop.color}
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
ariaSelected,
|
|
16
16
|
role,
|
|
17
17
|
onclick,
|
|
18
|
-
onkeydown,
|
|
18
|
+
onkeydown = () => {},
|
|
19
19
|
onkeyup = () => {},
|
|
20
20
|
onmousedown,
|
|
21
21
|
onmouseup,
|
|
@@ -117,7 +117,10 @@
|
|
|
117
117
|
font-weight: var(--disabled-font-weight);
|
|
118
118
|
border: var(--disabled-border);
|
|
119
119
|
background: var(--disabled-background-color, var(--button-color, #3a4550));
|
|
120
|
-
box-shadow: var(
|
|
120
|
+
box-shadow: var(
|
|
121
|
+
--button-disabled-box-shadow,
|
|
122
|
+
var(--disabled-box-shadow, var(--button-box-shadow, none))
|
|
123
|
+
);
|
|
121
124
|
}
|
|
122
125
|
|
|
123
126
|
.button-loader {
|
|
@@ -20,11 +20,18 @@ export type OptionalButtonProperties = {
|
|
|
20
20
|
};
|
|
21
21
|
export type ButtonEventProperties = {
|
|
22
22
|
onclick?: (event: MouseEvent) => void;
|
|
23
|
+
/** Fires when a key is pressed down while the button has focus. */
|
|
23
24
|
onkeydown?: (event: KeyboardEvent) => void;
|
|
25
|
+
/** Fires when a key is released while the button has focus. */
|
|
24
26
|
onkeyup?: (event: KeyboardEvent) => void;
|
|
27
|
+
/** Fires when a mouse button is pressed down on the button. */
|
|
25
28
|
onmousedown?: (event: MouseEvent) => void;
|
|
29
|
+
/** Fires when a mouse button is released over the button. */
|
|
26
30
|
onmouseup?: (event: MouseEvent) => void;
|
|
31
|
+
/** Fires when the pointer leaves the button area. */
|
|
27
32
|
onmouseleave?: (event: MouseEvent) => void;
|
|
33
|
+
/** Fires when a touch point is placed on the button. */
|
|
28
34
|
ontouchstart?: (event: TouchEvent) => void;
|
|
35
|
+
/** Fires when a touch point is removed from the button. */
|
|
29
36
|
ontouchend?: (event: TouchEvent) => void;
|
|
30
37
|
};
|
package/dist/Card/Card.svelte
CHANGED
|
@@ -110,6 +110,32 @@
|
|
|
110
110
|
|
|
111
111
|
let activePresetLabel: string | null = $state(resolvedInitialPresetLabel);
|
|
112
112
|
|
|
113
|
+
// Seed draft from initialPresetLabel whenever presets become available so
|
|
114
|
+
// the calendar shows the preset's date selection when the picker is first
|
|
115
|
+
// opened — even if presets arrive asynchronously after initial render.
|
|
116
|
+
// The draftStart/draftEnd/draftValue guards prevent re-seeding once the
|
|
117
|
+
// user has made a custom selection.
|
|
118
|
+
$effect.pre(() => {
|
|
119
|
+
if (
|
|
120
|
+
resolvedInitialPresetLabel !== null &&
|
|
121
|
+
presets !== null &&
|
|
122
|
+
draftStart === null &&
|
|
123
|
+
draftEnd === null &&
|
|
124
|
+
draftValue === null
|
|
125
|
+
) {
|
|
126
|
+
const matchedPreset = presets.find((p) => p.label === resolvedInitialPresetLabel) ?? null;
|
|
127
|
+
if (matchedPreset !== null) {
|
|
128
|
+
const { start, end } = matchedPreset.getValue();
|
|
129
|
+
if (mode === 'single') {
|
|
130
|
+
draftValue = start;
|
|
131
|
+
} else {
|
|
132
|
+
draftStart = start;
|
|
133
|
+
draftEnd = end;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
113
139
|
const now = new SvelteDate();
|
|
114
140
|
|
|
115
141
|
// Dual-month navigation: track the year+month of the left calendar
|
|
@@ -160,11 +186,11 @@
|
|
|
160
186
|
|
|
161
187
|
function openPicker(): void {
|
|
162
188
|
// Seed draft from current committed values
|
|
163
|
-
draftStart = rangeStart
|
|
164
|
-
draftEnd = rangeEnd
|
|
165
|
-
draftValue = value
|
|
166
|
-
draftCompareStart = compareStart
|
|
167
|
-
draftCompareEnd = compareEnd
|
|
189
|
+
draftStart = rangeStart;
|
|
190
|
+
draftEnd = rangeEnd;
|
|
191
|
+
draftValue = value;
|
|
192
|
+
draftCompareStart = compareStart;
|
|
193
|
+
draftCompareEnd = compareEnd;
|
|
168
194
|
|
|
169
195
|
// Reset preset tracking so each picker session starts clean.
|
|
170
196
|
selectedPresetLabel = null;
|
|
@@ -499,9 +525,7 @@
|
|
|
499
525
|
{#each presets as preset, presetIndex (preset.label)}
|
|
500
526
|
{@const previousPreset = presetIndex > 0 ? presets[presetIndex - 1] : null}
|
|
501
527
|
{@const groupChanged =
|
|
502
|
-
presetIndex > 0 &&
|
|
503
|
-
'group' in preset &&
|
|
504
|
-
(previousPreset === null || previousPreset.group !== preset.group)}
|
|
528
|
+
presetIndex > 0 && (previousPreset?.group ?? '') !== (preset.group ?? '')}
|
|
505
529
|
{#if groupChanged}
|
|
506
530
|
<div class="drp-preset-divider" role="separator" aria-hidden="true">
|
|
507
531
|
{#if preset.group !== ''}
|
package/dist/Gauge/Gauge.svelte
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
max = 100,
|
|
7
7
|
showLabel = true,
|
|
8
8
|
labelFormatter,
|
|
9
|
+
ariaLabel,
|
|
9
10
|
testId,
|
|
10
11
|
classes
|
|
11
12
|
}: GaugeProperties = $props();
|
|
@@ -33,7 +34,7 @@
|
|
|
33
34
|
aria-valuenow={clamped}
|
|
34
35
|
aria-valuemin={0}
|
|
35
36
|
aria-valuemax={100}
|
|
36
|
-
aria-label=
|
|
37
|
+
aria-label={ariaLabel ?? labelText}
|
|
37
38
|
>
|
|
38
39
|
<svg viewBox="0 0 {VIEW_BOX} {VIEW_BOX}">
|
|
39
40
|
<circle class="track" cx={CENTER} cy={CENTER} r={RADIUS} fill="none" />
|
|
@@ -11,6 +11,7 @@ export type OptionalGaugeProperties = {
|
|
|
11
11
|
* @default 100
|
|
12
12
|
*/
|
|
13
13
|
max?: number;
|
|
14
|
+
/** Whether to show the centered percentage label. @default true */
|
|
14
15
|
showLabel?: boolean;
|
|
15
16
|
/**
|
|
16
17
|
* Custom label renderer called with the raw `(value, max)` pair. Return any
|
|
@@ -18,6 +19,14 @@ export type OptionalGaugeProperties = {
|
|
|
18
19
|
* Example: `labelFormatter={(v, m) => \`${v} / ${m}\`}` shows "50 / 200".
|
|
19
20
|
*/
|
|
20
21
|
labelFormatter?: (value: number, max: number) => string;
|
|
22
|
+
/**
|
|
23
|
+
* Accessible label for the gauge element (`role="progressbar"`). Falls back
|
|
24
|
+
* to the computed `labelText` (e.g. `"75%"`) when not provided, so assistive
|
|
25
|
+
* technology announces the current percentage by default.
|
|
26
|
+
*/
|
|
27
|
+
ariaLabel?: string;
|
|
28
|
+
/** Value for the `data-pw` attribute on the root element for E2E test selection. */
|
|
21
29
|
testId?: string;
|
|
30
|
+
/** Extra CSS classes applied to the root element for consumer theming. */
|
|
22
31
|
classes?: string;
|
|
23
32
|
};
|
|
@@ -272,7 +272,7 @@
|
|
|
272
272
|
<ChartContainer bind:width={chartWidth} bind:height={chartHeight} {aspectRatio}>
|
|
273
273
|
<g transform="translate({MARGIN}, {MARGIN})">
|
|
274
274
|
{#if columnLabels != null && columnLabels.length > 0}
|
|
275
|
-
{#each columnLabels as label, ci (ci)}
|
|
275
|
+
{#each columnLabels.slice(0, columnCount) as label, ci (ci)}
|
|
276
276
|
<text
|
|
277
277
|
class="sankey-col-label"
|
|
278
278
|
x={ci * colWidth + nodeWidth / 2}
|
|
@@ -364,7 +364,7 @@
|
|
|
364
364
|
id={`${listboxId}-option-${index}`}
|
|
365
365
|
aria-selected={value.includes(item.id)}
|
|
366
366
|
tabindex="-1"
|
|
367
|
-
{...item.testId
|
|
367
|
+
{...typeof item.testId === 'string'
|
|
368
368
|
? { 'data-pw': item.testId }
|
|
369
369
|
: typeof itemTestId === 'string'
|
|
370
370
|
? { 'data-pw': `${itemTestId}-${item.id}` }
|
package/dist/Table/Table.svelte
CHANGED
|
@@ -189,10 +189,16 @@
|
|
|
189
189
|
* stable even when the visible set shrinks or expands as the search term
|
|
190
190
|
* changes — so a row that was "row 2" in the full set keeps ID `"2"` even
|
|
191
191
|
* when it becomes the only visible result.
|
|
192
|
+
*
|
|
193
|
+
* The Map lookup uses object reference equality, so row objects in
|
|
194
|
+
* `filteredTableData` must be the same references as those in
|
|
195
|
+
* `sortedTableData`. When `originalIndex` cannot be found (reference mismatch
|
|
196
|
+
* after a transform), the fallback positional index is used instead.
|
|
192
197
|
*/
|
|
193
198
|
let filteredRowIds = $derived.by((): string[] => {
|
|
199
|
+
const indexMap = new Map(sortedTableData.map((row, index) => [row, index]));
|
|
194
200
|
return filteredTableData.map((row, fallbackIndex) => {
|
|
195
|
-
const originalIndex =
|
|
201
|
+
const originalIndex = indexMap.get(row) ?? -1;
|
|
196
202
|
const stableIndex = originalIndex === -1 ? fallbackIndex : originalIndex;
|
|
197
203
|
if (checkboxSelection && checkboxSelection.enabled !== false) {
|
|
198
204
|
return resolveRowId(checkboxSelection, row, stableIndex);
|
|
@@ -314,6 +320,7 @@
|
|
|
314
320
|
oninput={handleSearchInput}
|
|
315
321
|
data-pw={searchConfig?.testId ?? null}
|
|
316
322
|
aria-label={searchConfig?.placeholder ?? 'Search'}
|
|
323
|
+
autocomplete="off"
|
|
317
324
|
/>
|
|
318
325
|
{#if searchTerm.length > 0}
|
|
319
326
|
<button
|
|
@@ -353,6 +360,7 @@
|
|
|
353
360
|
? 'mixed'
|
|
354
361
|
: headerCheckboxState === 'all'}
|
|
355
362
|
aria-label="Select all rows"
|
|
363
|
+
aria-controls={selectableRowIds.map((rowId) => `row-checkbox-${rowId}`).join(' ')}
|
|
356
364
|
onclick={toggleAllSelection}
|
|
357
365
|
onkeydown={(keyboardEvent) =>
|
|
358
366
|
handleCheckboxKeydown(keyboardEvent, toggleAllSelection)}
|
|
@@ -445,6 +453,7 @@
|
|
|
445
453
|
class:checked={rowSelected}
|
|
446
454
|
class:disabled={rowDisabled}
|
|
447
455
|
role="checkbox"
|
|
456
|
+
id={`row-checkbox-${rowId}`}
|
|
448
457
|
tabindex={rowDisabled ? -1 : 0}
|
|
449
458
|
aria-checked={rowSelected}
|
|
450
459
|
aria-disabled={rowDisabled}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@internal
|
|
3
|
+
Helper component that renders a Svelte snippet into an imperatively-created DOM element via
|
|
4
|
+
mount(). Used by Tooltip's portal mode to project rich `content` snippets into a bubble
|
|
5
|
+
that lives on document.body outside the normal component tree. Not intended for direct
|
|
6
|
+
consumer use.
|
|
7
|
+
-->
|
|
8
|
+
<script lang="ts">
|
|
9
|
+
import type { Snippet } from 'svelte';
|
|
10
|
+
|
|
11
|
+
let { snippet }: { snippet: Snippet } = $props();
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
{@render snippet()}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
snippet: Snippet;
|
|
4
|
+
};
|
|
5
|
+
declare const PortalContentRenderer: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
6
|
+
type PortalContentRenderer = ReturnType<typeof PortalContentRenderer>;
|
|
7
|
+
export default PortalContentRenderer;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import
|
|
2
|
+
import { mount, unmount, onDestroy } from 'svelte';
|
|
3
|
+
import type { TooltipProperties, TooltipPosition } from './properties';
|
|
4
|
+
import PortalContentRenderer from './PortalContentRenderer.svelte';
|
|
3
5
|
|
|
4
6
|
let {
|
|
5
7
|
text,
|
|
@@ -9,32 +11,199 @@
|
|
|
9
11
|
classes,
|
|
10
12
|
children,
|
|
11
13
|
icon,
|
|
12
|
-
content
|
|
14
|
+
content,
|
|
15
|
+
usePortal = false
|
|
13
16
|
}: TooltipProperties = $props();
|
|
14
17
|
|
|
15
18
|
let visible = $state(false);
|
|
16
19
|
let delayTimeout = $state<ReturnType<typeof setTimeout> | null>(null);
|
|
17
20
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
/** Reference to the trigger wrapper element, used for `getBoundingClientRect` in portal mode. */
|
|
22
|
+
let containerEl: HTMLDivElement | null = $state(null);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Imperatively managed portal bubble element.
|
|
26
|
+
* Created in `showTooltip` and removed in `hideTooltip` when `usePortal=true`.
|
|
27
|
+
* All styles are applied inline so the element is not subject to Svelte's CSS scoping.
|
|
28
|
+
*/
|
|
29
|
+
let portalBubbleEl: HTMLDivElement | null = null;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Mounted Svelte component instance used to render the `content` snippet into the
|
|
33
|
+
* portal bubble. Stored so it can be unmounted when the bubble is removed.
|
|
34
|
+
*/
|
|
35
|
+
let portalContentMount: Record<string, unknown> | null = null;
|
|
36
|
+
|
|
37
|
+
const computePortalCoords = (
|
|
38
|
+
rect: DOMRect,
|
|
39
|
+
pos: TooltipPosition
|
|
40
|
+
): { top: number; left: number; transform: string } => {
|
|
41
|
+
const offset = 8; // matches --tooltip-offset default
|
|
42
|
+
if (pos === 'top') {
|
|
43
|
+
return {
|
|
44
|
+
top: rect.top - offset,
|
|
45
|
+
left: rect.left + rect.width / 2,
|
|
46
|
+
transform: 'translate(-50%, -100%)'
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
if (pos === 'bottom') {
|
|
50
|
+
return {
|
|
51
|
+
top: rect.bottom + offset,
|
|
52
|
+
left: rect.left + rect.width / 2,
|
|
53
|
+
transform: 'translate(-50%, 0)'
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
if (pos === 'left') {
|
|
57
|
+
return {
|
|
58
|
+
top: rect.top + rect.height / 2,
|
|
59
|
+
left: rect.left - offset,
|
|
60
|
+
transform: 'translate(-100%, -50%)'
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
// right
|
|
64
|
+
return {
|
|
65
|
+
top: rect.top + rect.height / 2,
|
|
66
|
+
left: rect.right + offset,
|
|
67
|
+
transform: 'translate(0, -50%)'
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const computeArrowStyle = (pos: TooltipPosition): string => {
|
|
72
|
+
const arrowSize = 5; // matches --tooltip-arrow-size default
|
|
73
|
+
const bg = 'var(--tooltip-arrow-color,var(--tooltip-background,#333333))';
|
|
74
|
+
const t = 'transparent';
|
|
75
|
+
const base = 'position:absolute;width:0;height:0;border-style:solid;';
|
|
76
|
+
if (pos === 'top') {
|
|
77
|
+
return `${base}top:100%;left:50%;transform:translateX(-50%);border-width:${arrowSize}px ${arrowSize}px 0 ${arrowSize}px;border-color:${bg} ${t} ${t} ${t};`;
|
|
78
|
+
}
|
|
79
|
+
if (pos === 'bottom') {
|
|
80
|
+
return `${base}bottom:100%;left:50%;transform:translateX(-50%);border-width:0 ${arrowSize}px ${arrowSize}px ${arrowSize}px;border-color:${t} ${t} ${bg} ${t};`;
|
|
81
|
+
}
|
|
82
|
+
if (pos === 'left') {
|
|
83
|
+
return `${base}top:50%;left:100%;transform:translateY(-50%);border-width:${arrowSize}px 0 ${arrowSize}px ${arrowSize}px;border-color:${t} ${t} ${t} ${bg};`;
|
|
84
|
+
}
|
|
85
|
+
// right
|
|
86
|
+
return `${base}top:50%;right:100%;transform:translateY(-50%);border-width:${arrowSize}px ${arrowSize}px ${arrowSize}px 0;border-color:${t} ${bg} ${t} ${t};`;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const createPortalBubble = (rect: DOMRect, pos: TooltipPosition): HTMLDivElement | null => {
|
|
90
|
+
if (typeof document === 'undefined') {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
const bubble = document.createElement('div');
|
|
94
|
+
bubble.setAttribute('role', 'tooltip');
|
|
95
|
+
|
|
96
|
+
const coords = computePortalCoords(rect, pos);
|
|
97
|
+
bubble.style.cssText = [
|
|
98
|
+
`position:fixed`,
|
|
99
|
+
`top:${coords.top}px`,
|
|
100
|
+
`left:${coords.left}px`,
|
|
101
|
+
`transform:${coords.transform}`,
|
|
102
|
+
`z-index:var(--tooltip-z-index,1000)`,
|
|
103
|
+
`max-width:var(--tooltip-max-width,200px)`,
|
|
104
|
+
`background:var(--tooltip-background,#333333)`,
|
|
105
|
+
`color:var(--tooltip-color,#ffffff)`,
|
|
106
|
+
`font-size:var(--tooltip-font-size,12px)`,
|
|
107
|
+
`font-weight:var(--tooltip-font-weight,400)`,
|
|
108
|
+
`font-family:var(--tooltip-font-family,inherit)`,
|
|
109
|
+
`padding:var(--tooltip-padding,6px 10px)`,
|
|
110
|
+
`border-radius:var(--tooltip-border-radius,4px)`,
|
|
111
|
+
`border:var(--tooltip-border,none)`,
|
|
112
|
+
`box-shadow:var(--tooltip-box-shadow,0 2px 6px rgba(0,0,0,0.15))`,
|
|
113
|
+
`white-space:normal`,
|
|
114
|
+
`word-wrap:break-word`,
|
|
115
|
+
`pointer-events:none`,
|
|
116
|
+
`transition:opacity var(--tooltip-opacity-duration,0.15s) ease-in-out`
|
|
117
|
+
].join(';');
|
|
118
|
+
|
|
119
|
+
const arrowEl = document.createElement('div');
|
|
120
|
+
arrowEl.style.cssText = computeArrowStyle(pos);
|
|
121
|
+
bubble.appendChild(arrowEl);
|
|
122
|
+
|
|
123
|
+
// Render rich `content` snippet when provided; fall back to plain text.
|
|
124
|
+
if (typeof content === 'function') {
|
|
125
|
+
const contentContainer = document.createElement('span');
|
|
126
|
+
bubble.appendChild(contentContainer);
|
|
127
|
+
portalContentMount = mount(PortalContentRenderer, {
|
|
128
|
+
target: contentContainer,
|
|
129
|
+
props: { snippet: content }
|
|
130
|
+
});
|
|
23
131
|
} else {
|
|
132
|
+
const textEl = document.createElement('span');
|
|
133
|
+
textEl.style.cssText = `color:var(--tooltip-color,#ffffff)`;
|
|
134
|
+
textEl.textContent = text;
|
|
135
|
+
bubble.appendChild(textEl);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return bubble;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const showTooltip = () => {
|
|
142
|
+
// Guard: if a delay timer is already pending, a second showTooltip() call (e.g. rapid
|
|
143
|
+
// mouseenter + focusin) would schedule another timer. Return early so only one timer
|
|
144
|
+
// is ever pending at a time, preventing stale callbacks from re-showing after hide.
|
|
145
|
+
if (delayTimeout !== null) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const doShow = () => {
|
|
150
|
+
delayTimeout = null;
|
|
24
151
|
visible = true;
|
|
152
|
+
if (usePortal && containerEl !== null && typeof document !== 'undefined') {
|
|
153
|
+
const rect = containerEl.getBoundingClientRect();
|
|
154
|
+
const pos = position;
|
|
155
|
+
// Guard: only one portal bubble may exist at a time (overlapping events protection).
|
|
156
|
+
if (portalBubbleEl !== null) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
portalBubbleEl = createPortalBubble(rect, pos);
|
|
160
|
+
if (portalBubbleEl !== null) {
|
|
161
|
+
document.body.appendChild(portalBubbleEl);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
if (delay > 0) {
|
|
167
|
+
delayTimeout = setTimeout(doShow, delay);
|
|
168
|
+
} else {
|
|
169
|
+
doShow();
|
|
25
170
|
}
|
|
26
|
-
}
|
|
171
|
+
};
|
|
27
172
|
|
|
28
|
-
|
|
173
|
+
const hideTooltip = () => {
|
|
29
174
|
if (delayTimeout !== null) {
|
|
30
175
|
clearTimeout(delayTimeout);
|
|
31
176
|
delayTimeout = null;
|
|
32
177
|
}
|
|
33
178
|
visible = false;
|
|
34
|
-
|
|
179
|
+
if (portalContentMount !== null) {
|
|
180
|
+
unmount(portalContentMount);
|
|
181
|
+
portalContentMount = null;
|
|
182
|
+
}
|
|
183
|
+
if (portalBubbleEl !== null && typeof document !== 'undefined') {
|
|
184
|
+
portalBubbleEl.remove();
|
|
185
|
+
portalBubbleEl = null;
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
// Ensure the portal bubble and any pending delay timer are cleaned up when
|
|
190
|
+
// the component unmounts — prevents orphaned DOM nodes and memory leaks when
|
|
191
|
+
// Tooltip is used in dynamic lists or conditional rendering contexts.
|
|
192
|
+
onDestroy(() => {
|
|
193
|
+
if (delayTimeout !== null) {
|
|
194
|
+
clearTimeout(delayTimeout);
|
|
195
|
+
}
|
|
196
|
+
if (portalContentMount !== null) {
|
|
197
|
+
unmount(portalContentMount);
|
|
198
|
+
}
|
|
199
|
+
if (portalBubbleEl !== null && typeof document !== 'undefined') {
|
|
200
|
+
portalBubbleEl.remove();
|
|
201
|
+
}
|
|
202
|
+
});
|
|
35
203
|
</script>
|
|
36
204
|
|
|
37
205
|
<div
|
|
206
|
+
bind:this={containerEl}
|
|
38
207
|
class="tooltip-container {classes ?? ''}"
|
|
39
208
|
role="none"
|
|
40
209
|
onmouseenter={showTooltip}
|
|
@@ -47,7 +216,7 @@
|
|
|
47
216
|
<span class="tooltip-icon" aria-hidden="true">{@render icon()}</span>
|
|
48
217
|
{/if}
|
|
49
218
|
{@render children()}
|
|
50
|
-
{#if visible}
|
|
219
|
+
{#if visible && !usePortal}
|
|
51
220
|
<div class="tooltip-bubble {position}" role="tooltip">
|
|
52
221
|
<div class="tooltip-arrow"></div>
|
|
53
222
|
{#if typeof content === 'function'}
|
|
@@ -13,5 +13,23 @@ export type OptionalTooltipProperties = {
|
|
|
13
13
|
icon?: Snippet;
|
|
14
14
|
/** Snippet rendered as the bubble body. When provided, replaces the plain `text` string inside the tooltip bubble. */
|
|
15
15
|
content?: Snippet;
|
|
16
|
+
/**
|
|
17
|
+
* When true, the tooltip bubble is mounted directly on `document.body` using
|
|
18
|
+
* `position: fixed` coordinates derived from `getBoundingClientRect`. This prevents
|
|
19
|
+
* clipping inside overflow-hidden or stacking-context ancestors (e.g. toolbar items).
|
|
20
|
+
*/
|
|
21
|
+
usePortal?: boolean;
|
|
16
22
|
};
|
|
17
23
|
export type TooltipProperties = MandatoryTooltipProperties & OptionalTooltipProperties;
|
|
24
|
+
/**
|
|
25
|
+
* Options accepted by the `tooltip` Svelte action.
|
|
26
|
+
* All fields mirror the corresponding Tooltip component props.
|
|
27
|
+
*/
|
|
28
|
+
export type TooltipActionOptions = {
|
|
29
|
+
/** Tooltip text shown in the bubble. */
|
|
30
|
+
text: string;
|
|
31
|
+
position?: TooltipPosition;
|
|
32
|
+
delay?: number;
|
|
33
|
+
/** Custom CSS classes forwarded to the bubble element. */
|
|
34
|
+
classes?: string;
|
|
35
|
+
};
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Svelte `use:tooltip` action — renderless alternative to the `<Tooltip>` component.
|
|
3
|
+
*
|
|
4
|
+
* Attaches hover and focus listeners to the host element without injecting any wrapper
|
|
5
|
+
* div, which prevents flex-child sizing breakage inside toolbars and icon rows.
|
|
6
|
+
* The tooltip bubble is mounted directly on `document.body` with `position:fixed`
|
|
7
|
+
* coordinates derived from `getBoundingClientRect`, so it is never clipped by
|
|
8
|
+
* `overflow:hidden` ancestors.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* ```svelte
|
|
12
|
+
* <script>
|
|
13
|
+
* import { tooltip } from '@juspay/svelte-ui-components';
|
|
14
|
+
* </script>
|
|
15
|
+
* <button use:tooltip={{ text: 'Save', position: 'top' }}>💾</button>
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
/** Monotonically-incrementing counter used to generate unique tooltip bubble IDs. */
|
|
19
|
+
let tooltipIdCounter = 0;
|
|
20
|
+
export const tooltip = (node, options) => {
|
|
21
|
+
let currentOptions = { ...options };
|
|
22
|
+
let bubbleEl = null;
|
|
23
|
+
let arrowEl = null;
|
|
24
|
+
let delayTimer = null;
|
|
25
|
+
const bubbleId = `sui-tooltip-${++tooltipIdCounter}`;
|
|
26
|
+
const OFFSET = 8; // px — matches --tooltip-offset default
|
|
27
|
+
const computeCoords = (rect, pos) => {
|
|
28
|
+
const arrowSize = 5; // px — matches --tooltip-arrow-size default
|
|
29
|
+
const bg = 'var(--tooltip-arrow-color,var(--tooltip-background,#333333))';
|
|
30
|
+
const t = 'transparent';
|
|
31
|
+
if (pos === 'top') {
|
|
32
|
+
return {
|
|
33
|
+
top: rect.top - OFFSET,
|
|
34
|
+
left: rect.left + rect.width / 2,
|
|
35
|
+
transform: 'translate(-50%, -100%)',
|
|
36
|
+
arrowTop: '100%',
|
|
37
|
+
arrowLeft: '50%',
|
|
38
|
+
arrowRight: '',
|
|
39
|
+
arrowTransform: 'translateX(-50%)',
|
|
40
|
+
arrowBorderWidth: `${arrowSize}px ${arrowSize}px 0 ${arrowSize}px`,
|
|
41
|
+
arrowBorderColor: `${bg} ${t} ${t} ${t}`
|
|
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
|
+
};
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* Build the bubble and arrow elements and attach them to `document.body`.
|
|
85
|
+
* Inline styles are used to keep the action self-contained — no stylesheet injection.
|
|
86
|
+
* Sets `aria-describedby` on the host node to satisfy the ARIA tooltip pattern,
|
|
87
|
+
* which requires a programmatic association between the trigger and the bubble.
|
|
88
|
+
*/
|
|
89
|
+
const createBubble = () => {
|
|
90
|
+
if (typeof document === 'undefined') {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
bubbleEl = document.createElement('div');
|
|
94
|
+
bubbleEl.setAttribute('role', 'tooltip');
|
|
95
|
+
bubbleEl.id = bubbleId;
|
|
96
|
+
node.setAttribute('aria-describedby', bubbleId);
|
|
97
|
+
bubbleEl.style.cssText = [
|
|
98
|
+
'position:fixed',
|
|
99
|
+
`z-index:var(--tooltip-z-index,1000)`,
|
|
100
|
+
`max-width:var(--tooltip-max-width,200px)`,
|
|
101
|
+
`background:var(--tooltip-background,#333333)`,
|
|
102
|
+
`color:var(--tooltip-color,#ffffff)`,
|
|
103
|
+
`font-size:var(--tooltip-font-size,12px)`,
|
|
104
|
+
`font-weight:var(--tooltip-font-weight,400)`,
|
|
105
|
+
`font-family:var(--tooltip-font-family,inherit)`,
|
|
106
|
+
`padding:var(--tooltip-padding,6px 10px)`,
|
|
107
|
+
`border-radius:var(--tooltip-border-radius,4px)`,
|
|
108
|
+
`border:var(--tooltip-border,none)`,
|
|
109
|
+
`box-shadow:var(--tooltip-box-shadow,0 2px 6px rgba(0,0,0,0.15))`,
|
|
110
|
+
'white-space:normal',
|
|
111
|
+
'word-wrap:break-word',
|
|
112
|
+
'pointer-events:none',
|
|
113
|
+
`transition:opacity var(--tooltip-opacity-duration,0.15s) ease-in-out`
|
|
114
|
+
].join(';');
|
|
115
|
+
if (typeof currentOptions.classes === 'string' && currentOptions.classes.length > 0) {
|
|
116
|
+
bubbleEl.className = currentOptions.classes;
|
|
117
|
+
}
|
|
118
|
+
arrowEl = document.createElement('div');
|
|
119
|
+
arrowEl.style.cssText = 'position:absolute;width:0;height:0;border-style:solid;';
|
|
120
|
+
const textNode = document.createElement('span');
|
|
121
|
+
textNode.style.cssText = 'color:var(--tooltip-color,#ffffff)';
|
|
122
|
+
textNode.textContent = currentOptions.text;
|
|
123
|
+
bubbleEl.appendChild(arrowEl);
|
|
124
|
+
bubbleEl.appendChild(textNode);
|
|
125
|
+
document.body.appendChild(bubbleEl);
|
|
126
|
+
};
|
|
127
|
+
/**
|
|
128
|
+
* Compute and apply `top`/`left` fixed coordinates plus arrow styles based on the
|
|
129
|
+
* current bounding rect of the host element and the active `position` option.
|
|
130
|
+
*/
|
|
131
|
+
const positionBubble = () => {
|
|
132
|
+
if (bubbleEl === null || arrowEl === null) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const rect = node.getBoundingClientRect();
|
|
136
|
+
const pos = currentOptions.position ?? 'top';
|
|
137
|
+
const coords = computeCoords(rect, pos);
|
|
138
|
+
bubbleEl.style.top = `${coords.top}px`;
|
|
139
|
+
bubbleEl.style.left = `${coords.left}px`;
|
|
140
|
+
bubbleEl.style.transform = coords.transform;
|
|
141
|
+
arrowEl.style.top = coords.arrowTop;
|
|
142
|
+
arrowEl.style.left = coords.arrowLeft;
|
|
143
|
+
if (coords.arrowRight !== '') {
|
|
144
|
+
arrowEl.style.right = coords.arrowRight;
|
|
145
|
+
}
|
|
146
|
+
arrowEl.style.transform = coords.arrowTransform;
|
|
147
|
+
arrowEl.style.borderWidth = coords.arrowBorderWidth;
|
|
148
|
+
arrowEl.style.borderColor = coords.arrowBorderColor;
|
|
149
|
+
};
|
|
150
|
+
const show = () => {
|
|
151
|
+
// Guard against overlapping events (e.g. mouseenter + focusin firing simultaneously,
|
|
152
|
+
// or two delayed timers both completing) — only one bubble may exist at a time.
|
|
153
|
+
// Also guard when a delay timer is already pending: a second show() call while the
|
|
154
|
+
// first is waiting would schedule a second timer; clearing it first prevents stale
|
|
155
|
+
// timers from re-opening the tooltip after hide() has already run.
|
|
156
|
+
if (bubbleEl !== null || delayTimer !== null) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const doShow = () => {
|
|
160
|
+
// Re-check after the delay: hide() may have been called while the timer was pending.
|
|
161
|
+
if (bubbleEl !== null) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
delayTimer = null;
|
|
165
|
+
createBubble();
|
|
166
|
+
positionBubble();
|
|
167
|
+
};
|
|
168
|
+
const delayMs = currentOptions.delay ?? 0;
|
|
169
|
+
if (delayMs > 0) {
|
|
170
|
+
delayTimer = setTimeout(doShow, delayMs);
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
doShow();
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
const hide = () => {
|
|
177
|
+
if (delayTimer !== null) {
|
|
178
|
+
clearTimeout(delayTimer);
|
|
179
|
+
delayTimer = null;
|
|
180
|
+
}
|
|
181
|
+
if (bubbleEl !== null) {
|
|
182
|
+
bubbleEl.remove();
|
|
183
|
+
bubbleEl = null;
|
|
184
|
+
arrowEl = null;
|
|
185
|
+
node.removeAttribute('aria-describedby');
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
/**
|
|
189
|
+
* Reposition the bubble when the viewport changes (scroll or resize) so the tooltip
|
|
190
|
+
* stays anchored to the trigger element while it is visible.
|
|
191
|
+
*/
|
|
192
|
+
const handleReposition = () => {
|
|
193
|
+
if (bubbleEl !== null) {
|
|
194
|
+
positionBubble();
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
node.addEventListener('mouseenter', show);
|
|
198
|
+
node.addEventListener('mouseleave', hide);
|
|
199
|
+
node.addEventListener('focusin', show);
|
|
200
|
+
node.addEventListener('focusout', hide);
|
|
201
|
+
// Guard window access: Svelte actions run after mount (client-side only), but an
|
|
202
|
+
// explicit check keeps the action safe if it is somehow called during SSR.
|
|
203
|
+
const hasWindow = typeof window !== 'undefined';
|
|
204
|
+
if (hasWindow) {
|
|
205
|
+
window.addEventListener('resize', handleReposition);
|
|
206
|
+
window.addEventListener('scroll', handleReposition, true);
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
update(nextOptions) {
|
|
210
|
+
currentOptions = { ...nextOptions };
|
|
211
|
+
// If bubble is currently visible, re-render it with new options.
|
|
212
|
+
if (bubbleEl !== null) {
|
|
213
|
+
hide();
|
|
214
|
+
show();
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
destroy() {
|
|
218
|
+
hide();
|
|
219
|
+
node.removeEventListener('mouseenter', show);
|
|
220
|
+
node.removeEventListener('mouseleave', hide);
|
|
221
|
+
node.removeEventListener('focusin', show);
|
|
222
|
+
node.removeEventListener('focusout', hide);
|
|
223
|
+
if (hasWindow) {
|
|
224
|
+
window.removeEventListener('resize', handleReposition);
|
|
225
|
+
window.removeEventListener('scroll', handleReposition, true);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -31,6 +31,7 @@ export { default as Tabs } from './Tabs/Tabs.svelte';
|
|
|
31
31
|
export { default as Choicebox } from './Choicebox/Choicebox.svelte';
|
|
32
32
|
export { default as Slider } from './Slider/Slider.svelte';
|
|
33
33
|
export { default as Tooltip } from './Tooltip/Tooltip.svelte';
|
|
34
|
+
export { tooltip } from './Tooltip/tooltip-action';
|
|
34
35
|
export { default as Shimmer } from './Shimmer/Shimmer.svelte';
|
|
35
36
|
export { default as Progress } from './Progress/Progress.svelte';
|
|
36
37
|
export { default as Pill } from './Pill/Pill.svelte';
|
package/dist/index.js
CHANGED
|
@@ -31,6 +31,7 @@ export { default as Tabs } from './Tabs/Tabs.svelte';
|
|
|
31
31
|
export { default as Choicebox } from './Choicebox/Choicebox.svelte';
|
|
32
32
|
export { default as Slider } from './Slider/Slider.svelte';
|
|
33
33
|
export { default as Tooltip } from './Tooltip/Tooltip.svelte';
|
|
34
|
+
export { tooltip } from './Tooltip/tooltip-action';
|
|
34
35
|
export { default as Shimmer } from './Shimmer/Shimmer.svelte';
|
|
35
36
|
export { default as Progress } from './Progress/Progress.svelte';
|
|
36
37
|
export { default as Pill } from './Pill/Pill.svelte';
|