Skip to content

Commit

Permalink
feat: use styling to hide options instead of DOM rendering
Browse files Browse the repository at this point in the history
  • Loading branch information
logaretm committed Jan 16, 2025
1 parent 364158a commit 3f516ee
Show file tree
Hide file tree
Showing 5 changed files with 127 additions and 161 deletions.
97 changes: 49 additions & 48 deletions packages/core/src/useComboBox/useComboBox.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { computed, ref, toValue } from 'vue';
import { computed, ref, toValue, watch } from 'vue';
import { InputEvents, Reactivify, StandardSchema } from '../types';
import { Orientation } from '../types';
import {
Expand All @@ -16,7 +16,7 @@ import { useLabel } from '../a11y/useLabel';
import { useListBox } from '../useListBox';
import { useErrorMessage } from '../a11y/useErrorMessage';
import { useInputValidity } from '../validation';
import { CollectionManager, FilterFn } from '../collections';
import { FilterFn } from '../collections';

export interface ComboBoxProps<TOption, TValue = TOption> {
/**
Expand Down Expand Up @@ -64,11 +64,6 @@ export interface ComboBoxProps<TOption, TValue = TOption> {
*/
readonly?: boolean;

/**
* Whether multiple options can be selected.
*/
multiple?: boolean;

/**
* The orientation of the listbox popup (vertical or horizontal).
*/
Expand All @@ -88,16 +83,21 @@ export interface ComboBoxProps<TOption, TValue = TOption> {
* Whether to allow custom values, false by default. When the user blurs the input the value is reset to the selected option or blank if no option is selected.
*/
allowCustomValue?: boolean;

/**
* Whether multiple options can be selected.
*/
onNewValue?(value: TValue): TOption | Promise<TOption>;
}

export interface ComboBoxCollectionOptions<TOption> {
export interface ComboBoxCollectionOptions {
filter: FilterFn;
collection?: CollectionManager<TOption>;
// collection?: CollectionManager<TOption>;
}

export function useComboBox<TOption, TValue = TOption>(
_props: Reactivify<ComboBoxProps<TOption, TValue>, 'schema'>,
collectionOptions?: Partial<ComboBoxCollectionOptions<TOption>>,
_props: Reactivify<ComboBoxProps<TOption, TValue>, 'schema' | 'onNewValue'>,
collectionOptions?: Partial<ComboBoxCollectionOptions>,
) {
const props = normalizeProps(_props, ['schema']);
const inputEl = ref<HTMLElement>();
Expand Down Expand Up @@ -134,16 +134,13 @@ export function useComboBox<TOption, TValue = TOption>(
isPopupOpen,
listBoxEl,
selectedOption,
selectedOptions,
focusNext,
focusPrev,
findFocusedOption,
items,
renderedOptions,
} = useListBox<TOption, TValue>({
labeledBy: () => labelledByProps.value['aria-labelledby'],
focusStrategy: 'FOCUS_ATTR_SELECTED',
collection: collectionOptions?.collection,
disabled: isDisabled,
label: props.label,
multiple: false,
Expand All @@ -165,7 +162,9 @@ export function useComboBox<TOption, TValue = TOption>(
onChange(evt) {
inputValue.value = (evt.target as HTMLInputElement).value;
},
onBlur(evt) {
async onBlur(evt) {
setTouched(true);
// If an option was clicked, then it would blur the field and so we want to select the clicked option via the `relatedTarget` property.
let relatedTarget = (evt as any).relatedTarget as HTMLElement | null;

Check warning on line 168 in packages/core/src/useComboBox/useComboBox.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
if (relatedTarget) {
relatedTarget = relatedTarget.closest('[role="option"]') as HTMLElement | null;
Expand All @@ -179,18 +178,7 @@ export function useComboBox<TOption, TValue = TOption>(
return;
}

setTouched(true);
if (toValue(props.allowCustomValue)) {
return;
}

if (!items.value) {
inputValue.value = selectedOption.value?.label ?? '';
return;
}

const item = items.value.find(i => isEqual(collectionOptions?.collection?.key(i.option), fieldValue.value));
inputValue.value = item?.registration?.getLabel() ?? '';
findClosestOptionAndSetValue(inputValue.value);
},
onKeydown(evt: KeyboardEvent) {
if (isDisabled.value) {
Expand Down Expand Up @@ -234,6 +222,8 @@ export function useComboBox<TOption, TValue = TOption>(

if (hasKeyCode(evt, 'Tab')) {
isPopupOpen.value = false;
findClosestOptionAndSetValue(inputValue.value);

return;
}

Expand All @@ -243,6 +233,31 @@ export function useComboBox<TOption, TValue = TOption>(
},
};

function findClosestOptionAndSetValue(search: string) {
if (!renderedOptions.value) {
inputValue.value = selectedOption.value?.label ?? '';
return;
}

// Try to find if the search matches an option's label.
let item = renderedOptions.value.find(i => i?.getLabel() === search);

// Try to find if the search matches an option's label after trimming it.
if (!item) {
item = renderedOptions.value.find(i => i?.getLabel() === search.trim());
}

// Find an option with a matching value to the last one selected.
if (!item) {
item = renderedOptions.value.find(i => isEqual(i?.getValue(), fieldValue.value));
}

if (item) {
inputValue.value = item?.getLabel() ?? '';
setValue(item?.getValue());
}
}

/**
* Handles the click event on the button element.
*/
Expand Down Expand Up @@ -298,18 +313,12 @@ export function useComboBox<TOption, TValue = TOption>(

const filter = collectionOptions?.filter;

const filteredItems = computed(() => {
if (!filter) {
return items.value?.map(({ option }) => option) ?? [];
}

return (
items.value?.filter(({ registration, option }) => {
return registration
? filter({ option: { item: option, label: registration.getLabel() }, search: inputValue.value })
: true;
}) ?? []
).map(({ option }) => option);
watch(inputValue, textValue => {
renderedOptions.value.forEach(opt => {
opt.setHidden(
filter ? !filter({ option: { item: opt.getValue(), label: opt.getLabel() }, search: textValue }) : false,
);
});
});

return exposeField(
Expand Down Expand Up @@ -358,20 +367,12 @@ export function useComboBox<TOption, TValue = TOption>(
* Props for the button element that toggles the popup.
*/
buttonProps,
/**
* The options in the collection.
*/
options: filteredItems,
/**
* The value of the text field, will contain the label of the selected option or the user input if they are currently typing.
*/
inputValue,
/**
* The selected options if multiple is true.
*/
selectedOptions,
/**
* The selected option if multiple is false.
* The selected option.
*/
selectedOption,
},
Expand Down
33 changes: 7 additions & 26 deletions packages/core/src/useListBox/useListBox.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { computed, InjectionKey, nextTick, onBeforeUnmount, provide, ref, Ref, toValue, watch } from 'vue';
import { AriaLabelableProps, Maybe, Orientation, Reactivify } from '../types';
import { hasKeyCode, isEqual, normalizeProps, removeFirst, useUniqId, withRefCapture } from '../utils/common';
import { hasKeyCode, normalizeProps, removeFirst, useUniqId, withRefCapture } from '../utils/common';
import { useKeyPressed } from '../helpers/useKeyPressed';
import { isMac } from '../utils/platform';
import { usePopoverController } from '../helpers/usePopoverController';
import { FieldTypePrefixes } from '../constants';
import { useBasicOptionFinder } from './basicOptionFinder';
import { CollectionManager } from '../collections';

export type FocusStrategy = 'FOCUS_DOM' | 'FOCUS_ATTR_SELECTED';

Expand All @@ -15,7 +14,6 @@ export interface ListBoxProps<TOption, TValue = TOption> {
isValueSelected(value: TValue): boolean;
handleToggleValue(value: TValue): void;

collection?: CollectionManager<TOption>;
focusStrategy?: FocusStrategy;
labeledBy?: string;
multiple?: boolean;
Expand Down Expand Up @@ -43,6 +41,7 @@ export interface OptionRegistration<TValue> {
focus(): void;
unfocus(): void;
toggleSelected(): void;
setHidden(value: boolean): void;
}

export interface OptionRegistrationWithId<TValue> extends OptionRegistration<TValue> {
Expand All @@ -66,16 +65,14 @@ export interface OptionElement<TValue = unknown> extends HTMLElement {
export const ListManagerKey: InjectionKey<ListManagerCtx<any>> = Symbol('ListManagerKey');

export function useListBox<TOption, TValue = TOption>(
_props: Reactivify<ListBoxProps<TOption, TValue>, 'isValueSelected' | 'handleToggleValue' | 'collection'>,
_props: Reactivify<ListBoxProps<TOption, TValue>, 'isValueSelected' | 'handleToggleValue'>,
elementRef?: Ref<Maybe<HTMLElement>>,
) {
const itemAssociations = ref<Map<TOption, OptionRegistrationWithId<TValue>>>(new Map());
const props = normalizeProps(_props, ['isValueSelected', 'handleToggleValue', 'collection']);
const props = normalizeProps(_props, ['isValueSelected', 'handleToggleValue']);
const listBoxId = useUniqId(FieldTypePrefixes.ListBox);
const listBoxEl = elementRef || ref<HTMLElement>();
const renderedOptions = ref<OptionRegistrationWithId<TValue>[]>([]);
const finder = useBasicOptionFinder(renderedOptions);
const collection = props.collection;

// Initialize popover controller, NO-OP if the element is not a popover-enabled element.
const { isOpen } = usePopoverController(listBoxEl, { disabled: props.disabled });
Expand All @@ -85,20 +82,10 @@ export function useListBox<TOption, TValue = TOption>(
() => !isOpen.value,
);

function associateOption(registration: OptionRegistration<TValue>) {
const item = collection?.items.value.find(item => isEqual(collection?.key(item), registration.getValue()));
if (!item) {
return;
}

itemAssociations.value.set(item, registration);
}

const listManager: ListManagerCtx<TValue> = {
useOptionRegistration(init: OptionRegistration<TValue>) {
const id = init.id;
renderedOptions.value.push(init);
associateOption(init);
onBeforeUnmount(() => {
removeFirst(renderedOptions.value, reg => reg.id === id);
});
Expand Down Expand Up @@ -196,7 +183,9 @@ export function useListBox<TOption, TValue = TOption>(
}

function getDomOptions() {
return Array.from(listBoxEl.value?.querySelectorAll('[role="option"]') || []) as OptionElement<TValue>[];
return Array.from(
listBoxEl.value?.querySelectorAll('[role="option"]:not([hidden])') || [],
) as OptionElement<TValue>[];
}

function findFocusedIdx() {
Expand Down Expand Up @@ -297,13 +286,6 @@ export function useListBox<TOption, TValue = TOption>(
return renderedOptions.value.filter(opt => opt.isSelected()).map(opt => mapOption(opt));
});

const items = computed(() => {
return collection?.items.value.map(item => ({
option: item,
registration: itemAssociations.value.get(item),
}));
});

return {
listBoxId,
listBoxProps,
Expand All @@ -313,7 +295,6 @@ export function useListBox<TOption, TValue = TOption>(
listBoxEl,
selectedOption,
selectedOptions,
items,
focusNext,
focusPrev,
findFocusedOption,
Expand Down
17 changes: 16 additions & 1 deletion packages/core/src/useOption/useOption.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Maybe, Reactivify, RovingTabIndex } from '../types';
import { computed, inject, nextTick, ref, Ref, shallowRef, toValue, watch } from 'vue';
import { computed, CSSProperties, inject, nextTick, ref, Ref, shallowRef, toValue, watch } from 'vue';
import { hasKeyCode, normalizeProps, useUniqId, warn, withRefCapture } from '../utils/common';
import { ListManagerKey, OptionElement } from '../useListBox';
import { FieldTypePrefixes } from '../constants';
Expand All @@ -16,6 +16,9 @@ interface OptionDomProps {
// Used when the listbox allows multiple selections
'aria-checked'?: boolean;
'aria-disabled'?: boolean;
hidden?: boolean;

style?: CSSProperties;
}

export interface OptionProps<TValue> {
Expand All @@ -40,6 +43,8 @@ export function useOption<TOption>(_props: Reactivify<OptionProps<TOption>>, ele
const optionEl = elementRef || ref<OptionElement>();
const isFocused = shallowRef(false);
const isDisabled = createDisabledContext(props.disabled);
// Used to hide the option when a filter is applied and doesn't match the item.
const isHidden = shallowRef(false);
const listManager = inject(ListManagerKey, null);

if (!listManager) {
Expand All @@ -63,6 +68,9 @@ export function useOption<TOption>(_props: Reactivify<OptionProps<TOption>>, ele
isFocused: () => isFocused.value,
getLabel: () => toValue(props.label) ?? '',
getValue,
setHidden: value => {
isHidden.value = value;
},
unfocus: () => {
// Doesn't actually unfocus the option, just sets the focus state to false.
isFocused.value = false;
Expand Down Expand Up @@ -128,6 +136,13 @@ export function useOption<TOption>(_props: Reactivify<OptionProps<TOption>>, ele
selectedAttr = 'aria-checked';
}

if (isHidden.value) {
domProps.hidden = true;
domProps.style = {
display: 'none',
};
}

domProps[selectedAttr] = isSelected.value;

return withRefCapture(domProps, optionEl, elementRef);
Expand Down
Loading

0 comments on commit 3f516ee

Please sign in to comment.