diff --git a/packages/components/src/components/Button/Button.tsx b/packages/components/src/components/Button/Button.tsx index b5f2ae1fa8..9e8c76b371 100644 --- a/packages/components/src/components/Button/Button.tsx +++ b/packages/components/src/components/Button/Button.tsx @@ -1,6 +1,9 @@ -import React from 'react'; +import React, { forwardRef, ForwardedRef } from 'react'; import { Button as RACButton, ButtonProps } from 'react-aria-components'; -export function Button(props: ButtonProps) { - return ; -} +export const Button = forwardRef(function _Button( + props: ButtonProps, + ref: ForwardedRef, +) { + return ; +}); diff --git a/packages/components/src/components/Checkbox/Checkbox.scss b/packages/components/src/components/Checkbox/Checkbox.scss new file mode 100644 index 0000000000..acc2292238 --- /dev/null +++ b/packages/components/src/components/Checkbox/Checkbox.scss @@ -0,0 +1,97 @@ +.react-aria-Checkbox { + --selected-color: var(--highlight-background); + --selected-color-pressed: var(--highlight-background-pressed); + --checkmark-color: var(--highlight-foreground); + + display: flex; + align-items: center; + color: var(--text-color); + font-size: 1.143rem; + forced-color-adjust: none; + gap: 0.571rem; + + .checkbox { + display: flex; + width: 1.143rem; + height: 1.143rem; + align-items: center; + justify-content: center; + border: 2px solid var(--border-color); + border-radius: 4px; + transition: all 200ms; + } + + svg { + width: 1rem; + height: 1rem; + fill: none; + stroke: var(--checkmark-color); + stroke-dasharray: 22px; + stroke-dashoffset: 66; + stroke-width: 3px; + transition: all 200ms; + } + + &[data-pressed] .checkbox { + border-color: var(--border-color-pressed); + } + + &[data-focus-visible] .checkbox { + outline: 2px solid var(--focus-ring-color); + outline-offset: 2px; + } + + &[data-selected], + &[data-indeterminate] { + .checkbox { + border-color: var(--selected-color); + background: var(--selected-color); + } + + &[data-pressed] .checkbox { + border-color: var(--selected-color-pressed); + background: var(--selected-color-pressed); + } + + svg { + stroke-dashoffset: 44; + } + } + + &[data-indeterminate] { + & svg { + fill: var(--checkmark-color); + stroke: none; + } + } + + &[data-invalid] { + .checkbox { + --checkmark-color: var(--gray-50); + border-color: var(--color-invalid); + } + + &[data-pressed] .checkbox { + border-color: var(--color-pressed-invalid); + } + + &[data-selected], + &[data-indeterminate] { + .checkbox { + background: var(--color-invalid); + } + + &[data-pressed] .checkbox { + background: var(--color-pressed-invalid); + } + } + } + + &[data-disabled] { + color: var(--text-color-disabled); + + .checkbox { + border-color: var(--border-color-disabled); + } + } +} diff --git a/packages/components/src/components/Container/Container.tsx b/packages/components/src/components/Container/Container.tsx index 622c97d052..49693c3594 100644 --- a/packages/components/src/components/Container/Container.tsx +++ b/packages/components/src/components/Container/Container.tsx @@ -10,9 +10,9 @@ type ContainerProps = { /** Additional classes. */ className: string; /** Layout size */ - layout: boolean; + layout?: boolean; /** Narrow size. */ - narrow: boolean; + narrow?: boolean; }; export const Container = (props: ContainerProps) => { diff --git a/packages/components/src/components/Popover/Popover.tsx b/packages/components/src/components/Popover/Popover.tsx index d115987565..7718d751c7 100644 --- a/packages/components/src/components/Popover/Popover.tsx +++ b/packages/components/src/components/Popover/Popover.tsx @@ -1,24 +1,42 @@ import React from 'react'; import { - Dialog, OverlayArrow, Popover as RACPopover, PopoverProps as RACPopoverProps, } from 'react-aria-components'; +import { Dialog } from '../Dialog/Dialog'; export interface PopoverProps extends Omit { children: React.ReactNode; + /** Mandatory when children don't contain a or dialogAriaLabelledBy */ + dialogAriaLabel?: string; + /** Mandatory when children don't contain a or dialogAriaLabel */ + dialogAriaLabelledby?: string; + arrow?: boolean; } -export function Popover({ children, ...props }: PopoverProps) { +export function Popover({ + children, + dialogAriaLabel, + dialogAriaLabelledby, + arrow, + ...props +}: PopoverProps) { return ( - - - - - - {children} + {arrow && ( + + + + + + )} + + {children} + ); } diff --git a/packages/components/src/components/Table/Column.tsx b/packages/components/src/components/Table/Column.tsx new file mode 100644 index 0000000000..180f28a22e --- /dev/null +++ b/packages/components/src/components/Table/Column.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { type ColumnProps, Column as RACColumn } from 'react-aria-components'; + +export function Column(props: ColumnProps) { + return ( + + {({ allowsSorting, sortDirection }) => ( + <> + {props.children} + {allowsSorting && ( + + )} + + )} + + ); +} diff --git a/packages/components/src/components/Table/Row.tsx b/packages/components/src/components/Table/Row.tsx new file mode 100644 index 0000000000..b91645d734 --- /dev/null +++ b/packages/components/src/components/Table/Row.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { + type RowProps, + Row as RACRow, + Cell, + Collection, + useTableOptions, + Button, +} from 'react-aria-components'; +import { Checkbox } from '../Checkbox/Checkbox'; +import { DraggableIcon } from '../Icons'; + +export function Row({ + id, + columns, + children, + ...otherProps +}: RowProps) { + let { selectionBehavior, allowsDragging } = useTableOptions(); + + return ( + + {allowsDragging && ( + + + + )} + {selectionBehavior === 'toggle' && ( + + + + )} + {children} + + ); +} diff --git a/packages/components/src/components/Table/Table.stories.tsx b/packages/components/src/components/Table/Table.stories.tsx index 82f5f9270a..2b8d7bd71e 100644 --- a/packages/components/src/components/Table/Table.stories.tsx +++ b/packages/components/src/components/Table/Table.stories.tsx @@ -1,12 +1,14 @@ import React from 'react'; -import { Table, TableHeader, Row, Column } from './Table'; -import { Cell, TableBody } from 'react-aria-components'; - +import { Cell, TableBody, useDragAndDrop } from 'react-aria-components'; +import { Table } from './Table'; +import { TableHeader } from './TableHeader'; +import { Column } from './Column'; +import { Row } from './Row'; import type { Meta, StoryObj } from '@storybook/react'; import '../../styles/basic/Table.css'; -const meta: Meta = { +const meta = { title: 'Components/Table', component: Table, parameters: { @@ -19,8 +21,101 @@ export default meta; type Story = StoryObj; export const Default: Story = { + args: { + 'aria-label': 'Files', + columns: [ + { name: 'Name', id: 'name', isRowHeader: true }, + { name: 'Type', id: 'type' }, + { name: 'Date Modified', id: 'date' }, + ], + rows: [ + { id: '1', name: 'Games', date: '6/7/2020', type: 'File folder' }, + { id: '2', name: 'Program Files', date: '4/7/2021', type: 'File folder' }, + { id: '3', name: 'bootmgr', date: '11/20/2010', type: 'System file' }, + { id: '4', name: 'log.txt', date: '1/18/2016', type: 'Text Document' }, + ], + }, +}; + +/** + * For more fine grained control over the selection mode, + * see https://react-spectrum.adobe.com/react-aria/Table.html#single-selection + */ +export const SingleSelection: Story = { + args: { + ...Default.args, + selectionMode: 'single', + }, +}; + +/** + * For more fine grained control over the selection mode, + * see https://react-spectrum.adobe.com/react-aria/Table.html#multiple-selection + */ +export const MultipleSelection: Story = { + args: { + ...Default.args, + selectionMode: 'multiple', + }, +}; + +/** + * In order to make the rows draggable, you need to pass + * the `dragAndDropHooks` prop to the Table component. + * This prop has to be generated using the `useDragAndDrop` hook, + * passing the `getItems` and `onReorder` functions. + * + * See here for more information: + * https://react-spectrum.adobe.com/react-aria/Table.html#drag-and-drop + */ +export const DraggableRows: Story = { + decorators: [ + (Story) => { + const { dragAndDropHooks } = useDragAndDrop({ + getItems: (keys) => + [...keys].map((key) => ({ + 'text/plain': key.toString(), + })), + onReorder(e) { + if (e.target.dropPosition === 'before') { + console.log('moveBefore: key ', e.target.key, ', keys ', e.keys); + } else if (e.target.dropPosition === 'after') { + console.log('moveAfter: key ', e.target.key, ', keys ', e.keys); + } + }, + }); + + return ( + <> + + + ); + }, + ], + args: { + ...Default.args, + }, +}; + +/** + * Use the `resizableColumns` prop to make the columns resizable. + */ +export const ResizableColumns: Story = { + args: { + ...Default.args, + resizableColumns: true, + }, + parameters: { + layout: 'fullscreen', + }, +}; + +/** + * Create a table with manually inserted cells. + */ +export const Manual: Story = { render: (args: any) => ( - +
Name Type @@ -46,7 +141,6 @@ export const Default: Story = {
), args: { - onRowAction: null, - // selectionMode: "multiple", + 'aria-label': 'Files', }, }; diff --git a/packages/components/src/components/Table/Table.tsx b/packages/components/src/components/Table/Table.tsx index 1fb3074d96..0a2c1d886d 100644 --- a/packages/components/src/components/Table/Table.tsx +++ b/packages/components/src/components/Table/Table.tsx @@ -1,83 +1,87 @@ -import React from 'react'; +import React, { type ReactNode } from 'react'; import { - Button, - Cell, - Collection, - Column as RACColumn, - ColumnProps, - Row as RACRow, - RowProps, + type TableProps as RACTableProps, + ResizableTableContainer, + ColumnResizer, Table as RACTable, - TableHeader as RACTableHeader, - TableHeaderProps, - TableProps, - useTableOptions, + TableBody, + Cell, } from 'react-aria-components'; +import { TableHeader } from './TableHeader'; +import { Column } from './Column'; +import { Row } from './Row'; -import { Checkbox } from '../Checkbox/Checkbox'; - -export function Table(props: TableProps) { - return ; +interface ColumnType { + id: string; + name: string; + isRowHeader?: boolean; + // TODO support width constraints for resizable columns } -export function Column(props: ColumnProps) { - return ( - - {({ allowsSorting, sortDirection }) => ( - <> - {props.children} - {allowsSorting && ( - - )} - - )} - - ); +interface RowType { + id: string; + [key: string]: ReactNode; // TODO can we make this more specific? } -export function TableHeader({ - columns, - children, -}: TableHeaderProps) { - let { selectionBehavior, selectionMode, allowsDragging } = useTableOptions(); - - return ( - - {/* Add extra columns for drag and drop and selection. */} - {allowsDragging && } - {selectionBehavior === 'toggle' && ( - - {selectionMode === 'multiple' && } - - )} - {children} - - ); +interface TableProps extends RACTableProps { + columns?: C[]; + rows?: R[]; + resizableColumns?: boolean; + // TODO maybe a custom "selectall" component? Is it doable with react-aria-components? } -export function Row({ - id, +/** + * A wrapper around the `react-aria-components` Table component. + * + * See https://react-spectrum.adobe.com/react-aria/Table.html + */ +export function Table({ columns, - children, + rows, + resizableColumns, ...otherProps -}: RowProps) { - let { selectionBehavior, allowsDragging } = useTableOptions(); +}: TableProps) { + let table = null; + if (Array.isArray(columns) && Array.isArray(rows)) { + table = ( + + + {(column) => ( + + {resizableColumns && ( +
+ + {column.name} + + +
+ )} + {!resizableColumns && column.name} +
+ )} +
+ + {(item) => ( + + {(column) => {item[column.id]}} + + )} + +
+ ); + } else { + table = ; + + if (Array.isArray(columns)) { + console.warn('The Table component was given columns but no rows'); + } else if (Array.isArray(rows)) { + console.warn('The Table component was given rows but no columns'); + } + } - return ( - - {allowsDragging && ( - - - - )} - {selectionBehavior === 'toggle' && ( - - - - )} - {children} - - ); + if (resizableColumns) { + return {table}; + } else { + return table; + } } diff --git a/packages/components/src/components/Table/TableHeader.tsx b/packages/components/src/components/Table/TableHeader.tsx new file mode 100644 index 0000000000..0ee7494f21 --- /dev/null +++ b/packages/components/src/components/Table/TableHeader.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { + type TableHeaderProps, + TableHeader as RACTableHeader, + useTableOptions, + Collection, +} from 'react-aria-components'; +import { Checkbox } from '../Checkbox/Checkbox'; +import { Column } from './Column'; + +export function TableHeader({ + columns, + children, +}: TableHeaderProps) { + let { selectionBehavior, selectionMode, allowsDragging } = useTableOptions(); + + return ( + + {/* Add extra columns for drag and drop and selection. */} + {allowsDragging && } + {selectionBehavior === 'toggle' && ( + + {selectionMode === 'multiple' && } + + )} + {children} + + ); +} diff --git a/packages/components/src/components/Tooltip/Tooltip.tsx b/packages/components/src/components/Tooltip/Tooltip.tsx index 17213e6ce3..5dc99c9d48 100644 --- a/packages/components/src/components/Tooltip/Tooltip.tsx +++ b/packages/components/src/components/Tooltip/Tooltip.tsx @@ -7,16 +7,19 @@ import { export interface TooltipProps extends Omit { children: React.ReactNode; + arrow?: boolean; } -export function Tooltip({ children, ...props }: TooltipProps) { +export function Tooltip({ children, arrow, ...props }: TooltipProps) { return ( - - - - - + {arrow && ( + + + + + + )} {children} ); diff --git a/packages/components/src/components/quanta/Popover/Popover.stories.tsx b/packages/components/src/components/quanta/Popover/Popover.stories.tsx new file mode 100644 index 0000000000..656bdbc367 --- /dev/null +++ b/packages/components/src/components/quanta/Popover/Popover.stories.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { DialogTrigger } from 'react-aria-components'; +import { Button } from '../../Button/Button'; +import { QuantaPopover } from './Popover'; + +import type { Meta, StoryObj } from '@storybook/react'; + +import '../../../styles/basic/Popover.css'; +import '../../../styles/quanta/Popover.css'; + +const meta: Meta = { + title: 'Quanta/Popover', + component: QuantaPopover, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: (args: any) => ( + + + + + ), + args: { + children: 'Popover content', + }, +}; diff --git a/packages/components/src/components/quanta/Popover/Popover.tsx b/packages/components/src/components/quanta/Popover/Popover.tsx new file mode 100644 index 0000000000..55dfc9c5a7 --- /dev/null +++ b/packages/components/src/components/quanta/Popover/Popover.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { PopoverContext } from 'react-aria-components'; +import { Popover, PopoverProps } from '../../Popover/Popover'; + +export function QuantaPopover(props: PopoverProps) { + return ( + + + + ); +} diff --git a/packages/components/src/helpers/indexes.ts b/packages/components/src/helpers/indexes.ts new file mode 100644 index 0000000000..6d89c20876 --- /dev/null +++ b/packages/components/src/helpers/indexes.ts @@ -0,0 +1,37 @@ +export const indexes = { + // sortable_title: { label: 'Title', type: 'string', sort_on: 'sortable_title' }, + review_state: { label: 'Review state', type: 'string' }, + ModificationDate: { + label: 'Last modified', + type: 'date', + sort_on: 'modified', + }, + EffectiveDate: { + label: 'Publication date', + type: 'date', + sort_on: 'effective', + }, + id: { label: 'ID', type: 'string', sort_on: 'id' }, + ExpirationDate: { label: 'Expiration date', type: 'date' }, + CreationDate: { label: 'Created on', type: 'date', sort_on: 'created' }, + Subject: { label: 'Tags', type: 'array' }, + portal_type: { label: 'Type', type: 'string', sort_on: 'portal_type' }, + is_folderish: { label: 'Folder', type: 'boolean' }, + exclude_from_nav: { label: 'Excluded from navigation', type: 'boolean' }, + getObjSize: { label: 'Object Size', type: 'string' }, + last_comment_date: { label: 'Last comment date', type: 'date' }, + total_comments: { label: 'Total comments', type: 'number' }, + end: { label: 'End Date', type: 'date' }, + Description: { label: 'Description', type: 'string' }, + Creator: { label: 'Creator', type: 'string' }, + location: { label: 'Location', type: 'string' }, + UID: { label: 'UID', type: 'string' }, + start: { label: 'Start Date', type: 'date' }, + Type: { label: 'Type', type: 'string' }, +}; + +export const defaultIndexes: (keyof typeof indexes)[] = [ + 'review_state', + 'ModificationDate', + 'EffectiveDate', +]; diff --git a/packages/components/src/helpers/types.d.ts b/packages/components/src/helpers/types.d.ts new file mode 100644 index 0000000000..220a792ceb --- /dev/null +++ b/packages/components/src/helpers/types.d.ts @@ -0,0 +1,4 @@ +/** + * Get the type of the elements in an array + */ +export type ArrayElement = A extends readonly (infer T)[] ? T : never; diff --git a/packages/components/src/styles/_theme.scss b/packages/components/src/styles/_theme.scss new file mode 100644 index 0000000000..798e7c9a08 --- /dev/null +++ b/packages/components/src/styles/_theme.scss @@ -0,0 +1,125 @@ +/* color themes for dark and light modes, generated with Leonardo. + * Light: https://leonardocolor.io/theme.html?name=Light&config=%7B%22baseScale%22%3A%22Gray%22%2C%22colorScales%22%3A%5B%7B%22name%22%3A%22Gray%22%2C%22colorKeys%22%3A%5B%22%23000000%22%5D%2C%22colorspace%22%3A%22RGB%22%2C%22ratios%22%3A%5B%22-1.12%22%2C%221.45%22%2C%222.05%22%2C%223.02%22%2C%224.54%22%2C%227%22%2C%2210.86%22%5D%2C%22smooth%22%3Afalse%7D%2C%7B%22name%22%3A%22Purple%22%2C%22colorKeys%22%3A%5B%22%235e30eb%22%5D%2C%22colorspace%22%3A%22RGB%22%2C%22ratios%22%3A%5B%22-1.12%22%2C%221.45%22%2C%222.05%22%2C%223.02%22%2C%224.54%22%2C%227%22%2C%2210.86%22%5D%2C%22smooth%22%3Afalse%7D%2C%7B%22name%22%3A%22Red%22%2C%22colorKeys%22%3A%5B%22%23e32400%22%5D%2C%22colorspace%22%3A%22RGB%22%2C%22ratios%22%3A%5B%22-1.12%22%2C%221.45%22%2C%222.05%22%2C%223.02%22%2C%224.54%22%2C%227%22%2C%2210.86%22%5D%2C%22smooth%22%3Afalse%7D%5D%2C%22lightness%22%3A98%2C%22contrast%22%3A1%2C%22saturation%22%3A100%2C%22formula%22%3A%22wcag2%22%7D */ +:root { + --background-color: var(--air); + --gray-50: #ffffff; + --gray-100: #d0d0d0; + --gray-200: #afafaf; + --gray-300: #8f8f8f; + --gray-400: #717171; + --gray-500: #555555; + --gray-600: #393939; + --purple-100: #d5c9fa; + --purple-200: #b8a3f6; + --purple-300: #997cf2; + --purple-400: #7a54ef; + --purple-500: #582ddc; + --purple-600: #3c1e95; + --red-100: #f7c4ba; + --red-200: #f29887; + --red-300: #eb664d; + --red-400: #de2300; + --red-500: #a81b00; + --red-600: #731200; + --highlight-hover: rgb(0 0 0 / 0.07); + --highlight-pressed: rgb(0 0 0 / 0.15); + --shadow-exo: 0px 6px 12px 0px rgba(2, 19, 34, 0.06), + 0px 9px 18px 0px rgba(2, 19, 34, 0.18); +} + +/* Dark: https://leonardocolor.io/theme.html?name=Dark&config=%7B%22baseScale%22%3A%22Gray%22%2C%22colorScales%22%3A%5B%7B%22name%22%3A%22Gray%22%2C%22colorKeys%22%3A%5B%22%23000000%22%5D%2C%22colorspace%22%3A%22RGB%22%2C%22ratios%22%3A%5B%22-1.12%22%2C%221.45%22%2C%222.05%22%2C%223.02%22%2C%224.54%22%2C%227%22%2C%2210.86%22%5D%2C%22smooth%22%3Afalse%7D%2C%7B%22name%22%3A%22Purple%22%2C%22colorKeys%22%3A%5B%22%235e30eb%22%5D%2C%22colorspace%22%3A%22RGB%22%2C%22ratios%22%3A%5B%22-1.12%22%2C%221.45%22%2C%222.05%22%2C%223.02%22%2C%224.54%22%2C%227%22%2C%2210.86%22%5D%2C%22smooth%22%3Afalse%7D%2C%7B%22name%22%3A%22Red%22%2C%22colorKeys%22%3A%5B%22%23e32400%22%5D%2C%22colorspace%22%3A%22RGB%22%2C%22ratios%22%3A%5B%22-1.12%22%2C%221.45%22%2C%222.05%22%2C%223.02%22%2C%224.54%22%2C%227%22%2C%2210.86%22%5D%2C%22smooth%22%3Afalse%7D%5D%2C%22lightness%22%3A11%2C%22contrast%22%3A1%2C%22saturation%22%3A100%2C%22formula%22%3A%22wcag2%22%7D */ +// @media (prefers-color-scheme: dark) { +// :root { +// --background-color: #1d1d1d; +// --gray-50: #101010; +// --gray-100: #393939; +// --gray-200: #4f4f4f; +// --gray-300: #686868; +// --gray-400: #848484; +// --gray-500: #a7a7a7; +// --gray-600: #cfcfcf; +// --purple-100: #3c1e95; +// --purple-200: #522acd; +// --purple-300: #6f46ed; +// --purple-400: #8e6ef1; +// --purple-500: #b099f5; +// --purple-600: #d5c8fa; +// --red-100: #721200; +// --red-200: #9c1900; +// --red-300: #cc2000; +// --red-400: #e95034; +// --red-500: #f08c79; +// --red-600: #f7c3ba; +// --highlight-hover: rgb(255 255 255 / 0.1); +// --highlight-pressed: rgb(255 255 255 / 0.2); +// } +// } + +/* Semantic colors */ +:root { + --focus-ring-color: var(--cobalt); + --text-color: var(--denim); + --text-color-base: var(--pigeon); + --text-color-hover: var(--iron); + --text-color-disabled: var(--silver); + --text-color-placeholder: var(--dolphin); + --link-color: var(--sapphire); + --link-color-secondary: var(--pigeon); + --link-color-pressed: var(--royal); + --border-color: var(--silver); + --border-color-hover: var(--dolphin); + --border-color-pressed: var(--dolphin); + --border-color-disabled: var(--snow); + --field-background: var(--air); + --field-text-color: var(--iron); // denim? + --overlay-background: var(--air); + // --button-background: var(--gray-50); + --button-background: transparent; + --button-background-pressed: var(--background-color); + /* these colors are the same between light and dark themes + * to ensure contrast with the foreground color */ + // --highlight-background: #6f46ed; /* purple-300 from dark theme, 3.03:1 against background-color */ + --highlight-background: var(--azure); + // --highlight-background-pressed: #522acd; /* purple-200 from dark theme */ + --highlight-background-pressed: var(--sky); + // --highlight-background-invalid: #cc2000; /* red-300 from dark theme */ + --highlight-background-invalid: var(--poppy); + // --highlight-foreground: white; /* 5.56:1 against highlight-background */ + --highlight-foreground: var(--air); + --highlight-foreground-pressed: #ddd; + --highlight-overlay: rgb(from #6f46ed r g b / 15%); + --invalid-color: var(--rose); + --invalid-color-pressed: var(--candy); +} + +/* Windows high contrast mode overrides */ +@media (forced-colors: active) { + :root { + --background-color: Canvas; + --focus-ring-color: Highlight; + --text-color: ButtonText; + --text-color-base: ButtonText; + --text-color-hover: ButtonText; + --text-color-disabled: GrayText; + --text-color-placeholder: ButtonText; + --link-color: LinkText; + --link-color-secondary: LinkText; + --link-color-pressed: LinkText; + --border-color: ButtonBorder; + --border-color-hover: ButtonBorder; + --border-color-pressed: ButtonBorder; + --border-color-disabled: GrayText; + --field-background: Field; + --field-text-color: FieldText; + --overlay-background: Canvas; + --button-background: ButtonFace; + --button-background-pressed: ButtonFace; + --highlight-background: Highlight; + --highlight-background-pressed: Highlight; + --highlight-background-invalid: LinkText; + --highlight-foreground: HighlightText; + --highlight-foreground-pressed: HighlightText; + --invalid-color: LinkText; + --invalid-color-pressed: LinkText; + } +} diff --git a/packages/components/src/styles/basic/Table.css b/packages/components/src/styles/basic/Table.css index b7da2be130..7523d531b7 100644 --- a/packages/components/src/styles/basic/Table.css +++ b/packages/components/src/styles/basic/Table.css @@ -5,14 +5,38 @@ @import './Menu.css'; @import './theme.css'; +:root { + --plone-table-border: 0 none; + --plone-table-border-radius: 0; + --plone-table-padding: 0.286rem; + --plone-table-width: initial; + --plone-table-max-width: 100%; + --plone-table-background: var(--overlay-background); + + --plone-table-header-color: var(--text-color); + --plone-table-header-font-size: 1rem; + --plone-table-header-border-bottom: 1px solid var(--border-color); + + --plone-table-row-color: var(--text-color); + --plone-table-row-font-size: 1rem; + --plone-table-row-pressed: var(--highlight-pressed); + --plone-table-row-border-radius: 0; + + --plone-table-column-font-weight: 500; + + --plone-table-cell-padding: 18px 12px; + --plone-table-cell-border-bottom: 1px solid var(--smoke); +} + .react-aria-Table { + width: var(--plone-table-width); max-width: 100%; min-height: 100px; align-self: start; - padding: 0.286rem; - border: 1px solid var(--border-color); - border-radius: 6px; - background: var(--overlay-background); + padding: var(--plone-table-padding); + border: var(--plone-table-border); + border-radius: var(--plone-table-border-radius); + background: var(--plone-table-background); border-spacing: 0; forced-color-adjust: none; outline: none; @@ -24,16 +48,11 @@ } .react-aria-TableHeader { - color: var(--text-color); - - &:after { - display: table-row; - height: 2px; - content: ''; - } + color: var(--plone-table-header-color); + font-size: var(--plone-table-header-font-size); & tr:last-child .react-aria-Column { - border-bottom: 1px solid var(--border-color); + border-bottom: var(--plone-table-header-border-bottom); cursor: default; } } @@ -44,11 +63,11 @@ --radius: var(--radius-top) var(--radius-top) var(--radius-bottom) var(--radius-bottom); position: relative; - border-radius: var(--radius); + border-radius: var(--plone-table-row-border-radius); clip-path: inset(0 round var(--radius)); /* firefox */ - color: var(--text-color); + color: var(--plone-table-row-color); cursor: default; - font-size: 1.072rem; + font-size: var(--plone-table-row-font-size); outline: none; transform: scale(1); @@ -58,7 +77,7 @@ } &[data-pressed] { - background: var(--gray-100); + background: var(--plone-table-row-pressed); } &[data-selected] { @@ -75,11 +94,31 @@ &[data-disabled] { color: var(--text-color-disabled); } + + &[data-dragging] { + opacity: 0.6; + transform: translateZ(0); + } + + [slot='drag'] { + all: unset; + width: 1em; + text-align: center; + + &[data-focus-visible] { + border-radius: 4px; + outline: 2px solid var(--focus-ring-color); + } + } + + &[data-href] { + cursor: pointer; + } } .react-aria-Cell, .react-aria-Column { - padding: 4px 8px; + padding: var(--plone-table-cell-padding); outline: none; text-align: left; @@ -90,6 +129,7 @@ } .react-aria-Cell { + border-bottom: var(--plone-table-cell-border-bottom); transform: translateZ(0); &:first-child { @@ -126,11 +166,9 @@ --background-color: var(--highlight-background); } -.react-aria-Row[data-href] { - cursor: pointer; -} - .react-aria-Column { + font-weight: var(--plone-table-column-font-weight); + .sort-indicator { padding: 0 2px; } @@ -140,25 +178,23 @@ } } -.react-aria-TableBody { - &[data-empty] { - font-style: italic; - text-align: center; - } -} - .react-aria-ResizableTableContainer { position: relative; overflow: auto; - max-width: 400px; - border: 1px solid var(--border-color); - border-radius: 6px; - background: var(--background-color); + max-width: var(--plone-table-max-width); + border: var(--plone-table-border); + border-radius: var(--plone-table-border-radius); + background: var(--plone-table-background); .react-aria-Table { border: none; } + .flex-wrapper { + display: flex; + align-items: center; + } + .column-name, .react-aria-Button { --background-color: var(--overlay-background); @@ -226,24 +262,6 @@ } } -.react-aria-Row { - &[data-dragging] { - opacity: 0.6; - transform: translateZ(0); - } - - [slot='drag'] { - all: unset; - width: 15px; - text-align: center; - - &[data-focus-visible] { - border-radius: 4px; - outline: 2px solid var(--focus-ring-color); - } - } -} - .react-aria-DropIndicator[data-drop-target] { outline: 1px solid var(--highlight-background); transform: translateZ(0); @@ -259,15 +277,3 @@ background: var(--highlight-overlay); outline: 2px solid var(--highlight-background); } - -.react-aria-DropIndicator[data-drop-target] { - outline: 1px solid var(--highlight-background); - transform: translateZ(0); -} - -.react-aria-Cell img { - display: block; - width: 30px; - height: 30px; - object-fit: cover; -} diff --git a/packages/components/src/styles/quanta/Contents.css b/packages/components/src/styles/quanta/Contents.css new file mode 100644 index 0000000000..5f2bbc62cf --- /dev/null +++ b/packages/components/src/styles/quanta/Contents.css @@ -0,0 +1,94 @@ +.folder-contents { + .topbar { + display: flex; + align-items: center; + margin-bottom: 1.5rem; + } + + .search-input { + margin-inline-end: 3.75rem; + margin-inline-start: auto; + } + + .contents-table { + --plone-table-width: 100%; + } + + .add { + width: 2.25rem; + height: 2.25rem; + padding: 0.375rem; + border: 0 none; + border-radius: 50%; + background: var(--quanta-sapphire); + color: var(--quanta-air); + cursor: pointer; + + &:hover { + background: var(--quanta-royal); + } + } + + .tooltip { + padding: 0.1875rem 0.375rem; + border-radius: 3px; + margin-top: 0.25rem; + background-color: var(--quanta-denim); + color: var(--quanta-air); + line-height: 1.5; + } + + .title-link { + display: flex; + align-items: center; + } +} + +.add-content-list { + padding: 0; + margin: 0; + list-style: none; +} + +.add-content-list-item { + padding: 12px; + + & + .add-content-list-item { + border-top: 1px solid var(--quanta-smoke); + } + + a { + display: flex; + align-items: center; + color: var(--quanta-sapphire); + text-decoration: none; + + &:hover { + color: var(--quanta-royal); + text-decoration: underline; + } + } + + .icon { + margin-inline-start: auto; + } +} + +.item-actions-list { + padding: 0; + margin: 0; + list-style: none; +} + +.item-actions-list-item { + padding: 12px 0; + + .view, + .move-to-bottom { + border-bottom: 1px solid var(--quanta-smoke); + } + + .icon { + margin-inline-end: 1rem; + } +} diff --git a/packages/components/src/styles/quanta/Popover.css b/packages/components/src/styles/quanta/Popover.css new file mode 100644 index 0000000000..caf9cee464 --- /dev/null +++ b/packages/components/src/styles/quanta/Popover.css @@ -0,0 +1,3 @@ +.q.react-aria-Popover { + --border-color: transparent; +} diff --git a/packages/components/src/styles/quanta/Table.css b/packages/components/src/styles/quanta/Table.css new file mode 100644 index 0000000000..53a8bfb779 --- /dev/null +++ b/packages/components/src/styles/quanta/Table.css @@ -0,0 +1,4 @@ +:root { + --plone-table-header-color: var(--quanta-sapphire); + --plone-table-cell-border-bottom: 1px solid var(--quanta-smoke); +} diff --git a/packages/components/src/styles/quanta/main.css b/packages/components/src/styles/quanta/main.css index 36b9f0597b..6daed6b7d5 100644 --- a/packages/components/src/styles/quanta/main.css +++ b/packages/components/src/styles/quanta/main.css @@ -1,3 +1,10 @@ @import './colors.css'; + +/* Components */ @import './TextField.css'; @import './Select.css'; +@import './Table.css'; +@import './Popover.css'; + +/* Views */ +@import './Contents.css'; diff --git a/packages/components/src/views/Contents/AddContentPopover.tsx b/packages/components/src/views/Contents/AddContentPopover.tsx new file mode 100644 index 0000000000..737e6313e6 --- /dev/null +++ b/packages/components/src/views/Contents/AddContentPopover.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { Link } from '../../components/Link/Link'; +import { ChevronrightIcon } from '../../components/Icons'; +import { Popover } from '../../components/Popover/Popover'; + +interface Props { + addableTypes: { + '@id': string; + id: string; + title: string; + }[]; +} + +export const AddContentPopover = ({ addableTypes }: Props) => { + // const page = addableTypes.find((type) => type.id === 'Document'); + + return ( + +
    + {addableTypes.map((type) => ( +
  • + + {type.title} + + +
  • + ))} +
+
+ ); +}; diff --git a/packages/components/src/views/Contents/Contents.stories.tsx b/packages/components/src/views/Contents/Contents.stories.tsx new file mode 100644 index 0000000000..7ff14b10c9 --- /dev/null +++ b/packages/components/src/views/Contents/Contents.stories.tsx @@ -0,0 +1,168 @@ +import Contents from './Contents'; +import type { Meta, StoryObj } from '@storybook/react'; + +import '../../styles/basic/main.css'; +import '../../styles/quanta/main.css'; + +const meta = { + title: 'Views/Contents', + component: Contents, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + title: 'Section name', + pathname: '/folder', + objectActions: [ + { + id: 'folderContents', + title: 'Contents', + icon: 'contents', + url: '/folder/contents', + }, + ], + loading: false, + orderContent: async (baseUrl, id, delta) => { + console.log(`now PATCH https://api${baseUrl} with payload: +{ + "ordering": { + "obj_id": "${id}", + "delta": ${delta}, + }, +}`); + }, + addableTypes: [ + { + '@id': 'https://demo.plone.org/@types/Document', + id: 'Document', + title: 'Page', + }, + { + '@id': 'https://demo.plone.org/@types/Event', + id: 'Event', + title: 'Event', + }, + { + '@id': 'https://demo.plone.org/@types/Image', + id: 'Image', + title: 'Image', + }, + { + '@id': 'https://demo.plone.org/@types/Link', + id: 'Link', + title: 'Link', + }, + { + '@id': 'https://demo.plone.org/@types/News Item', + id: 'News Item', + title: 'News Item', + }, + ], + items: [ + { + '@id': 'https://demo.plone.org/images', + '@type': 'Document', + CreationDate: '2024-02-14T22:06:52+00:00', + Creator: 'plone-6-demo-site', + Date: '2024-02-14T22:06:52+00:00', + Description: 'Image bank.', + EffectiveDate: 'None', + ExpirationDate: 'None', + ModificationDate: '2024-02-14T22:06:52+00:00', + Subject: [], + Title: 'Images', + Type: 'Page', + UID: '7b00238f184342a7a85ef4463380ac37', + author_name: null, + cmf_uid: null, + commentators: [], + created: '2024-02-14T22:06:52+00:00', + description: 'Image bank.', + effective: '1969-12-31T00:00:00+00:00', + end: null, + exclude_from_nav: true, + expires: '2499-12-31T00:00:00+00:00', + getIcon: null, + getId: 'images', + getObjSize: '0 KB', + getPath: '/Plone/images', + getRemoteUrl: null, + getURL: 'https://demo.plone.org/images', + hasPreviewImage: null, + head_title: null, + id: 'images', + image_field: '', + image_scales: null, + in_response_to: null, + is_folderish: true, + last_comment_date: null, + listCreators: ['plone-6-demo-site'], + location: null, + mime_type: 'text/plain', + modified: '2024-02-14T22:06:52+00:00', + nav_title: null, + portal_type: 'Document', + review_state: 'published', + start: null, + sync_uid: null, + title: 'Images', + total_comments: 0, + type_title: 'Page', + }, + { + '@id': 'https://demo.plone.org/my-page', + '@type': 'Document', + CreationDate: '2024-02-15T11:22:02+00:00', + Creator: 'admin', + Date: '2024-02-15T11:22:02+00:00', + Description: 'This is my page', + EffectiveDate: 'None', + ExpirationDate: 'None', + ModificationDate: '2024-02-15T11:22:02+00:00', + Subject: ['its-mine'], + Title: 'My page', + Type: 'Page', + UID: '51cb4490e3a346349093f6c423c8f28a', + author_name: null, + cmf_uid: null, + commentators: [], + created: '2024-02-15T11:22:02+00:00', + description: 'This is my page', + effective: '1969-12-31T00:00:00+00:00', + end: null, + exclude_from_nav: false, + expires: '2499-12-31T00:00:00+00:00', + getIcon: null, + getId: 'my-page', + getObjSize: '0 KB', + getPath: '/Plone/my-page', + getRemoteUrl: null, + getURL: 'https://demo.plone.org/my-page', + hasPreviewImage: null, + head_title: null, + id: 'my-page', + image_field: '', + image_scales: null, + in_response_to: null, + is_folderish: true, + last_comment_date: null, + listCreators: ['admin'], + location: null, + mime_type: 'text/plain', + modified: '2024-02-15T11:22:02+00:00', + nav_title: null, + portal_type: 'Document', + review_state: 'private', + start: null, + sync_uid: null, + title: 'My page', + total_comments: 0, + type_title: 'Page', + }, + ], + }, +}; diff --git a/packages/components/src/views/Contents/Contents.tsx b/packages/components/src/views/Contents/Contents.tsx new file mode 100644 index 0000000000..28d239d963 --- /dev/null +++ b/packages/components/src/views/Contents/Contents.tsx @@ -0,0 +1,183 @@ +'use client'; + +import React from 'react'; +import type { ActionsResponse } from '@plone/types'; +import { ComponentProps, ReactNode, useState } from 'react'; +import { + DialogTrigger, + Tooltip, + TooltipTrigger, + useDragAndDrop, +} from 'react-aria-components'; +import cx from 'classnames'; +import type { Brain } from '@plone/types/src/content/brains'; +import { AddIcon } from '../../components/Icons'; +import { Breadcrumbs } from '../../components/Breadcrumbs/Breadcrumbs'; +import { Button } from '../../components/Button/Button'; +import { Container } from '../../components/Container/Container'; +import { QuantaTextField } from '../../components/quanta/TextField/TextField'; +import { Table } from '../../components/Table/Table'; +import { ContentsCell } from './ContentsCell'; +import { AddContentPopover } from './AddContentPopover'; +import { indexes, defaultIndexes } from '../../helpers/indexes'; +import type { ArrayElement } from '../../helpers/types'; + +interface ContentsProps { + pathname: string; + breadcrumbs: ComponentProps['items']; + objectActions: ActionsResponse['object']; + loading: boolean; + title: string; + items: Brain[]; + orderContent: (baseUrl: string, id: string, delta: number) => Promise; + addableTypes: ComponentProps['addableTypes']; +} + +/** + * A table showing the contents of an object. + * + * It has a toolbar for interactions with the items and a searchbar for filtering. + * Items can be sorted by drag and drop. + */ +export default function Contents({ + pathname, + breadcrumbs = [], + objectActions, + loading, + title, + items, + orderContent, + addableTypes, +}: ContentsProps) { + const [selected, setSelected] = useState([]); + // const path = getBaseUrl(pathname); + const path = pathname; + + const folderContentsActions = objectActions.find( + (action) => action.id === 'folderContents', + ); + + if (!folderContentsActions) { + // TODO current volto returns the Unauthorized component here + // it would be best if the permissions check was done at a higher level + // and this remained null + return null; + } + + const columns = [ + { + id: 'title', + name: 'Title', + isRowHeader: true, + }, + ...defaultIndexes.map((index) => ({ + id: index, + name: indexes[index].label, + })), + { + id: '_actions', + name: 'Actions', + }, + ] as const; + + const rows = items.map((item) => + columns.reduce['rows']>>( + (cells, column) => ({ + ...cells, + [column.id]: ( + + ), + }), + { id: item['@id'] }, + ), + ); + + const { dragAndDropHooks } = useDragAndDrop({ + getItems: (keys) => + [...keys].map((key) => ({ + 'text/plain': key.toString(), + })), + onReorder(e) { + if (e.keys.size !== 1) { + // TODO mostrare toast o rendere non ordinabile quando più di un elemento è selezionato + console.error('Only one item can be moved at a time'); + return; + } + const target = [...e.keys][0]; + if (target === e.target.key) return; + + const item = items.find((item) => item['@id'] === target); + if (!item) return; + + const initialPosition = rows.findIndex((row) => row.id === item['@id']); + if (initialPosition === -1) return; + + const finalPosition = rows.findIndex((row) => row.id === e.target.key); + + let delta = finalPosition - initialPosition; + if (delta > 0 && e.target.dropPosition === 'before') delta -= 1; + if (delta < 0 && e.target.dropPosition === 'after') delta += 1; + + // if (delta !== 0) { + // orderItem(item.id, delta); + // } + + orderContent( + path, + item.id.replace(/^.*\//, ''), + finalPosition - initialPosition, + ); + }, + }); + + return ( + + {/* TODO better loader */} + {loading &&

Loading...

} + {/* TODO helmet setting title here... or should we do it at a higher level? */} +
+
+
+ +

{[...breadcrumbs].slice(-1)[0]?.title}

+
+ + + + + + + + Add content + + +
+
+ + + + + ); +} diff --git a/packages/components/src/views/Contents/ContentsCell.tsx b/packages/components/src/views/Contents/ContentsCell.tsx new file mode 100644 index 0000000000..82d3b7de19 --- /dev/null +++ b/packages/components/src/views/Contents/ContentsCell.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { DialogTrigger } from 'react-aria-components'; +import { Brain } from '@plone/types/src/content/brains'; +import { Button } from '../../components/Button/Button'; +import { Link } from '../../components/Link/Link'; +import { MoreoptionsIcon, PageIcon } from '../../components/Icons'; +import { indexes } from '../../helpers/indexes'; +import { ItemActionsPopover } from './ItemActionsPopover'; + +interface Props { + item: Brain; + column: keyof typeof indexes | 'title' | '_actions'; +} + +export function ContentsCell({ item, column }: Props) { + if (column === 'title') { + return ( + + + {item.title} + {item.ExpirationDate !== 'None' && + new Date(item.ExpirationDate).getTime() < new Date().getTime() && ( + Expired + )} + {item.EffectiveDate !== 'None' && + new Date(item.EffectiveDate).getTime() > new Date().getTime() && ( + Scheduled + )} + + ); + } else if (column === '_actions') { + return ( + + + {}} + onMoveToTop={async () => {}} + onCopy={async () => {}} + onCut={async () => {}} + onDelete={async () => {}} + /> + + ); + } else { + if (indexes[column].type === 'boolean') { + return item[column] ? 'Yes' : 'No'; + } else if (indexes[column].type === 'string') { + if (column !== 'review_state') { + return item[column]; + } else { + return ( +
+ + {/* */} + + {item[column] || 'No workflow state'} +
+ ); + } + } else if (indexes[column].type === 'date') { + if (item[column] && item[column] !== 'None') { + // @ts-ignore TODO fix this, maybe a more strict type for the indexes? + return new Date(item[column]).toLocaleDateString(); + } else { + return 'None'; + } + } else if (indexes[column].type === 'array') { + const value = item[column]; + return Array.isArray(value) ? value.join(', ') : value; + } + } +} diff --git a/packages/components/src/views/Contents/ItemActionsPopover.tsx b/packages/components/src/views/Contents/ItemActionsPopover.tsx new file mode 100644 index 0000000000..243ad7ac57 --- /dev/null +++ b/packages/components/src/views/Contents/ItemActionsPopover.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { Link } from '../../components/Link/Link'; +import { Button } from '../../components/Button/Button'; +import { Popover } from '../../components/Popover/Popover'; +import { + EditIcon, + EyeIcon, + RowbeforeIcon, + RowafterIcon, + CutIcon, + CopyIcon, + BinIcon, +} from '../../components/Icons'; + +interface Props { + editLink: string; + viewLink: string; + onMoveToTop: () => Promise; + onMoveToBottom: () => Promise; + onCut: () => Promise; + onCopy: () => Promise; + onDelete: () => Promise; +} + +export function ItemActionsPopover({ + editLink, + viewLink, + onMoveToTop, + onMoveToBottom, + onCut, + onCopy, + onDelete, +}: Props) { + return ( + +
    +
  • + + + Edit + +
  • +
  • + + + View + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+ ); +} diff --git a/packages/types/src/content/brains.d.ts b/packages/types/src/content/brains.d.ts new file mode 100644 index 0000000000..0a1a2e6ae2 --- /dev/null +++ b/packages/types/src/content/brains.d.ts @@ -0,0 +1,52 @@ +import type { PreviewImage } from './common'; + +export interface Brain { + '@id': string; + '@type': string; + CreationDate: string; + Creator: string; + Date: string; + Description: string; + EffectiveDate: string | 'None'; // 'None' here is just for documentation + ExpirationDate: string | 'None'; // 'None' here is just for documentation + ModificationDate: string; + Subject: string[]; + Title: string; + Type: string; + UID: string; + author_name: string | null; + cmf_uid: string | null; + commentators: string[]; + created: string; + description: string; + effective: string | '1969-12-31T00:00:00+00:00'; // '1969-12-31T00:00:00+00:00' here is just for documentation + end: string | null; + exclude_from_nav: boolean; + expires: string | '2499-12-31T00:00:00+00:00'; // '2499-12-31T00:00:00+00:00' here is just for documentation + getIcon: string | null; // TODO is this correct? + getId: string; + getObjSize: string; + getPath: string; + getRemoteUrl: string | null; + getURL: string; + hasPreviewImage: boolean | null; // TODO is this correct? + head_title: string | null; // TODO is this correct? + id: string; + image_field: string; // TODO could this be more specific? + image_scales: Record | null; // TODO could this be more specific? + in_response_to: string | null; // TODO is this correct? + is_folderish: boolean; + last_comment_date: string | null; + listCreators: string[]; + location: string | null; // TODO is this correct? + mime_type: string; // TODO could this be more specific? + modified: string; + nav_title: string | null; // TODO is this correct? + portal_type: string; // TODO could this be more specific? + review_state: string; // TODO could this be more specific? + start: string | null; + sync_uid: string | null; + title: string; + total_comments: number; + type_title: string; // TODO could this be more specific? +} diff --git a/packages/types/src/content/index.d.ts b/packages/types/src/content/index.d.ts index b8e16c1269..bf16b511eb 100644 --- a/packages/types/src/content/index.d.ts +++ b/packages/types/src/content/index.d.ts @@ -4,6 +4,7 @@ import type { PreviewImage, RelatedItem, } from './common'; +import type { Brain } from './brains'; export interface Content { '@components': Expanders; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11781aa1a0..2b0cf9ba12 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -921,7 +921,7 @@ importers: specifier: ^2.11.0 version: 2.11.0(@parcel/core@2.11.0)(typescript@5.2.2) '@plone/types': - specifier: 'workspace: *' + specifier: workspace:* version: link:../types '@react-types/shared': specifier: ^3.22.0 @@ -35591,6 +35591,7 @@ packages: loose-envify: 1.4.0 object-assign: 4.1.1 prop-types: 15.7.2 + bundledDependencies: false /react@17.0.2: resolution: {integrity: sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==}