paris 0.18.0 → 0.19.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,21 @@
1
1
  # paris
2
2
 
3
+ ## 0.19.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 4a511cc: Add MarkdownEditor component — a WYSIWYG rich text editor built on Tiptap that outputs markdown. Features composable toolbars (FixedToolbar, FloatingToolbar), controlled value/onChange with markdown strings, feature gating via `features` prop, and Paris-styled content rendering. Adds lucide-react for toolbar icons.
8
+
9
+ ## 0.18.1
10
+
11
+ ### Patch Changes
12
+
13
+ - 389b9ba: fix(tabs): fix glass mode backdrop-filter blur and content bleed-through
14
+
15
+ Restructured glass tab bar from `position: absolute` to `position: sticky` with `backdrop-filter` applied directly on the sticky element. This fixes two bugs: content bleeding through the tab bar on scroll, and the blur effect never rendering due to compositing boundary conflicts. Removed the nested glass layer divs (glassContainer, glassOpacity, glassBlend) and padding-top hacks in favor of natural document flow. Uses `primaryThin` material for theme-aware frosted glass (white in light mode, dark in dark mode).
16
+
17
+ - 706e34c: fix(markdown): use contentPrimary instead of contentAccent for checked checkboxes
18
+
3
19
  ## 0.18.0
4
20
 
5
21
  ### Minor Changes
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "paris",
3
3
  "author": "Sanil Chawla <sanil@slingshot.fm> (https://sanil.co)",
4
4
  "description": "Paris is Slingshot's React design system. It's a collection of reusable components, design tokens, and guidelines that help us build consistent, accessible, and performant user interfaces.",
5
- "version": "0.18.0",
5
+ "version": "0.19.0",
6
6
  "homepage": "https://paris.slingshot.fm",
7
7
  "license": "MIT",
8
8
  "repository": {
@@ -62,6 +62,7 @@
62
62
  "./informationaltooltip": "./src/stories/informationaltooltip/index.ts",
63
63
  "./input": "./src/stories/input/index.ts",
64
64
  "./markdown": "./src/stories/markdown/index.ts",
65
+ "./markdowneditor": "./src/stories/markdowneditor/index.ts",
65
66
  "./menu": "./src/stories/menu/index.ts",
66
67
  "./pagination": "./src/stories/pagination/index.ts",
67
68
  "./popover": "./src/stories/popover/index.ts",
@@ -87,9 +88,22 @@
87
88
  "@headlessui/react": "^2.2.4",
88
89
  "@radix-ui/react-checkbox": "^1.3.3",
89
90
  "@radix-ui/react-tooltip": "^1.2.8",
91
+ "@tiptap/extension-image": "^3.22.2",
92
+ "@tiptap/extension-link": "^3.22.2",
93
+ "@tiptap/extension-placeholder": "^3.22.2",
94
+ "@tiptap/extension-table": "^3.22.2",
95
+ "@tiptap/extension-table-cell": "^3.22.2",
96
+ "@tiptap/extension-table-header": "^3.22.2",
97
+ "@tiptap/extension-table-row": "^3.22.2",
98
+ "@tiptap/extension-task-item": "^3.22.2",
99
+ "@tiptap/extension-task-list": "^3.22.2",
100
+ "@tiptap/markdown": "^3.22.2",
101
+ "@tiptap/react": "^3.22.2",
102
+ "@tiptap/starter-kit": "^3.22.2",
90
103
  "clsx": "^1.2.1",
91
104
  "font-color-contrast": "^11.1.0",
92
105
  "framer-motion": "^12.24.10",
106
+ "lucide-react": "^1.7.0",
93
107
  "pte": "^0.5.0",
94
108
  "react-hot-toast": "^2.4.1",
95
109
  "react-markdown": "^10.1.0",
@@ -142,8 +142,8 @@
142
142
  top: 2px;
143
143
 
144
144
  &:checked {
145
- background-color: var(--pte-new-colors-contentAccent);
146
- border-color: var(--pte-new-colors-contentAccent);
145
+ background-color: var(--pte-new-colors-contentPrimary);
146
+ border-color: var(--pte-new-colors-contentPrimary);
147
147
 
148
148
  &::after {
149
149
  content: '✓';
@@ -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,244 @@
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, 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 } = useMarkdownEditorContext();
60
+ const [showLinkPopover, setShowLinkPopover] = useState(false);
61
+
62
+ const handleLinkClick = useCallback(() => {
63
+ setShowLinkPopover((prev) => !prev);
64
+ }, []);
65
+
66
+ if (!editor) return null;
67
+
68
+ const inlineMarks: ToolbarItem[] = [
69
+ {
70
+ feature: 'bold',
71
+ label: <Bold size={ICON_SIZE} />,
72
+ tooltip: 'Bold (⌘B)',
73
+ action: () => editor.chain().focus().toggleBold().run(),
74
+ isActive: () => editor.isActive('bold'),
75
+ },
76
+ {
77
+ feature: 'italic',
78
+ label: <Italic size={ICON_SIZE} />,
79
+ tooltip: 'Italic (⌘I)',
80
+ action: () => editor.chain().focus().toggleItalic().run(),
81
+ isActive: () => editor.isActive('italic'),
82
+ },
83
+ {
84
+ feature: 'strikethrough',
85
+ label: <Strikethrough size={ICON_SIZE} />,
86
+ tooltip: 'Strikethrough (⌘⇧X)',
87
+ action: () => editor.chain().focus().toggleStrike().run(),
88
+ isActive: () => editor.isActive('strike'),
89
+ },
90
+ {
91
+ feature: 'code',
92
+ label: <Code size={ICON_SIZE} />,
93
+ tooltip: 'Inline code (⌘E)',
94
+ action: () => editor.chain().focus().toggleCode().run(),
95
+ isActive: () => editor.isActive('code'),
96
+ },
97
+ ];
98
+
99
+ const blocks: ToolbarItem[] = [
100
+ {
101
+ feature: 'heading',
102
+ label: <Heading1 size={ICON_SIZE} />,
103
+ tooltip: 'Heading 1',
104
+ action: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
105
+ isActive: () => editor.isActive('heading', { level: 1 }),
106
+ },
107
+ {
108
+ feature: 'heading',
109
+ label: <Heading2 size={ICON_SIZE} />,
110
+ tooltip: 'Heading 2',
111
+ action: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
112
+ isActive: () => editor.isActive('heading', { level: 2 }),
113
+ },
114
+ {
115
+ feature: 'heading',
116
+ label: <Heading3 size={ICON_SIZE} />,
117
+ tooltip: 'Heading 3',
118
+ action: () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
119
+ isActive: () => editor.isActive('heading', { level: 3 }),
120
+ },
121
+ {
122
+ feature: 'blockquote',
123
+ label: <Quote size={ICON_SIZE} />,
124
+ tooltip: 'Blockquote',
125
+ action: () => editor.chain().focus().toggleBlockquote().run(),
126
+ isActive: () => editor.isActive('blockquote'),
127
+ },
128
+ {
129
+ feature: 'horizontalRule',
130
+ label: <Minus size={ICON_SIZE} />,
131
+ tooltip: 'Horizontal rule',
132
+ action: () => editor.chain().focus().setHorizontalRule().run(),
133
+ isActive: () => false,
134
+ },
135
+ ];
136
+
137
+ const lists: ToolbarItem[] = [
138
+ {
139
+ feature: 'bulletList',
140
+ label: <List size={ICON_SIZE} />,
141
+ tooltip: 'Bullet list (⌘⇧8)',
142
+ action: () => editor.chain().focus().toggleBulletList().run(),
143
+ isActive: () => editor.isActive('bulletList'),
144
+ },
145
+ {
146
+ feature: 'orderedList',
147
+ label: <ListOrdered size={ICON_SIZE} />,
148
+ tooltip: 'Ordered list (⌘⇧7)',
149
+ action: () => editor.chain().focus().toggleOrderedList().run(),
150
+ isActive: () => editor.isActive('orderedList'),
151
+ },
152
+ {
153
+ feature: 'taskList',
154
+ label: <ListChecks size={ICON_SIZE} />,
155
+ tooltip: 'Task list',
156
+ action: () => editor.chain().focus().toggleTaskList().run(),
157
+ isActive: () => editor.isActive('taskList'),
158
+ },
159
+ ];
160
+
161
+ const inserts: ToolbarItem[] = [
162
+ {
163
+ feature: 'link',
164
+ label: <Link size={ICON_SIZE} />,
165
+ tooltip: 'Link',
166
+ action: handleLinkClick,
167
+ isActive: () => editor.isActive('link'),
168
+ },
169
+ {
170
+ feature: 'image',
171
+ label: <Image size={ICON_SIZE} />,
172
+ tooltip: 'Image',
173
+ action: () => {
174
+ const url = window.prompt('Image URL');
175
+ if (url) {
176
+ editor.chain().focus().setImage({ src: url }).run();
177
+ }
178
+ },
179
+ isActive: () => false,
180
+ },
181
+ {
182
+ feature: 'table',
183
+ label: <Table size={ICON_SIZE} />,
184
+ tooltip: 'Insert table',
185
+ action: () => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(),
186
+ isActive: () => false,
187
+ },
188
+ {
189
+ feature: 'codeBlock',
190
+ label: <SquareCode size={ICON_SIZE} />,
191
+ tooltip: 'Code block',
192
+ action: () => editor.chain().focus().toggleCodeBlock().run(),
193
+ isActive: () => editor.isActive('codeBlock'),
194
+ },
195
+ ];
196
+
197
+ const groupEntries = [
198
+ { name: 'inline', items: inlineMarks },
199
+ { name: 'blocks', items: blocks },
200
+ { name: 'lists', items: lists },
201
+ { name: 'inserts', items: inserts },
202
+ ];
203
+
204
+ // Filter each group to only include items whose feature is enabled
205
+ const filteredGroups = groupEntries
206
+ .map((g) => ({ name: g.name, items: g.items.filter((item) => features.has(item.feature)) }))
207
+ .filter((g) => g.items.length > 0);
208
+
209
+ if (filteredGroups.length === 0) return null;
210
+
211
+ return (
212
+ <div
213
+ role="toolbar"
214
+ aria-label="Formatting options"
215
+ {...overrides?.root}
216
+ className={clsx(styles.toolbar, className, overrides?.root?.className)}
217
+ >
218
+ {filteredGroups.map((group, groupIndex) => (
219
+ <Fragment key={group.name}>
220
+ {groupIndex > 0 && (
221
+ <div
222
+ {...overrides?.separator}
223
+ className={clsx(styles.separator, overrides?.separator?.className)}
224
+ aria-hidden="true"
225
+ />
226
+ )}
227
+ <div {...overrides?.group} className={clsx(styles.group, overrides?.group?.className)}>
228
+ {group.items.map((item) => (
229
+ <ToolbarButton
230
+ key={item.tooltip}
231
+ isActive={item.isActive()}
232
+ onAction={item.action}
233
+ tooltip={item.tooltip}
234
+ >
235
+ {item.label}
236
+ </ToolbarButton>
237
+ ))}
238
+ </div>
239
+ </Fragment>
240
+ ))}
241
+ {showLinkPopover && features.has('link') && <LinkPopover onClose={() => setShowLinkPopover(false)} />}
242
+ </div>
243
+ );
244
+ };
@@ -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
+ };
@@ -0,0 +1,124 @@
1
+ .popover {
2
+ position: absolute;
3
+ top: calc(100% + 8px);
4
+ left: 0;
5
+ z-index: 50;
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
+ padding: 8px 10px;
14
+ width: 280px;
15
+ display: flex;
16
+ flex-direction: column;
17
+ gap: 6px;
18
+ }
19
+
20
+ .label {
21
+ font-size: var(--pte-new-typography-styles-labelXSmall-fontSize, 11px);
22
+ font-weight: 600;
23
+ text-transform: uppercase;
24
+ letter-spacing: 0.05em;
25
+ color: var(--pte-new-colors-contentTertiary);
26
+ }
27
+
28
+ .inputRow {
29
+ display: flex;
30
+ gap: 6px;
31
+ }
32
+
33
+ .input {
34
+ flex: 1;
35
+ background-color: var(--pte-new-colors-inputFill);
36
+ border: 1px solid var(--pte-new-colors-borderSubtle);
37
+ border-radius: 4px;
38
+ padding: 4px 8px;
39
+ color: var(--pte-new-colors-contentPrimary);
40
+ font-size: 11px;
41
+ font-family: inherit;
42
+ outline: none;
43
+ transition: border-color var(--pte-new-animations-interaction, 150ms) ease;
44
+
45
+ &:focus {
46
+ border-color: var(--pte-new-colors-inputBorderFocus);
47
+ }
48
+
49
+ &::placeholder {
50
+ color: var(--pte-new-colors-contentTertiary);
51
+ }
52
+ }
53
+
54
+ .applyButton {
55
+ background-color: var(--pte-new-colors-buttonFill);
56
+ border: none;
57
+ border-radius: 4px;
58
+ padding: 4px 10px;
59
+ color: var(--pte-new-colors-contentInversePrimary, #fff);
60
+ font-size: 11px;
61
+ font-weight: 600;
62
+ font-family: inherit;
63
+ cursor: pointer;
64
+ transition: background-color var(--pte-new-animations-interaction, 150ms) ease;
65
+ flex-shrink: 0;
66
+
67
+ &:hover {
68
+ background-color: var(--pte-new-colors-buttonFillHover);
69
+ }
70
+ }
71
+
72
+ .footer {
73
+ display: flex;
74
+ justify-content: space-between;
75
+ align-items: center;
76
+ }
77
+
78
+ .checkboxLabel {
79
+ display: flex;
80
+ align-items: center;
81
+ gap: 4px;
82
+ font-size: 11px;
83
+ color: var(--pte-new-colors-contentTertiary);
84
+ cursor: pointer;
85
+
86
+ input[type='checkbox'] {
87
+ appearance: none;
88
+ width: 14px;
89
+ height: 14px;
90
+ border: 2px solid var(--pte-new-colors-contentTertiary);
91
+ border-radius: var(--pte-borders-radius-rectangle);
92
+ background: transparent;
93
+ cursor: pointer;
94
+ position: relative;
95
+ flex-shrink: 0;
96
+ transition: var(--pte-animations-interaction);
97
+
98
+ &:checked {
99
+ &::after {
100
+ content: '';
101
+ position: absolute;
102
+ inset: -2px;
103
+ background-color: var(--pte-new-colors-contentPrimary);
104
+ mask-image: url("data:image/svg+xml,%3Csvg width='14' height='14' viewBox='0 0 14 14' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0.333374 0.333252V13.6666H13.6667V0.333252H0.333374ZM6.00004 10.3999L2.26672 6.66658L3.66671 5.26658L5.93339 7.53325L10.2 3.26658L11.6001 4.66659L6.00004 10.3999Z' fill='black'/%3E%3C/svg%3E");
105
+ mask-size: contain;
106
+ mask-repeat: no-repeat;
107
+ }
108
+ }
109
+ }
110
+ }
111
+
112
+ .removeButton {
113
+ background: none;
114
+ border: none;
115
+ color: var(--pte-new-colors-contentNegative);
116
+ font-size: 11px;
117
+ font-family: inherit;
118
+ cursor: pointer;
119
+ padding: 0;
120
+
121
+ &:hover {
122
+ text-decoration: underline;
123
+ }
124
+ }