Compare commits

...

2 Commits

Author SHA1 Message Date
Julien Calixte
03e7185abe fix(todo): rebuild priority dropdown with controlled state
Some checks failed
CI / verify (push) Failing after 7s
The DaisyUI <details>/<summary> dropdown didn't toggle reliably in Firefox
(summary + inline-flex badge), and :focus-within left the wrong row open
when TransitionGroup reordered tasks on sort. Replace with a Vue-controlled
ref + onClickOutside, render options as colored badge buttons (no menu
hover underline), and lift the widget by z-index: 1 only while open so the
absolute menu paints above the next row.
2026-06-13 21:42:09 +02:00
Julien Calixte
5de84f135f feat(todo): add priority filter and stack filter rows inline
Adds a Priorities filter column with toggleable (A)/(B)/(C)/(D)/no-priority
chips. Reflows the filter section from a two-column grid into single-line
strips so each filter type (Priorities · Projects · Contexts) reads on one
row with a fixed-width label and wrap-only-on-overflow chips.
2026-06-13 21:42:04 +02:00
2 changed files with 178 additions and 40 deletions

View File

@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { onClickOutside } from "@vueuse/core"
import { computed, nextTick, ref, useTemplateRef, watch } from "vue" import { computed, nextTick, ref, useTemplateRef, watch } from "vue"
import { import {
@@ -23,8 +24,8 @@ const segments = computed(() => segmentBody(props.task.body))
const PRIORITIES = ["A", "B", "C", "D"] as const const PRIORITIES = ["A", "B", "C", "D"] as const
const priorityBadgeClass = computed(() => { const priorityBadgeClassFor = (priority?: string): string => {
switch (props.task.priority) { switch (priority) {
case "A": case "A":
return "badge badge-soft badge-error" return "badge badge-soft badge-error"
case "B": case "B":
@@ -34,17 +35,32 @@ const priorityBadgeClass = computed(() => {
default: default:
return "badge badge-soft badge-ghost" return "badge badge-soft badge-ghost"
} }
}) }
const priorityBadgeClass = computed(() =>
priorityBadgeClassFor(props.task.priority)
)
const toggle = () => { const toggle = () => {
if (!props.canEdit) return if (!props.canEdit) return
emit("update", toggleCompleted(props.task)) emit("update", toggleCompleted(props.task))
} }
const priorityOpen = ref(false)
const priorityDropdown = useTemplateRef<HTMLDivElement>("priorityDropdown")
onClickOutside(priorityDropdown, () => {
priorityOpen.value = false
})
const togglePriorityMenu = () => {
if (!props.canEdit) return
priorityOpen.value = !priorityOpen.value
}
const setPriority = (priority: string | undefined) => { const setPriority = (priority: string | undefined) => {
if (!props.canEdit) return if (!props.canEdit) return
emit("update", { ...props.task, priority }) emit("update", { ...props.task, priority })
closeDropdown() priorityOpen.value = false
} }
const removeToken = (kind: "project" | "context", value: string) => { const removeToken = (kind: "project" | "context", value: string) => {
@@ -174,11 +190,6 @@ watch(
if (editing.value) editing.value = false if (editing.value) editing.value = false
} }
) )
const dropdownRef = useTemplateRef<HTMLDetailsElement>("priorityDropdown")
const closeDropdown = () => {
if (dropdownRef.value) dropdownRef.value.open = false
}
</script> </script>
<template> <template>
@@ -194,26 +205,44 @@ const closeDropdown = () => {
@change="toggle" @change="toggle"
/> />
<details ref="priorityDropdown" class="dropdown dropdown-bottom"> <div
<summary ref="priorityDropdown"
class="todo-priority"
:class="{ 'todo-priority--open': priorityOpen }"
>
<button
type="button"
:class="priorityBadgeClass" :class="priorityBadgeClass"
class="cursor-pointer list-none w-7 justify-center mt-0.5" class="todo-priority-trigger cursor-pointer w-7 justify-center mt-0.5"
:tabindex="canEdit ? 0 : -1" :disabled="!canEdit"
@click="togglePriorityMenu"
> >
{{ task.priority ?? "—" }} {{ task.priority ?? "—" }}
</summary> </button>
<ul <div
v-if="canEdit" v-if="canEdit && priorityOpen"
class="dropdown-content menu bg-base-200 rounded-box z-10 shadow p-1 mt-1" class="todo-priority-menu bg-base-200 rounded-box shadow p-2"
> >
<li v-for="p in PRIORITIES" :key="p"> <button
<a @click="setPriority(p)">({{ p }})</a> v-for="p in PRIORITIES"
</li> :key="p"
<li> type="button"
<a @click="setPriority(undefined)">none</a> :class="priorityBadgeClassFor(p)"
</li> class="todo-priority-option w-10 justify-center cursor-pointer"
</ul> @click="setPriority(p)"
</details> >
({{ p }})
</button>
<button
type="button"
:class="priorityBadgeClassFor(undefined)"
class="todo-priority-option w-10 justify-center cursor-pointer"
@click="setPriority(undefined)"
>
</button>
</div>
</div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<input <input
@@ -445,6 +474,51 @@ const closeDropdown = () => {
word-break: break-word; word-break: break-word;
} }
.todo-priority {
position: relative;
display: inline-flex;
}
// Lift the whole priority widget above sibling rows while its menu is open,
// otherwise the absolutely-positioned menu paints behind row N+1's content.
// 1 is enough: no other element in this view sets a non-auto z-index.
.todo-priority--open {
z-index: 1;
}
.todo-priority-trigger {
border: 0;
}
.todo-priority-menu {
position: absolute;
top: 100%;
left: 0;
margin-top: 0.25rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: auto;
}
.todo-priority-option {
border: 0;
font: inherit;
transition:
transform 120ms ease,
filter 120ms ease;
&:hover {
filter: brightness(1.1);
transform: scale(1.05);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
}
.todo-chip { .todo-chip {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;

View File

@@ -89,8 +89,23 @@ const allContexts = computed(() => {
return Array.from(set).sort() return Array.from(set).sort()
}) })
// "none" represents tasks without a priority.
const allPriorities = computed<Array<string | "none">>(() => {
const set = new Set<string | "none">()
items.value.forEach((line) => {
if (isBlank(line)) return
set.add(line.priority ?? "none")
})
return Array.from(set).sort((a, b) => {
if (a === "none") return 1
if (b === "none") return -1
return a.localeCompare(b)
})
})
const activeProjects = ref<Set<string>>(new Set()) const activeProjects = ref<Set<string>>(new Set())
const activeContexts = ref<Set<string>>(new Set()) const activeContexts = ref<Set<string>>(new Set())
const activePriorities = ref<Set<string | "none">>(new Set())
const toggleProject = (p: string) => { const toggleProject = (p: string) => {
const next = new Set(activeProjects.value) const next = new Set(activeProjects.value)
@@ -106,15 +121,39 @@ const toggleContext = (c: string) => {
activeContexts.value = next activeContexts.value = next
} }
const togglePriority = (p: string | "none") => {
const next = new Set(activePriorities.value)
if (next.has(p)) next.delete(p)
else next.add(p)
activePriorities.value = next
}
const clearFilters = () => { const clearFilters = () => {
activeProjects.value = new Set() activeProjects.value = new Set()
activeContexts.value = new Set() activeContexts.value = new Set()
activePriorities.value = new Set()
} }
const hasFilters = computed( const hasFilters = computed(
() => activeProjects.value.size > 0 || activeContexts.value.size > 0 () =>
activeProjects.value.size > 0 ||
activeContexts.value.size > 0 ||
activePriorities.value.size > 0
) )
const priorityBadgeFilterClass = (p: string | "none"): string => {
switch (p) {
case "A":
return "badge-error"
case "B":
return "badge-warning"
case "C":
return "badge-info"
default:
return "badge-ghost"
}
}
const filteredEntries = computed(() => { const filteredEntries = computed(() => {
if (!hasFilters.value) return taskEntries.value if (!hasFilters.value) return taskEntries.value
return taskEntries.value.filter(({ line }) => { return taskEntries.value.filter(({ line }) => {
@@ -126,7 +165,10 @@ const filteredEntries = computed(() => {
const contextsOk = const contextsOk =
activeContexts.value.size === 0 || activeContexts.value.size === 0 ||
taskContexts.some((c) => activeContexts.value.has(c)) taskContexts.some((c) => activeContexts.value.has(c))
return projectsOk && contextsOk const prioritiesOk =
activePriorities.value.size === 0 ||
activePriorities.value.has(line.priority ?? "none")
return projectsOk && contextsOk && prioritiesOk
}) })
}) })
@@ -229,10 +271,33 @@ const createTodoFile = async () => {
</form> </form>
<div <div
v-if="allProjects.length || allContexts.length" v-if="
allProjects.length || allContexts.length || allPriorities.length
"
class="todo-filters" class="todo-filters"
> >
<div class="todo-filter-columns"> <div class="todo-filter-columns">
<section
v-if="allPriorities.length"
class="todo-filter-column"
>
<h4 class="todo-filter-label">Priorities</h4>
<div class="todo-filter-chips">
<button
v-for="p in allPriorities"
:key="`pri:${p}`"
type="button"
class="todo-filter-chip badge"
:class="[
priorityBadgeFilterClass(p),
{ 'badge-soft': !activePriorities.has(p) }
]"
@click="togglePriority(p)"
>
{{ p === "none" ? "—" : `(${p})` }}
</button>
</div>
</section>
<section <section
v-if="allProjects.length" v-if="allProjects.length"
class="todo-filter-column" class="todo-filter-column"
@@ -370,41 +435,40 @@ const createTodoFile = async () => {
} }
.todo-filter-columns { .todo-filter-columns {
display: grid; display: flex;
grid-template-columns: 1fr 1fr; flex-direction: column;
gap: 1rem; gap: 0.35rem;
} }
.todo-filter-column { .todo-filter-column {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
min-width: 0; min-width: 0;
text-align: center;
} }
.todo-filter-label { .todo-filter-label {
font-size: 0.8rem; font-size: 0.8rem;
opacity: 0.7; opacity: 0.7;
margin: 0 0 0.35rem; margin: 0;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.04em; letter-spacing: 0.04em;
text-align: center; flex: 0 0 auto;
min-width: 5.5rem;
} }
.todo-filter-chips { .todo-filter-chips {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.35rem; gap: 0.35rem;
justify-content: center; flex: 1 1 auto;
} }
.todo-filter-chip { .todo-filter-chip {
cursor: pointer; cursor: pointer;
} }
@media (max-width: 480px) {
.todo-filter-columns {
grid-template-columns: 1fr;
}
}
} }
</style> </style>