Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270<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 type { Snippet } from "svelte"; 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 Dropdown from "#lib/components/ui/Dropdown.svelte"; import DropdownItem from "#lib/components/ui/DropdownItem.svelte"; import LabelSummary, { labelChip } from "#lib/components/repo/LabelSummary.svelte"; import type { SelectOption } from "#lib/components/ui/selectField.ts"; import { plural } from "#lib/format.ts";
interface Props { labels?: Label[]; canEdit?: boolean; allLabels?: Label[]; onLabelsChange?: (keys: string[]) => void; onCreateLabel?: (name: string, color: string) => void; settingsHref?: string; summarize?: boolean; }
let { labels = [], canEdit = false, allLabels = [], onLabelsChange, onCreateLabel, settingsHref, summarize = false }: 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 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(face?: Snippet)} <Combobox multiple checkboxes freezePosition align="left" 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 })} {#if face} <button {...props} data-testid="editLabels" type="button" class="group/labels flex max-w-full min-w-0 cursor-pointer rounded-md text-left text-foreground-default focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-border-focus" aria-label={`Edit labels: ${labels.map((label) => label.name).join(", ")}`} > {@render face()} </button> {:else if labels.length === 0} <Button {...props} data-testid="editLabels" variant="ghost" size="small" icon={TagPlus}>Add label</Button > {:else} <Button {...props} data-testid="editLabels" variant="ghost" size="small" icon={TagPlus} aria-label="Edit labels" /> {/if} {/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}
{#snippet chip(label: Label)} {#if label.href} <Link href={label.href} variant="inherit" class={labelChip({ link: true })}> {@render labelBall(label)} </Link> {:else} <span class={labelChip()}>{@render labelBall(label)}</span> {/if}{/snippet}
{#if summarize} <div class="@container flex min-w-0 flex-1 items-center"> {#if canEdit && labels.length > 0} <LabelSummary {labels} addable> {#snippet trigger(face)} {@render editLabels(face)} {/snippet} </LabelSummary> {:else if canEdit} {@render editLabels()} {:else if labels.length > 0} <LabelSummary {labels}> {#snippet trigger(face)} <span data-fit="dots" class="flex min-w-0"> <Dropdown label="Labels" class="max-w-full min-w-0" triggerClass="group/labels max-w-full min-w-0 text-foreground-default" > {#snippet trigger()} {@render face()} {/snippet} {#each labels as label (label.key)} <DropdownItem href={label.href}> <ColorBall color={label.color} /> <span class="min-w-0 truncate">{label.name}</span> </DropdownItem> {/each} </Dropdown> </span> {/snippet} </LabelSummary> {/if} </div>{:else} <div class="flex w-full flex-col items-start gap-md"> <section class="flex w-full flex-col items-start gap-xs"> {@render sectionHeading(labelsHeadingId, "Labels")} <ul aria-labelledby={labelsHeadingId} class="flex w-full flex-wrap items-center gap-xs"> {#each shownLabels as label (label.key)} <li class="flex max-w-full">{@render chip(label)}</li> {:else} {#if !canEdit} <li class="flex py-0.75 typography-paragraph-small text-foreground-muted"> No labels </li> {/if} {/each} {#if hiddenCount > 0 && !expanded} <li class="flex"> <Button variant="ghost" size="small" onclick={() => (expanded = true)} icon={TagPlus} > Display all {labels.length} {plural(labels.length, "label", "labels")} </Button> </li> {/if} {#if canEdit} <li class="flex">{@render editLabels()}</li> {/if} </ul> </section> </div>{/if}