Skip to content

Commit

Permalink
[🐛][Select]: fix a bug that caused the group to be hidden incorrectly…
Browse files Browse the repository at this point in the history
… when`searchable={true}` (#100)

* Add `getOptions` to context

* Update useComboBox hook to fix the issue with search queries

* Improve getOptions utility

* Update version
  • Loading branch information
mimshins authored May 26, 2024
1 parent de499aa commit f0f3c5a
Show file tree
Hide file tree
Showing 6 changed files with 112 additions and 63 deletions.
11 changes: 6 additions & 5 deletions lib/Select/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
import { SelectContext, type SelectContextValue } from "./context";
import { Root as RootSlot } from "./slots";
import {
getOptions,
getOptions as getOptionsUtil,
noValueSelected,
normalizeValues,
useElementsRegistry,
Expand Down Expand Up @@ -415,6 +415,8 @@ const SelectBase = (props: Props, ref: React.Ref<HTMLDivElement>) => {
closeList();
}

const getOptions = () => getOptionsUtil(React.Children.toArray(children));

const context: SelectContextValue = {
readOnly,
disabled,
Expand All @@ -431,6 +433,7 @@ const SelectBase = (props: Props, ref: React.Ref<HTMLDivElement>) => {
closeListAndMaintainFocus,
setFilteredEntities,
setActiveDescendant,
getOptions,
openList,
closeList,
toggleList,
Expand Down Expand Up @@ -488,9 +491,7 @@ const SelectBase = (props: Props, ref: React.Ref<HTMLDivElement>) => {
if (selectedValues.length === 0) return null;

const renderOptions = () => {
const disabledOptions = getOptions(
React.Children.toArray(children),
).filter(o => o.disabled);
const disabledOptions = getOptions().filter(o => o.disabled);

const isOptionDisabled = (optionValue: string) =>
disabledOptions.some(o => o.value === optionValue);
Expand Down Expand Up @@ -538,7 +539,7 @@ const SelectBase = (props: Props, ref: React.Ref<HTMLDivElement>) => {
<div
{...otherProps}
// @ts-expect-error React hasn't added `inert` yet
inert={disabled || readOnly ? "" : undefined}
inert={disabled ? "" : undefined}
style={style}
id={id}
ref={handleRootRef}
Expand Down
3 changes: 2 additions & 1 deletion lib/Select/components/Controller/Controller.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ const ControllerBase = (props: Props, ref: React.Ref<HTMLInputElement>) => {
searchable: ctx?.searchable ?? false,
onInputChange: onChange,
onKeyDown,
getListItems: () => {
getOptionsInfo: ctx?.getOptions ?? (() => []),
getOptionElements: () => {
const listId = ctx?.elementsRegistry.getElementId("list");
const listNode = document.getElementById(listId ?? "");

Expand Down
48 changes: 30 additions & 18 deletions lib/Select/components/Controller/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,15 @@ import {
useIsomorphicLayoutEffect,
useOnChange,
} from "../../../utils";
import type { SelectContextValue } from "../../context";

type Props<T extends HTMLElement> = {
disabled: boolean;
readOnly: boolean;
autoFocus: boolean;
searchable: boolean;
activeDescendant: HTMLElement | null;
listOpenState: boolean;
activeDescendant: SelectContextValue["activeDescendant"];
onClick?: React.MouseEventHandler<T>;
onBlur?: React.FocusEventHandler<T>;
onFocus?: React.FocusEventHandler<T>;
Expand All @@ -26,10 +27,15 @@ type Props<T extends HTMLElement> = {
onEscapeKeyDown?: React.KeyboardEventHandler<T>;
onBackspaceKeyDown?: React.KeyboardEventHandler<T>;
onInputChange?: React.ChangeEventHandler<HTMLInputElement>;
getListItems: () => HTMLElement[];
onFilteredEntities: (entities: null | string[]) => void;
getOptionElements: () => HTMLElement[];
getOptionsInfo: SelectContextValue["getOptions"];
onFilteredEntities: (
entities: SelectContextValue["filteredEntities"],
) => void;
onListOpenChange: (nextListOpenState: boolean) => void;
onActiveDescendantChange: (nextActiveDescendant: HTMLElement | null) => void;
onActiveDescendantChange: (
nextActiveDescendant: SelectContextValue["activeDescendant"],
) => void;
};

export const useComboboxBase = <T extends HTMLElement>(props: Props<T>) => {
Expand All @@ -42,7 +48,8 @@ export const useComboboxBase = <T extends HTMLElement>(props: Props<T>) => {
onPrintableKeyDown,
onEscapeKeyDown,
onBackspaceKeyDown,
getListItems,
getOptionElements,
getOptionsInfo,
onActiveDescendantChange,
onListOpenChange,
onFilteredEntities,
Expand All @@ -65,7 +72,7 @@ export const useComboboxBase = <T extends HTMLElement>(props: Props<T>) => {

const jumpToChar = useJumpToChar({
activeDescendantElement: activeDescendant,
getListItems,
getListItems: getOptionElements,
onActiveDescendantElementChange: onActiveDescendantChange,
});

Expand Down Expand Up @@ -140,12 +147,14 @@ export const useComboboxBase = <T extends HTMLElement>(props: Props<T>) => {
});

const handleKeyDown = useEventCallback<React.KeyboardEvent<T>>(event => {
if (disabled || readOnly || !isMounted()) {
if (disabled || !isMounted()) {
event.preventDefault();

return;
}

if (readOnly) return;

const getAvailableItem = (
items: (HTMLElement | null)[],
idx: number,
Expand Down Expand Up @@ -195,15 +204,15 @@ export const useComboboxBase = <T extends HTMLElement>(props: Props<T>) => {
onListOpenChange(true);
});

const items = getListItems();
const items = getOptionElements();
const nextActive = getInitialAvailableItem(items, true);

onActiveDescendantChange(nextActive);

break;
}

const items = getListItems();
const items = getOptionElements();
let nextActive: HTMLElement | null = null;

if (activeDescendant) {
Expand All @@ -219,22 +228,24 @@ export const useComboboxBase = <T extends HTMLElement>(props: Props<T>) => {
}

case SystemKeys.UP: {
if (readOnly) return;

event.preventDefault();

if (!listOpenState) {
flushSync(() => {
onListOpenChange(true);
});

const items = getListItems();
const items = getOptionElements();
const nextActive = getInitialAvailableItem(items, true);

onActiveDescendantChange(nextActive);

break;
}

const items = getListItems();
const items = getOptionElements();
let nextActive: HTMLElement | null = null;

if (activeDescendant) {
Expand All @@ -257,7 +268,7 @@ export const useComboboxBase = <T extends HTMLElement>(props: Props<T>) => {

if (!listOpenState) break;

const items = getListItems();
const items = getOptionElements();
const nextActive = getAvailableItem(items, 0, true);

onActiveDescendantChange(nextActive);
Expand All @@ -270,7 +281,7 @@ export const useComboboxBase = <T extends HTMLElement>(props: Props<T>) => {

if (!listOpenState) break;

const items = getListItems();
const items = getOptionElements();
const nextActive = getAvailableItem(items, items.length - 1, false);

onActiveDescendantChange(nextActive);
Expand All @@ -280,6 +291,7 @@ export const useComboboxBase = <T extends HTMLElement>(props: Props<T>) => {

case SystemKeys.ESCAPE: {
event.preventDefault();

onEscapeKeyDown?.(event);

break;
Expand Down Expand Up @@ -353,15 +365,15 @@ export const useComboboxBase = <T extends HTMLElement>(props: Props<T>) => {

const query = target.value;

const items = getListItems();
const options = getOptionsInfo();

const entities = items
.filter(item => {
const text = item.textContent?.toLowerCase() ?? "";
const entities = options
.filter(option => {
const text = option.valueLabel.toLowerCase();

return text.includes(query.toLowerCase());
})
.map(item => item.getAttribute("data-entity") ?? "");
.map(option => option.value);

onFilteredEntities(entities);
onInputChange?.(event);
Expand Down
5 changes: 5 additions & 0 deletions lib/Select/context.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import * as React from "react";
import { type LabelInfo } from "../internals";
import type { PickAsMandatory } from "../types";
import type { RegisteredElementsKeys } from "./Select";
import type { OptionProps } from "./components";
import type { ElementsRegistry } from "./utils";

type ContextValue = {
Expand All @@ -19,6 +21,9 @@ type ContextValue = {
closeListAndMaintainFocus: () => void;
setActiveDescendant: React.Dispatch<React.SetStateAction<HTMLElement | null>>;
setFilteredEntities: React.Dispatch<React.SetStateAction<null | string[]>>;
getOptions: () => Array<
PickAsMandatory<OptionProps, "disabled" | "value" | "valueLabel">
>;
openList: () => void;
closeList: () => void;
toggleList: () => void;
Expand Down
106 changes: 68 additions & 38 deletions lib/Select/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { PickAsMandatory } from "../types";
import {
Controller,
EmptyStatement,
List,
Option,
Trigger,
type OptionProps,
Expand All @@ -25,44 +26,73 @@ export const noValueSelected = (value: string | string[] | undefined) =>

export const getOptions = (
childArray: Array<Exclude<React.ReactNode, boolean | null | undefined>>,
): Array<PickAsMandatory<OptionProps, "disabled" | "value" | "valueLabel">> => {
return childArray.reduce(
(result, child) => {
if (!React.isValidElement(child)) return result;

if (
child.type === EmptyStatement ||
child.type === Controller ||
child.type === Trigger
) {
return result;
}

if (child.type === Option) {
const { disabled, value, valueLabel } = (
child as React.ReactElement<OptionProps>
).props;

result.push({ disabled: disabled ?? false, value, valueLabel });

return result;
}

if (!("children" in child.props)) return result;

const options = getOptions(
React.Children.toArray(
(child as React.ReactElement<{ children: React.ReactNode }>).props
.children,
),
);

return [...result, ...options];
},
[] as Array<
PickAsMandatory<OptionProps, "disabled" | "value" | "valueLabel">
>,
);
) => {
let isListFound = false;

const recurse = (
childArray: Array<Exclude<React.ReactNode, boolean | null | undefined>>,
isInList = false,
): Array<
PickAsMandatory<OptionProps, "disabled" | "value" | "valueLabel">
> => {
return childArray.reduce(
(result, child) => {
if (!React.isValidElement(child)) return result;

if (child.type === List) {
isListFound = true;

const options = recurse(
React.Children.toArray(
(child as React.ReactElement<{ children: React.ReactNode }>).props
.children,
),
true,
);

return [...result, ...options];
}

if (child.type === Option) {
if (!isInList) return result;

const { disabled, value, valueLabel } = (
child as React.ReactElement<OptionProps>
).props;

result.push({ disabled: disabled ?? false, value, valueLabel });

return result;
}

if (
child.type === EmptyStatement ||
child.type === Controller ||
child.type === Trigger
) {
return result;
}

if (!("children" in child.props)) return result;
if (isListFound && !isInList) return result;

const options = recurse(
React.Children.toArray(
(child as React.ReactElement<{ children: React.ReactNode }>).props
.children,
),
isInList,
);

return [...result, ...options];
},
[] as Array<
PickAsMandatory<OptionProps, "disabled" | "value" | "valueLabel">
>,
);
};

return recurse(childArray);
};

type Registry<Key extends string> = Map<Key, string>;
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@styleless-ui/react",
"version": "1.0.0-rc.7",
"version": "1.0.0-rc.8",
"description": "Completely unstyled, headless and accessible React UI components.",
"author": "mimshins <[email protected]>",
"license": "MIT",
Expand Down

0 comments on commit f0f3c5a

Please sign in to comment.