@govbr-ds/webcomponents
Version:
Biblioteca de Web Components baseado no GovBR-DS
567 lines (566 loc) • 22.8 kB
JavaScript
/*!
* 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