@crisp-ui-kit/crisp 0.54.1 → 0.54.3

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.
Files changed (171) hide show
  1. package/dist/chip.css +7 -1
  2. package/dist/chip.d.ts +2 -1
  3. package/dist/chip.layered.css +7 -1
  4. package/dist/cjs/components/chip/chip.js +3 -2
  5. package/dist/cjs/components/chip/chip.recipe.js +37 -1
  6. package/dist/cjs/components/datatable/datatable.js +490 -111
  7. package/dist/cjs/components/datatable/datatable.recipe.js +4 -0
  8. package/dist/cjs/components/emptystate/emptystate.js +3 -2
  9. package/dist/cjs/components/emptystate/emptystate.recipe.js +26 -1
  10. package/dist/cjs/components/mentionchip/index.js +7 -0
  11. package/dist/cjs/components/mentionchip/mentionchip.js +57 -0
  12. package/dist/cjs/components/mentionchip/mentionchip.recipe.js +38 -0
  13. package/dist/cjs/components/personchip/index.js +17 -0
  14. package/dist/cjs/components/personchip/personchip.js +50 -0
  15. package/dist/cjs/components/table/table.js +2 -2
  16. package/dist/cjs/components/table/table.recipe.js +15 -3
  17. package/dist/cjs/components/tabs/tabs.js +2 -2
  18. package/dist/cjs/components/tabs/tabs.recipe.js +30 -3
  19. package/dist/cjs/components/text/index.js +7 -0
  20. package/dist/cjs/components/text/text.js +55 -0
  21. package/dist/cjs/components/text/text.recipe.js +34 -0
  22. package/dist/cjs/core/floating-stack.js +18 -0
  23. package/dist/cjs/core/index.js +2 -1
  24. package/dist/cjs/index.js +8 -0
  25. package/dist/cjs/product/formdialog/formdialog.js +171 -0
  26. package/dist/cjs/product/formdialog/formdialog.recipe.js +26 -0
  27. package/dist/cjs/product/formdialog/index.js +8 -0
  28. package/dist/cjs/product/headerbar/headerbar.recipe.js +9 -1
  29. package/dist/cjs/product/importdialog/importdialog.js +134 -0
  30. package/dist/cjs/product/importdialog/importdialog.recipe.js +92 -0
  31. package/dist/cjs/product/importdialog/index.js +18 -0
  32. package/dist/cjs/product/recordpage/recordpage.recipe.js +10 -1
  33. package/dist/cjs/product/recordstable/recordstable.js +89 -6
  34. package/dist/cjs/product/richtext/richtext.recipe.js +26 -1
  35. package/dist/cjs/product/savedviews/index.js +7 -0
  36. package/dist/cjs/product/savedviews/savedviews.js +207 -0
  37. package/dist/cjs/product/savedviews/savedviews.recipe.js +103 -0
  38. package/dist/cjs/product/taskstable/index.js +7 -0
  39. package/dist/cjs/product/taskstable/taskstable.js +27 -0
  40. package/dist/cjs/product/taskstable/taskstable.recipe.js +47 -0
  41. package/dist/cjs/product/themepicker/index.js +17 -0
  42. package/dist/cjs/product/themepicker/themepicker.js +32 -0
  43. package/dist/cjs/product/themepicker/themepicker.recipe.js +98 -0
  44. package/dist/cjs/tokens/palette.js +30 -13
  45. package/dist/cjs/tokens/semantic.js +6 -0
  46. package/dist/datatable.css +20 -21
  47. package/dist/datatable.layered.css +20 -21
  48. package/dist/emptystate.css +15 -1
  49. package/dist/emptystate.d.ts +2 -2
  50. package/dist/emptystate.layered.css +15 -1
  51. package/dist/esm/components/chip/chip.js +3 -2
  52. package/dist/esm/components/chip/chip.recipe.js +37 -1
  53. package/dist/esm/components/datatable/datatable.js +489 -111
  54. package/dist/esm/components/datatable/datatable.recipe.js +4 -0
  55. package/dist/esm/components/emptystate/emptystate.js +4 -3
  56. package/dist/esm/components/emptystate/emptystate.recipe.js +26 -1
  57. package/dist/esm/components/emptystate/index.js +1 -1
  58. package/dist/esm/components/mentionchip/index.js +2 -0
  59. package/dist/esm/components/mentionchip/mentionchip.js +21 -0
  60. package/dist/esm/components/mentionchip/mentionchip.recipe.js +35 -0
  61. package/dist/esm/components/personchip/index.js +1 -0
  62. package/dist/esm/components/personchip/personchip.js +47 -0
  63. package/dist/esm/components/table/table.js +2 -2
  64. package/dist/esm/components/table/table.recipe.js +15 -3
  65. package/dist/esm/components/tabs/tabs.js +2 -2
  66. package/dist/esm/components/tabs/tabs.recipe.js +30 -3
  67. package/dist/esm/components/text/index.js +2 -0
  68. package/dist/esm/components/text/text.js +19 -0
  69. package/dist/esm/components/text/text.recipe.js +31 -0
  70. package/dist/esm/core/floating-stack.js +17 -0
  71. package/dist/esm/core/index.js +1 -1
  72. package/dist/esm/index.js +8 -0
  73. package/dist/esm/product/formdialog/formdialog.js +134 -0
  74. package/dist/esm/product/formdialog/formdialog.recipe.js +23 -0
  75. package/dist/esm/product/formdialog/index.js +2 -0
  76. package/dist/esm/product/headerbar/headerbar.recipe.js +9 -1
  77. package/dist/esm/product/importdialog/importdialog.js +97 -0
  78. package/dist/esm/product/importdialog/importdialog.recipe.js +89 -0
  79. package/dist/esm/product/importdialog/index.js +2 -0
  80. package/dist/esm/product/recordpage/recordpage.recipe.js +10 -1
  81. package/dist/esm/product/recordstable/recordstable.js +90 -8
  82. package/dist/esm/product/richtext/richtext.recipe.js +26 -1
  83. package/dist/esm/product/savedviews/index.js +2 -0
  84. package/dist/esm/product/savedviews/savedviews.js +171 -0
  85. package/dist/esm/product/savedviews/savedviews.recipe.js +100 -0
  86. package/dist/esm/product/taskstable/index.js +2 -0
  87. package/dist/esm/product/taskstable/taskstable.js +24 -0
  88. package/dist/esm/product/taskstable/taskstable.recipe.js +44 -0
  89. package/dist/esm/product/themepicker/index.js +1 -0
  90. package/dist/esm/product/themepicker/themepicker.js +29 -0
  91. package/dist/esm/product/themepicker/themepicker.recipe.js +95 -0
  92. package/dist/esm/tokens/palette.js +30 -13
  93. package/dist/esm/tokens/semantic.js +6 -0
  94. package/dist/formdialog.css +6 -0
  95. package/dist/formdialog.d.ts +7 -0
  96. package/dist/formdialog.layered.css +8 -0
  97. package/dist/headerbar.css +1 -1
  98. package/dist/headerbar.layered.css +1 -1
  99. package/dist/importdialog.css +38 -0
  100. package/dist/importdialog.d.ts +7 -0
  101. package/dist/importdialog.layered.css +40 -0
  102. package/dist/mentionchip.css +3 -0
  103. package/dist/mentionchip.d.ts +7 -0
  104. package/dist/mentionchip.layered.css +5 -0
  105. package/dist/native/tokens.d.ts +1 -1
  106. package/dist/native/tokens.js +14 -6
  107. package/dist/recordpage.css +13 -2
  108. package/dist/recordpage.layered.css +13 -2
  109. package/dist/richtext.css +11 -1
  110. package/dist/richtext.layered.css +11 -1
  111. package/dist/savedviews.css +78 -0
  112. package/dist/savedviews.d.ts +7 -0
  113. package/dist/savedviews.layered.css +80 -0
  114. package/dist/styles.css +287 -37
  115. package/dist/styles.layered.css +287 -37
  116. package/dist/table.css +15 -6
  117. package/dist/table.layered.css +15 -6
  118. package/dist/tabs.css +7 -1
  119. package/dist/tabs.d.ts +2 -2
  120. package/dist/tabs.layered.css +7 -1
  121. package/dist/taskstable.css +23 -0
  122. package/dist/taskstable.d.ts +7 -0
  123. package/dist/taskstable.layered.css +25 -0
  124. package/dist/text.css +9 -0
  125. package/dist/text.d.ts +7 -0
  126. package/dist/text.layered.css +11 -0
  127. package/dist/themepicker.css +27 -0
  128. package/dist/themepicker.d.ts +7 -0
  129. package/dist/themepicker.layered.css +29 -0
  130. package/dist/tokens.css +7 -3
  131. package/dist/tokens.json +14 -6
  132. package/dist/types/components/chip/chip.d.ts +32 -0
  133. package/dist/types/components/chip/chip.recipe.d.ts +31 -0
  134. package/dist/types/components/datatable/datatable.d.ts +39 -4
  135. package/dist/types/components/emptystate/emptystate.d.ts +29 -4
  136. package/dist/types/components/emptystate/emptystate.recipe.d.ts +2 -0
  137. package/dist/types/components/emptystate/index.d.ts +1 -1
  138. package/dist/types/components/mentionchip/index.d.ts +2 -0
  139. package/dist/types/components/mentionchip/mentionchip.d.ts +20 -0
  140. package/dist/types/components/mentionchip/mentionchip.recipe.d.ts +18 -0
  141. package/dist/types/components/personchip/index.d.ts +1 -0
  142. package/dist/types/components/personchip/personchip.d.ts +37 -0
  143. package/dist/types/components/table/table.d.ts +36 -0
  144. package/dist/types/components/tabs/tabs.d.ts +4 -2
  145. package/dist/types/components/tabs/tabs.recipe.d.ts +18 -3
  146. package/dist/types/components/text/index.d.ts +2 -0
  147. package/dist/types/components/text/text.d.ts +32 -0
  148. package/dist/types/components/text/text.recipe.d.ts +40 -0
  149. package/dist/types/core/floating-stack.d.ts +14 -0
  150. package/dist/types/core/index.d.ts +1 -1
  151. package/dist/types/index.d.ts +8 -0
  152. package/dist/types/product/formdialog/formdialog.d.ts +57 -0
  153. package/dist/types/product/formdialog/formdialog.recipe.d.ts +3 -0
  154. package/dist/types/product/formdialog/index.d.ts +2 -0
  155. package/dist/types/product/importdialog/importdialog.d.ts +86 -0
  156. package/dist/types/product/importdialog/importdialog.recipe.d.ts +3 -0
  157. package/dist/types/product/importdialog/index.d.ts +2 -0
  158. package/dist/types/product/recordstable/recordstable.d.ts +24 -2
  159. package/dist/types/product/savedviews/index.d.ts +2 -0
  160. package/dist/types/product/savedviews/savedviews.d.ts +55 -0
  161. package/dist/types/product/savedviews/savedviews.recipe.d.ts +3 -0
  162. package/dist/types/product/taskstable/index.d.ts +2 -0
  163. package/dist/types/product/taskstable/taskstable.d.ts +54 -0
  164. package/dist/types/product/taskstable/taskstable.recipe.d.ts +3 -0
  165. package/dist/types/product/themepicker/index.d.ts +1 -0
  166. package/dist/types/product/themepicker/themepicker.d.ts +21 -0
  167. package/dist/types/product/themepicker/themepicker.recipe.d.ts +3 -0
  168. package/dist/types/tokens/contract.d.ts +2 -0
  169. package/dist/types/tokens/palette.d.ts +11 -3
  170. package/dist/types/tokens/semantic.d.ts +6 -0
  171. package/package.json +41 -1
@@ -66,6 +66,10 @@ const config = defineRecipe({
66
66
  /* sticky first column(s) + horizontal scroll shadow — RecordsTable's feel */
67
67
  & th[data-sticky], & td[data-sticky] { position: sticky; z-index: 1; background: ${c.bgSurface}; }
68
68
  & th[data-sticky] { z-index: 3; }
69
+ /* #554: a footer sticky-column cell sits in the bottom-sticky row (table.recipe.ts's
70
+ \`tfoot td { z-index: 2 }\`), which \`td[data-sticky]\`'s own higher-specificity
71
+ \`z-index: 1\` would otherwise undercut -- keep it level with the row it's in. */
72
+ & tfoot td[data-sticky] { z-index: 2; }
69
73
  & tbody tr[data-selected] td[data-sticky] { background: color-mix(in srgb, ${c.bgBrandSolid} 10%, ${c.bgSurface}); }
70
74
  & tbody tr:hover td[data-sticky] { background: color-mix(in srgb, ${c.fgPrimary} 4%, ${c.bgSurface}); }
71
75
  & .crisp-table-scroll[data-scrolled-x] [data-sticky-edge] { clip-path: inset(0 -38px 0 0); box-shadow: 6px 0 16px 4px rgba(0,0,0,0.12); }
@@ -1,7 +1,8 @@
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 { emptystateRecipe } from "./emptystate.recipe.js";
3
3
  /** A centred empty-state panel — icon, title, description, optional action. */
4
- export function EmptyState({ icon, title, description, action, variant, className, ...rest }) {
5
- return (_jsxs("div", { className: emptystateRecipe({ variant, className }), ...rest, children: [icon && _jsx("span", { className: "crisp-emptystate-icon", children: icon }), _jsx("div", { className: "crisp-emptystate-title", children: title }), description != null && (_jsx("div", { className: "crisp-emptystate-desc", children: description })), action && _jsx("div", { className: "crisp-emptystate-action", children: action })] }));
4
+ export function EmptyState({ icon, title, description, action, learnMore, variant, className, ...rest }) {
5
+ const learnMoreInner = learnMore && (_jsxs(_Fragment, { children: [_jsx("span", { className: "crisp-emptystate-learnmore-icon", children: learnMore.icon }), _jsx("span", { className: "crisp-emptystate-learnmore-title", children: learnMore.title })] }));
6
+ return (_jsxs("div", { className: emptystateRecipe({ variant, className }), ...rest, children: [icon && _jsx("span", { className: "crisp-emptystate-icon", children: icon }), _jsx("div", { className: "crisp-emptystate-title", children: title }), description != null && (_jsx("div", { className: "crisp-emptystate-desc", children: description })), action && _jsx("div", { className: "crisp-emptystate-action", children: action }), learnMore && (_jsxs("div", { className: "crisp-emptystate-learnmore", children: [_jsx("div", { className: "crisp-emptystate-learnmore-label", children: learnMore.label ?? "Learn more" }), learnMore.href ? (_jsx("a", { className: "crisp-emptystate-learnmore-card", href: learnMore.href, onClick: learnMore.onClick, children: learnMoreInner })) : (_jsx("button", { type: "button", className: "crisp-emptystate-learnmore-card", onClick: learnMore.onClick, children: learnMoreInner }))] }))] }));
6
7
  }
7
8
  EmptyState.displayName = "EmptyState";
@@ -7,7 +7,18 @@ const config = defineRecipe({
7
7
  & .crisp-emptystate-icon { display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; margin-bottom: ${c.space["1"]}; border-radius: ${c.radius.xl}; background: color-mix(in srgb, ${c.fgPrimary} 5%, transparent); color: ${c.fgMuted}; }
8
8
  & .crisp-emptystate-title { font-size: ${c.text.lg}; font-weight: ${c.weight.semibold}; color: ${c.fgPrimary}; }
9
9
  & .crisp-emptystate-desc { max-width: 320px; font-size: ${c.text.md}; color: ${c.fgMuted}; }
10
- & .crisp-emptystate-action { margin-top: ${c.space["2"]}; }`,
10
+ /* #603: measured 8px gap between "Import CSV"/"Connect data source" --
11
+ inline-flex so one action (the common case) or several lay out the
12
+ same way, wrapping if the container is narrow. */
13
+ & .crisp-emptystate-action { display: inline-flex; align-items: center; flex-wrap: wrap; justify-content: center; gap: ${c.space["2"]}; margin-top: ${c.space["2"]}; }
14
+ /* #603: Attio's zero-records "Learn more" card -- a label above a
15
+ clickable icon+title tile. Left/start-aligned, unlike the rest of
16
+ this centred panel, so it overrides the base text-align. */
17
+ & .crisp-emptystate-learnmore { display: flex; flex-direction: column; align-items: stretch; gap: ${c.space["2"]}; margin-top: ${c.space["6"]}; text-align: start; }
18
+ & .crisp-emptystate-learnmore-label { font-size: ${c.text.sm}; font-weight: ${c.weight.medium}; color: color-mix(in srgb, ${c.fgPrimary} 50%, transparent); }
19
+ & .crisp-emptystate-learnmore-card { display: flex; align-items: center; gap: ${c.space["2"]}; padding: ${c.space["2"]} ${c.space["3"]} ${c.space["2"]} ${c.space["2"]}; margin: 0; border: 1px solid ${c.borderSubtle}; border-radius: 18px; background: ${c.bgSurface}; font: inherit; text-align: start; text-decoration: none; color: inherit; cursor: pointer; }
20
+ & .crisp-emptystate-learnmore-icon { display: inline-flex; align-items: center; justify-content: center; width: 56px; height: 56px; flex-shrink: 0; border-radius: ${c.radius.xl}; background: color-mix(in srgb, ${c.fgPrimary} 2%, transparent); color: ${c.fgMuted}; }
21
+ & .crisp-emptystate-learnmore-title { font-size: ${c.text.lg}; font-weight: ${c.weight.medium}; color: ${c.fgPrimary}; }`,
11
22
  variants: {
12
23
  variant: {
13
24
  // compact icon tile (default)
@@ -22,6 +33,20 @@ const config = defineRecipe({
22
33
  & .crisp-emptystate-title { font-size: 20px; line-height: 24px; }
23
34
  & .crisp-emptystate-desc { max-width: 266px; font-size: ${c.text.lg}; font-weight: ${c.weight.medium}; line-height: 20px; }
24
35
  & .crisp-emptystate-action { margin-top: ${c.space["2"]}; }`,
36
+ // #603: Attio's zero-*records* table state (Users/Workspaces with
37
+ // nothing imported) -- the same dotted-grid backdrop `illustrated`
38
+ // uses, at its own measured 138x138 square (illustrated's is a
39
+ // 132x96 rectangle; different surface, not the same number).
40
+ // Attio's own illustration also draws four small "+" tick marks at
41
+ // the tile's corners -- a single proprietary SVG asset (confirmed
42
+ // by inspecting the live DOM: one <svg> with no separately
43
+ // measurable sub-elements), so not reproduced here per AUTHORING's
44
+ // "never copy Attio's icon set" rule; the icon itself is supplied
45
+ // by the consumer via the existing `icon` prop, same as every
46
+ // other variant.
47
+ records: `& .crisp-emptystate-icon { width: 138px; height: 138px; margin-bottom: ${c.space["2"]}; border-radius: 0; color: color-mix(in srgb, ${c.fgPrimary} 22%, transparent); background: radial-gradient(circle, color-mix(in srgb, ${c.fgPrimary} 12%, transparent) 1.2px, transparent 1.2px) 0 0 / 16px 16px; }
48
+ & .crisp-emptystate-title { font-size: 20px; line-height: 24px; }
49
+ & .crisp-emptystate-desc { max-width: 266px; font-size: ${c.text.lg}; font-weight: ${c.weight.medium}; line-height: 20px; }`,
25
50
  },
26
51
  },
27
52
  defaultVariants: { variant: "default" },
@@ -1,2 +1,2 @@
1
- export { EmptyState } from "./emptystate.js";
1
+ export { EmptyState, } from "./emptystate.js";
2
2
  export { emptystateRecipe } from "./emptystate.recipe.js";
@@ -0,0 +1,2 @@
1
+ export { MentionChip } from "./mentionchip.js";
2
+ export { mentionChipRecipe } from "./mentionchip.recipe.js";
@@ -0,0 +1,21 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import { Primitive } from "../../core/index.js";
4
+ import { mentionChipRecipe } from "./mentionchip.recipe.js";
5
+ /**
6
+ * A standalone "@mention" chip — the same look `RichTextEditor` draws
7
+ * inline while an editor is live (`.crisp-rte-mention`), exported on its
8
+ * own for a consumer rendering a mention OUTSIDE a mounted editor: a
9
+ * comment thread, a note's collapsed preview, anywhere a stored
10
+ * `@[Name](actor:id)`-shaped token needs to read as a person, not literal
11
+ * markup. Renders whatever `children` is given (the trigger character
12
+ * included, if the caller wants one, matching how `RichTextEditor`'s own
13
+ * chip includes it) rather than assuming an `@` prefix itself — plain text,
14
+ * no built-in click/remove behaviour (none was asked for; add one once a
15
+ * real interaction is measured, the same reasoning `Chip`'s own doc gives
16
+ * for not guessing a remove affordance).
17
+ */
18
+ export const MentionChip = React.forwardRef(({ className, asChild, ...props }, ref) => {
19
+ return (_jsx(Primitive.span, { ref: ref, asChild: asChild, className: mentionChipRecipe({ className }), ...props }));
20
+ });
21
+ MentionChip.displayName = "MentionChip";
@@ -0,0 +1,35 @@
1
+ import { defineSpec, resolveRecipe, specToRecipe } from "../../core/index.js";
2
+ import { contract as c } from "../../tokens/index.js";
3
+ // The exact same values `product/richtext`'s own inline "@mention" span
4
+ // uses (`.crisp-rte-mention`, richtext.recipe.ts) — not re-measured against
5
+ // Attio independently (#428's own doc: no live @mention was reachable to
6
+ // read computed styles from, and typing one to create one is off-limits),
7
+ // reused verbatim from the one measured "brand" tag/chip this repo already
8
+ // has (Chip's own `tone="brand"`). Pulled out to its own top-level recipe,
9
+ // rather than importing richtext's, because `.crisp-rte-mention`'s CSS is
10
+ // nested under richtext's own root selector (`&`, i.e. native CSS
11
+ // nesting) — it only resolves inside a mounted `RichTextEditor`. A
12
+ // standalone consumer rendering a mention OUTSIDE an editor (a comment
13
+ // thread, a note preview list) needs the same look with no such ancestor,
14
+ // which is what this recipe is for. Kept deliberately un-pill-shaped (no
15
+ // fixed height, no ring) unlike Chip's own `brand` tone: this reads as
16
+ // tinted text flowing inside a sentence, the same as a live mention does
17
+ // mid-edit, not a standalone tag sitting in its own table cell.
18
+ const spec = defineSpec({
19
+ name: "mention-chip",
20
+ base: {
21
+ display: "inline",
22
+ borderRadius: c.radius.chip,
23
+ paddingInline: c.space["1"],
24
+ background: `color-mix(in srgb, ${c.bgBrandSolid} 14%, transparent)`,
25
+ color: `color-mix(in srgb, ${c.bgBrandSolid} 82%, ${c.fgPrimary})`,
26
+ fontWeight: c.weight.medium,
27
+ cursor: "default",
28
+ },
29
+ });
30
+ const config = specToRecipe(spec);
31
+ export const mentionChipRecipe = Object.assign(resolveRecipe(config), {
32
+ config,
33
+ });
34
+ export { spec };
35
+ export default config;
@@ -0,0 +1 @@
1
+ export * from "./personchip.js";
@@ -0,0 +1,47 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { contract as c } from "../../tokens/index.js";
3
+ import { Avatar } from "../avatar/index.js";
4
+ import { Chip } from "../chip/index.js";
5
+ // The same five hashed avatar tones DataTable's own `avatarTone="hashed"`
6
+ // picks from (datatable.tsx's private `TAG_TONES`, transcribed rather than
7
+ // imported — DataTable owns its own file, and this is a one-line pure
8
+ // function, not a visual value that could drift). `neutral`/`brand` are
9
+ // left out: `neutral` is Avatar's own no-photo default (wrong here, every
10
+ // name needs a colour to tell people apart) and `brand` is reserved for an
11
+ // entity that IS the record a cell points at (`RecordChip`'s own tone),
12
+ // which a person mention never is.
13
+ const HASH_TONES = ["brand", "orange", "cyan", "purple", "green"];
14
+ function hashTone(name) {
15
+ let h = 0;
16
+ for (let i = 0; i < name.length; i++)
17
+ h = (h * 31 + name.charCodeAt(i)) >>> 0;
18
+ return HASH_TONES[h % HASH_TONES.length];
19
+ }
20
+ function initials(name) {
21
+ const parts = name.trim().split(/\s+/).filter(Boolean);
22
+ if (parts.length === 0)
23
+ return "?";
24
+ return (parts[0][0] + (parts[1]?.[0] ?? "")).toUpperCase();
25
+ }
26
+ /**
27
+ * A person, as a chip: a small avatar (a photo, or hashed-colour initials)
28
+ * plus their name, truncating with an ellipsis when it doesn't fit — Attio's
29
+ * own actor chip (measured live on `techpartners`, Tasks page, "Assigned to"
30
+ * column): a 16px circular avatar in a neutral `Chip`, `gap: 3px` between
31
+ * avatar and name (now `Chip`'s own base — every multi-child `Chip` gets
32
+ * it). Every other value — the pill's height, padding, radius, ring and text
33
+ * — is already `Chip`'s, unchanged; `PersonChip` adds no recipe of its own.
34
+ *
35
+ * Ellipsis only, no `Tooltip` on hover (#623 — no Attio reference for one;
36
+ * a truncated cell there is revealed by its own click-to-expand, #579, a
37
+ * different, DataTable-specific interaction).
38
+ *
39
+ * Composes `Chip`'s `truncate` CSS directly rather than its string-only
40
+ * auto-wrap (children here is an avatar plus a name, not a single string),
41
+ * so the name gets its own `.crisp-chip-label` span while the avatar stays
42
+ * fixed-size.
43
+ */
44
+ export function PersonChip({ name, avatar, muted, className, ...props }) {
45
+ return (_jsxs(Chip, { tone: "neutral", truncate: true, className: className, ...props, children: [_jsx(Avatar, { size: "sm", shape: "circle", tone: avatar ? "neutral" : hashTone(name), children: avatar ? _jsx("img", { src: avatar, alt: "" }) : initials(name) }), _jsx("span", { className: "crisp-chip-label", style: muted ? { color: c.fgNeutralMuted } : undefined, children: name })] }));
46
+ }
47
+ PersonChip.displayName = "PersonChip";
@@ -12,11 +12,11 @@ export const Table = React.forwardRef(({ interactive, surface, className, wrappe
12
12
  // on the root for the same reason as Textarea: the wrapper is the box a
13
13
  // consumer sees and the scroll container they want to size. Native
14
14
  // table attributes still reach the <table> below.
15
- style, ...props }, ref) => (_jsx("div", { className: tableRecipe({
15
+ style, tableStyle, ...props }, ref) => (_jsx("div", { className: tableRecipe({
16
16
  interactive,
17
17
  surface,
18
18
  className: wrapperClassName,
19
- }), style: style, children: _jsx("div", { ...scrollProps, className: `crisp-table-scroll${scrollProps?.className ? ` ${scrollProps.className}` : ""}`, children: _jsx("table", { ref: ref, className: className, ...props, children: children }) }) })));
19
+ }), style: style, children: _jsx("div", { ...scrollProps, className: `crisp-table-scroll${scrollProps?.className ? ` ${scrollProps.className}` : ""}`, children: _jsx("table", { ref: ref, className: className, style: tableStyle, ...props, children: children }) }) })));
20
20
  Table.displayName = "Table";
21
21
  export const TableHeader = React.forwardRef((props, ref) => _jsx("thead", { ref: ref, ...props }));
22
22
  TableHeader.displayName = "TableHeader";
@@ -8,18 +8,30 @@ import { contract as c } from "../../tokens/index.js";
8
8
  const config = defineRecipe({
9
9
  name: "table",
10
10
  base: `display: block; box-sizing: border-box; background: ${c.bgSurface};
11
- & .crisp-table-scroll { overflow-x: auto; }
11
+ /* #554: both axes -- a consumer bounds the height via \`scrollProps\`
12
+ (style/className on this element), and the header/footer below pin
13
+ inside it exactly the way Attio's own grid does ("Both the header and
14
+ the summary row are sticky, on the scroll container rather than the
15
+ cells" -- docs/study/attio-table.md). Unbounded, \`overflow: auto\`
16
+ shows no vertical scrollbar and nothing sticks, so this is a no-op
17
+ for every existing consumer that never set a height. */
18
+ & .crisp-table-scroll { overflow: auto; }
12
19
  & table { width: 100%; border-collapse: separate; border-spacing: 0; font-family: ${c.font.sans}; font-size: ${c.text.lg}; color: ${c.fgPrimary}; }
13
20
  /* #465: 12px, not crisp's old 8px -- measured on a live header cell
14
21
  (padding: 0 12px) and independently on a body cell's text inset
15
22
  (getBoundingClientRect delta from the cell edge). Matches
16
23
  ColumnCalc's own already-correct 12px trigger inset. */
17
24
  & th, & td { position: relative; height: ${c.size.tableRow}; padding: 0 ${c.space["3"]}; border-bottom: 1px solid ${c.borderSubtle}; border-inline-end: 1px solid ${c.borderSubtle}; text-align: start; vertical-align: middle; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
18
- & th { height: ${c.size.tableHeader}; background: ${c.bgSurface}; border-top: 1px solid ${c.borderSubtle}; font-size: ${c.text.lg}; font-weight: ${c.weight.medium}; letter-spacing: -0.14px; color: color-mix(in srgb, ${c.fgPrimary} 63%, transparent); user-select: none; }
25
+ /* #554: sticky on the vertical axis, mirroring RecordsTable's own
26
+ \`.crisp-recordstable-th\`. z-index 2 sits above a plain body cell (auto)
27
+ and below a sticky-column corner cell (DataTable's \`th[data-sticky]\`,
28
+ z-index 3), so the corner always wins. */
29
+ & th { position: sticky; top: 0; z-index: 2; height: ${c.size.tableHeader}; background: ${c.bgSurface}; border-top: 1px solid ${c.borderSubtle}; font-size: ${c.text.lg}; font-weight: ${c.weight.medium}; letter-spacing: -0.14px; color: color-mix(in srgb, ${c.fgPrimary} 63%, transparent); user-select: none; }
19
30
  & td { color: ${c.fgPrimary}; background: ${c.bgSurface}; }
20
31
  & th:last-child, & td:last-child { border-inline-end: none; }
21
32
  & tbody tr:last-child td { border-bottom: none; }
22
- & tfoot td { border-top: 1px solid ${c.borderSubtle}; border-bottom: 1px solid ${c.borderSubtle}; font-weight: ${c.weight.medium}; color: ${c.fgMuted}; }
33
+ /* #554: sticky to the bottom, mirroring \`.crisp-recordstable-sum\`. */
34
+ & tfoot td { position: sticky; bottom: 0; z-index: 2; border-top: 1px solid ${c.borderSubtle}; border-bottom: 1px solid ${c.borderSubtle}; font-weight: ${c.weight.medium}; color: ${c.fgMuted}; }
23
35
  & caption { padding: ${c.space["2"]} ${c.space["2"]}; text-align: start; font-size: ${c.text.sm}; color: ${c.fgMuted}; caption-side: bottom; }
24
36
  & td[data-align="right"], & th[data-align="right"] { text-align: end; }
25
37
  & td[data-align="center"], & th[data-align="center"] { text-align: center; }`,
@@ -8,7 +8,7 @@ import { useTablistKeyboard } from "./tabs-keyboard.js";
8
8
  * Home/End jump); activation follows focus. Controlled via
9
9
  * `value`/`onValueChange`, or uncontrolled from `defaultValue`.
10
10
  */
11
- export function Tabs({ tabs, value, defaultValue, onValueChange, leading, children, className, "aria-label": ariaLabel, ...rest }) {
11
+ export function Tabs({ tabs, value, defaultValue, onValueChange, leading, children, className, fill, "aria-label": ariaLabel, ...rest }) {
12
12
  // Scoped to this instance: `tab-${value}` and `panel-${value}` are global,
13
13
  // so two tab bars sharing a value mint the same id twice and every
14
14
  // aria-labelledby resolves to the first one (#227).
@@ -47,7 +47,7 @@ export function Tabs({ tabs, value, defaultValue, onValueChange, leading, childr
47
47
  select,
48
48
  listRef,
49
49
  });
50
- return (_jsxs("div", { className: tabsRecipe({ className }), ...rest, children: [_jsxs("div", { ref: listRef, className: "crisp-tabs-list", role: "tablist", "aria-label": ariaLabel, onKeyDown: onKeyDown, children: [leading && _jsx("span", { className: "crisp-tabs-leading", children: leading }), tabs.map((t) => {
50
+ return (_jsxs("div", { className: tabsRecipe({ fill, className }), ...rest, children: [_jsxs("div", { ref: listRef, className: "crisp-tabs-list", role: "tablist", "aria-label": ariaLabel, onKeyDown: onKeyDown, children: [leading && _jsx("span", { className: "crisp-tabs-leading", children: leading }), tabs.map((t) => {
51
51
  const selected = t.value === active;
52
52
  return (_jsxs("button", { type: "button", role: "tab", id: tabId(t.value), "aria-selected": selected, "aria-controls": children && selected ? panelId(t.value) : undefined, tabIndex: tabIndexFor(t.value), "data-value": t.value, "data-selected": selected ? "" : undefined, disabled: t.disabled, className: "crisp-tabs-tab", onClick: () => select(t.value), children: [t.icon, _jsx("span", { className: "tabs-tab-label", children: t.label }), t.badge != null && (_jsx("span", { className: "crisp-tabs-tab-badge", children: t.badge }))] }, t.value));
53
53
  }), indicator && (_jsx("span", { className: "crisp-tabs-indicator", style: {
@@ -12,6 +12,22 @@ import { contract as c } from "../../tokens/index.js";
12
12
  // a 16px leading icon and an optional count badge. Measured from the live
13
13
  // app: bar `padding: 0 12px 8px`, 28px pills, 4/8 padding, 6px icon-label
14
14
  // gap, 14/500 labels, 10px badges (rgba(0,0,0,.04)).
15
+ //
16
+ // The panel's own `padding-top` (`c.space["8"]`, 32px) is measured
17
+ // separately, live, on a company record page (Overview tab): the gap from
18
+ // the tab row's own bottom to the content below it (the "Highlights"
19
+ // heading) is a clean 32px with zero margin/padding contributed by either
20
+ // boundary — not the 12px a GRC consumer had been carrying locally, which
21
+ // turned out (checking its own history) to have been measured against that
22
+ // app's own demo rendering, never against Attio itself.
23
+ //
24
+ // The panel is also `display: flex; flex-direction: column` (structural,
25
+ // not measured): a consumer's own content — a table sizing itself to "the
26
+ // rest of the space" — needs a flex-column ancestor with a real height to
27
+ // grow into. A GRC consumer used to supply that itself, wrapping every
28
+ // `children(active)` in its own `<div className="flex h-full min-h-0
29
+ // flex-col">`; dropping that wrapper in favour of `fill` without this would
30
+ // have silently broken every such table the moment the wrapper went away.
15
31
  const config = defineRecipe({
16
32
  name: "tabs",
17
33
  base: `
@@ -29,11 +45,22 @@ const config = defineRecipe({
29
45
  & .crisp-tabs-tab-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 16px; height: 16px; padding: 0 ${c.space["1"]}; border-radius: ${c.radius.sm}; background: color-mix(in srgb, ${c.fgPrimary} 6%, transparent); font-size: ${c.text.xs}; font-weight: ${c.weight.medium}; color: ${c.fgMuted}; }
30
46
  & .crisp-tabs-tab[data-selected] .crisp-tabs-tab-badge { color: ${c.fgPrimary}; }
31
47
  & .crisp-tabs-indicator { position: absolute; bottom: 0; left: 0; z-index: 1; height: 1px; background: ${c.fgPrimary}; border-radius: 2px 2px 0 0; transition: transform .2s cubic-bezier(.4,0,.2,1), width .2s cubic-bezier(.4,0,.2,1); pointer-events: none; }
32
- & .crisp-tabs-panel { flex: 1; min-height: 0; }
48
+ & .crisp-tabs-panel { display: flex; flex-direction: column; flex: 1; min-height: 0; padding-top: ${c.space["8"]}; }
33
49
  & .crisp-tabs-panel:focus-visible { outline: none; }
34
50
  `,
35
- variants: {},
36
- defaultVariants: {},
51
+ variants: {
52
+ // Opt-in: a `Tabs` dropped into a document-flow layout (a docs page, a
53
+ // demo) should size to its content like anything else. `fill` is for a
54
+ // consumer embedding it in a fixed-height shell — a record page, a full
55
+ // screen — matching the record page's own tab area, which visibly
56
+ // fills all available height (the same live check this file's panel
57
+ // measurement came from).
58
+ fill: {
59
+ true: `height: 100%;`,
60
+ false: "",
61
+ },
62
+ },
63
+ defaultVariants: { fill: false },
37
64
  compoundVariants: [],
38
65
  });
39
66
  export const tabsRecipe = Object.assign(resolveRecipe(config), { config });
@@ -0,0 +1,2 @@
1
+ export { Text } from "./text.js";
2
+ export { textRecipe } from "./text.recipe.js";
@@ -0,0 +1,19 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import { Primitive } from "../../core/index.js";
4
+ import { textRecipe } from "./text.recipe.js";
5
+ /**
6
+ * Plain coloured text — `tone="default"` (the app's primary text colour) or
7
+ * `"muted"` — for the runs of text that aren't a Badge, a Chip, a link, or
8
+ * any of crisp's other typed things: a cell's own yes/no, an empty-state
9
+ * caption, a secondary line under a heading. One component so every such
10
+ * spot reaches for crisp's own tokens instead of each hand-writing its own
11
+ * colour utility for the same two values.
12
+ *
13
+ * Renders a `<span>` (inherits its size/weight/line-height from context —
14
+ * see `text.recipe.ts`'s doc for why `Text` sets none of those itself).
15
+ */
16
+ export const Text = React.forwardRef(({ tone, className, asChild, children, ...props }, ref) => {
17
+ return (_jsx(Primitive.span, { ref: ref, asChild: asChild, className: textRecipe({ tone, className }), ...props, children: children }));
18
+ });
19
+ Text.displayName = "Text";
@@ -0,0 +1,31 @@
1
+ import { defineSpec, resolveRecipe, specToRecipe } from "../../core/index.js";
2
+ import { contract as c } from "../../tokens/index.js";
3
+ // No new measurement — `tone` just names two colour tokens every other
4
+ // component already carries (`fgPrimary`/`fgNeutralMuted`), for the plain
5
+ // runs of text that aren't any of crisp's other typed things (a Badge, a
6
+ // Chip, a link). Font size/weight/line-height are deliberately NOT set here:
7
+ // unlike Badge or Chip, plain text has no single measured size across the
8
+ // app (a table cell, a label, a caption are all different sizes) — `Text`
9
+ // inherits type from its context, the same choice `MiddleTruncate` makes for
10
+ // the same reason (see its own doc).
11
+ const spec = defineSpec({
12
+ name: "text",
13
+ // `display: inline` -- a span's own default, stated rather than left
14
+ // implicit: an EMPTY base compiles to an empty string, and
15
+ // `core/resolve.ts`'s `if (base) classes.push(prefixedName)` treats that
16
+ // falsy string as "no base rule", which drops the `crisp-text` class
17
+ // itself, silently, from every instance (caught by this file's own
18
+ // tests going red on that exact class).
19
+ base: { display: "inline" },
20
+ variants: {
21
+ tone: {
22
+ default: { color: c.fgPrimary },
23
+ muted: { color: c.fgNeutralMuted },
24
+ },
25
+ },
26
+ defaultVariants: { tone: "default" },
27
+ });
28
+ const config = specToRecipe(spec);
29
+ export const textRecipe = Object.assign(resolveRecipe(config), { config });
30
+ export { spec };
31
+ export default config;
@@ -48,6 +48,23 @@ export function isInsideLaterFloatingLayer(target, self) {
48
48
  }
49
49
  return false;
50
50
  }
51
+ /**
52
+ * True when a layer is registered strictly after `self` — i.e. `self` has
53
+ * spawned a still-open nested layer of its own (a row's kebab `Menu` inside
54
+ * `SavedViews`' own panel, e.g.).
55
+ *
56
+ * This is `hasOpenFloatingLayer`'s sibling for a caller that DOES register
57
+ * itself on this stack: `hasOpenFloatingLayer` reads "is the stack
58
+ * non-empty", which is always true for such a caller once its own layer is
59
+ * open — checking that before closing on Escape would defer to itself
60
+ * forever and Escape would never close it. `self`'s own position on the
61
+ * stack has to be excluded from the count, which `hasOpenFloatingLayer`
62
+ * cannot do since it takes no argument.
63
+ */
64
+ export function hasNestedFloatingLayer(self) {
65
+ const selfIndex = self ? stack.indexOf(self) : -1;
66
+ return selfIndex + 1 < stack.length;
67
+ }
51
68
  /**
52
69
  * True when at least one `Select`/`MultiSelect`/`Menu`/`Popover`/
53
70
  * `ContextMenu` is currently open, anywhere (#361).
@@ -5,7 +5,7 @@ export { defineRecipe } from "./define.js";
5
5
  export { withDescribedBy } from "./dialog-description.js";
6
6
  export { isRtlElement } from "./direction.js";
7
7
  export { FloatingPortal } from "./floating-portal.js";
8
- export { hasOpenFloatingLayer, isInsideLaterFloatingLayer, registerFloatingLayer, } from "./floating-stack.js";
8
+ export { hasNestedFloatingLayer, hasOpenFloatingLayer, isInsideLaterFloatingLayer, registerFloatingLayer, } from "./floating-stack.js";
9
9
  export { formValueStyle } from "./form-value.js";
10
10
  export { CrispMessagesProvider, useCrispComponentDefaults, useCrispMessage, useCrispMessages, } from "./i18n.js";
11
11
  export { matchMentionTrigger, } from "./mention.js";
package/dist/esm/index.js CHANGED
@@ -33,9 +33,11 @@ export * from "./components/heatmapchart/index.js";
33
33
  export * from "./components/input/index.js";
34
34
  export * from "./components/kbd/index.js";
35
35
  export * from "./components/linechart/index.js";
36
+ export * from "./components/mentionchip/index.js";
36
37
  export * from "./components/menu/index.js";
37
38
  export * from "./components/middletruncate/index.js";
38
39
  export * from "./components/multiselect/index.js";
40
+ export * from "./components/personchip/index.js";
39
41
  export * from "./components/popover/index.js";
40
42
  export * from "./components/progress/index.js";
41
43
  export * from "./components/quantitypicker/index.js";
@@ -53,6 +55,7 @@ export * from "./components/switch/index.js";
53
55
  export * from "./components/table/index.js";
54
56
  export * from "./components/tabletoolbar/index.js";
55
57
  export * from "./components/tabs/index.js";
58
+ export * from "./components/text/index.js";
56
59
  export * from "./components/textarea/index.js";
57
60
  export * from "./components/textlink/index.js";
58
61
  export * from "./components/timepicker/index.js";
@@ -72,7 +75,9 @@ export * from "./product/board/index.js";
72
75
  export * from "./product/command/index.js";
73
76
  export * from "./product/entitycard/index.js";
74
77
  export * from "./product/flowcanvas/index.js";
78
+ export * from "./product/formdialog/index.js";
75
79
  export * from "./product/headerbar/index.js";
80
+ export * from "./product/importdialog/index.js";
76
81
  export * from "./product/inbox/index.js";
77
82
  export * from "./product/listpage/index.js";
78
83
  export * from "./product/navitem/index.js";
@@ -81,11 +86,14 @@ export * from "./product/recordpage/index.js";
81
86
  export * from "./product/recordpanel/index.js";
82
87
  export * from "./product/recordstable/index.js";
83
88
  export * from "./product/richtext/index.js";
89
+ export * from "./product/savedviews/index.js";
84
90
  export * from "./product/settingssection/index.js";
85
91
  export * from "./product/settingsshell/index.js";
86
92
  export * from "./product/sidebar/index.js";
87
93
  export * from "./product/statlist/index.js";
88
94
  export * from "./product/tasklist/index.js";
95
+ export * from "./product/taskstable/index.js";
96
+ export * from "./product/themepicker/index.js";
89
97
  export * from "./product/trialbanner/index.js";
90
98
  export * from "./product/viewtoggle/index.js";
91
99
  // design tokens + recipe core (for building custom components)
@@ -0,0 +1,134 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import { Button } from "../../components/button/index.js";
4
+ import { Dialog, DialogBody, DialogFooter } from "../../components/dialog/index.js";
5
+ import { Switch } from "../../components/switch/index.js";
6
+ import { useCrispMessage, usePrefersReducedMotion } from "../../core/index.js";
7
+ import { formdialogRecipe } from "./formdialog.recipe.js";
8
+ /**
9
+ * Whether a non-required field inside a `createMode` `FormDialog` gets
10
+ * `FormField`'s "(optional)" marker automatically — Attio suffixes every
11
+ * optional label this way on its own "New Company" dialog (crisp#548). A
12
+ * consumer's own field wrapper reads this once (`useFormDialogOptional`);
13
+ * `false` outside any `FormDialog`, or on an edit dialog.
14
+ */
15
+ const OptionalLabelsContext = React.createContext(false);
16
+ /** See `OptionalLabelsContext`. Call from a field wrapper as
17
+ * `optional={useFormDialogOptional(required)}`. */
18
+ export function useFormDialogOptional(required) {
19
+ return React.useContext(OptionalLabelsContext) && !required;
20
+ }
21
+ /**
22
+ * A modal for a create or edit form — crisp's own `Dialog`/`DialogBody`/
23
+ * `DialogFooter`, plus everything a record-creating form needs beyond plain
24
+ * chrome (crisp#548, moved out of internal/grc's own `forms.tsx`, called
25
+ * from nine places there): dirty tracking with a discard-changes confirm
26
+ * (any click/change/input inside the form marks it dirty; Escape/backdrop/
27
+ * header-×/Cancel all route through one `requestClose`, which only asks
28
+ * when something actually changed — a stacked crisp `Dialog`), a
29
+ * `beforeunload` guard for a reload or external navigation, and
30
+ * scroll-to-first-invalid-field once a submit attempt finishes (crisp's own
31
+ * `FormField` already marks every control `aria-invalid`, so this needs no
32
+ * per-field wiring). A record needs several fields at once to be valid,
33
+ * which is why records are not created by inline editing.
34
+ */
35
+ export function FormDialog({ title, submitLabel, onSubmit, onClose, busy, error, children, wide = false, size, createMode = false, createMoreLabel: createMoreLabelProp, cancelLabel: cancelLabelProp, discardTitle: discardTitleProp, discardConfirmLabel: discardConfirmLabelProp, discardCancelLabel: discardCancelLabelProp, className, ...rest }) {
36
+ const createMoreLabel = useCrispMessage(createMoreLabelProp, "Create more");
37
+ const createRecordLabel = useCrispMessage(undefined, "Create record");
38
+ const cancelLabel = useCrispMessage(cancelLabelProp, "Cancel");
39
+ const discardTitle = useCrispMessage(discardTitleProp, "Discard changes? Your changes will be lost.");
40
+ const discardConfirmLabel = useCrispMessage(discardConfirmLabelProp, "Discard");
41
+ const discardCancelLabel = useCrispMessage(discardCancelLabelProp, "Keep editing");
42
+ const formId = React.useId();
43
+ const formRef = React.useRef(null);
44
+ const switchId = React.useId();
45
+ // Read at submit time (click, Enter, or Cmd/Ctrl+Enter alike — all three
46
+ // go through the one <form onSubmit>), so `onSubmit` never has to reach
47
+ // back into the footer to know which way the switch was left.
48
+ const [createMore, setCreateMore] = React.useState(false);
49
+ const [confirmingDiscard, setConfirmingDiscard] = React.useState(false);
50
+ // Dirtiness is detected generically (any click, change or text input
51
+ // anywhere inside the form), not wired per field: React delivers these as
52
+ // synthetic events through the REACT tree, so a Select's portaled listbox
53
+ // or a DatePicker's portaled calendar — neither a DOM descendant of
54
+ // <form> — still reaches this handler, the same way a click inside either
55
+ // already reaches a submit handler on the form itself. A stray click
56
+ // (opening a picker without choosing anything) can mark this dirty when
57
+ // nothing actually changed; an occasional unneeded confirmation is a far
58
+ // smaller cost than silently discarding real, typed work.
59
+ const dirtyRef = React.useRef(false);
60
+ const markDirty = () => {
61
+ dirtyRef.current = true;
62
+ };
63
+ const requestClose = () => {
64
+ if (!dirtyRef.current) {
65
+ onClose();
66
+ return;
67
+ }
68
+ setConfirmingDiscard(true);
69
+ };
70
+ // A reload or an external navigation never reaches `requestClose` above —
71
+ // needs its own listener, live only while the dialog is mounted.
72
+ React.useEffect(() => {
73
+ const onBeforeUnload = (e) => {
74
+ if (!dirtyRef.current)
75
+ return;
76
+ e.preventDefault();
77
+ e.returnValue = "";
78
+ };
79
+ window.addEventListener("beforeunload", onBeforeUnload);
80
+ return () => window.removeEventListener("beforeunload", onBeforeUnload);
81
+ }, []);
82
+ // A refused save that leaves a field invalid is the one thing on screen
83
+ // that matters, and on a long form the field can be several screens above
84
+ // the submit button — a failed submit that never moves the viewport reads
85
+ // as nothing happened. `FormField` already marks every field it was given
86
+ // an error for with `aria-invalid`, on every control kind, so this needs
87
+ // no per-field wiring of its own — it just goes looking for the first one
88
+ // once a submit attempt finishes.
89
+ // `usePrefersReducedMotion`, not CSS `scroll-behavior: smooth`: this is JS
90
+ // motion driven by an explicit `behavior` argument, which CSS cannot reach.
91
+ const reduceMotion = usePrefersReducedMotion();
92
+ const wasBusy = React.useRef(false);
93
+ React.useEffect(() => {
94
+ if (wasBusy.current && !busy) {
95
+ const invalid = formRef.current?.querySelector('[aria-invalid="true"]');
96
+ if (invalid) {
97
+ invalid.scrollIntoView({
98
+ block: "center",
99
+ ...(reduceMotion
100
+ ? { behavior: "auto" }
101
+ : { behavior: "smooth" }),
102
+ });
103
+ invalid.focus({ preventScroll: true });
104
+ }
105
+ }
106
+ wasBusy.current = Boolean(busy);
107
+ }, [busy, reduceMotion]);
108
+ // Attached to the document, not the form: a portaled Popover (a
109
+ // DatePicker's calendar, a Select's listbox) renders outside the <form>
110
+ // element in the DOM, and a person pressing the shortcut while one of
111
+ // those is open should still submit the dialog it belongs to.
112
+ React.useEffect(() => {
113
+ if (!createMode)
114
+ return;
115
+ const onKeyDown = (e) => {
116
+ if (busy)
117
+ return;
118
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
119
+ e.preventDefault();
120
+ formRef.current?.requestSubmit();
121
+ }
122
+ };
123
+ document.addEventListener("keydown", onKeyDown);
124
+ return () => document.removeEventListener("keydown", onKeyDown);
125
+ }, [createMode, busy]);
126
+ return (_jsxs(_Fragment, { children: [_jsxs(Dialog, { open: true, onOpenChange: (open) => !open && requestClose(), title: title, width: size ?? (wide ? 760 : 640), className: formdialogRecipe({ className }), ...rest, children: [_jsxs(DialogBody, { children: [_jsx(OptionalLabelsContext.Provider, { value: createMode, children: _jsx("form", { ref: formRef, id: formId, className: "crisp-formdialog-form", onSubmit: (e) => {
127
+ e.preventDefault();
128
+ onSubmit(createMore);
129
+ }, onChange: markDirty, onInput: markDirty, onClick: markDirty, children: children }) }), error != null && _jsx("p", { className: "crisp-formdialog-error", children: error })] }), _jsx(DialogFooter, { children: createMode ? (_jsxs("div", { className: "crisp-formdialog-createfoot", children: [_jsxs("span", { className: "crisp-dialog-more", children: [_jsx(Switch, { id: switchId, checked: createMore, onCheckedChange: setCreateMore, "aria-label": createMoreLabel }), _jsx("label", { className: "crisp-formdialog-more-label", htmlFor: switchId, children: createMoreLabel })] }), _jsxs("button", { type: "submit", form: formId, className: "crisp-dialog-submit", disabled: busy, children: [createRecordLabel, _jsxs("span", { className: "crisp-dialog-submit-key", "aria-hidden": "true", children: [_jsx("kbd", { children: "\u2318" }), _jsx("kbd", { children: "\u21B5" })] })] })] })) : (_jsxs(_Fragment, { children: [_jsx(Button, { type: "button", intent: "neutral", onClick: requestClose, children: cancelLabel }), _jsx(Button, { type: "submit", form: formId, intent: "primary", loading: busy, children: submitLabel })] })) })] }), _jsx(Dialog, { open: confirmingDiscard, onOpenChange: (open) => !open && setConfirmingDiscard(false), title: discardTitle, width: 400, children: _jsxs(DialogFooter, { children: [_jsx(Button, { type: "button", intent: "neutral", onClick: () => setConfirmingDiscard(false), children: discardCancelLabel }), _jsx(Button, { type: "button", intent: "danger", onClick: () => {
130
+ setConfirmingDiscard(false);
131
+ onClose();
132
+ }, children: discardConfirmLabel })] }) })] }));
133
+ }
134
+ FormDialog.displayName = "FormDialog";
@@ -0,0 +1,23 @@
1
+ import { defineRecipe, resolveRecipe } from "../../core/index.js";
2
+ import { contract as c } from "../../tokens/index.js";
3
+ // The createMode footer's switch+button cluster — measured live off Attio's
4
+ // own "New Company" create dialog (crisp#548): a 16px gap between the
5
+ // "Create more" switch group and the submit button, different from
6
+ // `.crisp-dialog-footer`'s own 12px (the gap every OTHER dialog's
7
+ // Cancel/Save pair uses). One wrapper carries the 16px itself rather than
8
+ // widening the shared token, so it doesn't stretch to the footer's full
9
+ // width — `.crisp-dialog-footer`'s `justify-content: flex-end` still pushes
10
+ // the whole cluster to the right edge as one snug group.
11
+ const config = defineRecipe({
12
+ name: "formdialog",
13
+ base: `& .crisp-formdialog-createfoot { display: flex; align-items: center; gap: ${c.space["4"]}; }
14
+ & .crisp-formdialog-more-label { font-size: ${c.text.md}; color: ${c.fgMuted}; cursor: pointer; }
15
+ & .crisp-formdialog-error { font-size: ${c.text.md}; color: ${c.fgDanger}; margin: 0; }
16
+ & .crisp-formdialog-form { display: grid; gap: ${c.space["3"]}; }
17
+ `,
18
+ variants: {},
19
+ defaultVariants: {},
20
+ compoundVariants: [],
21
+ });
22
+ export const formdialogRecipe = resolveRecipe(config);
23
+ export default config;
@@ -0,0 +1,2 @@
1
+ export { FormDialog, useFormDialogOptional, } from "./formdialog.js";
2
+ export { formdialogRecipe } from "./formdialog.recipe.js";