@tuturuuu/ui 0.28.0 → 0.29.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,28 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.29.0](https://github.com/tutur3u/platform/compare/ui-v0.28.1...ui-v0.29.0) (2026-08-20)
4
+
5
+
6
+ ### Features
7
+
8
+ * **editor:** add copy format menu ([389a6f0](https://github.com/tutur3u/platform/commit/389a6f02d822feef271409a9ab31e8fcbb6ef18e))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **editor:** contain toolbar overflow ([6bda614](https://github.com/tutur3u/platform/commit/6bda61416cbd2e4d4d4af7c7337d7b4e905274ef))
14
+ * **editor:** keep copy menu in toolbar ([c9dd241](https://github.com/tutur3u/platform/commit/c9dd2416f7e1b397c3293445d0bd0564a48c6bda))
15
+ * **editor:** preserve semantic rich paste ([bea67ca](https://github.com/tutur3u/platform/commit/bea67cac57b52b82a9ed4d7fd1a8a892cf4ceea4))
16
+ * **editor:** preserve task clipboard structure ([2d05d86](https://github.com/tutur3u/platform/commit/2d05d8602f39975805944edfccb2e10818863413))
17
+ * **tasks:** restore pasted image uploads ([3edef4e](https://github.com/tutur3u/platform/commit/3edef4e04a1fc6963904b7861d422f8db9d5b749))
18
+
19
+ ## [0.28.1](https://github.com/tutur3u/platform/compare/ui-v0.28.0...ui-v0.28.1) (2026-08-16)
20
+
21
+
22
+ ### Bug Fixes
23
+
24
+ * **tasks:** preserve rich paste and responsive dialogs ([0a6c5a3](https://github.com/tutur3u/platform/commit/0a6c5a362f51b475c2e8fe984eb3569a46a878ee))
25
+
3
26
  ## [0.28.0](https://github.com/tutur3u/platform/compare/ui-v0.27.0...ui-v0.28.0) (2026-08-15)
4
27
 
5
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tuturuuu/ui",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -91,9 +91,9 @@
91
91
  "@tuturuuu/apis": "0.11.2",
92
92
  "@tuturuuu/hooks": "0.1.1",
93
93
  "@tuturuuu/icons": "0.1.0",
94
- "@tuturuuu/internal-api": "0.31.0",
94
+ "@tuturuuu/internal-api": "0.31.1",
95
95
  "@tuturuuu/supabase": "0.5.0",
96
- "@tuturuuu/utils": "0.25.1",
96
+ "@tuturuuu/utils": "0.25.3",
97
97
  "@types/debug": "^4.1.13",
98
98
  "browser-image-compression": "^2.0.2",
99
99
  "class-variance-authority": "^0.7.1",
@@ -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,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,87 @@
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
+
22
+ it('names an unnamed screenshot from its clipboard MIME type', () => {
23
+ const unnamedImage = new File(['image'], '', { type: 'image/png' });
24
+
25
+ const [image] = __imageExtensionPrivate.getClipboardImageFiles([
26
+ {
27
+ type: 'image/png',
28
+ getAsFile: () => unnamedImage,
29
+ } as DataTransferItem,
30
+ ]);
31
+
32
+ expect(image).toBeInstanceOf(File);
33
+ expect(image?.name).toBe('pasted-image.png');
34
+ expect(image?.type).toBe('image/png');
35
+ expect(image?.size).toBe(unnamedImage.size);
36
+ });
37
+
38
+ it('adds an extension to an extensionless clipboard filename', () => {
39
+ const extensionlessImage = new File(['image'], 'Screenshot', {
40
+ type: 'image/jpeg',
41
+ });
42
+
43
+ const [image] = __imageExtensionPrivate.getClipboardImageFiles([
44
+ {
45
+ type: 'image/jpeg',
46
+ getAsFile: () => extensionlessImage,
47
+ } as DataTransferItem,
48
+ ]);
49
+
50
+ expect(image?.name).toBe('Screenshot.jpg');
51
+ expect(image?.type).toBe('image/jpeg');
52
+ });
53
+
54
+ it('restores a missing file MIME type from the clipboard item', () => {
55
+ const imageWithoutType = new File(['image'], '', { type: '' });
56
+
57
+ const [image] = __imageExtensionPrivate.getClipboardImageFiles([
58
+ {
59
+ type: 'image/webp',
60
+ getAsFile: () => imageWithoutType,
61
+ } as DataTransferItem,
62
+ ]);
63
+
64
+ expect(image?.name).toBe('pasted-image.webp');
65
+ expect(image?.type).toBe('image/webp');
66
+ });
67
+
68
+ it('ignores non-image items and null image files without losing valid images', () => {
69
+ const imageFile = new File(['image'], 'capture.png', {
70
+ type: 'image/png',
71
+ });
72
+
73
+ const images = __imageExtensionPrivate.getClipboardImageFiles([
74
+ {
75
+ type: 'text/plain',
76
+ getAsFile: () => new File(['text'], 'note.txt'),
77
+ } as DataTransferItem,
78
+ { type: 'image/png', getAsFile: () => null } as DataTransferItem,
79
+ {
80
+ type: 'image/png',
81
+ getAsFile: () => imageFile,
82
+ } as DataTransferItem,
83
+ ]);
84
+
85
+ expect(images).toEqual([imageFile]);
86
+ });
87
+ });
@@ -1,9 +1,11 @@
1
- import { fireEvent, render, screen } from '@testing-library/react';
2
- import type { Editor } from '@tiptap/react';
3
- import { describe, expect, it, vi } from 'vitest';
1
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
2
+ import { Editor } from '@tiptap/core';
3
+ import type { Editor as TiptapEditor } from '@tiptap/react';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+ import { getEditorExtensions } from '../extensions';
4
6
  import { FixedToolbar } from '../tool-bar';
5
7
 
6
- function createEditorStub(): Editor {
8
+ function createEditorStub(): TiptapEditor {
7
9
  const chain = {
8
10
  focus: vi.fn(() => chain),
9
11
  insertTable: vi.fn(() => chain),
@@ -26,9 +28,16 @@ function createEditorStub(): Editor {
26
28
  return {
27
29
  chain: vi.fn(() => chain),
28
30
  isActive: vi.fn(() => false),
29
- } as unknown as Editor;
31
+ } as unknown as TiptapEditor;
30
32
  }
31
33
 
34
+ const editors: Editor[] = [];
35
+
36
+ afterEach(() => {
37
+ for (const editor of editors.splice(0)) editor.destroy();
38
+ vi.restoreAllMocks();
39
+ });
40
+
32
41
  describe('inline task conversion toolbar', () => {
33
42
  it('delegates conversion to the provided callback', async () => {
34
43
  const onConvertToTask = vi.fn();
@@ -73,4 +82,106 @@ describe('inline task conversion toolbar', () => {
73
82
  screen.getByRole('button', { name: 'Liste ou titre repliable' })
74
83
  ).toBeInTheDocument();
75
84
  });
85
+
86
+ it('keeps every control in a scrollbar-free horizontal lane without shrinking', () => {
87
+ const { container } = render(
88
+ <FixedToolbar
89
+ editor={createEditorStub()}
90
+ leadingContent={<button type="button">99% left</button>}
91
+ />
92
+ );
93
+ const toolbar = container.firstElementChild;
94
+ const capacityButton = screen.getByRole('button', { name: '99% left' });
95
+ const boldButton = screen.getByRole('button', { name: 'Bold' });
96
+ const copyButton = screen.getByRole('button', { name: 'Copy content' });
97
+
98
+ expect(toolbar).toHaveClass(
99
+ '@container',
100
+ 'flex-nowrap',
101
+ 'max-w-full',
102
+ 'overflow-x-auto',
103
+ 'overflow-y-hidden',
104
+ 'overscroll-x-contain',
105
+ 'scrollbar-hide'
106
+ );
107
+ expect(toolbar).not.toHaveClass('flex-wrap');
108
+ expect(copyButton.closest('.ml-auto')).toBeNull();
109
+ expect(capacityButton.compareDocumentPosition(boldButton)).toBe(
110
+ Node.DOCUMENT_POSITION_FOLLOWING
111
+ );
112
+ expect(copyButton).toHaveClass('h-8', 'w-8', 'shrink-0', 'rounded-md');
113
+ expect(boldButton).toHaveClass('h-8', 'w-8', 'shrink-0', 'rounded-md');
114
+ });
115
+
116
+ it('copies the full document in either Markdown or plain-text form', async () => {
117
+ const editor = new Editor({
118
+ extensions: getEditorExtensions(),
119
+ content: {
120
+ type: 'doc',
121
+ content: [
122
+ {
123
+ type: 'heading',
124
+ attrs: { level: 1 },
125
+ content: [{ type: 'text', text: 'Plan' }],
126
+ },
127
+ {
128
+ type: 'bulletList',
129
+ content: [
130
+ {
131
+ type: 'listItem',
132
+ content: [
133
+ {
134
+ type: 'paragraph',
135
+ content: [
136
+ {
137
+ type: 'text',
138
+ text: 'Ship it',
139
+ marks: [{ type: 'bold' }],
140
+ },
141
+ ],
142
+ },
143
+ ],
144
+ },
145
+ ],
146
+ },
147
+ ],
148
+ },
149
+ });
150
+ editors.push(editor);
151
+ const writeText = vi.fn().mockResolvedValue(undefined);
152
+ Object.defineProperty(navigator, 'clipboard', {
153
+ configurable: true,
154
+ value: { writeText },
155
+ });
156
+
157
+ render(<FixedToolbar editor={editor} />);
158
+
159
+ fireEvent.pointerDown(
160
+ await screen.findByRole('button', { name: 'Copy content' })
161
+ );
162
+ fireEvent.click(
163
+ await screen.findByRole('menuitem', { name: /Copy as Markdown/ })
164
+ );
165
+ await waitFor(() =>
166
+ expect(
167
+ screen.queryByRole('menuitem', { name: /Copy as Markdown/ })
168
+ ).not.toBeInTheDocument()
169
+ );
170
+ await waitFor(() =>
171
+ expect(writeText).toHaveBeenLastCalledWith('# Plan\n\n- **Ship it**')
172
+ );
173
+
174
+ fireEvent.pointerDown(screen.getByRole('button', { name: 'Copy content' }));
175
+ fireEvent.click(
176
+ await screen.findByRole('menuitem', { name: /Copy as plain text/ })
177
+ );
178
+ await waitFor(() =>
179
+ expect(
180
+ screen.queryByRole('menuitem', { name: /Copy as plain text/ })
181
+ ).not.toBeInTheDocument()
182
+ );
183
+ await waitFor(() =>
184
+ expect(writeText).toHaveBeenLastCalledWith('Plan\n\n• Ship it')
185
+ );
186
+ });
76
187
  });