@borisj74/bv-ds 0.1.8 → 0.1.10
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 +574 -299
- package/dist/index.d.cts +101 -22
- package/dist/index.d.ts +101 -22
- package/dist/index.js +577 -304
- package/package.json +7 -2
- package/src/components/Calendar/Calendar.tsx +249 -0
- package/src/components/Calendar/index.ts +2 -0
- package/src/components/ComboBox/ComboBox.tsx +114 -0
- package/src/components/ComboBox/index.ts +2 -0
- package/src/components/TagsInputField/TagsInputField.tsx +35 -1
- package/src/components/TextEditor/TextEditor.tsx +13 -0
- package/src/index.ts +2 -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.10",
|
|
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",
|
|
@@ -39,8 +39,10 @@
|
|
|
39
39
|
"@tiptap/extension-text-align": ">=2.0.0",
|
|
40
40
|
"@tiptap/react": ">=2.0.0",
|
|
41
41
|
"@tiptap/starter-kit": ">=2.0.0",
|
|
42
|
+
"date-fns": ">=2.0.0",
|
|
42
43
|
"react": ">=18.0.0",
|
|
43
|
-
"react-dom": ">=18.0.0"
|
|
44
|
+
"react-dom": ">=18.0.0",
|
|
45
|
+
"react-big-calendar": ">=1.8.0"
|
|
44
46
|
},
|
|
45
47
|
"devDependencies": {
|
|
46
48
|
"@borisj74/bv-ds-icons": "^0.1.0",
|
|
@@ -54,11 +56,14 @@
|
|
|
54
56
|
"@tiptap/react": "^3.27.1",
|
|
55
57
|
"@tiptap/starter-kit": "^3.27.1",
|
|
56
58
|
"@types/react": "^18.2.0",
|
|
59
|
+
"@types/react-big-calendar": "^1.16.3",
|
|
57
60
|
"@types/react-dom": "^18.2.0",
|
|
58
61
|
"autoprefixer": "^10.4.0",
|
|
59
62
|
"clsx": "^2.1.0",
|
|
63
|
+
"date-fns": "^4.4.0",
|
|
60
64
|
"postcss": "^8.4.0",
|
|
61
65
|
"react": "^18.2.0",
|
|
66
|
+
"react-big-calendar": "^1.20.0",
|
|
62
67
|
"react-dom": "^18.2.0",
|
|
63
68
|
"recharts": "^2.15.4",
|
|
64
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,114 @@
|
|
|
1
|
+
import { useMemo, useState, type ReactNode } from "react";
|
|
2
|
+
import clsx from "clsx";
|
|
3
|
+
import { ChevronDown } from "@borisj74/bv-ds-icons";
|
|
4
|
+
import { IconBox } from "../../internal/iconBox";
|
|
5
|
+
import { FieldWrapper, boxClasses } from "../InputField/inputFieldShared";
|
|
6
|
+
import { SelectMenuItem } from "../SelectMenuItem";
|
|
7
|
+
|
|
8
|
+
export interface ComboBoxOption {
|
|
9
|
+
value: string;
|
|
10
|
+
label: string;
|
|
11
|
+
supportingText?: ReactNode;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ComboBoxProps {
|
|
15
|
+
options: ComboBoxOption[];
|
|
16
|
+
/** Selected option value (controlled). */
|
|
17
|
+
value?: string;
|
|
18
|
+
onChange?: (value: string) => void;
|
|
19
|
+
/** Fires on every keystroke in the input — wire for async/remote filtering. */
|
|
20
|
+
onInputChange?: (input: string) => void;
|
|
21
|
+
placeholder?: string;
|
|
22
|
+
disabled?: boolean;
|
|
23
|
+
/** Error styling on the input shell + hint. */
|
|
24
|
+
error?: boolean;
|
|
25
|
+
label?: ReactNode;
|
|
26
|
+
hint?: ReactNode;
|
|
27
|
+
required?: boolean;
|
|
28
|
+
className?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Text input with an autocomplete dropdown. Typing filters `options` by label
|
|
33
|
+
* (case-insensitive, substring); picking a row commits its `value` via
|
|
34
|
+
* `onChange` and fills the input with that label. A non-matching query shows a
|
|
35
|
+
* "No results" row. Controlled selection via `value`/`onChange`; raw keystrokes
|
|
36
|
+
* surface through `onInputChange` for remote filtering.
|
|
37
|
+
*/
|
|
38
|
+
export function ComboBox({
|
|
39
|
+
options,
|
|
40
|
+
value,
|
|
41
|
+
onChange,
|
|
42
|
+
onInputChange,
|
|
43
|
+
placeholder = "Search...",
|
|
44
|
+
disabled = false,
|
|
45
|
+
error = false,
|
|
46
|
+
label,
|
|
47
|
+
hint,
|
|
48
|
+
required,
|
|
49
|
+
className,
|
|
50
|
+
}: ComboBoxProps) {
|
|
51
|
+
const selectedLabel = useMemo(
|
|
52
|
+
() => options.find((o) => o.value === value)?.label ?? "",
|
|
53
|
+
[options, value],
|
|
54
|
+
);
|
|
55
|
+
const [query, setQuery] = useState(selectedLabel);
|
|
56
|
+
const [open, setOpen] = useState(false);
|
|
57
|
+
|
|
58
|
+
// Reflect external value changes while the field is closed (not mid-typing).
|
|
59
|
+
const display = open ? query : selectedLabel;
|
|
60
|
+
|
|
61
|
+
const filtered = options.filter((o) =>
|
|
62
|
+
o.label.toLowerCase().includes(query.trim().toLowerCase()),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const pick = (o: ComboBoxOption) => {
|
|
66
|
+
onChange?.(o.value);
|
|
67
|
+
setQuery(o.label);
|
|
68
|
+
setOpen(false);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<FieldWrapper label={label} required={required} hint={hint} destructive={error} className={className}>
|
|
73
|
+
<div className="relative">
|
|
74
|
+
<div className={clsx(boxClasses(error), "px-lg py-md", disabled && "cursor-not-allowed bg-bg-secondary opacity-60")}>
|
|
75
|
+
<input
|
|
76
|
+
value={display}
|
|
77
|
+
disabled={disabled}
|
|
78
|
+
placeholder={placeholder}
|
|
79
|
+
onFocus={() => setOpen(true)}
|
|
80
|
+
onBlur={() => setTimeout(() => setOpen(false), 120)}
|
|
81
|
+
onChange={(e) => {
|
|
82
|
+
setQuery(e.target.value);
|
|
83
|
+
setOpen(true);
|
|
84
|
+
onInputChange?.(e.target.value);
|
|
85
|
+
}}
|
|
86
|
+
className="min-w-0 flex-1 bg-transparent text-md text-text-primary outline-none placeholder:text-text-placeholder"
|
|
87
|
+
/>
|
|
88
|
+
<span className={clsx("text-fg-quaternary transition-transform", open && "rotate-180")}>
|
|
89
|
+
<IconBox size={16}><ChevronDown /></IconBox>
|
|
90
|
+
</span>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
{open && !disabled && (
|
|
94
|
+
<div className="absolute left-0 right-0 top-full z-50 mt-xs flex max-h-[320px] flex-col gap-px overflow-auto rounded-md border border-border-secondary-alt bg-bg-primary p-xs shadow-lg">
|
|
95
|
+
{filtered.length ? (
|
|
96
|
+
filtered.map((o) => (
|
|
97
|
+
<SelectMenuItem
|
|
98
|
+
key={o.value}
|
|
99
|
+
label={o.label}
|
|
100
|
+
supportingText={o.supportingText}
|
|
101
|
+
selected={o.value === value}
|
|
102
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
103
|
+
onClick={() => pick(o)}
|
|
104
|
+
/>
|
|
105
|
+
))
|
|
106
|
+
) : (
|
|
107
|
+
<div className="px-md py-lg text-center text-sm text-text-tertiary">No results</div>
|
|
108
|
+
)}
|
|
109
|
+
</div>
|
|
110
|
+
)}
|
|
111
|
+
</div>
|
|
112
|
+
</FieldWrapper>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { useState, type InputHTMLAttributes, type KeyboardEvent, type ReactNode } from "react";
|
|
2
2
|
import clsx from "clsx";
|
|
3
3
|
import { FieldWrapper, type InputFieldSize } from "../InputField/inputFieldShared";
|
|
4
4
|
|
|
@@ -10,6 +10,14 @@ export interface TagsInputFieldProps
|
|
|
10
10
|
tags?: string[];
|
|
11
11
|
/** Remove handler (receives the tag index). Renders a close button per chip. */
|
|
12
12
|
onRemoveTag?: (index: number) => void;
|
|
13
|
+
/**
|
|
14
|
+
* Add handler. When supplied, the inner input becomes interactive: Enter or
|
|
15
|
+
* comma commits the trimmed input text as a new tag; Backspace on an empty
|
|
16
|
+
* input removes the last chip (via `onRemoveTag`). Empty/duplicate adds are
|
|
17
|
+
* ignored. Without it the field stays display-only (chips controlled by
|
|
18
|
+
* `tags`/`onRemoveTag`).
|
|
19
|
+
*/
|
|
20
|
+
onAddTag?: (tag: string) => void;
|
|
13
21
|
/** `inner` keeps chips inside the field; `outer` shows them below it. */
|
|
14
22
|
variant?: TagsInputVariant;
|
|
15
23
|
size?: InputFieldSize;
|
|
@@ -42,6 +50,7 @@ function TagChip({ label, onRemove }: { label: string; onRemove?: () => void })
|
|
|
42
50
|
export function TagsInputField({
|
|
43
51
|
tags = [],
|
|
44
52
|
onRemoveTag,
|
|
53
|
+
onAddTag,
|
|
45
54
|
variant = "inner",
|
|
46
55
|
size = "md",
|
|
47
56
|
label,
|
|
@@ -52,6 +61,24 @@ export function TagsInputField({
|
|
|
52
61
|
placeholder = "Add tag",
|
|
53
62
|
...rest
|
|
54
63
|
}: TagsInputFieldProps) {
|
|
64
|
+
const [draft, setDraft] = useState("");
|
|
65
|
+
|
|
66
|
+
const commit = (raw: string) => {
|
|
67
|
+
const next = raw.trim();
|
|
68
|
+
if (!next || tags.includes(next)) return;
|
|
69
|
+
onAddTag?.(next);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
|
73
|
+
if (e.key === "Enter" || e.key === ",") {
|
|
74
|
+
e.preventDefault();
|
|
75
|
+
commit(draft);
|
|
76
|
+
setDraft("");
|
|
77
|
+
} else if (e.key === "Backspace" && draft === "" && tags.length) {
|
|
78
|
+
onRemoveTag?.(tags.length - 1);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
55
82
|
const chips = tags.map((t, i) => (
|
|
56
83
|
<TagChip key={i} label={t} onRemove={onRemoveTag ? () => onRemoveTag(i) : undefined} />
|
|
57
84
|
));
|
|
@@ -71,6 +98,13 @@ export function TagsInputField({
|
|
|
71
98
|
placeholder={placeholder}
|
|
72
99
|
className="min-w-[80px] flex-1 bg-transparent text-md text-text-primary outline-none placeholder:text-text-placeholder"
|
|
73
100
|
{...rest}
|
|
101
|
+
{...(onAddTag
|
|
102
|
+
? {
|
|
103
|
+
value: draft,
|
|
104
|
+
onChange: (e) => setDraft(e.target.value),
|
|
105
|
+
onKeyDown: handleKeyDown,
|
|
106
|
+
}
|
|
107
|
+
: {})}
|
|
74
108
|
/>
|
|
75
109
|
</div>
|
|
76
110
|
);
|
|
@@ -73,6 +73,19 @@ const proseClass = clsx(
|
|
|
73
73
|
* surfaces link controls when the caret sits inside a link.
|
|
74
74
|
*
|
|
75
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.
|
|
76
89
|
*/
|
|
77
90
|
export function TextEditor({
|
|
78
91
|
placeholder,
|
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";
|
|
@@ -46,6 +47,7 @@ export * from "./components/CommandBarNavigationIcon";
|
|
|
46
47
|
export * from "./components/CommandDropdownMenuItem";
|
|
47
48
|
export * from "./components/CommandInput";
|
|
48
49
|
export * from "./components/CommandShortcut";
|
|
50
|
+
export * from "./components/ComboBox";
|
|
49
51
|
export * from "./components/ContentDivider";
|
|
50
52
|
export * from "./components/ContentFeatureText";
|
|
51
53
|
export * from "./components/ContentHeading";
|