@crisp-ui-kit/crisp 0.12.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: [],
@@ -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: [],
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crisp-ui-kit/crisp",
3
- "version": "0.12.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",