@tuturuuu/ui 0.30.1 → 0.31.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.
- package/CHANGELOG.md +49 -0
- package/biome.json +1 -1
- package/package.json +103 -57
- package/src/components/ui/calendar-app/calendar-client-page.test.tsx +57 -0
- package/src/components/ui/calendar-app/calendar-client-page.tsx +13 -4
- package/src/components/ui/calendar-app/calendar-page-shell.tsx +7 -1
- package/src/components/ui/calendar-app/components/calendar-connections-compact.tsx +47 -39
- package/src/components/ui/calendar-app/components/calendar-connections-settings-content.tsx +14 -0
- package/src/components/ui/calendar-app/components/calendar-connections-unified.tsx +4 -0
- package/src/components/ui/calendar-app/components/calendar-sync-attention-button.tsx +47 -0
- package/src/components/ui/calendar-app/components/calendar-sync-recovery.test.tsx +112 -0
- package/src/components/ui/calendar-app/components/calendar-sync-recovery.tsx +206 -0
- package/src/components/ui/calendar-app/components/calendar-types.ts +1 -1
- package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +86 -90
- package/src/components/ui/custom/language-dropdown-item.tsx +6 -14
- package/src/components/ui/custom/nav-link.tsx +7 -7
- package/src/components/ui/custom/nav.tsx +15 -63
- package/src/components/ui/custom/notification-card.tsx +361 -0
- package/src/components/ui/custom/notification-list.tsx +178 -0
- package/src/components/ui/custom/notification-popover-client.tsx +12 -780
- package/src/components/ui/custom/notification-runtime.tsx +23 -0
- package/src/components/ui/custom/onboarding-settings-panel.tsx +8 -55
- package/src/components/ui/custom/satellite-account-switcher-menu.tsx +212 -0
- package/src/components/ui/custom/satellite-brand.tsx +122 -0
- package/src/components/ui/custom/satellite-content.tsx +148 -0
- package/src/components/ui/custom/satellite-footer-actions.tsx +350 -0
- package/src/components/ui/custom/satellite-language-item.tsx +26 -0
- package/src/components/ui/custom/satellite-navigation.tsx +86 -0
- package/src/components/ui/custom/satellite-notification-popover.tsx +272 -0
- package/src/components/ui/custom/satellite-onboarding-settings-panel.tsx +80 -0
- package/src/components/ui/custom/satellite-settings-dialog-shell.tsx +621 -0
- package/src/components/ui/custom/satellite-shell-utils.ts +163 -0
- package/src/components/ui/custom/satellite-shell.test.tsx +169 -0
- package/src/components/ui/custom/satellite-shell.tsx +50 -0
- package/src/components/ui/custom/satellite-theme-dropdown-items.tsx +92 -0
- package/src/components/ui/custom/satellite-user-menu-items.tsx +190 -0
- package/src/components/ui/custom/satellite-user-menu.tsx +93 -0
- package/src/components/ui/custom/satellite-workspace-section.tsx +46 -0
- package/src/components/ui/custom/settings/keyboard-shortcuts-settings.tsx +16 -0
- package/src/components/ui/custom/settings-dialog-shell.tsx +21 -600
- package/src/components/ui/custom/sidebar-context.tsx +5 -14
- package/src/components/ui/custom/sidebar-footer-actions.tsx +19 -322
- package/src/components/ui/custom/structure.tsx +14 -0
- package/src/components/ui/custom/system-language-dropdown-item.tsx +7 -14
- package/src/components/ui/custom/theme-dropdown-items.tsx +2 -85
- package/src/components/ui/custom/use-satellite-shell.tsx +165 -0
- package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +49 -13
- package/src/components/ui/finance/invoices/subscription-invoice.tsx +4 -12
- package/src/components/ui/finance/invoices/utils.test.ts +6 -5
- package/src/components/ui/finance/invoices/utils.ts +3 -1
- package/src/components/ui/finance/transactions/columns.test.tsx +25 -1
- package/src/components/ui/finance/transactions/columns.tsx +45 -16
- package/src/components/ui/legacy/calendar/agenda-view.tsx +139 -47
- package/src/components/ui/legacy/calendar/calendar-content.test.tsx +116 -0
- package/src/components/ui/legacy/calendar/calendar-content.tsx +38 -74
- package/src/components/ui/legacy/calendar/calendar-event-tone.ts +20 -0
- package/src/components/ui/legacy/calendar/calendar-meeting-link.test.ts +27 -0
- package/src/components/ui/legacy/calendar/calendar-meeting-link.ts +34 -0
- package/src/components/ui/legacy/calendar/calendar-period.ts +28 -0
- package/src/components/ui/legacy/calendar/calendar-shortcuts.test.tsx +107 -0
- package/src/components/ui/legacy/calendar/calendar-views.test.tsx +169 -0
- package/src/components/ui/legacy/calendar/event-modal-header.tsx +52 -0
- package/src/components/ui/legacy/calendar/event-modal.tsx +107 -156
- package/src/components/ui/legacy/calendar/month-calendar.tsx +242 -933
- package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +25 -0
- package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +32 -0
- package/src/components/ui/legacy/calendar/smart-calendar.tsx +3 -0
- package/src/components/ui/legacy/calendar/use-calendar-view-shortcuts.ts +54 -0
- package/src/components/ui/legacy/calendar/use-event-draft-session.test.tsx +59 -0
- package/src/components/ui/legacy/calendar/use-event-draft-session.ts +20 -0
- package/src/components/ui/legacy/calendar/year-calendar.tsx +34 -16
- package/src/components/ui/pwa-status.test.tsx +53 -0
- package/src/components/ui/pwa-status.tsx +113 -0
- package/src/components/ui/report-problem-dialog-content.tsx +350 -0
- package/src/components/ui/report-problem-dialog.tsx +6 -975
- package/src/components/ui/report-problem-model.ts +133 -0
- package/src/components/ui/report-problem-mutation.ts +122 -0
- package/src/components/ui/save-notification.test.tsx +36 -0
- package/src/components/ui/save-notification.tsx +17 -0
- package/src/components/ui/text-editor/task-item-checkbox-view.test.tsx +85 -0
- package/src/components/ui/text-editor/task-item-checkbox-view.tsx +28 -18
- package/src/components/ui/use-report-problem.ts +488 -0
- package/src/declarations.d.ts +2 -0
- package/src/hooks/__tests__/use-calendar-readonly.test.tsx +46 -4
- package/src/hooks/__tests__/use-calendar-sync.test.tsx +24 -0
- package/src/hooks/calendar-date-utils.ts +11 -0
- package/src/hooks/calendar-event-lookup.test.ts +77 -0
- package/src/hooks/calendar-event-lookup.ts +47 -0
- package/src/hooks/calendar-range-cache.ts +52 -0
- package/src/hooks/pwa-install-prompt.ts +44 -0
- package/src/hooks/use-calendar-sync.tsx +23 -100
- package/src/hooks/use-calendar.tsx +21 -77
- package/src/hooks/use-notifications.ts +9 -24
- package/src/hooks/use-open-initial-calendar-event.ts +27 -0
- package/src/hooks/use-settings-dialog-shortcut.test.tsx +41 -0
- package/src/hooks/use-settings-dialog-shortcut.ts +12 -14
- package/src/hooks/use-workspace-presence.ts +0 -18
- package/src/lib/tasks-app-url.test.ts +25 -0
- package/src/lib/tasks-app-url.ts +8 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type { InternalApiClientOptions } from '@tuturuuu/internal-api/client';
|
|
2
|
+
import type { Product, SupportType } from '@tuturuuu/types';
|
|
3
|
+
import type React from 'react';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
export interface ReportProblemFormData {
|
|
6
|
+
product: Product | '';
|
|
7
|
+
type: SupportType | '';
|
|
8
|
+
suggestion: string;
|
|
9
|
+
media: File[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const MAX_MEDIA_SIZE = 5 * 1024 * 1024; // 5MB
|
|
13
|
+
export const MAX_FILES = 5;
|
|
14
|
+
|
|
15
|
+
// Allowed media types
|
|
16
|
+
export const ALLOWED_IMAGE_TYPES = [
|
|
17
|
+
'image/png',
|
|
18
|
+
'image/jpeg',
|
|
19
|
+
'image/webp',
|
|
20
|
+
'image/gif',
|
|
21
|
+
];
|
|
22
|
+
export const ALLOWED_VIDEO_TYPES = [
|
|
23
|
+
'video/mp4',
|
|
24
|
+
'video/webm',
|
|
25
|
+
'video/quicktime',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
// Zod schema for form validation
|
|
29
|
+
export const reportProblemSchema = z.object({
|
|
30
|
+
product: z.enum(
|
|
31
|
+
[
|
|
32
|
+
'web',
|
|
33
|
+
'nova',
|
|
34
|
+
'rewise',
|
|
35
|
+
'calendar',
|
|
36
|
+
'finance',
|
|
37
|
+
'tudo',
|
|
38
|
+
'tumeet',
|
|
39
|
+
'shortener',
|
|
40
|
+
'qr',
|
|
41
|
+
'drive',
|
|
42
|
+
'mail',
|
|
43
|
+
'other',
|
|
44
|
+
],
|
|
45
|
+
{
|
|
46
|
+
message: 'Please select a product',
|
|
47
|
+
}
|
|
48
|
+
),
|
|
49
|
+
type: z.enum(['bug', 'feature-request'], {
|
|
50
|
+
message: 'Please select a support type',
|
|
51
|
+
}),
|
|
52
|
+
suggestion: z
|
|
53
|
+
.string()
|
|
54
|
+
.trim()
|
|
55
|
+
.min(1, 'Please describe the issue or suggestion')
|
|
56
|
+
.max(1000, 'Suggestion must be at most 1000 characters'),
|
|
57
|
+
media: z
|
|
58
|
+
.array(z.instanceof(File))
|
|
59
|
+
.max(MAX_FILES, `You can upload up to ${MAX_FILES} files`)
|
|
60
|
+
.refine(
|
|
61
|
+
(files) =>
|
|
62
|
+
files.every(
|
|
63
|
+
(f) =>
|
|
64
|
+
ALLOWED_IMAGE_TYPES.includes(f.type) ||
|
|
65
|
+
ALLOWED_VIDEO_TYPES.includes(f.type)
|
|
66
|
+
),
|
|
67
|
+
{
|
|
68
|
+
message:
|
|
69
|
+
'Only image (PNG, JPEG, WebP, GIF) and video (MP4, WebM, MOV) files are allowed',
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
.refine((files) => files.every((f) => f.size <= MAX_MEDIA_SIZE), {
|
|
73
|
+
message: 'Each file must be 5MB or less',
|
|
74
|
+
}),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
export interface ReportProblemDialogProps {
|
|
78
|
+
ImageComponent?: React.ComponentType<{
|
|
79
|
+
src: string;
|
|
80
|
+
alt: string;
|
|
81
|
+
className: string;
|
|
82
|
+
width: number;
|
|
83
|
+
height: number;
|
|
84
|
+
}>;
|
|
85
|
+
t: (
|
|
86
|
+
key:
|
|
87
|
+
| 'affected-product-required'
|
|
88
|
+
| 'cancel'
|
|
89
|
+
| 'media-alt'
|
|
90
|
+
| 'media-optional'
|
|
91
|
+
| 'report-problem'
|
|
92
|
+
| 'report-problem-description'
|
|
93
|
+
| 'report-submitted-success'
|
|
94
|
+
| 'select-product-placeholder'
|
|
95
|
+
| 'submit-report'
|
|
96
|
+
| 'submitting'
|
|
97
|
+
| 'suggestion-improve'
|
|
98
|
+
| 'suggestion-placeholder',
|
|
99
|
+
values?: Record<string, string | number>
|
|
100
|
+
) => string;
|
|
101
|
+
apiOptions?: InternalApiClientOptions;
|
|
102
|
+
open?: boolean;
|
|
103
|
+
onOpenChange?: (open: boolean) => void;
|
|
104
|
+
products?: Array<{ value: Product; label: string }>;
|
|
105
|
+
className?: string;
|
|
106
|
+
trigger?: React.ReactNode;
|
|
107
|
+
showTrigger?: boolean;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const DEFAULT_PRODUCTS: Array<{ value: Product; label: string }> = [
|
|
111
|
+
{ value: 'web', label: 'Web Dashboard' },
|
|
112
|
+
{ value: 'nova', label: 'Nova' },
|
|
113
|
+
{ value: 'rewise', label: 'Rewise' },
|
|
114
|
+
{ value: 'calendar', label: 'Calendar' },
|
|
115
|
+
{ value: 'finance', label: 'Finance' },
|
|
116
|
+
{ value: 'tudo', label: 'Tuturuuu Tasks' },
|
|
117
|
+
{ value: 'tumeet', label: 'Tuturuuu Meet' },
|
|
118
|
+
{ value: 'shortener', label: 'URL Shortener' },
|
|
119
|
+
{ value: 'qr', label: 'QR Code' },
|
|
120
|
+
{ value: 'drive', label: 'Drive' },
|
|
121
|
+
{ value: 'mail', label: 'Mail' },
|
|
122
|
+
{ value: 'other', label: 'Other' },
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
export const DEFAULT_SUPPORT_TYPES: Array<{
|
|
126
|
+
value: SupportType;
|
|
127
|
+
label: string;
|
|
128
|
+
}> = [
|
|
129
|
+
{ value: 'bug', label: 'Bug Report' },
|
|
130
|
+
{ value: 'feature-request', label: 'Feature Request' },
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
export const LOCAL_STORAGE_KEY = 'report-problem-form-data';
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { useMutation } from '@tanstack/react-query';
|
|
2
|
+
import type { InternalApiClientOptions } from '@tuturuuu/internal-api/client';
|
|
3
|
+
import {
|
|
4
|
+
createReportUploadUrls,
|
|
5
|
+
deleteReportUploadPaths,
|
|
6
|
+
submitReport,
|
|
7
|
+
} from '@tuturuuu/internal-api/reports';
|
|
8
|
+
import type { Product, SupportType } from '@tuturuuu/types';
|
|
9
|
+
|
|
10
|
+
interface SubmitReportMutationInput {
|
|
11
|
+
product: Product;
|
|
12
|
+
type: SupportType;
|
|
13
|
+
suggestion: string;
|
|
14
|
+
subject: string;
|
|
15
|
+
media: File[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function useSubmitReportMutation(apiOptions?: InternalApiClientOptions) {
|
|
19
|
+
return useMutation({
|
|
20
|
+
mutationFn: async ({
|
|
21
|
+
product,
|
|
22
|
+
type,
|
|
23
|
+
suggestion,
|
|
24
|
+
subject,
|
|
25
|
+
media,
|
|
26
|
+
}: SubmitReportMutationInput) => {
|
|
27
|
+
const uploadedPaths: string[] = [];
|
|
28
|
+
|
|
29
|
+
const cleanupUploadedMedia = async () => {
|
|
30
|
+
if (uploadedPaths.length === 0) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
await deleteReportUploadPaths({ paths: uploadedPaths }, apiOptions);
|
|
36
|
+
} catch (cleanupError) {
|
|
37
|
+
console.error(
|
|
38
|
+
'Failed to clean up uploaded support report media:',
|
|
39
|
+
cleanupError
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
if (media.length > 0) {
|
|
46
|
+
const { uploads } = await createReportUploadUrls(
|
|
47
|
+
{
|
|
48
|
+
files: media.map((file) => ({
|
|
49
|
+
filename: file.name,
|
|
50
|
+
contentType: file.type,
|
|
51
|
+
size: file.size,
|
|
52
|
+
})),
|
|
53
|
+
},
|
|
54
|
+
apiOptions
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
if (uploads.length !== media.length) {
|
|
58
|
+
throw new Error('Failed to prepare media uploads');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const [index, upload] of uploads.entries()) {
|
|
62
|
+
const file = media[index];
|
|
63
|
+
|
|
64
|
+
if (!file) {
|
|
65
|
+
throw new Error('Missing media file during upload');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let uploadResponse = await fetch(upload.signedUrl, {
|
|
69
|
+
method: 'PUT',
|
|
70
|
+
cache: 'no-store',
|
|
71
|
+
headers: {
|
|
72
|
+
Authorization: `Bearer ${upload.token}`,
|
|
73
|
+
'Content-Type': file.type || 'application/octet-stream',
|
|
74
|
+
},
|
|
75
|
+
body: file,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
if (!uploadResponse.ok) {
|
|
79
|
+
uploadResponse = await fetch(upload.signedUrl, {
|
|
80
|
+
method: 'PUT',
|
|
81
|
+
cache: 'no-store',
|
|
82
|
+
headers: {
|
|
83
|
+
Authorization: `Bearer ${upload.token}`,
|
|
84
|
+
},
|
|
85
|
+
body: file,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!uploadResponse.ok) {
|
|
90
|
+
const text = await uploadResponse.text().catch(() => '');
|
|
91
|
+
throw new Error(
|
|
92
|
+
`Failed to upload media (${uploadResponse.status})${text ? `: ${text}` : ''}`
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
uploadedPaths.push(upload.path);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const result = await submitReport(
|
|
101
|
+
{
|
|
102
|
+
product,
|
|
103
|
+
type,
|
|
104
|
+
suggestion,
|
|
105
|
+
subject,
|
|
106
|
+
imagePaths: uploadedPaths,
|
|
107
|
+
},
|
|
108
|
+
apiOptions
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
if (!result.success) {
|
|
112
|
+
throw new Error(result.message || 'Failed to submit report');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return result;
|
|
116
|
+
} catch (error) {
|
|
117
|
+
await cleanupUploadedMedia();
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { act, render, screen } from '@testing-library/react';
|
|
2
|
+
import { afterEach, expect, it, vi } from 'vitest';
|
|
3
|
+
import { notifySave } from './save-notification';
|
|
4
|
+
import { Toaster, toast } from './sonner';
|
|
5
|
+
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
toast.dismiss();
|
|
8
|
+
vi.unstubAllGlobals();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('renders persistent save failures in the toaster used by app layouts', async () => {
|
|
12
|
+
vi.stubGlobal(
|
|
13
|
+
'matchMedia',
|
|
14
|
+
vi.fn(() => ({
|
|
15
|
+
matches: false,
|
|
16
|
+
addEventListener: vi.fn(),
|
|
17
|
+
removeEventListener: vi.fn(),
|
|
18
|
+
}))
|
|
19
|
+
);
|
|
20
|
+
render(<Toaster />);
|
|
21
|
+
act(() => {
|
|
22
|
+
notifySave({
|
|
23
|
+
title: 'Save failed',
|
|
24
|
+
description: 'Your draft is preserved. Try again.',
|
|
25
|
+
variant: 'destructive',
|
|
26
|
+
duration: Number.POSITIVE_INFINITY,
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
expect(await screen.findByText('Save failed')).toBeInTheDocument();
|
|
30
|
+
expect(
|
|
31
|
+
screen.getByText('Your draft is preserved. Try again.')
|
|
32
|
+
).toBeInTheDocument();
|
|
33
|
+
expect(
|
|
34
|
+
screen.getByRole('button', { name: 'Close toast' })
|
|
35
|
+
).toBeInTheDocument();
|
|
36
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from 'react';
|
|
4
|
+
import { toast } from './sonner';
|
|
5
|
+
|
|
6
|
+
interface SaveNotification {
|
|
7
|
+
title: ReactNode;
|
|
8
|
+
description?: ReactNode;
|
|
9
|
+
variant?: 'default' | 'destructive';
|
|
10
|
+
duration?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Save feedback must use the Sonner toaster mounted by the application shells. */
|
|
14
|
+
export function notifySave({ title, variant, ...options }: SaveNotification) {
|
|
15
|
+
const notify = variant === 'destructive' ? toast.error : toast.success;
|
|
16
|
+
return notify(title, { ...options, closeButton: true });
|
|
17
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
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';
|
|
5
|
+
|
|
6
|
+
vi.mock('@tuturuuu/supabase/next/client', () => ({
|
|
7
|
+
createClient: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
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 };
|
|
42
|
+
}
|
|
43
|
+
|
|
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',
|
|
51
|
+
});
|
|
52
|
+
expect(checkbox).toHaveAttribute('aria-checked', 'false');
|
|
53
|
+
|
|
54
|
+
fireEvent.pointerDown(checkbox, { button: 0, isPrimary: true });
|
|
55
|
+
|
|
56
|
+
expect(setNodeMarkup).toHaveBeenLastCalledWith(4, undefined, {
|
|
57
|
+
checked: 'indeterminate',
|
|
58
|
+
});
|
|
59
|
+
expect(checkbox).toHaveAttribute('aria-checked', 'mixed');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('cycles all three states from the keyboard', () => {
|
|
63
|
+
const { props, setNodeMarkup } = createNodeViewProps();
|
|
64
|
+
render(<TaskItemCheckboxContent {...props} />);
|
|
65
|
+
|
|
66
|
+
const checkbox = screen.getByRole('checkbox', {
|
|
67
|
+
name: 'Task item status',
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
fireEvent.keyDown(checkbox, { key: ' ' });
|
|
71
|
+
fireEvent.keyDown(checkbox, { key: 'Enter' });
|
|
72
|
+
fireEvent.keyDown(checkbox, { key: ' ' });
|
|
73
|
+
|
|
74
|
+
expect(setNodeMarkup).toHaveBeenNthCalledWith(1, 4, undefined, {
|
|
75
|
+
checked: 'indeterminate',
|
|
76
|
+
});
|
|
77
|
+
expect(setNodeMarkup).toHaveBeenNthCalledWith(2, 4, undefined, {
|
|
78
|
+
checked: true,
|
|
79
|
+
});
|
|
80
|
+
expect(setNodeMarkup).toHaveBeenNthCalledWith(3, 4, undefined, {
|
|
81
|
+
checked: false,
|
|
82
|
+
});
|
|
83
|
+
expect(checkbox).toHaveAttribute('aria-checked', 'false');
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -68,14 +68,24 @@ export function TaskItemCheckboxContent({
|
|
|
68
68
|
});
|
|
69
69
|
}, [editor, getPos, node.attrs, checkboxState]);
|
|
70
70
|
|
|
71
|
-
const
|
|
72
|
-
(event: React.
|
|
71
|
+
const handleCheckboxPointerDown = useCallback(
|
|
72
|
+
(event: React.PointerEvent<HTMLButtonElement>) => {
|
|
73
|
+
if (!event.isPrimary || event.button !== 0) return;
|
|
73
74
|
event.preventDefault();
|
|
74
75
|
event.stopPropagation();
|
|
75
|
-
if (!editor.isEditable) return;
|
|
76
76
|
handleCheckboxCycle();
|
|
77
77
|
},
|
|
78
|
-
[
|
|
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
|
+
},
|
|
88
|
+
[handleCheckboxCycle]
|
|
79
89
|
);
|
|
80
90
|
|
|
81
91
|
return (
|
|
@@ -84,29 +94,29 @@ export function TaskItemCheckboxContent({
|
|
|
84
94
|
className="task-list-checkbox-label flex shrink-0 select-none pt-2.5"
|
|
85
95
|
contentEditable={false}
|
|
86
96
|
>
|
|
97
|
+
{/* biome-ignore lint/a11y/useSemanticElements: a button avoids native checkbox state changes while ProseMirror owns the tri-state document update */}
|
|
87
98
|
<button
|
|
88
99
|
type="button"
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
: isIndeterminate
|
|
93
|
-
? 'Indeterminate'
|
|
94
|
-
: 'Unchecked'
|
|
95
|
-
}
|
|
100
|
+
role="checkbox"
|
|
101
|
+
aria-label="Task item status"
|
|
102
|
+
aria-checked={isIndeterminate ? 'mixed' : isChecked}
|
|
96
103
|
aria-disabled={!editor.isEditable}
|
|
97
|
-
|
|
104
|
+
onPointerDown={handleCheckboxPointerDown}
|
|
105
|
+
onKeyDown={handleCheckboxKeyDown}
|
|
98
106
|
className={cn(
|
|
99
|
-
'task-list-checkbox flex
|
|
100
|
-
'cursor-pointer rounded-
|
|
101
|
-
'transition-
|
|
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',
|
|
102
112
|
!completedTaskColor && [
|
|
103
113
|
'border-input',
|
|
104
114
|
'hover:scale-105 hover:border-dynamic-gray hover:bg-dynamic-gray/10',
|
|
105
|
-
'focus:border-dynamic-gray
|
|
115
|
+
'focus-visible:border-dynamic-gray',
|
|
106
116
|
isChecked &&
|
|
107
|
-
'border-dynamic-green bg-dynamic-green/20 hover:border-dynamic-green hover:bg-dynamic-green/10 focus:border-dynamic-green focus:ring-dynamic-green/30',
|
|
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',
|
|
108
118
|
isIndeterminate &&
|
|
109
|
-
'border-dynamic-orange bg-dynamic-orange/20 hover:border-dynamic-orange hover:bg-dynamic-orange/10 focus:border-dynamic-orange focus:ring-dynamic-orange/30',
|
|
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',
|
|
110
120
|
],
|
|
111
121
|
completedTaskColor === 'red' && [
|
|
112
122
|
'border-dynamic-red/50',
|