paris 0.18.1 → 0.20.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/package.json +31 -2
  3. package/src/stories/accordion/Accordion.test.tsx +140 -0
  4. package/src/stories/accordionselect/AccordionSelect.test.tsx +252 -0
  5. package/src/stories/avatar/Avatar.test.tsx +77 -0
  6. package/src/stories/button/Button.test.tsx +266 -0
  7. package/src/stories/callout/Callout.test.tsx +79 -0
  8. package/src/stories/card/Card.test.tsx +81 -0
  9. package/src/stories/cardbutton/CardButton.test.tsx +174 -0
  10. package/src/stories/checkbox/Checkbox.test.tsx +531 -0
  11. package/src/stories/combobox/Combobox.test.tsx +164 -0
  12. package/src/stories/dialog/Dialog.module.scss +2 -2
  13. package/src/stories/dialog/Dialog.test.tsx +244 -0
  14. package/src/stories/drawer/Drawer.module.scss +2 -2
  15. package/src/stories/drawer/Drawer.test.tsx +259 -0
  16. package/src/stories/field/Field.test.tsx +146 -0
  17. package/src/stories/icon/Icon.test.tsx +59 -0
  18. package/src/stories/informationaltooltip/InformationalTooltip.test.tsx +178 -0
  19. package/src/stories/input/Input.test.tsx +174 -0
  20. package/src/stories/markdown/Markdown.test.tsx +228 -0
  21. package/src/stories/markdowneditor/FixedToolbar.module.scss +24 -0
  22. package/src/stories/markdowneditor/FixedToolbar.tsx +274 -0
  23. package/src/stories/markdowneditor/FloatingToolbar.module.scss +21 -0
  24. package/src/stories/markdowneditor/FloatingToolbar.tsx +94 -0
  25. package/src/stories/markdowneditor/LinkPopover.module.scss +124 -0
  26. package/src/stories/markdowneditor/LinkPopover.tsx +135 -0
  27. package/src/stories/markdowneditor/MarkdownEditor.module.scss +405 -0
  28. package/src/stories/markdowneditor/MarkdownEditor.stories.tsx +226 -0
  29. package/src/stories/markdowneditor/MarkdownEditor.test.tsx +115 -0
  30. package/src/stories/markdowneditor/MarkdownEditor.tsx +133 -0
  31. package/src/stories/markdowneditor/MarkdownEditorContext.tsx +20 -0
  32. package/src/stories/markdowneditor/ToolbarButton.module.scss +35 -0
  33. package/src/stories/markdowneditor/ToolbarButton.tsx +52 -0
  34. package/src/stories/markdowneditor/features.ts +92 -0
  35. package/src/stories/markdowneditor/index.ts +12 -0
  36. package/src/stories/markdowneditor/useMarkdownEditor.ts +75 -0
  37. package/src/stories/menu/Menu.module.scss +1 -1
  38. package/src/stories/menu/Menu.test.tsx +211 -0
  39. package/src/stories/pagination/usePagination.test.ts +259 -0
  40. package/src/stories/popover/Popover.test.tsx +152 -0
  41. package/src/stories/select/Select.module.scss +2 -1
  42. package/src/stories/select/Select.test.tsx +233 -0
  43. package/src/stories/styledlink/StyledLink.test.tsx +59 -0
  44. package/src/stories/table/Table.test.tsx +156 -0
  45. package/src/stories/tabs/Tabs.module.scss +1 -3
  46. package/src/stories/tabs/Tabs.test.tsx +167 -0
  47. package/src/stories/tabs/Tabs.tsx +9 -0
  48. package/src/stories/tag/Tag.test.tsx +90 -0
  49. package/src/stories/text/Text.test.tsx +81 -0
  50. package/src/stories/textarea/TextArea.test.tsx +147 -0
  51. package/src/stories/theme/themes.ts +16 -0
  52. package/src/stories/tilt/Tilt.test.tsx +203 -0
  53. package/src/stories/toast/Toast.test.tsx +86 -0
  54. package/src/stories/utility/Dropdown.module.scss +1 -1
  55. package/src/stories/utility/Utility.test.tsx +96 -0
  56. package/src/test/render.tsx +20 -0
  57. package/src/test/setup.ts +32 -0
@@ -0,0 +1,228 @@
1
+ import { render, screen, waitFor } from '../../test/render';
2
+ import { Markdown } from './Markdown';
3
+
4
+ describe('Markdown', () => {
5
+ describe('headings', () => {
6
+ it('renders h1', async () => {
7
+ render(<Markdown>{'# Heading 1'}</Markdown>);
8
+ await waitFor(() => {
9
+ expect(screen.getByText('Heading 1')).toBeInTheDocument();
10
+ });
11
+ expect(screen.getByText('Heading 1').closest('h1')).toBeInTheDocument();
12
+ });
13
+
14
+ it('renders h2', async () => {
15
+ render(<Markdown>{'## Heading 2'}</Markdown>);
16
+ await waitFor(() => {
17
+ expect(screen.getByText('Heading 2').closest('h2')).toBeInTheDocument();
18
+ });
19
+ });
20
+
21
+ it('renders h3', async () => {
22
+ render(<Markdown>{'### Heading 3'}</Markdown>);
23
+ await waitFor(() => {
24
+ expect(screen.getByText('Heading 3').closest('h3')).toBeInTheDocument();
25
+ });
26
+ });
27
+
28
+ it('generates slug IDs for headings', async () => {
29
+ render(<Markdown>{'# Hello World'}</Markdown>);
30
+ await waitFor(() => {
31
+ expect(screen.getByText('Hello World')).toHaveAttribute('id', 'hello-world');
32
+ });
33
+ });
34
+ });
35
+
36
+ describe('paragraphs and inline text', () => {
37
+ it('renders paragraphs', async () => {
38
+ render(<Markdown>{'This is a paragraph.'}</Markdown>);
39
+ await waitFor(() => {
40
+ expect(screen.getByText('This is a paragraph.')).toBeInTheDocument();
41
+ });
42
+ });
43
+
44
+ it('renders bold text', async () => {
45
+ render(<Markdown>{'This is **bold** text.'}</Markdown>);
46
+ await waitFor(() => {
47
+ expect(screen.getByText('bold')).toBeInTheDocument();
48
+ });
49
+ });
50
+
51
+ it('renders italic text', async () => {
52
+ render(<Markdown>{'This is *italic* text.'}</Markdown>);
53
+ await waitFor(() => {
54
+ expect(screen.getByText('italic')).toBeInTheDocument();
55
+ });
56
+ });
57
+ });
58
+
59
+ describe('links', () => {
60
+ it('renders links with href', async () => {
61
+ render(<Markdown>{'[Click me](https://example.com)'}</Markdown>);
62
+ await waitFor(() => {
63
+ const link = screen.getByText('Click me');
64
+ expect(link.closest('a')).toHaveAttribute('href', 'https://example.com');
65
+ });
66
+ });
67
+
68
+ it('renders links with target="_blank"', async () => {
69
+ render(<Markdown>{'[Link](https://example.com)'}</Markdown>);
70
+ await waitFor(() => {
71
+ const link = screen.getByText('Link');
72
+ expect(link.closest('a')).toHaveAttribute('target', '_blank');
73
+ });
74
+ });
75
+ });
76
+
77
+ describe('lists', () => {
78
+ it('renders unordered lists', async () => {
79
+ render(<Markdown>{'- Item 1\n- Item 2\n- Item 3'}</Markdown>);
80
+ await waitFor(() => {
81
+ expect(screen.getByText('Item 1')).toBeInTheDocument();
82
+ expect(screen.getByText('Item 2')).toBeInTheDocument();
83
+ expect(screen.getByText('Item 3')).toBeInTheDocument();
84
+ });
85
+ });
86
+
87
+ it('renders ordered lists', async () => {
88
+ render(<Markdown>{'1. First\n2. Second\n3. Third'}</Markdown>);
89
+ await waitFor(() => {
90
+ expect(screen.getByText('First')).toBeInTheDocument();
91
+ expect(screen.getByText('Second')).toBeInTheDocument();
92
+ });
93
+ });
94
+ });
95
+
96
+ describe('code', () => {
97
+ it('renders inline code', async () => {
98
+ render(<Markdown>{'Use `console.log()` to debug.'}</Markdown>);
99
+ await waitFor(() => {
100
+ const code = screen.getByText('console.log()');
101
+ expect(code.tagName).toBe('CODE');
102
+ });
103
+ });
104
+
105
+ it('renders code blocks with language', async () => {
106
+ render(<Markdown>{'```js\nconst x = 1;\n```'}</Markdown>);
107
+ await waitFor(() => {
108
+ expect(screen.getByText('js')).toBeInTheDocument();
109
+ expect(screen.getByText('const x = 1;')).toBeInTheDocument();
110
+ });
111
+ });
112
+ });
113
+
114
+ describe('GFM extensions', () => {
115
+ it('renders strikethrough text', async () => {
116
+ render(<Markdown>{'~~deleted~~'}</Markdown>);
117
+ await waitFor(() => {
118
+ const del = screen.getByText('deleted');
119
+ expect(del.closest('span')).toHaveClass('strikethrough');
120
+ });
121
+ });
122
+
123
+ it('renders tables', async () => {
124
+ const md = '| Name | Age |\n| --- | --- |\n| Alice | 30 |';
125
+ render(<Markdown>{md}</Markdown>);
126
+ await waitFor(() => {
127
+ expect(screen.getByText('Name')).toBeInTheDocument();
128
+ expect(screen.getByText('Alice')).toBeInTheDocument();
129
+ expect(screen.getByText('30')).toBeInTheDocument();
130
+ });
131
+ });
132
+
133
+ it('renders task lists', async () => {
134
+ const md = '- [x] Done\n- [ ] Todo';
135
+ const { container } = render(<Markdown>{md}</Markdown>);
136
+ await waitFor(() => {
137
+ expect(screen.getByText('Done')).toBeInTheDocument();
138
+ expect(screen.getByText('Todo')).toBeInTheDocument();
139
+ });
140
+ const checkboxes = container.querySelectorAll('input[type="checkbox"]');
141
+ expect(checkboxes.length).toBe(2);
142
+ });
143
+ });
144
+
145
+ describe('raw HTML', () => {
146
+ it('renders kbd elements', async () => {
147
+ render(<Markdown>{'Press <kbd>Ctrl</kbd>+<kbd>C</kbd>'}</Markdown>);
148
+ await waitFor(() => {
149
+ const kbd = screen.getByText('Ctrl');
150
+ expect(kbd.tagName).toBe('KBD');
151
+ });
152
+ });
153
+
154
+ it('renders mark elements', async () => {
155
+ render(<Markdown>{'This is <mark>highlighted</mark> text.'}</Markdown>);
156
+ await waitFor(() => {
157
+ const mark = screen.getByText('highlighted');
158
+ expect(mark.tagName).toBe('MARK');
159
+ });
160
+ });
161
+
162
+ it('renders sup elements', async () => {
163
+ render(<Markdown>{'E=mc<sup>2</sup>'}</Markdown>);
164
+ await waitFor(() => {
165
+ const sup = screen.getByText('2');
166
+ expect(sup.tagName).toBe('SUP');
167
+ });
168
+ });
169
+ });
170
+
171
+ describe('blockquotes', () => {
172
+ it('renders blockquotes', async () => {
173
+ render(<Markdown>{'> This is a quote'}</Markdown>);
174
+ await waitFor(() => {
175
+ expect(screen.getByText('This is a quote')).toBeInTheDocument();
176
+ });
177
+ });
178
+ });
179
+
180
+ describe('horizontal rules', () => {
181
+ it('renders hr elements', async () => {
182
+ const { container } = render(<Markdown>{'Above\n\n---\n\nBelow'}</Markdown>);
183
+ await waitFor(() => {
184
+ expect(container.querySelector('hr')).toBeInTheDocument();
185
+ });
186
+ });
187
+ });
188
+
189
+ describe('images', () => {
190
+ it('renders images with src and alt', async () => {
191
+ render(<Markdown>{'![Alt text](https://example.com/image.png)'}</Markdown>);
192
+ await waitFor(() => {
193
+ const img = screen.getByAltText('Alt text');
194
+ expect(img).toHaveAttribute('src', 'https://example.com/image.png');
195
+ });
196
+ });
197
+ });
198
+
199
+ describe('className', () => {
200
+ it('applies custom className', () => {
201
+ const { container } = render(<Markdown className="custom-md">{'Hello'}</Markdown>);
202
+ expect(container.firstElementChild).toHaveClass('custom-md');
203
+ });
204
+
205
+ it('always applies the markdown base class', () => {
206
+ const { container } = render(<Markdown>{'Hello'}</Markdown>);
207
+ expect(container.firstElementChild).toHaveClass('markdown');
208
+ });
209
+ });
210
+
211
+ describe('size', () => {
212
+ it('sets CSS variable for base font size', () => {
213
+ const { container } = render(<Markdown size="paragraphLarge">{'Hello'}</Markdown>);
214
+ const wrapper = container.firstElementChild as HTMLElement;
215
+ expect(wrapper.style.getPropertyValue('--markdown-base-font-size')).toBe(
216
+ 'var(--pte-new-typography-styles-paragraphLarge-fontSize)',
217
+ );
218
+ });
219
+
220
+ it('defaults to paragraphSmall', () => {
221
+ const { container } = render(<Markdown>{'Hello'}</Markdown>);
222
+ const wrapper = container.firstElementChild as HTMLElement;
223
+ expect(wrapper.style.getPropertyValue('--markdown-base-font-size')).toBe(
224
+ 'var(--pte-new-typography-styles-paragraphSmall-fontSize)',
225
+ );
226
+ });
227
+ });
228
+ });
@@ -0,0 +1,24 @@
1
+ .toolbar {
2
+ display: flex;
3
+ align-items: center;
4
+ gap: 2px;
5
+ padding: 6px 8px;
6
+ border-bottom: 1px solid var(--pte-new-colors-borderSubtle);
7
+ background-color: var(--pte-new-colors-backgroundPrimary);
8
+ flex-wrap: wrap;
9
+ position: relative;
10
+ }
11
+
12
+ .group {
13
+ display: flex;
14
+ align-items: center;
15
+ gap: 2px;
16
+ }
17
+
18
+ .separator {
19
+ width: 1px;
20
+ height: 18px;
21
+ background-color: var(--pte-new-colors-borderSubtle);
22
+ margin: 0 4px;
23
+ flex-shrink: 0;
24
+ }
@@ -0,0 +1,274 @@
1
+ 'use client';
2
+
3
+ import { clsx } from 'clsx';
4
+ import {
5
+ Bold,
6
+ Code,
7
+ Heading1,
8
+ Heading2,
9
+ Heading3,
10
+ Image,
11
+ Italic,
12
+ Link,
13
+ List,
14
+ ListChecks,
15
+ ListOrdered,
16
+ Minus,
17
+ Quote,
18
+ SquareCode,
19
+ Strikethrough,
20
+ Table,
21
+ } from 'lucide-react';
22
+ import type { ComponentPropsWithoutRef, FC, ReactNode } from 'react';
23
+ import { Fragment, useCallback, useRef, useState } from 'react';
24
+ import styles from './FixedToolbar.module.scss';
25
+ import type { MarkdownEditorFeature } from './features';
26
+ import { LinkPopover } from './LinkPopover';
27
+ import { useMarkdownEditorContext } from './MarkdownEditorContext';
28
+ import { ToolbarButton } from './ToolbarButton';
29
+
30
+ export type FixedToolbarProps = {
31
+ /** An optional CSS class name. */
32
+ className?: string;
33
+ /** Prop overrides for sub-elements. */
34
+ overrides?: {
35
+ root?: ComponentPropsWithoutRef<'div'>;
36
+ group?: ComponentPropsWithoutRef<'div'>;
37
+ separator?: ComponentPropsWithoutRef<'div'>;
38
+ };
39
+ };
40
+
41
+ type ToolbarItem = {
42
+ feature: MarkdownEditorFeature;
43
+ label: ReactNode;
44
+ tooltip: string;
45
+ action: () => void;
46
+ isActive: () => boolean;
47
+ };
48
+
49
+ const ICON_SIZE = 14;
50
+
51
+ /**
52
+ * A fixed toolbar that renders above the editor content.
53
+ * Reads the editor instance and enabled features from MarkdownEditorContext.
54
+ *
55
+ * Renders button groups: Inline marks | Blocks | Lists | Inserts.
56
+ * Groups with no enabled features are hidden.
57
+ */
58
+ export const FixedToolbar: FC<FixedToolbarProps> = ({ className, overrides }) => {
59
+ const { editor, features, handleImageUpload } = useMarkdownEditorContext();
60
+ const [showLinkPopover, setShowLinkPopover] = useState(false);
61
+ const fileInputRef = useRef<HTMLInputElement>(null);
62
+
63
+ const handleLinkClick = useCallback(() => {
64
+ setShowLinkPopover((prev) => !prev);
65
+ }, []);
66
+
67
+ const handleImageClick = useCallback(() => {
68
+ fileInputRef.current?.click();
69
+ }, []);
70
+
71
+ const handleFileChange = useCallback(
72
+ async (e: React.ChangeEvent<HTMLInputElement>) => {
73
+ const file = e.target.files?.[0];
74
+ if (!file || !editor || !handleImageUpload) return;
75
+ try {
76
+ const url = await handleImageUpload(file);
77
+ editor.chain().focus().setImage({ src: url }).run();
78
+ } catch {
79
+ // Upload failed — consumer's handler should surface errors
80
+ }
81
+ // Reset so the same file can be re-selected
82
+ e.target.value = '';
83
+ },
84
+ [editor, handleImageUpload],
85
+ );
86
+
87
+ if (!editor) return null;
88
+
89
+ const inlineMarks: ToolbarItem[] = [
90
+ {
91
+ feature: 'bold',
92
+ label: <Bold size={ICON_SIZE} />,
93
+ tooltip: 'Bold (⌘B)',
94
+ action: () => editor.chain().focus().toggleBold().run(),
95
+ isActive: () => editor.isActive('bold'),
96
+ },
97
+ {
98
+ feature: 'italic',
99
+ label: <Italic size={ICON_SIZE} />,
100
+ tooltip: 'Italic (⌘I)',
101
+ action: () => editor.chain().focus().toggleItalic().run(),
102
+ isActive: () => editor.isActive('italic'),
103
+ },
104
+ {
105
+ feature: 'strikethrough',
106
+ label: <Strikethrough size={ICON_SIZE} />,
107
+ tooltip: 'Strikethrough (⌘⇧X)',
108
+ action: () => editor.chain().focus().toggleStrike().run(),
109
+ isActive: () => editor.isActive('strike'),
110
+ },
111
+ {
112
+ feature: 'code',
113
+ label: <Code size={ICON_SIZE} />,
114
+ tooltip: 'Inline code (⌘E)',
115
+ action: () => editor.chain().focus().toggleCode().run(),
116
+ isActive: () => editor.isActive('code'),
117
+ },
118
+ ];
119
+
120
+ const blocks: ToolbarItem[] = [
121
+ {
122
+ feature: 'heading',
123
+ label: <Heading1 size={ICON_SIZE} />,
124
+ tooltip: 'Heading 1',
125
+ action: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
126
+ isActive: () => editor.isActive('heading', { level: 1 }),
127
+ },
128
+ {
129
+ feature: 'heading',
130
+ label: <Heading2 size={ICON_SIZE} />,
131
+ tooltip: 'Heading 2',
132
+ action: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
133
+ isActive: () => editor.isActive('heading', { level: 2 }),
134
+ },
135
+ {
136
+ feature: 'heading',
137
+ label: <Heading3 size={ICON_SIZE} />,
138
+ tooltip: 'Heading 3',
139
+ action: () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
140
+ isActive: () => editor.isActive('heading', { level: 3 }),
141
+ },
142
+ {
143
+ feature: 'blockquote',
144
+ label: <Quote size={ICON_SIZE} />,
145
+ tooltip: 'Blockquote',
146
+ action: () => editor.chain().focus().toggleBlockquote().run(),
147
+ isActive: () => editor.isActive('blockquote'),
148
+ },
149
+ {
150
+ feature: 'horizontalRule',
151
+ label: <Minus size={ICON_SIZE} />,
152
+ tooltip: 'Horizontal rule',
153
+ action: () => editor.chain().focus().setHorizontalRule().run(),
154
+ isActive: () => false,
155
+ },
156
+ ];
157
+
158
+ const lists: ToolbarItem[] = [
159
+ {
160
+ feature: 'bulletList',
161
+ label: <List size={ICON_SIZE} />,
162
+ tooltip: 'Bullet list (⌘⇧8)',
163
+ action: () => editor.chain().focus().toggleBulletList().run(),
164
+ isActive: () => editor.isActive('bulletList'),
165
+ },
166
+ {
167
+ feature: 'orderedList',
168
+ label: <ListOrdered size={ICON_SIZE} />,
169
+ tooltip: 'Ordered list (⌘⇧7)',
170
+ action: () => editor.chain().focus().toggleOrderedList().run(),
171
+ isActive: () => editor.isActive('orderedList'),
172
+ },
173
+ {
174
+ feature: 'taskList',
175
+ label: <ListChecks size={ICON_SIZE} />,
176
+ tooltip: 'Task list',
177
+ action: () => editor.chain().focus().toggleTaskList().run(),
178
+ isActive: () => editor.isActive('taskList'),
179
+ },
180
+ ];
181
+
182
+ const inserts: ToolbarItem[] = [
183
+ {
184
+ feature: 'link',
185
+ label: <Link size={ICON_SIZE} />,
186
+ tooltip: 'Link',
187
+ action: handleLinkClick,
188
+ isActive: () => editor.isActive('link'),
189
+ },
190
+ ...(handleImageUpload
191
+ ? [
192
+ {
193
+ feature: 'image' as const,
194
+ label: <Image size={ICON_SIZE} />,
195
+ tooltip: 'Image',
196
+ action: handleImageClick,
197
+ isActive: () => false,
198
+ },
199
+ ]
200
+ : []),
201
+ {
202
+ feature: 'table',
203
+ label: <Table size={ICON_SIZE} />,
204
+ tooltip: 'Insert table',
205
+ action: () => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(),
206
+ isActive: () => false,
207
+ },
208
+ {
209
+ feature: 'codeBlock',
210
+ label: <SquareCode size={ICON_SIZE} />,
211
+ tooltip: 'Code block',
212
+ action: () => editor.chain().focus().toggleCodeBlock().run(),
213
+ isActive: () => editor.isActive('codeBlock'),
214
+ },
215
+ ];
216
+
217
+ const groupEntries = [
218
+ { name: 'inline', items: inlineMarks },
219
+ { name: 'blocks', items: blocks },
220
+ { name: 'lists', items: lists },
221
+ { name: 'inserts', items: inserts },
222
+ ];
223
+
224
+ // Filter each group to only include items whose feature is enabled
225
+ const filteredGroups = groupEntries
226
+ .map((g) => ({ name: g.name, items: g.items.filter((item) => features.has(item.feature)) }))
227
+ .filter((g) => g.items.length > 0);
228
+
229
+ if (filteredGroups.length === 0) return null;
230
+
231
+ return (
232
+ <div
233
+ role="toolbar"
234
+ aria-label="Formatting options"
235
+ {...overrides?.root}
236
+ className={clsx(styles.toolbar, className, overrides?.root?.className)}
237
+ >
238
+ {filteredGroups.map((group, groupIndex) => (
239
+ <Fragment key={group.name}>
240
+ {groupIndex > 0 && (
241
+ <div
242
+ {...overrides?.separator}
243
+ className={clsx(styles.separator, overrides?.separator?.className)}
244
+ aria-hidden="true"
245
+ />
246
+ )}
247
+ <div {...overrides?.group} className={clsx(styles.group, overrides?.group?.className)}>
248
+ {group.items.map((item) => (
249
+ <ToolbarButton
250
+ key={item.tooltip}
251
+ isActive={item.isActive()}
252
+ onAction={item.action}
253
+ tooltip={item.tooltip}
254
+ >
255
+ {item.label}
256
+ </ToolbarButton>
257
+ ))}
258
+ </div>
259
+ </Fragment>
260
+ ))}
261
+ {showLinkPopover && features.has('link') && <LinkPopover onClose={() => setShowLinkPopover(false)} />}
262
+ {handleImageUpload && (
263
+ <input
264
+ ref={fileInputRef}
265
+ type="file"
266
+ accept="image/*"
267
+ onChange={handleFileChange}
268
+ style={{ display: 'none' }}
269
+ tabIndex={-1}
270
+ />
271
+ )}
272
+ </div>
273
+ );
274
+ };
@@ -0,0 +1,21 @@
1
+ .toolbar {
2
+ display: flex;
3
+ align-items: center;
4
+ gap: 2px;
5
+ padding: 4px 6px;
6
+ background-color: var(--pte-new-colors-surfacePrimary, var(--pte-new-colors-backgroundPrimary));
7
+ border: 1px solid var(--pte-new-colors-borderSubtle);
8
+ border-radius: var(--pte-new-borders-radius-rounded, 8px);
9
+ box-shadow: var(
10
+ --pte-new-shadows-shallowPopup,
11
+ 0 4px 12px rgba(0, 0, 0, 0.15)
12
+ );
13
+ }
14
+
15
+ .separator {
16
+ width: 1px;
17
+ height: 18px;
18
+ background-color: var(--pte-new-colors-borderSubtle);
19
+ margin: 0 2px;
20
+ flex-shrink: 0;
21
+ }
@@ -0,0 +1,94 @@
1
+ 'use client';
2
+
3
+ import { BubbleMenu } from '@tiptap/react/menus';
4
+ import { clsx } from 'clsx';
5
+ import { Bold, Code, Italic, Link, Strikethrough } from 'lucide-react';
6
+ import type { ComponentPropsWithoutRef, FC } from 'react';
7
+ import { Fragment, useState } from 'react';
8
+ import styles from './FloatingToolbar.module.scss';
9
+ import { LinkPopover } from './LinkPopover';
10
+ import { useMarkdownEditorContext } from './MarkdownEditorContext';
11
+ import { ToolbarButton } from './ToolbarButton';
12
+
13
+ export type FloatingToolbarProps = {
14
+ /** An optional CSS class name. */
15
+ className?: string;
16
+ /** Prop overrides for sub-elements. */
17
+ overrides?: {
18
+ root?: ComponentPropsWithoutRef<'div'>;
19
+ };
20
+ };
21
+
22
+ const ICON_SIZE = 14;
23
+
24
+ /**
25
+ * A floating toolbar that appears when text is selected.
26
+ * Shows inline formatting options: bold, italic, strikethrough, code, link.
27
+ * Wraps Tiptap's BubbleMenu component.
28
+ */
29
+ export const FloatingToolbar: FC<FloatingToolbarProps> = ({ className, overrides }) => {
30
+ const { editor, features } = useMarkdownEditorContext();
31
+ const [showLinkPopover, setShowLinkPopover] = useState(false);
32
+
33
+ if (!editor) return null;
34
+
35
+ const items = [
36
+ {
37
+ feature: 'bold' as const,
38
+ label: <Bold size={ICON_SIZE} />,
39
+ tooltip: 'Bold (⌘B)',
40
+ action: () => editor.chain().focus().toggleBold().run(),
41
+ isActive: () => editor.isActive('bold'),
42
+ },
43
+ {
44
+ feature: 'italic' as const,
45
+ label: <Italic size={ICON_SIZE} />,
46
+ tooltip: 'Italic (⌘I)',
47
+ action: () => editor.chain().focus().toggleItalic().run(),
48
+ isActive: () => editor.isActive('italic'),
49
+ },
50
+ {
51
+ feature: 'strikethrough' as const,
52
+ label: <Strikethrough size={ICON_SIZE} />,
53
+ tooltip: 'Strikethrough (⌘⇧X)',
54
+ action: () => editor.chain().focus().toggleStrike().run(),
55
+ isActive: () => editor.isActive('strike'),
56
+ },
57
+ {
58
+ feature: 'code' as const,
59
+ label: <Code size={ICON_SIZE} />,
60
+ tooltip: 'Inline code (⌘E)',
61
+ action: () => editor.chain().focus().toggleCode().run(),
62
+ isActive: () => editor.isActive('code'),
63
+ },
64
+ {
65
+ feature: 'link' as const,
66
+ label: <Link size={ICON_SIZE} />,
67
+ tooltip: 'Link',
68
+ action: () => setShowLinkPopover((prev) => !prev),
69
+ isActive: () => editor.isActive('link'),
70
+ },
71
+ ];
72
+
73
+ const visibleItems = items.filter((item) => features.has(item.feature));
74
+
75
+ if (visibleItems.length === 0) return null;
76
+
77
+ return (
78
+ <BubbleMenu editor={editor} options={{ placement: 'top', offset: 8 }}>
79
+ <div {...overrides?.root} className={clsx(styles.toolbar, className, overrides?.root?.className)}>
80
+ {visibleItems.map((item, index) => (
81
+ <Fragment key={item.feature}>
82
+ {index > 0 && item.feature === 'link' && (
83
+ <div className={styles.separator} aria-hidden="true" />
84
+ )}
85
+ <ToolbarButton isActive={item.isActive()} onAction={item.action} tooltip={item.tooltip}>
86
+ {item.label}
87
+ </ToolbarButton>
88
+ </Fragment>
89
+ ))}
90
+ {showLinkPopover && features.has('link') && <LinkPopover onClose={() => setShowLinkPopover(false)} />}
91
+ </div>
92
+ </BubbleMenu>
93
+ );
94
+ };