svelte-comp 1.2.2 → 1.2.6

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.

Potentially problematic release.


This version of svelte-comp might be problematic. Click here for more details.

Files changed (84) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/dist/App.svelte +0 -551
  4. package/dist/App.svelte.d.ts +0 -3
  5. package/dist/Container.svelte +0 -60
  6. package/dist/Container.svelte.d.ts +0 -12
  7. package/dist/app.css +0 -235
  8. package/dist/index.d.ts +0 -5
  9. package/dist/index.js +0 -6
  10. package/dist/lang.d.ts +0 -1081
  11. package/dist/lang.js +0 -1096
  12. package/dist/lib/Accordion.svelte +0 -155
  13. package/dist/lib/Accordion.svelte.d.ts +0 -40
  14. package/dist/lib/Button.svelte +0 -170
  15. package/dist/lib/Button.svelte.d.ts +0 -53
  16. package/dist/lib/Card.svelte +0 -103
  17. package/dist/lib/Card.svelte.d.ts +0 -42
  18. package/dist/lib/Carousel.svelte +0 -293
  19. package/dist/lib/Carousel.svelte.d.ts +0 -13
  20. package/dist/lib/CheckBox.svelte +0 -210
  21. package/dist/lib/CheckBox.svelte.d.ts +0 -53
  22. package/dist/lib/CodeView.svelte +0 -307
  23. package/dist/lib/CodeView.svelte.d.ts +0 -64
  24. package/dist/lib/ColorPicker.svelte +0 -161
  25. package/dist/lib/ColorPicker.svelte.d.ts +0 -40
  26. package/dist/lib/DatePicker.svelte +0 -170
  27. package/dist/lib/DatePicker.svelte.d.ts +0 -53
  28. package/dist/lib/Dialog.svelte +0 -235
  29. package/dist/lib/Dialog.svelte.d.ts +0 -58
  30. package/dist/lib/Field.svelte +0 -299
  31. package/dist/lib/Field.svelte.d.ts +0 -8
  32. package/dist/lib/FilePicker.svelte +0 -241
  33. package/dist/lib/FilePicker.svelte.d.ts +0 -52
  34. package/dist/lib/Form.svelte +0 -438
  35. package/dist/lib/Form.svelte.d.ts +0 -20
  36. package/dist/lib/Hamburger.svelte +0 -211
  37. package/dist/lib/Hamburger.svelte.d.ts +0 -52
  38. package/dist/lib/Menu.svelte +0 -623
  39. package/dist/lib/Menu.svelte.d.ts +0 -33
  40. package/dist/lib/PaginatedCard.svelte +0 -73
  41. package/dist/lib/PaginatedCard.svelte.d.ts +0 -11
  42. package/dist/lib/Pagination.svelte +0 -98
  43. package/dist/lib/Pagination.svelte.d.ts +0 -9
  44. package/dist/lib/PrimaryColorSelect.svelte +0 -113
  45. package/dist/lib/PrimaryColorSelect.svelte.d.ts +0 -9
  46. package/dist/lib/ProgressBar.svelte +0 -141
  47. package/dist/lib/ProgressBar.svelte.d.ts +0 -48
  48. package/dist/lib/ProgressCircle.svelte +0 -192
  49. package/dist/lib/ProgressCircle.svelte.d.ts +0 -39
  50. package/dist/lib/Radio.svelte +0 -189
  51. package/dist/lib/Radio.svelte.d.ts +0 -55
  52. package/dist/lib/SearchInput.svelte +0 -97
  53. package/dist/lib/SearchInput.svelte.d.ts +0 -12
  54. package/dist/lib/Select.svelte +0 -524
  55. package/dist/lib/Select.svelte.d.ts +0 -21
  56. package/dist/lib/Slider.svelte +0 -253
  57. package/dist/lib/Slider.svelte.d.ts +0 -56
  58. package/dist/lib/Splitter.svelte +0 -150
  59. package/dist/lib/Splitter.svelte.d.ts +0 -43
  60. package/dist/lib/Switch.svelte +0 -167
  61. package/dist/lib/Switch.svelte.d.ts +0 -42
  62. package/dist/lib/Table.svelte +0 -299
  63. package/dist/lib/Table.svelte.d.ts +0 -17
  64. package/dist/lib/Tabs.svelte +0 -213
  65. package/dist/lib/Tabs.svelte.d.ts +0 -48
  66. package/dist/lib/ThemeToggle.svelte +0 -127
  67. package/dist/lib/ThemeToggle.svelte.d.ts +0 -32
  68. package/dist/lib/TimePicker.svelte +0 -269
  69. package/dist/lib/TimePicker.svelte.d.ts +0 -48
  70. package/dist/lib/Toast.svelte +0 -226
  71. package/dist/lib/Toast.svelte.d.ts +0 -14
  72. package/dist/lib/Tooltip.svelte +0 -110
  73. package/dist/lib/Tooltip.svelte.d.ts +0 -40
  74. package/dist/lib/index.d.ts +0 -32
  75. package/dist/lib/index.js +0 -33
  76. package/dist/lib/lang.d.ts +0 -149
  77. package/dist/lib/lang.js +0 -141
  78. package/dist/lib/types/index.d.ts +0 -111
  79. package/dist/lib/types/index.js +0 -26
  80. package/dist/main.d.ts +0 -3
  81. package/dist/main.js +0 -7
  82. package/dist/styles.css +0 -232
  83. package/dist/utils/index.d.ts +0 -34
  84. package/dist/utils/index.js +0 -268
@@ -1,438 +0,0 @@
1
- <!-- src/lib/Form.svelte -->
2
- <script lang="ts">
3
- /**
4
- * @component Form
5
- * @description Declarative, schema-driven form generator. Renders `Field`, `Select`, and `CheckBox` based on `FieldSchema`. Supports validation, controlled state, and an external API via `expose`.
6
- *
7
- * @prop schema {FieldSchema[]} - Field configuration for the generated form
8
- *
9
- * @prop value {FormValues} - Initial form data
10
- * @default {}
11
- *
12
- * @prop rowGap {number | SizeKey} - Vertical spacing between fields
13
- * @default "md"
14
- *
15
- * @prop validateOn {"input" | "blur" | "submit"} - When validation should run
16
- * @default "blur"
17
- *
18
- * @prop onChange {(form: FormValues) => void} - Fired when form values change
19
- *
20
- * @prop onSubmit {(form: FormValues, ctx: { reset: () => void }) => void | Promise<void>} - Submission handler
21
- *
22
- * @prop formId {string} - Stable identifier for form elements
23
- *
24
- * @prop expose {(api: FormApi) => void} - Exposes imperative Form API
25
- *
26
- * @prop labelAlign {AlignText} - Alignment for labels
27
- * @options left|center|right
28
- * @default "left"
29
- *
30
- * @prop labelWeight {LabelWeight} - Font weight for labels
31
- * @options normal|medium|semibold|bold
32
- * @default "medium"
33
- *
34
- * @prop labelSize {SizeKey} - Size preset for labels
35
- * @options xs|sm|md|lg|xl
36
- * @default "md"
37
- *
38
- * @prop compact {boolean} - Enables denser sizing across controls
39
- * @default false
40
- *
41
- * @note Initial value for each field: `value[name]` → `schema.default` → `''` (or `false` for checkboxes).
42
- * @note `validateOn='input'|'blur'|'submit'` controls when validators run; built-in checks: `required`, `number`, and `email` regex.
43
- * @note `when(form)` hides a field dynamically; hidden fields are skipped during validation.
44
- * @note `Select` options are coerced to strings for the underlying control; provide string values if you rely on strict equality.
45
- * @note Errors are rendered with stable `id`s and wired via `aria-describedby`; `invalid` flags are passed to inputs.
46
- * @note `expose` provides `{ reset, submit, validate, getData }`; `validate` returns `Promise<boolean>`.
47
- * @note `compact` reduces control sizes (`xs→xs`, `sm→xs`, `md→sm`, `lg→md`, `xl→lg`) and centers labels where applicable.
48
- */
49
- import Field from "./Field.svelte";
50
- import Select from "./Select.svelte";
51
- import CheckBox from "./CheckBox.svelte";
52
- import Toast from "./Toast.svelte";
53
- import type {
54
- AlignText,
55
- LabelWeight,
56
- SizeKey,
57
- ToastVariant,
58
- FieldSchema,
59
- FormApi,
60
- FormValues,
61
- } from "./types";
62
- import { TEXT } from "./types";
63
- import { cx, debounce } from "../utils";
64
-
65
- type Props = {
66
- schema: FieldSchema[];
67
- value?: FormValues;
68
- rowGap?: number | SizeKey;
69
- validateOn?: "input" | "blur" | "submit";
70
- onChange?: (form: FormValues) => void;
71
- onSubmit?: (
72
- form: FormValues,
73
- ctx: { reset: () => void }
74
- ) => void | Promise<void>;
75
- formId?: string;
76
- expose?: (api: FormApi) => void;
77
- labelAlign?: AlignText;
78
- labelWeight?: LabelWeight;
79
- labelSize?: SizeKey;
80
- compact?: boolean;
81
- };
82
-
83
- let {
84
- schema,
85
- value = {},
86
- rowGap = "md",
87
- validateOn = "blur",
88
- onChange,
89
- onSubmit,
90
- formId,
91
- expose,
92
- labelAlign = "left",
93
- labelWeight = "medium",
94
- labelSize = "md",
95
- compact = false,
96
- }: Props = $props();
97
-
98
- const compactMap = {
99
- xs: "xs",
100
- sm: "xs",
101
- md: "sm",
102
- lg: "md",
103
- xl: "lg",
104
- } as const;
105
-
106
- const weightClasses = {
107
- normal: "font-normal",
108
- medium: "font-medium",
109
- semibold: "font-semibold",
110
- bold: "font-bold",
111
- } as const;
112
-
113
- const gapClasses = {
114
- xs: "gap-1",
115
- sm: "gap-2",
116
- md: "gap-4",
117
- lg: "gap-6",
118
- xl: "gap-8",
119
- } as const;
120
-
121
- const toStr = (v: unknown) => (v == null ? "" : String(v));
122
-
123
- let form = $state<FormValues>({});
124
-
125
- const touched = $state<Record<string, boolean>>({});
126
- const errors = $state<Record<string, string[]>>({});
127
- let toasts = $state<
128
- Array<{
129
- id: number;
130
- message: string;
131
- variant: ToastVariant;
132
- title?: string;
133
- }>
134
- >([]);
135
- let toastId = 0;
136
-
137
- const inputWidthClass = $derived(compact ? "" : "w-full");
138
-
139
- const rowGapClass = $derived(
140
- typeof rowGap === "number" ? "" : (gapClasses[rowGap] ?? gapClasses.md)
141
- );
142
-
143
- const rowGapStyle = $derived(typeof rowGap === "number" ? rowGap + "px" : "");
144
-
145
- const getFieldValue = (name: string): string | number => {
146
- const current = form[name];
147
- return typeof current === "number" ? current : toStr(current);
148
- };
149
-
150
- function getCompactSize(size: SizeKey) {
151
- return compact ? (compactMap[size] ?? "md") : size;
152
- }
153
-
154
- const globalFormInstanceCounter = globalThis as unknown as {
155
- __svelteCompFormInstanceCounter?: number;
156
- };
157
- globalFormInstanceCounter.__svelteCompFormInstanceCounter ??= 0;
158
- const instanceId = $state(globalFormInstanceCounter.__svelteCompFormInstanceCounter++);
159
-
160
- const baseFormId = $derived.by(() => {
161
- if (formId && formId.trim()) return formId.trim();
162
- const key = schema.map((f) => f.name).join("|");
163
- let hash = 0;
164
- for (let i = 0; i < key.length; i += 1) {
165
- hash = (hash << 5) - hash + key.charCodeAt(i);
166
- hash |= 0;
167
- }
168
- const suffix = Math.abs(hash >>> 0).toString(36) || "form";
169
- return `form-${suffix}`;
170
- });
171
-
172
- const stableFormId = $derived.by(() => {
173
- if (formId && formId.trim()) return baseFormId;
174
- return `${baseFormId}-${instanceId}`;
175
- });
176
-
177
- $effect(() => {
178
- const nextNames = new Set(schema.map((f) => f.name));
179
-
180
- for (const key of Object.keys(form)) {
181
- if (!nextNames.has(key)) {
182
- delete form[key];
183
- delete touched[key];
184
- delete errors[key];
185
- }
186
- }
187
-
188
- for (const f of schema) {
189
- const hasIncoming = Object.prototype.hasOwnProperty.call(value, f.name);
190
- if (hasIncoming) {
191
- form[f.name] = value[f.name];
192
- } else if (!(f.name in form)) {
193
- form[f.name] = f.default ?? (f.type === "checkbox" ? false : "");
194
- }
195
-
196
- if (!(f.name in touched)) touched[f.name] = false;
197
- if (!errors[f.name]) errors[f.name] = [];
198
- }
199
- });
200
-
201
- const scheduleValidation = debounce((name: string) => {
202
- void validateField(name);
203
- }, 150);
204
-
205
- function setValue(name: string, v: unknown) {
206
- if (form[name] === v) return;
207
- form[name] = v;
208
- if (validateOn === "input") scheduleValidation(name);
209
- onChange?.({ ...form });
210
- }
211
-
212
- function blur(name: string) {
213
- if (!touched[name]) touched[name] = true;
214
- if (validateOn !== "submit") validateField(name);
215
- }
216
-
217
- function removeToast(id: number) {
218
- toasts = toasts.filter((t) => t.id !== id);
219
- }
220
-
221
- function addToast(variant: ToastVariant, message: string, title?: string) {
222
- const id = toastId++;
223
- toasts = [...toasts, { id, message, variant, title }];
224
- }
225
-
226
- async function validateField(name: string) {
227
- const f = schema.find((s) => s.name === name);
228
- if (!f) return;
229
- const val = form[name];
230
- const out: string[] = [];
231
-
232
- if (
233
- f.required &&
234
- (val === "" || val == null || (f.type === "checkbox" && !val))
235
- ) {
236
- out.push("Required");
237
- }
238
-
239
- if (f.type === "number" && val !== "" && Number.isNaN(Number(val))) {
240
- out.push("Must be a number");
241
- }
242
-
243
- if (f.type === "email" && val !== "") {
244
- const ok = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(val));
245
- if (!ok) out.push("Invalid email");
246
- }
247
-
248
- if (f.validators) {
249
- for (const v of f.validators) {
250
- const r = await v(val, form);
251
- if (r) out.push(r);
252
- }
253
- }
254
-
255
- errors[name] = out;
256
- }
257
-
258
- function showField(f: FieldSchema) {
259
- return f.when ? !!f.when(form) : true;
260
- }
261
-
262
- const visible = $derived(schema.filter(showField));
263
- const visibleNames = $derived(visible.map((f) => f.name));
264
-
265
- async function validateForm() {
266
- await Promise.all(visibleNames.map(validateField));
267
- return visibleNames.every((n) => (errors[n]?.length ?? 0) === 0);
268
- }
269
-
270
- function reset() {
271
- for (const f of schema) {
272
- form[f.name] = f.default ?? (f.type === "checkbox" ? false : "");
273
- touched[f.name] = false;
274
- errors[f.name] = [];
275
- }
276
- onChange?.({ ...form });
277
- }
278
-
279
- async function submit(e?: Event) {
280
- e?.preventDefault?.();
281
- const ok = await validateForm();
282
- if (!ok) return;
283
- try {
284
- await onSubmit?.({ ...form }, { reset });
285
- addToast("success", "Form submitted successfully");
286
- } catch (err) {
287
- addToast("danger", "Form submission failed", "Error");
288
- throw err;
289
- }
290
- }
291
-
292
- $effect(() => {
293
- const api: FormApi = {
294
- reset,
295
- submit: () => submit(),
296
- validate: () => validateForm(),
297
- getData: () => ({ ...form }),
298
- setValue: (name, value) => setValue(name, value),
299
- };
300
- expose?.(api);
301
- });
302
- </script>
303
-
304
- <form
305
- id={stableFormId}
306
- onsubmit={submit}
307
- novalidate
308
- class={cx(
309
- "grid font-sans w-full",
310
- rowGapClass,
311
- compact && "gap-2 justify-items-center"
312
- )}
313
- style:gap={rowGapStyle}
314
- >
315
- {#each visible as f (f.name)}
316
- {#key f.name}
317
- <div
318
- class={cx(
319
- "grid gap-1 w-full min-w-0",
320
- compact ? "justify-items-center" : "justify-items-stretch"
321
- )}
322
- >
323
- {#if f.label}
324
- <div
325
- class={cx(
326
- weightClasses[labelWeight],
327
- compact
328
- ? "text-xs -mb-1 text-center"
329
- : [
330
- TEXT[labelSize],
331
- labelAlign === "center"
332
- ? "text-center"
333
- : labelAlign === "right"
334
- ? "text-right"
335
- : "text-left",
336
- ],
337
- "text-[var(--color-text-default)] leading-normal"
338
- )}
339
- >
340
- {f.label}
341
- {#if f.required}
342
- <span class="text-[var(--color-text-default)]">*</span>
343
- {/if}
344
- </div>
345
- {/if}
346
-
347
- {#if f.type === "select"}
348
- {@const hasErr =
349
- (touched[f.name] || validateOn !== "submit") &&
350
- errors[f.name]?.length > 0}
351
- {@const errId = hasErr ? `${stableFormId}-err-${f.name}` : undefined}
352
- <Select
353
- class={cx(f.class, inputWidthClass)}
354
- options={(f.options ?? []).map((o) => ({
355
- label: toStr(o.label),
356
- value: toStr(o.value),
357
- disabled: o.disabled,
358
- }))}
359
- sz={getCompactSize(f.sz ?? "md")}
360
- variant={f.variant ?? "default"}
361
- value={toStr(form[f.name])}
362
- onChange={(v: string) => setValue(f.name, v)}
363
- onblur={() => blur(f.name)}
364
- invalid={hasErr}
365
- describedBy={errId}
366
- />
367
- {:else if f.type === "checkbox"}
368
- <div class={cx(f.class, compact && "mx-auto justify-self-center")}>
369
- <CheckBox
370
- class={f.class ?? ""}
371
- sz={getCompactSize(f.sz ?? "md")}
372
- variant="default"
373
- checked={!!form[f.name]}
374
- onChange={(c: boolean) => setValue(f.name, c)}
375
- onblur={() => blur(f.name)}
376
- />
377
- </div>
378
- {:else}
379
- {@const hasErr =
380
- (touched[f.name] || validateOn !== "submit") &&
381
- errors[f.name]?.length > 0}
382
-
383
- {@const errId = hasErr ? `${stableFormId}-err-${f.name}` : undefined}
384
-
385
- <Field
386
- class={cx(f.class, inputWidthClass)}
387
- as={f.type === "textarea" ? "textarea" : "input"}
388
- type={f.type === "number"
389
- ? "number"
390
- : f.type === "password"
391
- ? "password"
392
- : f.type === "email"
393
- ? "email"
394
- : "text"}
395
- rows={f.rows ?? 3}
396
- parseNumber={f.type === "number"}
397
- sz={getCompactSize(f.sz ?? "md")}
398
- variant={f.variant ?? "default"}
399
- value={getFieldValue(f.name)}
400
- onChange={(v: string | number) => setValue(f.name, v)}
401
- onblur={() => blur(f.name)}
402
- invalid={hasErr}
403
- describedBy={errId}
404
- autocomplete={f.type === "email" ? "email" : undefined}
405
- inputmode={f.type === "email" ? "email" : undefined}
406
- />
407
- {/if}
408
-
409
- {#if (touched[f.name] || validateOn !== "submit") && errors[f.name]?.length}
410
- <ul
411
- id={`${stableFormId}-err-${f.name}`}
412
- class={cx("m-0 pl-4 list-none", compact && "text-center")}
413
- >
414
- {#each errors[f.name] ?? [] as m, idx (`${f.name}-${idx}`)}
415
- <li
416
- class={cx(
417
- "text-xs leading-tight mt-1 text-[var(--color-text-red)]",
418
- compact && "text-[10px]"
419
- )}
420
- >
421
- {m}
422
- </li>
423
- {/each}
424
- </ul>
425
- {/if}
426
- </div>
427
- {/key}
428
- {/each}
429
- </form>
430
-
431
- {#each toasts as t (t.id)}
432
- <Toast
433
- title={t.title}
434
- message={t.message}
435
- variant={t.variant}
436
- onClose={() => removeToast(t.id)}
437
- />
438
- {/each}
@@ -1,20 +0,0 @@
1
- import type { AlignText, LabelWeight, SizeKey, FieldSchema, FormApi, FormValues } from "./types";
2
- type Props = {
3
- schema: FieldSchema[];
4
- value?: FormValues;
5
- rowGap?: number | SizeKey;
6
- validateOn?: "input" | "blur" | "submit";
7
- onChange?: (form: FormValues) => void;
8
- onSubmit?: (form: FormValues, ctx: {
9
- reset: () => void;
10
- }) => void | Promise<void>;
11
- formId?: string;
12
- expose?: (api: FormApi) => void;
13
- labelAlign?: AlignText;
14
- labelWeight?: LabelWeight;
15
- labelSize?: SizeKey;
16
- compact?: boolean;
17
- };
18
- declare const Form: import("svelte").Component<Props, {}, "">;
19
- type Form = ReturnType<typeof Form>;
20
- export default Form;
@@ -1,211 +0,0 @@
1
- <!-- src/lib/Hamburger.svelte -->
2
- <script lang="ts">
3
- /**
4
- * @component Hamburger
5
- * @description Off-canvas navigation drawer controlled by a floating hamburger button.
6
- *
7
- * @prop menuItems {Item[]} - Menu entries rendered in the drawer
8
- * @default []
9
- *
10
- * @prop activeItem {string} - ID of the currently active item
11
- * @default ""
12
- *
13
- * @prop header {Snippet} - Custom content rendered above the menu
14
- *
15
- * @prop footer {Snippet} - Custom content rendered below the menu
16
- *
17
- * @prop closeOnSelect {boolean} - Automatically closes after selecting an item
18
- * @default true
19
- *
20
- * @prop onSelect {(id: string) => void} - Fired when a menu item is chosen
21
- *
22
- * @prop onOpenChange {(v: boolean) => void} - Fired when open state changes in controlled mode
23
- *
24
- * @prop pressed {boolean} - Controlled open state
25
- *
26
- * @prop class {string} - Extra classes applied to the trigger button
27
- * @default ""
28
- *
29
- * @prop width {number | string} - Drawer width (px or CSS value)
30
- * @default 300
31
- *
32
- * @note Clicking outside the panel or pressing `Escape` closes the drawer.
33
- * @note Focus moves to the first interactive element inside the panel, is trapped while open, and returns to the trigger on close.
34
- * @note In controlled mode (`pressed` is defined), state changes are requested via `onOpenChange(open)`.
35
- * @note When `menuItems` is empty, a "No items" placeholder is shown.
36
- * @note The drawer uses `role=\"dialog\"` and `aria-modal=\"true\"`; the trigger reflects state via `aria-expanded`.
37
- */
38
- import type { Snippet } from "svelte";
39
- import type { Item } from "./types";
40
- import { cx, throttle, focusFirstInteractive, trapFocus } from "../utils";
41
-
42
- type Props = {
43
- menuItems?: Item[];
44
- activeItem?: string;
45
- header?: Snippet;
46
- footer?: Snippet;
47
- closeOnSelect?: boolean;
48
- onSelect?: (id: string) => void;
49
- onOpenChange?: (v: boolean) => void;
50
- pressed?: boolean;
51
- class?: string;
52
- width?: number | string;
53
- };
54
-
55
- let {
56
- menuItems = [],
57
- activeItem = "",
58
- header,
59
- footer,
60
- closeOnSelect = true,
61
- onSelect,
62
- onOpenChange,
63
- pressed,
64
- class: externalClass = "",
65
- width = 300,
66
- }: Props = $props();
67
-
68
- let triggerEl = $state<HTMLButtonElement | undefined>(undefined);
69
- let panelEl = $state<HTMLDivElement | undefined>(undefined);
70
- let releaseFocus: (() => void) | null = null;
71
-
72
- let _open = $state(false);
73
- const open = $derived(pressed ?? _open);
74
-
75
- function setOpen(v: boolean) {
76
- if (pressed === undefined) {
77
- _open = v;
78
- } else {
79
- onOpenChange?.(v);
80
- }
81
- }
82
-
83
- function toggle() {
84
- setOpen(!open);
85
- }
86
-
87
- function closeMenu() {
88
- setOpen(false);
89
- queueMicrotask(() => triggerEl?.focus());
90
- }
91
-
92
- const throttledClose = throttle(() => closeMenu(), 150);
93
-
94
- function handleKeydown(e: KeyboardEvent) {
95
- if (e.key === "Escape") throttledClose();
96
- }
97
-
98
- $effect(() => {
99
- if (open && panelEl) {
100
- queueMicrotask(() => {
101
- focusFirstInteractive(panelEl!);
102
- });
103
- releaseFocus?.();
104
- releaseFocus = trapFocus(panelEl);
105
- document.addEventListener("keydown", handleKeydown);
106
- } else {
107
- releaseFocus?.();
108
- releaseFocus = null;
109
- document.removeEventListener("keydown", handleKeydown);
110
- }
111
-
112
- return () => {
113
- document.removeEventListener("keydown", handleKeydown);
114
- releaseFocus?.();
115
- releaseFocus = null;
116
- };
117
- });
118
-
119
- const triggerBase =
120
- "fixed top-4 left-4 inline-flex items-center justify-center h-8 w-8 rounded-md border border-[var(--border-color-default)] bg-[var(--color-bg-secondary)] hover:bg-[var(--color-bg-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--border-color-focus)] transition-colors z-[var(--z-modal)]";
121
-
122
- const triggerClass = $derived(cx(triggerBase, externalClass));
123
- </script>
124
-
125
- <button
126
- type="button"
127
- aria-label="Toggle navigation"
128
- aria-expanded={open}
129
- class={triggerClass}
130
- onclick={toggle}
131
- bind:this={triggerEl}
132
- >
133
- <span class="relative block w-5 h-3.5">
134
- <span
135
- class={cx(
136
- "absolute left-0 top-1/2 h-[2px] w-full bg-current transition-transform duration-200",
137
- open ? "translate-y-[-50%] rotate-45" : "translate-y-[calc(-50%_-_6px)]"
138
- )}
139
- ></span>
140
- <span
141
- class={cx(
142
- "absolute left-0 top-1/2 h-[2px] w-full bg-current transition-opacity duration-200 translate-y-[-50%]",
143
- open ? "opacity-0" : "opacity-100"
144
- )}
145
- ></span>
146
- <span
147
- class={cx(
148
- "absolute left-0 top-1/2 h-[2px] w-full bg-current transition-transform duration-200",
149
- open
150
- ? "translate-y-[-50%] -rotate-45"
151
- : "translate-y-[calc(-50%_+_6px)]"
152
- )}
153
- ></span>
154
- </span>
155
- </button>
156
-
157
- {#if open}
158
- <div class="fixed inset-0 z-[var(--z-overlay)] flex">
159
- <div
160
- role="dialog"
161
- aria-modal="true"
162
- tabindex="-1"
163
- bind:this={panelEl}
164
- class="flex flex-col h-full bg-[var(--color-bg-surface)] shadow-xl"
165
- style={`width:${typeof width === "number" ? `${width}px` : width}`}
166
- >
167
- {#if header}
168
- <div class="p-4 border-b border-[var(--border-color-default)]">
169
- {@render header?.()}
170
- </div>
171
- {/if}
172
-
173
- <div class="flex-1 overflow-y-auto" tabindex="-1">
174
- {#if menuItems.length === 0}
175
- <div class="text-xs opacity-70 px-3 py-2 text-center">No items</div>
176
- {:else}
177
- <ul class="grid gap-2 p-4">
178
- {#each menuItems as it (it.id)}
179
- <li>
180
- <button
181
- type="button"
182
- class="w-full text-left px-3 py-2 rounded-md hover:bg-[var(--color-bg-hover)] focus:outline-[var(--border-color-focus)] focus:outline-2 transition-colors"
183
- aria-current={activeItem === it.id ? "page" : undefined}
184
- onclick={() => {
185
- onSelect?.(it.id);
186
- if (closeOnSelect) closeMenu();
187
- }}
188
- >
189
- {it.label}
190
- </button>
191
- </li>
192
- {/each}
193
- </ul>
194
- {/if}
195
- </div>
196
-
197
- {#if footer}
198
- <div class="p-4 border-t border-[var(--border-color-default)]">
199
- {@render footer?.()}
200
- </div>
201
- {/if}
202
- </div>
203
-
204
- <button
205
- type="button"
206
- class="flex-1 bg-black/40"
207
- aria-hidden="true"
208
- onclick={closeMenu}
209
- ></button>
210
- </div>
211
- {/if}