diff --git a/src/components/react-hook-form/FilterForm.stories.mdx b/src/components/react-hook-form/FilterForm.stories.mdx deleted file mode 100644 index 606a3e37..00000000 --- a/src/components/react-hook-form/FilterForm.stories.mdx +++ /dev/null @@ -1,121 +0,0 @@ -import { Meta, Story, Canvas, ArgsTable } from '@storybook/addon-docs' -import { useState } from 'react' -import { action } from '@storybook/addon-actions' -import { FormVariant, Option } from '../form' -import { - Section, - SectionContainer, - SectionContentList, - SectionHeaderArea, - SectionHeaderRow, - SectionHeaderGroup, - SectionListItemButton, - SectionHeaderTitle, -} from '../section' -import { SectionContentEmpty } from '../section/Section/ConvenientSectionContentMessage' -import { SelectFormField } from './SelectFormField' -import { FilterForm } from './FilterForm' - - - -# FilterForm - -You can use the `FilterForm` to have a form whose fields are updated with the given default values without overwriting user input. -This component is a form wrapper that submits automatically by default. You can disable auto-submit by passing `false` to the `autoSubmit` prop. - -Find more samples in the [List examples](/docs/examples-list--default-story). - -export const Template = ({ ...args }) => { - const [filter, setFilter] = useState(args.defaultValues) - const content = Array.from(Array(10).keys()) - .map((item, index) => ({ - name: `User ${item + 1}`, - role: index % 8 === 0 ? 'ADMIN' : 'USER', - department: index % 3 === 0 ? 'HR' : 'SALES', - })) - .filter((item) => { - return ( - (filter.role === '' || item.role === filter.role) && - (filter.department === '' || item.department === filter.department) - ) - }) - return ( -
- - - Users - setFilter(values)} - > - - - - - - - - - - - - - - - - - - {content.length === 0 ? ( - - ) : ( - - {content.map((item) => ( - - {`${item.name} (${item.role} - ${item.department})`} - - ))} - - )} - -
- ) -} - - - - {Template.bind({})} - - - -### Props - - diff --git a/src/components/react-hook-form/FilterForm.tsx b/src/components/react-hook-form/FilterForm.tsx deleted file mode 100644 index f6400f35..00000000 --- a/src/components/react-hook-form/FilterForm.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { ForwardedRef, forwardRef, ReactNode, useEffect } from 'react' -import { - DefaultValues, - FieldValues, - SubmitHandler, - UseFormProps, - useFormState, - useForm, -} from 'react-hook-form' -import { isEqual } from 'lodash' -import { ClassNameProps } from '../types' -import { AutoSubmit } from './AutoSubmit' -import { Form } from './Form' - -export type FilterFormProps = ClassNameProps & - Omit, 'defaultValues'> & { - defaultValues?: DefaultValues - onSubmit: SubmitHandler - /** - * Whether the form should submit automatically on change. - * - * @default true - */ - autoSubmit?: boolean - /** - * The auto-submit interval in milliseconds. - */ - autoSubmitInterval?: number - /** - * Whether the form should reset when the default values change. - * - * @default true - */ - enableReinitialize?: boolean - children?: ReactNode - } - -export const FilterForm = forwardRef(function FilterForm< - TFieldValues extends FieldValues, ->( - { - className, - onSubmit, - autoSubmit = true, - autoSubmitInterval, - enableReinitialize = true, - children, - ...props - }: FilterFormProps, - ref: ForwardedRef, -) { - const form = useForm(props) - const { dirtyFields } = useFormState({ control: form.control }) - - useEffect(() => { - if (enableReinitialize && props.defaultValues) { - // Only override the values of the non-dirty fields to prevent overriding the user's input - form.reset(props.defaultValues, { - keepDirtyValues: true, - }) - // Set the form as non-dirty if the current values and the default values match - // If they do not match, it means that the user changed some fields and we want those to stay dirty so that they are submitted - const currentValues = form.getValues() - if (isEqual(currentValues, props.defaultValues)) { - form.reset(currentValues) - } - } - }, [enableReinitialize, props.defaultValues, form]) - - const handleSubmit: SubmitHandler = (data, event) => { - // Only submit the form if there are dirty fields - if (Object.keys(dirtyFields).length > 0) { - onSubmit(data, event) - } - } - - return ( -
- {autoSubmit && } - {children} - - ) -}) diff --git a/src/components/react-hook-form/SearchFormField.stories.tsx b/src/components/react-hook-form/SearchFormField.stories.tsx index fc439635..e069767f 100644 --- a/src/components/react-hook-form/SearchFormField.stories.tsx +++ b/src/components/react-hook-form/SearchFormField.stories.tsx @@ -8,13 +8,14 @@ import { Title, } from '@storybook/blocks' import { action } from '@storybook/addon-actions' +import { useForm } from 'react-hook-form' import { InternationalizationMessages, Theme, } from '../../../.storybook/components' import { Section, SectionHeaderArea } from '../section' import { SearchFormField } from './SearchFormField' -import { FilterForm } from './FilterForm' +import { Form } from './Form' const meta = { component: SearchFormField, @@ -22,18 +23,20 @@ const meta = { disabled: { type: 'boolean' }, }, decorators: [ - (Story) => ( -
- - - - - -
- ), + (Story) => { + const form = useForm({ + defaultValues: { search: '' }, + }) + return ( +
+ +
+ + +
+
+ ) + }, ], parameters: { docs: { diff --git a/src/components/react-hook-form/__test__/FilterForm.test.tsx b/src/components/react-hook-form/__test__/FilterForm.test.tsx deleted file mode 100644 index 51fb6390..00000000 --- a/src/components/react-hook-form/__test__/FilterForm.test.tsx +++ /dev/null @@ -1,199 +0,0 @@ -import { render, screen, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { useCallback } from 'react' -import { act } from 'react-dom/test-utils' -import { vi } from 'vitest' -import { ReactUIProvider, defaultTheme } from '../../../framework' -import { Option } from '../../form' -import { InputFormField, SelectFormField } from '../../react-hook-form' -import { FilterForm } from '../FilterForm' - -const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) - -type FormValues = { - name: string - color: string -} - -const emptyDefaultValues: FormValues = { - name: '', - color: '', -} - -const MyForm = ({ - autoSubmitInterval = undefined, - onSubmit, - defaultValues, -}: { - autoSubmitInterval?: number - onSubmit: (formValues: FormValues) => void - defaultValues: FormValues -}) => { - // The requestSubmit has to be mocked, since it is not implement in jsdom - const formRef = useCallback((formElement: HTMLFormElement | null) => { - if (formElement) { - formElement.requestSubmit = () => { - const event = new Event('submit', { - bubbles: true, - cancelable: true, - }) - formElement.dispatchEvent(event) - } - } - }, []) - - return ( - - { - onSubmit(data) - }} - autoSubmitInterval={autoSubmitInterval} - ref={formRef} - > - - - - - - - - - ) -} - -describe('FilterForm', () => { - test('should not submit form on mount', async () => { - const handleSubmit = vi.fn() - render( - , - ) - - await waitFor(async () => { - await sleep(250) // Wait for possible auto submit to be triggered - expect(handleSubmit).not.toHaveBeenCalled() - }) - }) - - test('should submit form only once on data change and receiving new default values', async () => { - const handleSubmit = vi.fn() - const { rerender } = render( - , - ) - - await act(async () => { - const user = userEvent.setup() - await user.type(screen.getByLabelText('name'), 'John') - await sleep(250) // Wait for auto submit to be triggered - }) - - await waitFor(() => { - expect(handleSubmit).toHaveBeenCalledTimes(1) - expect(handleSubmit).toHaveBeenCalledWith({ - name: 'John', - color: '', - }) - }) - - rerender( - , - ) - - await waitFor(async () => { - await sleep(250) // Wait for possible auto submit to be triggered - expect(handleSubmit).toHaveBeenCalledTimes(1) - }) - }) - - test('should change field values when default values change', async () => { - const handleSubmit = vi.fn() - const { rerender } = render( - , - ) - - expect(screen.getByLabelText('name')).toHaveValue('') - expect(screen.getByLabelText('color')).toHaveValue('') - - rerender( - , - ) - - expect(screen.getByLabelText('name')).toHaveValue('Jane') - expect(screen.getByLabelText('color')).toHaveValue('green') - - await waitFor(async () => { - await sleep(250) // Wait for possible auto submit to be triggered - expect(handleSubmit).not.toHaveBeenCalled() - }) - }) - - test('should only change non-dirty field values when default values change', async () => { - const handleSubmit = vi.fn() - const { rerender } = render( - , - ) - - expect(screen.getByLabelText('name')).toHaveValue('') - expect(screen.getByLabelText('color')).toHaveValue('') - - await act(async () => { - const user = userEvent.setup() - await user.type(screen.getByLabelText('name'), 'John') - }) - - rerender( - , - ) - - expect(screen.getByLabelText('name')).toHaveValue('John') - expect(screen.getByLabelText('color')).toHaveValue('green') - }) - - test('should submit after value change after rerender', async () => { - const handleSubmit = vi.fn() - const { rerender } = render( - , - ) - - expect(screen.getByLabelText('name')).toHaveValue('') - expect(screen.getByLabelText('color')).toHaveValue('') - - rerender( - , - ) - - await act(async () => { - const user = userEvent.setup() - await user.clear(screen.getByLabelText('name')) - await user.type(screen.getByLabelText('name'), 'John') - }) - - expect(screen.getByLabelText('name')).toHaveValue('John') - expect(screen.getByLabelText('color')).toHaveValue('green') - - await waitFor(async () => { - await sleep(250) // Wait for auto submit to be triggered - expect(handleSubmit).toHaveBeenCalledTimes(1) - expect(handleSubmit).toHaveBeenCalledWith({ - name: 'John', - color: 'green', - }) - }) - }) -}) diff --git a/src/components/react-hook-form/index.ts b/src/components/react-hook-form/index.ts index 69896b4e..1e3795ca 100644 --- a/src/components/react-hook-form/index.ts +++ b/src/components/react-hook-form/index.ts @@ -3,7 +3,6 @@ export * from './CheckboxFormField' export * from './DateFormField' export * from './FieldSetFormField' export * from './Form' -export * from './FilterForm' export * from './FormSubmitFeedback' export * from './InputFormField' export * from './NumberFormField' diff --git a/src/components/util/index.ts b/src/components/util/index.ts index 60501966..c5507fa0 100644 --- a/src/components/util/index.ts +++ b/src/components/util/index.ts @@ -7,3 +7,4 @@ export * from './useHandleRequest' export * from './useId' export * from './useScrollToElementOnFristRender' export * from './useBackNavigation' +export * from './useFilter' diff --git a/src/components/util/useFilter.ts b/src/components/util/useFilter.ts new file mode 100644 index 00000000..6a4bd847 --- /dev/null +++ b/src/components/util/useFilter.ts @@ -0,0 +1,58 @@ +import { useDebounce } from '@aboutbits/react-toolbox' +import { ChangeEventHandler, useEffect, useMemo, useRef, useState } from 'react' + +export type FilterOptions = { + /** Whether to debounce and the debounce interval in milliseconds. + * If `true`, the debounce interval defaults to 200 ms. + * If a positive `number`, the given debounce interval is used. + * If otherwise, the debounce interval defaults to 0 ms. + */ + debounce?: true | number +} + +export function useFilter< + TElement extends HTMLElement & { value: unknown }, + TValue = TElement['value'], +>(value: TValue, setValue: (v: TValue) => void, options?: FilterOptions) { + const debounceInterval = useMemo(() => { + if (options?.debounce === true) { + return 200 + } + if (options?.debounce !== undefined && options.debounce > 0) { + return options.debounce + } + return 0 + }, [options?.debounce]) + const elementRef = useRef(null) + + const settingNewValueRef = useRef(false) + + const [internalValue, setInternalValue] = useState(value) + const debouncedInternalValue = useDebounce(internalValue, debounceInterval) + const oldDebouncedInternalValueRef = useRef() + + useEffect(() => { + // Check that the debounced value is new, because `setValue` might not be reference stable and trigger this effect even though the debounced value did not change + if (debouncedInternalValue !== oldDebouncedInternalValueRef.current) { + oldDebouncedInternalValueRef.current = debouncedInternalValue + setValue(debouncedInternalValue) + settingNewValueRef.current = false + } + }, [debouncedInternalValue, setValue]) + + useEffect(() => { + if (elementRef.current && !settingNewValueRef.current) { + elementRef.current.value = value + } + }, [value]) + + const onChange: ChangeEventHandler = (e) => { + settingNewValueRef.current = true + setInternalValue(e.target.value as TValue) + } + + return { + ref: elementRef, + onChange, + } +} diff --git a/src/examples/List.stories.tsx b/src/examples/List.stories.tsx new file mode 100644 index 00000000..bd7b129d --- /dev/null +++ b/src/examples/List.stories.tsx @@ -0,0 +1,217 @@ +import { Title, Stories } from '@storybook/blocks' +import { action } from '@storybook/addon-actions' +import { Meta, StoryFn } from '@storybook/react' +import { useMemo, useState } from 'react' +import { IndexType } from '@aboutbits/pagination' +import IconAdd from '@aboutbits/react-material-icons/dist/IconAdd' +import { useMatchMediaQuery } from '@aboutbits/react-toolbox' +import { Markdown } from '@storybook/addon-docs' +import { + ButtonIcon, + ButtonVariant, + FormVariant, + Section, + SectionContainer, + SectionContentEmpty, + SectionContentList, + SectionFooterWithPaginationInMemory, + SectionHeader, + SectionHeaderArea, + SectionHeaderGroup, + SectionHeaderGroupSpacing, + SectionHeaderRow, + SectionHeaderRowLayout, + SectionHeaderSpacer, + SectionHeaderTitle, + SectionListItemButton, + SelectField, + Tone, + Option, +} from '../components' +import { SearchField } from '../components/form/SearchField' +import { useFilter } from '../components/util/useFilter' + +const meta = { + component: SectionContentList, + argTypes: { + className: { table: { disable: true } }, + }, + parameters: { + docs: { + page: () => ( + <> + + <Markdown> + Examples on how to build simple and complex lists. + </Markdown> + <Stories /> + </> + ), + }, + }, +} satisfies Meta<typeof SectionContentList> + +export default meta +type Story = StoryFn<typeof meta> + +function useMockedList(numberOfTotalItems: number) { + return useMemo( + () => + Array.from(Array(numberOfTotalItems).keys()).map((item, index) => ({ + name: `User ${item + 1}`, + role: index % 8 === 0 ? 'ADMIN' : 'USER', + department: index % 3 === 0 ? 'HR' : 'SALES', + })), + [numberOfTotalItems], + ) +} + +const List = ({ numberOfTotalItems = 5 }: { numberOfTotalItems?: number }) => { + const content = useMockedList(numberOfTotalItems) + return ( + <Section> + <SectionHeader title="List of users" /> + <SectionContainer> + {content.length === 0 ? ( + <SectionContentEmpty + title="No users found" + text="There are no items in this list at the moment." + /> + ) : ( + <SectionContentList> + {content.map((item) => ( + <SectionListItemButton + key={item.name} + onClick={action('onItemClick')} + > + {`${item.name} (${item.role} - ${item.department})`} + </SectionListItemButton> + ))} + </SectionContentList> + )} + </SectionContainer> + </Section> + ) +} + +export const SimpleList: Story = () => <List /> + +export const EmptySimpleList: Story = () => <List numberOfTotalItems={0} /> + +/** + * The following example shows how multiple section components and the in memory pagination are used to create an overview list with filters. + */ +export const ListWithFilter: Story = () => { + const isScreenMedium = useMatchMediaQuery('(min-width: 768px)') + const filterVariant = isScreenMedium + ? FormVariant.Soft + : FormVariant.Transparent + const numberOfTotalItems = 1000 + const numberOfItemsPerPage = 5 + const [page, setPage] = useState(1) + const [filter, setFilter] = useState({ role: '', department: '', search: '' }) + const content = useMockedList(numberOfTotalItems).filter( + (item) => + item.name.includes(filter.search) && + (filter.role === '' || item.role === filter.role) && + (filter.department === '' || item.department === filter.department), + ) + const searchFilterProps = useFilter<HTMLInputElement>( + filter.search, + (v) => { + setFilter((prevFilter) => ({ ...prevFilter, search: v })) + }, + { + debounce: true, + }, + ) + const roleFilterProps = useFilter<HTMLSelectElement>(filter.role, (v) => { + setFilter((prevFilter) => ({ ...prevFilter, role: v })) + }) + const departmentFilterProps = useFilter<HTMLSelectElement>( + filter.department, + (v) => { + setFilter((prevFilter) => ({ ...prevFilter, department: v })) + }, + ) + return ( + <Section> + <SectionHeaderArea> + <SectionHeaderRow> + <SectionHeaderTitle>List of users</SectionHeaderTitle> + <ButtonIcon + label="Add" + icon={IconAdd} + variant={ButtonVariant.Transparent} + tone={Tone.Neutral} + onClick={action('onAddNew')} + /> + </SectionHeaderRow> + <SectionHeaderRow layout={SectionHeaderRowLayout.Stretch}> + <SectionHeaderGroup + spacing={SectionHeaderGroupSpacing.Md} + className="flex-col gap-y-2 md:flex-row" + > + <SearchField + {...searchFilterProps} + className="w-full grow md:w-auto" + /> + <SectionHeaderGroup className="grid w-full grid-cols-2 md:flex md:w-auto"> + <SelectField + {...roleFilterProps} + name="role" + variant={filterVariant} + > + <Option value="">All roles</Option> + <Option value="ADMIN">Admin</Option> + <Option value="USER">User</Option> + </SelectField> + <SelectField + {...departmentFilterProps} + name="department" + variant={filterVariant} + > + <Option value="">All departments</Option> + <Option value="HR">Human Resources</Option> + <Option value="IT">Engineering</Option> + <Option value="SALES">Sales</Option> + </SelectField> + </SectionHeaderGroup> + </SectionHeaderGroup> + </SectionHeaderRow> + <SectionHeaderSpacer /> + </SectionHeaderArea> + <SectionContainer> + {content.length === 0 ? ( + <SectionContentEmpty + title="No users found" + text="There are no items in this list at the moment." + /> + ) : ( + <SectionContentList> + {content + .slice( + (page - 1) * numberOfItemsPerPage, + (page - 1) * numberOfItemsPerPage + numberOfItemsPerPage, + ) + .map((item) => ( + <SectionListItemButton + key={item.name} + onClick={action('onItemClick')} + > + {`${item.name} (${item.role} - ${item.department})`} + </SectionListItemButton> + ))} + </SectionContentList> + )} + <SectionFooterWithPaginationInMemory + page={page} + size={numberOfItemsPerPage} + total={content.length} + onChangePage={setPage} + config={{ indexType: IndexType.ONE_BASED }} + /> + </SectionContainer> + </Section> + ) +} diff --git a/src/examples/list.stories.mdx b/src/examples/list.stories.mdx deleted file mode 100644 index 40f77f92..00000000 --- a/src/examples/list.stories.mdx +++ /dev/null @@ -1,508 +0,0 @@ -import IconAdd from '@aboutbits/react-material-icons/dist/IconAdd' -import IconFilterList from '@aboutbits/react-material-icons/dist/IconFilterList' -import { useMatchMediaQuery } from '@aboutbits/react-toolbox' -import { Meta, Story, Canvas, ArgsTable } from '@storybook/addon-docs' -import { action } from '@storybook/addon-actions' -import { useState } from 'react' -import { - Dialog, - DialogHeaderWithClose, - DialogContent, - DialogFooterWithActions, -} from '../components/dialog' -import { - Button, - ButtonIcon, - ButtonVariant as ButtonVariant, - SubmitButton, -} from '../components/button' -import { - Section, - SectionContainer, - SectionHeaderArea, - SectionHeaderTitle, - SubsectionTitle, - SectionContentList, - SectionHeader, - SectionHeaderSpacer, - SectionHeaderRow, - SectionHeaderRowLayout, - SectionHeaderGroup, - SectionHeaderGroupSpacing, - SectionListItemButton, - SectionFooterWithActions, - SectionFooterWithPaginationInMemory, -} from '../components/section' -import { FormVariant, Option } from '../components/form' -import { Tone } from '../components/types' -import { SectionContentEmpty } from '../components/section/Section/ConvenientSectionContentMessage' -import { - SelectFormField, - FilterForm, - SearchFormField, -} from '../components/react-hook-form' - -<Meta title="Examples/List" /> - -# Simple List - -Setting `numberOfTotalItems` to 0 will reveal the placeholder for the empty list. - -export const TemplateDefault = ({ numberOfTotalItems }) => { - const content = Array.from(Array(numberOfTotalItems).keys()).map( - (item, index) => ({ - name: `User ${item + 1}`, - role: index % 8 === 0 ? 'ADMIN' : 'USER', - department: index % 3 === 0 ? 'HR' : 'SALES', - }) - ) - return ( - <Section> - <SectionHeader title="List of users" /> - <SectionContainer> - {content.length === 0 ? ( - <SectionContentEmpty - title="No users found" - text="There are no items in this list at the moment." - /> - ) : ( - <SectionContentList> - {content.map((item) => ( - <SectionListItemButton - key={item.name} - onClick={() => action('clicked')} - > - {`${item.name} (${item.role} - ${item.department})`} - </SectionListItemButton> - ))} - </SectionContentList> - )} - </SectionContainer> - </Section> - ) -} - -<Canvas> - <Story - name="Default" - args={{ - numberOfTotalItems: 5, - }} - > - {TemplateDefault.bind({})} - </Story> -</Canvas> - -### Props - -<ArgsTable story="Default" /> - -# List with search and filter - -The following example shows how multiple section components and the in memory pagination are used to create an overview list with filters. - -Changing the properties `numberOfTotalItems` and `numberOfItemsPerPage` will change the number of pages and/or the number the of items per page. - -export const TemplateComplexList = ({ - numberOfTotalItems, - numberOfItemsPerPage, -}) => { - const [page, setPage] = useState(1) - const [filter, setFilter] = useState({ role: '', department: '', search: '' }) - const content = Array.from(Array(numberOfTotalItems).keys()) - .map((item, index) => ({ - name: `User ${item + 1}`, - role: index % 8 === 0 ? 'ADMIN' : 'USER', - department: index % 3 === 0 ? 'HR' : 'SALES', - })) - .filter( - (item) => - item.name.includes(filter.search) && - (filter.role === '' || item.role === filter.role) && - (filter.department === '' || item.department === filter.department) - ) - const isScreenMedium = useMatchMediaQuery('(min-width: 768px)') - const filterVariant = isScreenMedium - ? FormVariant.soft - : FormVariant.transparent - return ( - <Section> - <SectionHeaderArea> - <SectionHeaderRow> - <SectionHeaderTitle>List of users</SectionHeaderTitle> - <ButtonIcon - label="Add" - icon={IconAdd} - variant={ButtonVariant.transparent} - tone={Tone.neutral} - /> - </SectionHeaderRow> - <FilterForm - defaultValues={filter} - onSubmit={(values) => setFilter(values)} - > - <SectionHeaderRow layout={SectionHeaderRowLayout.stretch}> - <SectionHeaderGroup - spacing={SectionHeaderGroupSpacing.md} - className="flex-col md:flex-row gap-y-2" - > - <SearchFormField className="w-full md:w-auto grow" /> - <SectionHeaderGroup className="grid grid-cols-2 w-full md:block md:flex md:w-auto"> - <SelectFormField name="role" variant={filterVariant}> - <Option value="">All roles</Option> - <Option value="ADMIN">Admin</Option> - <Option value="USER">User</Option> - </SelectFormField> - <SelectFormField - id="department" - name="department" - variant={filterVariant} - > - <Option value="">All departments</Option> - <Option value="HR">Human Resources</Option> - <Option value="IT">Engineering</Option> - <Option value="SALES">Sales</Option> - </SelectFormField> - </SectionHeaderGroup> - </SectionHeaderGroup> - </SectionHeaderRow> - </FilterForm> - <SectionHeaderSpacer /> - </SectionHeaderArea> - <SectionContainer> - {content.length === 0 ? ( - <SectionContentEmpty - title="No users found" - text="There are no items in this list at the moment." - /> - ) : ( - <SectionContentList> - {content - .slice( - (page - 1) * numberOfItemsPerPage, - (page - 1) * numberOfItemsPerPage + numberOfItemsPerPage - ) - .map((item) => ( - <SectionListItemButton - key={item.name} - onClick={() => action('clicked')} - > - {`${item.name} (${item.role} - ${item.department})`} - </SectionListItemButton> - ))} - </SectionContentList> - )} - <SectionFooterWithPaginationInMemory - page={page} - size={numberOfItemsPerPage} - total={content.length} - onChangePage={setPage} - /> - </SectionContainer> - </Section> - ) -} - -<Canvas> - <Story - name="List with search and filter" - args={{ - numberOfTotalItems: 1000, - numberOfItemsPerPage: 5, - }} - > - {TemplateComplexList.bind({})} - </Story> -</Canvas> - -### Props - -<ArgsTable story="List with search and filter" /> - -# List with search - -export const TemplateSearch = ({ numberOfTotalItems }) => { - const isScreenMedium = useMatchMediaQuery('(min-width: 768px)') - const [filter, setFilter] = useState({ search: '' }) - const content = Array.from(Array(numberOfTotalItems).keys()) - .map((item, index) => ({ - name: `User ${item + 1}`, - role: index % 8 === 0 ? 'ADMIN' : 'USER', - department: index % 3 === 0 ? 'HR' : 'SALES', - })) - .filter((item) => item.name.includes(filter.search)) - return ( - <Section> - <FilterForm - defaultValues={filter} - onSubmit={(values) => setFilter(values)} - > - <SectionHeaderArea> - <SectionHeaderRow - layout={ - isScreenMedium - ? SectionHeaderRowLayout.spaceBetween - : SectionHeaderRowLayout.stretch - } - > - <SectionHeaderTitle>List of users</SectionHeaderTitle> - <SearchFormField /> - </SectionHeaderRow> - {/* <SectionHeaderRow - layout={SectionHeaderRowLayout.stretch} - className="block md:hidden" - > - <SearchFormField /> - </SectionHeaderRow> */} - <SectionHeaderSpacer className="md:hidden" /> - </SectionHeaderArea> - </FilterForm> - <SectionContainer> - {content.length === 0 ? ( - <SectionContentEmpty - title="No users found" - text="There are no items in this list at the moment." - /> - ) : ( - <SectionContentList> - {content.map((item) => ( - <SectionListItemButton - key={item.name} - onClick={() => action('clicked')} - > - {`${item.name} (${item.role} - ${item.department})`} - </SectionListItemButton> - ))} - </SectionContentList> - )} - </SectionContainer> - </Section> - ) -} - -<Canvas> - <Story - name="List with search" - args={{ - numberOfTotalItems: 10, - }} - > - {TemplateSearch.bind({})} - </Story> -</Canvas> - -# List with subsection title - -export const TemplateWithSubtitle = () => { - const content = Array.from(Array(5).keys()).map((item, index) => ({ - name: `User ${item + 1}`, - role: index % 3 === 0 ? 'ADMIN' : 'USER', - department: index % 2 === 0 ? 'HR' : 'SALES', - })) - const UserList = ({ role }) => ( - <SectionContentList> - {content.length > 0 && - content - .filter((item) => item.role === role) - .map((item) => ( - <SectionListItemButton - key={item.name} - onClick={() => action('clicked')} - > - {`${item.name} (${item.role} - ${item.department})`} - </SectionListItemButton> - ))} - </SectionContentList> - ) - return ( - <Section> - <SectionHeader title="List of users" /> - <SectionContainer> - <SubsectionTitle>Users</SubsectionTitle> - <UserList role="USER" /> - <SubsectionTitle>Admins</SubsectionTitle> - <UserList role="ADMIN" /> - <SectionFooterWithActions> - <Button>Submit</Button> - <Button variant={ButtonVariant.ghost} className="lg:order-first"> - Cancel - </Button> - </SectionFooterWithActions> - </SectionContainer> - </Section> - ) -} - -<Canvas> - <Story - name="List with subsection title" - args={{ - numberOfTotalItems: 1000, - numberOfItemsPerPage: 5, - }} - > - {TemplateWithSubtitle.bind({})} - </Story> -</Canvas> - -# List with filter dialog - -This example uses a dialog for the filter on smaller screen sizes, whereas the larger layout shows the filter in the section header. - -export const TemplateListWithFilterDialog = ({ - numberOfTotalItems, - numberOfItemsPerPage, -}) => { - const [page, setPage] = useState(1) - const [filter, setFilter] = useState({ role: '', department: '', search: '' }) - const [dialogShow, setDialogShow] = useState(false) - const isDialogEnabled = useMatchMediaQuery('(max-width: 768px)') - const content = Array.from(Array(numberOfTotalItems).keys()) - .map((item, index) => ({ - name: `User ${item + 1}`, - role: index % 8 === 0 ? 'ADMIN' : 'USER', - department: index % 3 === 0 ? 'HR' : 'SALES', - })) - .filter( - (item) => - item.name.includes(filter.search) && - (filter.role === '' || item.role === filter.role) && - (filter.department === '' || item.department === filter.department) - ) - const Filters = () => ( - <> - <SelectFormField id="role" name="role" variant={FormVariant.transparent}> - <Option value="">All roles</Option> - <Option value="ADMIN">Admin</Option> - <Option value="USER">User</Option> - </SelectFormField> - <SelectFormField - id="department" - name="department" - variant={FormVariant.transparent} - > - <Option value="">All departments</Option> - <Option value="HR">Human Resources</Option> - <Option value="IT">Engineering</Option> - <Option value="SALES">Sales</Option> - </SelectFormField> - <SearchFormField className="col-span-full" /> - </> - ) - return ( - <> - {isDialogEnabled && dialogShow && ( - <Dialog - onDismiss={() => setDialogShow(false)} - title="Filter" - className="mx-auto bg-white overflow-hidden" - > - <DialogHeaderWithClose - onDismiss={() => setDialogShow(false)} - title="Filter" - /> - <FilterForm - defaultValues={filter} - enableReinitialize - autoSubmit={false} - onSubmit={(values) => { - setFilter(values) - setDialogShow(false) - }} - > - <DialogContent className="flex flex-col gap-y-2"> - <Filters /> - </DialogContent> - <DialogFooterWithActions> - <SubmitButton className="col-span-full mt-2"> - Apply filter - </SubmitButton> - </DialogFooterWithActions> - </FilterForm> - </Dialog> - )} - <Section> - <SectionHeaderArea> - <SectionHeaderRow> - <SectionHeaderTitle>List of users</SectionHeaderTitle> - {isDialogEnabled ? ( - <SectionHeaderGroup> - <div className="relative flex items-center"> - <ButtonIcon - icon={IconFilterList} - label="Filter" - onClick={() => setDialogShow(true)} - variant={ButtonVariant.transparent} - tone={Tone.neutral} - /> - {filter.department !== '' || - filter.role !== '' || - (filter.search !== '' && ( - <div className="absolute left-0 p-1 bg-primary-500 rounded-full" /> - ))} - </div> - </SectionHeaderGroup> - ) : ( - <FilterForm - defaultValues={filter} - enableReinitialize - onSubmit={(values) => setFilter(values)} - className="flex" - > - <SectionHeaderGroup> - <Filters /> - </SectionHeaderGroup> - </FilterForm> - )} - </SectionHeaderRow> - </SectionHeaderArea> - <SectionContainer> - {content.length === 0 ? ( - <SectionContentEmpty - title="No users found" - text="There are no items in this list at the moment." - /> - ) : ( - <SectionContentList> - {content - .slice( - (page - 1) * numberOfItemsPerPage, - (page - 1) * numberOfItemsPerPage + numberOfItemsPerPage - ) - .map((item) => ( - <SectionListItemButton - key={item.name} - onClick={action('clicked')} - > - {`${item.name} (${item.role} - ${item.department})`} - </SectionListItemButton> - ))} - </SectionContentList> - )} - <SectionFooterWithPaginationInMemory - page={page} - size={numberOfItemsPerPage} - total={content.length} - onChangePage={setPage} - /> - </SectionContainer> - </Section> - </> - ) -} - -<Canvas> - <Story - name="List with filters in dialog on mobile" - args={{ - numberOfTotalItems: 1000, - numberOfItemsPerPage: 5, - }} - > - {TemplateListWithFilterDialog.bind({})} - </Story> -</Canvas> - -### Props - -<ArgsTable story="List with filters in dialog on mobile" />