-
Notifications
You must be signed in to change notification settings - Fork 13
feat: RFC DataView component #752
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
be11409
ca8e88d
de5b9f8
6af72a8
0aebe7e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| 'use client'; | ||
|
|
||
| import { ReactNode } from 'react'; | ||
| import { useDataView } from '../hooks/useDataView'; | ||
|
|
||
| export interface DataViewDisplayAccessProps { | ||
| /** Field (column) accessor key. Gates rendering on the column's current visibility state. */ | ||
| accessorKey: string; | ||
| children: ReactNode; | ||
| /** Rendered when the referenced field is currently hidden. Defaults to null. */ | ||
| fallback?: ReactNode; | ||
| } | ||
|
|
||
| /** | ||
| * Gates children on the current column visibility state from DataView context. | ||
| * Use inside free-form renderers (Timeline bars, custom renderers, cell overrides) | ||
| * so the single DisplayControls toggle reaches the same visibility story that | ||
| * Table/List rows get through their column specs. | ||
| */ | ||
| export function DisplayAccess({ | ||
| accessorKey, | ||
| children, | ||
| fallback = null | ||
| }: DataViewDisplayAccessProps) { | ||
| const { table } = useDataView(); | ||
| const column = table?.getColumn(accessorKey); | ||
| // If the column doesn't exist, default to visible so consumers can wrap JSX | ||
| // in DisplayAccess without worrying about typos silently breaking the render. | ||
| const isVisible = column ? column.getIsVisible() : true; | ||
| return <>{isVisible ? children : fallback}</>; | ||
| } | ||
|
|
||
| DisplayAccess.displayName = 'DataView.DisplayAccess'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| 'use client'; | ||
|
|
||
| import { Chip } from '../../chip'; | ||
| import { Flex } from '../../flex'; | ||
| import { Text } from '../../text'; | ||
| import { DataViewField } from '../data-view.types'; | ||
| import { useDataView } from '../hooks/useDataView'; | ||
|
|
||
| export function DisplayProperties<TData>({ | ||
| fields | ||
| }: { | ||
| fields: DataViewField<TData>[]; | ||
| }) { | ||
| const { table } = useDataView<TData>(); | ||
| const hidableFields = fields?.filter(f => f.hideable) ?? []; | ||
|
|
||
| return ( | ||
| <Flex direction='column' gap={3}> | ||
| <Text>Display Properties</Text> | ||
| <Flex gap={3} wrap='wrap'> | ||
| {hidableFields.map(field => { | ||
| const column = table.getColumn(field.accessorKey); | ||
| const isVisible = column ? column.getIsVisible() : true; | ||
| return ( | ||
| <Chip | ||
| key={field.accessorKey} | ||
| variant='outline' | ||
| size='small' | ||
| color={isVisible ? 'accent' : 'neutral'} | ||
| onClick={() => column?.toggleVisibility()} | ||
| > | ||
| {field.label} | ||
| </Chip> | ||
| ); | ||
| })} | ||
| </Flex> | ||
| </Flex> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,122 @@ | ||
| 'use client'; | ||
|
|
||
| import { MixerHorizontalIcon } from '@radix-ui/react-icons'; | ||
|
|
||
| import { isValidElement, ReactNode } from 'react'; | ||
| import { Button } from '../../button'; | ||
| import { Flex } from '../../flex'; | ||
| import { Popover } from '../../popover'; | ||
| import styles from '../data-view.module.css'; | ||
| import { defaultGroupOption, SortOrdersValues } from '../data-view.types'; | ||
| import { useDataView } from '../hooks/useDataView'; | ||
| import { DisplayProperties } from './display-properties'; | ||
| import { Grouping } from './grouping'; | ||
| import { Ordering } from './ordering'; | ||
|
|
||
| interface DisplaySettingsProps { | ||
| trigger?: ReactNode; | ||
| } | ||
|
|
||
| export function DisplaySettings<TData>({ | ||
| trigger = ( | ||
| <Button | ||
| variant='outline' | ||
| color='neutral' | ||
| size='small' | ||
| leadingIcon={<MixerHorizontalIcon />} | ||
| > | ||
| Display | ||
| </Button> | ||
| ) | ||
| }: DisplaySettingsProps) { | ||
| const { | ||
| fields, | ||
| updateTableQuery, | ||
| tableQuery, | ||
| defaultSort, | ||
| onDisplaySettingsReset | ||
| } = useDataView<TData>(); | ||
|
|
||
| const sortableColumns = (fields ?? []) | ||
| .filter(f => f.sortable) | ||
| .map(f => ({ | ||
| label: f.label, | ||
| id: f.accessorKey | ||
| })); | ||
|
|
||
| function onSortChange(columnId: string, order: SortOrdersValues) { | ||
| updateTableQuery(query => { | ||
| return { | ||
| ...query, | ||
| sort: [{ name: columnId, order }] | ||
| }; | ||
| }); | ||
| } | ||
|
|
||
| function onGroupChange(columnId: string) { | ||
| updateTableQuery(query => { | ||
| return { | ||
| ...query, | ||
| group_by: [columnId] | ||
| }; | ||
| }); | ||
| } | ||
|
|
||
| function onGroupRemove() { | ||
| updateTableQuery(query => { | ||
| return { | ||
| ...query, | ||
| group_by: [] | ||
| }; | ||
| }); | ||
| } | ||
|
|
||
| function onReset() { | ||
| onDisplaySettingsReset(); | ||
| } | ||
|
|
||
| return ( | ||
| <Popover> | ||
| <Popover.Trigger | ||
| render={isValidElement(trigger) ? trigger : <button>{trigger}</button>} | ||
| /> | ||
| <Popover.Content | ||
| className={styles['display-popover-content']} | ||
| align='end' | ||
| > | ||
| <Flex direction='column'> | ||
| <Flex | ||
| direction='column' | ||
| className={styles['display-popover-properties-container']} | ||
| gap={5} | ||
| > | ||
| <Ordering | ||
| columnList={sortableColumns} | ||
| onChange={onSortChange} | ||
| value={tableQuery?.sort?.[0] || defaultSort} | ||
| /> | ||
| <Grouping | ||
| fields={fields ?? []} | ||
| onRemove={onGroupRemove} | ||
| onChange={onGroupChange} | ||
| value={tableQuery?.group_by?.[0] || defaultGroupOption.id} | ||
| /> | ||
| </Flex> | ||
| <Flex className={styles['display-popover-properties-container']}> | ||
| <DisplayProperties fields={fields ?? []} /> | ||
| </Flex> | ||
| <Flex | ||
| justify='end' | ||
| className={styles['display-popover-reset-container']} | ||
| > | ||
| <Button variant='text' onClick={onReset} color='neutral'> | ||
| Reset to default | ||
| </Button> | ||
| </Flex> | ||
| </Flex> | ||
| </Popover.Content> | ||
| </Popover> | ||
| ); | ||
| } | ||
|
|
||
| DisplaySettings.displayName = 'DataView.DisplayControls'; | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,159 @@ | ||||||||||||||||||||||||||
| 'use client'; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| import { isValidElement, ReactNode, useMemo } from 'react'; | ||||||||||||||||||||||||||
| import { FilterIcon } from '~/icons'; | ||||||||||||||||||||||||||
| import { FilterOperatorTypes, FilterType } from '~/types/filters'; | ||||||||||||||||||||||||||
| import { Button } from '../../button'; | ||||||||||||||||||||||||||
| import { FilterChip } from '../../filter-chip'; | ||||||||||||||||||||||||||
| import { Flex } from '../../flex'; | ||||||||||||||||||||||||||
| import { IconButton } from '../../icon-button'; | ||||||||||||||||||||||||||
| import { Menu } from '../../menu'; | ||||||||||||||||||||||||||
| import { DataViewField } from '../data-view.types'; | ||||||||||||||||||||||||||
| import { useDataView } from '../hooks/useDataView'; | ||||||||||||||||||||||||||
| import { useFilters } from '../hooks/useFilters'; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| type Trigger<TData> = | ||||||||||||||||||||||||||
| | ReactNode | ||||||||||||||||||||||||||
| | (({ | ||||||||||||||||||||||||||
| availableFilters, | ||||||||||||||||||||||||||
| appliedFilters | ||||||||||||||||||||||||||
| }: { | ||||||||||||||||||||||||||
| availableFilters: DataViewField<TData>[]; | ||||||||||||||||||||||||||
| appliedFilters: Set<string>; | ||||||||||||||||||||||||||
| }) => ReactNode); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| interface AddFilterProps<TData> { | ||||||||||||||||||||||||||
| fieldList: DataViewField<TData>[]; | ||||||||||||||||||||||||||
| appliedFiltersSet: Set<string>; | ||||||||||||||||||||||||||
| onAddFilter: (field: DataViewField<TData>) => void; | ||||||||||||||||||||||||||
| children?: Trigger<TData>; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| function AddFilter<TData>({ | ||||||||||||||||||||||||||
| fieldList = [], | ||||||||||||||||||||||||||
| appliedFiltersSet, | ||||||||||||||||||||||||||
| onAddFilter, | ||||||||||||||||||||||||||
| children | ||||||||||||||||||||||||||
| }: AddFilterProps<TData>) { | ||||||||||||||||||||||||||
| const availableFilters = fieldList?.filter( | ||||||||||||||||||||||||||
| f => !appliedFiltersSet.has(f.accessorKey) | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const trigger = useMemo(() => { | ||||||||||||||||||||||||||
| if (typeof children === 'function') | ||||||||||||||||||||||||||
| return children({ availableFilters, appliedFilters: appliedFiltersSet }); | ||||||||||||||||||||||||||
| else if (children) return children; | ||||||||||||||||||||||||||
| else if (appliedFiltersSet.size > 0) | ||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||
| <IconButton size={4}> | ||||||||||||||||||||||||||
| <FilterIcon /> | ||||||||||||||||||||||||||
| </IconButton> | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
|
Comment on lines
+46
to
+51
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add an accessible label to the icon-only filter trigger. After filters are applied, the default trigger becomes an icon-only button without an accessible name. ♿ Proposed fix else if (appliedFiltersSet.size > 0)
return (
- <IconButton size={4}>
+ <IconButton size={4} aria-label='Add filter'>
<FilterIcon />
</IconButton>
);📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||
| else | ||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||
| <Button | ||||||||||||||||||||||||||
| variant='text' | ||||||||||||||||||||||||||
| size='small' | ||||||||||||||||||||||||||
| leadingIcon={<FilterIcon />} | ||||||||||||||||||||||||||
| color='neutral' | ||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||
| Filter | ||||||||||||||||||||||||||
| </Button> | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
| }, [children, appliedFiltersSet, availableFilters]); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| return availableFilters.length > 0 ? ( | ||||||||||||||||||||||||||
| <Menu> | ||||||||||||||||||||||||||
| <Menu.Trigger | ||||||||||||||||||||||||||
| render={isValidElement(trigger) ? trigger : <button>{trigger}</button>} | ||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||
| <Menu.Content> | ||||||||||||||||||||||||||
| {availableFilters?.map(field => ( | ||||||||||||||||||||||||||
| <Menu.Item key={field.accessorKey} onClick={() => onAddFilter(field)}> | ||||||||||||||||||||||||||
| {field.label} | ||||||||||||||||||||||||||
| </Menu.Item> | ||||||||||||||||||||||||||
| ))} | ||||||||||||||||||||||||||
| </Menu.Content> | ||||||||||||||||||||||||||
| </Menu> | ||||||||||||||||||||||||||
| ) : null; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| export function Filters<TData>({ | ||||||||||||||||||||||||||
| classNames, | ||||||||||||||||||||||||||
| className, | ||||||||||||||||||||||||||
| trigger | ||||||||||||||||||||||||||
| }: { | ||||||||||||||||||||||||||
| classNames?: { | ||||||||||||||||||||||||||
| container?: string; | ||||||||||||||||||||||||||
| filterChips?: string; | ||||||||||||||||||||||||||
| addFilter?: string; | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
| className?: string; | ||||||||||||||||||||||||||
| trigger?: Trigger<TData>; | ||||||||||||||||||||||||||
| }) { | ||||||||||||||||||||||||||
| const { fields, tableQuery } = useDataView<TData>(); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const { | ||||||||||||||||||||||||||
| onAddFilter, | ||||||||||||||||||||||||||
| handleRemoveFilter, | ||||||||||||||||||||||||||
| handleFilterValueChange, | ||||||||||||||||||||||||||
| handleFilterOperationChange | ||||||||||||||||||||||||||
| } = useFilters<TData>(); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const filterableFields = fields?.filter(f => f.filterable) ?? []; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const appliedFiltersSet = new Set( | ||||||||||||||||||||||||||
| tableQuery?.filters?.map(filter => filter.name) | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const appliedFilters = | ||||||||||||||||||||||||||
| tableQuery?.filters?.map(filter => { | ||||||||||||||||||||||||||
| const field = fields?.find(f => f.accessorKey === filter.name); | ||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||
| filterType: field?.filterType || FilterType.string, | ||||||||||||||||||||||||||
| label: field?.label || '', | ||||||||||||||||||||||||||
| options: field?.filterOptions || [], | ||||||||||||||||||||||||||
| selectProps: field?.filterProps?.select, | ||||||||||||||||||||||||||
| ...filter | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
| }) || []; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||
| <Flex gap={3} className={className}> | ||||||||||||||||||||||||||
| {appliedFilters.length > 0 && ( | ||||||||||||||||||||||||||
| <Flex gap={3} className={classNames?.container}> | ||||||||||||||||||||||||||
| {appliedFilters.map(filter => ( | ||||||||||||||||||||||||||
| <FilterChip | ||||||||||||||||||||||||||
| key={filter.name} | ||||||||||||||||||||||||||
| label={filter.label} | ||||||||||||||||||||||||||
| value={filter.value} | ||||||||||||||||||||||||||
| onRemove={() => handleRemoveFilter(filter.name)} | ||||||||||||||||||||||||||
| onValueChange={value => | ||||||||||||||||||||||||||
| handleFilterValueChange(filter.name, value) | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| onOperationChange={operator => | ||||||||||||||||||||||||||
| handleFilterOperationChange( | ||||||||||||||||||||||||||
| filter.name, | ||||||||||||||||||||||||||
| operator as FilterOperatorTypes | ||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| columnType={filter.filterType} | ||||||||||||||||||||||||||
| options={filter.options} | ||||||||||||||||||||||||||
| selectProps={filter.selectProps} | ||||||||||||||||||||||||||
| className={classNames?.filterChips} | ||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||
| ))} | ||||||||||||||||||||||||||
| </Flex> | ||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||
| <AddFilter | ||||||||||||||||||||||||||
| fieldList={filterableFields} | ||||||||||||||||||||||||||
| appliedFiltersSet={appliedFiltersSet} | ||||||||||||||||||||||||||
| onAddFilter={onAddFilter} | ||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||
| {trigger} | ||||||||||||||||||||||||||
| </AddFilter> | ||||||||||||||||||||||||||
| </Flex> | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| Filters.displayName = 'DataView.Filters'; | ||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Filter grouping options by
groupable.Groupingcurrently receives every field, so non-groupable fields can still be selected. Mirror the sortable filtering and pass onlyfields.filter(f => f.groupable).Suggested fix
📝 Committable suggestion
🤖 Prompt for AI Agents