@classic-homes/theme-svelte 1.5.1 → 1.7.0
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.
- package/dist/__tests__/sidebar-mobile.test.js +73 -0
- package/dist/lib/components/AlertDialog.svelte +1 -1
- package/dist/lib/components/Checkbox.svelte +1 -0
- package/dist/lib/components/Combobox.svelte +2 -2
- package/dist/lib/components/DataTable.svelte +16 -2
- package/dist/lib/components/DateTimePicker.svelte +6 -6
- package/dist/lib/components/Dialog.svelte +10 -8
- package/dist/lib/components/HeaderSearch.svelte +1 -1
- package/dist/lib/components/Input.svelte +1 -1
- package/dist/lib/components/NumberInput.svelte +2 -2
- package/dist/lib/components/OTPInput.svelte +4 -4
- package/dist/lib/components/PasswordInput.svelte +1 -1
- package/dist/lib/components/RadioGroup.svelte +1 -0
- package/dist/lib/components/Switch.svelte +1 -0
- package/dist/lib/components/Tabs.svelte +73 -28
- package/dist/lib/components/Textarea.svelte +1 -1
- package/dist/lib/components/data-table/DataTableEditableCell.svelte +1 -1
- package/dist/lib/components/data-table/DataTablePagination.svelte +7 -7
- package/dist/lib/components/layout/AppShell.svelte +6 -1
- package/dist/lib/components/layout/AppShell.svelte.d.ts +2 -0
- package/dist/lib/components/layout/AuthLayout.svelte +1 -1
- package/dist/lib/components/layout/DashboardLayout.svelte +32 -5
- package/dist/lib/components/layout/DashboardLayout.svelte.d.ts +2 -0
- package/dist/lib/components/layout/Header.svelte +5 -4
- package/dist/lib/components/layout/Sidebar.svelte +39 -9
- package/dist/lib/components/notifications/NotificationBulkActions.svelte +6 -3
- package/dist/lib/components/notifications/NotificationDialog.svelte +1 -1
- package/dist/lib/components/notifications/NotificationFilters.svelte +1 -1
- package/dist/lib/stores/sidebar.svelte.js +28 -8
- package/dist/lib/types/components.d.ts +6 -0
- package/package.json +1 -1
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidebar Mobile Semantics Tests
|
|
3
|
+
*
|
|
4
|
+
* Covers the mobile drawer rules: persisted desktop open-state must never
|
|
5
|
+
* open the drawer on a phone, drawer toggles never overwrite the desktop
|
|
6
|
+
* preference, and leaving mobile restores the desktop preference.
|
|
7
|
+
*
|
|
8
|
+
* Uses the shared singleton (module state), so each test re-initializes with
|
|
9
|
+
* a fresh appId to get an isolated localStorage namespace.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
12
|
+
import { sidebarStore } from '../lib/stores/sidebar.svelte.js';
|
|
13
|
+
let appCounter = 0;
|
|
14
|
+
function freshAppId() {
|
|
15
|
+
return `test-mobile-${++appCounter}`;
|
|
16
|
+
}
|
|
17
|
+
describe('sidebar mobile semantics', () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
localStorage.clear();
|
|
20
|
+
// Reset to a known desktop baseline.
|
|
21
|
+
sidebarStore.setMobile(false);
|
|
22
|
+
sidebarStore.open();
|
|
23
|
+
});
|
|
24
|
+
it('does not open the drawer from persisted state when initializing on mobile', () => {
|
|
25
|
+
const appId = freshAppId();
|
|
26
|
+
localStorage.setItem(`classic-theme-sidebar-${appId}-open`, 'true');
|
|
27
|
+
sidebarStore.setMobile(true);
|
|
28
|
+
sidebarStore.initialize(appId);
|
|
29
|
+
expect(sidebarStore.isOpen).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
it('applies persisted open state when initializing on desktop', () => {
|
|
32
|
+
const appId = freshAppId();
|
|
33
|
+
localStorage.setItem(`classic-theme-sidebar-${appId}-open`, 'false');
|
|
34
|
+
sidebarStore.initialize(appId);
|
|
35
|
+
expect(sidebarStore.isOpen).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
it('restores the persisted preference when leaving mobile, even if init ran while mobile', () => {
|
|
38
|
+
const appId = freshAppId();
|
|
39
|
+
localStorage.setItem(`classic-theme-sidebar-${appId}-open`, 'true');
|
|
40
|
+
sidebarStore.setMobile(true);
|
|
41
|
+
sidebarStore.initialize(appId);
|
|
42
|
+
expect(sidebarStore.isOpen).toBe(false);
|
|
43
|
+
sidebarStore.setMobile(false);
|
|
44
|
+
expect(sidebarStore.isOpen).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
it('entering mobile closes the drawer and stashes the desktop state', () => {
|
|
47
|
+
sidebarStore.initialize(freshAppId());
|
|
48
|
+
sidebarStore.open();
|
|
49
|
+
sidebarStore.setMobile(true);
|
|
50
|
+
expect(sidebarStore.isOpen).toBe(false);
|
|
51
|
+
sidebarStore.setMobile(false);
|
|
52
|
+
expect(sidebarStore.isOpen).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
it('mobile drawer toggles do not persist or overwrite the desktop preference', () => {
|
|
55
|
+
const appId = freshAppId();
|
|
56
|
+
sidebarStore.initialize(appId);
|
|
57
|
+
sidebarStore.open(); // persists 'true' as desktop preference
|
|
58
|
+
sidebarStore.setMobile(true);
|
|
59
|
+
sidebarStore.toggle(); // open drawer
|
|
60
|
+
sidebarStore.close(); // close drawer
|
|
61
|
+
expect(localStorage.getItem(`classic-theme-sidebar-${appId}-open`)).toBe('true');
|
|
62
|
+
sidebarStore.setMobile(false);
|
|
63
|
+
expect(sidebarStore.isOpen).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
it('a collapse made on desktop survives a mobile round-trip', () => {
|
|
66
|
+
sidebarStore.initialize(freshAppId());
|
|
67
|
+
sidebarStore.close();
|
|
68
|
+
sidebarStore.setMobile(true);
|
|
69
|
+
sidebarStore.toggle(); // user opens the drawer
|
|
70
|
+
sidebarStore.setMobile(false);
|
|
71
|
+
expect(sidebarStore.isOpen).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -97,7 +97,7 @@
|
|
|
97
97
|
/>
|
|
98
98
|
<AlertDialogPrimitive.Content
|
|
99
99
|
class={cn(
|
|
100
|
-
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-black bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]
|
|
100
|
+
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100vw-2rem)] sm:max-w-lg max-h-[calc(100dvh-2rem)] overflow-y-auto translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border border-black bg-background p-4 sm:p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
|
|
101
101
|
className
|
|
102
102
|
)}
|
|
103
103
|
>
|
|
@@ -60,6 +60,7 @@
|
|
|
60
60
|
aria-invalid={hasError ? 'true' : undefined}
|
|
61
61
|
aria-describedby={hasError ? errorId : undefined}
|
|
62
62
|
class={cn(
|
|
63
|
+
"after:absolute after:-inset-3 after:content-['']",
|
|
63
64
|
'peer relative h-5 w-5 shrink-0 rounded-sm border ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground flex items-center justify-center mt-0.5',
|
|
64
65
|
hasError ? 'border-destructive' : 'border-primary',
|
|
65
66
|
className
|
|
@@ -166,7 +166,7 @@
|
|
|
166
166
|
onfocus={() => (open = true)}
|
|
167
167
|
onclick={() => (open = true)}
|
|
168
168
|
class={cn(
|
|
169
|
-
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
|
169
|
+
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-base sm:text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
|
170
170
|
error && 'border-destructive focus:ring-destructive',
|
|
171
171
|
className
|
|
172
172
|
)}
|
|
@@ -262,7 +262,7 @@
|
|
|
262
262
|
onfocus={() => (open = true)}
|
|
263
263
|
onclick={() => (open = true)}
|
|
264
264
|
class={cn(
|
|
265
|
-
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
|
265
|
+
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-base sm:text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
|
266
266
|
error && 'border-destructive focus:ring-destructive',
|
|
267
267
|
className
|
|
268
268
|
)}
|
|
@@ -329,6 +329,13 @@
|
|
|
329
329
|
table.toggleRowSelection(rowId);
|
|
330
330
|
}
|
|
331
331
|
|
|
332
|
+
// Literal class strings so Tailwind's content scan generates them.
|
|
333
|
+
const hideBelowClasses = {
|
|
334
|
+
sm: 'hidden sm:table-cell',
|
|
335
|
+
md: 'hidden md:table-cell',
|
|
336
|
+
lg: 'hidden lg:table-cell',
|
|
337
|
+
} as const;
|
|
338
|
+
|
|
332
339
|
// Check if row can be selected
|
|
333
340
|
function canSelectRow(row: T): boolean {
|
|
334
341
|
if (typeof enableRowSelection === 'function') {
|
|
@@ -364,6 +371,7 @@
|
|
|
364
371
|
class={cn(
|
|
365
372
|
'px-4 py-3 font-medium text-muted-foreground',
|
|
366
373
|
getAlignClass(column.align),
|
|
374
|
+
column.hideBelow && hideBelowClasses[column.hideBelow],
|
|
367
375
|
column.sortable && 'cursor-pointer select-none hover:bg-muted-hover'
|
|
368
376
|
)}
|
|
369
377
|
style={getColumnWidthStyle(column)}
|
|
@@ -408,7 +416,7 @@
|
|
|
408
416
|
</td>
|
|
409
417
|
{/if}
|
|
410
418
|
{#each table.columns as column (column.id)}
|
|
411
|
-
<td class=
|
|
419
|
+
<td class={cn('px-4 py-3', column.hideBelow && hideBelowClasses[column.hideBelow])}>
|
|
412
420
|
<Skeleton class="h-4 w-3/4" />
|
|
413
421
|
</td>
|
|
414
422
|
{/each}
|
|
@@ -451,7 +459,13 @@
|
|
|
451
459
|
|
|
452
460
|
{#each table.columns as column (column.id)}
|
|
453
461
|
{@const value = getColumnValue(row.original, column)}
|
|
454
|
-
<td
|
|
462
|
+
<td
|
|
463
|
+
class={cn(
|
|
464
|
+
'px-4 py-3',
|
|
465
|
+
getAlignClass(column.align),
|
|
466
|
+
column.hideBelow && hideBelowClasses[column.hideBelow]
|
|
467
|
+
)}
|
|
468
|
+
>
|
|
455
469
|
{table.getFormattedCellValue(row.original, column)}
|
|
456
470
|
</td>
|
|
457
471
|
{/each}
|
|
@@ -431,7 +431,7 @@
|
|
|
431
431
|
onkeydown={handleInputKeyDown}
|
|
432
432
|
{disabled}
|
|
433
433
|
class={cn(
|
|
434
|
-
'flex h-10 w-full items-center rounded-md border border-input bg-background px-3 py-2 pr-10 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
|
434
|
+
'flex h-10 w-full items-center rounded-md border border-input bg-background px-3 py-2 pr-10 text-base sm:text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
|
435
435
|
!value && !isEditing && 'text-muted-foreground',
|
|
436
436
|
displayError && 'border-destructive focus:ring-destructive',
|
|
437
437
|
className
|
|
@@ -483,7 +483,7 @@
|
|
|
483
483
|
<button
|
|
484
484
|
type="button"
|
|
485
485
|
onclick={prevMonth}
|
|
486
|
-
class="h-7 w-7 inline-flex items-center justify-center rounded-md bg-transparent hover:bg-accent hover:text-accent-foreground"
|
|
486
|
+
class="relative h-7 w-7 inline-flex items-center justify-center rounded-md bg-transparent hover:bg-accent hover:text-accent-foreground after:absolute after:-inset-2 after:content-['']"
|
|
487
487
|
aria-label="Previous month"
|
|
488
488
|
>
|
|
489
489
|
<svg
|
|
@@ -507,7 +507,7 @@
|
|
|
507
507
|
<button
|
|
508
508
|
type="button"
|
|
509
509
|
onclick={nextMonth}
|
|
510
|
-
class="h-7 w-7 inline-flex items-center justify-center rounded-md bg-transparent hover:bg-accent hover:text-accent-foreground"
|
|
510
|
+
class="relative h-7 w-7 inline-flex items-center justify-center rounded-md bg-transparent hover:bg-accent hover:text-accent-foreground after:absolute after:-inset-2 after:content-['']"
|
|
511
511
|
aria-label="Next month"
|
|
512
512
|
>
|
|
513
513
|
<svg
|
|
@@ -571,7 +571,7 @@
|
|
|
571
571
|
bind:value={hour}
|
|
572
572
|
onchange={updateTime}
|
|
573
573
|
aria-label="Hour"
|
|
574
|
-
class="h-8 rounded-md border border-input bg-background px-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
|
574
|
+
class="h-8 rounded-md border border-input bg-background px-2 text-base sm:text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
|
575
575
|
>
|
|
576
576
|
{#each hourOptions as h}
|
|
577
577
|
<option value={timeFormat === '12h' ? h : h}>
|
|
@@ -584,7 +584,7 @@
|
|
|
584
584
|
bind:value={minute}
|
|
585
585
|
onchange={updateTime}
|
|
586
586
|
aria-label="Minute"
|
|
587
|
-
class="h-8 rounded-md border border-input bg-background px-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
|
587
|
+
class="h-8 rounded-md border border-input bg-background px-2 text-base sm:text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
|
588
588
|
>
|
|
589
589
|
{#each minuteOptions as m}
|
|
590
590
|
<option value={m}>{String(m).padStart(2, '0')}</option>
|
|
@@ -595,7 +595,7 @@
|
|
|
595
595
|
bind:value={period}
|
|
596
596
|
onchange={updateTime}
|
|
597
597
|
aria-label="AM or PM"
|
|
598
|
-
class="h-8 rounded-md border border-input bg-background px-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
|
598
|
+
class="h-8 rounded-md border border-input bg-background px-2 text-base sm:text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
|
599
599
|
>
|
|
600
600
|
<option value="AM">AM</option>
|
|
601
601
|
<option value="PM">PM</option>
|
|
@@ -30,12 +30,14 @@
|
|
|
30
30
|
showClose?: boolean;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
// Every size is clamped to the viewport minus a 1rem gutter on phones; the
|
|
34
|
+
// named width only takes over from `sm:` up.
|
|
33
35
|
const sizeClasses = {
|
|
34
|
-
sm: 'max-w-sm',
|
|
35
|
-
md: 'max-w-lg',
|
|
36
|
-
lg: 'max-w-xl',
|
|
37
|
-
xl: 'max-w-2xl',
|
|
38
|
-
'2xl': 'max-w-3xl',
|
|
36
|
+
sm: 'max-w-[calc(100vw-2rem)] sm:max-w-sm',
|
|
37
|
+
md: 'max-w-[calc(100vw-2rem)] sm:max-w-lg',
|
|
38
|
+
lg: 'max-w-[calc(100vw-2rem)] sm:max-w-xl',
|
|
39
|
+
xl: 'max-w-[calc(100vw-2rem)] sm:max-w-2xl',
|
|
40
|
+
'2xl': 'max-w-[calc(100vw-2rem)] sm:max-w-3xl',
|
|
39
41
|
full: 'max-w-[calc(100vw-2rem)]',
|
|
40
42
|
};
|
|
41
43
|
|
|
@@ -82,7 +84,7 @@
|
|
|
82
84
|
/>
|
|
83
85
|
<DialogPrimitive.Content
|
|
84
86
|
class={cn(
|
|
85
|
-
'fixed left-[50%] top-[50%] z-50
|
|
87
|
+
'fixed left-[50%] top-[50%] z-50 flex max-h-[calc(100dvh-2rem)] w-full translate-x-[-50%] translate-y-[-50%] flex-col gap-4 border border-border bg-background shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg overflow-hidden',
|
|
86
88
|
sizeClasses[size],
|
|
87
89
|
className
|
|
88
90
|
)}
|
|
@@ -142,8 +144,8 @@
|
|
|
142
144
|
</div>
|
|
143
145
|
{/if}
|
|
144
146
|
|
|
145
|
-
<!-- Content section -->
|
|
146
|
-
<div class="dialog-content px-6 py-2">
|
|
147
|
+
<!-- Content section: scrolls when max-h clamps; header and footer stay pinned -->
|
|
148
|
+
<div class="dialog-content min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-2">
|
|
147
149
|
{@render children()}
|
|
148
150
|
</div>
|
|
149
151
|
|
|
@@ -259,7 +259,7 @@
|
|
|
259
259
|
value={query}
|
|
260
260
|
oninput={(e) => handleInput(e.currentTarget.value)}
|
|
261
261
|
onkeydown={handleKeydown}
|
|
262
|
-
class="flex h-12 w-full bg-transparent px-3 py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
|
262
|
+
class="flex h-12 w-full bg-transparent px-3 py-3 text-base sm:text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
|
263
263
|
autocomplete="off"
|
|
264
264
|
autocorrect="off"
|
|
265
265
|
spellcheck="false"
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
aria-invalid={ariaInvalid}
|
|
83
83
|
aria-required={required}
|
|
84
84
|
class={cn(
|
|
85
|
-
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
|
85
|
+
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base sm:text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
|
86
86
|
ariaInvalid === true || ariaInvalid === 'true'
|
|
87
87
|
? 'border-destructive focus-visible:ring-destructive'
|
|
88
88
|
: '',
|
|
@@ -191,7 +191,7 @@
|
|
|
191
191
|
{name}
|
|
192
192
|
id={componentId}
|
|
193
193
|
class={cn(
|
|
194
|
-
'flex h-10 w-full bg-background px-3 py-2 text-sm text-center ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
|
194
|
+
'flex h-10 w-full bg-background px-3 py-2 text-base sm:text-sm text-center ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
|
195
195
|
showStepper ? 'border-y border-input' : 'border border-input rounded-md',
|
|
196
196
|
error && 'border-destructive focus-visible:ring-destructive'
|
|
197
197
|
)}
|
|
@@ -283,7 +283,7 @@
|
|
|
283
283
|
{name}
|
|
284
284
|
id={componentId}
|
|
285
285
|
class={cn(
|
|
286
|
-
'flex h-10 w-full bg-background px-3 py-2 text-sm text-center ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
|
286
|
+
'flex h-10 w-full bg-background px-3 py-2 text-base sm:text-sm text-center ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
|
287
287
|
showStepper ? 'border-y border-input' : 'border border-input rounded-md',
|
|
288
288
|
error && 'border-destructive focus-visible:ring-destructive'
|
|
289
289
|
)}
|
|
@@ -224,7 +224,7 @@
|
|
|
224
224
|
{/if}
|
|
225
225
|
|
|
226
226
|
<div
|
|
227
|
-
class={cn('flex gap-2', className)}
|
|
227
|
+
class={cn('flex gap-1.5 sm:gap-2', className)}
|
|
228
228
|
role="group"
|
|
229
229
|
aria-label={label || 'One-time password input'}
|
|
230
230
|
aria-describedby={describedBy}
|
|
@@ -244,7 +244,7 @@
|
|
|
244
244
|
onpaste={handlePaste}
|
|
245
245
|
onfocus={handleFocus}
|
|
246
246
|
class={cn(
|
|
247
|
-
'h-12 w-12 text-
|
|
247
|
+
'h-10 w-10 text-base sm:h-12 sm:w-12 sm:text-lg text-center font-semibold rounded-md border border-input bg-background ring-offset-background transition-colors',
|
|
248
248
|
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
|
249
249
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
|
250
250
|
error && 'border-destructive focus:ring-destructive'
|
|
@@ -275,7 +275,7 @@
|
|
|
275
275
|
</div>
|
|
276
276
|
{:else}
|
|
277
277
|
<div
|
|
278
|
-
class={cn('flex gap-2', className)}
|
|
278
|
+
class={cn('flex gap-1.5 sm:gap-2', className)}
|
|
279
279
|
role="group"
|
|
280
280
|
aria-label="One-time password input"
|
|
281
281
|
aria-describedby={describedBy}
|
|
@@ -295,7 +295,7 @@
|
|
|
295
295
|
onpaste={handlePaste}
|
|
296
296
|
onfocus={handleFocus}
|
|
297
297
|
class={cn(
|
|
298
|
-
'h-12 w-12 text-
|
|
298
|
+
'h-10 w-10 text-base sm:h-12 sm:w-12 sm:text-lg text-center font-semibold rounded-md border border-input bg-background ring-offset-background transition-colors',
|
|
299
299
|
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
|
300
300
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
|
301
301
|
error && 'border-destructive focus:ring-destructive'
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
{id}
|
|
70
70
|
{autocomplete}
|
|
71
71
|
class={cn(
|
|
72
|
-
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 pr-10 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
|
72
|
+
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 pr-10 text-base sm:text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
|
73
73
|
className
|
|
74
74
|
)}
|
|
75
75
|
oninput={handleInput}
|
|
@@ -105,6 +105,7 @@
|
|
|
105
105
|
disabled={option.disabled || disabled}
|
|
106
106
|
id={`${componentName}-${option.value}`}
|
|
107
107
|
class={cn(
|
|
108
|
+
"after:absolute after:-inset-3 after:content-['']",
|
|
108
109
|
'relative aspect-square h-5 w-5 rounded-full border border-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 flex items-center justify-center',
|
|
109
110
|
error && 'border-destructive'
|
|
110
111
|
)}
|
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
aria-labelledby={ariaLabelledby}
|
|
67
67
|
onCheckedChange={handleCheckedChange}
|
|
68
68
|
class={cn(
|
|
69
|
+
"after:absolute after:-inset-y-2 after:inset-x-0 after:content-['']",
|
|
69
70
|
'peer relative inline-flex h-7 w-12 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
|
70
71
|
className
|
|
71
72
|
)}
|
|
@@ -50,6 +50,35 @@
|
|
|
50
50
|
// Store references to tab button elements for direct focus management
|
|
51
51
|
let tabRefs: Record<string, HTMLButtonElement | undefined> = $state({});
|
|
52
52
|
|
|
53
|
+
// Horizontal-scroll handling for tab lists wider than their container
|
|
54
|
+
let tabList: HTMLDivElement | undefined = $state();
|
|
55
|
+
let canScrollLeft = $state(false);
|
|
56
|
+
let canScrollRight = $state(false);
|
|
57
|
+
|
|
58
|
+
function updateScrollShadows() {
|
|
59
|
+
if (!tabList) return;
|
|
60
|
+
canScrollLeft = tabList.scrollLeft > 1;
|
|
61
|
+
canScrollRight = tabList.scrollLeft + tabList.clientWidth < tabList.scrollWidth - 1;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Scroll only the tab list (never the page) so deep-linked tabs are visible
|
|
65
|
+
function scrollTabIntoView(el: HTMLButtonElement) {
|
|
66
|
+
if (!tabList) return;
|
|
67
|
+
const listRect = tabList.getBoundingClientRect();
|
|
68
|
+
const rect = el.getBoundingClientRect();
|
|
69
|
+
if (rect.left < listRect.left) {
|
|
70
|
+
tabList.scrollLeft += rect.left - listRect.left - 8;
|
|
71
|
+
} else if (rect.right > listRect.right) {
|
|
72
|
+
tabList.scrollLeft += rect.right - listRect.right + 8;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
$effect(() => {
|
|
77
|
+
const el = tabRefs[value];
|
|
78
|
+
if (el) scrollTabIntoView(el);
|
|
79
|
+
updateScrollShadows();
|
|
80
|
+
});
|
|
81
|
+
|
|
53
82
|
// Create a reactive object for context
|
|
54
83
|
// Initialize with empty values - the effect will set them immediately
|
|
55
84
|
const tabsState = $state({ activeTab: '', tabsId: '' });
|
|
@@ -108,34 +137,50 @@
|
|
|
108
137
|
|
|
109
138
|
<div class={cn('w-full', className)}>
|
|
110
139
|
<!-- Tab List -->
|
|
111
|
-
<div
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
140
|
+
<div class="relative inline-flex max-w-full">
|
|
141
|
+
{#if canScrollLeft}
|
|
142
|
+
<div
|
|
143
|
+
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 rounded-l-md bg-gradient-to-r from-muted to-transparent"
|
|
144
|
+
aria-hidden="true"
|
|
145
|
+
></div>
|
|
146
|
+
{/if}
|
|
147
|
+
{#if canScrollRight}
|
|
148
|
+
<div
|
|
149
|
+
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 rounded-r-md bg-gradient-to-l from-muted to-transparent"
|
|
150
|
+
aria-hidden="true"
|
|
151
|
+
></div>
|
|
152
|
+
{/if}
|
|
153
|
+
<div
|
|
154
|
+
bind:this={tabList}
|
|
155
|
+
onscroll={updateScrollShadows}
|
|
156
|
+
role="tablist"
|
|
157
|
+
aria-label={label}
|
|
158
|
+
class="inline-flex h-10 max-w-full items-center justify-center overflow-x-auto rounded-md bg-muted p-1 text-muted-foreground [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
|
159
|
+
>
|
|
160
|
+
{#each tabs as tab, index (tab.id)}
|
|
161
|
+
<button
|
|
162
|
+
bind:this={tabRefs[tab.id]}
|
|
163
|
+
id={`${id}-tab-${tab.id}`}
|
|
164
|
+
role="tab"
|
|
165
|
+
type="button"
|
|
166
|
+
aria-selected={value === tab.id}
|
|
167
|
+
aria-controls={children && value === tab.id ? `${id}-panel-${tab.id}` : undefined}
|
|
168
|
+
tabindex={value === tab.id ? 0 : -1}
|
|
169
|
+
disabled={tab.disabled}
|
|
170
|
+
class={cn(
|
|
171
|
+
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
|
172
|
+
value === tab.id
|
|
173
|
+
? 'bg-background text-foreground shadow-sm'
|
|
174
|
+
: 'hover:bg-background-hover hover:text-foreground',
|
|
175
|
+
tab.disabled && 'opacity-50 pointer-events-none cursor-not-allowed'
|
|
176
|
+
)}
|
|
177
|
+
onclick={() => selectTab(tab.id)}
|
|
178
|
+
onkeydown={(e) => handleKeyDown(e, index)}
|
|
179
|
+
>
|
|
180
|
+
{tab.label}
|
|
181
|
+
</button>
|
|
182
|
+
{/each}
|
|
183
|
+
</div>
|
|
139
184
|
</div>
|
|
140
185
|
|
|
141
186
|
<!-- Tab Panels Container -->
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
aria-describedby={ariaDescribedby}
|
|
63
63
|
aria-invalid={ariaInvalid}
|
|
64
64
|
class={cn(
|
|
65
|
-
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-gray-500 dark:placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 resize-y',
|
|
65
|
+
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base sm:text-sm ring-offset-background placeholder:text-gray-500 dark:placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 resize-y',
|
|
66
66
|
ariaInvalid === true || ariaInvalid === 'true'
|
|
67
67
|
? 'border-destructive focus-visible:ring-destructive'
|
|
68
68
|
: '',
|
|
@@ -106,7 +106,7 @@
|
|
|
106
106
|
oninput={(e: Event) => (editValue = (e.target as HTMLInputElement).value)}
|
|
107
107
|
onkeydown={handleKeydown}
|
|
108
108
|
onblur={handleBlur}
|
|
109
|
-
class="h-7 w-full rounded border border-input bg-background px-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
109
|
+
class="h-7 w-full rounded border border-input bg-background px-2 text-base sm:text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
110
110
|
/>
|
|
111
111
|
{:else}
|
|
112
112
|
<span class="border-b border-dashed border-transparent hover:border-primary">
|
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
}
|
|
34
34
|
</script>
|
|
35
35
|
|
|
36
|
-
<div class={cn('flex items-center justify-between px-2 py-4', className)}>
|
|
37
|
-
<div class="
|
|
36
|
+
<div class={cn('flex flex-wrap items-center justify-between gap-3 px-2 py-4', className)}>
|
|
37
|
+
<div class="basis-full text-sm text-muted-foreground sm:basis-auto sm:flex-1">
|
|
38
38
|
{#if showSelectedCount && table.selectedCount > 0}
|
|
39
39
|
{table.selectedCount} of {table.totalRows} row(s) selected
|
|
40
40
|
{:else}
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
{/if}
|
|
43
43
|
</div>
|
|
44
44
|
|
|
45
|
-
<div class="flex items-center gap-6 lg:gap-8">
|
|
45
|
+
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 sm:gap-x-6 lg:gap-x-8">
|
|
46
46
|
<!-- Page size selector -->
|
|
47
47
|
<div class="flex items-center gap-2">
|
|
48
48
|
<span class="text-sm text-muted-foreground">Rows per page</span>
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
<Button
|
|
68
68
|
variant="outline"
|
|
69
69
|
size="icon"
|
|
70
|
-
class="h-8 w-8"
|
|
70
|
+
class="relative h-8 w-8 after:absolute after:-inset-1.5 after:content-['']"
|
|
71
71
|
disabled={!table.canPreviousPage}
|
|
72
72
|
onclick={() => table.firstPage()}
|
|
73
73
|
aria-label="Go to first page"
|
|
@@ -87,7 +87,7 @@
|
|
|
87
87
|
<Button
|
|
88
88
|
variant="outline"
|
|
89
89
|
size="icon"
|
|
90
|
-
class="h-8 w-8"
|
|
90
|
+
class="relative h-8 w-8 after:absolute after:-inset-1.5 after:content-['']"
|
|
91
91
|
disabled={!table.canPreviousPage}
|
|
92
92
|
onclick={() => table.previousPage()}
|
|
93
93
|
aria-label="Go to previous page"
|
|
@@ -107,7 +107,7 @@
|
|
|
107
107
|
<Button
|
|
108
108
|
variant="outline"
|
|
109
109
|
size="icon"
|
|
110
|
-
class="h-8 w-8"
|
|
110
|
+
class="relative h-8 w-8 after:absolute after:-inset-1.5 after:content-['']"
|
|
111
111
|
disabled={!table.canNextPage}
|
|
112
112
|
onclick={() => table.nextPage()}
|
|
113
113
|
aria-label="Go to next page"
|
|
@@ -127,7 +127,7 @@
|
|
|
127
127
|
<Button
|
|
128
128
|
variant="outline"
|
|
129
129
|
size="icon"
|
|
130
|
-
class="h-8 w-8"
|
|
130
|
+
class="relative h-8 w-8 after:absolute after:-inset-1.5 after:content-['']"
|
|
131
131
|
disabled={!table.canNextPage}
|
|
132
132
|
onclick={() => table.lastPage()}
|
|
133
133
|
aria-label="Go to last page"
|
|
@@ -45,6 +45,8 @@
|
|
|
45
45
|
| 'bottom-left'
|
|
46
46
|
| 'top-center'
|
|
47
47
|
| 'bottom-center';
|
|
48
|
+
/** Render the built-in ToastContainer (disable when the app mounts its own) */
|
|
49
|
+
showToastContainer?: boolean;
|
|
48
50
|
/** Main content */
|
|
49
51
|
children: Snippet;
|
|
50
52
|
}
|
|
@@ -53,6 +55,7 @@
|
|
|
53
55
|
skipToId = 'main-content',
|
|
54
56
|
skipToText = 'Skip to main content',
|
|
55
57
|
toastPosition = 'bottom-right',
|
|
58
|
+
showToastContainer = true,
|
|
56
59
|
children,
|
|
57
60
|
}: Props = $props();
|
|
58
61
|
</script>
|
|
@@ -70,7 +73,9 @@
|
|
|
70
73
|
{@render children()}
|
|
71
74
|
|
|
72
75
|
<!-- Toast Container -->
|
|
73
|
-
|
|
76
|
+
{#if showToastContainer}
|
|
77
|
+
<ToastContainer position={toastPosition} />
|
|
78
|
+
{/if}
|
|
74
79
|
</div>
|
|
75
80
|
|
|
76
81
|
<style>
|
|
@@ -36,6 +36,8 @@ interface Props {
|
|
|
36
36
|
skipToText?: string;
|
|
37
37
|
/** Toast container position */
|
|
38
38
|
toastPosition?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center' | 'bottom-center';
|
|
39
|
+
/** Render the built-in ToastContainer (disable when the app mounts its own) */
|
|
40
|
+
showToastContainer?: boolean;
|
|
39
41
|
/** Main content */
|
|
40
42
|
children: Snippet;
|
|
41
43
|
}
|
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
<AppShell>
|
|
81
81
|
<div
|
|
82
82
|
class={cn(
|
|
83
|
-
'flex h-
|
|
83
|
+
'flex min-h-[100dvh] flex-col items-center justify-center bg-content-bg px-4 py-8 sm:px-6 lg:px-8',
|
|
84
84
|
showBackground && backgroundClasses[backgroundVariant],
|
|
85
85
|
className
|
|
86
86
|
)}
|
|
@@ -97,6 +97,8 @@
|
|
|
97
97
|
contentPadding?: 'default' | 'compact' | 'none';
|
|
98
98
|
/** App identifier for per-app sidebar preferences storage */
|
|
99
99
|
appId?: string;
|
|
100
|
+
/** Render the AppShell's built-in ToastContainer (disable when the app mounts its own) */
|
|
101
|
+
showToastContainer?: boolean;
|
|
100
102
|
/** Main content */
|
|
101
103
|
children: Snippet;
|
|
102
104
|
}
|
|
@@ -133,6 +135,7 @@
|
|
|
133
135
|
mobileBreakpoint = 'lg',
|
|
134
136
|
contentPadding = 'default',
|
|
135
137
|
appId,
|
|
138
|
+
showToastContainer = true,
|
|
136
139
|
children,
|
|
137
140
|
}: Props = $props();
|
|
138
141
|
|
|
@@ -156,8 +159,27 @@
|
|
|
156
159
|
: navigation
|
|
157
160
|
);
|
|
158
161
|
|
|
159
|
-
// Reactive media query for mobile detection
|
|
160
|
-
|
|
162
|
+
// Reactive media query for mobile detection. Initialized synchronously so
|
|
163
|
+
// the first client paint doesn't flash the desktop layout (256px content
|
|
164
|
+
// margin + full-width sidebar) before an effect corrects it. Deliberately
|
|
165
|
+
// reads the initial `mobileBreakpoint` — later changes are handled by the
|
|
166
|
+
// matchMedia effect below.
|
|
167
|
+
// svelte-ignore state_referenced_locally
|
|
168
|
+
const initialIsMobile =
|
|
169
|
+
typeof window !== 'undefined' &&
|
|
170
|
+
window.matchMedia(`(max-width: ${breakpointPixels[mobileBreakpoint]}px)`).matches;
|
|
171
|
+
let isMobile = $state(initialIsMobile);
|
|
172
|
+
if (typeof window !== 'undefined') {
|
|
173
|
+
sidebarStore.setMobile(initialIsMobile);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Suppress the margin transition until after the first paint so any
|
|
177
|
+
// hydration correction snaps instead of visibly sliding.
|
|
178
|
+
let mounted = $state(false);
|
|
179
|
+
|
|
180
|
+
$effect(() => {
|
|
181
|
+
mounted = true;
|
|
182
|
+
});
|
|
161
183
|
|
|
162
184
|
$effect(() => {
|
|
163
185
|
if (typeof window !== 'undefined') {
|
|
@@ -224,7 +246,7 @@
|
|
|
224
246
|
);
|
|
225
247
|
</script>
|
|
226
248
|
|
|
227
|
-
<AppShell>
|
|
249
|
+
<AppShell {showToastContainer}>
|
|
228
250
|
<!-- Sidebar -->
|
|
229
251
|
<Sidebar
|
|
230
252
|
navigation={effectiveNavigation}
|
|
@@ -256,7 +278,9 @@
|
|
|
256
278
|
|
|
257
279
|
<!-- Main Content Area -->
|
|
258
280
|
<div
|
|
259
|
-
class="flex flex-1 flex-col
|
|
281
|
+
class="flex flex-1 flex-col {mounted
|
|
282
|
+
? 'transition-[margin] duration-300 motion-reduce:transition-none'
|
|
283
|
+
: ''}"
|
|
260
284
|
style="margin-left: {sidebarWidth}px;"
|
|
261
285
|
>
|
|
262
286
|
{#if showHeader}
|
|
@@ -299,7 +323,10 @@
|
|
|
299
323
|
{/if}
|
|
300
324
|
|
|
301
325
|
<!-- Main Content -->
|
|
302
|
-
<main
|
|
326
|
+
<main
|
|
327
|
+
id="main-content"
|
|
328
|
+
class="min-w-0 flex-1 overflow-x-clip bg-content-bg {contentPaddingClass}"
|
|
329
|
+
>
|
|
303
330
|
{@render children()}
|
|
304
331
|
</main>
|
|
305
332
|
</div>
|
|
@@ -77,6 +77,8 @@ interface Props {
|
|
|
77
77
|
contentPadding?: 'default' | 'compact' | 'none';
|
|
78
78
|
/** App identifier for per-app sidebar preferences storage */
|
|
79
79
|
appId?: string;
|
|
80
|
+
/** Render the AppShell's built-in ToastContainer (disable when the app mounts its own) */
|
|
81
|
+
showToastContainer?: boolean;
|
|
80
82
|
/** Main content */
|
|
81
83
|
children: Snippet;
|
|
82
84
|
}
|
|
@@ -183,14 +183,15 @@
|
|
|
183
183
|
maxWidthClasses[maxWidth]
|
|
184
184
|
)}
|
|
185
185
|
>
|
|
186
|
-
<div class="flex items-center gap-4">
|
|
186
|
+
<div class="flex min-w-0 flex-1 items-center gap-4">
|
|
187
187
|
<!-- Mobile Menu Button -->
|
|
188
188
|
{#if showMenuButton}
|
|
189
189
|
<button
|
|
190
190
|
class={cn(
|
|
191
|
-
'rounded-md p-2 hover:bg-accent hover:text-accent-foreground',
|
|
191
|
+
'shrink-0 rounded-md p-2 hover:bg-accent hover:text-accent-foreground',
|
|
192
192
|
bp.menuButtonHide
|
|
193
193
|
)}
|
|
194
|
+
data-sidebar-trigger
|
|
194
195
|
onclick={() => onMenuClick?.()}
|
|
195
196
|
aria-label={menuOpen ? 'Close menu' : 'Open menu'}
|
|
196
197
|
aria-expanded={menuOpen}
|
|
@@ -246,7 +247,7 @@
|
|
|
246
247
|
|
|
247
248
|
<!-- Page Title -->
|
|
248
249
|
{#if title}
|
|
249
|
-
<h2 class="text-lg font-semibold">{title}</h2>
|
|
250
|
+
<h2 class="min-w-0 truncate text-lg font-semibold">{title}</h2>
|
|
250
251
|
{/if}
|
|
251
252
|
|
|
252
253
|
<!-- Start Content -->
|
|
@@ -264,7 +265,7 @@
|
|
|
264
265
|
|
|
265
266
|
<!-- End Section (search + custom end content) -->
|
|
266
267
|
{#if search?.enabled || end}
|
|
267
|
-
<div class="flex items-center gap-4">
|
|
268
|
+
<div class="flex shrink-0 items-center gap-4">
|
|
268
269
|
{#if search?.enabled}
|
|
269
270
|
<HeaderSearch {...search} />
|
|
270
271
|
{/if}
|
|
@@ -398,6 +398,37 @@
|
|
|
398
398
|
// Navigation element reference for arrow key handling
|
|
399
399
|
let navElement = $state<HTMLElement | null>(null);
|
|
400
400
|
|
|
401
|
+
// Root element reference for the mobile focus trap
|
|
402
|
+
let asideElement = $state<HTMLElement | null>(null);
|
|
403
|
+
|
|
404
|
+
// Focus trap for the open mobile drawer: Tab cycles within the drawer,
|
|
405
|
+
// Escape closes it and returns focus to the header menu button.
|
|
406
|
+
function handleDrawerKeydown(event: KeyboardEvent) {
|
|
407
|
+
if (!isMobile || !mobileOpen) return;
|
|
408
|
+
|
|
409
|
+
if (event.key === 'Escape') {
|
|
410
|
+
event.preventDefault();
|
|
411
|
+
onClose?.();
|
|
412
|
+
document.querySelector<HTMLElement>('[data-sidebar-trigger]')?.focus();
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (event.key !== 'Tab' || !asideElement) return;
|
|
417
|
+
const focusable = asideElement.querySelectorAll<HTMLElement>(
|
|
418
|
+
'a[href]:not([disabled]), button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
419
|
+
);
|
|
420
|
+
if (focusable.length === 0) return;
|
|
421
|
+
const first = focusable[0];
|
|
422
|
+
const last = focusable[focusable.length - 1];
|
|
423
|
+
if (event.shiftKey && document.activeElement === first) {
|
|
424
|
+
event.preventDefault();
|
|
425
|
+
last.focus();
|
|
426
|
+
} else if (!event.shiftKey && document.activeElement === last) {
|
|
427
|
+
event.preventDefault();
|
|
428
|
+
first.focus();
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
401
432
|
// Handle arrow key navigation within the sidebar
|
|
402
433
|
function handleNavKeydown(event: KeyboardEvent) {
|
|
403
434
|
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;
|
|
@@ -427,19 +458,15 @@
|
|
|
427
458
|
}
|
|
428
459
|
</script>
|
|
429
460
|
|
|
430
|
-
<!-- Mobile Overlay: z-40 positions below sidebar (z-50) but above main content
|
|
461
|
+
<!-- Mobile Overlay: z-40 positions below sidebar (z-50) but above main content.
|
|
462
|
+
Not focusable: the drawer traps focus and handles Escape itself. -->
|
|
431
463
|
{#if isMobile && mobileOpen}
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
onclick={() => onClose?.()}
|
|
435
|
-
onkeydown={(e) => e.key === 'Escape' && onClose?.()}
|
|
436
|
-
role="button"
|
|
437
|
-
tabindex="0"
|
|
438
|
-
aria-label="Close sidebar"
|
|
439
|
-
></div>
|
|
464
|
+
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
|
465
|
+
<div class="fixed inset-0 z-40 bg-black/50" onclick={() => onClose?.()} aria-hidden="true"></div>
|
|
440
466
|
{/if}
|
|
441
467
|
|
|
442
468
|
<!-- Sidebar: z-50 positions above overlay and content, below modals (z-100+) -->
|
|
469
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
443
470
|
<aside
|
|
444
471
|
class={cn(
|
|
445
472
|
'fixed left-0 top-0 z-[var(--z-sidebar,50)] flex h-full flex-col transition-[width,transform] duration-300 motion-reduce:transition-none touch-pan-y',
|
|
@@ -463,6 +490,9 @@
|
|
|
463
490
|
return styles.join('; ');
|
|
464
491
|
})()}
|
|
465
492
|
aria-label="Main navigation"
|
|
493
|
+
bind:this={asideElement}
|
|
494
|
+
inert={isMobile && !mobileOpen}
|
|
495
|
+
onkeydown={handleDrawerKeydown}
|
|
466
496
|
ontouchstart={handleTouchStart}
|
|
467
497
|
ontouchmove={handleTouchMove}
|
|
468
498
|
ontouchend={handleTouchEnd}
|
|
@@ -51,10 +51,13 @@
|
|
|
51
51
|
</script>
|
|
52
52
|
|
|
53
53
|
<div
|
|
54
|
-
class={cn(
|
|
54
|
+
class={cn(
|
|
55
|
+
'flex flex-wrap items-center justify-between gap-x-4 gap-y-2 rounded-lg border bg-muted/50 p-3',
|
|
56
|
+
className
|
|
57
|
+
)}
|
|
55
58
|
>
|
|
56
59
|
<!-- Selection controls -->
|
|
57
|
-
<div class="flex items-center gap-3">
|
|
60
|
+
<div class="flex flex-wrap items-center gap-3">
|
|
58
61
|
<Checkbox checked={allSelected} onCheckedChange={handleSelectAllChange}>
|
|
59
62
|
{#snippet children()}
|
|
60
63
|
<span class="text-sm">
|
|
@@ -80,7 +83,7 @@
|
|
|
80
83
|
|
|
81
84
|
<!-- Bulk actions -->
|
|
82
85
|
{#if hasSelection}
|
|
83
|
-
<div class="flex items-center gap-2">
|
|
86
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
84
87
|
{#if onMarkRead}
|
|
85
88
|
<Button variant="outline" size="sm" onclick={onMarkRead}>
|
|
86
89
|
{#snippet children()}
|
|
@@ -149,7 +149,7 @@
|
|
|
149
149
|
|
|
150
150
|
<!-- Metadata -->
|
|
151
151
|
<div class="border-t pt-4 mt-4">
|
|
152
|
-
<dl class="grid grid-cols-
|
|
152
|
+
<dl class="grid grid-cols-1 gap-4 text-sm sm:grid-cols-2">
|
|
153
153
|
<div>
|
|
154
154
|
<dt class="text-muted-foreground">Category</dt>
|
|
155
155
|
<dd class="font-medium capitalize">{notification.category}</dd>
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
role="tablist"
|
|
70
70
|
aria-label="Filter notifications"
|
|
71
71
|
class={cn(
|
|
72
|
-
'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
|
|
72
|
+
'inline-flex h-10 max-w-full items-center justify-center overflow-x-auto rounded-md bg-muted p-1 text-muted-foreground [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
|
|
73
73
|
className
|
|
74
74
|
)}
|
|
75
75
|
>
|
|
@@ -16,7 +16,7 @@ var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (
|
|
|
16
16
|
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
|
|
17
17
|
return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
|
|
18
18
|
};
|
|
19
|
-
var _SidebarStore_instances, _SidebarStore_initialized, _SidebarStore_loadedFromStorage, _SidebarStore_loadedExpansionFromStorage, _SidebarStore_loadedPinnedFromStorage, _SidebarStore_onPinnedChange, _SidebarStore_appId, _SidebarStore_getStorageKey, _SidebarStore_persist, _SidebarStore_persistExpansion, _SidebarStore_persistPinned;
|
|
19
|
+
var _SidebarStore_instances, _SidebarStore_initialized, _SidebarStore_loadedFromStorage, _SidebarStore_loadedExpansionFromStorage, _SidebarStore_loadedPinnedFromStorage, _SidebarStore_onPinnedChange, _SidebarStore_appId, _SidebarStore_desktopOpen, _SidebarStore_getStorageKey, _SidebarStore_persist, _SidebarStore_persistExpansion, _SidebarStore_persistPinned, _SidebarStore_persistDesktopState;
|
|
20
20
|
import { matchesAccess } from '../utils/nav-access.js';
|
|
21
21
|
const STORAGE_PREFIX = 'classic-theme-sidebar';
|
|
22
22
|
class SidebarStore {
|
|
@@ -33,6 +33,10 @@ class SidebarStore {
|
|
|
33
33
|
_SidebarStore_loadedPinnedFromStorage.set(this, false);
|
|
34
34
|
_SidebarStore_onPinnedChange.set(this, void 0);
|
|
35
35
|
_SidebarStore_appId.set(this, void 0);
|
|
36
|
+
// Desktop open/closed preference, kept separate from `isOpen` so that the
|
|
37
|
+
// mobile drawer (which must always start closed and never persists) can't
|
|
38
|
+
// clobber it. Restored when leaving mobile mode.
|
|
39
|
+
_SidebarStore_desktopOpen.set(this, null);
|
|
36
40
|
}
|
|
37
41
|
/**
|
|
38
42
|
* Whether the store has been initialized
|
|
@@ -77,10 +81,14 @@ class SidebarStore {
|
|
|
77
81
|
__classPrivateFieldSet(this, _SidebarStore_loadedExpansionFromStorage, false, "f");
|
|
78
82
|
__classPrivateFieldSet(this, _SidebarStore_loadedPinnedFromStorage, false, "f");
|
|
79
83
|
try {
|
|
80
|
-
// Load sidebar open state
|
|
84
|
+
// Load sidebar open state. On mobile only the desktop preference is
|
|
85
|
+
// recorded — the drawer must never open on first paint.
|
|
81
86
|
const stored = localStorage.getItem(__classPrivateFieldGet(this, _SidebarStore_instances, "m", _SidebarStore_getStorageKey).call(this, 'open'));
|
|
82
87
|
if (stored !== null) {
|
|
83
|
-
this
|
|
88
|
+
__classPrivateFieldSet(this, _SidebarStore_desktopOpen, stored === 'true', "f");
|
|
89
|
+
if (!this.isMobile) {
|
|
90
|
+
this.isOpen = __classPrivateFieldGet(this, _SidebarStore_desktopOpen, "f");
|
|
91
|
+
}
|
|
84
92
|
__classPrivateFieldSet(this, _SidebarStore_loadedFromStorage, true, "f");
|
|
85
93
|
}
|
|
86
94
|
// Load expanded sections
|
|
@@ -117,31 +125,38 @@ class SidebarStore {
|
|
|
117
125
|
*/
|
|
118
126
|
toggle() {
|
|
119
127
|
this.isOpen = !this.isOpen;
|
|
120
|
-
__classPrivateFieldGet(this, _SidebarStore_instances, "m",
|
|
128
|
+
__classPrivateFieldGet(this, _SidebarStore_instances, "m", _SidebarStore_persistDesktopState).call(this);
|
|
121
129
|
}
|
|
122
130
|
/**
|
|
123
131
|
* Open the sidebar
|
|
124
132
|
*/
|
|
125
133
|
open() {
|
|
126
134
|
this.isOpen = true;
|
|
127
|
-
__classPrivateFieldGet(this, _SidebarStore_instances, "m",
|
|
135
|
+
__classPrivateFieldGet(this, _SidebarStore_instances, "m", _SidebarStore_persistDesktopState).call(this);
|
|
128
136
|
}
|
|
129
137
|
/**
|
|
130
138
|
* Close the sidebar
|
|
131
139
|
*/
|
|
132
140
|
close() {
|
|
133
141
|
this.isOpen = false;
|
|
134
|
-
__classPrivateFieldGet(this, _SidebarStore_instances, "m",
|
|
142
|
+
__classPrivateFieldGet(this, _SidebarStore_instances, "m", _SidebarStore_persistDesktopState).call(this);
|
|
135
143
|
}
|
|
136
144
|
/**
|
|
137
145
|
* Set mobile mode
|
|
138
146
|
*/
|
|
139
147
|
setMobile(isMobile) {
|
|
148
|
+
if (this.isMobile === isMobile)
|
|
149
|
+
return;
|
|
140
150
|
this.isMobile = isMobile;
|
|
141
|
-
// Auto-close sidebar on mobile (don't persist mobile state)
|
|
142
151
|
if (isMobile) {
|
|
152
|
+
// Entering mobile: stash the desktop preference and close the drawer.
|
|
153
|
+
__classPrivateFieldSet(this, _SidebarStore_desktopOpen, this.isOpen, "f");
|
|
143
154
|
this.isOpen = false;
|
|
144
155
|
}
|
|
156
|
+
else {
|
|
157
|
+
// Leaving mobile: restore the desktop preference (default open).
|
|
158
|
+
this.isOpen = __classPrivateFieldGet(this, _SidebarStore_desktopOpen, "f") ?? true;
|
|
159
|
+
}
|
|
145
160
|
}
|
|
146
161
|
/**
|
|
147
162
|
* Toggle section expansion state
|
|
@@ -316,7 +331,7 @@ class SidebarStore {
|
|
|
316
331
|
};
|
|
317
332
|
}
|
|
318
333
|
}
|
|
319
|
-
_SidebarStore_initialized = new WeakMap(), _SidebarStore_loadedFromStorage = new WeakMap(), _SidebarStore_loadedExpansionFromStorage = new WeakMap(), _SidebarStore_loadedPinnedFromStorage = new WeakMap(), _SidebarStore_onPinnedChange = new WeakMap(), _SidebarStore_appId = new WeakMap(), _SidebarStore_instances = new WeakSet(), _SidebarStore_getStorageKey = function _SidebarStore_getStorageKey(suffix) {
|
|
334
|
+
_SidebarStore_initialized = new WeakMap(), _SidebarStore_loadedFromStorage = new WeakMap(), _SidebarStore_loadedExpansionFromStorage = new WeakMap(), _SidebarStore_loadedPinnedFromStorage = new WeakMap(), _SidebarStore_onPinnedChange = new WeakMap(), _SidebarStore_appId = new WeakMap(), _SidebarStore_desktopOpen = new WeakMap(), _SidebarStore_instances = new WeakSet(), _SidebarStore_getStorageKey = function _SidebarStore_getStorageKey(suffix) {
|
|
320
335
|
return __classPrivateFieldGet(this, _SidebarStore_appId, "f")
|
|
321
336
|
? `${STORAGE_PREFIX}-${__classPrivateFieldGet(this, _SidebarStore_appId, "f")}-${suffix}`
|
|
322
337
|
: `${STORAGE_PREFIX}-${suffix}`;
|
|
@@ -350,5 +365,10 @@ _SidebarStore_initialized = new WeakMap(), _SidebarStore_loadedFromStorage = new
|
|
|
350
365
|
}
|
|
351
366
|
// Notify external callback for server persistence
|
|
352
367
|
__classPrivateFieldGet(this, _SidebarStore_onPinnedChange, "f")?.call(this, [...this.pinnedItems]);
|
|
368
|
+
}, _SidebarStore_persistDesktopState = function _SidebarStore_persistDesktopState() {
|
|
369
|
+
if (this.isMobile)
|
|
370
|
+
return;
|
|
371
|
+
__classPrivateFieldSet(this, _SidebarStore_desktopOpen, this.isOpen, "f");
|
|
372
|
+
__classPrivateFieldGet(this, _SidebarStore_instances, "m", _SidebarStore_persist).call(this);
|
|
353
373
|
};
|
|
354
374
|
export const sidebarStore = new SidebarStore();
|
|
@@ -91,6 +91,12 @@ export interface DataTableColumn<T = object> {
|
|
|
91
91
|
width?: string;
|
|
92
92
|
/** Text alignment */
|
|
93
93
|
align?: 'left' | 'center' | 'right';
|
|
94
|
+
/**
|
|
95
|
+
* Hide this column below the given Tailwind breakpoint (CSS-only, SSR-safe).
|
|
96
|
+
* Applies to the header and default-rendered cells; custom row snippets must
|
|
97
|
+
* mirror the guard themselves so cell counts stay in sync.
|
|
98
|
+
*/
|
|
99
|
+
hideBelow?: 'sm' | 'md' | 'lg';
|
|
94
100
|
/** Custom cell render function returning string */
|
|
95
101
|
format?: (value: unknown, row: T) => string;
|
|
96
102
|
/** Filter function type or custom filter function */
|