@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.
- package/dist/cjs/components/datatable/datatable.js +2 -1
- package/dist/cjs/components/datatable/datatable.recipe.js +4 -0
- package/dist/cjs/components/mentionchip/index.js +7 -0
- package/dist/cjs/components/mentionchip/mentionchip.js +57 -0
- package/dist/cjs/components/mentionchip/mentionchip.recipe.js +38 -0
- package/dist/cjs/components/table/table.recipe.js +15 -3
- package/dist/cjs/core/floating-stack.js +18 -0
- package/dist/cjs/core/index.js +2 -1
- package/dist/cjs/index.js +6 -0
- package/dist/cjs/product/formdialog/formdialog.js +171 -0
- package/dist/cjs/product/formdialog/formdialog.recipe.js +26 -0
- package/dist/cjs/product/formdialog/index.js +8 -0
- package/dist/cjs/product/importdialog/importdialog.js +134 -0
- package/dist/cjs/product/importdialog/importdialog.recipe.js +92 -0
- package/dist/cjs/product/importdialog/index.js +18 -0
- package/dist/cjs/product/savedviews/index.js +7 -0
- package/dist/cjs/product/savedviews/savedviews.js +207 -0
- package/dist/cjs/product/savedviews/savedviews.recipe.js +103 -0
- package/dist/cjs/product/taskstable/index.js +7 -0
- package/dist/cjs/product/taskstable/taskstable.js +27 -0
- package/dist/cjs/product/taskstable/taskstable.recipe.js +47 -0
- package/dist/cjs/product/themepicker/index.js +17 -0
- package/dist/cjs/product/themepicker/themepicker.js +32 -0
- package/dist/cjs/product/themepicker/themepicker.recipe.js +98 -0
- package/dist/cjs/tokens/palette.js +30 -13
- package/dist/cjs/tokens/semantic.js +6 -0
- package/dist/datatable.css +20 -21
- package/dist/datatable.layered.css +20 -21
- package/dist/esm/components/datatable/datatable.js +2 -1
- package/dist/esm/components/datatable/datatable.recipe.js +4 -0
- package/dist/esm/components/mentionchip/index.js +2 -0
- package/dist/esm/components/mentionchip/mentionchip.js +21 -0
- package/dist/esm/components/mentionchip/mentionchip.recipe.js +35 -0
- package/dist/esm/components/table/table.recipe.js +15 -3
- package/dist/esm/core/floating-stack.js +17 -0
- package/dist/esm/core/index.js +1 -1
- package/dist/esm/index.js +6 -0
- package/dist/esm/product/formdialog/formdialog.js +134 -0
- package/dist/esm/product/formdialog/formdialog.recipe.js +23 -0
- package/dist/esm/product/formdialog/index.js +2 -0
- package/dist/esm/product/importdialog/importdialog.js +97 -0
- package/dist/esm/product/importdialog/importdialog.recipe.js +89 -0
- package/dist/esm/product/importdialog/index.js +2 -0
- package/dist/esm/product/savedviews/index.js +2 -0
- package/dist/esm/product/savedviews/savedviews.js +171 -0
- package/dist/esm/product/savedviews/savedviews.recipe.js +100 -0
- package/dist/esm/product/taskstable/index.js +2 -0
- package/dist/esm/product/taskstable/taskstable.js +24 -0
- package/dist/esm/product/taskstable/taskstable.recipe.js +44 -0
- package/dist/esm/product/themepicker/index.js +1 -0
- package/dist/esm/product/themepicker/themepicker.js +29 -0
- package/dist/esm/product/themepicker/themepicker.recipe.js +95 -0
- package/dist/esm/tokens/palette.js +30 -13
- package/dist/esm/tokens/semantic.js +6 -0
- package/dist/formdialog.css +6 -0
- package/dist/formdialog.d.ts +7 -0
- package/dist/formdialog.layered.css +8 -0
- package/dist/importdialog.css +38 -0
- package/dist/importdialog.d.ts +7 -0
- package/dist/importdialog.layered.css +40 -0
- package/dist/mentionchip.css +3 -0
- package/dist/mentionchip.d.ts +7 -0
- package/dist/mentionchip.layered.css +5 -0
- package/dist/native/tokens.d.ts +1 -1
- package/dist/native/tokens.js +14 -6
- package/dist/savedviews.css +78 -0
- package/dist/savedviews.d.ts +7 -0
- package/dist/savedviews.layered.css +80 -0
- package/dist/styles.css +223 -30
- package/dist/styles.layered.css +223 -30
- package/dist/table.css +15 -6
- package/dist/table.layered.css +15 -6
- package/dist/taskstable.css +23 -0
- package/dist/taskstable.d.ts +7 -0
- package/dist/taskstable.layered.css +25 -0
- package/dist/themepicker.css +27 -0
- package/dist/themepicker.d.ts +7 -0
- package/dist/themepicker.layered.css +29 -0
- package/dist/tokens.css +7 -3
- package/dist/tokens.json +14 -6
- package/dist/types/components/datatable/datatable.d.ts +12 -1
- package/dist/types/components/mentionchip/index.d.ts +2 -0
- package/dist/types/components/mentionchip/mentionchip.d.ts +20 -0
- package/dist/types/components/mentionchip/mentionchip.recipe.d.ts +18 -0
- package/dist/types/core/floating-stack.d.ts +14 -0
- package/dist/types/core/index.d.ts +1 -1
- package/dist/types/index.d.ts +6 -0
- package/dist/types/product/formdialog/formdialog.d.ts +57 -0
- package/dist/types/product/formdialog/formdialog.recipe.d.ts +3 -0
- package/dist/types/product/formdialog/index.d.ts +2 -0
- package/dist/types/product/importdialog/importdialog.d.ts +86 -0
- package/dist/types/product/importdialog/importdialog.recipe.d.ts +3 -0
- package/dist/types/product/importdialog/index.d.ts +2 -0
- package/dist/types/product/savedviews/index.d.ts +2 -0
- package/dist/types/product/savedviews/savedviews.d.ts +55 -0
- package/dist/types/product/savedviews/savedviews.recipe.d.ts +3 -0
- package/dist/types/product/taskstable/index.d.ts +2 -0
- package/dist/types/product/taskstable/taskstable.d.ts +54 -0
- package/dist/types/product/taskstable/taskstable.recipe.d.ts +3 -0
- package/dist/types/product/themepicker/index.d.ts +1 -0
- package/dist/types/product/themepicker/themepicker.d.ts +21 -0
- package/dist/types/product/themepicker/themepicker.recipe.d.ts +3 -0
- package/dist/types/tokens/contract.d.ts +2 -0
- package/dist/types/tokens/palette.d.ts +11 -3
- package/dist/types/tokens/semantic.d.ts +6 -0
- 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,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,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;
|