@seed-design/lynx-react 0.5.0 → 0.5.1

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.
@@ -1 +1 @@
1
- {"version":3,"file":"Tabs.d.ts","sourceRoot":"","sources":["../../../src/components/Tabs/Tabs.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,gBAAgB,EAAE,MAAM,oCAAoC,CAAC;AACjF,OAAO,KAAK,KAAK,MAAM,gBAAgB,CAAC;AACxC,OAAO,KAAK,EACV,iBAAiB,EAMlB,MAAM,gBAAgB,CAAC;AAKxB,OAAO,KAAK,EACV,sBAAsB,EACtB,sBAAsB,EAGvB,MAAM,aAAa,CAAC;AAIrB,KAAK,oBAAoB,GAAG,iBAAiB,CAAC,WAAW,CAAC,CAAC;AAI3D,KAAK,sBAAsB,GAAG,IAAI,CAAC,gBAAgB,EAAE,UAAU,GAAG,UAAU,GAAG,YAAY,CAAC,CAAC;AAyF7F;;;;;;;GAOG;AACH,MAAM,WAAW,aAAc,SAAQ,sBAAsB,EAAE,sBAAsB;IACnF,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CACzC;AAED,eAAO,MAAM,QAAQ,+EAmMnB,CAAC;AAKH,MAAM,WAAW,aAAc,SAAQ,sBAAsB;CAAG;AAEhE,eAAO,MAAM,QAAQ,+EAsCnB,CAAC;AAKH,MAAM,WAAW,gBAAiB,SAAQ,sBAAsB;IAC9D,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,qBAAqB,CAAC,EAAE,sBAAsB,CAAC,qBAAqB,CAAC,CAAC;CACvE;AAED,eAAO,MAAM,WAAW,kFAiEtB,CAAC;AAKH,MAAM,WAAW,kBAAmB,SAAQ,sBAAsB;CAAG;AAErE,eAAO,MAAM,aAAa,oFAkCxB,CAAC;AAKH,MAAM,WAAW,gBAAiB,SAAQ,sBAAsB;IAC9D,KAAK,EAAE,MAAM,CAAC;CACf;AAED,eAAO,MAAM,WAAW,kFAgCtB,CAAC;AAKH;;;;;GAKG;AACH,MAAM,WAAW,iBAAkB,SAAQ,sBAAsB;IAC/D,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,2CAA2C;IAC3C,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB,YAAY,CAAC,EAAE,MAAM,IAAI,CAAC;IAC1B,UAAU,CAAC,EAAE,MAAM,IAAI,CAAC;CACzB;AAED,eAAO,MAAM,YAAY,mFA8BvB,CAAC;AAKH,MAAM,WAAW,uBAAwB,SAAQ,sBAAsB;IACrE,UAAU,CAAC,EAAE,oBAAoB,CAAC,YAAY,CAAC,CAAC;IAChD,cAAc,CAAC,EAAE,oBAAoB,CAAC,gBAAgB,CAAC,CAAC;IACxD,gBAAgB,CAAC,EAAE,oBAAoB,CAAC,kBAAkB,CAAC,CAAC;CAC7D;AAED,eAAO,MAAM,kBAAkB,yFAmH9B,CAAC"}
1
+ {"version":3,"file":"Tabs.d.ts","sourceRoot":"","sources":["../../../src/components/Tabs/Tabs.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,gBAAgB,EAAE,MAAM,oCAAoC,CAAC;AACjF,OAAO,KAAK,KAAK,MAAM,gBAAgB,CAAC;AACxC,OAAO,KAAK,EACV,iBAAiB,EAMlB,MAAM,gBAAgB,CAAC;AAKxB,OAAO,KAAK,EACV,sBAAsB,EACtB,sBAAsB,EAGvB,MAAM,aAAa,CAAC;AAUrB,KAAK,oBAAoB,GAAG,iBAAiB,CAAC,WAAW,CAAC,CAAC;AAI3D,KAAK,sBAAsB,GAAG,IAAI,CAAC,gBAAgB,EAAE,UAAU,GAAG,UAAU,GAAG,YAAY,CAAC,CAAC;AAiF7F;;;;;;;GAOG;AACH,MAAM,WAAW,aAAc,SAAQ,sBAAsB,EAAE,sBAAsB;IACnF,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CACzC;AAED,eAAO,MAAM,QAAQ,+EA8MnB,CAAC;AAyBH,MAAM,WAAW,aAAc,SAAQ,sBAAsB;CAAG;AAEhE,eAAO,MAAM,QAAQ,+EAkCnB,CAAC;AAKH,MAAM,WAAW,gBAAiB,SAAQ,sBAAsB;IAC9D,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,qBAAqB,CAAC,EAAE,sBAAsB,CAAC,qBAAqB,CAAC,CAAC;CACvE;AAED,eAAO,MAAM,WAAW,kFAiEtB,CAAC;AAKH,MAAM,WAAW,kBAAmB,SAAQ,sBAAsB;CAAG;AAErE,eAAO,MAAM,aAAa,oFAiCxB,CAAC;AAKH,MAAM,WAAW,gBAAiB,SAAQ,sBAAsB;IAC9D,KAAK,EAAE,MAAM,CAAC;CACf;AAED,eAAO,MAAM,WAAW,kFAgCtB,CAAC;AAKH;;;;;GAKG;AACH,MAAM,WAAW,iBAAkB,SAAQ,sBAAsB;IAC/D,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,2CAA2C;IAC3C,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB,YAAY,CAAC,EAAE,MAAM,IAAI,CAAC;IAC1B,UAAU,CAAC,EAAE,MAAM,IAAI,CAAC;CACzB;AAED,eAAO,MAAM,YAAY,mFA8BvB,CAAC;AAKH,MAAM,WAAW,uBAAwB,SAAQ,sBAAsB;IACrE,UAAU,CAAC,EAAE,oBAAoB,CAAC,YAAY,CAAC,CAAC;IAChD,cAAc,CAAC,EAAE,oBAAoB,CAAC,gBAAgB,CAAC,CAAC;IACxD,gBAAgB,CAAC,EAAE,oBAAoB,CAAC,kBAAkB,CAAC,CAAC;CAC7D;AAED,eAAO,MAAM,kBAAkB,yFAgH9B,CAAC"}
@@ -1,6 +1,7 @@
1
1
  import { useControllableState } from "../../hooks/useControllableState.js";
2
2
  import { usePressTap } from "../../hooks/usePressTap.js";
3
3
  import { createSlotRecipeContext } from "../../utils/create-slot-recipe-context.jsx";
4
+ import { getTabsLayoutWidth, getTabsOrderedItems, getTabsTriggerRects } from "./Tabs.utils.js";
4
5
  import * as React from "@lynx-js/react";
5
6
  import clsx from "clsx";
6
7
  import { tabs } from "@seed-design/lynx-css/recipes/tabs";
@@ -32,15 +33,6 @@ function invokeScrollToOffset(list, offset) {
32
33
  }).exec();
33
34
  } catch {}
34
35
  }
35
- function getLayoutRect(event) {
36
- const width = event.detail?.width ?? event.params?.width;
37
- const left = event.detail?.left ?? event.params?.left;
38
- if (!Number.isFinite(width) || !Number.isFinite(left)) return null;
39
- return {
40
- width: Math.max(0, width),
41
- left
42
- };
43
- }
44
36
  var TabsContext = React.createContext(null);
45
37
  function useTabsContext(consumer) {
46
38
  const context = React.useContext(TabsContext);
@@ -72,17 +64,16 @@ var TabsRoot = React.forwardRef((props, ref) => {
72
64
  const [items, setItems] = React.useState([]);
73
65
  const [contentValues, setContentValues] = React.useState([]);
74
66
  const [indicatorValue, setIndicatorValue] = React.useState();
75
- const [triggerRects, setTriggerRects] = React.useState({});
76
- const [listLeft, setListLeft] = React.useState(0);
67
+ const [triggerWidths, setTriggerWidths] = React.useState({});
77
68
  const listRef = React.useRef(null);
78
69
  const pagerRef = React.useRef(null);
79
70
  const indicatorRef = React.useMainThreadRef(null);
80
71
  const pagerValues = React.useMemo(() => contentValues.filter((contentValue) => !items.find((item) => item.value === contentValue)?.disabled), [contentValues, items]);
72
+ const triggerRects = React.useMemo(() => getTabsTriggerRects(items.map((item) => item.value), triggerWidths), [items, triggerWidths]);
81
73
  const visualValue = indicatorValue ?? value;
82
74
  const indicatorIndex = items.findIndex((item) => item.value === visualValue);
83
75
  const selectedPagerIndex = value === void 0 ? -1 : pagerValues.indexOf(value);
84
- const selectedRect = value === void 0 ? void 0 : triggerRects[value];
85
- const selectedOffset = selectedRect ? Math.max(0, selectedRect.left - listLeft) : null;
76
+ const selectedOffset = (value === void 0 ? void 0 : triggerRects[value])?.left ?? null;
86
77
  const setListNode = React.useCallback((node) => {
87
78
  listRef.current = node;
88
79
  if (node && selectedOffset !== null) invokeScrollToOffset(node, selectedOffset);
@@ -102,7 +93,7 @@ var TabsRoot = React.forwardRef((props, ref) => {
102
93
  return () => {
103
94
  "background only";
104
95
  setItems((current) => current.filter((item) => item.value !== triggerValue));
105
- setTriggerRects((current) => {
96
+ setTriggerWidths((current) => {
106
97
  const next = { ...current };
107
98
  delete next[triggerValue];
108
99
  return next;
@@ -122,14 +113,16 @@ var TabsRoot = React.forwardRef((props, ref) => {
122
113
  disabled
123
114
  } : item));
124
115
  }, []);
125
- const updateTriggerRect = React.useCallback((triggerValue, rect) => {
126
- setTriggerRects((current) => {
127
- const previous = current[triggerValue];
128
- if (previous?.left === rect.left && previous.width === rect.width) return current;
129
- return {
130
- ...current,
131
- [triggerValue]: rect
132
- };
116
+ const syncTriggerOrder = React.useCallback((triggerValues) => {
117
+ setItems((current) => {
118
+ const ordered = getTabsOrderedItems(current, triggerValues);
119
+ return ordered.every((item, index) => item === current[index]) ? current : ordered;
120
+ });
121
+ }, []);
122
+ const updateTriggerWidth = React.useCallback((triggerValue, width) => {
123
+ setTriggerWidths((current) => current[triggerValue] === width ? current : {
124
+ ...current,
125
+ [triggerValue]: width
133
126
  });
134
127
  }, []);
135
128
  const selectValue = React.useCallback((nextValue) => {
@@ -159,13 +152,12 @@ var TabsRoot = React.forwardRef((props, ref) => {
159
152
  indicatorIndex,
160
153
  selectedPagerIndex,
161
154
  indicatorRef,
162
- listLeft,
163
155
  triggerRects,
164
156
  registerTrigger,
165
157
  registerContent,
166
158
  updateTriggerDisabled,
167
- updateTriggerRect,
168
- setListLeft,
159
+ syncTriggerOrder,
160
+ updateTriggerWidth,
169
161
  setListRef: setListNode,
170
162
  setPagerRef: setPagerNode,
171
163
  selectValue,
@@ -180,12 +172,12 @@ var TabsRoot = React.forwardRef((props, ref) => {
180
172
  indicatorIndex,
181
173
  selectedPagerIndex,
182
174
  indicatorRef,
183
- listLeft,
184
175
  triggerRects,
185
176
  registerTrigger,
186
177
  registerContent,
187
178
  updateTriggerDisabled,
188
- updateTriggerRect,
179
+ syncTriggerOrder,
180
+ updateTriggerWidth,
189
181
  setListNode,
190
182
  setPagerNode,
191
183
  selectValue,
@@ -201,21 +193,42 @@ var TabsRoot = React.forwardRef((props, ref) => {
201
193
  </TabsContext.Provider>;
202
194
  });
203
195
  TabsRoot.displayName = "TabsRoot";
196
+ function getTabsTriggerValues(children) {
197
+ const values = [];
198
+ function visit(node) {
199
+ if (Array.isArray(node)) {
200
+ node.forEach(visit);
201
+ return;
202
+ }
203
+ if (!React.isValidElement(node)) return;
204
+ if (node.type === TabsTrigger) {
205
+ values.push(node.props.value);
206
+ return;
207
+ }
208
+ if (node.type === React.Fragment) visit(node.props.children);
209
+ }
210
+ visit(children);
211
+ return values;
212
+ }
204
213
  var TabsList = React.forwardRef((props, ref) => {
205
214
  const { children, className, style, ...nativeProps } = props;
206
215
  const classNames = useClassNames();
207
- const { setListLeft, setListRef } = useTabsContext("TabsList");
216
+ const { items, setListRef, syncTriggerOrder } = useTabsContext("TabsList");
217
+ const triggerOrder = React.useMemo(() => getTabsTriggerValues(children), [children]);
218
+ React.useEffect(() => {
219
+ "background only";
220
+ syncTriggerOrder(triggerOrder);
221
+ }, [
222
+ items,
223
+ syncTriggerOrder,
224
+ triggerOrder
225
+ ]);
208
226
  const mergedRef = React.useCallback((node) => {
209
227
  setListRef(node);
210
228
  if (typeof ref === "function") ref(node);
211
229
  else if (ref) ref.current = node;
212
230
  }, [ref, setListRef]);
213
- const handleLayoutChange = React.useCallback((...args) => {
214
- "background only";
215
- const rect = getLayoutRect(args[0]);
216
- if (rect) setListLeft(rect.left);
217
- }, [setListLeft]);
218
- return <scroll-view ref={mergedRef} {...nativeProps} scroll-orientation="horizontal" scroll-bar-enable={false} bindlayoutchange={handleLayoutChange} accessibility-element={false} accessibility-traits="tabbar" className={clsx(classNames.list, className)} style={style}>
231
+ return <scroll-view ref={mergedRef} {...nativeProps} scroll-orientation="horizontal" scroll-bar-enable={false} accessibility-element={false} accessibility-traits="tabbar" className={clsx(classNames.list, className)} style={style}>
219
232
  <view className={classNames.listContent}>{children}</view>
220
233
  </scroll-view>;
221
234
  });
@@ -246,9 +259,9 @@ var TabsTrigger = React.forwardRef((props, ref) => {
246
259
  });
247
260
  const handleLayoutChange = React.useCallback((...args) => {
248
261
  "background only";
249
- const rect = getLayoutRect(args[0]);
250
- if (rect) context.updateTriggerRect(triggerValue, rect);
251
- }, [context.updateTriggerRect, triggerValue]);
262
+ const width = getTabsLayoutWidth(args[0]);
263
+ if (width !== null) context.updateTriggerWidth(triggerValue, width);
264
+ }, [context.updateTriggerWidth, triggerValue]);
252
265
  const triggerClasses = tabs({
253
266
  ...context.variantProps,
254
267
  selected: visuallySelected,
@@ -263,14 +276,14 @@ TabsTrigger.displayName = "TabsTrigger";
263
276
  var TabsIndicator = React.forwardRef((props, ref) => {
264
277
  const { className, style, ...nativeProps } = props;
265
278
  const classNames = useClassNames();
266
- const { indicatorRef, items, listLeft, indicatorIndex, triggerRects } = useTabsContext("TabsIndicator");
279
+ const { indicatorRef, items, indicatorIndex, triggerRects } = useTabsContext("TabsIndicator");
267
280
  const position = indicatorIndex;
268
281
  const lowerIndex = Math.max(0, Math.floor(position));
269
282
  const upperIndex = Math.min(items.length - 1, Math.ceil(position));
270
283
  const progress = Math.max(0, Math.min(1, position - lowerIndex));
271
284
  const lowerRect = triggerRects[items[lowerIndex]?.value ?? ""];
272
285
  const upperRect = triggerRects[items[upperIndex]?.value ?? ""] ?? lowerRect;
273
- const x = lowerRect ? lowerRect.left - listLeft + ((upperRect?.left ?? lowerRect.left) - lowerRect.left) * progress : 0;
286
+ const x = lowerRect ? lowerRect.left + ((upperRect?.left ?? lowerRect.left) - lowerRect.left) * progress : 0;
274
287
  const width = lowerRect ? lowerRect.width + ((upperRect?.width ?? lowerRect.width) - lowerRect.width) * progress : 0;
275
288
  return <view {...ref ? { ref } : {}} main-thread:ref={indicatorRef} {...nativeProps} accessibility-elements-hidden={true} className={clsx(classNames.indicator, className)} style={{
276
289
  "--tabs-indicator-x": `${x}px`,
@@ -337,7 +350,7 @@ var TabsCarouselCamera = React.forwardRef((props, ref) => {
337
350
  const tabsContext = useTabsContext("TabsCarouselCamera");
338
351
  const carouselContext = useTabsCarouselContext("TabsCarouselCamera");
339
352
  const swipingRef = React.useRef(false);
340
- const { indicatorRef, pagerValues, listLeft, triggerRects } = tabsContext;
353
+ const { indicatorRef, pagerValues, triggerRects } = tabsContext;
341
354
  const indicatorRects = pagerValues.map((value) => triggerRects[value] ?? null);
342
355
  const mergedRef = React.useCallback((node) => {
343
356
  tabsContext.setPagerRef(node);
@@ -388,7 +401,7 @@ var TabsCarouselCamera = React.forwardRef((props, ref) => {
388
401
  const lowerRect = indicatorRects[lowerIndex];
389
402
  const upperRect = indicatorRects[upperIndex] ?? lowerRect;
390
403
  if (!lowerRect) return;
391
- const x = lowerRect.left - listLeft + ((upperRect?.left ?? lowerRect.left) - lowerRect.left) * progress;
404
+ const x = lowerRect.left + ((upperRect?.left ?? lowerRect.left) - lowerRect.left) * progress;
392
405
  const width = lowerRect.width + ((upperRect?.width ?? lowerRect.width) - lowerRect.width) * progress;
393
406
  indicatorRef.current?.setStyleProperties({
394
407
  "--tabs-indicator-x": `${x}px`,
@@ -0,0 +1,18 @@
1
+ export interface TabsLayoutChangeEvent {
2
+ detail?: {
3
+ width?: number;
4
+ };
5
+ params?: {
6
+ width?: number;
7
+ };
8
+ }
9
+ export interface TabsLayoutRect {
10
+ left: number;
11
+ width: number;
12
+ }
13
+ export declare function getTabsLayoutWidth(event: TabsLayoutChangeEvent): number | null;
14
+ export declare function getTabsOrderedItems<T extends {
15
+ value: string;
16
+ }>(items: T[], values: string[]): T[];
17
+ export declare function getTabsTriggerRects(values: string[], widths: Record<string, number>): Record<string, TabsLayoutRect>;
18
+ //# sourceMappingURL=Tabs.utils.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Tabs.utils.d.ts","sourceRoot":"","sources":["../../../src/components/Tabs/Tabs.utils.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,qBAAqB;IACpC,MAAM,CAAC,EAAE;QACP,KAAK,CAAC,EAAE,MAAM,CAAC;KAChB,CAAC;IACF,MAAM,CAAC,EAAE;QACP,KAAK,CAAC,EAAE,MAAM,CAAC;KAChB,CAAC;CACH;AAED,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;CACf;AAED,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,qBAAqB,GAAG,MAAM,GAAG,IAAI,CAI9E;AAED,wBAAgB,mBAAmB,CAAC,CAAC,SAAS;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,EAC7D,KAAK,EAAE,CAAC,EAAE,EACV,MAAM,EAAE,MAAM,EAAE,GACf,CAAC,EAAE,CAUL;AAED,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,MAAM,EAAE,EAChB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAC7B,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAiBhC"}
@@ -0,0 +1,37 @@
1
+ //#region src/components/Tabs/Tabs.utils.ts
2
+ function getTabsLayoutWidth(event) {
3
+ const width = event.detail?.width ?? event.params?.width;
4
+ if (typeof width !== "number" || !Number.isFinite(width)) return null;
5
+ return Math.max(0, width);
6
+ }
7
+ function getTabsOrderedItems(items, values) {
8
+ const itemsByValue = new Map(items.map((item) => [item.value, item]));
9
+ const ordered = values.flatMap((value) => {
10
+ const item = itemsByValue.get(value);
11
+ if (!item) return [];
12
+ itemsByValue.delete(value);
13
+ return [item];
14
+ });
15
+ ordered.push(...itemsByValue.values());
16
+ return ordered;
17
+ }
18
+ function getTabsTriggerRects(values, widths) {
19
+ const rects = Object.create(null);
20
+ let left = 0;
21
+ let hasCompletePrefix = true;
22
+ for (const value of values) {
23
+ const width = Object.prototype.hasOwnProperty.call(widths, value) ? widths[value] : void 0;
24
+ if (width === void 0) {
25
+ hasCompletePrefix = false;
26
+ continue;
27
+ }
28
+ if (hasCompletePrefix) rects[value] = {
29
+ left,
30
+ width
31
+ };
32
+ left += width;
33
+ }
34
+ return rects;
35
+ }
36
+ //#endregion
37
+ export { getTabsLayoutWidth, getTabsOrderedItems, getTabsTriggerRects };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seed-design/lynx-react",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/daangn/seed-design.git",
@@ -39,7 +39,7 @@
39
39
  "devDependencies": {
40
40
  "@lynx-js/react": "^0.117.0",
41
41
  "@lynx-js/types": "^3.7.0",
42
- "@seed-design/lynx-css": "0.9.0",
42
+ "@seed-design/lynx-css": "0.9.1",
43
43
  "@testing-library/jest-dom": "^6.9.1",
44
44
  "jsdom": "^29.0.2",
45
45
  "preact": "npm:@lynx-js/internal-preact@10.28.4-ee7bb26",
@@ -3,6 +3,7 @@ import { fireEvent, render } from "@lynx-js/react/testing-library";
3
3
  import { describe, expect, it, vi } from "vitest";
4
4
 
5
5
  import * as Tabs from "./Tabs.namespace";
6
+ import { getTabsLayoutWidth, getTabsOrderedItems, getTabsTriggerRects } from "./Tabs.utils";
6
7
 
7
8
  function BasicTabs(props: {
8
9
  value?: string;
@@ -56,6 +57,45 @@ describe("Tabs", () => {
56
57
  expect(second).toHaveAttribute("accessibility-value", "selected");
57
58
  });
58
59
 
60
+ it("derives trigger positions from ordered widths instead of platform coordinates", () => {
61
+ expect(getTabsLayoutWidth({ detail: { width: 128 }, params: { width: 128 } })).toBe(128);
62
+ expect(
63
+ getTabsTriggerRects(["one", "two", "three"], {
64
+ one: 128,
65
+ two: 128,
66
+ three: 128,
67
+ }),
68
+ ).toEqual({
69
+ one: { left: 0, width: 128 },
70
+ two: { left: 128, width: 128 },
71
+ three: { left: 256, width: 128 },
72
+ });
73
+ });
74
+
75
+ it("waits for every preceding trigger width before positioning later triggers", () => {
76
+ expect(getTabsTriggerRects(["one", "two", "three"], { one: 80, three: 120 })).toEqual({
77
+ one: { left: 0, width: 80 },
78
+ });
79
+ });
80
+
81
+ it("ignores inherited object properties used as trigger values", () => {
82
+ expect(getTabsTriggerRects(["toString"], {})).toEqual({});
83
+ expect(getTabsTriggerRects(["constructor"], {})).toEqual({});
84
+ expect(getTabsTriggerRects(["__proto__"], {})).toEqual({});
85
+ });
86
+
87
+ it("updates trigger positions when keyed triggers are reordered", () => {
88
+ const items = [{ value: "one" }, { value: "two" }, { value: "three" }];
89
+ const reordered = getTabsOrderedItems(items, ["three", "two", "one"]);
90
+ const rects = getTabsTriggerRects(
91
+ reordered.map((item) => item.value),
92
+ { one: 80, two: 80, three: 80 },
93
+ );
94
+
95
+ expect(reordered.map((item) => item.value)).toEqual(["three", "two", "one"]);
96
+ expect(rects["three"]?.left).toBe(0);
97
+ });
98
+
59
99
  it("does not select a disabled trigger", () => {
60
100
  const onValueChange = vi.fn();
61
101
  const { container } = render(
@@ -19,11 +19,17 @@ import type {
19
19
  LynxViewRef,
20
20
  } from "../../types";
21
21
  import { createSlotRecipeContext } from "../../utils/create-slot-recipe-context";
22
+ import {
23
+ getTabsLayoutWidth,
24
+ getTabsOrderedItems,
25
+ getTabsTriggerRects,
26
+ type TabsLayoutRect,
27
+ } from "./Tabs.utils";
22
28
 
23
29
  type NativeViewProps = IntrinsicElements["view"];
24
30
  type NativeViewPagerProps = IntrinsicElements["viewpager"];
25
31
  type LayoutChangeHandler = NonNullable<NativeViewProps["bindlayoutchange"]>;
26
- type TriggerRect = { left: number; width: number };
32
+ type TriggerRect = TabsLayoutRect;
27
33
  type TriggerItem = { value: string; disabled: boolean };
28
34
  type TabsPublicVariantProps = Omit<TabsVariantProps, "selected" | "disabled" | "inCarousel">;
29
35
 
@@ -49,13 +55,6 @@ function invokeScrollToOffset(list: NodesRef | null, offset: number) {
49
55
  }
50
56
  }
51
57
 
52
- function getLayoutRect(event: Parameters<LayoutChangeHandler>[0]): TriggerRect | null {
53
- const width = event.detail?.width ?? event.params?.width;
54
- const left = event.detail?.left ?? event.params?.left;
55
- if (!Number.isFinite(width) || !Number.isFinite(left)) return null;
56
- return { width: Math.max(0, width), left };
57
- }
58
-
59
58
  interface TabsContextValue {
60
59
  value: string | undefined;
61
60
  visualValue: string | undefined;
@@ -65,13 +64,12 @@ interface TabsContextValue {
65
64
  indicatorIndex: number;
66
65
  selectedPagerIndex: number;
67
66
  indicatorRef: React.RefObject<MainThread.Element>;
68
- listLeft: number;
69
67
  triggerRects: Record<string, TriggerRect>;
70
68
  registerTrigger: (value: string, disabled: boolean) => () => void;
71
69
  registerContent: (value: string) => () => void;
72
70
  updateTriggerDisabled: (value: string, disabled: boolean) => void;
73
- updateTriggerRect: (value: string, rect: TriggerRect) => void;
74
- setListLeft: (left: number) => void;
71
+ syncTriggerOrder: (values: string[]) => void;
72
+ updateTriggerWidth: (value: string, width: number) => void;
75
73
  setListRef: (ref: NodesRef | null) => void;
76
74
  setPagerRef: (ref: NodesRef | null) => void;
77
75
  selectValue: (value: string) => void;
@@ -151,8 +149,7 @@ export const TabsRoot = React.forwardRef<unknown, TabsRootProps>((props, ref) =>
151
149
  const [items, setItems] = React.useState<TriggerItem[]>([]);
152
150
  const [contentValues, setContentValues] = React.useState<string[]>([]);
153
151
  const [indicatorValue, setIndicatorValue] = React.useState<string | undefined>();
154
- const [triggerRects, setTriggerRects] = React.useState<Record<string, TriggerRect>>({});
155
- const [listLeft, setListLeft] = React.useState(0);
152
+ const [triggerWidths, setTriggerWidths] = React.useState<Record<string, number>>({});
156
153
  const listRef = React.useRef<NodesRef | null>(null);
157
154
  const pagerRef = React.useRef<NodesRef | null>(null);
158
155
  const indicatorRef = React.useMainThreadRef<MainThread.Element>(null);
@@ -164,11 +161,19 @@ export const TabsRoot = React.forwardRef<unknown, TabsRootProps>((props, ref) =>
164
161
  ),
165
162
  [contentValues, items],
166
163
  );
164
+ const triggerRects = React.useMemo(
165
+ () =>
166
+ getTabsTriggerRects(
167
+ items.map((item) => item.value),
168
+ triggerWidths,
169
+ ),
170
+ [items, triggerWidths],
171
+ );
167
172
  const visualValue = indicatorValue ?? value;
168
173
  const indicatorIndex = items.findIndex((item) => item.value === visualValue);
169
174
  const selectedPagerIndex = value === undefined ? -1 : pagerValues.indexOf(value);
170
175
  const selectedRect = value === undefined ? undefined : triggerRects[value];
171
- const selectedOffset = selectedRect ? Math.max(0, selectedRect.left - listLeft) : null;
176
+ const selectedOffset = selectedRect?.left ?? null;
172
177
 
173
178
  const setListNode = React.useCallback(
174
179
  (node: NodesRef | null) => {
@@ -195,7 +200,7 @@ export const TabsRoot = React.forwardRef<unknown, TabsRootProps>((props, ref) =>
195
200
  return () => {
196
201
  "background only";
197
202
  setItems((current) => current.filter((item) => item.value !== triggerValue));
198
- setTriggerRects((current) => {
203
+ setTriggerWidths((current) => {
199
204
  const next = { ...current };
200
205
  delete next[triggerValue];
201
206
  return next;
@@ -222,14 +227,19 @@ export const TabsRoot = React.forwardRef<unknown, TabsRootProps>((props, ref) =>
222
227
  );
223
228
  }, []);
224
229
 
225
- const updateTriggerRect = React.useCallback((triggerValue: string, rect: TriggerRect) => {
226
- setTriggerRects((current) => {
227
- const previous = current[triggerValue];
228
- if (previous?.left === rect.left && previous.width === rect.width) return current;
229
- return { ...current, [triggerValue]: rect };
230
+ const syncTriggerOrder = React.useCallback((triggerValues: string[]) => {
231
+ setItems((current) => {
232
+ const ordered = getTabsOrderedItems(current, triggerValues);
233
+ return ordered.every((item, index) => item === current[index]) ? current : ordered;
230
234
  });
231
235
  }, []);
232
236
 
237
+ const updateTriggerWidth = React.useCallback((triggerValue: string, width: number) => {
238
+ setTriggerWidths((current) =>
239
+ current[triggerValue] === width ? current : { ...current, [triggerValue]: width },
240
+ );
241
+ }, []);
242
+
233
243
  const selectValue = React.useCallback(
234
244
  (nextValue: string) => {
235
245
  setIndicatorValue(undefined);
@@ -273,13 +283,12 @@ export const TabsRoot = React.forwardRef<unknown, TabsRootProps>((props, ref) =>
273
283
  indicatorIndex,
274
284
  selectedPagerIndex,
275
285
  indicatorRef,
276
- listLeft,
277
286
  triggerRects,
278
287
  registerTrigger,
279
288
  registerContent,
280
289
  updateTriggerDisabled,
281
- updateTriggerRect,
282
- setListLeft,
290
+ syncTriggerOrder,
291
+ updateTriggerWidth,
283
292
  setListRef: setListNode,
284
293
  setPagerRef: setPagerNode,
285
294
  selectValue,
@@ -295,12 +304,12 @@ export const TabsRoot = React.forwardRef<unknown, TabsRootProps>((props, ref) =>
295
304
  indicatorIndex,
296
305
  selectedPagerIndex,
297
306
  indicatorRef,
298
- listLeft,
299
307
  triggerRects,
300
308
  registerTrigger,
301
309
  registerContent,
302
310
  updateTriggerDisabled,
303
- updateTriggerRect,
311
+ syncTriggerOrder,
312
+ updateTriggerWidth,
304
313
  setListNode,
305
314
  setPagerNode,
306
315
  selectValue,
@@ -328,12 +337,38 @@ TabsRoot.displayName = "TabsRoot";
328
337
 
329
338
  ////////////////////////////////////////////////////////////////////////////////////
330
339
 
340
+ function getTabsTriggerValues(children: React.ReactNode): string[] {
341
+ const values: string[] = [];
342
+
343
+ function visit(node: React.ReactNode) {
344
+ if (Array.isArray(node)) {
345
+ node.forEach(visit);
346
+ return;
347
+ }
348
+ if (!React.isValidElement<TabsTriggerProps>(node)) return;
349
+ if (node.type === TabsTrigger) {
350
+ values.push(node.props.value);
351
+ return;
352
+ }
353
+ if (node.type === React.Fragment) visit(node.props.children);
354
+ }
355
+
356
+ visit(children);
357
+ return values;
358
+ }
359
+
331
360
  export interface TabsListProps extends LynxStyledElementProps {}
332
361
 
333
362
  export const TabsList = React.forwardRef<unknown, TabsListProps>((props, ref) => {
334
363
  const { children, className, style, ...nativeProps } = props;
335
364
  const classNames = useClassNames();
336
- const { setListLeft, setListRef } = useTabsContext("TabsList");
365
+ const { items, setListRef, syncTriggerOrder } = useTabsContext("TabsList");
366
+ const triggerOrder = React.useMemo(() => getTabsTriggerValues(children), [children]);
367
+
368
+ React.useEffect(() => {
369
+ "background only";
370
+ syncTriggerOrder(triggerOrder);
371
+ }, [items, syncTriggerOrder, triggerOrder]);
337
372
 
338
373
  const mergedRef = React.useCallback(
339
374
  (node: NodesRef | null) => {
@@ -344,22 +379,12 @@ export const TabsList = React.forwardRef<unknown, TabsListProps>((props, ref) =>
344
379
  [ref, setListRef],
345
380
  );
346
381
 
347
- const handleLayoutChange = React.useCallback<LayoutChangeHandler>(
348
- (...args) => {
349
- "background only";
350
- const rect = getLayoutRect(args[0]);
351
- if (rect) setListLeft(rect.left);
352
- },
353
- [setListLeft],
354
- );
355
-
356
382
  return (
357
383
  <scroll-view
358
384
  ref={mergedRef}
359
385
  {...nativeProps}
360
386
  scroll-orientation="horizontal"
361
387
  scroll-bar-enable={false}
362
- bindlayoutchange={handleLayoutChange}
363
388
  accessibility-element={false}
364
389
  accessibility-traits="tabbar"
365
390
  className={clsx(classNames.list, className)}
@@ -412,10 +437,10 @@ export const TabsTrigger = React.forwardRef<unknown, TabsTriggerProps>((props, r
412
437
  const handleLayoutChange = React.useCallback<LayoutChangeHandler>(
413
438
  (...args) => {
414
439
  "background only";
415
- const rect = getLayoutRect(args[0]);
416
- if (rect) context.updateTriggerRect(triggerValue, rect);
440
+ const width = getTabsLayoutWidth(args[0]);
441
+ if (width !== null) context.updateTriggerWidth(triggerValue, width);
417
442
  },
418
- [context.updateTriggerRect, triggerValue],
443
+ [context.updateTriggerWidth, triggerValue],
419
444
  );
420
445
 
421
446
  const triggerClasses = tabs({
@@ -454,8 +479,7 @@ export interface TabsIndicatorProps extends LynxStyledElementProps {}
454
479
  export const TabsIndicator = React.forwardRef<unknown, TabsIndicatorProps>((props, ref) => {
455
480
  const { className, style, ...nativeProps } = props;
456
481
  const classNames = useClassNames();
457
- const { indicatorRef, items, listLeft, indicatorIndex, triggerRects } =
458
- useTabsContext("TabsIndicator");
482
+ const { indicatorRef, items, indicatorIndex, triggerRects } = useTabsContext("TabsIndicator");
459
483
  const position = indicatorIndex;
460
484
  const lowerIndex = Math.max(0, Math.floor(position));
461
485
  const upperIndex = Math.min(items.length - 1, Math.ceil(position));
@@ -463,7 +487,7 @@ export const TabsIndicator = React.forwardRef<unknown, TabsIndicatorProps>((prop
463
487
  const lowerRect = triggerRects[items[lowerIndex]?.value ?? ""];
464
488
  const upperRect = triggerRects[items[upperIndex]?.value ?? ""] ?? lowerRect;
465
489
  const x = lowerRect
466
- ? lowerRect.left - listLeft + ((upperRect?.left ?? lowerRect.left) - lowerRect.left) * progress
490
+ ? lowerRect.left + ((upperRect?.left ?? lowerRect.left) - lowerRect.left) * progress
467
491
  : 0;
468
492
  const width = lowerRect
469
493
  ? lowerRect.width + ((upperRect?.width ?? lowerRect.width) - lowerRect.width) * progress
@@ -602,7 +626,7 @@ export const TabsCarouselCamera = React.forwardRef<unknown, TabsCarouselCameraPr
602
626
  const tabsContext = useTabsContext("TabsCarouselCamera");
603
627
  const carouselContext = useTabsCarouselContext("TabsCarouselCamera");
604
628
  const swipingRef = React.useRef(false);
605
- const { indicatorRef, pagerValues, listLeft, triggerRects } = tabsContext;
629
+ const { indicatorRef, pagerValues, triggerRects } = tabsContext;
606
630
  const indicatorRects = pagerValues.map((value) => triggerRects[value] ?? null);
607
631
 
608
632
  const mergedRef = React.useCallback(
@@ -669,10 +693,7 @@ export const TabsCarouselCamera = React.forwardRef<unknown, TabsCarouselCameraPr
669
693
  const upperRect = indicatorRects[upperIndex] ?? lowerRect;
670
694
  if (!lowerRect) return;
671
695
 
672
- const x =
673
- lowerRect.left -
674
- listLeft +
675
- ((upperRect?.left ?? lowerRect.left) - lowerRect.left) * progress;
696
+ const x = lowerRect.left + ((upperRect?.left ?? lowerRect.left) - lowerRect.left) * progress;
676
697
  const width =
677
698
  lowerRect.width + ((upperRect?.width ?? lowerRect.width) - lowerRect.width) * progress;
678
699
 
@@ -0,0 +1,56 @@
1
+ export interface TabsLayoutChangeEvent {
2
+ detail?: {
3
+ width?: number;
4
+ };
5
+ params?: {
6
+ width?: number;
7
+ };
8
+ }
9
+
10
+ export interface TabsLayoutRect {
11
+ left: number;
12
+ width: number;
13
+ }
14
+
15
+ export function getTabsLayoutWidth(event: TabsLayoutChangeEvent): number | null {
16
+ const width = event.detail?.width ?? event.params?.width;
17
+ if (typeof width !== "number" || !Number.isFinite(width)) return null;
18
+ return Math.max(0, width);
19
+ }
20
+
21
+ export function getTabsOrderedItems<T extends { value: string }>(
22
+ items: T[],
23
+ values: string[],
24
+ ): T[] {
25
+ const itemsByValue = new Map(items.map((item) => [item.value, item]));
26
+ const ordered = values.flatMap((value) => {
27
+ const item = itemsByValue.get(value);
28
+ if (!item) return [];
29
+ itemsByValue.delete(value);
30
+ return [item];
31
+ });
32
+ ordered.push(...itemsByValue.values());
33
+ return ordered;
34
+ }
35
+
36
+ export function getTabsTriggerRects(
37
+ values: string[],
38
+ widths: Record<string, number>,
39
+ ): Record<string, TabsLayoutRect> {
40
+ const rects = Object.create(null) as Record<string, TabsLayoutRect>;
41
+ let left = 0;
42
+ let hasCompletePrefix = true;
43
+
44
+ for (const value of values) {
45
+ // biome-ignore lint/suspicious/noPrototypeBuiltins: Object.hasOwn is unavailable in supported Lynx runtimes.
46
+ const width = Object.prototype.hasOwnProperty.call(widths, value) ? widths[value] : undefined;
47
+ if (width === undefined) {
48
+ hasCompletePrefix = false;
49
+ continue;
50
+ }
51
+ if (hasCompletePrefix) rects[value] = { left, width };
52
+ left += width;
53
+ }
54
+
55
+ return rects;
56
+ }