Add type-to-search to menu

This commit is contained in:
Cory LaViska
2020-06-19 17:53:04 -04:00
parent a56b3e2583
commit c47bd6adc4
10 changed files with 183 additions and 128 deletions

View File

@@ -11,6 +11,9 @@ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor i
<sl-menu-item value="option-1">Option 1</sl-menu-item>
<sl-menu-item value="option-2">Option 2</sl-menu-item>
<sl-menu-item value="option-3">Option 3</sl-menu-item>
<sl-menu-item value="cat">Cat</sl-menu-item>
<sl-menu-item value="dog">Dog</sl-menu-item>
<sl-menu-item value="monkey">Monkey</sl-menu-item>
<sl-menu-divider></sl-menu-divider>
<sl-menu-item value="option-4">Option 4</sl-menu-item>
<sl-menu-item value="option-5">Option 5</sl-menu-item>

View File

@@ -2,9 +2,7 @@
The roadmap tracks the status of components and features that are planned, in development, and under consideration.
## 1.0.0 📦
### Components
## 2.0 🚀
- [x] Alert
- [x] Avatar
@@ -14,19 +12,19 @@ The roadmap tracks the status of components and features that are planned, in de
- [x] Details
- [x] Dialog
- [x] Dropdown
- [ ] Form (necessary to make shadowed form controls easier to consume)
- [ ] Form
- [x] Icon
- [x] Input
- [x] Menu
- [x] Menu Divider
- [x] Menu Item
- [x] Menu Label
- [ ] Panel (aka "drawer")
- [ ] Panel
- [x] Progress Bar
- [x] Progress Ring
- [x] Radio
- [x] Range
- [x] Select (single + multi)
- [x] Select
- [x] Spinner
- [x] Switch
- [x] Tab List
@@ -36,29 +34,20 @@ The roadmap tracks the status of components and features that are planned, in de
- [x] Textarea
- [x] Tooltip
### Features / Misc.
## 2.1 📦
- [ ] Type-ahead selection for menu
- [ ] Form control labels
- [ ] Form control validation states
- [ ] Expose [parts](https://developer.mozilla.org/en-US/docs/Web/CSS/::part) to allow more granular styling of all components
- [ ] Ensure components are making proper use of design tokens
## Planned 🗺
- [ ] Alert service
- [ ] Alert service (aka "toast")
- [ ] Button group
- [ ] Card
- [ ] Content Placeholder (multiple variations)
- [ ] Date & Time Picker (possibly wrap [flatpickr](https://flatpickr.js.org/))
- [ ] Input group
- [ ] Popover
- [ ] Stepper
## Under Consideration 🤔
- [ ] Card
- [ ] Carousel
- [ ] Content Placeholders
- [ ] Date & Time Picker
- [ ] File Button / Uploader
- [ ] Fit text to container
- [ ] Graphing components (based on [Chart.js](https://www.chartjs.org/))
- [ ] Table (probably a `<table>` wrapper that implements styles, sorting, and some other features)
- [ ] Graphing components based on [Chart.js](https://www.chartjs.org/)
- [ ] Popover
- [ ] Stepper

8
src/components.d.ts vendored
View File

@@ -372,6 +372,14 @@ export namespace Components {
"value": string;
}
interface SlMenu {
/**
* Removes focus from the menu.
*/
"removeFocus": () => Promise<void>;
/**
* Passes key presses to the control. Useful for managing the menu when other elements have focus.
*/
"sendKeyEvent": (event: KeyboardEvent) => Promise<void>;
/**
* Sets focus on the menu.
*/

View File

@@ -26,6 +26,6 @@
transition: var(--sl-transition-fast) opacity;
::slotted(sl-menu) {
--max-height: 50vh;
max-height: 50vh;
}
}

View File

@@ -162,6 +162,13 @@ export class Dropdown {
document.removeEventListener('keydown', this.handleDocumentKeyDown);
}
getMenu() {
return this.panel
.querySelector('slot')
.assignedElements({ flatten: true })
.filter(el => el.tagName.toLowerCase() === 'sl-menu')[0] as HTMLSlMenuElement;
}
handleDocumentKeyDown(event: KeyboardEvent) {
// Close when escape is pressed
if (event.key === 'Escape') {
@@ -182,15 +189,20 @@ export class Dropdown {
});
}
// If a menu is present, focus on it when up/down is pressed
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
const elements = this.panel.querySelector('slot').assignedElements({ flatten: true });
const menu = elements.filter(el => el.tagName.toLowerCase() === 'sl-menu')[0] as HTMLSlMenuElement;
const menu = this.getMenu();
if (menu) {
menu.setFocus();
event.preventDefault();
}
// If a menu is present, focus on it when certain keys are pressed
if (menu && ['ArrowDown', 'ArrowUp'].includes(event.key)) {
event.preventDefault();
menu.setFocus();
return;
}
// All other keys should focus the menu and pass through the event to type-to-search
if (menu && event.target !== menu) {
menu.setFocus();
menu.sendKeyEvent(event);
return;
}
}

View File

@@ -22,39 +22,6 @@
transition: var(--sl-transition-fast) color, var(--sl-transition-fast) border, var(--sl-transition-fast) box-shadow;
cursor: inherit;
.sl-input__control {
flex: 1 1 auto;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
min-width: 0;
height: 100%;
color: var(--sl-input-color);
border: none;
background: none;
box-shadow: none;
padding: 0;
margin: 0;
cursor: inherit;
-webkit-appearance: none;
&::-webkit-search-decoration,
&::-webkit-search-cancel-button,
&::-webkit-search-results-button,
&::-webkit-search-results-decoration {
-webkit-appearance: none;
}
&::placeholder {
color: var(--sl-input-placeholder-color);
user-select: none;
}
&:focus {
outline: none;
}
}
&:hover:not(.sl-input--disabled) {
background-color: var(--sl-input-background-color-hover);
border-color: var(--sl-input-border-color-hover);
@@ -82,14 +49,47 @@
}
}
}
}
.sl-input__prefix,
.sl-input__suffix {
display: flex;
flex: 0 0 auto;
align-items: center;
color: var(--sl-input-icon-color);
.sl-input__control {
flex: 1 1 auto;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
min-width: 0;
height: 100%;
color: var(--sl-input-color);
border: none;
background: none;
box-shadow: none;
padding: 0;
margin: 0;
cursor: inherit;
-webkit-appearance: none;
&::-webkit-search-decoration,
&::-webkit-search-cancel-button,
&::-webkit-search-results-button,
&::-webkit-search-results-decoration {
-webkit-appearance: none;
}
&::placeholder {
color: var(--sl-input-placeholder-color);
user-select: none;
}
&:focus {
outline: none;
}
}
.sl-input__prefix,
.sl-input__suffix {
display: flex;
flex: 0 0 auto;
align-items: center;
color: var(--sl-input-icon-color);
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

View File

@@ -1,20 +1,13 @@
@import 'component';
/**
* @prop --max-height: The maximum height of the menu. */
:host {
display: block;
--max-height: none;
}
.sl-menu {
max-height: var(--max-height);
padding: var(--sl-spacing-x-small) 0;
overflow-y: auto;
overscroll-behavior: none;
&:focus {
outline: none;
}
}
:host(:focus) {
outline: none;
}

View File

@@ -1,5 +1,6 @@
import { Component, Element, Event, EventEmitter, Method, h } from '@stencil/core';
import { Component, Element, Event, EventEmitter, Host, Method, h } from '@stencil/core';
import { scrollIntoView } from '../../utilities/scroll';
import { getTextContent } from '../../utilities/slot';
/**
* @since 1.0.0
@@ -17,6 +18,8 @@ export class Menu {
ignoreMouseEvents = false;
ignoreMouseTimeout: any;
menu: HTMLElement;
typeToSelect = '';
typeToSelectTimeout: any;
constructor() {
this.handleBlur = this.handleBlur.bind(this);
@@ -42,11 +45,23 @@ export class Menu {
/** Sets focus on the menu. */
@Method()
async setFocus() {
this.menu.focus();
this.host.focus();
}
/** Removes focus from the menu. */
@Method()
async removeFocus() {
this.host.blur();
}
/** Passes key presses to the control. Useful for managing the menu when other elements have focus. */
@Method()
async sendKeyEvent(event: KeyboardEvent) {
this.handleKeyDown(event);
}
getItems() {
const slot = this.menu.querySelector('slot');
const slot = this.host.shadowRoot.querySelector('slot');
return [...slot.assignedElements({ flatten: true })].filter(
(el: any) => el.tagName.toLowerCase() === 'sl-menu-item' && !el.disabled
) as [HTMLSlMenuItemElement];
@@ -62,7 +77,7 @@ export class Menu {
scrollItemIntoView(item: HTMLSlMenuItemElement) {
if (item) {
scrollIntoView(item, this.menu);
scrollIntoView(item, this.host);
}
}
@@ -83,8 +98,6 @@ export class Menu {
const target = event.target as HTMLElement;
const item = target.closest('sl-menu-item');
this.menu.focus();
if (item && !item.disabled) {
this.slSelect.emit({ item });
}
@@ -98,8 +111,13 @@ export class Menu {
this.ignoreMouseTimeout = setTimeout(() => (this.ignoreMouseEvents = false), 500);
this.ignoreMouseEvents = true;
// Make a selection when pressing enter or space
if (event.key === 'Enter' || event.key === ' ') {
// Prevent scrolling when certain keys are pressed
if ([' ', 'ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
event.preventDefault();
}
// Make a selection when pressing enter
if (event.key === 'Enter') {
const item = this.getActiveItem();
event.preventDefault();
@@ -136,11 +154,33 @@ export class Menu {
return;
}
}
// Handle type-to-search behavior when non-control characters are entered
if (event.key === ' ' || /^[\d\w]$/i.test(event.key)) {
clearTimeout(this.typeToSelectTimeout);
this.typeToSelectTimeout = setTimeout(() => (this.typeToSelect = ''), 750);
this.typeToSelect += event.key;
const items = this.getItems();
for (const item of items) {
const slot = item.shadowRoot.querySelector('slot:not([name])') as HTMLSlotElement;
const label = getTextContent(slot).toLowerCase().trim();
if (label.substring(0, this.typeToSelect.length) === this.typeToSelect) {
items.map(i => (i.active = i === item));
break;
}
}
}
}
handleMouseDown(event: MouseEvent) {
// Prevent the menu's focus from being lost when interacting with items, dividers, and headers
event.preventDefault();
const target = event.target as HTMLElement;
const menuItem = target.closest('sl-menu-item:not([disabled])');
// Prevent the menu's focus from being lost when interacting with non-menu items
if (!menuItem) {
event.preventDefault();
}
}
handleMouseOver(event: MouseEvent) {
@@ -163,9 +203,7 @@ export class Menu {
render() {
return (
<div
ref={el => (this.menu = el)}
class="sl-menu"
<Host
tabIndex={0}
role="menu"
onClick={this.handleClick}
@@ -177,7 +215,7 @@ export class Menu {
onMouseOut={this.handleMouseOut}
>
<slot />
</div>
</Host>
);
}
}

View File

@@ -22,6 +22,7 @@ export class Select {
this.handleBlur = this.handleBlur.bind(this);
this.handleFocus = this.handleFocus.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.handleMenuKeyDown = this.handleMenuKeyDown.bind(this);
this.handleMenuHide = this.handleMenuHide.bind(this);
this.handleMenuShow = this.handleMenuShow.bind(this);
this.handleMenuSelect = this.handleMenuSelect.bind(this);
@@ -116,12 +117,18 @@ export class Select {
}
handleKeyDown(event: KeyboardEvent) {
if (!this.isOpen && (event.key === 'Enter' || event.key === ' ')) {
// Open the dropdown when enter is pressed
if (!this.isOpen && event.key === 'Enter') {
this.dropdown.show();
event.preventDefault();
return;
}
}
handleMenuKeyDown(event: KeyboardEvent) {
event.stopPropagation();
}
handleMenuSelect(event: CustomEvent) {
const item = event.detail.item;
@@ -261,7 +268,12 @@ export class Select {
<sl-icon slot="suffix" class="sl-select__icon" name="chevron-down" />
</sl-input>
<sl-menu ref={el => (this.menu = el)} class="sl-select__menu" onSlSelect={this.handleMenuSelect}>
<sl-menu
ref={el => (this.menu = el)}
class="sl-select__menu"
onSlSelect={this.handleMenuSelect}
onKeyDown={this.handleMenuKeyDown}
>
<slot />
</sl-menu>
</sl-dropdown>

View File

@@ -20,36 +20,6 @@
transition: var(--sl-transition-fast) color, var(--sl-transition-fast) border, var(--sl-transition-fast) box-shadow;
cursor: inherit;
.sl-textarea__control {
flex: 1 1 auto;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
line-height: 1.4;
color: var(--sl-input-color);
border: none;
background: none;
box-shadow: none;
cursor: inherit;
-webkit-appearance: none;
&::-webkit-search-decoration,
&::-webkit-search-cancel-button,
&::-webkit-search-results-button,
&::-webkit-search-results-decoration {
-webkit-appearance: none;
}
&::placeholder {
color: var(--sl-input-placeholder-color);
user-select: none;
}
&:focus {
outline: none;
}
}
&:hover:not(.sl-textarea--disabled) {
background-color: var(--sl-input-background-color-hover);
border-color: var(--sl-input-border-color-hover);
@@ -78,6 +48,36 @@
}
}
.sl-textarea__control {
flex: 1 1 auto;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
line-height: 1.4;
color: var(--sl-input-color);
border: none;
background: none;
box-shadow: none;
cursor: inherit;
-webkit-appearance: none;
&::-webkit-search-decoration,
&::-webkit-search-cancel-button,
&::-webkit-search-results-button,
&::-webkit-search-results-decoration {
-webkit-appearance: none;
}
&::placeholder {
color: var(--sl-input-placeholder-color);
user-select: none;
}
&:focus {
outline: none;
}
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Size modifiers
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////