@juspay/svelte-ui-components 2.90.0 → 2.91.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.
|
@@ -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
|
|
@@ -42,6 +42,20 @@
|
|
|
42
42
|
originalIndex: number;
|
|
43
43
|
} = $props();
|
|
44
44
|
|
|
45
|
+
// The td applies column.align as text-align, which flex containers ignore:
|
|
46
|
+
// column-flex builtins stretch their children and row-flex builtins pack to
|
|
47
|
+
// flex-start, so an aligned column's builtin cells stayed left. Mirror the
|
|
48
|
+
// column alignment as a modifier class the flex builtins translate into
|
|
49
|
+
// align-items / justify-content (same idiom as the header's
|
|
50
|
+
// justify-content mapping).
|
|
51
|
+
const alignmentClass = $derived(
|
|
52
|
+
column.align === 'right'
|
|
53
|
+
? 'builtin-align-end'
|
|
54
|
+
: column.align === 'center'
|
|
55
|
+
? 'builtin-align-center'
|
|
56
|
+
: ''
|
|
57
|
+
);
|
|
58
|
+
|
|
45
59
|
let copied = $state(false);
|
|
46
60
|
let copyResetTimer: ReturnType<typeof setTimeout> | null = null;
|
|
47
61
|
|
|
@@ -99,7 +113,7 @@
|
|
|
99
113
|
{:else if column.type === 'text-tag'}
|
|
100
114
|
{@const data = asJsonObject(value) ?? {}}
|
|
101
115
|
{@const tag = asTagCellData(data.tag ?? null)}
|
|
102
|
-
<div class="builtin-text-tag">
|
|
116
|
+
<div class="builtin-text-tag {alignmentClass}">
|
|
103
117
|
<span class="builtin-primary-text"
|
|
104
118
|
>{typeof data.text === 'string' ? data.text : cellValueToText(value)}</span
|
|
105
119
|
>
|
|
@@ -114,7 +128,7 @@
|
|
|
114
128
|
</div>
|
|
115
129
|
{:else if column.type === 'two-line-text'}
|
|
116
130
|
{@const data = asJsonObject(value) ?? {}}
|
|
117
|
-
<div class="builtin-two-line">
|
|
131
|
+
<div class="builtin-two-line {alignmentClass}">
|
|
118
132
|
<span class="builtin-primary-text">{typeof data.text1 === 'string' ? data.text1 : '-'}</span>
|
|
119
133
|
<span class="builtin-secondary-text">{typeof data.text2 === 'string' ? data.text2 : '-'}</span>
|
|
120
134
|
</div>
|
|
@@ -123,7 +137,7 @@
|
|
|
123
137
|
{@const icons = Array.isArray(data.icons)
|
|
124
138
|
? data.icons.filter((iconSrc) => typeof iconSrc === 'string')
|
|
125
139
|
: []}
|
|
126
|
-
<div class="builtin-icon-label">
|
|
140
|
+
<div class="builtin-icon-label {alignmentClass}">
|
|
127
141
|
{#each icons as iconSrc, iconIndex (`${iconIndex}-${iconSrc}`)}
|
|
128
142
|
<span class="builtin-icon-label-icon">
|
|
129
143
|
<Img src={String(iconSrc)} alt="" fallback="" />
|
|
@@ -133,7 +147,7 @@
|
|
|
133
147
|
</div>
|
|
134
148
|
{:else if column.type === 'image-two-line-text'}
|
|
135
149
|
{@const data = asJsonObject(value) ?? {}}
|
|
136
|
-
<div class="builtin-image-two-line">
|
|
150
|
+
<div class="builtin-image-two-line {alignmentClass}">
|
|
137
151
|
{#if typeof data.imageUrl === 'string' && data.imageUrl}
|
|
138
152
|
<span class="builtin-thumb">
|
|
139
153
|
<Img
|
|
@@ -145,7 +159,7 @@
|
|
|
145
159
|
{:else}
|
|
146
160
|
<span class="builtin-thumb builtin-thumb-placeholder"></span>
|
|
147
161
|
{/if}
|
|
148
|
-
<div class="builtin-two-line">
|
|
162
|
+
<div class="builtin-two-line {alignmentClass}">
|
|
149
163
|
<span class="builtin-primary-text">{typeof data.text1 === 'string' ? data.text1 : '-'}</span>
|
|
150
164
|
<span class="builtin-secondary-text">{typeof data.text2 === 'string' ? data.text2 : '-'}</span
|
|
151
165
|
>
|
|
@@ -154,7 +168,7 @@
|
|
|
154
168
|
{:else if column.type === 'tag-array'}
|
|
155
169
|
{@const tags = asTagArrayItems(value)}
|
|
156
170
|
{#if tags}
|
|
157
|
-
<div class="builtin-tag-array">
|
|
171
|
+
<div class="builtin-tag-array {alignmentClass}">
|
|
158
172
|
{#each tags as tag (tag.text)}
|
|
159
173
|
<Pill text={tag.text} classes={tag.classes ?? ''} />
|
|
160
174
|
{/each}
|
|
@@ -166,7 +180,7 @@
|
|
|
166
180
|
{@const stackData = asAvatarStackData(value)}
|
|
167
181
|
{#if stackData}
|
|
168
182
|
{@const stack = buildAvatarStack(stackData)}
|
|
169
|
-
<div class="builtin-avatar-stack">
|
|
183
|
+
<div class="builtin-avatar-stack {alignmentClass}">
|
|
170
184
|
<IconStack icons={stack.icons} />
|
|
171
185
|
{#if stack.rest > 0}
|
|
172
186
|
<span class="builtin-avatar-rest">+{stack.rest}</span>
|
|
@@ -181,7 +195,7 @@
|
|
|
181
195
|
{:else}
|
|
182
196
|
{@const compare = asCompareCellData(value)}
|
|
183
197
|
{#if compare}
|
|
184
|
-
<div class="builtin-compare">
|
|
198
|
+
<div class="builtin-compare {alignmentClass}">
|
|
185
199
|
{#if typeof compare.primary === 'string'}
|
|
186
200
|
<span class="builtin-primary-text">{compare.primary}</span>
|
|
187
201
|
{/if}
|
|
@@ -448,6 +462,35 @@
|
|
|
448
462
|
gap: var(--table-cell-line-gap, 2px);
|
|
449
463
|
}
|
|
450
464
|
|
|
465
|
+
/* column.align lands on the td as text-align, which flex layouts ignore.
|
|
466
|
+
Column-flex builtins follow it via align-items… */
|
|
467
|
+
.builtin-two-line.builtin-align-end,
|
|
468
|
+
.builtin-compare.builtin-align-end {
|
|
469
|
+
align-items: flex-end;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
.builtin-two-line.builtin-align-center,
|
|
473
|
+
.builtin-compare.builtin-align-center {
|
|
474
|
+
align-items: center;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/* …and row-flex builtins follow it via justify-content. */
|
|
478
|
+
.builtin-text-tag.builtin-align-end,
|
|
479
|
+
.builtin-icon-label.builtin-align-end,
|
|
480
|
+
.builtin-image-two-line.builtin-align-end,
|
|
481
|
+
.builtin-tag-array.builtin-align-end,
|
|
482
|
+
.builtin-avatar-stack.builtin-align-end {
|
|
483
|
+
justify-content: flex-end;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
.builtin-text-tag.builtin-align-center,
|
|
487
|
+
.builtin-icon-label.builtin-align-center,
|
|
488
|
+
.builtin-image-two-line.builtin-align-center,
|
|
489
|
+
.builtin-tag-array.builtin-align-center,
|
|
490
|
+
.builtin-avatar-stack.builtin-align-center {
|
|
491
|
+
justify-content: center;
|
|
492
|
+
}
|
|
493
|
+
|
|
451
494
|
.builtin-text-tag {
|
|
452
495
|
display: flex;
|
|
453
496
|
align-items: center;
|
package/dist/Table/Table.svelte
CHANGED
|
@@ -133,6 +133,37 @@
|
|
|
133
133
|
let isRowClickable = $derived(typeof onRowClick === 'function');
|
|
134
134
|
let isStickyHeader = $derived(stickyHeader || isTableScrollable);
|
|
135
135
|
|
|
136
|
+
// ─── Horizontal-scroll affordance ────────────────────────────────────────
|
|
137
|
+
// The table clips columns behind an internal horizontal scroll on narrow
|
|
138
|
+
// viewports, but a bare scroll container gives no visual hint that more
|
|
139
|
+
// columns exist. Track whether either edge has hidden content and surface
|
|
140
|
+
// it as edge scrims (see .table-scroll-shell styles).
|
|
141
|
+
let canScrollLeft = $state(false);
|
|
142
|
+
let canScrollRight = $state(false);
|
|
143
|
+
|
|
144
|
+
const trackHorizontalScroll = (scrollNode: HTMLElement) => {
|
|
145
|
+
const updateScrollHints = () => {
|
|
146
|
+
canScrollLeft = scrollNode.scrollLeft > 2;
|
|
147
|
+
canScrollRight = scrollNode.scrollLeft + scrollNode.clientWidth < scrollNode.scrollWidth - 2;
|
|
148
|
+
};
|
|
149
|
+
updateScrollHints();
|
|
150
|
+
scrollNode.addEventListener('scroll', updateScrollHints, { passive: true });
|
|
151
|
+
// Both the container resizing (viewport changes) and the table resizing
|
|
152
|
+
// (async rows/columns arriving) change scrollWidth, so observe both.
|
|
153
|
+
const hintResizeObserver = new ResizeObserver(updateScrollHints);
|
|
154
|
+
hintResizeObserver.observe(scrollNode);
|
|
155
|
+
const tableElement = scrollNode.querySelector('table');
|
|
156
|
+
if (tableElement) {
|
|
157
|
+
hintResizeObserver.observe(tableElement);
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
destroy: () => {
|
|
161
|
+
scrollNode.removeEventListener('scroll', updateScrollHints);
|
|
162
|
+
hintResizeObserver.disconnect();
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
};
|
|
166
|
+
|
|
136
167
|
// ─── C2-3: Search ─────────────────────────────────────────────────────────
|
|
137
168
|
let searchTerm = $state('');
|
|
138
169
|
let hasSearchConfig = $derived(!!searchConfig);
|
|
@@ -572,263 +603,286 @@
|
|
|
572
603
|
class="table-container {isTableScrollable ? 'scrollable-table' : ''} {classes ?? ''}"
|
|
573
604
|
data-pw={testId}
|
|
574
605
|
>
|
|
575
|
-
<
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
{
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
<span
|
|
585
|
-
class="table-checkbox-box"
|
|
586
|
-
class:checked={headerCheckboxState === 'all'}
|
|
587
|
-
class:indeterminate={headerCheckboxState === 'some'}
|
|
588
|
-
role="checkbox"
|
|
589
|
-
tabindex={0}
|
|
590
|
-
aria-checked={headerCheckboxState === 'some'
|
|
591
|
-
? 'mixed'
|
|
592
|
-
: headerCheckboxState === 'all'}
|
|
593
|
-
aria-label="Select all rows"
|
|
594
|
-
{...checkboxSelection?.getRowAttributes
|
|
595
|
-
? checkboxSelection.getRowAttributes('__header__', -1)
|
|
596
|
-
: {}}
|
|
597
|
-
aria-controls={selectableRowIds.map((rowId) => `row-checkbox-${rowId}`).join(' ')}
|
|
598
|
-
onclick={toggleAllSelection}
|
|
599
|
-
onkeydown={(keyboardEvent) =>
|
|
600
|
-
handleCheckboxKeydown(keyboardEvent, toggleAllSelection)}
|
|
601
|
-
>
|
|
602
|
-
{#if headerCheckboxState === 'all'}
|
|
603
|
-
<!-- eslint-disable svelte/no-at-html-tags -->
|
|
604
|
-
<span class="table-checkbox-icon">{@html checkmarkSvg}</span>
|
|
605
|
-
{:else if headerCheckboxState === 'some'}
|
|
606
|
-
<!-- eslint-disable svelte/no-at-html-tags -->
|
|
607
|
-
<span class="table-checkbox-icon">{@html minusSvg}</span>
|
|
608
|
-
{/if}
|
|
609
|
-
</span>
|
|
610
|
-
</th>
|
|
611
|
-
{:else if isCheckboxMode && isSingleSelect}
|
|
612
|
-
<!-- In single-select mode the header cell is an empty spacer -->
|
|
613
|
-
<th class="table-header table-checkbox-col" class:table-header-sticky={isStickyHeader}>
|
|
614
|
-
</th>
|
|
615
|
-
{/if}
|
|
616
|
-
{#if rowNumberColumn}
|
|
617
|
-
<th class="table-header table-row-number-col" class:table-header-sticky={isStickyHeader}
|
|
618
|
-
>{rowNumberLabel}</th
|
|
619
|
-
>
|
|
606
|
+
<div
|
|
607
|
+
class="table-scroll-shell"
|
|
608
|
+
class:scrollable-left={canScrollLeft}
|
|
609
|
+
class:scrollable-right={canScrollRight}
|
|
610
|
+
>
|
|
611
|
+
<div class="table-scroll" use:trackHorizontalScroll>
|
|
612
|
+
<table>
|
|
613
|
+
{#if caption}
|
|
614
|
+
<caption class="sr-only">{caption}</caption>
|
|
620
615
|
{/if}
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
>
|
|
630
|
-
<span
|
|
631
|
-
class="table-header-content"
|
|
632
|
-
style:justify-content={headerColumn?.align === 'right'
|
|
633
|
-
? 'flex-end'
|
|
634
|
-
: headerColumn?.align === 'center'
|
|
635
|
-
? 'center'
|
|
636
|
-
: null}
|
|
637
|
-
>
|
|
638
|
-
{#if headerColumn?.tooltip}
|
|
639
|
-
<Tooltip
|
|
640
|
-
text={headerColumn.tooltip}
|
|
641
|
-
position={headerTooltipPosition}
|
|
642
|
-
icon={headerTooltipIcon}
|
|
643
|
-
iconPosition="trailing"
|
|
644
|
-
>
|
|
645
|
-
<span
|
|
646
|
-
class="table-header-label"
|
|
647
|
-
class:table-header-label-plain={headerTooltipIcon}>{header}</span
|
|
648
|
-
>
|
|
649
|
-
</Tooltip>
|
|
650
|
-
{:else}
|
|
651
|
-
{header}
|
|
652
|
-
{/if}
|
|
653
|
-
{#if headerColumn?.filter}
|
|
654
|
-
{@const filter = headerColumn.filter}
|
|
655
|
-
<span class="table-header-filter">
|
|
656
|
-
<Menu
|
|
657
|
-
items={filter.options.map((option) => ({
|
|
658
|
-
value: option.value,
|
|
659
|
-
label: option.label
|
|
660
|
-
}))}
|
|
661
|
-
selectedValue={filter.selectedValue ?? null}
|
|
662
|
-
role="listbox"
|
|
663
|
-
testId={headerColumn.testId && `${headerColumn.testId}-filter`}
|
|
664
|
-
onselect={(menuItem) =>
|
|
665
|
-
filter.onFilterChange?.(
|
|
666
|
-
menuItem.value === filter.selectedValue ? null : menuItem.value
|
|
667
|
-
)}
|
|
668
|
-
>
|
|
669
|
-
{#snippet trigger()}
|
|
670
|
-
<span
|
|
671
|
-
class="table-header-filter-trigger"
|
|
672
|
-
class:table-header-filter-active={typeof filter.selectedValue ===
|
|
673
|
-
'string'}
|
|
674
|
-
>
|
|
675
|
-
<Button
|
|
676
|
-
ariaLabel="Filter by {header}"
|
|
677
|
-
testId={headerColumn.testId && `${headerColumn.testId}-filter-trigger`}
|
|
678
|
-
>
|
|
679
|
-
{#snippet icon()}
|
|
680
|
-
<!-- eslint-disable svelte/no-at-html-tags -->
|
|
681
|
-
<span class="table-header-filter-icon">{@html chevronDownSmSvg}</span>
|
|
682
|
-
{/snippet}
|
|
683
|
-
</Button>
|
|
684
|
-
</span>
|
|
685
|
-
{/snippet}
|
|
686
|
-
</Menu>
|
|
687
|
-
</span>
|
|
688
|
-
{/if}
|
|
689
|
-
{#if isColumnSortable(colIndex)}
|
|
690
|
-
<div class="sort-button">
|
|
691
|
-
<Button onclick={() => handleSort(colIndex)} ariaLabel="Sort by {header}">
|
|
692
|
-
{#if sortColumn === colIndex && sortDirection === 'asc'}
|
|
693
|
-
{#if typeof sortAscIcon === 'function'}
|
|
694
|
-
{@render sortAscIcon()}
|
|
695
|
-
{:else}
|
|
696
|
-
<span class="sort-icon">
|
|
697
|
-
<!-- eslint-disable svelte/no-at-html-tags -->
|
|
698
|
-
{@html chevronUpSvg}
|
|
699
|
-
</span>
|
|
700
|
-
{/if}
|
|
701
|
-
{:else if sortColumn === colIndex && sortDirection === 'desc'}
|
|
702
|
-
{#if typeof sortDescIcon === 'function'}
|
|
703
|
-
{@render sortDescIcon()}
|
|
704
|
-
{:else}
|
|
705
|
-
<span class="sort-icon">
|
|
706
|
-
<!-- eslint-disable svelte/no-at-html-tags -->
|
|
707
|
-
{@html chevronDownSvg}
|
|
708
|
-
</span>
|
|
709
|
-
{/if}
|
|
710
|
-
{:else if typeof sortDefaultIcon === 'function'}
|
|
711
|
-
{@render sortDefaultIcon()}
|
|
712
|
-
{:else}
|
|
713
|
-
<span class="sort-icon sort-icon-idle">
|
|
714
|
-
<!-- eslint-disable svelte/no-at-html-tags -->
|
|
715
|
-
{@html sortDefaultSvg}
|
|
716
|
-
</span>
|
|
717
|
-
{/if}
|
|
718
|
-
</Button>
|
|
719
|
-
</div>
|
|
720
|
-
{/if}
|
|
721
|
-
</span>
|
|
722
|
-
</th>
|
|
723
|
-
{/each}
|
|
724
|
-
</tr>
|
|
725
|
-
</thead>
|
|
726
|
-
<tbody>
|
|
727
|
-
{#if filteredTableData.length === 0 && typeof empty === 'function'}
|
|
728
|
-
<tr>
|
|
729
|
-
<td
|
|
730
|
-
class="table-empty"
|
|
731
|
-
colspan={effectiveHeaders.length +
|
|
732
|
-
(isCheckboxMode ? 1 : 0) +
|
|
733
|
-
(rowNumberColumn ? 1 : 0)}
|
|
734
|
-
>
|
|
735
|
-
{@render empty()}
|
|
736
|
-
</td>
|
|
737
|
-
</tr>
|
|
738
|
-
{:else}
|
|
739
|
-
{#each paginatedTableData as row, pageRowIndex (rowIdByRow.get(row) ?? pageRowIndex)}
|
|
740
|
-
{@const rowIndex = pageRowIndex + rowIndexOffset}
|
|
741
|
-
{@const originalIndex = originalIndexByRow.get(row) ?? rowIndex}
|
|
742
|
-
{@const rowId = rowIdByRow.get(row) ?? String(rowIndex)}
|
|
743
|
-
{@const rowDisabled = isCheckboxMode && isRowDisabled(rowId)}
|
|
744
|
-
{@const rowSelected = isCheckboxMode && isRowSelected(rowId)}
|
|
745
|
-
<tr
|
|
746
|
-
class="table-row"
|
|
747
|
-
class:table-row-clickable={isRowClickable}
|
|
748
|
-
class:table-row-selected={rowSelected}
|
|
749
|
-
data-pw={typeof getRowTestId === 'function' ? getRowTestId(row, rowIndex) : null}
|
|
750
|
-
onclick={isRowClickable ? () => handleRowClick(rowIndex, row, originalIndex) : null}
|
|
751
|
-
onkeydown={isRowClickable
|
|
752
|
-
? (keyboardEvent) => handleRowKeydown(keyboardEvent, rowIndex, row, originalIndex)
|
|
753
|
-
: null}
|
|
754
|
-
tabindex={isRowClickable ? 0 : null}
|
|
755
|
-
>
|
|
756
|
-
{#if isCheckboxMode}
|
|
757
|
-
<td class="table-content table-checkbox-col">
|
|
616
|
+
<thead>
|
|
617
|
+
<tr>
|
|
618
|
+
{#if isCheckboxMode && !isSingleSelect}
|
|
619
|
+
<th
|
|
620
|
+
class="table-header table-checkbox-col"
|
|
621
|
+
class:table-header-sticky={isStickyHeader}
|
|
622
|
+
>
|
|
623
|
+
<!-- Header tri-state checkbox -->
|
|
758
624
|
<span
|
|
759
625
|
class="table-checkbox-box"
|
|
760
|
-
class:checked={
|
|
761
|
-
class:
|
|
626
|
+
class:checked={headerCheckboxState === 'all'}
|
|
627
|
+
class:indeterminate={headerCheckboxState === 'some'}
|
|
762
628
|
role="checkbox"
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
aria-label=
|
|
629
|
+
tabindex={0}
|
|
630
|
+
aria-checked={headerCheckboxState === 'some'
|
|
631
|
+
? 'mixed'
|
|
632
|
+
: headerCheckboxState === 'all'}
|
|
633
|
+
aria-label="Select all rows"
|
|
768
634
|
{...checkboxSelection?.getRowAttributes
|
|
769
|
-
? checkboxSelection.getRowAttributes(
|
|
635
|
+
? checkboxSelection.getRowAttributes('__header__', -1)
|
|
770
636
|
: {}}
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
}
|
|
775
|
-
onkeydown={(keyboardEvent) =>
|
|
776
|
-
keyboardEvent
|
|
777
|
-
handleCheckboxKeydown(keyboardEvent, () => toggleRowSelection(rowId));
|
|
778
|
-
}}
|
|
637
|
+
aria-controls={selectableRowIds
|
|
638
|
+
.map((rowId) => `row-checkbox-${rowId}`)
|
|
639
|
+
.join(' ')}
|
|
640
|
+
onclick={toggleAllSelection}
|
|
641
|
+
onkeydown={(keyboardEvent) =>
|
|
642
|
+
handleCheckboxKeydown(keyboardEvent, toggleAllSelection)}
|
|
779
643
|
>
|
|
780
|
-
{#if
|
|
644
|
+
{#if headerCheckboxState === 'all'}
|
|
781
645
|
<!-- eslint-disable svelte/no-at-html-tags -->
|
|
782
646
|
<span class="table-checkbox-icon">{@html checkmarkSvg}</span>
|
|
647
|
+
{:else if headerCheckboxState === 'some'}
|
|
648
|
+
<!-- eslint-disable svelte/no-at-html-tags -->
|
|
649
|
+
<span class="table-checkbox-icon">{@html minusSvg}</span>
|
|
783
650
|
{/if}
|
|
784
651
|
</span>
|
|
785
|
-
</
|
|
652
|
+
</th>
|
|
653
|
+
{:else if isCheckboxMode && isSingleSelect}
|
|
654
|
+
<!-- In single-select mode the header cell is an empty spacer -->
|
|
655
|
+
<th
|
|
656
|
+
class="table-header table-checkbox-col"
|
|
657
|
+
class:table-header-sticky={isStickyHeader}
|
|
658
|
+
>
|
|
659
|
+
</th>
|
|
786
660
|
{/if}
|
|
787
661
|
{#if rowNumberColumn}
|
|
788
|
-
<
|
|
662
|
+
<th
|
|
663
|
+
class="table-header table-row-number-col"
|
|
664
|
+
class:table-header-sticky={isStickyHeader}>{rowNumberLabel}</th
|
|
665
|
+
>
|
|
789
666
|
{/if}
|
|
790
|
-
{#each
|
|
791
|
-
{@const
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
class="table-content"
|
|
799
|
-
data-pw={typeof getCellTestId === 'function'
|
|
800
|
-
? getCellTestId(row, cellValue, rowIndex)
|
|
801
|
-
: null}
|
|
802
|
-
style:text-align={keyedColumn?.align ?? null}
|
|
803
|
-
style:max-width={keyedColumn?.maxWidth ?? null}
|
|
804
|
-
title={keyedColumn?.maxWidth && isScalarCell ? String(cellValue) : null}
|
|
667
|
+
{#each effectiveHeaders as header, colIndex (colIndex)}
|
|
668
|
+
{@const headerColumn = columns?.[colIndex]}
|
|
669
|
+
<th
|
|
670
|
+
class="table-header"
|
|
671
|
+
class:table-header-sticky={isStickyHeader}
|
|
672
|
+
data-pw={headerColumn?.testId ?? null}
|
|
673
|
+
style:text-align={headerColumn?.align ?? null}
|
|
674
|
+
style:max-width={headerColumn?.maxWidth ?? null}
|
|
805
675
|
>
|
|
806
|
-
<
|
|
807
|
-
class=
|
|
808
|
-
|
|
676
|
+
<span
|
|
677
|
+
class="table-header-content"
|
|
678
|
+
style:justify-content={headerColumn?.align === 'right'
|
|
679
|
+
? 'flex-end'
|
|
680
|
+
: headerColumn?.align === 'center'
|
|
681
|
+
? 'center'
|
|
682
|
+
: null}
|
|
809
683
|
>
|
|
810
|
-
{#if
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
684
|
+
{#if headerColumn?.tooltip}
|
|
685
|
+
<Tooltip
|
|
686
|
+
text={headerColumn.tooltip}
|
|
687
|
+
position={headerTooltipPosition}
|
|
688
|
+
icon={headerTooltipIcon}
|
|
689
|
+
iconPosition="trailing"
|
|
690
|
+
>
|
|
691
|
+
<span
|
|
692
|
+
class="table-header-label"
|
|
693
|
+
class:table-header-label-plain={headerTooltipIcon}>{header}</span
|
|
694
|
+
>
|
|
695
|
+
</Tooltip>
|
|
821
696
|
{:else}
|
|
822
|
-
{
|
|
697
|
+
{header}
|
|
823
698
|
{/if}
|
|
824
|
-
|
|
825
|
-
|
|
699
|
+
{#if headerColumn?.filter}
|
|
700
|
+
{@const filter = headerColumn.filter}
|
|
701
|
+
<span class="table-header-filter">
|
|
702
|
+
<Menu
|
|
703
|
+
items={filter.options.map((option) => ({
|
|
704
|
+
value: option.value,
|
|
705
|
+
label: option.label
|
|
706
|
+
}))}
|
|
707
|
+
selectedValue={filter.selectedValue ?? null}
|
|
708
|
+
role="listbox"
|
|
709
|
+
testId={headerColumn.testId && `${headerColumn.testId}-filter`}
|
|
710
|
+
onselect={(menuItem) =>
|
|
711
|
+
filter.onFilterChange?.(
|
|
712
|
+
menuItem.value === filter.selectedValue ? null : menuItem.value
|
|
713
|
+
)}
|
|
714
|
+
>
|
|
715
|
+
{#snippet trigger()}
|
|
716
|
+
<span
|
|
717
|
+
class="table-header-filter-trigger"
|
|
718
|
+
class:table-header-filter-active={typeof filter.selectedValue ===
|
|
719
|
+
'string'}
|
|
720
|
+
>
|
|
721
|
+
<Button
|
|
722
|
+
ariaLabel="Filter by {header}"
|
|
723
|
+
testId={headerColumn.testId &&
|
|
724
|
+
`${headerColumn.testId}-filter-trigger`}
|
|
725
|
+
>
|
|
726
|
+
{#snippet icon()}
|
|
727
|
+
<!-- eslint-disable svelte/no-at-html-tags -->
|
|
728
|
+
<span class="table-header-filter-icon"
|
|
729
|
+
>{@html chevronDownSmSvg}</span
|
|
730
|
+
>
|
|
731
|
+
{/snippet}
|
|
732
|
+
</Button>
|
|
733
|
+
</span>
|
|
734
|
+
{/snippet}
|
|
735
|
+
</Menu>
|
|
736
|
+
</span>
|
|
737
|
+
{/if}
|
|
738
|
+
{#if isColumnSortable(colIndex)}
|
|
739
|
+
<div class="sort-button">
|
|
740
|
+
<Button onclick={() => handleSort(colIndex)} ariaLabel="Sort by {header}">
|
|
741
|
+
{#if sortColumn === colIndex && sortDirection === 'asc'}
|
|
742
|
+
{#if typeof sortAscIcon === 'function'}
|
|
743
|
+
{@render sortAscIcon()}
|
|
744
|
+
{:else}
|
|
745
|
+
<span class="sort-icon">
|
|
746
|
+
<!-- eslint-disable svelte/no-at-html-tags -->
|
|
747
|
+
{@html chevronUpSvg}
|
|
748
|
+
</span>
|
|
749
|
+
{/if}
|
|
750
|
+
{:else if sortColumn === colIndex && sortDirection === 'desc'}
|
|
751
|
+
{#if typeof sortDescIcon === 'function'}
|
|
752
|
+
{@render sortDescIcon()}
|
|
753
|
+
{:else}
|
|
754
|
+
<span class="sort-icon">
|
|
755
|
+
<!-- eslint-disable svelte/no-at-html-tags -->
|
|
756
|
+
{@html chevronDownSvg}
|
|
757
|
+
</span>
|
|
758
|
+
{/if}
|
|
759
|
+
{:else if typeof sortDefaultIcon === 'function'}
|
|
760
|
+
{@render sortDefaultIcon()}
|
|
761
|
+
{:else}
|
|
762
|
+
<span class="sort-icon sort-icon-idle">
|
|
763
|
+
<!-- eslint-disable svelte/no-at-html-tags -->
|
|
764
|
+
{@html sortDefaultSvg}
|
|
765
|
+
</span>
|
|
766
|
+
{/if}
|
|
767
|
+
</Button>
|
|
768
|
+
</div>
|
|
769
|
+
{/if}
|
|
770
|
+
</span>
|
|
771
|
+
</th>
|
|
826
772
|
{/each}
|
|
827
773
|
</tr>
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
774
|
+
</thead>
|
|
775
|
+
<tbody>
|
|
776
|
+
{#if filteredTableData.length === 0 && typeof empty === 'function'}
|
|
777
|
+
<tr>
|
|
778
|
+
<td
|
|
779
|
+
class="table-empty"
|
|
780
|
+
colspan={effectiveHeaders.length +
|
|
781
|
+
(isCheckboxMode ? 1 : 0) +
|
|
782
|
+
(rowNumberColumn ? 1 : 0)}
|
|
783
|
+
>
|
|
784
|
+
{@render empty()}
|
|
785
|
+
</td>
|
|
786
|
+
</tr>
|
|
787
|
+
{:else}
|
|
788
|
+
{#each paginatedTableData as row, pageRowIndex (rowIdByRow.get(row) ?? pageRowIndex)}
|
|
789
|
+
{@const rowIndex = pageRowIndex + rowIndexOffset}
|
|
790
|
+
{@const originalIndex = originalIndexByRow.get(row) ?? rowIndex}
|
|
791
|
+
{@const rowId = rowIdByRow.get(row) ?? String(rowIndex)}
|
|
792
|
+
{@const rowDisabled = isCheckboxMode && isRowDisabled(rowId)}
|
|
793
|
+
{@const rowSelected = isCheckboxMode && isRowSelected(rowId)}
|
|
794
|
+
<tr
|
|
795
|
+
class="table-row"
|
|
796
|
+
class:table-row-clickable={isRowClickable}
|
|
797
|
+
class:table-row-selected={rowSelected}
|
|
798
|
+
data-pw={typeof getRowTestId === 'function' ? getRowTestId(row, rowIndex) : null}
|
|
799
|
+
onclick={isRowClickable
|
|
800
|
+
? () => handleRowClick(rowIndex, row, originalIndex)
|
|
801
|
+
: null}
|
|
802
|
+
onkeydown={isRowClickable
|
|
803
|
+
? (keyboardEvent) =>
|
|
804
|
+
handleRowKeydown(keyboardEvent, rowIndex, row, originalIndex)
|
|
805
|
+
: null}
|
|
806
|
+
tabindex={isRowClickable ? 0 : null}
|
|
807
|
+
>
|
|
808
|
+
{#if isCheckboxMode}
|
|
809
|
+
<td class="table-content table-checkbox-col">
|
|
810
|
+
<span
|
|
811
|
+
class="table-checkbox-box"
|
|
812
|
+
class:checked={rowSelected}
|
|
813
|
+
class:disabled={rowDisabled}
|
|
814
|
+
role="checkbox"
|
|
815
|
+
id={`row-checkbox-${rowId}`}
|
|
816
|
+
tabindex={rowDisabled ? -1 : 0}
|
|
817
|
+
aria-checked={rowSelected}
|
|
818
|
+
aria-disabled={rowDisabled}
|
|
819
|
+
aria-label={`Select row ${rowId || 'non-selectable'}`}
|
|
820
|
+
{...checkboxSelection?.getRowAttributes
|
|
821
|
+
? checkboxSelection.getRowAttributes(rowId, rowIndex)
|
|
822
|
+
: {}}
|
|
823
|
+
onclick={(mouseEvent) => {
|
|
824
|
+
mouseEvent.stopPropagation();
|
|
825
|
+
toggleRowSelection(rowId);
|
|
826
|
+
}}
|
|
827
|
+
onkeydown={(keyboardEvent) => {
|
|
828
|
+
keyboardEvent.stopPropagation();
|
|
829
|
+
handleCheckboxKeydown(keyboardEvent, () => toggleRowSelection(rowId));
|
|
830
|
+
}}
|
|
831
|
+
>
|
|
832
|
+
{#if rowSelected}
|
|
833
|
+
<!-- eslint-disable svelte/no-at-html-tags -->
|
|
834
|
+
<span class="table-checkbox-icon">{@html checkmarkSvg}</span>
|
|
835
|
+
{/if}
|
|
836
|
+
</span>
|
|
837
|
+
</td>
|
|
838
|
+
{/if}
|
|
839
|
+
{#if rowNumberColumn}
|
|
840
|
+
<td class="table-content table-row-number-col">{rowNumberFor(pageRowIndex)}</td>
|
|
841
|
+
{/if}
|
|
842
|
+
{#each row as cellValue, colIndex (colIndex)}
|
|
843
|
+
{@const keyedColumn = columns?.[colIndex]}
|
|
844
|
+
{@const keyedRow = keyedRowByProjected?.get(row)}
|
|
845
|
+
{@const isScalarCell =
|
|
846
|
+
typeof cellValue === 'string' ||
|
|
847
|
+
typeof cellValue === 'number' ||
|
|
848
|
+
typeof cellValue === 'boolean'}
|
|
849
|
+
<td
|
|
850
|
+
class="table-content"
|
|
851
|
+
data-pw={typeof getCellTestId === 'function'
|
|
852
|
+
? getCellTestId(row, cellValue, rowIndex)
|
|
853
|
+
: null}
|
|
854
|
+
style:text-align={keyedColumn?.align ?? null}
|
|
855
|
+
style:max-width={keyedColumn?.maxWidth ?? null}
|
|
856
|
+
title={keyedColumn?.maxWidth && isScalarCell ? String(cellValue) : null}
|
|
857
|
+
>
|
|
858
|
+
<div
|
|
859
|
+
class={isContentScrollable ? 'scrollable-content' : ''}
|
|
860
|
+
class:table-cell-clamp={keyedColumn?.maxWidth && isScalarCell}
|
|
861
|
+
>
|
|
862
|
+
{#if keyedColumn && typeof keyedColumn.cell === 'function' && keyedRow}
|
|
863
|
+
{@render keyedColumn.cell(keyedRow, rowIndex, originalIndex)}
|
|
864
|
+
{:else if keyedColumn?.type && keyedColumn.type !== 'text' && keyedColumn.type !== 'custom'}
|
|
865
|
+
<BuiltinCell
|
|
866
|
+
column={keyedColumn}
|
|
867
|
+
value={cellValue}
|
|
868
|
+
{rowIndex}
|
|
869
|
+
{originalIndex}
|
|
870
|
+
/>
|
|
871
|
+
{:else if typeof cell === 'function'}
|
|
872
|
+
{@render cell(cellValue, rowIndex, colIndex)}
|
|
873
|
+
{:else}
|
|
874
|
+
{cellValue}
|
|
875
|
+
{/if}
|
|
876
|
+
</div>
|
|
877
|
+
</td>
|
|
878
|
+
{/each}
|
|
879
|
+
</tr>
|
|
880
|
+
{/each}
|
|
881
|
+
{/if}
|
|
882
|
+
</tbody>
|
|
883
|
+
</table>
|
|
884
|
+
</div>
|
|
885
|
+
</div>
|
|
832
886
|
{#if typeof paginatorSlot === 'function'}
|
|
833
887
|
<div class="table-footer">
|
|
834
888
|
{@render paginatorSlot()}
|
|
@@ -968,6 +1022,57 @@
|
|
|
968
1022
|
overflow-y: auto;
|
|
969
1023
|
}
|
|
970
1024
|
|
|
1025
|
+
.table-scroll-shell {
|
|
1026
|
+
position: relative;
|
|
1027
|
+
min-width: 0;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/* Edge scrims: fade the clipped side of the table into its background so
|
|
1031
|
+
hidden columns read as "more content this way". Class-driven from live
|
|
1032
|
+
scroll state — never shown when the table fits. pointer-events: none
|
|
1033
|
+
keeps cells under the fade clickable; z-index 2 paints above sticky
|
|
1034
|
+
headers (z-index 1). */
|
|
1035
|
+
.table-scroll-shell::before,
|
|
1036
|
+
.table-scroll-shell::after {
|
|
1037
|
+
content: '';
|
|
1038
|
+
position: absolute;
|
|
1039
|
+
top: 0;
|
|
1040
|
+
bottom: 0;
|
|
1041
|
+
width: var(--table-scroll-scrim-width, 32px);
|
|
1042
|
+
opacity: 0;
|
|
1043
|
+
pointer-events: none;
|
|
1044
|
+
transition: opacity 0.2s ease;
|
|
1045
|
+
z-index: 2;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
.table-scroll-shell::before {
|
|
1049
|
+
left: 0;
|
|
1050
|
+
border-radius: var(--table-border-radius, var(--radius, 4px)) 0 0
|
|
1051
|
+
var(--table-border-radius, var(--radius, 4px));
|
|
1052
|
+
background: linear-gradient(to right, var(--table-scroll-scrim-color, #ffffff), transparent);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
.table-scroll-shell::after {
|
|
1056
|
+
right: 0;
|
|
1057
|
+
border-radius: 0 var(--table-border-radius, var(--radius, 4px))
|
|
1058
|
+
var(--table-border-radius, var(--radius, 4px)) 0;
|
|
1059
|
+
background: linear-gradient(to left, var(--table-scroll-scrim-color, #ffffff), transparent);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
.table-scroll-shell.scrollable-left::before {
|
|
1063
|
+
opacity: 1;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
.table-scroll-shell.scrollable-right::after {
|
|
1067
|
+
opacity: 1;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
.table-scroll {
|
|
1071
|
+
overflow-x: auto;
|
|
1072
|
+
min-width: 0;
|
|
1073
|
+
scrollbar-width: thin;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
971
1076
|
table {
|
|
972
1077
|
width: var(--table-width, 100%);
|
|
973
1078
|
border-collapse: var(--table-border-collapse, collapse);
|
|
@@ -192,6 +192,9 @@ export type TableColumn = {
|
|
|
192
192
|
/**
|
|
193
193
|
* Horizontal alignment for this column's header and body cells. When unset,
|
|
194
194
|
* cells follow the table-wide `--table-text-align` (left by default).
|
|
195
|
+
* Built-in flex cells (compare, two-line-text, text-tag, icon-label,
|
|
196
|
+
* image-two-line-text, tag-array, avatar-stack) mirror it into their flex
|
|
197
|
+
* alignment so stacked/inline content follows the aligned edge too.
|
|
195
198
|
*/
|
|
196
199
|
align?: 'left' | 'center' | 'right';
|
|
197
200
|
/**
|
|
@@ -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,
|