@classic-homes/theme-svelte 1.5.0 → 1.6.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.
Files changed (28) hide show
  1. package/dist/__tests__/sidebar-mobile.test.js +73 -0
  2. package/dist/lib/components/Checkbox.svelte +1 -0
  3. package/dist/lib/components/Combobox.svelte +103 -95
  4. package/dist/lib/components/DataTable.svelte +16 -2
  5. package/dist/lib/components/DateTimePicker.svelte +6 -6
  6. package/dist/lib/components/Dialog.svelte +10 -8
  7. package/dist/lib/components/DropdownMenu.svelte +2 -2
  8. package/dist/lib/components/HeaderSearch.svelte +1 -1
  9. package/dist/lib/components/Input.svelte +1 -1
  10. package/dist/lib/components/MultiSelect.svelte +4 -4
  11. package/dist/lib/components/NumberInput.svelte +2 -2
  12. package/dist/lib/components/PasswordInput.svelte +1 -1
  13. package/dist/lib/components/RadioGroup.svelte +1 -0
  14. package/dist/lib/components/Select.svelte +2 -2
  15. package/dist/lib/components/Switch.svelte +1 -0
  16. package/dist/lib/components/Tabs.svelte +1 -1
  17. package/dist/lib/components/Textarea.svelte +1 -1
  18. package/dist/lib/components/data-table/DataTableEditableCell.svelte +1 -1
  19. package/dist/lib/components/data-table/DataTablePagination.svelte +7 -7
  20. package/dist/lib/components/layout/AppShell.svelte +6 -1
  21. package/dist/lib/components/layout/AppShell.svelte.d.ts +2 -0
  22. package/dist/lib/components/layout/DashboardLayout.svelte +28 -4
  23. package/dist/lib/components/layout/DashboardLayout.svelte.d.ts +2 -0
  24. package/dist/lib/components/layout/Header.svelte +5 -4
  25. package/dist/lib/components/layout/Sidebar.svelte +39 -9
  26. package/dist/lib/stores/sidebar.svelte.js +28 -8
  27. package/dist/lib/types/components.d.ts +6 -0
  28. 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
+ });
@@ -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
  )}
@@ -177,9 +177,108 @@
177
177
  />
178
178
  </div>
179
179
 
180
+ <!-- Portal to <body>: escapes transformed/overflow-hidden ancestors
181
+ (e.g. Dialog panels) that would clip the dropdown. -->
182
+ <ComboboxPrimitive.Portal>
183
+ <ComboboxPrimitive.Content
184
+ class="isolate relative z-50 min-w-[var(--bits-floating-anchor-width)] overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
185
+ sideOffset={4}
186
+ collisionPadding={8}
187
+ avoidCollisions={true}
188
+ >
189
+ <div class="p-1 max-h-[300px] overflow-y-auto">
190
+ {#if loading}
191
+ <div class="flex items-center justify-center py-6">
192
+ <Spinner size="sm" />
193
+ <span class="ml-2 text-sm text-muted-foreground">Loading...</span>
194
+ </div>
195
+ {:else if filteredOptions.length === 0}
196
+ <div class="py-6 text-center text-sm text-muted-foreground">
197
+ {emptyMessage}
198
+ </div>
199
+ {:else}
200
+ {#each filteredOptions as option}
201
+ <ComboboxPrimitive.Item
202
+ value={option.value}
203
+ label={option.label}
204
+ disabled={option.disabled}
205
+ class="relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-default"
206
+ >
207
+ {#if option.value === value}
208
+ <span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
209
+ <svg
210
+ xmlns="http://www.w3.org/2000/svg"
211
+ width="16"
212
+ height="16"
213
+ viewBox="0 0 24 24"
214
+ fill="none"
215
+ stroke="currentColor"
216
+ stroke-width="2"
217
+ stroke-linecap="round"
218
+ stroke-linejoin="round"
219
+ class="h-4 w-4"
220
+ >
221
+ <polyline points="20 6 9 17 4 12" />
222
+ </svg>
223
+ </span>
224
+ {/if}
225
+ {option.label}
226
+ </ComboboxPrimitive.Item>
227
+ {/each}
228
+ {/if}
229
+ </div>
230
+ </ComboboxPrimitive.Content>
231
+ </ComboboxPrimitive.Portal>
232
+ </ComboboxPrimitive.Root>
233
+
234
+ {#if hint && !error}
235
+ <p id={hintId} class="text-sm text-muted-foreground">
236
+ {hint}
237
+ </p>
238
+ {/if}
239
+
240
+ {#if error}
241
+ <p id={errorId} class="text-sm text-destructive" role="alert" aria-live="polite">
242
+ {error}
243
+ </p>
244
+ {/if}
245
+ </div>
246
+ {:else}
247
+ <ComboboxPrimitive.Root
248
+ type="single"
249
+ {disabled}
250
+ {required}
251
+ {name}
252
+ bind:open
253
+ onOpenChange={handleOpenChange}
254
+ onValueChange={handleValueChange}
255
+ {value}
256
+ >
257
+ <div class="relative">
258
+ <ComboboxPrimitive.Input
259
+ id={componentId}
260
+ placeholder={selectedLabel || placeholder}
261
+ oninput={handleSearchInput}
262
+ onfocus={() => (open = true)}
263
+ onclick={() => (open = true)}
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-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
+ error && 'border-destructive focus:ring-destructive',
267
+ className
268
+ )}
269
+ role="combobox"
270
+ aria-expanded={open}
271
+ aria-invalid={error ? 'true' : undefined}
272
+ aria-describedby={describedBy}
273
+ />
274
+ </div>
275
+
276
+ <!-- Portal to <body>: escapes transformed/overflow-hidden ancestors
277
+ (e.g. Dialog panels) that would clip the dropdown. -->
278
+ <ComboboxPrimitive.Portal>
180
279
  <ComboboxPrimitive.Content
181
- class="isolate relative z-50 min-w-[var(--bits-floating-anchor-width)] overflow-hidden rounded-md border border-black bg-popover text-popover-foreground shadow-md data-[side=bottom]:border-t-0 data-[side=bottom]:rounded-t-none data-[side=top]:border-b-0 data-[side=top]:rounded-b-none 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
182
- sideOffset={-1}
280
+ class="isolate relative z-50 min-w-[var(--bits-floating-anchor-width)] overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
281
+ sideOffset={4}
183
282
  collisionPadding={8}
184
283
  avoidCollisions={true}
185
284
  >
@@ -225,98 +324,7 @@
225
324
  {/if}
226
325
  </div>
227
326
  </ComboboxPrimitive.Content>
228
- </ComboboxPrimitive.Root>
229
-
230
- {#if hint && !error}
231
- <p id={hintId} class="text-sm text-muted-foreground">
232
- {hint}
233
- </p>
234
- {/if}
235
-
236
- {#if error}
237
- <p id={errorId} class="text-sm text-destructive" role="alert" aria-live="polite">
238
- {error}
239
- </p>
240
- {/if}
241
- </div>
242
- {:else}
243
- <ComboboxPrimitive.Root
244
- type="single"
245
- {disabled}
246
- {required}
247
- {name}
248
- bind:open
249
- onOpenChange={handleOpenChange}
250
- onValueChange={handleValueChange}
251
- {value}
252
- >
253
- <div class="relative">
254
- <ComboboxPrimitive.Input
255
- id={componentId}
256
- placeholder={selectedLabel || placeholder}
257
- oninput={handleSearchInput}
258
- onfocus={() => (open = true)}
259
- onclick={() => (open = true)}
260
- class={cn(
261
- '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',
262
- error && 'border-destructive focus:ring-destructive',
263
- className
264
- )}
265
- role="combobox"
266
- aria-expanded={open}
267
- aria-invalid={error ? 'true' : undefined}
268
- aria-describedby={describedBy}
269
- />
270
- </div>
271
-
272
- <ComboboxPrimitive.Content
273
- class="isolate relative z-50 min-w-[var(--bits-floating-anchor-width)] overflow-hidden rounded-md border border-black bg-popover text-popover-foreground shadow-md data-[side=bottom]:border-t-0 data-[side=bottom]:rounded-t-none data-[side=top]:border-b-0 data-[side=top]:rounded-b-none 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
274
- sideOffset={-1}
275
- collisionPadding={8}
276
- avoidCollisions={true}
277
- >
278
- <div class="p-1 max-h-[300px] overflow-y-auto">
279
- {#if loading}
280
- <div class="flex items-center justify-center py-6">
281
- <Spinner size="sm" />
282
- <span class="ml-2 text-sm text-muted-foreground">Loading...</span>
283
- </div>
284
- {:else if filteredOptions.length === 0}
285
- <div class="py-6 text-center text-sm text-muted-foreground">
286
- {emptyMessage}
287
- </div>
288
- {:else}
289
- {#each filteredOptions as option}
290
- <ComboboxPrimitive.Item
291
- value={option.value}
292
- label={option.label}
293
- disabled={option.disabled}
294
- class="relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-default"
295
- >
296
- {#if option.value === value}
297
- <span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
298
- <svg
299
- xmlns="http://www.w3.org/2000/svg"
300
- width="16"
301
- height="16"
302
- viewBox="0 0 24 24"
303
- fill="none"
304
- stroke="currentColor"
305
- stroke-width="2"
306
- stroke-linecap="round"
307
- stroke-linejoin="round"
308
- class="h-4 w-4"
309
- >
310
- <polyline points="20 6 9 17 4 12" />
311
- </svg>
312
- </span>
313
- {/if}
314
- {option.label}
315
- </ComboboxPrimitive.Item>
316
- {/each}
317
- {/if}
318
- </div>
319
- </ComboboxPrimitive.Content>
327
+ </ComboboxPrimitive.Portal>
320
328
  </ComboboxPrimitive.Root>
321
329
  {/if}
322
330
 
@@ -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="px-4 py-3">
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 class={cn('px-4 py-3', getAlignClass(column.align))}>
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 grid w-full translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-background shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg overflow-hidden',
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
 
@@ -67,11 +67,11 @@
67
67
  <DropdownMenuPrimitive.Content
68
68
  {side}
69
69
  {align}
70
- sideOffset={-1}
70
+ sideOffset={4}
71
71
  collisionPadding={8}
72
72
  avoidCollisions={true}
73
73
  class={cn(
74
- 'isolate z-50 min-w-[8rem] overflow-hidden rounded-md border border-black bg-popover p-1 text-popover-foreground shadow-md 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
74
+ 'isolate z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
75
75
  className
76
76
  )}
77
77
  >
@@ -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
  : '',
@@ -239,8 +239,8 @@
239
239
  (e.g. Dialog panels) that would clip the dropdown. -->
240
240
  <ComboboxPrimitive.Portal>
241
241
  <ComboboxPrimitive.Content
242
- class="isolate relative z-50 min-w-[var(--bits-floating-anchor-width)] overflow-hidden rounded-md border border-black bg-popover text-popover-foreground shadow-md data-[side=bottom]:border-t-0 data-[side=bottom]:rounded-t-none data-[side=top]:border-b-0 data-[side=top]:rounded-b-none 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
243
- sideOffset={-1}
242
+ class="isolate relative z-50 min-w-[var(--bits-floating-anchor-width)] overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
243
+ sideOffset={4}
244
244
  collisionPadding={8}
245
245
  avoidCollisions={true}
246
246
  >
@@ -376,8 +376,8 @@
376
376
  (e.g. Dialog panels) that would clip the dropdown. -->
377
377
  <ComboboxPrimitive.Portal>
378
378
  <ComboboxPrimitive.Content
379
- class="isolate relative z-50 min-w-[var(--bits-floating-anchor-width)] overflow-hidden rounded-md border border-black bg-popover text-popover-foreground shadow-md data-[side=bottom]:border-t-0 data-[side=bottom]:rounded-t-none data-[side=top]:border-b-0 data-[side=top]:rounded-b-none 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
380
- sideOffset={-1}
379
+ class="isolate relative z-50 min-w-[var(--bits-floating-anchor-width)] overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
380
+ sideOffset={4}
381
381
  collisionPadding={8}
382
382
  avoidCollisions={true}
383
383
  >
@@ -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
  )}
@@ -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
  )}
@@ -140,7 +140,7 @@
140
140
 
141
141
  // Common content classes
142
142
  const contentClasses =
143
- 'isolate relative z-50 min-w-[var(--bits-floating-anchor-width)] overflow-hidden rounded-md border border-black bg-popover text-popover-foreground shadow-md data-[side=bottom]:border-t-0 data-[side=bottom]:rounded-t-none data-[side=top]:border-b-0 data-[side=top]:rounded-b-none 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2';
143
+ 'isolate relative z-50 min-w-[var(--bits-floating-anchor-width)] overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2';
144
144
 
145
145
  // Common item classes
146
146
  const itemClasses =
@@ -205,7 +205,7 @@
205
205
  <SelectPrimitive.Portal>
206
206
  <SelectPrimitive.Content
207
207
  class={contentClasses}
208
- sideOffset={-1}
208
+ sideOffset={4}
209
209
  collisionPadding={8}
210
210
  avoidCollisions={true}
211
211
  >
@@ -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
  )}
@@ -111,7 +111,7 @@
111
111
  <div
112
112
  role="tablist"
113
113
  aria-label={label}
114
- class="inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground"
114
+ 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"
115
115
  >
116
116
  {#each tabs as tab, index (tab.id)}
117
117
  <button
@@ -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="flex-1 text-sm text-muted-foreground">
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
- <ToastContainer position={toastPosition} />
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
  }
@@ -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
- let isMobile = $state(false);
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 transition-[margin] duration-300 motion-reduce:transition-none"
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}
@@ -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
- <div
433
- class="fixed inset-0 z-40 bg-black/50 lg:hidden"
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}
@@ -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.isOpen = stored === 'true';
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", _SidebarStore_persist).call(this);
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", _SidebarStore_persist).call(this);
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", _SidebarStore_persist).call(this);
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 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classic-homes/theme-svelte",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "Svelte components for the Classic theme system",
5
5
  "type": "module",
6
6
  "svelte": "./dist/lib/index.js",