зеркало из https://github.com/microsoft/sonder-ui.git
add combobox component
This commit is contained in:
Родитель
06bb2a08a2
Коммит
5781ae4042
|
@ -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);
|
||||
});
|
||||
|
|
Загрузка…
Ссылка в новой задаче