Skip to content

Commit

Permalink
feat: activedescendant works with vanilla JS
Browse files Browse the repository at this point in the history
  • Loading branch information
ling1726 committed Jan 12, 2024
1 parent 38b6a76 commit 680a423
Show file tree
Hide file tree
Showing 28 changed files with 358 additions and 290 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import * as React from 'react';
import { ActiveDescendantImperativeRef } from './types';

export type ActiveDescendantContextValue = {
controller: ActiveDescendantImperativeRef;
};

const noop = () => undefined;

const activeDescendantContextDefaultValue: ActiveDescendantContextValue = {
controller: {
active: noop,
blur: noop,
find: noop,
first: noop,
focus: noop,
last: noop,
next: noop,
prev: noop,
},
};

const ActiveDescendantContext = React.createContext<ActiveDescendantContextValue | undefined>(undefined);

export const ActiveDescendantContextProvider = ActiveDescendantContext.Provider;
export const useActiveDescendantContext = () =>
React.useContext(ActiveDescendantContext) ?? activeDescendantContextDefaultValue;
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './ActiveDescendantContext';
export * from './useActiveDescendant';
export * from './constants';
export * from './types';
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export function useActiveDescendant<TActiveParentElement extends HTMLElement, TL
});

const matchOption = useEventCallback(matchOptionUnstable);
const { listboxRef, optionWalker } = useOptionWalker<TListboxElement>({ matchOption });
const { listboxRef, optionWalker, listboxCallbackRef } = useOptionWalker<TListboxElement>({ matchOption });
const getActiveDescendant = React.useCallback(() => {
return listboxRef.current?.querySelector<HTMLElement>(`#${activeIdRef.current}`);
}, [listboxRef]);
Expand Down Expand Up @@ -144,5 +144,5 @@ export function useActiveDescendant<TActiveParentElement extends HTMLElement, TL

React.useImperativeHandle(imperativeRef, () => controller);

return { listboxRef, activeParentRef, controller };
return { listboxRef: listboxCallbackRef, activeParentRef, controller };
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,18 @@ export function useOptionWalker<TListboxElement extends HTMLElement>(options: Us
[matchOption],
);

const setListbox = React.useCallback(
(el: TListboxElement) => {
if (el && targetDocument) {
listboxRef.current = el;
treeWalkerRef.current = targetDocument.createTreeWalker(el, NodeFilter.SHOW_ELEMENT, optionFilter);
} else {
listboxRef.current = null;
}
},
[targetDocument, optionFilter],
);

useIsomorphicLayoutEffect(() => {
if (!targetDocument || !listboxRef.current) {
return;
Expand Down Expand Up @@ -88,6 +100,7 @@ export function useOptionWalker<TListboxElement extends HTMLElement>(options: Us

return {
optionWalker,
listboxCallbackRef: setListbox,
listboxRef,
};
}
13 changes: 11 additions & 2 deletions packages/react-components/react-aria/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,17 @@ export {
useARIAButtonShorthand,
useARIAButtonProps,
} from './button/index';
export { useActiveDescendant, ACTIVEDESCENDANT_FOCUSVISIBLE_ATTRIBUTE } from './activedescendant';
export type { ActiveDescendantImperativeRef, ActiveDescendantOptions } from './activedescendant';
export {
useActiveDescendant,
ACTIVEDESCENDANT_FOCUSVISIBLE_ATTRIBUTE,
ActiveDescendantContextProvider,
useActiveDescendantContext,
} from './activedescendant';
export type {
ActiveDescendantImperativeRef,
ActiveDescendantOptions,
ActiveDescendantContextValue,
} from './activedescendant';
export type {
ARIAButtonSlotProps,
ARIAButtonProps,
Expand Down
8 changes: 4 additions & 4 deletions packages/react-components/react-combobox/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@fluentui/react-combobox",
"version": "9.5.40",
"version": "9.5.39",
"description": "Fluent UI React Combobox component",
"main": "lib-commonjs/index.js",
"module": "lib/index.js",
Expand Down Expand Up @@ -34,22 +34,22 @@
},
"dependencies": {
"@fluentui/keyboard-keys": "^9.0.7",
"@fluentui/react-aria": "^9.7.0",
"@fluentui/react-context-selector": "^9.1.46",
"@fluentui/react-field": "^9.1.47",
"@fluentui/react-icons": "^2.0.224",
"@fluentui/react-jsx-runtime": "^9.0.24",
"@fluentui/react-portal": "^9.4.7",
"@fluentui/react-positioning": "^9.12.1",
"@fluentui/react-positioning": "^9.12.0",
"@fluentui/react-shared-contexts": "^9.13.2",
"@fluentui/react-tabster": "^9.17.0",
"@fluentui/react-theme": "^9.1.16",
"@fluentui/react-utilities": "^9.15.6",
"@griffel/react": "^1.5.14",
"@swc/helpers": "^0.5.1"
},
"peerDependencies": {
"@types/react": ">=16.14.0 <19.0.0",
"@types/react-dom": ">=16.9.0 <19.0.0",
"@types/react-dom": ">=16.14.0 <19.0.0",
"react": ">=16.14.0 <19.0.0",
"react-dom": ">=16.14.0 <19.0.0",
"scheduler": "^0.19.0 || ^0.20.0"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as React from 'react';
import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities';
import { ActiveDescendantImperativeRef } from '@fluentui/react-aria';
import type {
ComboboxBaseContextValues,
ComboboxBaseOpenChangeData,
Expand Down Expand Up @@ -48,6 +49,7 @@ export type ComboboxProps = Omit<ComponentProps<Partial<ComboboxSlots>, 'input'>
export type ComboboxState = ComponentState<ComboboxSlots> &
ComboboxBaseState & {
showClearIcon?: boolean;
activeDescendantController: ActiveDescendantImperativeRef;
};

/* Export types defined in ComboboxBase */
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/** @jsxRuntime automatic */
/** @jsxImportSource @fluentui/react-jsx-runtime */
import { Portal } from '@fluentui/react-portal';
import { ActiveDescendantContextProvider } from '@fluentui/react-aria';

import { assertSlots } from '@fluentui/react-utilities';
import { ComboboxContext } from '../../contexts/ComboboxContext';
Expand All @@ -14,19 +15,21 @@ export const renderCombobox_unstable = (state: ComboboxState, contextValues: Com

return (
<state.root>
<ComboboxContext.Provider value={contextValues.combobox}>
<state.input />
{state.clearIcon && <state.clearIcon />}
<state.expandIcon />
{state.listbox &&
(state.inlinePopup ? (
<state.listbox />
) : (
<Portal mountNode={state.mountNode}>
<ActiveDescendantContextProvider value={contextValues.activeDescendant}>
<ComboboxContext.Provider value={contextValues.combobox}>
<state.input />
{state.clearIcon && <state.clearIcon />}
<state.expandIcon />
{state.listbox &&
(state.inlinePopup ? (
<state.listbox />
</Portal>
))}
</ComboboxContext.Provider>
) : (
<Portal mountNode={state.mountNode}>
<state.listbox />
</Portal>
))}
</ComboboxContext.Provider>
</ActiveDescendantContextProvider>
</state.root>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
useMergedRefs,
slot,
} from '@fluentui/react-utilities';
import { useActiveDescendant } from '@fluentui/react-aria';
import { useComboboxBaseState } from '../../utils/useComboboxBaseState';
import { useComboboxPositioning } from '../../utils/useComboboxPositioning';
import { Listbox } from '../Listbox/Listbox';
Expand All @@ -17,6 +18,7 @@ import type { OptionValue } from '../../utils/OptionCollection.types';
import type { ComboboxProps, ComboboxState } from './Combobox.types';
import { useListboxSlot } from '../../utils/useListboxSlot';
import { useInputTriggerSlot } from './useInputTriggerSlot';
import { optionClassNames } from '../Option/useOptionStyles.styles';

/**
* Create the state required to render Combobox.
Expand All @@ -31,7 +33,14 @@ export const useCombobox_unstable = (props: ComboboxProps, ref: React.Ref<HTMLIn
// Merge props from surrounding <Field>, if any
props = useFieldControlProps_unstable(props, { supportsLabelFor: true, supportsRequired: true, supportsSize: true });

const baseState = useComboboxBaseState({ ...props, editable: true });
const {
listboxRef: activeDescendantListboxRef,
activeParentRef,
controller: activeDescendantController,
} = useActiveDescendant<HTMLInputElement, HTMLDivElement>({
matchOption: el => el.classList.contains(optionClassNames.root),
});
const baseState = useComboboxBaseState({ ...props, editable: true, activeDescendantController });
const {
clearable,
clearSelection,
Expand Down Expand Up @@ -74,22 +83,23 @@ export const useCombobox_unstable = (props: ComboboxProps, ref: React.Ref<HTMLIn

const triggerRef = React.useRef<HTMLInputElement>(null);

const listbox = useListboxSlot(props.listbox, comboboxPopupRef, {
const listbox = useListboxSlot(props.listbox, useMergedRefs(comboboxPopupRef, activeDescendantListboxRef), {
state: baseState,
triggerRef,
defaultProps: {
children: props.children,
},
});

const triggerSlot = useInputTriggerSlot(props.input ?? {}, useMergedRefs(triggerRef, ref), {
const triggerSlot = useInputTriggerSlot(props.input ?? {}, useMergedRefs(triggerRef, activeParentRef, ref), {
state: baseState,
freeform,
defaultProps: {
type: 'text',
value: value ?? '',
...triggerNativeProps,
},
activeDescendantController,
});

const rootSlot = slot.always(props.root, {
Expand Down Expand Up @@ -125,6 +135,7 @@ export const useCombobox_unstable = (props: ComboboxProps, ref: React.Ref<HTMLIn
elementType: 'span',
}),
showClearIcon,
activeDescendantController,
...baseState,
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as React from 'react';
import { mergeCallbacks, useEventCallback } from '@fluentui/react-utilities';
import { ActiveDescendantImperativeRef } from '@fluentui/react-aria';
import type { ExtractSlotProps, Slot, SlotComponentType } from '@fluentui/react-utilities';
import { ArrowLeft, ArrowRight } from '@fluentui/keyboard-keys';
import { useTriggerSlot, UseTriggerSlotState } from '../../utils/useTriggerSlot';
Expand All @@ -8,12 +9,13 @@ import { OptionValue } from '../../utils/OptionCollection.types';
import { getDropdownActionFromKey } from '../../utils/dropdownKeyActions';

type UsedComboboxState = UseTriggerSlotState &
Pick<ComboboxState, 'value' | 'setValue' | 'selectedOptions' | 'clearSelection' | 'getOptionsMatchingText'>;
Pick<ComboboxState, 'value' | 'setValue' | 'selectedOptions' | 'clearSelection' | 'getOptionById'>;

type UseInputTriggerSlotOptions = {
state: UsedComboboxState;
freeform: boolean | undefined;
defaultProps: Partial<ComboboxProps>;
activeDescendantController: ActiveDescendantImperativeRef;
};

/*
Expand All @@ -30,25 +32,24 @@ export function useInputTriggerSlot(
state: {
open,
value,
activeOption,
selectOption,
setValue,
setActiveOption,
setFocusVisible,
multiselect,
selectedOptions,
clearSelection,
getOptionsMatchingText,
getIndexOfId,
getOptionById,
setOpen,
},
freeform,
defaultProps,
activeDescendantController,
} = options;

const onBlur = (ev: React.FocusEvent<HTMLInputElement>) => {
// handle selection and updating value if freeform is false
if (!open && !freeform) {
const activeOptionId = activeDescendantController.active();
const activeOption = activeOptionId ? getOptionById(activeOptionId) : null;
// select matching option, if the value fully matches
if (value && activeOption && value.trim().toLowerCase() === activeOption?.text.toLowerCase()) {
selectOption(ev, activeOption);
Expand All @@ -63,20 +64,22 @@ export function useInputTriggerSlot(
const searchString = inputValue?.trim().toLowerCase();

if (!searchString || searchString.length === 0) {
activeDescendantController.blur();
return;
}

const matcher = (optionText: string) => optionText.toLowerCase().indexOf(searchString) === 0;
const matches = getOptionsMatchingText(matcher);

// return first matching option after the current active option, looping back to the top
if (matches.length > 1 && activeOption) {
const startIndex = getIndexOfId(activeOption.id);
const nextMatch = matches.find(option => getIndexOfId(option.id) >= startIndex);
return nextMatch ?? matches[0];
const match = activeDescendantController.find(id => {
const option = getOptionById(id);
return !!option && matcher(option.text);
});

if (!match) {
activeDescendantController.blur();
return undefined;
}

return matches[0] ?? undefined;
return getOptionById(match);
};

// update value and active option based on input
Expand All @@ -87,9 +90,6 @@ export function useInputTriggerSlot(

// handle updating active option based on input
const matchingOption = getOptionFromInput(inputValue);
setActiveOption(matchingOption);

setFocusVisible(true);

// clear selection for single-select if the input value no longer matches the selection
if (!multiselect && selectedOptions.length === 1 && (inputValue.length < 1 || !matchingOption)) {
Expand All @@ -101,6 +101,7 @@ export function useInputTriggerSlot(
state: options.state,
defaultProps,
elementType: 'input',
activeDescendantController,
});

trigger.onChange = mergeCallbacks(trigger.onChange, onChange);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities';
import { ActiveDescendantImperativeRef } from '@fluentui/react-aria';
import type {
ComboboxBaseContextValues,
ComboboxBaseOpenChangeData,
Expand Down Expand Up @@ -39,6 +40,8 @@ export type DropdownState = ComponentState<DropdownSlots> &
placeholderVisible: boolean;

showClearButton?: boolean;

activeDescendantController: ActiveDescendantImperativeRef;
};

/* Export types defined in ComboboxBase */
Expand Down
Loading

0 comments on commit 680a423

Please sign in to comment.