diff --git a/packages/components/package.json b/packages/components/package.json index 1b71d89d08..401081fb94 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -73,6 +73,7 @@ "@telekom/design-tokens": "^1.0.0-beta.4", "@telekom/scale-design-tokens": "^3.0.0-beta.134", "classnames": "^2.2.6", + "highlight-words-core": "^1.2.2", "stencil-inline-svg": "^1.0.1" } } diff --git a/packages/components/src/components/search-input/search-input.css b/packages/components/src/components/search-input/search-input.css index c792d86a9f..814f1a7d51 100644 --- a/packages/components/src/components/search-input/search-input.css +++ b/packages/components/src/components/search-input/search-input.css @@ -40,6 +40,7 @@ --input-font-family: inherit; --input-font-size: var(--telekom-typography-font-size-body); --input-background-color: var(--telekom-color-ui-state-fill-standard); + --input-color: var(--telekom-color-text-and-icon-standard); /* interactive-icon */ --interactive-icon-color: var(--telekom-color-text-and-icon-additional); @@ -104,6 +105,7 @@ font-family: var(--input-font-family); font-size: var(--input-font-size); background-color: var(--input-background-color); + color: var(--input-color); } [part~='interactive-icon'] { @@ -112,6 +114,10 @@ color: var(--interactive-icon-color); } +[part~='label'] { + visibility: hidden; +} + [part~='clear-icon-button'] { margin-left: var(--clear-icon-button-margin-left); margin-right: var(--clear-icon-button-margin-right); diff --git a/packages/components/src/components/search-input/search-input.tsx b/packages/components/src/components/search-input/search-input.tsx index 73f8a8e496..e02eee6484 100644 --- a/packages/components/src/components/search-input/search-input.tsx +++ b/packages/components/src/components/search-input/search-input.tsx @@ -22,10 +22,6 @@ import { import classNames from 'classnames'; import { emitEvent, generateUniqueId } from '../../utils/utils'; -interface InputChangeEventDetail { - value: string | undefined | null; -} - @Component({ tag: 'scale-search-input', styleUrl: './search-input.css', @@ -33,8 +29,12 @@ interface InputChangeEventDetail { }) export class SearchInput { @Element() hostElement: HTMLElement; + /** (optional) Input name */ - @Prop() name?: string = 'Search'; + @Prop() name?: string = 'search'; + + /** (optional) Input label */ + @Prop() label?: string = 'Search'; /** (optional) Input status */ @Prop() invalid?: boolean = false; /** (optional) Input text string max length */ @@ -48,34 +48,23 @@ export class SearchInput { /** (optional) Input required */ @Prop() required?: boolean; /** (optional) Input value */ - @Prop({ mutable: true }) value?: string | null = ''; - /** (optional) Input id */ - @Prop() inputId?: string; + @Prop({ mutable: true, reflect: true }) value?: string | null = ''; /** (optional) input background transparent */ @Prop() transparent?: boolean; /** (optional) the input should automatically get focus when the page loads. */ @Prop() inputAutofocus?: boolean; /** (optional) custom value for autocomplete HTML attribute */ - @Prop() inputAutocomplete?: string; /** (optional) id or space separated list of ids of elements that provide or link to additional related information. */ @Prop() ariaDetailedId?: string; - /** (optional) to avoid displaying the label */ - @Prop() hideLabelVisually?: boolean = false; - /** (optional) Injected CSS styles */ - @Prop() styles?: string; + /** (optional)) Makes type `input` behave as a controlled component in React */ @Prop() experimentalControlled?: boolean = false; - /** Emitted when a keyboard input occurred. */ - @Event({ eventName: 'scale-input' }) scaleInput!: EventEmitter; - /** Emitted when the value has changed. */ - @Event({ eventName: 'scale-change' }) - scaleChange!: EventEmitter; + @Prop() innerAriaExpanded: string; + @Prop({ mutable: true, reflect: true }) inputId: string; /** Emitted when the input has focus. */ @Event({ eventName: 'scale-focus' }) scaleFocus!: EventEmitter; /** Emitted when the input loses focus. */ @Event({ eventName: 'scale-blur' }) scaleBlur!: EventEmitter; - /** Emitted when the input has focus. */ - @Event({ eventName: 'scale-focus-out' }) scaleFocusout!: EventEmitter; /** Emitted on keydown. */ @Event({ eventName: 'scale-keydown' }) scaleKeyDown!: EventEmitter; @@ -89,7 +78,14 @@ export class SearchInput { /** "forceUpdate" hack, set it to trigger and re-render */ @State() forceUpdate: string; + /** (optional) Input helper text */ + @Prop() helperText?: string = ''; + /** (optional) Variant */ + @Prop() variant?: 'informational' | 'warning' | 'danger' | 'success' = + 'informational'; + private readonly internalId = generateUniqueId(); + private inputElement: HTMLInputElement; componentWillLoad() { if (this.inputId == null) { @@ -98,57 +94,29 @@ export class SearchInput { } componentDidRender() { - // When `experimentalControlled` is true, - // make sure the is always in sync with the value. - const value = this.value == null ? '' : this.value.toString(); - const input = this.hostElement.querySelector('input'); - if (this.experimentalControlled && input.value.toString() !== value) { - input.value = value; - } + // // When `experimentalControlled` is true, + // // make sure the is always in sync with the value. + // const value = this.value == null ? '' : this.value.toString(); + // const input = this.hostElement.querySelector('input'); + // if (this.experimentalControlled && input.value.toString() !== value) { + // input.value = value; + // } } - handleInput = (event: Event) => { - const target = event.target as HTMLInputElement | null; - if (target) { - this.value = target.value || ''; - this.emitChange(); - } - if (this.experimentalControlled) { - this.hostElement.querySelector('input').value = String(this.value); - this.forceUpdate = String(Date.now()); - } - emitEvent(this, 'scaleInput', event as KeyboardEvent); - }; - - handleChange = (event: Event) => { - const target = event.target as HTMLInputElement | null; - if (target) { - this.value = target.value || ''; - this.emitChange(); - } - }; - handleFocus = () => { this.hasFocus = true; emitEvent(this, 'scaleFocus'); }; - handleFocusout = () => { - this.hasFocus = false; - emitEvent(this, 'scaleFocusout'); - }; - - emitChange() { - emitEvent(this, 'scaleChange', { - value: this.value == null ? this.value : this.value.toString(), - }); - } - emitBlur = () => { + this.hasFocus = false; emitEvent(this, 'scaleBlur'); }; emitKeyDown = (event: KeyboardEvent) => { + setTimeout(() => { + this.value = this.inputElement?.value; + }); emitEvent(this, 'scaleKeyDown', event); }; @@ -161,7 +129,7 @@ export class SearchInput { (this.value = '')} + onClick={() => (this.inputElement.value = '')} > - {this.styles && }
+ (this.inputElement = ref)} + aria-owns={`${this.inputId}-listbox`} + aria-expanded={this.innerAriaExpanded} + aria-labelledby={`${this.inputId}-label`} + aria-haspopup="listbox" + aria-autocomplete="list" type="search" inputMode="search" part="input" + role="combobox" placeholder={this.placeholder} value={this.value} {...(!!this.name ? { name: this.name } : {})} @@ -196,17 +177,16 @@ export class SearchInput { minLength={this.minLength} maxLength={this.maxLength} id={this.inputId} - onInput={this.handleInput} - onChange={this.handleChange} onFocus={this.handleFocus} - onFocusout={this.handleFocusout} onKeyDown={this.emitKeyDown} onBlur={this.emitBlur} disabled={this.disabled} - autocomplete={this.inputAutocomplete} + autocomplete="off" {...ariaDetailedById} + {...ariaInvalidAttr} + {...(this.helperText ? ariaDescribedByAttr : {})} > - {this.value ? ( + {this.inputElement?.value ? ( this.getClearIconButton() ) : (
)}
+ {this.helperText && ( + + )} ); } diff --git a/packages/components/src/components/search-list-box/search-list-box.css b/packages/components/src/components/search-list-box/search-list-box.css new file mode 100644 index 0000000000..214ffe2668 --- /dev/null +++ b/packages/components/src/components/search-list-box/search-list-box.css @@ -0,0 +1,40 @@ +:host { + /*listbox*/ + --background-listbox: var(--telekom-color-background-surface); + --box-shadow-listbox: 0 2px 4px 0 rgba(0, 0, 0, 0.1), + 0 4px 16px 0 rgba(0, 0, 0, 0.1); + --max-height-listbox: 300px; + --z-index-listbox: 99; + --radius: var(--telekom-radius-standard); +} +/*listbox*/ +[part='listbox'] { + position: relative; +} + +[part='listbox-scroll-container'] { + max-height: var(--max-height-listbox); + overflow-y: auto; +} + +[part='listbox-pad'] { + background: var(--background-listbox); + box-shadow: var(--box-shadow-listbox); + border-radius: var(--radius); + padding: var(--radius) 0; + margin-top: var(--telekom-line-weight-highlight); + left: 0; + position: absolute; + top: 100%; + width: 100%; + z-index: var(--z-index-listbox); + display: none; +} + +:host([open]) [part='listbox-pad'] { + display: block; +} + +[part~='transparent'] [part='listbox'] { + background-color: transparent; +} diff --git a/packages/components/src/components/search-list-box/search-list-box.tsx b/packages/components/src/components/search-list-box/search-list-box.tsx new file mode 100644 index 0000000000..74cde32963 --- /dev/null +++ b/packages/components/src/components/search-list-box/search-list-box.tsx @@ -0,0 +1,33 @@ +import { Component, h, Host, Prop } from '@stencil/core'; + +@Component({ + tag: 'scale-search-list-box', + styleUrl: 'search-list-box.css', + shadow: true, +}) +export class SearchListBox { + @Prop() open?: boolean; + @Prop() comboboxId?: string = 'combobox'; + @Prop() refListBoxPadEl: any; + @Prop() refListBoxEl: any; + + render() { + return ( + +
+
+
+ +
+
+
+
+ ); + } +} diff --git a/packages/components/src/components/search-list-category/search-list-category.css b/packages/components/src/components/search-list-category/search-list-category.css new file mode 100644 index 0000000000..19f2fa481b --- /dev/null +++ b/packages/components/src/components/search-list-category/search-list-category.css @@ -0,0 +1,11 @@ +:host { +} + +:host [part='head'] { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 8px 8px 16px; + color: var(--telekom-color-text-and-icon-additional); + font: var(--telekom-text-style-small-bold); +} diff --git a/packages/components/src/components/search-list-category/search-list-category.tsx b/packages/components/src/components/search-list-category/search-list-category.tsx new file mode 100644 index 0000000000..69d68a7026 --- /dev/null +++ b/packages/components/src/components/search-list-category/search-list-category.tsx @@ -0,0 +1,26 @@ +import { Component, Element, h, Host, Prop } from '@stencil/core'; + +@Component({ + tag: 'scale-search-list-category', + styleUrl: 'search-list-category.css', + shadow: true, +}) +export class SearchListCategory { + @Element() hostElement: HTMLElement; + + @Prop() refListBoxEl: any; + + render() { + return ( + +
+
+ + +
+ +
+
+ ); + } +} diff --git a/packages/components/src/components/search-list-item/search-list-item.css b/packages/components/src/components/search-list-item/search-list-item.css index 561cee9d6a..67475a10c6 100644 --- a/packages/components/src/components/search-list-item/search-list-item.css +++ b/packages/components/src/components/search-list-item/search-list-item.css @@ -1,6 +1,5 @@ :host { /* base */ - --width: 100%; --background-hover: var(--telekom-color-ui-state-fill-hovered); --font-weight: var(--telekom-typography-font-weight-medium); --letter-spacing-standard: var(--telekom-typography-letter-spacing-standard); @@ -18,10 +17,18 @@ /* clear-icon */ --clear-icon-color: var(--telekom-color-text-and-icon-standard); + cursor: pointer; + + --focus-outline: var(--telekom-line-weight-highlight) solid + var(--telekom-color-functional-focus-standard); +} + +[part~='base']:focus-visible { + outline: var(--focus-outline); + outline-offset: -2px; } [part~='base'] { - width: var(--width); font-family: var(--font-family); font-weight: var(--font-weight); letter-spacing: var(--letter-spacing-standard); @@ -31,6 +38,7 @@ padding: 8px 8px 8px 16px; } +[part~='highlighted'], [part~='base'].scale-search-list-item-hover:hover { background-color: var(--background-hover); } @@ -74,11 +82,3 @@ padding-right: 8px; margin-top: 4px; } - -[part~='clear-icon-button'] { - --background: transparent; -} - -[part~='clear-icon'] { - color: var(--clear-icon-color); -} diff --git a/packages/components/src/components/search-list-item/search-list-item.tsx b/packages/components/src/components/search-list-item/search-list-item.tsx index 177a5d61a9..614e8e9777 100644 --- a/packages/components/src/components/search-list-item/search-list-item.tsx +++ b/packages/components/src/components/search-list-item/search-list-item.tsx @@ -1,4 +1,12 @@ -import { Component, Element, h, Host, Prop } from '@stencil/core'; +import { + Component, + Element, + h, + Host, + Prop, + State, + Method, +} from '@stencil/core'; import classNames from 'classnames'; import { emitEvent } from '../../utils/utils'; @@ -7,7 +15,7 @@ import { emitEvent } from '../../utils/utils'; styleUrl: 'search-list-item.css', shadow: true, }) -export class SearchSelectItem { +export class SearchListItem { @Element() hostElement: HTMLElement; /** (optional) is close button to be shown */ @@ -15,6 +23,12 @@ export class SearchSelectItem { /** (optional) The buttons to be shown on Hover or always */ @Prop() variant?: 'always' | 'hover' = 'hover'; + @State() isHighlighted = false; + + @Method() + async highlight(toggle) { + this.isHighlighted = toggle; + } /** * Handles click event for close button. @@ -46,17 +60,26 @@ export class SearchSelectItem { ); } + connectedCallback() { + this.hostElement.setAttribute('role', 'option'); + } + render() { return ( -
+
- +
@@ -64,11 +87,7 @@ export class SearchSelectItem {
- {this.dismissible ? ( - this.getClearIconButton() - ) : ( - - )} +
diff --git a/packages/components/src/components/search/search.tsx b/packages/components/src/components/search/search.tsx new file mode 100644 index 0000000000..d6d77fe7bc --- /dev/null +++ b/packages/components/src/components/search/search.tsx @@ -0,0 +1,298 @@ +// https://github.com/krisk/Fuse +import { + Component, + h, + Host, + Prop, + Element, + State, + Event, + EventEmitter, + Listen, + Watch, +} from '@stencil/core'; +import { computePosition } from '@floating-ui/dom'; +import { findAll } from 'highlight-words-core'; + +enum Actions { + Close = 'Close', + CloseSelect = 'CloseSelect', + First = 'First', + Last = 'Last', + Next = 'Next', + Open = 'Open', + PageDown = 'PageDown', + PageUp = 'PageUp', + Previous = 'Previous', + Select = 'Select', + Type = 'Type', +} + +function getActionFromKey(event: KeyboardEvent, open: boolean) { + const { key, altKey, ctrlKey, metaKey } = event; + + if (!open && ['ArrowDown', 'ArrowUp', 'Enter'].includes(key)) { + return Actions['Open']; + } + + if (key === 'Home') { + return Actions['First']; + } + + if (key === 'End') { + return Actions['Last']; + } + + if ( + ['Backspace', 'Clear'].includes(key) || + (key.length === 1 && key !== ' ' && !altKey && !ctrlKey && !metaKey) + ) { + return Actions['Type']; + } + + if (!open) { + return; + } + + if (key === 'ArrowUp' && altKey) { + return Actions['CloseSelect']; + } + if (key === 'ArrowDown' && !altKey) { + return Actions['Next']; + } + + switch (key) { + case 'ArrowUp': + return Actions['Previous']; + case 'PageUp': + return Actions['PageUp']; + case 'PageDown': + return Actions['PageDown']; + case 'Escape': + return Actions['Close']; + case 'Enter': + return Actions['CloseSelect']; + case ' ': + return Actions['CloseSelect']; + } +} + +@Component({ + tag: 'scale-search', + shadow: true, +}) +export class Search { + @Element() hostElement: HTMLElement; + + @Prop() comboboxId?: string = 'combobox'; + @Prop() label: string; + @Prop() name?: string; + @Prop() helperText?: string = ''; + @Prop() disabled?: boolean; + @Prop() transparent?: boolean; + @Prop() variant?: 'informational' | 'warning' | 'danger' | 'success' = + 'informational'; + @Prop({ mutable: true, reflect: true }) value: any; + + @Event({ eventName: 'scale-change' }) scaleChange!: EventEmitter; + @Event({ eventName: 'scale-focus' }) scaleFocus!: EventEmitter; + @State() open: boolean = false; + @State() currentIndex: number = -1; + + private comboEl: HTMLElement; + private listboxPadEl: HTMLElement; + + get input() { + const slotFallback = this.comboEl.shadowRoot.querySelector('input'); + const slotted = this.hostElement.shadowRoot + .querySelector('slot[name=input]') + // @ts-ignore + ?.assignedNodes()[0]; + + return slotted || slotFallback; + } + + @Listen('scale-close-search', { target: 'window', capture: true }) + handleCloseUserMenu() { + this.setOpen(false); + } + + @Watch('value') + handleValueUpdate() { + this.updateHighlightedText(); + } + componentDidRender() { + if (!this.open) { + return; + } + computePosition(this.hostElement, this.listboxPadEl, { + placement: 'bottom', + }).then(({ x, y }) => { + Object.assign(this.listboxPadEl.style, { + left: `${x}px`, + top: `${y}px`, + }); + }); + } + + getItems = () => { + const allSearchItems = this.hostElement.querySelectorAll( + 'scale-search-list-item' + ); + return Array.from(allSearchItems, (x) => { + return { + element: x, + textContent: x.shadowRoot + .querySelector(`[part="label"] slot`) + // @ts-ignore + .assignedNodes()[0].innerText, + setTextContent: (textContent) => { + x.shadowRoot + .querySelector(`[part="label"] slot`) + // @ts-ignore + .assignedNodes()[0].innerHTML = textContent; + }, + }; + }); + }; + setOpen(open) { + if (this.open === open) { + return; + } + + if (this.disabled) { + return; + } + + this.comboEl.shadowRoot.querySelector('input').focus(); + this.open = open; + + if (!this.open) { + this.comboEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + this.comboEl.focus(); + } + } + + handleFocus = () => { + this.setOpen(true); + }; + + updateHighlightedText = (items = this.getItems()) => { + setTimeout(() => { + const inputValue = this.input.value; + + const searchWords = inputValue.split(' '); + + items.forEach((item) => { + const textToHighlight = item.textContent; + + const chunks = findAll({ + searchWords, + textToHighlight, + }); + + const highlightedText = chunks + .map((chunk) => { + const { end, highlight, start } = chunk; + const text = textToHighlight.substr(start, end - start); + if (highlight) { + return `${text}`; + } else { + return text; + } + }) + .join(''); + + if (highlightedText) { + item.setTextContent(highlightedText); + } + }); + }); + }; + + handleKeyDown = (event) => { + const action = getActionFromKey(event, this.open); + const items = this.getItems(); + + this.updateHighlightedText(items); + + switch (action) { + case Actions['Open']: + this.setOpen(true); + return; + case Actions['CloseSelect']: + case Actions['Close']: + this.setOpen(false); + return; + case Actions['Previous']: + this.currentIndex = this.currentIndex >= 1 ? this.currentIndex - 1 : 0; + this.handleHighlight(items); + return; + case Actions['Next']: + this.currentIndex = + this.currentIndex < items.length - 1 + ? this.currentIndex + 1 + : items.length - 1; + this.handleHighlight(items); + return; + } + }; + + handleHighlight = (items) => { + items.forEach((x, i) => { + x.element.highlight(i === this.currentIndex); + if (i === this.currentIndex) { + x.element.scrollIntoView(false); + this.input.value = x.textContent; + } + }); + }; + + render() { + return ( + +
+
+ { + this.input.ref = (el) => (this.comboEl = el); + this.input.inputId = this.comboboxId; + this.input.innerAriaExpanded = this.open ? 'true' : 'false'; + this.input.inputId = this.comboboxId; + + this.input.addEventListener('focus', this.handleFocus); + this.input.addEventListener('keydown', this.handleKeyDown); + }} + > + (this.comboEl = el)} + inputId={this.comboboxId} + inner-aria-expanded={this.open ? 'true' : 'false'} + onFocus={this.handleFocus} + onKeyDown={this.handleKeyDown} + label={this.label} + helperText={this.helperText} + name="search-input" + disabled={this.disabled} + transparent={this.transparent} + variant={this.variant} + value={this.value} + > + + + + + (this.listboxPadEl = el)} + > + + +
+
+
+ ); + } +} diff --git a/packages/components/src/html/index.html b/packages/components/src/html/index.html index 52252604d9..6d5aebbcd8 100644 --- a/packages/components/src/html/index.html +++ b/packages/components/src/html/index.html @@ -25,5 +25,66 @@

index.html

+ + + + Recent + Clear All + + dumbo + +
Supporting text
+ + + + +
+
+ + + Rest + Clear All + + dumbo + dumbo + dumbo + +
+ + diff --git a/packages/components/src/html/search.html b/packages/components/src/html/search.html new file mode 100644 index 0000000000..7d715a1fbc --- /dev/null +++ b/packages/components/src/html/search.html @@ -0,0 +1,302 @@ + + + + + + Stencil Component Starter + + + + + + + + + +

index.html

+ + + + + + + Special 1 + + + + + + + diff --git a/packages/components/src/html/search2.html b/packages/components/src/html/search2.html new file mode 100644 index 0000000000..d7a75fbba3 --- /dev/null +++ b/packages/components/src/html/search2.html @@ -0,0 +1,190 @@ + + + + + + Stencil Component Starter + + + + + + + +

index.html

+ + + Special 1 + + + + Special 2 + + + Special 3 + + + + + Recent + Clear All + + Item 1 + +
Supporting text
+ + + + +
+
+ + + Rest + Clear All + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + Item 6 + + + Item 7 + + + Item 8 + + + Item 9 + + + Item 10 + + + +
+ + + + diff --git a/yarn.lock b/yarn.lock index 440993f63f..829f0185a3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11257,6 +11257,11 @@ he@^1.1.0, he@^1.1.1, he@^1.2.0: resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz" integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== +highlight-words-core@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/highlight-words-core/-/highlight-words-core-1.2.2.tgz#1eff6d7d9f0a22f155042a00791237791b1eeaaa" + integrity sha512-BXUKIkUuh6cmmxzi5OIbUJxrG8OAk2MqoL1DtO3Wo9D2faJg2ph5ntyuQeLqaHJmzER6H5tllCDA9ZnNe9BVGg== + highlight.js@^10.1.1, highlight.js@~10.4.0: version "10.4.1" resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-10.4.1.tgz"