@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.
- package/.claude/skills/svelte-ui/SKILL.md +12 -2
- package/dist/components/Combobox/Combobox.svelte +205 -0
- package/dist/components/Combobox/Combobox.svelte.d.ts +28 -0
- package/dist/components/Combobox/index.d.ts +1 -0
- package/dist/components/Combobox/index.js +1 -0
- package/dist/components/DateRangePicker/DateRangePicker.svelte +157 -0
- package/dist/components/DateRangePicker/DateRangePicker.svelte.d.ts +31 -0
- package/dist/components/DateRangePicker/index.d.ts +2 -0
- package/dist/components/DateRangePicker/index.js +1 -0
- package/dist/components/FileUpload/FileUpload.svelte +212 -0
- package/dist/components/FileUpload/FileUpload.svelte.d.ts +25 -0
- package/dist/components/FileUpload/index.d.ts +1 -0
- package/dist/components/FileUpload/index.js +1 -0
- package/dist/components/SideBarMenu/SideBarMenu.svelte +77 -86
- package/dist/components/Stepper/Stepper.svelte +118 -0
- package/dist/components/Stepper/Stepper.svelte.d.ts +21 -0
- package/dist/components/Stepper/index.d.ts +2 -0
- package/dist/components/Stepper/index.js +1 -0
- package/dist/components/TokenInput/TokenInput.svelte +144 -0
- package/dist/components/TokenInput/TokenInput.svelte.d.ts +23 -0
- package/dist/components/TokenInput/index.d.ts +1 -0
- package/dist/components/TokenInput/index.js +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +5 -0
- package/docs/components.md +70 -0
- package/docs/usage.md +86 -0
- package/package.json +1 -1
|
@@ -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
|
-
//
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
:
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
|
547
|
-
//
|
|
548
|
-
//
|
|
549
|
-
//
|
|
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
|
-
|
|
850
|
-
|
|
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
|
-
: ''} {
|
|
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}
|
|
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}
|
|
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
|
-
<!--
|
|
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"
|
|
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
|
|
1131
|
-
<!-- Built-in hamburger — fixed top
|
|
1132
|
-
|
|
1133
|
-
to suppress
|
|
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]
|
|
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
|
-
|
|
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
|
|
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
|
-
{
|
|
1177
|
-
|
|
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 @@
|
|
|
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";
|