svelte-comp 1.2.7 → 1.3.5

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 (139) hide show
  1. package/README.md +12 -11
  2. package/dist/App.svelte +497 -2
  3. package/dist/app.css +2 -3
  4. package/dist/app.d.ts +10 -0
  5. package/dist/lang.d.ts +3 -3
  6. package/dist/lang.js +3 -3
  7. package/dist/lib/Accordion.svelte +14 -14
  8. package/dist/lib/Badge.svelte +44 -0
  9. package/dist/lib/Badge.svelte.d.ts +10 -0
  10. package/dist/lib/Button.svelte +23 -8
  11. package/dist/lib/Calendar.svelte +384 -377
  12. package/dist/lib/Card.svelte +6 -6
  13. package/dist/lib/Carousel.svelte +16 -16
  14. package/dist/lib/Carousel.svelte.d.ts +1 -1
  15. package/dist/lib/CheckBox.svelte +2 -2
  16. package/dist/lib/CodeView.svelte +6 -5
  17. package/dist/lib/ColorPicker.svelte +6 -6
  18. package/dist/lib/ContextMenu.svelte +328 -0
  19. package/dist/lib/ContextMenu.svelte.d.ts +14 -0
  20. package/dist/lib/DatePicker.svelte +161 -161
  21. package/dist/lib/Dialog.svelte +10 -10
  22. package/dist/lib/Field.svelte +1 -1
  23. package/dist/lib/FilePicker.svelte +127 -74
  24. package/dist/lib/FilePicker.svelte.d.ts +6 -3
  25. package/dist/lib/Hamburger.svelte +27 -21
  26. package/dist/lib/InstallPWA.svelte +94 -0
  27. package/dist/lib/InstallPWA.svelte.d.ts +8 -0
  28. package/dist/lib/Menu.svelte +18 -18
  29. package/dist/lib/NoticeBase.svelte +140 -0
  30. package/dist/lib/NoticeBase.svelte.d.ts +43 -0
  31. package/dist/lib/PrimaryColorSelect.svelte +6 -6
  32. package/dist/lib/ProgressCircle.svelte +7 -9
  33. package/dist/lib/ProgressCircle.svelte.d.ts +7 -9
  34. package/dist/lib/SearchInput.svelte +6 -6
  35. package/dist/lib/Select.svelte +2 -2
  36. package/dist/lib/Slider.svelte +1 -1
  37. package/dist/lib/Splitter.svelte +15 -6
  38. package/dist/lib/Switch.svelte +5 -4
  39. package/dist/lib/Tabs.svelte +6 -6
  40. package/dist/lib/ThemeToggle.svelte +1 -0
  41. package/dist/lib/TimePicker.svelte +103 -95
  42. package/dist/lib/TimePickerNew.svelte +634 -0
  43. package/dist/lib/TimePickerNew.svelte.d.ts +49 -0
  44. package/dist/lib/Toast.svelte +17 -120
  45. package/dist/lib/Tooltip.svelte +7 -7
  46. package/dist/lib/Topbar.svelte +112 -0
  47. package/dist/lib/Topbar.svelte.d.ts +44 -0
  48. package/dist/lib/__tests__/Accordion.test.d.ts +1 -0
  49. package/dist/lib/__tests__/Accordion.test.js +171 -0
  50. package/dist/lib/__tests__/Badge.test.d.ts +1 -0
  51. package/dist/lib/__tests__/Badge.test.js +41 -0
  52. package/dist/lib/__tests__/Button.test.d.ts +1 -0
  53. package/dist/lib/__tests__/Button.test.js +269 -0
  54. package/dist/lib/__tests__/Calendar.test.d.ts +1 -0
  55. package/dist/lib/__tests__/Calendar.test.js +171 -0
  56. package/dist/lib/__tests__/Card.test.d.ts +1 -0
  57. package/dist/lib/__tests__/Card.test.js +148 -0
  58. package/dist/lib/__tests__/Carousel.test.d.ts +1 -0
  59. package/dist/lib/__tests__/Carousel.test.js +439 -0
  60. package/dist/lib/__tests__/CheckBox.test.d.ts +1 -0
  61. package/dist/lib/__tests__/CheckBox.test.js +152 -0
  62. package/dist/lib/__tests__/CodeView.test.d.ts +1 -0
  63. package/dist/lib/__tests__/CodeView.test.js +157 -0
  64. package/dist/lib/__tests__/ColorPicker.test.d.ts +1 -0
  65. package/dist/lib/__tests__/ColorPicker.test.js +93 -0
  66. package/dist/lib/__tests__/ContextMenu.test.d.ts +1 -0
  67. package/dist/lib/__tests__/ContextMenu.test.js +67 -0
  68. package/dist/lib/__tests__/DatePicker.test.d.ts +1 -0
  69. package/dist/lib/__tests__/DatePicker.test.js +108 -0
  70. package/dist/lib/__tests__/Dialog.test.d.ts +1 -0
  71. package/dist/lib/__tests__/Dialog.test.js +183 -0
  72. package/dist/lib/__tests__/Field.test.d.ts +1 -0
  73. package/dist/lib/__tests__/Field.test.js +190 -0
  74. package/dist/lib/__tests__/FilePicker.test.d.ts +1 -0
  75. package/dist/lib/__tests__/FilePicker.test.js +179 -0
  76. package/dist/lib/__tests__/Form.integration.test.d.ts +1 -0
  77. package/dist/lib/__tests__/Form.integration.test.js +158 -0
  78. package/dist/lib/__tests__/Form.test.d.ts +1 -0
  79. package/dist/lib/__tests__/Form.test.js +463 -0
  80. package/dist/lib/__tests__/Hamburger.test.d.ts +1 -0
  81. package/dist/lib/__tests__/Hamburger.test.js +161 -0
  82. package/dist/lib/__tests__/InstallPWA.test.d.ts +1 -0
  83. package/dist/lib/__tests__/InstallPWA.test.js +15 -0
  84. package/dist/lib/__tests__/Menu.test.d.ts +1 -0
  85. package/dist/lib/__tests__/Menu.test.js +285 -0
  86. package/dist/lib/__tests__/NoticeBase.test.d.ts +1 -0
  87. package/dist/lib/__tests__/NoticeBase.test.js +60 -0
  88. package/dist/lib/__tests__/PaginatedCard.test.d.ts +1 -0
  89. package/dist/lib/__tests__/PaginatedCard.test.js +89 -0
  90. package/dist/lib/__tests__/Pagination.test.d.ts +1 -0
  91. package/dist/lib/__tests__/Pagination.test.js +168 -0
  92. package/dist/lib/__tests__/PrimaryColorSelect.test.d.ts +1 -0
  93. package/dist/lib/__tests__/PrimaryColorSelect.test.js +92 -0
  94. package/dist/lib/__tests__/ProgressBar.test.d.ts +1 -0
  95. package/dist/lib/__tests__/ProgressBar.test.js +69 -0
  96. package/dist/lib/__tests__/ProgressCircle.test.d.ts +1 -0
  97. package/dist/lib/__tests__/ProgressCircle.test.js +71 -0
  98. package/dist/lib/__tests__/Radio.test.d.ts +1 -0
  99. package/dist/lib/__tests__/Radio.test.js +127 -0
  100. package/dist/lib/__tests__/SearchInput.test.d.ts +1 -0
  101. package/dist/lib/__tests__/SearchInput.test.js +80 -0
  102. package/dist/lib/__tests__/Select.test.d.ts +1 -0
  103. package/dist/lib/__tests__/Select.test.js +408 -0
  104. package/dist/lib/__tests__/Slider.test.d.ts +1 -0
  105. package/dist/lib/__tests__/Slider.test.js +213 -0
  106. package/dist/lib/__tests__/Splitter.test.d.ts +1 -0
  107. package/dist/lib/__tests__/Splitter.test.js +87 -0
  108. package/dist/lib/__tests__/Switch.test.d.ts +1 -0
  109. package/dist/lib/__tests__/Switch.test.js +97 -0
  110. package/dist/lib/__tests__/Table.test.d.ts +1 -0
  111. package/dist/lib/__tests__/Table.test.js +349 -0
  112. package/dist/lib/__tests__/Tabs.test.d.ts +1 -0
  113. package/dist/lib/__tests__/Tabs.test.js +262 -0
  114. package/dist/lib/__tests__/ThemeToggle.test.d.ts +1 -0
  115. package/dist/lib/__tests__/ThemeToggle.test.js +84 -0
  116. package/dist/lib/__tests__/TimePicker.test.d.ts +1 -0
  117. package/dist/lib/__tests__/TimePicker.test.js +146 -0
  118. package/dist/lib/__tests__/TimePickerNew.test.d.ts +1 -0
  119. package/dist/lib/__tests__/TimePickerNew.test.js +322 -0
  120. package/dist/lib/__tests__/Toast.test.d.ts +1 -0
  121. package/dist/lib/__tests__/Toast.test.js +135 -0
  122. package/dist/lib/__tests__/Tooltip.test.d.ts +1 -0
  123. package/dist/lib/__tests__/Tooltip.test.js +171 -0
  124. package/dist/lib/__tests__/Topbar.test.d.ts +1 -0
  125. package/dist/lib/__tests__/Topbar.test.js +25 -0
  126. package/dist/lib/__tests__/setupLangContext.d.ts +1 -0
  127. package/dist/lib/__tests__/setupLangContext.js +65 -0
  128. package/dist/lib/__tests__/storage.test.d.ts +1 -0
  129. package/dist/lib/__tests__/storage.test.js +124 -0
  130. package/dist/lib/__tests__/utils.test.d.ts +1 -0
  131. package/dist/lib/__tests__/utils.test.js +11 -0
  132. package/dist/lib/index.d.ts +5 -0
  133. package/dist/lib/index.js +5 -0
  134. package/dist/lib/lang.d.ts +64 -0
  135. package/dist/lib/lang.js +64 -0
  136. package/dist/lib/types/index.d.ts +1 -0
  137. package/dist/styles.css +2 -0
  138. package/dist/utils/index.js +15 -4
  139. package/package.json +12 -12
@@ -37,10 +37,10 @@
37
37
  * @note Preview card includes `aria-live="polite"` to announce updated dates.
38
38
  */
39
39
  import type { HTMLAttributes } from "svelte/elements";
40
- import Button from "./Button.svelte";
41
- import Calendar from "./Calendar.svelte";
42
- import { cx, formatDate, uid } from "../utils";
43
- import { getComponentText, getLangContext, getLangKey } from "./lang-context";
40
+ import Button from "./Button.svelte";
41
+ import Calendar from "./Calendar.svelte";
42
+ import { cx, formatDate, uid } from "../utils";
43
+ import { getComponentText, getLangContext, getLangKey } from "./lang-context";
44
44
 
45
45
  type Props = HTMLAttributes<HTMLDivElement> & {
46
46
  value?: string | null;
@@ -58,7 +58,7 @@
58
58
 
59
59
  let {
60
60
  value = $bindable<string | null>(null),
61
- min = "1926-01-01",
61
+ min = "1926-01-01",
62
62
  max,
63
63
  label,
64
64
  placeholder,
@@ -71,166 +71,166 @@
71
71
  ...rest
72
72
  }: Props = $props();
73
73
 
74
- const langCtx = getLangContext();
75
- const langKey = $derived(getLangKey(langCtx));
76
- const L = $derived(getComponentText("datePicker", langKey));
74
+ const langCtx = getLangContext();
75
+ const langKey = $derived(getLangKey(langCtx));
76
+ const L = $derived(getComponentText("datePicker", langKey));
77
77
 
78
78
  const labelFinal = $derived(label ?? L.text);
79
79
  const placeholderFinal = $derived(placeholder ?? L.placeholder);
80
80
 
81
- const base = "inline-block w-full";
82
- const pickerClass = $derived(cx(base, externalClass));
83
-
84
- const hasValue = $derived(Boolean(value));
85
- const formattedValue = $derived(formatDate(value, locale, formatOptions));
86
-
87
- let open = $state(false);
88
- const panelId = uid("calendar-");
89
- const panelWidth = 240;
90
-
91
- let triggerEl = $state<HTMLDivElement | null>(null);
92
- let panelEl = $state<HTMLDivElement | null>(null);
93
- let panelTop = $state(0);
94
- let panelLeft = $state(0);
95
-
96
- const panelStyle = $derived(
97
- `position:fixed; top:${panelTop}px; left:${panelLeft}px; width:${panelWidth}px;`
98
- );
99
-
100
- function togglePanel() {
101
- if (disabled) return;
102
- open = !open;
103
- }
104
-
105
- function handleSelect(nextValue: string | null) {
106
- value = nextValue;
107
- onChange?.(nextValue);
108
- open = false;
109
- }
110
-
111
- function clearSelection() {
112
- if (!clearable) return;
113
- value = null;
114
- onChange?.(null);
115
- }
116
-
117
- $effect(() => {
118
- const currentTrigger = triggerEl;
119
- const currentPanel = panelEl;
120
- if (!open || !currentTrigger) return;
121
-
122
- const updatePosition = () => {
123
- const triggerRect = currentTrigger.getBoundingClientRect();
124
- const panelHeight =
125
- currentPanel?.getBoundingClientRect().height ?? 0;
126
- const spaceBelow = window.innerHeight - triggerRect.bottom;
127
- const spaceAbove = triggerRect.top;
128
- const shouldFlip = spaceBelow < panelHeight && spaceAbove > spaceBelow;
129
-
130
- panelTop = shouldFlip
131
- ? Math.max(0, triggerRect.top - panelHeight - 8)
132
- : triggerRect.bottom + 8;
133
-
134
- const viewportLeft = window.scrollX;
135
- const viewportRight = window.scrollX + window.innerWidth;
136
- const targetLeft = triggerRect.left + window.scrollX;
137
- const maxLeft = viewportRight - panelWidth;
138
- panelLeft = Math.max(viewportLeft, Math.min(targetLeft, maxLeft));
139
- };
140
-
141
- const onKeyDown = (event: KeyboardEvent) => {
142
- if (event.key === "Escape") {
143
- event.preventDefault();
144
- open = false;
145
- }
146
- };
147
-
148
- const onClickOutside = (event: MouseEvent) => {
149
- if (
150
- currentTrigger &&
151
- currentPanel &&
152
- !currentTrigger.contains(event.target as Node) &&
153
- !currentPanel.contains(event.target as Node)
154
- ) {
155
- open = false;
156
- }
157
- };
158
-
159
- queueMicrotask(updatePosition);
160
- window.addEventListener("scroll", updatePosition, true);
161
- window.addEventListener("resize", updatePosition);
162
- window.addEventListener("keydown", onKeyDown);
163
- document.addEventListener("mousedown", onClickOutside);
164
-
165
- return () => {
166
- window.removeEventListener("scroll", updatePosition, true);
167
- window.removeEventListener("resize", updatePosition);
168
- window.removeEventListener("keydown", onKeyDown);
169
- document.removeEventListener("mousedown", onClickOutside);
170
- };
171
- });
172
- </script>
173
-
174
- <div class={pickerClass} {...rest}>
175
- <div class="text-md font-medium mb-2 [color:var(--color-text-default)]">
176
- {labelFinal}:
177
- </div>
178
- <div class="inline-flex flex-wrap items-center gap-x-3 gap-y-2">
179
- <div bind:this={triggerEl}>
180
- <Button
181
- onClick={togglePanel}
182
- {disabled}
183
- sz="xs"
184
- aria-expanded={open}
185
- aria-controls={open ? panelId : undefined}
186
- >
187
- {L.date}
188
- </Button>
189
- </div>
190
-
191
- {#if clearable}
192
- <Button
193
- onClick={clearSelection}
194
- variant="danger"
195
- disabled={!hasValue || disabled}
196
- sz="xs"
197
- >
198
- {L.clear}
199
- </Button>
200
- {/if}
201
-
202
- {#if open}
203
- <div
204
- role="presentation"
205
- tabindex="-1"
206
- class="fixed inset-0 z-[var(--z-overlay)]"
207
- ></div>
208
-
209
- <div
210
- bind:this={panelEl}
211
- id={panelId}
212
- role="dialog"
213
- aria-label={labelFinal}
214
- class="z-[var(--z-modal)] p-3 border border-[var(--border-color-default)] rounded-[var(--radius-md)] bg-[var(--color-bg-surface)] shadow-[0_2px_6px_var(--shadow-color)]"
215
- style={panelStyle}
216
- >
217
- <Calendar
218
- value={value}
219
- {min}
220
- {max}
221
- {locale}
222
- onChange={handleSelect}
223
- showOutsideDays
224
- class="max-w-full"
225
- />
226
- </div>
227
- {/if}
228
- </div>
229
-
230
- <div
231
- class="mt-3 p-4 bg-[var(--color-bg-surface)] text-center"
232
- aria-live="polite"
233
- >
81
+ const base = "inline-block w-full";
82
+ const pickerClass = $derived(cx(base, externalClass));
83
+
84
+ const hasValue = $derived(Boolean(value));
85
+ const formattedValue = $derived(formatDate(value, locale, formatOptions));
86
+
87
+ let open = $state(false);
88
+ const panelId = uid("calendar-");
89
+ const panelSize = 240;
90
+
91
+ let triggerEl = $state<HTMLDivElement | null>(null);
92
+ let panelEl = $state<HTMLDivElement | null>(null);
93
+ let panelTop = $state(0);
94
+ let panelLeft = $state(0);
95
+
96
+ const panelStyle = $derived(
97
+ `position:fixed; top:${panelTop}px; left:${panelLeft}px; width:${panelSize}px; height:${panelSize}px;`
98
+ );
99
+
100
+ function togglePanel() {
101
+ if (disabled) return;
102
+ open = !open;
103
+ }
104
+
105
+ function handleSelect(nextValue: string | null) {
106
+ value = nextValue;
107
+ onChange?.(nextValue);
108
+ open = false;
109
+ }
110
+
111
+ function clearSelection() {
112
+ if (!clearable) return;
113
+ value = null;
114
+ onChange?.(null);
115
+ }
116
+
117
+ $effect(() => {
118
+ const currentTrigger = triggerEl;
119
+ const currentPanel = panelEl;
120
+ if (!open || !currentTrigger) return;
121
+
122
+ const updatePosition = () => {
123
+ const triggerRect = currentTrigger.getBoundingClientRect();
124
+ const panelHeight =
125
+ currentPanel?.getBoundingClientRect().height ?? 0;
126
+ const spaceBelow = window.innerHeight - triggerRect.bottom;
127
+ const spaceAbove = triggerRect.top;
128
+ const shouldFlip = spaceBelow < panelHeight && spaceAbove > spaceBelow;
129
+
130
+ panelTop = shouldFlip
131
+ ? Math.max(0, triggerRect.top - panelHeight - 8)
132
+ : triggerRect.bottom + 8;
133
+
134
+ const viewportLeft = window.scrollX;
135
+ const viewportRight = window.scrollX + window.innerWidth;
136
+ const targetLeft = triggerRect.left + window.scrollX;
137
+ const maxLeft = viewportRight - panelSize;
138
+ panelLeft = Math.max(viewportLeft, Math.min(targetLeft, maxLeft));
139
+ };
140
+
141
+ const onKeyDown = (event: KeyboardEvent) => {
142
+ if (event.key === "Escape") {
143
+ event.preventDefault();
144
+ open = false;
145
+ }
146
+ };
147
+
148
+ const onClickOutside = (event: MouseEvent) => {
149
+ if (
150
+ currentTrigger &&
151
+ currentPanel &&
152
+ !currentTrigger.contains(event.target as Node) &&
153
+ !currentPanel.contains(event.target as Node)
154
+ ) {
155
+ open = false;
156
+ }
157
+ };
158
+
159
+ queueMicrotask(updatePosition);
160
+ window.addEventListener("scroll", updatePosition, true);
161
+ window.addEventListener("resize", updatePosition);
162
+ window.addEventListener("keydown", onKeyDown);
163
+ document.addEventListener("mousedown", onClickOutside);
164
+
165
+ return () => {
166
+ window.removeEventListener("scroll", updatePosition, true);
167
+ window.removeEventListener("resize", updatePosition);
168
+ window.removeEventListener("keydown", onKeyDown);
169
+ document.removeEventListener("mousedown", onClickOutside);
170
+ };
171
+ });
172
+ </script>
173
+
174
+ <div class={pickerClass} {...rest}>
175
+ <div class="text-md font-medium mb-2 [color:var(--color-text-default)]">
176
+ {labelFinal}:
177
+ </div>
178
+ <div class="inline-flex flex-wrap items-center gap-x-3 gap-y-2">
179
+ <div bind:this={triggerEl}>
180
+ <Button
181
+ onClick={togglePanel}
182
+ {disabled}
183
+ sz="xs"
184
+ aria-expanded={open}
185
+ aria-controls={open ? panelId : undefined}
186
+ >
187
+ {L.date}
188
+ </Button>
189
+ </div>
190
+
191
+ {#if clearable}
192
+ <Button
193
+ onClick={clearSelection}
194
+ variant="danger"
195
+ disabled={!hasValue || disabled}
196
+ sz="xs"
197
+ >
198
+ {L.clear}
199
+ </Button>
200
+ {/if}
201
+
202
+ {#if open}
203
+ <div
204
+ role="presentation"
205
+ tabindex="-1"
206
+ class="fixed inset-0 z-[var(--z-overlay)]"
207
+ ></div>
208
+
209
+ <div
210
+ bind:this={panelEl}
211
+ id={panelId}
212
+ role="dialog"
213
+ aria-label={labelFinal}
214
+ class="z-[var(--z-modal)] p-3 border border-[var(--border-color-default)] rounded-[var(--radius-md)] bg-[var(--color-bg-surface)] shadow-[0_2px_6px_var(--shadow-color)]"
215
+ style={panelStyle}
216
+ >
217
+ <Calendar
218
+ value={value}
219
+ {min}
220
+ {max}
221
+ {locale}
222
+ onChange={handleSelect}
223
+ showOutsideDays
224
+ class="w-full h-full"
225
+ />
226
+ </div>
227
+ {/if}
228
+ </div>
229
+
230
+ <div
231
+ class="mt-3 p-4 bg-[var(--color-bg-surface)] text-center"
232
+ aria-live="polite"
233
+ >
234
234
  <p class="text-xs uppercase tracking-wide [color:var(--color-text-muted)]">
235
235
  {L.selectedDate}
236
236
  </p>
@@ -42,10 +42,10 @@
42
42
  * @note `sz` adjusts both dialog padding and text sizes to match the rest of the system tokens.
43
43
  */
44
44
  import type { Snippet } from "svelte";
45
- import Button from "./Button.svelte";
46
- import { type SizeKey, TEXT } from "./types";
47
- import { cx, focusFirstInteractive, trapFocus } from "../utils";
48
- import { getComponentText, getLangContext, getLangKey } from "./lang-context";
45
+ import Button from "./Button.svelte";
46
+ import { type SizeKey, TEXT } from "./types";
47
+ import { cx, focusFirstInteractive, trapFocus } from "../utils";
48
+ import { getComponentText, getLangContext, getLangKey } from "./lang-context";
49
49
 
50
50
  type Props = {
51
51
  open?: boolean;
@@ -73,9 +73,9 @@
73
73
  children,
74
74
  }: Props = $props();
75
75
 
76
- const langCtx = getLangContext();
77
- const langKey = $derived(getLangKey(langCtx));
78
- const L = $derived(getComponentText("dialog", langKey));
76
+ const langCtx = getLangContext();
77
+ const langKey = $derived(getLangKey(langCtx));
78
+ const L = $derived(getComponentText("dialog", langKey));
79
79
 
80
80
  let panelEl = $state<HTMLDivElement | null>(null);
81
81
  let releaseFocus: (() => void) | null = null;
@@ -98,7 +98,7 @@
98
98
  }
99
99
 
100
100
  const panelBase =
101
- "fusion-dialog bg-[var(--color-bg-surface)] rounded-[var(--radius-lg)] shadow-lg min-w-80 max-w-md w-full border border-[var(--border-color-default)]";
101
+ "fusion-dialog bg-[var(--color-bg-surface)] rounded-[var(--radius-lg)] shadow-[0_8px_24px_var(--shadow-color)] min-w-0 max-w-[min(100%,28rem)] max-h-[calc(100svh-var(--spacing-lg)*2)] overflow-auto w-full border border-[var(--border-color-default)]";
102
102
 
103
103
  const paddingBySize: Record<SizeKey, string> = {
104
104
  xs: "p-[var(--spacing-sm)]",
@@ -164,7 +164,7 @@
164
164
  {#if open}
165
165
  {#if modal}
166
166
  <div
167
- class="fixed inset-0 z-[var(--z-modal)] bg-oklch(0_0_0/var(--opacity-overlay)) flex items-center justify-center p-4"
167
+ class="fixed inset-0 z-[var(--z-modal)] bg-[oklch(0_0_0/var(--opacity-overlay))] flex items-center justify-center p-[var(--spacing-md)]"
168
168
  role="presentation"
169
169
  tabindex="-1"
170
170
  onkeydown={handleKeydown}
@@ -195,7 +195,7 @@
195
195
  </div>
196
196
  {:else}
197
197
  <div
198
- class="fixed top-4 right-4 z-[var(--z-modal)]"
198
+ class="fixed top-[var(--spacing-md)] right-[var(--spacing-md)] z-[var(--z-modal)] max-w-[calc(100vw-var(--spacing-md)*2)]"
199
199
  role="dialog"
200
200
  aria-modal="false"
201
201
  aria-label={title}
@@ -86,7 +86,7 @@
86
86
  }: Props = $props();
87
87
 
88
88
  const base =
89
- "w-full outline-none transition-colors duration-[var(--transition-fast)] ease-[var(--timing-default)] box-border rounded-[var(--radius-md)] border focus:border-[var(--border-color-focus)] focus:ring-2 focus:ring-[var(--border-color-focus)] disabled:opacity-[var(--opacity-disabled)] disabled:cursor-not-allowed";
89
+ "w-full outline-none transition-colors duration-[var(--transition-fast)] ease-[var(--timing-default)] box-border rounded-[var(--radius-md)] border focus:border-[var(--border-color-focus)] focus:ring-2 focus:ring-[var(--border-color-focus)] disabled:opacity-[var(--opacity-disabled)] disabled:cursor-not-allowed [@media(pointer:coarse)]:min-h-11";
90
90
 
91
91
  const sizes: Record<SizeKey, string> = {
92
92
  xs: "px-2 h-6",
@@ -18,6 +18,10 @@
18
18
  * @prop clearable {boolean} - Shows a clear button to reset selection
19
19
  * @default true
20
20
  *
21
+ * @prop maxBytes {number} - Maximum allowed file size in bytes
22
+ *
23
+ * @prop onError {(error: string) => void} - Fired when selected files are rejected
24
+ *
21
25
  * @prop placeholder {string} - Placeholder text for the drop zone
22
26
  *
23
27
  * @prop value {FileList | null} - Controlled selected files (bindable)
@@ -25,21 +29,19 @@
25
29
  *
26
30
  * @prop onFilesSelected {(files: FileList | null) => void} - Fired when files are chosen
27
31
  *
28
- * @prop onError {(error: string) => void} - Fired on validation errors
29
- *
30
32
  * @prop class {string} - Additional classes for the wrapper
31
33
  * @default ""
32
34
  *
33
35
  * @note The entire area is clickable and supports drag-and-drop.
34
36
  * @note After a selection, the underlying input resets its value, so choosing the same file twice still triggers updates.
35
- * @note `accept` does not apply to dropped files, only to the picker UI; validate files inside `onFilesSelected`.
37
+ * @note `accept` and `maxBytes` are enforced for both input and dropped files.
36
38
  * @note When `clearable=true`, the user can clear selected files and the callback receives `null`.
37
39
  * @note When `disabled=true`, clicks, drag events, focus, and keyboard input are blocked.
38
40
  */
39
41
  import type { HTMLAttributes } from "svelte/elements";
40
- import Button from "./Button.svelte";
41
- import { cx, formatFileSize } from "../utils";
42
- import { getComponentText, getLangContext, getLangKey } from "./lang-context";
42
+ import Button from "./Button.svelte";
43
+ import { cx, formatFileSize } from "../utils";
44
+ import { getComponentText, getLangContext, getLangKey } from "./lang-context";
43
45
 
44
46
  type Props = HTMLAttributes<HTMLDivElement> & {
45
47
  accept?: string;
@@ -49,79 +51,74 @@
49
51
  clearable?: boolean;
50
52
  placeholder?: string;
51
53
  value?: FileList | null;
54
+ maxBytes?: number;
52
55
  onFilesSelected?: (files: FileList | null) => void;
53
56
  onError?: (error: string) => void;
54
57
  class?: string;
55
58
  };
56
59
 
57
- let {
58
- accept = "*/*",
59
- multiple = false,
60
- label,
61
- disabled = false,
62
- clearable = true,
63
- placeholder,
64
- value = $bindable<FileList | null>(null),
65
- onFilesSelected,
66
- class: externalClass = "",
67
- ...rest
68
- }: Props = $props();
69
-
70
- const langCtx = getLangContext();
71
- const langKey = $derived(getLangKey(langCtx));
72
- const L = $derived(getComponentText("filePicker", langKey));
60
+ let {
61
+ accept = "*/*",
62
+ multiple = false,
63
+ label,
64
+ disabled = false,
65
+ clearable = true,
66
+ placeholder,
67
+ value = $bindable<FileList | null>(null),
68
+ maxBytes = Number.POSITIVE_INFINITY,
69
+ onFilesSelected,
70
+ onError,
71
+ class: externalClass = "",
72
+ ...rest
73
+ }: Props = $props();
74
+
75
+ const langCtx = getLangContext();
76
+ const langKey = $derived(getLangKey(langCtx));
77
+ const L = $derived(getComponentText("filePicker", langKey));
73
78
 
74
79
  const labelFinal = $derived(label ?? L.text);
75
80
  const placeholderFinal = $derived(placeholder ?? L.placeholder);
76
81
 
77
- let inputEl: HTMLInputElement;
78
- let isDragOver = $state(false);
82
+ let inputEl: HTMLInputElement;
83
+ let isDragOver = $state(false);
79
84
 
80
85
  const base = "inline-block w-full";
81
86
  const pickerClass = $derived(cx(base, externalClass));
82
87
 
83
- const hasValue = $derived(Boolean(value && value.length > 0));
84
- const fileNames = $derived(
85
- value
86
- ? Array.from(value)
87
- .map((file) => file.name)
88
- .join(", ")
89
- : ""
90
- );
91
- const totalBytes = $derived(
92
- value ? Array.from(value).reduce((acc, file) => acc + file.size, 0) : 0
93
- );
88
+ const hasValue = $derived(Boolean(value && value.length > 0));
89
+ const fileNames = $derived(
90
+ value
91
+ ? Array.from(value)
92
+ .map((file) => file.name)
93
+ .join(", ")
94
+ : ""
95
+ );
96
+ const totalBytes = $derived(
97
+ value ? Array.from(value).reduce((acc, file) => acc + file.size, 0) : 0
98
+ );
94
99
 
95
100
  function handleButtonClick() {
96
101
  if (disabled) return;
97
102
  inputEl?.click();
98
103
  }
99
104
 
100
- function handleFileChange(event: Event) {
101
- const target = event.target as HTMLInputElement;
102
- const files = target.files;
103
- value = files;
104
- if (files && files.length > 0) {
105
- onFilesSelected?.(files);
106
- }
107
- if (inputEl) {
108
- inputEl.value = "";
109
- }
110
- }
105
+ function handleFileChange(event: Event) {
106
+ const target = event.target as HTMLInputElement;
107
+ selectFiles(target.files);
108
+ if (inputEl) {
109
+ inputEl.value = "";
110
+ }
111
+ }
111
112
 
112
113
  function handleDrop(event: DragEvent) {
113
114
  event.preventDefault();
114
115
  isDragOver = false;
115
- if (disabled) return;
116
- const files = event.dataTransfer?.files;
117
- value = files || null;
118
- if (files && files.length > 0) {
119
- onFilesSelected?.(files);
120
- }
121
- if (inputEl) {
122
- inputEl.value = "";
123
- }
124
- }
116
+ if (disabled) return;
117
+ selectFiles(event.dataTransfer?.files ?? null);
118
+ if (inputEl) {
119
+ inputEl.value = "";
120
+ }
121
+ }
125
122
 
126
123
  function handleDragOver(event: DragEvent) {
127
124
  event.preventDefault();
@@ -147,14 +144,70 @@
147
144
  }
148
145
  }
149
146
 
150
- function clearSelection() {
151
- if (!clearable) return;
152
- value = null;
153
- if (inputEl) {
154
- inputEl.value = "";
155
- }
156
- onFilesSelected?.(null);
157
- }
147
+ function clearSelection() {
148
+ if (!clearable) return;
149
+ value = null;
150
+ if (inputEl) {
151
+ inputEl.value = "";
152
+ }
153
+ onFilesSelected?.(null);
154
+ }
155
+
156
+ function selectFiles(files: FileList | null) {
157
+ const acceptedFiles = filterFiles(files);
158
+ value = acceptedFiles;
159
+ if (acceptedFiles && acceptedFiles.length > 0) {
160
+ onFilesSelected?.(acceptedFiles);
161
+ }
162
+ }
163
+
164
+ function filterFiles(files: FileList | null) {
165
+ if (!files || files.length === 0) return null;
166
+
167
+ const selected = Array.from(files);
168
+ const accepted = selected.filter(isAllowedFile);
169
+
170
+ if (accepted.length !== selected.length) {
171
+ onError?.("Some files were rejected by type or size constraints.");
172
+ }
173
+
174
+ if (accepted.length === 0) return null;
175
+ if (accepted.length === selected.length) return files;
176
+
177
+ return toFileList(accepted);
178
+ }
179
+
180
+ function isAllowedFile(file: File) {
181
+ if (Number.isFinite(maxBytes) && file.size > maxBytes) return false;
182
+ return matchesAccept(file, accept);
183
+ }
184
+
185
+ function matchesAccept(file: File, acceptValue: string) {
186
+ const rules = acceptValue
187
+ .split(",")
188
+ .map((rule) => rule.trim().toLowerCase())
189
+ .filter(Boolean);
190
+
191
+ if (rules.length === 0 || rules.includes("*/*")) return true;
192
+
193
+ const fileName = file.name.toLowerCase();
194
+ const fileType = file.type.toLowerCase();
195
+
196
+ return rules.some((rule) => {
197
+ if (rule.startsWith(".")) return fileName.endsWith(rule);
198
+ if (rule.endsWith("/*")) return fileType.startsWith(rule.slice(0, -1));
199
+ return fileType === rule;
200
+ });
201
+ }
202
+
203
+ function toFileList(files: File[]) {
204
+ if (typeof DataTransfer === "undefined") return null;
205
+ const transfer = new DataTransfer();
206
+ for (const file of files) {
207
+ transfer.items.add(file);
208
+ }
209
+ return transfer.files;
210
+ }
158
211
  </script>
159
212
 
160
213
  <div class={pickerClass} {...rest}>
@@ -188,7 +241,7 @@
188
241
  class="mt-2 p-4 border-2 border-dashed rounded-[var(--radius-md)] text-center transition-colors duration-200"
189
242
  class:border-[var(--color-primary)]={isDragOver && !disabled}
190
243
  class:border-[var(--border-color-default)]={!isDragOver || disabled}
191
- class:bg-[var(--color-bg-hover)]={isDragOver && !disabled}
244
+ class:bg-[var(--color-bg-hover)]={isDragOver && !disabled}
192
245
  class:cursor-pointer={!disabled}
193
246
  class:opacity-[var(--opacity-disabled)]={disabled}
194
247
  class:cursor-not-allowed={disabled}
@@ -229,14 +282,14 @@
229
282
  {placeholderFinal}
230
283
  {/if}
231
284
  </p>
232
- {#if hasValue && value}
233
- <p class="text-sm mt-1 [color:var(--color-text-muted)]">
234
- {L.fileCount.replace("{n}", String(value.length))}
235
-
236
- {#if multiple && value.length > 1}
237
- - {L.totalSize}: {formatFileSize(totalBytes)}
238
- {/if}
239
- </p>
240
- {/if}
285
+ {#if hasValue && value}
286
+ <p class="text-sm mt-1 [color:var(--color-text-muted)]">
287
+ {L.fileCount.replace("{n}", String(value.length))}
288
+
289
+ {#if multiple && value.length > 1}
290
+ - {L.totalSize}: {formatFileSize(totalBytes)}
291
+ {/if}
292
+ </p>
293
+ {/if}
241
294
  </div>
242
295
  </div>