From 477ea5ecc00368750cefefec9455e7a41834122c Mon Sep 17 00:00:00 2001 From: Thijs Daniels Date: Sat, 8 Jun 2024 01:15:29 +0200 Subject: [PATCH] feat(react-select): experimental release --- .changeset/strange-ways-cough.md | 5 + .../useDatePicker.stories.tsx | 6 +- .../useSingleDatePicker.stories.tsx | 10 +- .../useDictionary.stories.tsx | 6 +- .../react-essentials/useDelta.stories.tsx | 7 +- .../react-essentials/usePrevious.stories.tsx | 16 +- .../react-essentials/useScroll.stories.tsx | 2 +- .../react-essentials/useSize.stories.tsx | 2 +- .../react-essentials/useTimer.stories.tsx | 8 +- .../useUpdateLoop.stories.tsx | 5 +- .../stories/react-forms/useForm.stories.tsx | 2 +- .../react-media/MediaProvider.stories.tsx | 4 +- .../NotificationsProvider.stories.tsx | 2 +- .../usePagination.stories.tsx | 2 +- .../react-parallax/useParallax.stories.tsx | 2 +- .../useColorSchemePreferences.stories.tsx | 2 +- .../useContrastPreferences.stories.tsx | 2 +- .../useMotionPreferences.stories.tsx | 2 +- .../react-select/useSelect.stories.tsx | 432 ++++++++++++++++++ .../react-select/useSingleSelect.stories.tsx | 394 ++++++++++++++++ .../react-select/useSuggestions.stories.tsx | 231 ++++++++++ package-lock.json | 50 +- packages/react-select/.eslintignore | 4 + packages/react-select/LICENSE.md | 9 + packages/react-select/README.md | 1 + packages/react-select/hooks/useSelect.ts | 90 ++++ .../react-select/hooks/useSingleSelect.ts | 44 ++ packages/react-select/hooks/useSuggestions.ts | 76 +++ packages/react-select/index.ts | 3 + packages/react-select/package.json | 44 ++ packages/react-select/tsconfig.json | 5 + packages/react-select/vitest.config.ts | 22 + 32 files changed, 1441 insertions(+), 49 deletions(-) create mode 100644 .changeset/strange-ways-cough.md create mode 100644 apps/storybook/stories/react-select/useSelect.stories.tsx create mode 100644 apps/storybook/stories/react-select/useSingleSelect.stories.tsx create mode 100644 apps/storybook/stories/react-select/useSuggestions.stories.tsx create mode 100644 packages/react-select/.eslintignore create mode 100644 packages/react-select/LICENSE.md create mode 100644 packages/react-select/README.md create mode 100644 packages/react-select/hooks/useSelect.ts create mode 100644 packages/react-select/hooks/useSingleSelect.ts create mode 100644 packages/react-select/hooks/useSuggestions.ts create mode 100644 packages/react-select/index.ts create mode 100644 packages/react-select/package.json create mode 100644 packages/react-select/tsconfig.json create mode 100644 packages/react-select/vitest.config.ts diff --git a/.changeset/strange-ways-cough.md b/.changeset/strange-ways-cough.md new file mode 100644 index 00000000..f23a1bcb --- /dev/null +++ b/.changeset/strange-ways-cough.md @@ -0,0 +1,5 @@ +--- +"@codedazur/react-select": patch +--- + +Experimental release. diff --git a/apps/storybook/stories/react-date-picker/useDatePicker.stories.tsx b/apps/storybook/stories/react-date-picker/useDatePicker.stories.tsx index b7d34de4..59d9028a 100644 --- a/apps/storybook/stories/react-date-picker/useDatePicker.stories.tsx +++ b/apps/storybook/stories/react-date-picker/useDatePicker.stories.tsx @@ -24,13 +24,13 @@ import { FunctionComponent, useMemo, useRef, useState } from "react"; import { DebugOverlay } from "../../components/DebugOverlay"; import { Weekdays } from "./components/Weekdays"; -import { Days } from "./components/Days"; -import { Navigation } from "./components/Navigation"; import { UseDatePickerProps, useDatePicker, } from "@codedazur/react-date-picker"; import { Monospace } from "../../components/Monospace"; +import { Days } from "./components/Days"; +import { Navigation } from "./components/Navigation"; const DatePicker: FunctionComponent = (props) => { const { cursor, dates, month, toNextMonth, toPreviousMonth } = @@ -105,7 +105,7 @@ const defaultArgTypes = { }; const meta: Meta = { - title: "React Date Picker/useDatePicker", + title: "React/DatePicker/useDatePicker", parameters: { docs: { page: docs, diff --git a/apps/storybook/stories/react-date-picker/useSingleDatePicker.stories.tsx b/apps/storybook/stories/react-date-picker/useSingleDatePicker.stories.tsx index dd50a998..4a995ac5 100644 --- a/apps/storybook/stories/react-date-picker/useSingleDatePicker.stories.tsx +++ b/apps/storybook/stories/react-date-picker/useSingleDatePicker.stories.tsx @@ -15,14 +15,14 @@ import { useSingleDatePicker, } from "@codedazur/react-date-picker"; import { Meta } from "@storybook/react"; -import docs from "./useSingleDatePicker.docs.mdx"; -import { addDays, Day } from "date-fns"; +import { Day, addDays } from "date-fns"; import { enGB, enUS, es, nl, ru } from "date-fns/locale"; import { FunctionComponent, useMemo, useRef, useState } from "react"; -import { Weekdays } from "./components/Weekdays"; +import { Monospace } from "../../components/Monospace"; import { Days } from "./components/Days"; import { Navigation } from "./components/Navigation"; -import { Monospace } from "../../components/Monospace"; +import { Weekdays } from "./components/Weekdays"; +import docs from "./useSingleDatePicker.docs.mdx"; const localeMap: Record = { "en-US": enUS, @@ -61,7 +61,7 @@ const defaultArgTypes = { }; const meta: Meta = { - title: "React Date Picker/useSingleDatePicker", + title: "React/DatePicker/useSingleDatePicker", parameters: { docs: { page: docs, diff --git a/apps/storybook/stories/react-dictionary/useDictionary.stories.tsx b/apps/storybook/stories/react-dictionary/useDictionary.stories.tsx index 0bc91bea..3b98499b 100644 --- a/apps/storybook/stories/react-dictionary/useDictionary.stories.tsx +++ b/apps/storybook/stories/react-dictionary/useDictionary.stories.tsx @@ -1,10 +1,10 @@ +import { Button, Row } from "@codedazur/react-components"; +import { DictionaryProvider, useDictionary } from "@codedazur/react-dictionary"; import { Meta, StoryObj } from "@storybook/react"; import docs from "./useDictionary.docs.mdx"; -import { DictionaryProvider, useDictionary } from "@codedazur/react-dictionary"; -import { Button, Row } from "@codedazur/react-components"; const meta: Meta = { - title: "React-Dictionary/useDictionary", + title: "React/Dictionary/useDictionary", parameters: { docs: { page: docs, diff --git a/apps/storybook/stories/react-essentials/useDelta.stories.tsx b/apps/storybook/stories/react-essentials/useDelta.stories.tsx index 898f3df8..877c47de 100644 --- a/apps/storybook/stories/react-essentials/useDelta.stories.tsx +++ b/apps/storybook/stories/react-essentials/useDelta.stories.tsx @@ -1,13 +1,12 @@ -import { Button, Column, Row } from "@codedazur/react-components"; +import { Button, Row } from "@codedazur/react-components"; import { useDelta } from "@codedazur/react-essentials"; import { Meta, StoryObj } from "@storybook/react"; -import { Dispatch, SetStateAction, useState } from "react"; +import { useState } from "react"; import { DebugOverlay } from "../../components/DebugOverlay"; -import { Monospace } from "../../components/Monospace"; import docs from "./useDelta.docs.mdx"; const meta: Meta = { - title: "react-essentials/useDelta", + title: "React/Essentials/useDelta", parameters: { docs: { page: docs, diff --git a/apps/storybook/stories/react-essentials/usePrevious.stories.tsx b/apps/storybook/stories/react-essentials/usePrevious.stories.tsx index 44799778..5a2a5a44 100644 --- a/apps/storybook/stories/react-essentials/usePrevious.stories.tsx +++ b/apps/storybook/stories/react-essentials/usePrevious.stories.tsx @@ -1,25 +1,13 @@ -import { sequence } from "@codedazur/essentials"; -import { - Button, - Column, - Grid, - GridItem, - Row, - background, - border, - shape, - transparent, -} from "@codedazur/react-components"; +import { Button, Column, Row } from "@codedazur/react-components"; import { usePrevious } from "@codedazur/react-essentials"; import { Meta, StoryObj } from "@storybook/react"; import { useState } from "react"; import { DebugOverlay } from "../../components/DebugOverlay"; import { Monospace } from "../../components/Monospace"; import docs from "./usePrevious.docs.mdx"; -import styled, { css } from "styled-components"; const meta: Meta = { - title: "react-essentials/usePrevious", + title: "React/Essentials/usePrevious", parameters: { docs: { page: docs, diff --git a/apps/storybook/stories/react-essentials/useScroll.stories.tsx b/apps/storybook/stories/react-essentials/useScroll.stories.tsx index 480da097..eab79632 100644 --- a/apps/storybook/stories/react-essentials/useScroll.stories.tsx +++ b/apps/storybook/stories/react-essentials/useScroll.stories.tsx @@ -21,7 +21,7 @@ import styled from "styled-components"; import { DebugOverlay } from "../../components/DebugOverlay"; export default { - title: "react-essentials/useScroll", + title: "React/Essentials/useScroll", parameters: { layout: "fullscreen", }, diff --git a/apps/storybook/stories/react-essentials/useSize.stories.tsx b/apps/storybook/stories/react-essentials/useSize.stories.tsx index 8ac87842..fbef71ab 100644 --- a/apps/storybook/stories/react-essentials/useSize.stories.tsx +++ b/apps/storybook/stories/react-essentials/useSize.stories.tsx @@ -11,7 +11,7 @@ import { useRef, useState } from "react"; import { DebugOverlay } from "../../components/DebugOverlay"; export default { - title: "react-essentials/useSize", + title: "React/Essentials/useSize", } as Meta; export const Default = () => { diff --git a/apps/storybook/stories/react-essentials/useTimer.stories.tsx b/apps/storybook/stories/react-essentials/useTimer.stories.tsx index 24e76725..25a30dd1 100644 --- a/apps/storybook/stories/react-essentials/useTimer.stories.tsx +++ b/apps/storybook/stories/react-essentials/useTimer.stories.tsx @@ -1,3 +1,4 @@ +import { Bar } from "@apps/storybook/components/Bar"; import { AddIcon, background, @@ -14,16 +15,15 @@ import { StopIcon, Text, } from "@codedazur/react-components"; -import { useTimer, TimerStatus } from "@codedazur/react-essentials"; +import { TimerStatus, useTimer } from "@codedazur/react-essentials"; import { action } from "@storybook/addon-actions"; import { Meta } from "@storybook/react"; -import { Bar } from "@apps/storybook/components/Bar"; import styled from "styled-components"; -import docs from "./useTimer.docs.mdx"; import { DebugOverlay } from "../../components/DebugOverlay"; +import docs from "./useTimer.docs.mdx"; const meta: Meta = { - title: "react-essentials/useTimer", + title: "React/Essentials/useTimer", parameters: { docs: { page: docs, diff --git a/apps/storybook/stories/react-essentials/useUpdateLoop.stories.tsx b/apps/storybook/stories/react-essentials/useUpdateLoop.stories.tsx index 53aff5be..7277999b 100644 --- a/apps/storybook/stories/react-essentials/useUpdateLoop.stories.tsx +++ b/apps/storybook/stories/react-essentials/useUpdateLoop.stories.tsx @@ -4,7 +4,6 @@ import { PauseIcon, PlayArrowIcon, Row, - ShapedBox, StopIcon, background, shape, @@ -13,9 +12,9 @@ import { import { Frame, useUpdateLoop } from "@codedazur/react-essentials"; import { Meta, StoryObj } from "@storybook/react"; import { useEffect, useMemo, useRef, useState } from "react"; +import styled from "styled-components"; import { DebugOverlay } from "../../components/DebugOverlay"; import docs from "./useUpdateLoop.docs.mdx"; -import styled from "styled-components"; interface UseUpdateLoopArgs { timeScale?: number; @@ -23,7 +22,7 @@ interface UseUpdateLoopArgs { } const meta: Meta = { - title: "react-essentials/useUpdateLoop", + title: "React/Essentials/useUpdateLoop", parameters: { docs: { page: docs, diff --git a/apps/storybook/stories/react-forms/useForm.stories.tsx b/apps/storybook/stories/react-forms/useForm.stories.tsx index e59e2659..fd0f5b2c 100644 --- a/apps/storybook/stories/react-forms/useForm.stories.tsx +++ b/apps/storybook/stories/react-forms/useForm.stories.tsx @@ -17,7 +17,7 @@ import { DebugOverlay } from "../../components/DebugOverlay"; import docs from "./useForm.docs.mdx"; const meta: Meta = { - title: "react-forms/useForm", + title: "React/Forms/useForm", parameters: { docs: { page: docs, diff --git a/apps/storybook/stories/react-media/MediaProvider.stories.tsx b/apps/storybook/stories/react-media/MediaProvider.stories.tsx index 64082951..21edd5a5 100644 --- a/apps/storybook/stories/react-media/MediaProvider.stories.tsx +++ b/apps/storybook/stories/react-media/MediaProvider.stories.tsx @@ -41,6 +41,7 @@ import { import { Meta, StoryObj } from "@storybook/react"; import { FunctionComponent, useMemo, useRef } from "react"; import { DebugOverlay } from "../../components/DebugOverlay"; +import { Monospace } from "../../components/Monospace"; import docs from "./MediaProvider.docs.mdx"; import distantWorldsIi from "./artworks/distant-worlds-ii.jpg"; import distantWorlds from "./artworks/distant-worlds.jpg"; @@ -49,10 +50,9 @@ import alienated from "./tracks/alienated.mp3"; import meteorites from "./tracks/meteorites.mp3"; import tabulaRasa from "./tracks/tabula-rasa.mp3"; import bigBuckBunny from "./videos/big-buck-bunny.mp4"; -import { Monospace } from "../../components/Monospace"; const meta: Meta = { - title: "React-Media/MediaProvider", + title: "React/Media/MediaProvider", decorators: [WithCenter], parameters: { docs: { diff --git a/apps/storybook/stories/react-notifications/NotificationsProvider.stories.tsx b/apps/storybook/stories/react-notifications/NotificationsProvider.stories.tsx index 74710c99..5961f4cd 100644 --- a/apps/storybook/stories/react-notifications/NotificationsProvider.stories.tsx +++ b/apps/storybook/stories/react-notifications/NotificationsProvider.stories.tsx @@ -24,7 +24,7 @@ import { FunctionComponent, ReactNode } from "react"; import docs from "./NotificationsProvider.docs.mdx"; const meta: Meta = { - title: "react-notifications/NotificationsProvider", + title: "React/Notifications/NotificationsProvider", parameters: { docs: { page: docs, diff --git a/apps/storybook/stories/react-pagination/usePagination.stories.tsx b/apps/storybook/stories/react-pagination/usePagination.stories.tsx index 63158330..109f1854 100644 --- a/apps/storybook/stories/react-pagination/usePagination.stories.tsx +++ b/apps/storybook/stories/react-pagination/usePagination.stories.tsx @@ -20,7 +20,7 @@ import docs from "./usePagination.docs.mdx"; import { DebugOverlay } from "../../components/DebugOverlay"; const meta: Meta> = { - title: "React-Pagination/usePagination", + title: "React/Pagination/usePagination", parameters: { docs: { page: docs, diff --git a/apps/storybook/stories/react-parallax/useParallax.stories.tsx b/apps/storybook/stories/react-parallax/useParallax.stories.tsx index 108bfc54..a8844b11 100644 --- a/apps/storybook/stories/react-parallax/useParallax.stories.tsx +++ b/apps/storybook/stories/react-parallax/useParallax.stories.tsx @@ -25,7 +25,7 @@ import layerTwo from "./diorama/layer-two.png"; import docs from "./useParallax.docs.mdx"; const meta: Meta = { - title: "react-parallax/useParallax", + title: "React/Parallax/useParallax", parameters: { layout: "fullscreen", docs: { diff --git a/apps/storybook/stories/react-preferences/useColorSchemePreferences.stories.tsx b/apps/storybook/stories/react-preferences/useColorSchemePreferences.stories.tsx index 0e62242f..78aa1b1c 100644 --- a/apps/storybook/stories/react-preferences/useColorSchemePreferences.stories.tsx +++ b/apps/storybook/stories/react-preferences/useColorSchemePreferences.stories.tsx @@ -6,7 +6,7 @@ import docs from "./useColorSchemePreferences.docs.mdx"; import { Meta, StoryObj } from "@storybook/react"; const meta: Meta = { - title: "Preferences/useColorSchemePreferences", + title: "React/Preferences/useColorSchemePreferences", parameters: { docs: { page: docs, diff --git a/apps/storybook/stories/react-preferences/useContrastPreferences.stories.tsx b/apps/storybook/stories/react-preferences/useContrastPreferences.stories.tsx index 8059d1b1..63d6e456 100644 --- a/apps/storybook/stories/react-preferences/useContrastPreferences.stories.tsx +++ b/apps/storybook/stories/react-preferences/useContrastPreferences.stories.tsx @@ -6,7 +6,7 @@ import docs from "./useContrastPreferences.docs.mdx"; import { Meta, StoryObj } from "@storybook/react"; const meta: Meta = { - title: "Preferences/useContrastPreferences", + title: "React/Preferences/useContrastPreferences", parameters: { docs: { page: docs, diff --git a/apps/storybook/stories/react-preferences/useMotionPreferences.stories.tsx b/apps/storybook/stories/react-preferences/useMotionPreferences.stories.tsx index 86f2c866..68aea80f 100644 --- a/apps/storybook/stories/react-preferences/useMotionPreferences.stories.tsx +++ b/apps/storybook/stories/react-preferences/useMotionPreferences.stories.tsx @@ -6,7 +6,7 @@ import docs from "./useMotionPreferences.docs.mdx"; import { Meta, StoryObj } from "@storybook/react"; const meta: Meta = { - title: "Preferences/useMotionPreferences", + title: "React/Preferences/useMotionPreferences", parameters: { docs: { page: docs, diff --git a/apps/storybook/stories/react-select/useSelect.stories.tsx b/apps/storybook/stories/react-select/useSelect.stories.tsx new file mode 100644 index 00000000..52087889 --- /dev/null +++ b/apps/storybook/stories/react-select/useSelect.stories.tsx @@ -0,0 +1,432 @@ +import { + ArrowDropDownIcon, + ArrowDropUpIcon, + Button, + CancelIcon, + CheckIcon, + Column, + Divider, + EdgeInset, + Flex, + highlight, + IconButton, + Opacity, + Origin, + Padding, + Placeholder, + Popover, + PopoverProps, + Row, + ScrollView, + Separate, + ShapedBox, + SizedBox, + TextInput, + useSuggestions, + Wrap, +} from "@codedazur/react-components"; +import { useSelect, UseSelectProps } from "@codedazur/react-select"; +import { faker } from "@faker-js/faker"; +import { Meta, StoryObj } from "@storybook/react"; +import { MutableRefObject, ReactNode, useMemo, useRef, useState } from "react"; +import { styled } from "styled-components"; +import { DebugOverlay } from "../../components/DebugOverlay"; +import { Monospace } from "../../components/Monospace"; + +const defaultOptions = Array.from( + new Set(Array.from({ length: 10 }).map(() => faker.commerce.product())), +); + +/** + * @todo Clean up some duplicate code. + */ + +export default { + title: "React/Select/useSelect", + args: { + options: defaultOptions, + initialSelected: [], + }, +} as Meta; + +interface Identifiable { + [key: string]: any; + id: string; +} + +type Story = StoryObj; + +type StoryArguments = + UseSelectProps; + +export const Default: Story = { + render: function Default(args) { + const { isSelected, selected, select, toggle, deselect } = useSelect(args); + + return ( + <> + + }> + {args.options.map((color, index) => ( + + + + + + + + ))} + + + color.toString()) }} + /> + + ); + }, + args: { + options: ["olive", "orange", "crimson"], + }, +}; + +export const WithPopover: Story = { + render: function WithPopover(args) { + const ref = useRef(null); + const [open, setOpen] = useState(false); + const { selected, isSelected, toggle } = useSelect(args); + + return ( + <> + setOpen(true)} + > + + + {selected.join(", ")} + + {open ? : } + + + + + setOpen(false)} + options={args.options} + onSelect={toggle} + isSelected={isSelected} + /> + + + ); + }, +}; + +export const WithInitialSelected: Story = { + ...WithPopover, + args: { + ...WithPopover.args, + initialSelected: defaultOptions.slice(2, 4), + }, +}; + +interface CategorizedOption extends Identifiable { + category: string; +} + +export const WithOptionGroups: StoryObj< + Omit & { options: CategorizedOption[] } +> = { + render: function WithOptionGroups({ options }) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + const { toggle, selected, isSelected } = useSelect({ + options, + }); + + const groupedOptions = useMemo( + () => + options.reduce( + (result: Record, suggestion) => { + if (!result[suggestion.category]) { + result[suggestion.category] = []; + } + result[suggestion.category].push(suggestion); + return result; + }, + {}, + ), + [options], + ); + + return ( + <> + setOpen(true)} + > + + + + {selected + ? selected + .map( + (selected) => `${selected?.id} (${selected?.category})`, + ) + .join(", ") + : undefined} + + + {open ? : } + + + + + setOpen(false)} + width="20rem" + maxHeight="10rem" + anchorOrigin={Origin.bottomLeft} + transformOrigin={Origin.topLeft} + scrollLock={false} + autoFocus={false} + > + + + + }> + {Object.entries(groupedOptions).map(([category, options]) => ( + + }> + + + {category} + + + {options.map((option) => ( + toggle(option)} + > + + + {option.id} + + {isSelected(option) ? : undefined} + + + ))} + + + ))} + + + + + + + + ); + }, + args: { + options: Array.from({ length: 3 }) + .map(() => { + const category = faker.commerce.department(); + return Array.from({ length: 3 }).map(() => ({ + category, + id: faker.commerce.product(), + })); + }) + .flat(), + }, +}; + +const InteractivePlaceholder = styled(Placeholder).attrs({ tabIndex: 1 })` + &:hover, + &:active, + &:focus { + cursor: pointer; + background-color: ${({ theme }) => + highlight(theme.colors.background).toString()}; + } +`; + +export const WithSuggestions: Story = { + render: function WithSuggestions(args) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + const { suggestions, query, setQuery } = useSuggestions(args); + const { selected, isSelected, deselect, toggle } = useSelect(args); + + return ( + <> + + setOpen(true)} + onChange={(event) => setQuery(event.target.value)} + leading={} + /> + + setOpen(false)} + options={suggestions} + onSelect={toggle} + isSelected={isSelected} + /> + + + ); + }, +}; + +interface OptionsProps { + anchor: MutableRefObject; + width?: PopoverProps["width"]; + open: boolean; + onClose: () => void; + options: T[]; + onSelect: (suggestion: T) => void; + isSelected: (suggestion: T) => boolean; +} + +function Options({ + anchor, + width = "20rem", + onClose, + open, + options: options, + onSelect, + isSelected, +}: OptionsProps) { + return ( + + + + + }> + {options.map((suggestion) => { + const label = getLabel(suggestion); + + return ( + onSelect(suggestion)}> + + {label} + {isSelected(suggestion) ? : undefined} + + + ); + })} + + + + + + ); +} + +const ListButton = styled(Button)` + background-color: transparent; + color: ${({ theme }) => theme.colors.foreground.toString()}; + border: none; + justify-content: flex-start; + + &:hover, + &:active, + &:focus { + background-color: ${({ theme }) => + highlight(theme.colors.background).toString()}; + } +`; + +const Swatch = styled(ShapedBox).attrs({ + shape: "square", + height: "2.5rem", + width: "2.5rem", +})<{ color: string }>` + background-color: ${({ color }) => color}; +`; + +interface ChipsProps { + selected: T[]; + deselect: (value: T) => void; + renderOption?: (option: T) => ReactNode; +} + +const Chips = ({ + selected, + deselect, + renderOption = getLabel, +}: ChipsProps) => + selected.length > 0 ? ( + + + + {selected.map((option) => ( + + + + {renderOption(option)} + deselect(option)}> + + + + + + ))} + + + + ) : null; + +function getLabel(option: string | Identifiable): string { + return typeof option !== "string" ? option.id : option; +} diff --git a/apps/storybook/stories/react-select/useSingleSelect.stories.tsx b/apps/storybook/stories/react-select/useSingleSelect.stories.tsx new file mode 100644 index 00000000..b8161a79 --- /dev/null +++ b/apps/storybook/stories/react-select/useSingleSelect.stories.tsx @@ -0,0 +1,394 @@ +import { + ArrowDropDownIcon, + ArrowDropUpIcon, + Button, + CheckIcon, + Column, + Divider, + EdgeInset, + highlight, + Identifiable, + Opacity, + Origin, + Padding, + Placeholder, + Popover, + PopoverProps, + Row, + ScrollView, + Separate, + ShapedBox, + SizedBox, + TextInput, + useSingleSelect, + UseSingleSelectProps, + useSuggestions, +} from "@codedazur/react-components"; +import { faker } from "@faker-js/faker"; +import { Meta, StoryObj } from "@storybook/react"; +import { MutableRefObject, useEffect, useMemo, useRef, useState } from "react"; +import styled from "styled-components"; +import { DebugOverlay } from "../../components/DebugOverlay"; +import { Monospace } from "../../components/Monospace"; + +const defaultOptions = Array.from( + new Set(Array.from({ length: 10 }).map(() => faker.commerce.product())), +); + +export default { + title: "React/Select/useSingleSelect", + args: { + options: defaultOptions, + }, +} as Meta; + +type StoryArguments = UseSingleSelectProps; + +type Story = StoryObj; + +export const Default: Story = { + render: function Default({ options }) { + const { isSelected, selected, select, toggle, deselect } = useSingleSelect({ + options, + }); + + return ( + <> + + }> + {options.map((color, index) => ( + + + + + + + + ))} + + + + + ); + }, + args: { + options: ["olive", "orange", "crimson"], + }, +}; + +export const WithPopover: Story = { + render: function WithPopover(args) { + const ref = useRef(null); + const [open, setOpen] = useState(false); + const { selected, isSelected, select } = useSingleSelect(args); + + return ( + <> + setOpen(true)} + > + + + {selected} + + {open ? : } + + + + + setOpen(false)} + options={args.options} + onSelect={(value) => { + select(value); + setOpen(false); + }} + isSelected={isSelected} + /> + + + ); + }, +}; + +const InteractivePlaceholder = styled(Placeholder).attrs({ tabIndex: 1 })` + &:hover, + &:active, + &:focus { + cursor: pointer; + background-color: ${({ theme }) => + highlight(theme.colors.background).toString()}; + } +`; + +export const WithInitialSelected: Story = { + ...WithPopover, + args: { + initialSelected: defaultOptions[1], + }, +}; + +export const WithOptionGroups: StoryObj< + Omit & { options: CategorizedOption[] } +> = { + render: function WithOptiongroups({ options }) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + const { select, selected, isSelected } = useSingleSelect( + { + options, + }, + ); + + const groupedOptions = useMemo( + () => + options.reduce( + (result: Record, suggestion) => { + if (!result[suggestion.category]) { + result[suggestion.category] = []; + } + result[suggestion.category].push(suggestion); + return result; + }, + {}, + ), + [options], + ); + + return ( + <> + setOpen(true)} + > + + + + {selected + ? `${selected?.id} (${selected?.category})` + : undefined} + + + {open ? : } + + + + + setOpen(false)} + width="20rem" + maxHeight="10rem" + anchorOrigin={Origin.bottomLeft} + transformOrigin={Origin.topLeft} + scrollLock={false} + autoFocus={false} + > + + + + }> + {Object.entries(groupedOptions).map(([category, options]) => ( + + }> + + + {category} + + + {options.map((option) => ( + { + select(option); + setOpen(false); + }} + > + + + {option.id} + + {isSelected(option) ? : undefined} + + + ))} + + + ))} + + + + + + + + ); + }, + args: { + options: Array.from({ length: 3 }) + .map(() => { + const category = faker.commerce.department(); + return Array.from({ length: 3 }).map(() => ({ + category, + id: faker.commerce.product(), + })); + }) + .flat(), + }, +}; + +export const WithSuggestions: Story = { + render: function WithSuggestions(args) { + const ref = useRef(null); + const [open, setOpen] = useState(false); + + const { query, setQuery, suggestions } = useSuggestions(args); + const { selected, isSelected, select, clear } = useSingleSelect(args); + + useEffect(() => { + if (!open) { + setQuery(""); + } + }, [open, setQuery]); + + return ( + <> + + setOpen(true)} + onChange={(event) => { + setQuery(event.target.value); + clear(); + }} + /> + + setOpen(false)} + options={suggestions} + onSelect={(value) => { + select(value); + setOpen(false); + }} + isSelected={isSelected} + /> + + + ); + }, +}; + +interface CategorizedOption extends Identifiable { + category: string; +} + +interface OptionsProps { + anchor: MutableRefObject; + width?: PopoverProps["width"]; + open: boolean; + onClose: () => void; + options: T[]; + onSelect: (option: T) => void; + isSelected?: (option: T) => boolean; +} + +function Options({ + anchor, + onClose, + open, + options, + onSelect, + isSelected = () => false, +}: OptionsProps) { + return ( + + + + + }> + {options.map((option) => { + const label = getLabel(option); + + return ( + onSelect(option)}> + + {label} + {isSelected(option) ? : undefined} + + + ); + })} + + + + + + ); +} + +const ListButton = styled(Button)` + background-color: transparent; + color: ${({ theme }) => theme.colors.foreground.toString()}; + border: none; + justify-content: flex-start; + + &:hover, + &:active, + &:focus { + background-color: ${({ theme }) => + highlight(theme.colors.background).toString()}; + } +`; + +const Swatch = styled(ShapedBox).attrs({ + shape: "square", + height: "2.5rem", + width: "2.5rem", +})<{ color: string }>` + background-color: ${({ color }) => color}; +`; + +function getLabel(option: string | Identifiable): string { + return typeof option !== "string" ? option.id : option; +} diff --git a/apps/storybook/stories/react-select/useSuggestions.stories.tsx b/apps/storybook/stories/react-select/useSuggestions.stories.tsx new file mode 100644 index 00000000..4b64b781 --- /dev/null +++ b/apps/storybook/stories/react-select/useSuggestions.stories.tsx @@ -0,0 +1,231 @@ +import { + Button, + CheckIcon, + Column, + Divider, + FormField, + highlight, + Identifiable, + Origin, + Placeholder, + Popover, + PopoverProps, + Row, + ScrollView, + Separate, + SizedBox, + TextInput, + useSuggestions, + UseSuggestionsProps, +} from "@codedazur/react-components"; +import { faker } from "@faker-js/faker"; +import { Meta, StoryObj } from "@storybook/react"; +import { MutableRefObject, useCallback, useRef, useState } from "react"; +import styled from "styled-components"; +import { DebugOverlay } from "../../components/DebugOverlay"; +import { Monospace } from "../../components/Monospace"; + +const defaultOptions = Array.from( + new Set(Array.from({ length: 10 }).map(() => faker.commerce.product())), +); + +export default { + title: "React/Select/useSuggestions", + args: { + options: defaultOptions, + }, +} as Meta; + +type StoryArguments = UseSuggestionsProps; + +type Story = StoryObj; + +export const Default: Story = { + render: function Default(args) { + const { query, setQuery, suggestions } = useSuggestions(args); + + return ( + <> + + setQuery(event.target.value)} + /> + + + + ); + }, +}; + +export const WithCustomFiltering: Story = { + ...Default, + args: { + filter: (query, options) => + options.filter((option) => option.match(new RegExp(query, "i"))), + }, +}; + +export const WithPopover: Story = { + render: function WithPopover(args) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + const { suggestions, query, setQuery } = useSuggestions(args); + + return ( + <> + + setOpen(true)} + onChange={(event) => setQuery(event.target.value)} + /> + + setOpen(false)} + suggestions={suggestions} + onSelect={(value) => { + setQuery(value); + setOpen(false); + }} + /> + + + ); + }, +}; + +interface GeocodingResult { + display_name: string; +} + +export const WithAsyncFilter: Story = { + render: function WithAsyncFilter() { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + const filter = useCallback(async (query: string) => { + const uri = `https://nominatim.openstreetmap.org/search?countrycodes=nl&format=json&q=${encodeURI( + query, + )}`; + + const results = (await fetch(uri).then((response) => + response.json(), + )) as GeocodingResult[]; + + return results.slice(0, 5).map((result) => result.display_name); + }, []); + + const { suggestions, query, setQuery } = useSuggestions({ + options: [], + filter, + debounce: 300, + }); + + return ( + <> + + + setOpen(true)} + onChange={(event) => setQuery(event.target.value)} + /> + + + setOpen(false)} + suggestions={suggestions} + onSelect={(value) => { + setQuery(value); + setOpen(false); + }} + /> + + + ); + }, +}; + +interface SuggestionsProps { + anchor: MutableRefObject; + width?: PopoverProps["width"]; + open: boolean; + onClose: () => void; + suggestions: T[]; + onSelect: (option: T) => void; + isSelected?: (option: T) => boolean; +} + +function Suggestions({ + anchor, + onClose, + open, + suggestions, + onSelect, + isSelected = () => false, +}: SuggestionsProps) { + return ( + + + + + }> + {suggestions.map((suggestion) => { + const label = getLabel(suggestion); + + return ( + onSelect(suggestion)}> + + {label} + {isSelected(suggestion) ? : undefined} + + + ); + })} + + + + + + ); +} + +const ListButton = styled(Button)` + background-color: transparent; + color: ${({ theme }) => theme.colors.foreground.toString()}; + border: none; + justify-content: flex-start; + + &:hover, + &:active, + &:focus { + background-color: ${({ theme }) => + highlight(theme.colors.background).toString()}; + } +`; + +function getLabel(option: string | Identifiable): string { + return typeof option !== "string" ? option.id : option; +} diff --git a/package-lock.json b/package-lock.json index 06db4220..c23ce829 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3196,6 +3196,10 @@ "resolved": "packages/react-preferences", "link": true }, + "node_modules/@codedazur/react-select": { + "resolved": "packages/react-select", + "link": true + }, "node_modules/@codedazur/react-tracking": { "resolved": "packages/react-tracking", "link": true @@ -23934,7 +23938,7 @@ }, "packages/cdk-docker-cluster": { "name": "@codedazur/cdk-docker-cluster", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "dependencies": { "@codedazur/cdk-cache-invalidator": "*" @@ -24200,7 +24204,7 @@ }, "packages/react-dictionary": { "name": "@codedazur/react-dictionary", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "dependencies": { "@codedazur/essentials": "^1.6.1" @@ -24376,6 +24380,48 @@ "react": ">=16.8.0" } }, + "packages/react-select": { + "name": "@codedazur/react-select", + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "@codedazur/essentials": "*", + "@codedazur/react-essentials": "*" + }, + "devDependencies": { + "@codedazur/eslint-config": "*", + "@codedazur/tsconfig": "*", + "@testing-library/dom": "9.3.3", + "@testing-library/react": "alpha", + "@types/react": "^18.2.18", + "@types/react-dom": "^18.2.7", + "eslint": "^8.46.0", + "jsdom": "^22.1.0", + "react": "^18.2.0", + "typescript": "^5.1.6" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "packages/react-select/node_modules/@testing-library/react": { + "version": "14.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.0.0-alpha.3.tgz", + "integrity": "sha512-INtYnpLluUELDZ6dSUQrSkwj3NOovYoO8JTUTDhGX/r9EjzJn7GZNP+/xj3oA7VMdyYNERznBmjefSAsK4JhDg==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@testing-library/dom": "^9.0.0", + "@types/react-dom": "^18.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "packages/react-tracking": { "name": "@codedazur/react-tracking", "version": "0.5.2", diff --git a/packages/react-select/.eslintignore b/packages/react-select/.eslintignore new file mode 100644 index 00000000..7b321ed3 --- /dev/null +++ b/packages/react-select/.eslintignore @@ -0,0 +1,4 @@ +.turbo +coverage +dist +node_modules \ No newline at end of file diff --git a/packages/react-select/LICENSE.md b/packages/react-select/LICENSE.md new file mode 100644 index 00000000..811b1d9f --- /dev/null +++ b/packages/react-select/LICENSE.md @@ -0,0 +1,9 @@ +The MIT License (MIT) + +Copyright (c) 2022 Code d'Azur Interactive B.V. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/react-select/README.md b/packages/react-select/README.md new file mode 100644 index 00000000..51bd5bd6 --- /dev/null +++ b/packages/react-select/README.md @@ -0,0 +1 @@ +# @codedazur/react-select diff --git a/packages/react-select/hooks/useSelect.ts b/packages/react-select/hooks/useSelect.ts new file mode 100644 index 00000000..2ecc5fec --- /dev/null +++ b/packages/react-select/hooks/useSelect.ts @@ -0,0 +1,90 @@ +import { useCallback, useState } from "react"; +import { Identifiable } from "./useSuggestions"; + +export interface UseSelectProps { + options: T[]; + initialSelected?: T | T[]; +} + +export interface UseSelectResult { + selected: T[]; + isSelected: (item: T) => boolean; + select: (item: T) => void; + deselect: (item: T) => void; + toggle: (item: T) => void; + clear: () => void; +} + +export const useSelect = ({ + options, + initialSelected, +}: UseSelectProps): UseSelectResult => { + const [selected, setSelected] = useState(() => { + if (initialSelected) { + return Array.isArray(initialSelected) + ? initialSelected + : [initialSelected]; + } + return []; + }); + + const assertOption = useCallback( + (item: T) => { + if (!options.includes(item)) { + throw new Error(`The option "${JSON.stringify(item)}" does not exist.`); + } + }, + [options], + ); + + const isSelected = useCallback( + (item: T) => selected.includes(item), + [selected], + ); + + const select = useCallback( + (item: T) => { + assertOption(item); + + if (!isSelected(item)) { + setSelected((prev) => prev.concat(item)); + } + }, + [assertOption, isSelected], + ); + + const deselect = useCallback( + (item: T) => { + if (isSelected(item)) { + setSelected((prev) => prev.filter((prevItem) => prevItem !== item)); + } + }, + [isSelected], + ); + + const toggle = useCallback( + (item: T) => { + assertOption(item); + + if (isSelected(item)) { + deselect(item); + } else { + select(item); + } + }, + [assertOption, isSelected, deselect, select], + ); + + const clear = useCallback(() => { + setSelected([]); + }, []); + + return { + selected, + isSelected, + select, + deselect, + toggle, + clear, + }; +}; diff --git a/packages/react-select/hooks/useSingleSelect.ts b/packages/react-select/hooks/useSingleSelect.ts new file mode 100644 index 00000000..8906aa51 --- /dev/null +++ b/packages/react-select/hooks/useSingleSelect.ts @@ -0,0 +1,44 @@ +import { useCallback } from "react"; +import { useSelect, UseSelectProps, UseSelectResult } from "./useSelect"; +import { Identifiable } from "./useSuggestions"; + +export interface UseSingleSelectProps + extends UseSelectProps { + initialSelected?: T; +} + +interface SingleSelectResult extends Omit, "selected"> { + selected: T | null; +} + +export const useSingleSelect = ( + props: UseSingleSelectProps, +): SingleSelectResult => { + const { selected, isSelected, select, clear, ...result } = + useSelect(props); + + return { + ...result, + selected: selected[0] ?? null, + isSelected, + select: useCallback( + (item: T) => { + clear(); + select(item); + }, + [clear, select], + ), + toggle: useCallback( + (item: T) => { + if (!isSelected(item)) { + clear(); + select(item); + } else { + clear(); + } + }, + [clear, isSelected, select], + ), + clear, + }; +}; diff --git a/packages/react-select/hooks/useSuggestions.ts b/packages/react-select/hooks/useSuggestions.ts new file mode 100644 index 00000000..fc90f1ba --- /dev/null +++ b/packages/react-select/hooks/useSuggestions.ts @@ -0,0 +1,76 @@ +import { useEffect, useMemo, useState } from "react"; +import { debounce as debounceFn } from "@codedazur/essentials"; +import { useSynchronizedRef } from "@codedazur/react-essentials"; + +export interface UseSuggestionsProps { + options: T[]; + initialQuery?: string; + filter?: ( + query: string, + options: T[], + defaultFilterFn: DefaultFilterFunction, + ) => T[] | Promise; + debounce?: false | number; +} + +export interface Identifiable { + id: string; +} + +type DefaultFilterFunction = ( + query: string, + options: T[], + defaultValidator: (query: string) => T[], +) => T[] | Promise; + +export interface UseSuggestionsResult { + query: string; + setQuery: (value: string) => void; + suggestions: T[]; +} + +const defaultFilterFn = ( + query: string, + options: T[], +) => + options.filter((option) => { + const pattern = new RegExp(`^${query}`, "i"); + + return typeof option === "string" + ? option.match(pattern) + : option.id.match(pattern); + }); + +export const useSuggestions = ({ + options, + initialQuery = "", + filter = defaultFilterFn, + debounce = false, +}: UseSuggestionsProps): UseSuggestionsResult => { + const [debouncedFilter, cancelDebouncedFilter] = useMemo( + () => debounceFn(filter, debounce !== false ? debounce : 0), + [debounce, filter], + ); + const cancelDebouncedFilterRef = useSynchronizedRef(cancelDebouncedFilter); + const filterFn = debounce !== false ? debouncedFilter : filter; + const [query, setQuery] = useState(initialQuery); + const [suggestions, setSuggestions] = useState([]); + + useEffect(() => { + void (async () => + setSuggestions(await filterFn(query, options, defaultFilterFn)))(); + }, [query, options, filterFn]); + + useEffect( + () => () => { + cancelDebouncedFilterRef.current?.(); + }, + [cancelDebouncedFilterRef], + ); + + return { + query, + setQuery, + suggestions, + }; +}; diff --git a/packages/react-select/index.ts b/packages/react-select/index.ts new file mode 100644 index 00000000..91c351fc --- /dev/null +++ b/packages/react-select/index.ts @@ -0,0 +1,3 @@ +export * from "./hooks/useSelect"; +export * from "./hooks/useSingleSelect"; +export * from "./hooks/useSuggestions"; diff --git a/packages/react-select/package.json b/packages/react-select/package.json new file mode 100644 index 00000000..b461efab --- /dev/null +++ b/packages/react-select/package.json @@ -0,0 +1,44 @@ +{ + "name": "@codedazur/react-select", + "version": "0.0.0", + "main": ".dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "require": "./dist/index.js", + "import": "./dist/index.mjs" + } + }, + "publishConfig": { + "access": "public" + }, + "license": "MIT", + "scripts": { + "develop": "tsup index.ts --format esm,cjs --dts --watch --external react", + "build": "tsup index.ts --format esm,cjs --dts", + "audit": "npm audit --omit dev", + "lint": "TIMING=1 eslint \"**/*.ts*\"", + "types": "tsc --noEmit", + "test": "vitest run" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "dependencies": { + "@codedazur/essentials": "*", + "@codedazur/react-essentials": "*" + }, + "devDependencies": { + "@codedazur/eslint-config": "*", + "@codedazur/tsconfig": "*", + "@testing-library/dom": "9.3.3", + "@testing-library/react": "alpha", + "@types/react-dom": "^18.2.7", + "@types/react": "^18.2.18", + "eslint": "^8.46.0", + "jsdom": "^22.1.0", + "react": "^18.2.0", + "typescript": "^5.1.6" + } +} diff --git a/packages/react-select/tsconfig.json b/packages/react-select/tsconfig.json new file mode 100644 index 00000000..91ca394d --- /dev/null +++ b/packages/react-select/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@codedazur/tsconfig/react-library.json", + "include": ["."], + "exclude": ["dist", "build", "node_modules"] +} diff --git a/packages/react-select/vitest.config.ts b/packages/react-select/vitest.config.ts new file mode 100644 index 00000000..345ef1d2 --- /dev/null +++ b/packages/react-select/vitest.config.ts @@ -0,0 +1,22 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [react()], + test: { + environment: "jsdom", + setupFiles: "./test/setup.ts", + passWithNoTests: true, + coverage: { + provider: "v8", + reporter: ["text", "html", "clover", "json", "lcov"], + exclude: ["./test/setup.ts"], + thresholdAutoUpdate: true, + lines: 36.62, + functions: 30.76, + branches: 74.57, + statements: 36.62, + all: true, + }, + }, +});