From c47bd6adc47532ff89ae5b6adcffc74dfe9ff3ef Mon Sep 17 00:00:00 2001 From: Cory LaViska Date: Fri, 19 Jun 2020 17:53:04 -0400 Subject: [PATCH] Add type-to-search to menu --- docs/components/select.md | 3 ++ docs/overview/roadmap.md | 35 +++++------- src/components.d.ts | 8 +++ src/components/dropdown/dropdown.scss | 2 +- src/components/dropdown/dropdown.tsx | 28 +++++++--- src/components/input/input.scss | 78 +++++++++++++-------------- src/components/menu/menu.scss | 15 ++---- src/components/menu/menu.tsx | 66 ++++++++++++++++++----- src/components/select/select.tsx | 16 +++++- src/components/textarea/textarea.scss | 60 ++++++++++----------- 10 files changed, 183 insertions(+), 128 deletions(-) diff --git a/docs/components/select.md b/docs/components/select.md index eb6f6389e..c4b3d97e8 100644 --- a/docs/components/select.md +++ b/docs/components/select.md @@ -11,6 +11,9 @@ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor i Option 1 Option 2 Option 3 + Cat + Dog + Monkey Option 4 Option 5 diff --git a/docs/overview/roadmap.md b/docs/overview/roadmap.md index 6080feb95..ce43c57cf 100644 --- a/docs/overview/roadmap.md +++ b/docs/overview/roadmap.md @@ -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 `` wrapper that implements styles, sorting, and some other features) +- [ ] Graphing components based on [Chart.js](https://www.chartjs.org/) +- [ ] Popover +- [ ] Stepper diff --git a/src/components.d.ts b/src/components.d.ts index f49082274..deb2e02c6 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -372,6 +372,14 @@ export namespace Components { "value": string; } interface SlMenu { + /** + * Removes focus from the menu. + */ + "removeFocus": () => Promise; + /** + * Passes key presses to the control. Useful for managing the menu when other elements have focus. + */ + "sendKeyEvent": (event: KeyboardEvent) => Promise; /** * Sets focus on the menu. */ diff --git a/src/components/dropdown/dropdown.scss b/src/components/dropdown/dropdown.scss index f28ff4f35..c32a0a323 100644 --- a/src/components/dropdown/dropdown.scss +++ b/src/components/dropdown/dropdown.scss @@ -26,6 +26,6 @@ transition: var(--sl-transition-fast) opacity; ::slotted(sl-menu) { - --max-height: 50vh; + max-height: 50vh; } } diff --git a/src/components/dropdown/dropdown.tsx b/src/components/dropdown/dropdown.tsx index fff381d0b..fc3954d49 100644 --- a/src/components/dropdown/dropdown.tsx +++ b/src/components/dropdown/dropdown.tsx @@ -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; } } diff --git a/src/components/input/input.scss b/src/components/input/input.scss index de4a78d30..9b786ecd4 100644 --- a/src/components/input/input.scss +++ b/src/components/input/input.scss @@ -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); } //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// diff --git a/src/components/menu/menu.scss b/src/components/menu/menu.scss index 154516369..b7d0a65ce 100644 --- a/src/components/menu/menu.scss +++ b/src/components/menu/menu.scss @@ -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; } diff --git a/src/components/menu/menu.tsx b/src/components/menu/menu.tsx index 1cfc21c83..79b27d1fc 100644 --- a/src/components/menu/menu.tsx +++ b/src/components/menu/menu.tsx @@ -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 ( -
(this.menu = el)} - class="sl-menu" + -
+ ); } } diff --git a/src/components/select/select.tsx b/src/components/select/select.tsx index 5ff67728e..99e9f5472 100644 --- a/src/components/select/select.tsx +++ b/src/components/select/select.tsx @@ -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 { - (this.menu = el)} class="sl-select__menu" onSlSelect={this.handleMenuSelect}> + (this.menu = el)} + class="sl-select__menu" + onSlSelect={this.handleMenuSelect} + onKeyDown={this.handleMenuKeyDown} + > diff --git a/src/components/textarea/textarea.scss b/src/components/textarea/textarea.scss index f4cdcc007..2896f2bd6 100644 --- a/src/components/textarea/textarea.scss +++ b/src/components/textarea/textarea.scss @@ -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 ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////