<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}