@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.
Files changed (124) hide show
  1. package/dist/chunk-HDGMSYQS.js +26461 -0
  2. package/dist/chunk-HDGMSYQS.js.map +1 -0
  3. package/dist/chunk-PR4QN5HX.js +39 -0
  4. package/dist/chunk-PR4QN5HX.js.map +1 -0
  5. package/dist/form.d.ts +175 -0
  6. package/dist/form.js +5207 -0
  7. package/dist/form.js.map +1 -0
  8. package/dist/index.d.ts +1462 -0
  9. package/dist/index.js +81862 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/input-CZvh825j.d.ts +24 -0
  12. package/dist/qr-code-styling-3Y6LZH6V.js +1123 -0
  13. package/dist/qr-code-styling-3Y6LZH6V.js.map +1 -0
  14. package/package.json +79 -0
  15. package/src/components/form/checkbox-group-field.tsx +101 -0
  16. package/src/components/form/date-field.tsx +79 -0
  17. package/src/components/form/date-range-field.tsx +106 -0
  18. package/src/components/form/form-context.ts +10 -0
  19. package/src/components/form/form.tsx +54 -0
  20. package/src/components/form/number-field.tsx +69 -0
  21. package/src/components/form/select-field.tsx +76 -0
  22. package/src/components/form/submit-button.tsx +28 -0
  23. package/src/components/form/text-field.tsx +107 -0
  24. package/src/components/layout/dashboard-header.tsx +54 -0
  25. package/src/components/layout/dashboard-panel.tsx +34 -0
  26. package/src/components/theme-provider.tsx +403 -0
  27. package/src/components/ui/accordion.tsx +69 -0
  28. package/src/components/ui/alert-dialog.tsx +169 -0
  29. package/src/components/ui/alert.tsx +80 -0
  30. package/src/components/ui/animated-theme-toggler.tsx +265 -0
  31. package/src/components/ui/app-store-buttons.tsx +182 -0
  32. package/src/components/ui/aspect-ratio.tsx +23 -0
  33. package/src/components/ui/autocomplete.tsx +296 -0
  34. package/src/components/ui/avatar-group.tsx +95 -0
  35. package/src/components/ui/avatar.tsx +285 -0
  36. package/src/components/ui/badge-group.tsx +160 -0
  37. package/src/components/ui/badge.tsx +172 -0
  38. package/src/components/ui/breadcrumb.tsx +112 -0
  39. package/src/components/ui/button.tsx +77 -0
  40. package/src/components/ui/calendar.tsx +137 -0
  41. package/src/components/ui/card.tsx +244 -0
  42. package/src/components/ui/carousel.tsx +258 -0
  43. package/src/components/ui/chart.tsx +379 -0
  44. package/src/components/ui/checkbox-group.tsx +16 -0
  45. package/src/components/ui/checkbox.tsx +82 -0
  46. package/src/components/ui/collapsible.tsx +45 -0
  47. package/src/components/ui/combobox.tsx +411 -0
  48. package/src/components/ui/command.tsx +264 -0
  49. package/src/components/ui/context-menu.tsx +271 -0
  50. package/src/components/ui/credit-card.tsx +214 -0
  51. package/src/components/ui/dialog.tsx +196 -0
  52. package/src/components/ui/drawer.tsx +135 -0
  53. package/src/components/ui/empty.tsx +127 -0
  54. package/src/components/ui/featured-icon.tsx +149 -0
  55. package/src/components/ui/field.tsx +88 -0
  56. package/src/components/ui/fieldset.tsx +29 -0
  57. package/src/components/ui/form.tsx +17 -0
  58. package/src/components/ui/frame.tsx +82 -0
  59. package/src/components/ui/generic-empty.tsx +142 -0
  60. package/src/components/ui/group.tsx +97 -0
  61. package/src/components/ui/horizontal-scroll-fader.tsx +228 -0
  62. package/src/components/ui/input-group.tsx +102 -0
  63. package/src/components/ui/input-otp.tsx +96 -0
  64. package/src/components/ui/input.tsx +66 -0
  65. package/src/components/ui/item.tsx +198 -0
  66. package/src/components/ui/kbd.tsx +30 -0
  67. package/src/components/ui/label.tsx +28 -0
  68. package/src/components/ui/menu.tsx +312 -0
  69. package/src/components/ui/menubar.tsx +93 -0
  70. package/src/components/ui/meter.tsx +67 -0
  71. package/src/components/ui/multi-select.tsx +308 -0
  72. package/src/components/ui/navigation-menu.tsx +143 -0
  73. package/src/components/ui/number-field.tsx +160 -0
  74. package/src/components/ui/pagination-controls.tsx +74 -0
  75. package/src/components/ui/pagination.tsx +149 -0
  76. package/src/components/ui/popover.tsx +119 -0
  77. package/src/components/ui/preview-card.tsx +55 -0
  78. package/src/components/ui/progress.tsx +289 -0
  79. package/src/components/ui/qr-code.tsx +150 -0
  80. package/src/components/ui/radio-group.tsx +103 -0
  81. package/src/components/ui/resizable.tsx +56 -0
  82. package/src/components/ui/scroll-area.tsx +90 -0
  83. package/src/components/ui/scroller.tsx +38 -0
  84. package/src/components/ui/section-header.tsx +118 -0
  85. package/src/components/ui/select.tsx +181 -0
  86. package/src/components/ui/separator.tsx +23 -0
  87. package/src/components/ui/sheet.tsx +224 -0
  88. package/src/components/ui/sidebar.tsx +744 -0
  89. package/src/components/ui/skeleton.tsx +16 -0
  90. package/src/components/ui/slider.tsx +108 -0
  91. package/src/components/ui/smooth-scroll.tsx +143 -0
  92. package/src/components/ui/social-button.tsx +247 -0
  93. package/src/components/ui/spinner-on-demand.tsx +32 -0
  94. package/src/components/ui/spinner.tsx +18 -0
  95. package/src/components/ui/stat.tsx +187 -0
  96. package/src/components/ui/stepper.tsx +167 -0
  97. package/src/components/ui/switch.tsx +56 -0
  98. package/src/components/ui/table.tsx +126 -0
  99. package/src/components/ui/tabs.tsx +90 -0
  100. package/src/components/ui/tag.tsx +229 -0
  101. package/src/components/ui/target-countdown.tsx +46 -0
  102. package/src/components/ui/text-editor.tsx +313 -0
  103. package/src/components/ui/textarea.tsx +51 -0
  104. package/src/components/ui/timeline.tsx +116 -0
  105. package/src/components/ui/toast.tsx +268 -0
  106. package/src/components/ui/toggle-group.tsx +101 -0
  107. package/src/components/ui/toggle.tsx +45 -0
  108. package/src/components/ui/toolbar.tsx +89 -0
  109. package/src/components/ui/tooltip.tsx +102 -0
  110. package/src/components/ui/vertical-scroll-fader.tsx +250 -0
  111. package/src/components/ui/video-player.tsx +275 -0
  112. package/src/components/upload/avatar-upload-base.tsx +131 -0
  113. package/src/components/upload/image-upload-base.tsx +112 -0
  114. package/src/form.ts +17 -0
  115. package/src/index.ts +125 -0
  116. package/src/lib/hooks/use-callback-ref.ts +15 -0
  117. package/src/lib/hooks/use-first-render.ts +11 -0
  118. package/src/lib/hooks/use-hover.ts +53 -0
  119. package/src/lib/hooks/use-is-tab-active.ts +17 -0
  120. package/src/lib/hooks/use-media-query.ts +164 -0
  121. package/src/lib/utils/css.ts +6 -0
  122. package/src/styles.css +300 -0
  123. package/src/types/helpers.ts +24 -0
  124. 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 };