@borisj74/bv-ds 0.1.6 → 0.1.8
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 +182 -38
- package/dist/index.d.cts +55 -5
- package/dist/index.d.ts +55 -5
- package/dist/index.js +182 -40
- package/package.json +13 -3
- package/src/components/SidebarNavigation/SidebarNavigation.tsx +29 -4
- package/src/components/TextEditor/TextEditor.tsx +175 -0
- package/src/components/TextEditor/index.ts +2 -0
- package/src/hooks/useTextEditor.ts +45 -0
- package/src/index.ts +4 -0
|
@@ -17,6 +17,12 @@ export interface SidebarNavigationProps extends Omit<HTMLAttributes<HTMLElement>
|
|
|
17
17
|
header?: ReactNode;
|
|
18
18
|
/** Nav items — compose `NavItemBase` / `NavItemDropdownBase` children. */
|
|
19
19
|
children?: ReactNode;
|
|
20
|
+
/**
|
|
21
|
+
* `dualTier` only — second-tier panel (256px) shown beside the primary rail,
|
|
22
|
+
* listing the active item's sub-pages. Compose `NavItemBase` rows. Ignored for
|
|
23
|
+
* other types and on mobile (where only the primary rail shows).
|
|
24
|
+
*/
|
|
25
|
+
panel?: ReactNode;
|
|
20
26
|
/** Footer slot — typically `NavFeaturedCard` + `NavAccountCard`. */
|
|
21
27
|
footer?: ReactNode;
|
|
22
28
|
/** Controlled mobile-drawer open state. Omit to use internal state. */
|
|
@@ -39,15 +45,16 @@ const WIDTH: Record<SidebarNavigationType, string> = {
|
|
|
39
45
|
* the rail is hidden behind a hamburger (`NavMenuButton`); opening it shows a
|
|
40
46
|
* `bg-bg-overlay` + `backdrop-blur-md` scrim and slides the rail in as a drawer.
|
|
41
47
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
48
|
+
* `dualTier` renders a 280px primary rail plus a 256px second-tier `panel`
|
|
49
|
+
* (total 536px) for the active item's sub-pages; on mobile only the primary
|
|
50
|
+
* rail shows.
|
|
45
51
|
*/
|
|
46
52
|
export function SidebarNavigation({
|
|
47
53
|
type = "simple",
|
|
48
54
|
logo,
|
|
49
55
|
header,
|
|
50
56
|
children,
|
|
57
|
+
panel,
|
|
51
58
|
footer,
|
|
52
59
|
open,
|
|
53
60
|
onOpenChange,
|
|
@@ -58,6 +65,7 @@ export function SidebarNavigation({
|
|
|
58
65
|
const isOpen = open ?? internalOpen;
|
|
59
66
|
const setOpen = (v: boolean) => (onOpenChange ? onOpenChange(v) : setInternalOpen(v));
|
|
60
67
|
const slim = type === "slim";
|
|
68
|
+
const dualTier = type === "dualTier";
|
|
61
69
|
|
|
62
70
|
const rail = (
|
|
63
71
|
<nav
|
|
@@ -81,10 +89,27 @@ export function SidebarNavigation({
|
|
|
81
89
|
</nav>
|
|
82
90
|
);
|
|
83
91
|
|
|
92
|
+
// dualTier: primary rail (280) + second-tier panel (256) = 536px.
|
|
93
|
+
const panelEl =
|
|
94
|
+
dualTier && panel ? (
|
|
95
|
+
<div className="flex h-full w-[256px] shrink-0 flex-col gap-xxs overflow-y-auto border-r border-border-secondary bg-bg-primary px-xl py-xl">
|
|
96
|
+
{panel}
|
|
97
|
+
</div>
|
|
98
|
+
) : null;
|
|
99
|
+
|
|
100
|
+
const desktopRail = dualTier ? (
|
|
101
|
+
<div className="flex h-full">
|
|
102
|
+
{rail}
|
|
103
|
+
{panelEl}
|
|
104
|
+
</div>
|
|
105
|
+
) : (
|
|
106
|
+
rail
|
|
107
|
+
);
|
|
108
|
+
|
|
84
109
|
return (
|
|
85
110
|
<>
|
|
86
111
|
{/* Desktop rail */}
|
|
87
|
-
<aside className="hidden h-full md:flex">{
|
|
112
|
+
<aside className="hidden h-full md:flex">{desktopRail}</aside>
|
|
88
113
|
|
|
89
114
|
{/* Mobile trigger + drawer */}
|
|
90
115
|
<div className="md:hidden">
|
|
@@ -0,0 +1,175 @@
|
|
|
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
|
+
export function TextEditor({
|
|
78
|
+
placeholder,
|
|
79
|
+
content,
|
|
80
|
+
onUpdate,
|
|
81
|
+
disabled = false,
|
|
82
|
+
className,
|
|
83
|
+
}: TextEditorProps) {
|
|
84
|
+
const editor = useTextEditor({ placeholder, content, editable: !disabled, onUpdate });
|
|
85
|
+
|
|
86
|
+
// Sync editable when `disabled` toggles without recreating the editor.
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
editor?.setEditable(!disabled);
|
|
89
|
+
}, [editor, disabled]);
|
|
90
|
+
|
|
91
|
+
const isActive = (name: string, attrs?: Record<string, unknown>) =>
|
|
92
|
+
editor?.isActive(name, attrs) ?? false;
|
|
93
|
+
|
|
94
|
+
const setLink = () => {
|
|
95
|
+
if (!editor) return;
|
|
96
|
+
const prev = (editor.getAttributes("link").href as string | undefined) ?? "";
|
|
97
|
+
const url = window.prompt("Link URL", prev);
|
|
98
|
+
if (url === null) return;
|
|
99
|
+
const chain = editor.chain().focus().extendMarkRange("link");
|
|
100
|
+
if (url === "") chain.unsetLink().run();
|
|
101
|
+
else chain.setLink({ href: url }).run();
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const ToolbarButton = ({
|
|
105
|
+
label,
|
|
106
|
+
icon,
|
|
107
|
+
active,
|
|
108
|
+
onRun,
|
|
109
|
+
}: {
|
|
110
|
+
label: string;
|
|
111
|
+
icon: ReactNode;
|
|
112
|
+
active?: boolean;
|
|
113
|
+
onRun: () => void;
|
|
114
|
+
}) => (
|
|
115
|
+
<ButtonUtility
|
|
116
|
+
variant="ghost"
|
|
117
|
+
label={label}
|
|
118
|
+
icon={<IconBox size={20}>{icon}</IconBox>}
|
|
119
|
+
aria-pressed={active}
|
|
120
|
+
disabled={disabled || !editor}
|
|
121
|
+
className={active ? "!bg-bg-secondary !text-fg-secondary" : undefined}
|
|
122
|
+
// Keep the editor selection when pressing a toolbar button.
|
|
123
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
124
|
+
onClick={onRun}
|
|
125
|
+
/>
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<div
|
|
130
|
+
className={clsx(
|
|
131
|
+
"relative flex flex-col overflow-hidden rounded-xl border border-border-primary bg-bg-primary",
|
|
132
|
+
"focus-within:ring-2 focus-within:ring-border-brand",
|
|
133
|
+
disabled && "opacity-60",
|
|
134
|
+
className,
|
|
135
|
+
)}
|
|
136
|
+
>
|
|
137
|
+
<TextEditorToolbar className="w-full !rounded-none !border-l-0 !border-r-0 !border-t-0 !shadow-none">
|
|
138
|
+
<ToolbarButton label="Bold" icon={<Bold01 />} active={isActive("bold")} onRun={() => editor?.chain().focus().toggleBold().run()} />
|
|
139
|
+
<ToolbarButton label="Italic" icon={<ItalicIcon />} active={isActive("italic")} onRun={() => editor?.chain().focus().toggleItalic().run()} />
|
|
140
|
+
<ToolbarButton label="Underline" icon={<UnderlineIcon />} active={isActive("underline")} onRun={() => editor?.chain().focus().toggleUnderline().run()} />
|
|
141
|
+
<ToolbarButton label="Strikethrough" icon={<StrikeIcon />} active={isActive("strike")} onRun={() => editor?.chain().focus().toggleStrike().run()} />
|
|
142
|
+
<TextEditorToolbarDivider />
|
|
143
|
+
<ToolbarButton label="Heading" icon={<HeadingIcon />} active={isActive("heading", { level: 2 })} onRun={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()} />
|
|
144
|
+
<ToolbarButton label="Bullet list" icon={<BulletListIcon />} active={isActive("bulletList")} onRun={() => editor?.chain().focus().toggleBulletList().run()} />
|
|
145
|
+
<ToolbarButton label="Ordered list" icon={<OrderedListIcon />} active={isActive("orderedList")} onRun={() => editor?.chain().focus().toggleOrderedList().run()} />
|
|
146
|
+
<TextEditorToolbarDivider />
|
|
147
|
+
<ToolbarButton label="Align left" icon={<AlignLeft />} active={editor?.isActive({ textAlign: "left" }) ?? false} onRun={() => editor?.chain().focus().setTextAlign("left").run()} />
|
|
148
|
+
<ToolbarButton label="Align center" icon={<AlignCenter />} active={editor?.isActive({ textAlign: "center" }) ?? false} onRun={() => editor?.chain().focus().setTextAlign("center").run()} />
|
|
149
|
+
<ToolbarButton label="Align right" icon={<AlignRight />} active={editor?.isActive({ textAlign: "right" }) ?? false} onRun={() => editor?.chain().focus().setTextAlign("right").run()} />
|
|
150
|
+
<TextEditorToolbarDivider />
|
|
151
|
+
<ToolbarButton label="Link" icon={<Link01 />} active={isActive("link")} onRun={setLink} />
|
|
152
|
+
</TextEditorToolbar>
|
|
153
|
+
|
|
154
|
+
<div className="relative min-h-[120px] px-3xl py-2xl">
|
|
155
|
+
<EditorContent editor={editor} className={proseClass} />
|
|
156
|
+
|
|
157
|
+
{editor?.isActive("link") && (
|
|
158
|
+
<TextEditorTooltip className="absolute right-3xl top-md z-10">
|
|
159
|
+
<span className="max-w-[160px] truncate px-xs text-xs text-text-secondary">
|
|
160
|
+
{String(editor.getAttributes("link").href ?? "")}
|
|
161
|
+
</span>
|
|
162
|
+
<ButtonUtility variant="ghost" size="xs" label="Edit link" icon={<IconBox size={16}><Link01 /></IconBox>} onClick={setLink} />
|
|
163
|
+
<ButtonUtility
|
|
164
|
+
variant="ghost"
|
|
165
|
+
size="xs"
|
|
166
|
+
label="Remove link"
|
|
167
|
+
icon={<IconBox size={16}><XClose /></IconBox>}
|
|
168
|
+
onClick={() => editor.chain().focus().extendMarkRange("link").unsetLink().run()}
|
|
169
|
+
/>
|
|
170
|
+
</TextEditorTooltip>
|
|
171
|
+
)}
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
@@ -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
|
@@ -135,6 +135,7 @@ export * from "./components/TableHeaderCell";
|
|
|
135
135
|
export * from "./components/TableHeaderLabel";
|
|
136
136
|
export * from "./components/Tabs";
|
|
137
137
|
export * from "./components/Tag";
|
|
138
|
+
export * from "./components/TextEditor";
|
|
138
139
|
export * from "./components/TextEditorToolbar";
|
|
139
140
|
export * from "./components/TextEditorTooltip";
|
|
140
141
|
export * from "./components/TextareaInputField";
|
|
@@ -144,6 +145,9 @@ export * from "./components/TreeView";
|
|
|
144
145
|
export * from "./components/TreeViewConnector";
|
|
145
146
|
export * from "./components/TreeViewItem";
|
|
146
147
|
export * from "./components/VerificationCodeInput";
|
|
148
|
+
// Hooks
|
|
149
|
+
export { useTextEditor } from "./hooks/useTextEditor";
|
|
150
|
+
export type { UseTextEditorOptions } from "./hooks/useTextEditor";
|
|
147
151
|
// Illustration set — namespaced group (not individual barrel exports).
|
|
148
152
|
export * as illustrations from "./illustrations";
|
|
149
153
|
// figma-to-react appends one line here per new component, e.g.:
|