@astryxdesign/lab 0.1.4-canary.39ffd21 → 0.1.4-canary.3d64ef2
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 +2 -0
- package/dist/CodeEditor/CodeEditor.d.ts +4 -1
- package/dist/CodeEditor/CodeEditor.d.ts.map +1 -1
- package/dist/CodeEditor/CodeEditor.js +3 -0
- 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/Sankey/SankeyChart.d.ts.map +1 -1
- package/dist/Sankey/SankeyChart.js +3 -0
- package/dist/Schedule/ListView.d.ts.map +1 -1
- package/dist/Schedule/ListView.js +7 -4
- package/dist/Schedule/MonthlyView.js +3 -2
- package/dist/Schedule/Schedule.d.ts +3 -1
- package/dist/Schedule/Schedule.d.ts.map +1 -1
- package/dist/Schedule/Schedule.js +9 -4
- package/dist/Schedule/TimeGridView.js +3 -2
- package/dist/Schedule/context.d.ts +2 -0
- package/dist/Schedule/context.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/lab.css +6 -15
- package/package.json +2 -2
- package/src/CodeEditor/CodeEditor.test.tsx +21 -0
- package/src/CodeEditor/CodeEditor.tsx +5 -0
- 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/Sankey/SankeyChart.test.tsx +77 -0
- package/src/Sankey/SankeyChart.tsx +3 -0
- package/src/Schedule/ListView.tsx +5 -2
- package/src/Schedule/MonthlyView.tsx +2 -2
- package/src/Schedule/Schedule.test.tsx +39 -0
- package/src/Schedule/Schedule.tsx +8 -0
- package/src/Schedule/TimeGridView.tsx +2 -2
- package/src/Schedule/context.tsx +2 -0
- package/src/index.ts +5 -46
- package/dist/ChartV2/Chart.d.ts +0 -32
- package/dist/ChartV2/Chart.d.ts.map +0 -1
- package/dist/ChartV2/Chart.js +0 -281
- package/dist/ChartV2/ChartAxis.d.ts +0 -36
- package/dist/ChartV2/ChartAxis.d.ts.map +0 -1
- package/dist/ChartV2/ChartAxis.js +0 -161
- package/dist/ChartV2/ChartGrid.d.ts +0 -20
- package/dist/ChartV2/ChartGrid.d.ts.map +0 -1
- package/dist/ChartV2/ChartGrid.js +0 -76
- package/dist/ChartV2/ChartLegend.d.ts +0 -14
- package/dist/ChartV2/ChartLegend.d.ts.map +0 -1
- package/dist/ChartV2/ChartLegend.js +0 -54
- package/dist/ChartV2/ChartSwatch.d.ts +0 -36
- package/dist/ChartV2/ChartSwatch.d.ts.map +0 -1
- package/dist/ChartV2/ChartSwatch.js +0 -82
- package/dist/ChartV2/ChartTooltip.d.ts +0 -46
- package/dist/ChartV2/ChartTooltip.d.ts.map +0 -1
- package/dist/ChartV2/ChartTooltip.js +0 -267
- package/dist/ChartV2/ChartV2Context.d.ts +0 -4
- package/dist/ChartV2/ChartV2Context.d.ts.map +0 -1
- package/dist/ChartV2/ChartV2Context.js +0 -12
- package/dist/ChartV2/index.d.ts +0 -13
- package/dist/ChartV2/index.d.ts.map +0 -1
- package/dist/ChartV2/index.js +0 -10
- package/dist/ChartV2/layout.d.ts +0 -25
- package/dist/ChartV2/layout.d.ts.map +0 -1
- package/dist/ChartV2/layout.js +0 -195
- package/dist/ChartV2/legend.d.ts +0 -18
- package/dist/ChartV2/legend.d.ts.map +0 -1
- package/dist/ChartV2/legend.js +0 -24
- package/dist/ChartV2/marks/area.d.ts +0 -19
- package/dist/ChartV2/marks/area.d.ts.map +0 -1
- package/dist/ChartV2/marks/area.js +0 -109
- package/dist/ChartV2/marks/band.d.ts +0 -9
- package/dist/ChartV2/marks/band.d.ts.map +0 -1
- package/dist/ChartV2/marks/band.js +0 -59
- package/dist/ChartV2/marks/bar.d.ts +0 -17
- package/dist/ChartV2/marks/bar.d.ts.map +0 -1
- package/dist/ChartV2/marks/bar.js +0 -120
- package/dist/ChartV2/marks/candlestick.d.ts +0 -15
- package/dist/ChartV2/marks/candlestick.d.ts.map +0 -1
- package/dist/ChartV2/marks/candlestick.js +0 -83
- package/dist/ChartV2/marks/dot.d.ts +0 -15
- package/dist/ChartV2/marks/dot.d.ts.map +0 -1
- package/dist/ChartV2/marks/dot.js +0 -65
- package/dist/ChartV2/marks/dotGL.d.ts +0 -16
- package/dist/ChartV2/marks/dotGL.d.ts.map +0 -1
- package/dist/ChartV2/marks/dotGL.js +0 -178
- package/dist/ChartV2/marks/dotGLInteractive.d.ts +0 -30
- package/dist/ChartV2/marks/dotGLInteractive.d.ts.map +0 -1
- package/dist/ChartV2/marks/dotGLInteractive.js +0 -464
- package/dist/ChartV2/marks/errorBar.d.ts +0 -27
- package/dist/ChartV2/marks/errorBar.d.ts.map +0 -1
- package/dist/ChartV2/marks/errorBar.js +0 -87
- package/dist/ChartV2/marks/heatmapGL.d.ts +0 -23
- package/dist/ChartV2/marks/heatmapGL.d.ts.map +0 -1
- package/dist/ChartV2/marks/heatmapGL.js +0 -242
- package/dist/ChartV2/marks/index.d.ts +0 -13
- package/dist/ChartV2/marks/index.d.ts.map +0 -1
- package/dist/ChartV2/marks/index.js +0 -14
- package/dist/ChartV2/marks/line.d.ts +0 -18
- package/dist/ChartV2/marks/line.d.ts.map +0 -1
- package/dist/ChartV2/marks/line.js +0 -76
- package/dist/ChartV2/marks/referenceLine.d.ts +0 -35
- package/dist/ChartV2/marks/referenceLine.d.ts.map +0 -1
- package/dist/ChartV2/marks/referenceLine.js +0 -186
- package/dist/ChartV2/marks/streamGL.d.ts +0 -36
- package/dist/ChartV2/marks/streamGL.d.ts.map +0 -1
- package/dist/ChartV2/marks/streamGL.js +0 -204
- package/dist/ChartV2/tooltip.d.ts +0 -38
- package/dist/ChartV2/tooltip.d.ts.map +0 -1
- package/dist/ChartV2/tooltip.js +0 -54
- package/dist/ChartV2/types.d.ts +0 -115
- package/dist/ChartV2/types.d.ts.map +0 -1
- package/dist/ChartV2/types.js +0 -35
- package/src/ChartV2/Chart.tsx +0 -348
- package/src/ChartV2/ChartAxis.tsx +0 -209
- package/src/ChartV2/ChartGrid.tsx +0 -99
- package/src/ChartV2/ChartLegend.tsx +0 -69
- package/src/ChartV2/ChartSwatch.doc.mjs +0 -17
- package/src/ChartV2/ChartSwatch.tsx +0 -86
- package/src/ChartV2/ChartTooltip.tsx +0 -382
- package/src/ChartV2/ChartV2.doc.mjs +0 -16
- package/src/ChartV2/ChartV2Context.ts +0 -15
- package/src/ChartV2/index.ts +0 -62
- package/src/ChartV2/layout.ts +0 -223
- package/src/ChartV2/legend.ts +0 -32
- package/src/ChartV2/marks/area.tsx +0 -122
- package/src/ChartV2/marks/band.tsx +0 -70
- package/src/ChartV2/marks/bar.tsx +0 -159
- package/src/ChartV2/marks/candlestick.tsx +0 -106
- package/src/ChartV2/marks/dot.tsx +0 -64
- package/src/ChartV2/marks/dotGL.tsx +0 -202
- package/src/ChartV2/marks/dotGLInteractive.tsx +0 -546
- package/src/ChartV2/marks/errorBar.tsx +0 -89
- package/src/ChartV2/marks/heatmapGL.tsx +0 -299
- package/src/ChartV2/marks/index.ts +0 -14
- package/src/ChartV2/marks/line.tsx +0 -94
- package/src/ChartV2/marks/referenceLine.tsx +0 -224
- package/src/ChartV2/marks/streamGL.tsx +0 -251
- package/src/ChartV2/tooltip.ts +0 -70
- package/src/ChartV2/types.ts +0 -129
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
import {describe, it, expect} from 'vitest';
|
|
4
|
+
import {render, screen} from '@testing-library/react';
|
|
5
|
+
import {CodeEditor} from './CodeEditor';
|
|
6
|
+
|
|
7
|
+
describe('CodeEditor', () => {
|
|
8
|
+
it('uses the label prop as the accessible name', () => {
|
|
9
|
+
render(
|
|
10
|
+
<CodeEditor
|
|
11
|
+
value=""
|
|
12
|
+
onChange={() => {}}
|
|
13
|
+
language="typescript"
|
|
14
|
+
label="Edit snippet"
|
|
15
|
+
/>,
|
|
16
|
+
);
|
|
17
|
+
expect(
|
|
18
|
+
screen.getByRole('textbox', {name: 'Edit snippet'}),
|
|
19
|
+
).toBeInTheDocument();
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -131,6 +131,8 @@ export interface CodeEditorProps extends Omit<
|
|
|
131
131
|
onChange: (value: string) => void;
|
|
132
132
|
/** Language for highlighting. @default "plaintext" */
|
|
133
133
|
language?: string;
|
|
134
|
+
/** Label for the code editor — used as the accessible name of the editable region. */
|
|
135
|
+
label: string;
|
|
134
136
|
/** Show line numbers. @default false */
|
|
135
137
|
hasLineNumbers?: boolean;
|
|
136
138
|
/** Read-only mode. @default false */
|
|
@@ -182,6 +184,7 @@ let editorInstanceCounter = 0;
|
|
|
182
184
|
* ```tsx
|
|
183
185
|
* const [code, setCode] = useState('');
|
|
184
186
|
* <CodeEditor
|
|
187
|
+
* label="Source code"
|
|
185
188
|
* value={code}
|
|
186
189
|
* onChange={setCode}
|
|
187
190
|
* language="typescript"
|
|
@@ -199,6 +202,7 @@ export function CodeEditor({
|
|
|
199
202
|
maxHeight,
|
|
200
203
|
size = 'md',
|
|
201
204
|
tokenizer: customTokenizer,
|
|
205
|
+
label,
|
|
202
206
|
xstyle,
|
|
203
207
|
className,
|
|
204
208
|
style,
|
|
@@ -447,6 +451,7 @@ export function CodeEditor({
|
|
|
447
451
|
contentEditable={isReadOnly ? false : ('plaintext-only' as any)}
|
|
448
452
|
role="textbox"
|
|
449
453
|
aria-multiline="true"
|
|
454
|
+
aria-label={label}
|
|
450
455
|
aria-readonly={isReadOnly}
|
|
451
456
|
spellCheck={false}
|
|
452
457
|
onInput={handleInput}
|
|
@@ -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';
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest';
|
|
4
|
+
import {render, screen, act} from '@testing-library/react';
|
|
5
|
+
import {SankeyChart} from './SankeyChart';
|
|
6
|
+
import {SankeyNode} from './SankeyNode';
|
|
7
|
+
|
|
8
|
+
const nodes = [
|
|
9
|
+
{id: 'a', label: 'A', value: 10},
|
|
10
|
+
{id: 'b', label: 'B', value: 10},
|
|
11
|
+
{id: 'c', label: 'C', value: 10},
|
|
12
|
+
];
|
|
13
|
+
const links = [
|
|
14
|
+
{source: 'a', target: 'b', value: 5},
|
|
15
|
+
{source: 'b', target: 'c', value: 5},
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
// Capture the ResizeObserver callback so tests can drive the reported width.
|
|
19
|
+
let resizeCallback: ResizeObserverCallback | undefined;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
resizeCallback = undefined;
|
|
23
|
+
vi.stubGlobal(
|
|
24
|
+
'ResizeObserver',
|
|
25
|
+
class {
|
|
26
|
+
constructor(cb: ResizeObserverCallback) {
|
|
27
|
+
resizeCallback = cb;
|
|
28
|
+
}
|
|
29
|
+
observe() {}
|
|
30
|
+
unobserve() {}
|
|
31
|
+
disconnect() {}
|
|
32
|
+
},
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
vi.unstubAllGlobals();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
function reportWidth(width: number) {
|
|
41
|
+
act(() => {
|
|
42
|
+
resizeCallback?.(
|
|
43
|
+
[{contentRect: {width}} as unknown as ResizeObserverEntry],
|
|
44
|
+
{} as ResizeObserver,
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('SankeyChart scroll container', () => {
|
|
50
|
+
it('is keyboard-focusable when the chart needs horizontal scrolling', () => {
|
|
51
|
+
render(
|
|
52
|
+
<SankeyChart nodes={nodes} links={links} minColumnWidth={400}>
|
|
53
|
+
<SankeyNode />
|
|
54
|
+
</SankeyChart>,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
// Container narrower than the minimum column width forces scrolling.
|
|
58
|
+
reportWidth(300);
|
|
59
|
+
|
|
60
|
+
const scrollRegion = screen.getByRole('group', {name: 'Sankey chart'});
|
|
61
|
+
expect(scrollRegion).toHaveAttribute('tabindex', '0');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('does not add a focusable scroll region when everything fits', () => {
|
|
65
|
+
render(
|
|
66
|
+
<SankeyChart nodes={nodes} links={links} minColumnWidth={50}>
|
|
67
|
+
<SankeyNode />
|
|
68
|
+
</SankeyChart>,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
reportWidth(1000);
|
|
72
|
+
|
|
73
|
+
expect(
|
|
74
|
+
screen.queryByRole('group', {name: 'Sankey chart'}),
|
|
75
|
+
).not.toBeInTheDocument();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -202,6 +202,9 @@ export function SankeyChart({
|
|
|
202
202
|
<div ref={containerRef} style={{width: '100%'}}>
|
|
203
203
|
{ctx && (
|
|
204
204
|
<div
|
|
205
|
+
role={needsScroll ? 'group' : undefined}
|
|
206
|
+
aria-label={needsScroll ? 'Sankey chart' : undefined}
|
|
207
|
+
tabIndex={needsScroll ? 0 : undefined}
|
|
205
208
|
style={
|
|
206
209
|
needsScroll ? {overflowX: 'auto', overflowY: 'hidden'} : undefined
|
|
207
210
|
}>
|
|
@@ -52,7 +52,7 @@ export interface ScheduleListViewOptions {
|
|
|
52
52
|
function ScheduleListView(
|
|
53
53
|
_props: ScheduleViewComponentProps<ScheduleListViewOptions>,
|
|
54
54
|
) {
|
|
55
|
-
const {events, timezoneID, range, isLoading} = useScheduleContext();
|
|
55
|
+
const {events, timezoneID, range, isLoading, headingLevel} = useScheduleContext();
|
|
56
56
|
const days = enumerateDates(range.startDate, range.endDate);
|
|
57
57
|
const currentTime = useCurrentTime();
|
|
58
58
|
const currentPlainDate = plainDateFromInstant(currentTime, timezoneID);
|
|
@@ -97,6 +97,7 @@ function ScheduleListView(
|
|
|
97
97
|
day={day}
|
|
98
98
|
isCurrentDay={isCurrentDay}
|
|
99
99
|
timezoneID={timezoneID}
|
|
100
|
+
headingLevel={headingLevel}
|
|
100
101
|
/>
|
|
101
102
|
<div {...stylex.props(styles.listEvents)}>
|
|
102
103
|
{renderListRows({
|
|
@@ -117,14 +118,16 @@ function ListDayHeading({
|
|
|
117
118
|
day,
|
|
118
119
|
isCurrentDay,
|
|
119
120
|
timezoneID,
|
|
121
|
+
headingLevel,
|
|
120
122
|
}: {
|
|
121
123
|
day: PlainDate;
|
|
122
124
|
isCurrentDay: boolean;
|
|
123
125
|
timezoneID: string;
|
|
126
|
+
headingLevel: 2 | 3 | 4 | 5 | 6;
|
|
124
127
|
}) {
|
|
125
128
|
return (
|
|
126
129
|
<Heading
|
|
127
|
-
level={
|
|
130
|
+
level={headingLevel}
|
|
128
131
|
color="secondary"
|
|
129
132
|
display="block"
|
|
130
133
|
aria-label={formatFullDate(day, timezoneID)}
|
|
@@ -57,7 +57,7 @@ export interface ScheduleMonthlyViewOptions {
|
|
|
57
57
|
function ScheduleMonthlyView(
|
|
58
58
|
_props: ScheduleViewComponentProps<ScheduleMonthlyViewOptions>,
|
|
59
59
|
) {
|
|
60
|
-
const {events, categories, date, focusDate, timezoneID, range, isLoading} =
|
|
60
|
+
const {events, categories, date, focusDate, timezoneID, range, isLoading, headingLevel} =
|
|
61
61
|
useScheduleContext();
|
|
62
62
|
const rangeDate = date.toPlainDate();
|
|
63
63
|
const highlightedDate = focusDate.toPlainDate();
|
|
@@ -86,7 +86,7 @@ function ScheduleMonthlyView(
|
|
|
86
86
|
aria-colindex={index + 1}
|
|
87
87
|
{...stylex.props(styles.weekdayLabel)}>
|
|
88
88
|
<Heading
|
|
89
|
-
level={
|
|
89
|
+
level={headingLevel}
|
|
90
90
|
color="secondary"
|
|
91
91
|
display="block"
|
|
92
92
|
xstyle={styles.weekdayHeading}>
|
|
@@ -99,6 +99,45 @@ describe('Schedule', () => {
|
|
|
99
99
|
expect(screen.queryByText('Outside range')).not.toBeInTheDocument();
|
|
100
100
|
});
|
|
101
101
|
|
|
102
|
+
it('renders monthly weekday headings at the configured headingLevel (default 3)', async () => {
|
|
103
|
+
render(
|
|
104
|
+
<Schedule
|
|
105
|
+
view={createScheduleMonthlyView()}
|
|
106
|
+
events={events}
|
|
107
|
+
categories={categories}
|
|
108
|
+
date={Date.UTC(2026, 4, 13)}
|
|
109
|
+
focusDate={Date.UTC(2026, 4, 13)}
|
|
110
|
+
timezoneID="UTC"
|
|
111
|
+
/>,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
await waitFor(() => {
|
|
115
|
+
expect(screen.getByText('Visible sync')).toBeInTheDocument();
|
|
116
|
+
});
|
|
117
|
+
expect(
|
|
118
|
+
screen.getAllByRole('heading', {level: 3}).length,
|
|
119
|
+
).toBeGreaterThan(0);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('renders list day headings at the configured headingLevel (default 3)', async () => {
|
|
123
|
+
render(
|
|
124
|
+
<Schedule
|
|
125
|
+
view={createScheduleListView({days: 7})}
|
|
126
|
+
events={events}
|
|
127
|
+
categories={categories}
|
|
128
|
+
date={Date.UTC(2026, 4, 13)}
|
|
129
|
+
timezoneID="UTC"
|
|
130
|
+
/>,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
await waitFor(() => {
|
|
134
|
+
expect(screen.getByText('Visible sync')).toBeInTheDocument();
|
|
135
|
+
});
|
|
136
|
+
expect(
|
|
137
|
+
screen.getAllByRole('heading', {level: 3}).length,
|
|
138
|
+
).toBeGreaterThan(0);
|
|
139
|
+
});
|
|
140
|
+
|
|
102
141
|
it('loads async events with Instant range boundaries', async () => {
|
|
103
142
|
const loader = vi.fn(
|
|
104
143
|
async (_start: Instant, _end: Instant) =>
|