@crisp-ui-kit/crisp 0.11.0 → 0.13.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/board.css +7 -0
- package/dist/cjs/product/board/board.js +68 -23
- package/dist/cjs/product/board/board.recipe.js +8 -1
- package/dist/cjs/product/richtext/richtext.js +10 -4
- package/dist/esm/product/board/board.js +68 -23
- package/dist/esm/product/board/board.recipe.js +8 -1
- package/dist/esm/product/richtext/richtext.js +10 -4
- package/dist/styles.css +7 -0
- package/dist/types/product/board/board.d.ts +9 -1
- package/dist/types/product/richtext/richtext.d.ts +3 -1
- package/package.json +1 -1
package/dist/board.css
CHANGED
|
@@ -14,6 +14,13 @@
|
|
|
14
14
|
& .board-card { display: flex; flex-direction: column; gap: var(--space-2); box-sizing: border-box; padding: var(--space-2-5) var(--space-3); border-radius: var(--radius-lg); background: var(--bg-surface); box-shadow: inset 0 0 0 1px var(--border-subtle), 0 1px 2px rgba(0,0,0,0.04); cursor: grab; }
|
|
15
15
|
& .board-card:active { cursor: grabbing; }
|
|
16
16
|
& .board-card[data-dragging] { opacity: 0.4; }
|
|
17
|
+
& .board-card:focus { outline: none; }
|
|
18
|
+
& .board-card:focus-visible { outline: 2px solid var(--bg-brand-solid); outline-offset: 1px; }
|
|
19
|
+
/* announced, never drawn */
|
|
20
|
+
& .board-live {
|
|
21
|
+
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
|
22
|
+
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
|
|
23
|
+
}
|
|
17
24
|
}
|
|
18
25
|
|
|
19
26
|
@media (prefers-reduced-motion: reduce) {
|
|
@@ -43,36 +43,81 @@ const board_recipe_1 = require("./board.recipe");
|
|
|
43
43
|
* (the parent owns the cards, so the move is a controlled state update).
|
|
44
44
|
* Measured from Attio's Deals pipeline.
|
|
45
45
|
*/
|
|
46
|
-
function Board({ columns, cards, onCardsChange, renderCard, onNewCard, newCardLabel = "New", className, ...rest }) {
|
|
46
|
+
function Board({ columns, cards, onCardsChange, renderCard, onNewCard, newCardLabel = "New", getCardLabel, announceMove = (column, card) => card ? `${card} moved to ${column}` : `Moved to ${column}`, className, ...rest }) {
|
|
47
47
|
const [dragId, setDragId] = React.useState(null);
|
|
48
48
|
const [overCol, setOverCol] = React.useState(null);
|
|
49
|
+
const [announcement, setAnnouncement] = React.useState("");
|
|
50
|
+
// the card a keyboard move just sent to another column; React unmounts and
|
|
51
|
+
// remounts it under the new parent, so focus has to be put back by hand
|
|
52
|
+
const [refocusId, setRefocusId] = React.useState(null);
|
|
53
|
+
const rootRef = React.useRef(null);
|
|
49
54
|
const move = (cardId, toColumn) => {
|
|
50
55
|
const card = cards.find((c) => c.id === cardId);
|
|
51
56
|
if (!card || card.columnId === toColumn)
|
|
52
57
|
return;
|
|
53
58
|
onCardsChange?.(cards.map((c) => (c.id === cardId ? { ...c, columnId: toColumn } : c)));
|
|
54
59
|
};
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
return
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
60
|
+
React.useEffect(() => {
|
|
61
|
+
if (!refocusId)
|
|
62
|
+
return;
|
|
63
|
+
rootRef.current
|
|
64
|
+
?.querySelector(`[data-card-id="${CSS.escape(refocusId)}"]`)
|
|
65
|
+
?.focus();
|
|
66
|
+
setRefocusId(null);
|
|
67
|
+
}, [refocusId]);
|
|
68
|
+
/**
|
|
69
|
+
* Native HTML5 drag-and-drop is pointer-only by specification, so the board's
|
|
70
|
+
* primary action needs its own key model or it has none at all.
|
|
71
|
+
*
|
|
72
|
+
* Ctrl/Cmd + Left/Right rather than plain arrows: a screen reader owns the
|
|
73
|
+
* bare arrow keys in browse mode, and a board scrolls horizontally, so plain
|
|
74
|
+
* arrows on a focused card would fight both.
|
|
75
|
+
*/
|
|
76
|
+
const moveByKey = (card, delta) => {
|
|
77
|
+
const from = columns.findIndex((c) => c.id === card.columnId);
|
|
78
|
+
const to = columns[from + delta];
|
|
79
|
+
if (from < 0 || !to)
|
|
80
|
+
return false;
|
|
81
|
+
move(card.id, to.id);
|
|
82
|
+
setAnnouncement(announceMove(to.title, getCardLabel?.(card)));
|
|
83
|
+
setRefocusId(card.id);
|
|
84
|
+
return true;
|
|
85
|
+
};
|
|
86
|
+
return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, board_recipe_1.boardRecipe)({ className }), ...rest, children: [columns.map((col) => {
|
|
87
|
+
const colCards = cards.filter((c) => c.columnId === col.id);
|
|
88
|
+
return ((0, jsx_runtime_1.jsxs)("div", { className: "board-col", "data-over": overCol === col.id ? "" : undefined, onDragOver: (e) => {
|
|
89
|
+
if (!dragId)
|
|
90
|
+
return;
|
|
91
|
+
e.preventDefault();
|
|
92
|
+
setOverCol(col.id);
|
|
93
|
+
}, onDragLeave: (e) => {
|
|
94
|
+
if (!e.currentTarget.contains(e.relatedTarget)) {
|
|
95
|
+
setOverCol((v) => (v === col.id ? null : v));
|
|
96
|
+
}
|
|
97
|
+
}, onDrop: (e) => {
|
|
98
|
+
e.preventDefault();
|
|
99
|
+
if (dragId)
|
|
100
|
+
move(dragId, col.id);
|
|
101
|
+
setDragId(null);
|
|
102
|
+
setOverCol(null);
|
|
103
|
+
}, children: [(0, jsx_runtime_1.jsxs)("div", { className: "board-col-header", children: [col.accent && ((0, jsx_runtime_1.jsx)("span", { className: "board-col-dot", style: { background: col.accent } })), (0, jsx_runtime_1.jsx)("span", { className: "board-col-title", children: col.title }), (0, jsx_runtime_1.jsx)("span", { className: "board-col-count", children: colCards.length }), onNewCard && ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "board-col-add", "aria-label": `Add to ${col.title}`, onClick: () => onNewCard(col.id), children: "+" }))] }), (0, jsx_runtime_1.jsxs)("div", { className: "board-col-body", role: "list", "aria-label": col.title, children: [onNewCard && ((0, jsx_runtime_1.jsxs)("button", { type: "button", className: "board-newcard", onClick: () => onNewCard(col.id), children: ["+ ", newCardLabel] })), colCards.map((card) => ((0, jsx_runtime_1.jsx)("div", { className: "board-card", role: "listitem",
|
|
104
|
+
// biome-ignore lint/a11y/noNoninteractiveTabindex: the rule assumes a listitem cannot be operated. This one carries the board's only keyboard path to move a card, so it has to be reachable by Tab
|
|
105
|
+
tabIndex: 0, "aria-label": getCardLabel?.(card), "data-card-id": card.id, draggable: true, "data-dragging": dragId === card.id ? "" : undefined, onDragStart: () => setDragId(card.id), onDragEnd: () => {
|
|
106
|
+
setDragId(null);
|
|
107
|
+
setOverCol(null);
|
|
108
|
+
}, onKeyDown: (e) => {
|
|
109
|
+
if (!(e.ctrlKey || e.metaKey))
|
|
110
|
+
return;
|
|
111
|
+
const delta = e.key === "ArrowLeft"
|
|
112
|
+
? -1
|
|
113
|
+
: e.key === "ArrowRight"
|
|
114
|
+
? 1
|
|
115
|
+
: 0;
|
|
116
|
+
// only swallow the key when the move actually happens, so
|
|
117
|
+
// Ctrl+Right at the last column still reaches the browser
|
|
118
|
+
if (delta && moveByKey(card, delta))
|
|
119
|
+
e.preventDefault();
|
|
120
|
+
}, children: renderCard(card) }, card.id)))] })] }, col.id));
|
|
121
|
+
}), (0, jsx_runtime_1.jsx)("div", { className: "board-live", role: "status", "aria-live": "polite", children: announcement })] }));
|
|
77
122
|
}
|
|
78
123
|
Board.displayName = "Board";
|
|
@@ -22,7 +22,14 @@ const config = (0, core_1.defineRecipe)({
|
|
|
22
22
|
& .board-newcard:hover { background: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 7%, transparent); color: ${tokens_1.contract.fgPrimary}; }
|
|
23
23
|
& .board-card { display: flex; flex-direction: column; gap: ${tokens_1.contract.space["2"]}; box-sizing: border-box; padding: ${tokens_1.contract.space["2.5"]} ${tokens_1.contract.space["3"]}; border-radius: ${tokens_1.contract.radius.lg}; background: ${tokens_1.contract.bgSurface}; box-shadow: inset 0 0 0 1px ${tokens_1.contract.borderSubtle}, 0 1px 2px rgba(0,0,0,0.04); cursor: grab; }
|
|
24
24
|
& .board-card:active { cursor: grabbing; }
|
|
25
|
-
& .board-card[data-dragging] { opacity: 0.4; }
|
|
25
|
+
& .board-card[data-dragging] { opacity: 0.4; }
|
|
26
|
+
& .board-card:focus { outline: none; }
|
|
27
|
+
& .board-card:focus-visible { outline: 2px solid ${tokens_1.contract.bgBrandSolid}; outline-offset: 1px; }
|
|
28
|
+
/* announced, never drawn */
|
|
29
|
+
& .board-live {
|
|
30
|
+
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
|
31
|
+
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
|
|
32
|
+
}`,
|
|
26
33
|
variants: {},
|
|
27
34
|
defaultVariants: {},
|
|
28
35
|
compoundVariants: [],
|
|
@@ -45,7 +45,7 @@ const exec = (cmd, value) => document.execCommand(cmd, false, value);
|
|
|
45
45
|
* that appears over a text selection. Built on contentEditable, dependency-free.
|
|
46
46
|
* Uncontrolled: seed with `defaultTitle`/`defaultHtml`, read via `onChange`.
|
|
47
47
|
*/
|
|
48
|
-
function RichTextEditor({ defaultTitle = "", defaultHtml = "", titlePlaceholder = "Untitled", placeholder = "Start writing, or press a toolbar button…", onChange, className, titleLabel = "Title", bodyLabel = "Note body", }) {
|
|
48
|
+
function RichTextEditor({ defaultTitle = "", defaultHtml = "", titlePlaceholder = "Untitled", placeholder = "Start writing, or press a toolbar button…", onChange, className, titleLabel = "Title", bodyLabel = "Note body", linkPromptLabel = "Link URL", }) {
|
|
49
49
|
const titleRef = React.useRef(null);
|
|
50
50
|
const bodyRef = React.useRef(null);
|
|
51
51
|
const [bar, setBar] = React.useState(null);
|
|
@@ -128,13 +128,19 @@ function RichTextEditor({ defaultTitle = "", defaultHtml = "", titlePlaceholder
|
|
|
128
128
|
run("formatBlock", blockTag() === tag ? "p" : tag);
|
|
129
129
|
};
|
|
130
130
|
const addLink = () => {
|
|
131
|
-
const url = window.prompt(
|
|
131
|
+
const url = window.prompt(linkPromptLabel);
|
|
132
132
|
if (url)
|
|
133
133
|
run("createLink", url);
|
|
134
134
|
};
|
|
135
135
|
// mousedown on the toolbar must not clear the selection
|
|
136
136
|
const keepSel = (e) => e.preventDefault();
|
|
137
|
-
|
|
137
|
+
/**
|
|
138
|
+
* `toggles` is false for a tool that performs an action rather than carrying
|
|
139
|
+
* a state. Link opens a prompt and has no "on" — giving it `aria-pressed`
|
|
140
|
+
* announces an un-pressed toggle that can never be pressed, which tells a
|
|
141
|
+
* screen-reader user there is a state here that does not exist.
|
|
142
|
+
*/
|
|
143
|
+
const tool = (key, label, icon, onClick, toggles = true) => ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "rte-tool", "aria-label": label, "aria-pressed": toggles ? !!active[key] : undefined, "data-active": active[key] ? "" : undefined, onMouseDown: keepSel, onClick: onClick, children: icon }));
|
|
138
144
|
const ic = { size: 15, strokeWidth: 2 };
|
|
139
145
|
return ((0, jsx_runtime_1.jsxs)("div", { className: (0, richtext_recipe_1.richtextRecipe)({ className }), children: [(0, jsx_runtime_1.jsx)("div", { ref: titleRef, className: "rte-title", contentEditable: true, suppressContentEditableWarning: true, role: "textbox", tabIndex: 0, "aria-label": titleLabel, "data-placeholder": titlePlaceholder, onInput: () => {
|
|
140
146
|
syncEmpty(titleRef.current);
|
|
@@ -142,6 +148,6 @@ function RichTextEditor({ defaultTitle = "", defaultHtml = "", titlePlaceholder
|
|
|
142
148
|
} }), (0, jsx_runtime_1.jsx)("div", { ref: bodyRef, className: "rte-body", contentEditable: true, suppressContentEditableWarning: true, role: "textbox", tabIndex: 0, "aria-multiline": "true", "aria-label": bodyLabel, "data-placeholder": placeholder, onInput: () => {
|
|
143
149
|
syncEmpty(bodyRef.current);
|
|
144
150
|
emit();
|
|
145
|
-
}, onMouseUp: updateBar, onKeyUp: updateBar }), bar && ((0, jsx_runtime_1.jsxs)("div", { className: "rte-toolbar", style: { left: bar.x, top: bar.y }, children: [tool("bold", "Bold", (0, jsx_runtime_1.jsx)(lucide_react_1.Bold, { ...ic }), () => run("bold")), tool("italic", "Italic", (0, jsx_runtime_1.jsx)(lucide_react_1.Italic, { ...ic }), () => run("italic")), tool("underline", "Underline", (0, jsx_runtime_1.jsx)(lucide_react_1.Underline, { ...ic }), () => run("underline")), tool("strikeThrough", "Strikethrough", (0, jsx_runtime_1.jsx)(lucide_react_1.Strikethrough, { ...ic }), () => run("strikeThrough")), tool("link", "Link", (0, jsx_runtime_1.jsx)(lucide_react_1.Link2, { ...ic }), addLink), (0, jsx_runtime_1.jsx)("span", { className: "rte-tool-sep" }), tool("h1", "Heading 1", (0, jsx_runtime_1.jsx)(lucide_react_1.Heading1, { ...ic }), () => toggleBlock("h1")), tool("h2", "Heading 2", (0, jsx_runtime_1.jsx)(lucide_react_1.Heading2, { ...ic }), () => toggleBlock("h2")), tool("ul", "Bullet list", (0, jsx_runtime_1.jsx)(lucide_react_1.List, { ...ic }), () => run("insertUnorderedList")), tool("ol", "Numbered list", (0, jsx_runtime_1.jsx)(lucide_react_1.ListOrdered, { ...ic }), () => run("insertOrderedList")), tool("blockquote", "Quote", (0, jsx_runtime_1.jsx)(lucide_react_1.Quote, { ...ic }), () => toggleBlock("blockquote"))] }))] }));
|
|
151
|
+
}, onMouseUp: updateBar, onKeyUp: updateBar }), bar && ((0, jsx_runtime_1.jsxs)("div", { className: "rte-toolbar", style: { left: bar.x, top: bar.y }, children: [tool("bold", "Bold", (0, jsx_runtime_1.jsx)(lucide_react_1.Bold, { ...ic }), () => run("bold")), tool("italic", "Italic", (0, jsx_runtime_1.jsx)(lucide_react_1.Italic, { ...ic }), () => run("italic")), tool("underline", "Underline", (0, jsx_runtime_1.jsx)(lucide_react_1.Underline, { ...ic }), () => run("underline")), tool("strikeThrough", "Strikethrough", (0, jsx_runtime_1.jsx)(lucide_react_1.Strikethrough, { ...ic }), () => run("strikeThrough")), tool("link", "Link", (0, jsx_runtime_1.jsx)(lucide_react_1.Link2, { ...ic }), addLink, false), (0, jsx_runtime_1.jsx)("span", { className: "rte-tool-sep" }), tool("h1", "Heading 1", (0, jsx_runtime_1.jsx)(lucide_react_1.Heading1, { ...ic }), () => toggleBlock("h1")), tool("h2", "Heading 2", (0, jsx_runtime_1.jsx)(lucide_react_1.Heading2, { ...ic }), () => toggleBlock("h2")), tool("ul", "Bullet list", (0, jsx_runtime_1.jsx)(lucide_react_1.List, { ...ic }), () => run("insertUnorderedList")), tool("ol", "Numbered list", (0, jsx_runtime_1.jsx)(lucide_react_1.ListOrdered, { ...ic }), () => run("insertOrderedList")), tool("blockquote", "Quote", (0, jsx_runtime_1.jsx)(lucide_react_1.Quote, { ...ic }), () => toggleBlock("blockquote"))] }))] }));
|
|
146
152
|
}
|
|
147
153
|
RichTextEditor.displayName = "RichTextEditor";
|
|
@@ -7,36 +7,81 @@ import { boardRecipe } from "./board.recipe.js";
|
|
|
7
7
|
* (the parent owns the cards, so the move is a controlled state update).
|
|
8
8
|
* Measured from Attio's Deals pipeline.
|
|
9
9
|
*/
|
|
10
|
-
export function Board({ columns, cards, onCardsChange, renderCard, onNewCard, newCardLabel = "New", className, ...rest }) {
|
|
10
|
+
export function Board({ columns, cards, onCardsChange, renderCard, onNewCard, newCardLabel = "New", getCardLabel, announceMove = (column, card) => card ? `${card} moved to ${column}` : `Moved to ${column}`, className, ...rest }) {
|
|
11
11
|
const [dragId, setDragId] = React.useState(null);
|
|
12
12
|
const [overCol, setOverCol] = React.useState(null);
|
|
13
|
+
const [announcement, setAnnouncement] = React.useState("");
|
|
14
|
+
// the card a keyboard move just sent to another column; React unmounts and
|
|
15
|
+
// remounts it under the new parent, so focus has to be put back by hand
|
|
16
|
+
const [refocusId, setRefocusId] = React.useState(null);
|
|
17
|
+
const rootRef = React.useRef(null);
|
|
13
18
|
const move = (cardId, toColumn) => {
|
|
14
19
|
const card = cards.find((c) => c.id === cardId);
|
|
15
20
|
if (!card || card.columnId === toColumn)
|
|
16
21
|
return;
|
|
17
22
|
onCardsChange?.(cards.map((c) => (c.id === cardId ? { ...c, columnId: toColumn } : c)));
|
|
18
23
|
};
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
return
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
24
|
+
React.useEffect(() => {
|
|
25
|
+
if (!refocusId)
|
|
26
|
+
return;
|
|
27
|
+
rootRef.current
|
|
28
|
+
?.querySelector(`[data-card-id="${CSS.escape(refocusId)}"]`)
|
|
29
|
+
?.focus();
|
|
30
|
+
setRefocusId(null);
|
|
31
|
+
}, [refocusId]);
|
|
32
|
+
/**
|
|
33
|
+
* Native HTML5 drag-and-drop is pointer-only by specification, so the board's
|
|
34
|
+
* primary action needs its own key model or it has none at all.
|
|
35
|
+
*
|
|
36
|
+
* Ctrl/Cmd + Left/Right rather than plain arrows: a screen reader owns the
|
|
37
|
+
* bare arrow keys in browse mode, and a board scrolls horizontally, so plain
|
|
38
|
+
* arrows on a focused card would fight both.
|
|
39
|
+
*/
|
|
40
|
+
const moveByKey = (card, delta) => {
|
|
41
|
+
const from = columns.findIndex((c) => c.id === card.columnId);
|
|
42
|
+
const to = columns[from + delta];
|
|
43
|
+
if (from < 0 || !to)
|
|
44
|
+
return false;
|
|
45
|
+
move(card.id, to.id);
|
|
46
|
+
setAnnouncement(announceMove(to.title, getCardLabel?.(card)));
|
|
47
|
+
setRefocusId(card.id);
|
|
48
|
+
return true;
|
|
49
|
+
};
|
|
50
|
+
return (_jsxs("div", { ref: rootRef, className: boardRecipe({ className }), ...rest, children: [columns.map((col) => {
|
|
51
|
+
const colCards = cards.filter((c) => c.columnId === col.id);
|
|
52
|
+
return (_jsxs("div", { className: "board-col", "data-over": overCol === col.id ? "" : undefined, onDragOver: (e) => {
|
|
53
|
+
if (!dragId)
|
|
54
|
+
return;
|
|
55
|
+
e.preventDefault();
|
|
56
|
+
setOverCol(col.id);
|
|
57
|
+
}, onDragLeave: (e) => {
|
|
58
|
+
if (!e.currentTarget.contains(e.relatedTarget)) {
|
|
59
|
+
setOverCol((v) => (v === col.id ? null : v));
|
|
60
|
+
}
|
|
61
|
+
}, onDrop: (e) => {
|
|
62
|
+
e.preventDefault();
|
|
63
|
+
if (dragId)
|
|
64
|
+
move(dragId, col.id);
|
|
65
|
+
setDragId(null);
|
|
66
|
+
setOverCol(null);
|
|
67
|
+
}, children: [_jsxs("div", { className: "board-col-header", children: [col.accent && (_jsx("span", { className: "board-col-dot", style: { background: col.accent } })), _jsx("span", { className: "board-col-title", children: col.title }), _jsx("span", { className: "board-col-count", children: colCards.length }), onNewCard && (_jsx("button", { type: "button", className: "board-col-add", "aria-label": `Add to ${col.title}`, onClick: () => onNewCard(col.id), children: "+" }))] }), _jsxs("div", { className: "board-col-body", role: "list", "aria-label": col.title, children: [onNewCard && (_jsxs("button", { type: "button", className: "board-newcard", onClick: () => onNewCard(col.id), children: ["+ ", newCardLabel] })), colCards.map((card) => (_jsx("div", { className: "board-card", role: "listitem",
|
|
68
|
+
// biome-ignore lint/a11y/noNoninteractiveTabindex: the rule assumes a listitem cannot be operated. This one carries the board's only keyboard path to move a card, so it has to be reachable by Tab
|
|
69
|
+
tabIndex: 0, "aria-label": getCardLabel?.(card), "data-card-id": card.id, draggable: true, "data-dragging": dragId === card.id ? "" : undefined, onDragStart: () => setDragId(card.id), onDragEnd: () => {
|
|
70
|
+
setDragId(null);
|
|
71
|
+
setOverCol(null);
|
|
72
|
+
}, onKeyDown: (e) => {
|
|
73
|
+
if (!(e.ctrlKey || e.metaKey))
|
|
74
|
+
return;
|
|
75
|
+
const delta = e.key === "ArrowLeft"
|
|
76
|
+
? -1
|
|
77
|
+
: e.key === "ArrowRight"
|
|
78
|
+
? 1
|
|
79
|
+
: 0;
|
|
80
|
+
// only swallow the key when the move actually happens, so
|
|
81
|
+
// Ctrl+Right at the last column still reaches the browser
|
|
82
|
+
if (delta && moveByKey(card, delta))
|
|
83
|
+
e.preventDefault();
|
|
84
|
+
}, children: renderCard(card) }, card.id)))] })] }, col.id));
|
|
85
|
+
}), _jsx("div", { className: "board-live", role: "status", "aria-live": "polite", children: announcement })] }));
|
|
41
86
|
}
|
|
42
87
|
Board.displayName = "Board";
|
|
@@ -19,7 +19,14 @@ const config = defineRecipe({
|
|
|
19
19
|
& .board-newcard:hover { background: color-mix(in srgb, ${c.fgPrimary} 7%, transparent); color: ${c.fgPrimary}; }
|
|
20
20
|
& .board-card { display: flex; flex-direction: column; gap: ${c.space["2"]}; box-sizing: border-box; padding: ${c.space["2.5"]} ${c.space["3"]}; border-radius: ${c.radius.lg}; background: ${c.bgSurface}; box-shadow: inset 0 0 0 1px ${c.borderSubtle}, 0 1px 2px rgba(0,0,0,0.04); cursor: grab; }
|
|
21
21
|
& .board-card:active { cursor: grabbing; }
|
|
22
|
-
& .board-card[data-dragging] { opacity: 0.4; }
|
|
22
|
+
& .board-card[data-dragging] { opacity: 0.4; }
|
|
23
|
+
& .board-card:focus { outline: none; }
|
|
24
|
+
& .board-card:focus-visible { outline: 2px solid ${c.bgBrandSolid}; outline-offset: 1px; }
|
|
25
|
+
/* announced, never drawn */
|
|
26
|
+
& .board-live {
|
|
27
|
+
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
|
28
|
+
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
|
|
29
|
+
}`,
|
|
23
30
|
variants: {},
|
|
24
31
|
defaultVariants: {},
|
|
25
32
|
compoundVariants: [],
|
|
@@ -9,7 +9,7 @@ const exec = (cmd, value) => document.execCommand(cmd, false, value);
|
|
|
9
9
|
* that appears over a text selection. Built on contentEditable, dependency-free.
|
|
10
10
|
* Uncontrolled: seed with `defaultTitle`/`defaultHtml`, read via `onChange`.
|
|
11
11
|
*/
|
|
12
|
-
export function RichTextEditor({ defaultTitle = "", defaultHtml = "", titlePlaceholder = "Untitled", placeholder = "Start writing, or press a toolbar button…", onChange, className, titleLabel = "Title", bodyLabel = "Note body", }) {
|
|
12
|
+
export function RichTextEditor({ defaultTitle = "", defaultHtml = "", titlePlaceholder = "Untitled", placeholder = "Start writing, or press a toolbar button…", onChange, className, titleLabel = "Title", bodyLabel = "Note body", linkPromptLabel = "Link URL", }) {
|
|
13
13
|
const titleRef = React.useRef(null);
|
|
14
14
|
const bodyRef = React.useRef(null);
|
|
15
15
|
const [bar, setBar] = React.useState(null);
|
|
@@ -92,13 +92,19 @@ export function RichTextEditor({ defaultTitle = "", defaultHtml = "", titlePlace
|
|
|
92
92
|
run("formatBlock", blockTag() === tag ? "p" : tag);
|
|
93
93
|
};
|
|
94
94
|
const addLink = () => {
|
|
95
|
-
const url = window.prompt(
|
|
95
|
+
const url = window.prompt(linkPromptLabel);
|
|
96
96
|
if (url)
|
|
97
97
|
run("createLink", url);
|
|
98
98
|
};
|
|
99
99
|
// mousedown on the toolbar must not clear the selection
|
|
100
100
|
const keepSel = (e) => e.preventDefault();
|
|
101
|
-
|
|
101
|
+
/**
|
|
102
|
+
* `toggles` is false for a tool that performs an action rather than carrying
|
|
103
|
+
* a state. Link opens a prompt and has no "on" — giving it `aria-pressed`
|
|
104
|
+
* announces an un-pressed toggle that can never be pressed, which tells a
|
|
105
|
+
* screen-reader user there is a state here that does not exist.
|
|
106
|
+
*/
|
|
107
|
+
const tool = (key, label, icon, onClick, toggles = true) => (_jsx("button", { type: "button", className: "rte-tool", "aria-label": label, "aria-pressed": toggles ? !!active[key] : undefined, "data-active": active[key] ? "" : undefined, onMouseDown: keepSel, onClick: onClick, children: icon }));
|
|
102
108
|
const ic = { size: 15, strokeWidth: 2 };
|
|
103
109
|
return (_jsxs("div", { className: richtextRecipe({ className }), children: [_jsx("div", { ref: titleRef, className: "rte-title", contentEditable: true, suppressContentEditableWarning: true, role: "textbox", tabIndex: 0, "aria-label": titleLabel, "data-placeholder": titlePlaceholder, onInput: () => {
|
|
104
110
|
syncEmpty(titleRef.current);
|
|
@@ -106,6 +112,6 @@ export function RichTextEditor({ defaultTitle = "", defaultHtml = "", titlePlace
|
|
|
106
112
|
} }), _jsx("div", { ref: bodyRef, className: "rte-body", contentEditable: true, suppressContentEditableWarning: true, role: "textbox", tabIndex: 0, "aria-multiline": "true", "aria-label": bodyLabel, "data-placeholder": placeholder, onInput: () => {
|
|
107
113
|
syncEmpty(bodyRef.current);
|
|
108
114
|
emit();
|
|
109
|
-
}, onMouseUp: updateBar, onKeyUp: updateBar }), bar && (_jsxs("div", { className: "rte-toolbar", style: { left: bar.x, top: bar.y }, children: [tool("bold", "Bold", _jsx(Bold, { ...ic }), () => run("bold")), tool("italic", "Italic", _jsx(Italic, { ...ic }), () => run("italic")), tool("underline", "Underline", _jsx(Underline, { ...ic }), () => run("underline")), tool("strikeThrough", "Strikethrough", _jsx(Strikethrough, { ...ic }), () => run("strikeThrough")), tool("link", "Link", _jsx(Link2, { ...ic }), addLink), _jsx("span", { className: "rte-tool-sep" }), tool("h1", "Heading 1", _jsx(Heading1, { ...ic }), () => toggleBlock("h1")), tool("h2", "Heading 2", _jsx(Heading2, { ...ic }), () => toggleBlock("h2")), tool("ul", "Bullet list", _jsx(List, { ...ic }), () => run("insertUnorderedList")), tool("ol", "Numbered list", _jsx(ListOrdered, { ...ic }), () => run("insertOrderedList")), tool("blockquote", "Quote", _jsx(Quote, { ...ic }), () => toggleBlock("blockquote"))] }))] }));
|
|
115
|
+
}, onMouseUp: updateBar, onKeyUp: updateBar }), bar && (_jsxs("div", { className: "rte-toolbar", style: { left: bar.x, top: bar.y }, children: [tool("bold", "Bold", _jsx(Bold, { ...ic }), () => run("bold")), tool("italic", "Italic", _jsx(Italic, { ...ic }), () => run("italic")), tool("underline", "Underline", _jsx(Underline, { ...ic }), () => run("underline")), tool("strikeThrough", "Strikethrough", _jsx(Strikethrough, { ...ic }), () => run("strikeThrough")), tool("link", "Link", _jsx(Link2, { ...ic }), addLink, false), _jsx("span", { className: "rte-tool-sep" }), tool("h1", "Heading 1", _jsx(Heading1, { ...ic }), () => toggleBlock("h1")), tool("h2", "Heading 2", _jsx(Heading2, { ...ic }), () => toggleBlock("h2")), tool("ul", "Bullet list", _jsx(List, { ...ic }), () => run("insertUnorderedList")), tool("ol", "Numbered list", _jsx(ListOrdered, { ...ic }), () => run("insertOrderedList")), tool("blockquote", "Quote", _jsx(Quote, { ...ic }), () => toggleBlock("blockquote"))] }))] }));
|
|
110
116
|
}
|
|
111
117
|
RichTextEditor.displayName = "RichTextEditor";
|
package/dist/styles.css
CHANGED
|
@@ -1950,6 +1950,13 @@
|
|
|
1950
1950
|
& .board-card { display: flex; flex-direction: column; gap: var(--space-2); box-sizing: border-box; padding: var(--space-2-5) var(--space-3); border-radius: var(--radius-lg); background: var(--bg-surface); box-shadow: inset 0 0 0 1px var(--border-subtle), 0 1px 2px rgba(0,0,0,0.04); cursor: grab; }
|
|
1951
1951
|
& .board-card:active { cursor: grabbing; }
|
|
1952
1952
|
& .board-card[data-dragging] { opacity: 0.4; }
|
|
1953
|
+
& .board-card:focus { outline: none; }
|
|
1954
|
+
& .board-card:focus-visible { outline: 2px solid var(--bg-brand-solid); outline-offset: 1px; }
|
|
1955
|
+
/* announced, never drawn */
|
|
1956
|
+
& .board-live {
|
|
1957
|
+
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
|
1958
|
+
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
|
|
1959
|
+
}
|
|
1953
1960
|
}
|
|
1954
1961
|
|
|
1955
1962
|
@media (prefers-reduced-motion: reduce) {
|
|
@@ -18,6 +18,14 @@ export type BoardProps<T extends BoardCard> = Omit<React.ComponentPropsWithoutRe
|
|
|
18
18
|
/** When set, each column shows a "+ {newCardLabel}" button and its header +. */
|
|
19
19
|
onNewCard?: (columnId: string) => void;
|
|
20
20
|
newCardLabel?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Accessible name for a card. Also used in the move announcement. Without it
|
|
23
|
+
* a card is announced by whatever `renderCard` puts in it, which is usually
|
|
24
|
+
* right but is not something the component can guarantee.
|
|
25
|
+
*/
|
|
26
|
+
getCardLabel?: (card: T) => string;
|
|
27
|
+
/** What a screen reader hears after a keyboard move. */
|
|
28
|
+
announceMove?: (columnTitle: string, cardLabel?: string) => string;
|
|
21
29
|
className?: string;
|
|
22
30
|
};
|
|
23
31
|
/**
|
|
@@ -26,7 +34,7 @@ export type BoardProps<T extends BoardCard> = Omit<React.ComponentPropsWithoutRe
|
|
|
26
34
|
* (the parent owns the cards, so the move is a controlled state update).
|
|
27
35
|
* Measured from Attio's Deals pipeline.
|
|
28
36
|
*/
|
|
29
|
-
export declare function Board<T extends BoardCard>({ columns, cards, onCardsChange, renderCard, onNewCard, newCardLabel, className, ...rest }: BoardProps<T>): React.JSX.Element;
|
|
37
|
+
export declare function Board<T extends BoardCard>({ columns, cards, onCardsChange, renderCard, onNewCard, newCardLabel, getCardLabel, announceMove, className, ...rest }: BoardProps<T>): React.JSX.Element;
|
|
30
38
|
export declare namespace Board {
|
|
31
39
|
var displayName: string;
|
|
32
40
|
}
|
|
@@ -8,6 +8,8 @@ export type RichTextEditorProps = {
|
|
|
8
8
|
titleLabel?: string;
|
|
9
9
|
/** Accessible name for the body editor. */
|
|
10
10
|
bodyLabel?: string;
|
|
11
|
+
/** Message shown when asking for a link URL. */
|
|
12
|
+
linkPromptLabel?: string;
|
|
11
13
|
defaultTitle?: string;
|
|
12
14
|
/** Initial body HTML. */
|
|
13
15
|
defaultHtml?: string;
|
|
@@ -22,7 +24,7 @@ export type RichTextEditorProps = {
|
|
|
22
24
|
* that appears over a text selection. Built on contentEditable, dependency-free.
|
|
23
25
|
* Uncontrolled: seed with `defaultTitle`/`defaultHtml`, read via `onChange`.
|
|
24
26
|
*/
|
|
25
|
-
export declare function RichTextEditor({ defaultTitle, defaultHtml, titlePlaceholder, placeholder, onChange, className, titleLabel, bodyLabel, }: RichTextEditorProps): React.JSX.Element;
|
|
27
|
+
export declare function RichTextEditor({ defaultTitle, defaultHtml, titlePlaceholder, placeholder, onChange, className, titleLabel, bodyLabel, linkPromptLabel, }: RichTextEditorProps): React.JSX.Element;
|
|
26
28
|
export declare namespace RichTextEditor {
|
|
27
29
|
var displayName: string;
|
|
28
30
|
}
|