@crisp-ui-kit/crisp 0.56.0 → 0.57.1
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/accentpicker.css +5 -0
- package/dist/accentpicker.d.ts +7 -0
- package/dist/accentpicker.layered.css +7 -0
- package/dist/avatar.css +1 -1
- package/dist/avatar.layered.css +1 -1
- package/dist/badge.css +2 -2
- package/dist/badge.layered.css +2 -2
- package/dist/button.css +3 -3
- package/dist/button.layered.css +3 -3
- package/dist/callout.css +6 -0
- package/dist/callout.d.ts +2 -2
- package/dist/callout.layered.css +6 -0
- package/dist/cjs/components/accentpicker/accentpicker.js +66 -0
- package/dist/cjs/components/accentpicker/accentpicker.recipe.js +25 -0
- package/dist/cjs/components/accentpicker/index.js +7 -0
- package/dist/cjs/components/avatar/avatar.recipe.js +4 -1
- package/dist/cjs/components/badge/badge.recipe.js +16 -2
- package/dist/cjs/components/button/button.recipe.js +16 -6
- package/dist/cjs/components/callout/callout.js +2 -2
- package/dist/cjs/components/callout/callout.recipe.js +15 -2
- package/dist/cjs/components/input/input.js +2 -2
- package/dist/cjs/components/input/input.recipe.js +14 -0
- package/dist/cjs/components/menu/menu.js +2 -2
- package/dist/cjs/components/menu/menu.recipe.js +8 -0
- package/dist/cjs/components/multiselect/multiselect.js +23 -10
- package/dist/cjs/components/multiselect/multiselect.recipe.js +104 -65
- package/dist/cjs/components/progress/progress.js +21 -4
- package/dist/cjs/components/progress/progress.recipe.js +30 -0
- package/dist/cjs/components/select/listbox-keyboard.js +7 -1
- package/dist/cjs/components/select/select.js +24 -11
- package/dist/cjs/components/select/select.recipe.js +177 -78
- package/dist/cjs/components/tabletoolbar/tabletoolbar.js +3 -3
- package/dist/cjs/components/tabletoolbar/tabletoolbar.recipe.js +26 -1
- package/dist/cjs/components/tabs/tabs.js +63 -4
- package/dist/cjs/components/tabs/tabs.recipe.js +13 -0
- package/dist/cjs/components/text/text.recipe.js +8 -7
- package/dist/cjs/components/textarea/textarea.js +3 -2
- package/dist/cjs/components/textarea/textarea.recipe.js +2 -1
- package/dist/cjs/components/textlink/textlink.js +2 -2
- package/dist/cjs/components/textlink/textlink.recipe.js +24 -3
- package/dist/cjs/index.js +1 -0
- package/dist/cjs/product/recordpage/recordpage.js +61 -7
- package/dist/cjs/tokens/elevation.js +7 -0
- package/dist/cjs/tokens/palette.js +120 -25
- package/dist/cjs/tokens/scale.js +9 -0
- package/dist/cjs/tokens/semantic.js +42 -0
- package/dist/esm/components/accentpicker/accentpicker.js +30 -0
- package/dist/esm/components/accentpicker/accentpicker.recipe.js +22 -0
- package/dist/esm/components/accentpicker/index.js +2 -0
- package/dist/esm/components/avatar/avatar.recipe.js +4 -1
- package/dist/esm/components/badge/badge.recipe.js +16 -2
- package/dist/esm/components/button/button.recipe.js +16 -6
- package/dist/esm/components/callout/callout.js +2 -2
- package/dist/esm/components/callout/callout.recipe.js +15 -2
- package/dist/esm/components/input/input.js +3 -3
- package/dist/esm/components/input/input.recipe.js +14 -0
- package/dist/esm/components/menu/menu.js +2 -2
- package/dist/esm/components/menu/menu.recipe.js +8 -0
- package/dist/esm/components/multiselect/multiselect.js +23 -10
- package/dist/esm/components/multiselect/multiselect.recipe.js +104 -65
- package/dist/esm/components/progress/progress.js +22 -5
- package/dist/esm/components/progress/progress.recipe.js +30 -0
- package/dist/esm/components/select/listbox-keyboard.js +7 -1
- package/dist/esm/components/select/select.js +24 -11
- package/dist/esm/components/select/select.recipe.js +177 -78
- package/dist/esm/components/tabletoolbar/tabletoolbar.js +3 -3
- package/dist/esm/components/tabletoolbar/tabletoolbar.recipe.js +26 -1
- package/dist/esm/components/tabs/tabs.js +63 -4
- package/dist/esm/components/tabs/tabs.recipe.js +13 -0
- package/dist/esm/components/text/text.recipe.js +8 -7
- package/dist/esm/components/textarea/textarea.js +3 -2
- package/dist/esm/components/textarea/textarea.recipe.js +2 -1
- package/dist/esm/components/textlink/textlink.js +2 -2
- package/dist/esm/components/textlink/textlink.recipe.js +24 -3
- package/dist/esm/index.js +1 -0
- package/dist/esm/product/recordpage/recordpage.js +62 -8
- package/dist/esm/tokens/elevation.js +7 -0
- package/dist/esm/tokens/palette.js +120 -25
- package/dist/esm/tokens/scale.js +9 -0
- package/dist/esm/tokens/semantic.js +42 -0
- package/dist/input.css +11 -0
- package/dist/input.d.ts +2 -2
- package/dist/input.layered.css +11 -0
- package/dist/menu.css +8 -0
- package/dist/menu.layered.css +8 -0
- package/dist/multiselect.css +57 -29
- package/dist/multiselect.d.ts +3 -1
- package/dist/multiselect.layered.css +57 -29
- package/dist/native/tokens.d.ts +1 -1
- package/dist/native/tokens.js +82 -28
- package/dist/progress.css +3 -0
- package/dist/progress.layered.css +3 -0
- package/dist/select.css +100 -42
- package/dist/select.d.ts +3 -2
- package/dist/select.layered.css +100 -42
- package/dist/styles.css +268 -93
- package/dist/styles.layered.css +268 -93
- package/dist/tabletoolbar.css +8 -1
- package/dist/tabletoolbar.layered.css +8 -1
- package/dist/tabs.css +6 -0
- package/dist/tabs.layered.css +6 -0
- package/dist/text.css +1 -1
- package/dist/text.layered.css +1 -1
- package/dist/textarea.css +2 -1
- package/dist/textarea.layered.css +2 -1
- package/dist/textlink.css +11 -2
- package/dist/textlink.d.ts +2 -1
- package/dist/textlink.layered.css +11 -2
- package/dist/tokens.css +43 -11
- package/dist/tokens.json +82 -28
- package/dist/types/components/accentpicker/accentpicker.d.ts +23 -0
- package/dist/types/components/accentpicker/accentpicker.recipe.d.ts +3 -0
- package/dist/types/components/accentpicker/index.d.ts +2 -0
- package/dist/types/components/button/button.recipe.d.ts +2 -2
- package/dist/types/components/callout/callout.d.ts +9 -1
- package/dist/types/components/callout/callout.recipe.d.ts +12 -2
- package/dist/types/components/input/input.d.ts +14 -0
- package/dist/types/components/input/input.recipe.d.ts +3 -0
- package/dist/types/components/menu/menu.d.ts +7 -1
- package/dist/types/components/multiselect/multiselect.d.ts +25 -6
- package/dist/types/components/multiselect/multiselect.recipe.d.ts +27 -0
- package/dist/types/components/progress/progress.d.ts +16 -4
- package/dist/types/components/progress/progress.recipe.d.ts +22 -0
- package/dist/types/components/select/listbox-keyboard.d.ts +10 -1
- package/dist/types/components/select/select.d.ts +29 -6
- package/dist/types/components/select/select.recipe.d.ts +25 -10
- package/dist/types/components/tabletoolbar/tabletoolbar.d.ts +14 -0
- package/dist/types/components/tabs/tabs.d.ts +9 -1
- package/dist/types/components/textarea/textarea.d.ts +16 -0
- package/dist/types/components/textlink/textlink.d.ts +8 -1
- package/dist/types/components/textlink/textlink.recipe.d.ts +10 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/product/recordpage/recordpage.d.ts +61 -1
- package/dist/types/tokens/contract.d.ts +16 -3
- package/dist/types/tokens/elevation.d.ts +7 -0
- package/dist/types/tokens/palette.d.ts +55 -11
- package/dist/types/tokens/scale.d.ts +6 -0
- package/dist/types/tokens/semantic.d.ts +33 -0
- package/package.json +6 -1
|
@@ -126,8 +126,14 @@ const spec = defineSpec({
|
|
|
126
126
|
{
|
|
127
127
|
variant: "solid",
|
|
128
128
|
tone: "brand",
|
|
129
|
+
// your-moon/crisp#897: `bgBrandContrast`, not `bgBrandSolid` — white
|
|
130
|
+
// text on the exact accent blue (#0298d9) only clears 3.23:1;
|
|
131
|
+
// `bgBrandContrast` is the darker sibling built to clear 4.5:1+ with
|
|
132
|
+
// white, so `fgOnBrand` (white) stays right rather than `fgOnTone`
|
|
133
|
+
// (ink read off-brand next to solid `success`/`warning`/`danger`,
|
|
134
|
+
// which really do need it).
|
|
129
135
|
decls: {
|
|
130
|
-
background: c.
|
|
136
|
+
background: c.bgBrandContrast,
|
|
131
137
|
color: c.fgOnBrand,
|
|
132
138
|
boxShadow: "none",
|
|
133
139
|
},
|
|
@@ -140,7 +146,15 @@ const spec = defineSpec({
|
|
|
140
146
|
{
|
|
141
147
|
variant: "solid",
|
|
142
148
|
tone: "warning",
|
|
143
|
-
|
|
149
|
+
// seed-identity migration (your-moon/crisp#890): `bgWarningSolid`, not
|
|
150
|
+
// `bgWarning` — seed's own solid warning fill is a distinct, paler
|
|
151
|
+
// yellow from the accent every other tone's solid fill reuses
|
|
152
|
+
// directly (see tokens/palette.ts's `amberSolid` comment).
|
|
153
|
+
decls: {
|
|
154
|
+
background: c.bgWarningSolid,
|
|
155
|
+
color: c.fgOnTone,
|
|
156
|
+
boxShadow: "none",
|
|
157
|
+
},
|
|
144
158
|
},
|
|
145
159
|
{
|
|
146
160
|
variant: "solid",
|
|
@@ -27,7 +27,13 @@ const spec = defineSpec({
|
|
|
27
27
|
intent: {
|
|
28
28
|
primary: {
|
|
29
29
|
decls: {
|
|
30
|
-
|
|
30
|
+
// your-moon/crisp#897: `bgBrandContrast`, not `bgBrandSolid` —
|
|
31
|
+
// white text on the exact accent blue (#0298d9) only clears
|
|
32
|
+
// 3.23:1; `bgBrandContrast` is the darker sibling built to clear
|
|
33
|
+
// white at 4.5:1+, keeping `fgOnBrand` (white) rather than
|
|
34
|
+
// reaching for `fgOnTone` (ink read off-brand — seed's own solid
|
|
35
|
+
// buttons use white).
|
|
36
|
+
background: c.bgBrandContrast,
|
|
31
37
|
color: c.fgOnBrand,
|
|
32
38
|
// crisp#494: the inset ring is a black tint, not white -- live-
|
|
33
39
|
// measured on Attio's own "New Company" button as
|
|
@@ -36,12 +42,16 @@ const spec = defineSpec({
|
|
|
36
42
|
// exactly.
|
|
37
43
|
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.06), 0 2px 4px -2px rgba(38,109,240,0.12), 0 3px 6px -2px rgba(38,109,240,0.08)",
|
|
38
44
|
},
|
|
45
|
+
// your-moon/crisp#897: a brightness filter, not a second background
|
|
46
|
+
// swap — matches `danger`'s own pattern below. `bgBrandSolidPressed`
|
|
47
|
+
// remains the accent's own hover-darken for the many OTHER
|
|
48
|
+
// consumers that pair `bgBrandSolid` with white already (out of
|
|
49
|
+
// scope for this fix; tracked separately), so reusing it here would
|
|
50
|
+
// have made primary's rest-to-hover jump inconsistent with its own
|
|
51
|
+
// rest fill's new, darker starting point.
|
|
39
52
|
parts: [
|
|
40
|
-
{ selector: "&:hover", decls: {
|
|
41
|
-
{
|
|
42
|
-
selector: "&:active",
|
|
43
|
-
decls: { background: c.bgBrandSolidPressed },
|
|
44
|
-
},
|
|
53
|
+
{ selector: "&:hover", decls: { filter: "brightness(0.96)" } },
|
|
54
|
+
{ selector: "&:active", decls: { filter: "brightness(0.92)" } },
|
|
45
55
|
],
|
|
46
56
|
},
|
|
47
57
|
neutral: {
|
|
@@ -12,11 +12,11 @@ const DEFAULT_ICONS = {
|
|
|
12
12
|
* An inline callout / banner — measured from info notices. `tone` tints
|
|
13
13
|
* the background and icon; pass a `title` and/or body `children`.
|
|
14
14
|
*/
|
|
15
|
-
export function Callout({ tone = "neutral", icon, title, children, action, className, ...rest }) {
|
|
15
|
+
export function Callout({ tone = "neutral", icon, title, children, action, bordered, className, ...rest }) {
|
|
16
16
|
const Fallback = DEFAULT_ICONS[tone];
|
|
17
17
|
const iconNode = icon === undefined
|
|
18
18
|
? Fallback && _jsx(Fallback, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" })
|
|
19
19
|
: icon;
|
|
20
|
-
return (_jsxs("div", { className: calloutRecipe({ className }), ...rest, "data-tone": tone, role: "note", children: [iconNode && _jsx("span", { className: "crisp-callout-icon", children: iconNode }), _jsxs("div", { className: "crisp-callout-body", children: [title != null && _jsx("div", { className: "crisp-callout-title", children: title }), children != null && (_jsx("div", { className: "crisp-callout-desc", children: children }))] }), action && _jsx("div", { className: "crisp-callout-action", children: action })] }));
|
|
20
|
+
return (_jsxs("div", { className: calloutRecipe({ bordered, className }), ...rest, "data-tone": tone, role: "note", children: [iconNode && _jsx("span", { className: "crisp-callout-icon", children: iconNode }), _jsxs("div", { className: "crisp-callout-body", children: [title != null && _jsx("div", { className: "crisp-callout-title", children: title }), children != null && (_jsx("div", { className: "crisp-callout-desc", children: children }))] }), action && _jsx("div", { className: "crisp-callout-action", children: action })] }));
|
|
21
21
|
}
|
|
22
22
|
Callout.displayName = "Callout";
|
|
@@ -18,8 +18,21 @@ const config = defineRecipe({
|
|
|
18
18
|
&[data-tone="warning"] .crisp-callout-icon { color: ${c.bgWarning}; }
|
|
19
19
|
&[data-tone="danger"] { background: color-mix(in srgb, ${c.bgDanger} 10%, transparent); }
|
|
20
20
|
&[data-tone="danger"] .crisp-callout-icon { color: ${c.bgDanger}; }`,
|
|
21
|
-
variants: {
|
|
22
|
-
|
|
21
|
+
variants: {
|
|
22
|
+
// crisp#869: a page-level banner look, measured live on Attio's Objects
|
|
23
|
+
// settings page (the "Create custom objects... Upgrade" Pro-plan
|
|
24
|
+
// nudge) — a real 1px opaque border, 12px radius, 8px/12px padding, and
|
|
25
|
+
// a 14%-brand-tinted background (the same `color-mix` ratio Badge's own
|
|
26
|
+
// soft-brand variant already uses). Only this one brand/info-toned
|
|
27
|
+
// instance was found live; `bordered` doesn't attempt a per-tone border
|
|
28
|
+
// colour for success/warning/danger — no such Attio banner was found to
|
|
29
|
+
// measure one against.
|
|
30
|
+
bordered: {
|
|
31
|
+
true: `border: 1px solid ${c.ringBrandSoft}; border-radius: ${c.radius.xl}; padding: ${c.space["2"]} ${c.space["3"]}; background: color-mix(in srgb, ${c.bgBrandSolid} 14%, transparent);`,
|
|
32
|
+
false: "",
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
defaultVariants: { bordered: false },
|
|
23
36
|
compoundVariants: [],
|
|
24
37
|
});
|
|
25
38
|
export const calloutRecipe = resolveRecipe(config);
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import * as React from "react";
|
|
3
3
|
import { inputRecipe } from "./input.recipe.js";
|
|
4
|
-
export const Input = React.forwardRef(({ size, invalid, className, style, ...props }, ref) => {
|
|
4
|
+
export const Input = React.forwardRef(({ size, invalid, className, style, suffix, ...props }, ref) => {
|
|
5
5
|
// The wrapper exists only to host the ::after focus ring — an <input> is a
|
|
6
6
|
// replaced element and cannot have one. Everything the consumer passes
|
|
7
7
|
// still lands on the <input> itself, so `id`, `name`, `onChange`, form
|
|
8
8
|
// association and every native attribute behave exactly as before.
|
|
9
|
-
return (
|
|
9
|
+
return (_jsxs("span", { className: inputRecipe({ size, invalid, className }), style: style, "data-readonly": props.readOnly ? "" : undefined, children: [_jsx("input", { ref: ref, className: "crisp-input-value", "aria-invalid": invalid === true || undefined, ...props }), suffix != null && _jsx("span", { className: "crisp-input-suffix", children: suffix })] }));
|
|
10
10
|
});
|
|
11
11
|
Input.displayName = "Input";
|
|
@@ -38,9 +38,23 @@ const config = defineRecipe({
|
|
|
38
38
|
& .crisp-input-value::placeholder { color: ${c.fgMuted}; }
|
|
39
39
|
& .crisp-input-value:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
40
40
|
&:has(.crisp-input-value:disabled) { opacity: 1; }
|
|
41
|
+
|
|
42
|
+
/* crisp#634: measured live on Attio's own Settings > Profile read-only
|
|
43
|
+
primary-email field -- a tinted field, not just muted text, driven off
|
|
44
|
+
the native \`readOnly\` attribute. \`ringField\` (rgba(0,0,0,.06)) is an
|
|
45
|
+
exact match for the measured ring; the 2%-fgPrimary tint reuses the
|
|
46
|
+
same color-mix idiom every other soft crisp surface already uses. */
|
|
47
|
+
&[data-readonly] { background: color-mix(in srgb, ${c.fgPrimary} 2%, transparent); box-shadow: inset 0 0 0 1px ${c.ringField}; }
|
|
48
|
+
& .crisp-input-suffix { flex-shrink: 0; margin-inline-start: ${c.space["1"]}; }
|
|
41
49
|
`,
|
|
42
50
|
variants: {
|
|
43
51
|
size: {
|
|
52
|
+
// crisp#878: not a fresh click-measurement -- reuses `TableToolbar`'s
|
|
53
|
+
// own compact search box shape (`tabletoolbar.chrome.ts`'s `-search`
|
|
54
|
+
// rule), already corroborated against Attio, at the same height as
|
|
55
|
+
// `c.size.row` ("nav item · search · control height"). Same radius and
|
|
56
|
+
// padding `medium` already uses, just 4px shorter.
|
|
57
|
+
sm: `height: ${c.size.row}; border-radius: ${c.radius.md}; & .crisp-input-value { padding-inline: ${c.space["2.5"]}; }`,
|
|
44
58
|
medium: `height: 32px; border-radius: ${c.radius.md}; & .crisp-input-value { padding-inline: ${c.space["2.5"]}; }`,
|
|
45
59
|
large: `height: 36px; border-radius: 10px; & .crisp-input-value { padding-inline: ${c.space["3"]}; }`,
|
|
46
60
|
},
|
|
@@ -128,13 +128,13 @@ export function Menu({ trigger, children, align = "start", open: openProp, defau
|
|
|
128
128
|
: "crisp-menu-content", role: "menu", id: surfaceId, "aria-label": ariaLabel, "aria-labelledby": ariaLabel ? undefined : triggerId, "data-align": align, "data-up": up ? "" : undefined, style: pos, onKeyDown: onContentKeyDown, children: _jsx(MenuContext.Provider, { value: { close }, children: children }) }) }))] }));
|
|
129
129
|
}
|
|
130
130
|
Menu.displayName = "Menu";
|
|
131
|
-
export function MenuItem({ children, icon, shortcut, danger, disabled, onSelect, closeOnSelect = true, }) {
|
|
131
|
+
export function MenuItem({ children, icon, shortcut, count, danger, disabled, onSelect, closeOnSelect = true, }) {
|
|
132
132
|
const ctx = React.useContext(MenuContext);
|
|
133
133
|
return (_jsxs("button", { type: "button", role: "menuitem", tabIndex: -1, disabled: disabled, "data-disabled": disabled ? "" : undefined, className: `crisp-menu-item${danger ? " crisp-menu-item--danger" : ""}`, onClick: () => {
|
|
134
134
|
onSelect?.();
|
|
135
135
|
if (closeOnSelect)
|
|
136
136
|
ctx?.close();
|
|
137
|
-
}, children: [icon, _jsx("span", { className: "crisp-menu-item-label", children: children }), shortcut && _jsx("span", { className: "crisp-menu-item-shortcut", children: shortcut })] }));
|
|
137
|
+
}, children: [icon, _jsx("span", { className: "crisp-menu-item-label", children: children }), count != null && _jsx("span", { className: "crisp-menu-item-count", children: count }), shortcut && _jsx("span", { className: "crisp-menu-item-shortcut", children: shortcut })] }));
|
|
138
138
|
}
|
|
139
139
|
MenuItem.displayName = "MenuItem";
|
|
140
140
|
export function MenuCheckboxItem({ children, checked, onCheckedChange, disabled, }) {
|
|
@@ -54,6 +54,14 @@ const config = defineRecipe({
|
|
|
54
54
|
.crisp-menu-item--danger svg { color: ${c.bgDanger}; }
|
|
55
55
|
.crisp-menu-item--danger:hover, .crisp-menu-item--danger:focus-visible, .crisp-menu-item--danger[data-highlighted] { background: color-mix(in srgb, ${c.bgDanger} 10%, transparent); }
|
|
56
56
|
.crisp-menu-item-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
|
57
|
+
/* crisp#875: same measured neutral chip TableToolbar's own ToolbarTool
|
|
58
|
+
count prop uses (re-confirmed on two live Attio surfaces this round:
|
|
59
|
+
the Sort tool's own "+N" overflow, a Kanban board's stage-column
|
|
60
|
+
count) -- a small badge, not bare text. */
|
|
61
|
+
.crisp-menu-item-count { flex-shrink: 0; margin-inline-start: ${c.space["1.5"]}; display: inline-flex; align-items: center; height: 16px; box-sizing: border-box;
|
|
62
|
+
padding: 0 ${c.space["1"]}; border-radius: ${c.radius.sm}; background: color-mix(in srgb, ${c.fgPrimary} 4%, transparent);
|
|
63
|
+
box-shadow: inset 0 0 0 1px ${c.borderSubtle}; font-size: ${c.text.sm}; font-weight: ${c.weight.medium}; color: ${c.fgNeutralSoft};
|
|
64
|
+
font-variant-numeric: tabular-nums; }
|
|
57
65
|
.crisp-menu-item-shortcut { flex-shrink: 0; margin-inline-start: ${c.space["3"]}; font-size: ${c.text.sm}; color: ${c.fgMuted}; letter-spacing: 0.02em; }
|
|
58
66
|
.crisp-menu-item-check { flex-shrink: 0; color: ${c.bgBrandSolid}; }
|
|
59
67
|
.crisp-menu-item-check--placeholder { width: 15px; }
|
|
@@ -24,16 +24,18 @@ function Chip({ opt }) {
|
|
|
24
24
|
return (_jsx("span", { className: "crisp-recordstable-tag", "data-tone": opt.tone ?? toneFor(opt.value), children: opt.label }));
|
|
25
25
|
}
|
|
26
26
|
/**
|
|
27
|
-
* A multi-select / tag editor
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
27
|
+
* A multi-select / tag editor, its trigger/listbox/item chrome built to the
|
|
28
|
+
* same seed-design parity as `Select` (crisp/#882, following #867/#879) —
|
|
29
|
+
* its own chip/checkbox picker behaviour is unchanged. The outlined trigger
|
|
30
|
+
* shows the selected values as coloured chips; it opens a popover with a
|
|
31
|
+
* search field over rows of "[chip] … [checkbox]" that toggle on click (the
|
|
32
|
+
* menu stays open). Keyboard: ↑/↓ move, Enter/Space toggle, Esc closes.
|
|
31
33
|
*/
|
|
32
34
|
export function MultiSelect({ options, value: valueProp, defaultValue, onValueChange, name, required,
|
|
33
35
|
// The id belongs on the trigger, not the wrapper: `<label for>` only names a
|
|
34
36
|
// LABELABLE element, so an id left on the root div meant a FormField label
|
|
35
37
|
// above this control named nothing (#247). Select already did this.
|
|
36
|
-
id, placeholder: placeholderProp, searchable = true, searchPlaceholder: searchPlaceholderProp, emptyLabel: emptyLabelProp, disabled, invalid, className, "aria-label": ariaLabel, ...rest }) {
|
|
38
|
+
id, placeholder: placeholderProp, searchable = true, searchPlaceholder: searchPlaceholderProp, emptyLabel: emptyLabelProp, disabled, readOnly, invalid, size = "small", variant = "default", className, "aria-label": ariaLabel, ...rest }) {
|
|
37
39
|
// crisp/#471: same resolution order as Select's own.
|
|
38
40
|
const placeholder = useCrispMessage(placeholderProp, "Select…");
|
|
39
41
|
const searchPlaceholder = useCrispMessage(searchPlaceholderProp, "Search…");
|
|
@@ -70,8 +72,12 @@ id, placeholder: placeholderProp, searchable = true, searchPlaceholder: searchPl
|
|
|
70
72
|
const filtered = searchable && q
|
|
71
73
|
? options.filter((o) => o.label.toLowerCase().includes(q))
|
|
72
74
|
: options;
|
|
75
|
+
// seed-design's `interactive` (crisp/#882, matching Select): disabled AND
|
|
76
|
+
// readOnly both refuse to open, but only `disabled` greys the text and
|
|
77
|
+
// removes the button from the tab order.
|
|
78
|
+
const interactive = !disabled && !readOnly;
|
|
73
79
|
const openMenu = () => {
|
|
74
|
-
if (
|
|
80
|
+
if (!interactive)
|
|
75
81
|
return;
|
|
76
82
|
const rect = triggerRef.current?.getBoundingClientRect();
|
|
77
83
|
if (rect) {
|
|
@@ -80,7 +86,7 @@ id, placeholder: placeholderProp, searchable = true, searchPlaceholder: searchPl
|
|
|
80
86
|
// direction that is (#210)
|
|
81
87
|
const { style, up: flipUp } = placeFloating({
|
|
82
88
|
rect,
|
|
83
|
-
gap: 4
|
|
89
|
+
gap: 8, // seed-design's gutter (crisp/#882, matching Select; was 4)
|
|
84
90
|
flipBelow: 300,
|
|
85
91
|
anchor: triggerRef.current,
|
|
86
92
|
});
|
|
@@ -125,6 +131,10 @@ id, placeholder: placeholderProp, searchable = true, searchPlaceholder: searchPl
|
|
|
125
131
|
close,
|
|
126
132
|
// Tab is already moving focus out; `close` would drag it back to the trigger
|
|
127
133
|
dismiss: () => setOpen(false),
|
|
134
|
+
// Space also toggles the highlighted option (crisp/#882, matching
|
|
135
|
+
// Select's Enter/Space parity) when the trigger itself owns navigation.
|
|
136
|
+
// Never when searchable -- see Select's own comment on this option.
|
|
137
|
+
commitOnSpace: !searchable,
|
|
128
138
|
});
|
|
129
139
|
// Registers the (now-portaled) listbox on the shared floating-layer stack
|
|
130
140
|
// (#339) so this doesn't get closed out from under itself when nested
|
|
@@ -162,11 +172,14 @@ id, placeholder: placeholderProp, searchable = true, searchPlaceholder: searchPl
|
|
|
162
172
|
document.addEventListener("keydown", onKey);
|
|
163
173
|
return () => document.removeEventListener("keydown", onKey);
|
|
164
174
|
}, [open, close]);
|
|
165
|
-
return (_jsxs("div", { ref: rootRef, className: multiselectRecipe({ invalid, className }), ...rest, children: [name &&
|
|
166
|
-
(value.length > 0 ? value : required ? [""] : []).map((v) => (_jsx("input", { className: "crisp-formvalue", style: formValueStyle, name: name, value: v, required: required, tabIndex: -1, "aria-hidden": "true", onChange: () => { } }, v))), _jsxs("button", { ref: triggerRef, id: id, type: "button", role: "combobox", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listboxId : undefined, "aria-activedescendant": searchable ? undefined : activeId, "aria-label": ariaLabel, "aria-invalid": invalid === true || undefined, disabled: disabled, "data-open": open ? "" : undefined, className: "crisp-multiselect-trigger", onClick: () => (open ? setOpen(false) : openMenu()), onKeyDown: (e) => {
|
|
175
|
+
return (_jsxs("div", { ref: rootRef, className: multiselectRecipe({ size, variant, invalid, className }), ...rest, children: [name &&
|
|
176
|
+
(value.length > 0 ? value : required ? [""] : []).map((v) => (_jsx("input", { className: "crisp-formvalue", style: formValueStyle, name: name, value: v, required: required, tabIndex: -1, "aria-hidden": "true", onChange: () => { } }, v))), _jsxs("button", { ref: triggerRef, id: id, type: "button", role: "combobox", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listboxId : undefined, "aria-activedescendant": searchable ? undefined : activeId, "aria-label": ariaLabel, "aria-invalid": invalid === true || undefined, "aria-readonly": readOnly ? "true" : undefined, disabled: disabled, "data-open": open ? "" : undefined, className: "crisp-multiselect-trigger", onClick: () => (open ? setOpen(false) : openMenu()), onKeyDown: (e) => {
|
|
177
|
+
if (!interactive)
|
|
178
|
+
return;
|
|
167
179
|
if (open && !searchable)
|
|
168
180
|
onNavKeys(e);
|
|
169
|
-
else if (!open &&
|
|
181
|
+
else if (!open &&
|
|
182
|
+
(e.key === "ArrowDown" || e.key === "Enter" || e.key === " ")) {
|
|
170
183
|
e.preventDefault();
|
|
171
184
|
openMenu();
|
|
172
185
|
}
|
|
@@ -1,30 +1,26 @@
|
|
|
1
1
|
import { defineRecipe, resolveRecipe } from "../../core/index.js";
|
|
2
2
|
import { contract as c, elevation as e } from "../../tokens/index.js";
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
// (
|
|
24
|
-
// that helper is written `& .crisp-recordstable-tag { ... }` for nesting
|
|
25
|
-
// under a recipe's `base`, which doesn't compile to anything inside a flat
|
|
26
|
-
// `globalCss` block. Keep the two in sync by hand (#380 added the measured
|
|
27
|
-
// `box-shadow: inset 0 0 0 1px <tone ring colour>` here to match).
|
|
3
|
+
// MultiSelect reads as the same family as Select (crisp/#882, following
|
|
4
|
+
// #867/#879): the trigger/listbox/item chrome below reuses Select's
|
|
5
|
+
// seed-design tokens verbatim (`strokeSelectTrigger`, `bgSelectPressed`,
|
|
6
|
+
// `bgSelectDisabled`, `strokeSelectInvalid`, `bgSelectListbox`,
|
|
7
|
+
// `selectFloating`, `fgSelectNeutral`, `fgSelectPlaceholder`, `radius["3xl"]`,
|
|
8
|
+
// `size.selectTriggerMd/Lg`) rather than resolving a second seed spec --
|
|
9
|
+
// seed-design has no multi-value combobox of its own (`packages/rootage/
|
|
10
|
+
// components/` has no `combobox`/`multiselect`; `chip.yaml`/`tag-group*.yaml`
|
|
11
|
+
// are a different, standalone pill pattern, not a field's multi-value
|
|
12
|
+
// trigger). What stays untouched: the `.crisp-recordstable-tag` chip
|
|
13
|
+
// styling (shared with RecordsTable/DataTable elsewhere), the checkbox-style
|
|
14
|
+
// selected indicator, and the toggle-without-closing interaction.
|
|
15
|
+
//
|
|
16
|
+
// The search row's geometry (36px height, 6px gap, no divider) is an
|
|
17
|
+
// Attio measurement (#481) `build/spec-check.mjs` guards directly -- it is
|
|
18
|
+
// NOT part of the seed-parity change and must not move. Only its text/icon
|
|
19
|
+
// colours follow the rest of this file onto the shared tokens.
|
|
20
|
+
//
|
|
21
|
+
// The listbox portals to `document.body` (#339), so its rules live in
|
|
22
|
+
// `globalCss`, not nested under `&` -- see Select's own recipe for the full
|
|
23
|
+
// reasoning (identical here).
|
|
28
24
|
const config = defineRecipe({
|
|
29
25
|
name: "multiselect",
|
|
30
26
|
base: `
|
|
@@ -32,53 +28,63 @@ const config = defineRecipe({
|
|
|
32
28
|
-webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
|
|
33
29
|
|
|
34
30
|
& .crisp-multiselect-trigger {
|
|
35
|
-
box-sizing: border-box; display: flex; align-items: center;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
transition: background-color .12s ease, box-shadow .12s ease;
|
|
31
|
+
box-sizing: border-box; display: flex; align-items: center; width: 100%; min-width: 0; margin: 0;
|
|
32
|
+
border: 1px solid ${c.strokeSelectTrigger}; background: transparent; cursor: pointer; text-align: start;
|
|
33
|
+
font-family: inherit; color: ${c.fgSelectNeutral};
|
|
34
|
+
transition: background-color .15s cubic-bezier(.35,0,.35,1), border-color .1s cubic-bezier(.35,0,.35,1), border-width .1s cubic-bezier(.35,0,.35,1);
|
|
40
35
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
& .crisp-multiselect-trigger:disabled {
|
|
36
|
+
/* Same pressed/disabled/readonly/focus model as Select's trigger -- see
|
|
37
|
+
that recipe's comment for why hover and an open/focus ring are gone. */
|
|
38
|
+
& .crisp-multiselect-trigger:active:not(:disabled):not([aria-readonly="true"]) { background: ${c.bgSelectPressed}; }
|
|
39
|
+
& .crisp-multiselect-trigger:focus-visible { outline: 2px solid ${c.strokeFocusRing}; outline-offset: -2px; }
|
|
40
|
+
& .crisp-multiselect-trigger:disabled { background: ${c.bgSelectDisabled}; cursor: not-allowed; }
|
|
41
|
+
& .crisp-multiselect-trigger:disabled .crisp-multiselect-placeholder,
|
|
42
|
+
& .crisp-multiselect-trigger:disabled .crisp-multiselect-chevron { color: ${c.fgDisabled}; }
|
|
43
|
+
& .crisp-multiselect-trigger[aria-readonly="true"] { background: ${c.bgSelectDisabled}; cursor: default; }
|
|
44
44
|
& .crisp-multiselect-chips { flex: 1; min-width: 0; display: flex; flex-wrap: wrap; align-items: center; gap: ${c.space["1"]}; }
|
|
45
|
-
& .crisp-multiselect-placeholder { color: ${c.
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
& .crisp-multiselect-placeholder { color: ${c.fgSelectPlaceholder}; }
|
|
46
|
+
/* closeRotateDuration/openRotateDuration, same asymmetric timing as
|
|
47
|
+
Select's own chevron (see that recipe's comment). */
|
|
48
|
+
& .crisp-multiselect-chevron { flex-shrink: 0; color: ${c.fgNeutralMuted}; transition: transform .1s cubic-bezier(.35,0,.35,1); }
|
|
49
|
+
& .crisp-multiselect-trigger[data-open] .crisp-multiselect-chevron { transform: rotate(180deg); transition-duration: .15s; }
|
|
48
50
|
`,
|
|
49
51
|
globalCss: `
|
|
50
|
-
/* pointer-events: auto (#375)
|
|
51
|
-
on document.body while open (Zag re-enables it only on layers IT
|
|
52
|
-
tracks), and the portaled listbox is outside Zag's knowledge -- it
|
|
53
|
-
inherited the lock, so every option was unclickable, hit-testing
|
|
54
|
-
right through to whatever real element sat behind it. */
|
|
52
|
+
/* pointer-events: auto (#375) -- see Select's matching comment. */
|
|
55
53
|
.crisp-multiselect-listbox {
|
|
56
54
|
font-family: ${c.font.sans}; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
|
|
57
|
-
position: fixed; z-index: 70; pointer-events: auto; box-sizing: border-box; display: flex; flex-direction: column; gap: ${c.space
|
|
58
|
-
max-height:
|
|
59
|
-
border-radius: ${c.radius
|
|
60
|
-
box-shadow: ${e.
|
|
61
|
-
transform-origin: top left; opacity: 1; transform: none;
|
|
55
|
+
position: fixed; z-index: 70; pointer-events: auto; box-sizing: border-box; display: flex; flex-direction: column; gap: ${c.space["2"]};
|
|
56
|
+
max-height: 480px; overflow-y: auto; padding: ${c.space["2"]};
|
|
57
|
+
border-radius: ${c.radius["3xl"]}; background: ${c.bgSelectListbox};
|
|
58
|
+
box-shadow: ${e.selectFloating};
|
|
59
|
+
transform-origin: top left; opacity: 1; transform: none;
|
|
60
|
+
transition: opacity .15s cubic-bezier(0,0,.15,1), transform .15s cubic-bezier(0,0,.15,1);
|
|
62
61
|
}
|
|
63
62
|
.crisp-multiselect-listbox[data-up] { transform-origin: bottom left; }
|
|
64
|
-
.crisp-multiselect-listbox { @starting-style { opacity: 0; transform: scale(0.
|
|
65
|
-
|
|
63
|
+
.crisp-multiselect-listbox { @starting-style { opacity: 0; transform: scale(0.95); } }
|
|
64
|
+
/* LOCKED geometry (#481, guarded by build/spec-check.mjs): height 36px,
|
|
65
|
+
gap space["1.5"] (6px), no border-bottom -- an Attio measurement, not
|
|
66
|
+
part of this seed-parity pass. Only colours below follow Select. */
|
|
67
|
+
.crisp-multiselect-search { display: flex; align-items: center; gap: ${c.space["1.5"]}; height: 36px; flex-shrink: 0; margin: calc(-1 * ${c.space["2"]}) calc(-1 * ${c.space["2"]}) ${c.space["2"]}; padding: ${c.space["2"]} ${c.space["3"]}; color: ${c.fgNeutralMuted}; }
|
|
66
68
|
.crisp-multiselect-search svg { flex-shrink: 0; }
|
|
67
|
-
.crisp-multiselect-search input { flex: 1; min-width: 0; border: none; outline: none; background: none; font-family: inherit; font-size: ${c.text.lg}; color: ${c.
|
|
68
|
-
.crisp-multiselect-search input::placeholder { color: ${c.
|
|
69
|
-
/*
|
|
70
|
-
|
|
71
|
-
same "small muted heading above a run of rows" treatment. */
|
|
72
|
-
.crisp-multiselect-label { padding: ${c.space["1.5"]} ${c.space["2"]} ${c.space["0.5"]}; font-size: ${c.text.sm}; font-weight: ${c.weight.medium}; color: ${c.fgMuted}; }
|
|
69
|
+
.crisp-multiselect-search input { flex: 1; min-width: 0; border: none; outline: none; background: none; font-family: inherit; font-size: ${c.text.lg}; color: ${c.fgSelectNeutral}; }
|
|
70
|
+
.crisp-multiselect-search input::placeholder { color: ${c.fgSelectPlaceholder}; }
|
|
71
|
+
/* Same groupLabel treatment as Select's (13px/regular/neutral-subtle). */
|
|
72
|
+
.crisp-multiselect-label { padding: ${c.space["2"]} ${c.space["4"]} 0; font-size: ${c.text.md}; line-height: 18px; font-weight: ${c.weight.regular}; color: ${c.fgNeutralSubtle}; }
|
|
73
73
|
.crisp-multiselect-option {
|
|
74
|
-
box-sizing: border-box; display: flex; align-items: center; gap: ${c.space["2"]};
|
|
75
|
-
padding:
|
|
74
|
+
box-sizing: border-box; display: flex; align-items: center; gap: ${c.space["2"]}; flex-shrink: 0;
|
|
75
|
+
padding: ${c.space["2.5"]} ${c.space["4"]}; margin: 0; border: none; background: none; border-radius: ${c.radius.xl};
|
|
76
|
+
font-family: inherit; text-align: start; cursor: pointer;
|
|
77
|
+
transition: background-color .15s cubic-bezier(.35,0,.35,1);
|
|
76
78
|
}
|
|
79
|
+
/* Keyboard/hover highlight keeps crisp's own visible neutral fill, same
|
|
80
|
+
reasoning as Select's matching rule (seed's own pressed tint is too
|
|
81
|
+
faint for a keyboard-navigation indicator). */
|
|
77
82
|
.crisp-multiselect-option[data-highlighted] { background: ${c.bgRaised}; }
|
|
83
|
+
.crisp-multiselect-option:active { background: ${c.bgSelectPressed}; }
|
|
78
84
|
.crisp-multiselect-option-chip { flex: 0 1 auto; min-width: 0; overflow: hidden; }
|
|
79
85
|
.crisp-multiselect-check { flex-shrink: 0; margin-inline-start: auto; display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; border-radius: ${c.radius.sm}; box-shadow: inset 0 0 0 1.5px ${c.borderDefault}; color: transparent; }
|
|
80
86
|
.crisp-multiselect-option[aria-selected="true"] .crisp-multiselect-check { background: ${c.bgBrandSolid}; box-shadow: none; color: #fff; }
|
|
81
|
-
.crisp-multiselect-empty { padding: ${c.space["3"]} ${c.space["
|
|
87
|
+
.crisp-multiselect-empty { padding: ${c.space["3"]} ${c.space["4"]}; text-align: center; font-size: ${c.text.md}; color: ${c.fgNeutralSubtle}; }
|
|
82
88
|
|
|
83
89
|
.crisp-recordstable-tag { display: inline-flex; align-items: center; height: ${c.size.chip}; padding: 0 ${c.space["1"]}; border-radius: ${c.radius.chip}; font-size: ${c.text.lg}; line-height: 20px; font-weight: ${c.weight.medium}; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; background: color-mix(in srgb, ${c.fgPrimary} 6%, transparent); color: color-mix(in srgb, ${c.fgPrimary} 82%, transparent); box-shadow: inset 0 0 0 1px ${c.borderSubtle}; }
|
|
84
90
|
.crisp-recordstable-tag[data-tone="brand"] { background: color-mix(in srgb, ${c.bgBrandSolid} 14%, transparent); color: color-mix(in srgb, ${c.bgBrandSolid} 82%, ${c.fgPrimary}); box-shadow: inset 0 0 0 1px color-mix(in srgb, ${c.bgBrandSolid} 30%, transparent); }
|
|
@@ -90,18 +96,51 @@ const config = defineRecipe({
|
|
|
90
96
|
.crisp-recordstable-tag[data-tone="orange"] { background: color-mix(in srgb, ${c.bgOrange} 16%, transparent); color: color-mix(in srgb, ${c.bgOrange} 82%, ${c.fgPrimary}); box-shadow: inset 0 0 0 1px color-mix(in srgb, ${c.bgOrange} 30%, transparent); }
|
|
91
97
|
`,
|
|
92
98
|
variants: {
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
99
|
+
// Owner's follow-up after #867/#879/#882 (crisp/#889): SIZE is Attio's
|
|
100
|
+
// again, LOOK stays seed's -- see Select's own recipe comment for the
|
|
101
|
+
// full reasoning (measured live in GRC's "New employee" dialog: a 40px
|
|
102
|
+
// trigger next to a 32px `Input` visibly mismatched). `small` is the new
|
|
103
|
+
// default: `c.size.menuRow` (32px) and the original, pre-#882
|
|
104
|
+
// Attio-measured asymmetric padding for a chip-wrapping trigger.
|
|
105
|
+
// `medium`/`large` (seed's own 40/52px) stay, opt-in.
|
|
106
|
+
size: {
|
|
107
|
+
small: `
|
|
108
|
+
& .crisp-multiselect-trigger { min-height: ${c.size.menuRow}; border-radius: ${c.radius.md}; gap: ${c.space["1"]}; padding: ${c.space["0.5"]} ${c.space["1.5"]} ${c.space["0.5"]} ${c.space["2"]}; }
|
|
109
|
+
& .crisp-multiselect-chevron { width: ${c.size.icon}; height: ${c.size.icon}; }
|
|
110
|
+
`,
|
|
111
|
+
medium: `
|
|
112
|
+
& .crisp-multiselect-trigger { min-height: ${c.size.selectTriggerMd}; border-radius: ${c.radius.md}; gap: ${c.space["2"]}; padding: ${c.space["1"]} ${c.space["3.5"]}; }
|
|
113
|
+
& .crisp-multiselect-chevron { width: ${c.size.icon}; height: ${c.size.icon}; }
|
|
100
114
|
`,
|
|
115
|
+
large: `
|
|
116
|
+
& .crisp-multiselect-trigger { min-height: ${c.size.selectTriggerLg}; border-radius: ${c.radius.xl}; gap: ${c.space["2.5"]}; padding: ${c.space["1.5"]} ${c.space["4"]}; }
|
|
117
|
+
& .crisp-multiselect-chevron { width: ${c.size.selectIconLg}; height: ${c.size.selectIconLg}; }
|
|
118
|
+
`,
|
|
119
|
+
},
|
|
120
|
+
// Real border width+colour change, same as Select's -- see that
|
|
121
|
+
// recipe's comment.
|
|
122
|
+
invalid: {
|
|
123
|
+
true: `& .crisp-multiselect-trigger { border-width: 2px; border-color: ${c.strokeSelectInvalid}; }`,
|
|
101
124
|
false: ``,
|
|
102
125
|
},
|
|
126
|
+
// ghost: fills whatever row it's embedded in instead of seed's fixed
|
|
127
|
+
// trigger height -- RecordsTable's TagsCell embeds a MultiSelect inline
|
|
128
|
+
// in a 36px table row, the exact regression #879 caught for Select's
|
|
129
|
+
// own datatable consumers. Not a seed state; crisp's own escape hatch.
|
|
130
|
+
//
|
|
131
|
+
// Declared LAST on purpose, same reasoning as Select's recipe: same-
|
|
132
|
+
// specificity CSS rules resolve by source order, and ghost's
|
|
133
|
+
// `min-height: 0; height: 100%` below must come after `size`'s fixed
|
|
134
|
+
// min-height to win the cascade.
|
|
135
|
+
variant: {
|
|
136
|
+
default: "",
|
|
137
|
+
ghost: `
|
|
138
|
+
& .crisp-multiselect-trigger { min-height: 0; height: 100%; border-color: transparent; }
|
|
139
|
+
& .crisp-multiselect-trigger:hover:not(:disabled) { background: ${c.bgSelectPressed}; }
|
|
140
|
+
`,
|
|
141
|
+
},
|
|
103
142
|
},
|
|
104
|
-
defaultVariants: { invalid: false },
|
|
143
|
+
defaultVariants: { size: "small", invalid: false, variant: "default" },
|
|
105
144
|
compoundVariants: [],
|
|
106
145
|
});
|
|
107
146
|
export const multiselectRecipe = Object.assign(resolveRecipe(config), {
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { progressRecipe } from "./progress.recipe.js";
|
|
3
3
|
/**
|
|
4
|
-
* A progress bar — a continuous brand fill,
|
|
5
|
-
* `segments` is set (the "Getting started 3/6" pattern)
|
|
6
|
-
* `
|
|
4
|
+
* A progress bar — a continuous brand fill, a segmented row of pills when
|
|
5
|
+
* `segments` is set (the "Getting started 3/6" pattern), or a small ring
|
|
6
|
+
* (`variant="circular"`, a character-counter's own progress). Reports its
|
|
7
|
+
* state via `role="progressbar"` and the ARIA value attributes.
|
|
7
8
|
*/
|
|
8
|
-
export function Progress({ value, max = 100, segments, "aria-label": ariaLabel, className, ...rest }) {
|
|
9
|
+
export function Progress({ value, max = 100, segments, variant = "linear", size = 18, "aria-label": ariaLabel, className, ...rest }) {
|
|
9
10
|
const pct = Math.max(0, Math.min(100, (value / max) * 100));
|
|
10
11
|
const aria = {
|
|
11
12
|
role: "progressbar",
|
|
@@ -14,6 +15,22 @@ export function Progress({ value, max = 100, segments, "aria-label": ariaLabel,
|
|
|
14
15
|
"aria-valuemax": max,
|
|
15
16
|
"aria-label": ariaLabel,
|
|
16
17
|
};
|
|
18
|
+
if (variant === "circular") {
|
|
19
|
+
// Measured live: an 18x18 SVG, r=7.4, stroke-width 1.6 (a 0.6px margin on
|
|
20
|
+
// each side) — a two-circle ring (a soft-tinted track + a brand arc),
|
|
21
|
+
// the same track tint Badge/Callout's own `soft(bgBrandSolid, 14%)`
|
|
22
|
+
// formula already gives.
|
|
23
|
+
const strokeWidth = size * (1.6 / 18);
|
|
24
|
+
const r = size / 2 - strokeWidth;
|
|
25
|
+
const circumference = 2 * Math.PI * r;
|
|
26
|
+
const offset = circumference * (1 - pct / 100);
|
|
27
|
+
// The measured sample was at value=0 (a full, invisible offset either
|
|
28
|
+
// way), so the arc's start point couldn't be read off that one DOM
|
|
29
|
+
// snapshot -- 12 o'clock is the conventional start for this shape and
|
|
30
|
+
// isn't itself in tension with anything measured, but isn't independently
|
|
31
|
+
// confirmed at a nonzero value either.
|
|
32
|
+
return (_jsx("div", { ...aria, className: progressRecipe({ className }), ...rest, "data-variant": "circular", children: _jsxs("svg", { width: size, height: size, viewBox: `0 0 ${size} ${size}`, "aria-hidden": "true", children: [_jsx("circle", { className: "crisp-progress-circle-track", r: r, cx: size / 2, cy: size / 2, strokeWidth: strokeWidth, fill: "transparent" }), _jsx("circle", { className: "crisp-progress-circle-fill", r: r, cx: size / 2, cy: size / 2, strokeWidth: strokeWidth, fill: "transparent", strokeDasharray: circumference, strokeDashoffset: offset, strokeLinecap: "round", transform: `rotate(-90 ${size / 2} ${size / 2})` })] }) }));
|
|
33
|
+
}
|
|
17
34
|
if (segments && segments > 0) {
|
|
18
35
|
const filled = Math.round((pct / 100) * segments);
|
|
19
36
|
return (_jsx("div", { ...aria, className: progressRecipe({ className }), ...rest, "data-segmented": "", children: Array.from({ length: segments }, (_, i) => (_jsx("span", { className: "crisp-progress-seg", "data-filled": i < filled ? "" : undefined }, i))) }));
|
|
@@ -49,6 +49,36 @@ const spec = defineSpec({
|
|
|
49
49
|
selector: "&[data-segmented] .crisp-progress-seg[data-filled]",
|
|
50
50
|
decls: { background: c.bgBrandSolid },
|
|
51
51
|
},
|
|
52
|
+
// crisp#634: measured live on Attio's own Settings > Profile
|
|
53
|
+
// character-counter ring — the track tint is the same
|
|
54
|
+
// `soft(bgBrandSolid, 14%)` formula Badge/Callout already use (measured
|
|
55
|
+
// rgb(229,238,255), within the same noise this codebase already treats
|
|
56
|
+
// as equivalent), the arc is the plain brand fill.
|
|
57
|
+
{
|
|
58
|
+
selector: '&[data-variant="circular"]',
|
|
59
|
+
decls: {
|
|
60
|
+
display: "inline-flex",
|
|
61
|
+
height: "auto",
|
|
62
|
+
borderRadius: "0",
|
|
63
|
+
background: "transparent",
|
|
64
|
+
overflow: "visible",
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
slot: "circleTrack",
|
|
69
|
+
selector: "& .crisp-progress-circle-track",
|
|
70
|
+
decls: {
|
|
71
|
+
stroke: `color-mix(in srgb, ${c.bgBrandSolid} 14%, transparent)`,
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
slot: "circleFill",
|
|
76
|
+
selector: "& .crisp-progress-circle-fill",
|
|
77
|
+
decls: {
|
|
78
|
+
stroke: c.bgBrandSolid,
|
|
79
|
+
transition: "stroke-dashoffset 0.25s ease",
|
|
80
|
+
},
|
|
81
|
+
},
|
|
52
82
|
],
|
|
53
83
|
});
|
|
54
84
|
const config = specToRecipe(spec);
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* NOT for `WheelPicker`: that is an always-visible multi-column wheel that
|
|
12
12
|
* commits on move, so Enter, Escape and Tab have no meaning in it.
|
|
13
13
|
*/
|
|
14
|
-
export function useListboxKeyboard({ enabledIndexes, highlight, setHighlight, commit, close, dismiss = close, }) {
|
|
14
|
+
export function useListboxKeyboard({ enabledIndexes, highlight, setHighlight, commit, close, dismiss = close, commitOnSpace = false, }) {
|
|
15
15
|
const move = (dir) => {
|
|
16
16
|
if (enabledIndexes.length === 0)
|
|
17
17
|
return;
|
|
@@ -50,6 +50,12 @@ export function useListboxKeyboard({ enabledIndexes, highlight, setHighlight, co
|
|
|
50
50
|
e.preventDefault();
|
|
51
51
|
commit();
|
|
52
52
|
break;
|
|
53
|
+
case " ":
|
|
54
|
+
if (!commitOnSpace)
|
|
55
|
+
break;
|
|
56
|
+
e.preventDefault();
|
|
57
|
+
commit();
|
|
58
|
+
break;
|
|
53
59
|
case "Escape":
|
|
54
60
|
e.preventDefault();
|
|
55
61
|
close();
|