@mediaradar/design-components 0.1.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/README.md +80 -0
- package/dist/index.cjs +103 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +38 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.js +76 -0
- package/dist/index.js.map +1 -0
- package/package.json +54 -0
package/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# @mediaradar/design-components
|
|
2
|
+
|
|
3
|
+
Real, shared React component primitives — the canonical implementation for
|
|
4
|
+
components genuinely common across consuming apps (`insights-studio-prototype`,
|
|
5
|
+
`mr-clientapps-insights-studio`), published as a real npm package so there's
|
|
6
|
+
structurally only one implementation instead of two that can silently drift.
|
|
7
|
+
|
|
8
|
+
**Status: spike/pilot.** One component (`InfoTip`) so far, not yet published
|
|
9
|
+
to the real npm registry, not yet consumed by `mr-clientapps-insights-studio`
|
|
10
|
+
at all. See `gh-design-system`'s open spike PR for the current state.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## What belongs here — and what doesn't
|
|
15
|
+
|
|
16
|
+
Not every shared-feeling component should move here. Two failure modes to
|
|
17
|
+
watch for, in opposite directions:
|
|
18
|
+
|
|
19
|
+
**Doesn't belong here: a concept that only exists in one app.** If
|
|
20
|
+
`mr-clientapps-insights-studio` has nothing resembling a component, or has
|
|
21
|
+
something meaningfully more sophisticated for the same job (its own
|
|
22
|
+
`FilterDrawer`/`FilterPanel` system vs. a prototype's simpler filter
|
|
23
|
+
components, for example), forcing it into a shared package is a downgrade
|
|
24
|
+
for whichever app had the better version, not an improvement. Leave it
|
|
25
|
+
where it is.
|
|
26
|
+
|
|
27
|
+
**The escape hatch — a genuine conflict discovered mid-migration.** Sometimes
|
|
28
|
+
a component looks like a good candidate until reconciliation actually starts,
|
|
29
|
+
and it turns out the two apps need behavior that's genuinely incompatible —
|
|
30
|
+
not just different-by-convenience, but conflicting requirements neither side
|
|
31
|
+
can drop. When that happens, **pull the component back out of this package**
|
|
32
|
+
rather than forcing a compromise that serves neither app well. A component
|
|
33
|
+
that ends up split by `if (isMrClientapps)`-style branching inside a
|
|
34
|
+
"shared" primitive has stopped being shared in any meaningful sense — it's
|
|
35
|
+
just drift with extra indirection. Keeping it separate, in each app's own
|
|
36
|
+
tree, is the honest outcome in that case, not a failure of the process.
|
|
37
|
+
|
|
38
|
+
This hasn't happened yet — `InfoTip`'s reconciliation (hover-trigger support
|
|
39
|
+
from `insights-studio-prototype`, z-index layering awareness from
|
|
40
|
+
`mr-clientapps-insights-studio`) turned out to be a clean, additive merge,
|
|
41
|
+
not a conflict. But the difference between "additive merge" and "genuine
|
|
42
|
+
conflict" is a judgment call every future migration needs to make freshly,
|
|
43
|
+
not assume away because the first one went smoothly.
|
|
44
|
+
|
|
45
|
+
**When it's genuinely additive but app-specific**, prefer these over
|
|
46
|
+
forking, in order:
|
|
47
|
+
1. **A prop with a default matching current behavior** — see `InfoTip`'s
|
|
48
|
+
own `trigger` prop: `mr-clientapps-insights-studio` never has to know it
|
|
49
|
+
exists, since the default preserves exactly what it already had.
|
|
50
|
+
2. **Composition** — an app wraps the shared primitive in its own local
|
|
51
|
+
component to add app-specific concerns (analytics, entitlements) the
|
|
52
|
+
shared primitive shouldn't know about. The shared component stays
|
|
53
|
+
strictly presentational.
|
|
54
|
+
|
|
55
|
+
## Reconciling two existing implementations
|
|
56
|
+
|
|
57
|
+
When a component already exists separately in both apps (as opposed to
|
|
58
|
+
being net-new), verify differences empirically before picking a winner —
|
|
59
|
+
don't assume one app's version is "more correct." `InfoTip`'s reconciliation
|
|
60
|
+
found real, verified divergence in both directions: `mr-clientapps`' bare
|
|
61
|
+
`outline` utility turned out to rely on a Tailwind v4 implementation
|
|
62
|
+
fallback rather than the intended `outline-solid` syntax (confirmed by
|
|
63
|
+
actually compiling both with the real installed Tailwind, not assumed), its
|
|
64
|
+
`shadow-2` didn't exist anywhere in `@mediaradar/design-tokens` at all (a
|
|
65
|
+
soon-to-be-stale local name), and its z-index value turned out to be in a
|
|
66
|
+
completely different numeric scale than this package's own semantic
|
|
67
|
+
`z-tooltip` token — using the "proper" shared token there would have
|
|
68
|
+
actually broken the thing its own code comment was trying to guarantee.
|
|
69
|
+
None of that would have surfaced without checking directly.
|
|
70
|
+
|
|
71
|
+
## Peer dependencies
|
|
72
|
+
|
|
73
|
+
`react`/`react-dom` are peer dependencies, never bundled — a bundled React
|
|
74
|
+
would sit alongside a consuming app's own instance instead of using it,
|
|
75
|
+
breaking hooks/context silently. `@mediaradar/design-tokens` is also a peer
|
|
76
|
+
dependency, even though nothing here literally imports from it — components
|
|
77
|
+
use Tailwind class names that only resolve correctly once a consuming app
|
|
78
|
+
already has that package's Tailwind theme wired up. Declaring it as a peer
|
|
79
|
+
(rather than not declaring it at all) means `npm install` at least warns if
|
|
80
|
+
it's missing, instead of the component silently rendering unstyled.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
InfoTip: () => InfoTip
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(index_exports);
|
|
26
|
+
|
|
27
|
+
// src/InfoTip.tsx
|
|
28
|
+
var import_react = require("react");
|
|
29
|
+
var import_react_dom = require("react-dom");
|
|
30
|
+
var import_lucide_react = require("lucide-react");
|
|
31
|
+
var import_jsx_runtime = require("react/jsx-runtime");
|
|
32
|
+
function InfoTip({ text, size = 11, label = "More information", trigger = "click" }) {
|
|
33
|
+
const [open, setOpen] = (0, import_react.useState)(false);
|
|
34
|
+
const [pos, setPos] = (0, import_react.useState)(null);
|
|
35
|
+
const btnRef = (0, import_react.useRef)(null);
|
|
36
|
+
(0, import_react.useEffect)(() => {
|
|
37
|
+
if (!open) return;
|
|
38
|
+
const onDown = (e) => {
|
|
39
|
+
if (!btnRef.current?.contains(e.target)) setOpen(false);
|
|
40
|
+
};
|
|
41
|
+
const onKey = (e) => e.key === "Escape" && setOpen(false);
|
|
42
|
+
window.addEventListener("mousedown", onDown);
|
|
43
|
+
window.addEventListener("keydown", onKey);
|
|
44
|
+
return () => {
|
|
45
|
+
window.removeEventListener("mousedown", onDown);
|
|
46
|
+
window.removeEventListener("keydown", onKey);
|
|
47
|
+
};
|
|
48
|
+
}, [open]);
|
|
49
|
+
const place = () => {
|
|
50
|
+
if (!btnRef.current) return;
|
|
51
|
+
const r = btnRef.current.getBoundingClientRect();
|
|
52
|
+
const width = Math.min(260, window.innerWidth - 24);
|
|
53
|
+
const left = Math.max(12, Math.min(r.left, window.innerWidth - width - 12));
|
|
54
|
+
setPos({ top: r.bottom + 6, left, width });
|
|
55
|
+
};
|
|
56
|
+
const show = () => {
|
|
57
|
+
place();
|
|
58
|
+
setOpen(true);
|
|
59
|
+
};
|
|
60
|
+
const toggle = (e) => {
|
|
61
|
+
e.stopPropagation();
|
|
62
|
+
if (!open) show();
|
|
63
|
+
else setOpen(false);
|
|
64
|
+
};
|
|
65
|
+
const hoverProps = trigger === "hover" ? {
|
|
66
|
+
onMouseEnter: show,
|
|
67
|
+
onMouseLeave: () => setOpen(false),
|
|
68
|
+
onFocus: show,
|
|
69
|
+
onBlur: () => setOpen(false)
|
|
70
|
+
} : {};
|
|
71
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
|
|
72
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
73
|
+
"button",
|
|
74
|
+
{
|
|
75
|
+
ref: btnRef,
|
|
76
|
+
type: "button",
|
|
77
|
+
onClick: toggle,
|
|
78
|
+
"aria-label": label,
|
|
79
|
+
"aria-expanded": open,
|
|
80
|
+
...hoverProps,
|
|
81
|
+
className: "inline-flex cursor-pointer items-center text-text-tertiary transition-colors hover:text-text-primary focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-border-focus",
|
|
82
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_lucide_react.Info, { size, "aria-hidden": "true" })
|
|
83
|
+
}
|
|
84
|
+
),
|
|
85
|
+
open && pos && (0, import_react_dom.createPortal)(
|
|
86
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
87
|
+
"div",
|
|
88
|
+
{
|
|
89
|
+
role: "tooltip",
|
|
90
|
+
style: { position: "fixed", top: pos.top, left: pos.left, width: pos.width },
|
|
91
|
+
className: "z-[90] rounded-control border border-border bg-surface px-3 py-2 text-[12px] font-normal not-italic normal-case leading-relaxed tracking-normal text-text-secondary shadow-overlay",
|
|
92
|
+
children: text
|
|
93
|
+
}
|
|
94
|
+
),
|
|
95
|
+
document.body
|
|
96
|
+
)
|
|
97
|
+
] });
|
|
98
|
+
}
|
|
99
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
100
|
+
0 && (module.exports = {
|
|
101
|
+
InfoTip
|
|
102
|
+
});
|
|
103
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/InfoTip.tsx"],"sourcesContent":["export { InfoTip } from \"./InfoTip\";\nexport type { InfoTipProps } from \"./InfoTip\";\n","\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { Info } from \"lucide-react\";\n\n/**\n * @pattern InfoTip\n * @description Info tooltip — a small info glyph that opens a portalled,\n * viewport-clamped bubble. Mirrors the Publisher Intel header affordance so\n * page subtitles can collapse into the eyebrow.\n * @tokens text-text-tertiary, rounded-control, border-border, bg-surface,\n * shadow-overlay, text-text-secondary, focus-visible:outline-border-focus,\n * hover:text-text-primary, tracking-normal\n * @dependencies lucide-react\n * @behavior 'click' mode toggles on click only. 'hover' mode also opens on\n * keyboard focus and closes on blur, so it stays reachable without a mouse —\n * not just a decorative hover effect. Both modes close on Escape and on any\n * outside click. The bubble is portaled to document.body and repositions\n * itself to stay clear of the right viewport edge.\n * @usage\n * ```tsx\n * <InfoTip text=\"Trims time periods to data that has finalized across the selected media formats.\" />\n * <InfoTip text=\"Hover variant\" trigger=\"hover\" />\n * ```\n */\nexport interface InfoTipProps {\n /** The bubble's content. */\n text: string;\n /** Glyph size in pixels. */\n size?: number;\n /** Accessible label for the trigger button. */\n label?: string;\n /**\n * 'click' toggles the bubble (default); 'hover' reveals it on hover, and\n * also on keyboard focus so it stays reachable without a pointer.\n */\n trigger?: \"click\" | \"hover\";\n}\n\nexport function InfoTip({ text, size = 11, label = \"More information\", trigger = \"click\" }: InfoTipProps) {\n const [open, setOpen] = useState(false);\n const [pos, setPos] = useState<{ top: number; left: number; width: number } | null>(null);\n const btnRef = useRef<HTMLButtonElement>(null);\n\n useEffect(() => {\n if (!open) return;\n const onDown = (e: MouseEvent) => {\n if (!btnRef.current?.contains(e.target as Node)) setOpen(false);\n };\n const onKey = (e: KeyboardEvent) => e.key === \"Escape\" && setOpen(false);\n window.addEventListener(\"mousedown\", onDown);\n window.addEventListener(\"keydown\", onKey);\n return () => {\n window.removeEventListener(\"mousedown\", onDown);\n window.removeEventListener(\"keydown\", onKey);\n };\n }, [open]);\n\n // Position the bubble under the glyph, clamped within the viewport so it\n // never squeezes against the right edge.\n const place = () => {\n if (!btnRef.current) return;\n const r = btnRef.current.getBoundingClientRect();\n const width = Math.min(260, window.innerWidth - 24);\n const left = Math.max(12, Math.min(r.left, window.innerWidth - width - 12));\n setPos({ top: r.bottom + 6, left, width });\n };\n\n const show = () => {\n place();\n setOpen(true);\n };\n\n const toggle = (e: React.MouseEvent) => {\n e.stopPropagation();\n if (!open) show();\n else setOpen(false);\n };\n\n // Hover trigger also opens on keyboard focus so it stays reachable.\n const hoverProps =\n trigger === \"hover\"\n ? {\n onMouseEnter: show,\n onMouseLeave: () => setOpen(false),\n onFocus: show,\n onBlur: () => setOpen(false),\n }\n : {};\n\n return (\n <>\n <button\n ref={btnRef}\n type=\"button\"\n onClick={toggle}\n aria-label={label}\n aria-expanded={open}\n {...hoverProps}\n className=\"inline-flex cursor-pointer items-center text-text-tertiary transition-colors hover:text-text-primary focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-border-focus\"\n >\n <Info size={size} aria-hidden=\"true\" />\n </button>\n {open &&\n pos &&\n createPortal(\n <div\n role=\"tooltip\"\n style={{ position: \"fixed\", top: pos.top, left: pos.left, width: pos.width }}\n // z-[90]: deliberately an arbitrary value, not @mediaradar/design-tokens'\n // semantic z-tooltip (1200) -- checked directly: mr-clientapps-insights-\n // studio's real toast layer is a literal z-[100] (ToastProvider.tsx),\n // completely unrelated in scale to design-tokens' 1000+ semantic z-index\n // tokens (z-dropdown/z-modal/z-tooltip). Using the shared token here\n // would render this bubble ABOVE that app's toasts -- the opposite of\n // the original intent (a tooltip should never cover a notification).\n // 90 sits above mr-clientapps' modals (below its own z-[100] toasts) as\n // of this writing -- portals to document.body, becoming a sibling of any\n // open dialog rather than a child of it, so a lower z-index would render\n // invisibly behind the dialog that triggered it. RE-VERIFY this number\n // against any NEW consuming app's own z-index scale before trusting it;\n // this is a leaf component making an assumption about global stacking\n // context it can't fully see.\n className=\"z-[90] rounded-control border border-border bg-surface px-3 py-2 text-[12px] font-normal not-italic normal-case leading-relaxed tracking-normal text-text-secondary shadow-overlay\"\n >\n {text}\n </div>,\n document.body\n )}\n </>\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACEA,mBAA4C;AAC5C,uBAA6B;AAC7B,0BAAqB;AAwFjB;AApDG,SAAS,QAAQ,EAAE,MAAM,OAAO,IAAI,QAAQ,oBAAoB,UAAU,QAAQ,GAAiB;AACxG,QAAM,CAAC,MAAM,OAAO,QAAI,uBAAS,KAAK;AACtC,QAAM,CAAC,KAAK,MAAM,QAAI,uBAA8D,IAAI;AACxF,QAAM,aAAS,qBAA0B,IAAI;AAE7C,8BAAU,MAAM;AACd,QAAI,CAAC,KAAM;AACX,UAAM,SAAS,CAAC,MAAkB;AAChC,UAAI,CAAC,OAAO,SAAS,SAAS,EAAE,MAAc,EAAG,SAAQ,KAAK;AAAA,IAChE;AACA,UAAM,QAAQ,CAAC,MAAqB,EAAE,QAAQ,YAAY,QAAQ,KAAK;AACvE,WAAO,iBAAiB,aAAa,MAAM;AAC3C,WAAO,iBAAiB,WAAW,KAAK;AACxC,WAAO,MAAM;AACX,aAAO,oBAAoB,aAAa,MAAM;AAC9C,aAAO,oBAAoB,WAAW,KAAK;AAAA,IAC7C;AAAA,EACF,GAAG,CAAC,IAAI,CAAC;AAIT,QAAM,QAAQ,MAAM;AAClB,QAAI,CAAC,OAAO,QAAS;AACrB,UAAM,IAAI,OAAO,QAAQ,sBAAsB;AAC/C,UAAM,QAAQ,KAAK,IAAI,KAAK,OAAO,aAAa,EAAE;AAClD,UAAM,OAAO,KAAK,IAAI,IAAI,KAAK,IAAI,EAAE,MAAM,OAAO,aAAa,QAAQ,EAAE,CAAC;AAC1E,WAAO,EAAE,KAAK,EAAE,SAAS,GAAG,MAAM,MAAM,CAAC;AAAA,EAC3C;AAEA,QAAM,OAAO,MAAM;AACjB,UAAM;AACN,YAAQ,IAAI;AAAA,EACd;AAEA,QAAM,SAAS,CAAC,MAAwB;AACtC,MAAE,gBAAgB;AAClB,QAAI,CAAC,KAAM,MAAK;AAAA,QACX,SAAQ,KAAK;AAAA,EACpB;AAGA,QAAM,aACJ,YAAY,UACR;AAAA,IACE,cAAc;AAAA,IACd,cAAc,MAAM,QAAQ,KAAK;AAAA,IACjC,SAAS;AAAA,IACT,QAAQ,MAAM,QAAQ,KAAK;AAAA,EAC7B,IACA,CAAC;AAEP,SACE,4EACE;AAAA;AAAA,MAAC;AAAA;AAAA,QACC,KAAK;AAAA,QACL,MAAK;AAAA,QACL,SAAS;AAAA,QACT,cAAY;AAAA,QACZ,iBAAe;AAAA,QACd,GAAG;AAAA,QACJ,WAAU;AAAA,QAEV,sDAAC,4BAAK,MAAY,eAAY,QAAO;AAAA;AAAA,IACvC;AAAA,IACC,QACC,WACA;AAAA,MACE;AAAA,QAAC;AAAA;AAAA,UACC,MAAK;AAAA,UACL,OAAO,EAAE,UAAU,SAAS,KAAK,IAAI,KAAK,MAAM,IAAI,MAAM,OAAO,IAAI,MAAM;AAAA,UAe3E,WAAU;AAAA,UAET;AAAA;AAAA,MACH;AAAA,MACA,SAAS;AAAA,IACX;AAAA,KACJ;AAEJ;","names":[]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @pattern InfoTip
|
|
5
|
+
* @description Info tooltip — a small info glyph that opens a portalled,
|
|
6
|
+
* viewport-clamped bubble. Mirrors the Publisher Intel header affordance so
|
|
7
|
+
* page subtitles can collapse into the eyebrow.
|
|
8
|
+
* @tokens text-text-tertiary, rounded-control, border-border, bg-surface,
|
|
9
|
+
* shadow-overlay, text-text-secondary, focus-visible:outline-border-focus,
|
|
10
|
+
* hover:text-text-primary, tracking-normal
|
|
11
|
+
* @dependencies lucide-react
|
|
12
|
+
* @behavior 'click' mode toggles on click only. 'hover' mode also opens on
|
|
13
|
+
* keyboard focus and closes on blur, so it stays reachable without a mouse —
|
|
14
|
+
* not just a decorative hover effect. Both modes close on Escape and on any
|
|
15
|
+
* outside click. The bubble is portaled to document.body and repositions
|
|
16
|
+
* itself to stay clear of the right viewport edge.
|
|
17
|
+
* @usage
|
|
18
|
+
* ```tsx
|
|
19
|
+
* <InfoTip text="Trims time periods to data that has finalized across the selected media formats." />
|
|
20
|
+
* <InfoTip text="Hover variant" trigger="hover" />
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
interface InfoTipProps {
|
|
24
|
+
/** The bubble's content. */
|
|
25
|
+
text: string;
|
|
26
|
+
/** Glyph size in pixels. */
|
|
27
|
+
size?: number;
|
|
28
|
+
/** Accessible label for the trigger button. */
|
|
29
|
+
label?: string;
|
|
30
|
+
/**
|
|
31
|
+
* 'click' toggles the bubble (default); 'hover' reveals it on hover, and
|
|
32
|
+
* also on keyboard focus so it stays reachable without a pointer.
|
|
33
|
+
*/
|
|
34
|
+
trigger?: "click" | "hover";
|
|
35
|
+
}
|
|
36
|
+
declare function InfoTip({ text, size, label, trigger }: InfoTipProps): react.JSX.Element;
|
|
37
|
+
|
|
38
|
+
export { InfoTip, type InfoTipProps };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @pattern InfoTip
|
|
5
|
+
* @description Info tooltip — a small info glyph that opens a portalled,
|
|
6
|
+
* viewport-clamped bubble. Mirrors the Publisher Intel header affordance so
|
|
7
|
+
* page subtitles can collapse into the eyebrow.
|
|
8
|
+
* @tokens text-text-tertiary, rounded-control, border-border, bg-surface,
|
|
9
|
+
* shadow-overlay, text-text-secondary, focus-visible:outline-border-focus,
|
|
10
|
+
* hover:text-text-primary, tracking-normal
|
|
11
|
+
* @dependencies lucide-react
|
|
12
|
+
* @behavior 'click' mode toggles on click only. 'hover' mode also opens on
|
|
13
|
+
* keyboard focus and closes on blur, so it stays reachable without a mouse —
|
|
14
|
+
* not just a decorative hover effect. Both modes close on Escape and on any
|
|
15
|
+
* outside click. The bubble is portaled to document.body and repositions
|
|
16
|
+
* itself to stay clear of the right viewport edge.
|
|
17
|
+
* @usage
|
|
18
|
+
* ```tsx
|
|
19
|
+
* <InfoTip text="Trims time periods to data that has finalized across the selected media formats." />
|
|
20
|
+
* <InfoTip text="Hover variant" trigger="hover" />
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
interface InfoTipProps {
|
|
24
|
+
/** The bubble's content. */
|
|
25
|
+
text: string;
|
|
26
|
+
/** Glyph size in pixels. */
|
|
27
|
+
size?: number;
|
|
28
|
+
/** Accessible label for the trigger button. */
|
|
29
|
+
label?: string;
|
|
30
|
+
/**
|
|
31
|
+
* 'click' toggles the bubble (default); 'hover' reveals it on hover, and
|
|
32
|
+
* also on keyboard focus so it stays reachable without a pointer.
|
|
33
|
+
*/
|
|
34
|
+
trigger?: "click" | "hover";
|
|
35
|
+
}
|
|
36
|
+
declare function InfoTip({ text, size, label, trigger }: InfoTipProps): react.JSX.Element;
|
|
37
|
+
|
|
38
|
+
export { InfoTip, type InfoTipProps };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// src/InfoTip.tsx
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { createPortal } from "react-dom";
|
|
4
|
+
import { Info } from "lucide-react";
|
|
5
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
6
|
+
function InfoTip({ text, size = 11, label = "More information", trigger = "click" }) {
|
|
7
|
+
const [open, setOpen] = useState(false);
|
|
8
|
+
const [pos, setPos] = useState(null);
|
|
9
|
+
const btnRef = useRef(null);
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
if (!open) return;
|
|
12
|
+
const onDown = (e) => {
|
|
13
|
+
if (!btnRef.current?.contains(e.target)) setOpen(false);
|
|
14
|
+
};
|
|
15
|
+
const onKey = (e) => e.key === "Escape" && setOpen(false);
|
|
16
|
+
window.addEventListener("mousedown", onDown);
|
|
17
|
+
window.addEventListener("keydown", onKey);
|
|
18
|
+
return () => {
|
|
19
|
+
window.removeEventListener("mousedown", onDown);
|
|
20
|
+
window.removeEventListener("keydown", onKey);
|
|
21
|
+
};
|
|
22
|
+
}, [open]);
|
|
23
|
+
const place = () => {
|
|
24
|
+
if (!btnRef.current) return;
|
|
25
|
+
const r = btnRef.current.getBoundingClientRect();
|
|
26
|
+
const width = Math.min(260, window.innerWidth - 24);
|
|
27
|
+
const left = Math.max(12, Math.min(r.left, window.innerWidth - width - 12));
|
|
28
|
+
setPos({ top: r.bottom + 6, left, width });
|
|
29
|
+
};
|
|
30
|
+
const show = () => {
|
|
31
|
+
place();
|
|
32
|
+
setOpen(true);
|
|
33
|
+
};
|
|
34
|
+
const toggle = (e) => {
|
|
35
|
+
e.stopPropagation();
|
|
36
|
+
if (!open) show();
|
|
37
|
+
else setOpen(false);
|
|
38
|
+
};
|
|
39
|
+
const hoverProps = trigger === "hover" ? {
|
|
40
|
+
onMouseEnter: show,
|
|
41
|
+
onMouseLeave: () => setOpen(false),
|
|
42
|
+
onFocus: show,
|
|
43
|
+
onBlur: () => setOpen(false)
|
|
44
|
+
} : {};
|
|
45
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
46
|
+
/* @__PURE__ */ jsx(
|
|
47
|
+
"button",
|
|
48
|
+
{
|
|
49
|
+
ref: btnRef,
|
|
50
|
+
type: "button",
|
|
51
|
+
onClick: toggle,
|
|
52
|
+
"aria-label": label,
|
|
53
|
+
"aria-expanded": open,
|
|
54
|
+
...hoverProps,
|
|
55
|
+
className: "inline-flex cursor-pointer items-center text-text-tertiary transition-colors hover:text-text-primary focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-border-focus",
|
|
56
|
+
children: /* @__PURE__ */ jsx(Info, { size, "aria-hidden": "true" })
|
|
57
|
+
}
|
|
58
|
+
),
|
|
59
|
+
open && pos && createPortal(
|
|
60
|
+
/* @__PURE__ */ jsx(
|
|
61
|
+
"div",
|
|
62
|
+
{
|
|
63
|
+
role: "tooltip",
|
|
64
|
+
style: { position: "fixed", top: pos.top, left: pos.left, width: pos.width },
|
|
65
|
+
className: "z-[90] rounded-control border border-border bg-surface px-3 py-2 text-[12px] font-normal not-italic normal-case leading-relaxed tracking-normal text-text-secondary shadow-overlay",
|
|
66
|
+
children: text
|
|
67
|
+
}
|
|
68
|
+
),
|
|
69
|
+
document.body
|
|
70
|
+
)
|
|
71
|
+
] });
|
|
72
|
+
}
|
|
73
|
+
export {
|
|
74
|
+
InfoTip
|
|
75
|
+
};
|
|
76
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/InfoTip.tsx"],"sourcesContent":["\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { Info } from \"lucide-react\";\n\n/**\n * @pattern InfoTip\n * @description Info tooltip — a small info glyph that opens a portalled,\n * viewport-clamped bubble. Mirrors the Publisher Intel header affordance so\n * page subtitles can collapse into the eyebrow.\n * @tokens text-text-tertiary, rounded-control, border-border, bg-surface,\n * shadow-overlay, text-text-secondary, focus-visible:outline-border-focus,\n * hover:text-text-primary, tracking-normal\n * @dependencies lucide-react\n * @behavior 'click' mode toggles on click only. 'hover' mode also opens on\n * keyboard focus and closes on blur, so it stays reachable without a mouse —\n * not just a decorative hover effect. Both modes close on Escape and on any\n * outside click. The bubble is portaled to document.body and repositions\n * itself to stay clear of the right viewport edge.\n * @usage\n * ```tsx\n * <InfoTip text=\"Trims time periods to data that has finalized across the selected media formats.\" />\n * <InfoTip text=\"Hover variant\" trigger=\"hover\" />\n * ```\n */\nexport interface InfoTipProps {\n /** The bubble's content. */\n text: string;\n /** Glyph size in pixels. */\n size?: number;\n /** Accessible label for the trigger button. */\n label?: string;\n /**\n * 'click' toggles the bubble (default); 'hover' reveals it on hover, and\n * also on keyboard focus so it stays reachable without a pointer.\n */\n trigger?: \"click\" | \"hover\";\n}\n\nexport function InfoTip({ text, size = 11, label = \"More information\", trigger = \"click\" }: InfoTipProps) {\n const [open, setOpen] = useState(false);\n const [pos, setPos] = useState<{ top: number; left: number; width: number } | null>(null);\n const btnRef = useRef<HTMLButtonElement>(null);\n\n useEffect(() => {\n if (!open) return;\n const onDown = (e: MouseEvent) => {\n if (!btnRef.current?.contains(e.target as Node)) setOpen(false);\n };\n const onKey = (e: KeyboardEvent) => e.key === \"Escape\" && setOpen(false);\n window.addEventListener(\"mousedown\", onDown);\n window.addEventListener(\"keydown\", onKey);\n return () => {\n window.removeEventListener(\"mousedown\", onDown);\n window.removeEventListener(\"keydown\", onKey);\n };\n }, [open]);\n\n // Position the bubble under the glyph, clamped within the viewport so it\n // never squeezes against the right edge.\n const place = () => {\n if (!btnRef.current) return;\n const r = btnRef.current.getBoundingClientRect();\n const width = Math.min(260, window.innerWidth - 24);\n const left = Math.max(12, Math.min(r.left, window.innerWidth - width - 12));\n setPos({ top: r.bottom + 6, left, width });\n };\n\n const show = () => {\n place();\n setOpen(true);\n };\n\n const toggle = (e: React.MouseEvent) => {\n e.stopPropagation();\n if (!open) show();\n else setOpen(false);\n };\n\n // Hover trigger also opens on keyboard focus so it stays reachable.\n const hoverProps =\n trigger === \"hover\"\n ? {\n onMouseEnter: show,\n onMouseLeave: () => setOpen(false),\n onFocus: show,\n onBlur: () => setOpen(false),\n }\n : {};\n\n return (\n <>\n <button\n ref={btnRef}\n type=\"button\"\n onClick={toggle}\n aria-label={label}\n aria-expanded={open}\n {...hoverProps}\n className=\"inline-flex cursor-pointer items-center text-text-tertiary transition-colors hover:text-text-primary focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-border-focus\"\n >\n <Info size={size} aria-hidden=\"true\" />\n </button>\n {open &&\n pos &&\n createPortal(\n <div\n role=\"tooltip\"\n style={{ position: \"fixed\", top: pos.top, left: pos.left, width: pos.width }}\n // z-[90]: deliberately an arbitrary value, not @mediaradar/design-tokens'\n // semantic z-tooltip (1200) -- checked directly: mr-clientapps-insights-\n // studio's real toast layer is a literal z-[100] (ToastProvider.tsx),\n // completely unrelated in scale to design-tokens' 1000+ semantic z-index\n // tokens (z-dropdown/z-modal/z-tooltip). Using the shared token here\n // would render this bubble ABOVE that app's toasts -- the opposite of\n // the original intent (a tooltip should never cover a notification).\n // 90 sits above mr-clientapps' modals (below its own z-[100] toasts) as\n // of this writing -- portals to document.body, becoming a sibling of any\n // open dialog rather than a child of it, so a lower z-index would render\n // invisibly behind the dialog that triggered it. RE-VERIFY this number\n // against any NEW consuming app's own z-index scale before trusting it;\n // this is a leaf component making an assumption about global stacking\n // context it can't fully see.\n className=\"z-[90] rounded-control border border-border bg-surface px-3 py-2 text-[12px] font-normal not-italic normal-case leading-relaxed tracking-normal text-text-secondary shadow-overlay\"\n >\n {text}\n </div>,\n document.body\n )}\n </>\n );\n}\n"],"mappings":";AAEA,SAAS,WAAW,QAAQ,gBAAgB;AAC5C,SAAS,oBAAoB;AAC7B,SAAS,YAAY;AAwFjB,mBAUI,KAVJ;AApDG,SAAS,QAAQ,EAAE,MAAM,OAAO,IAAI,QAAQ,oBAAoB,UAAU,QAAQ,GAAiB;AACxG,QAAM,CAAC,MAAM,OAAO,IAAI,SAAS,KAAK;AACtC,QAAM,CAAC,KAAK,MAAM,IAAI,SAA8D,IAAI;AACxF,QAAM,SAAS,OAA0B,IAAI;AAE7C,YAAU,MAAM;AACd,QAAI,CAAC,KAAM;AACX,UAAM,SAAS,CAAC,MAAkB;AAChC,UAAI,CAAC,OAAO,SAAS,SAAS,EAAE,MAAc,EAAG,SAAQ,KAAK;AAAA,IAChE;AACA,UAAM,QAAQ,CAAC,MAAqB,EAAE,QAAQ,YAAY,QAAQ,KAAK;AACvE,WAAO,iBAAiB,aAAa,MAAM;AAC3C,WAAO,iBAAiB,WAAW,KAAK;AACxC,WAAO,MAAM;AACX,aAAO,oBAAoB,aAAa,MAAM;AAC9C,aAAO,oBAAoB,WAAW,KAAK;AAAA,IAC7C;AAAA,EACF,GAAG,CAAC,IAAI,CAAC;AAIT,QAAM,QAAQ,MAAM;AAClB,QAAI,CAAC,OAAO,QAAS;AACrB,UAAM,IAAI,OAAO,QAAQ,sBAAsB;AAC/C,UAAM,QAAQ,KAAK,IAAI,KAAK,OAAO,aAAa,EAAE;AAClD,UAAM,OAAO,KAAK,IAAI,IAAI,KAAK,IAAI,EAAE,MAAM,OAAO,aAAa,QAAQ,EAAE,CAAC;AAC1E,WAAO,EAAE,KAAK,EAAE,SAAS,GAAG,MAAM,MAAM,CAAC;AAAA,EAC3C;AAEA,QAAM,OAAO,MAAM;AACjB,UAAM;AACN,YAAQ,IAAI;AAAA,EACd;AAEA,QAAM,SAAS,CAAC,MAAwB;AACtC,MAAE,gBAAgB;AAClB,QAAI,CAAC,KAAM,MAAK;AAAA,QACX,SAAQ,KAAK;AAAA,EACpB;AAGA,QAAM,aACJ,YAAY,UACR;AAAA,IACE,cAAc;AAAA,IACd,cAAc,MAAM,QAAQ,KAAK;AAAA,IACjC,SAAS;AAAA,IACT,QAAQ,MAAM,QAAQ,KAAK;AAAA,EAC7B,IACA,CAAC;AAEP,SACE,iCACE;AAAA;AAAA,MAAC;AAAA;AAAA,QACC,KAAK;AAAA,QACL,MAAK;AAAA,QACL,SAAS;AAAA,QACT,cAAY;AAAA,QACZ,iBAAe;AAAA,QACd,GAAG;AAAA,QACJ,WAAU;AAAA,QAEV,8BAAC,QAAK,MAAY,eAAY,QAAO;AAAA;AAAA,IACvC;AAAA,IACC,QACC,OACA;AAAA,MACE;AAAA,QAAC;AAAA;AAAA,UACC,MAAK;AAAA,UACL,OAAO,EAAE,UAAU,SAAS,KAAK,IAAI,KAAK,MAAM,IAAI,MAAM,OAAO,IAAI,MAAM;AAAA,UAe3E,WAAU;AAAA,UAET;AAAA;AAAA,MACH;AAAA,MACA,SAAS;AAAA,IACX;AAAA,KACJ;AAEJ;","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mediaradar/design-components",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./dist/index.cjs",
|
|
6
|
+
"module": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist"
|
|
10
|
+
],
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"import": "./dist/index.js",
|
|
15
|
+
"require": "./dist/index.cjs"
|
|
16
|
+
},
|
|
17
|
+
"./package.json": "./package.json"
|
|
18
|
+
},
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsup",
|
|
24
|
+
"typecheck": "tsc --noEmit"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
28
|
+
"react-dom": "^18.0.0 || ^19.0.0",
|
|
29
|
+
"@mediaradar/design-tokens": ">=2.0.0"
|
|
30
|
+
},
|
|
31
|
+
"peerDependenciesMeta": {
|
|
32
|
+
"@mediaradar/design-tokens": {
|
|
33
|
+
"optional": false
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"lucide-react": "^1.17.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/react": "^19.2.6",
|
|
41
|
+
"@types/react-dom": "^19.2.6",
|
|
42
|
+
"react": "^19.2.6",
|
|
43
|
+
"react-dom": "^19.2.6",
|
|
44
|
+
"tsup": "^8.3.5",
|
|
45
|
+
"typescript": "^5.7.2"
|
|
46
|
+
},
|
|
47
|
+
"author": "",
|
|
48
|
+
"license": "Apache-2.0",
|
|
49
|
+
"keywords": [
|
|
50
|
+
"design-components",
|
|
51
|
+
"mediaradar"
|
|
52
|
+
],
|
|
53
|
+
"description": "MediaRadar shared React component primitives -- the canonical implementation for components genuinely common across consuming apps (insights-studio-prototype, mr-clientapps-insights-studio). Not everything belongs here -- see the pilot notes in this package's README before adding a new component."
|
|
54
|
+
}
|