@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.
Files changed (96) 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 +419 -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/Tabs.utils.d.ts +18 -0
  51. package/lib/components/Tabs/Tabs.utils.d.ts.map +1 -0
  52. package/lib/components/Tabs/Tabs.utils.js +37 -0
  53. package/lib/components/Tabs/index.d.ts +3 -0
  54. package/lib/components/Tabs/index.d.ts.map +1 -0
  55. package/lib/components/TagGroup/TagGroup.jsx +2 -2
  56. package/lib/components/Text/Text.jsx +2 -2
  57. package/lib/components/TextField/TextField.jsx +2 -2
  58. package/lib/components/index.d.ts +5 -0
  59. package/lib/components/index.d.ts.map +1 -1
  60. package/lib/hooks/useIconColor.d.ts +2 -1
  61. package/lib/hooks/useIconColor.d.ts.map +1 -1
  62. package/lib/hooks/useIconColor.js +29 -5
  63. package/lib/hooks/usePressTap.d.ts.map +1 -1
  64. package/lib/hooks/usePressTap.js +1 -0
  65. package/lib/index.js +14 -5
  66. package/lib/utils/create-slot-recipe-context.jsx +1 -1
  67. package/package.json +2 -2
  68. package/src/components/Accordion/Accordion.namespace.ts +22 -0
  69. package/src/components/Accordion/Accordion.test.tsx +129 -0
  70. package/src/components/Accordion/Accordion.tsx +453 -0
  71. package/src/components/Accordion/index.ts +24 -0
  72. package/src/components/BottomSheet/BottomSheet.test.tsx +24 -0
  73. package/src/components/BottomSheet/BottomSheet.tsx +14 -2
  74. package/src/components/Callout/Callout.namespace.ts +14 -0
  75. package/src/components/Callout/Callout.test.tsx +188 -0
  76. package/src/components/Callout/Callout.tsx +272 -0
  77. package/src/components/Callout/index.ts +16 -0
  78. package/src/components/Checkbox/Checkbox.test.tsx +176 -0
  79. package/src/components/Checkbox/Checkbox.tsx +36 -2
  80. package/src/components/Chip/Chip.namespace.ts +20 -0
  81. package/src/components/Chip/Chip.test.tsx +113 -0
  82. package/src/components/Chip/Chip.tsx +373 -0
  83. package/src/components/Chip/index.ts +22 -0
  84. package/src/components/Divider/Divider.test.tsx +73 -0
  85. package/src/components/Divider/Divider.tsx +76 -0
  86. package/src/components/Divider/index.ts +1 -0
  87. package/src/components/Icon/Icon.test.tsx +129 -0
  88. package/src/components/Tabs/Tabs.namespace.ts +16 -0
  89. package/src/components/Tabs/Tabs.test.tsx +221 -0
  90. package/src/components/Tabs/Tabs.tsx +727 -0
  91. package/src/components/Tabs/Tabs.utils.ts +56 -0
  92. package/src/components/Tabs/index.ts +2 -0
  93. package/src/components/index.ts +5 -0
  94. package/src/hooks/useIconColor.ts +43 -5
  95. package/src/hooks/usePressTap.test.ts +13 -0
  96. package/src/hooks/usePressTap.ts +1 -0
@@ -0,0 +1,188 @@
1
+ import "@testing-library/jest-dom";
2
+ import { fireEvent, getQueriesForElement, render } from "@lynx-js/react/testing-library";
3
+ import { describe, expect, it, vi } from "vitest";
4
+
5
+ import * as Callout from "./Callout.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 getCalloutRoot() {
18
+ const root = getRenderedRoot();
19
+
20
+ if (root.classList.contains("seed-callout__root")) return root;
21
+
22
+ const calloutRoot = root.querySelector<HTMLElement>(".seed-callout__root");
23
+ if (!calloutRoot) {
24
+ throw new Error("Expected Callout root to exist.");
25
+ }
26
+
27
+ return calloutRoot;
28
+ }
29
+
30
+ function getRenderedQueries() {
31
+ return getQueriesForElement(getRenderedRoot());
32
+ }
33
+
34
+ describe("Callout", () => {
35
+ it("renders compound slots with the default neutral tone", () => {
36
+ render(
37
+ <Callout.Root>
38
+ <Callout.Content>
39
+ <Callout.Title>Title</Callout.Title>
40
+ <Callout.Description>Description</Callout.Description>
41
+ <Callout.Link bindtap={() => undefined}>Learn more</Callout.Link>
42
+ </Callout.Content>
43
+ <Callout.CloseButton accessibility-label="Close" />
44
+ </Callout.Root>,
45
+ );
46
+
47
+ const root = getCalloutRoot();
48
+
49
+ expect(root).toHaveClass("seed-callout__root--tone_neutral");
50
+ expect(root.querySelector(".seed-callout__content")?.tagName.toLowerCase()).toBe("text");
51
+ expect(root.querySelector(".seed-callout__title")).toHaveClass(
52
+ "seed-callout__title--tone_neutral",
53
+ );
54
+ expect(root.querySelector(".seed-callout__description")).toHaveClass(
55
+ "seed-callout__description--tone_neutral",
56
+ );
57
+ expect(root.querySelector(".seed-callout__link")).toHaveAttribute(
58
+ "accessibility-traits",
59
+ "link",
60
+ );
61
+ expect(root.querySelector(".seed-callout__closeButton")).toHaveAttribute(
62
+ "accessibility-label",
63
+ "Close",
64
+ );
65
+ });
66
+
67
+ it("applies an explicit tone to every visual slot", () => {
68
+ render(
69
+ <Callout.Root tone="critical">
70
+ <Callout.Content>
71
+ <Callout.Title>Title</Callout.Title>
72
+ <Callout.Description>Description</Callout.Description>
73
+ <Callout.Link>Link</Callout.Link>
74
+ </Callout.Content>
75
+ </Callout.Root>,
76
+ );
77
+
78
+ const root = getCalloutRoot();
79
+
80
+ expect(root).toHaveClass("seed-callout__root--tone_critical");
81
+ expect(root.querySelector(".seed-callout__title")).toHaveClass(
82
+ "seed-callout__title--tone_critical",
83
+ );
84
+ expect(root.querySelector(".seed-callout__description")).toHaveClass(
85
+ "seed-callout__description--tone_critical",
86
+ );
87
+ expect(root.querySelector(".seed-callout__link")).toHaveClass(
88
+ "seed-callout__link--tone_critical",
89
+ );
90
+ });
91
+
92
+ it("tracks pressed state and maps actionable accessibility", () => {
93
+ const onTap = vi.fn();
94
+
95
+ render(
96
+ <Callout.Root tone="positive" bindtap={onTap}>
97
+ <Callout.Content>
98
+ <Callout.Description>Actionable</Callout.Description>
99
+ </Callout.Content>
100
+ </Callout.Root>,
101
+ );
102
+
103
+ const root = getCalloutRoot();
104
+
105
+ expect(root).toHaveAttribute("accessibility-element", "true");
106
+ expect(root).toHaveAttribute("accessibility-traits", "button");
107
+
108
+ fireEvent.touchstart(root, {});
109
+ expect(root).toHaveClass("seed-callout__root--tone_positive-pressed_true");
110
+
111
+ fireEvent.tap(root);
112
+ expect(onTap).toHaveBeenCalledTimes(1);
113
+
114
+ fireEvent.touchend(root, {});
115
+ expect(root).not.toHaveClass("seed-callout__root--tone_positive-pressed_true");
116
+ });
117
+
118
+ it("does not render when defaultOpen is false", () => {
119
+ render(<Callout.Root defaultOpen={false} />);
120
+
121
+ expect(getRenderedRoot().querySelector(".seed-callout__root")).not.toBeInTheDocument();
122
+ });
123
+
124
+ it("dismisses an uncontrolled callout", () => {
125
+ const onDismiss = vi.fn();
126
+
127
+ render(
128
+ <Callout.Root onDismiss={onDismiss}>
129
+ <Callout.Content>
130
+ <Callout.Description>Dismissible</Callout.Description>
131
+ </Callout.Content>
132
+ <Callout.CloseButton accessibility-label="Close" />
133
+ </Callout.Root>,
134
+ );
135
+
136
+ const closeButton = getCalloutRoot().querySelector(".seed-callout__closeButton");
137
+ if (!closeButton) throw new Error("Expected close button to exist.");
138
+
139
+ fireEvent.tap(closeButton);
140
+
141
+ expect(onDismiss).toHaveBeenCalledTimes(1);
142
+ expect(getRenderedRoot().querySelector(".seed-callout__root")).not.toBeInTheDocument();
143
+ });
144
+
145
+ it("notifies without hiding a controlled callout", () => {
146
+ const onDismiss = vi.fn();
147
+
148
+ render(
149
+ <Callout.Root open onDismiss={onDismiss}>
150
+ <Callout.Content>
151
+ <Callout.Description>Controlled</Callout.Description>
152
+ </Callout.Content>
153
+ <Callout.CloseButton accessibility-label="Close" />
154
+ </Callout.Root>,
155
+ );
156
+
157
+ const closeButton = getCalloutRoot().querySelector(".seed-callout__closeButton");
158
+ if (!closeButton) throw new Error("Expected close button to exist.");
159
+
160
+ fireEvent.tap(closeButton);
161
+
162
+ expect(onDismiss).toHaveBeenCalledTimes(1);
163
+ expect(getCalloutRoot()).toBeInTheDocument();
164
+ expect(getRenderedQueries().getByText("Controlled")).toBeInTheDocument();
165
+
166
+ fireEvent.tap(closeButton);
167
+ expect(onDismiss).toHaveBeenCalledTimes(2);
168
+ });
169
+
170
+ it("composes the close button tap handler with dismiss", () => {
171
+ const onTap = vi.fn();
172
+ const onDismiss = vi.fn();
173
+
174
+ render(
175
+ <Callout.Root onDismiss={onDismiss}>
176
+ <Callout.CloseButton bindtap={onTap} accessibility-label="Close" />
177
+ </Callout.Root>,
178
+ );
179
+
180
+ const closeButton = getCalloutRoot().querySelector(".seed-callout__closeButton");
181
+ if (!closeButton) throw new Error("Expected close button to exist.");
182
+
183
+ fireEvent.tap(closeButton);
184
+
185
+ expect(onTap).toHaveBeenCalledTimes(1);
186
+ expect(onDismiss).toHaveBeenCalledTimes(1);
187
+ });
188
+ });
@@ -0,0 +1,272 @@
1
+ import { callout, type CalloutVariantProps } from "@seed-design/lynx-css/recipes/callout";
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, useClassNames } = createSlotRecipeContext(callout);
20
+
21
+ type TapHandler = NonNullable<LynxViewProps["bindtap"]>;
22
+
23
+ interface CalloutContextValue {
24
+ dismiss: () => void;
25
+ }
26
+
27
+ const CalloutContext = React.createContext<CalloutContextValue | null>(null);
28
+
29
+ function useCalloutContext(consumer: string) {
30
+ const context = React.useContext(CalloutContext);
31
+
32
+ if (!context) {
33
+ throw new Error(`<${consumer}/> must be rendered inside <CalloutRoot/>.`);
34
+ }
35
+
36
+ return context;
37
+ }
38
+
39
+ ////////////////////////////////////////////////////////////////////////////////////
40
+
41
+ /**
42
+ * @platform Lynx
43
+ *
44
+ * 웹 대비 차이:
45
+ * - `asChild`와 DOM 이벤트 대신 Lynx native `<view>`와 `bindtap`을 사용합니다.
46
+ * - 웹 focus ring은 지원하지 않습니다.
47
+ */
48
+ export interface CalloutRootProps
49
+ extends Omit<CalloutVariantProps, "pressed">,
50
+ LynxStyledElementProps,
51
+ LynxPressableProps,
52
+ LynxAccessibilityProps {
53
+ defaultOpen?: boolean;
54
+ open?: boolean;
55
+ onDismiss?: () => void;
56
+ }
57
+
58
+ export const CalloutRoot = React.forwardRef<unknown, CalloutRootProps>((props, ref) => {
59
+ const [variantProps, otherProps] = callout.splitVariantProps(props);
60
+ const {
61
+ children,
62
+ className,
63
+ style,
64
+ defaultOpen = true,
65
+ open: openProp,
66
+ onDismiss,
67
+ bindtap,
68
+ "main-thread:bindtap": mainThreadBindtap,
69
+ "accessibility-element": accessibilityElement,
70
+ "accessibility-traits": accessibilityTraits,
71
+ ...nativeProps
72
+ } = otherProps;
73
+ const [open, setOpen] = useControllableState({
74
+ value: openProp,
75
+ defaultValue: defaultOpen,
76
+ });
77
+ const isInteractive = bindtap != null || mainThreadBindtap != null;
78
+ const pressTap = usePressTap({
79
+ disabled: !isInteractive,
80
+ onTap: bindtap,
81
+ mainThreadOnTap: mainThreadBindtap,
82
+ });
83
+ const classNames = callout({ ...variantProps, pressed: pressTap.pressed });
84
+ const dismiss = useMemoizedFn(() => {
85
+ if (!open) return;
86
+
87
+ setOpen(false);
88
+ onDismiss?.();
89
+ });
90
+ const contextValue = React.useMemo(() => ({ dismiss }), [dismiss]);
91
+ const iconSlotContextValue = React.useMemo(
92
+ () => ({
93
+ classNames: {
94
+ prefixIcon: classNames.prefixIcon,
95
+ suffixIcon: classNames.suffixIcon,
96
+ },
97
+ deps: [variantProps.tone ?? "neutral", pressTap.pressed],
98
+ }),
99
+ [classNames.prefixIcon, classNames.suffixIcon, pressTap.pressed, variantProps.tone],
100
+ );
101
+
102
+ if (!open) return null;
103
+
104
+ return (
105
+ <CalloutContext.Provider value={contextValue}>
106
+ <ClassNamesProvider value={classNames}>
107
+ <IconSlotProvider value={iconSlotContextValue}>
108
+ <view
109
+ {...(ref ? { ref: ref as LynxViewRef } : {})}
110
+ {...nativeProps}
111
+ {...(isInteractive ? pressTap : {})}
112
+ accessibility-element={accessibilityElement ?? (isInteractive ? true : undefined)}
113
+ accessibility-traits={accessibilityTraits ?? (isInteractive ? "button" : undefined)}
114
+ className={clsx(classNames.root, className)}
115
+ style={style}
116
+ >
117
+ {children}
118
+ </view>
119
+ </IconSlotProvider>
120
+ </ClassNamesProvider>
121
+ </CalloutContext.Provider>
122
+ );
123
+ });
124
+ CalloutRoot.displayName = "CalloutRoot";
125
+
126
+ ////////////////////////////////////////////////////////////////////////////////////
127
+
128
+ export interface CalloutContentProps extends LynxStyledElementProps, LynxAccessibilityProps {}
129
+
130
+ export const CalloutContent = React.forwardRef<unknown, CalloutContentProps>((props, ref) => {
131
+ const { children, className, style, ...nativeProps } = props;
132
+ const classNames = useClassNames();
133
+
134
+ return (
135
+ <text
136
+ {...(ref ? { ref: ref as LynxTextRef } : {})}
137
+ {...nativeProps}
138
+ className={clsx(classNames.content, className)}
139
+ style={style}
140
+ >
141
+ {children}
142
+ </text>
143
+ );
144
+ });
145
+ CalloutContent.displayName = "CalloutContent";
146
+
147
+ ////////////////////////////////////////////////////////////////////////////////////
148
+
149
+ export interface CalloutTitleProps extends LynxStyledElementProps, LynxAccessibilityProps {}
150
+
151
+ export const CalloutTitle = React.forwardRef<unknown, CalloutTitleProps>((props, ref) => {
152
+ const { children, className, style, ...nativeProps } = props;
153
+ const classNames = useClassNames();
154
+
155
+ return (
156
+ <text
157
+ {...(ref ? { ref: ref as LynxTextRef } : {})}
158
+ {...nativeProps}
159
+ className={clsx(classNames.title, className)}
160
+ style={style}
161
+ >
162
+ {children}
163
+ {" "}
164
+ </text>
165
+ );
166
+ });
167
+ CalloutTitle.displayName = "CalloutTitle";
168
+
169
+ ////////////////////////////////////////////////////////////////////////////////////
170
+
171
+ export interface CalloutDescriptionProps extends LynxStyledElementProps, LynxAccessibilityProps {}
172
+
173
+ export const CalloutDescription = React.forwardRef<unknown, CalloutDescriptionProps>(
174
+ (props, ref) => {
175
+ const { children, className, style, ...nativeProps } = props;
176
+ const classNames = useClassNames();
177
+
178
+ return (
179
+ <text
180
+ {...(ref ? { ref: ref as LynxTextRef } : {})}
181
+ {...nativeProps}
182
+ className={clsx(classNames.description, className)}
183
+ style={style}
184
+ >
185
+ {children}
186
+ {" "}
187
+ </text>
188
+ );
189
+ },
190
+ );
191
+ CalloutDescription.displayName = "CalloutDescription";
192
+
193
+ ////////////////////////////////////////////////////////////////////////////////////
194
+
195
+ export interface CalloutLinkProps
196
+ extends LynxStyledElementProps,
197
+ LynxPressableProps,
198
+ LynxAccessibilityProps {}
199
+
200
+ export const CalloutLink = React.forwardRef<unknown, CalloutLinkProps>((props, ref) => {
201
+ const {
202
+ children,
203
+ className,
204
+ style,
205
+ "accessibility-element": accessibilityElement = true,
206
+ "accessibility-traits": accessibilityTraits = "link",
207
+ ...nativeProps
208
+ } = props;
209
+ const classNames = useClassNames();
210
+
211
+ return (
212
+ <text
213
+ {...(ref ? { ref: ref as LynxTextRef } : {})}
214
+ {...nativeProps}
215
+ accessibility-element={accessibilityElement}
216
+ accessibility-traits={accessibilityTraits}
217
+ className={clsx(classNames.link, className)}
218
+ style={style}
219
+ >
220
+ {children}
221
+ </text>
222
+ );
223
+ });
224
+ CalloutLink.displayName = "CalloutLink";
225
+
226
+ ////////////////////////////////////////////////////////////////////////////////////
227
+
228
+ export interface CalloutCloseButtonProps
229
+ extends LynxStyledElementProps,
230
+ LynxPressableProps,
231
+ LynxAccessibilityProps {}
232
+
233
+ export const CalloutCloseButton = React.forwardRef<unknown, CalloutCloseButtonProps>(
234
+ (props, ref) => {
235
+ const {
236
+ children,
237
+ className,
238
+ style,
239
+ bindtap,
240
+ "accessibility-element": accessibilityElement = true,
241
+ "accessibility-label": accessibilityLabel,
242
+ "accessibility-traits": accessibilityTraits = "button",
243
+ ...nativeProps
244
+ } = props;
245
+ const classNames = useClassNames();
246
+ const { dismiss } = useCalloutContext("CalloutCloseButton");
247
+ const handleTap = useMemoizedFn<TapHandler>((event) => {
248
+ bindtap?.(event);
249
+ dismiss();
250
+ });
251
+
252
+ if (process.env.NODE_ENV !== "production" && accessibilityElement && !accessibilityLabel) {
253
+ console.warn("CalloutCloseButton requires `accessibility-label` for accessibility.");
254
+ }
255
+
256
+ return (
257
+ <view
258
+ {...(ref ? { ref: ref as LynxViewRef } : {})}
259
+ {...nativeProps}
260
+ bindtap={handleTap}
261
+ accessibility-element={accessibilityElement}
262
+ accessibility-label={accessibilityLabel}
263
+ accessibility-traits={accessibilityTraits}
264
+ className={clsx(classNames.closeButton, className)}
265
+ style={style}
266
+ >
267
+ {children}
268
+ </view>
269
+ );
270
+ },
271
+ );
272
+ CalloutCloseButton.displayName = "CalloutCloseButton";
@@ -0,0 +1,16 @@
1
+ export {
2
+ CalloutCloseButton,
3
+ CalloutContent,
4
+ CalloutDescription,
5
+ CalloutLink,
6
+ CalloutRoot,
7
+ CalloutTitle,
8
+ type CalloutCloseButtonProps,
9
+ type CalloutContentProps,
10
+ type CalloutDescriptionProps,
11
+ type CalloutLinkProps,
12
+ type CalloutRootProps,
13
+ type CalloutTitleProps,
14
+ } from "./Callout";
15
+
16
+ export * as Callout from "./Callout.namespace";
@@ -0,0 +1,176 @@
1
+ import "@testing-library/jest-dom";
2
+ import * as React from "@lynx-js/react";
3
+ import { fireEvent, render } from "@lynx-js/react/testing-library";
4
+ import type { MainThread } from "@lynx-js/types";
5
+ import { describe, expect, it, vi } from "vitest";
6
+
7
+ import type { LynxIconElementProps } from "../../types";
8
+ import * as Checkbox from "./Checkbox.namespace";
9
+
10
+ const TestIcon = React.forwardRef<MainThread.Element, LynxIconElementProps>((props, ref) => {
11
+ return <image {...props} {...(ref ? { "main-thread:ref": ref } : {})} />;
12
+ });
13
+ TestIcon.displayName = "TestIcon";
14
+
15
+ function ControlledGhostCheckbox({
16
+ onCheckedChange,
17
+ }: {
18
+ onCheckedChange: (checked: boolean) => void;
19
+ }) {
20
+ const [checked, setChecked] = React.useState(false);
21
+
22
+ return (
23
+ <Checkbox.Root
24
+ variant="ghost"
25
+ checked={checked}
26
+ onCheckedChange={(nextChecked) => {
27
+ setChecked(nextChecked);
28
+ onCheckedChange(nextChecked);
29
+ }}
30
+ >
31
+ <Checkbox.Control>
32
+ <Checkbox.Indicator
33
+ checked={<TestIcon className="checked-test-icon" />}
34
+ unchecked={<TestIcon className="unchecked-test-icon" />}
35
+ />
36
+ </Checkbox.Control>
37
+ </Checkbox.Root>
38
+ );
39
+ }
40
+
41
+ function getCheckboxRoot() {
42
+ const root = elementTree.root;
43
+
44
+ if (!root) {
45
+ throw new Error("Expected Checkbox root to exist.");
46
+ }
47
+
48
+ if (root.classList.contains("seed-checkbox__root")) {
49
+ return root;
50
+ }
51
+
52
+ const checkboxRoot = root.querySelector<HTMLElement>(".seed-checkbox__root");
53
+
54
+ if (!checkboxRoot) {
55
+ throw new Error("Expected Checkbox root to exist.");
56
+ }
57
+
58
+ return checkboxRoot;
59
+ }
60
+
61
+ function getCheckboxControl() {
62
+ const control = getCheckboxRoot().querySelector<HTMLElement>(".seed-checkmark__root");
63
+
64
+ if (!control) {
65
+ throw new Error("Expected Checkbox control to exist.");
66
+ }
67
+
68
+ return control;
69
+ }
70
+
71
+ function getCheckboxBackground() {
72
+ const background = getCheckboxControl().querySelector<HTMLElement>(".seed-checkmark__background");
73
+
74
+ if (!background) {
75
+ throw new Error("Expected Checkbox background to exist.");
76
+ }
77
+
78
+ return background;
79
+ }
80
+
81
+ describe("Checkbox", () => {
82
+ it("keeps each ghost press color stable through release in both toggle directions", () => {
83
+ const onCheckedChange = vi.fn();
84
+
85
+ render(<ControlledGhostCheckbox onCheckedChange={onCheckedChange} />);
86
+
87
+ const root = getCheckboxRoot();
88
+ const idleRootClassName = getCheckboxControl().className;
89
+
90
+ expect(getCheckboxRoot().querySelector(".unchecked-test-icon")).not.toBeNull();
91
+ expect(getCheckboxBackground()).toHaveClass("seed-checkmark__background--checked_false");
92
+
93
+ fireEvent.touchstart(root, {});
94
+ expect(getCheckboxControl().className).toBe(idleRootClassName);
95
+ expect(getCheckboxBackground()).toHaveClass("seed-checkmark__background--pressed_true");
96
+ expect(getCheckboxBackground()).toHaveClass("seed-checkmark__background--checked_false");
97
+
98
+ fireEvent.touchend(root, {});
99
+ const uncheckedReleaseRootClassName = getCheckboxControl().className;
100
+ const uncheckedReleaseClassName = getCheckboxBackground().className;
101
+
102
+ expect(getCheckboxBackground()).toHaveClass("seed-checkmark__background--pressed_false");
103
+ expect(getCheckboxBackground()).toHaveClass("seed-checkmark__background--checked_false");
104
+
105
+ fireEvent.tap(root);
106
+
107
+ expect(onCheckedChange).toHaveBeenCalledWith(true);
108
+ expect(getCheckboxRoot().querySelector(".checked-test-icon")).not.toBeNull();
109
+ expect(getCheckboxControl().className).toBe(uncheckedReleaseRootClassName);
110
+ expect(getCheckboxBackground().className).toBe(uncheckedReleaseClassName);
111
+
112
+ fireEvent.touchstart(root, {});
113
+
114
+ expect(getCheckboxBackground()).toHaveClass("seed-checkmark__background--pressed_true");
115
+ expect(getCheckboxBackground()).toHaveClass("seed-checkmark__background--checked_true");
116
+ expect(getCheckboxBackground()).toHaveClass(
117
+ "seed-checkmark__background--variant_ghost-tone_brand-checked_true",
118
+ );
119
+
120
+ fireEvent.touchend(root, {});
121
+ const checkedReleaseRootClassName = getCheckboxControl().className;
122
+ const checkedReleaseClassName = getCheckboxBackground().className;
123
+
124
+ expect(getCheckboxBackground()).toHaveClass("seed-checkmark__background--pressed_false");
125
+ expect(getCheckboxBackground()).toHaveClass("seed-checkmark__background--checked_true");
126
+
127
+ fireEvent.tap(root);
128
+
129
+ expect(onCheckedChange.mock.calls).toEqual([[true], [false]]);
130
+ expect(getCheckboxRoot().querySelector(".unchecked-test-icon")).not.toBeNull();
131
+ expect(getCheckboxControl().className).toBe(checkedReleaseRootClassName);
132
+ expect(getCheckboxBackground().className).toBe(checkedReleaseClassName);
133
+
134
+ fireEvent.touchstart(root, {});
135
+
136
+ expect(getCheckboxBackground()).toHaveClass("seed-checkmark__background--pressed_true");
137
+ expect(getCheckboxBackground()).toHaveClass("seed-checkmark__background--checked_false");
138
+
139
+ fireEvent.touchcancel(root, {});
140
+
141
+ expect(getCheckboxBackground()).toHaveClass("seed-checkmark__background--pressed_false");
142
+ expect(getCheckboxBackground()).toHaveClass("seed-checkmark__background--checked_false");
143
+ });
144
+
145
+ it("keeps the touchstart selection while controlled state changes during a press", () => {
146
+ const renderCheckbox = (checked: boolean) => (
147
+ <Checkbox.Root variant="ghost" checked={checked}>
148
+ <Checkbox.Control />
149
+ </Checkbox.Root>
150
+ );
151
+ const { rerender } = render(renderCheckbox(true));
152
+
153
+ const root = getCheckboxRoot();
154
+
155
+ fireEvent.touchstart(root, {});
156
+ const checkedPressClassName = getCheckboxBackground().className;
157
+
158
+ expect(getCheckboxBackground()).toHaveClass("seed-checkmark__background--checked_true");
159
+
160
+ rerender(renderCheckbox(false));
161
+
162
+ expect(getCheckboxBackground().className).toBe(checkedPressClassName);
163
+
164
+ fireEvent.touchend(root, {});
165
+ expect(getCheckboxBackground()).toHaveClass("seed-checkmark__background--checked_true");
166
+
167
+ fireEvent.touchstart(root, {});
168
+ const uncheckedPressClassName = getCheckboxBackground().className;
169
+
170
+ expect(getCheckboxBackground()).toHaveClass("seed-checkmark__background--checked_false");
171
+
172
+ rerender(renderCheckbox(true));
173
+
174
+ expect(getCheckboxBackground().className).toBe(uncheckedPressClassName);
175
+ });
176
+ });