@wrrdev/fenix-ui 1.1.12 → 1.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -623,7 +623,7 @@ const _FxSelect = class _FxSelect extends FxElement {
623
623
  // `value` fica FORA da observação: refleti-lo no change não pode
624
624
  // re-renderizar o template e fechar o dropdown.
625
625
  static get observedAttributes() {
626
- return ["size", "disabled", "placeholder", "searchable", "clearable", "error", "invalid", "success", "valid"];
626
+ return ["size", "disabled", "placeholder", "searchable", "clearable", "error", "invalid", "success", "valid", "placement"];
627
627
  }
628
628
  /** Tamanho do campo. Padrão: `'md'`. */
629
629
  get size() {
@@ -658,7 +658,7 @@ const _FxSelect = class _FxSelect extends FxElement {
658
658
  this.docListener = (e) => {
659
659
  if (!this.hasAttr("open")) return;
660
660
  if (e.composedPath().includes(this)) return;
661
- this.removeAttribute("open");
661
+ this._fechar();
662
662
  this.render();
663
663
  };
664
664
  document.addEventListener("click", this.docListener);
@@ -667,10 +667,77 @@ const _FxSelect = class _FxSelect extends FxElement {
667
667
  super.disconnectedCallback();
668
668
  this.observer?.disconnect();
669
669
  if (this.docListener) document.removeEventListener("click", this.docListener);
670
+ this._unbindReflow();
671
+ }
672
+ /** Abre o dropdown, posiciona o painel e passa a reposicioná-lo no reflow. */
673
+ _abrir() {
674
+ this.setAttribute("open", "");
675
+ this._bindReflow();
676
+ }
677
+ /** Fecha o dropdown e libera os listeners de resize/scroll. */
678
+ _fechar() {
679
+ this.removeAttribute("open");
680
+ this.removeAttribute("data-placement");
681
+ this._unbindReflow();
682
+ }
683
+ /** Liga o reposicionamento (resize + scroll interno, em capture). */
684
+ _bindReflow() {
685
+ if (this.onReflow) return;
686
+ this.onReflow = () => {
687
+ if (this.hasAttr("open")) this.ajustarPosicao();
688
+ };
689
+ window.addEventListener("resize", this.onReflow);
690
+ window.addEventListener("scroll", this.onReflow, true);
691
+ }
692
+ _unbindReflow() {
693
+ if (!this.onReflow) return;
694
+ window.removeEventListener("resize", this.onReflow);
695
+ window.removeEventListener("scroll", this.onReflow, true);
696
+ this.onReflow = void 0;
697
+ }
698
+ /**
699
+ * Decide a posição vertical do painel medindo o espaço real da viewport.
700
+ *
701
+ * `placement="top"`/`"bottom"` são respeitados literalmente; o padrão
702
+ * (`auto`) só inverte quando o painel não cabe embaixo E couber melhor
703
+ * acima. Também limita a altura ao espaço disponível e evita overflow
704
+ * horizontal próximo à borda direita.
705
+ */
706
+ ajustarPosicao() {
707
+ const painel = this.root.querySelector(".panel");
708
+ const trigger = this.root.querySelector(".trigger");
709
+ if (!painel || !trigger) return;
710
+ const r = trigger.getBoundingClientRect();
711
+ const abaixo = window.innerHeight - r.bottom;
712
+ const acima = r.top;
713
+ const desejado = painel.scrollHeight;
714
+ const maxAltura = _FxSelect._maxAltura(this);
715
+ const pedido = this.getAttr("placement", "auto");
716
+ const paraCima = pedido === "top" || pedido !== "bottom" && abaixo < Math.min(desejado, maxAltura) && acima > abaixo;
717
+ if (paraCima) this.setAttribute("data-placement", "top");
718
+ else this.removeAttribute("data-placement");
719
+ const disponivel = Math.max(
720
+ _FxSelect.MIN_ALTURA,
721
+ (paraCima ? acima : abaixo) - _FxSelect.GAP
722
+ );
723
+ painel.style.maxHeight = `${Math.min(desejado || disponivel, disponivel, maxAltura)}px`;
724
+ painel.style.left = "";
725
+ const largura = painel.offsetWidth;
726
+ const excesso = r.left + largura - (window.innerWidth - _FxSelect.MARGEM);
727
+ if (excesso > 0) {
728
+ painel.style.left = `${-excesso}px`;
729
+ painel.style.right = "auto";
730
+ }
731
+ }
732
+ /** Altura máxima do painel a partir de `--fx-select-panel-max-height`. */
733
+ static _maxAltura(host) {
734
+ const raw = getComputedStyle(host).getPropertyValue("--fx-select-panel-max-height");
735
+ const n = parseInt(raw, 10);
736
+ return Number.isFinite(n) && n > 0 ? n : 260;
670
737
  }
671
738
  select(value) {
672
739
  this.value = value;
673
- this.removeAttribute("open");
740
+ this._fechar();
674
741
  this.render();
675
742
  this.dispatchEvent(
676
743
  new CustomEvent("change", { bubbles: true, composed: true, detail: { value } })
@@ -680,7 +747,7 @@ const _FxSelect = class _FxSelect extends FxElement {
680
747
  _navigateOptions(e) {
681
748
  e.preventDefault();
682
749
  if (!this.hasAttr("open")) {
683
- this.toggleAttribute("open");
750
+ this._abrir();
684
751
  this.render();
685
752
  }
686
753
  const opts = Array.from(this.root.querySelectorAll(".opt"));
@@ -725,6 +792,7 @@ const _FxSelect = class _FxSelect extends FxElement {
725
792
  </div>
726
793
  `);
727
794
  if (prevOpen) this.setAttribute("open", "");
795
+ if (this.hasAttr("open")) this.ajustarPosicao();
728
796
  const searchInput = this.root.querySelector(".search");
729
797
  if (searchInput) {
730
798
  searchInput.value = search;
@@ -744,7 +812,8 @@ const _FxSelect = class _FxSelect extends FxElement {
744
812
  trigger.addEventListener("click", (e) => {
745
813
  if (this.disabled) return;
746
814
  if (e.target.closest(".clear")) return;
747
- this.toggleAttribute("open");
815
+ if (this.hasAttr("open")) this._fechar();
816
+ else this._abrir();
748
817
  this.render();
749
818
  this.root.querySelector(".search")?.focus();
750
819
  });
@@ -757,7 +826,7 @@ const _FxSelect = class _FxSelect extends FxElement {
757
826
  }
758
827
  if (e.key === "Escape") {
759
828
  if (this.hasAttr("open")) {
760
- this.removeAttribute("open");
829
+ this._fechar();
761
830
  this.render();
762
831
  this.root.querySelector(".trigger")?.focus();
763
832
  }
@@ -770,7 +839,7 @@ const _FxSelect = class _FxSelect extends FxElement {
770
839
  this.root.querySelector(".clear")?.addEventListener("click", (e) => {
771
840
  e.stopPropagation();
772
841
  this.value = "";
773
- this.removeAttribute("open");
842
+ this._fechar();
774
843
  this.render();
775
844
  this.dispatchEvent(
776
845
  new CustomEvent("change", { bubbles: true, composed: true, detail: { value: "" } })
@@ -792,6 +861,11 @@ _FxSelect.styles = css`
792
861
  e style inline no elemento definem a largura sem precisar do full. */
793
862
  width: var(--fx-select-width, max-content);
794
863
  min-width: var(--fx-select-min-width, 200px);
864
+ /* Espaço entre o campo e o painel + altura máxima do painel. Expostos
865
+ como custom property porque ::part() do lado do consumidor tem
866
+ prioridade MENOR que a folha do shadow root e não vence o top. */
867
+ --fx-select-panel-offset: 4px;
868
+ --fx-select-panel-max-height: 260px;
795
869
  }
796
870
  .trigger {
797
871
  display: inline-flex;
@@ -879,14 +953,15 @@ _FxSelect.styles = css`
879
953
  }
880
954
  .caret { font-size: calc(var(--fx-font-size) - 3px); color: var(--fx-text-muted); pointer-events: none; }
881
955
 
882
- /* Painel do dropdown */
956
+ /* Painel do dropdown — a POSIÇÃO vertical é definida pelas regras
957
+ [data-placement] abaixo (o JS de ajustarPosicao decide), por isso
958
+ top/bottom não ficam aqui. */
883
959
  .panel {
884
960
  position: absolute;
885
- top: calc(100% + 4px);
886
961
  left: 0;
887
962
  z-index: var(--fx-z-dropdown, 1000);
888
963
  width: max(100%, 220px);
889
- max-height: 260px;
964
+ max-height: var(--fx-select-panel-max-height, 260px);
890
965
  overflow-y: auto;
891
966
  display: none;
892
967
  background: var(--fx-surface-background);
@@ -894,6 +969,16 @@ _FxSelect.styles = css`
894
969
  border-radius: var(--fx-radius-md);
895
970
  box-shadow: var(--fx-shadow-lg);
896
971
  }
972
+ /* Padrão: abre para baixo. */
973
+ :host([open]:not([data-placement='top'])) .panel {
974
+ top: calc(100% + var(--fx-select-panel-offset, 4px));
975
+ }
976
+ /* placement="top" explícito ou auto-flip: abre para cima. */
977
+ :host([open][placement='top']) .panel,
978
+ :host([open][data-placement='top']) .panel {
979
+ top: auto;
980
+ bottom: calc(100% + var(--fx-select-panel-offset, 4px));
981
+ }
897
982
  :host([open]) .panel { display: block; }
898
983
  .search {
899
984
  position: sticky;
@@ -941,6 +1026,9 @@ _FxSelect.styles = css`
941
1026
  .clear:hover { color: var(--fx-color-danger); }
942
1027
  .clear[hidden] { display: none; }
943
1028
  `;
1029
+ _FxSelect.GAP = 4;
1030
+ _FxSelect.MARGEM = 8;
1031
+ _FxSelect.MIN_ALTURA = 80;
944
1032
  let FxSelect = _FxSelect;
945
1033
  function defineFxSelect() {
946
1034
  return defineElement("fx-select", FxSelect);
@@ -5720,6 +5808,24 @@ const _FxCard = class _FxCard extends FxElement {
5720
5808
  <footer part="footer"><slot name="footer"></slot></footer>
5721
5809
  </div>
5722
5810
  `);
5811
+ this._syncSlots();
5812
+ for (const name of ["header", "footer"]) {
5813
+ this.root.querySelector(`slot[name="${name}"]`)?.addEventListener("slotchange", () => this._syncSlots());
5814
+ }
5815
+ }
5816
+ /**
5817
+ * Esconde `header`/`footer` quando o respectivo slot não recebeu nenhum nó.
5818
+ * O `<slot>` fica no shadow e os nós no light DOM, então a checagem é feita
5819
+ * via `assignedNodes()` — cobre conteúdo adicionado dinamicamente.
5820
+ */
5821
+ _syncSlots() {
5822
+ for (const name of ["header", "footer"]) {
5823
+ const slot = this.root.querySelector(`slot[name="${name}"]`);
5824
+ const section = this.root.querySelector(name);
5825
+ if (!slot || !section) continue;
5826
+ const temConteudo = slot.assignedNodes({ flatten: true }).some((n) => n.nodeType === 1 || (n.textContent ?? "").trim() !== "") || name === "header" && Boolean(this.getAttr("heading"));
5827
+ section.toggleAttribute("hidden", !temConteudo);
5828
+ }
5723
5829
  }
5724
5830
  };
5725
5831
  _FxCard.styles = css`