Something went wrong. Try again.
Monorepo for Tangled
Something went wrong. Try again.
Svelte
at sl/gitmirror
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227<script module lang="ts"> import type { ResolvedPathname } from "$app/types";
export interface Label { key: string; name: string; color: string; href?: ResolvedPathname; }
const COLLAPSED_LABEL_COUNT = 10; const CREATE_PREFIX = "\0create:"; const EDIT_VALUE = "\0edit";
const labelColorFor = (name: string): string => { let hash = 0x811c9dc5; for (const char of name) hash = Math.imul(hash ^ char.codePointAt(0)!, 0x01000193); return `#${((hash >>> 0) & 0xffffff).toString(16).padStart(6, "0")}`; };</script>
<script lang="ts"> import { goto } from "$app/navigation"; import { resolve } from "$app/paths"; import Pencil from "$icon/pencil"; import TagPlus from "$icon/tag-plus"; import Plus from "$icon/plus"; import Button from "$lib/components/ui/Button.svelte"; import ColorBall from "$lib/components/ui/ColorBall.svelte"; import Link from "$lib/components/ui/Link.svelte"; import Combobox from "$lib/components/ui/Combobox.svelte"; import type { SelectOption } from "$lib/components/ui/selectField";
interface Props { direction?: "horizontal" | "vertical"; labels?: Label[]; canEdit?: boolean; allLabels?: Label[]; onLabelsChange?: (keys: string[]) => void; onCreateLabel?: (name: string, color: string) => void; settingsHref?: string; }
let { direction = "horizontal", labels = [], canEdit = false, allLabels = [], onLabelsChange, onCreateLabel, settingsHref }: Props = $props();
let expanded = $state(false);
const pickedLabels = $derived(labels.map((label) => label.key));
const hiddenCount = $derived(Math.max(0, labels.length - COLLAPSED_LABEL_COUNT));
const shownLabels = $derived( expanded || hiddenCount === 0 ? labels : labels.slice(0, COLLAPSED_LABEL_COUNT) );
let search = $state("");
const query = $derived(search.toLowerCase());
const matchingLabels = $derived( allLabels.filter((label) => label.name.toLowerCase().includes(query)) );
const canCreate = $derived( !!onCreateLabel && search !== "" && !allLabels.some((label) => label.name.toLowerCase() === query) );
const createColor = $derived(labelColorFor(search));
const labelOptions = $derived<SelectOption[]>([ ...matchingLabels.map((label) => ({ value: label.key, label: label.name })), ...(canCreate ? [{ value: `${CREATE_PREFIX}${search}`, label: search, icon: Plus }] : []), ...(settingsHref ? [ { value: EDIT_VALUE, label: "Edit labels", icon: Pencil, separator: !canCreate && matchingLabels.length > 0 } ] : []) ]);
const labelColors = $derived(new Map(allLabels.map((label) => [label.key, label.color])));
const vertical = $derived(direction === "vertical");
const baseId = $props.id(); const labelsHeadingId = `${baseId}-labels`;</script>
{#snippet sectionHeading(id: string, title: string)} <h3 {id} class="pr-xs typography-paragraph-small font-medium text-foreground-muted"> {title} </h3>{/snippet}
{#snippet labelBall(label: Label)} <ColorBall color={label.color} size="small" /> <span class="truncate typography-paragraph-regular">{label.name}</span>{/snippet}
{#snippet editLabels()} <Combobox multiple freezePosition align="right" options={labelOptions} values={pickedLabels} onSearch={(value) => (search = value)} onToggle={(key) => { if (key === EDIT_VALUE) { if (settingsHref) void goto(resolve(settingsHref as "/")); return; } if (key.startsWith(CREATE_PREFIX)) { onCreateLabel?.(key.slice(CREATE_PREFIX.length), createColor); return; } onLabelsChange?.( pickedLabels.includes(key) ? pickedLabels.filter((picked) => picked !== key) : [...pickedLabels, key] ); }} label="Labels" placeholder="Select labels" searchPlaceholder="Filter labels…" emptyLabel="No labels" class="w-auto" > {#snippet trigger({ props })} <div class="opacity-0 transition group-hover:opacity-100 has-focus-visible:opacity-100 has-aria-expanded:opacity-100" > <Button {...props} variant="ghost" size="small" icon={TagPlus} aria-label="Edit labels" /> </div> {/snippet} {#snippet option(choice)} {#if choice.value.startsWith(CREATE_PREFIX)} <span class="shrink-0">Create</span> <ColorBall color={createColor} size="small" /> <span class="min-w-0 flex-1 truncate">"{choice.label}"</span> {:else if choice.value === EDIT_VALUE} <span class="min-w-0 flex-1 truncate">{choice.label}</span> {:else} <ColorBall color={labelColors.get(choice.value) ?? ""} size="small" /> <span class="min-w-0 flex-1 truncate">{choice.label ?? choice.value}</span> {/if} {/snippet} </Combobox>{/snippet}
<div class="flex w-full flex-col items-start gap-md"> <section class="group flex w-full flex-col items-start gap-xs"> <div class="flex w-full items-center justify-between"> {@render sectionHeading(labelsHeadingId, "Labels")} {#if canEdit && vertical} {@render editLabels()} {/if} </div> <ul aria-labelledby={labelsHeadingId} class={[ "flex w-full gap-xs", vertical ? "flex-col items-start" : "flex-wrap items-center" ]} > {#each shownLabels as label (label.key)} <li class={["flex", vertical && "max-w-full"]}> {#if label.href} <Link href={label.href} variant="inherit" class="flex min-w-0 items-center gap-xs rounded-sm border border-border-default bg-background-default px-xs py-[3px] hover:bg-background-subtle hover:no-underline" > {@render labelBall(label)} </Link> {:else} <span class="flex min-w-0 items-center gap-xs rounded-sm border border-border-default bg-background-default px-xs py-[3px]" > {@render labelBall(label)} </span> {/if} </li> {:else} <li class="flex py-[3px] typography-paragraph-small text-foreground-muted"> No labels </li> {/each} {#if hiddenCount > 0 && !expanded} <li class="flex"> <Button variant="ghost" size="small" onclick={() => (expanded = true)} icon={TagPlus} > Display all {labels.length} labels </Button> </li> {/if} {#if canEdit && !vertical} {@render editLabels()} {/if} </ul> </section></div>