refactor: Componentize HorizontalScrollCarousel

This commit is contained in:
Aleksander Grygier 2026-01-28 17:32:59 +01:00
parent 1bf2cb751b
commit 0894c1fbb6
2 changed files with 70 additions and 123 deletions

View File

@ -2,14 +2,15 @@
import { import {
ChatAttachmentMcpPrompt, ChatAttachmentMcpPrompt,
ChatAttachmentThumbnailImage, ChatAttachmentThumbnailImage,
ChatAttachmentThumbnailFile ChatAttachmentThumbnailFile,
HorizontalScrollCarousel,
DialogChatAttachmentPreview,
DialogChatAttachmentsViewAll
} from '$lib/components/app'; } from '$lib/components/app';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
import { DialogChatAttachmentPreview, DialogChatAttachmentsViewAll } from '$lib/components/app';
import { getAttachmentDisplayItems } from '$lib/utils';
import { AttachmentType } from '$lib/enums'; import { AttachmentType } from '$lib/enums';
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types'; import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
import { getAttachmentDisplayItems } from '$lib/utils';
interface Props { interface Props {
class?: string; class?: string;
@ -47,12 +48,10 @@
let displayItems = $derived(getAttachmentDisplayItems({ uploadedFiles, attachments })); let displayItems = $derived(getAttachmentDisplayItems({ uploadedFiles, attachments }));
let canScrollLeft = $state(false); let carouselRef: HorizontalScrollCarousel | undefined = $state();
let canScrollRight = $state(false);
let isScrollable = $state(false); let isScrollable = $state(false);
let previewDialogOpen = $state(false); let previewDialogOpen = $state(false);
let previewItem = $state<ChatAttachmentPreviewItem | null>(null); let previewItem = $state<ChatAttachmentPreviewItem | null>(null);
let scrollContainer: HTMLDivElement | undefined = $state();
let showViewAll = $derived(limitToSingleRow && displayItems.length > 0 && isScrollable); let showViewAll = $derived(limitToSingleRow && displayItems.length > 0 && isScrollable);
let viewAllDialogOpen = $state(false); let viewAllDialogOpen = $state(false);
@ -71,41 +70,9 @@
previewDialogOpen = true; previewDialogOpen = true;
} }
function scrollLeft(event?: MouseEvent) {
event?.stopPropagation();
event?.preventDefault();
if (!scrollContainer) return;
scrollContainer.scrollBy({ left: scrollContainer.clientWidth * -0.67, behavior: 'smooth' });
}
function scrollRight(event?: MouseEvent) {
event?.stopPropagation();
event?.preventDefault();
if (!scrollContainer) return;
scrollContainer.scrollBy({ left: scrollContainer.clientWidth * 0.67, behavior: 'smooth' });
}
function updateScrollButtons() {
if (!scrollContainer) return;
const { scrollLeft, scrollWidth, clientWidth } = scrollContainer;
canScrollLeft = scrollLeft > 0;
canScrollRight = scrollLeft < scrollWidth - clientWidth - 1;
isScrollable = scrollWidth > clientWidth;
}
$effect(() => { $effect(() => {
if (scrollContainer && displayItems.length) { if (carouselRef && displayItems.length) {
scrollContainer.scrollLeft = 0; carouselRef.resetScroll();
setTimeout(() => {
updateScrollButtons();
}, 0);
} }
}); });
</script> </script>
@ -113,93 +80,66 @@
{#if displayItems.length > 0} {#if displayItems.length > 0}
<div class={className} {style}> <div class={className} {style}>
{#if limitToSingleRow} {#if limitToSingleRow}
<div class="relative"> <HorizontalScrollCarousel
<button bind:this={carouselRef}
class="absolute top-1/2 left-4 z-10 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full bg-foreground/15 shadow-md backdrop-blur-xs transition-opacity hover:bg-foreground/35 {canScrollLeft onScrollableChange={(scrollable) => (isScrollable = scrollable)}
? 'opacity-100' >
: 'pointer-events-none opacity-0'}" {#each displayItems as item (item.id)}
onclick={scrollLeft} {#if item.isMcpPrompt}
aria-label="Scroll left" {@const mcpPrompt =
> item.attachment?.type === AttachmentType.MCP_PROMPT
<ChevronLeft class="h-4 w-4" /> ? (item.attachment as DatabaseMessageExtraMcpPrompt)
</button> : item.uploadedFile?.mcpPrompt
? {
<div type: AttachmentType.MCP_PROMPT as const,
class="scrollbar-hide flex items-start gap-3 overflow-x-auto" name: item.name,
bind:this={scrollContainer} serverName: item.uploadedFile.mcpPrompt.serverName,
onscroll={updateScrollButtons} promptName: item.uploadedFile.mcpPrompt.promptName,
> content: item.textContent ?? '',
{#each displayItems as item (item.id)} arguments: item.uploadedFile.mcpPrompt.arguments
{#if item.isMcpPrompt} }
{@const mcpPrompt = : null}
item.attachment?.type === AttachmentType.MCP_PROMPT {#if mcpPrompt}
? (item.attachment as DatabaseMessageExtraMcpPrompt) <ChatAttachmentMcpPrompt
: item.uploadedFile?.mcpPrompt class="max-w-[300px] min-w-[200px] flex-shrink-0 {limitToSingleRow
? {
type: AttachmentType.MCP_PROMPT as const,
name: item.name,
serverName: item.uploadedFile.mcpPrompt.serverName,
promptName: item.uploadedFile.mcpPrompt.promptName,
content: item.textContent ?? '',
arguments: item.uploadedFile.mcpPrompt.arguments
}
: null}
{#if mcpPrompt}
<ChatAttachmentMcpPrompt
class="max-w-[300px] min-w-[200px] flex-shrink-0 {limitToSingleRow
? 'first:ml-4 last:mr-4'
: ''}"
prompt={mcpPrompt}
{readonly}
isLoading={item.isLoading}
loadError={item.loadError}
onRemove={onFileRemove ? () => onFileRemove(item.id) : undefined}
/>
{/if}
{:else if item.isImage && item.preview}
<ChatAttachmentThumbnailImage
class="flex-shrink-0 cursor-pointer {limitToSingleRow
? 'first:ml-4 last:mr-4' ? 'first:ml-4 last:mr-4'
: ''}" : ''}"
id={item.id} prompt={mcpPrompt}
name={item.name}
preview={item.preview}
{readonly} {readonly}
onRemove={onFileRemove} isLoading={item.isLoading}
height={imageHeight} loadError={item.loadError}
width={imageWidth} onRemove={onFileRemove ? () => onFileRemove(item.id) : undefined}
{imageClass}
onClick={(event) => openPreview(item, event)}
/>
{:else}
<ChatAttachmentThumbnailFile
class="flex-shrink-0 cursor-pointer {limitToSingleRow
? 'first:ml-4 last:mr-4'
: ''}"
id={item.id}
name={item.name}
size={item.size}
{readonly}
onRemove={onFileRemove}
textContent={item.textContent}
attachment={item.attachment}
uploadedFile={item.uploadedFile}
onClick={(event) => openPreview(item, event)}
/> />
{/if} {/if}
{/each} {:else if item.isImage && item.preview}
</div> <ChatAttachmentThumbnailImage
class="flex-shrink-0 cursor-pointer {limitToSingleRow ? 'first:ml-4 last:mr-4' : ''}"
<button id={item.id}
class="absolute top-1/2 right-4 z-10 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full bg-foreground/15 shadow-md backdrop-blur-xs transition-opacity hover:bg-foreground/35 {canScrollRight name={item.name}
? 'opacity-100' preview={item.preview}
: 'pointer-events-none opacity-0'}" {readonly}
onclick={scrollRight} onRemove={onFileRemove}
aria-label="Scroll right" height={imageHeight}
> width={imageWidth}
<ChevronRight class="h-4 w-4" /> {imageClass}
</button> onClick={(event) => openPreview(item, event)}
</div> />
{:else}
<ChatAttachmentThumbnailFile
class="flex-shrink-0 cursor-pointer {limitToSingleRow ? 'first:ml-4 last:mr-4' : ''}"
id={item.id}
name={item.name}
size={item.size}
{readonly}
onRemove={onFileRemove}
textContent={item.textContent}
attachment={item.attachment}
uploadedFile={item.uploadedFile}
onClick={(event) => openPreview(item, event)}
/>
{/if}
{/each}
</HorizontalScrollCarousel>
{#if showViewAll} {#if showViewAll}
<div class="mt-2 -mr-2 flex justify-end px-4"> <div class="mt-2 -mr-2 flex justify-end px-4">

View File

@ -28,3 +28,10 @@ export { default as TruncatedText } from './TruncatedText.svelte';
* - Mode-specific UI (export vs import) * - Mode-specific UI (export vs import)
*/ */
export { default as ConversationSelection } from './ConversationSelection.svelte'; export { default as ConversationSelection } from './ConversationSelection.svelte';
/**
* Horizontal scrollable carousel with navigation arrows.
* Used for displaying items in a horizontally scrollable container
* with left/right navigation buttons that appear on hover.
*/
export { default as HorizontalScrollCarousel } from './HorizontalScrollCarousel.svelte';