Compare commits

...

2 Commits

Author SHA1 Message Date
Mees Frensel
fbdbbf4f1f autofocus 2026-03-23 17:40:32 +01:00
Mees Frensel
3283107c06 feat(web): use ui pin input element 2026-03-23 16:58:00 +01:00
6 changed files with 49 additions and 173 deletions

52
pnpm-lock.yaml generated
View File

@@ -744,8 +744,8 @@ importers:
specifier: workspace:*
version: link:../open-api/typescript-sdk
'@immich/ui':
specifier: ^0.65.3
version: 0.65.3(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)
specifier: ^0.67.1
version: 0.67.1(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)
'@mapbox/mapbox-gl-rtl-text':
specifier: 0.3.0
version: 0.3.0
@@ -1686,6 +1686,10 @@ packages:
resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
engines: {node: '>=6.9.0'}
'@babel/runtime@7.29.2':
resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
engines: {node: '>=6.9.0'}
'@babel/template@7.27.2':
resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
engines: {node: '>=6.9.0'}
@@ -3038,8 +3042,8 @@ packages:
peerDependencies:
svelte: ^5.0.0
'@immich/ui@0.65.3':
resolution: {integrity: sha512-jMXzCzMNTcCdWXt9IUP7GkALE5oEvPQk/jCOuI2bfxsxCZFzMkUfUS+AV83Vg1vQ6l+g39PbKSPKBEzv125ATQ==}
'@immich/ui@0.67.1':
resolution: {integrity: sha512-aWBjn/Okp5/2IuTDGzJjNgydgFQN2hAuMsy7wdF+qKNhHNETZSBfIwD9k3/GfzcV+8ksPchouX1/68lAYi6+fw==}
peerDependencies:
svelte: ^5.0.0
@@ -3186,8 +3190,8 @@ packages:
'@types/node':
optional: true
'@internationalized/date@3.10.0':
resolution: {integrity: sha512-oxDR/NTEJ1k+UFVQElaNIk65E/Z83HK1z1WI3lQyhTtnNg4R5oVXaPzK3jcpKG8UHKDVuDQHzn+wsxSz8RP3aw==}
'@internationalized/date@3.12.0':
resolution: {integrity: sha512-/PyIMzK29jtXaGU23qTvNZxvBXRtKbNnGDFD+PY6CZw/Y8Ex8pFUzkuCJCG9aOqmShjqhS9mPqP6Dk5onQY8rQ==}
'@ioredis/commands@1.5.0':
resolution: {integrity: sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==}
@@ -5814,8 +5818,8 @@ packages:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
bits-ui@2.16.0:
resolution: {integrity: sha512-utsUZE7W7MxOQF1jmSYfzUrt2nZxgkq0yPqQcBQ0WQDMq8ETd1yEiHlPpqhMrpKU7IivjSf4XVysDDy+UVkMUw==}
bits-ui@2.16.3:
resolution: {integrity: sha512-5hJ5dEhf5yPzkRFcxzgQHScGodeo0gK0MUUXrdLlRHWaBOBGZiacWLG96j/wwFatKwZvouw7q+sn14i0fx3RIg==}
engines: {node: '>=20'}
peerDependencies:
'@internationalized/date': ^3.8.1
@@ -8817,8 +8821,8 @@ packages:
engines: {node: '>= 20'}
hasBin: true
marked@17.0.3:
resolution: {integrity: sha512-jt1v2ObpyOKR8p4XaUJVk3YWRJ5n+i4+rjQopxvV32rSndTJXvIzuUdWWIy/1pFQMkQmvTXawzDNqOH/CUmx6A==}
marked@17.0.5:
resolution: {integrity: sha512-6hLvc0/JEbRjRgzI6wnT2P1XuM1/RrrDEX0kPt0N7jGm1133g6X7DlxFasUIx+72aKAr904GTxhSLDrd5DIlZg==}
engines: {node: '>= 20'}
hasBin: true
@@ -10976,8 +10980,8 @@ packages:
resolution: {integrity: sha512-i/w5Ie4tENfGYbdCo2iJ+oies0vOFd8QXWHopKOUzudfLCvnmeheF2PpHp89Z2azpc+c2su3lMiWO/SpP+429A==}
engines: {node: '>=0.12.18'}
simple-icons@16.9.0:
resolution: {integrity: sha512-aKst2C7cLkFyaiQ/Crlwxt9xYOpGPk05XuJZ0ZTJNNCzHCKYrGWz2ebJSi5dG8CmTCxUF/BGs6A8uyJn/EQxqw==}
simple-icons@16.13.0:
resolution: {integrity: sha512-N4AMZvFERU5YLEtUudtUesiM2H4O5xQ9qfS3K0oOV5II5KVtxOUAlmZ7KqBgiTSGBgCVkuLD/Z9dJKBtnI3kKQ==}
engines: {node: '>=0.12.18'}
sirv@2.0.4:
@@ -13389,6 +13393,8 @@ snapshots:
'@babel/runtime@7.28.6': {}
'@babel/runtime@7.29.2': {}
'@babel/template@7.27.2':
dependencies:
'@babel/code-frame': 7.29.0
@@ -15119,18 +15125,18 @@ snapshots:
'@immich/svelte-markdown-preprocess@0.2.1(svelte@5.53.13)':
dependencies:
front-matter: 4.0.2
marked: 17.0.3
marked: 17.0.5
node-emoji: 2.2.0
svelte: 5.53.13
'@immich/ui@0.65.3(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)':
'@immich/ui@0.67.1(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)':
dependencies:
'@immich/svelte-markdown-preprocess': 0.2.1(svelte@5.53.13)
'@internationalized/date': 3.10.0
'@internationalized/date': 3.12.0
'@mdi/js': 7.4.47
bits-ui: 2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)
bits-ui: 2.16.3(@internationalized/date@3.12.0)(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)
luxon: 3.7.2
simple-icons: 16.9.0
simple-icons: 16.13.0
svelte: 5.53.13
svelte-highlight: 7.9.0
tailwind-merge: 3.5.0
@@ -15279,7 +15285,7 @@ snapshots:
optionalDependencies:
'@types/node': 24.12.0
'@internationalized/date@3.10.0':
'@internationalized/date@3.12.0':
dependencies:
'@swc/helpers': 0.5.17
@@ -16495,7 +16501,7 @@ snapshots:
'@slorber/react-helmet-async@1.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@babel/runtime': 7.28.6
'@babel/runtime': 7.29.2
invariant: 2.2.4
prop-types: 15.8.1
react: 18.3.1
@@ -18115,11 +18121,11 @@ snapshots:
binary-extensions@2.3.0: {}
bits-ui@2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13):
bits-ui@2.16.3(@internationalized/date@3.12.0)(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13):
dependencies:
'@floating-ui/core': 1.7.3
'@floating-ui/dom': 1.7.4
'@internationalized/date': 3.10.0
'@internationalized/date': 3.12.0
esm-env: 1.2.2
runed: 0.35.1(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)
svelte: 5.53.13
@@ -21572,7 +21578,7 @@ snapshots:
marked@16.4.2: {}
marked@17.0.3: {}
marked@17.0.5: {}
math-intrinsics@1.1.0: {}
@@ -24311,7 +24317,7 @@ snapshots:
simple-icons@15.22.0: {}
simple-icons@16.9.0: {}
simple-icons@16.13.0: {}
sirv@2.0.4:
dependencies:

View File

@@ -27,7 +27,7 @@
"@formatjs/icu-messageformat-parser": "^3.0.0",
"@immich/justified-layout-wasm": "^0.4.3",
"@immich/sdk": "workspace:*",
"@immich/ui": "^0.65.3",
"@immich/ui": "^0.67.1",
"@mapbox/mapbox-gl-rtl-text": "0.3.0",
"@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.14.0",

View File

@@ -1,9 +1,8 @@
<script lang="ts">
import PinCodeInput from '$lib/components/user-settings-page/PinCodeInput.svelte';
import PinCodeResetModal from '$lib/modals/PinCodeResetModal.svelte';
import { handleError } from '$lib/utils/handle-error';
import { changePinCode } from '@immich/sdk';
import { Button, Heading, modalManager, Text, toastManager } from '@immich/ui';
import { Button, Field, Heading, modalManager, PinInput, Text, toastManager } from '@immich/ui';
import { t } from 'svelte-i18n';
let currentPinCode = $state('');
@@ -40,9 +39,15 @@
<form autocomplete="off" onsubmit={handleSubmit}>
<div class="flex flex-col gap-6 place-items-center place-content-center">
<Heading>{$t('change_pin_code')}</Heading>
<PinCodeInput label={$t('current_pin_code')} bind:value={currentPinCode} tabindexStart={1} pinLength={6} />
<PinCodeInput label={$t('new_pin_code')} bind:value={newPinCode} tabindexStart={7} pinLength={6} />
<PinCodeInput label={$t('confirm_new_pin_code')} bind:value={confirmPinCode} tabindexStart={13} pinLength={6} />
<Field label={$t('current_pin_code')}>
<PinInput bind:value={currentPinCode} />
</Field>
<Field label={$t('new_pin_code')}>
<PinInput bind:value={newPinCode} />
</Field>
<Field label={$t('confirm_new_pin_code')}>
<PinInput bind:value={confirmPinCode} />
</Field>
<button type="button" onclick={() => modalManager.show(PinCodeResetModal, {})}>
<Text color="muted" class="underline" size="small">{$t('forgot_pin_code_question')}</Text>
</button>

View File

@@ -1,8 +1,7 @@
<script lang="ts">
import PinCodeInput from '$lib/components/user-settings-page/PinCodeInput.svelte';
import { handleError } from '$lib/utils/handle-error';
import { setupPinCode } from '@immich/sdk';
import { Button, Heading, toastManager } from '@immich/ui';
import { Button, Field, Heading, PinInput, toastManager } from '@immich/ui';
import { t } from 'svelte-i18n';
interface Props {
@@ -47,8 +46,12 @@
{#if showLabel}
<Heading>{$t('setup_pin_code')}</Heading>
{/if}
<PinCodeInput label={$t('new_pin_code')} bind:value={newPinCode} tabindexStart={1} pinLength={6} />
<PinCodeInput label={$t('confirm_new_pin_code')} bind:value={confirmPinCode} tabindexStart={7} pinLength={6} />
<Field label={$t('new_pin_code')}>
<PinInput bind:value={newPinCode} />
</Field>
<Field label={$t('confirm_new_pin_code')}>
<PinInput bind:value={confirmPinCode} />
</Field>
</div>
<div class="flex justify-end gap-2 mt-4">

View File

@@ -1,129 +0,0 @@
<script lang="ts">
import { Label } from '@immich/ui';
import { onMount } from 'svelte';
interface Props {
label: string;
value?: string;
pinLength?: number;
tabindexStart?: number;
autofocus?: boolean;
onFilled?: (value: string) => void;
type?: 'text' | 'password';
}
let {
label,
value = $bindable(''),
pinLength = 6,
tabindexStart = 0,
autofocus = false,
onFilled,
type = 'text',
}: Props = $props();
let pinValues = $state(Array.from({ length: pinLength }).fill(''));
let pinCodeInputElements: HTMLInputElement[] = $state([]);
$effect(() => {
if (value === '') {
pinValues = Array.from({ length: pinLength }).fill('');
}
});
onMount(() => {
if (autofocus) {
pinCodeInputElements[0]?.focus();
}
});
const focusNext = (index: number) => {
pinCodeInputElements[Math.min(index + 1, pinLength - 1)]?.focus();
};
const focusPrev = (index: number) => {
if (index > 0) {
pinCodeInputElements[index - 1]?.focus();
}
};
const handleInput = (event: Event, index: number) => {
const target = event.target as HTMLInputElement;
const digits = target.value.replaceAll(/\D/g, '').slice(0, pinLength - index);
if (digits.length === 0) {
pinValues[index] = '';
value = pinValues.join('').trim();
return;
}
for (let i = 0; i < digits.length; i++) {
pinValues[index + i] = digits[i];
}
value = pinValues.join('').trim();
const lastFilledIndex = Math.min(index + digits.length, pinLength - 1);
pinCodeInputElements[lastFilledIndex]?.focus();
if (value.length === pinLength) {
onFilled?.(value);
}
};
function handleKeydown(event: KeyboardEvent & { currentTarget: EventTarget & HTMLInputElement }) {
const target = event.currentTarget as HTMLInputElement;
const index = pinCodeInputElements.indexOf(target);
switch (event.key) {
case 'Tab': {
return;
}
case 'Backspace': {
if (target.value === '' && index > 0) {
focusPrev(index);
pinValues[index - 1] = '';
} else if (target.value !== '') {
pinValues[index] = '';
}
value = pinValues.join('').trim();
return;
}
case 'ArrowLeft': {
if (index > 0) {
focusPrev(index);
}
return;
}
case 'ArrowRight': {
if (index < pinLength - 1) {
focusNext(index);
}
return;
}
}
}
</script>
<div class="flex flex-col gap-1">
{#if label}
<Label for={pinCodeInputElements[0]?.id}>{label}</Label>
{/if}
<div class="flex gap-2">
{#each { length: pinLength } as _, index (index)}
<input
tabindex={tabindexStart + index}
{type}
inputmode="numeric"
pattern="[0-9]*"
bind:this={pinCodeInputElements[index]}
id="pin-code-{index}"
class="h-12 w-10 rounded-xl border-2 border-suble dark:border-gray-700 text-center text-lg font-medium focus:border-immich-primary focus:ring-primary dark:focus:border-primary font-mono bg-white dark:bg-light"
bind:value={pinValues[index]}
onkeydown={handleKeydown}
oninput={(event) => handleInput(event, index)}
aria-label={`PIN digit ${index + 1} of ${pinLength}${label ? ` for ${label}` : ''}`}
/>
{/each}
</div>
</div>

View File

@@ -2,11 +2,10 @@
import { goto } from '$app/navigation';
import AuthPageLayout from '$lib/components/layouts/AuthPageLayout.svelte';
import PinCodeCreateForm from '$lib/components/user-settings-page/PinCodeCreateForm.svelte';
import PincodeInput from '$lib/components/user-settings-page/PinCodeInput.svelte';
import { Route } from '$lib/route';
import { handleError } from '$lib/utils/handle-error';
import { unlockAuthSession } from '@immich/sdk';
import { Button, Icon } from '@immich/ui';
import { Button, Icon, PinInput } from '@immich/ui';
import { mdiLockOpenVariantOutline, mdiLockOutline, mdiLockSmart } from '@mdi/js';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
@@ -55,15 +54,7 @@
<p class="text-center text-sm" style="text-wrap: pretty;">{$t('enter_your_pin_code_subtitle')}</p>
<PincodeInput
type="password"
autofocus
label=""
bind:value={pinCode}
tabindexStart={1}
pinLength={6}
onFilled={handleUnlockSession}
/>
<PinInput password autofocus bind:value={pinCode} onComplete={handleUnlockSession} />
{:else}
<div class="text-primary">
<Icon icon={mdiLockSmart} size="64" />