@astryxdesign/core 0.1.1 → 0.1.2-canary.2581eb4
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 +105 -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/Calendar/Calendar.d.ts +5 -4
- package/dist/Calendar/Calendar.d.ts.map +1 -1
- package/dist/Calendar/Calendar.js +6 -1
- package/dist/Calendar/index.d.ts +1 -1
- package/dist/Calendar/index.d.ts.map +1 -1
- package/dist/DateTimeInput/DateTimeInput.d.ts +7 -2
- package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
- package/dist/DateTimeInput/DateTimeInput.js +5 -4
- 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 +7 -3
- package/dist/Kbd/Kbd.d.ts.map +1 -1
- package/dist/Kbd/Kbd.js +1 -1
- package/dist/Layer/anchorName.d.ts +20 -0
- package/dist/Layer/anchorName.d.ts.map +1 -0
- package/dist/Layer/anchorName.js +36 -0
- package/dist/Layer/useLayer.d.ts +19 -0
- package/dist/Layer/useLayer.d.ts.map +1 -1
- package/dist/Layer/useLayer.js +15 -6
- package/dist/Link/Link.d.ts +1 -1
- package/dist/Link/Link.js +2 -2
- package/dist/Link/LinkProvider.d.ts +1 -0
- package/dist/Link/LinkProvider.d.ts.map +1 -1
- package/dist/Link/LinkProvider.js +1 -0
- 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/Pagination/Pagination.d.ts.map +1 -1
- package/dist/Pagination/Pagination.js +31 -27
- package/dist/Slider/Slider.js +2 -2
- package/dist/Table/BaseTable.d.ts.map +1 -1
- package/dist/Table/BaseTable.js +27 -8
- package/dist/Table/Table.d.ts.map +1 -1
- package/dist/Table/Table.js +30 -7
- package/dist/Table/TableRow.d.ts +6 -1
- package/dist/Table/TableRow.d.ts.map +1 -1
- package/dist/Table/TableRow.js +15 -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 +369 -0
- package/dist/Table/types.d.ts +95 -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/Text/text.stylex.d.ts +2 -2
- package/dist/Text/text.stylex.js +2 -2
- 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 +7 -9
- package/dist/ToggleButton/ToggleButton.d.ts.map +1 -1
- package/dist/ToggleButton/ToggleButton.js +34 -57
- package/dist/Tokenizer/Tokenizer.d.ts.map +1 -1
- package/dist/Tokenizer/Tokenizer.js +1 -1
- package/dist/Tooltip/useTooltip.d.ts.map +1 -1
- package/dist/Tooltip/useTooltip.js +1 -0
- package/dist/astryx.css +30 -1
- package/dist/astryx.umd.js +147 -0
- package/dist/astryx.umd.js.map +7 -0
- package/dist/theme/generateThemeRules.js +1 -1
- package/dist/theme/syntax/SyntaxTheme.d.ts +1 -1
- package/dist/theme/syntax/SyntaxTheme.js +1 -1
- package/dist/theme/syntax/defineSyntaxTheme.d.ts.map +1 -1
- package/dist/theme/syntax/defineSyntaxTheme.js +2 -2
- package/dist/theme/syntax/presets.d.ts +1 -1
- package/dist/theme/syntax/presets.d.ts.map +1 -1
- package/dist/theme/syntax/presets.js +1 -1
- package/dist/theme/syntax/tokens.d.ts +1 -1
- package/dist/theme/syntax/tokens.js +1 -1
- package/dist/theme/types.d.ts +1 -1
- package/dist/utils/dateTypes.d.ts +12 -0
- package/dist/utils/dateTypes.d.ts.map +1 -1
- package/dist/utils/dateTypes.js +41 -1
- package/package.json +6 -2
- package/src/AvatarGroup/AvatarGroupOverflow.tsx +3 -0
- package/src/Banner/Banner.doc.mjs +8 -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/Calendar/Calendar.doc.mjs +4 -4
- package/src/Calendar/Calendar.test.tsx +21 -26
- package/src/Calendar/Calendar.tsx +21 -5
- package/src/Calendar/index.ts +1 -0
- package/src/Chat/ChatComposer.doc.mjs +3 -0
- package/src/Chat/ChatComposerDrawer.doc.mjs +12 -0
- package/src/CheckboxInput/CheckboxInput.doc.mjs +8 -0
- package/src/ClickableCard/ClickableCard.doc.mjs +15 -0
- package/src/DateTimeInput/DateTimeInput.doc.mjs +11 -3
- package/src/DateTimeInput/DateTimeInput.test.tsx +23 -3
- package/src/DateTimeInput/DateTimeInput.tsx +13 -10
- package/src/Dialog/Dialog.doc.mjs +2 -2
- package/src/EmptyState/EmptyState.test.tsx +4 -2
- package/src/EmptyState/EmptyState.tsx +6 -2
- package/src/HoverCard/HoverCard.doc.mjs +3 -0
- package/src/HoverCard/HoverCard.test.tsx +189 -2
- package/src/HoverCard/HoverCard.tsx +20 -16
- package/src/HoverCard/useHoverCard.tsx +13 -10
- package/src/Icon/Icon.doc.mjs +8 -0
- package/src/Kbd/Kbd.tsx +2 -9
- package/src/Layer/anchorName.test.ts +146 -0
- package/src/Layer/anchorName.ts +46 -0
- package/src/Layer/useLayer.doc.mjs +7 -2
- package/src/Layer/useLayer.tsx +33 -9
- package/src/Layout/Layout.doc.mjs +1 -1
- package/src/Lightbox/Lightbox.doc.mjs +0 -2
- package/src/Link/Link.test.tsx +2 -2
- package/src/Link/Link.tsx +3 -3
- package/src/Link/LinkProvider.tsx +1 -0
- package/src/List/List.doc.mjs +7 -0
- package/src/Markdown/Markdown.doc.mjs +2 -0
- 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/OverflowList/OverflowList.doc.mjs +17 -0
- package/src/Pagination/Pagination.test.tsx +137 -13
- package/src/Pagination/Pagination.tsx +33 -28
- package/src/ProgressBar/ProgressBar.test.tsx +13 -3
- package/src/Resizable/Resizable.doc.mjs +1 -1
- package/src/Section/Section.doc.mjs +11 -0
- package/src/SelectableCard/SelectableCard.doc.mjs +15 -0
- package/src/Selector/Selector.doc.mjs +4 -0
- 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/TabList/TabList.doc.mjs +6 -0
- package/src/Table/BaseTable.tsx +51 -24
- package/src/Table/Table.doc.mjs +12 -0
- package/src/Table/Table.test.tsx +29 -30
- package/src/Table/Table.tsx +22 -1
- package/src/Table/TableRow.tsx +37 -7
- 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 +407 -0
- package/src/Table/types.ts +101 -4
- package/src/Table/useBaseTablePlugins.ts +1 -0
- package/src/Table/useTableColumnResize.doc.mjs +96 -0
- package/src/Table/useTableStickyColumns.doc.mjs +59 -0
- package/src/Text/Heading.doc.mjs +15 -2
- package/src/Text/Text.doc.mjs +7 -1
- package/src/Text/text.stylex.ts +2 -2
- package/src/Timestamp/Timestamp.doc.mjs +9 -0
- package/src/Timestamp/Timestamp.test.tsx +44 -18
- package/src/Timestamp/Timestamp.tsx +30 -10
- package/src/ToggleButton/ToggleButton.doc.mjs +9 -3
- package/src/ToggleButton/ToggleButton.test.tsx +39 -18
- package/src/ToggleButton/ToggleButton.tsx +46 -83
- package/src/Tokenizer/Tokenizer.tsx +4 -1
- package/src/Toolbar/Toolbar.doc.mjs +65 -5
- package/src/Tooltip/Tooltip.test.tsx +13 -0
- package/src/Tooltip/useTooltip.tsx +2 -3
- package/src/hooks/useImageMode.test.ts +133 -0
- package/src/theme/derivedVarRegistry.test.ts +4 -0
- package/src/theme/generateThemeRules.test.ts +7 -8
- package/src/theme/generateThemeRules.ts +1 -1
- package/src/theme/syntax/SyntaxTheme.tsx +1 -1
- package/src/theme/syntax/defineSyntaxTheme.ts +20 -11
- package/src/theme/syntax/presets.ts +5 -2
- package/src/theme/syntax/tokens.ts +1 -1
- package/src/theme/types.ts +1 -1
- package/src/utils/dateTypes.test.ts +40 -0
- package/src/utils/dateTypes.ts +40 -0
|
@@ -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
|
|
@@ -55,6 +58,17 @@ describe('HoverCard', () => {
|
|
|
55
58
|
expect(screen.getByRole('button', {name: 'Trigger'})).toBeInTheDocument();
|
|
56
59
|
});
|
|
57
60
|
|
|
61
|
+
it('gives the floating layer role="dialog"', () => {
|
|
62
|
+
render(
|
|
63
|
+
<HoverCard content={<span>Card content</span>}>
|
|
64
|
+
<button type="button">Trigger</button>
|
|
65
|
+
</HoverCard>,
|
|
66
|
+
);
|
|
67
|
+
expect(screen.getByRole('dialog', {hidden: true})).toHaveTextContent(
|
|
68
|
+
'Card content',
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
58
72
|
it('wraps element children in an inline-safe span', () => {
|
|
59
73
|
const {container} = render(
|
|
60
74
|
<p>
|
|
@@ -73,6 +87,31 @@ describe('HoverCard', () => {
|
|
|
73
87
|
expect(paragraph?.querySelector('div')).toBeNull();
|
|
74
88
|
});
|
|
75
89
|
|
|
90
|
+
it('renders the floating layer with inline-safe markup (no block elements in a paragraph)', () => {
|
|
91
|
+
// HoverCard renders its floating layer inline (no portal), so the layer
|
|
92
|
+
// must be phrasing content to stay valid — and stay put on hydration —
|
|
93
|
+
// inside a <p>. Assert the layer popover element is a <span> and that the
|
|
94
|
+
// paragraph contains no <div> descendants at all.
|
|
95
|
+
const {container} = render(
|
|
96
|
+
<p>
|
|
97
|
+
Before{' '}
|
|
98
|
+
<HoverCard content={<span>Card content</span>}>
|
|
99
|
+
<a href="#trigger">Trigger</a>
|
|
100
|
+
</HoverCard>{' '}
|
|
101
|
+
after
|
|
102
|
+
</p>,
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const paragraph = container.querySelector('p');
|
|
106
|
+
const layer = screen.getByText('Card content').closest('[popover]');
|
|
107
|
+
|
|
108
|
+
expect(layer).not.toBeNull();
|
|
109
|
+
expect(layer?.tagName).toBe('SPAN');
|
|
110
|
+
// The whole layer subtree lives inside the paragraph with no block boxes.
|
|
111
|
+
expect(paragraph?.contains(layer as Node)).toBe(true);
|
|
112
|
+
expect(paragraph?.querySelector('div')).toBeNull();
|
|
113
|
+
});
|
|
114
|
+
|
|
76
115
|
it('does not show content initially', () => {
|
|
77
116
|
render(
|
|
78
117
|
<HoverCard content={<span>Card content</span>}>
|
|
@@ -84,7 +123,7 @@ describe('HoverCard', () => {
|
|
|
84
123
|
expect(content).toBeInTheDocument();
|
|
85
124
|
});
|
|
86
125
|
|
|
87
|
-
it('applies the theme body font to the
|
|
126
|
+
it('applies the theme body font to the floating layer', () => {
|
|
88
127
|
render(
|
|
89
128
|
<HoverCard content={<span>Card content</span>}>
|
|
90
129
|
<button type="button">Trigger</button>
|
|
@@ -370,4 +409,152 @@ describe('HoverCard', () => {
|
|
|
370
409
|
expect(onOpenChange).not.toHaveBeenCalledWith(true);
|
|
371
410
|
});
|
|
372
411
|
});
|
|
412
|
+
|
|
413
|
+
describe('SSR / hydration', () => {
|
|
414
|
+
// Regression coverage for the hydration mismatch (#3107). The floating
|
|
415
|
+
// layer used to be portaled into document.body behind a
|
|
416
|
+
// `typeof document !== 'undefined'` gate: the server rendered nothing while
|
|
417
|
+
// the first client render emitted the portal, so the two trees disagreed.
|
|
418
|
+
//
|
|
419
|
+
// The layer is now rendered inline as inline-safe phrasing markup (a
|
|
420
|
+
// `<span popover>`), identically on the server and the client, so there is
|
|
421
|
+
// nothing for hydration to mismatch.
|
|
422
|
+
|
|
423
|
+
it('renders the floating layer in server markup (no document gate)', () => {
|
|
424
|
+
const html = renderToString(
|
|
425
|
+
<HoverCard content={<span>Card content</span>}>
|
|
426
|
+
<button type="button">Trigger</button>
|
|
427
|
+
</HoverCard>,
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
// The popover element is present in the server output...
|
|
431
|
+
expect(html).toContain('popover="manual"');
|
|
432
|
+
expect(html).toContain('Card content');
|
|
433
|
+
// ...and it is a <span> (inline-safe), not a <div>.
|
|
434
|
+
expect(html).toMatch(/<span[^>]*popover="manual"/);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it('keeps the floating layer inline-safe in server markup inside a paragraph', () => {
|
|
438
|
+
const html = renderToString(
|
|
439
|
+
<p>
|
|
440
|
+
Before{' '}
|
|
441
|
+
<HoverCard content={<span>Card content</span>}>
|
|
442
|
+
<a href="#trigger">Trigger</a>
|
|
443
|
+
</HoverCard>{' '}
|
|
444
|
+
after
|
|
445
|
+
</p>,
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
// No <div> is emitted inside the paragraph — the layer and its wrappers
|
|
449
|
+
// are all phrasing content, so the server string is valid <p> markup that
|
|
450
|
+
// the browser parser will not reparent (which would itself desync
|
|
451
|
+
// hydration).
|
|
452
|
+
expect(html).not.toContain('<div');
|
|
453
|
+
expect(html).toMatch(/<span[^>]*popover="manual"/);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it('server markup matches the first client render (no hydration mismatch)', async () => {
|
|
457
|
+
const tree = (
|
|
458
|
+
<StrictMode>
|
|
459
|
+
<p>
|
|
460
|
+
Glossary:{' '}
|
|
461
|
+
<HoverCard content={<span>Definition</span>}>
|
|
462
|
+
<a href="#term">term</a>
|
|
463
|
+
</HoverCard>
|
|
464
|
+
.
|
|
465
|
+
</p>
|
|
466
|
+
</StrictMode>
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
const serverHTML = renderToString(tree);
|
|
470
|
+
|
|
471
|
+
const container = document.createElement('div');
|
|
472
|
+
container.innerHTML = serverHTML;
|
|
473
|
+
document.body.appendChild(container);
|
|
474
|
+
|
|
475
|
+
// Capture any hydration diagnostics. React reports hydration mismatches
|
|
476
|
+
// both through console.error and through onRecoverableError.
|
|
477
|
+
const consoleErrorSpy = vi
|
|
478
|
+
.spyOn(console, 'error')
|
|
479
|
+
.mockImplementation(() => {});
|
|
480
|
+
const recoverableErrors: unknown[] = [];
|
|
481
|
+
|
|
482
|
+
let root: ReturnType<typeof hydrateRoot>;
|
|
483
|
+
await act(async () => {
|
|
484
|
+
root = hydrateRoot(container, tree, {
|
|
485
|
+
onRecoverableError: error => {
|
|
486
|
+
recoverableErrors.push(error);
|
|
487
|
+
},
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
const hydrationErrors = consoleErrorSpy.mock.calls.filter(call =>
|
|
492
|
+
String(call[0] ?? '')
|
|
493
|
+
.toLowerCase()
|
|
494
|
+
.includes('hydrat'),
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
expect(hydrationErrors).toEqual([]);
|
|
498
|
+
expect(recoverableErrors).toEqual([]);
|
|
499
|
+
|
|
500
|
+
await act(async () => {
|
|
501
|
+
root.unmount();
|
|
502
|
+
});
|
|
503
|
+
consoleErrorSpy.mockRestore();
|
|
504
|
+
container.remove();
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it('hydrates a default-open hover card without a mismatch', async () => {
|
|
508
|
+
vi.mocked(HTMLElement.prototype.showPopover).mockClear();
|
|
509
|
+
|
|
510
|
+
const tree = (
|
|
511
|
+
<HoverCard content={<span>Default open</span>} isDefaultOpen>
|
|
512
|
+
<button type="button">Trigger</button>
|
|
513
|
+
</HoverCard>
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
const serverHTML = renderToString(tree);
|
|
517
|
+
// isDefaultOpen must not leak the open state into SSR markup — the open
|
|
518
|
+
// call happens in an effect after hydration, so the server output is the
|
|
519
|
+
// same closed markup the first client render produces.
|
|
520
|
+
expect(serverHTML).toContain('popover="manual"');
|
|
521
|
+
|
|
522
|
+
const container = document.createElement('div');
|
|
523
|
+
container.innerHTML = serverHTML;
|
|
524
|
+
document.body.appendChild(container);
|
|
525
|
+
|
|
526
|
+
const consoleErrorSpy = vi
|
|
527
|
+
.spyOn(console, 'error')
|
|
528
|
+
.mockImplementation(() => {});
|
|
529
|
+
const recoverableErrors: unknown[] = [];
|
|
530
|
+
|
|
531
|
+
let root: ReturnType<typeof hydrateRoot>;
|
|
532
|
+
await act(async () => {
|
|
533
|
+
root = hydrateRoot(container, tree, {
|
|
534
|
+
onRecoverableError: error => {
|
|
535
|
+
recoverableErrors.push(error);
|
|
536
|
+
},
|
|
537
|
+
});
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
const hydrationErrors = consoleErrorSpy.mock.calls.filter(call =>
|
|
541
|
+
String(call[0] ?? '')
|
|
542
|
+
.toLowerCase()
|
|
543
|
+
.includes('hydrat'),
|
|
544
|
+
);
|
|
545
|
+
expect(hydrationErrors).toEqual([]);
|
|
546
|
+
expect(recoverableErrors).toEqual([]);
|
|
547
|
+
|
|
548
|
+
// The card opens after hydration via the mount effect.
|
|
549
|
+
await waitFor(() => {
|
|
550
|
+
expect(HTMLElement.prototype.showPopover).toHaveBeenCalled();
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
await act(async () => {
|
|
554
|
+
root.unmount();
|
|
555
|
+
});
|
|
556
|
+
consoleErrorSpy.mockRestore();
|
|
557
|
+
container.remove();
|
|
558
|
+
});
|
|
559
|
+
});
|
|
373
560
|
});
|
|
@@ -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',
|
|
@@ -453,15 +455,16 @@ export function useHoverCard(
|
|
|
453
455
|
const renderProps = {
|
|
454
456
|
placement: renderPlacement,
|
|
455
457
|
alignment: props?.alignment ?? alignment,
|
|
458
|
+
role: 'dialog',
|
|
456
459
|
xstyle: [popoverXstyle, layerAnimations[renderPlacement]],
|
|
460
|
+
// Render the layer as inline-safe phrasing markup so HoverCard stays
|
|
461
|
+
// valid (and hydration-stable) inside inline contexts like a `<p>`.
|
|
462
|
+
as: 'span' as const,
|
|
457
463
|
};
|
|
458
464
|
|
|
459
465
|
return layer.render(
|
|
460
|
-
<
|
|
461
|
-
{...mergeProps(
|
|
462
|
-
themeProps('hovercard'),
|
|
463
|
-
stylex.props(styles.content),
|
|
464
|
-
)}
|
|
466
|
+
<span
|
|
467
|
+
{...mergeProps(themeProps('hovercard'), stylex.props(styles.content))}
|
|
465
468
|
onMouseEnter={() => {
|
|
466
469
|
isHoveringContentRef.current = true;
|
|
467
470
|
clearTimeouts();
|
|
@@ -502,7 +505,7 @@ export function useHoverCard(
|
|
|
502
505
|
scheduleHide();
|
|
503
506
|
}}>
|
|
504
507
|
{children}
|
|
505
|
-
</
|
|
508
|
+
</span>,
|
|
506
509
|
renderProps,
|
|
507
510
|
);
|
|
508
511
|
},
|
package/src/Icon/Icon.doc.mjs
CHANGED
|
@@ -7,6 +7,14 @@ export const docs = {
|
|
|
7
7
|
displayName: 'Icon',
|
|
8
8
|
category: 'Content',
|
|
9
9
|
keywords: ["icon","svg","glyph","symbol","pictogram","graphic","vector"],
|
|
10
|
+
playground: {
|
|
11
|
+
// `icon` is required and its type can't be auto-generated, so the
|
|
12
|
+
// properties-tab preview showed "Missing: icon". Seed a valid semantic
|
|
13
|
+
// icon name so the interactive preview renders.
|
|
14
|
+
defaults: {
|
|
15
|
+
icon: 'search',
|
|
16
|
+
},
|
|
17
|
+
},
|
|
10
18
|
props: [
|
|
11
19
|
{
|
|
12
20
|
name: 'icon',
|
package/src/Kbd/Kbd.tsx
CHANGED
|
@@ -45,7 +45,7 @@ const styles = stylex.create({
|
|
|
45
45
|
backgroundColor: colorVars['--color-neutral'],
|
|
46
46
|
borderBottomWidth: '2px',
|
|
47
47
|
borderBottomStyle: 'solid',
|
|
48
|
-
borderBottomColor: colorVars['--color-border'],
|
|
48
|
+
borderBottomColor: colorVars['--color-border-emphasized'],
|
|
49
49
|
color: colorVars['--color-text-secondary'],
|
|
50
50
|
fontFamily: typographyVars['--font-family-body'],
|
|
51
51
|
fontSize: typeScaleVars['--text-supporting-size'],
|
|
@@ -146,14 +146,7 @@ export interface KbdProps extends BaseProps<HTMLSpanElement> {
|
|
|
146
146
|
* <Kbd keys="mod+k" />
|
|
147
147
|
* ```
|
|
148
148
|
*/
|
|
149
|
-
export function Kbd({
|
|
150
|
-
keys,
|
|
151
|
-
ref,
|
|
152
|
-
xstyle,
|
|
153
|
-
className,
|
|
154
|
-
style,
|
|
155
|
-
...rest
|
|
156
|
-
}: KbdProps) {
|
|
149
|
+
export function Kbd({keys, ref, xstyle, className, style, ...rest}: KbdProps) {
|
|
157
150
|
const isMac = useSyncExternalStore(
|
|
158
151
|
subscribeToPlatformChanges,
|
|
159
152
|
detectMac,
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file anchorName.test.ts
|
|
5
|
+
* @input Uses vitest, anchorName utilities
|
|
6
|
+
* @output Unit tests for anchor-name list manipulation helpers
|
|
7
|
+
* @position Testing; validates anchorName.ts implementation
|
|
8
|
+
*
|
|
9
|
+
* SYNC: When anchorName.ts changes, update tests to match new behavior
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {describe, it, expect} from 'vitest';
|
|
13
|
+
import {
|
|
14
|
+
readAnchorNames,
|
|
15
|
+
writeAnchorNames,
|
|
16
|
+
addAnchorName,
|
|
17
|
+
removeAnchorName,
|
|
18
|
+
} from './anchorName';
|
|
19
|
+
|
|
20
|
+
function createMockElement(): HTMLElement {
|
|
21
|
+
const el = document.createElement('div');
|
|
22
|
+
return el;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('readAnchorNames', () => {
|
|
26
|
+
it('returns empty array for element with no anchor-name', () => {
|
|
27
|
+
const el = createMockElement();
|
|
28
|
+
expect(readAnchorNames(el)).toEqual([]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('reads a single anchor name', () => {
|
|
32
|
+
const el = createMockElement();
|
|
33
|
+
(el.style as unknown as Record<string, string>).anchorName = '--layer-1';
|
|
34
|
+
expect(readAnchorNames(el)).toEqual(['--layer-1']);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('reads multiple comma-separated anchor names', () => {
|
|
38
|
+
const el = createMockElement();
|
|
39
|
+
(el.style as unknown as Record<string, string>).anchorName =
|
|
40
|
+
'--layer-1, --layer-2, --layer-3';
|
|
41
|
+
expect(readAnchorNames(el)).toEqual([
|
|
42
|
+
'--layer-1',
|
|
43
|
+
'--layer-2',
|
|
44
|
+
'--layer-3',
|
|
45
|
+
]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('trims whitespace around names', () => {
|
|
49
|
+
const el = createMockElement();
|
|
50
|
+
(el.style as unknown as Record<string, string>).anchorName =
|
|
51
|
+
' --layer-1 , --layer-2 ';
|
|
52
|
+
expect(readAnchorNames(el)).toEqual(['--layer-1', '--layer-2']);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('returns empty array for empty string value', () => {
|
|
56
|
+
const el = createMockElement();
|
|
57
|
+
(el.style as unknown as Record<string, string>).anchorName = '';
|
|
58
|
+
expect(readAnchorNames(el)).toEqual([]);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('writeAnchorNames', () => {
|
|
63
|
+
it('writes names as comma-separated list', () => {
|
|
64
|
+
const el = createMockElement();
|
|
65
|
+
writeAnchorNames(el, ['--layer-1', '--layer-2']);
|
|
66
|
+
expect((el.style as unknown as Record<string, string>).anchorName).toBe(
|
|
67
|
+
'--layer-1, --layer-2',
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('writes empty string for empty array', () => {
|
|
72
|
+
const el = createMockElement();
|
|
73
|
+
writeAnchorNames(el, []);
|
|
74
|
+
expect((el.style as unknown as Record<string, string>).anchorName).toBe('');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('addAnchorName', () => {
|
|
79
|
+
it('adds name to element with no existing anchors', () => {
|
|
80
|
+
const el = createMockElement();
|
|
81
|
+
addAnchorName(el, '--layer-1');
|
|
82
|
+
expect((el.style as unknown as Record<string, string>).anchorName).toBe(
|
|
83
|
+
'--layer-1',
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('appends name to existing anchor list', () => {
|
|
88
|
+
const el = createMockElement();
|
|
89
|
+
(el.style as unknown as Record<string, string>).anchorName = '--layer-1';
|
|
90
|
+
addAnchorName(el, '--layer-2');
|
|
91
|
+
expect((el.style as unknown as Record<string, string>).anchorName).toBe(
|
|
92
|
+
'--layer-1, --layer-2',
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('does not duplicate an already-present name', () => {
|
|
97
|
+
const el = createMockElement();
|
|
98
|
+
(el.style as unknown as Record<string, string>).anchorName = '--layer-1';
|
|
99
|
+
addAnchorName(el, '--layer-1');
|
|
100
|
+
expect((el.style as unknown as Record<string, string>).anchorName).toBe(
|
|
101
|
+
'--layer-1',
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('supports multiple layers sharing one element', () => {
|
|
106
|
+
const el = createMockElement();
|
|
107
|
+
addAnchorName(el, '--menu-1');
|
|
108
|
+
addAnchorName(el, '--menu-2');
|
|
109
|
+
addAnchorName(el, '--menu-3');
|
|
110
|
+
expect(readAnchorNames(el)).toEqual(['--menu-1', '--menu-2', '--menu-3']);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('removeAnchorName', () => {
|
|
115
|
+
it('removes name from a multi-anchor list', () => {
|
|
116
|
+
const el = createMockElement();
|
|
117
|
+
(el.style as unknown as Record<string, string>).anchorName =
|
|
118
|
+
'--layer-1, --layer-2, --layer-3';
|
|
119
|
+
removeAnchorName(el, '--layer-2');
|
|
120
|
+
expect(readAnchorNames(el)).toEqual(['--layer-1', '--layer-3']);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('removes the last remaining name, leaving empty string', () => {
|
|
124
|
+
const el = createMockElement();
|
|
125
|
+
(el.style as unknown as Record<string, string>).anchorName = '--layer-1';
|
|
126
|
+
removeAnchorName(el, '--layer-1');
|
|
127
|
+
expect((el.style as unknown as Record<string, string>).anchorName).toBe('');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('is a no-op if name is not present', () => {
|
|
131
|
+
const el = createMockElement();
|
|
132
|
+
(el.style as unknown as Record<string, string>).anchorName =
|
|
133
|
+
'--layer-1, --layer-2';
|
|
134
|
+
removeAnchorName(el, '--layer-3');
|
|
135
|
+
expect(readAnchorNames(el)).toEqual(['--layer-1', '--layer-2']);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('handles multiple add/remove cycles correctly', () => {
|
|
139
|
+
const el = createMockElement();
|
|
140
|
+
addAnchorName(el, '--menu-a');
|
|
141
|
+
addAnchorName(el, '--menu-b');
|
|
142
|
+
removeAnchorName(el, '--menu-a');
|
|
143
|
+
addAnchorName(el, '--menu-c');
|
|
144
|
+
expect(readAnchorNames(el)).toEqual(['--menu-b', '--menu-c']);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file anchorName.ts
|
|
5
|
+
* @input HTMLElement with CSS anchor-name property
|
|
6
|
+
* @output Helpers for managing comma-separated anchor-name lists
|
|
7
|
+
* @position Utility; used by useLayer for multi-anchor support
|
|
8
|
+
*
|
|
9
|
+
* SYNC: When modified, update:
|
|
10
|
+
* - /packages/core/src/Layer/useLayer.tsx (imports from here)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* CSS `anchor-name` is a comma-separated list, so multiple layers can anchor to
|
|
15
|
+
* the same element (e.g. several TopNavMegaMenus anchored to one <nav>). These
|
|
16
|
+
* helpers add/remove a single layer's anchor id without clobbering the others —
|
|
17
|
+
* overwriting the whole property would break every sibling layer's positioning.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export function readAnchorNames(el: HTMLElement): string[] {
|
|
21
|
+
const value =
|
|
22
|
+
(el.style as unknown as Record<string, string>).anchorName ?? '';
|
|
23
|
+
return value
|
|
24
|
+
.split(',')
|
|
25
|
+
.map(name => name.trim())
|
|
26
|
+
.filter(Boolean);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function writeAnchorNames(el: HTMLElement, names: string[]): void {
|
|
30
|
+
(el.style as unknown as Record<string, string>).anchorName = names.join(', ');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function addAnchorName(el: HTMLElement, name: string): void {
|
|
34
|
+
const names = readAnchorNames(el);
|
|
35
|
+
if (!names.includes(name)) {
|
|
36
|
+
names.push(name);
|
|
37
|
+
writeAnchorNames(el, names);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function removeAnchorName(el: HTMLElement, name: string): void {
|
|
42
|
+
writeAnchorNames(
|
|
43
|
+
el,
|
|
44
|
+
readAnchorNames(el).filter(existing => existing !== name),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -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\'s 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:
|