mirror of
https://github.com/microsoft/vscode.git
synced 2026-04-02 08:15:56 +01:00
make questions collapsible (#302870)
This commit is contained in:
@@ -11,6 +11,7 @@ import { IMarkdownString, MarkdownString, isMarkdownString } from '../../../../.
|
||||
import { KeyCode } from '../../../../../../base/common/keyCodes.js';
|
||||
import { Disposable, DisposableStore, MutableDisposable } from '../../../../../../base/common/lifecycle.js';
|
||||
import { isMacintosh } from '../../../../../../base/common/platform.js';
|
||||
import { generateUuid } from '../../../../../../base/common/uuid.js';
|
||||
import { hasKey } from '../../../../../../base/common/types.js';
|
||||
import { localize } from '../../../../../../nls.js';
|
||||
import { IAccessibilityService } from '../../../../../../platform/accessibility/common/accessibility.js';
|
||||
@@ -49,13 +50,16 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
|
||||
|
||||
private _currentIndex = 0;
|
||||
private readonly _answers = new Map<string, IChatQuestionAnswerValue>();
|
||||
private _isCollapsed = false;
|
||||
|
||||
private _questionContainer: HTMLElement | undefined;
|
||||
private _headerActionsContainer: HTMLElement | undefined;
|
||||
private _closeButtonContainer: HTMLElement | undefined;
|
||||
private _footerRow: HTMLElement | undefined;
|
||||
private _stepIndicator: HTMLElement | undefined;
|
||||
private _submitHint: HTMLElement | undefined;
|
||||
private _submitButton: Button | undefined;
|
||||
private _collapseButton: Button | undefined;
|
||||
private _prevButton: Button | undefined;
|
||||
private _nextButton: Button | undefined;
|
||||
private _skipAllButton: Button | undefined;
|
||||
@@ -92,6 +96,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
|
||||
super();
|
||||
|
||||
this.domNode = dom.$('.chat-question-carousel-container');
|
||||
this.domNode.id = generateUuid();
|
||||
this._inChatQuestionCarouselContextKey = ChatContextKeys.inChatQuestionCarousel.bindTo(this._contextKeyService);
|
||||
const focusTracker = this._register(dom.trackFocus(this.domNode));
|
||||
this._register(focusTracker.onDidFocus(() => this._inChatQuestionCarouselContextKey.set(true)));
|
||||
@@ -110,6 +115,10 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
|
||||
this._currentIndex = Math.max(0, Math.min(carousel.draftCurrentIndex, carousel.questions.length - 1));
|
||||
}
|
||||
|
||||
if (typeof carousel.draftCollapsed === 'boolean') {
|
||||
this._isCollapsed = carousel.draftCollapsed;
|
||||
}
|
||||
|
||||
if (carousel.draftAnswers) {
|
||||
for (const [key, value] of Object.entries(carousel.draftAnswers)) {
|
||||
this._answers.set(key, value);
|
||||
@@ -141,6 +150,13 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
|
||||
// Question container
|
||||
this._questionContainer = dom.$('.chat-question-carousel-content');
|
||||
this.domNode.append(this._questionContainer);
|
||||
this._headerActionsContainer = dom.$('.chat-question-header-actions');
|
||||
|
||||
const collapseToggleTitle = localize('chat.questionCarousel.collapseTitle', 'Collapse Questions');
|
||||
const collapseButton = interactiveStore.add(new Button(this._headerActionsContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true }));
|
||||
collapseButton.element.classList.add('chat-question-collapse-toggle');
|
||||
collapseButton.element.setAttribute('aria-label', collapseToggleTitle);
|
||||
this._collapseButton = collapseButton;
|
||||
|
||||
// Close/skip button (X) - placed in header row, only shown when allowSkip is true
|
||||
if (carousel.allowSkip) {
|
||||
@@ -155,6 +171,8 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
|
||||
}
|
||||
|
||||
// Register event listeners
|
||||
interactiveStore.add(collapseButton.onDidClick(() => this.toggleCollapsed()));
|
||||
|
||||
if (this._skipAllButton) {
|
||||
interactiveStore.add(this._skipAllButton.onDidClick(() => this.ignore()));
|
||||
}
|
||||
@@ -224,6 +242,31 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
|
||||
|
||||
this.carousel.draftAnswers = Object.fromEntries(this._answers.entries());
|
||||
this.carousel.draftCurrentIndex = this._currentIndex;
|
||||
this.carousel.draftCollapsed = this._isCollapsed;
|
||||
}
|
||||
|
||||
private toggleCollapsed(): void {
|
||||
this._isCollapsed = !this._isCollapsed;
|
||||
this.persistDraftState();
|
||||
this.updateCollapsedPresentation();
|
||||
this._onDidChangeHeight.fire();
|
||||
}
|
||||
|
||||
private updateCollapsedPresentation(): void {
|
||||
this.domNode.classList.toggle('chat-question-carousel-collapsed', this._isCollapsed);
|
||||
|
||||
if (this._collapseButton) {
|
||||
const collapsed = this._isCollapsed;
|
||||
const buttonTitle = collapsed
|
||||
? localize('chat.questionCarousel.expandTitle', 'Expand Questions')
|
||||
: localize('chat.questionCarousel.collapseTitle', 'Collapse Questions');
|
||||
const contentId = this.domNode.id;
|
||||
this._collapseButton.label = collapsed ? `$(${Codicon.chevronUp.id})` : `$(${Codicon.chevronDown.id})`;
|
||||
this._collapseButton.element.setAttribute('aria-label', buttonTitle);
|
||||
this._collapseButton.element.setAttribute('aria-expanded', String(!collapsed));
|
||||
this._collapseButton.element.setAttribute('aria-controls', contentId);
|
||||
this._collapseButton.setTitle(buttonTitle);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -335,7 +378,9 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
|
||||
this._submitButton = undefined;
|
||||
this._skipAllButton = undefined;
|
||||
this._questionContainer = undefined;
|
||||
this._headerActionsContainer = undefined;
|
||||
this._closeButtonContainer = undefined;
|
||||
this._collapseButton = undefined;
|
||||
this._footerRow = undefined;
|
||||
this._stepIndicator = undefined;
|
||||
this._submitHint = undefined;
|
||||
@@ -609,9 +654,15 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
|
||||
|
||||
headerRow.appendChild(titleRow);
|
||||
|
||||
// Always keep the close button in the title row so it does not overlap content.
|
||||
if (this._closeButtonContainer) {
|
||||
titleRow.appendChild(this._closeButtonContainer);
|
||||
if (this._headerActionsContainer) {
|
||||
dom.clearNode(this._headerActionsContainer);
|
||||
if (this._closeButtonContainer) {
|
||||
this._headerActionsContainer.appendChild(this._closeButtonContainer);
|
||||
}
|
||||
if (this._collapseButton) {
|
||||
this._headerActionsContainer.appendChild(this._collapseButton.element);
|
||||
}
|
||||
titleRow.appendChild(this._headerActionsContainer);
|
||||
}
|
||||
|
||||
this._questionContainer.appendChild(headerRow);
|
||||
@@ -680,6 +731,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
|
||||
|
||||
// Update aria-label to reflect the current question
|
||||
this._updateAriaLabel();
|
||||
this.updateCollapsedPresentation();
|
||||
|
||||
// In screen reader mode, focus the container and announce the question
|
||||
// This must happen after all render calls to avoid focus being stolen
|
||||
|
||||
@@ -99,6 +99,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
.chat-question-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-question-close-container {
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -117,6 +124,35 @@
|
||||
background: var(--vscode-toolbar-hoverBackground) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.monaco-button.chat-question-collapse-toggle {
|
||||
min-width: 22px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
color: var(--vscode-icon-foreground) !important;
|
||||
}
|
||||
|
||||
.monaco-button.chat-question-collapse-toggle:hover:not(.disabled) {
|
||||
background: var(--vscode-toolbar-hoverBackground) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.interactive-session .chat-question-carousel-container.chat-question-carousel-collapsed {
|
||||
.chat-question-carousel-content {
|
||||
.chat-question-description,
|
||||
.chat-question-input-scrollable,
|
||||
.chat-question-validation-message {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-question-footer-row {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export class ChatQuestionCarouselData implements IChatQuestionCarousel {
|
||||
public readonly completion = new DeferredPromise<{ answers: IChatQuestionAnswers | undefined }>();
|
||||
public draftAnswers: IChatQuestionAnswers | undefined;
|
||||
public draftCurrentIndex: number | undefined;
|
||||
public draftCollapsed: boolean | undefined;
|
||||
|
||||
constructor(
|
||||
public questions: IChatQuestion[],
|
||||
|
||||
@@ -149,6 +149,82 @@ suite('ChatQuestionCarouselPart', () => {
|
||||
const directChildCloseContainer = widget.domNode.querySelector(':scope > .chat-question-close-container');
|
||||
assert.strictEqual(directChildCloseContainer, null, 'close button container should not be positioned as a direct child of the carousel container');
|
||||
});
|
||||
|
||||
test('renders collapse button in title row even when skip is disabled', () => {
|
||||
const carousel = createMockCarousel([
|
||||
{ id: 'q1', type: 'text', title: 'Question 1' }
|
||||
], false);
|
||||
createWidget(carousel);
|
||||
|
||||
const titleRow = widget.domNode.querySelector('.chat-question-title-row');
|
||||
assert.ok(titleRow, 'title row should exist');
|
||||
|
||||
const collapseButton = titleRow?.querySelector('.chat-question-collapse-toggle');
|
||||
assert.ok(collapseButton, 'collapse button should be rendered even when skip is disabled');
|
||||
});
|
||||
|
||||
test('renders collapse button to the right of close button', () => {
|
||||
const carousel = createMockCarousel([
|
||||
{ id: 'q1', type: 'text', title: 'Question 1' },
|
||||
{ id: 'q2', type: 'text', title: 'Question 2' }
|
||||
], true);
|
||||
createWidget(carousel);
|
||||
|
||||
const actionsContainer = widget.domNode.querySelector('.chat-question-header-actions');
|
||||
assert.ok(actionsContainer, 'actions container should exist');
|
||||
if (!actionsContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const actionButtons = Array.from(actionsContainer.querySelectorAll('.monaco-button'));
|
||||
const closeIndex = actionButtons.findIndex(button => button.classList.contains('chat-question-close'));
|
||||
const collapseIndex = actionButtons.findIndex(button => button.classList.contains('chat-question-collapse-toggle'));
|
||||
|
||||
assert.ok(closeIndex >= 0, 'close button should exist');
|
||||
assert.ok(collapseIndex >= 0, 'collapse button should exist');
|
||||
assert.ok(collapseIndex > closeIndex, 'collapse button should be positioned to the right of close button');
|
||||
});
|
||||
|
||||
test('toggles collapsed state and updates aria-expanded', () => {
|
||||
const carousel = createMockCarousel([
|
||||
{ id: 'q1', type: 'text', title: 'Question 1' },
|
||||
{ id: 'q2', type: 'text', title: 'Question 2' }
|
||||
], true);
|
||||
createWidget(carousel);
|
||||
|
||||
const collapseButton = widget.domNode.querySelector('.chat-question-collapse-toggle') as HTMLElement;
|
||||
assert.ok(collapseButton, 'collapse button should exist');
|
||||
assert.strictEqual(collapseButton.getAttribute('aria-expanded'), 'true');
|
||||
|
||||
collapseButton.click();
|
||||
assert.ok(widget.domNode.classList.contains('chat-question-carousel-collapsed'), 'widget should enter collapsed state');
|
||||
assert.strictEqual(collapseButton.getAttribute('aria-expanded'), 'false');
|
||||
const collapsedSummary = widget.domNode.querySelector('.chat-question-collapsed-summary');
|
||||
assert.strictEqual(collapsedSummary, null, 'collapsed mode should not render an additional summary section');
|
||||
|
||||
const titleRow = widget.domNode.querySelector('.chat-question-title-row');
|
||||
assert.ok(titleRow, 'header should remain visible when collapsed');
|
||||
|
||||
const inputScrollable = widget.domNode.querySelector('.chat-question-input-scrollable');
|
||||
assert.ok(inputScrollable, 'input section exists in DOM but is hidden while collapsed');
|
||||
|
||||
collapseButton.click();
|
||||
assert.ok(!widget.domNode.classList.contains('chat-question-carousel-collapsed'), 'widget should exit collapsed state');
|
||||
assert.strictEqual(collapseButton.getAttribute('aria-expanded'), 'true');
|
||||
});
|
||||
|
||||
test('restores draft collapsed state from carousel data', () => {
|
||||
const carousel = new ChatQuestionCarouselData([
|
||||
{ id: 'q1', type: 'text', title: 'Question 1' },
|
||||
{ id: 'q2', type: 'text', title: 'Question 2' }
|
||||
], true);
|
||||
carousel.draftCollapsed = true;
|
||||
createWidget(carousel);
|
||||
|
||||
assert.ok(widget.domNode.classList.contains('chat-question-carousel-collapsed'), 'widget should restore collapsed draft state');
|
||||
const collapseButton = widget.domNode.querySelector('.chat-question-collapse-toggle');
|
||||
assert.strictEqual(collapseButton?.getAttribute('aria-expanded'), 'false');
|
||||
});
|
||||
});
|
||||
|
||||
suite('Question Types', () => {
|
||||
|
||||
Reference in New Issue
Block a user