diff --git a/docs/_sidebar.md b/docs/_sidebar.md index 18def2c4..b0bb526d 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -47,6 +47,7 @@ - [Buttons](/components/button) - [Button Group](/components/button-group) - [Checkbox](/components/checkbox) + - [Combobox](/components/combobox) - [Dropdown](/components/dropdown) - [Input](/components/input) - [Icon Button](/components/icon-button) @@ -99,6 +100,7 @@ - [Format Date](/utilities/format-date) - [Format Number](/utilities/format-number) - [Include](/utilities/include) + - [Intersection Observer](/utilities/intersection-observer) - [Mutation Observer](/utilities/mutation-observer) - [Popup](/utilities/popup) - [Relative Time](/utilities/relative-time) diff --git a/docs/components/combobox.md b/docs/components/combobox.md new file mode 100644 index 00000000..145268f8 --- /dev/null +++ b/docs/components/combobox.md @@ -0,0 +1,169 @@ +# Combobox + +[component-header:lynk-combobox] + +A combobox consist of a text input trigger and a popup containing a listbox. By default, interacting with the combobox via focus or input will expose the popup and interacting outside of the combobox will close it. + +```html preview + + + Alabama + Alaska + Arizona + Arkansas + California + Colorado + Connecticut + Delaware + District of Columbia + Florida + Georgia + Hawaii + Idaho + Illinois + Indiana + Iowa + Kansas + Kentucky + Louisiana + Maine + Montana + Nebraska + Nevada + New Hampshire + New Jersey + New Mexico + New York + North Carolina + North Dakota + Ohio + Oklahoma + Oregon + Maryland + Massachusetts + Michigan + Minnesota + Missippi + Missouri + Pennsylvania + Rhode Island + South Carolina + South Dakota + Tennessee + Texas + Utah + Vermont + Virginia + Washington + West Viginia + Wisconsin + Wyoming + +``` + +## Trigger + +By default, the ComboBox's popup is opened when the user types into the input field `trigger="input"`. There are two other supported modes: one where the menu opens when the ComboBox is focused `trigger="focus"` and the other `trigger="manual"` where the listbox can be opened by clicking on the expand icon, using the `open` attribute or by calling the `show()` method. + +```html preview + + Item One + Item Two + Item Three + + +
+ + + Item One + Item Two + Item Three + + +
+ + + Item One + Item Two + Item Three + +``` + + +## Autocomplete + +Determines if the value in the input changes or not as the user navigates with the keyboard. If true, the value changes automatically, if false the value will only change when a selection is made. + +Set this to false when you don't really need the value from the input but want to populate some other state (like the recipient selector in Gmail). But if your input is more like a normal text input, then leave the true default. + +### List Autocomplete + +This example illustrates the autocomplete behavior known as list autocomplete with manual selection. If the user types one or more characters in the combobox and the typed characters match the beginning of the name of one or more options, a listbox popup appears containing the matching names or values. When the listbox appears, a suggested option is not automatically selected. Thus, after typing, if the user tabs or clicks out of the combobox without choosing a value from the listbox, the typed string becomes the value of the combobox. Note that this implementation enables users to input the name or value of an option, but it does not prevent input of any other arbitrary value. + +```html preview + + + Alabama + Alaska + Arizona + Arkansas + California + Colorado + Connecticut + Delaware + District of Columbia + Florida + Georgia + Hawaii + Idaho + Illinois + Indiana + Iowa + Kansas + Kentucky + Louisiana + Maine + Montana + Nebraska + Nevada + New Hampshire + New Jersey + New Mexico + New York + North Carolina + North Dakota + Ohio + Oklahoma + Oregon + Maryland + Massachusetts + Michigan + Minnesota + Missippi + Missouri + Pennsylvania + Rhode Island + South Carolina + South Dakota + Tennessee + Texas + Utah + Vermont + Virginia + Washington + West Viginia + Wisconsin + Wyoming + +``` + +## Allow Custom Values + +By default on blur, a ComboBox will either reset its input value to match the selected option's text or clear its input value if an option has not been selected. If you would like to allow the end user to provide a custom input value to the ComboBox, the `allow-custom` property can be used to override the default behavior. + +## Multiple Selections + +Allow more than one option to be selected by adding the `multiple` attribute. By default, each additional option will be appended to the text input as a `lynk-tag` element. You can change this default behavior and append the options as a csv by using the `separator` attribute set to an string value like 'separator=", "'. + + +[component-metadata:lynk-combobox] diff --git a/docs/components/dropdown.md b/docs/components/dropdown.md index 88d343a6..dcb4f41c 100644 --- a/docs/components/dropdown.md +++ b/docs/components/dropdown.md @@ -84,6 +84,35 @@ Alternatively, you can listen for the `click` event on individual menu items. No ``` +### Using a lynk-input as the trigger + +```html preview + + + +``` + + ### Placement The preferred placement of the dropdown can be set with the `placement` attribute. Note that the actual position may vary to ensure the panel remains in the viewport. diff --git a/docs/components/nav-item.md b/docs/components/nav-item.md index efec14c5..45379cf9 100644 --- a/docs/components/nav-item.md +++ b/docs/components/nav-item.md @@ -7,7 +7,7 @@ Getting Started Usage Contributing - + Components Button Checkbox diff --git a/docs/components/option.md b/docs/components/option.md index 3f7dde56..8d1fbff9 100644 --- a/docs/components/option.md +++ b/docs/components/option.md @@ -21,6 +21,7 @@ Use the `disabled` attribute to disable an option and prevent it from being sele Option 1 Option 2 Option 3 + ``` diff --git a/docs/getting-started/changelog.md b/docs/getting-started/changelog.md index acc88fac..bc99fe5b 100644 --- a/docs/getting-started/changelog.md +++ b/docs/getting-started/changelog.md @@ -6,6 +6,12 @@ Components with the Experimental ba During the beta period, these restrictions may be relaxed in the event of a mission-critical bug. 🐛 +## 0.6.5 + +- 🎉 NEW: Added experimental `` component +- Improved `` so it converts non-string values to strings for convenience +- Improved styles for `` and `` slotted into a `` + ## 0.6.4 - Added `href` property to `` to support router navigation diff --git a/docs/getting-started/contributing.md b/docs/getting-started/contributing.md index 991d13c4..cc3078bb 100644 --- a/docs/getting-started/contributing.md +++ b/docs/getting-started/contributing.md @@ -41,7 +41,7 @@ After the initial build, a browser will open automatically to a local version of To scaffold a new component, run the following command, replacing `lynk-tag-name` with the desired tag name. ```bash -yarn create lynk-tag-name +yarn run create lynk-tag-name ``` This will generate a source file, a stylesheet, and a docs page for you. When you start the dev server, you'll find the new component in the "Components" section of the sidebar. diff --git a/docs/layout/app-layout-sidebar.md b/docs/layout/app-layout-sidebar.md index b9cd05e6..29617b02 100644 --- a/docs/layout/app-layout-sidebar.md +++ b/docs/layout/app-layout-sidebar.md @@ -25,7 +25,28 @@
- + + + + All Video Content + + + Static Graphics + + + Playlists + + + My Library + + Messages + + + + + + + diff --git a/docs/utilities/intersection-observer.md b/docs/utilities/intersection-observer.md new file mode 100644 index 00000000..07b55b39 --- /dev/null +++ b/docs/utilities/intersection-observer.md @@ -0,0 +1,43 @@ +# Intersection Observer + +[component-header:lynk-intersection-observer] + +The intersection observer will report the elements it wraps enters and exits the viewport through the `on:enter` and `on:exit` events. + +```html preview +
+ Scroll down and watch the console... + +
Hello, I'm a div!
+
Hello, I'm another div!
+
+
+ + + + +``` + +[component-metadata:lynk-intersection-observer] \ No newline at end of file diff --git a/docs/utilities/resize-observer.md b/docs/utilities/resize-observer.md index 8c42f108..1d9cbf33 100644 --- a/docs/utilities/resize-observer.md +++ b/docs/utilities/resize-observer.md @@ -15,7 +15,7 @@ The resize observer will report changes to the dimensions of the elements it wra const container = document.querySelector('.resize-observer-overview'); const resizeObserver = container.querySelector('lynk-resize-observer'); - resizeObserver.addEventListener('lynk-resize', event => { + resizeObserver.addEventListener('on:resize', event => { console.log(event.detail); }); diff --git a/src/components/button-group/button-group.ts b/src/components/button-group/button-group.ts index 2802d3f9..1dea75a3 100644 --- a/src/components/button-group/button-group.ts +++ b/src/components/button-group/button-group.ts @@ -25,22 +25,22 @@ export default class LynkButtonGroup extends LynkElement { /** A label to use for the button group's `aria-label` attribute. */ @property() label = ''; - handleFocus(event: CustomEvent) { + handleFocus(event: Event) { const button = findButton(event.target as HTMLElement); button?.classList.add('lynk-button-group__button--focus'); } - handleBlur(event: CustomEvent) { + handleBlur(event: Event) { const button = findButton(event.target as HTMLElement); button?.classList.remove('lynk-button-group__button--focus'); } - handleMouseOver(event: CustomEvent) { + handleMouseOver(event: Event) { const button = findButton(event.target as HTMLElement); button?.classList.add('lynk-button-group__button--hover'); } - handleMouseOut(event: CustomEvent) { + handleMouseOut(event: Event) { const button = findButton(event.target as HTMLElement); button?.classList.remove('lynk-button-group__button--hover'); } diff --git a/src/components/button/button.ts b/src/components/button/button.ts index 4314af0e..9f1b4360 100644 --- a/src/components/button/button.ts +++ b/src/components/button/button.ts @@ -281,6 +281,11 @@ export default class LynkButton extends LynkElement implements LynkFormControl { return true; } + /** Gets the associated form, if one exists. */ + getForm(): HTMLFormElement | null { + return this.formControlController.getForm(); + } + /** Checks for validity and shows the browser's validation message if the control is invalid. */ reportValidity() { if (this.isButton()) { diff --git a/src/components/checkbox/checkbox.ts b/src/components/checkbox/checkbox.ts index d4914e22..aaa4fc21 100644 --- a/src/components/checkbox/checkbox.ts +++ b/src/components/checkbox/checkbox.ts @@ -199,6 +199,11 @@ export default class LynkCheckbox extends LynkElement implements LynkFormControl return this.input.checkValidity(); } + /** Gets the associated form, if one exists. */ + getForm(): HTMLFormElement | null { + return this.formControlController.getForm(); + } + /** Checks for validity and shows a validation message if the control is invalid. */ reportValidity() { return this.input.reportValidity(); diff --git a/src/components/combobox/combobox-alt.styles.ts b/src/components/combobox/combobox-alt.styles.ts new file mode 100644 index 00000000..db310134 --- /dev/null +++ b/src/components/combobox/combobox-alt.styles.ts @@ -0,0 +1,34 @@ +import { css } from 'lit'; +import componentStyles from '../../styles/component.styles'; +import formControlStyles from '../../styles/form-control.styles'; + +export default css` + [hidden] { + display: none; + } + + :host([loading]) lynk-input lynk-icon { + animation-name: spin; + animation-duration: 500ms; + animation-iteration-count: infinite; + animation-timing-function: linear; + } + + @keyframes spin { + from { + transform:rotate(0deg); + } + to { + transform:rotate(360deg); + } + } + + lynk-dropdown { + display: block; + } + + lynk-input lynk-icon[slot=suffix] { + padding-right: 0; + margin-right: var(--lynk-input-spacing-medium); + } +`; diff --git a/src/components/combobox/combobox-alt.ts b/src/components/combobox/combobox-alt.ts new file mode 100644 index 00000000..e086c977 --- /dev/null +++ b/src/components/combobox/combobox-alt.ts @@ -0,0 +1,227 @@ +import '../dropdown/dropdown'; +import '../input/input'; +import '../menu/menu'; +import '../menu-item/menu-item'; +import { classMap } from 'lit/directives/class-map.js'; +import { customElement, property, query } from 'lit/decorators.js'; +import { HasSlotController } from '../../internal/slot'; +import { html } from 'lit'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { LocalizeController } from '../../utilities/localize'; +import { waitForEvent } from '../../internal/event'; +import { watch } from '../../internal/watch'; +import LynkElement from '../../internal/lynk-element'; +import styles from './combobox.styles'; +import type { CSSResultGroup } from 'lit'; +import type LynkDropdown from '../dropdown/dropdown'; +import type LynkInput from '../input/input'; +import type LynkMenu from '../menu/menu'; +import type LynkMenuItem from '../menu-item/menu-item'; + +/** + * @summary A combobox is a text input with an associated popup that enables users to select a value from a collection of possible values or optionally enter a custom value. + * @documentation https:/lynk.design/components/combobox + * @since 1.0 + * @status experimental + * + * @dependency lynk-input + * @dependency lynk-dropdown + * @dependency lynk-menu + * @dependency lynk-menu-item + * + * @slot - The combobox's content. + * @slot trigger - The combobox's trigger, usually a `` element. + * + * @event on:show - Emitted when the combobox opens. + * @event after:show - Emitted after the combobox opens and all animations are complete. + * @event on:hide - Emitted when the combobox closes. + * @event after:hide - Emitted after the combobox closes and all animations are complete. + * + * @csspart base - The component's internal wrapper. + * @csspart trigger - The container that wraps the trigger. + * @csspart panel - The panel that gets shown when the combobox is open. + * + * @animation combobox.show - The animation to use when showing the combobox. + * @animation combobox.hide - The animation to use when hiding the combobox. + */ +@customElement('lynk-combobox') +export default class LynkCombobox extends LynkElement { + static styles: CSSResultGroup = styles; + + private readonly hasSlotController = new HasSlotController(this, 'help-text', 'help-tip', 'label'); + + @query('lynk-dropdown') _dropdown: LynkDropdown; + @query('lynk-input') _input: LynkDropdown; + + @property({ type: String }) + public selected?: string; + + @property({ type: String }) + public value = ''; + + /** The combobox input's validity state when using manual validation or set automatically to `error` when field uses Contraint Validation */ + @property({ reflect: true }) + public state: 'error' | 'warning' | 'success' | 'default' = 'default'; + + /** The combobox input's size. */ + @property({ reflect: true }) + public size: 'small' | 'medium' | 'large' = 'medium'; + + /** The input's label. Alternatively, you can use the label slot. */ + @property({ type: String }) + public label = ''; + + /** + * Specifies what permission the browser has to provide assistance in filling out form field values. Refer to + * [this page on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete) for available values. + */ + @property() autocomplete: string; + + /** The help text below the input. Alternatively, you can use the help-text slot. */ + @property({ attribute: 'help-text', type: String }) + public helpText = ''; + + /** The help tooltip appended to the label. Alternatively, you can use the help-tip slot. */ + @property({ attribute: 'help-tip', type: String }) + public helpTip = ''; + + /** The help tooltip appended to the label. Alternatively, you can use the help-tip slot. */ + @property({ attribute: 'placeholder', type: String }) + public placeholder = ''; + + @property({ type: Boolean }) + public caret = false; + + /** Adds a clear button when the input is populated. */ + @property({ type: Boolean }) + public clearable = false; + + @property({ type: Boolean }) + public empty = true; + + @property({ type: Boolean }) + public expanded = false; + + @property({ type: Boolean }) + public hoist = false; + + @property({ type: Boolean }) + public disabled = false; + + @property({ type: Number, attribute: 'debounce-timeout' }) + public debounceTimeout = 350; + + @property({ type: Boolean, reflect: true }) + public loading?: boolean; + + protected updated(_changedProperties: PropertyValues) { + if (_changedProperties.has('loading')) { + if (this.loading) { + this._dropdown.hide(); + } else { + this._dropdown.show(); + } + } + } + + updateEmpty(e: Event) { + const slot = e.target as HTMLSlotElement; + this.empty = slot.assignedElements().length === 0; + } + + dispatchSearch() { + this.dispatchEvent(new CustomEvent('search', { + detail: { + value: this._input.value, + }, + })) + } + + dispatchItemSelected(e: CustomEvent) { + this.value = e.detail.item.getTextLabel() + this.selected = e.detail.item.value + this.dispatchEvent(new CustomEvent('itemSelected', { + detail: { + value: e.detail.item.value, + }, + })) + } + + handleTriggerKeyDown(e: KeyboardEvent) { + if (e.key === ' ') { + e.stopPropagation() + } + } + + handleMenuKeyDown(e: KeyboardEvent) { + if (['ArrowRight', 'ArrowLeft'].includes(event.key)) { + this._input.focus(); + } + + // All other "printable" keys trigger type to select + if (event.key.length === 1 || event.key === 'Backspace') { + + // Don't block important key combos like CMD+R + if (event.metaKey || event.ctrlKey || event.altKey) { + return; + } + + this._input.focus(); + } + } + + render() { + return html` + + + + + + ${this.caret + ? html` + + ` + : ''} + + + + + + + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'lynk-combobox': LynkDropdown; + } +} + diff --git a/src/components/combobox/combobox.styles.ts b/src/components/combobox/combobox.styles.ts new file mode 100644 index 00000000..5790a6f3 --- /dev/null +++ b/src/components/combobox/combobox.styles.ts @@ -0,0 +1,406 @@ +import { css } from 'lit'; +import componentStyles from '../../styles/component.styles'; +import formControlStyles from '../../styles/form-control.styles'; + +export default css` + ${componentStyles} + ${formControlStyles} + + :host { + display: block; + } + + /** The popup */ + .lynk-combobox { + flex: 1 1 auto; + display: inline-flex; + width: 100%; + position: relative; + vertical-align: middle; + } + + .lynk-combobox::part(popup) { + z-index: var(--lynk-z-index-dropdown); + } + + .lynk-combobox[data-current-placement^='top']::part(popup) { + transform-origin: bottom; + } + + .lynk-combobox[data-current-placement^='bottom']::part(popup) { + transform-origin: top; + } + + /* Combobox */ + .lynk-combobox__control { + flex: 1; + display: flex; + width: 100%; + min-width: 0; + position: relative; + align-items: center; + justify-content: start; + font-family: var(--lynk-input-font-family); + font-weight: var(--lynk-input-font-weight); + letter-spacing: var(--lynk-input-letter-spacing); + vertical-align: middle; + overflow: hidden; + cursor: pointer; + transition: var(--lynk-transition-fast) color, var(--lynk-transition-fast) border, + var(--lynk-transition-fast) box-shadow, var(--lynk-transition-fast) background-color; + } + + .lynk-combobox__display-input { + position: relative; + width: 100%; + font: inherit; + border: none; + background: none; + color: var(--lynk-input-color); + cursor: inherit; + overflow: hidden; + padding: 0; + margin: 0; + -webkit-appearance: none; + } + + .lynk-combobox:not(.lynk-combobox--disabled):hover .lynk-combobox__display-input { + color: var(--lynk-input-color-hover); + } + + .lynk-combobox__display-input:focus { + outline: none; + } + + /* Visually hide the display input when multiple is enabled */ + .lynk-combobox--multiple:not(.lynk-combobox--placeholder-visible) .lynk-combobox__display-input { + position: absolute; + z-index: -1; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0; + } + + .lynk-combobox__value-input { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + padding: 0; + margin: 0; + opacity: 0; + z-index: -1; + } + + .lynk-combobox__tags { + display: flex; + flex: 1; + align-items: center; + flex-wrap: wrap; + margin-inline-start: var(--lynk-spacing-2x-small); + } + + .lynk-combobox__tags::slotted(lynk-tag) { + cursor: pointer !important; + } + + .lynk-combobox--disabled .lynk-combobox__tags, + .lynk-combobox--disabled .lynk-combobox__tags::slotted(lynk-tag) { + cursor: not-allowed !important; + } + + /* Standard selects */ + .lynk-combobox--standard .lynk-combobox__control { + background-color: var(--lynk-input-background-color); + border: solid var(--lynk-input-border-width) var(--lynk-input-border-color); + } + + .lynk-combobox--standard.lynk-combobox--disabled .lynk-combobox__control { + background-color: var(--lynk-input-background-color-disabled); + border-color: var(--lynk-input-border-color-disabled); + color: var(--lynk-input-color-disabled); + opacity: 0.75; + cursor: not-allowed; + outline: none; + } + + .lynk-combobox--standard:not(.lynk-combobox--disabled).lynk-combobox--open .lynk-combobox__control, + .lynk-combobox--standard:not(.lynk-combobox--disabled).lynk-combobox--focused .lynk-combobox__control { + background-color: var(--lynk-input-background-color-focus); + border-color: var(--lynk-input-border-color-focus); + box-shadow: 0 0 0 var(--lynk-focus-ring-width) var(--lynk-input-focus-ring-color); + } + + /* Filled selects */ + .lynk-combobox--filled .lynk-combobox__control { + border: none; + background-color: var(--lynk-input-filled-background-color); + color: var(--lynk-input-color); + } + + .lynk-combobox--filled:hover:not(.lynk-combobox--disabled) .lynk-combobox__control { + background-color: var(--lynk-input-filled-background-color-hover); + } + + .lynk-combobox--filled.lynk-combobox--disabled .lynk-combobox__control { + background-color: var(--lynk-input-filled-background-color-disabled); + opacity: 0.5; + cursor: not-allowed; + } + + .lynk-combobox--filled:not(.lynk-combobox--disabled).lynk-combobox--open .lynk-combobox__control, + .lynk-combobox--filled:not(.lynk-combobox--disabled).lynk-combobox--focused .lynk-combobox__control { + background-color: var(--lynk-input-filled-background-color-focus); + outline: var(--lynk-focus-ring); + } + + /* Sizes */ + .lynk-combobox--small .lynk-combobox__control { + border-radius: var(--lynk-input-border-radius-small); + font-size: var(--lynk-input-font-size-small); + min-height: var(--lynk-input-height-small); + padding-block: 0; + padding-inline: var(--lynk-input-spacing-small); + } + + .lynk-combobox--small .lynk-combobox__clear-btn, + .lynk-combobox--small .lynk-combobox__expand-btn, { + margin-inline-start: var(--lynk-input-spacing-small); + } + + .lynk-combobox--small .lynk-combobox__prefix::slotted(*) { + margin-inline-end: var(--lynk-input-spacing-small); + } + + .lynk-combobox--small.lynk-combobox--multiple:not(.lynk-combobox--placeholder-visible) .lynk-combobox__control { + padding-block: 2px; + padding-inline-start: 0; + } + + .lynk-combobox--small .lynk-combobox__tags { + gap: 2px; + } + + .lynk-combobox--medium .lynk-combobox__control { + border-radius: var(--lynk-input-border-radius-medium); + font-size: var(--lynk-input-font-size-medium); + min-height: var(--lynk-input-height-medium); + padding-block: 0; + padding-inline: var(--lynk-input-spacing-medium); + } + + .lynk-combobox--medium .lynk-combobox__clear-btn, + .lynk-combobox--medium .lynk-combobox__expand-btn { + margin-inline-start: var(--lynk-input-spacing-medium); + } + + .lynk-combobox--medium .lynk-combobox__prefix::slotted(*) { + margin-inline-end: var(--lynk-input-spacing-medium); + } + + .lynk-combobox--medium.lynk-combobox--multiple:not(.lynk-combobox--placeholder-visible) .lynk-combobox__control { + padding-inline-start: 0; + padding-block: 3px; + } + + .lynk-combobox--medium .lynk-combobox__tags { + gap: 3px; + } + + .lynk-combobox--large .lynk-combobox__control { + border-radius: var(--lynk-input-border-radius-large); + font-size: var(--lynk-input-font-size-large); + min-height: var(--lynk-input-height-large); + padding-block: 0; + padding-inline: var(--lynk-input-spacing-large); + } + + .lynk-combobox--large .lynk-combobox__clear-btn, + .lynk-combobox--large .lynk-combobox__expand-btn, { + margin-inline-start: var(--lynk-input-spacing-large); + } + + .lynk-combobox--large .lynk-combobox__prefix::slotted(*) { + margin-inline-end: var(--lynk-input-spacing-large); + } + + .lynk-combobox--large.lynk-combobox--multiple:not(.lynk-combobox--placeholder-visible) .lynk-combobox__control { + padding-inline-start: 0; + padding-block: 4px; + } + + .lynk-combobox--large .lynk-combobox__tags { + gap: 4px; + } + + /* Pills */ + .lynk-combobox--pill.lynk-combobox--small .lynk-combobox__control { + border-radius: var(--lynk-input-height-small); + } + + .lynk-combobox--pill.lynk-combobox--medium .lynk-combobox__control { + border-radius: var(--lynk-input-height-medium); + } + + .lynk-combobox--pill.lynk-combobox--large .lynk-combobox__control { + border-radius: var(--lynk-input-height-large); + } + + /* + * Error & Warning States + */ + + .lynk-combobox--has-error .lynk-combobox__control, + .lynk-combobox--has-error:hover:not(.lynk-combobox--disabled) .lynk-combobox__control { + border-color: var(--lynk-color-danger-500); + } + + .lynk-combobox--has-error:not(.lynk-combobox--disabled).lynk-combobox--open .lynk-combobox__control, + .lynk-combobox--has-error:not(.lynk-combobox--disabled).lynk-combobox--focused .lynk-combobox__control { + border-color: var(--lynk-color-danger-500); + box-shadow: 0 0 2px var(--lynk-focus-ring-width) var(--lynk-color-danger-a50); + } + + .lynk-combobox--has-warning .lynk-combobox__control, + .lynk-combobox--has-warning:hover:not(.lynk-combobox--disabled) .lynk-combobox__control { + border-color: var(--lynk-color-warning-500); + } + + .lynk-combobox--has-warning:not(.lynk-combobox--disabled).lynk-combobox--open .lynk-combobox__control, + .lynk-combobox--has-warning:not(.lynk-combobox--disabled).lynk-combobox--focused .lynk-combobox__control { + border-color: var(--lynk-color-warning-500); + box-shadow: 0 0 2px var(--lynk-focus-ring-width) var(--lynk-color-warning-a50); + } + + .lynk-combobox--has-success .lynk-combobox__control, + .lynk-combobox--has-success:hover:not(.lynk-combobox--disabled) .lynk-combobox__control { + border-color: var(--lynk-color-success-500); + } + + .lynk-combobox--has-success:not(.lynk-combobox--disabled).lynk-combobox--open .lynk-combobox__control, + .lynk-combobox--has-success:not(.lynk-combobox--disabled).lynk-combobox--focused .lynk-combobox__control { + border-color: var(--lynk-color-success-500); + box-shadow: 0 0 2px var(--lynk-focus-ring-width) var(--lynk-color-success-a50); + } + + /* Prefix */ + .lynk-combobox__prefix { + flex: 0; + display: inline-flex; + align-items: center; + color: var(--lynk-input-placeholder-color); + } + + /* Clear/Expand button */ + .lynk-combobox__clear-btn, + .lynk-combobox__expand-btn { + display: inline-flex; + align-items: center; + justify-content: center; + font-size: inherit; + color: var(--lynk-input-icon-color); + border: none; + background: none; + padding: 0; + transition: var(--lynk-transition-fast) color; + cursor: pointer; + } + + .lynk-combobox__clear-btn:hover, + .lynk-combobox__expand-btn:hover { + color: var(--lynk-input-icon-color-hover); + } + + .lynk-combobox__clear-btn:focus, + .lynk-combobox__expand-btn:focus { + outline: none; + } + + /* Expand icon */ + .lynk-combobox__expand-icon { + flex: 0 0 auto; + display: flex; + align-items: center; + transition: var(--lynk-transition-medium) rotate ease; + rotate: 0; + margin-inline-start: var(--lynk-spacing-small); + } + + .lynk-combobox--open .lynk-combobox__expand-icon { + rotate: -180deg; + } + + /* Listbox */ + .lynk-combobox__listbox { + display: block; + position: relative; + font-family: var(--lynk-font-sans); + font-size: var(--lynk-font-size-medium); + font-weight: var(--lynk-font-weight-normal); + box-shadow: var(--lynk-shadow-large); + background: var(--lynk-panel-background-color); + border: solid var(--lynk-panel-border-width) var(--lynk-panel-border-color); + border-radius: var(--lynk-border-radius-medium); + padding-block: var(--lynk-spacing-x-small); + padding-inline: 0; + overflow: auto; + overscroll-behavior: none; + + /* Make sure it adheres to the popup's auto size */ + max-width: var(--auto-size-available-width); + max-height: var(--auto-size-available-height); + } + + .lynk-combobox__listbox::slotted(lynk-divider) { + --spacing: var(--lynk-spacing-x-small); + } + + .lynk-combobox__listbox::slotted(small) { + font-size: var(--lynk-font-size-small); + font-weight: var(--lynk-font-weight-semibold); + color: var(--lynk-color-neutral-500); + padding-block: var(--lynk-spacing-x-small); + padding-inline: var(--lynk-spacing-x-large); + } + + .lynk-combobox__listbox-empty { + display: block; + font-family: var(--lynk-font-sans); + font-size: var(--lynk-font-size-medium); + color: var(--lynk-color-neutral-500); + padding: var(--lynk-spacing-x-small) var(--lynk-spacing-medium); + } + + /* + * Restricted + */ + .lynk-combobox--restricted { + opacity: 1 !important; + } + + .lynk-combobox--restricted .lynk-combobox__control { + border-color: transparent; + cursor: initial; + gap: 0; + } + + .lynk-combobox--restricted .lynk-combobox__control, + .lynk-combobox--restricted .lynk-combobox__tags { + padding: 0; + margin-inline-start: 0; + user-select: text; + } + + .lynk-combobox--restricted .lynk-combobox__clear-btn, + .lynk-combobox--restricted .lynk-combobox__expand-btn, { + display: none; + } + + .lynk-combobox--restricted lynk-tag::part(base) { + cursor: text; + user-select: text; + } +`; diff --git a/src/components/combobox/combobox.test.ts b/src/components/combobox/combobox.test.ts new file mode 100644 index 00000000..ea98fe5f --- /dev/null +++ b/src/components/combobox/combobox.test.ts @@ -0,0 +1,9 @@ +import { expect, fixture, html } from '@open-wc/testing'; + +describe('', () => { + it('should render a component', async () => { + const el = await fixture(html` `); + + expect(el).to.exist; + }); +}); diff --git a/src/components/combobox/combobox.ts b/src/components/combobox/combobox.ts new file mode 100644 index 00000000..673e667d --- /dev/null +++ b/src/components/combobox/combobox.ts @@ -0,0 +1,1096 @@ +import '../icon/icon'; +import '../popup/popup'; +import { animateTo, stopAnimations } from '../../internal/animate'; +import { classMap } from 'lit/directives/class-map.js'; +import { customElement, property, query, state } from 'lit/decorators.js'; +import { defaultValue } from '../../internal/default-value'; +import { FormControlController } from '../../internal/form'; +import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry'; +import { HasSlotController } from '../../internal/slot'; +import { html } from 'lit'; +import { LocalizeController } from '../../utilities/localize'; +import { scrollIntoView } from 'src/internal/scroll'; +import { waitForEvent } from '../../internal/event'; +import { watch } from '../../internal/watch'; +import LynkElement from '../../internal/lynk-element'; +import styles from './combobox.styles'; +import type { CSSResultGroup } from 'lit'; +import type { LynkFormControl } from '../../internal/lynk-element'; +import type LynkOption from '../option/option'; +import type LynkPopup from '../popup/popup'; +import type OnRemoveEvent from '../../events/on-remove'; + +/** + * @summary A combobox is a text input with an associated popup that enables users to select a value from a collection of possible values. + * @documentation https:/lynk.design/components/combobox + * @since 1.0 + * @status experimental + * + * @dependency lynk-icon + * @dependency lynk-popup + * + * @slot - The listbox options. Must be `` elements. You can use `` to group items visually. + * @slot label - The input's label. Alternatively, you can use the `label` attribute. + * @slot prefix - Used to prepend a presentational icon or similar element to the combobox. + * @slot clear-icon - An icon to use in lieu of the default clear icon. + * @slot expand-icon - The icon to show when the control is expanded and collapsed. Rotates on open and close. + * @slot help-text - Text that describes how to use the input. Alternatively, you can use the `help-text` attribute. + * + * @event on:change - Emitted when the combobox's value changes. + * @event on:clear - Emitted when the combobox's value is cleared. + * @event on:input - Emitted when the combobox receives input. + * @event on:focus - Emitted when the combobox gains focus. + * @event on:blur - Emitted when the combobox loses focus. + * @event on:show - Emitted when the combobox's menu opens. + * @event after:show - Emitted after the combobox's menu opens and all animations are complete. + * @event on:hide - Emitted when the combobox's menu closes. + * @event after:hide - Emitted after the combobox's menu closes and all animations are complete. + * @event on:invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied. + */ +@customElement('lynk-combobox') +export default class LynkCombobox extends LynkElement implements LynkFormControl { + static styles: CSSResultGroup = styles; + + private readonly formControlController = new FormControlController(this, { + assumeInteractionOn: ['on:blur', 'on:input'] + }); + private readonly hasSlotController = new HasSlotController(this, 'help-text', 'help-tip', 'label'); + private readonly localize = new LocalizeController(this); + private typeToSelectString = ''; + private typeToSelectTimeout: number; + + @query('.lynk-combobox') popup: LynkPopup; + @query('.lynk-combobox__control') combobox: HTMLSlotElement; + @query('.lynk-combobox__display-input') displayInput: HTMLInputElement; + @query('.lynk-combobox__value-input') valueInput: HTMLInputElement; + @query('.lynk-combobox__listbox') listbox: HTMLSlotElement; + + + @state() private hasFocus = false; + @state() private comboboxHasFocus = false; + @state() private listboxHasFocus = false; + + @state() displayValue = ''; + @state() currentOption: LynkOption; + @state() selectedOptions: LynkOption[] = []; + @state() filteredOptions: LynkOption[] = []; + + /** The name of the combobox, submitted as a name/value pair with form data. */ + @property() name = ''; + + /** + * The current value of the combobox, submitted as a name/value pair with form data. When `multiple` is enabled, the + * value will be a space-delimited list of values based on the options selected. + */ + @property({ + converter: { + fromAttribute: (value: string) => value.split(' '), + toAttribute: (value: string[]) => value.join(' ') + } + }) + value: string | string[] = ''; + + /** The default value of the form control. Primarily used for resetting the form control. */ + @defaultValue() defaultValue: string | string[] = ''; + + /** The combobox's size. */ + @property() size: 'small' | 'medium' | 'large' = 'medium'; + + /** Placeholder text to show as a hint when the combobox is empty. */ + @property() placeholder = ''; + + /** Allow custom values to be entered */ + @property({ attribute: 'autocomplete', reflect: true }) autocomplete: 'none' | 'list' | 'inline' | 'both' = 'none'; + + /** Allow custom values to be entered */ + @property({ attribute: 'allow-custom-value', type: Boolean, reflect: true }) allowCustomValue = false; + + /** Determine when the listbox popup is triggered */ + @property({ attribute: 'trigger', reflect: true }) trigger: 'input' | 'focus' | 'manual' = 'input'; + + /** Allows more than one option to be selected. */ + @property({ type: Boolean, reflect: true }) multiple = false; + + /** + * The maximum number of selected options to show when `multiple` is true. After the maximum, "+n" will be shown to + * indicate the number of additional items that are selected. Set to 0 to remove the limit. + */ + @property({ attribute: 'max-options-visible', type: Number }) maxOptionsVisible = 3; + + /** Disables the combobox control. */ + @property({ type: Boolean, reflect: true }) disabled = false; + + /** Adds a clear button when the combobox is not empty. */ + @property({ type: Boolean }) clearable = false; + + /** + * Indicates whether or not the combobox is open. You can toggle this attribute to show and hide the menu, or you can + * use the `show()` and `hide()` methods and this attribute will reflect the combobox's open state. + */ + @property({ type: Boolean, reflect: true }) open = false; + + /** + * Enable this option to prevent the listbox from being clipped when the component is placed inside a container with + * `overflow: auto|scroll`. Hoisting uses a fixed positioning strategy that works in many, but not all, scenarios. + */ + @property({ type: Boolean }) hoist = false; + + /** Draws a filled combobox. */ + @property({ type: Boolean, reflect: true }) filled = false; + + /** Draws a pill-style combobox with rounded edges. */ + @property({ type: Boolean, reflect: true }) pill = false; + + /** The combobox's label. If you need to display HTML, use the `label` slot instead. */ + @property() label = ''; + + /** + * The preferred placement of the combobox's menu. Note that the actual placement may vary as needed to keep the listbox + * inside of the viewport. + */ + @property({ reflect: true }) placement: 'top' | 'bottom' = 'bottom'; + + /** The combobox's help text. If you need to display HTML, use the `help-text` slot instead. */ + @property({ attribute: 'help-text' }) helpText = ''; + + /** The combobox's help text. If you need to display HTML, use the `help-text` slot instead. */ + @property({ attribute: 'help-tip' }) helpTip = ''; + + /** + * By default, form controls are associated with the nearest containing `
` element. This attribute allows you + * to place the form control outside of a form and associate it with the form that has this `id`. The form must be in + * the same document or shadow root for this to work. + */ + @property({ reflect: true }) form = ''; + + /** The combobox's required attribute. */ + @property({ type: Boolean, reflect: true }) required = false; + + /** Replaces the combobox with a plain text string of the selected value. */ + @property({ type: Boolean, reflect: true }) restricted = false; + + /** The combobox's feedback status using manual validation. Alternatively, you can use the invalid attribute */ + @property({ reflect: true }) state: 'error' | 'warning' | 'success' | 'default' = 'default'; + + /** Gets the validity state object */ + get validity() { + return this.valueInput.validity; + } + + /** Gets the validation message */ + get validationMessage() { + return this.valueInput.validationMessage; + } + + connectedCallback() { + super.connectedCallback(); + this.handleDocumentFocusIn = this.handleDocumentFocusIn.bind(this); + this.handleDocumentKeyDown = this.handleDocumentKeyDown.bind(this); + this.handleComboboxKeyDown = this.handleComboboxKeyDown.bind(this); + this.handleComboboxKeyUp = this.handleComboboxKeyUp.bind(this); + this.handleListboxKeyDown = this.handleListboxKeyDown.bind(this); + this.handleDocumentMouseDown = this.handleDocumentMouseDown.bind(this); + + // Because this is a form control, it shouldn't be opened initially + this.open = false; + } + + firstUpdated() { + this.filteredOptions = this.getAllOptions(); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.hide(); + } + + private addComboboxListeners() { + document.addEventListener('focusin', this.handleDocumentFocusIn); + document.addEventListener('keydown', this.handleDocumentKeyDown); + document.addEventListener('mousedown', this.handleDocumentMouseDown); + } + + private removeComboboxListeners() { + document.removeEventListener('focusin', this.handleDocumentFocusIn); + document.removeEventListener('keydown', this.handleDocumentKeyDown); + document.removeEventListener('mousedown', this.handleDocumentMouseDown); + } + + private addListboxListeners() { + document.addEventListener('keydown', this.handleListboxKeyDown); + } + + private removeListboxListeners() { + document.removeEventListener('keydown', this.handleListboxKeyDown); + } + + private handleFocusIn() { + this.hasFocus = true; + this.emit('on:focus'); + } + + private handleFocusOut() { + this.hasFocus = false; + this.hide(); + this.removeComboboxListeners(); + this.emit('on:blur'); + } + + private handleComboboxFocus() { + this.comboboxHasFocus = true; + this.listboxHasFocus = false; + + // Set the cursor at the end of the display input value + const len = this.displayValue.length || 0; + this.displayInput.setSelectionRange(len, len); + + if (this.trigger === 'focus') { + this.open = true; + } + + this.handleFocusIn(); + } + + private handleComboboxBlur() { + this.comboboxHasFocus = false; + } + + private handleDocumentFocusIn(event: KeyboardEvent) { + // Close when focusing out of the combobox + const path = event.composedPath(); + if (this && !path.includes(this)) { + this.handleFocusOut(); + } + } + + private handleDocumentMouseDown(event: MouseEvent) { + // Close when clicking outside of the combobox + const path = event.composedPath(); + if (this && !path.includes(this)) { + this.handleFocusOut(); + } + } + + private handleDocumentKeyDown(event: KeyboardEvent) { + const target = event.target as HTMLElement; + const isClearButton = target.closest('.lynk-combobox__clear') !== null; + const isIconButton = target.closest('lynk-icon-button') !== null; + + // Ignore presses when the target is an icon button (e.g. the remove button in ) + if (isClearButton || isIconButton) { + return; + } + + if (event.key.length === 1 && this.trigger === 'input' && !this.open) { + this.show(); + } + + // Handle enter and space. When pressing space, we allow for type to select behaviors so if there's anything in the + // buffer we _don't_ close it. + if (event.key === 'Enter') { + event.preventDefault(); + event.stopImmediatePropagation(); + + // If it's not open, open it + if (!this.open) { + this.show(); + return; + } + + if (this.allowCustomValue && this.displayValue) { + this.value = this.displayValue; + } + + // If it is open, update the value based on the current selection and close it + if (this.currentOption && !this.currentOption.disabled) { + if (this.multiple) { + this.toggleOptionSelection(this.currentOption); + } else { + this.setSelectedOptions(this.currentOption); + } + + // Emit after updating + this.updateComplete.then(() => { + this.emit('on:input'); + this.emit('on:change'); + }); + + if (!this.multiple) { + this.hide(); + this.displayInput.focus({ preventScroll: true }); + } + } + + return + } + + // Navigate options + if (['ArrowUp', 'ArrowDown'].includes(event.key) && this.filteredOptions.length > 0) { + const allOptions = this.getAllOptions(); + const currentIndex = allOptions.indexOf(this.currentOption); + let newOption; + + // Prevent scrolling + event.preventDefault(); + + // Open it + if (!this.open) { + this.show(); + + // If an option is already selected, stop here because we want that one to remain highlighted when the listbox + // opens for the first time + if (this.currentOption) { + return; + } + } + + if (event.key === 'ArrowDown') { + newOption = this.getNextSelectableOption(currentIndex); + } else if (event.key === 'ArrowUp') { + newOption = this.getPreviousSelectableOption(currentIndex); + } + + this.listboxHasFocus = true; + this.setCurrentOption(newOption); + } + + // Close when pressing escape + if (event.key === 'Escape' && this.open) { + if (this.open) { + event.preventDefault(); + event.stopPropagation(); + this.hide(); + this.displayInput.focus({ preventScroll: true }); + } else { + this.displayValue = this.defaultValue; + this.setSelectedOptions([]); + } + } + } + + private handleComboboxKeyDown(event: KeyboardEvent) { + event.stopPropagation(); + + if (['Home', 'End'].includes(event.key) && this.comboboxHasFocus) { + // Prevent scrolling + event.preventDefault(); + + // Set the cursor at the start or end of the display input value + if (event.key === 'Home') { + this.displayInput.setSelectionRange(0, 0); + } else if (event.key === 'End') { + const len = this.displayValue.length || 0; + this.displayInput.setSelectionRange(len, len); + } + } + + this.handleDocumentKeyDown(event); + } + + private handleComboboxKeyUp(event: KeyboardEvent) { + // Printable keys trigger autocomplete + if (event.key.length === 1 || event.key === 'Backspace') { + if (this.autocomplete === 'list') { + this.filterOptions(); + } + } + } + + private filterOptions() { + const allOptions = this.getAllOptions(); + this.filteredOptions = []; + + for (const option of allOptions) { + const label = option.getTextLabel().toLowerCase(); + const value = this.displayValue.toLowerCase(); + + if (label.includes(value)) { + option.hidden = false; + this.filteredOptions.push(option); + } else { + option.hidden = true; + } + } + + this.setCurrentOption(this.getFirstSelectableOption()); + } + + private handleListboxKeyDown(event: KeyboardEvent) { + if (!this.hasFocus && !this.listboxHasFocus) { + return; + } + + if (['ArrowRight', 'ArrowLeft'].includes(event.key)) { + const len = this.displayValue.length; + this.displayInput.focus({ preventScroll: true }); + this.displayInput.setSelectionRange(len, len); + } + + // Navigate options + if (['Home', 'End'].includes(event.key) && this.listboxHasFocus && this.filteredOptions.length > 0) { + let newOption; + // Prevent scrolling + event.preventDefault(); + + if (event.key === 'Home') { + console.log("hello") + newOption = this.getFirstSelectableOption(); + // newIndex = 0; + } else if (event.key === 'End') { + newOption = this.getPreviousSelectableOption(0); + // newIndex = allOptions.length - 1; + } + + this.setCurrentOption(newOption); + } + + // All other "printable" keys trigger default text input + if (event.key.length === 1 || event.key === 'Backspace') { + this.displayInput.focus({ preventScroll: true }); + event.stopPropagation(); + } + } + + private handleComboboxMouseDown(event: MouseEvent) { + const path = event.composedPath(); + const isIconButton = path.some(el => el instanceof Element && el.tagName.toLowerCase() === 'lynk-icon-button'); + + // Ignore disabled controls and clicks on tags (remove buttons) + if (this.disabled || isIconButton) { + return; + } + + event.preventDefault(); + this.displayInput.focus({ preventScroll: true }); + } + + private handleLabelClick() { + this.displayInput.focus(); + } + + private handleClearClick(event: MouseEvent) { + event.stopPropagation(); + + this.displayValue = this.displayInput.value = ''; + + if (this.autocomplete === 'list') { + this.filterOptions(); + } + + if (this.value !== '') { + this.setSelectedOptions([]); + this.displayInput.focus({ preventScroll: true }); + + + + // Emit after update + this.updateComplete.then(() => { + this.emit('on:clear'); + this.emit('on:input'); + this.emit('on:change'); + }); + } + } + + private handleExpandClick(event: MouseEvent) { + event.stopPropagation(); + this.open = !this.open; + } + + private handleClearExpandMouseDown(event: MouseEvent) { + // Don't lose focus or propagate events when clicking the clear button + event.stopPropagation(); + event.preventDefault(); + } + + private handleInput() { + this.displayValue = this.displayInput.value; + } + + private handleChange() { + this.displayValue = this.displayInput.value; + } + + private handleOptionClick(event: MouseEvent) { + const target = event.target as HTMLElement; + const option = target.closest('lynk-option'); + const oldValue = this.value; + + if (option && !option.disabled) { + if (this.multiple) { + this.toggleOptionSelection(option); + } else { + this.setSelectedOptions(option); + } + + // Set focus after updating so the value is announced by screen readers + this.updateComplete.then(() => this.displayInput.focus({ preventScroll: true })); + + if (this.value !== oldValue) { + // Emit after updating + this.updateComplete.then(() => { + this.emit('on:input'); + this.emit('on:change'); + }); + } + + if (!this.multiple) { + this.hide(); + this.displayInput.focus({ preventScroll: true }); + } + } + } + + private handleDefaultSlotChange() { + const allOptions = this.getAllOptions(); + const value = Array.isArray(this.value) ? this.value : [this.value]; + const values: string[] = []; + + // Check for duplicate values in menu items + if (customElements.get('lynk-option')) { + allOptions.forEach(option => values.push(option.value)); + + // Select only the options that match the new value + this.setSelectedOptions(allOptions.filter(el => value.includes(el.value))); + } else { + // Rerun this handler when is registered + customElements.whenDefined('lynk-option').then(() => this.handleDefaultSlotChange()); + } + } + + private handleTagRemove(event: OnRemoveEvent, option: LynkOption) { + event.stopPropagation(); + + if (!this.disabled) { + this.toggleOptionSelection(option, false); + + // Emit after updating + this.updateComplete.then(() => { + this.emit('on:input'); + this.emit('on:change'); + }); + } + } + + // Gets an array of all elements + private getAllOptions() { + return [...this.querySelectorAll('lynk-option')]; + } + + // Gets the first element + private getFirstOption() { + return this.querySelector('lynk-option'); + } + + private getFirstSelectableOption() { + return this.querySelector('lynk-option:not([hidden])'); + } + + private getPreviousSelectableOption(currentIndex: number) { + const allOptions = this.getAllOptions(); + let previousIndex = currentIndex - 1; + + if (previousIndex < 0) previousIndex = allOptions.length - 1; + + const previousOption = allOptions[previousIndex]; + + if (previousOption.disabled || previousOption.hidden) { + return this.getPreviousSelectableOption(previousIndex); + } else { + return previousOption; + } + } + + private getNextSelectableOption(currentIndex: number) { + const allOptions = this.getAllOptions(); + let nextIndex = currentIndex + 1; + + if (nextIndex > allOptions.length - 1) nextIndex = 0; + + const nextOption = allOptions[nextIndex]; + + if (nextOption.disabled || nextOption.hidden) { + return this.getNextSelectableOption(nextIndex); + } else { + return nextOption; + } + } + + // Sets the current option, which is the option the user is currently interacting with (e.g. via keyboard). Only one + // option may be "current" at a time. + private setCurrentOption(option: LynkOption | null) { + const allOptions = this.getAllOptions(); + + // Clear selection + allOptions.forEach(el => { + el.current = false; + el.tabIndex = -1; + }); + + // Select the target option + if (option) { + this.currentOption = option; + option.current = true; + option.tabIndex = 0; + scrollIntoView(option, this.listbox, 'vertical', 'auto'); + // option.focus(); + } + } + + // Sets the selected option(s) + private setSelectedOptions(option: LynkOption | LynkOption[]) { + const allOptions = this.getAllOptions(); + const newSelectedOptions = Array.isArray(option) ? option : [option]; + + // Clear existing selection + allOptions.forEach(el => (el.selected = false)); + + // Set the new selection + if (newSelectedOptions.length) { + newSelectedOptions.forEach(el => (el.selected = true)); + } + + // Update selection, value, and display label + this.selectionChanged(); + } + + // Toggles an option's selected state + private toggleOptionSelection(option: LynkOption, force?: boolean) { + if (force === true || force === false) { + option.selected = force; + } else { + option.selected = !option.selected; + } + + this.selectionChanged(); + } + + // This method must be called whenever the selection changes. It will update the selected options cache, the current + // value, and the display value + private selectionChanged() { + // Update selected options cache + this.selectedOptions = this.getAllOptions().filter(el => el.selected); + + // Update the value and display label + if (this.multiple) { + this.value = this.selectedOptions.map(el => el.value); + + if (this.placeholder && this.value.length === 0) { + // When no items are selected, keep the value empty so the placeholder shows + this.displayValue = ''; + } else { + this.displayValue = this.localize.term('numOptionsSelected', this.selectedOptions.length); + } + } else { + this.value = this.selectedOptions[0]?.value ?? ''; + this.displayValue = this.selectedOptions[0]?.getTextLabel() ?? ''; + } + + // Update validity + this.updateComplete.then(() => { + this.formControlController.updateValidity(); + }); + } + + private handleInvalid(event: Event) { + this.formControlController.setValidity(false); + this.formControlController.emitInvalidEvent(event); + } + + @watch('hasFocus', { waitUntilFirstUpdate: true }) + handleFocusChange() { + if (this.hasFocus && !this.disabled) { + this.addComboboxListeners(); + } else { + this.removeComboboxListeners(); + } + } + + @watch('disabled', { waitUntilFirstUpdate: true }) + handleDisabledChange() { + // Close the listbox when the control is disabled + if (this.disabled) { + this.open = false; + this.handleOpenChange(); + } + } + + @watch('value', { waitUntilFirstUpdate: true }) + handleValueChange() { + const allOptions = this.getAllOptions(); + const value = Array.isArray(this.value) ? this.value : [this.value]; + + // Select only the options that match the new value + this.setSelectedOptions(allOptions.filter(el => value.includes(el.value))); + } + + @watch('open', { waitUntilFirstUpdate: true }) + async handleOpenChange() { + if (this.open && !this.disabled) { + // Reset the current option + this.setCurrentOption(this.selectedOptions[0] || this.getFirstOption()); + + // Show + this.emit('on:show'); + this.addListboxListeners(); + + await stopAnimations(this); + this.listbox.hidden = false; + this.popup.active = true; + + // Select the appropriate option based on value after the listbox opens + // requestAnimationFrame(() => { + // this.setCurrentOption(this.currentOption); + // }); + + const { keyframes, options } = getAnimation(this, 'combobox.show', { dir: this.localize.dir() }); + await animateTo(this.popup.popup, keyframes, options); + + // Make sure the current option is scrolled into view (required for Safari) + if (this.currentOption) { + scrollIntoView(this.currentOption, this.listbox, 'vertical', 'auto'); + } + + this.emit('after:show'); + } else { + // Hide + this.emit('on:hide'); + this.removeListboxListeners(); + + await stopAnimations(this); + const { keyframes, options } = getAnimation(this, 'combobox.hide', { dir: this.localize.dir() }); + await animateTo(this.popup.popup, keyframes, options); + this.listbox.hidden = true; + this.popup.active = false; + this.listboxHasFocus = false; + + this.emit('after:hide'); + } + } + + /** Shows the listbox. */ + async show() { + if (this.open || this.disabled) { + this.open = false; + return undefined; + } + + this.open = true; + return waitForEvent(this, 'after:show'); + } + + /** Hides the listbox. */ + async hide() { + if (!this.open || this.disabled) { + this.open = false; + return undefined; + } + + this.open = false; + return waitForEvent(this, 'after:hide'); + } + + /** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */ + checkValidity() { + return this.valueInput.checkValidity(); + } + + /** Gets the associated form, if one exists. */ + getForm(): HTMLFormElement | null { + return this.formControlController.getForm(); + } + + /** Checks for validity and shows the browser's validation message if the control is invalid. */ + reportValidity() { + return this.valueInput.reportValidity(); + } + + /** Sets a custom validation message. Pass an empty string to restore validity. */ + setCustomValidity(message: string) { + this.valueInput.setCustomValidity(message); + this.formControlController.updateValidity(); + } + + /** Sets focus on the control. */ + focus(options?: FocusOptions) { + this.displayInput.focus(options); + this.hasFocus = true; + this.comboboxHasFocus = true; + } + + /** Removes focus from the control. */ + blur() { + this.blur(); + this.hasFocus = false; + this.comboboxHasFocus = false; + this.listboxHasFocus = false; + } + + render() { + const hasLabelSlot = this.hasSlotController.test('label'); + const hasHelpTextSlot = this.hasSlotController.test('help-text'); + const hasHelpTipSlot = this.hasSlotController.test('help-tip'); + const hasLabel = this.label ? true : !!hasLabelSlot; + const hasHelpText = this.helpText ? true : !!hasHelpTextSlot; + const hasHelpTip = this.helpTip ? true : !!hasHelpTipSlot; + const hasClearIcon = this.clearable && !this.disabled && this.value.length > 0; + const isPlaceholderVisible = this.placeholder && this.value.length === 0; + const noFilterResutls = this.displayValue && this.filteredOptions.length === 0; + + return html` +
+ + +
+ +
+ + + ${this.restricted + ? html` +
+ ${this.displayValue ? this.displayValue : '-'} +
+ ` + : html` + + `} + ${this.multiple + ? html` +
+ ${this.selectedOptions.map((option, index) => { + if (index < this.maxOptionsVisible || this.maxOptionsVisible <= 0) { + return html` + this.handleTagRemove(event, option)} + > + ${option.getTextLabel()} + + `; + } else if (index === this.maxOptionsVisible) { + return html` + +${this.selectedOptions.length - index} + `; + } + return null; + })} +
+ ` + : ''} + + this.focus()} + @invalid=${this.handleInvalid} + /> + + ${hasClearIcon + ? html` + + ` + : html` + + `} +
+ +
+ ${noFilterResutls + ? html` + + No results found... + + ` : '' + } + +
+
+ + + ${this.helpText} + +
+
+ `; + } +} + +setDefaultAnimation('combobox.show', { + keyframes: [ + { opacity: 0, scale: 0.9 }, + { opacity: 1, scale: 1 } + ], + options: { duration: 100, easing: 'ease' } +}); + +setDefaultAnimation('combobox.hide', { + keyframes: [ + { opacity: 1, scale: 1 }, + { opacity: 0, scale: 0.9 } + ], + options: { duration: 100, easing: 'ease' } +}); + +declare global { + interface HTMLElementTagNameMap { + 'lynk-combobox': LynkCombobox; + } +} + diff --git a/src/components/dropdown/dropdown.ts b/src/components/dropdown/dropdown.ts index a54c1de8..1fd8b819 100644 --- a/src/components/dropdown/dropdown.ts +++ b/src/components/dropdown/dropdown.ts @@ -6,7 +6,6 @@ import { getAnimation, setDefaultAnimation } from '../../utilities/animation-reg import { getTabbableBoundary } from '../../internal/tabbable'; import { html } from 'lit'; import { LocalizeController } from '../../utilities/localize'; -import { scrollIntoView } from '../../internal/scroll'; import { waitForEvent } from '../../internal/event'; import { watch } from '../../internal/watch'; import LynkElement from '../../internal/lynk-element'; @@ -15,8 +14,8 @@ import type { CSSResultGroup } from 'lit'; import type LynkButton from '../button/button'; import type LynkIconButton from '../icon-button/icon-button'; import type LynkMenu from '../menu/menu'; -import type LynkMenuItem from '../menu-item/menu-item'; import type LynkPopup from '../popup/popup'; +import type OnSelectEvent from '../../events/on-select'; /** * @summary Dropdowns expose additional content that "drops down" in a panel. @@ -107,7 +106,6 @@ export default class LynkDropdown extends LynkElement { connectedCallback() { super.connectedCallback(); - this.handleMenuItemActivate = this.handleMenuItemActivate.bind(this); this.handlePanelSelect = this.handlePanelSelect.bind(this); this.handleKeyDown = this.handleKeyDown.bind(this); this.handleDocumentKeyDown = this.handleDocumentKeyDown.bind(this); @@ -204,12 +202,7 @@ export default class LynkDropdown extends LynkElement { } } - handleMenuItemActivate(event: CustomEvent) { - const item = event.target as LynkMenuItem; - scrollIntoView(item, this.panel); - } - - handlePanelSelect(event: CustomEvent) { + handlePanelSelect(event: OnSelectEvent) { const target = event.target as HTMLElement; // Hide the dropdown when a menu item is selected @@ -345,7 +338,6 @@ export default class LynkDropdown extends LynkElement { } addOpenListeners() { - this.panel.addEventListener('on:activate', this.handleMenuItemActivate); this.panel.addEventListener('on:select', this.handlePanelSelect); this.panel.addEventListener('keydown', this.handleKeyDown); document.addEventListener('keydown', this.handleDocumentKeyDown); @@ -354,7 +346,6 @@ export default class LynkDropdown extends LynkElement { removeOpenListeners() { if (this.panel) { - this.panel.removeEventListener('on:activate', this.handleMenuItemActivate); this.panel.removeEventListener('on:select', this.handlePanelSelect); this.panel.removeEventListener('keydown', this.handleKeyDown); } diff --git a/src/components/input/input.styles.ts b/src/components/input/input.styles.ts index b11a7b5c..6b9eb22f 100644 --- a/src/components/input/input.styles.ts +++ b/src/components/input/input.styles.ts @@ -123,7 +123,7 @@ export default css` .lynk-input__control:-webkit-autofill:focus, .lynk-input__control:-webkit-autofill:active { box-shadow: 0 0 0 var(--lynk-input-height-large) var(--lynk-input-background-color-hover) inset !important; - -webkit-text-fill-color: var(--lynk-color-primary-500); + -webkit-text-fill-color: var(--lynk-input-color); caret-color: var(--lynk-input-color); } diff --git a/src/components/input/input.ts b/src/components/input/input.ts index 35b86c0d..e60eeb45 100644 --- a/src/components/input/input.ts +++ b/src/components/input/input.ts @@ -405,6 +405,11 @@ export default class LynkInput extends LynkElement implements LynkFormControl { return this.input.checkValidity(); } + /** Gets the associated form, if one exists. */ + getForm(): HTMLFormElement | null { + return this.formControlController.getForm(); + } + /** Checks for validity and shows the browser's validation message if the control is invalid. */ reportValidity() { return this.input.reportValidity(); diff --git a/src/components/intersection-observer/intersection-observer.styles.ts b/src/components/intersection-observer/intersection-observer.styles.ts new file mode 100644 index 00000000..a4aafbb9 --- /dev/null +++ b/src/components/intersection-observer/intersection-observer.styles.ts @@ -0,0 +1,10 @@ +import { css } from 'lit'; +import componentStyles from '../../styles/component.styles'; + +export default css` + ${componentStyles} + + :host { + display: block; + } +`; diff --git a/src/components/intersection-observer/intersection-observer.ts b/src/components/intersection-observer/intersection-observer.ts new file mode 100644 index 00000000..24c680b7 --- /dev/null +++ b/src/components/intersection-observer/intersection-observer.ts @@ -0,0 +1,98 @@ +import { html } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import LynkElement from '../../internal/lynk-element'; +import { watch } from '../../internal/watch'; +import styles from './intersection-observer.styles'; +import type { CSSResultGroup } from 'lit'; + +/** + * @summary The Intersection Observer component offers a thin, declarative interface to the [`IntersectionObserver API`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver). + * + * @since 1.0 + * @status experimental + * + * @slot - One or more elements to watch for intersection. + * + * @event {{ entry: IntersectionObserverEntry[] }} on:enter - Emitted when a slotted element enters the view. + * @event {{ entry: IntersectionObserverEntry[] }} on:exit - Emitted when a slotted element exits the view. + + */ +@customElement('lynk-intersection-observer') +export default class LynkIntersectionObserver extends LynkElement { + static styles: CSSResultGroup = styles; + + private intersectionObserver: IntersectionObserver; + private observedElements: HTMLElement[] = []; + + /** Disables the observer. */ + @property({ type: Boolean, reflect: true }) disabled = false; + + connectedCallback() { + super.connectedCallback(); + this.intersectionObserver = new IntersectionObserver((entries: IntersectionObserverEntry[]) => { + entries.forEach((entry: IntersectionObserverEntry) => { + if (entry.intersectionRatio > 0) { + this.emit('on:enter-view', {detail: { entry } }); + } else { + this.emit('on:exit-view', {detail: { entry } }); + } + }); + }); + + if(!this.disabled) { + this.startObserver(); + } + } + + handleSlotChange() { + if (!this.disabled) { + this.startObserver(); + } + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.stopObserver(); + } + + startObserver() { + const slot = this.shadowRoot!.querySelector('slot'); + + if (slot !== null) { + const elements = slot.assignedElements({ flatten: true }) as HTMLElement[]; + + // Unwatch previous elements + this.observedElements.forEach(el => this.intersectionObserver.unobserve(el)); + this.observedElements = []; + + // Watch new elements + elements.forEach(el => { + this.intersectionObserver.observe(el); + this.observedElements.push(el); + }); + } + } + + stopObserver() { + this.intersectionObserver.disconnect(); + } + + @watch('disabled', { waitUntilFirstUpdate: true }) + handleDisabledChange() { + if (this.disabled) { + this.stopObserver(); + } else { + this.startObserver(); + } + } + + render() { + return html` `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'lynk-intersection-observer': LynkIntersectionObserver; + } +} diff --git a/src/components/menu-item/menu-item.styles.ts b/src/components/menu-item/menu-item.styles.ts index 33a6f3bb..486f34e7 100644 --- a/src/components/menu-item/menu-item.styles.ts +++ b/src/components/menu-item/menu-item.styles.ts @@ -69,7 +69,8 @@ export default css` color: var(--lynk-color-neutral-1000); } - :host(:focus-visible) .lynk-menu-item { + :host(:focus-visible) .lynk-menu-item, + :host(:focus-visible:hover:not([aria-disabled='true'])) .lynk-menu-item { outline: none; background-color: var(--lynk-color-primary-600); color: var(--lynk-color-neutral-0); diff --git a/src/components/menu/menu.styles.ts b/src/components/menu/menu.styles.ts index bb80e7db..65f05ecd 100644 --- a/src/components/menu/menu.styles.ts +++ b/src/components/menu/menu.styles.ts @@ -18,4 +18,14 @@ export default css` ::slotted(lynk-divider) { --spacing: var(--lynk-spacing-x-small); } + + ::slotted(lynk-input) { + padding: 0 var(--lynk-spacing-x-small); + margin-bottom: var(--lynk-spacing-x-small); + } + + ::slotted(lynk-spinner) { + display: block; + margin: var(--lynk-spacing-x-small) auto; + } `; diff --git a/src/components/nav-item/nav-item.ts b/src/components/nav-item/nav-item.ts index dcc03c37..45473f30 100644 --- a/src/components/nav-item/nav-item.ts +++ b/src/components/nav-item/nav-item.ts @@ -59,40 +59,31 @@ export default class LynkNavItem extends LynkElement { @state() depth = 0; @state() squished = false; - @property() - public title = ''; // make reactive to pass through + @property() title = ''; // make reactive to pass through /** An optional name for the button. Ignored when `href` is set. */ - @property() - public name = ''; + @property() name = ''; /** A unique value to store in the nav item. This can be used as a way to identify nav items when selected. */ - @property() - public value = ''; + @property() value = ''; /** When set, the underlying button will be rendered as an `` with this `href` instead of a `