@svadmin/ui 0.53.0 → 0.53.1

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.
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { onDestroy } from 'svelte';
2
3
  import { Copy, Check } from '@lucide/svelte';
3
4
  import { Badge } from '../ui/badge/index.js';
4
5
  import { cn } from '../../utils.js';
@@ -27,6 +28,12 @@
27
28
 
28
29
  let copied = $state(false);
29
30
  let timeoutId: ReturnType<typeof setTimeout> | null = null;
31
+ let destroyed = false;
32
+
33
+ onDestroy(() => {
34
+ destroyed = true;
35
+ if (timeoutId) clearTimeout(timeoutId);
36
+ });
30
37
 
31
38
  const formattedCode = $derived.by(() => {
32
39
  if (value == null || value === '') return '';
@@ -39,20 +46,25 @@
39
46
  }
40
47
  });
41
48
 
42
- async function handleCopy() {
49
+ async function handleCopy(event: MouseEvent) {
50
+ event.stopPropagation();
43
51
  if (!formattedCode || !copyable) return;
52
+
53
+ const writeText = globalThis.navigator?.clipboard?.writeText;
54
+ if (!writeText) return;
55
+
44
56
  try {
45
- if (typeof navigator !== 'undefined' && navigator.clipboard) {
46
- await navigator.clipboard.writeText(formattedCode);
47
- }
57
+ await writeText.call(globalThis.navigator.clipboard, formattedCode);
48
58
  } catch {
49
- // Fallback
59
+ return;
50
60
  }
51
61
 
62
+ if (destroyed) return;
52
63
  copied = true;
53
64
  if (timeoutId) clearTimeout(timeoutId);
54
65
  timeoutId = setTimeout(() => {
55
66
  copied = false;
67
+ timeoutId = null;
56
68
  }, 1500);
57
69
  }
58
70
  </script>
@@ -25,23 +25,28 @@
25
25
  class: className = '',
26
26
  }: Props = $props();
27
27
 
28
+ const normalizedPrecision = $derived(
29
+ Math.min(20, Math.max(0, Math.trunc(Number.isFinite(precision) ? precision : 1)))
30
+ );
31
+
28
32
  const numericValue = $derived.by(() => {
29
- if (value == null || value === '') return null;
30
- const num = typeof value === 'number' ? value : Number(value);
31
- return isNaN(num) ? null : num;
33
+ if (value == null) return null;
34
+ if (typeof value === 'string' && value.trim() === '') return null;
35
+ const parsed = typeof value === 'number' ? value : Number(value);
36
+ return Number.isFinite(parsed) ? parsed : null;
32
37
  });
33
38
 
34
39
  const percentNumber = $derived.by(() => {
35
40
  if (numericValue === null) return null;
36
- return scale === '1' ? numericValue * 100 : numericValue;
41
+ const scaled = scale === '1' ? numericValue * 100 : numericValue;
42
+ return Number.isFinite(scaled) ? scaled : null;
37
43
  });
38
44
 
39
- const formatted = $derived.by(() => {
40
- if (percentNumber === null) return nullLabel;
41
- return `${percentNumber.toFixed(precision)}%`;
42
- });
45
+ const formatted = $derived(
46
+ percentNumber === null ? nullLabel : `${percentNumber.toFixed(normalizedPrecision)}%`
47
+ );
43
48
 
44
- const resolvedTone = $derived.by(() => {
49
+ const resolvedTone = $derived.by((): PercentTone => {
45
50
  if (tone !== 'auto') return tone;
46
51
  if (percentNumber === null) return 'neutral';
47
52
  if (percentNumber >= 80) return 'success';
@@ -67,13 +72,12 @@
67
72
  neutral: 'bg-primary',
68
73
  };
69
74
 
70
- const clampedProgress = $derived.by(() => {
71
- if (percentNumber === null) return 0;
72
- return Math.min(100, Math.max(0, percentNumber));
73
- });
75
+ const clampedProgress = $derived(
76
+ percentNumber === null ? 0 : Math.min(100, Math.max(0, percentNumber))
77
+ );
74
78
  </script>
75
79
 
76
- {#if numericValue === null}
80
+ {#if percentNumber === null}
77
81
  <span class={cn('field-percent text-muted-foreground text-sm', className)}>{nullLabel}</span>
78
82
  {:else}
79
83
  <div class={cn('field-percent inline-flex items-center gap-2 text-sm', className)}>
@@ -81,7 +85,14 @@
81
85
  {formatted}
82
86
  </span>
83
87
  {#if showProgress}
84
- <div class="h-1.5 w-16 overflow-hidden rounded-full bg-muted">
88
+ <div
89
+ class="h-1.5 w-16 overflow-hidden rounded-full bg-muted"
90
+ role="progressbar"
91
+ aria-label="Percentage"
92
+ aria-valuemin="0"
93
+ aria-valuemax="100"
94
+ aria-valuenow={clampedProgress}
95
+ >
85
96
  <div
86
97
  class={cn('h-full transition-all duration-300', toneBarClass[resolvedTone])}
87
98
  style="width: {clampedProgress}%"
@@ -24,10 +24,16 @@
24
24
  class: className = '',
25
25
  }: Props = $props();
26
26
 
27
+ const normalizedMax = $derived(
28
+ Number.isFinite(max) ? Math.min(100, Math.max(1, Math.trunc(max))) : 5
29
+ );
30
+
27
31
  const numericValue = $derived.by(() => {
28
- if (value == null || value === '') return null;
29
- const num = typeof value === 'number' ? value : Number(value);
30
- return isNaN(num) ? null : Math.max(0, Math.min(max, num));
32
+ if (value == null) return null;
33
+ if (typeof value === 'string' && value.trim() === '') return null;
34
+ const parsed = typeof value === 'number' ? value : Number(value);
35
+ if (!Number.isFinite(parsed)) return null;
36
+ return Math.max(0, Math.min(normalizedMax, parsed));
31
37
  });
32
38
 
33
39
  const sizeClasses: Record<RatingSize, { icon: string; text: string }> = {
@@ -39,10 +45,10 @@
39
45
  const stars = $derived.by(() => {
40
46
  if (numericValue === null) return [];
41
47
  const result: ('full' | 'half' | 'empty')[] = [];
42
- for (let i = 1; i <= max; i++) {
43
- if (numericValue >= i) {
48
+ for (let index = 1; index <= normalizedMax; index += 1) {
49
+ if (numericValue >= index) {
44
50
  result.push('full');
45
- } else if (numericValue >= i - 0.5) {
51
+ } else if (numericValue >= index - 0.5) {
46
52
  result.push('half');
47
53
  } else {
48
54
  result.push('empty');
@@ -56,14 +62,18 @@
56
62
  <span class={cn('field-rating text-muted-foreground text-sm', className)}>{nullLabel}</span>
57
63
  {:else}
58
64
  <div class={cn('field-rating inline-flex items-center gap-1.5', className)}>
59
- <div class="inline-flex items-center gap-0.5 text-amber-500">
65
+ <div
66
+ class="inline-flex items-center gap-0.5 text-amber-500"
67
+ role="img"
68
+ aria-label={`${numericValue} out of ${normalizedMax}`}
69
+ >
60
70
  {#each stars as starType, idx (idx)}
61
71
  {#if starType === 'full'}
62
- <Star class={cn(sizeClasses[size].icon, 'fill-amber-500 text-amber-500')} />
72
+ <Star aria-hidden="true" class={cn(sizeClasses[size].icon, 'fill-amber-500 text-amber-500')} />
63
73
  {:else if starType === 'half'}
64
- <StarHalf class={cn(sizeClasses[size].icon, 'fill-amber-500 text-amber-500')} />
74
+ <StarHalf aria-hidden="true" class={cn(sizeClasses[size].icon, 'fill-amber-500 text-amber-500')} />
65
75
  {:else}
66
- <Star class={cn(sizeClasses[size].icon, 'text-muted-foreground/30')} />
76
+ <Star aria-hidden="true" class={cn(sizeClasses[size].icon, 'text-muted-foreground/30')} />
67
77
  {/if}
68
78
  {/each}
69
79
  </div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/ui",
3
- "version": "0.53.0",
3
+ "version": "0.53.1",
4
4
  "description": "Pre-built admin UI components — AdminApp, AutoTable, AutoForm, Sidebar, Layout",
5
5
  "type": "module",
6
6
  "sideEffects": [