@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,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
|
+
});
|