@astryxdesign/core 0.1.0 → 0.1.1-canary.080d887
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/CHANGELOG.md +66 -0
- package/README.md +119 -1
- package/dist/AvatarGroup/AvatarGroupOverflow.d.ts +1 -1
- package/dist/AvatarGroup/AvatarGroupOverflow.d.ts.map +1 -1
- package/dist/AvatarGroup/AvatarGroupOverflow.js +4 -1
- package/dist/Banner/Banner.d.ts +7 -0
- package/dist/Banner/Banner.d.ts.map +1 -1
- package/dist/Banner/Banner.js +9 -2
- package/dist/Button/Button.d.ts +12 -2
- package/dist/Button/Button.d.ts.map +1 -1
- package/dist/Button/Button.js +51 -13
- package/dist/Chat/ChatLayoutScrollButton.d.ts.map +1 -1
- package/dist/Chat/ChatLayoutScrollButton.js +5 -1
- package/dist/ContextMenu/ContextMenu.js +2 -2
- package/dist/DropdownMenu/DropdownMenu.js +2 -2
- package/dist/DropdownMenu/{renderXDSDropdownItems.d.ts → renderDropdownItems.d.ts} +3 -3
- package/dist/DropdownMenu/renderDropdownItems.d.ts.map +1 -0
- package/dist/DropdownMenu/{renderXDSDropdownItems.js → renderDropdownItems.js} +2 -2
- package/dist/EmptyState/EmptyState.d.ts.map +1 -1
- package/dist/EmptyState/EmptyState.js +7 -1
- package/dist/HoverCard/HoverCard.d.ts +2 -2
- package/dist/HoverCard/HoverCard.d.ts.map +1 -1
- package/dist/HoverCard/HoverCard.js +18 -6
- package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
- package/dist/HoverCard/useHoverCard.js +6 -3
- package/dist/Kbd/Kbd.d.ts.map +1 -1
- package/dist/Kbd/Kbd.js +1 -1
- package/dist/Layer/useLayer.d.ts +13 -0
- package/dist/Layer/useLayer.d.ts.map +1 -1
- package/dist/Layer/useLayer.js +7 -2
- package/dist/Layout/Layout.d.ts +10 -1
- package/dist/Layout/Layout.d.ts.map +1 -1
- package/dist/Layout/Layout.js +5 -1
- package/dist/Markdown/Markdown.d.ts.map +1 -1
- package/dist/Markdown/Markdown.js +13 -3
- package/dist/MobileNav/MobileNav.d.ts.map +1 -1
- package/dist/MobileNav/MobileNav.js +13 -0
- package/dist/Outline/Outline.d.ts +3 -2
- package/dist/Outline/Outline.d.ts.map +1 -1
- package/dist/Outline/Outline.js +23 -4
- package/dist/Outline/useScrollSpy.d.ts +14 -1
- package/dist/Outline/useScrollSpy.d.ts.map +1 -1
- package/dist/Outline/useScrollSpy.js +161 -50
- package/dist/Pagination/Pagination.d.ts.map +1 -1
- package/dist/Pagination/Pagination.js +31 -27
- package/dist/Resizable/useResizable.d.ts.map +1 -1
- package/dist/Resizable/useResizable.js +1 -5
- package/dist/Selector/Selector.d.ts.map +1 -1
- package/dist/Selector/Selector.js +1 -1
- package/dist/Slider/Slider.js +2 -2
- package/dist/Table/BaseTable.d.ts.map +1 -1
- package/dist/Table/BaseTable.js +26 -8
- package/dist/Table/Table.d.ts.map +1 -1
- package/dist/Table/Table.js +30 -7
- package/dist/Table/index.d.ts +3 -1
- package/dist/Table/index.d.ts.map +1 -1
- package/dist/Table/index.js +1 -0
- package/dist/Table/plugins/stickyColumns/index.d.ts +3 -0
- package/dist/Table/plugins/stickyColumns/index.d.ts.map +1 -0
- package/dist/Table/plugins/stickyColumns/index.js +3 -0
- package/dist/Table/plugins/stickyColumns/useTableStickyColumns.d.ts +25 -0
- package/dist/Table/plugins/stickyColumns/useTableStickyColumns.d.ts.map +1 -0
- package/dist/Table/plugins/stickyColumns/useTableStickyColumns.js +376 -0
- package/dist/Table/types.d.ts +90 -5
- package/dist/Table/types.d.ts.map +1 -1
- package/dist/Table/useBaseTablePlugins.d.ts.map +1 -1
- package/dist/Table/useBaseTablePlugins.js +1 -1
- package/dist/Timestamp/Timestamp.d.ts +1 -1
- package/dist/Timestamp/Timestamp.d.ts.map +1 -1
- package/dist/Timestamp/Timestamp.js +28 -3
- package/dist/ToggleButton/ToggleButton.d.ts +10 -5
- package/dist/ToggleButton/ToggleButton.d.ts.map +1 -1
- package/dist/ToggleButton/ToggleButton.js +40 -17
- package/dist/astryx.css +20 -0
- package/dist/astryx.umd.js +147 -0
- package/dist/astryx.umd.js.map +7 -0
- package/dist/theme/Theme.js +1 -1
- package/dist/theme/defineTheme.d.ts +1 -1
- package/dist/theme/defineTheme.d.ts.map +1 -1
- package/dist/theme/defineTheme.js +1 -1
- package/dist/theme/index.d.ts +1 -1
- package/dist/theme/index.d.ts.map +1 -1
- package/dist/theme/index.js +1 -1
- package/dist/theme/syntax/defineSyntaxTheme.js +1 -1
- package/dist/theme/tokens.d.ts +1 -1
- package/dist/theme/tokens.js +4 -4
- package/dist/theme/useTheme.d.ts +2 -2
- package/dist/utils/dateParser.d.ts.map +1 -1
- package/dist/utils/dateParser.js +15 -2
- package/package.json +7 -3
- package/src/AvatarGroup/AvatarGroupOverflow.tsx +3 -0
- package/src/Banner/Banner.test.tsx +16 -7
- package/src/Banner/Banner.tsx +9 -2
- package/src/Button/Button.doc.mjs +6 -0
- package/src/Button/Button.test.tsx +105 -11
- package/src/Button/Button.tsx +73 -5
- package/src/Chat/ChatLayoutScrollButton.tsx +7 -1
- package/src/Collapsible/useCollapsible.doc.mjs +2 -2
- package/src/ContextMenu/ContextMenu.tsx +2 -2
- package/src/DateInput/DateInput.test.tsx +68 -20
- package/src/Divider/Divider.doc.mjs +1 -1
- package/src/DropdownMenu/DropdownMenu.tsx +2 -2
- package/src/DropdownMenu/{renderXDSDropdownItems.tsx → renderDropdownItems.tsx} +2 -2
- package/src/EmptyState/EmptyState.test.tsx +4 -2
- package/src/EmptyState/EmptyState.tsx +6 -2
- package/src/FormLayout/FormLayout.doc.mjs +3 -3
- package/src/HoverCard/HoverCard.doc.mjs +3 -0
- package/src/HoverCard/HoverCard.test.tsx +178 -2
- package/src/HoverCard/HoverCard.tsx +20 -16
- package/src/HoverCard/useHoverCard.tsx +12 -10
- package/src/Icon/Icon.doc.mjs +12 -4
- package/src/Item/Item.doc.mjs +2 -2
- package/src/Kbd/Kbd.tsx +2 -9
- package/src/Layer/useLayer.doc.mjs +7 -2
- package/src/Layer/useLayer.tsx +19 -2
- package/src/Layout/Layout.doc.mjs +2 -1
- package/src/Layout/Layout.tsx +15 -1
- package/src/Layout/__tests__/childrenAsContent.test.tsx +59 -0
- package/src/Lightbox/Lightbox.doc.mjs +0 -2
- package/src/Link/Link.doc.mjs +3 -3
- package/src/Link/LinkProvider.doc.mjs +3 -3
- package/src/Markdown/Markdown.doc.mjs +6 -4
- package/src/Markdown/Markdown.test.tsx +17 -26
- package/src/Markdown/Markdown.tsx +16 -6
- package/src/MobileNav/MobileNav.doc.mjs +8 -8
- package/src/MobileNav/MobileNav.tsx +13 -0
- package/src/MobileNav/MobileNavReopen.test.tsx +118 -0
- package/src/Outline/Outline.doc.mjs +1 -1
- package/src/Outline/Outline.test.tsx +76 -38
- package/src/Outline/Outline.tsx +23 -4
- package/src/Outline/useScrollSpy.ts +196 -63
- package/src/Pagination/Pagination.test.tsx +137 -13
- package/src/Pagination/Pagination.tsx +33 -28
- package/src/Resizable/Resizable.doc.mjs +3 -3
- package/src/Resizable/useResizable.ts +1 -7
- package/src/Selector/Selector.doc.mjs +4 -0
- package/src/Selector/Selector.tsx +5 -6
- package/src/Skeleton/Skeleton.doc.mjs +11 -1
- package/src/Slider/Slider.doc.mjs +4 -0
- package/src/Slider/Slider.test.tsx +3 -16
- package/src/Slider/Slider.tsx +1 -1
- package/src/Table/BaseTable.tsx +50 -24
- package/src/Table/Table.doc.mjs +15 -3
- package/src/Table/Table.tsx +22 -1
- package/src/Table/index.ts +3 -0
- package/src/Table/plugins/stickyColumns/index.ts +4 -0
- package/src/Table/plugins/stickyColumns/useTableStickyColumns.test.tsx +163 -0
- package/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx +414 -0
- package/src/Table/types.ts +96 -4
- package/src/Table/useBaseTablePlugins.ts +1 -0
- package/src/Text/Heading.doc.mjs +13 -0
- package/src/Timestamp/Timestamp.test.tsx +44 -18
- package/src/Timestamp/Timestamp.tsx +30 -10
- package/src/ToggleButton/ToggleButton.doc.mjs +4 -4
- package/src/ToggleButton/ToggleButton.test.tsx +172 -9
- package/src/ToggleButton/ToggleButton.tsx +57 -31
- package/src/Toolbar/Toolbar.doc.mjs +1 -1
- package/src/hooks/useEntryAnimation.doc.mjs +3 -3
- package/src/hooks/useMediaQuery.doc.mjs +2 -2
- package/src/hooks/useStreamingText.doc.mjs +3 -3
- package/src/theme/Theme.doc.mjs +2 -2
- package/src/theme/Theme.tsx +1 -1
- package/src/theme/defineTheme.ts +1 -1
- package/src/theme/index.ts +1 -1
- package/src/theme/syntax/defineSyntaxTheme.ts +1 -1
- package/src/theme/tokens.ts +4 -4
- package/src/theme/useTheme.ts +2 -2
- package/src/utils/dateParser.test.ts +26 -0
- package/src/utils/dateParser.ts +16 -2
- package/dist/DropdownMenu/renderXDSDropdownItems.d.ts.map +0 -1
|
@@ -90,6 +90,12 @@ const styles = stylex.create({
|
|
|
90
90
|
whiteSpace: 'nowrap',
|
|
91
91
|
paddingInline: spacingVars['--spacing-2'],
|
|
92
92
|
},
|
|
93
|
+
// When a label is shown, the icon sits on the leading edge and the text on
|
|
94
|
+
// the trailing edge. Symmetric padding leaves the text cramped against the
|
|
95
|
+
// pill's rounded edge, so give the trailing side extra breathing room.
|
|
96
|
+
buttonWithLabel: {
|
|
97
|
+
paddingInlineEnd: spacingVars['--spacing-3'],
|
|
98
|
+
},
|
|
93
99
|
});
|
|
94
100
|
|
|
95
101
|
// =============================================================================
|
|
@@ -130,7 +136,7 @@ export function ChatLayoutScrollButton({
|
|
|
130
136
|
variant="ghost"
|
|
131
137
|
size="md"
|
|
132
138
|
onClick={onClick}
|
|
133
|
-
xstyle={styles.button}>
|
|
139
|
+
xstyle={[styles.button, label ? styles.buttonWithLabel : null]}>
|
|
134
140
|
{label ?? undefined}
|
|
135
141
|
</Button>
|
|
136
142
|
</div>
|
|
@@ -38,7 +38,7 @@ export const docs = {
|
|
|
38
38
|
usage: {
|
|
39
39
|
description: 'Reusable hook that encapsulates the collapsible state machine. Supports three modes: group-controlled (inside CollapsibleGroup), controlled (isOpen + onOpenChange), and uncontrolled (self-managed with defaultIsOpen). Used internally by Card and Section.',
|
|
40
40
|
bestPractices: [
|
|
41
|
-
{guidance: true, description: 'Use the hook directly when building custom collapsible components that need
|
|
41
|
+
{guidance: true, description: 'Use the hook directly when building custom collapsible components that need Astryx collapsible behavior without Collapsible wrapper.'},
|
|
42
42
|
{guidance: true, description: 'For accordion behavior, wrap items in CollapsibleGroup and pass unique value props.'},
|
|
43
43
|
{guidance: false, description: 'Implement your own open/close state when useCollapsible already provides it; the hook handles group coordination automatically.'},
|
|
44
44
|
],
|
|
@@ -64,7 +64,7 @@ export const docsDense = {
|
|
|
64
64
|
usage: {
|
|
65
65
|
description: 'Encapsulates collapsible state machine. 3 modes: group-controlled (inside CollapsibleGroup), controlled (isOpen + onOpenChange), uncontrolled (self-managed w/ defaultIsOpen). Used internally by Card + Section.',
|
|
66
66
|
bestPractices: [
|
|
67
|
-
{guidance: true, description: 'Use directly when building custom collapsible components needing
|
|
67
|
+
{guidance: true, description: 'Use directly when building custom collapsible components needing Astryx collapsible behavior w/o Collapsible wrapper.'},
|
|
68
68
|
{guidance: true, description: 'For accordion behavior, wrap items in CollapsibleGroup + pass unique value props.'},
|
|
69
69
|
{guidance: false, description: 'Implement your own open/close state when useCollapsible already provides it; hook handles group coordination automatically.'},
|
|
70
70
|
],
|
|
@@ -36,7 +36,7 @@ import React, {
|
|
|
36
36
|
import type {ReactNode} from 'react';
|
|
37
37
|
import * as stylex from '@stylexjs/stylex';
|
|
38
38
|
import {useLayer} from '../Layer/useLayer';
|
|
39
|
-
import {
|
|
39
|
+
import {renderDropdownItems} from '../DropdownMenu/renderDropdownItems';
|
|
40
40
|
import {
|
|
41
41
|
DropdownMenuContext,
|
|
42
42
|
type DropdownMenuContextValue,
|
|
@@ -278,7 +278,7 @@ export function ContextMenu({
|
|
|
278
278
|
);
|
|
279
279
|
|
|
280
280
|
const resolvedMenuContent =
|
|
281
|
-
props.items !== undefined ?
|
|
281
|
+
props.items !== undefined ? renderDropdownItems(items) : menuContent;
|
|
282
282
|
|
|
283
283
|
return (
|
|
284
284
|
<>
|
|
@@ -21,19 +21,13 @@ describe('DateInput', () => {
|
|
|
21
21
|
|
|
22
22
|
it('renders with placeholder', () => {
|
|
23
23
|
render(
|
|
24
|
-
<DateInput
|
|
25
|
-
label="Date"
|
|
26
|
-
onChange={() => {}}
|
|
27
|
-
placeholder="Pick a date"
|
|
28
|
-
/>,
|
|
24
|
+
<DateInput label="Date" onChange={() => {}} placeholder="Pick a date" />,
|
|
29
25
|
);
|
|
30
26
|
expect(screen.getByPlaceholderText('Pick a date')).toBeInTheDocument();
|
|
31
27
|
});
|
|
32
28
|
|
|
33
29
|
it('displays formatted date when value is provided', () => {
|
|
34
|
-
render(
|
|
35
|
-
<DateInput label="Date" value="2026-01-25" onChange={() => {}} />,
|
|
36
|
-
);
|
|
30
|
+
render(<DateInput label="Date" value="2026-01-25" onChange={() => {}} />);
|
|
37
31
|
expect(screen.getByDisplayValue('January 25, 2026')).toBeInTheDocument();
|
|
38
32
|
});
|
|
39
33
|
|
|
@@ -118,9 +112,7 @@ describe('DateInput', () => {
|
|
|
118
112
|
|
|
119
113
|
it('reverts to previous value on blur when input is invalid', async () => {
|
|
120
114
|
const onChange = vi.fn();
|
|
121
|
-
render(
|
|
122
|
-
<DateInput label="Date" value="2026-01-25" onChange={onChange} />,
|
|
123
|
-
);
|
|
115
|
+
render(<DateInput label="Date" value="2026-01-25" onChange={onChange} />);
|
|
124
116
|
|
|
125
117
|
const input = screen.getByRole('combobox');
|
|
126
118
|
fireEvent.change(input, {target: {value: 'not a date'}});
|
|
@@ -299,9 +291,7 @@ describe('DateInput', () => {
|
|
|
299
291
|
// --- P1: Tab order: calendar button first, then input ---
|
|
300
292
|
|
|
301
293
|
it('renders calendar button before input in DOM order', () => {
|
|
302
|
-
const {container} = render(
|
|
303
|
-
<DateInput label="Date" onChange={() => {}} />,
|
|
304
|
-
);
|
|
294
|
+
const {container} = render(<DateInput label="Date" onChange={() => {}} />);
|
|
305
295
|
const input = container.querySelector('input');
|
|
306
296
|
const button = container.querySelector('button');
|
|
307
297
|
// Calendar button should come before input in the DOM
|
|
@@ -389,9 +379,7 @@ describe('DateInput', () => {
|
|
|
389
379
|
|
|
390
380
|
it('calls onChange with undefined when input is cleared and blurred', () => {
|
|
391
381
|
const onChange = vi.fn();
|
|
392
|
-
render(
|
|
393
|
-
<DateInput label="Date" value="2026-01-25" onChange={onChange} />,
|
|
394
|
-
);
|
|
382
|
+
render(<DateInput label="Date" value="2026-01-25" onChange={onChange} />);
|
|
395
383
|
|
|
396
384
|
const input = screen.getByRole('combobox');
|
|
397
385
|
fireEvent.change(input, {target: {value: ''}});
|
|
@@ -476,9 +464,7 @@ describe('DateInput', () => {
|
|
|
476
464
|
});
|
|
477
465
|
|
|
478
466
|
it('does not show clear button when hasClear is false', () => {
|
|
479
|
-
render(
|
|
480
|
-
<DateInput label="Date" value="2026-01-15" onChange={() => {}} />,
|
|
481
|
-
);
|
|
467
|
+
render(<DateInput label="Date" value="2026-01-15" onChange={() => {}} />);
|
|
482
468
|
expect(
|
|
483
469
|
screen.queryByRole('button', {name: 'Clear Date'}),
|
|
484
470
|
).not.toBeInTheDocument();
|
|
@@ -514,6 +500,68 @@ describe('DateInput', () => {
|
|
|
514
500
|
});
|
|
515
501
|
});
|
|
516
502
|
|
|
503
|
+
// --- Regression: in-progress / leading-zero input must not crash ---
|
|
504
|
+
|
|
505
|
+
describe('incomplete typed input', () => {
|
|
506
|
+
it('does not crash or fire onChange when first digit typed is 0', () => {
|
|
507
|
+
const onChange = vi.fn();
|
|
508
|
+
render(<DateInput label="Date" onChange={onChange} />);
|
|
509
|
+
|
|
510
|
+
const input = screen.getByRole('combobox');
|
|
511
|
+
// Typing a leading "0" (e.g. starting "01" for January) must be treated
|
|
512
|
+
// as incomplete input, not coerced into an (invalid) date that crashes.
|
|
513
|
+
expect(() =>
|
|
514
|
+
fireEvent.change(input, {target: {value: '0'}}),
|
|
515
|
+
).not.toThrow();
|
|
516
|
+
|
|
517
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
518
|
+
expect(input).toHaveValue('0');
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it('does not crash or fire onChange when first digit typed is 1', () => {
|
|
522
|
+
const onChange = vi.fn();
|
|
523
|
+
render(<DateInput label="Date" onChange={onChange} />);
|
|
524
|
+
|
|
525
|
+
const input = screen.getByRole('combobox');
|
|
526
|
+
expect(() =>
|
|
527
|
+
fireEvent.change(input, {target: {value: '1'}}),
|
|
528
|
+
).not.toThrow();
|
|
529
|
+
|
|
530
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
531
|
+
expect(input).toHaveValue('1');
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it('does not crash while progressively typing a numeric date', () => {
|
|
535
|
+
const onChange = vi.fn();
|
|
536
|
+
render(<DateInput label="Date" onChange={onChange} />);
|
|
537
|
+
|
|
538
|
+
const input = screen.getByRole('combobox');
|
|
539
|
+
// Simulate keystroke-by-keystroke entry of "01/15/2026". The leading
|
|
540
|
+
// single-digit keystrokes must not crash (the original bug).
|
|
541
|
+
for (const partial of ['0', '01', '01/', '01/1', '01/15', '01/15/']) {
|
|
542
|
+
expect(() =>
|
|
543
|
+
fireEvent.change(input, {target: {value: partial}}),
|
|
544
|
+
).not.toThrow();
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Completing the date commits it without error.
|
|
548
|
+
expect(() =>
|
|
549
|
+
fireEvent.change(input, {target: {value: '01/15/2026'}}),
|
|
550
|
+
).not.toThrow();
|
|
551
|
+
expect(onChange).toHaveBeenCalledWith('2026-01-15');
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it('does not crash on blur after typing an incomplete value', () => {
|
|
555
|
+
const onChange = vi.fn();
|
|
556
|
+
render(<DateInput label="Date" onChange={onChange} />);
|
|
557
|
+
|
|
558
|
+
const input = screen.getByRole('combobox');
|
|
559
|
+
fireEvent.change(input, {target: {value: '0'}});
|
|
560
|
+
expect(() => fireEvent.blur(input)).not.toThrow();
|
|
561
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
562
|
+
});
|
|
563
|
+
});
|
|
564
|
+
|
|
517
565
|
describe('external value changes', () => {
|
|
518
566
|
it('clears pending input when value changes externally', () => {
|
|
519
567
|
const onChange = vi.fn();
|
|
@@ -108,7 +108,7 @@ export const docsZh = {
|
|
|
108
108
|
|
|
109
109
|
/** @type {import('../docs-types').TranslationDoc} */
|
|
110
110
|
export const docsDense = {
|
|
111
|
-
description: 'visual separator w/ optional label, using
|
|
111
|
+
description: 'visual separator w/ optional label, using Astryx design tokens',
|
|
112
112
|
usage: {
|
|
113
113
|
description: 'A visual separator that divides content into distinct sections. Use to create clear boundaries between groups of related content, or to demarcate interactive regions within a layout.',
|
|
114
114
|
bestPractices: [
|
|
@@ -37,7 +37,7 @@ import {Button, type ButtonProps} from '../Button';
|
|
|
37
37
|
import {Icon} from '../Icon';
|
|
38
38
|
import type {IconType} from '../Icon';
|
|
39
39
|
|
|
40
|
-
import {
|
|
40
|
+
import {renderDropdownItems} from './renderDropdownItems';
|
|
41
41
|
import {
|
|
42
42
|
DropdownMenuContext,
|
|
43
43
|
type DropdownMenuContextValue,
|
|
@@ -366,7 +366,7 @@ export function DropdownMenu({
|
|
|
366
366
|
|
|
367
367
|
// Resolve menu content: data-driven items become components
|
|
368
368
|
const menuContent =
|
|
369
|
-
props.items !== undefined ?
|
|
369
|
+
props.items !== undefined ? renderDropdownItems(items) : children;
|
|
370
370
|
|
|
371
371
|
return (
|
|
372
372
|
<>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* @file
|
|
4
|
+
* @file renderDropdownItems.tsx
|
|
5
5
|
* @output Converts data-driven menu items into DropdownMenuItem components
|
|
6
6
|
* @position Utility; used by DropdownMenu to unify data-driven and compound paths
|
|
7
7
|
*/
|
|
@@ -49,7 +49,7 @@ function getSectionKey(section: DropdownMenuSection, index: number): string {
|
|
|
49
49
|
* Converts data-driven items into DropdownMenuItem components,
|
|
50
50
|
* so both modes share the same rendering and keyboard navigation path.
|
|
51
51
|
*/
|
|
52
|
-
export function
|
|
52
|
+
export function renderDropdownItems(
|
|
53
53
|
items: DropdownMenuOption[],
|
|
54
54
|
): ReactNode {
|
|
55
55
|
const elements: ReactNode[] = [];
|
|
@@ -45,11 +45,13 @@ describe('EmptyState', () => {
|
|
|
45
45
|
/>,
|
|
46
46
|
);
|
|
47
47
|
expect(screen.getByText('Try adjusting your search.')).toBeInTheDocument();
|
|
48
|
+
// Description renders as <div> (never <p>) so block content composes safely.
|
|
49
|
+
expect(screen.getByText('Try adjusting your search.').tagName).toBe('DIV');
|
|
48
50
|
});
|
|
49
51
|
|
|
50
52
|
it('does not render description when not provided', () => {
|
|
51
|
-
|
|
52
|
-
expect(
|
|
53
|
+
render(<EmptyState title="No results" />);
|
|
54
|
+
expect(screen.queryByText('Try adjusting your search.')).toBeNull();
|
|
53
55
|
});
|
|
54
56
|
|
|
55
57
|
it('renders with icon', () => {
|
|
@@ -174,13 +174,17 @@ export function EmptyState({
|
|
|
174
174
|
title,
|
|
175
175
|
)}
|
|
176
176
|
{description != null && (
|
|
177
|
-
<p
|
|
177
|
+
// Rendered as <div> (not <p>): description accepts ReactNode and a
|
|
178
|
+
// <p> cannot legally contain block children, which causes hydration
|
|
179
|
+
// mismatches. The StyleX style sets margin: 0, so appearance is
|
|
180
|
+
// unchanged.
|
|
181
|
+
<div
|
|
178
182
|
{...stylex.props(
|
|
179
183
|
styles.description,
|
|
180
184
|
isCompact && styles.descriptionCompact,
|
|
181
185
|
)}>
|
|
182
186
|
{description}
|
|
183
|
-
</
|
|
187
|
+
</div>
|
|
184
188
|
)}
|
|
185
189
|
</div>
|
|
186
190
|
{actions != null && (
|
|
@@ -20,7 +20,7 @@ export const docs = {
|
|
|
20
20
|
name: 'children',
|
|
21
21
|
type: 'ReactNode',
|
|
22
22
|
description:
|
|
23
|
-
'Form fields to arrange. Accepts
|
|
23
|
+
'Form fields to arrange. Accepts Astryx inputs (TextInput, Selector, etc.) and Field-wrapped custom controls.',
|
|
24
24
|
},
|
|
25
25
|
{
|
|
26
26
|
name: 'xstyle',
|
|
@@ -68,7 +68,7 @@ export const docsZh = {
|
|
|
68
68
|
name: 'children',
|
|
69
69
|
type: 'ReactNode',
|
|
70
70
|
description:
|
|
71
|
-
'要排列的表单字段。接受
|
|
71
|
+
'要排列的表单字段。接受 Astryx 输入组件(TextInput、Selector 等)和 Field 包装的自定义控件。',
|
|
72
72
|
},
|
|
73
73
|
{
|
|
74
74
|
name: 'xstyle',
|
|
@@ -122,7 +122,7 @@ export const docsDense = {
|
|
|
122
122
|
},
|
|
123
123
|
propDescriptions: {
|
|
124
124
|
direction: 'Field arrangement. Vertical stacks top-to-bottom, horizontal arranges left-to-right w/ equal flex-grow, horizontal-labels uses CSS Grid w/ labels left of inputs (collapses <=480px).',
|
|
125
|
-
children: 'Form fields to arrange. Accepts
|
|
125
|
+
children: 'Form fields to arrange. Accepts Astryx inputs + Field-wrapped custom controls.',
|
|
126
126
|
xstyle: 'StyleX styles for layout customization. Must be stylex.create() value.',
|
|
127
127
|
},
|
|
128
128
|
};
|
|
@@ -108,6 +108,7 @@ export const docs = {
|
|
|
108
108
|
{ guidance: false, description: 'Place critical actions or required information inside a hover card; users may miss content that only appears on hover.' },
|
|
109
109
|
{ guidance: false, description: 'Use a hover card when a simple Tooltip or Popover would suffice.' },
|
|
110
110
|
{ guidance: false, description: 'Use a HoverCard for content the user must interact with; it disappears when the cursor leaves.' },
|
|
111
|
+
{ guidance: false, description: 'Nest a HoverCard whose content has block elements directly inside phrasing-only contexts such as a <p>, <label>, or heading. The card renders inline, so block content there is invalid HTML the browser reparents. Wrap the surrounding text in a block element (e.g. a <div>) instead.' },
|
|
111
112
|
],
|
|
112
113
|
anatomy: [
|
|
113
114
|
{name: 'Trigger', required: true, description: 'The element that opens the hover card on hover or focus: a button, link, or inline text.'},
|
|
@@ -216,6 +217,7 @@ export const docsZh = {
|
|
|
216
217
|
{ guidance: false, description: 'Place critical actions or required information inside a hover card; users may miss content that only appears on hover.' },
|
|
217
218
|
{ guidance: false, description: 'Use a hover card when a simple Tooltip or Popover would suffice.' },
|
|
218
219
|
{ guidance: false, description: 'Use a HoverCard for content the user must interact with; it disappears when the cursor leaves.' },
|
|
220
|
+
{ guidance: false, description: 'Nest a HoverCard whose content has block elements directly inside phrasing-only contexts such as a <p>, <label>, or heading. The card renders inline, so block content there is invalid HTML the browser reparents. Wrap the surrounding text in a block element (e.g. a <div>) instead.' },
|
|
219
221
|
],
|
|
220
222
|
},
|
|
221
223
|
};
|
|
@@ -233,6 +235,7 @@ export const docsDense = {
|
|
|
233
235
|
{ guidance: false, description: 'Place critical actions or required information inside a hover card; users may miss content that only appears on hover.' },
|
|
234
236
|
{ guidance: false, description: 'Use a hover card when a simple Tooltip or Popover would suffice.' },
|
|
235
237
|
{ guidance: false, description: 'Use a HoverCard for content the user must interact with; it disappears when the cursor leaves.' },
|
|
238
|
+
{ guidance: false, description: 'Nest a block-content HoverCard directly inside phrasing-only contexts (<p>, <label>, heading); it renders inline so block content is invalid HTML there. Wrap surrounding text in a block element instead.' },
|
|
236
239
|
],
|
|
237
240
|
},
|
|
238
241
|
components: [
|
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import {describe, it, expect, vi, beforeAll, afterAll} from 'vitest';
|
|
13
|
-
import {render, screen, fireEvent, waitFor} from '@testing-library/react';
|
|
13
|
+
import {render, screen, fireEvent, waitFor, act} from '@testing-library/react';
|
|
14
|
+
import {renderToString} from 'react-dom/server';
|
|
15
|
+
import {hydrateRoot} from 'react-dom/client';
|
|
16
|
+
import {StrictMode} from 'react';
|
|
14
17
|
import {HoverCard} from './HoverCard';
|
|
15
18
|
|
|
16
19
|
// Store original matches to restore later
|
|
@@ -73,6 +76,31 @@ describe('HoverCard', () => {
|
|
|
73
76
|
expect(paragraph?.querySelector('div')).toBeNull();
|
|
74
77
|
});
|
|
75
78
|
|
|
79
|
+
it('renders the floating layer with inline-safe markup (no block elements in a paragraph)', () => {
|
|
80
|
+
// HoverCard renders its floating layer inline (no portal), so the layer
|
|
81
|
+
// must be phrasing content to stay valid — and stay put on hydration —
|
|
82
|
+
// inside a <p>. Assert the layer popover element is a <span> and that the
|
|
83
|
+
// paragraph contains no <div> descendants at all.
|
|
84
|
+
const {container} = render(
|
|
85
|
+
<p>
|
|
86
|
+
Before{' '}
|
|
87
|
+
<HoverCard content={<span>Card content</span>}>
|
|
88
|
+
<a href="#trigger">Trigger</a>
|
|
89
|
+
</HoverCard>{' '}
|
|
90
|
+
after
|
|
91
|
+
</p>,
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const paragraph = container.querySelector('p');
|
|
95
|
+
const layer = screen.getByText('Card content').closest('[popover]');
|
|
96
|
+
|
|
97
|
+
expect(layer).not.toBeNull();
|
|
98
|
+
expect(layer?.tagName).toBe('SPAN');
|
|
99
|
+
// The whole layer subtree lives inside the paragraph with no block boxes.
|
|
100
|
+
expect(paragraph?.contains(layer as Node)).toBe(true);
|
|
101
|
+
expect(paragraph?.querySelector('div')).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
|
|
76
104
|
it('does not show content initially', () => {
|
|
77
105
|
render(
|
|
78
106
|
<HoverCard content={<span>Card content</span>}>
|
|
@@ -84,7 +112,7 @@ describe('HoverCard', () => {
|
|
|
84
112
|
expect(content).toBeInTheDocument();
|
|
85
113
|
});
|
|
86
114
|
|
|
87
|
-
it('applies the theme body font to the
|
|
115
|
+
it('applies the theme body font to the floating layer', () => {
|
|
88
116
|
render(
|
|
89
117
|
<HoverCard content={<span>Card content</span>}>
|
|
90
118
|
<button type="button">Trigger</button>
|
|
@@ -370,4 +398,152 @@ describe('HoverCard', () => {
|
|
|
370
398
|
expect(onOpenChange).not.toHaveBeenCalledWith(true);
|
|
371
399
|
});
|
|
372
400
|
});
|
|
401
|
+
|
|
402
|
+
describe('SSR / hydration', () => {
|
|
403
|
+
// Regression coverage for the hydration mismatch (#3107). The floating
|
|
404
|
+
// layer used to be portaled into document.body behind a
|
|
405
|
+
// `typeof document !== 'undefined'` gate: the server rendered nothing while
|
|
406
|
+
// the first client render emitted the portal, so the two trees disagreed.
|
|
407
|
+
//
|
|
408
|
+
// The layer is now rendered inline as inline-safe phrasing markup (a
|
|
409
|
+
// `<span popover>`), identically on the server and the client, so there is
|
|
410
|
+
// nothing for hydration to mismatch.
|
|
411
|
+
|
|
412
|
+
it('renders the floating layer in server markup (no document gate)', () => {
|
|
413
|
+
const html = renderToString(
|
|
414
|
+
<HoverCard content={<span>Card content</span>}>
|
|
415
|
+
<button type="button">Trigger</button>
|
|
416
|
+
</HoverCard>,
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
// The popover element is present in the server output...
|
|
420
|
+
expect(html).toContain('popover="manual"');
|
|
421
|
+
expect(html).toContain('Card content');
|
|
422
|
+
// ...and it is a <span> (inline-safe), not a <div>.
|
|
423
|
+
expect(html).toMatch(/<span[^>]*popover="manual"/);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it('keeps the floating layer inline-safe in server markup inside a paragraph', () => {
|
|
427
|
+
const html = renderToString(
|
|
428
|
+
<p>
|
|
429
|
+
Before{' '}
|
|
430
|
+
<HoverCard content={<span>Card content</span>}>
|
|
431
|
+
<a href="#trigger">Trigger</a>
|
|
432
|
+
</HoverCard>{' '}
|
|
433
|
+
after
|
|
434
|
+
</p>,
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
// No <div> is emitted inside the paragraph — the layer and its wrappers
|
|
438
|
+
// are all phrasing content, so the server string is valid <p> markup that
|
|
439
|
+
// the browser parser will not reparent (which would itself desync
|
|
440
|
+
// hydration).
|
|
441
|
+
expect(html).not.toContain('<div');
|
|
442
|
+
expect(html).toMatch(/<span[^>]*popover="manual"/);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it('server markup matches the first client render (no hydration mismatch)', async () => {
|
|
446
|
+
const tree = (
|
|
447
|
+
<StrictMode>
|
|
448
|
+
<p>
|
|
449
|
+
Glossary:{' '}
|
|
450
|
+
<HoverCard content={<span>Definition</span>}>
|
|
451
|
+
<a href="#term">term</a>
|
|
452
|
+
</HoverCard>
|
|
453
|
+
.
|
|
454
|
+
</p>
|
|
455
|
+
</StrictMode>
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
const serverHTML = renderToString(tree);
|
|
459
|
+
|
|
460
|
+
const container = document.createElement('div');
|
|
461
|
+
container.innerHTML = serverHTML;
|
|
462
|
+
document.body.appendChild(container);
|
|
463
|
+
|
|
464
|
+
// Capture any hydration diagnostics. React reports hydration mismatches
|
|
465
|
+
// both through console.error and through onRecoverableError.
|
|
466
|
+
const consoleErrorSpy = vi
|
|
467
|
+
.spyOn(console, 'error')
|
|
468
|
+
.mockImplementation(() => {});
|
|
469
|
+
const recoverableErrors: unknown[] = [];
|
|
470
|
+
|
|
471
|
+
let root: ReturnType<typeof hydrateRoot>;
|
|
472
|
+
await act(async () => {
|
|
473
|
+
root = hydrateRoot(container, tree, {
|
|
474
|
+
onRecoverableError: error => {
|
|
475
|
+
recoverableErrors.push(error);
|
|
476
|
+
},
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
const hydrationErrors = consoleErrorSpy.mock.calls.filter(call =>
|
|
481
|
+
String(call[0] ?? '')
|
|
482
|
+
.toLowerCase()
|
|
483
|
+
.includes('hydrat'),
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
expect(hydrationErrors).toEqual([]);
|
|
487
|
+
expect(recoverableErrors).toEqual([]);
|
|
488
|
+
|
|
489
|
+
await act(async () => {
|
|
490
|
+
root.unmount();
|
|
491
|
+
});
|
|
492
|
+
consoleErrorSpy.mockRestore();
|
|
493
|
+
container.remove();
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it('hydrates a default-open hover card without a mismatch', async () => {
|
|
497
|
+
vi.mocked(HTMLElement.prototype.showPopover).mockClear();
|
|
498
|
+
|
|
499
|
+
const tree = (
|
|
500
|
+
<HoverCard content={<span>Default open</span>} isDefaultOpen>
|
|
501
|
+
<button type="button">Trigger</button>
|
|
502
|
+
</HoverCard>
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
const serverHTML = renderToString(tree);
|
|
506
|
+
// isDefaultOpen must not leak the open state into SSR markup — the open
|
|
507
|
+
// call happens in an effect after hydration, so the server output is the
|
|
508
|
+
// same closed markup the first client render produces.
|
|
509
|
+
expect(serverHTML).toContain('popover="manual"');
|
|
510
|
+
|
|
511
|
+
const container = document.createElement('div');
|
|
512
|
+
container.innerHTML = serverHTML;
|
|
513
|
+
document.body.appendChild(container);
|
|
514
|
+
|
|
515
|
+
const consoleErrorSpy = vi
|
|
516
|
+
.spyOn(console, 'error')
|
|
517
|
+
.mockImplementation(() => {});
|
|
518
|
+
const recoverableErrors: unknown[] = [];
|
|
519
|
+
|
|
520
|
+
let root: ReturnType<typeof hydrateRoot>;
|
|
521
|
+
await act(async () => {
|
|
522
|
+
root = hydrateRoot(container, tree, {
|
|
523
|
+
onRecoverableError: error => {
|
|
524
|
+
recoverableErrors.push(error);
|
|
525
|
+
},
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
const hydrationErrors = consoleErrorSpy.mock.calls.filter(call =>
|
|
530
|
+
String(call[0] ?? '')
|
|
531
|
+
.toLowerCase()
|
|
532
|
+
.includes('hydrat'),
|
|
533
|
+
);
|
|
534
|
+
expect(hydrationErrors).toEqual([]);
|
|
535
|
+
expect(recoverableErrors).toEqual([]);
|
|
536
|
+
|
|
537
|
+
// The card opens after hydration via the mount effect.
|
|
538
|
+
await waitFor(() => {
|
|
539
|
+
expect(HTMLElement.prototype.showPopover).toHaveBeenCalled();
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
await act(async () => {
|
|
543
|
+
root.unmount();
|
|
544
|
+
});
|
|
545
|
+
consoleErrorSpy.mockRestore();
|
|
546
|
+
container.remove();
|
|
547
|
+
});
|
|
548
|
+
});
|
|
373
549
|
});
|
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* @file HoverCard.tsx
|
|
7
|
-
* @input Uses React,
|
|
7
|
+
* @input Uses React, useHoverCard hook
|
|
8
8
|
* @output Exports HoverCard component for hover/focus triggered layers
|
|
9
|
-
* @position Layer component; uses inline-safe trigger wrapper and
|
|
9
|
+
* @position Layer component; uses inline-safe trigger wrapper and renders the floating layer inline
|
|
10
10
|
*
|
|
11
11
|
* SYNC: When modified, update these files to stay in sync:
|
|
12
12
|
* - /packages/core/src/HoverCard/HoverCard.test.tsx
|
|
@@ -15,13 +15,7 @@
|
|
|
15
15
|
* - /packages/cli/templates/blocks/components/HoverCard/ (showcase blocks)
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import
|
|
19
|
-
useCallback,
|
|
20
|
-
useRef,
|
|
21
|
-
type ReactElement,
|
|
22
|
-
type ReactNode,
|
|
23
|
-
} from 'react';
|
|
24
|
-
import {createPortal} from 'react-dom';
|
|
18
|
+
import {useCallback, useRef, type ReactElement, type ReactNode} from 'react';
|
|
25
19
|
import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect';
|
|
26
20
|
import * as stylex from '@stylexjs/stylex';
|
|
27
21
|
import {useHoverCard, type HoverCardFocusTrigger} from './useHoverCard';
|
|
@@ -245,13 +239,23 @@ export function HoverCard({
|
|
|
245
239
|
};
|
|
246
240
|
}, [textOnly, hoverCard.ref, hoverCard.describedBy]);
|
|
247
241
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
242
|
+
// Render the floating layer inline, in the same place on the server and the
|
|
243
|
+
// client. The layer is a `popover` element opened via the Popover API, so the
|
|
244
|
+
// browser promotes it to the top layer when shown — that already escapes
|
|
245
|
+
// ancestor clipping, stacking, and transform containing-block traps, and CSS
|
|
246
|
+
// anchor positioning resolves the trigger reference regardless of where the
|
|
247
|
+
// element sits in the DOM, so no portal is needed to "escape" layout.
|
|
248
|
+
//
|
|
249
|
+
// The layer renders as inline-safe phrasing markup (a `<span>`, see
|
|
250
|
+
// useHoverCard), which stays put inside a `<p>` instead of being reparented
|
|
251
|
+
// by the HTML parser. That keeps the server markup and the first client
|
|
252
|
+
// render identical, so there is no hydration mismatch — and it preserves the
|
|
253
|
+
// inline-safety guarantee (no block elements injected into a paragraph).
|
|
254
|
+
const renderedHoverCard = hoverCard.renderHoverCard(content, {
|
|
255
|
+
xstyle,
|
|
256
|
+
className,
|
|
257
|
+
style,
|
|
258
|
+
});
|
|
255
259
|
|
|
256
260
|
// For text-only children: use inline span with ref on wrapper
|
|
257
261
|
if (textOnly) {
|
|
@@ -58,8 +58,12 @@ const styles = stylex.create({
|
|
|
58
58
|
marginInlineStart: spacingVars['--spacing-1'],
|
|
59
59
|
marginInlineEnd: spacingVars['--spacing-1'],
|
|
60
60
|
},
|
|
61
|
-
// Content wrapper for padding and mouse events
|
|
61
|
+
// Content wrapper for padding and mouse events.
|
|
62
|
+
// `display: block` keeps the wrapper a block box even though it renders as a
|
|
63
|
+
// `span` (the layer uses inline-safe phrasing markup so it is valid inside a
|
|
64
|
+
// paragraph and produces identical server/client markup).
|
|
62
65
|
content: {
|
|
66
|
+
display: 'block',
|
|
63
67
|
paddingBlockStart: spacingVars['--spacing-3'],
|
|
64
68
|
paddingBlockEnd: spacingVars['--spacing-3'],
|
|
65
69
|
paddingInlineStart: spacingVars['--spacing-3'],
|
|
@@ -234,9 +238,7 @@ function isFocusable(element: HTMLElement): boolean {
|
|
|
234
238
|
* {hoverCard.renderHoverCard(<ProfileCard user={user} />)}
|
|
235
239
|
* ```
|
|
236
240
|
*/
|
|
237
|
-
export function useHoverCard(
|
|
238
|
-
options: HoverCardOptions = {},
|
|
239
|
-
): HoverCardReturn {
|
|
241
|
+
export function useHoverCard(options: HoverCardOptions = {}): HoverCardReturn {
|
|
240
242
|
const {
|
|
241
243
|
placement = 'above',
|
|
242
244
|
alignment = 'center',
|
|
@@ -454,14 +456,14 @@ export function useHoverCard(
|
|
|
454
456
|
placement: renderPlacement,
|
|
455
457
|
alignment: props?.alignment ?? alignment,
|
|
456
458
|
xstyle: [popoverXstyle, layerAnimations[renderPlacement]],
|
|
459
|
+
// Render the layer as inline-safe phrasing markup so HoverCard stays
|
|
460
|
+
// valid (and hydration-stable) inside inline contexts like a `<p>`.
|
|
461
|
+
as: 'span' as const,
|
|
457
462
|
};
|
|
458
463
|
|
|
459
464
|
return layer.render(
|
|
460
|
-
<
|
|
461
|
-
{...mergeProps(
|
|
462
|
-
themeProps('hovercard'),
|
|
463
|
-
stylex.props(styles.content),
|
|
464
|
-
)}
|
|
465
|
+
<span
|
|
466
|
+
{...mergeProps(themeProps('hovercard'), stylex.props(styles.content))}
|
|
465
467
|
onMouseEnter={() => {
|
|
466
468
|
isHoveringContentRef.current = true;
|
|
467
469
|
clearTimeouts();
|
|
@@ -502,7 +504,7 @@ export function useHoverCard(
|
|
|
502
504
|
scheduleHide();
|
|
503
505
|
}}>
|
|
504
506
|
{children}
|
|
505
|
-
</
|
|
507
|
+
</span>,
|
|
506
508
|
renderProps,
|
|
507
509
|
);
|
|
508
510
|
},
|