@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.
Files changed (143) hide show
  1. package/README.md +2 -0
  2. package/dist/CodeEditor/CodeEditor.d.ts +4 -1
  3. package/dist/CodeEditor/CodeEditor.d.ts.map +1 -1
  4. package/dist/CodeEditor/CodeEditor.js +3 -0
  5. package/dist/InfoTip/InfoTip.d.ts +58 -0
  6. package/dist/InfoTip/InfoTip.d.ts.map +1 -0
  7. package/dist/InfoTip/InfoTip.js +91 -0
  8. package/dist/InfoTip/index.d.ts +6 -0
  9. package/dist/InfoTip/index.d.ts.map +1 -0
  10. package/dist/InfoTip/index.js +8 -0
  11. package/dist/Sankey/SankeyChart.d.ts.map +1 -1
  12. package/dist/Sankey/SankeyChart.js +3 -0
  13. package/dist/Schedule/ListView.d.ts.map +1 -1
  14. package/dist/Schedule/ListView.js +7 -4
  15. package/dist/Schedule/MonthlyView.js +3 -2
  16. package/dist/Schedule/Schedule.d.ts +3 -1
  17. package/dist/Schedule/Schedule.d.ts.map +1 -1
  18. package/dist/Schedule/Schedule.js +9 -4
  19. package/dist/Schedule/TimeGridView.js +3 -2
  20. package/dist/Schedule/context.d.ts +2 -0
  21. package/dist/Schedule/context.d.ts.map +1 -1
  22. package/dist/index.d.ts +1 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +5 -2
  25. package/dist/lab.css +6 -15
  26. package/package.json +2 -2
  27. package/src/CodeEditor/CodeEditor.test.tsx +21 -0
  28. package/src/CodeEditor/CodeEditor.tsx +5 -0
  29. package/src/InfoTip/InfoTip.doc.mjs +97 -0
  30. package/src/InfoTip/InfoTip.test.tsx +169 -0
  31. package/src/InfoTip/InfoTip.tsx +155 -0
  32. package/src/InfoTip/index.ts +10 -0
  33. package/src/Sankey/SankeyChart.test.tsx +77 -0
  34. package/src/Sankey/SankeyChart.tsx +3 -0
  35. package/src/Schedule/ListView.tsx +5 -2
  36. package/src/Schedule/MonthlyView.tsx +2 -2
  37. package/src/Schedule/Schedule.test.tsx +39 -0
  38. package/src/Schedule/Schedule.tsx +8 -0
  39. package/src/Schedule/TimeGridView.tsx +2 -2
  40. package/src/Schedule/context.tsx +2 -0
  41. package/src/index.ts +5 -46
  42. package/dist/ChartV2/Chart.d.ts +0 -32
  43. package/dist/ChartV2/Chart.d.ts.map +0 -1
  44. package/dist/ChartV2/Chart.js +0 -281
  45. package/dist/ChartV2/ChartAxis.d.ts +0 -36
  46. package/dist/ChartV2/ChartAxis.d.ts.map +0 -1
  47. package/dist/ChartV2/ChartAxis.js +0 -161
  48. package/dist/ChartV2/ChartGrid.d.ts +0 -20
  49. package/dist/ChartV2/ChartGrid.d.ts.map +0 -1
  50. package/dist/ChartV2/ChartGrid.js +0 -76
  51. package/dist/ChartV2/ChartLegend.d.ts +0 -14
  52. package/dist/ChartV2/ChartLegend.d.ts.map +0 -1
  53. package/dist/ChartV2/ChartLegend.js +0 -54
  54. package/dist/ChartV2/ChartSwatch.d.ts +0 -36
  55. package/dist/ChartV2/ChartSwatch.d.ts.map +0 -1
  56. package/dist/ChartV2/ChartSwatch.js +0 -82
  57. package/dist/ChartV2/ChartTooltip.d.ts +0 -46
  58. package/dist/ChartV2/ChartTooltip.d.ts.map +0 -1
  59. package/dist/ChartV2/ChartTooltip.js +0 -267
  60. package/dist/ChartV2/ChartV2Context.d.ts +0 -4
  61. package/dist/ChartV2/ChartV2Context.d.ts.map +0 -1
  62. package/dist/ChartV2/ChartV2Context.js +0 -12
  63. package/dist/ChartV2/index.d.ts +0 -13
  64. package/dist/ChartV2/index.d.ts.map +0 -1
  65. package/dist/ChartV2/index.js +0 -10
  66. package/dist/ChartV2/layout.d.ts +0 -25
  67. package/dist/ChartV2/layout.d.ts.map +0 -1
  68. package/dist/ChartV2/layout.js +0 -195
  69. package/dist/ChartV2/legend.d.ts +0 -18
  70. package/dist/ChartV2/legend.d.ts.map +0 -1
  71. package/dist/ChartV2/legend.js +0 -24
  72. package/dist/ChartV2/marks/area.d.ts +0 -19
  73. package/dist/ChartV2/marks/area.d.ts.map +0 -1
  74. package/dist/ChartV2/marks/area.js +0 -109
  75. package/dist/ChartV2/marks/band.d.ts +0 -9
  76. package/dist/ChartV2/marks/band.d.ts.map +0 -1
  77. package/dist/ChartV2/marks/band.js +0 -59
  78. package/dist/ChartV2/marks/bar.d.ts +0 -17
  79. package/dist/ChartV2/marks/bar.d.ts.map +0 -1
  80. package/dist/ChartV2/marks/bar.js +0 -120
  81. package/dist/ChartV2/marks/candlestick.d.ts +0 -15
  82. package/dist/ChartV2/marks/candlestick.d.ts.map +0 -1
  83. package/dist/ChartV2/marks/candlestick.js +0 -83
  84. package/dist/ChartV2/marks/dot.d.ts +0 -15
  85. package/dist/ChartV2/marks/dot.d.ts.map +0 -1
  86. package/dist/ChartV2/marks/dot.js +0 -65
  87. package/dist/ChartV2/marks/dotGL.d.ts +0 -16
  88. package/dist/ChartV2/marks/dotGL.d.ts.map +0 -1
  89. package/dist/ChartV2/marks/dotGL.js +0 -178
  90. package/dist/ChartV2/marks/dotGLInteractive.d.ts +0 -30
  91. package/dist/ChartV2/marks/dotGLInteractive.d.ts.map +0 -1
  92. package/dist/ChartV2/marks/dotGLInteractive.js +0 -464
  93. package/dist/ChartV2/marks/errorBar.d.ts +0 -27
  94. package/dist/ChartV2/marks/errorBar.d.ts.map +0 -1
  95. package/dist/ChartV2/marks/errorBar.js +0 -87
  96. package/dist/ChartV2/marks/heatmapGL.d.ts +0 -23
  97. package/dist/ChartV2/marks/heatmapGL.d.ts.map +0 -1
  98. package/dist/ChartV2/marks/heatmapGL.js +0 -242
  99. package/dist/ChartV2/marks/index.d.ts +0 -13
  100. package/dist/ChartV2/marks/index.d.ts.map +0 -1
  101. package/dist/ChartV2/marks/index.js +0 -14
  102. package/dist/ChartV2/marks/line.d.ts +0 -18
  103. package/dist/ChartV2/marks/line.d.ts.map +0 -1
  104. package/dist/ChartV2/marks/line.js +0 -76
  105. package/dist/ChartV2/marks/referenceLine.d.ts +0 -35
  106. package/dist/ChartV2/marks/referenceLine.d.ts.map +0 -1
  107. package/dist/ChartV2/marks/referenceLine.js +0 -186
  108. package/dist/ChartV2/marks/streamGL.d.ts +0 -36
  109. package/dist/ChartV2/marks/streamGL.d.ts.map +0 -1
  110. package/dist/ChartV2/marks/streamGL.js +0 -204
  111. package/dist/ChartV2/tooltip.d.ts +0 -38
  112. package/dist/ChartV2/tooltip.d.ts.map +0 -1
  113. package/dist/ChartV2/tooltip.js +0 -54
  114. package/dist/ChartV2/types.d.ts +0 -115
  115. package/dist/ChartV2/types.d.ts.map +0 -1
  116. package/dist/ChartV2/types.js +0 -35
  117. package/src/ChartV2/Chart.tsx +0 -348
  118. package/src/ChartV2/ChartAxis.tsx +0 -209
  119. package/src/ChartV2/ChartGrid.tsx +0 -99
  120. package/src/ChartV2/ChartLegend.tsx +0 -69
  121. package/src/ChartV2/ChartSwatch.doc.mjs +0 -17
  122. package/src/ChartV2/ChartSwatch.tsx +0 -86
  123. package/src/ChartV2/ChartTooltip.tsx +0 -382
  124. package/src/ChartV2/ChartV2.doc.mjs +0 -16
  125. package/src/ChartV2/ChartV2Context.ts +0 -15
  126. package/src/ChartV2/index.ts +0 -62
  127. package/src/ChartV2/layout.ts +0 -223
  128. package/src/ChartV2/legend.ts +0 -32
  129. package/src/ChartV2/marks/area.tsx +0 -122
  130. package/src/ChartV2/marks/band.tsx +0 -70
  131. package/src/ChartV2/marks/bar.tsx +0 -159
  132. package/src/ChartV2/marks/candlestick.tsx +0 -106
  133. package/src/ChartV2/marks/dot.tsx +0 -64
  134. package/src/ChartV2/marks/dotGL.tsx +0 -202
  135. package/src/ChartV2/marks/dotGLInteractive.tsx +0 -546
  136. package/src/ChartV2/marks/errorBar.tsx +0 -89
  137. package/src/ChartV2/marks/heatmapGL.tsx +0 -299
  138. package/src/ChartV2/marks/index.ts +0 -14
  139. package/src/ChartV2/marks/line.tsx +0 -94
  140. package/src/ChartV2/marks/referenceLine.tsx +0 -224
  141. package/src/ChartV2/marks/streamGL.tsx +0 -251
  142. package/src/ChartV2/tooltip.ts +0 -70
  143. 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,10 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file InfoTip component barrel export
7
+ */
8
+
9
+ export {InfoTip} from './InfoTip';
10
+ export type {InfoTipProps, InfoTipSize} from './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={4}
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={4}
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) =>