refactor: move components/elements to elements/ (#22091)

This commit is contained in:
Jason Rasmussen
2025-09-16 14:31:22 -04:00
committed by GitHub
parent 2bf484c91c
commit 7ce1d73c20
105 changed files with 157 additions and 158 deletions

View File

@@ -0,0 +1,18 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
rounded?: boolean | 'full';
children?: Snippet;
}
let { rounded = true, children }: Props = $props();
</script>
<span
class="bg-primary text-subtle inline-block h-min whitespace-nowrap px-3 py-1 text-center align-baseline text-xs leading-none"
class:rounded-md={rounded === true}
class:rounded-full={rounded === 'full'}
>
{@render children?.()}
</span>

View File

@@ -0,0 +1,37 @@
<script lang="ts">
interface Props {
type: 'date' | 'datetime-local';
value?: string;
min?: string;
max?: string;
class?: string;
id?: string;
name?: string;
placeholder?: string;
autofocus?: boolean;
onkeydown?: (e: KeyboardEvent) => void;
}
let { type, value = $bindable(), max = undefined, onkeydown, ...rest }: Props = $props();
let fallbackMax = $derived(type === 'date' ? '9999-12-31' : '9999-12-31T23:59');
// Updating `value` directly causes the date input to reset itself or
// interfere with user changes.
let updatedValue = $derived(value);
</script>
<input
{...rest}
{type}
bind:value
max={max || fallbackMax}
oninput={(e) => (updatedValue = e.currentTarget.value)}
onblur={() => (value = updatedValue)}
onkeydown={(e) => {
if (e.key === 'Enter') {
value = updatedValue;
}
onkeydown?.(e);
}}
/>

View File

@@ -0,0 +1,141 @@
<script lang="ts" module>
// Necessary for eslint
/* eslint-disable @typescript-eslint/no-explicit-any */
type T = any;
export type RenderedOption = {
title: string;
icon?: string;
disabled?: boolean;
};
</script>
<script lang="ts" generics="T">
import { clickOutside } from '$lib/actions/click-outside';
import { Button, Text } from '@immich/ui';
import { mdiCheck } from '@mdi/js';
import { isEqual } from 'lodash-es';
import { fly } from 'svelte/transition';
import Icon from './Icon.svelte';
interface Props {
class?: string;
options: T[];
selectedOption?: any;
showMenu?: boolean;
controlable?: boolean;
hideTextOnSmallScreen?: boolean;
title?: string | undefined;
position?: 'bottom-left' | 'bottom-right';
onSelect: (option: T) => void;
onClickOutside?: () => void;
render?: (item: T) => string | RenderedOption;
}
let {
position = 'bottom-left',
class: className = '',
options,
selectedOption = $bindable(options[0]),
showMenu = $bindable(false),
controlable = false,
hideTextOnSmallScreen = true,
title = undefined,
onSelect,
onClickOutside = () => {},
render = String,
}: Props = $props();
const handleClickOutside = () => {
if (!controlable) {
showMenu = false;
}
onClickOutside();
};
const handleSelectOption = (option: T) => {
onSelect(option);
selectedOption = option;
showMenu = false;
};
const renderOption = (option: T): RenderedOption => {
const renderedOption = render(option);
switch (typeof renderedOption) {
case 'string': {
return { title: renderedOption };
}
default: {
return {
title: renderedOption.title,
icon: renderedOption.icon,
disabled: renderedOption.disabled,
};
}
}
};
let renderedSelectedOption = $derived(renderOption(selectedOption));
const getAlignClass = (position: 'bottom-left' | 'bottom-right') => {
switch (position) {
case 'bottom-left': {
return 'start-0';
}
case 'bottom-right': {
return 'end-0';
}
default: {
return '';
}
}
};
</script>
<div use:clickOutside={{ onOutclick: handleClickOutside, onEscape: handleClickOutside }} class="relative">
<!-- BUTTON TITLE -->
<Button onclick={() => (showMenu = true)} fullWidth {title} variant="ghost" color="secondary" size="small">
{#if renderedSelectedOption?.icon}
<Icon path={renderedSelectedOption.icon} />
{/if}
<Text class={hideTextOnSmallScreen ? 'hidden sm:block' : ''}>{renderedSelectedOption.title}</Text>
</Button>
<!-- DROP DOWN MENU -->
{#if showMenu}
<div
transition:fly={{ y: -30, duration: 250 }}
class="text-sm font-medium z-1 absolute flex min-w-[250px] max-h-[70vh] overflow-y-auto immich-scrollbar flex-col rounded-2xl bg-gray-100 py-2 text-black shadow-lg dark:bg-gray-700 dark:text-white {className} {getAlignClass(
position,
)}"
>
{#each options as option (option)}
{@const renderedOption = renderOption(option)}
{@const buttonStyle = renderedOption.disabled ? '' : 'transition-all hover:bg-gray-300 dark:hover:bg-gray-800'}
<button
type="button"
class="grid grid-cols-[36px_1fr] place-items-center p-2 disabled:opacity-40 {buttonStyle}"
disabled={renderedOption.disabled}
onclick={() => !renderedOption.disabled && handleSelectOption(option)}
>
{#if isEqual(selectedOption, option)}
<div class="text-immich-primary dark:text-immich-dark-primary">
<Icon path={mdiCheck} />
</div>
<p class="justify-self-start text-immich-primary dark:text-immich-dark-primary">
{renderedOption.title}
</p>
{:else}
<div></div>
<p class="justify-self-start">
{renderedOption.title}
</p>
{/if}
</button>
{/each}
</div>
{/if}
</div>

View File

@@ -0,0 +1,52 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import { Duration } from 'luxon';
interface Props {
value: number;
class?: string;
id?: string;
}
let { value = $bindable(), class: className = '', ...rest }: Props = $props();
function minToParts(minutes: number) {
const duration = Duration.fromObject({ minutes: Math.abs(minutes) }).shiftTo('days', 'hours', 'minutes');
return {
sign: minutes < 0 ? -1 : 1,
days: duration.days === 0 ? null : duration.days,
hours: duration.hours === 0 ? null : duration.hours,
minutes: duration.minutes === 0 ? null : duration.minutes,
};
}
function partsToMin(sign: number, days: number | null, hours: number | null, minutes: number | null) {
return (
sign *
Duration.fromObject({ days: days ?? 0, hours: hours ?? 0, minutes: minutes ?? 0 }).shiftTo('minutes').minutes
);
}
const initial = minToParts(value);
let sign = $state(initial.sign);
let days = $state(initial.days);
let hours = $state(initial.hours);
let minutes = $state(initial.minutes);
$effect(() => {
value = partsToMin(sign, days, hours, minutes);
});
function toggleSign() {
sign = -sign;
}
</script>
<div class={`flex gap-2 ${className}`} {...rest}>
<button type="button" class="w-8 text-xl font-bold leading-none" onclick={toggleSign} title="Toggle sign">
{sign >= 0 ? '+' : '-'}
</button>
<input type="number" min="0" placeholder={$t('days')} class="w-1/3" bind:value={days} />
<input type="number" min="0" max="23" placeholder={$t('hours')} class="w-1/3" bind:value={hours} />
<input type="number" min="0" max="59" placeholder={$t('minutes')} class="w-1/3" bind:value={minutes} />
</div>

View File

@@ -0,0 +1,40 @@
<script lang="ts">
import { generateId } from '$lib/utils/generate-id';
interface Props {
filters: string[];
labels?: string[];
selected: string;
label: string;
onSelect: (selected: string) => void;
}
let { filters, selected, label, labels, onSelect }: Props = $props();
const id = `group-tab-${generateId()}`;
</script>
<fieldset
class="dark:bg-immich-dark-gray flex h-full rounded-2xl bg-gray-200 ring-gray-400 has-focus-visible:ring dark:ring-gray-600"
>
<legend class="sr-only">{label}</legend>
{#each filters as filter, index (`${id}-${index}`)}
<div class="group">
<input
type="radio"
name={id}
id="{id}-{index}"
class="peer sr-only"
value={filter}
checked={filter === selected}
onchange={() => onSelect(filter)}
/>
<label
for="{id}-{index}"
class="flex h-full cursor-pointer items-center px-4 text-sm hover:bg-gray-300 group-first-of-type:rounded-s-2xl group-last-of-type:rounded-e-2xl peer-checked:bg-gray-300 dark:hover:bg-gray-800 peer-checked:dark:bg-gray-700"
>
{labels?.[index] ?? filter}
</label>
</div>
{/each}
</fieldset>

View File

@@ -0,0 +1,60 @@
<script lang="ts">
import type { AriaRole } from 'svelte/elements';
interface Props {
size?: string | number;
color?: string;
path: string;
title?: string | null;
desc?: string;
flipped?: boolean;
class?: string;
viewBox?: string;
role?: AriaRole;
ariaHidden?: boolean | undefined;
ariaLabel?: string | undefined;
ariaLabelledby?: string | undefined;
strokeWidth?: number;
strokeColor?: string;
spin?: boolean;
}
let {
size = '1em',
color = 'currentColor',
path,
title = null,
desc = '',
flipped = false,
class: className = '',
viewBox = '0 0 24 24',
role = 'img',
ariaHidden = undefined,
ariaLabel = undefined,
ariaLabelledby = undefined,
strokeWidth = 0,
strokeColor = 'currentColor',
spin = false,
}: Props = $props();
</script>
<svg
width={size}
height={size}
{viewBox}
class="{className} {flipped ? '-scale-x-100' : ''} {spin ? 'animate-spin' : ''}"
{role}
stroke={strokeColor}
stroke-width={strokeWidth}
aria-label={ariaLabel}
aria-hidden={ariaHidden}
aria-labelledby={ariaLabelledby}
>
{#if title}
<title>{title}</title>
{/if}
{#if desc}
<desc>{desc}</desc>
{/if}
<path d={path} fill={color} />
</svg>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
interface Props {
id: string;
label: string;
name: string;
value: string;
group?: string | undefined;
}
let { id, label, name, value, group = $bindable(undefined) }: Props = $props();
</script>
<div class="flex items-center gap-2">
<input type="radio" {name} {id} {value} class="focus-visible:ring" bind:group />
<label for={id}>{label}</label>
</div>

View File

@@ -0,0 +1,80 @@
<script lang="ts">
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import type { SearchOptions } from '$lib/utils/dipatch';
import { IconButton } from '@immich/ui';
import { mdiClose, mdiMagnify } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
name: string;
roundedBottom?: boolean;
showLoadingSpinner: boolean;
placeholder: string;
onSearch?: (options: SearchOptions) => void;
onReset?: () => void;
}
let {
name = $bindable(),
roundedBottom = true,
showLoadingSpinner,
placeholder,
onSearch = () => {},
onReset = () => {},
}: Props = $props();
let inputRef = $state<HTMLElement>();
const resetSearch = () => {
name = '';
onReset();
inputRef?.focus();
};
const handleSearch = (event: KeyboardEvent) => {
if (event.key === 'Enter') {
onSearch({ force: true });
}
};
</script>
<div
class="flex items-center text-sm {roundedBottom
? 'rounded-2xl'
: 'rounded-t-lg'} bg-gray-200 p-2 dark:bg-immich-dark-gray gap-2 place-items-center h-full"
>
<IconButton
shape="round"
color="secondary"
variant="ghost"
icon={mdiMagnify}
aria-label={$t('search')}
size="small"
onclick={() => onSearch({ force: true })}
/>
<input
class="w-full gap-2 bg-gray-200 dark:bg-immich-dark-gray dark:text-white"
type="text"
{placeholder}
bind:value={name}
bind:this={inputRef}
onkeydown={handleSearch}
oninput={() => onSearch({ force: false })}
/>
{#if showLoadingSpinner}
<div class="flex place-items-center">
<LoadingSpinner />
</div>
{/if}
{#if name}
<IconButton
shape="round"
color="secondary"
variant="ghost"
icon={mdiClose}
aria-label={$t('clear_value')}
size="small"
onclick={resetSearch}
/>
{/if}
</div>

View File

@@ -0,0 +1,69 @@
<script lang="ts">
import { getTabbable } from '$lib/utils/focus-util';
import { Button } from '@immich/ui';
import { t } from 'svelte-i18n';
interface Props {
/**
* Target for the skip link to move focus to.
*/
target?: string;
/**
* Text for the skip link button.
*/
text?: string;
/**
* Breakpoint at which the skip link is visible. Defaults to always being visible.
*/
breakpoint?: 'sm' | 'md' | 'lg' | 'xl' | '2xl';
}
let { target = 'main', text = $t('skip_to_content'), breakpoint }: Props = $props();
let isFocused = $state(false);
const moveFocus = () => {
const targetEl = document.querySelector<HTMLElement>(target);
if (targetEl) {
const element = getTabbable(targetEl)[0];
if (element) {
element.focus();
}
}
};
const getBreakpoint = () => {
if (!breakpoint) {
return '';
}
switch (breakpoint) {
case 'sm': {
return 'hidden sm:block';
}
case 'md': {
return 'hidden md:block';
}
case 'lg': {
return 'hidden lg:block';
}
case 'xl': {
return 'hidden xl:block';
}
case '2xl': {
return 'hidden 2xl:block';
}
}
};
</script>
<div class="absolute top-2 start-2 transition-transform {isFocused ? 'translate-y-0' : '-translate-y-10 sr-only'}">
<Button
size="small"
onclick={moveFocus}
class={getBreakpoint()}
onfocus={() => (isFocused = true)}
onblur={() => (isFocused = false)}
>
{text}
</Button>
</div>