@tuturuuu/ui 0.32.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/biome.json +1 -1
  3. package/package.json +53 -51
  4. package/src/components/ui/calendar-app/components/calendar-sync-attention-button.tsx +58 -24
  5. package/src/components/ui/calendar-app/components/calendar-sync-recovery.test.tsx +57 -0
  6. package/src/components/ui/calendar-app/components/calendar-sync-recovery.tsx +28 -14
  7. package/src/components/ui/calendar-app/components/refresh-calendar-sync-status.test.tsx +321 -0
  8. package/src/components/ui/calendar-app/components/refresh-calendar-sync-status.ts +182 -0
  9. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +12 -14
  10. package/src/components/ui/chat/ai-message-parts.test.tsx +47 -0
  11. package/src/components/ui/chat/ai-message-parts.tsx +23 -16
  12. package/src/components/ui/chat/ai-message-render-utils.ts +19 -1
  13. package/src/components/ui/commercial-comparison-style.ts +104 -0
  14. package/src/components/ui/commercial-comparison-table.tsx +159 -0
  15. package/src/components/ui/commercial-comparison-toolbar.tsx +205 -0
  16. package/src/components/ui/commercial-comparison.test.tsx +86 -0
  17. package/src/components/ui/commercial-comparison.tsx +224 -0
  18. package/src/components/ui/comparison-value.test.tsx +32 -0
  19. package/src/components/ui/comparison-value.tsx +72 -0
  20. package/src/components/ui/custom/__tests__/report-preview-pagination-progress.test.ts +129 -0
  21. package/src/components/ui/custom/__tests__/report-preview-viewport.test.tsx +56 -0
  22. package/src/components/ui/custom/education/modules/resources/pdf-viewer.tsx +13 -2
  23. package/src/components/ui/custom/report-preview-pagination.ts +5 -3
  24. package/src/components/ui/custom/report-preview-viewport.tsx +40 -0
  25. package/src/components/ui/custom/report-preview.tsx +2 -3
  26. package/src/components/ui/finance/categories-tags-tabs.tsx +3 -0
  27. package/src/components/ui/finance/invoices/columns.tsx +2 -0
  28. package/src/components/ui/finance/invoices/components/invoice-checkout-summary.tsx +6 -3
  29. package/src/components/ui/finance/invoices/components/invoice-data-state.test.tsx +27 -0
  30. package/src/components/ui/finance/invoices/components/invoice-data-state.tsx +34 -0
  31. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +89 -0
  32. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +117 -0
  33. package/src/components/ui/finance/invoices/hooks.ts +5 -110
  34. package/src/components/ui/finance/invoices/internal-api.test.ts +122 -0
  35. package/src/components/ui/finance/invoices/internal-api.ts +19 -1
  36. package/src/components/ui/finance/invoices/invoice-discount.test.tsx +107 -0
  37. package/src/components/ui/finance/invoices/invoice-discount.ts +12 -0
  38. package/src/components/ui/finance/invoices/invoice-history-filters.tsx +121 -0
  39. package/src/components/ui/finance/invoices/invoice-history-pagination.tsx +81 -0
  40. package/src/components/ui/finance/invoices/invoice-history-row.tsx +159 -0
  41. package/src/components/ui/finance/invoices/invoice-history.test.tsx +170 -0
  42. package/src/components/ui/finance/invoices/invoice-history.tsx +300 -0
  43. package/src/components/ui/finance/invoices/invoice-page.tsx +18 -2
  44. package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +3 -7
  45. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.test.tsx +16 -0
  46. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +9 -0
  47. package/src/components/ui/finance/invoices/invoices-table.tsx +3 -0
  48. package/src/components/ui/finance/invoices/promotion-form.tsx +5 -4
  49. package/src/components/ui/finance/invoices/query-invalidation.ts +1 -0
  50. package/src/components/ui/finance/invoices/row-actions.tsx +35 -4
  51. package/src/components/ui/finance/invoices/standard-invoice.tsx +27 -16
  52. package/src/components/ui/finance/invoices/subscription-invoice-props.ts +20 -0
  53. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +71 -4
  54. package/src/components/ui/finance/invoices/subscription-invoice.tsx +44 -45
  55. package/src/components/ui/finance/shared/charts/category-breakdown-chart-body.tsx +73 -80
  56. package/src/components/ui/finance/shared/charts/category-breakdown-chart-controls.tsx +3 -1
  57. package/src/components/ui/finance/shared/charts/category-breakdown-chart-legend.test.tsx +30 -0
  58. package/src/components/ui/finance/shared/charts/category-breakdown-chart-legend.tsx +31 -24
  59. package/src/components/ui/finance/shared/charts/category-breakdown-chart-tooltip.tsx +9 -6
  60. package/src/components/ui/finance/shared/charts/category-breakdown-chart-types.ts +2 -0
  61. package/src/components/ui/finance/shared/charts/category-breakdown-chart-utils.test.ts +59 -0
  62. package/src/components/ui/finance/shared/charts/category-breakdown-chart-utils.ts +51 -37
  63. package/src/components/ui/finance/shared/charts/category-breakdown-chart.tsx +37 -7
  64. package/src/components/ui/public-workspace-prices.tsx +13 -0
  65. package/src/components/ui/pwa-status.test.tsx +13 -2
  66. package/src/components/ui/pwa-status.tsx +15 -3
  67. package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +86 -0
  68. package/src/components/ui/text-editor/editor.tsx +11 -3
  69. package/src/components/ui/text-editor/task-item-checkbox-view.test.tsx +188 -65
  70. package/src/components/ui/text-editor/task-item-checkbox-view.tsx +178 -164
  71. package/src/components/ui/text-editor/task-item-status-picker.tsx +130 -0
  72. package/src/hooks/__tests__/use-calendar-sync.test.tsx +20 -1
  73. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +99 -180
  74. package/src/hooks/calendar-provider-sync.ts +33 -0
  75. package/src/hooks/notification-subscription.ts +74 -0
  76. package/src/hooks/use-calendar-sync.tsx +11 -14
  77. package/src/hooks/use-calendar.tsx +2 -2
  78. package/src/hooks/use-notifications.ts +5 -141
@@ -1,85 +1,208 @@
1
- import { fireEvent, render, screen } from '@testing-library/react';
2
- import type { NodeViewProps } from '@tiptap/react';
3
- import { describe, expect, it, vi } from 'vitest';
4
- import { TaskItemCheckboxContent } from './task-item-checkbox-view';
1
+ import { act, fireEvent, render, screen } from '@testing-library/react';
2
+ import { TaskList } from '@tiptap/extension-list';
3
+ import { type Editor, EditorContent, useEditor } from '@tiptap/react';
4
+ import StarterKit from '@tiptap/starter-kit';
5
+ import { afterEach, describe, expect, it, vi } from 'vitest';
6
+ import { TaskItemCheckbox } from './task-item-checkbox-extension';
7
+ import { TaskItemStatusPicker } from './task-item-status-picker';
5
8
 
6
- vi.mock('@tuturuuu/supabase/next/client', () => ({
7
- createClient: vi.fn(),
9
+ vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
10
+ vi.mock('./task-item-checkbox', async (importOriginal) => ({
11
+ ...(await importOriginal<typeof import('./task-item-checkbox')>()),
12
+ useMentionedTaskStatuses: () => ({ data: [] }),
8
13
  }));
9
14
 
10
- vi.mock('./task-item-checkbox', async (importOriginal) => {
11
- const actual = await importOriginal<typeof import('./task-item-checkbox')>();
12
-
13
- return {
14
- ...actual,
15
- useMentionedTaskStatuses: () => ({ data: [] }),
16
- };
17
- });
18
-
19
- function createNodeViewProps() {
20
- const setNodeMarkup = vi.fn();
21
- const command = vi.fn(
22
- (
23
- callback: (props: {
24
- tr: { setNodeMarkup: typeof setNodeMarkup };
25
- }) => boolean
26
- ) => callback({ tr: { setNodeMarkup } })
27
- );
28
-
29
- const props = {
30
- node: {
31
- attrs: { checked: false },
32
- descendants: vi.fn(),
33
- },
34
- getPos: () => 4,
35
- editor: {
36
- isEditable: true,
37
- commands: { command },
38
- },
39
- } as unknown as NodeViewProps;
40
-
41
- return { command, props, setNodeMarkup };
15
+ let editor: Editor;
16
+ function Harness({ editable = true }: { editable?: boolean }) {
17
+ const instance = useEditor({
18
+ extensions: [StarterKit, TaskList, TaskItemCheckbox],
19
+ content:
20
+ '<ul data-type="taskList"><li data-type="taskItem" data-checked="false"><p>Checklist text</p></li></ul>',
21
+ editable,
22
+ });
23
+ if (instance) editor = instance;
24
+ return <EditorContent editor={instance} />;
42
25
  }
26
+ const checkbox = () => screen.getByRole('checkbox', { name: 'status' });
27
+ const checked = () => editor.state.doc.firstChild?.firstChild?.attrs.checked;
28
+ afterEach(() => vi.useRealTimers());
43
29
 
44
- describe('TaskItemCheckboxContent', () => {
45
- it('cycles pointer interactions before the editor can replace the node view', () => {
46
- const { props, setNodeMarkup } = createNodeViewProps();
47
- render(<TaskItemCheckboxContent {...props} />);
48
-
49
- const checkbox = screen.getByRole('checkbox', {
50
- name: 'Task item status',
30
+ describe('checklist editor interactions', () => {
31
+ it('cycles through all three persisted states with normal clicks', async () => {
32
+ await act(async () => {
33
+ render(<Harness />);
51
34
  });
52
- expect(checkbox).toHaveAttribute('aria-checked', 'false');
35
+ for (const state of ['indeterminate', true, false]) {
36
+ await act(async () => {
37
+ fireEvent.click(checkbox());
38
+ });
39
+ expect(checked()).toBe(state);
40
+ expect(checkbox()).toHaveAttribute(
41
+ 'aria-checked',
42
+ state === 'indeterminate' ? 'mixed' : String(state)
43
+ );
44
+ }
45
+ });
53
46
 
54
- fireEvent.pointerDown(checkbox, { button: 0, isPrimary: true });
47
+ it('enables the status picker after task collaboration finishes syncing', async () => {
48
+ await act(async () => {
49
+ render(<Harness editable={false} />);
50
+ });
51
+ await act(async () => {
52
+ editor.setEditable(true);
53
+ });
54
+ expect(checkbox()).toHaveAttribute('aria-disabled', 'false');
55
+ fireEvent.keyDown(checkbox(), { key: 'ArrowDown' });
56
+ fireEvent.click(screen.getByRole('button', { name: 'in_progress' }));
57
+ expect(checked()).toBe('indeterminate');
58
+ for (const state of [true, false, 'indeterminate']) {
59
+ await act(async () => {
60
+ fireEvent.click(checkbox());
61
+ });
62
+ expect(checked()).toBe(state);
63
+ }
64
+ await act(async () => {
65
+ editor.setEditable(false);
66
+ });
67
+ expect(checkbox()).toHaveAttribute('aria-disabled', 'true');
68
+ });
55
69
 
56
- expect(setNodeMarkup).toHaveBeenLastCalledWith(4, undefined, {
57
- checked: 'indeterminate',
70
+ it('follows undo, redo, and external document updates', async () => {
71
+ await act(async () => {
72
+ render(<Harness />);
73
+ });
74
+ await act(async () => {
75
+ fireEvent.click(checkbox());
76
+ });
77
+ await act(async () => {
78
+ editor.commands.undo();
79
+ });
80
+ expect(checkbox()).toHaveAttribute('aria-checked', 'false');
81
+ await act(async () => {
82
+ editor.commands.redo();
58
83
  });
59
- expect(checkbox).toHaveAttribute('aria-checked', 'mixed');
84
+ expect(checkbox()).toHaveAttribute('aria-checked', 'mixed');
85
+ await act(async () => {
86
+ editor.view.dispatch(
87
+ editor.state.tr.setNodeMarkup(1, undefined, { checked: true })
88
+ );
89
+ });
90
+ expect(checkbox()).toHaveAttribute('aria-checked', 'true');
60
91
  });
61
92
 
62
- it('cycles all three states from the keyboard', () => {
63
- const { props, setNodeMarkup } = createNodeViewProps();
64
- render(<TaskItemCheckboxContent {...props} />);
93
+ it('opens a delayed picker and selects a state without moving text selection', async () => {
94
+ vi.useFakeTimers();
95
+ await act(async () => {
96
+ render(<Harness />);
97
+ });
98
+ await act(async () => {
99
+ editor.commands.setTextSelection(4);
100
+ });
101
+ fireEvent.pointerEnter(checkbox().parentElement!);
102
+ await act(async () => {
103
+ vi.advanceTimersByTime(499);
104
+ });
105
+ expect(
106
+ screen.queryByRole('button', { name: 'completed' })
107
+ ).not.toBeInTheDocument();
108
+ await act(async () => {
109
+ vi.advanceTimersByTime(1);
110
+ });
111
+ fireEvent.click(screen.getByRole('button', { name: 'completed' }));
112
+ expect(checked()).toBe(true);
113
+ expect(editor.state.selection.from).toBe(4);
114
+ });
65
115
 
66
- const checkbox = screen.getByRole('checkbox', {
67
- name: 'Task item status',
116
+ it('cancels a brief hover and closes the picker with Escape', async () => {
117
+ vi.useFakeTimers();
118
+ await act(async () => {
119
+ render(<Harness />);
120
+ });
121
+ fireEvent.pointerEnter(checkbox().parentElement!);
122
+ await act(async () => {
123
+ vi.advanceTimersByTime(200);
124
+ });
125
+ fireEvent.pointerLeave(checkbox().parentElement!);
126
+ await act(async () => {
127
+ vi.advanceTimersByTime(500);
68
128
  });
129
+ expect(
130
+ screen.queryByRole('button', { name: 'completed' })
131
+ ).not.toBeInTheDocument();
132
+ fireEvent.keyDown(checkbox(), { key: 'ArrowDown' });
133
+ fireEvent.keyDown(screen.getByRole('button', { name: 'completed' }), {
134
+ key: 'Escape',
135
+ });
136
+ expect(
137
+ screen.queryByRole('button', { name: 'completed' })
138
+ ).not.toBeInTheDocument();
139
+ });
69
140
 
70
- fireEvent.keyDown(checkbox, { key: ' ' });
71
- fireEvent.keyDown(checkbox, { key: 'Enter' });
72
- fireEvent.keyDown(checkbox, { key: ' ' });
141
+ it('keeps indeterminate state when serializing and reloading the checklist', async () => {
142
+ await act(async () => {
143
+ render(<Harness />);
144
+ });
145
+ await act(async () => {
146
+ fireEvent.click(checkbox());
147
+ });
148
+ const html = editor.getHTML();
149
+ await act(async () => {
150
+ editor.commands.setContent(html);
151
+ });
152
+ expect(checked()).toBe('indeterminate');
153
+ expect(checkbox()).toHaveAttribute('aria-checked', 'mixed');
154
+ });
73
155
 
74
- expect(setNodeMarkup).toHaveBeenNthCalledWith(1, 4, undefined, {
75
- checked: 'indeterminate',
156
+ it('opens the picker from the keyboard and prevents read-only changes', async () => {
157
+ let unmount = () => {};
158
+ await act(async () => {
159
+ ({ unmount } = render(<Harness />));
76
160
  });
77
- expect(setNodeMarkup).toHaveBeenNthCalledWith(2, 4, undefined, {
78
- checked: true,
161
+ fireEvent.keyDown(checkbox(), { key: 'ArrowDown' });
162
+ expect(
163
+ screen.getByRole('button', { name: 'in_progress' })
164
+ ).toBeInTheDocument();
165
+ unmount();
166
+ await act(async () => {
167
+ render(<Harness editable={false} />);
79
168
  });
80
- expect(setNodeMarkup).toHaveBeenNthCalledWith(3, 4, undefined, {
81
- checked: false,
169
+ await act(async () => {
170
+ fireEvent.click(checkbox());
82
171
  });
83
- expect(checkbox).toHaveAttribute('aria-checked', 'false');
172
+ fireEvent.keyDown(checkbox(), { key: 'ArrowDown' });
173
+ expect(checked()).toBe(false);
174
+ expect(
175
+ screen.queryByRole('button', { name: 'in_progress' })
176
+ ).not.toBeInTheDocument();
84
177
  });
178
+ it.each([200, 500])(
179
+ 'clears a pending or open picker when disabled after %i ms',
180
+ async (hoverMs) => {
181
+ vi.useFakeTimers();
182
+ const picker = (disabled: boolean) => (
183
+ <TaskItemStatusPicker
184
+ state={false}
185
+ disabled={disabled}
186
+ onSelect={vi.fn()}
187
+ >
188
+ <button type="button">Toggle</button>
189
+ </TaskItemStatusPicker>
190
+ );
191
+ const { rerender } = render(picker(false));
192
+ fireEvent.pointerEnter(
193
+ screen.getByRole('button', { name: 'Toggle' }).parentElement!
194
+ );
195
+ await act(async () => {
196
+ vi.advanceTimersByTime(hoverMs);
197
+ });
198
+ rerender(picker(true));
199
+ rerender(picker(false));
200
+ await act(async () => {
201
+ vi.advanceTimersByTime(1000);
202
+ });
203
+ expect(
204
+ screen.queryByRole('button', { name: 'completed' })
205
+ ).not.toBeInTheDocument();
206
+ }
207
+ );
85
208
  });
@@ -3,7 +3,8 @@
3
3
  import type { NodeViewProps } from '@tiptap/react';
4
4
  import { NodeViewContent } from '@tiptap/react';
5
5
  import { cn } from '@tuturuuu/utils/format';
6
- import { useCallback, useMemo, useState } from 'react';
6
+ import { useTranslations } from 'next-intl';
7
+ import { useCallback, useMemo, useSyncExternalStore } from 'react';
7
8
  import { DraggableNodeContainer } from './draggable-node-container';
8
9
  import {
9
10
  areAllMentionedTasksCompleted,
@@ -14,16 +15,31 @@ import {
14
15
  type TriStateChecked,
15
16
  useMentionedTaskStatuses,
16
17
  } from './task-item-checkbox';
18
+ import { TaskItemStatusPicker } from './task-item-status-picker';
17
19
 
18
20
  export function TaskItemCheckboxContent({
19
21
  node,
20
22
  getPos,
21
23
  editor,
22
24
  }: NodeViewProps) {
23
- const [manualOverride, setManualOverride] = useState<TriStateChecked | null>(
24
- null
25
+ const t = useTranslations('common.checklist');
26
+ const subscribe = useCallback(
27
+ (notify: () => void) => {
28
+ // setEditable emits update without changing the document/node view.
29
+ editor.on('update', notify);
30
+ editor.on('transaction', notify);
31
+ return () => {
32
+ editor.off('update', notify);
33
+ editor.off('transaction', notify);
34
+ };
35
+ },
36
+ [editor]
37
+ );
38
+ const isEditable = useSyncExternalStore(
39
+ subscribe,
40
+ () => editor.isEditable,
41
+ () => false
25
42
  );
26
-
27
43
  const taskMentionIds = useMemo(() => {
28
44
  return extractTaskMentionIds(node);
29
45
  }, [node]);
@@ -40,187 +56,185 @@ export function TaskItemCheckboxContent({
40
56
 
41
57
  const checkboxState = useMemo((): TriStateChecked => {
42
58
  return resolveCheckboxState({
43
- manualOverride,
59
+ manualOverride: null,
44
60
  nodeChecked: node.attrs.checked,
45
61
  allMentionedTasksCompleted,
46
62
  });
47
- }, [manualOverride, node.attrs.checked, allMentionedTasksCompleted]);
63
+ }, [node.attrs.checked, allMentionedTasksCompleted]);
48
64
 
49
65
  const isChecked = checkboxState === true;
50
66
  const isIndeterminate = checkboxState === 'indeterminate';
51
67
 
52
- const handleCheckboxCycle = useCallback(() => {
53
- if (!editor.isEditable) return;
54
-
55
- const pos = getPos();
56
- if (typeof pos !== 'number') return;
57
-
58
- const nextState = getNextTriState(checkboxState);
59
-
60
- setManualOverride(nextState);
61
-
62
- editor.commands.command(({ tr }) => {
63
- tr.setNodeMarkup(pos, undefined, {
64
- ...node.attrs,
65
- checked: nextState,
68
+ const setCheckboxState = useCallback(
69
+ (
70
+ checked: TriStateChecked | ((current: TriStateChecked) => TriStateChecked)
71
+ ) => {
72
+ if (!editor.isEditable) return;
73
+ const pos = getPos();
74
+ if (typeof pos !== 'number') return;
75
+ editor.commands.command(({ tr }) => {
76
+ const currentNode = tr.doc.nodeAt(pos);
77
+ if (currentNode?.type.name !== 'taskItem') return false;
78
+ // Node view props can lag behind a transaction (e.g. a picker click).
79
+ // Cycle from the document so rapid clicks never repeat a stale state.
80
+ const current = resolveCheckboxState({
81
+ manualOverride: null,
82
+ nodeChecked: currentNode.attrs.checked,
83
+ allMentionedTasksCompleted,
84
+ });
85
+ tr.setNodeMarkup(pos, undefined, {
86
+ ...currentNode.attrs,
87
+ checked: typeof checked === 'function' ? checked(current) : checked,
88
+ });
89
+ return true;
66
90
  });
67
- return true;
68
- });
69
- }, [editor, getPos, node.attrs, checkboxState]);
70
-
71
- const handleCheckboxPointerDown = useCallback(
72
- (event: React.PointerEvent<HTMLButtonElement>) => {
73
- if (!event.isPrimary || event.button !== 0) return;
74
- event.preventDefault();
75
- event.stopPropagation();
76
- handleCheckboxCycle();
77
- },
78
- [handleCheckboxCycle]
79
- );
80
-
81
- const handleCheckboxKeyDown = useCallback(
82
- (event: React.KeyboardEvent<HTMLButtonElement>) => {
83
- if (event.key !== 'Enter' && event.key !== ' ') return;
84
- event.preventDefault();
85
- event.stopPropagation();
86
- handleCheckboxCycle();
87
91
  },
88
- [handleCheckboxCycle]
92
+ [editor, getPos, allMentionedTasksCompleted]
89
93
  );
90
94
 
91
95
  return (
92
- <div className="flex items-start gap-2">
96
+ <div className="flex min-w-0 flex-1 items-start gap-2">
93
97
  <div
94
98
  className="task-list-checkbox-label flex shrink-0 select-none pt-2.5"
95
99
  contentEditable={false}
96
100
  >
97
- {/* biome-ignore lint/a11y/useSemanticElements: a button avoids native checkbox state changes while ProseMirror owns the tri-state document update */}
98
- <button
99
- type="button"
100
- role="checkbox"
101
- aria-label="Task item status"
102
- aria-checked={isIndeterminate ? 'mixed' : isChecked}
103
- aria-disabled={!editor.isEditable}
104
- onPointerDown={handleCheckboxPointerDown}
105
- onKeyDown={handleCheckboxKeyDown}
106
- className={cn(
107
- 'task-list-checkbox flex size-5 items-center justify-center',
108
- 'cursor-pointer rounded-md border-2 bg-background',
109
- 'transition-[color,background-color,border-color,box-shadow,transform] duration-150 active:scale-95',
110
- 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 focus-visible:ring-offset-2',
111
- !editor.isEditable && 'cursor-default opacity-70',
112
- !completedTaskColor && [
113
- 'border-input',
114
- 'hover:scale-105 hover:border-dynamic-gray hover:bg-dynamic-gray/10',
115
- 'focus-visible:border-dynamic-gray',
116
- isChecked &&
117
- 'border-dynamic-green bg-dynamic-green/20 hover:border-dynamic-green hover:bg-dynamic-green/10 focus-visible:border-dynamic-green focus-visible:ring-dynamic-green/30',
118
- isIndeterminate &&
119
- 'border-dynamic-orange bg-dynamic-orange/20 hover:border-dynamic-orange hover:bg-dynamic-orange/10 focus-visible:border-dynamic-orange focus-visible:ring-dynamic-orange/30',
120
- ],
121
- completedTaskColor === 'red' && [
122
- 'border-dynamic-red/50',
123
- 'hover:scale-105 hover:border-dynamic-red hover:bg-dynamic-red/10',
124
- 'focus:border-dynamic-red focus:outline-none focus:ring-2 focus:ring-dynamic-red/30 focus:ring-offset-2',
125
- (isChecked || isIndeterminate) &&
126
- 'border-dynamic-red bg-dynamic-red/20',
127
- ],
128
- completedTaskColor === 'orange' && [
129
- 'border-dynamic-orange/50',
130
- 'hover:scale-105 hover:border-dynamic-orange hover:bg-dynamic-orange/10',
131
- 'focus:border-dynamic-orange focus:outline-none focus:ring-2 focus:ring-dynamic-orange/30 focus:ring-offset-2',
132
- (isChecked || isIndeterminate) &&
133
- 'border-dynamic-orange bg-dynamic-orange/20',
134
- ],
135
- completedTaskColor === 'yellow' && [
136
- 'border-dynamic-yellow/50',
137
- 'hover:scale-105 hover:border-dynamic-yellow hover:bg-dynamic-yellow/10',
138
- 'focus:border-dynamic-yellow focus:outline-none focus:ring-2 focus:ring-dynamic-yellow/30 focus:ring-offset-2',
139
- (isChecked || isIndeterminate) &&
140
- 'border-dynamic-yellow bg-dynamic-yellow/20',
141
- ],
142
- completedTaskColor === 'green' && [
143
- 'border-dynamic-green/50',
144
- 'hover:scale-105 hover:border-dynamic-green hover:bg-dynamic-green/10',
145
- 'focus:border-dynamic-green focus:outline-none focus:ring-2 focus:ring-dynamic-green/30 focus:ring-offset-2',
146
- (isChecked || isIndeterminate) &&
147
- 'border-dynamic-green bg-dynamic-green/20',
148
- ],
149
- completedTaskColor === 'cyan' && [
150
- 'border-dynamic-cyan/50',
151
- 'hover:scale-105 hover:border-dynamic-cyan hover:bg-dynamic-cyan/10',
152
- 'focus:border-dynamic-cyan focus:outline-none focus:ring-2 focus:ring-dynamic-cyan/30 focus:ring-offset-2',
153
- (isChecked || isIndeterminate) &&
154
- 'border-dynamic-cyan bg-dynamic-cyan/20',
155
- ],
156
- completedTaskColor === 'blue' && [
157
- 'border-dynamic-blue/50',
158
- 'hover:scale-105 hover:border-dynamic-blue hover:bg-dynamic-blue/10',
159
- 'focus:border-dynamic-blue focus:outline-none focus:ring-2 focus:ring-dynamic-blue/30 focus:ring-offset-2',
160
- (isChecked || isIndeterminate) &&
161
- 'border-dynamic-blue bg-dynamic-blue/20',
162
- ],
163
- completedTaskColor === 'purple' && [
164
- 'border-dynamic-purple/50',
165
- 'hover:scale-105 hover:border-dynamic-purple hover:bg-dynamic-purple/10',
166
- 'focus:border-dynamic-purple focus:outline-none focus:ring-2 focus:ring-dynamic-purple/30 focus:ring-offset-2',
167
- (isChecked || isIndeterminate) &&
168
- 'border-dynamic-purple bg-dynamic-purple/20',
169
- ],
170
- completedTaskColor === 'pink' && [
171
- 'border-dynamic-pink/50',
172
- 'hover:scale-105 hover:border-dynamic-pink hover:bg-dynamic-pink/10',
173
- 'focus:border-dynamic-pink focus:outline-none focus:ring-2 focus:ring-dynamic-pink/30 focus:ring-offset-2',
174
- (isChecked || isIndeterminate) &&
175
- 'border-dynamic-pink bg-dynamic-pink/20',
176
- ]
177
- )}
101
+ <TaskItemStatusPicker
102
+ state={checkboxState}
103
+ disabled={!isEditable}
104
+ onSelect={setCheckboxState}
178
105
  >
179
- {isChecked && (
180
- <svg
181
- className={cn(
182
- 'h-3.5 w-3.5',
183
- completedTaskColor ? 'text-foreground' : 'text-dynamic-green'
184
- )}
185
- viewBox="0 0 16 16"
186
- fill="none"
187
- xmlns="http://www.w3.org/2000/svg"
188
- aria-hidden="true"
189
- >
190
- <path
191
- d="M4 8l2.5 2.5L12 5"
192
- stroke="currentColor"
193
- strokeWidth="2"
194
- strokeLinecap="round"
195
- strokeLinejoin="round"
196
- />
197
- </svg>
198
- )}
199
- {isIndeterminate && (
200
- <svg
201
- className={cn(
202
- 'h-3.5 w-3.5',
203
- completedTaskColor ? 'text-foreground' : 'text-dynamic-orange'
204
- )}
205
- viewBox="0 0 16 16"
206
- fill="none"
207
- xmlns="http://www.w3.org/2000/svg"
208
- aria-hidden="true"
209
- >
210
- <path
211
- d="M4 8h8"
212
- stroke="currentColor"
213
- strokeWidth="2"
214
- strokeLinecap="round"
215
- />
216
- </svg>
217
- )}
218
- </button>
106
+ {/* biome-ignore lint/a11y/useSemanticElements: a button avoids native checkbox state changes while ProseMirror owns the tri-state document update */}
107
+ <button
108
+ type="button"
109
+ role="checkbox"
110
+ aria-label={t('status')}
111
+ aria-keyshortcuts="ArrowDown"
112
+ aria-checked={isIndeterminate ? 'mixed' : isChecked}
113
+ aria-disabled={!isEditable}
114
+ onMouseDown={(event) => event.preventDefault()}
115
+ onClick={(event) => {
116
+ event.stopPropagation();
117
+ setCheckboxState(getNextTriState);
118
+ }}
119
+ className={cn(
120
+ 'task-list-checkbox flex size-5 items-center justify-center',
121
+ 'cursor-pointer rounded-md border-2 bg-background',
122
+ 'transition-[color,background-color,border-color,box-shadow,transform] duration-150 active:scale-95',
123
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 focus-visible:ring-offset-2',
124
+ !isEditable && 'cursor-default opacity-70',
125
+ !completedTaskColor && [
126
+ 'border-muted-foreground/50',
127
+ 'hover:scale-105 hover:border-dynamic-gray hover:bg-dynamic-gray/10',
128
+ 'focus-visible:border-dynamic-gray',
129
+ isChecked &&
130
+ 'border-dynamic-green bg-dynamic-green/20 hover:border-dynamic-green hover:bg-dynamic-green/10 focus-visible:border-dynamic-green focus-visible:ring-dynamic-green/30',
131
+ isIndeterminate &&
132
+ 'border-dynamic-orange bg-dynamic-orange/20 hover:border-dynamic-orange hover:bg-dynamic-orange/10 focus-visible:border-dynamic-orange focus-visible:ring-dynamic-orange/30',
133
+ ],
134
+ completedTaskColor === 'red' && [
135
+ 'border-dynamic-red/50',
136
+ 'hover:scale-105 hover:border-dynamic-red hover:bg-dynamic-red/10',
137
+ 'focus:border-dynamic-red focus:outline-none focus:ring-2 focus:ring-dynamic-red/30 focus:ring-offset-2',
138
+ (isChecked || isIndeterminate) &&
139
+ 'border-dynamic-red bg-dynamic-red/20',
140
+ ],
141
+ completedTaskColor === 'orange' && [
142
+ 'border-dynamic-orange/50',
143
+ 'hover:scale-105 hover:border-dynamic-orange hover:bg-dynamic-orange/10',
144
+ 'focus:border-dynamic-orange focus:outline-none focus:ring-2 focus:ring-dynamic-orange/30 focus:ring-offset-2',
145
+ (isChecked || isIndeterminate) &&
146
+ 'border-dynamic-orange bg-dynamic-orange/20',
147
+ ],
148
+ completedTaskColor === 'yellow' && [
149
+ 'border-dynamic-yellow/50',
150
+ 'hover:scale-105 hover:border-dynamic-yellow hover:bg-dynamic-yellow/10',
151
+ 'focus:border-dynamic-yellow focus:outline-none focus:ring-2 focus:ring-dynamic-yellow/30 focus:ring-offset-2',
152
+ (isChecked || isIndeterminate) &&
153
+ 'border-dynamic-yellow bg-dynamic-yellow/20',
154
+ ],
155
+ completedTaskColor === 'green' && [
156
+ 'border-dynamic-green/50',
157
+ 'hover:scale-105 hover:border-dynamic-green hover:bg-dynamic-green/10',
158
+ 'focus:border-dynamic-green focus:outline-none focus:ring-2 focus:ring-dynamic-green/30 focus:ring-offset-2',
159
+ (isChecked || isIndeterminate) &&
160
+ 'border-dynamic-green bg-dynamic-green/20',
161
+ ],
162
+ completedTaskColor === 'cyan' && [
163
+ 'border-dynamic-cyan/50',
164
+ 'hover:scale-105 hover:border-dynamic-cyan hover:bg-dynamic-cyan/10',
165
+ 'focus:border-dynamic-cyan focus:outline-none focus:ring-2 focus:ring-dynamic-cyan/30 focus:ring-offset-2',
166
+ (isChecked || isIndeterminate) &&
167
+ 'border-dynamic-cyan bg-dynamic-cyan/20',
168
+ ],
169
+ completedTaskColor === 'blue' && [
170
+ 'border-dynamic-blue/50',
171
+ 'hover:scale-105 hover:border-dynamic-blue hover:bg-dynamic-blue/10',
172
+ 'focus:border-dynamic-blue focus:outline-none focus:ring-2 focus:ring-dynamic-blue/30 focus:ring-offset-2',
173
+ (isChecked || isIndeterminate) &&
174
+ 'border-dynamic-blue bg-dynamic-blue/20',
175
+ ],
176
+ completedTaskColor === 'purple' && [
177
+ 'border-dynamic-purple/50',
178
+ 'hover:scale-105 hover:border-dynamic-purple hover:bg-dynamic-purple/10',
179
+ 'focus:border-dynamic-purple focus:outline-none focus:ring-2 focus:ring-dynamic-purple/30 focus:ring-offset-2',
180
+ (isChecked || isIndeterminate) &&
181
+ 'border-dynamic-purple bg-dynamic-purple/20',
182
+ ],
183
+ completedTaskColor === 'pink' && [
184
+ 'border-dynamic-pink/50',
185
+ 'hover:scale-105 hover:border-dynamic-pink hover:bg-dynamic-pink/10',
186
+ 'focus:border-dynamic-pink focus:outline-none focus:ring-2 focus:ring-dynamic-pink/30 focus:ring-offset-2',
187
+ (isChecked || isIndeterminate) &&
188
+ 'border-dynamic-pink bg-dynamic-pink/20',
189
+ ]
190
+ )}
191
+ >
192
+ {isChecked && (
193
+ <svg
194
+ className={cn(
195
+ 'h-3.5 w-3.5',
196
+ completedTaskColor ? 'text-foreground' : 'text-dynamic-green'
197
+ )}
198
+ viewBox="0 0 16 16"
199
+ fill="none"
200
+ xmlns="http://www.w3.org/2000/svg"
201
+ aria-hidden="true"
202
+ >
203
+ <path
204
+ d="M4 8l2.5 2.5L12 5"
205
+ stroke="currentColor"
206
+ strokeWidth="2"
207
+ strokeLinecap="round"
208
+ strokeLinejoin="round"
209
+ />
210
+ </svg>
211
+ )}
212
+ {isIndeterminate && (
213
+ <svg
214
+ className={cn(
215
+ 'h-3.5 w-3.5',
216
+ completedTaskColor ? 'text-foreground' : 'text-dynamic-orange'
217
+ )}
218
+ viewBox="0 0 16 16"
219
+ fill="none"
220
+ xmlns="http://www.w3.org/2000/svg"
221
+ aria-hidden="true"
222
+ >
223
+ <path
224
+ d="M4 8h8"
225
+ stroke="currentColor"
226
+ strokeWidth="2"
227
+ strokeLinecap="round"
228
+ />
229
+ </svg>
230
+ )}
231
+ </button>
232
+ </TaskItemStatusPicker>
219
233
  </div>
220
234
 
221
235
  <NodeViewContent
222
236
  className={cn(
223
- 'transition-opacity duration-150',
237
+ 'min-w-0 flex-1 cursor-text caret-foreground transition-opacity duration-150',
224
238
  isChecked && 'task-content-faded'
225
239
  )}
226
240
  />