@seed-design/lynx-react 0.4.1 → 0.5.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 (92) hide show
  1. package/lib/components/Accordion/Accordion.d.ts +56 -0
  2. package/lib/components/Accordion/Accordion.d.ts.map +1 -0
  3. package/lib/components/Accordion/Accordion.jsx +210 -0
  4. package/lib/components/Accordion/Accordion.namespace.d.ts +2 -0
  5. package/lib/components/Accordion/Accordion.namespace.d.ts.map +1 -0
  6. package/lib/components/Accordion/Accordion.namespace.js +17 -0
  7. package/lib/components/Accordion/index.d.ts +3 -0
  8. package/lib/components/Accordion/index.d.ts.map +1 -0
  9. package/lib/components/ActionButton/ActionButton.jsx +6 -6
  10. package/lib/components/AppBar/AppBar.jsx +1 -1
  11. package/lib/components/Badge/Badge.jsx +1 -1
  12. package/lib/components/BottomSheet/BottomSheet.d.ts.map +1 -1
  13. package/lib/components/BottomSheet/BottomSheet.jsx +6 -3
  14. package/lib/components/Box/Box.jsx +1 -1
  15. package/lib/components/Callout/Callout.d.ts +32 -0
  16. package/lib/components/Callout/Callout.d.ts.map +1 -0
  17. package/lib/components/Callout/Callout.jsx +113 -0
  18. package/lib/components/Callout/Callout.namespace.d.ts +2 -0
  19. package/lib/components/Callout/Callout.namespace.d.ts.map +1 -0
  20. package/lib/components/Callout/Callout.namespace.js +13 -0
  21. package/lib/components/Callout/index.d.ts +3 -0
  22. package/lib/components/Callout/index.d.ts.map +1 -0
  23. package/lib/components/Checkbox/Checkbox.d.ts.map +1 -1
  24. package/lib/components/Checkbox/Checkbox.jsx +36 -5
  25. package/lib/components/Chip/Chip.d.ts +66 -0
  26. package/lib/components/Chip/Chip.d.ts.map +1 -0
  27. package/lib/components/Chip/Chip.jsx +159 -0
  28. package/lib/components/Chip/Chip.namespace.d.ts +2 -0
  29. package/lib/components/Chip/Chip.namespace.d.ts.map +1 -0
  30. package/lib/components/Chip/Chip.namespace.js +16 -0
  31. package/lib/components/Chip/index.d.ts +3 -0
  32. package/lib/components/Chip/index.d.ts.map +1 -0
  33. package/lib/components/Divider/Divider.d.ts +31 -0
  34. package/lib/components/Divider/Divider.d.ts.map +1 -0
  35. package/lib/components/Divider/Divider.jsx +26 -0
  36. package/lib/components/Divider/index.d.ts +2 -0
  37. package/lib/components/Divider/index.d.ts.map +1 -0
  38. package/lib/components/Field/Field.jsx +1 -1
  39. package/lib/components/Icon/Icon.jsx +2 -2
  40. package/lib/components/ProgressCircle/ProgressCircle.jsx +2 -2
  41. package/lib/components/RadioGroup/RadioGroup.jsx +2 -2
  42. package/lib/components/Stack/Stack.jsx +1 -1
  43. package/lib/components/Switch/Switch.jsx +2 -2
  44. package/lib/components/Tabs/Tabs.d.ts +59 -0
  45. package/lib/components/Tabs/Tabs.d.ts.map +1 -0
  46. package/lib/components/Tabs/Tabs.jsx +406 -0
  47. package/lib/components/Tabs/Tabs.namespace.d.ts +2 -0
  48. package/lib/components/Tabs/Tabs.namespace.d.ts.map +1 -0
  49. package/lib/components/Tabs/Tabs.namespace.js +14 -0
  50. package/lib/components/Tabs/index.d.ts +3 -0
  51. package/lib/components/Tabs/index.d.ts.map +1 -0
  52. package/lib/components/TagGroup/TagGroup.jsx +2 -2
  53. package/lib/components/Text/Text.jsx +2 -2
  54. package/lib/components/TextField/TextField.jsx +2 -2
  55. package/lib/components/index.d.ts +5 -0
  56. package/lib/components/index.d.ts.map +1 -1
  57. package/lib/hooks/useIconColor.d.ts +2 -1
  58. package/lib/hooks/useIconColor.d.ts.map +1 -1
  59. package/lib/hooks/useIconColor.js +29 -5
  60. package/lib/hooks/usePressTap.d.ts.map +1 -1
  61. package/lib/hooks/usePressTap.js +1 -0
  62. package/lib/index.js +14 -5
  63. package/lib/utils/create-slot-recipe-context.jsx +1 -1
  64. package/package.json +2 -2
  65. package/src/components/Accordion/Accordion.namespace.ts +22 -0
  66. package/src/components/Accordion/Accordion.test.tsx +129 -0
  67. package/src/components/Accordion/Accordion.tsx +453 -0
  68. package/src/components/Accordion/index.ts +24 -0
  69. package/src/components/BottomSheet/BottomSheet.test.tsx +24 -0
  70. package/src/components/BottomSheet/BottomSheet.tsx +14 -2
  71. package/src/components/Callout/Callout.namespace.ts +14 -0
  72. package/src/components/Callout/Callout.test.tsx +188 -0
  73. package/src/components/Callout/Callout.tsx +272 -0
  74. package/src/components/Callout/index.ts +16 -0
  75. package/src/components/Checkbox/Checkbox.test.tsx +176 -0
  76. package/src/components/Checkbox/Checkbox.tsx +36 -2
  77. package/src/components/Chip/Chip.namespace.ts +20 -0
  78. package/src/components/Chip/Chip.test.tsx +113 -0
  79. package/src/components/Chip/Chip.tsx +373 -0
  80. package/src/components/Chip/index.ts +22 -0
  81. package/src/components/Divider/Divider.test.tsx +73 -0
  82. package/src/components/Divider/Divider.tsx +76 -0
  83. package/src/components/Divider/index.ts +1 -0
  84. package/src/components/Icon/Icon.test.tsx +129 -0
  85. package/src/components/Tabs/Tabs.namespace.ts +16 -0
  86. package/src/components/Tabs/Tabs.test.tsx +181 -0
  87. package/src/components/Tabs/Tabs.tsx +706 -0
  88. package/src/components/Tabs/index.ts +2 -0
  89. package/src/components/index.ts +5 -0
  90. package/src/hooks/useIconColor.ts +43 -5
  91. package/src/hooks/usePressTap.test.ts +13 -0
  92. package/src/hooks/usePressTap.ts +1 -0
@@ -38,6 +38,8 @@ interface CheckboxContextValue {
38
38
  indeterminate: boolean;
39
39
  disabled: boolean;
40
40
  pressed: boolean;
41
+ pressStartChecked: boolean;
42
+ pressStartIndeterminate: boolean;
41
43
  checkboxVariantProps: CheckboxVariantProps;
42
44
  checkmarkVariantProps: CheckmarkVariantProps;
43
45
  toggle: () => void;
@@ -103,10 +105,18 @@ export const CheckboxRoot = React.forwardRef<unknown, CheckboxRootProps>((props,
103
105
 
104
106
  const toggle = React.useCallback(() => setChecked(!checked), [checked, setChecked]);
105
107
 
106
- const { pressed, ...pressHandlers } = usePressTap({
108
+ const pressSelectionRef = React.useRef({ checked, indeterminate });
109
+ const { pressed, bindtouchstart, ...pressHandlers } = usePressTap({
107
110
  disabled,
108
111
  onTap: toggle,
109
112
  });
113
+ const handleTouchStart = React.useCallback(
114
+ (...args: Parameters<typeof bindtouchstart>) => {
115
+ pressSelectionRef.current = { checked, indeterminate };
116
+ bindtouchstart(...args);
117
+ },
118
+ [bindtouchstart, checked, indeterminate],
119
+ );
110
120
 
111
121
  const rootClassName = checkbox({ ...checkboxVariantProps, disabled }).root;
112
122
 
@@ -116,6 +126,8 @@ export const CheckboxRoot = React.forwardRef<unknown, CheckboxRootProps>((props,
116
126
  indeterminate,
117
127
  disabled,
118
128
  pressed,
129
+ pressStartChecked: pressSelectionRef.current.checked,
130
+ pressStartIndeterminate: pressSelectionRef.current.indeterminate,
119
131
  checkboxVariantProps,
120
132
  checkmarkVariantProps,
121
133
  toggle,
@@ -136,6 +148,7 @@ export const CheckboxRoot = React.forwardRef<unknown, CheckboxRootProps>((props,
136
148
  <view
137
149
  {...(ref ? { ref: ref as LynxViewRef } : {})}
138
150
  className={clsx(rootClassName, className)}
151
+ bindtouchstart={handleTouchStart}
139
152
  {...pressHandlers}
140
153
  {...nativeProps}
141
154
  >
@@ -166,15 +179,36 @@ export const CheckboxControl = React.forwardRef<unknown, CheckboxControlProps>((
166
179
  };
167
180
  const classes = checkmark(checkmarkVariantProps);
168
181
 
182
+ // Lynx는 opaque color와 transparent black 사이의 background-color를 보간할 때
183
+ // 중간 RGB가 검게 탁해진다. ghost root는 투명 상태로 고정하고 별도 배경의
184
+ // opacity만 전환한다.
185
+ const pressStartClasses = checkmark({
186
+ ...checkmarkVariantProps,
187
+ checked: context.pressStartChecked,
188
+ indeterminate: context.pressStartIndeterminate,
189
+ pressed: context.pressed,
190
+ });
191
+ const isGhost = checkmarkVariantProps.variant === "ghost";
192
+ // ghost root 상태를 고정하고 overlay opacity만 전환한다.
193
+ const rootClassName = isGhost
194
+ ? checkmark({
195
+ ...checkmarkVariantProps,
196
+ checked: false,
197
+ indeterminate: false,
198
+ pressed: false,
199
+ }).root
200
+ : classes.root;
201
+
169
202
  return (
170
203
  <CheckmarkControlContext.Provider
171
204
  value={{ iconClassName: classes.icon, checkmarkVariantProps }}
172
205
  >
173
206
  <view
174
207
  {...(ref ? { ref: ref as LynxViewRef } : {})}
175
- className={clsx(classes.root, className)}
208
+ className={clsx(rootClassName, className)}
176
209
  {...nativeProps}
177
210
  >
211
+ {isGhost ? <view className={pressStartClasses.background} /> : null}
178
212
  {children}
179
213
  </view>
180
214
  </CheckmarkControlContext.Provider>
@@ -0,0 +1,20 @@
1
+ export {
2
+ ChipButton as Button,
3
+ ChipLabel as Label,
4
+ ChipPrefixAvatar as PrefixAvatar,
5
+ ChipPrefixIcon as PrefixIcon,
6
+ ChipRadioItem as RadioItem,
7
+ ChipRadioRoot as RadioRoot,
8
+ ChipRoot as Root,
9
+ ChipSuffixIcon as SuffixIcon,
10
+ ChipToggle as Toggle,
11
+ type ChipButtonProps as ButtonProps,
12
+ type ChipLabelProps as LabelProps,
13
+ type ChipPrefixAvatarProps as PrefixAvatarProps,
14
+ type ChipPrefixIconProps as PrefixIconProps,
15
+ type ChipRadioItemProps as RadioItemProps,
16
+ type ChipRadioRootProps as RadioRootProps,
17
+ type ChipRootProps as RootProps,
18
+ type ChipSuffixIconProps as SuffixIconProps,
19
+ type ChipToggleProps as ToggleProps,
20
+ } from "./Chip";
@@ -0,0 +1,113 @@
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 Chip from "./Chip.namespace";
6
+
7
+ function getRenderedRoot() {
8
+ const root = elementTree.root;
9
+
10
+ if (!root) {
11
+ throw new Error("Expected Lynx render root to exist.");
12
+ }
13
+
14
+ return root;
15
+ }
16
+
17
+ function getChipRoot() {
18
+ const root = getRenderedRoot();
19
+
20
+ if (root.classList.contains("seed-chip__root")) return root;
21
+
22
+ const chipRoot = root.querySelector<HTMLElement>(".seed-chip__root");
23
+ if (!chipRoot) throw new Error("Expected Chip root to exist.");
24
+
25
+ return chipRoot;
26
+ }
27
+
28
+ describe("Chip", () => {
29
+ it("renders the button and label recipe slots", () => {
30
+ render(
31
+ <Chip.Button size="large" variant="outlineStrong">
32
+ <Chip.Label>버튼 칩</Chip.Label>
33
+ </Chip.Button>,
34
+ );
35
+
36
+ const root = getChipRoot();
37
+ const label = root.querySelector(".seed-chip__label");
38
+
39
+ expect(root).toHaveClass("seed-chip__root--size_large");
40
+ expect(root).toHaveClass("seed-chip__root--variant_outlineStrong");
41
+ expect(label).toHaveTextContent("버튼 칩");
42
+ expect(label).toHaveClass("seed-chip__label--size_large");
43
+ });
44
+
45
+ it("toggles uncontrolled selected state", () => {
46
+ const onCheckedChange = vi.fn();
47
+ render(
48
+ <Chip.Toggle onCheckedChange={onCheckedChange}>
49
+ <Chip.Label>토글 칩</Chip.Label>
50
+ </Chip.Toggle>,
51
+ );
52
+
53
+ const root = getChipRoot();
54
+ expect(root).toHaveClass("seed-chip__root--selected_false");
55
+
56
+ fireEvent.tap(root);
57
+
58
+ expect(onCheckedChange).toHaveBeenCalledWith(true);
59
+ expect(getChipRoot()).toHaveClass("seed-chip__root--selected_true");
60
+ expect(getChipRoot()).toHaveAttribute("accessibility-traits", "selected");
61
+ });
62
+
63
+ it("does not toggle when disabled", () => {
64
+ const onCheckedChange = vi.fn();
65
+ render(
66
+ <Chip.Toggle disabled onCheckedChange={onCheckedChange}>
67
+ <Chip.Label>비활성 칩</Chip.Label>
68
+ </Chip.Toggle>,
69
+ );
70
+
71
+ const root = getChipRoot();
72
+ fireEvent.tap(root);
73
+
74
+ expect(onCheckedChange).not.toHaveBeenCalled();
75
+ expect(root).toHaveClass("seed-chip__root--disabled_true");
76
+ expect(root).toHaveAttribute("accessibility-traits", "disabled");
77
+ });
78
+
79
+ it("selects one radio item and reports the value", () => {
80
+ const onValueChange = vi.fn();
81
+ render(
82
+ <Chip.RadioRoot defaultValue="first" onValueChange={onValueChange}>
83
+ <Chip.RadioItem value="first">
84
+ <Chip.Label>첫 번째</Chip.Label>
85
+ </Chip.RadioItem>
86
+ <Chip.RadioItem value="second">
87
+ <Chip.Label>두 번째</Chip.Label>
88
+ </Chip.RadioItem>
89
+ </Chip.RadioRoot>,
90
+ );
91
+
92
+ const roots = getRenderedRoot().querySelectorAll(".seed-chip__root");
93
+ expect(roots[0]).toHaveClass("seed-chip__root--selected_true");
94
+ expect(roots[1]).toHaveClass("seed-chip__root--selected_false");
95
+
96
+ fireEvent.tap(roots[1] as HTMLElement);
97
+
98
+ expect(onValueChange).toHaveBeenCalledWith("second");
99
+ const updatedRoots = getRenderedRoot().querySelectorAll(".seed-chip__root");
100
+ expect(updatedRoots[0]).toHaveClass("seed-chip__root--selected_false");
101
+ expect(updatedRoots[1]).toHaveClass("seed-chip__root--selected_true");
102
+ });
103
+
104
+ it("requires radio items to be rendered inside a radio root", () => {
105
+ expect(() => {
106
+ render(
107
+ <Chip.RadioItem value="orphan">
108
+ <Chip.Label>고립된 칩</Chip.Label>
109
+ </Chip.RadioItem>,
110
+ );
111
+ }).toThrow(/must be rendered inside <ChipRadioRoot\/>/);
112
+ });
113
+ });
@@ -0,0 +1,373 @@
1
+ import * as React from "@lynx-js/react";
2
+ import clsx from "clsx";
3
+
4
+ import { chip, type ChipVariantProps } from "@seed-design/lynx-css/recipes/chip";
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
+ LynxViewRef,
14
+ } from "../../types";
15
+ import { createSlotRecipeContext } from "../../utils/create-slot-recipe-context";
16
+ import {
17
+ IconRequired,
18
+ IconSlotProvider,
19
+ PrefixIcon,
20
+ SuffixIcon,
21
+ type PrefixIconProps,
22
+ type SuffixIconProps,
23
+ } from "../Icon/Icon";
24
+
25
+ const { ClassNamesProvider, useClassNames } = createSlotRecipeContext(chip);
26
+
27
+ type ChipPublicVariantProps = Omit<ChipVariantProps, "selected" | "pressed">;
28
+
29
+ interface ChipRootViewProps
30
+ extends ChipVariantProps,
31
+ LynxStyledElementProps,
32
+ LynxPressableProps,
33
+ LynxAccessibilityProps {}
34
+
35
+ const ChipRootView = React.forwardRef<unknown, ChipRootViewProps>((props, ref) => {
36
+ const [variantProps, otherProps] = chip.splitVariantProps(props);
37
+ const classes = chip(variantProps);
38
+ const { children, className, ...nativeProps } = otherProps;
39
+ const iconSlotContextValue = React.useMemo(
40
+ () => ({
41
+ classNames: {
42
+ icon: classes.icon,
43
+ prefixIcon: classes.prefixIcon,
44
+ suffixIcon: classes.suffixIcon,
45
+ },
46
+ deps: [
47
+ variantProps.variant,
48
+ variantProps.size,
49
+ variantProps.layout,
50
+ variantProps.selected,
51
+ variantProps.pressed,
52
+ variantProps.disabled,
53
+ ],
54
+ }),
55
+ [
56
+ classes.icon,
57
+ classes.prefixIcon,
58
+ classes.suffixIcon,
59
+ variantProps.variant,
60
+ variantProps.size,
61
+ variantProps.layout,
62
+ variantProps.selected,
63
+ variantProps.pressed,
64
+ variantProps.disabled,
65
+ ],
66
+ );
67
+
68
+ return (
69
+ <ClassNamesProvider value={classes}>
70
+ <IconSlotProvider value={iconSlotContextValue}>
71
+ <IconRequired enabled={variantProps.layout === "iconOnly"}>
72
+ <view
73
+ {...(ref ? { ref: ref as LynxViewRef } : {})}
74
+ className={clsx(classes.root, className)}
75
+ {...nativeProps}
76
+ >
77
+ {children}
78
+ </view>
79
+ </IconRequired>
80
+ </IconSlotProvider>
81
+ </ClassNamesProvider>
82
+ );
83
+ });
84
+ ChipRootView.displayName = "ChipRootView";
85
+
86
+ ////////////////////////////////////////////////////////////////////////////////////
87
+
88
+ /**
89
+ * @platform Lynx
90
+ *
91
+ * 웹 대비 미지원 기능:
92
+ * - HTML button 속성 및 native form 제출
93
+ * - 키보드 focus / focusVisible
94
+ * - `asChild`
95
+ */
96
+ export interface ChipButtonProps
97
+ extends ChipPublicVariantProps,
98
+ LynxStyledElementProps,
99
+ LynxPressableProps,
100
+ LynxAccessibilityProps {
101
+ disabled?: boolean;
102
+ }
103
+
104
+ export const ChipButton = React.forwardRef<unknown, ChipButtonProps>((props, ref) => {
105
+ const {
106
+ bindtap,
107
+ "main-thread:bindtap": mainThreadBindtap,
108
+ disabled = false,
109
+ "accessibility-element": accessibilityElement = true,
110
+ "accessibility-traits": accessibilityTraits,
111
+ ...restProps
112
+ } = props;
113
+ const { pressed, ...pressHandlers } = usePressTap({
114
+ disabled,
115
+ onTap: bindtap,
116
+ mainThreadOnTap: mainThreadBindtap,
117
+ });
118
+
119
+ return (
120
+ <ChipRootView
121
+ ref={ref}
122
+ {...restProps}
123
+ disabled={disabled}
124
+ selected={false}
125
+ pressed={pressed}
126
+ accessibility-element={accessibilityElement}
127
+ accessibility-traits={disabled ? "disabled" : (accessibilityTraits ?? "button")}
128
+ {...pressHandlers}
129
+ />
130
+ );
131
+ });
132
+ ChipButton.displayName = "ChipButton";
133
+
134
+ /** React `Chip.Root`에 대응하는 기본 action chip입니다. */
135
+ export interface ChipRootProps extends ChipButtonProps {}
136
+
137
+ export const ChipRoot = React.forwardRef<unknown, ChipRootProps>((props, ref) => (
138
+ <ChipButton ref={ref} {...props} />
139
+ ));
140
+ ChipRoot.displayName = "ChipRoot";
141
+
142
+ ////////////////////////////////////////////////////////////////////////////////////
143
+
144
+ /**
145
+ * @platform Lynx
146
+ *
147
+ * 웹 대비 미지원 기능:
148
+ * - HiddenInput / inputProps / name / value: Lynx에 HTML form 제출 모델이 없음
149
+ * - raw DOM `onChange`: `onCheckedChange`로 대체
150
+ */
151
+ export interface ChipToggleProps extends ChipButtonProps {
152
+ checked?: boolean;
153
+ defaultChecked?: boolean;
154
+ onCheckedChange?: (checked: boolean) => void;
155
+ }
156
+
157
+ export const ChipToggle = React.forwardRef<unknown, ChipToggleProps>((props, ref) => {
158
+ const {
159
+ checked: checkedProp,
160
+ defaultChecked = false,
161
+ onCheckedChange,
162
+ bindtap,
163
+ "main-thread:bindtap": mainThreadBindtap,
164
+ disabled = false,
165
+ "accessibility-element": accessibilityElement = true,
166
+ "accessibility-traits": accessibilityTraits,
167
+ ...restProps
168
+ } = props;
169
+ const [checked, setChecked] = useControllableState({
170
+ value: checkedProp,
171
+ defaultValue: defaultChecked,
172
+ onChange: onCheckedChange,
173
+ });
174
+ const handleTap = React.useCallback(
175
+ (...args: Parameters<NonNullable<LynxPressableProps["bindtap"]>>) => {
176
+ setChecked(!checked);
177
+ bindtap?.(...args);
178
+ },
179
+ [bindtap, checked, setChecked],
180
+ );
181
+ const { pressed, ...pressHandlers } = usePressTap({
182
+ disabled,
183
+ onTap: handleTap,
184
+ mainThreadOnTap: mainThreadBindtap,
185
+ });
186
+
187
+ return (
188
+ <ChipRootView
189
+ ref={ref}
190
+ {...restProps}
191
+ disabled={disabled}
192
+ selected={checked}
193
+ pressed={pressed}
194
+ accessibility-element={accessibilityElement}
195
+ accessibility-traits={
196
+ disabled ? "disabled" : checked ? "selected" : (accessibilityTraits ?? "button")
197
+ }
198
+ {...pressHandlers}
199
+ />
200
+ );
201
+ });
202
+ ChipToggle.displayName = "ChipToggle";
203
+
204
+ ////////////////////////////////////////////////////////////////////////////////////
205
+
206
+ interface ChipRadioGroupContextValue {
207
+ value: string | null;
208
+ setValue: (value: string) => void;
209
+ disabled: boolean;
210
+ }
211
+
212
+ const ChipRadioGroupContext = React.createContext<ChipRadioGroupContextValue | null>(null);
213
+
214
+ function useChipRadioGroupContext(consumer: string): ChipRadioGroupContextValue {
215
+ const context = React.useContext(ChipRadioGroupContext);
216
+ if (!context) {
217
+ throw new Error(`<${consumer}/> must be rendered inside <ChipRadioRoot/>.`);
218
+ }
219
+ return context;
220
+ }
221
+
222
+ export interface ChipRadioRootProps extends LynxStyledElementProps {
223
+ value?: string;
224
+ defaultValue?: string;
225
+ disabled?: boolean;
226
+ onValueChange?: (value: string) => void;
227
+ }
228
+
229
+ export const ChipRadioRoot = React.forwardRef<unknown, ChipRadioRootProps>((props, ref) => {
230
+ const {
231
+ value: valueProp,
232
+ defaultValue,
233
+ disabled = false,
234
+ onValueChange,
235
+ children,
236
+ className,
237
+ ...nativeProps
238
+ } = props;
239
+ const handleChange = React.useCallback(
240
+ (nextValue: string | null) => {
241
+ if (nextValue !== null) onValueChange?.(nextValue);
242
+ },
243
+ [onValueChange],
244
+ );
245
+ const [value, setValueInternal] = useControllableState<string | null>({
246
+ value: valueProp !== undefined ? valueProp : undefined,
247
+ defaultValue: defaultValue ?? null,
248
+ onChange: handleChange,
249
+ });
250
+ const setValue = React.useCallback(
251
+ (nextValue: string) => setValueInternal(nextValue),
252
+ [setValueInternal],
253
+ );
254
+ const contextValue = React.useMemo(
255
+ () => ({ value, setValue, disabled }),
256
+ [value, setValue, disabled],
257
+ );
258
+
259
+ return (
260
+ <ChipRadioGroupContext.Provider value={contextValue}>
261
+ <view {...(ref ? { ref: ref as LynxViewRef } : {})} className={className} {...nativeProps}>
262
+ {children}
263
+ </view>
264
+ </ChipRadioGroupContext.Provider>
265
+ );
266
+ });
267
+ ChipRadioRoot.displayName = "ChipRadioRoot";
268
+
269
+ /**
270
+ * @platform Lynx
271
+ *
272
+ * 웹 대비 미지원 기능:
273
+ * - HiddenInput / inputProps / name / required: Lynx에 HTML form 제출 모델이 없음
274
+ * - raw DOM `onChange`: `Chip.RadioRoot`의 `onValueChange`로 대체
275
+ */
276
+ export interface ChipRadioItemProps extends ChipButtonProps {
277
+ value: string;
278
+ }
279
+
280
+ export const ChipRadioItem = React.forwardRef<unknown, ChipRadioItemProps>((props, ref) => {
281
+ const {
282
+ value: itemValue,
283
+ disabled: itemDisabled = false,
284
+ bindtap,
285
+ "main-thread:bindtap": mainThreadBindtap,
286
+ "accessibility-element": accessibilityElement = true,
287
+ "accessibility-traits": accessibilityTraits,
288
+ ...restProps
289
+ } = props;
290
+ const group = useChipRadioGroupContext("ChipRadioItem");
291
+ const disabled = group.disabled || itemDisabled;
292
+ const selected = group.value === itemValue;
293
+ const handleTap = React.useCallback(
294
+ (...args: Parameters<NonNullable<LynxPressableProps["bindtap"]>>) => {
295
+ if (!selected) group.setValue(itemValue);
296
+ bindtap?.(...args);
297
+ },
298
+ [bindtap, group, itemValue, selected],
299
+ );
300
+ const { pressed, ...pressHandlers } = usePressTap({
301
+ disabled,
302
+ onTap: handleTap,
303
+ mainThreadOnTap: mainThreadBindtap,
304
+ });
305
+
306
+ return (
307
+ <ChipRootView
308
+ ref={ref}
309
+ {...restProps}
310
+ disabled={disabled}
311
+ selected={selected}
312
+ pressed={pressed}
313
+ accessibility-element={accessibilityElement}
314
+ accessibility-traits={
315
+ disabled ? "disabled" : selected ? "selected" : (accessibilityTraits ?? "button")
316
+ }
317
+ {...pressHandlers}
318
+ />
319
+ );
320
+ });
321
+ ChipRadioItem.displayName = "ChipRadioItem";
322
+
323
+ ////////////////////////////////////////////////////////////////////////////////////
324
+
325
+ export interface ChipLabelProps extends LynxStyledElementProps {}
326
+
327
+ export const ChipLabel = React.forwardRef<unknown, ChipLabelProps>((props, ref) => {
328
+ const classes = useClassNames();
329
+ const { children, className, ...nativeProps } = props;
330
+
331
+ return (
332
+ <text
333
+ {...(ref ? { ref: ref as LynxTextRef } : {})}
334
+ className={clsx(classes.label, className)}
335
+ {...nativeProps}
336
+ >
337
+ {children}
338
+ </text>
339
+ );
340
+ });
341
+ ChipLabel.displayName = "ChipLabel";
342
+
343
+ export interface ChipPrefixIconProps extends PrefixIconProps {}
344
+
345
+ export const ChipPrefixIcon = React.forwardRef<unknown, ChipPrefixIconProps>((props, ref) => (
346
+ <PrefixIcon ref={ref} {...props} />
347
+ ));
348
+ ChipPrefixIcon.displayName = "ChipPrefixIcon";
349
+
350
+ export interface ChipPrefixAvatarProps extends LynxStyledElementProps {}
351
+
352
+ export const ChipPrefixAvatar = React.forwardRef<unknown, ChipPrefixAvatarProps>((props, ref) => {
353
+ const classes = useClassNames();
354
+ const { children, className, ...nativeProps } = props;
355
+
356
+ return (
357
+ <view
358
+ {...(ref ? { ref: ref as LynxViewRef } : {})}
359
+ className={clsx(classes.prefixAvatar, className)}
360
+ {...nativeProps}
361
+ >
362
+ {children}
363
+ </view>
364
+ );
365
+ });
366
+ ChipPrefixAvatar.displayName = "ChipPrefixAvatar";
367
+
368
+ export interface ChipSuffixIconProps extends SuffixIconProps {}
369
+
370
+ export const ChipSuffixIcon = React.forwardRef<unknown, ChipSuffixIconProps>((props, ref) => (
371
+ <SuffixIcon ref={ref} {...props} />
372
+ ));
373
+ ChipSuffixIcon.displayName = "ChipSuffixIcon";
@@ -0,0 +1,22 @@
1
+ export {
2
+ ChipButton,
3
+ ChipLabel,
4
+ ChipPrefixAvatar,
5
+ ChipPrefixIcon,
6
+ ChipRadioItem,
7
+ ChipRadioRoot,
8
+ ChipRoot,
9
+ ChipSuffixIcon,
10
+ ChipToggle,
11
+ type ChipButtonProps,
12
+ type ChipLabelProps,
13
+ type ChipPrefixAvatarProps,
14
+ type ChipPrefixIconProps,
15
+ type ChipRadioItemProps,
16
+ type ChipRadioRootProps,
17
+ type ChipRootProps,
18
+ type ChipSuffixIconProps,
19
+ type ChipToggleProps,
20
+ } from "./Chip";
21
+
22
+ export * as Chip from "./Chip.namespace";
@@ -0,0 +1,73 @@
1
+ import "@testing-library/jest-dom";
2
+ import { render } from "@lynx-js/react/testing-library";
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import { Divider } from "./Divider";
6
+
7
+ function getRenderedDivider() {
8
+ const divider = elementTree.root?.firstElementChild;
9
+
10
+ if (!divider) {
11
+ throw new Error("Expected Divider to render a root element.");
12
+ }
13
+
14
+ return divider as HTMLElement;
15
+ }
16
+
17
+ function expectStyle(style: CSSStyleDeclaration, expected: Record<string, string>) {
18
+ for (const [key, value] of Object.entries(expected)) {
19
+ expect(style.getPropertyValue(key)).toBe(value);
20
+ }
21
+ }
22
+
23
+ describe("Divider", () => {
24
+ it("renders a horizontal semantic divider by default", () => {
25
+ render(<Divider className="custom-divider" />);
26
+
27
+ const divider = getRenderedDivider();
28
+
29
+ expect(divider).toHaveClass("custom-divider");
30
+ expect(divider).toHaveAttribute("accessibility-element", "true");
31
+ expect(divider).toHaveAttribute("accessibility-role-description", "separator");
32
+ expectStyle(divider.style, {
33
+ width: "100%",
34
+ "border-color": "var(--seed-color-stroke-neutral-muted)",
35
+ "border-bottom-width": "1px",
36
+ "border-right-width": "0px",
37
+ });
38
+ });
39
+
40
+ it("supports vertical orientation, inset spacing, color, and thickness", () => {
41
+ render(
42
+ <Divider
43
+ orientation="vertical"
44
+ inset
45
+ color="palette.blue500"
46
+ thickness="2px"
47
+ accessibility-element={false}
48
+ />,
49
+ );
50
+
51
+ const divider = getRenderedDivider();
52
+
53
+ expect(divider).toHaveAttribute("accessibility-element", "false");
54
+ expectStyle(divider.style, {
55
+ height: "calc(100% - 32px)",
56
+ "border-color": "var(--seed-color-palette-blue-500)",
57
+ "border-bottom-width": "0px",
58
+ "border-right-width": "2px",
59
+ "margin-top": "16px",
60
+ "margin-bottom": "16px",
61
+ });
62
+ });
63
+
64
+ it("lets user style override the default layout style", () => {
65
+ render(<Divider style={{ width: "80%", marginLeft: "4px" }} inset />);
66
+
67
+ expectStyle(getRenderedDivider().style, {
68
+ width: "80%",
69
+ "margin-left": "4px",
70
+ "margin-right": "16px",
71
+ });
72
+ });
73
+ });