@classic-homes/theme-svelte 1.25.1 → 1.26.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 (69) hide show
  1. package/dist/lib/components/AlertDialog.svelte +14 -6
  2. package/dist/lib/components/AlertDialog.svelte.d.ts +8 -2
  3. package/dist/lib/components/Badge.svelte +6 -2
  4. package/dist/lib/components/CodeField.svelte +24 -8
  5. package/dist/lib/components/CodeField.svelte.d.ts +8 -0
  6. package/dist/lib/components/Combobox.svelte +25 -111
  7. package/dist/lib/components/DataPanel.svelte +31 -24
  8. package/dist/lib/components/DataPanelHeader.svelte +66 -26
  9. package/dist/lib/components/DataTable.svelte +303 -138
  10. package/dist/lib/components/DataTable.svelte.d.ts +37 -13
  11. package/dist/lib/components/DateTimePicker.svelte +159 -34
  12. package/dist/lib/components/Dialog.svelte +14 -6
  13. package/dist/lib/components/Dialog.svelte.d.ts +8 -2
  14. package/dist/lib/components/FileUpload.svelte +23 -6
  15. package/dist/lib/components/FileUpload.svelte.d.ts +7 -2
  16. package/dist/lib/components/MultiSelect.svelte +28 -155
  17. package/dist/lib/components/NumberInput.svelte +28 -110
  18. package/dist/lib/components/OTPInput.svelte +73 -85
  19. package/dist/lib/components/Select.svelte +24 -29
  20. package/dist/lib/components/Signature.svelte +80 -48
  21. package/dist/lib/components/Signature.svelte.d.ts +6 -1
  22. package/dist/lib/components/SkipLink.svelte +7 -2
  23. package/dist/lib/components/SkipLink.svelte.d.ts +2 -0
  24. package/dist/lib/components/Slider.svelte +4 -4
  25. package/dist/lib/components/Toast.svelte +11 -1
  26. package/dist/lib/components/ToastContainer.svelte +38 -24
  27. package/dist/lib/components/Tooltip.svelte +19 -6
  28. package/dist/lib/components/Tooltip.svelte.d.ts +8 -2
  29. package/dist/lib/components/data-table/DataTableColumnFilter.svelte +3 -1
  30. package/dist/lib/components/data-table/DataTableEditableCell.svelte +25 -3
  31. package/dist/lib/components/data-table/DataTableFacetedFilter.svelte +50 -40
  32. package/dist/lib/components/data-table/DataTableFacetedFilter.svelte.d.ts +0 -10
  33. package/dist/lib/components/data-table/DataTableVirtual.svelte +109 -57
  34. package/dist/lib/components/data-table/DataTableVirtual.svelte.d.ts +2 -0
  35. package/dist/lib/components/data-table/createDataTable.svelte.d.ts +26 -4
  36. package/dist/lib/components/data-table/createDataTable.svelte.js +97 -40
  37. package/dist/lib/components/data-table/index.d.ts +1 -1
  38. package/dist/lib/components/data-table/types.d.ts +14 -4
  39. package/dist/lib/components/data-table/types.js +5 -1
  40. package/dist/lib/components/internal/TriggerChild.svelte +60 -0
  41. package/dist/lib/components/internal/TriggerChild.svelte.d.ts +32 -0
  42. package/dist/lib/components/layout/Sidebar.svelte +33 -3
  43. package/dist/lib/components/layout/Sidebar.svelte.d.ts +1 -1
  44. package/dist/lib/components/layout/sidebar/SidebarFlyout.svelte +6 -1
  45. package/dist/lib/components/layout/sidebar/SidebarNavItem.svelte +6 -1
  46. package/dist/lib/components/layout/sidebar/SidebarSection.svelte +6 -1
  47. package/dist/lib/components/media/Audio.svelte +16 -0
  48. package/dist/lib/components/media/Lightbox.svelte +29 -3
  49. package/dist/lib/components/media/MediaCarousel.svelte +19 -0
  50. package/dist/lib/components/media/MediaEmbed.svelte +32 -1
  51. package/dist/lib/components/media/Video.svelte +16 -0
  52. package/dist/lib/components/media/internal/MediaControls.svelte +9 -1
  53. package/dist/lib/components/notifications/NotificationBadge.svelte +1 -1
  54. package/dist/lib/components/notifications/NotificationItem.svelte +19 -28
  55. package/dist/lib/composables/useEscapeKey.svelte.d.ts +4 -0
  56. package/dist/lib/composables/useEscapeKey.svelte.js +15 -0
  57. package/dist/lib/composables/useHlsSource.svelte.js +31 -4
  58. package/dist/lib/composables/useMediaPlayer.svelte.js +38 -9
  59. package/dist/lib/composables/useMediaZoom.svelte.js +32 -0
  60. package/dist/lib/composables/usePersistedForm.svelte.d.ts +6 -2
  61. package/dist/lib/composables/usePersistedForm.svelte.js +7 -7
  62. package/dist/lib/composables/useReducedMotion.svelte.js +6 -1
  63. package/dist/lib/composables/useRovingTabindex.svelte.js +12 -0
  64. package/dist/lib/index.d.ts +1 -1
  65. package/dist/lib/stores/toast.svelte.d.ts +13 -0
  66. package/dist/lib/stores/toast.svelte.js +36 -5
  67. package/dist/lib/utils/media.d.ts +7 -0
  68. package/dist/lib/utils/media.js +18 -3
  69. package/package.json +1 -1
@@ -2,6 +2,7 @@
2
2
  import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
3
3
  import { cn } from '../utils.js';
4
4
  import type { Snippet } from 'svelte';
5
+ import TriggerChild, { type TriggerSnippetProps } from './internal/TriggerChild.svelte';
5
6
  import { tv, type VariantProps } from 'tailwind-variants';
6
7
 
7
8
  const buttonVariants = tv({
@@ -41,8 +42,13 @@
41
42
  onCancel?: () => void;
42
43
  /** Additional class for dialog content */
43
44
  class?: string;
44
- /** Optional trigger element */
45
- trigger?: Snippet;
45
+ /**
46
+ * Optional trigger element. It receives `{ props }`: spread them on your
47
+ * own button (`{#snippet trigger({ props })}<Button {...props}>Open</Button>{/snippet}`)
48
+ * so that button is the trigger and gets focus back on close. A snippet
49
+ * that ignores them still works, wrapped in a span trigger as before.
50
+ */
51
+ trigger?: Snippet<[TriggerSnippetProps]>;
46
52
  /** Optional custom content */
47
53
  children?: Snippet;
48
54
  }
@@ -62,6 +68,10 @@
62
68
  children,
63
69
  }: Props = $props();
64
70
 
71
+ const uid = $props.id();
72
+ // Changes once if the trigger snippet ignores its props (see TriggerChild)
73
+ let triggerId = $state(`${uid}-trigger`);
74
+
65
75
  // Each time the dialog opens, exactly one of onConfirm / onCancel runs when it
66
76
  // closes. Escape and overlay clicks count as cancel, so callers that mount the
67
77
  // dialog as `{#if x}<AlertDialog open onCancel={() => (x = null)} />` reset
@@ -99,11 +109,9 @@
99
109
 
100
110
  <AlertDialogPrimitive.Root bind:open onOpenChange={handleOpenChange}>
101
111
  {#if trigger}
102
- <AlertDialogPrimitive.Trigger>
112
+ <AlertDialogPrimitive.Trigger id={triggerId}>
103
113
  {#snippet child({ props })}
104
- <span {...props}>
105
- {@render trigger()}
106
- </span>
114
+ <TriggerChild {props} {trigger} onfallback={() => (triggerId = `${uid}-trigger-wrap`)} />
107
115
  {/snippet}
108
116
  </AlertDialogPrimitive.Trigger>
109
117
  {/if}
@@ -1,4 +1,5 @@
1
1
  import type { Snippet } from 'svelte';
2
+ import { type TriggerSnippetProps } from './internal/TriggerChild.svelte';
2
3
  import { type VariantProps } from 'tailwind-variants';
3
4
  declare const AlertDialog: import("svelte").Component<{
4
5
  /** Whether the dialog is open */
@@ -70,8 +71,13 @@ declare const AlertDialog: import("svelte").Component<{
70
71
  onCancel?: () => void;
71
72
  /** Additional class for dialog content */
72
73
  class?: string;
73
- /** Optional trigger element */
74
- trigger?: Snippet;
74
+ /**
75
+ * Optional trigger element. It receives `{ props }`: spread them on your
76
+ * own button (`{#snippet trigger({ props })}<Button {...props}>Open</Button>{/snippet}`)
77
+ * so that button is the trigger and gets focus back on close. A snippet
78
+ * that ignores them still works, wrapped in a span trigger as before.
79
+ */
80
+ trigger?: Snippet<[TriggerSnippetProps]>;
75
81
  /** Optional custom content */
76
82
  children?: Snippet;
77
83
  }, {}, "open">;
@@ -77,10 +77,14 @@
77
77
  ...restProps
78
78
  }: Props = $props();
79
79
 
80
- const classes = $derived(cn(badgeVariants({ variant, size, clickable }), className));
81
-
82
80
  // Determine if badge is interactive (has click handler or clickable prop)
83
81
  const isInteractive = $derived(clickable || !!onclick);
82
+
83
+ // An onclick-only badge renders a <button>, so it gets the same hover and
84
+ // focus styles as clickable
85
+ const classes = $derived(
86
+ cn(badgeVariants({ variant, size, clickable: isInteractive }), className)
87
+ );
84
88
  </script>
85
89
 
86
90
  {#if isInteractive}
@@ -28,6 +28,10 @@
28
28
  'aria-invalid'?: boolean | 'true' | 'false';
29
29
  class?: string;
30
30
  onValueChange?: (value: string) => void;
31
+ /** Chained after the field's own input handling (bind:value keeps working) */
32
+ oninput?: (event: Event & { currentTarget: HTMLTextAreaElement }) => void;
33
+ /** Chained before the field's Tab handling; `preventDefault()` skips it */
34
+ onkeydown?: (event: KeyboardEvent & { currentTarget: HTMLTextAreaElement }) => void;
31
35
  [key: string]: unknown;
32
36
  }
33
37
 
@@ -46,6 +50,8 @@
46
50
  'aria-invalid': ariaInvalid,
47
51
  class: className,
48
52
  onValueChange,
53
+ oninput,
54
+ onkeydown,
49
55
  ...restProps
50
56
  }: Props = $props();
51
57
 
@@ -63,15 +69,18 @@
63
69
  * Replace a range through the browser's editing pipeline when it can, so
64
70
  * the change lands on the native undo stack.
65
71
  */
66
- function replace(target: HTMLTextAreaElement, start: number, end: number, text: string) {
72
+ function replace(target: HTMLTextAreaElement, start: number, end: number, text: string): boolean {
67
73
  target.setSelectionRange(start, end);
68
74
  const viaEditing =
69
75
  typeof document.execCommand === 'function' && document.execCommand('insertText', false, text);
70
76
  if (!viaEditing) target.setRangeText(text, start, end, 'end');
77
+ return viaEditing;
71
78
  }
72
79
 
73
- function handleKeydown(event: KeyboardEvent) {
74
- const target = event.currentTarget as HTMLTextAreaElement;
80
+ function handleKeydown(event: KeyboardEvent & { currentTarget: HTMLTextAreaElement }) {
81
+ onkeydown?.(event);
82
+ if (event.defaultPrevented) return;
83
+ const target = event.currentTarget;
75
84
  if (event.key === 'Escape') {
76
85
  releaseTab = true;
77
86
  return;
@@ -91,8 +100,10 @@
91
100
  const lineStart = text.lastIndexOf('\n', start - 1) + 1;
92
101
  const multiline = text.slice(start, end).includes('\n');
93
102
 
103
+ // execCommand fires an input event (which emits); setRangeText does not
104
+ let viaEditing: boolean;
94
105
  if (!event.shiftKey && !multiline) {
95
- replace(target, start, end, pad);
106
+ viaEditing = replace(target, start, end, pad);
96
107
  } else {
97
108
  // Indent or outdent every line the selection touches.
98
109
  const lineEnd = end > start && text[end - 1] === '\n' ? end - 1 : end;
@@ -103,13 +114,18 @@
103
114
  const firstLineShift = event.shiftKey
104
115
  ? -(block.match(new RegExp(`^ {1,${indent}}`))?.[0].length ?? 0)
105
116
  : indent;
106
- replace(target, lineStart, lineEnd, changed);
117
+ viaEditing = replace(target, lineStart, lineEnd, changed);
107
118
  target.setSelectionRange(
108
119
  Math.max(lineStart, start + firstLineShift),
109
120
  lineEnd + (changed.length - block.length)
110
121
  );
111
122
  }
112
- emit(target);
123
+ if (!viaEditing) emit(target);
124
+ }
125
+
126
+ function handleInput(event: Event & { currentTarget: HTMLTextAreaElement }) {
127
+ emit(event.currentTarget);
128
+ oninput?.(event);
113
129
  }
114
130
  </script>
115
131
 
@@ -136,8 +152,8 @@
136
152
  : '',
137
153
  className
138
154
  )}
139
- oninput={(event) => emit(event.currentTarget)}
140
- onkeydown={handleKeydown}
141
155
  {...restProps}
156
+ oninput={handleInput}
157
+ onkeydown={handleKeydown}
142
158
  ></textarea>
143
159
  <span id={hintId} class="sr-only">Tab indents. Press Escape, then Tab, to leave the field.</span>
@@ -25,6 +25,14 @@ interface Props {
25
25
  'aria-invalid'?: boolean | 'true' | 'false';
26
26
  class?: string;
27
27
  onValueChange?: (value: string) => void;
28
+ /** Chained after the field's own input handling (bind:value keeps working) */
29
+ oninput?: (event: Event & {
30
+ currentTarget: HTMLTextAreaElement;
31
+ }) => void;
32
+ /** Chained before the field's Tab handling; `preventDefault()` skips it */
33
+ onkeydown?: (event: KeyboardEvent & {
34
+ currentTarget: HTMLTextAreaElement;
35
+ }) => void;
28
36
  [key: string]: unknown;
29
37
  }
30
38
  declare const CodeField: import("svelte").Component<Props, {}, "value">;
@@ -123,9 +123,6 @@
123
123
  };
124
124
  });
125
125
 
126
- // Determine if we need a container wrapper
127
- const hasWrapper = $derived(!!label || !!hint || !!error);
128
-
129
126
  // Generate announcement message for screen readers
130
127
  const resultsAnnouncement = $derived.by(() => {
131
128
  if (loading) return 'Loading options...';
@@ -136,114 +133,19 @@
136
133
  });
137
134
  </script>
138
135
 
139
- {#if hasWrapper}
140
- <div class="space-y-2">
141
- {#if label}
142
- <Label for={componentId} {disabled}>
143
- {label}
144
- {#if required}
145
- <span class="text-destructive ml-0.5" aria-hidden="true">*</span>
146
- <span class="sr-only">(required)</span>
147
- {/if}
148
- </Label>
149
- {/if}
150
-
151
- <ComboboxPrimitive.Root
152
- type="single"
153
- {disabled}
154
- {required}
155
- {name}
156
- bind:open
157
- onOpenChange={handleOpenChange}
158
- onValueChange={handleValueChange}
159
- {value}
160
- >
161
- <div class="relative">
162
- <ComboboxPrimitive.Input
163
- id={componentId}
164
- placeholder={selectedLabel || placeholder}
165
- oninput={handleSearchInput}
166
- onfocus={() => (open = true)}
167
- onclick={() => (open = true)}
168
- class={cn(
169
- 'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background text-foreground 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
- error && 'border-destructive focus:ring-destructive',
171
- className
172
- )}
173
- role="combobox"
174
- aria-expanded={open}
175
- aria-invalid={error ? 'true' : undefined}
176
- aria-describedby={describedBy}
177
- />
178
- </div>
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-72 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>
136
+ <!-- One structure whether or not there is a label, hint or error, so the
137
+ focused control is never remounted when an error appears. -->
138
+ <div class="space-y-2">
139
+ {#if label}
140
+ <Label for={componentId} {disabled}>
141
+ {label}
142
+ {#if required}
143
+ <span class="text-destructive ml-0.5" aria-hidden="true">*</span>
144
+ <span class="sr-only">(required)</span>
145
+ {/if}
146
+ </Label>
147
+ {/if}
233
148
 
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
149
  <ComboboxPrimitive.Root
248
150
  type="single"
249
151
  {disabled}
@@ -326,7 +228,19 @@
326
228
  </ComboboxPrimitive.Content>
327
229
  </ComboboxPrimitive.Portal>
328
230
  </ComboboxPrimitive.Root>
329
- {/if}
231
+
232
+ {#if hint && !error}
233
+ <p id={hintId} class="text-sm text-muted-foreground">
234
+ {hint}
235
+ </p>
236
+ {/if}
237
+
238
+ {#if error}
239
+ <p id={errorId} class="text-sm text-destructive" role="alert" aria-live="polite">
240
+ {error}
241
+ </p>
242
+ {/if}
243
+ </div>
330
244
 
331
245
  <!-- Live region for screen reader announcements -->
332
246
  <LiveRegion message={resultsAnnouncement} politeness="polite" />
@@ -309,6 +309,23 @@
309
309
  // Save timeout for debounced persistence
310
310
  let saveTimeout: ReturnType<typeof setTimeout> | null = null;
311
311
 
312
+ function saveNow() {
313
+ if (!persistKey) return;
314
+ // Persist the resolved values so controlled mode saves what's shown,
315
+ // not the untouched internal defaults
316
+ savePanelState(persistKey, {
317
+ mode,
318
+ variant,
319
+ edge,
320
+ isExpanded,
321
+ detachedPosition,
322
+ detachedSize,
323
+ pinnedSize,
324
+ cardSnapIndex: 0, // Legacy field
325
+ tabOffset: internalTabOffset,
326
+ });
327
+ }
328
+
312
329
  function debouncedSave() {
313
330
  if (!persistKey) return;
314
331
 
@@ -317,25 +334,19 @@
317
334
  }
318
335
 
319
336
  saveTimeout = setTimeout(() => {
320
- // Persist the resolved values so controlled mode saves what's shown,
321
- // not the untouched internal defaults
322
- savePanelState(persistKey, {
323
- mode,
324
- variant,
325
- edge,
326
- isExpanded,
327
- detachedPosition,
328
- detachedSize,
329
- pinnedSize,
330
- cardSnapIndex: 0, // Legacy field
331
- tabOffset: internalTabOffset,
332
- });
333
337
  saveTimeout = null;
338
+ saveNow();
334
339
  }, 300);
335
340
  }
336
341
 
337
342
  onDestroy(() => {
338
- if (saveTimeout) clearTimeout(saveTimeout);
343
+ // Flush a pending save, or a change made just before unmount (e.g. closing
344
+ // the panel right after resizing it) is lost
345
+ if (saveTimeout) {
346
+ clearTimeout(saveTimeout);
347
+ saveTimeout = null;
348
+ saveNow();
349
+ }
339
350
  // If we unmount mid-drag/resize (e.g. Escape closes the panel), the *End
340
351
  // handlers never run — reset the body styles they own so the page isn't
341
352
  // left stuck at grabbing cursor / no user-select.
@@ -1560,7 +1571,9 @@
1560
1571
  <!-- Resize handles (full variant only) -->
1561
1572
  {#if variant === 'full' && !disableResize}
1562
1573
  {#if mode === 'detached'}
1563
- <!-- Edge + corner handles: keyboard-resizable sliders (arrow keys, shift for large steps) -->
1574
+ <!-- Edge + corner handles: keyboard-resizable sliders (arrow keys, shift for large steps).
1575
+ Top/bottom edges change height (Up/Down, so a vertical slider); left/right
1576
+ edges and corners report width (Left/Right, horizontal). -->
1564
1577
  {#each detachedResizeHandles as { handle, orientation } (handle)}
1565
1578
  <div
1566
1579
  class={cn(
@@ -1572,7 +1585,7 @@
1572
1585
  )}
1573
1586
  role="slider"
1574
1587
  tabindex="0"
1575
- aria-orientation={orientation === 'vertical' ? 'vertical' : 'horizontal'}
1588
+ aria-orientation={orientation === 'horizontal' ? 'vertical' : 'horizontal'}
1576
1589
  aria-label={`Resize panel from ${handle.replace('-', ' ')} ${
1577
1590
  handle.includes('-') ? 'corner' : 'edge'
1578
1591
  }`}
@@ -1683,7 +1696,6 @@
1683
1696
  right: 0;
1684
1697
  height: 2px;
1685
1698
  background: transparent;
1686
- transition: background-color 0.15s ease;
1687
1699
  }
1688
1700
 
1689
1701
  .resize-handle--top {
@@ -1717,7 +1729,6 @@
1717
1729
  bottom: 0;
1718
1730
  width: 2px;
1719
1731
  background: transparent;
1720
- transition: background-color 0.15s ease;
1721
1732
  }
1722
1733
 
1723
1734
  .resize-handle--left {
@@ -1736,7 +1747,8 @@
1736
1747
  right: 0;
1737
1748
  }
1738
1749
 
1739
- /* Hover / keyboard-focus state for edge handles */
1750
+ /* Hover / keyboard-focus state for edge handles. Hover colours snap (no
1751
+ transition), as everywhere in the theme: a fade reads as flicker. */
1740
1752
  .resize-handle--horizontal:hover::after,
1741
1753
  .resize-handle--vertical:hover::after,
1742
1754
  .resize-handle--horizontal:focus-visible::after,
@@ -1769,11 +1781,6 @@
1769
1781
  height: 10px;
1770
1782
  /* !important needed to override Tailwind preflight reset (border-width: 0) */
1771
1783
  border: 2px solid transparent !important;
1772
- transition:
1773
- border-top-color 0.15s ease,
1774
- border-right-color 0.15s ease,
1775
- border-bottom-color 0.15s ease,
1776
- border-left-color 0.15s ease;
1777
1784
  }
1778
1785
 
1779
1786
  /* Corner positions */
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { cn } from '../utils.js';
3
- import DropdownMenu from './DropdownMenu.svelte';
4
- import type { DropdownMenuItem } from './DropdownMenu.svelte';
3
+ import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
4
+ import Icon from './Icon.svelte';
5
5
  import type { Snippet } from 'svelte';
6
6
  import type { PanelEdge, PanelMode } from '@classic-homes/data-panel-core';
7
7
 
@@ -64,33 +64,37 @@
64
64
  bottom: 'Pin to bottom',
65
65
  };
66
66
 
67
- // Build dropdown menu items
68
- const menuItems = $derived<DropdownMenuItem[]>([
69
- { type: 'label', label: 'Panel Position' },
67
+ // Panel Position is one choice among five (four edges or detached), so it
68
+ // is a radio group: menuitemradio items with aria-checked, not a ✓ glyph.
69
+ const positionValue = $derived(mode === 'detached' ? 'detached' : edge);
70
+ const positionOptions: { value: PanelEdge | 'detached'; label: string }[] = [
70
71
  ...(['left', 'right', 'top', 'bottom'] as PanelEdge[]).map((e) => ({
71
- label: `${edgeLabels[e]}${mode === 'pinned' && edge === e ? ' ✓' : ''}`,
72
- onSelect: () => {
73
- onModeChange?.('pinned');
74
- onEdgeChange?.(e);
75
- },
72
+ value: e,
73
+ label: edgeLabels[e],
76
74
  })),
77
- { type: 'separator' as const },
78
- {
79
- label: `Detach${mode === 'detached' ? ' ✓' : ''}`,
80
- onSelect: () => onModeChange?.('detached'),
81
- },
82
- ]);
75
+ { value: 'detached', label: 'Detach' },
76
+ ];
77
+
78
+ function handlePositionChange(next: string) {
79
+ if (next === positionValue) return;
80
+ if (next === 'detached') {
81
+ onModeChange?.('detached');
82
+ } else {
83
+ onModeChange?.('pinned');
84
+ onEdgeChange?.(next as PanelEdge);
85
+ }
86
+ }
87
+
88
+ const itemClass =
89
+ 'relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none bg-popover data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50';
83
90
  </script>
84
91
 
85
92
  {#snippet modeMenu()}
86
- <DropdownMenu
87
- items={menuItems}
88
- side="bottom"
89
- align="end"
90
- triggerLabel="Panel options"
91
- triggerClass="relative flex h-8 w-8 items-center justify-center rounded-md before:absolute before:-inset-1.5 before:content-[''] bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
92
- >
93
- {#snippet trigger()}
93
+ <DropdownMenuPrimitive.Root>
94
+ <DropdownMenuPrimitive.Trigger
95
+ aria-label="Panel options"
96
+ class="relative flex h-8 w-8 items-center justify-center rounded-md before:absolute before:-inset-1.5 before:content-[''] bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
97
+ >
94
98
  <svg
95
99
  xmlns="http://www.w3.org/2000/svg"
96
100
  width="16"
@@ -107,8 +111,44 @@
107
111
  <circle cx="12" cy="5" r="1" />
108
112
  <circle cx="12" cy="19" r="1" />
109
113
  </svg>
110
- {/snippet}
111
- </DropdownMenu>
114
+ </DropdownMenuPrimitive.Trigger>
115
+ <DropdownMenuPrimitive.Portal>
116
+ <DropdownMenuPrimitive.Content
117
+ side="bottom"
118
+ align="end"
119
+ sideOffset={4}
120
+ collisionPadding={8}
121
+ class="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"
122
+ >
123
+ <DropdownMenuPrimitive.RadioGroup
124
+ value={positionValue}
125
+ onValueChange={handlePositionChange}
126
+ >
127
+ <DropdownMenuPrimitive.GroupHeading class="px-2 py-1.5 text-sm font-semibold">
128
+ Panel Position
129
+ </DropdownMenuPrimitive.GroupHeading>
130
+ {#each positionOptions as option (option.value)}
131
+ {#if option.value === 'detached'}
132
+ <DropdownMenuPrimitive.Separator class="-mx-1 my-1 h-px bg-muted" />
133
+ {/if}
134
+ <DropdownMenuPrimitive.RadioItem value={option.value} class={itemClass}>
135
+ {#snippet children({ checked })}
136
+ {#if checked}
137
+ <Icon
138
+ name="check"
139
+ size={16}
140
+ class="absolute left-2 shrink-0"
141
+ aria-hidden="true"
142
+ />
143
+ {/if}
144
+ {option.label}
145
+ {/snippet}
146
+ </DropdownMenuPrimitive.RadioItem>
147
+ {/each}
148
+ </DropdownMenuPrimitive.RadioGroup>
149
+ </DropdownMenuPrimitive.Content>
150
+ </DropdownMenuPrimitive.Portal>
151
+ </DropdownMenuPrimitive.Root>
112
152
  {/snippet}
113
153
 
114
154
  <!-- When showModeMenuOnly is true, only render the dropdown menu -->