@seed-design/lynx-react 0.4.1 → 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/Accordion/Accordion.d.ts +56 -0
- package/lib/components/Accordion/Accordion.d.ts.map +1 -0
- package/lib/components/Accordion/Accordion.jsx +210 -0
- package/lib/components/Accordion/Accordion.namespace.d.ts +2 -0
- package/lib/components/Accordion/Accordion.namespace.d.ts.map +1 -0
- package/lib/components/Accordion/Accordion.namespace.js +17 -0
- package/lib/components/Accordion/index.d.ts +3 -0
- package/lib/components/Accordion/index.d.ts.map +1 -0
- package/lib/components/ActionButton/ActionButton.jsx +6 -6
- package/lib/components/AppBar/AppBar.jsx +1 -1
- package/lib/components/Badge/Badge.jsx +1 -1
- package/lib/components/BottomSheet/BottomSheet.d.ts.map +1 -1
- package/lib/components/BottomSheet/BottomSheet.jsx +6 -3
- package/lib/components/Box/Box.jsx +1 -1
- package/lib/components/Callout/Callout.d.ts +32 -0
- package/lib/components/Callout/Callout.d.ts.map +1 -0
- package/lib/components/Callout/Callout.jsx +113 -0
- package/lib/components/Callout/Callout.namespace.d.ts +2 -0
- package/lib/components/Callout/Callout.namespace.d.ts.map +1 -0
- package/lib/components/Callout/Callout.namespace.js +13 -0
- package/lib/components/Callout/index.d.ts +3 -0
- package/lib/components/Callout/index.d.ts.map +1 -0
- package/lib/components/Checkbox/Checkbox.d.ts.map +1 -1
- package/lib/components/Checkbox/Checkbox.jsx +36 -5
- package/lib/components/Chip/Chip.d.ts +66 -0
- package/lib/components/Chip/Chip.d.ts.map +1 -0
- package/lib/components/Chip/Chip.jsx +159 -0
- package/lib/components/Chip/Chip.namespace.d.ts +2 -0
- package/lib/components/Chip/Chip.namespace.d.ts.map +1 -0
- package/lib/components/Chip/Chip.namespace.js +16 -0
- package/lib/components/Chip/index.d.ts +3 -0
- package/lib/components/Chip/index.d.ts.map +1 -0
- package/lib/components/Divider/Divider.d.ts +31 -0
- package/lib/components/Divider/Divider.d.ts.map +1 -0
- package/lib/components/Divider/Divider.jsx +26 -0
- package/lib/components/Divider/index.d.ts +2 -0
- package/lib/components/Divider/index.d.ts.map +1 -0
- package/lib/components/Field/Field.jsx +1 -1
- package/lib/components/Icon/Icon.jsx +2 -2
- package/lib/components/ProgressCircle/ProgressCircle.jsx +2 -2
- package/lib/components/RadioGroup/RadioGroup.jsx +2 -2
- package/lib/components/Stack/Stack.jsx +1 -1
- package/lib/components/Switch/Switch.jsx +2 -2
- package/lib/components/Tabs/Tabs.d.ts +59 -0
- package/lib/components/Tabs/Tabs.d.ts.map +1 -0
- package/lib/components/Tabs/Tabs.jsx +419 -0
- package/lib/components/Tabs/Tabs.namespace.d.ts +2 -0
- package/lib/components/Tabs/Tabs.namespace.d.ts.map +1 -0
- package/lib/components/Tabs/Tabs.namespace.js +14 -0
- 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/lib/components/Tabs/index.d.ts +3 -0
- package/lib/components/Tabs/index.d.ts.map +1 -0
- package/lib/components/TagGroup/TagGroup.jsx +2 -2
- package/lib/components/Text/Text.jsx +2 -2
- package/lib/components/TextField/TextField.jsx +2 -2
- package/lib/components/index.d.ts +5 -0
- package/lib/components/index.d.ts.map +1 -1
- package/lib/hooks/useIconColor.d.ts +2 -1
- package/lib/hooks/useIconColor.d.ts.map +1 -1
- package/lib/hooks/useIconColor.js +29 -5
- package/lib/hooks/usePressTap.d.ts.map +1 -1
- package/lib/hooks/usePressTap.js +1 -0
- package/lib/index.js +14 -5
- package/lib/utils/create-slot-recipe-context.jsx +1 -1
- package/package.json +2 -2
- package/src/components/Accordion/Accordion.namespace.ts +22 -0
- package/src/components/Accordion/Accordion.test.tsx +129 -0
- package/src/components/Accordion/Accordion.tsx +453 -0
- package/src/components/Accordion/index.ts +24 -0
- package/src/components/BottomSheet/BottomSheet.test.tsx +24 -0
- package/src/components/BottomSheet/BottomSheet.tsx +14 -2
- package/src/components/Callout/Callout.namespace.ts +14 -0
- package/src/components/Callout/Callout.test.tsx +188 -0
- package/src/components/Callout/Callout.tsx +272 -0
- package/src/components/Callout/index.ts +16 -0
- package/src/components/Checkbox/Checkbox.test.tsx +176 -0
- package/src/components/Checkbox/Checkbox.tsx +36 -2
- package/src/components/Chip/Chip.namespace.ts +20 -0
- package/src/components/Chip/Chip.test.tsx +113 -0
- package/src/components/Chip/Chip.tsx +373 -0
- package/src/components/Chip/index.ts +22 -0
- package/src/components/Divider/Divider.test.tsx +73 -0
- package/src/components/Divider/Divider.tsx +76 -0
- package/src/components/Divider/index.ts +1 -0
- package/src/components/Icon/Icon.test.tsx +129 -0
- package/src/components/Tabs/Tabs.namespace.ts +16 -0
- package/src/components/Tabs/Tabs.test.tsx +221 -0
- package/src/components/Tabs/Tabs.tsx +727 -0
- package/src/components/Tabs/Tabs.utils.ts +56 -0
- package/src/components/Tabs/index.ts +2 -0
- package/src/components/index.ts +5 -0
- package/src/hooks/useIconColor.ts +43 -5
- package/src/hooks/usePressTap.test.ts +13 -0
- package/src/hooks/usePressTap.ts +1 -0
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
import { tabs, type TabsVariantProps } from "@seed-design/lynx-css/recipes/tabs";
|
|
2
|
+
import * as React from "@lynx-js/react";
|
|
3
|
+
import type {
|
|
4
|
+
IntrinsicElements,
|
|
5
|
+
MainThread,
|
|
6
|
+
NodesRef,
|
|
7
|
+
ViewPagerChangeEvent,
|
|
8
|
+
ViewPagerOffsetChangeEvent,
|
|
9
|
+
ViewPagerWillChangeEvent,
|
|
10
|
+
} from "@lynx-js/types";
|
|
11
|
+
import clsx from "clsx";
|
|
12
|
+
|
|
13
|
+
import { useControllableState } from "../../hooks/useControllableState";
|
|
14
|
+
import { usePressTap } from "../../hooks/usePressTap";
|
|
15
|
+
import type {
|
|
16
|
+
LynxAccessibilityProps,
|
|
17
|
+
LynxStyledElementProps,
|
|
18
|
+
LynxViewProps,
|
|
19
|
+
LynxViewRef,
|
|
20
|
+
} from "../../types";
|
|
21
|
+
import { createSlotRecipeContext } from "../../utils/create-slot-recipe-context";
|
|
22
|
+
import {
|
|
23
|
+
getTabsLayoutWidth,
|
|
24
|
+
getTabsOrderedItems,
|
|
25
|
+
getTabsTriggerRects,
|
|
26
|
+
type TabsLayoutRect,
|
|
27
|
+
} from "./Tabs.utils";
|
|
28
|
+
|
|
29
|
+
type NativeViewProps = IntrinsicElements["view"];
|
|
30
|
+
type NativeViewPagerProps = IntrinsicElements["viewpager"];
|
|
31
|
+
type LayoutChangeHandler = NonNullable<NativeViewProps["bindlayoutchange"]>;
|
|
32
|
+
type TriggerRect = TabsLayoutRect;
|
|
33
|
+
type TriggerItem = { value: string; disabled: boolean };
|
|
34
|
+
type TabsPublicVariantProps = Omit<TabsVariantProps, "selected" | "disabled" | "inCarousel">;
|
|
35
|
+
|
|
36
|
+
const { ClassNamesProvider, useClassNames } = createSlotRecipeContext(tabs);
|
|
37
|
+
|
|
38
|
+
function invokeSelectTab(pager: NodesRef | null, index: number, smooth: boolean) {
|
|
39
|
+
"background only";
|
|
40
|
+
if (!pager || index < 0) return;
|
|
41
|
+
try {
|
|
42
|
+
pager.invoke({ method: "selectTab", params: { index, smooth } }).exec();
|
|
43
|
+
} catch {
|
|
44
|
+
// ReactLynx Testing Library의 NodesRef는 UI method를 구현하지 않는다.
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function invokeScrollToOffset(list: NodesRef | null, offset: number) {
|
|
49
|
+
"background only";
|
|
50
|
+
if (!list || offset < 0) return;
|
|
51
|
+
try {
|
|
52
|
+
list.invoke({ method: "scrollTo", params: { offset, smooth: true } }).exec();
|
|
53
|
+
} catch {
|
|
54
|
+
// ReactLynx Testing Library의 NodesRef는 UI method를 구현하지 않는다.
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface TabsContextValue {
|
|
59
|
+
value: string | undefined;
|
|
60
|
+
visualValue: string | undefined;
|
|
61
|
+
variantProps: TabsPublicVariantProps;
|
|
62
|
+
items: TriggerItem[];
|
|
63
|
+
pagerValues: string[];
|
|
64
|
+
indicatorIndex: number;
|
|
65
|
+
selectedPagerIndex: number;
|
|
66
|
+
indicatorRef: React.RefObject<MainThread.Element>;
|
|
67
|
+
triggerRects: Record<string, TriggerRect>;
|
|
68
|
+
registerTrigger: (value: string, disabled: boolean) => () => void;
|
|
69
|
+
registerContent: (value: string) => () => void;
|
|
70
|
+
updateTriggerDisabled: (value: string, disabled: boolean) => void;
|
|
71
|
+
syncTriggerOrder: (values: string[]) => void;
|
|
72
|
+
updateTriggerWidth: (value: string, width: number) => void;
|
|
73
|
+
setListRef: (ref: NodesRef | null) => void;
|
|
74
|
+
setPagerRef: (ref: NodesRef | null) => void;
|
|
75
|
+
selectValue: (value: string) => void;
|
|
76
|
+
handlePagerWillChange: (index: number) => void;
|
|
77
|
+
handlePagerChange: (index: number) => void;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const TabsContext = React.createContext<TabsContextValue | null>(null);
|
|
81
|
+
|
|
82
|
+
function useTabsContext(consumer: string) {
|
|
83
|
+
const context = React.useContext(TabsContext);
|
|
84
|
+
if (!context) {
|
|
85
|
+
throw new Error(`<${consumer}/> must be rendered inside <TabsRoot/>.`);
|
|
86
|
+
}
|
|
87
|
+
return context;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface TabsCarouselContextValue {
|
|
91
|
+
swipeable: boolean;
|
|
92
|
+
iosBackGestureEdgeWidth: number;
|
|
93
|
+
onSettle?: () => void;
|
|
94
|
+
onSwipeStart?: () => void;
|
|
95
|
+
onSwipeEnd?: () => void;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const TabsCarouselContext = React.createContext<TabsCarouselContextValue | null>(null);
|
|
99
|
+
const TabsCarouselCameraContext = React.createContext<boolean | null>(null);
|
|
100
|
+
|
|
101
|
+
function useTabsCarouselCameraContext() {
|
|
102
|
+
return React.useContext(TabsCarouselCameraContext) ?? false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function useTabsCarouselContext(consumer: string) {
|
|
106
|
+
const context = React.useContext(TabsCarouselContext);
|
|
107
|
+
if (!context) {
|
|
108
|
+
throw new Error(`<${consumer}/> must be rendered inside <TabsCarousel/>.`);
|
|
109
|
+
}
|
|
110
|
+
return context;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
////////////////////////////////////////////////////////////////////////////////////
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* @platform Lynx
|
|
117
|
+
*
|
|
118
|
+
* 웹 대비 미지원 기능:
|
|
119
|
+
* - `orientation`: Lynx Tabs는 수평 방향만 지원
|
|
120
|
+
* - `lazyMount`, `unmountOnExit`: native viewpager가 모든 page slot을 유지해야 함
|
|
121
|
+
* - 키보드 포커스와 roving tabindex
|
|
122
|
+
*/
|
|
123
|
+
export interface TabsRootProps extends TabsPublicVariantProps, LynxStyledElementProps {
|
|
124
|
+
value?: string;
|
|
125
|
+
defaultValue?: string;
|
|
126
|
+
onValueChange?: (value: string) => void;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export const TabsRoot = React.forwardRef<unknown, TabsRootProps>((props, ref) => {
|
|
130
|
+
const [variantProps, otherProps] = tabs.splitVariantProps(props);
|
|
131
|
+
const {
|
|
132
|
+
children,
|
|
133
|
+
className,
|
|
134
|
+
style,
|
|
135
|
+
value: valueProp,
|
|
136
|
+
defaultValue,
|
|
137
|
+
onValueChange,
|
|
138
|
+
...nativeProps
|
|
139
|
+
} = otherProps;
|
|
140
|
+
const classNames = tabs(variantProps);
|
|
141
|
+
const [value, setValueInternal] = useControllableState<string | undefined>({
|
|
142
|
+
value: valueProp,
|
|
143
|
+
defaultValue,
|
|
144
|
+
onChange(nextValue) {
|
|
145
|
+
"background only";
|
|
146
|
+
if (nextValue !== undefined) onValueChange?.(nextValue);
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
const [items, setItems] = React.useState<TriggerItem[]>([]);
|
|
150
|
+
const [contentValues, setContentValues] = React.useState<string[]>([]);
|
|
151
|
+
const [indicatorValue, setIndicatorValue] = React.useState<string | undefined>();
|
|
152
|
+
const [triggerWidths, setTriggerWidths] = React.useState<Record<string, number>>({});
|
|
153
|
+
const listRef = React.useRef<NodesRef | null>(null);
|
|
154
|
+
const pagerRef = React.useRef<NodesRef | null>(null);
|
|
155
|
+
const indicatorRef = React.useMainThreadRef<MainThread.Element>(null);
|
|
156
|
+
|
|
157
|
+
const pagerValues = React.useMemo(
|
|
158
|
+
() =>
|
|
159
|
+
contentValues.filter(
|
|
160
|
+
(contentValue) => !items.find((item) => item.value === contentValue)?.disabled,
|
|
161
|
+
),
|
|
162
|
+
[contentValues, items],
|
|
163
|
+
);
|
|
164
|
+
const triggerRects = React.useMemo(
|
|
165
|
+
() =>
|
|
166
|
+
getTabsTriggerRects(
|
|
167
|
+
items.map((item) => item.value),
|
|
168
|
+
triggerWidths,
|
|
169
|
+
),
|
|
170
|
+
[items, triggerWidths],
|
|
171
|
+
);
|
|
172
|
+
const visualValue = indicatorValue ?? value;
|
|
173
|
+
const indicatorIndex = items.findIndex((item) => item.value === visualValue);
|
|
174
|
+
const selectedPagerIndex = value === undefined ? -1 : pagerValues.indexOf(value);
|
|
175
|
+
const selectedRect = value === undefined ? undefined : triggerRects[value];
|
|
176
|
+
const selectedOffset = selectedRect?.left ?? null;
|
|
177
|
+
|
|
178
|
+
const setListNode = React.useCallback(
|
|
179
|
+
(node: NodesRef | null) => {
|
|
180
|
+
listRef.current = node;
|
|
181
|
+
if (node && selectedOffset !== null) invokeScrollToOffset(node, selectedOffset);
|
|
182
|
+
},
|
|
183
|
+
[selectedOffset],
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
const setPagerNode = React.useCallback(
|
|
187
|
+
(node: NodesRef | null) => {
|
|
188
|
+
pagerRef.current = node;
|
|
189
|
+
if (node && selectedPagerIndex >= 0) invokeSelectTab(node, selectedPagerIndex, false);
|
|
190
|
+
},
|
|
191
|
+
[selectedPagerIndex],
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const registerTrigger = React.useCallback((triggerValue: string, disabled: boolean) => {
|
|
195
|
+
setItems((current) => {
|
|
196
|
+
if (current.some((item) => item.value === triggerValue)) return current;
|
|
197
|
+
return [...current, { value: triggerValue, disabled }];
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
return () => {
|
|
201
|
+
"background only";
|
|
202
|
+
setItems((current) => current.filter((item) => item.value !== triggerValue));
|
|
203
|
+
setTriggerWidths((current) => {
|
|
204
|
+
const next = { ...current };
|
|
205
|
+
delete next[triggerValue];
|
|
206
|
+
return next;
|
|
207
|
+
});
|
|
208
|
+
};
|
|
209
|
+
}, []);
|
|
210
|
+
|
|
211
|
+
const registerContent = React.useCallback((contentValue: string) => {
|
|
212
|
+
setContentValues((current) =>
|
|
213
|
+
current.includes(contentValue) ? current : [...current, contentValue],
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
return () => {
|
|
217
|
+
"background only";
|
|
218
|
+
setContentValues((current) => current.filter((value) => value !== contentValue));
|
|
219
|
+
};
|
|
220
|
+
}, []);
|
|
221
|
+
|
|
222
|
+
const updateTriggerDisabled = React.useCallback((triggerValue: string, disabled: boolean) => {
|
|
223
|
+
setItems((current) =>
|
|
224
|
+
current.map((item) =>
|
|
225
|
+
item.value === triggerValue && item.disabled !== disabled ? { ...item, disabled } : item,
|
|
226
|
+
),
|
|
227
|
+
);
|
|
228
|
+
}, []);
|
|
229
|
+
|
|
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;
|
|
234
|
+
});
|
|
235
|
+
}, []);
|
|
236
|
+
|
|
237
|
+
const updateTriggerWidth = React.useCallback((triggerValue: string, width: number) => {
|
|
238
|
+
setTriggerWidths((current) =>
|
|
239
|
+
current[triggerValue] === width ? current : { ...current, [triggerValue]: width },
|
|
240
|
+
);
|
|
241
|
+
}, []);
|
|
242
|
+
|
|
243
|
+
const selectValue = React.useCallback(
|
|
244
|
+
(nextValue: string) => {
|
|
245
|
+
setIndicatorValue(undefined);
|
|
246
|
+
setValueInternal(nextValue);
|
|
247
|
+
},
|
|
248
|
+
[setValueInternal],
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
const handlePagerWillChange = React.useCallback(
|
|
252
|
+
(targetIndex: number) => {
|
|
253
|
+
setIndicatorValue(pagerValues[targetIndex]);
|
|
254
|
+
},
|
|
255
|
+
[pagerValues],
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
const handlePagerChange = React.useCallback(
|
|
259
|
+
(targetIndex: number) => {
|
|
260
|
+
const nextValue = pagerValues[targetIndex];
|
|
261
|
+
if (nextValue === undefined) return;
|
|
262
|
+
setIndicatorValue(undefined);
|
|
263
|
+
setValueInternal(nextValue);
|
|
264
|
+
},
|
|
265
|
+
[pagerValues, setValueInternal],
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
React.useEffect(() => {
|
|
269
|
+
"background only";
|
|
270
|
+
if (selectedPagerIndex >= 0) {
|
|
271
|
+
invokeSelectTab(pagerRef.current, selectedPagerIndex, false);
|
|
272
|
+
}
|
|
273
|
+
if (selectedOffset !== null) invokeScrollToOffset(listRef.current, selectedOffset);
|
|
274
|
+
}, [selectedPagerIndex, selectedOffset]);
|
|
275
|
+
|
|
276
|
+
const contextValue = React.useMemo<TabsContextValue>(
|
|
277
|
+
() => ({
|
|
278
|
+
value,
|
|
279
|
+
visualValue,
|
|
280
|
+
variantProps,
|
|
281
|
+
items,
|
|
282
|
+
pagerValues,
|
|
283
|
+
indicatorIndex,
|
|
284
|
+
selectedPagerIndex,
|
|
285
|
+
indicatorRef,
|
|
286
|
+
triggerRects,
|
|
287
|
+
registerTrigger,
|
|
288
|
+
registerContent,
|
|
289
|
+
updateTriggerDisabled,
|
|
290
|
+
syncTriggerOrder,
|
|
291
|
+
updateTriggerWidth,
|
|
292
|
+
setListRef: setListNode,
|
|
293
|
+
setPagerRef: setPagerNode,
|
|
294
|
+
selectValue,
|
|
295
|
+
handlePagerWillChange,
|
|
296
|
+
handlePagerChange,
|
|
297
|
+
}),
|
|
298
|
+
[
|
|
299
|
+
value,
|
|
300
|
+
visualValue,
|
|
301
|
+
variantProps,
|
|
302
|
+
items,
|
|
303
|
+
pagerValues,
|
|
304
|
+
indicatorIndex,
|
|
305
|
+
selectedPagerIndex,
|
|
306
|
+
indicatorRef,
|
|
307
|
+
triggerRects,
|
|
308
|
+
registerTrigger,
|
|
309
|
+
registerContent,
|
|
310
|
+
updateTriggerDisabled,
|
|
311
|
+
syncTriggerOrder,
|
|
312
|
+
updateTriggerWidth,
|
|
313
|
+
setListNode,
|
|
314
|
+
setPagerNode,
|
|
315
|
+
selectValue,
|
|
316
|
+
handlePagerWillChange,
|
|
317
|
+
handlePagerChange,
|
|
318
|
+
],
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
return (
|
|
322
|
+
<TabsContext.Provider value={contextValue}>
|
|
323
|
+
<ClassNamesProvider value={classNames}>
|
|
324
|
+
<view
|
|
325
|
+
{...(ref ? { ref: ref as LynxViewRef } : {})}
|
|
326
|
+
{...nativeProps}
|
|
327
|
+
className={clsx(classNames.root, className)}
|
|
328
|
+
style={style}
|
|
329
|
+
>
|
|
330
|
+
{children}
|
|
331
|
+
</view>
|
|
332
|
+
</ClassNamesProvider>
|
|
333
|
+
</TabsContext.Provider>
|
|
334
|
+
);
|
|
335
|
+
});
|
|
336
|
+
TabsRoot.displayName = "TabsRoot";
|
|
337
|
+
|
|
338
|
+
////////////////////////////////////////////////////////////////////////////////////
|
|
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
|
+
|
|
360
|
+
export interface TabsListProps extends LynxStyledElementProps {}
|
|
361
|
+
|
|
362
|
+
export const TabsList = React.forwardRef<unknown, TabsListProps>((props, ref) => {
|
|
363
|
+
const { children, className, style, ...nativeProps } = props;
|
|
364
|
+
const classNames = useClassNames();
|
|
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]);
|
|
372
|
+
|
|
373
|
+
const mergedRef = React.useCallback(
|
|
374
|
+
(node: NodesRef | null) => {
|
|
375
|
+
setListRef(node);
|
|
376
|
+
if (typeof ref === "function") ref(node);
|
|
377
|
+
else if (ref) ref.current = node;
|
|
378
|
+
},
|
|
379
|
+
[ref, setListRef],
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
return (
|
|
383
|
+
<scroll-view
|
|
384
|
+
ref={mergedRef}
|
|
385
|
+
{...nativeProps}
|
|
386
|
+
scroll-orientation="horizontal"
|
|
387
|
+
scroll-bar-enable={false}
|
|
388
|
+
accessibility-element={false}
|
|
389
|
+
accessibility-traits="tabbar"
|
|
390
|
+
className={clsx(classNames.list, className)}
|
|
391
|
+
style={style}
|
|
392
|
+
>
|
|
393
|
+
<view className={classNames.listContent}>{children}</view>
|
|
394
|
+
</scroll-view>
|
|
395
|
+
);
|
|
396
|
+
});
|
|
397
|
+
TabsList.displayName = "TabsList";
|
|
398
|
+
|
|
399
|
+
////////////////////////////////////////////////////////////////////////////////////
|
|
400
|
+
|
|
401
|
+
export interface TabsTriggerProps extends LynxStyledElementProps {
|
|
402
|
+
value: string;
|
|
403
|
+
disabled?: boolean;
|
|
404
|
+
"accessibility-label"?: LynxAccessibilityProps["accessibility-label"];
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export const TabsTrigger = React.forwardRef<unknown, TabsTriggerProps>((props, ref) => {
|
|
408
|
+
const {
|
|
409
|
+
children,
|
|
410
|
+
className,
|
|
411
|
+
style,
|
|
412
|
+
value: triggerValue,
|
|
413
|
+
disabled = false,
|
|
414
|
+
"accessibility-label": accessibilityLabel,
|
|
415
|
+
...nativeProps
|
|
416
|
+
} = props;
|
|
417
|
+
const context = useTabsContext("TabsTrigger");
|
|
418
|
+
const selected = context.value === triggerValue;
|
|
419
|
+
const visuallySelected = context.visualValue === triggerValue;
|
|
420
|
+
|
|
421
|
+
React.useEffect(() => {
|
|
422
|
+
"background only";
|
|
423
|
+
return context.registerTrigger(triggerValue, disabled);
|
|
424
|
+
}, [context.registerTrigger, triggerValue]);
|
|
425
|
+
|
|
426
|
+
React.useEffect(() => {
|
|
427
|
+
"background only";
|
|
428
|
+
context.updateTriggerDisabled(triggerValue, disabled);
|
|
429
|
+
}, [context.updateTriggerDisabled, triggerValue, disabled]);
|
|
430
|
+
|
|
431
|
+
const handleTap = React.useCallback<NonNullable<NativeViewProps["bindtap"]>>(() => {
|
|
432
|
+
"background only";
|
|
433
|
+
context.selectValue(triggerValue);
|
|
434
|
+
}, [context.selectValue, triggerValue]);
|
|
435
|
+
const { pressed: _pressed, ...pressHandlers } = usePressTap({ disabled, onTap: handleTap });
|
|
436
|
+
|
|
437
|
+
const handleLayoutChange = React.useCallback<LayoutChangeHandler>(
|
|
438
|
+
(...args) => {
|
|
439
|
+
"background only";
|
|
440
|
+
const width = getTabsLayoutWidth(args[0]);
|
|
441
|
+
if (width !== null) context.updateTriggerWidth(triggerValue, width);
|
|
442
|
+
},
|
|
443
|
+
[context.updateTriggerWidth, triggerValue],
|
|
444
|
+
);
|
|
445
|
+
|
|
446
|
+
const triggerClasses = tabs({
|
|
447
|
+
...context.variantProps,
|
|
448
|
+
selected: visuallySelected,
|
|
449
|
+
disabled,
|
|
450
|
+
});
|
|
451
|
+
const label =
|
|
452
|
+
typeof children === "string" || typeof children === "number" ? String(children) : undefined;
|
|
453
|
+
|
|
454
|
+
return (
|
|
455
|
+
<view
|
|
456
|
+
{...(ref ? { ref: ref as LynxViewRef } : {})}
|
|
457
|
+
{...nativeProps}
|
|
458
|
+
{...pressHandlers}
|
|
459
|
+
flatten={false}
|
|
460
|
+
bindlayoutchange={handleLayoutChange}
|
|
461
|
+
accessibility-element={true}
|
|
462
|
+
accessibility-role-description="tab"
|
|
463
|
+
accessibility-label={accessibilityLabel ?? label}
|
|
464
|
+
accessibility-value={selected ? "selected" : "not selected"}
|
|
465
|
+
accessibility-traits={disabled ? "disabled" : selected ? "selected" : "button"}
|
|
466
|
+
className={clsx(triggerClasses.trigger, className)}
|
|
467
|
+
style={style}
|
|
468
|
+
>
|
|
469
|
+
<text className={triggerClasses.triggerLabel}>{children}</text>
|
|
470
|
+
</view>
|
|
471
|
+
);
|
|
472
|
+
});
|
|
473
|
+
TabsTrigger.displayName = "TabsTrigger";
|
|
474
|
+
|
|
475
|
+
////////////////////////////////////////////////////////////////////////////////////
|
|
476
|
+
|
|
477
|
+
export interface TabsIndicatorProps extends LynxStyledElementProps {}
|
|
478
|
+
|
|
479
|
+
export const TabsIndicator = React.forwardRef<unknown, TabsIndicatorProps>((props, ref) => {
|
|
480
|
+
const { className, style, ...nativeProps } = props;
|
|
481
|
+
const classNames = useClassNames();
|
|
482
|
+
const { indicatorRef, items, indicatorIndex, triggerRects } = useTabsContext("TabsIndicator");
|
|
483
|
+
const position = indicatorIndex;
|
|
484
|
+
const lowerIndex = Math.max(0, Math.floor(position));
|
|
485
|
+
const upperIndex = Math.min(items.length - 1, Math.ceil(position));
|
|
486
|
+
const progress = Math.max(0, Math.min(1, position - lowerIndex));
|
|
487
|
+
const lowerRect = triggerRects[items[lowerIndex]?.value ?? ""];
|
|
488
|
+
const upperRect = triggerRects[items[upperIndex]?.value ?? ""] ?? lowerRect;
|
|
489
|
+
const x = lowerRect
|
|
490
|
+
? lowerRect.left + ((upperRect?.left ?? lowerRect.left) - lowerRect.left) * progress
|
|
491
|
+
: 0;
|
|
492
|
+
const width = lowerRect
|
|
493
|
+
? lowerRect.width + ((upperRect?.width ?? lowerRect.width) - lowerRect.width) * progress
|
|
494
|
+
: 0;
|
|
495
|
+
|
|
496
|
+
return (
|
|
497
|
+
<view
|
|
498
|
+
{...(ref ? { ref: ref as LynxViewRef } : {})}
|
|
499
|
+
main-thread:ref={indicatorRef}
|
|
500
|
+
{...nativeProps}
|
|
501
|
+
accessibility-elements-hidden={true}
|
|
502
|
+
className={clsx(classNames.indicator, className)}
|
|
503
|
+
style={
|
|
504
|
+
{
|
|
505
|
+
"--tabs-indicator-x": `${x}px`,
|
|
506
|
+
"--tabs-indicator-width": `${width}px`,
|
|
507
|
+
...style,
|
|
508
|
+
} as LynxViewProps["style"]
|
|
509
|
+
}
|
|
510
|
+
/>
|
|
511
|
+
);
|
|
512
|
+
});
|
|
513
|
+
TabsIndicator.displayName = "TabsIndicator";
|
|
514
|
+
|
|
515
|
+
////////////////////////////////////////////////////////////////////////////////////
|
|
516
|
+
|
|
517
|
+
export interface TabsContentProps extends LynxStyledElementProps {
|
|
518
|
+
value: string;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
export const TabsContent = React.forwardRef<unknown, TabsContentProps>((props, ref) => {
|
|
522
|
+
const { children, className, style, value: contentValue, ...nativeProps } = props;
|
|
523
|
+
const tabsContext = useTabsContext("TabsContent");
|
|
524
|
+
const inCarousel = useTabsCarouselCameraContext();
|
|
525
|
+
const selected = tabsContext.value === contentValue;
|
|
526
|
+
const disabled = tabsContext.items.find((item) => item.value === contentValue)?.disabled ?? false;
|
|
527
|
+
const contentClasses = tabs({ ...tabsContext.variantProps, selected, inCarousel });
|
|
528
|
+
|
|
529
|
+
React.useEffect(() => {
|
|
530
|
+
"background only";
|
|
531
|
+
if (inCarousel) return tabsContext.registerContent(contentValue);
|
|
532
|
+
}, [inCarousel, tabsContext.registerContent, contentValue]);
|
|
533
|
+
|
|
534
|
+
const content = (
|
|
535
|
+
<view
|
|
536
|
+
{...(ref ? { ref: ref as LynxViewRef } : {})}
|
|
537
|
+
{...nativeProps}
|
|
538
|
+
accessibility-elements-hidden={!selected}
|
|
539
|
+
accessibility-role-description="tabpanel"
|
|
540
|
+
accessibility-value={selected ? "selected" : "not selected"}
|
|
541
|
+
className={clsx(contentClasses.content, className)}
|
|
542
|
+
style={style}
|
|
543
|
+
>
|
|
544
|
+
{children}
|
|
545
|
+
</view>
|
|
546
|
+
);
|
|
547
|
+
|
|
548
|
+
if (inCarousel) {
|
|
549
|
+
if (disabled) return null;
|
|
550
|
+
return <viewpager-item>{content}</viewpager-item>;
|
|
551
|
+
}
|
|
552
|
+
return content;
|
|
553
|
+
});
|
|
554
|
+
TabsContent.displayName = "TabsContent";
|
|
555
|
+
|
|
556
|
+
////////////////////////////////////////////////////////////////////////////////////
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* @platform Lynx
|
|
560
|
+
*
|
|
561
|
+
* native viewpager를 사용하므로 웹의 `loop`, `autoHeight`, `dragThreshold`,
|
|
562
|
+
* `carouselPreventDrag`는 지원하지 않습니다.
|
|
563
|
+
*/
|
|
564
|
+
export interface TabsCarouselProps extends LynxStyledElementProps {
|
|
565
|
+
swipeable?: boolean;
|
|
566
|
+
/** iOS 뒤로가기 제스처를 우선하는 화면 왼쪽 가장자리 너비입니다. */
|
|
567
|
+
iosBackGestureEdgeWidth?: number;
|
|
568
|
+
onSettle?: () => void;
|
|
569
|
+
onSwipeStart?: () => void;
|
|
570
|
+
onSwipeEnd?: () => void;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
export const TabsCarousel = React.forwardRef<unknown, TabsCarouselProps>((props, ref) => {
|
|
574
|
+
const {
|
|
575
|
+
children,
|
|
576
|
+
className,
|
|
577
|
+
style,
|
|
578
|
+
swipeable = false,
|
|
579
|
+
iosBackGestureEdgeWidth = 32,
|
|
580
|
+
onSettle,
|
|
581
|
+
onSwipeStart,
|
|
582
|
+
onSwipeEnd,
|
|
583
|
+
...nativeProps
|
|
584
|
+
} = props;
|
|
585
|
+
const classNames = useClassNames();
|
|
586
|
+
const contextValue = React.useMemo<TabsCarouselContextValue>(
|
|
587
|
+
() => ({ swipeable, iosBackGestureEdgeWidth, onSettle, onSwipeStart, onSwipeEnd }),
|
|
588
|
+
[swipeable, iosBackGestureEdgeWidth, onSettle, onSwipeStart, onSwipeEnd],
|
|
589
|
+
);
|
|
590
|
+
|
|
591
|
+
return (
|
|
592
|
+
<TabsCarouselContext.Provider value={contextValue}>
|
|
593
|
+
<view
|
|
594
|
+
{...(ref ? { ref: ref as LynxViewRef } : {})}
|
|
595
|
+
{...nativeProps}
|
|
596
|
+
className={clsx(classNames.carousel, className)}
|
|
597
|
+
style={style}
|
|
598
|
+
>
|
|
599
|
+
{children}
|
|
600
|
+
</view>
|
|
601
|
+
</TabsCarouselContext.Provider>
|
|
602
|
+
);
|
|
603
|
+
});
|
|
604
|
+
TabsCarousel.displayName = "TabsCarousel";
|
|
605
|
+
|
|
606
|
+
////////////////////////////////////////////////////////////////////////////////////
|
|
607
|
+
|
|
608
|
+
export interface TabsCarouselCameraProps extends LynxStyledElementProps {
|
|
609
|
+
bindchange?: NativeViewPagerProps["bindchange"];
|
|
610
|
+
bindwillchange?: NativeViewPagerProps["bindwillchange"];
|
|
611
|
+
bindoffsetchange?: NativeViewPagerProps["bindoffsetchange"];
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
export const TabsCarouselCamera = React.forwardRef<unknown, TabsCarouselCameraProps>(
|
|
615
|
+
(props, ref) => {
|
|
616
|
+
const {
|
|
617
|
+
children,
|
|
618
|
+
className,
|
|
619
|
+
style,
|
|
620
|
+
bindchange,
|
|
621
|
+
bindwillchange,
|
|
622
|
+
bindoffsetchange,
|
|
623
|
+
...nativeProps
|
|
624
|
+
} = props;
|
|
625
|
+
const classNames = useClassNames();
|
|
626
|
+
const tabsContext = useTabsContext("TabsCarouselCamera");
|
|
627
|
+
const carouselContext = useTabsCarouselContext("TabsCarouselCamera");
|
|
628
|
+
const swipingRef = React.useRef(false);
|
|
629
|
+
const { indicatorRef, pagerValues, triggerRects } = tabsContext;
|
|
630
|
+
const indicatorRects = pagerValues.map((value) => triggerRects[value] ?? null);
|
|
631
|
+
|
|
632
|
+
const mergedRef = React.useCallback(
|
|
633
|
+
(node: NodesRef | null) => {
|
|
634
|
+
tabsContext.setPagerRef(node);
|
|
635
|
+
if (typeof ref === "function") ref(node);
|
|
636
|
+
else if (ref) ref.current = node;
|
|
637
|
+
},
|
|
638
|
+
[ref, tabsContext.setPagerRef],
|
|
639
|
+
);
|
|
640
|
+
|
|
641
|
+
const handleWillChange = React.useCallback(
|
|
642
|
+
(event: ViewPagerWillChangeEvent) => {
|
|
643
|
+
"background only";
|
|
644
|
+
bindwillchange?.(event);
|
|
645
|
+
if (event.detail.isDragged) {
|
|
646
|
+
tabsContext.handlePagerWillChange(event.detail.index);
|
|
647
|
+
if (!swipingRef.current) {
|
|
648
|
+
swipingRef.current = true;
|
|
649
|
+
carouselContext.onSwipeStart?.();
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
},
|
|
653
|
+
[bindwillchange, carouselContext.onSwipeStart, tabsContext.handlePagerWillChange],
|
|
654
|
+
);
|
|
655
|
+
|
|
656
|
+
const handleChange = React.useCallback(
|
|
657
|
+
(event: ViewPagerChangeEvent) => {
|
|
658
|
+
"background only";
|
|
659
|
+
bindchange?.(event);
|
|
660
|
+
tabsContext.handlePagerChange(event.detail.index);
|
|
661
|
+
carouselContext.onSettle?.();
|
|
662
|
+
if (swipingRef.current || event.detail.isDragged) {
|
|
663
|
+
swipingRef.current = false;
|
|
664
|
+
carouselContext.onSwipeEnd?.();
|
|
665
|
+
}
|
|
666
|
+
},
|
|
667
|
+
[
|
|
668
|
+
bindchange,
|
|
669
|
+
carouselContext.onSettle,
|
|
670
|
+
carouselContext.onSwipeEnd,
|
|
671
|
+
tabsContext.handlePagerChange,
|
|
672
|
+
],
|
|
673
|
+
);
|
|
674
|
+
|
|
675
|
+
const handleOffsetChange = React.useCallback(
|
|
676
|
+
(event: ViewPagerOffsetChangeEvent) => {
|
|
677
|
+
"background only";
|
|
678
|
+
bindoffsetchange?.(event);
|
|
679
|
+
},
|
|
680
|
+
[bindoffsetchange],
|
|
681
|
+
);
|
|
682
|
+
|
|
683
|
+
function handleIndicatorOffsetChange(event: ViewPagerOffsetChangeEvent) {
|
|
684
|
+
"main thread";
|
|
685
|
+
|
|
686
|
+
const position = Number(event.detail.offset);
|
|
687
|
+
if (!Number.isFinite(position)) return;
|
|
688
|
+
|
|
689
|
+
const lowerIndex = Math.max(0, Math.floor(position));
|
|
690
|
+
const upperIndex = Math.min(indicatorRects.length - 1, Math.ceil(position));
|
|
691
|
+
const progress = Math.max(0, Math.min(1, position - lowerIndex));
|
|
692
|
+
const lowerRect = indicatorRects[lowerIndex];
|
|
693
|
+
const upperRect = indicatorRects[upperIndex] ?? lowerRect;
|
|
694
|
+
if (!lowerRect) return;
|
|
695
|
+
|
|
696
|
+
const x = lowerRect.left + ((upperRect?.left ?? lowerRect.left) - lowerRect.left) * progress;
|
|
697
|
+
const width =
|
|
698
|
+
lowerRect.width + ((upperRect?.width ?? lowerRect.width) - lowerRect.width) * progress;
|
|
699
|
+
|
|
700
|
+
indicatorRef.current?.setStyleProperties({
|
|
701
|
+
"--tabs-indicator-x": `${x}px`,
|
|
702
|
+
"--tabs-indicator-width": `${width}px`,
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
return (
|
|
707
|
+
<viewpager
|
|
708
|
+
ref={mergedRef}
|
|
709
|
+
{...nativeProps}
|
|
710
|
+
initial-select-index={Math.max(0, tabsContext.selectedPagerIndex)}
|
|
711
|
+
enable-scroll={carouselContext.swipeable}
|
|
712
|
+
ios-gesture-offset={carouselContext.iosBackGestureEdgeWidth}
|
|
713
|
+
bindwillchange={handleWillChange}
|
|
714
|
+
bindchange={handleChange}
|
|
715
|
+
bindoffsetchange={handleOffsetChange}
|
|
716
|
+
main-thread:bindoffsetchange={handleIndicatorOffsetChange}
|
|
717
|
+
className={clsx(classNames.carouselCamera, className)}
|
|
718
|
+
style={style}
|
|
719
|
+
>
|
|
720
|
+
<TabsCarouselCameraContext.Provider value={true}>
|
|
721
|
+
{children}
|
|
722
|
+
</TabsCarouselCameraContext.Provider>
|
|
723
|
+
</viewpager>
|
|
724
|
+
);
|
|
725
|
+
},
|
|
726
|
+
);
|
|
727
|
+
TabsCarouselCamera.displayName = "TabsCarouselCamera";
|