@seed-design/lynx-react 0.5.2 → 0.6.0

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.
Files changed (86) hide show
  1. package/lib/components/Checkbox/Checkbox.d.ts +26 -0
  2. package/lib/components/Checkbox/Checkbox.d.ts.map +1 -1
  3. package/lib/components/Checkbox/Checkbox.jsx +1 -1
  4. package/lib/components/Icon/Icon.d.ts +5 -0
  5. package/lib/components/Icon/Icon.d.ts.map +1 -1
  6. package/lib/components/Icon/Icon.jsx +14 -3
  7. package/lib/components/MannerTemp/MannerTemp.d.ts +19 -0
  8. package/lib/components/MannerTemp/MannerTemp.d.ts.map +1 -0
  9. package/lib/components/MannerTemp/MannerTemp.jsx +47 -0
  10. package/lib/components/MannerTemp/index.d.ts +2 -0
  11. package/lib/components/MannerTemp/index.d.ts.map +1 -0
  12. package/lib/components/MannerTempBadge/MannerTempBadge.d.ts +13 -0
  13. package/lib/components/MannerTempBadge/MannerTempBadge.d.ts.map +1 -0
  14. package/lib/components/MannerTempBadge/MannerTempBadge.jsx +15 -0
  15. package/lib/components/MannerTempBadge/index.d.ts +2 -0
  16. package/lib/components/MannerTempBadge/index.d.ts.map +1 -0
  17. package/lib/components/NotificationBadge/NotificationBadge.d.ts +18 -0
  18. package/lib/components/NotificationBadge/NotificationBadge.d.ts.map +1 -0
  19. package/lib/components/NotificationBadge/NotificationBadge.jsx +34 -0
  20. package/lib/components/NotificationBadge/index.d.ts +2 -0
  21. package/lib/components/NotificationBadge/index.d.ts.map +1 -0
  22. package/lib/components/PageBanner/PageBanner.d.ts +35 -0
  23. package/lib/components/PageBanner/PageBanner.d.ts.map +1 -0
  24. package/lib/components/PageBanner/PageBanner.jsx +150 -0
  25. package/lib/components/PageBanner/PageBanner.namespace.d.ts +2 -0
  26. package/lib/components/PageBanner/PageBanner.namespace.d.ts.map +1 -0
  27. package/lib/components/PageBanner/PageBanner.namespace.js +14 -0
  28. package/lib/components/PageBanner/index.d.ts +3 -0
  29. package/lib/components/PageBanner/index.d.ts.map +1 -0
  30. package/lib/components/RadioGroup/RadioGroup.d.ts +9 -0
  31. package/lib/components/RadioGroup/RadioGroup.d.ts.map +1 -1
  32. package/lib/components/RadioGroup/RadioGroup.jsx +1 -1
  33. package/lib/components/SegmentedControl/SegmentedControl.d.ts +27 -0
  34. package/lib/components/SegmentedControl/SegmentedControl.d.ts.map +1 -0
  35. package/lib/components/SegmentedControl/SegmentedControl.jsx +120 -0
  36. package/lib/components/SegmentedControl/SegmentedControl.namespace.d.ts +2 -0
  37. package/lib/components/SegmentedControl/SegmentedControl.namespace.d.ts.map +1 -0
  38. package/lib/components/SegmentedControl/SegmentedControl.namespace.js +10 -0
  39. package/lib/components/SegmentedControl/index.d.ts +3 -0
  40. package/lib/components/SegmentedControl/index.d.ts.map +1 -0
  41. package/lib/components/SelectBox/CheckSelectBox.namespace.d.ts +2 -0
  42. package/lib/components/SelectBox/CheckSelectBox.namespace.d.ts.map +1 -0
  43. package/lib/components/SelectBox/CheckSelectBox.namespace.js +17 -0
  44. package/lib/components/SelectBox/RadioSelectBox.namespace.d.ts +2 -0
  45. package/lib/components/SelectBox/RadioSelectBox.namespace.d.ts.map +1 -0
  46. package/lib/components/SelectBox/RadioSelectBox.namespace.js +15 -0
  47. package/lib/components/SelectBox/SelectBox.d.ts +85 -0
  48. package/lib/components/SelectBox/SelectBox.d.ts.map +1 -0
  49. package/lib/components/SelectBox/SelectBox.jsx +237 -0
  50. package/lib/components/SelectBox/index.d.ts +4 -0
  51. package/lib/components/SelectBox/index.d.ts.map +1 -0
  52. package/lib/components/index.d.ts +6 -0
  53. package/lib/components/index.d.ts.map +1 -1
  54. package/lib/hooks/useIconColor.d.ts +1 -0
  55. package/lib/hooks/useIconColor.d.ts.map +1 -1
  56. package/lib/hooks/useIconColor.js +4 -1
  57. package/lib/index.js +11 -1
  58. package/package.json +3 -3
  59. package/src/components/Checkbox/Checkbox.tsx +1 -1
  60. package/src/components/Icon/Icon.test.tsx +25 -1
  61. package/src/components/Icon/Icon.tsx +22 -1
  62. package/src/components/MannerTemp/MannerTemp.test.tsx +41 -0
  63. package/src/components/MannerTemp/MannerTemp.tsx +85 -0
  64. package/src/components/MannerTemp/index.ts +1 -0
  65. package/src/components/MannerTempBadge/MannerTempBadge.test.tsx +35 -0
  66. package/src/components/MannerTempBadge/MannerTempBadge.tsx +34 -0
  67. package/src/components/MannerTempBadge/index.ts +1 -0
  68. package/src/components/NotificationBadge/NotificationBadge.test.tsx +28 -0
  69. package/src/components/NotificationBadge/NotificationBadge.tsx +81 -0
  70. package/src/components/NotificationBadge/index.ts +1 -0
  71. package/src/components/PageBanner/PageBanner.namespace.ts +16 -0
  72. package/src/components/PageBanner/PageBanner.test.tsx +124 -0
  73. package/src/components/PageBanner/PageBanner.tsx +340 -0
  74. package/src/components/PageBanner/index.ts +18 -0
  75. package/src/components/RadioGroup/RadioGroup.tsx +1 -1
  76. package/src/components/SegmentedControl/SegmentedControl.namespace.ts +8 -0
  77. package/src/components/SegmentedControl/SegmentedControl.test.tsx +81 -0
  78. package/src/components/SegmentedControl/SegmentedControl.tsx +236 -0
  79. package/src/components/SegmentedControl/index.ts +10 -0
  80. package/src/components/SelectBox/CheckSelectBox.namespace.ts +22 -0
  81. package/src/components/SelectBox/RadioSelectBox.namespace.ts +18 -0
  82. package/src/components/SelectBox/SelectBox.test.tsx +169 -0
  83. package/src/components/SelectBox/SelectBox.tsx +533 -0
  84. package/src/components/SelectBox/index.ts +3 -0
  85. package/src/components/index.ts +6 -0
  86. package/src/hooks/useIconColor.ts +6 -1
@@ -0,0 +1,340 @@
1
+ import { pageBanner, type PageBannerVariantProps } from "@seed-design/lynx-css/recipes/page-banner";
2
+ import * as React from "@lynx-js/react";
3
+ import { useMemoizedFn } from "@lynx-js/lynx-ui-common";
4
+ import clsx from "clsx";
5
+
6
+ import { useControllableState } from "../../hooks/useControllableState";
7
+ import { usePressTap } from "../../hooks/usePressTap";
8
+ import type {
9
+ LynxAccessibilityProps,
10
+ LynxPressableProps,
11
+ LynxStyledElementProps,
12
+ LynxTextRef,
13
+ LynxViewProps,
14
+ LynxViewRef,
15
+ } from "../../types";
16
+ import { createSlotRecipeContext } from "../../utils/create-slot-recipe-context";
17
+ import { IconSlotProvider } from "../Icon/Icon";
18
+
19
+ const { ClassNamesProvider, PropsProvider, useClassNames, useProps } =
20
+ createSlotRecipeContext(pageBanner);
21
+
22
+ type TapHandler = NonNullable<LynxViewProps["bindtap"]>;
23
+
24
+ interface PageBannerContextValue {
25
+ dismiss: () => void;
26
+ }
27
+
28
+ const PageBannerContext = React.createContext<PageBannerContextValue | null>(null);
29
+
30
+ function usePageBannerContext(consumer: string) {
31
+ const context = React.useContext(PageBannerContext);
32
+
33
+ if (!context) {
34
+ throw new Error(`<${consumer}/> must be rendered inside <PageBannerRoot/>.`);
35
+ }
36
+
37
+ return context;
38
+ }
39
+
40
+ ////////////////////////////////////////////////////////////////////////////////////
41
+
42
+ /**
43
+ * @platform Lynx
44
+ *
45
+ * 웹 대비 차이:
46
+ * - `asChild`와 DOM 이벤트 대신 Lynx native `<view>`와 `bindtap`을 사용합니다.
47
+ * - 웹 focus ring과 동적 scale feedback은 지원하지 않습니다.
48
+ */
49
+ export interface PageBannerRootProps
50
+ extends Omit<PageBannerVariantProps, "pressed" | "closeButtonPressed">,
51
+ LynxStyledElementProps,
52
+ LynxPressableProps,
53
+ LynxAccessibilityProps {
54
+ defaultOpen?: boolean;
55
+ open?: boolean;
56
+ onDismiss?: () => void;
57
+ }
58
+
59
+ export const PageBannerRoot = React.forwardRef<unknown, PageBannerRootProps>((props, ref) => {
60
+ if (
61
+ process.env.NODE_ENV !== "production" &&
62
+ props.variant === "solid" &&
63
+ props.tone === "magic"
64
+ ) {
65
+ console.error(
66
+ '`magic` tone is not available for `solid` variant in PageBanner components. Please use variant="weak" or a different tone instead.',
67
+ );
68
+ }
69
+
70
+ const [variantProps, otherProps] = pageBanner.splitVariantProps(props);
71
+ const {
72
+ children,
73
+ className,
74
+ style,
75
+ defaultOpen = true,
76
+ open: openProp,
77
+ onDismiss,
78
+ bindtap,
79
+ "main-thread:bindtap": mainThreadBindtap,
80
+ "accessibility-element": accessibilityElement,
81
+ "accessibility-traits": accessibilityTraits,
82
+ ...nativeProps
83
+ } = otherProps;
84
+ const [open, setOpen] = useControllableState({
85
+ value: openProp,
86
+ defaultValue: defaultOpen,
87
+ });
88
+ const isInteractive = bindtap != null || mainThreadBindtap != null;
89
+ const pressTap = usePressTap({
90
+ disabled: !isInteractive,
91
+ onTap: bindtap,
92
+ mainThreadOnTap: mainThreadBindtap,
93
+ });
94
+ const classNames = pageBanner({ ...variantProps, pressed: pressTap.pressed });
95
+ const dismiss = useMemoizedFn(() => {
96
+ if (!open) return;
97
+
98
+ setOpen(false);
99
+ onDismiss?.();
100
+ });
101
+ const contextValue = React.useMemo(() => ({ dismiss }), [dismiss]);
102
+ const iconSlotContextValue = React.useMemo(
103
+ () => ({
104
+ classNames: {
105
+ prefixIcon: classNames.prefixIcon,
106
+ suffixIcon: classNames.suffixIcon,
107
+ },
108
+ deps: [variantProps.tone ?? "neutral", variantProps.variant ?? "weak", pressTap.pressed],
109
+ }),
110
+ [
111
+ classNames.prefixIcon,
112
+ classNames.suffixIcon,
113
+ pressTap.pressed,
114
+ variantProps.tone,
115
+ variantProps.variant,
116
+ ],
117
+ );
118
+
119
+ if (!open) return null;
120
+
121
+ return (
122
+ <PageBannerContext.Provider value={contextValue}>
123
+ <ClassNamesProvider value={classNames}>
124
+ <PropsProvider value={variantProps}>
125
+ <IconSlotProvider value={iconSlotContextValue}>
126
+ <view
127
+ {...(ref ? { ref: ref as LynxViewRef } : {})}
128
+ {...nativeProps}
129
+ {...(isInteractive ? pressTap : {})}
130
+ accessibility-element={accessibilityElement ?? (isInteractive ? true : undefined)}
131
+ accessibility-traits={accessibilityTraits ?? (isInteractive ? "button" : undefined)}
132
+ className={clsx(classNames.root, className)}
133
+ style={style}
134
+ >
135
+ {children}
136
+ </view>
137
+ </IconSlotProvider>
138
+ </PropsProvider>
139
+ </ClassNamesProvider>
140
+ </PageBannerContext.Provider>
141
+ );
142
+ });
143
+ PageBannerRoot.displayName = "PageBannerRoot";
144
+
145
+ ////////////////////////////////////////////////////////////////////////////////////
146
+
147
+ export interface PageBannerContentProps extends LynxStyledElementProps, LynxAccessibilityProps {}
148
+
149
+ export const PageBannerContent = React.forwardRef<unknown, PageBannerContentProps>((props, ref) => {
150
+ const { children, className, style, ...nativeProps } = props;
151
+ const classNames = useClassNames();
152
+
153
+ return (
154
+ <view
155
+ {...(ref ? { ref: ref as LynxViewRef } : {})}
156
+ {...nativeProps}
157
+ className={clsx(classNames.content, className)}
158
+ style={style}
159
+ >
160
+ {children}
161
+ </view>
162
+ );
163
+ });
164
+ PageBannerContent.displayName = "PageBannerContent";
165
+
166
+ ////////////////////////////////////////////////////////////////////////////////////
167
+
168
+ export interface PageBannerBodyProps extends LynxStyledElementProps, LynxAccessibilityProps {}
169
+
170
+ export const PageBannerBody = React.forwardRef<unknown, PageBannerBodyProps>((props, ref) => {
171
+ const { children, className, style, ...nativeProps } = props;
172
+ const classNames = useClassNames();
173
+
174
+ return (
175
+ <text
176
+ {...(ref ? { ref: ref as LynxTextRef } : {})}
177
+ {...nativeProps}
178
+ className={clsx(classNames.body, className)}
179
+ style={style}
180
+ >
181
+ {children}
182
+ </text>
183
+ );
184
+ });
185
+ PageBannerBody.displayName = "PageBannerBody";
186
+
187
+ ////////////////////////////////////////////////////////////////////////////////////
188
+
189
+ export interface PageBannerTitleProps extends LynxStyledElementProps, LynxAccessibilityProps {}
190
+
191
+ export const PageBannerTitle = React.forwardRef<unknown, PageBannerTitleProps>((props, ref) => {
192
+ const { children, className, style, ...nativeProps } = props;
193
+ const classNames = useClassNames();
194
+
195
+ return (
196
+ <text
197
+ {...(ref ? { ref: ref as LynxTextRef } : {})}
198
+ {...nativeProps}
199
+ className={clsx(classNames.title, className)}
200
+ style={style}
201
+ >
202
+ {children}
203
+ {" "}
204
+ </text>
205
+ );
206
+ });
207
+ PageBannerTitle.displayName = "PageBannerTitle";
208
+
209
+ ////////////////////////////////////////////////////////////////////////////////////
210
+
211
+ export interface PageBannerDescriptionProps
212
+ extends LynxStyledElementProps,
213
+ LynxAccessibilityProps {}
214
+
215
+ export const PageBannerDescription = React.forwardRef<unknown, PageBannerDescriptionProps>(
216
+ (props, ref) => {
217
+ const { children, className, style, ...nativeProps } = props;
218
+ const classNames = useClassNames();
219
+
220
+ return (
221
+ <text
222
+ {...(ref ? { ref: ref as LynxTextRef } : {})}
223
+ {...nativeProps}
224
+ className={clsx(classNames.description, className)}
225
+ style={style}
226
+ >
227
+ {children}
228
+ </text>
229
+ );
230
+ },
231
+ );
232
+ PageBannerDescription.displayName = "PageBannerDescription";
233
+
234
+ ////////////////////////////////////////////////////////////////////////////////////
235
+
236
+ export interface PageBannerButtonProps
237
+ extends LynxStyledElementProps,
238
+ LynxPressableProps,
239
+ LynxAccessibilityProps {}
240
+
241
+ export const PageBannerButton = React.forwardRef<unknown, PageBannerButtonProps>((props, ref) => {
242
+ const {
243
+ children,
244
+ className,
245
+ style,
246
+ "accessibility-element": accessibilityElement = true,
247
+ "accessibility-traits": accessibilityTraits = "button",
248
+ ...nativeProps
249
+ } = props;
250
+ const classNames = useClassNames();
251
+
252
+ return (
253
+ <text
254
+ {...(ref ? { ref: ref as LynxTextRef } : {})}
255
+ {...nativeProps}
256
+ accessibility-element={accessibilityElement}
257
+ accessibility-traits={accessibilityTraits}
258
+ className={clsx(classNames.button, className)}
259
+ style={style}
260
+ >
261
+ {children}
262
+ </text>
263
+ );
264
+ });
265
+ PageBannerButton.displayName = "PageBannerButton";
266
+
267
+ ////////////////////////////////////////////////////////////////////////////////////
268
+
269
+ export interface PageBannerCloseButtonProps
270
+ extends LynxStyledElementProps,
271
+ LynxPressableProps,
272
+ LynxAccessibilityProps {}
273
+
274
+ export const PageBannerCloseButton = React.forwardRef<unknown, PageBannerCloseButtonProps>(
275
+ (props, ref) => {
276
+ const {
277
+ children,
278
+ className,
279
+ style,
280
+ bindtap,
281
+ "main-thread:bindtap": mainThreadBindtap,
282
+ "accessibility-element": accessibilityElement = true,
283
+ "accessibility-label": accessibilityLabel,
284
+ "accessibility-traits": accessibilityTraits = "button",
285
+ ...nativeProps
286
+ } = props;
287
+ const parentVariantProps = useProps() ?? {};
288
+ const { dismiss } = usePageBannerContext("PageBannerCloseButton");
289
+ const handleTap = useMemoizedFn<TapHandler>((event) => {
290
+ bindtap?.(event);
291
+ dismiss();
292
+ });
293
+ const pressTap = usePressTap({
294
+ onTap: handleTap,
295
+ mainThreadOnTap: mainThreadBindtap,
296
+ });
297
+ const classNames = pageBanner({
298
+ ...parentVariantProps,
299
+ closeButtonPressed: pressTap.pressed,
300
+ });
301
+ const iconSlotContextValue = React.useMemo(
302
+ () => ({
303
+ classNames: { suffixIcon: classNames.closeButtonIcon },
304
+ deps: [
305
+ parentVariantProps.tone ?? "neutral",
306
+ parentVariantProps.variant ?? "weak",
307
+ pressTap.pressed,
308
+ ],
309
+ }),
310
+ [
311
+ classNames.closeButtonIcon,
312
+ parentVariantProps.tone,
313
+ parentVariantProps.variant,
314
+ pressTap.pressed,
315
+ ],
316
+ );
317
+
318
+ if (process.env.NODE_ENV !== "production" && accessibilityElement && !accessibilityLabel) {
319
+ console.warn("PageBannerCloseButton requires `accessibility-label` for accessibility.");
320
+ }
321
+
322
+ return (
323
+ <IconSlotProvider value={iconSlotContextValue}>
324
+ <view
325
+ {...(ref ? { ref: ref as LynxViewRef } : {})}
326
+ {...nativeProps}
327
+ {...pressTap}
328
+ accessibility-element={accessibilityElement}
329
+ accessibility-label={accessibilityLabel}
330
+ accessibility-traits={accessibilityTraits}
331
+ className={clsx(classNames.closeButton, className)}
332
+ style={style}
333
+ >
334
+ {children}
335
+ </view>
336
+ </IconSlotProvider>
337
+ );
338
+ },
339
+ );
340
+ PageBannerCloseButton.displayName = "PageBannerCloseButton";
@@ -0,0 +1,18 @@
1
+ export {
2
+ PageBannerBody,
3
+ PageBannerButton,
4
+ PageBannerCloseButton,
5
+ PageBannerContent,
6
+ PageBannerDescription,
7
+ PageBannerRoot,
8
+ PageBannerTitle,
9
+ type PageBannerBodyProps,
10
+ type PageBannerButtonProps,
11
+ type PageBannerCloseButtonProps,
12
+ type PageBannerContentProps,
13
+ type PageBannerDescriptionProps,
14
+ type PageBannerRootProps,
15
+ type PageBannerTitleProps,
16
+ } from "./PageBanner";
17
+
18
+ export * as PageBanner from "./PageBanner.namespace";
@@ -60,7 +60,7 @@ interface RadioGroupItemContextValue {
60
60
 
61
61
  const RadioGroupItemContext = React.createContext<RadioGroupItemContextValue | null>(null);
62
62
 
63
- function useRadioGroupItemContext(consumer: string): RadioGroupItemContextValue {
63
+ export function useRadioGroupItemContext(consumer: string): RadioGroupItemContextValue {
64
64
  const ctx = React.useContext(RadioGroupItemContext);
65
65
  if (!ctx) {
66
66
  throw new Error(`<${consumer}/> must be rendered inside <RadioGroupItem/>.`);
@@ -0,0 +1,8 @@
1
+ export {
2
+ SegmentedControlIndicator as Indicator,
3
+ SegmentedControlItem as Item,
4
+ SegmentedControlRoot as Root,
5
+ type SegmentedControlIndicatorProps as IndicatorProps,
6
+ type SegmentedControlItemProps as ItemProps,
7
+ type SegmentedControlRootProps as RootProps,
8
+ } from "./SegmentedControl";
@@ -0,0 +1,81 @@
1
+ import "@testing-library/jest-dom";
2
+ import { fireEvent, render } from "@lynx-js/react/testing-library";
3
+ import { describe, expect, it, vi } from "vitest";
4
+
5
+ import * as SegmentedControl from "./SegmentedControl.namespace";
6
+
7
+ function BasicSegmentedControl(props: {
8
+ value?: string;
9
+ defaultValue?: string;
10
+ onValueChange?: (value: string) => void;
11
+ }) {
12
+ return (
13
+ <SegmentedControl.Root {...props} accessibility-label="정렬 기준">
14
+ <SegmentedControl.Item value="hot">인기순</SegmentedControl.Item>
15
+ <SegmentedControl.Item value="new">최신순</SegmentedControl.Item>
16
+ <SegmentedControl.Indicator />
17
+ </SegmentedControl.Root>
18
+ );
19
+ }
20
+
21
+ function getItem(container: HTMLElement, label: string) {
22
+ const item = Array.from(
23
+ container.querySelectorAll<HTMLElement>(".seed-segmented-control__item"),
24
+ ).find((element) => element.textContent === label);
25
+ if (!item) throw new Error(`Expected item for ${label} to exist.`);
26
+ return item;
27
+ }
28
+
29
+ describe("SegmentedControl", () => {
30
+ it("changes an uncontrolled value when an item is tapped", () => {
31
+ const onValueChange = vi.fn();
32
+ const { container } = render(
33
+ <BasicSegmentedControl defaultValue="hot" onValueChange={onValueChange} />,
34
+ );
35
+ const hot = getItem(container, "인기순");
36
+ const latest = getItem(container, "최신순");
37
+
38
+ expect(hot).toHaveAttribute("accessibility-value", "selected");
39
+ expect(latest).toHaveAttribute("accessibility-value", "not selected");
40
+
41
+ fireEvent.tap(latest);
42
+
43
+ expect(onValueChange).toHaveBeenCalledWith("new");
44
+ expect(latest).toHaveAttribute("accessibility-value", "selected");
45
+ });
46
+
47
+ it("does not select a disabled item", () => {
48
+ const onValueChange = vi.fn();
49
+ const { container } = render(
50
+ <SegmentedControl.Root defaultValue="hot" onValueChange={onValueChange}>
51
+ <SegmentedControl.Item value="hot">인기순</SegmentedControl.Item>
52
+ <SegmentedControl.Item value="new" disabled>
53
+ 최신순
54
+ </SegmentedControl.Item>
55
+ </SegmentedControl.Root>,
56
+ );
57
+ const disabledItem = getItem(container, "최신순");
58
+
59
+ fireEvent.tap(disabledItem);
60
+
61
+ expect(onValueChange).not.toHaveBeenCalled();
62
+ expect(disabledItem).toHaveAttribute("accessibility-traits", "disabled");
63
+ });
64
+
65
+ it("keeps the press-start selection on the fading item background", () => {
66
+ const { container } = render(<BasicSegmentedControl defaultValue="hot" />);
67
+ const latest = getItem(container, "최신순");
68
+ const getBackground = () =>
69
+ latest.querySelector<HTMLElement>(".seed-segmented-control__itemBackground");
70
+
71
+ fireEvent.touchstart(latest, {});
72
+ expect(getBackground()).toHaveClass("seed-segmented-control__itemBackground--pressed_true");
73
+ expect(getBackground()).toHaveClass("seed-segmented-control__itemBackground--selected_false");
74
+
75
+ fireEvent.touchend(latest, {});
76
+ fireEvent.tap(latest);
77
+
78
+ expect(getBackground()).toHaveClass("seed-segmented-control__itemBackground--pressed_false");
79
+ expect(getBackground()).toHaveClass("seed-segmented-control__itemBackground--selected_false");
80
+ });
81
+ });
@@ -0,0 +1,236 @@
1
+ import { segmentedControl } from "@seed-design/lynx-css/recipes/segmented-control";
2
+ import * as React from "@lynx-js/react";
3
+ import clsx from "clsx";
4
+
5
+ import { useControllableState } from "../../hooks/useControllableState";
6
+ import { usePressTap } from "../../hooks/usePressTap";
7
+ import type {
8
+ LynxAccessibilityProps,
9
+ LynxPressableProps,
10
+ LynxStyledElementProps,
11
+ LynxViewProps,
12
+ LynxViewRef,
13
+ } from "../../types";
14
+ import { createSlotRecipeContext } from "../../utils/create-slot-recipe-context";
15
+
16
+ interface SegmentedControlContextValue {
17
+ value: string | undefined;
18
+ disabled: boolean;
19
+ items: string[];
20
+ selectValue: (value: string) => void;
21
+ registerItem: (value: string) => () => void;
22
+ }
23
+
24
+ const SegmentedControlContext = React.createContext<SegmentedControlContextValue | null>(null);
25
+ const { ClassNamesProvider, useClassNames } = createSlotRecipeContext(segmentedControl);
26
+
27
+ function useSegmentedControlContext(consumer: string) {
28
+ const context = React.useContext(SegmentedControlContext);
29
+ if (!context) {
30
+ throw new Error(`<${consumer}/> must be rendered inside <SegmentedControlRoot/>.`);
31
+ }
32
+ return context;
33
+ }
34
+
35
+ /**
36
+ * @platform Lynx
37
+ *
38
+ * 웹 대비 미지원 기능:
39
+ * - HiddenInput, name, form: Lynx에 HTML form 제출 모델이 없음
40
+ * - focus, focusVisible: Lynx에 키보드 포커스 모델이 없음
41
+ * - raw DOM change event: 선택 변경은 onValueChange로 노출
42
+ */
43
+ export interface SegmentedControlRootProps extends LynxStyledElementProps, LynxAccessibilityProps {
44
+ value?: string;
45
+ defaultValue?: string;
46
+ disabled?: boolean;
47
+ onValueChange?: (value: string) => void;
48
+ }
49
+
50
+ export const SegmentedControlRoot = React.forwardRef<unknown, SegmentedControlRootProps>(
51
+ (props, ref) => {
52
+ const {
53
+ children,
54
+ className,
55
+ style,
56
+ value: valueProp,
57
+ defaultValue,
58
+ disabled = false,
59
+ onValueChange,
60
+ "accessibility-element": accessibilityElement = true,
61
+ "accessibility-role-description": accessibilityRoleDescription = "radiogroup",
62
+ ...nativeProps
63
+ } = props;
64
+ const [value, setValue] = useControllableState<string | undefined>({
65
+ value: valueProp,
66
+ defaultValue,
67
+ onChange(nextValue) {
68
+ "background only";
69
+ if (nextValue !== undefined) onValueChange?.(nextValue);
70
+ },
71
+ });
72
+ const [items, setItems] = React.useState<string[]>([]);
73
+
74
+ const registerItem = React.useCallback((itemValue: string) => {
75
+ setItems((current) => {
76
+ if (current.includes(itemValue)) return current;
77
+ return [...current, itemValue];
78
+ });
79
+ return () => {
80
+ "background only";
81
+ setItems((current) => current.filter((item) => item !== itemValue));
82
+ };
83
+ }, []);
84
+
85
+ const selectValue = React.useCallback(
86
+ (nextValue: string) => {
87
+ if (!disabled) setValue(nextValue);
88
+ },
89
+ [disabled, setValue],
90
+ );
91
+ const selectedIndex = items.indexOf(value ?? "");
92
+ const classNames = segmentedControl({ hasSelection: selectedIndex >= 0 });
93
+ const contextValue = React.useMemo<SegmentedControlContextValue>(
94
+ () => ({
95
+ value,
96
+ disabled,
97
+ items,
98
+ selectValue,
99
+ registerItem,
100
+ }),
101
+ [value, disabled, items, selectValue, registerItem],
102
+ );
103
+
104
+ return (
105
+ <SegmentedControlContext.Provider value={contextValue}>
106
+ <ClassNamesProvider value={classNames}>
107
+ <view
108
+ {...(ref ? { ref: ref as LynxViewRef } : {})}
109
+ {...nativeProps}
110
+ accessibility-element={accessibilityElement}
111
+ accessibility-role-description={accessibilityRoleDescription}
112
+ className={clsx(classNames.root, className)}
113
+ style={
114
+ {
115
+ "--segment-count": Math.max(items.length, 1).toString(),
116
+ "--segment-index": Math.max(selectedIndex, 0).toString(),
117
+ ...style,
118
+ } as LynxViewProps["style"]
119
+ }
120
+ >
121
+ {children}
122
+ </view>
123
+ </ClassNamesProvider>
124
+ </SegmentedControlContext.Provider>
125
+ );
126
+ },
127
+ );
128
+ SegmentedControlRoot.displayName = "SegmentedControlRoot";
129
+
130
+ export interface SegmentedControlItemProps
131
+ extends Omit<LynxStyledElementProps, "children">,
132
+ LynxAccessibilityProps,
133
+ LynxPressableProps {
134
+ children: string | number;
135
+ value: string;
136
+ disabled?: boolean;
137
+ }
138
+
139
+ export const SegmentedControlItem = React.forwardRef<unknown, SegmentedControlItemProps>(
140
+ (props, ref) => {
141
+ const {
142
+ children,
143
+ className,
144
+ style,
145
+ value: itemValue,
146
+ disabled: itemDisabled = false,
147
+ bindtap,
148
+ "main-thread:bindtap": mainThreadBindtap,
149
+ "accessibility-element": accessibilityElement = true,
150
+ "accessibility-label": accessibilityLabel,
151
+ "accessibility-role-description": accessibilityRoleDescription = "radio",
152
+ "accessibility-traits": accessibilityTraits,
153
+ ...nativeProps
154
+ } = props;
155
+ const context = useSegmentedControlContext("SegmentedControlItem");
156
+ const disabled = context.disabled || itemDisabled;
157
+ const selected = context.value === itemValue;
158
+
159
+ React.useEffect(() => {
160
+ "background only";
161
+ return context.registerItem(itemValue);
162
+ }, [context.registerItem, itemValue]);
163
+
164
+ const handleTap = React.useCallback(
165
+ (...args: Parameters<NonNullable<LynxPressableProps["bindtap"]>>) => {
166
+ "background only";
167
+ if (!selected) context.selectValue(itemValue);
168
+ bindtap?.(...args);
169
+ },
170
+ [bindtap, context.selectValue, itemValue, selected],
171
+ );
172
+ const pressSelectionRef = React.useRef(selected);
173
+ const { pressed, bindtouchstart, ...pressHandlers } = usePressTap({
174
+ disabled,
175
+ onTap: handleTap,
176
+ mainThreadOnTap: mainThreadBindtap,
177
+ });
178
+ const handleTouchStart = React.useCallback(
179
+ (...args: Parameters<typeof bindtouchstart>) => {
180
+ pressSelectionRef.current = selected;
181
+ bindtouchstart(...args);
182
+ },
183
+ [bindtouchstart, selected],
184
+ );
185
+ const classes = segmentedControl({ selected, disabled, pressed });
186
+ const pressStartClasses = segmentedControl({
187
+ selected: pressSelectionRef.current,
188
+ disabled,
189
+ pressed,
190
+ });
191
+ const label =
192
+ typeof children === "string" || typeof children === "number" ? String(children) : undefined;
193
+
194
+ return (
195
+ <view
196
+ {...(ref ? { ref: ref as LynxViewRef } : {})}
197
+ {...nativeProps}
198
+ bindtouchstart={handleTouchStart}
199
+ {...pressHandlers}
200
+ accessibility-element={accessibilityElement}
201
+ accessibility-label={accessibilityLabel ?? label}
202
+ accessibility-role-description={accessibilityRoleDescription}
203
+ accessibility-value={selected ? "selected" : "not selected"}
204
+ accessibility-traits={
205
+ disabled ? "disabled" : selected ? "selected" : (accessibilityTraits ?? "button")
206
+ }
207
+ className={clsx(classes.item, className)}
208
+ style={style}
209
+ >
210
+ <view accessibility-elements-hidden={true} className={pressStartClasses.itemBackground} />
211
+ <text className={classes.label}>{children}</text>
212
+ </view>
213
+ );
214
+ },
215
+ );
216
+ SegmentedControlItem.displayName = "SegmentedControlItem";
217
+
218
+ export interface SegmentedControlIndicatorProps extends LynxStyledElementProps {}
219
+
220
+ export const SegmentedControlIndicator = React.forwardRef<unknown, SegmentedControlIndicatorProps>(
221
+ (props, ref) => {
222
+ const { className, style, ...nativeProps } = props;
223
+ const classNames = useClassNames();
224
+
225
+ return (
226
+ <view
227
+ {...(ref ? { ref: ref as LynxViewRef } : {})}
228
+ {...nativeProps}
229
+ accessibility-elements-hidden={true}
230
+ className={clsx(classNames.indicator, className)}
231
+ style={style}
232
+ />
233
+ );
234
+ },
235
+ );
236
+ SegmentedControlIndicator.displayName = "SegmentedControlIndicator";