@crisp-ui-kit/crisp 0.35.0 → 0.36.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/bulkbar.css CHANGED
@@ -5,6 +5,9 @@
5
5
  & .crisp-bulkbar-count { display: inline-flex; align-items: center; gap: var(--crisp-space-1); height: var(--crisp-size-menuRow); margin: 0; padding: 0 var(--crisp-space-2); border: none; border-radius: var(--crisp-radius-md); background: color-mix(in srgb, var(--crisp-fg-primary) 4%, transparent); font-family: inherit; font-size: var(--crisp-text-md); font-weight: var(--crisp-weight-medium); color: var(--crisp-fg-muted); white-space: nowrap; cursor: pointer; transition: background-color .12s; }
6
6
  & .crisp-bulkbar-count:hover { background: color-mix(in srgb, var(--crisp-fg-primary) 8%, transparent); color: var(--crisp-fg-primary); }
7
7
  & .crisp-bulkbar-count svg { flex-shrink: 0; }
8
+ /* the ", Clear selection" ARIA concatenates onto the pill's name (#324)
9
+ -- read, never drawn. Same technique as Slider's thumblabel. */
10
+ & .crisp-bulkbar-clearlabel { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
8
11
  & .crisp-bulkbar-btn { display: inline-flex; align-items: center; gap: var(--crisp-space-1); height: var(--crisp-size-menuRow); padding: 0 var(--crisp-space-2); border: none; border-radius: var(--crisp-radius-md); background: transparent; color: var(--crisp-fg-primary); font-family: inherit; font-size: var(--crisp-text-md); font-weight: var(--crisp-weight-medium); white-space: nowrap; cursor: pointer; transition: background-color .12s; }
9
12
  & .crisp-bulkbar-btn:hover { background: var(--crisp-bg-raised); }
10
13
  & .crisp-bulkbar-btn--danger { color: var(--crisp-bg-danger); }
@@ -6,6 +6,9 @@
6
6
  & .crisp-bulkbar-count { display: inline-flex; align-items: center; gap: var(--crisp-space-1); height: var(--crisp-size-menuRow); margin: 0; padding: 0 var(--crisp-space-2); border: none; border-radius: var(--crisp-radius-md); background: color-mix(in srgb, var(--crisp-fg-primary) 4%, transparent); font-family: inherit; font-size: var(--crisp-text-md); font-weight: var(--crisp-weight-medium); color: var(--crisp-fg-muted); white-space: nowrap; cursor: pointer; transition: background-color .12s; }
7
7
  & .crisp-bulkbar-count:hover { background: color-mix(in srgb, var(--crisp-fg-primary) 8%, transparent); color: var(--crisp-fg-primary); }
8
8
  & .crisp-bulkbar-count svg { flex-shrink: 0; }
9
+ /* the ", Clear selection" ARIA concatenates onto the pill's name (#324)
10
+ -- read, never drawn. Same technique as Slider's thumblabel. */
11
+ & .crisp-bulkbar-clearlabel { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
9
12
  & .crisp-bulkbar-btn { display: inline-flex; align-items: center; gap: var(--crisp-space-1); height: var(--crisp-size-menuRow); padding: 0 var(--crisp-space-2); border: none; border-radius: var(--crisp-radius-md); background: transparent; color: var(--crisp-fg-primary); font-family: inherit; font-size: var(--crisp-text-md); font-weight: var(--crisp-weight-medium); white-space: nowrap; cursor: pointer; transition: background-color .12s; }
10
13
  & .crisp-bulkbar-btn:hover { background: var(--crisp-bg-raised); }
11
14
  & .crisp-bulkbar-btn--danger { color: var(--crisp-bg-danger); }
@@ -45,7 +45,7 @@ const bulkbar_recipe_1 = require("./bulkbar.recipe");
45
45
  * (e.g. `count > 0 && <BulkBar …/>`). Measured from a records table.
46
46
  */
47
47
  function BulkBar({ count, onClear, countLabel = "selected", countText = (count, label) => ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [count, " ", label] })), children, className, clearLabel = "Clear selection", ...rest }) {
48
- return ((0, jsx_runtime_1.jsx)("div", { className: (0, bulkbar_recipe_1.bulkbarRecipe)({ className }), ...rest, children: (0, jsx_runtime_1.jsxs)("div", { className: "crisp-bulkbar-bar", children: [count != null && ((0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-bulkbar-count", onClick: onClear, title: clearLabel, children: [countText(count, countLabel), (0, jsx_runtime_1.jsx)(lucide_react_1.X, { size: 13, strokeWidth: 2, "aria-hidden": "true" })] })), children] }) }));
48
+ return ((0, jsx_runtime_1.jsx)("div", { className: (0, bulkbar_recipe_1.bulkbarRecipe)({ className }), ...rest, children: (0, jsx_runtime_1.jsxs)("div", { className: "crisp-bulkbar-bar", children: [count != null && ((0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-bulkbar-count", onClick: onClear, title: clearLabel, children: [countText(count, countLabel), (0, jsx_runtime_1.jsx)("span", { className: "crisp-bulkbar-clearlabel", children: clearLabel }), (0, jsx_runtime_1.jsx)(lucide_react_1.X, { size: 13, strokeWidth: 2, "aria-hidden": "true" })] })), children] }) }));
49
49
  }
50
50
  BulkBar.displayName = "BulkBar";
51
51
  /** A styled action button for use inside a `<BulkBar>` (or as a `<Menu>` trigger). */
@@ -18,6 +18,12 @@ const config = (0, core_1.defineRecipe)({
18
18
  & .crisp-bulkbar-count { display: inline-flex; align-items: center; gap: ${tokens_1.contract.space["1"]}; height: ${tokens_1.contract.size.menuRow}; margin: 0; padding: 0 ${tokens_1.contract.space["2"]}; border: none; border-radius: ${tokens_1.contract.radius.md}; background: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 4%, transparent); font-family: inherit; font-size: ${tokens_1.contract.text.md}; font-weight: ${tokens_1.contract.weight.medium}; color: ${tokens_1.contract.fgMuted}; white-space: nowrap; cursor: pointer; transition: background-color .12s; }
19
19
  & .crisp-bulkbar-count:hover { background: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 8%, transparent); color: ${tokens_1.contract.fgPrimary}; }
20
20
  & .crisp-bulkbar-count svg { flex-shrink: 0; }
21
+ /* the ", Clear selection" ARIA concatenates onto the pill's name (#324)
22
+ -- read, never drawn. Same technique as Slider's thumblabel. */
23
+ & .crisp-bulkbar-clearlabel {
24
+ position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
25
+ overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
26
+ }
21
27
  & .crisp-bulkbar-btn { display: inline-flex; align-items: center; gap: ${tokens_1.contract.space["1"]}; height: ${tokens_1.contract.size.menuRow}; padding: 0 ${tokens_1.contract.space["2"]}; border: none; border-radius: ${tokens_1.contract.radius.md}; background: transparent; color: ${tokens_1.contract.fgPrimary}; font-family: inherit; font-size: ${tokens_1.contract.text.md}; font-weight: ${tokens_1.contract.weight.medium}; white-space: nowrap; cursor: pointer; transition: background-color .12s; }
22
28
  & .crisp-bulkbar-btn:hover { background: ${tokens_1.contract.bgRaised}; }
23
29
  & .crisp-bulkbar-btn--danger { color: ${tokens_1.contract.bgDanger}; }
@@ -62,7 +62,8 @@ function FieldValue({ col, row, emptyLabel, }) {
62
62
  * relative` container (e.g. the app main area); it slides in from the right and
63
63
  * overlays the content.
64
64
  */
65
- function RecordPanel({ record, columns, onClose, className, closeLabel = "Close panel", detailsLabel = "Details", emptyLabel = "Empty", ...rest }) {
65
+ function RecordPanel({ record, columns, onClose, className, closeLabel = "Close panel", title, detailsLabel = "Details", emptyLabel = "Empty", footer, ...rest }) {
66
+ const titleId = React.useId();
66
67
  // close on Escape while open
67
68
  React.useEffect(() => {
68
69
  if (!record)
@@ -78,6 +79,7 @@ function RecordPanel({ record, columns, onClose, className, closeLabel = "Close
78
79
  ? record[entityCol.key]
79
80
  : undefined;
80
81
  const fields = columns.filter((c) => c.type !== "entity");
81
- return ((0, jsx_runtime_1.jsxs)("div", { className: (0, recordpanel_recipe_1.recordpanelRecipe)({ className }), ...rest, role: "dialog", "aria-label": entity?.name ?? "Record", children: [(0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpanel-header", children: [entity?.avatar ? ((0, jsx_runtime_1.jsx)("img", { className: "crisp-recordpanel-fav", src: entity.avatar, alt: "" })) : entity ? ((0, jsx_runtime_1.jsx)(avatar_1.Avatar, { size: "md", shape: "rounded", tone: "brand", children: entity.name.charAt(0) })) : null, (0, jsx_runtime_1.jsx)("span", { className: "crisp-recordpanel-title", children: entity?.name ?? "Record" }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-recordpanel-iconbtn", "aria-label": closeLabel, onClick: onClose, children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { size: 18, strokeWidth: 1.75 }) })] }), (0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpanel-body", children: [(0, jsx_runtime_1.jsx)("div", { className: "crisp-recordpanel-sectionlabel", children: detailsLabel }), fields.map((col) => ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpanel-field", children: [(0, jsx_runtime_1.jsxs)("span", { className: "crisp-recordpanel-field-label", children: [col.icon ? (0, jsx_runtime_1.jsx)(col.icon, { size: 15, strokeWidth: 1.75 }) : null, col.label] }), (0, jsx_runtime_1.jsx)("span", { className: "crisp-recordpanel-field-value", children: (0, jsx_runtime_1.jsx)(FieldValue, { col: col, row: record, emptyLabel: emptyLabel }) })] }, col.key)))] })] }));
82
+ const heading = title ?? entity?.name ?? "Record";
83
+ return ((0, jsx_runtime_1.jsxs)("div", { className: (0, recordpanel_recipe_1.recordpanelRecipe)({ className }), ...rest, role: "dialog", "aria-labelledby": titleId, children: [(0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpanel-header", children: [entity?.avatar ? ((0, jsx_runtime_1.jsx)("img", { className: "crisp-recordpanel-fav", src: entity.avatar, alt: "" })) : entity ? ((0, jsx_runtime_1.jsx)(avatar_1.Avatar, { size: "md", shape: "rounded", tone: "brand", children: entity.name.charAt(0) })) : null, (0, jsx_runtime_1.jsx)("span", { id: titleId, className: "crisp-recordpanel-title", children: heading }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-recordpanel-iconbtn", "aria-label": closeLabel, onClick: onClose, children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { size: 18, strokeWidth: 1.75 }) })] }), (0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpanel-body", children: [(0, jsx_runtime_1.jsx)("div", { className: "crisp-recordpanel-sectionlabel", children: detailsLabel }), fields.map((col) => ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpanel-field", children: [(0, jsx_runtime_1.jsxs)("span", { className: "crisp-recordpanel-field-label", children: [col.icon ? (0, jsx_runtime_1.jsx)(col.icon, { size: 15, strokeWidth: 1.75 }) : null, col.label] }), (0, jsx_runtime_1.jsx)("span", { className: "crisp-recordpanel-field-value", children: (0, jsx_runtime_1.jsx)(FieldValue, { col: col, row: record, emptyLabel: emptyLabel }) })] }, col.key)))] }), footer != null && ((0, jsx_runtime_1.jsx)("div", { className: "crisp-recordpanel-footer", children: footer }))] }));
82
84
  }
83
85
  RecordPanel.displayName = "RecordPanel";
@@ -39,6 +39,14 @@ const config = (0, core_1.defineRecipe)({
39
39
  ${recordstable_tag_1.recordTagCss}
40
40
  & .crisp-recordpanel-link { color: ${tokens_1.contract.bgBrandSolid}; text-decoration: none; overflow: hidden; text-overflow: ellipsis; }
41
41
  & .crisp-recordpanel-link:hover { text-decoration: underline; }
42
+
43
+ /* #329: an optional slot for record actions (e.g. Edit). Reuses the
44
+ header's own measured values rather than inventing new ones -- the
45
+ same horizontal inset (space.3), the same hairline border token
46
+ (borderSubtle, flipped to border-top since this sits at the opposite
47
+ edge), and the header/body gap (space.2) for its own vertical padding
48
+ and for spacing between whatever it's given. */
49
+ & .crisp-recordpanel-footer { flex-shrink: 0; display: flex; align-items: center; gap: ${tokens_1.contract.space["2"]}; padding: ${tokens_1.contract.space["2"]} ${tokens_1.contract.space["3"]}; border-top: 1px solid ${tokens_1.contract.borderSubtle}; }
42
50
  `,
43
51
  variants: {},
44
52
  defaultVariants: {},
@@ -117,8 +117,14 @@ exports.ATTIO_SIDEBAR_FOOTER = {
117
117
  function SectionHeader({ label, open, onToggle, }) {
118
118
  return ((0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-sidebar-section-header", "data-collapsed": open ? undefined : "", "aria-expanded": open, onClick: onToggle, children: [(0, jsx_runtime_1.jsx)(IconChevronMini, {}), (0, jsx_runtime_1.jsx)("span", { children: label })] }));
119
119
  }
120
- exports.Sidebar = React.forwardRef(({ className, asChild, onToggleCollapse, collapseLabel = "Collapse sidebar", inboxLabel = "Inbox", searchLabel = "Search", workspace = exports.ATTIO_SIDEBAR_WORKSPACE, items = exports.ATTIO_SIDEBAR_ITEMS, recordsSection = exports.ATTIO_SIDEBAR_RECORDS_SECTION, listsSection = exports.ATTIO_SIDEBAR_LISTS_SECTION, chatsSection = exports.ATTIO_SIDEBAR_CHATS_SECTION, footer = exports.ATTIO_SIDEBAR_FOOTER, ...props }, ref) => {
121
- const [collapsedSections, setCollapsedSections] = React.useState(() => new Set());
120
+ exports.Sidebar = React.forwardRef(({ className, asChild, onToggleCollapse, collapseLabel = "Collapse sidebar", inboxLabel = "Inbox", searchLabel = "Search", workspace = exports.ATTIO_SIDEBAR_WORKSPACE, items = exports.ATTIO_SIDEBAR_ITEMS, recordsSection = exports.ATTIO_SIDEBAR_RECORDS_SECTION, listsSection = exports.ATTIO_SIDEBAR_LISTS_SECTION, chatsSection = exports.ATTIO_SIDEBAR_CHATS_SECTION, groups, footer = exports.ATTIO_SIDEBAR_FOOTER, ...props }, ref) => {
121
+ // Seeded once, on mount, from whatever groups start collapsed — not
122
+ // reactive to `groups` changing later, matching every other piece of
123
+ // this component's state (recordsSection/listsSection/chatsSection have
124
+ // never supported a starting-collapsed prop at all).
125
+ const [collapsedSections, setCollapsedSections] = React.useState(() => new Set(groups
126
+ ?.filter((g) => g.defaultCollapsed)
127
+ .map((g) => g.key ?? g.label)));
122
128
  const [commandOpen, setCommandOpen] = React.useState(false);
123
129
  React.useEffect(() => {
124
130
  function handleKeyDown(event) {
@@ -143,6 +149,13 @@ exports.Sidebar = React.forwardRef(({ className, asChild, onToggleCollapse, coll
143
149
  return next;
144
150
  });
145
151
  }, []);
146
- return ((0, jsx_runtime_1.jsxs)(core_1.Primitive.div, { ref: ref, asChild: asChild, className: (0, sidebar_recipe_1.sidebarRecipe)({ className }), ...props, children: [(0, jsx_runtime_1.jsxs)("div", { className: "crisp-sidebar-ws", children: [(0, jsx_runtime_1.jsx)(avatar_1.Avatar, { size: "lg", shape: "rounded", tone: workspace.avatarTone, children: workspace.avatarLabel }), (0, jsx_runtime_1.jsx)("span", { className: "crisp-sidebar-ws-name", children: workspace.name }), (0, jsx_runtime_1.jsx)(IconChevronDown, {}), (0, jsx_runtime_1.jsx)(button_1.Button, { intent: "ghost", size: "sm", shape: "icon", className: "crisp-sidebar-ws-collapse", "aria-label": collapseLabel, onClick: onToggleCollapse, children: (0, jsx_runtime_1.jsx)(IconCollapsePanel, {}) })] }), (0, jsx_runtime_1.jsxs)("div", { className: "crisp-sidebar-searchrow", children: [(0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-sidebar-search", onClick: () => setCommandOpen(true), children: [(0, jsx_runtime_1.jsx)(IconSearch, {}), (0, jsx_runtime_1.jsx)("span", { className: "crisp-sidebar-search-label", children: searchLabel }), (0, jsx_runtime_1.jsx)("span", { className: "crisp-sidebar-search-kbd", children: "\u2318K" })] }), (0, jsx_runtime_1.jsx)(button_1.Button, { intent: "neutral", size: "sm", shape: "icon", "aria-label": inboxLabel, children: (0, jsx_runtime_1.jsx)(IconInbox, {}) })] }), (0, jsx_runtime_1.jsx)("div", { className: "crisp-sidebar-divider" }), (0, jsx_runtime_1.jsx)("div", { className: "crisp-sidebar-section", children: items.map(({ label, icon: ItemIcon, active, trailing }) => ((0, jsx_runtime_1.jsx)(navitem_1.NavItem, { href: "#", active: active, icon: (0, jsx_runtime_1.jsx)(ItemIcon, {}), trailing: trailing, children: label }, label))) }), (0, jsx_runtime_1.jsx)(SectionHeader, { label: recordsSection.label, open: !collapsedSections.has("records"), onToggle: () => toggleSection("records") }), (0, jsx_runtime_1.jsx)("div", { className: "crisp-sidebar-section", "data-collapsed": collapsedSections.has("records") ? "" : undefined, children: recordsSection.items.map(({ label, tone, glyph: Glyph }) => ((0, jsx_runtime_1.jsx)(navitem_1.NavItem, { href: "#", icon: (0, jsx_runtime_1.jsx)(avatar_1.Avatar, { size: "sm", shape: "rounded", tone: tone, children: (0, jsx_runtime_1.jsx)(Glyph, {}) }), children: label }, label))) }), (0, jsx_runtime_1.jsx)(SectionHeader, { label: listsSection.label, open: !collapsedSections.has("lists"), onToggle: () => toggleSection("lists") }), (0, jsx_runtime_1.jsx)("div", { className: "crisp-sidebar-section", "data-collapsed": collapsedSections.has("lists") ? "" : undefined, children: (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-sidebar-newlist", children: listsSection.newListLabel }) }), (0, jsx_runtime_1.jsx)(SectionHeader, { label: chatsSection.label, open: !collapsedSections.has("chats"), onToggle: () => toggleSection("chats") }), (0, jsx_runtime_1.jsx)("div", { className: "crisp-sidebar-section", "data-collapsed": collapsedSections.has("chats") ? "" : undefined, children: chatsSection.items.map(({ label, icon: ItemIcon }) => ((0, jsx_runtime_1.jsx)(navitem_1.NavItem, { href: "#", icon: (0, jsx_runtime_1.jsx)(ItemIcon, {}), children: label }, label))) }), (0, jsx_runtime_1.jsxs)("div", { className: "crisp-sidebar-footer", children: [(0, jsx_runtime_1.jsx)(button_1.Button, { intent: "neutral", size: "sm", className: "crisp-sidebar-pill", children: footer.gettingStartedLabel }), (0, jsx_runtime_1.jsx)(navitem_1.NavItem, { href: "#", icon: (0, jsx_runtime_1.jsx)(IconInvite, {}), children: footer.inviteLabel }), (0, jsx_runtime_1.jsx)("div", { className: "crisp-sidebar-divider" }), (0, jsx_runtime_1.jsxs)("div", { className: "crisp-sidebar-trial", children: [(0, jsx_runtime_1.jsx)("span", { children: footer.trialText }), (0, jsx_runtime_1.jsx)(button_1.Button, { intent: "neutral", size: "sm", children: footer.trialActionLabel })] })] }), (0, jsx_runtime_1.jsx)(command_1.Command, { open: commandOpen, onOpenChange: setCommandOpen })] }));
152
+ return ((0, jsx_runtime_1.jsxs)(core_1.Primitive.div, { ref: ref, asChild: asChild, className: (0, sidebar_recipe_1.sidebarRecipe)({ className }), ...props, children: [(0, jsx_runtime_1.jsxs)("div", { className: "crisp-sidebar-ws", children: [(0, jsx_runtime_1.jsx)(avatar_1.Avatar, { size: "lg", shape: "rounded", tone: workspace.avatarTone, children: workspace.avatarLabel }), (0, jsx_runtime_1.jsx)("span", { className: "crisp-sidebar-ws-name", children: workspace.name }), (0, jsx_runtime_1.jsx)(IconChevronDown, {}), (0, jsx_runtime_1.jsx)(button_1.Button, { intent: "ghost", size: "sm", shape: "icon", className: "crisp-sidebar-ws-collapse", "aria-label": collapseLabel, onClick: onToggleCollapse, children: (0, jsx_runtime_1.jsx)(IconCollapsePanel, {}) })] }), (0, jsx_runtime_1.jsxs)("div", { className: "crisp-sidebar-searchrow", children: [(0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-sidebar-search", onClick: () => setCommandOpen(true), children: [(0, jsx_runtime_1.jsx)(IconSearch, {}), (0, jsx_runtime_1.jsx)("span", { className: "crisp-sidebar-search-label", children: searchLabel }), (0, jsx_runtime_1.jsx)("span", { className: "crisp-sidebar-search-kbd", children: "\u2318K" })] }), (0, jsx_runtime_1.jsx)(button_1.Button, { intent: "neutral", size: "sm", shape: "icon", "aria-label": inboxLabel, children: (0, jsx_runtime_1.jsx)(IconInbox, {}) })] }), (0, jsx_runtime_1.jsx)("div", { className: "crisp-sidebar-divider" }), (0, jsx_runtime_1.jsx)("div", { className: "crisp-sidebar-section", children: items.map(({ label, icon: ItemIcon, active, trailing }) => ((0, jsx_runtime_1.jsx)(navitem_1.NavItem, { href: "#", active: active, icon: (0, jsx_runtime_1.jsx)(ItemIcon, {}), trailing: trailing, children: label }, label))) }), groups ? (
153
+ // #325: navigation as data, reusing the exact section markup/
154
+ // recipe above — SectionHeader + .crisp-sidebar-section — instead
155
+ // of the three fixed Attio sections.
156
+ groups.map((group) => {
157
+ const groupKey = group.key ?? group.label;
158
+ return ((0, jsx_runtime_1.jsxs)(React.Fragment, { children: [(0, jsx_runtime_1.jsx)(SectionHeader, { label: group.label, open: !collapsedSections.has(groupKey), onToggle: () => toggleSection(groupKey) }), (0, jsx_runtime_1.jsx)("div", { className: "crisp-sidebar-section", "data-collapsed": collapsedSections.has(groupKey) ? "" : undefined, children: group.items.map((item) => ((0, jsx_runtime_1.jsx)(navitem_1.NavItem, { href: item.href ?? "#", active: item.active, icon: item.icon ? (0, jsx_runtime_1.jsx)(item.icon, {}) : undefined, trailing: item.count, onClick: item.onSelect, children: item.label }, item.href ?? item.label))) })] }, groupKey));
159
+ })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(SectionHeader, { label: recordsSection.label, open: !collapsedSections.has("records"), onToggle: () => toggleSection("records") }), (0, jsx_runtime_1.jsx)("div", { className: "crisp-sidebar-section", "data-collapsed": collapsedSections.has("records") ? "" : undefined, children: recordsSection.items.map(({ label, tone, glyph: Glyph }) => ((0, jsx_runtime_1.jsx)(navitem_1.NavItem, { href: "#", icon: (0, jsx_runtime_1.jsx)(avatar_1.Avatar, { size: "sm", shape: "rounded", tone: tone, children: (0, jsx_runtime_1.jsx)(Glyph, {}) }), children: label }, label))) }), (0, jsx_runtime_1.jsx)(SectionHeader, { label: listsSection.label, open: !collapsedSections.has("lists"), onToggle: () => toggleSection("lists") }), (0, jsx_runtime_1.jsx)("div", { className: "crisp-sidebar-section", "data-collapsed": collapsedSections.has("lists") ? "" : undefined, children: (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-sidebar-newlist", children: listsSection.newListLabel }) }), (0, jsx_runtime_1.jsx)(SectionHeader, { label: chatsSection.label, open: !collapsedSections.has("chats"), onToggle: () => toggleSection("chats") }), (0, jsx_runtime_1.jsx)("div", { className: "crisp-sidebar-section", "data-collapsed": collapsedSections.has("chats") ? "" : undefined, children: chatsSection.items.map(({ label, icon: ItemIcon }) => ((0, jsx_runtime_1.jsx)(navitem_1.NavItem, { href: "#", icon: (0, jsx_runtime_1.jsx)(ItemIcon, {}), children: label }, label))) })] })), (0, jsx_runtime_1.jsxs)("div", { className: "crisp-sidebar-footer", children: [(0, jsx_runtime_1.jsx)(button_1.Button, { intent: "neutral", size: "sm", className: "crisp-sidebar-pill", children: footer.gettingStartedLabel }), (0, jsx_runtime_1.jsx)(navitem_1.NavItem, { href: "#", icon: (0, jsx_runtime_1.jsx)(IconInvite, {}), children: footer.inviteLabel }), (0, jsx_runtime_1.jsx)("div", { className: "crisp-sidebar-divider" }), (0, jsx_runtime_1.jsxs)("div", { className: "crisp-sidebar-trial", children: [(0, jsx_runtime_1.jsx)("span", { children: footer.trialText }), (0, jsx_runtime_1.jsx)(button_1.Button, { intent: "neutral", size: "sm", children: footer.trialActionLabel })] })] }), (0, jsx_runtime_1.jsx)(command_1.Command, { open: commandOpen, onOpenChange: setCommandOpen })] }));
147
160
  });
148
161
  exports.Sidebar.displayName = "Sidebar";
@@ -8,7 +8,7 @@ import { bulkbarRecipe } from "./bulkbar.recipe.js";
8
8
  * (e.g. `count > 0 && <BulkBar …/>`). Measured from a records table.
9
9
  */
10
10
  export function BulkBar({ count, onClear, countLabel = "selected", countText = (count, label) => (_jsxs(_Fragment, { children: [count, " ", label] })), children, className, clearLabel = "Clear selection", ...rest }) {
11
- return (_jsx("div", { className: bulkbarRecipe({ className }), ...rest, children: _jsxs("div", { className: "crisp-bulkbar-bar", children: [count != null && (_jsxs("button", { type: "button", className: "crisp-bulkbar-count", onClick: onClear, title: clearLabel, children: [countText(count, countLabel), _jsx(X, { size: 13, strokeWidth: 2, "aria-hidden": "true" })] })), children] }) }));
11
+ return (_jsx("div", { className: bulkbarRecipe({ className }), ...rest, children: _jsxs("div", { className: "crisp-bulkbar-bar", children: [count != null && (_jsxs("button", { type: "button", className: "crisp-bulkbar-count", onClick: onClear, title: clearLabel, children: [countText(count, countLabel), _jsx("span", { className: "crisp-bulkbar-clearlabel", children: clearLabel }), _jsx(X, { size: 13, strokeWidth: 2, "aria-hidden": "true" })] })), children] }) }));
12
12
  }
13
13
  BulkBar.displayName = "BulkBar";
14
14
  /** A styled action button for use inside a `<BulkBar>` (or as a `<Menu>` trigger). */
@@ -15,6 +15,12 @@ const config = defineRecipe({
15
15
  & .crisp-bulkbar-count { display: inline-flex; align-items: center; gap: ${c.space["1"]}; height: ${c.size.menuRow}; margin: 0; padding: 0 ${c.space["2"]}; border: none; border-radius: ${c.radius.md}; background: color-mix(in srgb, ${c.fgPrimary} 4%, transparent); font-family: inherit; font-size: ${c.text.md}; font-weight: ${c.weight.medium}; color: ${c.fgMuted}; white-space: nowrap; cursor: pointer; transition: background-color .12s; }
16
16
  & .crisp-bulkbar-count:hover { background: color-mix(in srgb, ${c.fgPrimary} 8%, transparent); color: ${c.fgPrimary}; }
17
17
  & .crisp-bulkbar-count svg { flex-shrink: 0; }
18
+ /* the ", Clear selection" ARIA concatenates onto the pill's name (#324)
19
+ -- read, never drawn. Same technique as Slider's thumblabel. */
20
+ & .crisp-bulkbar-clearlabel {
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
+ }
18
24
  & .crisp-bulkbar-btn { display: inline-flex; align-items: center; gap: ${c.space["1"]}; height: ${c.size.menuRow}; padding: 0 ${c.space["2"]}; border: none; border-radius: ${c.radius.md}; background: transparent; color: ${c.fgPrimary}; font-family: inherit; font-size: ${c.text.md}; font-weight: ${c.weight.medium}; white-space: nowrap; cursor: pointer; transition: background-color .12s; }
19
25
  & .crisp-bulkbar-btn:hover { background: ${c.bgRaised}; }
20
26
  & .crisp-bulkbar-btn--danger { color: ${c.bgDanger}; }
@@ -26,7 +26,8 @@ function FieldValue({ col, row, emptyLabel, }) {
26
26
  * relative` container (e.g. the app main area); it slides in from the right and
27
27
  * overlays the content.
28
28
  */
29
- export function RecordPanel({ record, columns, onClose, className, closeLabel = "Close panel", detailsLabel = "Details", emptyLabel = "Empty", ...rest }) {
29
+ export function RecordPanel({ record, columns, onClose, className, closeLabel = "Close panel", title, detailsLabel = "Details", emptyLabel = "Empty", footer, ...rest }) {
30
+ const titleId = React.useId();
30
31
  // close on Escape while open
31
32
  React.useEffect(() => {
32
33
  if (!record)
@@ -42,6 +43,7 @@ export function RecordPanel({ record, columns, onClose, className, closeLabel =
42
43
  ? record[entityCol.key]
43
44
  : undefined;
44
45
  const fields = columns.filter((c) => c.type !== "entity");
45
- return (_jsxs("div", { className: recordpanelRecipe({ className }), ...rest, role: "dialog", "aria-label": entity?.name ?? "Record", children: [_jsxs("div", { className: "crisp-recordpanel-header", children: [entity?.avatar ? (_jsx("img", { className: "crisp-recordpanel-fav", src: entity.avatar, alt: "" })) : entity ? (_jsx(Avatar, { size: "md", shape: "rounded", tone: "brand", children: entity.name.charAt(0) })) : null, _jsx("span", { className: "crisp-recordpanel-title", children: entity?.name ?? "Record" }), _jsx("button", { type: "button", className: "crisp-recordpanel-iconbtn", "aria-label": closeLabel, onClick: onClose, children: _jsx(X, { size: 18, strokeWidth: 1.75 }) })] }), _jsxs("div", { className: "crisp-recordpanel-body", children: [_jsx("div", { className: "crisp-recordpanel-sectionlabel", children: detailsLabel }), fields.map((col) => (_jsxs("div", { className: "crisp-recordpanel-field", children: [_jsxs("span", { className: "crisp-recordpanel-field-label", children: [col.icon ? _jsx(col.icon, { size: 15, strokeWidth: 1.75 }) : null, col.label] }), _jsx("span", { className: "crisp-recordpanel-field-value", children: _jsx(FieldValue, { col: col, row: record, emptyLabel: emptyLabel }) })] }, col.key)))] })] }));
46
+ const heading = title ?? entity?.name ?? "Record";
47
+ return (_jsxs("div", { className: recordpanelRecipe({ className }), ...rest, role: "dialog", "aria-labelledby": titleId, children: [_jsxs("div", { className: "crisp-recordpanel-header", children: [entity?.avatar ? (_jsx("img", { className: "crisp-recordpanel-fav", src: entity.avatar, alt: "" })) : entity ? (_jsx(Avatar, { size: "md", shape: "rounded", tone: "brand", children: entity.name.charAt(0) })) : null, _jsx("span", { id: titleId, className: "crisp-recordpanel-title", children: heading }), _jsx("button", { type: "button", className: "crisp-recordpanel-iconbtn", "aria-label": closeLabel, onClick: onClose, children: _jsx(X, { size: 18, strokeWidth: 1.75 }) })] }), _jsxs("div", { className: "crisp-recordpanel-body", children: [_jsx("div", { className: "crisp-recordpanel-sectionlabel", children: detailsLabel }), fields.map((col) => (_jsxs("div", { className: "crisp-recordpanel-field", children: [_jsxs("span", { className: "crisp-recordpanel-field-label", children: [col.icon ? _jsx(col.icon, { size: 15, strokeWidth: 1.75 }) : null, col.label] }), _jsx("span", { className: "crisp-recordpanel-field-value", children: _jsx(FieldValue, { col: col, row: record, emptyLabel: emptyLabel }) })] }, col.key)))] }), footer != null && (_jsx("div", { className: "crisp-recordpanel-footer", children: footer }))] }));
46
48
  }
47
49
  RecordPanel.displayName = "RecordPanel";
@@ -36,6 +36,14 @@ const config = defineRecipe({
36
36
  ${recordTagCss}
37
37
  & .crisp-recordpanel-link { color: ${c.bgBrandSolid}; text-decoration: none; overflow: hidden; text-overflow: ellipsis; }
38
38
  & .crisp-recordpanel-link:hover { text-decoration: underline; }
39
+
40
+ /* #329: an optional slot for record actions (e.g. Edit). Reuses the
41
+ header's own measured values rather than inventing new ones -- the
42
+ same horizontal inset (space.3), the same hairline border token
43
+ (borderSubtle, flipped to border-top since this sits at the opposite
44
+ edge), and the header/body gap (space.2) for its own vertical padding
45
+ and for spacing between whatever it's given. */
46
+ & .crisp-recordpanel-footer { flex-shrink: 0; display: flex; align-items: center; gap: ${c.space["2"]}; padding: ${c.space["2"]} ${c.space["3"]}; border-top: 1px solid ${c.borderSubtle}; }
39
47
  `,
40
48
  variants: {},
41
49
  defaultVariants: {},
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Building2, ChartNoAxesColumn, ChevronDown, CircleDollarSign, FileText, House, Inbox, LayoutGrid, Mail, MessageSquare, PanelLeft, Phone, Search, Send, SquareCheck, User, UserPlus, Users, Waypoints, } from "lucide-react";
3
3
  import * as React from "react";
4
4
  import { Avatar } from "../../components/avatar/index.js";
@@ -81,8 +81,14 @@ export const ATTIO_SIDEBAR_FOOTER = {
81
81
  function SectionHeader({ label, open, onToggle, }) {
82
82
  return (_jsxs("button", { type: "button", className: "crisp-sidebar-section-header", "data-collapsed": open ? undefined : "", "aria-expanded": open, onClick: onToggle, children: [_jsx(IconChevronMini, {}), _jsx("span", { children: label })] }));
83
83
  }
84
- export const Sidebar = React.forwardRef(({ className, asChild, onToggleCollapse, collapseLabel = "Collapse sidebar", inboxLabel = "Inbox", searchLabel = "Search", workspace = ATTIO_SIDEBAR_WORKSPACE, items = ATTIO_SIDEBAR_ITEMS, recordsSection = ATTIO_SIDEBAR_RECORDS_SECTION, listsSection = ATTIO_SIDEBAR_LISTS_SECTION, chatsSection = ATTIO_SIDEBAR_CHATS_SECTION, footer = ATTIO_SIDEBAR_FOOTER, ...props }, ref) => {
85
- const [collapsedSections, setCollapsedSections] = React.useState(() => new Set());
84
+ export const Sidebar = React.forwardRef(({ className, asChild, onToggleCollapse, collapseLabel = "Collapse sidebar", inboxLabel = "Inbox", searchLabel = "Search", workspace = ATTIO_SIDEBAR_WORKSPACE, items = ATTIO_SIDEBAR_ITEMS, recordsSection = ATTIO_SIDEBAR_RECORDS_SECTION, listsSection = ATTIO_SIDEBAR_LISTS_SECTION, chatsSection = ATTIO_SIDEBAR_CHATS_SECTION, groups, footer = ATTIO_SIDEBAR_FOOTER, ...props }, ref) => {
85
+ // Seeded once, on mount, from whatever groups start collapsed — not
86
+ // reactive to `groups` changing later, matching every other piece of
87
+ // this component's state (recordsSection/listsSection/chatsSection have
88
+ // never supported a starting-collapsed prop at all).
89
+ const [collapsedSections, setCollapsedSections] = React.useState(() => new Set(groups
90
+ ?.filter((g) => g.defaultCollapsed)
91
+ .map((g) => g.key ?? g.label)));
86
92
  const [commandOpen, setCommandOpen] = React.useState(false);
87
93
  React.useEffect(() => {
88
94
  function handleKeyDown(event) {
@@ -107,6 +113,13 @@ export const Sidebar = React.forwardRef(({ className, asChild, onToggleCollapse,
107
113
  return next;
108
114
  });
109
115
  }, []);
110
- return (_jsxs(Primitive.div, { ref: ref, asChild: asChild, className: sidebarRecipe({ className }), ...props, children: [_jsxs("div", { className: "crisp-sidebar-ws", children: [_jsx(Avatar, { size: "lg", shape: "rounded", tone: workspace.avatarTone, children: workspace.avatarLabel }), _jsx("span", { className: "crisp-sidebar-ws-name", children: workspace.name }), _jsx(IconChevronDown, {}), _jsx(Button, { intent: "ghost", size: "sm", shape: "icon", className: "crisp-sidebar-ws-collapse", "aria-label": collapseLabel, onClick: onToggleCollapse, children: _jsx(IconCollapsePanel, {}) })] }), _jsxs("div", { className: "crisp-sidebar-searchrow", children: [_jsxs("button", { type: "button", className: "crisp-sidebar-search", onClick: () => setCommandOpen(true), children: [_jsx(IconSearch, {}), _jsx("span", { className: "crisp-sidebar-search-label", children: searchLabel }), _jsx("span", { className: "crisp-sidebar-search-kbd", children: "\u2318K" })] }), _jsx(Button, { intent: "neutral", size: "sm", shape: "icon", "aria-label": inboxLabel, children: _jsx(IconInbox, {}) })] }), _jsx("div", { className: "crisp-sidebar-divider" }), _jsx("div", { className: "crisp-sidebar-section", children: items.map(({ label, icon: ItemIcon, active, trailing }) => (_jsx(NavItem, { href: "#", active: active, icon: _jsx(ItemIcon, {}), trailing: trailing, children: label }, label))) }), _jsx(SectionHeader, { label: recordsSection.label, open: !collapsedSections.has("records"), onToggle: () => toggleSection("records") }), _jsx("div", { className: "crisp-sidebar-section", "data-collapsed": collapsedSections.has("records") ? "" : undefined, children: recordsSection.items.map(({ label, tone, glyph: Glyph }) => (_jsx(NavItem, { href: "#", icon: _jsx(Avatar, { size: "sm", shape: "rounded", tone: tone, children: _jsx(Glyph, {}) }), children: label }, label))) }), _jsx(SectionHeader, { label: listsSection.label, open: !collapsedSections.has("lists"), onToggle: () => toggleSection("lists") }), _jsx("div", { className: "crisp-sidebar-section", "data-collapsed": collapsedSections.has("lists") ? "" : undefined, children: _jsx("button", { type: "button", className: "crisp-sidebar-newlist", children: listsSection.newListLabel }) }), _jsx(SectionHeader, { label: chatsSection.label, open: !collapsedSections.has("chats"), onToggle: () => toggleSection("chats") }), _jsx("div", { className: "crisp-sidebar-section", "data-collapsed": collapsedSections.has("chats") ? "" : undefined, children: chatsSection.items.map(({ label, icon: ItemIcon }) => (_jsx(NavItem, { href: "#", icon: _jsx(ItemIcon, {}), children: label }, label))) }), _jsxs("div", { className: "crisp-sidebar-footer", children: [_jsx(Button, { intent: "neutral", size: "sm", className: "crisp-sidebar-pill", children: footer.gettingStartedLabel }), _jsx(NavItem, { href: "#", icon: _jsx(IconInvite, {}), children: footer.inviteLabel }), _jsx("div", { className: "crisp-sidebar-divider" }), _jsxs("div", { className: "crisp-sidebar-trial", children: [_jsx("span", { children: footer.trialText }), _jsx(Button, { intent: "neutral", size: "sm", children: footer.trialActionLabel })] })] }), _jsx(Command, { open: commandOpen, onOpenChange: setCommandOpen })] }));
116
+ return (_jsxs(Primitive.div, { ref: ref, asChild: asChild, className: sidebarRecipe({ className }), ...props, children: [_jsxs("div", { className: "crisp-sidebar-ws", children: [_jsx(Avatar, { size: "lg", shape: "rounded", tone: workspace.avatarTone, children: workspace.avatarLabel }), _jsx("span", { className: "crisp-sidebar-ws-name", children: workspace.name }), _jsx(IconChevronDown, {}), _jsx(Button, { intent: "ghost", size: "sm", shape: "icon", className: "crisp-sidebar-ws-collapse", "aria-label": collapseLabel, onClick: onToggleCollapse, children: _jsx(IconCollapsePanel, {}) })] }), _jsxs("div", { className: "crisp-sidebar-searchrow", children: [_jsxs("button", { type: "button", className: "crisp-sidebar-search", onClick: () => setCommandOpen(true), children: [_jsx(IconSearch, {}), _jsx("span", { className: "crisp-sidebar-search-label", children: searchLabel }), _jsx("span", { className: "crisp-sidebar-search-kbd", children: "\u2318K" })] }), _jsx(Button, { intent: "neutral", size: "sm", shape: "icon", "aria-label": inboxLabel, children: _jsx(IconInbox, {}) })] }), _jsx("div", { className: "crisp-sidebar-divider" }), _jsx("div", { className: "crisp-sidebar-section", children: items.map(({ label, icon: ItemIcon, active, trailing }) => (_jsx(NavItem, { href: "#", active: active, icon: _jsx(ItemIcon, {}), trailing: trailing, children: label }, label))) }), groups ? (
117
+ // #325: navigation as data, reusing the exact section markup/
118
+ // recipe above — SectionHeader + .crisp-sidebar-section — instead
119
+ // of the three fixed Attio sections.
120
+ groups.map((group) => {
121
+ const groupKey = group.key ?? group.label;
122
+ return (_jsxs(React.Fragment, { children: [_jsx(SectionHeader, { label: group.label, open: !collapsedSections.has(groupKey), onToggle: () => toggleSection(groupKey) }), _jsx("div", { className: "crisp-sidebar-section", "data-collapsed": collapsedSections.has(groupKey) ? "" : undefined, children: group.items.map((item) => (_jsx(NavItem, { href: item.href ?? "#", active: item.active, icon: item.icon ? _jsx(item.icon, {}) : undefined, trailing: item.count, onClick: item.onSelect, children: item.label }, item.href ?? item.label))) })] }, groupKey));
123
+ })) : (_jsxs(_Fragment, { children: [_jsx(SectionHeader, { label: recordsSection.label, open: !collapsedSections.has("records"), onToggle: () => toggleSection("records") }), _jsx("div", { className: "crisp-sidebar-section", "data-collapsed": collapsedSections.has("records") ? "" : undefined, children: recordsSection.items.map(({ label, tone, glyph: Glyph }) => (_jsx(NavItem, { href: "#", icon: _jsx(Avatar, { size: "sm", shape: "rounded", tone: tone, children: _jsx(Glyph, {}) }), children: label }, label))) }), _jsx(SectionHeader, { label: listsSection.label, open: !collapsedSections.has("lists"), onToggle: () => toggleSection("lists") }), _jsx("div", { className: "crisp-sidebar-section", "data-collapsed": collapsedSections.has("lists") ? "" : undefined, children: _jsx("button", { type: "button", className: "crisp-sidebar-newlist", children: listsSection.newListLabel }) }), _jsx(SectionHeader, { label: chatsSection.label, open: !collapsedSections.has("chats"), onToggle: () => toggleSection("chats") }), _jsx("div", { className: "crisp-sidebar-section", "data-collapsed": collapsedSections.has("chats") ? "" : undefined, children: chatsSection.items.map(({ label, icon: ItemIcon }) => (_jsx(NavItem, { href: "#", icon: _jsx(ItemIcon, {}), children: label }, label))) })] })), _jsxs("div", { className: "crisp-sidebar-footer", children: [_jsx(Button, { intent: "neutral", size: "sm", className: "crisp-sidebar-pill", children: footer.gettingStartedLabel }), _jsx(NavItem, { href: "#", icon: _jsx(IconInvite, {}), children: footer.inviteLabel }), _jsx("div", { className: "crisp-sidebar-divider" }), _jsxs("div", { className: "crisp-sidebar-trial", children: [_jsx("span", { children: footer.trialText }), _jsx(Button, { intent: "neutral", size: "sm", children: footer.trialActionLabel })] })] }), _jsx(Command, { open: commandOpen, onOpenChange: setCommandOpen })] }));
111
124
  });
112
125
  Sidebar.displayName = "Sidebar";
@@ -25,6 +25,12 @@
25
25
  & .crisp-recordstable-tag[data-tone="orange"] { background: color-mix(in srgb, var(--crisp-bg-orange) 16%, transparent); color: color-mix(in srgb, var(--crisp-bg-orange) 82%, var(--crisp-fg-primary)); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-orange) 30%, transparent); }
26
26
  & .crisp-recordpanel-link { color: var(--crisp-bg-brand-solid); text-decoration: none; overflow: hidden; text-overflow: ellipsis; }
27
27
  & .crisp-recordpanel-link:hover { text-decoration: underline; }
28
+ /* #329: an optional slot for record actions (e.g. Edit). Reuses the header's own measured values rather than inventing new ones -- the
29
+ same horizontal inset (space.3), the same hairline border token
30
+ (borderSubtle, flipped to border-top since this sits at the opposite
31
+ edge), and the header/body gap (space.2) for its own vertical padding
32
+ and for spacing between whatever it's given. */
33
+ & .crisp-recordpanel-footer { flex-shrink: 0; display: flex; align-items: center; gap: var(--crisp-space-2); padding: var(--crisp-space-2) var(--crisp-space-3); border-top: 1px solid var(--crisp-border-subtle); }
28
34
  }
29
35
 
30
36
  @media (prefers-reduced-motion: reduce) {
@@ -26,6 +26,12 @@
26
26
  & .crisp-recordstable-tag[data-tone="orange"] { background: color-mix(in srgb, var(--crisp-bg-orange) 16%, transparent); color: color-mix(in srgb, var(--crisp-bg-orange) 82%, var(--crisp-fg-primary)); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-orange) 30%, transparent); }
27
27
  & .crisp-recordpanel-link { color: var(--crisp-bg-brand-solid); text-decoration: none; overflow: hidden; text-overflow: ellipsis; }
28
28
  & .crisp-recordpanel-link:hover { text-decoration: underline; }
29
+ /* #329: an optional slot for record actions (e.g. Edit). Reuses the header's own measured values rather than inventing new ones -- the
30
+ same horizontal inset (space.3), the same hairline border token
31
+ (borderSubtle, flipped to border-top since this sits at the opposite
32
+ edge), and the header/body gap (space.2) for its own vertical padding
33
+ and for spacing between whatever it's given. */
34
+ & .crisp-recordpanel-footer { flex-shrink: 0; display: flex; align-items: center; gap: var(--crisp-space-2); padding: var(--crisp-space-2) var(--crisp-space-3); border-top: 1px solid var(--crisp-border-subtle); }
29
35
  }
30
36
 
31
37
  @media (prefers-reduced-motion: reduce) {
package/dist/styles.css CHANGED
@@ -283,6 +283,9 @@
283
283
  & .crisp-bulkbar-count { display: inline-flex; align-items: center; gap: var(--crisp-space-1); height: var(--crisp-size-menuRow); margin: 0; padding: 0 var(--crisp-space-2); border: none; border-radius: var(--crisp-radius-md); background: color-mix(in srgb, var(--crisp-fg-primary) 4%, transparent); font-family: inherit; font-size: var(--crisp-text-md); font-weight: var(--crisp-weight-medium); color: var(--crisp-fg-muted); white-space: nowrap; cursor: pointer; transition: background-color .12s; }
284
284
  & .crisp-bulkbar-count:hover { background: color-mix(in srgb, var(--crisp-fg-primary) 8%, transparent); color: var(--crisp-fg-primary); }
285
285
  & .crisp-bulkbar-count svg { flex-shrink: 0; }
286
+ /* the ", Clear selection" ARIA concatenates onto the pill's name (#324)
287
+ -- read, never drawn. Same technique as Slider's thumblabel. */
288
+ & .crisp-bulkbar-clearlabel { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
286
289
  & .crisp-bulkbar-btn { display: inline-flex; align-items: center; gap: var(--crisp-space-1); height: var(--crisp-size-menuRow); padding: 0 var(--crisp-space-2); border: none; border-radius: var(--crisp-radius-md); background: transparent; color: var(--crisp-fg-primary); font-family: inherit; font-size: var(--crisp-text-md); font-weight: var(--crisp-weight-medium); white-space: nowrap; cursor: pointer; transition: background-color .12s; }
287
290
  & .crisp-bulkbar-btn:hover { background: var(--crisp-bg-raised); }
288
291
  & .crisp-bulkbar-btn--danger { color: var(--crisp-bg-danger); }
@@ -2062,6 +2065,12 @@
2062
2065
  & .crisp-recordstable-tag[data-tone="orange"] { background: color-mix(in srgb, var(--crisp-bg-orange) 16%, transparent); color: color-mix(in srgb, var(--crisp-bg-orange) 82%, var(--crisp-fg-primary)); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-orange) 30%, transparent); }
2063
2066
  & .crisp-recordpanel-link { color: var(--crisp-bg-brand-solid); text-decoration: none; overflow: hidden; text-overflow: ellipsis; }
2064
2067
  & .crisp-recordpanel-link:hover { text-decoration: underline; }
2068
+ /* #329: an optional slot for record actions (e.g. Edit). Reuses the header's own measured values rather than inventing new ones -- the
2069
+ same horizontal inset (space.3), the same hairline border token
2070
+ (borderSubtle, flipped to border-top since this sits at the opposite
2071
+ edge), and the header/body gap (space.2) for its own vertical padding
2072
+ and for spacing between whatever it's given. */
2073
+ & .crisp-recordpanel-footer { flex-shrink: 0; display: flex; align-items: center; gap: var(--crisp-space-2); padding: var(--crisp-space-2) var(--crisp-space-3); border-top: 1px solid var(--crisp-border-subtle); }
2065
2074
  }
2066
2075
 
2067
2076
  @media (prefers-reduced-motion: reduce) {
@@ -284,6 +284,9 @@
284
284
  & .crisp-bulkbar-count { display: inline-flex; align-items: center; gap: var(--crisp-space-1); height: var(--crisp-size-menuRow); margin: 0; padding: 0 var(--crisp-space-2); border: none; border-radius: var(--crisp-radius-md); background: color-mix(in srgb, var(--crisp-fg-primary) 4%, transparent); font-family: inherit; font-size: var(--crisp-text-md); font-weight: var(--crisp-weight-medium); color: var(--crisp-fg-muted); white-space: nowrap; cursor: pointer; transition: background-color .12s; }
285
285
  & .crisp-bulkbar-count:hover { background: color-mix(in srgb, var(--crisp-fg-primary) 8%, transparent); color: var(--crisp-fg-primary); }
286
286
  & .crisp-bulkbar-count svg { flex-shrink: 0; }
287
+ /* the ", Clear selection" ARIA concatenates onto the pill's name (#324)
288
+ -- read, never drawn. Same technique as Slider's thumblabel. */
289
+ & .crisp-bulkbar-clearlabel { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
287
290
  & .crisp-bulkbar-btn { display: inline-flex; align-items: center; gap: var(--crisp-space-1); height: var(--crisp-size-menuRow); padding: 0 var(--crisp-space-2); border: none; border-radius: var(--crisp-radius-md); background: transparent; color: var(--crisp-fg-primary); font-family: inherit; font-size: var(--crisp-text-md); font-weight: var(--crisp-weight-medium); white-space: nowrap; cursor: pointer; transition: background-color .12s; }
288
291
  & .crisp-bulkbar-btn:hover { background: var(--crisp-bg-raised); }
289
292
  & .crisp-bulkbar-btn--danger { color: var(--crisp-bg-danger); }
@@ -2063,6 +2066,12 @@
2063
2066
  & .crisp-recordstable-tag[data-tone="orange"] { background: color-mix(in srgb, var(--crisp-bg-orange) 16%, transparent); color: color-mix(in srgb, var(--crisp-bg-orange) 82%, var(--crisp-fg-primary)); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-orange) 30%, transparent); }
2064
2067
  & .crisp-recordpanel-link { color: var(--crisp-bg-brand-solid); text-decoration: none; overflow: hidden; text-overflow: ellipsis; }
2065
2068
  & .crisp-recordpanel-link:hover { text-decoration: underline; }
2069
+ /* #329: an optional slot for record actions (e.g. Edit). Reuses the header's own measured values rather than inventing new ones -- the
2070
+ same horizontal inset (space.3), the same hairline border token
2071
+ (borderSubtle, flipped to border-top since this sits at the opposite
2072
+ edge), and the header/body gap (space.2) for its own vertical padding
2073
+ and for spacing between whatever it's given. */
2074
+ & .crisp-recordpanel-footer { flex-shrink: 0; display: flex; align-items: center; gap: var(--crisp-space-2); padding: var(--crisp-space-2) var(--crisp-space-3); border-top: 1px solid var(--crisp-border-subtle); }
2066
2075
  }
2067
2076
 
2068
2077
  @media (prefers-reduced-motion: reduce) {
@@ -4,10 +4,20 @@ export type RecordPanelProps = Omit<React.ComponentPropsWithoutRef<"div">, "chil
4
4
  /** The record to show; render nothing when null. */
5
5
  /** Label for the close control. */
6
6
  closeLabel?: string;
7
+ /**
8
+ * The panel's heading, and so its accessible name (via `aria-labelledby`,
9
+ * not a stringified `aria-label` — it also drives the visible title, which
10
+ * can be any node). Defaults to the entity column's name, same as before
11
+ * this prop existed; pass it to title the panel by something else, e.g.
12
+ * "{Collection} record".
13
+ */
14
+ title?: React.ReactNode;
7
15
  /** Heading above the field list. */
8
16
  detailsLabel?: React.ReactNode;
9
17
  /** Shown in place of a field that has no value. */
10
18
  emptyLabel?: React.ReactNode;
19
+ /** Rendered below the field list, e.g. an Edit button. Omit for no footer. */
20
+ footer?: React.ReactNode;
11
21
  record: Row | null;
12
22
  columns: Column[];
13
23
  onClose: () => void;
@@ -19,7 +29,7 @@ export type RecordPanelProps = Omit<React.ComponentPropsWithoutRef<"div">, "chil
19
29
  * relative` container (e.g. the app main area); it slides in from the right and
20
30
  * overlays the content.
21
31
  */
22
- export declare function RecordPanel({ record, columns, onClose, className, closeLabel, detailsLabel, emptyLabel, ...rest }: RecordPanelProps): React.JSX.Element | null;
32
+ export declare function RecordPanel({ record, columns, onClose, className, closeLabel, title, detailsLabel, emptyLabel, footer, ...rest }: RecordPanelProps): React.JSX.Element | null;
23
33
  export declare namespace RecordPanel {
24
34
  var displayName: string;
25
35
  }
@@ -40,6 +40,42 @@ export type SidebarListsSection = {
40
40
  label: string;
41
41
  newListLabel: string;
42
42
  };
43
+ /** A row in a `groups` section (#325). */
44
+ export type SidebarNavGroupItem = {
45
+ label: string;
46
+ /** 16px icon component, rendered `<Icon />`. Optional — not every group's
47
+ * rows carry one (unlike the main nav's `SidebarNavItem`, where it's
48
+ * required). */
49
+ icon?: React.ComponentType<IconProps>;
50
+ /** Either this or `onSelect` — a plain link, or a handler for anything
51
+ * that isn't real navigation (opening a panel, selecting a view). Both
52
+ * may be set; neither is required to make the row *something* clickable
53
+ * (it still renders as a link, defaulting `href` to "#"). */
54
+ href?: string;
55
+ onSelect?: () => void;
56
+ active?: boolean;
57
+ /** Trailing content — a count, a badge, anything `NavItem`'s own
58
+ * `trailing` slot already takes. */
59
+ count?: React.ReactNode;
60
+ };
61
+ /**
62
+ * A named, collapsible group of `SidebarNavGroupItem` rows — navigation as
63
+ * data, for a consumer whose sections aren't Attio's Records/Lists/Chats
64
+ * (#325). Pass `groups` to `Sidebar` and it replaces those three sections
65
+ * entirely; a group's `items` can come from runtime data (a re-render with a
66
+ * new array is all a group needs to pick up new rows).
67
+ */
68
+ export type SidebarNavGroup = {
69
+ /** Identity for this group's own collapse state across renders. Falls
70
+ * back to `label` — set it explicitly if two groups could ever share a
71
+ * label, or if a group's label itself changes at runtime. */
72
+ key?: string;
73
+ label: string;
74
+ items: SidebarNavGroupItem[];
75
+ /** Starts this group collapsed (default: expanded, matching every
76
+ * existing section). Read once, on mount. */
77
+ defaultCollapsed?: boolean;
78
+ };
43
79
  /** Footer copy: the onboarding pill, the invite row, and the trial banner. */
44
80
  export type SidebarFooter = {
45
81
  gettingStartedLabel: string;
@@ -67,6 +103,16 @@ export type SidebarProps = React.ComponentPropsWithoutRef<"nav"> & {
67
103
  listsSection?: SidebarListsSection;
68
104
  /** The Chats section (label + nav-item rows). */
69
105
  chatsSection?: SidebarSection;
106
+ /**
107
+ * Navigation as data (#325): any number of named, collapsible groups,
108
+ * reusing the same section markup/recipe as Records/Lists/Chats. When
109
+ * given, it REPLACES those three sections entirely — a consumer whose
110
+ * sections aren't Attio's (internal/grc's Activity, Governance, Identity,
111
+ * Organization, System Administration, plus user-built registers) passes
112
+ * `groups` instead of the three fixed props. Omit it and `<Sidebar />`
113
+ * renders exactly as before this prop existed.
114
+ */
115
+ groups?: SidebarNavGroup[];
70
116
  /** Footer copy (onboarding pill, invite row, trial banner). */
71
117
  footer?: SidebarFooter;
72
118
  };
@@ -102,6 +148,16 @@ export declare const Sidebar: React.ForwardRefExoticComponent<Omit<React.Detaile
102
148
  listsSection?: SidebarListsSection;
103
149
  /** The Chats section (label + nav-item rows). */
104
150
  chatsSection?: SidebarSection;
151
+ /**
152
+ * Navigation as data (#325): any number of named, collapsible groups,
153
+ * reusing the same section markup/recipe as Records/Lists/Chats. When
154
+ * given, it REPLACES those three sections entirely — a consumer whose
155
+ * sections aren't Attio's (internal/grc's Activity, Governance, Identity,
156
+ * Organization, System Administration, plus user-built registers) passes
157
+ * `groups` instead of the three fixed props. Omit it and `<Sidebar />`
158
+ * renders exactly as before this prop existed.
159
+ */
160
+ groups?: SidebarNavGroup[];
105
161
  /** Footer copy (onboarding pill, invite row, trial banner). */
106
162
  footer?: SidebarFooter;
107
163
  } & React.RefAttributes<HTMLDivElement>>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crisp-ui-kit/crisp",
3
- "version": "0.35.0",
3
+ "version": "0.36.0",
4
4
  "description": "A lean React design system, measured pixel-for-pixel from Attio.",
5
5
  "license": "MIT",
6
6
  "type": "module",