From 6e111a686561eef202d0156287b809847ef536a7 Mon Sep 17 00:00:00 2001 From: jeremy-babylonlabs Date: Thu, 5 Dec 2024 14:38:07 +0800 Subject: [PATCH 1/4] add input and select components --- .changeset/blue-pumpkins-complain.md | 5 ++ src/components/Dialog/Dialog.stories.tsx | 2 +- .../Dialog/MobileDialog.stories.tsx | 2 +- .../{Input => Inputs}/Checkbox.stories.tsx | 0 src/components/{Input => Inputs}/Checkbox.tsx | 0 src/components/Inputs/Input.css | 15 ++++ src/components/Inputs/Input.stories.tsx | 70 +++++++++++++++++++ src/components/Inputs/Input.tsx | 37 ++++++++++ .../{Input => Inputs}/Radio.stories.tsx | 0 src/components/{Input => Inputs}/Radio.tsx | 0 src/components/Inputs/Select.css | 31 ++++++++ src/components/Inputs/Select.stories.tsx | 54 ++++++++++++++ src/components/Inputs/Select.tsx | 65 +++++++++++++++++ .../{Input => Inputs}/components/Toggle.css | 0 .../{Input => Inputs}/components/Toggle.tsx | 0 src/components/{Input => Inputs}/index.tsx | 2 + src/index.tsx | 2 +- tailwind.config.js | 1 + 18 files changed, 283 insertions(+), 3 deletions(-) create mode 100644 .changeset/blue-pumpkins-complain.md rename src/components/{Input => Inputs}/Checkbox.stories.tsx (100%) rename src/components/{Input => Inputs}/Checkbox.tsx (100%) create mode 100644 src/components/Inputs/Input.css create mode 100644 src/components/Inputs/Input.stories.tsx create mode 100644 src/components/Inputs/Input.tsx rename src/components/{Input => Inputs}/Radio.stories.tsx (100%) rename src/components/{Input => Inputs}/Radio.tsx (100%) create mode 100644 src/components/Inputs/Select.css create mode 100644 src/components/Inputs/Select.stories.tsx create mode 100644 src/components/Inputs/Select.tsx rename src/components/{Input => Inputs}/components/Toggle.css (100%) rename src/components/{Input => Inputs}/components/Toggle.tsx (100%) rename src/components/{Input => Inputs}/index.tsx (50%) diff --git a/.changeset/blue-pumpkins-complain.md b/.changeset/blue-pumpkins-complain.md new file mode 100644 index 0000000..3863d2f --- /dev/null +++ b/.changeset/blue-pumpkins-complain.md @@ -0,0 +1,5 @@ +--- +"@babylonlabs-io/bbn-core-ui": minor +--- + +add input and select components diff --git a/src/components/Dialog/Dialog.stories.tsx b/src/components/Dialog/Dialog.stories.tsx index c0b384c..7e1838e 100644 --- a/src/components/Dialog/Dialog.stories.tsx +++ b/src/components/Dialog/Dialog.stories.tsx @@ -5,7 +5,7 @@ import { Dialog, DialogFooter, DialogBody, DialogHeader } from "./index"; import { ScrollLocker } from "@/context/Dialog.context"; import { Button } from "@/components/Button"; -import { Checkbox } from "@/components/Input"; +import { Checkbox } from "@/components/Inputs"; import { Text } from "@/components/Text"; import { Heading } from "@/index"; diff --git a/src/components/Dialog/MobileDialog.stories.tsx b/src/components/Dialog/MobileDialog.stories.tsx index 0c20f44..fea8e6e 100644 --- a/src/components/Dialog/MobileDialog.stories.tsx +++ b/src/components/Dialog/MobileDialog.stories.tsx @@ -5,7 +5,7 @@ import { MobileDialog, DialogFooter, DialogBody, DialogHeader } from "./index"; import { ScrollLocker } from "@/context/Dialog.context"; import { Button } from "@/components/Button"; -import { Checkbox } from "@/components/Input"; +import { Checkbox } from "@/components/Inputs"; import { Text } from "@/components/Text"; const meta: Meta = { diff --git a/src/components/Input/Checkbox.stories.tsx b/src/components/Inputs/Checkbox.stories.tsx similarity index 100% rename from src/components/Input/Checkbox.stories.tsx rename to src/components/Inputs/Checkbox.stories.tsx diff --git a/src/components/Input/Checkbox.tsx b/src/components/Inputs/Checkbox.tsx similarity index 100% rename from src/components/Input/Checkbox.tsx rename to src/components/Inputs/Checkbox.tsx diff --git a/src/components/Inputs/Input.css b/src/components/Inputs/Input.css new file mode 100644 index 0000000..e8fc4c3 --- /dev/null +++ b/src/components/Inputs/Input.css @@ -0,0 +1,15 @@ +.bbn-input { + @apply relative flex items-center rounded border border-primary-light/20 bg-secondary-contrast px-4 py-2 text-primary-light transition-colors; + + &:focus-within { + @apply border-primary-light; + } + + &-field { + @apply w-full bg-transparent text-sm outline-none placeholder:text-primary-light/50; + } + + &-suffix { + @apply ml-2 flex items-center text-primary-light/50; + } +} \ No newline at end of file diff --git a/src/components/Inputs/Input.stories.tsx b/src/components/Inputs/Input.stories.tsx new file mode 100644 index 0000000..e672e32 --- /dev/null +++ b/src/components/Inputs/Input.stories.tsx @@ -0,0 +1,70 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { RiSearchLine } from "react-icons/ri"; +import { useState } from "react"; + +import { Input } from "./Input"; + +const meta: Meta = { + component: Input, + tags: ["autodocs"], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + placeholder: "Default input", + }, +}; + +export const WithSuffix: Story = { + args: { + placeholder: "Search by Name or Public Key", + suffix: , + }, +}; + +export const WithClickableSuffix: Story = { + args: { + placeholder: "Click the search icon", + suffix: , + onSuffixClick: () => alert("Search clicked!"), + }, +}; + +export const Loading: Story = { + args: { + placeholder: "Loading state", + suffix: , + isLoading: true, + }, +}; + +export const LoadingWithInteraction: Story = { + render: () => { + const [isLoading, setIsLoading] = useState(false); + + const handleSearch = () => { + setIsLoading(true); + setTimeout(() => setIsLoading(false), 2000); + }; + + return ( + } + isLoading={isLoading} + onSuffixClick={handleSearch} + /> + ); + }, +}; + +export const Disabled: Story = { + args: { + placeholder: "Disabled input", + disabled: true, + }, +}; diff --git a/src/components/Inputs/Input.tsx b/src/components/Inputs/Input.tsx new file mode 100644 index 0000000..3960863 --- /dev/null +++ b/src/components/Inputs/Input.tsx @@ -0,0 +1,37 @@ +import { forwardRef, type DetailedHTMLProps, type InputHTMLAttributes, type ReactNode } from "react"; +import { twJoin } from "tailwind-merge"; +import { Loader } from "../Loader"; +import "./Input.css"; + +export interface InputProps + extends Omit, HTMLInputElement>, "prefix" | "suffix"> { + className?: string; + wrapperClassName?: string; + suffix?: ReactNode; + isLoading?: boolean; + onSuffixClick?: () => void; +} + +export const Input = forwardRef( + ({ className, wrapperClassName, suffix, isLoading, onSuffixClick, disabled, ...props }, ref) => { + return ( +
+ + {suffix && ( +
+ {isLoading ? : suffix} +
+ )} +
+ ); + }, +); + +Input.displayName = "Input"; diff --git a/src/components/Input/Radio.stories.tsx b/src/components/Inputs/Radio.stories.tsx similarity index 100% rename from src/components/Input/Radio.stories.tsx rename to src/components/Inputs/Radio.stories.tsx diff --git a/src/components/Input/Radio.tsx b/src/components/Inputs/Radio.tsx similarity index 100% rename from src/components/Input/Radio.tsx rename to src/components/Inputs/Radio.tsx diff --git a/src/components/Inputs/Select.css b/src/components/Inputs/Select.css new file mode 100644 index 0000000..22f2ea3 --- /dev/null +++ b/src/components/Inputs/Select.css @@ -0,0 +1,31 @@ +.bbn-select { + @apply relative; + + &-trigger { + @apply flex w-full cursor-pointer items-center justify-between rounded border border-primary-light/20 bg-secondary-contrast px-4 py-2 text-sm text-primary-light transition-colors; + + &:focus-within { + @apply border-primary-light; + } + } + + &-icon { + @apply ml-2 text-primary-light/50 transition-transform; + + &-open { + @apply rotate-180; + } + } + + &-menu { + @apply z-50 mt-1 max-h-60 overflow-auto rounded border border-primary-light/20 bg-secondary-contrast py-1 shadow-lg; + } + + &-option { + @apply cursor-pointer px-4 py-2 text-sm text-primary-light transition-colors hover:bg-primary-light/10; + + &-selected { + @apply bg-primary-light/10; + } + } +} \ No newline at end of file diff --git a/src/components/Inputs/Select.stories.tsx b/src/components/Inputs/Select.stories.tsx new file mode 100644 index 0000000..7560406 --- /dev/null +++ b/src/components/Inputs/Select.stories.tsx @@ -0,0 +1,54 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useState } from "react"; + +import { Select } from "./Select"; + +const meta: Meta = { + component: Select, + tags: ["autodocs"], +}; + +export default meta; + +type Story = StoryObj; + +const options = [ + { value: "active", label: "Active" }, + { value: "inactive", label: "Inactive" }, + { value: "pending", label: "Pending" }, +]; + +export const Default: Story = { + args: { + options, + placeholder: "Select status", + }, +}; + +export const Controlled: Story = { + args: { + options, + placeholder: "Select status", + }, + render: (args) => { + const [value, setValue] = useState("active"); + + return ( + } - isLoading={isLoading} - onSuffixClick={handleSearch} + decorators={{ + suffix: ( + + ), + }} + disabled={isLoading} /> ); }, }; - -export const Disabled: Story = { - args: { - placeholder: "Disabled input", - disabled: true, - }, -}; diff --git a/src/components/Form/Input.tsx b/src/components/Form/Input.tsx new file mode 100644 index 0000000..a7f2306 --- /dev/null +++ b/src/components/Form/Input.tsx @@ -0,0 +1,35 @@ +import { forwardRef, type DetailedHTMLProps, type InputHTMLAttributes, type ReactNode } from "react"; +import { twJoin } from "tailwind-merge"; +import "./Input.css"; + +export interface InputProps + extends Omit, HTMLInputElement>, "prefix" | "suffix"> { + className?: string; + wrapperClassName?: string; + decorators?: { + prefix?: ReactNode; + suffix?: ReactNode; + }; + disabled?: boolean; + state?: "default" | "error" | "warning"; + stateText?: string; +} + +export const Input = forwardRef( + ({ className, wrapperClassName, decorators, disabled = false, state = "default", stateText, ...props }, ref) => { + return ( +
+
+ {decorators?.prefix &&
{decorators.prefix}
} + + {decorators?.suffix &&
{decorators.suffix}
} +
+ {stateText && ( + {stateText} + )} +
+ ); + }, +); + +Input.displayName = "Input"; diff --git a/src/components/Inputs/Radio.stories.tsx b/src/components/Form/Radio.stories.tsx similarity index 100% rename from src/components/Inputs/Radio.stories.tsx rename to src/components/Form/Radio.stories.tsx diff --git a/src/components/Inputs/Radio.tsx b/src/components/Form/Radio.tsx similarity index 100% rename from src/components/Inputs/Radio.tsx rename to src/components/Form/Radio.tsx diff --git a/src/components/Form/Select.css b/src/components/Form/Select.css new file mode 100644 index 0000000..7cf1b5f --- /dev/null +++ b/src/components/Form/Select.css @@ -0,0 +1,40 @@ +.bbn-select { + @apply relative; + width: var(--select-width, auto); + + &-trigger { + @apply flex w-full cursor-pointer items-center justify-between rounded border border-primary-light bg-secondary-contrast px-4 py-2 text-sm text-primary-light transition-all outline-none; + + &:focus-visible { + @apply border-primary-light ring-1 ring-primary-light; + } + } + + &-icon { + @apply ml-2 text-primary-light transition-transform; + + &-open { + @apply rotate-180; + } + } + + &-menu { + @apply z-50 max-h-60 overflow-auto rounded border border-primary-light/20 bg-secondary-contrast py-1 shadow-lg; + } + + &-option { + @apply cursor-pointer px-4 py-3 text-sm text-primary-light transition-colors hover:bg-primary-light/10; + + &-selected { + @apply border-primary-light bg-primary-light/10; + } + + &-highlighted { + @apply bg-primary-light/20; + } + } + + &-disabled { + @apply cursor-not-allowed opacity-50 pointer-events-none; + } +} \ No newline at end of file diff --git a/src/components/Form/Select.stories.tsx b/src/components/Form/Select.stories.tsx new file mode 100644 index 0000000..d5359bd --- /dev/null +++ b/src/components/Form/Select.stories.tsx @@ -0,0 +1,90 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { Select } from "./Select"; + +const meta: Meta = { + component: Select, + tags: ["autodocs"], +}; + +export default meta; + +type Story = StoryObj; + +const options = [ + { value: "active", label: "Active" }, + { value: "inactive", label: "Inactive" }, + { value: "pending", label: "Pending" }, +]; + +export const Default: Story = { + args: { + options, + placeholder: "Select status", + }, +}; + +export const Controlled: Story = { + args: { + options, + placeholder: "Select status", + }, + render: (args) => { + return + + ), +}; + +export const LeftAligned: Story = { + args: { + options, + selectWidth: "200px", + menuWidth: "300px", + }, + render: (args) => ( +
+ +
+ ), +}; + +export const Disabled: Story = { + args: { + options, + placeholder: "Select status", + disabled: true, + }, +}; + +export const CustomSelectedDisplay: Story = { + args: { + options, + placeholder: "Select status", + renderSelected: (option) => `Showing ${option.value}`, + }, +}; diff --git a/src/components/Form/Select.tsx b/src/components/Form/Select.tsx new file mode 100644 index 0000000..01373e3 --- /dev/null +++ b/src/components/Form/Select.tsx @@ -0,0 +1,180 @@ +import { + forwardRef, + useCallback, + useRef, + useMemo, + type DetailedHTMLProps, + type SelectHTMLAttributes, + type ReactNode, + useState, +} from "react"; +import { twJoin } from "tailwind-merge"; +import { RiArrowDownSLine } from "react-icons/ri"; +import { Popover } from "@/components/Popover"; +import { useControlledState } from "@/hooks/useControlledState"; +import { usePopper } from "react-popper"; +import "./Select.css"; +import { usePopperUpdate } from "@/hooks/usePopperUpdate"; + +export interface SelectOption { + value: string; + label: string; +} +export interface SelectProps + extends Omit< + DetailedHTMLProps, HTMLDivElement>, + "value" | "onChange" | "onSelect" + > { + options: SelectOption[]; + open?: boolean; + defaultOpen?: boolean; + value?: string | number; + defaultValue?: string | number; + onOpen?: () => void; + onSelect?: (value: string | number) => void; + className?: string; + placeholder?: string; + selectWidth?: string; + menuWidth?: string; + disabled?: boolean; + renderSelected?: (option: SelectOption) => ReactNode; +} + +export const Select = forwardRef( + ( + { + className, + options, + value, + defaultValue, + onOpen, + onSelect, + placeholder = "Select option", + open, + defaultOpen, + selectWidth, + menuWidth, + disabled, + renderSelected, + ...props + }, + ref, + ) => { + // refs + const triggerRef = useRef(null); + + // state + const [isOpen, setIsOpen] = useControlledState({ + value: open, + defaultValue: defaultOpen, + onStateChange: onOpen, + }); + + const [selectedValue, setSelectedValue] = useControlledState({ + value, + defaultValue, + onStateChange: onSelect, + }); + + const selectedOption = useMemo( + () => options.find((option) => option.value === selectedValue), + [options, selectedValue], + ); + + // popper + const [popperElement, setPopperElement] = useState(null); + + const { styles, attributes, update } = usePopper(triggerRef.current, popperElement, { + placement: "bottom-start", + modifiers: [ + { + name: "offset", + options: { + offset: [0, 4], // set offset to 4px on y axis + }, + }, + ], + }); + + // hook to update popper on resize + usePopperUpdate({ + isOpen: Boolean(isOpen && !disabled), + triggerRef, + popperElement, + update, + }); + + // handlers + const handleSelect = useCallback( + (option: SelectOption) => { + setSelectedValue(option.value); + setIsOpen(false); + }, + [setSelectedValue, setIsOpen], + ); + + const handleClose = useCallback(() => { + setIsOpen(false); + }, [setIsOpen]); + + const handleOpen = useCallback(() => { + if (!disabled) { + setIsOpen(true); + onOpen?.(); + } + }, [disabled, setIsOpen, onOpen]); + + const handleClick = useCallback(() => { + if (!isOpen) { + handleOpen(); + } else { + setIsOpen(false); + } + }, [isOpen, handleOpen, setIsOpen]); + + return ( +
+
+ + {selectedOption ? (renderSelected ? renderSelected(selectedOption) : selectedOption.label) : placeholder} + + +
+ + + {options.map((option) => ( +
handleSelect(option)} + > + {option.label} +
+ ))} +
+
+ ); + }, +); + +Select.displayName = "Select"; diff --git a/src/components/Inputs/components/Toggle.css b/src/components/Form/components/Toggle.css similarity index 100% rename from src/components/Inputs/components/Toggle.css rename to src/components/Form/components/Toggle.css diff --git a/src/components/Inputs/components/Toggle.tsx b/src/components/Form/components/Toggle.tsx similarity index 100% rename from src/components/Inputs/components/Toggle.tsx rename to src/components/Form/components/Toggle.tsx diff --git a/src/components/Inputs/index.tsx b/src/components/Form/index.tsx similarity index 100% rename from src/components/Inputs/index.tsx rename to src/components/Form/index.tsx diff --git a/src/components/Inputs/Input.css b/src/components/Inputs/Input.css deleted file mode 100644 index e8fc4c3..0000000 --- a/src/components/Inputs/Input.css +++ /dev/null @@ -1,15 +0,0 @@ -.bbn-input { - @apply relative flex items-center rounded border border-primary-light/20 bg-secondary-contrast px-4 py-2 text-primary-light transition-colors; - - &:focus-within { - @apply border-primary-light; - } - - &-field { - @apply w-full bg-transparent text-sm outline-none placeholder:text-primary-light/50; - } - - &-suffix { - @apply ml-2 flex items-center text-primary-light/50; - } -} \ No newline at end of file diff --git a/src/components/Inputs/Input.tsx b/src/components/Inputs/Input.tsx deleted file mode 100644 index 3960863..0000000 --- a/src/components/Inputs/Input.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { forwardRef, type DetailedHTMLProps, type InputHTMLAttributes, type ReactNode } from "react"; -import { twJoin } from "tailwind-merge"; -import { Loader } from "../Loader"; -import "./Input.css"; - -export interface InputProps - extends Omit, HTMLInputElement>, "prefix" | "suffix"> { - className?: string; - wrapperClassName?: string; - suffix?: ReactNode; - isLoading?: boolean; - onSuffixClick?: () => void; -} - -export const Input = forwardRef( - ({ className, wrapperClassName, suffix, isLoading, onSuffixClick, disabled, ...props }, ref) => { - return ( -
- - {suffix && ( -
- {isLoading ? : suffix} -
- )} -
- ); - }, -); - -Input.displayName = "Input"; diff --git a/src/components/Inputs/Select.css b/src/components/Inputs/Select.css deleted file mode 100644 index 22f2ea3..0000000 --- a/src/components/Inputs/Select.css +++ /dev/null @@ -1,31 +0,0 @@ -.bbn-select { - @apply relative; - - &-trigger { - @apply flex w-full cursor-pointer items-center justify-between rounded border border-primary-light/20 bg-secondary-contrast px-4 py-2 text-sm text-primary-light transition-colors; - - &:focus-within { - @apply border-primary-light; - } - } - - &-icon { - @apply ml-2 text-primary-light/50 transition-transform; - - &-open { - @apply rotate-180; - } - } - - &-menu { - @apply z-50 mt-1 max-h-60 overflow-auto rounded border border-primary-light/20 bg-secondary-contrast py-1 shadow-lg; - } - - &-option { - @apply cursor-pointer px-4 py-2 text-sm text-primary-light transition-colors hover:bg-primary-light/10; - - &-selected { - @apply bg-primary-light/10; - } - } -} \ No newline at end of file diff --git a/src/components/Inputs/Select.stories.tsx b/src/components/Inputs/Select.stories.tsx deleted file mode 100644 index 7560406..0000000 --- a/src/components/Inputs/Select.stories.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { useState } from "react"; - -import { Select } from "./Select"; - -const meta: Meta = { - component: Select, - tags: ["autodocs"], -}; - -export default meta; - -type Story = StoryObj; - -const options = [ - { value: "active", label: "Active" }, - { value: "inactive", label: "Inactive" }, - { value: "pending", label: "Pending" }, -]; - -export const Default: Story = { - args: { - options, - placeholder: "Select status", - }, -}; - -export const Controlled: Story = { - args: { - options, - placeholder: "Select status", - }, - render: (args) => { - const [value, setValue] = useState("active"); - - return ( - ; - }, }; export const CenterAligned: Story = { args: { options, - selectWidth: "200px", + placeholder: "Select status", }, render: (args) => (
-
), }; export const LeftAligned: Story = { args: { + placeholder: "Select status", options, - selectWidth: "200px", - menuWidth: "300px", }, render: (args) => (
-
), }; export const RightAligned: Story = { args: { + placeholder: "Select status", options, - selectWidth: "200px", - menuWidth: "300px", }, render: (args) => (
Title
-
), }; @@ -85,6 +82,6 @@ export const CustomSelectedDisplay: Story = { args: { options, placeholder: "Select status", - renderSelected: (option) => `Showing ${option.value}`, + renderSelectedOption: (option) => `Showing ${option.value}`, }, }; diff --git a/src/components/Form/Select.tsx b/src/components/Form/Select.tsx index 01373e3..f79d356 100644 --- a/src/components/Form/Select.tsx +++ b/src/components/Form/Select.tsx @@ -1,73 +1,80 @@ import { + type ReactNode, + type CSSProperties, forwardRef, useCallback, useRef, useMemo, - type DetailedHTMLProps, - type SelectHTMLAttributes, - type ReactNode, - useState, + useImperativeHandle, } from "react"; import { twJoin } from "tailwind-merge"; import { RiArrowDownSLine } from "react-icons/ri"; + import { Popover } from "@/components/Popover"; import { useControlledState } from "@/hooks/useControlledState"; -import { usePopper } from "react-popper"; import "./Select.css"; -import { usePopperUpdate } from "@/hooks/usePopperUpdate"; +import { useResizeObserver } from "@/hooks/useResizeObserver"; + +export type Value = string | number; -export interface SelectOption { +export interface Option { value: string; label: string; } -export interface SelectProps - extends Omit< - DetailedHTMLProps, HTMLDivElement>, - "value" | "onChange" | "onSelect" - > { - options: SelectOption[]; - open?: boolean; + +export interface SelectProps { + id?: string; + name?: string; + disabled?: boolean; defaultOpen?: boolean; - value?: string | number; - defaultValue?: string | number; - onOpen?: () => void; - onSelect?: (value: string | number) => void; - className?: string; + open?: boolean; + defaultValue?: Value; + value?: Value; placeholder?: string; - selectWidth?: string; - menuWidth?: string; - disabled?: boolean; - renderSelected?: (option: SelectOption) => ReactNode; + options?: Option[]; + style?: CSSProperties; + className?: string; + optionClassName?: string; + popoverClassName?: string; + onSelect?: (value: Value) => void; + onOpen?: () => void; + onClose?: () => void; + onFocus?: () => void; + onBlur?: () => void; + renderSelectedOption?: (option: Option) => ReactNode; } +const defaultOptionRenderer = (option: Option) => option.label; + export const Select = forwardRef( ( { + disabled, className, - options, value, defaultValue, - onOpen, - onSelect, placeholder = "Select option", open, defaultOpen, - selectWidth, - menuWidth, - disabled, - renderSelected, + options = [], + optionClassName, + popoverClassName, + onOpen, + onSelect, + onClose, + renderSelectedOption = defaultOptionRenderer, ...props }, ref, ) => { - // refs - const triggerRef = useRef(null); + const anchorEl = useRef(null); + useImperativeHandle(ref, () => anchorEl.current, []); + const { width } = useResizeObserver(anchorEl.current); - // state const [isOpen, setIsOpen] = useControlledState({ value: open, defaultValue: defaultOpen, - onStateChange: onOpen, + onStateChange: (open) => void (open ? onOpen?.() : onClose?.()), }); const [selectedValue, setSelectedValue] = useControlledState({ @@ -81,32 +88,8 @@ export const Select = forwardRef( [options, selectedValue], ); - // popper - const [popperElement, setPopperElement] = useState(null); - - const { styles, attributes, update } = usePopper(triggerRef.current, popperElement, { - placement: "bottom-start", - modifiers: [ - { - name: "offset", - options: { - offset: [0, 4], // set offset to 4px on y axis - }, - }, - ], - }); - - // hook to update popper on resize - usePopperUpdate({ - isOpen: Boolean(isOpen && !disabled), - triggerRef, - popperElement, - update, - }); - - // handlers const handleSelect = useCallback( - (option: SelectOption) => { + (option: Option) => { setSelectedValue(option.value); setIsOpen(false); }, @@ -117,47 +100,33 @@ export const Select = forwardRef( setIsOpen(false); }, [setIsOpen]); - const handleOpen = useCallback(() => { - if (!disabled) { - setIsOpen(true); - onOpen?.(); - } - }, [disabled, setIsOpen, onOpen]); - const handleClick = useCallback(() => { - if (!isOpen) { - handleOpen(); - } else { - setIsOpen(false); - } - }, [isOpen, handleOpen, setIsOpen]); + if (disabled) return; + + setIsOpen(!isOpen); + }, [isOpen, disabled, setIsOpen]); return ( -
+ <>
- - {selectedOption ? (renderSelected ? renderSelected(selectedOption) : selectedOption.label) : placeholder} - + {selectedOption ? renderSelectedOption(selectedOption) : placeholder}
{options.map((option) => (
( className={twJoin( "bbn-select-option", selectedOption?.value === option.value && "bbn-select-option-selected", + optionClassName, )} onClick={() => handleSelect(option)} > @@ -172,7 +142,7 @@ export const Select = forwardRef(
))}
-
+ ); }, ); diff --git a/src/components/Popover/Popover.css b/src/components/Popover/Popover.css index 60a35e7..64d58b7 100644 --- a/src/components/Popover/Popover.css +++ b/src/components/Popover/Popover.css @@ -1,3 +1,3 @@ .bbn-popover { - @apply z-50 mt-1 rounded border border-primary-light/20 bg-secondary-contrast shadow-lg; -} \ No newline at end of file + @apply z-50; +} diff --git a/src/components/Popover/Popover.stories.tsx b/src/components/Popover/Popover.stories.tsx new file mode 100644 index 0000000..41840c2 --- /dev/null +++ b/src/components/Popover/Popover.stories.tsx @@ -0,0 +1,48 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react"; + +import { Text } from "@/components/Text"; +import { Button } from "@/components/Button"; + +import { Popover } from "./Popover"; + +const meta: Meta = { + component: Popover, + tags: ["autodocs"], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + placement: "bottom-start", + children: The content of the Popover, + }, + render: (props) => { + const [open, setOpen] = useState(props.open); + const [anchorEl, setAnchorEl] = useState(); + + return ( + <> + + { + setOpen(false); + }} + /> + + ); + }, +}; diff --git a/src/components/Popover/Popover.tsx b/src/components/Popover/Popover.tsx index 4914994..58fe40d 100644 --- a/src/components/Popover/Popover.tsx +++ b/src/components/Popover/Popover.tsx @@ -1,38 +1,45 @@ -import { ReactNode, CSSProperties, forwardRef, HTMLAttributes } from "react"; +import { type PropsWithChildren, type CSSProperties, useState } from "react"; +import { twJoin } from "tailwind-merge"; +import { usePopper } from "react-popper"; +import { type Placement } from "@popperjs/core"; + import { Portal } from "@/components/Portal"; import { useClickOutside } from "@/hooks/useClickOutside"; import "./Popover.css"; -export interface PopoverProps extends HTMLAttributes { - children?: ReactNode; +export interface PopoverProps extends PropsWithChildren { open?: boolean; - onClose?: () => void; - anchorRef: React.RefObject; className?: string; + placement?: Placement; + anchorEl?: Element | null; + offset?: [number, number]; + onClickOutside?: () => void; style?: CSSProperties; } -export const Popover = forwardRef( - ({ children, open = false, onClose, className, anchorRef, style, ...props }, ref) => { - const [setClickOutsideRef] = useClickOutside(onClose, anchorRef); +export function Popover({ + open = false, + className, + anchorEl, + placement = "bottom-start", + offset = [0, 0], + children, + style, + onClickOutside, +}: PopoverProps) { + const [tooltipRef, setTooltipRef] = useState(null); + const { styles } = usePopper(anchorEl, tooltipRef, { + placement, + modifiers: [{ name: "offset", options: { offset } }], + }); - return ( - -
{ - setClickOutsideRef(node); - if (typeof ref === "function") ref(node); - else if (ref) ref.current = node; - }} - className={className} - style={style} - {...props} - > - {children} -
-
- ); - }, -); + useClickOutside([tooltipRef, anchorEl], onClickOutside, { enabled: open }); -Popover.displayName = "Popover"; + return ( + +
+ {children} +
+
+ ); +} diff --git a/src/hooks/useClickOutside.ts b/src/hooks/useClickOutside.ts index 5d70906..02dd472 100644 --- a/src/hooks/useClickOutside.ts +++ b/src/hooks/useClickOutside.ts @@ -1,29 +1,31 @@ -import { useEffect, useRef } from "react"; +import { useEffect } from "react"; +import { useMemoizedArray } from "./useMemoizedArray"; -export const useClickOutside = (onClose?: () => void, excludeRef?: RefObject) => { - const ref = useRef(null); +type TargetElement = E | null | undefined; - useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if (!onClose) return; +interface Options { + enabled?: boolean; +} - const target = event.target as Node; - const isOutside = ref.current && !ref.current.contains(target); - const isNotExcluded = !excludeRef?.current?.contains(target); +export function useClickOutside( + targetElement: TargetElement | TargetElement[], + handler: () => void = () => null, + { enabled = true }: Options = {}, +) { + const targetElements = Array.isArray(targetElement) ? targetElement : [targetElement]; + const memoizedElements = useMemoizedArray(targetElements); - if (isOutside && isNotExcluded) { - onClose(); + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (enabled && memoizedElements.every((element) => !element?.contains(event.target as Node))) { + handler(); } } document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, [onClose, excludeRef]); - return [ - (node: T | null) => { - ref.current = node; - }, - ref, - ] as const; -}; + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [memoizedElements, enabled, handler]); +} diff --git a/src/hooks/useMemoizedArray.ts b/src/hooks/useMemoizedArray.ts new file mode 100644 index 0000000..9f9f8c6 --- /dev/null +++ b/src/hooks/useMemoizedArray.ts @@ -0,0 +1,29 @@ +import { useRef } from "react"; + +function areArraysEqual(arrA: V[], arrB: V[]) { + if (arrA === arrB) { + return true; + } + + if (arrA.length !== arrB.length) { + return false; + } + + for (let i = 0; i < arrA.length; i++) { + if (arrA[i] !== arrB[i]) { + return false; + } + } + + return true; +} + +export function useMemoizedArray(array: V[]) { + const ref = useRef(array); + + if (!areArraysEqual(ref.current, array)) { + ref.current = array; + } + + return ref.current; +} diff --git a/src/hooks/useResizeObserver.ts b/src/hooks/useResizeObserver.ts new file mode 100644 index 0000000..020fb39 --- /dev/null +++ b/src/hooks/useResizeObserver.ts @@ -0,0 +1,32 @@ +import { useState, useEffect } from "react"; + +interface Dimentions { + width: number; + height: number; +} + +export function useResizeObserver(element?: E | null) { + const [dimentions, setDimentions] = useState({ width: 0, height: 0 }); + + useEffect(() => { + if (!element) { + setDimentions({ width: 0, height: 0 }); + return; + } + + const observer = new ResizeObserver(([entry]) => { + setDimentions({ + width: entry.target.clientWidth, + height: entry.target.clientHeight, + }); + }); + + observer.observe(element); + + return () => { + observer.unobserve(element); + }; + }, [element]); + + return dimentions; +} From ef1d7267a70cc104519a1dd9e91c894240665600 Mon Sep 17 00:00:00 2001 From: David Totraev Date: Mon, 9 Dec 2024 18:07:27 +0500 Subject: [PATCH 4/4] feat: refactor input component --- src/components/Form/Input.stories.tsx | 20 +++++++------------- src/components/Form/Input.tsx | 12 +++++------- 2 files changed, 12 insertions(+), 20 deletions(-) diff --git a/src/components/Form/Input.stories.tsx b/src/components/Form/Input.stories.tsx index 076dbd7..98f26bd 100644 --- a/src/components/Form/Input.stories.tsx +++ b/src/components/Form/Input.stories.tsx @@ -39,18 +39,14 @@ export const Error: Story = { export const WithSuffix: Story = { args: { placeholder: "Search...", - decorators: { - suffix: , - }, + suffix: , }, }; export const WithPrefix: Story = { args: { placeholder: "Amount", - decorators: { - prefix: "$", - }, + prefix: "$", }, }; @@ -66,13 +62,11 @@ export const LoadingWithInteraction: Story = { return ( - {isLoading ? : } - - ), - }} + suffix={ + + } disabled={isLoading} /> ); diff --git a/src/components/Form/Input.tsx b/src/components/Form/Input.tsx index a7f2306..a1deabb 100644 --- a/src/components/Form/Input.tsx +++ b/src/components/Form/Input.tsx @@ -6,23 +6,21 @@ export interface InputProps extends Omit, HTMLInputElement>, "prefix" | "suffix"> { className?: string; wrapperClassName?: string; - decorators?: { - prefix?: ReactNode; - suffix?: ReactNode; - }; + prefix?: ReactNode; + suffix?: ReactNode; disabled?: boolean; state?: "default" | "error" | "warning"; stateText?: string; } export const Input = forwardRef( - ({ className, wrapperClassName, decorators, disabled = false, state = "default", stateText, ...props }, ref) => { + ({ className, wrapperClassName, prefix, suffix, disabled = false, state = "default", stateText, ...props }, ref) => { return (
- {decorators?.prefix &&
{decorators.prefix}
} + {prefix &&
{prefix}
} - {decorators?.suffix &&
{decorators.suffix}
} + {suffix &&
{suffix}
}
{stateText && ( {stateText}