Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { property, queryAssignedElements } from 'lit/decorators.js';

import { FocusgroupNavigationController } from '@spectrum-web-components/core/controllers/index.js';
import { SpectrumElement } from '@spectrum-web-components/core/element/index.js';
import { getActiveElement } from '@spectrum-web-components/core/utils/index.js';

import styles from './conversation-thread.css';

Expand All @@ -24,7 +25,8 @@ import styles from './conversation-thread.css';
* Slotted `<swc-conversation-turn>` children participate in a roving `tabindex` model:
* one active turn is tabbable while the rest are programmatically focusable.
* Use <kbd>ArrowUp</kbd> / <kbd>ArrowDown</kbd> to move, and <kbd>Home</kbd> / <kbd>End</kbd>
* to jump to the first/last turn.
* to jump to the first/last turn. When new turns are appended, the newest turn
* becomes the active roving target so re-entering the thread returns to latest content.
*
* @element swc-conversation-thread
* @slot - Conversation turns, typically `<swc-conversation-turn>` elements.
Expand All @@ -39,7 +41,6 @@ export class ConversationThread extends SpectrumElement {
@queryAssignedElements({ flatten: true, selector: 'swc-conversation-turn' })
private _assignedTurns!: HTMLElement[];

private _items: HTMLElement[] = [];
private focusgroupNavigationController = new FocusgroupNavigationController(
this,
{
Expand All @@ -58,14 +59,27 @@ export class ConversationThread extends SpectrumElement {
*
* Before focusing, we refresh the slotted turn list and roving-tabindex state so
* we never target a stale turn when messages were added/removed just before focus.
* Then we focus the controller's active item, with a fallback to `activeIndex`.
*/
public override focus(options?: FocusOptions): void {
this._syncFocusableItems();
const active =
this.focusgroupNavigationController.getActiveItem() ??
this._items[this._clampIndex(this.activeIndex)];
this._syncRovingFocusTarget();
const turns = this._getItemsFromSlot();
const active = this.focusgroupNavigationController.getActiveItem();
active?.focus(options);
if (!active && turns.length) {
turns[this._clampIndex(this.activeIndex, turns)]?.focus(options);
}
}

/**
* Sets the active roving turn to the last slotted turn.
* This aligns focus re-entry with the latest message in the thread.
*/
public setActiveIndexToLast(): void {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this need to be public?

const turns = this._getItemsFromSlot();
if (!turns.length) {
return;
}
this._setActiveTurn(turns[turns.length - 1]);
}

protected override updated(changedProperties: PropertyValues<this>): void {
Expand All @@ -79,48 +93,72 @@ export class ConversationThread extends SpectrumElement {
return Array.from(this._assignedTurns ?? []);
}

private _clampIndex(index: number): number {
if (!this._items.length) {
private _clampIndex(index: number, turns: HTMLElement[]): number {
if (!turns.length) {
return 0;
}

return Math.min(Math.max(index, 0), this._items.length - 1);
return Math.min(Math.max(index, 0), turns.length - 1);
}

private _syncFocusableItems(): void {
const nextItems = this._getItemsFromSlot();

for (const previousItem of this._items) {
if (!nextItems.includes(previousItem)) {
previousItem.removeAttribute('tabindex');
private _syncRovingFocusTarget(setLatestAsTabStop = false): void {
this.focusgroupNavigationController.refresh();
const turns = this._getItemsFromSlot();
if (!turns.length) {
if (this.activeIndex !== 0) {
this.activeIndex = 0;
}
}

this._items = nextItems;
if (!this._items.length) {
return;
}

this.focusgroupNavigationController.refresh();
if (setLatestAsTabStop) {
this.focusgroupNavigationController.setActiveItem(
turns[turns.length - 1]
);
}
this._syncActiveIndex(this.focusgroupNavigationController.getActiveItem());
}

private _syncActiveItemFromProperty(): void {
if (!this._items.length) {
const turns = this._getItemsFromSlot();
if (!turns.length) {
return;
}

const nextIndex = this._clampIndex(this.activeIndex);
const nextActive = this._items[nextIndex];
if (!nextActive) {
this._setActiveTurn(turns[this._clampIndex(this.activeIndex, turns)]);
}

private _handleSlotChange(): void {
const hadRovingTarget = this._getItemsFromSlot().some((turn) =>
turn.hasAttribute('tabindex')
);
this._syncRovingFocusTarget(hadRovingTarget && !this._hasFocusWithin());
}

private _handleFocusOut(event: FocusEvent): void {
const next = event.relatedTarget;
if (next instanceof Node && this.contains(next)) {
return;
}
this.focusgroupNavigationController.setActiveItem(nextActive);
this._syncActiveIndex(this.focusgroupNavigationController.getActiveItem());

this.setActiveIndexToLast();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this expected? I thought we'd only reset to last if we're going into the prompt. here it is on every focus out. can you confirm?

}

private _handleSlotChange(): void {
this._syncFocusableItems();
private _hasFocusWithin(): boolean {
const active = getActiveElement(
this.getRootNode() as Document | ShadowRoot
);

return active instanceof Node && this.contains(active);
}

private _setActiveTurn(turn: HTMLElement | undefined): void {
if (!turn) {
return;
}

this.focusgroupNavigationController.setActiveItem(turn);
this._syncActiveIndex(this.focusgroupNavigationController.getActiveItem());
}

private _syncActiveIndex(active: HTMLElement | null): void {
Expand All @@ -131,15 +169,15 @@ export class ConversationThread extends SpectrumElement {
return;
}

const index = this._items.indexOf(active);
const index = this._getItemsFromSlot().indexOf(active);
if (index !== -1 && index !== this.activeIndex) {
this.activeIndex = index;
}
}

protected override render(): TemplateResult {
return html`
<div class="swc-ConversationThread">
<div class="swc-ConversationThread" @focusout=${this._handleFocusOut}>
<slot @slotchange=${this._handleSlotChange}></slot>
</div>
`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ const threadExampleSource = `<swc-conversation-thread style="max-inline-size: 72
</div>
<swc-message-feedback slot="feedback"></swc-message-feedback>
<swc-message-sources slot="sources">
<li><a href="#source-1">Brand brief Q1 2026</a></li>
<a href="#source-1">Brand brief Q1 2026</a>
</swc-message-sources>
</swc-system-message>
</swc-conversation-turn>
Expand Down Expand Up @@ -109,7 +109,7 @@ const renderThread = () => html`
</div>
<swc-message-feedback slot="feedback"></swc-message-feedback>
<swc-message-sources slot="sources">
<li><a href="#source-1">Brand brief Q1 2026</a></li>
<a href="#source-1">Brand brief Q1 2026</a>
</swc-message-sources>
</swc-system-message>
</swc-conversation-turn>
Expand Down Expand Up @@ -498,8 +498,8 @@ class ConversationFullPatternDemo extends LitElement {
data-sources-id=${turn.id}
?open=${!!turn.sourcesOpen}
>
<li><a href="#">Brand brief Q1 2026</a></li>
<li><a href="#">Market research summary</a></li>
<a href="#">Brand brief Q1 2026</a>
<a href="#">Market research summary</a>
</swc-message-sources>
`}
${turn.loading
Expand Down Expand Up @@ -660,7 +660,7 @@ const fullPatternSource = `<div style="max-width:800px; margin:auto; padding:24p
</div>
<swc-message-feedback slot="feedback"></swc-message-feedback>
<swc-message-sources slot="sources">
<li><a href="#">Brand brief Q1 2026</a></li>
<a href="#">Brand brief Q1 2026</a>
</swc-message-sources>
</swc-system-message>
</swc-conversation-turn>
Expand Down Expand Up @@ -717,13 +717,15 @@ export const Overview: Story = {
* ### Keyboard behavior
*
* - Tab enters the thread on the active turn.
* - Shift+Tab from controls after the thread returns to the active turn.
* - ArrowUp and ArrowDown move between turns.
* - Home and End jump to first and last turn.
*
* ### Focus behavior
*
* - The thread applies roving `tabindex` across slotted `<swc-conversation-turn>` children.
* - Exactly one turn is tabbable at a time.
* - A newly appended turn becomes the active roving target.
*/
export const Accessibility: Story = {
render: renderThread,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ export const OverviewTest: Story = {
});

await step(
'slot changes keep roving tabindex correct for added/removed turns',
'slot changes preserve the current roving target while focus is inside the thread',
async () => {
const addedTurn = document.createElement('swc-conversation-turn');
addedTurn.setAttribute('type', 'user');
Expand All @@ -128,20 +128,70 @@ export const OverviewTest: Story = {
canvasElement.querySelectorAll<HTMLElement>('swc-conversation-turn')
);
expect(currentTurns.length).toBe(4);
expect(el.activeIndex).toBe(0);
expect(currentTurns[0]?.getAttribute('tabindex')).toBe('0');
expect(currentTurns[3]?.getAttribute('tabindex')).toBe('-1');

const removedTurn = currentTurns[0];
const removedTurn = currentTurns[1];
removedTurn?.remove();
await el.updateComplete;

currentTurns = Array.from(
canvasElement.querySelectorAll<HTMLElement>('swc-conversation-turn')
);
expect(currentTurns.length).toBe(3);
expect(removedTurn?.hasAttribute('tabindex')).toBe(false);
expect(el.activeIndex).toBe(0);
expect(currentTurns[0]?.getAttribute('tabindex')).toBe('0');
}
);

await step(
're-entry targets newest turn without stealing focus from the prompt',
async () => {
const promptField = document.createElement('input');
promptField.type = 'text';
canvasElement.append(promptField);

let currentTurns = Array.from(
canvasElement.querySelectorAll<HTMLElement>('swc-conversation-turn')
);
currentTurns[0]?.focus();
await el.updateComplete;
expect(el.activeIndex).toBe(0);

promptField.focus();
await el.updateComplete;
el.focus();
await el.updateComplete;
expect(el.activeIndex).toBe(currentTurns.length - 1);
expect(canvasElement.ownerDocument.activeElement).toBe(
currentTurns[currentTurns.length - 1]
);

promptField.focus();
expect(canvasElement.ownerDocument.activeElement).toBe(promptField);

const appendedTurn = document.createElement('swc-conversation-turn');
appendedTurn.setAttribute('type', 'system');
appendedTurn.textContent = 'Newest system turn';
el.append(appendedTurn);
await el.updateComplete;

currentTurns = Array.from(
canvasElement.querySelectorAll<HTMLElement>('swc-conversation-turn')
);
expect(canvasElement.ownerDocument.activeElement).toBe(promptField);
expect(el.activeIndex).toBe(currentTurns.length - 1);
expect(
currentTurns[currentTurns.length - 1]?.getAttribute('tabindex')
).toBe('0');

el.focus();
await el.updateComplete;
expect(canvasElement.ownerDocument.activeElement).toBe(
currentTurns[currentTurns.length - 1]
);
}
);
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* governing permissions and limitations under the License.
*/

import { CSSResultArray, html, TemplateResult } from 'lit';
import { CSSResultArray, html, PropertyValues, TemplateResult } from 'lit';
import { property } from 'lit/decorators.js';

import { SpectrumElement } from '@spectrum-web-components/core/element/index.js';
Expand Down Expand Up @@ -61,15 +61,28 @@ export class ConversationTurn extends SpectrumElement {
return this.type === 'user' ? 'User message' : 'System message';
}

public override connectedCallback(): void {
super.connectedCallback();
this._applyHostSemantics();
}

protected override willUpdate(changedProperties: PropertyValues<this>): void {
if (
changedProperties.has('type') ||
changedProperties.has('accessibleLabel')
) {
this._applyHostSemantics();
}
}

private _applyHostSemantics(): void {
this.setAttribute('role', 'group');
this.setAttribute('aria-label', this._turnAriaLabel);
}

protected override render(): TemplateResult {
return html`
<div
class="swc-ConversationTurn"
role="group"
aria-label=${this._turnAriaLabel}
>
<slot></slot>
</div>
<div class="swc-ConversationTurn"><slot></slot></div>
`;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,9 @@ export const Overview: Story = {
/**
* ### Features
*
* Each `<swc-conversation-turn>` exposes a single **`role="group"`** landmark in
* the shadow tree with **`aria-label`**.
* Each `<swc-conversation-turn>` exposes **`role="group"`** and an
* **`aria-label`** on the host element. This ensures the element that receives
* roving keyboard focus has an accessible role and name.
*
* Default labels are derived from **`type`**:
*
Expand All @@ -128,6 +129,13 @@ export const Overview: Story = {
*
* Sighted users infer the speaker from alignment; this label gives screen reader
* users the same turn context before the slotted message content is read.
*
* When used inside `<swc-conversation-thread>`, ArrowUp and ArrowDown move
* focus between turns. Tab leaves the thread for the next page control, and
* Shift+Tab back into the thread returns to the current roving focus target.
* When focus has left the thread, newly appended turns become the next tab
* target so users can return from the prompt field to the latest response
* without focus being moved while they type.
*/
export const Accessibility: Story = {
render: () => html`
Expand Down
Loading
Loading