@crisp-ui-kit/crisp 0.45.0 → 0.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/components/menu/menu.js +18 -0
- package/dist/cjs/components/multiselect/multiselect.js +14 -2
- package/dist/cjs/components/popover/popover.js +17 -0
- package/dist/cjs/components/select/select.js +20 -2
- package/dist/cjs/components/table/table.recipe.js +1 -1
- package/dist/cjs/components/tabs/tabs.recipe.js +14 -7
- package/dist/cjs/components/tooltip/tooltip.js +85 -26
- package/dist/esm/components/menu/menu.js +18 -0
- package/dist/esm/components/multiselect/multiselect.js +14 -2
- package/dist/esm/components/popover/popover.js +17 -0
- package/dist/esm/components/select/select.js +20 -2
- package/dist/esm/components/table/table.recipe.js +1 -1
- package/dist/esm/components/tabs/tabs.recipe.js +14 -7
- package/dist/esm/components/tooltip/tooltip.js +83 -24
- package/dist/styles.css +4 -3
- package/dist/styles.layered.css +4 -3
- package/dist/table.css +1 -1
- package/dist/table.layered.css +1 -1
- package/dist/tabs.css +3 -2
- package/dist/tabs.layered.css +3 -2
- package/dist/types/components/tooltip/tooltip.d.ts +66 -11
- package/package.json +1 -1
|
@@ -126,6 +126,24 @@ function Menu({ trigger, children, align = "start", open: openProp, defaultOpen,
|
|
|
126
126
|
document.addEventListener("pointerdown", onDown);
|
|
127
127
|
return () => document.removeEventListener("pointerdown", onDown);
|
|
128
128
|
}, [open, setOpen]);
|
|
129
|
+
// A document-level fallback for Escape (#376 come back). The content's own
|
|
130
|
+
// Escape handling (`useMenuKeyboard`'s onKeyDown, below) only fires once
|
|
131
|
+
// focus is actually inside `.crisp-menu-content` -- but focus lands there
|
|
132
|
+
// via a `requestAnimationFrame` scheduled on open (menu-keyboard.ts), not
|
|
133
|
+
// synchronously. An Escape pressed before that frame paints reaches
|
|
134
|
+
// whatever still has focus (the trigger button), which is outside the
|
|
135
|
+
// content div's subtree, so `onContentKeyDown` never sees it and nothing
|
|
136
|
+
// closes. This listener doesn't depend on focus location, so it closes
|
|
137
|
+
// regardless -- the same pattern `ContextMenu` already uses for the
|
|
138
|
+
// identical reason (its own comment: "Escape is handled twice on
|
|
139
|
+
// purpose").
|
|
140
|
+
React.useEffect(() => {
|
|
141
|
+
if (!open)
|
|
142
|
+
return;
|
|
143
|
+
const onKey = (e) => e.key === "Escape" && close();
|
|
144
|
+
document.addEventListener("keydown", onKey);
|
|
145
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
146
|
+
}, [open, close]);
|
|
129
147
|
const { onKeyDown: onContentKeyDown } = (0, menu_keyboard_1.useMenuKeyboard)({
|
|
130
148
|
open,
|
|
131
149
|
close,
|
|
@@ -125,10 +125,10 @@ id, placeholder = "Select…", searchable = true, searchPlaceholder = "Search…
|
|
|
125
125
|
if (searchable)
|
|
126
126
|
requestAnimationFrame(() => searchRef.current?.focus({ preventScroll: true }));
|
|
127
127
|
};
|
|
128
|
-
const close = () => {
|
|
128
|
+
const close = React.useCallback(() => {
|
|
129
129
|
setOpen(false);
|
|
130
130
|
triggerRef.current?.focus({ preventScroll: true });
|
|
131
|
-
};
|
|
131
|
+
}, []);
|
|
132
132
|
const activeId = open && highlight >= 0 && highlight < filtered.length
|
|
133
133
|
? optionId(highlight)
|
|
134
134
|
: undefined;
|
|
@@ -182,6 +182,18 @@ id, placeholder = "Select…", searchable = true, searchPlaceholder = "Search…
|
|
|
182
182
|
document.addEventListener("pointerdown", onDown);
|
|
183
183
|
return () => document.removeEventListener("pointerdown", onDown);
|
|
184
184
|
}, [open]);
|
|
185
|
+
// A document-level fallback for Escape (#376 come back) -- see Select's
|
|
186
|
+
// matching comment: the listbox's own Escape handling depends on focus
|
|
187
|
+
// already being inside it, which a requestAnimationFrame moves there
|
|
188
|
+
// asynchronously after open, so an Escape pressed before that frame
|
|
189
|
+
// paints is missed entirely. This closes regardless of focus location.
|
|
190
|
+
React.useEffect(() => {
|
|
191
|
+
if (!open)
|
|
192
|
+
return;
|
|
193
|
+
const onKey = (e) => e.key === "Escape" && close();
|
|
194
|
+
document.addEventListener("keydown", onKey);
|
|
195
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
196
|
+
}, [open, close]);
|
|
185
197
|
return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, multiselect_recipe_1.multiselectRecipe)({ invalid, className }), ...rest, children: [name &&
|
|
186
198
|
(value.length > 0 ? value : required ? [""] : []).map((v) => ((0, jsx_runtime_1.jsx)("input", { className: "crisp-formvalue", style: core_1.formValueStyle, name: name, value: v, required: required, tabIndex: -1, "aria-hidden": "true", onChange: () => { } }, v))), (0, jsx_runtime_1.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) => {
|
|
187
199
|
if (open && !searchable)
|
|
@@ -109,6 +109,23 @@ function Popover({ trigger, children, align = "start", open: openProp, defaultOp
|
|
|
109
109
|
document.addEventListener("pointerdown", onDown);
|
|
110
110
|
return () => document.removeEventListener("pointerdown", onDown);
|
|
111
111
|
}, [open, setOpen]);
|
|
112
|
+
// A document-level fallback for Escape (#376 come back). The content's own
|
|
113
|
+
// Escape handling (its `onKeyDown`, below) only fires when focus is
|
|
114
|
+
// already inside `.crisp-popover-content` -- but Popover, unlike Menu,
|
|
115
|
+
// never moves focus there on open at all, so that handler only ever fires
|
|
116
|
+
// if a consumer's own content happens to contain something already
|
|
117
|
+
// focused. In the common case (trigger stays focused after the click that
|
|
118
|
+
// opened it) Escape reached nothing and did nothing. This listener
|
|
119
|
+
// doesn't depend on focus location, so it closes regardless -- the same
|
|
120
|
+
// pattern `ContextMenu` already uses for the identical reason (its own
|
|
121
|
+
// comment: "Escape is handled twice on purpose").
|
|
122
|
+
React.useEffect(() => {
|
|
123
|
+
if (!open)
|
|
124
|
+
return;
|
|
125
|
+
const onKey = (e) => e.key === "Escape" && setOpen(false);
|
|
126
|
+
document.addEventListener("keydown", onKey);
|
|
127
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
128
|
+
}, [open, setOpen]);
|
|
112
129
|
const triggerEl = React.cloneElement(trigger, {
|
|
113
130
|
ref: triggerRef,
|
|
114
131
|
id: triggerId,
|
|
@@ -117,10 +117,10 @@ function Select({ options, value, defaultValue, onValueChange, name, required, p
|
|
|
117
117
|
if (searchable)
|
|
118
118
|
requestAnimationFrame(() => searchRef.current?.focus({ preventScroll: true }));
|
|
119
119
|
};
|
|
120
|
-
const close = () => {
|
|
120
|
+
const close = React.useCallback(() => {
|
|
121
121
|
setOpen(false);
|
|
122
122
|
triggerRef.current?.focus({ preventScroll: true });
|
|
123
|
-
};
|
|
123
|
+
}, []);
|
|
124
124
|
const pick = (opt) => {
|
|
125
125
|
if (!opt || opt.disabled)
|
|
126
126
|
return;
|
|
@@ -177,6 +177,24 @@ function Select({ options, value, defaultValue, onValueChange, name, required, p
|
|
|
177
177
|
document.addEventListener("pointerdown", onDown);
|
|
178
178
|
return () => document.removeEventListener("pointerdown", onDown);
|
|
179
179
|
}, [open]);
|
|
180
|
+
// A document-level fallback for Escape (#376 come back): the listbox's own
|
|
181
|
+
// Escape handling lives on `useListboxKeyboard`'s `onKeyDown`, which only
|
|
182
|
+
// fires once focus is actually inside the listbox -- but focus lands there
|
|
183
|
+
// via a `requestAnimationFrame` scheduled on open (below), not
|
|
184
|
+
// synchronously. Escape pressed before that frame paints reaches whatever
|
|
185
|
+
// still has focus (the trigger, or the search input before ITS OWN focus
|
|
186
|
+
// lands), which is outside the listbox's own subtree, so its handler never
|
|
187
|
+
// sees the keydown at all and nothing closes. This listener doesn't depend
|
|
188
|
+
// on focus location, so it closes regardless -- the same pattern
|
|
189
|
+
// `ContextMenu` already uses for the identical reason (its own comment:
|
|
190
|
+
// "Escape is handled twice on purpose").
|
|
191
|
+
React.useEffect(() => {
|
|
192
|
+
if (!open)
|
|
193
|
+
return;
|
|
194
|
+
const onKey = (e) => e.key === "Escape" && close();
|
|
195
|
+
document.addEventListener("keydown", onKey);
|
|
196
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
197
|
+
}, [open, close]);
|
|
180
198
|
return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, select_recipe_1.selectRecipe)({ variant, width, invalid, className }), ...rest, children: [name && (selected || required) && ((0, jsx_runtime_1.jsx)("input", { className: "crisp-formvalue", style: core_1.formValueStyle, name: name, value: selected ?? "", required: required, tabIndex: -1, "aria-hidden": "true",
|
|
181
199
|
// controlled by the listbox above; `readOnly` would bar it from
|
|
182
200
|
// validation, which is the only reason this input is not hidden
|
|
@@ -13,7 +13,7 @@ const config = (0, core_1.defineRecipe)({
|
|
|
13
13
|
base: `display: block; box-sizing: border-box; background: ${tokens_1.contract.bgSurface};
|
|
14
14
|
& .crisp-table-scroll { overflow-x: auto; }
|
|
15
15
|
& table { width: 100%; border-collapse: separate; border-spacing: 0; font-family: ${tokens_1.contract.font.sans}; font-size: ${tokens_1.contract.text.lg}; color: ${tokens_1.contract.fgPrimary}; }
|
|
16
|
-
& th, & td { position: relative; height: ${tokens_1.contract.size.tableRow}; padding: 0 ${tokens_1.contract.space["2"]}; border-bottom: 1px solid ${tokens_1.contract.borderSubtle}; border-inline-end: 1px solid ${tokens_1.contract.borderSubtle}; text-align: start; vertical-align: middle; white-space: nowrap; overflow: hidden; }
|
|
16
|
+
& th, & td { position: relative; height: ${tokens_1.contract.size.tableRow}; padding: 0 ${tokens_1.contract.space["2"]}; border-bottom: 1px solid ${tokens_1.contract.borderSubtle}; border-inline-end: 1px solid ${tokens_1.contract.borderSubtle}; text-align: start; vertical-align: middle; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
17
17
|
& th { height: ${tokens_1.contract.size.tableHeader}; background: ${tokens_1.contract.bgSurface}; font-size: ${tokens_1.contract.text.lg}; font-weight: ${tokens_1.contract.weight.medium}; letter-spacing: -0.14px; color: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 63%, transparent); user-select: none; }
|
|
18
18
|
& td { color: ${tokens_1.contract.fgPrimary}; background: ${tokens_1.contract.bgSurface}; }
|
|
19
19
|
& th:last-child, & td:last-child { border-inline-end: none; }
|
|
@@ -4,18 +4,25 @@ exports.tabsRecipe = void 0;
|
|
|
4
4
|
const core_1 = require("../../core");
|
|
5
5
|
const tokens_1 = require("../../tokens");
|
|
6
6
|
// Record-view tabs: a row of segmented tab buttons over a 1px baseline.
|
|
7
|
-
// The
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
7
|
+
// The list itself is the 36px bar — the 28px tab row plus the 8px the
|
|
8
|
+
// baseline needs, reserved as bottom padding so it's real box height and not
|
|
9
|
+
// paint that trailing content can sit on top of. The bar carries 12px inline
|
|
10
|
+
// padding too, and draws its own full-width `::after` baseline in
|
|
11
|
+
// borderSubtle at its bottom edge, spanning that padding. The active tab is
|
|
12
|
+
// a light pill — bg #fbfbfb + inset ring borderSubtle, r8 — with a 1px ink
|
|
13
|
+
// underline that sits on the baseline (painted above it) and slides along it;
|
|
14
|
+
// inactive tabs are muted and fill to a subtle pill on hover. Each tab carries
|
|
15
|
+
// a 16px leading icon and an optional count badge. Measured from the live
|
|
16
|
+
// app: bar `padding: 0 12px 8px`, 28px pills, 4/8 padding, 6px icon-label
|
|
17
|
+
// gap, 14/500 labels, 10px badges (rgba(0,0,0,.04)).
|
|
12
18
|
const config = (0, core_1.defineRecipe)({
|
|
13
19
|
name: "tabs",
|
|
14
20
|
base: `
|
|
15
21
|
display: flex; flex-direction: column; min-height: 0; font-family: ${tokens_1.contract.font.sans};
|
|
16
22
|
-webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
|
|
17
23
|
|
|
18
|
-
& .crisp-tabs-list { position: relative; display: flex; align-items: center; gap: ${tokens_1.contract.space["1"]}; height: ${tokens_1.contract.size.row}; flex-shrink: 0; }
|
|
24
|
+
& .crisp-tabs-list { position: relative; display: flex; align-items: center; gap: ${tokens_1.contract.space["1"]}; box-sizing: content-box; height: ${tokens_1.contract.size.row}; padding: 0 ${tokens_1.contract.space["3"]} ${tokens_1.contract.space["2"]}; flex-shrink: 0; }
|
|
25
|
+
& .crisp-tabs-list::after { content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 1px; background: ${tokens_1.contract.borderSubtle}; }
|
|
19
26
|
& .crisp-tabs-leading { display: inline-flex; align-items: center; margin-inline-end: ${tokens_1.contract.space["1"]}; }
|
|
20
27
|
& .crisp-tabs-tab { position: relative; display: inline-flex; align-items: center; gap: ${tokens_1.contract.space["1.5"]}; height: 28px; margin: 0; padding: 0 ${tokens_1.contract.space["2"]}; border: none; background: none; box-shadow: inset 0 0 0 1px transparent; border-radius: ${tokens_1.contract.radius.md}; font-family: inherit; font-size: ${tokens_1.contract.text.lg}; font-weight: ${tokens_1.contract.weight.medium}; letter-spacing: -0.14px; color: ${tokens_1.contract.fgMuted}; cursor: pointer; white-space: nowrap; transition: color .12s ease, background-color .12s ease, box-shadow .12s ease; }
|
|
21
28
|
& .crisp-tabs-tab svg { flex-shrink: 0; opacity: 0.85; }
|
|
@@ -24,7 +31,7 @@ const config = (0, core_1.defineRecipe)({
|
|
|
24
31
|
& .crisp-tabs-tab:focus-visible { outline: 2px solid ${tokens_1.contract.bgBrandSolid}; outline-offset: 1px; }
|
|
25
32
|
& .crisp-tabs-tab-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 16px; height: 16px; padding: 0 ${tokens_1.contract.space["1"]}; border-radius: ${tokens_1.contract.radius.sm}; background: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 6%, transparent); font-size: ${tokens_1.contract.text.xs}; font-weight: ${tokens_1.contract.weight.medium}; color: ${tokens_1.contract.fgMuted}; }
|
|
26
33
|
& .crisp-tabs-tab[data-selected] .crisp-tabs-tab-badge { color: ${tokens_1.contract.fgPrimary}; }
|
|
27
|
-
& .crisp-tabs-indicator { position: absolute; bottom:
|
|
34
|
+
& .crisp-tabs-indicator { position: absolute; bottom: 0; left: 0; z-index: 1; height: 1px; background: ${tokens_1.contract.fgPrimary}; border-radius: 2px 2px 0 0; transition: transform .2s cubic-bezier(.4,0,.2,1), width .2s cubic-bezier(.4,0,.2,1); pointer-events: none; }
|
|
28
35
|
& .crisp-tabs-panel { flex: 1; min-height: 0; }
|
|
29
36
|
& .crisp-tabs-panel:focus-visible { outline: none; }
|
|
30
37
|
`,
|
|
@@ -33,19 +33,55 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.Tooltip =
|
|
36
|
+
exports.Tooltip = void 0;
|
|
37
37
|
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
38
|
+
const react_slot_1 = require("@radix-ui/react-slot");
|
|
38
39
|
const React = __importStar(require("react"));
|
|
39
40
|
const react_dom_1 = require("react-dom");
|
|
40
41
|
const tooltip_recipe_1 = require("./tooltip.recipe");
|
|
42
|
+
function mergeRefs(...refs) {
|
|
43
|
+
return (node) => {
|
|
44
|
+
for (const ref of refs) {
|
|
45
|
+
if (!ref)
|
|
46
|
+
continue;
|
|
47
|
+
if (typeof ref === "function")
|
|
48
|
+
ref(node);
|
|
49
|
+
else
|
|
50
|
+
ref.current = node;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
}
|
|
41
54
|
/**
|
|
42
55
|
* A hover/focus tooltip — a dark hint chip measured live. Wraps its
|
|
43
56
|
* trigger in an inline element, shows after `delay` on pointer-enter / focus,
|
|
44
|
-
* and hides on leave / blur / Escape
|
|
45
|
-
*
|
|
57
|
+
* and hides on leave / blur / Escape (`asChild` skips the wrapper — see its
|
|
58
|
+
* doc). The bubble is portaled and positioned above the trigger (flipping
|
|
59
|
+
* below near the top edge).
|
|
60
|
+
*
|
|
61
|
+
* Composable as another component's trigger (#360: wrapping a `Popover`'s
|
|
62
|
+
* or `Menu`'s trigger in a `Tooltip` silently broke it). A composer clones
|
|
63
|
+
* a `ref`/`id`/`aria-*`/`onClick` onto whatever element it's handed —
|
|
64
|
+
* `Tooltip` wasn't `forwardRef` and didn't read any of the rest, so they
|
|
65
|
+
* landed on the `<Tooltip>` element itself, where nothing forwarded them
|
|
66
|
+
* anywhere. Both now merge onto the actual trigger via `Slot` (the same
|
|
67
|
+
* mechanism `ContextMenu`'s `asChild` uses) — the wrapping `<span>` can't
|
|
68
|
+
* carry them for the same reason it can't carry `aria-describedby` (below):
|
|
69
|
+
* a screen reader reads state off the focused element, not an ancestor.
|
|
46
70
|
*/
|
|
47
|
-
|
|
71
|
+
exports.Tooltip = React.forwardRef(({ content, children, side = "top", delay = 350, open: openProp, defaultOpen, onOpenChange, asChild = false, "aria-describedby": restDescribedBy,
|
|
72
|
+
// Not forwarded, even though a composer's other rest props are
|
|
73
|
+
// (below): `style` sizes a VISUAL box, and Tooltip's wrapper has none
|
|
74
|
+
// of its own -- the box that matters is whatever the consumer's own
|
|
75
|
+
// trigger renders as, which they already own and can style directly.
|
|
76
|
+
// Also matches Popover/Menu's own composer contract: their
|
|
77
|
+
// cloneElement injects ref/id/aria-*/onClick, never style.
|
|
78
|
+
style: _style, ...rest }, forwardedRef) => {
|
|
48
79
|
const tipId = React.useId();
|
|
80
|
+
// The trigger's box for positioning the bubble: the wrapper `<span>` in
|
|
81
|
+
// the default mode (it exists and hugs the child tightly even when
|
|
82
|
+
// `children` isn't a single element, so hover positioning never depends
|
|
83
|
+
// on Slot succeeding), or the child itself in `asChild` mode (there is
|
|
84
|
+
// no wrapper to measure).
|
|
49
85
|
const triggerRef = React.useRef(null);
|
|
50
86
|
const timer = React.useRef(undefined);
|
|
51
87
|
const [pos, setPos] = React.useState(null);
|
|
@@ -87,27 +123,50 @@ function Tooltip({ content, children, side = "top", delay = 350, open: openProp,
|
|
|
87
123
|
// the only way a screen reader learns of one is `aria-describedby` on the
|
|
88
124
|
// element that gets focus. It cannot go on the wrapper: a description on a
|
|
89
125
|
// non-focusable span around a button is never announced. So the trigger is
|
|
90
|
-
//
|
|
91
|
-
//
|
|
126
|
+
// Slot-merged, keeping any aria-describedby it already had (plus a
|
|
127
|
+
// composer's, if any — see restDescribedBy above), and the tipId is only
|
|
128
|
+
// present while the bubble is (a reference to a removed node is dead
|
|
92
129
|
// wiring, #225).
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
130
|
+
const isElement = React.isValidElement(children);
|
|
131
|
+
const ownDescribedBy = isElement
|
|
132
|
+
? children.props["aria-describedby"]
|
|
133
|
+
: undefined;
|
|
134
|
+
const describedBy = [ownDescribedBy, restDescribedBy, open ? tipId : undefined]
|
|
135
|
+
.filter(Boolean)
|
|
136
|
+
.join(" ") || undefined;
|
|
137
|
+
// Baked onto a CLONE, not passed as a prop to <Slot> below: Slot's own
|
|
138
|
+
// mergeProps lets the child's OWN pre-existing prop win over anything
|
|
139
|
+
// unrecognised (it only special-cases style/className/on* handlers), so
|
|
140
|
+
// a `describedBy` passed to Slot would be silently discarded in favour
|
|
141
|
+
// of the child's original, un-merged `aria-describedby` the moment the
|
|
142
|
+
// child already had one.
|
|
143
|
+
const describedChild = isElement
|
|
144
|
+
? React.cloneElement(children, { "aria-describedby": describedBy })
|
|
102
145
|
: children;
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
146
|
+
const hoverHandlers = {
|
|
147
|
+
onPointerEnter: openAfterDelay,
|
|
148
|
+
onPointerLeave: close,
|
|
149
|
+
onFocusCapture: openAfterDelay,
|
|
150
|
+
onBlurCapture: close,
|
|
151
|
+
onKeyDown: (e) => e.key === "Escape" && close(),
|
|
152
|
+
};
|
|
153
|
+
const portal = open &&
|
|
154
|
+
pos != null &&
|
|
155
|
+
(0, react_dom_1.createPortal)((0, jsx_runtime_1.jsx)("div", { id: tipId, role: "tooltip", className: (0, tooltip_recipe_1.tooltipRecipe)(), style: {
|
|
156
|
+
left: pos.left,
|
|
157
|
+
top: pos.top,
|
|
158
|
+
transform: pos.below
|
|
159
|
+
? "translate(-50%, 0)"
|
|
160
|
+
: "translate(-50%, -100%)",
|
|
161
|
+
}, children: content }), document.body);
|
|
162
|
+
if (asChild) {
|
|
163
|
+
// No wrapper at all: the child carries the hover/focus/Escape
|
|
164
|
+
// handling AND the composer's ref/id/aria-*/onClick directly. Not an
|
|
165
|
+
// element (asChild has nothing to attach to) degrades the same way
|
|
166
|
+
// #225 already documented for aria-describedby: renders, but neither
|
|
167
|
+
// hovers nor composes.
|
|
168
|
+
return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [isElement ? ((0, jsx_runtime_1.jsx)(react_slot_1.Slot, { ref: mergeRefs(triggerRef, forwardedRef), ...hoverHandlers, ...rest, children: describedChild })) : (children), portal] }));
|
|
169
|
+
}
|
|
170
|
+
return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("span", { ref: triggerRef, style: { display: "inline-flex" }, ...hoverHandlers, children: isElement ? ((0, jsx_runtime_1.jsx)(react_slot_1.Slot, { ref: forwardedRef, ...rest, children: describedChild })) : (children) }), portal] }));
|
|
171
|
+
});
|
|
172
|
+
exports.Tooltip.displayName = "Tooltip";
|
|
@@ -86,6 +86,24 @@ export function Menu({ trigger, children, align = "start", open: openProp, defau
|
|
|
86
86
|
document.addEventListener("pointerdown", onDown);
|
|
87
87
|
return () => document.removeEventListener("pointerdown", onDown);
|
|
88
88
|
}, [open, setOpen]);
|
|
89
|
+
// A document-level fallback for Escape (#376 come back). The content's own
|
|
90
|
+
// Escape handling (`useMenuKeyboard`'s onKeyDown, below) only fires once
|
|
91
|
+
// focus is actually inside `.crisp-menu-content` -- but focus lands there
|
|
92
|
+
// via a `requestAnimationFrame` scheduled on open (menu-keyboard.ts), not
|
|
93
|
+
// synchronously. An Escape pressed before that frame paints reaches
|
|
94
|
+
// whatever still has focus (the trigger button), which is outside the
|
|
95
|
+
// content div's subtree, so `onContentKeyDown` never sees it and nothing
|
|
96
|
+
// closes. This listener doesn't depend on focus location, so it closes
|
|
97
|
+
// regardless -- the same pattern `ContextMenu` already uses for the
|
|
98
|
+
// identical reason (its own comment: "Escape is handled twice on
|
|
99
|
+
// purpose").
|
|
100
|
+
React.useEffect(() => {
|
|
101
|
+
if (!open)
|
|
102
|
+
return;
|
|
103
|
+
const onKey = (e) => e.key === "Escape" && close();
|
|
104
|
+
document.addEventListener("keydown", onKey);
|
|
105
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
106
|
+
}, [open, close]);
|
|
89
107
|
const { onKeyDown: onContentKeyDown } = useMenuKeyboard({
|
|
90
108
|
open,
|
|
91
109
|
close,
|
|
@@ -89,10 +89,10 @@ id, placeholder = "Select…", searchable = true, searchPlaceholder = "Search…
|
|
|
89
89
|
if (searchable)
|
|
90
90
|
requestAnimationFrame(() => searchRef.current?.focus({ preventScroll: true }));
|
|
91
91
|
};
|
|
92
|
-
const close = () => {
|
|
92
|
+
const close = React.useCallback(() => {
|
|
93
93
|
setOpen(false);
|
|
94
94
|
triggerRef.current?.focus({ preventScroll: true });
|
|
95
|
-
};
|
|
95
|
+
}, []);
|
|
96
96
|
const activeId = open && highlight >= 0 && highlight < filtered.length
|
|
97
97
|
? optionId(highlight)
|
|
98
98
|
: undefined;
|
|
@@ -146,6 +146,18 @@ id, placeholder = "Select…", searchable = true, searchPlaceholder = "Search…
|
|
|
146
146
|
document.addEventListener("pointerdown", onDown);
|
|
147
147
|
return () => document.removeEventListener("pointerdown", onDown);
|
|
148
148
|
}, [open]);
|
|
149
|
+
// A document-level fallback for Escape (#376 come back) -- see Select's
|
|
150
|
+
// matching comment: the listbox's own Escape handling depends on focus
|
|
151
|
+
// already being inside it, which a requestAnimationFrame moves there
|
|
152
|
+
// asynchronously after open, so an Escape pressed before that frame
|
|
153
|
+
// paints is missed entirely. This closes regardless of focus location.
|
|
154
|
+
React.useEffect(() => {
|
|
155
|
+
if (!open)
|
|
156
|
+
return;
|
|
157
|
+
const onKey = (e) => e.key === "Escape" && close();
|
|
158
|
+
document.addEventListener("keydown", onKey);
|
|
159
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
160
|
+
}, [open, close]);
|
|
149
161
|
return (_jsxs("div", { ref: rootRef, className: multiselectRecipe({ invalid, className }), ...rest, children: [name &&
|
|
150
162
|
(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) => {
|
|
151
163
|
if (open && !searchable)
|
|
@@ -73,6 +73,23 @@ export function Popover({ trigger, children, align = "start", open: openProp, de
|
|
|
73
73
|
document.addEventListener("pointerdown", onDown);
|
|
74
74
|
return () => document.removeEventListener("pointerdown", onDown);
|
|
75
75
|
}, [open, setOpen]);
|
|
76
|
+
// A document-level fallback for Escape (#376 come back). The content's own
|
|
77
|
+
// Escape handling (its `onKeyDown`, below) only fires when focus is
|
|
78
|
+
// already inside `.crisp-popover-content` -- but Popover, unlike Menu,
|
|
79
|
+
// never moves focus there on open at all, so that handler only ever fires
|
|
80
|
+
// if a consumer's own content happens to contain something already
|
|
81
|
+
// focused. In the common case (trigger stays focused after the click that
|
|
82
|
+
// opened it) Escape reached nothing and did nothing. This listener
|
|
83
|
+
// doesn't depend on focus location, so it closes regardless -- the same
|
|
84
|
+
// pattern `ContextMenu` already uses for the identical reason (its own
|
|
85
|
+
// comment: "Escape is handled twice on purpose").
|
|
86
|
+
React.useEffect(() => {
|
|
87
|
+
if (!open)
|
|
88
|
+
return;
|
|
89
|
+
const onKey = (e) => e.key === "Escape" && setOpen(false);
|
|
90
|
+
document.addEventListener("keydown", onKey);
|
|
91
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
92
|
+
}, [open, setOpen]);
|
|
76
93
|
const triggerEl = React.cloneElement(trigger, {
|
|
77
94
|
ref: triggerRef,
|
|
78
95
|
id: triggerId,
|
|
@@ -81,10 +81,10 @@ export function Select({ options, value, defaultValue, onValueChange, name, requ
|
|
|
81
81
|
if (searchable)
|
|
82
82
|
requestAnimationFrame(() => searchRef.current?.focus({ preventScroll: true }));
|
|
83
83
|
};
|
|
84
|
-
const close = () => {
|
|
84
|
+
const close = React.useCallback(() => {
|
|
85
85
|
setOpen(false);
|
|
86
86
|
triggerRef.current?.focus({ preventScroll: true });
|
|
87
|
-
};
|
|
87
|
+
}, []);
|
|
88
88
|
const pick = (opt) => {
|
|
89
89
|
if (!opt || opt.disabled)
|
|
90
90
|
return;
|
|
@@ -141,6 +141,24 @@ export function Select({ options, value, defaultValue, onValueChange, name, requ
|
|
|
141
141
|
document.addEventListener("pointerdown", onDown);
|
|
142
142
|
return () => document.removeEventListener("pointerdown", onDown);
|
|
143
143
|
}, [open]);
|
|
144
|
+
// A document-level fallback for Escape (#376 come back): the listbox's own
|
|
145
|
+
// Escape handling lives on `useListboxKeyboard`'s `onKeyDown`, which only
|
|
146
|
+
// fires once focus is actually inside the listbox -- but focus lands there
|
|
147
|
+
// via a `requestAnimationFrame` scheduled on open (below), not
|
|
148
|
+
// synchronously. Escape pressed before that frame paints reaches whatever
|
|
149
|
+
// still has focus (the trigger, or the search input before ITS OWN focus
|
|
150
|
+
// lands), which is outside the listbox's own subtree, so its handler never
|
|
151
|
+
// sees the keydown at all and nothing closes. This listener doesn't depend
|
|
152
|
+
// on focus location, so it closes regardless -- the same pattern
|
|
153
|
+
// `ContextMenu` already uses for the identical reason (its own comment:
|
|
154
|
+
// "Escape is handled twice on purpose").
|
|
155
|
+
React.useEffect(() => {
|
|
156
|
+
if (!open)
|
|
157
|
+
return;
|
|
158
|
+
const onKey = (e) => e.key === "Escape" && close();
|
|
159
|
+
document.addEventListener("keydown", onKey);
|
|
160
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
161
|
+
}, [open, close]);
|
|
144
162
|
return (_jsxs("div", { ref: rootRef, className: selectRecipe({ variant, width, invalid, className }), ...rest, children: [name && (selected || required) && (_jsx("input", { className: "crisp-formvalue", style: formValueStyle, name: name, value: selected ?? "", required: required, tabIndex: -1, "aria-hidden": "true",
|
|
145
163
|
// controlled by the listbox above; `readOnly` would bar it from
|
|
146
164
|
// validation, which is the only reason this input is not hidden
|
|
@@ -10,7 +10,7 @@ const config = defineRecipe({
|
|
|
10
10
|
base: `display: block; box-sizing: border-box; background: ${c.bgSurface};
|
|
11
11
|
& .crisp-table-scroll { overflow-x: auto; }
|
|
12
12
|
& table { width: 100%; border-collapse: separate; border-spacing: 0; font-family: ${c.font.sans}; font-size: ${c.text.lg}; color: ${c.fgPrimary}; }
|
|
13
|
-
& th, & td { position: relative; height: ${c.size.tableRow}; padding: 0 ${c.space["2"]}; border-bottom: 1px solid ${c.borderSubtle}; border-inline-end: 1px solid ${c.borderSubtle}; text-align: start; vertical-align: middle; white-space: nowrap; overflow: hidden; }
|
|
13
|
+
& th, & td { position: relative; height: ${c.size.tableRow}; padding: 0 ${c.space["2"]}; border-bottom: 1px solid ${c.borderSubtle}; border-inline-end: 1px solid ${c.borderSubtle}; text-align: start; vertical-align: middle; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
14
14
|
& th { height: ${c.size.tableHeader}; background: ${c.bgSurface}; font-size: ${c.text.lg}; font-weight: ${c.weight.medium}; letter-spacing: -0.14px; color: color-mix(in srgb, ${c.fgPrimary} 63%, transparent); user-select: none; }
|
|
15
15
|
& td { color: ${c.fgPrimary}; background: ${c.bgSurface}; }
|
|
16
16
|
& th:last-child, & td:last-child { border-inline-end: none; }
|
|
@@ -1,18 +1,25 @@
|
|
|
1
1
|
import { defineRecipe, resolveRecipe } from "../../core/index.js";
|
|
2
2
|
import { contract as c } from "../../tokens/index.js";
|
|
3
3
|
// Record-view tabs: a row of segmented tab buttons over a 1px baseline.
|
|
4
|
-
// The
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
4
|
+
// The list itself is the 36px bar — the 28px tab row plus the 8px the
|
|
5
|
+
// baseline needs, reserved as bottom padding so it's real box height and not
|
|
6
|
+
// paint that trailing content can sit on top of. The bar carries 12px inline
|
|
7
|
+
// padding too, and draws its own full-width `::after` baseline in
|
|
8
|
+
// borderSubtle at its bottom edge, spanning that padding. The active tab is
|
|
9
|
+
// a light pill — bg #fbfbfb + inset ring borderSubtle, r8 — with a 1px ink
|
|
10
|
+
// underline that sits on the baseline (painted above it) and slides along it;
|
|
11
|
+
// inactive tabs are muted and fill to a subtle pill on hover. Each tab carries
|
|
12
|
+
// a 16px leading icon and an optional count badge. Measured from the live
|
|
13
|
+
// app: bar `padding: 0 12px 8px`, 28px pills, 4/8 padding, 6px icon-label
|
|
14
|
+
// gap, 14/500 labels, 10px badges (rgba(0,0,0,.04)).
|
|
9
15
|
const config = defineRecipe({
|
|
10
16
|
name: "tabs",
|
|
11
17
|
base: `
|
|
12
18
|
display: flex; flex-direction: column; min-height: 0; font-family: ${c.font.sans};
|
|
13
19
|
-webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
|
|
14
20
|
|
|
15
|
-
& .crisp-tabs-list { position: relative; display: flex; align-items: center; gap: ${c.space["1"]}; height: ${c.size.row}; flex-shrink: 0; }
|
|
21
|
+
& .crisp-tabs-list { position: relative; display: flex; align-items: center; gap: ${c.space["1"]}; box-sizing: content-box; height: ${c.size.row}; padding: 0 ${c.space["3"]} ${c.space["2"]}; flex-shrink: 0; }
|
|
22
|
+
& .crisp-tabs-list::after { content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 1px; background: ${c.borderSubtle}; }
|
|
16
23
|
& .crisp-tabs-leading { display: inline-flex; align-items: center; margin-inline-end: ${c.space["1"]}; }
|
|
17
24
|
& .crisp-tabs-tab { position: relative; display: inline-flex; align-items: center; gap: ${c.space["1.5"]}; height: 28px; margin: 0; padding: 0 ${c.space["2"]}; border: none; background: none; box-shadow: inset 0 0 0 1px transparent; border-radius: ${c.radius.md}; font-family: inherit; font-size: ${c.text.lg}; font-weight: ${c.weight.medium}; letter-spacing: -0.14px; color: ${c.fgMuted}; cursor: pointer; white-space: nowrap; transition: color .12s ease, background-color .12s ease, box-shadow .12s ease; }
|
|
18
25
|
& .crisp-tabs-tab svg { flex-shrink: 0; opacity: 0.85; }
|
|
@@ -21,7 +28,7 @@ const config = defineRecipe({
|
|
|
21
28
|
& .crisp-tabs-tab:focus-visible { outline: 2px solid ${c.bgBrandSolid}; outline-offset: 1px; }
|
|
22
29
|
& .crisp-tabs-tab-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 16px; height: 16px; padding: 0 ${c.space["1"]}; border-radius: ${c.radius.sm}; background: color-mix(in srgb, ${c.fgPrimary} 6%, transparent); font-size: ${c.text.xs}; font-weight: ${c.weight.medium}; color: ${c.fgMuted}; }
|
|
23
30
|
& .crisp-tabs-tab[data-selected] .crisp-tabs-tab-badge { color: ${c.fgPrimary}; }
|
|
24
|
-
& .crisp-tabs-indicator { position: absolute; bottom:
|
|
31
|
+
& .crisp-tabs-indicator { position: absolute; bottom: 0; left: 0; z-index: 1; height: 1px; background: ${c.fgPrimary}; border-radius: 2px 2px 0 0; transition: transform .2s cubic-bezier(.4,0,.2,1), width .2s cubic-bezier(.4,0,.2,1); pointer-events: none; }
|
|
25
32
|
& .crisp-tabs-panel { flex: 1; min-height: 0; }
|
|
26
33
|
& .crisp-tabs-panel:focus-visible { outline: none; }
|
|
27
34
|
`,
|
|
@@ -1,15 +1,51 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Slot } from "@radix-ui/react-slot";
|
|
2
3
|
import * as React from "react";
|
|
3
4
|
import { createPortal } from "react-dom";
|
|
4
5
|
import { tooltipRecipe } from "./tooltip.recipe.js";
|
|
6
|
+
function mergeRefs(...refs) {
|
|
7
|
+
return (node) => {
|
|
8
|
+
for (const ref of refs) {
|
|
9
|
+
if (!ref)
|
|
10
|
+
continue;
|
|
11
|
+
if (typeof ref === "function")
|
|
12
|
+
ref(node);
|
|
13
|
+
else
|
|
14
|
+
ref.current = node;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
}
|
|
5
18
|
/**
|
|
6
19
|
* A hover/focus tooltip — a dark hint chip measured live. Wraps its
|
|
7
20
|
* trigger in an inline element, shows after `delay` on pointer-enter / focus,
|
|
8
|
-
* and hides on leave / blur / Escape
|
|
9
|
-
*
|
|
21
|
+
* and hides on leave / blur / Escape (`asChild` skips the wrapper — see its
|
|
22
|
+
* doc). The bubble is portaled and positioned above the trigger (flipping
|
|
23
|
+
* below near the top edge).
|
|
24
|
+
*
|
|
25
|
+
* Composable as another component's trigger (#360: wrapping a `Popover`'s
|
|
26
|
+
* or `Menu`'s trigger in a `Tooltip` silently broke it). A composer clones
|
|
27
|
+
* a `ref`/`id`/`aria-*`/`onClick` onto whatever element it's handed —
|
|
28
|
+
* `Tooltip` wasn't `forwardRef` and didn't read any of the rest, so they
|
|
29
|
+
* landed on the `<Tooltip>` element itself, where nothing forwarded them
|
|
30
|
+
* anywhere. Both now merge onto the actual trigger via `Slot` (the same
|
|
31
|
+
* mechanism `ContextMenu`'s `asChild` uses) — the wrapping `<span>` can't
|
|
32
|
+
* carry them for the same reason it can't carry `aria-describedby` (below):
|
|
33
|
+
* a screen reader reads state off the focused element, not an ancestor.
|
|
10
34
|
*/
|
|
11
|
-
export
|
|
35
|
+
export const Tooltip = React.forwardRef(({ content, children, side = "top", delay = 350, open: openProp, defaultOpen, onOpenChange, asChild = false, "aria-describedby": restDescribedBy,
|
|
36
|
+
// Not forwarded, even though a composer's other rest props are
|
|
37
|
+
// (below): `style` sizes a VISUAL box, and Tooltip's wrapper has none
|
|
38
|
+
// of its own -- the box that matters is whatever the consumer's own
|
|
39
|
+
// trigger renders as, which they already own and can style directly.
|
|
40
|
+
// Also matches Popover/Menu's own composer contract: their
|
|
41
|
+
// cloneElement injects ref/id/aria-*/onClick, never style.
|
|
42
|
+
style: _style, ...rest }, forwardedRef) => {
|
|
12
43
|
const tipId = React.useId();
|
|
44
|
+
// The trigger's box for positioning the bubble: the wrapper `<span>` in
|
|
45
|
+
// the default mode (it exists and hugs the child tightly even when
|
|
46
|
+
// `children` isn't a single element, so hover positioning never depends
|
|
47
|
+
// on Slot succeeding), or the child itself in `asChild` mode (there is
|
|
48
|
+
// no wrapper to measure).
|
|
13
49
|
const triggerRef = React.useRef(null);
|
|
14
50
|
const timer = React.useRef(undefined);
|
|
15
51
|
const [pos, setPos] = React.useState(null);
|
|
@@ -51,27 +87,50 @@ export function Tooltip({ content, children, side = "top", delay = 350, open: op
|
|
|
51
87
|
// the only way a screen reader learns of one is `aria-describedby` on the
|
|
52
88
|
// element that gets focus. It cannot go on the wrapper: a description on a
|
|
53
89
|
// non-focusable span around a button is never announced. So the trigger is
|
|
54
|
-
//
|
|
55
|
-
//
|
|
90
|
+
// Slot-merged, keeping any aria-describedby it already had (plus a
|
|
91
|
+
// composer's, if any — see restDescribedBy above), and the tipId is only
|
|
92
|
+
// present while the bubble is (a reference to a removed node is dead
|
|
56
93
|
// wiring, #225).
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
94
|
+
const isElement = React.isValidElement(children);
|
|
95
|
+
const ownDescribedBy = isElement
|
|
96
|
+
? children.props["aria-describedby"]
|
|
97
|
+
: undefined;
|
|
98
|
+
const describedBy = [ownDescribedBy, restDescribedBy, open ? tipId : undefined]
|
|
99
|
+
.filter(Boolean)
|
|
100
|
+
.join(" ") || undefined;
|
|
101
|
+
// Baked onto a CLONE, not passed as a prop to <Slot> below: Slot's own
|
|
102
|
+
// mergeProps lets the child's OWN pre-existing prop win over anything
|
|
103
|
+
// unrecognised (it only special-cases style/className/on* handlers), so
|
|
104
|
+
// a `describedBy` passed to Slot would be silently discarded in favour
|
|
105
|
+
// of the child's original, un-merged `aria-describedby` the moment the
|
|
106
|
+
// child already had one.
|
|
107
|
+
const describedChild = isElement
|
|
108
|
+
? React.cloneElement(children, { "aria-describedby": describedBy })
|
|
66
109
|
: children;
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
110
|
+
const hoverHandlers = {
|
|
111
|
+
onPointerEnter: openAfterDelay,
|
|
112
|
+
onPointerLeave: close,
|
|
113
|
+
onFocusCapture: openAfterDelay,
|
|
114
|
+
onBlurCapture: close,
|
|
115
|
+
onKeyDown: (e) => e.key === "Escape" && close(),
|
|
116
|
+
};
|
|
117
|
+
const portal = open &&
|
|
118
|
+
pos != null &&
|
|
119
|
+
createPortal(_jsx("div", { id: tipId, role: "tooltip", className: tooltipRecipe(), style: {
|
|
120
|
+
left: pos.left,
|
|
121
|
+
top: pos.top,
|
|
122
|
+
transform: pos.below
|
|
123
|
+
? "translate(-50%, 0)"
|
|
124
|
+
: "translate(-50%, -100%)",
|
|
125
|
+
}, children: content }), document.body);
|
|
126
|
+
if (asChild) {
|
|
127
|
+
// No wrapper at all: the child carries the hover/focus/Escape
|
|
128
|
+
// handling AND the composer's ref/id/aria-*/onClick directly. Not an
|
|
129
|
+
// element (asChild has nothing to attach to) degrades the same way
|
|
130
|
+
// #225 already documented for aria-describedby: renders, but neither
|
|
131
|
+
// hovers nor composes.
|
|
132
|
+
return (_jsxs(_Fragment, { children: [isElement ? (_jsx(Slot, { ref: mergeRefs(triggerRef, forwardedRef), ...hoverHandlers, ...rest, children: describedChild })) : (children), portal] }));
|
|
133
|
+
}
|
|
134
|
+
return (_jsxs(_Fragment, { children: [_jsx("span", { ref: triggerRef, style: { display: "inline-flex" }, ...hoverHandlers, children: isElement ? (_jsx(Slot, { ref: forwardedRef, ...rest, children: describedChild })) : (children) }), portal] }));
|
|
135
|
+
});
|
|
77
136
|
Tooltip.displayName = "Tooltip";
|
package/dist/styles.css
CHANGED
|
@@ -1514,7 +1514,7 @@
|
|
|
1514
1514
|
display: block; box-sizing: border-box; background: var(--crisp-bg-surface);
|
|
1515
1515
|
& .crisp-table-scroll { overflow-x: auto; }
|
|
1516
1516
|
& table { width: 100%; border-collapse: separate; border-spacing: 0; font-family: var(--crisp-font-sans); font-size: var(--crisp-text-lg); color: var(--crisp-fg-primary); }
|
|
1517
|
-
& th, & td { position: relative; height: var(--crisp-size-tableRow); padding: 0 var(--crisp-space-2); border-bottom: 1px solid var(--crisp-border-subtle); border-inline-end: 1px solid var(--crisp-border-subtle); text-align: start; vertical-align: middle; white-space: nowrap; overflow: hidden; }
|
|
1517
|
+
& th, & td { position: relative; height: var(--crisp-size-tableRow); padding: 0 var(--crisp-space-2); border-bottom: 1px solid var(--crisp-border-subtle); border-inline-end: 1px solid var(--crisp-border-subtle); text-align: start; vertical-align: middle; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
1518
1518
|
& th { height: var(--crisp-size-tableHeader); background: var(--crisp-bg-surface); font-size: var(--crisp-text-lg); font-weight: var(--crisp-weight-medium); letter-spacing: -0.14px; color: color-mix(in srgb, var(--crisp-fg-primary) 63%, transparent); user-select: none; }
|
|
1519
1519
|
& td { color: var(--crisp-fg-primary); background: var(--crisp-bg-surface); }
|
|
1520
1520
|
& th:last-child, & td:last-child { border-inline-end: none; }
|
|
@@ -1625,7 +1625,8 @@
|
|
|
1625
1625
|
|
|
1626
1626
|
.crisp-tabs {
|
|
1627
1627
|
display: flex; flex-direction: column; min-height: 0; font-family: var(--crisp-font-sans); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
|
|
1628
|
-
& .crisp-tabs-list { position: relative; display: flex; align-items: center; gap: var(--crisp-space-1); height: var(--crisp-size-row); flex-shrink: 0; }
|
|
1628
|
+
& .crisp-tabs-list { position: relative; display: flex; align-items: center; gap: var(--crisp-space-1); box-sizing: content-box; height: var(--crisp-size-row); padding: 0 var(--crisp-space-3) var(--crisp-space-2); flex-shrink: 0; }
|
|
1629
|
+
& .crisp-tabs-list::after { content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 1px; background: var(--crisp-border-subtle); }
|
|
1629
1630
|
& .crisp-tabs-leading { display: inline-flex; align-items: center; margin-inline-end: var(--crisp-space-1); }
|
|
1630
1631
|
& .crisp-tabs-tab { position: relative; display: inline-flex; align-items: center; gap: var(--crisp-space-1-5); height: 28px; margin: 0; padding: 0 var(--crisp-space-2); border: none; background: none; box-shadow: inset 0 0 0 1px transparent; border-radius: var(--crisp-radius-md); font-family: inherit; font-size: var(--crisp-text-lg); font-weight: var(--crisp-weight-medium); letter-spacing: -0.14px; color: var(--crisp-fg-muted); cursor: pointer; white-space: nowrap; transition: color .12s ease, background-color .12s ease, box-shadow .12s ease; }
|
|
1631
1632
|
& .crisp-tabs-tab svg { flex-shrink: 0; opacity: 0.85; }
|
|
@@ -1634,7 +1635,7 @@
|
|
|
1634
1635
|
& .crisp-tabs-tab:focus-visible { outline: 2px solid var(--crisp-bg-brand-solid); outline-offset: 1px; }
|
|
1635
1636
|
& .crisp-tabs-tab-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 16px; height: 16px; padding: 0 var(--crisp-space-1); border-radius: var(--crisp-radius-sm); background: color-mix(in srgb, var(--crisp-fg-primary) 6%, transparent); font-size: var(--crisp-text-xs); font-weight: var(--crisp-weight-medium); color: var(--crisp-fg-muted); }
|
|
1636
1637
|
& .crisp-tabs-tab[data-selected] .crisp-tabs-tab-badge { color: var(--crisp-fg-primary); }
|
|
1637
|
-
& .crisp-tabs-indicator { position: absolute; bottom:
|
|
1638
|
+
& .crisp-tabs-indicator { position: absolute; bottom: 0; left: 0; z-index: 1; height: 1px; background: var(--crisp-fg-primary); border-radius: 2px 2px 0 0; transition: transform .2s cubic-bezier(.4,0,.2,1), width .2s cubic-bezier(.4,0,.2,1); pointer-events: none; }
|
|
1638
1639
|
& .crisp-tabs-panel { flex: 1; min-height: 0; }
|
|
1639
1640
|
& .crisp-tabs-panel:focus-visible { outline: none; }
|
|
1640
1641
|
}
|
package/dist/styles.layered.css
CHANGED
|
@@ -1515,7 +1515,7 @@
|
|
|
1515
1515
|
display: block; box-sizing: border-box; background: var(--crisp-bg-surface);
|
|
1516
1516
|
& .crisp-table-scroll { overflow-x: auto; }
|
|
1517
1517
|
& table { width: 100%; border-collapse: separate; border-spacing: 0; font-family: var(--crisp-font-sans); font-size: var(--crisp-text-lg); color: var(--crisp-fg-primary); }
|
|
1518
|
-
& th, & td { position: relative; height: var(--crisp-size-tableRow); padding: 0 var(--crisp-space-2); border-bottom: 1px solid var(--crisp-border-subtle); border-inline-end: 1px solid var(--crisp-border-subtle); text-align: start; vertical-align: middle; white-space: nowrap; overflow: hidden; }
|
|
1518
|
+
& th, & td { position: relative; height: var(--crisp-size-tableRow); padding: 0 var(--crisp-space-2); border-bottom: 1px solid var(--crisp-border-subtle); border-inline-end: 1px solid var(--crisp-border-subtle); text-align: start; vertical-align: middle; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
1519
1519
|
& th { height: var(--crisp-size-tableHeader); background: var(--crisp-bg-surface); font-size: var(--crisp-text-lg); font-weight: var(--crisp-weight-medium); letter-spacing: -0.14px; color: color-mix(in srgb, var(--crisp-fg-primary) 63%, transparent); user-select: none; }
|
|
1520
1520
|
& td { color: var(--crisp-fg-primary); background: var(--crisp-bg-surface); }
|
|
1521
1521
|
& th:last-child, & td:last-child { border-inline-end: none; }
|
|
@@ -1626,7 +1626,8 @@
|
|
|
1626
1626
|
|
|
1627
1627
|
.crisp-tabs {
|
|
1628
1628
|
display: flex; flex-direction: column; min-height: 0; font-family: var(--crisp-font-sans); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
|
|
1629
|
-
& .crisp-tabs-list { position: relative; display: flex; align-items: center; gap: var(--crisp-space-1); height: var(--crisp-size-row); flex-shrink: 0; }
|
|
1629
|
+
& .crisp-tabs-list { position: relative; display: flex; align-items: center; gap: var(--crisp-space-1); box-sizing: content-box; height: var(--crisp-size-row); padding: 0 var(--crisp-space-3) var(--crisp-space-2); flex-shrink: 0; }
|
|
1630
|
+
& .crisp-tabs-list::after { content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 1px; background: var(--crisp-border-subtle); }
|
|
1630
1631
|
& .crisp-tabs-leading { display: inline-flex; align-items: center; margin-inline-end: var(--crisp-space-1); }
|
|
1631
1632
|
& .crisp-tabs-tab { position: relative; display: inline-flex; align-items: center; gap: var(--crisp-space-1-5); height: 28px; margin: 0; padding: 0 var(--crisp-space-2); border: none; background: none; box-shadow: inset 0 0 0 1px transparent; border-radius: var(--crisp-radius-md); font-family: inherit; font-size: var(--crisp-text-lg); font-weight: var(--crisp-weight-medium); letter-spacing: -0.14px; color: var(--crisp-fg-muted); cursor: pointer; white-space: nowrap; transition: color .12s ease, background-color .12s ease, box-shadow .12s ease; }
|
|
1632
1633
|
& .crisp-tabs-tab svg { flex-shrink: 0; opacity: 0.85; }
|
|
@@ -1635,7 +1636,7 @@
|
|
|
1635
1636
|
& .crisp-tabs-tab:focus-visible { outline: 2px solid var(--crisp-bg-brand-solid); outline-offset: 1px; }
|
|
1636
1637
|
& .crisp-tabs-tab-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 16px; height: 16px; padding: 0 var(--crisp-space-1); border-radius: var(--crisp-radius-sm); background: color-mix(in srgb, var(--crisp-fg-primary) 6%, transparent); font-size: var(--crisp-text-xs); font-weight: var(--crisp-weight-medium); color: var(--crisp-fg-muted); }
|
|
1637
1638
|
& .crisp-tabs-tab[data-selected] .crisp-tabs-tab-badge { color: var(--crisp-fg-primary); }
|
|
1638
|
-
& .crisp-tabs-indicator { position: absolute; bottom:
|
|
1639
|
+
& .crisp-tabs-indicator { position: absolute; bottom: 0; left: 0; z-index: 1; height: 1px; background: var(--crisp-fg-primary); border-radius: 2px 2px 0 0; transition: transform .2s cubic-bezier(.4,0,.2,1), width .2s cubic-bezier(.4,0,.2,1); pointer-events: none; }
|
|
1639
1640
|
& .crisp-tabs-panel { flex: 1; min-height: 0; }
|
|
1640
1641
|
& .crisp-tabs-panel:focus-visible { outline: none; }
|
|
1641
1642
|
}
|
package/dist/table.css
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
display: block; box-sizing: border-box; background: var(--crisp-bg-surface);
|
|
3
3
|
& .crisp-table-scroll { overflow-x: auto; }
|
|
4
4
|
& table { width: 100%; border-collapse: separate; border-spacing: 0; font-family: var(--crisp-font-sans); font-size: var(--crisp-text-lg); color: var(--crisp-fg-primary); }
|
|
5
|
-
& th, & td { position: relative; height: var(--crisp-size-tableRow); padding: 0 var(--crisp-space-2); border-bottom: 1px solid var(--crisp-border-subtle); border-inline-end: 1px solid var(--crisp-border-subtle); text-align: start; vertical-align: middle; white-space: nowrap; overflow: hidden; }
|
|
5
|
+
& th, & td { position: relative; height: var(--crisp-size-tableRow); padding: 0 var(--crisp-space-2); border-bottom: 1px solid var(--crisp-border-subtle); border-inline-end: 1px solid var(--crisp-border-subtle); text-align: start; vertical-align: middle; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
6
6
|
& th { height: var(--crisp-size-tableHeader); background: var(--crisp-bg-surface); font-size: var(--crisp-text-lg); font-weight: var(--crisp-weight-medium); letter-spacing: -0.14px; color: color-mix(in srgb, var(--crisp-fg-primary) 63%, transparent); user-select: none; }
|
|
7
7
|
& td { color: var(--crisp-fg-primary); background: var(--crisp-bg-surface); }
|
|
8
8
|
& th:last-child, & td:last-child { border-inline-end: none; }
|
package/dist/table.layered.css
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
display: block; box-sizing: border-box; background: var(--crisp-bg-surface);
|
|
4
4
|
& .crisp-table-scroll { overflow-x: auto; }
|
|
5
5
|
& table { width: 100%; border-collapse: separate; border-spacing: 0; font-family: var(--crisp-font-sans); font-size: var(--crisp-text-lg); color: var(--crisp-fg-primary); }
|
|
6
|
-
& th, & td { position: relative; height: var(--crisp-size-tableRow); padding: 0 var(--crisp-space-2); border-bottom: 1px solid var(--crisp-border-subtle); border-inline-end: 1px solid var(--crisp-border-subtle); text-align: start; vertical-align: middle; white-space: nowrap; overflow: hidden; }
|
|
6
|
+
& th, & td { position: relative; height: var(--crisp-size-tableRow); padding: 0 var(--crisp-space-2); border-bottom: 1px solid var(--crisp-border-subtle); border-inline-end: 1px solid var(--crisp-border-subtle); text-align: start; vertical-align: middle; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
7
7
|
& th { height: var(--crisp-size-tableHeader); background: var(--crisp-bg-surface); font-size: var(--crisp-text-lg); font-weight: var(--crisp-weight-medium); letter-spacing: -0.14px; color: color-mix(in srgb, var(--crisp-fg-primary) 63%, transparent); user-select: none; }
|
|
8
8
|
& td { color: var(--crisp-fg-primary); background: var(--crisp-bg-surface); }
|
|
9
9
|
& th:last-child, & td:last-child { border-inline-end: none; }
|
package/dist/tabs.css
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
.crisp-tabs {
|
|
2
2
|
display: flex; flex-direction: column; min-height: 0; font-family: var(--crisp-font-sans); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
|
|
3
|
-
& .crisp-tabs-list { position: relative; display: flex; align-items: center; gap: var(--crisp-space-1); height: var(--crisp-size-row); flex-shrink: 0; }
|
|
3
|
+
& .crisp-tabs-list { position: relative; display: flex; align-items: center; gap: var(--crisp-space-1); box-sizing: content-box; height: var(--crisp-size-row); padding: 0 var(--crisp-space-3) var(--crisp-space-2); flex-shrink: 0; }
|
|
4
|
+
& .crisp-tabs-list::after { content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 1px; background: var(--crisp-border-subtle); }
|
|
4
5
|
& .crisp-tabs-leading { display: inline-flex; align-items: center; margin-inline-end: var(--crisp-space-1); }
|
|
5
6
|
& .crisp-tabs-tab { position: relative; display: inline-flex; align-items: center; gap: var(--crisp-space-1-5); height: 28px; margin: 0; padding: 0 var(--crisp-space-2); border: none; background: none; box-shadow: inset 0 0 0 1px transparent; border-radius: var(--crisp-radius-md); font-family: inherit; font-size: var(--crisp-text-lg); font-weight: var(--crisp-weight-medium); letter-spacing: -0.14px; color: var(--crisp-fg-muted); cursor: pointer; white-space: nowrap; transition: color .12s ease, background-color .12s ease, box-shadow .12s ease; }
|
|
6
7
|
& .crisp-tabs-tab svg { flex-shrink: 0; opacity: 0.85; }
|
|
@@ -9,7 +10,7 @@
|
|
|
9
10
|
& .crisp-tabs-tab:focus-visible { outline: 2px solid var(--crisp-bg-brand-solid); outline-offset: 1px; }
|
|
10
11
|
& .crisp-tabs-tab-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 16px; height: 16px; padding: 0 var(--crisp-space-1); border-radius: var(--crisp-radius-sm); background: color-mix(in srgb, var(--crisp-fg-primary) 6%, transparent); font-size: var(--crisp-text-xs); font-weight: var(--crisp-weight-medium); color: var(--crisp-fg-muted); }
|
|
11
12
|
& .crisp-tabs-tab[data-selected] .crisp-tabs-tab-badge { color: var(--crisp-fg-primary); }
|
|
12
|
-
& .crisp-tabs-indicator { position: absolute; bottom:
|
|
13
|
+
& .crisp-tabs-indicator { position: absolute; bottom: 0; left: 0; z-index: 1; height: 1px; background: var(--crisp-fg-primary); border-radius: 2px 2px 0 0; transition: transform .2s cubic-bezier(.4,0,.2,1), width .2s cubic-bezier(.4,0,.2,1); pointer-events: none; }
|
|
13
14
|
& .crisp-tabs-panel { flex: 1; min-height: 0; }
|
|
14
15
|
& .crisp-tabs-panel:focus-visible { outline: none; }
|
|
15
16
|
}
|
package/dist/tabs.layered.css
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
@layer crisp-components {
|
|
2
2
|
.crisp-tabs {
|
|
3
3
|
display: flex; flex-direction: column; min-height: 0; font-family: var(--crisp-font-sans); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
|
|
4
|
-
& .crisp-tabs-list { position: relative; display: flex; align-items: center; gap: var(--crisp-space-1); height: var(--crisp-size-row); flex-shrink: 0; }
|
|
4
|
+
& .crisp-tabs-list { position: relative; display: flex; align-items: center; gap: var(--crisp-space-1); box-sizing: content-box; height: var(--crisp-size-row); padding: 0 var(--crisp-space-3) var(--crisp-space-2); flex-shrink: 0; }
|
|
5
|
+
& .crisp-tabs-list::after { content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 1px; background: var(--crisp-border-subtle); }
|
|
5
6
|
& .crisp-tabs-leading { display: inline-flex; align-items: center; margin-inline-end: var(--crisp-space-1); }
|
|
6
7
|
& .crisp-tabs-tab { position: relative; display: inline-flex; align-items: center; gap: var(--crisp-space-1-5); height: 28px; margin: 0; padding: 0 var(--crisp-space-2); border: none; background: none; box-shadow: inset 0 0 0 1px transparent; border-radius: var(--crisp-radius-md); font-family: inherit; font-size: var(--crisp-text-lg); font-weight: var(--crisp-weight-medium); letter-spacing: -0.14px; color: var(--crisp-fg-muted); cursor: pointer; white-space: nowrap; transition: color .12s ease, background-color .12s ease, box-shadow .12s ease; }
|
|
7
8
|
& .crisp-tabs-tab svg { flex-shrink: 0; opacity: 0.85; }
|
|
@@ -10,7 +11,7 @@
|
|
|
10
11
|
& .crisp-tabs-tab:focus-visible { outline: 2px solid var(--crisp-bg-brand-solid); outline-offset: 1px; }
|
|
11
12
|
& .crisp-tabs-tab-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 16px; height: 16px; padding: 0 var(--crisp-space-1); border-radius: var(--crisp-radius-sm); background: color-mix(in srgb, var(--crisp-fg-primary) 6%, transparent); font-size: var(--crisp-text-xs); font-weight: var(--crisp-weight-medium); color: var(--crisp-fg-muted); }
|
|
12
13
|
& .crisp-tabs-tab[data-selected] .crisp-tabs-tab-badge { color: var(--crisp-fg-primary); }
|
|
13
|
-
& .crisp-tabs-indicator { position: absolute; bottom:
|
|
14
|
+
& .crisp-tabs-indicator { position: absolute; bottom: 0; left: 0; z-index: 1; height: 1px; background: var(--crisp-fg-primary); border-radius: 2px 2px 0 0; transition: transform .2s cubic-bezier(.4,0,.2,1), width .2s cubic-bezier(.4,0,.2,1); pointer-events: none; }
|
|
14
15
|
& .crisp-tabs-panel { flex: 1; min-height: 0; }
|
|
15
16
|
& .crisp-tabs-panel:focus-visible { outline: none; }
|
|
16
17
|
}
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
|
-
export type TooltipProps = {
|
|
2
|
+
export type TooltipProps = Omit<React.ComponentPropsWithoutRef<"span">, "content" | "children"> & {
|
|
3
3
|
/** The hint text/content shown on hover or focus. */
|
|
4
4
|
content: React.ReactNode;
|
|
5
5
|
/**
|
|
6
|
-
* The trigger element. A SINGLE element: it
|
|
7
|
-
* `aria-describedby` that makes the hint reachable,
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* The trigger element. A SINGLE element: it carries the
|
|
7
|
+
* `aria-describedby` that makes the hint reachable (and, when `Tooltip`
|
|
8
|
+
* itself is composed as another component's trigger, e.g. `Popover`'s
|
|
9
|
+
* `trigger` prop cloning in a `ref`/`id`/`aria-*`/`onClick` — #360 — those
|
|
10
|
+
* too), so anything else — a string, a fragment, several children —
|
|
11
|
+
* renders and hovers correctly but can't carry either.
|
|
10
12
|
*/
|
|
11
13
|
children: React.ReactNode;
|
|
12
14
|
/** Preferred side; flips to the other axis end when there's no room. */
|
|
@@ -20,14 +22,67 @@ export type TooltipProps = {
|
|
|
20
22
|
defaultOpen?: boolean;
|
|
21
23
|
/** Fires with the next open state, controlled or not. */
|
|
22
24
|
onOpenChange?: (open: boolean) => void;
|
|
25
|
+
/**
|
|
26
|
+
* Attach directly to the child instead of wrapping it in a `<span>`
|
|
27
|
+
* (#360). The wrapper's `display: inline-flex` makes the child a flex
|
|
28
|
+
* item of a NEW container; flex items default to `min-width: auto` and
|
|
29
|
+
* won't shrink below their own content's width, so a child depending on
|
|
30
|
+
* an ANCESTOR's width constraint — most commonly `text-overflow:
|
|
31
|
+
* ellipsis` inside a fixed-width cell — silently stops truncating once
|
|
32
|
+
* it's wrapped. `asChild` requires a single element, the same as
|
|
33
|
+
* `ContextMenu`'s.
|
|
34
|
+
*/
|
|
35
|
+
asChild?: boolean;
|
|
23
36
|
};
|
|
24
37
|
/**
|
|
25
38
|
* A hover/focus tooltip — a dark hint chip measured live. Wraps its
|
|
26
39
|
* trigger in an inline element, shows after `delay` on pointer-enter / focus,
|
|
27
|
-
* and hides on leave / blur / Escape
|
|
28
|
-
*
|
|
40
|
+
* and hides on leave / blur / Escape (`asChild` skips the wrapper — see its
|
|
41
|
+
* doc). The bubble is portaled and positioned above the trigger (flipping
|
|
42
|
+
* below near the top edge).
|
|
43
|
+
*
|
|
44
|
+
* Composable as another component's trigger (#360: wrapping a `Popover`'s
|
|
45
|
+
* or `Menu`'s trigger in a `Tooltip` silently broke it). A composer clones
|
|
46
|
+
* a `ref`/`id`/`aria-*`/`onClick` onto whatever element it's handed —
|
|
47
|
+
* `Tooltip` wasn't `forwardRef` and didn't read any of the rest, so they
|
|
48
|
+
* landed on the `<Tooltip>` element itself, where nothing forwarded them
|
|
49
|
+
* anywhere. Both now merge onto the actual trigger via `Slot` (the same
|
|
50
|
+
* mechanism `ContextMenu`'s `asChild` uses) — the wrapping `<span>` can't
|
|
51
|
+
* carry them for the same reason it can't carry `aria-describedby` (below):
|
|
52
|
+
* a screen reader reads state off the focused element, not an ancestor.
|
|
29
53
|
*/
|
|
30
|
-
export declare
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
54
|
+
export declare const Tooltip: React.ForwardRefExoticComponent<Omit<Omit<React.DetailedHTMLProps<React.HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>, "ref">, "children" | "content"> & {
|
|
55
|
+
/** The hint text/content shown on hover or focus. */
|
|
56
|
+
content: React.ReactNode;
|
|
57
|
+
/**
|
|
58
|
+
* The trigger element. A SINGLE element: it carries the
|
|
59
|
+
* `aria-describedby` that makes the hint reachable (and, when `Tooltip`
|
|
60
|
+
* itself is composed as another component's trigger, e.g. `Popover`'s
|
|
61
|
+
* `trigger` prop cloning in a `ref`/`id`/`aria-*`/`onClick` — #360 — those
|
|
62
|
+
* too), so anything else — a string, a fragment, several children —
|
|
63
|
+
* renders and hovers correctly but can't carry either.
|
|
64
|
+
*/
|
|
65
|
+
children: React.ReactNode;
|
|
66
|
+
/** Preferred side; flips to the other axis end when there's no room. */
|
|
67
|
+
side?: "top" | "bottom";
|
|
68
|
+
/** Delay in ms before showing (default 350). Hides immediately. */
|
|
69
|
+
delay?: number;
|
|
70
|
+
/** Controlled open state. Omit to let Tooltip manage it (see defaultOpen).
|
|
71
|
+
* A controlled tooltip shows at once — `delay` only paces hover. */
|
|
72
|
+
open?: boolean;
|
|
73
|
+
/** Initially open, when uncontrolled. */
|
|
74
|
+
defaultOpen?: boolean;
|
|
75
|
+
/** Fires with the next open state, controlled or not. */
|
|
76
|
+
onOpenChange?: (open: boolean) => void;
|
|
77
|
+
/**
|
|
78
|
+
* Attach directly to the child instead of wrapping it in a `<span>`
|
|
79
|
+
* (#360). The wrapper's `display: inline-flex` makes the child a flex
|
|
80
|
+
* item of a NEW container; flex items default to `min-width: auto` and
|
|
81
|
+
* won't shrink below their own content's width, so a child depending on
|
|
82
|
+
* an ANCESTOR's width constraint — most commonly `text-overflow:
|
|
83
|
+
* ellipsis` inside a fixed-width cell — silently stops truncating once
|
|
84
|
+
* it's wrapped. `asChild` requires a single element, the same as
|
|
85
|
+
* `ContextMenu`'s.
|
|
86
|
+
*/
|
|
87
|
+
asChild?: boolean;
|
|
88
|
+
} & React.RefAttributes<HTMLElement>>;
|