@seedgrid/fe-components 2026.8.2 → 2026.8.4

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.
@@ -15,6 +15,19 @@ function resolveRecords(children, hiddenPageIds) {
15
15
  return { id, props: element.props, element, hidden };
16
16
  });
17
17
  }
18
+ // <option> so aceita texto. `title` e ReactNode (pode vir com icone/markup), entao
19
+ // caimos para `hint` e, por ultimo, para o id — nunca para string vazia, que deixaria
20
+ // a opcao invisivel no select do celular.
21
+ function optionLabel(record) {
22
+ const { title, hint } = record.props;
23
+ if (typeof title === "string" && title.trim())
24
+ return title;
25
+ if (typeof title === "number")
26
+ return String(title);
27
+ if (hint?.trim())
28
+ return hint;
29
+ return record.id;
30
+ }
18
31
  function clampIndex(index, length) {
19
32
  if (length <= 0)
20
33
  return 0;
@@ -23,7 +36,7 @@ function clampIndex(index, length) {
23
36
  return Math.max(0, Math.min(length - 1, Math.floor(index)));
24
37
  }
25
38
  export function SgPageControl(props) {
26
- const { children, activePageId, activeIndex, defaultActivePageId, defaultActiveIndex = 0, onActivePageIdChange, onActiveIndexChange, hiddenPageIds, keepMounted = false, pageControlStyle = "underline", size = "md", fullWidthTabs = false, keyboardNavigation = true, ariaLabel, emptyMessage, className, tabListClassName, tabClassName, panelClassName, style } = props;
39
+ const { children, activePageId, activeIndex, defaultActivePageId, defaultActiveIndex = 0, onActivePageIdChange, onActiveIndexChange, hiddenPageIds, keepMounted = false, pageControlStyle = "underline", size = "md", fullWidthTabs = false, mobilePageControl = "scroll", keyboardNavigation = true, ariaLabel, emptyMessage, className, tabListClassName, tabClassName, panelClassName, style } = props;
27
40
  const i18n = useComponentsI18n();
28
41
  const resolvedAriaLabel = ariaLabel ?? t(i18n, "components.pageControl.ariaLabel");
29
42
  const resolvedEmptyMessage = emptyMessage ?? t(i18n, "components.pageControl.empty");
@@ -71,23 +84,50 @@ export function SgPageControl(props) {
71
84
  fireOnChange(nextId);
72
85
  }, [fireOnChange, isControlled, visiblePages]);
73
86
  const tabsRef = React.useRef([]);
87
+ const tabListRef = React.useRef(null);
74
88
  const rootId = React.useId();
89
+ // Com rolagem horizontal a aba ativa pode estar fora da area visivel — ao trocar de
90
+ // pagina por teclado, por codigo (modo controlado) ou pelo select do celular, ela
91
+ // precisa aparecer. Mexemos em scrollLeft em vez de scrollIntoView de proposito:
92
+ // scrollIntoView sobe pelos ancestrais e rola a PAGINA junto.
93
+ React.useEffect(() => {
94
+ const list = tabListRef.current;
95
+ const tab = tabsRef.current[activeIndexResolved];
96
+ if (!list || !tab)
97
+ return;
98
+ if (list.scrollWidth <= list.clientWidth)
99
+ return;
100
+ const margin = 8;
101
+ const tabStart = tab.offsetLeft;
102
+ const tabEnd = tabStart + tab.offsetWidth;
103
+ const viewStart = list.scrollLeft;
104
+ const viewEnd = viewStart + list.clientWidth;
105
+ if (tabStart < viewStart) {
106
+ list.scrollLeft = Math.max(0, tabStart - margin);
107
+ }
108
+ else if (tabEnd > viewEnd) {
109
+ list.scrollLeft = tabEnd - list.clientWidth + margin;
110
+ }
111
+ }, [activeIndexResolved]);
75
112
  const sizeClasses = size === "sm"
76
113
  ? {
77
114
  tab: "h-8 px-3 text-xs gap-1.5",
78
115
  icon: "size-4",
79
- panel: "p-3"
116
+ panel: "p-3",
117
+ select: "h-8 text-xs"
80
118
  }
81
119
  : size === "lg"
82
120
  ? {
83
121
  tab: "h-11 px-4 text-sm gap-2.5",
84
122
  icon: "size-5",
85
- panel: "p-5"
123
+ panel: "p-5",
124
+ select: "h-11 text-sm"
86
125
  }
87
126
  : {
88
127
  tab: "h-9 px-3.5 text-sm gap-2",
89
128
  icon: "size-4.5",
90
- panel: "p-4"
129
+ panel: "p-4",
130
+ select: "h-9 text-sm"
91
131
  };
92
132
  const tabStyle = pageControlStyle === "pills"
93
133
  ? {
@@ -100,7 +140,14 @@ export function SgPageControl(props) {
100
140
  active: "border-primary text-primary bg-muted/30",
101
141
  inactive: ""
102
142
  };
103
- return (_jsxs("div", { className: cn("w-full", className), style: style, children: [_jsx("div", { role: "tablist", "aria-label": resolvedAriaLabel, className: cn("flex min-h-0 items-end gap-1 overflow-x-auto border-b border-border pb-0.5", fullWidthTabs ? "grid auto-cols-fr grid-flow-col" : "", tabListClassName), onKeyDown: (event) => {
143
+ return (_jsxs("div", { className: cn("w-full", className), style: style, children: [mobilePageControl === "select" && visiblePages.length > 0 ? (
144
+ // Alternativa do celular: as abas viram um select nativo. Fica escondido a partir
145
+ // do tablet, e a troca e feita por CSS (nao por media query em JS) para nao
146
+ // depender do tamanho da janela na primeira renderizacao.
147
+ _jsx("div", { className: "mb-2 md:hidden", children: _jsx("select", { "aria-label": resolvedAriaLabel, value: resolvedActiveId ?? "", onChange: (event) => selectPage(event.target.value), className: cn("w-full rounded-md border border-border bg-background px-3 font-medium text-foreground", sizeClasses.select), children: visiblePages.map((record) => (_jsx("option", { value: record.id, disabled: record.props.disabled, children: optionLabel(record) }, record.id))) }) })) : null, _jsx("div", { role: "tablist", ref: tabListRef, "aria-label": resolvedAriaLabel, className: cn("min-h-0 items-end gap-1 overflow-x-auto border-b border-border pb-0.5",
148
+ // Um unico utilitario de display por breakpoint. Antes `flex` e `grid` saiam
149
+ // juntos no mesmo elemento e quem vencia dependia da ordem no CSS gerado.
150
+ mobilePageControl === "select" ? "hidden" : "flex", fullWidthTabs ? "md:grid md:auto-cols-fr md:grid-flow-col" : "md:flex", tabListClassName), onKeyDown: (event) => {
104
151
  if (!keyboardNavigation || visiblePages.length === 0)
105
152
  return;
106
153
  const focused = document.activeElement;
@@ -141,7 +188,10 @@ export function SgPageControl(props) {
141
188
  const panelId = `${rootId}-panel-${record.id}`;
142
189
  return (_jsxs("button", { ref: (el) => {
143
190
  tabsRef.current[index] = el;
144
- }, type: "button", id: tabId, role: "tab", "aria-controls": panelId, "aria-selected": isActive, tabIndex: isActive ? 0 : -1, title: record.props.hint, disabled: record.props.disabled, onClick: () => selectPage(record.id), className: cn("inline-flex shrink-0 items-center justify-center whitespace-nowrap font-medium transition-colors", sizeClasses.tab, tabStyle.base, isActive ? tabStyle.active : tabStyle.inactive, fullWidthTabs ? "w-full" : "", record.props.disabled ? "cursor-not-allowed opacity-45" : "", tabClassName, record.props.tabClassName), children: [record.props.icon ? (_jsx("span", { className: cn("inline-flex items-center justify-center", sizeClasses.icon), children: record.props.icon })) : null, _jsx("span", { className: "truncate", children: record.props.title })] }, record.id));
191
+ }, type: "button", id: tabId, role: "tab", "aria-controls": panelId, "aria-selected": isActive, tabIndex: isActive ? 0 : -1, title: record.props.hint, disabled: record.props.disabled, onClick: () => selectPage(record.id), className: cn("inline-flex shrink-0 items-center justify-center whitespace-nowrap font-medium transition-colors", sizeClasses.tab, tabStyle.base, isActive ? tabStyle.active : tabStyle.inactive,
192
+ // No celular a lista e flex com rolagem: `w-full` faria cada aba ocupar a
193
+ // largura toda do container.
194
+ fullWidthTabs ? "md:w-full" : "", record.props.disabled ? "cursor-not-allowed opacity-45" : "", tabClassName, record.props.tabClassName), children: [record.props.icon ? (_jsx("span", { className: cn("inline-flex items-center justify-center", sizeClasses.icon), children: record.props.icon })) : null, _jsx("span", { className: "truncate", children: record.props.title })] }, record.id));
145
195
  }) }), _jsx("div", { className: cn("rounded-b-md border border-t-0 border-border bg-background", panelClassName), children: visiblePages.length === 0 ? (_jsx("div", { className: cn(sizeClasses.panel, "text-sm text-muted-foreground"), children: resolvedEmptyMessage })) : keepMounted ? (visiblePages.map((record) => {
146
196
  const isActive = record.id === resolvedActiveId;
147
197
  const tabId = `${rootId}-tab-${record.id}`;
@@ -1 +1 @@
1
- {"version":3,"file":"SgPageControl.meta.d.ts","sourceRoot":"","sources":["../../src/layout/SgPageControl.meta.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAE9D,eAAO,MAAM,MAAM,EAAE,QA8BpB,CAAC;AAEF,eAAO,MAAM,OAAO,EAAE,WAmBrB,CAAC"}
1
+ {"version":3,"file":"SgPageControl.meta.d.ts","sourceRoot":"","sources":["../../src/layout/SgPageControl.meta.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAE9D,eAAO,MAAM,MAAM,EAAE,QAgCpB,CAAC;AAEF,eAAO,MAAM,OAAO,EAAE,WAmBrB,CAAC"}
@@ -9,7 +9,7 @@ export const sgMeta = {
9
9
  subcategory: "page-control",
10
10
  description: "Controle de paginas em abas para alternar entre paineis de conteudo relacionados em uma mesma area da tela.",
11
11
  tags: ["navigation", "tabs", "page-control", "panels"],
12
- capabilities: ["tab-navigation", "controlled-active-page", "hidden-pages", "keyboard-navigation"],
12
+ capabilities: ["tab-navigation", "controlled-active-page", "hidden-pages", "keyboard-navigation", "responsive-tabs"],
13
13
  fieldSemantics: ["tabs", "pageTabs", "panelNavigation", "sectionSwitcher"],
14
14
  props: [
15
15
  { name: "children", type: "ReactNode", required: true, description: "Paginas filhas definidas por SgPageControlPage.", semanticRole: "data", bindable: false },
@@ -18,6 +18,8 @@ export const sgMeta = {
18
18
  { name: "keepMounted", type: "boolean", default: false, description: "Mantem paineis inativos montados no DOM.", semanticRole: "behavior", bindable: true },
19
19
  { name: "pageControlStyle", type: '"underline" | "pills"', default: "underline", description: "Estilo visual da lista de abas.", semanticRole: "appearance", bindable: true },
20
20
  { name: "size", type: '"sm" | "md" | "lg"', default: "md", description: "Escala visual das abas.", semanticRole: "appearance", bindable: true },
21
+ { name: "fullWidthTabs", type: "boolean", default: false, description: "Distribui as abas em larguras iguais a partir do tablet; no celular elas mantem a largura natural e a lista rola.", semanticRole: "appearance", bindable: true },
22
+ { name: "mobilePageControl", type: '"scroll" | "select"', default: "scroll", description: "Comportamento no celular: rolagem horizontal das abas ou troca por um select nativo.", semanticRole: "behavior", bindable: true },
21
23
  { name: "onActivePageIdChange", type: "(pageId: string, context: object) => void", description: "Callback disparado quando a pagina ativa muda.", semanticRole: "event", bindable: false }
22
24
  ],
23
25
  states: ["default", "controlled", "keyboard-navigation"],