Ionic/core/src/components/searchbar/searchbar.tsx

625 lines
18 KiB
TypeScript

import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Component, Element, Event, Host, Method, Prop, State, Watch, forceUpdate, h } from '@stencil/core';
import { arrowBackSharp, closeCircle, closeSharp, searchOutline, searchSharp } from 'ionicons/icons';
import { config } from '../../global/config';
import { getIonMode } from '../../global/ionic-global';
import type { AutocompleteTypes, Color, SearchbarChangeEventDetail, StyleEventDetail } from '../../interface';
import { debounceEvent, raf } from '../../utils/helpers';
import { isRTL } from '../../utils/rtl';
import { createColorClasses } from '../../utils/theme';
/**
* @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use.
*/
@Component({
tag: 'ion-searchbar',
styleUrls: {
ios: 'searchbar.ios.scss',
md: 'searchbar.md.scss',
},
scoped: true,
})
export class Searchbar implements ComponentInterface {
private nativeInput?: HTMLInputElement;
private isCancelVisible = false;
private shouldAlignLeft = true;
private originalIonInput?: EventEmitter<KeyboardEvent | null>;
/**
* The value of the input when the textarea is focused.
*/
private focusedValue?: string | null;
@Element() el!: HTMLIonSearchbarElement;
@State() focused = false;
@State() noAnimate = true;
/**
* The color to use from your application's color palette.
* Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`.
* For more information on colors, see [theming](/docs/theming/basics).
*/
@Prop({ reflect: true }) color?: Color;
/**
* If `true`, enable searchbar animation.
*/
@Prop() animated = false;
/**
* Set the input's autocomplete property.
*/
@Prop() autocomplete: AutocompleteTypes = 'off';
/**
* Set the input's autocorrect property.
*/
@Prop() autocorrect: 'on' | 'off' = 'off';
/**
* Set the cancel button icon. Only applies to `md` mode.
* Defaults to `arrow-back-sharp`.
*/
@Prop() cancelButtonIcon = config.get('backButtonIcon', arrowBackSharp) as string;
/**
* Set the the cancel button text. Only applies to `ios` mode.
*/
@Prop() cancelButtonText = 'Cancel';
/**
* Set the clear icon. Defaults to `close-circle` for `ios` and `close-sharp` for `md`.
*/
@Prop() clearIcon?: string;
/**
* Set the amount of time, in milliseconds, to wait to trigger the `ionInput` event after each keystroke.
*/
@Prop() debounce?: number;
@Watch('debounce')
protected debounceChanged() {
const { ionInput, debounce, originalIonInput } = this;
/**
* If debounce is undefined, we have to manually revert the ionInput emitter in case
* debounce used to be set to a number. Otherwise, the event would stay debounced.
*/
this.ionInput = debounce === undefined ? originalIonInput ?? ionInput : debounceEvent(ionInput, debounce);
}
/**
* If `true`, the user cannot interact with the input.
*/
@Prop() disabled = false;
/**
* A hint to the browser for which keyboard to display.
* Possible values: `"none"`, `"text"`, `"tel"`, `"url"`,
* `"email"`, `"numeric"`, `"decimal"`, and `"search"`.
*/
@Prop() inputmode?: 'none' | 'text' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search';
/**
* A hint to the browser for which enter key to display.
* Possible values: `"enter"`, `"done"`, `"go"`, `"next"`,
* `"previous"`, `"search"`, and `"send"`.
*/
@Prop() enterkeyhint?: 'enter' | 'done' | 'go' | 'next' | 'previous' | 'search' | 'send';
/**
* Set the input's placeholder.
* `placeholder` can accept either plaintext or HTML as a string.
* To display characters normally reserved for HTML, they
* must be escaped. For example `<Ionic>` would become
* `&lt;Ionic&gt;`
*
* For more information: [Security Documentation](https://ionicframework.com/docs/faq/security)
*/
@Prop() placeholder = 'Search';
/**
* The icon to use as the search icon. Defaults to `search-outline` in
* `ios` mode and `search-sharp` in `md` mode.
*/
@Prop() searchIcon?: string;
/**
* Sets the behavior for the cancel button. Defaults to `"never"`.
* Setting to `"focus"` shows the cancel button on focus.
* Setting to `"never"` hides the cancel button.
* Setting to `"always"` shows the cancel button regardless
* of focus state.
*/
@Prop() showCancelButton: 'never' | 'focus' | 'always' = 'never';
/**
* Sets the behavior for the clear button. Defaults to `"focus"`.
* Setting to `"focus"` shows the clear button on focus if the
* input is not empty.
* Setting to `"never"` hides the clear button.
* Setting to `"always"` shows the clear button regardless
* of focus state, but only if the input is not empty.
*/
@Prop() showClearButton: 'never' | 'focus' | 'always' = 'always';
/**
* If `true`, enable spellcheck on the input.
*/
@Prop() spellcheck = false;
/**
* Set the type of the input.
*/
@Prop() type: 'text' | 'password' | 'email' | 'number' | 'search' | 'tel' | 'url' = 'search';
/**
* the value of the searchbar.
*/
@Prop({ mutable: true }) value?: string | null = '';
/**
* Emitted when the `value` of the `ion-searchbar` element has changed.
*/
@Event() ionInput!: EventEmitter<KeyboardEvent | null>;
/**
* The `ionChange` event is fired for `<ion-searchbar>` elements when the user
* modifies the element's value. Unlike the `ionInput` event, the `ionChange`
* event is not necessarily fired for each alteration to an element's value.
*
* The `ionChange` event is fired when the element loses focus after its value
* has been modified. This includes modifications made when clicking the clear
* or cancel buttons.
*/
@Event() ionChange!: EventEmitter<SearchbarChangeEventDetail>;
/**
* Emitted when the cancel button is clicked.
*/
@Event() ionCancel!: EventEmitter<void>;
/**
* Emitted when the clear input button is clicked.
*/
@Event() ionClear!: EventEmitter<void>;
/**
* Emitted when the input loses focus.
*/
@Event() ionBlur!: EventEmitter<void>;
/**
* Emitted when the input has focus.
*/
@Event() ionFocus!: EventEmitter<void>;
/**
* Emitted when the styles change.
* @internal
*/
@Event() ionStyle!: EventEmitter<StyleEventDetail>;
@Watch('value')
protected valueChanged() {
const inputEl = this.nativeInput;
const value = this.getValue();
if (inputEl && inputEl.value !== value) {
inputEl.value = value;
}
}
@Watch('showCancelButton')
protected showCancelButtonChanged() {
requestAnimationFrame(() => {
this.positionElements();
forceUpdate(this);
});
}
connectedCallback() {
this.emitStyle();
}
componentDidLoad() {
this.originalIonInput = this.ionInput;
this.positionElements();
this.debounceChanged();
setTimeout(() => {
this.noAnimate = false;
}, 300);
}
private emitStyle() {
this.ionStyle.emit({
searchbar: true,
});
}
/**
* Sets focus on the specified `ion-searchbar`. Use this method instead of the global
* `input.focus()`.
*/
@Method()
async setFocus() {
if (this.nativeInput) {
this.nativeInput.focus();
}
}
/**
* Returns the native `<input>` element used under the hood.
*/
@Method()
getInputElement(): Promise<HTMLInputElement> {
return Promise.resolve(this.nativeInput!);
}
/**
* Emits an `ionChange` event.
*
* This API should be called for user committed changes.
* This API should not be used for external value changes.
*/
private emitValueChange() {
const { value } = this;
// Checks for both null and undefined values
const newValue = value == null ? value : value.toString();
// Emitting a value change should update the internal state for tracking the focused value
this.focusedValue = newValue;
this.ionChange.emit({ value: newValue });
}
/**
* Clears the input field and triggers the control change.
*/
private onClearInput = async (shouldFocus?: boolean) => {
this.ionClear.emit();
return new Promise<void>((resolve) => {
// setTimeout() fixes https://github.com/ionic-team/ionic/issues/7527
// wait for 4 frames
setTimeout(() => {
const value = this.getValue();
if (value !== '') {
this.value = '';
this.ionInput.emit(null);
/**
* When tapping clear button
* ensure input is focused after
* clearing input so users
* can quickly start typing.
*/
if (shouldFocus && !this.focused) {
this.setFocus();
/**
* The setFocus call above will clear focusedValue,
* but ionChange will never have gotten a chance to
* fire. Manually revert focusedValue so onBlur can
* compare against what was in the box before the clear.
*/
this.focusedValue = value;
}
}
resolve();
}, 16 * 4);
});
};
/**
* Clears the input field and tells the input to blur since
* the clearInput function doesn't want the input to blur
* then calls the custom cancel function if the user passed one in.
*/
private onCancelSearchbar = async (ev?: Event) => {
if (ev) {
ev.preventDefault();
ev.stopPropagation();
}
this.ionCancel.emit();
// get cached values before clearing the input
const value = this.getValue();
const focused = this.focused;
await this.onClearInput();
/**
* If there used to be something in the box, and we weren't focused
* beforehand (meaning no blur fired that would already handle this),
* manually fire ionChange.
*/
if (value && !focused) {
this.emitValueChange();
}
if (this.nativeInput) {
this.nativeInput.blur();
}
};
/**
* Update the Searchbar input value when the input changes
*/
private onInput = (ev: Event) => {
const input = ev.target as HTMLInputElement | null;
if (input) {
this.value = input.value;
}
this.ionInput.emit(ev as KeyboardEvent);
};
private onChange = () => {
this.emitValueChange();
};
/**
* Sets the Searchbar to not focused and checks if it should align left
* based on whether there is a value in the searchbar or not.
*/
private onBlur = () => {
this.focused = false;
this.ionBlur.emit();
this.positionElements();
if (this.focusedValue !== this.value) {
this.emitValueChange();
}
this.focusedValue = undefined;
};
/**
* Sets the Searchbar to focused and active on input focus.
*/
private onFocus = () => {
this.focused = true;
this.focusedValue = this.value;
this.ionFocus.emit();
this.positionElements();
};
/**
* Positions the input search icon, placeholder, and the cancel button
* based on the input value and if it is focused. (ios only)
*/
private positionElements() {
const value = this.getValue();
const prevAlignLeft = this.shouldAlignLeft;
const mode = getIonMode(this);
const shouldAlignLeft = !this.animated || value.trim() !== '' || !!this.focused;
this.shouldAlignLeft = shouldAlignLeft;
if (mode !== 'ios') {
return;
}
if (prevAlignLeft !== shouldAlignLeft) {
this.positionPlaceholder();
}
if (this.animated) {
this.positionCancelButton();
}
}
/**
* Positions the input placeholder
*/
private positionPlaceholder() {
const inputEl = this.nativeInput;
if (!inputEl) {
return;
}
const rtl = isRTL(this.el);
const iconEl = (this.el.shadowRoot || this.el).querySelector('.searchbar-search-icon') as HTMLElement;
if (this.shouldAlignLeft) {
inputEl.removeAttribute('style');
iconEl.removeAttribute('style');
} else {
// Create a dummy span to get the placeholder width
const doc = document;
const tempSpan = doc.createElement('span');
tempSpan.innerText = this.placeholder || '';
doc.body.appendChild(tempSpan);
// Get the width of the span then remove it
raf(() => {
const textWidth = tempSpan.offsetWidth;
tempSpan.remove();
// Calculate the input padding
const inputLeft = 'calc(50% - ' + textWidth / 2 + 'px)';
// Calculate the icon margin
const iconLeft = 'calc(50% - ' + (textWidth / 2 + 30) + 'px)';
// Set the input padding start and icon margin start
if (rtl) {
inputEl.style.paddingRight = inputLeft;
iconEl.style.marginRight = iconLeft;
} else {
inputEl.style.paddingLeft = inputLeft;
iconEl.style.marginLeft = iconLeft;
}
});
}
}
/**
* Show the iOS Cancel button on focus, hide it offscreen otherwise
*/
private positionCancelButton() {
const rtl = isRTL(this.el);
const cancelButton = (this.el.shadowRoot || this.el).querySelector('.searchbar-cancel-button') as HTMLElement;
const shouldShowCancel = this.shouldShowCancelButton();
if (cancelButton !== null && shouldShowCancel !== this.isCancelVisible) {
const cancelStyle = cancelButton.style;
this.isCancelVisible = shouldShowCancel;
if (shouldShowCancel) {
if (rtl) {
cancelStyle.marginLeft = '0';
} else {
cancelStyle.marginRight = '0';
}
} else {
const offset = cancelButton.offsetWidth;
if (offset > 0) {
if (rtl) {
cancelStyle.marginLeft = -offset + 'px';
} else {
cancelStyle.marginRight = -offset + 'px';
}
}
}
}
}
private getValue() {
return this.value || '';
}
private hasValue(): boolean {
return this.getValue() !== '';
}
/**
* Determines whether or not the cancel button should be visible onscreen.
* Cancel button should be shown if one of two conditions applies:
* 1. `showCancelButton` is set to `always`.
* 2. `showCancelButton` is set to `focus`, and the searchbar has been focused.
*/
private shouldShowCancelButton(): boolean {
if (this.showCancelButton === 'never' || (this.showCancelButton === 'focus' && !this.focused)) {
return false;
}
return true;
}
/**
* Determines whether or not the clear button should be visible onscreen.
* Clear button should be shown if one of two conditions applies:
* 1. `showClearButton` is set to `always`.
* 2. `showClearButton` is set to `focus`, and the searchbar has been focused.
*/
private shouldShowClearButton(): boolean {
if (this.showClearButton === 'never' || (this.showClearButton === 'focus' && !this.focused)) {
return false;
}
return true;
}
render() {
const { cancelButtonText } = this;
const animated = this.animated && config.getBoolean('animated', true);
const mode = getIonMode(this);
const clearIcon = this.clearIcon || (mode === 'ios' ? closeCircle : closeSharp);
const searchIcon = this.searchIcon || (mode === 'ios' ? searchOutline : searchSharp);
const shouldShowCancelButton = this.shouldShowCancelButton();
const cancelButton = this.showCancelButton !== 'never' && (
<button
aria-label={cancelButtonText}
// Screen readers should not announce button if it is not visible on screen
aria-hidden={shouldShowCancelButton ? undefined : 'true'}
type="button"
tabIndex={mode === 'ios' && !shouldShowCancelButton ? -1 : undefined}
onMouseDown={this.onCancelSearchbar}
onTouchStart={this.onCancelSearchbar}
class="searchbar-cancel-button"
>
<div aria-hidden="true">
{mode === 'md' ? (
<ion-icon aria-hidden="true" mode={mode} icon={this.cancelButtonIcon} lazy={false}></ion-icon>
) : (
cancelButtonText
)}
</div>
</button>
);
return (
<Host
role="search"
aria-disabled={this.disabled ? 'true' : null}
class={createColorClasses(this.color, {
[mode]: true,
'searchbar-animated': animated,
'searchbar-disabled': this.disabled,
'searchbar-no-animate': animated && this.noAnimate,
'searchbar-has-value': this.hasValue(),
'searchbar-left-aligned': this.shouldAlignLeft,
'searchbar-has-focus': this.focused,
'searchbar-should-show-clear': this.shouldShowClearButton(),
'searchbar-should-show-cancel': this.shouldShowCancelButton(),
})}
>
<div class="searchbar-input-container">
<input
aria-label="search text"
disabled={this.disabled}
ref={(el) => (this.nativeInput = el)}
class="searchbar-input"
inputMode={this.inputmode}
enterKeyHint={this.enterkeyhint}
onInput={this.onInput}
onChange={this.onChange}
onBlur={this.onBlur}
onFocus={this.onFocus}
placeholder={this.placeholder}
type={this.type}
value={this.getValue()}
autoComplete={this.autocomplete}
autoCorrect={this.autocorrect}
spellcheck={this.spellcheck}
/>
{mode === 'md' && cancelButton}
<ion-icon
aria-hidden="true"
mode={mode}
icon={searchIcon}
lazy={false}
class="searchbar-search-icon"
></ion-icon>
<button
aria-label="reset"
type="button"
no-blur
class="searchbar-clear-button"
onPointerDown={(ev) => {
/**
* This prevents mobile browsers from
* blurring the input when the clear
* button is activated.
*/
ev.preventDefault();
}}
onClick={() => this.onClearInput(true)}
>
<ion-icon
aria-hidden="true"
mode={mode}
icon={clearIcon}
lazy={false}
class="searchbar-clear-icon"
></ion-icon>
</button>
</div>
{mode === 'ios' && cancelButton}
</Host>
);
}
}