diff --git a/.changeset/tidy-roses-sniff.md b/.changeset/tidy-roses-sniff.md new file mode 100644 index 000000000..2b90f9efe --- /dev/null +++ b/.changeset/tidy-roses-sniff.md @@ -0,0 +1,7 @@ +--- +'@keystar/docs': patch +'@keystatic/core': patch +'@keystar/ui': patch +--- + +Update react-aria and friends. Fix tests/types etc. from update. diff --git a/design-system/docs/.storybook/preview.js b/design-system/docs/.storybook/preview.js index a39fbcdae..ca820db06 100644 --- a/design-system/docs/.storybook/preview.js +++ b/design-system/docs/.storybook/preview.js @@ -18,7 +18,7 @@ export const decorators = [
( let domRef = useObjectRef(forwardedRef); return ( - /* @ts-expect-error FIXME: resolve ref inconsistencies */ diff --git a/design-system/pkg/src/action-bar/test/ActionBar.test.tsx b/design-system/pkg/src/action-bar/test/ActionBar.test.tsx index 950346891..ebcd6592b 100644 --- a/design-system/pkg/src/action-bar/test/ActionBar.test.tsx +++ b/design-system/pkg/src/action-bar/test/ActionBar.test.tsx @@ -6,22 +6,18 @@ import { it, jest, } from '@jest/globals'; -// import { announce } from '@react-aria/live-announcer'; +import userEvent from '@testing-library/user-event'; import React from 'react'; -import { - act, - fireEvent, - firePress, - renderWithProvider, - within, -} from '#test-utils'; +import { act, renderWithProvider, within } from '#test-utils'; import { ListExample } from '../stories/ListExample'; // jest.mock('@react-aria/live-announcer'); describe('action-bar/ActionBar', () => { + let user: ReturnType; beforeAll(() => { + user = userEvent.setup({ delay: null }); jest .spyOn(window.HTMLElement.prototype, 'clientWidth', 'get') .mockImplementation(() => 1000); @@ -35,17 +31,15 @@ describe('action-bar/ActionBar', () => { act(() => jest.runAllTimers()); }); - it('should open when there are selected items', () => { + it('should open when there are selected items', async () => { let tree = renderWithProvider(); - act(() => { - jest.runAllTimers(); - }); + act(() => jest.runAllTimers()); let grid = tree.getByRole('grid'); let rows = within(grid).getAllByRole('row'); expect(tree.queryByRole('toolbar')).toBeNull(); - firePress(rows[1]); + await user.click(rows[1]); // FIXME: get this mock working // expect(announce).toHaveBeenCalledWith('Actions available.'); @@ -62,88 +56,78 @@ describe('action-bar/ActionBar', () => { expect(clearButton.tagName).toBe('BUTTON'); }); - it('should update the selected count when selecting more items', () => { + it('should update the selected count when selecting more items', async () => { let tree = renderWithProvider(); - act(() => { - jest.runAllTimers(); - }); + act(() => jest.runAllTimers()); let grid = tree.getByRole('grid'); let rows = within(grid).getAllByRole('row'); - firePress(rows[1]); + await user.click(rows[1]); let selectedCount = tree.getByText('1 selected'); - firePress(rows[2]); + await user.click(rows[2]); expect(selectedCount).toHaveTextContent('2 selected'); }); - it('should close and restore focus when pressing the clear button', () => { + it('should close and restore focus when pressing the clear button', async () => { let tree = renderWithProvider(); - act(() => { - jest.runAllTimers(); - }); + act(() => jest.runAllTimers()); let grid = tree.getByRole('grid'); let rows = within(grid).getAllByRole('row'); let checkbox = within(rows[1]).getByRole('checkbox'); - firePress(checkbox); + await user.click(checkbox); act(() => jest.runAllTimers()); expect(document.activeElement).toBe(checkbox); let clearButton = tree.getByLabelText('Clear selection'); - act(() => clearButton.focus()); - firePress(clearButton); + await user.click(clearButton); act(() => jest.runAllTimers()); act(() => jest.runAllTimers()); expect(tree.queryByRole('toolbar')).toBeNull(); - expect(document.activeElement).toBe(checkbox); + expect(document.activeElement).toBe(checkbox.closest('[role="row"]')); }); - it('should close when pressing the escape key', () => { + it('should close when pressing the escape key', async () => { let tree = renderWithProvider(); - act(() => { - jest.runAllTimers(); - }); + act(() => jest.runAllTimers()); - let grid = tree.getByRole('grid'); - let rows = within(grid).getAllByRole('row'); + let table = tree.getByRole('grid'); + let rows = within(table).getAllByRole('row'); let checkbox = within(rows[1]).getByRole('checkbox'); - firePress(checkbox); + await user.click(checkbox); act(() => jest.runAllTimers()); expect(document.activeElement).toBe(checkbox); let toolbar = tree.getByRole('toolbar'); act(() => within(toolbar).getAllByRole('button')[0].focus()); - fireEvent.keyDown(document.activeElement!, { key: 'Escape' }); - fireEvent.keyUp(document.activeElement!, { key: 'Escape' }); + await user.keyboard('{Escape}'); act(() => jest.runAllTimers()); act(() => jest.runAllTimers()); expect(tree.queryByRole('toolbar')).toBeNull(); - expect(document.activeElement).toBe(checkbox); + expect(document.activeElement).toBe(checkbox.closest('[role="row"]')); }); - it('should fire onAction when clicking on an action', () => { + it('should fire onAction when clicking on an action', async () => { let onAction = jest.fn(); let tree = renderWithProvider(); - act(() => { - jest.runAllTimers(); - }); + act(() => jest.runAllTimers()); let grid = tree.getByRole('grid'); let rows = within(grid).getAllByRole('row'); - firePress(rows[1]); + await user.click(rows[1]); let toolbar = tree.getByRole('toolbar'); - firePress(within(toolbar).getAllByRole('button')[0]); + await user.click(within(toolbar).getAllByRole('button')[0]); expect(onAction).toHaveBeenCalledWith('edit'); }); diff --git a/design-system/pkg/src/button/ActionButton.tsx b/design-system/pkg/src/button/ActionButton.tsx index 3660fca18..7150286e5 100644 --- a/design-system/pkg/src/button/ActionButton.tsx +++ b/design-system/pkg/src/button/ActionButton.tsx @@ -60,26 +60,14 @@ const LinkButton = forwardRef(function LinkActionButton( props: ActionLinkElementProps, forwardedRef: ForwardedRef ) { - const { - children, - isDisabled, - // link specific - download, - href, - hrefLang, - ping, - referrerPolicy, - rel, - target, - ...otherProps - } = props; + const { children, isDisabled, ...otherProps } = props; const domRef = useObjectRef(forwardedRef); const { buttonProps, isPressed } = useButton( { elementType: 'a', ...props }, domRef ); - const { linkProps } = useLink(otherProps, domRef); + const { linkProps } = useLink(props, domRef); const { hoverProps, isHovered } = useHover({ isDisabled }); const styleProps = useActionButtonStyles(props, { isHovered, isPressed }); @@ -88,13 +76,6 @@ const LinkButton = forwardRef(function LinkActionButton( {...filterDOMProps(otherProps)} {...mergeProps(buttonProps, linkProps, hoverProps, styleProps)} ref={domRef} - download={download} - href={href} - hrefLang={hrefLang} - ping={ping} - referrerPolicy={referrerPolicy} - rel={rel} - target={target} > {children} diff --git a/design-system/pkg/src/button/Button.tsx b/design-system/pkg/src/button/Button.tsx index 42adbcb62..6b9ce35cd 100644 --- a/design-system/pkg/src/button/Button.tsx +++ b/design-system/pkg/src/button/Button.tsx @@ -61,26 +61,14 @@ const LinkButton = forwardRef(function Button( props: LinkElementProps, forwardedRef: ForwardedRef ) { - const { - children, - isDisabled, - // link specific - download, - href, - hrefLang, - ping, - referrerPolicy, - rel, - target, - ...otherProps - } = props; + const { children, isDisabled, ...otherProps } = props; const domRef = useObjectRef(forwardedRef); const { buttonProps, isPressed } = useButton( { elementType: 'a', ...props }, domRef ); - const { linkProps } = useLink(otherProps, domRef); + const { linkProps } = useLink(props, domRef); const { hoverProps, isHovered } = useHover({ isDisabled }); const styleProps = useButtonStyles(props, { isHovered, isPressed }); @@ -89,13 +77,6 @@ const LinkButton = forwardRef(function Button( {...filterDOMProps(otherProps)} {...mergeProps(buttonProps, linkProps, hoverProps, styleProps)} ref={domRef} - download={download} - href={href} - hrefLang={hrefLang} - ping={ping} - referrerPolicy={referrerPolicy} - rel={rel} - target={target} > {children} diff --git a/design-system/pkg/src/button/stories/Button.stories.tsx b/design-system/pkg/src/button/stories/Button.stories.tsx index a4db48168..e73970eec 100644 --- a/design-system/pkg/src/button/stories/Button.stories.tsx +++ b/design-system/pkg/src/button/stories/Button.stories.tsx @@ -77,8 +77,8 @@ export const StaticLight = () => ( {render('High', { prominence: 'high', static: 'light' })} {render('Default', { prominence: 'default', static: 'light' })} @@ -102,6 +102,7 @@ export const StaticDark = () => ( gap="regular" backgroundColor="accent" padding="large" + UNSAFE_style={{ backgroundColor: '#eee' }} > {render('High', { prominence: 'high', static: 'dark' })} {render('Default', { prominence: 'default', static: 'dark' })} diff --git a/design-system/pkg/src/button/useButtonStyles.tsx b/design-system/pkg/src/button/useButtonStyles.tsx index f58854702..0a13aab07 100644 --- a/design-system/pkg/src/button/useButtonStyles.tsx +++ b/design-system/pkg/src/button/useButtonStyles.tsx @@ -33,7 +33,7 @@ export function useButtonStyles(props: ButtonProps, state: ButtonState) { pending: isPending || undefined, pressed: isPressed || undefined, prominence: prominence === 'default' ? undefined : prominence, - tone: tone === 'neutral' ? undefined : tone, + tone: tone, static: props.static, }), style: styleProps.style, @@ -131,7 +131,7 @@ export function useButtonStyles(props: ButtonProps, state: ButtonState) { }, // states - '&:disabled, &[data-pending=true]': { + '&:disabled, &[aria-disabled=true]': { backgroundColor: tokenSchema.color.alias.backgroundDisabled, color: tokenSchema.color.alias.foregroundDisabled, }, @@ -206,7 +206,7 @@ export function useButtonStyles(props: ButtonProps, state: ButtonState) { }, // tone selector to increase specificity - '&[data-tone]:disabled, &[data-tone][data-pending=true]': { + '&[data-tone]:disabled, &[data-tone][aria-disabled=true]': { backgroundColor: tokenSchema.color.alias.backgroundDisabled, color: tokenSchema.color.alias.foregroundDisabled, }, @@ -277,7 +277,7 @@ export function useButtonStyles(props: ButtonProps, state: ButtonState) { }, }, - '&:disabled, &[data-pending=true]': { + '&:disabled, &[aria-disabled=true]': { backgroundColor: tokenSchema.color.alias.backgroundDisabled, color: tokenSchema.color.alias.foregroundDisabled, }, diff --git a/design-system/pkg/src/calendar/CalendarCell.tsx b/design-system/pkg/src/calendar/CalendarCell.tsx index 28b33d1c4..684e1c08a 100644 --- a/design-system/pkg/src/calendar/CalendarCell.tsx +++ b/design-system/pkg/src/calendar/CalendarCell.tsx @@ -158,8 +158,8 @@ type CellStyleProps = { isRangeSelection: boolean; isRangeStart: boolean; isSelected: boolean; - isSelectionEnd: boolean; - isSelectionStart: boolean; + isSelectionEnd: boolean | null; + isSelectionStart: boolean | null; isToday: boolean; isUnavailable: boolean; }; diff --git a/design-system/pkg/src/calendar/test/RangeCalendar.test.tsx b/design-system/pkg/src/calendar/test/RangeCalendar.test.tsx index 0f4b6973b..e1fb2e364 100644 --- a/design-system/pkg/src/calendar/test/RangeCalendar.test.tsx +++ b/design-system/pkg/src/calendar/test/RangeCalendar.test.tsx @@ -217,7 +217,7 @@ describe('calendar/RangeCalendar', () => { // TODO: selects a range with the keyboard it('selects a range by clicking with the mouse', () => { - let onChange = jest.fn<(value: RangeValue) => void>(); + let onChange = jest.fn<(value: RangeValue | null) => void>(); let { getAllByLabelText, getByText } = renderWithProvider( { expect(selectedDates[selectedDates.length - 1].textContent).toBe('17'); expect(onChange).toHaveBeenCalledTimes(1); - let { start, end } = onChange.mock.calls[0][0]; + let result = onChange.mock.calls[0][0]; + if (!result) { + throw new Error('Expected a result'); + } + let { start, end } = result; expect(start).toEqual(new CalendarDate(2019, 6, 7)); expect(end).toEqual(new CalendarDate(2019, 6, 17)); }); it('selects a range by dragging with the mouse', () => { - let onChange = jest.fn<(value: RangeValue) => void>(); + let onChange = jest.fn<(value: RangeValue | null) => void>(); let { getAllByLabelText, getByText } = renderWithProvider( { expect(selectedDates[selectedDates.length - 1].textContent).toBe('23'); expect(onChange).toHaveBeenCalledTimes(1); - let { start, end } = onChange.mock.calls[0][0]; + let result = onChange.mock.calls[0][0]; + if (!result) { + throw new Error('Expected a result'); + } + let { start, end } = result; expect(start).toEqual(new CalendarDate(2019, 6, 17)); expect(end).toEqual(new CalendarDate(2019, 6, 23)); }); diff --git a/design-system/pkg/src/checkbox/Checkbox.tsx b/design-system/pkg/src/checkbox/Checkbox.tsx index a5ba65495..ed0a44ffa 100644 --- a/design-system/pkg/src/checkbox/Checkbox.tsx +++ b/design-system/pkg/src/checkbox/Checkbox.tsx @@ -91,6 +91,8 @@ function CheckboxInner( alignItems: 'flex-start', display: 'inline-flex', gap: tokenSchema.size.space.regular, + paddingInlineEnd: tokenSchema.size.space.large, + paddingBlock: tokenSchema.size.space.regular, position: 'relative', userSelect: 'none', }); @@ -118,6 +120,7 @@ function CheckboxInner( position: 'absolute', zIndex: 1, inset: 0, + insetInlineStart: `calc(${tokenSchema.size.space.regular} * -1)`, opacity: 0.0001, }) )} @@ -159,6 +162,7 @@ const Indicator = (props: IndicatorProps) => { position: 'relative', height: sizeToken, width: sizeToken, + // marginBlock: `calc((${tokenSchema.size.element.regular} - ${tokenSchema.typography.text.regular.size}) / 2)`, // prominence '--selected-idle-bg': tokenSchema.color.scale.indigo9, @@ -265,8 +269,9 @@ const Content = (props: HTMLAttributes) => { css({ color: tokenSchema.color.alias.foregroundIdle, display: 'grid', - paddingTop: `calc((${sizeToken} - ${tokenSchema.typography.text.regular.capheight}) / 2)`, gap: tokenSchema.size.space.large, + // paddingTop: `calc((${tokenSchema.size.element.regular} - ${tokenSchema.typography.text.regular.capheight}) / 2)`, + paddingTop: `calc((${sizeToken} - ${tokenSchema.typography.text.regular.capheight}) / 2)`, 'input[type="checkbox"]:hover ~ &': { color: tokenSchema.color.alias.foregroundHovered, diff --git a/design-system/pkg/src/checkbox/CheckboxGroup.tsx b/design-system/pkg/src/checkbox/CheckboxGroup.tsx index bb92d879a..3538ef85b 100644 --- a/design-system/pkg/src/checkbox/CheckboxGroup.tsx +++ b/design-system/pkg/src/checkbox/CheckboxGroup.tsx @@ -8,12 +8,7 @@ import React, { import { useProviderProps } from '@keystar/ui/core'; import { FieldPrimitive, validateFieldProps } from '@keystar/ui/field'; -import { - classNames, - css, - toDataAttributes, - tokenSchema, -} from '@keystar/ui/style'; +import { classNames, css, toDataAttributes } from '@keystar/ui/style'; import { CheckboxGroupContext } from './context'; import { CheckboxGroupProps } from './types'; @@ -50,7 +45,6 @@ export const CheckboxGroup: ForwardRefExoticComponent = className={classNames( css({ display: 'flex', - gap: tokenSchema.size.space.large, '&[data-orientation="vertical"]': { flexDirection: 'column', diff --git a/design-system/pkg/src/combobox/Combobox.tsx b/design-system/pkg/src/combobox/Combobox.tsx index 265d26efe..f74fad0ed 100644 --- a/design-system/pkg/src/combobox/Combobox.tsx +++ b/design-system/pkg/src/combobox/Combobox.tsx @@ -92,7 +92,7 @@ const ComboboxBase = React.forwardRef(function ComboboxBase( defaultFilter: contains, allowsEmptyCollection: isAsync, }); - let layout = useListBoxLayout(state); + let layout = useListBoxLayout(); let { buttonProps, @@ -104,7 +104,7 @@ const ComboboxBase = React.forwardRef(function ComboboxBase( } = useComboBox( { ...props, - keyboardDelegate: layout, + layoutDelegate: layout, buttonRef, popoverRef: popoverRefLikeValue, listBoxRef, diff --git a/design-system/pkg/src/combobox/MobileCombobox.tsx b/design-system/pkg/src/combobox/MobileCombobox.tsx index 53dc6dd91..60cd053ed 100644 --- a/design-system/pkg/src/combobox/MobileCombobox.tsx +++ b/design-system/pkg/src/combobox/MobileCombobox.tsx @@ -300,13 +300,13 @@ function ComboboxTray(props: ComboboxTrayProps) { let buttonRef = useRef(null); let popoverRef = useRef(null); let listBoxRef = useRef(null); - let layout = useListBoxLayout(state); + let layout = useListBoxLayout(); let stringFormatter = useLocalizedStringFormatter(localizedMessages); let { inputProps, listBoxProps, labelProps } = useComboBox( { ...props, - keyboardDelegate: layout, + layoutDelegate: layout, buttonRef, popoverRef, listBoxRef, diff --git a/design-system/pkg/src/date-time/DatePicker.tsx b/design-system/pkg/src/date-time/DatePicker.tsx index ed1813903..14c02ab15 100644 --- a/design-system/pkg/src/date-time/DatePicker.tsx +++ b/design-system/pkg/src/date-time/DatePicker.tsx @@ -117,6 +117,7 @@ function DatePicker( disableFocusRing {...styleProps.input} > + {/* @ts-expect-error can't reconcile changes to react-aria errorMessage fn type */} ( disableFocusRing {...styleProps.input} > + {/* @ts-expect-error can't reconcile changes to react-aria errorMessage fn type */} ( + {/* @ts-expect-error can't reconcile changes to react-aria errorMessage fn type */} + props: Pick, 'description' | 'showFormatHelpText'> ) { let formatter = useDateFormatter({ dateStyle: 'short' }); let displayNames = useDisplayNames(); diff --git a/design-system/pkg/src/drag-and-drop/InsertionIndicatorPrimitive.tsx b/design-system/pkg/src/drag-and-drop/InsertionIndicatorPrimitive.tsx new file mode 100644 index 000000000..0bd6071c3 --- /dev/null +++ b/design-system/pkg/src/drag-and-drop/InsertionIndicatorPrimitive.tsx @@ -0,0 +1,54 @@ +import { classNames, css, tokenSchema } from '@keystar/ui/style'; +import React, { HTMLAttributes } from 'react'; + +export function InsertionIndicatorPrimitive( + props: { isDropTarget?: boolean } & HTMLAttributes +) { + let { children, isDropTarget, ...otherProps } = props; + let maskColor = tokenSchema.color.background.canvas; + let borderColor = tokenSchema.color.background.accentEmphasis; + let borderSize = tokenSchema.size.border.medium; + let circleSize = tokenSchema.size.space.regular; + + return ( +
+ {children} +
+ ); +} diff --git a/design-system/pkg/src/drag-and-drop/index.ts b/design-system/pkg/src/drag-and-drop/index.ts index 5135af667..cfc1d5166 100644 --- a/design-system/pkg/src/drag-and-drop/index.ts +++ b/design-system/pkg/src/drag-and-drop/index.ts @@ -4,6 +4,8 @@ export type { DropZoneProps } from './DropZone'; export { FileTrigger } from './FileTrigger'; export type { FileTriggerProps } from './FileTrigger'; +export { InsertionIndicatorPrimitive } from './InsertionIndicatorPrimitive'; + export { useDragAndDrop } from './useDragAndDrop'; export type { DragAndDropOptions, DragAndDropHooks } from './types'; diff --git a/design-system/pkg/src/drag-and-drop/types.ts b/design-system/pkg/src/drag-and-drop/types.ts index 1660a1262..0efddb4cf 100644 --- a/design-system/pkg/src/drag-and-drop/types.ts +++ b/design-system/pkg/src/drag-and-drop/types.ts @@ -33,7 +33,7 @@ export interface DragHooks { useDraggableCollection?: ( props: DraggableCollectionOptions, state: DraggableCollectionState, - ref: RefObject + ref: RefObject ) => void; useDraggableItem?: ( props: DraggableItemProps, @@ -49,24 +49,27 @@ export interface DropHooks { useDroppableCollection?: ( props: DroppableCollectionOptions, state: DroppableCollectionState, - ref: RefObject + ref: RefObject ) => DroppableCollectionResult; useDroppableItem?: ( options: DroppableItemOptions, state: DroppableCollectionState, - ref: RefObject + ref: RefObject ) => DroppableItemResult; useDropIndicator?: ( props: DropIndicatorProps, state: DroppableCollectionState, - ref: RefObject + ref: RefObject ) => DropIndicatorAria; } export interface DragAndDropHooks { /** Drag and drop hooks for the collection element. */ dragAndDropHooks: DragHooks & - DropHooks & { isVirtualDragging?: () => boolean }; + DropHooks & { + isVirtualDragging?: () => boolean; + renderPreview?: (keys: Set, draggedKey: Key | null) => JSX.Element; + }; } export interface DragAndDropOptions @@ -77,4 +80,6 @@ export interface DragAndDropOptions * @default () => [] */ getItems?: (keys: Set) => DragItem[]; + /** Provide a custom drag preview. `draggedKey` represents the key of the item the user actually dragged. */ + renderPreview?: (keys: Set, draggedKey: Key | null) => JSX.Element; } diff --git a/design-system/pkg/src/drag-and-drop/useDragAndDrop.ts b/design-system/pkg/src/drag-and-drop/useDragAndDrop.ts index 43b76de2d..25fef9d75 100644 --- a/design-system/pkg/src/drag-and-drop/useDragAndDrop.ts +++ b/design-system/pkg/src/drag-and-drop/useDragAndDrop.ts @@ -16,6 +16,7 @@ import { useDraggableCollectionState, useDroppableCollectionState, } from '@react-stately/dnd'; +import type { Key } from '@react-types/shared'; import { RefObject, useMemo } from 'react'; import { @@ -26,14 +27,19 @@ import { } from './types'; /** - * Provides the hooks required to enable drag and drop behavior for a drag and - * drop compatible component. + * Provides the hooks required to enable drag and drop behavior for a drag and drop compatible React Spectrum component. */ -// NOTE: if more components become drag-n-droppable move elsewhere. export function useDragAndDrop(options: DragAndDropOptions): DragAndDropHooks { let dragAndDropHooks = useMemo(() => { - let { onDrop, onInsert, onItemDrop, onReorder, onRootDrop, getItems } = - options; + let { + onDrop, + onInsert, + onItemDrop, + onReorder, + onRootDrop, + getItems, + renderPreview, + } = options; let isDraggable = !!getItems; let isDroppable = !!( @@ -45,7 +51,10 @@ export function useDragAndDrop(options: DragAndDropOptions): DragAndDropHooks { ); let hooks = {} as DragHooks & - DropHooks & { isVirtualDragging?: () => boolean }; + DropHooks & { + isVirtualDragging?: () => boolean; + renderPreview?: (keys: Set, draggedKey: Key | null) => JSX.Element; + }; if (isDraggable) { // @ts-expect-error hooks.useDraggableCollectionState = @@ -57,21 +66,21 @@ export function useDragAndDrop(options: DragAndDropOptions): DragAndDropHooks { hooks.useDraggableCollection = useDraggableCollection; hooks.useDraggableItem = useDraggableItem; hooks.DragPreview = DragPreview; + hooks.renderPreview = renderPreview; } if (isDroppable) { - // eslint-disable-next-line no-unused-expressions - (hooks.useDroppableCollectionState = + hooks.useDroppableCollectionState = function useDroppableCollectionStateOverride( props: DroppableCollectionStateOptions ) { return useDroppableCollectionState({ ...props, ...options }); - }), - (hooks.useDroppableItem = useDroppableItem); + }; + hooks.useDroppableItem = useDroppableItem; hooks.useDroppableCollection = function useDroppableCollectionOverride( props: DroppableCollectionOptions, state: DroppableCollectionState, - ref: RefObject + ref: RefObject ) { return useDroppableCollection({ ...props, ...options }, state, ref); }; diff --git a/design-system/pkg/src/editor/EditorListbox.tsx b/design-system/pkg/src/editor/EditorListbox.tsx index 775b04c6b..dcf8a14d4 100644 --- a/design-system/pkg/src/editor/EditorListbox.tsx +++ b/design-system/pkg/src/editor/EditorListbox.tsx @@ -1,4 +1,7 @@ -import { useSelectableCollection } from '@react-aria/selection'; +import { + ListKeyboardDelegate, + useSelectableCollection, +} from '@react-aria/selection'; import { chain } from '@react-aria/utils'; import { useListState } from '@react-stately/list'; import { @@ -6,7 +9,7 @@ import { CollectionBase, MultipleSelection, } from '@react-types/shared'; -import { Key, RefObject, useEffect, useRef } from 'react'; +import { Key, RefObject, useEffect, useMemo, useRef } from 'react'; import { ListBoxBase, listStyles, useListBoxLayout } from '@keystar/ui/listbox'; import { BaseStyleProps } from '@keystar/ui/style'; @@ -34,12 +37,21 @@ export type EditorListboxProps = { export function EditorListbox(props: EditorListboxProps) { let { listenerRef, onEscape, scrollRef, ...otherProps } = props; let state = useListState(props); - let layout = useListBoxLayout(state); + let layout = useListBoxLayout(); + let listboxRef = useRef(null); + let delegate = useMemo( + () => + new ListKeyboardDelegate({ + collection: state.collection, + ref: listboxRef, + layoutDelegate: layout, + }), + [layout, state.collection] + ); // keyboard and selection management - let listboxRef = useRef(null); let { collectionProps } = useSelectableCollection({ - keyboardDelegate: layout, + keyboardDelegate: delegate, ref: listenerRef, scrollRef: scrollRef ?? listboxRef, selectionManager: state.selectionManager, diff --git a/design-system/pkg/src/field/types.tsx b/design-system/pkg/src/field/types.tsx index f2b224233..700bd89a0 100644 --- a/design-system/pkg/src/field/types.tsx +++ b/design-system/pkg/src/field/types.tsx @@ -30,6 +30,7 @@ export type FieldProps = Pick< DOMProps; export type FieldPrimitiveProps = { + /** The field contents. */ children: ReactElement; /** A `ContextualHelp` element to place next to the label. */ contextualHelp?: ReactElement; @@ -38,12 +39,14 @@ export type FieldPrimitiveProps = { * field. */ description?: ReactNode; + /** Props for the description element. */ descriptionProps?: HTMLAttributes; /** * Error messages inform the user when the input does not meet validation * criteria. */ errorMessage?: ReactNode; + /** Props for the message element. */ errorMessageProps?: HTMLAttributes; /** Whether user input is required on the input before form submission. */ isRequired?: boolean; @@ -54,6 +57,7 @@ export type FieldPrimitiveProps = { * @default 'label' */ labelElementType?: HTMLTag; + /** Props for the label element. */ labelProps?: HTMLAttributes; /** * For controls that DO NOT use a semantic element for user input. In these diff --git a/design-system/pkg/src/link/TextLink/TextLinkAnchor.tsx b/design-system/pkg/src/link/TextLink/TextLinkAnchor.tsx index 9b74f2ecc..215009f14 100644 --- a/design-system/pkg/src/link/TextLink/TextLinkAnchor.tsx +++ b/design-system/pkg/src/link/TextLink/TextLinkAnchor.tsx @@ -10,35 +10,15 @@ export const TextLinkAnchor = forwardRef< HTMLAnchorElement, TextLinkAnchorProps >(function TextLink(props, forwardedRef) { - const { - children, - download, - href, - hrefLang, - ping, - referrerPolicy, - rel, - target, - ...otherProps - } = props; + const { children } = props; const domRef = useObjectRef(forwardedRef); const { Wrapper, ...styleProps } = useTextLink(props); - const { linkProps } = useLink(otherProps, domRef); + const { linkProps } = useLink(props, domRef); return ( - + {children} diff --git a/design-system/pkg/src/link/TextLink/useTextLink.ts b/design-system/pkg/src/link/TextLink/useTextLink.ts index b6246b364..fbf4fcf68 100644 --- a/design-system/pkg/src/link/TextLink/useTextLink.ts +++ b/design-system/pkg/src/link/TextLink/useTextLink.ts @@ -27,10 +27,6 @@ export function useTextLink({ const { focusProps, isFocusVisible } = useFocusRing({ autoFocus }); const { hoverProps, isHovered } = useHover({}); - const fontWeight = headingContext - ? undefined - : tokenSchema.typography.fontWeight.medium; - const dataOptions = { prominence, hover: isHovered ? 'true' : undefined, @@ -45,7 +41,6 @@ export function useTextLink({ css({ color: tokenSchema.color.foreground.neutral, cursor: 'pointer', - fontWeight, outline: 0, textDecoration: 'underline', textDecorationColor: tokenSchema.color.border.emphasis, diff --git a/design-system/pkg/src/list-view/InsertionIndicator.tsx b/design-system/pkg/src/list-view/InsertionIndicator.tsx index 48e6bc72b..ca1ccf7df 100644 --- a/design-system/pkg/src/list-view/InsertionIndicator.tsx +++ b/design-system/pkg/src/list-view/InsertionIndicator.tsx @@ -3,12 +3,7 @@ import { ItemDropTarget } from '@react-types/shared'; import { assert } from 'emery'; import { useRef } from 'react'; -import { - classNames, - css, - toDataAttributes, - tokenSchema, -} from '@keystar/ui/style'; +import { InsertionIndicatorPrimitive } from '@keystar/ui/drag-and-drop'; import { useListViewContext } from './context'; @@ -17,7 +12,7 @@ interface InsertionIndicatorProps { isPresentationOnly?: boolean; } -export default function InsertionIndicator(props: InsertionIndicatorProps) { +export function InsertionIndicator(props: InsertionIndicatorProps) { let { dropState, dragAndDropHooks } = useListViewContext(); const { target, isPresentationOnly } = props; @@ -41,49 +36,12 @@ export default function InsertionIndicator(props: InsertionIndicatorProps) { return null; } - let maskColor = tokenSchema.color.background.canvas; - let borderColor = tokenSchema.color.background.accentEmphasis; - let borderSize = tokenSchema.size.border.medium; - let circleSize = tokenSchema.size.space.regular; - return (
-
{!isPresentationOnly && (
)} -
+
); } diff --git a/design-system/pkg/src/list-view/ListView.tsx b/design-system/pkg/src/list-view/ListView.tsx index f27964fe1..6ed15e2e8 100644 --- a/design-system/pkg/src/list-view/ListView.tsx +++ b/design-system/pkg/src/list-view/ListView.tsx @@ -1,14 +1,17 @@ import { useGridList } from '@react-aria/gridlist'; import type { DroppableCollectionResult } from '@react-aria/dnd'; import { FocusScope } from '@react-aria/focus'; -import { useCollator, useLocalizedStringFormatter } from '@react-aria/i18n'; +import { useLocalizedStringFormatter } from '@react-aria/i18n'; import { Virtualizer } from '@react-aria/virtualizer'; import { filterDOMProps, mergeProps, useObjectRef } from '@react-aria/utils'; -import type { DroppableCollectionState } from '@react-stately/dnd'; -import { ListLayout } from '@react-stately/layout'; +import type { + DraggableCollectionState, + DroppableCollectionState, +} from '@react-stately/dnd'; import { ListState, useListState } from '@react-stately/list'; import { assert } from 'emery'; import React, { + Key, PropsWithChildren, ReactElement, RefObject, @@ -32,10 +35,12 @@ import { listViewClassList } from './class-list'; import { ListViewProvider, useListViewContext } from './context'; import localizedMessages from './l10n.json'; import { DragPreview as DragPreviewElement } from './DragPreview'; -import InsertionIndicator from './InsertionIndicator'; +import { InsertionIndicator } from './InsertionIndicator'; import { ListViewItem } from './ListViewItem'; +import { ListViewLayout } from './ListViewLayout'; import RootDropIndicator from './RootDropIndicator'; import { ListViewProps } from './types'; +import { ListKeyboardDelegate } from '@react-aria/selection'; const ROW_HEIGHTS = { compact: { @@ -52,35 +57,21 @@ const ROW_HEIGHTS = { }, } as const; -function createLayout( - collator: Intl.Collator, - scale: 'medium' | 'large', - density: keyof typeof ROW_HEIGHTS, - isEmpty: boolean, - _overflowMode: string | undefined -) { - return new ListLayout({ - estimatedRowHeight: ROW_HEIGHTS[density][scale], - padding: 0, - collator, - loaderHeight: isEmpty ? undefined : ROW_HEIGHTS[density][scale], - }); -} - function useListLayout( state: ListState, density: NonNullable['density']>, overflowMode: ListViewProps['overflowMode'] ) { let { scale } = useProvider(); - let collator = useCollator({ usage: 'search', sensitivity: 'base' }); - let isEmpty = state.collection.size === 0; - let layout = useMemo(() => { - return createLayout(collator, scale, density, isEmpty, overflowMode); - }, [collator, scale, density, isEmpty, overflowMode]); + let layout = useMemo( + () => + new ListViewLayout({ + estimatedRowHeight: + overflowMode === 'wrap' ? undefined : ROW_HEIGHTS[density][scale], + }), + [scale, density, overflowMode] + ); - layout.collection = state.collection; - layout.disabledKeys = state.disabledKeys; return layout; } @@ -96,6 +87,7 @@ function ListView( overflowMode = 'truncate', onAction, dragAndDropHooks, + renderEmptyState, ...otherProps } = props; @@ -132,44 +124,50 @@ function ListView( let preview = useRef(null); // DraggableCollectionState; - let dragState = (() => { - if ( - dragAndDropHooks != null && - dragAndDropHooks.useDraggableCollectionState && - dragAndDropHooks.useDraggableCollection - ) { - let state = dragAndDropHooks.useDraggableCollectionState({ - collection, - selectionManager, - preview, - }); - dragAndDropHooks.useDraggableCollection({}, state, domRef); - return state; - } - })(); - + let dragState!: DraggableCollectionState; + if ( + isListDraggable && + dragAndDropHooks?.useDraggableCollectionState && + dragAndDropHooks?.useDraggableCollection + ) { + // consumers are warned when hooks change between renders + // eslint-disable-next-line react-compiler/react-compiler + dragState = dragAndDropHooks.useDraggableCollectionState({ + collection, + selectionManager, + preview, + }); + // eslint-disable-next-line react-compiler/react-compiler + dragAndDropHooks.useDraggableCollection({}, dragState, domRef); + } let layout = useListLayout(state, props.density || 'regular', overflowMode); - // !!0 is false, so we can cast size or undefined and they'll be falsy - layout.allowDisabledKeyFocus = - state.selectionManager.disabledBehavior === 'selection' || - !!dragState?.draggingKeys.size; let DragPreview = dragAndDropHooks?.DragPreview; - let dropState: DroppableCollectionState; + let dropState!: DroppableCollectionState; let droppableCollection: DroppableCollectionResult; let isRootDropTarget: boolean; if ( - dragAndDropHooks && - dragAndDropHooks.useDroppableCollectionState && - dragAndDropHooks.useDroppableCollection + isListDroppable && + dragAndDropHooks?.useDroppableCollectionState && + dragAndDropHooks?.useDroppableCollection ) { + // consumers are warned when hooks change between renders + // eslint-disable-next-line react-compiler/react-compiler dropState = dragAndDropHooks.useDroppableCollectionState({ collection, selectionManager, }); + // eslint-disable-next-line react-compiler/react-compiler droppableCollection = dragAndDropHooks.useDroppableCollection( { - keyboardDelegate: layout, + keyboardDelegate: new ListKeyboardDelegate({ + collection, + disabledKeys: dragState?.draggingKeys.size + ? undefined + : selectionManager.disabledKeys, + ref: domRef, + layoutDelegate: layout, + }), dropTargetDelegate: layout, }, dropState, @@ -183,22 +181,28 @@ function ListView( { ...props, isVirtualized: true, - keyboardDelegate: layout, + layoutDelegate: layout, onAction, }, state, domRef ); - // Sync loading state into the layout. - layout.isLoading = isLoading; - let focusedKey = selectionManager.focusedKey; - // @ts-expect-error + let dropTargetKey: Key | null = null; if (dropState?.target?.type === 'item') { - focusedKey = dropState.target.key; + dropTargetKey = dropState.target.key; + if (dropState.target.dropPosition === 'after') { + // Normalize to the "before" drop position since we only render those in the DOM. + dropTargetKey = + state.collection.getKeyAfter(dropTargetKey) ?? dropTargetKey; + } } + let persistedKeys = useMemo(() => { + return new Set([focusedKey, dropTargetKey].filter(k => k !== null)); + }, [focusedKey, dropTargetKey]); + let hasAnyChildren = useMemo( () => [...collection].some(item => item.hasChildNodes), [collection] @@ -210,19 +214,17 @@ function ListView( density, // @ts-expect-error dragAndDropHooks, - // @ts-expect-error dragState, - // @ts-expect-error dropState, isListDraggable, isListDroppable, - // @ts-expect-error layout, // @ts-expect-error loadingState, // @ts-expect-error onAction, overflowMode, + renderEmptyState, state, }} > @@ -248,8 +250,11 @@ function ListView( isLoading={isLoading} onLoadMore={onLoadMore} ref={domRef} - focusedKey={focusedKey} + persistedKeys={persistedKeys} scrollDirection="vertical" + layout={layout} + layoutOptions={useMemo(() => ({ isLoading }), [isLoading])} + collection={collection} className={classNames( listViewClassList.element('root'), css({ @@ -275,9 +280,6 @@ function ListView( }), styleProps.className )} - layout={layout} - collection={collection} - transitionDuration={isLoading ? 160 : 220} > {(type, item) => { if (type === 'item') { @@ -353,7 +355,6 @@ function ListView( assert(item != null, 'Dragged item must exist in collection.'); - // @ts-expect-error let itemCount = dragState.draggingKeys.size; // @ts-expect-error let itemHeight = layout.getLayoutInfo(dragState.draggedKey).rect diff --git a/design-system/pkg/src/list-view/ListViewItem.tsx b/design-system/pkg/src/list-view/ListViewItem.tsx index a193b50e4..5546c4878 100644 --- a/design-system/pkg/src/list-view/ListViewItem.tsx +++ b/design-system/pkg/src/list-view/ListViewItem.tsx @@ -205,8 +205,7 @@ export function ListViewItem(props: ListViewItemProps) { let isFlushWithContainerBottom = false; if (isLastRow && loadingState !== 'loadingMore') { if ( - layout.getContentSize()?.height >= - layout.virtualizer?.getVisibleRect().height + layout.getContentSize()?.height >= layout.virtualizer?.visibleRect.height ) { isFlushWithContainerBottom = true; } diff --git a/design-system/pkg/src/list-view/ListViewLayout.tsx b/design-system/pkg/src/list-view/ListViewLayout.tsx new file mode 100644 index 000000000..2a6ebf8a4 --- /dev/null +++ b/design-system/pkg/src/list-view/ListViewLayout.tsx @@ -0,0 +1,71 @@ +import { + InvalidationContext, + LayoutInfo, + Rect, +} from '@react-stately/virtualizer'; +import { LayoutNode, ListLayout } from '@react-stately/layout'; +import { Node } from '@react-types/shared'; + +interface ListViewLayoutProps { + isLoading?: boolean; +} + +export class ListViewLayout extends ListLayout { + private isLoading: boolean = false; + + update(invalidationContext: InvalidationContext): void { + this.isLoading = invalidationContext.layoutOptions?.isLoading || false; + super.update(invalidationContext); + } + + protected buildCollection(): LayoutNode[] { + let nodes = super.buildCollection(); + let y = this.contentSize.height; + + if (this.isLoading) { + let rect = new Rect( + 0, + y, + this.virtualizer.visibleRect.width, + nodes.length === 0 + ? this.virtualizer.visibleRect.height + : this.estimatedRowHeight + ); + let loader = new LayoutInfo('loader', 'loader', rect); + let node = { + layoutInfo: loader, + validRect: loader.rect, + }; + nodes.push(node); + this.layoutNodes.set(loader.key, node); + y = loader.rect.maxY; + } + + if (nodes.length === 0) { + let rect = new Rect( + 0, + y, + this.virtualizer.visibleRect.width, + this.virtualizer.visibleRect.height + ); + let placeholder = new LayoutInfo('placeholder', 'placeholder', rect); + let node = { + layoutInfo: placeholder, + validRect: placeholder.rect, + }; + nodes.push(node); + this.layoutNodes.set(placeholder.key, node); + y = placeholder.rect.maxY; + } + + this.contentSize.height = y; + return nodes; + } + + protected buildItem(node: Node, x: number, y: number): LayoutNode { + let res = super.buildItem(node, x, y); + // allow overflow so the focus ring/selection ring can extend outside to overlap with the adjacent items borders + res.layoutInfo.allowOverflow = true; + return res; + } +} diff --git a/design-system/pkg/src/listbox/ListBox.tsx b/design-system/pkg/src/listbox/ListBox.tsx index 0a9b54191..04f15e56b 100644 --- a/design-system/pkg/src/listbox/ListBox.tsx +++ b/design-system/pkg/src/listbox/ListBox.tsx @@ -12,7 +12,7 @@ function ListBox( ) { let domRef = useObjectRef(forwardedRef); let state = useListState(props); - let layout = useListBoxLayout(state); + let layout = useListBoxLayout(); return ; } diff --git a/design-system/pkg/src/listbox/ListBoxBase.tsx b/design-system/pkg/src/listbox/ListBoxBase.tsx index b63452ea9..bfbcdbb59 100644 --- a/design-system/pkg/src/listbox/ListBoxBase.tsx +++ b/design-system/pkg/src/listbox/ListBoxBase.tsx @@ -1,10 +1,8 @@ import { FocusScope } from '@react-aria/focus'; import { useListBox } from '@react-aria/listbox'; -import { useCollator, useLocalizedStringFormatter } from '@react-aria/i18n'; +import { useLocalizedStringFormatter } from '@react-aria/i18n'; import { mergeProps } from '@react-aria/utils'; import { Virtualizer, VirtualizerItem } from '@react-aria/virtualizer'; -import { ListLayout } from '@react-stately/layout'; -import { ListState } from '@react-stately/list'; import { ReusableView } from '@react-stately/virtualizer'; import { Node } from '@react-types/shared'; import { RefObject, forwardRef, ReactElement, ReactNode, useMemo } from 'react'; @@ -15,29 +13,25 @@ import { useStyleProps } from '@keystar/ui/style'; import localizedMessages from './l10n.json'; import { ListBoxContext } from './context'; +import { ListBoxLayout } from './ListBoxLayout'; import { ListBoxOption } from './ListBoxOption'; import { ListBoxSection } from './ListBoxSection'; import { ListBoxBaseProps } from './types'; /** @private */ -export function useListBoxLayout(state: ListState) { +export function useListBoxLayout(): ListBoxLayout { let { scale } = useProvider(); - let collator = useCollator({ usage: 'search', sensitivity: 'base' }); let layout = useMemo( () => - new ListLayout({ + new ListBoxLayout({ estimatedRowHeight: scale === 'large' ? 48 : 32, estimatedHeadingHeight: scale === 'large' ? 33 : 26, - padding: scale === 'large' ? 5 : 4, - loaderHeight: 40, + padding: scale === 'large' ? 5 : 4, // TODO: get from DNA placeholderHeight: scale === 'large' ? 48 : 32, - collator, }), - [collator, scale] + [scale] ); - layout.collection = state.collection; - layout.disabledKeys = state.disabledKeys; return layout; } @@ -49,17 +43,18 @@ function ListBoxBase( let { layout, state, - shouldSelectOnPressUp, - focusOnPointerEnter, - shouldUseVirtualFocus, + shouldFocusOnHover = false, + shouldUseVirtualFocus = false, domProps = {}, - transitionDuration = 0, + isLoading, + showLoadingSpinner = isLoading, onScroll, + renderEmptyState, } = props; let { listBoxProps } = useListBox( { ...props, - keyboardDelegate: layout, + layoutDelegate: layout, isVirtualized: true, }, state, @@ -68,9 +63,6 @@ function ListBoxBase( let styleProps = useStyleProps(props); let stringFormatter = useLocalizedStringFormatter(localizedMessages); - // Sync loading state into the layout. - layout.isLoading = !!props.isLoading; - // This overrides collection view's renderWrapper to support heirarchy of items in sections. // The header is extracted from the children so it can receive ARIA labeling properties. type View = ReusableView, ReactNode>; @@ -108,24 +100,40 @@ function ListBoxBase( ); }; + let focusedKey = state.selectionManager.focusedKey; + let persistedKeys = useMemo( + () => (focusedKey != null ? new Set([focusedKey]) : null), + [focusedKey] + ); + return ( - + ({ + isLoading: showLoadingSpinner, + }), + [showLoadingSpinner] + )} collection={state.collection} renderWrapper={renderWrapper} - transitionDuration={transitionDuration} isLoading={props.isLoading} onLoadMore={props.onLoadMore} - shouldUseVirtualFocus={shouldUseVirtualFocus} onScroll={onScroll} > {(type, item: Node) => { @@ -133,8 +141,6 @@ function ListBoxBase( return ( ); diff --git a/design-system/pkg/src/listbox/ListBoxLayout.tsx b/design-system/pkg/src/listbox/ListBoxLayout.tsx new file mode 100644 index 000000000..47f41ab47 --- /dev/null +++ b/design-system/pkg/src/listbox/ListBoxLayout.tsx @@ -0,0 +1,100 @@ +import { + InvalidationContext, + LayoutInfo, + Rect, +} from '@react-stately/virtualizer'; +import { + LayoutNode, + ListLayout, + ListLayoutOptions, +} from '@react-stately/layout'; +import { Node } from '@react-types/shared'; + +interface ListBoxLayoutProps { + isLoading?: boolean; +} + +interface ListBoxLayoutOptions extends ListLayoutOptions { + placeholderHeight: number; + padding: number; +} + +export class ListBoxLayout extends ListLayout { + private isLoading: boolean = false; + private placeholderHeight: number; + private padding: number; + + constructor(opts: ListBoxLayoutOptions) { + super(opts); + this.placeholderHeight = opts.placeholderHeight; + this.padding = opts.padding; + } + + update(invalidationContext: InvalidationContext): void { + this.isLoading = invalidationContext.layoutOptions?.isLoading || false; + super.update(invalidationContext); + } + + protected buildCollection(): LayoutNode[] { + let nodes = super.buildCollection(this.padding); + let y = this.contentSize.height; + + if (this.isLoading) { + let rect = new Rect(0, y, this.virtualizer.visibleRect.width, 40); + let loader = new LayoutInfo('loader', 'loader', rect); + let node = { + layoutInfo: loader, + validRect: loader.rect, + }; + nodes.push(node); + this.layoutNodes.set(loader.key, node); + y = loader.rect.maxY; + } + + if (nodes.length === 0) { + let rect = new Rect( + 0, + y, + this.virtualizer.visibleRect.width, + this.placeholderHeight ?? this.virtualizer.visibleRect.height + ); + let placeholder = new LayoutInfo('placeholder', 'placeholder', rect); + let node = { + layoutInfo: placeholder, + validRect: placeholder.rect, + }; + nodes.push(node); + this.layoutNodes.set(placeholder.key, node); + y = placeholder.rect.maxY; + } + + this.contentSize.height = y + this.padding; + return nodes; + } + + protected buildSection(node: Node, x: number, y: number): LayoutNode { + // Synthesize a collection node for the header. + let headerNode = { + type: 'header', + key: node.key + ':header', + parentKey: node.key, + value: null, + level: node.level, + hasChildNodes: false, + childNodes: [], + rendered: node.rendered, + textValue: node.textValue, + }; + + // Build layout node for it and adjust y offset of section children. + let header = this.buildSectionHeader(headerNode, x, y); + header.node = headerNode; + header.layoutInfo.parentKey = node.key; + this.layoutNodes.set(headerNode.key, header); + y += header.layoutInfo.rect.height; + + let section = super.buildSection(node, x, y); + section.children?.unshift(header); + return section; + } +} diff --git a/design-system/pkg/src/listbox/ListBoxOption.tsx b/design-system/pkg/src/listbox/ListBoxOption.tsx index 6af8a08d8..98bf1baf7 100644 --- a/design-system/pkg/src/listbox/ListBoxOption.tsx +++ b/design-system/pkg/src/listbox/ListBoxOption.tsx @@ -32,7 +32,7 @@ export function ListBoxOption(props: OptionProps) { let { rendered, key } = item; - let state = useListBoxContext(); + let { state } = useListBoxContext(); let ref = useRef(null); let { diff --git a/design-system/pkg/src/listbox/ListBoxSection.tsx b/design-system/pkg/src/listbox/ListBoxSection.tsx index 059ee3f1d..a1db47824 100644 --- a/design-system/pkg/src/listbox/ListBoxSection.tsx +++ b/design-system/pkg/src/listbox/ListBoxSection.tsx @@ -9,11 +9,11 @@ import { LayoutInfo } from '@react-stately/virtualizer'; import { Node } from '@react-types/shared'; import { Fragment, ReactNode, useRef } from 'react'; +import { Divider } from '@keystar/ui/layout'; import { classNames, css, tokenSchema } from '@keystar/ui/style'; +import { Text } from '@keystar/ui/typography'; import { useListBoxContext } from './context'; -import { Text } from '@keystar/ui/typography'; -import { Divider } from '@keystar/ui/layout'; interface ListBoxSectionProps extends Omit { headerLayoutInfo: LayoutInfo; @@ -37,7 +37,7 @@ export function ListBoxSection(props: ListBoxSectionProps) { }); let { direction } = useLocale(); - let state = useListBoxContext(); + let { state } = useListBoxContext(); return ( diff --git a/design-system/pkg/src/listbox/context.ts b/design-system/pkg/src/listbox/context.ts index 80f7b9261..03e40f8a3 100644 --- a/design-system/pkg/src/listbox/context.ts +++ b/design-system/pkg/src/listbox/context.ts @@ -1,8 +1,14 @@ import { ListState } from '@react-stately/list'; import { assert } from 'emery'; -import { createContext, useContext } from 'react'; +import { type ReactNode, createContext, useContext } from 'react'; -export const ListBoxContext = createContext | null>(null); +interface ListBoxContextValue { + state: ListState; + renderEmptyState?: () => ReactNode; + shouldFocusOnHover: boolean; + shouldUseVirtualFocus: boolean; +} +export const ListBoxContext = createContext(null); export function useListBoxContext() { let context = useContext(ListBoxContext); diff --git a/design-system/pkg/src/listbox/types.ts b/design-system/pkg/src/listbox/types.ts index a2309fbb8..8733a5747 100644 --- a/design-system/pkg/src/listbox/types.ts +++ b/design-system/pkg/src/listbox/types.ts @@ -1,5 +1,4 @@ import { AriaListBoxOptions } from '@react-aria/listbox'; -import { ListLayout } from '@react-stately/layout'; import { ListState } from '@react-stately/list'; import { AriaLabelingProps, @@ -14,6 +13,8 @@ import { HTMLAttributes, ReactNode } from 'react'; import { BaseStyleProps } from '@keystar/ui/style'; +import { ListBoxLayout } from './ListBoxLayout'; + /** @private */ export type ListBoxBaseProps = { autoFocus?: boolean | FocusStrategy; @@ -21,15 +22,15 @@ export type ListBoxBaseProps = { domProps?: HTMLAttributes; focusOnPointerEnter?: boolean; isLoading?: boolean; - layout: ListLayout; + layout: ListBoxLayout; onLoadMore?: () => void; onScroll?: () => void; renderEmptyState?: () => ReactNode; shouldFocusWrap?: boolean; shouldSelectOnPressUp?: boolean; shouldUseVirtualFocus?: boolean; + showLoadingSpinner?: boolean; state: ListState; - transitionDuration?: number; } & AriaListBoxOptions & AriaLabelingProps & BaseStyleProps & diff --git a/design-system/pkg/src/menu/stories/ActionMenu.stories.tsx b/design-system/pkg/src/menu/stories/ActionMenu.stories.tsx index b13527409..3c01eda81 100644 --- a/design-system/pkg/src/menu/stories/ActionMenu.stories.tsx +++ b/design-system/pkg/src/menu/stories/ActionMenu.stories.tsx @@ -137,9 +137,9 @@ export const DOMId = { args: { id: 'my-action-menu' }, }; -export const Quiet = { +export const LowProminence = { render: Template, - args: { isQuiet: true }, + args: { prominence: 'low' }, }; export const Disabled = { diff --git a/design-system/pkg/src/menu/test/Menu.test.tsx b/design-system/pkg/src/menu/test/Menu.test.tsx index f8989b2bd..d8011d4c7 100644 --- a/design-system/pkg/src/menu/test/Menu.test.tsx +++ b/design-system/pkg/src/menu/test/Menu.test.tsx @@ -121,7 +121,7 @@ describe('menu/Menu', () => { let selectedItem = menuItems[3]; expect(selectedItem).toBe(document.activeElement); expect(selectedItem).toHaveAttribute('aria-checked', 'true'); - expect(selectedItem).toHaveAttribute('tabindex', '0'); + expect(selectedItem).toHaveAttribute('tabindex', '-1'); let itemText = within(selectedItem).getByText('Blah'); expect(itemText).toBeTruthy(); let checkmark: HTMLElement | null = within(selectedItem).getByRole( @@ -147,6 +147,7 @@ describe('menu/Menu', () => { expect(onSelectionChange).toHaveBeenCalledTimes(1); expect(onSelectionChange.mock.calls[0][0].has('Bleh')).toBeTruthy(); }); + it('supports `selectedKeys` (controlled)', () => { let tree = renderComponent({ selectionMode: 'single', @@ -160,7 +161,7 @@ describe('menu/Menu', () => { let selectedItem = menuItems[3]; expect(selectedItem).toBe(document.activeElement); expect(selectedItem).toHaveAttribute('aria-checked', 'true'); - expect(selectedItem).toHaveAttribute('tabindex', '0'); + expect(selectedItem).toHaveAttribute('tabindex', '-1'); let itemText = within(selectedItem).getByText('Blah'); expect(itemText).toBeTruthy(); let checkmark: HTMLElement | null = within(selectedItem).getByRole( diff --git a/design-system/pkg/src/menu/test/MenuTrigger.test.tsx b/design-system/pkg/src/menu/test/MenuTrigger.test.tsx index a1c7f4b2e..5a2f6d0f9 100644 --- a/design-system/pkg/src/menu/test/MenuTrigger.test.tsx +++ b/design-system/pkg/src/menu/test/MenuTrigger.test.tsx @@ -7,6 +7,17 @@ import { beforeAll, afterAll, } from '@jest/globals'; +import { + Item, + Menu, + MenuProps, + MenuTrigger, + MenuTriggerProps, + Section, +} from '..'; +import { Button, ButtonProps } from '@keystar/ui/button'; +import { SelectionMode } from '@react-types/shared'; +import { createRef } from 'react'; import { RenderResult, act, @@ -23,17 +34,6 @@ import { waitFor, } from '#test-utils'; -import { - Item, - Menu, - MenuProps, - MenuTrigger, - MenuTriggerProps, - Section, -} from '..'; -import { Button, ButtonProps } from '@keystar/ui/button'; -import { createRef } from 'react'; - let triggerText = 'Menu Button'; let withSection = [ @@ -213,7 +213,10 @@ describe('menu/MenuTrigger', () => { describe('default focus behavior', function () { it('autofocuses the selected item on menu open', function () { - let tree = renderComponent({}, { selectedKeys: ['Bar'] }); + let tree = renderComponent( + {}, + { selectedKeys: ['Bar'], selectionMode: 'single' } + ); act(() => { jest.runAllTimers(); }); @@ -224,7 +227,7 @@ describe('menu/MenuTrigger', () => { }); let menu = tree.getByRole('menu'); expect(menu).toBeTruthy(); - let menuItems = within(menu).getAllByRole('menuitem'); + let menuItems = within(menu).getAllByRole('menuitemradio'); let selectedItem = menuItems[1]; expect(selectedItem).toBe(document.activeElement); firePress(button); @@ -244,7 +247,7 @@ describe('menu/MenuTrigger', () => { jest.runAllTimers(); }); menu = tree.getByRole('menu'); - menuItems = within(menu).getAllByRole('menuitem'); + menuItems = within(menu).getAllByRole('menuitemradio'); selectedItem = menuItems[1]; expect(selectedItem).toBe(document.activeElement); firePress(button); @@ -256,7 +259,7 @@ describe('menu/MenuTrigger', () => { // Opening menu via up arrow still autofocuses the selected item fireEvent.keyDown(button, KEYS.ArrowUp); menu = tree.getByRole('menu'); - menuItems = within(menu).getAllByRole('menuitem'); + menuItems = within(menu).getAllByRole('menuitemradio'); selectedItem = menuItems[1]; expect(selectedItem).toBe(document.activeElement); }); @@ -895,10 +898,20 @@ describe('menu/MenuTrigger', () => { describe('trigger="longPress" focus behavior', function () { installPointerEvent(); - function expectMenuItemToBeActive(tree: RenderResult, idx: number) { + function expectMenuItemToBeActive( + tree: RenderResult, + idx: number, + selectionMode: SelectionMode + ) { + let menuItemRole = 'menuitem'; + if (selectionMode === 'multiple') { + menuItemRole = 'menuitemcheckbox'; + } else if (selectionMode === 'single') { + menuItemRole = 'menuitemradio'; + } let menu = tree.getByRole('menu'); expect(menu).toBeTruthy(); - let menuItems = within(menu).getAllByRole('menuitem'); + let menuItems = within(menu).getAllByRole(menuItemRole); let selectedItem = menuItems[idx < 0 ? menuItems.length + idx : idx]; expect(selectedItem).toBe(document.activeElement); return menu; @@ -907,14 +920,14 @@ describe('menu/MenuTrigger', () => { it('should focus the selected item on menu open', async function () { let tree = renderComponent( { trigger: 'longPress' }, - { selectedKeys: ['Bar'] } + { selectedKeys: ['Bar'], selectionMode: 'single' } ); let button = tree.getByRole('button'); act(() => { fireLongPress(button); jest.runAllTimers(); }); - let menu = expectMenuItemToBeActive(tree, 1); + let menu = expectMenuItemToBeActive(tree, 1, 'single'); act(() => { fireTouch(button); jest.runAllTimers(); @@ -926,7 +939,7 @@ describe('menu/MenuTrigger', () => { // Opening menu via Alt+ArrowUp still autofocuses the selected item fireEvent.keyDown(button, { key: 'ArrowUp', altKey: true }); - menu = expectMenuItemToBeActive(tree, 1); + menu = expectMenuItemToBeActive(tree, 1, 'single'); act(() => { fireTouch(button); @@ -938,7 +951,7 @@ describe('menu/MenuTrigger', () => { // Opening menu via Alt+ArrowDown still autofocuses the selected item fireEvent.keyDown(button, { key: 'ArrowDown', altKey: true }); - menu = expectMenuItemToBeActive(tree, 1); + menu = expectMenuItemToBeActive(tree, 1, 'single'); act(() => { fireTouch(button); @@ -953,14 +966,14 @@ describe('menu/MenuTrigger', () => { let tree = renderComponent({ trigger: 'longPress' }); let button = tree.getByRole('button'); fireEvent.keyDown(button, { key: 'ArrowUp', altKey: true }); - expectMenuItemToBeActive(tree, -1); + expectMenuItemToBeActive(tree, -1, 'none'); }); it('should focus the first item on Alt+ArrowDown if no selectedKeys specified', function () { let tree = renderComponent({ trigger: 'longPress' }); let button = tree.getByRole('button'); fireEvent.keyDown(button, { key: 'ArrowDown', altKey: true }); - expectMenuItemToBeActive(tree, 0); + expectMenuItemToBeActive(tree, 0, 'none'); }); }); }); diff --git a/design-system/pkg/src/overlays/Modal.tsx b/design-system/pkg/src/overlays/Modal.tsx index 3b2554ad1..7290c9714 100644 --- a/design-system/pkg/src/overlays/Modal.tsx +++ b/design-system/pkg/src/overlays/Modal.tsx @@ -43,7 +43,6 @@ export const Modal: ForwardRefExoticComponent< let wrapperRef = useRef(null); return ( - /* @ts-expect-error FIXME: resolve ref inconsistencies */ {children} diff --git a/design-system/pkg/src/overlays/Popover.tsx b/design-system/pkg/src/overlays/Popover.tsx index 04fb9d052..a37f601f6 100644 --- a/design-system/pkg/src/overlays/Popover.tsx +++ b/design-system/pkg/src/overlays/Popover.tsx @@ -42,7 +42,6 @@ export const Popover: ForwardRefExoticComponent< let wrapperRef = useRef(null); return ( - /* @ts-expect-error FIXME: resolve ref inconsistencies */ {children} diff --git a/design-system/pkg/src/overlays/Tray.tsx b/design-system/pkg/src/overlays/Tray.tsx index 555b2064f..9bead4783 100644 --- a/design-system/pkg/src/overlays/Tray.tsx +++ b/design-system/pkg/src/overlays/Tray.tsx @@ -42,7 +42,6 @@ export const Tray: ForwardRefExoticComponent< let wrapperRef = useRef(null); return ( - /* @ts-expect-error FIXME: resolve ref inconsistencies */ {children} diff --git a/design-system/pkg/src/overlays/test/Overlay.test.tsx b/design-system/pkg/src/overlays/test/Overlay.test.tsx index d2a52455a..572db4222 100644 --- a/design-system/pkg/src/overlays/test/Overlay.test.tsx +++ b/design-system/pkg/src/overlays/test/Overlay.test.tsx @@ -35,7 +35,6 @@ const ExampleOverlay = forwardRef(function ExampleOverlay( ) { let nodeRef = useRef(null); return ( - /* @ts-expect-error FIXME: resolve ref inconsistencies */ Overlay content diff --git a/design-system/pkg/src/overlays/types.ts b/design-system/pkg/src/overlays/types.ts index 5a302a883..b83510a80 100644 --- a/design-system/pkg/src/overlays/types.ts +++ b/design-system/pkg/src/overlays/types.ts @@ -39,7 +39,7 @@ export type TrayProps = { export type TransitionProps = { children: ReactNode; isOpen?: boolean; - nodeRef: MutableRefObject; + nodeRef: MutableRefObject; onEnter?: () => void; onEntered?: () => void; onEntering?: () => void; diff --git a/design-system/pkg/src/picker/Picker.tsx b/design-system/pkg/src/picker/Picker.tsx index 70c9c8ec9..89380bb7b 100644 --- a/design-system/pkg/src/picker/Picker.tsx +++ b/design-system/pkg/src/picker/Picker.tsx @@ -60,9 +60,8 @@ function Picker( // We create the listbox layout in Picker and pass it to ListBoxBase below // so that the layout information can be cached even while the listbox is not mounted. - // We also use the layout as the keyboard delegate for type to select. + let layout = useListBoxLayout(); let state = useSelectState(props); - let layout = useListBoxLayout(state); let { labelProps, triggerProps, @@ -70,7 +69,7 @@ function Picker( menuProps, descriptionProps, errorMessageProps, - } = useSelect({ ...props, keyboardDelegate: layout }, state, triggerRef); + } = useSelect(props, state, triggerRef); let isMobile = useIsMobileDevice(); let isLoadingInitial = props.isLoading && state.collection.size === 0; diff --git a/design-system/pkg/src/table/DragPreview.tsx b/design-system/pkg/src/table/DragPreview.tsx new file mode 100644 index 000000000..dce1e1198 --- /dev/null +++ b/design-system/pkg/src/table/DragPreview.tsx @@ -0,0 +1,46 @@ +import { Text } from '@keystar/ui/typography'; +import { Flex } from '@keystar/ui/layout'; +import React from 'react'; + +import { rowDragPreviewClassname } from './styles'; + +type DragPreviewProps = { + itemText?: string; // can't guarantee this will be available + itemCount: number; + height: number; + maxWidth?: number; +}; + +export function DragPreview(props: DragPreviewProps) { + let { itemText, itemCount, height, maxWidth } = props; + let isDraggingMultiple = itemCount > 1; + return ( + /* TODO: export as `DragPreview` from "@keystar/ui/drag-and-drop" for use here and in the list view */ + + {itemText} + + {/* TODO: export as `DragPreviewCount` from "@keystar/ui/drag-and-drop" for use here and in the list view */} + {isDraggingMultiple && ( + + + {itemCount} + + + )} + + ); +} diff --git a/design-system/pkg/src/table/InsertionIndicator.tsx b/design-system/pkg/src/table/InsertionIndicator.tsx new file mode 100644 index 000000000..386cdc54f --- /dev/null +++ b/design-system/pkg/src/table/InsertionIndicator.tsx @@ -0,0 +1,68 @@ +import { useVisuallyHidden } from '@react-aria/visually-hidden'; +import { FocusableElement, ItemDropTarget } from '@react-types/shared'; +import { assert } from 'emery'; +import React, { DOMAttributes, HTMLAttributes, useRef } from 'react'; + +import { InsertionIndicatorPrimitive } from '@keystar/ui/drag-and-drop'; + +import { useTableContext } from './context'; +import { Rect } from '@react-stately/virtualizer'; + +interface InsertionIndicatorProps { + rowProps: HTMLAttributes & DOMAttributes; + target: ItemDropTarget; + visibleRect: Rect; +} + +export function InsertionIndicator(props: InsertionIndicatorProps) { + let { rowProps, target, visibleRect } = props; + let { dropState, dragAndDropHooks } = useTableContext(); + let ref = useRef(null); + + assert( + !!dragAndDropHooks?.useDropIndicator, + 'dragAndDropHooks.useDropIndicator is not defined.' + ); + assert(!!dropState, 'dropState is not defined.'); + + // if the indicator is rendered dnd hooks are defined + // eslint-disable-next-line react-compiler/react-compiler + let { dropIndicatorProps } = dragAndDropHooks.useDropIndicator( + props, + dropState, + ref + ); + let { visuallyHiddenProps } = useVisuallyHidden(); + + let isDropTarget = dropState && dropState.isDropTarget(target); + + if (!isDropTarget && dropIndicatorProps['aria-hidden']) { + return null; + } + + let rowTop = Number(rowProps?.style?.top) ?? 0; + let rowHeight = Number(rowProps?.style?.height) ?? 0; + + return ( +
+ +
+ +
+ ); +} diff --git a/design-system/pkg/src/table/Resizer.tsx b/design-system/pkg/src/table/Resizer.tsx new file mode 100644 index 000000000..d06f99d21 --- /dev/null +++ b/design-system/pkg/src/table/Resizer.tsx @@ -0,0 +1,166 @@ +import { FocusRing } from '@react-aria/focus'; +import { useLocale, useLocalizedStringFormatter } from '@react-aria/i18n'; +import { useUNSTABLE_PortalContext } from '@react-aria/overlays'; +import { useTableColumnResize } from '@react-aria/table'; +import { mergeProps, useObjectRef } from '@react-aria/utils'; +import { TableColumnResizeState } from '@react-stately/table'; +import { GridNode } from '@react-types/grid'; +import { Key, RefObject } from '@react-types/shared'; +import { ColumnSize } from '@react-types/table'; +import React, { + createContext, + ForwardedRef, + PropsWithChildren, + useContext, + useEffect, + useState, +} from 'react'; +import ReactDOM from 'react-dom'; + +import { useTableContext, useVirtualizerContext } from './context'; +import localizedMessages from './l10n.json'; +import { + columnResizerClassname, + columnResizerPlaceholderClassname, +} from './styles'; + +interface ResizerProps { + column: GridNode; + showResizer: boolean; + triggerRef: RefObject; + onResizeStart: (widths: Map) => void; + onResize: (widths: Map) => void; + onResizeEnd: (widths: Map) => void; +} + +const CURSORS = { + ew: 'col-resize', + w: 'w-resize', + e: 'e-resize', +}; + +export const ResizeStateContext = + createContext | null>(null); +export function useResizeStateContext() { + const context = useContext(ResizeStateContext); + if (context === null) { + throw new Error('ResizeStateContext not found'); + } + return context; +} + +function Resizer( + props: ResizerProps, + forwardedRef: ForwardedRef +) { + let { column, showResizer } = props; + let { isEmpty, onFocusedResizer } = useTableContext(); + let layout = useContext(ResizeStateContext)!; + // Virtualizer re-renders, but these components are all cached + // in order to get around that and cause a rerender here, we use context + // but we don't actually need any value, they are available on the layout object + useVirtualizerContext(); + let stringFormatter = useLocalizedStringFormatter( + localizedMessages, + '@react-spectrum/table' + ); + let { direction } = useLocale(); + + let [isPointerDown, setIsPointerDown] = useState(false); + useEffect(() => { + let setDown = (e: PointerEvent) => { + if (e.pointerType === 'mouse') { + setIsPointerDown(true); + } + }; + let setUp = (e: PointerEvent) => { + if (e.pointerType === 'mouse') { + setIsPointerDown(false); + } + }; + document.addEventListener('pointerdown', setDown, { capture: true }); + document.addEventListener('pointerup', setUp, { capture: true }); + return () => { + document.removeEventListener('pointerdown', setDown, { capture: true }); + document.removeEventListener('pointerup', setUp, { capture: true }); + }; + }, []); + + let domRef = useObjectRef(forwardedRef); + let { inputProps, resizerProps } = useTableColumnResize( + mergeProps(props, { + 'aria-label': stringFormatter.format('columnResizer'), + isDisabled: isEmpty, + }), + layout, + domRef + ); + + let isEResizable = + layout.getColumnMinWidth(column.key) >= layout.getColumnWidth(column.key); + let isWResizable = + layout.getColumnMaxWidth(column.key) <= layout.getColumnWidth(column.key); + let isResizing = layout.resizingColumn === column.key; + let cursor = ''; + if (isEResizable) { + cursor = direction === 'rtl' ? CURSORS.w : CURSORS.e; + } else if (isWResizable) { + cursor = direction === 'rtl' ? CURSORS.e : CURSORS.w; + } else { + cursor = CURSORS.ew; + } + + let style = { + ...resizerProps.style, + height: '100%', + display: showResizer ? undefined : 'none', + cursor, + }; + + return ( + <> + +
+ +
+
+ {/* Placeholder so that the title doesn't intersect with space reserved by the resizer when it appears. */} +
+ +
+ + + ); +} + +function CursorOverlay(props: PropsWithChildren<{ show: boolean }>) { + let { show, children } = props; + let { getContainer } = useUNSTABLE_PortalContext(); + return show + ? ReactDOM.createPortal(children, getContainer?.() ?? document.body) + : null; +} + +const _Resizer = React.forwardRef(Resizer); +export { _Resizer as Resizer }; diff --git a/design-system/pkg/src/table/TableView.tsx b/design-system/pkg/src/table/TableView.tsx index 8f6e238fa..d1a5ebc7e 100644 --- a/design-system/pkg/src/table/TableView.tsx +++ b/design-system/pkg/src/table/TableView.tsx @@ -1,22 +1,38 @@ +import { assert } from 'emery'; import { - CSSProperties, - ForwardedRef, - HTMLAttributes, - Key, - ReactElement, - ReactNode, - createContext, - forwardRef, + type CSSProperties, + // type ForwardedRef, + type HTMLAttributes, + type Key, + type PropsWithChildren, + type ReactElement, + type ReactNode, + type RefObject, + Children, + cloneElement, useCallback, - useContext, + useEffect, useMemo, useRef, useState, } from 'react'; +import { useButton } from '@react-aria/button'; +import { + DraggableItemResult, + DropIndicatorAria, + DroppableCollectionResult, + DroppableItemResult, + DropTarget, +} from '@react-aria/dnd'; import { FocusScope, useFocusRing } from '@react-aria/focus'; import { useLocale, useLocalizedStringFormatter } from '@react-aria/i18n'; -import { useHover, usePress } from '@react-aria/interactions'; +import { + getInteractionModality, + useHover, + usePress, +} from '@react-aria/interactions'; +import { ListKeyboardDelegate } from '@react-aria/selection'; import { useTable, useTableCell, @@ -27,166 +43,310 @@ import { useTableSelectAllCheckbox, useTableSelectionCheckbox, } from '@react-aria/table'; -import { filterDOMProps, mergeProps } from '@react-aria/utils'; +import { + mergeProps, + scrollIntoView, + scrollIntoViewport, + useLoadMore, +} from '@react-aria/utils'; import { layoutInfoToStyle, ScrollView, setScrollLeft, - useVirtualizer, VirtualizerItem, + VirtualizerItemOptions, } from '@react-aria/virtualizer'; -import { VisuallyHidden } from '@react-aria/visually-hidden'; -import { TableLayout } from '@react-stately/layout'; +import { useVisuallyHidden, VisuallyHidden } from '@react-aria/visually-hidden'; +import { + DraggableCollectionState, + DroppableCollectionState, +} from '@react-stately/dnd'; import { - TableColumnLayout, TableState, + useTableColumnResizeState, useTableState, } from '@react-stately/table'; -import { ReusableView, useVirtualizerState } from '@react-stately/virtualizer'; +import { + LayoutInfo, + Rect, + ReusableView, + useVirtualizerState, +} from '@react-stately/virtualizer'; import { GridNode } from '@react-types/grid'; -import { ColumnSize } from '@react-types/table'; +import { ColumnSize, TableCollection } from '@react-types/table'; import { Checkbox } from '@keystar/ui/checkbox'; +import { Icon } from '@keystar/ui/icon'; +import { gripVerticalIcon } from '@keystar/ui/icon/icons/gripVerticalIcon'; import { ProgressCircle } from '@keystar/ui/progress'; import { SlotProvider } from '@keystar/ui/slots'; -import { classNames, css, tokenSchema } from '@keystar/ui/style'; +import { + classNames, + css, + FocusRing, + toDataAttributes, + tokenSchema, + useStyleProps, +} from '@keystar/ui/style'; import { TooltipTrigger, Tooltip } from '@keystar/ui/tooltip'; import { Text } from '@keystar/ui/typography'; import { isReactText } from '@keystar/ui/utils'; +import { + TableContext, + TableRowContext, + useTableContext, + useTableRowContext, + VirtualizerContext, +} from './context'; +import { DragPreview as KeystarDragPreview } from './DragPreview'; +import { InsertionIndicator } from './InsertionIndicator'; import localizedMessages from './l10n.json'; +import { Resizer, ResizeStateContext, useResizeStateContext } from './Resizer'; import { SortIndicator, - tableViewClassList, - useBodyStyleProps, - useCellStyleProps, - useHeadStyleProps, - useHeaderWrapperStyleProps, - useRowHeaderStyleProps, - useRowStyleProps, - useSelectionCellStyleProps, - useTableStyleProps, + bodyClassname, + bodyResizeIndicatorClassname, + cellClassname, + cellWrapperClassname, + centeredWrapperClassname, + checkboxCellClassname, + columnResizeIndicatorClassname, + dragCellClassname, + headerCellClassname, + headerClassname, + headerWrapperClassname, + rowClassname, + tableClassname, } from './styles'; -import { ColumnProps, TableProps } from './types'; +import { TableViewLayout } from './TableViewLayout'; +import { ColumnProps, TableCosmeticConfig, TableProps } from './types'; // Constants -const DEFAULT_HEADER_HEIGHT = 34; -const DEFAULT_HIDE_HEADER_CELL_WIDTH = 34; -const SELECTION_CELL_DEFAULT_WIDTH = 34; +const DEFAULT_HEADER_HEIGHT = 36; +const DEFAULT_HIDE_HEADER_CELL_WIDTH = 36; +const SELECTION_CELL_DEFAULT_WIDTH = 36; +const DRAG_BUTTON_CELL_DEFAULT_WIDTH = 20; const ROW_HEIGHTS = { - compact: 26, - regular: 34, - spacious: 42, + compact: 28, + regular: 36, + spacious: 44, } as const; -// Context - -export interface TableContextValue { - state: TableState; - layout: TableLayout & { state: TableState }; - isEmpty: boolean; -} - -// @ts-ignore FIXME: generics in context? -export const TableContext = createContext>(null); -export function useTableContext() { - return useContext(TableContext); -} +// Main -export const VirtualizerContext = createContext(null); -export function useVirtualizerContext() { - return useContext(VirtualizerContext); -} +export function TableView( + props: TableProps + // forwardedRef: ForwardedRef +) { + let { + density = 'regular', + prominence = 'default', + dragAndDropHooks, + onAction, + onResizeEnd: propsOnResizeEnd, + onResizeStart: propsOnResizeStart, + overflowMode = 'truncate', + } = props; -// Main + let isTableDraggable = !!dragAndDropHooks?.useDraggableCollectionState; + let isTableDroppable = !!dragAndDropHooks?.useDroppableCollectionState; + let dragHooksProvided = useRef(isTableDraggable); + let dropHooksProvided = useRef(isTableDroppable); + let state = useTableState({ + ...props, + showSelectionCheckboxes: true, + showDragButtons: isTableDraggable, + selectionBehavior: 'toggle', + }); -export function TableView(props: TableProps) { + useEffect(() => { + if (dragHooksProvided.current !== isTableDraggable) { + console.warn( + 'Drag hooks were provided during one render, but not another. This should be avoided as it may produce unexpected behavior.' + ); + } + if (dropHooksProvided.current !== isTableDroppable) { + console.warn( + 'Drop hooks were provided during one render, but not another. This should be avoided as it may produce unexpected behavior.' + ); + } + if ('expandedKeys' in state && (isTableDraggable || isTableDroppable)) { + console.warn( + 'Drag and drop is not yet fully supported with expandable rows and may produce unexpected results.' + ); + } + }, [isTableDraggable, isTableDroppable, state]); + + // Starts when the user selects resize from the menu, ends when resizing ends + // used to control the visibility of the resizer Nubbin + let [isInResizeMode, setIsInResizeMode] = useState(false); + // Starts when the resizer is actually moved + // entering resizing/exiting resizing doesn't trigger a render + // with table layout, so we need to track it here + let [, setIsResizing] = useState(false); + + // TODO: support consumer provided ref + // let domRef = useObjectRef(forwardedRef); let domRef = useRef(null); let headerRef = useRef(null); let bodyRef = useRef(null); - let { direction } = useLocale(); - let stringFormatter = useLocalizedStringFormatter(localizedMessages); + let styleProps = useStyleProps(props); + + let layout = useMemo( + () => + new TableViewLayout({ + // If props.overflowMode is wrap, then use estimated heights based on scale, otherwise use fixed heights. + rowHeight: + props.overflowMode === 'wrap' ? undefined : ROW_HEIGHTS[density], + estimatedRowHeight: + props.overflowMode === 'wrap' ? ROW_HEIGHTS[density] : undefined, + headingHeight: + props.overflowMode === 'wrap' ? undefined : DEFAULT_HEADER_HEIGHT, + estimatedHeadingHeight: + props.overflowMode === 'wrap' ? DEFAULT_HEADER_HEIGHT : undefined, + }), + [props.overflowMode, density] + ); + + let dragState: DraggableCollectionState | undefined = undefined; + let preview = useRef(null); + if ( + dragAndDropHooks?.useDraggableCollection && + dragAndDropHooks?.useDraggableCollectionState + ) { + dragState = dragAndDropHooks.useDraggableCollectionState({ + collection: state.collection, + selectionManager: state.selectionManager, + preview, + }); + dragAndDropHooks.useDraggableCollection({}, dragState, domRef); + } + + let DragPreview = dragAndDropHooks?.DragPreview; + let dropState: DroppableCollectionState | undefined = undefined; + let droppableCollection: DroppableCollectionResult | undefined = undefined; + let isRootDropTarget = false; + if ( + dragAndDropHooks?.useDroppableCollection && + dragAndDropHooks?.useDroppableCollectionState + ) { + dropState = dragAndDropHooks.useDroppableCollectionState({ + collection: state.collection, + selectionManager: state.selectionManager, + }); + droppableCollection = dragAndDropHooks.useDroppableCollection( + { + keyboardDelegate: new ListKeyboardDelegate({ + collection: state.collection, + disabledKeys: state.selectionManager.disabledKeys, + ref: domRef, + layoutDelegate: layout, + }), + dropTargetDelegate: layout, + }, + dropState, + domRef + ); - let { density = 'regular', overflowMode } = props; + isRootDropTarget = dropState.isDropTarget({ type: 'root' }); + } - // Renderers + let { gridProps } = useTable( + { + ...props, + isVirtualized: true, + layoutDelegate: layout, + onRowAction: onAction ?? props.onRowAction, + scrollRef: bodyRef, + }, + state, + domRef + ); + let [headerMenuOpen, setHeaderMenuOpen] = useState(false); + let [headerRowHovered, setHeaderRowHovered] = useState(false); // This overrides collection view's renderWrapper to support DOM hierarchy. - type View = ReusableView, ReactNode>; - let renderWrapper = ( - parent: View, - reusableView: View, - children: View[], - renderChildren: (views: View[]) => ReactElement[] - ) => { - let style = layoutInfoToStyle( - reusableView.layoutInfo!, - direction, - parent && parent.layoutInfo - ); - if (style.overflow === 'hidden') { - style.overflow = 'visible'; // needed to support position: sticky - } + let renderWrapper = useCallback( + ( + parent: View, + reusableView: View, + children: View[], + renderChildren: (views: View[]) => ReactElement[] + ) => { + if (reusableView.viewType === 'rowgroup') { + return ( + + {renderChildren(children)} + + ); + } - if (reusableView.viewType === 'rowgroup') { - return ( - - {renderChildren(children)} - - ); - } + if (reusableView.viewType === 'header') { + return ( + + {renderChildren(children)} + + ); + } - if (reusableView.viewType === 'header') { - return ( - - {renderChildren(children)} - - ); - } + if (reusableView.viewType === 'row') { + return ( + + {renderChildren(children)} + + ); + } - if (reusableView.viewType === 'row') { - return ( - - {renderChildren(children)} - - ); - } + if (reusableView.viewType === 'headerrow') { + return ( + + {renderChildren(children)} + + ); + } - if (reusableView.viewType === 'headerrow') { return ( - - {renderChildren(children)} - + {reusableView.rendered} + ); - } - - return ( - - {reusableView.rendered} - - ); - }; + }, + [] + ); - let renderView = (type: string, item: GridNode) => { + let renderView = useCallback((type: string, item: GridNode) => { switch (type) { case 'header': case 'rowgroup': @@ -199,14 +359,20 @@ export function TableView(props: TableProps) { return ; } - return ; + if (item.props.isDragButtonCell) { + return ; + } + + return ; } case 'placeholder': return (
1 ? item.colspan : undefined} + aria-colindex={item.index && item.index + 1} + aria-colspan={ + item.colspan && item.colspan > 1 ? item.colspan : undefined + } /> ); case 'column': @@ -214,6 +380,11 @@ export function TableView(props: TableProps) { return ; } + if (item.props.isDragButtonCell) { + return ; + } + + // TODO: consider this case, what if we have hidden headers and a empty table if (item.props.hideHeader) { return ( @@ -223,460 +394,1076 @@ export function TableView(props: TableProps) { ); } + // NOTE: don't allow resizing on the last column, it creates a weird UX + // where the combined column width can be less than the table + if (item.props.allowsResizing && !!item.nextKey) { + return ; + } + return ; case 'loader': - return ( - - 0 - ? stringFormatter.format('loadingMore') - : stringFormatter.format('loading') - } - /> - - ); + return ; case 'empty': { - let emptyState = props.renderEmptyState - ? props.renderEmptyState() - : null; - if (emptyState == null) { - return null; - } - - return {emptyState}; + return ; } } - }; + }, []); + + let [isVerticalScrollbarVisible, setVerticalScollbarVisible] = + useState(false); + let [isHorizontalScrollbarVisible, setHorizontalScollbarVisible] = + useState(false); + let viewport = useRef({ x: 0, y: 0, width: 0, height: 0 }); + let onVisibleRectChange = useCallback((rect: Rect) => { + if ( + viewport.current.width === rect.width && + viewport.current.height === rect.height + ) { + return; + } + viewport.current = rect; + if (bodyRef.current) { + setVerticalScollbarVisible( + bodyRef.current.clientWidth + 2 < bodyRef.current.offsetWidth + ); + setHorizontalScollbarVisible( + bodyRef.current.clientHeight + 2 < bodyRef.current.offsetHeight + ); + } + }, []); + let { isFocusVisible, focusProps } = useFocusRing(); + let isEmpty = state.collection.size === 0; - let state = useTableState({ - ...props, - showSelectionCheckboxes: props.selectionMode === 'multiple', - }); + let onFocusedResizer = () => { + if (bodyRef.current && headerRef.current) { + bodyRef.current.scrollLeft = headerRef.current.scrollLeft; + } + }; - const getDefaultWidth = useCallback( - ({ - props: { hideHeader, isSelectionCell }, - }: GridNode): ColumnSize | null | undefined => { - if (hideHeader) { - return DEFAULT_HIDE_HEADER_CELL_WIDTH; - } else if (isSelectionCell) { - return SELECTION_CELL_DEFAULT_WIDTH; - } + let onResizeStart = useCallback( + (widths: Map) => { + setIsResizing(true); + propsOnResizeStart?.(widths); }, - [] + [setIsResizing, propsOnResizeStart] ); - const getDefaultMinWidth = useCallback( - ({ - props: { hideHeader, isSelectionCell }, - }: GridNode): ColumnSize | null | undefined => { - if (hideHeader) { - return DEFAULT_HIDE_HEADER_CELL_WIDTH; - } else if (isSelectionCell) { - return SELECTION_CELL_DEFAULT_WIDTH; - } - - return 75; + let onResizeEnd = useCallback( + (widths: Map) => { + setIsInResizeMode(false); + setIsResizing(false); + propsOnResizeEnd?.(widths); }, - [] - ); - let columnLayout = useMemo( - () => - new TableColumnLayout({ - getDefaultWidth, - getDefaultMinWidth, - }), - [getDefaultWidth, getDefaultMinWidth] + [propsOnResizeEnd, setIsInResizeMode, setIsResizing] ); - const [initialCollection] = useState(state.collection); - - let tableLayout = useMemo( - () => - new TableLayout({ - // If props.rowHeight is auto, then use estimated heights, otherwise use fixed heights. - rowHeight: overflowMode === 'wrap' ? undefined : ROW_HEIGHTS[density], - estimatedRowHeight: - overflowMode === 'wrap' ? ROW_HEIGHTS[density] : undefined, - headingHeight: - overflowMode === 'wrap' ? undefined : DEFAULT_HEADER_HEIGHT, - estimatedHeadingHeight: - overflowMode === 'wrap' ? DEFAULT_HEADER_HEIGHT : undefined, - columnLayout, - initialCollection, - }), - [overflowMode, density, columnLayout, initialCollection] - ); + let focusedKey = state.selectionManager.focusedKey; + let dropTargetKey: Key | null = null; + if (dropState?.target?.type === 'item') { + dropTargetKey = dropState.target.key; + if ( + dropState.target.dropPosition === 'before' && + dropTargetKey !== state.collection.getFirstKey() + ) { + // Normalize to the "after" drop position since we only render those in the DOM. + // The exception to this is for the first row in the table, where we also render the "before" position. + dropTargetKey = state.collection.getKeyBefore(dropTargetKey); + } + } - // Use a proxy so that a new object is created for each render so that alternate instances aren't affected by mutation. - // This can be thought of as equivalent to `{…tableLayout, tableState: state}`, but works with classes as well. - let layout = useMemo(() => { - let proxy = new Proxy(tableLayout, { - get(target, prop, receiver) { - return prop === 'tableState' - ? state - : Reflect.get(target, prop, receiver); - }, - }); - return proxy as TableLayout & { state: TableState }; - }, [state, tableLayout]); + let persistedKeys = useMemo(() => { + return new Set([focusedKey, dropTargetKey].filter(k => k !== null)); + }, [focusedKey, dropTargetKey]); - let { gridProps } = useTable( - { ...props, isVirtualized: true, layout }, - state, - domRef + let mergedProps = mergeProps( + isTableDroppable ? droppableCollection?.collectionProps : {}, + gridProps, + focusProps ); - - const isEmpty = state.collection.size === 0; + if (dragAndDropHooks?.isVirtualDragging?.()) { + delete mergedProps.tabIndex; + } + let cosmeticConfig = { + density, + overflowMode, + prominence, + }; return ( - + {}), + onResizeEnd, + onResizeStart, + renderEmptyState: props.renderEmptyState, + setHeaderMenuOpen, + setIsInResizeMode, + state, + }} + > + {DragPreview && isTableDraggable && ( + + {() => { + if (dragAndDropHooks?.renderPreview && dragState?.draggingKeys) { + return dragAndDropHooks.renderPreview( + dragState.draggingKeys, + dragState.draggedKey + ); + } + let itemCount = dragState?.draggingKeys.size ?? 0; + let maxWidth = bodyRef?.current?.getBoundingClientRect().width; + let height = ROW_HEIGHTS[density]; + let itemText = state.collection.getTextValue?.( + dragState?.draggedKey! + ); + return ( + + ); + }} + + )} ); } -type View = ReusableView, ReactNode>; -type TableVirtualizerProps = TableProps & { - collection: TableState['collection']; - layout: TableLayout & { state: TableState }; - domRef: React.RefObject; - headerRef: React.RefObject; - bodyRef: React.RefObject; - renderView: ( - type: string, - item: GridNode - ) => ReactElement | null | undefined; - renderWrapper: ( - parent: View, - reusableView: View, - children: View[], - renderChildren: (views: View[]) => ReactElement[] +type View = ReusableView, ReactNode>; +interface TableVirtualizerProps extends HTMLAttributes { + cosmeticConfig: TableCosmeticConfig; + tableState: TableState; + layout: TableViewLayout; + collection: TableCollection; + persistedKeys: Set | null; + renderView: (type: string, content: GridNode) => ReactElement; + renderWrapper?: ( + parent: View | null, + reusableView: View, + children: View[], + renderChildren: (views: View[]) => ReactElement[] ) => ReactElement; -}; + domRef: RefObject; + bodyRef: RefObject; + headerRef: RefObject; + onVisibleRectChange: (rect: Rect) => void; + isFocusVisible: boolean; + isVirtualDragging: boolean; + isRootDropTarget: boolean; +} -function TableVirtualizer(props: TableVirtualizerProps) { +function TableVirtualizer(props: TableVirtualizerProps) { let { + cosmeticConfig, + tableState, layout, collection, - // focusedKey, + persistedKeys, renderView, renderWrapper, domRef, bodyRef, headerRef, - disallowEmptySelection: UNUSED_disallowEmptySelection, - onRowAction: UNUSED_onRowAction, - onSelectionChange: UNUSED_onSelectionChange, - onSortChange: UNUSED_onSortChange, - overflowMode: UNUSED_overflowMode, - renderEmptyState: UNUSED_renderEmptyState, - selectedKeys: UNUSED_selectedKeys, - sortDescriptor: UNUSED_sortDescriptor, - selectionMode, + onVisibleRectChange: onVisibleRectChangeProp, + isFocusVisible, + isVirtualDragging, + isRootDropTarget, ...otherProps } = props; - let { direction } = useLocale(); let loadingState = collection.body.props.loadingState; let isLoading = loadingState === 'loading' || loadingState === 'loadingMore'; let onLoadMore = collection.body.props.onLoadMore; + let [tableWidth, setTableWidth] = useState(0); + + const slots = useMemo(() => { + return { text: { truncate: cosmeticConfig.overflowMode === 'truncate' } }; + }, [cosmeticConfig.overflowMode]); + + const getDefaultWidth = useCallback( + ({ + props: { hideHeader, isSelectionCell, showDivider, isDragButtonCell }, + }: GridNode): ColumnSize | null | undefined => { + if (hideHeader) { + let width = DEFAULT_HIDE_HEADER_CELL_WIDTH; + return showDivider ? width + 1 : width; + } else if (isSelectionCell) { + return SELECTION_CELL_DEFAULT_WIDTH; + } else if (isDragButtonCell) { + return DRAG_BUTTON_CELL_DEFAULT_WIDTH; + } + }, + [] + ); + + const getDefaultMinWidth = useCallback( + ({ + props: { hideHeader, isSelectionCell, showDivider, isDragButtonCell }, + }: GridNode): ColumnSize | null | undefined => { + if (hideHeader) { + let width = DEFAULT_HIDE_HEADER_CELL_WIDTH; + return showDivider ? width + 1 : width; + } else if (isSelectionCell) { + return SELECTION_CELL_DEFAULT_WIDTH; + } else if (isDragButtonCell) { + return DRAG_BUTTON_CELL_DEFAULT_WIDTH; + } + return 75; + }, + [] + ); + + let columnResizeState = useTableColumnResizeState( + { + tableWidth, + getDefaultWidth, + getDefaultMinWidth, + }, + tableState + ); - let virtualizerState = useVirtualizerState({ + let state = useVirtualizerState, ReactNode>({ layout, collection, renderView, - renderWrapper, onVisibleRectChange(rect) { - let bodyEl = bodyRef.current; - if (bodyEl) { - bodyEl.scrollTop = rect.y; - setScrollLeft(bodyEl, direction, rect.x); + if (bodyRef.current) { + bodyRef.current.scrollTop = rect.y; + setScrollLeft(bodyRef.current, direction, rect.x); } }, + persistedKeys, + layoutOptions: useMemo( + () => ({ + columnWidths: columnResizeState.columnWidths, + }), + [columnResizeState.columnWidths] + ), }); - let styleProps = useTableStyleProps(props); + + useLoadMore({ isLoading, onLoadMore, scrollOffset: 1 }, bodyRef); + let onVisibleRectChange = useCallback( + (rect: Rect) => { + state.setVisibleRect(rect); + }, + [state] + ); + + let onVisibleRectChangeMemo = useCallback( + (rect: Rect) => { + setTableWidth(rect.width); + onVisibleRectChange(rect); + onVisibleRectChangeProp(rect); + }, + [onVisibleRectChange, onVisibleRectChangeProp] + ); + + // this effect runs whenever the contentSize changes, it doesn't matter what the content size is + // only that it changes in a resize, and when that happens, we want to sync the body to the + // header scroll position + useEffect(() => { + if ( + getInteractionModality() === 'keyboard' && + bodyRef.current && + domRef.current && + headerRef.current && + headerRef.current.contains(document.activeElement) && + document.activeElement instanceof HTMLElement + ) { + scrollIntoView(headerRef.current, document.activeElement); + scrollIntoViewport(document.activeElement, { + containingElement: domRef.current, + }); + bodyRef.current.scrollLeft = headerRef.current.scrollLeft; + } + }, [state.contentSize, headerRef, bodyRef, domRef]); + + let headerHeight = layout.getLayoutInfo('header')?.rect.height || 0; // Sync the scroll position from the table body to the header container. - let syncScroll = useCallback(() => { - let bodyEl = bodyRef.current; - let headerEl = headerRef.current; - if (bodyEl && headerEl) { - headerEl.scrollLeft = bodyEl.scrollLeft; + let onScroll = useCallback(() => { + if (bodyRef.current && headerRef.current) { + headerRef.current.scrollLeft = bodyRef.current.scrollLeft; } }, [bodyRef, headerRef]); - let scrollToItem = useCallback( - (key: Key) => { - let item = collection.getItem(key); - let column = collection.columns[0]; - let virtualizer = virtualizerState.virtualizer; - - virtualizer.scrollToItem(key, { - duration: 0, - // Prevent scrolling to the top when clicking on column headers. - shouldScrollY: item?.type !== 'column', - // Offset scroll position by width of selection cell - // (which is sticky and will overlap the cell we're scrolling to). - offsetX: - column.props.isSelectionCell || column.props.isDragButtonCell - ? layout.getColumnWidth(column.key) - : 0, - }); - - // Sync the scroll positions of the column headers and the body so scrollIntoViewport can - // properly decide if the column is outside the viewport or not - syncScroll(); - }, - [collection, layout, syncScroll, virtualizerState.virtualizer] - ); - let memoedVirtualizerProps = useMemo( + let resizerPosition = + columnResizeState.resizingColumn != null + ? layout.getLayoutInfo(columnResizeState.resizingColumn).rect.maxX - 2 + : 0; + + // minimize re-render caused on Resizers by memoing this + let resizingColumnWidth = + columnResizeState.resizingColumn != null + ? columnResizeState.getColumnWidth(columnResizeState.resizingColumn) + : 0; + let resizingColumn = useMemo( () => ({ - scrollToItem, - isLoading, - onLoadMore, + width: resizingColumnWidth, + key: columnResizeState.resizingColumn, }), - [scrollToItem, isLoading, onLoadMore] - ); - let { virtualizerProps, scrollViewProps } = useVirtualizer( - memoedVirtualizerProps, - virtualizerState, - domRef + [resizingColumnWidth, columnResizeState.resizingColumn] ); - let mergedProps = mergeProps(filterDOMProps(otherProps), virtualizerProps); + if (isVirtualDragging) { + delete otherProps.tabIndex; + } - const [headerView, bodyView] = virtualizerState.visibleViews; - let headerHeight = layout.getLayoutInfo('header')?.rect.height || 0; + let firstColumn = collection.columns[0]; + let scrollPadding = 0; + if (firstColumn.props.isSelectionCell || firstColumn.props.isDragButtonCell) { + scrollPadding = columnResizeState.getColumnWidth(firstColumn.key); + } - let bodyStyleProps = useBodyStyleProps({ style: { flex: 1 } }); + // @ts-expect-error `renderWrapper` will be defined + let visibleViews = renderChildren(null, state.visibleViews, renderWrapper); return ( - -
- - {headerView} - - - {bodyView} - -
-
+ + +
+
+ + {visibleViews[0]} + +
+ + + {visibleViews[1]} +
+ + +
+ + ); } // Styled components // ------------------------------ -function TableHead({ children, style }: HTMLAttributes) { +type PropsWithLayoutInfos = { + layoutInfo: LayoutInfo; + parent: LayoutInfo | null; +} & HTMLAttributes; + +function renderChildren( + parent: View | null, + views: View[], + renderWrapper: NonNullable['renderWrapper']> +): ReactElement[] { + return views.map(view => { + return renderWrapper( + parent, + view, + view.children ? Array.from(view.children) : [], + childViews => renderChildren(view, childViews, renderWrapper) + ); + }); +} + +function useStyle(layoutInfo: LayoutInfo, parent: LayoutInfo | null) { + let { direction } = useLocale(); + let style = layoutInfoToStyle(layoutInfo, direction, parent); + if (style.overflow === 'hidden') { + style.overflow = 'visible'; // needed to support position: sticky + } + return style; +} + +function TableHead({ + children, + layoutInfo, + parent, + ...otherProps +}: PropsWithLayoutInfos) { let { rowGroupProps } = useTableRowGroup(); - let styleProps = useHeadStyleProps({ style }); + let style = useStyle(layoutInfo, parent); return ( -
+
{children}
); } -function TableBody(props: HTMLAttributes) { - let { rowGroupProps } = useTableRowGroup(); - return
; +function TableDragHeaderCell(props: { column: GridNode }) { + let { column } = props; + let ref = useRef(null); + let { state } = useTableContext(); + let { columnHeaderProps } = useTableColumnHeader( + { + node: column, + isVirtualized: true, + }, + state, + ref + ); + let stringFormatter = useLocalizedStringFormatter(localizedMessages); + + return ( + +
+ {stringFormatter.format('drag')} +
+
+ ); } -const TableHeaderWrapper = forwardRef(function TableHeaderWrapper( - props: { - children: ReactNode; - style: CSSProperties; - }, - ref: ForwardedRef -) { - let styleProps = useHeaderWrapperStyleProps(props); +function TableBody({ + children, + layoutInfo, + parent, + ...otherProps +}: PropsWithLayoutInfos) { + let { rowGroupProps } = useTableRowGroup(); + let style = useStyle(layoutInfo, parent); return ( -
- {props.children} +
+ {children}
); -}); +} function TableHeaderRow(props: { children: ReactNode; + onHoverChange: (hovered: boolean) => void; item: any; style: CSSProperties; }) { let ref = useRef(null); let { state } = useTableContext(); + let { hoverProps } = useHover(props); let { rowProps } = useTableHeaderRow( { node: props.item, isVirtualized: true }, state, ref ); - let styleProps = useRowHeaderStyleProps(props); + // let styleProps = useRowHeaderStyleProps(props); return ( -
+
{props.children}
); } -function TableColumnHeader({ column }: { column: any }) { +function TableColumnHeader(props: { column: GridNode }) { + let { column } = props; + let columnProps = column.props as ColumnProps; + let ref = useRef(null); - let { state } = useTableContext(); + let { cosmeticConfig, isEmpty, state } = useTableContext(); let { columnHeaderProps } = useTableColumnHeader( { node: column, isVirtualized: true }, state, ref ); - let { isFocusVisible, focusProps } = useFocusRing(); - let columnProps = column.props as ColumnProps; - let cellStyleProps = useCellStyleProps(columnProps, { isFocusVisible }); + let { hoverProps, isHovered } = useHover({ + ...props, + isDisabled: isEmpty || !columnProps.allowsSorting, + }); + let slots = useMemo(() => { + return { + text: { color: 'inherit', weight: 'medium', truncate: true }, + } as const; + }, []); return ( -
- {columnProps.allowsSorting && columnProps.align === 'end' && ( - - )} + +
+ + {columnProps.hideHeader ? ( + {column.rendered} + ) : isReactText(column.rendered) ? ( + {column.rendered} + ) : ( + column.rendered + )} + + + {columnProps.allowsSorting && } +
+
+ ); +} - {columnProps.hideHeader ? ( - {column.rendered} - ) : isReactText(column.rendered) ? ( - - {column.rendered} - - ) : ( - column.rendered - )} +function ResizableTableColumnHeader(props: { column: GridNode }) { + let { column } = props; + let ref = useRef(null); + let triggerRef = useRef(null); + let resizingRef = useRef(null); + let { + state, + onResizeStart, + onResize, + onResizeEnd, + headerRowHovered, + isEmpty, + } = useTableContext(); + let columnResizeState = useResizeStateContext(); + let { pressProps, isPressed } = usePress({ isDisabled: isEmpty }); + let { columnHeaderProps } = useTableColumnHeader( + { + node: column, + isVirtualized: true, + }, + state, + ref + ); + let { hoverProps, isHovered } = useHover({ ...props, isDisabled: isEmpty }); + let slots = useMemo(() => { + return { + text: { color: 'inherit', weight: 'medium', truncate: true }, + } as const; + }, []); + + let resizingColumn = columnResizeState.resizingColumn; + let showResizer = + !isEmpty && + ((headerRowHovered && getInteractionModality() !== 'keyboard') || + resizingColumn != null); + let alignment = 'start'; + if ( + column.props.align === 'center' || + (column.colspan && column.colspan > 1) + ) { + alignment = 'center'; + } else if (column.props.align === 'end') { + alignment = 'end'; + } - {columnProps.allowsSorting && columnProps.align !== 'end' && ( - - )} -
+ return ( + +
+ + {column.props.hideHeader ? ( + {column.rendered} + ) : isReactText(column.rendered) ? ( + {column.rendered} + ) : ( + column.rendered + )} + + + {column.props.allowsSorting && } + + +
+
+ ); } -function TableRow({ - children, - hasAction, - item, - style, -}: { - children: ReactNode; - hasAction: boolean; - item: any; - style: CSSProperties; -}) { - let ref = useRef(null); - let { state } = useTableContext(); - let allowsInteraction = - state.selectionManager.selectionMode !== 'none' || hasAction; - let isDisabled = !allowsInteraction || state.disabledKeys.has(item.key); - - let { rowProps } = useTableRow( - { node: item, isVirtualized: true }, +function TableRow(props: PropsWithLayoutInfos & { item: GridNode }) { + let { item, children, layoutInfo, parent, ...otherProps } = props; + let ref = useRef(null); + let { + state, + layout, + dragAndDropHooks, + isTableDraggable, + isTableDroppable, + dragState, + dropState, + } = useTableContext(); + let { rowProps, hasAction, allowsSelection } = useTableRow( + { + node: item, + isVirtualized: true, + shouldSelectOnPressUp: isTableDraggable, + }, state, ref ); + + let isSelected = state.selectionManager.isSelected(item.key); + let isDisabled = state.selectionManager.isDisabled(item.key); + let isInteractive = + !isDisabled && (hasAction || allowsSelection || isTableDraggable); + let isDroppable = isTableDroppable && !isDisabled; + let { pressProps, isPressed } = usePress({ isDisabled: !isInteractive }); + // The row should show the focus background style when any cell inside it is focused. // If the row itself is focused, then it should have a blue focus indicator on the left. - let { isFocusVisible: isFocusWithin, focusProps: focusWithinProps } = + let { isFocusVisible: isFocusVisibleWithin, focusProps: focusWithinProps } = useFocusRing({ within: true }); let { isFocusVisible, focusProps } = useFocusRing(); - let { hoverProps, isHovered } = useHover({ isDisabled }); - let { pressProps, isPressed } = usePress({ isDisabled }); - let styleProps = useRowStyleProps( - { style }, - { - isFocusVisible, - isFocusWithin, - isHovered, - isPressed, + let { hoverProps, isHovered } = useHover({ isDisabled: !isInteractive }); + let isFirstRow = + state.collection.rows.find(row => row.level === 1)?.key === item.key; + let isLastRow = item.nextKey == null; + // Figure out if the TableView content is equal or greater in height to the container. If so, we'll need to round the bottom + // border corners of the last row when selected. + let isFlushWithContainerBottom = false; + if (isLastRow) { + if ( + layout.getContentSize()?.height >= layout.virtualizer?.visibleRect.height + ) { + isFlushWithContainerBottom = true; } + } + + let draggableItem: DraggableItemResult | null = null; + if (isTableDraggable) { + assert(!!dragAndDropHooks?.useDraggableItem); + assert(!!dragState); + draggableItem = dragAndDropHooks.useDraggableItem( + { key: item.key, hasDragButton: true }, + dragState + ); + if (isDisabled) { + draggableItem = null; + } + } + let droppableItem: DroppableItemResult | null = null; + let isDropTarget: boolean = false; + let dropIndicator: DropIndicatorAria | null = null; + let dropIndicatorRef = useRef(null); + if (isTableDroppable) { + assert(!!dragAndDropHooks?.useDroppableItem); + assert(!!dragAndDropHooks?.useDropIndicator); + assert(!!dropState); + let target = { + type: 'item', + key: item.key, + dropPosition: 'on', + } as DropTarget; + isDropTarget = dropState.isDropTarget(target); + droppableItem = dragAndDropHooks.useDroppableItem( + { target }, + dropState, + dropIndicatorRef + ); + dropIndicator = dragAndDropHooks.useDropIndicator( + { target }, + dropState, + dropIndicatorRef + ); + } + + let dragButtonRef = useRef(null); + let { buttonProps: dragButtonProps } = useButton( + { + ...draggableItem?.dragButtonProps, + elementType: 'div', + }, + dragButtonRef + ); + + let style = useStyle(layoutInfo, parent); + + let mergedRowProps = mergeProps( + rowProps, + otherProps, + { style }, + focusWithinProps, + focusProps, + hoverProps, + pressProps, + draggableItem?.dragProps ); + // Remove tab index from list row if performing a screenreader drag. This + // prevents TalkBack from focusing the row, allowing for single swipe + // navigation between row drop indicator + if (dragAndDropHooks?.isVirtualDragging?.()) { + delete mergedRowProps.tabIndex; + } + + let dropProps = isDroppable + ? droppableItem?.dropProps + : { 'aria-hidden': droppableItem?.dropProps['aria-hidden'] }; + let { visuallyHiddenProps } = useVisuallyHidden(); return ( -
- {children} -
+ {isTableDroppable && isFirstRow && ( + + )} + {isTableDroppable && !dropIndicator?.isHidden && ( +
+
+
+
+
+ )} +
+ {children} +
+ {isTableDroppable && ( + + )} + ); } -function TableCell({ - cell, - overflowMode, -}: { - cell: any; - overflowMode: TableProps['overflowMode']; -}) { +function TableDragCell(props: { cell: GridNode }) { + let { cell } = props; let ref = useRef(null); - let { state } = useTableContext(); + let { cosmeticConfig, state, isTableDraggable } = useTableContext(); + let isDisabled = + cell.parentKey && state.selectionManager.isDisabled(cell.parentKey); let { gridCellProps } = useTableCell( - { node: cell, isVirtualized: true }, + { + node: cell, + isVirtualized: true, + }, state, ref ); - let { isFocusVisible, focusProps } = useFocusRing(); - let styleProps = useCellStyleProps(cell.column.props, { isFocusVisible }); return ( -
- - {isReactText(cell.rendered) ? ( - {cell.rendered} - ) : ( - cell.rendered + +
-
+ ref={ref} + className={classNames(cellClassname, dragCellClassname)} + > + {isTableDraggable && !isDisabled && } +
+ + ); +} + +function DragButton() { + let { dragButtonProps, dragButtonRef, isFocusVisibleWithin, isHovered } = + useTableRowContext(); + let { visuallyHiddenProps } = useVisuallyHidden(); + return ( + +
)} + className={css({ + borderRadius: tokenSchema.size.radius.xsmall, + display: 'flex', + justifyContent: 'center', + outline: 0, + padding: 0, + height: tokenSchema.size.icon.regular, + width: 10, // magic number specific to this icon. minimizing space taken by drag handle + + '&[data-focus=visible]': { + outline: `${tokenSchema.size.alias.focusRing} solid ${tokenSchema.color.alias.focusRing}`, + }, + })} + style={ + !isFocusVisibleWithin && !isHovered ? visuallyHiddenProps.style : {} + } + ref={dragButtonRef} + draggable="true" + > + +
+
+ ); +} + +function TableCell({ cell }: { cell: GridNode }) { + let { cosmeticConfig, state } = useTableContext(); + let ref = useRef(null); + let { gridCellProps } = useTableCell( + { + node: cell, + isVirtualized: true, + }, + state, + ref + ); + let { id, ...otherGridCellProps } = gridCellProps; + + return ( + +
+ + {isReactText(cell.rendered) ? ( + {cell.rendered} + ) : ( + cell.rendered + )} + +
+
+ ); +} +function CellContents(props: HTMLAttributes) { + const { children, ...attributes } = props; + const slots = useMemo(() => ({ text: { color: 'inherit' } }) as const, []); + const element = Children.only(children) as ReactElement; + return ( + + {cloneElement(element, mergeProps(element.props, attributes))} + + ); +} + +export function getWrappedElement( + children: string | ReactElement | ReactNode +): ReactElement { + let element: ReactElement; + if (isReactText(children)) { + element = {children}; + } else { + element = Children.only(children) as ReactElement; + } + return element; +} + +function TableCellWrapper( + props: VirtualizerItemOptions & { + parent: View; + } & HTMLAttributes +) { + let { layoutInfo, virtualizer, parent, children } = props; + let { isTableDroppable, dropState } = useTableContext(); + let isDropTarget = false; + let isRootDroptarget = false; + if (isTableDroppable) { + assert(!!dropState); + let key = parent.content.key; + if (key) { + isDropTarget = dropState.isDropTarget({ + type: 'item', + dropPosition: 'on', + key: key, + }); + } + isRootDroptarget = dropState.isDropTarget({ type: 'root' }); + } + + return ( + + {children} + ); } function TableCheckboxCell({ cell }: { cell: any }) { let ref = useRef(null); - let { state } = useTableContext(); + let { cosmeticConfig, state } = useTableContext(); + // The TableCheckbox should always render its disabled status if the row is disabled, regardless of disabledBehavior, + // but the cell itself should not render its disabled styles if disabledBehavior="selection" because the row might have actions on it. + let isSelectionDisabled = state.disabledKeys.has(cell.parentKey); + let isDisabled = state.selectionManager.isDisabled(cell.parentKey); let { gridCellProps } = useTableCell( { node: cell, isVirtualized: true }, state, @@ -686,11 +1473,18 @@ function TableCheckboxCell({ cell }: { cell: any }) { { key: cell.parentKey }, state ); - let styleProps = useSelectionCellStyleProps(); return ( -
- +
+
); } @@ -704,12 +1498,21 @@ function TableSelectAllCell({ column }: { column: any }) { ref ); let { checkboxProps } = useTableSelectAllCheckbox(state); - let styleProps = useSelectionCellStyleProps(); + // FIXME + // let styleProps = useSelectionCellStyleProps(); return ( -
+
{state.selectionManager.selectionMode === 'single' ? ( - {checkboxProps['aria-label']} + {checkboxProps['aria-label']} ) : ( )} @@ -717,22 +1520,42 @@ function TableSelectAllCell({ column }: { column: any }) { ); } -function CenteredWrapper({ children }: { children: ReactNode }) { +function LoadingState() { let { state } = useTableContext(); + let stringFormatter = useLocalizedStringFormatter(localizedMessages); return ( -
+ + 0 + ? stringFormatter.format('loadingMore') + : stringFormatter.format('loading') + } + /> + + ); +} + +function EmptyState() { + let { renderEmptyState } = useTableContext(); + let emptyState = renderEmptyState ? renderEmptyState() : null; + if (emptyState == null) { + return null; + } + + return {emptyState}; +} + +function CenteredWrapper({ children }: PropsWithChildren) { + let { state } = useTableContext(); + let rowProps = { + 'aria-rowindex': + state.collection.headerRows.length + state.collection.size + 1, + }; + + return ( +
{children}
diff --git a/design-system/pkg/src/table/TableViewLayout.tsx b/design-system/pkg/src/table/TableViewLayout.tsx new file mode 100644 index 000000000..e56ae4c3c --- /dev/null +++ b/design-system/pkg/src/table/TableViewLayout.tsx @@ -0,0 +1,104 @@ +import { DropTarget } from '@react-types/shared'; +import { GridNode } from '@react-types/grid'; +import { LayoutInfo, Rect } from '@react-stately/virtualizer'; +import { LayoutNode, TableLayout } from '@react-stately/layout'; + +export class TableViewLayout extends TableLayout { + private isLoading: boolean = false; + + protected buildCollection(): LayoutNode[] { + let loadingState = this.collection.body.props.loadingState; + this.isLoading = + loadingState === 'loading' || loadingState === 'loadingMore'; + return super.buildCollection(); + } + + protected buildColumn(node: GridNode, x: number, y: number): LayoutNode { + let res = super.buildColumn(node, x, y); + res.layoutInfo.allowOverflow = true; // for resizer nubbin + return res; + } + + protected buildBody(): LayoutNode { + let node = super.buildBody(0); + let { children, layoutInfo } = node; + let width = node.layoutInfo.rect.width; + + if (this.isLoading) { + // Add some margin around the loader to ensure that scrollbars don't flicker in and out. + let rect = new Rect( + 40, + children?.length === 0 ? 40 : layoutInfo.rect.maxY, + (width || this.virtualizer.visibleRect.width) - 80, + children?.length === 0 ? this.virtualizer.visibleRect.height - 80 : 60 + ); + let loader = new LayoutInfo('loader', 'loader', rect); + loader.parentKey = layoutInfo.key; + loader.isSticky = children?.length === 0; + let node = { + layoutInfo: loader, + validRect: loader.rect, + }; + children?.push(node); + this.layoutNodes.set(loader.key, node); + layoutInfo.rect.height = loader.rect.maxY; + width = Math.max(width, rect.width); + } else if (children?.length === 0) { + let rect = new Rect( + 40, + 40, + this.virtualizer.visibleRect.width - 80, + this.virtualizer.visibleRect.height - 80 + ); + let empty = new LayoutInfo('empty', 'empty', rect); + empty.parentKey = layoutInfo.key; + empty.isSticky = true; + let node = { + layoutInfo: empty, + validRect: empty.rect, + }; + children.push(node); + layoutInfo.rect.height = empty.rect.maxY; + width = Math.max(width, rect.width); + } + + return node; + } + + protected buildRow(node: GridNode, x: number, y: number): LayoutNode { + let res = super.buildRow(node, x, y); + res.layoutInfo.rect.height += 1; // for bottom border + return res; + } + + protected buildCell(node: GridNode, x: number, y: number): LayoutNode { + let res = super.buildCell(node, x, y); + if (node.column?.props.hideHeader) { + res.layoutInfo.allowOverflow = true; + } + return res; + } + + protected getEstimatedRowHeight(): number { + return super.getEstimatedRowHeight() + 1; // for bottom border + } + + protected isStickyColumn(node: GridNode) { + return node.props?.isDragButtonCell || node.props?.isSelectionCell; + } + + getDropTargetFromPoint( + x: number, + y: number, + isValidDropTarget: (target: DropTarget) => boolean + ): DropTarget { + // Offset for height of header row + let headerRowHeight = this.virtualizer.layout + .getVisibleLayoutInfos(new Rect(x, y, 1, 1)) + .find(info => info.type === 'headerrow')?.rect.height; + if (headerRowHeight) { + y -= headerRowHeight; + } + return super.getDropTargetFromPoint(x, y, isValidDropTarget); + } +} diff --git a/design-system/pkg/src/table/context.tsx b/design-system/pkg/src/table/context.tsx new file mode 100644 index 000000000..ee29298a9 --- /dev/null +++ b/design-system/pkg/src/table/context.tsx @@ -0,0 +1,82 @@ +import type { DragAndDropHooks } from '@keystar/ui/drag-and-drop'; + +import type { + DraggableCollectionState, + DroppableCollectionState, +} from '@react-stately/dnd'; +import { TableState } from '@react-stately/table'; +import type { ColumnSize } from '@react-types/table'; +import type { Key } from '@react-types/shared'; +import { + type HTMLAttributes, + type ReactElement, + type RefObject, + createContext, + useContext, +} from 'react'; + +import { TableViewLayout } from './TableViewLayout'; +import { TableCosmeticConfig } from './types'; + +export type TableContextValue = { + cosmeticConfig: TableCosmeticConfig; + dragAndDropHooks?: DragAndDropHooks['dragAndDropHooks']; + dragState?: DraggableCollectionState; + dropState?: DroppableCollectionState; + headerMenuOpen: boolean; + headerRowHovered: boolean; + isEmpty: boolean; + isInResizeMode: boolean; + isTableDraggable: boolean; + isTableDroppable: boolean; + layout: TableViewLayout; + onFocusedResizer: () => void; + onResize: (widths: Map) => void; + onResizeEnd: (widths: Map) => void; + onResizeStart: (widths: Map) => void; + renderEmptyState?: () => ReactElement; + setHeaderMenuOpen: (val: boolean) => void; + setIsInResizeMode: (val: boolean) => void; + state: TableState; +}; + +export const TableContext = createContext | null>( + null +); +export function useTableContext() { + const context = useContext(TableContext); + if (context === null) { + throw new Error('TableContext not found'); + } + return context; +} + +type VirtualizerContextValue = { + key: Key | null; + width: number; +}; +export const VirtualizerContext = createContext( + null +); +export function useVirtualizerContext() { + const context = useContext(VirtualizerContext); + if (context === null) { + throw new Error('VirtualizerContext not found'); + } + return context; +} + +type TableRowContextValue = { + dragButtonProps: HTMLAttributes; + dragButtonRef: RefObject; + isFocusVisibleWithin: boolean; + isHovered: boolean; +}; +export const TableRowContext = createContext(null); +export function useTableRowContext() { + const context = useContext(TableRowContext); + if (context === null) { + throw new Error('TableRowContext not found'); + } + return context; +} diff --git a/design-system/pkg/src/table/l10n.json b/design-system/pkg/src/table/l10n.json index cdd4c8f2a..f4bedd876 100644 --- a/design-system/pkg/src/table/l10n.json +++ b/design-system/pkg/src/table/l10n.json @@ -1,138 +1,376 @@ { "ar-AE": { + "collapse": "طي", + "columnResizer": "أداة تغيير حجم العمود", + "drag": "سحب", + "expand": "مد", "loading": "جارٍ التحميل...", - "loadingMore": "جارٍ تحميل المزيد..." + "loadingMore": "جارٍ تحميل المزيد...", + "resizeColumn": "تغيير حجم العمود", + "sortAscending": "فرز بترتيب تصاعدي", + "sortDescending": "فرز بترتيب تنازلي" }, "bg-BG": { + "collapse": "Свиване", + "columnResizer": "Преоразмеряване на колони", + "drag": "Плъзнете", + "expand": "Разширяване", "loading": "Зареждане...", - "loadingMore": "Зареждане на още..." + "loadingMore": "Зареждане на още...", + "resizeColumn": "Преоразмеряване на колона", + "sortAscending": "Възходящо сортиране", + "sortDescending": "Низходящо сортиране " }, "cs-CZ": { + "collapse": "Zmenšit", + "columnResizer": "Změna velikosti sloupce", + "drag": "Přetáhnout", + "expand": "Roztáhnout", "loading": "Načítání...", - "loadingMore": "Načítání dalších..." + "loadingMore": "Načítání dalších...", + "resizeColumn": "Změnit velikost sloupce", + "sortAscending": "Seřadit vzestupně", + "sortDescending": "Seřadit sestupně" }, "da-DK": { - "loading": "Indlæser...", - "loadingMore": "Indlæser flere..." + "collapse": "Skjul", + "columnResizer": "Kolonneændring", + "drag": "Træk", + "expand": "Udvid", + "loading": "Indlæser ...", + "loadingMore": "Indlæser flere ...", + "resizeColumn": "Tilpas størrelse på kolonne", + "sortAscending": "Sorter stigende", + "sortDescending": "Sorter faldende" }, "de-DE": { + "collapse": "Reduzieren", + "columnResizer": "Spaltenanpassung", + "drag": "Ziehen", + "expand": "Erweitern", "loading": "Laden...", - "loadingMore": "Mehr laden ..." + "loadingMore": "Mehr laden ...", + "resizeColumn": "Spaltengröße ändern", + "sortAscending": "Aufsteigend sortieren", + "sortDescending": "Absteigend sortieren" }, "el-GR": { + "collapse": "Σύμπτυξη", + "columnResizer": "Αλλαγή μεγέθους στήλης", + "drag": "Μεταφορά", + "expand": "Ανάπτυξη", "loading": "Φόρτωση...", - "loadingMore": "Φόρτωση περισσότερων..." + "loadingMore": "Φόρτωση περισσότερων...", + "resizeColumn": "Αλλαγή μεγέθους στήλης", + "sortAscending": "Ταξινόμηση κατά αύξουσα σειρά", + "sortDescending": "Ταξινόμηση κατά φθίνουσα σειρά" }, "en-US": { "loading": "Loading…", - "loadingMore": "Loading more…" + "loadingMore": "Loading more…", + "sortAscending": "Sort Ascending", + "sortDescending": "Sort Descending", + "resizeColumn": "Resize column", + "columnResizer": "Column resizer", + "drag": "Drag", + "expand": "Expand", + "collapse": "Collapse" }, "es-ES": { + "collapse": "Contraer", + "columnResizer": "Redimensionador de columnas", + "drag": "Arrastrar", + "expand": "Ampliar", "loading": "Cargando…", - "loadingMore": "Cargando más…" + "loadingMore": "Cargando más…", + "resizeColumn": "Cambiar el tamaño de la columna", + "sortAscending": "Orden de subida", + "sortDescending": "Orden de bajada" }, "et-EE": { + "collapse": "Ahenda", + "columnResizer": "Veeru suuruse muutja", + "drag": "Lohista", + "expand": "Laienda", "loading": "Laadimine...", - "loadingMore": "Laadi rohkem..." + "loadingMore": "Laadi rohkem...", + "resizeColumn": "Muuda veeru suurust", + "sortAscending": "Sordi kasvavalt", + "sortDescending": "Sordi kahanevalt" }, "fi-FI": { + "collapse": "Pienennä", + "columnResizer": "Sarakekoon muuttaja", + "drag": "Vedä", + "expand": "Laajenna", "loading": "Ladataan…", - "loadingMore": "Ladataan lisää…" + "loadingMore": "Ladataan lisää…", + "resizeColumn": "Muuta sarakkeen kokoa", + "sortAscending": "Lajittelujärjestys: nouseva", + "sortDescending": "Lajittelujärjestys: laskeva" }, "fr-FR": { + "collapse": "Réduire", + "columnResizer": "Redimensionnement de colonne", + "drag": "Faire glisser", + "expand": "Développer", "loading": "Chargement...", - "loadingMore": "Chargement supplémentaire..." + "loadingMore": "Chargement supplémentaire...", + "resizeColumn": "Redimensionner la colonne", + "sortAscending": "Trier par ordre croissant", + "sortDescending": "Trier par ordre décroissant" }, "he-IL": { + "collapse": "כווץ", + "columnResizer": "שינוי גודל עמודה", + "drag": "גרור", + "expand": "הרחב", "loading": "טוען...", - "loadingMore": "טוען עוד..." + "loadingMore": "טוען עוד...", + "resizeColumn": "שנה את גודל העמודה", + "sortAscending": "מיין בסדר עולה", + "sortDescending": "מיין בסדר יורד" }, "hr-HR": { + "collapse": "Sažmi", + "columnResizer": "Alat za promjenu veličine stupca", + "drag": "Povucite", + "expand": "Proširi", "loading": "Učitavam...", - "loadingMore": "Učitavam još..." + "loadingMore": "Učitavam još...", + "resizeColumn": "Promijeni veličinu stupca", + "sortAscending": "Sortiraj uzlazno", + "sortDescending": "Sortiraj silazno" }, "hu-HU": { + "collapse": "Összecsukás", + "columnResizer": "Oszlopátméretező", + "drag": "Húzás", + "expand": "Kibontás", "loading": "Betöltés folyamatban…", - "loadingMore": "Továbbiak betöltése folyamatban…" + "loadingMore": "Továbbiak betöltése folyamatban…", + "resizeColumn": "Oszlop átméretezése", + "sortAscending": "Növekvő rendezés", + "sortDescending": "Csökkenő rendezés" }, "it-IT": { + "collapse": "Comprimi", + "columnResizer": "Ridimensionamento colonne", + "drag": "Trascina", + "expand": "Espandi", "loading": "Caricamento...", - "loadingMore": "Caricamento altri..." + "loadingMore": "Caricamento altri...", + "resizeColumn": "Ridimensiona colonna", + "sortAscending": "Ordinamento crescente", + "sortDescending": "Ordinamento decrescente" }, "ja-JP": { + "collapse": "折りたたむ", + "columnResizer": "列リサイザー", + "drag": "ドラッグ", + "expand": "展開", "loading": "読み込み中...", - "loadingMore": "さらに読み込み中..." + "loadingMore": "さらに読み込み中...", + "resizeColumn": "列幅を変更", + "sortAscending": "昇順に並べ替え", + "sortDescending": "降順に並べ替え" }, "ko-KR": { - "loading": "로드 중…", - "loadingMore": "추가 로드 중…" + "collapse": "접기", + "columnResizer": "열 크기 조정기", + "drag": "드래그", + "expand": "펼치기", + "loading": "로드 중", + "loadingMore": "추가 로드 중", + "resizeColumn": "열 크기 조정", + "sortAscending": "오름차순 정렬", + "sortDescending": "내림차순 정렬" }, "lt-LT": { + "collapse": "Sutraukti", + "columnResizer": "Stulpelio dydžio keitiklis", + "drag": "Vilkti", + "expand": "Išskleisti", "loading": "Įkeliama...", - "loadingMore": "Įkeliama daugiau..." + "loadingMore": "Įkeliama daugiau...", + "resizeColumn": "Keisti stulpelio dydį", + "sortAscending": "Rikiuoti didėjimo tvarka", + "sortDescending": "Rikiuoti mažėjimo tvarka" }, "lv-LV": { + "collapse": "Sakļaut", + "columnResizer": "Kolonnas izmēru mainītājs", + "drag": "Vilkšana", + "expand": "Izvērst", "loading": "Notiek ielāde...", - "loadingMore": "Tiek ielādēts vēl..." + "loadingMore": "Tiek ielādēts vēl...", + "resizeColumn": "Mainīt kolonnas lielumu", + "sortAscending": "Kārtot augošā secībā", + "sortDescending": "Kārtot dilstošā secībā" }, "nb-NO": { + "collapse": "Skjul", + "columnResizer": "Størrelsesendring av kolonne", + "drag": "Dra", + "expand": "Utvid", "loading": "Laster inn ...", - "loadingMore": "Laster inn flere ..." + "loadingMore": "Laster inn flere ...", + "resizeColumn": "Endre størrelse på kolonne", + "sortAscending": "Sorter stigende", + "sortDescending": "Sorter synkende" }, "nl-NL": { + "collapse": "Samenvouwen", + "columnResizer": "Groottewijziging van kolom", + "drag": "Slepen", + "expand": "Uitvouwen", "loading": "Laden...", - "loadingMore": "Meer laden..." + "loadingMore": "Meer laden...", + "resizeColumn": "Kolomgrootte wijzigen", + "sortAscending": "Oplopend sorteren", + "sortDescending": "Aflopend sorteren" }, "pl-PL": { + "collapse": "Zwiń", + "columnResizer": "Narzędzie zmiany rozmiaru kolumny", + "drag": "Przeciągnij", + "expand": "Rozwiń", "loading": "Ładowanie...", - "loadingMore": "Wczytywanie większej liczby..." + "loadingMore": "Wczytywanie większej liczby...", + "resizeColumn": "Zmień rozmiar kolumny", + "sortAscending": "Sortuj rosnąco", + "sortDescending": "Sortuj malejąco" }, "pt-BR": { + "collapse": "Recolher", + "columnResizer": "Redimensionamento de colunas", + "drag": "Arraste", + "expand": "Expandir", "loading": "Carregando...", - "loadingMore": "Carregando mais..." + "loadingMore": "Carregando mais...", + "resizeColumn": "Redimensionar coluna", + "sortAscending": "Ordenar por ordem crescente", + "sortDescending": "Ordenar por ordem decrescente" }, "pt-PT": { + "collapse": "Colapsar", + "columnResizer": "Redimensionador de coluna", + "drag": "Arrastar", + "expand": "Expandir", "loading": "A carregar...", - "loadingMore": "A carregar mais..." + "loadingMore": "A carregar mais...", + "resizeColumn": "Redimensionar coluna", + "sortAscending": "Ordenar por ordem ascendente", + "sortDescending": "Ordenar por ordem decrescente" }, "ro-RO": { + "collapse": "Restrângeți", + "columnResizer": "Instrument redimensionare coloane", + "drag": "Trageți", + "expand": "Extindeți", "loading": "Se încarcă...", - "loadingMore": "Se încarcă mai multe..." + "loadingMore": "Se încarcă mai multe...", + "resizeColumn": "Redimensionați coloana", + "sortAscending": "Sortați crescător", + "sortDescending": "Sortați descrescător" }, "ru-RU": { + "collapse": "Свернуть", + "columnResizer": "Средство изменения размера столбцов", + "drag": "Перетаскивание", + "expand": "Развернуть", "loading": "Загрузка...", - "loadingMore": "Дополнительная загрузка..." + "loadingMore": "Дополнительная загрузка...", + "resizeColumn": "Изменить размер столбца", + "sortAscending": "Сортировать по возрастанию", + "sortDescending": "Сортировать по убыванию" }, "sk-SK": { + "collapse": "Zbaliť", + "columnResizer": "Nástroj na zmenu veľkosti stĺpcov", + "drag": "Presunúť", + "expand": "Rozbaliť", "loading": "Načítava sa...", - "loadingMore": "Načítava sa viac..." + "loadingMore": "Načítava sa viac...", + "resizeColumn": "Zmeniť veľkosť stĺpca", + "sortAscending": "Zoradiť vzostupne", + "sortDescending": "Zoradiť zostupne" }, "sl-SI": { - "loading": "Nalaganje ...", - "loadingMore": "Nalaganje več vsebine ..." + "collapse": "Strni", + "columnResizer": "Prilagojevalnik velikosti stolpcev", + "drag": "Povleci", + "expand": "Razširi", + "loading": "Nalaganje...", + "loadingMore": "Nalaganje več vsebine...", + "resizeColumn": "Spremeni velikost stolpca", + "sortAscending": "Razvrsti naraščajoče", + "sortDescending": "Razvrsti padajoče" }, "sr-SP": { + "collapse": "Sažmi", + "columnResizer": "Alat za promenu veličine kolone", + "drag": "Prevuci", + "expand": "Proširi", "loading": "Učitavam...", - "loadingMore": "Učitavam još..." + "loadingMore": "Učitavam još...", + "resizeColumn": "Promeni veličinu kolone", + "sortAscending": "Sortiraj po rastućem redosledu", + "sortDescending": "Sortiraj po opadajućem redosledu" }, "sv-SE": { + "collapse": "Dölj", + "columnResizer": "Ändra storlek på kolumn", + "drag": "Dra", + "expand": "Expandera", "loading": "Läser in...", - "loadingMore": "Läser in mer..." + "loadingMore": "Läser in mer...", + "resizeColumn": "Ändra storlek på kolumn", + "sortAscending": "Sortera i stigande ordning", + "sortDescending": "Sortera i fallande ordning" }, "tr-TR": { + "collapse": "Daralt", + "columnResizer": "Yeniden sütun boyutlandırıcı", + "drag": "Sürükle", + "expand": "Genişlet", "loading": "Yükleniyor...", - "loadingMore": "Daha fazla yükleniyor..." + "loadingMore": "Daha fazla yükleniyor...", + "resizeColumn": "Sütunu yeniden boyutlandır", + "sortAscending": "Artan Sıralama", + "sortDescending": "Azalan Sıralama" }, "uk-UA": { + "collapse": "Згорнути", + "columnResizer": "Засіб змінення розміру стовпця", + "drag": "Перетягнути", + "expand": "Розгорнути", "loading": "Завантаження…", - "loadingMore": "Завантаження інших об’єктів..." + "loadingMore": "Завантаження інших об’єктів...", + "resizeColumn": "Змінити розмір стовпця", + "sortAscending": "Сортувати за зростанням", + "sortDescending": "Сортувати за спаданням" }, "zh-CN": { + "collapse": "折叠", + "columnResizer": "列尺寸调整器", + "drag": "拖动", + "expand": "扩展", "loading": "正在加载...", - "loadingMore": "正在加载更多..." - }, - "zh-T": { - "loading": "載入中…", - "loadingMore": "正在載入更多…" + "loadingMore": "正在加载更多...", + "resizeColumn": "调整列大小", + "sortAscending": "升序排序", + "sortDescending": "降序排序" + }, + "zh-TW": { + "collapse": "收合", + "columnResizer": "欄大小調整器", + "drag": "拖曳", + "expand": "展開", + "loading": "正在載入", + "loadingMore": "正在載入更多…", + "resizeColumn": "調整欄大小", + "sortAscending": "升序排序", + "sortDescending": "降序排序" } } diff --git a/design-system/pkg/src/table/stories/ReorderExample.tsx b/design-system/pkg/src/table/stories/ReorderExample.tsx new file mode 100644 index 000000000..bd59263c9 --- /dev/null +++ b/design-system/pkg/src/table/stories/ReorderExample.tsx @@ -0,0 +1,210 @@ +import { action } from '@keystar/ui-storybook'; +import { useListData } from '@react-stately/data'; +import { ItemDropTarget, Key } from '@react-types/shared'; +import React from 'react'; + +import { useDragAndDrop } from '@keystar/ui/drag-and-drop'; + +import { Cell, Column, Row, TableBody, TableHeader, TableView } from '../index'; + +let columns = [ + { name: 'First name', key: 'first_name', width: '25%', isRowHeader: true }, + { name: 'Last name', key: 'last_name', width: '25%', isRowHeader: true }, + { name: 'Email', key: 'email', minWidth: 200 }, + { name: 'Department', key: 'department', width: 200 }, + { name: 'Job Title', key: 'job_title', width: 180 }, + { name: 'IP Address', key: 'ip_address', minWidth: 140 }, +]; + +export let items = [ + { + id: 'a', + first_name: 'Vin', + last_name: 'Charlet', + email: 'vcharlet0@123-reg.co.uk', + ip_address: '18.45.175.130', + department: 'Services', + job_title: 'Analog Circuit Design manager', + }, + { + id: 'b', + first_name: 'Lexy', + last_name: 'Maddison', + email: 'lmaddison1@xinhuanet.com', + ip_address: '238.210.151.48', + department: 'Research and Development', + job_title: 'Analog Circuit Design manager', + }, + { + id: 'c', + first_name: 'Robbi', + last_name: 'Persence', + email: 'rpersence2@hud.gov', + ip_address: '130.2.120.99', + department: 'Business Development', + job_title: 'Analog Circuit Design manager', + }, + { + id: 'd', + first_name: 'Dodie', + last_name: 'Hurworth', + email: 'dhurworth3@webs.com', + ip_address: '235.183.154.184', + department: 'Training', + job_title: 'Account Coordinator', + }, + { + id: 'e', + first_name: 'Audrye', + last_name: 'Hember', + email: 'ahember4@blogtalkradio.com', + ip_address: '136.25.192.37', + department: 'Legal', + job_title: 'Operator', + }, + { + id: 'f', + first_name: 'Beau', + last_name: 'Oller', + email: 'boller5@nytimes.com', + ip_address: '93.111.22.12', + department: 'Business Development', + job_title: 'Speech Pathologist', + }, + { + id: 'g', + first_name: 'Roarke', + last_name: 'Gration', + email: 'rgration6@purevolume.com', + ip_address: '234.221.23.241', + department: 'Product Management', + job_title: 'Electrical Engineer', + }, + { + id: 'h', + first_name: 'Cathy', + last_name: 'Lishman', + email: 'clishman7@constantcontact.com', + ip_address: '181.158.213.202', + department: 'Research and Development', + job_title: 'Assistant Professor', + }, + { + id: 'i', + first_name: 'Enrika', + last_name: 'Soitoux', + email: 'esoitoux8@google.com.hk', + ip_address: '51.244.20.173', + department: 'Support', + job_title: 'Teacher', + }, + { + id: 'j', + first_name: 'Aloise', + last_name: 'Tuxsell', + email: 'atuxsell9@jigsy.com', + ip_address: '253.46.84.168', + department: 'Training', + job_title: 'Financial Advisor', + }, +]; + +let getAllowedDropOperationsAction = action('getAllowedDropOperationsAction'); + +export function ReorderExample(props: any) { + let { onDrop, onDragStart, onDragEnd, tableViewProps, ...otherProps } = props; + let list = useListData({ + initialItems: (props.items as typeof items) || items, + getKey: item => item.id, + }); + + // Use a random drag type so the items can only be reordered within this table and not dragged elsewhere. + let dragType = React.useMemo( + () => `keys-${Math.random().toString(36).slice(2)}`, + [] + ); + + let onMove = (keys: Key[], target: ItemDropTarget) => { + console.log('onMove', keys, target); + if (target.dropPosition === 'before') { + list.moveBefore(target.key, keys); + } else { + list.moveAfter(target.key, keys); + } + }; + + let { dragAndDropHooks } = useDragAndDrop({ + getItems(keys) { + return [...keys].map(key => { + key = JSON.stringify(key); + return { + [dragType]: key, + 'text/plain': key, + }; + }); + }, + getAllowedDropOperations() { + getAllowedDropOperationsAction(); + return ['move', 'cancel']; + }, + onDragStart: onDragStart, + onDragEnd: onDragEnd, + async onDrop(e) { + onDrop(e); + if (e.target.type !== 'root' && e.target.dropPosition !== 'on') { + let keys = []; + for (let item of e.items) { + if (item.kind === 'text') { + let key; + if (item.types.has(dragType)) { + key = JSON.parse(await item.getText(dragType)); + keys.push(key); + } else if (item.types.has('text/plain')) { + // Fallback for Chrome Android case: https://bugs.chromium.org/p/chromium/issues/detail?id=1293803 + // Multiple drag items are contained in a single string so we need to split them out + key = await item.getText('text/plain'); + keys = key.split('\n').map(val => val.replaceAll('"', '')); + } + } + } + onMove(keys, e.target); + } + }, + getDropOperation(target) { + if (target.type === 'root' || target.dropPosition === 'on') { + return 'cancel'; + } + + return 'move'; + }, + }); + + return ( + + + {column => ( + + {column.name} + + )} + + + {item => ( + {key => {item[key as keyof typeof item]}} + )} + + + ); +} diff --git a/design-system/pkg/src/table/stories/Table.stories.tsx b/design-system/pkg/src/table/stories/Table.stories.tsx index 0faede366..86469a964 100644 --- a/design-system/pkg/src/table/stories/Table.stories.tsx +++ b/design-system/pkg/src/table/stories/Table.stories.tsx @@ -1,9 +1,12 @@ import { action, ArgTypes } from '@keystar/ui-storybook'; -import { Flex, VStack } from '@keystar/ui/layout'; +import { Badge } from '@keystar/ui/badge'; +import { Box, Flex, VStack } from '@keystar/ui/layout'; import { TextLink } from '@keystar/ui/link'; import { tokenSchema } from '@keystar/ui/style'; +import { ActionButton } from '@keystar/ui/button'; import { Switch } from '@keystar/ui/switch'; import { Heading, Text } from '@keystar/ui/typography'; +import { useAsyncList } from '@react-stately/data'; import { Key, useMemo, useState } from 'react'; import { @@ -16,7 +19,7 @@ import { TableHeader, } from '..'; import { pokemonItems } from './data'; -import { useAsyncList } from '@react-stately/data'; +import { ReorderExample } from './ReorderExample'; function onSelectionChange(keys: 'all' | Set) { const selection = typeof keys === 'string' ? keys : [...keys]; @@ -35,7 +38,7 @@ export default { density: 'regular', height: undefined, width: 'scale.6000', - onRowAction: action('onRowAction'), + onAction: action('onAction'), onSelectionChange: action('onSelectionChange'), onSortChange: action('onSortChange'), }, @@ -44,7 +47,7 @@ export default { // there is no argType for function // use the controls reset button to undo it // https://storybook.js.org/docs/react/essentials/controls#annotation - onRowAction: { + onAction: { control: 'select', options: [undefined], }, @@ -58,6 +61,11 @@ export default { disable: true, }, }, + disabledBehavior: { + table: { + disable: true, + }, + }, disabledKeys: { table: { disable: true, @@ -168,11 +176,17 @@ export const Dynamic = (args: ArgTypes) => ( ); -export const DisabledKeys = (args: ArgTypes) => ; +export const DisabledKeys = (args: ArgTypes) => ( + +); DisabledKeys.args = { disabledKeys: new Set(['Foo 1', 'Foo 3']), + selectionMode: 'none', }; -DisabledKeys.storyName = 'disabled keys'; export const HiddenHeader = (args: ArgTypes) => ( ( {...args} > - Foo - Bar - + + Foo + + + Bar + + Actions @@ -193,14 +211,14 @@ export const HiddenHeader = (args: ArgTypes) => ( One Two - + Three Four Five - + Six @@ -224,14 +242,16 @@ export const FocusableContent = (args: ArgTypes) => ( - Yahoo + Thinkmill - Three + + Company + @@ -239,14 +259,16 @@ export const FocusableContent = (args: ArgTypes) => ( - Google + Keystatic - Three + + Project + @@ -254,14 +276,16 @@ export const FocusableContent = (args: ArgTypes) => ( - Yahoo + Keystone - Three + + Project + @@ -275,7 +299,6 @@ export const Selection = (args: ArgTypes) => ( aria-label="TableView with selection" // width="scale.3400" // height="scale.2400" - onRowAction={args.none ? action('onRowAction') : undefined} onSelectionChange={onSelectionChange} {...args} > @@ -302,6 +325,51 @@ Selection.args = { selectionMode: 'multiple', }; +export const Resizing = (args: ArgTypes) => ( + + + + + Foo + + + Bar + + + Baz + + + + + One + Two + Three + + + Four + Five + Six + + + + +); + +export const Reorderable = (args: ArgTypes) => ( + +); + export const TableProps = (args: ArgTypes) => ( { width="scale.6000" height="scale.2400" selectionMode="multiple" - // onRowAction={(...args) => { - // console.log('onRowAction', ...args); - // action('onRowAction')(...args); + // onAction={(...args) => { + // console.log('onAction', ...args); + // action('onAction')(...args); // }} onSelectionChange={onSelectionChange} onSortChange={descriptor => { @@ -382,8 +450,8 @@ export const StickyCheckboxes = () => { { // ); // } - return ( - - {value} - - ); + return {value}; }} )} diff --git a/design-system/pkg/src/table/styles.tsx b/design-system/pkg/src/table/styles.tsx index 54ad1a280..64d750ae7 100644 --- a/design-system/pkg/src/table/styles.tsx +++ b/design-system/pkg/src/table/styles.tsx @@ -4,35 +4,19 @@ import { ClassList, classNames, css, - toDataAttributes, tokenSchema, transition, - useStyleProps, } from '@keystar/ui/style'; -import { CSSProperties, HTMLAttributes } from 'react'; - -import { TableProps } from './types'; +import { HTMLAttributes } from 'react'; export const tableViewClassList = new ClassList('TableView', [ 'cell', 'cell-wrapper', 'row', + 'body', + 'header', ]); -// ============================================================================ -// UTILS -// ============================================================================ - -// function getStyleFromColumn(props: CellProps) { -// const { maxWidth, minWidth, width } = props; - -// if (width) { -// return { flex: '0 0 auto', width, maxWidth, minWidth }; -// } - -// return { maxWidth, minWidth }; -// } - // ============================================================================ // COMPONENTS // ============================================================================ @@ -65,6 +49,7 @@ export const SortIndicator = () => { alignItems: 'center', display: 'flex', flexShrink: 0, + gridArea: 'sort-indicator', height: labelHeight, justifyContent: 'center', marginInline: tokenSchema.size.space.small, @@ -93,94 +78,222 @@ export const SortIndicator = () => { }; // ============================================================================ -// HOOKS +// CLASSES // ============================================================================ -// Table root +// TODO: review styles +export const tableClassname = css({ + display: 'flex', + flexDirection: 'column', + isolation: 'isolate', + minHeight: 0, + minWidth: 0, + outline: 'none', + position: 'relative', + userSelect: 'none', +}); + +// Row group (head/body/foot) // ---------------------------------------------------------------------------- -export function useTableStyleProps(props: TableProps) { - let { density, overflowMode, prominence } = props; - let styleProps = useStyleProps(props); +export const headerWrapperClassname = css({ + boxSizing: 'content-box', + // keep aligned with the border of the body + borderLeft: `${tokenSchema.size.border.regular} solid transparent`, + borderRight: `${tokenSchema.size.border.regular} solid transparent`, +}); +export const headerClassname = classNames( + tableViewClassList.element('header'), + css({ + boxSizing: 'border-box', + }) +); + +export const bodyClassname = classNames( + tableViewClassList.element('body'), + css({ + backgroundColor: tokenSchema.color.background.canvas, + border: `${tokenSchema.size.border.regular} solid ${tokenSchema.color.border.neutral}`, + borderRadius: tokenSchema.size.radius.medium, + /* Fix scrollbars on iOS with sticky row headers */ + transform: 'translate3d(0, 0, 0)', + }) +); + +// resizing +export const columnResizerClassname = css({ + blockSize: '100%', + boxSizing: 'border-box', + display: 'flex', + flexShrink: 0, + inlineSize: 21, + insetInlineEnd: -10, + justifyContent: 'center', + outline: 0, + position: 'absolute', + userSelect: 'none', - return { - ...toDataAttributes({ density, overflowMode, prominence }), - className: classNames( - tableViewClassList.element('root'), - styleProps.className, - css({ - display: 'flex', - flexDirection: 'column', - isolation: 'isolate', - minHeight: 0, - minWidth: 0, - outline: 'none', - position: 'relative', - userSelect: 'none', - }) - ), - style: styleProps.style, - }; -} + '&::after': { + backgroundColor: tokenSchema.color.border.neutral, + blockSize: '100%', + boxSizing: 'border-box', + content: '""', + display: 'block', + inlineSize: 1, + }, +}); +export const columnResizerPlaceholderClassname = css({ + blockSize: '100%', + boxSizing: 'border-box', + flex: '0 0 auto', + flexShrink: 0, + inlineSize: 10, + userSelect: 'none', +}); +export const columnResizeIndicatorClassname = css({ + backgroundColor: tokenSchema.color.background.accentEmphasis, + display: 'none', + flexShrink: 0, + height: '100%', + insetInlineEnd: 0, + pointerEvents: 'none', + position: 'absolute', + top: 1, + width: 2, + zIndex: 3, + + '&[data-resizing=true]': { + display: 'block', + }, +}); +export const bodyResizeIndicatorClassname = css({ + backgroundColor: tokenSchema.color.background.accentEmphasis, + display: 'none', + height: '100%', + position: 'absolute', + top: 0, + width: 2, +}); -// Row group (head/body/foot) +// utilities +export const centeredWrapperClassname = css({ + alignItems: 'center', + display: 'flex', + height: '100%', + justifyContent: 'center', + width: '100%', +}); + +// Row // ---------------------------------------------------------------------------- -export function useHeaderWrapperStyleProps({ - style, -}: { - style?: CSSProperties; -} = {}) { - return { - className: css({ - overflow: 'hidden', - position: 'relative', - boxSizing: 'content-box', - flex: 'none', - // keep aligned with the border of the body - [`${tableViewClassList.selector('root')}:not([data-prominence="low"]) &`]: - { - borderLeft: `${tokenSchema.size.border.regular} solid transparent`, - borderRight: `${tokenSchema.size.border.regular} solid transparent`, - }, - }), - style, - }; -} -export function useHeadStyleProps({ style }: { style?: CSSProperties } = {}) { - return { - className: css({ - boxSizing: 'border-box', - display: 'flex', - flexDirection: 'column', - }), - style, - }; -} -export function useBodyStyleProps({ style }: { style?: CSSProperties } = {}) { - return { - className: css({ - [`${tableViewClassList.selector('root')}[data-prominence="low"] &`]: { - borderBlock: `${tokenSchema.size.border.regular} solid ${tokenSchema.color.border.muted}`, - }, - [`${tableViewClassList.selector('root')}:not([data-prominence="low"]) &`]: - { - backgroundColor: tokenSchema.color.background.canvas, - border: `${tokenSchema.size.border.regular} solid ${tokenSchema.color.border.muted}`, - borderRadius: tokenSchema.size.radius.medium, - /* Fix scrollbars on iOS with sticky row headers */ - transform: 'translate3d(0, 0, 0)', - }, - }), - style, - }; -} +export const rowClassname = css({ + boxSizing: 'border-box', + display: 'flex', + position: 'relative', + outline: 0, -// Cell common + // separators + [`${tableViewClassList.selector('body')} &::after`]: { + content: '""', + boxShadow: `inset 0 -1px 0 0 ${tokenSchema.color.border.muted}`, + position: 'absolute', + inset: 0, + pointerEvents: 'none', + zIndex: 2, + }, + '&[data-flush-with-container-bottom]::after': { + display: 'none', + }, + // selection + '&[aria-selected="true"]::after': { + boxShadow: `inset 0 -1px 0 0 ${tokenSchema.color.alias.backgroundSelectedHovered}`, + }, + '&[data-next-selected="true"]::after': { + boxShadow: `inset 0 -1px 0 0 ${tokenSchema.color.alias.backgroundSelectedHovered}`, + }, + + // prominence + [`${tableViewClassList.selector('root')}:not([data-prominence="low"]) &`]: { + '&:first-child': { + borderStartStartRadius: `calc(${tokenSchema.size.radius.medium} - ${tokenSchema.size.border.regular})`, + borderStartEndRadius: `calc(${tokenSchema.size.radius.medium} - ${tokenSchema.size.border.regular})`, + }, + '&:last-child': { + borderEndStartRadius: `calc(${tokenSchema.size.radius.medium} - ${tokenSchema.size.border.regular})`, + borderEndEndRadius: `calc(${tokenSchema.size.radius.medium} - ${tokenSchema.size.border.regular})`, + }, + }, + + // focus indicator + '&[data-focus-visible]': { + '&::before': { + backgroundColor: tokenSchema.color.background.accentEmphasis, + borderRadius: tokenSchema.size.space.small, + content: '""', + insetInlineStart: tokenSchema.size.space.xsmall, + marginBlock: tokenSchema.size.space.xsmall, + marginInlineEnd: `calc(${tokenSchema.size.space.small} * -1)`, + position: 'sticky', + width: tokenSchema.size.space.small, + zIndex: 4, + }, + }, + + // interactions + [`&[data-hovered=true] ${tableViewClassList.selector('cell')}`]: { + backgroundColor: tokenSchema.color.scale.slate2, + }, + [`&[data-pressed=true] ${tableViewClassList.selector('cell')}`]: { + backgroundColor: tokenSchema.color.scale.slate3, + // backgroundColor: tokenSchema.color.alias.backgroundPressed, + }, + [`&[data-disabled] ${tableViewClassList.selector('cell')}`]: { + color: tokenSchema.color.alias.foregroundDisabled, + }, + + // selected + [`&[aria-selected="true"] ${tableViewClassList.selector('cell')}`]: { + backgroundColor: tokenSchema.color.alias.backgroundSelected, + }, + [`&[aria-selected="true"][data-hovered=true] ${tableViewClassList.selector( + 'cell' + )}`]: { + backgroundColor: tokenSchema.color.alias.backgroundSelectedHovered, + }, +}); + +export const rowDragPreviewClassname = css({ + backgroundColor: tokenSchema.color.background.canvas, + border: `${tokenSchema.size.border.regular} solid ${tokenSchema.color.alias.borderSelected}`, + borderRadius: tokenSchema.size.radius.small, + paddingInline: tokenSchema.size.space.medium, + position: 'relative', + outline: 0, + width: tokenSchema.size.alias.singleLineWidth, + + // indicate that multiple items are being dragged by implying a stack + '&[data-multi=true]::after': { + backgroundColor: 'inherit', + border: 'inherit', + borderRadius: 'inherit', + content: '" "', + display: 'block', + height: '100%', + insetBlockStart: tokenSchema.size.space.small, + insetInlineStart: tokenSchema.size.space.small, + position: 'absolute', + width: '100%', + zIndex: -1, + }, +}); + +// Cell // ---------------------------------------------------------------------------- -const commonCellStyles = { - // borderBottom: `${tokenSchema.size.border.regular} solid ${tokenSchema.color.border.neutral}`, +// FIXME: review these styles. many may not be necessary. def get rid of the +// root selectors, and pass data-attributes onto elements directly +const commonCellStyles = css({ boxSizing: 'border-box', cursor: 'default', display: 'flex', @@ -190,208 +303,115 @@ const commonCellStyles = { outline: 0, paddingInline: tokenSchema.size.space.medium, position: 'relative', + textAlign: 'start', + + // focus ring + '&[data-focus=visible]': { + borderRadius: tokenSchema.size.radius.small, + inset: 0, + outline: `${tokenSchema.size.alias.focusRing} solid ${tokenSchema.color.alias.focusRing}`, + outlineOffset: `calc(${tokenSchema.size.alias.focusRingGap} * -1)`, + position: 'absolute', + }, - // Density - paddingBlock: tokenSchema.size.space.medium, - [`${tableViewClassList.selector( - 'root' - )}[data-density="compact"] &:not([role="columnheader"])`]: { - paddingBlock: tokenSchema.size.space.regular, + // density + paddingBlock: tokenSchema.size.space.regular, + '&[data-density="compact"]': { + paddingBlock: tokenSchema.size.space.small, }, - [`${tableViewClassList.selector( - 'root' - )}[data-density="spacious"] &:not([role="columnheader"])`]: { - paddingBlock: tokenSchema.size.space.large, + '&[data-density="spacious"]': { + paddingBlock: tokenSchema.size.space.medium, }, - // wrapping text shouldn't be centered - alignItems: 'center', - [`${tableViewClassList.selector( - 'root' - )}[data-overflow-mode="wrap"] &:not([role="columnheader"])`]: { - alignItems: 'start', + // alignment + '&[data-align="end"]': { + justifyContent: 'flex-end', + textAlign: 'end', + }, + '&[data-align="center"]': { + justifyContent: 'center', + textAlign: 'center', }, -} as const; - -type CellProps = { - align?: 'start' | 'end' | 'center'; - maxWidth?: number | string; - minWidth?: number | string; - width?: number | string; -}; - -export function useCellStyleProps( - props: CellProps, - state?: { isFocusVisible: boolean } -) { - const className = classNames( - tableViewClassList.element('cell'), - css([ - commonCellStyles, - { - // Alignment - '&[data-align="end"]': { - justifyContent: 'flex-end', - textAlign: 'end', - }, - '&[data-align="center"]': { - justifyContent: 'center', - textAlign: 'center', - }, - - // focus ring - '&[data-focus="visible"]::after': { - borderRadius: tokenSchema.size.radius.small, - boxShadow: `inset 0 0 0 ${tokenSchema.size.alias.focusRing} ${tokenSchema.color.alias.focusRing}`, - content: '""', - inset: 0, - position: 'absolute', - transition: transition(['box-shadow', 'margin'], { - easing: 'easeOut', - }), - }, - - // HEADERS - '&[role="columnheader"]': { - color: tokenSchema.color.foreground.neutralSecondary, - - ['&[aria-sort]']: { - cursor: 'default', - - '&:hover, &[data-focus="visible"]': { - color: tokenSchema.color.foreground.neutralEmphasis, - }, - }, - }, - }, - ]) - ); - - return { - ...toDataAttributes({ - focus: state?.isFocusVisible ? 'visible' : undefined, - align: props?.align, - }), - className, - // style: getStyleFromColumn(props), - }; -} - -export function useSelectionCellStyleProps() { - return { - className: classNames( - tableViewClassList.element('cell'), - css(commonCellStyles, { - alignItems: 'center', - flex: '0 0 auto', - paddingInlineStart: tokenSchema.size.space.medium, - width: 'auto', - }) - ), - }; -} - -// Row body -// ---------------------------------------------------------------------------- -export function useRowStyleProps( - props: { - style?: CSSProperties; + // overflow mode + '&[data-overflow-mode="truncate"]': { + alignItems: 'center', }, - state: { - isFocusVisible: boolean; - isFocusWithin: boolean; - isPressed: boolean; - isHovered: boolean; - } -) { - let { style } = props; - let calculatedRadius = `calc(${tokenSchema.size.radius.medium} - ${tokenSchema.size.border.regular})`; - - const className = css({ - boxSizing: 'border-box', - display: 'flex', - position: 'relative', - outline: 0, - - // separators - '&:not(:last-child)': { - backgroundColor: tokenSchema.color.border.muted, - paddingBottom: 1, - }, +}); - // prominence - [`${tableViewClassList.selector('root')}:not([data-prominence="low"]) &`]: { - '&:first-child': { - borderStartStartRadius: calculatedRadius, - borderStartEndRadius: calculatedRadius, - }, - '&:last-child': { - borderEndStartRadius: calculatedRadius, - borderEndEndRadius: calculatedRadius, +export const cellWrapperClassname = css({ + [`${tableViewClassList.selector('body')} &`]: { + backgroundColor: tokenSchema.color.background.canvas, + }, +}); + +// data-attributes +// - align +// - hide-header +// - show-divider +export const cellClassname = classNames( + tableViewClassList.element('cell'), + commonCellStyles, + css({ + color: tokenSchema.color.foreground.neutral, + }) +); +// TODO: assess styles +export const cellContentsClassname = css({ + // color: tokenSchema.color.foreground.neutral, + // fontFamily: tokenSchema.typography.fontFamily.base, + // fontSize: tokenSchema.typography.text.regular.size, + minWidth: 0, + flex: 1, +}); + +export const headerCellClassname = classNames( + commonCellStyles, + css({ + alignItems: 'center', + backgroundColor: tokenSchema.color.background.surface, + color: tokenSchema.color.foreground.neutralSecondary, + minWidth: 0, + flex: 1, + + // SORTABLE + ['&[aria-sort]']: { + display: 'grid', + gridTemplateAreas: '". sort-indicator"', + + '&[data-align="end"]': { + gridTemplateAreas: '"sort-indicator ."', }, - }, - - // focus indicator - '&[data-focus="visible"]': { - '&::before': { - backgroundColor: tokenSchema.color.background.accentEmphasis, - borderRadius: tokenSchema.size.space.small, - content: '""', - insetInlineStart: tokenSchema.size.space.xsmall, - marginBlock: tokenSchema.size.space.xsmall, - marginInlineEnd: `calc(${tokenSchema.size.space.small} * -1)`, - position: 'sticky', - width: tokenSchema.size.space.small, - zIndex: 4, + '&[data-hovered=true]': { + color: tokenSchema.color.foreground.neutralEmphasis, }, }, + }) +); + +export const dragCellClassname = css({ + paddingInlineStart: tokenSchema.size.space.regular, + paddingInlineEnd: 0, +}); +export const checkboxCellClassname = css({ + paddingBlock: 0, + paddingInlineEnd: tokenSchema.size.space.regular, + + label: { + paddingInlineEnd: tokenSchema.size.space.regular, + paddingBlock: tokenSchema.size.space.regular, + }, - // interactions - [`&[data-interaction="hover"] ${tableViewClassList.selector('cell')}`]: { - backgroundColor: tokenSchema.color.scale.slate2, - }, - [`&[data-interaction="press"] ${tableViewClassList.selector('cell')}`]: { - backgroundColor: tokenSchema.color.scale.slate3, - // backgroundColor: tokenSchema.color.alias.backgroundPressed, - }, - - // selected - [`&[aria-selected="true"] ${tableViewClassList.selector('cell')}`]: { - backgroundColor: tokenSchema.color.alias.backgroundSelected, + '&[data-density="compact"]': { + paddingBlock: 0, + label: { + paddingBlock: tokenSchema.size.space.small, }, - [`&[aria-selected="true"][data-interaction="hover"] ${tableViewClassList.selector( - 'cell' - )}`]: { - backgroundColor: tokenSchema.color.alias.backgroundSelectedHovered, + }, + '&[data-density="spacious"]': { + paddingBlock: 0, + label: { + paddingBlock: tokenSchema.size.space.medium, }, - }); - - return { - ...toDataAttributes({ - focus: state.isFocusVisible - ? 'visible' - : state.isFocusWithin - ? 'within' - : undefined, - interaction: state.isPressed - ? 'press' - : state.isHovered - ? 'hover' - : undefined, - }), - className: classNames(tableViewClassList.element('row'), className), - style, - }; -} - -// Row header -// ---------------------------------------------------------------------------- - -export function useRowHeaderStyleProps({ style }: { style?: CSSProperties }) { - const className = css({ - display: 'flex', - }); - - return { className, style }; -} + }, +}); diff --git a/design-system/pkg/src/table/types.ts b/design-system/pkg/src/table/types.ts index 5e8f936e6..12cbb5fcc 100644 --- a/design-system/pkg/src/table/types.ts +++ b/design-system/pkg/src/table/types.ts @@ -1,20 +1,26 @@ -import { CollectionChildren, DOMProps } from '@react-types/shared'; -import { TableProps as _TableProps } from '@react-types/table'; +import { + AriaLabelingProps, + CollectionChildren, + DOMProps, +} from '@react-types/shared'; +import { + ColumnSize, + TableProps as ReactAriaTableProps, +} from '@react-types/table'; import { Key, ReactElement, ReactNode } from 'react'; +import { DragAndDropHooks } from '@keystar/ui/drag-and-drop'; import { BaseStyleProps } from '@keystar/ui/style'; type ColumnElement = ReactElement>; type ColumnRenderer = (item: T) => ColumnElement; -export type TableProps = { +export type TableCosmeticConfig = { /** * Sets the amount of vertical padding within each cell. * @default 'regular' */ density?: 'compact' | 'regular' | 'spacious'; - /** Handler that is called when a user performs an action on a row. */ - onRowAction?: (key: Key) => void; /** * Sets the overflow behavior for the cell contents. * @default 'truncate' @@ -25,9 +31,38 @@ export type TableProps = { * @default 'default' */ prominence?: 'default' | 'low'; +}; +export type TableProps = { + /** Handler that is called when a user performs an action on a row. */ + onAction?: (key: Key) => void; + /** @deprecated Use `onAction` instead. */ + onRowAction?: (key: Key) => void; /** What should render when there is no content to display. */ renderEmptyState?: () => JSX.Element; -} & _TableProps & + /** + * Handler that is called when a user starts a column resize. + */ + onResizeStart?: (widths: Map) => void; + /** + * Handler that is called when a user performs a column resize. + * Can be used with the width property on columns to put the column widths into + * a controlled state. + */ + onResize?: (widths: Map) => void; + /** + * Handler that is called after a user performs a column resize. + * Can be used to store the widths of columns for another future session. + */ + onResizeEnd?: (widths: Map) => void; + /** + * The drag and drop hooks returned by `useDragAndDrop` used to enable drag and drop behavior for the TableView. + * @version beta + */ + dragAndDropHooks?: DragAndDropHooks['dragAndDropHooks']; +} & AriaLabelingProps & + DOMProps & + TableCosmeticConfig & + ReactAriaTableProps & BaseStyleProps & DOMProps; diff --git a/design-system/pkg/src/text-field/TextArea.tsx b/design-system/pkg/src/text-field/TextArea.tsx index f77a91343..8b971c464 100644 --- a/design-system/pkg/src/text-field/TextArea.tsx +++ b/design-system/pkg/src/text-field/TextArea.tsx @@ -14,7 +14,7 @@ export const TextArea = forwardRef( let domRef = useObjectRef(forwardedRef); let [inputValue, setInputValue] = useControlledState( props.value, - props.defaultValue, + props.defaultValue ?? '', () => {} ); diff --git a/design-system/pkg/src/toast/Toast.tsx b/design-system/pkg/src/toast/Toast.tsx index 69435fb42..5893471da 100644 --- a/design-system/pkg/src/toast/Toast.tsx +++ b/design-system/pkg/src/toast/Toast.tsx @@ -8,6 +8,7 @@ import { Icon } from '@keystar/ui/icon'; import { checkCircle2Icon } from '@keystar/ui/icon/icons/checkCircle2Icon'; import { infoIcon } from '@keystar/ui/icon/icons/infoIcon'; import { alertTriangleIcon } from '@keystar/ui/icon/icons/alertTriangleIcon'; +import { SlotProvider } from '@keystar/ui/slots'; import { classNames, css, @@ -20,7 +21,6 @@ import { isReactText } from '@keystar/ui/utils'; import intlMessages from './l10n.json'; import { ToastProps } from './types'; -import { SlotProvider } from '@keystar/ui/slots'; const ICONS = { info: infoIcon, @@ -40,7 +40,7 @@ function Toast(props: ToastProps, ref: ForwardedRef) { ...otherProps } = props; let domRef = useObjectRef(ref); - let { closeButtonProps, titleProps, toastProps } = useToast( + let { closeButtonProps, titleProps, toastProps, contentProps } = useToast( props, state, domRef @@ -124,50 +124,52 @@ function Toast(props: ToastProps, ref: ForwardedRef) { }} > - {icon && ( - - )} -
+ {icon && ( + )} - >
- {isReactText(children) ? {children} : children} -
- {actionLabel && ( - - )} + {isReactText(children) ? {children} : children} +
+ {actionLabel && ( + + )} +
{ + let user: ReturnType; + + beforeAll(() => { + user = userEvent.setup({ delay: null }); + }); beforeEach(() => { jest.useFakeTimers(); clearToastQueue(); @@ -61,35 +68,44 @@ describe('toast/Toast', () => { act(() => jest.runAllTimers()); }); - it('renders a button that triggers a toast', () => { + it('renders a button that triggers a toast', async () => { let { getByRole, queryByRole } = renderComponent(); let button = getByRole('button'); + expect(queryByRole('alertdialog')).toBeNull(); expect(queryByRole('alert')).toBeNull(); - firePress(button); + await user.click(button); + + act(() => jest.advanceTimersByTime(100)); let region = getByRole('region'); - expect(region).toHaveAttribute('aria-label', 'Notifications'); + expect(region).toHaveAttribute('aria-label', '1 notification.'); - let alert = getByRole('alert'); + let toast = getByRole('alertdialog'); + let alert = within(toast).getByRole('alert'); + expect(toast).toBeVisible(); expect(alert).toBeVisible(); - button = within(alert).getByRole('button'); + button = within(toast).getByRole('button'); expect(button).toHaveAttribute('aria-label', 'Close'); - firePress(button); + await user.click(button); fireAnimationEnd(alert); + expect(queryByRole('alertdialog')).toBeNull(); expect(queryByRole('alert')).toBeNull(); }); - it('should label icon by tone', () => { + it('should label icon by tone', async () => { let { getByRole } = renderComponent(); let button = getByRole('button'); - firePress(button); + await user.click(button); - let alert = getByRole('alert'); + let toast = getByRole('alertdialog'); + let alert = within(toast).getByRole('alert'); let icon = within(alert).getByRole('img'); expect(icon).toHaveAttribute('aria-label', 'Success'); + let title = within(alert).getByText('Toast is default').parentElement!; // content is wrapped + expect(toast).toHaveAttribute('aria-labelledby', `${title.id}`); }); it('removes a toast via timeout', () => { @@ -100,7 +116,7 @@ describe('toast/Toast', () => { firePress(button); - let toast = getByRole('alert'); + let toast = getByRole('alertdialog'); expect(toast).toBeVisible(); act(() => jest.advanceTimersByTime(1000)); @@ -110,55 +126,44 @@ describe('toast/Toast', () => { expect(toast).toHaveAttribute('data-animation', 'exiting'); fireAnimationEnd(toast); - expect(queryByRole('alert')).toBeNull(); + expect(queryByRole('alertdialog')).toBeNull(); }); - // TODO: Can't get this working + it sometimes takes down the other tests... - // it('pauses timers when hovering', () => { - // let { getByRole, queryByRole } = renderComponent( - // - // ); - // let button = getByRole('button'); + it('pauses timers when hovering', async () => { + let { getByRole, queryByRole } = renderComponent( + + ); + let button = getByRole('button'); - // firePress(button); + await user.click(button); - // let toast = getByRole('alert'); - // expect(toast).toBeVisible(); + let toast = getByRole('alertdialog'); + expect(toast).toBeVisible(); - // act(() => { - // jest.advanceTimersByTime(1000); - // }); - // act(() => { - // userEvent.hover(toast); - // }); + act(() => jest.advanceTimersByTime(1000)); + await user.hover(toast); - // act(() => { - // jest.advanceTimersByTime(7000); - // }); - // expect(toast).not.toHaveAttribute('data-animation', 'exiting'); + act(() => jest.advanceTimersByTime(7000)); + expect(toast).not.toHaveAttribute('data-animation', 'exiting'); - // act(() => { - // userEvent.unhover(toast); - // }); + await user.unhover(toast); - // act(() => { - // jest.advanceTimersByTime(4000); - // }); - // expect(toast).toHaveAttribute('data-animation', 'exiting'); + act(() => jest.advanceTimersByTime(4000)); + expect(toast).toHaveAttribute('data-animation', 'exiting'); - // fireAnimationEnd(toast); - // expect(queryByRole('alert')).toBeNull(); - // }); + fireAnimationEnd(toast); + expect(queryByRole('alertdialog')).toBeNull(); + }); - it('pauses timers when focusing', () => { + it('pauses timers when focusing', async () => { let { getByRole, queryByRole } = renderComponent( ); let button = getByRole('button'); - firePress(button); + await user.click(button); - let toast = getByRole('alert'); + let toast = getByRole('alertdialog'); expect(toast).toBeVisible(); act(() => jest.advanceTimersByTime(1000)); @@ -173,10 +178,10 @@ describe('toast/Toast', () => { expect(toast).toHaveAttribute('data-animation', 'exiting'); fireAnimationEnd(toast); - expect(queryByRole('alert')).toBeNull(); + expect(queryByRole('alertdialog')).toBeNull(); }); - it('renders a toast with an action', () => { + it('renders a toast with an action', async () => { let onAction = jest.fn(); let onClose = jest.fn(); let { getByRole, queryByRole } = renderComponent( @@ -188,21 +193,24 @@ describe('toast/Toast', () => { ); let button = getByRole('button'); - expect(queryByRole('alert')).toBeNull(); - firePress(button); + expect(queryByRole('alertdialog')).toBeNull(); + await user.click(button); - let alert = getByRole('alert'); + act(() => jest.advanceTimersByTime(100)); + let toast = getByRole('alertdialog'); + let alert = within(toast).getByRole('alert'); + expect(toast).toBeVisible(); expect(alert).toBeVisible(); let buttons = within(alert).getAllByRole('button'); expect(buttons[0]).toHaveTextContent('Action'); - firePress(buttons[0]); + await user.click(buttons[0]); expect(onAction).toHaveBeenCalledTimes(1); expect(onClose).not.toHaveBeenCalled(); }); - it('closes toast on action', () => { + it('closes toast on action', async () => { let onAction = jest.fn(); let onClose = jest.fn(); let { getByRole, queryByRole } = renderComponent( @@ -215,25 +223,28 @@ describe('toast/Toast', () => { ); let button = getByRole('button'); - expect(queryByRole('alert')).toBeNull(); - firePress(button); + expect(queryByRole('alertdialog')).toBeNull(); + await user.click(button); - let alert = getByRole('alert'); + act(() => jest.advanceTimersByTime(100)); + let toast = getByRole('alertdialog'); + let alert = within(toast).getByRole('alert'); + expect(toast).toBeVisible(); expect(alert).toBeVisible(); - let buttons = within(alert).getAllByRole('button'); + let buttons = within(toast).getAllByRole('button'); expect(buttons[0]).toHaveTextContent('Action'); - firePress(buttons[0]); + await user.click(buttons[0]); expect(onAction).toHaveBeenCalledTimes(1); expect(onClose).toHaveBeenCalledTimes(1); - expect(alert).toHaveAttribute('data-animation', 'exiting'); - fireAnimationEnd(alert); - expect(queryByRole('alert')).toBeNull(); + expect(toast).toHaveAttribute('data-animation', 'exiting'); + fireAnimationEnd(toast); + expect(queryByRole('alertdialog')).toBeNull(); }); - it('prioritizes toasts based on tone', () => { + it('prioritizes toasts based on tone', async () => { function ToastPriorites(props = {}) { return (
@@ -252,57 +263,57 @@ describe('toast/Toast', () => { // show info toast first. error toast should supersede it. - expect(queryByRole('alert')).toBeNull(); - firePress(buttons[0]); + expect(queryByRole('alertdialog')).toBeNull(); + await user.click(buttons[0]); - let alert = getByRole('alert'); - expect(alert).toBeVisible(); - expect(alert).toHaveTextContent('Info'); + let toast = getByRole('alertdialog'); + expect(toast).toBeVisible(); + expect(toast).toHaveTextContent('Info'); - firePress(buttons[1]); - fireAnimationEnd(alert); + await user.click(buttons[1]); + fireAnimationEnd(toast); - alert = getByRole('alert'); - expect(alert).toHaveTextContent('Error'); + toast = getByRole('alertdialog'); + expect(toast).toHaveTextContent('Error'); - firePress(within(alert).getByRole('button')); - fireAnimationEnd(alert); + await user.click(within(toast).getByRole('button')); + fireAnimationEnd(toast); - alert = getByRole('alert'); - expect(alert).toHaveTextContent('Info'); + toast = getByRole('alertdialog'); + expect(toast).toHaveTextContent('Info'); - firePress(within(alert).getByRole('button')); - fireAnimationEnd(alert); - expect(queryByRole('alert')).toBeNull(); + await user.click(within(toast).getByRole('button')); + fireAnimationEnd(toast); + expect(queryByRole('alertdialog')).toBeNull(); // again, but with error toast first. - firePress(buttons[1]); - alert = getByRole('alert'); - expect(alert).toHaveTextContent('Error'); + await user.click(buttons[1]); + toast = getByRole('alertdialog'); + expect(toast).toHaveTextContent('Error'); - firePress(buttons[0]); - alert = getByRole('alert'); - expect(alert).toHaveTextContent('Error'); + await user.click(buttons[0]); + toast = getByRole('alertdialog'); + expect(toast).toHaveTextContent('Error'); - firePress(within(alert).getByRole('button')); - fireAnimationEnd(alert); + await user.click(within(toast).getByRole('button')); + fireAnimationEnd(toast); - alert = getByRole('alert'); - expect(alert).toHaveTextContent('Info'); + toast = getByRole('alertdialog'); + expect(toast).toHaveTextContent('Info'); - firePress(within(alert).getByRole('button')); - fireAnimationEnd(alert); - expect(queryByRole('alert')).toBeNull(); + await user.click(within(toast).getByRole('button')); + fireAnimationEnd(toast); + expect(queryByRole('alertdialog')).toBeNull(); }); - it('can focus toast region using F6', () => { + it('can focus toast region using F6', async () => { let { getByRole } = renderComponent(); let button = getByRole('button'); - firePress(button); + await user.click(button); - let toast = getByRole('alert'); + let toast = getByRole('alertdialog'); expect(toast).toBeVisible(); expect(document.activeElement).toBe(button); @@ -313,46 +324,45 @@ describe('toast/Toast', () => { expect(document.activeElement).toBe(region); }); - it('should restore focus when a toast exits', () => { + it('should restore focus when a toast exits', async () => { let { getByRole, queryByRole } = renderComponent(); let button = getByRole('button'); - firePress(button); + await user.click(button); - let toast = getByRole('alert'); + let toast = getByRole('alertdialog'); let closeButton = within(toast).getByRole('button'); - act(() => closeButton.focus()); - firePress(closeButton); + await user.click(closeButton); fireAnimationEnd(toast); - expect(queryByRole('alert')).toBeNull(); - expect(document.activeElement).toBe(button); + expect(queryByRole('alertdialog')).toBeNull(); + expect(button).toHaveFocus(); }); - it('should move focus to container when a toast exits and there are more', () => { + it('should move focus to the next available toast, when closed', async () => { let { getByRole, queryByRole } = renderComponent(); let button = getByRole('button'); - firePress(button); - firePress(button); + await user.click(button); + await user.click(button); - let toast = getByRole('alert'); + let toast = getByRole('alertdialog'); let closeButton = within(toast).getByRole('button'); - firePress(closeButton); + await user.click(closeButton); fireAnimationEnd(toast); - expect(document.activeElement).toBe(getByRole('region')); - - toast = getByRole('alert'); + // next toast + toast = getByRole('alertdialog'); + expect(document.activeElement).toBe(toast); closeButton = within(toast).getByRole('button'); - firePress(closeButton); + await user.click(closeButton); fireAnimationEnd(toast); - expect(queryByRole('alert')).toBeNull(); + expect(queryByRole('alertdialog')).toBeNull(); expect(document.activeElement).toBe(button); }); - it('should support programmatically closing toasts', () => { + it('should support programmatically closing toasts', async () => { function ToastToggle() { let [close, setClose] = useState<(() => void) | null>(null); @@ -373,17 +383,17 @@ describe('toast/Toast', () => { let { getByRole, queryByRole } = renderComponent(); let button = getByRole('button'); - firePress(button); + await user.click(button); let toast = getByRole('alert'); expect(toast).toBeVisible(); - firePress(button); + await user.click(button); fireAnimationEnd(toast); expect(queryByRole('alert')).toBeNull(); }); - it('should only render one Toaster', () => { + it('should only render one Toaster', async () => { let { getByRole, getAllByRole, rerender } = renderWithProvider( <> @@ -393,10 +403,10 @@ describe('toast/Toast', () => { ); let button = getByRole('button'); - firePress(button); + await user.click(button); expect(getAllByRole('region')).toHaveLength(1); - expect(getAllByRole('alert')).toHaveLength(1); + expect(getAllByRole('alertdialog')).toHaveLength(1); rerender( <> @@ -406,7 +416,7 @@ describe('toast/Toast', () => { ); expect(getAllByRole('region')).toHaveLength(1); - expect(getAllByRole('alert')).toHaveLength(1); + expect(getAllByRole('alertdialog')).toHaveLength(1); rerender( <> @@ -416,7 +426,7 @@ describe('toast/Toast', () => { ); expect(getAllByRole('region')).toHaveLength(1); - expect(getAllByRole('alert')).toHaveLength(1); + expect(getAllByRole('alertdialog')).toHaveLength(1); rerender( <> @@ -427,7 +437,7 @@ describe('toast/Toast', () => { ); expect(getAllByRole('region')).toHaveLength(1); - expect(getAllByRole('alert')).toHaveLength(1); + expect(getAllByRole('alertdialog')).toHaveLength(1); }); it('should support custom aria-label', () => { diff --git a/design-system/pkg/src/tooltip/TooltipTrigger.tsx b/design-system/pkg/src/tooltip/TooltipTrigger.tsx index 21eb2c910..3fd1a170b 100644 --- a/design-system/pkg/src/tooltip/TooltipTrigger.tsx +++ b/design-system/pkg/src/tooltip/TooltipTrigger.tsx @@ -42,7 +42,6 @@ function TooltipTrigger(props: TooltipTriggerProps) { ...tooltipProps, }} > - {/* @ts-expect-error FIXME: resolve ref inconsistencies */} {tooltipElement} diff --git a/design-system/pkg/src/tooltip/test/TooltipTrigger.test.tsx b/design-system/pkg/src/tooltip/test/TooltipTrigger.test.tsx index 4836321a1..5325342a5 100644 --- a/design-system/pkg/src/tooltip/test/TooltipTrigger.test.tsx +++ b/design-system/pkg/src/tooltip/test/TooltipTrigger.test.tsx @@ -1,4 +1,5 @@ import '@testing-library/jest-dom/jest-globals'; +import userEvent from '@testing-library/user-event'; import { act, fireEvent, @@ -29,8 +30,10 @@ const LEAVE_TIMEOUT = 320; // NOTE: skipped tests have something to do with mouse events and timers... describe('tooltip/TooltipTrigger', () => { let onOpenChange = jest.fn(); + let user: ReturnType; beforeAll(() => { + user = userEvent.setup({ delay: null }); jest.useFakeTimers(); }); @@ -85,26 +88,21 @@ describe('tooltip/TooltipTrigger', () => { expect(tooltip).not.toBeInTheDocument(); }); - // eslint-disable-next-line jest/no-disabled-tests - it.skip('opens for hover', async () => { + it('opens for hover', async () => { let { getByRole, getByLabelText } = renderWithProvider(