@astryxdesign/core 0.4.1 → 0.4.2-canary.464a445
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 +13 -7
- package/dist/astryx.umd.js +50 -50
- package/dist/astryx.umd.js.map +4 -4
- package/dist/hooks/index.d.ts +1 -1
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +1 -1
- 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 +8 -2
- package/dist/theme/defineTheme.d.ts.map +1 -1
- package/dist/theme/defineTheme.js +8 -1
- 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/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/index.ts +5 -1
- 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.ts +9 -0
- 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/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
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) => {
|
|
@@ -114,7 +114,7 @@ export const docs = {
|
|
|
114
114
|
{ guidance: false, description: 'Place critical actions or required information inside a hover card; users may miss content that only appears on hover.' },
|
|
115
115
|
{ guidance: false, description: 'Use a hover card when a simple Tooltip or Popover would suffice.' },
|
|
116
116
|
{ guidance: false, description: 'Use a HoverCard for content the user must interact with; it disappears when the cursor leaves.' },
|
|
117
|
-
{ guidance:
|
|
117
|
+
{ guidance: true, description: 'Prefer placing HoverCard in a block context rather than directly in a <p>, heading, or link. Those placements are supported when necessary through a corrective portal, but the DOM and tab order may differ.' },
|
|
118
118
|
],
|
|
119
119
|
anatomy: [
|
|
120
120
|
{name: 'Trigger', required: true, description: 'The element that opens the hover card on hover or focus: a button, link, or inline text.'},
|
|
@@ -229,7 +229,7 @@ export const docsZh = {
|
|
|
229
229
|
{ guidance: false, description: 'Place critical actions or required information inside a hover card; users may miss content that only appears on hover.' },
|
|
230
230
|
{ guidance: false, description: 'Use a hover card when a simple Tooltip or Popover would suffice.' },
|
|
231
231
|
{ guidance: false, description: 'Use a HoverCard for content the user must interact with; it disappears when the cursor leaves.' },
|
|
232
|
-
{ guidance:
|
|
232
|
+
{ guidance: true, description: 'Prefer placing HoverCard in a block context rather than directly in a <p>, heading, or link. Those placements are supported when necessary through a corrective portal, but the DOM and tab order may differ.' },
|
|
233
233
|
],
|
|
234
234
|
},
|
|
235
235
|
};
|
|
@@ -247,7 +247,7 @@ export const docsDense = {
|
|
|
247
247
|
{ guidance: false, description: 'Place critical actions or required information inside a hover card; users may miss content that only appears on hover.' },
|
|
248
248
|
{ guidance: false, description: 'Use a hover card when a simple Tooltip or Popover would suffice.' },
|
|
249
249
|
{ guidance: false, description: 'Use a HoverCard for content the user must interact with; it disappears when the cursor leaves.' },
|
|
250
|
-
{ guidance:
|
|
250
|
+
{ guidance: true, description: 'Prefer a block context over a direct <p>, heading, or link placement. Those placements work when necessary through a corrective portal, but DOM/tab order may differ.' },
|
|
251
251
|
],
|
|
252
252
|
},
|
|
253
253
|
components: [
|