@classic-homes/theme-svelte 1.34.0 → 1.35.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.
@@ -0,0 +1,156 @@
1
+ <script lang="ts" module>
2
+ import type { IconName } from './Icon.svelte';
3
+
4
+ export interface AppLauncherItem {
5
+ id: string;
6
+ /** Name under the tile */
7
+ label: string;
8
+ /** Where the tile goes: a real link, so Ctrl/middle-click open a new tab */
9
+ href: string;
10
+ /** Tile icon (ignored when the `icon` snippet is set) */
11
+ icon?: IconName;
12
+ /** Opens in a new tab (`target="_blank"`, `rel="noopener noreferrer"`) */
13
+ external?: boolean;
14
+ /** Shown as the tile's tooltip */
15
+ description?: string;
16
+ }
17
+ </script>
18
+
19
+ <script lang="ts">
20
+ import { Popover } from 'bits-ui';
21
+ import type { Snippet } from 'svelte';
22
+ import { cn } from '../utils.js';
23
+ import Icon from './Icon.svelte';
24
+ import { buttonVariants } from './button-variants.js';
25
+
26
+ /**
27
+ * AppLauncher - the header's "apps" grid: a grid-icon button that opens a
28
+ * panel of tiles linking to the other systems people use.
29
+ *
30
+ * Tiles are plain links, so Ctrl/middle-click open a new tab; external ones
31
+ * open in a new tab anyway. Arrow keys move between tiles, Home/End jump to
32
+ * the first/last, Escape closes and returns focus to the button.
33
+ */
34
+ interface Props {
35
+ apps: AppLauncherItem[];
36
+ /** Accessible name of the button and the panel (default: "Apps") */
37
+ triggerLabel?: string;
38
+ /** Heading at the top of the panel; none when unset */
39
+ title?: string;
40
+ /** Tiles per row (default: 3) */
41
+ columns?: number;
42
+ /** Panel alignment against the button (default: end) */
43
+ align?: 'start' | 'center' | 'end';
44
+ /** Panel open (bindable) */
45
+ open?: boolean;
46
+ onOpenChange?: (open: boolean) => void;
47
+ /** Classes for the panel */
48
+ class?: string;
49
+ /** Classes for the trigger button */
50
+ triggerClass?: string;
51
+ /** Draws a tile's icon instead of `item.icon`, e.g. an app's own mark */
52
+ icon?: Snippet<[AppLauncherItem]>;
53
+ }
54
+
55
+ let {
56
+ apps,
57
+ triggerLabel = 'Apps',
58
+ title,
59
+ columns = 3,
60
+ align = 'end',
61
+ open = $bindable(false),
62
+ onOpenChange,
63
+ class: className,
64
+ triggerClass,
65
+ icon,
66
+ }: Props = $props();
67
+
68
+ function handleOpenChange(next: boolean) {
69
+ open = next;
70
+ onOpenChange?.(next);
71
+ }
72
+
73
+ /** Arrow keys walk the grid by row and column; Home/End go to the ends. */
74
+ function handleKeydown(event: KeyboardEvent) {
75
+ const tiles = Array.from(
76
+ (event.currentTarget as HTMLElement).querySelectorAll<HTMLElement>('[data-app-launcher-tile]')
77
+ );
78
+ const current = tiles.indexOf(document.activeElement as HTMLElement);
79
+ if (current === -1) return;
80
+ const last = tiles.length - 1;
81
+ const step: Record<string, number> = {
82
+ ArrowRight: 1,
83
+ ArrowLeft: -1,
84
+ ArrowDown: columns,
85
+ ArrowUp: -columns,
86
+ };
87
+ let next: number;
88
+ if (event.key === 'Home') next = 0;
89
+ else if (event.key === 'End') next = last;
90
+ else if (event.key in step) next = current + step[event.key];
91
+ else return;
92
+ event.preventDefault();
93
+ tiles[Math.min(Math.max(next, 0), last)].focus();
94
+ }
95
+ </script>
96
+
97
+ <Popover.Root bind:open onOpenChange={handleOpenChange}>
98
+ <Popover.Trigger
99
+ class={cn(buttonVariants({ variant: 'ghost', size: 'icon' }), triggerClass)}
100
+ aria-label={triggerLabel}
101
+ >
102
+ <Icon name="grid" size={20} aria-hidden="true" />
103
+ </Popover.Trigger>
104
+ <Popover.Portal>
105
+ <Popover.Content
106
+ {align}
107
+ sideOffset={6}
108
+ collisionPadding={8}
109
+ class={cn(
110
+ 'z-50 max-h-[calc(100dvh-5rem)] w-[min(20rem,calc(100vw-2rem))] overflow-y-auto rounded-lg border border-border bg-popover p-2 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',
111
+ className
112
+ )}
113
+ >
114
+ <nav aria-label={title ?? triggerLabel}>
115
+ {#if title}
116
+ <p class="px-2 pb-2 pt-1 text-sm font-semibold">{title}</p>
117
+ {/if}
118
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
119
+ <ul
120
+ class="grid gap-1"
121
+ style:grid-template-columns="repeat({columns}, minmax(0, 1fr))"
122
+ onkeydown={handleKeydown}
123
+ >
124
+ {#each apps as app (app.id)}
125
+ <li>
126
+ <a
127
+ href={app.href}
128
+ target={app.external ? '_blank' : undefined}
129
+ rel={app.external ? 'noopener noreferrer' : undefined}
130
+ title={app.description}
131
+ data-app-launcher-tile={app.id}
132
+ class="flex h-full flex-col items-center gap-1.5 rounded-md px-1 py-3 text-center text-xs font-medium text-foreground hover:bg-muted-hover focus-visible:bg-muted-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
133
+ onclick={() => handleOpenChange(false)}
134
+ >
135
+ <span
136
+ class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary"
137
+ aria-hidden="true"
138
+ >
139
+ {#if icon}
140
+ {@render icon(app)}
141
+ {:else}
142
+ <Icon name={app.icon ?? 'external-link'} size={22} />
143
+ {/if}
144
+ </span>
145
+ <span class="line-clamp-2 break-words">{app.label}</span>
146
+ {#if app.external}
147
+ <span class="sr-only">(opens in a new tab)</span>
148
+ {/if}
149
+ </a>
150
+ </li>
151
+ {/each}
152
+ </ul>
153
+ </nav>
154
+ </Popover.Content>
155
+ </Popover.Portal>
156
+ </Popover.Root>
@@ -0,0 +1,46 @@
1
+ import type { IconName } from './Icon.svelte';
2
+ export interface AppLauncherItem {
3
+ id: string;
4
+ /** Name under the tile */
5
+ label: string;
6
+ /** Where the tile goes: a real link, so Ctrl/middle-click open a new tab */
7
+ href: string;
8
+ /** Tile icon (ignored when the `icon` snippet is set) */
9
+ icon?: IconName;
10
+ /** Opens in a new tab (`target="_blank"`, `rel="noopener noreferrer"`) */
11
+ external?: boolean;
12
+ /** Shown as the tile's tooltip */
13
+ description?: string;
14
+ }
15
+ import type { Snippet } from 'svelte';
16
+ /**
17
+ * AppLauncher - the header's "apps" grid: a grid-icon button that opens a
18
+ * panel of tiles linking to the other systems people use.
19
+ *
20
+ * Tiles are plain links, so Ctrl/middle-click open a new tab; external ones
21
+ * open in a new tab anyway. Arrow keys move between tiles, Home/End jump to
22
+ * the first/last, Escape closes and returns focus to the button.
23
+ */
24
+ interface Props {
25
+ apps: AppLauncherItem[];
26
+ /** Accessible name of the button and the panel (default: "Apps") */
27
+ triggerLabel?: string;
28
+ /** Heading at the top of the panel; none when unset */
29
+ title?: string;
30
+ /** Tiles per row (default: 3) */
31
+ columns?: number;
32
+ /** Panel alignment against the button (default: end) */
33
+ align?: 'start' | 'center' | 'end';
34
+ /** Panel open (bindable) */
35
+ open?: boolean;
36
+ onOpenChange?: (open: boolean) => void;
37
+ /** Classes for the panel */
38
+ class?: string;
39
+ /** Classes for the trigger button */
40
+ triggerClass?: string;
41
+ /** Draws a tile's icon instead of `item.icon`, e.g. an app's own mark */
42
+ icon?: Snippet<[AppLauncherItem]>;
43
+ }
44
+ declare const AppLauncher: import("svelte").Component<Props, {}, "open">;
45
+ type AppLauncher = ReturnType<typeof AppLauncher>;
46
+ export default AppLauncher;
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { tick } from 'svelte';
2
3
  import { Combobox as ComboboxPrimitive } from 'bits-ui';
3
4
  import { cn } from '../utils.js';
4
5
  import { computeDescribedBy } from '../utils/form.js';
@@ -80,6 +81,7 @@
80
81
  const describedBy = $derived(computeDescribedBy(hintId, errorId, !!hint, !!error));
81
82
 
82
83
  let searchQuery = $state('');
84
+ let inputEl = $state<HTMLInputElement | null>(null);
83
85
  let debounceTimer: ReturnType<typeof setTimeout> | null = null;
84
86
  let open = $state(false);
85
87
 
@@ -131,6 +133,20 @@
131
133
  if (toggled !== null) anchor = toggled;
132
134
  value = next;
133
135
  onValueChange?.(next);
136
+ clearInput();
137
+ }
138
+
139
+ /**
140
+ * The combobox writes the picked option's label into the input; clear it
141
+ * (through an input event, so the combobox's own state and the filter both
142
+ * reset) so the next search starts empty and the full list shows again.
143
+ */
144
+ function clearInput() {
145
+ void tick().then(() => {
146
+ if (!inputEl || inputEl.value === '') return;
147
+ inputEl.value = '';
148
+ inputEl.dispatchEvent(new Event('input', { bubbles: true }));
149
+ });
134
150
  }
135
151
 
136
152
  function removeValue(valueToRemove: string) {
@@ -143,6 +159,7 @@
143
159
  open = isOpen;
144
160
  if (!isOpen) {
145
161
  searchQuery = '';
162
+ clearInput();
146
163
  }
147
164
  }
148
165
 
@@ -235,6 +252,7 @@
235
252
  >
236
253
  <div class="relative">
237
254
  <ComboboxPrimitive.Input
255
+ bind:ref={inputEl}
238
256
  id={componentId}
239
257
  placeholder={value.length > 0 ? `${value.length} selected` : placeholder}
240
258
  oninput={handleSearchInput}
@@ -15,8 +15,9 @@
15
15
  *
16
16
  * Picking an item from elsewhere (an outline, an issue list) scrolls it into
17
17
  * view; picking it on the canvas does not, so it never moves under the
18
- * pointer. `insertPlacement="gutter"` puts the "+" in the left margin, for
19
- * items edited in place, where a centred "+" would sit on the text.
18
+ * pointer. For items edited in place, `insertPlacement="gutter"` puts the
19
+ * "+" in the left margin and `toolbarPlacement="above"` lifts the toolbar
20
+ * clear of the item, so neither sits on the text.
20
21
  */
21
22
  interface Props {
22
23
  children: Snippet;
@@ -39,6 +40,12 @@
39
40
  revealOnSelect?: boolean;
40
41
  /** Where the "+" sits: centred on the item's bottom edge, or in the left gutter beside it */
41
42
  insertPlacement?: 'center' | 'gutter';
43
+ /**
44
+ * Where the toolbar sits: straddling the item's top edge (default), or
45
+ * wholly above it, for items edited in place, where the default would
46
+ * cover the first line of text.
47
+ */
48
+ toolbarPlacement?: 'edge' | 'above';
42
49
  class?: string;
43
50
  }
44
51
 
@@ -53,6 +60,7 @@
53
60
  invalid = false,
54
61
  revealOnSelect = true,
55
62
  insertPlacement = 'center',
63
+ toolbarPlacement = 'edge',
56
64
  class: className,
57
65
  }: Props = $props();
58
66
 
@@ -129,7 +137,10 @@
129
137
  {#if selected && toolbar}
130
138
  <div
131
139
  data-studio-quick-action
132
- class="absolute -top-4 right-3 z-10 flex items-center gap-0.5 rounded-md border border-border bg-background p-0.5 shadow-md"
140
+ class={cn(
141
+ 'absolute right-3 z-10 flex items-center gap-0.5 rounded-md border border-border bg-background p-0.5 shadow-md',
142
+ toolbarPlacement === 'above' ? 'bottom-full mb-1' : '-top-4'
143
+ )}
133
144
  >
134
145
  {@render toolbar()}
135
146
  </div>
@@ -10,8 +10,9 @@ import { type Snippet } from 'svelte';
10
10
  *
11
11
  * Picking an item from elsewhere (an outline, an issue list) scrolls it into
12
12
  * view; picking it on the canvas does not, so it never moves under the
13
- * pointer. `insertPlacement="gutter"` puts the "+" in the left margin, for
14
- * items edited in place, where a centred "+" would sit on the text.
13
+ * pointer. For items edited in place, `insertPlacement="gutter"` puts the
14
+ * "+" in the left margin and `toolbarPlacement="above"` lifts the toolbar
15
+ * clear of the item, so neither sits on the text.
15
16
  */
16
17
  interface Props {
17
18
  children: Snippet;
@@ -34,6 +35,12 @@ interface Props {
34
35
  revealOnSelect?: boolean;
35
36
  /** Where the "+" sits: centred on the item's bottom edge, or in the left gutter beside it */
36
37
  insertPlacement?: 'center' | 'gutter';
38
+ /**
39
+ * Where the toolbar sits: straddling the item's top edge (default), or
40
+ * wholly above it, for items edited in place, where the default would
41
+ * cover the first line of text.
42
+ */
43
+ toolbarPlacement?: 'edge' | 'above';
37
44
  class?: string;
38
45
  }
39
46
  declare const StudioCanvasItem: import("svelte").Component<Props, {}, "">;
@@ -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 AppLauncher, type AppLauncherItem } from './components/AppLauncher.svelte';
44
45
  export { default as DataPanel } from './components/DataPanel.svelte';
45
46
  export { default as DataPanelHeader } from './components/DataPanelHeader.svelte';
46
47
  export { default as DataPanelContent } from './components/DataPanelContent.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 AppLauncher } from './components/AppLauncher.svelte';
52
53
  // DataPanel Components
53
54
  export { default as DataPanel } from './components/DataPanel.svelte';
54
55
  export { default as DataPanelHeader } from './components/DataPanelHeader.svelte';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classic-homes/theme-svelte",
3
- "version": "1.34.0",
3
+ "version": "1.35.0",
4
4
  "description": "Svelte components for the Classic theme system",
5
5
  "type": "module",
6
6
  "svelte": "./dist/lib/index.js",