@juspay/svelte-ui-components 4.29.1 → 4.31.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/README.md +15 -14
- package/dist/Accordion/Accordion.svelte +18 -0
- package/dist/AnimatedNumber/AnimatedNumber.svelte +776 -0
- package/dist/AnimatedNumber/AnimatedNumber.svelte.d.ts +4 -0
- package/dist/AnimatedNumber/motion.d.ts +47 -0
- package/dist/AnimatedNumber/motion.js +190 -0
- package/dist/AnimatedNumber/properties.d.ts +58 -0
- package/dist/AnimatedNumber/properties.js +1 -0
- package/dist/Badge/Badge.svelte +20 -3
- package/dist/Badge/properties.d.ts +9 -0
- package/dist/Book/Book.svelte +9 -0
- package/dist/Calendar/Calendar.svelte +34 -2
- package/dist/Carousel/Carousel.svelte +17 -2
- package/dist/ChatBubble/ChatBubble.svelte +12 -0
- package/dist/ChatComposer/ChatComposer.svelte +9 -0
- package/dist/CommandMenu/CommandMenu.svelte +13 -0
- package/dist/DateRangePicker/DateRangePicker.svelte +77 -20
- package/dist/DeltaIndicator/DeltaIndicator.svelte +12 -1
- package/dist/DeltaIndicator/properties.d.ts +9 -0
- package/dist/Gallery/Gallery.svelte +16 -0
- package/dist/Gauge/Gauge.svelte +21 -2
- package/dist/Gauge/properties.d.ts +13 -0
- package/dist/Input/Input.svelte +34 -26
- package/dist/KeyboardInput/KeyboardInput.svelte +1 -1
- package/dist/ListItem/ListItem.svelte +27 -2
- package/dist/ListItem/label.d.ts +11 -0
- package/dist/ListItem/label.js +40 -0
- package/dist/ListItem/properties.d.ts +52 -0
- package/dist/MediaUpload/MediaUpload.svelte +12 -1
- package/dist/MediaUpload/properties.d.ts +9 -0
- package/dist/Menu/Menu.svelte +11 -2
- package/dist/Modal/Modal.svelte +14 -4
- package/dist/PieChart/PieChart.svelte +16 -1
- package/dist/PieChart/properties.d.ts +13 -0
- package/dist/Progress/Progress.svelte +21 -1
- package/dist/Progress/properties.d.ts +12 -0
- package/dist/ProportionBar/ProportionBar.svelte +7 -1
- package/dist/ProportionBar/ProportionBar.svelte.d.ts +0 -19
- package/dist/ProportionBar/properties.d.ts +10 -0
- package/dist/Select/Select.svelte +23 -8
- package/dist/SplitButton/SplitButton.svelte +4 -1
- package/dist/SplitInput/SplitInput.svelte +1 -1
- package/dist/StatCard/StatCard.svelte +129 -5
- package/dist/StatCard/metric.d.ts +22 -0
- package/dist/StatCard/metric.js +74 -0
- package/dist/StatCard/properties.d.ts +44 -0
- package/dist/Table/BuiltinCell.svelte +19 -2
- package/dist/Table/cellData.js +3 -0
- package/dist/Table/properties.d.ts +15 -0
- package/dist/Tabs/Tabs.svelte +16 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/styles/theme-dark.css +27 -3
- package/dist-codemod/codemod/migration-assets/legacy-display.css +2 -1
- package/dist-codemod/codemod/migration-assets/legacy-palette.css +4 -1
- package/dist-codemod/codemod/migration-assets/wc-display.json +5 -0
- package/dist-wc/index.js +1435 -747
- package/package.json +4 -2
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import AnimatedNumber from '../AnimatedNumber/AnimatedNumber.svelte';
|
|
2
3
|
import Button from '../Button/Button.svelte';
|
|
3
4
|
import Img from '../Img/Img.svelte';
|
|
4
5
|
import Shimmer from '../Shimmer/Shimmer.svelte';
|
|
@@ -26,6 +27,7 @@
|
|
|
26
27
|
fileIcon,
|
|
27
28
|
errorMessages,
|
|
28
29
|
files = $bindable([]),
|
|
30
|
+
animateCounter = false,
|
|
29
31
|
onchange,
|
|
30
32
|
onremove,
|
|
31
33
|
onerror,
|
|
@@ -191,7 +193,16 @@
|
|
|
191
193
|
<div class="label">{label}</div>
|
|
192
194
|
{/if}
|
|
193
195
|
{#if showCounter}
|
|
194
|
-
|
|
196
|
+
{#if animateCounter}
|
|
197
|
+
<!-- The whole "N / max" reads as one value to assistive technology.
|
|
198
|
+
Only the count changes, so only its digits roll; the separator and
|
|
199
|
+
the maximum sit still as literal columns. -->
|
|
200
|
+
<div class="counter">
|
|
201
|
+
<AnimatedNumber value={`${items.length} / ${maxLength}`} />
|
|
202
|
+
</div>
|
|
203
|
+
{:else}
|
|
204
|
+
<div class="counter">{items.length} / {maxLength}</div>
|
|
205
|
+
{/if}
|
|
195
206
|
{/if}
|
|
196
207
|
</div>
|
|
197
208
|
{/if}
|
|
@@ -36,6 +36,15 @@ export type OptionalMediaUploadProperties = {
|
|
|
36
36
|
fileIcon?: Snippet;
|
|
37
37
|
errorMessages?: MediaUploadErrorMessages;
|
|
38
38
|
files?: File[];
|
|
39
|
+
/**
|
|
40
|
+
* Routes the header's `N / maxLength` counter through `AnimatedNumber` so an
|
|
41
|
+
* add/remove rolls the count instead of swapping it as plain text. Only
|
|
42
|
+
* `items.length` animates -- the `/` separator and `maxLength` stay static,
|
|
43
|
+
* since neither of those actually changes. Off by default so an unset prop
|
|
44
|
+
* renders the exact static text node it always has.
|
|
45
|
+
* @default false
|
|
46
|
+
*/
|
|
47
|
+
animateCounter?: boolean;
|
|
39
48
|
testId?: string;
|
|
40
49
|
classes?: string;
|
|
41
50
|
};
|
package/dist/Menu/Menu.svelte
CHANGED
|
@@ -527,8 +527,17 @@
|
|
|
527
527
|
-webkit-tap-highlight-color: transparent;
|
|
528
528
|
}
|
|
529
529
|
|
|
530
|
-
|
|
531
|
-
|
|
530
|
+
/* :focus-visible, not :focus: the trigger is opened by mouse click as often
|
|
531
|
+
as by keyboard (onclick={toggle} above), and a ring on every mouse-opened
|
|
532
|
+
menu would be noise. The default used to be `none` with nothing standing
|
|
533
|
+
in for it, so a keyboard user tabbing to (or landing back on, after
|
|
534
|
+
Escape closes the dropdown) the trigger had no visible sign it was
|
|
535
|
+
focused. SplitButton's own trigger wraps this same element and used to
|
|
536
|
+
set --menu-trigger-focus-outline to `none` for the identical reason --
|
|
537
|
+
see its .split-button-trigger, now fixed to inherit this default instead. */
|
|
538
|
+
.menu-trigger:focus-visible {
|
|
539
|
+
outline: var(--menu-trigger-focus-outline, 2px solid #2563eb);
|
|
540
|
+
outline-offset: var(--menu-trigger-focus-outline-offset, 2px);
|
|
532
541
|
}
|
|
533
542
|
|
|
534
543
|
.menu-dropdown {
|
package/dist/Modal/Modal.svelte
CHANGED
|
@@ -445,10 +445,6 @@
|
|
|
445
445
|
border-radius: var(--modal-border-radius, var(--radius, 4px));
|
|
446
446
|
overflow: var(--modal-content-overflow, auto);
|
|
447
447
|
border-top: var(--modal-content-border-top);
|
|
448
|
-
/* tabindex="-1" makes this the focus-trap's fallback target when the modal
|
|
449
|
-
has no focusable content of its own (see _interaction/focus.ts); it is
|
|
450
|
-
never in the Tab order itself, so it needs no visible focus ring the way
|
|
451
|
-
a real focusable element would. Matches Sheet's sheet-panel. */
|
|
452
448
|
outline: none;
|
|
453
449
|
/* Viewport containment for every size class: only .fit-content used to carry
|
|
454
450
|
a max-height, so a size whose height var is overridden to fit-content (or
|
|
@@ -459,6 +455,20 @@
|
|
|
459
455
|
max-height: var(--modal-max-height, calc(100dvh - 32px));
|
|
460
456
|
}
|
|
461
457
|
|
|
458
|
+
/* The comment this replaced claimed tabindex="-1" meant this never needed a
|
|
459
|
+
ring because it's "never in the Tab order itself" -- true, but beside the
|
|
460
|
+
point: onMount calls focusEntryPoint(modalContent), which focuses THIS
|
|
461
|
+
element directly (real DOM focus, not merely reachable via Tab) whenever
|
|
462
|
+
the modal has no focusable content of its own. That is exactly Sheet's
|
|
463
|
+
sheet-panel problem, and sheet-panel got the fix this comment said it
|
|
464
|
+
"matches" -- this one didn't. :focus-visible so the trigger click that
|
|
465
|
+
opens the modal shows nothing new; offset is negative like Sheet's,
|
|
466
|
+
since a modal can be sized to fill nearly the whole viewport. */
|
|
467
|
+
.modal-content:focus-visible {
|
|
468
|
+
outline: var(--modal-content-focus-outline, 2px solid #2563eb);
|
|
469
|
+
outline-offset: var(--modal-content-focus-outline-offset, -2px);
|
|
470
|
+
}
|
|
471
|
+
|
|
462
472
|
.slot-content {
|
|
463
473
|
display: var(--modal-display, flex);
|
|
464
474
|
/* Flex children default to min-height: auto and refuse to shrink below their
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import ChartTooltip from '../_chart/ChartTooltip.svelte';
|
|
7
7
|
import Legend from '../_chart/Legend.svelte';
|
|
8
8
|
import DeltaIndicator from '../DeltaIndicator/DeltaIndicator.svelte';
|
|
9
|
+
import AnimatedNumber from '../AnimatedNumber/AnimatedNumber.svelte';
|
|
9
10
|
import { arcPath } from '../_chart/paths';
|
|
10
11
|
import { computePieLayout, pieSliceValue } from '../_chart/geometry';
|
|
11
12
|
import { getColor } from '../_chart/colors';
|
|
@@ -45,6 +46,7 @@
|
|
|
45
46
|
legendPosition = 'bottom',
|
|
46
47
|
legendMaxItems,
|
|
47
48
|
onlegendmore,
|
|
49
|
+
animateLegendValues = false,
|
|
48
50
|
percentDecimals = 0,
|
|
49
51
|
onchartready,
|
|
50
52
|
highlightedIndex = null,
|
|
@@ -123,6 +125,15 @@
|
|
|
123
125
|
(v: number): string =>
|
|
124
126
|
total === 0 ? '0%' : ((v / total) * 100).toFixed(percentDecimals) + '%'
|
|
125
127
|
);
|
|
128
|
+
// Builds the exact text the static legend branch below prints -- value,
|
|
129
|
+
// then a non-breaking space, then the percentage -- as one string, so
|
|
130
|
+
// the animateLegendValues branch feeds AnimatedNumber literally what a
|
|
131
|
+
// reader already sees today rather than a re-derived approximation of it.
|
|
132
|
+
let legendValueText = $derived.by(
|
|
133
|
+
() =>
|
|
134
|
+
(v: number): string =>
|
|
135
|
+
`${format(v)}\u00A0${pctFormat(v)}`
|
|
136
|
+
);
|
|
126
137
|
let isEmpty = $derived(data.length === 0 || total === 0);
|
|
127
138
|
|
|
128
139
|
// When semiCircle is true the effective aspect ratio is driven by:
|
|
@@ -590,7 +601,11 @@
|
|
|
590
601
|
<span class="pie-legend-swatch" style="background: {d.color ?? getColor(i)}"></span>
|
|
591
602
|
<span class="pie-legend-label">{d.label}</span>
|
|
592
603
|
<span class="pie-legend-value">
|
|
593
|
-
{
|
|
604
|
+
{#if animateLegendValues}
|
|
605
|
+
<AnimatedNumber value={legendValueText(pieSliceValue(d.value))} />
|
|
606
|
+
{:else}
|
|
607
|
+
{format(pieSliceValue(d.value))} {pctFormat(pieSliceValue(d.value))}
|
|
608
|
+
{/if}
|
|
594
609
|
</span>
|
|
595
610
|
</li>
|
|
596
611
|
{/each}
|
|
@@ -66,6 +66,19 @@ export type OptionalPieChartProperties = {
|
|
|
66
66
|
* the control toggles the hidden rows in place.
|
|
67
67
|
*/
|
|
68
68
|
onlegendmore?: () => void;
|
|
69
|
+
/**
|
|
70
|
+
* Routes `.pie-legend-value` (the `legendShowValues` list) through
|
|
71
|
+
* `AnimatedNumber` so a changed slice value rolls instead of jumping. Off
|
|
72
|
+
* by default because it is new: with it left unset, the legend renders the
|
|
73
|
+
* same static text node it always has. The exact string the static branch
|
|
74
|
+
* already prints -- `valueFormat` when supplied, else the built-in
|
|
75
|
+
* default, plus the percentage -- is passed straight through, so a
|
|
76
|
+
* consumer's own formatter keeps deciding what the digits say. The on-arc
|
|
77
|
+
* `<text>` labels can't host per-digit spans and are unaffected either
|
|
78
|
+
* way.
|
|
79
|
+
* @default false
|
|
80
|
+
*/
|
|
81
|
+
animateLegendValues?: boolean;
|
|
69
82
|
percentDecimals?: number;
|
|
70
83
|
/**
|
|
71
84
|
* Called once on mount with the imperative highlight API. Pass the returned
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { ProgressProperties } from './properties';
|
|
3
|
+
import AnimatedNumber from '../AnimatedNumber/AnimatedNumber.svelte';
|
|
3
4
|
|
|
4
5
|
let {
|
|
5
6
|
value,
|
|
6
7
|
max = 100,
|
|
7
8
|
showLabel = false,
|
|
9
|
+
animateValue = false,
|
|
8
10
|
ariaLabel,
|
|
9
11
|
testId,
|
|
10
12
|
classes
|
|
@@ -58,7 +60,25 @@
|
|
|
58
60
|
></div>
|
|
59
61
|
</div>
|
|
60
62
|
{#if showLabel && !isIndeterminate}
|
|
61
|
-
<div class="label">
|
|
63
|
+
<div class="label">
|
|
64
|
+
{#if animateValue}
|
|
65
|
+
<!-- The rounded percentage is already the display value labelText
|
|
66
|
+
computes, so it is passed as a plain number rather than through
|
|
67
|
+
`format`: `AnimatedNumber`'s Intl percent style expects a 0-1
|
|
68
|
+
fraction and would re-derive a different number from this
|
|
69
|
+
already-0-100 one. The % is a literal beside it instead, exactly
|
|
70
|
+
mirroring what labelText already spells out below. -->
|
|
71
|
+
<!-- The unit goes INSIDE AnimatedNumber, not beside it. The odometer
|
|
72
|
+
carries role="img" with its own aria-label, so a unit left outside is
|
|
73
|
+
invisible to anyone navigating by graphic -- the rotor reads "75" with
|
|
74
|
+
no sign of what it measures. Passing the formatted string keeps the
|
|
75
|
+
unit in the accessible name; only the digits move, since the string
|
|
76
|
+
path rolls the characters that changed and leaves the rest. -->
|
|
77
|
+
<AnimatedNumber value={`${Math.round(percentage)}%`} />
|
|
78
|
+
{:else}
|
|
79
|
+
{labelText}
|
|
80
|
+
{/if}
|
|
81
|
+
</div>
|
|
62
82
|
{/if}
|
|
63
83
|
</div>
|
|
64
84
|
|
|
@@ -12,6 +12,18 @@ export type OptionalProgressProperties = {
|
|
|
12
12
|
*/
|
|
13
13
|
max?: number;
|
|
14
14
|
showLabel?: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Routes the completion percentage through `AnimatedNumber` instead of
|
|
17
|
+
* static text, so a consumer that already renders `showLabel` gets the
|
|
18
|
+
* label visibly counting to its new value for free. Kept opt-in rather
|
|
19
|
+
* than folded into `showLabel` itself, since `AnimatedNumber` is a new
|
|
20
|
+
* dependency this component didn't previously have and existing markup
|
|
21
|
+
* must stay untouched by default. An indeterminate bar has no percentage
|
|
22
|
+
* to animate towards, so this is silently inert there rather than a state
|
|
23
|
+
* that would need its own documentation.
|
|
24
|
+
* @default false
|
|
25
|
+
*/
|
|
26
|
+
animateValue?: boolean;
|
|
15
27
|
/**
|
|
16
28
|
* Accessible label for the progress element (`role="progressbar"`). Falls
|
|
17
29
|
* back to the computed percentage text (e.g. `"75%"`) when determinate, or
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*
|
|
19
19
|
* @see docs/ProportionBar.md
|
|
20
20
|
*/
|
|
21
|
+
import AnimatedNumber from '../AnimatedNumber/AnimatedNumber.svelte';
|
|
21
22
|
import type { ProportionBarProperties, ProportionBarSegment } from './properties';
|
|
22
23
|
|
|
23
24
|
const DEFAULT_PALETTE = ['#8F49DE', '#FFC533', '#62D5C0', '#FF74CD', '#D1E7FF'];
|
|
@@ -27,6 +28,7 @@
|
|
|
27
28
|
showLegend = true,
|
|
28
29
|
valueFormat,
|
|
29
30
|
trackHeight,
|
|
31
|
+
animateValue = false,
|
|
30
32
|
testId,
|
|
31
33
|
classes
|
|
32
34
|
}: ProportionBarProperties = $props();
|
|
@@ -152,7 +154,11 @@
|
|
|
152
154
|
></span>
|
|
153
155
|
<span class="proportion-bar-legend-label">{computedSegment.label}</span>
|
|
154
156
|
<span class="proportion-bar-legend-value">
|
|
155
|
-
{
|
|
157
|
+
{#if animateValue}
|
|
158
|
+
<AnimatedNumber value={formatValue(computedSegment.value, computedSegment.percent)} />
|
|
159
|
+
{:else}
|
|
160
|
+
{formatValue(computedSegment.value, computedSegment.percent)}
|
|
161
|
+
{/if}
|
|
156
162
|
</span>
|
|
157
163
|
</li>
|
|
158
164
|
{/each}
|
|
@@ -1,22 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ProportionBar — a horizontal stacked bar that visualises how a total is
|
|
3
|
-
* distributed across labelled segments. Renders proportional coloured bands in
|
|
4
|
-
* an SVG track with an optional legend. Each band's width is derived from its
|
|
5
|
-
* `value` relative to the sum of all segments; non-finite or negative values
|
|
6
|
-
* are treated as zero so the rendered widths always stay within 0–100%.
|
|
7
|
-
*
|
|
8
|
-
* @example
|
|
9
|
-
* ```svelte
|
|
10
|
-
* <ProportionBar
|
|
11
|
-
* segments={[
|
|
12
|
-
* { label: 'UPI', value: 480 },
|
|
13
|
-
* { label: 'Cards', value: 220 }
|
|
14
|
-
* ]}
|
|
15
|
-
* />
|
|
16
|
-
* ```
|
|
17
|
-
*
|
|
18
|
-
* @see docs/ProportionBar.md
|
|
19
|
-
*/
|
|
20
1
|
import type { ProportionBarProperties } from './properties';
|
|
21
2
|
declare const ProportionBar: import("svelte").Component<ProportionBarProperties, {}, "">;
|
|
22
3
|
type ProportionBar = ReturnType<typeof ProportionBar>;
|
|
@@ -27,6 +27,16 @@ export type OptionalProportionBarProperties = {
|
|
|
27
27
|
* `--proportion-bar-track-height` CSS variable.
|
|
28
28
|
*/
|
|
29
29
|
trackHeight?: string;
|
|
30
|
+
/**
|
|
31
|
+
* Routes the legend value column through `AnimatedNumber` so a changed
|
|
32
|
+
* value rolls instead of jumping. Off by default because it is new: with it
|
|
33
|
+
* left unset, the legend renders the same static text node it always has.
|
|
34
|
+
* The formatted string from `valueFormat` (or the built-in default) is
|
|
35
|
+
* passed straight through, never the raw `value` number, so a consumer's
|
|
36
|
+
* own formatter keeps deciding what the digits say.
|
|
37
|
+
* @default false
|
|
38
|
+
*/
|
|
39
|
+
animateValue?: boolean;
|
|
30
40
|
/** Test selector applied as the `data-pw` attribute on the root element. */
|
|
31
41
|
testId?: string;
|
|
32
42
|
/** Extra CSS class names appended to the root element. */
|
|
@@ -451,10 +451,18 @@
|
|
|
451
451
|
|
|
452
452
|
async function moveHighlight(delta: number): Promise<void> {
|
|
453
453
|
const next = highlightedIndex + delta;
|
|
454
|
-
|
|
454
|
+
const inBounds = next >= 0 && next < optionRows.length;
|
|
455
|
+
// Off either end, wrap to the opposite edge instead of stopping -- landing
|
|
456
|
+
// on the nearest selectable row there the same way moveHighlightToEdge
|
|
457
|
+
// does for Home/End, rather than the raw first/last row which may be
|
|
458
|
+
// disabled. Wrapping only makes sense once the panel is open and has rows
|
|
459
|
+
// to land on; ArrowUp (unlike ArrowDown) reaches here even while closed,
|
|
460
|
+
// and closed this must stay the plain out-of-bounds no-op it always was.
|
|
461
|
+
const target = inBounds ? next : open ? nearestSelectableEdge(next < 0 ? 'last' : 'first') : -1;
|
|
462
|
+
if (target < 0) {
|
|
455
463
|
return;
|
|
456
464
|
}
|
|
457
|
-
highlightedIndex =
|
|
465
|
+
highlightedIndex = target;
|
|
458
466
|
await tick();
|
|
459
467
|
// Query the dropdown node itself, not containerEl, so highlight-scrolling
|
|
460
468
|
// keeps working once the panel is portaled out to <body>.
|
|
@@ -476,17 +484,24 @@
|
|
|
476
484
|
return event.ctrlKey || event.metaKey || event.altKey || event.shiftKey;
|
|
477
485
|
}
|
|
478
486
|
|
|
487
|
+
// The nearest selectable row from an edge, or -1 when nothing in that
|
|
488
|
+
// direction is selectable (including an empty list). Shared by Home/End
|
|
489
|
+
// (moveHighlightToEdge) and arrow-key wraparound (moveHighlight), which both
|
|
490
|
+
// need to land on a real option rather than a disabled row sitting at the
|
|
491
|
+
// very edge of the list.
|
|
492
|
+
function nearestSelectableEdge(edge: 'first' | 'last'): number {
|
|
493
|
+
return edge === 'first'
|
|
494
|
+
? optionRows.findIndex(isRowSelectable)
|
|
495
|
+
: optionRows.findLastIndex(isRowSelectable);
|
|
496
|
+
}
|
|
497
|
+
|
|
479
498
|
/**
|
|
480
499
|
* Home/End: jump the highlight to the first/last selectable row, skipping
|
|
481
|
-
* disabled options the same way
|
|
482
|
-
* targets a different index and reuses the same scroll-into-view step. A
|
|
500
|
+
* disabled options the same way arrow-key wraparound does at the ends. A
|
|
483
501
|
* no-op when no row in that direction is selectable, including an empty list.
|
|
484
502
|
*/
|
|
485
503
|
async function moveHighlightToEdge(edge: 'first' | 'last'): Promise<void> {
|
|
486
|
-
const target =
|
|
487
|
-
edge === 'first'
|
|
488
|
-
? optionRows.findIndex(isRowSelectable)
|
|
489
|
-
: optionRows.findLastIndex(isRowSelectable);
|
|
504
|
+
const target = nearestSelectableEdge(edge);
|
|
490
505
|
if (target < 0) {
|
|
491
506
|
return;
|
|
492
507
|
}
|
|
@@ -114,7 +114,10 @@
|
|
|
114
114
|
.split-button-trigger {
|
|
115
115
|
--menu-container-position: static;
|
|
116
116
|
--menu-container-display: flex;
|
|
117
|
-
--menu-trigger-focus-outline
|
|
117
|
+
/* Used to force --menu-trigger-focus-outline to `none` with nothing
|
|
118
|
+
standing in for it (WCAG 2.4.7) -- removed so the trigger falls
|
|
119
|
+
through to Menu's own :focus-visible default instead of suppressing
|
|
120
|
+
it. See Menu.svelte's .menu-trigger:focus-visible for the fix. */
|
|
118
121
|
--menu-dropdown-left: 0;
|
|
119
122
|
--menu-min-width: 100%;
|
|
120
123
|
border-left: var(--split-button-trigger-separator, 1px solid rgba(255, 255, 255, 0.3));
|
|
@@ -367,7 +367,7 @@
|
|
|
367
367
|
align-items: center;
|
|
368
368
|
height: var(--field-group-input-height, 36px);
|
|
369
369
|
font-size: var(--field-group-separator-font-size, 16px);
|
|
370
|
-
color: var(--field-group-separator-color, #
|
|
370
|
+
color: var(--field-group-separator-color, #6b7280);
|
|
371
371
|
flex-shrink: 0;
|
|
372
372
|
}
|
|
373
373
|
</style>
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
import DeltaIndicator from '../DeltaIndicator/DeltaIndicator.svelte';
|
|
4
4
|
import Tooltip from '../Tooltip/Tooltip.svelte';
|
|
5
5
|
import CheckListItem from '../CheckListItem/CheckListItem.svelte';
|
|
6
|
+
import AnimatedNumber from '../AnimatedNumber/AnimatedNumber.svelte';
|
|
7
|
+
import { isAnimatableMetric } from './metric';
|
|
6
8
|
|
|
7
9
|
let {
|
|
8
10
|
title,
|
|
@@ -12,6 +14,10 @@
|
|
|
12
14
|
subtitle,
|
|
13
15
|
footer,
|
|
14
16
|
valueSnippet,
|
|
17
|
+
animateValue = false,
|
|
18
|
+
animatePrimary = false,
|
|
19
|
+
primary = 'auto',
|
|
20
|
+
animateOnMount = false,
|
|
15
21
|
rows,
|
|
16
22
|
rowsDirection = 'column',
|
|
17
23
|
tooltip,
|
|
@@ -26,6 +32,99 @@
|
|
|
26
32
|
|
|
27
33
|
const isInteractive = $derived(typeof onclick === 'function');
|
|
28
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Which ONE value on this card rolls.
|
|
37
|
+
*
|
|
38
|
+
* A card has several numeric slots and they are not equally important. The
|
|
39
|
+
* business variant renders `subtitle` at 44px as the headline and its rows at
|
|
40
|
+
* 18-20px underneath, so animating "the values" animated the small ones and
|
|
41
|
+
* left the number the card is actually about sitting still -- backwards, and
|
|
42
|
+
* visible on every dashboard.
|
|
43
|
+
*
|
|
44
|
+
* `primary` resolves three ways so a caller can be as specific as it needs:
|
|
45
|
+
* 'auto' measure the rendered slots and pick the largest metric
|
|
46
|
+
* 'subtitle' | 'value' | <row index> name it outright
|
|
47
|
+
* 'auto' is the default because it follows the card's own type scale rather
|
|
48
|
+
* than a second copy of it kept in a mapper, so a card that restyles keeps
|
|
49
|
+
* animating the right thing. A caller that disagrees names the slot instead.
|
|
50
|
+
*
|
|
51
|
+
* Measured after mount rather than guessed from props: the sizes come from
|
|
52
|
+
* tokens the consuming app can override, so only the resolved style knows.
|
|
53
|
+
*/
|
|
54
|
+
let autoKey = $state<string | null>(null);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Re-measured whenever the card's values change, not once at mount.
|
|
58
|
+
*
|
|
59
|
+
* A card whose figures arrive from a fetch renders empty first: the balance
|
|
60
|
+
* card mounted with no balance, so the measurement found no metric, and
|
|
61
|
+
* because an attachment only re-runs when its argument changes it never looked
|
|
62
|
+
* again -- the number arrived and sat there unanimated. Keying the attachment
|
|
63
|
+
* on the values themselves fixes that, and also re-picks the primary if the
|
|
64
|
+
* card's content later changes shape.
|
|
65
|
+
*/
|
|
66
|
+
const valueSignature = $derived(
|
|
67
|
+
JSON.stringify([
|
|
68
|
+
value,
|
|
69
|
+
subtitle,
|
|
70
|
+
(rows ?? []).map((row) => [
|
|
71
|
+
row.value,
|
|
72
|
+
row.comparisonValue,
|
|
73
|
+
(row.breakdown ?? []).map((item) => item.value)
|
|
74
|
+
])
|
|
75
|
+
])
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const measurePrimary =
|
|
79
|
+
(_signature: string) =>
|
|
80
|
+
(node: HTMLElement): void => {
|
|
81
|
+
if (!animatePrimary || primary !== 'auto') {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
let best: string | null = null;
|
|
85
|
+
let bestSize = -1;
|
|
86
|
+
for (const slot of node.querySelectorAll('[data-sc-slot]')) {
|
|
87
|
+
if (!(slot instanceof HTMLElement)) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (!isAnimatableMetric(slot.textContent ?? '')) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const size = Number.parseFloat(getComputedStyle(slot).fontSize);
|
|
94
|
+
if (Number.isFinite(size) && size > bestSize) {
|
|
95
|
+
bestSize = size;
|
|
96
|
+
best = slot.dataset.scSlot ?? null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
autoKey = best;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A numeric `primary` names a row by index, and rows carry `data-sc-slot`
|
|
104
|
+
* values of `row-<index>`. Passing the number through `String` alone yielded
|
|
105
|
+
* `'0'`, which matches no slot at all, so `primary={0}` resolved to nothing
|
|
106
|
+
* and the card animated none of its values. The custom element declares the
|
|
107
|
+
* prop as a String, so `primary="0"` arrives the same way and needs the same
|
|
108
|
+
* normalisation -- hence matching the digits rather than only `typeof`.
|
|
109
|
+
*/
|
|
110
|
+
const slotKeyFor = (value: 'auto' | 'subtitle' | 'value' | number | string): string =>
|
|
111
|
+
typeof value === 'number' || /^\d+$/.test(String(value)) ? `row-${value}` : String(value);
|
|
112
|
+
|
|
113
|
+
const primaryKey = $derived(primary === 'auto' ? autoKey : slotKeyFor(primary));
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* `animateValue` keeps its old meaning -- every value on the card -- but is
|
|
117
|
+
* now gated on the value being a metric at all, so an identifier that merely
|
|
118
|
+
* contains digits is never rolled. See metric.ts: this is what stopped a store
|
|
119
|
+
* domain and a GA4 account id spinning on the anomaly cards.
|
|
120
|
+
*/
|
|
121
|
+
const animates = (key: string, text: unknown): boolean => {
|
|
122
|
+
if (typeof text !== 'string' || !isAnimatableMetric(text)) {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
return animatePrimary ? key === primaryKey : animateValue;
|
|
126
|
+
};
|
|
127
|
+
|
|
29
128
|
/**
|
|
30
129
|
* When `deltaPositive` is explicitly provided use it; otherwise infer from
|
|
31
130
|
* the leading character of `delta` — '+' → positive, '-' → negative,
|
|
@@ -82,6 +181,7 @@
|
|
|
82
181
|
|
|
83
182
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
84
183
|
<div
|
|
184
|
+
{@attach measurePrimary(valueSignature)}
|
|
85
185
|
class="statcard {classes ?? ''}"
|
|
86
186
|
class:statcard-interactive={isInteractive}
|
|
87
187
|
data-pw={typeof testId === 'string' ? testId : null}
|
|
@@ -187,10 +287,15 @@
|
|
|
187
287
|
class="statcard-value"
|
|
188
288
|
class:statcard-value-success={row.valueVariant === 'success'}
|
|
189
289
|
class:statcard-value-warning={row.valueVariant === 'warning'}
|
|
290
|
+
data-sc-slot={`row-${rowIndex}`}
|
|
190
291
|
data-pw={typeof testId === 'string' ? `${testId}-value-${rowIndex}` : null}
|
|
191
292
|
testID={typeof testId === 'string' ? `${testId}-value-${rowIndex}` : null}
|
|
192
293
|
>
|
|
193
|
-
{row.value}
|
|
294
|
+
{#if animates(`row-${rowIndex}`, row.value)}
|
|
295
|
+
<AnimatedNumber value={row.value} {animateOnMount} />
|
|
296
|
+
{:else}
|
|
297
|
+
{row.value}
|
|
298
|
+
{/if}
|
|
194
299
|
</div>
|
|
195
300
|
{#if typeof row.comparisonValue === 'string' && row.comparisonValue.length > 0}
|
|
196
301
|
<div
|
|
@@ -198,7 +303,10 @@
|
|
|
198
303
|
data-pw={typeof testId === 'string' ? `${testId}-comparison-${rowIndex}` : null}
|
|
199
304
|
testID={typeof testId === 'string' ? `${testId}-comparison-${rowIndex}` : null}
|
|
200
305
|
>
|
|
201
|
-
/ {row.comparisonValue}
|
|
306
|
+
/ {#if animates(`row-${rowIndex}-comparison`, row.comparisonValue)}<AnimatedNumber
|
|
307
|
+
{animateOnMount}
|
|
308
|
+
value={row.comparisonValue}
|
|
309
|
+
/>{:else}{row.comparisonValue}{/if}
|
|
202
310
|
</div>
|
|
203
311
|
{/if}
|
|
204
312
|
{#if typeof row.change === 'number'}
|
|
@@ -255,7 +363,13 @@
|
|
|
255
363
|
>
|
|
256
364
|
<div class="statcard-breakdown-label">{breakdownItem.label}</div>
|
|
257
365
|
<div class="statcard-breakdown-value-line">
|
|
258
|
-
<div class="statcard-breakdown-value">
|
|
366
|
+
<div class="statcard-breakdown-value">
|
|
367
|
+
{#if animates(`breakdown-${rowIndex}-${breakdownIndex}`, breakdownItem.value)}
|
|
368
|
+
<AnimatedNumber value={breakdownItem.value} {animateOnMount} />
|
|
369
|
+
{:else}
|
|
370
|
+
{breakdownItem.value}
|
|
371
|
+
{/if}
|
|
372
|
+
</div>
|
|
259
373
|
{#if typeof breakdownItem.change === 'number'}
|
|
260
374
|
<DeltaIndicator
|
|
261
375
|
value={breakdownItem.change}
|
|
@@ -283,10 +397,15 @@
|
|
|
283
397
|
{:else if typeof value === 'string' && value.length > 0}
|
|
284
398
|
<div
|
|
285
399
|
class="statcard-value"
|
|
400
|
+
data-sc-slot="value"
|
|
286
401
|
data-pw={typeof testId === 'string' ? `${testId}-value` : null}
|
|
287
402
|
testID={typeof testId === 'string' ? `${testId}-value` : null}
|
|
288
403
|
>
|
|
289
|
-
{value}
|
|
404
|
+
{#if animates('value', value)}
|
|
405
|
+
<AnimatedNumber {value} {animateOnMount} />
|
|
406
|
+
{:else}
|
|
407
|
+
{value}
|
|
408
|
+
{/if}
|
|
290
409
|
</div>
|
|
291
410
|
{/if}
|
|
292
411
|
|
|
@@ -305,10 +424,15 @@
|
|
|
305
424
|
{#if typeof subtitle === 'string' && subtitle.length > 0}
|
|
306
425
|
<div
|
|
307
426
|
class="statcard-subtitle"
|
|
427
|
+
data-sc-slot="subtitle"
|
|
308
428
|
data-pw={typeof testId === 'string' ? `${testId}-subtitle` : null}
|
|
309
429
|
testID={typeof testId === 'string' ? `${testId}-subtitle` : null}
|
|
310
430
|
>
|
|
311
|
-
{subtitle}
|
|
431
|
+
{#if animates('subtitle', subtitle)}
|
|
432
|
+
<AnimatedNumber value={subtitle} {animateOnMount} />
|
|
433
|
+
{:else}
|
|
434
|
+
{subtitle}
|
|
435
|
+
{/if}
|
|
312
436
|
</div>
|
|
313
437
|
{/if}
|
|
314
438
|
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a displayed value is a METRIC worth rolling, or an identifier that
|
|
3
|
+
* merely contains digits.
|
|
4
|
+
*
|
|
5
|
+
* The distinction is not cosmetic. Adopting the odometer across a dashboard
|
|
6
|
+
* mapper animated `demo-store.myshopify.com` and `Shinchan Store GA4 (987654321)`
|
|
7
|
+
* on the anomaly surface: a store domain and a GA4 account id, rolled digit by
|
|
8
|
+
* digit as though they were measurements. Nothing was broken -- they went
|
|
9
|
+
* through the same `statcard-value` slot as `₹60k` -- which is exactly why a
|
|
10
|
+
* blanket "animate this card's values" is the wrong instruction.
|
|
11
|
+
*
|
|
12
|
+
* The rule is digits plus decoration. A metric is allowed to carry a currency
|
|
13
|
+
* symbol, a sign, a percent, group and decimal separators, and SHORT unit words
|
|
14
|
+
* -- `₹60k`, `90%`, `₹1.23Cr`, `12m 15s`, `-8%`. Anything else with a long
|
|
15
|
+
* alphabetic run, a dotted hostname or a parenthesised id is an identifier and
|
|
16
|
+
* is left alone.
|
|
17
|
+
*
|
|
18
|
+
* Deliberately conservative in one direction only: a metric wrongly refused
|
|
19
|
+
* renders exactly as it does today, while an identifier wrongly accepted is a
|
|
20
|
+
* number spinning in a domain name.
|
|
21
|
+
*/
|
|
22
|
+
export declare const isAnimatableMetric: (text: string) => boolean;
|