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 */}
(
{'\u2014'}
+ {/* @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