@wrrdev/fenix-ui 1.1.11 → 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);
@@ -3311,7 +3399,10 @@ const _FxTable = class _FxTable extends FxElement {
3311
3399
  "loading",
3312
3400
  "loading-message",
3313
3401
  "sort-field",
3314
- "sort-order"
3402
+ "sort-order",
3403
+ "rounded",
3404
+ "radius",
3405
+ "no-borders-x"
3315
3406
  ];
3316
3407
  }
3317
3408
  get data() {
@@ -3550,16 +3641,18 @@ const _FxTable = class _FxTable extends FxElement {
3550
3641
  this._shellKey = shellKey;
3551
3642
  this.setTemplate(`
3552
3643
  ${toolbarHtml}
3553
- <div class="table-wrap">
3554
- <div class="scroll">
3555
- <table part="table">
3556
- <thead><tr>${headHtml}</tr>${filterRow}</thead>
3557
- <tbody>${bodyHtml}</tbody>
3558
- </table>
3644
+ <div class="frame${toolbar ? " has-toolbar" : ""}" part="frame">
3645
+ <div class="table-wrap">
3646
+ <div class="scroll">
3647
+ <table part="table">
3648
+ <thead><tr>${headHtml}</tr>${filterRow}</thead>
3649
+ <tbody>${bodyHtml}</tbody>
3650
+ </table>
3651
+ </div>
3652
+ ${loadingHtml}
3559
3653
  </div>
3560
- ${loadingHtml}
3654
+ ${pagerHtml}
3561
3655
  </div>
3562
- ${pagerHtml}
3563
3656
  `);
3564
3657
  this.root.querySelectorAll("th.sortable").forEach((th) => {
3565
3658
  th.addEventListener("click", () => {
@@ -3691,14 +3784,61 @@ _FxTable.styles = css`
3691
3784
  font-size: var(--fx-font-size);
3692
3785
  color: var(--fx-text-default);
3693
3786
  background: var(--fx-surface-background);
3787
+ /* Raio da moldura (tabela + paginação). O atributo "rounded"
3788
+ sobrescreve este valor e o consumidor também pode sobrescrever
3789
+ por CSS: fx-table { --fx-table-radius: 20px; }. */
3790
+ --fx-table-radius: var(--fx-radius-md);
3791
+ }
3792
+ /* Arredondamento das bordas — "none" deixa tudo reto, como nos demais
3793
+ tokens de radius do design system. "rounded" é o nome atual; "radius"
3794
+ segue aceito como alias para ficar consistente com o <fx-card>. */
3795
+ :host([rounded="none"]),
3796
+ :host([radius="none"]) {
3797
+ --fx-table-radius: var(--fx-radius-none, 0);
3798
+ }
3799
+ :host([rounded="sm"]),
3800
+ :host([radius="sm"]) {
3801
+ --fx-table-radius: var(--fx-radius-sm);
3802
+ }
3803
+ :host([rounded="md"]),
3804
+ :host([radius="md"]) {
3805
+ --fx-table-radius: var(--fx-radius-md);
3806
+ }
3807
+ :host([rounded="lg"]),
3808
+ :host([radius="lg"]) {
3809
+ --fx-table-radius: var(--fx-radius-lg);
3810
+ }
3811
+ /* Moldura: uma única borda envolvendo a tabela E a paginação, para que
3812
+ o pager não fique "solto" fora do contorno da tabela. */
3813
+ .frame {
3814
+ border: 1px solid var(--fx-border-default);
3815
+ border-radius: var(--fx-table-radius);
3816
+ background: var(--fx-surface-background);
3817
+ }
3818
+ /* Com toolbar no topo, quem desenha a borda superior é a própria
3819
+ toolbar — a moldura desenha apenas o contorno restante. */
3820
+ .frame.has-toolbar {
3821
+ border-top: none;
3822
+ }
3823
+ /* "no-borders-x": mantém somente as bordas de topo e de base
3824
+ (tabela encaixada em um layout que já desenha as laterais). */
3825
+ :host([no-borders-x]) .frame,
3826
+ :host([no-borders-x]) .toolbar {
3827
+ border-left: none;
3828
+ border-right: none;
3694
3829
  }
3695
3830
  .table-wrap {
3696
3831
  position: relative;
3697
3832
  }
3698
3833
  .scroll {
3699
3834
  overflow-x: auto;
3700
- border: 1px solid var(--fx-border-default);
3701
- border-radius: var(--fx-radius-md);
3835
+ /* O topo acompanha o raio da moldura; a base fica reta porque o
3836
+ rodapé (.pager) é quem a desenha. */
3837
+ border-radius: var(--fx-table-radius) var(--fx-table-radius) 0 0;
3838
+ }
3839
+ /* Sem paginação não existe rodapé: a moldura fecha embaixo. */
3840
+ :host(:not([pagination])) .scroll {
3841
+ border-radius: var(--fx-table-radius);
3702
3842
  }
3703
3843
  table {
3704
3844
  width: 100%;
@@ -3785,7 +3925,13 @@ _FxTable.styles = css`
3785
3925
  align-items: center;
3786
3926
  gap: var(--fx-space-sm);
3787
3927
  flex-wrap: wrap;
3788
- padding: var(--fx-space-sm) 0;
3928
+ /* O rodapé é parte da moldura: borda só no topo (separando da
3929
+ tabela) e o mesmo padding horizontal das células. Antes ele
3930
+ ficava fora do contorno, sem borda e sem respiro lateral. */
3931
+ padding: var(--fx-space-sm) var(--fx-space-md);
3932
+ border-top: 1px solid var(--fx-border-default);
3933
+ border-radius: 0 0 var(--fx-table-radius) var(--fx-table-radius);
3934
+ background: var(--fx-surface-background);
3789
3935
  }
3790
3936
  :host([pagination-position="center"]) .pager {
3791
3937
  justify-content: center;
@@ -3834,15 +3980,27 @@ _FxTable.styles = css`
3834
3980
  color: #fff;
3835
3981
  font-weight: var(--fx-font-weight);
3836
3982
  }
3837
- fx-select {
3983
+ /* O seletor "Por página" só mostra números, então não pode herdar a
3984
+ largura padrão do <fx-select> (200px / 180px em size="sm"), que
3985
+ deixava o rodapé da paginação desproporcional. O limite tem que ser
3986
+ no HOST — apertar só ::part(trigger) não funciona, porque o
3987
+ trigger é width:100% e continua herdando o min-width do host.
3988
+ O painel (dropdown) continua largo (220px) em select.ts. */
3989
+ fx-select.rows-sel {
3838
3990
  vertical-align: middle;
3991
+ /* Não deixa o flex do .pager esticar nem esmagar o controle. */
3992
+ flex: none;
3993
+ width: auto;
3994
+ --fx-select-min-width: 56px;
3995
+ --fx-select-min-width-sm: 56px;
3839
3996
  }
3840
- fx-select::part(trigger) {
3841
- min-width: 64px !important;
3842
- min-height: var(--fx-size-sm) !important;
3843
- padding: 0 var(--fx-space-sm) !important;
3844
- border-radius: var(--fx-radius-sm) !important;
3845
- font-size: calc(var(--fx-font-size) - 2px) !important;
3997
+ fx-select.rows-sel::part(trigger) {
3998
+ min-width: 0;
3999
+ min-height: var(--fx-size-sm);
4000
+ /* Padding curto: sobra espaço para o número + caret. */
4001
+ padding: 0 var(--fx-space-xs);
4002
+ border-radius: var(--fx-radius-sm);
4003
+ font-size: calc(var(--fx-font-size) - 2px);
3846
4004
  }
3847
4005
  .toolbar {
3848
4006
  display: flex;
@@ -3852,7 +4010,7 @@ _FxTable.styles = css`
3852
4010
  padding: var(--fx-space-sm) var(--fx-space-md);
3853
4011
  border: 1px solid var(--fx-border-default);
3854
4012
  border-bottom: none;
3855
- border-radius: var(--fx-radius-md) var(--fx-radius-md) 0 0;
4013
+ border-radius: var(--fx-table-radius) var(--fx-table-radius) 0 0;
3856
4014
  background: var(--fx-surface-background);
3857
4015
  }
3858
4016
  .toolbar:empty {
@@ -3873,7 +4031,7 @@ _FxTable.styles = css`
3873
4031
  );
3874
4032
  backdrop-filter: blur(2px);
3875
4033
  z-index: 10;
3876
- border-radius: var(--fx-radius-md);
4034
+ border-radius: var(--fx-table-radius) var(--fx-table-radius) 0 0;
3877
4035
  }
3878
4036
  .tbl-spinner {
3879
4037
  width: 32px;
@@ -5650,6 +5808,24 @@ const _FxCard = class _FxCard extends FxElement {
5650
5808
  <footer part="footer"><slot name="footer"></slot></footer>
5651
5809
  </div>
5652
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
+ }
5653
5829
  }
5654
5830
  };
5655
5831
  _FxCard.styles = css`