@makolabs/ripple 3.0.3 → 3.0.4
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/ai/ai-types.d.ts +7 -0
- package/dist/charts/Chart.svelte +10 -1
- package/dist/charts/chart-types.d.ts +7 -0
- package/dist/elements/progress/Progress.svelte +18 -5
- package/dist/elements/progress/progress-types.d.ts +8 -0
- package/dist/file-browser/FileBrowser.svelte +6 -3
- package/dist/filters/CompactFilters.svelte +78 -68
- package/dist/filters/filter-types.d.ts +11 -0
- package/dist/forms/DateRange.svelte +12 -3
- package/dist/forms/NumberInput.svelte +6 -2
- package/dist/forms/Tags.svelte +9 -4
- package/dist/forms/Toggle.svelte +8 -6
- package/dist/forms/calendar/Calendar.svelte +8 -1
- package/dist/forms/calendar/calendar-types.d.ts +9 -0
- package/dist/forms/form-types.d.ts +23 -0
- package/dist/forms/month-picker/MonthPicker.svelte +3 -1
- package/dist/forms/month-picker/month-picker-types.d.ts +7 -0
- package/dist/header/Breadcrumbs.svelte +19 -5
- package/dist/header/header-types.d.ts +7 -0
- package/dist/layout/activity-list/ActivityList.svelte +29 -7
- package/dist/layout/activity-list/activity-list-types.d.ts +8 -0
- package/dist/layout/card/MetricCard.svelte +34 -8
- package/dist/layout/card/RankedCard.svelte +34 -10
- package/dist/layout/card/card-types.d.ts +8 -0
- package/dist/layout/card/ranked-card.d.ts +10 -0
- package/dist/layout/table/Table.svelte +5 -0
- package/dist/layout/table/table-types.d.ts +8 -0
- package/dist/pipeline/Pipeline.svelte +7 -3
- package/dist/pipeline/pipeline-types.d.ts +6 -0
- package/package.json +1 -1
package/dist/ai/ai-types.d.ts
CHANGED
|
@@ -107,6 +107,13 @@ export interface FileBrowserProps {
|
|
|
107
107
|
selectAllScope?: 'page' | 'all';
|
|
108
108
|
/** Tailwind height class for the browser container. @default 'h-[500px]' */
|
|
109
109
|
height?: string;
|
|
110
|
+
/**
|
|
111
|
+
* Allow clicking into subdirectories. When `false`, folder rows are
|
|
112
|
+
* still shown but clicking them does nothing — useful for flat file
|
|
113
|
+
* lists or when the adapter doesn't support folder traversal.
|
|
114
|
+
* @default true
|
|
115
|
+
*/
|
|
116
|
+
allowFolderNavigation?: boolean;
|
|
110
117
|
/** Additional CSS classes for the outer container. */
|
|
111
118
|
class?: ClassValue;
|
|
112
119
|
/**
|
package/dist/charts/Chart.svelte
CHANGED
|
@@ -42,6 +42,8 @@
|
|
|
42
42
|
GraphicComponent
|
|
43
43
|
]);
|
|
44
44
|
|
|
45
|
+
import { buildTestId } from '../helper/testid.js';
|
|
46
|
+
|
|
45
47
|
let {
|
|
46
48
|
data = [],
|
|
47
49
|
config,
|
|
@@ -50,7 +52,8 @@
|
|
|
50
52
|
class: className = '',
|
|
51
53
|
colors = {},
|
|
52
54
|
onpointclick,
|
|
53
|
-
onchartrender
|
|
55
|
+
onchartrender,
|
|
56
|
+
testId
|
|
54
57
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
55
58
|
}: ChartProps<any> = $props();
|
|
56
59
|
|
|
@@ -753,4 +756,10 @@
|
|
|
753
756
|
class={cn('advanced-chart', className)}
|
|
754
757
|
style="height: {height}; width: {width}"
|
|
755
758
|
bind:this={chartContainer}
|
|
759
|
+
data-testid={buildTestId('chart', undefined, testId)}
|
|
756
760
|
></div>
|
|
761
|
+
{#if testId}
|
|
762
|
+
<div data-testid={buildTestId('chart', 'data', testId)} style="display:none" aria-hidden="true">
|
|
763
|
+
{JSON.stringify({ data, series: config.series, xAxis: config.xAxis, yAxis: config.yAxis })}
|
|
764
|
+
</div>
|
|
765
|
+
{/if}
|
|
@@ -222,5 +222,12 @@ export interface ChartProps<T> {
|
|
|
222
222
|
* access (custom plugins, imperative animation triggers, etc.).
|
|
223
223
|
*/
|
|
224
224
|
onchartrender?: (event: ChartRenderType<T>) => void;
|
|
225
|
+
/**
|
|
226
|
+
* Test ID prefix. When set, the component emits these selectors:
|
|
227
|
+
* - `{testId}-chart` — root wrapper (around the canvas)
|
|
228
|
+
* - `{testId}-chart-data` — hidden element containing the chart's
|
|
229
|
+
* source data as JSON, so test automation can read values without
|
|
230
|
+
* parsing the canvas. The JSON shape is `{ data, series, xAxis, yAxis }`.
|
|
231
|
+
*/
|
|
225
232
|
testId?: string;
|
|
226
233
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { cn } from '../../helper/cls.js';
|
|
3
|
+
import { buildTestId } from '../../helper/testid.js';
|
|
3
4
|
import { Color, Size } from '../../variants.js';
|
|
4
5
|
import { Tween } from 'svelte/motion';
|
|
5
6
|
import { quintOut } from 'svelte/easing';
|
|
@@ -17,7 +18,8 @@
|
|
|
17
18
|
showLabels = false,
|
|
18
19
|
class: className = '',
|
|
19
20
|
labelClass = '',
|
|
20
|
-
barClass = ''
|
|
21
|
+
barClass = '',
|
|
22
|
+
testId
|
|
21
23
|
}: ProgressProps = $props();
|
|
22
24
|
|
|
23
25
|
// Function composition for better readability and maintainability
|
|
@@ -120,13 +122,14 @@
|
|
|
120
122
|
);
|
|
121
123
|
</script>
|
|
122
124
|
|
|
123
|
-
<div class={containerClass}>
|
|
125
|
+
<div class={containerClass} data-testid={buildTestId('progress', undefined, testId)}>
|
|
124
126
|
<div
|
|
125
127
|
class={progressClass}
|
|
126
128
|
role="progressbar"
|
|
127
129
|
aria-valuenow={percentage}
|
|
128
130
|
aria-valuemin="0"
|
|
129
131
|
aria-valuemax="100"
|
|
132
|
+
data-testid={buildTestId('progress', 'track', testId)}
|
|
130
133
|
>
|
|
131
134
|
{#if segments}
|
|
132
135
|
{#each segmentPercentages as segment, index (segment.label ?? `segment-${index}`)}
|
|
@@ -135,11 +138,16 @@
|
|
|
135
138
|
class={cn(getColorClass(segment.color), barClass)}
|
|
136
139
|
style="width: {segmentTweens[index]?.current ?? 0}%"
|
|
137
140
|
title={segment.label || `${segment.value} (${segment.percentage}%)`}
|
|
141
|
+
data-testid={buildTestId('progress', 'segment', testId, index)}
|
|
138
142
|
></div>
|
|
139
143
|
{/if}
|
|
140
144
|
{/each}
|
|
141
145
|
{:else}
|
|
142
|
-
<div
|
|
146
|
+
<div
|
|
147
|
+
class={fillClass}
|
|
148
|
+
style="width: {tween.current}%"
|
|
149
|
+
data-testid={buildTestId('progress', 'fill', testId)}
|
|
150
|
+
></div>
|
|
143
151
|
{/if}
|
|
144
152
|
</div>
|
|
145
153
|
|
|
@@ -147,7 +155,10 @@
|
|
|
147
155
|
<div class="mt-1 flex justify-between">
|
|
148
156
|
{#each segmentPercentages as segment, index (segment.label ?? `segment-${index}`)}
|
|
149
157
|
{#if segment.percentage > 0}
|
|
150
|
-
<div
|
|
158
|
+
<div
|
|
159
|
+
class={labelTextClass}
|
|
160
|
+
data-testid={buildTestId('progress', 'segment-label', testId, index)}
|
|
161
|
+
>
|
|
151
162
|
{#if showLabels && segment.label}
|
|
152
163
|
{segment.label}
|
|
153
164
|
{/if}
|
|
@@ -162,6 +173,8 @@
|
|
|
162
173
|
{/each}
|
|
163
174
|
</div>
|
|
164
175
|
{:else if showLabel}
|
|
165
|
-
<span class={labelTextClass}
|
|
176
|
+
<span class={labelTextClass} data-testid={buildTestId('progress', 'value', testId)}
|
|
177
|
+
>{percentage}%</span
|
|
178
|
+
>
|
|
166
179
|
{/if}
|
|
167
180
|
</div>
|
|
@@ -65,5 +65,13 @@ export type ProgressProps = {
|
|
|
65
65
|
labelClass?: ClassValue;
|
|
66
66
|
/** Classes on the inner bar element(s). */
|
|
67
67
|
barClass?: ClassValue;
|
|
68
|
+
/**
|
|
69
|
+
* Test ID prefix. When set, the component emits these selectors:
|
|
70
|
+
* - `{testId}-progress` — root wrapper
|
|
71
|
+
* - `{testId}-progress-track` — track bar
|
|
72
|
+
* - `{testId}-progress-fill` — fill bar (single mode)
|
|
73
|
+
* - `{testId}-progress-segment-{i}` — each segment
|
|
74
|
+
* - `{testId}-progress-value` — percentage display
|
|
75
|
+
*/
|
|
68
76
|
testId?: string;
|
|
69
77
|
};
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
actions = [],
|
|
19
19
|
infoSection,
|
|
20
20
|
selectAllScope = 'page',
|
|
21
|
+
allowFolderNavigation = true,
|
|
21
22
|
height = 'h-[500px]',
|
|
22
23
|
class: className = '',
|
|
23
24
|
selectedItems = $bindable<FileItem[]>([]),
|
|
@@ -197,6 +198,7 @@
|
|
|
197
198
|
}
|
|
198
199
|
|
|
199
200
|
function navigateToFolder(folderPath: string) {
|
|
201
|
+
if (!allowFolderNavigation) return;
|
|
200
202
|
// If we have the file info for this folder, cache its name in the adapter
|
|
201
203
|
// before navigating (useful for Google Drive adapter)
|
|
202
204
|
const folderFile = files.find((file) => file.key === folderPath);
|
|
@@ -290,9 +292,10 @@
|
|
|
290
292
|
|
|
291
293
|
// Sort files based on current sort state
|
|
292
294
|
function getSortedFiles(filesArray: FileItem[]): FileItem[] {
|
|
293
|
-
//
|
|
294
|
-
const
|
|
295
|
-
const
|
|
295
|
+
// When folder navigation is off, hide folders entirely.
|
|
296
|
+
const filtered = allowFolderNavigation ? filesArray : filesArray.filter((f) => !f.isFolder);
|
|
297
|
+
const folders = filtered.filter((file) => file.isFolder);
|
|
298
|
+
const files = filtered.filter((file) => !file.isFolder);
|
|
296
299
|
|
|
297
300
|
// Handle default sort (folders alphabetically, files by date newest first)
|
|
298
301
|
if (sortState.column === 'default' && sortState.direction === 'default') {
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
chipSummary = false,
|
|
20
20
|
isExpanded = $bindable(false),
|
|
21
21
|
title = 'Filters',
|
|
22
|
+
card = true,
|
|
23
|
+
expandable = true,
|
|
22
24
|
class: className,
|
|
23
25
|
summaryClass,
|
|
24
26
|
expandedClass,
|
|
@@ -28,7 +30,11 @@
|
|
|
28
30
|
// Search input only renders when the consumer bound to searchQuery.
|
|
29
31
|
const searchEnabled = $derived(searchQuery !== undefined);
|
|
30
32
|
|
|
33
|
+
// When not expandable, filters are always visible.
|
|
34
|
+
if (!expandable) isExpanded = true;
|
|
35
|
+
|
|
31
36
|
function toggleExpanded() {
|
|
37
|
+
if (!expandable) return;
|
|
32
38
|
isExpanded = !isExpanded;
|
|
33
39
|
}
|
|
34
40
|
|
|
@@ -181,80 +187,84 @@
|
|
|
181
187
|
</svg>
|
|
182
188
|
{/snippet}
|
|
183
189
|
|
|
184
|
-
<div class={cn('border-default-200 rounded-lg border bg-white p-3 shadow-sm', className)}>
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
<div class="mb-2 flex flex-wrap items-center gap-2">
|
|
189
|
-
<button
|
|
190
|
-
type="button"
|
|
191
|
-
onclick={toggleExpanded}
|
|
192
|
-
class="flex flex-1 cursor-pointer items-center gap-2"
|
|
193
|
-
>
|
|
194
|
-
{#if FilterIcon}
|
|
195
|
-
<FilterIcon size={16} class="text-default-500" />
|
|
196
|
-
{:else}
|
|
197
|
-
<span class="text-default-500">{@render DefaultFilterIcon()}</span>
|
|
198
|
-
{/if}
|
|
199
|
-
<span class="text-sm font-medium">{title}</span>
|
|
200
|
-
</button>
|
|
201
|
-
|
|
202
|
-
{#if searchEnabled}
|
|
203
|
-
<!-- `w-full sm:w-48` lets the search field fill the row on
|
|
204
|
-
narrow viewports (after wrapping) but stay compact on wide. -->
|
|
205
|
-
<div class="relative order-last w-full sm:order-none sm:w-auto">
|
|
206
|
-
<input
|
|
207
|
-
type="text"
|
|
208
|
-
class="border-default-200 focus:border-primary-400 h-7 w-full rounded-md border px-2 pr-6 text-xs focus:outline-none sm:w-48"
|
|
209
|
-
placeholder={searchPlaceholder}
|
|
210
|
-
bind:value={searchQuery}
|
|
211
|
-
data-filters-search=""
|
|
212
|
-
/>
|
|
213
|
-
{#if searchQuery}
|
|
214
|
-
<button
|
|
215
|
-
type="button"
|
|
216
|
-
onclick={() => (searchQuery = '')}
|
|
217
|
-
class="text-default-400 hover:text-default-700 absolute top-1/2 right-1 flex size-5 -translate-y-1/2 cursor-pointer items-center justify-center rounded"
|
|
218
|
-
aria-label="Clear search"
|
|
219
|
-
data-filters-search-clear=""
|
|
220
|
-
>
|
|
221
|
-
<svg class="size-3" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
|
222
|
-
<path
|
|
223
|
-
d="M3 3l6 6M9 3l-6 6"
|
|
224
|
-
stroke="currentColor"
|
|
225
|
-
stroke-width="1.5"
|
|
226
|
-
stroke-linecap="round"
|
|
227
|
-
/>
|
|
228
|
-
</svg>
|
|
229
|
-
</button>
|
|
230
|
-
{/if}
|
|
231
|
-
</div>
|
|
232
|
-
{/if}
|
|
233
|
-
|
|
234
|
-
{#if showClearAll && isDirty}
|
|
190
|
+
<div class={cn(card && 'border-default-200 rounded-lg border bg-white p-3 shadow-sm', className)}>
|
|
191
|
+
{#if card || expandable}
|
|
192
|
+
<!-- Header row: title | search | clear | chevron -->
|
|
193
|
+
<div class="mb-2 flex flex-wrap items-center gap-2">
|
|
235
194
|
<button
|
|
236
195
|
type="button"
|
|
237
|
-
onclick={
|
|
238
|
-
class="
|
|
239
|
-
data-filters-clear-all=""
|
|
196
|
+
onclick={toggleExpanded}
|
|
197
|
+
class="flex flex-1 cursor-pointer items-center gap-2"
|
|
240
198
|
>
|
|
241
|
-
{
|
|
199
|
+
{#if FilterIcon}
|
|
200
|
+
<FilterIcon size={16} class="text-default-500" />
|
|
201
|
+
{:else}
|
|
202
|
+
<span class="text-default-500">{@render DefaultFilterIcon()}</span>
|
|
203
|
+
{/if}
|
|
204
|
+
<span class="text-sm font-medium">{title}</span>
|
|
242
205
|
</button>
|
|
243
|
-
{/if}
|
|
244
206
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
207
|
+
{#if searchEnabled}
|
|
208
|
+
<!-- `w-full sm:w-48` lets the search field fill the row on
|
|
209
|
+
narrow viewports (after wrapping) but stay compact on wide. -->
|
|
210
|
+
<div class="relative order-last w-full sm:order-none sm:w-auto">
|
|
211
|
+
<input
|
|
212
|
+
type="text"
|
|
213
|
+
class="border-default-200 focus:border-primary-400 h-7 w-full rounded-md border px-2 pr-6 text-xs focus:outline-none sm:w-48"
|
|
214
|
+
placeholder={searchPlaceholder}
|
|
215
|
+
bind:value={searchQuery}
|
|
216
|
+
data-filters-search=""
|
|
217
|
+
/>
|
|
218
|
+
{#if searchQuery}
|
|
219
|
+
<button
|
|
220
|
+
type="button"
|
|
221
|
+
onclick={() => (searchQuery = '')}
|
|
222
|
+
class="text-default-400 hover:text-default-700 absolute top-1/2 right-1 flex size-5 -translate-y-1/2 cursor-pointer items-center justify-center rounded"
|
|
223
|
+
aria-label="Clear search"
|
|
224
|
+
data-filters-search-clear=""
|
|
225
|
+
>
|
|
226
|
+
<svg class="size-3" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
|
227
|
+
<path
|
|
228
|
+
d="M3 3l6 6M9 3l-6 6"
|
|
229
|
+
stroke="currentColor"
|
|
230
|
+
stroke-width="1.5"
|
|
231
|
+
stroke-linecap="round"
|
|
232
|
+
/>
|
|
233
|
+
</svg>
|
|
234
|
+
</button>
|
|
235
|
+
{/if}
|
|
236
|
+
</div>
|
|
237
|
+
{/if}
|
|
238
|
+
|
|
239
|
+
{#if showClearAll && isDirty}
|
|
240
|
+
<button
|
|
241
|
+
type="button"
|
|
242
|
+
onclick={clearAll}
|
|
243
|
+
class="text-default-600 hover:bg-default-100 cursor-pointer rounded-md px-2 py-1 text-xs font-medium"
|
|
244
|
+
data-filters-clear-all=""
|
|
245
|
+
>
|
|
246
|
+
{clearAllLabel}
|
|
247
|
+
</button>
|
|
255
248
|
{/if}
|
|
256
|
-
|
|
257
|
-
|
|
249
|
+
|
|
250
|
+
{#if expandable}
|
|
251
|
+
<button
|
|
252
|
+
type="button"
|
|
253
|
+
onclick={toggleExpanded}
|
|
254
|
+
class="text-default-500 hover:bg-default-100 hover:text-default-700 cursor-pointer rounded-md p-1"
|
|
255
|
+
aria-label={isExpanded
|
|
256
|
+
? `Collapse ${title.toLowerCase()}`
|
|
257
|
+
: `Expand ${title.toLowerCase()}`}
|
|
258
|
+
>
|
|
259
|
+
{#if isExpanded}
|
|
260
|
+
{@render DefaultChevronUp()}
|
|
261
|
+
{:else}
|
|
262
|
+
{@render DefaultChevronDown()}
|
|
263
|
+
{/if}
|
|
264
|
+
</button>
|
|
265
|
+
{/if}
|
|
266
|
+
</div>
|
|
267
|
+
{/if}
|
|
258
268
|
|
|
259
269
|
{#if !isExpanded}
|
|
260
270
|
<div class={cn('flex flex-wrap gap-2', summaryClass)}>
|
|
@@ -82,6 +82,17 @@ export type CompactFiltersProps = {
|
|
|
82
82
|
chipSummary?: boolean;
|
|
83
83
|
isExpanded?: boolean;
|
|
84
84
|
title?: string;
|
|
85
|
+
/**
|
|
86
|
+
* Render with a bordered card wrapper (border, shadow, padding,
|
|
87
|
+
* white background). Set to `false` to embed inside your own card
|
|
88
|
+
* or layout without double-bordering. @default true
|
|
89
|
+
*/
|
|
90
|
+
card?: boolean;
|
|
91
|
+
/**
|
|
92
|
+
* Show the expand/collapse chevron toggle. When `false`, filters
|
|
93
|
+
* are always expanded (no collapsed chip summary). @default true
|
|
94
|
+
*/
|
|
95
|
+
expandable?: boolean;
|
|
85
96
|
class?: ClassValue;
|
|
86
97
|
summaryClass?: ClassValue;
|
|
87
98
|
expandedClass?: ClassValue;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { cn } from '../helper/cls.js';
|
|
3
|
+
import { buildTestId } from '../helper/testid.js';
|
|
3
4
|
import { Size } from '../variants.js';
|
|
4
5
|
import { formSizeTokens } from './form-size.js';
|
|
5
6
|
import type { DateRangeProps } from '../index.js';
|
|
@@ -23,7 +24,8 @@
|
|
|
23
24
|
size = Size.MD,
|
|
24
25
|
id,
|
|
25
26
|
name,
|
|
26
|
-
onselect
|
|
27
|
+
onselect,
|
|
28
|
+
testId
|
|
27
29
|
}: DateRangeProps = $props();
|
|
28
30
|
|
|
29
31
|
const tokens = $derived(formSizeTokens[size]);
|
|
@@ -249,7 +251,11 @@
|
|
|
249
251
|
<input type="hidden" name={`${name}[format]`} value={format} />
|
|
250
252
|
<input type="hidden" name={`${name}`} value={getValue()} />
|
|
251
253
|
|
|
252
|
-
<div
|
|
254
|
+
<div
|
|
255
|
+
class={cn('relative block w-full', className)}
|
|
256
|
+
bind:this={datePickerRef}
|
|
257
|
+
data-testid={buildTestId('date-range', undefined, testId)}
|
|
258
|
+
>
|
|
253
259
|
<div class="relative">
|
|
254
260
|
<button
|
|
255
261
|
{id}
|
|
@@ -268,6 +274,7 @@
|
|
|
268
274
|
: 'focus-visible:border-primary-500 focus-visible:ring-primary-500 hover:border-default-400 focus-visible:ring-2'
|
|
269
275
|
)}
|
|
270
276
|
onclick={toggleDatepicker}
|
|
277
|
+
data-testid={buildTestId('date-range', 'trigger', testId)}
|
|
271
278
|
aria-haspopup="true"
|
|
272
279
|
aria-expanded={isOpen}
|
|
273
280
|
aria-describedby={errors?.length ? `${id}-errors` : undefined}
|
|
@@ -299,6 +306,7 @@
|
|
|
299
306
|
'right-8'
|
|
300
307
|
)}
|
|
301
308
|
onclick={clearDates}
|
|
309
|
+
data-testid={buildTestId('date-range', 'clear', testId)}
|
|
302
310
|
aria-label="Clear dates"
|
|
303
311
|
>
|
|
304
312
|
<svg class={cn(tokens.iconSize)} viewBox="0 0 20 20" fill="currentColor">
|
|
@@ -313,7 +321,7 @@
|
|
|
313
321
|
</div>
|
|
314
322
|
|
|
315
323
|
{#if errors?.length}
|
|
316
|
-
<div id="{id}-errors">
|
|
324
|
+
<div id="{id}-errors" data-testid={buildTestId('date-range', 'errors', testId)}>
|
|
317
325
|
{#each errors as error, i (i)}
|
|
318
326
|
<p class="text-danger-600 mt-1 text-sm">{error}</p>
|
|
319
327
|
{/each}
|
|
@@ -325,6 +333,7 @@
|
|
|
325
333
|
<div
|
|
326
334
|
bind:this={calendarRef}
|
|
327
335
|
class="ring-opacity-5 ring-default-300 absolute z-10 mt-1 w-full origin-top-left rounded-md bg-white p-4 shadow-lg ring-1 focus:outline-none"
|
|
336
|
+
data-testid={buildTestId('date-range', 'panel', testId)}
|
|
328
337
|
transition:fly={{ y: -8, duration: 300, easing: quintOut }}
|
|
329
338
|
>
|
|
330
339
|
{@render calendarContent()}
|
|
@@ -154,6 +154,7 @@
|
|
|
154
154
|
<button
|
|
155
155
|
type="button"
|
|
156
156
|
class="hover:bg-default-100 flex items-center gap-1 rounded px-1"
|
|
157
|
+
data-testid={buildTestId('numberinput', 'unit', testId)}
|
|
157
158
|
onclick={handleUnitToggle}
|
|
158
159
|
{disabled}
|
|
159
160
|
>
|
|
@@ -173,7 +174,10 @@
|
|
|
173
174
|
{:else if unit}
|
|
174
175
|
<!-- Static unit label — no chevron, not clickable. Matches the
|
|
175
176
|
field's text size so it sits inline. -->
|
|
176
|
-
<span
|
|
177
|
+
<span
|
|
178
|
+
class={cn('text-default-500 flex items-center gap-1 pr-2', tokens.text)}
|
|
179
|
+
data-testid={buildTestId('numberinput', 'unit', testId)}
|
|
180
|
+
>
|
|
177
181
|
{#if selectedOption?.icon}
|
|
178
182
|
{@const Icon = selectedOption.icon}
|
|
179
183
|
<Icon />
|
|
@@ -183,7 +187,7 @@
|
|
|
183
187
|
{/if}
|
|
184
188
|
|
|
185
189
|
{#if showUnitDropdown && hasMultipleUnits}
|
|
186
|
-
<div class={dropdownClass}>
|
|
190
|
+
<div class={dropdownClass} data-testid={buildTestId('numberinput', 'unit-dropdown', testId)}>
|
|
187
191
|
{#each units as unitOption (unitOption.value)}
|
|
188
192
|
<button
|
|
189
193
|
type="button"
|
package/dist/forms/Tags.svelte
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import Badge from '../elements/badge/Badge.svelte';
|
|
3
3
|
import { Size } from '../variants.js';
|
|
4
4
|
import { cn } from '../helper/cls.js';
|
|
5
|
+
import { buildTestId } from '../helper/testid.js';
|
|
5
6
|
import { formSizeTokens } from './form-size.js';
|
|
6
7
|
import { fade } from 'svelte/transition';
|
|
7
8
|
import { flip } from 'svelte/animate';
|
|
@@ -18,7 +19,8 @@
|
|
|
18
19
|
class: className = '',
|
|
19
20
|
suggestions = [],
|
|
20
21
|
onaddtag: onAddTag,
|
|
21
|
-
onremovetag: onRemoveTag
|
|
22
|
+
onremovetag: onRemoveTag,
|
|
23
|
+
testId
|
|
22
24
|
}: TagsProps = $props();
|
|
23
25
|
|
|
24
26
|
let inputValue = $state('');
|
|
@@ -154,16 +156,17 @@
|
|
|
154
156
|
);
|
|
155
157
|
</script>
|
|
156
158
|
|
|
157
|
-
<div class="space-y-1">
|
|
159
|
+
<div class="space-y-1" data-testid={buildTestId('tags', undefined, testId)}>
|
|
158
160
|
{#if label}
|
|
159
161
|
<label for={name} class="text-default-700 block text-sm font-medium">{label}</label>
|
|
160
162
|
{/if}
|
|
161
163
|
<div class={containerClass} onfocusout={handleFocusOut}>
|
|
162
|
-
{#each value as tag (tag)}
|
|
164
|
+
{#each value as tag, index (tag)}
|
|
163
165
|
<div
|
|
164
166
|
class="inline-flex"
|
|
165
167
|
transition:fade={{ duration: 250, easing: quintOut }}
|
|
166
168
|
animate:flip={{ duration: 300, easing: quintOut }}
|
|
169
|
+
data-testid={buildTestId('tags', 'tag', testId, index)}
|
|
167
170
|
>
|
|
168
171
|
<Badge size={chipSize} color="info" onclose={() => handleTagRemoval(tag)} class="shadow-xs">
|
|
169
172
|
{tag}
|
|
@@ -184,10 +187,11 @@
|
|
|
184
187
|
autocomplete="off"
|
|
185
188
|
onkeydown={handleKeydown}
|
|
186
189
|
onfocus={handleFocusIn}
|
|
190
|
+
data-testid={buildTestId('tags', 'input', testId)}
|
|
187
191
|
/>
|
|
188
192
|
|
|
189
193
|
{#if showSuggestions && filteredSuggestions.length > 0}
|
|
190
|
-
<div class={suggestionClass}>
|
|
194
|
+
<div class={suggestionClass} data-testid={buildTestId('tags', 'suggestions', testId)}>
|
|
191
195
|
{#each filteredSuggestions as suggestion, i (suggestion)}
|
|
192
196
|
<button
|
|
193
197
|
type="button"
|
|
@@ -196,6 +200,7 @@
|
|
|
196
200
|
onmouseover={() => handleSuggestionHover(i)}
|
|
197
201
|
onfocus={() => handleSuggestionHover(i)}
|
|
198
202
|
tabindex="-1"
|
|
203
|
+
data-testid={buildTestId('tags', 'suggestion', testId, i)}
|
|
199
204
|
>
|
|
200
205
|
{suggestion}
|
|
201
206
|
</button>
|
package/dist/forms/Toggle.svelte
CHANGED
|
@@ -68,15 +68,17 @@
|
|
|
68
68
|
)[size]
|
|
69
69
|
);
|
|
70
70
|
|
|
71
|
+
// On-position = track_width − thumb_width − 2px margin.
|
|
72
|
+
// Off-position = 2px margin (translate-x-0.5).
|
|
71
73
|
const thumbPosition = $derived(
|
|
72
74
|
(
|
|
73
75
|
{
|
|
74
|
-
[Size.XS]: value ? 'translate-x-
|
|
75
|
-
[Size.SM]: value ? 'translate-x-
|
|
76
|
-
[Size.MD]: value ? 'translate-x-
|
|
77
|
-
[Size.LG]: value ? 'translate-x-
|
|
78
|
-
[Size.XL]: value ? 'translate-x-
|
|
79
|
-
[Size.XXL]: value ? 'translate-x-
|
|
76
|
+
[Size.XS]: value ? 'translate-x-4' : 'translate-x-0.5',
|
|
77
|
+
[Size.SM]: value ? 'translate-x-[18px]' : 'translate-x-0.5',
|
|
78
|
+
[Size.MD]: value ? 'translate-x-[22px]' : 'translate-x-0.5',
|
|
79
|
+
[Size.LG]: value ? 'translate-x-[26px]' : 'translate-x-0.5',
|
|
80
|
+
[Size.XL]: value ? 'translate-x-[30px]' : 'translate-x-0.5',
|
|
81
|
+
[Size.XXL]: value ? 'translate-x-[30px]' : 'translate-x-0.5'
|
|
80
82
|
} satisfies Record<VariantSizes, string>
|
|
81
83
|
)[size]
|
|
82
84
|
);
|
|
@@ -246,6 +246,7 @@
|
|
|
246
246
|
density.navBtn
|
|
247
247
|
)}
|
|
248
248
|
aria-label="Previous month"
|
|
249
|
+
data-testid={buildTestId('calendar', 'prev-month', testId)}
|
|
249
250
|
{disabled}
|
|
250
251
|
>
|
|
251
252
|
<svg class={density.navIcon} viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
|
@@ -256,7 +257,10 @@
|
|
|
256
257
|
/>
|
|
257
258
|
</svg>
|
|
258
259
|
</button>
|
|
259
|
-
<span
|
|
260
|
+
<span
|
|
261
|
+
class={cn('text-default-800 font-semibold', density.monthText)}
|
|
262
|
+
data-testid={buildTestId('calendar', 'month-label', testId)}>{monthLabel}</span
|
|
263
|
+
>
|
|
260
264
|
<button
|
|
261
265
|
type="button"
|
|
262
266
|
onclick={nextMonth}
|
|
@@ -265,6 +269,7 @@
|
|
|
265
269
|
density.navBtn
|
|
266
270
|
)}
|
|
267
271
|
aria-label="Next month"
|
|
272
|
+
data-testid={buildTestId('calendar', 'next-month', testId)}
|
|
268
273
|
{disabled}
|
|
269
274
|
>
|
|
270
275
|
<svg class={density.navIcon} viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
|
@@ -283,6 +288,7 @@
|
|
|
283
288
|
'text-default-400 mb-1 grid grid-cols-7 gap-0.5 text-center font-medium',
|
|
284
289
|
density.dayHeaderText
|
|
285
290
|
)}
|
|
291
|
+
data-testid={buildTestId('calendar', 'day-headers', testId)}
|
|
286
292
|
>
|
|
287
293
|
{#each dayHeaders() as d (d)}
|
|
288
294
|
<div>{d}</div>
|
|
@@ -297,6 +303,7 @@
|
|
|
297
303
|
disabled={cell.disabled}
|
|
298
304
|
aria-pressed={cell.isSelected}
|
|
299
305
|
aria-label={cell.date.toLocaleDateString()}
|
|
306
|
+
data-testid={buildTestId('calendar', 'day', testId, cell.date.getDate())}
|
|
300
307
|
class={cn(
|
|
301
308
|
'relative flex items-center justify-center rounded transition-colors',
|
|
302
309
|
density.cell,
|
|
@@ -50,5 +50,14 @@ export type CalendarProps = {
|
|
|
50
50
|
from: Date | null;
|
|
51
51
|
to: Date | null;
|
|
52
52
|
}) => void;
|
|
53
|
+
/**
|
|
54
|
+
* Test ID prefix. When set, the component emits these selectors:
|
|
55
|
+
* - `{testId}-calendar` — root wrapper
|
|
56
|
+
* - `{testId}-calendar-prev-month` — prev button
|
|
57
|
+
* - `{testId}-calendar-next-month` — next button
|
|
58
|
+
* - `{testId}-calendar-month-label` — month/year header
|
|
59
|
+
* - `{testId}-calendar-day-headers` — weekday row
|
|
60
|
+
* - `{testId}-calendar-day-{dayNumber}` — each day cell
|
|
61
|
+
*/
|
|
53
62
|
testId?: string;
|
|
54
63
|
};
|
|
@@ -280,6 +280,13 @@ export type NumberInputProps = {
|
|
|
280
280
|
* BCP 47 locale used for thousands-separator formatting. @default 'en-US'
|
|
281
281
|
*/
|
|
282
282
|
locale?: string;
|
|
283
|
+
/**
|
|
284
|
+
* Test ID prefix. When set, the component emits these selectors:
|
|
285
|
+
* - `{testId}-numberinput` — root input
|
|
286
|
+
* - `{testId}-numberinput-label` — label
|
|
287
|
+
* - `{testId}-numberinput-unit` — unit label/button
|
|
288
|
+
* - `{testId}-numberinput-unit-dropdown` — unit dropdown
|
|
289
|
+
*/
|
|
283
290
|
testId?: string;
|
|
284
291
|
};
|
|
285
292
|
/**
|
|
@@ -334,6 +341,14 @@ export interface DateRangeProps {
|
|
|
334
341
|
}) => void;
|
|
335
342
|
id?: string;
|
|
336
343
|
name?: string;
|
|
344
|
+
/**
|
|
345
|
+
* Test ID prefix. When set, the component emits these selectors:
|
|
346
|
+
* - `{testId}-date-range` — root wrapper
|
|
347
|
+
* - `{testId}-date-range-trigger` — trigger button
|
|
348
|
+
* - `{testId}-date-range-clear` — clear button
|
|
349
|
+
* - `{testId}-date-range-errors` — error container
|
|
350
|
+
* - `{testId}-date-range-panel` — calendar panel
|
|
351
|
+
*/
|
|
337
352
|
testId?: string;
|
|
338
353
|
}
|
|
339
354
|
/**
|
|
@@ -379,6 +394,14 @@ export type TagsProps = {
|
|
|
379
394
|
onaddtag?: (tag: string) => void;
|
|
380
395
|
/** Fires when a tag is removed (via × or backspace). */
|
|
381
396
|
onremovetag?: (tag: string) => void;
|
|
397
|
+
/**
|
|
398
|
+
* Test ID prefix. When set, the component emits these selectors:
|
|
399
|
+
* - `{testId}-tags` — root wrapper
|
|
400
|
+
* - `{testId}-tags-input` — text input
|
|
401
|
+
* - `{testId}-tags-tag-{i}` — each tag badge
|
|
402
|
+
* - `{testId}-tags-suggestions` — dropdown
|
|
403
|
+
* - `{testId}-tags-suggestion-{i}` — each suggestion
|
|
404
|
+
*/
|
|
382
405
|
testId?: string;
|
|
383
406
|
};
|
|
384
407
|
/** Selection mode for `<Slider>`. */
|
|
@@ -138,7 +138,7 @@
|
|
|
138
138
|
});
|
|
139
139
|
</script>
|
|
140
140
|
|
|
141
|
-
<div class={cn('w-full', className)}>
|
|
141
|
+
<div class={cn('w-full', className)} data-testid={buildTestId('month-picker', 'root', testId)}>
|
|
142
142
|
{#if label}
|
|
143
143
|
<label for={id} class="text-default-700 mb-1 block text-sm font-medium">
|
|
144
144
|
{label}
|
|
@@ -246,6 +246,7 @@
|
|
|
246
246
|
yearSelected ? 'bg-primary-50 text-primary-700' : 'bg-default-50 text-default-800'
|
|
247
247
|
)}
|
|
248
248
|
data-year={year}
|
|
249
|
+
data-testid={buildTestId('month-picker', 'year', testId, year)}
|
|
249
250
|
>
|
|
250
251
|
{year}
|
|
251
252
|
</div>
|
|
@@ -258,6 +259,7 @@
|
|
|
258
259
|
<button
|
|
259
260
|
type="button"
|
|
260
261
|
data-month={m}
|
|
262
|
+
data-testid={buildTestId('month-picker', 'month', testId, `${year}-${m}`)}
|
|
261
263
|
data-selected={sel || undefined}
|
|
262
264
|
data-current={cur || undefined}
|
|
263
265
|
onclick={() => entry && pick(entry)}
|
|
@@ -18,5 +18,12 @@ export type MonthPickerProps = {
|
|
|
18
18
|
errors?: string[];
|
|
19
19
|
class?: ClassValue;
|
|
20
20
|
onselect?: (value: Date | null) => void;
|
|
21
|
+
/**
|
|
22
|
+
* Test ID prefix. When set, the component emits these selectors:
|
|
23
|
+
* - `{testId}-month-picker-root` — root wrapper
|
|
24
|
+
* - `{testId}-month-picker` — trigger button
|
|
25
|
+
* - `{testId}-month-picker-year-{year}` — year header
|
|
26
|
+
* - `{testId}-month-picker-month-{year}-{month}` — month cell
|
|
27
|
+
*/
|
|
21
28
|
testId?: string;
|
|
22
29
|
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { cn } from '../helper/cls.js';
|
|
3
|
+
import { buildTestId } from '../helper/testid.js';
|
|
3
4
|
import { breadcrumbs } from './breadcrumbs.js';
|
|
4
5
|
import type { BreadcrumbsProps } from '../index.js';
|
|
5
6
|
import { resolve } from '$app/paths';
|
|
@@ -14,7 +15,8 @@
|
|
|
14
15
|
listClass = '',
|
|
15
16
|
itemClass = '',
|
|
16
17
|
separatorClass = '',
|
|
17
|
-
wrapperClass = ''
|
|
18
|
+
wrapperClass = '',
|
|
19
|
+
testId
|
|
18
20
|
}: BreadcrumbsProps = $props();
|
|
19
21
|
|
|
20
22
|
const { base, list, item, separator, wrapper } = $derived(
|
|
@@ -33,10 +35,14 @@
|
|
|
33
35
|
const wrapperClasses = $derived(cn(wrapper(), wrapperClass));
|
|
34
36
|
</script>
|
|
35
37
|
|
|
36
|
-
<nav
|
|
38
|
+
<nav
|
|
39
|
+
class={baseClass}
|
|
40
|
+
aria-label="Breadcrumb"
|
|
41
|
+
data-testid={buildTestId('breadcrumbs', undefined, testId)}
|
|
42
|
+
>
|
|
37
43
|
<ol role="list" class={listClasses}>
|
|
38
44
|
{#each items as breadcrumbItem, index (index)}
|
|
39
|
-
<li>
|
|
45
|
+
<li data-testid={buildTestId('breadcrumbs', 'item', testId, index)}>
|
|
40
46
|
<div class={wrapperClasses}>
|
|
41
47
|
{#if index > 0}
|
|
42
48
|
<span class={separatorClasses} aria-hidden="true">
|
|
@@ -58,7 +64,11 @@
|
|
|
58
64
|
</span>
|
|
59
65
|
{/if}
|
|
60
66
|
{#if breadcrumbItem.current}
|
|
61
|
-
<span
|
|
67
|
+
<span
|
|
68
|
+
class={activeItemClasses}
|
|
69
|
+
aria-current="page"
|
|
70
|
+
data-testid={buildTestId('breadcrumbs', 'current', testId, index)}
|
|
71
|
+
>
|
|
62
72
|
{#if breadcrumbItem.icon}
|
|
63
73
|
{@const ItemIcon = breadcrumbItem.icon}
|
|
64
74
|
<ItemIcon class="size-4 flex-shrink-0" />
|
|
@@ -66,7 +76,11 @@
|
|
|
66
76
|
{breadcrumbItem.label}
|
|
67
77
|
</span>
|
|
68
78
|
{:else}
|
|
69
|
-
<a
|
|
79
|
+
<a
|
|
80
|
+
href={resolve(breadcrumbItem.href as `/`)}
|
|
81
|
+
class={itemClasses}
|
|
82
|
+
data-testid={buildTestId('breadcrumbs', 'link', testId, index)}
|
|
83
|
+
>
|
|
70
84
|
{#if breadcrumbItem.icon}
|
|
71
85
|
{@const ItemIcon = breadcrumbItem.icon}
|
|
72
86
|
<ItemIcon class="size-4 flex-shrink-0" />
|
|
@@ -49,6 +49,13 @@ export type BreadcrumbsProps = {
|
|
|
49
49
|
separatorClass?: ClassValue;
|
|
50
50
|
/** Classes on the inner item wrapper (icon + label). */
|
|
51
51
|
wrapperClass?: ClassValue;
|
|
52
|
+
/**
|
|
53
|
+
* Test ID prefix. When set, the component emits these selectors:
|
|
54
|
+
* - `{testId}-breadcrumbs` — root nav
|
|
55
|
+
* - `{testId}-breadcrumbs-item-{i}` — each item
|
|
56
|
+
* - `{testId}-breadcrumbs-link-{i}` — link element
|
|
57
|
+
* - `{testId}-breadcrumbs-current-{i}` — current page
|
|
58
|
+
*/
|
|
52
59
|
testId?: string;
|
|
53
60
|
};
|
|
54
61
|
/**
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { cn } from '../../helper/cls.js';
|
|
3
|
+
import { buildTestId } from '../../helper/testid.js';
|
|
3
4
|
import {
|
|
4
5
|
activityList,
|
|
5
6
|
iconCircleClasses,
|
|
@@ -25,7 +26,8 @@
|
|
|
25
26
|
itemClass = '',
|
|
26
27
|
onitemclick,
|
|
27
28
|
children,
|
|
28
|
-
customContent
|
|
29
|
+
customContent,
|
|
30
|
+
testId
|
|
29
31
|
}: ActivityListProps = $props();
|
|
30
32
|
|
|
31
33
|
const {
|
|
@@ -54,7 +56,7 @@
|
|
|
54
56
|
const contentClasses = $derived(cn(content(), contentClass, timeline && 'relative divide-y-0'));
|
|
55
57
|
</script>
|
|
56
58
|
|
|
57
|
-
<div class={baseClass}>
|
|
59
|
+
<div class={baseClass} data-testid={buildTestId('activity-list', undefined, testId)}>
|
|
58
60
|
{#if title}
|
|
59
61
|
<div class={headerClasses}>
|
|
60
62
|
<h2 class={titleClasses}>{title}</h2>
|
|
@@ -85,6 +87,7 @@
|
|
|
85
87
|
<div
|
|
86
88
|
class={cn(item(), highlightClass, itemClass)}
|
|
87
89
|
data-activity-row=""
|
|
90
|
+
data-testid={buildTestId('activity-list', 'item', testId, index)}
|
|
88
91
|
data-activity-highlighted={activityItem.highlighted ? '' : undefined}
|
|
89
92
|
>
|
|
90
93
|
{#if accentBarColor}
|
|
@@ -113,9 +116,13 @@
|
|
|
113
116
|
data-activity-comment=""
|
|
114
117
|
>
|
|
115
118
|
<div class="mb-1 flex justify-between gap-x-4">
|
|
116
|
-
<div
|
|
119
|
+
<div
|
|
120
|
+
class="flex flex-wrap items-center gap-2"
|
|
121
|
+
data-testid={buildTestId('activity-list', 'badges', testId, index)}
|
|
122
|
+
>
|
|
117
123
|
<button
|
|
118
124
|
class="text-default-900 cursor-pointer text-sm font-medium"
|
|
125
|
+
data-testid={buildTestId('activity-list', 'title', testId, index)}
|
|
119
126
|
onclick={() => onitemclick?.(activityItem, index)}
|
|
120
127
|
>
|
|
121
128
|
{activityItem.title}
|
|
@@ -140,7 +147,11 @@
|
|
|
140
147
|
{/if}
|
|
141
148
|
</div>
|
|
142
149
|
{#if activityItem.timestamp}
|
|
143
|
-
<div
|
|
150
|
+
<div
|
|
151
|
+
class={itemTimestamp()}
|
|
152
|
+
data-activity-timestamp=""
|
|
153
|
+
data-testid={buildTestId('activity-list', 'timestamp', testId, index)}
|
|
154
|
+
>
|
|
144
155
|
{activityItem.timestamp}
|
|
145
156
|
</div>
|
|
146
157
|
{/if}
|
|
@@ -166,8 +177,15 @@
|
|
|
166
177
|
{:else}
|
|
167
178
|
<div class={itemContent()}>
|
|
168
179
|
<div class={itemMain()}>
|
|
169
|
-
<div
|
|
170
|
-
|
|
180
|
+
<div
|
|
181
|
+
class={itemHeader()}
|
|
182
|
+
data-testid={buildTestId('activity-list', 'badges', testId, index)}
|
|
183
|
+
>
|
|
184
|
+
<button
|
|
185
|
+
class={itemTitle()}
|
|
186
|
+
data-testid={buildTestId('activity-list', 'title', testId, index)}
|
|
187
|
+
onclick={() => onitemclick?.(activityItem, index)}
|
|
188
|
+
>
|
|
171
189
|
{activityItem.title}
|
|
172
190
|
</button>
|
|
173
191
|
{#if activityItem.badges}
|
|
@@ -197,7 +215,11 @@
|
|
|
197
215
|
</div>
|
|
198
216
|
|
|
199
217
|
{#if activityItem.timestamp}
|
|
200
|
-
<div
|
|
218
|
+
<div
|
|
219
|
+
class={itemTimestamp()}
|
|
220
|
+
data-activity-timestamp=""
|
|
221
|
+
data-testid={buildTestId('activity-list', 'timestamp', testId, index)}
|
|
222
|
+
>
|
|
201
223
|
{activityItem.timestamp}
|
|
202
224
|
</div>
|
|
203
225
|
{/if}
|
|
@@ -145,5 +145,13 @@ export type ActivityListProps = {
|
|
|
145
145
|
* `custom: true` continue to use the default rendering.
|
|
146
146
|
*/
|
|
147
147
|
customContent?: Snippet<[ActivityItem, number]>;
|
|
148
|
+
/**
|
|
149
|
+
* Test ID prefix. When set, the component emits these selectors:
|
|
150
|
+
* - `{testId}-activity-list` — root wrapper
|
|
151
|
+
* - `{testId}-activity-list-item-{i}` — each item
|
|
152
|
+
* - `{testId}-activity-list-title-{i}` — item title
|
|
153
|
+
* - `{testId}-activity-list-timestamp-{i}` — timestamp
|
|
154
|
+
* - `{testId}-activity-list-badges-{i}` — badges container
|
|
155
|
+
*/
|
|
148
156
|
testId?: string;
|
|
149
157
|
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { cn } from '../../helper/cls.js';
|
|
3
|
+
import { buildTestId } from '../../helper/testid.js';
|
|
3
4
|
import { metricCard } from './metric-card.js';
|
|
4
5
|
import type { MetricCardProps } from '../../index.js';
|
|
5
6
|
import Progress from '../../elements/progress/Progress.svelte';
|
|
@@ -16,7 +17,8 @@
|
|
|
16
17
|
onclick,
|
|
17
18
|
action,
|
|
18
19
|
actionHover,
|
|
19
|
-
loading = false
|
|
20
|
+
loading = false,
|
|
21
|
+
testId
|
|
20
22
|
}: MetricCardProps = $props();
|
|
21
23
|
|
|
22
24
|
const interactive = $derived(!!onclick);
|
|
@@ -54,6 +56,7 @@
|
|
|
54
56
|
<span
|
|
55
57
|
class={cn(actionSlot(), hasHoverSwap && 'group-hover:opacity-0')}
|
|
56
58
|
data-metric-card-action=""
|
|
59
|
+
data-testid={buildTestId('metric-card', 'action', testId)}
|
|
57
60
|
aria-hidden="true"
|
|
58
61
|
>
|
|
59
62
|
{#if action}
|
|
@@ -90,17 +93,24 @@
|
|
|
90
93
|
</div>
|
|
91
94
|
{:else}
|
|
92
95
|
{#if title}
|
|
93
|
-
<div class={titleSlot()}
|
|
96
|
+
<div class={titleSlot()} data-testid={buildTestId('metric-card', 'label', testId)}>
|
|
97
|
+
{title}
|
|
98
|
+
</div>
|
|
94
99
|
{/if}
|
|
95
100
|
|
|
96
101
|
{#if value !== undefined}
|
|
97
|
-
<div class={valueSlot()}
|
|
102
|
+
<div class={valueSlot()} data-testid={buildTestId('metric-card', 'value', testId)}>
|
|
103
|
+
{value}
|
|
104
|
+
</div>
|
|
98
105
|
{/if}
|
|
99
106
|
|
|
100
107
|
{#if details.length > 0}
|
|
101
108
|
<div class={detailSlot()}>
|
|
102
109
|
{#each details as detail, index (detail.label + index)}
|
|
103
|
-
<div
|
|
110
|
+
<div
|
|
111
|
+
class="flex justify-between text-xs"
|
|
112
|
+
data-testid={buildTestId('metric-card', 'detail', testId, index)}
|
|
113
|
+
>
|
|
104
114
|
<span class="text-default-500">{detail.label}</span>
|
|
105
115
|
<span class="font-medium {detail.color || 'text-default-900'}">{detail.value}</span>
|
|
106
116
|
</div>
|
|
@@ -109,7 +119,10 @@
|
|
|
109
119
|
{/if}
|
|
110
120
|
|
|
111
121
|
{#if segments}
|
|
112
|
-
<div
|
|
122
|
+
<div
|
|
123
|
+
class={cn(progressSlot(), 'pt-2')}
|
|
124
|
+
data-testid={buildTestId('metric-card', 'trend', testId)}
|
|
125
|
+
>
|
|
113
126
|
<Progress
|
|
114
127
|
value={0}
|
|
115
128
|
{segments}
|
|
@@ -120,7 +133,10 @@
|
|
|
120
133
|
/>
|
|
121
134
|
</div>
|
|
122
135
|
{:else if percent !== undefined}
|
|
123
|
-
<div
|
|
136
|
+
<div
|
|
137
|
+
class={cn(progressSlot(), 'pt-2')}
|
|
138
|
+
data-testid={buildTestId('metric-card', 'trend', testId)}
|
|
139
|
+
>
|
|
124
140
|
<Progress value={percent} size={Size.SM} color={Color.SUCCESS} showLabel={false} />
|
|
125
141
|
</div>
|
|
126
142
|
{/if}
|
|
@@ -128,11 +144,21 @@
|
|
|
128
144
|
{/snippet}
|
|
129
145
|
|
|
130
146
|
{#if interactive}
|
|
131
|
-
<button
|
|
147
|
+
<button
|
|
148
|
+
type="button"
|
|
149
|
+
class={baseClass}
|
|
150
|
+
data-metric-card=""
|
|
151
|
+
data-testid={buildTestId('metric-card', undefined, testId)}
|
|
152
|
+
onclick={handleClick}
|
|
153
|
+
>
|
|
132
154
|
{@render body()}
|
|
133
155
|
</button>
|
|
134
156
|
{:else}
|
|
135
|
-
<div
|
|
157
|
+
<div
|
|
158
|
+
class={baseClass}
|
|
159
|
+
data-metric-card=""
|
|
160
|
+
data-testid={buildTestId('metric-card', undefined, testId)}
|
|
161
|
+
>
|
|
136
162
|
{@render body()}
|
|
137
163
|
</div>
|
|
138
164
|
{/if}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { cn } from '../../helper/cls.js';
|
|
3
|
+
import { buildTestId } from '../../helper/testid.js';
|
|
3
4
|
import { rankedCard, type RankedCardProps } from './ranked-card.js';
|
|
4
5
|
|
|
5
|
-
let { items, columns = 3, class: className = '' }: RankedCardProps = $props();
|
|
6
|
+
let { items, columns = 3, class: className = '', testId }: RankedCardProps = $props();
|
|
6
7
|
|
|
7
8
|
const {
|
|
8
9
|
container,
|
|
@@ -22,28 +23,51 @@
|
|
|
22
23
|
const containerClass = $derived(cn(container(), className));
|
|
23
24
|
</script>
|
|
24
25
|
|
|
25
|
-
<div class={containerClass}>
|
|
26
|
-
{#each items as item (item.rank)}
|
|
27
|
-
<div class={card()}>
|
|
26
|
+
<div class={containerClass} data-testid={buildTestId('ranked-card', undefined, testId)}>
|
|
27
|
+
{#each items as item, index (item.rank)}
|
|
28
|
+
<div class={card()} data-testid={buildTestId('ranked-card', 'card', testId, index)}>
|
|
28
29
|
<!-- Header with Rank and Title -->
|
|
29
30
|
<div class={header()}>
|
|
30
|
-
<span class={rank()}
|
|
31
|
-
|
|
31
|
+
<span class={rank()} data-testid={buildTestId('ranked-card', 'rank', testId, index)}
|
|
32
|
+
>#{item.rank}</span
|
|
33
|
+
>
|
|
34
|
+
<h4 class={title()} data-testid={buildTestId('ranked-card', 'title', testId, index)}>
|
|
35
|
+
{item.title}
|
|
36
|
+
</h4>
|
|
32
37
|
</div>
|
|
33
38
|
|
|
34
39
|
<!-- Metrics -->
|
|
35
40
|
<div class={metricsContainer()}>
|
|
36
|
-
{#each item.metrics as metric (metric.label)}
|
|
41
|
+
{#each item.metrics as metric, metricIndex (metric.label)}
|
|
37
42
|
<div class={metricRow()}>
|
|
38
|
-
<span
|
|
39
|
-
|
|
43
|
+
<span
|
|
44
|
+
class={metricLabel()}
|
|
45
|
+
data-testid={buildTestId(
|
|
46
|
+
'ranked-card',
|
|
47
|
+
'metric-label',
|
|
48
|
+
testId,
|
|
49
|
+
`${index}-${metricIndex}`
|
|
50
|
+
)}>{metric.label}:</span
|
|
51
|
+
>
|
|
52
|
+
<span
|
|
53
|
+
class={cn(metricValue(), metric.color || '')}
|
|
54
|
+
data-testid={buildTestId(
|
|
55
|
+
'ranked-card',
|
|
56
|
+
'metric-value',
|
|
57
|
+
testId,
|
|
58
|
+
`${index}-${metricIndex}`
|
|
59
|
+
)}>{metric.value}</span
|
|
60
|
+
>
|
|
40
61
|
</div>
|
|
41
62
|
{/each}
|
|
42
63
|
</div>
|
|
43
64
|
|
|
44
65
|
<!-- Action/Recommendation -->
|
|
45
66
|
{#if item.action}
|
|
46
|
-
<div
|
|
67
|
+
<div
|
|
68
|
+
class={actionContainer()}
|
|
69
|
+
data-testid={buildTestId('ranked-card', 'action', testId, index)}
|
|
70
|
+
>
|
|
47
71
|
<p class={actionText()}>
|
|
48
72
|
<span class={actionLabel()}>Action:</span>
|
|
49
73
|
<span class={item.action.color || ''}>{item.action.text}</span>
|
|
@@ -168,5 +168,13 @@ export type MetricCardProps = {
|
|
|
168
168
|
* data is loading. @default false
|
|
169
169
|
*/
|
|
170
170
|
loading?: boolean;
|
|
171
|
+
/**
|
|
172
|
+
* Test ID prefix. When set, the component emits these selectors:
|
|
173
|
+
* - `{testId}-metric-card` — root wrapper
|
|
174
|
+
* - `{testId}-metric-card-value` — big number
|
|
175
|
+
* - `{testId}-metric-card-label` — label text
|
|
176
|
+
* - `{testId}-metric-card-detail-{i}` — each detail row
|
|
177
|
+
* - `{testId}-metric-card-action` — action area
|
|
178
|
+
*/
|
|
171
179
|
testId?: string;
|
|
172
180
|
};
|
|
@@ -102,4 +102,14 @@ export type RankedCardProps = {
|
|
|
102
102
|
items: RankedCardItem[];
|
|
103
103
|
columns?: 1 | 2 | 3 | 4;
|
|
104
104
|
class?: ClassValue;
|
|
105
|
+
/**
|
|
106
|
+
* Test ID prefix. When set, the component emits these selectors:
|
|
107
|
+
* - `{testId}-ranked-card` — root wrapper
|
|
108
|
+
* - `{testId}-ranked-card-item-{i}` — each card
|
|
109
|
+
* - `{testId}-ranked-card-rank-{i}` — rank number
|
|
110
|
+
* - `{testId}-ranked-card-title-{i}` — title
|
|
111
|
+
* - `{testId}-ranked-card-metric-label-{i}-{j}` — metric label
|
|
112
|
+
* - `{testId}-ranked-card-metric-value-{i}-{j}` — metric value
|
|
113
|
+
*/
|
|
114
|
+
testId?: string;
|
|
105
115
|
};
|
|
@@ -202,5 +202,13 @@ export type TableProps<T = DataRow> = {
|
|
|
202
202
|
rowKey?: string;
|
|
203
203
|
/** Allow rows to expand/collapse showing `expandedContent`. @default false */
|
|
204
204
|
expandable?: boolean;
|
|
205
|
+
/**
|
|
206
|
+
* Test ID prefix. When set, the component emits these selectors:
|
|
207
|
+
* - `{testId}-table` — root table element
|
|
208
|
+
* - `{testId}-table-head` — thead
|
|
209
|
+
* - `{testId}-table-data` — hidden element containing the current
|
|
210
|
+
* page's row data as JSON, so test automation can read values
|
|
211
|
+
* without parsing individual cells
|
|
212
|
+
*/
|
|
205
213
|
testId?: string;
|
|
206
214
|
};
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
class: className = '',
|
|
12
12
|
size = 'md',
|
|
13
13
|
equalWidth = true,
|
|
14
|
-
chevronGap =
|
|
14
|
+
chevronGap = 4,
|
|
15
15
|
selectedClass,
|
|
16
16
|
unselectedClass,
|
|
17
17
|
disabledClass,
|
|
@@ -74,9 +74,13 @@
|
|
|
74
74
|
{#if children}
|
|
75
75
|
{@render children(stage, index)}
|
|
76
76
|
{:else}
|
|
77
|
-
<span class={s.label()}
|
|
77
|
+
<span class={s.label()} data-testid={buildTestId('pipeline', 'label', testId, index)}
|
|
78
|
+
>{stage.label}</span
|
|
79
|
+
>
|
|
78
80
|
{#if stage.count !== undefined}
|
|
79
|
-
<span class={s.count()}
|
|
81
|
+
<span class={s.count()} data-testid={buildTestId('pipeline', 'count', testId, index)}
|
|
82
|
+
>{stage.count}</span
|
|
83
|
+
>
|
|
80
84
|
{/if}
|
|
81
85
|
{/if}
|
|
82
86
|
</div>
|
|
@@ -86,5 +86,11 @@ export type PipelineProps = {
|
|
|
86
86
|
onstagehover?: (e: PipelineStagePointerEvent) => void;
|
|
87
87
|
/** Fires on stage mouseleave. */
|
|
88
88
|
onstageleave?: (e: PipelineStagePointerEvent) => void;
|
|
89
|
+
/**
|
|
90
|
+
* Test ID prefix. When set, the component emits these selectors:
|
|
91
|
+
* - `{testId}-pipeline` — root wrapper
|
|
92
|
+
* - `{testId}-pipeline-label-{i}` — stage label
|
|
93
|
+
* - `{testId}-pipeline-count-{i}` — stage count
|
|
94
|
+
*/
|
|
89
95
|
testId?: string;
|
|
90
96
|
};
|