From c31987f3342b488504da7b69d41715856bfd9010 Mon Sep 17 00:00:00 2001 From: Leonardo Giacone Date: Thu, 21 Nov 2024 17:10:02 +0100 Subject: [PATCH] feat(admin-ui): Select component (#4365) --- packages/admin-ui/package.json | 1 + .../admin-ui/src/Select/Select.stories.tsx | 328 +++++++++++ packages/admin-ui/src/Select/Select.tsx | 518 ++++++++++++++++++ packages/admin-ui/src/Select/SelectOption.ts | 63 +++ .../admin-ui/src/Select/SelectOptionDto.ts | 7 + .../src/Select/SelectOptionFormatted.ts | 7 + .../admin-ui/src/Select/SelectOptionMapper.ts | 14 + .../src/Select/SelectPresenter.test.ts | 145 +++++ .../admin-ui/src/Select/SelectPresenter.ts | 73 +++ packages/admin-ui/src/Select/index.ts | 2 + packages/admin-ui/src/Select/useSelect.ts | 41 ++ packages/admin-ui/src/index.ts | 1 + yarn.lock | 95 +++- 13 files changed, 1294 insertions(+), 1 deletion(-) create mode 100644 packages/admin-ui/src/Select/Select.stories.tsx create mode 100644 packages/admin-ui/src/Select/Select.tsx create mode 100644 packages/admin-ui/src/Select/SelectOption.ts create mode 100644 packages/admin-ui/src/Select/SelectOptionDto.ts create mode 100644 packages/admin-ui/src/Select/SelectOptionFormatted.ts create mode 100644 packages/admin-ui/src/Select/SelectOptionMapper.ts create mode 100644 packages/admin-ui/src/Select/SelectPresenter.test.ts create mode 100644 packages/admin-ui/src/Select/SelectPresenter.ts create mode 100644 packages/admin-ui/src/Select/index.ts create mode 100644 packages/admin-ui/src/Select/useSelect.ts diff --git a/packages/admin-ui/package.json b/packages/admin-ui/package.json index 5160db909ba..979a8a1d528 100644 --- a/packages/admin-ui/package.json +++ b/packages/admin-ui/package.json @@ -13,6 +13,7 @@ "@radix-ui/react-accessible-icon": "^1.1.0", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-slider": "^1.2.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.0", diff --git a/packages/admin-ui/src/Select/Select.stories.tsx b/packages/admin-ui/src/Select/Select.stories.tsx new file mode 100644 index 00000000000..8d7c97055ed --- /dev/null +++ b/packages/admin-ui/src/Select/Select.stories.tsx @@ -0,0 +1,328 @@ +import React, { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react"; +import { ReactComponent as SearchIcon } from "@material-design-icons/svg/outlined/search.svg"; +import { ReactComponent as ChevronRight } from "@material-design-icons/svg/outlined/chevron_right.svg"; +import { Select } from "./Select"; +import { Button } from "~/Button"; + +const meta: Meta = { + title: "Components/Select", + component: Select, + tags: ["autodocs"], + argTypes: { + onValueChange: { action: "onValueChange" }, + onOpenChange: { action: "onOpenChange" }, + variant: { control: "select", options: ["primary", "secondary", "ghost"] }, + size: { control: "select", options: ["md", "lg", "xl"] }, + disabled: { control: "boolean" }, + invalid: { control: "boolean" } + }, + parameters: { + layout: "fullscreen" + }, + decorators: [ + Story => ( +
+ +
+ ) + ], + render: args => { + const [value, setValue] = useState(args.value); + return setValue(value)} /> + +
+
+
+ Current selected value:
{value}
+
+ + ); + } +}; diff --git a/packages/admin-ui/src/Select/Select.tsx b/packages/admin-ui/src/Select/Select.tsx new file mode 100644 index 00000000000..6abf31fd90f --- /dev/null +++ b/packages/admin-ui/src/Select/Select.tsx @@ -0,0 +1,518 @@ +import * as React from "react"; +import { ReactComponent as ChevronUp } from "@material-design-icons/svg/outlined/keyboard_arrow_up.svg"; +import { ReactComponent as ChevronDown } from "@material-design-icons/svg/outlined/keyboard_arrow_down.svg"; +import { ReactComponent as Check } from "@material-design-icons/svg/outlined/check.svg"; +import { ReactComponent as Close } from "@material-design-icons/svg/outlined/close.svg"; +import { makeDecoratable } from "@webiny/react-composition"; +import * as SelectPrimitive from "@radix-ui/react-select"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "~/utils"; +import { useSelect } from "./useSelect"; +import { SelectOptionDto } from "./SelectOptionDto"; +import { SelectOptionFormatted } from "./SelectOptionFormatted"; + +const SelectRoot = SelectPrimitive.Root; + +const SelectGroup = SelectPrimitive.Group; + +const SelectValue = SelectPrimitive.Value; + +/** + * Icon + */ +type IconWrapperProps = { + icon: React.ReactElement; +}; + +const Icon = ({ icon }: IconWrapperProps) => { + return ( + + {React.cloneElement(icon)} + + ); +}; + +/** + * Trigger + */ +const triggerVariants = cva( + [ + "w-full flex items-center justify-between gap-sm border-sm text-md relative", + "focus:outline-none", + "disabled:cursor-not-allowed" + ], + { + variants: { + variant: { + primary: [ + "bg-neutral-base border-neutral-muted text-neutral-strong placeholder:text-neutral-dimmed fill-neutral-xstrong", + "hover:border-neutral-strong", + "focus:border-neutral-black", + "disabled:bg-neutral-disabled disabled:border-neutral-dimmed disabled:text-neutral-disabled disabled:placeholder:text-neutral-disabled disabled:fill-neutral-disabled" + ], + secondary: [ + "bg-neutral-light border-neutral-subtle text-neutral-strong placeholder:text-neutral-muted fill-neutral-xstrong", + "hover:bg-neutral-dimmed", + "focus:border-neutral-black focus:bg-neutral-base", + "disabled:bg-neutral-disabled disabled:border-neutral-dimmed disabled:text-neutral-disabled disabled:placeholder:text-neutral-disabled disabled:fill-neutral-disabled" + ], + ghost: [ + "bg-neutral-base border-transparent text-neutral-strong placeholder:text-neutral-dimmed", + "hover:bg-neutral-light", + "focus:bg-neutral-light", + "disabled:bg-neutral-disabled disabled:border-neutral-dimmed disabled:text-neutral-disabled disabled:placeholder:text-neutral-disabled disabled:fill-neutral-disabled" + ] + }, + size: { + md: [ + "rounded-sm", + "py-[calc(theme(padding.xs-plus)-theme(borderWidth.sm))] px-[calc(theme(padding.sm-extra)-theme(borderWidth.sm))]" + ], + lg: [ + "rounded-sm", + "py-[calc(theme(padding.sm-plus)-theme(borderWidth.sm))] px-[calc(theme(padding.sm-extra)-theme(borderWidth.sm))]" + ], + xl: [ + "rounded-md leading-6", + "py-[calc(theme(padding.md)-theme(borderWidth.sm))] px-[calc(theme(padding.md)-theme(borderWidth.sm))]" + ] + }, + invalid: { + true: [ + "border-destructive-default", + "hover:border-destructive-default", + "focus:border-destructive-default", + "disabled:border-destructive-default" + ] + } + }, + compoundVariants: [ + // Add specific classNames in case of invalid variant. + { + variant: "secondary", + invalid: true, + class: [ + "bg-neutral-base border-destructive-default", + "hover:bg-neutral-dimmed hover:border-destructive-default", + "focus:bg-neutral-base focus:border-destructive-default", + "disabled:bg-neutral-disabled disabled:border-destructive-default" + ] + }, + { + variant: "ghost", + invalid: true, + class: [ + "border-none bg-destructive-subtle", + "hover:bg-destructive-subtle", + "focus:bg-destructive-subtle", + "disabled:bg-destructive-subtle" + ] + } + ], + defaultVariants: { + variant: "primary", + size: "md" + } + } +); + +interface TriggerProps + extends React.ComponentPropsWithoutRef, + VariantProps { + startIcon?: React.ReactElement; + endIcon?: React.ReactElement; + resetIcon?: React.ReactElement; +} + +const DecoratableTrigger = React.forwardRef< + React.ElementRef, + TriggerProps +>( + ( + { + className, + children, + size, + variant, + startIcon, + endIcon = , + resetIcon, + disabled, + invalid, + ...props + }, + ref + ) => ( + + {startIcon && } + {children} + {resetIcon && } + + + ) +); +DecoratableTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const Trigger = makeDecoratable("Trigger", DecoratableTrigger); + +/** + * SelectScrollUpButton + */ +const DecoratableSelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +DecoratableSelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +const SelectScrollUpButton = makeDecoratable( + "SelectScrollUpButton", + DecoratableSelectScrollUpButton +); + +/** + * SelectScrollDownButton + */ +const DecoratableSelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +DecoratableSelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName; + +const SelectScrollDownButton = makeDecoratable( + "SelectScrollDownButton", + DecoratableSelectScrollDownButton +); + +/** + * SelectContent + */ +const selectContentVariants = cva([ + "relative z-50 max-h-96 min-w-56 shadow-lg py-sm overflow-hidden rounded-sm border-sm border-neutral-muted bg-neutral-base text-neutral-strong", + "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1" +]); + +interface SelectContentProps + extends React.ComponentPropsWithoutRef, + VariantProps {} + +const DecoratableSelectContent = React.forwardRef< + React.ElementRef, + SelectContentProps +>(({ className, children, ...props }, ref) => ( + + + + + {children} + + + + +)); +DecoratableSelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectContent = makeDecoratable("SelectContent", DecoratableSelectContent); + +/** + * SelectLabel + */ +const DecoratableSelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DecoratableSelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectLabel = makeDecoratable("SelectLabel", DecoratableSelectLabel); + +/** + * SelectItem + */ +const DecoratableSelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + {children} + + + + +)); +DecoratableSelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectItem = makeDecoratable("SelectItem", DecoratableSelectItem); + +/** + * SelectSeparator + */ +const DecoratableSelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DecoratableSelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +const SelectSeparator = makeDecoratable("SelectSeparator", DecoratableSelectSeparator); + +/** + * Trigger + */ +type SelectTriggerVm = { + placeholder: string; + hasValue: boolean; +}; + +type SelectTriggerProps = SelectPrimitive.SelectValueProps & + SelectTriggerVm & { + size: VariantProps["size"]; + variant: VariantProps["variant"]; + invalid: VariantProps["invalid"]; + startIcon?: React.ReactElement; + endIcon?: React.ReactElement; + onValueReset: () => void; + }; + +const DecoratableSelectTrigger = ({ + hasValue, + size, + variant, + startIcon, + endIcon, + invalid, + onValueReset, + ...props +}: SelectTriggerProps) => { + const resetIcon = React.useMemo(() => { + if (!hasValue) { + return undefined; + } + + return ( + { + event.stopPropagation(); + onValueReset(); + }} + > + + + ); + }, [hasValue, onValueReset]); + + return ( + +
+ +
+
+ ); +}; + +const SelectTrigger = makeDecoratable("SelectTrigger", DecoratableSelectTrigger); + +/** + * SelectOptions + */ +type SelectOptionsVm = { + options: SelectOptionFormatted[]; +}; + +type SelectOptionsProps = SelectOptionsVm; + +const DecoratableSelectOptions = (props: SelectOptionsProps) => { + const renderOptions = React.useCallback((items: SelectOptionFormatted[]) => { + return items.map((item, index) => { + const elements = []; + + if (item.options.length > 0) { + // Render as a group if there are nested options + elements.push( + + {item.label} + {renderOptions(item.options)} + + ); + } + + if (item.value) { + // Render as a select item if there are no nested options + elements.push( + + {item.label} + + ); + } + + // Conditionally render the separator if hasSeparator is true + if (item.separator) { + elements.push(); + } + + return elements; + }); + }, []); + + return {renderOptions(props.options)}; +}; + +const SelectOptions = makeDecoratable("SelectOptions", DecoratableSelectOptions); + +/** + * SelectRenderer + */ +type SelectRootProps = SelectPrimitive.SelectProps; + +type SelectRendererProps = { + selectRootProps: Omit; + selectTriggerProps: Omit; + selectOptionsProps: SelectOptionsProps; + onValueChange: (value: string) => void; + onValueReset: () => void; +}; + +const DecoratableSelectRenderer = ({ + selectRootProps, + selectTriggerProps, + selectOptionsProps, + onValueChange, + onValueReset +}: SelectRendererProps) => { + return ( + + + + + ); +}; + +const SelectRenderer = makeDecoratable("SelectRenderer", DecoratableSelectRenderer); + +/** + * Select + */ +type SelectOption = SelectOptionDto | string; + +type SelectProps = SelectPrimitive.SelectProps & { + endIcon?: React.ReactElement; + invalid?: VariantProps["invalid"]; + onValueChange: (value: string) => void; + onValueReset?: () => void; + options?: SelectOption[]; + placeholder?: string; + size?: VariantProps["size"]; + startIcon?: React.ReactElement; + variant?: VariantProps["variant"]; +}; + +const DecoratableSelect = (props: SelectProps) => { + const { vm, changeValue, resetValue } = useSelect(props); + const { size, variant, startIcon, endIcon, invalid, ...selectRootProps } = props; + + return ( + + ); +}; + +const Select = makeDecoratable("Select", DecoratableSelect); + +export { + Select, + SelectRenderer, + type SelectOption, + type SelectRootProps, + type SelectTriggerProps, + type SelectOptionsProps, + type SelectProps, + type SelectTriggerVm, + type SelectOptionsVm +}; diff --git a/packages/admin-ui/src/Select/SelectOption.ts b/packages/admin-ui/src/Select/SelectOption.ts new file mode 100644 index 00000000000..d18f862c15b --- /dev/null +++ b/packages/admin-ui/src/Select/SelectOption.ts @@ -0,0 +1,63 @@ +import { SelectOptionDto } from "./SelectOptionDto"; + +export class SelectOption { + private readonly _label: string; + private readonly _value: string | null; + private readonly _options: SelectOption[]; + private readonly _disabled: boolean; + private readonly _separator: boolean; + + protected constructor(data: { + label: string; + value: string | null; + options: SelectOptionDto[]; + disabled: boolean; + separator: boolean; + }) { + this._label = data.label; + this._value = data.value; + this._options = data.options.map(option => SelectOption.create(option)); + this._disabled = data.disabled; + this._separator = data.separator; + } + + static create(data: SelectOptionDto) { + return new SelectOption({ + label: data.label, + value: data.value ?? null, + options: data.options ?? [], + disabled: data.disabled ?? false, + separator: data.separator ?? false + }); + } + + static createFromString(value: string) { + return new SelectOption({ + label: value, + value: value, + options: [], + disabled: false, + separator: false + }); + } + + get label() { + return this._label; + } + + get value() { + return this._value; + } + + get options() { + return this._options; + } + + get disabled() { + return this._disabled; + } + + get separator() { + return this._separator; + } +} diff --git a/packages/admin-ui/src/Select/SelectOptionDto.ts b/packages/admin-ui/src/Select/SelectOptionDto.ts new file mode 100644 index 00000000000..b1ae4c83aed --- /dev/null +++ b/packages/admin-ui/src/Select/SelectOptionDto.ts @@ -0,0 +1,7 @@ +export interface SelectOptionDto { + label: string; + value?: string; + options?: SelectOptionDto[]; + disabled?: boolean; + separator?: boolean; +} diff --git a/packages/admin-ui/src/Select/SelectOptionFormatted.ts b/packages/admin-ui/src/Select/SelectOptionFormatted.ts new file mode 100644 index 00000000000..d4b4d062039 --- /dev/null +++ b/packages/admin-ui/src/Select/SelectOptionFormatted.ts @@ -0,0 +1,7 @@ +export interface SelectOptionFormatted { + label: string; + value: string | null; + options: SelectOptionFormatted[]; + disabled: boolean; + separator: boolean; +} diff --git a/packages/admin-ui/src/Select/SelectOptionMapper.ts b/packages/admin-ui/src/Select/SelectOptionMapper.ts new file mode 100644 index 00000000000..45bbfbf278d --- /dev/null +++ b/packages/admin-ui/src/Select/SelectOptionMapper.ts @@ -0,0 +1,14 @@ +import { SelectOption } from "./SelectOption"; +import { SelectOptionFormatted } from "./SelectOptionFormatted"; + +export class SelectOptionMapper { + static toFormatted(option: SelectOption): SelectOptionFormatted { + return { + label: option.label, + value: option.value, + disabled: option.disabled, + separator: option.separator, + options: option.options.map(option => SelectOptionMapper.toFormatted(option)) + }; + } +} diff --git a/packages/admin-ui/src/Select/SelectPresenter.test.ts b/packages/admin-ui/src/Select/SelectPresenter.test.ts new file mode 100644 index 00000000000..77ced753905 --- /dev/null +++ b/packages/admin-ui/src/Select/SelectPresenter.test.ts @@ -0,0 +1,145 @@ +import { SelectPresenter } from "./SelectPresenter"; + +describe("SelectPresenter", () => { + const onValueChange = jest.fn(); + + it("should return the compatible `vm.selectTrigger` based on props", () => { + const onValueChange = jest.fn(); + + // `placeholder` + { + const presenter = new SelectPresenter(); + presenter.init({ onValueChange, placeholder: "Custom placeholder" }); + expect(presenter.vm.selectTrigger.placeholder).toEqual("Custom placeholder"); + } + + { + // default: no props + const presenter = new SelectPresenter(); + presenter.init({ onValueChange }); + expect(presenter.vm.selectTrigger.placeholder).toEqual("Select an option"); + expect(presenter.vm.selectTrigger.hasValue).toEqual(false); + } + }); + + it("should return the compatible `vm.selectOptions` based on props", () => { + // with `options` as string + { + const presenter = new SelectPresenter(); + presenter.init({ onValueChange, options: ["Option 1", "Option 2"] }); + expect(presenter.vm.selectOptions.options).toEqual([ + { + value: "Option 1", + label: "Option 1", + options: [], + disabled: false, + separator: false + }, + { + value: "Option 2", + label: "Option 2", + options: [], + disabled: false, + separator: false + } + ]); + } + + // with `options` as formatted options + { + const presenter = new SelectPresenter(); + presenter.init({ + onValueChange, + options: [ + { + value: "option-1", + label: "Option 1" + }, + { + value: "option-2", + label: "Option 2", + options: [ + { + value: "option-3", + label: "Option 3", + options: [{ value: "option-4", label: "Option 4" }] + } + ] + }, + { + value: "option-5", + label: "Option 5", + disabled: true + }, + { + value: "option-6", + label: "Option 6", + separator: true + } + ] + }); + expect(presenter.vm.selectOptions.options).toEqual([ + { + value: "option-1", + label: "Option 1", + options: [], + disabled: false, + separator: false + }, + { + value: "option-2", + label: "Option 2", + options: [ + { + value: "option-3", + label: "Option 3", + options: [ + { + value: "option-4", + label: "Option 4", + options: [], + disabled: false, + separator: false + } + ], + disabled: false, + separator: false + } + ], + disabled: false, + separator: false + }, + { + value: "option-5", + label: "Option 5", + options: [], + disabled: true, + separator: false + }, + { + value: "option-6", + label: "Option 6", + options: [], + disabled: false, + separator: true + } + ]); + } + }); + + it("should call `onValueChange` callback when `changeValue` is called", () => { + const presenter = new SelectPresenter(); + presenter.init({ onValueChange, value: "value" }); + presenter.changeValue("value-2"); + expect(onValueChange).toHaveBeenCalledWith("value-2"); + }); + + it("should call `onValueChange` and `onValueReset` callbacks when `resetValue` is called", () => { + const onValueReset = jest.fn(); + const presenter = new SelectPresenter(); + presenter.init({ onValueChange, onValueReset, value: "value" }); + presenter.resetValue(); + expect(onValueChange).toHaveBeenCalledWith(""); + expect(onValueReset).toHaveBeenCalled(); + }); +}); diff --git a/packages/admin-ui/src/Select/SelectPresenter.ts b/packages/admin-ui/src/Select/SelectPresenter.ts new file mode 100644 index 00000000000..fe905841edf --- /dev/null +++ b/packages/admin-ui/src/Select/SelectPresenter.ts @@ -0,0 +1,73 @@ +import { makeAutoObservable } from "mobx"; +import { SelectOptionsVm, SelectTriggerVm, SelectOption as SelectOptionParams } from "./Select"; +import { SelectOption } from "./SelectOption"; +import { SelectOptionMapper } from "~/Select/SelectOptionMapper"; + +interface SelectPresenterParams { + options?: SelectOptionParams[]; + value?: string; + placeholder?: string; + onValueChange: (value: string) => void; + onValueReset?: () => void; +} + +interface ISelectPresenter { + vm: { + selectTrigger: SelectTriggerVm; + selectOptions: SelectOptionsVm; + }; + init: (params: TParams) => void; + changeValue: (value: string) => void; + resetValue: () => void; +} + +class SelectPresenter implements ISelectPresenter { + private params?: SelectPresenterParams; + private options?: SelectOption[]; + + constructor() { + this.params = undefined; + makeAutoObservable(this); + } + + init(params: SelectPresenterParams) { + this.params = params; + this.options = this.transformOptions(params.options); + } + + get vm() { + return { + selectTrigger: { + placeholder: this.params?.placeholder || "Select an option", + hasValue: !!this.params?.value + }, + selectOptions: { + options: this.options?.map(option => SelectOptionMapper.toFormatted(option)) ?? [] + } + }; + } + + public changeValue = (value: string) => { + this.params?.onValueChange(value); + }; + + public resetValue = () => { + this.params?.onValueChange?.(""); + this.params?.onValueReset?.(); + }; + + private transformOptions(options: SelectPresenterParams["options"]): SelectOption[] { + if (!options) { + return []; + } + + return options.map(option => { + if (typeof option === "string") { + return SelectOption.createFromString(option); + } + return SelectOption.create(option); + }); + } +} + +export { SelectPresenter, type ISelectPresenter, type SelectPresenterParams }; diff --git a/packages/admin-ui/src/Select/index.ts b/packages/admin-ui/src/Select/index.ts new file mode 100644 index 00000000000..1f0dad15c33 --- /dev/null +++ b/packages/admin-ui/src/Select/index.ts @@ -0,0 +1,2 @@ +export * from "./Select"; +export * from "./SelectPresenter"; diff --git a/packages/admin-ui/src/Select/useSelect.ts b/packages/admin-ui/src/Select/useSelect.ts new file mode 100644 index 00000000000..6c515695899 --- /dev/null +++ b/packages/admin-ui/src/Select/useSelect.ts @@ -0,0 +1,41 @@ +import { useEffect, useMemo, useState } from "react"; +import { autorun } from "mobx"; +import { SelectPresenter, SelectPresenterParams } from "./SelectPresenter"; +import { SelectProps } from "./Select"; + +export const useSelect = (props: SelectProps) => { + const params: SelectPresenterParams = useMemo( + () => ({ + options: props.options, + value: props.value, + placeholder: props.placeholder, + onValueChange: props.onValueChange, + onValueReset: props.onValueReset + }), + [props.options, props.value, props.placeholder, props.onValueChange, props.onValueReset] + ); + + const presenter = useMemo(() => { + const presenter = new SelectPresenter(); + presenter.init(params); + return presenter; + }, []); + + const [vm, setVm] = useState(presenter.vm); + + useEffect(() => { + presenter.init(params); + }, [params, presenter]); + + useEffect(() => { + return autorun(() => { + setVm(presenter.vm); + }); + }, [presenter]); + + return { + vm, + changeValue: presenter.changeValue, + resetValue: presenter.resetValue + }; +}; diff --git a/packages/admin-ui/src/index.ts b/packages/admin-ui/src/index.ts index 30810051eb0..d912bee8f17 100644 --- a/packages/admin-ui/src/index.ts +++ b/packages/admin-ui/src/index.ts @@ -9,6 +9,7 @@ export * from "./Label"; export * from "./Progress"; export * from "./Providers"; export * from "./RangeSlider"; +export * from "./Select"; export * from "./Slider"; export * from "./Switch"; export * from "./Tabs"; diff --git a/yarn.lock b/yarn.lock index 68c4cb20fe0..82fb6b0344b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9143,6 +9143,19 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-focus-guards@npm:1.1.1": + version: 1.1.1 + resolution: "@radix-ui/react-focus-guards@npm:1.1.1" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: ac8dd31f48fa0500bafd9368f2f06c5a06918dccefa89fa5dc77ca218dc931a094a81ca57f6b181138029822f7acdd5280dceccf5ba4d9263c754fb8f7961879 + languageName: node + linkType: hard + "@radix-ui/react-focus-scope@npm:1.0.3": version: 1.0.3 resolution: "@radix-ui/react-focus-scope@npm:1.0.3" @@ -9165,6 +9178,27 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-focus-scope@npm:1.1.0": + version: 1.1.0 + resolution: "@radix-ui/react-focus-scope@npm:1.1.0" + dependencies: + "@radix-ui/react-compose-refs": 1.1.0 + "@radix-ui/react-primitive": 2.0.0 + "@radix-ui/react-use-callback-ref": 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: bea6c993752780c46c69f0c21a0fd96f11b9ed7edac23deb0953fbd8524d90938bf4c8060ccac7cad14caba3eb493f2642be7f8933910f4b6fa184666b7fcb40 + languageName: node + linkType: hard + "@radix-ui/react-id@npm:1.0.1": version: 1.0.1 resolution: "@radix-ui/react-id@npm:1.0.1" @@ -9478,6 +9512,45 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-select@npm:^2.1.2": + version: 2.1.2 + resolution: "@radix-ui/react-select@npm:2.1.2" + dependencies: + "@radix-ui/number": 1.1.0 + "@radix-ui/primitive": 1.1.0 + "@radix-ui/react-collection": 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-dismissable-layer": 1.1.1 + "@radix-ui/react-focus-guards": 1.1.1 + "@radix-ui/react-focus-scope": 1.1.0 + "@radix-ui/react-id": 1.1.0 + "@radix-ui/react-popper": 1.2.0 + "@radix-ui/react-portal": 1.1.2 + "@radix-ui/react-primitive": 2.0.0 + "@radix-ui/react-slot": 1.1.0 + "@radix-ui/react-use-callback-ref": 1.1.0 + "@radix-ui/react-use-controllable-state": 1.1.0 + "@radix-ui/react-use-layout-effect": 1.1.0 + "@radix-ui/react-use-previous": 1.1.0 + "@radix-ui/react-visually-hidden": 1.1.0 + aria-hidden: ^1.1.1 + react-remove-scroll: 2.6.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: cd662a5f0b1cc77dd81df51997ddc1dd47cc0025551e4ffa0c2675c056b3609257096d4f4e27189ddac98771a0191d68323c97d61fa0991d6fae78e708650959 + languageName: node + linkType: hard + "@radix-ui/react-separator@npm:1.1.0": version: 1.1.0 resolution: "@radix-ui/react-separator@npm:1.1.0" @@ -14591,6 +14664,7 @@ __metadata: "@radix-ui/react-accessible-icon": ^1.1.0 "@radix-ui/react-avatar": ^1.1.0 "@radix-ui/react-label": ^2.1.0 + "@radix-ui/react-select": ^2.1.2 "@radix-ui/react-slider": ^1.2.0 "@radix-ui/react-slot": ^1.1.0 "@radix-ui/react-switch": ^1.1.0 @@ -37620,7 +37694,7 @@ __metadata: languageName: node linkType: hard -"react-remove-scroll-bar@npm:^2.3.3": +"react-remove-scroll-bar@npm:^2.3.3, react-remove-scroll-bar@npm:^2.3.6": version: 2.3.6 resolution: "react-remove-scroll-bar@npm:2.3.6" dependencies: @@ -37655,6 +37729,25 @@ __metadata: languageName: node linkType: hard +"react-remove-scroll@npm:2.6.0": + version: 2.6.0 + resolution: "react-remove-scroll@npm:2.6.0" + dependencies: + react-remove-scroll-bar: ^2.3.6 + react-style-singleton: ^2.2.1 + tslib: ^2.1.0 + use-callback-ref: ^1.3.0 + use-sidecar: ^1.1.2 + peerDependencies: + "@types/react": ^16.8.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: e7ad2383ce20d63cf28f3ed14e63f684e139301fc4a5c1573da330d4465b733e6084c33b2bfcaee448c9b1df0e37993a15d6cba8a1dd80fe631f803e48e9f798 + languageName: node + linkType: hard + "react-resizable-panels@npm:^2.0.19": version: 2.0.19 resolution: "react-resizable-panels@npm:2.0.19"