diff --git a/.stylelintrc b/.stylelintrc index e307d119..052e2508 100644 --- a/.stylelintrc +++ b/.stylelintrc @@ -10,7 +10,7 @@ "rules": { "declaration-empty-line-before": null, "declaration-property-unit-whitelist": { - "/.*/": ["rem", "deg", "fr", "ms", "%", "px", "vw"] + "/.*/": ["rem", "deg", "fr", "ms", "%", "px", "vw", "vh"] }, "declaration-property-value-blacklist": { "/.*/": ["(\\d+[1]+px|[^1]+px)"] diff --git a/src/components/experimental/Table/Table.tsx b/src/components/experimental/Table/Table.tsx new file mode 100644 index 00000000..a9120a24 --- /dev/null +++ b/src/components/experimental/Table/Table.tsx @@ -0,0 +1,96 @@ +import { + Table as BaseTable, + TableProps, + Cell as BaseCell, + Column as BaseColumn, + Row as BaseRow, + TableBody, + TableHeader +} from 'react-aria-components'; +import styled from 'styled-components'; +import { get } from '../../../utils/experimental/themeGet'; +import { textStyles } from '../Text/Text'; +import { getSemanticValue } from '../../../essentials/experimental'; + +const Table = styled(BaseTable)` + border-collapse: collapse; + border-spacing: 0; + position: relative; + width: 100%; + max-height: 100vh; + background: ${getSemanticValue('surface')}; + color: ${getSemanticValue('on-surface')}; +` as typeof BaseTable; + +const Cell = styled(BaseCell)` + padding: 0 ${get('space.3')}; + position: relative; + + &::before { + position: absolute; + top: 0; + right: 0; + left: 0; + bottom: 0; + content: ''; + border-radius: inherit; + opacity: 0; + transition: opacity ease 200ms; + } + + &:first-of-type { + border-radius: ${get('radii.4')} 0 0 ${get('radii.4')}; + } + + &:last-of-type { + border-radius: 0 ${get('radii.4')} ${get('radii.4')} 0; + } + + &[data-focused] { + outline: 0; + } +` as typeof BaseCell; + +/* Z-Index is needed for sticky header cells to be on top of other cells */ +const Column = styled(BaseColumn)` + position: sticky; + top: 0; + z-index: 1; + padding: 0 ${get('space.3')}; + height: 3rem; + background: ${getSemanticValue('surface')}; + border-bottom: 1px solid ${getSemanticValue('divider')}; + text-align: start; + white-space: nowrap; + outline: 0; + ${textStyles.variants.title2} +` as typeof BaseColumn; + +const Row = styled(BaseRow)` + height: 3rem; + border-bottom: 1px solid ${getSemanticValue('divider')}; + border-radius: ${get('radii.4')}; + ${textStyles.variants.body1} + + &[data-hovered] td::before { + background: ${getSemanticValue('on-surface')}; + opacity: 0.08; + } + + &[data-selected] { + background: ${getSemanticValue('interactive-container')}; + } + + &[data-focused] { + outline: 0.125rem solid ${getSemanticValue('accent')}; + outline-offset: -0.125rem; + } +` as typeof BaseRow; + +const Skeleton = styled.div` + height: 1rem; + border-radius: ${get('radii.2')}; + background: ${getSemanticValue('surface-variant')}; +`; + +export { Table, TableProps, Cell, Column, Row, TableBody, TableHeader, Skeleton }; diff --git a/src/components/experimental/Table/docs/Table.stories.tsx b/src/components/experimental/Table/docs/Table.stories.tsx new file mode 100644 index 00000000..47471b0b --- /dev/null +++ b/src/components/experimental/Table/docs/Table.stories.tsx @@ -0,0 +1,169 @@ +import React from 'react'; +import { StoryObj, Meta } from '@storybook/react'; +import { Table, TableHeader, TableBody, Row, Cell, Column, Skeleton } from '../Table'; +import { Text } from '../../Text/Text'; + +const meta: Meta = { + title: 'Experimental/Components/Table', + component: Table, + parameters: { + layout: 'centered' + }, + args: { + label: 'Files' + } +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: () => { + const columns: Array<{ id: string; name: string; isRowHeader?: boolean }> = [ + { name: 'Name', id: 'name', isRowHeader: true }, + { name: 'Type', id: 'type' }, + { name: 'Date Modified', id: 'date' } + ]; + + const rows: Array<{ id: number; name: string; date: string; type: string }> = [ + { id: 1, name: 'Games', date: '6/7/2020', type: 'File folder' }, + { id: 2, name: 'Program Files', date: '4/7/2021', type: 'File folder' }, + { id: 3, name: 'bootmgr', date: '11/20/2010', type: 'System file' }, + { id: 4, name: 'log.txt', date: '1/18/2016', type: 'Text Document' }, + { id: 5, name: 'log.txt', date: '1/18/2016', type: 'Text Document' }, + { id: 6, name: 'log.txt', date: '1/18/2016', type: 'Text Document' }, + { id: 7, name: 'log.txt', date: '1/18/2016', type: 'Text Document' } + ]; + + return ( + + + {column => {column.name}} + + + {item => {column => {item[column.id]}}} + +
+ ); + } +}; + +export const Loading: Story = { + render: () => { + const columns: Array<{ id: string; name: string; isRowHeader?: boolean }> = [ + { name: 'Name', id: 'name', isRowHeader: true }, + { name: 'Type', id: 'type' }, + { name: 'Date Modified', id: 'date' } + ]; + + return ( + + + {column => {column.name}} + + + {() => ( + + {() => ( + + + + )} + + )} + +
+ ); + } +}; + +export const Empty: Story = { + render: () => { + const columns: Array<{ id: string; name: string; isRowHeader?: boolean }> = [ + { name: 'Name', id: 'name', isRowHeader: true }, + { name: 'Type', id: 'type' }, + { name: 'Date Modified', id: 'date' } + ]; + + return ( + + + {column => {column.name}} + + ( +
+ No results found +
+ )} + > + {[]} +
+
+ ); + } +}; + +export const Async: Story = { + render: () => { + type Character = { name: string; height: number; mass: number; birth_year: string }; + const emptyCharacter: Character = { + name: '', + height: 0, + mass: 0, + birth_year: '' + }; + const pageSize = 10; + const [isLoading, setIsLoading] = React.useState(true); + const [items, setItems] = React.useState( + /* eslint-disable-next-line unicorn/no-new-array */ + new Array(pageSize).fill(emptyCharacter).map((value, idx) => ({ ...value, name: idx.toString() })) + ); + + React.useEffect(() => { + let ignore = false; + + async function startFetching() { + const res = await fetch(`https://swapi.py4e.com/api/people`); + const json = await res.json(); + + if (!ignore) { + setItems(json.results); + } + + setIsLoading(false); + } + + // eslint-disable-next-line no-void + void startFetching(); + + return () => { + ignore = true; + }; + }, []); + + const columns: Array<{ id: string; name: string; isRowHeader?: boolean }> = [ + { name: 'Name', id: 'name', isRowHeader: true }, + { name: 'Height', id: 'height' }, + { name: 'Mass', id: 'mass' }, + { name: 'Birth Year', id: 'birth_year' } + ]; + + return ( + + + {column => {column.name}} + + + {item => ( + + {column => {isLoading ? : item[column.id]}} + + )} + +
+ ); + } +}; diff --git a/src/components/experimental/index.ts b/src/components/experimental/index.ts index bc0bca59..3e559fe3 100644 --- a/src/components/experimental/index.ts +++ b/src/components/experimental/index.ts @@ -11,6 +11,7 @@ export { Label } from './Label/Label'; export { ListBox, ListBoxItem } from './ListBox/ListBox'; export { Popover } from './Popover/Popover'; export { Select } from './Select/Select'; +export { Table } from './Table/Table'; export { Text } from './Text/Text'; export { TextField } from './TextField/TextField'; export { TimeField } from './TimeField/TimeField';