@purpurds/radio-card-group 3.0.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.
- package/dist/LICENSE.txt +234 -0
- package/dist/classnames.d.ts +2 -0
- package/dist/classnames.d.ts.map +1 -0
- package/dist/radio-card-group.cjs.js +18 -0
- package/dist/radio-card-group.cjs.js.map +1 -0
- package/dist/radio-card-group.d.ts +73 -0
- package/dist/radio-card-group.d.ts.map +1 -0
- package/dist/radio-card-group.es.js +1200 -0
- package/dist/radio-card-group.es.js.map +1 -0
- package/dist/radio-card-group.system.js +18 -0
- package/dist/radio-card-group.system.js.map +1 -0
- package/dist/radio-card-item-image.d.ts +11 -0
- package/dist/radio-card-item-image.d.ts.map +1 -0
- package/dist/radio-card-item.d.ts +23 -0
- package/dist/radio-card-item.d.ts.map +1 -0
- package/dist/styles.css +1 -0
- package/package.json +68 -0
- package/readme.mdx +188 -0
- package/src/classnames.ts +4 -0
- package/src/global.d.ts +6 -0
- package/src/radio-card-group.module.scss +257 -0
- package/src/radio-card-group.stories.tsx +172 -0
- package/src/radio-card-group.test.tsx +180 -0
- package/src/radio-card-group.tsx +140 -0
- package/src/radio-card-item-image.tsx +53 -0
- package/src/radio-card-item.test.tsx +70 -0
- package/src/radio-card-item.tsx +129 -0
- package/static/story-image.png +0 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import * as matchers from "@testing-library/jest-dom/matchers";
|
|
3
|
+
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { RadioCardGroup, RadioCardItem } from "./radio-card-group";
|
|
7
|
+
import { RadioCardImage } from "./radio-card-item-image";
|
|
8
|
+
import { Paragraph } from "@purpurds/paragraph";
|
|
9
|
+
|
|
10
|
+
expect.extend(matchers);
|
|
11
|
+
|
|
12
|
+
describe("RadioButtonGroup", () => {
|
|
13
|
+
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
|
14
|
+
observe: vi.fn(),
|
|
15
|
+
unobserve: vi.fn(),
|
|
16
|
+
disconnect: vi.fn(),
|
|
17
|
+
}));
|
|
18
|
+
afterEach(cleanup);
|
|
19
|
+
|
|
20
|
+
it("should not render items when no items", () => {
|
|
21
|
+
render(<RadioCardGroup items={[]} id="test" data-testid="test" />);
|
|
22
|
+
expect(screen.queryByTestId("test")).not.toBeInTheDocument();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("should render items with different states", () => {
|
|
26
|
+
const items = [
|
|
27
|
+
{ id: "1", title: "1", value: "1" },
|
|
28
|
+
{ id: "2", title: "2", value: "2", disabled: true },
|
|
29
|
+
{ id: "3", title: "3", value: "3" },
|
|
30
|
+
];
|
|
31
|
+
render(<RadioCardGroup items={items} id="test" data-testid="test" value="1" />);
|
|
32
|
+
expect(screen.queryByTestId("test-label")).not.toBeInTheDocument();
|
|
33
|
+
const radioCardItems = screen.getAllByRole("radio");
|
|
34
|
+
|
|
35
|
+
expect(radioCardItems).toHaveLength(3);
|
|
36
|
+
expect(radioCardItems["0"]).toBeChecked();
|
|
37
|
+
expect(radioCardItems["0"]).not.toBeDisabled();
|
|
38
|
+
|
|
39
|
+
expect(radioCardItems["1"]).not.toBeChecked();
|
|
40
|
+
expect(radioCardItems["1"]).toBeDisabled();
|
|
41
|
+
|
|
42
|
+
expect(radioCardItems["2"]).not.toBeChecked();
|
|
43
|
+
expect(radioCardItems["2"]).not.toBeDisabled();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("should render items with different states in a form", () => {
|
|
47
|
+
const items = [
|
|
48
|
+
{ id: "1", title: "1", value: "1" },
|
|
49
|
+
{ id: "2", title: "2", value: "2", disabled: true },
|
|
50
|
+
{ id: "3", title: "3", value: "3", required: true },
|
|
51
|
+
];
|
|
52
|
+
render(
|
|
53
|
+
<form>
|
|
54
|
+
<RadioCardGroup items={items} id="test" data-testid="test" value="1" />
|
|
55
|
+
</form>
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
expect(screen.queryByTestId("test-label")).not.toBeInTheDocument();
|
|
59
|
+
const radioCardItems = screen.getAllByTestId("test-item");
|
|
60
|
+
|
|
61
|
+
expect(radioCardItems).toHaveLength(3);
|
|
62
|
+
expect(radioCardItems["0"].nextElementSibling).toBeChecked();
|
|
63
|
+
expect(radioCardItems["0"].nextElementSibling).not.toBeDisabled();
|
|
64
|
+
expect(radioCardItems["0"].nextElementSibling).not.toBeRequired();
|
|
65
|
+
|
|
66
|
+
expect(radioCardItems["1"].nextElementSibling).not.toBeChecked();
|
|
67
|
+
expect(radioCardItems["1"].nextElementSibling).toBeDisabled();
|
|
68
|
+
expect(radioCardItems["1"].nextElementSibling).not.toBeRequired();
|
|
69
|
+
|
|
70
|
+
expect(radioCardItems["2"].nextElementSibling).not.toBeChecked();
|
|
71
|
+
expect(radioCardItems["2"].nextElementSibling).not.toBeDisabled();
|
|
72
|
+
expect(radioCardItems["2"].nextElementSibling).toBeRequired();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("should render with label", () => {
|
|
76
|
+
const items = [
|
|
77
|
+
{ id: "1", title: "1", value: "1" },
|
|
78
|
+
{ id: "2", title: "2", value: "2" },
|
|
79
|
+
];
|
|
80
|
+
render(<RadioCardGroup items={items} id="test" data-testid="test" value="1" label="Label" />);
|
|
81
|
+
|
|
82
|
+
expect(screen.queryByTestId("test-label")).toHaveTextContent("Label");
|
|
83
|
+
expect(screen.getByRole("radiogroup")).toHaveAttribute("aria-labelledby", "test-label");
|
|
84
|
+
expect(screen.getAllByRole("radio")).toHaveLength(2);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("should only render children that are RadioCardItems", () => {
|
|
88
|
+
const items = [
|
|
89
|
+
{ id: "1", title: "1", value: "1" },
|
|
90
|
+
{ id: "2", title: "2", value: "2" },
|
|
91
|
+
];
|
|
92
|
+
render(
|
|
93
|
+
<RadioCardGroup id="test" data-testid="test" value="1" label="Label">
|
|
94
|
+
<RadioCardImage data-testid="test-radio-item-image" altText="test-alt" src="test-source" />
|
|
95
|
+
{items.map((item) => (
|
|
96
|
+
<RadioCardItem data-testid="test-radio-card-item" key={item.id} {...item} />
|
|
97
|
+
))}
|
|
98
|
+
<div data-testid="test-div" />
|
|
99
|
+
<Paragraph data-testid="test-paragraph">text</Paragraph>
|
|
100
|
+
</RadioCardGroup>
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
expect(screen.queryByTestId("test-radio-item-image")).not.toBeInTheDocument();
|
|
104
|
+
expect(screen.queryByTestId("test-div")).not.toBeInTheDocument();
|
|
105
|
+
expect(screen.queryByTestId("test-paragraph")).not.toBeInTheDocument();
|
|
106
|
+
expect(screen.getAllByTestId("test-radio-card-item")).toHaveLength(2);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("should render controlled and not emit value change clicking select item", () => {
|
|
110
|
+
const items = [
|
|
111
|
+
{ id: "1", title: "1", value: "1" },
|
|
112
|
+
{ id: "2", title: "2", value: "2" },
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
const onValueChangeMock = vi.fn();
|
|
116
|
+
render(
|
|
117
|
+
<RadioCardGroup
|
|
118
|
+
id="test"
|
|
119
|
+
data-testid="test"
|
|
120
|
+
value="1"
|
|
121
|
+
label="Label"
|
|
122
|
+
onValueChange={onValueChangeMock}
|
|
123
|
+
>
|
|
124
|
+
{items.map((item) => (
|
|
125
|
+
<RadioCardItem data-testid="test-radio-card-item" key={item.id} {...item} />
|
|
126
|
+
))}
|
|
127
|
+
</RadioCardGroup>
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const radioCardItems = screen.getAllByTestId("test-radio-card-item");
|
|
131
|
+
|
|
132
|
+
fireEvent.click(radioCardItems[0]);
|
|
133
|
+
expect(onValueChangeMock).not.toHaveBeenCalled();
|
|
134
|
+
|
|
135
|
+
fireEvent.click(radioCardItems[1]);
|
|
136
|
+
expect(onValueChangeMock).toHaveBeenCalledWith("2");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("should render uncontrolled", () => {
|
|
140
|
+
const items = [
|
|
141
|
+
{ id: "1", title: "1", value: "1" },
|
|
142
|
+
{ id: "2", title: "2", value: "2" },
|
|
143
|
+
{ id: "3", title: "3", value: "3" },
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
render(
|
|
147
|
+
<RadioCardGroup id="test" data-testid="test" label="Label">
|
|
148
|
+
{items.map((item) => (
|
|
149
|
+
<RadioCardItem data-testid="test-radio-card-item" key={item.id} {...item} />
|
|
150
|
+
))}
|
|
151
|
+
</RadioCardGroup>
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const radioCardItems = screen.getAllByTestId("test-radio-card-item");
|
|
155
|
+
expect(radioCardItems).toHaveLength(3);
|
|
156
|
+
radioCardItems.forEach((radioCardItem) =>
|
|
157
|
+
expect(radioCardItem).toHaveAttribute("data-state", "unchecked")
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
fireEvent.click(radioCardItems[0]);
|
|
161
|
+
expect(radioCardItems[0]).toHaveAttribute("data-state", "checked");
|
|
162
|
+
expect(radioCardItems[1]).toHaveAttribute("data-state", "unchecked");
|
|
163
|
+
expect(radioCardItems[2]).toHaveAttribute("data-state", "unchecked");
|
|
164
|
+
|
|
165
|
+
fireEvent.click(radioCardItems[0]);
|
|
166
|
+
expect(radioCardItems[0]).toHaveAttribute("data-state", "checked");
|
|
167
|
+
expect(radioCardItems[1]).toHaveAttribute("data-state", "unchecked");
|
|
168
|
+
expect(radioCardItems[2]).toHaveAttribute("data-state", "unchecked");
|
|
169
|
+
|
|
170
|
+
fireEvent.click(radioCardItems[1]);
|
|
171
|
+
expect(radioCardItems[0]).toHaveAttribute("data-state", "unchecked");
|
|
172
|
+
expect(radioCardItems[1]).toHaveAttribute("data-state", "checked");
|
|
173
|
+
expect(radioCardItems[2]).toHaveAttribute("data-state", "unchecked");
|
|
174
|
+
|
|
175
|
+
fireEvent.click(radioCardItems[2]);
|
|
176
|
+
expect(radioCardItems[0]).toHaveAttribute("data-state", "unchecked");
|
|
177
|
+
expect(radioCardItems[1]).toHaveAttribute("data-state", "unchecked");
|
|
178
|
+
expect(radioCardItems[2]).toHaveAttribute("data-state", "checked");
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import React, { Children, cloneElement, ForwardedRef, forwardRef, ReactNode, Ref } from "react";
|
|
2
|
+
import { Heading } from "@purpurds/heading";
|
|
3
|
+
import * as RadixRadioGroup from "@radix-ui/react-radio-group";
|
|
4
|
+
|
|
5
|
+
import { cx } from "./classnames";
|
|
6
|
+
import { isRadioCardItem, RadioCardItem, RadioCardItemProps } from "./radio-card-item";
|
|
7
|
+
|
|
8
|
+
export type RadioCardGroupItem = RadioCardItemProps & { ref?: Ref<HTMLDivElement> };
|
|
9
|
+
|
|
10
|
+
export type RadioCardGroupProps = {
|
|
11
|
+
/**
|
|
12
|
+
* To use when no label is given.
|
|
13
|
+
* */
|
|
14
|
+
["aria-label"]?: string;
|
|
15
|
+
/**
|
|
16
|
+
* To use with custom label.
|
|
17
|
+
* */
|
|
18
|
+
["aria-labelledby"]?: string;
|
|
19
|
+
["data-testid"]?: string;
|
|
20
|
+
className?: string;
|
|
21
|
+
/**
|
|
22
|
+
* The value of the radio card item that should be checked when initially rendered. Use when you do not need to control the state of the radio items.
|
|
23
|
+
* */
|
|
24
|
+
defaultValue?: string;
|
|
25
|
+
/**
|
|
26
|
+
* When true, prevents the user from interacting with radio card items.
|
|
27
|
+
* */
|
|
28
|
+
disabled?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* ID of the radio card group container.
|
|
31
|
+
* */
|
|
32
|
+
id: string;
|
|
33
|
+
/**
|
|
34
|
+
* The radio card items.
|
|
35
|
+
* */
|
|
36
|
+
items?: RadioCardGroupItem[];
|
|
37
|
+
/**
|
|
38
|
+
* Renders above the radio card group as a heading
|
|
39
|
+
* */
|
|
40
|
+
label?: string;
|
|
41
|
+
/**
|
|
42
|
+
* When true, keyboard navigation will loop from last item to first, and vice versa.
|
|
43
|
+
* */
|
|
44
|
+
loop?: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* The name of the group. Submitted with its owning form as part of a name/value pair.
|
|
47
|
+
* */
|
|
48
|
+
name?: string;
|
|
49
|
+
/**
|
|
50
|
+
* Event handler called when the value changes.
|
|
51
|
+
* */
|
|
52
|
+
onValueChange?: (value: string) => void;
|
|
53
|
+
/**
|
|
54
|
+
* Set to stack cards horizontally or vertically. When `horizontal`, the min card width with is 100% when container is sm, and `--purpur-breakpoint-lg` / 4 when the container is md or larger.
|
|
55
|
+
* */
|
|
56
|
+
orientation?: "horizontal" | "vertical";
|
|
57
|
+
/**
|
|
58
|
+
* The position if the radio in the card items.
|
|
59
|
+
* */
|
|
60
|
+
radioPosition?: "right" | "left";
|
|
61
|
+
/**
|
|
62
|
+
* When true, indicates that the user must check a radio card item before the owning form can be submitted.
|
|
63
|
+
* */
|
|
64
|
+
required?: boolean;
|
|
65
|
+
/**
|
|
66
|
+
* The controlled value of the radio card item to check. Should be used in conjunction with onValueChange.
|
|
67
|
+
* */
|
|
68
|
+
value?: string;
|
|
69
|
+
/**
|
|
70
|
+
* Will only render given RadioCardItems.
|
|
71
|
+
* */
|
|
72
|
+
children?: ReactNode;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const rootClassName = "purpur-radio-card-group";
|
|
76
|
+
|
|
77
|
+
const RadioCardGroupComponent = (
|
|
78
|
+
{
|
|
79
|
+
"data-testid": dataTestId,
|
|
80
|
+
"aria-labelledby": ariaLabelledby,
|
|
81
|
+
children,
|
|
82
|
+
className,
|
|
83
|
+
items,
|
|
84
|
+
label,
|
|
85
|
+
loop = true,
|
|
86
|
+
orientation = "vertical",
|
|
87
|
+
radioPosition = "right",
|
|
88
|
+
...props
|
|
89
|
+
}: RadioCardGroupProps,
|
|
90
|
+
ref: ForwardedRef<HTMLDivElement>
|
|
91
|
+
) => {
|
|
92
|
+
const radioCardItemChildren = Children.toArray(children).filter(isRadioCardItem);
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<div className={cx(className, `${rootClassName}__container`)} ref={ref}>
|
|
96
|
+
{label && (
|
|
97
|
+
<Heading
|
|
98
|
+
data-testid={dataTestId ? `${dataTestId}-label` : undefined}
|
|
99
|
+
id={`${props.id}-label`}
|
|
100
|
+
tag="h2"
|
|
101
|
+
variant="subsection-100"
|
|
102
|
+
>
|
|
103
|
+
{`${props.required ? "* " : ""}${label}`}
|
|
104
|
+
</Heading>
|
|
105
|
+
)}
|
|
106
|
+
{(!!items?.length || radioCardItemChildren.length) && (
|
|
107
|
+
<RadixRadioGroup.Root
|
|
108
|
+
{...props}
|
|
109
|
+
aria-labelledby={ariaLabelledby || (label ? `${props.id}-label` : undefined)}
|
|
110
|
+
className={cx(
|
|
111
|
+
rootClassName,
|
|
112
|
+
`${rootClassName}--${orientation}`,
|
|
113
|
+
`${rootClassName}--radio-${radioPosition}`
|
|
114
|
+
)}
|
|
115
|
+
data-testid={dataTestId}
|
|
116
|
+
loop={loop}
|
|
117
|
+
>
|
|
118
|
+
{items?.map((item) => (
|
|
119
|
+
<RadioCardItem
|
|
120
|
+
key={item.id}
|
|
121
|
+
{...item}
|
|
122
|
+
disabled={item.disabled || props.disabled}
|
|
123
|
+
data-testid={dataTestId ? `${dataTestId}-item` : undefined}
|
|
124
|
+
/>
|
|
125
|
+
))}
|
|
126
|
+
{Children.map(radioCardItemChildren, (child) =>
|
|
127
|
+
cloneElement(child, { disabled: child.props.disabled || props.disabled })
|
|
128
|
+
)}
|
|
129
|
+
</RadixRadioGroup.Root>
|
|
130
|
+
)}
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export const RadioCardGroup = forwardRef(RadioCardGroupComponent);
|
|
136
|
+
RadioCardGroup.displayName = "RadioCardGroup";
|
|
137
|
+
|
|
138
|
+
// RadioCardItem must be exported after `RadioCardGroup` for the Storybook controls to be generated from RadioCardGroup props
|
|
139
|
+
export type { RadioCardItemProps } from "./radio-card-item";
|
|
140
|
+
export { RadioCardItem } from "./radio-card-item";
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { Skeleton } from "@purpurds/skeleton";
|
|
4
|
+
|
|
5
|
+
import { cx } from "./classnames";
|
|
6
|
+
|
|
7
|
+
export type RadioCardImageProps = {
|
|
8
|
+
["data-testid"]?: string;
|
|
9
|
+
altText: string;
|
|
10
|
+
loading?: "lazy" | "eager";
|
|
11
|
+
noPlaceholder?: boolean;
|
|
12
|
+
src: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const rootClassName = "purpur-radio-card-group__item-image";
|
|
16
|
+
|
|
17
|
+
export const isRadioCardItemImageProps = (
|
|
18
|
+
imageProps?: RadioCardImageProps | unknown
|
|
19
|
+
): imageProps is RadioCardImageProps =>
|
|
20
|
+
!!(imageProps as RadioCardImageProps)?.src && !!(imageProps as RadioCardImageProps).altText;
|
|
21
|
+
|
|
22
|
+
export const RadioCardImage = ({
|
|
23
|
+
src,
|
|
24
|
+
altText,
|
|
25
|
+
noPlaceholder,
|
|
26
|
+
loading = "lazy",
|
|
27
|
+
["data-testid"]: dataTestid,
|
|
28
|
+
}: RadioCardImageProps) => {
|
|
29
|
+
const [isLoaded, setIsLoaded] = useState(false);
|
|
30
|
+
const onLoad = () => setIsLoaded(true);
|
|
31
|
+
|
|
32
|
+
const imageClassName = cx(rootClassName, {
|
|
33
|
+
[`${rootClassName}--loaded`]: isLoaded,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const placeholderClassName = cx(`${rootClassName}-placeholder`, {
|
|
37
|
+
[`${rootClassName}-placeholder--loaded`]: isLoaded,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<>
|
|
42
|
+
<img
|
|
43
|
+
data-testid={dataTestid}
|
|
44
|
+
className={imageClassName}
|
|
45
|
+
src={src}
|
|
46
|
+
alt={altText}
|
|
47
|
+
loading={loading}
|
|
48
|
+
onLoad={onLoad}
|
|
49
|
+
/>
|
|
50
|
+
{!noPlaceholder && <Skeleton className={placeholderClassName} />}
|
|
51
|
+
</>
|
|
52
|
+
);
|
|
53
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import * as RadixRadioGroup from "@radix-ui/react-radio-group";
|
|
3
|
+
import * as matchers from "@testing-library/jest-dom/matchers";
|
|
4
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
|
|
7
|
+
import { RadioCardItem } from "./radio-card-group";
|
|
8
|
+
|
|
9
|
+
expect.extend(matchers);
|
|
10
|
+
|
|
11
|
+
describe("RadioButtonGroup", () => {
|
|
12
|
+
afterEach(cleanup);
|
|
13
|
+
|
|
14
|
+
it("should render with given props", () => {
|
|
15
|
+
render(
|
|
16
|
+
<RadixRadioGroup.RadioGroup>
|
|
17
|
+
<RadioCardItem
|
|
18
|
+
id="test"
|
|
19
|
+
data-testid="test"
|
|
20
|
+
value="test-value"
|
|
21
|
+
title="Test title"
|
|
22
|
+
body="Test body"
|
|
23
|
+
image={{ src: "test-src", altText: "test alt text" }}
|
|
24
|
+
/>
|
|
25
|
+
</RadixRadioGroup.RadioGroup>
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
expect(screen.getByTestId("test")).toHaveAttribute("id", "test");
|
|
29
|
+
expect(screen.getByTestId("test")).toHaveValue("test-value");
|
|
30
|
+
expect(screen.getByTestId("test-title")).toHaveTextContent("Test title");
|
|
31
|
+
expect(screen.getByTestId("test-body")).toHaveTextContent("Test body");
|
|
32
|
+
expect(screen.getByTestId("test-image")).toHaveAttribute("src", "test-src");
|
|
33
|
+
expect(screen.getByTestId("test-image")).toHaveAttribute("alt", "test alt text");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("should render with given custom props", () => {
|
|
37
|
+
render(
|
|
38
|
+
<RadixRadioGroup.RadioGroup>
|
|
39
|
+
<RadioCardItem
|
|
40
|
+
id="test"
|
|
41
|
+
data-testid="test"
|
|
42
|
+
value="test-value"
|
|
43
|
+
title={<div data-testid="custom-title">Custom title</div>}
|
|
44
|
+
body={<div data-testid="custom-body">Custom body</div>}
|
|
45
|
+
image={<img data-testid="custom-image" />}
|
|
46
|
+
/>
|
|
47
|
+
</RadixRadioGroup.RadioGroup>
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
expect(screen.getByTestId("test")).toHaveAttribute("id", "test");
|
|
51
|
+
expect(screen.getByTestId("test")).toHaveValue("test-value");
|
|
52
|
+
expect(screen.getByTestId("custom-title")).toHaveTextContent("Custom title");
|
|
53
|
+
expect(screen.getByTestId("custom-body")).toHaveTextContent("Custom body");
|
|
54
|
+
expect(screen.getByTestId("custom-image")).toBeInTheDocument();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("should optional props when not given", () => {
|
|
58
|
+
render(
|
|
59
|
+
<RadixRadioGroup.RadioGroup>
|
|
60
|
+
<RadioCardItem id="test" data-testid="test" value="test-value" title="Test title" />
|
|
61
|
+
</RadixRadioGroup.RadioGroup>
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
expect(screen.getByTestId("test")).toHaveAttribute("id", "test");
|
|
65
|
+
expect(screen.getByTestId("test")).toHaveValue("test-value");
|
|
66
|
+
expect(screen.getByTestId("test-title")).toHaveTextContent("Test title");
|
|
67
|
+
expect(screen.queryByTestId("test-body")).not.toBeInTheDocument();
|
|
68
|
+
expect(screen.queryByTestId("test-image")).not.toBeInTheDocument();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
ForwardedRef,
|
|
3
|
+
forwardRef,
|
|
4
|
+
isValidElement,
|
|
5
|
+
ReactElement,
|
|
6
|
+
ReactNode,
|
|
7
|
+
ReactPortal,
|
|
8
|
+
} from "react";
|
|
9
|
+
import { Paragraph } from "@purpurds/paragraph";
|
|
10
|
+
import * as RadixRadioGroup from "@radix-ui/react-radio-group";
|
|
11
|
+
|
|
12
|
+
import { cx } from "./classnames";
|
|
13
|
+
import {
|
|
14
|
+
isRadioCardItemImageProps,
|
|
15
|
+
RadioCardImage,
|
|
16
|
+
RadioCardImageProps,
|
|
17
|
+
} from "./radio-card-item-image";
|
|
18
|
+
|
|
19
|
+
const rootClassName = "purpur-radio-card-group__item";
|
|
20
|
+
|
|
21
|
+
type CustomElement = ((props: { disabled?: boolean }) => ReactNode) | ReactNode;
|
|
22
|
+
type NonNullCustomElement =
|
|
23
|
+
| ((props: { disabled?: boolean }) => NonNullable<ReactNode>)
|
|
24
|
+
| NonNullable<ReactNode>;
|
|
25
|
+
|
|
26
|
+
export type RadioCardItemProps = {
|
|
27
|
+
["data-testid"]?: string;
|
|
28
|
+
body?: string | CustomElement;
|
|
29
|
+
children?: ReactNode;
|
|
30
|
+
disabled?: boolean;
|
|
31
|
+
id: string;
|
|
32
|
+
image?: RadioCardImageProps | CustomElement;
|
|
33
|
+
required?: boolean;
|
|
34
|
+
title: string | NonNullCustomElement;
|
|
35
|
+
value: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const renderCustomElement = (
|
|
39
|
+
element: CustomElement | NonNullCustomElement,
|
|
40
|
+
{ disabled }: Pick<RadioCardItemProps, "disabled">
|
|
41
|
+
) => (typeof element === "function" ? element({ disabled }) : element);
|
|
42
|
+
|
|
43
|
+
const RadioCardItemComponent = (props: RadioCardItemProps, ref: ForwardedRef<HTMLDivElement>) => {
|
|
44
|
+
const {
|
|
45
|
+
["data-testid"]: dataTestid,
|
|
46
|
+
body,
|
|
47
|
+
children,
|
|
48
|
+
disabled,
|
|
49
|
+
id,
|
|
50
|
+
image,
|
|
51
|
+
required,
|
|
52
|
+
title,
|
|
53
|
+
value,
|
|
54
|
+
} = props;
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div className={cx(`${rootClassName}-container`)} ref={ref}>
|
|
58
|
+
<RadixRadioGroup.Item
|
|
59
|
+
className={cx(rootClassName)}
|
|
60
|
+
data-testid={dataTestid}
|
|
61
|
+
disabled={disabled}
|
|
62
|
+
id={id}
|
|
63
|
+
required={required}
|
|
64
|
+
value={value}
|
|
65
|
+
>
|
|
66
|
+
{image && (
|
|
67
|
+
<span className={cx(`${rootClassName}-image-container`)}>
|
|
68
|
+
{isRadioCardItemImageProps(image) ? (
|
|
69
|
+
<RadioCardImage {...image} data-testid={dataTestid && `${dataTestid}-image`} />
|
|
70
|
+
) : (
|
|
71
|
+
renderCustomElement(image, props)
|
|
72
|
+
)}
|
|
73
|
+
</span>
|
|
74
|
+
)}
|
|
75
|
+
<span className={cx(`${rootClassName}-content`)}>
|
|
76
|
+
<span className={cx(`${rootClassName}-top-container`)}>
|
|
77
|
+
{typeof title === "string" ? (
|
|
78
|
+
<Paragraph
|
|
79
|
+
className={cx(`${rootClassName}-title`)}
|
|
80
|
+
data-testid={dataTestid && `${dataTestid}-title`}
|
|
81
|
+
variant="paragraph-100"
|
|
82
|
+
disabled={disabled}
|
|
83
|
+
>
|
|
84
|
+
{title}
|
|
85
|
+
</Paragraph>
|
|
86
|
+
) : (
|
|
87
|
+
renderCustomElement(title, props)
|
|
88
|
+
)}
|
|
89
|
+
<span className={cx(`${rootClassName}-radio`)}>
|
|
90
|
+
<RadixRadioGroup.Indicator className={cx(`${rootClassName}-indicator`)} />
|
|
91
|
+
</span>
|
|
92
|
+
</span>
|
|
93
|
+
{body && (
|
|
94
|
+
<span className={cx(`${rootClassName}-bottom-container`)}>
|
|
95
|
+
{typeof body === "string" ? (
|
|
96
|
+
<Paragraph
|
|
97
|
+
data-testid={dataTestid && `${dataTestid}-body`}
|
|
98
|
+
variant="paragraph-100"
|
|
99
|
+
disabled={disabled}
|
|
100
|
+
>
|
|
101
|
+
{body}
|
|
102
|
+
</Paragraph>
|
|
103
|
+
) : (
|
|
104
|
+
renderCustomElement(body, props)
|
|
105
|
+
)}
|
|
106
|
+
</span>
|
|
107
|
+
)}
|
|
108
|
+
</span>
|
|
109
|
+
</RadixRadioGroup.Item>
|
|
110
|
+
{children}
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export const RadioCardItem = forwardRef(RadioCardItemComponent);
|
|
116
|
+
RadioCardItem.displayName = "RadioCardItem";
|
|
117
|
+
|
|
118
|
+
export const isRadioCardItem = (
|
|
119
|
+
child:
|
|
120
|
+
| ReactElement
|
|
121
|
+
| Iterable<ReactNode>
|
|
122
|
+
| ReactPortal
|
|
123
|
+
| string
|
|
124
|
+
| number
|
|
125
|
+
| boolean
|
|
126
|
+
| null
|
|
127
|
+
| undefined
|
|
128
|
+
): child is ReactElement<RadioCardItemProps> =>
|
|
129
|
+
isValidElement<RadioCardItemProps>(child) && child?.type === RadioCardItem;
|
|
Binary file
|