Skip to content

Commit

Permalink
Merge branch 'main' into issue-7660
Browse files Browse the repository at this point in the history
  • Loading branch information
reidbarber authored Jan 29, 2025
2 parents d9293d3 + e228ed8 commit 9224e82
Show file tree
Hide file tree
Showing 41 changed files with 702 additions and 166 deletions.
6 changes: 3 additions & 3 deletions packages/@react-aria/datepicker/docs/useDateField.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -130,12 +130,12 @@ function DateSegment({segment, state}) {
let {segmentProps} = useDateSegment(segment, state, ref);

return (
<div
<span
{...segmentProps}
ref={ref}
className={`segment ${segment.isPlaceholder ? 'placeholder' : ''}`}>
{segment.text}
</div>
</span>
);
}

Expand All @@ -153,7 +153,7 @@ function DateSegment({segment, state}) {
}

.field {
display: inline-flex;
display: block;
padding: 2px 4px;
border-radius: 2px;
border: 1px solid var(--gray);
Expand Down
3 changes: 3 additions & 0 deletions packages/@react-aria/datepicker/src/useDateField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,9 @@ export function useDateField<T extends DateValue>(props: AriaDateFieldOptions<T>
if (props.onKeyUp) {
props.onKeyUp(e);
}
},
style: {
unicodeBidi: 'isolate'
}
}),
inputProps,
Expand Down
73 changes: 69 additions & 4 deletions packages/@react-aria/datepicker/src/useDatePickerGroup.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,48 @@
import {createFocusManager, getFocusableTreeWalker} from '@react-aria/focus';
import {DateFieldState, DatePickerState, DateRangePickerState} from '@react-stately/datepicker';
import {FocusableElement, KeyboardEvent, RefObject} from '@react-types/shared';
import {mergeProps} from '@react-aria/utils';
import {mergeProps, useLayoutEffect} from '@react-aria/utils';
import {useLocale} from '@react-aria/i18n';
import {useMemo} from 'react';
import {useMemo, useRef} from 'react';
import {usePress} from '@react-aria/interactions';

export function useDatePickerGroup(state: DatePickerState | DateRangePickerState | DateFieldState, ref: RefObject<Element | null>, disableArrowNavigation?: boolean) {
let {direction} = useLocale();
let focusManager = useMemo(() => createFocusManager(ref), [ref]);
let segments = useRef<FocusableElement[]>(undefined);
useLayoutEffect(() => {
if (ref?.current) {

let update = () => {
if (ref.current) {
// TODO: For now, just querying this list of elements. However, it's possible that either through hooks or RAC that some users may include other focusable items that they would want to able to keyboard navigate to. In that case, we might want to utilize focusableElements in isFocusable.ts
let editableSegments: NodeListOf<Element> | undefined = ref.current?.querySelectorAll('span[role="spinbutton"], span[role="textbox"], button, div[role="spinbutton"], div[role="textbox"]');

let segmentsArr = Array.from(editableSegments as NodeListOf<Element>).filter(Boolean).map(node => {
return {
element: node as FocusableElement,
rectX: node.getBoundingClientRect().left
};
});

let orderedSegments = segmentsArr.sort((a, b) => a.rectX - b.rectX).map((item => item.element));
segments.current = orderedSegments;
}
};

update();

let observer = new MutationObserver(update);
observer.observe(ref.current, {
subtree: true,
childList: true
});

return () => {
observer.disconnect();
};
}
}, []);

// Open the popover on alt + arrow down
let onKeyDown = (e: KeyboardEvent) => {
Expand All @@ -31,7 +65,21 @@ export function useDatePickerGroup(state: DatePickerState | DateRangePickerState
e.preventDefault();
e.stopPropagation();
if (direction === 'rtl') {
focusManager.focusNext();
if (segments.current) {
let orderedSegments = segments.current;
let target = e.target as FocusableElement;
let index = orderedSegments.indexOf(target);

if (index === 0) {
target = orderedSegments[0] || target;
} else {
target = orderedSegments[index - 1] || target;
}

if (target) {
target.focus();
}
}
} else {
focusManager.focusPrevious();
}
Expand All @@ -40,7 +88,24 @@ export function useDatePickerGroup(state: DatePickerState | DateRangePickerState
e.preventDefault();
e.stopPropagation();
if (direction === 'rtl') {
focusManager.focusPrevious();
if (segments.current) {
let orderedSegments = segments.current;
let target = e.target as FocusableElement;
let index = orderedSegments.indexOf(target);

if (index === orderedSegments.length - 1) {
target = orderedSegments[orderedSegments.length - 1] || target;
} else {
target = orderedSegments[index - 1] || target;
}


target = orderedSegments[index + 1] || target;

if (target) {
target.focus();
}
}
} else {
focusManager.focusNext();
}
Expand Down
18 changes: 13 additions & 5 deletions packages/@react-aria/datepicker/src/useDateSegment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {DateFieldState, DateSegment} from '@react-stately/datepicker';
import {getScrollParent, isIOS, isMac, mergeProps, scrollIntoViewport, useEvent, useId, useLabels, useLayoutEffect} from '@react-aria/utils';
import {hookData} from './useDateField';
import {NumberParser} from '@internationalized/number';
import React, {useMemo, useRef} from 'react';
import React, {CSSProperties, useMemo, useRef} from 'react';
import {RefObject} from '@react-types/shared';
import {useDateFormatter, useFilter, useLocale} from '@react-aria/i18n';
import {useDisplayNames} from './useDisplayNames';
Expand All @@ -33,7 +33,7 @@ export interface DateSegmentAria {
*/
export function useDateSegment(segment: DateSegment, state: DateFieldState, ref: RefObject<HTMLElement | null>): DateSegmentAria {
let enteredKeys = useRef('');
let {locale} = useLocale();
let {locale, direction} = useLocale();
let displayNames = useDisplayNames();
let {ariaLabel, ariaLabelledBy, ariaDescribedBy, focusManager} = hookData.get(state)!;

Expand Down Expand Up @@ -385,6 +385,16 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
};
}

let dateSegments = ['day', 'month', 'year'];
let segmentStyle : CSSProperties = {caretColor: 'transparent'};
if (direction === 'rtl') {
if (dateSegments.includes(segment.type)) {
segmentStyle = {caretColor: 'transparent', direction: 'ltr', unicodeBidi: 'embed'};
} else if (segment.type === 'timeZoneName') {
segmentStyle = {caretColor: 'transparent', unicodeBidi: 'embed'};
}
}

return {
segmentProps: mergeProps(spinButtonProps, labelProps, {
id,
Expand All @@ -403,9 +413,7 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
tabIndex: state.isDisabled ? undefined : 0,
onKeyDown,
onFocus,
style: {
caretColor: 'transparent'
},
style: segmentStyle,
// Prevent pointer events from reaching useDatePickerGroup, and allow native browser behavior to focus the segment.
onPointerDown(e) {
e.stopPropagation();
Expand Down
6 changes: 5 additions & 1 deletion packages/@react-aria/gridlist/src/useGridList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
DisabledBehavior,
DOMAttributes,
DOMProps,
FocusStrategy,
Key,
KeyboardDelegate,
LayoutDelegate,
Expand All @@ -30,6 +31,8 @@ import {useHasTabbableChild} from '@react-aria/focus';
import {useSelectableList} from '@react-aria/selection';

export interface GridListProps<T> extends CollectionBase<T>, MultipleSelection {
/** Whether to auto focus the gridlist or an option. */
autoFocus?: boolean | FocusStrategy,
/**
* Handler that is called when a user performs an action on an item. The exact user event depends on
* the collection's `selectionBehavior` prop and the interaction modality.
Expand Down Expand Up @@ -113,7 +116,8 @@ export function useGridList<T>(props: AriaGridListOptions<T>, state: ListState<T
isVirtualized,
selectOnFocus: state.selectionManager.selectionBehavior === 'replace',
shouldFocusWrap: props.shouldFocusWrap,
linkBehavior
linkBehavior,
autoFocus: props.autoFocus
});

let id = useId(props.id);
Expand Down
6 changes: 4 additions & 2 deletions packages/@react-aria/interactions/src/usePress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,7 +498,9 @@ export function usePress(props: PressHookProps): PressResult {
// However, iOS and Android do not focus or fire onClick after a long press.
// We work around this by triggering a click ourselves after a timeout.
// This timeout is canceled during the click event in case the real one fires first.
// In testing, a 0ms delay is too short. 5ms seems long enough for the browser to fire the real events.
// The timeout must be at least 32ms, because Safari on iOS delays the click event on
// non-form elements without certain ARIA roles (for hover emulation).
// https://github.com/WebKit/WebKit/blob/dccfae42bb29bd4bdef052e469f604a9387241c0/Source/WebKit/WebProcess/WebPage/ios/WebPageIOS.mm#L875-L892
let clicked = false;
let timeout = setTimeout(() => {
if (state.isPressed && state.target instanceof HTMLElement) {
Expand All @@ -509,7 +511,7 @@ export function usePress(props: PressHookProps): PressResult {
state.target.click();
}
}
}, 5);
}, 40);
// Use a capturing listener to track if a click occurred.
// If stopPropagation is called it may never reach our handler.
addGlobalListener(e.currentTarget as Document, 'click', () => clicked = true, true);
Expand Down
4 changes: 2 additions & 2 deletions packages/@react-aria/interactions/test/usePress.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ describe('usePress', function () {
expect(shouldFocus).toBe(true);

// Mouse events are not fired in this case, and the browser does not focus the element.
act(() => jest.advanceTimersByTime(10));
act(() => jest.advanceTimersByTime(50));
expect(document.activeElement).toBe(el);

expect(events).toEqual([
Expand Down Expand Up @@ -352,7 +352,7 @@ describe('usePress', function () {
expect(shouldClick).toBe(true);
fireEvent(el, pointerEvent('pointerout', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0}));

act(() => jest.advanceTimersByTime(10));
act(() => jest.advanceTimersByTime(50));

expect(events).toEqual([
{
Expand Down
10 changes: 4 additions & 6 deletions packages/@react-aria/radio/src/useRadio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,16 +63,14 @@ export function useRadio(props: AriaRadioProps, state: RadioGroupState, ref: Ref
state.setSelectedValue(value);
};

// Handle press state for keyboard interactions and cases where labelProps is not used.
let {pressProps, isPressed} = usePress({
isDisabled
});

// iOS does not toggle radios if you drag off and back onto the label, so handle it ourselves.
// Handle press state on the label.
let {pressProps: labelProps, isPressed: isLabelPressed} = usePress({
isDisabled,
onPress() {
state.setSelectedValue(value);
}
isDisabled
});

let {focusableProps} = useFocusable(mergeProps(props, {
Expand All @@ -97,7 +95,7 @@ export function useRadio(props: AriaRadioProps, state: RadioGroupState, ref: Ref
useFormValidation({validationBehavior}, state, ref);

return {
labelProps: mergeProps(labelProps, {onClick: e => e.preventDefault()}),
labelProps,
inputProps: mergeProps(domProps, {
...interactions,
type: 'radio',
Expand Down
11 changes: 4 additions & 7 deletions packages/@react-aria/toggle/src/useToggle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,17 +64,14 @@ export function useToggle(props: AriaToggleProps, state: ToggleState, ref: RefOb
console.warn('If you do not provide children, you must specify an aria-label for accessibility');
}

// This handles focusing the input on pointer down, which Safari does not do by default.
// Handle press state for keyboard interactions and cases where labelProps is not used.
let {pressProps, isPressed} = usePress({
isDisabled
});

// iOS does not toggle checkboxes if you drag off and back onto the label, so handle it ourselves.
// Handle press state on the label.
let {pressProps: labelProps, isPressed: isLabelPressed} = usePress({
isDisabled: isDisabled || isReadOnly,
onPress() {
state.toggle();
}
isDisabled: isDisabled || isReadOnly
});

let {focusableProps} = useFocusable(props, ref);
Expand All @@ -84,7 +81,7 @@ export function useToggle(props: AriaToggleProps, state: ToggleState, ref: RefOb
useFormReset(ref, state.isSelected, state.setSelected);

return {
labelProps: mergeProps(labelProps, {onClick: e => e.preventDefault()}),
labelProps,
inputProps: mergeProps(domProps, {
'aria-invalid': isInvalid || validationState === 'invalid' || undefined,
'aria-errormessage': props['aria-errormessage'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export default {
title: 'DateField',
parameters: {
chromaticProvider: {
locales: ['en-US', 'ar-EG', 'ja-JP']
locales: ['en-US', 'ar-EG', 'ja-JP', 'he-IL']
}
}
};
Expand All @@ -41,6 +41,16 @@ PlaceholderFocus.parameters = {
}
};

export const PlaceholderFocusRTL = () => <DateField label="Date" placeholderValue={date} autoFocus />;
PlaceholderFocusRTL.parameters = {
chromaticProvider: {
locales: ['he-IL'],
scales: ['medium'],
colorSchemes: ['light'],
express: false
}
};

export const PlaceholderFocusExpress = () => <DateField label="Date" placeholderValue={date} autoFocus />;
PlaceholderFocusExpress.parameters = {
chromaticProvider: {
Expand Down
Loading

0 comments on commit 9224e82

Please sign in to comment.