From 977e46f71685d667aa0f9ce3c11d1ebda17edf7b Mon Sep 17 00:00:00 2001 From: Dika Mahendra Date: Thu, 21 Mar 2024 10:12:21 +0700 Subject: [PATCH] feat: initial version radio --- packages/components/radio/README.md | 24 ++ .../components/radio/__tests__/radio.test.tsx | 198 +++++++++ packages/components/radio/package.json | 64 +++ packages/components/radio/src/index.ts | 16 + .../radio/src/radio-group-context.ts | 8 + packages/components/radio/src/radio-group.tsx | 40 ++ packages/components/radio/src/radio.tsx | 43 ++ .../components/radio/src/use-radio-group.ts | 222 ++++++++++ packages/components/radio/src/use-radio.ts | 275 +++++++++++++ .../radio/stories/radio.stories.tsx | 383 ++++++++++++++++++ packages/components/radio/tsconfig.json | 10 + packages/components/radio/tsup.config.ts | 8 + packages/core/theme/src/components/index.ts | 2 +- packages/core/theme/src/components/radio.ts | 281 +++++++------ pnpm-lock.yaml | 95 +++++ 15 files changed, 1549 insertions(+), 120 deletions(-) create mode 100644 packages/components/radio/README.md create mode 100644 packages/components/radio/__tests__/radio.test.tsx create mode 100644 packages/components/radio/package.json create mode 100644 packages/components/radio/src/index.ts create mode 100644 packages/components/radio/src/radio-group-context.ts create mode 100644 packages/components/radio/src/radio-group.tsx create mode 100644 packages/components/radio/src/radio.tsx create mode 100644 packages/components/radio/src/use-radio-group.ts create mode 100644 packages/components/radio/src/use-radio.ts create mode 100644 packages/components/radio/stories/radio.stories.tsx create mode 100644 packages/components/radio/tsconfig.json create mode 100644 packages/components/radio/tsup.config.ts diff --git a/packages/components/radio/README.md b/packages/components/radio/README.md new file mode 100644 index 00000000..f9cd0010 --- /dev/null +++ b/packages/components/radio/README.md @@ -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). diff --git a/packages/components/radio/__tests__/radio.test.tsx b/packages/components/radio/__tests__/radio.test.tsx new file mode 100644 index 00000000..26da111d --- /dev/null +++ b/packages/components/radio/__tests__/radio.test.tsx @@ -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( + + Option 1 + , + ); + + expect(() => wrapper.unmount()).not.toThrow(); + }); + + it("ref should be forwarded - group", () => { + const ref = React.createRef(); + + render( + + Option 1 + , + ); + expect(ref.current).not.toBeNull(); + }); + + it("ref should be forwarded - option", () => { + const ref = React.createRef(); + + render( + + + Option 1 + + , + ); + expect(ref.current).not.toBeNull(); + }); + + it("should work correctly with initial value", () => { + let {container} = render( + + + Option 1 + + , + ); + + expect(container.querySelector("[data-testid=radio-test-1] input")).toBeChecked(); + + let wrapper = render( + + Option 1 + + Option 1 + + , + ); + + expect(wrapper.container.querySelector("[data-testid=radio-test-2] input")).toBeChecked(); + }); + + it("should change value after click", () => { + const {container} = render( + + Option 1 + + Option 1 + + , + ); + + 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( + + + Option 1 + + Option 2 + , + ); + + 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( + + Option 1 + + Option 2 + + , + ); + + 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( + + Option 1 + + Option 2 + + , + ); + + 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( + + Option 1 + + Option 2 + + , + ); + + 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) => { + const [value, setValue] = React.useState("1"); + + return ( + { + setValue(next); + onValueChange?.(next as any); + }} + > + Option 1 + + Option 2 + + + ); + }; + + const {container} = render(); + + let radio2 = container.querySelector(".radio-test-2 input") as HTMLInputElement; + + act(() => { + radio2.click(); + }); + + expect(onValueChange).toBeCalled(); + + expect(radio2).toBeChecked(); + }); +}); diff --git a/packages/components/radio/package.json b/packages/components/radio/package.json new file mode 100644 index 00000000..9ec90dc4 --- /dev/null +++ b/packages/components/radio/package.json @@ -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 ", + "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" +} diff --git a/packages/components/radio/src/index.ts b/packages/components/radio/src/index.ts new file mode 100644 index 00000000..4ed0690e --- /dev/null +++ b/packages/components/radio/src/index.ts @@ -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}; diff --git a/packages/components/radio/src/radio-group-context.ts b/packages/components/radio/src/radio-group-context.ts new file mode 100644 index 00000000..0418b364 --- /dev/null +++ b/packages/components/radio/src/radio-group-context.ts @@ -0,0 +1,8 @@ +import type {ContextType} from "./use-radio-group"; + +import {createContext} from "@jala-banyu/react-utils"; + +export const [RadioGroupProvider, useRadioGroupContext] = createContext({ + name: "RadioGroupContext", + strict: false, +}); diff --git a/packages/components/radio/src/radio-group.tsx b/packages/components/radio/src/radio-group.tsx new file mode 100644 index 00000000..d88ec408 --- /dev/null +++ b/packages/components/radio/src/radio-group.tsx @@ -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 {} + +const RadioGroup = forwardRef<"div", RadioGroupProps>((props, ref) => { + const { + Component, + children, + label, + context, + description, + errorMessage, + getGroupProps, + getLabelProps, + getWrapperProps, + getDescriptionProps, + getErrorMessageProps, + } = useRadioGroup({...props, ref}); + + return ( + + {label && {label}} +
+ {children} +
+ {errorMessage ? ( +
{errorMessage as string}
+ ) : description ? ( +
{description}
+ ) : null} +
+ ); +}); + +RadioGroup.displayName = "Banyu.RadioGroup"; + +export default RadioGroup; diff --git a/packages/components/radio/src/radio.tsx b/packages/components/radio/src/radio.tsx new file mode 100644 index 00000000..0027df43 --- /dev/null +++ b/packages/components/radio/src/radio.tsx @@ -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 ( + + + + + + + +
+ {children && {children}} + {description && ( + {description} + )} +
+
+ ); +}); + +Radio.displayName = "Banyu.Radio"; + +export default Radio; diff --git a/packages/components/radio/src/use-radio-group.ts b/packages/components/radio/src/use-radio-group.ts new file mode 100644 index 00000000..8f4266cc --- /dev/null +++ b/packages/components/radio/src/use-radio-group.ts @@ -0,0 +1,222 @@ +import type {AriaRadioGroupProps} from "@react-types/radio"; +import type {Orientation} from "@react-types/shared"; +import type {ReactRef} from "@jala-banyu/react-utils"; +import type {RadioGroupSlots, SlotsToClasses} from "@jala-banyu/theme"; + +import {radioGroup} from "@jala-banyu/theme"; +import {useCallback, useMemo} from "react"; +import {RadioGroupState, useRadioGroupState} from "@react-stately/radio"; +import {useRadioGroup as useReactAriaRadioGroup} from "@react-aria/radio"; +import {HTMLBanyuProps, PropGetter} from "@jala-banyu/system"; +import {useDOMRef} from "@jala-banyu/react-utils"; +import {clsx, safeAriaLabel} from "@jala-banyu/shared-utils"; +import {mergeProps} from "@react-aria/utils"; + +import {RadioProps} from "./index"; + +interface Props extends Omit, "onChange"> { + /** + * Ref to the DOM node. + */ + ref?: ReactRef; + /** + * The axis the radio group items should align with. + * @default "vertical" + */ + orientation?: Orientation; + /** + * Classname or List of classes to change the classNames of the element. + * if `className` is passed, it will be added to the base slot. + * + * @example + * ```ts + * + * // radios + * + * ``` + */ + classNames?: SlotsToClasses; + /** + * React aria onChange event. + */ + onValueChange?: AriaRadioGroupProps["onChange"]; +} + +export type UseRadioGroupProps = Omit & + Omit & + Partial>; + +export type ContextType = { + groupState: RadioGroupState; + isRequired?: UseRadioGroupProps["isRequired"]; + isInvalid?: UseRadioGroupProps["isInvalid"]; + color?: RadioProps["color"]; + size?: RadioProps["size"]; + isDisabled?: RadioProps["isDisabled"]; + disableAnimation?: RadioProps["disableAnimation"]; + onChange?: RadioProps["onChange"]; +}; + +export function useRadioGroup(props: UseRadioGroupProps) { + const { + as, + ref, + classNames, + children, + label, + value, + name, + size = "md", + color = "primary", + isDisabled = false, + disableAnimation = false, + orientation = "vertical", + isRequired = false, + validationState, + isInvalid = validationState === "invalid", + isReadOnly, + errorMessage, + description, + className, + onChange, + onValueChange, + ...otherProps + } = props; + + const Component = as || "div"; + + const domRef = useDOMRef(ref); + + const otherPropsWithOrientation = useMemo(() => { + return { + ...otherProps, + value, + name, + "aria-label": safeAriaLabel(otherProps["aria-label"], label), + isRequired, + isReadOnly, + isInvalid, + orientation, + onChange: onValueChange, + }; + }, [ + otherProps, + value, + name, + label, + isRequired, + isReadOnly, + isInvalid, + orientation, + onValueChange, + ]); + + const groupState = useRadioGroupState(otherPropsWithOrientation); + + const { + labelProps, + radioGroupProps: groupProps, + errorMessageProps, + descriptionProps, + } = useReactAriaRadioGroup(otherPropsWithOrientation, groupState); + + const context: ContextType = useMemo( + () => ({ + size, + color, + groupState, + isRequired, + isInvalid, + isDisabled, + disableAnimation, + onChange, + }), + [ + size, + color, + isRequired, + isDisabled, + isInvalid, + onChange, + disableAnimation, + groupState.name, + groupState?.isDisabled, + groupState?.isReadOnly, + groupState?.isRequired, + groupState?.selectedValue, + groupState?.lastFocusedValue, + ], + ); + + const slots = useMemo( + () => radioGroup({isRequired, isInvalid, disableAnimation}), + [isInvalid, isRequired, disableAnimation], + ); + + const baseStyles = clsx(classNames?.base, className); + + const getGroupProps: PropGetter = useCallback(() => { + return { + ref: domRef, + className: slots.base({class: baseStyles}), + ...mergeProps(groupProps, otherProps), + }; + }, [domRef, slots, baseStyles, groupProps, otherProps]); + + const getLabelProps: PropGetter = useCallback(() => { + return { + className: slots.label({class: classNames?.label}), + ...labelProps, + }; + }, [slots, classNames?.label, labelProps, classNames?.label]); + + const getWrapperProps: PropGetter = useCallback(() => { + return { + className: slots.wrapper({class: classNames?.wrapper}), + role: "presentation", + "data-orientation": orientation, + }; + }, [slots, classNames?.wrapper, orientation, slots.wrapper]); + + const getDescriptionProps: PropGetter = useCallback( + (props = {}) => { + return { + ...props, + ...descriptionProps, + className: slots.description({class: clsx(classNames?.description, props?.className)}), + }; + }, + [slots, classNames?.description, descriptionProps, slots.description], + ); + + const getErrorMessageProps: PropGetter = useCallback( + (props = {}) => { + return { + ...props, + ...errorMessageProps, + className: slots.errorMessage({class: clsx(classNames?.errorMessage, props?.className)}), + }; + }, + [slots, classNames?.errorMessage, errorMessageProps], + ); + + return { + Component, + children, + label, + context, + errorMessage, + description, + getGroupProps, + getLabelProps, + getWrapperProps, + getDescriptionProps, + getErrorMessageProps, + }; +} + +export type UseRadioGroupReturn = ReturnType; diff --git a/packages/components/radio/src/use-radio.ts b/packages/components/radio/src/use-radio.ts new file mode 100644 index 00000000..c0251237 --- /dev/null +++ b/packages/components/radio/src/use-radio.ts @@ -0,0 +1,275 @@ +import type {AriaRadioProps} from "@react-types/radio"; +import type {RadioVariantProps, RadioSlots, SlotsToClasses} from "@jala-banyu/theme"; + +import {Ref, ReactNode, useCallback, useId, useState} from "react"; +import {useMemo, useRef} from "react"; +import {useFocusRing} from "@react-aria/focus"; +import {useHover} from "@react-aria/interactions"; +import {usePress} from "@jala-banyu/use-aria-press"; +import {radio} from "@jala-banyu/theme"; +import {useRadio as useReactAriaRadio} from "@react-aria/radio"; +import {HTMLBanyuProps, PropGetter} from "@jala-banyu/system"; +import {__DEV__, warn, clsx, dataAttr} from "@jala-banyu/shared-utils"; +import {useDOMRef} from "@jala-banyu/react-utils"; +import {chain, mergeProps} from "@react-aria/utils"; + +import {useRadioGroupContext} from "./radio-group-context"; + +interface Props extends Omit, keyof RadioVariantProps> { + /** + * Ref to the DOM node. + */ + ref?: Ref; + /** + * The label of the checkbox. + */ + children?: ReactNode; + /** + * The radio description text. + */ + description?: string | ReactNode; + /** + * Classname or List of classes to change the classNames of the element. + * if `className` is passed, it will be added to the base slot. + * + * @example + * ```ts + * + * ``` + */ + classNames?: SlotsToClasses; +} + +export type UseRadioProps = Omit & + Omit & + RadioVariantProps; + +export function useRadio(props: UseRadioProps) { + const groupContext = useRadioGroupContext(); + + const { + as, + ref, + classNames, + id, + value, + children, + description, + size = groupContext?.size ?? "md", + color = groupContext?.color ?? "primary", + isDisabled: isDisabledProp = groupContext?.isDisabled ?? false, + disableAnimation = groupContext?.disableAnimation ?? false, + onChange = groupContext?.onChange, + autoFocus = false, + className, + ...otherProps + } = props; + + if (groupContext && __DEV__) { + if ("checked" in otherProps) { + warn('Remove props "checked" if in the Radio.Group.', "Radio"); + } + if (value === undefined) { + warn('Props "value" must be defined if in the Radio.Group.', "Radio"); + } + } + + const Component = as || "label"; + + const domRef = useDOMRef(ref); + const inputRef = useRef(null); + + const labelId = useId(); + + const isRequired = useMemo(() => groupContext.isRequired ?? false, [groupContext.isRequired]); + const isInvalid = groupContext.isInvalid; + + const ariaRadioProps = useMemo(() => { + const ariaLabel = + otherProps["aria-label"] || typeof children === "string" ? (children as string) : undefined; + const ariaDescribedBy = + otherProps["aria-describedby"] || typeof description === "string" + ? (description as string) + : undefined; + + return { + id, + isRequired, + isDisabled: isDisabledProp, + "aria-label": ariaLabel, + "aria-labelledby": otherProps["aria-labelledby"] || labelId, + "aria-describedby": ariaDescribedBy, + }; + }, [labelId, id, isDisabledProp, isRequired]); + + const { + inputProps, + isDisabled, + isSelected, + isPressed: isPressedKeyboard, + } = useReactAriaRadio( + { + value, + children, + ...groupContext, + ...ariaRadioProps, + }, + groupContext.groupState, + inputRef, + ); + + const {focusProps, isFocused, isFocusVisible} = useFocusRing({ + autoFocus, + }); + + const interactionDisabled = isDisabled || inputProps.readOnly; + + // Handle press state for full label. Keyboard press state is returned by useCheckbox + // since it is handled on the element itself. + const [isPressed, setPressed] = useState(false); + const {pressProps} = usePress({ + isDisabled: interactionDisabled, + onPressStart(e) { + if (e.pointerType !== "keyboard") { + setPressed(true); + } + }, + onPressEnd(e) { + if (e.pointerType !== "keyboard") { + setPressed(false); + } + }, + }); + + const {hoverProps, isHovered} = useHover({ + isDisabled: interactionDisabled, + }); + + const pressed = interactionDisabled ? false : isPressed || isPressedKeyboard; + + const slots = useMemo( + () => + radio({ + color, + size, + isInvalid, + isDisabled, + disableAnimation, + }), + [color, size, isDisabled, isInvalid, disableAnimation], + ); + + const baseStyles = clsx(classNames?.base, className); + + const getBaseProps: PropGetter = useCallback( + (props = {}) => { + return { + ...props, + ref: domRef, + className: slots.base({class: baseStyles}), + "data-disabled": dataAttr(isDisabled), + "data-focus": dataAttr(isFocused), + "data-focus-visible": dataAttr(isFocusVisible), + "data-selected": dataAttr(isSelected), + "data-invalid": dataAttr(isInvalid), + "data-hover": dataAttr(isHovered), + "data-pressed": dataAttr(pressed), + "data-hover-unselected": dataAttr(isHovered && !isSelected), + "data-readonly": dataAttr(inputProps.readOnly), + "aria-required": dataAttr(isRequired), + ...mergeProps(hoverProps, pressProps, otherProps), + }; + }, + [ + slots, + baseStyles, + domRef, + isDisabled, + isFocused, + isFocusVisible, + isSelected, + isInvalid, + isHovered, + pressed, + inputProps.readOnly, + isRequired, + otherProps, + ], + ); + + const getWrapperProps: PropGetter = useCallback( + (props = {}) => { + return { + ...props, + "aria-hidden": true, + className: clsx(slots.wrapper({class: clsx(classNames?.wrapper, props.className)})), + }; + }, + [slots, classNames?.wrapper], + ); + + const getInputProps: PropGetter = useCallback( + (props = {}) => { + return { + ...props, + ref: inputRef, + required: isRequired, + ...mergeProps(inputProps, focusProps), + onChange: chain(inputProps.onChange, onChange), + }; + }, + [inputProps, focusProps, isRequired, onChange], + ); + + const getLabelProps: PropGetter = useCallback( + (props = {}) => ({ + ...props, + id: labelId, + className: slots.label({class: classNames?.label}), + }), + [slots, classNames?.label, isDisabled, isSelected, isInvalid], + ); + + const getLabelWrapperProps: PropGetter = useCallback( + (props = {}) => ({ + ...props, + className: slots.labelWrapper({class: classNames?.labelWrapper}), + }), + [slots, classNames?.labelWrapper], + ); + + const getControlProps: PropGetter = useCallback( + (props = {}) => ({ + ...props, + className: slots.control({class: classNames?.control}), + }), + [slots, classNames?.control], + ); + + return { + Component, + children, + slots, + classNames, + description, + isSelected, + isDisabled, + isInvalid, + isFocusVisible, + getBaseProps, + getWrapperProps, + getInputProps, + getLabelProps, + getLabelWrapperProps, + getControlProps, + }; +} + +export type UseRadioReturn = ReturnType; diff --git a/packages/components/radio/stories/radio.stories.tsx b/packages/components/radio/stories/radio.stories.tsx new file mode 100644 index 00000000..35abab3c --- /dev/null +++ b/packages/components/radio/stories/radio.stories.tsx @@ -0,0 +1,383 @@ +import React from "react"; +import {Meta} from "@storybook/react"; +import {VisuallyHidden} from "@react-aria/visually-hidden"; +import {radio, button} from "@jala-banyu/theme"; +import {clsx} from "@jala-banyu/shared-utils"; + +import { + RadioGroup, + Radio, + RadioProps, + RadioGroupProps, + useRadio, + useRadioGroupContext, +} from "../src"; + +export default { + title: "Components/RadioGroup", + component: RadioGroup, + onChange: {action: "changed"}, + argTypes: { + color: { + control: { + type: "select", + }, + options: ["default", "primary", "secondary", "success", "warning", "danger"], + }, + size: { + control: { + type: "select", + }, + options: ["sm", "md", "lg"], + }, + isDisabled: { + control: { + type: "boolean", + }, + }, + }, +} as Meta; + +const defaultProps = { + ...radio.defaultVariants, + label: "Options", +}; + +const Template = (args: RadioGroupProps) => { + const radioProps = args.description + ? { + a: { + description: "Description for Option A", + }, + b: { + description: "Description for Option B", + }, + c: { + description: "Description for Option C", + }, + d: { + description: "Description for Option D", + }, + } + : { + a: {}, + b: {}, + c: {}, + d: {}, + }; + + const items = ( + <> + + Option A + + + Option B + + + Option C + + + Option D + + + ); + + return args.isRequired ? ( +
{ + e.preventDefault(); + alert("Submitted!"); + }} + > + {items} + +
+ ) : ( + {items} + ); +}; + +const InvalidTemplate = (args: RadioGroupProps) => { + const [isInvalid, setIsInvalid] = React.useState(true); + + const radioProps = args.description + ? { + a: { + description: "Description for Option A", + }, + b: { + description: "Description for Option B", + }, + c: { + description: "Description for Option C", + }, + d: { + description: "Description for Option D", + }, + } + : { + a: {}, + b: {}, + c: {}, + d: {}, + }; + + const items = ( + <> + + Option A + + + Option B + + + Option C + + + Option D + + + ); + + const validOptions = ["C", "B"]; + + return args.isRequired ? ( +
{ + e.preventDefault(); + alert("Submitted!"); + }} + > + setIsInvalid(!validOptions.includes(value))} + > + {items} + + +
+ ) : ( + {items} + ); +}; + +const ControlledTemplate = (args: RadioGroupProps) => { + const [selectedItem, setSelectedItem] = React.useState("london"); + + React.useEffect(() => { + // eslint-disable-next-line no-console + console.log("isSelected:", selectedItem); + }, [selectedItem]); + + return ( +
+ + Buenos Aires + Sydney + London + Tokyo + +

Selected: {selectedItem}

+
+ ); +}; + +export const Default = { + render: Template, + + args: { + ...defaultProps, + }, +}; + +export const IsDisabled = { + render: Template, + + args: { + ...defaultProps, + isDisabled: true, + }, +}; + +export const DefaultChecked = { + render: Template, + + args: { + ...defaultProps, + defaultValue: "C", + }, +}; + +export const IsRequired = { + render: Template, + + args: { + ...defaultProps, + isRequired: true, + }, +}; + +export const WithDescription = { + render: Template, + + args: { + ...defaultProps, + description: "Please select an option", + }, +}; + +export const IsInvalid = { + render: InvalidTemplate, + + args: { + ...defaultProps, + isRequired: true, + description: "Please select an option", + }, +}; + +export const WithErrorMessage = { + render: Template, + + args: { + ...defaultProps, + isRequired: true, + validationState: "invalid", + errorMessage: "The selected option is invalid", + }, +}; + +export const Row = { + render: Template, + + args: { + ...defaultProps, + orientation: "horizontal", + }, +}; + +export const DisableAnimation = { + render: Template, + + args: { + ...defaultProps, + disableAnimation: true, + }, +}; + +export const Controlled = { + render: ControlledTemplate, + + args: { + ...defaultProps, + }, +}; + +const CustomRadio = (props: RadioProps) => { + const {children, ...otherProps} = props; + + const {groupState} = useRadioGroupContext(); + + const isSelected = groupState.selectedValue === otherProps.value; + + return ( + + {children} + + ); +}; + +export const CustomWithClassNames = () => { + return ( + + + Free + + + Pro + + + Enterprise + + + ); +}; + +const RadioCard = (props: RadioProps) => { + const { + Component, + children, + isSelected, + description, + getBaseProps, + getWrapperProps, + getInputProps, + getLabelProps, + getLabelWrapperProps, + getControlProps, + } = useRadio(props); + + return ( + + + + + + + +
+ {children && {children}} + {description && ( + {description} + )} +
+
+ ); +}; + +export const CustomWithHooks = () => { + return ( + + + Free + + + Pro + + + Enterprise + + + ); +}; diff --git a/packages/components/radio/tsconfig.json b/packages/components/radio/tsconfig.json new file mode 100644 index 00000000..5d012f6e --- /dev/null +++ b/packages/components/radio/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "tailwind-variants": ["../../../node_modules/tailwind-variants"] + }, + }, + "include": ["src", "index.ts"] +} diff --git a/packages/components/radio/tsup.config.ts b/packages/components/radio/tsup.config.ts new file mode 100644 index 00000000..3e2bcff6 --- /dev/null +++ b/packages/components/radio/tsup.config.ts @@ -0,0 +1,8 @@ +import {defineConfig} from "tsup"; + +export default defineConfig({ + clean: true, + target: "es2019", + format: ["cjs", "esm"], + banner: {js: '"use client";'}, +}); diff --git a/packages/core/theme/src/components/index.ts b/packages/core/theme/src/components/index.ts index 19d17832..debb81bf 100644 --- a/packages/core/theme/src/components/index.ts +++ b/packages/core/theme/src/components/index.ts @@ -11,7 +11,7 @@ export * from "./popover"; export * from "./chip"; // export * from "./badge"; export * from "./checkbox"; -// export * from "./radio"; +export * from "./radio"; // export * from "./pagination"; export * from "./toggle"; export * from "./accordion"; diff --git a/packages/core/theme/src/components/radio.ts b/packages/core/theme/src/components/radio.ts index d2e560e5..5c95088c 100644 --- a/packages/core/theme/src/components/radio.ts +++ b/packages/core/theme/src/components/radio.ts @@ -1,7 +1,6 @@ -import type {VariantProps} from "tailwind-variants" +import type {VariantProps} from "tailwind-variants"; -import {tv} from "../utils/tv" -import {groupDataFocusVisibleClasses} from "../utils" +import {tv} from "../utils/tv"; /** * Radio wrapper **Tailwind Variants** component @@ -10,7 +9,7 @@ import {groupDataFocusVisibleClasses} from "../utils" * * @example * */ -const radio = tv({ - slots: { - base: "group relative max-w-fit inline-flex items-center justify-start cursor-pointer tap-highlight-transparent p-2 -m-2", - wrapper: [ - "relative", - "inline-flex", - "items-center", - "justify-center", - "flex-shrink-0", - "overflow-hidden", - "border-solid", - "border-md", - "box-border", - "border-default", - "rounded-full", - "group-data-[hover-unselected=true]:bg-default-100", - // focus ring - ...groupDataFocusVisibleClasses, - ], - labelWrapper: "flex flex-col ml-1", - control: [ - "z-10", - "w-2", - "h-2", - "opacity-0", - "scale-0", - "origin-center", - "rounded-full", - "group-data-[selected=true]:opacity-100", - "group-data-[selected=true]:scale-100", - ], - label: "relative text-foreground select-none", - description: "relative text-foreground-400", - }, - variants: { - color: { - default: { - control: "bg-default-500 text-default-foreground", - wrapper: "group-data-[selected=true]:border-default-500", - }, - primary: { - control: "bg-primary text-primary-foreground", - wrapper: "group-data-[selected=true]:border-primary", - }, - secondary: { - control: "bg-secondary text-secondary-foreground", - wrapper: "group-data-[selected=true]:border-secondary", - }, - success: { - control: "bg-success text-success-foreground", - wrapper: "group-data-[selected=true]:border-success", - }, - warning: { - control: "bg-warning text-warning-foreground", - wrapper: "group-data-[selected=true]:border-warning", - }, - danger: { - control: "bg-danger text-danger-foreground", - wrapper: "group-data-[selected=true]:border-danger", - }, +const radio = tv( + { + slots: { + base: "group relative max-w-fit inline-flex items-center justify-start cursor-pointer tap-highlight-transparent p-2 -m-2", + wrapper: [ + "relative", + "inline-flex", + "items-center", + "justify-center", + "flex-shrink-0", + "overflow-hidden", + "border-solid", + "box-border", + "border-[1px]", + "border-neutral-300", + "rounded-full", + "ring-0", + "group-data-[hover=true]:ring-4", + "group-data-[hover=true]:ring-neutral-200", + "group-data-[focus=true]:ring-4", + // focus ring + ], + labelWrapper: "flex flex-col ml-1", + control: [ + "z-10", + "w-2", + "h-2", + "opacity-100", + "scale-100", + "origin-center", + "rounded-full", + "group-data-[selected=true]:opacity-100", + "group-data-[selected=true]:scale-100", + ], + label: "relative text-foreground select-none", + description: "relative text-foreground-400", }, - size: { - sm: { - wrapper: "w-4 h-4", - control: "w-1.5 h-1.5", - labelWrapper: "ml-1", - label: "text-sm", - description: "text-xs", + variants: { + color: { + primary: { + control: "group-data-[selected=true]:bg-white text-brand-foreground", + wrapper: [ + "group-data-[selected=true]:border-brand", + "group-data-[selected=true]:bg-brand", + "group-data-[focus=true]:ring-brand-100", + ], + }, + success: { + control: "group-data-[selected=true]:bg-white text-success-foreground", + wrapper: [ + "group-data-[selected=true]:border-success", + "group-data-[selected=true]:bg-success", + "group-data-[focus=true]:ring-success-100", + ], + }, + warning: { + control: "group-data-[selected=true]:bg-white text-warning-foreground", + wrapper: [ + "group-data-[selected=true]:border-warning", + "group-data-[selected=true]:bg-warning", + "group-data-[focus=true]:ring-warning-100", + ], + }, + danger: { + control: "group-data-[selected=true]:bg-white text-danger-foreground", + wrapper: [ + "group-data-[selected=true]:border-danger", + "group-data-[selected=true]:bg-danger", + "group-data-[focus=true]:ring-danger-100", + ], + }, + basic: { + control: "group-data-[selected=true]:bg-white text-neutral-800", + wrapper: [ + "group-data-[selected=true]:border-neutral-50", + "group-data-[selected=true]:bg-neutral-50", + "group-data-[focus=true]:ring-neutral-100", + ], + }, + secondary: { + control: "group-data-[selected=true]:bg-white text-neutral-800", + wrapper: [ + "group-data-[selected=true]:border-neutral-300", + "group-data-[selected=true]:bg-neutral-100", + "group-data-[focus=true]:ring-neutral-200", + ], + }, + white: { + control: "group-data-[selected=true]:bg-white text-neutral-800", + wrapper: [ + "group-data-[selected=true]:border-neutral-300", + "group-data-[selected=true]:bg-neutral-100", + "group-data-[focus=true]:ring-neutral-200", + ], + }, }, - md: { - wrapper: "w-5 h-5", - control: "w-2 h-2", - labelWrapper: "ml-2", - label: "text-md", - description: "text-sm", + size: { + sm: { + wrapper: "w-4 h-4", + control: "w-1.5 h-1.5", + labelWrapper: "ml-1", + label: "text-sm", + description: "text-xs", + }, + md: { + wrapper: "w-5 h-5", + control: "w-2 h-2", + labelWrapper: "ml-2", + label: "text-md", + description: "text-sm", + }, + lg: { + wrapper: "w-6 h-6", + control: "w-2.5 h-2.5", + labelWrapper: "ml-2", + label: "text-lg", + description: "text-md", + }, }, - lg: { - wrapper: "w-6 h-6", - control: "w-2.5 h-2.5", - labelWrapper: "ml-2", - label: "text-lg", - description: "text-md", + isDisabled: { + true: { + base: "pointer-events-none", + wrapper: [ + "border-neutral-100", + "bg-neutral-200", + "group-data-[selected=true]:bg-neutral-200", + "group-data-[selected=true]:border-neutral-200", + ], + control: "bg-neutral-200 group-data-[selected=true]:bg-neutral-400", + }, }, - }, - isDisabled: { - true: { - base: "opacity-disabled pointer-events-none", + isInvalid: { + true: { + control: "bg-danger text-danger-foreground", + wrapper: "border-danger group-data-[selected=true]:border-danger", + label: "text-danger", + description: "text-danger-300", + }, }, - }, - isInvalid: { - true: { - control: "bg-danger text-danger-foreground", - wrapper: "border-danger group-data-[selected=true]:border-danger", - label: "text-danger", - description: "text-danger-300", + disableAnimation: { + true: {}, + false: { + wrapper: [ + "group-data-[pressed=true]:scale-95", + "transition-all", + "motion-reduce:transition-none", + ], + control: "transition-transform-opacity motion-reduce:transition-none", + label: "transition-colors motion-reduce:transition-none", + description: "transition-colors motion-reduce:transition-none", + }, }, }, - disableAnimation: { - true: {}, - false: { - wrapper: [ - "group-data-[pressed=true]:scale-95", - "transition-transform-colors", - "motion-reduce:transition-none", - ], - control: "transition-transform-opacity motion-reduce:transition-none", - label: "transition-colors motion-reduce:transition-none", - description: "transition-colors motion-reduce:transition-none", - }, + defaultVariants: { + color: "primary", + size: "md", + isDisabled: false, + isInvalid: false, + disableAnimation: false, }, }, - defaultVariants: { - color: "primary", - size: "md", - isDisabled: false, - isInvalid: false, - disableAnimation: false, - }, -}) + // {twMerge: false}, +); /** * RadioGroup wrapper **Tailwind Variants** component @@ -189,11 +232,11 @@ const radioGroup = tv({ isRequired: false, disableAnimation: false, }, -}) +}); -export type RadioGroupSlots = keyof ReturnType +export type RadioGroupSlots = keyof ReturnType; -export type RadioVariantProps = VariantProps -export type RadioSlots = keyof ReturnType +export type RadioVariantProps = VariantProps; +export type RadioSlots = keyof ReturnType; -export {radio, radioGroup} +export {radio, radioGroup}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 77d01c24..87143ef9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1358,6 +1358,61 @@ importers: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) + packages/components/radio: + dependencies: + '@jala-banyu/react-utils': + specifier: workspace:* + version: link:../../utilities/react-utils + '@jala-banyu/shared-utils': + specifier: workspace:* + version: link:../../utilities/shared-utils + '@jala-banyu/use-aria-press': + specifier: workspace:* + version: link:../../hooks/use-aria-press + '@react-aria/focus': + specifier: ^3.14.3 + version: 3.16.2(react@18.2.0) + '@react-aria/interactions': + specifier: ^3.19.1 + version: 3.21.1(react@18.2.0) + '@react-aria/radio': + specifier: ^3.8.2 + version: 3.10.2(react@18.2.0) + '@react-aria/utils': + specifier: ^3.21.1 + version: 3.23.2(react@18.2.0) + '@react-aria/visually-hidden': + specifier: ^3.8.6 + version: 3.8.10(react@18.2.0) + '@react-stately/radio': + specifier: ^3.9.1 + version: 3.10.2(react@18.2.0) + '@react-types/radio': + specifier: ^3.5.2 + version: 3.7.1(react@18.2.0) + '@react-types/shared': + specifier: ^3.21.0 + version: 3.22.1(react@18.2.0) + devDependencies: + '@jala-banyu/button': + specifier: workspace:* + version: link:../button + '@jala-banyu/system': + specifier: workspace:* + version: link:../../core/system + '@jala-banyu/theme': + specifier: workspace:* + version: link:../../core/theme + clean-package: + specifier: 2.2.0 + version: 2.2.0 + react: + specifier: ^18.2.0 + version: 18.2.0 + react-dom: + specifier: ^18.2.0 + version: 18.2.0(react@18.2.0) + packages/components/ripple: dependencies: '@jala-banyu/react-utils': @@ -6651,6 +6706,24 @@ packages: react: 18.2.0 dev: false + /@react-aria/radio@3.10.2(react@18.2.0): + resolution: {integrity: sha512-CTUTR+qt3BLjmyQvKHZuVm+1kyvT72ZptOty++sowKXgJApTLdjq8so1IpaLAr8JIfzqD5I4tovsYwIQOX8log==} + peerDependencies: + react: ^18.2.0 + dependencies: + '@react-aria/focus': 3.16.2(react@18.2.0) + '@react-aria/form': 3.0.3(react@18.2.0) + '@react-aria/i18n': 3.10.2(react@18.2.0) + '@react-aria/interactions': 3.21.1(react@18.2.0) + '@react-aria/label': 3.7.6(react@18.2.0) + '@react-aria/utils': 3.23.2(react@18.2.0) + '@react-stately/radio': 3.10.2(react@18.2.0) + '@react-types/radio': 3.7.1(react@18.2.0) + '@react-types/shared': 3.22.1(react@18.2.0) + '@swc/helpers': 0.5.6 + react: 18.2.0 + dev: false + /@react-aria/selection@3.17.5(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-gO5jBUkc7WdkiFMlWt3x9pTSuj3Yeegsxfo44qU5NPlKrnGtPRZDWrlACNgkDHu645RNNPhlyoX0C+G8mUg1xA==} peerDependencies: @@ -6921,6 +6994,19 @@ packages: react: 18.2.0 dev: false + /@react-stately/radio@3.10.2(react@18.2.0): + resolution: {integrity: sha512-JW5ZWiNMKcZvMTsuPeWJQLHXD5rlqy7Qk6fwUx/ZgeibvMBW/NnW19mm2+IMinzmbtERXvR6nsiA837qI+4dew==} + peerDependencies: + react: ^18.2.0 + dependencies: + '@react-stately/form': 3.0.1(react@18.2.0) + '@react-stately/utils': 3.9.1(react@18.2.0) + '@react-types/radio': 3.7.1(react@18.2.0) + '@react-types/shared': 3.22.1(react@18.2.0) + '@swc/helpers': 0.5.6 + react: 18.2.0 + dev: false + /@react-stately/selection@3.14.3(react@18.2.0): resolution: {integrity: sha512-d/t0rIWieqQ7wjLoMoWnuHEUSMoVXxkPBFuSlJF3F16289FiQ+b8aeKFDzFTYN7fFD8rkZTnpuE4Tcxg3TmA+w==} peerDependencies: @@ -7117,6 +7203,15 @@ packages: react: 18.2.0 dev: false + /@react-types/radio@3.7.1(react@18.2.0): + resolution: {integrity: sha512-Zut3rN1odIUBLZdijeyou+UqsLeRE76d9A+npykYGu29ndqmo3w4sLn8QeQcdj1IR71ZnG0pW2Y2BazhK5XrrQ==} + peerDependencies: + react: ^18.2.0 + dependencies: + '@react-types/shared': 3.22.1(react@18.2.0) + react: 18.2.0 + dev: false + /@react-types/select@3.9.2(react@18.2.0): resolution: {integrity: sha512-fGFrunednY3Pq/BBwVOf87Fsuyo/SlevL0wFIE9OOl2V5NXVaTY7/7RYA8hIOHPzmvsMbndy419BEudiNGhv4A==} peerDependencies: