Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions packages/@react-aria/focus/stories/FocusScope.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -278,3 +278,34 @@ export const FocusableInputForm: FocusScopeStoryObj = {
}
}
};

export let ContainsHiddenElement: FocusScopeStoryObj = {
render: (args) => (
<FocusScope {...args}>
<input />
<input style={{visibility: 'hidden'}} />
<input />
</FocusScope>
),
args: {
contain: true
},
argTypes: {
contain: {
control: 'boolean'
},
restoreFocus: {
control: 'boolean'
},
autoFocus: {
control: 'boolean'
}
},
parameters: {
description: {
data: `
Should be able to tab navigate from the first input to the last input.
`
}
}
};
84 changes: 53 additions & 31 deletions packages/@react-aria/select/src/HiddenSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@
* governing permissions and limitations under the License.
*/

import {FocusableElement, RefObject} from '@react-types/shared';
import {FocusableElement, Key, RefObject} from '@react-types/shared';
import React, {InputHTMLAttributes, JSX, ReactNode, useCallback, useRef} from 'react';
import {selectData} from './useSelect';
import {SelectionMode} from '@react-types/select';
import {SelectState} from '@react-stately/select';
import {useFormReset} from '@react-aria/utils';
import {useFormValidation} from '@react-aria/form';
Expand Down Expand Up @@ -41,9 +42,9 @@ export interface AriaHiddenSelectProps {
isDisabled?: boolean
}

export interface HiddenSelectProps<T> extends AriaHiddenSelectProps {
export interface HiddenSelectProps<T, M extends SelectionMode = 'single'> extends AriaHiddenSelectProps {
/** State for the select. */
state: SelectState<T>,
state: SelectState<T, M>,

/** A ref to the trigger element. */
triggerRef: RefObject<FocusableElement | null>
Expand All @@ -70,7 +71,7 @@ export interface HiddenSelectAria {
* can be used in combination with `useSelect` to support browser form autofill, mobile form
* navigation, and native HTML form submission.
*/
export function useHiddenSelect<T>(props: AriaHiddenSelectOptions, state: SelectState<T>, triggerRef: RefObject<FocusableElement | null>): HiddenSelectAria {
export function useHiddenSelect<T, M extends SelectionMode = 'single'>(props: AriaHiddenSelectOptions, state: SelectState<T, M>, triggerRef: RefObject<FocusableElement | null>): HiddenSelectAria {
let data = selectData.get(state) || {};
let {autoComplete, name = data.name, form = data.form, isDisabled = data.isDisabled} = props;
let {validationBehavior, isRequired} = data;
Expand All @@ -83,14 +84,23 @@ export function useHiddenSelect<T>(props: AriaHiddenSelectOptions, state: Select
}
});

useFormReset(props.selectRef, state.defaultSelectedKey, state.setSelectedKey);
useFormReset(props.selectRef, state.defaultValue, state.setValue);
useFormValidation({
validationBehavior,
focus: () => triggerRef.current?.focus()
}, state, props.selectRef);

// eslint-disable-next-line react-hooks/exhaustive-deps
let onChange = useCallback((e: React.ChangeEvent<HTMLSelectElement> | React.FormEvent<HTMLSelectElement>) => state.setSelectedKey(e.currentTarget.value), [state.setSelectedKey]);
let setValue = state.setValue;
let onChange = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
if (e.target.multiple) {
setValue(Array.from(
e.target.selectedOptions,
(option) => option.value
) as any);
} else {
setValue(e.currentTarget.value as any);
}
}, [setValue]);

// In Safari, the <select> cannot have `display: none` or `hidden` for autofill to work.
// In Firefox, there must be a <label> to identify the <select> whereas other browsers
Expand All @@ -114,10 +124,11 @@ export function useHiddenSelect<T>(props: AriaHiddenSelectOptions, state: Select
tabIndex: -1,
autoComplete,
disabled: isDisabled,
multiple: state.selectionManager.selectionMode === 'multiple',
required: validationBehavior === 'native' && isRequired,
name,
form,
value: state.selectedKey ?? '',
value: (state.value as string | string[]) ?? '',
onChange,
onInput: onChange
}
Expand All @@ -128,7 +139,7 @@ export function useHiddenSelect<T>(props: AriaHiddenSelectOptions, state: Select
* Renders a hidden native `<select>` element, which can be used to support browser
* form autofill, mobile form navigation, and native form submission.
*/
export function HiddenSelect<T>(props: HiddenSelectProps<T>): JSX.Element | null {
export function HiddenSelect<T, M extends SelectionMode = 'single'>(props: HiddenSelectProps<T, M>): JSX.Element | null {
let {state, triggerRef, label, name, form, isDisabled} = props;
let selectRef = useRef(null);
let inputRef = useRef(null);
Expand Down Expand Up @@ -164,32 +175,43 @@ export function HiddenSelect<T>(props: HiddenSelectProps<T>): JSX.Element | null
let data = selectData.get(state) || {};
let {validationBehavior} = data;

let inputProps: InputHTMLAttributes<HTMLInputElement> = {
type: 'hidden',
autoComplete: selectProps.autoComplete,
name,
form,
disabled: isDisabled,
value: state.selectedKey ?? ''
};
// Always render at least one hidden input to ensure required form submission.
let values: (Key | null)[] = Array.isArray(state.value) ? state.value : [state.value];
if (values.length === 0) {
values = [null];
}

let res = values.map((value, i) => {
let inputProps: InputHTMLAttributes<HTMLInputElement> = {
type: 'hidden',
autoComplete: selectProps.autoComplete,
name,
form,
disabled: isDisabled,
value: value ?? ''
};

if (validationBehavior === 'native') {
// Use a hidden <input type="text"> rather than <input type="hidden">
// so that an empty value blocks HTML form submission when the field is required.
return (
<input
key={i}
{...inputProps}
ref={i === 0 ? inputRef : null}
style={{display: 'none'}}
type="text"
required={i === 0 ? selectProps.required : false}
onChange={() => {/** Ignore react warning. */}} />
);
}

if (validationBehavior === 'native') {
// Use a hidden <input type="text"> rather than <input type="hidden">
// so that an empty value blocks HTML form submission when the field is required.
return (
<input
{...inputProps}
ref={inputRef}
style={{display: 'none'}}
type="text"
required={selectProps.required}
onChange={() => {/** Ignore react warning. */}} />
<input key={i} {...inputProps} ref={i === 0 ? inputRef : null} />
);
}
});

return (
<input {...inputProps} ref={inputRef} />
);
return <>{res}</>;
}

return null;
Expand Down
19 changes: 13 additions & 6 deletions packages/@react-aria/select/src/useSelect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

import {AriaButtonProps} from '@react-types/button';
import {AriaListBoxOptions} from '@react-aria/listbox';
import {AriaSelectProps} from '@react-types/select';
import {AriaSelectProps, SelectionMode} from '@react-types/select';
import {chain, filterDOMProps, mergeProps, useId} from '@react-aria/utils';
import {DOMAttributes, KeyboardDelegate, RefObject, ValidationResult} from '@react-types/shared';
import {FocusEvent, useMemo} from 'react';
Expand All @@ -24,15 +24,15 @@ import {useCollator} from '@react-aria/i18n';
import {useField} from '@react-aria/label';
import {useMenuTrigger} from '@react-aria/menu';

export interface AriaSelectOptions<T> extends Omit<AriaSelectProps<T>, 'children'> {
export interface AriaSelectOptions<T, M extends SelectionMode = 'single'> extends Omit<AriaSelectProps<T, M>, 'children'> {
/**
* An optional keyboard delegate implementation for type to select,
* to override the default.
*/
keyboardDelegate?: KeyboardDelegate
}

export interface SelectAria<T> extends ValidationResult {
export interface SelectAria<T, M extends SelectionMode = 'single'> extends ValidationResult {
/** Props for the label element. */
labelProps: DOMAttributes,

Expand All @@ -52,7 +52,7 @@ export interface SelectAria<T> extends ValidationResult {
errorMessageProps: DOMAttributes,

/** Props for the hidden select element. */
hiddenSelectProps: HiddenSelectProps<T>
hiddenSelectProps: HiddenSelectProps<T, M>
}

interface SelectData {
Expand All @@ -63,15 +63,15 @@ interface SelectData {
validationBehavior?: 'aria' | 'native'
}

export const selectData: WeakMap<SelectState<any>, SelectData> = new WeakMap<SelectState<any>, SelectData>();
export const selectData: WeakMap<SelectState<any, any>, SelectData> = new WeakMap<SelectState<any>, SelectData>();

/**
* Provides the behavior and accessibility implementation for a select component.
* A select displays a collapsible list of options and allows a user to select one of them.
* @param props - Props for the select.
* @param state - State for the select, as returned by `useListState`.
*/
export function useSelect<T>(props: AriaSelectOptions<T>, state: SelectState<T>, ref: RefObject<HTMLElement | null>): SelectAria<T> {
export function useSelect<T, M extends SelectionMode = 'single'>(props: AriaSelectOptions<T, M>, state: SelectState<T, M>, ref: RefObject<HTMLElement | null>): SelectAria<T, M> {
let {
keyboardDelegate,
isDisabled,
Expand All @@ -96,6 +96,10 @@ export function useSelect<T>(props: AriaSelectOptions<T>, state: SelectState<T>,
);

let onKeyDown = (e: KeyboardEvent) => {
if (state.selectionManager.selectionMode === 'multiple') {
return;
}

switch (e.key) {
case 'ArrowLeft': {
// prevent scrolling containers
Expand Down Expand Up @@ -138,6 +142,9 @@ export function useSelect<T>(props: AriaSelectOptions<T>, state: SelectState<T>,

typeSelectProps.onKeyDown = typeSelectProps.onKeyDownCapture;
delete typeSelectProps.onKeyDownCapture;
if (state.selectionManager.selectionMode === 'multiple') {
typeSelectProps = {};
}

let domProps = filterDOMProps(props, {labelable: true});
let triggerProps = mergeProps(typeSelectProps, menuTriggerProps, fieldProps);
Expand Down
4 changes: 3 additions & 1 deletion packages/@react-aria/test-utils/src/select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,8 @@ export class SelectTester {
throw new Error('Target option not found in the listbox.');
}

let isMultiSelect = listbox.getAttribute('aria-multiselectable') === 'true';

if (interactionType === 'keyboard') {
if (option?.getAttribute('aria-disabled') === 'true') {
return;
Expand All @@ -203,7 +205,7 @@ export class SelectTester {
}
}

if (option?.getAttribute('href') == null) {
if (!isMultiSelect && option?.getAttribute('href') == null) {
await waitFor(() => {
if (document.activeElement !== this._trigger) {
throw new Error(`Expected the document.activeElement after selecting an option to be the select component trigger but got ${document.activeElement}`);
Expand Down
2 changes: 1 addition & 1 deletion packages/@react-aria/utils/src/isElementVisible.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ function isAttributeVisible(element: Element, childElement?: Element) {
*/
export function isElementVisible(element: Element, childElement?: Element): boolean {
if (supportsCheckVisibility) {
return element.checkVisibility() && !element.closest('[data-react-aria-prevent-focus]');
return element.checkVisibility({visibilityProperty: true}) && !element.closest('[data-react-aria-prevent-focus]');
}

return (
Expand Down
60 changes: 47 additions & 13 deletions packages/@react-aria/utils/src/scrollIntoView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
*/

import {getScrollParents} from './getScrollParents';
import {isChrome} from './platform';

interface ScrollIntoViewportOpts {
/** The optional containing element of the target to be centered in the viewport. */
Expand Down Expand Up @@ -40,32 +41,64 @@ export function scrollIntoView(scrollView: HTMLElement, element: HTMLElement): v
scrollPaddingLeft
} = getComputedStyle(scrollView);

// Account for scroll margin of the element
let {
scrollMarginTop,
scrollMarginRight,
scrollMarginBottom,
scrollMarginLeft
} = getComputedStyle(element);

let borderAdjustedX = x + parseInt(borderLeftWidth, 10);
let borderAdjustedY = y + parseInt(borderTopWidth, 10);
// Ignore end/bottom border via clientHeight/Width instead of offsetHeight/Width
let maxX = borderAdjustedX + scrollView.clientWidth;
let maxY = borderAdjustedY + scrollView.clientHeight;

// Get scroll padding values as pixels - defaults to 0 if no scroll padding
// Get scroll padding / margin values as pixels - defaults to 0 if no scroll padding / margin
// is used.
let scrollPaddingTopNumber = parseInt(scrollPaddingTop, 10) || 0;
let scrollPaddingBottomNumber = parseInt(scrollPaddingBottom, 10) || 0;
let scrollPaddingRightNumber = parseInt(scrollPaddingRight, 10) || 0;
let scrollPaddingLeftNumber = parseInt(scrollPaddingLeft, 10) || 0;
let scrollMarginTopNumber = parseInt(scrollMarginTop, 10) || 0;
let scrollMarginBottomNumber = parseInt(scrollMarginBottom, 10) || 0;
let scrollMarginRightNumber = parseInt(scrollMarginRight, 10) || 0;
let scrollMarginLeftNumber = parseInt(scrollMarginLeft, 10) || 0;

let targetLeft = offsetX - scrollMarginLeftNumber;
let targetRight = offsetX + width + scrollMarginRightNumber;
let targetTop = offsetY - scrollMarginTopNumber;
let targetBottom = offsetY + height + scrollMarginBottomNumber;

if (offsetX <= x + scrollPaddingLeftNumber) {
x = offsetX - parseInt(borderLeftWidth, 10) - scrollPaddingLeftNumber;
} else if (offsetX + width > maxX - scrollPaddingRightNumber) {
x += offsetX + width - maxX + scrollPaddingRightNumber;
let scrollPortLeft = x + parseInt(borderLeftWidth, 10) + scrollPaddingLeftNumber;
let scrollPortRight = maxX - scrollPaddingRightNumber;
let scrollPortTop = y + parseInt(borderTopWidth, 10) + scrollPaddingTopNumber;
let scrollPortBottom = maxY - scrollPaddingBottomNumber;

if (targetLeft > scrollPortLeft || targetRight < scrollPortRight) {
if (targetLeft <= x + scrollPaddingLeftNumber) {
x = targetLeft - parseInt(borderLeftWidth, 10) - scrollPaddingLeftNumber;
} else if (targetRight > maxX - scrollPaddingRightNumber) {
x += targetRight - maxX + scrollPaddingRightNumber;
}
}
if (offsetY <= borderAdjustedY + scrollPaddingTopNumber) {
y = offsetY - parseInt(borderTopWidth, 10) - scrollPaddingTopNumber;
} else if (offsetY + height > maxY - scrollPaddingBottomNumber) {
y += offsetY + height - maxY + scrollPaddingBottomNumber;

if (targetTop > scrollPortTop || targetBottom < scrollPortBottom) {
if (targetTop <= borderAdjustedY + scrollPaddingTopNumber) {
y = targetTop - parseInt(borderTopWidth, 10) - scrollPaddingTopNumber;
} else if (targetBottom > maxY - scrollPaddingBottomNumber) {
y += targetBottom - maxY + scrollPaddingBottomNumber;
}
}

if (process.env.NODE_ENV === 'test') {
scrollView.scrollLeft = x;
scrollView.scrollTop = y;
return;
}

scrollView.scrollLeft = x;
scrollView.scrollTop = y;
scrollView.scrollTo({left: x, top: y});
}

/**
Expand Down Expand Up @@ -101,8 +134,9 @@ export function scrollIntoViewport(targetElement: Element | null, opts?: ScrollI
if (targetElement && document.contains(targetElement)) {
let root = document.scrollingElement || document.documentElement;
let isScrollPrevented = window.getComputedStyle(root).overflow === 'hidden';
// If scrolling is not currently prevented then we aren’t in a overlay nor is a overlay open, just use element.scrollIntoView to bring the element into view
if (!isScrollPrevented) {
// If scrolling is not currently prevented then we aren't in a overlay nor is a overlay open, just use element.scrollIntoView to bring the element into view
// Also ignore in chrome because of this bug: https://issues.chromium.org/issues/40074749
if (!isScrollPrevented && !isChrome()) {
let {left: originalLeft, top: originalTop} = targetElement.getBoundingClientRect();

// use scrollIntoView({block: 'nearest'}) instead of .focus to check if the element is fully in view or not since .focus()
Expand Down
3 changes: 1 addition & 2 deletions packages/@react-spectrum/picker/test/Picker.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1508,8 +1508,7 @@ describe('Picker', function () {
expect(document.activeElement).toBe(items[1]);

await selectTester.selectOption({option: 'Two'});
expect(onSelectionChange).toHaveBeenCalledTimes(1);
expect(onSelectionChange).toHaveBeenCalledWith('two');
expect(onSelectionChange).not.toHaveBeenCalled();

expect(document.activeElement).toBe(picker);
expect(picker).toHaveTextContent('Two');
Expand Down
2 changes: 1 addition & 1 deletion packages/@react-spectrum/s2/src/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ export let checkmark = style({
aspectRatio: 'square'
});

let checkbox = style({
export let checkbox = style({
gridArea: 'checkmark',
marginEnd: 'text-to-control'
});
Expand Down
Loading
Loading