@astryxdesign/core 0.1.0 → 0.1.1-canary.129bf0e

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 (155) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +68 -0
  3. package/dist/AvatarGroup/AvatarGroupOverflow.d.ts +1 -1
  4. package/dist/AvatarGroup/AvatarGroupOverflow.d.ts.map +1 -1
  5. package/dist/AvatarGroup/AvatarGroupOverflow.js +4 -1
  6. package/dist/Banner/Banner.d.ts +7 -0
  7. package/dist/Banner/Banner.d.ts.map +1 -1
  8. package/dist/Banner/Banner.js +9 -2
  9. package/dist/Button/Button.d.ts.map +1 -1
  10. package/dist/Button/Button.js +2 -0
  11. package/dist/Chat/ChatLayoutScrollButton.d.ts.map +1 -1
  12. package/dist/Chat/ChatLayoutScrollButton.js +5 -1
  13. package/dist/ContextMenu/ContextMenu.js +2 -2
  14. package/dist/DropdownMenu/DropdownMenu.js +2 -2
  15. package/dist/DropdownMenu/{renderXDSDropdownItems.d.ts → renderDropdownItems.d.ts} +3 -3
  16. package/dist/DropdownMenu/renderDropdownItems.d.ts.map +1 -0
  17. package/dist/DropdownMenu/{renderXDSDropdownItems.js → renderDropdownItems.js} +2 -2
  18. package/dist/EmptyState/EmptyState.d.ts.map +1 -1
  19. package/dist/EmptyState/EmptyState.js +7 -1
  20. package/dist/HoverCard/HoverCard.d.ts +2 -2
  21. package/dist/HoverCard/HoverCard.d.ts.map +1 -1
  22. package/dist/HoverCard/HoverCard.js +18 -6
  23. package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
  24. package/dist/HoverCard/useHoverCard.js +6 -3
  25. package/dist/Layer/useLayer.d.ts +13 -0
  26. package/dist/Layer/useLayer.d.ts.map +1 -1
  27. package/dist/Layer/useLayer.js +7 -2
  28. package/dist/Layout/Layout.d.ts +10 -1
  29. package/dist/Layout/Layout.d.ts.map +1 -1
  30. package/dist/Layout/Layout.js +5 -1
  31. package/dist/Markdown/Markdown.d.ts.map +1 -1
  32. package/dist/Markdown/Markdown.js +13 -3
  33. package/dist/MobileNav/MobileNav.d.ts.map +1 -1
  34. package/dist/MobileNav/MobileNav.js +13 -0
  35. package/dist/Outline/Outline.d.ts +3 -2
  36. package/dist/Outline/Outline.d.ts.map +1 -1
  37. package/dist/Outline/Outline.js +23 -4
  38. package/dist/Outline/useScrollSpy.d.ts +14 -1
  39. package/dist/Outline/useScrollSpy.d.ts.map +1 -1
  40. package/dist/Outline/useScrollSpy.js +161 -50
  41. package/dist/Pagination/Pagination.d.ts.map +1 -1
  42. package/dist/Pagination/Pagination.js +31 -27
  43. package/dist/Resizable/useResizable.d.ts.map +1 -1
  44. package/dist/Resizable/useResizable.js +1 -5
  45. package/dist/Selector/Selector.d.ts.map +1 -1
  46. package/dist/Selector/Selector.js +1 -1
  47. package/dist/Table/BaseTable.d.ts.map +1 -1
  48. package/dist/Table/BaseTable.js +26 -8
  49. package/dist/Table/Table.d.ts.map +1 -1
  50. package/dist/Table/Table.js +30 -7
  51. package/dist/Table/index.d.ts +3 -1
  52. package/dist/Table/index.d.ts.map +1 -1
  53. package/dist/Table/index.js +1 -0
  54. package/dist/Table/plugins/stickyColumns/index.d.ts +3 -0
  55. package/dist/Table/plugins/stickyColumns/index.d.ts.map +1 -0
  56. package/dist/Table/plugins/stickyColumns/index.js +3 -0
  57. package/dist/Table/plugins/stickyColumns/useTableStickyColumns.d.ts +25 -0
  58. package/dist/Table/plugins/stickyColumns/useTableStickyColumns.d.ts.map +1 -0
  59. package/dist/Table/plugins/stickyColumns/useTableStickyColumns.js +376 -0
  60. package/dist/Table/types.d.ts +90 -5
  61. package/dist/Table/types.d.ts.map +1 -1
  62. package/dist/Table/useBaseTablePlugins.d.ts.map +1 -1
  63. package/dist/Table/useBaseTablePlugins.js +1 -1
  64. package/dist/ToggleButton/ToggleButton.d.ts +10 -3
  65. package/dist/ToggleButton/ToggleButton.d.ts.map +1 -1
  66. package/dist/ToggleButton/ToggleButton.js +64 -18
  67. package/dist/astryx.css +11 -0
  68. package/dist/astryx.umd.js +147 -0
  69. package/dist/astryx.umd.js.map +7 -0
  70. package/dist/theme/Theme.js +1 -1
  71. package/dist/theme/defineTheme.d.ts +1 -1
  72. package/dist/theme/defineTheme.d.ts.map +1 -1
  73. package/dist/theme/defineTheme.js +1 -1
  74. package/dist/theme/index.d.ts +1 -1
  75. package/dist/theme/index.d.ts.map +1 -1
  76. package/dist/theme/index.js +1 -1
  77. package/dist/theme/syntax/defineSyntaxTheme.js +1 -1
  78. package/dist/theme/tokens.d.ts +1 -1
  79. package/dist/theme/tokens.js +4 -4
  80. package/dist/theme/useTheme.d.ts +2 -2
  81. package/dist/utils/dateParser.d.ts.map +1 -1
  82. package/dist/utils/dateParser.js +15 -2
  83. package/package.json +7 -3
  84. package/src/AvatarGroup/AvatarGroupOverflow.tsx +3 -0
  85. package/src/Banner/Banner.test.tsx +16 -7
  86. package/src/Banner/Banner.tsx +9 -2
  87. package/src/Button/Button.test.tsx +26 -11
  88. package/src/Button/Button.tsx +2 -0
  89. package/src/Chat/ChatLayoutScrollButton.tsx +7 -1
  90. package/src/Collapsible/useCollapsible.doc.mjs +2 -2
  91. package/src/ContextMenu/ContextMenu.tsx +2 -2
  92. package/src/DateInput/DateInput.test.tsx +68 -20
  93. package/src/Divider/Divider.doc.mjs +1 -1
  94. package/src/DropdownMenu/DropdownMenu.tsx +2 -2
  95. package/src/DropdownMenu/{renderXDSDropdownItems.tsx → renderDropdownItems.tsx} +2 -2
  96. package/src/EmptyState/EmptyState.test.tsx +4 -2
  97. package/src/EmptyState/EmptyState.tsx +6 -2
  98. package/src/FormLayout/FormLayout.doc.mjs +3 -3
  99. package/src/HoverCard/HoverCard.doc.mjs +3 -0
  100. package/src/HoverCard/HoverCard.test.tsx +178 -2
  101. package/src/HoverCard/HoverCard.tsx +20 -16
  102. package/src/HoverCard/useHoverCard.tsx +12 -10
  103. package/src/Icon/Icon.doc.mjs +4 -4
  104. package/src/Item/Item.doc.mjs +2 -2
  105. package/src/Layer/useLayer.doc.mjs +7 -2
  106. package/src/Layer/useLayer.tsx +19 -2
  107. package/src/Layout/Layout.doc.mjs +2 -1
  108. package/src/Layout/Layout.tsx +15 -1
  109. package/src/Layout/__tests__/childrenAsContent.test.tsx +59 -0
  110. package/src/Lightbox/Lightbox.doc.mjs +0 -2
  111. package/src/Link/Link.doc.mjs +3 -3
  112. package/src/Link/LinkProvider.doc.mjs +3 -3
  113. package/src/Markdown/Markdown.doc.mjs +6 -4
  114. package/src/Markdown/Markdown.test.tsx +17 -26
  115. package/src/Markdown/Markdown.tsx +16 -6
  116. package/src/MobileNav/MobileNav.doc.mjs +8 -8
  117. package/src/MobileNav/MobileNav.tsx +13 -0
  118. package/src/MobileNav/MobileNavReopen.test.tsx +118 -0
  119. package/src/Outline/Outline.doc.mjs +1 -1
  120. package/src/Outline/Outline.test.tsx +76 -38
  121. package/src/Outline/Outline.tsx +23 -4
  122. package/src/Outline/useScrollSpy.ts +196 -63
  123. package/src/Pagination/Pagination.test.tsx +137 -13
  124. package/src/Pagination/Pagination.tsx +33 -28
  125. package/src/Resizable/Resizable.doc.mjs +3 -3
  126. package/src/Resizable/useResizable.ts +1 -7
  127. package/src/Selector/Selector.doc.mjs +4 -0
  128. package/src/Selector/Selector.tsx +5 -6
  129. package/src/Skeleton/Skeleton.doc.mjs +11 -1
  130. package/src/Table/BaseTable.tsx +50 -24
  131. package/src/Table/Table.doc.mjs +3 -3
  132. package/src/Table/Table.tsx +22 -1
  133. package/src/Table/index.ts +3 -0
  134. package/src/Table/plugins/stickyColumns/index.ts +4 -0
  135. package/src/Table/plugins/stickyColumns/useTableStickyColumns.test.tsx +163 -0
  136. package/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx +414 -0
  137. package/src/Table/types.ts +96 -4
  138. package/src/Table/useBaseTablePlugins.ts +1 -0
  139. package/src/ToggleButton/ToggleButton.doc.mjs +2 -2
  140. package/src/ToggleButton/ToggleButton.test.tsx +148 -6
  141. package/src/ToggleButton/ToggleButton.tsx +83 -20
  142. package/src/Toolbar/Toolbar.doc.mjs +1 -1
  143. package/src/hooks/useEntryAnimation.doc.mjs +3 -3
  144. package/src/hooks/useMediaQuery.doc.mjs +2 -2
  145. package/src/hooks/useStreamingText.doc.mjs +3 -3
  146. package/src/theme/Theme.doc.mjs +2 -2
  147. package/src/theme/Theme.tsx +1 -1
  148. package/src/theme/defineTheme.ts +1 -1
  149. package/src/theme/index.ts +1 -1
  150. package/src/theme/syntax/defineSyntaxTheme.ts +1 -1
  151. package/src/theme/tokens.ts +4 -4
  152. package/src/theme/useTheme.ts +2 -2
  153. package/src/utils/dateParser.test.ts +26 -0
  154. package/src/utils/dateParser.ts +16 -2
  155. package/dist/DropdownMenu/renderXDSDropdownItems.d.ts.map +0 -1
@@ -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
- const {container} = render(<EmptyState title="No results" />);
52
- expect(container.querySelector('p')).not.toBeInTheDocument();
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
- </p>
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 XDS inputs (TextInput, Selector, etc.) and Field-wrapped custom controls.',
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
- '要排列的表单字段。接受 XDS 输入组件(TextInput、Selector 等)和 Field 包装的自定义控件。',
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 XDS inputs + Field-wrapped custom controls.',
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 portaled layer', () => {
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, ReactDOM createPortal, useHoverCard hook
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 portals floating layer
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 React, {
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
- const renderedHoverCard =
249
- typeof document !== 'undefined'
250
- ? createPortal(
251
- hoverCard.renderHoverCard(content, {xstyle, className, style}),
252
- document.body,
253
- )
254
- : null;
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
- <div
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
- </div>,
507
+ </span>,
506
508
  renderProps,
507
509
  );
508
510
  },
@@ -17,7 +17,7 @@ export const docs = {
17
17
  {
18
18
  name: 'color',
19
19
  type: "'primary' | 'secondary' | 'tertiary' | 'disabled' | 'accent' | 'success' | 'error' | 'warning' | 'inherit'",
20
- description: 'Color variant mapped to XDS icon color tokens.',
20
+ description: 'Color variant mapped to Astryx icon color tokens.',
21
21
  default: "'inherit'",
22
22
  },
23
23
  {
@@ -62,7 +62,7 @@ export const docsZh = {
62
62
  {
63
63
  name: 'color',
64
64
  type: "'primary' | 'secondary' | 'tertiary' | 'disabled' | 'accent' | 'success' | 'error' | 'warning' | 'inherit'",
65
- description: '映射到 XDS 图标颜色令牌的颜色变体。',
65
+ description: '映射到 Astryx 图标颜色令牌的颜色变体。',
66
66
  default: "'inherit'",
67
67
  },
68
68
  {
@@ -96,7 +96,7 @@ export const docsZh = {
96
96
  /** @type {import('../docs-types').TranslationDoc} */
97
97
  export const docsDense = {
98
98
  description:
99
- 'Renders icons w/ XDS design system colors + sizes. Supports direct SVG icon components + semantic icon names that adapt to active theme.',
99
+ 'Renders icons w/ Astryx design system colors + sizes. Supports direct SVG icon components + semantic icon names that adapt to active theme.',
100
100
  usage: {
101
101
  description: 'Icons are small visual symbols that represent actions, objects, or concepts. They improve scannability and reinforce meaning alongside text. Supports both direct SVG components and semantic icon names that adapt to the active theme.',
102
102
  bestPractices: [
@@ -113,7 +113,7 @@ export const docsDense = {
113
113
  },
114
114
  propDescriptions: {
115
115
  icon: 'Semantic icon name or SVG component. Valid names: close, chevronDown, chevronLeft, chevronRight, check, success, error, warning, info, calendar, clock, externalLink, menu, moreHorizontal, search, arrowUp, arrowDown, arrowsUpDown, funnel, eyeSlash, viewColumns, copy, checkDouble, wrench, stop, microphone. For others, pass an SVG component.',
116
- color: 'Color variant mapped to XDS icon color tokens.',
116
+ color: 'Color variant mapped to Astryx icon color tokens.',
117
117
  size: 'Icon size.',
118
118
  },
119
119
  };
@@ -51,7 +51,7 @@ export const docs = {
51
51
  ],
52
52
  usage: {
53
53
  description:
54
- 'A single, flexible item primitive that unifies the "start content + label + description + end content" pattern across XDS. Use it wherever you need a structured row: dropdown menus, selectors, contact lists, notifications, file browsers, and activity feeds.',
54
+ 'A single, flexible item primitive that unifies the "start content + label + description + end content" pattern across Astryx. Use it wherever you need a structured row: dropdown menus, selectors, contact lists, notifications, file browsers, and activity feeds.',
55
55
  bestPractices: [
56
56
  {guidance: true, description: 'Use named slots (startContent, label, description, endContent) for the common layout. These cover the 80% case.'},
57
57
  {guidance: true, description: 'Use density="compact" for menus and dense lists, "balanced" for standard rows, and "spacious" for roomier layouts.'},
@@ -104,7 +104,7 @@ export const docsZh = {
104
104
  ],
105
105
  usage: {
106
106
  description:
107
- '通用项目原语,统一 XDS 中 "起始内容 + 标签 + 描述 + 结束内容" 的布局模式。适用于下拉菜单、选择器、联系人列表、通知、文件浏览器和活动流等场景。',
107
+ '通用项目原语,统一 Astryx 中 "起始内容 + 标签 + 描述 + 结束内容" 的布局模式。适用于下拉菜单、选择器、联系人列表、通知、文件浏览器和活动流等场景。',
108
108
  bestPractices: [
109
109
  {guidance: true, description: '使用命名插槽(startContent、label、description、endContent)处理常见布局。'},
110
110
  {guidance: true, description: '菜单和密集列表使用 density="compact",标准行使用 "balanced",宽松布局使用 "spacious"。'},
@@ -78,7 +78,7 @@ export const docs = {
78
78
  name: 'render',
79
79
  type: '(children: ReactNode, props: ContextRenderProps | FixedRenderProps) => ReactNode',
80
80
  description:
81
- 'Render function for the popover element. Pass placement/alignment in context mode or x/y in fixed mode.',
81
+ 'Render function for the popover element. Pass placement/alignment in context mode or x/y in fixed mode. In context mode, pass `as: "span"` to render an inline-safe layer (e.g. inside a paragraph). The layer renders inline in the React tree — the Popover API promotes it to the top layer when shown, so it escapes ancestor clipping and stacking without a portal.',
82
82
  },
83
83
  ],
84
84
  usage: {
@@ -95,6 +95,11 @@ export const docs = {
95
95
  description:
96
96
  'Build on higher-level components like Popover, HoverCard, and Tooltip for common overlay patterns.',
97
97
  },
98
+ {
99
+ guidance: true,
100
+ description:
101
+ 'Rely on the Popover API top layer to escape ancestor clipping and stacking — render the layer inline (no portal) so it inherits the trigger\u2019s theme cascade and keeps a natural focus order. Use `as: "span"` when the layer must be valid inside inline contexts like a paragraph.',
102
+ },
98
103
  {
99
104
  guidance: false,
100
105
  description:
@@ -125,7 +130,7 @@ export const docsDense = {
125
130
  hide: 'hide layer.',
126
131
  isOpen: 'whether layer is open.',
127
132
  id: 'unique ARIA id.',
128
- render: 'renders popover element; pass placement/alignment or x/y.',
133
+ render: 'renders popover element; pass placement/alignment or x/y. Context mode accepts `as: "span"` for inline-safe layers. Renders inline; the Popover API top layer escapes clipping/stacking without a portal.',
129
134
  },
130
135
  usage: {
131
136
  description:
@@ -79,6 +79,19 @@ export interface ContextRenderProps {
79
79
  * Merged after StyleX and anchor positioning styles.
80
80
  */
81
81
  style?: React.CSSProperties;
82
+ /**
83
+ * HTML tag to render the popover container as.
84
+ *
85
+ * Defaults to `'div'`. Pass `'span'` when the layer must render inline-safe
86
+ * markup — e.g. a `HoverCard` wrapping inline text inside a `<p>`. A `<span>`
87
+ * is phrasing content, so it stays put in the DOM tree instead of being
88
+ * reparented out of a paragraph by the HTML parser, which keeps server and
89
+ * client markup identical. The Popover API and CSS anchor positioning work
90
+ * the same on either tag.
91
+ *
92
+ * @default 'div'
93
+ */
94
+ as?: 'div' | 'span';
82
95
  }
83
96
 
84
97
  /**
@@ -369,6 +382,7 @@ export function useLayer(
369
382
  xstyle,
370
383
  className: extraClassName,
371
384
  style: extraStyle,
385
+ as: Container = 'div',
372
386
  } = props || {};
373
387
 
374
388
  // CSS anchor positioning (dynamic, not in StyleX)
@@ -383,15 +397,18 @@ export function useLayer(
383
397
  ? `${extraClassName} ${stylexResult.className ?? ''}`
384
398
  : stylexResult.className;
385
399
 
400
+ // Render as the requested tag. A `span` keeps the layer phrasing content
401
+ // so it is valid (and stays put on hydration) inside inline contexts like
402
+ // a `<p>`; `div` remains the default for block layers.
386
403
  return (
387
- <div
404
+ <Container
388
405
  ref={popoverRefCallback}
389
406
  id={id}
390
407
  popover={lightDismiss ? 'auto' : 'manual'}
391
408
  className={combinedClassName}
392
409
  style={{...stylexResult.style, ...anchorStyle, ...extraStyle}}>
393
410
  {children}
394
- </div>
411
+ </Container>
395
412
  );
396
413
  },
397
414
  [anchorId, id, lightDismiss, popoverRefCallback],
@@ -29,7 +29,8 @@ export const docs = {
29
29
  {
30
30
  name: 'content',
31
31
  type: 'ReactNode',
32
- description: 'Main content area (center).',
32
+ description:
33
+ 'Main content area (center). Children passed to `<Layout>` render here too — `<Layout>{main}</Layout>` is shorthand for `<Layout content={main} />`.',
33
34
  slotElements: [
34
35
  {
35
36
  __element: 'LayoutContent',
@@ -157,6 +157,16 @@ export interface LayoutProps extends Omit<BaseProps, 'content'> {
157
157
  * Inline styles to apply to the root element.
158
158
  */
159
159
  style?: React.CSSProperties;
160
+
161
+ /**
162
+ * Children are a shorthand for the `content` slot:
163
+ * `<Layout>{main}</Layout>` is equivalent to `<Layout content={main} />`.
164
+ * The surrounding zones (`header`/`start`/`end`/`footer`) stay explicit
165
+ * props. If both `content` and `children` are provided, `content` wins.
166
+ * Accepting children keeps the natural `<Layout>…</Layout>` form from
167
+ * rendering a blank shell.
168
+ */
169
+ children?: ReactNode;
160
170
  }
161
171
 
162
172
  /**
@@ -222,6 +232,7 @@ function AreaProvider({
222
232
  * ```
223
233
  */
224
234
  export function Layout({
235
+ children,
225
236
  content,
226
237
  contentWidth,
227
238
  defaultHasDividers,
@@ -237,6 +248,9 @@ export function Layout({
237
248
  style,
238
249
  }: LayoutProps) {
239
250
  const isFill = height === 'fill';
251
+ // Children are a shorthand for the content slot; an explicit `content` prop
252
+ // wins when both are provided.
253
+ const resolvedContent = content ?? children;
240
254
 
241
255
  const dividerCtxValue = useMemo(
242
256
  () => (defaultHasDividers != null ? {defaultHasDividers} : null),
@@ -287,7 +301,7 @@ export function Layout({
287
301
  )}>
288
302
  <AreaProvider area="start">{start}</AreaProvider>
289
303
  <div {...stylex.props(...stackItem({size: 'fill'}))}>
290
- <AreaProvider area="content">{content}</AreaProvider>
304
+ <AreaProvider area="content">{resolvedContent}</AreaProvider>
291
305
  </div>
292
306
  <AreaProvider area="end">{end}</AreaProvider>
293
307
  </div>
@@ -0,0 +1,59 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file childrenAsContent.test.tsx
5
+ * @input Uses vitest, @testing-library/react
6
+ * @output Verifies Layout renders children as a shorthand for the content slot
7
+ * (so the natural `<Layout>…</Layout>` form never renders a blank shell), with
8
+ * an explicit `content` prop taking precedence.
9
+ */
10
+
11
+ import {describe, it, expect} from 'vitest';
12
+ import {render, screen} from '@testing-library/react';
13
+ import {Layout} from '../Layout';
14
+ import {LayoutContent} from '../LayoutContent';
15
+
16
+ describe('Layout children-as-content', () => {
17
+ it('renders nested children in the content slot', () => {
18
+ render(
19
+ <Layout>
20
+ <LayoutContent>
21
+ <span data-testid="body">Body</span>
22
+ </LayoutContent>
23
+ </Layout>,
24
+ );
25
+ expect(screen.getByTestId('body')).toBeInTheDocument();
26
+ });
27
+
28
+ it('renders bare children (no LayoutContent wrapper) too', () => {
29
+ render(
30
+ <Layout>
31
+ <span data-testid="bare">Bare</span>
32
+ </Layout>,
33
+ );
34
+ expect(screen.getByTestId('bare')).toBeInTheDocument();
35
+ });
36
+
37
+ it('lets an explicit content prop win over children', () => {
38
+ render(
39
+ <Layout content={<span data-testid="slot">Slot</span>}>
40
+ <span data-testid="child">Child</span>
41
+ </Layout>,
42
+ );
43
+ expect(screen.getByTestId('slot')).toBeInTheDocument();
44
+ expect(screen.queryByTestId('child')).not.toBeInTheDocument();
45
+ });
46
+
47
+ it('still supports the canonical slot-only API', () => {
48
+ render(
49
+ <Layout
50
+ content={
51
+ <LayoutContent>
52
+ <span data-testid="canon">Canonical</span>
53
+ </LayoutContent>
54
+ }
55
+ />,
56
+ );
57
+ expect(screen.getByTestId('canon')).toBeInTheDocument();
58
+ });
59
+ });