Skip to content

Commit

Permalink
feat(collection): Add a list data loader (#2203)
Browse files Browse the repository at this point in the history
Add a list data loader to help with dynamically loaded data from the server. Adds documentation, tests, and an example.

[category:Components]

Release Note:
A data loader has been added to collections systems to make it easier to interact with server data.

```tsx
const {model, loader} = useListLoader(
  {
    total: 1000,
    pageSize: 20,
    async load({pageNumber, pageSize}) {
      return fetch('/myUrl')
        .then(response => response.json())
        .then(response => {
          return {total: response.total, items: response.items};
        });
    },
  },
  useListModel
);
```
  • Loading branch information
NicholasBoll authored May 8, 2023
1 parent 9304d13 commit 608e353
Show file tree
Hide file tree
Showing 8 changed files with 463 additions and 40 deletions.
1 change: 1 addition & 0 deletions modules/react/collection/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export * from './lib/useListRenderItem';
export * from './lib/useListResetCursorOnBlur';
export * from './lib/useListItemRovingFocus';
export * from './lib/useListItemSelect';
export * from './lib/useListLoader';
export * from './lib/useListModel';
export * from './lib/useGridModel';
export * from './lib/useListItemAllowChildStrings';
Expand Down
20 changes: 17 additions & 3 deletions modules/react/collection/lib/useCursorListModel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,8 @@ export const navigationManager = createNavigationManager({
getLastOfRow,
});

type Writeable<T> = {-readonly [P in keyof T]: T[P]};

/**
* A `CursorModel` extends a `ListModel` and adds a "cursor" to the list. A cursor is a pointer to a
* current position in the list. The most common use-case is keeping track of which item currently
Expand Down Expand Up @@ -323,17 +325,21 @@ export const useCursorListModel = createModelHook({
const columnCount = config.columnCount || 0;
const list = useBaseListModel(config);
const navigation = config.navigation;
const cursorIndexRef = React.useRef(-1);
// Cast as a readonly to signify this value should never be set
const cursorIndexRef = React.useRef(-1) as {readonly current: number};
const setCursor = (index: number) => {
const id = state.items[index]?.id || '';
setCursorId(id);
};

// Keep the cursorIndex up to date with the cursor ID
if (cursorId && list.state.items[cursorIndexRef.current]?.id !== cursorId) {
cursorIndexRef.current = list.state.items.findIndex(item => item.id === cursorId);
// We cast back as a writeable because this is the only place the value should be changed.
(cursorIndexRef as Writeable<typeof cursorIndexRef>).current = list.state.items.findIndex(
item => item.id === cursorId
);
} else if (!cursorId) {
cursorIndexRef.current = -1;
(cursorIndexRef as Writeable<typeof cursorIndexRef>).current = -1;
}

const state = {
Expand All @@ -351,6 +357,14 @@ export const useCursorListModel = createModelHook({
* based on the size of the list container and the number of items fitting within the container.
*/
pageSizeRef,
/**
* A readonly [React.Ref](https://react.dev/learn/referencing-values-with-refs) that tracks the
* index of the `state.cursorId`. This value is automatically updated when the `state.cursorId`
* or the `items` change.
*
* @readonly
*/
cursorIndexRef,
};

const events = {
Expand Down
6 changes: 3 additions & 3 deletions modules/react/collection/lib/useListItemAllowChildStrings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import {createElemPropsHook} from '@workday/canvas-kit-react/common';
import {useListModel} from '@workday/canvas-kit-react/collection';

/**
* This elemProps hook allows for children values to be considered identifiers if the children
* are strings. This can be useful for autocomplete or select components that allow string values.
* This hook must be defined _before_ {@link useListItemRegister} because it sets the `data-id`
* This elemProps hook allows for children values to be considered identifiers if the children are
* strings. This can be useful for autocomplete or select components that allow string values. This
* hook must be passed _after_ {@link useListItemRegister} because this hook sets the `data-id`
* attribute if one hasn't been defined by the application.
*
* An example might look like:
Expand Down
26 changes: 19 additions & 7 deletions modules/react/collection/lib/useListItemRovingFocus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ import {useIsRTL, createElemPropsHook} from '@workday/canvas-kit-react/common';
import {useCursorListModel} from './useCursorListModel';
import {keyboardEventToCursorEvents} from './keyUtils';

// retry a function each frame so we don't rely on the timing mechanism of React's render cycle.
const retryEachFrame = (cb: () => boolean, iterations: number) => {
if (cb() === false && iterations > 1) {
requestAnimationFrame(() => retryEachFrame(cb, iterations - 1));
}
};

/**
* This elemProps hook is used for cursor navigation by using [Roving
* Tabindex](https://w3c.github.io/aria-practices/#kbd_roving_tabindex). Only a single item in the
Expand All @@ -19,7 +26,7 @@ import {keyboardEventToCursorEvents} from './keyUtils';
```
*/
export const useListItemRovingFocus = createElemPropsHook(useCursorListModel)(
(model, ref, elemProps: {'data-id'?: string} = {}) => {
(model, _ref, elemProps: {'data-id'?: string} = {}) => {
// Create a ref out of state. We don't want to watch state on unmount, so we use a ref to get the
// current value at the time of unmounting. Otherwise, `state.items` would be a cached value of an
// empty array
Expand All @@ -35,11 +42,17 @@ export const useListItemRovingFocus = createElemPropsHook(useCursorListModel)(
if (model.state.isVirtualized) {
model.state.UNSTABLE_virtual.scrollToIndex(item.index);
}
requestAnimationFrame(() => {
document
.querySelector<HTMLElement>(`[data-focus-id="${`${model.state.id}-${item.id}`}"]`)
?.focus();
});

// In React concurrent mode, there could be several render attempts before the element we're
// looking for could be available in the DOM
retryEachFrame(() => {
const element = document.querySelector<HTMLElement>(
`[data-focus-id="${`${model.state.id}-${item.id}`}"]`
);

element?.focus();
return !!element;
}, 5); // 5 should be enough, right?!

keyDownRef.current = false;
}
Expand Down Expand Up @@ -71,7 +84,6 @@ export const useListItemRovingFocus = createElemPropsHook(useCursorListModel)(
: !!elemProps['data-id'] && model.state.cursorId === elemProps['data-id']
? 0 // A name is known and cursor is here
: -1, // A name is known an cursor is somewhere else
ref,
};
}
);
Loading

0 comments on commit 608e353

Please sign in to comment.