@crisp-ui-kit/crisp 0.51.0 → 0.52.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.
Files changed (40) hide show
  1. package/dist/cjs/core/caret-rect.js +73 -0
  2. package/dist/cjs/core/index.js +7 -1
  3. package/dist/cjs/core/mention.js +32 -0
  4. package/dist/cjs/core/use-mention-trigger.js +124 -0
  5. package/dist/cjs/product/recordpanel/recordpanel.js +13 -81
  6. package/dist/cjs/product/recordpanel/recordpanel.recipe.js +53 -44
  7. package/dist/cjs/product/recordstable/editable-field.js +11 -8
  8. package/dist/cjs/product/recordstable/recordstable.tag.js +8 -3
  9. package/dist/cjs/product/richtext/index.js +2 -1
  10. package/dist/cjs/product/richtext/richtext.js +440 -23
  11. package/dist/cjs/product/richtext/richtext.recipe.js +59 -9
  12. package/dist/esm/core/caret-rect.js +70 -0
  13. package/dist/esm/core/index.js +3 -0
  14. package/dist/esm/core/mention.js +29 -0
  15. package/dist/esm/core/use-mention-trigger.js +88 -0
  16. package/dist/esm/product/recordpanel/index.js +1 -1
  17. package/dist/esm/product/recordpanel/recordpanel.js +15 -83
  18. package/dist/esm/product/recordpanel/recordpanel.recipe.js +54 -45
  19. package/dist/esm/product/recordstable/editable-field.js +11 -8
  20. package/dist/esm/product/recordstable/recordstable.tag.js +8 -3
  21. package/dist/esm/product/richtext/index.js +1 -1
  22. package/dist/esm/product/richtext/richtext.js +439 -22
  23. package/dist/esm/product/richtext/richtext.recipe.js +60 -10
  24. package/dist/recordpanel.css +47 -41
  25. package/dist/recordpanel.layered.css +47 -41
  26. package/dist/richtext.css +31 -5
  27. package/dist/richtext.layered.css +31 -5
  28. package/dist/styles.css +79 -47
  29. package/dist/styles.layered.css +79 -47
  30. package/dist/types/core/caret-rect.d.ts +16 -0
  31. package/dist/types/core/index.d.ts +3 -0
  32. package/dist/types/core/mention.d.ts +27 -0
  33. package/dist/types/core/use-mention-trigger.d.ts +41 -0
  34. package/dist/types/product/recordpanel/index.d.ts +1 -1
  35. package/dist/types/product/recordpanel/recordpanel.d.ts +81 -27
  36. package/dist/types/product/recordstable/editable-field.d.ts +11 -8
  37. package/dist/types/product/recordstable/recordstable.tag.d.ts +8 -3
  38. package/dist/types/product/richtext/index.d.ts +1 -1
  39. package/dist/types/product/richtext/richtext.d.ts +168 -7
  40. package/package.json +1 -1
@@ -2239,52 +2239,58 @@
2239
2239
  position: absolute; top: var(--crisp-space-2); inset-inline-end: var(--crisp-space-2); bottom: var(--crisp-space-2); z-index: 20; display: flex; flex-direction: column; overflow: hidden; width: 420px; max-width: calc(92% - var(--crisp-space-2)); background: var(--crisp-bg-surface); border-radius: var(--crisp-radius-xl); box-shadow: inset 0 0 0 1px var(--crisp-ring-popover), 0 0 0 1px var(--crisp-shadow-popover1), 0 4px 8px -4px var(--crisp-shadow-popover2), 0 4px 12px -2px var(--crisp-shadow-popover3); font-family: var(--crisp-font-sans); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; transform: translateX(0); transition: transform .2s ease;
2240
2240
  @starting-style { transform: translateX(calc(100% + var(--crisp-space-2))); }
2241
2241
  & * { box-sizing: border-box; }
2242
- & .crisp-recordpanel-header { display: flex; align-items: center; gap: var(--crisp-space-2); height: 52px; flex-shrink: 0; padding: 0 var(--crisp-space-3); border-bottom: 1px solid var(--crisp-border-subtle); }
2242
+ /* #448: measured live on app.attio.com's peek -- a toolbar row above
2243
+ the identity header: Close, then Previous/Next, on the left; "Open
2244
+ record" on the right. 49px tall, 8px/10px padding, a hairline border
2245
+ beneath (the same borderSubtle every other panel/row edge here
2246
+ already uses). Close keeps the existing transparent ghost button;
2247
+ Previous/Next/Open record are a distinct field-elevation style
2248
+ (white fill, e.field's own ring+shadow, solid icon) -- measured as genuinely different chrome from Close, not a reuse. */
2249
+ & .crisp-recordpanel-toolbar { display: flex; align-items: center; height: 49px; flex-shrink: 0; padding: var(--crisp-space-2) var(--crisp-space-2-5); border-bottom: 1px solid var(--crisp-border-subtle); }
2250
+ & .crisp-recordpanel-toolbar-spacer { flex: 1; }
2251
+ & .crisp-recordpanel-pager { display: flex; align-items: center; gap: var(--crisp-space-1-5); margin-inline-start: var(--crisp-space-3); }
2252
+ & .crisp-recordpanel-iconbtn { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; flex-shrink: 0; padding: 0; border: none; border-radius: var(--crisp-radius-md); background: transparent; color: color-mix(in srgb, var(--crisp-fg-primary) 55%, transparent); font-family: inherit; cursor: pointer; transition: background-color .12s; }
2253
+ & .crisp-recordpanel-iconbtn:hover { background: var(--crisp-bg-raised); color: var(--crisp-fg-primary); }
2254
+ & .crisp-recordpanel-navbtn { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; flex-shrink: 0; padding: 0; border: none; border-radius: var(--crisp-radius-md); background: var(--crisp-bg-surface); box-shadow: inset 0 0 0 1px var(--crisp-ring-neutral), 0 0 2px rgba(28,40,64,0.18), 0 1px 3px rgba(0,0,0,0.04); color: var(--crisp-fg-primary); font-family: inherit; cursor: pointer; }
2255
+ & .crisp-recordpanel-navbtn:hover:not(:disabled) { background: var(--crisp-bg-raised); }
2256
+ & .crisp-recordpanel-navbtn:disabled { opacity: 0.4; cursor: not-allowed; }
2257
+ & .crisp-recordpanel-header { display: flex; align-items: center; gap: var(--crisp-space-2); height: 52px; flex-shrink: 0; padding: 0 var(--crisp-space-3); }
2243
2258
  & .crisp-recordpanel-fav { width: 22px; height: var(--crisp-size-chip); border-radius: var(--crisp-radius-sm); flex-shrink: 0; object-fit: cover; background: var(--crisp-bg-raised); }
2244
2259
  & .crisp-recordpanel-title { flex: 1; min-width: 0; font-size: var(--crisp-text-xl); font-weight: var(--crisp-weight-semibold); letter-spacing: -0.2px; color: var(--crisp-fg-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2245
- & .crisp-recordpanel-iconbtn { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; flex-shrink: 0; padding: 0; border: none; border-radius: var(--crisp-radius-md); background: transparent; color: var(--crisp-fg-muted); font-family: inherit; cursor: pointer; transition: background-color .12s; }
2246
- & .crisp-recordpanel-iconbtn:hover { background: var(--crisp-bg-raised); color: var(--crisp-fg-primary); }
2247
2260
  & .crisp-recordpanel-body { flex: 1; min-height: 0; overflow-y: auto; padding: var(--crisp-space-2) var(--crisp-space-3); }
2248
- & .crisp-recordpanel-sectionlabel { font-size: var(--crisp-text-sm); font-weight: var(--crisp-weight-medium); color: var(--crisp-fg-muted); padding: var(--crisp-space-2) var(--crisp-space-1) var(--crisp-space-1); }
2249
- & .crisp-recordpanel-field { display: flex; align-items: flex-start; gap: var(--crisp-space-2); min-height: 32px; padding: var(--crisp-space-1) var(--crisp-space-1); border-radius: var(--crisp-radius-md); }
2250
- & .crisp-recordpanel-field:hover { background: color-mix(in srgb, var(--crisp-fg-primary) 3%, transparent); }
2251
- & .crisp-recordpanel-field-label { display: flex; align-items: center; gap: var(--crisp-space-1-5); width: 132px; flex-shrink: 0; padding-top: 3px; color: var(--crisp-fg-muted); font-size: var(--crisp-text-md); }
2252
- & .crisp-recordpanel-field-label svg { flex-shrink: 0; }
2253
- & .crisp-recordpanel-field-value { flex: 1; min-width: 0; display: flex; flex-wrap: wrap; align-items: center; gap: var(--crisp-space-1); padding-top: 3px; font-size: var(--crisp-text-lg); color: var(--crisp-fg-primary); }
2254
- & .crisp-recordpanel-field-value[data-editable] { cursor: pointer; }
2255
- /* the click-to-edit trigger is a real <button> (keyboard-operable), reset back to plain inline text and made a flex-wrap container of its own -- FieldValue can render several chips (tags), which still need to wrap the way they did before this had a button around them at all. It wraps only FieldValue, never the error slot beside it (see the component's own comment) -- NOT `display: contents`, which
2256
- drops a button's accessible semantics in some engines. */
2257
- & .crisp-recordpanel-edit-trigger { display: inline-flex; flex-wrap: wrap; align-items: center; gap: var(--crisp-space-1); margin: 0; padding: 0; border: none; background: none; text-align: start; font: inherit; color: inherit; cursor: pointer; }
2258
- & .crisp-recordpanel-empty { color: var(--crisp-fg-muted); }
2259
- /* #431: measured live on app.attio.com's record side panel -- clicking a
2260
- field's value opens a plain, borderless field exactly filling the value's own box (no border, no background, no box-shadow), same font
2261
- as the value it replaces (hence `font: inherit`, not a restated
2262
- size/weight). A field row's own hover tint above already carries the "this is interactive" affordance; the pending/error treatment isn't
2263
- Attio-measured -- it reuses the same tokens DataTable's identical states already use (opacity 0.5, `bgDanger`), per #385/#430. */
2264
- & .crisp-recordpanel-edit { display: block; width: 100%; margin: 0; padding: 0; border: none; border-radius: var(--crisp-radius-sm); background: none; box-shadow: none; outline: none; font: inherit; color: inherit; }
2265
- /* not Attio-measured (its own field shows no focus ring either, caret only) -- kept for real keyboard users, the same brand ring every other crisp field already shows on focus (DataTable's identical
2266
- .crisp-datatable-edit:focus, e.g.). */
2267
- & .crisp-recordpanel-edit:focus { box-shadow: inset 0 0 0 1px var(--crisp-bg-brand-solid); }
2268
- & .crisp-recordpanel-edit:disabled { opacity: 0.5; cursor: not-allowed; }
2269
- & .crisp-recordpanel-edit-pending { flex-shrink: 0; color: var(--crisp-fg-muted); }
2270
- & .crisp-recordpanel-edit-error { flex-basis: 100%; margin-top: var(--crisp-space-0-5); font-size: var(--crisp-text-sm); color: var(--crisp-bg-danger); }
2271
- & .crisp-recordpanel-edit-picker { display: flex; align-items: center; gap: var(--crisp-space-1); width: 100%; }
2272
- & .crisp-recordpanel-edit-picker .crisp-select-trigger { flex: 1; min-width: 0; }
2273
-
2274
- & .crisp-recordstable-tag { display: inline-flex; align-items: center; height: var(--crisp-size-chip); padding: 0 var(--crisp-space-1); border-radius: var(--crisp-radius-chip); font-size: var(--crisp-text-lg); line-height: 20px; font-weight: var(--crisp-weight-medium); white-space: nowrap; background: color-mix(in srgb, var(--crisp-fg-primary) 6%, transparent); color: color-mix(in srgb, var(--crisp-fg-primary) 82%, transparent); box-shadow: inset 0 0 0 1px var(--crisp-border-subtle); }
2275
- & .crisp-recordstable-tag[data-tone="brand"] { background: color-mix(in srgb, var(--crisp-bg-brand-solid) 14%, transparent); color: color-mix(in srgb, var(--crisp-bg-brand-solid) 82%, var(--crisp-fg-primary)); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-brand-solid) 30%, transparent); }
2276
- & .crisp-recordstable-tag[data-tone="success"] { background: color-mix(in srgb, var(--crisp-bg-success) 16%, transparent); color: color-mix(in srgb, var(--crisp-bg-success) 78%, var(--crisp-fg-primary)); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-success) 30%, transparent); }
2277
- & .crisp-recordstable-tag[data-tone="warning"] { background: color-mix(in srgb, var(--crisp-bg-warning) 18%, transparent); color: color-mix(in srgb, var(--crisp-bg-warning) 82%, var(--crisp-fg-primary)); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-warning) 30%, transparent); }
2278
- & .crisp-recordstable-tag[data-tone="danger"] { background: color-mix(in srgb, var(--crisp-bg-danger) 15%, transparent); color: color-mix(in srgb, var(--crisp-bg-danger) 80%, var(--crisp-fg-primary)); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-danger) 30%, transparent); }
2279
- & .crisp-recordstable-tag[data-tone="purple"] { background: color-mix(in srgb, var(--crisp-bg-purple) 16%, transparent); color: color-mix(in srgb, var(--crisp-bg-purple) 80%, var(--crisp-fg-primary)); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-purple) 30%, transparent); }
2280
- & .crisp-recordstable-tag[data-tone="cyan"] { background: color-mix(in srgb, var(--crisp-bg-cyan) 18%, transparent); color: color-mix(in srgb, var(--crisp-bg-cyan) 82%, var(--crisp-fg-primary)); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-cyan) 30%, transparent); }
2281
- & .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); }
2282
- & .crisp-recordpanel-link { color: var(--crisp-bg-brand-solid); text-decoration: none; overflow: hidden; text-overflow: ellipsis; }
2283
- & .crisp-recordpanel-link:hover { text-decoration: underline; }
2284
-
2285
- /* #329: an optional slot for record actions (e.g. Edit). Reuses the
2286
- header's own measured values rather than inventing new ones -- the same horizontal inset (space.3), the same hairline border token (borderSubtle, flipped to border-top since this sits at the opposite edge), and the header/body gap (space.2) for its own vertical padding and for spacing between whatever it's given. */
2261
+ /* #448: measured live on app.attio.com's peek (hover a Companies row,
2262
+ click "Open preview"). Section label reads identically for both
2263
+ Highlights and Activity: 14px/500, the same 63%-of-fgPrimary mix
2264
+ RecordPage's own field-label already uses (product/recordpage's
2265
+ .crisp-recordpage-field-label) -- one more surface landing on the
2266
+ same measured "muted section heading" colour. */
2267
+ & .crisp-recordpanel-section { padding: var(--crisp-space-2) var(--crisp-space-1) 0; }
2268
+ & .crisp-recordpanel-section-head { display: flex; align-items: center; justify-content: space-between; gap: var(--crisp-space-2); height: 20px; margin-bottom: var(--crisp-space-2); }
2269
+ & .crisp-recordpanel-section-label { font-size: var(--crisp-text-lg); font-weight: var(--crisp-weight-medium); color: color-mix(in srgb, var(--crisp-fg-primary) 63%, transparent); }
2270
+ /* "View all" -- measured 10px/400, a lighter mix than the section label
2271
+ (55%, not 63%) -- its own distinct, honestly-measured value, not a
2272
+ reuse of a nearby one that happened to be close. */
2273
+ & .crisp-recordpanel-section-action { border: none; background: none; padding: 0; font-family: inherit; font-size: 10px; font-weight: var(--crisp-weight-regular); color: color-mix(in srgb, var(--crisp-fg-primary) 55%, transparent); cursor: pointer; }
2274
+ & .crisp-recordpanel-section-action:hover { color: var(--crisp-fg-primary); }
2275
+
2276
+ /* Highlights: a 2-column grid of label+icon+value cards, no card
2277
+ chrome at all (no border/background/radius/shadow -- confirmed on
2278
+ the live DOM: every highlight's computed style is fully transparent). 8px row/column gap; two rows measured "233x86" inside 473px content width is exactly (473 - 8) / 2. */
2279
+ & .crisp-recordpanel-highlights { display: grid; grid-template-columns: 1fr 1fr; gap: var(--crisp-space-2); }
2280
+ & .crisp-recordpanel-highlight { display: flex; flex-direction: column; gap: var(--crisp-space-1-5); min-width: 0; }
2281
+ & .crisp-recordpanel-highlight-label { display: flex; align-items: center; gap: var(--crisp-space-1); font-size: var(--crisp-text-sm); font-weight: var(--crisp-weight-medium); color: color-mix(in srgb, var(--crisp-fg-primary) 63%, transparent); }
2282
+ & .crisp-recordpanel-highlight-label span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2283
+ & .crisp-recordpanel-highlight-label svg { flex-shrink: 0; color: var(--crisp-fg-muted); }
2284
+ & .crisp-recordpanel-highlight-value { font-size: var(--crisp-text-lg); font-weight: var(--crisp-weight-medium); color: var(--crisp-fg-primary); overflow: hidden; text-overflow: ellipsis; }
2285
+ /* Activity is a consumer-supplied slot (crisp has no activity-feed model of its own to render) -- this only wraps it with the measured section heading above. */
2286
+ & .crisp-recordpanel-activity { font-size: var(--crisp-text-lg); }
2287
+ /* #329: an optional slot for record actions (e.g. Edit). Reuses the header's own measured values rather than inventing new ones -- the
2288
+ same horizontal inset (space.3), the same hairline border token
2289
+ (borderSubtle, flipped to border-top since this sits at the opposite
2290
+ edge), and the header/body gap (space.2) for its own vertical padding
2291
+ and for spacing between whatever it's given. */
2287
2292
  & .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); }
2293
+ [dir="rtl"] & [data-rtl-flip] { transform: scaleX(-1); }
2288
2294
  }
2289
2295
 
2290
2296
  @media (prefers-reduced-motion: reduce) {
@@ -2532,12 +2538,38 @@
2532
2538
  & .crisp-rte-body code { font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; font-size: var(--crisp-text-md); padding: 1px var(--crisp-space-1); border-radius: var(--crisp-radius-sm); background: color-mix(in srgb, var(--crisp-fg-primary) 6%, transparent); }
2533
2539
  & .crisp-rte-body a { color: var(--crisp-bg-brand-solid); text-decoration: underline; text-underline-offset: 2px; }
2534
2540
  & .crisp-rte-body strong { font-weight: var(--crisp-weight-semibold); }
2535
- & .crisp-rte-toolbar { position: fixed; z-index: 70; display: inline-flex; align-items: center; gap: 2px; padding: var(--crisp-space-0-5); border-radius: var(--crisp-radius-md); background: #20242c; box-shadow: 0 8px 28px -6px rgba(0,0,0,0.36), 0 2px 6px -2px rgba(0,0,0,0.24); transform: translate(-50%, -100%); opacity: 1; transition: opacity 0.1s ease; }
2541
+ & .crisp-rte-body img { display: block; max-width: 100%; border-radius: var(--crisp-radius-md); }
2542
+ /* Not measured against Attio (#428) — nothing in a read-only pass over app.attio.com had a live @mention rendered to read computed styles from, and typing one to create one is off-limits there. Reused verbatim from the one measured "brand" tag/chip this repo already has (RecordsTable/MultiSelect's [data-tone="brand"]) rather than
2543
+ inventing a new color or shape. */
2544
+ & .crisp-rte-mention { display: inline; border-radius: var(--crisp-radius-chip); padding: 0 var(--crisp-space-1); background: color-mix(in srgb, var(--crisp-bg-brand-solid) 14%, transparent); color: color-mix(in srgb, var(--crisp-bg-brand-solid) 82%, var(--crisp-fg-primary)); font-weight: var(--crisp-weight-medium); cursor: default; }
2545
+
2546
+ & .crisp-rte-toolbar { position: fixed; z-index: 70; display: inline-flex; align-items: center; gap: 2px; padding: 2px; border-radius: 10px; background: var(--crisp-bg-surface); box-shadow: inset 0 0 0 1px var(--crisp-ring-popover), 0 0 0 1px var(--crisp-shadow-popover1), 0 4px 8px -4px var(--crisp-shadow-popover2), 0 4px 12px -2px var(--crisp-shadow-popover3); transform: translate(-50%, -100%); opacity: 1; transition: opacity 0.1s ease; }
2536
2547
  & .crisp-rte-toolbar { @starting-style { opacity: 0; } }
2537
- & .crisp-rte-tool { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; padding: 0; margin: 0; border: none; border-radius: var(--crisp-radius-sm); background: none; color: rgba(255,255,255,0.86); font-family: inherit; cursor: pointer; transition: background-color .1s; }
2538
- & .crisp-rte-tool:hover { background: rgba(255,255,255,0.12); color: #fff; }
2539
- & .crisp-rte-tool[data-active] { color: var(--crisp-bg-brand-solid); }
2540
- & .crisp-rte-tool-sep { width: 1px; height: 16px; margin: 0 2px; background: rgba(255,255,255,0.16); }
2548
+ & .crisp-rte-tool { display: inline-flex; align-items: center; justify-content: center; gap: var(--crisp-space-1); min-width: 30px; height: 28px; padding: 0 7px; margin: 0; border: none; border-radius: var(--crisp-radius-md); background: none; color: color-mix(in srgb, var(--crisp-fg-primary) 63%, transparent); font-family: inherit; font-size: var(--crisp-text-sm); font-weight: var(--crisp-weight-medium); cursor: pointer; transition: background-color .1s; }
2549
+ & .crisp-rte-tool--labelled { width: auto; }
2550
+ & .crisp-rte-tool:hover { background: color-mix(in srgb, var(--crisp-fg-primary) 6%, transparent); color: var(--crisp-fg-primary); }
2551
+ & .crisp-rte-tool[data-active], & .crisp-rte-tool[aria-expanded="true"] { background: color-mix(in srgb, var(--crisp-bg-brand-solid) 12%, transparent); color: var(--crisp-bg-brand-solid); }
2552
+ & .crisp-rte-tool-sep { width: 1px; height: 16px; margin: 0 2px; background: var(--crisp-border-subtle); }
2553
+
2554
+ & .crisp-rte-swatchpanel { position: fixed; z-index: 71; display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--crisp-space-1); width: 168px; padding: var(--crisp-space-1-5); border-radius: var(--crisp-radius-lg); background: var(--crisp-bg-surface); box-shadow: inset 0 0 0 1px var(--crisp-ring-popover), 0 0 0 1px var(--crisp-shadow-popover1), 0 4px 8px -4px var(--crisp-shadow-popover2), 0 4px 12px -2px var(--crisp-shadow-popover3); transform: translate(-50%, var(--crisp-space-1-5)); opacity: 1; transition: opacity 0.1s ease; }
2555
+ & .crisp-rte-swatchpanel { @starting-style { opacity: 0; } }
2556
+ & .crisp-rte-swatch { width: 28px; height: 28px; padding: 0; margin: 0; border: none; border-radius: 50%; background: none; font-family: inherit; box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-fg-primary) 10%, transparent); cursor: pointer; }
2557
+ & .crisp-rte-swatch--none { background: var(--crisp-bg-surface); position: relative; }
2558
+ & .crisp-rte-swatch--none::after { content: ""; position: absolute; inset: 6px; border-top: 1.5px solid color-mix(in srgb, var(--crisp-fg-primary) 40%, transparent); transform: rotate(-45deg); }
2559
+
2560
+ & .crisp-rte-blockmenu { position: fixed; z-index: 71; box-sizing: border-box; width: 356px; padding: 0; border-radius: var(--crisp-radius-xl); background: var(--crisp-bg-surface); box-shadow: inset 0 0 0 1px var(--crisp-ring-popover), 0 0 0 1px var(--crisp-shadow-popover1), 0 4px 8px -4px var(--crisp-shadow-popover2), 0 4px 12px -2px var(--crisp-shadow-popover3); opacity: 1; transition: opacity 0.1s ease; }
2561
+ & .crisp-rte-blockmenu { @starting-style { opacity: 0; } }
2562
+ & .crisp-rte-blockmenu-search { display: flex; align-items: center; gap: var(--crisp-space-1-5); padding: var(--crisp-space-2-5) var(--crisp-space-3); border-bottom: 1px solid var(--crisp-border-subtle); color: color-mix(in srgb, var(--crisp-fg-primary) 63%, transparent); }
2563
+ & .crisp-rte-blockmenu-input { flex: 1; border: none; outline: none; background: none; font-family: inherit; font-size: var(--crisp-text-lg); height: 20px; color: var(--crisp-fg-primary); }
2564
+ & .crisp-rte-blockmenu-input::placeholder { color: color-mix(in srgb, var(--crisp-fg-primary) 40%, transparent); }
2565
+ & .crisp-rte-blockmenu-list { display: flex; flex-direction: column; padding: var(--crisp-space-1-5); gap: var(--crisp-space-px); }
2566
+ & .crisp-rte-blockmenu-item { display: flex; align-items: center; gap: var(--crisp-space-1-5); height: 52px; padding: var(--crisp-space-1-5) var(--crisp-space-2); margin: 0; border: none; border-radius: var(--crisp-radius-md); background: none; font-family: inherit; text-align: start; cursor: pointer; }
2567
+ & .crisp-rte-blockmenu-item svg { flex-shrink: 0; color: color-mix(in srgb, var(--crisp-fg-primary) 63%, transparent); }
2568
+ & .crisp-rte-blockmenu-item[data-highlighted] { background: color-mix(in srgb, var(--crisp-fg-primary) 6%, transparent); }
2569
+ & .crisp-rte-blockmenu-item-text { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
2570
+ & .crisp-rte-blockmenu-item-label { font-size: var(--crisp-text-lg); font-weight: var(--crisp-weight-medium); color: var(--crisp-fg-primary); }
2571
+ & .crisp-rte-blockmenu-item-desc { font-size: var(--crisp-text-sm); color: color-mix(in srgb, var(--crisp-fg-primary) 50%, transparent); }
2572
+ & .crisp-rte-blockmenu-empty { padding: var(--crisp-space-4) var(--crisp-space-3); text-align: center; font-size: var(--crisp-text-md); color: color-mix(in srgb, var(--crisp-fg-primary) 50%, transparent); }
2541
2573
  }
2542
2574
 
2543
2575
  @media (prefers-reduced-motion: reduce) {
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The pixel position of a caret index inside a `<textarea>`/`<input>`, in
3
+ * viewport coordinates — where a mention picker anchors.
4
+ *
5
+ * Neither element exposes caret geometry directly, so this uses the standard
6
+ * mirror-div technique: an off-screen div replicating every computed style
7
+ * that affects text layout (font, padding, border, wrapping), holding the
8
+ * same text up to the caret plus a marker span. The marker's own
9
+ * `getBoundingClientRect()`, translated back onto the real field's box, is
10
+ * the caret's actual rendered position — nothing here guesses at line
11
+ * height or character width itself.
12
+ */
13
+ export declare function caretRectInField(field: HTMLTextAreaElement | HTMLInputElement, index: number): {
14
+ x: number;
15
+ y: number;
16
+ };
@@ -1,3 +1,4 @@
1
+ export { caretRectInField } from "./caret-rect";
1
2
  export { type CellDatePrecision, cellDatePrecision, formatCellDate, parseCellDate, } from "./cell-date";
2
3
  export { cx } from "./cx";
3
4
  export { defineRecipe } from "./define";
@@ -6,6 +7,7 @@ export { isRtlElement } from "./direction";
6
7
  export { FloatingPortal } from "./floating-portal";
7
8
  export { hasOpenFloatingLayer, isInsideLaterFloatingLayer, registerFloatingLayer, } from "./floating-stack";
8
9
  export { formValueStyle } from "./form-value";
10
+ export { type MentionAnchor, type MentionMatch, matchMentionTrigger, } from "./mention";
9
11
  export { type FloatingAlign, type PlaceFloatingOptions, placeFloating, } from "./place-floating";
10
12
  export { Primitive } from "./primitive";
11
13
  export { resolveRecipe } from "./resolve";
@@ -15,4 +17,5 @@ export type * from "./types";
15
17
  export { useFocusAfterRemoval } from "./use-focus-after-removal";
16
18
  export { useFormReset } from "./use-form-reset";
17
19
  export { useLabelAssociation } from "./use-label-association";
20
+ export { type MentionTrigger, type MentionTriggerState, type UseMentionTriggerOptions, useMentionTrigger, } from "./use-mention-trigger";
18
21
  export { usePrefersReducedMotion } from "./use-prefers-reduced-motion";
@@ -0,0 +1,27 @@
1
+ export type MentionMatch = {
2
+ /** Text typed after the trigger character — what a picker filters by. */
3
+ query: string;
4
+ /** Index, in the text passed in, where the trigger character itself sits. */
5
+ start: number;
6
+ };
7
+ /**
8
+ * Is there an active "@query" mention trigger ending at the caret?
9
+ *
10
+ * Matches the rule every "@mention" implementation (GitHub, Slack, Linear)
11
+ * actually uses: the trigger character must start a word — the very first
12
+ * character, or preceded by whitespace — and nothing between it and the
13
+ * caret may contain whitespace. `foo@bar` is an email/handle stuck to a word,
14
+ * not a trigger; `@jo next` has already moved past the mention it typed.
15
+ *
16
+ * Takes plain text rather than a live DOM node so it is one pure function
17
+ * usable against a `<textarea>`'s `value` and a `contentEditable` text
18
+ * node's content alike, and is the one thing here worth unit-testing on its
19
+ * own — everything downstream of it (caret geometry, DOM insertion) needs a
20
+ * real browser to verify.
21
+ */
22
+ export declare function matchMentionTrigger(textBeforeCaret: string, trigger?: string): MentionMatch | null;
23
+ /** A caret's position in viewport coordinates — where a picker anchors. */
24
+ export type MentionAnchor = {
25
+ x: number;
26
+ y: number;
27
+ };
@@ -0,0 +1,41 @@
1
+ import * as React from "react";
2
+ import { type MentionAnchor } from "./mention";
3
+ export type MentionTriggerState = {
4
+ open: boolean;
5
+ query: string;
6
+ /** Where to anchor a picker — null while closed. */
7
+ anchor: MentionAnchor | null;
8
+ };
9
+ export type UseMentionTriggerOptions = {
10
+ /** Character that opens the picker. Default `"@"`. */
11
+ trigger?: string;
12
+ };
13
+ export type MentionTrigger = MentionTriggerState & {
14
+ /** Dismiss the picker without inserting anything — e.g. on Escape. */
15
+ close: () => void;
16
+ /** Replace the "@query" text (the trigger included) with `text`, restore
17
+ * focus and the caret right after it, and dismiss. */
18
+ insert: (text: string) => void;
19
+ };
20
+ /**
21
+ * Watches a `<textarea>`/`<input>` for an "@query" mention trigger and
22
+ * reports where to anchor a consumer-supplied picker (#372).
23
+ *
24
+ * crisp draws no picker of its own here — GitHub/Slack/Linear-style @mention
25
+ * UI varies too much by app (avatars, groups, recent-first ordering, a
26
+ * "create new person" row) to have one measured shape, and this repo's rule
27
+ * is measure-first, never invented polish. What is shared and worth owning
28
+ * centrally is the fiddly, easy-to-get-wrong part: deciding when "@" is
29
+ * actually a trigger rather than part of a word or an email address (see
30
+ * `matchMentionTrigger`), and where its picker anchors on screen (see
31
+ * `caretRectInField`, since neither element exposes caret geometry itself).
32
+ *
33
+ * Does not close on blur: a picker built from this needs to survive the
34
+ * field losing focus to a `mousedown` on one of its own rows, the same
35
+ * problem every floating surface in this repo solves for itself (see
36
+ * `RichTextEditor`'s toolbar `keepSel`, or `Popover`'s own outside-pointer-
37
+ * down listener) — so closing on outside interaction is left to the
38
+ * consumer's picker, which already knows what counts as "outside" for its
39
+ * own markup.
40
+ */
41
+ export declare function useMentionTrigger(ref: React.RefObject<HTMLTextAreaElement | HTMLInputElement | null>, { trigger }?: UseMentionTriggerOptions): MentionTrigger;
@@ -1,2 +1,2 @@
1
- export { RecordPanel, type RecordPanelProps } from "./recordpanel";
1
+ export { RecordPanel, type RecordPanelHighlight, type RecordPanelProps, } from "./recordpanel";
2
2
  export { recordpanelRecipe } from "./recordpanel.recipe";
@@ -1,45 +1,99 @@
1
1
  import * as React from "react";
2
- import { type Column, type Row } from "../recordstable";
2
+ import { type AvatarProps } from "../../components/avatar";
3
+ import type { Row } from "../recordstable";
4
+ type IconType = React.ComponentType<{
5
+ size?: number;
6
+ strokeWidth?: number;
7
+ }>;
8
+ /** One Highlights card — label, value and an optional icon, laid out the
9
+ * way Attio's peek panel lays its own out (measured live, #448): a 2-column
10
+ * grid, no card chrome (no border/background/shadow), a muted label row
11
+ * with a trailing icon, a filled-colour value below it. */
12
+ export type RecordPanelHighlight = {
13
+ key: string;
14
+ label: string;
15
+ value: React.ReactNode;
16
+ icon?: IconType;
17
+ };
3
18
  export type RecordPanelProps = Omit<React.ComponentPropsWithoutRef<"div">, "children" | "title"> & {
4
- /** The record to show; render nothing when null. */
5
19
  /** Label for the close control. */
6
20
  closeLabel?: string;
21
+ /**
22
+ * A toolbar row above the panel's own identity header — measured live on
23
+ * app.attio.com's peek (hover a Companies row, click "Open preview"):
24
+ * Close, then Previous/Next record on the left, an "Open record" icon on
25
+ * the right, a hairline border beneath separating it from the identity
26
+ * block below. Close always renders (`onClose` is required); Previous/
27
+ * Next/Open record each render only when given a handler — a consumer
28
+ * with no adjacent-row concept, or nowhere to open the record's own full
29
+ * page, simply omits them, leaving just Close.
30
+ */
31
+ onOpenRecord?: () => void;
32
+ openRecordLabel?: string;
33
+ onPrevious?: () => void;
34
+ previousRecordLabel?: string;
35
+ onNext?: () => void;
36
+ nextRecordLabel?: string;
7
37
  /**
8
38
  * The panel's heading, and so its accessible name (via `aria-labelledby`,
9
39
  * 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".
40
+ * can be any node). Defaults to `record.name`.
13
41
  */
14
42
  title?: React.ReactNode;
15
- /** Heading above the field list. */
16
- detailsLabel?: React.ReactNode;
17
- /** Shown in place of a field that has no value. */
18
- emptyLabel?: React.ReactNode;
19
- /** Rendered below the field list, e.g. an Edit button. Omit for no footer. */
20
- footer?: React.ReactNode;
21
- record: Row | null;
22
- columns: Column[];
23
- onClose: () => void;
43
+ /** Avatar image URL beside the title. Omit for an initials tile built
44
+ * from `record.name`'s first character. */
45
+ avatar?: string;
46
+ /** Tone for the initials tile when no `avatar` image is given (default
47
+ * "brand", matching Attio's own peek). */
48
+ avatarTone?: NonNullable<AvatarProps["tone"]>;
49
+ /** The record to show; render nothing when null. Only `id`/`name` are
50
+ * read directly here — everything else the panel shows comes from
51
+ * `highlights`/`activity`, the consumer's own data. */
52
+ record: (Row & {
53
+ name?: string;
54
+ }) | null;
55
+ /** Heading above the highlight cards (default "Highlights"). Omit the
56
+ * whole section by omitting `highlights`. */
57
+ highlightsLabel?: React.ReactNode;
24
58
  /**
25
- * Commit handler for a `column.editable` field (#431) — same shape
26
- * `DataTable`'s own `onEdit` uses (#385/#430): returning (or resolving)
27
- * nothing commits synchronously; returning a `Promise` that rejects shows
28
- * a pending state on the field while it's in flight, then reverts to the
29
- * last-known-good value (this component never mutates `record` itself)
30
- * and renders the rejection's message beside the field. A field with
31
- * `editable` but no `onEdit` never opens — there's nothing to commit to.
59
+ * Highlight cards — label, value, optional icon (#448). Attio's own peek
60
+ * shows these as a fixed set of business-pipeline stats (Connection
61
+ * strength, Estimated ARR, …); crisp has no such fixed set, so this is
62
+ * fully consumer-supplied, laid out in Attio's own measured 2-column
63
+ * grid. Omit for no Highlights section at all.
32
64
  */
33
- onEdit?: (id: string, key: string, value: string) => Promise<void> | void;
65
+ highlights?: RecordPanelHighlight[];
66
+ /** Heading above the activity feed (default "Activity"). */
67
+ activityLabel?: React.ReactNode;
68
+ /** Label for the trailing "View all" action beside the Activity heading.
69
+ * Only rendered when `onViewAllActivity` is given. */
70
+ viewAllActivityLabel?: React.ReactNode;
71
+ onViewAllActivity?: () => void;
72
+ /**
73
+ * Activity feed content (#448) — a consumer-supplied slot. crisp has no
74
+ * activity-feed model of its own (no fixed shape for "who did what,
75
+ * when" — that's app data); this only wraps whatever's given with the
76
+ * measured "Activity" heading Attio's peek shows above its own feed. Omit
77
+ * for no Activity section at all.
78
+ */
79
+ activity?: React.ReactNode;
80
+ /** Rendered below the panel's content, e.g. an Edit button. Omit for no footer. */
81
+ footer?: React.ReactNode;
82
+ onClose: () => void;
34
83
  className?: string;
35
84
  };
36
85
  /**
37
- * Right-side record peek panel — shows one record's fields, opened from a
38
- * RecordsTable row (via `onOpenRecord`). Render it inside a `position:
39
- * relative` container (e.g. the app main area); it slides in from the right and
40
- * overlays the content.
86
+ * Right-side record peek panel — Attio's own peek shows a Highlights card
87
+ * area and an Activity feed (measured live: hover a Companies row, click its
88
+ * "Open preview" trigger), never an editable field list (#448 — a "Details"
89
+ * list was this component's own invention before, and #431's editable API
90
+ * had nothing real in this panel to attach to; that API stays on
91
+ * `RecordPage`'s own Details column, where Attio does let you edit in
92
+ * place). Render it inside a `position: relative` container (e.g. the app
93
+ * main area); it slides in from the right and overlays the content.
41
94
  */
42
- export declare function RecordPanel({ record, columns, onClose, className, closeLabel, title, detailsLabel, emptyLabel, footer, onEdit, ...rest }: RecordPanelProps): React.JSX.Element | null;
95
+ export declare function RecordPanel({ record, onClose, className, closeLabel, onOpenRecord, openRecordLabel, onPrevious, previousRecordLabel, onNext, nextRecordLabel, title, avatar, avatarTone, highlightsLabel, highlights, activityLabel, viewAllActivityLabel, onViewAllActivity, activity, footer, ...rest }: RecordPanelProps): React.JSX.Element | null;
43
96
  export declare namespace RecordPanel {
44
97
  var displayName: string;
45
98
  }
99
+ export {};
@@ -8,18 +8,21 @@ export type EditableOption = {
8
8
  color?: string;
9
9
  };
10
10
  /**
11
- * The async-commit state machine behind `RecordPanel`/`RecordPage`'s
12
- * editable fields (#431) — the same shape `DataTable`'s own `commitEdit`
13
- * uses (#385/#430): a sync `onEdit` (returns `undefined`) commits and closes
11
+ * The async-commit state machine behind `RecordPage`'s editable Details
12
+ * column (#431) — the same shape `DataTable`'s own `commitEdit` uses
13
+ * (#385/#430): a sync `onEdit` (returns `undefined`) commits and closes
14
14
  * immediately; a Promise-returning `onEdit` keeps the field open and pending
15
15
  * while it settles, then either closes it (resolve) or closes it AND
16
16
  * reverts while showing the rejection's message (reject). The revert needs
17
- * no extra code here either: neither `RecordPanel` nor `RecordPage` mutates
18
- * `record` itself, so once `editingKey` clears, the field simply renders
19
- * `record[key]` again — whatever it still says.
17
+ * no extra code here either: `RecordPage` never mutates `record` itself, so
18
+ * once `editingKey` clears, the field simply renders `record[key]` again —
19
+ * whatever it still says.
20
20
  *
21
- * One hook shared by both consumers rather than two copies of the same
22
- * three `useState`s and the same `.then` branching.
21
+ * `RecordPanel` (the peek panel) used to share this hook too, before #448
22
+ * found that Attio's own peek has no editable field at all to attach it to
23
+ * — only `RecordPage`'s Details column does. Kept as its own module rather
24
+ * than folded back into `recordpage.tsx`: a second consumer turning up
25
+ * again is a smaller change than re-extracting it would be.
23
26
  */
24
27
  export declare function useEditableField(onEdit?: (id: string, key: string, value: string) => Promise<void> | void): {
25
28
  editingKey: string | null;
@@ -3,8 +3,13 @@
3
3
  * `border-radius: ${c.radius.chip}`, 14px/20px at weight 500, and a 1px inset ring in the
4
4
  * tone's colour.
5
5
  *
6
- * Shared because RecordsTable and RecordPanel render the *same* chip. It used
7
- * to be pasted into both recipes, and the two copies had already started to
8
- * drift — adding the measured inset ring meant patching the same rules twice.
6
+ * Its own module (not pasted into `recordstable.recipe.ts`) because it used
7
+ * to be shared with `RecordPanel`'s own Details field list too, and the two
8
+ * copies had already started to drift before that sharing existed — adding
9
+ * the measured inset ring meant patching the same rules twice. `RecordPanel`
10
+ * no longer renders a Details list (#448, Attio's own peek doesn't have
11
+ * one), so `RecordsTable` is this module's only consumer now; kept as its
12
+ * own file rather than folded back in, since a second consumer showing up
13
+ * again is exactly how the drift happened the first time.
9
14
  */
10
15
  export declare const recordTagCss: string;
@@ -1,2 +1,2 @@
1
- export { RichTextEditor, type RichTextEditorProps, type RichTextValue, } from "./richtext";
1
+ export { RICHTEXT_COLOR_TONES, type RichTextColorTone, RichTextEditor, type RichTextEditorHandle, type RichTextEditorProps, type RichTextMention, type RichTextMentionTriggerState, type RichTextValue, } from "./richtext";
2
2
  export { richtextRecipe } from "./richtext.recipe";