@borisj74/bv-ds 0.1.7 → 0.1.9
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/dist/index.cjs +573 -244
- package/dist/index.d.cts +106 -21
- package/dist/index.d.ts +106 -21
- package/dist/index.js +573 -247
- package/package.json +17 -2
- package/src/components/Calendar/Calendar.tsx +249 -0
- package/src/components/Calendar/index.ts +2 -0
- package/src/components/TextEditor/TextEditor.tsx +188 -0
- package/src/components/TextEditor/index.ts +2 -0
- package/src/hooks/useTextEditor.ts +45 -0
- package/src/index.ts +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@borisj74/bv-ds",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "bv-ds — React component library synced from Figma (Untitled UI v8.0), built on Tailwind CSS",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -34,21 +34,36 @@
|
|
|
34
34
|
"prepublishOnly": "npm run typecheck && npm run build"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
|
+
"@borisj74/bv-ds-icons": ">=0.1.0",
|
|
38
|
+
"@tiptap/extension-placeholder": ">=2.0.0",
|
|
39
|
+
"@tiptap/extension-text-align": ">=2.0.0",
|
|
40
|
+
"@tiptap/react": ">=2.0.0",
|
|
41
|
+
"@tiptap/starter-kit": ">=2.0.0",
|
|
42
|
+
"date-fns": ">=2.0.0",
|
|
37
43
|
"react": ">=18.0.0",
|
|
38
44
|
"react-dom": ">=18.0.0",
|
|
39
|
-
"
|
|
45
|
+
"react-big-calendar": ">=1.8.0"
|
|
40
46
|
},
|
|
41
47
|
"devDependencies": {
|
|
42
48
|
"@borisj74/bv-ds-icons": "^0.1.0",
|
|
43
49
|
"@storybook/addon-a11y": "^8.0.0",
|
|
44
50
|
"@storybook/addon-essentials": "^8.6.14",
|
|
45
51
|
"@storybook/react-vite": "^8.0.0",
|
|
52
|
+
"@tiptap/extension-link": "^3.27.1",
|
|
53
|
+
"@tiptap/extension-placeholder": "^3.27.1",
|
|
54
|
+
"@tiptap/extension-text-align": "^3.27.1",
|
|
55
|
+
"@tiptap/extension-underline": "^3.27.1",
|
|
56
|
+
"@tiptap/react": "^3.27.1",
|
|
57
|
+
"@tiptap/starter-kit": "^3.27.1",
|
|
46
58
|
"@types/react": "^18.2.0",
|
|
59
|
+
"@types/react-big-calendar": "^1.16.3",
|
|
47
60
|
"@types/react-dom": "^18.2.0",
|
|
48
61
|
"autoprefixer": "^10.4.0",
|
|
49
62
|
"clsx": "^2.1.0",
|
|
63
|
+
"date-fns": "^4.4.0",
|
|
50
64
|
"postcss": "^8.4.0",
|
|
51
65
|
"react": "^18.2.0",
|
|
66
|
+
"react-big-calendar": "^1.20.0",
|
|
52
67
|
"react-dom": "^18.2.0",
|
|
53
68
|
"recharts": "^2.15.4",
|
|
54
69
|
"storybook": "^8.0.0",
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { useMemo, useState, useCallback } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Calendar as BigCalendar,
|
|
4
|
+
dateFnsLocalizer,
|
|
5
|
+
type SlotInfo,
|
|
6
|
+
type View,
|
|
7
|
+
type NavigateAction,
|
|
8
|
+
} from "react-big-calendar";
|
|
9
|
+
import { format, parse, startOfWeek, getDay, isSameDay } from "date-fns";
|
|
10
|
+
import { enUS } from "date-fns/locale";
|
|
11
|
+
import clsx from "clsx";
|
|
12
|
+
import { ArrowLeft, ArrowRight, Plus } from "@borisj74/bv-ds-icons";
|
|
13
|
+
import { IconBox } from "../../internal/iconBox";
|
|
14
|
+
import { CalendarHeader } from "../CalendarHeader";
|
|
15
|
+
import { CalendarColumnHeader } from "../CalendarColumnHeader";
|
|
16
|
+
import { CalendarEvent, type CalendarEventColor } from "../CalendarEvent";
|
|
17
|
+
import { CalendarViewDropdown, type CalendarView } from "../CalendarViewDropdown";
|
|
18
|
+
|
|
19
|
+
const localizer = dateFnsLocalizer({
|
|
20
|
+
format,
|
|
21
|
+
parse,
|
|
22
|
+
// Monday-first week, matching the Figma column-header order (Mon…Sun).
|
|
23
|
+
startOfWeek: (date: Date) => startOfWeek(date, { weekStartsOn: 1 }),
|
|
24
|
+
getDay,
|
|
25
|
+
locales: { "en-US": enUS },
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Event shape consumed by the `Calendar` composite. Named `CalendarEventData`
|
|
30
|
+
* (not `CalendarEvent`) to avoid colliding with the `CalendarEvent` chip
|
|
31
|
+
* component already exported from the primitives.
|
|
32
|
+
*/
|
|
33
|
+
export interface CalendarEventData {
|
|
34
|
+
id: string | number;
|
|
35
|
+
title: string;
|
|
36
|
+
start: Date;
|
|
37
|
+
end: Date;
|
|
38
|
+
color?: CalendarEventColor;
|
|
39
|
+
allDay?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CalendarProps {
|
|
43
|
+
events: CalendarEventData[];
|
|
44
|
+
defaultView?: CalendarView;
|
|
45
|
+
defaultDate?: Date;
|
|
46
|
+
onNavigate?: (date: Date) => void;
|
|
47
|
+
onView?: (view: CalendarView) => void;
|
|
48
|
+
onSelectEvent?: (event: CalendarEventData) => void;
|
|
49
|
+
onSelectSlot?: (slotInfo: SlotInfo) => void;
|
|
50
|
+
/** Wires the header "Add event" button. */
|
|
51
|
+
onAddEvent?: () => void;
|
|
52
|
+
className?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Structural layout for react-big-calendar's month grid. We deliberately do NOT
|
|
56
|
+
// import rbc's stylesheet — every visible token comes from our classes here +
|
|
57
|
+
// the component overrides below.
|
|
58
|
+
const rbcStructural = clsx(
|
|
59
|
+
"h-full font-body",
|
|
60
|
+
"[&_.rbc-month-view]:flex [&_.rbc-month-view]:flex-1 [&_.rbc-month-view]:flex-col [&_.rbc-month-view]:overflow-hidden",
|
|
61
|
+
"[&_.rbc-month-header]:flex [&_.rbc-month-header]:flex-row",
|
|
62
|
+
"[&_.rbc-header]:min-w-0 [&_.rbc-header]:flex-1 [&_.rbc-header]:overflow-hidden",
|
|
63
|
+
"[&_.rbc-month-row]:relative [&_.rbc-month-row]:flex [&_.rbc-month-row]:min-h-[120px] [&_.rbc-month-row]:flex-1 [&_.rbc-month-row]:flex-col [&_.rbc-month-row]:overflow-hidden",
|
|
64
|
+
"[&_.rbc-row-bg]:absolute [&_.rbc-row-bg]:inset-0 [&_.rbc-row-bg]:flex [&_.rbc-row-bg]:flex-row",
|
|
65
|
+
"[&_.rbc-day-bg]:min-w-0 [&_.rbc-day-bg]:flex-1 [&_.rbc-day-bg]:border-b [&_.rbc-day-bg]:border-r [&_.rbc-day-bg]:border-border-secondary [&_.rbc-day-bg]:bg-bg-primary",
|
|
66
|
+
"[&_.rbc-off-range-bg]:!bg-bg-secondary-alt",
|
|
67
|
+
"[&_.rbc-row-content]:relative [&_.rbc-row-content]:z-[1] [&_.rbc-row-content]:flex [&_.rbc-row-content]:flex-1 [&_.rbc-row-content]:flex-col",
|
|
68
|
+
"[&_.rbc-row]:flex [&_.rbc-row]:flex-row",
|
|
69
|
+
"[&_.rbc-date-cell]:min-w-0 [&_.rbc-date-cell]:flex-1",
|
|
70
|
+
"[&_.rbc-off-range]:opacity-50",
|
|
71
|
+
"[&_.rbc-row-segment]:min-w-0 [&_.rbc-row-segment]:px-md [&_.rbc-row-segment]:pb-xxs",
|
|
72
|
+
"[&_.rbc-event]:block [&_.rbc-event]:w-full [&_.rbc-event]:outline-none",
|
|
73
|
+
"[&_.rbc-show-more]:block [&_.rbc-show-more]:px-md [&_.rbc-show-more]:text-xs [&_.rbc-show-more]:font-semibold [&_.rbc-show-more]:text-utility-neutral-500",
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
function NavArrow({ dir }: { dir: "left" | "right" }) {
|
|
77
|
+
return (
|
|
78
|
+
<IconBox size={20}>{dir === "left" ? <ArrowLeft /> : <ArrowRight />}</IconBox>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function Calendar({
|
|
83
|
+
events,
|
|
84
|
+
defaultView = "month",
|
|
85
|
+
defaultDate,
|
|
86
|
+
onNavigate,
|
|
87
|
+
onView,
|
|
88
|
+
onSelectEvent,
|
|
89
|
+
onSelectSlot,
|
|
90
|
+
onAddEvent,
|
|
91
|
+
className,
|
|
92
|
+
}: CalendarProps) {
|
|
93
|
+
const [date, setDate] = useState<Date>(defaultDate ?? new Date(2027, 0, 1));
|
|
94
|
+
const [view, setView] = useState<View>(defaultView);
|
|
95
|
+
const [selected, setSelected] = useState<Date | null>(null);
|
|
96
|
+
|
|
97
|
+
const handleNavigate = useCallback(
|
|
98
|
+
(next: Date) => {
|
|
99
|
+
setDate(next);
|
|
100
|
+
onNavigate?.(next);
|
|
101
|
+
},
|
|
102
|
+
[onNavigate],
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const handleView = useCallback(
|
|
106
|
+
(next: View) => {
|
|
107
|
+
setView(next);
|
|
108
|
+
onView?.(next as CalendarView);
|
|
109
|
+
},
|
|
110
|
+
[onView],
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const handleSelectSlot = useCallback(
|
|
114
|
+
(slot: SlotInfo) => {
|
|
115
|
+
setSelected(slot.start);
|
|
116
|
+
onSelectSlot?.(slot);
|
|
117
|
+
},
|
|
118
|
+
[onSelectSlot],
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const components = useMemo(
|
|
122
|
+
() => ({
|
|
123
|
+
// Header bar → CalendarHeader primitive + nav/view/add controls.
|
|
124
|
+
toolbar: (tb: {
|
|
125
|
+
label: string;
|
|
126
|
+
onNavigate: (action: NavigateAction) => void;
|
|
127
|
+
onView: (view: View) => void;
|
|
128
|
+
view: View;
|
|
129
|
+
}) => (
|
|
130
|
+
<div className="border-b border-border-secondary px-3xl py-2xl">
|
|
131
|
+
<CalendarHeader
|
|
132
|
+
title={tb.label}
|
|
133
|
+
actions={
|
|
134
|
+
<div className="flex items-center gap-lg">
|
|
135
|
+
<div className="flex items-center overflow-hidden rounded-md border border-border-primary shadow-xs">
|
|
136
|
+
<button
|
|
137
|
+
type="button"
|
|
138
|
+
aria-label="Previous"
|
|
139
|
+
onClick={() => tb.onNavigate("PREV")}
|
|
140
|
+
className="flex items-center justify-center bg-bg-primary px-[10px] py-md text-fg-quaternary transition-colors hover:bg-bg-primary-hover"
|
|
141
|
+
>
|
|
142
|
+
<NavArrow dir="left" />
|
|
143
|
+
</button>
|
|
144
|
+
<button
|
|
145
|
+
type="button"
|
|
146
|
+
onClick={() => tb.onNavigate("TODAY")}
|
|
147
|
+
className="border-x border-border-primary bg-bg-primary px-[14px] py-md text-sm font-semibold text-text-secondary transition-colors hover:bg-bg-primary-hover"
|
|
148
|
+
>
|
|
149
|
+
Today
|
|
150
|
+
</button>
|
|
151
|
+
<button
|
|
152
|
+
type="button"
|
|
153
|
+
aria-label="Next"
|
|
154
|
+
onClick={() => tb.onNavigate("NEXT")}
|
|
155
|
+
className="flex items-center justify-center bg-bg-primary px-[10px] py-md text-fg-quaternary transition-colors hover:bg-bg-primary-hover"
|
|
156
|
+
>
|
|
157
|
+
<NavArrow dir="right" />
|
|
158
|
+
</button>
|
|
159
|
+
</div>
|
|
160
|
+
<CalendarViewDropdown
|
|
161
|
+
value={tb.view as CalendarView}
|
|
162
|
+
onChange={(v) => tb.onView(v)}
|
|
163
|
+
/>
|
|
164
|
+
{onAddEvent && (
|
|
165
|
+
<button
|
|
166
|
+
type="button"
|
|
167
|
+
onClick={onAddEvent}
|
|
168
|
+
className="flex items-center gap-xs rounded-md border-2 border-white/[0.12] bg-bg-brand-solid px-lg py-md text-sm font-semibold text-text-white shadow-skeuomorphic"
|
|
169
|
+
>
|
|
170
|
+
<span className="opacity-60"><IconBox size={20}><Plus /></IconBox></span>
|
|
171
|
+
Add event
|
|
172
|
+
</button>
|
|
173
|
+
)}
|
|
174
|
+
</div>
|
|
175
|
+
}
|
|
176
|
+
/>
|
|
177
|
+
</div>
|
|
178
|
+
),
|
|
179
|
+
month: {
|
|
180
|
+
// Weekday column header → CalendarColumnHeader primitive.
|
|
181
|
+
header: ({ label }: { label: string }) => (
|
|
182
|
+
<div className="flex h-full items-center justify-center border-b border-r border-border-secondary bg-bg-primary p-md">
|
|
183
|
+
<CalendarColumnHeader weekday={label} date="" />
|
|
184
|
+
</div>
|
|
185
|
+
),
|
|
186
|
+
// Date-number circle (Figma 7991:81840): default / today / selected.
|
|
187
|
+
dateHeader: ({ date: cellDate, label }: { date: Date; label: string }) => {
|
|
188
|
+
const today = isSameDay(cellDate, new Date());
|
|
189
|
+
const isSel = selected ? isSameDay(cellDate, selected) : false;
|
|
190
|
+
return (
|
|
191
|
+
<div className="flex p-md">
|
|
192
|
+
<span
|
|
193
|
+
className={clsx(
|
|
194
|
+
"flex size-6 items-center justify-center rounded-full text-xs font-semibold",
|
|
195
|
+
today
|
|
196
|
+
? "bg-bg-brand-solid text-text-white"
|
|
197
|
+
: isSel
|
|
198
|
+
? "bg-bg-secondary text-text-secondary"
|
|
199
|
+
: "text-text-secondary",
|
|
200
|
+
)}
|
|
201
|
+
>
|
|
202
|
+
{label}
|
|
203
|
+
</span>
|
|
204
|
+
</div>
|
|
205
|
+
);
|
|
206
|
+
},
|
|
207
|
+
// Event chip → CalendarEvent primitive (desktop, filled).
|
|
208
|
+
event: ({ event }: { event: CalendarEventData }) => (
|
|
209
|
+
<CalendarEvent
|
|
210
|
+
title={event.title}
|
|
211
|
+
time={event.allDay ? undefined : format(event.start, "h:mm a")}
|
|
212
|
+
color={event.color ?? "brand"}
|
|
213
|
+
filled
|
|
214
|
+
/>
|
|
215
|
+
),
|
|
216
|
+
},
|
|
217
|
+
}),
|
|
218
|
+
[onAddEvent, selected],
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
return (
|
|
222
|
+
<div
|
|
223
|
+
className={clsx(
|
|
224
|
+
"flex w-full flex-col overflow-hidden rounded-xl border border-border-secondary bg-bg-primary shadow-xs",
|
|
225
|
+
className,
|
|
226
|
+
)}
|
|
227
|
+
>
|
|
228
|
+
<BigCalendar
|
|
229
|
+
localizer={localizer}
|
|
230
|
+
events={events}
|
|
231
|
+
date={date}
|
|
232
|
+
view={view}
|
|
233
|
+
onNavigate={handleNavigate}
|
|
234
|
+
onView={handleView}
|
|
235
|
+
views={["month", "week", "day"]}
|
|
236
|
+
selectable
|
|
237
|
+
popup
|
|
238
|
+
startAccessor="start"
|
|
239
|
+
endAccessor="end"
|
|
240
|
+
onSelectEvent={(e) => onSelectEvent?.(e as CalendarEventData)}
|
|
241
|
+
onSelectSlot={handleSelectSlot}
|
|
242
|
+
messages={{ showMore: (total: number) => `${total} more…` }}
|
|
243
|
+
components={components as never}
|
|
244
|
+
className={rbcStructural}
|
|
245
|
+
style={{ height: 800 }}
|
|
246
|
+
/>
|
|
247
|
+
</div>
|
|
248
|
+
);
|
|
249
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { useEffect, type ReactNode } from "react";
|
|
2
|
+
import { EditorContent } from "@tiptap/react";
|
|
3
|
+
import clsx from "clsx";
|
|
4
|
+
import { Bold01, Link01, AlignLeft, AlignCenter, AlignRight, XClose } from "@borisj74/bv-ds-icons";
|
|
5
|
+
import { ButtonUtility } from "../ButtonUtility";
|
|
6
|
+
import { TextEditorToolbar, TextEditorToolbarDivider } from "../TextEditorToolbar";
|
|
7
|
+
import { TextEditorTooltip } from "../TextEditorTooltip";
|
|
8
|
+
import { IconBox } from "../../internal/iconBox";
|
|
9
|
+
import { useTextEditor } from "../../hooks/useTextEditor";
|
|
10
|
+
|
|
11
|
+
export interface TextEditorProps {
|
|
12
|
+
placeholder?: string;
|
|
13
|
+
/** Initial HTML content. */
|
|
14
|
+
content?: string;
|
|
15
|
+
/** Fires with serialized HTML on every change. */
|
|
16
|
+
onUpdate?: (html: string) => void;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
className?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/*
|
|
22
|
+
* Inline-SVG fallbacks for formatting glyphs absent from @borisj74/bv-ds-icons
|
|
23
|
+
* v0.1.0 (italic / underline / strike / bullet-list / ordered-list / heading).
|
|
24
|
+
* Swap to package icons once they exist. 24-grid, currentColor stroke.
|
|
25
|
+
*/
|
|
26
|
+
const stroke = {
|
|
27
|
+
fill: "none",
|
|
28
|
+
stroke: "currentColor",
|
|
29
|
+
strokeWidth: 2,
|
|
30
|
+
strokeLinecap: "round" as const,
|
|
31
|
+
strokeLinejoin: "round" as const,
|
|
32
|
+
};
|
|
33
|
+
const ItalicIcon = () => (
|
|
34
|
+
<svg viewBox="0 0 24 24" {...stroke}><path d="M19 4h-9M14 20H5M15 4 9 20" /></svg>
|
|
35
|
+
);
|
|
36
|
+
const UnderlineIcon = () => (
|
|
37
|
+
<svg viewBox="0 0 24 24" {...stroke}><path d="M6 4v6a6 6 0 0 0 12 0V4M4 21h16" /></svg>
|
|
38
|
+
);
|
|
39
|
+
const StrikeIcon = () => (
|
|
40
|
+
<svg viewBox="0 0 24 24" {...stroke}><path d="M4 12h16M16 7.5A4 3 0 0 0 8 8M8 16.5a4 3 0 0 0 8-.5" /></svg>
|
|
41
|
+
);
|
|
42
|
+
const HeadingIcon = () => (
|
|
43
|
+
<svg viewBox="0 0 24 24" {...stroke}><path d="M6 4v16M18 4v16M6 12h12" /></svg>
|
|
44
|
+
);
|
|
45
|
+
const BulletListIcon = () => (
|
|
46
|
+
<svg viewBox="0 0 24 24" {...stroke}><path d="M9 6h11M9 12h11M9 18h11M4.5 6h.01M4.5 12h.01M4.5 18h.01" /></svg>
|
|
47
|
+
);
|
|
48
|
+
const OrderedListIcon = () => (
|
|
49
|
+
<svg viewBox="0 0 24 24" {...stroke}><path d="M10 6h10M10 12h10M10 18h10M4 4v4M3 14h2l-2 3h2" /></svg>
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
// ProseMirror content styling (Tailwind preflight strips list/heading styles).
|
|
53
|
+
const proseClass = clsx(
|
|
54
|
+
"font-body text-sm text-text-primary",
|
|
55
|
+
"[&_.ProseMirror]:min-h-[80px] [&_.ProseMirror]:outline-none",
|
|
56
|
+
"[&_.ProseMirror_p]:my-0",
|
|
57
|
+
"[&_.ProseMirror_ul]:list-disc [&_.ProseMirror_ul]:pl-2xl",
|
|
58
|
+
"[&_.ProseMirror_ol]:list-decimal [&_.ProseMirror_ol]:pl-2xl",
|
|
59
|
+
"[&_.ProseMirror_h2]:text-lg [&_.ProseMirror_h2]:font-semibold [&_.ProseMirror_h2]:text-text-primary",
|
|
60
|
+
"[&_.ProseMirror_a]:text-text-brand-secondary [&_.ProseMirror_a]:underline",
|
|
61
|
+
// Placeholder (extension adds .is-editor-empty + data-placeholder).
|
|
62
|
+
"[&_.ProseMirror_p.is-editor-empty:first-child]:before:pointer-events-none",
|
|
63
|
+
"[&_.ProseMirror_p.is-editor-empty:first-child]:before:float-left",
|
|
64
|
+
"[&_.ProseMirror_p.is-editor-empty:first-child]:before:h-0",
|
|
65
|
+
"[&_.ProseMirror_p.is-editor-empty:first-child]:before:text-text-placeholder",
|
|
66
|
+
"[&_.ProseMirror_p.is-editor-empty:first-child]:before:content-[attr(data-placeholder)]",
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Rich-text editor — composes the `TextEditorToolbar` / `TextEditorTooltip`
|
|
71
|
+
* chrome shells onto a TipTap editor (`useTextEditor`). The toolbar buttons map
|
|
72
|
+
* to TipTap commands and reflect `editor.isActive(...)`; a `TextEditorTooltip`
|
|
73
|
+
* surfaces link controls when the caret sits inside a link.
|
|
74
|
+
*
|
|
75
|
+
* Pass `content` as initial HTML and read changes via `onUpdate(html)`.
|
|
76
|
+
*
|
|
77
|
+
* Behavioral constraints (confirmed in Batch 38):
|
|
78
|
+
*
|
|
79
|
+
* 1. **`content` is initial-only.** It seeds the document on mount; changing it
|
|
80
|
+
* afterwards does NOT resync into the editor (TipTap owns its state once
|
|
81
|
+
* mounted). For controlled behavior, lift state via `onUpdate(html)` instead
|
|
82
|
+
* of feeding `content` back in.
|
|
83
|
+
*
|
|
84
|
+
* 2. **The link tooltip is an inline affordance, not a floating BubbleMenu.**
|
|
85
|
+
* It renders inside the editor area when the caret is in a link — it is not
|
|
86
|
+
* positioned over the selection and pulls in no `@tiptap/extension-bubble-menu`
|
|
87
|
+
* dependency. Swap to a real BubbleMenu if positioned-over-selection UX is
|
|
88
|
+
* needed.
|
|
89
|
+
*/
|
|
90
|
+
export function TextEditor({
|
|
91
|
+
placeholder,
|
|
92
|
+
content,
|
|
93
|
+
onUpdate,
|
|
94
|
+
disabled = false,
|
|
95
|
+
className,
|
|
96
|
+
}: TextEditorProps) {
|
|
97
|
+
const editor = useTextEditor({ placeholder, content, editable: !disabled, onUpdate });
|
|
98
|
+
|
|
99
|
+
// Sync editable when `disabled` toggles without recreating the editor.
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
editor?.setEditable(!disabled);
|
|
102
|
+
}, [editor, disabled]);
|
|
103
|
+
|
|
104
|
+
const isActive = (name: string, attrs?: Record<string, unknown>) =>
|
|
105
|
+
editor?.isActive(name, attrs) ?? false;
|
|
106
|
+
|
|
107
|
+
const setLink = () => {
|
|
108
|
+
if (!editor) return;
|
|
109
|
+
const prev = (editor.getAttributes("link").href as string | undefined) ?? "";
|
|
110
|
+
const url = window.prompt("Link URL", prev);
|
|
111
|
+
if (url === null) return;
|
|
112
|
+
const chain = editor.chain().focus().extendMarkRange("link");
|
|
113
|
+
if (url === "") chain.unsetLink().run();
|
|
114
|
+
else chain.setLink({ href: url }).run();
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const ToolbarButton = ({
|
|
118
|
+
label,
|
|
119
|
+
icon,
|
|
120
|
+
active,
|
|
121
|
+
onRun,
|
|
122
|
+
}: {
|
|
123
|
+
label: string;
|
|
124
|
+
icon: ReactNode;
|
|
125
|
+
active?: boolean;
|
|
126
|
+
onRun: () => void;
|
|
127
|
+
}) => (
|
|
128
|
+
<ButtonUtility
|
|
129
|
+
variant="ghost"
|
|
130
|
+
label={label}
|
|
131
|
+
icon={<IconBox size={20}>{icon}</IconBox>}
|
|
132
|
+
aria-pressed={active}
|
|
133
|
+
disabled={disabled || !editor}
|
|
134
|
+
className={active ? "!bg-bg-secondary !text-fg-secondary" : undefined}
|
|
135
|
+
// Keep the editor selection when pressing a toolbar button.
|
|
136
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
137
|
+
onClick={onRun}
|
|
138
|
+
/>
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<div
|
|
143
|
+
className={clsx(
|
|
144
|
+
"relative flex flex-col overflow-hidden rounded-xl border border-border-primary bg-bg-primary",
|
|
145
|
+
"focus-within:ring-2 focus-within:ring-border-brand",
|
|
146
|
+
disabled && "opacity-60",
|
|
147
|
+
className,
|
|
148
|
+
)}
|
|
149
|
+
>
|
|
150
|
+
<TextEditorToolbar className="w-full !rounded-none !border-l-0 !border-r-0 !border-t-0 !shadow-none">
|
|
151
|
+
<ToolbarButton label="Bold" icon={<Bold01 />} active={isActive("bold")} onRun={() => editor?.chain().focus().toggleBold().run()} />
|
|
152
|
+
<ToolbarButton label="Italic" icon={<ItalicIcon />} active={isActive("italic")} onRun={() => editor?.chain().focus().toggleItalic().run()} />
|
|
153
|
+
<ToolbarButton label="Underline" icon={<UnderlineIcon />} active={isActive("underline")} onRun={() => editor?.chain().focus().toggleUnderline().run()} />
|
|
154
|
+
<ToolbarButton label="Strikethrough" icon={<StrikeIcon />} active={isActive("strike")} onRun={() => editor?.chain().focus().toggleStrike().run()} />
|
|
155
|
+
<TextEditorToolbarDivider />
|
|
156
|
+
<ToolbarButton label="Heading" icon={<HeadingIcon />} active={isActive("heading", { level: 2 })} onRun={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()} />
|
|
157
|
+
<ToolbarButton label="Bullet list" icon={<BulletListIcon />} active={isActive("bulletList")} onRun={() => editor?.chain().focus().toggleBulletList().run()} />
|
|
158
|
+
<ToolbarButton label="Ordered list" icon={<OrderedListIcon />} active={isActive("orderedList")} onRun={() => editor?.chain().focus().toggleOrderedList().run()} />
|
|
159
|
+
<TextEditorToolbarDivider />
|
|
160
|
+
<ToolbarButton label="Align left" icon={<AlignLeft />} active={editor?.isActive({ textAlign: "left" }) ?? false} onRun={() => editor?.chain().focus().setTextAlign("left").run()} />
|
|
161
|
+
<ToolbarButton label="Align center" icon={<AlignCenter />} active={editor?.isActive({ textAlign: "center" }) ?? false} onRun={() => editor?.chain().focus().setTextAlign("center").run()} />
|
|
162
|
+
<ToolbarButton label="Align right" icon={<AlignRight />} active={editor?.isActive({ textAlign: "right" }) ?? false} onRun={() => editor?.chain().focus().setTextAlign("right").run()} />
|
|
163
|
+
<TextEditorToolbarDivider />
|
|
164
|
+
<ToolbarButton label="Link" icon={<Link01 />} active={isActive("link")} onRun={setLink} />
|
|
165
|
+
</TextEditorToolbar>
|
|
166
|
+
|
|
167
|
+
<div className="relative min-h-[120px] px-3xl py-2xl">
|
|
168
|
+
<EditorContent editor={editor} className={proseClass} />
|
|
169
|
+
|
|
170
|
+
{editor?.isActive("link") && (
|
|
171
|
+
<TextEditorTooltip className="absolute right-3xl top-md z-10">
|
|
172
|
+
<span className="max-w-[160px] truncate px-xs text-xs text-text-secondary">
|
|
173
|
+
{String(editor.getAttributes("link").href ?? "")}
|
|
174
|
+
</span>
|
|
175
|
+
<ButtonUtility variant="ghost" size="xs" label="Edit link" icon={<IconBox size={16}><Link01 /></IconBox>} onClick={setLink} />
|
|
176
|
+
<ButtonUtility
|
|
177
|
+
variant="ghost"
|
|
178
|
+
size="xs"
|
|
179
|
+
label="Remove link"
|
|
180
|
+
icon={<IconBox size={16}><XClose /></IconBox>}
|
|
181
|
+
onClick={() => editor.chain().focus().extendMarkRange("link").unsetLink().run()}
|
|
182
|
+
/>
|
|
183
|
+
</TextEditorTooltip>
|
|
184
|
+
)}
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { useEditor, type Editor } from "@tiptap/react";
|
|
2
|
+
import { StarterKit } from "@tiptap/starter-kit";
|
|
3
|
+
import { TextAlign } from "@tiptap/extension-text-align";
|
|
4
|
+
import { Placeholder } from "@tiptap/extension-placeholder";
|
|
5
|
+
|
|
6
|
+
export interface UseTextEditorOptions {
|
|
7
|
+
/** Placeholder shown while the document is empty. */
|
|
8
|
+
placeholder?: string;
|
|
9
|
+
/** Initial HTML content. */
|
|
10
|
+
content?: string;
|
|
11
|
+
/** Whether the editor is editable (default `true`). */
|
|
12
|
+
editable?: boolean;
|
|
13
|
+
/** Fires with the serialized HTML on every change. */
|
|
14
|
+
onUpdate?: (html: string) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Thin wrapper around TipTap's `useEditor`, pre-configured with the standard
|
|
19
|
+
* extension set: StarterKit (bold/italic/strike/code/heading/lists — and, in
|
|
20
|
+
* TipTap v3, **underline + link**), TextAlign (left/center/right) and
|
|
21
|
+
* Placeholder. Link is configured `openOnClick: false` via StarterKit.
|
|
22
|
+
*
|
|
23
|
+
* Note: Underline and Link ship inside StarterKit v3, so they are NOT added as
|
|
24
|
+
* separate extensions (doing so throws a duplicate-extension error).
|
|
25
|
+
*
|
|
26
|
+
* Returns the TipTap `Editor` (or `null` until mounted). Compose it with
|
|
27
|
+
* `EditorContent` + the toolbar, or use the `TextEditor` composite.
|
|
28
|
+
*/
|
|
29
|
+
export function useTextEditor({
|
|
30
|
+
placeholder,
|
|
31
|
+
content,
|
|
32
|
+
editable = true,
|
|
33
|
+
onUpdate,
|
|
34
|
+
}: UseTextEditorOptions = {}): Editor | null {
|
|
35
|
+
return useEditor({
|
|
36
|
+
editable,
|
|
37
|
+
content: content ?? "",
|
|
38
|
+
extensions: [
|
|
39
|
+
StarterKit.configure({ link: { openOnClick: false } }),
|
|
40
|
+
TextAlign.configure({ types: ["heading", "paragraph"] }),
|
|
41
|
+
Placeholder.configure({ placeholder: placeholder ?? "" }),
|
|
42
|
+
],
|
|
43
|
+
onUpdate: ({ editor }) => onUpdate?.(editor.getHTML()),
|
|
44
|
+
});
|
|
45
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ export * from "./components/ButtonDestructive";
|
|
|
18
18
|
export * from "./components/ButtonGroup";
|
|
19
19
|
export * from "./components/ButtonGroupSegment";
|
|
20
20
|
export * from "./components/ButtonUtility";
|
|
21
|
+
export * from "./components/Calendar";
|
|
21
22
|
export * from "./components/CalendarCell";
|
|
22
23
|
export * from "./components/CalendarCellDayWeekView";
|
|
23
24
|
export * from "./components/CalendarColumnHeader";
|
|
@@ -135,6 +136,7 @@ export * from "./components/TableHeaderCell";
|
|
|
135
136
|
export * from "./components/TableHeaderLabel";
|
|
136
137
|
export * from "./components/Tabs";
|
|
137
138
|
export * from "./components/Tag";
|
|
139
|
+
export * from "./components/TextEditor";
|
|
138
140
|
export * from "./components/TextEditorToolbar";
|
|
139
141
|
export * from "./components/TextEditorTooltip";
|
|
140
142
|
export * from "./components/TextareaInputField";
|
|
@@ -144,6 +146,9 @@ export * from "./components/TreeView";
|
|
|
144
146
|
export * from "./components/TreeViewConnector";
|
|
145
147
|
export * from "./components/TreeViewItem";
|
|
146
148
|
export * from "./components/VerificationCodeInput";
|
|
149
|
+
// Hooks
|
|
150
|
+
export { useTextEditor } from "./hooks/useTextEditor";
|
|
151
|
+
export type { UseTextEditorOptions } from "./hooks/useTextEditor";
|
|
147
152
|
// Illustration set — namespaced group (not individual barrel exports).
|
|
148
153
|
export * as illustrations from "./illustrations";
|
|
149
154
|
// figma-to-react appends one line here per new component, e.g.:
|