@spaethtech/svelte-ui 0.13.0 → 0.14.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.
@@ -0,0 +1,25 @@
1
+ import type { Snippet } from "svelte";
2
+ import type { Variant } from "../../types/variants.js";
3
+ import type { Size } from "../../types/sizes.js";
4
+ import { type Responsive } from "../../types/responsive.js";
5
+ type $$ComponentProps = {
6
+ files?: File[];
7
+ accept?: string;
8
+ multiple?: boolean;
9
+ maxSize?: number;
10
+ progress?: Record<string, number>;
11
+ disabled?: boolean;
12
+ onfiles?: (files: File[]) => void;
13
+ variant?: Variant;
14
+ size?: Responsive<Size>;
15
+ label?: string | null;
16
+ aside?: Snippet;
17
+ error?: string | Snippet | null;
18
+ description?: string | Snippet | null;
19
+ id?: string;
20
+ required?: boolean;
21
+ class?: string;
22
+ };
23
+ declare const FileUpload: import("svelte").Component<$$ComponentProps, {}, "files">;
24
+ type FileUpload = ReturnType<typeof FileUpload>;
25
+ export default FileUpload;
@@ -0,0 +1 @@
1
+ export { default as FileUpload } from "./FileUpload.svelte";
@@ -0,0 +1 @@
1
+ export { default as FileUpload } from "./FileUpload.svelte";
@@ -312,31 +312,12 @@
312
312
  activeMode = currentMode;
313
313
  });
314
314
 
315
- // Suppress the slide/width transition for a frame whenever the MODE changes (from a resize), so the
316
- // switch SNAPS instead of animating the wrong intermediate state — e.g. a labeled drawer sliding
317
- // offscreen, or an expanded rail collapsing to icon. Same-mode interactions (drawer open/close,
318
- // hover-expand) still animate because `currentMode` didn't change.
319
- let noTransition = $state(false);
320
- $effect(() => {
321
- currentMode; // re-run on mode change
322
- noTransition = true;
323
- let r2: number | undefined;
324
- const r1 = requestAnimationFrame(() => {
325
- r2 = requestAnimationFrame(() => (noTransition = false));
326
- });
327
- return () => {
328
- cancelAnimationFrame(r1);
329
- if (r2 !== undefined) cancelAnimationFrame(r2);
330
- };
331
- });
332
-
333
315
  const isStepped = $derived(currentMode === "stepped");
334
316
  const isDrawer = $derived(currentMode === "drawer" || currentMode === "stepped");
335
317
  // Dock side. `right` mirrors the frame, drawer slide, hamburger corner, popout side, and item layout.
336
318
  const dockRight = $derived(side === "right");
337
319
  const popoutSide = $derived<"left" | "right">(dockRight ? "left" : "right");
338
320
  const dockEdge = $derived(dockRight ? "right-0" : "left-0");
339
- const dockOff = $derived(dockRight ? "translate-x-full" : "-translate-x-full");
340
321
  const dockBorder = $derived(
341
322
  dockRight
342
323
  ? "[border-left:1px_solid_color-mix(in_srgb,var(--ui-color-text)_15%,transparent)]"
@@ -387,36 +368,49 @@
387
368
  // (A per-section stepper-button affordance is the planned replacement — see FUTURE.md.)
388
369
  const scrollList = "flex-1 min-h-0 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden";
389
370
 
390
- // Aside width, driven by mode (inline style robust, no Tailwind arbitrary-var class):
391
- // - stepped: full width (100%)
392
- // - collapsed icon rail: the square icon-box width
393
- // - expanded / drawer / hover-expanded rail: `max-content` so the strip fits the widest row
394
- // (icon + longest label + chevron) exactly, keeping every row's icon/label/chevron aligned.
395
- // `expandedWidth` becomes an optional MAX so a very long label truncates instead of blowing out.
396
- const contentFit = $derived(
397
- currentMode === "drawer" ||
398
- currentMode === "expanded" ||
399
- (currentMode === "icon" && effectivelyExpanded),
400
- );
401
- const asideWidthCss = $derived(
402
- currentMode === "stepped"
403
- ? "100%"
404
- : currentMode === "icon" && !effectivelyExpanded
405
- ? `${collapsedW}px`
406
- : "max-content",
407
- );
408
- const maxWidthCss = $derived(contentFit && expandedWidth != null ? `${expandedWidth}px` : "");
409
- // Slide (drawer/stepped) or width (icon hover-expand) transition — suppressed on a mode change (see
410
- // `noTransition`) so resizing between modes snaps rather than animating a wrong intermediate state.
411
- const transitionClass = $derived(
412
- noTransition
413
- ? "transition-none"
414
- : currentMode === "stepped" || currentMode === "drawer"
415
- ? "transition-transform duration-200"
416
- : currentMode === "icon" && hoverExpandActive
417
- ? "transition-[width] duration-200"
418
- : "",
419
- );
371
+ // ── Mode presentation CSS (the permanent fix for the pre-hydration flash) ──────────────────
372
+ // The mode's VISUAL properties (aside width/position/inset/z/transform, label + hamburger display)
373
+ // are emitted as a per-instance media-query stylesheet in `<svelte:head>` — so it's in the SSR HTML
374
+ // and the browser applies the correct mode at FIRST PAINT, before any hydration JS runs. That kills
375
+ // the `expanded → rail` flash at the source (no JS trick can beat the first paint of server HTML).
376
+ // `currentMode`/matchMedia (above) stay for BEHAVIOR only drawer open, `interactMode`,
377
+ // popout-vs-step, `activeMode`. Drawer-open and hover-expand layer on via the `.sbm-open`/`.sbm-hover`
378
+ // classes below (JS state, not first-paint presentation).
379
+ const modeCss = $derived.by(() => {
380
+ const A = `[data-sbm-aside="${uid}"]`;
381
+ const B = `[data-sbm-burger="${uid}"]`;
382
+ const closedX = dockRight ? "100%" : "-100%";
383
+ const insetI = dockRight ? "inset-inline:auto 0;" : "inset-inline:0 auto;";
384
+ const maxw = expandedWidth != null ? `max-width:${expandedWidth}px;` : "max-width:none;";
385
+ const asideDecl = (m: SideBarMenuMode): string =>
386
+ m === "stepped"
387
+ ? `position:absolute;inset-block:0;${insetI}width:100%;max-width:none;z-index:75;transform:translateX(${closedX});`
388
+ : m === "drawer"
389
+ ? `position:absolute;inset-block:0;${insetI}width:max-content;${maxw}z-index:75;transform:translateX(${closedX});`
390
+ : m === "icon"
391
+ ? `position:relative;inset:auto;width:${collapsedW}px;max-width:none;z-index:60;transform:none;`
392
+ : `position:relative;inset:auto;width:max-content;${maxw}z-index:60;transform:none;`;
393
+ const lbl = (m: SideBarMenuMode) => (m === "icon" ? "none" : "inline");
394
+ const affix = (m: SideBarMenuMode) => (m === "icon" ? "none" : "inline-flex");
395
+ const burger = (m: SideBarMenuMode) => (m === "stepped" || m === "drawer" ? "inline-flex" : "none");
396
+ const block = (m: SideBarMenuMode): string =>
397
+ `${A}{${asideDecl(m)}--sbm-lbl:${lbl(m)};--sbm-affix:${affix(m)};}${B}{display:${burger(m)};}`;
398
+ const map = effectiveModes;
399
+ const tiers = (Object.keys(map) as Breakpoint[])
400
+ .filter((t) => t !== "base")
401
+ .sort((a, b) => BREAKPOINT_PX[a] - BREAKPOINT_PX[b]);
402
+ let css = block(map.base ?? "expanded");
403
+ for (const t of tiers) {
404
+ const m = map[t];
405
+ if (m) css += `@media (min-width:${BREAKPOINT_PX[t]}px){${block(m)}}`;
406
+ }
407
+ // Overlay slide-in (drawer/stepped `open`) and icon hover-expand layer on via JS classes (higher
408
+ // specificity than the media rules). Label/affix spans read the mode's inherited display var.
409
+ css += `${A}.sbm-open{transform:translateX(0);}`;
410
+ css += `${A}.sbm-hover{position:absolute;inset-block:0;${insetI}width:max-content;${maxw}z-index:65;--sbm-lbl:inline;--sbm-affix:inline-flex;}`;
411
+ css += `${A} .sbm-label{display:var(--sbm-lbl,inline);}${A} .sbm-affix{display:var(--sbm-affix,inline-flex);}`;
412
+ return css;
413
+ });
420
414
 
421
415
  // ── Size axis ───────────────────────────────────────────────────
422
416
  // All sub-element sizes derive from the icon size:
@@ -543,13 +537,10 @@
543
537
  setStepLevel: (n: number) => (stepLevel = n),
544
538
  };
545
539
 
546
- // Label / chevron visibility are now driven by the active mode
547
- // instead of by responsive class strings the matchMedia effect
548
- // above keeps `currentMode` (and therefore `labelsVisible`) in sync
549
- // with the viewport. The chevron span needs `inline-flex` (not
550
- // `inline`) so its SVG stays centered; otherwise the same logic.
551
- const labelClass = $derived(labelsVisible ? "inline" : "hidden");
552
- const chevronVisibility = $derived(labelsVisible ? "inline-flex" : "hidden");
540
+ // Label / chevron visibility is driven by CSS (the `.sbm-label` / `.sbm-affix` display vars set per
541
+ // mode in `modeCss`), so it's correct at first paint. `labelsVisible` (JS) is kept only for the
542
+ // tooltip-suppression + row-padding logic below (a post-hydration refinement; the padding is clipped
543
+ // by the rail's `overflow-hidden` in icon mode anyway, so a first-paint mismatch isn't visible).
553
544
  // Row tooltips exist ONLY to surface a label the rail can't show. That's a single case: a collapsed
554
545
  // `icon` rail that does NOT hover-expand. When labels are already visible (`expanded`/`drawer`/
555
546
  // `stepped`), or the rail hover-expands to reveal them (`iconHoverExpand`), the tooltip is redundant
@@ -809,6 +800,12 @@
809
800
  }
810
801
  </script>
811
802
 
803
+ <!-- Per-instance mode stylesheet — emitted into <head> (SSR-included) so the browser paints the
804
+ correct mode at first paint, killing the pre-hydration flash. See `modeCss`. -->
805
+ <svelte:head>
806
+ {@html `<style>${modeCss}</style>`}
807
+ </svelte:head>
808
+
812
809
  <!-- Badge slot — count omitted = solid colored dot covering the icon's
813
810
  top-right corner; count present = solid pill with the number. Both
814
811
  have an opaque background that occludes the main icon underneath so
@@ -846,8 +843,9 @@
846
843
  isOpen: boolean,
847
844
  reserveIcon: boolean,
848
845
  )}
849
- {@const labelClassResolved = forceLabel ? "inline" : labelClass}
850
- {@const chevronClass = forceLabel ? "inline-flex" : chevronVisibility}
846
+ <!-- Label/affix visibility is CSS-driven: `.sbm-label`/`.sbm-affix` read the mode's `--sbm-lbl`/
847
+ `--sbm-affix` display var (set per mode in `modeCss`, correct at first paint). Popout content
848
+ resets those vars to "shown" on its own container, so popout rows are always labelled. -->
851
849
  <!-- 1rem gap before the trailing/chevron cluster (label ↔ chevron breathing room); mirrors under
852
850
  `side="right"`. -->
853
851
  {@const endGap = dockRight ? "mr-4" : "ml-4"}
@@ -865,15 +863,15 @@
865
863
  <!-- When the group reserves an icon column the box supplies the leading inset; when it doesn't,
866
864
  give the label a 1rem leading inset so it isn't flush to the edge. -->
867
865
  <span
868
- class="flex-1 min-w-0 truncate {reserveIcon ? '' : dockRight ? 'pr-4' : 'pl-4'} {dockRight
866
+ class="sbm-label flex-1 min-w-0 truncate {reserveIcon ? '' : dockRight ? 'pr-4' : 'pl-4'} {dockRight
869
867
  ? 'text-right'
870
- : ''} {labelClassResolved} {sz.text}">{item.label}</span
868
+ : ''} {sz.text}">{item.label}</span
871
869
  >
872
870
  {#if item.trailing}
873
871
  <!-- Trailing slot — a state indicator / glyph on the far end. Visible with the label (like the
874
872
  chevron); sits before the chevron on a parent row. `chevronClass` keeps it `inline-flex`. -->
875
873
  <span
876
- class="shrink-0 items-center justify-center {endGap} {sz.chevronBox} {sz.chevronSvg} {chevronClass}"
874
+ class="sbm-affix shrink-0 items-center justify-center {endGap} {sz.chevronBox} {sz.chevronSvg}"
877
875
  >{@render item.trailing()}</span
878
876
  >
879
877
  {/if}
@@ -885,9 +883,9 @@
885
883
  `chevronVisibility` (not `labelClassResolved`) so the span
886
884
  stays `inline-flex` and the SVG centers vertically. -->
887
885
  <span
888
- class="shrink-0 items-center justify-center opacity-60 transition-transform duration-150 {endGap} {isOpen
886
+ class="sbm-affix shrink-0 items-center justify-center opacity-60 transition-transform duration-150 {endGap} {isOpen
889
887
  ? 'rotate-180'
890
- : ''} {sz.chevronBox} {sz.chevronSvg} {chevronClass}"
888
+ : ''} {sz.chevronBox} {sz.chevronSvg}"
891
889
  >
892
890
  <IconChevronRight />
893
891
  </span>
@@ -918,7 +916,6 @@
918
916
  class={rowClass}
919
917
  data-sveltekit-reload={item.reload ? "" : undefined}
920
918
  aria-current={ownActive ? "page" : undefined}
921
- aria-haspopup={isParent ? "menu" : undefined}
922
919
  aria-expanded={isParent && opts.id ? isOpen : undefined}
923
920
  aria-controls={isParent && isOpen ? popoutId : undefined}
924
921
  use:tooltip={{ text: item.label, side: popoutSide, disabled: tipSuppressed || isOpen }}
@@ -959,7 +956,6 @@
959
956
  type="button"
960
957
  use:refIntoMap={opts.id}
961
958
  class="{rowClass} text-left"
962
- aria-haspopup="menu"
963
959
  aria-expanded={isOpen}
964
960
  aria-controls={isOpen ? popoutId : undefined}
965
961
  use:tooltip={{ text: item.label, side: popoutSide, disabled: tipSuppressed || isOpen }}
@@ -1075,14 +1071,14 @@
1075
1071
  {@const childReserve = anyIcon(kids)}
1076
1072
  {@const rowsShown = (win.up ? 1 : 0) + (win.end - win.start) + (win.down ? 1 : 0)}
1077
1073
  {@const bd = popoutBorder(id, group, !id.includes(">"), rowsShown)}
1078
- <!-- svelte-ignore a11y_interactive_supports_focus a11y_no_noninteractive_element_interactions -->
1074
+ <!-- Nav submenu popout — a list of links (NOT role="menu"); Tab navigates, per the disclosure-nav
1075
+ pattern. The wheel/↑/↓ handlers step an overflowing window (keydown bubbles from a focused row). -->
1076
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
1079
1077
  <div
1080
- role="menu"
1081
- tabindex="-1"
1082
1078
  id={popoutId}
1083
1079
  data-sbm-popout=""
1084
1080
  data-open={isOpen}
1085
- style="width: max-content;{expandedWidth != null ? ` max-width: ${expandedWidth}px;` : ''} max-height: {maxHeight}; --sbm-accent: {accentVar};{bd.nudge ? ` transform: translateY(${bd.nudge}px);` : ''}"
1081
+ style="width: max-content;{expandedWidth != null ? ` max-width: ${expandedWidth}px;` : ''} max-height: {maxHeight}; --sbm-accent: {accentVar}; --sbm-lbl: inline; --sbm-affix: inline-flex;{bd.nudge ? ` transform: translateY(${bd.nudge}px);` : ''}"
1086
1082
  class="overflow-hidden shadow-lg [background-color:var(--ui-color-background)] [color:var(--ui-color-text)] [border-color:color-mix(in_srgb,var(--ui-color-text)_15%,transparent)] {bd.cls}"
1087
1083
  onmouseenter={popoutOnHover ? cancelClose : undefined}
1088
1084
  onmouseleave={popoutOnHover ? scheduleClose : undefined}
@@ -1117,7 +1113,7 @@
1117
1113
  {#if win.up}{@render arrowRow(id, kids.length, -1)}{/if}
1118
1114
  <ul class="flex flex-col">
1119
1115
  {#each kids.slice(win.start, win.end) as child, i (child.label)}
1120
- <li class="list-none" role="none">
1116
+ <li class="list-none">
1121
1117
  {@render menuNode(child, `${id}>${win.start + i}`, group, childReserve, 1)}
1122
1118
  </li>
1123
1119
  {/each}
@@ -1127,15 +1123,16 @@
1127
1123
  </div>
1128
1124
  {/snippet}
1129
1125
 
1130
- {#if hamburger && isDrawer}
1131
- <!-- Built-in hamburger — fixed top-left. Only rendered when the
1132
- active mode is `drawer` or `stepped`. Set `hamburger={false}`
1133
- to suppress this when the consumer provides its own trigger. -->
1126
+ {#if hamburger}
1127
+ <!-- Built-in hamburger — fixed top corner. Rendered whenever `hamburger` is set; its DISPLAY is
1128
+ CSS-media-driven (shown only in `drawer`/`stepped` tiers, hidden otherwise) so it's correct at
1129
+ first paint. Set `hamburger={false}` to suppress it when the consumer provides its own trigger. -->
1134
1130
  <button
1135
1131
  type="button"
1132
+ data-sbm-burger={uid}
1136
1133
  class="fixed top-3 {dockRight
1137
1134
  ? 'right-3'
1138
- : 'left-3'} z-[80] inline-flex w-10 h-10 items-center justify-center cursor-pointer [background-color:var(--ui-color-background)] border [border-color:color-mix(in_srgb,var(--ui-color-text)_15%,transparent)] shadow-md [&_svg]:w-5 [&_svg]:h-5"
1135
+ : 'left-3'} z-[80] w-10 h-10 items-center justify-center cursor-pointer [background-color:var(--ui-color-background)] border [border-color:color-mix(in_srgb,var(--ui-color-text)_15%,transparent)] shadow-md [&_svg]:w-5 [&_svg]:h-5"
1139
1136
  aria-label={open ? "Close menu" : "Open menu"}
1140
1137
  aria-expanded={open}
1141
1138
  onclick={() => (open = !open)}
@@ -1165,23 +1162,17 @@
1165
1162
  <aside
1166
1163
  bind:this={asideRef}
1167
1164
  bind:clientHeight={asideH}
1168
- style="width: {asideWidthCss};{maxWidthCss ? ` max-width: ${maxWidthCss};` : ''} --sbm-accent: {accentVar};"
1165
+ data-sbm-aside={uid}
1166
+ style="--sbm-accent: {accentVar};"
1169
1167
  onmouseenter={hoverExpandActive ? openHoverExpand : undefined}
1170
1168
  onmouseleave={hoverExpandActive ? scheduleHoverCollapse : undefined}
1171
1169
  class="
1172
- flex flex-col z-[60] overflow-hidden shrink-0 {transitionClass}
1170
+ flex flex-col overflow-hidden shrink-0 transition-[transform,width] duration-200
1173
1171
  [background-color:var(--ui-color-background)]
1174
1172
  [color:var(--ui-color-text)]
1175
1173
  {dockBorder}
1176
- {currentMode === 'stepped'
1177
- ? `absolute inset-y-0 ${dockEdge} z-[75] ${open ? 'translate-x-0' : dockOff}`
1178
- : currentMode === 'drawer'
1179
- ? `absolute inset-y-0 ${dockEdge} z-[75] ${open ? 'translate-x-0' : dockOff}`
1180
- : currentMode === 'icon'
1181
- ? hoverExpandActive
1182
- ? `absolute inset-y-0 ${dockEdge} z-[65] ${effectivelyExpanded ? 'shadow-lg' : ''}`
1183
- : 'relative'
1184
- : 'relative'}
1174
+ {open ? 'sbm-open' : ''}
1175
+ {effectivelyExpanded ? 'sbm-hover shadow-lg' : ''}
1185
1176
  {additionalClass}
1186
1177
  "
1187
1178
  >
@@ -0,0 +1,118 @@
1
+ <!--
2
+ /**
3
+ * Stepper — a step indicator for a linear multi-step flow (done / current / upcoming markers +
4
+ * connectors). Display of position only; the step content + Next/Back + validation are the consumer's
5
+ * (they drive `current`). Horizontal or vertical; shared variant/size axes. See Stepper.spec.md.
6
+ */
7
+ -->
8
+ <script lang="ts" module>
9
+ import type { Snippet } from "svelte";
10
+ export type StepItem = { label: string; description?: string; icon?: Snippet };
11
+ </script>
12
+
13
+ <script lang="ts">
14
+ import IconCheck from "~icons/mdi/check";
15
+ import type { Variant } from "../../types/variants.js";
16
+ import { variantToken } from "../../types/variants.js";
17
+ import type { Size } from "../../types/sizes.js";
18
+ import { responsiveClasses, type Responsive } from "../../types/responsive.js";
19
+
20
+ let {
21
+ steps,
22
+ current = $bindable(0),
23
+ orientation = "horizontal",
24
+ variant = "primary",
25
+ size = "md",
26
+ onstep,
27
+ class: cls = "",
28
+ }: {
29
+ steps: StepItem[];
30
+ current?: number;
31
+ orientation?: "horizontal" | "vertical";
32
+ variant?: Variant;
33
+ size?: Responsive<Size>;
34
+ onstep?: (index: number) => void;
35
+ class?: string;
36
+ } = $props();
37
+
38
+ const token = $derived(variantToken[variant]);
39
+ const isH = $derived(orientation === "horizontal");
40
+
41
+ const markerSize: Record<Size, string> = {
42
+ sm: "w-6 h-6 text-[0.6875rem] [&_svg]:w-3.5 [&_svg]:h-3.5",
43
+ md: "w-8 h-8 text-sm [&_svg]:w-4 [&_svg]:h-4",
44
+ lg: "w-10 h-10 text-base [&_svg]:w-5 [&_svg]:h-5",
45
+ };
46
+ const labelSize: Record<Size, string> = { sm: "text-xs", md: "text-sm", lg: "text-base" };
47
+
48
+ const state = (i: number): "done" | "current" | "upcoming" =>
49
+ i < current ? "done" : i === current ? "current" : "upcoming";
50
+
51
+ // Marker style: done/current fill with the variant (white glyph); upcoming = ringed + muted.
52
+ const markerStyle = (i: number): string => {
53
+ const s = state(i);
54
+ return s === "upcoming"
55
+ ? "background-color: transparent; color: color-mix(in srgb, var(--ui-color-text) 55%, transparent); border-color: var(--ui-border-color);"
56
+ : `background-color: var(${token}); color: white; border-color: var(${token});`;
57
+ };
58
+ const connectorStyle = (i: number): string =>
59
+ `background-color: ${i < current ? `var(${token})` : "var(--ui-border-color)"};`;
60
+ </script>
61
+
62
+ <ol
63
+ class="flex {isH ? 'flex-row items-start' : 'flex-col'} {cls}"
64
+ aria-label="Progress"
65
+ >
66
+ {#each steps as step, i (i)}
67
+ {@const s = state(i)}
68
+ {@const last = i === steps.length - 1}
69
+
70
+ <li class="flex {isH ? 'flex-1 flex-col items-center last:flex-none' : 'gap-3'} min-w-0">
71
+ <!-- Marker row (+ horizontal connector to the right, or vertical connector below in `col`). -->
72
+ <div class="flex {isH ? 'w-full items-center' : 'flex-col items-center self-stretch'}">
73
+ <!-- svelte-ignore a11y_no_static_element_interactions (it's a real <button> when clickable) -->
74
+ <svelte:element
75
+ this={onstep ? "button" : "div"}
76
+ type={onstep ? "button" : undefined}
77
+ onclick={onstep ? () => onstep(i) : undefined}
78
+ aria-current={s === "current" ? "step" : undefined}
79
+ aria-label={onstep ? `Step ${i + 1}: ${step.label}` : undefined}
80
+ class="inline-flex shrink-0 items-center justify-center rounded-full border-2 font-semibold {responsiveClasses(
81
+ size,
82
+ markerSize,
83
+ )} {onstep
84
+ ? 'cursor-pointer focus-visible:[outline:2px_solid_color-mix(in_srgb,var(--ui-color-text)_70%,transparent)] focus-visible:[outline-offset:2px]'
85
+ : ''}"
86
+ style={markerStyle(i)}
87
+ >
88
+ {#if step.icon}{@render step.icon()}{:else if s === "done"}<IconCheck
89
+ />{:else}{i + 1}{/if}
90
+ </svelte:element>
91
+
92
+ {#if !last}
93
+ <div
94
+ class="{isH ? 'h-0.5 flex-1 mx-2' : 'w-0.5 flex-1 my-1 min-h-4'} rounded-full"
95
+ style={connectorStyle(i)}
96
+ aria-hidden="true"
97
+ ></div>
98
+ {/if}
99
+ </div>
100
+
101
+ <!-- Label + description. -->
102
+ <div class="{isH ? 'mt-2 text-center px-1' : 'pb-4'} min-w-0 {responsiveClasses(size, labelSize)}">
103
+ <div
104
+ class="truncate font-medium {s === 'upcoming'
105
+ ? '[color:color-mix(in_srgb,var(--ui-color-text)_60%,transparent)]'
106
+ : '[color:var(--ui-color-text)]'}"
107
+ >
108
+ {step.label}
109
+ </div>
110
+ {#if step.description}
111
+ <div class="text-xs [color:color-mix(in_srgb,var(--ui-color-text)_55%,transparent)]">
112
+ {step.description}
113
+ </div>
114
+ {/if}
115
+ </div>
116
+ </li>
117
+ {/each}
118
+ </ol>
@@ -0,0 +1,21 @@
1
+ import type { Snippet } from "svelte";
2
+ export type StepItem = {
3
+ label: string;
4
+ description?: string;
5
+ icon?: Snippet;
6
+ };
7
+ import type { Variant } from "../../types/variants.js";
8
+ import type { Size } from "../../types/sizes.js";
9
+ import { type Responsive } from "../../types/responsive.js";
10
+ type $$ComponentProps = {
11
+ steps: StepItem[];
12
+ current?: number;
13
+ orientation?: "horizontal" | "vertical";
14
+ variant?: Variant;
15
+ size?: Responsive<Size>;
16
+ onstep?: (index: number) => void;
17
+ class?: string;
18
+ };
19
+ declare const Stepper: import("svelte").Component<$$ComponentProps, {}, "current">;
20
+ type Stepper = ReturnType<typeof Stepper>;
21
+ export default Stepper;
@@ -0,0 +1,2 @@
1
+ export { default as Stepper } from "./Stepper.svelte";
2
+ export type { StepItem } from "./Stepper.svelte";
@@ -0,0 +1 @@
1
+ export { default as Stepper } from "./Stepper.svelte";
@@ -0,0 +1,144 @@
1
+ <!--
2
+ /**
3
+ * TokenInput — multi-value entry: type + Enter/comma to add a removable `Chip` token; Backspace on an
4
+ * empty field removes the last. Wears FieldChrome + the shared variant/size axes. Composes `Chip` for
5
+ * tokens; the text entry is a bare <input> inside the field frame (a leaf field, like Input).
6
+ * See TokenInput.spec.md.
7
+ */
8
+ -->
9
+ <script lang="ts">
10
+ import type { Snippet } from "svelte";
11
+ import FieldChrome, { nextFieldId } from "../FieldChrome.svelte";
12
+ import Chip from "../Chip/Chip.svelte";
13
+ import type { Variant } from "../../types/variants.js";
14
+ import { variantToken } from "../../types/variants.js";
15
+ import type { Size } from "../../types/sizes.js";
16
+ import { responsiveClasses, type Responsive } from "../../types/responsive.js";
17
+
18
+ let {
19
+ values = $bindable([]),
20
+ allowDuplicates = false,
21
+ max,
22
+ variant = "secondary",
23
+ size = "md",
24
+ disabled = false,
25
+ placeholder,
26
+ label = null,
27
+ aside,
28
+ error = null,
29
+ description = null,
30
+ id,
31
+ required = false,
32
+ class: cls = "",
33
+ }: {
34
+ values?: string[];
35
+ allowDuplicates?: boolean;
36
+ max?: number;
37
+ variant?: Variant;
38
+ size?: Responsive<Size>;
39
+ disabled?: boolean;
40
+ placeholder?: string;
41
+ label?: string | null;
42
+ aside?: Snippet;
43
+ error?: string | Snippet | null;
44
+ description?: string | Snippet | null;
45
+ id?: string;
46
+ required?: boolean;
47
+ class?: string;
48
+ } = $props();
49
+
50
+ const controlId = id ?? nextFieldId();
51
+ const token = $derived(variantToken[variant]);
52
+ const hasError = $derived(typeof error === "function" ? true : !!(error && String(error).trim()));
53
+
54
+ const borderRest = $derived(
55
+ hasError
56
+ ? "var(--ui-color-error)"
57
+ : `color-mix(in srgb, var(${token}) var(--ui-tint-border), transparent)`,
58
+ );
59
+ const borderFocus = $derived(
60
+ hasError
61
+ ? "var(--ui-color-error)"
62
+ : `color-mix(in srgb, var(${token}) var(--ui-tint-border-hover), transparent)`,
63
+ );
64
+
65
+ const fieldSize: Record<Size, string> = {
66
+ sm: "min-h-6 px-1.5 py-0.5 gap-1 text-xs",
67
+ md: "min-h-8 px-2 py-1 gap-1.5 text-sm",
68
+ lg: "min-h-10 px-2.5 py-1.5 gap-2 text-base",
69
+ };
70
+ const chipSize = $derived<Size>(
71
+ (typeof size === "string" ? size : "md") === "lg" ? "md" : "sm",
72
+ );
73
+
74
+ let draft = $state("");
75
+
76
+ function commit() {
77
+ const t = draft.trim();
78
+ if (!t) return;
79
+ if (!allowDuplicates && values.includes(t)) {
80
+ draft = "";
81
+ return;
82
+ }
83
+ if (max != null && values.length >= max) return;
84
+ values = [...values, t];
85
+ draft = "";
86
+ }
87
+ function removeAt(i: number) {
88
+ values = values.filter((_, j) => j !== i);
89
+ }
90
+ function onkeydown(e: KeyboardEvent) {
91
+ if (disabled) return;
92
+ if (e.key === "Enter" || e.key === ",") {
93
+ e.preventDefault();
94
+ commit();
95
+ } else if (e.key === "Backspace" && draft === "" && values.length) {
96
+ e.preventDefault();
97
+ removeAt(values.length - 1);
98
+ }
99
+ }
100
+ </script>
101
+
102
+ <FieldChrome {label} {aside} {error} {description} {required} {controlId} {size} class={cls}>
103
+ {#snippet control({ describedBy })}
104
+ <!-- A <label> so clicking anywhere in the field focuses the input natively (no JS handler). -->
105
+ <label
106
+ for={controlId}
107
+ class="tok-field flex flex-wrap items-center rounded-[var(--ui-border-radius)] border transition-colors {responsiveClasses(
108
+ size,
109
+ fieldSize,
110
+ )} {disabled ? 'opacity-50 pointer-events-none' : 'cursor-text'}"
111
+ style="--tok-border: {borderRest}; --tok-border-focus: {borderFocus};"
112
+ >
113
+ {#each values as v, i (v + i)}
114
+ <Chip
115
+ text={v}
116
+ variant={variant === "ghost" ? "secondary" : variant}
117
+ size={chipSize}
118
+ removable
119
+ {disabled}
120
+ onremove={() => removeAt(i)}
121
+ />
122
+ {/each}
123
+ <input
124
+ bind:value={draft}
125
+ id={controlId}
126
+ {disabled}
127
+ {onkeydown}
128
+ placeholder={values.length === 0 ? placeholder : undefined}
129
+ aria-label={label ?? undefined}
130
+ aria-describedby={describedBy}
131
+ class="flex-1 min-w-[4rem] bg-transparent outline-none [color:var(--ui-color-text)] placeholder:[color:color-mix(in_srgb,var(--ui-color-text)_45%,transparent)]"
132
+ />
133
+ </label>
134
+ {/snippet}
135
+ </FieldChrome>
136
+
137
+ <style>
138
+ .tok-field {
139
+ border-color: var(--tok-border);
140
+ }
141
+ .tok-field:focus-within {
142
+ border-color: var(--tok-border-focus);
143
+ }
144
+ </style>
@@ -0,0 +1,23 @@
1
+ import type { Snippet } from "svelte";
2
+ import type { Variant } from "../../types/variants.js";
3
+ import type { Size } from "../../types/sizes.js";
4
+ import { type Responsive } from "../../types/responsive.js";
5
+ type $$ComponentProps = {
6
+ values?: string[];
7
+ allowDuplicates?: boolean;
8
+ max?: number;
9
+ variant?: Variant;
10
+ size?: Responsive<Size>;
11
+ disabled?: boolean;
12
+ placeholder?: string;
13
+ label?: string | null;
14
+ aside?: Snippet;
15
+ error?: string | Snippet | null;
16
+ description?: string | Snippet | null;
17
+ id?: string;
18
+ required?: boolean;
19
+ class?: string;
20
+ };
21
+ declare const TokenInput: import("svelte").Component<$$ComponentProps, {}, "values">;
22
+ type TokenInput = ReturnType<typeof TokenInput>;
23
+ export default TokenInput;
@@ -0,0 +1 @@
1
+ export { default as TokenInput } from "./TokenInput.svelte";
@@ -0,0 +1 @@
1
+ export { default as TokenInput } from "./TokenInput.svelte";