@immich/ui 0.63.0 → 0.64.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.
@@ -22,14 +22,17 @@
22
22
  }: ContextMenuProps = $props();
23
23
 
24
24
  const itemStyles = tv({
25
- base: 'hover:bg-light-200 flex w-full items-center gap-1 rounded-lg p-1 text-start hover:cursor-pointer',
25
+ base: 'data-highlighted:bg-light-200 flex items-center gap-1 rounded-lg p-1 text-start outline-none hover:cursor-pointer',
26
26
  variants: {
27
27
  color: styleVariants.textColor,
28
+ inset: {
29
+ true: 'mx-1',
30
+ },
28
31
  },
29
32
  });
30
33
 
31
34
  const wrapperStyles = tv({
32
- base: 'bg-light-100 dark:border-light-300 flex flex-col gap-1 overflow-hidden rounded-xl border py-1 shadow-sm',
35
+ base: 'bg-light-100 dark:border-light-300 flex flex-col gap-1 overflow-hidden rounded-xl border py-1 shadow-sm outline-none',
33
36
  variants: {
34
37
  size: {
35
38
  tiny: 'w-32',
@@ -130,14 +133,12 @@
130
133
  textValue={item.title}
131
134
  closeOnSelect
132
135
  onSelect={() => item.onAction(item)}
133
- class="px-1"
136
+ class={itemStyles({ color: item.color, inset: true })}
134
137
  >
135
- <div class={itemStyles({ color: item.color })}>
136
- {#if item.icon}
137
- <Icon icon={item.icon} class="m-2 shrink-0" />
138
- {/if}
139
- <Text class="grow text-start font-medium select-none" size="medium">{item.title}</Text>
140
- </div>
138
+ {#if item.icon}
139
+ <Icon icon={item.icon} class="m-2 shrink-0" />
140
+ {/if}
141
+ <Text class="grow text-start font-medium select-none" size="medium">{item.title}</Text>
141
142
  </DropdownMenu.Item>
142
143
  {/if}
143
144
  {/each}
@@ -152,10 +153,9 @@
152
153
  closeOnSelect
153
154
  onSelect={() => item.onAction(item)}
154
155
  title={item.title}
156
+ class={itemStyles({ color: item.color })}
155
157
  >
156
- <div class={cleanClass(itemStyles({ color: item.color }))}>
157
- <Icon icon={item.icon} class="m-2 shrink-0" />
158
- </div>
158
+ <Icon icon={item.icon} class="m-2 shrink-0" />
159
159
  </DropdownMenu.Item>
160
160
  {/if}
161
161
  {/each}
@@ -5,8 +5,6 @@
5
5
  import { styleVariants } from '../../styles.js';
6
6
  import type { TextareaProps } from '../../types.js';
7
7
  import { cleanClass, generateId } from '../../utilities/internal.js';
8
- import { onMount } from 'svelte';
9
- import type { FormEventHandler } from 'svelte/elements';
10
8
  import { tv } from 'tailwind-variants';
11
9
 
12
10
  let {
@@ -75,12 +73,10 @@
75
73
  }
76
74
  };
77
75
 
78
- const onInput: FormEventHandler<HTMLTextAreaElement> = (event) => {
79
- autogrow(event.target as HTMLTextAreaElement);
80
- restProps?.oninput?.(event);
81
- };
82
-
83
- onMount(() => autogrow(ref));
76
+ $effect(() => {
77
+ void value;
78
+ autogrow(ref);
79
+ });
84
80
  </script>
85
81
 
86
82
  <div class="flex w-full flex-col gap-1" bind:this={containerRef}>
@@ -94,7 +90,6 @@
94
90
 
95
91
  <div class="relative w-full">
96
92
  <textarea
97
- oninput={onInput}
98
93
  id={inputId}
99
94
  aria-labelledby={label && labelId}
100
95
  required={!!required}
@@ -1,12 +1,12 @@
1
1
  <script lang="ts" generics="T extends string">
2
2
  import { getFieldContext } from '../common/context.svelte.js';
3
3
  import Icon from '../components/Icon/Icon.svelte';
4
- import IconButton from '../components/IconButton/IconButton.svelte';
5
- import Input from '../components/Input/Input.svelte';
4
+ import Label from '../components/Label/Label.svelte';
5
+ import Text from '../components/Text/Text.svelte';
6
6
  import { zIndex } from '../constants.js';
7
- import { t } from '../services/translation.svelte.js';
7
+ import { styleVariants } from '../styles.js';
8
8
  import type { SelectCommonProps, SelectOption } from '../types.js';
9
- import { cleanClass } from '../utilities/internal.js';
9
+ import { cleanClass, generateId } from '../utilities/internal.js';
10
10
  import { mdiArrowDown, mdiArrowUp, mdiCheck, mdiChevronDown } from '@mdi/js';
11
11
  import { Select } from 'bits-ui';
12
12
  import { tv } from 'tailwind-variants';
@@ -21,7 +21,7 @@
21
21
 
22
22
  let {
23
23
  options: optionsOrItems,
24
- shape,
24
+ shape = 'semi-round',
25
25
  size: initialSize,
26
26
  multiple = false,
27
27
  values = $bindable([]),
@@ -44,34 +44,105 @@
44
44
  };
45
45
 
46
46
  const context = getFieldContext();
47
- const { invalid, disabled, ...labelProps } = $derived(context());
47
+ const { label, description, required, invalid, disabled, ...labelProps } = $derived(context());
48
48
  const size = $derived(initialSize ?? labelProps.size ?? 'small');
49
+ const roundedSize = $derived(shape === 'semi-round' ? size : undefined);
49
50
  const options = $derived(asOptions(optionsOrItems));
50
51
 
51
52
  const findOption = (value: string) => options.find((option) => option.value === value);
52
53
  const valuesToOptions = (values: T[]) =>
53
54
  values.map(findOption).filter((item): item is SelectOption<T> => Boolean(item));
54
55
 
56
+ let open = $state(false);
57
+ let triggerRef = $state<HTMLButtonElement | null>(null);
58
+ let internalValue = $derived(multiple ? values : (values[0] ?? ''));
55
59
  const selectedLabel = $derived(asLabel(valuesToOptions(values)));
56
60
 
61
+ const id = generateId();
62
+ const triggerId = `trigger-${id}`;
63
+ const descriptionId = $derived(description ? `description-${id}` : undefined);
64
+
57
65
  const triggerStyles = tv({
58
- base: 'w-full gap-1 rounded-lg py-0 text-start focus-visible:outline-none',
66
+ base: 'w-full gap-1 p-0 text-start ring-1 ring-gray-200 outline-none focus-visible:outline-none dark:ring-neutral-900',
59
67
  variants: {
68
+ disabled: {
69
+ true: 'cursor-not-allowed',
70
+ false: 'cursor-pointer',
71
+ },
72
+ shape: styleVariants.shape,
73
+ roundedSize: styleVariants.inputRoundedSize,
60
74
  invalid: {
61
- true: 'border-danger border',
75
+ true: 'ring-danger-300 dark:ring-danger-300 focus-visible:ring-danger dark:focus-visible:ring-danger',
76
+ false: 'focus-visible:ring-primary dark:focus-visible:ring-primary',
77
+ },
78
+ },
79
+ });
80
+
81
+ const containerStyles = tv({
82
+ base: 'flex w-full items-center bg-gray-100 dark:bg-gray-800',
83
+ variants: {
84
+ shape: styleVariants.shape,
85
+ roundedSize: styleVariants.inputRoundedSize,
86
+ disabled: {
87
+ true: 'bg-light-300 dark:bg-gray-900',
62
88
  false: '',
63
89
  },
64
90
  },
65
91
  });
66
92
 
67
- let inputRef = $state<HTMLInputElement | null>(null);
68
- let contentRef = $state<HTMLElement | null>(null);
69
- let ref = $state<HTMLElement | null>(null);
93
+ const valueStyles = tv({
94
+ base: 'block w-full min-w-0 flex-1 truncate py-2.5 text-start',
95
+ variants: {
96
+ textSize: styleVariants.textSize,
97
+ height: {
98
+ tiny: 'h-9',
99
+ small: 'h-10',
100
+ medium: 'h-11',
101
+ large: 'h-12',
102
+ giant: 'h-12',
103
+ },
104
+ leadingPadding: {
105
+ base: 'pl-4',
106
+ icon: 'pl-0',
107
+ },
108
+ trailingPadding: {
109
+ base: 'pr-4',
110
+ icon: 'pr-0',
111
+ },
112
+ roundedSize: styleVariants.inputRoundedSize,
113
+ },
114
+ });
70
115
 
71
- $effect(() => {
72
- if (ref && contentRef) {
73
- contentRef.style.width = `${ref.clientWidth}px`;
74
- }
116
+ const contentStyles = tv({
117
+ base: cleanClass(
118
+ 'text-dark dark:bg-primary-100 bg-light-100 max-h-96 w-(--bits-select-anchor-width) min-w-(--bits-select-anchor-width) border py-3 outline-none select-none',
119
+ zIndex.SelectDropdown,
120
+ ),
121
+ variants: {
122
+ shape: {
123
+ rectangle: 'rounded-none',
124
+ 'semi-round': '',
125
+ round: 'rounded-2xl',
126
+ },
127
+ roundedSize: styleVariants.inputRoundedSize,
128
+ },
129
+ });
130
+
131
+ const itemStyles = tv({
132
+ base: 'hover:bg-light-200 hover:dark:bg-primary-200 data-highlighted:bg-light-200 dark:data-highlighted:bg-primary-200 flex w-full items-center duration-75 outline-none select-none data-disabled:opacity-50',
133
+ variants: {
134
+ size: {
135
+ tiny: 'h-9 px-4 text-xs',
136
+ small: 'h-10 px-5 text-sm',
137
+ medium: 'h-11 px-5 text-base',
138
+ large: 'h-12 px-5 text-lg',
139
+ giant: 'h-12 px-6 text-xl',
140
+ },
141
+ disabled: {
142
+ true: 'cursor-not-allowed',
143
+ false: 'cursor-pointer',
144
+ },
145
+ },
75
146
  });
76
147
 
77
148
  const onValueChange = (newValues: string[] | string) => {
@@ -83,61 +154,85 @@
83
154
  onChange?.(values);
84
155
  onItemChange?.(items);
85
156
  };
86
-
87
- let internalValue = $derived(multiple ? values : (values[0] ?? ''));
88
157
  </script>
89
158
 
90
- <div class={cleanClass('flex flex-col gap-1', className)} bind:this={ref}>
91
- <Select.Root type={multiple ? 'multiple' : 'single'} bind:value={internalValue as never} {onValueChange}>
92
- <Select.Trigger {disabled} class={cleanClass(triggerStyles({ invalid: false }))} aria-label={placeholder}>
93
- <Input
94
- bind:containerRef={inputRef}
95
- {size}
96
- {shape}
97
- {placeholder}
98
- value={selectedLabel}
99
- readonly
100
- class={cleanClass('text-start', invalid && 'border-danger')}
159
+ <div class={cleanClass('flex w-full flex-col gap-1', className)}>
160
+ {#if label}
161
+ <Label
162
+ {...labelProps}
163
+ {label}
164
+ {size}
165
+ requiredIndicator={required === 'indicator'}
166
+ for={triggerId}
167
+ onclick={() => {
168
+ if (disabled) {
169
+ return;
170
+ }
171
+ open = true;
172
+ triggerRef?.focus();
173
+ }}
174
+ />
175
+ {/if}
176
+
177
+ {#if description}
178
+ <Text color="muted" size="small" id={descriptionId} class="mb-2">{description}</Text>
179
+ {/if}
180
+
181
+ <Select.Root
182
+ type={multiple ? 'multiple' : 'single'}
183
+ bind:value={internalValue as never}
184
+ bind:open
185
+ items={options.map(({ value, label, disabled }) => ({ value, label: label ?? value, disabled }))}
186
+ {onValueChange}
187
+ >
188
+ <Select.Trigger
189
+ bind:ref={triggerRef}
190
+ {disabled}
191
+ id={triggerId}
192
+ class={triggerStyles({
193
+ disabled,
194
+ invalid,
195
+ shape,
196
+ roundedSize,
197
+ })}
198
+ aria-describedby={descriptionId}
199
+ aria-label={label ? undefined : placeholder}
200
+ >
201
+ <div
202
+ class={containerStyles({
203
+ disabled,
204
+ shape,
205
+ roundedSize,
206
+ })}
101
207
  >
102
- {#snippet trailingIcon()}
103
- <IconButton
104
- variant="ghost"
105
- shape="round"
106
- color="secondary"
107
- size="tiny"
108
- class="m-1"
109
- icon={mdiChevronDown}
110
- {disabled}
111
- aria-label={t('expand')}
112
- />
113
- {/snippet}
114
- </Input>
208
+ <span
209
+ class={cleanClass(
210
+ valueStyles({
211
+ textSize: size,
212
+ height: size,
213
+ leadingPadding: 'base',
214
+ trailingPadding: 'icon',
215
+ roundedSize,
216
+ }),
217
+ !selectedLabel && placeholder && 'text-gray-600 dark:text-gray-400',
218
+ )}
219
+ >
220
+ {selectedLabel || placeholder}
221
+ </span>
222
+
223
+ <Icon icon={mdiChevronDown} class={cleanClass('mx-3 shrink-0')} aria-hidden />
224
+ </div>
115
225
  </Select.Trigger>
116
226
  <Select.Portal>
117
- <Select.Content
118
- bind:ref={contentRef}
119
- class="text-dark dark:bg-primary-100 bg-light-100 max-h-96 rounded-xl border py-3 outline-none select-none {zIndex.SelectDropdown}"
120
- customAnchor={inputRef}
121
- sideOffset={4}
122
- >
227
+ <Select.Content class={contentStyles({ shape, roundedSize })} sideOffset={4}>
123
228
  <Select.ScrollUpButton class="flex w-full items-center justify-center">
124
229
  <Icon icon={mdiArrowUp} />
125
230
  </Select.ScrollUpButton>
126
231
  <Select.Viewport>
127
232
  {#each options as { value, label, disabled }, i (i + value)}
128
- <Select.Item
129
- class={cleanClass(
130
- 'hover:bg-light-200 hover:dark:bg-primary-200 data-selected:bg-light-200 dark:data-selected:bg-primary-200 flex h-10 w-full items-center px-5 py-3 text-sm duration-75 outline-none select-none data-disabled:opacity-50',
131
- disabled ? 'cursor-not-allowed' : 'cursor-pointer',
132
- )}
133
- {value}
134
- {label}
135
- {disabled}
136
- >
233
+ <Select.Item class={cleanClass(itemStyles({ size, disabled }))} {value} {label} {disabled}>
137
234
  {#snippet children({ selected })}
138
- <div
139
- class="flex items-center justify-center gap-2 text-sm font-medium whitespace-nowrap transition-colors"
140
- >
235
+ <div class="flex items-center justify-center gap-2 font-medium whitespace-nowrap transition-colors">
141
236
  <span>{label}</span>
142
237
  </div>
143
238
  {#if selected}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@immich/ui",
3
- "version": "0.63.0",
3
+ "version": "0.64.0",
4
4
  "license": "GNU Affero General Public License version 3",
5
5
  "repository": {
6
6
  "type": "git",
@@ -49,7 +49,7 @@
49
49
  "dependencies": {
50
50
  "@internationalized/date": "^3.10.0",
51
51
  "@mdi/js": "^7.4.47",
52
- "bits-ui": "^2.9.8",
52
+ "bits-ui": "^2.15.7",
53
53
  "luxon": "^3.7.2",
54
54
  "simple-icons": "^16.0.0",
55
55
  "svelte-highlight": "^7.8.4",
@@ -58,7 +58,7 @@
58
58
  "@immich/svelte-markdown-preprocess": "^0.2.1"
59
59
  },
60
60
  "volta": {
61
- "node": "24.13.0"
61
+ "node": "24.13.1"
62
62
  },
63
63
  "scripts": {
64
64
  "create": "node scripts/create.js",