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
1 change: 0 additions & 1 deletion .storybook-s2/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ channel.on(DARK_MODE_EVENT_NAME, isDark => document.documentElement.dataset.colo
/** @type { import('@storybook/react').Preview } */
const preview = {
parameters: {
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {},
exclude: ['key', 'ref']
Expand Down
44 changes: 42 additions & 2 deletions packages/@internationalized/date/src/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ export function endOfWeek(date: DateValue, locale: string, firstDayOfWeek?: DayO
}

const cachedRegions = new Map<string, string>();
const cachedWeekInfo = new Map<string, {firstDay: number}>();

function getRegion(locale: string): string | undefined {
// If the Intl.Locale API is available, use it to get the region for the locale.
Expand Down Expand Up @@ -251,8 +252,47 @@ function getRegion(locale: string): string | undefined {
function getWeekStart(locale: string): number {
// TODO: use Intl.Locale for this once browsers support the weekInfo property
// https://github.com/tc39/proposal-intl-locale-info
let region = getRegion(locale);
return region ? weekStartData[region] || 0 : 0;
let weekInfo = cachedWeekInfo.get(locale);
if (!weekInfo) {
if (Intl.Locale) {
// @ts-ignore
let localeInst = new Intl.Locale(locale);
if ('getWeekInfo' in localeInst) {
// @ts-expect-error
weekInfo = localeInst.getWeekInfo();
if (weekInfo) {
cachedWeekInfo.set(locale, weekInfo);
return weekInfo.firstDay;
}
}
}
let region = getRegion(locale);
if (locale.includes('-fw-')) {
let day = locale.split('-fw-')[1];
if (day === 'mon') {
weekInfo = {firstDay: 1};
} else if (day === 'tue') {
weekInfo = {firstDay: 2};
} else if (day === 'wed') {
weekInfo = {firstDay: 3};
} else if (day === 'thu') {
weekInfo = {firstDay: 4};
} else if (day === 'fri') {
weekInfo = {firstDay: 5};
} else if (day === 'sat') {
weekInfo = {firstDay: 6};
} else {
weekInfo = {firstDay: 0};
}
} else if (locale.includes('u-ca-iso8601')) {
weekInfo = {firstDay: 1};
} else {
weekInfo = {firstDay: region ? weekStartData[region] || 0 : 0};
}
cachedWeekInfo.set(locale, weekInfo);
}

return weekInfo.firstDay;
}

/** Returns the number of weeks in the given month and locale. */
Expand Down
9 changes: 9 additions & 0 deletions packages/@internationalized/date/tests/queries.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,15 @@ describe('queries', function () {
expect(startOfWeek(new CalendarDate(2021, 8, 4), 'fr-FR', 'sun')).toEqual(new CalendarDate(2021, 8, 1));
expect(startOfWeek(new CalendarDate(2021, 8, 4), 'en-US', 'thu')).toEqual(new CalendarDate(2021, 7, 29));
});

it('should return the start of the week in en-US-u-ca-iso8601', function () {
// start of week is monday
expect(startOfWeek(new CalendarDate(2021, 8, 4), 'en-US-u-ca-iso8601')).toEqual(new CalendarDate(2021, 8, 2));
expect(startOfWeek(new CalendarDate(2021, 8, 4), 'fr-FR-u-ca-iso8601')).toEqual(new CalendarDate(2021, 8, 2));

// override first day of week
expect(startOfWeek(new CalendarDate(2021, 8, 4), 'en-US-u-ca-iso8601-fw-tue')).toEqual(new CalendarDate(2021, 8, 3));
});
});

describe('endOfWeek', function () {
Expand Down
10 changes: 9 additions & 1 deletion packages/@react-aria/interactions/src/usePress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,10 +298,18 @@ export function usePress(props: PressHookProps): PressResult {
});

let triggerClick = useEffectEvent((e: RMouseEvent<FocusableElement>) => {
if (isDisabled) {
return;
}

onClick?.(e);
});

let triggerSyntheticClick = useEffectEvent((e: KeyboardEvent | TouchEvent, target: FocusableElement) => {
if (isDisabled) {
return;
}

// Some third-party libraries pass in onClick instead of onPress.
// Create a fake mouse event and trigger onClick as well.
// This matches the browser's native activation behavior for certain elements (e.g. button).
Expand Down Expand Up @@ -374,7 +382,7 @@ export function usePress(props: PressHookProps): PressResult {
if (isDisabled) {
e.preventDefault();
}

// If triggered from a screen reader or by using element.click(),
// trigger as if it were a keyboard click.
if (!state.ignoreEmulatedMouseEvents && !state.isPressed && (state.pointerType === 'virtual' || isVirtualClick(e.nativeEvent))) {
Expand Down
8 changes: 5 additions & 3 deletions packages/@react-aria/interactions/test/usePress.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1049,7 +1049,7 @@ describe('usePress', function () {
]);
});

it('should not fire press events for disabled elements', function () {
it('should not fire press/click events for disabled elements', function () {
let events = [];
let addEvent = (e) => events.push(e);
let res = render(
Expand All @@ -1066,6 +1066,7 @@ describe('usePress', function () {
let el = res.getByText('test');
fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse'}));
fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0}));
fireEvent.click(el);

expect(events).toEqual([]);
});
Expand Down Expand Up @@ -4553,12 +4554,13 @@ describe('usePress', function () {
]);
});

it('should not fire press events for disabled elements', function () {
const shadowRoot = setupShadowDOMTest({isDisabled: true});
it('should not fire press/click events for disabled elements', function () {
const shadowRoot = setupShadowDOMTest({isDisabled: true, onClick: e => addEvent({type: e.type, target: e.target})});

const el = shadowRoot.getElementById('testElement');
fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse'}));
fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0}));
fireEvent.click(el);

expect(events).toEqual([]);
});
Expand Down
81 changes: 52 additions & 29 deletions packages/@react-aria/overlays/src/calculatePosition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,11 @@ function getContainerDimensions(containerNode: Element): Dimensions {
let scroll: Position = {};
let isPinchZoomedIn = (visualViewport?.scale ?? 1) > 1;

if (containerNode.tagName === 'BODY') {
// In the case where the container is `html` or `body` and the container doesn't have something like `position: relative`,
// then position absolute will be positioned relative to the viewport, also known as the `initial containing block`.
// That's why we use the visual viewport instead.

if (containerNode.tagName === 'BODY' || containerNode.tagName === 'HTML') {
let documentElement = document.documentElement;
totalWidth = documentElement.clientWidth;
totalHeight = documentElement.clientHeight;
Expand Down Expand Up @@ -179,10 +183,13 @@ function getDelta(
let boundarySize = boundaryDimensions[AXIS_SIZE[axis]];
// Calculate the edges of the boundary (accomodating for the boundary padding) and the edges of the overlay.
// Note that these values are with respect to the visual viewport (aka 0,0 is the top left of the viewport)
let boundaryStartEdge = boundaryDimensions.scroll[AXIS[axis]] + padding;
let boundaryEndEdge = boundarySize + boundaryDimensions.scroll[AXIS[axis]] - padding;
let startEdgeOffset = offset - containerScroll + containerOffsetWithBoundary[axis] - boundaryDimensions[AXIS[axis]];
let endEdgeOffset = offset - containerScroll + size + containerOffsetWithBoundary[axis] - boundaryDimensions[AXIS[axis]];

let boundaryStartEdge = containerOffsetWithBoundary[axis] + boundaryDimensions.scroll[AXIS[axis]] + padding;
let boundaryEndEdge = containerOffsetWithBoundary[axis] + boundaryDimensions.scroll[AXIS[axis]] + boundarySize - padding;
// transformed value of the left edge of the overlay
let startEdgeOffset = offset - containerScroll + boundaryDimensions.scroll[AXIS[axis]] + containerOffsetWithBoundary[axis] - boundaryDimensions[AXIS[axis]];
// transformed value of the right edge of the overlay
let endEdgeOffset = offset - containerScroll + size + boundaryDimensions.scroll[AXIS[axis]] + containerOffsetWithBoundary[axis] - boundaryDimensions[AXIS[axis]];

// If any of the overlay edges falls outside of the boundary, shift the overlay the required amount to align one of the overlay's
// edges with the closest boundary edge.
Expand Down Expand Up @@ -234,7 +241,8 @@ function computePosition(
containerOffsetWithBoundary: Offset,
isContainerPositioned: boolean,
arrowSize: number,
arrowBoundaryOffset: number
arrowBoundaryOffset: number,
containerDimensions: Dimensions
) {
let {placement, crossPlacement, axis, crossAxis, size, crossSize} = placementInfo;
let position: Position = {};
Expand All @@ -255,9 +263,9 @@ function computePosition(

position[crossAxis]! += crossOffset;

// overlay top overlapping arrow with button bottom
// overlay top or left overlapping arrow with button bottom or right
const minPosition = childOffset[crossAxis] - overlaySize[crossSize] + arrowSize + arrowBoundaryOffset;
// overlay bottom overlapping arrow with button top
// overlay bottom or right overlapping arrow with button top or left
const maxPosition = childOffset[crossAxis] + childOffset[crossSize] - arrowSize - arrowBoundaryOffset;
position[crossAxis] = clamp(position[crossAxis]!, minPosition, maxPosition);

Expand All @@ -266,8 +274,8 @@ function computePosition(
// If the container is positioned (non-static), then we use the container's actual
// height, as `bottom` will be relative to this height. But if the container is static,
// then it can only be the `document.body`, and `bottom` will be relative to _its_
// container, which should be as large as boundaryDimensions.
const containerHeight = (isContainerPositioned ? containerOffsetWithBoundary[size] : boundaryDimensions[TOTAL_SIZE[size]]);
// container.
const containerHeight = (isContainerPositioned ? containerOffsetWithBoundary[size] : containerDimensions[TOTAL_SIZE[size]]);
position[FLIPPED_DIRECTION[axis]] = Math.floor(containerHeight - childOffset[axis] + offset);
} else {
position[axis] = Math.floor(childOffset[axis] + childOffset[size] + offset);
Expand All @@ -283,42 +291,55 @@ function getMaxHeight(
margins: Position,
padding: number,
overlayHeight: number,
heightGrowthDirection: HeightGrowthDirection
heightGrowthDirection: HeightGrowthDirection,
containerDimensions: Dimensions
) {
const containerHeight = (isContainerPositioned ? containerOffsetWithBoundary.height : boundaryDimensions[TOTAL_SIZE.height]);
// For cases where position is set via "bottom" instead of "top", we need to calculate the true overlay top with respect to the boundary. Reverse calculate this with the same method
// used in computePosition.
let overlayTop = position.top != null ? containerOffsetWithBoundary.top + position.top : containerOffsetWithBoundary.top + (containerHeight - (position.bottom ?? 0) - overlayHeight);
const containerHeight = (isContainerPositioned ? containerOffsetWithBoundary.height : containerDimensions[TOTAL_SIZE.height]);
// For cases where position is set via "bottom" instead of "top", we need to calculate the true overlay top
// with respect to the container.
let overlayTop = position.top != null ? position.top : (containerHeight - (position.bottom ?? 0) - overlayHeight);
let maxHeight = heightGrowthDirection !== 'top' ?
// We want the distance between the top of the overlay to the bottom of the boundary
Math.max(0,
(boundaryDimensions.height + boundaryDimensions.top + (boundaryDimensions.scroll.top ?? 0)) // this is the bottom of the boundary
(boundaryDimensions.height + boundaryDimensions.top) // this is the bottom of the boundary
- overlayTop // this is the top of the overlay
- ((margins.top ?? 0) + (margins.bottom ?? 0) + padding) // save additional space for margin and padding
)
// We want the distance between the bottom of the overlay to the top of the boundary
: Math.max(0,
(overlayTop + overlayHeight) // this is the bottom of the overlay
- (boundaryDimensions.top + (boundaryDimensions.scroll.top ?? 0)) // this is the top of the boundary
- boundaryDimensions.top // this is the top of the boundary
- ((margins.top ?? 0) + (margins.bottom ?? 0) + padding) // save additional space for margin and padding
);
return Math.min(boundaryDimensions.height - (padding * 2), maxHeight);
}

function getAvailableSpace(
boundaryDimensions: Dimensions,
boundaryDimensions: Dimensions, // boundary
containerOffsetWithBoundary: Offset,
childOffset: Offset,
margins: Position,
padding: number,
childOffset: Offset, // trigger
margins: Position, // overlay
padding: number, // overlay <-> boundary
placementInfo: ParsedPlacement
) {
let {placement, axis, size} = placementInfo;
if (placement === axis) {
return Math.max(0, childOffset[axis] - boundaryDimensions[axis] - (boundaryDimensions.scroll[axis] ?? 0) + containerOffsetWithBoundary[axis] - (margins[axis] ?? 0) - margins[FLIPPED_DIRECTION[axis]] - padding);
return Math.max(0,
childOffset[axis] // trigger start
- boundaryDimensions[axis] // boundary start
- (margins[axis] ?? 0) // margins usually for arrows or other decorations
- margins[FLIPPED_DIRECTION[axis]]
- padding); // padding between overlay and boundary
}

return Math.max(0, boundaryDimensions[size] + boundaryDimensions[axis] + boundaryDimensions.scroll[axis] - containerOffsetWithBoundary[axis] - childOffset[axis] - childOffset[size] - (margins[axis] ?? 0) - margins[FLIPPED_DIRECTION[axis]] - padding);
return Math.max(0,
boundaryDimensions[size]
+ boundaryDimensions[axis]
- childOffset[axis]
- childOffset[size]
- (margins[axis] ?? 0)
- margins[FLIPPED_DIRECTION[axis]]
- padding);
}

export function calculatePositionInternal(
Expand All @@ -341,7 +362,7 @@ export function calculatePositionInternal(
): PositionResult {
let placementInfo = parsePlacement(placementInput);
let {size, crossAxis, crossSize, placement, crossPlacement} = placementInfo;
let position = computePosition(childOffset, boundaryDimensions, overlaySize, placementInfo, offset, crossOffset, containerOffsetWithBoundary, isContainerPositioned, arrowSize, arrowBoundaryOffset);
let position = computePosition(childOffset, boundaryDimensions, overlaySize, placementInfo, offset, crossOffset, containerOffsetWithBoundary, isContainerPositioned, arrowSize, arrowBoundaryOffset, containerDimensions);
let normalizedOffset = offset;
let space = getAvailableSpace(
boundaryDimensions,
Expand All @@ -355,7 +376,8 @@ export function calculatePositionInternal(
// Check if the scroll size of the overlay is greater than the available space to determine if we need to flip
if (flip && scrollSize[size] > space) {
let flippedPlacementInfo = parsePlacement(`${FLIPPED_DIRECTION[placement]} ${crossPlacement}` as Placement);
let flippedPosition = computePosition(childOffset, boundaryDimensions, overlaySize, flippedPlacementInfo, offset, crossOffset, containerOffsetWithBoundary, isContainerPositioned, arrowSize, arrowBoundaryOffset);
let flippedPosition = computePosition(childOffset, boundaryDimensions, overlaySize, flippedPlacementInfo, offset, crossOffset, containerOffsetWithBoundary, isContainerPositioned, arrowSize, arrowBoundaryOffset, containerDimensions);

let flippedSpace = getAvailableSpace(
boundaryDimensions,
containerOffsetWithBoundary,
Expand Down Expand Up @@ -400,7 +422,8 @@ export function calculatePositionInternal(
margins,
padding,
overlaySize.height,
heightGrowthDirection
heightGrowthDirection,
containerDimensions
);

if (userSetMaxHeight && userSetMaxHeight < maxHeight) {
Expand All @@ -409,7 +432,7 @@ export function calculatePositionInternal(

overlaySize.height = Math.min(overlaySize.height, maxHeight);

position = computePosition(childOffset, boundaryDimensions, overlaySize, placementInfo, normalizedOffset, crossOffset, containerOffsetWithBoundary, isContainerPositioned, arrowSize, arrowBoundaryOffset);
position = computePosition(childOffset, boundaryDimensions, overlaySize, placementInfo, normalizedOffset, crossOffset, containerOffsetWithBoundary, isContainerPositioned, arrowSize, arrowBoundaryOffset, containerDimensions);
delta = getDelta(crossAxis, position[crossAxis]!, overlaySize[crossSize], boundaryDimensions, containerDimensions, padding, containerOffsetWithBoundary);
position[crossAxis]! += delta;

Expand Down Expand Up @@ -507,7 +530,7 @@ export function calculatePosition(opts: PositionOpts): PositionResult {
// If the container is the HTML element wrapping the body element, the retrieved scrollTop/scrollLeft will be equal to the
// body element's scroll. Set the container's scroll values to 0 since the overlay's edge position value in getDelta don't then need to be further offset
// by the container scroll since they are essentially the same containing element and thus in the same coordinate system
let containerOffsetWithBoundary: Offset = boundaryElement.tagName === 'BODY' ? getOffset(container, false) : getPosition(container, boundaryElement, false);
let containerOffsetWithBoundary: Offset = boundaryElement.tagName === 'BODY' ? getOffset(container, false) : getPosition(boundaryElement, container, false);
if (container.tagName === 'HTML' && boundaryElement.tagName === 'BODY') {
containerDimensions.scroll.top = 0;
containerDimensions.scroll.left = 0;
Expand Down Expand Up @@ -536,7 +559,7 @@ export function calculatePosition(opts: PositionOpts): PositionResult {
export function getRect(node: Element, ignoreScale: boolean) {
let {top, left, width, height} = node.getBoundingClientRect();

// Use offsetWidth and offsetHeight if this is an HTML element, so that
// Use offsetWidth and offsetHeight if this is an HTML element, so that
// the size is not affected by scale transforms.
if (ignoreScale && node instanceof node.ownerDocument.defaultView!.HTMLElement) {
width = node.offsetWidth;
Expand Down
5 changes: 4 additions & 1 deletion packages/@react-aria/overlays/test/calculatePosition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,13 +138,16 @@ describe('calculatePosition', function () {
};

const container = createElementWithDimensions('div', containerDimensions);
Object.assign(container.style, {
position: 'relative'
});
const target = createElementWithDimensions('div', targetDimension);
const overlay = createElementWithDimensions('div', overlaySize, margins);

const parentElement = document.createElement('div');
parentElement.appendChild(container);
parentElement.appendChild(target);
parentElement.appendChild(overlay);
container.appendChild(overlay);

document.documentElement.appendChild(parentElement);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ describe('useOverlayPosition', function () {
position: absolute;
z-index: 100000;
left: 12px;
bottom: 518px;
bottom: 350px;
max-height: 238px;
`);

Expand Down Expand Up @@ -281,7 +281,7 @@ describe('useOverlayPosition with positioned container', () => {
z-index: 100000;
left: 12px;
top: 200px;
max-height: 406px;
max-height: 556px;
`);

expect(overlay).toHaveTextContent('placement: bottom');
Expand All @@ -303,7 +303,7 @@ describe('useOverlayPosition with positioned container', () => {
z-index: 100000;
left: 12px;
bottom: 300px;
max-height: 238px;
max-height: 88px;
`);

expect(overlay).toHaveTextContent('placement: top');
Expand Down
6 changes: 6 additions & 0 deletions packages/@react-spectrum/menu/chromatic/Submenu.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,9 @@ Default.play = async ({canvasElement}) => {
let body = canvasElement.ownerDocument.body;
let menu = await within(body).getByRole('menu');
let menuItems = within(menu).getAllByRole('menuitem');

// clean up any previous click state
await userEvent.click(document.body);
await userEvent.hover(menuItems[0]);
let submenuTrigger = await within(body).findByText('Baseline');
await userEvent.hover(submenuTrigger);
Expand All @@ -166,6 +169,9 @@ Mobile.play = async ({canvasElement}) => {
let body = canvasElement.ownerDocument.body;
let menu = await within(body).getByRole('menu');
let menuItems = within(menu).getAllByRole('menuitem');

// clean up any previous click state
await userEvent.click(document.body);
await userEvent.click(menuItems[0]);
await within(body).findByText('Baseline');
};
Expand Down
Loading
Loading