diff --git a/packages/core/config/stencil.bindings.angular.ts b/packages/core/config/stencil.bindings.angular.ts index cbaf88154c..00ccd132b9 100644 --- a/packages/core/config/stencil.bindings.angular.ts +++ b/packages/core/config/stencil.bindings.angular.ts @@ -9,6 +9,7 @@ export const angularValueAccessorBindings: ValueAccessorConfig[] = [ 'bal-checkbox-group', 'bal-select', 'bal-dropdown', + 'bal-typeahead', 'bal-date', 'bal-input-date', 'bal-file-upload', @@ -51,6 +52,7 @@ export const AngularGenerator = () => 'bal-checkbox', 'bal-date', 'bal-dropdown', + 'bal-typeahead', 'bal-file-upload', 'bal-input-date', 'bal-input-slider', diff --git a/packages/core/src/components.d.ts b/packages/core/src/components.d.ts index 98f2b77b58..c921580538 100644 --- a/packages/core/src/components.d.ts +++ b/packages/core/src/components.d.ts @@ -2234,6 +2234,7 @@ export namespace Components { "value": string; } interface BalOptionList { + "configChanged": (state: BalConfigState) => Promise; /** * Defines the max height of the list element */ @@ -3627,6 +3628,113 @@ export namespace Components { "reference": string; "update": () => Promise; } + /** + * TODOS: + * Features: + * - [ ] option list pagination + * - [ ] option list highlight search term + */ + interface BalTypeahead { + /** + * Indicates whether the value of the control can be automatically completed by the browser. + */ + "autocomplete": BalProps.BalInputAutocomplete; + /** + * Sets the value to `[]`, the input value to ´''´ and the focus index to ´0´. + */ + "clear": () => Promise; + /** + * If `true`, a cross at the end is visible to clear the selection + */ + "clearable": boolean; + /** + * Closes the popup with option list + */ + "close": () => Promise; + "configChanged": (state: BalConfigState) => Promise; + /** + * Defines the max height of the list element + */ + "contentHeight": number; + /** + * If `true`, the user cannot interact with the option. + */ + "disabled": boolean; + /** + * Defines the filter logic of the list + */ + "filter": BalProps.BalOptionListFilter; + /** + * Returns the value of the component + */ + "getValue": () => Promise; + /** + * If `true` there will be on trigger icon visible + */ + "icon": string; + /** + * Defines a inline label to be shown before the value + */ + "inlineLabel": string; + /** + * If `true`, the component will be shown as invalid + */ + "invalid": boolean; + "inverted": boolean; + /** + * Defines if the select is in a loading state. + */ + "loading": boolean; + /** + * If `true`, the user can select multiple options. + */ + "multiple": boolean; + /** + * The name of the control, which is submitted with the form data. + */ + "name": string; + /** + * Opens the popup with option list + */ + "open": () => Promise; + /** + * Steps can be passed as a property or through HTML markup. + */ + "options": BalOption[]; + /** + * Defines the placeholder of the component. Only shown when the value is empty + */ + "placeholder": string; + /** + * If `true` the element can not mutated, meaning the user can not edit the control. + */ + "readonly": boolean; + /** + * If `true`, the user must fill in a value before submitting a form. + */ + "required": boolean; + /** + * Select option by passed value + */ + "select": (newValue: string | string[]) => Promise; + "setAriaForm": (ariaForm: BalAriaForm) => Promise; + /** + * Sets the focus on the input element + */ + "setFocus": () => Promise; + /** + * Defines the size of the control. + */ + "size": BalProps.BalTypeaheadSize; + /** + * Defines the color style of the control + */ + "theme": BalProps.BalTypeaheadTheme; + /** + * The value of the selected options. + */ + "value"?: string | string[]; + } } export interface BalAccordionCustomEvent extends CustomEvent { detail: T; @@ -3792,6 +3900,10 @@ export interface BalTooltipCustomEvent extends CustomEvent { detail: T; target: HTMLBalTooltipElement; } +export interface BalTypeaheadCustomEvent extends CustomEvent { + detail: T; + target: HTMLBalTypeaheadElement; +} declare global { interface HTMLBalAccordionElementEventMap { "balChange": BalEvents.BalAccordionChangeDetail; @@ -5043,6 +5155,31 @@ declare global { prototype: HTMLBalTooltipElement; new (): HTMLBalTooltipElement; }; + interface HTMLBalTypeaheadElementEventMap { + "balChange": BalEvents.BalTypeaheadChangeDetail; + "balFocus": BalEvents.BalTypeaheadFocusDetail; + "balBlur": BalEvents.BalTypeaheadBlurDetail; + } + /** + * TODOS: + * Features: + * - [ ] option list pagination + * - [ ] option list highlight search term + */ + interface HTMLBalTypeaheadElement extends Components.BalTypeahead, HTMLStencilElement { + addEventListener(type: K, listener: (this: HTMLBalTypeaheadElement, ev: BalTypeaheadCustomEvent) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void; + removeEventListener(type: K, listener: (this: HTMLBalTypeaheadElement, ev: BalTypeaheadCustomEvent) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void; + } + var HTMLBalTypeaheadElement: { + prototype: HTMLBalTypeaheadElement; + new (): HTMLBalTypeaheadElement; + }; interface HTMLElementTagNameMap { "bal-accordion": HTMLBalAccordionElement; "bal-accordion-details": HTMLBalAccordionDetailsElement; @@ -5162,6 +5299,7 @@ declare global { "bal-time-input": HTMLBalTimeInputElement; "bal-toast": HTMLBalToastElement; "bal-tooltip": HTMLBalTooltipElement; + "bal-typeahead": HTMLBalTypeaheadElement; } } declare namespace LocalJSX { @@ -8786,6 +8924,99 @@ declare namespace LocalJSX { */ "reference"?: string; } + /** + * TODOS: + * Features: + * - [ ] option list pagination + * - [ ] option list highlight search term + */ + interface BalTypeahead { + /** + * Indicates whether the value of the control can be automatically completed by the browser. + */ + "autocomplete"?: BalProps.BalInputAutocomplete; + /** + * If `true`, a cross at the end is visible to clear the selection + */ + "clearable"?: boolean; + /** + * Defines the max height of the list element + */ + "contentHeight"?: number; + /** + * If `true`, the user cannot interact with the option. + */ + "disabled"?: boolean; + /** + * Defines the filter logic of the list + */ + "filter"?: BalProps.BalOptionListFilter; + /** + * If `true` there will be on trigger icon visible + */ + "icon"?: string; + /** + * Defines a inline label to be shown before the value + */ + "inlineLabel"?: string; + /** + * If `true`, the component will be shown as invalid + */ + "invalid"?: boolean; + "inverted"?: boolean; + /** + * Defines if the select is in a loading state. + */ + "loading"?: boolean; + /** + * If `true`, the user can select multiple options. + */ + "multiple"?: boolean; + /** + * The name of the control, which is submitted with the form data. + */ + "name"?: string; + /** + * Emitted when the input loses focus. + */ + "onBalBlur"?: (event: BalTypeaheadCustomEvent) => void; + /** + * Emitted when a option got selected. + */ + "onBalChange"?: (event: BalTypeaheadCustomEvent) => void; + /** + * Emitted when the input has focus. + */ + "onBalFocus"?: (event: BalTypeaheadCustomEvent) => void; + /** + * Steps can be passed as a property or through HTML markup. + */ + "options"?: BalOption[]; + /** + * Defines the placeholder of the component. Only shown when the value is empty + */ + "placeholder"?: string; + /** + * If `true` the element can not mutated, meaning the user can not edit the control. + */ + "readonly"?: boolean; + /** + * If `true`, the user must fill in a value before submitting a form. + */ + "required"?: boolean; + /** + * Defines the size of the control. + */ + "size"?: BalProps.BalTypeaheadSize; + /** + * Defines the color style of the control + */ + "theme"?: BalProps.BalTypeaheadTheme; + /** + * The value of the selected options. + */ + "value"?: string | string[]; + } interface IntrinsicElements { "bal-accordion": BalAccordion; "bal-accordion-details": BalAccordionDetails; @@ -8905,6 +9136,7 @@ declare namespace LocalJSX { "bal-time-input": BalTimeInput; "bal-toast": BalToast; "bal-tooltip": BalTooltip; + "bal-typeahead": BalTypeahead; } } export { LocalJSX as JSX }; @@ -9029,6 +9261,13 @@ declare module "@stencil/core" { "bal-time-input": LocalJSX.BalTimeInput & JSXBase.HTMLAttributes; "bal-toast": LocalJSX.BalToast & JSXBase.HTMLAttributes; "bal-tooltip": LocalJSX.BalTooltip & JSXBase.HTMLAttributes; + /** + * TODOS: + * Features: + * - [ ] option list pagination + * - [ ] option list highlight search term + */ + "bal-typeahead": LocalJSX.BalTypeahead & JSXBase.HTMLAttributes; } } } diff --git a/packages/core/src/components/bal-dropdown/bal-dropdown.sass b/packages/core/src/components/bal-dropdown/bal-dropdown.sass index 69300fe7ac..9afb4dbea5 100644 --- a/packages/core/src/components/bal-dropdown/bal-dropdown.sass +++ b/packages/core/src/components/bal-dropdown/bal-dropdown.sass @@ -111,7 +111,8 @@ display: none visibility: hidden opacity: 0 - width: 100vw + min-width: 100% + max-width: 100% position: absolute top: 0 left: 0 @@ -123,7 +124,6 @@ +tablet max-width: 100vw width: fit-content - min-width: 100% &--expanded display: block visibility: visible diff --git a/packages/core/src/components/bal-dropdown/bal-dropdown.tsx b/packages/core/src/components/bal-dropdown/bal-dropdown.tsx index 67d84f5307..a3ed7852fe 100644 --- a/packages/core/src/components/bal-dropdown/bal-dropdown.tsx +++ b/packages/core/src/components/bal-dropdown/bal-dropdown.tsx @@ -1,49 +1,50 @@ import { Component, - h, ComponentInterface, - Host, Element, - Prop, - State, - Watch, Event, EventEmitter, + Host, Method, + Prop, + State, + Watch, + h, } from '@stencil/core' -import { isArrowDownKey, isArrowUpKey, isEnterKey, isEscapeKey, isSpaceKey } from '../../utils/keyboard' -import { BEM } from '../../utils/bem' -import { LogInstance, Loggable, Logger } from '../../utils/log' -import { stopEventBubbling } from '../../utils/form-input' import { Attributes, inheritAttributes } from '../../utils/attributes' +import { BEM } from '../../utils/bem' +import { balBrowser } from '../../utils/browser' +import { + BalConfigObserver, + BalConfigState, + BalLanguage, + BalRegion, + ListenToConfig, + defaultConfig, +} from '../../utils/config' import { BalOption, + DropdownAutoFillUtil, DropdownEventsUtil, - DropdownFormSubmit, - DropdownFormSubmitUtil, - DropdownOptionUtil, - DropdownPopupUtil, - DropdownValueUtil, - DropdownOptionList, DropdownFocus, DropdownFocusUtil, + DropdownFormSubmit, + DropdownFormSubmitUtil, DropdownIcon, - DropdownNativeSelect, DropdownInput, + DropdownNativeSelect, + DropdownOptionList, + DropdownOptionUtil, + DropdownPopupUtil, DropdownValue, - DropdownAutoFillUtil, + DropdownValueUtil, } from '../../utils/dropdown' -import { - BalConfigObserver, - BalConfigState, - BalLanguage, - BalRegion, - ListenToConfig, - defaultConfig, -} from '../../utils/config' +import { DropdownMode } from '../../utils/dropdown/mode' import { BalAriaForm, BalAriaFormLinking, defaultBalAriaForm } from '../../utils/form' +import { stopEventBubbling } from '../../utils/form-input' import { addEventListener, removeEventListener, waitAfterIdleCallback } from '../../utils/helpers' -import { balBrowser } from '../../utils/browser' +import { isArrowDownKey, isArrowUpKey, isEnterKey, isEscapeKey, isSpaceKey } from '../../utils/keyboard' +import { LogInstance, Loggable, Logger } from '../../utils/log' @Component({ tag: 'bal-dropdown', @@ -58,6 +59,7 @@ export class Dropdown nativeEl: HTMLInputElement | undefined selectEl: HTMLSelectElement | undefined + mode = DropdownMode.Basic inputId = `bal-dropdown-${balDropdownIds++}` inheritedAttributes: Attributes = {} initialValue?: string | string[] = [] @@ -502,6 +504,7 @@ export class Dropdown > = { + de: { + noOptions: 'Kein Treffer gefunden.', + }, + en: { + noOptions: 'No matches found.', + }, + fr: { + noOptions: 'Aucun résultat trouvé.', + }, + it: { + noOptions: 'Nessun risultato trovato.', + }, + nl: { + noOptions: 'Geen resultaat gevonden.', + }, + es: { + noOptions: 'No se han encontrado resultados.', + }, + pl: { + noOptions: 'Nie znaleziono wyników.', + }, + pt: { + noOptions: 'Nenhum resultado encontrado.', + }, + sv: { + noOptions: 'Inga träffar hittades.', + }, + fi: { + noOptions: 'Tuloksia ei löytynyt.', + }, +} diff --git a/packages/core/src/components/bal-option-list/bal-option-list.sass b/packages/core/src/components/bal-option-list/bal-option-list.sass index 1da128cbf1..8164ff9f95 100644 --- a/packages/core/src/components/bal-option-list/bal-option-list.sass +++ b/packages/core/src/components/bal-option-list/bal-option-list.sass @@ -13,3 +13,13 @@ max-height: var(--bal-option-list-max-height) border-radius: var(--bal-radius-normal) overflow: hidden auto + +.bal-option-list__no-options + color: var(--bal-color-text-primary-light) + display: flex + justify-content: flex-start + align-items: center + min-height: 2.5rem + padding-left: var(--bal-option-padding-x) + padding-right: calc(var(--bal-option-padding-x) + var(--bal-option-padding-x)) + user-select: none diff --git a/packages/core/src/components/bal-option-list/bal-option-list.tsx b/packages/core/src/components/bal-option-list/bal-option-list.tsx index 3f57755987..1556fb8283 100644 --- a/packages/core/src/components/bal-option-list/bal-option-list.tsx +++ b/packages/core/src/components/bal-option-list/bal-option-list.tsx @@ -1,13 +1,15 @@ -import { Component, h, ComponentInterface, Host, Element, Prop, Watch, Method, State, Listen } from '@stencil/core' +import { Component, ComponentInterface, Element, h, Host, Listen, Method, Prop, State, Watch } from '@stencil/core' import isNil from 'lodash.isnil' +import { ariaBooleanToString } from '../../utils/aria' import { Attributes, inheritAttributes } from '../../utils/attributes' import { BEM } from '../../utils/bem' +import { BalConfigState, BalLanguage, BalRegion, defaultConfig, ListenToConfig } from '../../utils/config' +import { BalOption } from '../../utils/dropdown' +import { BalAriaForm, defaultBalAriaForm } from '../../utils/form' import { raf, waitAfterFramePaint } from '../../utils/helpers' import { Loggable, Logger, LogInstance } from '../../utils/log' import { includes, startsWith } from '../bal-select/utils/utils' -import { BalAriaForm, defaultBalAriaForm } from '../../utils/form' -import { BalOption } from '../../utils/dropdown' -import { ariaBooleanToString } from '../../utils/aria' +import { I18nBalOptionList } from './bal-option-list.i18n' @Component({ tag: 'bal-option-list', @@ -23,7 +25,10 @@ export class OptionList implements ComponentInterface, Loggable { log!: LogInstance + @State() noOptions = false @State() ariaForm: BalAriaForm = defaultBalAriaForm + @State() language: BalLanguage = defaultConfig.language + @State() region: BalRegion = defaultConfig.region @Logger('bal-option-list') createLogger(log: LogInstance) { @@ -95,6 +100,16 @@ export class OptionList implements ComponentInterface, Loggable { * ------------------------------------------------------ */ + /** + * @internal define config for the component + */ + @Method() + @ListenToConfig() + async configChanged(state: BalConfigState): Promise { + this.language = state.language + this.region = state.region + } + @Listen('balOptionFocus', { passive: true }) listenToMouseEnter(ev: BalEvents.BalOptionFocus) { const options = this.options @@ -270,7 +285,7 @@ export class OptionList implements ComponentInterface, Loggable { * Returns a list of option values */ @Method() async getSelectedValues(): Promise { - const options = this.options + const options = this.allOptions return options.filter(option => option.selected).map(option => option.value) } @@ -278,7 +293,7 @@ export class OptionList implements ComponentInterface, Loggable { * Returns a list of option labels */ @Method() async getSelectedOptions(values?: string[]): Promise { - const options = this.options + const options = this.allOptions if (values && values.length > 0) { return options.filter(option => values.includes(option.value)).map(option => option) } @@ -289,21 +304,21 @@ export class OptionList implements ComponentInterface, Loggable { * Returns a list of options */ @Method() async getValues(): Promise { - return this.options.map(option => option.value) + return this.allOptions.map(option => option.value) } /** * Returns a list of options */ @Method() async getLabels(): Promise { - return this.options.map(option => option.label) + return this.allOptions.map(option => option.label) } /** * Returns a list of accessible options */ @Method() async getOptions(): Promise { - return this.options.filter(o => !o.disabled || !o.hidden) + return this.allOptions.filter(o => !o.disabled) } /** @@ -364,6 +379,7 @@ export class OptionList implements ComponentInterface, Loggable { } } + this.noOptions = filteredOptions.length === 0 return filteredOptions } @@ -554,6 +570,18 @@ export class OptionList implements ComponentInterface, Loggable { {...this.inheritAttributes} > + + {this.noOptions ? ( +
+ {I18nBalOptionList[this.language].noOptions} +
+ ) : ( + '' + )} ) diff --git a/packages/core/src/components/bal-option/bal-option.sass b/packages/core/src/components/bal-option/bal-option.sass index f4eb7d0d36..22252e7a5d 100644 --- a/packages/core/src/components/bal-option/bal-option.sass +++ b/packages/core/src/components/bal-option/bal-option.sass @@ -15,7 +15,7 @@ border-bottom-style: solid background: var(--bal-option-background) padding-left: var(--bal-option-padding-x) - padding-right: var(--bal-option-padding-x) + padding-right: calc(var(--bal-option-padding-x) + var(--bal-option-padding-x)) min-height: var(--bal-option-min-height) & > bal-stack min-height: var(--bal-option-min-height) diff --git a/packages/core/src/components/bal-typeahead/bal-typeahead.interfaces.ts b/packages/core/src/components/bal-typeahead/bal-typeahead.interfaces.ts new file mode 100644 index 0000000000..df84f5e316 --- /dev/null +++ b/packages/core/src/components/bal-typeahead/bal-typeahead.interfaces.ts @@ -0,0 +1,25 @@ +/* eslint-disable no-unused-vars */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +// eslint-disable-next-line @typescript-eslint/triple-slash-reference +/// + +namespace BalProps { + export type BalTypeaheadSize = '' | 'small' + export type BalTypeaheadTheme = '' | 'purple' +} + +namespace BalEvents { + export interface BalTypeaheadCustomEvent extends CustomEvent { + detail: T + target: any // HTMLBalTypeaheadElement + } + + export type BalTypeaheadChangeDetail = string | string[] + export type BalTypeaheadChange = BalTypeaheadCustomEvent + + export type BalTypeaheadBlurDetail = FocusEvent + export type BalTypeaheadBlur = BalTypeaheadCustomEvent + + export type BalTypeaheadFocusDetail = FocusEvent + export type BalTypeaheadFocus = BalTypeaheadCustomEvent +} diff --git a/packages/core/src/components/bal-typeahead/bal-typeahead.sass b/packages/core/src/components/bal-typeahead/bal-typeahead.sass new file mode 100644 index 0000000000..f8bf77fe55 --- /dev/null +++ b/packages/core/src/components/bal-typeahead/bal-typeahead.sass @@ -0,0 +1,16 @@ +@use '../bal-dropdown/bal-dropdown' as * +@use './bal-typeahead.vars' as * + +.bal-dropdown__root__input--typeahead + position: static + appearance: textfield + opacity: 1 + flex: 1 + width: 100% + min-height: 1.5rem + padding-left: 0 + padding-right: 0 + + +.bal-dropdown__list--typeahead + width: 100% diff --git a/packages/core/src/components/bal-typeahead/bal-typeahead.tsx b/packages/core/src/components/bal-typeahead/bal-typeahead.tsx new file mode 100644 index 0000000000..4d09790853 --- /dev/null +++ b/packages/core/src/components/bal-typeahead/bal-typeahead.tsx @@ -0,0 +1,596 @@ +import { + Component, + ComponentInterface, + Element, + Event, + EventEmitter, + Host, + Method, + Prop, + State, + Watch, + h, +} from '@stencil/core' +import { Attributes, inheritAttributes } from '../../utils/attributes' +import { BEM } from '../../utils/bem' +import { balBrowser } from '../../utils/browser' +import { + BalConfigObserver, + BalConfigState, + BalLanguage, + BalRegion, + ListenToConfig, + defaultConfig, +} from '../../utils/config' +import { + BalOption, + DropdownAutoFillUtil, + DropdownEventsUtil, + DropdownFocus, + DropdownFocusUtil, + DropdownFormSubmit, + DropdownFormSubmitUtil, + DropdownIcon, + DropdownInput, + DropdownNativeSelect, + DropdownOptionList, + DropdownOptionUtil, + DropdownPopupUtil, + DropdownValue, + DropdownValueUtil, +} from '../../utils/dropdown' +import { DropdownMode } from '../../utils/dropdown/mode' +import { BalAriaForm, BalAriaFormLinking, defaultBalAriaForm } from '../../utils/form' +import { stopEventBubbling } from '../../utils/form-input' +import { addEventListener, removeEventListener, waitAfterIdleCallback } from '../../utils/helpers' +import { isArrowDownKey, isArrowUpKey, isEnterKey, isEscapeKey, isSpaceKey } from '../../utils/keyboard' +import { LogInstance, Loggable, Logger } from '../../utils/log' + +/** + * TODOS: + * + * Features: + * - [ ] option list pagination + * - [ ] option list highlight search term + */ + +@Component({ + tag: 'bal-typeahead', + styleUrl: 'bal-typeahead.sass', +}) +export class Typeahead + implements ComponentInterface, Loggable, BalConfigObserver, BalAriaFormLinking, DropdownFormSubmit, DropdownFocus +{ + @Element() el!: HTMLElement + panelEl: HTMLDivElement | undefined + listEl: HTMLBalOptionListElement | undefined + nativeEl: HTMLInputElement | undefined + selectEl: HTMLSelectElement | undefined + + mode = DropdownMode.Typeahead + inputId = `bal-typeahead-${balTypeaheadIds++}` + inheritedAttributes: Attributes = {} + initialValue?: string | string[] = [] + nativeOptions: string[] = [] + chips = true + + @State() rawOptions: BalOption[] = [] + @State() choices: BalOption[] = [] + @State() rawValue: string[] = [] + @State() typeaheadValue = '' + @State() hasFocus = false + @State() isExpanded = false + @State() isAutoFilled = false + @State() isKeyboardMode = false + @State() inputLabel = '' + @State() ariaForm: BalAriaForm = defaultBalAriaForm + @State() language: BalLanguage = defaultConfig.language + @State() region: BalRegion = defaultConfig.region + @State() httpFormSubmit: boolean = defaultConfig.httpFormSubmit + @State() labelToFocus = '' + + valueUtil = new DropdownValueUtil() + eventsUtil = new DropdownEventsUtil() + popupUtil = new DropdownPopupUtil() + optionUtil = new DropdownOptionUtil() + formSubmitUtil = new DropdownFormSubmitUtil() + focusUtil = new DropdownFocusUtil() + autoFillUtil = new DropdownAutoFillUtil() + + log!: LogInstance + + @Logger('bal-typeahead') + createLogger(log: LogInstance) { + this.log = log + } + + /** + * PUBLIC PROPERTY API + * ------------------------------------------------------ + */ + + /** + * Indicates whether the value of the control can be automatically completed by the browser. + */ + @Prop() autocomplete: BalProps.BalInputAutocomplete = 'off' + + /** + * The name of the control, which is submitted with the form data. + */ + @Prop() name: string = this.inputId + + /** + * Defines the placeholder of the component. Only shown when the value is empty + */ + @Prop() placeholder = '' + + /** + * Defines a inline label to be shown before the value + */ + @Prop() inlineLabel = '' + + /** + * If `true` there will be on trigger icon visible + */ + @Prop() icon = 'caret-down' + + /** + * Defines the size of the control. + */ + @Prop() size: BalProps.BalTypeaheadSize = '' + + /** + * Defines the color style of the control + */ + @Prop() theme: BalProps.BalTypeaheadTheme = '' + + /** + * If `true`, the user cannot interact with the option. + */ + @Prop() disabled = false + + /** + * If `true` the element can not mutated, meaning the user can not edit the control. + */ + @Prop() readonly = false + + /** + * If `true`, the user can select multiple options. + */ + @Prop() multiple = false + + /** + * If `true`, a cross at the end is visible to clear the selection + */ + @Prop() clearable = false + + /** + * If `true`, the component will be shown as invalid + */ + @Prop() invalid = false + + /** + * If `true`, the user must fill in a value before submitting a form. + */ + @Prop() required = false + + /** + * Defines if the select is in a loading state. + */ + @Prop() loading = false + + /** + * Defines the filter logic of the list + */ + @Prop() filter: BalProps.BalOptionListFilter = 'includes' + + /** + * Defines the max height of the list element + */ + @Prop() contentHeight = 262 + + /** + * @internal + * Set this to `true` when the component is placed on a dark background. + */ + @Prop() inverted = false + + /** + * Steps can be passed as a property or through HTML markup. + */ + @Prop() options: BalOption[] = [] + @Watch('options') + protected async optionChanged() { + this.optionUtil.optionChanged() + } + + /** + * The value of the selected options. + */ + @Prop() value?: string | string[] = [] + @Watch('value') + valueChanged(newValue: string | string[] | undefined, oldValue: string | string[] | undefined) { + this.valueUtil.valueChanged(newValue, oldValue) + } + + /** + * Emitted when a option got selected. + */ + @Event() balChange!: EventEmitter + + /** + * Emitted when the input has focus. + */ + @Event() balFocus!: EventEmitter + + /** + * Emitted when the input loses focus. + */ + @Event() balBlur!: EventEmitter + + /** + * LIFECYCLE + * ------------------------------------------------------ + */ + hasConnected = false + connectedCallback(): void { + if (!this.hasConnected) { + this.eventsUtil.connectedCallback(this) + this.valueUtil.connectedCallback(this) + this.popupUtil.connectedCallback(this) + this.optionUtil.connectedCallback(this) + this.formSubmitUtil.connectedCallback(this) + this.focusUtil.connectedCallback(this) + this.autoFillUtil.connectedCallback(this) + } + + addEventListener(this.el, 'balOptionChange', this.listenToOptionChange) + + if (balBrowser.hasDocument) { + addEventListener(document, 'keydown', this.eventsUtil.handleKeydown) + addEventListener(document, 'touchstart', this.eventsUtil.handlePointerDown) + addEventListener(document, 'mousedown', this.eventsUtil.handlePointerDown) + addEventListener(document, 'click', this.listenOnClickOutside) + addEventListener(document, 'reset', this.resetHandler, { + capture: true, + }) + } + + this.hasConnected = true + } + + disconnectedCallback(): void { + removeEventListener(this.el, 'balOptionChange', this.listenToOptionChange) + + if (balBrowser.hasDocument) { + removeEventListener(document, 'keydown', this.eventsUtil.handleKeydown) + removeEventListener(document, 'touchstart', this.eventsUtil.handlePointerDown) + removeEventListener(document, 'mousedown', this.eventsUtil.handlePointerDown) + removeEventListener(document, 'click', this.listenOnClickOutside) + removeEventListener(document, 'reset', this.resetHandler, { + capture: true, + }) + } + } + + async componentWillRender() { + this.inheritedAttributes = inheritAttributes(this.el, ['tabindex']) + await this.optionUtil.componentWillRender() + } + + componentDidRender() { + this.formSubmitUtil.componentDidRender() + } + + componentDidLoad(): void { + this.valueUtil.componentDidLoad() + } + + /** + * LISTENERS + * ------------------------------------------------------ + */ + + /** + * @internal define config for the component + */ + @Method() + @ListenToConfig() + async configChanged(state: BalConfigState): Promise { + this.language = state.language + this.region = state.region + this.httpFormSubmit = state.httpFormSubmit + } + + listenToOptionChange = (ev: BalEvents.BalOptionChange) => { + this.optionUtil.listenToOptionChange(ev) + } + + listenOnClickOutside = (ev: UIEvent) => { + this.eventsUtil.handleOutsideClick(ev) + } + + resetHandler = (ev: UIEvent) => { + this.formSubmitUtil.handle(ev) + } + + /** + * PUBLIC METHODS + * ------------------------------------------------------ + */ + + /** + * Sets the focus on the input element + */ + @Method() + async setFocus() { + if (this.nativeEl && !this.valueUtil.isDisabled()) { + await waitAfterIdleCallback() + this.nativeEl.focus() + } + } + + /** + * Returns the value of the component + */ + @Method() + async getValue() { + return this.rawValue + } + + /** + * Sets the value to `[]`, the input value to ´''´ and the focus index to ´0´. + */ + @Method() + async clear() { + this.nativeEl.value = '' + this.valueUtil.updateRawValueBySelection([]) + } + + /** + * Opens the popup with option list + */ + @Method() + async open(): Promise { + if (!this.valueUtil.isDisabled() && this.panelEl) { + await this.popupUtil.expandList() + } + } + + /** + * Closes the popup with option list + */ + @Method() + async close(): Promise { + if (!this.valueUtil.isDisabled() && this.panelEl) { + await this.popupUtil.collapseList() + } + } + + /** + * Select option by passed value + */ + @Method() + async select(newValue: string | string[]): Promise { + const parsedNewValue = this.valueUtil.parseValueString(newValue) + this.valueUtil.updateRawValueBySelection(parsedNewValue) + } + + /** + * @internal + */ + @Method() + async setAriaForm(ariaForm: BalAriaForm): Promise { + this.ariaForm = { ...ariaForm } + } + + /** + * EVENT BINDING + * ------------------------------------------------------ + */ + handleAutoFill = async (ev: Event) => { + this.log('(handleAutoFill)', ev, this.nativeEl?.value) + this.autoFillUtil.handleAutoFill(ev) + } + + handleInput = async (ev: InputEvent) => { + stopEventBubbling(ev) + const value = this.nativeEl.value.trim() + if (value) { + this.listEl?.filterByContent(value) + this.typeaheadValue = value + } + this.popupUtil.expandList() + } + + handleKeyDown = (ev: KeyboardEvent) => { + if (ev && ev.key) { + if (this.isExpanded) { + /** + * ⬇️ Arrow up key + */ + if (isArrowDownKey(ev)) { + stopEventBubbling(ev) + this.listEl?.focusNext() + /** + * ⬆️ Arrow up key + */ + } else if (isArrowUpKey(ev)) { + stopEventBubbling(ev) + this.listEl?.focusPrevious() + /** + * Go to top of the list + */ + } else if (ev.key === 'Home' || ev.key === 'PageUp') { + stopEventBubbling(ev) + this.listEl?.focusFirst() + /** + * Go to bottom of the list + */ + } else if (ev.key === 'End' || ev.key === 'PageDown') { + stopEventBubbling(ev) + this.listEl?.focusLast() + /** + * Select focused option + */ + } else if (isEnterKey(ev)) { + stopEventBubbling(ev) + this.listEl?.selectByFocus() + /** + * Close list + */ + } else if (ev.key === 'Tab' || isEscapeKey(ev)) { + this.popupUtil.collapseList() + } + } else { + /** + * Open list + */ + if (isEnterKey(ev) || isSpaceKey(ev)) { + stopEventBubbling(ev) + this.popupUtil.expandList() + } + } + } else { + // Close the popup on autofill + if (this.isExpanded) { + this.popupUtil.collapseList() + } + } + } + + /** + * RENDER + * ------------------------------------------------------ + */ + + render() { + const block = BEM.block('dropdown') + + const isFilled = this.valueUtil.isFilled() + + const hasSize = this.size !== '' + const size = `size-${this.size}` + + const hasTheme = this.theme !== '' + const theme = `theme-${this.theme}` + + const focused = this.theme === 'purple' ? this.hasFocus && this.isKeyboardMode : this.hasFocus + + return ( + +
this.eventsUtil.handleClick(ev)} + > + + this.valueUtil.removeOption(option)} + > + 0 ? '' : this.placeholder} + expanded={this.isExpanded} + invalid={this.invalid} + language={this.language} + inputLabel={this.inputLabel} + inheritedAttributes={this.inheritedAttributes} + refInputEl={el => (this.nativeEl = el)} + onInput={ev => this.handleInput(ev)} + onChange={ev => this.handleAutoFill(ev)} + onFocus={ev => this.eventsUtil.handleFocus(ev)} + onBlur={ev => this.eventsUtil.handleBlur(ev)} + onKeyDown={ev => this.handleKeyDown(ev)} + > + + + (el ? (this.selectEl = el) : void 0)} + > + +
+ (el ? (this.panelEl = el) : void 0)} + refListEl={el => (el ? (this.listEl = el) : void 0)} + > + + +
+ ) + } +} + +let balTypeaheadIds = 0 diff --git a/packages/core/src/components/bal-typeahead/bal-typeahead.vars.sass b/packages/core/src/components/bal-typeahead/bal-typeahead.vars.sass new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/packages/core/src/components/bal-typeahead/bal-typeahead.vars.sass @@ -0,0 +1 @@ + diff --git a/packages/core/src/components/bal-typeahead/test/bal-typeahead.a11y.html b/packages/core/src/components/bal-typeahead/test/bal-typeahead.a11y.html new file mode 100644 index 0000000000..f3920a70de --- /dev/null +++ b/packages/core/src/components/bal-typeahead/test/bal-typeahead.a11y.html @@ -0,0 +1,34 @@ + + + + + + + + + + + + + +
+ + + + Year + + + 1988 + 1989 + 1990 + 1991 + 1992 + + + + + +
+
+ + diff --git a/packages/core/src/components/bal-typeahead/test/bal-typeahead.auto-fill.html b/packages/core/src/components/bal-typeahead/test/bal-typeahead.auto-fill.html new file mode 100644 index 0000000000..7f16d3dab5 --- /dev/null +++ b/packages/core/src/components/bal-typeahead/test/bal-typeahead.auto-fill.html @@ -0,0 +1,59 @@ + + + + + + + + + + + + + +
+

Autocomplete

+
+
+ + + + First Name + + + + + + + + Last Name + + + + + + + + Country + + + Switzerland + Germany + Italy + + + + + + + +
+
+
+
+ + diff --git a/packages/core/src/components/bal-typeahead/test/bal-typeahead.cy.html b/packages/core/src/components/bal-typeahead/test/bal-typeahead.cy.html new file mode 100644 index 0000000000..f3d4b16bb1 --- /dev/null +++ b/packages/core/src/components/bal-typeahead/test/bal-typeahead.cy.html @@ -0,0 +1,221 @@ + + + + + + + + + + + + + +
+

Basic

+
+ + Iron Man + Captain America + Thor + Hulk + Black Widow + Hawkeye + Spider-Man + Black Panther + Doctor Strange + Scarlet Witch + Vision + Falcon + Winter Soldier + Ant-Man + Wasp + Star-Lord + Gamora + Drax + Groot + Rocket + + + + Iron Man + Captain America + Thor + Hulk + Black Widow + Hawkeye + Spider-Man + Black Panther + Doctor Strange + Scarlet Witch + Vision + Falcon + Winter Soldier + Ant-Man + Wasp + Star-Lord + Gamora + Drax + Groot + Rocket + + + + Star-Lord + Gamora + Drax + Groot + Rocket + +
+

Required

+
+ + Star-Lord + Gamora + Drax + Groot + Rocket + +
+

Multiple

+
+ + + Black Widow + S.H.I.E.L.D. + + + Black Panter + Wakanda + + + Iron Man + Malibu + + + Spider Man + Queens + + + Captain America + Broklyn + + + Thor God of Thunder + Asgard + + +
+

Form Submit

+
+
+ + + + Country + + + + Black Widow + S.H.I.E.L.D. + + + Black Panter + Wakanda + + + Iron Man + Malibu + + + Spider Man + Queens + + + Captain America + Broklyn + + + Thor God of Thunder + Asgard + + + + + + + + Country + + + Switzerland + Germany + Italy + + + + + + + +
+
+

Autocomplete

+
+
+ + + + First Name + + + + + + + + Last Name + + + + + + + + Country + + + Switzerland + Germany + Italy + + + + + + + +
+
+
+
+ + diff --git a/packages/core/src/components/bal-typeahead/test/bal-typeahead.visual.html b/packages/core/src/components/bal-typeahead/test/bal-typeahead.visual.html new file mode 100644 index 0000000000..f18186a815 --- /dev/null +++ b/packages/core/src/components/bal-typeahead/test/bal-typeahead.visual.html @@ -0,0 +1,270 @@ + + + + + + + + + + + + + +
+

Basic

+
+ + 1988 + 1989 + 1990 + 1991 + 1992 + 1993 + 1994 + 1995 + 1996 + 1997 + 1998 + 1999 + 2000 + 2001 + 2002 + 2003 + 2004 + 2005 + 2006 + 2007 + +
+

Long Content

+
+ + + Black Widow + Black Widow is a 2021 American superhero film based on Marvel Comics featuring the character of the + same name. + + + Black Panter + Wakanda + + + Iron Man + Malibu + + + Spider Man + Queens + + + Captain America + Broklyn + + + Thor God of Thunder + Asgard + + +
+

Multiple

+
+ + 1988 + 1989 + 1990 + 1991 + 1992 + 1993 + 1994 + 1995 + 1996 + 1997 + 1998 + 1999 + 2000 + 2001 + 2002 + 2003 + 2004 + 2005 + 2006 + 2007 + +
+

Multiple with Chips

+
+ + 1988 + 1989 + 1990 + 1991 + 1992 + 1993 + 1994 + 1995 + 1996 + 1997 + 1998 + 1999 + 2000 + 2001 + 2002 + 2003 + 2004 + 2005 + 2006 + 2007 + +
+

Clearable

+
+ + 1988 + 1989 + 1990 + 1991 + 1992 + +
+

Loading

+
+ + 1988 + 1989 + 1990 + 1991 + 1992 + +
+

Invalid

+
+ + 1988 + 1989 + + + 1988 + 1989 + + + 1988 + 1989 + +
+

Disabled

+
+ + 1988 + 1989 + + + 1988 + 1989 + + + 1988 + 1989 + +
+

Form Field

+
+ + Form Label + + + 1988 + 1989 + 1990 + 1991 + 1992 + 1993 + 1994 + 1995 + 1996 + 1997 + 1998 + 1999 + 2000 + 2001 + 2002 + 2003 + 2004 + 2005 + 2006 + 2007 + + + +
+

Small Purple Theme

+
+
+ + 100 PS With a long label + 140 PS + 165 PS + 210 PS + + + 100 PS + 140 PS + 165 PS + 210 PS + +
+
+ + 100 PS + 140 PS + 165 PS + 210 PS + + + 100 PS + 140 PS + 165 PS + 210 PS + +
+
+
+
+ + diff --git a/packages/core/src/utils/dropdown/auto-fill.ts b/packages/core/src/utils/dropdown/auto-fill.ts index 1688e50e90..f83bde5752 100644 --- a/packages/core/src/utils/dropdown/auto-fill.ts +++ b/packages/core/src/utils/dropdown/auto-fill.ts @@ -23,6 +23,7 @@ export class DropdownAutoFillUtil { if (!areArraysEqual(newValue, this.component.rawValue)) { this.component.valueUtil.updateRawValueBySelection(newValue, true) + this.component.popupUtil.collapseList() } } } diff --git a/packages/core/src/utils/dropdown/component.ts b/packages/core/src/utils/dropdown/component.ts index 0322b72d7b..9ac77c6a09 100644 --- a/packages/core/src/utils/dropdown/component.ts +++ b/packages/core/src/utils/dropdown/component.ts @@ -1,11 +1,13 @@ import { EventEmitter } from '@stencil/core' -import { DropdownValueUtil } from './value' -import { BalOption } from './option' import { DropdownFocus } from './focus' +import { DropdownMode } from './mode' +import { BalOption } from './option' import { DropdownPopupUtil } from './popup' +import { DropdownValueUtil } from './value' export type DropdownComponent = DropdownFocus & { el: HTMLElement + mode: DropdownMode selectEl: HTMLSelectElement | undefined panelEl: HTMLDivElement | undefined nativeEl: HTMLInputElement | undefined @@ -32,6 +34,7 @@ export type DropdownComponent = DropdownFocus & { rawValue: string[] value?: string | string[] initialValue?: string | string[] + typeaheadValue?: string panelCleanup?: () => void balChange: EventEmitter diff --git a/packages/core/src/utils/dropdown/dropdown.i18n.ts b/packages/core/src/utils/dropdown/dropdown.i18n.ts index 3b88a11802..9ec9df8683 100644 --- a/packages/core/src/utils/dropdown/dropdown.i18n.ts +++ b/packages/core/src/utils/dropdown/dropdown.i18n.ts @@ -4,6 +4,7 @@ interface I18nBalDropdown { clearable: string open: string close: string + noOptions: string } export const i18nBalDropdown: I18n = { @@ -11,50 +12,60 @@ export const i18nBalDropdown: I18n = { clearable: 'Löschen', open: 'Öffnen', close: 'Schließen', + noOptions: 'Kein Treffer gefunden.', }, en: { clearable: 'clear', open: 'Open', close: 'Close', + noOptions: 'No matches found.', }, fr: { clearable: 'Effacer', open: 'Ouvrir', close: 'Fermer', + noOptions: 'Aucun résultat trouvé.', }, it: { clearable: 'Cancellare', open: 'Apri', close: 'Chiudi', + noOptions: 'Nessun risultato trovato.', }, nl: { clearable: 'Wissen', open: 'Open', close: 'Sluiten', + noOptions: 'Geen resultaat gevonden.', }, es: { clearable: 'Limpiar', open: 'Abrir', close: 'Cerrar', + noOptions: 'No se han encontrado resultados.', }, pl: { clearable: 'Wyczyść', open: 'Otwórz', close: 'Zamknij', + noOptions: 'Nie znaleziono wyników.', }, pt: { clearable: 'Limpar', open: 'Abrir', close: 'Fechar', + noOptions: 'Nenhum resultado encontrado.', }, sv: { clearable: 'Rensa', open: 'Öppna', close: 'Stäng', + noOptions: 'Inga träffar hittades.', }, fi: { clearable: 'Tyhjennä', open: 'Avaa', close: 'Sulje', + noOptions: 'Tuloksia ei löytynyt.', }, } diff --git a/packages/core/src/utils/dropdown/events.ts b/packages/core/src/utils/dropdown/events.ts index aab8763e6d..cc3d8a8c34 100644 --- a/packages/core/src/utils/dropdown/events.ts +++ b/packages/core/src/utils/dropdown/events.ts @@ -48,11 +48,27 @@ export class DropdownEventsUtil { const targetEl = ev.target as HTMLElement const clearEl = targetEl.closest('.bal-dropdown__clear') if (clearEl) { + if (this.component.mode === 'typeahead' && this.component.nativeEl) { + this.component.nativeEl.value = '' + this.component.typeaheadValue = '' + } this.component.valueUtil.updateRawValueBySelection([]) return } } + if (this.component.mode === 'typeahead' && this.component.nativeEl) { + const targetEl = ev.target as HTMLElement + + if (targetEl === this.component.nativeEl && this.component.isExpanded) { + return + } + + if (!this.component.isExpanded) { + this.component.nativeEl.select() + } + } + this.component.popupUtil.toggleList() } } diff --git a/packages/core/src/utils/dropdown/icon.tsx b/packages/core/src/utils/dropdown/icon.tsx index 6401f375d8..37b6e37e57 100644 --- a/packages/core/src/utils/dropdown/icon.tsx +++ b/packages/core/src/utils/dropdown/icon.tsx @@ -32,7 +32,12 @@ export const DropdownIcon: FunctionalComponent = ({ if (loading) { return ( - + ) } else if (clearable && filled && !disabled) { return ( diff --git a/packages/core/src/utils/dropdown/input.tsx b/packages/core/src/utils/dropdown/input.tsx index b68591acf7..8b6c9446c5 100644 --- a/packages/core/src/utils/dropdown/input.tsx +++ b/packages/core/src/utils/dropdown/input.tsx @@ -1,16 +1,18 @@ -import { h, FunctionalComponent } from '@stencil/core' -import { BEM } from '../bem' -import { i18nBalDropdown } from './dropdown.i18n' +import { FunctionalComponent, h } from '@stencil/core' import { ariaBooleanToString } from '../aria' -import { BalAriaForm } from '../form' -import { BalLanguage } from '../config' import { Attributes } from '../attributes' +import { BEM } from '../bem' +import { BalLanguage } from '../config' +import { BalAriaForm } from '../form' +import { i18nBalDropdown } from './dropdown.i18n' +import { DropdownMode } from './mode' export interface DropdownInputProps { + mode: DropdownMode inputId: string httpFormSubmit: boolean ariaForm: BalAriaForm - rawValue: string[] + value: string autocomplete: string placeholder: string inputLabel: string @@ -22,6 +24,7 @@ export interface DropdownInputProps { language: BalLanguage inheritedAttributes: Attributes refInputEl: (el: HTMLInputElement) => void + onInput?: (ev: InputEvent) => void onChange: (ev: Event) => void onFocus: (ev: FocusEvent) => void onBlur: (ev: FocusEvent) => void @@ -29,10 +32,11 @@ export interface DropdownInputProps { } export const DropdownInput: FunctionalComponent = ({ + mode, inputId, httpFormSubmit, ariaForm, - rawValue, + value, autocomplete, required, disabled, @@ -44,6 +48,7 @@ export const DropdownInput: FunctionalComponent = ({ inputLabel, inheritedAttributes, refInputEl, + onInput, onChange, onFocus, onBlur, @@ -56,13 +61,25 @@ export const DropdownInput: FunctionalComponent = ({ id={ariaForm.controlId || `${inputId}-ctrl`} class={{ ...block.element('root').element('input').class(), + ...block + .element('root') + .element('input') + .modifier('typeahead') + .class(mode === DropdownMode.Typeahead), }} + {...(mode === DropdownMode.Typeahead + ? { + style: { + 'min-width': placeholder ? `${placeholder.length}ch` : '4rem', + }, + } + : {})} type="text" size={1} inputmode="none" tabindex="0" autoComplete={autocomplete} - value={rawValue.join(',')} + value={value} required={required} disabled={disabled} readonly={readonly} @@ -77,8 +94,9 @@ export const DropdownInput: FunctionalComponent = ({ aria-haspopup={'listbox'} data-native data-label={inputLabel} - data-value={rawValue.join(',')} - ref={el => refInputEl(el)} + data-value={value} + ref={el => (el ? refInputEl(el) : void 0)} + {...(onInput ? { onInput: ev => onInput(ev) } : {})} onChange={ev => onChange(ev)} onFocus={ev => onFocus(ev)} onBlur={ev => onBlur(ev)} diff --git a/packages/core/src/utils/dropdown/mode.ts b/packages/core/src/utils/dropdown/mode.ts new file mode 100644 index 0000000000..3d225139f0 --- /dev/null +++ b/packages/core/src/utils/dropdown/mode.ts @@ -0,0 +1,4 @@ +export enum DropdownMode { + Basic = 'basic', + Typeahead = 'typeahead', +} diff --git a/packages/core/src/utils/dropdown/option-list.tsx b/packages/core/src/utils/dropdown/option-list.tsx index ce65cb6f10..83e3801b3a 100644 --- a/packages/core/src/utils/dropdown/option-list.tsx +++ b/packages/core/src/utils/dropdown/option-list.tsx @@ -1,8 +1,10 @@ import { FunctionalComponent, h } from '@stencil/core' -import { BalOption } from './option' import { BEM } from '../bem' +import { DropdownMode } from './mode' +import { BalOption } from './option' export interface DropdownOptionListProps { + mode: DropdownMode inputId: string block: string filter: BalProps.BalOptionListFilter @@ -18,6 +20,7 @@ export interface DropdownOptionListProps { } export const DropdownOptionList: FunctionalComponent = ({ + mode, inputId, isExpanded, rawOptions, @@ -38,6 +41,10 @@ export const DropdownOptionList: FunctionalComponent = class={{ ...block.element('list').class(), ...block.element('list').modifier('expanded').class(isExpanded), + ...block + .element('list') + .modifier('typeahead') + .class(mode === DropdownMode.Typeahead), }} ref={refPanelEl} > diff --git a/packages/core/src/utils/dropdown/option.tsx b/packages/core/src/utils/dropdown/option.tsx index 9cdf29cc9f..3b6e6bb46c 100644 --- a/packages/core/src/utils/dropdown/option.tsx +++ b/packages/core/src/utils/dropdown/option.tsx @@ -1,4 +1,4 @@ -import { h } from '@stencil/core' +import { rIC } from '../helpers' import { DropdownComponent } from './component' export type BalBaseOption = { @@ -83,12 +83,22 @@ export class DropdownOptionUtil { async listenToOptionChange(ev: BalEvents.BalOptionChange) { const newSelectedValues = (await this.component.listEl?.getSelectedValues()) || [] this.component.valueUtil.updateRawValueBySelection(newSelectedValues) + if (!this.component.multiple) { this.component.popupUtil.collapseList() if (this.component.hasFocus) { this.component.balBlur.emit(new FocusEvent('blur', { relatedTarget: this.component.el })) this.component.hasFocus = false } + } else { + if (this.component.mode === 'typeahead' && this.component.nativeEl) { + this.component.typeaheadValue = '' + this.component.nativeEl.value = '' + rIC(() => { + this.component.listEl?.filterByContent(this.component.typeaheadValue) + this.component.nativeEl.focus() + }) + } } } } diff --git a/packages/core/src/utils/dropdown/popup.ts b/packages/core/src/utils/dropdown/popup.ts index d69a3825bc..ca573dcaf8 100644 --- a/packages/core/src/utils/dropdown/popup.ts +++ b/packages/core/src/utils/dropdown/popup.ts @@ -16,7 +16,6 @@ export class DropdownPopupUtil { }) .then(({ x, y }) => { Object.assign(floatingEl.style, { - left: `${x}px`, top: `${y}px`, }) }) @@ -43,6 +42,14 @@ export class DropdownPopupUtil { } this.component.isExpanded = true await this.component.listEl?.focusSelected() + + /** + * If the dropdown is in typeahead mode, we want to focus the input element + * and select its content to allow the user to start typing immediately. + */ + if (this.component.mode === 'typeahead' && this.component.nativeEl) { + await this.component.nativeEl?.focus() + } } collapseList() { diff --git a/packages/core/src/utils/dropdown/value.tsx b/packages/core/src/utils/dropdown/value.tsx index 6486684baa..5d3b01b3b2 100644 --- a/packages/core/src/utils/dropdown/value.tsx +++ b/packages/core/src/utils/dropdown/value.tsx @@ -1,10 +1,11 @@ import { FunctionalComponent, h } from '@stencil/core' -import { areArraysEqual } from '../../utils/array' import isNil from 'lodash.isnil' -import { DropdownComponent } from './component' +import { areArraysEqual } from '../../utils/array' import { BEM } from '../bem' -import { BalOption } from './option' import { waitAfterFramePaint } from '../helpers' +import { DropdownComponent } from './component' +import { DropdownMode } from './mode' +import { BalOption } from './option' export class DropdownValueUtil { private component!: DropdownComponent @@ -98,15 +99,18 @@ export class DropdownValueUtil { await waitAfterFramePaint() if (this.component.listEl) { this.component.choices = await this.component.listEl.getSelectedOptions(this.component.rawValue) - this.component.inputLabel = this.component.choices - .map(option => option.label.trim()) - .sort() - .join(',') + if (this.component.mode === DropdownMode.Basic) { + this.component.inputLabel = this.component.choices + .map(option => option.label.trim()) + .sort() + .join(',') + } } } } export interface DropdownValueProps { + mode: DropdownMode inlineLabel: string filled: boolean chips: boolean @@ -118,19 +122,11 @@ export interface DropdownValueProps { onRemoveChip: (option: BalOption) => void } -export const DropdownValue: FunctionalComponent = ({ - inlineLabel, - filled, - chips, - placeholder, - choices, - invalid, - disabled, - readonly, - onRemoveChip, -}) => { +export const DropdownValue: FunctionalComponent = ( + { mode, inlineLabel, filled, chips, placeholder, choices, invalid, disabled, readonly, onRemoveChip }, + children, +) => { const block = BEM.block('dropdown') - if (filled) { if (chips) { return ( @@ -148,12 +144,13 @@ export const DropdownValue: FunctionalComponent = ({ {option.label} ))} + {children} ) } else { return (inlineLabel && `${inlineLabel}: `) + choices.map(option => option.label).join(', ') } } else { - return placeholder + return mode === DropdownMode.Basic ? placeholder : children } }