import { __decorate } from "tslib";
import { DOM, Observable, attr, html, observable, ref } from '@ni/fast-element';
import { DesignSystem, ComboboxAutocomplete, SelectPosition, ListboxOption, DelegatesARIACombobox, applyMixins, StartEnd } from '@ni/fast-foundation';
import { keyArrowDown, keyArrowUp, keyEnter, keyEscape, keySpace, keyTab, limit, uniqueId } from '@ni/fast-web-utilities';
import { ToggleButton, toggleButtonTag } from '../toggle-button';
import { errorTextTemplate } from '../patterns/error/template';
import { iconArrowExpanderDownTag } from '../icons/arrow-expander-down';
import { iconExclamationMarkTag } from '../icons/exclamation-mark';
import { styles } from './styles';
import { mixinErrorPattern } from '../patterns/error/types';
import { DropdownAppearance } from '../patterns/dropdown/types';
import { template } from './template';
import { FormAssociatedCombobox } from './models/combobox-form-associated';
import { mixinRequiredVisiblePattern } from '../patterns/required-visible/types';
/**
 * A nimble-styed HTML combobox
 */
export class Combobox extends mixinErrorPattern(mixinRequiredVisiblePattern(FormAssociatedCombobox)) {
    constructor() {
        super(...arguments);
        this.appearance = DropdownAppearance.underline;
        this.appearanceReadOnly = false;
        this.fullBleed = false;
        /**
         * The open attribute.
         */
        this.open = false;
        /**
         * @internal
         *
         * The collection of currently filtered options.
         */
        this.filteredOptions = [];
        /** @internal */
        this.hasOverflow = false;
        /**
         * The unique id for the internal listbox element.
         *
         * @internal
         */
        this.listboxId = uniqueId('listbox-');
        /**
         * The space available in the viewport for the listbox when opened.
         *
         * @internal
         */
        this.availableViewportHeight = 0;
        this.valueUpdatedByInput = false;
        this._value = '';
        this.filter = '';
        /**
         * The initial state of the position attribute.
         */
        this.forcedPosition = false;
    }
    get value() {
        Observable.track(this, 'value');
        return this._value;
    }
    set value(next) {
        const prev = this._value;
        let updatedValue = next;
        if (this.$fastController.isConnected) {
            const selectedIndex = this.findIndexOfValidOption(next);
            const prevSelectedValue = this.options[this.selectedIndex]?.text;
            const nextSelectedValue = this.options[selectedIndex]?.text;
            if (prevSelectedValue !== nextSelectedValue) {
                this.selectedIndex = selectedIndex;
            }
            updatedValue = this.firstSelectedOption?.text || updatedValue;
        }
        if (prev !== updatedValue) {
            this._value = updatedValue;
            super.valueChanged(prev, updatedValue);
            Observable.notify(this, 'value');
        }
        // Can remove when following resolved: https://github.com/microsoft/fast/issues/6749
        this.filter = next;
        this.filterOptions();
        this.selectedIndex = this.findIndexOfValidOption(this.value);
    }
    /**
     * The list of options.
     *
     * Overrides `Listbox.options`.
     */
    get options() {
        Observable.track(this, 'options');
        return this.filter
            ? this.filteredOptions
            : this._options;
    }
    set options(value) {
        this._options = value;
        Observable.notify(this, 'options');
    }
    get isAutocompleteInline() {
        return (this.autocomplete === ComboboxAutocomplete.inline
            || this.isAutocompleteBoth);
    }
    get isAutocompleteList() {
        return (this.autocomplete === ComboboxAutocomplete.list
            || this.isAutocompleteBoth);
    }
    get isAutocompleteBoth() {
        return this.autocomplete === ComboboxAutocomplete.both;
    }
    slottedOptionsChanged(prev, next) {
        // Workaround for https://github.com/microsoft/fast/issues/5773
        const value = this.value;
        super.slottedOptionsChanged(prev, next);
        this.updateValue();
        if (value) {
            this.value = value;
        }
    }
    connectedCallback() {
        super.connectedCallback();
        this.forcedPosition = !!this.positionAttribute;
        if (this.value) {
            this.initialValue = this.value;
        }
        this.setPositioning();
        this.updateInputAriaLabel();
    }
    /**
     * @internal
     */
    clickHandler(e) {
        if (this.disabled) {
            return false;
        }
        if (this.open) {
            const captured = e.target.closest('option,[role=option]');
            if (!captured || captured.disabled) {
                return false;
            }
            this.selectedOptions = [captured];
            this.control.value = captured.text;
            this.clearSelectionRange();
            this.updateValue(true);
        }
        this.open = !this.open;
        if (this.open) {
            this.control.focus();
        }
        return true;
    }
    /**
     * @internal
     */
    toggleButtonClickHandler(e) {
        e.stopImmediatePropagation();
    }
    /**
     * @internal
     */
    toggleButtonChangeHandler(e) {
        this.open = this.dropdownButton.checked;
        e.stopImmediatePropagation();
    }
    /**
     * @internal
     */
    toggleButtonKeyDownHandler(e) {
        switch (e.key) {
            case keyArrowUp:
            case keyArrowDown:
            case keySpace:
            case keyEnter:
                this.open = true;
                this.stopPropagation(e);
                return false;
            default:
                return true;
        }
    }
    /**
     * @internal
     */
    filterOptions() {
        if (!this.autocomplete
            || this.autocomplete === ComboboxAutocomplete.none) {
            this.filter = '';
        }
        const filter = this.filter.toLowerCase();
        this.filteredOptions = this._options.filter(o => o.text.toLowerCase().startsWith(filter) && !o.hidden);
        if (this.isAutocompleteList) {
            this._options.forEach(o => {
                o.visuallyHidden = !this.filteredOptions.includes(o);
            });
        }
    }
    /**
     * @internal
     */
    inputHandler(e) {
        this.filter = this.control.value;
        this.filterOptions();
        if (!this.isAutocompleteInline) {
            this.selectedIndex = this.findIndexOfValidOption(this.control.value);
        }
        if (!e.inputType.includes('deleteContent') && this.filter.length > 0) {
            if (this.isAutocompleteList && !this.open) {
                this.open = true;
            }
            if (this.isAutocompleteInline) {
                if (this.filteredOptions.length > 0) {
                    this.selectedOptions = [this.filteredOptions[0]];
                    this.selectedIndex = this.options.indexOf(this.firstSelectedOption);
                    this.setInlineSelection();
                }
                else {
                    this.selectedIndex = -1;
                }
            }
        }
        // This is a workaround for the issue described here: https://github.com/microsoft/fast/issues/6267
        // For now, we will update the value ourselves while a user types in text. Note that there is other
        // implementation related to this (like the 'keydownEventHandler') needed to create the complete set
        // of desired behavior described in the issue noted above.
        if (!this.valueUpdatedByInput) {
            this.valueBeforeTextUpdate = this.value;
        }
        this.valueUpdatedByInput = true;
        // This is a workaround for this FAST issue: https://github.com/microsoft/fast/issues/6776
        if (this.value !== this.control.value) {
            this.focusAndScrollOptionIntoView();
        }
        this.value = this.control.value;
        return true;
    }
    keydownHandler(e) {
        if (e.ctrlKey || e.altKey) {
            return true;
        }
        switch (e.key) {
            case keyEnter:
                this.syncValue();
                if (this.isAutocompleteInline) {
                    this.filter = this.value;
                }
                this.open = false;
                this.clearSelectionRange();
                this.emitChangeIfValueUpdated();
                break;
            case keyEscape:
                if (!this.isAutocompleteInline) {
                    this.selectedIndex = -1;
                }
                if (this.open) {
                    this.open = false;
                    break;
                }
                this.value = '';
                this.control.value = '';
                this.filter = '';
                this.filterOptions();
                break;
            case keyTab:
                this.setInputToSelection();
                if (!this.open) {
                    return true;
                }
                e.preventDefault();
                this.open = false;
                break;
            case keyArrowDown:
            case keyArrowUp:
                this.filterOptions();
                if (!this.open) {
                    this.open = true;
                    break;
                }
                if (this.filteredOptions.length > 0) {
                    super.keydownHandler(e);
                }
                if (this.isAutocompleteInline) {
                    this.setInlineSelection();
                }
                if (this.open && this.valueUpdatedByInput) {
                    this.valueUpdatedByInput = false;
                }
                break;
            default:
                return true;
        }
        return true;
    }
    /**
     * @internal
     */
    keyupHandler(e) {
        const key = e.key;
        switch (key) {
            case 'ArrowLeft':
            case 'ArrowRight':
            case 'Backspace':
            case 'Delete':
            case 'Home':
            case 'End': {
                this.filter = this.control.value;
                this.selectedIndex = -1;
                this.filterOptions();
                break;
            }
            default: {
                break;
            }
        }
        return true;
    }
    /**
     * @internal
     */
    focusoutHandler(e) {
        this.syncValue();
        if (this.open) {
            const focusTarget = e.relatedTarget;
            if (this.isSameNode(focusTarget)) {
                this.focus();
            }
        }
        this.open = false;
        this.emitChangeIfValueUpdated();
        return true;
    }
    /**
     * Reset the element to its first selectable option when its parent form is reset.
     *
     * @internal
     */
    formResetCallback() {
        super.formResetCallback();
        this.setDefaultSelectedOption();
        this.updateValue();
    }
    /** {@inheritDoc (FormAssociated:interface).validate} */
    validate() {
        super.validate(this.control);
    }
    /**
     * Set the default selected options at initialization or reset.
     *
     * @internal
     * @remarks
     * Overrides `Listbox.setDefaultSelectedOption`
     */
    setDefaultSelectedOption() {
        if (this.$fastController.isConnected) {
            const selectedIndex = this.options.findIndex(el => !el.disabled
                && (el.getAttribute('selected') !== null || el.selected));
            this.selectedIndex = selectedIndex;
            if (!this.dirtyValue && this.firstSelectedOption !== null) {
                this.value = this.firstSelectedOption.text;
            }
            this.setSelectedOptions();
        }
    }
    /**
     * @internal
     */
    selectedIndexChanged(prev, next) {
        if (this.$fastController.isConnected) {
            let pinnedSelectedIndex = limit(-1, this.options.length - 1, next);
            // Ensure selectedIndex doesn't get set to a disabled option
            if (this.options[pinnedSelectedIndex]?.disabled) {
                pinnedSelectedIndex = -1;
            }
            // we only want to call the super method when the selectedIndex is in range
            if (pinnedSelectedIndex !== this.selectedIndex) {
                this.selectedIndex = pinnedSelectedIndex;
                return;
            }
            super.selectedIndexChanged(prev, pinnedSelectedIndex);
            // the base class doesn't call this when no option is selected, but we need to,
            // otherwise selectedOptions, ariaActiveDescendant, and the previously selected
            // option's selected state won't be updated
            this.setSelectedOptions();
        }
    }
    /**
     * Synchronize the `aria-disabled` property when the `disabled` property changes.
     *
     * @internal
     */
    disabledChanged(prev, next) {
        if (super.disabledChanged) {
            super.disabledChanged(prev, next);
        }
        this.ariaDisabled = this.disabled ? 'true' : 'false';
    }
    /**
     * Move focus to the next selectable option.
     *
     * @internal
     * @remarks Has the same behavior as `Listbox.selectNextOption` except it skips disabled options.
     * Overrides `Listbox.selectNextOption`
     */
    selectNextOption() {
        if (!this.disabled) {
            let newIndex = this.selectedIndex;
            do {
                if (newIndex + 1 >= this.options.length) {
                    return;
                }
                newIndex += 1;
            } while (this.options[newIndex].disabled);
            this.selectedIndex = newIndex;
        }
    }
    /**
     * Move focus to the previous selectable option.
     *
     * @internal
     * @remarks Has the same behavior as `Listbox.selectPreviousOption` except it skips disabled options and allows moving focus to the input.
     * Overrides `Listbox.selectPreviousOption`
     */
    selectPreviousOption() {
        if (!this.disabled) {
            let newIndex = this.selectedIndex;
            do {
                newIndex -= 1;
                if (newIndex < 0) {
                    break;
                }
            } while (this.options[newIndex].disabled);
            this.selectedIndex = newIndex;
        }
    }
    /**
     * @internal
     */
    setPositioning() {
        // Workaround for https://github.com/microsoft/fast/issues/5123
        if (!this.$fastController.isConnected) {
            // Don't call setPositioning() until we're connected,
            // since this.forcedPosition isn't initialized yet.
            return;
        }
        const currentBox = this.getBoundingClientRect();
        const viewportHeight = window.innerHeight;
        const availableBottom = viewportHeight - currentBox.bottom;
        if (this.forcedPosition) {
            this.position = this.positionAttribute;
        }
        else if (currentBox.top > availableBottom) {
            this.position = SelectPosition.above;
        }
        else {
            this.position = SelectPosition.below;
        }
        this.positionAttribute = this.forcedPosition
            ? this.positionAttribute
            : this.position;
        this.availableViewportHeight = this.position === SelectPosition.above
            ? Math.trunc(currentBox.top)
            : Math.trunc(availableBottom);
    }
    /**
     * Focus the control and scroll the first selected option into view.
     *
     * @internal
     * @remarks
     * Overrides: `Listbox.focusAndScrollOptionIntoView`
     */
    focusAndScrollOptionIntoView() {
        if (this.open) {
            if (this.contains(document.activeElement)) {
                this.control.focus();
                if (this.firstSelectedOption !== null) {
                    requestAnimationFrame(() => {
                        this.firstSelectedOption?.scrollIntoView({
                            block: 'nearest'
                        });
                    });
                }
            }
        }
    }
    openChanged() {
        if (this.open) {
            this.ariaControls = this.listboxId;
            this.ariaExpanded = 'true';
            this.setPositioning();
            this.focusAndScrollOptionIntoView();
            // focus is directed to the element when `open` is changed programmatically
            DOM.queueUpdate(() => this.focus());
        }
        else {
            this.ariaControls = '';
            this.ariaExpanded = 'false';
        }
        if (this.dropdownButton) {
            this.dropdownButton.checked = this.open;
        }
    }
    placeholderChanged() {
        if (this.proxy instanceof HTMLInputElement) {
            this.proxy.placeholder = this.placeholder ?? '';
        }
    }
    /**
     * Need to update even when options is empty.
     * @internal
     * @remarks Same as `Listbox.setSelectedOptions` except does not check if options is non-empty.
     * Overrides: `Listbox.setSelectedOptions`
     */
    setSelectedOptions() {
        this.selectedOptions = this.selectedIndex > -1 ? [this.options[this.selectedIndex]] : [];
        this.ariaActiveDescendant = this.firstSelectedOption?.id ?? '';
        this.focusAndScrollOptionIntoView();
    }
    /**
     * Ensure that the entire list of options is used when setting the selected property.
     * @internal
     * @remarks
     * Overrides: `Listbox.selectedOptionsChanged`
     */
    selectedOptionsChanged(_, next) {
        if (this.$fastController.isConnected) {
            this._options.forEach(o => {
                o.selected = next.includes(o);
            });
        }
    }
    positionChanged(_, next) {
        this.positionAttribute = next;
        this.setPositioning();
    }
    regionChanged(_prev, _next) {
        if (this.region) {
            this.region.anchorElement = this.controlWrapper;
        }
    }
    controlWrapperChanged(_prev, _next) {
        if (this.region) {
            this.region.anchorElement = this.controlWrapper;
        }
    }
    // Workaround for https://github.com/microsoft/fast/issues/6041.
    ariaLabelChanged(_oldValue, _newValue) {
        this.updateInputAriaLabel();
    }
    /**
     * Sets the value and to match the first selected option.
     */
    updateValue(shouldEmit) {
        if (this.$fastController.isConnected) {
            this.value = this.firstSelectedOption?.text || this.control.value;
            this.control.value = this.value;
        }
        if (shouldEmit) {
            this.$emit('change');
        }
    }
    /**
     * Focus and set the content of the control based on the first selected option.
     */
    setInputToSelection() {
        if (this.firstSelectedOption !== null) {
            this.control.value = this.firstSelectedOption.text;
            this.control.focus();
        }
    }
    /**
     * Focus, set and select the content of the control based on the first selected option.
     */
    setInlineSelection() {
        if (this.firstSelectedOption !== null) {
            this.setInputToSelection();
            this.control.setSelectionRange(this.filter.length, this.control.value.length, 'backward');
        }
    }
    clearSelectionRange() {
        const controlValueLength = this.control.value.length;
        this.control.setSelectionRange(controlValueLength, controlValueLength);
    }
    /**
     * Determines if a value update should involve emitting a change event, then updates the value.
     */
    syncValue() {
        const newValue = this.selectedIndex > -1
            ? this.firstSelectedOption?.text
            : this.control.value;
        this.updateValue(this.value !== newValue);
    }
    updateInputAriaLabel() {
        const inputElement = this.shadowRoot?.querySelector('.selected-value');
        if (this.ariaLabel) {
            inputElement?.setAttribute('aria-label', this.ariaLabel);
        }
        else {
            inputElement?.removeAttribute('aria-label');
        }
    }
    /**
     * This will only emit a `change` event after text entry where the text in the input prior to
     * typing is different than the text present upon an attempt to commit (e.g. pressing <Enter>).
     * So, for a concrete example:
     * 1) User types 'Sue' (when Combobox input was blank).
     * 2) User presses <Enter> -> 'change' event fires
     * 3) User deletes 'Sue'
     * 4) User re-types 'Sue'
     * 5) User presses <Enter> -> NO 'change' event is fired
     */
    emitChangeIfValueUpdated() {
        if (this.valueUpdatedByInput) {
            if (this.value !== this.valueBeforeTextUpdate) {
                this.$emit('change');
            }
            this.valueUpdatedByInput = false;
        }
    }
    findIndexOfValidOption(optionText) {
        return this.options.findIndex(o => !o.disabled && o.text === optionText);
    }
}
__decorate([
    attr
], Combobox.prototype, "appearance", void 0);
__decorate([
    attr({ attribute: 'appearance-readonly', mode: 'boolean' })
], Combobox.prototype, "appearanceReadOnly", void 0);
__decorate([
    attr({ attribute: 'full-bleed', mode: 'boolean' })
], Combobox.prototype, "fullBleed", void 0);
__decorate([
    attr({ attribute: 'autocomplete', mode: 'fromView' })
], Combobox.prototype, "autocomplete", void 0);
__decorate([
    attr({ attribute: 'position' })
], Combobox.prototype, "positionAttribute", void 0);
__decorate([
    attr({ attribute: 'open', mode: 'boolean' })
], Combobox.prototype, "open", void 0);
__decorate([
    attr
], Combobox.prototype, "placeholder", void 0);
__decorate([
    observable
], Combobox.prototype, "position", void 0);
__decorate([
    observable
], Combobox.prototype, "region", void 0);
__decorate([
    observable
], Combobox.prototype, "controlWrapper", void 0);
__decorate([
    observable
], Combobox.prototype, "control", void 0);
__decorate([
    observable
], Combobox.prototype, "listbox", void 0);
__decorate([
    observable
], Combobox.prototype, "dropdownButton", void 0);
__decorate([
    observable
], Combobox.prototype, "filteredOptions", void 0);
__decorate([
    observable
], Combobox.prototype, "hasOverflow", void 0);
__decorate([
    observable
], Combobox.prototype, "availableViewportHeight", void 0);
const nimbleCombobox = Combobox.compose({
    baseName: 'combobox',
    baseClass: FormAssociatedCombobox,
    template,
    styles,
    shadowOptions: {
        delegatesFocus: true
    },
    end: html `
        <div class="end-slot-container">
            <${iconExclamationMarkTag}
                severity="error"
                class="error-icon"
            ></${iconExclamationMarkTag}>
            <div class="separator"></div>
            <${toggleButtonTag}
                ${ref('dropdownButton')}
                appearance="ghost"
                ?checked="${x => x.open}"
                ?disabled="${x => x.disabled}"
                content-hidden="true"
                @click="${(x, c) => x.toggleButtonClickHandler(c.event)}"
                @change="${(x, c) => x.toggleButtonChangeHandler(c.event)}"
                @keydown="${(x, c) => x.toggleButtonKeyDownHandler(c.event)}"
                class="dropdown-button"
                part="button"
                aria-hidden="true"
                tabindex="-1"
            >
                <${iconArrowExpanderDownTag}
                    slot="start"
                    class="dropdown-icon"
                >
                </${iconArrowExpanderDownTag}>
            </${toggleButtonTag}>
        </div>
        ${errorTextTemplate}
    `
});
applyMixins(Combobox, StartEnd, DelegatesARIACombobox);
DesignSystem.getOrCreate().withPrefix('nimble').register(nimbleCombobox());
export const comboboxTag = 'nimble-combobox';
//# sourceMappingURL=index.js.map