@astryxdesign/core 0.4.1 → 0.4.2-canary.356d2f9
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 +55 -0
- package/dist/Avatar/Avatar.d.ts +4 -1
- package/dist/Avatar/Avatar.d.ts.map +1 -1
- package/dist/Avatar/Avatar.js +19 -23
- package/dist/AvatarGroup/AvatarGroupOverflow.d.ts.map +1 -1
- package/dist/AvatarGroup/AvatarGroupOverflow.js +7 -2
- package/dist/Button/Button.d.ts.map +1 -1
- package/dist/Button/Button.js +9 -7
- package/dist/Chat/ChatMessage.d.ts +7 -0
- package/dist/Chat/ChatMessage.d.ts.map +1 -1
- package/dist/Chat/ChatMessageBubble.d.ts +13 -1
- package/dist/Chat/ChatMessageBubble.d.ts.map +1 -1
- package/dist/Chat/ChatMessageBubble.js +19 -1
- package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
- package/dist/CheckboxInput/CheckboxInput.js +5 -0
- package/dist/CommandPalette/CommandPaletteFooter.d.ts.map +1 -1
- package/dist/CommandPalette/CommandPaletteFooter.js +5 -3
- package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
- package/dist/DateTimeInput/DateTimeInput.js +2 -2
- package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuSubMenu.js +19 -15
- package/dist/HoverCard/HoverCard.d.ts +1 -1
- package/dist/HoverCard/HoverCard.d.ts.map +1 -1
- package/dist/HoverCard/HoverCard.js +5 -13
- package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
- package/dist/HoverCard/useHoverCard.js +6 -5
- package/dist/InputGroup/groupStyles.d.ts.map +1 -1
- package/dist/InputGroup/groupStyles.js +7 -2
- package/dist/Layer/layerHost.d.ts +24 -0
- package/dist/Layer/layerHost.d.ts.map +1 -0
- package/dist/Layer/layerHost.js +79 -0
- package/dist/Layer/useLayer.d.ts +14 -6
- package/dist/Layer/useLayer.d.ts.map +1 -1
- package/dist/Layer/useLayer.js +158 -30
- package/dist/NavItem/navItemStyles.stylex.d.ts +17 -5
- package/dist/NavItem/navItemStyles.stylex.d.ts.map +1 -1
- package/dist/NavItem/navItemStyles.stylex.js +11 -5
- package/dist/RadioList/RadioListItem.d.ts.map +1 -1
- package/dist/RadioList/RadioListItem.js +5 -0
- package/dist/SideNav/SideNav.d.ts +7 -9
- package/dist/SideNav/SideNav.d.ts.map +1 -1
- package/dist/SideNav/SideNav.js +32 -5
- package/dist/SideNav/SideNavCollapseButton.d.ts +25 -9
- package/dist/SideNav/SideNavCollapseButton.d.ts.map +1 -1
- package/dist/SideNav/SideNavCollapseButton.js +39 -15
- package/dist/SideNav/SideNavCollapseContext.d.ts +21 -0
- package/dist/SideNav/SideNavCollapseContext.d.ts.map +1 -1
- package/dist/SideNav/SideNavCollapseContext.js +16 -2
- package/dist/SideNav/SideNavHeading.d.ts.map +1 -1
- package/dist/SideNav/SideNavHeading.js +89 -32
- package/dist/SideNav/SideNavItem.d.ts +7 -2
- package/dist/SideNav/SideNavItem.d.ts.map +1 -1
- package/dist/SideNav/SideNavItem.js +119 -75
- package/dist/SideNav/SideNavSection.d.ts.map +1 -1
- package/dist/SideNav/SideNavSection.js +7 -16
- package/dist/SideNav/index.d.ts +1 -1
- package/dist/SideNav/index.d.ts.map +1 -1
- package/dist/Slider/Slider.d.ts.map +1 -1
- package/dist/Slider/Slider.js +56 -15
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +5 -0
- package/dist/Thumbnail/Thumbnail.d.ts.map +1 -1
- package/dist/Thumbnail/Thumbnail.js +5 -0
- package/dist/TopNav/TopNavHeading.d.ts.map +1 -1
- package/dist/TopNav/TopNavHeading.js +14 -6
- package/dist/TopNav/TopNavMegaMenu.d.ts.map +1 -1
- package/dist/TopNav/TopNavMegaMenu.js +25 -93
- package/dist/TopNav/TopNavMegaMenuItem.js +1 -1
- package/dist/TopNav/TopNavMenu.d.ts.map +1 -1
- package/dist/TopNav/TopNavMenu.js +10 -5
- package/dist/astryx.css +40 -8
- package/dist/astryx.umd.js +50 -50
- package/dist/astryx.umd.js.map +4 -4
- package/dist/hooks/containerReveal.stylex.d.ts +71 -4
- package/dist/hooks/containerReveal.stylex.d.ts.map +1 -1
- package/dist/hooks/containerReveal.stylex.js +57 -5
- package/dist/hooks/index.d.ts +2 -2
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +1 -1
- package/dist/hooks/useContainerReveal.d.ts +60 -3
- package/dist/hooks/useContainerReveal.d.ts.map +1 -1
- package/dist/hooks/useContainerReveal.js +27 -5
- package/dist/hooks/useFocusTrap.d.ts.map +1 -1
- package/dist/hooks/useFocusTrap.js +16 -2
- package/dist/hooks/useMenuHover.d.ts +50 -5
- package/dist/hooks/useMenuHover.d.ts.map +1 -1
- package/dist/hooks/useMenuHover.js +171 -51
- package/dist/theme/defineTheme.d.ts +16 -5
- package/dist/theme/defineTheme.d.ts.map +1 -1
- package/dist/theme/defineTheme.js +44 -48
- package/dist/theme/derivedVarRegistry.d.ts.map +1 -1
- package/dist/theme/derivedVarRegistry.js +7 -0
- package/dist/theme/expandColorScale.d.ts.map +1 -1
- package/dist/theme/expandColorScale.js +1 -0
- package/dist/theme/expandMotionScale.d.ts +1 -0
- package/dist/theme/expandMotionScale.d.ts.map +1 -1
- package/dist/theme/expandMotionScale.js +1 -0
- package/dist/theme/expandRadiusScale.d.ts +1 -0
- package/dist/theme/expandRadiusScale.d.ts.map +1 -1
- package/dist/theme/expandRadiusScale.js +1 -0
- package/dist/theme/expandTypeScale.d.ts +1 -0
- package/dist/theme/expandTypeScale.d.ts.map +1 -1
- package/dist/theme/expandTypeScale.js +1 -0
- package/dist/theme/mergeComponents.d.ts +20 -0
- package/dist/theme/mergeComponents.d.ts.map +1 -0
- package/dist/theme/mergeComponents.js +56 -0
- package/dist/theme/onMediaTokens.d.ts +6 -1
- package/dist/theme/onMediaTokens.d.ts.map +1 -1
- package/dist/theme/onMediaTokens.js +11 -3
- package/dist/theme/tokens.stylex.d.ts +3 -1
- package/dist/theme/tokens.stylex.d.ts.map +1 -1
- package/dist/theme/tokens.stylex.js +3 -1
- package/locales/en.json +84 -0
- package/locales/pseudo.json +63 -0
- package/package.json +3 -3
- package/src/Avatar/Avatar.doc.mjs +5 -2
- package/src/Avatar/Avatar.test.tsx +51 -0
- package/src/Avatar/Avatar.tsx +30 -20
- package/src/AvatarGroup/AvatarGroup.doc.mjs +1 -1
- package/src/AvatarGroup/AvatarGroup.test.tsx +14 -0
- package/src/AvatarGroup/AvatarGroupOverflow.tsx +3 -2
- package/src/Button/Button.tsx +5 -3
- package/src/ButtonGroup/ButtonGroup.test.tsx +7 -4
- package/src/Card/Card.doc.mjs +2 -0
- package/src/Chat/Chat.doc.mjs +4 -1
- package/src/Chat/ChatMessage.doc.mjs +3 -3
- package/src/Chat/ChatMessage.tsx +7 -0
- package/src/Chat/ChatMessageBubble.doc.mjs +7 -0
- package/src/Chat/ChatMessageBubble.test.tsx +95 -6
- package/src/Chat/ChatMessageBubble.tsx +25 -0
- package/src/CheckboxInput/CheckboxInput.tsx +20 -0
- package/src/CodeBlock/CodeBlock.doc.mjs +3 -0
- package/src/CommandPalette/CommandPaletteFooter.tsx +6 -3
- package/src/DateTimeInput/DateTimeInput.tsx +4 -2
- package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +72 -1
- package/src/DropdownMenu/DropdownMenuSubMenu.tsx +24 -20
- package/src/HoverCard/HoverCard.doc.mjs +3 -3
- package/src/HoverCard/HoverCard.test.tsx +283 -55
- package/src/HoverCard/HoverCard.tsx +5 -13
- package/src/HoverCard/useHoverCard.tsx +8 -11
- package/src/InputGroup/groupStyles.ts +7 -8
- package/src/Item/Item.doc.mjs +4 -0
- package/src/Layer/layerHost.test.ts +99 -0
- package/src/Layer/layerHost.ts +141 -0
- package/src/Layer/useLayer.doc.mjs +14 -4
- package/src/Layer/useLayer.test.tsx +332 -7
- package/src/Layer/useLayer.tsx +235 -36
- package/src/MobileNav/MobileNav.doc.mjs +7 -0
- package/src/NavItem/navItemStyles.stylex.ts +31 -5
- package/src/RadioList/RadioListItem.tsx +20 -0
- package/src/SideNav/SideNav.doc.mjs +13 -4
- package/src/SideNav/SideNav.test.tsx +858 -2
- package/src/SideNav/SideNav.tsx +37 -16
- package/src/SideNav/SideNavCollapseButton.doc.mjs +28 -6
- package/src/SideNav/SideNavCollapseButton.tsx +67 -15
- package/src/SideNav/SideNavCollapseContext.ts +29 -9
- package/src/SideNav/SideNavHeading.tsx +53 -25
- package/src/SideNav/SideNavItem.doc.mjs +13 -0
- package/src/SideNav/SideNavItem.tsx +97 -84
- package/src/SideNav/SideNavSection.tsx +6 -20
- package/src/SideNav/index.ts +2 -0
- package/src/Slider/Slider.test.tsx +69 -33
- package/src/Slider/Slider.tsx +65 -13
- package/src/Switch/Switch.tsx +20 -0
- package/src/TabList/TabList.doc.mjs +3 -0
- package/src/Text/Text.doc.mjs +1 -1
- package/src/Thumbnail/Thumbnail.doc.mjs +3 -0
- package/src/Thumbnail/Thumbnail.tsx +10 -0
- package/src/Timestamp/Timestamp.test.tsx +40 -22
- package/src/Toolbar/Toolbar.test.tsx +14 -14
- package/src/TopNav/TopNav.test.tsx +86 -1
- package/src/TopNav/TopNavHeading.tsx +21 -14
- package/src/TopNav/TopNavMegaMenu.test.tsx +43 -0
- package/src/TopNav/TopNavMegaMenu.tsx +29 -105
- package/src/TopNav/TopNavMegaMenuItem.tsx +1 -1
- package/src/TopNav/TopNavMenu.test.tsx +41 -0
- package/src/TopNav/TopNavMenu.tsx +14 -4
- package/src/TreeList/TreeList.doc.mjs +1 -0
- package/src/hooks/containerReveal.stylex.ts +140 -9
- package/src/hooks/index.ts +6 -1
- package/src/hooks/useContainerReveal.doc.mjs +11 -5
- package/src/hooks/useContainerReveal.test.tsx +70 -3
- package/src/hooks/useContainerReveal.ts +86 -7
- package/src/hooks/useFocusTrap.test.tsx +16 -0
- package/src/hooks/useFocusTrap.ts +20 -2
- package/src/hooks/useMenuHover.test.tsx +364 -0
- package/src/hooks/useMenuHover.ts +243 -47
- package/src/theme/defineTheme.test.ts +127 -0
- package/src/theme/defineTheme.ts +57 -51
- package/src/theme/derivedVarRegistry.test.ts +78 -12
- package/src/theme/derivedVarRegistry.ts +4 -0
- package/src/theme/expandColorScale.ts +1 -0
- package/src/theme/expandMotionScale.ts +1 -0
- package/src/theme/expandRadiusScale.ts +1 -0
- package/src/theme/expandTypeScale.ts +1 -0
- package/src/theme/mergeComponents.ts +59 -0
- package/src/theme/onMediaTokens.ts +9 -2
- package/src/theme/themingTargets.test.ts +152 -26
- package/src/theme/tokens.stylex.ts +3 -1
- package/src/theme/tokens.test.ts +12 -0
- package/src/theme/useTheme.test.tsx +18 -0
|
@@ -104,6 +104,20 @@ describe('AvatarGroupOverflow', () => {
|
|
|
104
104
|
expect(overflow).toHaveTextContent('+5');
|
|
105
105
|
});
|
|
106
106
|
|
|
107
|
+
it('applies the group size class to the overflow chip', () => {
|
|
108
|
+
render(
|
|
109
|
+
<AvatarGroup size="lg">
|
|
110
|
+
<Avatar name="Alice" />
|
|
111
|
+
<AvatarGroupOverflow count={5} />
|
|
112
|
+
</AvatarGroup>,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const overflow = screen.getByLabelText('5 more');
|
|
116
|
+
expect(overflow.className).toContain('astryx-avatar-group-overflow');
|
|
117
|
+
expect(overflow.className).toContain('lg');
|
|
118
|
+
expect(overflow).toHaveAttribute('data-size', 'lg');
|
|
119
|
+
});
|
|
120
|
+
|
|
107
121
|
it('renders as button when onClick is provided', () => {
|
|
108
122
|
render(
|
|
109
123
|
<AvatarGroup>
|
|
@@ -146,6 +146,7 @@ export function AvatarGroupOverflow({
|
|
|
146
146
|
}: AvatarGroupOverflowProps): ReactNode {
|
|
147
147
|
const t = useTranslator();
|
|
148
148
|
const group = useAvatarGroup();
|
|
149
|
+
const size = group?.size ?? 'md';
|
|
149
150
|
const numericSize = group?.numericSize ?? 36;
|
|
150
151
|
const overlap = group?.overlap ?? 0;
|
|
151
152
|
|
|
@@ -162,7 +163,7 @@ export function AvatarGroupOverflow({
|
|
|
162
163
|
aria-label={label}
|
|
163
164
|
data-avatar-item=""
|
|
164
165
|
{...mergeProps(
|
|
165
|
-
themeProps('avatar-group-overflow'),
|
|
166
|
+
themeProps('avatar-group-overflow', {size}),
|
|
166
167
|
focusOutlineProps.focusVisible(
|
|
167
168
|
styles.base,
|
|
168
169
|
styles.button,
|
|
@@ -186,7 +187,7 @@ export function AvatarGroupOverflow({
|
|
|
186
187
|
{...rest}
|
|
187
188
|
aria-label={label}
|
|
188
189
|
{...mergeProps(
|
|
189
|
-
themeProps('avatar-group-overflow'),
|
|
190
|
+
themeProps('avatar-group-overflow', {size}),
|
|
190
191
|
stylex.props(
|
|
191
192
|
styles.base,
|
|
192
193
|
styles.overlap,
|
package/src/Button/Button.tsx
CHANGED
|
@@ -438,8 +438,10 @@ const loadingStyles = stylex.create({
|
|
|
438
438
|
*
|
|
439
439
|
* Layers always carry the native `popover` attribute (useLayer.tsx), and a
|
|
440
440
|
* popover is never an in-flow member — it is `display: none` until shown, then
|
|
441
|
-
* promoted to the top layer.
|
|
442
|
-
*
|
|
441
|
+
* promoted to the top layer. Context layers also retain an inert `<template>`
|
|
442
|
+
* marker so they can re-resolve their JSX position. Neither element is a group
|
|
443
|
+
* member, so "last member" is: no following element sibling besides those two
|
|
444
|
+
* pieces of layer infrastructure.
|
|
443
445
|
*
|
|
444
446
|
* Reading it the other way round — marking the *buttons* and testing for a
|
|
445
447
|
* marked sibling — is the trap: it silently reclassifies anything it doesn't
|
|
@@ -455,7 +457,7 @@ const loadingStyles = stylex.create({
|
|
|
455
457
|
* The leading edge still uses `:first-child` — a member's button always precedes
|
|
456
458
|
* its own layer, so the first button is genuinely `:first-child`.
|
|
457
459
|
*/
|
|
458
|
-
const IS_LAST_ITEM = ':not(:has(~ *:not([popover])))';
|
|
460
|
+
const IS_LAST_ITEM = ':not(:has(~ *:not([popover]):not(template)))';
|
|
459
461
|
|
|
460
462
|
const groupStyles = stylex.create({
|
|
461
463
|
horizontal: {
|
|
@@ -202,9 +202,9 @@ describe('ButtonGroup', () => {
|
|
|
202
202
|
// Trailing radius (issue #2508)
|
|
203
203
|
//
|
|
204
204
|
// The trailing end cap cannot be keyed off `:last-child`: members render an
|
|
205
|
-
// invisible layer AFTER their button (tooltip'd Button,
|
|
206
|
-
// useLayer renders
|
|
207
|
-
// slot. See IS_LAST_ITEM in Button.tsx.
|
|
205
|
+
// invisible layer infrastructure AFTER their button (tooltip'd Button,
|
|
206
|
+
// DropdownMenu), and useLayer renders a marker plus the layer inline when the
|
|
207
|
+
// host is safe, so they steal the slot. See IS_LAST_ITEM in Button.tsx.
|
|
208
208
|
//
|
|
209
209
|
// HOW THESE TESTS CATCH THE BUG
|
|
210
210
|
// jsdom applies no StyleX CSS, so a DOM-only test cannot prove which rule
|
|
@@ -219,7 +219,9 @@ describe('ButtonGroup', () => {
|
|
|
219
219
|
describe('trailing radius (#2508)', () => {
|
|
220
220
|
/** The group members, in DOM order (excludes invisible layer siblings). */
|
|
221
221
|
const items = (group: HTMLElement): Element[] =>
|
|
222
|
-
Array.from(
|
|
222
|
+
Array.from(
|
|
223
|
+
group.querySelectorAll(':scope > *:not([popover]):not(template)'),
|
|
224
|
+
);
|
|
223
225
|
|
|
224
226
|
// -- Compiled CSS, read from the source -----------------------------------
|
|
225
227
|
|
|
@@ -375,6 +377,7 @@ describe('ButtonGroup', () => {
|
|
|
375
377
|
// .stylex.ts file it compiles to a mangled selector like `[x13pbwiz]`
|
|
376
378
|
// that matches nothing in the DOM.
|
|
377
379
|
expect(selector).toContain('[popover]');
|
|
380
|
+
expect(selector).toContain('template');
|
|
378
381
|
}
|
|
379
382
|
},
|
|
380
383
|
);
|
package/src/Card/Card.doc.mjs
CHANGED
|
@@ -95,6 +95,8 @@ export const docs = {
|
|
|
95
95
|
],
|
|
96
96
|
vars: [
|
|
97
97
|
{name: '--_card-radius', description: 'Border radius of the card', default: 'var(--radius-container)', private: true},
|
|
98
|
+
{name: '--_card-elevation', description: 'Resting shadow of the card, set from the elevation prop. Composed into the card box-shadow list alongside --_card-ring rather than written as boxShadow directly, so a ring and an elevation can coexist.', default: '0 0 transparent', private: true},
|
|
99
|
+
{name: '--_card-ring', description: 'Inset ring drawn in the card box-shadow list. SelectableCard sets it to show selection without taking over the shadow.', default: '0 0 transparent', private: true},
|
|
98
100
|
],
|
|
99
101
|
derived: [
|
|
100
102
|
{property: 'borderRadius', vars: ['--_card-radius']},
|
package/src/Chat/Chat.doc.mjs
CHANGED
|
@@ -67,6 +67,7 @@ export const docs = {
|
|
|
67
67
|
{ guidance: true, description: 'Use the group prop on bubbles (first, middle, last) when a single sender sends multiple consecutive messages; it tightens corner radius to visually connect them.' },
|
|
68
68
|
{ guidance: true, description: 'Use ChatSystemMessage with variant="divider" for date separators and default for inline status notices like joins, leaves, or topic changes.' },
|
|
69
69
|
{ guidance: true, description: 'Put name on the first bubble and metadata on the last bubble in a message so they align with the bubble\'s inline padding.' },
|
|
70
|
+
{ guidance: true, description: 'Wrap custom in-message content (cards, attachments, citations) in a ghost bubble so it aligns with the bubble text column; add width="100%" when it should span the full message column instead of the default bubble width cap.' },
|
|
70
71
|
{ guidance: true, description: 'Provide an emptyState prop so new users see a clear prompt to start a conversation instead of a blank screen.' },
|
|
71
72
|
{ guidance: true, description: 'Use the ghost bubble variant for AI-style responses that show rich content like code blocks or markdown without a visible boundary.' },
|
|
72
73
|
{ guidance: false, description: 'Don\'t use ChatSystemMessage for sender content; it has no avatar, alignment, or bubble. Use ChatMessage with a sender role instead.' },
|
|
@@ -84,7 +85,7 @@ export const docs = {
|
|
|
84
85
|
{ name: 'Empty state', required: false, description: 'Centered placeholder shown when no messages exist. Use EmptyState for a consistent look.' },
|
|
85
86
|
{ name: 'Avatar', required: false, description: 'A sender avatar rendered beside the message. Typically Avatar with size="md". Hidden for system messages.' },
|
|
86
87
|
{ name: 'Name', required: false, description: 'Sender name above the message body. Place on the bubble when using bubbles, or on the message wrapper for raw content.' },
|
|
87
|
-
{ name: 'Content', required: true, description: 'The message body: one or more ChatMessageBubble elements, or any free-form ReactNode like images or tool calls.' },
|
|
88
|
+
{ name: 'Content', required: true, description: 'The message body: one or more ChatMessageBubble elements, or any free-form ReactNode like images or tool calls. Wrap non-bubble content in a ghost bubble to align it with the bubble text column.' },
|
|
88
89
|
{ name: 'Metadata', required: false, description: 'Timestamp, delivery status, and footer actions below the message. Place on the last bubble or on the message wrapper.' },
|
|
89
90
|
],
|
|
90
91
|
},
|
|
@@ -100,6 +101,7 @@ export const docsZh = {
|
|
|
100
101
|
{ guidance: true, description: '当同一发送者发送多条连续消息时,使用气泡的 group 属性(first、middle、last)来视觉上连接相关消息。' },
|
|
101
102
|
{ guidance: true, description: '使用 ChatSystemMessage 的 variant="divider" 作为日期分隔线,使用 default 作为内联状态通知。' },
|
|
102
103
|
{ guidance: true, description: '将 name 放在第一个气泡上,metadata 放在最后一个气泡上,使其与气泡的内边距对齐。' },
|
|
104
|
+
{ guidance: true, description: '将自定义消息内容(卡片、附件、引用)包裹在 ghost 变体的气泡中,使其与气泡文本列对齐;需要占满消息列宽度时设置 width="100%"。' },
|
|
103
105
|
{ guidance: true, description: '提供 emptyState 属性,让新用户看到开始对话的提示,而不是空白屏幕。' },
|
|
104
106
|
{ guidance: true, description: '使用 ghost 气泡变体显示代码块或 Markdown 等富内容,无需可见边界。' },
|
|
105
107
|
{ guidance: false, description: '不要将 ChatSystemMessage 用于发送者内容,它没有头像、对齐或气泡。请使用带有发送者角色的 ChatMessage。' },
|
|
@@ -131,6 +133,7 @@ export const docsDense = {
|
|
|
131
133
|
{ guidance: true, description: 'Group prop on bubbles (first/middle/last) for consecutive same-sender messages; tightens corner radius.' },
|
|
132
134
|
{ guidance: true, description: 'SystemMessage: divider variant for date breaks, default for status notices.' },
|
|
133
135
|
{ guidance: true, description: 'Name on first bubble, metadata on last; aligns with bubble inline padding.' },
|
|
136
|
+
{ guidance: true, description: 'Ghost bubble wraps custom content (cards/attachments); width="100%" spans full message column (default cap max(80%,280px)).' },
|
|
134
137
|
{ guidance: true, description: 'Provide emptyState so new users see a prompt, not a blank screen.' },
|
|
135
138
|
{ guidance: true, description: 'Ghost bubble variant for rich AI content like code/markdown without visible boundary.' },
|
|
136
139
|
{ guidance: false, description: 'SystemMessage for sender content: no avatar/alignment/bubble. Use ChatMessage instead.' },
|
|
@@ -17,7 +17,7 @@ export const docs = {
|
|
|
17
17
|
{
|
|
18
18
|
name: 'children',
|
|
19
19
|
type: 'ReactNode',
|
|
20
|
-
description: 'Free-form content: bubbles, asset lists, tool calls, images.',
|
|
20
|
+
description: 'Free-form content: bubbles, asset lists, tool calls, images. Custom (non-bubble) children render flush with the message edge; wrap them in a ghost ChatMessageBubble to align them with the bubble text column.',
|
|
21
21
|
required: true,
|
|
22
22
|
},
|
|
23
23
|
{
|
|
@@ -67,7 +67,7 @@ export const docsZh = {
|
|
|
67
67
|
description: '发送者上下文包装器,根据发送者角色处理头像、名称、元数据和对齐方式。',
|
|
68
68
|
propDescriptions: {
|
|
69
69
|
sender: '消息发送者,控制对齐和布局。',
|
|
70
|
-
children: '
|
|
70
|
+
children: '自由内容:气泡、资源列表、工具调用、图片。非气泡的自定义子内容默认与消息边缘齐平,可用 ghost 变体的 ChatMessageBubble 包裹使其与气泡文本列对齐。',
|
|
71
71
|
avatar: '消息旁边渲染的头像元素。通常是 Avatar。',
|
|
72
72
|
name: '消息正文上方渲染的发送者名称。当第一个子元素不是气泡时使用。',
|
|
73
73
|
metadata: '消息正文下方渲染的元数据。当最后一个子元素不是气泡时使用。',
|
|
@@ -81,7 +81,7 @@ export const docsDense = {
|
|
|
81
81
|
description: 'sender context wrapper; handles avatar+name+metadata+alignment by sender role',
|
|
82
82
|
propDescriptions: {
|
|
83
83
|
sender: 'who sent; controls alignment+layout',
|
|
84
|
-
children: 'free-form: bubbles, assets, tool calls, images',
|
|
84
|
+
children: 'free-form: bubbles, assets, tool calls, images; wrap non-bubble content in a ghost bubble to align with bubble text column',
|
|
85
85
|
avatar: 'avatar element beside msg; typically Avatar',
|
|
86
86
|
name: 'sender name above body; use when first child is raw (not bubble)',
|
|
87
87
|
displayName: 'sender name above body; use when first child is raw (not bubble)',
|
package/src/Chat/ChatMessage.tsx
CHANGED
|
@@ -41,6 +41,13 @@ import {useTranslator} from '../i18n';
|
|
|
41
41
|
export interface ChatMessageProps extends BaseProps<HTMLElement> {
|
|
42
42
|
ref?: React.Ref<HTMLElement>;
|
|
43
43
|
sender: ChatMessageSender;
|
|
44
|
+
/**
|
|
45
|
+
* Message body — bubbles, tool calls, images, or any free-form content.
|
|
46
|
+
* Custom (non-bubble) children render flush with the message edge; wrap
|
|
47
|
+
* them in a ghost bubble (`<ChatMessageBubble variant="ghost">`) to align
|
|
48
|
+
* them with the bubble's text column, and add `width="100%"` when they
|
|
49
|
+
* should span the full message column.
|
|
50
|
+
*/
|
|
44
51
|
children: ReactNode;
|
|
45
52
|
avatar?: ReactNode;
|
|
46
53
|
/**
|
|
@@ -45,6 +45,11 @@ export const docs = {
|
|
|
45
45
|
type: "'first' | 'middle' | 'last'",
|
|
46
46
|
description: 'Position within a multi-bubble group. Controls corner radius reduction on the sender side. Leave unset for standalone bubbles (full radius).',
|
|
47
47
|
},
|
|
48
|
+
{
|
|
49
|
+
name: 'width',
|
|
50
|
+
type: 'SizeValue',
|
|
51
|
+
description: 'Width of the bubble (number = pixels, string = used as-is). When set, replaces the default max(80%, 280px) width cap. Combine with variant="ghost" to let custom content (an artifact card, attachments) span the full message column.',
|
|
52
|
+
},
|
|
48
53
|
],
|
|
49
54
|
};
|
|
50
55
|
|
|
@@ -59,6 +64,7 @@ export const docsZh = {
|
|
|
59
64
|
name: '气泡上方渲染的发送者名称,与气泡文本内边距对齐。用于消息中的第一个气泡。',
|
|
60
65
|
metadata: '气泡下方渲染的元数据内容,与气泡文本内边距对齐。用于消息中的最后一个气泡。',
|
|
61
66
|
group: '多气泡组中的位置。控制发送者侧的圆角缩减。',
|
|
67
|
+
width: '气泡宽度(数字 = 像素,字符串 = 按原样使用)。设置后替代默认的 max(80%, 280px) 宽度上限;与 ghost 变体组合可让自定义内容占满消息列宽度。',
|
|
62
68
|
},
|
|
63
69
|
};
|
|
64
70
|
|
|
@@ -74,5 +80,6 @@ export const docsDense = {
|
|
|
74
80
|
displayName: 'sender name above bubble, aligned w/ bubble padding',
|
|
75
81
|
metadata: 'metadata below bubble, aligned w/ bubble padding',
|
|
76
82
|
group: 'position in multi-bubble group; controls corner radius reduction',
|
|
83
|
+
width: 'bubble width (number=px, string=as-is); replaces default max(80%,280px) cap; ghost+width="100%" spans full message column',
|
|
77
84
|
},
|
|
78
85
|
};
|
|
@@ -7,6 +7,16 @@ import {ChatMessageBubble} from './ChatMessageBubble';
|
|
|
7
7
|
import {ChatMessageList} from './ChatMessageList';
|
|
8
8
|
import {ChatMessageMetadata} from './ChatMessageMetadata';
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Classes shared between two elements. StyleX emits one atomic class per
|
|
12
|
+
* style declaration, so two elements share a class exactly when they share
|
|
13
|
+
* a declaration — e.g. the same density paddingInline.
|
|
14
|
+
*/
|
|
15
|
+
function sharedClasses(a: Element, b: Element): string[] {
|
|
16
|
+
const bClasses = new Set(Array.from(b.classList));
|
|
17
|
+
return Array.from(a.classList).filter(c => bClasses.has(c));
|
|
18
|
+
}
|
|
19
|
+
|
|
10
20
|
describe('ChatMessageBubble', () => {
|
|
11
21
|
it('renders children', () => {
|
|
12
22
|
render(
|
|
@@ -29,9 +39,7 @@ describe('ChatMessageBubble', () => {
|
|
|
29
39
|
|
|
30
40
|
it('defaults to assistant when no context', () => {
|
|
31
41
|
render(
|
|
32
|
-
<ChatMessageBubble data-testid="bubble">
|
|
33
|
-
Standalone
|
|
34
|
-
</ChatMessageBubble>,
|
|
42
|
+
<ChatMessageBubble data-testid="bubble">Standalone</ChatMessageBubble>,
|
|
35
43
|
);
|
|
36
44
|
const el = screen.getByTestId('bubble');
|
|
37
45
|
expect(el.className).toContain('assistant');
|
|
@@ -41,9 +49,7 @@ describe('ChatMessageBubble', () => {
|
|
|
41
49
|
render(
|
|
42
50
|
<ChatMessageList density="compact">
|
|
43
51
|
<ChatMessage sender="assistant">
|
|
44
|
-
<ChatMessageBubble data-testid="bubble">
|
|
45
|
-
Compact
|
|
46
|
-
</ChatMessageBubble>
|
|
52
|
+
<ChatMessageBubble data-testid="bubble">Compact</ChatMessageBubble>
|
|
47
53
|
</ChatMessage>
|
|
48
54
|
</ChatMessageList>,
|
|
49
55
|
);
|
|
@@ -59,6 +65,89 @@ describe('ChatMessageBubble', () => {
|
|
|
59
65
|
);
|
|
60
66
|
expect(screen.getByTestId('my-bubble')).toBeTruthy();
|
|
61
67
|
});
|
|
68
|
+
|
|
69
|
+
it('ghost variant aligns custom content with the bubble text column (#2574)', () => {
|
|
70
|
+
// Repro from the issue: a raw child renders flush with the message
|
|
71
|
+
// edge — it carries none of the inset the bubble's name slot gets.
|
|
72
|
+
const {container} = render(
|
|
73
|
+
<ChatMessage sender="assistant">
|
|
74
|
+
<ChatMessageBubble name="Navi">Hello</ChatMessageBubble>
|
|
75
|
+
<div data-testid="raw">Artifact card</div>
|
|
76
|
+
<ChatMessageBubble variant="ghost" data-testid="ghost">
|
|
77
|
+
Artifact card
|
|
78
|
+
</ChatMessageBubble>
|
|
79
|
+
</ChatMessage>,
|
|
80
|
+
);
|
|
81
|
+
const nameSlot = container.querySelector('[data-chat-name]')!;
|
|
82
|
+
expect(nameSlot).toBeTruthy();
|
|
83
|
+
|
|
84
|
+
// Unwrapped custom content: no shared inset — the misalignment case.
|
|
85
|
+
expect(sharedClasses(screen.getByTestId('raw'), nameSlot)).toEqual([]);
|
|
86
|
+
|
|
87
|
+
// Ghost-wrapped content: shares the bubble slot's paddingInline
|
|
88
|
+
// declaration, so its text column matches the filled bubble exactly.
|
|
89
|
+
expect(
|
|
90
|
+
sharedClasses(screen.getByTestId('ghost'), nameSlot).length,
|
|
91
|
+
).toBeGreaterThan(0);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('ghost inset tracks message density', () => {
|
|
95
|
+
const renderAtDensity = (density: 'balanced' | 'spacious') => {
|
|
96
|
+
const {container} = render(
|
|
97
|
+
<ChatMessage sender="assistant" density={density}>
|
|
98
|
+
<ChatMessageBubble name="Navi">Hello</ChatMessageBubble>
|
|
99
|
+
<ChatMessageBubble variant="ghost" data-testid={`ghost-${density}`}>
|
|
100
|
+
Card
|
|
101
|
+
</ChatMessageBubble>
|
|
102
|
+
</ChatMessage>,
|
|
103
|
+
);
|
|
104
|
+
return sharedClasses(
|
|
105
|
+
screen.getByTestId(`ghost-${density}`),
|
|
106
|
+
container.querySelector('[data-chat-name]')!,
|
|
107
|
+
);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const balancedInset = renderAtDensity('balanced');
|
|
111
|
+
const spaciousInset = renderAtDensity('spacious');
|
|
112
|
+
|
|
113
|
+
// Both densities align with their own bubble's slot padding...
|
|
114
|
+
expect(balancedInset.length).toBeGreaterThan(0);
|
|
115
|
+
expect(spaciousInset.length).toBeGreaterThan(0);
|
|
116
|
+
// ...and spacious uses a wider inset than balanced.
|
|
117
|
+
expect(spaciousInset).not.toEqual(balancedInset);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('width prop replaces the default width cap', () => {
|
|
121
|
+
render(
|
|
122
|
+
<ChatMessage sender="assistant">
|
|
123
|
+
<ChatMessageBubble data-testid="capped">Text</ChatMessageBubble>
|
|
124
|
+
<ChatMessageBubble variant="ghost" width="100%" data-testid="full">
|
|
125
|
+
Card
|
|
126
|
+
</ChatMessageBubble>
|
|
127
|
+
</ChatMessage>,
|
|
128
|
+
);
|
|
129
|
+
const capped = screen.getByTestId('capped');
|
|
130
|
+
const full = screen.getByTestId('full');
|
|
131
|
+
|
|
132
|
+
// Default bubbles keep the cap; a width bubble replaces it with none.
|
|
133
|
+
expect(getComputedStyle(capped).maxWidth).toMatch(/^max\(80%,\s*280px\)$/);
|
|
134
|
+
expect(getComputedStyle(full).maxWidth).toBe('none');
|
|
135
|
+
// The dynamic width value is set on the element (string passes through).
|
|
136
|
+
expect(full.getAttribute('style')).toContain('100%');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('numeric width is treated as pixels', () => {
|
|
140
|
+
render(
|
|
141
|
+
<ChatMessage sender="assistant">
|
|
142
|
+
<ChatMessageBubble width={420} data-testid="fixed">
|
|
143
|
+
Artifact
|
|
144
|
+
</ChatMessageBubble>
|
|
145
|
+
</ChatMessage>,
|
|
146
|
+
);
|
|
147
|
+
expect(screen.getByTestId('fixed').getAttribute('style')).toContain(
|
|
148
|
+
'420px',
|
|
149
|
+
);
|
|
150
|
+
});
|
|
62
151
|
});
|
|
63
152
|
|
|
64
153
|
describe('ChatMessageMetadata', () => {
|
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
* - If you use bubbles on one side (e.g. assistant), use them consistently
|
|
16
16
|
* for all messages on that side. Use `ghost` variant for content that
|
|
17
17
|
* needs alignment without a visual boundary.
|
|
18
|
+
* - For custom content (cards, attachments, citations) that should span
|
|
19
|
+
* the full message column instead of the default width cap, combine
|
|
20
|
+
* `ghost` with `width="100%"` (#2574).
|
|
18
21
|
* - Put `name` on the first bubble in a message, `metadata` on the last.
|
|
19
22
|
* - For unbubbled messages, use ChatMessage's `name` and `metadata`
|
|
20
23
|
* props instead.
|
|
@@ -36,6 +39,7 @@ import {
|
|
|
36
39
|
} from '../theme/tokens.stylex';
|
|
37
40
|
import {useChatMessageContext} from './ChatContext';
|
|
38
41
|
import {mergeProps} from '../utils';
|
|
42
|
+
import type {SizeValue} from '../utils/types';
|
|
39
43
|
import type {BaseProps} from '../BaseProps';
|
|
40
44
|
import {themeProps} from '../utils/themeProps';
|
|
41
45
|
|
|
@@ -83,6 +87,15 @@ export interface ChatMessageBubbleProps extends BaseProps<HTMLDivElement> {
|
|
|
83
87
|
* Leave unset for standalone bubbles (full radius).
|
|
84
88
|
*/
|
|
85
89
|
group?: 'first' | 'middle' | 'last';
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Width of the bubble.
|
|
93
|
+
* Numbers are treated as pixels, strings are used as-is (e.g. "100%").
|
|
94
|
+
* When set, replaces the default `max(80%, 280px)` width cap; leave unset
|
|
95
|
+
* to keep the cap. Combine with `variant="ghost"` to let custom content
|
|
96
|
+
* (an artifact card, attachments) span the full message column.
|
|
97
|
+
*/
|
|
98
|
+
width?: SizeValue;
|
|
86
99
|
}
|
|
87
100
|
|
|
88
101
|
// =============================================================================
|
|
@@ -189,6 +202,16 @@ const styles = stylex.create({
|
|
|
189
202
|
},
|
|
190
203
|
});
|
|
191
204
|
|
|
205
|
+
// Dynamic styles for sizing props
|
|
206
|
+
const dynamicStyles = stylex.create({
|
|
207
|
+
sizing: (width: SizeValue) => ({
|
|
208
|
+
width,
|
|
209
|
+
// An explicit width replaces the default cap — a full-column or
|
|
210
|
+
// fixed-width bubble shouldn't also be clamped by max(80%, 280px).
|
|
211
|
+
maxWidth: 'none',
|
|
212
|
+
}),
|
|
213
|
+
});
|
|
214
|
+
|
|
192
215
|
// =============================================================================
|
|
193
216
|
// Component
|
|
194
217
|
// =============================================================================
|
|
@@ -216,6 +239,7 @@ export function ChatMessageBubble({
|
|
|
216
239
|
name,
|
|
217
240
|
metadata,
|
|
218
241
|
group,
|
|
242
|
+
width,
|
|
219
243
|
xstyle,
|
|
220
244
|
className,
|
|
221
245
|
style: styleProp,
|
|
@@ -292,6 +316,7 @@ export function ChatMessageBubble({
|
|
|
292
316
|
paddingStyle,
|
|
293
317
|
variant === 'ghost' && styles.paddingBlockNone,
|
|
294
318
|
groupStyle,
|
|
319
|
+
width != null && dynamicStyles.sizing(width),
|
|
295
320
|
xstyle,
|
|
296
321
|
),
|
|
297
322
|
className,
|
|
@@ -81,6 +81,26 @@ const styles = stylex.create({
|
|
|
81
81
|
opacity: 0,
|
|
82
82
|
cursor: 'pointer',
|
|
83
83
|
zIndex: 1,
|
|
84
|
+
minInlineSize: {
|
|
85
|
+
default: null,
|
|
86
|
+
'@media (pointer: coarse)': '24px',
|
|
87
|
+
},
|
|
88
|
+
minBlockSize: {
|
|
89
|
+
default: null,
|
|
90
|
+
'@media (pointer: coarse)': '24px',
|
|
91
|
+
},
|
|
92
|
+
insetBlockStart: {
|
|
93
|
+
default: null,
|
|
94
|
+
'@media (pointer: coarse)': '50%',
|
|
95
|
+
},
|
|
96
|
+
insetInlineStart: {
|
|
97
|
+
default: null,
|
|
98
|
+
'@media (pointer: coarse)': '50%',
|
|
99
|
+
},
|
|
100
|
+
transform: {
|
|
101
|
+
default: null,
|
|
102
|
+
'@media (pointer: coarse)': 'translate(-50%, -50%)',
|
|
103
|
+
},
|
|
84
104
|
},
|
|
85
105
|
inputDisabled: {
|
|
86
106
|
cursor: 'not-allowed',
|
|
@@ -146,6 +146,9 @@ export const docs = {
|
|
|
146
146
|
{className: 'astryx-codeblock-title', visualProps: ['size', 'language']},
|
|
147
147
|
{className: 'astryx-codeblock-copy-button'},
|
|
148
148
|
],
|
|
149
|
+
vars: [
|
|
150
|
+
{name: '--_codeblock-gutter-width', description: 'Width of the line-number gutter, computed from the digit count of the last line so the code column starts at a stable offset.', default: '2ch', private: true},
|
|
151
|
+
],
|
|
149
152
|
},
|
|
150
153
|
usage: {
|
|
151
154
|
description: 'CodeBlock renders syntax-highlighted code with line numbers, a copy button, and optional collapsible sections. Use CodeBlock for multi-line snippets like source files, terminal commands, and configuration examples. Use Code for inline references to function names, variables, or CLI flags within body text.',
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
} from '../theme/tokens.stylex';
|
|
25
25
|
import {Kbd} from '../Kbd';
|
|
26
26
|
import {themeProps} from '../utils/themeProps';
|
|
27
|
+
import {useTranslator} from '../i18n';
|
|
27
28
|
|
|
28
29
|
const styles = stylex.create({
|
|
29
30
|
footer: {
|
|
@@ -85,6 +86,8 @@ export function CommandPaletteFooter({
|
|
|
85
86
|
style,
|
|
86
87
|
...props
|
|
87
88
|
}: CommandPaletteFooterProps) {
|
|
89
|
+
const t = useTranslator();
|
|
90
|
+
|
|
88
91
|
return (
|
|
89
92
|
<div
|
|
90
93
|
ref={ref}
|
|
@@ -100,15 +103,15 @@ export function CommandPaletteFooter({
|
|
|
100
103
|
<span {...stylex.props(styles.hint)}>
|
|
101
104
|
<Kbd keys="up" />
|
|
102
105
|
<Kbd keys="down" />
|
|
103
|
-
|
|
106
|
+
{t('@astryx.commandPalette.footer.navigate')}
|
|
104
107
|
</span>
|
|
105
108
|
<span {...stylex.props(styles.hint)}>
|
|
106
109
|
<Kbd keys="enter" />
|
|
107
|
-
|
|
110
|
+
{t('@astryx.commandPalette.footer.select')}
|
|
108
111
|
</span>
|
|
109
112
|
<span {...stylex.props(styles.hint)}>
|
|
110
113
|
<Kbd keys="escape" />
|
|
111
|
-
|
|
114
|
+
{t('@astryx.commandPalette.footer.close')}
|
|
112
115
|
</span>
|
|
113
116
|
</>
|
|
114
117
|
)}
|
|
@@ -594,10 +594,12 @@ export function DateTimeInput({
|
|
|
594
594
|
|
|
595
595
|
const resolvedTimePlaceholder = useMemo(() => {
|
|
596
596
|
if (isTimeFocused && !timeDisplayValue) {
|
|
597
|
-
return hourFormat === '12h'
|
|
597
|
+
return hourFormat === '12h'
|
|
598
|
+
? t('@astryx.dateTimeInput.timeHint12h')
|
|
599
|
+
: t('@astryx.dateTimeInput.timeHint24h');
|
|
598
600
|
}
|
|
599
601
|
return timePlaceholder;
|
|
600
|
-
}, [isTimeFocused, timeDisplayValue, hourFormat, timePlaceholder]);
|
|
602
|
+
}, [isTimeFocused, timeDisplayValue, hourFormat, timePlaceholder, t]);
|
|
601
603
|
|
|
602
604
|
// --- Unified change handler ---
|
|
603
605
|
const fireChange = useCallback(
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import {describe, it, expect, vi, beforeEach} from 'vitest';
|
|
10
10
|
import {useState} from 'react';
|
|
11
|
-
import {render, screen, waitFor, fireEvent} from '@testing-library/react';
|
|
11
|
+
import {render, screen, waitFor, fireEvent, act} from '@testing-library/react';
|
|
12
12
|
import userEvent from '@testing-library/user-event';
|
|
13
13
|
import {DropdownMenu} from './DropdownMenu';
|
|
14
14
|
import {DropdownMenuItem} from './DropdownMenuItem';
|
|
@@ -708,3 +708,74 @@ describe('DropdownMenuSubMenu theming slots', () => {
|
|
|
708
708
|
).toBeInTheDocument();
|
|
709
709
|
});
|
|
710
710
|
});
|
|
711
|
+
|
|
712
|
+
describe('DropdownMenuSubMenu hover/click guard', () => {
|
|
713
|
+
it('keeps the flyout open when a hover-open is immediately clicked', async () => {
|
|
714
|
+
vi.useFakeTimers({shouldAdvanceTime: true});
|
|
715
|
+
const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime});
|
|
716
|
+
render(<MoveMenu />);
|
|
717
|
+
|
|
718
|
+
await user.click(screen.getByRole('button', {name: /Actions/}));
|
|
719
|
+
const trigger = screen.getByRole('menuitem', {
|
|
720
|
+
name: /Move to/,
|
|
721
|
+
hidden: true,
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
await user.hover(trigger);
|
|
725
|
+
act(() => {
|
|
726
|
+
vi.advanceTimersByTime(300);
|
|
727
|
+
});
|
|
728
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
|
729
|
+
|
|
730
|
+
await user.click(trigger);
|
|
731
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
|
732
|
+
expect(
|
|
733
|
+
screen.getByRole('menuitem', {name: 'Folder A', hidden: true}),
|
|
734
|
+
).toHaveFocus();
|
|
735
|
+
|
|
736
|
+
vi.useRealTimers();
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
it('closes on a click that lands well after the hover-open', async () => {
|
|
740
|
+
vi.useFakeTimers({shouldAdvanceTime: true});
|
|
741
|
+
const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime});
|
|
742
|
+
render(<MoveMenu />);
|
|
743
|
+
|
|
744
|
+
await user.click(screen.getByRole('button', {name: /Actions/}));
|
|
745
|
+
const trigger = screen.getByRole('menuitem', {
|
|
746
|
+
name: /Move to/,
|
|
747
|
+
hidden: true,
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
await user.hover(trigger);
|
|
751
|
+
act(() => {
|
|
752
|
+
vi.advanceTimersByTime(300);
|
|
753
|
+
});
|
|
754
|
+
act(() => {
|
|
755
|
+
vi.advanceTimersByTime(1200);
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
await user.click(trigger);
|
|
759
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
760
|
+
|
|
761
|
+
vi.useRealTimers();
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
it('moves focus into the flyout synchronously on a click-open', async () => {
|
|
765
|
+
const user = userEvent.setup();
|
|
766
|
+
render(<MoveMenu />);
|
|
767
|
+
|
|
768
|
+
await user.click(screen.getByRole('button', {name: /Actions/}));
|
|
769
|
+
const trigger = screen.getByRole('menuitem', {
|
|
770
|
+
name: /Move to/,
|
|
771
|
+
hidden: true,
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
await user.click(trigger);
|
|
775
|
+
|
|
776
|
+
// No waitFor: a deferred (rAF) focus would fail here.
|
|
777
|
+
expect(
|
|
778
|
+
screen.getByRole('menuitem', {name: 'Folder A', hidden: true}),
|
|
779
|
+
).toHaveFocus();
|
|
780
|
+
});
|
|
781
|
+
});
|
|
@@ -299,12 +299,16 @@ export function DropdownMenuSubMenu(
|
|
|
299
299
|
|
|
300
300
|
// Hover-intent: entering the trigger opens after a short delay; leaving
|
|
301
301
|
// either surface closes after a delay. Hover-open does not steal focus.
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
302
|
+
// Hover intent and the shared hover→click guard only: this level owns its own
|
|
303
|
+
// click handling, roving focus and typeahead. popover="manual", so the
|
|
304
|
+
// invoker wiring other consumers need does not apply.
|
|
305
|
+
const {triggerProps, contentProps, confirmHoverOpen} =
|
|
306
|
+
useMenuHover<HTMLDivElement>({
|
|
307
|
+
show: showLayer,
|
|
308
|
+
hide: hideLayer,
|
|
309
|
+
isOpen,
|
|
310
|
+
isEnabled: canOpen,
|
|
311
|
+
});
|
|
308
312
|
|
|
309
313
|
const open = useCallback(
|
|
310
314
|
(options?: {focusFirst?: boolean}) => {
|
|
@@ -313,18 +317,12 @@ export function DropdownMenuSubMenu(
|
|
|
313
317
|
}
|
|
314
318
|
layer.show();
|
|
315
319
|
if (options?.focusFirst) {
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
// arrow keys rove the parent while the empty flyout stays open.
|
|
323
|
-
const focusedItem = focusFirst();
|
|
324
|
-
if (!focusedItem) {
|
|
325
|
-
menuRef.current?.focus();
|
|
326
|
-
}
|
|
327
|
-
});
|
|
320
|
+
// Synchronous by design — see the focus note in useMenuHover. A
|
|
321
|
+
// still-loading flyout has no focusable item, so fall back to the
|
|
322
|
+
// container: keyboard ownership must leave the parent list either way.
|
|
323
|
+
if (!focusFirst()) {
|
|
324
|
+
menuRef.current?.focus();
|
|
325
|
+
}
|
|
328
326
|
}
|
|
329
327
|
},
|
|
330
328
|
[canOpen, layer, focusFirst, menuRef],
|
|
@@ -354,13 +352,19 @@ export function DropdownMenuSubMenu(
|
|
|
354
352
|
if (isDisabled) {
|
|
355
353
|
return;
|
|
356
354
|
}
|
|
357
|
-
//
|
|
355
|
+
// Toggles, except for the click that follows a hover-open (#3121).
|
|
358
356
|
if (isOpen) {
|
|
357
|
+
if (confirmHoverOpen()) {
|
|
358
|
+
if (!focusFirst()) {
|
|
359
|
+
menuRef.current?.focus();
|
|
360
|
+
}
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
359
363
|
close({focusTrigger: true});
|
|
360
364
|
} else {
|
|
361
365
|
open({focusFirst: true});
|
|
362
366
|
}
|
|
363
|
-
}, [isDisabled, isOpen, open, close]);
|
|
367
|
+
}, [isDisabled, isOpen, open, close, confirmHoverOpen, focusFirst, menuRef]);
|
|
364
368
|
|
|
365
369
|
const handleTriggerKeyDown = useCallback(
|
|
366
370
|
(e: React.KeyboardEvent) => {
|