diff --git a/2nd-gen/packages/swc/.storybook/preview.ts b/2nd-gen/packages/swc/.storybook/preview.ts index 73e4673e0ab..37e79dcd670 100644 --- a/2nd-gen/packages/swc/.storybook/preview.ts +++ b/2nd-gen/packages/swc/.storybook/preview.ts @@ -330,7 +330,10 @@ const preview = { 'Rendering and styling migration analysis', ], 'Action button', - ['Rendering and styling migration analysis'], + [ + 'Accessibility migration analysis', + 'Rendering and styling migration analysis', + ], 'Action group', ['Rendering and styling migration analysis'], 'Action menu', @@ -360,9 +363,14 @@ const preview = { 'Rendering and styling migration analysis', ], 'Button group', - ['Rendering and styling migration analysis'], + [ + 'Accessibility migration analysis', + 'Rendering and styling migration analysis', + ], 'Checkbox', ['Rendering and styling migration analysis'], + 'Close button', + ['Accessibility migration analysis'], 'Color field', ['Rendering and styling migration analysis'], 'Color loupe', @@ -397,6 +405,7 @@ const preview = { 'Link', [ 'Accessibility migration analysis', + 'Migration plan', 'Rendering and styling migration analysis', ], 'Menu', diff --git a/2nd-gen/packages/swc/patterns/conversational-ai/multi-artifact-upload-rfc.md b/2nd-gen/packages/swc/patterns/conversational-ai/multi-artifact-upload-rfc.md new file mode 100644 index 00000000000..e0e5f46482e --- /dev/null +++ b/2nd-gen/packages/swc/patterns/conversational-ai/multi-artifact-upload-rfc.md @@ -0,0 +1,178 @@ +# Conversational AI multi-artifact plan (working RFC) + +## What this is + +Working plan for multi-artifact behavior in Conversational AI patterns. + +This doc is intentionally practical: + +- first half = overview + plan +- second half = concrete TODOs by component/track + +## Scope + +In scope now: + +- composer/prompt-field artifacts (pre-submit) +- user-message/thread artifacts (post-submit) +- component API updates needed for both +- accessibility behavior definition for both + +Out of scope now: + +- drag-and-drop behavior (follow-up) +- backend upload/storage/validation logic +- file processing and metadata editing + +## Current snapshot (short) + +- `swc-prompt-field` already supports multiple slotted artifacts and mixed types. +- `swc-upload-artifact` already supports `card` and `media` plus dismiss event. +- current system does not model per-artifact upload states yet. +- `swc-user-message` supports only single-mode rendering via `type="copy|card|media"`. +- current `swc-user-message` API cannot represent mixed multi-artifact + text composition in one message. + +## User-message API direction (intentional breaking change) + +Current `swc-user-message` is mode-based (`copy|card|media`) and assumes one visual message shape at a time. + +That is not a scalable fit for target behavior, where one submitted user message can include: + +- multiple artifacts +- mixed artifact types +- optional text in the same message + +Decision for this RFC track: + +- we will treat `swc-user-message` redesign as an intentional breaking API change +- move from mode-based API to composition-based API + +Target shape: + +- `swc-user-message` is a message container, not a single-type switch +- message can render: + - artifact region (0..n) + - text region (0..1) +- ordering/layout rules are defined by the component contract + +Migration direction: + +- replace `type="copy|card|media"` usage with explicit content composition +- document before/after examples in stories and migration notes +- keep visual parity where possible while removing mode coupling + +## Ownership model + +`swc-prompt-field`: + +- composer layout, actions, and artifact region behavior +- upload trigger event surface +- optional max-artifact UI constraints + +`swc-upload-artifact`: + +- artifact visuals/variants +- artifact state rendering (loading/success/error) +- per-artifact actions and state semantics + +`swc-user-message`: + +- post-submit artifact + text presentation contract +- message-level artifact layout/overflow/a11y behavior + +Consumer app: + +- file acquisition (picker now, drag/drop later) +- file type and size validation +- upload transport and persistence +- mapping real upload status into component props/slots + +## Delivery plan + +1. Lock contracts + +- finalize API names and behavior for prompt-field, upload-artifact, user-message touchpoints + +2. Define interaction and accessibility behavior + +- keyboard movement model +- screen-reader announcements +- state transitions and announcements + +3. Implement component updates + +- add non-breaking API additions +- add stories for new states/layouts +- add tests for behavior and a11y + +4. Follow-up + +- drag-and-drop support with same ownership split + +--- + +## TODOs: `swc-prompt-field` + +- [ ] Add `max-artifacts` API (name can still change if team wants) +- [ ] Define behavior when limit is reached: + - [ ] upload affordance disabled/hidden rule + - [ ] event emitted when user tries to add at limit (name TBD) +- [ ] Define artifact region keyboard behavior: + - [ ] how focus enters artifact region + - [ ] arrow key movement rules + - [ ] how focus exits to next control +- [ ] Confirm overflow strategy for composer artifact strip: + - [ ] wrap only + - [ ] or capped row + overflow summary +- [ ] Update stories for all intended composer artifact combinations +- [ ] Add/extend tests for keyboard + a11y expectations + +## TODOs: `swc-upload-artifact` + +- [ ] Add `state` API: + - [ ] `idle` + - [ ] `uploading` + - [ ] `success` + - [ ] `error` +- [ ] Add `error-reason` API shape (enum/string decision) +- [ ] Add new additive variant for thumbnail + tag (name TBD) +- [ ] Define optional retry interaction for error state: + - [ ] slot vs event vs both +- [ ] Define accessible naming requirements per variant +- [ ] Add screen-reader state messaging behavior +- [ ] Add stories for each state x variant matrix we commit to +- [ ] Add tests for dismiss/retry/state/a11y behavior + +## TODOs: `swc-user-message` (post-submit artifacts) + +- [ ] Replace mode-based API (`type="copy|card|media"`) with composition-based API +- [ ] Define API contract for submitted user message content: + - [ ] artifact region contract (0..n) + - [ ] text region contract (0..1) + - [ ] any props/events needed for layout or state +- [ ] Define artifact + text composition rules in one message bubble +- [ ] Define layout rules for multiple and mixed artifact types +- [ ] Define overflow behavior for message-level artifact groups +- [ ] Define keyboard and screen-reader behavior for message artifacts +- [ ] Define state handoff expectations from composer to submitted message +- [ ] Add stories for key message layouts and states +- [ ] Add tests for layout behavior and a11y behavior +- [ ] Add migration notes from old `type` API to new composition API + +## TODOs: cross-cutting decisions + +- [ ] Finalize naming: + - [ ] `max-artifacts` + - [ ] artifact variant names + - [ ] any new events +- [ ] Finalize what counts as component-owned vs consumer-owned behavior +- [ ] Document explicit non-goals in spike PR description +- [ ] Ensure docs clearly state: drag-and-drop is follow-up only + +## PR notes (spike) + +Must explicitly say: + +- this spike includes composer + user-message artifact planning work +- drag-and-drop is intentionally deferred +- upload transport/validation remains consumer-owned diff --git a/2nd-gen/packages/swc/patterns/conversational-ai/prompt-field/prompt-field.css b/2nd-gen/packages/swc/patterns/conversational-ai/prompt-field/prompt-field.css index 177c1932ed2..1b82125b083 100644 --- a/2nd-gen/packages/swc/patterns/conversational-ai/prompt-field/prompt-field.css +++ b/2nd-gen/packages/swc/patterns/conversational-ai/prompt-field/prompt-field.css @@ -106,10 +106,10 @@ /* Prompt-field media artifact uses 68×68 preview tile. */ .swc-PromptField-artifacts > slot::slotted([type="media"]) { - inline-size: var(--swc-prompt-field-artifact-media-inline-size, 68px); - min-inline-size: var(--swc-prompt-field-artifact-media-min-inline-size, 68px); - block-size: var(--swc-prompt-field-artifact-media-block-size, 68px); - min-block-size: var(--swc-prompt-field-artifact-media-min-block-size, 68px); + inline-size: var(--swc-prompt-field-artifact-media-inline-size, 64px); + min-inline-size: var(--swc-prompt-field-artifact-media-min-inline-size, 64px); + block-size: var(--swc-prompt-field-artifact-media-block-size, 64px); + min-block-size: var(--swc-prompt-field-artifact-media-min-block-size, 64px); aspect-ratio: 1 / 1; } @@ -185,8 +185,8 @@ display: flex; align-items: center; justify-content: center; - inline-size: var(--swc-prompt-field-upload-inline-size, 32px); - block-size: var(--swc-prompt-field-upload-block-size, 32px); + inline-size: var(--swc-prompt-field-upload-inline-size, 44px); + block-size: var(--swc-prompt-field-upload-block-size, 44px); padding: 0; color: token("gray-800"); background: transparent; @@ -215,8 +215,8 @@ display: flex; align-items: center; justify-content: center; - inline-size: var(--swc-prompt-field-send-inline-size, 32px); - block-size: var(--swc-prompt-field-send-block-size, 32px); + inline-size: var(--swc-prompt-field-send-inline-size, 44px); + block-size: var(--swc-prompt-field-send-block-size, 44px); padding: 0; color: token("gray-25"); background: token("neutral-background-color-default"); @@ -229,8 +229,8 @@ display: flex; align-items: center; justify-content: center; - inline-size: var(--swc-prompt-field-stop-inline-size, 32px); - block-size: var(--swc-prompt-field-stop-block-size, 32px); + inline-size: var(--swc-prompt-field-stop-inline-size, 44px); + block-size: var(--swc-prompt-field-stop-block-size, 44px); padding: 0; color: token("gray-25"); background: token("gray-900"); diff --git a/2nd-gen/packages/swc/patterns/conversational-ai/prompt-field/stories/multi-artifact-demo.stories.ts b/2nd-gen/packages/swc/patterns/conversational-ai/prompt-field/stories/multi-artifact-demo.stories.ts new file mode 100644 index 00000000000..5f16df6470f --- /dev/null +++ b/2nd-gen/packages/swc/patterns/conversational-ai/prompt-field/stories/multi-artifact-demo.stories.ts @@ -0,0 +1,1971 @@ +/** + * 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 { html, LitElement, nothing, TemplateResult } from 'lit'; +import { customElement, query, state } from 'lit/decorators.js'; +import type { Meta, StoryObj as Story } from '@storybook/web-components'; + +import '@adobe/spectrum-wc/components/icon/swc-icon.js'; +import '@adobe/spectrum-wc/components/illustrated-message/swc-illustrated-message.js'; +import '../../upload-artifact/index.js'; +import '../index.js'; + +import { + ChevronLeftIcon, + ChevronRightIcon, + ChevronUpIcon, + PlusIcon, +} from '../../utils/icons/index.js'; + +const defaultPlaceholder = + 'Ready to get started? Ask a question, share an idea, or add a task.'; +const demoPrompt = + 'Review all attached materials and generate a product launch strategy with timeline, risks, and budget allocation.'; + +const artifactTileSizePx = 64; +const artifactMaxVisibleRows = 2; + +const gradients = [ + 'linear-gradient(135deg,#818cf8,#f43f5e)', + 'linear-gradient(135deg,#6366f1,#ec4899)', + 'linear-gradient(135deg,#0ea5e9,#22c55e)', + 'linear-gradient(135deg,#a855f7,#ef4444)', + 'linear-gradient(135deg,#14b8a6,#f97316)', + 'linear-gradient(135deg,#f472b6,#8b5cf6)', +]; + +/** Spectrum dropzone upload illustration (from 1st-gen dropzone stories). */ +const dropzoneUploadIllustration = html` + +`; + +interface DemoArtifact { + id: string; + fileName: string; + kind: 'image' | 'document'; + badge?: string; + thumbnailUrl?: string; + gradient: string; +} + +const createSeedArtifacts = (count: number): DemoArtifact[] => + Array.from({ length: count }, (_, index) => { + const isDocument = index % 5 === 4; + return { + id: `seed-${index}`, + fileName: isDocument + ? `Launch brief budget v${index + 1}.pdf` + : `Campaign still ${index + 1}.png`, + kind: isDocument ? 'document' : 'image', + badge: isDocument ? (index % 10 === 4 ? 'PDF' : 'Excel') : undefined, + gradient: gradients[index % gradients.length]!, + }; + }); + +const revokeArtifactUrls = (artifacts: DemoArtifact[]): void => { + for (const artifact of artifacts) { + if (artifact.thumbnailUrl) { + URL.revokeObjectURL(artifact.thumbnailUrl); + } + } +}; + +const createArtifactsFromFiles = ( + files: File[], + startIndex: number +): DemoArtifact[] => + files.map((file, index) => { + const isImage = + file.type.startsWith('image/') || + /\.(png|jpe?g|gif|webp|bmp|svg|avif)$/i.test(file.name); + const extension = file.name.split('.').pop()?.toUpperCase(); + return { + id: `${crypto.randomUUID()}-${index}`, + fileName: file.name || 'Attachment', + kind: isImage ? 'image' : 'document', + badge: isImage ? undefined : extension?.slice(0, 4) || 'File', + thumbnailUrl: isImage ? URL.createObjectURL(file) : undefined, + gradient: gradients[(startIndex + index) % gradients.length]!, + } satisfies DemoArtifact; + }); + +const getDraggedFiles = (event: DragEvent): File[] => { + const dataTransfer = event.dataTransfer; + if (!dataTransfer) { + return []; + } + + return Array.from(dataTransfer.files ?? []); +}; + +const getDismissedArtifactId = (event: Event): string | null => { + const customEvent = event as CustomEvent<{ artifact?: HTMLElement }>; + const artifactHost = + customEvent.detail?.artifact ?? + (event.target as HTMLElement | null)?.closest('[data-artifact-id]'); + + return artifactHost?.getAttribute('data-artifact-id') ?? null; +}; + +const renderArtifactTile = (artifact: DemoArtifact): TemplateResult => html` + + ${artifact.thumbnailUrl + ? html` + ${artifact.fileName} + ` + : html` +
+ `} + ${artifact.badge + ? html` + ${artifact.badge} + ` + : nothing} +
+`; + +const demoStyles = html` + +`; + +@customElement('swc-multi-artifact-scroll-demo') +class MultiArtifactScrollDemo extends LitElement { + @state() + private artifacts: DemoArtifact[] = createSeedArtifacts(18); + + @state() + private value = demoPrompt; + + @state() + private readout = + 'Scroll with arrows or drag the strip. Use + to add files and dismiss tiles to remove them.'; + + @state() + private canScrollBack = false; + + @state() + private canScrollForward = true; + + @state() + private hasOverflow = false; + + @state() + private thumbWidthPx = 0; + + @state() + private thumbLeftPx = 3; + + @query('.MultiArtifactDemo-scroll-track--horizontal') + private _scrollTrack!: HTMLElement; + + @query('.MultiArtifactDemo-scrollbar--horizontal') + private _scrollbar!: HTMLElement; + + @query('[data-file-input]') + private _fileInput!: HTMLInputElement; + + protected override createRenderRoot(): this { + return this; + } + + public override disconnectedCallback(): void { + revokeArtifactUrls(this.artifacts); + super.disconnectedCallback?.(); + } + + public override firstUpdated(): void { + this._syncScrollState(); + + const track = this._scrollTrack; + if (!track || typeof ResizeObserver === 'undefined') { + return; + } + + const observer = new ResizeObserver(() => this._syncScrollState()); + observer.observe(track); + } + + private _handleArtifactDismiss(event: Event): void { + const artifactId = getDismissedArtifactId(event); + if (!artifactId) { + return; + } + + const removed = this.artifacts.find( + (artifact) => artifact.id === artifactId + ); + if (removed?.thumbnailUrl) { + URL.revokeObjectURL(removed.thumbnailUrl); + } + + this.artifacts = this.artifacts.filter( + (artifact) => artifact.id !== artifactId + ); + this.readout = `Removed ${removed?.fileName ?? 'artifact'}. ${this.artifacts.length} attachment${this.artifacts.length === 1 ? '' : 's'} remaining.`; + this.updateComplete.then(() => { + this._clampScrollPosition(); + this._syncScrollState(); + }); + } + + private _clampScrollPosition(): void { + const track = this._scrollTrack; + if (!track) { + return; + } + + const maxScroll = Math.max(0, track.scrollWidth - track.clientWidth); + if (track.scrollLeft > maxScroll) { + track.scrollLeft = maxScroll; + } + } + + private _handleUploadClick(event: Event): void { + event.preventDefault(); + this._fileInput?.click(); + } + + private _handleFileChange(event: Event): void { + const input = event.target as HTMLInputElement; + const files = Array.from(input.files ?? []); + if (!files.length) { + return; + } + + const nextArtifacts = createArtifactsFromFiles( + files, + this.artifacts.length + ); + this.artifacts = [...this.artifacts, ...nextArtifacts]; + this.readout = `Added ${files.length} file${files.length === 1 ? '' : 's'}. ${this.artifacts.length} attachment${this.artifacts.length === 1 ? '' : 's'} total.`; + input.value = ''; + this.updateComplete.then(() => { + this._scrollTrack?.scrollTo({ + left: this._scrollTrack.scrollWidth, + behavior: 'smooth', + }); + this._syncScrollState(); + }); + } + + private _syncScrollState(): void { + const track = this._scrollTrack; + const scrollbar = this._scrollbar; + if (!track || !scrollbar) { + return; + } + + const maxScroll = Math.max(0, track.scrollWidth - track.clientWidth); + this.hasOverflow = maxScroll > 0; + this.canScrollBack = track.scrollLeft > 1; + this.canScrollForward = track.scrollLeft < maxScroll - 1; + + if (!this.hasOverflow) { + this.thumbWidthPx = 0; + this.thumbLeftPx = 3; + return; + } + + const trackWidth = scrollbar.clientWidth; + const inset = 3; + const minThumbWidth = 48; + this.thumbWidthPx = Math.max( + minThumbWidth, + (track.clientWidth / track.scrollWidth) * trackWidth + ); + + const maxThumbLeft = Math.max( + inset, + trackWidth - this.thumbWidthPx - inset + ); + this.thumbLeftPx = + maxScroll === 0 + ? inset + : inset + (track.scrollLeft / maxScroll) * (maxThumbLeft - inset); + } + + private _scrollBy(direction: -1 | 1): void { + const track = this._scrollTrack; + if (!track) { + return; + } + + const tileStep = artifactTileSizePx + 8; + const visibleTiles = Math.max(1, Math.floor(track.clientWidth / tileStep)); + const delta = direction * visibleTiles * tileStep; + const maxScroll = Math.max(0, track.scrollWidth - track.clientWidth); + const nextLeft = Math.min(maxScroll, Math.max(0, track.scrollLeft + delta)); + + track.scrollTo({ left: nextLeft, behavior: 'smooth' }); + } + + private _handleSubmit(): void { + this.readout = `Submitted prompt with ${this.artifacts.length} attachment${this.artifacts.length === 1 ? '' : 's'}.`; + } + + protected override render(): TemplateResult { + return html` + ${demoStyles} +
+
+
+
+ ${this.artifacts.length > 0 + ? html` +
+
+ ${this.canScrollBack + ? html` + + ` + : nothing} +
+
this._syncScrollState()} + > + ${this.artifacts.map((artifact) => + renderArtifactTile(artifact) + )} +
+ ${this.hasOverflow + ? html` + + + ` + : nothing} +
+ ${this.canScrollForward + ? html` + + ` + : nothing} +
+ +
+ ` + : nothing} + +
+ + +
+
+ +
+ + +
+
+ + + +

${this.readout}

+
+
+ `; + } +} + +@customElement('swc-multi-artifact-vertical-scroll-demo') +class MultiArtifactVerticalScrollDemo extends LitElement { + @state() + private artifacts: DemoArtifact[] = createSeedArtifacts(18); + + @state() + private value = demoPrompt; + + @state() + private readout = + 'Tiles wrap to two rows, then scroll vertically. Use + to add files and dismiss tiles to remove them.'; + + @state() + private canScrollBack = false; + + @state() + private canScrollForward = true; + + @state() + private hasOverflow = false; + + @state() + private thumbHeightPx = 0; + + @state() + private thumbTopPx = 3; + + @query('.MultiArtifactDemo-scroll-track--vertical') + private _scrollTrack!: HTMLElement; + + @query('.MultiArtifactDemo-scrollbar--vertical') + private _scrollbar!: HTMLElement; + + @query('[data-file-input]') + private _fileInput!: HTMLInputElement; + + protected override createRenderRoot(): this { + return this; + } + + public override disconnectedCallback(): void { + revokeArtifactUrls(this.artifacts); + super.disconnectedCallback?.(); + } + + public override firstUpdated(): void { + this._syncScrollState(); + + const track = this._scrollTrack; + if (!track || typeof ResizeObserver === 'undefined') { + return; + } + + const observer = new ResizeObserver(() => this._syncScrollState()); + observer.observe(track); + } + + private _handleArtifactDismiss(event: Event): void { + const artifactId = getDismissedArtifactId(event); + if (!artifactId) { + return; + } + + const removed = this.artifacts.find( + (artifact) => artifact.id === artifactId + ); + if (removed?.thumbnailUrl) { + URL.revokeObjectURL(removed.thumbnailUrl); + } + + this.artifacts = this.artifacts.filter( + (artifact) => artifact.id !== artifactId + ); + this.readout = `Removed ${removed?.fileName ?? 'artifact'}. ${this.artifacts.length} attachment${this.artifacts.length === 1 ? '' : 's'} remaining.`; + this.updateComplete.then(() => { + this._clampScrollPosition(); + this._syncScrollState(); + }); + } + + private _clampScrollPosition(): void { + const track = this._scrollTrack; + if (!track) { + return; + } + + const maxScroll = Math.max(0, track.scrollHeight - track.clientHeight); + if (track.scrollTop > maxScroll) { + track.scrollTop = maxScroll; + } + } + + private _handleUploadClick(event: Event): void { + event.preventDefault(); + this._fileInput?.click(); + } + + private _handleFileChange(event: Event): void { + const input = event.target as HTMLInputElement; + const files = Array.from(input.files ?? []); + if (!files.length) { + return; + } + + const nextArtifacts = createArtifactsFromFiles( + files, + this.artifacts.length + ); + this.artifacts = [...this.artifacts, ...nextArtifacts]; + this.readout = `Added ${files.length} file${files.length === 1 ? '' : 's'}. ${this.artifacts.length} attachment${this.artifacts.length === 1 ? '' : 's'} total.`; + input.value = ''; + this.updateComplete.then(() => { + this._scrollTrack?.scrollTo({ + top: this._scrollTrack.scrollHeight, + behavior: 'smooth', + }); + this._syncScrollState(); + }); + } + + private _syncScrollState(): void { + const track = this._scrollTrack; + const scrollbar = this._scrollbar; + if (!track || !scrollbar) { + return; + } + + const maxScroll = Math.max(0, track.scrollHeight - track.clientHeight); + this.hasOverflow = maxScroll > 0; + this.canScrollBack = track.scrollTop > 1; + this.canScrollForward = track.scrollTop < maxScroll - 1; + + if (!this.hasOverflow) { + this.thumbHeightPx = 0; + this.thumbTopPx = 3; + return; + } + + const scrollbarHeight = scrollbar.clientHeight; + const inset = 3; + const minThumbHeight = 48; + this.thumbHeightPx = Math.max( + minThumbHeight, + (track.clientHeight / track.scrollHeight) * scrollbarHeight + ); + + const maxThumbTop = Math.max( + inset, + scrollbarHeight - this.thumbHeightPx - inset + ); + this.thumbTopPx = + maxScroll === 0 + ? inset + : inset + (track.scrollTop / maxScroll) * (maxThumbTop - inset); + } + + private _handleSubmit(): void { + this.readout = `Submitted prompt with ${this.artifacts.length} attachment${this.artifacts.length === 1 ? '' : 's'}.`; + } + + protected override render(): TemplateResult { + return html` + ${demoStyles} +
+
+
+
+ ${this.artifacts.length > 0 + ? html` +
+
+
+
+
this._syncScrollState()} + > + ${this.artifacts.map((artifact) => + renderArtifactTile(artifact) + )} +
+ ${this.hasOverflow + ? html` + + + ` + : nothing} +
+ +
+
+
+ ` + : nothing} + +
+ + +
+
+ +
+ + +
+
+ + + +

${this.readout}

+
+
+ `; + } +} + +@customElement('swc-multi-artifact-view-more-demo') +class MultiArtifactViewMoreDemo extends LitElement { + private static visibleCount = 8; + + @state() + private artifacts: DemoArtifact[] = createSeedArtifacts(18); + + @state() + private value = demoPrompt; + + @state() + private readout = + 'Shows eight inline tiles, then View more for the rest. Use + to add files.'; + + @state() + private dialogOpen = false; + + @query('.MultiArtifactDemo-view-more-dialog') + private _dialog!: HTMLDialogElement; + + @query('.MultiArtifactDemo-view-more') + private _viewMoreButton!: HTMLButtonElement; + + @query('[data-file-input]') + private _fileInput!: HTMLInputElement; + + protected override createRenderRoot(): this { + return this; + } + + public override disconnectedCallback(): void { + document.removeEventListener( + 'pointerdown', + this._backdropPointerDown, + true + ); + if (this._dialog?.open) { + this._dialog.close(); + } + revokeArtifactUrls(this.artifacts); + super.disconnectedCallback?.(); + } + + private _handleArtifactDismiss(event: Event): void { + const artifactId = getDismissedArtifactId(event); + if (!artifactId) { + return; + } + + const removed = this.artifacts.find( + (artifact) => artifact.id === artifactId + ); + if (removed?.thumbnailUrl) { + URL.revokeObjectURL(removed.thumbnailUrl); + } + + this.artifacts = this.artifacts.filter( + (artifact) => artifact.id !== artifactId + ); + this.readout = `Removed ${removed?.fileName ?? 'artifact'}. ${this.artifacts.length} attachment${this.artifacts.length === 1 ? '' : 's'} remaining.`; + + if (this.artifacts.length <= MultiArtifactViewMoreDemo.visibleCount) { + this._closeViewMoreDialog(); + } else if (this._dialog?.open) { + this.updateComplete.then(() => this._positionViewMoreDialog()); + } + } + + private _handleUploadClick(event: Event): void { + event.preventDefault(); + this._fileInput?.click(); + } + + private _handleFileChange(event: Event): void { + const input = event.target as HTMLInputElement; + const files = Array.from(input.files ?? []); + if (!files.length) { + return; + } + + const nextArtifacts = createArtifactsFromFiles( + files, + this.artifacts.length + ); + this.artifacts = [...this.artifacts, ...nextArtifacts]; + this.readout = `Added ${files.length} file${files.length === 1 ? '' : 's'}. ${this.artifacts.length} attachment${this.artifacts.length === 1 ? '' : 's'} total.`; + input.value = ''; + + if (this._dialog?.open) { + this.updateComplete.then(() => this._positionViewMoreDialog()); + } + } + + private _backdropPointerDown = (event: PointerEvent): void => { + const target = event.target as Node; + const panel = this._dialog?.querySelector( + '.MultiArtifactDemo-view-more-dialog-panel' + ); + + if ( + panel?.contains(target) || + this._viewMoreButton?.contains(target) || + target === this._viewMoreButton + ) { + return; + } + + this._closeViewMoreDialog(); + }; + + private _openViewMoreDialog(): void { + const dialog = this._dialog; + if (!dialog || dialog.open) { + return; + } + + dialog.showModal(); + this.dialogOpen = true; + document.addEventListener('pointerdown', this._backdropPointerDown, true); + this.readout = `Opened view-more dialog with ${this._overflowArtifacts.length} additional artifact${this._overflowArtifacts.length === 1 ? '' : 's'}.`; + this.updateComplete.then(() => this._positionViewMoreDialog()); + } + + private _closeViewMoreDialog(): void { + if (this._dialog?.open) { + this._dialog.close(); + } + } + + private _handleViewMoreClick(): void { + if (this._dialog?.open) { + this._closeViewMoreDialog(); + return; + } + + this._openViewMoreDialog(); + } + + private _handleDialogClose(): void { + document.removeEventListener( + 'pointerdown', + this._backdropPointerDown, + true + ); + this.dialogOpen = false; + this.readout = 'Closed view-more dialog.'; + this._viewMoreButton?.focus(); + } + + private _positionViewMoreDialog(): void { + const button = this._viewMoreButton; + const dialog = this._dialog; + if (!button || !dialog) { + return; + } + + const rect = button.getBoundingClientRect(); + const gap = 8; + const top = rect.top - dialog.offsetHeight - gap; + const left = rect.right - dialog.offsetWidth; + + dialog.style.top = `${Math.max(8, top)}px`; + dialog.style.left = `${Math.max(8, left)}px`; + } + + private get _visibleArtifacts(): DemoArtifact[] { + return this.artifacts.slice(0, MultiArtifactViewMoreDemo.visibleCount); + } + + private get _overflowArtifacts(): DemoArtifact[] { + return this.artifacts.slice(MultiArtifactViewMoreDemo.visibleCount); + } + + private _handleSubmit(): void { + this.readout = `Submitted prompt with ${this.artifacts.length} attachment${this.artifacts.length === 1 ? '' : 's'}.`; + } + + protected override render(): TemplateResult { + const overflowCount = this._overflowArtifacts.length; + + return html` + ${demoStyles} +
+
+
+
+ ${this.artifacts.length > 0 + ? html` +
+
+ ${this._visibleArtifacts.map((artifact) => + renderArtifactTile(artifact) + )} + ${overflowCount > 0 + ? html` + + ` + : nothing} +
+
+ ` + : nothing} + +
+ + +
+
+ +
+ + +
+
+ + + +

${this.readout}

+ + +
+
+
+ ${this._overflowArtifacts.map((artifact) => + renderArtifactTile(artifact) + )} +
+
+
+
+
+
+ `; + } +} + +void MultiArtifactScrollDemo; +void MultiArtifactVerticalScrollDemo; +void MultiArtifactViewMoreDemo; + +const meta: Meta = { + title: 'Conversational AI/Prompt field/Multi-artifact demos', + parameters: { + docs: { + disable: true, + page: null, + }, + layout: 'fullscreen', + }, + tags: ['dev'], +}; + +export default meta; + +/** + * Horizontal scroll strip with previous/next controls and a synced scrollbar. + * Starts with 18 seeded artifacts so overflow is immediate. + */ +export const ScrollGallery: Story = { + render: () => html` + + `, +}; + +/** + * Wrapped artifact strip capped at two rows, with vertical scroll and a synced + * scrollbar. Starts with 18 seeded artifacts so overflow is immediate. + */ +export const ScrollGalleryVertical: Story = { + render: () => html` + + `, +}; + +/** + * Inline cap of eight artifacts, with a view-more dialog for the remainder. + * Starts with 18 seeded artifacts so the overflow button appears immediately. + */ +export const ViewMorePopover: Story = { + render: () => html` + + `, +}; + +// ──────────────────────────────── +// DRAG AND DROP STORY +// ──────────────────────────────── + +@customElement('swc-multi-artifact-drag-drop-demo') +class MultiArtifactDragDropDemo extends LitElement { + @state() + private artifacts: DemoArtifact[] = createSeedArtifacts(8); + + @state() + private value = demoPrompt; + + @state() + private readout = + 'Drag files onto the composer or use + to add files and dismiss tiles to remove them.'; + + @state() + private isDragged = false; + + private _debouncedDragLeave: number | null = null; + + private _dropzone: HTMLElement | null = null; + + @query('[data-file-input]') + private _fileInput!: HTMLInputElement; + + protected override createRenderRoot(): this { + return this; + } + + public override disconnectedCallback(): void { + this._bindDropzoneListeners(false); + this._clearDebouncedDragLeave(); + revokeArtifactUrls(this.artifacts); + super.disconnectedCallback?.(); + } + + public override firstUpdated(): void { + this._dropzone = this.querySelector('[data-dropzone]'); + this._bindDropzoneListeners(true); + } + + private _bindDropzoneListeners(bind: boolean): void { + const zone = this._dropzone ?? this.querySelector('[data-dropzone]'); + if (!zone) { + return; + } + + this._dropzone = zone; + + if (bind) { + zone.addEventListener('dragover', this._onDragOver); + zone.addEventListener('dragleave', this._onDragLeave); + zone.addEventListener('drop', this._onDrop); + return; + } + + zone.removeEventListener('dragover', this._onDragOver); + zone.removeEventListener('dragleave', this._onDragLeave); + zone.removeEventListener('drop', this._onDrop); + } + + private _clearDebouncedDragLeave(): void { + if (this._debouncedDragLeave !== null) { + window.clearTimeout(this._debouncedDragLeave); + this._debouncedDragLeave = null; + } + } + + /** + * Mirrors sp-dropzone should-accept: cancelable gate before showing dragged state. + */ + private _shouldAcceptDrop(event: DragEvent): boolean { + const shouldAcceptEvent = new CustomEvent( + 'swc-multi-artifact-drop-should-accept', + { + bubbles: true, + cancelable: true, + composed: true, + detail: event, + } + ); + + if (!this.dispatchEvent(shouldAcceptEvent)) { + return false; + } + + const types = event.dataTransfer?.types; + if (!types?.length) { + return true; + } + + return Array.from(types).includes('Files'); + } + + private _onDragOver = (event: DragEvent): void => { + event.preventDefault(); + + if (!this._shouldAcceptDrop(event)) { + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'none'; + } + return; + } + + this._clearDebouncedDragLeave(); + + if (!this.isDragged) { + this.isDragged = true; + } + + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'copy'; + } + }; + + private _onDragLeave = (event: DragEvent): void => { + const zone = event.currentTarget as HTMLElement; + if (event.relatedTarget && zone.contains(event.relatedTarget as Node)) { + return; + } + + this._clearDebouncedDragLeave(); + this._debouncedDragLeave = window.setTimeout(() => { + this.isDragged = false; + }, 100); + }; + + private _onDrop = (event: DragEvent): void => { + event.preventDefault(); + + if (!this.isDragged) { + return; + } + + this._clearDebouncedDragLeave(); + this.isDragged = false; + this._acceptFiles(getDraggedFiles(event), 'drop'); + }; + + private _handleArtifactDismiss(event: Event): void { + const artifactId = getDismissedArtifactId(event); + if (!artifactId) { + return; + } + + const removed = this.artifacts.find( + (artifact) => artifact.id === artifactId + ); + if (removed?.thumbnailUrl) { + URL.revokeObjectURL(removed.thumbnailUrl); + } + + this.artifacts = this.artifacts.filter( + (artifact) => artifact.id !== artifactId + ); + this.readout = `Removed ${removed?.fileName ?? 'artifact'}.`; + } + + private _handleUploadClick(event: Event): void { + event.preventDefault(); + this._fileInput?.click(); + } + + private _acceptFiles(files: File[], source: 'drop' | 'picker'): void { + if (!files.length) { + if (source === 'drop') { + this.readout = 'No files were dropped.'; + } + return; + } + + const nextArtifacts = createArtifactsFromFiles( + files, + this.artifacts.length + ); + this.artifacts = [...this.artifacts, ...nextArtifacts]; + this.readout = + source === 'drop' + ? `Added ${files.length} file${files.length === 1 ? '' : 's'} from drop. ${this.artifacts.length} attachment${this.artifacts.length === 1 ? '' : 's'} total.` + : `Added ${files.length} file${files.length === 1 ? '' : 's'}. ${this.artifacts.length} attachment${this.artifacts.length === 1 ? '' : 's'} total.`; + } + + private _handleFileChange(event: Event): void { + const input = event.target as HTMLInputElement; + const files = Array.from(input.files ?? []); + this._acceptFiles(files, 'picker'); + input.value = ''; + } + + private _handleSubmit(): void { + this.readout = `Submitted prompt with ${this.artifacts.length} attachment${this.artifacts.length === 1 ? '' : 's'}.`; + } + + protected override render(): TemplateResult { + return html` + ${demoStyles} +
+
+
+
+
+ ${this.artifacts.length > 0 + ? html` +
+
+ ${this.artifacts.map((artifact) => + renderArtifactTile(artifact) + )} +
+
+ ` + : nothing} + +
+ + +
+
+ +
+ + +
+
+ + ${this.isDragged + ? html` + + ` + : nothing} +
+ + + +

${this.readout}

+
+
+ `; + } +} + +void MultiArtifactDragDropDemo; + +/** + * Same composer layout as the other multi-artifact demos. Drag-and-drop follows + * the sp-dropzone interaction model: dragover with preventDefault, debounced + * dragleave, and a dragged visual state on the composer box. + */ +export const DragAndDropUpload: Story = { + render: () => html` + + `, +}; diff --git a/2nd-gen/packages/swc/patterns/conversational-ai/upload-artifact/UploadArtifact.ts b/2nd-gen/packages/swc/patterns/conversational-ai/upload-artifact/UploadArtifact.ts index 2d2d15e3e6a..1ca7707525d 100644 --- a/2nd-gen/packages/swc/patterns/conversational-ai/upload-artifact/UploadArtifact.ts +++ b/2nd-gen/packages/swc/patterns/conversational-ai/upload-artifact/UploadArtifact.ts @@ -11,12 +11,10 @@ */ import { CSSResultArray, html, TemplateResult } from 'lit'; -import { property } from 'lit/decorators.js'; +import { property, queryAssignedElements } from 'lit/decorators.js'; import { SpectrumElement } from '@spectrum-web-components/core/element/index.js'; -import '@adobe/spectrum-wc/components/icon/swc-icon.js'; - import { CrossIcon } from '../utils/icons/index.js'; import styles from './upload-artifact.css'; @@ -27,11 +25,11 @@ import styles from './upload-artifact.css'; * @element swc-upload-artifact * * @slot thumbnail - Shared visual slot for icon/thumbnail/preview image. + * @slot badge - Optional file-type badge rendered over media previews (for example, "PDF"). * @slot title - Primary text label. * @slot subtitle - Secondary text label. * @slot actions - Optional trailing actions. - * @fires swc-upload-artifact-dismiss - Dispatched when the dismiss button is pressed. - * Detail: `{ artifact: this }` + * @fires swc-upload-artifact-dismiss - Dispatched when the dismiss button is pressed. Detail includes the artifact element. */ export class UploadArtifact extends SpectrumElement { /** Visual treatment type for this artifact. */ @@ -46,10 +44,21 @@ export class UploadArtifact extends SpectrumElement { @property({ type: String, attribute: 'dismiss-label' }) public dismissLabel = 'Remove attachment'; + @queryAssignedElements({ slot: 'badge', flatten: true }) + private _assignedBadge!: HTMLElement[]; + public static override get styles(): CSSResultArray { return [styles]; } + private _handleSlotChange(): void { + this.requestUpdate(); + } + + private _hasBadgeContent(): boolean { + return (this._assignedBadge?.length ?? 0) > 0; + } + private _handleDismissClick(): void { this.dispatchEvent( new CustomEvent('swc-upload-artifact-dismiss', { @@ -60,45 +69,74 @@ export class UploadArtifact extends SpectrumElement { ); } + private _renderDismissButton(): TemplateResult { + return html` + + `; + } + + private _renderBadge(): TemplateResult { + if (!this._hasBadgeContent()) { + return html` + + `; + } + + return html` +
+ +
+ `; + } + protected override render(): TemplateResult { const isMedia = this.type === 'media'; + if (isMedia) { + return html` + ${this._renderDismissButton()} +
+
+
+ +
+ ${this._renderBadge()} +
+ +
+
+
+ `; + } + return html` + ${this._renderDismissButton()}
- -
- - ${isMedia - ? html` -
- -
- ` - : html` -
-
- -
-
- -
-
- -
- -
- `} +
+
+ +
+
+ +
+
+
+ +
`; diff --git a/2nd-gen/packages/swc/patterns/conversational-ai/upload-artifact/stories/upload-artifact.stories.ts b/2nd-gen/packages/swc/patterns/conversational-ai/upload-artifact/stories/upload-artifact.stories.ts index 70573cdef77..108d3956af6 100644 --- a/2nd-gen/packages/swc/patterns/conversational-ai/upload-artifact/stories/upload-artifact.stories.ts +++ b/2nd-gen/packages/swc/patterns/conversational-ai/upload-artifact/stories/upload-artifact.stories.ts @@ -162,13 +162,27 @@ export const Card: Story = { }; /** - * Media type uses a larger preview region without title and subtitle text. + * Media type uses a square preview region. Optional badge text identifies document types such as PDF. */ export const Media: Story = { render: () => html` -
+
-
+
+
+ +
+ PDF
`, diff --git a/2nd-gen/packages/swc/patterns/conversational-ai/upload-artifact/test/upload-artifact.test.ts b/2nd-gen/packages/swc/patterns/conversational-ai/upload-artifact/test/upload-artifact.test.ts index bc2d7161cde..05171f59bcf 100644 --- a/2nd-gen/packages/swc/patterns/conversational-ai/upload-artifact/test/upload-artifact.test.ts +++ b/2nd-gen/packages/swc/patterns/conversational-ai/upload-artifact/test/upload-artifact.test.ts @@ -48,7 +48,9 @@ export const OverviewTest: Story = { const dismissButton = el.shadowRoot?.querySelector( '.swc-UploadArtifact-dismiss' ); - const icon = dismissButton?.querySelector('swc-icon'); + const icon = dismissButton?.querySelector( + '.swc-UploadArtifact-dismiss-icon' + ); expect(dismissButton?.getAttribute('aria-label')).toBe( 'Remove attachment' ); @@ -152,3 +154,38 @@ export const MediaPreviewOnlyTest: Story = { }); }, }; + +export const MediaBadgeTest: Story = { + render: () => html` + +
+ PDF +
+ `, + play: async ({ canvasElement, step }) => { + const el = await getComponent( + canvasElement, + 'swc-upload-artifact' + ); + + await step('media artifact renders badge overlay', async () => { + expect( + el.shadowRoot?.querySelector('.swc-UploadArtifact-badge') + ).toBeTruthy(); + expect(el.querySelector('[slot="badge"]')?.textContent?.trim()).toBe( + 'PDF' + ); + }); + + await step( + 'media dismiss button renders as a sibling of the preview surface', + async () => { + const root = el.shadowRoot; + const surface = root?.querySelector('.swc-UploadArtifact-surface'); + const dismissButton = root?.querySelector('.swc-UploadArtifact-dismiss'); + expect(surface).toBeTruthy(); + expect(dismissButton).toBeTruthy(); + } + ); + }, +}; diff --git a/2nd-gen/packages/swc/patterns/conversational-ai/upload-artifact/upload-artifact.css b/2nd-gen/packages/swc/patterns/conversational-ai/upload-artifact/upload-artifact.css index 3406c2dcad2..b5fab5173e5 100644 --- a/2nd-gen/packages/swc/patterns/conversational-ai/upload-artifact/upload-artifact.css +++ b/2nd-gen/packages/swc/patterns/conversational-ai/upload-artifact/upload-artifact.css @@ -23,33 +23,104 @@ } .swc-UploadArtifact-dismiss { + position: absolute; + z-index: 2; + padding: 0; + color: token("gray-25"); + background: transparent; + border: none; + cursor: pointer; +} + +.swc-UploadArtifact-dismiss-visual, +.swc-UploadArtifact-dismiss-icon { + pointer-events: none; +} + +.swc-UploadArtifact-dismiss-icon svg { + display: block; + inline-size: var(--swc-upload-artifact-dismiss-icon-inline-size, 10px); + block-size: var(--swc-upload-artifact-dismiss-icon-block-size, 10px); +} + +.swc-UploadArtifact-dismiss[hidden] { + display: none; +} + +.swc-UploadArtifact-dismiss:hover { + background: transparent; +} + +:host([type="media"]) .swc-UploadArtifact-dismiss { + /* 32px hit target centered on the 20px visual at spacing-75 (4px) inset. */ + inset-block-start: calc(token("spacing-75") - 6px); + inset-inline-end: calc(token("spacing-75") - 6px); + inline-size: 32px; + block-size: 32px; +} + +:host([type="media"]) .swc-UploadArtifact-dismiss-visual { + position: absolute; + inset-block-start: 6px; + inset-inline-end: 6px; + inline-size: 20px; + block-size: 20px; + background: token("transparent-black-700"); + border-radius: token("corner-radius-full"); +} + +:host([type="media"]) .swc-UploadArtifact-dismiss-icon { display: flex; position: absolute; - inset-block-start: -12px; - inset-inline-end: -12px; - z-index: 1; + inset-block-start: 6px; + inset-inline-end: 6px; align-items: center; justify-content: center; - inline-size: var(--swc-upload-artifact-dismiss-inline-size, 24px); - block-size: var(--swc-upload-artifact-dismiss-block-size, 24px); - padding: 0; - color: token("gray-800"); + inline-size: 20px; + block-size: 20px; +} + +:host([type="card"]) .swc-UploadArtifact-dismiss { + inset-block-start: -12px; + inset-inline-end: -12px; + inline-size: var(--swc-upload-artifact-dismiss-inline-size, 44px); + block-size: var(--swc-upload-artifact-dismiss-block-size, 44px); +} + +:host([type="card"]) .swc-UploadArtifact-dismiss-visual { + position: absolute; + inset-block-start: 0; + inset-inline-end: 0; + inline-size: 24px; + block-size: 24px; background: token("gray-200"); border: 1px solid token("gray-25"); border-radius: token("corner-radius-full"); } -.swc-UploadArtifact-dismiss[hidden] { - display: none; +:host([type="card"]) .swc-UploadArtifact-dismiss-icon { + display: flex; + position: absolute; + inset-block-start: 0; + inset-inline-end: 0; + align-items: center; + justify-content: center; + inline-size: 24px; + block-size: 24px; } -.swc-UploadArtifact-dismiss:hover { +:host([type="media"]) .swc-UploadArtifact-dismiss:hover .swc-UploadArtifact-dismiss-visual { + background: token("transparent-black-800"); +} + +:host([type="card"]) .swc-UploadArtifact-dismiss:hover .swc-UploadArtifact-dismiss-visual { background: token("gray-300"); } -.swc-UploadArtifact-dismiss swc-icon { - --swc-icon-inline-size: var(--swc-upload-artifact-dismiss-icon-inline-size, 10px); - --swc-icon-block-size: var(--swc-upload-artifact-dismiss-icon-block-size, 10px); +.swc-UploadArtifact { + position: relative; + inline-size: 100%; + block-size: 100%; } .swc-UploadArtifact-surface { @@ -149,25 +220,27 @@ } :host([type="media"]) { - inline-size: var(--swc-upload-artifact-preview-size, 68px); - block-size: var(--swc-upload-artifact-preview-size, 68px); -} - -:host([type="media"]) .swc-UploadArtifact { - inline-size: 100%; - block-size: 100%; + flex: 0 0 auto; + inline-size: var(--swc-upload-artifact-preview-size, 64px); + min-inline-size: var(--swc-upload-artifact-preview-size, 64px); + block-size: var(--swc-upload-artifact-preview-size, 64px); + min-block-size: var(--swc-upload-artifact-preview-size, 64px); + overflow: visible; } :host([type="media"]) .swc-UploadArtifact-surface { + position: relative; flex-direction: column; - gap: token("spacing-100"); + block-size: 100%; + border-radius: token("spacing-200"); overflow: hidden; } :host([type="media"]) .swc-UploadArtifact-thumbnail { + block-size: 100%; background: token("gray-100"); border: 1px solid transparent; - border-radius: token("corner-radius-200"); + border-radius: token("spacing-200"); overflow: hidden; } @@ -184,36 +257,35 @@ block-size: 100%; } -:host([type="media"]) .swc-UploadArtifact-meta { - inline-size: 100%; -} - -:host([type="media"]) .swc-UploadArtifact-header { - display: flex; - gap: token("spacing-100"); - align-items: flex-start; - justify-content: space-between; -} - -:host([type="media"]) .swc-UploadArtifact-header > div { - min-inline-size: 0; -} - -:host([type="media"]) .swc-UploadArtifact-title { - flex: 1 1 0; - min-inline-size: 0; - line-height: token("line-height-font-size-100"); - overflow: hidden; +:host([type="media"]) .swc-UploadArtifact-badge { + display: inline-flex; + position: absolute; + inset-block-end: token("corner-radius-75"); + inset-inline-start: token("corner-radius-75"); + z-index: 1; + align-items: center; + max-inline-size: calc(100% - (2 * token("corner-radius-75"))); + min-block-size: 24px; + padding-block: token("spacing-50"); + padding-inline: token("spacing-100"); + 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-UploadArtifact-title ::slotted(*) { +:host([type="media"]) .swc-UploadArtifact-badge ::slotted(*) { display: block; min-inline-size: 0; + max-inline-size: 152px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -:host([type="media"]) .swc-UploadArtifact-subtitle { - line-height: token("line-height-font-size-75"); +:host([type="media"]) .swc-UploadArtifact-actions { + display: none; } 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 2078e91406c..260a104d713 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 @@ -11,58 +11,134 @@ */ import { CSSResultArray, html, TemplateResult } from 'lit'; -import { property } from 'lit/decorators.js'; +import { queryAssignedElements, queryAssignedNodes } from 'lit/decorators.js'; import { SpectrumElement } from '@spectrum-web-components/core/element/index.js'; import styles from './user-message.css'; -export type UserMessageType = 'copy' | 'card' | 'media'; - /** - * 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"`. + * User-authored message container for conversational AI pattern exploration. * * @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 artifacts-media - Optional media artifacts rendered above file artifacts and message text. + * @slot artifacts-file - Optional file/card artifacts rendered above message text. + * @slot - Optional message copy rendered as the text bubble. */ export class UserMessage extends SpectrumElement { - /** - * Visual content type for the user message bubble. - */ - @property({ type: String, reflect: true }) - public type: UserMessageType = 'copy'; + @queryAssignedElements({ slot: 'artifacts-media', flatten: true }) + private _assignedMediaArtifacts!: HTMLElement[]; + + @queryAssignedElements({ slot: 'artifacts-file', flatten: true }) + private _assignedFileArtifacts!: HTMLElement[]; + + @queryAssignedNodes({ flatten: true }) + private _assignedDefaultNodes!: Node[]; public static override get styles(): CSSResultArray { return [styles]; } + private _handleSlotChange(): void { + this.requestUpdate(); + } + + private _handleHostClick(event: MouseEvent): void { + const target = event.composedPath()[0]; + if (!(target instanceof HTMLElement)) { + return; + } + if (!target.classList.contains('swc-UserMessage-overflow-indicator')) { + return; + } + this.dispatchEvent( + new CustomEvent('swc-user-message-view-all-click', { + bubbles: true, + composed: true, + }) + ); + } + + private _hasTextContent(): boolean { + return (this._assignedDefaultNodes ?? []).some((node) => { + if (node.nodeType === Node.TEXT_NODE) { + return Boolean(node.textContent?.trim()); + } + + return node.nodeType === Node.ELEMENT_NODE; + }); + } + + private _renderMediaArtifacts(): TemplateResult | null { + const count = this._assignedMediaArtifacts?.length ?? 0; + if (count === 0) { + return html` + + `; + } + + return html` +
+ +
+ `; + } + + private _renderFileArtifacts(): TemplateResult | null { + const count = this._assignedFileArtifacts?.length ?? 0; + if (count === 0) { + return html` + + `; + } + + return html` +
+ +
+ `; + } + + private _renderTextBubble(): TemplateResult | null { + if (!this._hasTextContent()) { + return html` + + `; + } + + return html` +
+
+ +
+
+ `; + } + protected override render(): TemplateResult { - return this.type === 'copy' - ? html` - - ` - : html` -
-
- -
-
-
- -
-
- -
-
-
- `; + return html` +
+ ${this._renderMediaArtifacts()} ${this._renderFileArtifacts()} + ${this._renderTextBubble()} +
+ `; + } + + protected override firstUpdated(): void { + this.addEventListener('click', this._handleHostClick); } } 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 b8c9d3beabd..ef59c2a094c 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 @@ -10,53 +10,25 @@ * governing permissions and limitations under the License. */ -import { html } from 'lit'; +import { html, LitElement } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; import type { Meta, StoryObj as Story } from '@storybook/web-components'; -import { getStorybookHelpers } from '@wc-toolkit/storybook-helpers'; import '../../conversation-turn/index.js'; +import '../../upload-artifact/index.js'; import '../index.js'; -// ──────────────── -// METADATA -// ──────────────── - -const { args, argTypes, template } = getStorybookHelpers('swc-user-message'); -delete (args as Record).content; -delete (argTypes as Record).content; - -argTypes.type = { - ...argTypes.type, - control: { type: 'select' }, - options: ['copy', 'card', 'media'], - table: { - category: 'attributes', - defaultValue: { summary: 'copy' }, - }, -}; - -// Wraps a single swc-user-message in a conversation turn for proper alignment. const withUserTurn = (story: () => unknown) => html` ${story()} `; -/** - * User-authored message bubble. Use inside `` for thread alignment. - * - * - * Note: This component does not sanitize slotted content. When rendering user-provided - * or AI-generated markup, consumers must sanitize input to prevent XSS and - * validate link targets. This is the consumer's responsibility. - */ const meta: Meta = { title: 'Conversational AI/User message', component: 'swc-user-message', - args, - argTypes, - render: (args) => template(args), parameters: { docs: { - subtitle: 'User-submitted message rendered in the thread.', + subtitle: + 'User-submitted message rendered in the thread with optional media artifacts, file artifacts, and text.', }, layout: 'padded', }, @@ -66,147 +38,338 @@ const meta: Meta = { export { meta }; export default meta; -// ──────────────────── -// AUTODOCS STORY -// ──────────────────── - export const Playground: Story = { - args: { - type: 'copy', - 'default-slot': - 'Can you help me create a 45-minute presentation, with animations, for an executive update?', - 'thumbnail-slot': - 'Placeholder', - 'title-slot': 'Hilton commercial assets', - 'subtitle-slot': '2026', - }, + render: () => html` + + Can you help me create a 45-minute presentation, with animations, for an + executive update? + + `, decorators: [withUserTurn], tags: ['autodocs', 'dev'], }; -// ────────────────────────────── -// OVERVIEW STORY -// ────────────────────────────── - export const Overview: Story = { - args: { - type: 'copy', - 'default-slot': - 'Can you help me create a 45-minute presentation, with animations, for an executive update?', - 'thumbnail-slot': - 'Placeholder', - 'title-slot': 'Hilton commercial assets', - 'subtitle-slot': '2026', - }, + render: () => html` + + Can you help me create a 45-minute presentation, with animations, for an + executive update? + + `, decorators: [withUserTurn], tags: ['overview'], }; -// ────────────────────────── -// ANATOMY STORY -// ────────────────────────── - -/** - * A user message consists of: - * - * 1. **Bubble** — Rounded container with a neutral gray background (`gray-50`) - * 2. **Default slot** — The message content: plain text, a card attachment, or image-first content - */ -export const Anatomy: Story = { - args: { - 'default-slot': 'Can you help me create a 45-minute presentation?', - }, - decorators: [withUserTurn], - tags: ['anatomy'], -}; - -// ────────────────────────── -// OPTIONS STORIES -// ────────────────────────── - -/** - * 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 - */ export const Content: Story = { render: () => html`
-
- - - Can you help me create a 45-minute presentation, with animations, - for an executive update? - - - Copy -
-
- - + + +
- Hilton commercial assets - 2026 -
-
- Card -
-
- - + +
- Hilton commercial assets - 2026 -
-
- Media -
+ + +
+
+ +
+
+ + +
+ Launch brief budget + Excel +
+ +
+ FY26Q1 Competitor analysis + PDF +
+ + Can you help me create a 45-minute presentation, with animations, for + an executive update? + +
`, parameters: { 'section-order': 1 }, tags: ['options'], }; -// ──────────────────────────────── -// ACCESSIBILITY STORY -// ──────────────────────────────── +export const OverflowIndicator: Story = { + render: () => html` + + +
+
+ +
+
+ +
+
+ -/** - * ### Features - * - * The `` element implements the following accessibility features: - * - * #### Semantic structure - * - * - 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 - * - * ### Best practices - * - * - Ensure message text is descriptive and self-contained - * - For card and media attachments, ensure titles/subtitles and `aria-label`/`alt` text are present for previews - */ -export const Accessibility: Story = { - args: { - type: 'copy', - 'default-slot': - 'Can you help me create a 45-minute presentation, with animations, for an executive update?', - }, + +
+ Launch brief budget + Excel +
+ +
+ FY26Q1 Competitor analysis + PDF +
+ + Can you help me create a 45-minute presentation, with animations, for an + executive update? + + `, + decorators: [withUserTurn], + tags: ['states'], +}; + +@customElement('swc-user-message-overflow-demo') +class UserMessageOverflowDemo extends LitElement { + @state() + private open = false; + + protected override createRenderRoot(): this { + return this; + } + + private _openOverlay(): void { + this.open = true; + } + + private _closeOverlay(): void { + this.open = false; + } + + public override render() { + return html` +
+ + + +
+
+ +
+
+ +
+
+ + +
+ Launch brief budget + Excel +
+ +
+ FY26Q1 Competitor analysis + PDF +
+ Can you help me create a 45-minute presentation, with animations, + for an executive update? +
+
+ + ${this.open + ? html` +
+
+ ${Array.from({ length: 8 }).map( + (_, i) => html` +
+ ` + )} +
+ +
+ ` + : null} +
+ `; + } +} + +void UserMessageOverflowDemo; + +export const ViewAllOverlay: Story = { + render: () => html` + + `, + tags: ['states'], +}; + +export const MediaGrid: Story = { + render: () => html` + + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + +
+ Launch brief budget + Excel +
+ +
+ FY26Q1 Competitor analysis + PDF +
+ + Can you help me create a 45-minute presentation, with animations, for an + executive update? +
+ `, decorators: [withUserTurn], - tags: ['a11y'], + tags: ['states'], }; 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..472863bbcc1 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 @@ -1,28 +1,11 @@ /** * 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 { expect, test } from '@playwright/test'; import { gotoStory } from '../../../../utils/a11y-helpers.js'; -/** - * Accessibility tests for UserMessage pattern (2nd Generation) - * - * ARIA snapshot tests validate the accessibility tree structure. - * aXe WCAG compliance and color contrast validation are run via - * test-storybook (see .storybook/test-runner.ts). Both are included - * in the `test:a11y` command. - */ - test.describe('UserMessage - ARIA Snapshots', () => { test('should have correct accessibility tree for default user message', async ({ page, @@ -37,31 +20,21 @@ test.describe('UserMessage - ARIA Snapshots', () => { `); }); - test('should expose accessible card attachment content', async ({ page }) => { + test('should expose mixed artifacts and text content', async ({ page }) => { const root = await gotoStory( page, 'patterns-conversational-ai-user-message--content', 'swc-user-message' ); - const cardMessage = root.locator('swc-user-message').nth(1); - await expect(cardMessage).toMatchAriaSnapshot(` - - img "File" - - text: Hilton commercial assets 2026 - `); - }); - - test('should expose accessible media attachment content', async ({ - page, - }) => { - const root = await gotoStory( - page, - 'patterns-conversational-ai-user-message--content', - 'swc-user-message' - ); - const mediaMessage = root.locator('swc-user-message').nth(2); - await expect(mediaMessage).toMatchAriaSnapshot(` - - img "Campaign preview" - - text: Hilton commercial assets 2026 + await expect(root).toMatchAriaSnapshot(` + - img "Campaign still" + - img "Storyboard frame" + - img "Moodboard" + - img "Excel" + - text: Launch brief budget Excel + - img "PDF" + - text: FY26Q1 Competitor analysis PDF + - text: /Can you help me/ `); }); }); 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 7ab95ecfb2e..9843779bca6 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 @@ -3,21 +3,14 @@ * 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 { html } from 'lit'; import { expect } from '@storybook/test'; import type { Meta, StoryObj as Story } from '@storybook/web-components'; -import '../../conversation-turn/index.js'; import '../index.js'; -import { getComponent, getComponents } from '../../../../utils/test-utils.js'; +import { getComponent } from '../../../../utils/test-utils.js'; import { meta, Overview } from '../stories/user-message.stories.js'; import { UserMessage } from '../UserMessage.js'; @@ -39,196 +32,10 @@ export const OverviewTest: Story = { 'swc-user-message' ); - await step('uses copy type by default', async () => { - expect(el.type).toBe('copy'); - expect(el.getAttribute('type')).toBe('copy'); + await step('renders composed user-message shell', async () => { + const root = el.shadowRoot; + expect(root?.querySelector('.swc-UserMessage')).toBeTruthy(); + expect(root?.querySelector('.swc-UserMessage-text-bubble')).toBeTruthy(); }); }, }; - -export const TypeAndSlotTest: Story = { - ...Overview, - play: async ({ canvasElement, step }) => { - const el = await getComponent( - canvasElement, - 'swc-user-message' - ); - - await step( - 'type reflects to the host and drives card structure', - async () => { - el.type = 'card'; - el.innerHTML = ` -
- Brand guidelines - PDF - `; - await el.updateComplete; - - const title = el.shadowRoot?.querySelector('.swc-UserMessage-title'); - const subtitle = el.shadowRoot?.querySelector( - '.swc-UserMessage-subtitle' - ); - expect(el.getAttribute('type')).toBe('card'); - expect(title).toBeTruthy(); - expect(subtitle).toBeTruthy(); - } - ); - - await step( - 'media type renders the media attachment container', - async () => { - el.type = 'media'; - el.innerHTML = ` -
- Preview image - PNG - `; - await el.updateComplete; - - const attachment = el.shadowRoot?.querySelector( - '.swc-UserMessage-attachment--media' - ); - expect(el.getAttribute('type')).toBe('media'); - expect(attachment).toBeTruthy(); - } - ); - - await step('copy type uses the default slot text path', async () => { - el.type = 'copy'; - el.innerHTML = `Can you summarize this document?`; - await el.updateComplete; - - const textSlot = - el.shadowRoot?.querySelector('slot:not([name])'); - const assignedText = textSlot - ?.assignedNodes({ flatten: true }) - .map((node) => node.textContent ?? '') - .join('') - .trim(); - - expect(el.getAttribute('type')).toBe('copy'); - expect(assignedText).toBe('Can you summarize this document?'); - }); - }, -}; - -export const DefaultSlotHiddenForAttachmentTypesTest: Story = { - name: 'Default slot not used for card and media', - ...Overview, - play: async ({ canvasElement, step }) => { - const el = await getComponent( - canvasElement, - 'swc-user-message' - ); - - const attachmentMarkup = (label: string) => ` -

${label}

-
- T - S - `; - - 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); - } - ); - } - - await step( - 'type="copy" keeps an unnamed (default) slot in the shadow root', - async () => { - el.type = 'copy'; - el.innerHTML = 'Visible copy text'; - await el.updateComplete; - - const defaultSlot = - el.shadowRoot?.querySelector('slot:not([name])'); - expect(defaultSlot).toBeTruthy(); - } - ); - }, -}; - -const longSpacedCopy = - 'This is a deliberately long line of user copy that should wrap within a narrow column without horizontal overflow. '.repeat( - 3 - ); - -const longUnbrokenFileName = `${'VeryLongAttachmentNamePortion'.repeat(12)}.pdf`; - -/** - * Exercises long copy in a tight column and a long unbroken card title - * (ellipsis) so the bubble does not grow past its layout width. - */ -export const LongTextWrapTest: Story = { - name: 'Long text wrap and containment', - render: () => html` -
- - ${longSpacedCopy} - -
-
- - -
- ${longUnbrokenFileName} - PDF -
-
-
- `, - play: async ({ canvasElement, step }) => { - const [copyBubble, cardBubble] = await getComponents( - canvasElement, - 'swc-user-message' - ); - - await step( - 'long copy message does not overflow horizontally in a narrow column', - async () => { - expect(copyBubble.scrollWidth).toBeLessThanOrEqual( - copyBubble.clientWidth + 1 - ); - expect(copyBubble.getBoundingClientRect().height).toBeGreaterThan(40); - } - ); - - await step( - 'long unbroken card title is confined with ellipsis (no host overflow)', - async () => { - expect(cardBubble.scrollWidth).toBeLessThanOrEqual( - cardBubble.clientWidth + 1 - ); - } - ); - }, -}; 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..bfc1e06cc5d 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 @@ -10,30 +10,9 @@ * governing permissions and limitations under the License. */ -/* 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 - * - * The host IS the visible bubble so focus rings rendered by the parent - * `swc-conversation-turn` hug its border box exactly. - */ - :host { display: block; - inline-size: fit-content; - max-inline-size: 536px; - padding-block: var(--swc-user-message-padding-block, token("spacing-100")); - padding-inline: var(--swc-user-message-padding-inline, token("spacing-300")); - font-family: token("sans-serif-font"); - font-size: token("font-size-200"); - font-weight: token("regular-font-weight"); - line-height: token("line-height-200"); - color: token("gray-800"); - background: token("gray-50"); - border: 1px solid transparent; - border-radius: token("corner-radius-large-default"); + inline-size: 100%; } *, @@ -42,135 +21,104 @@ 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")); -} - -.swc-UserMessage-attachment { +.swc-UserMessage { 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; + gap: token("spacing-300"); + align-items: flex-end; + inline-size: 100%; } -.swc-UserMessage-thumbnail { - min-inline-size: 0; +.swc-UserMessage-media-row, +.swc-UserMessage-file-row, +.swc-UserMessage-text-row { + display: flex; + gap: token("spacing-100"); + align-items: flex-start; + justify-content: flex-end; + inline-size: fit-content; + max-inline-size: 100%; } -.swc-UserMessage-thumbnail ::slotted(*) { - display: block; +.swc-UserMessage-media-row > slot, +.swc-UserMessage-file-row > slot { + display: contents; } -:host([type="card"]) .swc-UserMessage-thumbnail { - display: flex; - flex-shrink: 0; - align-items: center; - justify-content: center; +.swc-UserMessage-media-row > slot::slotted(*) { + flex: 0 0 auto; } -: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; -} - -/* Thumbnail fills the available vertical space, image covers it fully. */ -:host([type="media"]) .swc-UserMessage-thumbnail { +.swc-UserMessage-media-row { 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; + flex-wrap: wrap; + max-inline-size: 536px; } -:host([type="media"]) .swc-UserMessage-thumbnail ::slotted(*) { - position: absolute; - inset: 0; - inline-size: 100%; - block-size: 100%; - object-fit: cover; +.swc-UserMessage-file-row > slot::slotted(*) { + flex: 0 1 auto; } -.swc-UserMessage-meta { - display: flex; - flex-direction: column; - gap: var(--swc-user-message-meta-gap, token("spacing-50")); - min-inline-size: 0; +/* Figma thread mock uses 128x128 media tiles in user-message rows. */ +.swc-UserMessage-media-row > slot::slotted([type="media"]) { + inline-size: 128px; + min-inline-size: 128px; + block-size: 128px; + min-block-size: 128px; + border-radius: token("corner-radius-700"); + overflow: hidden; } -:host([type="card"]) .swc-UserMessage-meta { - flex: 1; - align-items: flex-start; +/* Figma thread mock uses ~304px card artifacts in post-submit user messages. */ +.swc-UserMessage-file-row > slot::slotted([type="card"]) { + inline-size: 304px; + max-inline-size: 304px; + border-radius: token("spacing-200"); } -:host([type="media"]) .swc-UserMessage-meta { - inline-size: 100%; +.swc-UserMessage-file-row { + max-inline-size: 616px; } -.swc-UserMessage-title { +.swc-UserMessage-media-row > slot::slotted(.swc-UserMessage-overflow-indicator) { + display: inline-flex; + position: absolute; + inset-block-end: token("spacing-100"); + inset-inline-end: 0; + z-index: 1; + align-items: center; + justify-content: center; + min-inline-size: 88px; + min-block-size: 44px; + padding-block: 7px; + padding-inline: token("spacing-300"); 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 { - 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"); - text-overflow: ellipsis; + background: token("gray-100"); + border: none; + border-radius: token("corner-radius-800"); + cursor: pointer; } -: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; +.swc-UserMessage-text-row { + max-inline-size: 536px; } -/* 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(*) { +.swc-UserMessage-text-bubble { display: block; - min-inline-size: 0; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + inline-size: fit-content; + max-inline-size: 536px; + padding-block: token("spacing-100"); + padding-inline: token("spacing-300"); + font-family: token("sans-serif-font"); + font-size: token("font-size-200"); + font-weight: token("regular-font-weight"); + line-height: token("line-height-200"); + color: token("gray-800"); + background: token("gray-50"); + border: 1px solid transparent; + border-radius: token("spacing-200"); } diff --git a/2nd-gen/packages/swc/patterns/conversational-ai/utils/icons/index.ts b/2nd-gen/packages/swc/patterns/conversational-ai/utils/icons/index.ts index 4f36d07950e..3ac5a8eb865 100644 --- a/2nd-gen/packages/swc/patterns/conversational-ai/utils/icons/index.ts +++ b/2nd-gen/packages/swc/patterns/conversational-ai/utils/icons/index.ts @@ -51,6 +51,34 @@ export const ChevronUpIcon = (): TemplateResult => html` `; +/** Chevron-left icon — used for scroll-back controls. */ +export const ChevronLeftIcon = (): TemplateResult => html` + + + +`; + +/** Chevron-right icon — used for scroll-forward controls. */ +export const ChevronRightIcon = (): TemplateResult => html` + + + +`; + /** Three-dots icon — used for the loading / AI-thinking state. */ export const ThreeDotsIcon = (): TemplateResult => html` diff --git a/2nd-gen/packages/swc/utils/test-utils.ts b/2nd-gen/packages/swc/utils/test-utils.ts index c511d39d026..beb79ef6e18 100644 --- a/2nd-gen/packages/swc/utils/test-utils.ts +++ b/2nd-gen/packages/swc/utils/test-utils.ts @@ -12,21 +12,34 @@ import { render } from 'lit'; -type SwcTestGlobals = { - warn: (...args: unknown[]) => void; - DEBUG?: boolean; - issuedWarnings?: Set; -}; +type SwcGlobals = Window['__swc']; -// Returns the shared SWC test globals, creating defaults when needed. -export const getSwcTestGlobals = (): SwcTestGlobals => { - const swcWindow = window as Window & { __swc?: SwcTestGlobals }; +const createDefaultSwcGlobals = (): SwcGlobals => ({ + DEBUG: false, + warn: () => {}, + issuedWarnings: new Set(), + ignoreWarningTypes: { + default: false, + accessibility: false, + api: false, + }, + ignoreWarningLevels: { + default: false, + low: false, + medium: false, + high: false, + deprecation: false, + }, + ignoreWarningLocalNames: {}, +}); - if (!swcWindow.__swc) { - swcWindow.__swc = { warn: () => {} }; +// Returns the shared SWC test globals, creating defaults when needed. +export const getSwcTestGlobals = (): SwcGlobals => { + if (!window.__swc) { + window.__swc = createDefaultSwcGlobals(); } - return swcWindow.__swc; + return window.__swc; }; // Enables debug warnings and captures warn calls for assertions.