@immich/ui 0.44.0 → 0.45.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 (32) hide show
  1. package/dist/common/context.svelte.d.ts +1 -2
  2. package/dist/common/context.svelte.js +2 -2
  3. package/dist/components/AnnouncementBanner/AnnouncementBanner.svelte +6 -6
  4. package/dist/components/Badge/Badge.svelte +69 -0
  5. package/dist/components/Badge/Badge.svelte.d.ts +13 -0
  6. package/dist/components/Card/Card.svelte +6 -6
  7. package/dist/components/Checkbox/Checkbox.svelte +2 -2
  8. package/dist/components/DatePicker/DatePicker.svelte +45 -0
  9. package/dist/components/DatePicker/DatePicker.svelte.d.ts +4 -0
  10. package/dist/components/FormModal/FormModal.svelte +65 -0
  11. package/dist/components/FormModal/FormModal.svelte.d.ts +17 -0
  12. package/dist/components/Input/Input.svelte +24 -15
  13. package/dist/components/Switch/Switch.svelte +11 -11
  14. package/dist/components/Textarea/Textarea.svelte +3 -3
  15. package/dist/components/Toast/ToastContainer.svelte +12 -45
  16. package/dist/components/Toast/ToastContent.svelte +3 -11
  17. package/dist/index.d.ts +3 -0
  18. package/dist/index.js +3 -0
  19. package/dist/internal/Button.svelte +5 -5
  20. package/dist/internal/CommandPaletteModal.svelte +29 -18
  21. package/dist/internal/DatePicker.svelte +159 -0
  22. package/dist/internal/DatePicker.svelte.d.ts +15 -0
  23. package/dist/internal/Select.svelte +2 -2
  24. package/dist/services/command-palette-manager.svelte.d.ts +5 -2
  25. package/dist/services/command-palette-manager.svelte.js +42 -10
  26. package/dist/services/translation.svelte.d.ts +5 -0
  27. package/dist/services/translation.svelte.js +5 -0
  28. package/dist/styles.d.ts +9 -0
  29. package/dist/styles.js +9 -0
  30. package/dist/theme/default.css +194 -21
  31. package/dist/types.d.ts +11 -3
  32. package/package.json +2 -1
@@ -61,7 +61,7 @@
61
61
  ]}
62
62
  />
63
63
 
64
- <Modal size="large" {onClose} closeOnBackdropClick>
64
+ <Modal size="large" {onClose} closeOnBackdropClick class="max-h-[75vh] lg:max-h-[50vh]">
65
65
  <ModalHeader>
66
66
  <div class="flex place-items-center gap-1">
67
67
  <Input
@@ -93,7 +93,9 @@
93
93
  <CommandPaletteItem
94
94
  {item}
95
95
  selected={commandPaletteManager.selectedIndex === i}
96
- onRemove={commandPaletteManager.query ? undefined : () => commandPaletteManager.remove(i)}
96
+ onRemove={commandPaletteManager.query || commandPaletteManager.isShowAll
97
+ ? undefined
98
+ : () => commandPaletteManager.remove(i)}
97
99
  onSelect={() => commandPaletteManager.select(i)}
98
100
  />
99
101
  {/each}
@@ -103,29 +105,38 @@
103
105
  </ModalBody>
104
106
  <ModalFooter>
105
107
  <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
-
108
+ {#if commandPaletteManager.isEmpty}
114
109
  <div class="flex place-items-center gap-1">
115
110
  <span class="flex gap-1 rounded bg-gray-300 p-1 dark:bg-gray-500">
116
- <Icon icon={mdiArrowUp} size="1rem" />
117
111
  <Icon icon={mdiArrowDown} size="1rem" />
118
112
  </span>
119
- <Text size="small">to navigate</Text>
113
+ <Text size="small">{t('command_palette_to_show_all', translations)}</Text>
120
114
  </div>
115
+ {:else}
116
+ <div class="flex gap-4">
117
+ <div class="flex place-items-center gap-1">
118
+ <span class="rounded bg-gray-300 p-1 dark:bg-gray-500">
119
+ <Icon icon={mdiKeyboardReturn} size="1rem" />
120
+ </span>
121
+ <Text size="small">{t('command_palette_to_select', translations)}</Text>
122
+ </div>
121
123
 
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>
124
+ <div class="flex place-items-center gap-1">
125
+ <span class="flex gap-1 rounded bg-gray-300 p-1 dark:bg-gray-500">
126
+ <Icon icon={mdiArrowUp} size="1rem" />
127
+ <Icon icon={mdiArrowDown} size="1rem" />
128
+ </span>
129
+ <Text size="small">{t('command_palette_to_navigate', translations)}</Text>
130
+ </div>
131
+
132
+ <div class="flex place-items-center gap-1">
133
+ <span class="rounded bg-gray-300 p-1 dark:bg-gray-500">
134
+ <Icon icon={mdiKeyboardEsc} size="1rem" />
135
+ </span>
136
+ <Text size="small">{t('command_palette_to_close', translations)}</Text>
137
+ </div>
127
138
  </div>
128
- </div>
139
+ {/if}
129
140
  </div>
130
141
  </ModalFooter>
131
142
  </Modal>
@@ -0,0 +1,159 @@
1
+ <script lang="ts">
2
+ import { getFieldContext } from '../common/context.svelte.js';
3
+ import Icon from '../components/Icon/Icon.svelte';
4
+ import IconButton from '../components/IconButton/IconButton.svelte';
5
+ import Label from '../components/Label/Label.svelte';
6
+ import { zIndex } from '../constants.js';
7
+ import { styleVariants } from '../styles.js';
8
+ import type { Shape, Size } from '../types.js';
9
+ import { cleanClass, generateId } from '../utilities/internal.js';
10
+ import type { DateValue } from '@internationalized/date';
11
+ import { mdiCalendar, mdiChevronLeft, mdiChevronRight } from '@mdi/js';
12
+ import { DatePicker } from 'bits-ui';
13
+ import { tv } from 'tailwind-variants';
14
+
15
+ type Props = {
16
+ onChange?: (date?: DateValue) => void;
17
+ minDate?: DateValue;
18
+ maxDate?: DateValue;
19
+ date?: DateValue;
20
+ class?: string;
21
+ shape?: Shape;
22
+ size?: Size;
23
+ };
24
+
25
+ let {
26
+ onChange,
27
+ minDate,
28
+ maxDate,
29
+ date = $bindable<DateValue | undefined>(undefined),
30
+ class: className,
31
+ shape = 'semi-round',
32
+ size = 'small',
33
+ }: Props = $props();
34
+
35
+ const { readOnly, required, invalid, disabled, label, ...labelProps } = $derived(getFieldContext());
36
+
37
+ const id = generateId();
38
+ const inputId = `datepicker-${id}`;
39
+ const labelId = `label-${id}`;
40
+
41
+ const containerStyles = tv({
42
+ base: cleanClass(styleVariants.inputContainerCommon, 'flex w-full items-center'),
43
+ variants: {
44
+ shape: styleVariants.shape,
45
+ roundedSize: styleVariants.inputRoundedSize,
46
+ invalid: {
47
+ true: 'border-danger/80 border',
48
+ false: '',
49
+ },
50
+ },
51
+ });
52
+
53
+ const buttonStyles = tv({
54
+ base: 'flex h-8 w-8 items-center justify-center rounded-lg transition-colors hover:bg-neutral-200 hover:dark:bg-neutral-700',
55
+ });
56
+
57
+ const segmentStyles = tv({
58
+ base: 'rounded px-1 py-0.5 tabular-nums outline-none focus:bg-gray-300 focus:text-gray-900 data-disabled:cursor-not-allowed data-focused:bg-gray-300 data-focused:text-gray-900 data-placeholder:text-gray-400 dark:focus:bg-gray-700 dark:focus:text-gray-100 dark:data-focused:bg-gray-700 dark:data-focused:text-gray-100',
59
+ variants: {
60
+ textSize: styleVariants.textSize,
61
+ },
62
+ });
63
+ </script>
64
+
65
+ <div class={cleanClass('flex w-full flex-col gap-1', className)}>
66
+ {#if label}
67
+ <Label id={labelId} for={inputId} {label} requiredIndicator={required === 'indicator'} {...labelProps} />
68
+ {/if}
69
+
70
+ <DatePicker.Root
71
+ onValueChange={onChange}
72
+ minValue={minDate}
73
+ maxValue={maxDate}
74
+ bind:value={date}
75
+ readonly={readOnly}
76
+ {disabled}
77
+ >
78
+ <DatePicker.Input
79
+ id={inputId}
80
+ aria-labelledby={labelId}
81
+ class={containerStyles({
82
+ shape,
83
+ roundedSize: shape === 'semi-round' ? size : undefined,
84
+ invalid,
85
+ })}
86
+ >
87
+ {#snippet children({ segments })}
88
+ <div class={cleanClass(styleVariants.inputCommon, 'w-full px-4 py-2')}>
89
+ {#each segments as { part, value }, i (`segment-${i}`)}
90
+ <DatePicker.Segment {part} class={segmentStyles({ textSize: size })}>
91
+ {value}
92
+ </DatePicker.Segment>
93
+ {/each}
94
+ </div>
95
+ <DatePicker.Trigger class="mr-2 rounded-full">
96
+ <IconButton
97
+ variant="ghost"
98
+ shape="round"
99
+ color="secondary"
100
+ {size}
101
+ icon={mdiCalendar}
102
+ {disabled}
103
+ aria-label="Open calendar"
104
+ />
105
+ </DatePicker.Trigger>
106
+ {/snippet}
107
+ </DatePicker.Input>
108
+ <DatePicker.Portal>
109
+ <DatePicker.Content
110
+ class="bg-subtle text-dark rounded-xl border p-4 shadow-lg outline-none select-none {zIndex.SelectDropdown}"
111
+ sideOffset={10}
112
+ >
113
+ <DatePicker.Calendar class="w-full">
114
+ {#snippet children({ months, weekdays })}
115
+ <DatePicker.Header class="mb-3 flex items-center justify-between">
116
+ <DatePicker.PrevButton class={buttonStyles()}>
117
+ <Icon icon={mdiChevronLeft} size="1.25rem" />
118
+ </DatePicker.PrevButton>
119
+ <DatePicker.Heading class="text-sm font-semibold" />
120
+ <DatePicker.NextButton class={buttonStyles()}>
121
+ <Icon icon={mdiChevronRight} size="1.25rem" />
122
+ </DatePicker.NextButton>
123
+ </DatePicker.Header>
124
+ {#each months as month (`month-${month.value}`)}
125
+ <DatePicker.Grid class="w-full border-collapse">
126
+ <DatePicker.GridHead>
127
+ <DatePicker.GridRow class="flex w-full">
128
+ {#each weekdays as day, i (`weekday-${i}`)}
129
+ <DatePicker.HeadCell
130
+ class="text-muted flex h-8 w-8 flex-1 items-center justify-center text-xs font-medium"
131
+ >
132
+ {day.slice(0, 2)}
133
+ </DatePicker.HeadCell>
134
+ {/each}
135
+ </DatePicker.GridRow>
136
+ </DatePicker.GridHead>
137
+ <DatePicker.GridBody>
138
+ {#each month.weeks as weekDates (`weekDates-${weekDates}`)}
139
+ <DatePicker.GridRow class="flex w-full">
140
+ {#each weekDates as date (`date-${date.toString()}`)}
141
+ <DatePicker.Cell {date} month={month.value} class="flex-1">
142
+ <DatePicker.Day
143
+ class="{buttonStyles()} data-selected:bg-primary data-selected:text-light data-today:border-primary border border-transparent data-disabled:cursor-not-allowed data-disabled:opacity-40 data-outside-month:text-gray-400 data-unavailable:cursor-not-allowed data-unavailable:text-gray-300 data-unavailable:line-through"
144
+ >
145
+ {date.day}
146
+ </DatePicker.Day>
147
+ </DatePicker.Cell>
148
+ {/each}
149
+ </DatePicker.GridRow>
150
+ {/each}
151
+ </DatePicker.GridBody>
152
+ </DatePicker.Grid>
153
+ {/each}
154
+ {/snippet}
155
+ </DatePicker.Calendar>
156
+ </DatePicker.Content>
157
+ </DatePicker.Portal>
158
+ </DatePicker.Root>
159
+ </div>
@@ -0,0 +1,15 @@
1
+ import type { Shape, Size } from '../types.js';
2
+ import type { DateValue } from '@internationalized/date';
3
+ import { DatePicker } from 'bits-ui';
4
+ type Props = {
5
+ onChange?: (date?: DateValue) => void;
6
+ minDate?: DateValue;
7
+ maxDate?: DateValue;
8
+ date?: DateValue;
9
+ class?: string;
10
+ shape?: Shape;
11
+ size?: Size;
12
+ };
13
+ declare const DatePicker: import("svelte").Component<Props, {}, "date">;
14
+ type DatePicker = ReturnType<typeof DatePicker>;
15
+ export default DatePicker;
@@ -78,7 +78,7 @@
78
78
 
79
79
  <div class={cleanClass('flex flex-col gap-1', className)} bind:this={ref}>
80
80
  {#if label}
81
- <Label id={labelId} for={inputId} {label} {...labelProps} />
81
+ <Label id={labelId} for={inputId} {label} requiredIndicator={required === 'indicator'} {...labelProps} />
82
82
  {/if}
83
83
 
84
84
  <Select.Root type={multiple ? 'multiple' : 'single'} bind:value={internalValue as never} {onValueChange}>
@@ -126,7 +126,7 @@
126
126
  {#each options as { value, label, disabled }, i (i + value)}
127
127
  <Select.Item
128
128
  class={cleanClass(
129
- 'hover:bg-subtle data-[selected]:bg-primary/10 flex h-10 w-full items-center px-5 py-3 text-sm duration-75 outline-none select-none data-disabled:opacity-50',
129
+ 'hover:bg-subtle data-selected:bg-primary/10 flex h-10 w-full items-center px-5 py-3 text-sm duration-75 outline-none select-none data-disabled:opacity-50',
130
130
  disabled ? 'cursor-not-allowed' : 'cursor-pointer',
131
131
  )}
132
132
  {value}
@@ -17,11 +17,10 @@ export type CommandItem = {
17
17
  } | {
18
18
  action: (command: CommandItem) => void;
19
19
  });
20
- export type CommandPaletteTranslations = TranslationProps<'search_placeholder' | 'search_no_results' | 'search_recently_used' | 'command_palette_prompt_default'>;
20
+ export type CommandPaletteTranslations = TranslationProps<'search_placeholder' | 'search_no_results' | 'search_recently_used' | 'command_palette_prompt_default' | 'command_palette_to_select' | 'command_palette_to_close' | 'command_palette_to_navigate' | 'command_palette_to_show_all'>;
21
21
  export declare const asText: (...items: unknown[]) => string;
22
22
  declare class CommandPaletteManager {
23
23
  #private;
24
- query: string;
25
24
  selectedIndex: number;
26
25
  items: (CommandItem & {
27
26
  id: string;
@@ -37,6 +36,10 @@ declare class CommandPaletteManager {
37
36
  })[];
38
37
  get isEnabled(): boolean;
39
38
  enable(): void;
39
+ get query(): string;
40
+ set query(query: string);
41
+ get isShowAll(): boolean;
42
+ get isEmpty(): boolean;
40
43
  setTranslations(translations?: CommandPaletteTranslations): void;
41
44
  pushContextLayer(): (() => void) | undefined;
42
45
  popContextLayer(): void;
@@ -17,19 +17,21 @@ const isMatch = ({ title, description, type, text = asText(title, description, t
17
17
  return text.includes(query);
18
18
  };
19
19
  class CommandPaletteManager {
20
- query = $state('');
20
+ #query = $state('');
21
21
  selectedIndex = $state(0);
22
22
  #isEnabled = $state(false);
23
- #normalizedQuery = $derived(this.query.toLowerCase());
23
+ #normalizedQuery = $derived(this.#query.toLowerCase());
24
24
  #modal;
25
25
  #translations = {};
26
26
  #isOpen = false;
27
+ #isShowAll = $state(false);
27
28
  #globalLayer = $state({ items: [], recentItems: [] });
28
29
  #layers = $state([{ items: [], recentItems: [] }]);
29
30
  items = $derived([...this.#globalLayer.items, ...this.#layers.at(-1).items].filter(isEnabled));
30
31
  filteredItems = $derived(this.items.filter((item) => isMatch(item, this.#normalizedQuery)).slice(0, 100));
31
32
  recentItems = $derived([...this.#globalLayer.recentItems, ...this.#layers.at(-1).recentItems].filter(isEnabled));
32
- results = $derived(this.query ? this.filteredItems : this.recentItems);
33
+ results = $derived(this.#isShowAll ? this.items : this.#query ? this.filteredItems : this.recentItems);
34
+ #isEmpty = $derived(!this.#isShowAll && !this.#query && this.results.length === 0);
33
35
  get isEnabled() {
34
36
  return this.#isEnabled;
35
37
  }
@@ -47,6 +49,21 @@ class CommandPaletteManager {
47
49
  document.body.addEventListener('keydown', (event) => this.#handleKeydown(event));
48
50
  }
49
51
  }
52
+ get query() {
53
+ return this.#query;
54
+ }
55
+ set query(query) {
56
+ this.#query = query;
57
+ if (this.#isShowAll && query) {
58
+ this.#isShowAll = false;
59
+ }
60
+ }
61
+ get isShowAll() {
62
+ return this.#isShowAll;
63
+ }
64
+ get isEmpty() {
65
+ return this.#isEmpty;
66
+ }
50
67
  async #handleKeydown(event) {
51
68
  const command = this.items.find(({ shortcuts }) => {
52
69
  if (!shortcuts) {
@@ -116,35 +133,50 @@ class CommandPaletteManager {
116
133
  return this.#modal.close();
117
134
  }
118
135
  #onClose() {
119
- this.query = '';
136
+ this.#query = '';
120
137
  this.#modal = undefined;
121
138
  this.#isOpen = false;
139
+ this.#isShowAll = false;
122
140
  }
123
141
  async select(selectedIndex) {
124
142
  const selected = this.results[selectedIndex ?? this.selectedIndex];
125
143
  if (!selected) {
126
144
  return;
127
145
  }
128
- // no duplicates
129
- this.recentItems = this.recentItems.filter(({ id }) => id !== selected.id);
130
- this.recentItems.unshift(selected);
131
- this.recentItems = this.recentItems.slice(0, 5);
146
+ const globalItem = this.#globalLayer.items.find(({ id }) => id !== selected.id);
147
+ if (globalItem) {
148
+ this.#globalLayer.recentItems = this.#globalLayer.recentItems.filter(({ id }) => id !== selected.id);
149
+ this.#globalLayer.recentItems.unshift(selected);
150
+ }
151
+ else {
152
+ this.#layers.at(-1).recentItems = this.#layers.at(-1).recentItems.filter(({ id }) => id !== selected.id);
153
+ this.#layers.at(-1)?.recentItems.unshift(selected);
154
+ }
132
155
  await this.#executeCommand(selected);
133
156
  await this.close();
134
157
  }
135
158
  remove(index) {
136
- this.recentItems.splice(index, 1);
159
+ const item = this.recentItems.at(index);
160
+ if (!item) {
161
+ return;
162
+ }
163
+ this.#globalLayer.recentItems = this.#globalLayer.recentItems.filter(({ id }) => id !== item.id);
164
+ this.#layers.at(-1).recentItems = this.#layers.at(-1).recentItems.filter(({ id }) => id !== item.id);
137
165
  }
138
166
  up() {
139
167
  this.selectedIndex = (this.selectedIndex - 1 + this.results.length) % (this.results.length || 1);
140
168
  }
141
169
  down() {
170
+ if (this.#isEmpty) {
171
+ this.#isShowAll = true;
172
+ return;
173
+ }
142
174
  this.selectedIndex = (this.selectedIndex + 1) % (this.results.length || 1);
143
175
  }
144
176
  reset() {
145
177
  this.#layers = [{ items: [], recentItems: [] }];
146
178
  this.#globalLayer = { items: [], recentItems: [] };
147
- this.query = '';
179
+ this.#query = '';
148
180
  }
149
181
  addCommands(itemOrItems, options = {}) {
150
182
  const items = Array.isArray(itemOrItems) ? itemOrItems : [itemOrItems];
@@ -13,10 +13,15 @@ declare const defaultTranslations: {
13
13
  hide_password: string;
14
14
  dark_theme: string;
15
15
  command_palette_prompt_default: string;
16
+ command_palette_to_select: string;
17
+ command_palette_to_navigate: string;
18
+ command_palette_to_close: string;
19
+ command_palette_to_show_all: string;
16
20
  toast_success_title: string;
17
21
  toast_info_title: string;
18
22
  toast_warning_title: string;
19
23
  toast_danger_title: string;
24
+ save: string;
20
25
  };
21
26
  export type Translations = typeof defaultTranslations;
22
27
  export declare const translate: <T extends keyof Translations>(key: T, overrides?: TranslationProps<T>) => string;
@@ -19,10 +19,15 @@ const defaultTranslations = {
19
19
  dark_theme: 'Toggle dark theme',
20
20
  // command palette
21
21
  command_palette_prompt_default: 'Quickly find pages, actions, or commands',
22
+ command_palette_to_select: 'to select',
23
+ command_palette_to_navigate: 'to navigate',
24
+ command_palette_to_close: 'to close',
25
+ command_palette_to_show_all: 'to show all',
22
26
  toast_success_title: 'Success',
23
27
  toast_info_title: 'Info',
24
28
  toast_warning_title: 'Warning',
25
29
  toast_danger_title: 'Error',
30
+ save: 'Save',
26
31
  };
27
32
  let translations = $state(defaultTranslations);
28
33
  export const translate = (key, overrides) => overrides?.[key] ?? translations[key];
package/dist/styles.d.ts CHANGED
@@ -16,11 +16,20 @@ export declare const styleVariants: {
16
16
  warning: string;
17
17
  info: string;
18
18
  };
19
+ inputCommon: string;
20
+ inputContainerCommon: string;
19
21
  shape: {
20
22
  rectangle: string;
21
23
  'semi-round': string;
22
24
  round: string;
23
25
  };
26
+ inputRoundedSize: {
27
+ tiny: string;
28
+ small: string;
29
+ medium: string;
30
+ large: string;
31
+ giant: string;
32
+ };
24
33
  border: {
25
34
  true: string;
26
35
  false: string;
package/dist/styles.js CHANGED
@@ -12,11 +12,20 @@ export const styleVariants = {
12
12
  ...color,
13
13
  muted: 'text-gray-600 dark:text-gray-400',
14
14
  },
15
+ inputCommon: 'disabled:bg-gray-300 disabled:text-dark dark:disabled:bg-gray-900 dark:disabled:text-gray-200 bg-transparent transition outline-none disabled:cursor-not-allowed ',
16
+ inputContainerCommon: 'bg-gray-100 ring-1 ring-gray-200 focus-within:ring-primary dark:focus-within:ring-primary transition outline-none focus-within:ring-1 disabled:cursor-not-allowed dark:bg-gray-800 dark:ring-black',
15
17
  shape: {
16
18
  rectangle: 'rounded-none',
17
19
  'semi-round': '',
18
20
  round: 'rounded-full',
19
21
  },
22
+ inputRoundedSize: {
23
+ tiny: 'rounded-lg',
24
+ small: 'rounded-lg',
25
+ medium: 'rounded-lg',
26
+ large: 'rounded-lg',
27
+ giant: 'rounded-lg',
28
+ },
20
29
  border: {
21
30
  true: 'border',
22
31
  false: '',