212 lines
7.5 KiB
Svelte
212 lines
7.5 KiB
Svelte
<script lang="ts">
|
|
import Archive from "@lucide/svelte/icons/archive";
|
|
import CalendarDays from "@lucide/svelte/icons/calendar-days";
|
|
import Repeat from "@lucide/svelte/icons/repeat";
|
|
import { dueLabel, dueTone } from "../lib/due";
|
|
import { anchorDate, isOpen } from "../model/types";
|
|
import type { Task } from "../model/types";
|
|
import { draggable, dropZone } from "../lib/dnd.svelte";
|
|
import { labelHue } from "../lib/hue";
|
|
import { cn } from "../lib/utils";
|
|
import { STATUS_META } from "../lib/vocab";
|
|
import { setStatus, setText } from "../vault/mutate";
|
|
import ContextArea from "./context-area.svelte";
|
|
import PriorityBars from "./priority-bars.svelte";
|
|
import RowMenu from "./row-menu.svelte";
|
|
import { dropTarget, handleDrop } from "./drop";
|
|
import { followLink, markdown } from "./markdown";
|
|
import { taskMenu } from "./task-menu";
|
|
import { useView } from "./context";
|
|
|
|
interface Props {
|
|
showProject?: boolean;
|
|
showSphere?: boolean;
|
|
task: Task;
|
|
}
|
|
|
|
let { task, showSphere = true, showProject = true }: Props = $props();
|
|
|
|
const view = useView();
|
|
|
|
let editing = $state(false);
|
|
let draft = $state("");
|
|
|
|
const meta = $derived(STATUS_META[task.status]);
|
|
const target = $derived(dropTarget.task(task.id));
|
|
const zone = $derived(dropZone());
|
|
const isDropTarget = $derived(zone?.target === target);
|
|
const date = $derived(anchorDate(task));
|
|
const tone = $derived(dueTone(date, view.store.today));
|
|
const label = $derived(dueLabel(date, view.store.today));
|
|
/** The editor hangs off the chip that opened it, not the whole row. */
|
|
const chip = (field: string): string => `${field}:${task.id}`;
|
|
const on = $derived(view.store.cursorTask === task.id);
|
|
const here = $derived(on && view.store.keyboard);
|
|
|
|
function startEdit() {
|
|
draft = task.text;
|
|
editing = true;
|
|
}
|
|
|
|
async function commitEdit() {
|
|
if (!editing) return;
|
|
editing = false;
|
|
await setText(view.app, task, draft);
|
|
}
|
|
|
|
function onkeydown(event: KeyboardEvent) {
|
|
if (event.key === "Enter") {
|
|
event.preventDefault();
|
|
void commitEdit();
|
|
} else if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
editing = false;
|
|
}
|
|
}
|
|
|
|
async function toggle() {
|
|
await setStatus(view.app, task, isOpen(task.status) ? "x" : " ");
|
|
}
|
|
</script>
|
|
|
|
<ContextArea class="contents" items={taskMenu(view, task)}>
|
|
<!-- scroll-mt clears the sticky group header when the cursor scrolls a row
|
|
into view, which would otherwise land it half underneath. -->
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div
|
|
class={cn(
|
|
"group/task grid scroll-mt-8 select-none items-center gap-2 border-border/60 border-b px-3 transition-colors hover:bg-secondary/50",
|
|
!isOpen(task.status) && "opacity-60 hover:opacity-100",
|
|
here && "bg-secondary/60 opacity-100 inset-ring-1 inset-ring-ring",
|
|
isDropTarget &&
|
|
(zone?.after
|
|
? "shadow-[inset_0_-2px_0_0_var(--signal)]"
|
|
: "shadow-[inset_0_2px_0_0_var(--signal)]"),
|
|
)}
|
|
data-cursor={on}
|
|
data-drop={target}
|
|
onpointerdown={() => {
|
|
view.store.cursorTask = task.id;
|
|
}}
|
|
style="grid-template-columns: 1.25rem minmax(0, 1fr) auto;"
|
|
use:draggable={{
|
|
payload: () => ({ id: task.id, kind: "task" }),
|
|
ondrop: (to, after) => void handleDrop(view, task, to, after),
|
|
enabled: () => !editing,
|
|
}}
|
|
>
|
|
<button
|
|
aria-label={`Status: ${meta.label}`}
|
|
class="grid size-5 place-items-center rounded-md outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
data-nodrag
|
|
onclick={toggle}
|
|
title={meta.label}
|
|
type="button"
|
|
>
|
|
<meta.icon aria-hidden="true" class="size-4" style="color: {meta.color}" />
|
|
</button>
|
|
|
|
<div class="flex min-w-0 items-center gap-1.5 py-2.5">
|
|
{#if showSphere}
|
|
<span
|
|
class="hidden shrink-0 rounded-md border border-border bg-secondary/40 px-1.5 py-0.5 font-medium text-[11px] text-muted-foreground @min-[30rem]:inline-block"
|
|
>
|
|
{task.sphere}
|
|
</span>
|
|
{/if}
|
|
{#if task.inArchive}
|
|
<span
|
|
class="hidden shrink-0 items-center gap-1 rounded-md border border-border border-dashed px-1.5 py-0.5 text-[11px] text-muted-foreground @min-[24rem]:inline-flex"
|
|
title={task.project}
|
|
>
|
|
<Archive aria-hidden="true" class="size-3" />
|
|
{task.project}
|
|
</span>
|
|
{:else if showProject}
|
|
<button
|
|
aria-label="Project"
|
|
class="hue-chip hidden max-w-32 shrink-0 truncate rounded-md border px-1.5 py-0.5 text-[11px] outline-none transition-[filter] hover:brightness-110 focus-visible:ring-2 focus-visible:ring-ring @min-[24rem]:inline-block"
|
|
data-edit={chip("project")}
|
|
data-nodrag
|
|
onclick={() =>
|
|
view.store.edit(task.id, "project", `[data-edit="${chip("project")}"]`)}
|
|
style="--hue: {labelHue(task.project)}"
|
|
type="button"
|
|
>
|
|
{task.project}
|
|
</button>
|
|
{/if}
|
|
|
|
{#if editing}
|
|
<!-- svelte-ignore a11y_autofocus -->
|
|
<input
|
|
autofocus
|
|
class="min-w-0 flex-1 rounded-md border border-ring bg-background px-1.5 py-0.5 text-sm outline-none"
|
|
onblur={commitEdit}
|
|
{onkeydown}
|
|
bind:value={draft}
|
|
/>
|
|
{:else}
|
|
<div
|
|
class={cn(
|
|
"bcal-md min-w-0 flex-1 cursor-text truncate font-medium text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
!isOpen(task.status) && "line-through decoration-muted-foreground/50",
|
|
)}
|
|
onclick={(event) => {
|
|
if (!followLink(view.app, event, task.path)) startEdit();
|
|
}}
|
|
onkeydown={(event) => {
|
|
if (event.key === "Enter") startEdit();
|
|
}}
|
|
role="button"
|
|
tabindex="0"
|
|
title={task.text}
|
|
use:markdown={{
|
|
app: view.app,
|
|
component: view.component,
|
|
sourcePath: task.path,
|
|
text: task.text,
|
|
}}
|
|
></div>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="flex items-center gap-1">
|
|
{#if task.priority !== 2}
|
|
<PriorityBars class="shrink-0" priority={task.priority} />
|
|
{/if}
|
|
<div class="flex justify-end @min-[22rem]:w-24">
|
|
<button
|
|
aria-label={task.recurrence ? "Date and repeat" : "Date"}
|
|
class={cn(
|
|
"inline-flex h-6 items-center gap-1 rounded-md px-1 text-xs outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring",
|
|
tone === "overdue" && "text-destructive",
|
|
tone === "soon" && "text-status-snooze",
|
|
tone === "none" && "text-muted-foreground",
|
|
!date &&
|
|
"opacity-0 transition-opacity group-hover/task:opacity-100 focus-visible:opacity-100 touch-shown",
|
|
)}
|
|
data-edit={chip("date")}
|
|
data-nodrag
|
|
onclick={() =>
|
|
view.store.edit(task.id, "date", `[data-edit="${chip("date")}"]`)}
|
|
type="button"
|
|
>
|
|
{#if task.recurrence}
|
|
<Repeat aria-hidden="true" class="size-3" />
|
|
{:else}
|
|
<CalendarDays aria-hidden="true" class="size-3" />
|
|
{/if}
|
|
{#if label}
|
|
<span class="tabular">{label}</span>
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
<RowMenu
|
|
class="opacity-0 transition-opacity focus-visible:opacity-100 group-hover/task:opacity-100 touch-shown"
|
|
items={taskMenu(view, task)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</ContextArea>
|