@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.
- package/lib/components/Tabs/Tabs.d.ts.map +1 -1
- package/lib/components/Tabs/Tabs.jsx +54 -41
- package/lib/components/Tabs/Tabs.utils.d.ts +18 -0
- package/lib/components/Tabs/Tabs.utils.d.ts.map +1 -0
- package/lib/components/Tabs/Tabs.utils.js +37 -0
- package/package.json +2 -2
- package/src/components/Tabs/Tabs.test.tsx +40 -0
- package/src/components/Tabs/Tabs.tsx +68 -47
- package/src/components/Tabs/Tabs.utils.ts +56 -0
|
@@ -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;
|
|
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 [
|
|
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
|
|
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
|
-
|
|
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
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
250
|
-
if (
|
|
251
|
-
}, [context.
|
|
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,
|
|
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
|
|
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,
|
|
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
|
|
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.
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
74
|
-
|
|
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 [
|
|
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
|
|
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
|
-
|
|
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
|
|
226
|
-
|
|
227
|
-
const
|
|
228
|
-
|
|
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
|
-
|
|
282
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
416
|
-
if (
|
|
440
|
+
const width = getTabsLayoutWidth(args[0]);
|
|
441
|
+
if (width !== null) context.updateTriggerWidth(triggerValue, width);
|
|
417
442
|
},
|
|
418
|
-
[context.
|
|
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,
|
|
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
|
|
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,
|
|
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
|
+
}
|