@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 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
- return ((0, jsx_runtime_1.jsx)("div", { className: (0, board_recipe_1.boardRecipe)({ className }), ...rest, children: columns.map((col) => {
56
- const colCards = cards.filter((c) => c.columnId === col.id);
57
- return ((0, jsx_runtime_1.jsxs)("div", { className: "board-col", "data-over": overCol === col.id ? "" : undefined, onDragOver: (e) => {
58
- if (!dragId)
59
- return;
60
- e.preventDefault();
61
- setOverCol(col.id);
62
- }, onDragLeave: (e) => {
63
- if (!e.currentTarget.contains(e.relatedTarget)) {
64
- setOverCol((v) => (v === col.id ? null : v));
65
- }
66
- }, onDrop: (e) => {
67
- e.preventDefault();
68
- if (dragId)
69
- move(dragId, col.id);
70
- setDragId(null);
71
- setOverCol(null);
72
- }, 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", 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", draggable: true, "data-dragging": dragId === card.id ? "" : undefined, onDragStart: () => setDragId(card.id), onDragEnd: () => {
73
- setDragId(null);
74
- setOverCol(null);
75
- }, children: renderCard(card) }, card.id)))] })] }, col.id));
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("Link URL");
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
- const tool = (key, label, icon, onClick) => ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "rte-tool", "aria-label": label, "aria-pressed": !!active[key], "data-active": active[key] ? "" : undefined, onMouseDown: keepSel, onClick: onClick, children: icon }));
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
- return (_jsx("div", { className: boardRecipe({ className }), ...rest, children: columns.map((col) => {
20
- const colCards = cards.filter((c) => c.columnId === col.id);
21
- return (_jsxs("div", { className: "board-col", "data-over": overCol === col.id ? "" : undefined, onDragOver: (e) => {
22
- if (!dragId)
23
- return;
24
- e.preventDefault();
25
- setOverCol(col.id);
26
- }, onDragLeave: (e) => {
27
- if (!e.currentTarget.contains(e.relatedTarget)) {
28
- setOverCol((v) => (v === col.id ? null : v));
29
- }
30
- }, onDrop: (e) => {
31
- e.preventDefault();
32
- if (dragId)
33
- move(dragId, col.id);
34
- setDragId(null);
35
- setOverCol(null);
36
- }, 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", children: [onNewCard && (_jsxs("button", { type: "button", className: "board-newcard", onClick: () => onNewCard(col.id), children: ["+ ", newCardLabel] })), colCards.map((card) => (_jsx("div", { className: "board-card", draggable: true, "data-dragging": dragId === card.id ? "" : undefined, onDragStart: () => setDragId(card.id), onDragEnd: () => {
37
- setDragId(null);
38
- setOverCol(null);
39
- }, children: renderCard(card) }, card.id)))] })] }, col.id));
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("Link URL");
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
- const tool = (key, label, icon, onClick) => (_jsx("button", { type: "button", className: "rte-tool", "aria-label": label, "aria-pressed": !!active[key], "data-active": active[key] ? "" : undefined, onMouseDown: keepSel, onClick: onClick, children: icon }));
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crisp-ui-kit/crisp",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "A lean React design system, measured pixel-for-pixel from Attio.",
5
5
  "license": "MIT",
6
6
  "type": "module",