@salmexio/ui 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,427 @@
1
+ <!--
2
+ @component Modal
3
+
4
+ Win2K × Basquiat — Dialog with canvas surface, bold 3px border, title bar header.
5
+ Follows WAI-ARIA Dialog (Modal) pattern: focus trap, restore focus on close,
6
+ aria-labelledby, aria-describedby, aria-modal. Escape and backdrop close.
7
+ For full inert support (background not focusable), render modal in a portal
8
+ and set inert on main content when modalOpenCount > 0 (see modalStore).
9
+ -->
10
+ <script lang="ts">
11
+ import type { Snippet } from 'svelte';
12
+ import { createFocusTrap, Keys, generateId } from '../../utils/keyboard.js';
13
+ import { modalOpenCount } from '../modalStore.js';
14
+
15
+ type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'full';
16
+ type ModalPosition = 'center' | 'top';
17
+ type ScrollBehavior = 'inside' | 'outside';
18
+ /** APG: 'first' = first focusable in body; 'title' = dialog title (for long content so AT reads title first). */
19
+ type InitialFocusStrategy = 'first' | 'title';
20
+
21
+ interface Props {
22
+ open?: boolean;
23
+ id?: string;
24
+ title?: string;
25
+ size?: ModalSize;
26
+ position?: ModalPosition;
27
+ scrollBehavior?: ScrollBehavior;
28
+ showCloseButton?: boolean;
29
+ closeOnBackdrop?: boolean;
30
+ closeOnEscape?: boolean;
31
+ preventClose?: boolean;
32
+ /** Element to focus when opening (overrides initialFocusStrategy). */
33
+ initialFocus?: HTMLElement | null;
34
+ /** APG: 'first' = first focusable; 'title' = focus title (tabindex="-1") so screen readers announce it first. */
35
+ initialFocusStrategy?: InitialFocusStrategy;
36
+ enableFocusTrap?: boolean;
37
+ header?: Snippet;
38
+ footer?: Snippet;
39
+ children?: Snippet;
40
+ class?: string;
41
+ onclose?: () => void;
42
+ closeBtnAriaLabel?: string;
43
+ testId?: string;
44
+ }
45
+
46
+ let {
47
+ open = false,
48
+ id,
49
+ title = 'Dialog',
50
+ size = 'md',
51
+ position = 'center',
52
+ scrollBehavior = 'inside',
53
+ showCloseButton = true,
54
+ closeOnBackdrop = true,
55
+ closeOnEscape = true,
56
+ preventClose = false,
57
+ initialFocus,
58
+ initialFocusStrategy = 'first',
59
+ enableFocusTrap = true,
60
+ header,
61
+ footer,
62
+ children,
63
+ class: className = '',
64
+ onclose,
65
+ closeBtnAriaLabel,
66
+ testId,
67
+ }: Props = $props();
68
+
69
+ const modalId = $derived(id ?? generateId('modal'));
70
+ const titleId = $derived(`${modalId}-title`);
71
+ const descriptionId = $derived(`${modalId}-description`);
72
+
73
+ let dialogElement = $state<HTMLDivElement | null>(null);
74
+ let focusTrap: ReturnType<typeof createFocusTrap> | null = null;
75
+ let previouslyFocusedElement: HTMLElement | null = null;
76
+ let hasActivated = $state(false);
77
+
78
+ function getScrollbarWidth(): number {
79
+ return window.innerWidth - document.documentElement.clientWidth;
80
+ }
81
+
82
+ const FOCUSABLE =
83
+ 'button:not([disabled]):not([tabindex="-1"]), [href]:not([tabindex="-1"]), input:not([disabled]):not([tabindex="-1"]), select:not([disabled]):not([tabindex="-1"]), textarea:not([disabled]):not([tabindex="-1"]), [tabindex]:not([tabindex="-1"])';
84
+
85
+ function setInitialFocus() {
86
+ if (initialFocus) {
87
+ initialFocus.focus();
88
+ return;
89
+ }
90
+ if (!dialogElement) return;
91
+ const titleEl = dialogElement.querySelector<HTMLElement>('.salmex-modal-title-wrap');
92
+ const body = dialogElement.querySelector('.salmex-modal-body');
93
+ const first = body?.querySelector<HTMLElement>(FOCUSABLE);
94
+ const closeBtn = dialogElement.querySelector<HTMLElement>('.salmex-modal-close');
95
+
96
+ if (initialFocusStrategy === 'title' && titleEl) {
97
+ titleEl.focus();
98
+ return;
99
+ }
100
+ if (first) {
101
+ first.focus();
102
+ } else if (closeBtn) {
103
+ closeBtn.focus();
104
+ } else if (titleEl) {
105
+ titleEl.focus();
106
+ }
107
+ }
108
+
109
+ $effect(() => {
110
+ if (open) {
111
+ if (!hasActivated && dialogElement) {
112
+ previouslyFocusedElement = document.activeElement as HTMLElement | null;
113
+ modalOpenCount.update((n) => n + 1);
114
+ if (enableFocusTrap) {
115
+ focusTrap = createFocusTrap(dialogElement);
116
+ focusTrap.activate();
117
+ }
118
+ const scrollbarWidth = getScrollbarWidth();
119
+ document.body.style.overflow = 'hidden';
120
+ if (scrollbarWidth > 0) {
121
+ document.body.style.paddingRight = `${scrollbarWidth}px`;
122
+ }
123
+ hasActivated = true;
124
+ setTimeout(setInitialFocus, 0);
125
+ }
126
+ } else {
127
+ if (hasActivated) {
128
+ modalOpenCount.update((n) => Math.max(0, n - 1));
129
+ focusTrap?.deactivate();
130
+ focusTrap = null;
131
+ hasActivated = false;
132
+ document.body.style.overflow = '';
133
+ document.body.style.paddingRight = '';
134
+ if (previouslyFocusedElement?.focus) {
135
+ const el = previouslyFocusedElement;
136
+ setTimeout(() => el.focus(), 0);
137
+ }
138
+ previouslyFocusedElement = null;
139
+ }
140
+ }
141
+ });
142
+
143
+ function handleClose() {
144
+ if (!preventClose) onclose?.();
145
+ }
146
+
147
+ function handleBackdropClick(e: MouseEvent) {
148
+ if (closeOnBackdrop && e.target === e.currentTarget) handleClose();
149
+ }
150
+
151
+ function handleKeyDown(e: KeyboardEvent) {
152
+ if (closeOnEscape && e.key === Keys.Escape) {
153
+ e.preventDefault();
154
+ handleClose();
155
+ }
156
+ }
157
+
158
+ const closeLabel = $derived(closeBtnAriaLabel ?? 'Close dialog');
159
+ </script>
160
+
161
+ <svelte:window onkeydown={open ? handleKeyDown : undefined} />
162
+
163
+ {#if open}
164
+ <!-- Backdrop -->
165
+ <div
166
+ class="salmex-modal-backdrop salmex-modal-position-{position}"
167
+ role="presentation"
168
+ onclick={handleBackdropClick}
169
+ onkeydown={() => {}}
170
+ >
171
+ <div
172
+ bind:this={dialogElement}
173
+ id={modalId}
174
+ class="salmex-modal-container salmex-modal-{size} salmex-modal-scroll-{scrollBehavior} {className}"
175
+ role="dialog"
176
+ aria-modal="true"
177
+ aria-labelledby={titleId}
178
+ aria-describedby={children ? descriptionId : undefined}
179
+ data-testid={testId}
180
+ >
181
+ {#if header || title || showCloseButton}
182
+ <div class="salmex-modal-header">
183
+ <div id={titleId} class="salmex-modal-title-wrap" tabindex="-1">
184
+ {#if header}
185
+ {@render header()}
186
+ {:else if title}
187
+ <h2 class="salmex-modal-title">{title}</h2>
188
+ {/if}
189
+ </div>
190
+ {#if showCloseButton && !preventClose}
191
+ <button
192
+ type="button"
193
+ class="salmex-modal-close"
194
+ onclick={handleClose}
195
+ aria-label={closeLabel}
196
+ >
197
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
198
+ <line x1="18" y1="6" x2="6" y2="18" />
199
+ <line x1="6" y1="6" x2="18" y2="18" />
200
+ </svg>
201
+ </button>
202
+ {/if}
203
+ </div>
204
+ {/if}
205
+ {#if children}
206
+ <div id={descriptionId} class="salmex-modal-body">
207
+ {@render children()}
208
+ </div>
209
+ {/if}
210
+ {#if footer}
211
+ <div class="salmex-modal-footer">
212
+ {@render footer()}
213
+ </div>
214
+ {/if}
215
+ </div>
216
+ </div>
217
+ {/if}
218
+
219
+ <style>
220
+ .salmex-modal-backdrop {
221
+ position: fixed;
222
+ inset: 0;
223
+ z-index: var(--salmex-z-modal-backdrop, 1040);
224
+ display: flex;
225
+ justify-content: center;
226
+ padding: var(--salmex-space-6);
227
+ background: rgb(0 0 0 / 0.6);
228
+ overflow-y: auto;
229
+ animation: salmex-modal-fade-in var(--salmex-transition-base);
230
+ }
231
+
232
+ .salmex-modal-position-center {
233
+ align-items: center;
234
+ }
235
+
236
+ .salmex-modal-position-top {
237
+ align-items: flex-start;
238
+ padding-top: 3rem;
239
+ }
240
+
241
+ @keyframes salmex-modal-fade-in {
242
+ from {
243
+ opacity: 0;
244
+ }
245
+ to {
246
+ opacity: 1;
247
+ }
248
+ }
249
+
250
+ .salmex-modal-container {
251
+ position: relative;
252
+ display: flex;
253
+ flex-direction: column;
254
+ width: 100%;
255
+ max-height: calc(100vh - 2rem);
256
+ background: rgb(var(--salmex-window-surface));
257
+ border: 3px solid rgb(var(--salmex-button-dark-edge));
258
+ box-shadow: var(--salmex-shadow-lg);
259
+ animation: salmex-modal-scale-in var(--salmex-transition-slow) ease-out;
260
+ overflow: hidden;
261
+ }
262
+
263
+ @keyframes salmex-modal-scale-in {
264
+ from {
265
+ opacity: 0;
266
+ transform: scale(0.97) translateY(8px);
267
+ }
268
+ to {
269
+ opacity: 1;
270
+ transform: scale(1) translateY(0);
271
+ }
272
+ }
273
+
274
+ .salmex-modal-sm {
275
+ max-width: 24rem;
276
+ }
277
+ .salmex-modal-md {
278
+ max-width: 28rem;
279
+ }
280
+ .salmex-modal-lg {
281
+ max-width: 32rem;
282
+ }
283
+ .salmex-modal-xl {
284
+ max-width: 40rem;
285
+ }
286
+ .salmex-modal-full {
287
+ max-width: 56rem;
288
+ }
289
+
290
+ .salmex-modal-scroll-inside {
291
+ max-height: calc(100vh - 4rem);
292
+ }
293
+
294
+ .salmex-modal-scroll-inside .salmex-modal-body {
295
+ overflow-y: auto;
296
+ }
297
+
298
+ /* Title bar — Win2K gradient */
299
+ .salmex-modal-header {
300
+ display: flex;
301
+ align-items: center;
302
+ justify-content: space-between;
303
+ gap: var(--salmex-space-4);
304
+ padding: var(--salmex-space-4);
305
+ background: linear-gradient(
306
+ 90deg,
307
+ rgb(var(--salmex-electric-blue)),
308
+ rgb(var(--salmex-titlebar-bold))
309
+ );
310
+ color: rgb(var(--salmex-chalk-white));
311
+ border-bottom: 3px solid rgb(var(--salmex-button-dark-edge));
312
+ flex-shrink: 0;
313
+ font-family: var(--salmex-font-display);
314
+ }
315
+
316
+ :global([data-theme='dark']) .salmex-modal-header {
317
+ background: linear-gradient(
318
+ 90deg,
319
+ rgb(0 100 220),
320
+ rgb(0 140 255)
321
+ );
322
+ }
323
+
324
+ .salmex-modal-title-wrap {
325
+ flex: 1;
326
+ min-width: 0;
327
+ outline: none;
328
+ }
329
+
330
+ .salmex-modal-title-wrap:focus-visible {
331
+ outline: 2px solid rgb(var(--salmex-crown-yellow));
332
+ outline-offset: 2px;
333
+ }
334
+
335
+ .salmex-modal-title {
336
+ margin: 0;
337
+ font-size: var(--salmex-font-size-md);
338
+ font-weight: 900;
339
+ text-transform: uppercase;
340
+ letter-spacing: 0.5px;
341
+ line-height: 1.3;
342
+ }
343
+
344
+ .salmex-modal-close {
345
+ display: flex;
346
+ align-items: center;
347
+ justify-content: center;
348
+ width: 32px;
349
+ height: 32px;
350
+ padding: 0;
351
+ background: rgb(var(--salmex-button-face));
352
+ border: 2px solid rgb(var(--salmex-button-dark-edge));
353
+ color: rgb(var(--salmex-text-primary));
354
+ cursor: pointer;
355
+ flex-shrink: 0;
356
+ box-shadow:
357
+ inset 1px 1px 0 rgb(var(--salmex-button-highlight)),
358
+ inset -1px -1px 0 rgb(var(--salmex-button-shadow)),
359
+ 2px 2px 0 rgb(0 0 0 / 0.3);
360
+ transition: all var(--salmex-transition-fast);
361
+ }
362
+
363
+ .salmex-modal-close:hover {
364
+ background: rgb(var(--salmex-button-light));
365
+ }
366
+
367
+ .salmex-modal-close:active {
368
+ box-shadow:
369
+ inset -1px -1px 0 rgb(var(--salmex-button-highlight)),
370
+ inset 1px 1px 0 rgb(var(--salmex-button-shadow)),
371
+ 1px 1px 0 rgb(0 0 0 / 0.3);
372
+ transform: translate(1px, 1px);
373
+ }
374
+
375
+ .salmex-modal-close:focus-visible {
376
+ outline: none;
377
+ box-shadow:
378
+ inset 1px 1px 0 rgb(var(--salmex-button-highlight)),
379
+ inset -1px -1px 0 rgb(var(--salmex-button-shadow)),
380
+ 2px 2px 0 rgb(0 0 0 / 0.3),
381
+ 0 0 0 2px rgb(var(--salmex-midnight-black)),
382
+ 0 0 0 5px rgb(var(--salmex-crown-yellow));
383
+ }
384
+
385
+ .salmex-modal-body {
386
+ padding: var(--salmex-space-6);
387
+ flex: 1;
388
+ min-height: 0;
389
+ font-family: var(--salmex-font-system);
390
+ color: rgb(var(--salmex-text-primary));
391
+ }
392
+
393
+ .salmex-modal-footer {
394
+ display: flex;
395
+ align-items: center;
396
+ justify-content: flex-end;
397
+ gap: var(--salmex-space-3);
398
+ padding: var(--salmex-space-4);
399
+ border-top: 3px solid rgb(var(--salmex-button-dark-edge));
400
+ background: rgb(var(--salmex-bg-secondary));
401
+ flex-shrink: 0;
402
+ }
403
+
404
+ @media (prefers-reduced-motion: reduce) {
405
+ .salmex-modal-backdrop,
406
+ .salmex-modal-container {
407
+ animation: none;
408
+ }
409
+ }
410
+
411
+ @media (max-width: 640px) {
412
+ .salmex-modal-backdrop {
413
+ padding: 0;
414
+ align-items: flex-end;
415
+ }
416
+
417
+ .salmex-modal-position-top {
418
+ padding-top: 0;
419
+ }
420
+
421
+ .salmex-modal-container {
422
+ max-width: 100%;
423
+ max-height: 90vh;
424
+ margin: 0;
425
+ }
426
+ }
427
+ </style>
@@ -0,0 +1,43 @@
1
+ import type { Snippet } from 'svelte';
2
+ type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'full';
3
+ type ModalPosition = 'center' | 'top';
4
+ type ScrollBehavior = 'inside' | 'outside';
5
+ /** APG: 'first' = first focusable in body; 'title' = dialog title (for long content so AT reads title first). */
6
+ type InitialFocusStrategy = 'first' | 'title';
7
+ interface Props {
8
+ open?: boolean;
9
+ id?: string;
10
+ title?: string;
11
+ size?: ModalSize;
12
+ position?: ModalPosition;
13
+ scrollBehavior?: ScrollBehavior;
14
+ showCloseButton?: boolean;
15
+ closeOnBackdrop?: boolean;
16
+ closeOnEscape?: boolean;
17
+ preventClose?: boolean;
18
+ /** Element to focus when opening (overrides initialFocusStrategy). */
19
+ initialFocus?: HTMLElement | null;
20
+ /** APG: 'first' = first focusable; 'title' = focus title (tabindex="-1") so screen readers announce it first. */
21
+ initialFocusStrategy?: InitialFocusStrategy;
22
+ enableFocusTrap?: boolean;
23
+ header?: Snippet;
24
+ footer?: Snippet;
25
+ children?: Snippet;
26
+ class?: string;
27
+ onclose?: () => void;
28
+ closeBtnAriaLabel?: string;
29
+ testId?: string;
30
+ }
31
+ /**
32
+ * Modal
33
+ *
34
+ * Win2K × Basquiat — Dialog with canvas surface, bold 3px border, title bar header.
35
+ * Follows WAI-ARIA Dialog (Modal) pattern: focus trap, restore focus on close,
36
+ * aria-labelledby, aria-describedby, aria-modal. Escape and backdrop close.
37
+ * For full inert support (background not focusable), render modal in a portal
38
+ * and set inert on main content when modalOpenCount > 0 (see modalStore).
39
+ */
40
+ declare const Modal: import("svelte").Component<Props, {}, "">;
41
+ type Modal = ReturnType<typeof Modal>;
42
+ export default Modal;
43
+ //# sourceMappingURL=Modal.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Modal.svelte.d.ts","sourceRoot":"","sources":["../../../src/dialogs/Modal/Modal.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAKrC,KAAK,SAAS,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,GAAG,MAAM,CAAC;AACpD,KAAK,aAAa,GAAG,QAAQ,GAAG,KAAK,CAAC;AACtC,KAAK,cAAc,GAAG,QAAQ,GAAG,SAAS,CAAC;AAC3C,iHAAiH;AACjH,KAAK,oBAAoB,GAAG,OAAO,GAAG,OAAO,CAAC;AAE9C,UAAU,KAAK;IACd,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB,QAAQ,CAAC,EAAE,aAAa,CAAC;IACzB,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,sEAAsE;IACtE,YAAY,CAAC,EAAE,WAAW,GAAG,IAAI,CAAC;IAClC,iHAAiH;IACjH,oBAAoB,CAAC,EAAE,oBAAoB,CAAC;IAC5C,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,MAAM,CAAC,EAAE,MAAM,CAAC;CAChB;AA0KF;;;;;;;;GAQG;AACH,QAAA,MAAM,KAAK,2CAAwC,CAAC;AACpD,KAAK,KAAK,GAAG,UAAU,CAAC,OAAO,KAAK,CAAC,CAAC;AACtC,eAAe,KAAK,CAAC"}
@@ -0,0 +1,2 @@
1
+ export { default as Modal } from './Modal.svelte';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/dialogs/Modal/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,IAAI,KAAK,EAAE,MAAM,gBAAgB,CAAC"}
@@ -0,0 +1 @@
1
+ export { default as Modal } from './Modal.svelte';
@@ -0,0 +1,3 @@
1
+ export { Modal } from './Modal/index.js';
2
+ export { modalOpenCount } from './modalStore.js';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/dialogs/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAC;AACzC,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC"}
@@ -0,0 +1,2 @@
1
+ export { Modal } from './Modal/index.js';
2
+ export { modalOpenCount } from './modalStore.js';
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Count of currently open Modal instances.
3
+ * Use this to set the `inert` attribute on main content when any modal is open,
4
+ * so keyboard and screen reader users cannot interact with background content (APG).
5
+ *
6
+ * Example (when using a portal so the modal renders outside the inert wrapper):
7
+ * <div inert={$modalOpenCount > 0}>
8
+ * <Sidebar />
9
+ * <main>...</main>
10
+ * </div>
11
+ */
12
+ export declare const modalOpenCount: import("svelte/store").Writable<number>;
13
+ //# sourceMappingURL=modalStore.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"modalStore.d.ts","sourceRoot":"","sources":["../../src/dialogs/modalStore.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAIH,eAAO,MAAM,cAAc,yCAAc,CAAC"}
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Count of currently open Modal instances.
3
+ * Use this to set the `inert` attribute on main content when any modal is open,
4
+ * so keyboard and screen reader users cannot interact with background content (APG).
5
+ *
6
+ * Example (when using a portal so the modal renders outside the inert wrapper):
7
+ * <div inert={$modalOpenCount > 0}>
8
+ * <Sidebar />
9
+ * <main>...</main>
10
+ * </div>
11
+ */
12
+ import { writable } from 'svelte/store';
13
+ export const modalOpenCount = writable(0);
@@ -0,0 +1,328 @@
1
+ <!--
2
+ @component Checkbox
3
+
4
+ Win2K × Basquiat — 3D checkbox with bold borders, crown yellow check.
5
+ Uses native <input type="checkbox"> per WAI-ARIA APG (best practice: native over custom role="checkbox").
6
+ Label, description, error, indeterminate. Full a11y; focus and semantics follow the native input.
7
+ -->
8
+ <script lang="ts">
9
+ import { cn } from '../../utils/cn.js';
10
+ import { generateId } from '../../utils/keyboard.js';
11
+
12
+ type CheckboxSize = 'sm' | 'md' | 'lg';
13
+
14
+ interface Props {
15
+ id?: string;
16
+ name?: string;
17
+ value?: string;
18
+ label: string;
19
+ checked?: boolean;
20
+ description?: string;
21
+ required?: boolean;
22
+ disabled?: boolean;
23
+ error?: string;
24
+ indeterminate?: boolean;
25
+ size?: CheckboxSize;
26
+ hideLabel?: boolean;
27
+ class?: string;
28
+ onchange?: (event: Event) => void;
29
+ testId?: string;
30
+ validateOnMount?: boolean;
31
+ }
32
+
33
+ let {
34
+ id = generateId('checkbox'),
35
+ name,
36
+ value,
37
+ label,
38
+ checked = $bindable(false),
39
+ description = '',
40
+ required = false,
41
+ disabled = false,
42
+ error = '',
43
+ indeterminate = false,
44
+ size = 'md',
45
+ hideLabel = false,
46
+ class: className = '',
47
+ onchange,
48
+ testId,
49
+ validateOnMount = false,
50
+ }: Props = $props();
51
+
52
+ let inputElement = $state<HTMLInputElement | undefined>(undefined);
53
+ let hasInteracted = $state(false);
54
+
55
+ $effect(() => {
56
+ const el = inputElement;
57
+ if (el) el.indeterminate = indeterminate;
58
+ });
59
+
60
+ const showChecked = $derived(checked && !indeterminate);
61
+ const shouldValidate = $derived(hasInteracted || validateOnMount);
62
+ const showError = $derived(shouldValidate && !!error);
63
+ const errorId = $derived(`${id}-error`);
64
+ const descriptionId = $derived(`${id}-description`);
65
+ const ariaDescribedBy = $derived(
66
+ [showError && errorId, description && descriptionId].filter(Boolean).join(' ') || undefined
67
+ );
68
+
69
+ function handleChange(e: Event) {
70
+ hasInteracted = true;
71
+ onchange?.(e);
72
+ }
73
+ </script>
74
+
75
+ <div
76
+ class={cn(
77
+ 'salmex-checkbox',
78
+ `salmex-checkbox-${size}`,
79
+ disabled && 'salmex-checkbox-disabled',
80
+ className
81
+ )}
82
+ >
83
+ <label for={id} class="salmex-checkbox-label">
84
+ <input
85
+ bind:this={inputElement}
86
+ {id}
87
+ {name}
88
+ {value}
89
+ type="checkbox"
90
+ bind:checked
91
+ {disabled}
92
+ {required}
93
+ aria-invalid={showError}
94
+ aria-describedby={ariaDescribedBy}
95
+ class="salmex-checkbox-input"
96
+ data-testid={testId}
97
+ onchange={handleChange}
98
+ />
99
+ <span
100
+ class={cn(
101
+ 'salmex-checkbox-box',
102
+ (checked || indeterminate) && 'salmex-checkbox-box-checked',
103
+ indeterminate && 'salmex-checkbox-box-indeterminate',
104
+ showError && 'salmex-checkbox-box-error'
105
+ )}
106
+ aria-hidden="true"
107
+ >
108
+ {#if showChecked}
109
+ <svg class="salmex-checkbox-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
110
+ <polyline points="20 6 9 17 4 12" />
111
+ </svg>
112
+ {:else if indeterminate}
113
+ <svg class="salmex-checkbox-icon salmex-checkbox-icon-minus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" aria-hidden="true">
114
+ <line x1="5" y1="12" x2="19" y2="12" />
115
+ </svg>
116
+ {/if}
117
+ </span>
118
+ <span class="salmex-checkbox-content" class:salmex-sr-only={hideLabel}>
119
+ <span class="salmex-checkbox-text">
120
+ {label}
121
+ {#if required}
122
+ <span class="salmex-checkbox-required" aria-hidden="true">*</span>
123
+ {/if}
124
+ </span>
125
+ {#if description && !hideLabel}
126
+ <span id={descriptionId} class="salmex-checkbox-description">{description}</span>
127
+ {/if}
128
+ </span>
129
+ </label>
130
+ {#if showError}
131
+ <p id={errorId} class="salmex-checkbox-error" role="alert">{error}</p>
132
+ {/if}
133
+ </div>
134
+
135
+ <style>
136
+ .salmex-checkbox {
137
+ display: flex;
138
+ flex-direction: column;
139
+ gap: var(--salmex-space-1);
140
+ }
141
+
142
+ .salmex-checkbox-disabled {
143
+ opacity: 0.6;
144
+ }
145
+
146
+ .salmex-checkbox-label {
147
+ display: flex;
148
+ align-items: center;
149
+ gap: var(--salmex-space-2);
150
+ cursor: pointer;
151
+ user-select: none;
152
+ font-family: var(--salmex-font-system);
153
+ }
154
+
155
+ .salmex-checkbox-label:has(.salmex-checkbox-description) {
156
+ align-items: flex-start;
157
+ }
158
+
159
+ .salmex-checkbox-label:has(.salmex-checkbox-description) .salmex-checkbox-box {
160
+ margin-top: 2px;
161
+ }
162
+
163
+ .salmex-checkbox-disabled .salmex-checkbox-label {
164
+ cursor: not-allowed;
165
+ }
166
+
167
+ /* Visually hidden, not display:none — keeps focus and semantics */
168
+ .salmex-checkbox-input {
169
+ position: absolute;
170
+ width: 1px;
171
+ height: 1px;
172
+ padding: 0;
173
+ margin: -1px;
174
+ overflow: hidden;
175
+ clip: rect(0, 0, 0, 0);
176
+ white-space: nowrap;
177
+ border: 0;
178
+ }
179
+
180
+ /* Win2K × Basquiat — 3D box */
181
+ .salmex-checkbox-box {
182
+ flex-shrink: 0;
183
+ display: flex;
184
+ align-items: center;
185
+ justify-content: center;
186
+ background: rgb(var(--salmex-button-face));
187
+ border: 2px solid rgb(var(--salmex-button-dark-edge));
188
+ box-shadow:
189
+ inset 2px 2px 0 rgb(var(--salmex-button-highlight)),
190
+ inset -2px -2px 0 rgb(var(--salmex-button-shadow));
191
+ transition: background var(--salmex-transition-fast), border-color var(--salmex-transition-fast),
192
+ box-shadow var(--salmex-transition-fast);
193
+ color: rgb(var(--salmex-chalk-white));
194
+ }
195
+
196
+ .salmex-checkbox-sm .salmex-checkbox-box {
197
+ width: 16px;
198
+ height: 16px;
199
+ }
200
+
201
+ .salmex-checkbox-md .salmex-checkbox-box {
202
+ width: 20px;
203
+ height: 20px;
204
+ }
205
+
206
+ .salmex-checkbox-lg .salmex-checkbox-box {
207
+ width: 24px;
208
+ height: 24px;
209
+ }
210
+
211
+ .salmex-checkbox-icon {
212
+ width: 100%;
213
+ height: 100%;
214
+ padding: 2px;
215
+ }
216
+
217
+ .salmex-checkbox-icon-minus {
218
+ padding: 3px 4px;
219
+ }
220
+
221
+ /* Checked — electric blue fill, crown accent */
222
+ .salmex-checkbox-box-checked {
223
+ background: rgb(var(--salmex-electric-blue));
224
+ border-color: rgb(var(--salmex-button-dark-edge));
225
+ box-shadow:
226
+ inset 2px 2px 0 rgb(var(--salmex-button-highlight)),
227
+ inset -2px -2px 0 rgb(var(--salmex-button-shadow)),
228
+ 0 0 0 2px rgb(var(--salmex-border-dark));
229
+ }
230
+
231
+ :global([data-theme='dark']) .salmex-checkbox-box-checked {
232
+ background: rgb(var(--salmex-electric-blue));
233
+ border-color: rgb(var(--salmex-button-dark-edge));
234
+ }
235
+
236
+ /* Indeterminate — same as checked, minus icon */
237
+ .salmex-checkbox-box-indeterminate {
238
+ background: rgb(var(--salmex-electric-blue));
239
+ border-color: rgb(var(--salmex-button-dark-edge));
240
+ }
241
+
242
+ .salmex-checkbox-label:hover .salmex-checkbox-box:not(.salmex-checkbox-box-checked):not(.salmex-checkbox-box-indeterminate) {
243
+ border-color: rgb(var(--salmex-electric-blue));
244
+ }
245
+
246
+ /* Focus — crown yellow ring (design system) */
247
+ .salmex-checkbox-input:focus-visible + .salmex-checkbox-box {
248
+ box-shadow:
249
+ inset 2px 2px 0 rgb(var(--salmex-button-highlight)),
250
+ inset -2px -2px 0 rgb(var(--salmex-button-shadow)),
251
+ 0 0 0 2px rgb(var(--salmex-midnight-black)),
252
+ 0 0 0 5px rgb(var(--salmex-crown-yellow));
253
+ }
254
+
255
+ :global([data-theme='dark']) .salmex-checkbox-input:focus-visible + .salmex-checkbox-box {
256
+ box-shadow:
257
+ inset 2px 2px 0 rgb(var(--salmex-button-highlight)),
258
+ inset -2px -2px 0 rgb(var(--salmex-button-shadow)),
259
+ 0 0 0 3px rgb(var(--salmex-crown-yellow));
260
+ }
261
+
262
+ /* High contrast / forced colors: solid outline so focus is visible (APG) */
263
+ @media (forced-colors: active) {
264
+ .salmex-checkbox-input:focus-visible + .salmex-checkbox-box {
265
+ outline: 2px solid CanvasText;
266
+ outline-offset: 2px;
267
+ }
268
+ }
269
+
270
+ .salmex-checkbox-box-error {
271
+ border-color: rgb(var(--salmex-street-red));
272
+ }
273
+
274
+ :global([data-theme='dark']) .salmex-checkbox-box-error {
275
+ border-color: rgb(var(--salmex-street-red));
276
+ }
277
+
278
+ .salmex-checkbox-content {
279
+ display: flex;
280
+ flex-direction: column;
281
+ gap: 2px;
282
+ }
283
+
284
+ .salmex-checkbox-text {
285
+ font-size: var(--salmex-font-size-sm);
286
+ font-weight: 600;
287
+ color: rgb(var(--salmex-text-primary));
288
+ }
289
+
290
+ .salmex-checkbox-required {
291
+ color: rgb(var(--salmex-street-red));
292
+ margin-left: 2px;
293
+ }
294
+
295
+ .salmex-checkbox-description {
296
+ font-size: var(--salmex-font-size-xs);
297
+ color: rgb(var(--salmex-text-secondary));
298
+ }
299
+
300
+ .salmex-checkbox-error {
301
+ font-size: var(--salmex-font-size-xs);
302
+ font-weight: 600;
303
+ color: rgb(var(--salmex-street-red));
304
+ margin: 0 0 0 calc(20px + var(--salmex-space-2));
305
+ }
306
+
307
+ .salmex-checkbox-lg .salmex-checkbox-error {
308
+ margin-left: calc(24px + var(--salmex-space-2));
309
+ }
310
+
311
+ .salmex-sr-only {
312
+ position: absolute;
313
+ width: 1px;
314
+ height: 1px;
315
+ padding: 0;
316
+ margin: -1px;
317
+ overflow: hidden;
318
+ clip: rect(0, 0, 0, 0);
319
+ white-space: nowrap;
320
+ border: 0;
321
+ }
322
+
323
+ @media (prefers-reduced-motion: reduce) {
324
+ .salmex-checkbox-box {
325
+ transition: none;
326
+ }
327
+ }
328
+ </style>
@@ -0,0 +1,30 @@
1
+ type CheckboxSize = 'sm' | 'md' | 'lg';
2
+ interface Props {
3
+ id?: string;
4
+ name?: string;
5
+ value?: string;
6
+ label: string;
7
+ checked?: boolean;
8
+ description?: string;
9
+ required?: boolean;
10
+ disabled?: boolean;
11
+ error?: string;
12
+ indeterminate?: boolean;
13
+ size?: CheckboxSize;
14
+ hideLabel?: boolean;
15
+ class?: string;
16
+ onchange?: (event: Event) => void;
17
+ testId?: string;
18
+ validateOnMount?: boolean;
19
+ }
20
+ /**
21
+ * Checkbox
22
+ *
23
+ * Win2K × Basquiat — 3D checkbox with bold borders, crown yellow check.
24
+ * Uses native <input type="checkbox"> per WAI-ARIA APG (best practice: native over custom role="checkbox").
25
+ * Label, description, error, indeterminate. Full a11y; focus and semantics follow the native input.
26
+ */
27
+ declare const Checkbox: import("svelte").Component<Props, {}, "checked">;
28
+ type Checkbox = ReturnType<typeof Checkbox>;
29
+ export default Checkbox;
30
+ //# sourceMappingURL=Checkbox.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Checkbox.svelte.d.ts","sourceRoot":"","sources":["../../../src/forms/Checkbox/Checkbox.svelte.ts"],"names":[],"mappings":"AAOC,KAAK,YAAY,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;AAEvC,UAAU,KAAK;IACd,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,IAAI,CAAC,EAAE,YAAY,CAAC;IACpB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;IAClC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,eAAe,CAAC,EAAE,OAAO,CAAC;CAC1B;AA+FF;;;;;;GAMG;AACH,QAAA,MAAM,QAAQ,kDAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
@@ -0,0 +1,2 @@
1
+ export { default as Checkbox } from './Checkbox.svelte';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/forms/Checkbox/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,IAAI,QAAQ,EAAE,MAAM,mBAAmB,CAAC"}
@@ -0,0 +1 @@
1
+ export { default as Checkbox } from './Checkbox.svelte';
@@ -1,2 +1,3 @@
1
1
  export { TextInput } from './TextInput/index.js';
2
+ export { Checkbox } from './Checkbox/index.js';
2
3
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/forms/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/forms/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AACjD,OAAO,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC"}
@@ -1 +1,2 @@
1
1
  export { TextInput } from './TextInput/index.js';
2
+ export { Checkbox } from './Checkbox/index.js';
package/dist/index.d.ts CHANGED
@@ -3,5 +3,6 @@ export * from './navigation/index.js';
3
3
  export * from './layout/index.js';
4
4
  export * from './feedback/index.js';
5
5
  export * from './forms/index.js';
6
+ export * from './dialogs/index.js';
6
7
  export * from './utils/index.js';
7
8
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,uBAAuB,CAAC;AACtC,cAAc,uBAAuB,CAAC;AACtC,cAAc,mBAAmB,CAAC;AAClC,cAAc,qBAAqB,CAAC;AACpC,cAAc,kBAAkB,CAAC;AACjC,cAAc,kBAAkB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,uBAAuB,CAAC;AACtC,cAAc,uBAAuB,CAAC;AACtC,cAAc,mBAAmB,CAAC;AAClC,cAAc,qBAAqB,CAAC;AACpC,cAAc,kBAAkB,CAAC;AACjC,cAAc,oBAAoB,CAAC;AACnC,cAAc,kBAAkB,CAAC"}
package/dist/index.js CHANGED
@@ -3,4 +3,5 @@ export * from './navigation/index.js';
3
3
  export * from './layout/index.js';
4
4
  export * from './feedback/index.js';
5
5
  export * from './forms/index.js';
6
+ export * from './dialogs/index.js';
6
7
  export * from './utils/index.js';
@@ -3,12 +3,9 @@
3
3
  * Win2K reimagined by Jean-Michel Basquiat
4
4
  * Raw. Expressive. Powerful. Unfinished.
5
5
  *
6
- * Typography: restrained Windows-2000-ish workhorse + Basquiat-like accents (Google Fonts).
7
- * Functional default: Work Sans (UI), Roboto Mono (code), Space Grotesk (display), Caveat Brush (accent).
8
- * ARIA-critical text uses system/mono; brush fonts for decorative/duplicate only.
6
+ * Typography: Work Sans (UI), Roboto Mono (code), Space Grotesk (display), Caveat Brush (accent).
7
+ * Load fonts first in your app (e.g. in root layout CSS): @import url('https://fonts.googleapis.com/...') before other imports.
9
8
  */
10
- @import url('https://fonts.googleapis.com/css2?family=Work+Sans:wght@400;600;700&family=Roboto+Mono:wght@400;600;700&family=Space+Grotesk:wght@600;700&family=Caveat+Brush&display=swap');
11
-
12
9
  :root {
13
10
  /* ========================================
14
11
  BASQUIAT × WIN2K COLORS
@@ -1,2 +1,4 @@
1
1
  export { cn } from './cn.js';
2
+ export { createFocusTrap, generateId, Keys } from './keyboard.js';
3
+ export type { FocusTrap, Key } from './keyboard.js';
2
4
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/utils/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/utils/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAC7B,OAAO,EAAE,eAAe,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,eAAe,CAAC;AAClE,YAAY,EAAE,SAAS,EAAE,GAAG,EAAE,MAAM,eAAe,CAAC"}
@@ -1 +1,2 @@
1
1
  export { cn } from './cn.js';
2
+ export { createFocusTrap, generateId, Keys } from './keyboard.js';
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Keyboard and focus utilities for accessible components.
3
+ * WCAG 2.1 / WAI-ARIA aligned; used by Modal and other dialogs.
4
+ */
5
+ export declare const Keys: {
6
+ readonly Enter: "Enter";
7
+ readonly Space: " ";
8
+ readonly Escape: "Escape";
9
+ readonly Tab: "Tab";
10
+ readonly ArrowUp: "ArrowUp";
11
+ readonly ArrowDown: "ArrowDown";
12
+ readonly ArrowLeft: "ArrowLeft";
13
+ readonly ArrowRight: "ArrowRight";
14
+ readonly Home: "Home";
15
+ readonly End: "End";
16
+ };
17
+ export type Key = (typeof Keys)[keyof typeof Keys];
18
+ export interface FocusTrap {
19
+ activate: () => void;
20
+ deactivate: () => void;
21
+ }
22
+ /**
23
+ * Trap focus within a container (e.g. modal). Tab cycles inside; restores focus on deactivate.
24
+ */
25
+ export declare function createFocusTrap(container: HTMLElement): FocusTrap;
26
+ /** Unique ID for aria attributes (per-instance safe). */
27
+ export declare function generateId(prefix?: string): string;
28
+ //# sourceMappingURL=keyboard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"keyboard.d.ts","sourceRoot":"","sources":["../../src/utils/keyboard.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,eAAO,MAAM,IAAI;;;;;;;;;;;CAWP,CAAC;AAEX,MAAM,MAAM,GAAG,GAAG,CAAC,OAAO,IAAI,CAAC,CAAC,MAAM,OAAO,IAAI,CAAC,CAAC;AAEnD,MAAM,WAAW,SAAS;IACzB,QAAQ,EAAE,MAAM,IAAI,CAAC;IACrB,UAAU,EAAE,MAAM,IAAI,CAAC;CACvB;AAED;;GAEG;AACH,wBAAgB,eAAe,CAAC,SAAS,EAAE,WAAW,GAAG,SAAS,CA4CjE;AAED,yDAAyD;AACzD,wBAAgB,UAAU,CAAC,MAAM,SAAW,GAAG,MAAM,CAEpD"}
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Keyboard and focus utilities for accessible components.
3
+ * WCAG 2.1 / WAI-ARIA aligned; used by Modal and other dialogs.
4
+ */
5
+ export const Keys = {
6
+ Enter: 'Enter',
7
+ Space: ' ',
8
+ Escape: 'Escape',
9
+ Tab: 'Tab',
10
+ ArrowUp: 'ArrowUp',
11
+ ArrowDown: 'ArrowDown',
12
+ ArrowLeft: 'ArrowLeft',
13
+ ArrowRight: 'ArrowRight',
14
+ Home: 'Home',
15
+ End: 'End',
16
+ };
17
+ /**
18
+ * Trap focus within a container (e.g. modal). Tab cycles inside; restores focus on deactivate.
19
+ */
20
+ export function createFocusTrap(container) {
21
+ let previouslyFocused = null;
22
+ const focusableSelector = [
23
+ 'button:not([disabled]):not([tabindex="-1"])',
24
+ '[href]:not([tabindex="-1"])',
25
+ 'input:not([disabled]):not([tabindex="-1"])',
26
+ 'select:not([disabled]):not([tabindex="-1"])',
27
+ 'textarea:not([disabled]):not([tabindex="-1"])',
28
+ '[tabindex]:not([tabindex="-1"])',
29
+ ].join(', ');
30
+ function getFocusable() {
31
+ return Array.from(container.querySelectorAll(focusableSelector));
32
+ }
33
+ function onKeyDown(e) {
34
+ if (e.key !== Keys.Tab)
35
+ return;
36
+ const focusable = getFocusable();
37
+ if (focusable.length === 0)
38
+ return;
39
+ const first = focusable[0];
40
+ const last = focusable[focusable.length - 1];
41
+ if (e.shiftKey && document.activeElement === first) {
42
+ e.preventDefault();
43
+ last.focus();
44
+ }
45
+ else if (!e.shiftKey && document.activeElement === last) {
46
+ e.preventDefault();
47
+ first.focus();
48
+ }
49
+ }
50
+ return {
51
+ activate() {
52
+ previouslyFocused = document.activeElement;
53
+ container.addEventListener('keydown', onKeyDown, { capture: true });
54
+ },
55
+ deactivate() {
56
+ container.removeEventListener('keydown', onKeyDown, { capture: true });
57
+ if (previouslyFocused?.focus) {
58
+ previouslyFocused.focus();
59
+ }
60
+ previouslyFocused = null;
61
+ },
62
+ };
63
+ }
64
+ /** Unique ID for aria attributes (per-instance safe). */
65
+ export function generateId(prefix = 'salmex') {
66
+ return `${prefix}-${Math.random().toString(36).slice(2, 11)}`;
67
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salmexio/ui",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Salmex I/O Design System — Win2K-inspired UI component library for Salmex and Salmex I/O products",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",