@tuturuuu/ui 0.27.0 → 0.28.1
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 +21 -0
- package/package.json +4 -3
- package/src/components/ui/button.tsx +12 -8
- package/src/components/ui/calendar.tsx +6 -6
- package/src/components/ui/color-picker.test.tsx +23 -0
- package/src/components/ui/color-picker.tsx +1 -1
- package/src/components/ui/custom/notification-popover-client.tsx +6 -19
- package/src/components/ui/custom/notification-popover-trigger.test.tsx +37 -0
- package/src/components/ui/custom/notification-popover-trigger.tsx +44 -0
- package/src/components/ui/date-time-picker-layout.ts +6 -0
- package/src/components/ui/date-time-picker.test.tsx +25 -0
- package/src/components/ui/date-time-picker.tsx +5 -5
- package/src/components/ui/text-editor/__tests__/editor-classes.test.ts +36 -0
- package/src/components/ui/text-editor/__tests__/image-extension-clipboard.test.ts +21 -0
- package/src/components/ui/text-editor/__tests__/inline-task-conversion.test.tsx +30 -0
- package/src/components/ui/text-editor/__tests__/keyboard.test.ts +173 -3
- package/src/components/ui/text-editor/__tests__/markdown-paste-extension.test.ts +29 -1
- package/src/components/ui/text-editor/__tests__/toggle-block-extension.test.ts +251 -0
- package/src/components/ui/text-editor/editor-classes.ts +100 -0
- package/src/components/ui/text-editor/editor.tsx +11 -153
- package/src/components/ui/text-editor/extensions.ts +19 -0
- package/src/components/ui/text-editor/image-extension.ts +10 -14
- package/src/components/ui/text-editor/keyboard.ts +47 -0
- package/src/components/ui/text-editor/markdown-paste-extension.ts +25 -2
- package/src/components/ui/text-editor/toggle-block-extension.ts +153 -0
- package/src/components/ui/text-editor/tool-bar.tsx +24 -73
- package/src/components/ui/text-editor/toolbar-config.ts +67 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.28.1](https://github.com/tutur3u/platform/compare/ui-v0.28.0...ui-v0.28.1) (2026-08-16)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **tasks:** preserve rich paste and responsive dialogs ([0a6c5a3](https://github.com/tutur3u/platform/commit/0a6c5a362f51b475c2e8fe984eb3569a46a878ee))
|
|
9
|
+
|
|
10
|
+
## [0.28.0](https://github.com/tutur3u/platform/compare/ui-v0.27.0...ui-v0.28.0) (2026-08-15)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* **tasks:** add toggle blocks and repair editor indentation ([c0805c8](https://github.com/tutur3u/platform/commit/c0805c8cc0e26c172eb6f46582e267b084289501))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Bug Fixes
|
|
19
|
+
|
|
20
|
+
* **contacts:** restore group tag labels ([9fbcb25](https://github.com/tutur3u/platform/commit/9fbcb25bd44fcedcfad4a463599940e9497ebc06))
|
|
21
|
+
* **notifications:** prevent pre-hydration bell clicks ([6adba83](https://github.com/tutur3u/platform/commit/6adba83f3db63817cc64e332c33a6a2939e0bf83))
|
|
22
|
+
* **ui:** support button slots with TypeScript 7 ([3bdc636](https://github.com/tutur3u/platform/commit/3bdc6368daf0be48719a479c63a6325d414233d6))
|
|
23
|
+
|
|
3
24
|
## [0.27.0](https://github.com/tutur3u/platform/compare/ui-v0.26.1...ui-v0.27.0) (2026-08-14)
|
|
4
25
|
|
|
5
26
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tuturuuu/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -64,6 +64,7 @@
|
|
|
64
64
|
"@tiptap/extension-collaboration": "3.30.0",
|
|
65
65
|
"@tiptap/extension-collaboration-caret": "3.30.0",
|
|
66
66
|
"@tiptap/extension-color": "3.30.0",
|
|
67
|
+
"@tiptap/extension-details": "3.30.0",
|
|
67
68
|
"@tiptap/extension-drag-handle": "3.30.0",
|
|
68
69
|
"@tiptap/extension-drag-handle-react": "3.30.0",
|
|
69
70
|
"@tiptap/extension-highlight": "3.30.0",
|
|
@@ -90,9 +91,9 @@
|
|
|
90
91
|
"@tuturuuu/apis": "0.11.2",
|
|
91
92
|
"@tuturuuu/hooks": "0.1.1",
|
|
92
93
|
"@tuturuuu/icons": "0.1.0",
|
|
93
|
-
"@tuturuuu/internal-api": "0.
|
|
94
|
+
"@tuturuuu/internal-api": "0.31.0",
|
|
94
95
|
"@tuturuuu/supabase": "0.5.0",
|
|
95
|
-
"@tuturuuu/utils": "0.25.
|
|
96
|
+
"@tuturuuu/utils": "0.25.2",
|
|
96
97
|
"@types/debug": "^4.1.13",
|
|
97
98
|
"browser-image-compression": "^2.0.2",
|
|
98
99
|
"class-variance-authority": "^0.7.1",
|
|
@@ -46,15 +46,19 @@ function Button({
|
|
|
46
46
|
VariantProps<typeof buttonVariants> & {
|
|
47
47
|
asChild?: boolean;
|
|
48
48
|
}) {
|
|
49
|
-
const
|
|
49
|
+
const classNames = cn(buttonVariants({ variant, size, className }));
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
51
|
+
if (asChild) {
|
|
52
|
+
return (
|
|
53
|
+
<Slot
|
|
54
|
+
data-slot="button"
|
|
55
|
+
className={classNames}
|
|
56
|
+
{...(props as React.ComponentProps<typeof Slot>)}
|
|
57
|
+
/>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return <button data-slot="button" className={classNames} {...props} />;
|
|
58
62
|
}
|
|
59
63
|
|
|
60
64
|
type ButtonProps = React.ComponentProps<typeof Button>;
|
|
@@ -189,7 +189,7 @@ function Calendar({
|
|
|
189
189
|
};
|
|
190
190
|
|
|
191
191
|
return (
|
|
192
|
-
<div className="space-y-4">
|
|
192
|
+
<div className="min-w-0 space-y-4">
|
|
193
193
|
<div>
|
|
194
194
|
<div className="mb-4 flex items-center justify-between gap-2 border-b px-2 pb-4">
|
|
195
195
|
<button
|
|
@@ -318,21 +318,21 @@ function Calendar({
|
|
|
318
318
|
root: 'bg-transparent',
|
|
319
319
|
months: 'flex flex-col',
|
|
320
320
|
month:
|
|
321
|
-
'
|
|
321
|
+
'w-full min-w-0 max-w-[calc(100vw-4rem)] shrink-0 space-y-4 p-2 text-center font-semibold',
|
|
322
322
|
month_caption: 'hidden',
|
|
323
323
|
nav: 'hidden',
|
|
324
324
|
button_next: 'hidden',
|
|
325
325
|
button_previous: 'hidden',
|
|
326
326
|
month_grid: 'w-full border-collapse',
|
|
327
327
|
weeks: 'flex flex-col gap-1',
|
|
328
|
-
week: 'grid grid-cols-7 gap-
|
|
329
|
-
weekdays: 'grid w-full grid-cols-7 gap-
|
|
328
|
+
week: 'mt-2 grid grid-cols-7 gap-0.5 @[20rem]:gap-1',
|
|
329
|
+
weekdays: 'mb-2 grid w-full grid-cols-7 gap-0.5 @[20rem]:gap-1',
|
|
330
330
|
weekday:
|
|
331
331
|
'text-muted-foreground rounded-md font-normal text-[0.8rem] text-center',
|
|
332
|
-
day: '
|
|
332
|
+
day: 'relative min-w-0 p-0 text-center text-sm',
|
|
333
333
|
day_button: cn(
|
|
334
334
|
buttonVariants({ variant: 'ghost' }),
|
|
335
|
-
'h-
|
|
335
|
+
'aspect-square h-auto min-h-8 w-full rounded-md p-0 font-normal transition-colors duration-300',
|
|
336
336
|
'aria-selected:bg-primary aria-selected:text-primary-foreground',
|
|
337
337
|
'hover:bg-accent/50 hover:text-accent-foreground',
|
|
338
338
|
'hover:aria-selected:bg-primary hover:aria-selected:text-primary-foreground'
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { ColorPicker } from './color-picker';
|
|
4
|
+
|
|
5
|
+
vi.mock('../../hooks/use-dom-resolved-theme', () => ({
|
|
6
|
+
useDomResolvedTheme: () => 'light',
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
describe('ColorPicker', () => {
|
|
10
|
+
it('renders the supplied read-only label', () => {
|
|
11
|
+
render(<ColorPicker text="Visible workspace tag" value="#123456" />);
|
|
12
|
+
|
|
13
|
+
expect(
|
|
14
|
+
screen.getByRole('button', { name: 'Visible workspace tag' })
|
|
15
|
+
).toBeVisible();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('keeps the icon-only control when no label is supplied', () => {
|
|
19
|
+
const { container } = render(<ColorPicker value="#123456" />);
|
|
20
|
+
|
|
21
|
+
expect(container.querySelector('button svg')).toBeTruthy();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -65,7 +65,7 @@ const ColorPicker = forwardRef<
|
|
|
65
65
|
variant="outline"
|
|
66
66
|
disabled={disabled}
|
|
67
67
|
>
|
|
68
|
-
<TextIcon className="h-4 w-4" />
|
|
68
|
+
{text || <TextIcon className="h-4 w-4" />}
|
|
69
69
|
</Button>
|
|
70
70
|
</PopoverTrigger>
|
|
71
71
|
<PopoverContent className="flex w-full flex-col items-center justify-center gap-4">
|
|
@@ -41,7 +41,7 @@ import {
|
|
|
41
41
|
dispatchRequestOpenTask,
|
|
42
42
|
waitForTaskOpenResult,
|
|
43
43
|
} from '@tuturuuu/ui/lib/task-open-events';
|
|
44
|
-
import { Popover, PopoverContent
|
|
44
|
+
import { Popover, PopoverContent } from '@tuturuuu/ui/popover';
|
|
45
45
|
import { toast } from '@tuturuuu/ui/sonner';
|
|
46
46
|
import { cn } from '@tuturuuu/utils/format';
|
|
47
47
|
import dayjs from 'dayjs';
|
|
@@ -49,6 +49,7 @@ import relativeTime from 'dayjs/plugin/relativeTime';
|
|
|
49
49
|
import Link from 'next/link';
|
|
50
50
|
import { useParams, usePathname, useRouter } from 'next/navigation';
|
|
51
51
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
52
|
+
import { NotificationPopoverTriggerButton } from './notification-popover-trigger';
|
|
52
53
|
|
|
53
54
|
dayjs.extend(relativeTime);
|
|
54
55
|
|
|
@@ -190,24 +191,10 @@ export default function NotificationPopoverClient({
|
|
|
190
191
|
|
|
191
192
|
return (
|
|
192
193
|
<Popover open={open} onOpenChange={setOpen}>
|
|
193
|
-
<
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
aria-label={notificationsText}
|
|
198
|
-
title={notificationsText}
|
|
199
|
-
className="group relative flex size-10 flex-none transition-all"
|
|
200
|
-
>
|
|
201
|
-
<Bell className="h-6 w-6" />
|
|
202
|
-
{unreadCount > 0 && (
|
|
203
|
-
<div className="absolute top-1 right-2 flex h-1.5 w-1.5 flex-none items-center justify-center rounded-full bg-dynamic-red p-1 text-center font-semibold text-xs transition-all group-hover:-top-2 group-hover:-right-1 group-hover:h-5 group-hover:w-auto group-hover:px-1.5 group-hover:text-background">
|
|
204
|
-
<div className="relative opacity-0 group-hover:opacity-100">
|
|
205
|
-
{unreadCount > 99 ? '99+' : unreadCount}
|
|
206
|
-
</div>
|
|
207
|
-
</div>
|
|
208
|
-
)}
|
|
209
|
-
</Button>
|
|
210
|
-
</PopoverTrigger>
|
|
194
|
+
<NotificationPopoverTriggerButton
|
|
195
|
+
notificationsText={notificationsText}
|
|
196
|
+
unreadCount={unreadCount}
|
|
197
|
+
/>
|
|
211
198
|
<PopoverContent
|
|
212
199
|
className="w-100 overflow-hidden rounded-xl border p-0 shadow-xl"
|
|
213
200
|
align="end"
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import '@testing-library/jest-dom';
|
|
2
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
3
|
+
import { Popover } from '@tuturuuu/ui/popover';
|
|
4
|
+
import { renderToString } from 'react-dom/server';
|
|
5
|
+
import { describe, expect, it } from 'vitest';
|
|
6
|
+
import { NotificationPopoverTriggerButton } from './notification-popover-trigger';
|
|
7
|
+
|
|
8
|
+
function TriggerHarness({ unreadCount = 0 }: { unreadCount?: number }) {
|
|
9
|
+
return (
|
|
10
|
+
<Popover>
|
|
11
|
+
<NotificationPopoverTriggerButton
|
|
12
|
+
notificationsText="Notifications"
|
|
13
|
+
unreadCount={unreadCount}
|
|
14
|
+
/>
|
|
15
|
+
</Popover>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('NotificationPopoverTriggerButton', () => {
|
|
20
|
+
it('disables the server-rendered trigger until event handlers are attached', () => {
|
|
21
|
+
const markup = renderToString(<TriggerHarness unreadCount={1} />);
|
|
22
|
+
|
|
23
|
+
expect(markup).toContain('disabled=""');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('enables the trigger after hydration and opens the popover on the first click', async () => {
|
|
27
|
+
render(<TriggerHarness unreadCount={1} />);
|
|
28
|
+
|
|
29
|
+
const trigger = screen.getByRole('button', { name: 'Notifications' });
|
|
30
|
+
await waitFor(() => expect(trigger).toBeEnabled());
|
|
31
|
+
fireEvent.click(trigger);
|
|
32
|
+
|
|
33
|
+
await waitFor(() =>
|
|
34
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Bell } from '@tuturuuu/icons';
|
|
4
|
+
import { Button } from '@tuturuuu/ui/button';
|
|
5
|
+
import { PopoverTrigger } from '@tuturuuu/ui/popover';
|
|
6
|
+
import { useEffect, useState } from 'react';
|
|
7
|
+
|
|
8
|
+
interface NotificationPopoverTriggerProps {
|
|
9
|
+
notificationsText: string;
|
|
10
|
+
unreadCount: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function NotificationPopoverTriggerButton({
|
|
14
|
+
notificationsText,
|
|
15
|
+
unreadCount,
|
|
16
|
+
}: NotificationPopoverTriggerProps) {
|
|
17
|
+
const [isHydrated, setIsHydrated] = useState(false);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
setIsHydrated(true);
|
|
21
|
+
}, []);
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<PopoverTrigger asChild>
|
|
25
|
+
<Button
|
|
26
|
+
variant="ghost"
|
|
27
|
+
size="icon"
|
|
28
|
+
aria-label={notificationsText}
|
|
29
|
+
title={notificationsText}
|
|
30
|
+
disabled={!isHydrated}
|
|
31
|
+
className="group relative flex size-10 flex-none transition-all"
|
|
32
|
+
>
|
|
33
|
+
<Bell className="h-6 w-6" />
|
|
34
|
+
{unreadCount > 0 && (
|
|
35
|
+
<div className="absolute top-1 right-2 flex h-1.5 w-1.5 flex-none items-center justify-center rounded-full bg-dynamic-red p-1 text-center font-semibold text-xs transition-all group-hover:-top-2 group-hover:-right-1 group-hover:h-5 group-hover:w-auto group-hover:px-1.5 group-hover:text-background">
|
|
36
|
+
<div className="relative opacity-0 group-hover:opacity-100">
|
|
37
|
+
{unreadCount > 99 ? '99+' : unreadCount}
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
)}
|
|
41
|
+
</Button>
|
|
42
|
+
</PopoverTrigger>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export const DATE_TIME_PICKER_LAYOUT_CLASS_NAMES = {
|
|
2
|
+
calendar: 'min-w-0 overflow-x-auto p-2',
|
|
3
|
+
container: '@container min-w-0 overflow-hidden',
|
|
4
|
+
layout: 'flex min-w-0 flex-col @[34rem]:flex-row',
|
|
5
|
+
time: 'flex min-w-0 flex-col gap-3 border-t p-3 @[34rem]:w-52 @[34rem]:shrink-0 @[34rem]:border-t-0 @[34rem]:border-l',
|
|
6
|
+
} as const;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { DATE_TIME_PICKER_LAYOUT_CLASS_NAMES } from './date-time-picker-layout';
|
|
3
|
+
|
|
4
|
+
describe('DateTimePicker responsive layout', () => {
|
|
5
|
+
it('switches calendar and time controls from the picker container width', () => {
|
|
6
|
+
expect(DATE_TIME_PICKER_LAYOUT_CLASS_NAMES.container).toContain(
|
|
7
|
+
'@container'
|
|
8
|
+
);
|
|
9
|
+
expect(DATE_TIME_PICKER_LAYOUT_CLASS_NAMES.layout).toContain(
|
|
10
|
+
'@[34rem]:flex-row'
|
|
11
|
+
);
|
|
12
|
+
expect(DATE_TIME_PICKER_LAYOUT_CLASS_NAMES.time).toContain(
|
|
13
|
+
'@[34rem]:border-l'
|
|
14
|
+
);
|
|
15
|
+
expect(DATE_TIME_PICKER_LAYOUT_CLASS_NAMES.layout).not.toContain('sm:');
|
|
16
|
+
expect(DATE_TIME_PICKER_LAYOUT_CLASS_NAMES.time).not.toContain('sm:');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('contains narrow calendars instead of letting them escape the dialog', () => {
|
|
20
|
+
expect(DATE_TIME_PICKER_LAYOUT_CLASS_NAMES.calendar).toContain('min-w-0');
|
|
21
|
+
expect(DATE_TIME_PICKER_LAYOUT_CLASS_NAMES.calendar).toContain(
|
|
22
|
+
'overflow-x-auto'
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
useRef,
|
|
32
32
|
useState,
|
|
33
33
|
} from 'react';
|
|
34
|
+
import { DATE_TIME_PICKER_LAYOUT_CLASS_NAMES } from './date-time-picker-layout';
|
|
34
35
|
import { Separator } from './separator';
|
|
35
36
|
|
|
36
37
|
interface DateTimePickerProps {
|
|
@@ -63,7 +64,6 @@ interface DateTimePickerProps {
|
|
|
63
64
|
};
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
// Utility to find the nearest scrollable parent
|
|
67
67
|
function getScrollableParent(node: HTMLElement | null): HTMLElement | null {
|
|
68
68
|
if (!node) return null;
|
|
69
69
|
let parent = node.parentElement;
|
|
@@ -530,7 +530,7 @@ export function DateTimePicker({
|
|
|
530
530
|
: undefined;
|
|
531
531
|
|
|
532
532
|
const timeControl = showTimeSelect ? (
|
|
533
|
-
<div className=
|
|
533
|
+
<div className={DATE_TIME_PICKER_LAYOUT_CLASS_NAMES.time}>
|
|
534
534
|
<div className="space-y-1">
|
|
535
535
|
<div className="flex items-center gap-2 font-medium text-sm">
|
|
536
536
|
<Clock className="h-4 w-4 text-muted-foreground" />
|
|
@@ -628,7 +628,7 @@ export function DateTimePicker({
|
|
|
628
628
|
|
|
629
629
|
// Inline content (used both for inline mode and popover content)
|
|
630
630
|
const pickerContent = (
|
|
631
|
-
<div className=
|
|
631
|
+
<div className={DATE_TIME_PICKER_LAYOUT_CLASS_NAMES.container}>
|
|
632
632
|
{date && (
|
|
633
633
|
<div className="border-b bg-muted/30 px-3 py-2">
|
|
634
634
|
<div className="flex min-w-0 items-center gap-2 text-sm">
|
|
@@ -646,8 +646,8 @@ export function DateTimePicker({
|
|
|
646
646
|
</div>
|
|
647
647
|
)}
|
|
648
648
|
|
|
649
|
-
<div className=
|
|
650
|
-
<div className=
|
|
649
|
+
<div className={DATE_TIME_PICKER_LAYOUT_CLASS_NAMES.layout}>
|
|
650
|
+
<div className={DATE_TIME_PICKER_LAYOUT_CLASS_NAMES.calendar}>
|
|
651
651
|
<Calendar
|
|
652
652
|
mode="single"
|
|
653
653
|
selected={date}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { getRichTextEditorClasses } from '../editor-classes';
|
|
3
|
+
|
|
4
|
+
describe('rich text editor classes', () => {
|
|
5
|
+
it('keeps regular and checklist nesting visibly indented', () => {
|
|
6
|
+
const classes = getRichTextEditorClasses({ readOnly: false });
|
|
7
|
+
|
|
8
|
+
expect(classes).toContain('[&_li_ul]:ml-6');
|
|
9
|
+
expect(classes).toContain('[&_li_ol]:ml-6');
|
|
10
|
+
expect(classes).toContain(
|
|
11
|
+
'[&_ul[data-type="taskList"]_ul[data-type="taskList"]]:ml-6'
|
|
12
|
+
);
|
|
13
|
+
expect(classes).not.toContain(
|
|
14
|
+
'[&_ul[data-type="taskList"]_ul[data-type="taskList"]]:ml-0'
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('styles toggle summaries by heading level and indents their body', () => {
|
|
19
|
+
const classes = getRichTextEditorClasses({ readOnly: false });
|
|
20
|
+
|
|
21
|
+
expect(classes).toContain('[&_summary[data-heading-level="1"]]:text-4xl');
|
|
22
|
+
expect(classes).toContain('[&_summary[data-heading-level="2"]]:text-3xl');
|
|
23
|
+
expect(classes).toContain('[&_summary[data-heading-level="3"]]:text-2xl');
|
|
24
|
+
expect(classes).toContain('[&_div[data-type="detailsContent"]]:border-l');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('retains read-only interaction guards and custom classes', () => {
|
|
28
|
+
const classes = getRichTextEditorClasses({
|
|
29
|
+
className: 'task-description-editor',
|
|
30
|
+
readOnly: true,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
expect(classes).toContain('task-description-editor');
|
|
34
|
+
expect(classes).toContain('[&_.task-list-checkbox]:!pointer-events-none');
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { __imageExtensionPrivate } from '../image-extension';
|
|
3
|
+
|
|
4
|
+
describe('ImageExtension clipboard files', () => {
|
|
5
|
+
it('keeps images when screenshot clipboards advertise text and HTML', () => {
|
|
6
|
+
const imageFile = new File(['image'], 'screenshot.png', {
|
|
7
|
+
type: 'image/png',
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const images = __imageExtensionPrivate.getClipboardImageFiles([
|
|
11
|
+
{ type: 'text/plain', getAsFile: () => null } as DataTransferItem,
|
|
12
|
+
{ type: 'text/html', getAsFile: () => null } as DataTransferItem,
|
|
13
|
+
{
|
|
14
|
+
type: 'image/png',
|
|
15
|
+
getAsFile: () => imageFile,
|
|
16
|
+
} as DataTransferItem,
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
expect(images).toEqual([imageFile]);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -19,6 +19,7 @@ function createEditorStub(): Editor {
|
|
|
19
19
|
toggleSubscript: vi.fn(() => chain),
|
|
20
20
|
toggleSuperscript: vi.fn(() => chain),
|
|
21
21
|
toggleTaskListSmart: vi.fn(() => chain),
|
|
22
|
+
toggleDetailsBlock: vi.fn(() => chain),
|
|
22
23
|
run: vi.fn(() => true),
|
|
23
24
|
};
|
|
24
25
|
|
|
@@ -43,4 +44,33 @@ describe('inline task conversion toolbar', () => {
|
|
|
43
44
|
|
|
44
45
|
expect(onConvertToTask).toHaveBeenCalledTimes(1);
|
|
45
46
|
});
|
|
47
|
+
|
|
48
|
+
it('exposes toggle blocks and delegates the command through the editor chain', () => {
|
|
49
|
+
const editor = createEditorStub();
|
|
50
|
+
render(<FixedToolbar editor={editor} />);
|
|
51
|
+
|
|
52
|
+
fireEvent.click(
|
|
53
|
+
screen.getByRole('button', { name: 'Toggle List or Heading' })
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const chain = vi.mocked(editor.chain).mock.results[0]?.value as {
|
|
57
|
+
toggleDetailsBlock: ReturnType<typeof vi.fn>;
|
|
58
|
+
run: ReturnType<typeof vi.fn>;
|
|
59
|
+
};
|
|
60
|
+
expect(chain.toggleDetailsBlock).toHaveBeenCalledTimes(1);
|
|
61
|
+
expect(chain.run).toHaveBeenCalled();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('uses the localized toggle block label when provided', () => {
|
|
65
|
+
render(
|
|
66
|
+
<FixedToolbar
|
|
67
|
+
editor={createEditorStub()}
|
|
68
|
+
toggleBlockLabel="Liste ou titre repliable"
|
|
69
|
+
/>
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
expect(
|
|
73
|
+
screen.getByRole('button', { name: 'Liste ou titre repliable' })
|
|
74
|
+
).toBeInTheDocument();
|
|
75
|
+
});
|
|
46
76
|
});
|
|
@@ -2,7 +2,7 @@ import { Schema } from '@tiptap/pm/model';
|
|
|
2
2
|
import { EditorState, TextSelection } from '@tiptap/pm/state';
|
|
3
3
|
import type { EditorView } from '@tiptap/pm/view';
|
|
4
4
|
import { describe, expect, it, vi } from 'vitest';
|
|
5
|
-
import { handlePlainEnterFallback } from '../keyboard';
|
|
5
|
+
import { handleListIndentation, handlePlainEnterFallback } from '../keyboard';
|
|
6
6
|
|
|
7
7
|
const schema = new Schema({
|
|
8
8
|
nodes: {
|
|
@@ -15,6 +15,10 @@ const schema = new Schema({
|
|
|
15
15
|
group: 'block',
|
|
16
16
|
content: 'listItem+',
|
|
17
17
|
},
|
|
18
|
+
orderedList: {
|
|
19
|
+
group: 'block',
|
|
20
|
+
content: 'listItem+',
|
|
21
|
+
},
|
|
18
22
|
listItem: {
|
|
19
23
|
content: 'paragraph block*',
|
|
20
24
|
defining: true,
|
|
@@ -37,11 +41,19 @@ const schema = new Schema({
|
|
|
37
41
|
marks: {},
|
|
38
42
|
});
|
|
39
43
|
|
|
40
|
-
function stateWithSelection(
|
|
44
|
+
function stateWithSelection(
|
|
45
|
+
doc: ReturnType<typeof schema.node>,
|
|
46
|
+
offset = 1,
|
|
47
|
+
targetText?: string
|
|
48
|
+
) {
|
|
41
49
|
let textStart: number | null = null;
|
|
42
50
|
|
|
43
51
|
doc.descendants((node, pos) => {
|
|
44
|
-
if (
|
|
52
|
+
if (
|
|
53
|
+
textStart === null &&
|
|
54
|
+
node.isText &&
|
|
55
|
+
(!targetText || node.text === targetText)
|
|
56
|
+
) {
|
|
45
57
|
textStart = pos;
|
|
46
58
|
return false;
|
|
47
59
|
}
|
|
@@ -69,6 +81,19 @@ function enterEvent(overrides: Partial<KeyboardEvent> = {}) {
|
|
|
69
81
|
} as unknown as KeyboardEvent;
|
|
70
82
|
}
|
|
71
83
|
|
|
84
|
+
function tabEvent(overrides: Partial<KeyboardEvent> = {}) {
|
|
85
|
+
return {
|
|
86
|
+
altKey: false,
|
|
87
|
+
ctrlKey: false,
|
|
88
|
+
key: 'Tab',
|
|
89
|
+
metaKey: false,
|
|
90
|
+
preventDefault: vi.fn(),
|
|
91
|
+
shiftKey: false,
|
|
92
|
+
stopPropagation: vi.fn(),
|
|
93
|
+
...overrides,
|
|
94
|
+
} as unknown as KeyboardEvent;
|
|
95
|
+
}
|
|
96
|
+
|
|
72
97
|
function viewForState(state: EditorState) {
|
|
73
98
|
let currentState = state;
|
|
74
99
|
const view = {
|
|
@@ -173,4 +198,149 @@ describe('text editor keyboard handling', () => {
|
|
|
173
198
|
expect(handlePlainEnterFallback(view, event)).toBe(false);
|
|
174
199
|
expect(event.preventDefault).not.toHaveBeenCalled();
|
|
175
200
|
});
|
|
201
|
+
|
|
202
|
+
it.each(['bulletList', 'orderedList'] as const)(
|
|
203
|
+
'indents and outdents a second %s item with Tab and Shift+Tab',
|
|
204
|
+
(listType) => {
|
|
205
|
+
const state = stateWithSelection(
|
|
206
|
+
schema.node('doc', null, [
|
|
207
|
+
schema.node(listType, null, [
|
|
208
|
+
schema.node('listItem', null, [
|
|
209
|
+
schema.node('paragraph', null, schema.text('first')),
|
|
210
|
+
]),
|
|
211
|
+
schema.node('listItem', null, [
|
|
212
|
+
schema.node('paragraph', null, schema.text('second')),
|
|
213
|
+
]),
|
|
214
|
+
]),
|
|
215
|
+
]),
|
|
216
|
+
1,
|
|
217
|
+
'second'
|
|
218
|
+
);
|
|
219
|
+
const { view, getState } = viewForState(state);
|
|
220
|
+
const indentEvent = tabEvent();
|
|
221
|
+
|
|
222
|
+
expect(handleListIndentation(view, indentEvent)).toBe(true);
|
|
223
|
+
expect(indentEvent.preventDefault).toHaveBeenCalledTimes(1);
|
|
224
|
+
expect(indentEvent.stopPropagation).toHaveBeenCalledTimes(1);
|
|
225
|
+
expect(getState().doc.toJSON()).toMatchObject({
|
|
226
|
+
content: [
|
|
227
|
+
{
|
|
228
|
+
type: listType,
|
|
229
|
+
content: [
|
|
230
|
+
{
|
|
231
|
+
type: 'listItem',
|
|
232
|
+
content: [
|
|
233
|
+
{ type: 'paragraph' },
|
|
234
|
+
{
|
|
235
|
+
type: listType,
|
|
236
|
+
content: [{ type: 'listItem' }],
|
|
237
|
+
},
|
|
238
|
+
],
|
|
239
|
+
},
|
|
240
|
+
],
|
|
241
|
+
},
|
|
242
|
+
],
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const outdentEvent = tabEvent({ shiftKey: true });
|
|
246
|
+
expect(handleListIndentation(view, outdentEvent)).toBe(true);
|
|
247
|
+
expect(getState().doc.child(0).childCount).toBe(2);
|
|
248
|
+
}
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
it('indents and outdents checklist items', () => {
|
|
252
|
+
const state = stateWithSelection(
|
|
253
|
+
schema.node('doc', null, [
|
|
254
|
+
schema.node('taskList', null, [
|
|
255
|
+
schema.node('taskItem', null, [
|
|
256
|
+
schema.node('paragraph', null, schema.text('first')),
|
|
257
|
+
]),
|
|
258
|
+
schema.node('taskItem', null, [
|
|
259
|
+
schema.node('paragraph', null, schema.text('second')),
|
|
260
|
+
]),
|
|
261
|
+
]),
|
|
262
|
+
]),
|
|
263
|
+
1,
|
|
264
|
+
'second'
|
|
265
|
+
);
|
|
266
|
+
const { view, getState } = viewForState(state);
|
|
267
|
+
|
|
268
|
+
expect(handleListIndentation(view, tabEvent())).toBe(true);
|
|
269
|
+
expect(getState().doc.toJSON()).toMatchObject({
|
|
270
|
+
content: [
|
|
271
|
+
{
|
|
272
|
+
type: 'taskList',
|
|
273
|
+
content: [
|
|
274
|
+
{
|
|
275
|
+
type: 'taskItem',
|
|
276
|
+
content: [
|
|
277
|
+
{ type: 'paragraph' },
|
|
278
|
+
{
|
|
279
|
+
type: 'taskList',
|
|
280
|
+
content: [{ type: 'taskItem' }],
|
|
281
|
+
},
|
|
282
|
+
],
|
|
283
|
+
},
|
|
284
|
+
],
|
|
285
|
+
},
|
|
286
|
+
],
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
expect(handleListIndentation(view, tabEvent({ shiftKey: true }))).toBe(
|
|
290
|
+
true
|
|
291
|
+
);
|
|
292
|
+
expect(getState().doc.child(0).childCount).toBe(2);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('does not consume Tab outside a list or when the first item cannot indent', () => {
|
|
296
|
+
const paragraph = viewForState(
|
|
297
|
+
stateWithSelection(
|
|
298
|
+
schema.node('doc', null, [
|
|
299
|
+
schema.node('paragraph', null, schema.text('plain')),
|
|
300
|
+
])
|
|
301
|
+
)
|
|
302
|
+
);
|
|
303
|
+
const paragraphEvent = tabEvent();
|
|
304
|
+
|
|
305
|
+
expect(handleListIndentation(paragraph.view, paragraphEvent)).toBe(false);
|
|
306
|
+
expect(paragraphEvent.preventDefault).not.toHaveBeenCalled();
|
|
307
|
+
|
|
308
|
+
const firstItem = viewForState(
|
|
309
|
+
stateWithSelection(
|
|
310
|
+
schema.node('doc', null, [
|
|
311
|
+
schema.node('bulletList', null, [
|
|
312
|
+
schema.node('listItem', null, [
|
|
313
|
+
schema.node('paragraph', null, schema.text('only')),
|
|
314
|
+
]),
|
|
315
|
+
]),
|
|
316
|
+
])
|
|
317
|
+
)
|
|
318
|
+
);
|
|
319
|
+
const firstItemEvent = tabEvent();
|
|
320
|
+
|
|
321
|
+
expect(handleListIndentation(firstItem.view, firstItemEvent)).toBe(false);
|
|
322
|
+
expect(firstItemEvent.preventDefault).not.toHaveBeenCalled();
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('does not hijack modified Tab shortcuts', () => {
|
|
326
|
+
const state = stateWithSelection(
|
|
327
|
+
schema.node('doc', null, [
|
|
328
|
+
schema.node('bulletList', null, [
|
|
329
|
+
schema.node('listItem', null, [
|
|
330
|
+
schema.node('paragraph', null, schema.text('first')),
|
|
331
|
+
]),
|
|
332
|
+
schema.node('listItem', null, [
|
|
333
|
+
schema.node('paragraph', null, schema.text('second')),
|
|
334
|
+
]),
|
|
335
|
+
]),
|
|
336
|
+
]),
|
|
337
|
+
1,
|
|
338
|
+
'second'
|
|
339
|
+
);
|
|
340
|
+
const { view } = viewForState(state);
|
|
341
|
+
|
|
342
|
+
expect(handleListIndentation(view, tabEvent({ metaKey: true }))).toBe(
|
|
343
|
+
false
|
|
344
|
+
);
|
|
345
|
+
});
|
|
176
346
|
});
|