diff --git a/pages/app/page.tsx b/pages/app/page.tsx index bcb399ab..aff8d1b3 100644 --- a/pages/app/page.tsx +++ b/pages/app/page.tsx @@ -1,6 +1,8 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { Suspense } from "react"; + +import { Suspense, useEffect } from "react"; +import { useSearchParams } from "react-router-dom"; import { pagesMap } from "../pages"; export interface PageProps { @@ -8,6 +10,13 @@ export interface PageProps { } export default function Page({ pageId }: PageProps) { + const [searchParams] = useSearchParams(); + const direction = searchParams.get("direction") ?? "ltr"; + + useEffect(() => { + document.documentElement.setAttribute("dir", direction); + }, [direction]); + const Component = pagesMap[pageId]; return ( diff --git a/pages/dnd/engine-page-template.tsx b/pages/dnd/engine-page-template.tsx index 5b5564db..dc917047 100644 --- a/pages/dnd/engine-page-template.tsx +++ b/pages/dnd/engine-page-template.tsx @@ -41,6 +41,7 @@ export function EnginePageTemplate({ ariaLabel="Widget settings" variant="icon" onItemClick={() => actions.removeItem()} + expandToViewport={true} /> } i18nStrings={boardItemI18nStrings} diff --git a/pages/widget-container/permutations.page.tsx b/pages/widget-container/permutations.page.tsx index c88bccff..a5820112 100644 --- a/pages/widget-container/permutations.page.tsx +++ b/pages/widget-container/permutations.page.tsx @@ -56,6 +56,7 @@ export default function WidgetContainerPermutations() { { id: "two", text: "Two" }, ]} variant="icon" + expandToViewport={true} /> } i18nStrings={i18nStrings.boardItemI18nStrings} @@ -91,6 +92,7 @@ export default function WidgetContainerPermutations() { { id: "two", text: "Two" }, ]} variant="icon" + expandToViewport={true} /> } i18nStrings={i18nStrings.boardItemI18nStrings} @@ -119,6 +121,7 @@ export default function WidgetContainerPermutations() { { id: "two", text: "Two" }, ]} variant="icon" + expandToViewport={true} /> } i18nStrings={i18nStrings.boardItemI18nStrings} diff --git a/pages/with-app-layout/widgets-board.tsx b/pages/with-app-layout/widgets-board.tsx index 79fac5be..e0083067 100644 --- a/pages/with-app-layout/widgets-board.tsx +++ b/pages/with-app-layout/widgets-board.tsx @@ -44,6 +44,7 @@ export function WidgetsBoard({ loading, widgets, onWidgetsChange }: WidgetsBoard ariaLabel={clientI18nStrings.widgetsBoard.widgetSettings} variant="icon" onItemClick={() => setDeleteConfirmation(item.id)} + expandToViewport={true} /> } i18nStrings={boardItemI18nStrings} diff --git a/src/board-item/styles.scss b/src/board-item/styles.scss index bfbe84ad..92f9a012 100644 --- a/src/board-item/styles.scss +++ b/src/board-item/styles.scss @@ -17,7 +17,8 @@ .header { display: flex; justify-items: center; - padding: cs.$space-scaled-s calc(#{cs.$space-container-horizontal} - #{cs.$space-scaled-xs}); + padding-block: cs.$space-scaled-s; + padding-inline: calc(#{cs.$space-container-horizontal} - #{cs.$space-scaled-xs}); } .flexible { @@ -25,21 +26,21 @@ } .handle { - margin-top: calc(cs.$space-scaled-xxs + 1px); + margin-block-start: calc(cs.$space-scaled-xxs + 1px); .refresh > & { - margin-top: calc(cs.$space-static-xxxs + 1px); + margin-block-start: calc(cs.$space-static-xxxs + 1px); } } .header-content { - margin-left: cs.$space-scaled-xxs; + margin-inline-start: cs.$space-scaled-xxs; } .settings { - margin-top: calc(cs.$space-scaled-xxxs + 1px); - margin-left: cs.$space-static-xs; + margin-block-start: calc(cs.$space-scaled-xxxs + 1px); + margin-inline-start: cs.$space-static-xs; .refresh > & { - margin-top: 0px; + margin-block-start: 0px; } } @@ -50,6 +51,6 @@ .resizer { position: absolute; // offset for inner paddings in the handle - bottom: calc(#{cs.$space-static-xs} - #{cs.$space-static-xxxs}); - right: calc(#{cs.$space-static-xs} - #{cs.$space-static-xxxs}); + inset-block-end: calc(#{cs.$space-static-xs} - #{cs.$space-static-xxxs}); + inset-inline-end: calc(#{cs.$space-static-xs} - #{cs.$space-static-xxxs}); } diff --git a/src/board/internal.tsx b/src/board/internal.tsx index aca6e09c..d2b25f27 100644 --- a/src/board/internal.tsx +++ b/src/board/internal.tsx @@ -22,6 +22,7 @@ import { interpretItems, } from "../internal/utils/layout"; import { Position } from "../internal/utils/position"; +import { useIsRtl } from "../internal/utils/screen"; import { useAutoScroll } from "../internal/utils/use-auto-scroll"; import { useMergeRefs } from "../internal/utils/use-merge-refs"; @@ -47,11 +48,13 @@ export function InternalBoard({ const containerRef = useMergeRefs(containerAccessRef, containerQueryRef); const itemContainerRef = useRef<{ [id: ItemId]: ItemContainerRef }>({}); + const isRtl = useIsRtl(containerAccessRef); + useGlobalDragStateStyles(); const autoScrollHandlers = useAutoScroll(); - const [transitionState, dispatch] = useTransition(); + const [transitionState, dispatch] = useTransition({ isRtl }); const transition = transitionState.transition; const removeTransition = transitionState.removeTransition; const transitionAnnouncement = transitionState.announcement; @@ -112,11 +115,14 @@ export function InternalBoard({ function isElementOverBoard(rect: Rect) { const board = containerAccessRef.current!; const boardContains = (target: null | Element) => board === target || board.contains(target); + const left = !isRtl() ? rect.left : document.documentElement.clientWidth - rect.left; + const right = !isRtl() ? rect.right : document.documentElement.clientWidth - rect.right; + const { top, bottom } = rect; return ( - boardContains(document.elementFromPoint(rect.left, rect.top)) || - boardContains(document.elementFromPoint(rect.right, rect.top)) || - boardContains(document.elementFromPoint(rect.right, rect.bottom)) || - boardContains(document.elementFromPoint(rect.left, rect.bottom)) + boardContains(document.elementFromPoint(left, top)) || + boardContains(document.elementFromPoint(right, top)) || + boardContains(document.elementFromPoint(right, bottom)) || + boardContains(document.elementFromPoint(left, bottom)) ); } @@ -228,7 +234,11 @@ export function InternalBoard({
{rows > 0 ? ( - + {(gridContext) => { const layoutShift = transition?.layoutShift ?? removeTransition?.layoutShift; const transforms = layoutShift ? createTransforms(itemsLayout, layoutShift.moves, gridContext) : {}; @@ -289,6 +299,7 @@ export function InternalBoard({ maxHeight: gridContext.getHeight(itemMaxSize.height), })} onKeyMove={onItemMove} + isRtl={isRtl} > {item.id === acquiredItem?.id && acquiredItemElement ? () => acquiredItemElement diff --git a/src/board/styles.scss b/src/board/styles.scss index 8b080722..a02676f3 100644 --- a/src/board/styles.scss +++ b/src/board/styles.scss @@ -2,7 +2,7 @@ .placeholder { border-radius: cs.$border-radius-container; - height: 100%; + block-size: 100%; &--active { background-color: cs.$color-board-placeholder-active; } @@ -17,8 +17,10 @@ .empty { box-sizing: border-box; - width: 100%; - padding: cs.$space-scaled-m cs.$space-scaled-l cs.$space-scaled-l; + inline-size: 100%; + padding-block-start: cs.$space-scaled-m; + padding-block-end: cs.$space-scaled-l; + padding-inline: cs.$space-scaled-l; color: cs.$color-text-empty; display: flex; justify-content: center; diff --git a/src/board/transition.ts b/src/board/transition.ts index d5af9959..c31a2453 100644 --- a/src/board/transition.ts +++ b/src/board/transition.ts @@ -7,6 +7,7 @@ import { LayoutEngine } from "../internal/layout-engine/engine"; import { Coordinates } from "../internal/utils/coordinates"; import { getDefaultColumnSpan, getDefaultRowSpan, getMinColumnSpan, getMinRowSpan } from "../internal/utils/layout"; import { Position } from "../internal/utils/position"; +import { getLogicalBoundingClientRect } from "../internal/utils/screen"; import { BoardProps, RemoveTransition, Transition, TransitionAnnouncement } from "./interfaces"; import { createOperationAnnouncement } from "./utils/announcements"; import { getHoveredRect } from "./utils/get-hovered-rect"; @@ -66,31 +67,37 @@ interface AcquireItemAction { acquiredItemElement?: ReactNode; } -export function useTransition(): [TransitionState, Dispatch>] { - return useReducer(transitionReducer, { transition: null, removeTransition: null, announcement: null }); +export function useTransition({ isRtl }: { isRtl: () => boolean }): [TransitionState, Dispatch>] { + return useReducer(createTransitionReducer({ isRtl }), { + transition: null, + removeTransition: null, + announcement: null, + }); } export function selectTransitionRows(state: TransitionState) { return state.transition ? getLayoutRows(state.transition) : 0; } -function transitionReducer(state: TransitionState, action: Action): TransitionState { - switch (action.type) { - case "init": - return initTransition(action); - case "init-remove": - return initRemoveTransition(action); - case "submit": - return submitTransition(state); - case "discard": - return discardTransition(state); - case "update-with-pointer": - return updateTransitionWithPointerEvent(state, action); - case "update-with-keyboard": - return updateTransitionWithKeyboardEvent(state, action); - case "acquire-item": - return acquireTransitionItem(state, action); - } +function createTransitionReducer({ isRtl }: { isRtl: () => boolean }) { + return function transitionReducer(state: TransitionState, action: Action): TransitionState { + switch (action.type) { + case "init": + return initTransition(action); + case "init-remove": + return initRemoveTransition(action); + case "submit": + return submitTransition(state); + case "discard": + return discardTransition(state); + case "update-with-pointer": + return updateTransitionWithPointerEvent(state, action); + case "update-with-keyboard": + return updateTransitionWithKeyboardEvent(state, action, { isRtl }); + case "acquire-item": + return acquireTransitionItem(state, action); + } + }; } function initTransition({ @@ -256,6 +263,7 @@ function updateTransitionWithPointerEvent( function updateTransitionWithKeyboardEvent( state: TransitionState, { direction }: UpdateWithKeyboardAction, + { isRtl }: { isRtl: () => boolean }, ): TransitionState { const { transition } = state; @@ -299,9 +307,9 @@ function updateTransitionWithKeyboardEvent( switch (direction) { case "left": - return updateManualItemTransition(transition, "left"); + return updateManualItemTransition(transition, !isRtl() ? "left" : "right"); case "right": - return updateManualItemTransition(transition, "right"); + return updateManualItemTransition(transition, !isRtl() ? "right" : "left"); case "up": return updateManualItemTransition(transition, "up"); case "down": @@ -321,9 +329,10 @@ function acquireTransitionItem( const { columns } = transition.itemsLayout; - const layoutRect = layoutElement.getBoundingClientRect(); + const layoutRect = getLogicalBoundingClientRect(layoutElement); const itemRect = transition.draggableRect; - const offset = new Coordinates({ x: itemRect.left - layoutRect.x, y: itemRect.top - layoutRect.y }); + const coordinatesX = itemRect.left - layoutRect.insetInlineStart; + const offset = new Coordinates({ x: coordinatesX, y: itemRect.top - layoutRect.insetBlockStart }); const insertionDirection = getInsertionDirection(offset); // Update original insertion position if the item can't fit into the layout by width. diff --git a/src/internal/global-drag-state-styles/styles.scss b/src/internal/global-drag-state-styles/styles.scss index 72b41648..2a67ee04 100644 --- a/src/internal/global-drag-state-styles/styles.scss +++ b/src/internal/global-drag-state-styles/styles.scss @@ -1,9 +1,15 @@ +@use "../shared.scss" as shared; + .show-grab-cursor * { cursor: grabbing; } .show-resize-cursor * { cursor: nwse-resize; + + @include shared.with-direction('rtl') { + cursor: nesw-resize; + } } .disable-selection * { diff --git a/src/internal/grid/grid.tsx b/src/internal/grid/grid.tsx index 5d08da23..d27cdf04 100644 --- a/src/internal/grid/grid.tsx +++ b/src/internal/grid/grid.tsx @@ -24,7 +24,7 @@ const ROWSPAN_HEIGHT = { compact: 76, }; -export default function Grid({ layout, children: render, columns }: GridProps) { +export default function Grid({ layout, children: render, columns, isRtl }: GridProps) { const gridRef = useRef(null); const [gridWidth, containerQueryRef] = useContainerQuery((entry) => entry.contentBoxWidth, []); const densityMode = useDensityMode(gridRef); @@ -38,7 +38,10 @@ export default function Grid({ layout, children: render, columns }: GridProps) { return colspan * cellWidth + (colspan - 1) * gridGap; }; const getHeight = (rowspan: number) => rowspan * rowspanHeight + (rowspan - 1) * gridGap; - const getColOffset = (x: number) => getWidth(x) + gridGap; + const getColOffset = (x: number) => { + const offset = getWidth(x) + gridGap; + return !isRtl?.() ? offset : -offset; + }; const getRowOffset = (y: number) => getHeight(y) + gridGap; const gridContext = { getWidth, getHeight, getColOffset, getRowOffset }; diff --git a/src/internal/grid/interfaces.ts b/src/internal/grid/interfaces.ts index 712ecd0a..212d8d84 100644 --- a/src/internal/grid/interfaces.ts +++ b/src/internal/grid/interfaces.ts @@ -8,6 +8,7 @@ export interface GridProps { layout: GridLayoutItem[]; columns: number; children?: (context: GridContext) => ReactNode; + isRtl?: () => boolean; } export interface GridContext { diff --git a/src/internal/handle/styles.scss b/src/internal/handle/styles.scss index e237ffff..53b7c437 100644 --- a/src/internal/handle/styles.scss +++ b/src/internal/handle/styles.scss @@ -4,7 +4,8 @@ appearance: none; background: transparent; border: none; - padding: cs.$space-scaled-xxs; + padding-block: cs.$space-scaled-xxs; + padding-inline: cs.$space-scaled-xxs; color: cs.$color-text-interactive-default; diff --git a/src/internal/item-container/__tests__/get-next-droppable.test.ts b/src/internal/item-container/__tests__/get-next-droppable.test.ts index e1be84a4..caae61e2 100644 --- a/src/internal/item-container/__tests__/get-next-droppable.test.ts +++ b/src/internal/item-container/__tests__/get-next-droppable.test.ts @@ -7,7 +7,7 @@ import { getNextDroppable } from "../../../../lib/components/internal/item-conta function getMockElement({ left, right, top, bottom }: Rect) { return { - getBoundingClientRect: () => ({ left, right, top, bottom }), + getBoundingClientRect: () => ({ left, right, top, bottom, width: right - left, height: bottom - top }), ownerDocument: { defaultView: { pageXOffset: 0, @@ -19,19 +19,22 @@ function getMockElement({ left, right, top, bottom }: Rect) { test("returns null if there are no droppables", () => { const elementMock = getMockElement({ left: 0, right: 0, top: 0, bottom: 0 }); - expect(getNextDroppable(elementMock, [], "left")).toBe(null); + expect(getNextDroppable({ draggableElement: elementMock, droppables: [], direction: "left", isRtl: false })).toBe( + null, + ); }); test("returns next droppable matching the direction", () => { const elementMock = getMockElement({ left: 6, right: 4, top: 0, bottom: 0 }); - const next = getNextDroppable( - elementMock, - [ + const next = getNextDroppable({ + draggableElement: elementMock, + droppables: [ ["1", { element: getMockElement({ left: 0, right: 10, top: 0, bottom: 0 }) } as Droppable], ["2", { element: getMockElement({ left: 5, right: 5, top: 0, bottom: 0 }) } as Droppable], ["3", { element: getMockElement({ left: 10, right: 0, top: 0, bottom: 0 }) } as Droppable], ], - "right", - ); + direction: "right", + isRtl: false, + }); expect(next).toBe("2"); }); diff --git a/src/internal/item-container/__tests__/item-container.test.tsx b/src/internal/item-container/__tests__/item-container.test.tsx index 3ac47089..033ded52 100644 --- a/src/internal/item-container/__tests__/item-container.test.tsx +++ b/src/internal/item-container/__tests__/item-container.test.tsx @@ -22,6 +22,7 @@ const defaultProps: ItemContainerProps = { inTransition: false, getItemSize: () => ({ width: 1, minWidth: 1, maxWidth: 1, height: 1, minHeight: 1, maxHeight: 1 }), children: () => , + isRtl: () => false, }; function Item() { diff --git a/src/internal/item-container/get-next-droppable.ts b/src/internal/item-container/get-next-droppable.ts index 952b089e..911689e1 100644 --- a/src/internal/item-container/get-next-droppable.ts +++ b/src/internal/item-container/get-next-droppable.ts @@ -10,13 +10,24 @@ import { getNormalizedElementRect } from "../utils/screen"; * Finds closest droppable to provided draggable element and direction. * Returns null if there is no droppable in the given direction. */ -export function getNextDroppable( - draggableElement: HTMLElement, - droppables: readonly [ItemId, Droppable][], - direction: Direction, -): null | ItemId { +export function getNextDroppable({ + draggableElement, + droppables, + direction, + isRtl, +}: { + draggableElement: HTMLElement; + droppables: readonly [ItemId, Droppable][]; + direction: Direction; + isRtl: boolean; +}): null | ItemId { const draggableRect = getNormalizedElementRect(draggableElement); const sources = new Map(droppables.map(([id, d]) => [getNormalizedElementRect(d.element), id])); - const closest = getClosestNeighbor(draggableRect, [...sources.keys()], direction); + const closest = getClosestNeighbor({ + target: draggableRect, + sources: [...sources.keys()], + direction, + isRtl, + }); return sources.get(closest as DOMRect) ?? null; } diff --git a/src/internal/item-container/index.tsx b/src/internal/item-container/index.tsx index e217f09d..f8f88844 100644 --- a/src/internal/item-container/index.tsx +++ b/src/internal/item-container/index.tsx @@ -28,7 +28,7 @@ import { } from "../dnd-controller/controller"; import { BoardItemDefinitionBase, Direction, ItemId, Transform } from "../interfaces"; import { Coordinates } from "../utils/coordinates"; -import { getNormalizedElementRect } from "../utils/screen"; +import { getLogicalBoundingClientRect, getLogicalClientX, getNormalizedElementRect } from "../utils/screen"; import { throttle } from "../utils/throttle"; import { getCollisionRect } from "./get-collision-rect"; import { getNextDroppable } from "./get-next-droppable"; @@ -99,12 +99,13 @@ export interface ItemContainerProps { }; onKeyMove?(direction: Direction): void; children: (hasDropTarget: boolean) => ReactNode; + isRtl: () => boolean; } export const ItemContainer = forwardRef(ItemContainerComponent); function ItemContainerComponent( - { item, placed, acquired, inTransition, transform, getItemSize, onKeyMove, children }: ItemContainerProps, + { item, placed, acquired, inTransition, transform, getItemSize, onKeyMove, children, isRtl }: ItemContainerProps, ref: Ref, ) { const originalSizeRef = useRef({ width: 0, height: 0 }); @@ -176,7 +177,7 @@ function ItemContainerComponent( const transitionItemId = transition?.itemId ?? null; useEffect(() => { const onPointerMove = throttle((event: PointerEvent) => { - const coordinates = Coordinates.fromEvent(event); + const coordinates = Coordinates.fromEvent(event, { isRtl: isRtl() }); draggableApi.updateTransition( new Coordinates({ x: Math.max(coordinates.x, pointerBoundariesRef.current?.x ?? Number.NEGATIVE_INFINITY), @@ -245,7 +246,12 @@ function ItemContainerComponent( function handleInsert(direction: Direction) { // Find the closest droppable (in the direction) to the item. const droppables = draggableApi.getDroppables(); - const nextDroppable = getNextDroppable(itemRef.current!, droppables, direction); + const nextDroppable = getNextDroppable({ + draggableElement: itemRef.current!, + droppables, + direction, + isRtl: isRtl(), + }); if (!nextDroppable) { // TODO: add announcement @@ -308,12 +314,17 @@ function ItemContainerComponent( function onDragHandlePointerDown(event: ReactPointerEvent) { // Calculate the offset between item's top-left corner and the pointer landing position. - const rect = itemRef.current!.getBoundingClientRect(); - pointerOffsetRef.current = new Coordinates({ x: event.clientX - rect.left, y: event.clientY - rect.top }); - originalSizeRef.current = { width: rect.width, height: rect.height }; + const rect = getLogicalBoundingClientRect(itemRef.current!); + const clientX = getLogicalClientX(event, isRtl()); + const clientY = event.clientY; + pointerOffsetRef.current = new Coordinates({ + x: clientX - rect.insetInlineStart, + y: clientY - rect.insetBlockStart, + }); + originalSizeRef.current = { width: rect.inlineSize, height: rect.blockSize }; pointerBoundariesRef.current = null; - draggableApi.start(!placed ? "insert" : "reorder", "pointer", Coordinates.fromEvent(event)); + draggableApi.start(!placed ? "insert" : "reorder", "pointer", Coordinates.fromEvent(event, { isRtl: isRtl() })); } function onDragHandleKeyDown(event: KeyboardEvent) { @@ -322,19 +333,21 @@ function ItemContainerComponent( function onResizeHandlePointerDown(event: ReactPointerEvent) { // Calculate the offset between item's bottom-right corner and the pointer landing position. - const rect = itemRef.current!.getBoundingClientRect(); - pointerOffsetRef.current = new Coordinates({ x: event.clientX - rect.right, y: event.clientY - rect.bottom }); - originalSizeRef.current = { width: rect.width, height: rect.height }; + const rect = getLogicalBoundingClientRect(itemRef.current!); + const clientX = getLogicalClientX(event, isRtl()); + const clientY = event.clientY; + pointerOffsetRef.current = new Coordinates({ x: clientX - rect.insetInlineEnd, y: clientY - rect.insetBlockEnd }); + originalSizeRef.current = { width: rect.inlineSize, height: rect.blockSize }; // Calculate boundaries below which the cursor cannot move. const minWidth = getItemSize(null).minWidth; const minHeight = getItemSize(null).minHeight; pointerBoundariesRef.current = new Coordinates({ - x: event.clientX - rect.width + minWidth, - y: event.clientY - rect.height + minHeight, + x: clientX - rect.inlineSize + minWidth, + y: clientY - rect.blockSize + minHeight, }); - draggableApi.start("resize", "pointer", Coordinates.fromEvent(event)); + draggableApi.start("resize", "pointer", Coordinates.fromEvent(event, { isRtl: isRtl() })); } function onResizeHandleKeyDown(event: KeyboardEvent) { @@ -351,10 +364,10 @@ function ItemContainerComponent( if (transition && transition.interactionType === "pointer") { // Adjust the dragged/resized item to the pointer's location. itemTransitionClassNames.push(transition.operation === "resize" ? styles.resized : styles.dragged); - itemTransitionStyle.left = transition.positionTransform?.x; - itemTransitionStyle.top = transition.positionTransform?.y; - itemTransitionStyle.width = transition.sizeTransform?.width; - itemTransitionStyle.height = transition.sizeTransform?.height; + itemTransitionStyle.insetInlineStart = transition.positionTransform?.x; + itemTransitionStyle.insetBlockStart = transition.positionTransform?.y; + itemTransitionStyle.inlineSize = transition.sizeTransform?.width; + itemTransitionStyle.blockSize = transition.sizeTransform?.height; itemTransitionStyle.pointerEvents = "none"; } diff --git a/src/internal/item-container/styles.scss b/src/internal/item-container/styles.scss index 0fdf328d..3fe6503d 100644 --- a/src/internal/item-container/styles.scss +++ b/src/internal/item-container/styles.scss @@ -1,7 +1,7 @@ .root { touch-action: none; position: relative; - height: 100%; + block-size: 100%; } .inTransition { diff --git a/src/internal/resize-handle/styles.scss b/src/internal/resize-handle/styles.scss index f10f1ea9..6c852392 100644 --- a/src/internal/resize-handle/styles.scss +++ b/src/internal/resize-handle/styles.scss @@ -2,6 +2,10 @@ .handle { cursor: nwse-resize; + + @include shared.with-direction('rtl') { + cursor: nesw-resize; + } } .handle:not(.active):focus-visible { diff --git a/src/internal/screenreader-grid-navigation/styles.scss b/src/internal/screenreader-grid-navigation/styles.scss index 12c6678a..3d9d280d 100644 --- a/src/internal/screenreader-grid-navigation/styles.scss +++ b/src/internal/screenreader-grid-navigation/styles.scss @@ -5,7 +5,8 @@ .screen-reader-navigation-visible { position: fixed; background: white; - padding: 8px; + padding-block: 8px; + padding-inline: 8px; border: 1px solid black; z-index: 10001; } diff --git a/src/internal/screenreader-only/styles.scss b/src/internal/screenreader-only/styles.scss index 7e75bce8..5f6bd960 100644 --- a/src/internal/screenreader-only/styles.scss +++ b/src/internal/screenreader-only/styles.scss @@ -5,6 +5,6 @@ .root { position: absolute !important; - top: -9999px !important; - left: -9999px !important; + inset-block-start: -9999px !important; + inset-inline-start: -9999px !important; } diff --git a/src/internal/shared.scss b/src/internal/shared.scss index eab76e15..349f0cc7 100644 --- a/src/internal/shared.scss +++ b/src/internal/shared.scss @@ -11,11 +11,17 @@ display: block; position: absolute; box-sizing: border-box; - left: calc(-1 * #{$gutter}); - top: calc(-1 * #{$gutter}); - width: calc(100% + 2 * #{$gutter}); - height: calc(100% + 2 * #{$gutter}); + inset-inline-start: calc(-1 * #{$gutter}); + inset-block-start: calc(-1 * #{$gutter}); + inline-size: calc(100% + 2 * #{$gutter}); + block-size: calc(100% + 2 * #{$gutter}); border-radius: $border-radius; border: 2px solid cs.$color-border-item-focused; } } + +@mixin with-direction($direction) { + &:dir(#{$direction}) { + @content; + } +} diff --git a/src/internal/utils/__tests__/rects.test.ts b/src/internal/utils/__tests__/rects.test.ts index a84482a5..85d2a21e 100644 --- a/src/internal/utils/__tests__/rects.test.ts +++ b/src/internal/utils/__tests__/rects.test.ts @@ -95,16 +95,72 @@ describe("getGridPlacement", () => { describe("getClosestNeighbor", () => { test("returns null if can't find a neighbor in the given direction", () => { - expect(getClosestNeighbor({ left: -2, right: -1, top: 0, bottom: 1 }, grid, "left")).toBe(null); - expect(getClosestNeighbor({ left: 12, right: 13, top: 0, bottom: 1 }, grid, "right")).toBe(null); - expect(getClosestNeighbor({ left: 0, right: 1, top: -2, bottom: -1 }, grid, "up")).toBe(null); - expect(getClosestNeighbor({ left: 0, right: 1, top: 8, bottom: 9 }, grid, "down")).toBe(null); + expect( + getClosestNeighbor({ + target: { left: -2, right: -1, top: 0, bottom: 1 }, + sources: grid, + direction: "left", + isRtl: false, + }), + ).toBe(null); + expect( + getClosestNeighbor({ + target: { left: 12, right: 13, top: 0, bottom: 1 }, + sources: grid, + direction: "right", + isRtl: false, + }), + ).toBe(null); + expect( + getClosestNeighbor({ + target: { left: 0, right: 1, top: -2, bottom: -1 }, + sources: grid, + direction: "up", + isRtl: false, + }), + ).toBe(null); + expect( + getClosestNeighbor({ + target: { left: 0, right: 1, top: 8, bottom: 9 }, + sources: grid, + direction: "down", + isRtl: false, + }), + ).toBe(null); }); test("returns closest grid cell in the given direction", () => { - expect(getClosestNeighbor({ left: -2, right: -1, top: 3, bottom: 4 }, grid, "right")).toBe(grid[3]); - expect(getClosestNeighbor({ left: 12, right: 13, top: 3, bottom: 4 }, grid, "left")).toBe(grid[5]); - expect(getClosestNeighbor({ left: 5, right: 6, top: -2, bottom: -1 }, grid, "down")).toBe(grid[1]); - expect(getClosestNeighbor({ left: 5, right: 6, top: 8, bottom: 9 }, grid, "up")).toBe(grid[4]); + expect( + getClosestNeighbor({ + target: { left: -2, right: -1, top: 3, bottom: 4 }, + sources: grid, + direction: "right", + isRtl: false, + }), + ).toBe(grid[3]); + expect( + getClosestNeighbor({ + target: { left: 12, right: 13, top: 3, bottom: 4 }, + sources: grid, + direction: "left", + isRtl: false, + }), + ).toBe(grid[5]); + expect( + getClosestNeighbor({ + target: { left: 5, right: 6, top: -2, bottom: -1 }, + sources: grid, + direction: "down", + isRtl: false, + }), + ).toBe(grid[1]); + expect( + getClosestNeighbor({ + target: { left: 5, right: 6, top: 8, bottom: 9 }, + sources: grid, + direction: "up", + isRtl: false, + }), + ).toBe(grid[4]); }); }); diff --git a/src/internal/utils/coordinates.ts b/src/internal/utils/coordinates.ts index d496e591..80b0ca67 100644 --- a/src/internal/utils/coordinates.ts +++ b/src/internal/utils/coordinates.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { PointerEvent as ReactPointerEvent } from "react"; +import { getLogicalClientX } from "./screen"; export class Coordinates { readonly __type = "Coordinates"; @@ -10,8 +11,10 @@ export class Coordinates { readonly scrollX = window.scrollX; readonly scrollY = window.scrollY; - static fromEvent(event: PointerEvent | ReactPointerEvent): Coordinates { - return new Coordinates({ x: event.clientX, y: event.clientY }); + static fromEvent(event: PointerEvent | ReactPointerEvent, { isRtl }: { isRtl: boolean }): Coordinates { + const clientX = getLogicalClientX(event, isRtl); + const clientY = event.clientY; + return new Coordinates({ x: clientX, y: clientY }); } static cursorOffset(current: Coordinates, start: Coordinates): Coordinates { diff --git a/src/internal/utils/rects.ts b/src/internal/utils/rects.ts index 3c56cc6e..03f9e94b 100644 --- a/src/internal/utils/rects.ts +++ b/src/internal/utils/rects.ts @@ -67,11 +67,27 @@ export function getGridPlacement(target: Rect, grid: readonly Rect[]): Rect { return placement; } -export function getClosestNeighbor(target: Rect, sources: readonly Rect[], direction: Direction): null | Rect { +export function getClosestNeighbor({ + target, + sources, + direction, + isRtl, +}: { + target: Rect; + sources: readonly Rect[]; + direction: Direction; + isRtl: boolean; +}): null | Rect { const getFirst = (rects: Rect[]) => rects[0] ?? null; const verticalDiff = (r1: Rect, r2: Rect) => Math.abs(r1.top - target.top) - Math.abs(r2.top - target.top); const horizontalDiff = (r1: Rect, r2: Rect) => Math.abs(r1.left - target.left) - Math.abs(r2.left - target.left); + if (isRtl && direction === "left") { + direction = "right"; + } else if (isRtl && direction === "right") { + direction = "left"; + } + switch (direction) { case "left": return getFirst( diff --git a/src/internal/utils/screen.ts b/src/internal/utils/screen.ts index 306a5cc0..5587cfef 100644 --- a/src/internal/utils/screen.ts +++ b/src/internal/utils/screen.ts @@ -1,18 +1,71 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import { PointerEvent as ReactPointerEvent } from "react"; -export function getNormalizedElementRect(element: HTMLElement): DOMRect { - const { x, y, left, right, top, bottom, width, height } = element.getBoundingClientRect(); +export function getNormalizedElementRect(element: HTMLElement): { + left: number; + right: number; + top: number; + bottom: number; + width: number; + height: number; +} { + const { insetInlineStart, insetInlineEnd, insetBlockStart, insetBlockEnd, inlineSize, blockSize } = + getLogicalBoundingClientRect(element); const xOffset = element.ownerDocument.defaultView!.pageXOffset - window.scrollX; const yOffset = element.ownerDocument.defaultView!.pageYOffset - window.scrollY; return { - x: x + xOffset, - left: left + xOffset, - right: right + xOffset, - y: y + yOffset, - top: top + yOffset, - bottom: bottom + yOffset, - width: width, - height: height, - } as DOMRect; + left: insetInlineStart + xOffset, + right: insetInlineEnd + xOffset, + top: insetBlockStart + yOffset, + bottom: insetBlockEnd + yOffset, + width: inlineSize, + height: blockSize, + }; +} + +export function useIsRtl(elementRef: React.RefObject) { + const getIsRtl = (): boolean => + elementRef.current && elementRef.current instanceof Element + ? getComputedStyle(elementRef.current).direction === "rtl" + : false; + return getIsRtl; +} + +/** + * The clientX position needs to be converted so it is relative to the right of + * the document in order for computations to yield the same result in both + * element directions. + */ +export function getLogicalClientX(event: PointerEvent | ReactPointerEvent, isRtl: boolean) { + return isRtl ? document.documentElement.clientWidth - event.clientX : event.clientX; +} + +/** + * The getBoundingClientRect() function returns values relative to the top left + * corner of the document regardless of document direction. The left/right position + * will be transformed to insetInlineStart based on element direction in order to + * support direction agnostic position computation. + */ +export function getLogicalBoundingClientRect(element: HTMLElement | SVGElement) { + const boundingClientRect = element.getBoundingClientRect(); + + const blockSize = boundingClientRect.height; + const inlineSize = boundingClientRect.width; + const insetBlockStart = boundingClientRect.top; + const insetBlockEnd = boundingClientRect.bottom; + const insetInlineStart = + element instanceof Element && getComputedStyle(element).direction === "rtl" + ? document.documentElement.clientWidth - boundingClientRect.right + : boundingClientRect.left; + const insetInlineEnd = insetInlineStart + inlineSize; + + return { + blockSize, + inlineSize, + insetBlockStart, + insetBlockEnd, + insetInlineStart, + insetInlineEnd, + }; } diff --git a/src/items-palette/internal.tsx b/src/items-palette/internal.tsx index 08f1cbed..7237e0d8 100644 --- a/src/items-palette/internal.tsx +++ b/src/items-palette/internal.tsx @@ -9,6 +9,7 @@ import { ItemId } from "../internal/interfaces"; import { ItemContainer, ItemContainerRef } from "../internal/item-container"; import LiveRegion from "../internal/live-region"; import { ScreenReaderGridNavigation } from "../internal/screenreader-grid-navigation"; +import { useIsRtl } from "../internal/utils/screen"; import { ItemsPaletteProps } from "./interfaces"; import styles from "./styles.css.js"; @@ -24,6 +25,8 @@ export function InternalItemsPalette({ const [dropState, setDropState] = useState<{ id: string }>(); const [announcement, setAnnouncement] = useState(""); + const isRtl = useIsRtl(paletteRef); + function focusItem(itemId: ItemId) { itemContainerRef.current[itemId].focusDragHandle(); } @@ -109,6 +112,7 @@ export function InternalItemsPalette({ const { width, height } = dropContext.scale(item); return { width, minWidth: width, maxWidth: width, height, minHeight: height, maxHeight: height }; }} + isRtl={isRtl} > {(hasDropTarget) => renderItem(item, { showPreview: hasDropTarget })}