@crisp-ui-kit/crisp 0.54.1 → 0.54.2

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 (106) hide show
  1. package/dist/cjs/components/datatable/datatable.js +2 -1
  2. package/dist/cjs/components/datatable/datatable.recipe.js +4 -0
  3. package/dist/cjs/components/mentionchip/index.js +7 -0
  4. package/dist/cjs/components/mentionchip/mentionchip.js +57 -0
  5. package/dist/cjs/components/mentionchip/mentionchip.recipe.js +38 -0
  6. package/dist/cjs/components/table/table.recipe.js +15 -3
  7. package/dist/cjs/core/floating-stack.js +18 -0
  8. package/dist/cjs/core/index.js +2 -1
  9. package/dist/cjs/index.js +6 -0
  10. package/dist/cjs/product/formdialog/formdialog.js +171 -0
  11. package/dist/cjs/product/formdialog/formdialog.recipe.js +26 -0
  12. package/dist/cjs/product/formdialog/index.js +8 -0
  13. package/dist/cjs/product/importdialog/importdialog.js +134 -0
  14. package/dist/cjs/product/importdialog/importdialog.recipe.js +92 -0
  15. package/dist/cjs/product/importdialog/index.js +18 -0
  16. package/dist/cjs/product/savedviews/index.js +7 -0
  17. package/dist/cjs/product/savedviews/savedviews.js +207 -0
  18. package/dist/cjs/product/savedviews/savedviews.recipe.js +103 -0
  19. package/dist/cjs/product/taskstable/index.js +7 -0
  20. package/dist/cjs/product/taskstable/taskstable.js +27 -0
  21. package/dist/cjs/product/taskstable/taskstable.recipe.js +47 -0
  22. package/dist/cjs/product/themepicker/index.js +17 -0
  23. package/dist/cjs/product/themepicker/themepicker.js +32 -0
  24. package/dist/cjs/product/themepicker/themepicker.recipe.js +98 -0
  25. package/dist/cjs/tokens/palette.js +30 -13
  26. package/dist/cjs/tokens/semantic.js +6 -0
  27. package/dist/datatable.css +20 -21
  28. package/dist/datatable.layered.css +20 -21
  29. package/dist/esm/components/datatable/datatable.js +2 -1
  30. package/dist/esm/components/datatable/datatable.recipe.js +4 -0
  31. package/dist/esm/components/mentionchip/index.js +2 -0
  32. package/dist/esm/components/mentionchip/mentionchip.js +21 -0
  33. package/dist/esm/components/mentionchip/mentionchip.recipe.js +35 -0
  34. package/dist/esm/components/table/table.recipe.js +15 -3
  35. package/dist/esm/core/floating-stack.js +17 -0
  36. package/dist/esm/core/index.js +1 -1
  37. package/dist/esm/index.js +6 -0
  38. package/dist/esm/product/formdialog/formdialog.js +134 -0
  39. package/dist/esm/product/formdialog/formdialog.recipe.js +23 -0
  40. package/dist/esm/product/formdialog/index.js +2 -0
  41. package/dist/esm/product/importdialog/importdialog.js +97 -0
  42. package/dist/esm/product/importdialog/importdialog.recipe.js +89 -0
  43. package/dist/esm/product/importdialog/index.js +2 -0
  44. package/dist/esm/product/savedviews/index.js +2 -0
  45. package/dist/esm/product/savedviews/savedviews.js +171 -0
  46. package/dist/esm/product/savedviews/savedviews.recipe.js +100 -0
  47. package/dist/esm/product/taskstable/index.js +2 -0
  48. package/dist/esm/product/taskstable/taskstable.js +24 -0
  49. package/dist/esm/product/taskstable/taskstable.recipe.js +44 -0
  50. package/dist/esm/product/themepicker/index.js +1 -0
  51. package/dist/esm/product/themepicker/themepicker.js +29 -0
  52. package/dist/esm/product/themepicker/themepicker.recipe.js +95 -0
  53. package/dist/esm/tokens/palette.js +30 -13
  54. package/dist/esm/tokens/semantic.js +6 -0
  55. package/dist/formdialog.css +6 -0
  56. package/dist/formdialog.d.ts +7 -0
  57. package/dist/formdialog.layered.css +8 -0
  58. package/dist/importdialog.css +38 -0
  59. package/dist/importdialog.d.ts +7 -0
  60. package/dist/importdialog.layered.css +40 -0
  61. package/dist/mentionchip.css +3 -0
  62. package/dist/mentionchip.d.ts +7 -0
  63. package/dist/mentionchip.layered.css +5 -0
  64. package/dist/native/tokens.d.ts +1 -1
  65. package/dist/native/tokens.js +14 -6
  66. package/dist/savedviews.css +78 -0
  67. package/dist/savedviews.d.ts +7 -0
  68. package/dist/savedviews.layered.css +80 -0
  69. package/dist/styles.css +223 -30
  70. package/dist/styles.layered.css +223 -30
  71. package/dist/table.css +15 -6
  72. package/dist/table.layered.css +15 -6
  73. package/dist/taskstable.css +23 -0
  74. package/dist/taskstable.d.ts +7 -0
  75. package/dist/taskstable.layered.css +25 -0
  76. package/dist/themepicker.css +27 -0
  77. package/dist/themepicker.d.ts +7 -0
  78. package/dist/themepicker.layered.css +29 -0
  79. package/dist/tokens.css +7 -3
  80. package/dist/tokens.json +14 -6
  81. package/dist/types/components/datatable/datatable.d.ts +12 -1
  82. package/dist/types/components/mentionchip/index.d.ts +2 -0
  83. package/dist/types/components/mentionchip/mentionchip.d.ts +20 -0
  84. package/dist/types/components/mentionchip/mentionchip.recipe.d.ts +18 -0
  85. package/dist/types/core/floating-stack.d.ts +14 -0
  86. package/dist/types/core/index.d.ts +1 -1
  87. package/dist/types/index.d.ts +6 -0
  88. package/dist/types/product/formdialog/formdialog.d.ts +57 -0
  89. package/dist/types/product/formdialog/formdialog.recipe.d.ts +3 -0
  90. package/dist/types/product/formdialog/index.d.ts +2 -0
  91. package/dist/types/product/importdialog/importdialog.d.ts +86 -0
  92. package/dist/types/product/importdialog/importdialog.recipe.d.ts +3 -0
  93. package/dist/types/product/importdialog/index.d.ts +2 -0
  94. package/dist/types/product/savedviews/index.d.ts +2 -0
  95. package/dist/types/product/savedviews/savedviews.d.ts +55 -0
  96. package/dist/types/product/savedviews/savedviews.recipe.d.ts +3 -0
  97. package/dist/types/product/taskstable/index.d.ts +2 -0
  98. package/dist/types/product/taskstable/taskstable.d.ts +54 -0
  99. package/dist/types/product/taskstable/taskstable.recipe.d.ts +3 -0
  100. package/dist/types/product/themepicker/index.d.ts +1 -0
  101. package/dist/types/product/themepicker/themepicker.d.ts +21 -0
  102. package/dist/types/product/themepicker/themepicker.recipe.d.ts +3 -0
  103. package/dist/types/tokens/contract.d.ts +2 -0
  104. package/dist/types/tokens/palette.d.ts +11 -3
  105. package/dist/types/tokens/semantic.d.ts +6 -0
  106. package/package.json +31 -1
@@ -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";
@@ -0,0 +1,97 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useFileUpload } from "@seed-design/react-file-upload";
4
+ import * as dialog from "@zag-js/dialog";
5
+ import { normalizeProps, Portal, useMachine } from "@zag-js/react";
6
+ import { X } from "lucide-react";
7
+ import * as React from "react";
8
+ import { Button } from "../../components/button/index.js";
9
+ import { cx, hasOpenFloatingLayer, useCrispMessage, withDescribedBy, } from "../../core/index.js";
10
+ import { importdialogRecipe } from "./importdialog.recipe.js";
11
+ let instanceCounter = 0;
12
+ function useImportDialogInstanceId() {
13
+ const idRef = React.useRef(null);
14
+ if (idRef.current == null) {
15
+ instanceCounter += 1;
16
+ idRef.current = `crisp-importdialog-${instanceCounter}`;
17
+ }
18
+ return idRef.current;
19
+ }
20
+ /**
21
+ * A full-viewport step wizard — Attio's own CSV import flow (measured live:
22
+ * `1584x871` filling a ~1600x900 viewport, `border-radius: 0`, no shadow,
23
+ * opaque white). Composes Zag's dialog machine for focus-trap/scroll-lock/
24
+ * Escape exactly like `Dialog`/`Drawer`, with its own recipe for the
25
+ * full-bleed chrome neither of those two share (see importdialog.recipe.ts).
26
+ *
27
+ * The step header and footer are generic — the wizard doesn't know about
28
+ * CSV. Step 1's body is typically `ImportDialogDropzone`; steps 2-4
29
+ * (map columns / review values / preview import) are consumer-supplied
30
+ * `children`, since Attio's own column-mapping UI wasn't part of this
31
+ * measurement pass.
32
+ */
33
+ export function ImportDialog({ open, onOpenChange, title = "Import", steps, activeStep, onStepClick, children, primaryLabel = "Next", onPrimary, primaryDisabled, "aria-label": ariaLabel, closeLabel: closeLabelProp, className, style, "aria-describedby": describedBy, ...rest }) {
34
+ const closeLabel = useCrispMessage(closeLabelProp, "Close");
35
+ const id = useImportDialogInstanceId();
36
+ const service = useMachine(dialog.machine, {
37
+ id,
38
+ open,
39
+ onOpenChange(details) {
40
+ onOpenChange(details.open);
41
+ },
42
+ "aria-label": typeof title === "string" ? title : ariaLabel,
43
+ // Defer to a nested Menu/Select/Popover/ContextMenu's own Escape
44
+ // handling (#361) -- see Dialog's identical comment; this composes the
45
+ // same Zag machine.
46
+ onEscapeKeyDown(event) {
47
+ if (hasOpenFloatingLayer())
48
+ event.preventDefault();
49
+ },
50
+ });
51
+ const api = dialog.connect(service, normalizeProps);
52
+ if (!api.open)
53
+ return null;
54
+ return (_jsx(Portal, { children: _jsxs("div", { className: importdialogRecipe(), children: [_jsx("div", { ...api.getBackdropProps(), className: "crisp-importdialog-backdrop" }), _jsx("div", { ...api.getPositionerProps(), className: "crisp-importdialog-positioner", children: _jsxs("div", { ...rest, ...withDescribedBy(api.getContentProps(), describedBy), className: cx("crisp-importdialog-content", className), style: style, "data-crisp-importdialog": id, children: [_jsxs("div", { className: "crisp-importdialog-header", children: [_jsx("h2", { ...api.getTitleProps(), className: "crisp-importdialog-title", children: title }), _jsx("button", { type: "button", ...api.getCloseTriggerProps(), className: "crisp-importdialog-close", "aria-label": closeLabel, children: _jsx(X, { size: 16, strokeWidth: 2, "aria-hidden": "true" }) })] }), _jsx("nav", { className: "crisp-importdialog-steps", "aria-label": (typeof title === "string" ? title : ariaLabel) ?? "Steps", children: steps.map((step) => {
55
+ const isActive = step.id === activeStep;
56
+ const commonProps = {
57
+ className: "crisp-importdialog-step",
58
+ "data-active": isActive ? "" : undefined,
59
+ "aria-current": isActive ? "step" : undefined,
60
+ };
61
+ return onStepClick ? (_jsx("button", { type: "button", ...commonProps, onClick: () => onStepClick(step.id), children: step.label }, step.id)) : (_jsx("span", { ...commonProps, children: step.label }, step.id));
62
+ }) }), _jsx("div", { className: "crisp-importdialog-body", children: children }), _jsx("div", { className: "crisp-importdialog-footer", children: _jsx(Button, { type: "button", intent: "primary", size: "md", disabled: primaryDisabled, onClick: onPrimary, children: primaryLabel }) })] }) })] }) }));
63
+ }
64
+ ImportDialog.displayName = "ImportDialog";
65
+ /**
66
+ * The step-1 drop target — measured chrome (`border: 1px dashed
67
+ * rgb(230,231,234)`, `border-radius: 20px`, `background: rgb(251,251,251)`)
68
+ * over the SAME headless drag/drop + file-picker engine `FileUpload` uses
69
+ * (`@seed-design/react-file-upload`'s `useFileUpload`), rather than a fork
70
+ * of it. `FileUpload`'s own rendered markup (a single button carrying one
71
+ * title + one hint) can't host this dropzone's four separate pieces of copy
72
+ * — a title, an "or" divider, a "Choose a .CSV file" trigger, and a "Learn
73
+ * more" link that needs its own `<a href>` — so this composes the hook
74
+ * directly instead of wrapping `<FileUpload>`, and the engine (not this
75
+ * file) still owns every bit of drag-state/accept/reject behaviour.
76
+ */
77
+ export function ImportDialogDropzone({ accept = ".csv", disabled, maxFileSize, onFileAccept, onFileReject, dropLabel = "Drop your .CSV file onto this area to upload", orLabel = "or", chooseLabel = "Choose a .CSV file", learnMoreHref, learnMoreLabel = "Learn more", className, id, ...rest }) {
78
+ const [rejections, setRejections] = React.useState([]);
79
+ const { dropzoneProps, triggerProps, hiddenInputProps, inputRef } = useFileUpload({
80
+ accept,
81
+ disabled,
82
+ maxFileSize,
83
+ maxFiles: 1,
84
+ onFileReject: (r) => {
85
+ setRejections(r);
86
+ onFileReject?.(r);
87
+ },
88
+ onFileAccept: (entries) => {
89
+ setRejections([]);
90
+ const file = entries[entries.length - 1]?.file;
91
+ if (file)
92
+ onFileAccept?.(file);
93
+ },
94
+ });
95
+ return (_jsxs("div", { ...rest, ...dropzoneProps, className: cx("crisp-importdialog-dropzone", className), children: [_jsx("input", { ...hiddenInputProps, id: id ?? hiddenInputProps.id, ref: inputRef, className: "crisp-importdialog-dropzone-input" }), _jsx("span", { className: "crisp-importdialog-dropzone-title", children: dropLabel }), _jsx("span", { className: "crisp-importdialog-dropzone-or", children: orLabel }), _jsx("button", { ...triggerProps, className: "crisp-importdialog-dropzone-choose", children: chooseLabel }), learnMoreHref && (_jsx("a", { className: "crisp-importdialog-dropzone-learnmore", href: learnMoreHref, target: "_blank", rel: "noreferrer", children: learnMoreLabel })), _jsx("ul", { className: "crisp-importdialog-dropzone-errors", "aria-live": "polite", children: rejections.map((r) => (_jsx("li", { className: "crisp-importdialog-dropzone-error", children: r.file.name }, r.file.name))) })] }));
96
+ }
97
+ ImportDialogDropzone.displayName = "ImportDialogDropzone";
@@ -0,0 +1,89 @@
1
+ import { defineRecipe, resolveRecipe } from "../../core/index.js";
2
+ import { contract as c } from "../../tokens/index.js";
3
+ // A full-viewport wizard takeover — measured live from Attio's own Import CSV
4
+ // flow (app.attio.com/techpartners/companies -> toolbar "Import / Export" ->
5
+ // "Import CSV"): panel `1584x871` in a ~1600x900 viewport (i.e. it fills the
6
+ // content area, not a small centred modal like `Dialog`), `border-radius: 0`,
7
+ // `box-shadow: none`, opaque `rgb(255,255,255)` background. Unlike
8
+ // Dialog/Drawer's frosted, edge-gutter surfaces (AUTHORING Lesson 30), a
9
+ // full-viewport takeover has no edge to float off of, so it gets its own
10
+ // recipe rather than composing either.
11
+ //
12
+ // The 4-step header renders each step as a 28px pill (`size.row`), 8px
13
+ // corner (`radius.md`), transparent at rest. Attio's own active-step
14
+ // treatment wasn't captured in the measurement pass, so the current step
15
+ // reuses crisp's existing brand-tint pattern (14%/82% `color-mix` — the same
16
+ // ratio `RecordsTable`'s brand-tone tag and `MultiSelect`'s brand chip
17
+ // already use) rather than inventing a new colour — a documented scope
18
+ // decision, not a guess.
19
+ //
20
+ // The dropzone is `border: 1px dashed rgb(230,231,234)` (`borderDropzone`,
21
+ // see tokens/palette.ts), `border-radius: 20px`, `background: rgb(251,251,251)`
22
+ // (`bgSubtle`). 20px sits between the scale's `2xl` (16px) and `full` (999px)
23
+ // with no other measured use yet — left as a raw px per AUTHORING 4b's
24
+ // "genuine one-off" exception rather than added to the shared scale for a
25
+ // single consumer.
26
+ //
27
+ // The primary "Next" button is composed from crisp's own `Button` (`intent`
28
+ // `primary`, `size` `md` — 28px tall, 8px corner, brand solid background —
29
+ // matches the measurement exactly), not a hand-rolled button.
30
+ const config = defineRecipe({
31
+ name: "importdialog",
32
+ base: `display: contents; font-family: ${c.font.sans};
33
+ & .crisp-importdialog-backdrop { position: fixed; inset: 0; z-index: 60; background: transparent; }
34
+ & .crisp-importdialog-positioner { position: fixed; inset: 0; z-index: 61; display: flex; }
35
+ & .crisp-importdialog-content {
36
+ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
37
+ position: relative; display: flex; flex-direction: column; box-sizing: border-box;
38
+ width: 100%; height: 100%; border-radius: 0; background: ${c.bgSurface}; box-shadow: none;
39
+ transform: none; opacity: 1; transition: opacity 0.2s ease;
40
+ @starting-style { opacity: 0; }
41
+ }
42
+ & .crisp-importdialog-header { display: flex; align-items: center; gap: ${c.space["2"]}; flex-shrink: 0; height: 52px; padding: 0 ${c.space["2"]} 0 ${c.space["6"]}; border-bottom: 1px solid ${c.borderSubtle}; }
43
+ & .crisp-importdialog-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: ${c.text.xl}; font-weight: ${c.weight.semibold}; color: ${c.fgPrimary}; margin: 0; }
44
+ & .crisp-importdialog-close { display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; width: 28px; height: 28px; margin: 0; padding: 0; border: none; border-radius: ${c.radius.md}; background: none; color: ${c.fgMuted}; font-family: inherit; cursor: pointer; }
45
+ & .crisp-importdialog-close:hover { background: color-mix(in srgb, ${c.fgPrimary} 6%, transparent); color: ${c.fgPrimary}; }
46
+
47
+ & .crisp-importdialog-steps { display: flex; align-items: center; gap: ${c.space["2"]}; flex-shrink: 0; padding: ${c.space["4"]} ${c.space["6"]} 0; }
48
+ & .crisp-importdialog-step {
49
+ display: inline-flex; align-items: center; height: ${c.size.row}; padding: 0 ${c.space["2.5"]};
50
+ border: none; border-radius: ${c.radius.md}; background: transparent; color: ${c.fgMuted};
51
+ font-family: inherit; font-size: ${c.text.md}; font-weight: ${c.weight.medium}; cursor: default;
52
+ transition: background-color .12s, color .12s;
53
+ }
54
+ & button.crisp-importdialog-step { cursor: pointer; }
55
+ & .crisp-importdialog-step[data-active] { background: color-mix(in srgb, ${c.bgBrandSolid} 14%, transparent); color: color-mix(in srgb, ${c.bgBrandSolid} 82%, ${c.fgPrimary}); }
56
+ & .crisp-importdialog-step:focus-visible { outline: 2px solid ${c.strokeFocusRing}; outline-offset: 1px; }
57
+
58
+ & .crisp-importdialog-body { flex: 1; min-height: 0; display: flex; flex-direction: column; overflow-y: auto; padding: ${c.space["6"]}; }
59
+
60
+ & .crisp-importdialog-footer { display: flex; align-items: center; justify-content: flex-end; gap: ${c.space["3"]}; flex-shrink: 0; min-height: 56px; padding: 0 ${c.space["6"]}; border-top: 1px solid ${c.borderSubtle}; }
61
+
62
+ & .crisp-importdialog-dropzone {
63
+ box-sizing: border-box; position: relative; flex: 1; min-height: 0;
64
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
65
+ gap: ${c.space["1"]}; width: 100%;
66
+ border: 1px dashed ${c.borderDropzone}; border-radius: 20px; background: ${c.bgSubtle};
67
+ text-align: center; transition: background-color .12s, border-color .12s;
68
+ }
69
+ & .crisp-importdialog-dropzone[data-dragging-over] { border-color: ${c.bgBrandSolid}; background: color-mix(in srgb, ${c.bgBrandSolid} 6%, transparent); }
70
+ & .crisp-importdialog-dropzone[data-invalid] { border-color: ${c.bgDanger}; }
71
+ & .crisp-importdialog-dropzone-title { color: ${c.fgPrimary}; font-size: ${c.text.lg}; line-height: 20px; font-weight: ${c.weight.medium}; }
72
+ & .crisp-importdialog-dropzone-or { color: ${c.fgMuted}; font-size: ${c.text.md}; line-height: 18px; }
73
+ & .crisp-importdialog-dropzone-choose {
74
+ border: none; background: none; padding: 0; margin: 0; font-family: inherit;
75
+ font-size: ${c.text.lg}; font-weight: ${c.weight.medium}; color: ${c.bgBrandSolid}; cursor: pointer;
76
+ }
77
+ & .crisp-importdialog-dropzone-choose:hover { color: ${c.bgBrandSolidPressed}; }
78
+ & .crisp-importdialog-dropzone-choose:focus-visible { outline: 2px solid ${c.strokeFocusRing}; outline-offset: 2px; border-radius: ${c.radius.sm}; }
79
+ & .crisp-importdialog-dropzone-learnmore { color: ${c.fgMuted}; font-size: ${c.text.md}; text-decoration: underline; }
80
+ & .crisp-importdialog-dropzone-input { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
81
+ & .crisp-importdialog-dropzone-errors { margin: 0; padding: 0; list-style: none; }
82
+ & .crisp-importdialog-dropzone-errors:empty { position: absolute; }
83
+ & .crisp-importdialog-dropzone-error { color: ${c.bgDanger}; font-size: ${c.text.md}; line-height: 18px; }`,
84
+ variants: {},
85
+ defaultVariants: {},
86
+ compoundVariants: [],
87
+ });
88
+ export const importdialogRecipe = resolveRecipe(config);
89
+ export default config;
@@ -0,0 +1,2 @@
1
+ export * from "./importdialog.js";
2
+ export * from "./importdialog.recipe.js";
@@ -0,0 +1,2 @@
1
+ export { SavedViews, } from "./savedviews.js";
2
+ export { savedviewsRecipe } from "./savedviews.recipe.js";
@@ -0,0 +1,171 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ChevronDown, MoreVertical, Plus, Users2 } from "lucide-react";
3
+ import * as React from "react";
4
+ import { Menu, MenuItem } from "../../components/menu/index.js";
5
+ import { useListboxKeyboard } from "../../components/select/index.js";
6
+ import { FloatingPortal, hasNestedFloatingLayer, isInsideLaterFloatingLayer, placeFloating, registerFloatingLayer, useCrispMessage, } from "../../core/index.js";
7
+ import { savedviewsRecipe } from "./savedviews.recipe.js";
8
+ /**
9
+ * A saved-view switcher — measured from Attio's "All Companies ▾" menu. A
10
+ * chip trigger opens a popover listing every saved view as a `role="option"`
11
+ * row (leading coloured icon, name, a hover-revealed kebab of
12
+ * rename/duplicate/delete), plus a trailing "Create new view" row. The
13
+ * active view is marked `aria-selected`/`aria-current="page"` and mirrored
14
+ * onto the trigger's own icon/label.
15
+ *
16
+ * Keyboard: while the trigger has focus and the panel is open, ↑/↓ move a
17
+ * virtual highlight (`aria-activedescendant`, the same "focus stays on the
18
+ * trigger" model `Select`/`MultiSelect` use without a search field), Enter
19
+ * chooses the highlighted row, Escape closes — deferring to a still-open
20
+ * kebab submenu first (`hasOpenFloatingLayer`), so Escape closes one layer
21
+ * at a time.
22
+ */
23
+ export function SavedViews({ label, icon, views, activeId, onSelect, onCreate, onRename, onDuplicate, onDelete, onToggleFavorite, createLabel: createLabelProp, renameLabel: renameLabelProp, duplicateLabel: duplicateLabelProp, deleteLabel: deleteLabelProp, favoriteLabel: favoriteLabelProp, viewOptionsLabel: viewOptionsLabelProp, className, "aria-label": ariaLabel, ...rest }) {
24
+ const createLabel = useCrispMessage(createLabelProp, "Create new view");
25
+ const renameLabel = useCrispMessage(renameLabelProp, "Rename");
26
+ const duplicateLabel = useCrispMessage(duplicateLabelProp, "Duplicate");
27
+ const deleteLabel = useCrispMessage(deleteLabelProp, "Delete");
28
+ const favoriteLabel = useCrispMessage(favoriteLabelProp, "Add to favorites");
29
+ const viewOptionsLabel = useCrispMessage(viewOptionsLabelProp, "View options");
30
+ const autoId = React.useId();
31
+ const triggerId = `${autoId}-trigger`;
32
+ // role="combobox" (below) does not take its name from content — unlike a
33
+ // plain button, aria-activedescendant is only valid on a widget role that
34
+ // supports it (Select's/MultiSelect's own !searchable trigger is the same
35
+ // shape). aria-labelledby, resolved recursively, DOES read a referenced
36
+ // node's own text, so pointing it at the visible label span gives the
37
+ // trigger a name equal to what's on screen without a second, redundant
38
+ // aria-label prop — self-referencing (aria-labelledby={triggerId} on the
39
+ // trigger itself) does not work, since the referenced node's own
40
+ // name-from-content restriction still applies to it.
41
+ const triggerLabelId = `${autoId}-trigger-label`;
42
+ const panelId = `${autoId}-panel`;
43
+ const optionId = (i) => `${autoId}-opt-${i}`;
44
+ const [open, setOpen] = React.useState(false);
45
+ const [up, setUp] = React.useState(false);
46
+ const [pos, setPos] = React.useState({});
47
+ const [highlight, setHighlight] = React.useState(-1);
48
+ const rootRef = React.useRef(null);
49
+ const triggerRef = React.useRef(null);
50
+ const panelRef = React.useRef(null);
51
+ const activeView = views.find((v) => v.id === activeId) ?? null;
52
+ // the trailing "Create new view" row is one more enabled index, past every
53
+ // real view
54
+ const createIndex = views.length;
55
+ const openPanel = () => {
56
+ const rect = triggerRef.current?.getBoundingClientRect();
57
+ if (rect) {
58
+ const { style, up: flipUp } = placeFloating({
59
+ rect,
60
+ gap: 4,
61
+ flipBelow: 280,
62
+ anchor: triggerRef.current,
63
+ });
64
+ setUp(flipUp);
65
+ setPos(style);
66
+ }
67
+ setHighlight(-1);
68
+ setOpen(true);
69
+ };
70
+ const close = React.useCallback(() => {
71
+ setOpen(false);
72
+ triggerRef.current?.focus({ preventScroll: true });
73
+ }, []);
74
+ const commit = () => {
75
+ if (highlight === createIndex) {
76
+ onCreate();
77
+ close();
78
+ }
79
+ else {
80
+ const v = views[highlight];
81
+ if (v) {
82
+ onSelect(v.id);
83
+ close();
84
+ }
85
+ }
86
+ };
87
+ const { onKeyDown: onNavKeys } = useListboxKeyboard({
88
+ enabledIndexes: Array.from({ length: views.length + 1 }, (_, i) => i),
89
+ highlight,
90
+ setHighlight,
91
+ commit,
92
+ close,
93
+ dismiss: () => setOpen(false),
94
+ });
95
+ // Registers the portaled panel on the shared floating-layer stack (#339)
96
+ // so a kebab's own Menu (also portaled, also registered) counts as
97
+ // "inside" this layer for outside-click purposes, and so this panel's own
98
+ // Escape can defer to it below.
99
+ React.useEffect(() => {
100
+ if (!open || !panelRef.current)
101
+ return;
102
+ return registerFloatingLayer(panelRef.current);
103
+ }, [open]);
104
+ React.useEffect(() => {
105
+ if (!open)
106
+ return;
107
+ const onDown = (e) => {
108
+ const target = e.target;
109
+ if (rootRef.current?.contains(target))
110
+ return;
111
+ if (panelRef.current?.contains(target))
112
+ return;
113
+ if (isInsideLaterFloatingLayer(target, panelRef.current))
114
+ return;
115
+ setOpen(false);
116
+ };
117
+ document.addEventListener("pointerdown", onDown);
118
+ return () => document.removeEventListener("pointerdown", onDown);
119
+ }, [open]);
120
+ // Escape defers to a still-open kebab submenu (core/floating-stack.ts):
121
+ // without this, both this panel's listener AND the kebab Menu's own
122
+ // Escape listener fire on the same keydown and both close at once, which
123
+ // is the double-close bug that module's doc comment warns about. One
124
+ // Escape now closes the kebab; a second closes the panel.
125
+ //
126
+ // `hasNestedFloatingLayer`, not `hasOpenFloatingLayer`: this panel
127
+ // registers ITSELF on the stack (below), so a plain "is the stack
128
+ // non-empty" check would always be true the moment this panel is open and
129
+ // Escape would never reach `close()` — `hasNestedFloatingLayer` excludes
130
+ // this panel's own slot and only counts a layer registered after it.
131
+ React.useEffect(() => {
132
+ if (!open)
133
+ return;
134
+ const onKey = (e) => {
135
+ if (e.key !== "Escape")
136
+ return;
137
+ if (hasNestedFloatingLayer(panelRef.current))
138
+ return;
139
+ close();
140
+ };
141
+ document.addEventListener("keydown", onKey);
142
+ return () => document.removeEventListener("keydown", onKey);
143
+ }, [open, close]);
144
+ const triggerIcon = activeView ? activeView.icon : icon;
145
+ const triggerLabel = activeView ? activeView.name : label;
146
+ return (_jsxs("div", { ref: rootRef, className: savedviewsRecipe({ className }), ...rest, children: [_jsxs("button", { ref: triggerRef, id: triggerId, type: "button",
147
+ // combobox, not the bare default "button" role: aria-activedescendant
148
+ // (the virtual-focus highlight, same model Select/MultiSelect use
149
+ // without a search field) is only valid on a widget role that
150
+ // supports it, and combobox's aria-haspopup explicitly allows
151
+ // "dialog" — the value Attio's own trigger measures — alongside the
152
+ // more common "listbox".
153
+ role: "combobox", "aria-haspopup": "dialog", "aria-expanded": open, "aria-controls": open ? panelId : undefined, "aria-activedescendant": open && highlight >= 0 ? optionId(highlight) : undefined, "aria-label": ariaLabel, "aria-labelledby": ariaLabel ? undefined : triggerLabelId, "data-open": open ? "" : undefined, className: "crisp-savedviews-trigger", onClick: () => (open ? setOpen(false) : openPanel()), onKeyDown: (e) => {
154
+ if (open)
155
+ onNavKeys(e);
156
+ else if (e.key === "ArrowDown" || e.key === "Enter") {
157
+ e.preventDefault();
158
+ openPanel();
159
+ }
160
+ }, children: [triggerIcon != null && (_jsx("span", { className: "crisp-savedviews-trigger-icon", children: triggerIcon })), _jsx("span", { id: triggerLabelId, className: "crisp-savedviews-trigger-label", children: triggerLabel }), _jsx(ChevronDown, { className: "crisp-savedviews-trigger-chevron", size: 16, strokeWidth: 1.75 })] }), open && (_jsx(FloatingPortal, { sourceRef: rootRef, children: _jsxs("div", { ref: panelRef, id: panelId, role: "listbox", "aria-label": ariaLabel, "aria-labelledby": ariaLabel ? undefined : triggerLabelId, className: "crisp-savedviews-panel", "data-up": up ? "" : undefined, style: pos, children: [views.map((v, i) => {
161
+ const selected = v.id === activeId;
162
+ return (_jsxs("div", { id: optionId(i), role: "option", "aria-selected": selected, "aria-current": selected ? "page" : undefined, "data-highlighted": i === highlight ? "" : undefined, className: "crisp-savedviews-row", onMouseEnter: () => setHighlight(i), onClick: () => {
163
+ onSelect(v.id);
164
+ close();
165
+ }, children: [v.icon != null && (_jsx("span", { className: "crisp-savedviews-row-icon", children: v.icon })), _jsx("span", { className: "crisp-savedviews-row-label", children: v.name }), v.shared && (_jsx("span", { className: "crisp-savedviews-row-shared", "aria-hidden": "true", children: _jsx(Users2, { size: 12, strokeWidth: 1.75 }) })), _jsx("div", { className: "crisp-savedviews-kebab-slot", onClick: (e) => e.stopPropagation(), children: _jsxs(Menu, { align: "end", trigger: _jsx("button", { type: "button", className: "crisp-savedviews-kebab", "aria-label": viewOptionsLabel, children: _jsx(MoreVertical, { size: 14, strokeWidth: 1.75 }) }), children: [onToggleFavorite && (_jsx(MenuItem, { onSelect: () => onToggleFavorite(v.id), children: favoriteLabel })), _jsx(MenuItem, { onSelect: () => onRename(v.id), children: renameLabel }), _jsx(MenuItem, { onSelect: () => onDuplicate(v.id), children: duplicateLabel }), _jsx(MenuItem, { danger: true, onSelect: () => onDelete(v.id), children: deleteLabel })] }) })] }, v.id));
166
+ }), _jsxs("div", { id: optionId(createIndex), role: "option", "aria-selected": false, "data-highlighted": highlight === createIndex ? "" : undefined, className: "crisp-savedviews-row", onMouseEnter: () => setHighlight(createIndex), onClick: () => {
167
+ onCreate();
168
+ close();
169
+ }, children: [_jsx("span", { className: "crisp-savedviews-row-icon", "aria-hidden": "true", children: _jsx(Plus, { size: 16, strokeWidth: 1.75 }) }), _jsx("span", { className: "crisp-savedviews-row-label", children: createLabel })] })] }) }))] }));
170
+ }
171
+ SavedViews.displayName = "SavedViews";
@@ -0,0 +1,100 @@
1
+ import { defineRecipe, resolveRecipe } from "../../core/index.js";
2
+ import { contract as c, elevation as e } from "../../tokens/index.js";
3
+ // A saved-view switcher — measured live from Attio's own "All Companies ▾"
4
+ // trigger (app.attio.com/techpartners/companies): a 28px chip opens a
5
+ // popover of 32px `role="option"` rows, each revealing a 20px kebab on
6
+ // hover that opens a second, identical-chrome popover of rename/duplicate/
7
+ // delete actions. The trigger stays un-portaled (`&`-nested); the panel and
8
+ // its rows portal to document.body (#339), so they live in `globalCss`, not
9
+ // nested under `&` — the same split Menu/MultiSelect use and for the same
10
+ // reason: a descendant selector needs real DOM containment, which the
11
+ // portal breaks.
12
+ //
13
+ // Panel chrome (radius 12 / 4px padding / elevation.floating) and row height
14
+ // (32px) are the exact values Menu's own `crisp-menu-content`/`crisp-menu-item`
15
+ // already carry — reused by reference (radius.xl, space["1"], size.menuRow,
16
+ // elevation.floating), not re-measured, since it is the same overlay
17
+ // surface. The panel's measured 308.75×182 size is deliberately NOT
18
+ // hardcoded — it grows with the number of views — so width/height are left
19
+ // to content, with the same 200px floor Menu's own popover uses.
20
+ const config = defineRecipe({
21
+ name: "savedviews",
22
+ base: `
23
+ position: relative; display: inline-flex; font-family: ${c.font.sans};
24
+ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
25
+
26
+ /* Trigger fill measured rgb(246,247,247); bgRaised's light value is
27
+ #f4f4f5 (244,244,245) — a 2-unit-per-channel difference, inside
28
+ screenshot/anti-aliasing noise, not a second real colour. Reusing the
29
+ token (Lesson 3 — tokens, not raw px/hex) gets a correct,
30
+ already-established dark-mode fill for free instead of a light-only
31
+ literal; AUTHORING.md Lesson 29 is the case where that shortcut went
32
+ wrong (a flat colour that stayed visible where the reference had
33
+ none) — this is the opposite risk (a token that already flips per
34
+ theme, applied to the closest measured match). */
35
+ & .crisp-savedviews-trigger {
36
+ box-sizing: border-box; display: inline-flex; align-items: center; gap: ${c.space["1.5"]};
37
+ height: ${c.size.row}; margin: 0; padding: ${c.space["1"]} ${c.space["1.5"]};
38
+ border: none; border-radius: ${c.radius.md}; background: ${c.bgRaised};
39
+ font-family: inherit; font-size: ${c.text.lg}; font-weight: ${c.weight.medium}; color: ${c.fgPrimary};
40
+ cursor: pointer; white-space: nowrap; transition: background-color .12s ease;
41
+ }
42
+ & .crisp-savedviews-trigger:hover,
43
+ & .crisp-savedviews-trigger[data-open] { background: color-mix(in srgb, ${c.fgPrimary} 4%, ${c.bgRaised}); }
44
+ & .crisp-savedviews-trigger-icon { flex-shrink: 0; display: inline-flex; width: ${c.size.icon}; height: ${c.size.icon}; }
45
+ & .crisp-savedviews-trigger-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
46
+ & .crisp-savedviews-trigger-chevron { flex-shrink: 0; color: ${c.fgMuted}; }
47
+ `,
48
+ globalCss: `
49
+ /* pointer-events: auto (#375) — see Menu/MultiSelect's matching comment:
50
+ a modal Dialog sets pointer-events: none on document.body, and a
51
+ portaled layer outside Zag's knowledge inherits that lock. */
52
+ .crisp-savedviews-panel {
53
+ font-family: ${c.font.sans}; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
54
+ position: fixed; z-index: 70; pointer-events: auto; box-sizing: border-box; display: flex; flex-direction: column; gap: ${c.space.px};
55
+ min-width: 200px; max-height: 360px; overflow-y: auto; padding: ${c.space["1"]};
56
+ border-radius: ${c.radius.xl}; background: ${c.bgPopover};
57
+ box-shadow: ${e.floating};
58
+ transform-origin: top left; opacity: 1; transform: none; transition: opacity .08s ease, transform .08s ease;
59
+ }
60
+ .crisp-savedviews-panel[data-up] { transform-origin: bottom left; }
61
+ .crisp-savedviews-panel { @starting-style { opacity: 0; transform: scale(0.97) translateY(-1px); } }
62
+
63
+ .crisp-savedviews-row {
64
+ box-sizing: border-box; position: relative; display: flex; align-items: center; gap: ${c.space["1.5"]};
65
+ height: ${c.size.menuRow}; flex-shrink: 0; padding: ${c.space["1.5"]} ${c.space["2"]};
66
+ border-radius: ${c.radius.md}; cursor: pointer; -webkit-user-select: none; user-select: none;
67
+ }
68
+ .crisp-savedviews-row:hover, .crisp-savedviews-row[data-highlighted] { background: ${c.bgRaised}; }
69
+ .crisp-savedviews-row-icon { flex-shrink: 0; display: inline-flex; width: ${c.size.icon}; height: ${c.size.icon}; color: ${c.fgMuted}; }
70
+ .crisp-savedviews-row-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: ${c.text.lg}; font-weight: ${c.weight.medium}; color: ${c.fgPrimary}; }
71
+ .crisp-savedviews-row[aria-selected="true"] .crisp-savedviews-row-label { font-weight: ${c.weight.semibold}; }
72
+ .crisp-savedviews-row-shared { flex-shrink: 0; display: inline-flex; color: ${c.fgMuted}; }
73
+
74
+ /* The kebab's own <Menu> is wrapped in a slot div (savedviews.tsx) that
75
+ stops a click from bubbling — through the REACT tree, which a portal
76
+ does not leave, even though the Menu's content is a DOM sibling under
77
+ document.body — into the row's own onClick. The slot, not the kebab
78
+ button itself, is what the row's flex layout pushes to the trailing
79
+ edge. */
80
+ .crisp-savedviews-kebab-slot { flex-shrink: 0; margin-inline-start: auto; }
81
+ /* 20px / 4px padding is a component-intrinsic size (AUTHORING.md §4b —
82
+ "a badge's 20px" is the named example), used once here: raw, not a
83
+ scale step. */
84
+ .crisp-savedviews-kebab {
85
+ box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center;
86
+ width: 20px; height: 20px; padding: ${c.space["1"]}; margin: 0;
87
+ border: none; border-radius: ${c.radius.sm}; background: none; color: ${c.fgMuted}; cursor: pointer;
88
+ opacity: 0; transition: opacity .1s ease, background-color .12s ease, color .12s ease;
89
+ }
90
+ .crisp-savedviews-row:hover .crisp-savedviews-kebab,
91
+ .crisp-savedviews-row:focus-within .crisp-savedviews-kebab,
92
+ .crisp-savedviews-kebab[data-open] { opacity: 1; }
93
+ .crisp-savedviews-kebab:hover, .crisp-savedviews-kebab[data-open] { background: ${c.bgSurface}; color: ${c.fgPrimary}; }
94
+ `,
95
+ variants: {},
96
+ defaultVariants: {},
97
+ compoundVariants: [],
98
+ });
99
+ export const savedviewsRecipe = resolveRecipe(config);
100
+ export default config;