@immich/ui 0.40.3 → 0.41.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.
@@ -46,7 +46,7 @@
46
46
  });
47
47
 
48
48
  const cardStyles = tv({
49
- base: 'flex grow flex-col',
49
+ base: 'flex w-full grow flex-col',
50
50
  variants: {
51
51
  color: {
52
52
  primary: 'bg-primary/25 dark:bg-primary/25',
@@ -0,0 +1,13 @@
1
+ <script lang="ts">
2
+ import { commandPaletteManager, type CommandItem } from '../../services/command-palette-manager.svelte';
3
+ import { onMount } from 'svelte';
4
+
5
+ type Props = {
6
+ commands?: CommandItem[];
7
+ global?: boolean;
8
+ };
9
+
10
+ const { commands = [], global }: Props = $props();
11
+
12
+ onMount(() => commandPaletteManager.addCommands(commands, { global }));
13
+ </script>
@@ -0,0 +1,8 @@
1
+ import { type CommandItem } from '../../services/command-palette-manager.svelte';
2
+ type Props = {
3
+ commands?: CommandItem[];
4
+ global?: boolean;
5
+ };
6
+ declare const CommandPaletteContext: import("svelte").Component<Props, {}, "">;
7
+ type CommandPaletteContext = ReturnType<typeof CommandPaletteContext>;
8
+ export default CommandPaletteContext;
@@ -0,0 +1,139 @@
1
+ <script lang="ts">
2
+ import Icon from '../Icon/Icon.svelte';
3
+ import Text from '../Text/Text.svelte';
4
+ import { zIndex } from '../../constants.js';
5
+ import { styleVariants } from '../../styles.js';
6
+ import { MenuItemType, type ContextMenuProps, type MenuItem } from '../../types.js';
7
+ import { cleanClass } from '../../utilities/internal.js';
8
+ import { DropdownMenu } from 'bits-ui';
9
+ import { fly } from 'svelte/transition';
10
+ import { tv } from 'tailwind-variants';
11
+
12
+ let {
13
+ onClose,
14
+ items,
15
+ bottomItems,
16
+ size = 'medium',
17
+ anchor,
18
+ position = 'top-left',
19
+ class: className,
20
+ ...restProps
21
+ }: ContextMenuProps = $props();
22
+
23
+ const isDivider = (item: MenuItem | MenuItemType): item is MenuItemType => {
24
+ return item === MenuItemType.Divider;
25
+ };
26
+
27
+ const itemStyles = tv({
28
+ base: 'hover:bg-subtle flex w-full items-center gap-1 rounded-md px-1 py-0.5 text-start',
29
+ variants: {
30
+ color: styleVariants.textColor,
31
+ },
32
+ });
33
+
34
+ const wrapperStyles = tv({
35
+ base: 'bg-light flex flex-col gap-1 overflow-hidden rounded-lg border py-1',
36
+ variants: {
37
+ size: {
38
+ tiny: 'w-32',
39
+ small: 'w-48',
40
+ medium: 'w-3xs',
41
+ large: 'w-sm',
42
+ giant: 'w-lg',
43
+ full: 'w-full',
44
+ },
45
+ },
46
+ });
47
+
48
+ const getAlignment = (
49
+ align: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right',
50
+ ): { align: 'start' | 'center' | 'end'; side: 'top' | 'right' | 'bottom' | 'left' } => {
51
+ switch (align) {
52
+ case 'top-left': {
53
+ return {
54
+ align: 'start',
55
+ side: 'bottom',
56
+ };
57
+ }
58
+ case 'top-right': {
59
+ return {
60
+ align: 'end',
61
+ side: 'bottom',
62
+ };
63
+ }
64
+ case 'bottom-left': {
65
+ return {
66
+ align: 'start',
67
+ side: 'top',
68
+ };
69
+ }
70
+ case 'bottom-right': {
71
+ return {
72
+ align: 'end',
73
+ side: 'top',
74
+ };
75
+ }
76
+
77
+ default: {
78
+ return {
79
+ align: 'start',
80
+ side: 'bottom',
81
+ };
82
+ }
83
+ }
84
+ };
85
+
86
+ const alignOffset = $derived(anchor.clientWidth / 2);
87
+ const sideOffset = $derived(-anchor.clientHeight / 2);
88
+ const { side, align } = $derived(getAlignment(position));
89
+ </script>
90
+
91
+ <DropdownMenu.Root open={true} onOpenChange={() => onClose()}>
92
+ <DropdownMenu.Portal>
93
+ <DropdownMenu.Content forceMount customAnchor={anchor} {side} {align} {alignOffset} {sideOffset}>
94
+ {#snippet child({ wrapperProps, props, open })}
95
+ {#if open}
96
+ <div {...wrapperProps} class={zIndex.ContextMenu}>
97
+ <div {...props} {...restProps} class={cleanClass(wrapperStyles({ size }), className)} transition:fly>
98
+ {#each items as item, i (isDivider(item) ? i : item.title)}
99
+ {#if isDivider(item)}
100
+ <DropdownMenu.Separator class="my-0.5 border-t" />
101
+ {:else}
102
+ <DropdownMenu.Item
103
+ textValue={item.title}
104
+ closeOnSelect
105
+ onSelect={(event) => item.onSelect?.({ event, item })}
106
+ class="px-1"
107
+ >
108
+ <div class={itemStyles({ color: item.color })}>
109
+ <Icon icon={item.icon} class="m-2 shrink-0" />
110
+ <Text class="grow text-start">{item.title}</Text>
111
+ </div>
112
+ </DropdownMenu.Item>
113
+ {/if}
114
+ {/each}
115
+
116
+ {#if bottomItems}
117
+ <DropdownMenu.Separator class="my-0.5 border-t" />
118
+ <div class="flex gap-1 px-1">
119
+ {#each bottomItems as item (item.title)}
120
+ <DropdownMenu.Item
121
+ textValue={item.title}
122
+ closeOnSelect
123
+ onSelect={(event) => item.onSelect?.({ event, item })}
124
+ title={item.title}
125
+ >
126
+ <div class={cleanClass(itemStyles({ color: item.color }))}>
127
+ <Icon icon={item.icon} class="m-2 shrink-0" />
128
+ </div>
129
+ </DropdownMenu.Item>
130
+ {/each}
131
+ </div>
132
+ {/if}
133
+ </div>
134
+ </div>
135
+ {/if}
136
+ {/snippet}
137
+ </DropdownMenu.Content>
138
+ </DropdownMenu.Portal>
139
+ </DropdownMenu.Root>
@@ -0,0 +1,4 @@
1
+ import { type ContextMenuProps } from '../../types.js';
2
+ declare const ContextMenu: import("svelte").Component<ContextMenuProps, {}, "">;
3
+ type ContextMenu = ReturnType<typeof ContextMenu>;
4
+ export default ContextMenu;
@@ -9,10 +9,11 @@
9
9
  import Icon from '../Icon/Icon.svelte';
10
10
  import Logo from '../Logo/Logo.svelte';
11
11
  import { ChildKey, zIndex } from '../../constants.js';
12
+ import { commandPaletteManager } from '../../services/command-palette-manager.svelte.js';
12
13
  import type { ModalSize } from '../../types.js';
13
14
  import { cleanClass } from '../../utilities/internal.js';
14
15
  import { Dialog } from 'bits-ui';
15
- import { tick, type Snippet } from 'svelte';
16
+ import { onMount, tick, type Snippet } from 'svelte';
16
17
  import { tv } from 'tailwind-variants';
17
18
 
18
19
  type Props = {
@@ -85,6 +86,8 @@
85
86
 
86
87
  const interactOutsideBehavior = $derived(closeOnBackdropClick ? 'close' : 'ignore');
87
88
  const escapeKeydownBehavior = $derived(closeOnEsc ? 'close' : 'ignore');
89
+
90
+ onMount(() => commandPaletteManager.pushContextLayer());
88
91
  </script>
89
92
 
90
93
  <Dialog.Root open={true} onOpenChange={(isOpen: boolean) => !isOpen && handleClose()}>
@@ -17,5 +17,7 @@ export declare const zIndex: {
17
17
  AppShellSidebar: string;
18
18
  ModalBackdrop: string;
19
19
  ModalContent: string;
20
+ SelectDropdown: string;
20
21
  ToastPanel: string;
22
+ ContextMenu: string;
21
23
  };
package/dist/constants.js CHANGED
@@ -18,5 +18,7 @@ export const zIndex = {
18
18
  AppShellSidebar: 'z-30',
19
19
  ModalBackdrop: 'z-40',
20
20
  ModalContent: 'z-50',
21
+ SelectDropdown: 'z-55',
21
22
  ToastPanel: 'z-60',
23
+ ContextMenu: 'z-70!',
22
24
  };
package/dist/index.d.ts CHANGED
@@ -27,7 +27,7 @@ export { default as Checkbox } from './components/Checkbox/Checkbox.svelte';
27
27
  export { default as CloseButton } from './components/CloseButton/CloseButton.svelte';
28
28
  export { default as Code } from './components/Code/Code.svelte';
29
29
  export { default as CodeBlock } from './components/CodeBlock/CodeBlock.svelte';
30
- export { default as CommandPalette } from './components/CommandPalette/CommandPalette.svelte';
30
+ export { default as CommandPaletteContext } from './components/CommandPalette/CommandPaletteContext.svelte';
31
31
  export { default as ConfirmModal } from './components/ConfirmModal/ConfirmModal.svelte';
32
32
  export { default as Container } from './components/Container/Container.svelte';
33
33
  export { default as Field } from './components/Field/Field.svelte';
package/dist/index.js CHANGED
@@ -29,7 +29,7 @@ export { default as Checkbox } from './components/Checkbox/Checkbox.svelte';
29
29
  export { default as CloseButton } from './components/CloseButton/CloseButton.svelte';
30
30
  export { default as Code } from './components/Code/Code.svelte';
31
31
  export { default as CodeBlock } from './components/CodeBlock/CodeBlock.svelte';
32
- export { default as CommandPalette } from './components/CommandPalette/CommandPalette.svelte';
32
+ export { default as CommandPaletteContext } from './components/CommandPalette/CommandPaletteContext.svelte';
33
33
  export { default as ConfirmModal } from './components/ConfirmModal/ConfirmModal.svelte';
34
34
  export { default as Container } from './components/Container/Container.svelte';
35
35
  export { default as Field } from './components/Field/Field.svelte';
@@ -0,0 +1,131 @@
1
+ <script lang="ts">
2
+ import { shortcuts } from '../actions/shortcut.js';
3
+ import CloseButton from '../components/CloseButton/CloseButton.svelte';
4
+ import CommandPaletteItem from '../components/CommandPalette/CommandPaletteItem.svelte';
5
+ import Icon from '../components/Icon/Icon.svelte';
6
+ import Input from '../components/Input/Input.svelte';
7
+ import Modal from '../components/Modal/Modal.svelte';
8
+ import ModalBody from '../components/Modal/ModalBody.svelte';
9
+ import ModalFooter from '../components/Modal/ModalFooter.svelte';
10
+ import ModalHeader from '../components/Modal/ModalHeader.svelte';
11
+ import Stack from '../components/Stack/Stack.svelte';
12
+ import Text from '../components/Text/Text.svelte';
13
+ import {
14
+ commandPaletteManager,
15
+ type CommandPaletteTranslations,
16
+ } from '../services/command-palette-manager.svelte.js';
17
+ import { t } from '../services/translation.svelte.js';
18
+ import { mdiArrowDown, mdiArrowUp, mdiKeyboardEsc, mdiKeyboardReturn, mdiMagnify } from '@mdi/js';
19
+
20
+ type Props = {
21
+ onClose: () => void;
22
+ translations?: CommandPaletteTranslations;
23
+ };
24
+
25
+ const handleUp = (event: KeyboardEvent) => handleNavigate(event, 'up');
26
+ const handleDown = (event: KeyboardEvent) => handleNavigate(event, 'down');
27
+ const handleSelect = (event: KeyboardEvent) => handleNavigate(event, 'select');
28
+ const handleNavigate = async (event: KeyboardEvent, direction: 'up' | 'down' | 'select') => {
29
+ event.preventDefault();
30
+
31
+ switch (direction) {
32
+ case 'up': {
33
+ commandPaletteManager.up();
34
+ break;
35
+ }
36
+
37
+ case 'down': {
38
+ commandPaletteManager.down();
39
+ break;
40
+ }
41
+
42
+ case 'select': {
43
+ await commandPaletteManager.select();
44
+ break;
45
+ }
46
+ }
47
+ };
48
+
49
+ const { onClose, translations }: Props = $props();
50
+ </script>
51
+
52
+ <svelte:window
53
+ use:shortcuts={[
54
+ { shortcut: { key: 'ArrowUp' }, preventDefault: false, ignoreInputFields: false, onShortcut: handleUp },
55
+ { shortcut: { key: 'ArrowDown' }, preventDefault: false, ignoreInputFields: false, onShortcut: handleDown },
56
+ { shortcut: { key: 'k', ctrl: true }, ignoreInputFields: false, onShortcut: handleUp },
57
+ { shortcut: { key: 'k', meta: true }, ignoreInputFields: false, onShortcut: handleUp },
58
+ { shortcut: { key: 'j', ctrl: true }, ignoreInputFields: false, onShortcut: handleDown },
59
+ { shortcut: { key: 'j', meta: true }, ignoreInputFields: false, onShortcut: handleDown },
60
+ { shortcut: { key: 'Enter' }, ignoreInputFields: false, onShortcut: handleSelect },
61
+ ]}
62
+ />
63
+
64
+ <Modal size="large" {onClose} closeOnBackdropClick>
65
+ <ModalHeader>
66
+ <div class="flex place-items-center gap-1">
67
+ <Input
68
+ bind:value={commandPaletteManager.query}
69
+ placeholder={t('search_placeholder', translations)}
70
+ leadingIcon={mdiMagnify}
71
+ tabindex={1}
72
+ />
73
+ <div>
74
+ <CloseButton onclick={() => commandPaletteManager.close()} class="md:hidden" />
75
+ </div>
76
+ </div>
77
+ </ModalHeader>
78
+ <ModalBody>
79
+ <Stack gap={2}>
80
+ {#if commandPaletteManager.query}
81
+ {#if commandPaletteManager.results.length === 0}
82
+ <Text>{t('search_no_results', translations)}</Text>
83
+ {/if}
84
+ {:else if commandPaletteManager.recentItems.length > 0}
85
+ <Text>{t('search_recently_used', translations)}</Text>
86
+ {:else}
87
+ <Text>{t('command_palette_prompt_default', translations)}</Text>
88
+ {/if}
89
+
90
+ {#if commandPaletteManager.results.length > 0}
91
+ <div class="flex flex-col">
92
+ {#each commandPaletteManager.results as item, i (i)}
93
+ <CommandPaletteItem
94
+ {item}
95
+ selected={commandPaletteManager.selectedIndex === i}
96
+ onRemove={commandPaletteManager.query ? undefined : () => commandPaletteManager.remove(i)}
97
+ onSelect={() => commandPaletteManager.select(i)}
98
+ />
99
+ {/each}
100
+ </div>
101
+ {/if}
102
+ </Stack>
103
+ </ModalBody>
104
+ <ModalFooter>
105
+ <div class="flex w-full justify-around">
106
+ <div class="flex gap-4">
107
+ <div class="flex place-items-center gap-1">
108
+ <span class="rounded bg-gray-300 p-1 dark:bg-gray-500">
109
+ <Icon icon={mdiKeyboardReturn} size="1rem" />
110
+ </span>
111
+ <Text size="small">to select</Text>
112
+ </div>
113
+
114
+ <div class="flex place-items-center gap-1">
115
+ <span class="flex gap-1 rounded bg-gray-300 p-1 dark:bg-gray-500">
116
+ <Icon icon={mdiArrowUp} size="1rem" />
117
+ <Icon icon={mdiArrowDown} size="1rem" />
118
+ </span>
119
+ <Text size="small">to navigate</Text>
120
+ </div>
121
+
122
+ <div class="flex place-items-center gap-1">
123
+ <span class="rounded bg-gray-300 p-1 dark:bg-gray-500">
124
+ <Icon icon={mdiKeyboardEsc} size="1rem" />
125
+ </span>
126
+ <Text size="small">to close</Text>
127
+ </div>
128
+ </div>
129
+ </div>
130
+ </ModalFooter>
131
+ </Modal>
@@ -0,0 +1,8 @@
1
+ import { type CommandPaletteTranslations } from '../services/command-palette-manager.svelte.js';
2
+ type Props = {
3
+ onClose: () => void;
4
+ translations?: CommandPaletteTranslations;
5
+ };
6
+ declare const CommandPaletteModal: import("svelte").Component<Props, {}, "">;
7
+ type CommandPaletteModal = ReturnType<typeof CommandPaletteModal>;
8
+ export default CommandPaletteModal;
@@ -5,6 +5,7 @@
5
5
  import IconButton from '../components/IconButton/IconButton.svelte';
6
6
  import Input from '../components/Input/Input.svelte';
7
7
  import Label from '../components/Label/Label.svelte';
8
+ import { zIndex } from '../constants.js';
8
9
  import type { SelectCommonProps, SelectItem } from '../types.js';
9
10
  import { cleanClass, generateId } from '../utilities/internal.js';
10
11
  import { mdiArrowDown, mdiArrowUp, mdiCheck, mdiUnfoldMoreHorizontal } from '@mdi/js';
@@ -115,7 +116,7 @@
115
116
  <Select.Portal>
116
117
  <Select.Content
117
118
  bind:ref={contentRef}
118
- class="bg-light text-dark max-h-96 rounded-xl border py-3 outline-none select-none"
119
+ class="bg-light text-dark max-h-96 rounded-xl border py-3 outline-none select-none {zIndex.SelectDropdown}"
119
120
  sideOffset={10}
120
121
  >
121
122
  <Select.ScrollUpButton class="flex w-full items-center justify-center">
@@ -1,3 +1,5 @@
1
+ import { type Shortcut } from '../actions/shortcut.js';
2
+ import type { MaybeArray, TranslationProps } from '../types.js';
1
3
  export type CommandItem = {
2
4
  icon: string;
3
5
  iconClass: string;
@@ -5,32 +7,54 @@ export type CommandItem = {
5
7
  title: string;
6
8
  description?: string;
7
9
  text: string;
10
+ shortcuts?: MaybeArray<Shortcut>;
11
+ shortcutOptions?: {
12
+ ignoreInputFields?: boolean;
13
+ preventDefault?: boolean;
14
+ };
8
15
  } & ({
9
16
  href: string;
10
17
  } | {
11
- action: () => void;
18
+ action: () => void | Promise<void>;
12
19
  });
20
+ export type CommandPaletteTranslations = TranslationProps<'search_placeholder' | 'search_no_results' | 'search_recently_used' | 'command_palette_prompt_default'>;
13
21
  export declare const asText: (...items: unknown[]) => string;
14
22
  declare class CommandPaletteManager {
15
- isEnabled: boolean;
16
- isOpen: boolean;
23
+ #private;
17
24
  query: string;
18
25
  selectedIndex: number;
19
- private normalizedQuery;
20
- items: CommandItem[];
21
- filteredItems: CommandItem[];
22
- recentItems: CommandItem[];
23
- results: CommandItem[];
26
+ items: (CommandItem & {
27
+ id: string;
28
+ })[];
29
+ filteredItems: (CommandItem & {
30
+ id: string;
31
+ })[];
32
+ recentItems: (CommandItem & {
33
+ id: string;
34
+ })[];
35
+ results: (CommandItem & {
36
+ id: string;
37
+ })[];
38
+ get isEnabled(): boolean;
24
39
  enable(): void;
25
- open(): Promise<void>;
26
- close(): void;
40
+ setTranslations(translations?: CommandPaletteTranslations): void;
41
+ pushContextLayer(): (() => void) | undefined;
42
+ popContextLayer(): void;
43
+ open(): void;
44
+ close(): Promise<void> | undefined;
27
45
  select(selectedIndex?: number): Promise<void>;
28
46
  remove(index: number): void;
29
47
  up(): void;
30
48
  down(): void;
31
49
  reset(): void;
32
- addCommands(itemOrItems: CommandItem | CommandItem[]): void;
33
- removeCommands(itemOrItems: CommandItem | CommandItem[]): void;
50
+ addCommands(itemOrItems: MaybeArray<CommandItem & {
51
+ id?: string;
52
+ }>, options?: {
53
+ global?: boolean;
54
+ }): () => void;
55
+ removeCommands(itemOrItems: MaybeArray<{
56
+ id: string;
57
+ }>): void;
34
58
  }
35
59
  export declare const commandPaletteManager: CommandPaletteManager;
36
60
  export {};
@@ -1,4 +1,8 @@
1
1
  import { goto } from '$app/navigation';
2
+ import { matchesShortcut, shortcuts, shouldIgnoreEvent } from '../actions/shortcut.js';
3
+ import CommandPaletteModal from '../internal/CommandPaletteModal.svelte';
4
+ import { generateId } from '../utilities/internal.js';
5
+ import { modalManager } from './modal-manager.svelte.js';
2
6
  export const asText = (...items) => {
3
7
  return items
4
8
  .filter((item) => item !== undefined && item !== null)
@@ -6,9 +10,6 @@ export const asText = (...items) => {
6
10
  .join('|')
7
11
  .toLowerCase();
8
12
  };
9
- const isEqual = (a, b) => {
10
- return a.title === b.title && a.type === b.type;
11
- };
12
13
  const isMatch = (item, query) => {
13
14
  if (!query) {
14
15
  return true;
@@ -16,31 +17,110 @@ const isMatch = (item, query) => {
16
17
  return item.text.includes(query);
17
18
  };
18
19
  class CommandPaletteManager {
19
- isEnabled = $state(false);
20
- isOpen = $state(false);
21
20
  query = $state('');
22
21
  selectedIndex = $state(0);
23
- normalizedQuery = $derived(this.query.toLowerCase());
24
- items = [];
25
- filteredItems = $derived(this.items.filter((item) => isMatch(item, this.normalizedQuery)).slice(0, 100));
26
- recentItems = $state([]);
22
+ #isEnabled = $state(false);
23
+ #normalizedQuery = $derived(this.query.toLowerCase());
24
+ #modal;
25
+ #translations = {};
26
+ #isOpen = false;
27
+ #globalLayer = $state({ items: [], recentItems: [] });
28
+ #layers = $state([{ items: [], recentItems: [] }]);
29
+ items = $derived([...this.#globalLayer.items, ...this.#layers.at(-1).items]);
30
+ filteredItems = $derived(this.items.filter((item) => isMatch(item, this.#normalizedQuery)).slice(0, 100));
31
+ recentItems = $derived([...this.#globalLayer.recentItems, ...this.#layers.at(-1).recentItems]);
27
32
  results = $derived(this.query ? this.filteredItems : this.recentItems);
33
+ get isEnabled() {
34
+ return this.#isEnabled;
35
+ }
28
36
  enable() {
29
- this.isEnabled = true;
37
+ if (this.#isEnabled) {
38
+ return;
39
+ }
40
+ this.#isEnabled = true;
41
+ if (globalThis.window && document.body) {
42
+ shortcuts(document.body, [
43
+ { shortcut: { key: 'k', meta: true }, onShortcut: () => this.open() },
44
+ { shortcut: { key: 'k', ctrl: true }, onShortcut: () => this.open() },
45
+ { shortcut: { key: '/' }, preventDefault: true, onShortcut: () => this.open() },
46
+ ]);
47
+ document.body.addEventListener('keydown', (event) => this.#handleKeydown(event));
48
+ }
49
+ }
50
+ async #handleKeydown(event) {
51
+ const command = this.items.find(({ shortcuts }) => {
52
+ if (!shortcuts) {
53
+ return;
54
+ }
55
+ if (shortcuts)
56
+ return Array.isArray(shortcuts)
57
+ ? shortcuts.some((shortcut) => matchesShortcut(event, shortcut))
58
+ : matchesShortcut(event, shortcuts);
59
+ });
60
+ if (!command) {
61
+ return;
62
+ }
63
+ const { ignoreInputFields = true, preventDefault = true } = command.shortcutOptions ?? {};
64
+ if (ignoreInputFields && shouldIgnoreEvent(event)) {
65
+ return;
66
+ }
67
+ if (preventDefault) {
68
+ event.preventDefault();
69
+ }
70
+ await this.#executeCommand(command);
71
+ }
72
+ async #executeCommand(command) {
73
+ if ('href' in command) {
74
+ if (!command.href.startsWith('/')) {
75
+ window.open(command.href, '_blank');
76
+ }
77
+ else {
78
+ await goto(command.href);
79
+ }
80
+ }
81
+ else {
82
+ await command.action();
83
+ }
84
+ }
85
+ setTranslations(translations = {}) {
86
+ this.#translations = translations;
30
87
  }
31
- async open() {
32
- if (!this.isEnabled || this.isOpen) {
88
+ pushContextLayer() {
89
+ if (!this.#isEnabled) {
90
+ return;
91
+ }
92
+ // we do not want the command palette to have its own context layer
93
+ if (this.#isOpen) {
94
+ return;
95
+ }
96
+ this.#layers.push({ items: [], recentItems: [] });
97
+ return () => this.popContextLayer();
98
+ }
99
+ popContextLayer() {
100
+ if (this.#layers.length > 1) {
101
+ this.#layers = this.#layers.slice(0, -1);
102
+ }
103
+ }
104
+ open() {
105
+ if (this.#modal || !this.#isEnabled) {
33
106
  return;
34
107
  }
35
108
  this.selectedIndex = 0;
36
- this.isOpen = true;
109
+ this.#isOpen = true;
110
+ const { close, onClose } = modalManager.open(CommandPaletteModal, { translations: this.#translations });
111
+ this.#modal = { close };
112
+ void onClose.then(() => this.#onClose());
37
113
  }
38
114
  close() {
39
- if (!this.isEnabled || !this.isOpen) {
115
+ if (!this.#modal) {
40
116
  return;
41
117
  }
118
+ return this.#modal.close();
119
+ }
120
+ #onClose() {
42
121
  this.query = '';
43
- this.isOpen = false;
122
+ this.#modal = undefined;
123
+ this.#isOpen = false;
44
124
  }
45
125
  async select(selectedIndex) {
46
126
  const selected = this.results[selectedIndex ?? this.selectedIndex];
@@ -48,45 +128,48 @@ class CommandPaletteManager {
48
128
  return;
49
129
  }
50
130
  // no duplicates
51
- this.recentItems = this.recentItems.filter((item) => !isEqual(item, selected));
131
+ this.recentItems = this.recentItems.filter(({ id }) => id !== selected.id);
52
132
  this.recentItems.unshift(selected);
53
133
  this.recentItems = this.recentItems.slice(0, 5);
54
- if ('href' in selected) {
55
- if (!selected.href.startsWith('/')) {
56
- window.open(selected.href, '_blank');
57
- }
58
- else {
59
- await goto(selected.href);
60
- }
61
- }
62
- else {
63
- await selected.action();
64
- }
65
- this.close();
134
+ await this.#executeCommand(selected);
135
+ await this.close();
66
136
  }
67
137
  remove(index) {
68
138
  this.recentItems.splice(index, 1);
69
139
  }
70
140
  up() {
71
- this.selectedIndex = (this.selectedIndex - 1 + this.results.length) % this.results.length;
141
+ this.selectedIndex = (this.selectedIndex - 1 + this.results.length) % (this.results.length || 1);
72
142
  }
73
143
  down() {
74
- this.selectedIndex = (this.selectedIndex + 1) % this.results.length;
144
+ this.selectedIndex = (this.selectedIndex + 1) % (this.results.length || 1);
75
145
  }
76
146
  reset() {
77
- this.items = [];
78
- this.isOpen = false;
147
+ this.#layers = [{ items: [], recentItems: [] }];
148
+ this.#globalLayer = { items: [], recentItems: [] };
79
149
  this.query = '';
80
150
  }
81
- addCommands(itemOrItems) {
151
+ addCommands(itemOrItems, options = {}) {
82
152
  const items = Array.isArray(itemOrItems) ? itemOrItems : [itemOrItems];
83
- this.items.push(...items);
153
+ const itemsWithId = items.map((item) => ({ ...item, id: item.id ?? generateId() }));
154
+ if (options.global) {
155
+ this.#globalLayer.items.push(...itemsWithId);
156
+ }
157
+ else {
158
+ this.#layers.at(-1).items.push(...itemsWithId);
159
+ }
160
+ return () => this.removeCommands(itemsWithId);
161
+ }
162
+ #removeCommands(layer, ids) {
163
+ return {
164
+ items: layer.items.filter(({ id }) => !ids[id]),
165
+ recentItems: layer.recentItems.filter(({ id }) => !ids[id]),
166
+ };
84
167
  }
85
168
  removeCommands(itemOrItems) {
86
169
  const items = Array.isArray(itemOrItems) ? itemOrItems : [itemOrItems];
87
- for (const remoteItem of items) {
88
- this.items = this.items.filter((item) => !isEqual(item, remoteItem));
89
- }
170
+ const ids = items.reduce((acc, { id }) => ({ ...acc, [id]: true }), {});
171
+ this.#layers = this.#layers.map((layer) => this.#removeCommands(layer, ids));
172
+ this.#globalLayer = this.#removeCommands(this.#globalLayer, ids);
90
173
  }
91
174
  }
92
175
  export const commandPaletteManager = new CommandPaletteManager();
@@ -0,0 +1,6 @@
1
+ import type { ContextMenuBaseProps } from '../types.js';
2
+ declare class MenuManager {
3
+ show(event: MouseEvent, props: ContextMenuBaseProps): Promise<void>;
4
+ }
5
+ export declare const menuManager: MenuManager;
6
+ export {};
@@ -0,0 +1,11 @@
1
+ import ContextMenu from '../components/ContextMenu/ContextMenu.svelte';
2
+ import { modalManager } from './modal-manager.svelte.js';
3
+ class MenuManager {
4
+ show(event, props) {
5
+ return modalManager.show(ContextMenu, {
6
+ ...props,
7
+ anchor: event.currentTarget,
8
+ });
9
+ }
10
+ }
11
+ export const menuManager = new MenuManager();
@@ -1,20 +1,20 @@
1
1
  import { type Component, type ComponentProps } from 'svelte';
2
2
  import ConfirmModal from '../components/ConfirmModal/ConfirmModal.svelte';
3
3
  type OnCloseData<T> = T extends {
4
- onClose: (data?: infer R) => void;
5
- } ? R | undefined : T extends {
6
4
  onClose: (data: infer R) => void;
7
- } ? R : never;
5
+ } ? unknown extends R ? void : R : T extends {
6
+ onClose: (data?: infer R) => void;
7
+ } ? R | undefined : never;
8
8
  type ExtendsEmptyObject<T> = keyof T extends never ? never : T;
9
- type StripValueIfOptional<T> = T extends undefined ? undefined : T;
9
+ type StripParamIfOptional<T> = T extends void ? [] : [T];
10
10
  type OptionalParamIfEmpty<T> = ExtendsEmptyObject<T> extends never ? [] | [Record<string, never> | undefined] : [T];
11
11
  declare class ModalManager {
12
12
  #private;
13
13
  get openCount(): number;
14
- show<T extends object>(Component: Component<T>, ...props: OptionalParamIfEmpty<Omit<T, 'onClose'>>): Promise<StripValueIfOptional<OnCloseData<T>>>;
14
+ show<T extends object>(Component: Component<T>, ...props: OptionalParamIfEmpty<Omit<T, 'onClose'>>): Promise<OnCloseData<T>>;
15
15
  open<T extends object, K = OnCloseData<T>>(Component: Component<T>, ...props: OptionalParamIfEmpty<Omit<T, 'onClose'>>): {
16
- onClose: Promise<StripValueIfOptional<K>>;
17
- close: (args_0: StripValueIfOptional<K>) => Promise<void>;
16
+ onClose: Promise<K>;
17
+ close: (...args: StripParamIfOptional<K>) => Promise<void>;
18
18
  };
19
19
  showDialog(options: Omit<ComponentProps<typeof ConfirmModal>, 'onClose'>): Promise<boolean>;
20
20
  }
@@ -16,7 +16,7 @@ class ModalManager {
16
16
  await unmount(modal);
17
17
  this.#openCount--;
18
18
  // make sure bits-ui clean up finishes before resolving
19
- setTimeout(() => resolve(args?.[0]), 10);
19
+ setTimeout(() => resolve(args[0]), 10);
20
20
  };
21
21
  modal = mount(Component, {
22
22
  target: document.body,
@@ -29,7 +29,7 @@ class ModalManager {
29
29
  });
30
30
  return {
31
31
  onClose: deferred,
32
- close: (...args) => onClose(args[0]),
32
+ close: (...args) => onClose(...args),
33
33
  };
34
34
  }
35
35
  showDialog(options) {
package/dist/types.d.ts CHANGED
@@ -9,6 +9,7 @@ export type HeadingColor = TextColor;
9
9
  export type Size = 'tiny' | 'small' | 'medium' | 'large' | 'giant';
10
10
  export type ModalSize = Size | 'full';
11
11
  export type ContainerSize = ModalSize;
12
+ export type MenuSize = ModalSize;
12
13
  export type HeadingSize = Size | 'title';
13
14
  export type HeadingTag = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p';
14
15
  export type Shape = 'rectangle' | 'semi-round' | 'round';
@@ -25,6 +26,7 @@ export type TranslationProps<T extends keyof Translations> = {
25
26
  export type IconLike = string | {
26
27
  path: string;
27
28
  };
29
+ export type MaybeArray<T> = T | T[];
28
30
  export type IconProps = {
29
31
  icon: IconLike;
30
32
  title?: string;
@@ -202,4 +204,31 @@ export type ToastButton = {
202
204
  variant?: Variants;
203
205
  onClick: () => void;
204
206
  };
207
+ export type MenuSelectHandler = (context: {
208
+ event: Event;
209
+ item: MenuItem;
210
+ }) => void;
211
+ export type MenuItem = {
212
+ title: string;
213
+ icon: IconLike;
214
+ color?: Color;
215
+ onSelect?: MenuSelectHandler;
216
+ };
217
+ export declare enum MenuItemType {
218
+ Divider = "divider"
219
+ }
220
+ export type MenuItems = Array<MenuItem | MenuItemType>;
221
+ export type MenuProps = {
222
+ items: MenuItems;
223
+ bottomItems?: MenuItem[];
224
+ size?: MenuSize;
225
+ } & HTMLAttributes<HTMLDivElement>;
226
+ export type ContextMenuPosition = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
227
+ export type ContextMenuBaseProps = MenuProps & {
228
+ position?: ContextMenuPosition;
229
+ };
230
+ export type ContextMenuProps = ContextMenuBaseProps & {
231
+ onClose: () => void;
232
+ anchor: HTMLElement;
233
+ };
205
234
  export {};
package/dist/types.js CHANGED
@@ -3,3 +3,7 @@ export var Theme;
3
3
  Theme["Light"] = "light";
4
4
  Theme["Dark"] = "dark";
5
5
  })(Theme || (Theme = {}));
6
+ export var MenuItemType;
7
+ (function (MenuItemType) {
8
+ MenuItemType["Divider"] = "divider";
9
+ })(MenuItemType || (MenuItemType = {}));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@immich/ui",
3
- "version": "0.40.3",
3
+ "version": "0.41.0",
4
4
  "license": "GNU Affero General Public License version 3",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,145 +0,0 @@
1
- <script lang="ts">
2
- import { shortcuts } from '../../actions/shortcut.js';
3
- import CloseButton from '../CloseButton/CloseButton.svelte';
4
- import CommandPaletteItem from './CommandPaletteItem.svelte';
5
- import Icon from '../Icon/Icon.svelte';
6
- import Input from '../Input/Input.svelte';
7
- import Modal from '../Modal/Modal.svelte';
8
- import ModalBody from '../Modal/ModalBody.svelte';
9
- import ModalFooter from '../Modal/ModalFooter.svelte';
10
- import ModalHeader from '../Modal/ModalHeader.svelte';
11
- import Stack from '../Stack/Stack.svelte';
12
- import Text from '../Text/Text.svelte';
13
- import { commandPaletteManager } from '../../services/command-palette-manager.svelte';
14
- import { t } from '../../services/translation.svelte.js';
15
- import type { TranslationProps } from '../../types.js';
16
- import { mdiArrowDown, mdiArrowUp, mdiKeyboardEsc, mdiKeyboardReturn, mdiMagnify } from '@mdi/js';
17
-
18
- type Props = {
19
- translations?: TranslationProps<
20
- 'search_placeholder' | 'search_no_results' | 'search_recently_used' | 'command_palette_prompt_default'
21
- >;
22
- };
23
-
24
- let { translations }: Props = $props();
25
-
26
- let inputElement = $state<HTMLInputElement | null>(null);
27
-
28
- const handleOpen = () => commandPaletteManager.open();
29
- const handleClose = () => commandPaletteManager.close();
30
- const handleUp = (event: KeyboardEvent) => handleNavigate(event, 'up');
31
- const handleDown = (event: KeyboardEvent) => handleNavigate(event, 'down');
32
- const handleSelect = (event: KeyboardEvent) => handleNavigate(event, 'select');
33
- const handleNavigate = async (event: KeyboardEvent, direction: 'up' | 'down' | 'select') => {
34
- if (!commandPaletteManager.isOpen) {
35
- return;
36
- }
37
-
38
- event.preventDefault();
39
-
40
- switch (direction) {
41
- case 'up': {
42
- commandPaletteManager.up();
43
- break;
44
- }
45
-
46
- case 'down': {
47
- commandPaletteManager.down();
48
- break;
49
- }
50
-
51
- case 'select': {
52
- await commandPaletteManager.select();
53
- break;
54
- }
55
- }
56
- };
57
- </script>
58
-
59
- <svelte:window
60
- use:shortcuts={[
61
- { shortcut: { key: 'k', meta: true }, onShortcut: handleOpen },
62
- { shortcut: { key: 'k', ctrl: true }, onShortcut: handleOpen },
63
- { shortcut: { key: '/' }, preventDefault: true, onShortcut: handleOpen },
64
- { shortcut: { key: 'ArrowUp' }, preventDefault: false, ignoreInputFields: false, onShortcut: handleUp },
65
- { shortcut: { key: 'ArrowDown' }, preventDefault: false, ignoreInputFields: false, onShortcut: handleDown },
66
- { shortcut: { key: 'k', ctrl: true }, ignoreInputFields: false, onShortcut: handleUp },
67
- { shortcut: { key: 'k', meta: true }, ignoreInputFields: false, onShortcut: handleUp },
68
- { shortcut: { key: 'j', ctrl: true }, ignoreInputFields: false, onShortcut: handleDown },
69
- { shortcut: { key: 'j', meta: true }, ignoreInputFields: false, onShortcut: handleDown },
70
- { shortcut: { key: 'Enter' }, ignoreInputFields: false, onShortcut: handleSelect },
71
- { shortcut: { key: 'Escape' }, onShortcut: handleClose },
72
- ]}
73
- />
74
-
75
- {#if commandPaletteManager.isOpen}
76
- <Modal size="large" onClose={handleClose} closeOnBackdropClick>
77
- <ModalHeader>
78
- <div class="flex place-items-center gap-1">
79
- <Input
80
- bind:ref={inputElement}
81
- bind:value={commandPaletteManager.query}
82
- placeholder={t('search_placeholder', translations)}
83
- leadingIcon={mdiMagnify}
84
- tabindex={1}
85
- />
86
- <div>
87
- <CloseButton onclick={() => commandPaletteManager.close()} class="md:hidden" />
88
- </div>
89
- </div>
90
- </ModalHeader>
91
- <ModalBody>
92
- <Stack gap={2}>
93
- {#if commandPaletteManager.query}
94
- {#if commandPaletteManager.results.length === 0}
95
- <Text>{t('search_no_results', translations)}</Text>
96
- {/if}
97
- {:else if commandPaletteManager.recentItems.length > 0}
98
- <Text>{t('search_recently_used', translations)}</Text>
99
- {:else}
100
- <Text>{t('command_palette_prompt_default', translations)}</Text>
101
- {/if}
102
-
103
- {#if commandPaletteManager.results.length > 0}
104
- <div class="flex flex-col">
105
- {#each commandPaletteManager.results as item, i (i)}
106
- <CommandPaletteItem
107
- {item}
108
- selected={commandPaletteManager.selectedIndex === i}
109
- onRemove={commandPaletteManager.query ? undefined : () => commandPaletteManager.remove(i)}
110
- onSelect={() => commandPaletteManager.select(i)}
111
- />
112
- {/each}
113
- </div>
114
- {/if}
115
- </Stack>
116
- </ModalBody>
117
- <ModalFooter>
118
- <div class="flex w-full justify-around">
119
- <div class="flex gap-4">
120
- <div class="flex place-items-center gap-1">
121
- <span class="rounded bg-gray-300 p-1 dark:bg-gray-500">
122
- <Icon icon={mdiKeyboardReturn} size="1rem" />
123
- </span>
124
- <Text size="small">to select</Text>
125
- </div>
126
-
127
- <div class="flex place-items-center gap-1">
128
- <span class="flex gap-1 rounded bg-gray-300 p-1 dark:bg-gray-500">
129
- <Icon icon={mdiArrowUp} size="1rem" />
130
- <Icon icon={mdiArrowDown} size="1rem" />
131
- </span>
132
- <Text size="small">to navigate</Text>
133
- </div>
134
-
135
- <div class="flex place-items-center gap-1">
136
- <span class="rounded bg-gray-300 p-1 dark:bg-gray-500">
137
- <Icon icon={mdiKeyboardEsc} size="1rem" />
138
- </span>
139
- <Text size="small">to close</Text>
140
- </div>
141
- </div>
142
- </div>
143
- </ModalFooter>
144
- </Modal>
145
- {/if}
@@ -1,7 +0,0 @@
1
- import type { TranslationProps } from '../../types.js';
2
- type Props = {
3
- translations?: TranslationProps<'search_placeholder' | 'search_no_results' | 'search_recently_used' | 'command_palette_prompt_default'>;
4
- };
5
- declare const CommandPalette: import("svelte").Component<Props, {}, "">;
6
- type CommandPalette = ReturnType<typeof CommandPalette>;
7
- export default CommandPalette;