From 58feb1e3115f6617e9670520ae3ce093d43a0a85 Mon Sep 17 00:00:00 2001 From: Fabien MARIE-LOUISE Date: Fri, 25 Aug 2023 11:18:59 +0200 Subject: [PATCH] fix: #250 --- packages/core/dev/App.tsx | 96 ++++++--- packages/core/dev/index.css | 125 ----------- packages/core/src/combobox/combobox-base.tsx | 198 +++++++++++------- .../core/src/combobox/combobox-content.tsx | 2 +- .../core/src/combobox/combobox-context.tsx | 2 +- packages/core/src/combobox/combobox-input.tsx | 6 +- packages/core/src/combobox/combobox-root.tsx | 6 +- packages/core/src/select/select-base.tsx | 4 +- 8 files changed, 199 insertions(+), 240 deletions(-) diff --git a/packages/core/dev/App.tsx b/packages/core/dev/App.tsx index 03b18aa4..db846567 100644 --- a/packages/core/dev/App.tsx +++ b/packages/core/dev/App.tsx @@ -1,37 +1,83 @@ -import { createSignal } from "solid-js"; +import { createSignal, For } from "solid-js"; -import { Select } from "../src"; +import { Combobox } from "../src"; + +interface Fruit { + value: string; + label: string; + disabled: boolean; +} + +const ALL_OPTIONS: Fruit[] = [ + { value: "apple", label: "Apple", disabled: false }, + { value: "banana", label: "Banana", disabled: false }, + { value: "blueberry", label: "Blueberry", disabled: false }, + { value: "grapes", label: "Grapes", disabled: true }, + { value: "pineapple", label: "Pineapple", disabled: false }, +]; export default function App() { - const [value, setValue] = createSignal("Blueberry"); + const [value, setValue] = createSignal(ALL_OPTIONS[0]); return ( - <> - +

{value()?.label}

+ ( - - {props.item.rawValue} - X - + + {props.item.rawValue?.label} + X + )} > - - class="select__value"> - {state => state.selectedOption()} - - V - - - - - - -
-

Your favorite fruit is: {value()}.

- + + + + V + + + + + + + + + + {option => { + return ( + + ); + }} + + + ); } diff --git a/packages/core/dev/index.css b/packages/core/dev/index.css index 2d12406e..e69de29b 100644 --- a/packages/core/dev/index.css +++ b/packages/core/dev/index.css @@ -1,125 +0,0 @@ -.select__trigger { - display: inline-flex; - align-items: center; - justify-content: space-between; - width: 200px; - border-radius: 6px; - padding: 0 10px 0 16px; - font-size: 16px; - line-height: 1; - height: 40px; - outline: none; - background-color: white; - border: 1px solid hsl(240 6% 90%); - color: hsl(240 4% 16%); - transition: border-color 250ms, color 250ms; -} -.select__trigger:hover { - border-color: hsl(240 5% 65%); -} -.select__trigger:focus-visible { - outline: 2px solid hsl(200 98% 39%); - outline-offset: 2px; -} -.select__trigger[data-invalid] { - border-color: hsl(0 72% 51%); - color: hsl(0 72% 51%); -} -.select__value { - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; -} -.select__value[data-placeholder-shown] { - color: hsl(240 4% 46%); -} -.select__icon { - height: 20px; - width: 20px; - flex: 0 0 20px; -} -.select__description { - margin-top: 8px; - color: hsl(240 5% 26%); - font-size: 12px; - user-select: none; -} -.select__error-message { - margin-top: 8px; - color: hsl(0 72% 51%); - font-size: 12px; - user-select: none; -} -.select__content { - background-color: white; - border-radius: 6px; - border: 1px solid hsl(240 6% 90%); - box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); - transform-origin: var(--kb-select-content-transform-origin); - animation: contentHide 250ms ease-in forwards; -} -.select__content[data-expanded] { - animation: contentShow 250ms ease-out; -} -.select__listbox { - overflow-y: auto; - max-height: 360px; - padding: 8px; -} -.select__item { - font-size: 16px; - line-height: 1; - color: hsl(240 4% 16%); - border-radius: 4px; - display: flex; - align-items: center; - justify-content: space-between; - height: 32px; - padding: 0 8px; - position: relative; - user-select: none; - outline: none; -} -.select__item[data-disabled] { - color: hsl(240 5% 65%); - opacity: 0.5; - pointer-events: none; -} -.select__item[data-highlighted] { - outline: none; - background-color: hsl(200 98% 39%); - color: white; -} -.select__section { - padding: 8px 0 0 8px; - font-size: 14px; - line-height: 32px; - color: hsl(240 4% 46%); -} -.select__item-indicator { - height: 20px; - width: 20px; - display: inline-flex; - align-items: center; - justify-content: center; -} -@keyframes contentShow { - from { - opacity: 0; - transform: translateY(-8px); - } - to { - opacity: 1; - transform: translateY(0); - } -} -@keyframes contentHide { - from { - opacity: 1; - transform: translateY(0); - } - to { - opacity: 0; - transform: translateY(-8px); - } -} diff --git a/packages/core/src/combobox/combobox-base.tsx b/packages/core/src/combobox/combobox-base.tsx index 976cdc8c..009c7edd 100644 --- a/packages/core/src/combobox/combobox-base.tsx +++ b/packages/core/src/combobox/combobox-base.tsx @@ -15,7 +15,6 @@ import { isFunction, mergeDefaultProps, OverrideComponentProps, - ReactiveMap, ValidationState, } from "@kobalte/utils"; import { @@ -31,7 +30,7 @@ import { } from "solid-js"; import { createFormControl, FORM_CONTROL_PROP_NAMES, FormControlContext } from "../form-control"; -import { createMessageFormatter } from "../i18n"; +import { createFilter, createMessageFormatter } from "../i18n"; import { createListState, ListKeyboardDelegate } from "../list"; import { announce } from "../live-announcer"; import { AsChildProp, Polymorphic } from "../polymorphic"; @@ -57,6 +56,8 @@ import { COMBOBOX_INTL_MESSAGES } from "./combobox.intl"; import { ComboboxContext, ComboboxContextValue, ComboboxDataSet } from "./combobox-context"; import { ComboboxTriggerMode } from "./types"; +type FilterFn = (textValue: string, inputValue: string) => boolean; + export interface ComboboxBaseItemComponentProps { /** The item to render. */ item: CollectionNode; @@ -113,26 +114,29 @@ export interface ComboboxBaseOptions * Property name or getter function to use as the value of an option. * This is the value that will be submitted when the combobox is part of a `
`. */ - optionValue?: keyof Option | ((option: Option) => string | number); + optionValue?: keyof Exclude | ((option: Exclude) => string | number); /** Property name or getter function to use as the text value of an option for typeahead purpose. */ - optionTextValue?: keyof Option | ((option: Option) => string); + optionTextValue?: keyof Exclude | ((option: Exclude) => string); /** * Property name or getter function to use as the label of an option. * This is the string representation of the option to display in the `Combobox.Input`. */ - optionLabel?: keyof Option | ((option: Option) => string); + optionLabel?: keyof Exclude | ((option: Exclude) => string); /** Property name or getter function to use as the disabled flag of an option. */ - optionDisabled?: keyof Option | ((option: Option) => boolean); + optionDisabled?: keyof Exclude | ((option: Exclude) => boolean); - /** Property name or getter function that refers to the children options of an option group. */ - optionGroupChildren?: keyof OptGroup | ((optGroup: OptGroup) => Option[]); + /** Property name that refers to the children options of an option group. */ + optionGroupChildren?: keyof Exclude; /** An optional keyboard delegate to override the default. */ keyboardDelegate?: KeyboardDelegate; + /** The filter function used to determine if an option should be included in the combo box list. */ + defaultFilter?: "startsWith" | "endsWith" | "contains" | FilterFn; + /** Whether focus should wrap around when the end/start is reached. */ shouldFocusWrap?: boolean; @@ -224,6 +228,8 @@ export interface ComboboxBaseProps export function ComboboxBase(props: ComboboxBaseProps) { const defaultId = `combobox-${createUniqueId()}`; + const filter = createFilter({ sensitivity: "base" }); + props = mergeDefaultProps( { id: defaultId, @@ -236,6 +242,7 @@ export function ComboboxBase(props: ComboboxBaseProps< sameWidth: true, modal: false, preventScroll: false, + defaultFilter: "contains", triggerMode: "input", }, props, @@ -264,6 +271,7 @@ export function ComboboxBase(props: ComboboxBaseProps< "keyboardDelegate", "allowDuplicateSelectionEvents", "disallowEmptySelection", + "defaultFilter", "shouldFocusWrap", "allowsEmptyCollection", "removeOnBackspace", @@ -304,6 +312,8 @@ export function ComboboxBase(props: ComboboxBaseProps< const [isInputFocused, setIsInputFocusedState] = createSignal(false); + const [showAllOptions, setShowAllOptions] = createSignal(false); + const [lastDisplayedOptions, setLastDisplayedOptions] = createSignal(local.options); const messageFormatter = createMessageFormatter(() => COMBOBOX_INTL_MESSAGES); @@ -314,8 +324,27 @@ export function ComboboxBase(props: ComboboxBaseProps< onOpenChange: isOpen => local.onOpenChange?.(isOpen, openTriggerMode), }); - // Track what action is attempting to open the combobox. - let openTriggerMode: ComboboxTriggerMode | undefined = "focus"; + const [inputValue, setInputValue] = createControllableSignal({ + defaultValue: () => "", + onChange: value => { + local.onInputChange?.(value); + + // Remove selection when input is cleared and value is uncontrolled (in single selection mode). + // If controlled, this is the application developer's responsibility. + if ( + value === "" && + local.selectionMode === "single" && + !listState.selectionManager().isEmpty() && + local.value === undefined + ) { + // Bypass `disallowEmptySelection`. + listState.selectionManager().setSelectedKeys([]); + } + + // Clear focused key when input value changes. + listState.selectionManager().setFocusedKey(undefined); + }, + }); const getOptionValue = (option: Option) => { const optionValue = local.optionValue; @@ -326,7 +355,9 @@ export function ComboboxBase(props: ComboboxBaseProps< } // Get the value from the option object as a string. - return String(isFunction(optionValue) ? optionValue(option) : option[optionValue]); + return String( + isFunction(optionValue) ? optionValue(option as any) : (option as any)[optionValue], + ); }; const getOptionLabel = (option: Option) => { @@ -338,15 +369,13 @@ export function ComboboxBase(props: ComboboxBaseProps< } // Get the label from the option object as a string. - return String(isFunction(optionLabel) ? optionLabel(option) : option[optionLabel]); + return String( + isFunction(optionLabel) ? optionLabel(option as any) : (option as any)[optionLabel], + ); }; - const displayedOptions = createMemo(() => { - return disclosureState.isOpen() ? local.options : lastDisplayedOptions(); - }); - - // Only options without option groups. - const flattenOptions = createMemo(() => { + // All options flattened without option groups. + const allOptions = createMemo(() => { const optionGroupChildren = local.optionGroupChildren; // The combobox doesn't contains option groups. @@ -354,63 +383,69 @@ export function ComboboxBase(props: ComboboxBaseProps< return local.options as Option[]; } - if (isFunction(optionGroupChildren)) { - return local.options.flatMap( - item => optionGroupChildren(item as OptGroup) ?? (item as Option), - ); - } - return local.options.flatMap( - item => ((item as OptGroup)[optionGroupChildren] as Option[]) ?? (item as Option), + item => ((item as any)[optionGroupChildren] as Option[]) ?? (item as Option), ); }); - const getOptionsFromValues = (values: Set): Option[] => { - return [...values] - .map(value => flattenOptions().find(option => getOptionValue(option) === value)) - .filter(option => option != null) as Option[]; - }; + const filterFn = (option: Option) => { + const textVal = getOptionLabel(option); + const inputVal = inputValue() ?? ""; - const [inputValue, setInputValue] = createControllableSignal({ - defaultValue: () => "", - onChange: value => { - local.onInputChange?.(value); + if (isFunction(local.defaultFilter)) { + return local.defaultFilter?.(textVal, inputVal); + } - // Remove selection when input is cleared and value is uncontrolled (in single selection mode). - // If controlled, this is the application developer's responsibility. - if ( - value === "" && - local.selectionMode === "single" && - !listState.selectionManager().isEmpty() && - local.value === undefined - ) { - // Bypass `disallowEmptySelection`. - listState.selectionManager().setSelectedKeys([]); - } + switch (local.defaultFilter) { + case "startsWith": + return filter.startsWith(textVal, inputVal); + case "endsWith": + return filter.endsWith(textVal, inputVal); + case "contains": + return filter.contains(textVal, inputVal); + } + }; - // Clear focused key when input value changes. - listState.selectionManager().setFocusedKey(undefined); - }, - }); + // Filtered options with same structure as `local.options` + const filteredOptions = createMemo(() => { + const optionGroupChildren = local.optionGroupChildren; - const selectedOptionsMap = new ReactiveMap(); + // The combobox doesn't contains option groups. + if (optionGroupChildren == null) { + return (local.options as Option[]).filter(filterFn); + } - const selectedOptions = createMemo(() => { - return [...selectedOptionsMap.values()]; + return local.options.filter(optGroup => { + const filteredChildrenOptions = ((optGroup as any)[optionGroupChildren] as Option[]).filter( + filterFn, + ); + + return { + ...optGroup, + [optionGroupChildren]: filteredChildrenOptions, + }; + }); }); - const syncSelectedOptionsMapWithSelectedKeys = (selectedKeys: Set) => { - // Remove keys that are not selected anymore. - selectedOptionsMap.forEach((_, key) => { - if (!selectedKeys.has(key)) { - selectedOptionsMap.delete(key); + const displayedOptions = createMemo(() => { + if (disclosureState.isOpen()) { + if (showAllOptions()) { + return local.options; + } else { + return filteredOptions(); } - }); + } else { + return lastDisplayedOptions(); + } + }); - getOptionsFromValues(selectedKeys).forEach(option => { - // Use a clone of the option object in case it get removed from the filtered options. - selectedOptionsMap.set(getOptionValue(option), structuredClone(option)); - }); + // Track what action is attempting to open the combobox. + let openTriggerMode: ComboboxTriggerMode | undefined = "focus"; + + const getOptionsFromValues = (values: Set): Option[] => { + return [...values] + .map(value => allOptions().find(option => getOptionValue(option) === value)) + .filter(option => option != null) as Option[]; }; const listState = createListState({ @@ -428,15 +463,13 @@ export function ComboboxBase(props: ComboboxBaseProps< return local.defaultValue; }, - onSelectionChange: keys => { - syncSelectedOptionsMapWithSelectedKeys(keys); - - local.onChange?.(selectedOptions()); + onSelectionChange: selectedKeys => { + local.onChange?.(getOptionsFromValues(selectedKeys)); if (local.selectionMode === "single") { // Only close if an option is selected. // Prevents the combobox to close and reopen when the input is cleared. - if (disclosureState.isOpen() && keys.size > 0) { + if (disclosureState.isOpen() && selectedKeys.size > 0) { close(); } } @@ -461,6 +494,10 @@ export function ComboboxBase(props: ComboboxBaseProps< getSectionChildren: () => local.optionGroupChildren as any, }); + const selectedOptions = createMemo(() => { + return getOptionsFromValues(listState.selectionManager().selectedKeys()); + }); + const removeOptionFromSelection = (option: Option) => { listState.selectionManager().toggleSelection(getOptionValue(option)); }; @@ -473,6 +510,9 @@ export function ComboboxBase(props: ComboboxBaseProps< return; } + // Show all option if menu is manually opened. + setShowAllOptions(triggerMode === "manual"); + openTriggerMode = triggerMode; setFocusStrategy(focusStrategy); disclosureState.open(); @@ -495,7 +535,7 @@ export function ComboboxBase(props: ComboboxBaseProps< // If combobox is going to close, so we can freeze the displayed options // when the user clicks outside the popover to close the combobox. // Prevents the popover contents from updating as the combobox closes. - setLastDisplayedOptions(local.options); + setLastDisplayedOptions(displayedOptions()); disclosureState.close(); @@ -565,9 +605,12 @@ export function ComboboxBase(props: ComboboxBaseProps< return undefined; }); - const resetInputValue = () => { + const resetInputValue = (selectedKeys: Set) => { if (local.selectionMode === "single") { - const selectedOption = selectedOptions().at(0); + const selectedKey = [...selectedKeys][0]; + + const selectedOption = allOptions().find(option => getOptionValue(option) === selectedKey); + setInputValue(selectedOption ? getOptionLabel(selectedOption) : ""); } else { setInputValue(""); @@ -582,17 +625,16 @@ export function ComboboxBase(props: ComboboxBaseProps< return local.sectionComponent?.({ section }); }; - // Keep selected options (Objects) and combobox input in sync with listState selected keys. + // Display filtered collection again when input value changes. createEffect( - on( - () => listState.selectionManager().selectedKeys(), - selectedKeys => { - syncSelectedOptionsMapWithSelectedKeys(selectedKeys); - resetInputValue(); - }, - ), + on(inputValue, () => { + setShowAllOptions(false); + }), ); + // Reset input value when selection change + createEffect(on(() => listState.selectionManager().selectedKeys(), resetInputValue)); + // VoiceOver has issues with announcing aria-activedescendant properly on change. // We use a live region announcer to announce focus changes manually. let lastAnnouncedFocusedKey = ""; diff --git a/packages/core/src/combobox/combobox-content.tsx b/packages/core/src/combobox/combobox-content.tsx index a1604e97..0a63693b 100644 --- a/packages/core/src/combobox/combobox-content.tsx +++ b/packages/core/src/combobox/combobox-content.tsx @@ -64,7 +64,7 @@ export function ComboboxContent(props: ComboboxContentProps) { const close = () => { context.close(); - context.resetInputValue(); + context.resetInputValue(context.listState().selectionManager().selectedKeys()); }; const onFocusOutside = (e: FocusOutsideEvent) => { diff --git a/packages/core/src/combobox/combobox-context.tsx b/packages/core/src/combobox/combobox-context.tsx index b9a5ade5..92fb43a5 100644 --- a/packages/core/src/combobox/combobox-context.tsx +++ b/packages/core/src/combobox/combobox-context.tsx @@ -37,7 +37,7 @@ export interface ComboboxContextValue { listboxAriaLabel: Accessor; listState: Accessor; keyboardDelegate: Accessor; - resetInputValue: () => void; + resetInputValue: (selectedKeys: Set) => void; setIsInputFocused: (isFocused: boolean) => void; setInputValue: (value: string) => void; setControlRef: (el: HTMLDivElement) => void; diff --git a/packages/core/src/combobox/combobox-input.tsx b/packages/core/src/combobox/combobox-input.tsx index 3b26a8de..9c004247 100644 --- a/packages/core/src/combobox/combobox-input.tsx +++ b/packages/core/src/combobox/combobox-input.tsx @@ -111,13 +111,13 @@ export function ComboboxInput(props: ComboboxInputProps) { case "Tab": if (context.isOpen()) { context.close(); - context.resetInputValue(); + context.resetInputValue(context.listState().selectionManager().selectedKeys()); } break; case "Escape": if (context.isOpen()) { context.close(); - context.resetInputValue(); + context.resetInputValue(context.listState().selectionManager().selectedKeys()); } else { // trigger a remove selection. context.setInputValue(""); @@ -134,7 +134,7 @@ export function ComboboxInput(props: ComboboxInputProps) { } else { if (e.altKey) { context.close(); - context.resetInputValue(); + context.resetInputValue(context.listState().selectionManager().selectedKeys()); } } break; diff --git a/packages/core/src/combobox/combobox-root.tsx b/packages/core/src/combobox/combobox-root.tsx index 82454a43..4e4530d1 100644 --- a/packages/core/src/combobox/combobox-root.tsx +++ b/packages/core/src/combobox/combobox-root.tsx @@ -76,16 +76,12 @@ export function ComboboxRoot(props: ComboboxRootProps< }); const onChange = (value: Option[]) => { - local.onChange?.(local.multiple ? value : (value[0] as any)); - - /* - if (local.multiple) { + if (local.multiple) { local.onChange?.(value as any); } else { // use `null` as "no value" because `undefined` mean the component is "uncontrolled". local.onChange?.((value[0] ?? null) as any); } - */ }; return ( diff --git a/packages/core/src/select/select-base.tsx b/packages/core/src/select/select-base.tsx index 0b872f18..56176e35 100644 --- a/packages/core/src/select/select-base.tsx +++ b/packages/core/src/select/select-base.tsx @@ -331,8 +331,8 @@ export function SelectBase(props: SelectBaseProps { - local.onChange?.(getOptionsFromValues(keys)); + onSelectionChange: selectedKeys => { + local.onChange?.(getOptionsFromValues(selectedKeys)); if (local.selectionMode === "single") { close();