From eedd04acfadaced7f761ca1bacba44509bf6a838 Mon Sep 17 00:00:00 2001 From: Leonardo Giacone Date: Thu, 28 Nov 2024 09:41:50 +0100 Subject: [PATCH] feat(admin-ui): Radio & RadioGroup component (#4400) --- packages/admin-ui/package.json | 1 + packages/admin-ui/src/Radio/Radio.tsx | 59 +++++ .../admin-ui/src/Radio/RadioGroup.stories.tsx | 215 ++++++++++++++++++ packages/admin-ui/src/Radio/RadioGroup.tsx | 93 ++++++++ .../src/Radio/RadioGroupPresenter.test.ts | 58 +++++ .../admin-ui/src/Radio/RadioGroupPresenter.ts | 48 ++++ packages/admin-ui/src/Radio/RadioItem.ts | 41 ++++ .../admin-ui/src/Radio/RadioItemFormatted.ts | 8 + .../admin-ui/src/Radio/RadioItemFormatter.ts | 13 ++ .../admin-ui/src/Radio/RadioItemParams.ts | 8 + packages/admin-ui/src/Radio/index.ts | 3 + packages/admin-ui/src/Radio/useRadioGroup.ts | 30 +++ packages/admin-ui/src/index.ts | 1 + yarn.lock | 29 +++ 14 files changed, 607 insertions(+) create mode 100644 packages/admin-ui/src/Radio/Radio.tsx create mode 100644 packages/admin-ui/src/Radio/RadioGroup.stories.tsx create mode 100644 packages/admin-ui/src/Radio/RadioGroup.tsx create mode 100644 packages/admin-ui/src/Radio/RadioGroupPresenter.test.ts create mode 100644 packages/admin-ui/src/Radio/RadioGroupPresenter.ts create mode 100644 packages/admin-ui/src/Radio/RadioItem.ts create mode 100644 packages/admin-ui/src/Radio/RadioItemFormatted.ts create mode 100644 packages/admin-ui/src/Radio/RadioItemFormatter.ts create mode 100644 packages/admin-ui/src/Radio/RadioItemParams.ts create mode 100644 packages/admin-ui/src/Radio/index.ts create mode 100644 packages/admin-ui/src/Radio/useRadioGroup.ts diff --git a/packages/admin-ui/package.json b/packages/admin-ui/package.json index 02317ec4fd..1dc4fc31e1 100644 --- a/packages/admin-ui/package.json +++ b/packages/admin-ui/package.json @@ -14,6 +14,7 @@ "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-radio-group": "^1.2.1", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slider": "^1.2.0", diff --git a/packages/admin-ui/src/Radio/Radio.tsx b/packages/admin-ui/src/Radio/Radio.tsx new file mode 100644 index 0000000000..13ba6b913f --- /dev/null +++ b/packages/admin-ui/src/Radio/Radio.tsx @@ -0,0 +1,59 @@ +import * as React from "react"; +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"; +import { cn, makeDecoratable } from "~/utils"; + +/** + * RadioItem + */ +interface RadioProps extends React.ComponentPropsWithoutRef { + label: string | React.ReactNode; + id: string; +} + +const DecoratableRadio = React.forwardRef< + React.ElementRef, + RadioProps +>(({ className, label, id, ...props }, ref) => { + return ( +
+ + + + + + +
+ ); +}); +DecoratableRadio.displayName = RadioGroupPrimitive.Item.displayName; +const Radio = makeDecoratable("Radio", DecoratableRadio); + +export { Radio, type RadioProps }; diff --git a/packages/admin-ui/src/Radio/RadioGroup.stories.tsx b/packages/admin-ui/src/Radio/RadioGroup.stories.tsx new file mode 100644 index 0000000000..3ab1d73c9b --- /dev/null +++ b/packages/admin-ui/src/Radio/RadioGroup.stories.tsx @@ -0,0 +1,215 @@ +import React, { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react"; +import { RadioGroup } from "./RadioGroup"; +import { Text } from "~/Text"; +import { Button } from "~/Button"; + +const meta: Meta = { + title: "Components/RadioGroup", + component: RadioGroup, + tags: ["autodocs"], + parameters: { + layout: "fullscreen" + }, + decorators: [ + Story => ( +
+ +
+ ) + ], + render: args => { + const [value, setValue] = useState(args.value); + return setValue(value)} />; + } +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + items: [ + { + value: "value-1", + label: "Value 1" + }, + { + value: "value-2", + label: "Value 2" + }, + { + value: "value-3", + label: "Value 3" + } + ] + } +}; + +export const Disabled: Story = { + args: { + ...Default.args, + value: "value-2", + disabled: true, + items: [ + { + value: "value-1", + label: "Value 1" + }, + { + value: "value-2", + label: "Value 2" + }, + { + value: "value-3", + label: "Value 3" + } + ] + } +}; + +export const WithDefaultOption: Story = { + args: { + ...Default.args, + value: "value-2" + } +}; + +export const WithDisabledOption: Story = { + args: { + ...Default.args, + items: [ + { + value: "value-1", + label: "Value 1" + }, + { + value: "value-2", + label: "Value 2", + disabled: true + }, + { + value: "value-3", + label: "Value 3" + } + ] + } +}; + +export const WithDefaultDisabledOption: Story = { + args: { + ...Default.args, + value: "value-2", + items: [ + { + value: "value-1", + label: "Value 1" + }, + { + value: "value-2", + label: "Value 2", + disabled: true + }, + { + value: "value-3", + label: "Value 3" + } + ] + } +}; + +export const WithLongOption: Story = { + args: { + ...Default.args, + items: [ + { + value: "value-1", + label: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer fringilla magna vel massa suscipit mollis. Nunc dui felis, iaculis id tortor ut, hendrerit pulvinar felis." + }, + { + value: "value-2", + label: " Pellentesque erat ipsum, posuere dapibus diam id, accumsan sagittis mi. In eu nibh ut nunc ultricies placerat.", + disabled: true + }, + { + value: "value-3", + label: "Integer a hendrerit dui. Sed tincidunt vel nibh a finibus." + } + ] + } +}; + +export const WithComplexOptions: Story = { + args: { + ...Default.args, + items: [ + { + id: "value-1", + value: "value-1", + label: ( +
+
{"Value 1"}
+ +
+ ) + }, + { + id: "value-2", + value: "value-2", + label: ( +
+
{"Value 2"}
+ +
+ ) + }, + { + id: "value-3", + value: "value-3", + label: ( +
+
{"Value 3"}
+ +
+ ) + } + ] + } +}; + +export const WithExternalValueControl: Story = { + args: { + value: "value-2", + items: [ + { + value: "value-1", + label: "Value 1" + }, + { + value: "value-2", + label: "Value 2" + }, + { + value: "value-3", + label: "Value 3" + } + ] + }, + render: args => { + const [value, setValue] = useState(args.value); + return ( +
+
+ setValue(value)} /> +
+
+
+
+ Current selected value:
{value}
+
+
+ ); + } +}; diff --git a/packages/admin-ui/src/Radio/RadioGroup.tsx b/packages/admin-ui/src/Radio/RadioGroup.tsx new file mode 100644 index 0000000000..761b529cc1 --- /dev/null +++ b/packages/admin-ui/src/Radio/RadioGroup.tsx @@ -0,0 +1,93 @@ +import * as React from "react"; +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"; +import { cn, makeDecoratable } from "~/utils"; +import { Radio } from "./Radio"; +import { RadioItemParams } from "./RadioItemParams"; +import { RadioItemFormatted } from "./RadioItemFormatted"; +import { useRadioGroup } from "./useRadioGroup"; + +/** + * Radio Group Root + */ +const DecoratableRadioGroupRoot = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + ); +}); +DecoratableRadioGroupRoot.displayName = RadioGroupPrimitive.Root.displayName; +const RadioGroupRoot = makeDecoratable("RadioGroupRoot", DecoratableRadioGroupRoot); + +/** + * Radio Group Renderer + */ +interface RadioGroupProps + extends Omit { + items: RadioItemParams[]; + onValueChange: (value: string) => void; +} + +interface RadioGroupVm { + items: RadioItemFormatted[]; +} + +interface RadioGroupRendererProps extends Omit { + items: RadioItemFormatted[]; + changeValue: (value: string) => void; +} + +const DecoratableRadioGroupRenderer = React.forwardRef< + React.ElementRef, + RadioGroupRendererProps +>(({ items, changeValue, ...props }, ref) => { + return ( + changeValue(value)}> + {items.map(item => ( + + ))} + + ); +}); +DecoratableRadioGroupRenderer.displayName = "DecoratableRadioGroupRenderer"; +const RadioGroupRenderer = makeDecoratable("RadioGroupRenderer", DecoratableRadioGroupRenderer); + +/** + * Radio Group + */ +const DecoratableRadioGroup = React.forwardRef< + React.ElementRef, + RadioGroupProps +>((props, ref) => { + const { vm, changeValue } = useRadioGroup(props); + return ; +}); +DecoratableRadioGroup.displayName = RadioGroupPrimitive.Root.displayName; +const RadioGroup = makeDecoratable("RadioGroup", DecoratableRadioGroup); + +/** + * @deprecated + * This component is retained in @webiny/admin-ui and used in @webiny/ui solely as a compatibility layer. + * It is marked as deprecated because nesting `Radio` components inside `RadioGroup` is no longer the recommended approach. + * Instead, we now pass an array of `RadioItemDto` directly to `RadioGroup` for better maintainability. + */ +const DeprecatedRadioGroup = makeDecoratable("DeprecatedRadioGroup", RadioGroupRoot); + +export { + RadioGroup, + RadioGroupRenderer, + DeprecatedRadioGroup, + type RadioGroupProps, + type RadioGroupVm +}; diff --git a/packages/admin-ui/src/Radio/RadioGroupPresenter.test.ts b/packages/admin-ui/src/Radio/RadioGroupPresenter.test.ts new file mode 100644 index 0000000000..c44c2d68c3 --- /dev/null +++ b/packages/admin-ui/src/Radio/RadioGroupPresenter.test.ts @@ -0,0 +1,58 @@ +import { RadioGroupPresenter } from "./RadioGroupPresenter"; + +describe("RadioGroupPresenter", () => { + const onValueChange = jest.fn(); + + it("should return the compatible `vm` based on props", () => { + // `items` + { + const presenter = new RadioGroupPresenter(); + presenter.init({ + onValueChange, + items: [ + { + value: "value-1", + label: "Value 1" + }, + { + value: "value-2", + label: "Value 2" + } + ] + }); + expect(presenter.vm.items).toEqual([ + { + id: expect.any(String), + value: "value-1", + label: "Value 1", + disabled: false + }, + { + id: expect.any(String), + value: "value-2", + label: "Value 2", + disabled: false + } + ]); + } + }); + + it("should call `onValueChange` callback when `changeValue` is called", () => { + const presenter = new RadioGroupPresenter(); + presenter.init({ + onValueChange, + items: [ + { + value: "value-1", + label: "Value 1" + }, + { + value: "value-2", + label: "Value 2" + } + ] + }); + presenter.changeValue("value-2"); + expect(onValueChange).toHaveBeenCalledWith("value-2"); + }); +}); diff --git a/packages/admin-ui/src/Radio/RadioGroupPresenter.ts b/packages/admin-ui/src/Radio/RadioGroupPresenter.ts new file mode 100644 index 0000000000..58720ba304 --- /dev/null +++ b/packages/admin-ui/src/Radio/RadioGroupPresenter.ts @@ -0,0 +1,48 @@ +import { makeAutoObservable } from "mobx"; +import { RadioGroupVm } from "./RadioGroup"; +import { RadioItem } from "./RadioItem"; +import { RadioItemFormatter } from "./RadioItemFormatter"; +import { RadioItemParams } from "./RadioItemParams"; + +interface RadioGroupPresenterParams { + items: RadioItemParams[]; + onValueChange: (value: TValue) => void; +} + +interface IRadioGroupPresenter { + vm: RadioGroupVm; + init: (props: RadioGroupPresenterParams) => void; + changeValue: (value: TValue) => void; +} + +class RadioGroupPresenter implements IRadioGroupPresenter { + private params?: RadioGroupPresenterParams = undefined; + + constructor() { + makeAutoObservable(this); + } + + public init = (params: RadioGroupPresenterParams) => { + this.params = params; + }; + + get vm() { + return { + items: this.getRadioItems().map(item => RadioItemFormatter.format(item)) + }; + } + + public changeValue = (value: TValue) => { + this.params?.onValueChange(value); + }; + + private getRadioItems() { + if (!this.params?.items) { + return []; + } + + return this.params.items.map(item => RadioItem.create(item)); + } +} + +export { RadioGroupPresenter, type RadioGroupPresenterParams, type IRadioGroupPresenter }; diff --git a/packages/admin-ui/src/Radio/RadioItem.ts b/packages/admin-ui/src/Radio/RadioItem.ts new file mode 100644 index 0000000000..966370eaa0 --- /dev/null +++ b/packages/admin-ui/src/Radio/RadioItem.ts @@ -0,0 +1,41 @@ +import { RadioItemParams } from "./RadioItemParams"; +import { generateId } from "~/utils"; + +export class RadioItem { + private readonly _id: string; + private readonly _label: any; + private readonly _value: string; + private readonly _disabled: boolean; + + protected constructor(data: { id: string; label: any; value: string; disabled: boolean }) { + this._id = data.id; + this._label = data.label; + this._value = data.value; + this._disabled = data.disabled; + } + + static create(data: RadioItemParams) { + return new RadioItem({ + id: generateId(data.id), + label: data.label, + value: data.value, + disabled: data.disabled ?? false + }); + } + + get id() { + return this._id; + } + + get label() { + return this._label; + } + + get value() { + return this._value; + } + + get disabled() { + return this._disabled; + } +} diff --git a/packages/admin-ui/src/Radio/RadioItemFormatted.ts b/packages/admin-ui/src/Radio/RadioItemFormatted.ts new file mode 100644 index 0000000000..c33db823be --- /dev/null +++ b/packages/admin-ui/src/Radio/RadioItemFormatted.ts @@ -0,0 +1,8 @@ +import React from "react"; + +export interface RadioItemFormatted { + id: string; + label: string | React.ReactNode; + value: string; + disabled: boolean; +} diff --git a/packages/admin-ui/src/Radio/RadioItemFormatter.ts b/packages/admin-ui/src/Radio/RadioItemFormatter.ts new file mode 100644 index 0000000000..f7f27d1329 --- /dev/null +++ b/packages/admin-ui/src/Radio/RadioItemFormatter.ts @@ -0,0 +1,13 @@ +import { RadioItemFormatted } from "./RadioItemFormatted"; +import { RadioItem } from "./RadioItem"; + +export class RadioItemFormatter { + static format(item: RadioItem): RadioItemFormatted { + return { + id: item.id, + label: item.label, + value: item.value, + disabled: item.disabled + }; + } +} diff --git a/packages/admin-ui/src/Radio/RadioItemParams.ts b/packages/admin-ui/src/Radio/RadioItemParams.ts new file mode 100644 index 0000000000..8c2e6b6c66 --- /dev/null +++ b/packages/admin-ui/src/Radio/RadioItemParams.ts @@ -0,0 +1,8 @@ +import React from "react"; + +export interface RadioItemParams { + id?: string; + label: string | React.ReactNode; + value: string; + disabled?: boolean; +} diff --git a/packages/admin-ui/src/Radio/index.ts b/packages/admin-ui/src/Radio/index.ts new file mode 100644 index 0000000000..608fafaefd --- /dev/null +++ b/packages/admin-ui/src/Radio/index.ts @@ -0,0 +1,3 @@ +export * from "./Radio"; +export * from "./RadioGroup"; +export * from "./RadioGroupPresenter"; diff --git a/packages/admin-ui/src/Radio/useRadioGroup.ts b/packages/admin-ui/src/Radio/useRadioGroup.ts new file mode 100644 index 0000000000..20243544d5 --- /dev/null +++ b/packages/admin-ui/src/Radio/useRadioGroup.ts @@ -0,0 +1,30 @@ +import { useEffect, useMemo, useState } from "react"; +import { autorun } from "mobx"; +import { RadioGroupProps } from "./RadioGroup"; +import { RadioGroupPresenter, RadioGroupPresenterParams } from "./RadioGroupPresenter"; + +export const useRadioGroup = (props: RadioGroupProps) => { + const params: RadioGroupPresenterParams = useMemo( + () => ({ + items: props.items, + onValueChange: props.onValueChange + }), + [props.items, props.onValueChange] + ); + + const presenter = useMemo(() => { + const presenter = new RadioGroupPresenter(); + presenter.init(params); + return presenter; + }, []); + + const [vm, setVm] = useState(presenter.vm); + + useEffect(() => { + return autorun(() => { + setVm(presenter.vm); + }); + }, [presenter]); + + return { vm, changeValue: presenter.changeValue }; +}; diff --git a/packages/admin-ui/src/index.ts b/packages/admin-ui/src/index.ts index 68bb1925ab..3c1dc27593 100644 --- a/packages/admin-ui/src/index.ts +++ b/packages/admin-ui/src/index.ts @@ -9,6 +9,7 @@ export * from "./Input"; export * from "./Label"; export * from "./Progress"; export * from "./Providers"; +export * from "./Radio"; export * from "./RangeSlider"; export * from "./Select"; export * from "./Separator"; diff --git a/yarn.lock b/yarn.lock index 38d053c1dd..4752e885fb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9471,6 +9471,34 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-radio-group@npm:^1.2.1": + version: 1.2.1 + resolution: "@radix-ui/react-radio-group@npm:1.2.1" + dependencies: + "@radix-ui/primitive": 1.1.0 + "@radix-ui/react-compose-refs": 1.1.0 + "@radix-ui/react-context": 1.1.1 + "@radix-ui/react-direction": 1.1.0 + "@radix-ui/react-presence": 1.1.1 + "@radix-ui/react-primitive": 2.0.0 + "@radix-ui/react-roving-focus": 1.1.0 + "@radix-ui/react-use-controllable-state": 1.1.0 + "@radix-ui/react-use-previous": 1.1.0 + "@radix-ui/react-use-size": 1.1.0 + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: c0bc847150b37a397d790c81e5a15caac2979943ccb14439e1bdfb65d50cb6e0bc8e7b2971254d89b65159e3c501767e61635b73e4bbd6855e3e33fc23505a89 + languageName: node + linkType: hard + "@radix-ui/react-roving-focus@npm:1.1.0": version: 1.1.0 resolution: "@radix-ui/react-roving-focus@npm:1.1.0" @@ -14691,6 +14719,7 @@ __metadata: "@radix-ui/react-avatar": ^1.1.0 "@radix-ui/react-checkbox": ^1.1.2 "@radix-ui/react-label": ^2.1.0 + "@radix-ui/react-radio-group": ^1.2.1 "@radix-ui/react-select": ^2.1.2 "@radix-ui/react-separator": ^1.1.0 "@radix-ui/react-slider": ^1.2.0