Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
281 changes: 281 additions & 0 deletions .agents/notes/implemented/feature/2026-09-15-ui-toggle-toolbar.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -3648,6 +3648,8 @@
"tasks.body.linkPlaceholder": "Paste or type a link",
"tasks.body.format.link": "Link",
"tasks.body.format.removeLink": "Remove link",
"tasks.body.format.marks": "Marks",
"tasks.body.format.blocks": "Blocks",
"tasks.body.format.more": "More…",
"tasks.actions.more": "More actions",
"tasks.actions.copyUrl": "Copy task URL",
Expand Down
2 changes: 2 additions & 0 deletions locales/zh_CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -3648,6 +3648,8 @@
"tasks.body.linkPlaceholder": "粘贴或输入链接",
"tasks.body.format.link": "链接",
"tasks.body.format.removeLink": "移除链接",
"tasks.body.format.marks": "标记",
"tasks.body.format.blocks": "块",
"tasks.body.format.more": "更多…",
"tasks.actions.more": "更多操作",
"tasks.actions.copyUrl": "复制任务链接",
Expand Down
1 change: 0 additions & 1 deletion packages/components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,6 @@
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toggle": "^1.1.9",
"@radix-ui/react-tooltip": "^1.2.9",
"@react-three/fiber": "^9.7.0",
"@sqlite.org/sqlite-wasm": "3.50.1-build1",
Expand Down
204 changes: 126 additions & 78 deletions packages/components/src/components/tasks/task-body-selection-toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ import {
InlinePopoverPositioner,
InlinePopoverRoot,
} from '@prosekit/react/inline-popover';
import { cn } from '@/lib/utils';
import { Button } from '@lody/ui/button';
import { Input } from '@lody/ui/input';
import { Toggle } from '@lody/ui/toggle';
import { Toolbar } from '@lody/ui/toolbar';

/**
* Floating format toolbar over a text selection.
Expand Down Expand Up @@ -65,7 +68,15 @@ export type TaskBodySelectionToolbarProps = {
onQuote?: () => void;
};

function ToolbarButton({
/**
* A mark that stays on while the selection carries it.
*
* `Toolbar.Button` is what puts a control into the bar's roving focus, and
* `render` is how a control is composed into a Base UI part here — so the whole
* bar is one tab stop and the arrow keys walk it, rather than eight stops on
* the way past a text selection.
*/
function FormatToggle({
icon: Icon,
label,
active = false,
Expand All @@ -77,24 +88,56 @@ function ToolbarButton({
onTrigger: () => void;
}) {
return (
<button
type="button"
// The whole reason the toolbar can act on a selection: pointerdown would
// otherwise move focus out of the editor and collapse it before click.
onPointerDown={(event) => event.preventDefault()}
onClick={onTrigger}
aria-label={label}
aria-pressed={active}
title={label}
className={cn(
'flex h-7 w-7 items-center justify-center rounded transition-colors',
active
? 'bg-muted-foreground/20 text-foreground'
: 'text-muted-foreground hover:bg-muted-foreground/10 hover:text-foreground'
)}
<Toolbar.Button
render={
<Toggle
size="small"
icon
pressed={active}
onPressedChange={onTrigger}
// The whole reason the toolbar can act on a selection: pointerdown
// would otherwise move focus out of the editor and collapse it
// before click.
onPointerDown={(event) => event.preventDefault()}
aria-label={label}
title={label}
/>
}
>
<Icon className="h-3.5 w-3.5" />
</button>
<Icon className="size-full" aria-hidden="true" />
</Toolbar.Button>
);
}

/**
* A command in the same bar: it acts and leaves nothing pressed behind, so it
* is a ghost Button rather than a Toggle.
*/
function FormatAction({
icon: Icon,
label,
onTrigger,
}: {
icon: LucideIcon;
label: string;
onTrigger: () => void;
}) {
return (
<Toolbar.Button
render={
<Button
variant="ghost"
size="small"
icon
onClick={onTrigger}
onPointerDown={(event) => event.preventDefault()}
aria-label={label}
title={label}
/>
}
>
<Icon className="size-full" aria-hidden="true" />
</Toolbar.Button>
);
}

Expand Down Expand Up @@ -160,19 +203,22 @@ export function TaskBodySelectionToolbar({ onQuote }: TaskBodySelectionToolbarPr
hoist
>
<InlinePopoverPopup>
<div
role="toolbar"
<Toolbar.Root
aria-label={t('tasks.body.formatToolbar', 'Format selection')}
className="flex items-center gap-0.5 rounded-lg border border-border bg-popover p-1 shadow-md"
// The bar itself draws nothing, so the surface under it is this
// popover's and stays here until the popover is a primitive too.
className="rounded-lg border border-border bg-popover p-1 shadow-md"
onPointerDown={(event) => event.preventDefault()}
>
{linkOpen ? (
// Link is a sub-state of the same popover, not a second surface:
// it replaces the row in place so the bar does not jump.
<div className="flex items-center gap-1 px-1">
<LinkIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<input
<Toolbar.Group className="px-1">
<LinkIcon className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<Input
ref={linkInputRef}
size="small"
className="w-56"
value={href}
onChange={(event) => setHref(event.target.value)}
onKeyDown={(event) => {
Expand All @@ -185,73 +231,75 @@ export function TaskBodySelectionToolbar({ onQuote }: TaskBodySelectionToolbarPr
}
}}
placeholder={t('tasks.body.linkPlaceholder', 'Paste or type a link')}
className="h-6 w-56 bg-transparent text-[13px] outline-none placeholder:text-muted-foreground"
/>
<ToolbarButton
<FormatAction
icon={Unlink}
label={t('tasks.body.format.removeLink', 'Remove link')}
onTrigger={() => {
commands?.removeLink?.();
closeLink();
}}
/>
</div>
</Toolbar.Group>
) : (
<>
<ToolbarButton
icon={Bold}
label={t('tasks.body.format.bold', 'Bold')}
active={isMarkActive('strong')}
onTrigger={() => commands?.toggleStrong?.()}
/>
<ToolbarButton
icon={Italic}
label={t('tasks.body.format.italic', 'Italic')}
active={isMarkActive('em')}
onTrigger={() => commands?.toggleEm?.()}
/>
<ToolbarButton
icon={Strikethrough}
label={t('tasks.body.format.strike', 'Strikethrough')}
active={isMarkActive('del')}
onTrigger={() => commands?.toggleDel?.()}
/>
<ToolbarButton
icon={Code}
label={t('tasks.body.format.code', 'Code')}
active={isMarkActive('code')}
onTrigger={() => commands?.toggleCode?.()}
/>
<ToolbarButton
icon={Highlighter}
label={t('tasks.body.format.highlight', 'Highlight')}
active={isMarkActive('highlight')}
onTrigger={() => commands?.toggleHighlight?.()}
/>
<Toolbar.Group aria-label={t('tasks.body.format.marks', 'Marks')}>
<FormatToggle
icon={Bold}
label={t('tasks.body.format.bold', 'Bold')}
active={isMarkActive('strong')}
onTrigger={() => commands?.toggleStrong?.()}
/>
<FormatToggle
icon={Italic}
label={t('tasks.body.format.italic', 'Italic')}
active={isMarkActive('em')}
onTrigger={() => commands?.toggleEm?.()}
/>
<FormatToggle
icon={Strikethrough}
label={t('tasks.body.format.strike', 'Strikethrough')}
active={isMarkActive('del')}
onTrigger={() => commands?.toggleDel?.()}
/>
<FormatToggle
icon={Code}
label={t('tasks.body.format.code', 'Code')}
active={isMarkActive('code')}
onTrigger={() => commands?.toggleCode?.()}
/>
<FormatToggle
icon={Highlighter}
label={t('tasks.body.format.highlight', 'Highlight')}
active={isMarkActive('highlight')}
onTrigger={() => commands?.toggleHighlight?.()}
/>
</Toolbar.Group>

<span aria-hidden className="mx-0.5 h-4 w-px bg-border" />
<Toolbar.Separator />

<ToolbarButton
icon={LinkIcon}
label={t('tasks.body.format.link', 'Link')}
onTrigger={() => setLinkOpen(true)}
/>
<ToolbarButton
icon={SquareCheck}
label={t('tasks.body.format.task', 'Turn into task item')}
onTrigger={() => commands?.wrapInSquareTask?.()}
/>

<ToolbarButton
icon={RemoveFormatting}
label={t('tasks.body.format.clear', 'Clear formatting')}
onTrigger={() => commands?.setParagraph?.()}
/>
<Toolbar.Group aria-label={t('tasks.body.format.blocks', 'Blocks')}>
<FormatAction
icon={LinkIcon}
label={t('tasks.body.format.link', 'Link')}
onTrigger={() => setLinkOpen(true)}
/>
<FormatAction
icon={SquareCheck}
label={t('tasks.body.format.task', 'Turn into task item')}
onTrigger={() => commands?.wrapInSquareTask?.()}
/>
<FormatAction
icon={RemoveFormatting}
label={t('tasks.body.format.clear', 'Clear formatting')}
onTrigger={() => commands?.setParagraph?.()}
/>
</Toolbar.Group>

{onQuote ? (
<>
<span aria-hidden className="mx-0.5 h-4 w-px bg-border" />
<ToolbarButton
<Toolbar.Separator />
<FormatAction
icon={Quote}
label={t('tasks.body.quote', 'Quote selection')}
onTrigger={onQuote}
Expand All @@ -260,7 +308,7 @@ export function TaskBodySelectionToolbar({ onQuote }: TaskBodySelectionToolbarPr
) : null}
</>
)}
</div>
</Toolbar.Root>
</InlinePopoverPopup>
</InlinePopoverPositioner>
</InlinePopoverRoot>
Expand Down
57 changes: 24 additions & 33 deletions packages/components/src/components/tasks/tasks-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import { useOrganization } from '@/hooks/useOrganization';
import { useTaskActions } from '@/hooks/use-task-actions';
import { useTaskSessionRollups } from '@/hooks/use-task-session-rollup';
import { Button } from '@lody/ui/button';
import { Toggle } from '@lody/ui/toggle';
import { ToggleGroup } from '@lody/ui/toggle-group';
import { ScrollArea } from '@/ui/scroll-area';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/ui/tooltip';
import {
Expand Down Expand Up @@ -120,15 +122,11 @@ function DesktopTasksWorkspace({ activeTaskId }: { activeTaskId: TaskId | null }
const layout = useAtomValue(tasksLayoutAtom);
const [visibleByView, setVisibleByView] = useAtom(taskVisiblePropertiesAtom);
const visibleProperties = visibleByView[layout] ?? [];
const toggleProperty = useCallback(
(property: TaskCardProperty) => {
setVisibleByView((previous: TaskVisibleProperties) => {
const current = previous[layout] ?? [];
const next = current.includes(property)
? current.filter((item: TaskCardProperty) => item !== property)
: [...current, property];
return { ...previous, [layout]: next };
});
// The set reports every pressed member rather than the one that changed, so
// this stores the list it was handed instead of adding and removing from it.
const setVisibleProperties = useCallback(
(next: TaskCardProperty[]) => {
setVisibleByView((previous: TaskVisibleProperties) => ({ ...previous, [layout]: next }));
},
[layout, setVisibleByView]
);
Expand Down Expand Up @@ -230,30 +228,23 @@ function DesktopTasksWorkspace({ activeTaskId }: { activeTaskId: TaskId | null }
<p className="mb-1.5 text-[11px] font-medium text-muted-foreground">
{t('tasks.show.title', 'Show')}
</p>
<div className="flex flex-wrap gap-1">
{TASK_CARD_PROPERTIES.map((property) => {
const active = visibleProperties.includes(property);
return (
<button
key={property}
type="button"
aria-pressed={active}
onClick={() => toggleProperty(property)}
className={cn(
'rounded-md border px-2 py-1 text-[11px] transition-colors',
active
? 'border-transparent bg-muted-foreground/20 text-foreground'
: 'border-border/70 text-muted-foreground hover:bg-muted-foreground/10 hover:text-foreground'
)}
>
{t(
TASK_CARD_PROPERTY_LABELS[property].key,
TASK_CARD_PROPERTY_LABELS[property].fallback
)}
</button>
);
})}
</div>
<ToggleGroup
multiple
wrap
size="mini"
aria-label={t('tasks.show.title', 'Show')}
value={visibleProperties}
onValueChange={setVisibleProperties}
>
{TASK_CARD_PROPERTIES.map((property) => (
<Toggle key={property} value={property}>
{t(
TASK_CARD_PROPERTY_LABELS[property].key,
TASK_CARD_PROPERTY_LABELS[property].fallback
)}
</Toggle>
))}
</ToggleGroup>
</div>
</DropdownMenuContent>
</DropdownMenu>
Expand Down
Loading
Loading