UNPKG

@govbr-ds/webcomponents

Version:

Biblioteca de Web Components baseado no GovBR-DS

567 lines (566 loc) 22.8 kB
/*! * Construído por SERPRO * © https://serpro.gov.br/ - MIT License. */ import { h } from "@stencil/core"; export class Select { /** * Referência ao elemento host do componente. * Utilize esta propriedade para acessar e manipular o elemento do DOM associado ao componente. */ el; elementInternals; /** * Rótulo que indica o tipo de informação que deve ser selecionada. */ label; /** * Texto auxiliar exibido antes de uma seleção. */ placeholder = ''; /** * Habilita o modo múltiplo para selecionar várias opções. */ isMultiple = false; /** * Define as opções disponíveis no componente de seleção. * Pode ser fornecida como: * - Uma **string** contendo um JSON válido que será analisado em um array de objetos. Exemplo: `'[{"label": "Opção 1", "value": "1"}]'. * - Um **array de objetos**, onde cada objeto representa uma opção com as propriedades: * - `label`: O texto exibido para o usuário (obrigatório). * - `value`: O valor associado à opção (obrigatório). * - `selected`: Um boolean opcional que indica se a opção está pré-selecionada (padrão: `false`). * Se uma string for fornecida, ela será convertida internamente para um array via `JSON.parse`. Caso o formato seja inválido, um erro será registrado e as opções serão definidas como um array vazio. * Exemplo de uso: * ```typescript * options='[{"label": "Sim", "value": "1"}, {"label": "Não", "value": "0"}]' * // ou * options=[{ label: "Sim", value: "1" }, { label: "Não", value: "0" }] * ``` */ options = []; /** * Rótulo para selecionar todas as opções. */ selectAllLabel = 'Selecionar todos'; /** * Rótulo para desmarcar todas as opções. */ unselectAllLabel = 'Desselecionar todos'; /** * Exibe o ícone de busca no campo de entrada. */ showSearchIcon = false; /** * Indica se a listagem de itens do select está expandida */ isOpen = false; /** * Identificador único. * Caso não seja fornecido, um ID gerado automaticamente será usado. */ customId = `br-select-${selectId++}`; // Representa as opções do select disponíveis para renderização. // Cada opção contém `label`, `value` e o estado de seleção (`selected`). parsedOptions = []; // Valor exibido no input, refletindo as opções selecionadas. inputValue = ''; // Indica se todas as opções estão selecionadas. Comportamento do checkbox "Selecionar Todos". isAllSelected = false; // Controla se a lista de opções do select está expandida (aberta) ou colapsada (fechada). isExpanded = false; // Armazena o índice do item atualmente focado, usado para navegação por teclado. focusedIndex = -1; /** * Evento emitido sempre que houver atualização nos itens selecionados. */ brDidSelectChange; synchronizeSelect() { try { let parsedOptions = []; if (typeof this.options === 'string') { try { const jsonData = JSON.parse(this.options); if (Array.isArray(jsonData)) { parsedOptions = jsonData; } else { throw new Error('JSON inválido: deve ser um array'); } } catch (error) { console.error('Erro ao converter options para JSON:', error.message); parsedOptions = []; } } else if (Array.isArray(this.options)) { parsedOptions = this.options; } // Sanitiza as opções (remove opções inválidas, etc.) this.parsedOptions = parsedOptions; // Atualiza o estado do componente this.updateSelectedOptions(); this.updateSelectedAllState(); this.emitSelectedOptions(); } catch (error) { console.error('Erro ao processar as opções do select:', error); this.parsedOptions = []; } } componentWillLoad() { this.isExpanded = this.isOpen; this.synchronizeSelect(); } componentDidLoad() { document.addEventListener('click', this.handleClickOutside); } disconnectedCallback() { document.removeEventListener('click', this.handleClickOutside); } /** * Fecha a lista suspensa (`dropdown`) do `br-select` quando o usuário clica fora do componente. * O listener global é adicionado no `componentDidLoad` e removido no `disconnectedCallback` para evitar vazamentos de memória. * Se a lista estiver aberta (`this.isExpanded`) e o clique ocorrer fora (`!this.el.contains(target)`), ela é fechada. * A reatividade do StencilJS garante a atualização automática da interface. * A arrow function (`=>`) mantém o contexto correto de `this` ao ser usada como callback. * * @param {MouseEvent} event O evento de clique. * @returns {void} */ handleClickOutside = (event) => { const target = event.target; if (this.isExpanded && !this.el.contains(target)) { this.isExpanded = false; } }; handleKeydownOnInput(event) { switch (event.key) { case 'Tab': this.closeSelect(); break; case 'ArrowDown': event.preventDefault(); if (!this.isExpanded) { this.openSelect(); } this.focusItemInDirection('next'); break; case 'ArrowUp': event.preventDefault(); if (!this.isExpanded) { this.openSelect(); } this.focusItemInDirection('previous'); break; case 'Escape': this.closeSelect(); this.resetFocus(); break; case 'Enter': this.toggleOpen(); break; default: } } handleKeydownOnList(event) { event.preventDefault(); switch (event.key) { case 'Tab': case 'Escape': this.resetFocus(); break; case 'ArrowUp': this.focusItemInDirection('previous'); break; case 'ArrowDown': this.focusItemInDirection('next'); break; default: break; } } handleKeydownOnItem(event, index) { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); this.handleSelection(index); } else if (event.key === 'Escape') { this.closeSelect(); } } handleSelectAllKeyDown(event) { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); this.toggleSelectAll(); } else if (event.key === 'Escape') { event.preventDefault(); this.closeSelect(); } } resetFocus() { const input = this.el.shadowRoot.querySelector('br-input'); input.focus(); } focusItemInDirection(direction) { let itemCount = this.parsedOptions.length; if (this.isMultiple) itemCount += 1; this.focusedIndex = direction === 'next' ? (this.focusedIndex + 1) % itemCount : (this.focusedIndex - 1 + itemCount) % itemCount; this.focusItem(this.focusedIndex); } focusItem(index) { const items = this.el.shadowRoot.querySelectorAll('.br-item'); if (items[index]) { const item = items[index]; item.focus(); } } openSelect() { this.isExpanded = true; } closeSelect() { this.isExpanded = false; // Resetar o índice focado ao fechar o select this.focusedIndex = -1; } handleSelection(index) { if (index < 0 || index >= this.parsedOptions.length) { return; } if (this.isMultiple) { const updatedOptions = [...this.parsedOptions]; updatedOptions[index].selected = !updatedOptions[index].selected; this.parsedOptions = updatedOptions; } else { const updatedOptions = this.parsedOptions.map((option) => ({ ...option, selected: false })); updatedOptions[index].selected = true; this.parsedOptions = updatedOptions; this.isExpanded = false; } this.updateSelectedOptions(); this.emitSelectedOptions(); this.updateSelectedAllState(); } updateSelectedOptions() { this.inputValue = this.getStringValue(this.parsedOptions.filter((option) => option.selected)); } getStringValue(options) { if (options.length > 0) { const firstSelected = options[0].label || options[0].value; if (this.isMultiple) { const howManySelected = options.length > 1 ? ` + (${options.length - 1})` : ''; return `${firstSelected}${howManySelected}`; } else { return firstSelected; } } return ''; } emitSelectedOptions() { const selectedOptions = this.parsedOptions .filter((option) => option.selected === true) .map((option) => option.label ?? option.value); this.brDidSelectChange.emit(selectedOptions); // Atualiza o valor do form associado this.elementInternals.setFormValue(JSON.stringify(selectedOptions)); } updateSelectedAllState() { const allSelected = this.parsedOptions.every((option) => option.selected); this.isAllSelected = allSelected; } toggleSelectAll() { const updatedOptions = this.parsedOptions.map((option) => ({ ...option, selected: !this.isAllSelected })); this.parsedOptions = updatedOptions; this.isAllSelected = !this.isAllSelected; this.updateSelectedOptions(); this.emitSelectedOptions(); } renderInput() { return (h("br-input", { value: this.inputValue, label: this.label, placeholder: this.placeholder, readonly: true, onClick: () => this.toggleOpen(), onKeyDown: (event) => this.handleKeydownOnInput(event) }, this.showSearchIcon && h("br-icon", { slot: "icon", "icon-name": "fa-solid:search", "aria-hidden": "true" }), h("br-icon", { slot: "action", "icon-name": this.isExpanded ? 'fa-solid:angle-up' : 'fa-solid:angle-down', height: "16", "aria-hidden": "true" }))); } renderSelectAllCheckbox() { return (h("div", { class: `br-item highlighted ${this.isAllSelected ? 'selected' : ''}`, "data-all": "data-all", tabindex: "-1", role: "option", "aria-selected": this.isAllSelected, onKeyDown: (event) => this.handleSelectAllKeyDown(event), onClick: () => this.toggleSelectAll() }, h("br-checkbox", { name: `checkbox-${this.customId}`, label: this.isAllSelected ? this.unselectAllLabel : this.selectAllLabel, checked: this.isAllSelected, onCheckedChange: () => this.toggleSelectAll() }))); } renderOptions() { return this.parsedOptions.map((option, index) => (h("div", { class: `br-item ${option.selected ? 'selected' : ''}`, key: `${option.label}-${this.customId}`, role: "option", tabindex: this.focusedIndex === index ? 0 : -1, "aria-selected": option.selected, onClick: () => this.handleSelection(index), onKeyDown: (event) => this.handleKeydownOnItem(event, index) }, this.isMultiple ? (h("br-checkbox", { name: `checkbox-${index}-${this.customId}`, label: option.label || option.value, checked: option.selected === true, onCheckedChange: () => this.handleSelection(index) })) : (h("br-radio", { exportparts: "radio-input, radio-label", name: `radio-${index}-${this.customId}`, label: option.label || option.value, checked: option.selected === true, onCheckedChange: () => this.handleSelection(index) }))))); } /** * Determina um id para o radio. */ getComputedId() { return this.customId?.length > 0 ? this.customId : `br-select-${this.customId}`; } /** * Inverte o valor da prop `isOpen` */ async toggleOpen() { this.isExpanded = !this.isExpanded; } getCssClassMap() { return { 'br-select': true, }; } render() { return (h("div", { key: 'a50be325be0c86a7b5332c1ba5f6af7477f5f9ac', class: this.getCssClassMap(), id: this.getComputedId() }, this.renderInput(), this.isExpanded && (h("br-list", { key: '5c15039f0dd50809feb23ce294d8ddfd87436868', tabindex: -1, onKeyDown: (event) => this.handleKeydownOnList(event) }, this.isMultiple && this.renderSelectAllCheckbox(), this.renderOptions())))); } static get is() { return "br-select"; } static get encapsulation() { return "shadow"; } static get formAssociated() { return true; } static get originalStyleUrls() { return { "$": ["select.scss"] }; } static get styleUrls() { return { "$": ["select.css"] }; } static get properties() { return { "label": { "type": "string", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "R\u00F3tulo que indica o tipo de informa\u00E7\u00E3o que deve ser selecionada." }, "getter": false, "setter": false, "attribute": "label", "reflect": true }, "placeholder": { "type": "string", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "Texto auxiliar exibido antes de uma sele\u00E7\u00E3o." }, "getter": false, "setter": false, "attribute": "placeholder", "reflect": true, "defaultValue": "''" }, "isMultiple": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "Habilita o modo m\u00FAltiplo para selecionar v\u00E1rias op\u00E7\u00F5es." }, "getter": false, "setter": false, "attribute": "is-multiple", "reflect": true, "defaultValue": "false" }, "options": { "type": "string", "mutable": false, "complexType": { "original": "| string\n | {\n label: string\n value: string\n selected?: boolean\n }[]", "resolved": "string | { label: string; value: string; selected?: boolean; }[]", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "Define as op\u00E7\u00F5es dispon\u00EDveis no componente de sele\u00E7\u00E3o.\nPode ser fornecida como:\n- Uma **string** contendo um JSON v\u00E1lido que ser\u00E1 analisado em um array de objetos. Exemplo: `'[{\"label\": \"Op\u00E7\u00E3o 1\", \"value\": \"1\"}]'.\n- Um **array de objetos**, onde cada objeto representa uma op\u00E7\u00E3o com as propriedades:\n - `label`: O texto exibido para o usu\u00E1rio (obrigat\u00F3rio).\n - `value`: O valor associado \u00E0 op\u00E7\u00E3o (obrigat\u00F3rio).\n - `selected`: Um boolean opcional que indica se a op\u00E7\u00E3o est\u00E1 pr\u00E9-selecionada (padr\u00E3o: `false`).\nSe uma string for fornecida, ela ser\u00E1 convertida internamente para um array via `JSON.parse`. Caso o formato seja inv\u00E1lido, um erro ser\u00E1 registrado e as op\u00E7\u00F5es ser\u00E3o definidas como um array vazio.\nExemplo de uso:\n```typescript\noptions='[{\"label\": \"Sim\", \"value\": \"1\"}, {\"label\": \"N\u00E3o\", \"value\": \"0\"}]'\n// ou\noptions=[{ label: \"Sim\", value: \"1\" }, { label: \"N\u00E3o\", value: \"0\" }]\n```" }, "getter": false, "setter": false, "attribute": "options", "reflect": true, "defaultValue": "[]" }, "selectAllLabel": { "type": "string", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "R\u00F3tulo para selecionar todas as op\u00E7\u00F5es." }, "getter": false, "setter": false, "attribute": "select-all-label", "reflect": true, "defaultValue": "'Selecionar todos'" }, "unselectAllLabel": { "type": "string", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "R\u00F3tulo para desmarcar todas as op\u00E7\u00F5es." }, "getter": false, "setter": false, "attribute": "unselect-all-label", "reflect": true, "defaultValue": "'Desselecionar todos'" }, "showSearchIcon": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "Exibe o \u00EDcone de busca no campo de entrada." }, "getter": false, "setter": false, "attribute": "show-search-icon", "reflect": true, "defaultValue": "false" }, "isOpen": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "Indica se a listagem de itens do select est\u00E1 expandida" }, "getter": false, "setter": false, "attribute": "is-open", "reflect": true, "defaultValue": "false" }, "customId": { "type": "string", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "Identificador \u00FAnico.\nCaso n\u00E3o seja fornecido, um ID gerado automaticamente ser\u00E1 usado." }, "getter": false, "setter": false, "attribute": "custom-id", "reflect": true, "defaultValue": "`br-select-${selectId++}`" } }; } static get states() { return { "parsedOptions": {}, "inputValue": {}, "isAllSelected": {}, "isExpanded": {}, "focusedIndex": {} }; } static get events() { return [{ "method": "brDidSelectChange", "name": "brDidSelectChange", "bubbles": true, "cancelable": true, "composed": true, "docs": { "tags": [], "text": "Evento emitido sempre que houver atualiza\u00E7\u00E3o nos itens selecionados." }, "complexType": { "original": "any", "resolved": "any", "references": {} } }]; } static get methods() { return { "toggleOpen": { "complexType": { "signature": "() => Promise<void>", "parameters": [], "references": { "Promise": { "location": "global", "id": "global::Promise" } }, "return": "Promise<void>" }, "docs": { "text": "Inverte o valor da prop `isOpen`", "tags": [] } } }; } static get elementRef() { return "el"; } static get watchers() { return [{ "propName": "options", "methodName": "synchronizeSelect" }]; } static get attachInternalsMemberName() { return "elementInternals"; } } let selectId = 0; //# sourceMappingURL=select.js.map