diff --git a/src/components/Avatar/Avatar.tsx b/src/components/Avatar/Avatar.tsx index 4db51ecd..f3083424 100644 --- a/src/components/Avatar/Avatar.tsx +++ b/src/components/Avatar/Avatar.tsx @@ -21,6 +21,8 @@ import { SuspenseImg } from "../../utils/SuspenseImg"; import styles from "./Avatar.module.css"; import { useIdColorHash } from "./useIdColorHash"; +export type OnAvatarErrorHandler = React.ComponentProps["onError"]; + type AvatarProps = ( | JSX.IntrinsicElements["button"] | JSX.IntrinsicElements["span"] @@ -62,7 +64,7 @@ type AvatarProps = ( /** * Callback when the image has failed to load. */ - onError?: React.ComponentProps["onError"]; + onError?: OnAvatarErrorHandler; }; /** diff --git a/src/components/FilterChips/FilterChip.module.css b/src/components/FilterChips/FilterChip.module.css new file mode 100644 index 00000000..65238a2e --- /dev/null +++ b/src/components/FilterChips/FilterChip.module.css @@ -0,0 +1,17 @@ +.filterChip { + /* disable padding here and move to the button inside */ + padding: 0; +} + +.filterChip:hover { + background-color: var(--cpd-color-gray-500); +} + +.filterChipButton { + align-items: center; + display: flex; + gap: var(--cpd-space-1x); + /* apply compound badge padding here */ + padding: var(--cpd-space-1x) var(--cpd-space-3x); + white-space: nowrap; +} diff --git a/src/components/FilterChips/FilterChip.stories.tsx b/src/components/FilterChips/FilterChip.stories.tsx new file mode 100644 index 00000000..dbf0516a --- /dev/null +++ b/src/components/FilterChips/FilterChip.stories.tsx @@ -0,0 +1,45 @@ +/* +Copyright 2023 New Vector Ltd + +Licensed 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 CONDITIONS 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 { Meta, StoryFn } from "@storybook/react"; + +import { FilterChip } from "./FilterChip"; +import { FilterChipsContainer } from "./FilterChipsContainer"; + +export default { + title: "FilterChip", + component: FilterChip, + tags: ["autodocs"], + argTypes: { + label: String, + }, + args: { + label: "hello", + }, +} as Meta; + +const Template: StoryFn = (args) => ( + + + + +); + +export const Primary = Template.bind({}); +Primary.args = {}; diff --git a/src/components/FilterChips/FilterChip.tsx b/src/components/FilterChips/FilterChip.tsx new file mode 100644 index 00000000..3c9e8cbe --- /dev/null +++ b/src/components/FilterChips/FilterChip.tsx @@ -0,0 +1,20 @@ +import { Badge } from '../Badge/Badge'; +import Close20 from '@vector-im/compound-design-tokens/icons/close.svg' +import React from 'react'; +import styles from './FilterChip.module.css'; + +interface FilterChipProps { + label: string; + onClick?: () => void; +} + +export const FilterChip: React.FC = ({ label, onClick }) => { + return ( + +
+ {label} + +
+
+ ); +}; diff --git a/src/components/FilterChips/FilterChipsContainer.module.css b/src/components/FilterChips/FilterChipsContainer.module.css new file mode 100644 index 00000000..8bd5bcbb --- /dev/null +++ b/src/components/FilterChips/FilterChipsContainer.module.css @@ -0,0 +1,5 @@ +.filterChipsContainer { + display: flex; + flex-flow: wrap; + gap: var(--cpd-space-2x); +} diff --git a/src/components/FilterChips/FilterChipsContainer.tsx b/src/components/FilterChips/FilterChipsContainer.tsx new file mode 100644 index 00000000..2a3cb5f4 --- /dev/null +++ b/src/components/FilterChips/FilterChipsContainer.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import styles from './FilterChipsContainer.module.css'; + +interface FilterChipsContainerProps { + children: React.ReactNode; +} + +export const FilterChipsContainer: React.FC = ({ children }) => { + return
{children}
; +}; diff --git a/src/components/Table/Table.module.css b/src/components/Table/Table.module.css new file mode 100644 index 00000000..c061b76a --- /dev/null +++ b/src/components/Table/Table.module.css @@ -0,0 +1,22 @@ +.table { + border-collapse: collapse; + margin-bottom: var(--cpd-space-13x); + width: 100%; +} + +.tableHeader { + border-bottom: var(--cpd-border-width-1) solid var(--cpd-color-border-interactive-secondary); + color: var(--cpd-color-text-secondary); + font-size: var(--cpd-font-size-body-xs); + font-weight: var(--cpd-font-weight-regular); + padding: var(--cpd-space-4x); + text-transform: uppercase; +} + +.tableHeaderCentred { + text-align: center; +} + +.tableHeaderCheckbox { + width: 24px; +} diff --git a/src/components/Table/Table.stories.tsx b/src/components/Table/Table.stories.tsx new file mode 100644 index 00000000..aa2145c9 --- /dev/null +++ b/src/components/Table/Table.stories.tsx @@ -0,0 +1,136 @@ +/* +Copyright 2023 New Vector Ltd + +Licensed 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 CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { useState } from "react"; +import { Meta, StoryFn } from "@storybook/react"; + +import { Table, TableSpec } from "./Table"; +import { TableCell } from "./TableCell"; +import { TableFilter, TableHeadline } from "."; + +export default { + title: "Table", + component: Table, + tags: ["autodocs"], + argTypes: { + }, + args: { + }, +} as Meta; + +interface SimpleTableItem { + name: string, + flavor: string, + [value: string]: string; +} + +const Template: StoryFn = (args) => { + const tableSpec: TableSpec = { + columns: [ + { + id: 'name', + cell: ({ item }) => ( + { item.name } + ), + title: 'Name', + }, + { + id: 'flavor', + cell: ({ item }) => {item.flavor}, + title: 'Flavor', + }, + ], + getItemId: (i) => i.name, + }; + + const items = [{ + name: 'Chili 🌶️', + flavor: 'Spicy', + }]; + + return +}; + +export const Primary = Template.bind({}); +Primary.args = {}; + +export const WithHeader: StoryFn = (args) => { + const tableSpec: TableSpec = { + columns: [ + { + id: 'name', + cell: ({ item }) => ( + { item.name } + ), + title: 'Name', + }, + { + id: 'flavor', + cell: ({ item }) => {item.flavor}, + title: 'Flavor', + }, + ], + getItemId: (i) => i.name, + }; + + const items = [{ + name: 'Chili 🌶️', + flavor: 'Spicy', + }]; + return <> + +
+ ; +}; + +export const WithHeaderAndFilter: StoryFn = (args) => { + const tableSpec: TableSpec = { + columns: [ + { + id: 'name', + cell: ({ item }) => ( + { item.name } + ), + title: 'Name', + }, + { + id: 'flavor', + cell: ({ item }) => {item.flavor}, + title: 'Flavor', + }, + ], + getItemId: (i) => i.name, + }; + + const items = [{ + name: 'Chili 🌶️', + flavor: 'Spicy', + }]; + + const [filterForSauces, setFilterForSauces] = useState(false); + + const filters: TableFilter[] = [{ + id: 'sauces', + disabled: false, + label: 'Only Sauces', + value: filterForSauces, + setValue: () => setFilterForSauces(!filterForSauces), + }]; + return <> + +
+ ; +}; \ No newline at end of file diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx new file mode 100644 index 00000000..ad9e2cd0 --- /dev/null +++ b/src/components/Table/Table.tsx @@ -0,0 +1,208 @@ +import React, { useCallback, useMemo } from 'react'; +import classNames from 'classnames'; + +import styles from './Table.module.css'; +import { OrderByDirection } from '../../hooks/useOrderBy'; +import { MultiSelect, SelectAllMode } from '../../hooks/useMultiSelect'; +import { TableMultiSelectCheckbox } from './TableMutliSelectCheckbox'; +import { TableRow } from './TableRow'; + + +/** + * Helper Id to make checkboxes etc. unique accross tables. + */ +let tableId = 1; + +export interface SortOption { + /** + * Label of the sort option, e.g. Username A-Z, Username Z-A + */ + label: string; + /** + * The order by field id. Undefined means no specific order. + */ + orderById: string; + /** + * The order by direction. + */ + orderByDirection: OrderByDirection; +} + +export interface TableColumnSpec { + /** + * Unique ID for the column. + */ + id: string; + /** + * Human-readable title of the column. + */ + title: string; + /** + * A component to render for cells in this column. + */ + cell: React.ComponentType<{ item: I }>; +} + +export interface TableSpec { + /** + * Returns a value that uniquely identifies the item. + */ + getItemId: (item: I) => string | number; + columns: TableColumnSpec[]; + sortOptions?: SortOption[]; + /** + * Optional component to render a table row. + */ + Row?: typeof TableRow; +} + +interface TableProps { + /** + * List of items to be displayed in the table. + */ + items: Array; + /** + * Optional callback that is called when an item is clicked. + */ + onItemClicked?: (item: I) => void; + /** + * Spec of the table structure. + */ + spec: TableSpec; + multiSelect?: MultiSelect; +} + +const determineSelectAllState = ( + currentItems: Array, + getItemId: (item: I) => string | number, + multiSelect?: MultiSelect, +): { isSelectAllChecked: boolean; isSelectedAllIndeterminate: boolean } => { + if (!multiSelect) return { isSelectAllChecked: false, isSelectedAllIndeterminate: false }; + + if (multiSelect.selectAllMode === SelectAllMode.FLAG) { + // Select all should be triggered by a flag. + // The "select all" checkbox should be checked. + // It is only in indeterminate state if there are selected items + // (selected items should be subtracted from "all"). + return { + isSelectAllChecked: multiSelect.allSelected, + isSelectedAllIndeterminate: multiSelect.selectedItems.size > 0, + }; + } + + // Select all collects items from different pages. + // It should be checked if any item is selected. + // It is only indeterminate if not all items of the current page are selected. + const isSelectAllChecked = multiSelect.selectedItems.size > 0; + return { + isSelectAllChecked, + isSelectedAllIndeterminate: + isSelectAllChecked && + !currentItems.every((i) => { + return multiSelect.selectedItems.has(getItemId(i)); + }), + }; +}; + +/** + * Simple table component. Provide at least a table spec and some items to display a table. + */ +export const Table = ({ + items, + onItemClicked, + spec, + className, + multiSelect, + ...props +}: TableProps & React.ComponentPropsWithoutRef<'table'>) => { + const Row = spec.Row || TableRow; + + const thisTableId = useMemo(() => { + return tableId++; + }, []); + + const rows = items.map((item) => { + const rowKey = spec.getItemId(item); + return ( + + ); + }); + + const headers = spec.columns.map((columnSpec) => { + return ( + + ); + }); + + const { isSelectAllChecked, isSelectedAllIndeterminate } = determineSelectAllState( + items, + spec.getItemId, + multiSelect, + ); + + const handleSelectAllChange = useCallback(() => { + if (!multiSelect) return; + + if (multiSelect.selectAllMode === SelectAllMode.FLAG) { + // Select all is in "flag" mode: + // Toggle the flag and deselect all items. + multiSelect.setAllSelected(!multiSelect.allSelected); + multiSelect.setSelectedItems(new Map()); + return; + } + + if (!isSelectAllChecked || isSelectedAllIndeterminate) { + // None or not all current items are selected: Select current items. + const newSelectedItems = new Map(multiSelect.selectedItems); + items.forEach((i) => { + newSelectedItems.set(spec.getItemId(i), i); + }); + multiSelect.setSelectedItems(newSelectedItems); + return; + } + + // Deselect current items. + const newSelectedItems = new Map(multiSelect.selectedItems); + items.forEach((i) => { + newSelectedItems.delete(spec.getItemId(i)); + }); + multiSelect.setSelectedItems(newSelectedItems); + }, [isSelectAllChecked, isSelectedAllIndeterminate, items, multiSelect, spec]); + + if (multiSelect) { + // add the "select all" checkbox to the table header + headers.unshift( + , + ); + } + + return ( +
+ {columnSpec.title} + + +
+ + {headers} + + {rows} +
+ ); +}; \ No newline at end of file diff --git a/src/components/Table/TableAvatarCell.module.css b/src/components/Table/TableAvatarCell.module.css new file mode 100644 index 00000000..04dea705 --- /dev/null +++ b/src/components/Table/TableAvatarCell.module.css @@ -0,0 +1,13 @@ +.avatarCell { + align-items: center; + display: flex; + gap: var(--cpd-space-3x); +} + +.avatar { + flex-shrink: 0; +} + +.userId { + min-width: 0; +} diff --git a/src/components/Table/TableAvatarCell.tsx b/src/components/Table/TableAvatarCell.tsx new file mode 100644 index 00000000..9c920f7f --- /dev/null +++ b/src/components/Table/TableAvatarCell.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { Avatar, OnAvatarErrorHandler } from '../Avatar/Avatar'; +import { TableCell } from './TableCell'; +import styles from './TableAvatarCell.module.scss'; + +interface TableAvatarCellProps { + name: string; + avatarId: string; + avatarName: string; + src?: string | undefined; + disabled?: boolean; + onError: OnAvatarErrorHandler, +} + +/** + * Table cell that displays an avatar and a name. + * The avatar is expected to be 24px in size. + */ +export const TableAvatarCell: React.FC = ({ + name, + avatarId, + avatarName, + src, + disabled, + onError +}) => { + + return ( + +
+ +
{name}
+
+
+ ); +}; diff --git a/src/components/Table/TableCell.module.css b/src/components/Table/TableCell.module.css new file mode 100644 index 00000000..d21fe37e --- /dev/null +++ b/src/components/Table/TableCell.module.css @@ -0,0 +1,25 @@ +.tableCell { + padding: var(--cpd-space-4x); + border-bottom: var(--cpd-border-width-1) solid var(--cpd-color-border-interactive-secondary); + + color: var(--cpd-color-text-primary); + font-size: var(--cpd-font-size-body-sm); +} + +.tableCellPrimary { + color: var(--cpd-color-text-success-primary); + font-size: var(--cpd-font-size-body-md); + font-weight: var(--cpd-font-weight-semibold); +} + +.tableCellPrimary.tableCellDisabled { + color: var(--cpd-color-text-disabled); +} + +.tableCellCheckbox { + width: 24px; +} + +.tableCellCentred { + text-align: center; +} diff --git a/src/components/Table/TableCell.tsx b/src/components/Table/TableCell.tsx new file mode 100644 index 00000000..37125479 --- /dev/null +++ b/src/components/Table/TableCell.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import classNames from 'classnames'; + +import styles from './TableCell.module.css'; + +interface TableCellProps extends React.ComponentPropsWithoutRef<'td'> { + /** + * Whether the cell content should be centred. + */ + centred?: boolean; + /** + * Kind of the cell. Has impact on the cell styles. + */ + kind?: 'primary' | 'checkbox'; + disabled?: boolean; +} + +export const TableCell: React.FC = ({ + kind, + disabled, + className, + children, + centred, + width, + ...props +}) => { + return ( + + {children} + + ); +}; diff --git a/src/components/Table/TableFilterChips.tsx b/src/components/Table/TableFilterChips.tsx new file mode 100644 index 00000000..f699a7b8 --- /dev/null +++ b/src/components/Table/TableFilterChips.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { TableFilter } from '.'; +import { FilterChip } from '../FilterChips/FilterChip'; +import { FilterChipsContainer } from '../FilterChips/FilterChipsContainer'; + +interface TableFilterChipsProps { + filters: TableFilter[]; +} + +export const TableFilterChips: React.FC = ({ filters }) => { + const filterChips = filters + .filter((f) => f.value) + .map((f) => { + const handleClick = () => { + f.setValue(false); + }; + return ; + }); + + return {filterChips}; +}; diff --git a/src/components/Table/TableFiltersDropdown.module.css b/src/components/Table/TableFiltersDropdown.module.css new file mode 100644 index 00000000..ed6372df --- /dev/null +++ b/src/components/Table/TableFiltersDropdown.module.css @@ -0,0 +1,49 @@ +.item { + align-items: center; + display: flex; + gap: var(--cpd-space-2x); +} + +.iconPlaceholder { + width: 24px; +} + +button.toggle[data-size="sm"] { + background: none; + color: var(--cpd-color-text-primary); + padding: var(--cpd-space-5x); + transition: background-color 100ms linear; +} + +button.toggle[data-size="sm"].open[data-size="sm"] { + background: var(--cpd-color-border-interactive-secondary); +} + +button.toggle[data-size="sm"]:hover { + animation-duration: 250ms; + background: var(--cpd-color-border-interactive-secondary); +} + +.floatingMenu { + z-index: 1; + position: absolute; + top: 110%; + left: -35%; + direction:rtl; +} + +.floatingMenu > * { + direction:ltr; +} + +.floatingMenuWrapper { + position: relative; +} + +.menuitem { + margin-left: 1em; +} +.menuitem > input { + color: red; + background-color: black; +} \ No newline at end of file diff --git a/src/components/Table/TableFiltersDropdown.tsx b/src/components/Table/TableFiltersDropdown.tsx new file mode 100644 index 00000000..c96bacfb --- /dev/null +++ b/src/components/Table/TableFiltersDropdown.tsx @@ -0,0 +1,73 @@ +import React, { MouseEvent, useCallback, useEffect, useRef, useState } from 'react'; +import { FloatingMenu } from '../Menu/FloatingMenu'; + +import { TableFilter } from '.'; +import Check24 from '@vector-im/compound-design-tokens/icons/check.svg'; +import Filter20 from '@vector-im/compound-design-tokens/icons/filter.svg'; +import styles from './TableFiltersDropdown.module.css'; +import { MenuItem } from '../MenuItem/MenuItem'; +import { Button } from '../Button/Button'; +import classNames from 'classnames'; + +interface TableFiltersDropdownProps { + filters: TableFilter[]; +} + +interface TableFilterDropdownItemProps { + filter: TableFilter; +} + +const TableFilterDropdownItem: React.FC = ({ filter }) => { + const handleClick = useCallback(() => { + filter.setValue(!filter.value); + }, [filter]); + return ( +
+ + +
+ ); +}; + +export const TableFiltersDropdown: React.FC = ({ filters }) => { + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + + const toggleDropDown = useCallback(() => { + setIsDropdownOpen(!isDropdownOpen); + }, [setIsDropdownOpen, isDropdownOpen]); + + const items = filters.map((filter) => ); + + const buttonClassName = classNames(styles.toggle, { + [styles.open]: isDropdownOpen, + }) + + const buttonRef = useRef(null); + const menuRef = useRef(null); + // Track clicks outside of dropdown + useEffect(() => { + const menu = menuRef.current; + const button = buttonRef.current; + if (!menu || !button) { + return; + } + if (!isDropdownOpen) { + return; + } + const listener = (evt: Event) => { + const target = evt.target as HTMLElement; + if (target !== button && !button.contains(target) && !menu.contains(target)) { + setIsDropdownOpen(false); + } + }; + menu.ownerDocument.addEventListener('click', listener); + return () => menu.ownerDocument.removeEventListener('click', listener); + }, [isDropdownOpen, menuRef, buttonRef]); + + return <> + + {isDropdownOpen ? + {items} + : null} + ; +}; diff --git a/src/components/Table/index.tsx b/src/components/Table/index.tsx new file mode 100644 index 00000000..a6e59cb0 --- /dev/null +++ b/src/components/Table/index.tsx @@ -0,0 +1,15 @@ +export * from './TableCell'; +export * from './TableHeadline'; +export * from './Table'; + +export interface TableFilter { + id: string; + /** + * Whether to disable the filter item. + */ + disabled?: boolean; + label: string; + sublabel?: string; + value: boolean; + setValue(newValue: boolean): void; +} diff --git a/src/hooks/useMultiSelect.tsx b/src/hooks/useMultiSelect.tsx new file mode 100644 index 00000000..46e723f7 --- /dev/null +++ b/src/hooks/useMultiSelect.tsx @@ -0,0 +1,43 @@ +import { useState } from 'react'; + +export enum SelectAllMode { + /** + * The "allSelected" flag should be set. + * Selecting further items should deselect them. + */ + FLAG, + /** + * Select all should only select items and not set the "allSelected" flag. + */ + ITEMS, +} + +export interface MultiSelect { + selectAllMode: SelectAllMode; + /** + * Whether all items are selected. + */ + allSelected: boolean; + setAllSelected: (allSelected: boolean) => void; + /** + * Map of selected items: Unique identifier of the item in the map -> item. + * If "all" is selected, this list is inverted and contains the elements not selected. + */ + selectedItems: Map; + /** + * Setter for selected item IDs. + */ + setSelectedItems: (selectedItems: Map) => void; +} + +export const useMultiSelect = (selectAllMode = SelectAllMode.FLAG): MultiSelect => { + const [selectedItems, setSelectedItems] = useState>(new Map()); + const [allSelected, setAllSelected] = useState(false); + return { + selectAllMode, + selectedItems, + setSelectedItems, + allSelected, + setAllSelected, + }; +}; diff --git a/src/hooks/useOrderBy.ts b/src/hooks/useOrderBy.ts new file mode 100644 index 00000000..0e1ba76f --- /dev/null +++ b/src/hooks/useOrderBy.ts @@ -0,0 +1,32 @@ +import { useState } from 'react'; + +export type OrderByDirection = 'f' | 'b'; + +export interface OrderByProps { + /** + * The order by field id. Undefined means no specific order. + */ + orderById: string | undefined; + /** + * Setter for the oder by field id. + * It does not have to be a field of the table spec. + * It can be any field supported by the underlying API. + */ + setOrderById: (fieldId: string) => void; + /** + * The order by direction. + */ + orderByDirection?: OrderByDirection; + setOrderByDirection: (orderByDirection: OrderByDirection) => void; +} + +export const useOrderBy = (defaultOrderBy: string | undefined = undefined): OrderByProps => { + const [orderById, setOrderById] = useState(defaultOrderBy); + const [orderByDirection, setOrderByDirection] = useState('f'); + return { + orderById, + setOrderById, + orderByDirection, + setOrderByDirection, + }; +}; diff --git a/src/index.ts b/src/index.ts index 3cfdb7c6..d1a87299 100644 --- a/src/index.ts +++ b/src/index.ts @@ -42,6 +42,7 @@ export { Search } from "./components/Search/Search"; export { Separator } from "./components/Separator/Separator"; export { ToggleMenuItem } from "./components/MenuItem/ToggleMenuItem"; export { Tooltip } from "./components/Tooltip/Tooltip"; +export * from "./components/Table"; export { TextControl,