From e552adbc30c3716866dd266a548848bb043dfd1f Mon Sep 17 00:00:00 2001 From: Tarun Tomar Date: Wed, 15 Jul 2026 15:22:18 +0530 Subject: [PATCH 1/4] feat(user-message): support grouped multi-attachment messages Add swc-user-message-attachment: a minimal, presentation-only tile (media/card, no dismiss, no click behavior) kept as a separate component from swc-upload-artifact so the compose-time and sent-message tiles can diverge independently. Extend swc-user-message with type="attachments": media attachments lay out in a 4-column grid, card attachments stack full-width beneath it, and overflow beyond 4 media tiles collapses behind a show all/less disclosure (same open/aria-expanded/aria-controls shape as swc-message-sources), firing swc-user-message-toggle. Grouping and slot-routing are driven by a MutationController watching the light DOM, following the same pattern as swc-response-status. Adds unit tests for grouping/overflow/toggle behavior, an ARIA snapshot test, stories, and docs. Co-Authored-By: Claude Sonnet 5 --- .../user-message/UserMessage.ts | 227 +++++++++++++++--- .../conversational-ai/user-message/index.ts | 17 ++ .../stories/user-message.stories.ts | 58 ++++- .../test/user-message.a11y.spec.ts | 13 + .../user-message/test/user-message.test.ts | 127 ++++++++++ .../UserMessageAttachment.ts | 111 +++++++++ .../user-message-attachment/index.ts | 24 ++ .../user-message-attachment.css | 166 +++++++++++++ .../user-message/user-message.css | 65 +++++ .../user-message/user-message.mdx | 20 ++ 10 files changed, 800 insertions(+), 28 deletions(-) create mode 100644 2nd-gen/packages/swc/patterns/conversational-ai/user-message/user-message-attachment/UserMessageAttachment.ts create mode 100644 2nd-gen/packages/swc/patterns/conversational-ai/user-message/user-message-attachment/index.ts create mode 100644 2nd-gen/packages/swc/patterns/conversational-ai/user-message/user-message-attachment/user-message-attachment.css diff --git a/2nd-gen/packages/swc/patterns/conversational-ai/user-message/UserMessage.ts b/2nd-gen/packages/swc/patterns/conversational-ai/user-message/UserMessage.ts index c6f26e7ecce..76ff21bd895 100644 --- a/2nd-gen/packages/swc/patterns/conversational-ai/user-message/UserMessage.ts +++ b/2nd-gen/packages/swc/patterns/conversational-ai/user-message/UserMessage.ts @@ -10,59 +10,232 @@ * governing permissions and limitations under the License. */ -import { CSSResultArray, html, TemplateResult } from 'lit'; -import { property } from 'lit/decorators.js'; +import { CSSResultArray, html, PropertyValues, TemplateResult } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { MutationController } from '@lit-labs/observers/mutation-controller.js'; +import { Chevron75Icon } from '@adobe/spectrum-wc/icon/elements/index.js'; import { SpectrumElement } from '@adobe/spectrum-wc-core/element/index.js'; +import '@adobe/spectrum-wc/components/icon/swc-icon.js'; + +import { uniqueId } from '../../../utils/id.js'; +import { UserMessageAttachment } from './user-message-attachment/UserMessageAttachment.js'; + import styles from './user-message.css'; -export type UserMessageType = 'copy' | 'card' | 'media'; +export type UserMessageType = 'copy' | 'card' | 'media' | 'attachments'; + +/** Grid tiles beyond this count collapse behind "Show all" by default. */ +const VISIBLE_MEDIA_COUNT = 4; /** * User-authored conversation bubble for conversational AI pattern exploration. - * Default slot content is rendered only when `type="copy"` and ignored when - * `type="card"` or `type="media"`. + * Default slot content is rendered only when `type="copy"` and ignored otherwise. + * + * `type="attachments"` accepts many `` children: + * `type="media"` attachments lay out in a 4-column grid (collapsing behind a + * "Show all" disclosure beyond {@link VISIBLE_MEDIA_COUNT}), `type="card"` + * attachments stack full-width beneath the grid. `swc-user-message` owns this + * grouping and disclosure; the attachment tiles are presentation-only. * * @element swc-user-message * @slot - Message copy content when `type="copy"`. * @slot thumbnail - Attachment preview when `type="card"` or `type="media"`. * @slot title - Attachment title when `type="card"` or `type="media"`. * @slot subtitle - Attachment subtitle when `type="card"` or `type="media"`. + * @slot - `` elements when `type="attachments"`. + * @fires swc-user-message-toggle - Dispatched when the "Show all/less" disclosure is toggled (`type="attachments"` only). + * Detail: `{ open: boolean }` */ export class UserMessage extends SpectrumElement { + private readonly attachmentsPanelId = uniqueId('swc-user-message-panel'); + /** * Visual content type for the user message bubble. */ @property({ type: String, reflect: true }) public type: UserMessageType = 'copy'; + /** Whether the attachments grid's "Show all" disclosure is open (`type="attachments"` only). */ + @property({ type: Boolean, reflect: true }) + public open = false; + + /** Label for the disclosure button when collapsed. */ + @property({ type: String, attribute: 'show-all-label' }) + public showAllLabel = 'Show all'; + + /** Label for the disclosure button when expanded. */ + @property({ type: String, attribute: 'show-less-label' }) + public showLessLabel = 'Show less'; + + @state() + private _mediaCount = 0; + + @state() + private _cardCount = 0; + + @state() + private _hasMediaOverflow = false; + public static override get styles(): CSSResultArray { return [styles]; } - protected override render(): TemplateResult { - return this.type === 'copy' - ? html` - - ` - : html` -
-
- -
-
-
- -
-
- -
-
+ public constructor() { + super(); + + new MutationController(this, { + config: { + attributes: true, + attributeFilter: ['type'], + childList: true, + }, + callback: () => { + this._routeAttachments(); + }, + }); + } + + public override connectedCallback(): void { + super.connectedCallback(); + this._routeAttachments(); + } + + protected override willUpdate(changed: PropertyValues): void { + if (changed.has('open')) { + this._routeAttachments(); + } + } + + private _isAttachmentElement( + element: Element + ): element is UserMessageAttachment { + return ( + element instanceof UserMessageAttachment || + element.localName === 'swc-user-message-attachment' + ); + } + + private _routeAttachments(): void { + if (this.type !== 'attachments') { + return; + } + + const attachments = Array.from(this.children).filter( + (element): element is UserMessageAttachment => + this._isAttachmentElement(element) + ); + const mediaAttachments = attachments.filter((el) => el.type !== 'card'); + const cardAttachments = attachments.filter((el) => el.type === 'card'); + const hasOverflow = mediaAttachments.length > VISIBLE_MEDIA_COUNT; + + for (const el of attachments) { + const targetSlot = + el.type === 'card' ? 'attachment-card' : 'attachment-media'; + if (el.getAttribute('slot') !== targetSlot) { + el.setAttribute('slot', targetSlot); + } + } + + mediaAttachments.forEach((el, index) => { + el.hidden = hasOverflow && !this.open && index >= VISIBLE_MEDIA_COUNT; + }); + + this._mediaCount = mediaAttachments.length; + this._cardCount = cardAttachments.length; + this._hasMediaOverflow = hasOverflow; + } + + private _handleAttachmentsToggle(): void { + this.open = !this.open; + this.dispatchEvent( + new CustomEvent('swc-user-message-toggle', { + bubbles: true, + composed: true, + detail: { open: this.open }, + }) + ); + } + + private _renderAttachmentsToggle(): TemplateResult | '' { + if (!this._hasMediaOverflow) { + return ''; + } + + const label = this.open ? this.showLessLabel : this.showAllLabel; + + return html` + + `; + } + + private _renderAttachments(): TemplateResult { + return html` +
+
+ +
+
+ +
+ ${this._renderAttachmentsToggle()} +
+ `; + } + + private _renderSingleAttachment(): TemplateResult { + return html` +
+
+ +
+
+
+
- `; +
+ +
+
+
+ `; + } + + protected override render(): TemplateResult { + if (this.type === 'copy') { + return html` + + `; + } + + return this.type === 'attachments' + ? this._renderAttachments() + : this._renderSingleAttachment(); } } diff --git a/2nd-gen/packages/swc/patterns/conversational-ai/user-message/index.ts b/2nd-gen/packages/swc/patterns/conversational-ai/user-message/index.ts index 28320b39c8d..0e95c093b7e 100644 --- a/2nd-gen/packages/swc/patterns/conversational-ai/user-message/index.ts +++ b/2nd-gen/packages/swc/patterns/conversational-ai/user-message/index.ts @@ -9,4 +9,21 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ +import { defineElement } from '@adobe/spectrum-wc-core/element/index.js'; + +import './user-message-attachment/index.js'; + +import { UserMessageAttachment } from './user-message-attachment/UserMessageAttachment.js'; +import { UserMessage } from './UserMessage.js'; + export * from './UserMessage.js'; +export * from './user-message-attachment/UserMessageAttachment.js'; + +declare global { + interface HTMLElementTagNameMap { + 'swc-user-message': UserMessage; + 'swc-user-message-attachment': UserMessageAttachment; + } +} + +defineElement('swc-user-message', UserMessage); diff --git a/2nd-gen/packages/swc/patterns/conversational-ai/user-message/stories/user-message.stories.ts b/2nd-gen/packages/swc/patterns/conversational-ai/user-message/stories/user-message.stories.ts index 1ba97d028f9..fcdc484e5bd 100644 --- a/2nd-gen/packages/swc/patterns/conversational-ai/user-message/stories/user-message.stories.ts +++ b/2nd-gen/packages/swc/patterns/conversational-ai/user-message/stories/user-message.stories.ts @@ -28,7 +28,7 @@ delete (argTypes as Record).content; argTypes.type = { ...argTypes.type, control: { type: 'select' }, - options: ['copy', 'card', 'media'], + options: ['copy', 'card', 'media', 'attachments'], table: { category: 'attributes', defaultValue: { summary: 'copy' }, @@ -169,6 +169,62 @@ export const Content: Story = { tags: ['options'], }; +const mediaAttachment = (src: string, alt: string) => html` + + ${alt} + +`; + +export const Attachments: Story = { + render: () => html` + + + ${mediaAttachment( + 'https://picsum.photos/id/64/240/240', + 'User portrait' + )} + ${mediaAttachment( + 'https://picsum.photos/id/823/240/240', + 'Team profile' + )} + ${mediaAttachment('https://picsum.photos/id/56/240/240', 'Texture')} + +
+ colorful-video-1770010806412.mp4 + 18 MB +
+
+
+ `, + tags: ['options'], +}; + +// ────────────────────────────── +// BEHAVIORS STORIES +// ────────────────────────────── + +export const AttachmentsDisclosure: Story = { + render: () => html` + + + ${mediaAttachment('https://picsum.photos/id/64/240/240', 'Photo 1')} + ${mediaAttachment('https://picsum.photos/id/823/240/240', 'Photo 2')} + ${mediaAttachment('https://picsum.photos/id/56/240/240', 'Photo 3')} + ${mediaAttachment('https://picsum.photos/id/65/240/240', 'Photo 4')} + ${mediaAttachment('https://picsum.photos/id/48/240/240', 'Photo 5')} + ${mediaAttachment('https://picsum.photos/id/28/240/240', 'Photo 6')} + + + `, + tags: ['behaviors'], +}; +AttachmentsDisclosure.storyName = 'Show all / show less'; + // ──────────────────────────────── // ACCESSIBILITY STORY // ──────────────────────────────── diff --git a/2nd-gen/packages/swc/patterns/conversational-ai/user-message/test/user-message.a11y.spec.ts b/2nd-gen/packages/swc/patterns/conversational-ai/user-message/test/user-message.a11y.spec.ts index e96fa10ed77..a5c8102286d 100644 --- a/2nd-gen/packages/swc/patterns/conversational-ai/user-message/test/user-message.a11y.spec.ts +++ b/2nd-gen/packages/swc/patterns/conversational-ai/user-message/test/user-message.a11y.spec.ts @@ -64,4 +64,17 @@ test.describe('UserMessage - ARIA Snapshots', () => { - text: Hilton commercial assets 2026 `); }); + + test('should expose the show all disclosure and grouped attachments', async ({ + page, + }) => { + const root = await gotoStory( + page, + 'patterns-conversational-ai-user-message--attachments-disclosure', + 'swc-user-message' + ); + await expect(root).toMatchAriaSnapshot(` + - button "Show all" [expanded=false] + `); + }); }); diff --git a/2nd-gen/packages/swc/patterns/conversational-ai/user-message/test/user-message.test.ts b/2nd-gen/packages/swc/patterns/conversational-ai/user-message/test/user-message.test.ts index 6dc2b48cd51..06454fcfdbc 100644 --- a/2nd-gen/packages/swc/patterns/conversational-ai/user-message/test/user-message.test.ts +++ b/2nd-gen/packages/swc/patterns/conversational-ai/user-message/test/user-message.test.ts @@ -170,6 +170,133 @@ export const DefaultSlotHiddenForAttachmentTypesTest: Story = { }, }; +function attachmentsMarkup(mediaCount: number, cardCount: number): string { + const media = Array.from( + { length: mediaCount }, + (_, index) => ` + +
+
+ ` + ).join(''); + + const cards = Array.from( + { length: cardCount }, + (_, index) => ` + +
+ File ${index + 1} + 1 MB +
+ ` + ).join(''); + + return media + cards; +} + +export const AttachmentsGroupingTest: Story = { + ...Overview, + play: async ({ canvasElement, step }) => { + const el = await getComponent( + canvasElement, + 'swc-user-message' + ); + + await step( + 'media count at or below the visible limit renders no disclosure', + async () => { + el.type = 'attachments'; + el.open = false; + el.innerHTML = attachmentsMarkup(4, 1); + await el.updateComplete; + + const toggle = el.shadowRoot?.querySelector( + '.swc-UserMessage-attachments-toggle' + ); + expect(toggle).toBeNull(); + + const mediaChildren = Array.from(el.children).filter( + (child) => child.getAttribute('type') !== 'card' + ); + const cardChildren = Array.from(el.children).filter( + (child) => child.getAttribute('type') === 'card' + ); + expect( + mediaChildren.every((child) => !child.hasAttribute('hidden')) + ).toBe(true); + expect( + cardChildren.every((child) => !child.hasAttribute('hidden')) + ).toBe(true); + expect( + mediaChildren.every( + (child) => child.getAttribute('slot') === 'attachment-media' + ) + ).toBe(true); + expect( + cardChildren.every( + (child) => child.getAttribute('slot') === 'attachment-card' + ) + ).toBe(true); + } + ); + + await step( + 'media count above the visible limit hides overflow tiles and shows the disclosure', + async () => { + el.type = 'attachments'; + el.open = false; + el.innerHTML = attachmentsMarkup(6, 0); + await el.updateComplete; + await new Promise((resolve) => requestAnimationFrame(resolve)); + await el.updateComplete; + + const toggle = el.shadowRoot?.querySelector( + '.swc-UserMessage-attachments-toggle' + ); + expect(toggle).toBeTruthy(); + expect(toggle?.getAttribute('aria-expanded')).toBe('false'); + expect(toggle?.textContent?.trim()).toContain('Show all'); + + const mediaChildren = Array.from(el.children); + expect(mediaChildren.slice(0, 4).some((child) => child.hidden)).toBe( + false + ); + expect(mediaChildren.slice(4).every((child) => child.hidden)).toBe( + true + ); + } + ); + + await step( + 'clicking the disclosure reveals overflow tiles and fires the toggle event', + async () => { + let detail: { open: boolean } | undefined; + el.addEventListener( + 'swc-user-message-toggle', + (event) => { + detail = (event as CustomEvent<{ open: boolean }>).detail; + }, + { once: true } + ); + + const toggle = el.shadowRoot?.querySelector( + '.swc-UserMessage-attachments-toggle' + ); + toggle?.click(); + await el.updateComplete; + + expect(el.open).toBe(true); + expect(detail?.open).toBe(true); + expect(toggle?.getAttribute('aria-expanded')).toBe('true'); + expect(toggle?.textContent?.trim()).toContain('Show less'); + expect(Array.from(el.children).every((child) => !child.hidden)).toBe( + true + ); + } + ); + }, +}; + const longSpacedCopy = 'This is a deliberately long line of user copy that should wrap within a narrow column without horizontal overflow. '.repeat( 3 diff --git a/2nd-gen/packages/swc/patterns/conversational-ai/user-message/user-message-attachment/UserMessageAttachment.ts b/2nd-gen/packages/swc/patterns/conversational-ai/user-message/user-message-attachment/UserMessageAttachment.ts new file mode 100644 index 00000000000..821183eb2c6 --- /dev/null +++ b/2nd-gen/packages/swc/patterns/conversational-ai/user-message/user-message-attachment/UserMessageAttachment.ts @@ -0,0 +1,111 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { CSSResultArray, html, TemplateResult } from 'lit'; +import { property, queryAssignedElements } from 'lit/decorators.js'; + +import { SpectrumElement } from '@spectrum-web-components/core/element/index.js'; + +import styles from './user-message-attachment.css'; + +/** + * One attachment tile inside ``. + * + * A deliberately minimal, presentation-only tile: no dismiss affordance, no + * actions slot. `swc-user-message` owns all grouping (media grid vs. stacked + * files), show-all/less disclosure, and layout; this component only renders + * its own thumbnail/title/subtitle/badge. + * + * @element swc-user-message-attachment + * + * @slot thumbnail - Shared visual slot for icon/thumbnail/preview image. + * @slot badge - Optional file-type badge rendered over `type="media"` previews (for example, "PDF"). + * @slot title - Primary text label. + * @slot subtitle - Secondary text label. + */ +export class UserMessageAttachment extends SpectrumElement { + /** Visual treatment type for this attachment: `media` renders a grid tile, `card` renders a stacked row. */ + @property({ type: String, reflect: true }) + public type: 'card' | 'media' = 'media'; + + @queryAssignedElements({ slot: 'badge', flatten: true }) + private _assignedBadge!: HTMLElement[]; + + public static override get styles(): CSSResultArray { + return [styles]; + } + + private _hasBadgeContent(): boolean { + return (this._assignedBadge?.length ?? 0) > 0; + } + + private _handleBadgeSlotChange(): void { + this.requestUpdate(); + } + + private _renderBadge(): TemplateResult { + if (!this._hasBadgeContent()) { + return html` + + `; + } + + return html` +
+ +
+ `; + } + + private _renderMediaSurface(): TemplateResult { + return html` +
+
+ +
+ ${this._renderBadge()} +
+ `; + } + + private _renderCardSurface(): TemplateResult { + return html` +
+
+ +
+
+
+ +
+
+ +
+
+
+ `; + } + + protected override render(): TemplateResult { + return html` +
+ ${this.type === 'card' + ? this._renderCardSurface() + : this._renderMediaSurface()} +
+ `; + } +} diff --git a/2nd-gen/packages/swc/patterns/conversational-ai/user-message/user-message-attachment/index.ts b/2nd-gen/packages/swc/patterns/conversational-ai/user-message/user-message-attachment/index.ts new file mode 100644 index 00000000000..4d5f6a436a3 --- /dev/null +++ b/2nd-gen/packages/swc/patterns/conversational-ai/user-message/user-message-attachment/index.ts @@ -0,0 +1,24 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { defineElement } from '@spectrum-web-components/core/element/index.js'; + +import { UserMessageAttachment } from './UserMessageAttachment.js'; + +export * from './UserMessageAttachment.js'; + +declare global { + interface HTMLElementTagNameMap { + 'swc-user-message-attachment': UserMessageAttachment; + } +} + +defineElement('swc-user-message-attachment', UserMessageAttachment); diff --git a/2nd-gen/packages/swc/patterns/conversational-ai/user-message/user-message-attachment/user-message-attachment.css b/2nd-gen/packages/swc/patterns/conversational-ai/user-message/user-message-attachment/user-message-attachment.css new file mode 100644 index 00000000000..750015d01f0 --- /dev/null +++ b/2nd-gen/packages/swc/patterns/conversational-ai/user-message/user-message-attachment/user-message-attachment.css @@ -0,0 +1,166 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +:host { + display: block; + position: relative; + inline-size: 100%; + block-size: 100%; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +.swc-UserMessageAttachment { + inline-size: 100%; + block-size: 100%; +} + +.swc-UserMessageAttachment-meta { + display: flex; + flex-direction: column; + gap: token("spacing-50"); + min-inline-size: 0; +} + +.swc-UserMessageAttachment-title { + font-family: token("sans-serif-font"); + font-size: token("font-size-100"); + font-weight: token("bold-font-weight"); + line-height: token("line-height-font-size-100"); + color: token("gray-900"); +} + +.swc-UserMessageAttachment-subtitle { + font-family: token("sans-serif-font"); + font-size: token("font-size-75"); + font-weight: token("regular-font-weight"); + line-height: token("line-height-font-size-75"); + color: token("gray-800"); +} + +/* Card: stacked-row tile (icon/preview + title + subtitle). */ +:host([type="card"]) .swc-UserMessageAttachment-surface { + display: flex; + gap: token("spacing-300"); + align-items: center; + min-block-size: var(--swc-user-message-attachment-card-min-block-size, 68px); + padding: token("spacing-300"); + background: token("gray-50"); + border: 1px solid transparent; + border-radius: token("corner-radius-medium-default"); +} + +:host([type="card"]) .swc-UserMessageAttachment-thumbnail { + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; +} + +:host([type="card"]) .swc-UserMessageAttachment-thumbnail ::slotted(*) { + box-sizing: border-box; + flex-shrink: 0; + inline-size: var(--swc-user-message-attachment-card-thumbnail-inline-size, 32px); + block-size: var(--swc-user-message-attachment-card-thumbnail-block-size, 32px); + background: token("gray-100"); + border: 1px solid transparent; + border-radius: token("corner-radius-75"); +} + +:host([type="card"]) .swc-UserMessageAttachment-meta { + flex: 1; + align-items: flex-start; +} + +:host([type="card"]) .swc-UserMessageAttachment-title, +:host([type="card"]) .swc-UserMessageAttachment-subtitle { + inline-size: 100%; + min-inline-size: 0; + text-align: start; +} + +:host([type="card"]) .swc-UserMessageAttachment-title ::slotted(*), +:host([type="card"]) .swc-UserMessageAttachment-subtitle ::slotted(*) { + display: block; + min-inline-size: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Media: fixed square grid tile. */ +:host([type="media"]) { + inline-size: var(--swc-user-message-attachment-media-size, 120px); + block-size: var(--swc-user-message-attachment-media-size, 120px); +} + +:host([type="media"]) .swc-UserMessageAttachment-surface { + display: block; + position: relative; + inline-size: 100%; + block-size: 100%; + border-radius: token("corner-radius-medium-default"); + overflow: hidden; +} + +:host([type="media"]) .swc-UserMessageAttachment-thumbnail { + display: block; + position: relative; + inline-size: 100%; + block-size: 100%; + background: token("gray-100"); + border: 1px solid transparent; + border-radius: token("corner-radius-medium-default"); + overflow: hidden; +} + +:host([type="media"]) .swc-UserMessageAttachment-thumbnail ::slotted(*) { + display: block; + position: absolute; + inset: 0; + inline-size: 100%; + block-size: 100%; + object-fit: cover; +} + +:host([type="media"]) .swc-UserMessageAttachment-badge { + display: inline-flex; + position: absolute; + inset-block-end: token("spacing-50"); + inset-inline-start: token("spacing-50"); + z-index: 1; + align-items: center; + max-inline-size: calc(100% - (2 * token("spacing-50"))); + min-block-size: 24px; + padding-block: token("spacing-75"); + padding-inline: calc(token("spacing-75") + token("spacing-50") + token("corner-radius-75")); + font-family: token("sans-serif-font"); + font-size: token("font-size-75"); + font-weight: token("medium-font-weight"); + line-height: token("line-height-font-size-75"); + color: token("gray-25"); + background: token("transparent-black-700"); + border-radius: token("corner-radius-400"); +} + +:host([type="media"]) .swc-UserMessageAttachment-badge ::slotted(*) { + display: block; + min-inline-size: 0; + max-inline-size: 152px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/2nd-gen/packages/swc/patterns/conversational-ai/user-message/user-message.css b/2nd-gen/packages/swc/patterns/conversational-ai/user-message/user-message.css index bee1d8bf8d2..f25c5ea3c31 100644 --- a/2nd-gen/packages/swc/patterns/conversational-ai/user-message/user-message.css +++ b/2nd-gen/packages/swc/patterns/conversational-ai/user-message/user-message.css @@ -56,6 +56,13 @@ padding: var(--swc-user-message-media-padding, token("spacing-100")); } +/* Attachments: fits a 4-column media grid; not yet in the Figma sizing spec above, revisit with design. */ +:host([type="attachments"]) { + inline-size: var(--swc-user-message-attachments-inline-size, 420px); + max-inline-size: 536px; + padding: var(--swc-user-message-attachments-padding, token("spacing-100")); +} + .swc-UserMessage-attachment { display: flex; min-inline-size: 0; @@ -174,3 +181,61 @@ overflow: hidden; text-overflow: ellipsis; } + +/* Attachments: 4-column media grid + full-width stacked file rows. */ +.swc-UserMessage-attachments { + display: flex; + flex-direction: column; + gap: token("spacing-200"); +} + +.swc-UserMessage-attachments-media { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: token("spacing-100"); +} + +.swc-UserMessage-attachments-media[hidden] { + display: none; +} + +/* Assigned nodes become direct grid items instead of the slot itself. */ +.swc-UserMessage-attachments-media slot { + display: contents; +} + +.swc-UserMessage-attachments-files { + display: flex; + flex-direction: column; + gap: token("spacing-100"); +} + +.swc-UserMessage-attachments-files[hidden] { + display: none; +} + +.swc-UserMessage-attachments-files slot { + display: contents; +} + +.swc-UserMessage-attachments-toggle { + display: inline-flex; + gap: token("spacing-75"); + align-items: center; + padding: 0; + font-family: token("sans-serif-font"); + font-size: token("font-size-75"); + font-weight: token("bold-font-weight"); + color: token("gray-800"); + background: transparent; + border: none; + cursor: pointer; +} + +.swc-UserMessage-attachments-chevron { + transition: transform token("animation-duration-100") ease; +} + +.swc-UserMessage-attachments-chevron--down { + transform: rotate(90deg); +} diff --git a/2nd-gen/packages/swc/patterns/conversational-ai/user-message/user-message.mdx b/2nd-gen/packages/swc/patterns/conversational-ai/user-message/user-message.mdx index a6c97e542c7..9574f763aab 100644 --- a/2nd-gen/packages/swc/patterns/conversational-ai/user-message/user-message.mdx +++ b/2nd-gen/packages/swc/patterns/conversational-ai/user-message/user-message.mdx @@ -28,6 +28,24 @@ Bubble sizing and padding are inferred from slotted content: +### Attachments + +`type="attachments"` accepts many `` children in one bubble, for sending several files together: + +- **`type="media"`** attachments (images) lay out in a fixed 4-column grid +- **`type="card"`** attachments (documents, video, other non-image files) stack full-width, one per row, beneath the grid +- `swc-user-message` groups attachments by their own `type`, regardless of the order they're slotted in + + + +## Behaviors + +### Show all / show less + +When there are more than 4 media attachments, the extra tiles collapse behind a "Show all" disclosure below the grid. Card/file attachments are always fully shown. Toggling fires `swc-user-message-toggle` with `{ open: boolean }`. + + + ## Accessibility ### Features @@ -39,11 +57,13 @@ The `` element implements the following accessibility features - The bubble is rendered as a `
` acting as a visual container - `type="copy"` uses the default slot for message text - `type="card"` and `type="media"` use named slots for thumbnail, title, and subtitle +- `type="attachments"` renders a ` + `; + } + + /** "Show less" control below the grid, shown only while expanded. */ + private _renderShowLessToggle(): TemplateResult | '' { + if (!this._hasMediaOverflow || !this.open) { + return ''; + } return html`
- -
- Hilton commercial assets - 2026 + + +
+ Hilton commercial assets + 2026 +
Card
- -
- Hilton commercial assets - 2026 + + +
+ Hilton commercial assets + 2026 +
- Media + + Media (single attachment gets a larger hero tile) +
`, @@ -210,16 +208,18 @@ export const Attachments: Story = { export const AttachmentsDisclosure: Story = { render: () => html` - - - ${mediaAttachment('https://picsum.photos/id/64/240/240', 'Photo 1')} - ${mediaAttachment('https://picsum.photos/id/823/240/240', 'Photo 2')} - ${mediaAttachment('https://picsum.photos/id/56/240/240', 'Photo 3')} - ${mediaAttachment('https://picsum.photos/id/65/240/240', 'Photo 4')} - ${mediaAttachment('https://picsum.photos/id/48/240/240', 'Photo 5')} - ${mediaAttachment('https://picsum.photos/id/28/240/240', 'Photo 6')} - - +
+ + + ${mediaAttachment('https://picsum.photos/id/64/240/240', 'Photo 1')} + ${mediaAttachment('https://picsum.photos/id/823/240/240', 'Photo 2')} + ${mediaAttachment('https://picsum.photos/id/56/240/240', 'Photo 3')} + ${mediaAttachment('https://picsum.photos/id/65/240/240', 'Photo 4')} + ${mediaAttachment('https://picsum.photos/id/48/240/240', 'Photo 5')} + ${mediaAttachment('https://picsum.photos/id/28/240/240', 'Photo 6')} + + +
`, tags: ['behaviors'], }; diff --git a/2nd-gen/packages/swc/patterns/conversational-ai/user-message/test/user-message.a11y.spec.ts b/2nd-gen/packages/swc/patterns/conversational-ai/user-message/test/user-message.a11y.spec.ts index a5c8102286d..f2f6eb0d1f5 100644 --- a/2nd-gen/packages/swc/patterns/conversational-ai/user-message/test/user-message.a11y.spec.ts +++ b/2nd-gen/packages/swc/patterns/conversational-ai/user-message/test/user-message.a11y.spec.ts @@ -74,7 +74,7 @@ test.describe('UserMessage - ARIA Snapshots', () => { 'swc-user-message' ); await expect(root).toMatchAriaSnapshot(` - - button "Show all" [expanded=false] + - button "View all (6)" [expanded=false] `); }); }); diff --git a/2nd-gen/packages/swc/patterns/conversational-ai/user-message/test/user-message.test.ts b/2nd-gen/packages/swc/patterns/conversational-ai/user-message/test/user-message.test.ts index 06454fcfdbc..90a0640ea95 100644 --- a/2nd-gen/packages/swc/patterns/conversational-ai/user-message/test/user-message.test.ts +++ b/2nd-gen/packages/swc/patterns/conversational-ai/user-message/test/user-message.test.ts @@ -19,6 +19,7 @@ import '../swc-user-message.js'; import { getComponent, getComponents } from '../../../../utils/test-utils.js'; import { meta, Overview } from '../stories/user-message.stories.js'; +import { UserMessageAttachment } from '../user-message-attachment/UserMessageAttachment.js'; import { UserMessage } from '../UserMessage.js'; export default { @@ -55,46 +56,61 @@ export const TypeAndSlotTest: Story = { ); await step( - 'type reflects to the host and drives card structure', + 'a single card attachment reflects "attachments" on the host and gets the hero row', async () => { - el.type = 'card'; + el.type = 'attachments'; el.innerHTML = ` -
- Brand guidelines - PDF + +
+ Brand guidelines + PDF +
`; await el.updateComplete; + await new Promise((resolve) => requestAnimationFrame(resolve)); + await el.updateComplete; - const title = el.shadowRoot?.querySelector('.swc-UserMessage-title'); - const subtitle = el.shadowRoot?.querySelector( - '.swc-UserMessage-subtitle' + const attachment = el.querySelector( + 'swc-user-message-attachment' + )!; + await attachment.updateComplete; + + const heroFilesBox = el.shadowRoot?.querySelector( + '.swc-UserMessage-attachments-files--single' + ); + const title = attachment.shadowRoot?.querySelector( + '.swc-UserMessageAttachment-title' ); - expect(el.getAttribute('type')).toBe('card'); + const subtitle = attachment.shadowRoot?.querySelector( + '.swc-UserMessageAttachment-subtitle' + ); + expect(el.getAttribute('type')).toBe('attachments'); + expect(heroFilesBox).toBeTruthy(); expect(title).toBeTruthy(); expect(subtitle).toBeTruthy(); } ); await step( - 'media type renders the media attachment container', + 'a single media attachment gets the hero grid tile', async () => { - el.type = 'media'; + el.type = 'attachments'; el.innerHTML = ` -
- Preview image - PNG + +
+ Preview image + PNG +
`; await el.updateComplete; + await new Promise((resolve) => requestAnimationFrame(resolve)); + await el.updateComplete; - const attachment = el.shadowRoot?.querySelector( - '.swc-UserMessage-attachment--media' + const heroMediaBox = el.shadowRoot?.querySelector( + '.swc-UserMessage-attachments-media--single' ); - expect(el.getAttribute('type')).toBe('media'); - expect(attachment).toBeTruthy(); + expect(el.getAttribute('type')).toBe('attachments'); + expect(heroMediaBox).toBeTruthy(); } ); @@ -118,7 +134,7 @@ export const TypeAndSlotTest: Story = { }; export const DefaultSlotHiddenForAttachmentTypesTest: Story = { - name: 'Default slot not used for card and media', + name: 'Default slot not used for attachments', ...Overview, play: async ({ canvasElement, step }) => { const el = await getComponent( @@ -126,34 +142,28 @@ export const DefaultSlotHiddenForAttachmentTypesTest: Story = { 'swc-user-message' ); - const attachmentMarkup = (label: string) => ` -

${label}

-
- T - S + await step( + 'type="attachments": no unnamed slot; unslotted children are not shown', + async () => { + el.type = 'attachments'; + el.innerHTML = ` +

Default copy that must not appear in the bubble for attachments type.

+ +
+
`; + await el.updateComplete; - for (const type of ['card', 'media'] as const) { - await step( - `type="${type}": no unnamed slot; default-slot children are not shown`, - async () => { - el.type = type; - el.innerHTML = attachmentMarkup( - 'Default copy that must not appear in the bubble for attachment types.' - ); - await el.updateComplete; - - expect(el.shadowRoot?.querySelector('slot:not([name])')).toBeNull(); - - const leaked = el.querySelector( - '[data-test-default-slotted]' - ); - expect(leaked).toBeTruthy(); - const { width, height } = leaked!.getBoundingClientRect(); - expect(width * height).toBe(0); - } - ); - } + expect(el.shadowRoot?.querySelector('slot:not([name])')).toBeNull(); + + const leaked = el.querySelector( + '[data-test-default-slotted]' + ); + expect(leaked).toBeTruthy(); + const { width, height } = leaked!.getBoundingClientRect(); + expect(width * height).toBe(0); + } + ); await step( 'type="copy" keeps an unnamed (default) slot in the shadow root', @@ -210,6 +220,10 @@ export const AttachmentsGroupingTest: Story = { el.innerHTML = attachmentsMarkup(4, 1); await el.updateComplete; + const overflow = el.shadowRoot?.querySelector( + '.swc-UserMessage-attachments-overflow' + ); + expect(overflow).toBeNull(); const toggle = el.shadowRoot?.querySelector( '.swc-UserMessage-attachments-toggle' ); @@ -250,12 +264,12 @@ export const AttachmentsGroupingTest: Story = { await new Promise((resolve) => requestAnimationFrame(resolve)); await el.updateComplete; - const toggle = el.shadowRoot?.querySelector( - '.swc-UserMessage-attachments-toggle' + const overflow = el.shadowRoot?.querySelector( + '.swc-UserMessage-attachments-overflow' ); - expect(toggle).toBeTruthy(); - expect(toggle?.getAttribute('aria-expanded')).toBe('false'); - expect(toggle?.textContent?.trim()).toContain('Show all'); + expect(overflow).toBeTruthy(); + expect(overflow?.getAttribute('aria-expanded')).toBe('false'); + expect(overflow?.textContent?.trim()).toContain('View all (6)'); const mediaChildren = Array.from(el.children); expect(mediaChildren.slice(0, 4).some((child) => child.hidden)).toBe( @@ -279,14 +293,23 @@ export const AttachmentsGroupingTest: Story = { { once: true } ); - const toggle = el.shadowRoot?.querySelector( - '.swc-UserMessage-attachments-toggle' + const overflow = el.shadowRoot?.querySelector( + '.swc-UserMessage-attachments-overflow' ); - toggle?.click(); + overflow?.click(); await el.updateComplete; expect(el.open).toBe(true); expect(detail?.open).toBe(true); + // Stays in the DOM (fades out via CSS) rather than being removed. + expect( + el.shadowRoot?.querySelector('.swc-UserMessage-attachments-overflow') + ).not.toBeNull(); + expect(overflow?.hidden).toBe(true); + + const toggle = el.shadowRoot?.querySelector( + '.swc-UserMessage-attachments-toggle' + ); expect(toggle?.getAttribute('aria-expanded')).toBe('true'); expect(toggle?.textContent?.trim()).toContain('Show less'); expect(Array.from(el.children).every((child) => !child.hidden)).toBe( @@ -320,15 +343,17 @@ export const LongTextWrapTest: Story = { style="max-inline-size: 640px; margin-block-start: 32px; padding-inline: 1px;" > - -
- ${longUnbrokenFileName} - PDF + + +
+ ${longUnbrokenFileName} + PDF +
diff --git a/2nd-gen/packages/swc/patterns/conversational-ai/user-message/user-message-attachment/UserMessageAttachment.ts b/2nd-gen/packages/swc/patterns/conversational-ai/user-message/user-message-attachment/UserMessageAttachment.ts index 821183eb2c6..026ba9d4587 100644 --- a/2nd-gen/packages/swc/patterns/conversational-ai/user-message/user-message-attachment/UserMessageAttachment.ts +++ b/2nd-gen/packages/swc/patterns/conversational-ai/user-message/user-message-attachment/UserMessageAttachment.ts @@ -29,8 +29,10 @@ import styles from './user-message-attachment.css'; * * @slot thumbnail - Shared visual slot for icon/thumbnail/preview image. * @slot badge - Optional file-type badge rendered over `type="media"` previews (for example, "PDF"). - * @slot title - Primary text label. - * @slot subtitle - Secondary text label. + * @slot title - Primary text label. For `type="media"`, omit on grouped grid tiles + * (matching the compose-time `swc-upload-artifact` strip's caption-less tiles); a + * single "hero" attachment typically includes one. + * @slot subtitle - Secondary text label. Same `type="media"` guidance as `title`. */ export class UserMessageAttachment extends SpectrumElement { /** Visual treatment type for this attachment: `media` renders a grid tile, `card` renders a stacked row. */ @@ -40,6 +42,12 @@ export class UserMessageAttachment extends SpectrumElement { @queryAssignedElements({ slot: 'badge', flatten: true }) private _assignedBadge!: HTMLElement[]; + @queryAssignedElements({ slot: 'title', flatten: true }) + private _assignedTitle!: HTMLElement[]; + + @queryAssignedElements({ slot: 'subtitle', flatten: true }) + private _assignedSubtitle!: HTMLElement[]; + public static override get styles(): CSSResultArray { return [styles]; } @@ -48,10 +56,21 @@ export class UserMessageAttachment extends SpectrumElement { return (this._assignedBadge?.length ?? 0) > 0; } + private _hasMediaMetaContent(): boolean { + return ( + (this._assignedTitle?.length ?? 0) > 0 || + (this._assignedSubtitle?.length ?? 0) > 0 + ); + } + private _handleBadgeSlotChange(): void { this.requestUpdate(); } + private _handleMediaMetaSlotChange(): void { + this.requestUpdate(); + } + private _renderBadge(): TemplateResult { if (!this._hasBadgeContent()) { return html` @@ -70,6 +89,46 @@ export class UserMessageAttachment extends SpectrumElement { `; } + /** + * Title/subtitle beneath the media square, shown only when slotted: + * grouped grid tiles are conventionally caption-less (matching the compose-time + * `swc-upload-artifact` strip), while a single "hero" attachment typically + * has one, same as `type="card"`. + */ + private _renderMediaMeta(): TemplateResult { + if (!this._hasMediaMetaContent()) { + return html` + + + `; + } + + return html` +
+
+ +
+
+ +
+
+ `; + } + private _renderMediaSurface(): TemplateResult { return html`
@@ -78,6 +137,7 @@ export class UserMessageAttachment extends SpectrumElement {
${this._renderBadge()} + ${this._renderMediaMeta()} `; } diff --git a/2nd-gen/packages/swc/patterns/conversational-ai/user-message/user-message-attachment/user-message-attachment.css b/2nd-gen/packages/swc/patterns/conversational-ai/user-message/user-message-attachment/user-message-attachment.css index 750015d01f0..96b2e9afb8d 100644 --- a/2nd-gen/packages/swc/patterns/conversational-ai/user-message/user-message-attachment/user-message-attachment.css +++ b/2nd-gen/packages/swc/patterns/conversational-ai/user-message/user-message-attachment/user-message-attachment.css @@ -15,6 +15,16 @@ position: relative; inline-size: 100%; block-size: 100%; + transition-timing-function: ease; + transition-duration: token("animation-duration-100"); + transition-property: opacity, display; + transition-behavior: allow-discrete; +} + +@starting-style { + :host { + opacity: 0; + } } *, @@ -101,17 +111,27 @@ text-overflow: ellipsis; } -/* Media: fixed square grid tile. */ +/* Media: fixed-width square thumbnail + optional caption below (see + * `_renderMediaMeta`). The grid tracks in `swc-user-message` + * (`.swc-UserMessage-attachments-media`) are sized from this same custom + * property so the grid's `fit-content` box shrink-wraps to a well-defined + * width instead of stretching to fill the bubble. Block-size is `auto` (not + * matched to inline-size) so a captioned "hero" tile can grow taller than a + * plain square grid tile; the thumbnail itself stays square via + * `aspect-ratio` regardless. */ :host([type="media"]) { - inline-size: var(--swc-user-message-attachment-media-size, 120px); - block-size: var(--swc-user-message-attachment-media-size, 120px); + display: flex; + flex-direction: column; + gap: token("spacing-100"); + inline-size: var(--swc-user-message-attachment-media-size, 96px); } :host([type="media"]) .swc-UserMessageAttachment-surface { display: block; position: relative; + flex-shrink: 0; inline-size: 100%; - block-size: 100%; + aspect-ratio: 1; border-radius: token("corner-radius-medium-default"); overflow: hidden; } @@ -164,3 +184,40 @@ overflow: hidden; text-overflow: ellipsis; } + +:host([type="media"]) .swc-UserMessageAttachment-meta { + display: flex; + flex-direction: column; + gap: token("spacing-50"); + min-inline-size: 0; +} + +:host([type="media"]) .swc-UserMessageAttachment-title, +:host([type="media"]) .swc-UserMessageAttachment-subtitle { + inline-size: 100%; + min-inline-size: 0; + text-align: start; +} + +:host([type="media"]) .swc-UserMessageAttachment-title ::slotted(*), +:host([type="media"]) .swc-UserMessageAttachment-subtitle ::slotted(*) { + display: block; + min-inline-size: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Declared last (not just once, near the base `:host` rule) so it wins the + * cascade regardless of source order: every `:host([type="..."])` rule above + * has the same specificity as `:host([hidden])`, and author `display` rules + * always beat the UA `[hidden] { display: none }` rule regardless of + * specificity — so whichever same-specificity rule is declared *later* wins, + * and any future `:host([type="..."])` block that sets its own `display` + * would otherwise silently re-break `swc-user-message`'s `el.hidden = true` + * on overflow tiles. Fades rather than abruptly popping in/out when the + * "Show all/less" disclosure toggles. */ +:host([hidden]) { + display: none; + opacity: 0; +} diff --git a/2nd-gen/packages/swc/patterns/conversational-ai/user-message/user-message.css b/2nd-gen/packages/swc/patterns/conversational-ai/user-message/user-message.css index f25c5ea3c31..939b7566425 100644 --- a/2nd-gen/packages/swc/patterns/conversational-ai/user-message/user-message.css +++ b/2nd-gen/packages/swc/patterns/conversational-ai/user-message/user-message.css @@ -12,9 +12,11 @@ /* Sizing per explicit user-message type sourced from Figma spec (node 5-2281), full-screen modality. * - * copy — fills available column width, capped at 536px (text reflows naturally) - * card — fixed 324px (content 292px = 324px − 2×16px padding), capped at 440px - * media — shrinks to the fixed 180×180px inner card + 2×8px padding ≈ 196px square + * copy — fills available column width, capped at 536px (text reflows naturally) + * attachments — shrink-wraps to its content; a single attachment gets a larger + * "hero" tile (see `.swc-UserMessage-attachments-media--single` + * and `.swc-UserMessage-attachments-files--single`) than a + * grouped one * * The host IS the visible bubble so focus rings rendered by the parent * `swc-conversation-turn` hug its border box exactly. @@ -42,172 +44,128 @@ box-sizing: border-box; } -/* Card: fixed 324px per Figma spec (content 292px = 324px − 2×16px padding). */ -:host([type="card"]) { - inline-size: 324px; - min-inline-size: 212px; - max-inline-size: 440px; - padding: var(--swc-user-message-card-padding, token("spacing-300")); -} - -/* Image: shrinks to the fixed 180px inner card + 2×8px padding. */ -:host([type="media"]) { - inline-size: fit-content; - padding: var(--swc-user-message-media-padding, token("spacing-100")); -} - -/* Attachments: fits a 4-column media grid; not yet in the Figma sizing spec above, revisit with design. */ +/* Attachments: fits its content; the media grid and each card row are + * independent boxes (see `.swc-UserMessage-attachments-media` and each + * `swc-user-message-attachment[type="card"]`'s own surface), never merged + * into one shared box. */ :host([type="attachments"]) { - inline-size: var(--swc-user-message-attachments-inline-size, 420px); + inline-size: fit-content; max-inline-size: 536px; - padding: var(--swc-user-message-attachments-padding, token("spacing-100")); + padding: 0; + background: transparent; } -.swc-UserMessage-attachment { +/* Attachments: 4-column media grid + full-width stacked file rows. */ +.swc-UserMessage-attachments { display: flex; - min-inline-size: 0; -} - -.swc-UserMessage-attachment--card { - gap: var(--swc-user-message-attachment-card-gap, token("spacing-300")); - align-items: center; -} - -/* Media inner card is fixed 180 × 180px, constrained by min/max (Figma spec). */ -.swc-UserMessage-attachment--media { flex-direction: column; - gap: var(--swc-user-message-attachment-media-gap, token("spacing-100")); - inline-size: 180px; - min-inline-size: 150px; - max-inline-size: 210px; - block-size: 180px; - min-block-size: 135px; -} - -.swc-UserMessage-thumbnail { - min-inline-size: 0; -} - -.swc-UserMessage-thumbnail ::slotted(*) { - display: block; + gap: token("spacing-200"); } -:host([type="card"]) .swc-UserMessage-thumbnail { - display: flex; - flex-shrink: 0; - align-items: center; - justify-content: center; +/* Its own box, independent of the card rows below (never a shared background). + * Fixed-size (not `1fr`) columns + `inline-size: fit-content` so the box + * shrink-wraps to however many tiles are actually present (1-4 per row) + * instead of always reserving a full 4-column width. Column *count* itself + * is also dynamic (set as an inline `grid-template-columns` by + * `swc-user-message`, since `repeat()`'s count argument must be a literal + * integer — a custom property doesn't substitute there): a fixed + * `repeat(4, ...)` would always reserve 4 columns' width even for e.g. 3 + * tiles, which `fit-content` alone can't shrink past. This is the no-JS/ + * initial-paint fallback. */ +.swc-UserMessage-attachments-media { + display: grid; + grid-template-columns: repeat(4, var(--swc-user-message-attachment-media-size, 96px)); + gap: token("spacing-100"); + inline-size: fit-content; + padding: token("spacing-100"); + background: token("gray-50"); + border-radius: token("corner-radius-large-default"); } -:host([type="card"]) .swc-UserMessage-thumbnail ::slotted(*) { - flex-shrink: 0; - inline-size: 32px; - block-size: 32px; - border: 1px solid transparent; - border-radius: token("corner-radius-75"); - object-fit: cover; +.swc-UserMessage-attachments-media[hidden] { + display: none; } -/* Thumbnail fills the available vertical space, image covers it fully. */ -:host([type="media"]) .swc-UserMessage-thumbnail { - position: relative; - flex: 1 0 0; - inline-size: 100%; - min-block-size: 0; - background: token("gray-100"); - border: 1px solid transparent; - border-radius: token("corner-radius-200"); - overflow: hidden; +/* A single media attachment (no siblings) is a 180×180 "hero" tile rather + * than the smaller grouped-grid size (Figma spec). Overriding the custom + * property here resizes both the grid track (above) and the tile itself + * (`swc-user-message-attachment`'s `:host([type="media"])`), since it + * inherits through the flattened tree into the slotted attachment. */ +.swc-UserMessage-attachments-media--single { + --swc-user-message-attachment-media-size: 180px; } -:host([type="media"]) .swc-UserMessage-thumbnail ::slotted(*) { - position: absolute; - inset: 0; - inline-size: 100%; - block-size: 100%; - object-fit: cover; +/* Assigned nodes become direct grid items instead of the slot itself. */ +.swc-UserMessage-attachments-media slot { + display: contents; } -.swc-UserMessage-meta { +/* Scrim + pill overlay on the last visible tile (4th column, 1st row) instead + * of a separate control below the grid; stretches to fill that grid cell so + * it exactly covers the tile underneath. `position: relative` + `z-index` + * are required, not cosmetic: the tile sharing this cell is slotted content, + * and the flattened tree paints it above later shadow-DOM siblings despite + * DOM order, so an explicit stacking context is the only reliable way to + * keep the scrim on top. */ +.swc-UserMessage-attachments-overflow { display: flex; - flex-direction: column; - gap: var(--swc-user-message-meta-gap, token("spacing-50")); - min-inline-size: 0; + position: relative; + z-index: 1; + grid-row: 1; + grid-column: 4; + align-items: flex-end; + justify-content: flex-end; + padding: token("spacing-100"); + background: token("transparent-black-700"); + border: none; + border-radius: token("corner-radius-medium-default"); + cursor: pointer; + transition-timing-function: ease; + transition-duration: token("animation-duration-100"); + transition-property: opacity, display; + transition-behavior: allow-discrete; } -:host([type="card"]) .swc-UserMessage-meta { - flex: 1; - align-items: flex-start; +/* Fades out in step with the newly-revealed tiles fading in (see + * `user-message-attachment.css`), rather than vanishing instantly while + * they animate — matches `?hidden` binding in `_renderMediaOverflow`. */ +.swc-UserMessage-attachments-overflow[hidden] { + display: none; + opacity: 0; } -:host([type="media"]) .swc-UserMessage-meta { - inline-size: 100%; +@starting-style { + .swc-UserMessage-attachments-overflow { + opacity: 0; + } } -.swc-UserMessage-title { - font-family: token("sans-serif-font"); - font-size: token("font-size-100"); - font-weight: token("bold-font-weight"); - line-height: token("line-height-font-size-100"); - color: token("gray-900"); -} - -.swc-UserMessage-subtitle { +.swc-UserMessage-attachments-overflow-pill { + padding-block: token("spacing-75"); + padding-inline: token("spacing-200"); font-family: token("sans-serif-font"); font-size: token("font-size-75"); - font-weight: token("regular-font-weight"); + font-weight: token("medium-font-weight"); line-height: token("line-height-font-size-75"); - color: token("gray-800"); - text-overflow: ellipsis; -} - -:host([type="card"]) .swc-UserMessage-title, -:host([type="card"]) .swc-UserMessage-subtitle, -:host([type="media"]) .swc-UserMessage-title { - inline-size: 100%; - min-inline-size: 0; - text-align: start; -} - -/* Long, unbroken tokens (e.g. filenames without spaces) break at any character - * so the title wraps inside the meta column instead of overflowing the bubble. */ -:host([type="card"]) .swc-UserMessage-title ::slotted(*), -:host([type="media"]) .swc-UserMessage-title ::slotted(*), -:host([type="card"]) .swc-UserMessage-subtitle ::slotted(*), -:host([type="media"]) .swc-UserMessage-subtitle ::slotted(*) { - display: block; - min-inline-size: 0; + color: token("gray-900"); white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + background: token("gray-25"); + border-radius: token("corner-radius-400"); } -/* Attachments: 4-column media grid + full-width stacked file rows. */ -.swc-UserMessage-attachments { +.swc-UserMessage-attachments-files { display: flex; flex-direction: column; - gap: token("spacing-200"); -} - -.swc-UserMessage-attachments-media { - display: grid; - grid-template-columns: repeat(4, 1fr); gap: token("spacing-100"); } -.swc-UserMessage-attachments-media[hidden] { - display: none; -} - -/* Assigned nodes become direct grid items instead of the slot itself. */ -.swc-UserMessage-attachments-media slot { - display: contents; -} - -.swc-UserMessage-attachments-files { - display: flex; - flex-direction: column; - gap: token("spacing-100"); +/* A single card attachment (no siblings) is a 324px "hero" row (content + * 292px = 324px − 2×16px padding, capped 212-440px) rather than an + * unbounded full-width row (Figma spec). */ +.swc-UserMessage-attachments-files--single { + inline-size: 324px; + min-inline-size: 212px; + max-inline-size: 440px; } .swc-UserMessage-attachments-files[hidden] { diff --git a/2nd-gen/packages/swc/patterns/conversational-ai/user-message/user-message.mdx b/2nd-gen/packages/swc/patterns/conversational-ai/user-message/user-message.mdx index 9574f763aab..68de737efb4 100644 --- a/2nd-gen/packages/swc/patterns/conversational-ai/user-message/user-message.mdx +++ b/2nd-gen/packages/swc/patterns/conversational-ai/user-message/user-message.mdx @@ -22,18 +22,17 @@ A user message consists of: Bubble sizing and padding are inferred from slotted content: -- **Copy**: default text-only content with the bubble's default width and padding -- **Card**: compact attachment layout with thumbnail, title, and subtitle -- **Media**: larger preview-first attachment layout with metadata beneath the preview +- **Copy** (`type="copy"`): default text-only content with the bubble's default width and padding +- **Attachments** (`type="attachments"`): one or more `` children — a single attachment (media or card) renders at a larger "hero" size than a grouped one ### Attachments -`type="attachments"` accepts many `` children in one bubble, for sending several files together: +`type="attachments"` accepts one or more `` children in one bubble — a single attachment is a common case, not a special one: -- **`type="media"`** attachments (images) lay out in a fixed 4-column grid -- **`type="card"`** attachments (documents, video, other non-image files) stack full-width, one per row, beneath the grid +- **`type="media"`** attachments (images) lay out in a fixed 4-column grid; a lone media attachment gets a larger 180×180 hero tile instead of the smaller grouped-grid size +- **`type="card"`** attachments (documents, video, other non-image files) stack full-width, one per row, beneath the grid; a lone card attachment gets a wider hero row (capped at 440px) instead of shrink-wrapping to the grid's width - `swc-user-message` groups attachments by their own `type`, regardless of the order they're slotted in @@ -42,7 +41,7 @@ Bubble sizing and padding are inferred from slotted content: ### Show all / show less -When there are more than 4 media attachments, the extra tiles collapse behind a "Show all" disclosure below the grid. Card/file attachments are always fully shown. Toggling fires `swc-user-message-toggle` with `{ open: boolean }`. +When there are more than 4 media attachments, the extra tiles collapse behind a "View all (N)" scrim overlay on the last visible tile; expanding reveals every tile, still 4 per row, with a "Show less" control below the grid to collapse again. Card/file attachments are always fully shown. Toggling fires `swc-user-message-toggle` with `{ open: boolean }`. @@ -56,13 +55,11 @@ The `` element implements the following accessibility features - The bubble is rendered as a `
` acting as a visual container - `type="copy"` uses the default slot for message text -- `type="card"` and `type="media"` use named slots for thumbnail, title, and subtitle -- `type="attachments"` renders a `