@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.
@@ -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