@classic-homes/theme-svelte 0.1.53 → 0.1.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/__tests__/FileUpload.test.js +45 -0
  2. package/dist/lib/components/AlertDialog.svelte +1 -1
  3. package/dist/lib/components/AlertDialog.svelte.d.ts +2 -2
  4. package/dist/lib/components/Badge.svelte +28 -11
  5. package/dist/lib/components/Badge.svelte.d.ts +2 -0
  6. package/dist/lib/components/Button.svelte +12 -1
  7. package/dist/lib/components/Combobox.svelte +2 -2
  8. package/dist/lib/components/DataPanelHeader.svelte +4 -4
  9. package/dist/lib/components/DataPanelTab.svelte +1 -1
  10. package/dist/lib/components/DataTable.svelte +7 -7
  11. package/dist/lib/components/DataTable.svelte.d.ts +5 -5
  12. package/dist/lib/components/DateTimePicker.svelte +3 -3
  13. package/dist/lib/components/Dialog.svelte +5 -5
  14. package/dist/lib/components/DropdownMenu.svelte +2 -2
  15. package/dist/lib/components/FileUpload.svelte +35 -1
  16. package/dist/lib/components/FileUpload.svelte.d.ts +6 -0
  17. package/dist/lib/components/HeaderSearch.svelte +9 -9
  18. package/dist/lib/components/Input.svelte +13 -0
  19. package/dist/lib/components/MultiSelect.svelte +2 -2
  20. package/dist/lib/components/NumberInput.svelte +4 -4
  21. package/dist/lib/components/PasswordInput.svelte +12 -0
  22. package/dist/lib/components/PasswordInput.svelte.d.ts +4 -0
  23. package/dist/lib/components/RadioGroup.svelte +5 -5
  24. package/dist/lib/components/Select.svelte +13 -1
  25. package/dist/lib/components/Signature.svelte +5 -5
  26. package/dist/lib/components/Tabs.svelte +1 -1
  27. package/dist/lib/components/Toast.svelte +1 -1
  28. package/dist/lib/components/layout/AuthLayout.svelte +1 -1
  29. package/dist/lib/components/layout/DashboardLayout.svelte +1 -1
  30. package/dist/lib/components/layout/Footer.svelte +1 -1
  31. package/dist/lib/components/layout/PublicLayout.svelte +2 -2
  32. package/dist/lib/components/layout/QuickLinks.svelte +3 -3
  33. package/dist/lib/components/layout/Sidebar.svelte +1 -1
  34. package/dist/lib/components/layout/sidebar/SidebarFlyout.svelte +1 -2
  35. package/dist/lib/components/layout/sidebar/SidebarNavItem.svelte +20 -21
  36. package/dist/lib/components/layout/sidebar/SidebarSection.svelte +2 -2
  37. package/dist/lib/components/notifications/NotificationFilters.svelte +1 -1
  38. package/dist/lib/components/notifications/NotificationItem.svelte +1 -1
  39. package/dist/lib/utils.d.ts +4 -2
  40. package/dist/lib/utils.js +5 -11
  41. package/package.json +3 -3
@@ -0,0 +1,45 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { render, screen } from '@testing-library/svelte';
3
+ import FileUpload from '../lib/components/FileUpload.svelte';
4
+ describe('FileUpload accepted-types label', () => {
5
+ it('derives a friendly label from MIME types instead of showing raw values', () => {
6
+ render(FileUpload, {
7
+ props: {
8
+ label: 'Upload files',
9
+ acceptedTypes: [
10
+ 'image/jpeg',
11
+ 'image/png',
12
+ 'application/pdf',
13
+ 'video/mp4',
14
+ 'video/quicktime',
15
+ ],
16
+ },
17
+ });
18
+ expect(screen.getByText(/Images, PDF, Videos \(max/)).toBeInTheDocument();
19
+ // Raw MIME types should not be shown
20
+ expect(screen.queryByText(/image\/jpeg/)).not.toBeInTheDocument();
21
+ });
22
+ it('uses the default accepted types (Images, PDF) when none are provided', () => {
23
+ render(FileUpload, { props: { label: 'Upload files' } });
24
+ expect(screen.getByText(/^Images, PDF \(max/)).toBeInTheDocument();
25
+ });
26
+ it('respects an explicit typesLabel override', () => {
27
+ render(FileUpload, {
28
+ props: {
29
+ label: 'Upload files',
30
+ acceptedTypes: ['image/png', 'video/mp4'],
31
+ typesLabel: 'Photos and clips',
32
+ },
33
+ });
34
+ expect(screen.getByText(/^Photos and clips \(max/)).toBeInTheDocument();
35
+ });
36
+ it('falls back to the uppercased subtype for unmapped types', () => {
37
+ render(FileUpload, {
38
+ props: {
39
+ label: 'Upload files',
40
+ acceptedTypes: ['application/zip'],
41
+ },
42
+ });
43
+ expect(screen.getByText(/^ZIP \(max/)).toBeInTheDocument();
44
+ });
45
+ });
@@ -5,7 +5,7 @@
5
5
  import { tv, type VariantProps } from 'tailwind-variants';
6
6
 
7
7
  const buttonVariants = tv({
8
- base: 'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
8
+ base: 'inline-flex items-center justify-center rounded-md text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
9
9
  variants: {
10
10
  variant: {
11
11
  default: 'bg-primary text-primary-foreground hover:bg-primary-hover',
@@ -21,7 +21,7 @@ declare const AlertDialog: import("svelte").Component<{
21
21
  outline: string;
22
22
  ghost: string;
23
23
  };
24
- }, undefined, "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", import("tailwind-variants/dist/config.js").TVConfig<{
24
+ }, undefined, "inline-flex items-center justify-center rounded-md text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", import("tailwind-variants/dist/config.js").TVConfig<{
25
25
  variant: {
26
26
  default: string;
27
27
  destructive: string;
@@ -49,7 +49,7 @@ declare const AlertDialog: import("svelte").Component<{
49
49
  outline: string;
50
50
  ghost: string;
51
51
  };
52
- }, undefined, "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", import("tailwind-variants/dist/config.js").TVConfig<{
52
+ }, undefined, "inline-flex items-center justify-center rounded-md text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", import("tailwind-variants/dist/config.js").TVConfig<{
53
53
  variant: {
54
54
  default: string;
55
55
  destructive: string;
@@ -4,16 +4,16 @@
4
4
  import { tv, type VariantProps } from 'tailwind-variants';
5
5
 
6
6
  const badgeVariants = tv({
7
- base: 'inline-flex items-center justify-center rounded-md border font-medium w-fit whitespace-nowrap shrink-0 gap-1 transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
7
+ base: 'inline-flex items-center justify-center rounded-md border font-medium w-fit whitespace-nowrap shrink-0 gap-1',
8
8
  variants: {
9
9
  variant: {
10
10
  default: 'border-transparent bg-primary text-primary-foreground',
11
11
  secondary: 'border-transparent bg-secondary text-secondary-foreground',
12
- destructive: 'border-transparent bg-destructive text-white',
12
+ destructive: 'border-transparent bg-destructive text-destructive-foreground',
13
13
  outline: 'text-foreground',
14
- success: 'border-transparent bg-green-500 text-white',
15
- warning: 'border-transparent bg-yellow-500 text-white',
16
- info: 'border-transparent bg-blue-500 text-white',
14
+ success: 'border-transparent bg-success text-success-foreground',
15
+ warning: 'border-transparent bg-warning text-warning-foreground',
16
+ info: 'border-transparent bg-info text-info-foreground',
17
17
  },
18
18
  size: {
19
19
  default: 'px-2 py-0.5 text-xs',
@@ -21,7 +21,7 @@
21
21
  dot: 'h-5 w-5 p-0 text-xs',
22
22
  },
23
23
  clickable: {
24
- true: 'cursor-pointer',
24
+ true: 'cursor-pointer focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
25
25
  false: '',
26
26
  },
27
27
  },
@@ -34,9 +34,9 @@
34
34
  clickable: true,
35
35
  class: 'hover:bg-accent-hover hover:text-accent-foreground',
36
36
  },
37
- { variant: 'success', clickable: true, class: 'hover:bg-green-600' },
38
- { variant: 'warning', clickable: true, class: 'hover:bg-yellow-600' },
39
- { variant: 'info', clickable: true, class: 'hover:bg-blue-600' },
37
+ { variant: 'success', clickable: true, class: 'hover:bg-success/80' },
38
+ { variant: 'warning', clickable: true, class: 'hover:bg-warning/80' },
39
+ { variant: 'info', clickable: true, class: 'hover:bg-info/80' },
40
40
  ],
41
41
  defaultVariants: {
42
42
  variant: 'default',
@@ -51,6 +51,8 @@
51
51
  variant?: BadgeVariants['variant'];
52
52
  size?: BadgeVariants['size'];
53
53
  clickable?: boolean;
54
+ /** When true, adds role="status" and aria-live="polite" for dynamic content announcements */
55
+ live?: boolean;
54
56
  class?: string;
55
57
  children: Snippet;
56
58
  onclick?: (e: MouseEvent) => void;
@@ -67,6 +69,7 @@
67
69
  variant = 'default',
68
70
  size = 'default',
69
71
  clickable = false,
72
+ live = false,
70
73
  class: className,
71
74
  children,
72
75
  onclick,
@@ -81,12 +84,26 @@
81
84
  </script>
82
85
 
83
86
  {#if isInteractive}
84
- <button type="button" class={classes} {onclick} {...restProps}>
87
+ <button
88
+ type="button"
89
+ class={classes}
90
+ {onclick}
91
+ role={live ? 'status' : undefined}
92
+ aria-live={live ? 'polite' : undefined}
93
+ aria-atomic={live ? 'true' : undefined}
94
+ {...restProps}
95
+ >
85
96
  {@render children()}
86
97
  {#if label}<span class="sr-only">{label}</span>{/if}
87
98
  </button>
88
99
  {:else}
89
- <span class={classes} {...restProps}>
100
+ <span
101
+ class={classes}
102
+ role={live ? 'status' : undefined}
103
+ aria-live={live ? 'polite' : undefined}
104
+ aria-atomic={live ? 'true' : undefined}
105
+ {...restProps}
106
+ >
90
107
  {@render children()}
91
108
  {#if label}<span class="sr-only">{label}</span>{/if}
92
109
  </span>
@@ -4,6 +4,8 @@ declare const Badge: import("svelte").Component<{
4
4
  variant?: "default" | "secondary" | "destructive" | "outline" | "info" | "warning" | "success" | undefined;
5
5
  size?: "default" | "sm" | "dot" | undefined;
6
6
  clickable?: boolean;
7
+ /** When true, adds role="status" and aria-live="polite" for dynamic content announcements */
8
+ live?: boolean;
7
9
  class?: string;
8
10
  children: Snippet;
9
11
  onclick?: (e: MouseEvent) => void;
@@ -4,7 +4,7 @@
4
4
  import { tv, type VariantProps } from 'tailwind-variants';
5
5
 
6
6
  const buttonVariants = tv({
7
- base: 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
7
+ base: 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
8
8
  variants: {
9
9
  variant: {
10
10
  default: 'bg-primary text-primary-foreground shadow hover:bg-primary-hover',
@@ -63,6 +63,17 @@
63
63
  }: Props = $props();
64
64
 
65
65
  const classes = $derived(cn(buttonVariants({ variant, size }), className));
66
+
67
+ // Dev-mode a11y warning for icon buttons without accessible labels
68
+ if (typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production') {
69
+ $effect(() => {
70
+ if (size === 'icon' && !restProps['aria-label'] && !restProps['aria-labelledby']) {
71
+ console.warn(
72
+ 'Button: Icon-only buttons (size="icon") should have an aria-label or aria-labelledby for accessibility.'
73
+ );
74
+ }
75
+ });
76
+ }
66
77
  </script>
67
78
 
68
79
  {#snippet loadingSpinner()}
@@ -199,7 +199,7 @@
199
199
  value={option.value}
200
200
  label={option.label}
201
201
  disabled={option.disabled}
202
- class="relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-default"
202
+ class="relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-default"
203
203
  >
204
204
  {#if option.value === value}
205
205
  <span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
@@ -291,7 +291,7 @@
291
291
  value={option.value}
292
292
  label={option.label}
293
293
  disabled={option.disabled}
294
- class="relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-default"
294
+ class="relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-default"
295
295
  >
296
296
  {#if option.value === value}
297
297
  <span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
@@ -89,7 +89,7 @@
89
89
  {#snippet trigger()}
90
90
  <button
91
91
  type="button"
92
- class="flex h-8 w-8 items-center justify-center rounded-md bg-transparent text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
92
+ class="flex h-8 w-8 items-center justify-center rounded-md bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
93
93
  aria-label="Panel options"
94
94
  >
95
95
  <svg
@@ -141,7 +141,7 @@
141
141
  <!-- Collapse/Expand button -->
142
142
  <button
143
143
  type="button"
144
- class="flex h-8 w-8 items-center justify-center rounded-md bg-transparent text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
144
+ class="flex h-8 w-8 items-center justify-center rounded-md bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
145
145
  onclick={() => onExpandedChange?.(!isExpanded)}
146
146
  aria-label={isExpanded ? 'Collapse panel' : 'Expand panel'}
147
147
  aria-expanded={isExpanded}
@@ -168,7 +168,7 @@
168
168
  {#snippet trigger()}
169
169
  <button
170
170
  type="button"
171
- class="flex h-8 w-8 items-center justify-center rounded-md bg-transparent text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
171
+ class="flex h-8 w-8 items-center justify-center rounded-md bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
172
172
  aria-label="Panel options"
173
173
  >
174
174
  <svg
@@ -195,7 +195,7 @@
195
195
  {#if !disableClose}
196
196
  <button
197
197
  type="button"
198
- class="flex h-8 w-8 items-center justify-center rounded-md bg-transparent text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
198
+ class="flex h-8 w-8 items-center justify-center rounded-md bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
199
199
  onclick={() => onClose?.()}
200
200
  aria-label="Close panel"
201
201
  >
@@ -32,7 +32,7 @@
32
32
  <button
33
33
  type="button"
34
34
  class={cn(
35
- 'fixed z-40 flex items-center justify-center gap-2 border border-border bg-background px-3 py-2 text-sm font-medium shadow-md transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
35
+ 'fixed z-40 flex items-center justify-center gap-2 border border-border bg-background px-3 py-2 text-sm font-medium shadow-md hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
36
36
  positionClasses,
37
37
  isVertical && 'writing-mode-vertical',
38
38
  className
@@ -22,11 +22,11 @@
22
22
  * ## Basic Usage
23
23
  * ```svelte
24
24
  * <DataTable
25
- * data={users}
26
- * columns={[
27
- * { id: 'name', header: 'Name', accessor: 'name', sortable: true },
28
- * { id: 'email', header: 'Email', accessor: 'email' },
29
- * ]}
25
+ * data={users}
26
+ * columns={[
27
+ * { id: 'name', header: 'Name', accessor: 'name', sortable: true },
28
+ * { id: 'email', header: 'Email', accessor: 'email' },
29
+ * ]}
30
30
  * />
31
31
  * ```
32
32
  */
@@ -218,7 +218,7 @@
218
218
  }
219
219
  if (duplicates.length > 0) {
220
220
  throw new Error(
221
- `[DataTable] Duplicate column IDs found: ${duplicates.join(', ')}. ` +
221
+ `[DataTable] Duplicate column IDs found: ${duplicates.join(',')}. ` +
222
222
  'Each column must have a unique ID.'
223
223
  );
224
224
  }
@@ -430,7 +430,7 @@
430
430
  {:else}
431
431
  <tr
432
432
  class={cn(
433
- 'bg-card transition-colors',
433
+ 'bg-card',
434
434
  onRowClick && 'cursor-pointer hover:bg-muted-hover',
435
435
  row.isSelected && 'bg-primary/5'
436
436
  )}
@@ -21,11 +21,11 @@
21
21
  * ## Basic Usage
22
22
  * ```svelte
23
23
  * <DataTable
24
- * data={users}
25
- * columns={[
26
- * { id: 'name', header: 'Name', accessor: 'name', sortable: true },
27
- * { id: 'email', header: 'Email', accessor: 'email' },
28
- * ]}
24
+ * data={users}
25
+ * columns={[
26
+ * { id: 'name', header: 'Name', accessor: 'name', sortable: true },
27
+ * { id: 'email', header: 'Email', accessor: 'email' },
28
+ * ]}
29
29
  * />
30
30
  * ```
31
31
  */
@@ -482,7 +482,7 @@
482
482
  <button
483
483
  type="button"
484
484
  onclick={prevMonth}
485
- class="h-7 w-7 inline-flex items-center justify-center rounded-md bg-transparent transition-colors duration-150 hover:bg-accent hover:text-accent-foreground"
485
+ class="h-7 w-7 inline-flex items-center justify-center rounded-md bg-transparent hover:bg-accent hover:text-accent-foreground"
486
486
  aria-label="Previous month"
487
487
  >
488
488
  <svg
@@ -506,7 +506,7 @@
506
506
  <button
507
507
  type="button"
508
508
  onclick={nextMonth}
509
- class="h-7 w-7 inline-flex items-center justify-center rounded-md bg-transparent transition-colors duration-150 hover:bg-accent hover:text-accent-foreground"
509
+ class="h-7 w-7 inline-flex items-center justify-center rounded-md bg-transparent hover:bg-accent hover:text-accent-foreground"
510
510
  aria-label="Next month"
511
511
  >
512
512
  <svg
@@ -547,7 +547,7 @@
547
547
  onclick={() => selectDate(day)}
548
548
  disabled={isDateDisabled(day)}
549
549
  class={cn(
550
- 'h-8 w-8 inline-flex items-center justify-center rounded-md text-sm bg-transparent transition-colors duration-150',
550
+ 'h-8 w-8 inline-flex items-center justify-center rounded-md text-sm bg-transparent',
551
551
  'hover:bg-accent hover:text-accent-foreground',
552
552
  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
553
553
  'disabled:pointer-events-none disabled:opacity-50',
@@ -78,11 +78,11 @@
78
78
 
79
79
  <DialogPrimitive.Portal>
80
80
  <DialogPrimitive.Overlay
81
- class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
81
+ class="fixed inset-0 z-50 bg-foreground/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
82
82
  />
83
83
  <DialogPrimitive.Content
84
84
  class={cn(
85
- 'fixed left-[50%] top-[50%] z-50 grid w-full translate-x-[-50%] translate-y-[-50%] gap-4 border border-black bg-background shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg overflow-hidden',
85
+ 'fixed left-[50%] top-[50%] z-50 grid w-full translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-background shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg overflow-hidden',
86
86
  sizeClasses[size],
87
87
  className
88
88
  )}
@@ -90,7 +90,7 @@
90
90
  <!-- Header section with title and actions -->
91
91
  {#if header || title || description || headerActions || showClose}
92
92
  <div
93
- class="flex items-start justify-between gap-4 border-b border-black bg-muted/30 px-6 py-4"
93
+ class="flex items-start justify-between gap-4 border-b border-border bg-muted/30 px-6 py-4"
94
94
  >
95
95
  <!-- Title section -->
96
96
  <div class="flex flex-col space-y-1.5 min-w-0 flex-1 overflow-hidden">
@@ -117,7 +117,7 @@
117
117
  {/if}
118
118
  {#if showClose}
119
119
  <DialogPrimitive.Close
120
- class="flex h-9 w-9 items-center justify-center rounded-md bg-transparent text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none"
120
+ class="flex h-9 w-9 items-center justify-center rounded-md bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none"
121
121
  aria-label="Close dialog"
122
122
  >
123
123
  <svg
@@ -150,7 +150,7 @@
150
150
  <!-- Footer section -->
151
151
  {#if footer}
152
152
  <div
153
- class="flex flex-col-reverse border-t border-black bg-muted/30 px-6 py-4 sm:flex-row sm:justify-end sm:space-x-2"
153
+ class="flex flex-col-reverse border-t border-border bg-muted/30 px-6 py-4 sm:flex-row sm:justify-end sm:space-x-2"
154
154
  >
155
155
  {@render footer()}
156
156
  </div>
@@ -96,7 +96,7 @@
96
96
  disabled={subItem.disabled}
97
97
  onSelect={() => subItem.onSelect?.()}
98
98
  class={cn(
99
- 'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-default',
99
+ 'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none bg-popover data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-default',
100
100
  subItem.destructive && 'text-destructive data-[highlighted]:text-destructive'
101
101
  )}
102
102
  aria-keyshortcuts={subItem.shortcut || undefined}
@@ -123,7 +123,7 @@
123
123
  disabled={item.disabled}
124
124
  onSelect={() => item.onSelect?.()}
125
125
  class={cn(
126
- 'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-default',
126
+ 'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none bg-popover data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-default',
127
127
  item.destructive && 'text-destructive data-[highlighted]:text-destructive'
128
128
  )}
129
129
  aria-keyshortcuts={item.shortcut || undefined}
@@ -38,6 +38,12 @@
38
38
  label?: string;
39
39
  /** Hint text shown below the drop zone */
40
40
  hint?: string;
41
+ /**
42
+ * Human-friendly label for the accepted file types shown in the drop zone.
43
+ * When omitted, a readable label is derived from `acceptedTypes`
44
+ * (e.g. "Images, PDF, Videos") instead of showing raw MIME types.
45
+ */
46
+ typesLabel?: string;
41
47
  /** Additional classes */
42
48
  class?: string;
43
49
  }
@@ -56,6 +62,7 @@
56
62
  onUpload,
57
63
  label,
58
64
  hint,
65
+ typesLabel,
59
66
  class: className,
60
67
  }: Props = $props();
61
68
 
@@ -68,6 +75,33 @@
68
75
 
69
76
  const displayError = $derived(externalError || internalError);
70
77
 
78
+ /**
79
+ * Readable label for the accepted types shown in the drop zone.
80
+ * Uses the explicit `typesLabel` when provided, otherwise groups the
81
+ * accepted MIME types into friendly names (Images, PDF, Videos, ...).
82
+ */
83
+ const acceptedTypesLabel = $derived.by(() => {
84
+ if (typesLabel) return typesLabel;
85
+
86
+ const groups: string[] = [];
87
+ const add = (name: string) => {
88
+ if (!groups.includes(name)) groups.push(name);
89
+ };
90
+
91
+ for (const type of acceptedTypes) {
92
+ if (type.startsWith('image/')) add('Images');
93
+ else if (type === 'application/pdf') add('PDF');
94
+ else if (type.startsWith('video/')) add('Videos');
95
+ else if (type.startsWith('audio/')) add('Audio');
96
+ else {
97
+ const subtype = type.split('/')[1];
98
+ add(subtype ? subtype.toUpperCase() : type);
99
+ }
100
+ }
101
+
102
+ return groups.join(', ');
103
+ });
104
+
71
105
  // Helper to generate unique ID
72
106
  function generateId(): string {
73
107
  return `file-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
@@ -337,7 +371,7 @@
337
371
  <span class="font-medium text-foreground">Click to upload</span> or drag and drop
338
372
  </p>
339
373
  <p class="mt-1 text-xs text-muted-foreground">
340
- {acceptedTypes.join(', ')} (max {formatSize(maxSizeBytes)})
374
+ {acceptedTypesLabel} (max {formatSize(maxSizeBytes)})
341
375
  </p>
342
376
 
343
377
  <!-- Hidden file input -->
@@ -34,6 +34,12 @@ interface Props {
34
34
  label?: string;
35
35
  /** Hint text shown below the drop zone */
36
36
  hint?: string;
37
+ /**
38
+ * Human-friendly label for the accepted file types shown in the drop zone.
39
+ * When omitted, a readable label is derived from `acceptedTypes`
40
+ * (e.g. "Images, PDF, Videos") instead of showing raw MIME types.
41
+ */
42
+ typesLabel?: string;
37
43
  /** Additional classes */
38
44
  class?: string;
39
45
  }
@@ -15,12 +15,12 @@
15
15
  * @example Basic usage:
16
16
  * ```svelte
17
17
  * <HeaderSearch
18
- * enabled={true}
19
- * placeholder="Search..."
20
- * onSearch={(query) => handleSearch(query)}
21
- * onSelect={(item) => goto(item.href)}
22
- * results={searchResults}
23
- * loading={isSearching}
18
+ * enabled={true}
19
+ * placeholder="Search..."
20
+ * onSearch={(query) => handleSearch(query)}
21
+ * onSelect={(item) => goto(item.href)}
22
+ * results={searchResults}
23
+ * loading={isSearching}
24
24
  * />
25
25
  * ```
26
26
  */
@@ -175,7 +175,7 @@
175
175
  type="button"
176
176
  onclick={() => (open = true)}
177
177
  class={cn(
178
- 'inline-flex items-center justify-center rounded-md border border-input bg-background text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
178
+ 'inline-flex items-center justify-center rounded-md border border-input bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
179
179
  size === 'sm' ? 'h-8 w-8' : 'h-9 w-9',
180
180
  className
181
181
  )}
@@ -197,7 +197,7 @@
197
197
  type="button"
198
198
  onclick={() => (open = true)}
199
199
  class={cn(
200
- 'flex items-center gap-2 rounded-md border border-input bg-background px-3 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
200
+ 'flex items-center gap-2 rounded-md border border-input bg-background px-3 text-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
201
201
  size === 'sm' ? 'h-8' : 'h-9',
202
202
  className
203
203
  )}
@@ -287,7 +287,7 @@
287
287
  type="button"
288
288
  onclick={() => handleSelect(item)}
289
289
  class={cn(
290
- 'flex w-full cursor-pointer items-start gap-3 rounded-md px-3 py-2 text-left text-sm transition-colors',
290
+ 'flex w-full cursor-pointer items-start gap-3 rounded-md px-3 py-2 text-left text-sm',
291
291
  index === selectedIndex
292
292
  ? 'bg-accent text-accent-foreground'
293
293
  : 'hover:bg-accent-hover'
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { cn } from '../utils.js';
3
+ import { DEV } from 'esm-env';
3
4
 
4
5
  interface Props {
5
6
  type?: string;
@@ -46,6 +47,17 @@
46
47
  ...restProps
47
48
  }: Props = $props();
48
49
 
50
+ // Accessibility: warn if no label is associated in dev mode
51
+ if (DEV) {
52
+ $effect(() => {
53
+ if (!ariaLabel && !ariaLabelledby && !id) {
54
+ console.warn(
55
+ '[Input] Missing accessible label. Provide aria-label, aria-labelledby, or an id with a matching <label for="...">.'
56
+ );
57
+ }
58
+ });
59
+ }
60
+
49
61
  function handleInput(e: Event) {
50
62
  const target = e.target as HTMLInputElement;
51
63
  value = target.value;
@@ -68,6 +80,7 @@
68
80
  aria-labelledby={ariaLabelledby}
69
81
  aria-describedby={ariaDescribedby}
70
82
  aria-invalid={ariaInvalid}
83
+ aria-required={required}
71
84
  class={cn(
72
85
  'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
73
86
  ariaInvalid === true || ariaInvalid === 'true'
@@ -259,7 +259,7 @@
259
259
  value={option.value}
260
260
  label={option.label}
261
261
  disabled={option.disabled || isDisabledByMax}
262
- class="relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-default"
262
+ class="relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-default"
263
263
  >
264
264
  <span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
265
265
  {#if isSelected}
@@ -392,7 +392,7 @@
392
392
  value={option.value}
393
393
  label={option.label}
394
394
  disabled={option.disabled || isDisabledByMax}
395
- class="relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-default"
395
+ class="relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-default"
396
396
  >
397
397
  <span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
398
398
  {#if isSelected}
@@ -159,7 +159,7 @@
159
159
  onclick={decrement}
160
160
  disabled={disabled || !canDecrement}
161
161
  class={cn(
162
- 'flex h-10 w-10 items-center justify-center rounded-l-md border border-r-0 border-input bg-background text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
162
+ 'flex h-10 w-10 items-center justify-center rounded-l-md border border-r-0 border-input bg-background text-sm font-medium hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
163
163
  error && 'border-destructive'
164
164
  )}
165
165
  aria-label="Decrease value"
@@ -208,7 +208,7 @@
208
208
  onclick={increment}
209
209
  disabled={disabled || !canIncrement}
210
210
  class={cn(
211
- 'flex h-10 w-10 items-center justify-center rounded-r-md border border-l-0 border-input bg-background text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
211
+ 'flex h-10 w-10 items-center justify-center rounded-r-md border border-l-0 border-input bg-background text-sm font-medium hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
212
212
  error && 'border-destructive'
213
213
  )}
214
214
  aria-label="Increase value"
@@ -251,7 +251,7 @@
251
251
  onclick={decrement}
252
252
  disabled={disabled || !canDecrement}
253
253
  class={cn(
254
- 'flex h-10 w-10 items-center justify-center rounded-l-md border border-r-0 border-input bg-background text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
254
+ 'flex h-10 w-10 items-center justify-center rounded-l-md border border-r-0 border-input bg-background text-sm font-medium hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
255
255
  error && 'border-destructive'
256
256
  )}
257
257
  aria-label="Decrease value"
@@ -300,7 +300,7 @@
300
300
  onclick={increment}
301
301
  disabled={disabled || !canIncrement}
302
302
  class={cn(
303
- 'flex h-10 w-10 items-center justify-center rounded-r-md border border-l-0 border-input bg-background text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
303
+ 'flex h-10 w-10 items-center justify-center rounded-r-md border border-l-0 border-input bg-background text-sm font-medium hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
304
304
  error && 'border-destructive'
305
305
  )}
306
306
  aria-label="Increase value"
@@ -17,6 +17,10 @@
17
17
  showLabel?: string;
18
18
  /** Label for the toggle button when password is visible */
19
19
  hideLabel?: string;
20
+ /** Screen reader announcement when password becomes visible */
21
+ visibleAnnouncement?: string;
22
+ /** Screen reader announcement when password becomes hidden */
23
+ hiddenAnnouncement?: string;
20
24
  [key: string]: unknown;
21
25
  }
22
26
 
@@ -33,10 +37,13 @@
33
37
  onValueChange,
34
38
  showLabel = 'Show password',
35
39
  hideLabel = 'Hide password',
40
+ visibleAnnouncement = 'Password is now visible',
41
+ hiddenAnnouncement = 'Password is now hidden',
36
42
  ...restProps
37
43
  }: Props = $props();
38
44
 
39
45
  let showPassword = $state(false);
46
+ let announcement = $state('');
40
47
 
41
48
  function handleInput(e: Event) {
42
49
  const target = e.target as HTMLInputElement;
@@ -46,6 +53,7 @@
46
53
 
47
54
  function toggleVisibility() {
48
55
  showPassword = !showPassword;
56
+ announcement = showPassword ? visibleAnnouncement : hiddenAnnouncement;
49
57
  }
50
58
  </script>
51
59
 
@@ -77,4 +85,8 @@
77
85
  >
78
86
  <Icon name={showPassword ? 'eye-off' : 'eye'} size={18} />
79
87
  </button>
88
+ <!-- Screen reader live region for announcing visibility changes -->
89
+ <div role="status" aria-live="polite" aria-atomic="true" class="sr-only">
90
+ {announcement}
91
+ </div>
80
92
  </div>
@@ -13,6 +13,10 @@ interface Props {
13
13
  showLabel?: string;
14
14
  /** Label for the toggle button when password is visible */
15
15
  hideLabel?: string;
16
+ /** Screen reader announcement when password becomes visible */
17
+ visibleAnnouncement?: string;
18
+ /** Screen reader announcement when password becomes hidden */
19
+ hiddenAnnouncement?: string;
16
20
  [key: string]: unknown;
17
21
  }
18
22
  declare const PasswordInput: import("svelte").Component<Props, {}, "value">;
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { RadioGroup as RadioGroupPrimitive } from 'bits-ui';
3
- import { cn } from '../utils.js';
4
- import { generateId, computeDescribedBy } from '../utils/form.js';
3
+ import { cn, useId } from '../utils.js';
4
+ import { computeDescribedBy } from '../utils/form.js';
5
5
  import { validateNonEmptyArray } from '../validation.js';
6
6
  import Label from './Label.svelte';
7
7
 
@@ -51,9 +51,9 @@
51
51
  class: className,
52
52
  }: Props = $props();
53
53
 
54
- // Generate unique IDs for accessibility
55
- const randomSuffix = generateId('radio-group').split('-').pop();
56
- const componentName = $derived(name || `radio-group-${randomSuffix}`);
54
+ // Generate unique IDs for accessibility (sequential counter, SSR-safe)
55
+ const generatedId = useId('radio-group');
56
+ const componentName = $derived(name || generatedId);
57
57
  const labelId = $derived(`${componentName}-label`);
58
58
  const hintId = $derived(`${componentName}-hint`);
59
59
  const errorId = $derived(`${componentName}-error`);
@@ -2,6 +2,7 @@
2
2
  import { Select as SelectPrimitive } from 'bits-ui';
3
3
  import { cn, useId } from '../utils.js';
4
4
  import { validateNonEmptyArray } from '../validation.js';
5
+ import { DEV } from 'esm-env';
5
6
  import Label from './Label.svelte';
6
7
 
7
8
  export interface SelectOption {
@@ -76,6 +77,17 @@
76
77
  validateNonEmptyArray(options, 'options', 'Select');
77
78
  });
78
79
 
80
+ // Accessibility: warn if no label is associated in dev mode
81
+ if (DEV) {
82
+ $effect(() => {
83
+ if (!label && !id) {
84
+ console.warn(
85
+ '[Select] Missing accessible label. Provide a label prop or an id with a matching <label for="...">.'
86
+ );
87
+ }
88
+ });
89
+ }
90
+
79
91
  function isGroup(item: SelectOption | SelectGroup): item is SelectGroup {
80
92
  return 'options' in item;
81
93
  }
@@ -131,7 +143,7 @@
131
143
 
132
144
  // Common item classes
133
145
  const itemClasses =
134
- 'relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-default';
146
+ 'relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-default';
135
147
  </script>
136
148
 
137
149
  {#snippet chevronIcon()}
@@ -28,14 +28,14 @@
28
28
  labelText: 'text-sm font-medium leading-none',
29
29
  modeToggle: 'inline-flex items-center rounded-lg bg-muted p-1',
30
30
  modeButton:
31
- 'inline-flex items-center justify-center rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
31
+ 'inline-flex items-center justify-center rounded-md px-3 py-1.5 text-sm font-medium',
32
32
  modeButtonActive: 'bg-background text-foreground shadow-sm',
33
33
  modeButtonInactive: 'text-muted-foreground hover:text-foreground',
34
34
  canvasContainer: 'relative w-full overflow-hidden rounded-lg border bg-background',
35
35
  canvas: 'block w-full cursor-crosshair touch-none',
36
36
  toolbar: 'flex flex-wrap items-center gap-2',
37
37
  toolButton:
38
- 'inline-flex h-9 w-9 items-center justify-center rounded-md border border-input bg-background text-sm font-medium ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
38
+ 'inline-flex h-9 w-9 items-center justify-center rounded-md border border-input bg-background text-sm font-medium ring-offset-background hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
39
39
  toolGroup: 'flex items-center gap-1.5',
40
40
  toolLabel: 'text-xs text-muted-foreground',
41
41
  colorPicker:
@@ -44,7 +44,7 @@
44
44
  'h-2 w-20 cursor-pointer appearance-none rounded-full bg-muted accent-primary',
45
45
  typeInput: 'w-full',
46
46
  fontPicker: 'flex flex-wrap gap-2',
47
- fontButton: 'rounded-lg border-2 px-3 py-2 text-lg transition-colors hover:border-primary',
47
+ fontButton: 'rounded-lg border-2 px-3 py-2 text-lg hover:border-primary',
48
48
  fontButtonSelected: 'border-primary bg-primary/5',
49
49
  fontButtonUnselected: 'border-input',
50
50
  consent: 'flex items-start gap-2',
@@ -384,8 +384,8 @@
384
384
 
385
385
  return `<?xml version="1.0" encoding="UTF-8"?>
386
386
  <svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
387
- ${backgroundColor === 'white' ? `<rect width="${width}" height="${height}" fill="white"/>` : ''}
388
- ${paths}
387
+ ${backgroundColor === 'white' ? `<rect width="${width}" height="${height}" fill="white"/>` : ''}
388
+ ${paths}
389
389
  </svg>`;
390
390
  }
391
391
 
@@ -114,7 +114,7 @@
114
114
  tabindex={value === tab.id ? 0 : -1}
115
115
  disabled={tab.disabled}
116
116
  class={cn(
117
- 'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
117
+ 'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
118
118
  value === tab.id
119
119
  ? 'bg-background text-foreground shadow-sm'
120
120
  : 'hover:bg-background-hover hover:text-foreground',
@@ -92,7 +92,7 @@
92
92
  {#if onclose}
93
93
  <button
94
94
  type="button"
95
- class="flex-shrink-0 rounded-md p-1 hover:bg-muted-hover transition-colors"
95
+ class="flex-shrink-0 rounded-md p-1 hover:bg-muted-hover"
96
96
  onclick={onclose}
97
97
  aria-label="Close"
98
98
  >
@@ -119,7 +119,7 @@
119
119
  {#each footerLinks as link}
120
120
  <a
121
121
  href={link.href}
122
- class="text-sm text-muted-foreground transition-colors hover:text-foreground"
122
+ class="text-sm text-muted-foreground hover:text-foreground"
123
123
  target={link.external ? '_blank' : undefined}
124
124
  rel={link.external ? 'noopener noreferrer' : undefined}
125
125
  >
@@ -255,7 +255,7 @@
255
255
 
256
256
  <!-- Main Content Area -->
257
257
  <div
258
- class="flex flex-1 flex-col transition-all duration-300 motion-reduce:transition-none"
258
+ class="flex flex-1 flex-col transition-[margin] duration-300 motion-reduce:transition-none"
259
259
  style="margin-left: {sidebarWidth}px;"
260
260
  >
261
261
  {#if showHeader}
@@ -75,7 +75,7 @@
75
75
  <a
76
76
  href={item.href}
77
77
  class={cn(
78
- 'text-sm transition-colors',
78
+ 'text-sm',
79
79
  isDark
80
80
  ? 'text-white hover:text-primary'
81
81
  : 'text-muted-foreground hover:text-foreground'
@@ -97,7 +97,7 @@
97
97
  <a
98
98
  href={item.href}
99
99
  class={cn(
100
- 'text-sm font-bold uppercase transition-colors hover:text-primary',
100
+ 'text-sm font-bold uppercase hover:text-primary',
101
101
  item.active ? 'text-primary' : 'text-muted-foreground'
102
102
  )}
103
103
  target={item.external ? '_blank' : undefined}
@@ -122,7 +122,7 @@
122
122
  <a
123
123
  href={item.href}
124
124
  class={cn(
125
- 'block rounded-md px-3 py-2 text-sm font-bold uppercase transition-colors',
125
+ 'block rounded-md px-3 py-2 text-sm font-bold uppercase',
126
126
  item.active
127
127
  ? 'bg-primary/10 text-primary'
128
128
  : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
@@ -50,7 +50,7 @@
50
50
  <a
51
51
  href={link.href}
52
52
  class={cn(
53
- 'flex items-center transition-all duration-300 motion-reduce:transition-none overflow-hidden',
53
+ 'flex items-center overflow-hidden',
54
54
  // List mode styles
55
55
  display === 'list' && 'gap-3 rounded-md px-3 py-2 text-sm',
56
56
  display === 'list' &&
@@ -93,7 +93,7 @@
93
93
 
94
94
  <span
95
95
  class={cn(
96
- 'truncate whitespace-nowrap transition-all duration-300 motion-reduce:transition-none',
96
+ 'truncate whitespace-nowrap transition-[width,opacity] duration-300 motion-reduce:transition-none',
97
97
  display === 'icons' ? 'w-0 opacity-0 flex-none' : 'flex-1 opacity-100'
98
98
  )}
99
99
  >
@@ -111,7 +111,7 @@
111
111
  {#if link.external}
112
112
  <svg
113
113
  class={cn(
114
- 'h-4 w-4 shrink-0 transition-all duration-300',
114
+ 'h-4 w-4 shrink-0 transition-opacity duration-300',
115
115
  display === 'icons' ? 'hidden' : 'opacity-50'
116
116
  )}
117
117
  fill="none"
@@ -437,7 +437,7 @@
437
437
  <!-- Sidebar: z-50 positions above overlay and content, below modals (z-100+) -->
438
438
  <aside
439
439
  class={cn(
440
- 'fixed left-0 top-0 z-[var(--z-sidebar,50)] flex h-full flex-col transition-all duration-300 touch-pan-y',
440
+ 'fixed left-0 top-0 z-[var(--z-sidebar,50)] flex h-full flex-col transition-[width,transform] duration-300 motion-reduce:transition-none touch-pan-y',
441
441
  // Variant-based background and text colors
442
442
  isLight ? 'bg-background text-foreground' : 'bg-sidebar text-sidebar-foreground',
443
443
  strongBorder ? 'border-r-2 border-black' : 'border-r border-black',
@@ -110,7 +110,6 @@
110
110
  bind:this={flyoutElement}
111
111
  class={cn(
112
112
  'fixed min-w-48 rounded-md border shadow-lg z-[calc(var(--z-sidebar,50)+10)]',
113
- 'motion-reduce:transition-none',
114
113
  isLight ? 'bg-background border-border' : 'bg-sidebar border-sidebar-foreground/20'
115
114
  )}
116
115
  style="top: {top}px; left: {left}px;"
@@ -174,7 +173,7 @@
174
173
  <a
175
174
  href={child.disabled ? undefined : child.href}
176
175
  class={cn(
177
- 'flex items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors ml-2',
176
+ 'flex items-center gap-2 rounded-md px-3 py-2 text-sm ml-2',
178
177
  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
179
178
  isLight
180
179
  ? 'text-foreground hover:bg-accent hover:text-accent-foreground'
@@ -84,11 +84,13 @@
84
84
 
85
85
  function getItemStateClasses(active?: boolean, disabled?: boolean): string {
86
86
  return cn(
87
- isLight && !active && 'text-foreground hover:bg-accent hover:text-accent-foreground',
87
+ isLight &&
88
+ !active &&
89
+ 'text-foreground bg-background hover:bg-accent hover:text-accent-foreground',
88
90
  isLight && active && 'bg-primary/10 text-primary',
89
91
  !isLight &&
90
92
  !active &&
91
- 'text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
93
+ 'text-sidebar-foreground bg-sidebar hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
92
94
  !isLight && active && 'bg-sidebar-primary text-sidebar-primary-foreground',
93
95
  disabled && 'opacity-50 pointer-events-none cursor-not-allowed'
94
96
  );
@@ -172,13 +174,13 @@
172
174
  highlight: { before: string; match: string; after: string } | null
173
175
  )}
174
176
  {#if highlight}
175
- <span class="flex-1 truncate">
177
+ <span class="flex-1 min-w-0 truncate">
176
178
  {highlight.before}<mark class="bg-yellow-200 dark:bg-yellow-900 text-inherit rounded px-0.5"
177
179
  >{highlight.match}</mark
178
180
  >{highlight.after}
179
181
  </span>
180
182
  {:else}
181
- <span class="flex-1 truncate">{name}</span>
183
+ <span class="flex-1 min-w-0 truncate">{name}</span>
182
184
  {/if}
183
185
  {/snippet}
184
186
 
@@ -186,7 +188,7 @@
186
188
  <button
187
189
  type="button"
188
190
  class={cn(
189
- 'flex items-center justify-center p-1 rounded transition-all shrink-0',
191
+ 'flex items-center justify-center p-1 rounded shrink-0',
190
192
  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
191
193
  // Show on hover for regular items, always show in pinned section
192
194
  isUnpin ? 'opacity-100' : 'opacity-0 group-hover:opacity-100',
@@ -247,10 +249,10 @@
247
249
  <div>
248
250
  <div
249
251
  class={cn(
250
- 'group flex w-full items-center rounded-md transition-colors overflow-hidden',
251
- isLight && !item.active && 'hover:bg-accent',
252
+ 'group flex w-full items-center rounded-md',
253
+ isLight && !item.active && 'bg-background hover:bg-accent',
252
254
  isLight && item.active && 'bg-primary/10',
253
- !isLight && !item.active && 'hover:bg-sidebar-accent',
255
+ !isLight && !item.active && 'bg-sidebar hover:bg-sidebar-accent',
254
256
  !isLight && item.active && 'bg-sidebar-primary'
255
257
  )}
256
258
  >
@@ -259,7 +261,7 @@
259
261
  <a
260
262
  href={item.disabled ? undefined : item.href}
261
263
  class={cn(
262
- 'flex flex-1 items-center gap-3 px-3 py-2 text-sm font-medium transition-colors overflow-hidden',
264
+ 'flex flex-1 items-center gap-3 px-3 py-2 text-sm font-medium',
263
265
  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
264
266
  'group-hover:underline underline-offset-2',
265
267
  isLight && !item.active && 'text-foreground group-hover:text-accent-foreground',
@@ -287,7 +289,7 @@
287
289
  <button
288
290
  type="button"
289
291
  class={cn(
290
- 'flex flex-1 items-center gap-3 px-3 py-2 text-sm font-medium transition-colors overflow-hidden text-left',
292
+ 'flex flex-1 items-center gap-3 px-3 py-2 text-sm font-medium text-left',
291
293
  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
292
294
  isLight && !item.active && 'text-foreground group-hover:text-accent-foreground',
293
295
  isLight && item.active && 'text-primary',
@@ -312,7 +314,7 @@
312
314
  <button
313
315
  type="button"
314
316
  class={cn(
315
- 'flex items-center justify-center p-2 transition-colors rounded-md',
317
+ 'flex items-center justify-center p-2 rounded-md',
316
318
  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
317
319
  isLight
318
320
  ? 'hover:bg-accent-hover group-hover:text-accent-foreground'
@@ -353,16 +355,13 @@
353
355
  </div>
354
356
 
355
357
  {#if isExpanded}
356
- <ul
357
- class="mt-1 space-y-1 pl-4 motion-reduce:transition-none overflow-hidden"
358
- transition:slide={{ duration: 200 }}
359
- >
358
+ <ul class="mt-1 space-y-1 pl-4 overflow-hidden" transition:slide={{ duration: 200 }}>
360
359
  {#each item.children as child}
361
360
  <li>
362
361
  <a
363
362
  href={child.disabled ? undefined : child.href}
364
363
  class={cn(
365
- 'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors overflow-hidden',
364
+ 'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium',
366
365
  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
367
366
  getItemStateClasses(child.active, child.disabled)
368
367
  )}
@@ -394,10 +393,10 @@
394
393
  <!-- Wrap in group div for pin button hover behavior -->
395
394
  <div
396
395
  class={cn(
397
- 'group flex w-full items-center rounded-md transition-colors overflow-hidden',
398
- isLight && !item.active && 'hover:bg-accent',
396
+ 'group flex w-full items-center rounded-md',
397
+ isLight && !item.active && 'bg-background hover:bg-accent',
399
398
  isLight && item.active && 'bg-primary/10',
400
- !isLight && !item.active && 'hover:bg-sidebar-accent',
399
+ !isLight && !item.active && 'bg-sidebar hover:bg-sidebar-accent',
401
400
  !isLight && item.active && 'bg-sidebar-primary'
402
401
  )}
403
402
  >
@@ -406,7 +405,7 @@
406
405
  <a
407
406
  href={item.disabled ? undefined : item.href}
408
407
  class={cn(
409
- 'flex flex-1 items-center gap-3 px-3 py-2 text-sm font-medium transition-colors overflow-hidden relative',
408
+ 'flex flex-1 items-center gap-3 px-3 py-2 text-sm font-medium relative',
410
409
  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
411
410
  isLight && !item.active && 'text-foreground group-hover:text-accent-foreground',
412
411
  isLight && item.active && 'text-primary',
@@ -453,7 +452,7 @@
453
452
  <button
454
453
  type="button"
455
454
  class={cn(
456
- 'flex flex-1 items-center gap-3 px-3 py-2 text-sm font-medium transition-colors overflow-hidden text-left relative',
455
+ 'flex flex-1 items-center gap-3 px-3 py-2 text-sm font-medium text-left relative',
457
456
  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
458
457
  isLight && !item.active && 'text-foreground group-hover:text-accent-foreground',
459
458
  isLight && item.active && 'text-primary',
@@ -67,7 +67,7 @@
67
67
  <button
68
68
  onclick={toggleSection}
69
69
  class={cn(
70
- 'flex w-full items-center justify-between mb-2 px-4 text-xs font-semibold uppercase tracking-wider transition-colors rounded-md overflow-hidden',
70
+ 'flex w-full items-center justify-between mb-2 px-4 text-xs font-semibold uppercase tracking-wider rounded-md overflow-hidden',
71
71
  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
72
72
  isLight
73
73
  ? 'text-muted-foreground hover:text-foreground'
@@ -132,7 +132,7 @@
132
132
  {:else if isExpanded}
133
133
  <!-- Collapsible and expanded: animate -->
134
134
  <ul
135
- class="space-y-1 px-2 motion-reduce:transition-none overflow-hidden"
135
+ class="space-y-1 px-2 overflow-hidden"
136
136
  id={`sidebar-section-${section.id}`}
137
137
  transition:slide={{ duration: 200 }}
138
138
  >
@@ -81,7 +81,7 @@
81
81
  aria-selected={filter === filterItem.id}
82
82
  tabindex={filter === filterItem.id ? 0 : -1}
83
83
  class={cn(
84
- 'inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
84
+ 'inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
85
85
  filter === filterItem.id
86
86
  ? 'bg-background text-foreground shadow-sm'
87
87
  : 'hover:bg-background-hover hover:text-foreground'
@@ -69,7 +69,7 @@
69
69
  <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
70
70
  <div
71
71
  class={cn(
72
- 'group relative flex gap-3 rounded-lg border p-4 transition-colors',
72
+ 'group relative flex gap-3 rounded-lg border p-4',
73
73
  !notification.read && 'bg-blue-50/50 border-blue-200',
74
74
  notification.read && 'bg-card border-border',
75
75
  onClick && 'cursor-pointer hover:bg-muted/50',
@@ -6,9 +6,11 @@ import { type ClassValue } from 'clsx';
6
6
  export declare function cn(...inputs: ClassValue[]): string;
7
7
  /**
8
8
  * Generate a unique ID with an optional prefix.
9
- * IDs are sequential and stable, preventing SSR hydration mismatches.
9
+ * Uses crypto.randomUUID() to avoid SSR hydration mismatches caused by
10
+ * sequential counters (where server and client counts can diverge).
11
+ * The short suffix is derived from the UUID for compact, readable IDs.
10
12
  *
11
13
  * @param prefix - Optional prefix for the ID (e.g., 'checkbox', 'tabs')
12
- * @returns A unique ID string like 'checkbox-1' or 'ct-1' (ct = classic-theme)
14
+ * @returns A unique ID string like 'checkbox-a1b2c3d4' or 'ct-a1b2c3d4'
13
15
  */
14
16
  export declare function useId(prefix?: string): string;
package/dist/lib/utils.js CHANGED
@@ -7,22 +7,16 @@ import { twMerge } from 'tailwind-merge';
7
7
  export function cn(...inputs) {
8
8
  return twMerge(clsx(inputs));
9
9
  }
10
- /**
11
- * Counter for generating unique sequential IDs.
12
- * Uses a sequential counter instead of Math.random() to ensure:
13
- * - Deterministic IDs that match between SSR and hydration
14
- * - Stable IDs across component re-renders
15
- * - Accessibility-friendly ID associations
16
- */
17
- let idCounter = 0;
18
10
  /**
19
11
  * Generate a unique ID with an optional prefix.
20
- * IDs are sequential and stable, preventing SSR hydration mismatches.
12
+ * Uses crypto.randomUUID() to avoid SSR hydration mismatches caused by
13
+ * sequential counters (where server and client counts can diverge).
14
+ * The short suffix is derived from the UUID for compact, readable IDs.
21
15
  *
22
16
  * @param prefix - Optional prefix for the ID (e.g., 'checkbox', 'tabs')
23
- * @returns A unique ID string like 'checkbox-1' or 'ct-1' (ct = classic-theme)
17
+ * @returns A unique ID string like 'checkbox-a1b2c3d4' or 'ct-a1b2c3d4'
24
18
  */
25
19
  export function useId(prefix) {
26
- const id = ++idCounter;
20
+ const id = crypto.randomUUID().slice(0, 8);
27
21
  return prefix ? `${prefix}-${id}` : `ct-${id}`;
28
22
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classic-homes/theme-svelte",
3
- "version": "0.1.53",
3
+ "version": "0.1.55",
4
4
  "description": "Svelte components for the Classic theme system",
5
5
  "type": "module",
6
6
  "svelte": "./dist/lib/index.js",
@@ -27,7 +27,7 @@
27
27
  "scripts": {
28
28
  "build": "svelte-package -i src",
29
29
  "dev": "svelte-package -i src --watch",
30
- "clean": "rm -rf dist",
30
+ "clean": "node ../../scripts/rm.mjs dist",
31
31
  "typecheck": "svelte-check --tsconfig ./tsconfig.json",
32
32
  "test": "vitest run",
33
33
  "test:watch": "vitest",
@@ -69,7 +69,7 @@
69
69
  },
70
70
  "devDependencies": {
71
71
  "@sveltejs/package": "^2.3.0",
72
- "@sveltejs/vite-plugin-svelte": "^4.0.0",
72
+ "@sveltejs/vite-plugin-svelte": "^7.0.0",
73
73
  "@testing-library/jest-dom": "^6.6.0",
74
74
  "@testing-library/svelte": "^5.0.0",
75
75
  "@testing-library/user-event": "^14.5.0",