@tuturuuu/ui 0.27.0 → 0.28.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 CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.28.0](https://github.com/tutur3u/platform/compare/ui-v0.27.0...ui-v0.28.0) (2026-08-15)
4
+
5
+
6
+ ### Features
7
+
8
+ * **tasks:** add toggle blocks and repair editor indentation ([c0805c8](https://github.com/tutur3u/platform/commit/c0805c8cc0e26c172eb6f46582e267b084289501))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **contacts:** restore group tag labels ([9fbcb25](https://github.com/tutur3u/platform/commit/9fbcb25bd44fcedcfad4a463599940e9497ebc06))
14
+ * **notifications:** prevent pre-hydration bell clicks ([6adba83](https://github.com/tutur3u/platform/commit/6adba83f3db63817cc64e332c33a6a2939e0bf83))
15
+ * **ui:** support button slots with TypeScript 7 ([3bdc636](https://github.com/tutur3u/platform/commit/3bdc6368daf0be48719a479c63a6325d414233d6))
16
+
3
17
  ## [0.27.0](https://github.com/tutur3u/platform/compare/ui-v0.26.1...ui-v0.27.0) (2026-08-14)
4
18
 
5
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tuturuuu/ui",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
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.1",
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>;
@@ -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,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
+ });
@@ -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
  });