@classic-homes/theme-svelte 1.37.0 → 1.38.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.
@@ -51,6 +51,12 @@
51
51
  trigger?: Snippet<[TriggerSnippetProps]>;
52
52
  /** Optional custom content */
53
53
  children?: Snippet;
54
+ /**
55
+ * Called before focus returns to the trigger (or the previously focused
56
+ * element) on close; `preventDefault()` keeps focus where you put it, e.g.
57
+ * when the confirmed action removed that element.
58
+ */
59
+ onCloseAutoFocus?: (event: Event) => void;
54
60
  }
55
61
 
56
62
  let {
@@ -66,6 +72,7 @@
66
72
  class: className,
67
73
  trigger,
68
74
  children,
75
+ onCloseAutoFocus,
69
76
  }: Props = $props();
70
77
 
71
78
  const uid = $props.id();
@@ -121,6 +128,7 @@
121
128
  class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
122
129
  />
123
130
  <AlertDialogPrimitive.Content
131
+ {onCloseAutoFocus}
124
132
  class={cn(
125
133
  '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%]',
126
134
  className
@@ -80,6 +80,12 @@ declare const AlertDialog: import("svelte").Component<{
80
80
  trigger?: Snippet<[TriggerSnippetProps]>;
81
81
  /** Optional custom content */
82
82
  children?: Snippet;
83
+ /**
84
+ * Called before focus returns to the trigger (or the previously focused
85
+ * element) on close; `preventDefault()` keeps focus where you put it, e.g.
86
+ * when the confirmed action removed that element.
87
+ */
88
+ onCloseAutoFocus?: (event: Event) => void;
83
89
  }, {}, "open">;
84
90
  type AlertDialog = ReturnType<typeof AlertDialog>;
85
91
  export default AlertDialog;
@@ -25,6 +25,12 @@
25
25
  placeholder?: string;
26
26
  /** Message to display when no results found */
27
27
  emptyMessage?: string;
28
+ /**
29
+ * Render at most this many matching options, with a note to type to
30
+ * narrow the rest. Filtering still searches every option. Keeps long
31
+ * lists (thousands of options) quick to open.
32
+ */
33
+ maxResults?: number;
28
34
  /** Whether the combobox is disabled */
29
35
  disabled?: boolean;
30
36
  /** Whether selection is required */
@@ -69,6 +75,7 @@
69
75
  loading = false,
70
76
  onSearch,
71
77
  debounceMs = 300,
78
+ maxResults,
72
79
  class: className,
73
80
  'aria-label': ariaLabelProp,
74
81
  'aria-labelledby': ariaLabelledby,
@@ -129,6 +136,28 @@
129
136
  filteredOptions.reduce((n: number, item) => n + (isGroup(item) ? item.options.length : 1), 0)
130
137
  );
131
138
 
139
+ // The first `maxResults` matches, keeping their groups.
140
+ const shownOptions = $derived.by((): (ComboboxOption | SelectGroup)[] => {
141
+ if (maxResults === undefined || filteredCount <= maxResults) return filteredOptions;
142
+ let left = maxResults;
143
+ const out: (ComboboxOption | SelectGroup)[] = [];
144
+ for (const item of filteredOptions) {
145
+ if (left <= 0) break;
146
+ if (isGroup(item)) {
147
+ const opts = item.options.slice(0, left);
148
+ out.push({ ...item, options: opts });
149
+ left -= opts.length;
150
+ } else {
151
+ out.push(item);
152
+ left--;
153
+ }
154
+ }
155
+ return out;
156
+ });
157
+ const hiddenCount = $derived(
158
+ maxResults === undefined ? 0 : Math.max(0, filteredCount - maxResults)
159
+ );
160
+
132
161
  // Closed, the input shows the selected label; open, it holds what the user
133
162
  // types, with the selected label as the placeholder so the full list shows.
134
163
  const inputValue = $derived(open ? searchQuery : (selectedLabel ?? ''));
@@ -174,7 +203,8 @@
174
203
  if (!open) return '';
175
204
  if (filteredCount === 0) return emptyMessage;
176
205
  const count = filteredCount;
177
- return `${count} ${count === 1 ? 'option' : 'options'} available`;
206
+ const shown = hiddenCount ? `, showing the first ${count - hiddenCount}` : '';
207
+ return `${count} ${count === 1 ? 'option' : 'options'} available${shown}`;
178
208
  });
179
209
  </script>
180
210
 
@@ -276,7 +306,7 @@
276
306
  {emptyMessage}
277
307
  </div>
278
308
  {:else}
279
- {#each filteredOptions as item}
309
+ {#each shownOptions as item}
280
310
  {#if isGroup(item)}
281
311
  <ComboboxPrimitive.Group>
282
312
  <ComboboxPrimitive.GroupHeading class="py-1.5 pl-8 pr-2 text-sm font-semibold">
@@ -290,6 +320,11 @@
290
320
  {@render comboboxItem(item)}
291
321
  {/if}
292
322
  {/each}
323
+ {#if hiddenCount}
324
+ <div class="px-2 py-1.5 text-xs text-muted-foreground" data-combobox-more>
325
+ {hiddenCount} more. Type to narrow the list.
326
+ </div>
327
+ {/if}
293
328
  {/if}
294
329
  </div>
295
330
  </ComboboxPrimitive.Content>
@@ -15,6 +15,12 @@ interface Props {
15
15
  placeholder?: string;
16
16
  /** Message to display when no results found */
17
17
  emptyMessage?: string;
18
+ /**
19
+ * Render at most this many matching options, with a note to type to
20
+ * narrow the rest. Filtering still searches every option. Keeps long
21
+ * lists (thousands of options) quick to open.
22
+ */
23
+ maxResults?: number;
18
24
  /** Whether the combobox is disabled */
19
25
  disabled?: boolean;
20
26
  /** Whether selection is required */
@@ -0,0 +1,135 @@
1
+ <script lang="ts">
2
+ import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
3
+ import type { Snippet } from 'svelte';
4
+ import { cn } from '../utils.js';
5
+ import Icon from './Icon.svelte';
6
+ import type { DropdownMenuGroup, DropdownMenuItem } from './DropdownMenu.svelte';
7
+
8
+ interface Props {
9
+ /** Menu items, the same shape as DropdownMenu's */
10
+ items: (DropdownMenuItem | DropdownMenuGroup)[];
11
+ /** The area that opens the menu on right-click (or long-press) */
12
+ children: Snippet;
13
+ /** Whether the menu is open (bindable) */
14
+ open?: boolean;
15
+ /** Called when the menu opens or closes */
16
+ onOpenChange?: (open: boolean) => void;
17
+ /** Leave the browser's own menu in place */
18
+ disabled?: boolean;
19
+ /** Classes for the trigger area */
20
+ triggerClass?: string;
21
+ /** Classes for the menu */
22
+ class?: string;
23
+ /**
24
+ * Called as the menu closes, before focus returns to where it was;
25
+ * `preventDefault()` keeps focus where a chosen item put it.
26
+ */
27
+ onCloseAutoFocus?: (event: Event) => void;
28
+ }
29
+
30
+ let {
31
+ items,
32
+ children,
33
+ open = $bindable(false),
34
+ onOpenChange,
35
+ disabled = false,
36
+ triggerClass,
37
+ class: className,
38
+ onCloseAutoFocus,
39
+ }: Props = $props();
40
+
41
+ function isGroup(item: DropdownMenuItem | DropdownMenuGroup): item is DropdownMenuGroup {
42
+ return 'items' in item && Array.isArray(item.items);
43
+ }
44
+
45
+ function handleOpenChange(next: boolean) {
46
+ open = next;
47
+ onOpenChange?.(next);
48
+ }
49
+
50
+ const itemClass =
51
+ 'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none bg-popover data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-default';
52
+ </script>
53
+
54
+ {#snippet itemContent(item: DropdownMenuItem)}
55
+ {#if item.icon}
56
+ <Icon name={item.icon} size={16} class="mr-2 shrink-0" aria-hidden="true" />
57
+ {/if}
58
+ {item.label}
59
+ {#if item.shortcut}
60
+ <span class="ml-auto pl-4 text-xs tracking-widest opacity-60" aria-hidden="true">
61
+ {item.shortcut}
62
+ </span>
63
+ <span class="sr-only">, keyboard shortcut {item.shortcut}</span>
64
+ {/if}
65
+ {/snippet}
66
+
67
+ {#snippet entry(item: DropdownMenuItem)}
68
+ {#if item.type === 'separator'}
69
+ <ContextMenuPrimitive.Separator class="-mx-1 my-1 h-px bg-muted" />
70
+ {:else if item.type === 'label'}
71
+ <div role="presentation" class="px-2 py-1.5 text-sm font-semibold">{item.label}</div>
72
+ {:else if item.checked !== undefined}
73
+ <ContextMenuPrimitive.CheckboxItem
74
+ checked={item.checked}
75
+ disabled={item.disabled}
76
+ closeOnSelect={false}
77
+ onSelect={() => item.onSelect?.()}
78
+ class={cn(itemClass, 'pl-8')}
79
+ >
80
+ {#if item.checked}
81
+ <Icon name="check" size={16} class="absolute left-2 shrink-0" aria-hidden="true" />
82
+ {/if}
83
+ {@render itemContent(item)}
84
+ </ContextMenuPrimitive.CheckboxItem>
85
+ {:else}
86
+ <ContextMenuPrimitive.Item
87
+ disabled={item.disabled}
88
+ onSelect={() => item.onSelect?.()}
89
+ class={cn(
90
+ itemClass,
91
+ item.destructive && 'text-destructive data-[highlighted]:text-destructive'
92
+ )}
93
+ aria-keyshortcuts={item.shortcut || undefined}
94
+ >
95
+ {@render itemContent(item)}
96
+ </ContextMenuPrimitive.Item>
97
+ {/if}
98
+ {/snippet}
99
+
100
+ <ContextMenuPrimitive.Root bind:open onOpenChange={handleOpenChange}>
101
+ <ContextMenuPrimitive.Trigger {disabled} class={triggerClass}>
102
+ {@render children()}
103
+ </ContextMenuPrimitive.Trigger>
104
+
105
+ <!-- Portal so the menu is not clipped by an overflow/transformed ancestor. -->
106
+ <ContextMenuPrimitive.Portal>
107
+ <ContextMenuPrimitive.Content
108
+ collisionPadding={8}
109
+ {onCloseAutoFocus}
110
+ class={cn(
111
+ 'isolate z-50 min-w-[10rem] 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',
112
+ className
113
+ )}
114
+ >
115
+ <div class="max-h-80 overflow-y-auto overflow-x-hidden">
116
+ {#each items as item, i (i)}
117
+ {#if isGroup(item)}
118
+ <ContextMenuPrimitive.Group>
119
+ {#if item.label}
120
+ <ContextMenuPrimitive.GroupHeading class="px-2 py-1.5 text-sm font-semibold">
121
+ {item.label}
122
+ </ContextMenuPrimitive.GroupHeading>
123
+ {/if}
124
+ {#each item.items as sub, j (j)}
125
+ {@render entry(sub)}
126
+ {/each}
127
+ </ContextMenuPrimitive.Group>
128
+ {:else}
129
+ {@render entry(item)}
130
+ {/if}
131
+ {/each}
132
+ </div>
133
+ </ContextMenuPrimitive.Content>
134
+ </ContextMenuPrimitive.Portal>
135
+ </ContextMenuPrimitive.Root>
@@ -0,0 +1,26 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { DropdownMenuGroup, DropdownMenuItem } from './DropdownMenu.svelte';
3
+ interface Props {
4
+ /** Menu items, the same shape as DropdownMenu's */
5
+ items: (DropdownMenuItem | DropdownMenuGroup)[];
6
+ /** The area that opens the menu on right-click (or long-press) */
7
+ children: Snippet;
8
+ /** Whether the menu is open (bindable) */
9
+ open?: boolean;
10
+ /** Called when the menu opens or closes */
11
+ onOpenChange?: (open: boolean) => void;
12
+ /** Leave the browser's own menu in place */
13
+ disabled?: boolean;
14
+ /** Classes for the trigger area */
15
+ triggerClass?: string;
16
+ /** Classes for the menu */
17
+ class?: string;
18
+ /**
19
+ * Called as the menu closes, before focus returns to where it was;
20
+ * `preventDefault()` keeps focus where a chosen item put it.
21
+ */
22
+ onCloseAutoFocus?: (event: Event) => void;
23
+ }
24
+ declare const ContextMenu: import("svelte").Component<Props, {}, "open">;
25
+ type ContextMenu = ReturnType<typeof ContextMenu>;
26
+ export default ContextMenu;
@@ -51,6 +51,8 @@
51
51
  * renders one once there are at least that many options.
52
52
  */
53
53
  searchable?: boolean | number;
54
+ /** With `searchable`: render at most this many matches (see Combobox) */
55
+ maxResults?: number;
54
56
  }
55
57
 
56
58
  let {
@@ -69,6 +71,7 @@
69
71
  'aria-label': ariaLabelProp,
70
72
  'aria-labelledby': ariaLabelledby,
71
73
  searchable = false,
74
+ maxResults,
72
75
  }: Props = $props();
73
76
 
74
77
  // Generate unique IDs for accessibility using sequential counter
@@ -291,6 +294,7 @@
291
294
  {id}
292
295
  {error}
293
296
  {hint}
297
+ {maxResults}
294
298
  aria-label={ariaLabelProp}
295
299
  aria-labelledby={ariaLabelledby}
296
300
  class={className}
@@ -41,6 +41,8 @@ interface Props {
41
41
  * renders one once there are at least that many options.
42
42
  */
43
43
  searchable?: boolean | number;
44
+ /** With `searchable`: render at most this many matches (see Combobox) */
45
+ maxResults?: number;
44
46
  }
45
47
  declare const Select: import("svelte").Component<Props, {}, "value">;
46
48
  type Select = ReturnType<typeof Select>;
@@ -41,6 +41,7 @@ export { default as Dialog } from './components/Dialog.svelte';
41
41
  export { default as AlertDialog } from './components/AlertDialog.svelte';
42
42
  export { default as Tooltip } from './components/Tooltip.svelte';
43
43
  export { default as DropdownMenu } from './components/DropdownMenu.svelte';
44
+ export { default as ContextMenu } from './components/ContextMenu.svelte';
44
45
  export { default as AppLauncher, type AppLauncherItem } from './components/AppLauncher.svelte';
45
46
  export { default as AppLauncherGrid } from './components/AppLauncherGrid.svelte';
46
47
  export { default as DataPanel } from './components/DataPanel.svelte';
package/dist/lib/index.js CHANGED
@@ -49,6 +49,7 @@ export { default as Dialog } from './components/Dialog.svelte';
49
49
  export { default as AlertDialog } from './components/AlertDialog.svelte';
50
50
  export { default as Tooltip } from './components/Tooltip.svelte';
51
51
  export { default as DropdownMenu } from './components/DropdownMenu.svelte';
52
+ export { default as ContextMenu } from './components/ContextMenu.svelte';
52
53
  export { default as AppLauncher } from './components/AppLauncher.svelte';
53
54
  export { default as AppLauncherGrid } from './components/AppLauncherGrid.svelte';
54
55
  // DataPanel Components
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classic-homes/theme-svelte",
3
- "version": "1.37.0",
3
+ "version": "1.38.0",
4
4
  "description": "Svelte components for the Classic theme system",
5
5
  "type": "module",
6
6
  "svelte": "./dist/lib/index.js",