@makolabs/ripple 2.5.9 → 3.0.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.
Files changed (183) hide show
  1. package/README.md +403 -497
  2. package/dist/adapters/storage/S3Adapter.d.ts +49 -1
  3. package/dist/adapters/storage/S3Adapter.js +38 -1
  4. package/dist/adapters/storage/types.d.ts +20 -0
  5. package/dist/ai/AIChatInterface.svelte +2 -1
  6. package/dist/ai/AIChatInterface.svelte.d.ts +2 -1
  7. package/dist/ai/CodeRenderer.svelte +7 -2
  8. package/dist/ai/CodeRenderer.svelte.d.ts +2 -1
  9. package/dist/ai/ComposeDropdown.svelte +1 -1
  10. package/dist/ai/MessageBox.svelte +3 -3
  11. package/dist/ai/MessageBox.svelte.d.ts +3 -2
  12. package/dist/ai/ThinkingDisplay.svelte +4 -3
  13. package/dist/ai/ThinkingDisplay.svelte.d.ts +2 -1
  14. package/dist/ai/ai-types.d.ts +55 -1
  15. package/dist/button/Button.svelte +5 -5
  16. package/dist/button/button-types.d.ts +49 -4
  17. package/dist/button/button.d.ts +9 -9
  18. package/dist/button/button.js +6 -6
  19. package/dist/charts/Chart.svelte +8 -16
  20. package/dist/charts/chart-types.d.ts +78 -1
  21. package/dist/drawer/Drawer.svelte +6 -26
  22. package/dist/drawer/drawer-types.d.ts +33 -12
  23. package/dist/drawer/drawer.d.ts +3 -3
  24. package/dist/drawer/drawer.js +1 -1
  25. package/dist/elements/accordion/Accordion.svelte +6 -17
  26. package/dist/elements/accordion/accordion-types.d.ts +53 -6
  27. package/dist/elements/alert/Alert.svelte +3 -0
  28. package/dist/elements/badge/Badge.svelte +1 -1
  29. package/dist/elements/badge/badge-types.d.ts +22 -0
  30. package/dist/elements/badge/badge.d.ts +3 -3
  31. package/dist/elements/badge/badge.js +1 -1
  32. package/dist/elements/combobox/ComboBox.svelte +247 -0
  33. package/dist/elements/combobox/ComboBox.svelte.d.ts +4 -0
  34. package/dist/elements/combobox/combobox-types.d.ts +41 -0
  35. package/dist/elements/combobox/combobox-types.js +1 -0
  36. package/dist/elements/context-menu/ContextMenu.svelte +137 -0
  37. package/dist/elements/context-menu/ContextMenu.svelte.d.ts +4 -0
  38. package/dist/elements/context-menu/context-menu-types.d.ts +40 -0
  39. package/dist/elements/context-menu/context-menu-types.js +1 -0
  40. package/dist/elements/dropdown/Dropdown.svelte +1 -1
  41. package/dist/elements/dropdown/Select.svelte +4 -1
  42. package/dist/elements/dropdown/dropdown-types.d.ts +114 -0
  43. package/dist/elements/dropdown/dropdown.d.ts +3 -3
  44. package/dist/elements/dropdown/dropdown.js +2 -2
  45. package/dist/elements/dropdown/select.d.ts +3 -3
  46. package/dist/elements/dropdown/select.js +2 -2
  47. package/dist/elements/empty-state/EmptyState.svelte +1 -1
  48. package/dist/elements/empty-state/empty-state-types.d.ts +32 -1
  49. package/dist/elements/empty-state/empty-state.d.ts +3 -3
  50. package/dist/elements/empty-state/empty-state.js +2 -2
  51. package/dist/elements/file-upload/FileUpload.svelte +5 -0
  52. package/dist/elements/file-upload/file-upload-types.d.ts +59 -0
  53. package/dist/elements/pagination/Pagination.svelte +53 -21
  54. package/dist/elements/pagination/Pagination.svelte.d.ts +33 -5
  55. package/dist/elements/popover/Popover.svelte +234 -0
  56. package/dist/elements/popover/Popover.svelte.d.ts +4 -0
  57. package/dist/elements/popover/index.d.ts +2 -0
  58. package/dist/elements/popover/index.js +1 -0
  59. package/dist/elements/popover/popover-types.d.ts +60 -0
  60. package/dist/elements/popover/popover-types.js +1 -0
  61. package/dist/elements/progress/Progress.svelte +32 -7
  62. package/dist/elements/progress/progress-types.d.ts +48 -1
  63. package/dist/elements/skeleton/Skeleton.svelte +56 -0
  64. package/dist/elements/skeleton/Skeleton.svelte.d.ts +4 -0
  65. package/dist/elements/skeleton/index.d.ts +2 -0
  66. package/dist/elements/skeleton/index.js +1 -0
  67. package/dist/elements/skeleton/skeleton-types.d.ts +50 -0
  68. package/dist/elements/skeleton/skeleton-types.js +1 -0
  69. package/dist/elements/spinner/Spinner.svelte +1 -1
  70. package/dist/elements/spinner/spinner-types.d.ts +20 -0
  71. package/dist/elements/spinner/spinner.d.ts +3 -3
  72. package/dist/elements/spinner/spinner.js +2 -2
  73. package/dist/elements/tooltip/Tooltip.svelte +108 -11
  74. package/dist/elements/tooltip/tooltip-types.d.ts +49 -1
  75. package/dist/file-browser/FileBrowser.svelte +21 -12
  76. package/dist/filters/CompactFilters.svelte +221 -33
  77. package/dist/filters/CompactFilters.svelte.d.ts +1 -1
  78. package/dist/filters/FilterBar.svelte +184 -0
  79. package/dist/filters/FilterBar.svelte.d.ts +4 -0
  80. package/dist/filters/FilterPopover.svelte +346 -0
  81. package/dist/filters/FilterPopover.svelte.d.ts +4 -0
  82. package/dist/filters/date-presets.d.ts +15 -0
  83. package/dist/filters/date-presets.js +107 -0
  84. package/dist/filters/filter-types.d.ts +69 -3
  85. package/dist/filters/index.d.ts +5 -0
  86. package/dist/filters/index.js +4 -0
  87. package/dist/filters/sync-filters-to-url.svelte.d.ts +37 -0
  88. package/dist/filters/sync-filters-to-url.svelte.js +114 -0
  89. package/dist/forms/DateRange.svelte +4 -2
  90. package/dist/forms/Input.svelte +2 -2
  91. package/dist/forms/MarketSelector.svelte +8 -3
  92. package/dist/forms/NumberInput.svelte +4 -4
  93. package/dist/forms/RadioGroup.svelte +123 -0
  94. package/dist/forms/RadioGroup.svelte.d.ts +4 -0
  95. package/dist/forms/SegmentedControl.svelte +11 -4
  96. package/dist/forms/Slider.svelte +72 -3
  97. package/dist/forms/Tags.svelte +14 -5
  98. package/dist/forms/Textarea.svelte +126 -0
  99. package/dist/forms/Textarea.svelte.d.ts +4 -0
  100. package/dist/forms/Toggle.svelte +8 -8
  101. package/dist/forms/calendar/Calendar.svelte +218 -0
  102. package/dist/forms/calendar/Calendar.svelte.d.ts +4 -0
  103. package/dist/forms/calendar/calendar-types.d.ts +46 -0
  104. package/dist/forms/calendar/calendar-types.js +1 -0
  105. package/dist/forms/calendar/index.d.ts +2 -0
  106. package/dist/forms/calendar/index.js +1 -0
  107. package/dist/forms/date-picker/DatePicker.svelte +144 -0
  108. package/dist/forms/date-picker/DatePicker.svelte.d.ts +4 -0
  109. package/dist/forms/date-picker/date-picker-types.d.ts +29 -0
  110. package/dist/forms/date-picker/date-picker-types.js +1 -0
  111. package/dist/forms/form-types.d.ts +425 -6
  112. package/dist/forms/market/market-selector-types.d.ts +52 -1
  113. package/dist/forms/segmented-control.d.ts +5 -2
  114. package/dist/forms/segmented-control.js +16 -5
  115. package/dist/forms/slider.d.ts +3 -3
  116. package/dist/forms/slider.js +2 -2
  117. package/dist/funcs/user-management.remote.js +1 -1
  118. package/dist/header/Breadcrumbs.svelte +4 -20
  119. package/dist/header/PageHeader.svelte +6 -14
  120. package/dist/header/breadcrumbs.d.ts +3 -11
  121. package/dist/header/breadcrumbs.js +10 -5
  122. package/dist/header/header-types.d.ts +62 -11
  123. package/dist/index.d.ts +35 -9
  124. package/dist/index.js +24 -4
  125. package/dist/layout/activity-list/ActivityList.svelte +13 -7
  126. package/dist/layout/activity-list/activity-list-types.d.ts +46 -7
  127. package/dist/layout/card/Card.svelte +12 -15
  128. package/dist/layout/card/MetricCard.svelte +50 -32
  129. package/dist/layout/card/card-types.d.ts +114 -4
  130. package/dist/layout/navbar/navbar-types.d.ts +48 -0
  131. package/dist/layout/navbar/navbar.d.ts +3 -3
  132. package/dist/layout/navbar/navbar.js +2 -2
  133. package/dist/layout/sidebar/Sidebar.svelte +87 -11
  134. package/dist/layout/sidebar/sidebar-types.d.ts +60 -1
  135. package/dist/layout/stepper/Stepper.svelte +288 -0
  136. package/dist/layout/stepper/Stepper.svelte.d.ts +4 -0
  137. package/dist/layout/stepper/stepper-types.d.ts +80 -0
  138. package/dist/layout/stepper/stepper-types.js +1 -0
  139. package/dist/layout/table/Table.svelte +91 -85
  140. package/dist/layout/table/table-types.d.ts +148 -24
  141. package/dist/layout/table/table.d.ts +3 -3
  142. package/dist/layout/table/table.js +2 -2
  143. package/dist/layout/tabs/Tab.svelte +6 -2
  144. package/dist/layout/tabs/Tab.svelte.d.ts +4 -1
  145. package/dist/layout/tabs/TabGroup.svelte +9 -2
  146. package/dist/layout/tabs/tabs-types.d.ts +63 -0
  147. package/dist/layout/tabs/tabs.d.ts +3 -3
  148. package/dist/layout/tabs/tabs.js +12 -6
  149. package/dist/modal/ConfirmDialog.svelte +65 -0
  150. package/dist/modal/ConfirmDialog.svelte.d.ts +4 -0
  151. package/dist/modal/Modal.svelte +6 -26
  152. package/dist/modal/confirm-dialog-types.d.ts +39 -0
  153. package/dist/modal/confirm-dialog-types.js +1 -0
  154. package/dist/modal/modal-types.d.ts +51 -12
  155. package/dist/modal/modal.d.ts +3 -3
  156. package/dist/modal/modal.js +3 -3
  157. package/dist/pipeline/Pipeline.svelte +8 -3
  158. package/dist/pipeline/pipeline-types.d.ts +55 -3
  159. package/dist/pipeline/pipeline.d.ts +18 -3
  160. package/dist/pipeline/pipeline.js +7 -2
  161. package/dist/server/s3.d.ts +35 -3
  162. package/dist/sonner/Toaster.svelte +29 -0
  163. package/dist/sonner/Toaster.svelte.d.ts +4 -0
  164. package/dist/sonner/index.d.ts +21 -0
  165. package/dist/sonner/index.js +20 -0
  166. package/dist/user-management/UserManagement.svelte +22 -16
  167. package/dist/user-management/UserModal.svelte +10 -7
  168. package/dist/user-management/UserTable.svelte +16 -17
  169. package/dist/user-management/UserViewModal.svelte +11 -11
  170. package/dist/user-management/user-management-types.d.ts +118 -31
  171. package/dist/variants.d.ts +1 -1
  172. package/dist/variants.js +1 -1
  173. package/package.json +7 -4
  174. package/dist/config/ai.d.ts +0 -13
  175. package/dist/config/ai.js +0 -44
  176. package/dist/elements/empty-state/EmptyStateTestWrapper.svelte +0 -25
  177. package/dist/elements/empty-state/EmptyStateTestWrapper.svelte.d.ts +0 -8
  178. package/dist/elements/tooltip/TooltipTestWrapper.svelte +0 -14
  179. package/dist/elements/tooltip/TooltipTestWrapper.svelte.d.ts +0 -7
  180. package/dist/helper/deprecation.d.ts +0 -14
  181. package/dist/helper/deprecation.js +0 -24
  182. package/dist/modal/ModalFooterTestWrapper.svelte +0 -17
  183. package/dist/modal/ModalFooterTestWrapper.svelte.d.ts +0 -8
@@ -12,15 +12,46 @@
12
12
  });
13
13
  setContext('menubar', menubar);
14
14
 
15
- // Track screen size for responsive behavior
16
- let isSmallScreen = $state(false);
15
+ // Track screen size for responsive behavior.
16
+ // `isSmallScreen`: below xl (1280px) → auto-collapse to icon rail.
17
+ // `isMobile`: below md (768px) → sidebar slides in as a fixed overlay
18
+ // instead of taking a permanent column.
19
+ //
20
+ // Initialised via `matchMedia` inside the `$state` expression so the
21
+ // client-side first render already has the correct value — avoids the
22
+ // flash where mobile viewports would briefly show the desktop layout
23
+ // before onMount runs. On SSR both resolve to `false`; the CSS below
24
+ // uses `md:` / `max-md:` modifiers to hide the drawer regardless so
25
+ // the SSR output doesn't paint a w-64 column on narrow viewports.
26
+ function matchMediaOrFalse(query: string): boolean {
27
+ return typeof window !== 'undefined' && window.matchMedia(query).matches;
28
+ }
29
+ let isSmallScreen = $state(matchMediaOrFalse('(max-width: 1279.98px)'));
30
+ let isMobile = $state(matchMediaOrFalse('(max-width: 767.98px)'));
31
+
32
+ // Track previous viewport class so we only force-close the drawer
33
+ // when transitioning INTO mobile from a larger viewport. Without this,
34
+ // a minor resize on mobile (soft keyboard, orientation change) would
35
+ // unexpectedly close an already-open drawer.
36
+ let wasMobile = isMobile;
17
37
 
18
38
  function updateCollapseState() {
19
39
  if (typeof window !== 'undefined') {
20
- isSmallScreen = window.innerWidth < 1280; // xl breakpoint
21
- if (isSmallScreen && !menubar.collapsed) {
40
+ isSmallScreen = window.innerWidth < 1280;
41
+ const nextIsMobile = window.innerWidth < 768;
42
+ // Keep legacy auto-collapse on md-xl only — below md we fully
43
+ // hide via translate, so don't force the icon-rail state.
44
+ if (isSmallScreen && !nextIsMobile && !menubar.collapsed) {
22
45
  menubar.collapsed = true;
23
46
  }
47
+ if (nextIsMobile && !wasMobile) {
48
+ // Transitioning into mobile: close the drawer so it doesn't
49
+ // cover the app. Subsequent resizes on mobile won't re-close
50
+ // an open drawer.
51
+ menubar.collapsed = true;
52
+ }
53
+ isMobile = nextIsMobile;
54
+ wasMobile = nextIsMobile;
24
55
  }
25
56
  }
26
57
 
@@ -34,6 +65,10 @@
34
65
  menubar.collapsed = !menubar.collapsed;
35
66
  }
36
67
 
68
+ function openMobile() {
69
+ menubar.collapsed = false;
70
+ }
71
+
37
72
  /**
38
73
  * Process a navigation item to determine if it should be active
39
74
  */
@@ -73,11 +108,19 @@
73
108
 
74
109
  const sidebarClasses = $derived(
75
110
  clsx(
76
- `min-h-screen flex flex-col bg-gradient-to-b from-default-900 to-default-900 h-full shrink-0`,
77
- {
78
- 'w-16': menubar.collapsed,
79
- 'w-64': !menubar.collapsed
80
- }
111
+ `flex flex-col bg-gradient-to-b from-default-900 to-default-900 overflow-hidden`,
112
+ 'transition-[width,transform] duration-300 ease-out',
113
+ // Mobile drawer mode: fixed overlay that slides in from the left.
114
+ // Full-width drawer (w-64), translates off-screen when collapsed.
115
+ // `isMobile` is initialised synchronously via matchMedia above so
116
+ // the client-side first render already picks the right branch,
117
+ // minimising the hydration flash.
118
+ isMobile && 'fixed inset-y-0 left-0 z-50 w-64 shadow-2xl',
119
+ isMobile && menubar.collapsed && '-translate-x-full',
120
+ // Desktop / tablet: normal flow, collapses to an icon rail.
121
+ !isMobile && 'min-h-screen h-full shrink-0',
122
+ !isMobile && menubar.collapsed && 'w-16',
123
+ !isMobile && !menubar.collapsed && 'w-64'
81
124
  )
82
125
  );
83
126
 
@@ -92,14 +135,47 @@
92
135
  );
93
136
  </script>
94
137
 
138
+ {#if menubar.collapsed}
139
+ <!-- Floating hamburger trigger: only needed on mobile when the drawer
140
+ is closed. `md:hidden` keeps it mobile-only without needing a JS
141
+ viewport check, which avoids a first-paint flash. -->
142
+ <button
143
+ type="button"
144
+ onclick={openMobile}
145
+ class="bg-default-900 fixed top-4 left-4 z-40 flex size-10 cursor-pointer items-center justify-center rounded-lg text-white shadow-lg md:hidden"
146
+ aria-label="Open navigation"
147
+ data-testid={buildTestId('sidebar', 'mobile-trigger', testId)}
148
+ >
149
+ {@render ToggleIcon('size-5')}
150
+ </button>
151
+ {:else}
152
+ <!-- Backdrop: tap-to-close. Also `md:hidden` so desktop doesn't get
153
+ covered by a full-viewport click-catcher. -->
154
+ <button
155
+ type="button"
156
+ onclick={toggle}
157
+ aria-label="Close navigation"
158
+ class="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm md:hidden"
159
+ ></button>
160
+ {/if}
161
+
95
162
  <div class={sidebarClasses} data-testid={buildTestId('sidebar', undefined, testId)}>
96
163
  <div class={logoWrapperClasses}>
97
164
  <div class="flex items-center gap-x-1">
98
165
  {#if logo.src && !menubar.collapsed}
99
166
  <img src={logo.src} alt={logo.title} class="size-8 shrink-0" />
100
167
  {/if}
101
- {#if logo.title && !menubar.collapsed}
102
- <h1 class="text-xl font-bold text-white">{logo.title}</h1>
168
+ {#if logo.title}
169
+ <h1
170
+ class="overflow-hidden text-xl font-bold whitespace-nowrap text-white transition-all duration-200 ease-out"
171
+ class:max-w-0={menubar.collapsed}
172
+ class:opacity-0={menubar.collapsed}
173
+ class:max-w-xs={!menubar.collapsed}
174
+ class:opacity-100={!menubar.collapsed}
175
+ aria-hidden={menubar.collapsed}
176
+ >
177
+ {logo.title}
178
+ </h1>
103
179
  {/if}
104
180
  </div>
105
181
  <button
@@ -1,54 +1,113 @@
1
1
  import type { ClassValue } from 'tailwind-variants';
2
2
  import type { Snippet } from 'svelte';
3
3
  import type { Component } from 'svelte';
4
+ /** Collapse state passed to sidebar snippets. */
4
5
  export type MenuBar = {
5
6
  collapsed: boolean;
6
7
  };
8
+ /** Shared fields for any named navigation entry. */
7
9
  export interface BaseNavigationItem {
8
10
  label: string;
9
11
  }
12
+ /** Mixin for entries that can render a leading icon. */
10
13
  export interface WithIcon {
11
14
  Icon?: Component;
12
15
  }
16
+ /** Mixin for entries that can show an "active" (current route) state. */
13
17
  export interface Activatable {
14
18
  active?: boolean;
15
19
  }
20
+ /**
21
+ * Standard clickable link in the sidebar. Most sidebars are built from
22
+ * a mix of these plus `ParentItem`s (groups) and `DividerItem`s (rules).
23
+ */
16
24
  export interface LinkItem extends BaseNavigationItem, WithIcon, Activatable {
17
25
  href: string;
26
+ /** Short text rendered to the right of the label (e.g. count, shortcut). */
18
27
  meta?: string;
19
28
  /**
20
29
  * When true, the link will be active if the current route starts with this href.
21
- * Example: href="/tushar" will be active for "/tushar/overview" and "/tushar/profile"
30
+ * Example: `href="/tushar"` will be active for `"/tushar/overview"` and `"/tushar/profile"`
22
31
  * @default false
23
32
  */
24
33
  matchPartial?: boolean;
25
34
  }
35
+ /**
36
+ * Collapsible group with child links. Active when any descendant link
37
+ * matches the current route.
38
+ */
26
39
  export interface ParentItem extends BaseNavigationItem, Activatable {
27
40
  children: LinkItem[];
28
41
  }
42
+ /** Horizontal separator between sections. */
29
43
  export interface DividerItem {
30
44
  type: 'horizontal-divider';
31
45
  }
46
+ /** Union of everything that can appear in `SidebarProps.items`. */
32
47
  export type NavigationItem = LinkItem | ParentItem | DividerItem;
48
+ /** Logo configuration rendered at the top of the sidebar. */
33
49
  export type LogoType = {
50
+ /** Image path. Omit for a text-only wordmark. */
34
51
  src?: string;
52
+ /** Wordmark shown next to (or instead of) the image. */
35
53
  title: string;
36
54
  };
55
+ /**
56
+ * Props for `<NavGroup>` — a collapsible group of nav items. Usually
57
+ * consumed via `<Sidebar>`'s `items` prop, but exposed for custom
58
+ * compositions.
59
+ */
37
60
  export interface NavGroupProps {
61
+ /** Header snippet — receives `(label, meta)` from the parent. */
38
62
  labelArea: Snippet<[string, string]>;
63
+ /** Whether the group is currently "open" (expanded). */
39
64
  active?: boolean;
40
65
  children?: Snippet;
41
66
  class?: ClassValue;
42
67
  testId?: string;
43
68
  }
69
+ /**
70
+ * Props for `<NavItem>` — a single navigation link. Usually consumed via
71
+ * `<Sidebar>`, but exposed for custom navigation compositions.
72
+ */
44
73
  export interface NavItemProps {
45
74
  href: string;
46
75
  active?: boolean;
76
+ /** Item content — receives a class value so custom children can style consistently. */
47
77
  children: Snippet<[ClassValue]>;
48
78
  class?: ClassValue;
49
79
  testId?: string;
50
80
  }
81
+ /**
82
+ * Props for `<Sidebar>` — a left-rail navigation. Supports collapse
83
+ * (icon-only), nested groups, dividers, and custom below-logo / footer
84
+ * snippets (for market selectors, user menus, etc.).
85
+ *
86
+ * @example
87
+ * ```svelte
88
+ * <Sidebar
89
+ * logo={{ src: '/logo.svg', title: 'Clark' }}
90
+ * items={[
91
+ * { label: 'Dashboard', href: '/', Icon: HomeIcon, matchPartial: false },
92
+ * { label: 'Reports', href: '/reports', Icon: ReportIcon, matchPartial: true },
93
+ * { type: 'horizontal-divider' },
94
+ * {
95
+ * label: 'Admin',
96
+ * children: [
97
+ * { label: 'Users', href: '/admin/users', Icon: UsersIcon },
98
+ * { label: 'Billing', href: '/admin/billing', Icon: BillIcon }
99
+ * ]
100
+ * }
101
+ * ]}
102
+ * >
103
+ * {#snippet footer({ collapsed })}
104
+ * <UserMenu compact={collapsed} />
105
+ * {/snippet}
106
+ * </Sidebar>
107
+ * ```
108
+ */
51
109
  export interface SidebarProps {
110
+ /** Navigation items in render order. */
52
111
  items?: NavigationItem[];
53
112
  logo: LogoType;
54
113
  /** Optional snippet between the logo row and the navigation (receives collapse state) */
@@ -0,0 +1,288 @@
1
+ <script lang="ts">
2
+ import { cn } from '../../helper/cls.js';
3
+ import { buildTestId } from '../../helper/testid.js';
4
+ import Button from '../../button/Button.svelte';
5
+ import { Color } from '../../variants.js';
6
+ import type { StepperProps, StepState, StepperStep } from './stepper-types.js';
7
+
8
+ let {
9
+ steps,
10
+ currentStep = $bindable(0),
11
+ orientation = 'horizontal',
12
+ color = Color.PRIMARY,
13
+ clickableCompleted = true,
14
+ showNav = true,
15
+ backLabel = 'Back',
16
+ nextLabel = 'Next',
17
+ finishLabel = 'Finish',
18
+ content,
19
+ onfinish,
20
+ onchange,
21
+ responsive = true,
22
+ class: className = '',
23
+ testId
24
+ }: StepperProps = $props();
25
+
26
+ function stateFor(index: number, step: StepperStep): StepState {
27
+ if (step.state) return step.state;
28
+ if (index < safeStep) return 'complete';
29
+ if (index === safeStep) return 'active';
30
+ return 'upcoming';
31
+ }
32
+
33
+ // Clamp `currentStep` to valid bounds so an out-of-range bound value
34
+ // (or a parent that shrinks `steps`) can't leave us with an undefined
35
+ // `activeStep` while `canAdvance` reports true. Empty `steps` resolves
36
+ // to 0, which `steps[0]` tolerates (returns undefined handled below).
37
+ const safeStep = $derived(
38
+ steps.length === 0 ? 0 : Math.min(Math.max(currentStep, 0), steps.length - 1)
39
+ );
40
+
41
+ const activeStep = $derived(steps[safeStep]);
42
+ const isLast = $derived(safeStep === steps.length - 1);
43
+ const canAdvance = $derived(!!activeStep && !(activeStep.disabled ?? false));
44
+ const canGoBack = $derived(safeStep > 0);
45
+
46
+ // Fire `onchange` whenever the effective step index changes from any
47
+ // source — parent updates, `safeStep` clamping after `steps` shrinks,
48
+ // or our own `goTo()`. Docs promise "from any source", so handling it
49
+ // once here (instead of only inside `goTo`) keeps that contract.
50
+ let lastReportedStep: number | undefined = undefined;
51
+ $effect(() => {
52
+ if (lastReportedStep === undefined) {
53
+ lastReportedStep = safeStep;
54
+ return;
55
+ }
56
+ if (safeStep !== lastReportedStep) {
57
+ lastReportedStep = safeStep;
58
+ onchange?.(safeStep);
59
+ }
60
+ });
61
+
62
+ function goTo(index: number) {
63
+ if (index < 0 || index >= steps.length) return;
64
+ if (index === currentStep) return;
65
+ currentStep = index;
66
+ // `onchange` fires via the effect above.
67
+ }
68
+
69
+ function handleMarkerClick(index: number, state: StepState) {
70
+ // Require both `clickableCompleted` and `index < safeStep` so a
71
+ // consumer-supplied `state: 'complete'` on a future step can't
72
+ // unlock forward navigation — only backward jumps are allowed.
73
+ if (index < safeStep && state === 'complete' && clickableCompleted) goTo(index);
74
+ }
75
+
76
+ function handleNext() {
77
+ if (!canAdvance) return;
78
+ if (isLast) {
79
+ onfinish?.();
80
+ return;
81
+ }
82
+ goTo(safeStep + 1);
83
+ }
84
+
85
+ function handleBack() {
86
+ if (!canGoBack) return;
87
+ goTo(safeStep - 1);
88
+ }
89
+
90
+ const colorClasses = $derived(
91
+ {
92
+ [Color.DEFAULT]: { bg: 'bg-default-600', ring: 'ring-default-600', text: 'text-default-600' },
93
+ [Color.PRIMARY]: { bg: 'bg-primary-500', ring: 'ring-primary-500', text: 'text-primary-600' },
94
+ [Color.SECONDARY]: {
95
+ bg: 'bg-secondary-500',
96
+ ring: 'ring-secondary-500',
97
+ text: 'text-secondary-600'
98
+ },
99
+ [Color.INFO]: { bg: 'bg-info-500', ring: 'ring-info-500', text: 'text-info-600' },
100
+ [Color.SUCCESS]: { bg: 'bg-success-500', ring: 'ring-success-500', text: 'text-success-600' },
101
+ [Color.WARNING]: { bg: 'bg-warning-500', ring: 'ring-warning-500', text: 'text-warning-600' },
102
+ [Color.DANGER]: { bg: 'bg-danger-500', ring: 'ring-danger-500', text: 'text-danger-600' }
103
+ }[color]
104
+ );
105
+
106
+ function markerClass(state: StepState): string {
107
+ // `[&_svg]:size-4` constrains consumer-provided icon components so
108
+ // they match the built-in checkmark (size-4) inside the size-8 circle.
109
+ const base =
110
+ 'flex size-8 items-center justify-center rounded-full border text-sm font-semibold transition-colors [&_svg]:size-4';
111
+ if (state === 'complete') return `${base} ${colorClasses.bg} border-transparent text-white`;
112
+ if (state === 'active')
113
+ return `${base} ${colorClasses.text} bg-white ring-2 ring-offset-2 ${colorClasses.ring} border-transparent`;
114
+ if (state === 'error') return `${base} bg-danger-500 border-transparent text-white`;
115
+ return `${base} border-default-300 bg-white text-default-400`;
116
+ }
117
+
118
+ function connectorBg(index: number): string {
119
+ return index < safeStep ? colorClasses.bg : 'bg-default-200';
120
+ }
121
+
122
+ // When responsive, the full list hides below `sm` and the compact
123
+ // indicator takes its place. Vertical doesn't need this — it already
124
+ // fits narrow viewports.
125
+ const horizontalListClass = $derived(
126
+ cn('flex flex-row items-start', responsive && 'hidden sm:flex')
127
+ );
128
+ </script>
129
+
130
+ <div
131
+ class={cn('flex flex-col gap-4', className)}
132
+ data-testid={buildTestId('stepper', undefined, testId)}
133
+ >
134
+ {#if orientation === 'horizontal'}
135
+ {#if responsive}
136
+ <!-- Compact indicator: dots + "Step X of Y · Label", shown only below sm. -->
137
+ <div
138
+ class="flex items-center gap-3 sm:hidden"
139
+ data-testid={buildTestId('stepper', 'compact', testId)}
140
+ >
141
+ <div class="flex items-center gap-1.5" aria-hidden="true">
142
+ {#each steps as _s, i (i)}
143
+ <span
144
+ class={cn(
145
+ 'size-2 rounded-full transition-colors',
146
+ i < safeStep
147
+ ? colorClasses.bg
148
+ : i === safeStep
149
+ ? cn(colorClasses.bg, 'ring-2 ring-offset-1', colorClasses.ring)
150
+ : 'bg-default-200'
151
+ )}
152
+ ></span>
153
+ {/each}
154
+ </div>
155
+ {#if activeStep}
156
+ <span class="text-default-800 text-sm font-medium">
157
+ Step {safeStep + 1} of {steps.length}: {activeStep.label}
158
+ </span>
159
+ {/if}
160
+ </div>
161
+ {/if}
162
+
163
+ <ol class={horizontalListClass} aria-label="Progress">
164
+ {#each steps as step, i (step.label + i)}
165
+ {@const state = stateFor(i, step)}
166
+ {@const canClick = i < safeStep && state === 'complete' && clickableCompleted}
167
+ {@const notLast = i < steps.length - 1}
168
+ <li class="flex flex-1 items-start last:flex-none">
169
+ <div class="flex flex-col items-center gap-2">
170
+ <button
171
+ type="button"
172
+ class={cn(markerClass(state), canClick && 'cursor-pointer hover:brightness-95')}
173
+ disabled={!canClick}
174
+ aria-current={state === 'active' ? 'step' : undefined}
175
+ aria-label={step.label}
176
+ onclick={() => handleMarkerClick(i, state)}
177
+ >
178
+ {#if state === 'complete'}
179
+ <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
180
+ <path
181
+ fill-rule="evenodd"
182
+ d="M16.704 5.295a1 1 0 0 1 .001 1.414l-7.5 7.516a1 1 0 0 1-1.414 0l-3.5-3.5a1 1 0 1 1 1.414-1.414l2.793 2.793 6.793-6.809a1 1 0 0 1 1.414 0z"
183
+ clip-rule="evenodd"
184
+ />
185
+ </svg>
186
+ {:else if step.icon}
187
+ {@const Icon = step.icon}
188
+ <Icon />
189
+ {:else}
190
+ {i + 1}
191
+ {/if}
192
+ </button>
193
+ <div class="flex flex-col text-center">
194
+ <span
195
+ class={cn(
196
+ 'text-sm font-medium',
197
+ state === 'upcoming' ? 'text-default-500' : 'text-default-800'
198
+ )}
199
+ >
200
+ {step.label}
201
+ </span>
202
+ {#if step.description}
203
+ <span class="text-default-500 text-xs">{step.description}</span>
204
+ {/if}
205
+ </div>
206
+ </div>
207
+ {#if notLast}
208
+ <!-- mt-4 = half the size-8 marker so the line centres on the circle. -->
209
+ <div
210
+ class={cn('mx-2 mt-4 h-0.5 flex-1 transition-colors', connectorBg(i))}
211
+ aria-hidden="true"
212
+ ></div>
213
+ {/if}
214
+ </li>
215
+ {/each}
216
+ </ol>
217
+ {:else}
218
+ <!-- Vertical: marker column owns the connector so flex-1 stretches it to the next marker. -->
219
+ <ol class="flex flex-col" aria-label="Progress">
220
+ {#each steps as step, i (step.label + i)}
221
+ {@const state = stateFor(i, step)}
222
+ {@const canClick = i < safeStep && state === 'complete' && clickableCompleted}
223
+ {@const notLast = i < steps.length - 1}
224
+ <li class="flex items-stretch gap-3">
225
+ <div class="flex flex-col items-center">
226
+ <button
227
+ type="button"
228
+ class={cn(markerClass(state), canClick && 'cursor-pointer hover:brightness-95')}
229
+ disabled={!canClick}
230
+ aria-current={state === 'active' ? 'step' : undefined}
231
+ aria-label={step.label}
232
+ onclick={() => handleMarkerClick(i, state)}
233
+ >
234
+ {#if state === 'complete'}
235
+ <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
236
+ <path
237
+ fill-rule="evenodd"
238
+ d="M16.704 5.295a1 1 0 0 1 .001 1.414l-7.5 7.516a1 1 0 0 1-1.414 0l-3.5-3.5a1 1 0 1 1 1.414-1.414l2.793 2.793 6.793-6.809a1 1 0 0 1 1.414 0z"
239
+ clip-rule="evenodd"
240
+ />
241
+ </svg>
242
+ {:else if step.icon}
243
+ {@const Icon = step.icon}
244
+ <Icon />
245
+ {:else}
246
+ {i + 1}
247
+ {/if}
248
+ </button>
249
+ {#if notLast}
250
+ <div
251
+ class={cn('my-2 w-0.5 flex-1 transition-colors', connectorBg(i))}
252
+ aria-hidden="true"
253
+ ></div>
254
+ {/if}
255
+ </div>
256
+ <div class={cn('flex flex-col pt-0.5', notLast && 'pb-6')}>
257
+ <span
258
+ class={cn(
259
+ 'text-sm font-medium',
260
+ state === 'upcoming' ? 'text-default-500' : 'text-default-800'
261
+ )}
262
+ >
263
+ {step.label}
264
+ </span>
265
+ {#if step.description}
266
+ <span class="text-default-500 text-xs">{step.description}</span>
267
+ {/if}
268
+ </div>
269
+ </li>
270
+ {/each}
271
+ </ol>
272
+ {/if}
273
+
274
+ {#if content && activeStep}
275
+ <div data-testid={buildTestId('stepper', 'content', testId)}>
276
+ {@render content(safeStep, activeStep)}
277
+ </div>
278
+ {/if}
279
+
280
+ {#if showNav}
281
+ <div class="flex items-center justify-between">
282
+ <Button variant="outline" onclick={handleBack} disabled={!canGoBack}>{backLabel}</Button>
283
+ <Button {color} onclick={handleNext} disabled={!canAdvance}>
284
+ {isLast ? finishLabel : nextLabel}
285
+ </Button>
286
+ </div>
287
+ {/if}
288
+ </div>
@@ -0,0 +1,4 @@
1
+ import type { StepperProps } from './stepper-types.js';
2
+ declare const Stepper: import("svelte").Component<StepperProps, {}, "currentStep">;
3
+ type Stepper = ReturnType<typeof Stepper>;
4
+ export default Stepper;
@@ -0,0 +1,80 @@
1
+ import type { ClassValue } from 'tailwind-variants';
2
+ import type { Snippet } from 'svelte';
3
+ import type { Component } from 'svelte';
4
+ import type { VariantColors } from '../../index.js';
5
+ export type StepperStep = {
6
+ /** Short title shown under the step marker. */
7
+ label: string;
8
+ /** Optional sub-label (e.g. "3 min"). */
9
+ description?: string;
10
+ /**
11
+ * Optional icon rendered inside the marker instead of a number. Falls
12
+ * back to the index when omitted.
13
+ */
14
+ icon?: Component;
15
+ /**
16
+ * Explicit state override. When omitted, state is derived from
17
+ * `currentStep`:
18
+ * - index < currentStep → `'complete'`
19
+ * - index === currentStep → `'active'`
20
+ * - index > currentStep → `'upcoming'`
21
+ */
22
+ state?: StepState;
23
+ /**
24
+ * When true, the user cannot advance past this step (next button is
25
+ * disabled). Use to gate progression on validation.
26
+ */
27
+ disabled?: boolean;
28
+ };
29
+ export type StepState = 'complete' | 'active' | 'upcoming' | 'error';
30
+ export type StepperOrientation = 'horizontal' | 'vertical';
31
+ export type StepperProps = {
32
+ /** Ordered list of steps. */
33
+ steps: StepperStep[];
34
+ /**
35
+ * Zero-based index of the currently active step. Bindable. @default 0
36
+ */
37
+ currentStep?: number;
38
+ orientation?: StepperOrientation;
39
+ /** Color accent for the active/complete states. @default 'primary' */
40
+ color?: VariantColors;
41
+ /**
42
+ * Allow clicking completed step markers to jump back. Upcoming steps
43
+ * are never clickable. @default true
44
+ */
45
+ clickableCompleted?: boolean;
46
+ /** Show the built-in Back/Next navigation below the step list. @default true */
47
+ showNav?: boolean;
48
+ /** Back button label. @default 'Back' */
49
+ backLabel?: string;
50
+ /** Next button label. @default 'Next' */
51
+ nextLabel?: string;
52
+ /** Label for the last step's advance button. @default 'Finish' */
53
+ finishLabel?: string;
54
+ /**
55
+ * Content for the current step. Receives the current index and the
56
+ * step object so you can render per-step panels.
57
+ *
58
+ * ```svelte
59
+ * {#snippet content(index, step)}
60
+ * {#if index === 0}<StepOneForm />{/if}
61
+ * {#if index === 1}<StepTwoForm />{/if}
62
+ * {/snippet}
63
+ * ```
64
+ */
65
+ content?: Snippet<[number, StepperStep]>;
66
+ /**
67
+ * Fires when the user advances past the last step. No-op if unset.
68
+ */
69
+ onfinish?: () => void;
70
+ /** Fires whenever `currentStep` changes (from any source). */
71
+ onchange?: (index: number) => void;
72
+ /**
73
+ * Collapse to a compact "Step X of Y · Label" indicator below the
74
+ * `sm` (640px) breakpoint. Only affects `orientation: 'horizontal'`
75
+ * — vertical stepping already fits on narrow viewports. @default true
76
+ */
77
+ responsive?: boolean;
78
+ class?: ClassValue;
79
+ testId?: string;
80
+ };
@@ -0,0 +1 @@
1
+ export {};