This commit is contained in:
Sarah Higley 2019-12-05 08:20:07 +08:00
Родитель 06bb2a08a2
Коммит 5781ae4042
5 изменённых файлов: 443 добавлений и 1 удалений

43
src/components.d.ts поставляемый
Просмотреть файл

@ -24,6 +24,39 @@ import {
export namespace Components {
interface SuiCombobox {
/**
* Whether the combobox should filter based on user input. Defaults to false.
*/
'filter': boolean;
/**
* String label
*/
'label': string;
/**
* Array of name/value options
*/
'options': SelectOption[];
}
interface SuiComboboxAttributes extends StencilHTMLAttributes {
/**
* Whether the combobox should filter based on user input. Defaults to false.
*/
'filter'?: boolean;
/**
* String label
*/
'label'?: string;
/**
* Emit a custom select event on value change
*/
'onSelect'?: (event: CustomEvent) => void;
/**
* Array of name/value options
*/
'options'?: SelectOption[];
}
interface SuiDisclosure {
/**
* Optional override to the button's accessible name (using aria-label)
@ -651,6 +684,7 @@ export namespace Components {
declare global {
interface StencilElementInterfaces {
'SuiCombobox': Components.SuiCombobox;
'SuiDisclosure': Components.SuiDisclosure;
'SuiModal': Components.SuiModal;
'SuiSelect': Components.SuiSelect;
@ -674,6 +708,7 @@ declare global {
}
interface StencilIntrinsicElements {
'sui-combobox': Components.SuiComboboxAttributes;
'sui-disclosure': Components.SuiDisclosureAttributes;
'sui-modal': Components.SuiModalAttributes;
'sui-select': Components.SuiSelectAttributes;
@ -697,6 +732,12 @@ declare global {
}
interface HTMLSuiComboboxElement extends Components.SuiCombobox, HTMLStencilElement {}
var HTMLSuiComboboxElement: {
prototype: HTMLSuiComboboxElement;
new (): HTMLSuiComboboxElement;
};
interface HTMLSuiDisclosureElement extends Components.SuiDisclosure, HTMLStencilElement {}
var HTMLSuiDisclosureElement: {
prototype: HTMLSuiDisclosureElement;
@ -818,6 +859,7 @@ declare global {
};
interface HTMLElementTagNameMap {
'sui-combobox': HTMLSuiComboboxElement
'sui-disclosure': HTMLSuiDisclosureElement
'sui-modal': HTMLSuiModalElement
'sui-select': HTMLSuiSelectElement
@ -841,6 +883,7 @@ declare global {
}
interface ElementTagNameMap {
'sui-combobox': HTMLSuiComboboxElement;
'sui-disclosure': HTMLSuiDisclosureElement;
'sui-modal': HTMLSuiModalElement;
'sui-select': HTMLSuiSelectElement;

Просмотреть файл

@ -0,0 +1,96 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT license. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
.combo {
display: block;
max-width: 400px;
position: relative;
}
.combo::after {
border-bottom: 2px solid rgba(0,0,0,.5);
border-right: 2px solid rgba(0,0,0,.5);
content: '';
display: block;
height: 12px;
pointer-events: none;
position: absolute;
right: 16px;
top: 50%;
transform: translate(0, -65%) rotate(45deg);
width: 12px;
}
.combo-input {
background-color: #f5f5f5;
border: 2px solid rgba(0,0,0,.5);
border-radius: 4px;
display: block;
min-height: calc(1.4em + 26px);
padding: 12px 16px 14px;
text-align: left;
width: 100%;
}
.open .combo-input {
border-radius: 4px 4px 0 0;
}
.combo-input:focus {
border-color: #0067b8;
box-shadow: 0 0 4px 2px #0067b8;
outline: 5px solid transparent;
}
.combo-label {
display: block;
font-size: 20px;
font-weight: 100;
margin-bottom: 0.25em;
}
.combo-menu {
background-color: #f5f5f5;
border: 1px solid rgba(0,0,0,.42);
border-radius: 0 0 4px 4px;
display: none;
max-height: 300px;
overflow-y:scroll;
left: 0;
position: absolute;
top: 100%;
width: 100%;
z-index: 100;
}
.open .combo-menu {
display: block;
}
.combo-option {
padding: 10px 12px 12px;
}
.combo-option.option-current,
.combo-option:hover {
background-color: rgba(0,0,0,0.1);
}
.combo-option.option-selected {
padding-right: 30px;
position: relative;
}
.combo-option.option-selected::after {
border-bottom: 2px solid #000;
border-right: 2px solid #000;
content: '';
height: 16px;
position: absolute;
right: 15px;
top: 50%;
transform: translate(0, -50%) rotate(45deg);
width: 8px;
}

Просмотреть файл

@ -0,0 +1,243 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT license. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Component, Event, EventEmitter, Prop, State, Watch } from '@stencil/core';
import { SelectOption } from '../../shared/interfaces';
import { getActionFromKey, getUpdatedIndex, isScrollable, maintainScrollVisibility, MenuActions, uniqueId, filterOptions } from '../../shared/utils';
@Component({
tag: 'sui-combobox',
styleUrl: './combobox.css',
shadow: false
})
export class SuiCombobox {
/**
* Whether the combobox should filter based on user input. Defaults to false.
*/
@Prop() filter: boolean;
/**
* String label
*/
@Prop() label: string;
/**
* Array of name/value options
*/
@Prop() options: SelectOption[];
/**
* Emit a custom select event on value change
*/
@Event({
eventName: 'select'
}) selectEvent: EventEmitter;
// Active option index
@State() activeIndex = 0;
// Filtered options
@State() filteredOptions: SelectOption[];
// Menu state
@State() open = false;
// input value
@State() value: string;
// save reference to active option
private activeOptionRef: HTMLElement;
// Unique ID that should really use a UUID library instead
private htmlId = uniqueId();
// Prevent menu closing before click completed
private ignoreBlur = false;
// save reference to input element
private inputRef: HTMLInputElement;
// save reference to listbox
private listboxRef: HTMLElement;
// save the last selected value
private selectedValue = '';
@Watch('options')
watchOptions(newValue: SelectOption[]) {
if (this.filter) {
this.filteredOptions = filterOptions(newValue, this.value);
}
}
componentDidLoad() {
const {
filter = false,
options = [],
value = ''
} = this;
this.filteredOptions = filter ? filterOptions(options, value) : options;
this.value = typeof this.value === 'string' ? value : this.filteredOptions.length > 0 ? this.filteredOptions[0].name : '';
}
componentDidUpdate() {
if (this.open && isScrollable(this.listboxRef)) {
maintainScrollVisibility(this.activeOptionRef, this.listboxRef);
}
}
render() {
const {
activeIndex,
htmlId,
label = '',
open = false,
filteredOptions = [],
value
} = this;
const activeId = open ? `${htmlId}-${activeIndex}` : '';
return ([
<label id={htmlId} class="combo-label">{label}</label>,
<div class={{ combo: true, open }}>
<input
aria-activedescendant={activeId}
aria-autocomplete="list"
aria-controls={`${htmlId}-listbox`}
aria-expanded={`${open}`}
aria-haspopup="listbox"
aria-labelledby={htmlId}
class="combo-input"
ref={(el) => this.inputRef = el}
role="combobox"
type="text"
value={value}
onBlur={this.onInputBlur.bind(this)}
onClick={() => this.updateMenuState(true)}
onInput={this.onInput.bind(this)}
onKeyDown={this.onInputKeyDown.bind(this)}
/>
<div class="combo-menu" role="listbox" ref={(el) => this.listboxRef = el} id={`${htmlId}-listbox`}>
{filteredOptions.map((option, i) => {
return (
<div
class={{ 'option-current': this.activeIndex === i, 'combo-option': true }}
id={`${this.htmlId}-${i}`}
aria-selected={this.activeIndex === i ? 'true' : false}
ref={(el) => {if (this.activeIndex === i) this.activeOptionRef = el; }}
role="option"
onClick={() => { this.onOptionClick(i); }}
onMouseDown={this.onOptionMouseDown.bind(this)}
>{option.name}</div>
);
})}
</div>
</div>
]);
}
private onInput() {
const curValue = this.inputRef.value;
const matches = filterOptions(this.options, curValue);
// if we're filtering options, just need to set activeIndex to 0
if (this.filter) {
this.filteredOptions = [...matches];
this.activeIndex = 0;
}
// if not filtering options, set activeIndex to first matching option
// (or leave it alone, if the active option is already in the matching set)
else {
const filterCurrentOption = matches.filter((option) => option.name === this.options[this.activeIndex].name);
if (matches.length > 0 && !filterCurrentOption.length) {
this.activeIndex = this.options.indexOf(matches[0]);
}
}
if (this.value !== curValue) {
this.value = curValue;
}
const menuState = this.filteredOptions.length > 0;
if (this.open !== menuState) {
this.updateMenuState(menuState, false);
}
}
private onInputKeyDown(event: KeyboardEvent) {
const { key } = event;
const max = this.filteredOptions.length - 1;
const action = getActionFromKey(key, this.open);
switch(action) {
case MenuActions.Next:
case MenuActions.Last:
case MenuActions.First:
case MenuActions.Previous:
event.preventDefault();
return this.onOptionChange(getUpdatedIndex(this.activeIndex, max, action));
case MenuActions.CloseSelect:
event.preventDefault();
this.selectOption(this.activeIndex);
return this.updateMenuState(false);
case MenuActions.Close:
event.preventDefault();
this.activeIndex = 0;
this.value = this.selectedValue;
this.filteredOptions = this.options;
return this.updateMenuState(false);
case MenuActions.Open:
return this.updateMenuState(true);
}
}
private onInputBlur() {
if (this.ignoreBlur) {
this.ignoreBlur = false;
return;
}
if (this.open) {
this.selectOption(this.activeIndex);
this.updateMenuState(false, false);
}
}
private onOptionChange(index: number) {
this.activeIndex = index;
}
private onOptionClick(index: number) {
this.onOptionChange(index);
this.selectOption(index);
this.updateMenuState(false);
}
private onOptionMouseDown() {
this.ignoreBlur = true;
}
private selectOption(index: number) {
const selected = this.filteredOptions[index];
this.value = selected.name;
this.selectedValue = selected.name;
this.activeIndex = 0;
if (this.filter) {
this.filteredOptions = filterOptions(this.options, this.value);
}
this.selectEvent.emit(selected);
}
private updateMenuState(open: boolean, callFocus = true) {
this.open = open;
callFocus && this.inputRef.focus();
}
}

Просмотреть файл

@ -0,0 +1,58 @@
# Combobox autoselect active option
A test of an alternative to a native `<select>` that uses `div` with `role="combobox"` with an `<input>` child. Provides filtering. Instead of autocomplete the first matching option is selected.
- When the listbox popup is displayed, it contains suggested values that complete or logically correspond to the characters typed in the textbox. In - this implementation, the values in the listbox have names that start with the characters typed in the textbox.
- The first suggestion is automatically highlighted as selected.
- The automatically selected suggestion becomes the value of the textbox when the combobox loses focus unless the user chooses a different suggestion or changes the character string in the textbox.
## Purpose
Custom dropdown selection widgets have historically been difficult to implement in an accessible way. The [ARIA combobox pattern](https://www.w3.org/TR/wai-aria-practices-1.1/#combobox) altered quite a bit from ARIA 1.0 to ARIA 1.1, and browser and assistive tech support is still imperfect.
Another element of confusion comes from the ambiguity of needing to choose between a button/listbox implementation and a combobox implementation. The native `<select>` maps to the former on both macOS and iOS, and to the latter on Windows machines.
The native `<select>` is still a much better choice than any custom element.
- The main complaint with a native `<select>` is that the options menu is not easily styled.
- Custom selection components remain some of the hardest to get right. There was significant change in the pattern between ARIA 1.0 and ARIA 1.1
- macOS and Windows interpret the roles of the native `<select>` differently, which makes it hard to choose which semantic avenue to follow. The ARIA spec comes down on the macOS side, but more screen reader users are on Windows.
- There are widely varying implementations found in the wild, even from accessibility professionals. There does not seem to be a single easy consensus about how to write this, and we still get frequent questions and issues raised about this pattern
## Testing
### Test setup
(Add more information on usability tests, including link to scenarios and list of ATs included in the study)
### Results
(Pending study completion)
## Design Guidelines
All the usual requirements for form fields apply: label, visible focus state, perceivable control boundaries, etc.
### Keyboard Interaction
<!-- Auto Generated Below -->
## Properties
| Property | Attribute | Description | Type | Default |
| --------- | --------- | --------------------------- | ---------------- | ----------- |
| `label` | `label` | String label | `string` | `undefined` |
| `options` | -- | Array of name/value options | `SelectOption[]` | `undefined` |
## Events
| Event | Description | Type |
| -------- | ------------------------------------------ | ------------------- |
| `select` | Emit a custom select event on value change | `CustomEvent<void>` |
----------------------------------------------
*Built with [StencilJS](https://stenciljs.com/)*

Просмотреть файл

@ -23,6 +23,8 @@
<sui-select label="Select a Fruit" class="fruit-select"></sui-select>
<sui-combobox label="Select a State" class="state-select"></sui-combobox>
<h2>Disclosure</h2>
<sui-disclosure>
@ -94,7 +96,6 @@
const pageWrapper = document.querySelector('.main');
dialogTrigger.addEventListener('click', () => {
console.log('open dialog');
dialog.open = true;
pageWrapper.setAttribute('inert', true);
});
@ -102,6 +103,7 @@
dialog.open = false;
pageWrapper.removeAttribute('inert');
setTimeout(function() {
// setTimeout needed to work around inert polyfill implementation
dialogTrigger.focus();
}, 0);
});