Brandon Wees a02e1f5e7c
chore(web): migrate CircleIconButton to @immich/ui IconButton (#18486)
* remove import and referenced file

* first pass at replacing all CircleIconButtons

* fix linting issues

* fix combobox formatting issues

* fix button context menu coloring

* remove circle icon button from search history box

* use theme switcher from UI lib

* dark mode force the asset viewer icons

* fix forced dark mode icons

* dark mode memory viewer icons

* fix: back button in memory viewer

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-06-02 14:47:23 +00:00

206 lines
5.1 KiB
Svelte

<script lang="ts">
import { contextMenuNavigation } from '$lib/actions/context-menu-navigation';
import { shortcuts } from '$lib/actions/shortcut';
import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte';
import { languageManager } from '$lib/managers/language-manager.svelte';
import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store';
import {
getContextMenuPositionFromBoundingRect,
getContextMenuPositionFromEvent,
type Align,
} from '$lib/utils/context-menu';
import { generateId } from '$lib/utils/generate-id';
import { IconButton, type Color, type Size, type Variants } from '@immich/ui';
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
type Props = {
icon: string;
title: string;
/**
* The alignment of the context menu relative to the button.
*/
align?: Align;
/**
* The direction in which the context menu should open.
*/
// TODO change to start vs end
direction?: 'left' | 'right';
color?: Color;
size?: Size | undefined;
variant?: Variants | undefined;
/**
* Additional classes to apply to the button.
*/
buttonClass?: string | undefined;
hideContent?: boolean;
children?: Snippet;
offset?: {
x: number;
y: number;
};
} & HTMLAttributes<HTMLDivElement>;
let {
icon,
title,
align = 'top-left',
direction = 'right',
color = 'secondary',
size = undefined,
variant = 'ghost',
buttonClass = undefined,
hideContent = false,
children,
offset,
...restProps
}: Props = $props();
let isOpen = $state(false);
let contextMenuPosition = $state({ x: 0, y: 0 });
let menuContainer: HTMLUListElement | undefined = $state();
let buttonContainer: HTMLDivElement | undefined = $state();
const id = generateId();
const buttonId = `context-menu-button-${id}`;
const menuId = `context-menu-${id}`;
const openDropdown = (event: KeyboardEvent | MouseEvent) => {
let layoutAlign = align;
if (languageManager.rtl) {
if (align.includes('left')) {
layoutAlign = align.replace('left', 'right') as Align;
} else if (align.includes('right')) {
layoutAlign = align.replace('right', 'left') as Align;
}
}
contextMenuPosition = getContextMenuPositionFromEvent(event, layoutAlign);
isOpen = true;
menuContainer?.focus();
};
const handleClick = (event: MouseEvent) => {
if (isOpen) {
closeDropdown();
return;
}
openDropdown(event);
};
const onEscape = (event: KeyboardEvent) => {
if (isOpen) {
// if the dropdown is open, stop the event from propagating
event.stopPropagation();
}
closeDropdown();
};
const onResize = () => {
if (!isOpen || !buttonContainer) {
return;
}
contextMenuPosition = getContextMenuPositionFromBoundingRect(buttonContainer.getBoundingClientRect(), align);
};
const closeDropdown = () => {
if (!isOpen) {
return;
}
focusButton();
isOpen = false;
$selectedIdStore = undefined;
};
const handleOptionClick = () => {
closeDropdown();
};
const handleDocumentClick = (event: MouseEvent) => {
if (!isOpen) {
return;
}
const target = event.target as Node | null;
if (buttonContainer?.contains(target)) {
return;
}
closeDropdown();
};
const focusButton = () => {
const button = buttonContainer?.querySelector(`#${buttonId}`) as HTMLButtonElement | null;
button?.focus();
};
$effect(() => {
if (isOpen) {
$optionClickCallbackStore = handleOptionClick;
}
});
</script>
<svelte:window onresize={onResize} />
<svelte:document onclick={handleDocumentClick} />
<div
use:contextMenuNavigation={{
closeDropdown,
container: menuContainer,
isOpen,
onEscape,
openDropdown,
selectedId: $selectedIdStore,
selectionChanged: (id) => ($selectedIdStore = id),
}}
onresize={onResize}
{...restProps}
>
<div bind:this={buttonContainer}>
<IconButton
{color}
{icon}
{size}
shape="round"
{variant}
aria-label={title}
aria-controls={menuId}
aria-expanded={isOpen}
aria-haspopup={true}
class={buttonClass}
id={buttonId}
onclick={handleClick}
/>
</div>
{#if isOpen || !hideContent}
<div
use:shortcuts={[
{
shortcut: { key: 'Tab' },
onShortcut: closeDropdown,
preventDefault: false,
},
{
shortcut: { key: 'Tab', shift: true },
onShortcut: closeDropdown,
preventDefault: false,
},
]}
>
<ContextMenu
{direction}
ariaActiveDescendant={$selectedIdStore}
ariaLabelledBy={buttonId}
bind:menuElement={menuContainer}
id={menuId}
isVisible={isOpen}
x={contextMenuPosition.x - (offset?.x ?? 0)}
y={contextMenuPosition.y + (offset?.y ?? 0)}
>
{@render children?.()}
</ContextMenu>
</div>
{/if}
</div>