@makolabs/ripple 3.0.3 → 3.0.5

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.
@@ -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
  /**
@@ -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 class={fillClass} style="width: {tween.current}%"></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 class={labelTextClass}>
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}>{percentage}%</span>
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
- // Separate folders and files
294
- const folders = filesArray.filter((file) => file.isFolder);
295
- const files = filesArray.filter((file) => !file.isFolder);
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
- <!-- Header row: title | search | clear | chevron.
186
- `flex-wrap` lets the search + controls drop to a second row on
187
- narrow viewports instead of overflowing off-screen. -->
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={clearAll}
238
- class="text-default-600 hover:bg-default-100 cursor-pointer rounded-md px-2 py-1 text-xs font-medium"
239
- data-filters-clear-all=""
196
+ onclick={toggleExpanded}
197
+ class="flex flex-1 cursor-pointer items-center gap-2"
240
198
  >
241
- {clearAllLabel}
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
- <button
246
- type="button"
247
- onclick={toggleExpanded}
248
- class="text-default-500 hover:bg-default-100 hover:text-default-700 cursor-pointer rounded-md p-1"
249
- aria-label={isExpanded ? `Collapse ${title.toLowerCase()}` : `Expand ${title.toLowerCase()}`}
250
- >
251
- {#if isExpanded}
252
- {@render DefaultChevronUp()}
253
- {:else}
254
- {@render DefaultChevronDown()}
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
- </button>
257
- </div>
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 class={cn('relative block w-full', className)} bind:this={datePickerRef}>
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 class={cn('text-default-500 flex items-center gap-1 pr-2', tokens.text)}>
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"
@@ -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>
@@ -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-3.5' : 'translate-x-0.5',
75
- [Size.SM]: value ? 'translate-x-4' : 'translate-x-0.5',
76
- [Size.MD]: value ? 'translate-x-5' : 'translate-x-0.5',
77
- [Size.LG]: value ? 'translate-x-6' : 'translate-x-0.5',
78
- [Size.XL]: value ? 'translate-x-7' : 'translate-x-0.5',
79
- [Size.XXL]: value ? 'translate-x-7' : 'translate-x-0.5'
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 class={cn('text-default-800 font-semibold', density.monthText)}>{monthLabel}</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 class={baseClass} aria-label="Breadcrumb">
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 class={activeItemClasses} aria-current="page">
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 href={resolve(breadcrumbItem.href as `/`)} class={itemClasses}>
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 class="flex flex-wrap items-center gap-2">
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 class={itemTimestamp()} data-activity-timestamp="">
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 class={itemHeader()}>
170
- <button class={itemTitle()} onclick={() => onitemclick?.(activityItem, index)}>
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 class={itemTimestamp()} data-activity-timestamp="">
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()}>{title}</div>
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()}>{value}</div>
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 class="flex justify-between text-xs">
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 class={cn(progressSlot(), 'pt-2')}>
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 class={cn(progressSlot(), 'pt-2')}>
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 type="button" class={baseClass} data-metric-card="" onclick={handleClick}>
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 class={baseClass} data-metric-card="">
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()}>#{item.rank}</span>
31
- <h4 class={title()}>{item.title}</h4>
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 class={metricLabel()}>{metric.label}:</span>
39
- <span class={cn(metricValue(), metric.color || '')}>{metric.value}</span>
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 class={actionContainer()}>
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
  };
@@ -560,7 +560,7 @@
560
560
  </td>
561
561
  {/each}
562
562
  </tr>
563
- {#if expandedContent && (!expandable || isRowExpanded(row, rowIndex))}
563
+ {#if expandedContent && expandable && isRowExpanded(row, rowIndex)}
564
564
  <tr class="expandedContent-row">
565
565
  <td colspan={totalColumnCount} class="border-0 p-0">
566
566
  <div transition:slide={{ duration: 350, easing: quintOut }}>
@@ -622,3 +622,8 @@
622
622
  {:else}
623
623
  {@render tableContent()}
624
624
  {/if}
625
+ {#if testId}
626
+ <div data-testid={buildTestId('table', 'data', testId)} style="display:none" aria-hidden="true">
627
+ {JSON.stringify(getPaginatedData())}
628
+ </div>
629
+ {/if}
@@ -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 = 0,
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()}>{stage.label}</span>
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()}>{stage.count}</span>
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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@makolabs/ripple",
3
- "version": "3.0.3",
3
+ "version": "3.0.5",
4
4
  "description": "Simple Svelte 5 powered component library ✨",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "repository": {