@cntyclub/ui-react 0.1.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/dist/chunk-HDGMSYQS.js +26461 -0
- package/dist/chunk-HDGMSYQS.js.map +1 -0
- package/dist/chunk-PR4QN5HX.js +39 -0
- package/dist/chunk-PR4QN5HX.js.map +1 -0
- package/dist/form.d.ts +175 -0
- package/dist/form.js +5207 -0
- package/dist/form.js.map +1 -0
- package/dist/index.d.ts +1462 -0
- package/dist/index.js +81862 -0
- package/dist/index.js.map +1 -0
- package/dist/input-CZvh825j.d.ts +24 -0
- package/dist/qr-code-styling-3Y6LZH6V.js +1123 -0
- package/dist/qr-code-styling-3Y6LZH6V.js.map +1 -0
- package/package.json +79 -0
- package/src/components/form/checkbox-group-field.tsx +101 -0
- package/src/components/form/date-field.tsx +79 -0
- package/src/components/form/date-range-field.tsx +106 -0
- package/src/components/form/form-context.ts +10 -0
- package/src/components/form/form.tsx +54 -0
- package/src/components/form/number-field.tsx +69 -0
- package/src/components/form/select-field.tsx +76 -0
- package/src/components/form/submit-button.tsx +28 -0
- package/src/components/form/text-field.tsx +107 -0
- package/src/components/layout/dashboard-header.tsx +54 -0
- package/src/components/layout/dashboard-panel.tsx +34 -0
- package/src/components/theme-provider.tsx +403 -0
- package/src/components/ui/accordion.tsx +69 -0
- package/src/components/ui/alert-dialog.tsx +169 -0
- package/src/components/ui/alert.tsx +80 -0
- package/src/components/ui/animated-theme-toggler.tsx +265 -0
- package/src/components/ui/app-store-buttons.tsx +182 -0
- package/src/components/ui/aspect-ratio.tsx +23 -0
- package/src/components/ui/autocomplete.tsx +296 -0
- package/src/components/ui/avatar-group.tsx +95 -0
- package/src/components/ui/avatar.tsx +285 -0
- package/src/components/ui/badge-group.tsx +160 -0
- package/src/components/ui/badge.tsx +172 -0
- package/src/components/ui/breadcrumb.tsx +112 -0
- package/src/components/ui/button.tsx +77 -0
- package/src/components/ui/calendar.tsx +137 -0
- package/src/components/ui/card.tsx +244 -0
- package/src/components/ui/carousel.tsx +258 -0
- package/src/components/ui/chart.tsx +379 -0
- package/src/components/ui/checkbox-group.tsx +16 -0
- package/src/components/ui/checkbox.tsx +82 -0
- package/src/components/ui/collapsible.tsx +45 -0
- package/src/components/ui/combobox.tsx +411 -0
- package/src/components/ui/command.tsx +264 -0
- package/src/components/ui/context-menu.tsx +271 -0
- package/src/components/ui/credit-card.tsx +214 -0
- package/src/components/ui/dialog.tsx +196 -0
- package/src/components/ui/drawer.tsx +135 -0
- package/src/components/ui/empty.tsx +127 -0
- package/src/components/ui/featured-icon.tsx +149 -0
- package/src/components/ui/field.tsx +88 -0
- package/src/components/ui/fieldset.tsx +29 -0
- package/src/components/ui/form.tsx +17 -0
- package/src/components/ui/frame.tsx +82 -0
- package/src/components/ui/generic-empty.tsx +142 -0
- package/src/components/ui/group.tsx +97 -0
- package/src/components/ui/horizontal-scroll-fader.tsx +228 -0
- package/src/components/ui/input-group.tsx +102 -0
- package/src/components/ui/input-otp.tsx +96 -0
- package/src/components/ui/input.tsx +66 -0
- package/src/components/ui/item.tsx +198 -0
- package/src/components/ui/kbd.tsx +30 -0
- package/src/components/ui/label.tsx +28 -0
- package/src/components/ui/menu.tsx +312 -0
- package/src/components/ui/menubar.tsx +93 -0
- package/src/components/ui/meter.tsx +67 -0
- package/src/components/ui/multi-select.tsx +308 -0
- package/src/components/ui/navigation-menu.tsx +143 -0
- package/src/components/ui/number-field.tsx +160 -0
- package/src/components/ui/pagination-controls.tsx +74 -0
- package/src/components/ui/pagination.tsx +149 -0
- package/src/components/ui/popover.tsx +119 -0
- package/src/components/ui/preview-card.tsx +55 -0
- package/src/components/ui/progress.tsx +289 -0
- package/src/components/ui/qr-code.tsx +150 -0
- package/src/components/ui/radio-group.tsx +103 -0
- package/src/components/ui/resizable.tsx +56 -0
- package/src/components/ui/scroll-area.tsx +90 -0
- package/src/components/ui/scroller.tsx +38 -0
- package/src/components/ui/section-header.tsx +118 -0
- package/src/components/ui/select.tsx +181 -0
- package/src/components/ui/separator.tsx +23 -0
- package/src/components/ui/sheet.tsx +224 -0
- package/src/components/ui/sidebar.tsx +744 -0
- package/src/components/ui/skeleton.tsx +16 -0
- package/src/components/ui/slider.tsx +108 -0
- package/src/components/ui/smooth-scroll.tsx +143 -0
- package/src/components/ui/social-button.tsx +247 -0
- package/src/components/ui/spinner-on-demand.tsx +32 -0
- package/src/components/ui/spinner.tsx +18 -0
- package/src/components/ui/stat.tsx +187 -0
- package/src/components/ui/stepper.tsx +167 -0
- package/src/components/ui/switch.tsx +56 -0
- package/src/components/ui/table.tsx +126 -0
- package/src/components/ui/tabs.tsx +90 -0
- package/src/components/ui/tag.tsx +229 -0
- package/src/components/ui/target-countdown.tsx +46 -0
- package/src/components/ui/text-editor.tsx +313 -0
- package/src/components/ui/textarea.tsx +51 -0
- package/src/components/ui/timeline.tsx +116 -0
- package/src/components/ui/toast.tsx +268 -0
- package/src/components/ui/toggle-group.tsx +101 -0
- package/src/components/ui/toggle.tsx +45 -0
- package/src/components/ui/toolbar.tsx +89 -0
- package/src/components/ui/tooltip.tsx +102 -0
- package/src/components/ui/vertical-scroll-fader.tsx +250 -0
- package/src/components/ui/video-player.tsx +275 -0
- package/src/components/upload/avatar-upload-base.tsx +131 -0
- package/src/components/upload/image-upload-base.tsx +112 -0
- package/src/form.ts +17 -0
- package/src/index.ts +125 -0
- package/src/lib/hooks/use-callback-ref.ts +15 -0
- package/src/lib/hooks/use-first-render.ts +11 -0
- package/src/lib/hooks/use-hover.ts +53 -0
- package/src/lib/hooks/use-is-tab-active.ts +17 -0
- package/src/lib/hooks/use-media-query.ts +164 -0
- package/src/lib/utils/css.ts +6 -0
- package/src/styles.css +300 -0
- package/src/types/helpers.ts +24 -0
- package/src/types/react.d.ts +7 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { cva } from "class-variance-authority";
|
|
4
|
+
import { CheckIcon, XIcon } from "lucide-react";
|
|
5
|
+
import { createContext, useContext, useState } from "react";
|
|
6
|
+
import type * as React from "react";
|
|
7
|
+
|
|
8
|
+
import { cn } from "../../lib/utils/css";
|
|
9
|
+
|
|
10
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
type TagSize = "sm" | "md" | "lg";
|
|
13
|
+
type TagSelectionMode = "none" | "single" | "multiple";
|
|
14
|
+
|
|
15
|
+
// ─── Context ──────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
interface TagGroupContextValue {
|
|
18
|
+
size: TagSize;
|
|
19
|
+
selectionMode: TagSelectionMode;
|
|
20
|
+
selectedKeys: ReadonlySet<string>;
|
|
21
|
+
onItemSelect: (key: string) => void;
|
|
22
|
+
onItemRemove?: (key: string) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const TagGroupContext = createContext<TagGroupContextValue | null>(null);
|
|
26
|
+
|
|
27
|
+
// ─── TagGroup ─────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
interface TagGroupProps {
|
|
30
|
+
size?: TagSize;
|
|
31
|
+
selectionMode?: TagSelectionMode;
|
|
32
|
+
defaultSelectedKeys?: Iterable<string>;
|
|
33
|
+
selectedKeys?: Set<string>;
|
|
34
|
+
onSelectionChange?: (keys: Set<string>) => void;
|
|
35
|
+
onRemove?: (key: string) => void;
|
|
36
|
+
className?: string;
|
|
37
|
+
children?: React.ReactNode;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function TagGroup({
|
|
41
|
+
size = "md",
|
|
42
|
+
selectionMode = "none",
|
|
43
|
+
defaultSelectedKeys,
|
|
44
|
+
selectedKeys: controlledKeys,
|
|
45
|
+
onSelectionChange,
|
|
46
|
+
onRemove,
|
|
47
|
+
className,
|
|
48
|
+
children,
|
|
49
|
+
}: TagGroupProps) {
|
|
50
|
+
const [internalKeys, setInternalKeys] = useState<Set<string>>(
|
|
51
|
+
() => new Set(defaultSelectedKeys),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const selectedKeys = controlledKeys ?? internalKeys;
|
|
55
|
+
|
|
56
|
+
function handleSelect(key: string) {
|
|
57
|
+
if (selectionMode === "none") return;
|
|
58
|
+
let next: Set<string>;
|
|
59
|
+
if (selectionMode === "single") {
|
|
60
|
+
next = selectedKeys.has(key) ? new Set() : new Set([key]);
|
|
61
|
+
} else {
|
|
62
|
+
next = new Set(selectedKeys);
|
|
63
|
+
if (next.has(key)) next.delete(key);
|
|
64
|
+
else next.add(key);
|
|
65
|
+
}
|
|
66
|
+
if (!controlledKeys) setInternalKeys(next);
|
|
67
|
+
onSelectionChange?.(next);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<TagGroupContext.Provider
|
|
72
|
+
value={{
|
|
73
|
+
size,
|
|
74
|
+
selectionMode,
|
|
75
|
+
selectedKeys,
|
|
76
|
+
onItemSelect: handleSelect,
|
|
77
|
+
onItemRemove: onRemove,
|
|
78
|
+
}}
|
|
79
|
+
>
|
|
80
|
+
<div
|
|
81
|
+
className={cn("flex flex-wrap items-center gap-1.5", className)}
|
|
82
|
+
data-slot="tag-group"
|
|
83
|
+
role={selectionMode !== "none" ? "group" : undefined}
|
|
84
|
+
>
|
|
85
|
+
{children}
|
|
86
|
+
</div>
|
|
87
|
+
</TagGroupContext.Provider>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ─── Tag ──────────────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
const tagVariants = cva(
|
|
94
|
+
[
|
|
95
|
+
"relative inline-flex items-center rounded-full border font-medium whitespace-nowrap select-none",
|
|
96
|
+
"outline-none transition-colors",
|
|
97
|
+
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
|
|
98
|
+
],
|
|
99
|
+
{
|
|
100
|
+
defaultVariants: { size: "md" },
|
|
101
|
+
variants: {
|
|
102
|
+
size: {
|
|
103
|
+
sm: "h-6 gap-1 px-2 text-xs",
|
|
104
|
+
md: "h-7 gap-1.5 px-2.5 text-sm",
|
|
105
|
+
lg: "h-8 gap-2 px-3 text-base",
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const iconSize = { sm: "size-3", md: "size-3.5", lg: "size-4" } as const;
|
|
112
|
+
const countSize = {
|
|
113
|
+
sm: "h-4 min-w-4 text-[10px]",
|
|
114
|
+
md: "h-[18px] min-w-[18px] text-xs",
|
|
115
|
+
lg: "h-5 min-w-5 text-sm",
|
|
116
|
+
} as const;
|
|
117
|
+
|
|
118
|
+
interface TagProps extends Omit<React.ComponentProps<"span">, "children"> {
|
|
119
|
+
/** Unique value used for selection and removal tracking. */
|
|
120
|
+
value?: string;
|
|
121
|
+
size?: TagSize;
|
|
122
|
+
/** Numeric count displayed as a small pill beside the label. */
|
|
123
|
+
count?: number;
|
|
124
|
+
/** Called when the remove button is clicked. Renders the X button. */
|
|
125
|
+
onRemove?: () => void;
|
|
126
|
+
disabled?: boolean;
|
|
127
|
+
children?: React.ReactNode;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function Tag({
|
|
131
|
+
value = "",
|
|
132
|
+
size: sizeProp,
|
|
133
|
+
count,
|
|
134
|
+
onRemove: onRemoveProp,
|
|
135
|
+
disabled,
|
|
136
|
+
children,
|
|
137
|
+
className,
|
|
138
|
+
...props
|
|
139
|
+
}: TagProps) {
|
|
140
|
+
const ctx = useContext(TagGroupContext);
|
|
141
|
+
|
|
142
|
+
const size = sizeProp ?? ctx?.size ?? "md";
|
|
143
|
+
const selectionMode = ctx?.selectionMode ?? "none";
|
|
144
|
+
const isSelected = Boolean(ctx?.selectedKeys.has(value));
|
|
145
|
+
const isSelectable = selectionMode !== "none";
|
|
146
|
+
const handleRemove =
|
|
147
|
+
onRemoveProp ??
|
|
148
|
+
(value && ctx?.onItemRemove ? () => ctx.onItemRemove!(value) : undefined);
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<span
|
|
152
|
+
aria-disabled={disabled || undefined}
|
|
153
|
+
aria-pressed={isSelectable ? isSelected : undefined}
|
|
154
|
+
className={cn(
|
|
155
|
+
tagVariants({ size }),
|
|
156
|
+
"border-input bg-background text-foreground",
|
|
157
|
+
isSelectable && !disabled && "cursor-pointer hover:bg-accent",
|
|
158
|
+
isSelected && "border-brand bg-brand/10 text-brand hover:bg-brand/15",
|
|
159
|
+
disabled && "pointer-events-none opacity-50",
|
|
160
|
+
className,
|
|
161
|
+
)}
|
|
162
|
+
data-disabled={disabled || undefined}
|
|
163
|
+
data-selected={isSelected || undefined}
|
|
164
|
+
data-slot="tag"
|
|
165
|
+
role={isSelectable ? "button" : undefined}
|
|
166
|
+
tabIndex={isSelectable && !disabled ? 0 : undefined}
|
|
167
|
+
onClick={
|
|
168
|
+
isSelectable && !disabled ? () => ctx?.onItemSelect(value) : undefined
|
|
169
|
+
}
|
|
170
|
+
onKeyDown={
|
|
171
|
+
isSelectable && !disabled
|
|
172
|
+
? (e) => {
|
|
173
|
+
if (e.key === " " || e.key === "Enter") {
|
|
174
|
+
e.preventDefault();
|
|
175
|
+
ctx?.onItemSelect(value);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
: undefined
|
|
179
|
+
}
|
|
180
|
+
{...props}
|
|
181
|
+
>
|
|
182
|
+
{isSelected && isSelectable && (
|
|
183
|
+
<CheckIcon
|
|
184
|
+
aria-hidden
|
|
185
|
+
className={cn("shrink-0", iconSize[size])}
|
|
186
|
+
strokeWidth={2.5}
|
|
187
|
+
/>
|
|
188
|
+
)}
|
|
189
|
+
<span>{children}</span>
|
|
190
|
+
{count !== undefined && (
|
|
191
|
+
<span
|
|
192
|
+
className={cn(
|
|
193
|
+
"inline-flex items-center justify-center rounded-full px-1 font-medium tabular-nums",
|
|
194
|
+
countSize[size],
|
|
195
|
+
isSelected
|
|
196
|
+
? "bg-brand/20 text-brand"
|
|
197
|
+
: "bg-muted text-muted-foreground",
|
|
198
|
+
)}
|
|
199
|
+
data-slot="tag-count"
|
|
200
|
+
>
|
|
201
|
+
{count}
|
|
202
|
+
</span>
|
|
203
|
+
)}
|
|
204
|
+
{handleRemove && (
|
|
205
|
+
<button
|
|
206
|
+
aria-label="Remove"
|
|
207
|
+
className={cn(
|
|
208
|
+
"-me-0.5 inline-flex shrink-0 cursor-pointer items-center justify-center rounded-full",
|
|
209
|
+
"text-current opacity-60 outline-none transition-opacity",
|
|
210
|
+
"hover:opacity-100 focus-visible:ring-1 focus-visible:ring-ring focus-visible:opacity-100",
|
|
211
|
+
iconSize[size],
|
|
212
|
+
)}
|
|
213
|
+
data-slot="tag-remove"
|
|
214
|
+
onClick={(e) => {
|
|
215
|
+
e.stopPropagation();
|
|
216
|
+
handleRemove();
|
|
217
|
+
}}
|
|
218
|
+
tabIndex={-1}
|
|
219
|
+
type="button"
|
|
220
|
+
>
|
|
221
|
+
<XIcon aria-hidden className="pointer-events-none size-full" />
|
|
222
|
+
</button>
|
|
223
|
+
)}
|
|
224
|
+
</span>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export { TagGroup, Tag };
|
|
229
|
+
export type { TagSize, TagSelectionMode, TagGroupProps, TagProps };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { type Duration, intervalToDuration } from "date-fns";
|
|
4
|
+
import { useEffect, useState } from "react";
|
|
5
|
+
import { useFirstRender } from "../../lib/hooks/use-first-render";
|
|
6
|
+
import useIsTabActive from "../../lib/hooks/use-is-tab-active";
|
|
7
|
+
|
|
8
|
+
function TargetCountdown({
|
|
9
|
+
target,
|
|
10
|
+
children,
|
|
11
|
+
}: {
|
|
12
|
+
target: Date | number;
|
|
13
|
+
children: (
|
|
14
|
+
duration: Duration | null,
|
|
15
|
+
isFirstRender: boolean,
|
|
16
|
+
) => React.ReactNode;
|
|
17
|
+
}) {
|
|
18
|
+
const [now, setNow] = useState(Date.now());
|
|
19
|
+
const [start] = useState(() => now);
|
|
20
|
+
const isTabActive = useIsTabActive();
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (!isTabActive || now >= Number(target)) return;
|
|
24
|
+
const timeout = setTimeout(
|
|
25
|
+
() => {
|
|
26
|
+
setNow(Date.now());
|
|
27
|
+
},
|
|
28
|
+
1000 - ((Date.now() - start) % 1000),
|
|
29
|
+
);
|
|
30
|
+
return () => clearTimeout(timeout);
|
|
31
|
+
}, [start, now, isTabActive, target]);
|
|
32
|
+
|
|
33
|
+
const isFirstRender = useFirstRender();
|
|
34
|
+
|
|
35
|
+
if (isFirstRender || now >= Number(target))
|
|
36
|
+
return children(null, isFirstRender);
|
|
37
|
+
return children(
|
|
38
|
+
intervalToDuration({
|
|
39
|
+
start: now,
|
|
40
|
+
end: target,
|
|
41
|
+
}),
|
|
42
|
+
isFirstRender,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export default TargetCountdown;
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { EditorContent, useEditor } from "@tiptap/react";
|
|
4
|
+
import { BubbleMenu } from "@tiptap/react/menus";
|
|
5
|
+
import Placeholder from "@tiptap/extension-placeholder";
|
|
6
|
+
import TextAlign from "@tiptap/extension-text-align";
|
|
7
|
+
import StarterKit from "@tiptap/starter-kit";
|
|
8
|
+
import type { Editor } from "@tiptap/core";
|
|
9
|
+
import type * as React from "react";
|
|
10
|
+
import {
|
|
11
|
+
AlignCenter,
|
|
12
|
+
AlignJustify,
|
|
13
|
+
AlignLeft,
|
|
14
|
+
AlignRight,
|
|
15
|
+
Bold,
|
|
16
|
+
Italic,
|
|
17
|
+
List,
|
|
18
|
+
ListOrdered,
|
|
19
|
+
Redo2,
|
|
20
|
+
Strikethrough,
|
|
21
|
+
Undo2,
|
|
22
|
+
} from "lucide-react";
|
|
23
|
+
|
|
24
|
+
import { cn } from "../../lib/utils/css";
|
|
25
|
+
import {
|
|
26
|
+
Tooltip,
|
|
27
|
+
TooltipPopup,
|
|
28
|
+
TooltipProvider,
|
|
29
|
+
TooltipTrigger,
|
|
30
|
+
} from "./tooltip";
|
|
31
|
+
|
|
32
|
+
// Inject placeholder + list styles once (client only, idempotent)
|
|
33
|
+
if (typeof window !== "undefined") {
|
|
34
|
+
const CSS_ID = "cc-text-editor";
|
|
35
|
+
if (!document.getElementById(CSS_ID)) {
|
|
36
|
+
const s = document.createElement("style");
|
|
37
|
+
s.id = CSS_ID;
|
|
38
|
+
s.textContent = [
|
|
39
|
+
".cc-text-editor .ProseMirror{outline:none}",
|
|
40
|
+
".cc-text-editor .ProseMirror p.is-editor-empty:first-child::before{content:attr(data-placeholder);float:left;color:var(--muted-foreground);pointer-events:none;height:0}",
|
|
41
|
+
".cc-text-editor .ProseMirror ul{list-style-type:disc;padding-left:1.5em}",
|
|
42
|
+
".cc-text-editor .ProseMirror ol{list-style-type:decimal;padding-left:1.5em}",
|
|
43
|
+
".cc-text-editor .ProseMirror li{margin-top:0.15em}",
|
|
44
|
+
".cc-text-editor .ProseMirror > * + *{margin-top:0.4em}",
|
|
45
|
+
].join("\n");
|
|
46
|
+
document.head.appendChild(s);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ─── Toolbar internals ────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
interface TBtnProps {
|
|
53
|
+
icon: React.ReactNode;
|
|
54
|
+
label: string;
|
|
55
|
+
onClick?: () => void;
|
|
56
|
+
isActive?: boolean;
|
|
57
|
+
disabled?: boolean;
|
|
58
|
+
compact?: boolean;
|
|
59
|
+
tooltip?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function TBtn({ icon, label, onClick, isActive, disabled, compact, tooltip }: TBtnProps) {
|
|
63
|
+
const btnClass = cn(
|
|
64
|
+
"relative inline-flex shrink-0 cursor-pointer items-center justify-center rounded-md border border-transparent outline-none transition-[background-color,box-shadow] duration-100",
|
|
65
|
+
"text-muted-foreground hover:bg-accent hover:text-foreground",
|
|
66
|
+
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
|
|
67
|
+
"disabled:pointer-events-none disabled:opacity-40",
|
|
68
|
+
"data-[active]:bg-accent data-[active]:text-foreground",
|
|
69
|
+
"[&_svg]:pointer-events-none [&_svg]:shrink-0",
|
|
70
|
+
compact ? "size-6 [&_svg]:size-3" : "size-7 [&_svg]:size-3.5",
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
if (tooltip) {
|
|
74
|
+
return (
|
|
75
|
+
<Tooltip>
|
|
76
|
+
<TooltipTrigger
|
|
77
|
+
type="button"
|
|
78
|
+
onClick={onClick}
|
|
79
|
+
disabled={disabled}
|
|
80
|
+
aria-label={label}
|
|
81
|
+
data-active={isActive ? "" : undefined}
|
|
82
|
+
className={btnClass}
|
|
83
|
+
>
|
|
84
|
+
{icon}
|
|
85
|
+
</TooltipTrigger>
|
|
86
|
+
<TooltipPopup>{tooltip}</TooltipPopup>
|
|
87
|
+
</Tooltip>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<button
|
|
93
|
+
type="button"
|
|
94
|
+
onClick={onClick}
|
|
95
|
+
disabled={disabled}
|
|
96
|
+
aria-label={label}
|
|
97
|
+
data-active={isActive ? "" : undefined}
|
|
98
|
+
className={btnClass}
|
|
99
|
+
>
|
|
100
|
+
{icon}
|
|
101
|
+
</button>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function TSep() {
|
|
106
|
+
return <div className="mx-0.5 h-4 w-px shrink-0 bg-border" />;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
interface ToolbarButtonsProps {
|
|
110
|
+
editor: Editor | null;
|
|
111
|
+
compact?: boolean;
|
|
112
|
+
withTooltips?: boolean;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function ToolbarButtons({ editor, compact, withTooltips }: ToolbarButtonsProps) {
|
|
116
|
+
if (!editor) return null;
|
|
117
|
+
const t = (label: string) => (withTooltips ? label : undefined);
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<>
|
|
121
|
+
<TBtn
|
|
122
|
+
icon={<Undo2 />}
|
|
123
|
+
label="Undo"
|
|
124
|
+
tooltip={t("Undo")}
|
|
125
|
+
compact={compact}
|
|
126
|
+
onClick={() => editor.chain().focus().undo().run()}
|
|
127
|
+
disabled={!editor.can().undo()}
|
|
128
|
+
/>
|
|
129
|
+
<TBtn
|
|
130
|
+
icon={<Redo2 />}
|
|
131
|
+
label="Redo"
|
|
132
|
+
tooltip={t("Redo")}
|
|
133
|
+
compact={compact}
|
|
134
|
+
onClick={() => editor.chain().focus().redo().run()}
|
|
135
|
+
disabled={!editor.can().redo()}
|
|
136
|
+
/>
|
|
137
|
+
<TSep />
|
|
138
|
+
<TBtn
|
|
139
|
+
icon={<Bold />}
|
|
140
|
+
label="Bold"
|
|
141
|
+
tooltip={t("Bold")}
|
|
142
|
+
compact={compact}
|
|
143
|
+
isActive={editor.isActive("bold")}
|
|
144
|
+
onClick={() => editor.chain().focus().toggleBold().run()}
|
|
145
|
+
/>
|
|
146
|
+
<TBtn
|
|
147
|
+
icon={<Italic />}
|
|
148
|
+
label="Italic"
|
|
149
|
+
tooltip={t("Italic")}
|
|
150
|
+
compact={compact}
|
|
151
|
+
isActive={editor.isActive("italic")}
|
|
152
|
+
onClick={() => editor.chain().focus().toggleItalic().run()}
|
|
153
|
+
/>
|
|
154
|
+
<TBtn
|
|
155
|
+
icon={<Strikethrough />}
|
|
156
|
+
label="Strikethrough"
|
|
157
|
+
tooltip={t("Strikethrough")}
|
|
158
|
+
compact={compact}
|
|
159
|
+
isActive={editor.isActive("strike")}
|
|
160
|
+
onClick={() => editor.chain().focus().toggleStrike().run()}
|
|
161
|
+
/>
|
|
162
|
+
<TSep />
|
|
163
|
+
<TBtn
|
|
164
|
+
icon={<List />}
|
|
165
|
+
label="Bullet list"
|
|
166
|
+
tooltip={t("Bullet list")}
|
|
167
|
+
compact={compact}
|
|
168
|
+
isActive={editor.isActive("bulletList")}
|
|
169
|
+
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
|
170
|
+
/>
|
|
171
|
+
<TBtn
|
|
172
|
+
icon={<ListOrdered />}
|
|
173
|
+
label="Ordered list"
|
|
174
|
+
tooltip={t("Ordered list")}
|
|
175
|
+
compact={compact}
|
|
176
|
+
isActive={editor.isActive("orderedList")}
|
|
177
|
+
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
|
178
|
+
/>
|
|
179
|
+
<TSep />
|
|
180
|
+
<TBtn
|
|
181
|
+
icon={<AlignLeft />}
|
|
182
|
+
label="Align left"
|
|
183
|
+
tooltip={t("Align left")}
|
|
184
|
+
compact={compact}
|
|
185
|
+
isActive={editor.isActive({ textAlign: "left" })}
|
|
186
|
+
onClick={() => editor.chain().focus().setTextAlign("left").run()}
|
|
187
|
+
/>
|
|
188
|
+
<TBtn
|
|
189
|
+
icon={<AlignCenter />}
|
|
190
|
+
label="Align center"
|
|
191
|
+
tooltip={t("Align center")}
|
|
192
|
+
compact={compact}
|
|
193
|
+
isActive={editor.isActive({ textAlign: "center" })}
|
|
194
|
+
onClick={() => editor.chain().focus().setTextAlign("center").run()}
|
|
195
|
+
/>
|
|
196
|
+
<TBtn
|
|
197
|
+
icon={<AlignRight />}
|
|
198
|
+
label="Align right"
|
|
199
|
+
tooltip={t("Align right")}
|
|
200
|
+
compact={compact}
|
|
201
|
+
isActive={editor.isActive({ textAlign: "right" })}
|
|
202
|
+
onClick={() => editor.chain().focus().setTextAlign("right").run()}
|
|
203
|
+
/>
|
|
204
|
+
<TBtn
|
|
205
|
+
icon={<AlignJustify />}
|
|
206
|
+
label="Justify"
|
|
207
|
+
tooltip={t("Justify")}
|
|
208
|
+
compact={compact}
|
|
209
|
+
isActive={editor.isActive({ textAlign: "justify" })}
|
|
210
|
+
onClick={() => editor.chain().focus().setTextAlign("justify").run()}
|
|
211
|
+
/>
|
|
212
|
+
</>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
export interface TextEditorProps {
|
|
219
|
+
/** Compact (sm) or standard (md) size */
|
|
220
|
+
size?: "sm" | "md";
|
|
221
|
+
/** Placeholder when the editor is empty */
|
|
222
|
+
placeholder?: string;
|
|
223
|
+
/** Initial HTML content */
|
|
224
|
+
content?: string;
|
|
225
|
+
/** Called with the updated HTML on every change */
|
|
226
|
+
onChange?: (html: string) => void;
|
|
227
|
+
/** Show tooltips on every toolbar button */
|
|
228
|
+
withTooltips?: boolean;
|
|
229
|
+
/** Replace the fixed toolbar with a selection-triggered floating toolbar */
|
|
230
|
+
floatingToolbar?: boolean;
|
|
231
|
+
/** Extra className for the outer wrapper */
|
|
232
|
+
className?: string;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function TextEditor({
|
|
236
|
+
size = "md",
|
|
237
|
+
placeholder = "Start typing...",
|
|
238
|
+
content,
|
|
239
|
+
onChange,
|
|
240
|
+
withTooltips = false,
|
|
241
|
+
floatingToolbar = false,
|
|
242
|
+
className,
|
|
243
|
+
}: TextEditorProps) {
|
|
244
|
+
const editor = useEditor(
|
|
245
|
+
{
|
|
246
|
+
immediatelyRender: false,
|
|
247
|
+
extensions: [
|
|
248
|
+
StarterKit,
|
|
249
|
+
TextAlign.configure({ types: ["heading", "paragraph"] }),
|
|
250
|
+
Placeholder.configure({
|
|
251
|
+
placeholder,
|
|
252
|
+
emptyEditorClass: "is-editor-empty",
|
|
253
|
+
}),
|
|
254
|
+
],
|
|
255
|
+
content: content ?? "",
|
|
256
|
+
onUpdate({ editor: e }) {
|
|
257
|
+
onChange?.(e.getHTML());
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
const compact = size === "sm";
|
|
263
|
+
|
|
264
|
+
return (
|
|
265
|
+
<TooltipProvider>
|
|
266
|
+
<div
|
|
267
|
+
data-slot="text-editor"
|
|
268
|
+
className={cn(
|
|
269
|
+
"cc-text-editor relative overflow-hidden rounded-xl border border-input bg-background",
|
|
270
|
+
"not-dark:bg-clip-padding shadow-xs/5",
|
|
271
|
+
className,
|
|
272
|
+
)}
|
|
273
|
+
>
|
|
274
|
+
{/* Fixed toolbar */}
|
|
275
|
+
{!floatingToolbar && (
|
|
276
|
+
<div
|
|
277
|
+
className={cn(
|
|
278
|
+
"flex flex-wrap items-center gap-0.5 border-b border-input",
|
|
279
|
+
compact ? "px-1.5 py-1" : "px-2 py-1.5",
|
|
280
|
+
)}
|
|
281
|
+
>
|
|
282
|
+
<ToolbarButtons editor={editor} compact={compact} withTooltips={withTooltips} />
|
|
283
|
+
</div>
|
|
284
|
+
)}
|
|
285
|
+
|
|
286
|
+
{/* Floating (bubble) toolbar — appears on text selection */}
|
|
287
|
+
{floatingToolbar && editor && (
|
|
288
|
+
<BubbleMenu
|
|
289
|
+
editor={editor}
|
|
290
|
+
className={cn(
|
|
291
|
+
"flex items-center gap-0.5 rounded-lg border border-input bg-popover not-dark:bg-clip-padding p-1",
|
|
292
|
+
"relative shadow-md/8 before:pointer-events-none before:absolute before:inset-0",
|
|
293
|
+
"before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)]",
|
|
294
|
+
)}
|
|
295
|
+
>
|
|
296
|
+
<ToolbarButtons editor={editor} compact withTooltips={withTooltips} />
|
|
297
|
+
</BubbleMenu>
|
|
298
|
+
)}
|
|
299
|
+
|
|
300
|
+
{/* Editor content */}
|
|
301
|
+
<EditorContent
|
|
302
|
+
editor={editor}
|
|
303
|
+
className={cn(
|
|
304
|
+
"text-foreground",
|
|
305
|
+
compact
|
|
306
|
+
? "[&_.ProseMirror]:min-h-24 [&_.ProseMirror]:px-3 [&_.ProseMirror]:py-2 [&_.ProseMirror]:text-sm"
|
|
307
|
+
: "[&_.ProseMirror]:min-h-36 [&_.ProseMirror]:px-4 [&_.ProseMirror]:py-3 [&_.ProseMirror]:text-sm",
|
|
308
|
+
)}
|
|
309
|
+
/>
|
|
310
|
+
</div>
|
|
311
|
+
</TooltipProvider>
|
|
312
|
+
);
|
|
313
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Field as FieldPrimitive } from "@base-ui/react/field";
|
|
4
|
+
import { mergeProps } from "@base-ui/react/merge-props";
|
|
5
|
+
import type * as React from "react";
|
|
6
|
+
|
|
7
|
+
import { cn } from "../../lib/utils/css";
|
|
8
|
+
|
|
9
|
+
type TextareaProps = React.ComponentProps<"textarea"> & {
|
|
10
|
+
size?: "sm" | "default" | "lg" | number;
|
|
11
|
+
unstyled?: boolean;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function Textarea({
|
|
15
|
+
className,
|
|
16
|
+
size = "default",
|
|
17
|
+
unstyled = false,
|
|
18
|
+
...props
|
|
19
|
+
}: TextareaProps) {
|
|
20
|
+
return (
|
|
21
|
+
<span
|
|
22
|
+
className={
|
|
23
|
+
cn(
|
|
24
|
+
!unstyled &&
|
|
25
|
+
"relative inline-flex w-full rounded-lg border border-input bg-background not-dark:bg-clip-padding text-base text-foreground shadow-xs/5 ring-ring/24 transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] has-focus-visible:has-aria-invalid:border-destructive/64 has-focus-visible:has-aria-invalid:ring-destructive/16 has-aria-invalid:border-destructive/36 has-focus-visible:border-ring has-disabled:opacity-64 has-[:disabled,:focus-visible,[aria-invalid]]:shadow-none has-focus-visible:ring-[3px] not-has-disabled:has-not-focus-visible:not-has-aria-invalid:before:shadow-[0_1px_--theme(--color-black/6%)] sm:text-sm dark:bg-input/32 dark:has-aria-invalid:ring-destructive/24 dark:not-has-disabled:has-not-focus-visible:not-has-aria-invalid:before:shadow-[0_-1px_--theme(--color-white/6%)]",
|
|
26
|
+
className,
|
|
27
|
+
) || undefined
|
|
28
|
+
}
|
|
29
|
+
data-size={size}
|
|
30
|
+
data-slot="textarea-control"
|
|
31
|
+
>
|
|
32
|
+
<FieldPrimitive.Control
|
|
33
|
+
render={(defaultProps) => (
|
|
34
|
+
<textarea
|
|
35
|
+
className={cn(
|
|
36
|
+
"field-sizing-content min-h-17.5 w-full rounded-[inherit] px-[calc(--spacing(3)-1px)] py-[calc(--spacing(1.5)-1px)] outline-none max-sm:min-h-20.5",
|
|
37
|
+
size === "sm" &&
|
|
38
|
+
"min-h-16.5 px-[calc(--spacing(2.5)-1px)] py-[calc(--spacing(1)-1px)] max-sm:min-h-19.5",
|
|
39
|
+
size === "lg" &&
|
|
40
|
+
"min-h-18.5 py-[calc(--spacing(2)-1px)] max-sm:min-h-21.5",
|
|
41
|
+
)}
|
|
42
|
+
data-slot="textarea"
|
|
43
|
+
{...mergeProps(defaultProps, props)}
|
|
44
|
+
/>
|
|
45
|
+
)}
|
|
46
|
+
/>
|
|
47
|
+
</span>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export { Textarea, type TextareaProps };
|