diff --git a/.changeset/witty-chairs-confess.md b/.changeset/witty-chairs-confess.md new file mode 100644 index 00000000..ca1d4f11 --- /dev/null +++ b/.changeset/witty-chairs-confess.md @@ -0,0 +1,5 @@ +--- +"@atomicjolt/atomic-elements": major +--- + +Migrated Table to new collections API diff --git a/.storybook/main.ts b/.storybook/main.ts index 3cb4aa5c..73b74c77 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -3,6 +3,7 @@ import { StorybookConfig } from "@storybook/react-vite"; const config: StorybookConfig = { stories: [ "../packages/atomic-elements/**/*.stories.@(js|jsx|ts|tsx)", + "../packages/atomic-elements/src/**/*.mdx", ], addons: [ diff --git a/package-lock.json b/package-lock.json index 8e89fe04..50fed6b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5102,18 +5102,19 @@ } }, "node_modules/@react-aria/collections": { - "version": "3.0.0-alpha.5", - "license": "Apache-2.0", + "version": "3.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/@react-aria/collections/-/collections-3.0.0-alpha.6.tgz", + "integrity": "sha512-A+7Eap/zvsghMb5/C3EAPn41axSzRhtX2glQRXSBj1mK31CTPCZ9BhrMIMC5DL7ZnfA7C+Ysilo9nI2YQh5PMg==", "dependencies": { - "@react-aria/ssr": "^3.9.6", - "@react-aria/utils": "^3.25.3", - "@react-types/shared": "^3.25.0", + "@react-aria/ssr": "^3.9.7", + "@react-aria/utils": "^3.26.0", + "@react-types/shared": "^3.26.0", "@swc/helpers": "^0.5.0", "use-sync-external-store": "^1.2.0" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "node_modules/@react-aria/combobox": { @@ -5531,8 +5532,9 @@ } }, "node_modules/@react-aria/ssr": { - "version": "3.9.6", - "license": "Apache-2.0", + "version": "3.9.7", + "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.7.tgz", + "integrity": "sha512-GQygZaGlmYjmYM+tiNBA5C6acmiDWF52Nqd40bBp0Znk4M4hP+LTmI0lpI1BuKMw45T8RIhrAsICIfKwZvi2Gg==", "dependencies": { "@swc/helpers": "^0.5.0" }, @@ -5540,7 +5542,7 @@ "node": ">= 12" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "node_modules/@react-aria/switch": { @@ -5671,17 +5673,18 @@ } }, "node_modules/@react-aria/utils": { - "version": "3.25.3", - "license": "Apache-2.0", + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.26.0.tgz", + "integrity": "sha512-LkZouGSjjQ0rEqo4XJosS4L3YC/zzQkfRM3KoqK6fUOmUJ9t0jQ09WjiF+uOoG9u+p30AVg3TrZRUWmoTS+koQ==", "dependencies": { - "@react-aria/ssr": "^3.9.6", - "@react-stately/utils": "^3.10.4", - "@react-types/shared": "^3.25.0", + "@react-aria/ssr": "^3.9.7", + "@react-stately/utils": "^3.10.5", + "@react-types/shared": "^3.26.0", "@swc/helpers": "^0.5.0", "clsx": "^2.0.0" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "node_modules/@react-aria/visually-hidden": { @@ -5726,14 +5729,15 @@ } }, "node_modules/@react-stately/collections": { - "version": "3.11.0", - "license": "Apache-2.0", + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@react-stately/collections/-/collections-3.12.0.tgz", + "integrity": "sha512-MfR9hwCxe5oXv4qrLUnjidwM50U35EFmInUeFf8i9mskYwWlRYS0O1/9PZ0oF1M0cKambaRHKEy98jczgb9ycA==", "dependencies": { - "@react-types/shared": "^3.25.0", + "@react-types/shared": "^3.26.0", "@swc/helpers": "^0.5.0" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "node_modules/@react-stately/color": { @@ -5814,8 +5818,9 @@ } }, "node_modules/@react-stately/flags": { - "version": "3.0.4", - "license": "Apache-2.0", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.0.5.tgz", + "integrity": "sha512-6wks4csxUwPCp23LgJSnkBRhrWpd9jGd64DjcCTNB2AHIFu7Ab1W59pJpUL6TW7uAxVxdNKjgn6D1hlBy8qWsA==", "dependencies": { "@swc/helpers": "^0.5.0" } @@ -5832,17 +5837,18 @@ } }, "node_modules/@react-stately/grid": { - "version": "3.9.3", - "license": "Apache-2.0", + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/@react-stately/grid/-/grid-3.10.0.tgz", + "integrity": "sha512-ii+DdsOBvCnHMgL0JvUfFwO1kiAPP19Bpdpl6zn/oOltk6F5TmnoyNrzyz+2///1hCiySI3FE1O7ujsAQs7a6Q==", "dependencies": { - "@react-stately/collections": "^3.11.0", - "@react-stately/selection": "^3.17.0", - "@react-types/grid": "^3.2.9", - "@react-types/shared": "^3.25.0", + "@react-stately/collections": "^3.12.0", + "@react-stately/selection": "^3.18.0", + "@react-types/grid": "^3.2.10", + "@react-types/shared": "^3.26.0", "@swc/helpers": "^0.5.0" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "node_modules/@react-stately/list": { @@ -5940,16 +5946,17 @@ } }, "node_modules/@react-stately/selection": { - "version": "3.17.0", - "license": "Apache-2.0", + "version": "3.18.0", + "resolved": "https://registry.npmjs.org/@react-stately/selection/-/selection-3.18.0.tgz", + "integrity": "sha512-6EaNNP3exxBhW2LkcRR4a3pg+3oDguZlBSqIVVR7lyahv/D8xXHRC4dX+m0mgGHJpsgjs7664Xx6c8v193TFxg==", "dependencies": { - "@react-stately/collections": "^3.11.0", - "@react-stately/utils": "^3.10.4", - "@react-types/shared": "^3.25.0", + "@react-stately/collections": "^3.12.0", + "@react-stately/utils": "^3.10.5", + "@react-types/shared": "^3.26.0", "@swc/helpers": "^0.5.0" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "node_modules/@react-stately/slider": { @@ -5966,21 +5973,22 @@ } }, "node_modules/@react-stately/table": { - "version": "3.12.3", - "license": "Apache-2.0", - "dependencies": { - "@react-stately/collections": "^3.11.0", - "@react-stately/flags": "^3.0.4", - "@react-stately/grid": "^3.9.3", - "@react-stately/selection": "^3.17.0", - "@react-stately/utils": "^3.10.4", - "@react-types/grid": "^3.2.9", - "@react-types/shared": "^3.25.0", - "@react-types/table": "^3.10.2", + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/@react-stately/table/-/table-3.13.0.tgz", + "integrity": "sha512-mRbNYrwQIE7xzVs09Lk3kPteEVFVyOc20vA8ph6EP54PiUf/RllJpxZe/WUYLf4eom9lUkRYej5sffuUBpxjCA==", + "dependencies": { + "@react-stately/collections": "^3.12.0", + "@react-stately/flags": "^3.0.5", + "@react-stately/grid": "^3.10.0", + "@react-stately/selection": "^3.18.0", + "@react-stately/utils": "^3.10.5", + "@react-types/grid": "^3.2.10", + "@react-types/shared": "^3.26.0", + "@react-types/table": "^3.10.3", "@swc/helpers": "^0.5.0" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "node_modules/@react-stately/tabs": { @@ -6035,13 +6043,14 @@ } }, "node_modules/@react-stately/utils": { - "version": "3.10.4", - "license": "Apache-2.0", + "version": "3.10.5", + "resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.10.5.tgz", + "integrity": "sha512-iMQSGcpaecghDIh3mZEpZfoFH3ExBwTtuBEcvZ2XnGzCgQjeYXcMdIUwAfVQLXFTdHUHGF6Gu6/dFrYsCzySBQ==", "dependencies": { "@swc/helpers": "^0.5.0" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "node_modules/@react-types/button": { @@ -6121,13 +6130,14 @@ } }, "node_modules/@react-types/grid": { - "version": "3.2.9", - "license": "Apache-2.0", + "version": "3.2.10", + "resolved": "https://registry.npmjs.org/@react-types/grid/-/grid-3.2.10.tgz", + "integrity": "sha512-Z5cG0ITwqjUE4kWyU5/7VqiPl4wqMJ7kG/ZP7poAnLmwRsR8Ai0ceVn+qzp5nTA19cgURi8t3LsXn3Ar1FBoog==", "dependencies": { - "@react-types/shared": "^3.25.0" + "@react-types/shared": "^3.26.0" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "node_modules/@react-types/link": { @@ -6213,10 +6223,11 @@ } }, "node_modules/@react-types/shared": { - "version": "3.25.0", - "license": "Apache-2.0", + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.26.0.tgz", + "integrity": "sha512-6FuPqvhmjjlpEDLTiYx29IJCbCNWPlsyO+ZUmCUXzhUv2ttShOXfw8CmeHWHftT/b2KweAWuzqSlfeXPR76jpw==", "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "node_modules/@react-types/slider": { @@ -6240,14 +6251,15 @@ } }, "node_modules/@react-types/table": { - "version": "3.10.2", - "license": "Apache-2.0", + "version": "3.10.3", + "resolved": "https://registry.npmjs.org/@react-types/table/-/table-3.10.3.tgz", + "integrity": "sha512-Ac+W+m/zgRzlTU8Z2GEg26HkuJFswF9S6w26r+R3MHwr8z2duGPvv37XRtE1yf3dbpRBgHEAO141xqS2TqGwNg==", "dependencies": { - "@react-types/grid": "^3.2.9", - "@react-types/shared": "^3.25.0" + "@react-types/grid": "^3.2.10", + "@react-types/shared": "^3.26.0" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "node_modules/@react-types/tabs": { @@ -26473,7 +26485,7 @@ "@react-aria/button": "^3.9.5", "@react-aria/calendar": "^3.5.8", "@react-aria/checkbox": "^3.14.3", - "@react-aria/collections": "^3.0.0-alpha.5", + "@react-aria/collections": "^3.0.0-alpha.6", "@react-aria/combobox": "^3.9.1", "@react-aria/datepicker": "^3.10.1", "@react-aria/dialog": "^3.5.14", @@ -26502,7 +26514,7 @@ "@react-aria/utils": "^3.24.1", "@react-aria/visually-hidden": "^3.8.12", "@react-stately/flags": "^3.0.3", - "@react-stately/table": "^3.11.8", + "@react-stately/table": "^3.13.0", "@react-stately/utils": "^3.10.1", "classnames": "^2.3.1", "react-stately": "^3.30.1", diff --git a/packages/atomic-elements/package.json b/packages/atomic-elements/package.json index 72bfa5e7..4f48aebe 100644 --- a/packages/atomic-elements/package.json +++ b/packages/atomic-elements/package.json @@ -15,7 +15,7 @@ "@react-aria/button": "^3.9.5", "@react-aria/calendar": "^3.5.8", "@react-aria/checkbox": "^3.14.3", - "@react-aria/collections": "^3.0.0-alpha.5", + "@react-aria/collections": "^3.0.0-alpha.6", "@react-aria/combobox": "^3.9.1", "@react-aria/datepicker": "^3.10.1", "@react-aria/dialog": "^3.5.14", @@ -44,7 +44,7 @@ "@react-aria/utils": "^3.24.1", "@react-aria/visually-hidden": "^3.8.12", "@react-stately/flags": "^3.0.3", - "@react-stately/table": "^3.11.8", + "@react-stately/table": "^3.13.0", "@react-stately/utils": "^3.10.1", "classnames": "^2.3.1", "react-stately": "^3.30.1", diff --git a/packages/atomic-elements/src/components/Buttons/Button/Button.component.tsx b/packages/atomic-elements/src/components/Buttons/Button/Button.component.tsx index 05f94be7..ae731454 100644 --- a/packages/atomic-elements/src/components/Buttons/Button/Button.component.tsx +++ b/packages/atomic-elements/src/components/Buttons/Button/Button.component.tsx @@ -5,6 +5,7 @@ import { mergeProps, useObjectRef } from "@react-aria/utils"; import { SpinnerLoader } from "../../Loaders/SpinnerLoader"; import { ExtendedSize, + HasIcon, HasVariant, LoadingProps, RenderBaseProps, @@ -53,7 +54,8 @@ export const Button = forwardRef( function Button(props, forwardedRef) { [props, forwardedRef] = useContextPropsV2( ButtonContext, - props, + // Button doesn't have Icon props, but the context does + props as ButtonProps & HasIcon, forwardedRef ); diff --git a/packages/atomic-elements/src/components/Fields/Atoms/Input/Input.component.tsx b/packages/atomic-elements/src/components/Fields/Atoms/Input/Input.component.tsx index 32995d69..ec0bd7eb 100644 --- a/packages/atomic-elements/src/components/Fields/Atoms/Input/Input.component.tsx +++ b/packages/atomic-elements/src/components/Fields/Atoms/Input/Input.component.tsx @@ -5,6 +5,7 @@ import { ElementWrapperProps } from "../../../../types"; import { useContextPropsV2 } from "@hooks/useContextProps"; import { useRenderProps } from "@hooks"; import { InputContext } from "./Input.context"; +import { SlotProps } from "@hooks/useSlottedContext"; const StyledInput = styled.input` ${mixins.Regular} @@ -13,7 +14,11 @@ const StyledInput = styled.input` `; export interface InputProps - extends ElementWrapperProps> {} + extends Omit< + ElementWrapperProps>, + "slot" + >, + SlotProps {} /** A wrapped `` element */ export const Input = forwardRef(function Input( @@ -30,5 +35,12 @@ export const Input = forwardRef(function Input( style, }); - return ; + return ( + + ); }); diff --git a/packages/atomic-elements/src/components/Inputs/Checkbox/Checkbox.component.tsx b/packages/atomic-elements/src/components/Inputs/Checkbox/Checkbox.component.tsx index 14c14eab..e38b9ba9 100644 --- a/packages/atomic-elements/src/components/Inputs/Checkbox/Checkbox.component.tsx +++ b/packages/atomic-elements/src/components/Inputs/Checkbox/Checkbox.component.tsx @@ -3,19 +3,24 @@ import cn from "classnames"; import { useToggleState } from "react-stately"; import { AriaCheckboxProps, useCheckbox } from "@react-aria/checkbox"; import { useLocale } from "@react-aria/i18n"; -import { useForwardedRef } from "../../../hooks/useForwardedRef"; import { AriaProps, FieldInputProps } from "../../../types"; import { ChooseInput, ChooseLabel } from "../Inputs.styles"; import { CheckboxWrapper } from "./Checkbox.styles"; import { ErrorMessage, Message } from "../../Fields"; +import { useContextPropsV2 } from "@hooks/useContextProps"; +import { CheckBoxContext } from "./Checkbox.context"; +import { SlotProps } from "@hooks/useSlottedContext"; export interface CheckBoxProps extends AriaProps, - Omit {} + Omit, + SlotProps {} /** Checkbox Component. Accepts a `ref` */ export const CheckBox = React.forwardRef( (props, ref) => { + [props, ref] = useContextPropsV2(CheckBoxContext, props, ref); + const { children, error = "error", @@ -28,10 +33,10 @@ export const CheckBox = React.forwardRef( size = "medium", isIndeterminate = false, } = props; - const internalRef = useForwardedRef(ref); + const state = useToggleState(props); const { direction } = useLocale(); - const { inputProps, labelProps } = useCheckbox(props, state, internalRef); + const { inputProps, labelProps } = useCheckbox(props, state, ref); return ( ( > {children} @@ -59,5 +64,3 @@ export const CheckBox = React.forwardRef( ); } ); - -export default CheckBox; diff --git a/packages/atomic-elements/src/components/Inputs/Checkbox/Checkbox.context.ts b/packages/atomic-elements/src/components/Inputs/Checkbox/Checkbox.context.ts new file mode 100644 index 00000000..7ecfd3fa --- /dev/null +++ b/packages/atomic-elements/src/components/Inputs/Checkbox/Checkbox.context.ts @@ -0,0 +1,5 @@ +import { createComponentContext } from '@utils/index'; +import { CheckBoxProps } from './Checkbox.component'; + + +export const CheckBoxContext = createComponentContext(); diff --git a/packages/atomic-elements/src/components/Inputs/Checkbox/index.tsx b/packages/atomic-elements/src/components/Inputs/Checkbox/index.tsx index 9688e549..515bab6b 100644 --- a/packages/atomic-elements/src/components/Inputs/Checkbox/index.tsx +++ b/packages/atomic-elements/src/components/Inputs/Checkbox/index.tsx @@ -1,2 +1,3 @@ export { CheckBox } from "./Checkbox.component"; export type { CheckBoxProps } from "./Checkbox.component"; +export { CheckBoxContext } from "./Checkbox.context"; diff --git a/packages/atomic-elements/src/components/Layout/Table/Table.component.tsx b/packages/atomic-elements/src/components/Layout/Table/Table.component.tsx index 6944fb1f..f4e5c7ea 100644 --- a/packages/atomic-elements/src/components/Layout/Table/Table.component.tsx +++ b/packages/atomic-elements/src/components/Layout/Table/Table.component.tsx @@ -1,94 +1,124 @@ -import { TableProps } from "./Table.types"; - -import { useTableState } from "./hooks/useTableState"; -import { useGridTreeState } from "./hooks/useGridTreeState"; -import { TableShared } from "./components/internal/TableShared"; - -import { Row } from "./components/public/TableRow"; -import { TableCell } from "./components/public/TableCell"; -import { TableHeader } from "./components/public/TableHeader"; -import { TableColumn } from "./components/public/TableColumn"; -import { TableBody } from "./components/public/TableBody"; -import { TableFooter } from "./components/public/TableFooter"; -import { LoadingCellContent } from "./components/internal/Loading"; -import { PaginationDescriptor } from "../../../types"; -import { TableBottom } from "./components/public/TableBottom"; - -/** Table component that supports sorting, row selection, and column reordering. */ +import { useMemo } from "react"; +import { Collection, CollectionBuilder } from "@react-aria/collections"; +import { TableOptions, TableProps } from "./Table.types"; +import { TableRowWrapper } from "./components/TableRow"; +import { TableCell } from "./components/TableCell"; +import { TableHeaderWrapper } from "./components/TableHeader"; +import { TableColumnWrapper } from "./components/TableColumn"; +import { TableBody } from "./components/TableBody"; +import { LoadingCellContent } from "./components/Loading"; +import { TableBottom } from "./components/TableBottom"; +import { TableCollection } from "./TableCollection"; +import { TableOptionsContext } from "./Table.context"; +import { SimpleTable } from "./components/SimpleTable"; +import { TreeGridTable } from "./components/TreeGridTable"; + +/** Table component + * + * Features: + * - Column Sorting + * - Column Searching + * - Row & Cell Actions + * - Row Selection + * - Row Nesting + * + * @example + * ```jsx + * + * + * Column 1 + * Column 2 + * Column 3 + * + * + * + * Row 1, Cell 1 + * Row 1, Cell 2 + * Row 1, Cell 3 + * + * + * Row 2, Cell 1 + * Row 2, Cell 2 + * Row 2, Cell 3 + * + * + *
+ */ export function Table(props: TableProps) { const { allowsExpandableRows } = props; - if (allowsExpandableRows) { - return ; - } - - return ; -} - -export function InternalTable(props: TableProps) { - const { selectionMode, selectionBehavior } = props; - - const state = useTableState({ - ...props, - showSelectionCheckboxes: - selectionMode === "multiple" && selectionBehavior !== "replace", - }); - - return ; -} - -export function TreeGridTable(props: TableProps) { - const { selectionMode, selectionBehavior } = props; + const tableOptions: TableOptions = useMemo( + () => ({ + allowsExpandableRows: false, + hasBottom: props.hasBottom, + isSticky: props.isSticky, + selectionBehavior: props.selectionBehavior, + selectionMode: props.selectionMode, + }), + [ + props.hasBottom, + props.isSticky, + props.selectionBehavior, + props.selectionMode, + ] + ); - const state = useGridTreeState({ - ...props, - showSelectionCheckboxes: - selectionMode === "multiple" && selectionBehavior !== "replace", - }); + const content = ( + + + + ); - return ; + return ( + new TableCollection()} + > + {(collection: TableCollection) => + allowsExpandableRows ? ( + + ) : ( + + ) + } + + ); } -Table.Header = TableHeader; -Table.Column = TableColumn; +Table.Header = TableHeaderWrapper; +Table.Column = TableColumnWrapper; Table.Body = TableBody; -Table.Footer = TableFooter; -Table.Row = Row; +Table.Row = TableRowWrapper; Table.Cell = TableCell; Table.Bottom = TableBottom; interface TableSkeletonProps { + /** Number of columns in the table */ columns: number; + /** Number of rows in the table + * @default 10 + */ rows?: number; - paginationDescriptor?: PaginationDescriptor; } function TableSkeleton(props: TableSkeletonProps) { - const { - columns, - paginationDescriptor, - rows = paginationDescriptor?.pageSize ?? 10, - } = props; + const { columns, rows = 10 } = props; const cols = Array.from({ length: columns }).map((_, index) => ({ - key: index, + isRowHeader: index === 0, + id: index, })); return ( - +
{(col) => ( - + )} - {/* @ts-expect-error - in this case, we don't care about an actual body since we won't be rendering one */} - +
); } diff --git a/packages/atomic-elements/src/components/Layout/Table/Table.context.ts b/packages/atomic-elements/src/components/Layout/Table/Table.context.ts new file mode 100644 index 00000000..3ac68b2a --- /dev/null +++ b/packages/atomic-elements/src/components/Layout/Table/Table.context.ts @@ -0,0 +1,6 @@ +import { createContext } from 'react'; +import { TableOptions, TableState, TreeGridState } from './Table.types'; + +export const TableStateContext = createContext | TreeGridState | null>(null); + +export const TableOptionsContext = createContext(null); diff --git a/packages/atomic-elements/src/components/Layout/Table/Table.mdx b/packages/atomic-elements/src/components/Layout/Table/Table.mdx index 4a45fd6e..cf4bcd23 100644 --- a/packages/atomic-elements/src/components/Layout/Table/Table.mdx +++ b/packages/atomic-elements/src/components/Layout/Table/Table.mdx @@ -98,13 +98,11 @@ export default function SortableTable() { ### Searchable Table Columns -Any column in the table can be made searchable by setting the `allowsSearching` prop to `true` on a given column. +You can make any column in the table searchable by setting the `allowsSearching` prop to `true` on that column. -If there are multiple columns that are searchable, when the user selects a new column to search by, -the current search term will be cleared. Closing the search input will also clear the search term. +If multiple columns are searchable, selecting a new column to search by will clear the current search term. Closing the search input will also clear the search term. - -({ column: null, @@ -181,155 +179,40 @@ function SearchableTable() { ### Table with Row Selection -The table component supports row selection. By default, the selected rows will be managed internally, but you can -also provide `selectedKeys` and `onSelectionChange` props to manage the selected rows externally. +The table component supports row selection. By default, the selected rows are managed internally, but you can also provide `selectedKeys` and `onSelectionChange` props to manage the selected rows externally. -The table component supports two types of row selection: 'single' and 'multiple' +The table supports two types of row selection: 'single' and 'multiple'. #### Single Selection -In single selection mode, only one row can be selected at a time. Clicking on a row will select it and deselect any other selected row. +In single selection mode, only one row can be selected at a time. #### Multiple Selection -In multiple selection mode, multiple rows can be selected at the same time. Clicking on a row will toggle its selection state. +In multiple selection mode, multiple rows can be selected at the same time. Or all rows can be selected by clicking the checkbox in the header. -Multiple selection also displays a checkbox in each row to allow the user to select or deselect the row. +Note that selecting all rows sets `selectedKeys` to `"all"`, so you can handle either selecting a single page or all rows in your application. -### Table with reoderable columns - -The table supports the ability to drag-and-drop columns to reorder them. - -([ - "name", - "type", - "level", - ]); - - // Define your columns programatically - const columns = [ - { - key: "name", - name: "Name", - }, - { - key: "type", - name: "Type", - }, - { - key: "level", - name: "Level", - }, - ]; - - // Sort those columns based on the order you have in state - const sortedColumns = columnOrder.map((key) => - columns.find((c) => c.key === key) - ) as typeof columns; - - const pokemons = [ - { - name: "Charizard", - type: "Fire, Flying", - level: 67, - }, - { - name: "Blastoise", - type: "Water", - level: 56, - }, - { - name: "Venusaur", - type: "Grass, Poison", - level: 83, - }, - { - name: "Pikachu", - type: "Electric", - level: 100, - }, - ]; - - return ( - - - {(column) => ( - - {column.name} - - )} - - - {(pokemon) => ( - // The cells of the row should be rendered in the same order as the columns - - {sortedColumns.map((column) => ( - {pokemon[column.key]} - ))} - - )} - -
- ); -} -` -}} /> - - -### Nested Columns - -The table component supports nested columns. Nested columns can be used to group columns together under a common header. - - - -### Nested Rows - -Rows can be nested into collapsible groups with the `allowsExpandableRows` prop on the `Table` component. - -For any row that should have nested Children, provide those rows as children to their parent, after all -the cells of the parent row have been defined. - - - - ### Loading States -The table component supports two loading variants. -The first is for when the columns of the table are known but the content is not. - +The table component supports two loading variants. The first is for when the columns of the table are known but the content is not. -This is almost always going to be the one you want +This is almost always the one you want. - -In the cases where you don't know the columns or the content of the table, you can make use of `` +In cases where you don't know the columns or the content of the table, you can use ``. +### Table Bottom -### Table Bottom - -The `Table.Bottom` component can be used to render a visual footer for the table. It is not actually a footer in the DOM, -but rather a visual footer that is rendered at the bottom of the table. The reason for this is so that it can be made sticky. +The `Table.Bottom` component can be used to render a visual footer for the table. It is not actually a footer in the DOM, but rather a visual footer that is rendered at the bottom of the table. This allows it to be made sticky. - \ No newline at end of file + diff --git a/packages/atomic-elements/src/components/Layout/Table/Table.spec.tsx b/packages/atomic-elements/src/components/Layout/Table/Table.spec.tsx index 8bb425e1..c77fc843 100644 --- a/packages/atomic-elements/src/components/Layout/Table/Table.spec.tsx +++ b/packages/atomic-elements/src/components/Layout/Table/Table.spec.tsx @@ -1,18 +1,18 @@ import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; import { Table } from "."; -import { TableProps } from "./Table.types"; +import { TableProps, LoadingProps } from "./Table.types"; -const TestTable = (props: TableProps) => { +const TestTable = (props: TableProps & LoadingProps) => { return ( - + Column 1 - Column 2 + Column 2 - + Row 1, Cell 1 Row 1, Cell 2 @@ -43,16 +43,6 @@ describe("Table", () => { expect(res).toMatchSnapshot(); }); - it("should match the snapshot with pagination", () => { - const res = render( - - ); - - expect(res).toMatchSnapshot(); - }); - it("should match the snapshot when in a loading state", () => { const res = render(); expect(res).toMatchSnapshot(); @@ -61,10 +51,12 @@ describe("Table", () => { it("should renderEmpty when there is no rows", () => { render( -
+
- Column 1 - Column 2 + + Column 1 + + Column 2 "No data"}> {() => ( diff --git a/packages/atomic-elements/src/components/Layout/Table/Table.stories.tsx b/packages/atomic-elements/src/components/Layout/Table/Table.stories.tsx index 7197af34..fbffcf7f 100644 --- a/packages/atomic-elements/src/components/Layout/Table/Table.stories.tsx +++ b/packages/atomic-elements/src/components/Layout/Table/Table.stories.tsx @@ -2,50 +2,55 @@ import { useState } from "react"; import { Meta, StoryObj } from "@storybook/react"; import { fn } from "@storybook/test"; import { Table } from "."; -import { Key, SearchDescriptor } from "../../../types"; +import { SearchDescriptor } from "../../../types"; import { getCssProps } from "@sb/cssprops"; import { Flex } from "../Flex/Flex"; import { Button } from "@components/Buttons/Button"; -import { MaterialIcon } from "@components/index"; +import { MaterialIcon, Pagination } from "@components/index"; +import { RenderPropsArgTypes } from "@sb/helpers"; const meta: Meta = { title: "Layouts/Table", component: Table, - tags: ["!autodocs"], parameters: { cssprops: getCssProps("Table"), }, argTypes: { + ...RenderPropsArgTypes, + isSticky: { + description: "Whether the bottom content should be sticky", + control: "boolean", + }, + hasBottom: { + description: "Whether the table has a bottom content", + control: "boolean", + }, children: { control: false, }, sortDescriptor: { description: "The current sort descriptor, if any", }, - variant: { - description: "The visual variant of the table", - options: ["default", "full-borders", "sheet"], - control: "select", - }, - onSortChange: { - description: "Fires when the user changes the sort descriptor", + onCellAction: { + description: "Fires when the user interacts with a cell", table: { category: "Events", }, }, - onSearchChange: { - description: "Fires when the user changes search descriptor", + onRowAction: { + description: "Fires when the user interacts with a row", table: { category: "Events", }, }, - onColumnReorder: { - description: "Fires when the user changes the column order", + onSortChange: { + description: "Fires when the user changes the sort descriptor", table: { category: "Events", }, }, - onPaginationChange: { + onSearchChange: { + description: "Fires when the user changes search descriptor", table: { category: "Events", }, @@ -60,13 +65,12 @@ type Story = StoryObj; export const Primary: Story = { args: { onCellAction: fn(), + onRowAction: fn(), onSortChange: fn(), onSearchChange: fn(), - onColumnReorder: fn(), - onPaginationChange: fn(), children: [ - Name + Name Type Level , @@ -103,44 +107,44 @@ export const MultipleSelection: Story = { }, }; -export const NestedColumns: Story = { - args: { - variant: "full-borders", - children: [ - - - Name - - - Type - Level - - , - - - Charizard - Fire, Flying - 67 - - - Blastoise - Water - 56 - - - Venusaur - Grass, Poison - 83 - - - Pikachu - Electric - 100 - - , - ], - }, -}; +// export const NestedColumns: Story = { +// args: { +// variant: "full-borders", +// children: [ +// +// +// Name +// +// +// Type +// Level +// +// , +// +// +// Charizard +// Fire, Flying +// 67 +// +// +// Blastoise +// Water +// 56 +// +// +// Venusaur +// Grass, Poison +// 83 +// +// +// Pikachu +// Electric +// 100 +// +// , +// ], +// }, +// }; export const SingleSelection: Story = { args: { @@ -157,33 +161,33 @@ export const SortableHeaders: Story = { }, children: [ - + Name - + Type - + Level , - + Charizard Fire, Flying 67 - + Blastoise Water 56 - + Venusaur Grass, Poison 83 - + Pikachu Electric 100 @@ -193,89 +197,7 @@ export const SortableHeaders: Story = { }, }; -export const WithColumnReordering: Story = { - render: (args) => { - const [columnOrder, setColumnOrder] = useState([ - "name", - "type", - "level", - ]); - - const columns = [ - { - key: "name", - name: "Name", - allowsReordering: true, - }, - { - key: "type", - name: "Type", - allowsReordering: true, - }, - { - key: "level", - name: "Level", - allowsReordering: true, - }, - ]; - - const sortedColumns = columnOrder.map((key) => - columns.find((c) => c.key === key) - ) as typeof columns; - - const pokemons: Record[] = [ - { - name: "Charizard", - type: "Fire, Flying", - level: 67, - }, - { - name: "Blastoise", - type: "Water", - level: 56, - }, - { - name: "Venusaur", - type: "Grass, Poison", - level: 83, - }, - { - name: "Pikachu", - type: "Electric", - level: 100, - }, - ]; - - return ( -
- - {(column) => ( - - {column.name} - - )} - - - {(pokemon) => ( - - {sortedColumns.map((column) => ( - {pokemon[column.key]} - ))} - - )} - -
- ); - }, -}; - -export const WithColumnSearch: Story = { +export const SearchableColumns: Story = { render: (props) => { const [searchDescriptor, setSearchDescriptor] = useState({ column: null, @@ -325,19 +247,19 @@ export const WithColumnSearch: Story = { {...props} > - + Name - + Type - + Level {(pokemon) => ( - + {pokemon.name} {pokemon.type} {pokemon.level} @@ -349,135 +271,129 @@ export const WithColumnSearch: Story = { ); }, args: { - variant: "full-borders", "aria-label": "Table with searching", }, }; -export const WithNestedRows: Story = { - render: (args) => { - return ( - - - Name - Special Move - Level - - - - Fire, Flying - - - - Charizard - Flamethrower - 67 - - - - Moltres - Fire Spin - 60 - - - - Water - - - - - Blastoise - Hydro Pump - 56 - - - - Vaporeon - Aqua Tail - 65 - - - - Grass, Poison - - - - - Venusaur - Solar Beam - 83 - - - - Victreebel - Leaf Blade - 70 - - - - - Electric - - - - - Pikachu - Thunderbolt - 100 - - - - Raichu - Thunder Punch - 90 - - - -
- ); - }, - argTypes: { - defaultExpandedKeys: { - description: "The default expanded keys", - control: false, - }, - expandedKeys: { - description: "The expanded keys", - control: "multi-select", - options: ["Fire, Flying", "Water", "Grass, Poison", "Electric"], - }, - onExpandedChange: { - action: "expandedChange", - description: "Fires when the expanded keys change", - table: { - category: "Events", - }, - }, - }, - args: { - "aria-label": "Table with nested rows", - allowsExpandableRows: true, - defaultExpandedKeys: ["Fire, Flying", "Water"], - onExpandedChange: fn(), - }, -}; - -export const PaginatedTable: Story = { - args: { - ...Primary.args, - paginationDescriptor: { - page: 1, - pageSize: 10, - totalPages: 10, - }, - }, -}; +// export const WithNestedRows: Story = { +// render: (args) => { +// return ( +// +// +// Name +// Special Move +// Level +// +// +// +// Fire, Flying +// +// +// +// Charizard +// Flamethrower +// 67 +// + +// +// Moltres +// Fire Spin +// 60 +// +// +// +// Water +// +// + +// +// Blastoise +// Hydro Pump +// 56 +// + +// +// Vaporeon +// Aqua Tail +// 65 +// +// +// +// Grass, Poison +// +// + +// +// Venusaur +// Solar Beam +// 83 +// + +// +// Victreebel +// Leaf Blade +// 70 +// +// + +// +// Electric +// +// + +// +// Pikachu +// Thunderbolt +// 100 +// + +// +// Raichu +// Thunder Punch +// 90 +// +// +// +//
+// ); +// }, +// argTypes: { +// defaultExpandedKeys: { +// description: "The default expanded keys", +// control: false, +// }, +// expandedKeys: { +// description: "The expanded keys", +// control: "multi-select", +// options: ["Fire, Flying", "Water", "Grass, Poison", "Electric"], +// }, +// onExpandedChange: { +// action: "expandedChange", +// description: "Fires when the expanded keys change", +// table: { +// category: "Events", +// }, +// }, +// }, +// args: { +// "aria-label": "Table with nested rows", +// allowsExpandableRows: true, +// defaultExpandedKeys: ["Fire, Flying", "Water"], +// onExpandedChange: fn(), +// }, +// }; export const LoadingState: Story = { args: { ...Primary.args, - isLoading: true, - loadingRows: 8, + children: [ + + Name + Type + Level + , + , + ], }, }; @@ -494,11 +410,10 @@ export const RenderEmptyTable: Story = { args: { children: [ - Name + Name Type Level , - // @ts-expect-error ( + <> + + + + + + + + + + + + + + + + ), + args: { + ...Primary.args, + hasBottom: true, + }, +}; + +export const StickyRowHeader: Story = { + ...Primary, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: { + ...Primary.args, + children: [ + + + Name + + Type + Level + HP + Attack + Defense + , + + + Charizard + Fire, Flying + 67 + 78 + 84 + 78 + + + Blastoise + Water + 56 + 79 + 83 + 100 + + + Venusaur + Grass, Poison + 83 + 80 + 82 + 83 + + + Pikachu + Electric + 100 + 35 + 55 + 40 + + , + ], + isSticky: true, + style: { + width: "1000px", + }, + }, +}; diff --git a/packages/atomic-elements/src/components/Layout/Table/Table.styles.ts b/packages/atomic-elements/src/components/Layout/Table/Table.styles.ts index 9446b0ac..5346dba0 100644 --- a/packages/atomic-elements/src/components/Layout/Table/Table.styles.ts +++ b/packages/atomic-elements/src/components/Layout/Table/Table.styles.ts @@ -171,7 +171,6 @@ export const RowHeader = styled.th` background-color: var(--table-bg-clr); text-align: left; font-weight: inherit; - width: 25%; height: var(--table-cell-height); vertical-align: middle; diff --git a/packages/atomic-elements/src/components/Layout/Table/Table.types.ts b/packages/atomic-elements/src/components/Layout/Table/Table.types.ts index 9daf3d2d..414567fe 100644 --- a/packages/atomic-elements/src/components/Layout/Table/Table.types.ts +++ b/packages/atomic-elements/src/components/Layout/Table/Table.types.ts @@ -1,11 +1,9 @@ import { AriaTableProps } from "@react-aria/table"; import { - TableBodyProps, - TableHeaderProps, TableState as StatelyTableState, TreeGridState as StatelyTreeGridState, } from "@react-stately/table"; -import { Expandable } from "@react-types/shared"; +import { Expandable, SelectionMode } from "@react-types/shared"; import { SelectionBehavior, Sortable, @@ -15,20 +13,11 @@ import { import { ExtendedSize, Key, - PaginationDescriptor, RenderBaseProps, SearchDescriptor, SuggestStrings, } from "../../../types"; -import { TableFooterProps } from "./components/public/TableFooter"; -import { ElementsTableCollection } from "./TableCollection"; - -export interface PaginationProps { - /** Object representing the current pagination state of the table */ - paginationDescriptor?: PaginationDescriptor; - /** Handler called whenever a change is made to the paginationDescriptor */ - onPaginationChange?: (descriptor: PaginationDescriptor) => void; -} +import { TableCollection } from "./TableCollection"; export interface LoadingProps { /** Whether the table is in a loading state @@ -36,7 +25,7 @@ export interface LoadingProps { */ isLoading?: boolean; /** The number of rows to render when loading - * @default paginationDescriptor.pageSize ?? 10 + * @default 10 */ loadingRows?: number; } @@ -62,22 +51,6 @@ export interface ColumnReorderProps { export type TableVariants = SuggestStrings<"default" | "grid" | "full-borders">; -export type TableChildren = - | [ - React.ReactElement>, - React.ReactElement>, - ] - | [ - React.ReactElement>, - React.ReactElement>, - React.ReactElement>, // Footer is optional - ] - | [ - React.ReactElement>, - React.ReactElement>, // Footer is optional - React.ReactElement>, - ]; - export interface RenderEmptyProps { /** The content to render when the table has no rows * The content provided is rendered within a Table row that @@ -91,22 +64,17 @@ export interface TableProps MultipleSelection, Sortable, SearchProps, - ColumnReorderProps, RenderBaseProps, - PaginationProps, - Expandable, - LoadingProps { + Expandable { /** Whether the table allows expandable rows. * When it's `false`, rows cannot have nested rows. */ allowsExpandableRows?: boolean; - variant?: TableVariants; - /** The selection behavior for the table. */ selectionBehavior?: SelectionBehavior; - children?: TableChildren; + children?: React.ReactNode; isSticky?: boolean; @@ -127,11 +95,35 @@ export interface TableStateExtensions { export interface TableState extends StatelyTableState, TableStateExtensions { - collection: ElementsTableCollection; + collection: TableCollection; } export interface TreeGridState extends StatelyTreeGridState, TableStateExtensions { - collection: ElementsTableCollection; + collection: TableCollection; +} + +export interface TableInternalProps extends TableProps { + collection: TableCollection; +} + + +export interface TableOptions { + /** Whether the table allows expandable rows. + * When it's `false`, rows cannot have nested rows. + */ + allowsExpandableRows?: boolean; + + /** The selection behavior for the table. */ + selectionBehavior?: SelectionBehavior; + + /** */ + selectionMode?: SelectionMode; + + /** Whether the table is in a sticky state */ + isSticky?: boolean; + + /** Whether to show a bottom to the table */ + hasBottom?: boolean; } diff --git a/packages/atomic-elements/src/components/Layout/Table/TableCollection.ts b/packages/atomic-elements/src/components/Layout/Table/TableCollection.ts index cba8c2f9..1fc79e65 100644 --- a/packages/atomic-elements/src/components/Layout/Table/TableCollection.ts +++ b/packages/atomic-elements/src/components/Layout/Table/TableCollection.ts @@ -1,27 +1,184 @@ -import { TableCollection as StatelyTableCollection } from "@react-stately/table"; +import { BaseCollection, CollectionNode } from "@react-aria/collections"; +import { TableCollection as ITableCollection, buildHeaderRows } from "@react-stately/table"; import { GridNode } from "@react-types/grid"; -import { TableCollection as ITableCollection } from "@react-types/table"; +import { Key, Node } from "react-stately"; -interface GridCollectionOptions { - showSelectionCheckboxes?: boolean; - showDragButtons?: boolean; -} +// From https://github.com/adobe/react-spectrum/blob/main/packages/react-aria-components/src/Table.tsx + +export class TableCollection + extends BaseCollection + implements Omit, "_size" | "keyMap"> +{ + headerRows: GridNode[] = []; + columns: GridNode[] = []; + rows: GridNode[] = []; + rowHeaderColumnKeys: Set = new Set(); + head: CollectionNode = new CollectionNode("tableheader", -1); + body: CollectionNode = new CollectionNode("tablebody", -2); + columnsDirty = true; + + addNode(node: CollectionNode) { + super.addNode(node); + + this.columnsDirty ||= node.type === "column"; + if (node.type === "tableheader") { + this.head = node; + } + + if (node.type === "tablebody") { + this.body = node; + } + } + + commit(firstKey: Key, lastKey: Key, isSSR = false) { + this.updateColumns(isSSR); + super.commit(firstKey, lastKey, isSSR); + this.rows = [...this.getChildren(this.body.key)]; + } -// Extends the table collection to add support for a footer node. -export class ElementsTableCollection extends StatelyTableCollection { - footer: GridNode | null = null; + private updateColumns(isSSR: boolean) { + if (!this.columnsDirty) { + return; + } - constructor( - nodes: Iterable>, - prev?: ITableCollection, - opts?: GridCollectionOptions - ) { - super(nodes, prev, opts); + this.rowHeaderColumnKeys = new Set(); + this.columns = []; - for (let node of nodes) { - if (node.type === "footer") { - this.footer = node; + let columnKeyMap = new Map(); + let visit = (node: Node) => { + switch (node.type) { + case "column": + columnKeyMap.set(node.key, node); + if (!node.hasChildNodes) { + node.index = this.columns.length; + this.columns.push(node); + + if (node.props.isRowHeader) { + this.rowHeaderColumnKeys.add(node.key); + } + } + break; + } + for (let child of this.getChildren(node.key)) { + visit(child); } + }; + + for (let node of this.getChildren(this.head.key)) { + visit(node); } + + this.headerRows = buildHeaderRows(columnKeyMap, this.columns); + this.columnsDirty = false; + + // For accessibility react-aria-component enforces that there is a + // row header for every table. We don't want to enforce this + // if ( + // this.rowHeaderColumnKeys.size === 0 && + // this.columns.length > 0 && + // !isSSR + // ) { + // throw new Error( + // "A table must have at least one Column with the isRowHeader prop set to true" + // ); + // } + } + + get columnCount() { + return this.columns.length; + } + + *[Symbol.iterator]() { + // Wait until the collection is initialized. + if (this.head.key === -1) { + return; + } + yield this.head; + yield this.body; + } + + get size() { + return this.rows.length; + } + + getFirstKey() { + return this.body.firstChildKey; + } + + getLastKey() { + return this.body.lastChildKey; + } + + getKeyAfter(key: Key) { + let node = this.getItem(key); + if (node?.type === "column") { + return node.nextKey ?? null; + } + + return super.getKeyAfter(key) ; + } + + getKeyBefore(key: Key) { + let node = this.getItem(key); + if (node?.type === "column") { + return node.prevKey ?? null; + } + + let k = super.getKeyBefore(key); + if (k != null && this.getItem(k)?.type === "tablebody") { + return null; + } + + return k; + } + + getChildren(key: Key): Iterable> { + if (!this.getItem(key)) { + for (let row of this.headerRows) { + if (row.key === key) { + return row.childNodes; + } + } + } + + return super.getChildren(key); + } + + clone() { + let collection = super.clone(); + collection.headerRows = this.headerRows; + collection.columns = this.columns; + collection.rowHeaderColumnKeys = this.rowHeaderColumnKeys; + collection.head = this.head; + collection.body = this.body; + return collection; + } + + getTextValue(key: Key): string { + let row = this.getItem(key); + if (!row) { + return ""; + } + + // If the row has a textValue, use that. + if (row.textValue) { + return row.textValue; + } + + // Otherwise combine the text of each of the row header columns. + let rowHeaderColumnKeys = this.rowHeaderColumnKeys; + let text: string[] = []; + for (let cell of this.getChildren(key)) { + let column = this.columns[cell.index!]; + if (rowHeaderColumnKeys.has(column.key) && cell.textValue) { + text.push(cell.textValue); + } + + if (text.length === rowHeaderColumnKeys.size) { + break; + } + } + + return text.join(" "); } } diff --git a/packages/atomic-elements/src/components/Layout/Table/__snapshots__/Table.spec.tsx.snap b/packages/atomic-elements/src/components/Layout/Table/__snapshots__/Table.spec.tsx.snap index ed4af706..d1e363a7 100644 --- a/packages/atomic-elements/src/components/Layout/Table/__snapshots__/Table.spec.tsx.snap +++ b/packages/atomic-elements/src/components/Layout/Table/__snapshots__/Table.spec.tsx.snap @@ -8,12 +8,12 @@ exports[`Table > Snapshots > Should match the snapshot when searchable 1`] = `
Snapshots > Should match the snapshot when searchable 1`] = ` role="row" >
Column 1
Column 2
@@ -73,111 +74,73 @@ exports[`Table > Snapshots > Should match the snapshot when searchable 1`] = `
-
- - Row 1, Cell 1 - -
+ Row 1, Cell 1
-
- - Row 1, Cell 2 - -
+ Row 1, Cell 2
-
- - Row 2, Cell 1 - -
+ Row 2, Cell 1
-
- - Row 2, Cell 2 - -
+ Row 2, Cell 2
- , "container":
Snapshots > Should match the snapshot when searchable 1`] = ` role="row" > @@ -387,12 +319,12 @@ exports[`Table > Snapshots > should match the snapshot 1`] = `
Column 1
Column 2
@@ -237,89 +201,57 @@ exports[`Table > Snapshots > Should match the snapshot when searchable 1`] = `
-
- - Row 1, Cell 1 - -
+ Row 1, Cell 1
-
- - Row 1, Cell 2 - -
+ Row 1, Cell 2
-
- - Row 2, Cell 1 - -
+ Row 2, Cell 1
-
- - Row 2, Cell 2 - -
+ Row 2, Cell 2
Snapshots > should match the snapshot 1`] = ` role="row" >
Snapshots > should match the snapshot 1`] = ` tabindex="-1" >
Column 1
Column 2
@@ -463,111 +396,73 @@ exports[`Table > Snapshots > should match the snapshot 1`] = `
-
- - Row 1, Cell 1 - -
+ Row 1, Cell 1
-
- - Row 1, Cell 2 - -
+ Row 1, Cell 2
-
- - Row 2, Cell 1 - -
+ Row 2, Cell 1
-
- - Row 2, Cell 2 - -
+ Row 2, Cell 2
- , "container":
Snapshots > should match the snapshot 1`] = ` role="row" > @@ -788,13 +652,12 @@ exports[`Table > Snapshots > should match the snapshot when in a loading state 1
Snapshots > should match the snapshot 1`] = ` tabindex="-1" >
Column 1
Column 2
@@ -638,89 +534,57 @@ exports[`Table > Snapshots > should match the snapshot 1`] = `
-
- - Row 1, Cell 1 - -
+ Row 1, Cell 1
-
- - Row 1, Cell 2 - -
+ Row 1, Cell 2
-
- - Row 2, Cell 1 - -
+ Row 2, Cell 1
-
- - Row 2, Cell 2 - -
+ Row 2, Cell 2
Snapshots > should match the snapshot when in a loading state 1 role="row" >
Column 1
Column 2
@@ -865,32 +729,33 @@ exports[`Table > Snapshots > should match the snapshot when in a loading state 1
Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1
Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1
Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1
Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1
Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1
Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1
Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1
Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1
Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1
Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1
- , "container":
Snapshots > should match the snapshot when in a loading state 1 role="row" >
Column 1
Column 2
@@ -2139,32 +1998,33 @@ exports[`Table > Snapshots > should match the snapshot when in a loading state 1
Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1
Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1
Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1
Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1
Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1
Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1
Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1
Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1
Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1
Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1 Snapshots > should match the snapshot when in a loading state 1 "unmount": [Function], } `; - -exports[`Table > Snapshots > should match the snapshot with pagination 1`] = ` -{ - "asFragment": [Function], - "baseElement": -
- - - - - - - - - - - - - - - - - -
-
- Column 1 - - -
-
-
- Column 2 -
-
-
- - Row 1, Cell 1 - -
-
-
- - Row 1, Cell 2 - -
-
-
- - Row 2, Cell 1 - -
-
-
- - Row 2, Cell 2 - -
-
-
-
-
- - -
-
-
- - - - -
-
-
-
-
- - , - "container":
- - - - - - - - - - - - - - - - - -
-
- Column 1 - - -
-
-
- Column 2 -
-
-
- - Row 1, Cell 1 - -
-
-
- - Row 1, Cell 2 - -
-
-
- - Row 2, Cell 1 - -
-
-
- - Row 2, Cell 2 - -
-
-
-
-
- - -
-
-
- - - - -
-
-
-
-
, - "debug": [Function], - "findAllByAltText": [Function], - "findAllByDisplayValue": [Function], - "findAllByLabelText": [Function], - "findAllByPlaceholderText": [Function], - "findAllByRole": [Function], - "findAllByTestId": [Function], - "findAllByText": [Function], - "findAllByTitle": [Function], - "findByAltText": [Function], - "findByDisplayValue": [Function], - "findByLabelText": [Function], - "findByPlaceholderText": [Function], - "findByRole": [Function], - "findByTestId": [Function], - "findByText": [Function], - "findByTitle": [Function], - "getAllByAltText": [Function], - "getAllByDisplayValue": [Function], - "getAllByLabelText": [Function], - "getAllByPlaceholderText": [Function], - "getAllByRole": [Function], - "getAllByTestId": [Function], - "getAllByText": [Function], - "getAllByTitle": [Function], - "getByAltText": [Function], - "getByDisplayValue": [Function], - "getByLabelText": [Function], - "getByPlaceholderText": [Function], - "getByRole": [Function], - "getByTestId": [Function], - "getByText": [Function], - "getByTitle": [Function], - "queryAllByAltText": [Function], - "queryAllByDisplayValue": [Function], - "queryAllByLabelText": [Function], - "queryAllByPlaceholderText": [Function], - "queryAllByRole": [Function], - "queryAllByTestId": [Function], - "queryAllByText": [Function], - "queryAllByTitle": [Function], - "queryByAltText": [Function], - "queryByDisplayValue": [Function], - "queryByLabelText": [Function], - "queryByPlaceholderText": [Function], - "queryByRole": [Function], - "queryByTestId": [Function], - "queryByText": [Function], - "queryByTitle": [Function], - "rerender": [Function], - "unmount": [Function], -} -`; diff --git a/packages/atomic-elements/src/components/Layout/Table/components/internal/EmptyTable.tsx b/packages/atomic-elements/src/components/Layout/Table/components/EmptyTable.tsx similarity index 81% rename from packages/atomic-elements/src/components/Layout/Table/components/internal/EmptyTable.tsx rename to packages/atomic-elements/src/components/Layout/Table/components/EmptyTable.tsx index af1d7b91..758232ae 100644 --- a/packages/atomic-elements/src/components/Layout/Table/components/internal/EmptyTable.tsx +++ b/packages/atomic-elements/src/components/Layout/Table/components/EmptyTable.tsx @@ -1,5 +1,5 @@ -import { StyledCell, StyledRow } from "../../Table.styles"; -import { RenderEmptyProps, TableState, TreeGridState } from "../../Table.types"; +import { StyledCell, StyledRow } from "../Table.styles"; +import { RenderEmptyProps, TableState, TreeGridState } from "../Table.types"; export interface EmptyTableProps extends RenderEmptyProps { state: TableState | TreeGridState; @@ -11,7 +11,7 @@ export function EmptyTable(props: EmptyTableProps) { if (!renderEmpty) return null; return ( - + {typeof renderEmpty === "function" ? renderEmpty() : renderEmpty} diff --git a/packages/atomic-elements/src/components/Layout/Table/components/internal/Loading.tsx b/packages/atomic-elements/src/components/Layout/Table/components/Loading.tsx similarity index 87% rename from packages/atomic-elements/src/components/Layout/Table/components/internal/Loading.tsx rename to packages/atomic-elements/src/components/Layout/Table/components/Loading.tsx index 5f953eba..f64b9554 100644 --- a/packages/atomic-elements/src/components/Layout/Table/components/internal/Loading.tsx +++ b/packages/atomic-elements/src/components/Layout/Table/components/Loading.tsx @@ -1,5 +1,5 @@ -import { CellLoader, StyledCell, StyledRow } from "../../Table.styles"; -import { TableState, TreeGridState } from "../../Table.types"; +import { CellLoader, StyledCell, StyledRow } from "../Table.styles"; +import { TableState, TreeGridState } from "../Table.types"; interface LoadingTableRowProps { state: TableState | TreeGridState; diff --git a/packages/atomic-elements/src/components/Layout/Table/components/SimpleTable.tsx b/packages/atomic-elements/src/components/Layout/Table/components/SimpleTable.tsx new file mode 100644 index 00000000..0520f158 --- /dev/null +++ b/packages/atomic-elements/src/components/Layout/Table/components/SimpleTable.tsx @@ -0,0 +1,18 @@ +import { Provider } from "@components/Internal/Provider"; +import { useTableState } from "../hooks/useTableState"; +import { TableInternalProps } from "../Table.types"; +import { TableStateContext } from "../Table.context"; +import { TableShared } from "./TableShared"; + +export function SimpleTable(props: TableInternalProps) { + const state = useTableState({ + ...props, + children: undefined, + }); + + return ( + + + + ); +} diff --git a/packages/atomic-elements/src/components/Layout/Table/components/TableBody.tsx b/packages/atomic-elements/src/components/Layout/Table/components/TableBody.tsx new file mode 100644 index 00000000..e37f8078 --- /dev/null +++ b/packages/atomic-elements/src/components/Layout/Table/components/TableBody.tsx @@ -0,0 +1,59 @@ +import React, { useContext } from "react"; +import { Node } from "@react-types/shared"; +import { useRenderProps } from "@hooks/useRenderProps"; +import { Collection, createBranchComponent } from "@react-aria/collections"; +import { TableRowGroup } from "./TableRowGroup"; +import { LoadingProps } from "../Table.types"; +import { StyledTBody } from "../Table.styles"; +import { LoadingTableRows } from "./Loading"; +import { EmptyTable } from "./EmptyTable"; +import { TableStateContext } from "../Table.context"; +import { useCollectionRenderer } from "@hooks/useCollectionRenderer"; + +export interface TableBodyProps extends LoadingProps { + renderEmpty?: (() => React.ReactNode) | React.ReactNode; + /** The contents of the table body. Supports static items or a function for dynamic rendering. */ + children?: React.ReactNode | ((item: T) => React.ReactNode); + /** A list of row objects in the table body used when dynamically rendering rows. */ + items?: Iterable; +} + +export const TableBody = createBranchComponent( + "tablebody", + function TableBody( + props: TableBodyProps, + ref: React.ForwardedRef, + body: Node + ) { + const state = useContext(TableStateContext)!; + const { isLoading, loadingRows = 10 } = props; + + const renderProps = useRenderProps({ + componentClassName: "aje-table__body", + selectors: { + "data-empty": !body.hasChildNodes, + "data-loading": isLoading, + }, + }); + + const { CollectionBranchRenderer } = useCollectionRenderer(); + + return ( + + {!body.hasChildNodes && !isLoading && ( + + )} + + {isLoading && } + + {!isLoading && ( + + )} + + ); + }, + (props) => {props.children} +); diff --git a/packages/atomic-elements/src/components/Layout/Table/components/public/TableBottom.tsx b/packages/atomic-elements/src/components/Layout/Table/components/TableBottom.tsx similarity index 82% rename from packages/atomic-elements/src/components/Layout/Table/components/public/TableBottom.tsx rename to packages/atomic-elements/src/components/Layout/Table/components/TableBottom.tsx index 3bbb8451..d04958e8 100644 --- a/packages/atomic-elements/src/components/Layout/Table/components/public/TableBottom.tsx +++ b/packages/atomic-elements/src/components/Layout/Table/components/TableBottom.tsx @@ -1,6 +1,6 @@ import { useRenderProps } from "@hooks"; -import { RenderBaseProps } from "../../../../../types"; -import { StyledTableBottom } from "../../Table.styles"; +import { RenderBaseProps } from "../../../../types"; +import { StyledTableBottom } from "../Table.styles"; interface TableBottomProps extends RenderBaseProps { isSticky?: boolean; diff --git a/packages/atomic-elements/src/components/Layout/Table/components/internal/TableCell.tsx b/packages/atomic-elements/src/components/Layout/Table/components/TableCell.tsx similarity index 56% rename from packages/atomic-elements/src/components/Layout/Table/components/internal/TableCell.tsx rename to packages/atomic-elements/src/components/Layout/Table/components/TableCell.tsx index fded1cb8..4c5e9fc2 100644 --- a/packages/atomic-elements/src/components/Layout/Table/components/internal/TableCell.tsx +++ b/packages/atomic-elements/src/components/Layout/Table/components/TableCell.tsx @@ -1,25 +1,47 @@ -import { useRef, MouseEvent } from "react"; -import { mergeProps } from "@react-aria/utils"; +import { MouseEvent, useContext } from "react"; +import { mergeProps, useObjectRef } from "@react-aria/utils"; import { useTableCell } from "@react-aria/table"; import { GridNode } from "@react-types/grid"; import { useFocusRing } from "@hooks/useFocusRing"; import { useRenderProps } from "@hooks/useRenderProps"; import { IconButton } from "@components/Buttons/IconButton"; import { Flex } from "@components/Layout/Flex/Flex"; -import { CellContent, RowHeader, StyledCell } from "../../Table.styles"; -import { TreeGridState, TableState } from "../../Table.types"; - -interface TableCellProps { - cell: GridNode; - state: TableState | TreeGridState; +import { CellContent, RowHeader, StyledCell } from "../Table.styles"; +import { TreeGridState } from "../Table.types"; +import { createLeafComponent } from "@react-aria/collections"; +import { TableStateContext } from "../Table.context"; +import { DomProps, RenderBaseProps } from "../../../../types"; + +export interface TableCellProps extends RenderBaseProps, DomProps { + // TODO: should this stay here? The rowHeader is defined at the column level in the collection + /** Whether the cell is a header for the row. When true, the cell will be a th instead of a td */ + isRowHeader?: boolean; + /** The contents of the cell. */ + children?: React.ReactNode; + /** A string representation of the cell's contents, used for features like typeahead. */ + textValue?: string; + /** The number of columns the cell should span. */ + colSpan?: number; + /** Whether to show a divider between this cell and the next cell */ + showDivider?: boolean; + /** Callback when a user clicks on or otherwise interacts with the cell */ + onAction?: () => void; + /** Controls whether the text in the cell is selectable. When isStatic is false users will not be able to select text inside of the cell */ + isStatic?: boolean; } -export function TableCell(props: TableCellProps) { - const { cell, state } = props; +export const TableCell = createLeafComponent("cell", function TableCell< + T extends object +>(props: TableCellProps, forwardedRef: React.ForwardedRef, cell: GridNode) { + const state = useContext(TableStateContext)!; + const ref = useObjectRef(forwardedRef); + + const { collection } = state; + + cell.column = collection.columns[cell.index]; - const ref = useRef(null); const { gridCellProps } = useTableCell({ node: cell }, state, ref); - const { focusProps, isFocusVisible } = useFocusRing(); + const { focusProps } = useFocusRing(); const isLastCell = cell.column?.key === @@ -29,7 +51,7 @@ export function TableCell(props: TableCellProps) { (cell.props.showDivider ?? cell.column?.props?.showDivider ?? false) && !isLastCell; - const colSpan = cell.colspan ?? cell.props.colSpan; + const colSpan = props.colSpan || cell.column?.props.colSpan; const isRowHeaderCell = state.collection.rowHeaderColumnKeys.has( cell?.column?.key! @@ -54,7 +76,7 @@ export function TableCell(props: TableCellProps) { state.expandedKeys === "all" || state.expandedKeys.has(cell.parentKey!); } - const nestedLevel = cell.level - 2; + const nestedLevel = Math.max(cell.level - 2, 0); const levelOffset = isRowHeaderCell ? `calc(var(--table-padding-horz) + var(--table-nesting-offset) * ${nestedLevel})` @@ -62,11 +84,9 @@ export function TableCell(props: TableCellProps) { const renderProps = useRenderProps({ componentClassName: "aje-table__cell", - className: cell.props.className, - style: cell.props.style, + ...props, selectors: { "data-divider": showDivider, - "data-focused": isFocusVisible, }, }); @@ -93,7 +113,7 @@ export function TableCell(props: TableCellProps) { return ( - + {/* {showExpandButton && ( (props: TableCellProps) { /> )} - {cell.rendered} + {renderProps.children} - + */} + {renderProps.children} ); -} +}); diff --git a/packages/atomic-elements/src/components/Layout/Table/components/internal/TableCheckbox.tsx b/packages/atomic-elements/src/components/Layout/Table/components/TableCheckbox.tsx similarity index 96% rename from packages/atomic-elements/src/components/Layout/Table/components/internal/TableCheckbox.tsx rename to packages/atomic-elements/src/components/Layout/Table/components/TableCheckbox.tsx index d4be8722..b3796c69 100644 --- a/packages/atomic-elements/src/components/Layout/Table/components/internal/TableCheckbox.tsx +++ b/packages/atomic-elements/src/components/Layout/Table/components/TableCheckbox.tsx @@ -8,7 +8,7 @@ import { } from "@react-aria/table"; import { VisuallyHidden } from "@react-aria/visually-hidden"; import { CheckBox } from "@components/Inputs/Checkbox"; -import { StyledCell, StyledTh } from "../../Table.styles"; +import { StyledCell, StyledTh } from "../Table.styles"; interface TableCheckboxCellProps { cell: Node; diff --git a/packages/atomic-elements/src/components/Layout/Table/components/TableColumn.tsx b/packages/atomic-elements/src/components/Layout/Table/components/TableColumn.tsx new file mode 100644 index 00000000..37484fa1 --- /dev/null +++ b/packages/atomic-elements/src/components/Layout/Table/components/TableColumn.tsx @@ -0,0 +1,204 @@ +import { useContext, useRef } from "react"; +import { filterDOMProps, mergeProps, useObjectRef } from "@react-aria/utils"; +import { GridNode } from "@react-types/grid"; +import { createLeafComponent } from "@react-aria/collections"; +import { ColumnProps } from "@react-stately/table"; + +import { useFocusRing } from "@hooks/useFocusRing"; +import { useRenderProps } from "@hooks/useRenderProps"; +import { MaterialIcon } from "@components/Icons/MaterialIcon"; + +import { ColumnContent, SearchComboInput, StyledTh } from "../Table.styles"; +import { useExtendedTableColumnHeader } from "../hooks/useExtendedTableColumnHeader"; +import { TableStateContext } from "../Table.context"; +import { RenderBaseProps } from "../../../../types"; +import { Input, InputContext } from "@components/Fields/Atoms/Input"; +import { useTableSearchInput } from "../hooks/useTableSearchInput"; +import { Provider } from "@components/Internal/Provider"; +import { DEFAULT_SLOT } from "@hooks/useSlottedContext"; +import { ButtonContext } from "@components/Buttons/Button/Button.context"; +import { IconButton } from "@components/Buttons/IconButton"; +import { TableHeaderWrapper } from "./TableHeader"; + +interface TableColumnRenderProps { + allowsSorting: boolean; + isSorting: boolean; + allowsSearching: boolean; + isSearching: boolean; + sortDirection: "ascending" | "descending" | undefined; +} + +export interface TableColumnProps + extends Omit, "children">, + RenderBaseProps { + /** Whether the column can be searched on */ + allowsSearching?: boolean; + + /** Whether to show a divider between this column and the next */ + showDivider?: boolean; + + id?: string; + + /** The number of columns to span */ + colSpan?: number; +} + +export const TableColumn = createLeafComponent("column", function TableColumn< + T extends object +>(props: TableColumnProps, forwardedRef: React.ForwardedRef, column: GridNode) { + const state = useContext(TableStateContext)!; + const ref = useObjectRef(forwardedRef); + + // Placeholder cells don't have props + column.props = column.props || {}; + + const { allowsSearching = false, allowsSorting = false, colSpan } = props; + + const isLastCol = + state.collection.columns[state.collection.columnCount - 1].key === + column.key; + + const showDivider = props.showDivider && !isLastCol; + + const inputRef = useRef(null); + + const { + isSearching, + searchInputProps, + searchCloseButtonProps, + searchOpenButtonProps, + } = useTableSearchInput( + { + column, + allowsSearching, + }, + state, + inputRef + ); + + const { columnHeaderProps } = useExtendedTableColumnHeader( + { node: column, isSearching }, + state, + ref, + inputRef + ); + + const { focusProps } = useFocusRing(); + + const renderProps = useRenderProps({ + componentClassName: "aje-table__column", + ...props, + values: { + allowsSorting, + isSorting: state.sortDescriptor?.column === column.key, + allowsSearching, + isSearching, + sortDirection: state.sortDescriptor?.direction, + }, + selectors: { + "data-divider": showDivider, + "data-sortable": allowsSorting, + "data-has-children": column.hasChildNodes, + "data-searchable": allowsSearching, + "data-searching": isSearching, + }, + }); + + const headerProps = mergeProps( + filterDOMProps(props as any), + columnHeaderProps, + focusProps, + renderProps + ); + + return ( + + + {renderProps.children} + + + ); +}); + +export function TableColumnWrapper( + props: TableColumnProps +) { + return ( + + {(renderProps) => { + const { + allowsSearching, + allowsSorting, + isSearching, + isSorting, + sortDirection, + } = renderProps; + + const arrowIcon = + sortDirection === "ascending" ? "arrow_drop_down" : "arrow_drop_up"; + + const children = + typeof props.children === "function" + ? props.children(renderProps) + : props.children; + + return ( + + {children} + {allowsSorting && isSorting && } + {allowsSorting && !isSorting && ( + + )} + {allowsSearching && ( + <> + + + + + + {!isSearching && ( + + )} + + )} + + ); + }} + + ); +} diff --git a/packages/atomic-elements/src/components/Layout/Table/components/internal/TableFooter.tsx b/packages/atomic-elements/src/components/Layout/Table/components/TableFooter.tsx similarity index 88% rename from packages/atomic-elements/src/components/Layout/Table/components/internal/TableFooter.tsx rename to packages/atomic-elements/src/components/Layout/Table/components/TableFooter.tsx index 9df7df90..cad5f0c1 100644 --- a/packages/atomic-elements/src/components/Layout/Table/components/internal/TableFooter.tsx +++ b/packages/atomic-elements/src/components/Layout/Table/components/TableFooter.tsx @@ -1,6 +1,6 @@ import { TableRowGroup } from "./TableRowGroup"; -import { StyledTableFooter } from "../../Table.styles"; -import { TableState, TreeGridState } from "../../Table.types"; +import { StyledTableFooter } from "../Table.styles"; +import { TableState, TreeGridState } from "../Table.types"; import { TableRow } from "./TableRow"; import { TableCell } from "./TableCell"; import { useRenderProps } from "@hooks"; diff --git a/packages/atomic-elements/src/components/Layout/Table/components/TableHeader.tsx b/packages/atomic-elements/src/components/Layout/Table/components/TableHeader.tsx new file mode 100644 index 00000000..7d02b41c --- /dev/null +++ b/packages/atomic-elements/src/components/Layout/Table/components/TableHeader.tsx @@ -0,0 +1,94 @@ +import { useContext } from "react"; +import { Node } from "react-stately"; +import { useTableSelectAllCheckbox } from "@react-aria/table"; +import { useRenderProps } from "@hooks/useRenderProps"; +import { Collection, createBranchComponent } from "@react-aria/collections"; + +import { useCollectionRenderer } from "@hooks/useCollectionRenderer"; +import { CheckBox, CheckBoxContext } from "@components/Inputs/Checkbox"; +import { DEFAULT_SLOT } from "@hooks/useSlottedContext"; + +import { TableStateContext } from "../Table.context"; +import { StyledThead } from "../Table.styles"; +import { TableRowGroup } from "./TableRowGroup"; +import { TableHeaderRow } from "./TableHeaderRow"; +import { TableColumn } from "./TableColumn"; +import { useTableOptions } from "../hooks/useTableOptions"; + +export interface TableHeaderProps { + /** Columns to render in the header in a dynamic collection + * + * @example + * ```jsx + * const columns = [ + * { id: "column-1", title: "Column 1" }, + * { id: "column-2", title: "Column 2" }, + * { id: "column-3", title: "Column 3" }, + * ]; + * + * {(column) => {column.title}} + * + */ + columns?: T[]; + children?: + | React.ReactNode + | React.ReactNode[] + | ((column: T) => React.ReactNode); +} + +export const TableHeader = createBranchComponent( + "tableheader", + function TableHeader( + props: TableHeaderProps, + ref: React.ForwardedRef, + header: Node + ) { + const state = useContext(TableStateContext)!; + const { checkboxProps } = useTableSelectAllCheckbox(state); + + const { CollectionBranchRenderer } = useCollectionRenderer(); + + const renderProps = useRenderProps({ + componentClassName: "aje-table__header", + }); + + return ( + + + + + + + + ); + }, + (props) => {props.children} +); + +export function TableHeaderWrapper( + props: TableHeaderProps +) { + const { selectionMode } = useTableOptions(); + + return ( + + {["multiple", "single"].includes(selectionMode ?? "") && ( + // 32 is the width of the checkbox + + {selectionMode === "multiple" && } + + )} + {props.children} + + ); +} diff --git a/packages/atomic-elements/src/components/Layout/Table/components/internal/TableHeaderRow.tsx b/packages/atomic-elements/src/components/Layout/Table/components/TableHeaderRow.tsx similarity index 87% rename from packages/atomic-elements/src/components/Layout/Table/components/internal/TableHeaderRow.tsx rename to packages/atomic-elements/src/components/Layout/Table/components/TableHeaderRow.tsx index 000cc012..c84b905a 100644 --- a/packages/atomic-elements/src/components/Layout/Table/components/internal/TableHeaderRow.tsx +++ b/packages/atomic-elements/src/components/Layout/Table/components/TableHeaderRow.tsx @@ -1,9 +1,9 @@ import { useRef } from "react"; import { useTableHeaderRow } from "@react-aria/table"; import { Node } from "react-stately"; -import { HasChildren } from "../../../../../types"; +import { HasChildren } from "../../../../types"; import { useRenderProps } from "@hooks/useRenderProps"; -import { TableState } from "../../Table.types"; +import { TableState } from "../Table.types"; interface TableHeaderRowProps extends HasChildren { item: Node; diff --git a/packages/atomic-elements/src/components/Layout/Table/components/internal/TablePagination.tsx b/packages/atomic-elements/src/components/Layout/Table/components/TablePagination.tsx similarity index 94% rename from packages/atomic-elements/src/components/Layout/Table/components/internal/TablePagination.tsx rename to packages/atomic-elements/src/components/Layout/Table/components/TablePagination.tsx index 94f14904..88035828 100644 --- a/packages/atomic-elements/src/components/Layout/Table/components/internal/TablePagination.tsx +++ b/packages/atomic-elements/src/components/Layout/Table/components/TablePagination.tsx @@ -1,7 +1,7 @@ -import { StyledTableBottom } from "../../Table.styles"; +import { StyledTableBottom } from "../Table.styles"; import { PageSizeSelect } from "@components/Pagination/PageSizeSelect"; import { Pagination } from "@components/Pagination/Pagination"; -import { PaginationDescriptor } from "../../../../../types"; +import { PaginationDescriptor } from "../../../../types"; import { useRenderProps } from "@hooks"; import { Flex } from "@components/Layout/Flex/Flex"; diff --git a/packages/atomic-elements/src/components/Layout/Table/components/TableRow.tsx b/packages/atomic-elements/src/components/Layout/Table/components/TableRow.tsx new file mode 100644 index 00000000..9a4a61f9 --- /dev/null +++ b/packages/atomic-elements/src/components/Layout/Table/components/TableRow.tsx @@ -0,0 +1,102 @@ +import { useContext } from "react"; +import { filterDOMProps, mergeProps, useObjectRef } from "@react-aria/utils"; +import { useTableRow, useTableSelectionCheckbox } from "@react-aria/table"; +import { Collection, createBranchComponent } from "@react-aria/collections"; +import { Node } from "react-stately"; + +import { DomProps, RenderStyleProps } from "../../../../types"; +import { useFocusRing } from "@hooks/useFocusRing"; +import { useRenderProps } from "@hooks/useRenderProps"; +import { useCollectionRenderer } from "@hooks/useCollectionRenderer"; +import { DEFAULT_SLOT } from "@hooks/useSlottedContext"; +import { CheckBox, CheckBoxContext } from "@components/Inputs/Checkbox"; +import { TableStateContext } from "../Table.context"; +import { StyledRow } from "../Table.styles"; +import { TableCell } from "./TableCell"; +import { useTableOptions } from "../hooks/useTableOptions"; + +export interface TableRowProps extends RenderStyleProps, DomProps { + /** Callback when a user clicks on or otherwise interacts with the cell */ + onAction?: () => void; + children?: + | React.ReactNode + | React.ReactNode[] + | ((column: T) => React.ReactNode); + columns?: T[]; + dependencies?: any[]; +} + +export const TableRow = createBranchComponent( + "item", + function TableRow( + props: TableRowProps, + forwardedRef: React.ForwardedRef, + row: Node + ) { + const state = useContext(TableStateContext)!; + const ref = useObjectRef(forwardedRef); + const { rowProps, ...states } = useTableRow({ node: row }, state, ref); + const { checkboxProps } = useTableSelectionCheckbox( + { key: row.key }, + state + ); + const { focusProps } = useFocusRing(); + + const renderProps = useRenderProps({ + componentClassName: "aje-table__row", + ...props, + selectors: { + "data-selected": states.isSelected, + }, + }); + + const { CollectionBranchRenderer } = useCollectionRenderer(); + + return ( + + + + + + ); + }, + (props) => ( + + {props.children} + + ) +); + +export function TableRowWrapper(props: TableRowProps) { + const { selectionMode } = useTableOptions(); + + return ( + + {["multiple", "single"].includes(selectionMode ?? "") && ( + + + + )} + {props.children} + + ); +} diff --git a/packages/atomic-elements/src/components/Layout/Table/components/internal/TableRowGroup.tsx b/packages/atomic-elements/src/components/Layout/Table/components/TableRowGroup.tsx similarity index 100% rename from packages/atomic-elements/src/components/Layout/Table/components/internal/TableRowGroup.tsx rename to packages/atomic-elements/src/components/Layout/Table/components/TableRowGroup.tsx diff --git a/packages/atomic-elements/src/components/Layout/Table/components/internal/TableShared.tsx b/packages/atomic-elements/src/components/Layout/Table/components/TableShared.tsx similarity index 52% rename from packages/atomic-elements/src/components/Layout/Table/components/internal/TableShared.tsx rename to packages/atomic-elements/src/components/Layout/Table/components/TableShared.tsx index 4e395d29..7f6f5b57 100644 --- a/packages/atomic-elements/src/components/Layout/Table/components/internal/TableShared.tsx +++ b/packages/atomic-elements/src/components/Layout/Table/components/TableShared.tsx @@ -1,30 +1,23 @@ import { useRef } from "react"; import { useTable } from "@react-aria/table"; +import { TableProps, TableState, TreeGridState } from "../Table.types"; +import { StyledTable } from "../Table.styles"; import { useRenderProps } from "@hooks/useRenderProps"; -import { TableProps, TableState, TreeGridState } from "../../Table.types"; -import { StyledTable } from "../../Table.styles"; -import { TableFooter } from "./TableFooter"; -import { TablePagination } from "./TablePagination"; -import { TableHeader } from "./TableHeader"; -import { TableBody } from "./TableBody"; +import { useCollectionRenderer } from "@hooks/useCollectionRenderer"; -export interface TableInternalProps extends TableProps { +export interface TableSharedProps extends TableProps { state: TableState | TreeGridState; } -export function TableShared(props: TableInternalProps) { +export function TableShared(props: TableSharedProps) { const { state, onRowAction, onCellAction, className, - variant, isSticky, style, - paginationDescriptor = null, - onPaginationChange, - isLoading = false, hasBottom = false, } = props; @@ -55,30 +48,20 @@ export function TableShared(props: TableInternalProps) { const renderProps = useRenderProps({ componentClassName: "aje-table", className, - variant, style, selectors: { "data-sticky": isSticky, - "data-loading": isLoading, - "data-has-bottom": paginationDescriptor !== null || hasBottom, + "data-has-bottom": hasBottom, }, }); + const { CollectionRenderer } = useCollectionRenderer(); + return ( <> - - - {state.collection.footer && } + - - {paginationDescriptor && ( - - )} ); } diff --git a/packages/atomic-elements/src/components/Layout/Table/components/TreeGridTable.tsx b/packages/atomic-elements/src/components/Layout/Table/components/TreeGridTable.tsx new file mode 100644 index 00000000..6dfb8d98 --- /dev/null +++ b/packages/atomic-elements/src/components/Layout/Table/components/TreeGridTable.tsx @@ -0,0 +1,20 @@ +import { useGridTreeState } from "../hooks/useGridTreeState"; +import { TableStateContext } from "../Table.context"; +import { TableInternalProps } from "../Table.types"; +import { TableShared } from "./TableShared"; + +export function TreeGridTable(props: TableInternalProps) { + // TODO: re-implement nested rows + throw new Error("allowExpandableRows is not currently implemented"); + + const state = useGridTreeState({ + ...props, + children: undefined, + }); + + return ( + + + + ); +} diff --git a/packages/atomic-elements/src/components/Layout/Table/components/internal/ColumnSearch.tsx b/packages/atomic-elements/src/components/Layout/Table/components/internal/ColumnSearch.tsx deleted file mode 100644 index c41b57c0..00000000 --- a/packages/atomic-elements/src/components/Layout/Table/components/internal/ColumnSearch.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import React, { ChangeEvent, forwardRef } from "react"; -import { GridNode } from "@react-types/grid"; - -import { IconButton } from "@components/Buttons/IconButton"; -import { SearchInput, SearchComboInput } from "../../Table.styles"; -import { TableState, TreeGridState } from "../../Table.types"; - -interface ColumnSearchProps { - column: GridNode; - state: TableState | TreeGridState; - isSearching?: boolean; -} - -export const ColumnSearch = forwardRef(function ColumnSearch( - props: ColumnSearchProps, - ref: React.ForwardedRef | null -) { - const { column, state, isSearching } = props; - - const { title } = column.props || {}; - - return ( - <> - - state.setKeyboardNavigationDisabled?.(true)} - onBlur={() => state.setKeyboardNavigationDisabled?.(false)} - onChange={(e: ChangeEvent) => { - state.search.set(column.key, e.target.value); - }} - onKeyDown={(e: React.KeyboardEvent) => - // Table listens for keydown events for things - // like flipping to sorting when pressing the spacebar - // so we need to stop propagation to prevent that - // when the input is focused - e.stopPropagation() - } - onKeyUp={(e) => { - if (e.key === "Escape") { - state.search.clear(); - } - }} - ref={ref} - // Stops react-aria from focusing the input - // when clicking on the column header - disabled={!isSearching} - /> - { - state.search.clear(); - }} - /> - - - {!isSearching && ( - { - state.search.set(column.key, ""); - - // Focus the input after the search icon is clicked - // We have to do this in a timeout because the input - // is disabled when the search icon is clicked - - // @ts-ignore - setTimeout(() => ref?.current?.focus(), 0); - }} - /> - )} - - ); -}); diff --git a/packages/atomic-elements/src/components/Layout/Table/components/internal/TableBody.tsx b/packages/atomic-elements/src/components/Layout/Table/components/internal/TableBody.tsx deleted file mode 100644 index f10755f6..00000000 --- a/packages/atomic-elements/src/components/Layout/Table/components/internal/TableBody.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { useRef } from "react"; -import { Expandable } from "@react-types/shared"; -import { useRenderProps } from "@hooks/useRenderProps"; -import { TableRowGroup } from "./TableRowGroup"; -import { TableRow } from "./TableRow"; -import { TableCell } from "./TableCell"; -import { TableCheckboxCell } from "./TableCheckbox"; -import { - LoadingProps, - PaginationProps, - RenderEmptyProps, - TableState, - TreeGridState, -} from "../../Table.types"; -import { StyledTBody } from "../../Table.styles"; -import { LoadingTableRows } from "./Loading"; -import { EmptyTable } from "./EmptyTable"; - -interface TableBodyProps extends Expandable, LoadingProps, PaginationProps { - state: TableState | TreeGridState; -} - -export function TableBody(props: TableBodyProps) { - const { - state, - isLoading, - paginationDescriptor, - loadingRows = paginationDescriptor?.pageSize || 10, - } = props; - const { collection } = state; - const ref = useRef(null); - - const rows = [...collection.body.childNodes]; - - const renderProps = useRenderProps({ - componentClassName: "aje-table__body", - ...collection.body.props, - selectors: { - "data-empty": rows.length === 0, - }, - }); - - return ( - - {rows.length === 0 && !isLoading && ( - - )} - {isLoading && } - {!isLoading && - rows.map((row) => { - return ( - - {[...collection.getChildren!(row.key)].map((cell) => { - if (cell.props.isSelectionCell) { - return ( - - ); - } else { - return ; - } - })} - - ); - })} - - ); -} diff --git a/packages/atomic-elements/src/components/Layout/Table/components/internal/TableColumn.tsx b/packages/atomic-elements/src/components/Layout/Table/components/internal/TableColumn.tsx deleted file mode 100644 index 7926dbd6..00000000 --- a/packages/atomic-elements/src/components/Layout/Table/components/internal/TableColumn.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { useRef } from "react"; -import { useDrag, useDrop, TextDropItem } from "@react-aria/dnd"; -import { mergeProps } from "@react-aria/utils"; -import { GridNode } from "@react-types/grid"; - -import { useFocusRing } from "@hooks/useFocusRing"; -import { useRenderProps } from "@hooks/useRenderProps"; -import { MaterialIcon } from "../../../../Icons/MaterialIcon"; - -import { - ColumnDropIndicator, - ColumnContent, - StyledTh, -} from "../../Table.styles"; - -import { useExtendedTableColumnHeader } from "../../hooks/useExtendedTableColumnHeader"; -import { TableState, TreeGridState } from "../../Table.types"; -import { ColumnSearch } from "./ColumnSearch"; - -interface TableColumnProps { - column: GridNode; - state: TableState | TreeGridState; - onDrop?: (columnKey: string) => void; -} - -export function TableColumn(props: TableColumnProps) { - const { column, state } = props; - - // Placeholder cells don't have props - column.props = column.props || {}; - - const { - props: { - allowsSearching = false, - className, - allowsSorting = false, - allowsReordering = false, - }, - } = column; - - const isLastCol = - state.collection.columns[state.collection.columnCount - 1].key === - column.key; - - const showDivider = column.props.showDivider && !isLastCol; - - const colSpan = column.colspan; - - const ref = useRef(null); - const inputRef = useRef(null); - const { columnHeaderProps, isSearching } = useExtendedTableColumnHeader( - { node: column }, - state, - ref, - inputRef - ); - const { focusProps } = useFocusRing(); - - const { dragProps } = useDrag({ - getItems() { - return [ - { - "text/plain": column.key as string, - }, - ]; - }, - }); - - const { dropProps, isDropTarget } = useDrop({ - ref, - async onDrop(e) { - console.log("onDrop", e); - const items = await Promise.all( - e.items - .filter( - (item) => item.kind === "text" && item.types.has("text/plain") - ) - // @ts-expect-error - .map((item: TextDropItem) => item.getText("text/plain")) - ); - const columnKey = items[0]; - - props.onDrop?.(columnKey); - }, - }); - - const arrowIcon = - state.sortDescriptor?.direction === "ascending" - ? "arrow_drop_down" - : "arrow_drop_up"; - - const renderProps = useRenderProps({ - componentClassName: "aje-table__column", - className, - style: column.props.style, - selectors: { - "data-divider": showDivider, - "data-sortable": allowsSorting, - "data-draggable": allowsReordering, - "data-has-children": column.hasChildNodes, - "data-searchable": allowsSearching, - "data-searching": isSearching, - }, - }); - - const headerProps = [columnHeaderProps, focusProps, renderProps]; - - if (allowsReordering) { - headerProps.push(dragProps, dropProps); - } - - return ( - - - {allowsReordering && ( - <> - - - - )} - - {column.rendered} - {column.props.allowsSorting && - state.sortDescriptor.column === column.key && ( - - )} - {column.props.allowsSorting && - state.sortDescriptor.column !== column.key && ( - - )} - {allowsSearching && ( - - )} - - - ); -} diff --git a/packages/atomic-elements/src/components/Layout/Table/components/internal/TableHeader.tsx b/packages/atomic-elements/src/components/Layout/Table/components/internal/TableHeader.tsx deleted file mode 100644 index bdd2f360..00000000 --- a/packages/atomic-elements/src/components/Layout/Table/components/internal/TableHeader.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { useRenderProps } from "@hooks/useRenderProps"; -import { StyledThead } from "../../Table.styles"; -import { TableColumn } from "./TableColumn"; -import { TreeGridState, TableState } from "../../Table.types"; -import { TableSelectAllCell } from "./TableCheckbox"; -import { TableRowGroup } from "./TableRowGroup"; -import { TableHeaderRow } from "./TableHeaderRow"; - -interface TableHeaderProps { - state: TableState | TreeGridState; -} - -export function TableHeader(props: TableHeaderProps) { - const { state } = props; - const { collection } = state; - - const renderProps = useRenderProps({ - componentClassName: "aje-table__header", - }); - - return ( - - {collection.headerRows.map((headerRow) => { - const columns = [...collection.getChildren!(headerRow.key)]; - return ( - - {columns.map((column) => { - if (column?.props?.isSelectionCell) { - return ( - - ); - } else { - return ( - state.reorderColumns(column.key, key)} - /> - ); - } - })} - - ); - })} - - ); -} diff --git a/packages/atomic-elements/src/components/Layout/Table/components/internal/TableRow.tsx b/packages/atomic-elements/src/components/Layout/Table/components/internal/TableRow.tsx deleted file mode 100644 index 6962a2a4..00000000 --- a/packages/atomic-elements/src/components/Layout/Table/components/internal/TableRow.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { useRef } from "react"; -import { mergeProps } from "@react-aria/utils"; -import { useTableRow } from "@react-aria/table"; -import { Node, TableState } from "react-stately"; - -import { HasChildren } from "../../../../../types"; -import { StyledRow } from "../../Table.styles"; -import { useFocusRing } from "@hooks/useFocusRing"; -import { useRenderProps } from "@hooks/useRenderProps"; - -interface TableRowProps extends HasChildren { - item: Node; - state: TableState; -} - -export function TableRow(props: TableRowProps) { - const { item, children, state } = props; - const ref = useRef(null); - const isSelected = state.selectionManager.isSelected(item.key); - const { rowProps } = useTableRow({ node: item }, state, ref); - const { focusProps } = useFocusRing(); - - const renderProps = useRenderProps({ - componentClassName: "aje-table__row", - className: item.props.className, - selectors: { - "data-selected": isSelected, - }, - }); - - return ( - - {children} - - ); -} diff --git a/packages/atomic-elements/src/components/Layout/Table/components/public/TableBody.tsx b/packages/atomic-elements/src/components/Layout/Table/components/public/TableBody.tsx deleted file mode 100644 index c3ced5f2..00000000 --- a/packages/atomic-elements/src/components/Layout/Table/components/public/TableBody.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { - TableBody as StatelyTableBody, - TableBodyProps as StatelyTableBodyProps, -} from "react-stately"; -import { Argument } from "classnames"; -import { cloneComponent } from "@utils/clone"; -import { RenderEmptyProps } from "../../Table.types"; - -export interface TableBodyProps - extends StatelyTableBodyProps, - RenderEmptyProps { - className?: Argument | Argument[]; - id?: string; -} - -/** A `Table.Body` is a container for the `Table.Row` elements in a Table. - * Rows can be statically defined as children, or generated dynamically - * using a function based on the data passed to the `items` prop. - */ -export const TableBody = cloneComponent(StatelyTableBody, "Table.Body") as ( - props: TableBodyProps -) => JSX.Element; diff --git a/packages/atomic-elements/src/components/Layout/Table/components/public/TableCell.tsx b/packages/atomic-elements/src/components/Layout/Table/components/public/TableCell.tsx deleted file mode 100644 index e5db923a..00000000 --- a/packages/atomic-elements/src/components/Layout/Table/components/public/TableCell.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { cloneComponent } from "@utils/clone"; -import { Argument } from "classnames"; -import { Cell } from "react-stately"; - -export interface TableCellProps { - /** Whether the cell is a header for the row. When true, the cell will be a th instead of a td */ - isRowHeader?: boolean; - /** The contents of the cell. */ - children?: React.ReactNode; - /** A string representation of the cell's contents, used for features like typeahead. */ - textValue?: string; - /** The number of columns the cell should span. */ - colSpan?: number; - - /** Whether to show a divider between this cell and the next cell */ - showDivider?: boolean; - - /** Callback when a user clicks on or otherwise interacts with the cell */ - onAction?: () => void; - - className?: Argument | Argument[]; - - id?: string; - - /** Controls whether the text in the cell is selectable. When isStatic is false users will not be able to select text inside of the cell */ - isStatic?: boolean; -} - -/** A `Table.Cell` represents a single cell in a Table. */ -export const TableCell = cloneComponent(Cell, "Table.Cell") as ( - props: TableCellProps -) => JSX.Element; diff --git a/packages/atomic-elements/src/components/Layout/Table/components/public/TableColumn.tsx b/packages/atomic-elements/src/components/Layout/Table/components/public/TableColumn.tsx deleted file mode 100644 index e47abed9..00000000 --- a/packages/atomic-elements/src/components/Layout/Table/components/public/TableColumn.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { cloneComponent } from "@utils/clone"; -import { Argument } from "classnames"; -import { Column, ColumnProps } from "react-stately"; - -export interface TableColumnProps extends Omit, "children"> { - /** Whether the column can be re-orderd by dragging and dropping on another re-orderable column */ - allowsReordering?: boolean; - - /** Whether the column can be searched on */ - allowsSearching?: boolean; - - /** Static child columns or content to render as the column header. */ - children?: ColumnProps["children"]; - - /** Whether to show a divider between this column and the next */ - showDivider?: boolean; - - className?: Argument | Argument[]; - - id?: string; -} - -/** A `Table.Column` represents a single column in a Table. - * It can be used as a child of a `Table.Header` to statically define - * or dynamically generated using a function based on the `columns` prop. - * of the `Table.Header`. - */ -export const TableColumn = cloneComponent(Column, "Table.Column") as ( - props: TableColumnProps -) => JSX.Element; diff --git a/packages/atomic-elements/src/components/Layout/Table/components/public/TableFooter.tsx b/packages/atomic-elements/src/components/Layout/Table/components/public/TableFooter.tsx deleted file mode 100644 index 8d959961..00000000 --- a/packages/atomic-elements/src/components/Layout/Table/components/public/TableFooter.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import React from "react"; -import { AriaLabelProps } from "../../../../../types"; -import { PartialNode } from "@react-stately/collections"; -import { TableBodyProps } from "./TableBody"; - -export interface TableFooterProps - extends AriaLabelProps, - TableBodyProps {} - -export function TableFooter(props: TableFooterProps) { - return <>; -} - -// https://github.com/adobe/react-spectrum/blob/main/packages/%40react-stately/table/src/TableBody.ts - -TableFooter.getCollectionNode = function* getCollectionNode( - props: TableFooterProps -): Generator> { - const { children, items } = props; - yield { - type: "footer", - hasChildNodes: true, - props, - *childNodes() { - if (typeof children === "function") { - if (!items) - throw new Error( - "props.children was a function but props.items is missing" - ); - - for (const item of items) { - yield { - type: "item", - value: item, - renderer: children, - }; - } - } else { - const items: PartialNode[] = []; - React.Children.forEach(children, (item) => { - items.push({ - type: "item", - element: item, - }); - }); - - yield* items; - } - }, - }; -}; diff --git a/packages/atomic-elements/src/components/Layout/Table/components/public/TableHeader.tsx b/packages/atomic-elements/src/components/Layout/Table/components/public/TableHeader.tsx deleted file mode 100644 index f4bf1004..00000000 --- a/packages/atomic-elements/src/components/Layout/Table/components/public/TableHeader.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { cloneComponent } from "@utils/clone"; -import { - TableHeader as StatelyTableHeader, - TableHeaderProps, -} from "react-stately"; - -export const TableHeader = cloneComponent( - StatelyTableHeader, - "Table.Header" -) as (props: TableHeaderProps) => JSX.Element; diff --git a/packages/atomic-elements/src/components/Layout/Table/components/public/TableRow.tsx b/packages/atomic-elements/src/components/Layout/Table/components/public/TableRow.tsx deleted file mode 100644 index 978abb01..00000000 --- a/packages/atomic-elements/src/components/Layout/Table/components/public/TableRow.tsx +++ /dev/null @@ -1,145 +0,0 @@ -/* - * Copyright 2020 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -import React from "react"; -import { PartialNode } from "@react-stately/collections"; -import { RowProps as StatelyRowProps } from "@react-types/table"; -import { AriaLabelProps, BaseProps } from "../../../../../types"; - -// Modified from: https://github.com/adobe/react-spectrum/blob/main/packages/%40react-stately/table/src/Row.ts - -export interface RowProps - extends Omit, "UNSTABLE_childItems">, - BaseProps, - AriaLabelProps { - /** Callback when a user clicks on or otherwise interacts with the cell */ - onAction?: () => void; - childItems?: StatelyRowProps["UNSTABLE_childItems"]; -} - -function Row(props: RowProps): React.ReactElement { - return <>; -} - -Row.displayName = "Table.Row"; - -Row.getCollectionNode = function* getCollectionNode( - props: RowProps, - context: any -): Generator> { - let { children, textValue, childItems } = props; - - yield { - type: "item", - props: props, - textValue, - "aria-label": props["aria-label"], - hasChildNodes: true, - *childNodes() { - // Process cells first - if (context.showDragButtons) { - yield { - type: "cell", - key: "header-drag", // this is combined with the row key by CollectionBuilder - props: { - isDragButtonCell: true, - }, - }; - } - - if (context.showSelectionCheckboxes && context.selectionMode !== "none") { - yield { - type: "cell", - key: "header", // this is combined with the row key by CollectionBuilder - props: { - isSelectionCell: true, - }, - }; - } - - if (typeof children === "function") { - for (let column of context.columns) { - yield { - type: "cell", - element: children(column.key), - key: column.key, // this is combined with the row key by CollectionBuilder - }; - } - - if (childItems) { - for (let child of childItems) { - // Note: in order to reuse the render function of TableBody for our child rows, we just need to yield a type and a value here. CollectionBuilder will then look up - // the parent renderer and use that to build the full node of this child row, using the value provided here to generate the cells - yield { - type: "item", - value: child, - }; - } - } - } else { - let colSpanCount = 0; - let cells: PartialNode[] = []; - let childRows: PartialNode[] = []; - React.Children.forEach(children, (node) => { - if (node.type === Row) { - if (colSpanCount < context.columns.length) { - throw new Error( - "All of a Row's child Cells must be positioned before any child Rows." - ); - } - - childRows.push({ - type: "item", - element: node, - }); - } else { - colSpanCount += (node.props as any).colSpan ?? 1; - cells.push({ - type: "cell", - element: node, - }); - } - }); - - if (colSpanCount !== context.columns.length) { - throw new Error( - `Cells must span all columns. Cells currently span ${colSpanCount} out of ${context.columns.length} columns.` - ); - } - - yield* cells; - yield* childRows; - } - }, - shouldInvalidate(newContext: any) { - // Invalidate all rows if the columns changed. - return ( - newContext.columns.length !== context.columns.length || - // @ts-expect-error - newContext.columns.some((c, i) => c.key !== context.columns[i].key) || - newContext.showSelectionCheckboxes !== - context.showSelectionCheckboxes || - newContext.showDragButtons !== context.showDragButtons || - newContext.selectionMode !== context.selectionMode - ); - }, - }; -}; - -/** - * A Row represents a single item in a Table and contains Cell elements for each column. - * Cells can be statically defined as children, or generated dynamically using a function - * based on the columns defined in the TableHeader. - */ -// We don't want getCollectionNode to show up in the type definition -let _Row = Row as (props: RowProps) => JSX.Element; -export { _Row as Row }; diff --git a/packages/atomic-elements/src/components/Layout/Table/hooks/useExtendedTableColumnHeader.ts b/packages/atomic-elements/src/components/Layout/Table/hooks/useExtendedTableColumnHeader.ts index bbdb6422..e8a37546 100644 --- a/packages/atomic-elements/src/components/Layout/Table/hooks/useExtendedTableColumnHeader.ts +++ b/packages/atomic-elements/src/components/Layout/Table/hooks/useExtendedTableColumnHeader.ts @@ -6,11 +6,13 @@ import { } from "@react-aria/table"; import { RefObject } from "react"; import { TableState } from "../Table.types"; +import { useTableSearchInput } from './useTableSearchInput'; -interface TableColumnHeaderProps extends AriaTableColumnHeaderProps {} +interface TableColumnHeaderProps extends AriaTableColumnHeaderProps { + isSearching?: boolean; +} interface TableColumnHeader extends TableColumnHeaderAria { - isSearching: boolean; } export function useExtendedTableColumnHeader( @@ -21,11 +23,7 @@ export function useExtendedTableColumnHeader( ): TableColumnHeader { const tableColumnHeader = useTableColumnHeader(props, state, ref); - const column = props.node; - - const allowsSearching = state.search.column === column.key; - - const isSearching = state.search.column === column.key && allowsSearching; + const { isSearching, node: column} = props; if (isSearching) { const headerProps = tableColumnHeader.columnHeaderProps; @@ -64,6 +62,5 @@ export function useExtendedTableColumnHeader( width: column.props.width, }, }, - isSearching, }; } diff --git a/packages/atomic-elements/src/components/Layout/Table/hooks/useGridTreeState.ts b/packages/atomic-elements/src/components/Layout/Table/hooks/useGridTreeState.ts index 21e08d48..a02c9166 100644 --- a/packages/atomic-elements/src/components/Layout/Table/hooks/useGridTreeState.ts +++ b/packages/atomic-elements/src/components/Layout/Table/hooks/useGridTreeState.ts @@ -3,7 +3,7 @@ import { UNSTABLE_useTreeGridState, } from "@react-stately/table"; import { enableTableNestedRows } from "@react-stately/flags"; -import { TableChildren, TableState } from "../Table.types"; +import { TableState } from "../Table.types"; import { TableStateExtensionsProps, useTableStateExtensions, @@ -11,11 +11,9 @@ import { import { Expandable } from "@react-types/shared"; export interface TreeGridStateProps - extends Omit, "children">, + extends StatelyTreeGridStateProps, TableStateExtensionsProps, - Expandable { - children?: TableChildren; -} + Expandable {} export function useGridTreeState( props: TreeGridStateProps diff --git a/packages/atomic-elements/src/components/Layout/Table/hooks/useTableCollection.ts b/packages/atomic-elements/src/components/Layout/Table/hooks/useTableCollection.ts deleted file mode 100644 index 8545c86b..00000000 --- a/packages/atomic-elements/src/components/Layout/Table/hooks/useTableCollection.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { useCallback, useMemo } from "react"; -import { Node, useCollection } from "react-stately"; -import { ElementsTableCollection } from "../TableCollection"; -import { TableChildren } from "../Table.types"; -import { TableCollection } from "@react-stately/table"; - -interface TableCollectionOptions { - showSelectionCheckboxes?: boolean; - showDragButtons?: boolean; - selectionMode?: "single" | "multiple" | "none"; - children?: TableChildren; -} - -export function useTableCollection( - options: TableCollectionOptions, - prevCollection: TableCollection -) { - const { - showSelectionCheckboxes = false, - showDragButtons = false, - selectionMode = "none", - children, - } = options; - - const context = useMemo( - () => ({ - showSelectionCheckboxes: - showSelectionCheckboxes && selectionMode !== "none", - showDragButtons: showDragButtons, - selectionMode, - columns: [], - }), - [children, showSelectionCheckboxes, selectionMode, showDragButtons] - ); - - const collection = useCollection( - options, - useCallback( - (nodes: Iterable>) => - new ElementsTableCollection(nodes, prevCollection, context), - [context] - ), - context - ) as ElementsTableCollection; - - return collection; -} diff --git a/packages/atomic-elements/src/components/Layout/Table/hooks/useTableOptions.ts b/packages/atomic-elements/src/components/Layout/Table/hooks/useTableOptions.ts new file mode 100644 index 00000000..637b8210 --- /dev/null +++ b/packages/atomic-elements/src/components/Layout/Table/hooks/useTableOptions.ts @@ -0,0 +1,13 @@ +import { useContext } from 'react'; +import { TableOptionsContext } from '../Table.context'; +import { TableOptions } from '../Table.types'; + +export function useTableOptions(): TableOptions { + const ctx = useContext(TableOptionsContext); + + if (!ctx) { + throw new Error('useTableOptions must be used within a Table'); + } + + return ctx; +} diff --git a/packages/atomic-elements/src/components/Layout/Table/hooks/useTableSearchInput.ts b/packages/atomic-elements/src/components/Layout/Table/hooks/useTableSearchInput.ts new file mode 100644 index 00000000..ebe4d53c --- /dev/null +++ b/packages/atomic-elements/src/components/Layout/Table/hooks/useTableSearchInput.ts @@ -0,0 +1,67 @@ +import { TableState } from "../Table.types"; +import { GridNode } from "@react-types/grid"; + +interface TableColumnHeaderProps { + column: GridNode; + allowsSearching: boolean; +} + +export function useTableSearchInput( + props: TableColumnHeaderProps, + state: TableState, + ref: React.RefObject, +) { + const { column, allowsSearching } = props; + + const isSearching = state.search.column === column.key && allowsSearching; + + const { title } = column.props || {}; + + const searchInputProps = { + role: "search", + "aria-label": `Search ${title || column.textValue || column.key}`, + value: state.search.text, + onFocus: () => state.setKeyboardNavigationDisabled?.(true), + onBlur: () => state.setKeyboardNavigationDisabled?.(false), + onChange: (e: React.ChangeEvent) => { + state.search.set(column.key, e.target.value); + }, + onKeyDown: (e: React.KeyboardEvent) => { + // Table listens for keydown events for things + // like flipping to sorting when pressing the spacebar + // so we need to stop propagation to prevent that + // when the input is focused + e.stopPropagation(); + }, + onKeyUp: (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + state.search.clear(); + } + }, + // Stops react-aria from focusing the input + // when clicking on the column header + disabled: !isSearching, + }; + + const searchOpenButtonProps = { + onPress: () => { + state.search.set(column.key, ""); + + // @ts-ignore + setTimeout(() => ref?.current?.focus(), 0); + }, + }; + + const searchCloseButtonProps = { + onPress: () => { + state.search.clear(); + }, + }; + + return { + searchInputProps, + searchOpenButtonProps, + searchCloseButtonProps, + isSearching, + }; +} diff --git a/packages/atomic-elements/src/components/Layout/Table/hooks/useTableState.ts b/packages/atomic-elements/src/components/Layout/Table/hooks/useTableState.ts index ddd427a0..af46011f 100644 --- a/packages/atomic-elements/src/components/Layout/Table/hooks/useTableState.ts +++ b/packages/atomic-elements/src/components/Layout/Table/hooks/useTableState.ts @@ -1,19 +1,20 @@ import { useTableState as useStatelyTableState, TableStateProps as StatelyTableStateProps, - TableCollection, } from "@react-stately/table"; -import { TableChildren, TableState } from "../Table.types"; +import { TableState } from "../Table.types"; import { TableStateExtensionsProps, useTableStateExtensions, } from "./useTableStateExtensions"; -import { useTableCollection } from "./useTableCollection"; +import { TableCollection } from '../TableCollection'; + export interface TableStateProps - extends Omit, "children">, + extends Omit, "children" | "collection">, TableStateExtensionsProps { - children?: TableChildren; + children?: React.ReactNode; + collection: TableCollection; } export function useTableState( @@ -21,14 +22,10 @@ export function useTableState( ): TableState { const state = useStatelyTableState(props as StatelyTableStateProps); const stateExtensions = useTableStateExtensions(props, state); - const collection = useTableCollection( - props, - state.collection as TableCollection - ); return { ...state, ...stateExtensions, - collection, + collection: props.collection, }; } diff --git a/packages/atomic-elements/src/hooks/useContextProps.ts b/packages/atomic-elements/src/hooks/useContextProps.ts index f861ad8b..ac556120 100644 --- a/packages/atomic-elements/src/hooks/useContextProps.ts +++ b/packages/atomic-elements/src/hooks/useContextProps.ts @@ -1,11 +1,11 @@ import { useContext } from "react"; import { mergeProps, mergeRefs, useObjectRef } from "@react-aria/utils"; +import { SlotProps, SlottedContextValue, useSlottedContext } from './useSlottedContext'; -type Props = Record; -type PropsArg = Props | null | undefined; type WithRef = T & { ref?: React.Ref }; +export type ContextValue = SlottedContextValue>; -export function useContextProps( +export function useContextProps( context: React.Context, props: T ) { @@ -14,12 +14,16 @@ export function useContextProps( return mergeProps(contextProps, props); } -export function useContextPropsV2( - context: React.Context, R>>, + +// from: https://github.com/adobe/react-spectrum/blob/c6bd2cb0808838a9f1f850b6c1ffe88465254222/packages/react-aria-components/src/utils.tsx#L180 +/** Consume a value from a context & merge it with the provided props */ +export function useContextPropsV2( + context: React.Context>, props: T, ref: React.Ref ): [T, React.RefObject] { - const { ref: contextRef, ...contextProps } = useContext(context); + const ctx = useSlottedContext(context, (props as SlotProps).slot) || {} + const { ref: contextRef, ...contextProps } = ctx as WithRef; const mergedRef = useObjectRef(mergeRefs(ref, contextRef!)); diff --git a/packages/atomic-elements/src/hooks/useSlottedContext.ts b/packages/atomic-elements/src/hooks/useSlottedContext.ts new file mode 100644 index 00000000..9b3d2846 --- /dev/null +++ b/packages/atomic-elements/src/hooks/useSlottedContext.ts @@ -0,0 +1,44 @@ +import { useContext } from 'react'; + +export type SlottedContextValue = T | { + slots?: Record +} | null | undefined; + +export interface SlotProps { + slot?: string | null; +} + +export const DEFAULT_SLOT = Symbol("DEFAULT_SLOT"); + +// from: https://github.com/adobe/react-spectrum/blob/c6bd2cb0808838a9f1f850b6c1ffe88465254222/packages/react-aria-components/src/utils.tsx#L157 +/** Consume a context OR consume a slotted context value */ +export function useSlottedContext( + context: React.Context>, + slot?: string | null +): T | null | undefined { + const ctx = useContext(context); + if (slot === null) { + // An explicit `null` slot means don't use context. + return null; + } + + // If slots are provided by the context we need to consume the correct slot + if (ctx && typeof ctx === "object" && "slots" in ctx && ctx.slots) { + const allSlots = Object.keys(ctx.slots); + + if (!slot && !ctx.slots[DEFAULT_SLOT]) { + throw new Error(`A slot prop is required. Valid slot names are ${allSlots.join(", ")}`); + } + + const key = slot || DEFAULT_SLOT; + + if (!ctx.slots[key]) { + throw new Error(`Invalid slot name. Valid slot names are ${allSlots.join(", ")}`); + } + + return ctx.slots[key]; + } + + // The context doesn't provide a slot, consume the context as is + return ctx as T; +} diff --git a/packages/atomic-elements/src/utils/index.ts b/packages/atomic-elements/src/utils/index.ts index 7a7bb8b1..cd02384a 100644 --- a/packages/atomic-elements/src/utils/index.ts +++ b/packages/atomic-elements/src/utils/index.ts @@ -1,9 +1,8 @@ +import { SlottedContextValue } from "@hooks/useSlottedContext"; import React from "react"; -export function createComponentContext( - defaultValue: Partial = {} -) { - return React.createContext }>>( - defaultValue - ); +export function createComponentContext() { + return React.createContext>(null as any); } + + diff --git a/playground/src/Playground.tsx b/playground/src/Playground.tsx index a7f343e9..40799571 100644 --- a/playground/src/Playground.tsx +++ b/playground/src/Playground.tsx @@ -14,7 +14,7 @@ import Links from "./tabs/Links"; import { Home } from "./tabs/Home"; function Playground() { - const [currentTab, setCurrentTab] = useState("home"); + const [currentTab, setCurrentTab] = useState("aria"); return (
diff --git a/playground/src/tabs/Aria.tsx b/playground/src/tabs/Aria.tsx index e9645925..7d77cf09 100644 --- a/playground/src/tabs/Aria.tsx +++ b/playground/src/tabs/Aria.tsx @@ -1,5 +1,5 @@ import React from "react"; export default function Aria() { - return
Hi There
; + return
Hi there
; } diff --git a/playground/vite.config.ts b/playground/vite.config.ts index 6fb1dac1..ae44385c 100644 --- a/playground/vite.config.ts +++ b/playground/vite.config.ts @@ -6,4 +6,16 @@ import path from "path"; export default defineConfig({ root: "playground", plugins: [react()], + resolve: { + alias: { + "@components": path.resolve( + __dirname, + "../packages/atomic-elements/src/components" + ), + "@hooks": path.resolve(__dirname, "../packages/atomic-elements/src/hooks"), + "@styles": path.resolve(__dirname, "../packages/atomic-elements/src/styles"), + "@utils": path.resolve(__dirname, "../packages/atomic-elements/src/utils"), + "@sb": path.resolve(__dirname, "../.storybook/utils"), + }, + }, });