@astryxdesign/lab 0.1.4-canary.39ffd21 → 0.1.4-canary.3b5f42e
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/InfoTip/InfoTip.d.ts +58 -0
- package/dist/InfoTip/InfoTip.d.ts.map +1 -0
- package/dist/InfoTip/InfoTip.js +91 -0
- package/dist/InfoTip/index.d.ts +6 -0
- package/dist/InfoTip/index.d.ts.map +1 -0
- package/dist/InfoTip/index.js +8 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/lab.css +6 -0
- package/package.json +2 -2
- package/src/InfoTip/InfoTip.doc.mjs +97 -0
- package/src/InfoTip/InfoTip.test.tsx +169 -0
- package/src/InfoTip/InfoTip.tsx +155 -0
- package/src/InfoTip/index.ts +10 -0
- package/src/index.ts +3 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file InfoTip.tsx
|
|
3
|
+
* @input Uses React, stylex, Tooltip + Icon from @astryxdesign/core, color/spacing/radius/duration/ease tokens
|
|
4
|
+
* @output Exports InfoTip component, InfoTipProps, InfoTipSize types
|
|
5
|
+
* @position Lab experiment (RFC facebook/astryx#3349); core implementation consumed by index.ts
|
|
6
|
+
*
|
|
7
|
+
* SYNC: When modified, update these files to stay in sync:
|
|
8
|
+
* - /packages/lab/src/InfoTip/InfoTip.doc.mjs (props table, features)
|
|
9
|
+
* - /packages/lab/src/InfoTip/InfoTip.test.tsx (tests for new/changed behavior)
|
|
10
|
+
* - /packages/lab/src/InfoTip/index.ts (exports if types change)
|
|
11
|
+
*/
|
|
12
|
+
import { type ReactNode } from 'react';
|
|
13
|
+
import { type IconSize } from '@astryxdesign/core/Icon';
|
|
14
|
+
/**
|
|
15
|
+
* Size of the info icon. Maps 1:1 to Icon sizes
|
|
16
|
+
* (xsm: 12px, sm: 16px, md: 20px, lg: 24px).
|
|
17
|
+
*/
|
|
18
|
+
export type InfoTipSize = IconSize;
|
|
19
|
+
export interface InfoTipProps {
|
|
20
|
+
/**
|
|
21
|
+
* Content to display in the tooltip.
|
|
22
|
+
* Typically short, non-interactive text. Mirrors Tooltip's `content` prop.
|
|
23
|
+
*/
|
|
24
|
+
content: ReactNode;
|
|
25
|
+
/**
|
|
26
|
+
* Accessible name for the trigger button.
|
|
27
|
+
* @default 'More information'
|
|
28
|
+
*/
|
|
29
|
+
label?: string;
|
|
30
|
+
/**
|
|
31
|
+
* Size of the info icon.
|
|
32
|
+
* @default 'sm'
|
|
33
|
+
*/
|
|
34
|
+
size?: InfoTipSize;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* An inline info-icon help affordance: a small "i" button that reveals a
|
|
38
|
+
* tooltip on hover and keyboard focus. Use it next to labels, values, and
|
|
39
|
+
* metrics for permission notes, metric definitions, and field help.
|
|
40
|
+
*
|
|
41
|
+
* The value over hand-composing Icon inside Tooltip is the pre-wired
|
|
42
|
+
* accessible trigger: a real button with an aria-label, Tab-reachable,
|
|
43
|
+
* tooltip on hover AND focus, and Escape dismissal.
|
|
44
|
+
*
|
|
45
|
+
* Composed entirely from core primitives (Tooltip + Icon); the info icon
|
|
46
|
+
* resolves from the global icon registry, so themes can override it.
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```
|
|
50
|
+
* <InfoTip content="Editors can change this field; viewers cannot." />
|
|
51
|
+
* <InfoTip content="30-day rolling average." label="About this metric" />
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
export declare function InfoTip({ content, label, size, }: InfoTipProps): ReactNode;
|
|
55
|
+
export declare namespace InfoTip {
|
|
56
|
+
var displayName: string;
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=InfoTip.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"InfoTip.d.ts","sourceRoot":"","sources":["../../src/InfoTip/InfoTip.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;GAUG;AAEH,OAAO,EAAgC,KAAK,SAAS,EAAC,MAAM,OAAO,CAAC;AAGpE,OAAO,EAAO,KAAK,QAAQ,EAAC,MAAM,yBAAyB,CAAC;AAU5D;;;GAGG;AACH,MAAM,MAAM,WAAW,GAAG,QAAQ,CAAC;AAEnC,MAAM,WAAW,YAAY;IAC3B;;;OAGG;IACH,OAAO,EAAE,SAAS,CAAC;IACnB;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,IAAI,CAAC,EAAE,WAAW,CAAC;CACpB;AAmCD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,OAAO,CAAC,EACtB,OAAO,EACP,KAA0B,EAC1B,IAAW,GACZ,EAAE,YAAY,GAAG,SAAS,CA4C1B;yBAhDe,OAAO"}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
'use client';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @file InfoTip.tsx
|
|
7
|
+
* @input Uses React, stylex, Tooltip + Icon from @astryxdesign/core, color/spacing/radius/duration/ease tokens
|
|
8
|
+
* @output Exports InfoTip component, InfoTipProps, InfoTipSize types
|
|
9
|
+
* @position Lab experiment (RFC facebook/astryx#3349); core implementation consumed by index.ts
|
|
10
|
+
*
|
|
11
|
+
* SYNC: When modified, update these files to stay in sync:
|
|
12
|
+
* - /packages/lab/src/InfoTip/InfoTip.doc.mjs (props table, features)
|
|
13
|
+
* - /packages/lab/src/InfoTip/InfoTip.test.tsx (tests for new/changed behavior)
|
|
14
|
+
* - /packages/lab/src/InfoTip/index.ts (exports if types change)
|
|
15
|
+
*/
|
|
16
|
+
import { useCallback, useRef, useState } from 'react';
|
|
17
|
+
import * as stylex from '@stylexjs/stylex';
|
|
18
|
+
import { Icon } from '@astryxdesign/core/Icon';
|
|
19
|
+
import { Tooltip } from '@astryxdesign/core/Tooltip';
|
|
20
|
+
import '@astryxdesign/core/theme/tokens.stylex';
|
|
21
|
+
import { colorVars, durationVars, easeVars, radiusVars, spacingVars } from '@astryxdesign/core/theme/tokens.stylex';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Size of the info icon. Maps 1:1 to Icon sizes
|
|
25
|
+
* (xsm: 12px, sm: 16px, md: 20px, lg: 24px).
|
|
26
|
+
*/
|
|
27
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
28
|
+
/**
|
|
29
|
+
* An inline info-icon help affordance: a small "i" button that reveals a
|
|
30
|
+
* tooltip on hover and keyboard focus. Use it next to labels, values, and
|
|
31
|
+
* metrics for permission notes, metric definitions, and field help.
|
|
32
|
+
*
|
|
33
|
+
* The value over hand-composing Icon inside Tooltip is the pre-wired
|
|
34
|
+
* accessible trigger: a real button with an aria-label, Tab-reachable,
|
|
35
|
+
* tooltip on hover AND focus, and Escape dismissal.
|
|
36
|
+
*
|
|
37
|
+
* Composed entirely from core primitives (Tooltip + Icon); the info icon
|
|
38
|
+
* resolves from the global icon registry, so themes can override it.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```
|
|
42
|
+
* <InfoTip content="Editors can change this field; viewers cannot." />
|
|
43
|
+
* <InfoTip content="30-day rolling average." label="About this metric" />
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export function InfoTip({
|
|
47
|
+
content,
|
|
48
|
+
label = 'More information',
|
|
49
|
+
size = 'sm'
|
|
50
|
+
}) {
|
|
51
|
+
// Escape dismissal: `true` force-hides the tooltip (Tooltip isOpen={false});
|
|
52
|
+
// `false` returns control to Tooltip's own hover/focus triggers (isOpen
|
|
53
|
+
// undefined). Reset when the pointer or focus leaves the trigger so the
|
|
54
|
+
// tooltip can re-open on the next hover/focus.
|
|
55
|
+
const [isDismissed, setIsDismissed] = useState(false);
|
|
56
|
+
const isOpenRef = useRef(false);
|
|
57
|
+
const handleOpenChange = useCallback(isOpen => {
|
|
58
|
+
isOpenRef.current = isOpen;
|
|
59
|
+
}, []);
|
|
60
|
+
const handleKeyDown = useCallback(event => {
|
|
61
|
+
if (event.key === 'Escape' && isOpenRef.current) {
|
|
62
|
+
// Only swallow Escape when it actually dismissed the tooltip, so an
|
|
63
|
+
// enclosing dialog still closes on the next press.
|
|
64
|
+
event.stopPropagation();
|
|
65
|
+
setIsDismissed(true);
|
|
66
|
+
}
|
|
67
|
+
}, []);
|
|
68
|
+
const handleReset = useCallback(() => {
|
|
69
|
+
setIsDismissed(false);
|
|
70
|
+
}, []);
|
|
71
|
+
return /*#__PURE__*/_jsx(Tooltip, {
|
|
72
|
+
content: content,
|
|
73
|
+
isOpen: isDismissed ? false : undefined,
|
|
74
|
+
onOpenChange: handleOpenChange,
|
|
75
|
+
children: /*#__PURE__*/_jsx("button", {
|
|
76
|
+
type: "button",
|
|
77
|
+
"aria-label": label,
|
|
78
|
+
onKeyDown: handleKeyDown,
|
|
79
|
+
onBlur: handleReset,
|
|
80
|
+
onMouseLeave: handleReset,
|
|
81
|
+
...{
|
|
82
|
+
className: "x3nfvp2 x6s0dn4 xl56j7k xxymvpz x12nba7r x1ghz6dp xng3xce xjbqb8w xjspbzw x1ypdohk xv9yike xkxie9p x17nn4n9 x1wfwxd8 x7s97pk xt3l3uh xuedmi6 xlr8y92"
|
|
83
|
+
},
|
|
84
|
+
children: /*#__PURE__*/_jsx(Icon, {
|
|
85
|
+
icon: "info",
|
|
86
|
+
size: size
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
InfoTip.displayName = 'InfoTip';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/InfoTip/index.ts"],"names":[],"mappings":"AAIA;;GAEG;AAEH,OAAO,EAAC,OAAO,EAAC,MAAM,WAAW,CAAC;AAClC,YAAY,EAAC,YAAY,EAAE,WAAW,EAAC,MAAM,WAAW,CAAC"}
|
package/dist/index.d.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
export { CodeBlock, type CodeBlockProps } from './CodeBlock';
|
|
13
13
|
export { CodeEditor, type CodeEditorProps } from './CodeEditor';
|
|
14
14
|
export { tokenize, tokenizeAsync, SYNC_TOKENIZE_THRESHOLD, type SyntaxToken, } from '@astryxdesign/core/CodeBlock';
|
|
15
|
+
export { InfoTip, type InfoTipProps, type InfoTipSize } from './InfoTip';
|
|
15
16
|
export { ChatReasoning, type ChatReasoningProps, } from './ChatReasoning/ChatReasoning';
|
|
16
17
|
export * from './Schedule';
|
|
17
18
|
export { SVGIcon, type SVGIconProps, type SVGIconVariation, type SVGIconSize, type SVGIconColor, type SVGIconDef, type IconShape, type IconShapeRole, iconVars, variations, opticalSize, xIcon, checkIcon, bellIcon, homeIcon, settingsIcon, calendarIcon, menuIcon, heartIcon, eyeIcon, starIcon, folderIcon, shieldIcon, searchIcon, mailIcon, lockIcon, starterIcons, } from './SVGIcon';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/dist/index.js
CHANGED
|
@@ -17,6 +17,9 @@ export { CodeBlock } from "./CodeBlock/index.js";
|
|
|
17
17
|
export { CodeEditor } from "./CodeEditor/index.js";
|
|
18
18
|
export { tokenize, tokenizeAsync, SYNC_TOKENIZE_THRESHOLD } from '@astryxdesign/core/CodeBlock';
|
|
19
19
|
|
|
20
|
+
// InfoTip — accessible info-icon help affordance (RFC facebook/astryx#3349)
|
|
21
|
+
export { InfoTip } from "./InfoTip/index.js";
|
|
22
|
+
|
|
20
23
|
// Chat — experimental reasoning display
|
|
21
24
|
export { ChatReasoning } from "./ChatReasoning/ChatReasoning.js";
|
|
22
25
|
|
package/dist/lab.css
CHANGED
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
.x1ghz6dp:not(#\#){margin:0}
|
|
53
53
|
.x10h29id:not(#\#){margin:var(--spacing-0-5)}
|
|
54
54
|
.x1717udv:not(#\#){padding:0}
|
|
55
|
+
.x12nba7r:not(#\#){padding:var(--spacing-0-5)}
|
|
55
56
|
.x103w4nt:not(#\#):not(#\#){border-color:color-mix(in srgb,var(--color-border-blue) 48%,var(--color-border))}
|
|
56
57
|
.x14n1b0s:not(#\#):not(#\#){border-color:color-mix(in srgb,var(--color-border-cyan) 48%,var(--color-border))}
|
|
57
58
|
.x1uk4s2l:not(#\#):not(#\#){border-color:color-mix(in srgb,var(--color-border-green) 48%,var(--color-border))}
|
|
@@ -79,6 +80,7 @@
|
|
|
79
80
|
.xh6dtrn:not(#\#):not(#\#){border-radius:var(--radius-element)}
|
|
80
81
|
.xjspbzw:not(#\#):not(#\#){border-radius:var(--radius-full)}
|
|
81
82
|
.xx3sua9:not(#\#):not(#\#){border-radius:var(--radius-inner)}
|
|
83
|
+
.xng3xce:not(#\#):not(#\#){border-style:none}
|
|
82
84
|
.x1y0btm7:not(#\#):not(#\#){border-style:solid}
|
|
83
85
|
.xc342km:not(#\#):not(#\#){border-width:0}
|
|
84
86
|
.x1litavf:not(#\#):not(#\#){border-width:var(--border-width)}
|
|
@@ -326,11 +328,14 @@
|
|
|
326
328
|
.x19jd1h0:not(#\#):not(#\#):not(#\#){transform:rotate(180deg)}
|
|
327
329
|
.x1cb1t30:not(#\#):not(#\#):not(#\#){transform:translateY(-50%)}
|
|
328
330
|
.xkvfbh3:not(#\#):not(#\#):not(#\#){transition-duration:var(--duration-fast-min)}
|
|
331
|
+
.xuedmi6:not(#\#):not(#\#):not(#\#){transition-duration:var(--duration-fast)}
|
|
329
332
|
.x80gvsz:not(#\#):not(#\#):not(#\#){transition-duration:var(--duration-medium)}
|
|
330
333
|
.x15406qy:not(#\#):not(#\#):not(#\#){transition-property:background-color}
|
|
334
|
+
.xt3l3uh:not(#\#):not(#\#):not(#\#){transition-property:color}
|
|
331
335
|
.xxnu56j:not(#\#):not(#\#):not(#\#){transition-property:stroke-dashoffset}
|
|
332
336
|
.xlr8y92:not(#\#):not(#\#):not(#\#){transition-timing-function:var(--ease-standard)}
|
|
333
337
|
.x87ps6o:not(#\#):not(#\#):not(#\#){user-select:none}
|
|
338
|
+
.xxymvpz:not(#\#):not(#\#):not(#\#){vertical-align:middle}
|
|
334
339
|
.xlshs6z:not(#\#):not(#\#):not(#\#){visibility:hidden}
|
|
335
340
|
.xuxw1ft:not(#\#):not(#\#):not(#\#){white-space:nowrap}
|
|
336
341
|
.x1sdyfia:not(#\#):not(#\#):not(#\#){white-space:pre}
|
|
@@ -344,6 +349,7 @@
|
|
|
344
349
|
@media (prefers-reduced-motion: reduce){.xnh0sag.xnh0sag:not(#\#):not(#\#):not(#\#){animation-duration:3s}}
|
|
345
350
|
@media (prefers-reduced-motion: reduce){.xy02sl2.xy02sl2:not(#\#):not(#\#):not(#\#){animation-duration:4s}}
|
|
346
351
|
@media (hover: hover){.xe9uy6x.xe9uy6x:hover:not(#\#):not(#\#):not(#\#){background-color:var(--color-overlay-hover)}}
|
|
352
|
+
@media (hover: hover){.xkxie9p.xkxie9p:hover:not(#\#):not(#\#):not(#\#){color:var(--color-icon-primary)}}
|
|
347
353
|
.xw8gpjh:not(#\#):not(#\#):not(#\#):not(#\#){border-bottom-color:var(--color-border)}
|
|
348
354
|
.x1q0q8m5:not(#\#):not(#\#):not(#\#):not(#\#){border-bottom-style:solid}
|
|
349
355
|
.x1qhh985:not(#\#):not(#\#):not(#\#):not(#\#){border-bottom-width:0}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astryxdesign/lab",
|
|
3
|
-
"version": "0.1.4-canary.
|
|
3
|
+
"version": "0.1.4-canary.3b5f42e",
|
|
4
4
|
"description": "Experimental Astryx components — published to npm only under the @canary dist-tag for early testing; never released as a stable (latest) version.",
|
|
5
5
|
"author": "Meta Open Source",
|
|
6
6
|
"license": "MIT",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
47
47
|
"@stylexjs/stylex": ">=0.10.0",
|
|
48
|
-
"@astryxdesign/core": "0.1.4-canary.
|
|
48
|
+
"@astryxdesign/core": "0.1.4-canary.3b5f42e",
|
|
49
49
|
"react": ">=19.0.0",
|
|
50
50
|
"react-dom": ">=19.0.0"
|
|
51
51
|
},
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/** @type {import('../docs-types').ComponentDoc} */
|
|
4
|
+
|
|
5
|
+
export const docs = {
|
|
6
|
+
name: 'InfoTip',
|
|
7
|
+
displayName: 'Info Tip',
|
|
8
|
+
category: 'Feedback & Status',
|
|
9
|
+
keywords: ["info","tooltip","help","hint","affordance","icon","label","field","metric","definition"],
|
|
10
|
+
props: [
|
|
11
|
+
{
|
|
12
|
+
name: 'content',
|
|
13
|
+
type: 'ReactNode',
|
|
14
|
+
description: 'Content to display in the tooltip. Typically short, non-interactive text. Mirrors Tooltip’s content prop.',
|
|
15
|
+
required: true,
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
name: 'label',
|
|
19
|
+
type: 'string',
|
|
20
|
+
description: 'Accessible name for the trigger button.',
|
|
21
|
+
default: "'More information'",
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: 'size',
|
|
25
|
+
type: "'xsm' | 'sm' | 'md' | 'lg'",
|
|
26
|
+
description: 'Size of the info icon (12px, 16px, 20px, 24px). Maps 1:1 to Icon sizes.',
|
|
27
|
+
default: "'sm'",
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
usage: {
|
|
31
|
+
description:
|
|
32
|
+
'An inline info-icon help affordance: a small "i" button that reveals a tooltip on hover and keyboard focus. Use it next to labels, values, and metrics for permission notes, metric definitions, and field help. Its value over hand-composing Icon inside Tooltip is the pre-wired accessible trigger: a real button with an aria-label, Tab-reachable, tooltip on hover AND focus, and Escape dismissal.',
|
|
33
|
+
bestPractices: [
|
|
34
|
+
{ guidance: true, description: 'Keep tooltip content concise, plain, and non-interactive; use Popover or HoverCard for links and buttons.' },
|
|
35
|
+
{ guidance: true, description: 'Override label when "More information" is too generic, e.g. "About this metric".' },
|
|
36
|
+
{ guidance: false, description: 'Hand-compose Icon inside Tooltip for info affordances; the bare Icon is aria-hidden and unfocusable, so keyboard and screen-reader users never see it.' },
|
|
37
|
+
{ guidance: false, description: 'Use InfoTip for essential information users must see to complete a task; put that text inline instead.' },
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** @type {import('../docs-types').ComponentDoc} */
|
|
43
|
+
export const docsZh = {
|
|
44
|
+
name: 'InfoTip',
|
|
45
|
+
displayName: 'Info Tip',
|
|
46
|
+
props: [
|
|
47
|
+
{
|
|
48
|
+
name: 'content',
|
|
49
|
+
type: 'ReactNode',
|
|
50
|
+
description: '工具提示中显示的内容。通常为简短的非交互文本。与 Tooltip 的 content 属性一致。',
|
|
51
|
+
required: true,
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: 'label',
|
|
55
|
+
type: 'string',
|
|
56
|
+
description: '触发按钮的无障碍名称。',
|
|
57
|
+
default: "'More information'",
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: 'size',
|
|
61
|
+
type: "'xsm' | 'sm' | 'md' | 'lg'",
|
|
62
|
+
description: '信息图标的尺寸(12px、16px、20px、24px)。与 Icon 尺寸一一对应。',
|
|
63
|
+
default: "'sm'",
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
usage: {
|
|
67
|
+
description:
|
|
68
|
+
'An inline info-icon help affordance: a small "i" button that reveals a tooltip on hover and keyboard focus. Use it next to labels, values, and metrics for permission notes, metric definitions, and field help.',
|
|
69
|
+
bestPractices: [
|
|
70
|
+
{ guidance: true, description: 'Keep tooltip content concise, plain, and non-interactive; use Popover or HoverCard for links and buttons.' },
|
|
71
|
+
{ guidance: true, description: 'Override label when "More information" is too generic, e.g. "About this metric".' },
|
|
72
|
+
{ guidance: false, description: 'Hand-compose Icon inside Tooltip for info affordances; the bare Icon is aria-hidden and unfocusable, so keyboard and screen-reader users never see it.' },
|
|
73
|
+
{ guidance: false, description: 'Use InfoTip for essential information users must see to complete a task; put that text inline instead.' },
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** @type {import('../docs-types').TranslationDoc} */
|
|
79
|
+
export const docsDense = {
|
|
80
|
+
description:
|
|
81
|
+
'Inline info-icon help affordance: accessible "i" button trigger pre-wired into a Tooltip.',
|
|
82
|
+
usage: {
|
|
83
|
+
description:
|
|
84
|
+
'A small "i" button that reveals a tooltip on hover and keyboard focus. Use next to labels, values, and metrics for permission notes, metric definitions, and field help. Pre-wired accessible trigger: real button, aria-label, Tab-reachable, tooltip on hover AND focus, Escape dismissal.',
|
|
85
|
+
bestPractices: [
|
|
86
|
+
{ guidance: true, description: 'Keep tooltip content concise, plain, and non-interactive; use Popover or HoverCard for links and buttons.' },
|
|
87
|
+
{ guidance: true, description: 'Override label when "More information" is too generic, e.g. "About this metric".' },
|
|
88
|
+
{ guidance: false, description: 'Hand-compose Icon inside Tooltip for info affordances; the bare Icon is aria-hidden and unfocusable.' },
|
|
89
|
+
{ guidance: false, description: 'Use InfoTip for essential information users must see to complete a task; put that text inline instead.' },
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
propDescriptions: {
|
|
93
|
+
content: 'Tooltip content. Short, non-interactive text. Mirrors Tooltip content prop.',
|
|
94
|
+
label: "Accessible name for the trigger button. Default 'More information'.",
|
|
95
|
+
size: 'Info icon size (12/16/20/24px). Maps 1:1 to Icon sizes.',
|
|
96
|
+
},
|
|
97
|
+
};
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file InfoTip.test.tsx
|
|
5
|
+
* @input Uses vitest, @testing-library/react, @testing-library/user-event, InfoTip component
|
|
6
|
+
* @output Unit tests for InfoTip trigger accessibility and tooltip behavior
|
|
7
|
+
* @position Testing; validates InfoTip.tsx implementation
|
|
8
|
+
*
|
|
9
|
+
* SYNC: When InfoTip.tsx changes, update tests to match new behavior
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {describe, it, expect, vi, beforeAll, afterAll} from 'vitest';
|
|
13
|
+
import {render, screen, fireEvent, waitFor, act} from '@testing-library/react';
|
|
14
|
+
import userEvent from '@testing-library/user-event';
|
|
15
|
+
import {InfoTip} from './InfoTip';
|
|
16
|
+
|
|
17
|
+
// Store original matches to restore later
|
|
18
|
+
const originalMatches = HTMLElement.prototype.matches;
|
|
19
|
+
|
|
20
|
+
// Track popover open state per element
|
|
21
|
+
const popoverOpenState = new WeakMap<HTMLElement, boolean>();
|
|
22
|
+
|
|
23
|
+
// Mock Popover API for jsdom, and :focus-visible for keyboard-focus tests
|
|
24
|
+
// (same harness as core Tooltip.test.tsx, plus :focus-visible)
|
|
25
|
+
beforeAll(() => {
|
|
26
|
+
HTMLElement.prototype.showPopover = vi.fn(function (this: HTMLElement) {
|
|
27
|
+
popoverOpenState.set(this, true);
|
|
28
|
+
});
|
|
29
|
+
HTMLElement.prototype.hidePopover = vi.fn(function (this: HTMLElement) {
|
|
30
|
+
popoverOpenState.set(this, false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Intercept :popover-open and :focus-visible, delegate everything else
|
|
34
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
35
|
+
(HTMLElement.prototype as any).matches = function (
|
|
36
|
+
selector: string,
|
|
37
|
+
): boolean {
|
|
38
|
+
if (selector === ':popover-open') {
|
|
39
|
+
return popoverOpenState.get(this) ?? false;
|
|
40
|
+
}
|
|
41
|
+
if (selector === ':focus-visible') {
|
|
42
|
+
// Treat any focused element as keyboard-focused in tests
|
|
43
|
+
return this === document.activeElement;
|
|
44
|
+
}
|
|
45
|
+
return originalMatches.call(this, selector);
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
afterAll(() => {
|
|
50
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
51
|
+
(HTMLElement.prototype as any).matches = originalMatches;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/** Focus the trigger the way a keyboard user would land on it. */
|
|
55
|
+
function focusTrigger(trigger: HTMLElement) {
|
|
56
|
+
act(() => {
|
|
57
|
+
trigger.focus();
|
|
58
|
+
});
|
|
59
|
+
fireEvent.focusIn(trigger);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('InfoTip', () => {
|
|
63
|
+
it('renders a button trigger with the default accessible name', () => {
|
|
64
|
+
render(<InfoTip content="Helpful context" />);
|
|
65
|
+
expect(
|
|
66
|
+
screen.getByRole('button', {name: 'More information'}),
|
|
67
|
+
).toBeInTheDocument();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('uses a custom label as the accessible name', () => {
|
|
71
|
+
render(
|
|
72
|
+
<InfoTip content="30-day rolling average." label="About this metric" />,
|
|
73
|
+
);
|
|
74
|
+
const trigger = screen.getByRole('button', {name: 'About this metric'});
|
|
75
|
+
expect(trigger).toHaveAttribute('aria-label', 'About this metric');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('links the trigger to the tooltip layer via aria-describedby', () => {
|
|
79
|
+
render(<InfoTip content="Helpful context" />);
|
|
80
|
+
const layer = screen.getByRole('tooltip', {hidden: true});
|
|
81
|
+
expect(layer).toHaveTextContent('Helpful context');
|
|
82
|
+
const trigger = screen.getByRole('button', {name: 'More information'});
|
|
83
|
+
expect(trigger.getAttribute('aria-describedby')).toBe(layer.id);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('is reachable with Tab', async () => {
|
|
87
|
+
const user = userEvent.setup();
|
|
88
|
+
render(<InfoTip content="Helpful context" />);
|
|
89
|
+
const trigger = screen.getByRole('button', {name: 'More information'});
|
|
90
|
+
await user.tab();
|
|
91
|
+
expect(trigger).toHaveFocus();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('shows the tooltip on hover', async () => {
|
|
95
|
+
vi.mocked(HTMLElement.prototype.showPopover).mockClear();
|
|
96
|
+
render(<InfoTip content="Helpful context" />);
|
|
97
|
+
const trigger = screen.getByRole('button', {name: 'More information'});
|
|
98
|
+
|
|
99
|
+
fireEvent.mouseEnter(trigger);
|
|
100
|
+
|
|
101
|
+
await waitFor(() => {
|
|
102
|
+
expect(HTMLElement.prototype.showPopover).toHaveBeenCalled();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('shows the tooltip on keyboard focus', async () => {
|
|
107
|
+
vi.mocked(HTMLElement.prototype.showPopover).mockClear();
|
|
108
|
+
render(<InfoTip content="Helpful context" />);
|
|
109
|
+
const trigger = screen.getByRole('button', {name: 'More information'});
|
|
110
|
+
|
|
111
|
+
focusTrigger(trigger);
|
|
112
|
+
|
|
113
|
+
await waitFor(() => {
|
|
114
|
+
expect(HTMLElement.prototype.showPopover).toHaveBeenCalled();
|
|
115
|
+
});
|
|
116
|
+
const layer = screen.getByRole('tooltip', {hidden: true});
|
|
117
|
+
expect(popoverOpenState.get(layer)).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('closes the tooltip on Escape', async () => {
|
|
121
|
+
render(<InfoTip content="Helpful context" />);
|
|
122
|
+
const trigger = screen.getByRole('button', {name: 'More information'});
|
|
123
|
+
const layer = screen.getByRole('tooltip', {hidden: true});
|
|
124
|
+
|
|
125
|
+
focusTrigger(trigger);
|
|
126
|
+
await waitFor(() => {
|
|
127
|
+
expect(popoverOpenState.get(layer)).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
fireEvent.keyDown(trigger, {key: 'Escape'});
|
|
131
|
+
|
|
132
|
+
await waitFor(() => {
|
|
133
|
+
expect(popoverOpenState.get(layer)).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('re-opens after Escape once the trigger is left and re-hovered', async () => {
|
|
138
|
+
render(<InfoTip content="Helpful context" />);
|
|
139
|
+
const trigger = screen.getByRole('button', {name: 'More information'});
|
|
140
|
+
const layer = screen.getByRole('tooltip', {hidden: true});
|
|
141
|
+
|
|
142
|
+
focusTrigger(trigger);
|
|
143
|
+
await waitFor(() => {
|
|
144
|
+
expect(popoverOpenState.get(layer)).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
fireEvent.keyDown(trigger, {key: 'Escape'});
|
|
148
|
+
await waitFor(() => {
|
|
149
|
+
expect(popoverOpenState.get(layer)).toBe(false);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// Leave (resets dismissal), then re-enter
|
|
153
|
+
act(() => {
|
|
154
|
+
trigger.blur();
|
|
155
|
+
});
|
|
156
|
+
fireEvent.focusOut(trigger);
|
|
157
|
+
fireEvent.mouseLeave(trigger);
|
|
158
|
+
|
|
159
|
+
fireEvent.mouseEnter(trigger);
|
|
160
|
+
await waitFor(() => {
|
|
161
|
+
expect(popoverOpenState.get(layer)).toBe(true);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('renders ReactNode tooltip content', () => {
|
|
166
|
+
render(<InfoTip content={<span data-testid="rich-content">Rich</span>} />);
|
|
167
|
+
expect(screen.getByTestId('rich-content')).toBeInTheDocument();
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
'use client';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @file InfoTip.tsx
|
|
7
|
+
* @input Uses React, stylex, Tooltip + Icon from @astryxdesign/core, color/spacing/radius/duration/ease tokens
|
|
8
|
+
* @output Exports InfoTip component, InfoTipProps, InfoTipSize types
|
|
9
|
+
* @position Lab experiment (RFC facebook/astryx#3349); core implementation consumed by index.ts
|
|
10
|
+
*
|
|
11
|
+
* SYNC: When modified, update these files to stay in sync:
|
|
12
|
+
* - /packages/lab/src/InfoTip/InfoTip.doc.mjs (props table, features)
|
|
13
|
+
* - /packages/lab/src/InfoTip/InfoTip.test.tsx (tests for new/changed behavior)
|
|
14
|
+
* - /packages/lab/src/InfoTip/index.ts (exports if types change)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {useCallback, useRef, useState, type ReactNode} from 'react';
|
|
18
|
+
import * as stylex from '@stylexjs/stylex';
|
|
19
|
+
|
|
20
|
+
import {Icon, type IconSize} from '@astryxdesign/core/Icon';
|
|
21
|
+
import {Tooltip} from '@astryxdesign/core/Tooltip';
|
|
22
|
+
import {
|
|
23
|
+
colorVars,
|
|
24
|
+
durationVars,
|
|
25
|
+
easeVars,
|
|
26
|
+
radiusVars,
|
|
27
|
+
spacingVars,
|
|
28
|
+
} from '@astryxdesign/core/theme/tokens.stylex';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Size of the info icon. Maps 1:1 to Icon sizes
|
|
32
|
+
* (xsm: 12px, sm: 16px, md: 20px, lg: 24px).
|
|
33
|
+
*/
|
|
34
|
+
export type InfoTipSize = IconSize;
|
|
35
|
+
|
|
36
|
+
export interface InfoTipProps {
|
|
37
|
+
/**
|
|
38
|
+
* Content to display in the tooltip.
|
|
39
|
+
* Typically short, non-interactive text. Mirrors Tooltip's `content` prop.
|
|
40
|
+
*/
|
|
41
|
+
content: ReactNode;
|
|
42
|
+
/**
|
|
43
|
+
* Accessible name for the trigger button.
|
|
44
|
+
* @default 'More information'
|
|
45
|
+
*/
|
|
46
|
+
label?: string;
|
|
47
|
+
/**
|
|
48
|
+
* Size of the info icon.
|
|
49
|
+
* @default 'sm'
|
|
50
|
+
*/
|
|
51
|
+
size?: InfoTipSize;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const styles = stylex.create({
|
|
55
|
+
trigger: {
|
|
56
|
+
display: 'inline-flex',
|
|
57
|
+
alignItems: 'center',
|
|
58
|
+
justifyContent: 'center',
|
|
59
|
+
verticalAlign: 'middle',
|
|
60
|
+
padding: spacingVars['--spacing-0-5'],
|
|
61
|
+
margin: 0,
|
|
62
|
+
borderStyle: 'none',
|
|
63
|
+
backgroundColor: 'transparent',
|
|
64
|
+
borderRadius: radiusVars['--radius-full'],
|
|
65
|
+
cursor: 'pointer',
|
|
66
|
+
color: {
|
|
67
|
+
default: colorVars['--color-icon-secondary'],
|
|
68
|
+
':hover': {
|
|
69
|
+
default: null,
|
|
70
|
+
'@media (hover: hover)': colorVars['--color-icon-primary'],
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
outline: {
|
|
74
|
+
default: null,
|
|
75
|
+
':focus-visible': `2px solid ${colorVars['--color-accent']}`,
|
|
76
|
+
},
|
|
77
|
+
outlineOffset: {
|
|
78
|
+
default: '0',
|
|
79
|
+
':focus-visible': '2px',
|
|
80
|
+
},
|
|
81
|
+
transitionProperty: 'color',
|
|
82
|
+
transitionDuration: durationVars['--duration-fast'],
|
|
83
|
+
transitionTimingFunction: easeVars['--ease-standard'],
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* An inline info-icon help affordance: a small "i" button that reveals a
|
|
89
|
+
* tooltip on hover and keyboard focus. Use it next to labels, values, and
|
|
90
|
+
* metrics for permission notes, metric definitions, and field help.
|
|
91
|
+
*
|
|
92
|
+
* The value over hand-composing Icon inside Tooltip is the pre-wired
|
|
93
|
+
* accessible trigger: a real button with an aria-label, Tab-reachable,
|
|
94
|
+
* tooltip on hover AND focus, and Escape dismissal.
|
|
95
|
+
*
|
|
96
|
+
* Composed entirely from core primitives (Tooltip + Icon); the info icon
|
|
97
|
+
* resolves from the global icon registry, so themes can override it.
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* ```
|
|
101
|
+
* <InfoTip content="Editors can change this field; viewers cannot." />
|
|
102
|
+
* <InfoTip content="30-day rolling average." label="About this metric" />
|
|
103
|
+
* ```
|
|
104
|
+
*/
|
|
105
|
+
export function InfoTip({
|
|
106
|
+
content,
|
|
107
|
+
label = 'More information',
|
|
108
|
+
size = 'sm',
|
|
109
|
+
}: InfoTipProps): ReactNode {
|
|
110
|
+
// Escape dismissal: `true` force-hides the tooltip (Tooltip isOpen={false});
|
|
111
|
+
// `false` returns control to Tooltip's own hover/focus triggers (isOpen
|
|
112
|
+
// undefined). Reset when the pointer or focus leaves the trigger so the
|
|
113
|
+
// tooltip can re-open on the next hover/focus.
|
|
114
|
+
const [isDismissed, setIsDismissed] = useState(false);
|
|
115
|
+
const isOpenRef = useRef(false);
|
|
116
|
+
|
|
117
|
+
const handleOpenChange = useCallback((isOpen: boolean) => {
|
|
118
|
+
isOpenRef.current = isOpen;
|
|
119
|
+
}, []);
|
|
120
|
+
|
|
121
|
+
const handleKeyDown = useCallback(
|
|
122
|
+
(event: React.KeyboardEvent<HTMLButtonElement>) => {
|
|
123
|
+
if (event.key === 'Escape' && isOpenRef.current) {
|
|
124
|
+
// Only swallow Escape when it actually dismissed the tooltip, so an
|
|
125
|
+
// enclosing dialog still closes on the next press.
|
|
126
|
+
event.stopPropagation();
|
|
127
|
+
setIsDismissed(true);
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
[],
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const handleReset = useCallback(() => {
|
|
134
|
+
setIsDismissed(false);
|
|
135
|
+
}, []);
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<Tooltip
|
|
139
|
+
content={content}
|
|
140
|
+
isOpen={isDismissed ? false : undefined}
|
|
141
|
+
onOpenChange={handleOpenChange}>
|
|
142
|
+
<button
|
|
143
|
+
type="button"
|
|
144
|
+
aria-label={label}
|
|
145
|
+
onKeyDown={handleKeyDown}
|
|
146
|
+
onBlur={handleReset}
|
|
147
|
+
onMouseLeave={handleReset}
|
|
148
|
+
{...stylex.props(styles.trigger)}>
|
|
149
|
+
<Icon icon="info" size={size} />
|
|
150
|
+
</button>
|
|
151
|
+
</Tooltip>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
InfoTip.displayName = 'InfoTip';
|
package/src/index.ts
CHANGED
|
@@ -22,6 +22,9 @@ export {
|
|
|
22
22
|
type SyntaxToken,
|
|
23
23
|
} from '@astryxdesign/core/CodeBlock';
|
|
24
24
|
|
|
25
|
+
// InfoTip — accessible info-icon help affordance (RFC facebook/astryx#3349)
|
|
26
|
+
export {InfoTip, type InfoTipProps, type InfoTipSize} from './InfoTip';
|
|
27
|
+
|
|
25
28
|
// Chat — experimental reasoning display
|
|
26
29
|
export {
|
|
27
30
|
ChatReasoning,
|