@immich/ui 0.49.2 → 0.50.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.
@@ -18,7 +18,7 @@
18
18
  <Scrollable
19
19
  class={cleanClass(
20
20
  'bg-light text-dark absolute shrink-0 border-e shadow-lg transition-all duration-200 md:relative',
21
- open ? `${zIndex.AppShellSidebar} w-[min(100vw,16rem)]` : 'w-[0px] border-e-0',
21
+ open ? `${zIndex.AppShellSidebar} w-[min(100vw,16rem)]` : 'w-0 border-e-0',
22
22
  className,
23
23
  )}
24
24
  >
@@ -19,7 +19,7 @@
19
19
  </span>
20
20
  {/snippet}
21
21
 
22
- <nav class={cleanClass('flex items-center gap-1', className)} {...props}>
22
+ <nav class={cleanClass('flex flex-wrap items-center gap-1', className)} {...props}>
23
23
  {#each items as item, index (index)}
24
24
  {#if index > 0}
25
25
  {#if typeof separator === 'object' && 'text' in separator}
@@ -9,7 +9,6 @@
9
9
  import { cubicOut } from 'svelte/easing';
10
10
  import type { HTMLAttributes } from 'svelte/elements';
11
11
  import { slide } from 'svelte/transition';
12
- import { twMerge } from 'tailwind-merge';
13
12
  import { tv } from 'tailwind-variants';
14
13
 
15
14
  type Props = HTMLAttributes<HTMLDivElement> & {
@@ -87,14 +86,12 @@
87
86
  const headerPadding = $derived(headerBorder || !expanded);
88
87
 
89
88
  const headerContainerClasses = $derived(
90
- twMerge(
91
- cleanClass(
92
- headerContainerStyles({
93
- padding: headerPadding,
94
- border: headerBorder,
95
- }),
96
- headerChild?.class,
97
- ),
89
+ cleanClass(
90
+ headerContainerStyles({
91
+ padding: headerPadding,
92
+ border: headerBorder,
93
+ }),
94
+ headerChild?.class,
98
95
  ),
99
96
  );
100
97
  </script>
@@ -137,14 +134,14 @@
137
134
  {#if bodyChild && expanded}
138
135
  <div
139
136
  transition:slide={{ duration: expandable ? 200 : 0, easing: cubicOut }}
140
- class={twMerge(cleanClass('immich-scrollbar h-full w-full overflow-auto p-4', bodyChild?.class))}
137
+ class={cleanClass('immich-scrollbar h-full w-full overflow-auto p-4', bodyChild?.class)}
141
138
  >
142
139
  {@render bodyChild?.snippet()}
143
140
  </div>
144
141
  {/if}
145
142
 
146
143
  {#if footerChild}
147
- <div class={twMerge(cleanClass('flex items-center border-t p-4', footerChild.class))}>
144
+ <div class={cleanClass('flex items-center border-t p-4', footerChild.class)}>
148
145
  {@render footerChild.snippet()}
149
146
  </div>
150
147
  {/if}
@@ -3,7 +3,8 @@
3
3
  import Text from '../Text/Text.svelte';
4
4
  import { zIndex } from '../../constants.js';
5
5
  import { styleVariants } from '../../styles.js';
6
- import { MenuItemType, type ContextMenuProps, type ActionItem } from '../../types.js';
6
+ import { type ActionItem, type ContextMenuProps, type MenuItems } from '../../types.js';
7
+ import { isMenuItemType } from '../../utilities/common.js';
7
8
  import { cleanClass, isEnabled } from '../../utilities/internal.js';
8
9
  import { DropdownMenu } from 'bits-ui';
9
10
  import { fly } from 'svelte/transition';
@@ -20,10 +21,6 @@
20
21
  ...restProps
21
22
  }: ContextMenuProps = $props();
22
23
 
23
- const isDivider = (item: ActionItem | MenuItemType): item is MenuItemType => {
24
- return item === MenuItemType.Divider;
25
- };
26
-
27
24
  const itemStyles = tv({
28
25
  base: 'hover:bg-light-200 flex w-full items-center gap-1 rounded-lg p-1 text-start hover:cursor-pointer',
29
26
  variants: {
@@ -83,10 +80,35 @@
83
80
  }
84
81
  };
85
82
 
86
- const filteredItems = $derived(
87
- items.filter((item) => item !== undefined).filter((item) => isDivider(item) || isEnabled(item)),
88
- );
89
- const filteredBottomItems = $derived(bottomItems?.filter((item) => item !== undefined).filter(isEnabled));
83
+ const getFilteredItems = (items?: MenuItems) => {
84
+ if (!items) {
85
+ return [];
86
+ }
87
+
88
+ const results = [];
89
+ for (const item of items) {
90
+ if (item && (isMenuItemType(item) || isEnabled(item))) {
91
+ results.push(item);
92
+ continue;
93
+ }
94
+ }
95
+
96
+ // remove trailing dividers
97
+ for (let i = results.length - 1; i >= 0; i--) {
98
+ const item = results[i];
99
+ if (isMenuItemType(item)) {
100
+ results.pop();
101
+ continue;
102
+ }
103
+
104
+ break;
105
+ }
106
+
107
+ return results;
108
+ };
109
+
110
+ const filteredItems = $derived(getFilteredItems(items));
111
+ const filteredBottomItems = $derived(getFilteredItems(bottomItems) as ActionItem[]);
90
112
 
91
113
  const alignOffset = $derived(target.clientWidth / 2);
92
114
  const sideOffset = $derived(-target.clientHeight / 2);
@@ -100,8 +122,8 @@
100
122
  {#if open}
101
123
  <div {...wrapperProps} class={zIndex.ContextMenu}>
102
124
  <div {...props} {...restProps} class={cleanClass(wrapperStyles({ size }), className)} transition:fly>
103
- {#each filteredItems as item, i (isDivider(item) ? i : item.title)}
104
- {#if isDivider(item)}
125
+ {#each filteredItems as item, i (isMenuItemType(item) ? i : item.title)}
126
+ {#if isMenuItemType(item)}
105
127
  <DropdownMenu.Separator class="dark:border-light-300 my-0.5 border-t" />
106
128
  {:else}
107
129
  <DropdownMenu.Item
@@ -118,7 +140,7 @@
118
140
  {/if}
119
141
  {/each}
120
142
 
121
- {#if filteredBottomItems}
143
+ {#if filteredBottomItems.length > 0}
122
144
  <DropdownMenu.Separator class="dark:border-light-300 my-0.5 border-t" />
123
145
  <div class="flex gap-1 px-1">
124
146
  {#each filteredBottomItems as item (item.title)}
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import IconButton from '../IconButton/IconButton.svelte';
3
3
  import { menuManager } from '../../services/menu-manager.svelte.js';
4
+ import { t } from '../../services/translation.svelte.js';
4
5
  import type { ContextMenuButtonProps } from '../../types.js';
5
6
  import { mdiDotsVertical } from '@mdi/js';
6
7
 
@@ -10,8 +11,10 @@
10
11
  icon = mdiDotsVertical,
11
12
  variant = 'ghost',
12
13
  shape = 'round',
14
+ 'aria-label': ariaLabel,
13
15
  items,
14
16
  bottomItems,
17
+ translations,
15
18
  ...rest
16
19
  }: ContextMenuButtonProps = $props();
17
20
 
@@ -20,4 +23,12 @@
20
23
  };
21
24
  </script>
22
25
 
23
- <IconButton {icon} {color} {shape} {variant} {...rest} {onclick} />
26
+ <IconButton
27
+ {icon}
28
+ {color}
29
+ {shape}
30
+ {variant}
31
+ aria-label={ariaLabel ?? t('open_menu', translations)}
32
+ {...rest}
33
+ {onclick}
34
+ />
@@ -2,16 +2,22 @@
2
2
  import Toast from './Toast.svelte';
3
3
  import { zIndex } from '../../constants.js';
4
4
  import { isCustomToast } from '../../services/toast-manager.svelte.js';
5
- import type { ToastId, ToastItem } from '../../types.js';
5
+ import type { ToastPanelProps } from '../../types.js';
6
+ import { cleanClass } from '../../utilities/internal.js';
6
7
 
7
- type Props = {
8
- items: Array<ToastItem & ToastId>;
9
- };
8
+ const { items, class: className, ...props }: ToastPanelProps = $props();
10
9
 
11
- const { items }: Props = $props();
10
+ const isEmpty = $derived(items.length === 0);
12
11
  </script>
13
12
 
14
- <div class="absolute top-0 right-0 flex flex-col items-end justify-end gap-2 p-4 {zIndex.ToastPanel}">
13
+ <div
14
+ class={cleanClass(
15
+ isEmpty ? 'hidden' : 'absolute top-0 right-0 flex flex-col items-end justify-end gap-2 p-4',
16
+ zIndex.ToastPanel,
17
+ className,
18
+ )}
19
+ {...props}
20
+ >
15
21
  {#each items as item (item.id)}
16
22
  {#if isCustomToast(item)}
17
23
  <item.component {...item.props} />
@@ -1,7 +1,4 @@
1
- import type { ToastId, ToastItem } from '../../types.js';
2
- type Props = {
3
- items: Array<ToastItem & ToastId>;
4
- };
5
- declare const ToastPanel: import("svelte").Component<Props, {}, "">;
1
+ import type { ToastPanelProps } from '../../types.js';
2
+ declare const ToastPanel: import("svelte").Component<ToastPanelProps, {}, "">;
6
3
  type ToastPanel = ReturnType<typeof ToastPanel>;
7
4
  export default ToastPanel;
@@ -7,7 +7,6 @@
7
7
  import { cleanClass } from '../utilities/internal.js';
8
8
  import { Button as ButtonPrimitive } from 'bits-ui';
9
9
  import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
10
- import { twMerge } from 'tailwind-merge';
11
10
  import { tv } from 'tailwind-variants';
12
11
 
13
12
  type InternalButtonProps = ButtonProps & {
@@ -99,22 +98,20 @@
99
98
 
100
99
  const classList = $derived(
101
100
  cleanClass(
102
- twMerge(
103
- buttonVariants({
104
- shape,
105
- fullWidth,
106
- textPadding: icon ? undefined : size,
107
- textSize: size,
108
- iconSize: icon ? size : undefined,
109
- disabled,
110
- roundedSize: shape === 'semi-round' ? size : undefined,
111
- filledColor: variant === 'filled' ? color : undefined,
112
- filledColorHover: variant === 'filled' ? color : undefined,
113
- outlineColor: variant === 'outline' ? color : undefined,
114
- ghostColor: variant === 'ghost' ? color : undefined,
115
- }),
116
- className,
117
- ),
101
+ buttonVariants({
102
+ shape,
103
+ fullWidth,
104
+ textPadding: icon ? undefined : size,
105
+ textSize: size,
106
+ iconSize: icon ? size : undefined,
107
+ disabled,
108
+ roundedSize: shape === 'semi-round' ? size : undefined,
109
+ filledColor: variant === 'filled' ? color : undefined,
110
+ filledColorHover: variant === 'filled' ? color : undefined,
111
+ outlineColor: variant === 'outline' ? color : undefined,
112
+ ghostColor: variant === 'ghost' ? color : undefined,
113
+ }),
114
+ className,
118
115
  ),
119
116
  );
120
117
 
@@ -4,16 +4,76 @@ export declare const asText: (...items: unknown[]) => string;
4
4
  declare class CommandPaletteManager {
5
5
  #private;
6
6
  selectedIndex: number;
7
- items: (ActionItem & {
7
+ items: ({
8
+ title: string;
9
+ description?: string;
10
+ type?: string;
11
+ searchText?: string;
12
+ icon: import("../types.js").IconLike;
13
+ iconClass?: string;
14
+ color?: import("../types.js").Color;
15
+ onAction: import("../types.js").ActionItemHandler;
16
+ shortcuts?: MaybeArray<import("../actions/shortcut.js").Shortcut>;
17
+ shortcutOptions?: {
18
+ ignoreInputFields?: boolean;
19
+ preventDefault?: boolean;
20
+ };
21
+ isGlobal?: boolean;
22
+ } & import("../types.js").IfLike & {
8
23
  id: string;
9
24
  })[];
10
- filteredItems: (ActionItem & {
25
+ filteredItems: ({
26
+ title: string;
27
+ description?: string;
28
+ type?: string;
29
+ searchText?: string;
30
+ icon: import("../types.js").IconLike;
31
+ iconClass?: string;
32
+ color?: import("../types.js").Color;
33
+ onAction: import("../types.js").ActionItemHandler;
34
+ shortcuts?: MaybeArray<import("../actions/shortcut.js").Shortcut>;
35
+ shortcutOptions?: {
36
+ ignoreInputFields?: boolean;
37
+ preventDefault?: boolean;
38
+ };
39
+ isGlobal?: boolean;
40
+ } & import("../types.js").IfLike & {
11
41
  id: string;
12
42
  })[];
13
- recentItems: (ActionItem & {
43
+ recentItems: ({
44
+ title: string;
45
+ description?: string;
46
+ type?: string;
47
+ searchText?: string;
48
+ icon: import("../types.js").IconLike;
49
+ iconClass?: string;
50
+ color?: import("../types.js").Color;
51
+ onAction: import("../types.js").ActionItemHandler;
52
+ shortcuts?: MaybeArray<import("../actions/shortcut.js").Shortcut>;
53
+ shortcutOptions?: {
54
+ ignoreInputFields?: boolean;
55
+ preventDefault?: boolean;
56
+ };
57
+ isGlobal?: boolean;
58
+ } & import("../types.js").IfLike & {
14
59
  id: string;
15
60
  })[];
16
- results: (ActionItem & {
61
+ results: ({
62
+ title: string;
63
+ description?: string;
64
+ type?: string;
65
+ searchText?: string;
66
+ icon: import("../types.js").IconLike;
67
+ iconClass?: string;
68
+ color?: import("../types.js").Color;
69
+ onAction: import("../types.js").ActionItemHandler;
70
+ shortcuts?: MaybeArray<import("../actions/shortcut.js").Shortcut>;
71
+ shortcutOptions?: {
72
+ ignoreInputFields?: boolean;
73
+ preventDefault?: boolean;
74
+ };
75
+ isGlobal?: boolean;
76
+ } & import("../types.js").IfLike & {
17
77
  id: string;
18
78
  })[];
19
79
  get isEnabled(): boolean;
@@ -1,16 +1,17 @@
1
- import type { ToastCustom, ToastItem, ToastOptions, ToastShow } from '../types.js';
1
+ import type { ToastCustom, ToastItem, ToastOptions, ToastPanelProps, ToastShow } from '../types.js';
2
2
  export declare const isCustomToast: (item: ToastItem) => item is ToastCustom;
3
3
  declare class ToastManager {
4
4
  #private;
5
5
  show(item: ToastShow, options?: ToastOptions): void;
6
6
  custom(item: ToastCustom, options?: ToastOptions): void;
7
+ setOptions(options: Omit<ToastPanelProps, 'items'>): void;
7
8
  open(item: ToastItem, options?: ToastOptions): void;
9
+ mount(): Promise<void>;
8
10
  unmount(): Promise<void>;
9
11
  success(item?: string | ToastShow, options?: ToastOptions): void;
10
12
  info(item?: string | ToastShow, options?: ToastOptions): void;
11
13
  warning(item?: string | ToastShow, options?: ToastOptions): void;
12
14
  danger(item?: string | ToastShow, options?: ToastOptions): void;
13
- mount(): Promise<void>;
14
15
  private remove;
15
16
  }
16
17
  export declare const toastManager: ToastManager;
@@ -13,6 +13,9 @@ class ToastManager {
13
13
  custom(item, options) {
14
14
  return this.open(item, options);
15
15
  }
16
+ setOptions(options) {
17
+ Object.assign(this.#props, options);
18
+ }
16
19
  open(item, options) {
17
20
  const { timeout = 3000, closable = true, id = generateId() } = options || {};
18
21
  const toast = item;
@@ -32,6 +35,14 @@ class ToastManager {
32
35
  setTimeout(() => this.remove(toast), timeout);
33
36
  }
34
37
  }
38
+ async mount() {
39
+ if (!this.#ref) {
40
+ this.#ref = await mount(ToastPanel, {
41
+ target: document.body,
42
+ props: this.#props,
43
+ });
44
+ }
45
+ }
35
46
  async unmount() {
36
47
  if (this.#ref) {
37
48
  await unmount(this.#ref);
@@ -49,14 +60,6 @@ class ToastManager {
49
60
  danger(item, options) {
50
61
  this.show({ title: t('toast_danger_title'), color: 'danger', ...expand(item) }, options);
51
62
  }
52
- async mount() {
53
- if (!this.#ref) {
54
- this.#ref = await mount(ToastPanel, {
55
- target: document.body,
56
- props: this.#props,
57
- });
58
- }
59
- }
60
63
  remove(target) {
61
64
  this.#props.items = this.#props.items.filter((item) => item.id !== target.id);
62
65
  }
@@ -12,6 +12,7 @@ declare const defaultTranslations: {
12
12
  show_password: string;
13
13
  hide_password: string;
14
14
  dark_theme: string;
15
+ open_menu: string;
15
16
  command_palette_prompt_default: string;
16
17
  command_palette_to_select: string;
17
18
  command_palette_to_navigate: string;
@@ -17,6 +17,8 @@ const defaultTranslations = {
17
17
  hide_password: 'Hide password',
18
18
  // theme switcher
19
19
  dark_theme: 'Toggle dark theme',
20
+ // context menu
21
+ open_menu: 'Open menu',
20
22
  // command palette
21
23
  command_palette_prompt_default: 'Quickly find pages, actions, or commands',
22
24
  command_palette_to_select: 'to select',
package/dist/types.d.ts CHANGED
@@ -1,8 +1,8 @@
1
+ import type { Shortcut } from './actions/shortcut.js';
1
2
  import type { Translations } from './services/translation.svelte.js';
2
3
  import type { DateTime } from 'luxon';
3
4
  import type { Component, Snippet } from 'svelte';
4
5
  import type { HTMLAnchorAttributes, HTMLAttributes, HTMLButtonAttributes, HTMLInputAttributes, HTMLLabelAttributes, HTMLTextareaAttributes } from 'svelte/elements';
5
- import type { Shortcut } from './actions/shortcut.js';
6
6
  export type Color = 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info';
7
7
  export type TextColor = Color | 'muted';
8
8
  export type TextVariant = 'italic';
@@ -71,9 +71,9 @@ export type CloseButtonProps = {
71
71
  export type ContextMenuButtonProps = ButtonBase & {
72
72
  icon?: IconLike;
73
73
  position?: ContextMenuPosition;
74
- 'aria-label': string;
75
74
  items: MenuItems;
76
75
  bottomItems?: Array<ActionItem | undefined>;
76
+ translations?: TranslationProps<'open_menu'>;
77
77
  } & Omit<HTMLButtonAttributes, 'color' | 'size'>;
78
78
  export type IconButtonProps = ButtonBase & {
79
79
  icon: IconLike;
@@ -181,6 +181,9 @@ export type ToastContainerProps = ToastCommonProps & {
181
181
  shape?: Shape;
182
182
  size?: ContainerSize;
183
183
  } & Omit<HTMLAttributes<HTMLElement>, 'title' | 'color' | 'size'>;
184
+ export type ToastPanelProps = {
185
+ items: Array<ToastItem & ToastId>;
186
+ } & HTMLAttributes<HTMLDivElement>;
184
187
  export type ToastProps = ToastContentProps & ToastContainerProps;
185
188
  type Closable = {
186
189
  onClose: () => void;
@@ -240,8 +243,8 @@ export type DatePickerProps = {
240
243
  export type IfLike = {
241
244
  $if?: () => boolean;
242
245
  };
243
- export type ActionItemHandler<T = never> = (item: ActionItem<T>) => void | Promise<void>;
244
- export type ActionItem<T = never> = Omit<{
246
+ export type ActionItemHandler<T extends ActionItem = ActionItem> = (item: T) => void | Promise<void>;
247
+ export type ActionItem = {
245
248
  title: string;
246
249
  description?: string;
247
250
  type?: string;
@@ -249,17 +252,14 @@ export type ActionItem<T = never> = Omit<{
249
252
  icon: IconLike;
250
253
  iconClass?: string;
251
254
  color?: Color;
252
- onAction: ActionItemHandler<T>;
253
- data: T;
255
+ onAction: ActionItemHandler;
254
256
  shortcuts?: MaybeArray<Shortcut>;
255
257
  shortcutOptions?: {
256
258
  ignoreInputFields?: boolean;
257
259
  preventDefault?: boolean;
258
260
  };
259
261
  isGlobal?: boolean;
260
- } & IfLike, [
261
- T
262
- ] extends [never] ? 'data' : ''>;
262
+ } & IfLike;
263
263
  export type BreadcrumbsProps = {
264
264
  separator?: IconLike | {
265
265
  text: string;
@@ -1,3 +1,4 @@
1
+ import { MenuItemType, type ActionItem } from '../types.js';
1
2
  import type { DateTime } from 'luxon';
2
3
  export declare const resolveUrl: (url: string, currentHostname?: string) => string;
3
4
  export declare const isExternalLink: (href: string) => boolean;
@@ -14,6 +15,7 @@ export type ArticleMetadata = {
14
15
  section?: string;
15
16
  tags?: string[];
16
17
  };
18
+ export declare const isMenuItemType: (item: ActionItem | MenuItemType) => item is MenuItemType;
17
19
  export declare const resolveMetadata: (site: Metadata, page?: Metadata, article?: ArticleMetadata) => {
18
20
  type: string;
19
21
  siteName: string;
@@ -1,4 +1,5 @@
1
1
  import { env } from '$env/dynamic/public';
2
+ import { MenuItemType } from '../types.js';
2
3
  const getImmichApp = (host) => {
3
4
  if (!host || !host.endsWith('immich.app')) {
4
5
  return false;
@@ -25,6 +26,9 @@ export const resolveUrl = (url, currentHostname) => {
25
26
  export const isExternalLink = (href) => {
26
27
  return !(href.startsWith('/') || href.startsWith('#'));
27
28
  };
29
+ export const isMenuItemType = (item) => {
30
+ return item === MenuItemType.Divider;
31
+ };
28
32
  export const resolveMetadata = (site, page, article) => {
29
33
  const title = page ? `${page.title} | ${site.title}` : site.title;
30
34
  const description = page?.description ?? site.description;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@immich/ui",
3
- "version": "0.49.2",
3
+ "version": "0.50.0",
4
4
  "license": "GNU Affero General Public License version 3",
5
5
  "repository": {
6
6
  "type": "git",