@tuturuuu/ui 0.28.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 CHANGED
@@ -1,5 +1,12 @@
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
+
3
10
  ## [0.28.0](https://github.com/tutur3u/platform/compare/ui-v0.27.0...ui-v0.28.0) (2026-08-15)
4
11
 
5
12
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tuturuuu/ui",
3
- "version": "0.28.0",
3
+ "version": "0.28.1",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -93,7 +93,7 @@
93
93
  "@tuturuuu/icons": "0.1.0",
94
94
  "@tuturuuu/internal-api": "0.31.0",
95
95
  "@tuturuuu/supabase": "0.5.0",
96
- "@tuturuuu/utils": "0.25.1",
96
+ "@tuturuuu/utils": "0.25.2",
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,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
+ });
@@ -1,7 +1,8 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { __markdownPastePrivate } from '../markdown-paste-extension';
3
3
 
4
- const { markdownToHtml } = __markdownPastePrivate;
4
+ const { markdownToHtml, looksLikeMarkdown, normalizePastedPlainText } =
5
+ __markdownPastePrivate;
5
6
 
6
7
  describe('markdownToHtml', () => {
7
8
  it('should convert headings', () => {
@@ -47,6 +48,27 @@ describe('markdownToHtml', () => {
47
48
  expect(html).toContain('<li><p>item 2</p></li>');
48
49
  });
49
50
 
51
+ it('normalizes copied visual bullets into structured list markers', () => {
52
+ const text = normalizePastedPlainText(
53
+ 'Next steps\r\n• Draft the interview plan\r\n◦ Share it with the team'
54
+ );
55
+
56
+ expect(text).toBe(
57
+ 'Next steps\n- Draft the interview plan\n- Share it with the team'
58
+ );
59
+ expect(looksLikeMarkdown(text)).toBe(true);
60
+ expect(markdownToHtml(text)).toContain(
61
+ '<ul><li><p>Draft the interview plan</p></li><li><p>Share it with the team</p></li></ul>'
62
+ );
63
+ });
64
+
65
+ it('normalizes copied visual checkboxes into task list markers', () => {
66
+ const text = normalizePastedPlainText('☐ Plan interviews\n☑ Invite team');
67
+
68
+ expect(text).toBe('- [ ] Plan interviews\n- [x] Invite team');
69
+ expect(markdownToHtml(text)).toContain('data-type="taskList"');
70
+ });
71
+
50
72
  it('should convert ordered lists', () => {
51
73
  const md = '1. first\n2. second';
52
74
  const html = markdownToHtml(md);
@@ -210,6 +232,12 @@ describe('markdownToHtml', () => {
210
232
  expect(html).toContain('<p>Hello world</p>');
211
233
  });
212
234
 
235
+ it('preserves intentional line breaks inside pasted paragraphs', () => {
236
+ const html = markdownToHtml('Line one\nLine two');
237
+
238
+ expect(html).toContain('<p>Line one<br>Line two</p>');
239
+ });
240
+
213
241
  it('should escape HTML in plain text', () => {
214
242
  const md = '<script>alert(1)</script>';
215
243
  const html = markdownToHtml(md);
@@ -85,9 +85,18 @@ function resolveUploadHandler({
85
85
  return configuredHandler;
86
86
  }
87
87
 
88
+ function getClipboardImageFiles(
89
+ items: DataTransferItemList | DataTransferItem[]
90
+ ) {
91
+ return Array.from(items)
92
+ .map((item) => (item.type.startsWith('image/') ? item.getAsFile() : null))
93
+ .filter((file): file is File => file !== null);
94
+ }
95
+
88
96
  export const __imageExtensionPrivate = {
89
97
  clearImageResizeUIFromNodeDom,
90
98
  getSelectedImagePos,
99
+ getClipboardImageFiles,
91
100
  resolveUploadHandler,
92
101
  };
93
102
 
@@ -437,23 +446,10 @@ export const CustomImage = (options: ImageOptions = {}) => {
437
446
  if (!items) return false;
438
447
 
439
448
  // Filter and collect image files
440
- const images = Array.from(items)
441
- .map((item) =>
442
- item.type.startsWith('image/') ? item.getAsFile() : null
443
- )
444
- .filter((file): file is File => file !== null);
449
+ const images = getClipboardImageFiles(items);
445
450
 
446
451
  if (images.length === 0) return false;
447
452
 
448
- const hasTextOrHtml = Array.from(items).some(
449
- (item) =>
450
- item.type === 'text/plain' || item.type === 'text/html'
451
- );
452
-
453
- if (hasTextOrHtml) {
454
- return false;
455
- }
456
-
457
453
  const onImageUpload = getOnImageUpload();
458
454
  if (!onImageUpload) {
459
455
  event.preventDefault();
@@ -27,6 +27,26 @@ const BLOCK_START_PATTERNS = [
27
27
  /^\s*\|/,
28
28
  ];
29
29
 
30
+ const VISUAL_BULLET_PATTERN = /^(\s*)[•◦▪‣●]\s+/u;
31
+ const VISUAL_CHECKBOX_PATTERN = /^(\s*)[☐☑☒]\s+/u;
32
+
33
+ function normalizePastedPlainText(text: string): string {
34
+ return text
35
+ .replace(/\r\n?/g, '\n')
36
+ .split('\n')
37
+ .map((line) => {
38
+ const checkboxMatch = line.match(VISUAL_CHECKBOX_PATTERN);
39
+ if (checkboxMatch) {
40
+ const marker = line.trimStart().charAt(0);
41
+ const content = line.replace(VISUAL_CHECKBOX_PATTERN, '');
42
+ return `${checkboxMatch[1] ?? ''}- [${marker === '☐' ? ' ' : 'x'}] ${content}`;
43
+ }
44
+
45
+ return line.replace(VISUAL_BULLET_PATTERN, '$1- ');
46
+ })
47
+ .join('\n');
48
+ }
49
+
30
50
  function isBlockStart(line: string): boolean {
31
51
  return BLOCK_START_PATTERNS.some((p) => p.test(line));
32
52
  }
@@ -496,7 +516,7 @@ function markdownToHtml(markdown: string): string {
496
516
  paraLines.push(lines[i]!);
497
517
  i++;
498
518
  }
499
- result.push(`<p>${parseInline(paraLines.join(' '))}</p>`);
519
+ result.push(`<p>${paraLines.map(parseInline).join('<br>')}</p>`);
500
520
  }
501
521
 
502
522
  return result.join('\n');
@@ -539,6 +559,7 @@ function looksLikeMarkdown(text: string): boolean {
539
559
  export const __markdownPastePrivate = {
540
560
  markdownToHtml,
541
561
  looksLikeMarkdown,
562
+ normalizePastedPlainText,
542
563
  };
543
564
 
544
565
  const markdownPastePluginKey = new PluginKey('markdownPastePlugin');
@@ -569,7 +590,9 @@ export const MarkdownPaste = Extension.create({
569
590
  return false;
570
591
  }
571
592
 
572
- const text = clipboardData.getData('text/plain');
593
+ const text = normalizePastedPlainText(
594
+ clipboardData.getData('text/plain')
595
+ );
573
596
  if (!text || !looksLikeMarkdown(text)) {
574
597
  return false;
575
598
  }