Skip to content

Commit

Permalink
Merge pull request #45 from Atnic/feature/radio
Browse files Browse the repository at this point in the history
feat: radio components
  • Loading branch information
muhamien authored Mar 21, 2024
2 parents 36a4508 + 977e46f commit 042079b
Show file tree
Hide file tree
Showing 15 changed files with 1,549 additions and 120 deletions.
24 changes: 24 additions & 0 deletions packages/components/radio/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# @jala-banyu/radio

Radios allow users to select a single option from a list of mutually exclusive options.

> This is an internal utility, not intended for public usage.
## Installation

```sh
yarn add @jala-banyu/radio
# or
npm i @jala-banyu/radio
```

## Contribution

Yes please! See the
[contributing guidelines](https://github.com/Atnic/banyu/blob/master/CONTRIBUTING.md)
for details.

## Licence

This project is licensed under the terms of the
[MIT license](https://github.com/Atnic/banyu/blob/master/LICENSE).
198 changes: 198 additions & 0 deletions packages/components/radio/__tests__/radio.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import * as React from "react";
import {act, render} from "@testing-library/react";

import {RadioGroup, Radio, RadioGroupProps} from "../src";

describe("Radio", () => {
it("should render correctly", () => {
const wrapper = render(
<RadioGroup label="Options">
<Radio value="1">Option 1</Radio>
</RadioGroup>,
);

expect(() => wrapper.unmount()).not.toThrow();
});

it("ref should be forwarded - group", () => {
const ref = React.createRef<HTMLDivElement>();

render(
<RadioGroup ref={ref} label="Options">
<Radio value="1">Option 1</Radio>
</RadioGroup>,
);
expect(ref.current).not.toBeNull();
});

it("ref should be forwarded - option", () => {
const ref = React.createRef<HTMLLabelElement>();

render(
<RadioGroup label="Options">
<Radio ref={ref} value="1">
Option 1
</Radio>
</RadioGroup>,
);
expect(ref.current).not.toBeNull();
});

it("should work correctly with initial value", () => {
let {container} = render(
<RadioGroup label="Options" value="1">
<Radio data-testid="radio-test-1" value="1">
Option 1
</Radio>
</RadioGroup>,
);

expect(container.querySelector("[data-testid=radio-test-1] input")).toBeChecked();

let wrapper = render(
<RadioGroup defaultValue="2" label="Options">
<Radio value="1">Option 1</Radio>
<Radio data-testid="radio-test-2" value="2">
Option 1
</Radio>
</RadioGroup>,
);

expect(wrapper.container.querySelector("[data-testid=radio-test-2] input")).toBeChecked();
});

it("should change value after click", () => {
const {container} = render(
<RadioGroup defaultValue="1" label="Options">
<Radio value="1">Option 1</Radio>
<Radio className="radio-test-2" value="2">
Option 1
</Radio>
</RadioGroup>,
);

let radio2 = container.querySelector(".radio-test-2 input") as HTMLInputElement;

act(() => {
radio2.click();
});

expect(radio2).toBeChecked();
});

it("should ignore events when disabled", () => {
const {container} = render(
<RadioGroup label="Options">
<Radio isDisabled className="radio-test-1" value="1">
Option 1
</Radio>
<Radio value="2">Option 2</Radio>
</RadioGroup>,
);

let radio1 = container.querySelector(".radio-test-1 input") as HTMLInputElement;

act(() => {
radio1.click();
});

expect(radio1).not.toBeChecked();
});

it('should work correctly with "onValueChange" prop', () => {
const onValueChange = jest.fn();

const {container} = render(
<RadioGroup defaultValue="1" label="Options" onValueChange={onValueChange}>
<Radio value="1">Option 1</Radio>
<Radio className="radio-test-2" value="2">
Option 2
</Radio>
</RadioGroup>,
);

let radio2 = container.querySelector(".radio-test-2 input") as HTMLInputElement;

act(() => {
radio2.click();
});

expect(onValueChange).toBeCalledWith("2");

expect(radio2).toBeChecked();
});

it('should work correctly with "onFocus" prop', () => {
const onFocus = jest.fn();

const {container} = render(
<RadioGroup defaultValue="1" label="Options" onFocus={onFocus}>
<Radio value="1">Option 1</Radio>
<Radio className="radio-test-2" value="2">
Option 2
</Radio>
</RadioGroup>,
);

let radio2 = container.querySelector(".radio-test-2 input") as HTMLInputElement;

act(() => {
radio2.focus();
});

expect(onFocus).toBeCalled();
});

it('should work correctly with "isRequired" prop', () => {
const {container} = render(
<RadioGroup isRequired label="Options">
<Radio value="1">Option 1</Radio>
<Radio className="radio-test-2" value="2">
Option 2
</Radio>
</RadioGroup>,
);

let radio2 = container
.querySelector(".radio-test-2")
?.querySelector("input") as HTMLInputElement;

expect(radio2?.required).toBe(true);
});

it("should work correctly with controlled value", () => {
const onValueChange = jest.fn();

const Component = ({onValueChange}: Omit<RadioGroupProps, "value">) => {
const [value, setValue] = React.useState("1");

return (
<RadioGroup
label="Options"
value={value}
onValueChange={(next) => {
setValue(next);
onValueChange?.(next as any);
}}
>
<Radio value="1">Option 1</Radio>
<Radio className="radio-test-2" value="2">
Option 2
</Radio>
</RadioGroup>
);
};

const {container} = render(<Component onValueChange={onValueChange} />);

let radio2 = container.querySelector(".radio-test-2 input") as HTMLInputElement;

act(() => {
radio2.click();
});

expect(onValueChange).toBeCalled();

expect(radio2).toBeChecked();
});
});
64 changes: 64 additions & 0 deletions packages/components/radio/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
{
"name": "@jala-banyu/radio",
"version": "0.0.0",
"description": "Radios allow users to select a single option from a list of mutually exclusive options.",
"keywords": [
"radio"
],
"author": "Dika Mahendra <[email protected]>",
"homepage": "#",
"license": "MIT",
"main": "src/index.ts",
"sideEffects": false,
"files": [
"dist"
],
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/Atnic/banyu.git",
"directory": "packages/components/radio"
},
"bugs": {
"url": "https://github.com/Atnic/banyu/issues"
},
"scripts": {
"build": "tsup src --dts",
"build:fast": "tsup src",
"dev": "yarn build:fast -- --watch",
"clean": "rimraf dist .turbo",
"typecheck": "tsc --noEmit",
"prepack": "clean-package",
"postpack": "clean-package restore"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18",
"@jala-banyu/theme": ">=1.3.0",
"@jala-banyu/system": ">=1.0.0"
},
"dependencies": {
"@jala-banyu/shared-utils": "workspace:*",
"@jala-banyu/react-utils": "workspace:*",
"@jala-banyu/use-aria-press": "workspace:*",
"@react-aria/focus": "^3.14.3",
"@react-aria/interactions": "^3.19.1",
"@react-aria/radio": "^3.8.2",
"@react-aria/utils": "^3.21.1",
"@react-aria/visually-hidden": "^3.8.6",
"@react-stately/radio": "^3.9.1",
"@react-types/radio": "^3.5.2",
"@react-types/shared": "^3.21.0"
},
"devDependencies": {
"@jala-banyu/theme": "workspace:*",
"@jala-banyu/system": "workspace:*",
"@jala-banyu/button": "workspace:*",
"clean-package": "2.2.0",
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"clean-package": "../../../clean-package.config.json"
}
16 changes: 16 additions & 0 deletions packages/components/radio/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import Radio from "./radio";
import RadioGroup from "./radio-group";

// export types
export type {RadioProps} from "./radio";
export type {RadioGroupProps} from "./radio-group";

// export hooks
export {useRadio} from "./use-radio";
export {useRadioGroup} from "./use-radio-group";

// export context
export {RadioGroupProvider, useRadioGroupContext} from "./radio-group-context";

// export component
export {Radio, RadioGroup};
8 changes: 8 additions & 0 deletions packages/components/radio/src/radio-group-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type {ContextType} from "./use-radio-group";

import {createContext} from "@jala-banyu/react-utils";

export const [RadioGroupProvider, useRadioGroupContext] = createContext<ContextType>({
name: "RadioGroupContext",
strict: false,
});
40 changes: 40 additions & 0 deletions packages/components/radio/src/radio-group.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import {forwardRef} from "@jala-banyu/system";

import {RadioGroupProvider} from "./radio-group-context";
import {UseRadioGroupProps, useRadioGroup} from "./use-radio-group";

export interface RadioGroupProps extends Omit<UseRadioGroupProps, "defaultChecked"> {}

const RadioGroup = forwardRef<"div", RadioGroupProps>((props, ref) => {
const {
Component,
children,
label,
context,
description,
errorMessage,
getGroupProps,
getLabelProps,
getWrapperProps,
getDescriptionProps,
getErrorMessageProps,
} = useRadioGroup({...props, ref});

return (
<Component {...getGroupProps()}>
{label && <span {...getLabelProps()}>{label}</span>}
<div {...getWrapperProps()}>
<RadioGroupProvider value={context}>{children}</RadioGroupProvider>
</div>
{errorMessage ? (
<div {...getErrorMessageProps()}>{errorMessage as string}</div>
) : description ? (
<div {...getDescriptionProps()}>{description}</div>
) : null}
</Component>
);
});

RadioGroup.displayName = "Banyu.RadioGroup";

export default RadioGroup;
43 changes: 43 additions & 0 deletions packages/components/radio/src/radio.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import {forwardRef} from "@jala-banyu/system";
import {VisuallyHidden} from "@react-aria/visually-hidden";

import {UseRadioProps, useRadio} from "./use-radio";

export interface RadioProps extends UseRadioProps {}

const Radio = forwardRef<"input", RadioProps>((props, ref) => {
const {
Component,
children,
slots,
classNames,
description,
getBaseProps,
getWrapperProps,
getInputProps,
getLabelProps,
getLabelWrapperProps,
getControlProps,
} = useRadio({...props, ref});

return (
<Component {...getBaseProps()}>
<VisuallyHidden>
<input {...getInputProps()} />
</VisuallyHidden>
<span {...getWrapperProps()}>
<span {...getControlProps()} />
</span>
<div {...getLabelWrapperProps()}>
{children && <span {...getLabelProps()}>{children}</span>}
{description && (
<span className={slots.description({class: classNames?.description})}>{description}</span>
)}
</div>
</Component>
);
});

Radio.displayName = "Banyu.Radio";

export default Radio;
Loading

0 comments on commit 042079b

Please sign in to comment.