svelte-comp 1.0.7 → 1.1.2

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.
@@ -48,6 +48,7 @@
48
48
  import "prismjs/components/prism-json";
49
49
  import "prismjs/components/prism-python";
50
50
  import "prismjs/themes/prism.css";
51
+ import { cx } from "../utils";
51
52
 
52
53
  type Props = {
53
54
  code?: string;
@@ -140,20 +141,27 @@
140
141
  </script>
141
142
 
142
143
  <div
143
- class="w-full border border-[var(--border-color-default)] bg-[var(--color-bg-surface)] text-[var(--color-text-default)]"
144
+ class={cx(
145
+ "w-full border border-[var(--border-color-default)] bg-[var(--color-bg-surface)]",
146
+ "text-[var(--color-text-default)]"
147
+ )}
144
148
  >
145
149
  {#if title}
146
150
  <div
147
- class="px-3 py-1 bg-[var(--color-bg-muted)] font-semibold uppercase flex items-center justify-between {TEXT[
148
- sz
149
- ]}"
151
+ class={cx(
152
+ "px-3 py-1 bg-[var(--color-bg-muted)] font-semibold uppercase flex items-center justify-between",
153
+ TEXT[sz]
154
+ )}
150
155
  >
151
156
  <div>{title}</div>
152
157
 
153
158
  {#if showCopyButton}
154
159
  <button
155
160
  onclick={copyToClipboard}
156
- class="px-3 py-0.5 text-xs rounded bg-[var(--color-primary)] text-white hover:opacity-[var(--opacity-hover)] transition"
161
+ class={cx(
162
+ "px-3 py-0.5 text-xs rounded bg-[var(--color-primary)] text-white hover:opacity-[var(--opacity-hover)]",
163
+ "transition focus-visible:ring-2 focus-visible:ring-[var(--border-color-focus)] focus:outline-none"
164
+ )}
157
165
  class:!bg-green-600={copied}
158
166
  >
159
167
  {copied ? "Copied" : "Copy"}
@@ -166,9 +174,11 @@
166
174
  {#if showLineNumbers}
167
175
  <div
168
176
  bind:this={gutterEl}
169
- class="select-none px-3 py-[12px] border-r border-[var(--border-color-default)]
170
- text-[var(--color-text-muted)] text-right overflow-hidden
171
- bg-[var(--color-bg-surface)] tabular-nums min-h-[180px] max-h-[480px]"
177
+ class={cx(
178
+ "select-none px-3 py-[12px] border-r border-[var(--border-color-default)]",
179
+ "text-[var(--color-text-muted)] text-right overflow-hidden",
180
+ "bg-[var(--color-bg-surface)] tabular-nums min-h-[180px] max-h-[480px]"
181
+ )}
172
182
  >
173
183
  {#each lines as _, i (i)}
174
184
  <div class={LINE_HEIGHT[sz]}>{i + 1}</div>
@@ -252,6 +262,14 @@
252
262
  box-sizing: border-box;
253
263
  }
254
264
 
265
+ .cv-input:focus {
266
+ outline: none;
267
+ }
268
+
269
+ .cv-input:focus-visible {
270
+ outline: none !important;
271
+ }
272
+
255
273
  /* Prism */
256
274
  .token.comment {
257
275
  color: oklch(0.937 0.019 256 / 0.45);
@@ -226,7 +226,7 @@
226
226
  {L.fileCount.replace("{n}", String(internalValue.length))}
227
227
 
228
228
  {#if multiple && internalValue.length > 1}
229
- • {L.totalSize}: {(
229
+ {L.totalSize}: {(
230
230
  Array.from(internalValue).reduce(
231
231
  (acc, file) => acc + file.size,
232
232
  0
@@ -38,13 +38,13 @@
38
38
  * @prop compact {boolean} - Enables denser sizing across controls
39
39
  * @default false
40
40
  *
41
- * @note Initial value for each field: `value[name]` в†’ `schema.default` в†’ `''` (or `false` for checkboxes).
41
+ * @note Initial value for each field: `value[name]` `schema.default` `''` (or `false` for checkboxes).
42
42
  * @note `validateOn='input'|'blur'|'submit'` controls when validators run; built-in checks: `required`, `number`, and `email` regex.
43
43
  * @note `when(form)` hides a field dynamically; hidden fields are skipped during validation.
44
44
  * @note `Select` options are coerced to strings for the underlying control; provide string values if you rely on strict equality.
45
45
  * @note Errors are rendered with stable `id`s and wired via `aria-describedby`; `invalid` flags are passed to inputs.
46
46
  * @note `expose` provides `{ reset, submit, validate, getData }`; `validate` returns `Promise<boolean>`.
47
- * @note `compact` reduces control sizes (`xs→xs`, `sm→xs`, `md→sm`, `lg→md`, `xl→lg`) and centers labels where applicable.
47
+ * @note `compact` reduces control sizes (`xsxs`, `smxs`, `mdsm`, `lgmd`, `xllg`) and centers labels where applicable.
48
48
  */
49
49
  import Field from "./Field.svelte";
50
50
  import Select from "./Select.svelte";
@@ -1,4 +1,4 @@
1
- <!-- src/lib/Hamburger.svelte -->
1
+ <!-- src/lib/Hamburger.svelte -->
2
2
  <script lang="ts">
3
3
  /**
4
4
  * @component Hamburger
@@ -32,7 +32,7 @@
32
32
  * @note Clicking outside the panel or pressing `Escape` closes the drawer.
33
33
  * @note Focus moves to the first interactive element inside the panel, is trapped while open, and returns to the trigger on close.
34
34
  * @note In controlled mode (`pressed` is defined), state changes are requested via `onOpenChange(open)`.
35
- * @note When `menuItems` is empty, a “No items” placeholder is shown.
35
+ * @note When `menuItems` is empty, a "No items" placeholder is shown.
36
36
  * @note The drawer uses `role=\"dialog\"` and `aria-modal=\"true\"`; the trigger reflects state via `aria-expanded`.
37
37
  */
38
38
  import type { Snippet } from "svelte";
@@ -30,7 +30,7 @@
30
30
  * @note Clicking outside the panel or pressing `Escape` closes the drawer.
31
31
  * @note Focus moves to the first interactive element inside the panel, is trapped while open, and returns to the trigger on close.
32
32
  * @note In controlled mode (`pressed` is defined), state changes are requested via `onOpenChange(open)`.
33
- * @note When `menuItems` is empty, a “No items” placeholder is shown.
33
+ * @note When `menuItems` is empty, a "No items" placeholder is shown.
34
34
  * @note The drawer uses `role=\"dialog\"` and `aria-modal=\"true\"`; the trigger reflects state via `aria-expanded`.
35
35
  */
36
36
  import type { Snippet } from "svelte";
@@ -1,4 +1,4 @@
1
- <!-- src/lib/Menu.svelte -->
1
+ <!-- src/lib/Menu.svelte -->
2
2
  <script lang="ts">
3
3
  /**
4
4
  * @component Menu
@@ -48,6 +48,7 @@
48
48
 
49
49
  // Refs for focus control
50
50
  let triggerRefs = $state<Record<string, HTMLButtonElement>>({});
51
+ let menuRefs = $state<Record<string, HTMLDivElement>>({});
51
52
  let itemRefs = $state<Record<string, HTMLButtonElement>>({});
52
53
  let subItemRefs = $state<Record<string, HTMLButtonElement>>({});
53
54
 
@@ -55,6 +56,7 @@
55
56
  let menuTop = $state(0);
56
57
  let menuLeft = $state(0);
57
58
 
59
+ let subMenuRefs = $state<Record<string, HTMLDivElement>>({});
58
60
  let subMenuTop = $state(0);
59
61
  let subMenuLeft = $state(0);
60
62
 
@@ -69,8 +71,10 @@
69
71
  const navBase =
70
72
  "flex items-stretch pl-2 gap-1 border-b relative z-10 bg-[var(--color-bg-surface)] text-[var(--color-text-default)] border-[var(--border-color-default)]";
71
73
 
74
+ const subMenuGutter = 8;
75
+
72
76
  const topButtonBase =
73
- "px-4 rounded-xs leading-none transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--border-color-focus)] focus-visible:outline-offset-2";
77
+ "px-4 rounded-xs leading-none transition-colors outline-none focus-visible:shadow-[inset_0_0_0_2px_var(--border-color-focus)]";
74
78
 
75
79
  const topButtonActive =
76
80
  "bg-[var(--color-bg-muted)] text-[var(--color-text-default)]";
@@ -78,10 +82,10 @@
78
82
  "hover:bg-[var(--color-bg-muted)] text-[var(--color-text-default)]";
79
83
 
80
84
  const menuStyle = $derived(
81
- `position:fixed; top:${menuTop}px; left:${menuLeft}px; width:max-content;`
85
+ `position:fixed; top:${menuTop}px; left:${menuLeft}px; width:max-content; max-width:calc(100vw - 16px);`
82
86
  );
83
87
  const subMenuStyle = $derived(
84
- `position:fixed; top:${subMenuTop}px; left:${subMenuLeft}px; width:max-content;`
88
+ `position:fixed; top:${subMenuTop}px; left:${subMenuLeft}px; width:max-content; max-width:calc(100vw - 16px);`
85
89
  );
86
90
 
87
91
  const textCls = $derived(TEXT[sz]);
@@ -136,16 +140,51 @@
136
140
  }
137
141
 
138
142
  // Positioning dropdown
139
- function updateMenuPosition(triggerEl: HTMLElement) {
143
+ function updateMenuPosition(
144
+ triggerEl: HTMLElement,
145
+ menuEl?: HTMLElement | null
146
+ ) {
140
147
  const rect = triggerEl.getBoundingClientRect();
148
+ const menuWidth = Math.min(
149
+ menuEl?.getBoundingClientRect().width ?? rect.width,
150
+ window.innerWidth - 16
151
+ );
152
+ const spaceRight = window.innerWidth - rect.left;
153
+ const spaceLeft = rect.right;
154
+ const alignRight = spaceRight < menuWidth && spaceLeft > spaceRight;
155
+ const viewportLeft = window.scrollX;
156
+ const viewportRight = window.scrollX + window.innerWidth;
157
+
141
158
  menuTop = rect.bottom + window.scrollY;
142
- menuLeft = rect.left + window.scrollX;
159
+ const targetLeft = alignRight
160
+ ? rect.right + window.scrollX - menuWidth
161
+ : rect.left + window.scrollX;
162
+ const maxLeft = viewportRight - menuWidth;
163
+ menuLeft = Math.max(viewportLeft, Math.min(targetLeft, maxLeft));
143
164
  }
144
165
 
145
- function updateSubMenuPosition(parentItemEl: HTMLElement) {
166
+ function updateSubMenuPosition(
167
+ parentItemEl: HTMLElement,
168
+ subMenuEl?: HTMLElement | null
169
+ ) {
146
170
  const rect = parentItemEl.getBoundingClientRect();
171
+ const subRect = subMenuEl?.getBoundingClientRect();
172
+ const subWidth = Math.min(
173
+ subRect?.width ?? rect.width,
174
+ window.innerWidth - 16
175
+ );
176
+ const spaceRight = window.innerWidth - rect.right;
177
+ const spaceLeft = rect.left;
178
+ const shouldFlipLeft = spaceRight < subWidth && spaceLeft > spaceRight;
179
+
147
180
  subMenuTop = rect.top + window.scrollY;
148
- subMenuLeft = rect.right + window.scrollX;
181
+ const viewportLeft = window.scrollX;
182
+ const viewportRight = window.scrollX + window.innerWidth;
183
+ const targetLeft = shouldFlipLeft
184
+ ? rect.left + window.scrollX - subWidth - subMenuGutter
185
+ : rect.right + window.scrollX + subMenuGutter;
186
+ const maxLeft = viewportRight - subWidth - subMenuGutter;
187
+ subMenuLeft = Math.max(viewportLeft, Math.min(targetLeft, maxLeft));
149
188
  }
150
189
 
151
190
  function firstActionIndex(actions: MenuAction[]) {
@@ -209,7 +248,7 @@
209
248
  activeIndex = firstIndex;
210
249
  const triggerEl = triggerRefs[menuItem.name];
211
250
  if (triggerEl) {
212
- updateMenuPosition(triggerEl);
251
+ updateMenuPosition(triggerEl, menuRefs[menuItem.name]);
213
252
  }
214
253
  if (focusFirst && firstIndex !== -1) {
215
254
  focusMenuAction(menuItem, firstIndex);
@@ -221,7 +260,7 @@
221
260
  openSub = actionId(parentAction);
222
261
  const parentEl = itemRefs[actionId(parentAction)];
223
262
  if (parentEl) {
224
- updateSubMenuPosition(parentEl);
263
+ updateSubMenuPosition(parentEl, subMenuRefs[actionId(parentAction)]);
225
264
  }
226
265
  const firstIndex = focusFirst ? firstActionIndex(parentAction.submenu) : -1;
227
266
  activeSubIndex = firstIndex;
@@ -352,10 +391,10 @@
352
391
  if (open) {
353
392
  const triggerEl = triggerRefs[open];
354
393
  if (triggerEl) {
355
- updateMenuPosition(triggerEl);
394
+ updateMenuPosition(triggerEl, menuRefs[open]);
356
395
 
357
396
  const handleScrollResize = () => {
358
- updateMenuPosition(triggerEl);
397
+ updateMenuPosition(triggerEl, menuRefs[open]);
359
398
  };
360
399
 
361
400
  window.addEventListener("scroll", handleScrollResize, true);
@@ -372,11 +411,12 @@
372
411
  $effect(() => {
373
412
  if (openSub) {
374
413
  const itemEl = itemRefs[openSub];
414
+ const subEl = subMenuRefs[openSub];
375
415
  if (itemEl) {
376
- updateSubMenuPosition(itemEl);
416
+ updateSubMenuPosition(itemEl, subEl);
377
417
 
378
418
  const handleScrollResize = () => {
379
- updateSubMenuPosition(itemEl);
419
+ updateSubMenuPosition(itemEl, subMenuRefs[openSub]);
380
420
  };
381
421
 
382
422
  window.addEventListener("scroll", handleScrollResize, true);
@@ -438,6 +478,7 @@
438
478
 
439
479
  <!-- Main Menu -->
440
480
  <div
481
+ bind:this={menuRefs[menuItem.name]}
441
482
  class={cx(
442
483
  "fixed z-50 min-w-44 p-2 rounded-xs shadow-[0_2px_4px_var(--shadow-color)] ",
443
484
  "border border-[var(--border-color-default)] bg-[var(--color-bg-surface)]"
@@ -462,6 +503,7 @@
462
503
  class={cx(
463
504
  "relative text-left rounded-xs transition-colors outline-none px-1.5 py-0.5 my-1 mr-1 min-w-full flex items-center",
464
505
  "gap-3 hover:bg-[var(--color-bg-muted)] focus-visible:bg-[var(--color-bg-muted)]",
506
+ "focus-visible:shadow-[inset_0_0_0_2px_var(--border-color-focus)]",
465
507
  textCls
466
508
  )}
467
509
  onmousedown={(e) => e.preventDefault()}
@@ -518,9 +560,10 @@
518
560
  <!-- Sub Menu -->
519
561
  {#if hasSubmenu(action) && openSub === actionId(action)}
520
562
  <div
563
+ bind:this={subMenuRefs[actionId(action)]}
521
564
  class={cx(
522
565
  "fixed z-50 min-w-44 p-2 rounded-xs shadow-[0_2px_4px_var(--shadow-color)]",
523
- "border border-[var(--border-color-default)] bg-[var(--color-bg-surface)] ml-2"
566
+ "border border-[var(--border-color-default)] bg-[var(--color-bg-surface)]"
524
567
  )}
525
568
  style={subMenuStyle}
526
569
  role="menu"
@@ -543,8 +586,7 @@
543
586
  "relative text-left rounded-xs transition-colors outline-none px-1.5 py-0.5",
544
587
  "my-1 mr-1 w-full flex items-center justify-between gap-3",
545
588
  "hover:bg-[var(--color-bg-muted)] focus-visible:bg-[var(--color-bg-muted)]",
546
- "focus-visible:ring-2 focus-visible:ring-[var(--border-color-focus)]",
547
- "focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-bg-surface)]",
589
+ "focus-visible:shadow-[inset_0_0_0_2px_var(--border-color-focus)]",
548
590
  "decoration-[var(--color-text-default)]",
549
591
  textCls
550
592
  )}
@@ -1,4 +1,4 @@
1
- <!-- src/lib/PaginatedCard.svelte -->
1
+ <!-- src/lib/PaginatedCard.svelte -->
2
2
  <script lang="ts">
3
3
  /**
4
4
  * @component PaginatedCard
@@ -13,7 +13,7 @@
13
13
  * @prop class {string} - Custom classes applied to the pagination wrapper
14
14
  * @default ""
15
15
  *
16
- * @note Displays “Page X of Y” and numbered page buttons.
16
+ * @note Displays Page X of Y and numbered page buttons.
17
17
  * @note Prev/next buttons are disabled at the edges.
18
18
  * @note Shows up to 3 numbered buttons centered around the current page.
19
19
  * @note Uses `aria-current=\"page\"` on the active page for accessibility.
@@ -0,0 +1,99 @@
1
+ <!-- src/lib/PrimaryColorSelect.svelte -->
2
+ <script lang="ts">
3
+ /**
4
+ * @component PrimaryColorSelect
5
+ * @description Theme primary-color selector built on top of Select. Provides a fixed palette,
6
+ * handles persistence, and updates the global <html> attribute.
7
+ *
8
+ * @prop sz {SizeKey} - Sizing preset passed directly to Select
9
+ * @options xs|sm|md|lg|xl
10
+ * @default sm
11
+ *
12
+ * @prop label {string} - Custom label text. Falls back to localized copy when omitted.
13
+ *
14
+ * @prop class {string} - Extra classes forwarded to the underlying Select component
15
+ * @default ""
16
+ *
17
+ * @note The palette is predefined internally (`{ value, label, swatch }`).
18
+ * @note Selected value is stored in localStorage under "primary".
19
+ * @note The `html` element receives `data-primary="{value}"` for theme styling.
20
+ * @note Uses the same onChange contract as Select and forwards palette options as-is.
21
+ */
22
+ import { getContext } from "svelte";
23
+ import Select from "./Select.svelte";
24
+ import type { PrimaryKey, PaletteOption, SizeKey } from "./types";
25
+ import { TEXTS } from "./lang";
26
+
27
+ type Props = {
28
+ sz?: SizeKey;
29
+ label?: string;
30
+ class?: string;
31
+ };
32
+
33
+ let { sz = "sm", label, class: externalClass = "" }: Props = $props();
34
+
35
+ const langCtx =
36
+ getContext<{ value: keyof typeof TEXTS } | undefined>("lang") ?? null;
37
+ const langKey = $derived(langCtx?.value ?? "en");
38
+ const L = $derived(TEXTS[langKey].components.primaryColorSelect);
39
+
40
+ const labelFinal = $derived(label ?? L.text);
41
+
42
+ const palette: PaletteOption[] = [
43
+ {
44
+ value: "default",
45
+ label: "Indigo",
46
+ swatch: "oklch(62.3% 0.214 259.8deg)",
47
+ },
48
+ { value: "cyan", label: "Cyan", swatch: "oklch(71.5% 0.143 215.221)" },
49
+ { value: "red", label: "Red", swatch: "oklch(58% 0.24 30deg)" },
50
+ { value: "green", label: "Green", swatch: "oklch(65% 0.22 140deg)" },
51
+ { value: "yellow", label: "Yellow", swatch: "oklch(75% 0.18 90deg)" },
52
+ { value: "pink", label: "Pink", swatch: "oklch(70% 0.25 350deg)" },
53
+ { value: "orange", label: "Orange", swatch: "oklch(72% 0.22 60deg)" },
54
+ { value: "purple", label: "Purple", swatch: "oklch(55% 0.22 290deg)" },
55
+ ];
56
+
57
+ const paletteMap = palette.reduce<Record<PrimaryKey, PaletteOption>>(
58
+ (acc, option) => {
59
+ acc[option.value] = option;
60
+ return acc;
61
+ },
62
+ {} as Record<PrimaryKey, PaletteOption>
63
+ );
64
+
65
+ let selected = $state<PrimaryKey>("default");
66
+ let mounted = $state(false);
67
+
68
+ function isPrimaryKey(value: unknown): value is PrimaryKey {
69
+ return typeof value === "string" && value in paletteMap;
70
+ }
71
+
72
+ $effect(() => {
73
+ if (!mounted) {
74
+ const saved = localStorage.getItem("primary");
75
+ if (isPrimaryKey(saved)) {
76
+ selected = saved;
77
+ }
78
+ mounted = true;
79
+ }
80
+ });
81
+
82
+ $effect(() => {
83
+ if (mounted) {
84
+ document.documentElement.setAttribute("data-primary", selected);
85
+ localStorage.setItem("primary", selected);
86
+ }
87
+ });
88
+ </script>
89
+
90
+ <Select
91
+ {sz}
92
+ label={labelFinal}
93
+ options={palette}
94
+ value={selected}
95
+ onChange={(v) => {
96
+ if (isPrimaryKey(v)) selected = v;
97
+ }}
98
+ class={externalClass}
99
+ />
@@ -0,0 +1,9 @@
1
+ import type { SizeKey } from "./types";
2
+ type Props = {
3
+ sz?: SizeKey;
4
+ label?: string;
5
+ class?: string;
6
+ };
7
+ declare const PrimaryColorSelect: import("svelte").Component<Props, {}, "">;
8
+ type PrimaryColorSelect = ReturnType<typeof PrimaryColorSelect>;
9
+ export default PrimaryColorSelect;
@@ -0,0 +1,191 @@
1
+ <!-- src/lib/ProgressCircle.svelte -->
2
+ <script lang="ts">
3
+ /**
4
+ * @component ProgressCircle
5
+ * @description Circular progress indicator for visualizing completion or load state (0-100). Supports indeterminate mode.
6
+ * @prop value {number} - Current progress value
7
+ * @default 0
8
+ * @prop indeterminate {boolean} - Enables spinning infinite mode
9
+ * @default false
10
+ * @prop size {number} - Diameter in px
11
+ * @default 48
12
+ * @prop stroke {number} - Stroke width in px
13
+ * @default 4
14
+ * @prop variant {ComponentVariant} - Color/style variant
15
+ * @options default|neutral|success|warning|error
16
+ * @default default
17
+ * @prop label {string} - Optional text shown in center
18
+ * @default ""
19
+ * @prop max {number} - Max progress value for normalization
20
+ * @default 100
21
+ * @prop class {string} - Extra wrapper classes
22
+ * @default ""
23
+ * @note Clamps value between 0-max
24
+ * @note Uses SVG stroke-dashoffset animation
25
+ * @note Accessible role=progressbar with aria-valuenow
26
+ * @note Works in both determinate/indeterminate modes
27
+ */
28
+ import type { HTMLAttributes } from "svelte/elements";
29
+ import { type SizeKey, type ComponentVariant, TEXT } from "./types";
30
+ import { cx, clamp } from "../utils";
31
+
32
+ type Props = HTMLAttributes<HTMLDivElement> & {
33
+ value?: number;
34
+ indeterminate?: boolean;
35
+ sz?: SizeKey;
36
+ variant?: ComponentVariant;
37
+ class?: string;
38
+ label?: string;
39
+ disabled?: boolean;
40
+ };
41
+
42
+ let {
43
+ value = 0,
44
+ indeterminate = false,
45
+ sz = "md",
46
+ variant = "default",
47
+ class: externalClass = "",
48
+ label = "",
49
+ disabled = false,
50
+ ...rest
51
+ }: Props = $props();
52
+
53
+ const sizes: Record<SizeKey, { diameter: number; stroke: number }> = {
54
+ xs: { diameter: 40, stroke: 4 },
55
+ sm: { diameter: 48, stroke: 5 },
56
+ md: { diameter: 56, stroke: 6 },
57
+ lg: { diameter: 64, stroke: 7 },
58
+ xl: { diameter: 72, stroke: 8 },
59
+ };
60
+
61
+ const pctValue = $derived(clamp(value, 0, 100));
62
+ const pctText = $derived(Math.round(pctValue));
63
+
64
+ const geometry = $derived(sizes[sz]);
65
+ const center = $derived(geometry.diameter / 2);
66
+ const radius = $derived(center - geometry.stroke / 2);
67
+ const circumference = $derived(2 * Math.PI * radius);
68
+
69
+ const dashOffset = $derived(((100 - pctValue) / 100) * circumference);
70
+ const dashArray = $derived(`${circumference} ${circumference}`);
71
+ const indeterminateDash = $derived(`${circumference * 0.3} ${circumference}`);
72
+
73
+ const strokeColor = $derived(
74
+ variant === "neutral"
75
+ ? "stroke-[var(--color-bg-secondary)]"
76
+ : "stroke-[var(--color-bg-primary)]"
77
+ );
78
+
79
+ const rootClass = $derived(
80
+ cx(
81
+ "inline-flex flex-col items-center gap-1 data-[disabled=true]:opacity-[var(--opacity-disabled)] data-[disabled=true]:cursor-not-allowed",
82
+ externalClass
83
+ )
84
+ );
85
+ </script>
86
+
87
+ <div
88
+ class={rootClass}
89
+ role="progressbar"
90
+ aria-valuemin="0"
91
+ aria-valuemax="100"
92
+ aria-valuenow={indeterminate ? undefined : pctText}
93
+ data-disabled={disabled ? "true" : undefined}
94
+ {...rest}
95
+ >
96
+ {#if label}
97
+ <span class="text-[var(--color-text-muted)] select-none {TEXT[sz]}">
98
+ {label}
99
+ </span>
100
+ {/if}
101
+
102
+ <div
103
+ class="relative inline-flex items-center justify-center"
104
+ style={`width:${geometry.diameter}px;height:${geometry.diameter}px;`}
105
+ >
106
+ <svg
107
+ class="pc-svg"
108
+ viewBox={`0 0 ${geometry.diameter} ${geometry.diameter}`}
109
+ role="presentation"
110
+ aria-hidden="true"
111
+ >
112
+ <g class="pc-rot">
113
+ <circle
114
+ class="pc-track"
115
+ cx={center}
116
+ cy={center}
117
+ r={radius}
118
+ stroke-width={geometry.stroke}
119
+ ></circle>
120
+
121
+ {#if indeterminate}
122
+ <circle
123
+ class={cx("pc-bar pc-indet", strokeColor)}
124
+ cx={center}
125
+ cy={center}
126
+ r={radius}
127
+ stroke-width={geometry.stroke}
128
+ stroke-dasharray={indeterminateDash}
129
+ ></circle>
130
+ {:else}
131
+ <circle
132
+ class={cx("pc-bar", strokeColor)}
133
+ cx={center}
134
+ cy={center}
135
+ r={radius}
136
+ stroke-width={geometry.stroke}
137
+ stroke-dasharray={dashArray}
138
+ stroke-dashoffset={dashOffset}
139
+ ></circle>
140
+ {/if}
141
+ </g>
142
+ </svg>
143
+
144
+ {#if !indeterminate}
145
+ <div
146
+ class="absolute inset-0 flex items-center justify-center text-[var(--color-text-muted)] font-medium select-none {TEXT[
147
+ sz
148
+ ]}"
149
+ >
150
+ {pctText}%
151
+ </div>
152
+ {/if}
153
+ </div>
154
+ </div>
155
+
156
+ <style>
157
+ .pc-svg {
158
+ width: 100%;
159
+ height: 100%;
160
+ }
161
+ .pc-rot {
162
+ transform: rotate(-90deg);
163
+ transform-origin: center;
164
+ }
165
+ .pc-track {
166
+ fill: none;
167
+ stroke: var(--border-color-default);
168
+ }
169
+ .pc-bar {
170
+ fill: none;
171
+ stroke-linecap: round;
172
+ transition:
173
+ stroke-dashoffset 0.25s ease,
174
+ stroke 0.2s ease;
175
+ transform-origin: center;
176
+ }
177
+ .pc-indet {
178
+ animation: pc-spin 1.2s linear infinite;
179
+ }
180
+
181
+ :global {
182
+ @keyframes pc-spin {
183
+ 0% {
184
+ transform: rotate(0deg);
185
+ }
186
+ 100% {
187
+ transform: rotate(360deg);
188
+ }
189
+ }
190
+ }
191
+ </style>
@@ -0,0 +1,39 @@
1
+ /**
2
+ * @component ProgressCircle
3
+ * @description Circular progress indicator for visualizing completion or load state (0-100). Supports indeterminate mode.
4
+ * @prop value {number} - Current progress value
5
+ * @default 0
6
+ * @prop indeterminate {boolean} - Enables spinning infinite mode
7
+ * @default false
8
+ * @prop size {number} - Diameter in px
9
+ * @default 48
10
+ * @prop stroke {number} - Stroke width in px
11
+ * @default 4
12
+ * @prop variant {ComponentVariant} - Color/style variant
13
+ * @options default|neutral|success|warning|error
14
+ * @default default
15
+ * @prop label {string} - Optional text shown in center
16
+ * @default ""
17
+ * @prop max {number} - Max progress value for normalization
18
+ * @default 100
19
+ * @prop class {string} - Extra wrapper classes
20
+ * @default ""
21
+ * @note Clamps value between 0-max
22
+ * @note Uses SVG stroke-dashoffset animation
23
+ * @note Accessible role=progressbar with aria-valuenow
24
+ * @note Works in both determinate/indeterminate modes
25
+ */
26
+ import type { HTMLAttributes } from "svelte/elements";
27
+ import { type SizeKey, type ComponentVariant } from "./types";
28
+ type Props = HTMLAttributes<HTMLDivElement> & {
29
+ value?: number;
30
+ indeterminate?: boolean;
31
+ sz?: SizeKey;
32
+ variant?: ComponentVariant;
33
+ class?: string;
34
+ label?: string;
35
+ disabled?: boolean;
36
+ };
37
+ declare const ProgressCircle: import("svelte").Component<Props, {}, "">;
38
+ type ProgressCircle = ReturnType<typeof ProgressCircle>;
39
+ export default ProgressCircle;