@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.
Files changed (27) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/package.json +4 -3
  3. package/src/components/ui/button.tsx +12 -8
  4. package/src/components/ui/calendar.tsx +6 -6
  5. package/src/components/ui/color-picker.test.tsx +23 -0
  6. package/src/components/ui/color-picker.tsx +1 -1
  7. package/src/components/ui/custom/notification-popover-client.tsx +6 -19
  8. package/src/components/ui/custom/notification-popover-trigger.test.tsx +37 -0
  9. package/src/components/ui/custom/notification-popover-trigger.tsx +44 -0
  10. package/src/components/ui/date-time-picker-layout.ts +6 -0
  11. package/src/components/ui/date-time-picker.test.tsx +25 -0
  12. package/src/components/ui/date-time-picker.tsx +5 -5
  13. package/src/components/ui/text-editor/__tests__/editor-classes.test.ts +36 -0
  14. package/src/components/ui/text-editor/__tests__/image-extension-clipboard.test.ts +21 -0
  15. package/src/components/ui/text-editor/__tests__/inline-task-conversion.test.tsx +30 -0
  16. package/src/components/ui/text-editor/__tests__/keyboard.test.ts +173 -3
  17. package/src/components/ui/text-editor/__tests__/markdown-paste-extension.test.ts +29 -1
  18. package/src/components/ui/text-editor/__tests__/toggle-block-extension.test.ts +251 -0
  19. package/src/components/ui/text-editor/editor-classes.ts +100 -0
  20. package/src/components/ui/text-editor/editor.tsx +11 -153
  21. package/src/components/ui/text-editor/extensions.ts +19 -0
  22. package/src/components/ui/text-editor/image-extension.ts +10 -14
  23. package/src/components/ui/text-editor/keyboard.ts +47 -0
  24. package/src/components/ui/text-editor/markdown-paste-extension.ts +25 -2
  25. package/src/components/ui/text-editor/toggle-block-extension.ts +153 -0
  26. package/src/components/ui/text-editor/tool-bar.tsx +24 -73
  27. 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.27.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.30.0",
94
+ "@tuturuuu/internal-api": "0.31.0",
94
95
  "@tuturuuu/supabase": "0.5.0",
95
- "@tuturuuu/utils": "0.25.0",
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 Comp = asChild ? Slot : 'button';
49
+ const classNames = cn(buttonVariants({ variant, size, className }));
50
50
 
51
- return (
52
- <Comp
53
- data-slot="button"
54
- className={cn(buttonVariants({ variant, size, className }))}
55
- {...props}
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
- 'space-y-4 max-w-[calc(100vw-4rem)] text-center p-2 font-semibold shrink-0',
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-1 mt-2',
329
- weekdays: 'grid w-full grid-cols-7 gap-1 mb-2',
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: 'text-center text-sm p-0 relative w-9',
332
+ day: 'relative min-w-0 p-0 text-center text-sm',
333
333
  day_button: cn(
334
334
  buttonVariants({ variant: 'ghost' }),
335
- 'h-9 w-full rounded-md p-0 font-normal transition-colors duration-300',
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, PopoverTrigger } from '@tuturuuu/ui/popover';
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
- <PopoverTrigger asChild>
194
- <Button
195
- variant="ghost"
196
- size="icon"
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="flex min-w-0 flex-col gap-3 border-t p-3 sm:w-52 sm:border-t-0 sm:border-l">
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="overflow-hidden">
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="flex flex-col sm:flex-row">
650
- <div className="p-2">
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(doc: ReturnType<typeof schema.node>, offset = 1) {
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 (textStart === null && node.isText) {
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
  });