Skip to content

Commit 2969511

Browse files
committed
fix bugs with subdialog filtering, arrow nav, dividers, etc
1 parent 290514a commit 2969511

File tree

6 files changed

+134
-134
lines changed

6 files changed

+134
-134
lines changed

packages/@react-aria/collections/src/BaseCollection.ts

Lines changed: 54 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,17 @@ export class CollectionNode<T> implements Node<T> {
6565
node.render = this.render;
6666
node.colSpan = this.colSpan;
6767
node.colIndex = this.colIndex;
68+
node.filter = this.filter;
6869
return node;
6970
}
71+
72+
filter(collection: BaseCollection<T>, newCollection: BaseCollection<T>, filterFn: (textValue: string) => boolean): CollectionNode<T> | null {
73+
let [firstKey, lastKey] = filterChildren(collection, newCollection, this.firstChildKey, filterFn);
74+
let newNode: Mutable<CollectionNode<T>> = this.clone();
75+
newNode.firstChildKey = firstKey;
76+
newNode.lastChildKey = lastKey;
77+
return newNode;
78+
}
7079
}
7180

7281
/**
@@ -213,141 +222,61 @@ export class BaseCollection<T> implements ICollection<Node<T>> {
213222
this.frozen = !isSSR;
214223
}
215224

216-
// TODO: this is pretty specific to menu, will need to check if it is generic enough
217-
// Will need to handle varying levels I assume but will revisit after I get searchable menu working for base menu
218-
// TODO: an alternative is to simply walk the collection and add all item nodes that match the filter and any sections/separators we encounter
219-
// to an array, then walk that new array and fix all the next/Prev keys while adding them to the new collection
220-
UNSTABLE_filter(filterFn: (nodeValue: string) => boolean): BaseCollection<T> {
225+
UNSTABLE_filter(filterFn: (textValue: string) => boolean): BaseCollection<T> {
221226
let newCollection = new BaseCollection<T>();
222-
// This tracks the absolute last node we've visited in the collection when filtering, used for setting up the filteredCollection's lastKey and
223-
// for updating the next/prevKey for every non-filtered node.
224-
let lastNode: Mutable<CollectionNode<T>> | null = null;
225-
226-
for (let node of this) {
227-
if (node.type === 'section' && node.hasChildNodes) {
228-
let clonedSection: Mutable<CollectionNode<T>> = (node as CollectionNode<T>).clone();
229-
let lastChildInSection: Mutable<CollectionNode<T>> | null = null;
230-
for (let child of this.getChildren(node.key)) {
231-
if (shouldKeepNode(child, filterFn, this, newCollection)) {
232-
let clonedChild: Mutable<CollectionNode<T>> = (child as CollectionNode<T>).clone();
233-
// eslint-disable-next-line max-depth
234-
if (lastChildInSection == null) {
235-
clonedSection.firstChildKey = clonedChild.key;
236-
}
237-
238-
// eslint-disable-next-line max-depth
239-
if (newCollection.firstKey == null) {
240-
newCollection.firstKey = clonedSection.key;
241-
}
242-
243-
// eslint-disable-next-line max-depth
244-
if (lastChildInSection && lastChildInSection.parentKey === clonedChild.parentKey) {
245-
lastChildInSection.nextKey = clonedChild.key;
246-
clonedChild.prevKey = lastChildInSection.key;
247-
} else {
248-
clonedChild.prevKey = null;
249-
}
250-
251-
clonedChild.nextKey = null;
252-
newCollection.addNode(clonedChild);
253-
lastChildInSection = clonedChild;
254-
}
255-
}
227+
let [firstKey, lastKey] = filterChildren(this, newCollection, this.firstKey, filterFn);
228+
newCollection.firstKey = firstKey;
229+
newCollection.lastKey = lastKey;
230+
return newCollection;
231+
}
232+
}
256233

257-
// Add newly filtered section to collection if it has any valid child nodes, otherwise remove it and its header if any
258-
if (lastChildInSection) {
259-
if (lastChildInSection.type !== 'header') {
260-
clonedSection.lastChildKey = lastChildInSection.key;
261-
262-
// If the old prev section was filtered out, will need to attach to whatever came before
263-
// eslint-disable-next-line max-depth
264-
if (lastNode == null) {
265-
clonedSection.prevKey = null;
266-
} else if (lastNode.type === 'section' || lastNode.type === 'separator') {
267-
lastNode.nextKey = clonedSection.key;
268-
clonedSection.prevKey = lastNode.key;
269-
}
270-
clonedSection.nextKey = null;
271-
lastNode = clonedSection;
272-
newCollection.addNode(clonedSection);
273-
} else {
274-
if (newCollection.firstKey === clonedSection.key) {
275-
newCollection.firstKey = null;
276-
}
277-
newCollection.removeNode(lastChildInSection.key);
278-
}
279-
}
280-
} else if (node.type === 'separator') {
281-
// will need to check if previous section key exists, if it does then we add the separator to the collection.
282-
// After the full collection is created we'll need to remove it it is the last node in the section (aka no following section after the separator)
283-
let clonedSeparator: Mutable<CollectionNode<T>> = (node as CollectionNode<T>).clone();
284-
clonedSeparator.nextKey = null;
285-
if (lastNode?.type === 'section') {
286-
lastNode.nextKey = clonedSeparator.key;
287-
clonedSeparator.prevKey = lastNode.key;
288-
lastNode = clonedSeparator;
289-
newCollection.addNode(clonedSeparator);
290-
}
291-
} else {
292-
// At this point, the node is either a subdialogtrigger node or a standard row/item
293-
let clonedNode: Mutable<CollectionNode<T>> = (node as CollectionNode<T>).clone();
294-
if (shouldKeepNode(clonedNode, filterFn, this, newCollection)) {
295-
if (newCollection.firstKey == null) {
296-
newCollection.firstKey = clonedNode.key;
297-
}
298-
299-
if (lastNode != null) {
300-
if (
301-
(lastNode.type !== 'section' && lastNode.type !== 'separator' && lastNode.parentKey === clonedNode.parentKey) ||
302-
(clonedNode.type === 'loader')
303-
) {
304-
lastNode.nextKey = clonedNode.key;
305-
clonedNode.prevKey = lastNode.key;
306-
} else {
307-
clonedNode.prevKey = null;
308-
}
309-
}
310-
311-
clonedNode.nextKey = null;
312-
newCollection.addNode(clonedNode);
313-
lastNode = clonedNode;
314-
}
234+
function filterChildren<T>(collection: BaseCollection<T>, newCollection: BaseCollection<T>, firstChildKey: Key | null, filterFn: (textValue: string) => boolean): [Key | null, Key | null] {
235+
// loop over the siblings for firstChildKey
236+
// create new nodes based on calling node.filter for each child
237+
// if it returns null then don't include it, otherwise update its prev/next keys
238+
// add them to the newCollection
239+
if (firstChildKey == null) {
240+
return [null, null];
241+
}
242+
243+
let firstNode: Node<T> | null = null;
244+
let lastNode: Node<T> | null = null;
245+
let currentNode = collection.getItem(firstChildKey);
246+
247+
while (currentNode != null) {
248+
let newNode: Mutable<CollectionNode<T>> | null = (currentNode as CollectionNode<T>).filter(collection, newCollection, filterFn);
249+
if (newNode != null) {
250+
if (lastNode) {
251+
newNode.prevKey = lastNode.key;
252+
lastNode.nextKey = newNode.key;
315253
}
316-
}
317254

318-
if (lastNode?.type === 'separator' && lastNode.nextKey === null) {
319-
let lastSection;
320-
if (lastNode.prevKey != null) {
321-
lastSection = newCollection.getItem(lastNode.prevKey) as Mutable<CollectionNode<T>>;
322-
lastSection.nextKey = null;
255+
if (firstNode == null) {
256+
firstNode = newNode;
323257
}
324-
newCollection.removeNode(lastNode.key);
325-
lastNode = lastSection;
326-
}
327258

328-
newCollection.lastKey = lastNode?.key || null;
259+
newCollection.addNode(newNode);
260+
lastNode = newNode;
261+
}
329262

330-
return newCollection;
263+
currentNode = currentNode.nextKey ? collection.getItem(currentNode.nextKey) : null;
331264
}
332-
}
333265

334-
function shouldKeepNode<T>(node: Node<T>, filterFn: (nodeValue: string) => boolean, oldCollection: BaseCollection<T>, newCollection: BaseCollection<T>): boolean {
335-
if (node.type === 'subdialogtrigger' || node.type === 'submenutrigger') {
336-
// Subdialog wrapper should only have one child, if it passes the filter add it to the new collection since we don't need to
337-
// do any extra handling for its first/next key
338-
let triggerChild = [...oldCollection.getChildren(node.key)][0];
339-
if (triggerChild && filterFn(triggerChild.textValue)) {
340-
let clonedChild: Mutable<CollectionNode<T>> = (triggerChild as CollectionNode<T>).clone();
341-
newCollection.addNode(clonedChild);
342-
return true;
266+
// TODO: this is pretty specific to dividers but doesn't feel like there is a good way to get around it since we only can know
267+
// to filter the last separator in a collection only after performing a filter for the rest of the contents after it
268+
// Its gross that it needs to live here, might be nice if somehow we could have this live in the separator code
269+
if (lastNode && lastNode.type === 'separator') {
270+
let prevKey = lastNode.prevKey;
271+
newCollection.removeNode(lastNode.key);
272+
273+
if (prevKey) {
274+
lastNode = newCollection.getItem(prevKey) as Mutable<CollectionNode<T>>;
275+
lastNode.nextKey = null;
343276
} else {
344-
return false;
277+
lastNode = null;
345278
}
346-
} else if (node.type === 'header' || node.type === 'loader') {
347-
// TODO what about tree multiple loaders? Should a loader still be preserved if its parent row is filtered out
348-
// Actually how should a tree structure be filtered? Do levels no longer matter or does it filter at each level?
349-
return true;
350-
} else {
351-
return filterFn(node.textValue);
352279
}
280+
281+
return [firstNode?.key ?? null, lastNode?.key ?? null];
353282
}

packages/react-aria-components/src/Header.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ class HeaderNode extends CollectionNode<any> {
2121
constructor(key: Key) {
2222
super('header', key);
2323
}
24+
25+
filter(): CollectionNode<any> {
26+
return this.clone();
27+
}
2428
}
2529

2630
export const Header = /*#__PURE__*/ createLeafComponent(HeaderNode, function Header(props: HTMLAttributes<HTMLElement>, ref: ForwardedRef<HTMLElement>) {

packages/react-aria-components/src/ListBox.tsx

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
*/
1212

1313
import {AriaListBoxOptions, AriaListBoxProps, DraggableItemResult, DragPreviewRenderer, DroppableCollectionResult, DroppableItemResult, FocusScope, ListKeyboardDelegate, mergeProps, useCollator, useFocusRing, useHover, useListBox, useListBoxSection, useLocale, useOption} from 'react-aria';
14-
import {Collection, CollectionBuilder, CollectionNode, createBranchComponent, createLeafComponent} from '@react-aria/collections';
14+
import {BaseCollection, Collection, CollectionBuilder, CollectionNode, createBranchComponent, createLeafComponent} from '@react-aria/collections';
1515
import {CollectionProps, CollectionRendererContext, ItemRenderProps, SectionContext, SectionProps} from './Collection';
1616
import {ContextValue, DEFAULT_SLOT, Provider, RenderProps, ScrollableProps, SlotProps, StyleProps, StyleRenderProps, useContextProps, useRenderProps, useSlot} from './utils';
1717
import {DragAndDropContext, DropIndicatorContext, DropIndicatorProps, useDndPersistedKeys, useRenderDropIndicator} from './DragAndDrop';
@@ -307,10 +307,24 @@ function ListBoxSectionInner<T extends object>(props: ListBoxSectionProps<T>, re
307307

308308
// todo make a class here
309309

310-
class SectionNode extends CollectionNode<any> {
310+
class SectionNode<T> extends CollectionNode<T> {
311311
constructor(key: Key) {
312312
super('section', key);
313313
}
314+
315+
filter(collection: BaseCollection<T>, newCollection: BaseCollection<T>, filterFn: (textValue: string) => boolean): CollectionNode<T> | null {
316+
let filteredSection = super.filter(collection, newCollection, filterFn);
317+
if (filteredSection) {
318+
if (filteredSection.lastChildKey !== null) {
319+
let lastChild = collection.getItem(filteredSection.lastChildKey);
320+
if (lastChild && lastChild.type !== 'header') {
321+
return filteredSection;
322+
}
323+
}
324+
}
325+
326+
return null;
327+
}
314328
}
315329

316330
/**
@@ -341,10 +355,18 @@ export interface ListBoxItemProps<T = object> extends RenderProps<ListBoxItemRen
341355

342356
// TODO create item type here
343357

344-
class ItemNode extends CollectionNode<any> {
358+
class ItemNode<T> extends CollectionNode<T> {
345359
constructor(key: Key) {
346360
super('item', key);
347361
}
362+
363+
filter(_, __, filterFn: (textValue: string) => boolean): CollectionNode<T> | null {
364+
if (filterFn(this.textValue)) {
365+
return this.clone();
366+
}
367+
368+
return null;
369+
}
348370
}
349371

350372
/**
@@ -489,6 +511,10 @@ class LoaderNode extends CollectionNode<any> {
489511
constructor(key: Key) {
490512
super('loader', key);
491513
}
514+
515+
filter(): CollectionNode<any> | null {
516+
return this.clone();
517+
}
492518
}
493519

494520
const ListBoxDropIndicatorForwardRef = forwardRef(ListBoxDropIndicator);

packages/react-aria-components/src/Menu.tsx

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,11 +107,22 @@ export interface SubmenuTriggerProps {
107107

108108
const SubmenuTriggerContext = createContext<{parentMenuRef: RefObject<HTMLElement | null>, shouldUseVirtualFocus?: boolean} | null>(null);
109109

110-
// todo: what logic should this have?
111-
class SubMenuTriggerNode extends CollectionNode<any> {
110+
class SubMenuTriggerNode<T> extends CollectionNode<T> {
112111
constructor(key: Key) {
113112
super('submenutrigger', key);
114113
}
114+
115+
filter(collection: BaseCollection<T>, newCollection: BaseCollection<T>, filterFn: (textValue: string) => boolean): CollectionNode<T> | null {
116+
let triggerNode = collection.getItem(this.firstChildKey!);
117+
if (triggerNode && filterFn(triggerNode.textValue)) {
118+
// TODO: perhaps should call super.filter for correctness, but basically add the menu item child of the submenutrigger
119+
// to the keymap so it renders
120+
newCollection.addNode(triggerNode as CollectionNode<T>);
121+
return this.clone();
122+
}
123+
124+
return null;
125+
}
115126
}
116127

117128
/**
@@ -326,10 +337,24 @@ function MenuSectionInner<T extends object>(props: MenuSectionProps<T>, ref: For
326337
}
327338

328339
// todo can probably reuse the SectionNode from ListBox?
329-
class SectionNode extends CollectionNode<any> {
340+
class SectionNode<T> extends CollectionNode<T> {
330341
constructor(key: Key) {
331342
super('section', key);
332343
}
344+
345+
filter(collection: BaseCollection<T>, newCollection: BaseCollection<T>, filterFn: (textValue: string) => boolean): CollectionNode<T> | null {
346+
let filteredSection = super.filter(collection, newCollection, filterFn);
347+
if (filteredSection) {
348+
if (filteredSection.lastChildKey !== null) {
349+
let lastChild = collection.getItem(filteredSection.lastChildKey);
350+
if (lastChild && lastChild.type !== 'header') {
351+
return filteredSection;
352+
}
353+
}
354+
}
355+
356+
return null;
357+
}
333358
}
334359

335360
/**
@@ -370,10 +395,18 @@ export interface MenuItemProps<T = object> extends RenderProps<MenuItemRenderPro
370395
const MenuItemContext = createContext<ContextValue<MenuItemProps, HTMLDivElement>>(null);
371396

372397
// TODO maybe this needs to a separate node type?
373-
class MenuItemNode extends CollectionNode<any> {
398+
class MenuItemNode<T> extends CollectionNode<T> {
374399
constructor(key: Key) {
375400
super('item', key);
376401
}
402+
403+
filter(_, __, filterFn: (textValue: string) => boolean): CollectionNode<T> | null {
404+
if (filterFn(this.textValue)) {
405+
return this.clone();
406+
}
407+
408+
return null;
409+
}
377410
}
378411

379412
/**

packages/react-aria-components/src/Separator.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
*/
1212

1313
import {SeparatorProps as AriaSeparatorProps, useSeparator} from 'react-aria';
14-
import {CollectionNode, createLeafComponent} from '@react-aria/collections';
14+
import {BaseCollection, CollectionNode, createLeafComponent} from '@react-aria/collections';
1515
import {ContextValue, SlotProps, StyleProps, useContextProps} from './utils';
1616
import {filterDOMProps} from '@react-aria/utils';
1717
import {Key} from '@react-types/shared';
@@ -25,6 +25,14 @@ class SeparatorNode extends CollectionNode<any> {
2525
constructor(key: Key) {
2626
super('separator', key);
2727
}
28+
29+
filter(_, newCollection: BaseCollection<any>): CollectionNode<any> | null {
30+
if (newCollection.getItem(this.prevKey!)) {
31+
return this.clone();
32+
}
33+
34+
return null;
35+
}
2836
}
2937

3038
export const Separator = /*#__PURE__*/ createLeafComponent(SeparatorNode, function Separator(props: SeparatorProps, ref: ForwardedRef<HTMLElement>) {

packages/react-aria-components/stories/Autocomplete.stories.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,11 @@
1313
import {action} from '@storybook/addon-actions';
1414
import {Autocomplete, Button, Collection, DialogTrigger, Header, Input, Keyboard, Label, ListBox, ListBoxSection, ListLayout, Menu, MenuItem, MenuSection, MenuTrigger, Popover, SearchField, Select, SelectValue, Separator, SubmenuTrigger, Text, TextField, Virtualizer} from 'react-aria-components';
1515
import {MyListBoxItem, MyMenuItem} from './utils';
16+
import {MyListBoxLoaderIndicator, renderEmptyState} from './ListBox.stories';
1617
import React from 'react';
1718
import styles from '../example/index.css';
1819
import {useAsyncList, useListData, useTreeData} from 'react-stately';
1920
import {useFilter} from 'react-aria';
20-
import { MyListBoxLoaderIndicator, renderEmptyState } from './ListBox.stories';
2121

2222
export default {
2323
title: 'React Aria Components',

0 commit comments

Comments
 (0)