Skip to content

Commit 47f01bb

Browse files
committed
More model picker improvements
1 parent 167a0e8 commit 47f01bb

14 files changed

Lines changed: 1772 additions & 150 deletions

File tree

src/vs/base/browser/ui/radio/radio.css

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,11 @@
2525
border-bottom-right-radius: 3px;
2626
}
2727

28-
.monaco-custom-radio > .monaco-button:not(.active):not(:last-child) {
28+
.monaco-custom-radio:not(.segmented) > .monaco-button:not(.active):not(:last-child) {
2929
border-right: none;
3030
}
3131

32-
.monaco-custom-radio > .monaco-button.previous-active {
32+
.monaco-custom-radio:not(.segmented) > .monaco-button.previous-active {
3333
border-left: none;
3434
}
3535

@@ -93,6 +93,20 @@
9393
white-space: nowrap;
9494
}
9595

96+
/* Reserve the selected label's width without making unselected labels bold. */
97+
.monaco-custom-radio.segmented > .monaco-button > [data-label] {
98+
display: inline-grid;
99+
justify-items: center;
100+
}
101+
102+
.monaco-custom-radio.segmented > .monaco-button > [data-label]::after {
103+
content: attr(data-label);
104+
height: 0;
105+
font-weight: var(--vscode-fontWeight-semiBold);
106+
visibility: hidden;
107+
pointer-events: none;
108+
}
109+
96110
.monaco-custom-radio.segmented > .monaco-button:hover:not(.active) {
97111
color: var(--vscode-foreground);
98112
background: transparent;
@@ -112,3 +126,7 @@
112126
outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder);
113127
outline-offset: calc(-1 * var(--vscode-strokeThickness));
114128
}
129+
130+
.monaco-custom-radio.segmented > .monaco-button:focus:not(:focus-visible) {
131+
outline: none;
132+
}

src/vs/base/browser/ui/radio/radio.ts

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ export class Radio extends Widget {
5555
private readonly _onDidSelect = this._register(new Emitter<number>());
5656
readonly onDidSelect = this._onDidSelect.event;
5757

58+
private readonly _onDidActivate = this._register(new Emitter<number>());
59+
/** Fires on click, Enter, or Space, even when the item is already selected. */
60+
readonly onDidActivate = this._onDidActivate.event;
61+
5862
readonly domNode: HTMLElement;
5963

6064
private readonly hoverDelegate: IHoverDelegate;
@@ -95,8 +99,8 @@ export class Radio extends Widget {
9599
if (!button || !item) {
96100
return Disposable.None;
97101
}
98-
button.label = text;
99-
return toDisposable(() => { button.label = item.text; });
102+
this.setButtonLabel(button, text);
103+
return toDisposable(() => this.setButtonLabel(button, item.text));
100104
}
101105

102106
setItems(items: ReadonlyArray<IRadioOptionItem>): void {
@@ -115,8 +119,10 @@ export class Radio extends Widget {
115119
}));
116120
button.element.setAttribute('role', 'radio');
117121
button.enabled = !item.disabled;
118-
// Button turns Enter and Space into a click, which is how `focus` mode selects.
119-
disposables.add(button.onDidClick(() => this.selectItem(index)));
122+
disposables.add(button.onDidClick(() => {
123+
this.selectItem(index);
124+
this._onDidActivate.fire(index);
125+
}));
120126
disposables.add(addDisposableListener(button.element, EventType.KEY_DOWN, e => {
121127
const event = new StandardKeyboardEvent(e);
122128
const delta = event.equals(KeyCode.RightArrow) || event.equals(KeyCode.DownArrow) ? 1
@@ -152,10 +158,21 @@ export class Radio extends Widget {
152158
focusActiveItem(): void {
153159
const index = this.activeItem ? this.items.indexOf(this.activeItem) : -1;
154160
if (index !== -1) {
155-
this.orderedButtons[index]?.focus();
161+
this.focusItem(index);
156162
}
157163
}
158164

165+
/** Moves focus to an item without selecting it. */
166+
focusItem(index: number): void {
167+
if (!this.orderedButtons[index]) {
168+
throw new Error('Invalid Index');
169+
}
170+
for (let candidate = 0; candidate < this.orderedButtons.length; candidate++) {
171+
this.orderedButtons[candidate].element.tabIndex = candidate === index ? 0 : -1;
172+
}
173+
this.orderedButtons[index].focus();
174+
}
175+
159176
private selectItem(index: number): void {
160177
const item = this.items[index];
161178
if (!item || this.activeItem === item) {
@@ -182,11 +199,15 @@ export class Radio extends Widget {
182199
}
183200
}
184201

185-
private focusItem(index: number): void {
186-
for (let candidate = 0; candidate < this.orderedButtons.length; candidate++) {
187-
this.orderedButtons[candidate].element.tabIndex = candidate === index ? 0 : -1;
202+
private setButtonLabel(button: Button, text: string): void {
203+
button.label = text;
204+
if (this.domNode.classList.contains('segmented')) {
205+
for (const element of button.element.children) {
206+
if (!element.classList.contains('codicon')) {
207+
element.setAttribute('data-label', element.textContent ?? '');
208+
}
209+
}
188210
}
189-
this.orderedButtons[index]?.focus();
190211
}
191212

192213
private updateButtons(): void {
@@ -198,7 +219,7 @@ export class Radio extends Widget {
198219
button.element.classList.toggle('previous-active', isPreviousActive);
199220
button.element.setAttribute('aria-checked', String(isActive));
200221
button.element.tabIndex = isActive ? 0 : -1;
201-
button.label = item.text;
222+
this.setButtonLabel(button, item.text);
202223
}
203224
}
204225

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import assert from 'assert';
7+
import { getWindow } from '../../../../browser/dom.js';
8+
import { Radio, IRadioOptions } from '../../../../browser/ui/radio/radio.js';
9+
import { mainWindow } from '../../../../browser/window.js';
10+
import { toDisposable } from '../../../../common/lifecycle.js';
11+
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../common/utils.js';
12+
13+
suite('Radio', () => {
14+
const disposables = ensureNoDisposablesAreLeakedInTestSuite();
15+
16+
function createRadio(options: IRadioOptions): Radio {
17+
const radio = disposables.add(new Radio(options));
18+
mainWindow.document.body.appendChild(radio.domNode);
19+
disposables.add(toDisposable(() => radio.domNode.remove()));
20+
radio.domNode.style.cssText = `
21+
position: absolute;
22+
top: 0;
23+
left: 0;
24+
font-family: sans-serif;
25+
--vscode-spacing-sizeNone: 0px;
26+
--vscode-spacing-size20: 2px;
27+
--vscode-spacing-size60: 6px;
28+
--vscode-spacing-size240: 24px;
29+
--vscode-strokeThickness: 1px;
30+
--vscode-fontSize-label2: 11px;
31+
--vscode-fontWeight-semiBold: 600;
32+
`;
33+
return radio;
34+
}
35+
36+
for (const labels of [
37+
['Low', 'Medium', 'High'],
38+
['Low', 'Medium', 'High', 'Extra High'],
39+
['Minimal', 'Low', 'Medium', 'High', 'Max'],
40+
['None', 'Low', 'Medium', 'High', 'Extra High', 'Max'],
41+
['Standard', '$(zap) Fast'],
42+
]) {
43+
for (const width of [200, 276]) {
44+
test(`segmented option bounds stay fixed at ${width}px: ${labels.join(', ')}`, () => {
45+
const radio = createRadio({
46+
className: 'segmented',
47+
items: labels.map(text => ({ text })),
48+
});
49+
radio.domNode.style.width = `${width}px`;
50+
const measure = () => radio.optionElements.map(element => {
51+
const { x, y, width, height } = element.getBoundingClientRect();
52+
return { x, y, width, height };
53+
});
54+
const initial = measure();
55+
const selections = labels.map((_, index) => {
56+
radio.optionElements[index].click();
57+
return measure();
58+
});
59+
60+
assert.deepStrictEqual({
61+
laidOut: initial.every(box => box.width > 0 && box.height > 0),
62+
selections,
63+
}, {
64+
laidOut: true,
65+
selections: labels.map(() => initial),
66+
});
67+
});
68+
}
69+
}
70+
71+
test('segmented label sizing follows temporary labels and restoration without duplicating icons', () => {
72+
const radio = createRadio({ className: 'segmented', items: [{ text: '$(zap) Fast', ariaLabel: 'Fast' }] });
73+
const element = radio.optionElements[0];
74+
const readLabel = () => ({
75+
text: element.textContent,
76+
labels: Array.from(element.querySelectorAll('[data-label]'), label => label.getAttribute('data-label')),
77+
icons: element.querySelectorAll('.codicon').length,
78+
ariaLabel: element.getAttribute('aria-label'),
79+
});
80+
const initial = readLabel();
81+
const override = disposables.add(radio.overrideOptionLabel(0, '$(check) Standard'));
82+
const temporary = readLabel();
83+
override.dispose();
84+
85+
assert.deepStrictEqual({ initial, temporary, restored: readLabel() }, {
86+
initial: { text: 'Fast', labels: ['Fast'], icons: 1, ariaLabel: 'Fast' },
87+
temporary: { text: 'Standard', labels: ['Standard'], icons: 1, ariaLabel: 'Fast' },
88+
restored: { text: 'Fast', labels: ['Fast'], icons: 1, ariaLabel: 'Fast' },
89+
});
90+
});
91+
92+
test('joined radios retain shared borders and do not add label sizing', () => {
93+
const radio = createRadio({ items: [{ text: 'One' }, { text: 'Two' }, { text: 'Three' }] });
94+
const borders = () => radio.optionElements.map(element => {
95+
const style = getWindow(element).getComputedStyle(element);
96+
return [style.borderLeftWidth, style.borderRightWidth];
97+
});
98+
const firstSelected = borders();
99+
radio.setActiveItem(1);
100+
101+
assert.deepStrictEqual({
102+
firstSelected,
103+
secondSelected: borders(),
104+
sizingLabels: radio.domNode.querySelectorAll('[data-label]').length,
105+
}, {
106+
firstSelected: [['1px', '1px'], ['0px', '0px'], ['1px', '1px']],
107+
secondSelected: [['1px', '0px'], ['1px', '1px'], ['0px', '1px']],
108+
sizingLabels: 0,
109+
});
110+
});
111+
112+
test('segmented arrows can move focus without selecting until Enter', () => {
113+
const radio = createRadio({
114+
className: 'segmented',
115+
arrowKeyBehavior: 'focus',
116+
items: [{ text: 'Low' }, { text: 'Medium' }, { text: 'High' }],
117+
});
118+
const selected: number[] = [];
119+
disposables.add(radio.onDidSelect(index => selected.push(index)));
120+
const [low, medium] = radio.optionElements;
121+
low.focus();
122+
low.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', keyCode: 39, bubbles: true }));
123+
const beforeEnter = {
124+
focused: mainWindow.document.activeElement === medium,
125+
checked: radio.optionElements.map(element => element.getAttribute('aria-checked')),
126+
tabIndexes: radio.optionElements.map(element => element.tabIndex),
127+
};
128+
medium.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13, bubbles: true }));
129+
130+
assert.deepStrictEqual({
131+
beforeEnter,
132+
selected,
133+
checked: radio.optionElements.map(element => element.getAttribute('aria-checked')),
134+
}, {
135+
beforeEnter: { focused: true, checked: ['true', 'false', 'false'], tabIndexes: [-1, 0, -1] },
136+
selected: [1],
137+
checked: ['false', 'true', 'false'],
138+
});
139+
});
140+
141+
test('activation fires for clicks on both selected and unselected items without changing selection events', () => {
142+
const radio = createRadio({ items: [{ text: 'One' }, { text: 'Two' }] });
143+
const events: string[] = [];
144+
disposables.add(radio.onDidSelect(index => events.push(`selected:${index}`)));
145+
disposables.add(radio.onDidActivate(index => events.push(`activated:${index}`)));
146+
radio.optionElements[0].click();
147+
radio.optionElements[1].click();
148+
radio.optionElements[1].click();
149+
radio.setActiveItem(0);
150+
151+
assert.deepStrictEqual(events, ['activated:0', 'selected:1', 'activated:1', 'activated:1']);
152+
});
153+
154+
for (const [key, keyCode] of [['Enter', 13], [' ', 32]] as const) {
155+
test(`${key === ' ' ? 'Space' : key} activates both selected and unselected items exactly once`, () => {
156+
const radio = createRadio({
157+
arrowKeyBehavior: 'focus',
158+
items: [{ text: 'One' }, { text: 'Two' }],
159+
});
160+
const events: string[] = [];
161+
disposables.add(radio.onDidSelect(index => events.push(`selected:${index}`)));
162+
disposables.add(radio.onDidActivate(index => events.push(`activated:${index}`)));
163+
for (const index of [0, 1, 1]) {
164+
radio.optionElements[index].dispatchEvent(new KeyboardEvent('keydown', { key, keyCode, bubbles: true }));
165+
}
166+
167+
assert.deepStrictEqual(events, ['activated:0', 'selected:1', 'activated:1', 'activated:1']);
168+
});
169+
}
170+
171+
for (const arrowKeyBehavior of ['select', 'focus'] as const) {
172+
test(`arrows in ${arrowKeyBehavior} mode skip disabled items and never activate`, () => {
173+
const radio = createRadio({
174+
arrowKeyBehavior,
175+
items: [{ text: 'One' }, { text: 'Two', disabled: true }, { text: 'Three' }],
176+
});
177+
const selected: number[] = [];
178+
const activated: number[] = [];
179+
disposables.add(radio.onDidSelect(index => selected.push(index)));
180+
disposables.add(radio.onDidActivate(index => activated.push(index)));
181+
radio.focusActiveItem();
182+
radio.optionElements[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', keyCode: 39, bubbles: true }));
183+
const focusedAfterArrow = mainWindow.document.activeElement === radio.optionElements[2];
184+
radio.optionElements[2].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', keyCode: 39, bubbles: true }));
185+
186+
assert.deepStrictEqual({
187+
selected,
188+
activated,
189+
focusedAfterArrow,
190+
focusedAfterWrap: mainWindow.document.activeElement === radio.optionElements[0],
191+
}, {
192+
selected: arrowKeyBehavior === 'select' ? [2, 0] : [],
193+
activated: [],
194+
focusedAfterArrow: true,
195+
focusedAfterWrap: true,
196+
});
197+
});
198+
}
199+
200+
test('disabled items and disabled controls cannot be activated', () => {
201+
const radio = createRadio({ items: [{ text: 'One' }, { text: 'Two', disabled: true }] });
202+
const events: string[] = [];
203+
disposables.add(radio.onDidSelect(index => events.push(`selected:${index}`)));
204+
disposables.add(radio.onDidActivate(index => events.push(`activated:${index}`)));
205+
const activate = (element: HTMLElement) => {
206+
element.click();
207+
element.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13, bubbles: true }));
208+
element.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', keyCode: 32, bubbles: true }));
209+
};
210+
activate(radio.optionElements[1]);
211+
radio.setEnabled(false);
212+
activate(radio.optionElements[0]);
213+
214+
assert.deepStrictEqual(events, []);
215+
});
216+
217+
test('restoring focus keeps one tab stop without selecting or activating', () => {
218+
const radio = createRadio({ arrowKeyBehavior: 'focus', items: [{ text: 'One' }, { text: 'Two' }] });
219+
const events: number[] = [];
220+
disposables.add(radio.onDidSelect(index => events.push(index)));
221+
disposables.add(radio.onDidActivate(index => events.push(index)));
222+
radio.focusItem(1);
223+
const focusedItem = {
224+
focused: mainWindow.document.activeElement === radio.optionElements[1],
225+
tabIndexes: radio.optionElements.map(element => element.tabIndex),
226+
checked: radio.optionElements.map(element => element.getAttribute('aria-checked')),
227+
};
228+
radio.focusActiveItem();
229+
230+
assert.deepStrictEqual({
231+
focusedItem,
232+
events,
233+
activeFocused: mainWindow.document.activeElement === radio.optionElements[0],
234+
tabIndexes: radio.optionElements.map(element => element.tabIndex),
235+
}, {
236+
focusedItem: { focused: true, tabIndexes: [-1, 0], checked: ['true', 'false'] },
237+
events: [],
238+
activeFocused: true,
239+
tabIndexes: [0, -1],
240+
});
241+
});
242+
243+
test('replaced and disposed buttons no longer select or activate', () => {
244+
const radio = createRadio({ items: [{ text: 'One' }, { text: 'Two' }] });
245+
const events: number[] = [];
246+
disposables.add(radio.onDidSelect(index => events.push(index)));
247+
disposables.add(radio.onDidActivate(index => events.push(index)));
248+
const previousButton = radio.optionElements[1];
249+
radio.setItems([{ text: 'Three' }]);
250+
previousButton.click();
251+
const currentButton = radio.optionElements[0];
252+
radio.dispose();
253+
currentButton.click();
254+
255+
assert.deepStrictEqual(events, []);
256+
});
257+
258+
test('focusing an invalid item reports an error without changing the tab order', () => {
259+
const radio = createRadio({ items: [{ text: 'One' }] });
260+
assert.throws(() => radio.focusItem(-1), /Invalid Index/);
261+
assert.throws(() => radio.focusItem(1), /Invalid Index/);
262+
assert.deepStrictEqual(radio.optionElements.map(element => element.tabIndex), [0]);
263+
});
264+
});

0 commit comments

Comments
 (0)