keyboard improvements to question list (#292429)

* keyboard improvements to question list

* fix test

* address comments
This commit is contained in:
Justin Chen 2026-02-02 17:44:03 -08:00 committed by GitHub
parent b2f9a674cb
commit fd9aeb781c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 202 additions and 76 deletions

View File

@ -23,6 +23,7 @@ import './media/chatQuestionCarousel.css';
export interface IChatQuestionCarouselOptions {
onSubmit: (answers: Map<string, unknown> | undefined) => void;
shouldAutoFocus?: boolean;
}
export class ChatQuestionCarouselPart extends Disposable implements IChatContentPart {
@ -397,9 +398,11 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
this._questionContainer.appendChild(headerRow);
const isSingleQuestion = this.carousel.questions.length === 1;
// Update step indicator in footer
if (this._stepIndicator) {
this._stepIndicator.textContent = `${this._currentIndex + 1}/${this.carousel.questions.length}`;
this._stepIndicator.style.display = isSingleQuestion ? 'none' : '';
}
// Render input based on question type
@ -409,13 +412,14 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
// Update navigation button states (prevButton and nextButton are guaranteed non-null from guard above)
this._prevButton!.enabled = this._currentIndex > 0;
this._prevButton!.element.style.display = isSingleQuestion ? 'none' : '';
// Update next button icon/label for last question
const isLastQuestion = this._currentIndex === this.carousel.questions.length - 1;
const submitLabel = localize('submit', 'Submit');
const nextLabel = localize('next', 'Next');
if (isLastQuestion) {
this._nextButton!.label = `$(${Codicon.check.id})`;
this._nextButton!.label = submitLabel;
this._nextButton!.element.title = submitLabel;
this._nextButton!.element.setAttribute('aria-label', submitLabel);
// Switch to primary style for submit
@ -476,7 +480,9 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
this._textInputBoxes.set(question.id, inputBox);
// Focus on input when rendered using proper DOM scheduling
this._inputBoxes.add(dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(inputBox.element), () => inputBox.focus()));
if (this._options.shouldAutoFocus !== false) {
this._inputBoxes.add(dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(inputBox.element), () => inputBox.focus()));
}
}
private renderSingleSelect(container: HTMLElement, question: IChatQuestion): void {
@ -540,12 +546,15 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
listItem.id = `option-${question.id}-${index}`;
listItem.tabIndex = -1;
const number = dom.$('.chat-question-list-number');
number.textContent = `${index + 1}`;
listItem.appendChild(number);
// Selection indicator (checkmark when selected)
const indicator = dom.$('.chat-question-list-indicator');
if (isSelected) {
indicator.classList.add('codicon', 'codicon-check');
}
listItem.appendChild(indicator);
indicators.push(indicator);
// Label with optional description (format: "Title - Description")
@ -563,6 +572,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
label.textContent = option.label;
}
listItem.appendChild(label);
listItem.appendChild(indicator);
if (isSelected) {
listItem.classList.add('selected');
@ -586,35 +596,13 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
selectContainer.setAttribute('aria-activedescendant', listItems[selectedIndex].id);
}
// Keyboard navigation for the list
this._inputBoxes.add(dom.addDisposableListener(selectContainer, dom.EventType.KEY_DOWN, (e: KeyboardEvent) => {
const event = new StandardKeyboardEvent(e);
const data = this._singleSelectItems.get(question.id);
if (!data || !listItems.length) {
return;
}
let newIndex = data.selectedIndex;
if (event.keyCode === KeyCode.DownArrow) {
e.preventDefault();
newIndex = Math.min(data.selectedIndex + 1, listItems.length - 1);
} else if (event.keyCode === KeyCode.UpArrow) {
e.preventDefault();
newIndex = Math.max(data.selectedIndex - 1, 0);
} else if (event.keyCode === KeyCode.Space || event.keyCode === KeyCode.Enter) {
// Space/Enter confirms current selection (already selected, nothing extra to do)
e.preventDefault();
return;
}
if (newIndex !== data.selectedIndex && newIndex >= 0) {
updateSelection(newIndex);
}
}));
// Always show freeform input for single-select questions
const freeformContainer = dom.$('.chat-question-freeform');
const freeformNumber = dom.$('.chat-question-freeform-number');
freeformNumber.textContent = `${options.length + 1}`;
freeformContainer.appendChild(freeformNumber);
const freeformTextarea = dom.$<HTMLTextAreaElement>('textarea.chat-question-freeform-textarea');
freeformTextarea.placeholder = localize('chat.questionCarousel.enterCustomAnswer', 'Enter custom answer');
freeformTextarea.rows = 1;
@ -637,10 +625,62 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
container.appendChild(freeformContainer);
this._freeformTextareas.set(question.id, freeformTextarea);
// Keyboard navigation for the list
this._inputBoxes.add(dom.addDisposableListener(selectContainer, dom.EventType.KEY_DOWN, (e: KeyboardEvent) => {
const event = new StandardKeyboardEvent(e);
const data = this._singleSelectItems.get(question.id);
if (!data || !listItems.length) {
return;
}
let newIndex = data.selectedIndex;
if (event.keyCode === KeyCode.DownArrow) {
e.preventDefault();
newIndex = Math.min(data.selectedIndex + 1, listItems.length - 1);
} else if (event.keyCode === KeyCode.UpArrow) {
e.preventDefault();
newIndex = Math.max(data.selectedIndex - 1, 0);
} else if (event.keyCode === KeyCode.Enter || event.keyCode === KeyCode.Space) {
// Enter confirms current selection and advances to next question
e.preventDefault();
e.stopPropagation();
this.handleNext();
return;
} else if (event.keyCode >= KeyCode.Digit1 && event.keyCode <= KeyCode.Digit9) {
// Number keys 1-9 select the corresponding option, or focus freeform for next number
const numberIndex = event.keyCode - KeyCode.Digit1;
if (numberIndex < listItems.length) {
e.preventDefault();
updateSelection(numberIndex);
} else if (numberIndex === listItems.length) {
e.preventDefault();
updateSelection(-1);
freeformTextarea.focus();
}
return;
}
if (newIndex !== data.selectedIndex && newIndex >= 0) {
updateSelection(newIndex);
}
}));
// Resize textarea if it has restored content
if (previousFreeform !== undefined) {
this._inputBoxes.add(dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(freeformTextarea), () => autoResize()));
}
// focus on the row when first rendered
if (this._options.shouldAutoFocus !== false && listItems.length > 0) {
const focusIndex = selectedIndex >= 0 ? selectedIndex : 0;
// if no default, select the first answer
if (selectedIndex < 0) {
updateSelection(0);
}
this._inputBoxes.add(dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(selectContainer), () => {
listItems[focusIndex]?.focus();
}));
}
}
private renderMultiSelect(container: HTMLElement, question: IChatQuestion): void {
@ -669,6 +709,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
const checkboxes: Checkbox[] = [];
const listItems: HTMLElement[] = [];
let focusedIndex = 0;
let firstCheckedIndex = -1;
options.forEach((option, index) => {
// Determine initial checked state
@ -685,6 +726,10 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
listItem.id = `option-${question.id}-${index}`;
listItem.tabIndex = -1;
const number = dom.$('.chat-question-list-number');
number.textContent = `${index + 1}`;
listItem.appendChild(number);
// Create checkbox using the VS Code Checkbox component
const checkbox = this._inputBoxes.add(new Checkbox(option.label, isChecked, defaultCheckboxStyles));
checkbox.domNode.classList.add('chat-question-list-checkbox');
@ -710,6 +755,9 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
if (isChecked) {
listItem.classList.add('checked');
if (firstCheckedIndex === -1) {
firstCheckedIndex = index;
}
}
// Sync checkbox state with list item visual state
@ -736,6 +784,29 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
this._multiSelectCheckboxes.set(question.id, checkboxes);
// Always show freeform input for multi-select questions
const freeformContainer = dom.$('.chat-question-freeform');
// Number indicator for freeform (comes after all options)
const freeformNumber = dom.$('.chat-question-freeform-number');
freeformNumber.textContent = `${options.length + 1}`;
freeformContainer.appendChild(freeformNumber);
const freeformTextarea = dom.$<HTMLTextAreaElement>('textarea.chat-question-freeform-textarea');
freeformTextarea.placeholder = localize('chat.questionCarousel.enterCustomAnswer', 'Enter custom answer');
freeformTextarea.rows = 1;
if (previousFreeform !== undefined) {
freeformTextarea.value = previousFreeform;
}
// Setup auto-resize behavior
const autoResize = this.setupTextareaAutoResize(freeformTextarea);
freeformContainer.appendChild(freeformTextarea);
container.appendChild(freeformContainer);
this._freeformTextareas.set(question.id, freeformTextarea);
// Keyboard navigation for the list
this._inputBoxes.add(dom.addDisposableListener(selectContainer, dom.EventType.KEY_DOWN, (e: KeyboardEvent) => {
const event = new StandardKeyboardEvent(e);
@ -753,37 +824,42 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
e.preventDefault();
focusedIndex = Math.max(focusedIndex - 1, 0);
listItems[focusedIndex].focus();
} else if (event.keyCode === KeyCode.Enter) {
e.preventDefault();
e.stopPropagation();
this.handleNext();
} else if (event.keyCode === KeyCode.Space) {
e.preventDefault();
// Toggle the currently focused checkbox using click() to trigger onChange
if (focusedIndex >= 0 && focusedIndex < checkboxes.length) {
checkboxes[focusedIndex].domNode.click();
}
} else if (event.keyCode >= KeyCode.Digit1 && event.keyCode <= KeyCode.Digit9) {
// Number keys 1-9 toggle the corresponding checkbox, or focus freeform for next number
const numberIndex = event.keyCode - KeyCode.Digit1;
if (numberIndex < checkboxes.length) {
e.preventDefault();
checkboxes[numberIndex].domNode.click();
} else if (numberIndex === checkboxes.length) {
e.preventDefault();
freeformTextarea.focus();
}
}
}));
// Always show freeform input for multi-select questions
const freeformContainer = dom.$('.chat-question-freeform');
const freeformTextarea = dom.$<HTMLTextAreaElement>('textarea.chat-question-freeform-textarea');
freeformTextarea.placeholder = localize('chat.questionCarousel.enterCustomAnswer', 'Enter custom answer');
freeformTextarea.rows = 1;
if (previousFreeform !== undefined) {
freeformTextarea.value = previousFreeform;
}
// Setup auto-resize behavior
const autoResize = this.setupTextareaAutoResize(freeformTextarea);
freeformContainer.appendChild(freeformTextarea);
container.appendChild(freeformContainer);
this._freeformTextareas.set(question.id, freeformTextarea);
// Resize textarea if it has restored content
if (previousFreeform !== undefined) {
this._inputBoxes.add(dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(freeformTextarea), () => autoResize()));
}
// Focus on the appropriate row when rendered (first checked row, or first row if none)
if (this._options.shouldAutoFocus !== false && listItems.length > 0) {
const initialFocusIndex = firstCheckedIndex >= 0 ? firstCheckedIndex : 0;
focusedIndex = initialFocusIndex;
this._inputBoxes.add(dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(selectContainer), () => {
listItems[initialFocusIndex]?.focus();
}));
}
}
private getCurrentAnswer(): unknown {
@ -836,9 +912,14 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
}
});
}
// Include defaults if nothing selected (defaultValue is option id or array of ids)
// Always include freeform value for multi-select questions
const freeformTextarea = this._freeformTextareas.get(question.id);
const freeformValue = freeformTextarea?.value !== '' ? freeformTextarea?.value : undefined;
// Only include defaults if nothing selected AND no freeform input
let finalSelectedValues = selectedValues;
if (selectedValues.length === 0 && question.defaultValue !== undefined) {
if (selectedValues.length === 0 && !freeformValue && question.defaultValue !== undefined) {
const defaultIds = Array.isArray(question.defaultValue)
? question.defaultValue
: [question.defaultValue];
@ -848,9 +929,6 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
finalSelectedValues = defaultValues?.filter(v => v !== undefined) || [];
}
// Always include freeform value for multi-select questions
const freeformTextarea = this._freeformTextareas.get(question.id);
const freeformValue = freeformTextarea?.value !== '' ? freeformTextarea?.value : undefined;
if (freeformValue || finalSelectedValues.length > 0) {
return { selectedValues: finalSelectedValues, freeformValue };
}

View File

@ -67,7 +67,6 @@
min-width: 0;
padding-bottom: 12px;
border-bottom: 1px solid var(--vscode-chat-requestBorder);
margin-bottom: 4px;
}
.chat-question-header-row .chat-question-title {
@ -117,6 +116,7 @@
align-items: center;
gap: 4px;
flex-shrink: 0;
margin-left: auto;
}
.chat-question-nav-arrows {
@ -149,6 +149,9 @@
.chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-submit {
background: var(--vscode-button-background) !important;
color: var(--vscode-button-foreground) !important;
width: auto;
min-width: auto;
padding: 0 8px;
}
.chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-submit:hover:not(.disabled) {
@ -168,7 +171,6 @@
.chat-question-carousel-content {
display: flex;
flex-direction: column;
gap: 8px;
background: var(--vscode-chat-requestBackground);
padding: 12px 16px;
overflow: hidden;
@ -204,7 +206,6 @@
.chat-question-input-container {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 4px;
min-width: 0;
}
@ -214,8 +215,6 @@
display: flex;
flex-direction: column;
gap: 0;
border: 1px solid var(--vscode-input-border, var(--vscode-chat-requestBorder));
border-radius: 4px;
outline: none;
padding: 4px 0;
}
@ -233,23 +232,21 @@
cursor: pointer;
border-radius: 3px;
user-select: none;
border-bottom: 1px solid var(--vscode-chat-requestBorder);
}
.chat-question-list-item:last-child {
border-bottom: none;
}
.chat-question-list-item:hover {
background-color: var(--vscode-list-hoverBackground);
}
.chat-question-list-item:focus {
outline: none;
background-color: var(--vscode-list-hoverBackground);
.interactive-session .interactive-response .value {
.chat-question-list-item:focus,
.chat-question-list:focus {
outline: none;
}
}
/* Single-select: highlight entire row when selected */
/* Single-select: highlight entire row when selected and also outline */
.chat-question-list-item.selected {
background-color: var(--vscode-list-activeSelectionBackground);
color: var(--vscode-list-activeSelectionForeground);
@ -259,7 +256,41 @@
background-color: var(--vscode-list-activeSelectionBackground);
}
/* Selection indicator (checkmark) for single select */
/* todo: change to use keybinding service so we don't have to recreate this */
.chat-question-list-number,
.chat-question-freeform-number {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 14px;
padding: 2px 4px;
border-style: solid;
border-width: 1px;
border-radius: 3px;
font-size: 11px;
font-weight: normal;
background-color: var(--vscode-keybindingLabel-background);
color: var(--vscode-keybindingLabel-foreground);
border-color: var(--vscode-keybindingLabel-border);
border-bottom-color: var(--vscode-keybindingLabel-bottomBorder);
box-shadow: inset 0 -1px 0 var(--vscode-widget-shadow);
flex-shrink: 0;
}
.chat-question-freeform-number {
margin-top: 4px;
height: fit-content;
}
.chat-question-list-item.selected .chat-question-list-number {
background-color: transparent;
color: var(--vscode-list-activeSelectionForeground);
border-color: var(--vscode-list-activeSelectionForeground);
border-bottom-color: var(--vscode-list-activeSelectionForeground);
box-shadow: none;
}
/* Selection indicator (checkmark) for single select - positioned on right */
.chat-question-list-indicator {
width: 16px;
height: 16px;
@ -267,6 +298,7 @@
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-left: auto;
}
.chat-question-list-indicator.codicon-check {
@ -290,6 +322,7 @@
background-color: var(--vscode-button-background) !important;
border-color: var(--vscode-button-background) !important;
color: var(--vscode-button-foreground) !important;
align-content: center;
}
.chat-question-list-checkbox.monaco-custom-toggle.checked .codicon {
@ -414,10 +447,11 @@
.chat-question-freeform {
margin-top: 8px;
margin-left: 8px;
display: flex;
flex-direction: column;
gap: 4px;
flex-direction: row;
align-items: flex-start;
gap: 8px;
}
.chat-question-freeform-label {
@ -439,6 +473,7 @@
font-size: var(--vscode-chat-font-size-body-s);
box-sizing: border-box;
overflow-y: hidden;
align-content: center;
}
.chat-question-freeform-textarea:focus {

View File

@ -1899,7 +1899,11 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
private renderQuestionCarousel(context: IChatContentPartRenderContext, carousel: IChatQuestionCarousel, templateData: IChatListItemTemplate): IChatContentPart {
this.finalizeCurrentThinkingPart(context, templateData);
const widget = isResponseVM(context.element) ? this.chatWidgetService.getWidgetBySessionResource(context.element.sessionResource) : undefined;
const shouldAutoFocus = widget ? widget.getInput() === '' : true;
const part = this.instantiationService.createInstance(ChatQuestionCarouselPart, carousel, context, {
shouldAutoFocus,
onSubmit: async (answers) => {
// Mark the carousel as used and store the answers
const answersRecord = answers ? Object.fromEntries(answers) : undefined;

View File

@ -383,17 +383,26 @@ suite('ChatQuestionCarouselPart', () => {
type: 'singleSelect',
title: 'Choose one',
options: [
{ id: 'a', label: 'Option A', value: 'a' }
{ id: 'a', label: 'Option A', value: 'a' },
{ id: 'b', label: 'Option B', value: 'b' }
]
}
]);
createWidget(carousel);
const listItem = widget.domNode.querySelector('.chat-question-list-item') as HTMLElement;
assert.ok(listItem, 'List item should exist');
assert.strictEqual(listItem.getAttribute('role'), 'option');
assert.ok(listItem.id, 'List item should have an id');
assert.strictEqual(listItem.getAttribute('aria-selected'), 'false', 'Unselected item should have aria-selected=false');
const listItems = widget.domNode.querySelectorAll('.chat-question-list-item');
assert.strictEqual(listItems.length, 2, 'Should have 2 list items');
// First item should be auto-selected (no default value, so first is selected)
const firstItem = listItems[0] as HTMLElement;
assert.strictEqual(firstItem.getAttribute('role'), 'option');
assert.ok(firstItem.id, 'List item should have an id');
assert.strictEqual(firstItem.getAttribute('aria-selected'), 'true', 'First item should be auto-selected');
// Second item should not be selected
const secondItem = listItems[1] as HTMLElement;
assert.strictEqual(secondItem.getAttribute('role'), 'option');
assert.strictEqual(secondItem.getAttribute('aria-selected'), 'false', 'Unselected item should have aria-selected=false');
});
});