include, exclude or neither on any tree, for filters that nest
npx shadcn add @ag/tree-marksAlready installed? Preview an update
npx shadcn add @ag/tree-marks --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-10-11
- New: the include, exclude and neither marks on a tree as a layer of
tree(layer={useTreeMarks({ marks, onMarksChange })}), its own item, installed withnpx shadcn add @ag/tree-marks: whatTree'smarksdid, pressed the same way (a click only this row, ⌘ adds, ⇧ a range, ⌥ excludes, ⌘⌥ adds as or; Space, + and - from the keys),pressRowswhere the rows are the marks,onMarkandorWithfor a lens.lib/tree-marks-coreholdsmarkStates,setMark,toggleMarkand the lens;pressMark,markEntries,MarkSignandJoinBadgegive a list that is not a tree the same gestures.
the demo above, verbatim
"use client"
import { Pencil, ScanSearch } from "lucide-react"
import * as React from "react"
import { Sample } from "@/app/samples"
import { cn } from "@/lib/utils"
import {
fromPaths,
ownChildren,
preorder,
rollup,
} from "@/registry/base-nova/lib/tree-core"
import {
type Lens,
lensMarks,
lensOf,
lensText,
type Mark,
markLens,
NO_LENS,
} from "@/registry/base-nova/lib/tree-marks-core"
import { Tree } from "@/registry/base-nova/ui/tree"
import { ICONS } from "@/registry/base-nova/ui/tree-icons"
import { useTreeMarks } from "@/registry/base-nova/ui/tree-marks"
const PANEL =
"w-72 space-y-1 rounded-xl bg-foreground/[0.03] p-1 ring-1 ring-foreground/8"
const LINE =
"block h-8 truncate rounded-lg bg-foreground/4 px-2.5 font-mono text-[12px] leading-8"
// #region tags
const TAG_COUNTS: Record<string, number> = {
"vehicles/sedan": 41,
"vehicles/truck": 12,
"vehicles/bike": 7,
"color/red": 23,
"color/blue": 18,
"color/green": 5,
"people/adult": 64,
"people/child": 9,
"animal/dog": 15,
"animal/cat": 11,
}
const TAGS = fromPaths(Object.keys(TAG_COUNTS))
const TAG_TOTALS = rollup(TAGS, ownChildren, (t) => TAG_COUNTS[t.id] ?? 0)
const TAG_ORDER = preorder(TAGS, ownChildren)
// #endregion
// #region marks
function MarkedTags() {
const [marks, setMarks] = React.useState<Record<string, Mark>>({
"vehicles/sedan": "include",
"color/red": "exclude",
})
const [said, setSaid] = React.useState("")
// The rows are marks: a press marks only this row, ⌘ adds, ⌥ excludes.
const layer = useTreeMarks({
marks,
onMarksChange: setMarks,
pressRows: true,
})
const lens = lensText(lensOf(TAG_ORDER, marks))
return (
<div className={PANEL}>
{/* One line, whatever it says: a mark never moves the tree under the hand. */}
<output
aria-label="the lens"
title={lens || undefined}
className={cn(LINE, lens ? "text-foreground" : "text-foreground/40")}
>
{lens || "no lens: every file"}
</output>
<Tree
aria-label="tags"
items={TAGS}
label={(t) => t.name}
icon={(t) => (t.children ? <ICONS.tags /> : <ICONS.tag />)}
meta={(t) => TAG_TOTALS.get(t.id)}
selection="none"
defaultExpanded={["vehicles", "color"]}
layer={layer}
rowMenu={(t) => [
{
icon: <Pencil />,
label: "edit tag",
onSelect: () => setSaid(`edit ${t.id}`),
},
{
icon: <ScanSearch />,
label: "scan all files",
onSelect: () => setSaid(`scan ${t.id}`),
},
]}
/>
<p className="min-h-4 px-2.5 font-mono text-[11px] text-foreground/45">
{said}
</p>
</div>
)
}
// #endregion
// #region lens
function LensTags() {
const [lens, setLens] = React.useState<Lens>({
groups: [["people/adult"], ["color/red", "color/blue"]],
exclude: ["animal"],
})
// The marks are a lens: a gesture's changes land on it whole, "only this" starts
// its includes from none (its excludes stay), and ⌘⌥ ORs into the last group.
const layer = useTreeMarks({
marks: lensMarks(lens),
onMark: (changes, only) =>
setLens(
changes.reduce(
(l, c) => markLens(l, c.id, c.mark, c.or, c.clear),
only ? { ...NO_LENS, exclude: lens.exclude } : lens,
),
),
orWith: lens.groups.at(-1)?.join(" or ") ?? null,
pressRows: true,
})
const text = lensText(lens)
return (
<div className={PANEL}>
<output
aria-label="the lens"
title={text || undefined}
className={cn(LINE, text ? "text-foreground" : "text-foreground/40")}
>
{text || "no lens: every file"}
</output>
<Tree
aria-label="tags as a lens"
items={TAGS}
label={(t) => t.name}
icon={(t) => (t.children ? <ICONS.tags /> : <ICONS.tag />)}
meta={(t) => TAG_TOTALS.get(t.id)}
selection="none"
defaultExpanded={["color"]}
layer={layer}
/>
</div>
)
}
// #endregion
export default function TreeMarksDemo() {
return (
<div className="space-y-8">
<Sample
name="marks"
title="tags, marked"
with="tags marks"
description="a click marks only that row (again, none), ⌘-click adds, ⇧-click a range, ⌥-click excludes; space, + and - from the keys. a branch covers what is under it and reads as a dot when its children disagree. right-click or the ⋯: include and exclude first, then the tree's own verbs."
>
<MarkedTags />
</Sample>
<Sample
name="lens"
glance
title="a lens: and, or, not"
with="tags lens"
description="the same marks with a grammar: each include its own group, ANDed; ⌘⌥-click ORs a tag into the last group (the menu offers it, naming what it joins); excludes come after."
>
<LensTags />
</Sample>
</div>
)
}the source, every file an install writes
"use client"
// MARKS ON A TREE: tri-state include, exclude, neither, as a layer on ui/tree (its
// `layer` prop). The arithmetic is lib/tree-marks-core (what a row reads as once a
// mark covers its subtree, the lens and its grammar); this is the hands.
//
// MARKS ARE PRESSED LIKE FILES ARE SELECTED, so the hand needs nothing new: a click
// marks ONLY this row (its includes replaced; pressed again while it is the only one,
// none), ⌘-click (Ctrl elsewhere) adds it or takes it back, ⇧-click marks every row
// from the last one pressed, and ⌥-click is the negative, exclude (or take the exclude
// back); ⌘⌥ adds as "or" where the marks are a lens (`onMark` with `orWith`). Excludes
// are only set with ⌥ or the menu, and a click's "only this" never clears them. On the
// mark glyph always; on the whole row with `pressRows` (the rows ARE marks: a filter
// rail). The keyboard presses the same: Space or Enter with the same modifiers, `+`
// include, `-` exclude, Backspace or 0 clears. The row's menu starts with include and
// exclude, their presses taught beside them, and "or" with what it joins.
//
// const layer = useTreeMarks({ marks, onMarksChange: setMarks, pressRows: true })
// <Tree items={tags} label={(t) => t.name} layer={layer} />
import { Minus, Plus, Split } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
import {
between,
descendants,
type Nodelike,
} from "@/registry/base-nova/lib/tree-core"
import {
type Mark,
type MarkChange,
type MarkEntry,
markStates,
setMark,
toggleMark,
} from "@/registry/base-nova/lib/tree-marks-core"
import type { MenuEntry } from "@/registry/base-nova/ui/menu"
import type {
TreeApi,
TreeLayer,
TreeRowState,
} from "@/registry/base-nova/ui/tree"
export interface TreeMarksOptions {
/** tri-state marks by id (include, exclude, absent) */
marks: Readonly<Record<string, Mark>>
onMarksChange?: (marks: Record<string, Mark>) => void
/** the mark gesture whole, in place of onMarksChange, for marks that are more
* than a record (lib/tree-marks-core's Lens, with or-groups): its changes in
* order, and `only`: the includes start from none (a plain press, "only this";
* excludes stay) before the changes, else the changes go on top of what is marked */
onMark?: (changes: readonly MarkChange[], only: boolean) => void
/** what an "or" joins, by name ("people", "beach or mountains"), or null when
* nothing is included to join: the menu then offers [OR] with those names, and
* ⌘⌥-click joins them. "Or" is only ever offered with what it is or to. */
orWith?: string | null
/** a press on the row marks it instead of selecting, for lists whose rows are
* marks (a filter rail) */
pressRows?: boolean
}
/** AND and OR, the lens's two joiners, as one badge of one size: they trade places
* under a press without moving anything. OR is lit (it changes what a group means,
* "any of these"), AND is the same badge, quiet. */
export function JoinBadge({
word,
className,
}: {
word: "and" | "or"
className?: string
}) {
return (
<span
data-slot="join-badge"
data-word={word}
className={cn(
"inline-flex h-4 w-7 shrink-0 items-center justify-center rounded-[4px] font-mono font-semibold text-[9px] tracking-wider ring-1 ring-inset",
word === "or"
? "bg-amber-400/15 text-amber-300 ring-amber-400/35"
: "bg-foreground/6 text-foreground/55 ring-foreground/15",
className,
)}
>
{word.toUpperCase()}
</span>
)
}
export const OrBadge = ({ className }: { className?: string }) => (
<JoinBadge word="or" className={className} />
)
/** The mark column's glyph: + included, − excluded, a dot for a branch whose subtree
* disagrees (tinted by what is under it); neither is nothing at all, a faint + only
* under the hand (what a press would do). Inherited or derived marks draw at half
* strength. */
function MarkGlyph({
state,
own,
has,
className,
}: MarkEntry & { className?: string }) {
if (state === "mixed")
return (
<span
className={cn(
"size-1.5 rounded-full bg-current",
has.include && has.exclude
? "text-foreground/55"
: has.include
? "text-tone-ok/70"
: "text-tone-bad/70",
)}
/>
)
return (
<span
className={cn(
"font-mono text-[13px] leading-none",
state === "include" && "text-tone-ok",
state === "exclude" && "text-tone-bad",
state === null &&
"text-foreground/35 opacity-0 group-hover/row:opacity-100",
state !== null && !own && "opacity-55",
className,
)}
>
{state === "exclude" ? "−" : "+"}
</span>
)
}
const MARK_WORDS: Record<string, string> = {
include: "included",
exclude: "excluded",
mixed: "mixed",
null: "not marked",
}
/** The press that excludes: ⌥ alone (⌘ adds, ⌘⌥ adds as "or"). */
const excludes = (e: React.MouseEvent | React.KeyboardEvent) =>
e.altKey && !e.metaKey && !e.ctrlKey
/** A tree's `layer`, for a tree of any node. */
export type TreeMarksLayer = <N extends Nodelike<N>>(
tree: TreeApi<N>,
) => TreeLayer<N>
/** The marks as a tree's `layer`: what each row reads as, the mark column, the
* presses and keys, the menu's verbs (see the header). Call it where the tree is
* drawn, one per tree: it keeps the row a press last marked. */
export function useTreeMarks({
marks,
onMarksChange,
onMark,
orWith,
pressRows = false,
}: TreeMarksOptions): TreeMarksLayer {
/** The row a press last marked: a ⇧-press marks every row from it. */
const markAnchor = React.useRef<string | null>(null)
// What every row reads as, computed again only when the forest or the marks change.
const cache = React.useRef<{
items: unknown
childrenOf: unknown
marks: Readonly<Record<string, Mark>>
states: Map<string, MarkEntry>
} | null>(null)
return <N extends Nodelike<N>>(tree: TreeApi<N>): TreeLayer<N> => {
const { items, rows, rowAt, index, childrenOf, isDisabled, setActive } =
tree
const c = cache.current
const markOf =
c && c.items === items && c.childrenOf === childrenOf && c.marks === marks
? c.states
: markStates(items, childrenOf, marks)
cache.current = { items, childrenOf, marks, states: markOf }
/** A row's next mark: `mark` toggled against its own (or set, `toggle` false; or
* cleared with null), its subtree made whole. `or` joins the last group. */
const changeOf = (
n: N,
mark: Mark | null,
or: boolean,
toggle = true,
): MarkChange => {
const state = markOf.get(n.id)?.state ?? null
return {
id: n.id,
mark: mark === null ? null : toggle ? toggleMark(state, mark) : mark,
or,
clear: descendants(n, childrenOf),
}
}
/** A gesture's changes, applied at once: `only` starts the includes from none. */
const apply = (changes: MarkChange[], only: boolean) => {
if (changes.length === 0 && !only) return
if (onMark) return onMark(changes, only)
const start = only
? Object.fromEntries(
Object.entries(marks).filter(([, m]) => m === "exclude"),
)
: marks
onMarksChange?.(
changes.reduce<Record<string, Mark>>(
(acc, c) => setMark(acc, c.id, c.mark, c.clear),
{ ...start },
),
)
}
const canMark = !!onMarksChange || !!onMark
/** Toggle one mark on a row (or clear it with null), on top of the rest: the
* menu's verbs and the keys. */
const markRow = (n: N, mark: Mark | null, or = false) => {
if (!canMark || isDisabled(n)) return
apply([changeOf(n, mark, or)], false)
}
/** What a press means: only this row, ⌘ adds, ⇧ a range, ⌥ excludes, ⌘⌥ adds
* as "or". */
const press = (e: React.MouseEvent | React.KeyboardEvent, n: N) => {
if (!canMark || isDisabled(n)) return
const mod = e.metaKey || e.ctrlKey
const from = markAnchor.current
if (e.shiftKey && from !== null && rowAt.has(from)) {
// A range: every row from the last pressed, included, one "or" group (onMark).
const ids = between(
rows,
rowAt.get(from) as number,
rowAt.get(n.id) as number,
)
const range = ids
.map((id) => (index.get(id) as { node: N }).node)
.filter((x) => !isDisabled(x))
apply(
range.map((x, i) => changeOf(x, "include", !!onMark && i > 0, false)),
false,
)
return
}
markAnchor.current = n.id
if (e.altKey && !mod) return apply([changeOf(n, "exclude", false)], false)
if (mod)
return apply(
[changeOf(n, "include", e.altKey && !!onMark && !!orWith)],
false,
)
// Only this row; pressed again while it is the only one, none.
const sole =
markOf.get(n.id)?.state === "include" &&
Object.entries(marks).every(([id, m]) => m !== "include" || id === n.id)
apply(sole ? [] : [changeOf(n, "include", false, false)], true)
}
return {
pressRows,
stateOf: (id) => markOf.get(id)?.state ?? null,
rowData: (id) => {
const m = markOf.get(id)
return {
"data-mark": m?.state ?? undefined,
"data-own-mark": (m?.own && m.state) || undefined,
}
},
labelClass:
"group-data-[mark=exclude]/row:text-foreground/45 group-data-[mark=exclude]/row:line-through",
column: (n, text) => {
const m = markOf.get(n.id)
if (!m) return null
return (
<button
type="button"
tabIndex={-1}
data-slot="tree-mark"
aria-label={`${text}: ${MARK_WORDS[String(m.state)]}`}
onClick={(e) => {
e.stopPropagation()
setActive(n.id)
press(e, n)
}}
onDoubleClick={(e) => e.stopPropagation()}
className="ml-1.5 grid size-5 shrink-0 place-items-center rounded-sm hover:bg-foreground/10"
>
<MarkGlyph state={m.state} own={m.own} has={m.has} />
</button>
)
},
press,
key: (e, n) => {
if (e.key === " ") {
e.preventDefault()
press(e, n)
} else if (e.key === "+" || e.key === "=") {
e.preventDefault()
markRow(n, "include")
} else if (e.key === "-") {
e.preventDefault()
markRow(n, "exclude")
} else if (e.key === "0" || e.key === "Backspace") {
e.preventDefault()
markRow(n, null)
} else return false
return true
},
menu: canMark
? (n, state: TreeRowState) => [
...markEntries(
state.mark === "mixed" ? null : (state.mark as Mark | null),
(next) => markRow(n, next),
),
...(onMark && orWith && state.mark !== "include"
? [
{
icon: <Split className="text-amber-300" />,
label: (
<span className="flex min-w-0 items-center gap-1.5">
<OrBadge />
<span className="truncate text-foreground/70">
{orWith}
</span>
</span>
),
hint: "mod+alt+click",
onSelect: () => markRow(n, "include", true),
} satisfies MenuEntry,
]
: []),
]
: undefined,
}
}
}
// ── a row's gestures, for any list of markable rows: the tree's, and a list that is
// not a tree (a timeline's layer heads) wearing the same ones, so a click, a ⌘-click,
// a right-click and the ⋯ mean one thing everywhere.
/** What a press on a row's own mark means, for a row marked alone (no list to be
* "only" in): ⌥ excludes (or takes the exclude back), any other press includes (or
* takes the include back), as the tree's ⌥ and ⌘ do. */
export function pressMark(
e: React.MouseEvent | React.KeyboardEvent,
state: Mark | null,
): Mark | null {
return toggleMark(state, excludes(e) ? "exclude" : "include")
}
/** The mark verbs a row menu starts with, as the tree words them: exclude (or include
* again), with the press that does each drawn on the right. */
export function markEntries(
state: Mark | null,
mark: (next: Mark | null) => void,
): MenuEntry[] {
return [
state === "include"
? {
icon: <Minus />,
label: "leave out",
hint: "mod+click",
onSelect: () => mark(null),
}
: {
icon: <Plus className="text-tone-ok" />,
label: state === "exclude" ? "include again" : "include",
hint: "mod+click",
onSelect: () => mark("include"),
},
state === "exclude"
? {
icon: <Minus />,
label: "stop excluding",
hint: "alt+click",
onSelect: () => mark(null),
}
: {
icon: <Minus className="text-tone-bad" />,
label: "exclude",
hint: "alt+click",
onSelect: () => mark("exclude"),
},
]
}
/** A mark as a row draws it: + included, − excluded, a faint + under the hand when
* neither. */
export function MarkSign({
state,
className,
}: {
state: Mark | null
className?: string
}) {
return (
<MarkGlyph
state={state}
own
has={{ include: state === "include", exclude: state === "exclude" }}
className={className}
/>
)
}// MARKS ON A TREE, framework-free: a tri-state mark per node (include, exclude,
// neither) and what each row reads as once marks cover their subtrees, and the LENS,
// marks with a grammar (groups ANDed, OR inside a group, excludes after), the way a
// filter rail or a tag query reads them. ui/tree-marks draws them on ui/tree; a
// server or a test can use them alone. Built on lib/tree-core's nodes and children.
import type { Children, Nodelike } from "@/registry/base-nova/lib/tree-core"
// ── tri-state marks (include, exclude, neither)
export type Mark = "include" | "exclude"
/** What a row reads as: one mark across its whole subtree, nothing, or mixed. */
export type MarkState = Mark | "mixed" | null
/** One mark a gesture changes: the row, its next mark (null: none), whether it joins
* the last group as "or" (a Lens's), and its descendants, cleared. */
export interface MarkChange {
id: string
mark: Mark | null
or: boolean
clear: readonly string[]
}
export interface MarkEntry {
state: MarkState
/** set on the node itself, not inherited from a branch or derived from children */
own: boolean
/** which marks the subtree holds: what a mixed branch is mixed of */
has: { include: boolean; exclude: boolean }
}
const INCLUDE = 1
const EXCLUDE = 2
const NONE = 4
/** Every node's mark as a row draws it. A mark covers its descendants (a branch
* included includes what is under it) and EXCLUSION WINS (nothing under an excluded
* branch reads as included, the lab's rule). An unmarked branch reads as its
* children, and any branch whose subtree does not agree with itself reads `mixed`.
* `own` says the mark was set on the node itself, not inherited or derived. */
export function markStates<N extends Nodelike<N>>(
roots: readonly N[],
children: Children<N>,
marks: Readonly<Record<string, Mark>>,
): Map<string, MarkEntry> {
const out = new Map<string, MarkEntry>()
const walk = (n: N, inherited: Mark | null): number => {
const mine = marks[n.id]
const eff: Mark | null =
inherited === "exclude" ? "exclude" : (mine ?? inherited)
const kids = children(n) ?? []
// An unmarked branch reads as its children: all of them excluded is excluded.
let mask =
eff === "include"
? INCLUDE
: eff === "exclude"
? EXCLUDE
: kids.length
? 0
: NONE
for (const k of kids) mask |= walk(k, eff)
const state: MarkState =
mask === INCLUDE
? "include"
: mask === EXCLUDE
? "exclude"
: mask === NONE
? null
: "mixed"
out.set(n.id, {
state,
own: mine !== undefined,
has: { include: (mask & INCLUDE) > 0, exclude: (mask & EXCLUDE) > 0 },
})
return mask
}
for (const r of roots) walk(r, null)
return out
}
/** A mark set on a node, its descendants' own marks cleared so the subtree reads as
* one thing (a click on a mixed branch makes it whole). null clears the node's own. */
export function setMark(
marks: Readonly<Record<string, Mark>>,
id: string,
mark: Mark | null,
clear: readonly string[],
): Record<string, Mark> {
const next = { ...marks }
for (const d of clear) delete next[d]
if (mark) next[id] = mark
else delete next[id]
return next
}
/** A mark gesture on one row is a TOGGLE of one mark, never a cycle: include
* (neither and include trade places, excluded becomes included), or
* exclude the same way. A mixed row takes the mark whole. */
export const toggleMark = (state: MarkState, mark: Mark): Mark | null =>
state === mark ? null : mark
// ── the lens: marks with a grammar (TAGS.md's)
/** A query over marked ids. `groups` are ANDed, the ids inside one group ORed (OR
* binds tighter: `people (beach | mountains)`), and every `exclude` is AND NOT after
* them. Ordered: the order a person built it in is the order it reads. */
export interface Lens {
groups: readonly (readonly string[])[]
exclude: readonly string[]
}
export const NO_LENS: Lens = { groups: [], exclude: [] }
/** Every included id, in reading order. */
export const lensTerms = (lens: Lens): string[] => lens.groups.flat()
export const lensEmpty = (lens: Lens): boolean =>
lens.groups.length === 0 && lens.exclude.length === 0
/** The lens as plain marks, what a tree draws. */
export function lensMarks(lens: Lens): Record<string, Mark> {
const out: Record<string, Mark> = {}
for (const id of lensTerms(lens)) out[id] = "include"
for (const id of lens.exclude) out[id] = "exclude"
return out
}
/** Plain marks as a lens: each include its own group (all ANDed), in `order`. */
export function lensOf(
order: readonly string[],
marks: Readonly<Record<string, Mark>>,
): Lens {
return {
groups: order.filter((id) => marks[id] === "include").map((id) => [id]),
exclude: order.filter((id) => marks[id] === "exclude"),
}
}
/** A mark gesture on the lens: `id` and the `clear`ed ids (its descendants) leave
* wherever they were, then `id` comes back included, as its own ANDed group or, with
* `or`, ORed into the last group; or excluded at the end; or not at all. */
export function markLens(
lens: Lens,
id: string,
mark: Mark | null,
or: boolean,
clear: readonly string[] = [],
): Lens {
const gone = new Set([id, ...clear])
const groups = lens.groups
.map((g) => g.filter((t) => !gone.has(t)))
.filter((g) => g.length > 0)
const exclude = lens.exclude.filter((t) => !gone.has(t))
const last = groups.at(-1)
if (mark === "include")
return {
groups:
or && last
? [...groups.slice(0, -1), [...last, id]]
: [...groups, [id]],
exclude,
}
if (mark === "exclude") return { groups, exclude: [...exclude, id] }
return { groups, exclude }
}
/** Flip the joiner between included term `at` and the next, in reading order: an
* "or" inside a group splits it there into two ANDed groups, an "and" between two
* groups merges them into one. */
export function flipJoin(lens: Lens, at: number): Lens {
const { groups } = lens
let start = 0
for (let k = 0; k < groups.length; k++) {
const g = groups[k] as readonly string[]
const end = start + g.length - 1
if (at >= start && at < end) {
const cut = at - start + 1
return {
...lens,
groups: [
...groups.slice(0, k),
g.slice(0, cut),
g.slice(cut),
...groups.slice(k + 1),
],
}
}
if (at === end) {
const next = groups[k + 1]
if (!next) throw new Error(`flipJoin: no term after ${at}`)
return {
...lens,
groups: [
...groups.slice(0, k),
[...g, ...next],
...groups.slice(k + 2),
],
}
}
start = end + 1
}
throw new Error(`flipJoin: no term ${at}`)
}
/** The lens in its text form: `people (beach | mountains) -color/red`. */
export function lensText(lens: Lens): string {
return [
...lens.groups.map((g) => (g.length > 1 ? `(${g.join(" | ")})` : g[0])),
...lens.exclude.map((id) => `-${id}`),
].join(" ")
}