From a69344973dd704d411cd26de9667a0cb0275d482 Mon Sep 17 00:00:00 2001 From: Ernst Kaese Date: Wed, 8 Jul 2026 10:15:26 +0000 Subject: [PATCH 1/2] feat(tabs): reorderable, pinned, and add-tab support (within-list) --- pages/tabs/reorderable.page.tsx | 132 ++++++++++ src/i18n/messages-types.ts | 19 ++ src/i18n/messages/all.en.json | 9 +- .../__tests__/sortable-area.test.tsx | 178 ++++++++++++- .../components/sortable-area/index.tsx | 41 ++- .../components/sortable-area/interfaces.ts | 8 + .../sortable-area/keyboard-sensor/index.ts | 30 ++- .../use-drag-and-drop-reorder.ts | 40 ++- src/tabs/__tests__/tabs.test.tsx | 235 ++++++++++++++++++ src/tabs/index.tsx | 13 +- src/tabs/interfaces.ts | 79 +++++- src/tabs/reorder-live-announcements.ts | 28 +++ src/tabs/tab-header-bar.scss | 12 + src/tabs/tab-header-bar.tsx | 175 +++++++++++-- src/tabs/test-classes/styles.scss | 8 + src/test-utils/dom/tabs/index.ts | 25 ++ 16 files changed, 991 insertions(+), 41 deletions(-) create mode 100644 pages/tabs/reorderable.page.tsx create mode 100644 src/tabs/reorder-live-announcements.ts diff --git a/pages/tabs/reorderable.page.tsx b/pages/tabs/reorderable.page.tsx new file mode 100644 index 0000000000..5476245ea0 --- /dev/null +++ b/pages/tabs/reorderable.page.tsx @@ -0,0 +1,132 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; + +import { Button } from '~components'; +import SpaceBetween from '~components/space-between'; +import Tabs, { TabsProps } from '~components/tabs'; + +const reorderI18n: TabsProps.I18nStrings = { + scrollLeftAriaLabel: 'Scroll left', + scrollRightAriaLabel: 'Scroll right', + tabsWithActionsAriaRoleDescription: 'Reorderable tabs', + reorderDragHandleAriaLabel: 'Drag handle', + reorderDragHandleAriaDescription: + "Use Space or Enter to activate drag for a tab, then use the arrow keys to move the tab's position. To complete the position move, use Space or Enter, or to discard the move, use Escape.", + liveAnnouncementReorderStarted: (position, total) => `Picked up tab at position ${position} of ${total}`, + liveAnnouncementReorderMoved: (from, to, total) => + from === to ? `Moving tab back to position ${to} of ${total}` : `Moving tab to position ${to} of ${total}`, + liveAnnouncementReorderCommitted: (from, to, total) => + from === to + ? `Tab returned to its original position ${to} of ${total}` + : `Tab moved from position ${from} to position ${to} of ${total}`, + liveAnnouncementReorderDiscarded: 'Reordering canceled', + addTabAriaLabel: 'Add new tab', +}; + +function makeInitialTabs(): Array { + return [ + { id: 'home', label: 'Home', disableReorder: true, content:

Home content (pinned)

}, + { id: 'first', label: 'First tab', content:

First content

}, + { + id: 'second', + label: 'Second tab', + dismissible: true, + dismissLabel: 'Dismiss second tab', + content:

Second content (dismissible)

, + }, + { + id: 'third', + label: 'Third tab', + action: + + + ); +} diff --git a/src/i18n/messages-types.ts b/src/i18n/messages-types.ts index 26d43876e3..39c4273c06 100644 --- a/src/i18n/messages-types.ts +++ b/src/i18n/messages-types.ts @@ -559,6 +559,10 @@ export interface I18nFormatArgTypes { finalPosition: string | number; }; 'i18nStrings.liveAnnouncementReorderDiscarded': never; + 'i18nStrings.liveAnnouncementTabMovedAcrossLists': { + targetPosition: string | number; + targetTotal: string | number; + }; 'i18nStrings.addTabAriaLabel': never; }; 'tag-editor': { diff --git a/src/i18n/messages/all.en.json b/src/i18n/messages/all.en.json index 049215fec5..c5352c1033 100644 --- a/src/i18n/messages/all.en.json +++ b/src/i18n/messages/all.en.json @@ -421,6 +421,7 @@ "i18nStrings.liveAnnouncementReorderMoved": "{isInitialPosition, select, true {Moving tab back to position {currentPosition} of {total}} false {Moving tab to position {currentPosition} of {total}} other {}}", "i18nStrings.liveAnnouncementReorderCommitted": "{isInitialPosition, select, true {Tab moved back to its original position {initialPosition} of {total}} false {Tab moved from position {initialPosition} to position {finalPosition} of {total}} other {}}", "i18nStrings.liveAnnouncementReorderDiscarded": "Reordering canceled", + "i18nStrings.liveAnnouncementTabMovedAcrossLists": "Tab moved to position {targetPosition} of {targetTotal} in the destination tab list", "i18nStrings.addTabAriaLabel": "Add new tab" }, "tag-editor": { diff --git a/src/internal/components/sortable-area/index.tsx b/src/internal/components/sortable-area/index.tsx index c7449f9a35..8725486240 100644 --- a/src/internal/components/sortable-area/index.tsx +++ b/src/internal/components/sortable-area/index.tsx @@ -35,6 +35,10 @@ export default function SortableArea({ disableReorder, i18nStrings, direction = 'vertical', + onReorderStart, + onReorderMove, + onReorderEnd, + onReorderCancel, }: SortableAreaProps) { const { activeItemId, setActiveItemId, collisionDetection, handleKeyDown, sensors, isKeyboard } = useDragAndDropReorder({ @@ -59,10 +63,27 @@ export default function SortableArea({ : undefined, container: portalContainer ?? undefined, }} - onDragStart={({ active }) => setActiveItemId(active.id)} + onDragStart={({ active }) => { + setActiveItemId(active.id); + onReorderStart?.(String(active.id)); + }} + onDragMove={({ active }) => { + if (onReorderMove) { + const rect = active.rect.current.translated; + onReorderMove( + String(active.id), + rect ? { left: rect.left, top: rect.top, width: rect.width, height: rect.height } : null + ); + } + }} onDragEnd={event => { setActiveItemId(null); const { active, over } = event; + // Allow an external (cross-list) coordinator to consume the drop; when it does, + // skip the within-list reorder so the tab is not also moved inside this list. + if (onReorderEnd?.(String(active.id))) { + return; + } if (over && active.id !== over.id) { const movedItem = items.find(item => itemDefinition.id(item) === active.id)!; const oldIndex = items.findIndex(item => itemDefinition.id(item) === active.id); @@ -70,7 +91,10 @@ export default function SortableArea({ fireNonCancelableEvent(onItemsChange, { items: arrayMove([...items], oldIndex, newIndex), movedItem }); } }} - onDragCancel={() => setActiveItemId(null)} + onDragCancel={() => { + setActiveItemId(null); + onReorderCancel?.(); + }} > ({ +export function DraggableItem({ item, itemDefinition, dragHandleAriaLabel, @@ -145,6 +169,7 @@ function DraggableItem({ showDirectionButtons, onKeyDown, renderItem, + sortableId, }: { item: Item; itemDefinition: SortableAreaProps.ItemDefinition; @@ -153,8 +178,10 @@ function DraggableItem({ showDirectionButtons: boolean; onKeyDown: (event: React.KeyboardEvent) => void; renderItem: (props: SortableAreaProps.RenderItemProps) => React.ReactNode; + sortableId?: string; }) { - const id = itemDefinition.id(item); + const rawId = itemDefinition.id(item); + const id = sortableId ?? rawId; const { isDragging, isSorting, listeners, setNodeRef, transform, attributes } = useSortable({ id, }); @@ -187,7 +214,7 @@ function DraggableItem({ <> {renderItem({ item, - id, + id: rawId, ref: setNodeRef, style, className, diff --git a/src/internal/components/sortable-area/interfaces.ts b/src/internal/components/sortable-area/interfaces.ts index 2bb9a8942e..ee74990121 100644 --- a/src/internal/components/sortable-area/interfaces.ts +++ b/src/internal/components/sortable-area/interfaces.ts @@ -18,6 +18,26 @@ export interface SortableAreaProps { * it switches the sorting strategy and maps ArrowLeft/ArrowRight (RTL-aware) to reordering. */ direction?: SortableAreaProps.Direction; + /** + * Fired when a pointer/keyboard drag starts inside this area's own `DndContext`. + * Used by cross-list coordinators (e.g. Tabs reorder groups) to begin tracking a drag. + */ + onReorderStart?: (activeItemId: string) => void; + /** + * Fired on every drag move with the dragged item's current translated rectangle + * (viewport coordinates), or `null` if unavailable. Used to hit-test against sibling lists. + */ + onReorderMove?: (activeItemId: string, rect: SortableAreaProps.DragRect | null) => void; + /** + * Fired when a drag ends, before the default within-list reorder is applied. Return `true` + * to signal the drop was consumed by an external (cross-list) target, which suppresses the + * within-list reorder for this drag. + */ + onReorderEnd?: (activeItemId: string) => boolean; + /** + * Fired when a drag is cancelled (e.g. via Escape). + */ + onReorderCancel?: () => void; } export namespace SortableAreaProps { @@ -31,6 +51,14 @@ export namespace SortableAreaProps { export type Direction = 'vertical' | 'horizontal'; + /** Minimal viewport-coordinate rectangle used by cross-list drag hit-testing. */ + export interface DragRect { + left: number; + top: number; + width: number; + height: number; + } + export interface RenderItemProps { ref?: React.RefCallback; item: Item; diff --git a/src/tabs/__tests__/cross-list-move.test.ts b/src/tabs/__tests__/cross-list-move.test.ts new file mode 100644 index 0000000000..734d37de32 --- /dev/null +++ b/src/tabs/__tests__/cross-list-move.test.ts @@ -0,0 +1,100 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { moveTabAcrossLists } from '../../../lib/components/tabs/cross-list-move'; +import { TabsProps } from '../../../lib/components/tabs/interfaces'; + +function tabs(...ids: string[]): TabsProps.Tab[] { + return ids.map(id => ({ id, label: id.toUpperCase() })); +} +function ids(list: readonly TabsProps.Tab[]): string[] { + return list.map(tab => tab.id); +} + +describe('moveTabAcrossLists', () => { + test('returns null when the moved tab does not exist in the source', () => { + expect( + moveTabAcrossLists({ sourceTabs: tabs('a', 'b'), targetTabs: tabs('x'), movedTabId: 'zzz', desiredIndex: 0 }) + ).toBeNull(); + }); + + test('moves a tab to the start of the target list', () => { + const result = moveTabAcrossLists({ + sourceTabs: tabs('a', 'b', 'c'), + targetTabs: tabs('x', 'y'), + movedTabId: 'b', + desiredIndex: 0, + })!; + expect(ids(result.sourceTabs)).toEqual(['a', 'c']); + expect(ids(result.targetTabs)).toEqual(['b', 'x', 'y']); + expect(result.targetIndex).toBe(0); + }); + + test('moves a tab to the middle of the target list', () => { + const result = moveTabAcrossLists({ + sourceTabs: tabs('a', 'b', 'c'), + targetTabs: tabs('x', 'y', 'z'), + movedTabId: 'a', + desiredIndex: 2, + })!; + expect(ids(result.sourceTabs)).toEqual(['b', 'c']); + expect(ids(result.targetTabs)).toEqual(['x', 'y', 'a', 'z']); + expect(result.targetIndex).toBe(2); + }); + + test('moves a tab to the end of the target list', () => { + const result = moveTabAcrossLists({ + sourceTabs: tabs('a', 'b'), + targetTabs: tabs('x', 'y'), + movedTabId: 'a', + desiredIndex: 99, + })!; + expect(ids(result.sourceTabs)).toEqual(['b']); + expect(ids(result.targetTabs)).toEqual(['x', 'y', 'a']); + expect(result.targetIndex).toBe(2); + }); + + test('preserves source order when removing from the middle', () => { + const result = moveTabAcrossLists({ + sourceTabs: tabs('a', 'b', 'c', 'd'), + targetTabs: tabs('x'), + movedTabId: 'c', + desiredIndex: 0, + })!; + expect(ids(result.sourceTabs)).toEqual(['a', 'b', 'd']); + }); + + test('locks a pinned tab at the start of the target so the moved tab lands after it', () => { + const target: TabsProps.Tab[] = [ + { id: 'pin', label: 'PIN', disableReorder: true }, + { id: 'x', label: 'X' }, + { id: 'y', label: 'Y' }, + ]; + const result = moveTabAcrossLists({ + sourceTabs: tabs('a', 'b'), + targetTabs: target, + movedTabId: 'a', + desiredIndex: 0, // tried to drop at the very start, but 'pin' is locked there + })!; + expect(ids(result.targetTabs)).toEqual(['pin', 'a', 'x', 'y']); + expect(result.targetTabs[0].disableReorder).toBe(true); + expect(result.targetIndex).toBe(1); + }); + + test('locks a pinned tab in the middle of the target', () => { + const target: TabsProps.Tab[] = [ + { id: 'x', label: 'X' }, + { id: 'pin', label: 'PIN', disableReorder: true }, + { id: 'y', label: 'Y' }, + ]; + const result = moveTabAcrossLists({ + sourceTabs: tabs('a'), + targetTabs: target, + movedTabId: 'a', + desiredIndex: 3, // drop at the end + })!; + // 'pin' must remain at index 1; the moved tab fills the last non-pinned slot. + expect(ids(result.targetTabs)).toEqual(['x', 'pin', 'y', 'a']); + expect(result.targetTabs[1].id).toBe('pin'); + }); +}); diff --git a/src/tabs/__tests__/cross-list-reorder.test.tsx b/src/tabs/__tests__/cross-list-reorder.test.tsx new file mode 100644 index 0000000000..624e7947e7 --- /dev/null +++ b/src/tabs/__tests__/cross-list-reorder.test.tsx @@ -0,0 +1,307 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useMemo, useState } from 'react'; +import { act, fireEvent, render } from '@testing-library/react'; + +import { getIsRtl } from '@cloudscape-design/component-toolkit/internal'; + +import Tabs, { TabsProps } from '../../../lib/components/tabs'; +import createWrapper, { TabsWrapper } from '../../../lib/components/test-utils/dom'; + +// jsdom cannot derive CSS `direction` from the `dir` attribute, so RTL is simulated by +// controlling the toolkit's `getIsRtl` probe — the same approach the repo already uses in +// responsive-text.test.tsx. The default return (false) matches real jsdom behaviour, so the +// LTR tests exercise the unmocked code path; only the explicit RTL test overrides it. +jest.mock('@cloudscape-design/component-toolkit/internal', () => ({ + ...jest.requireActual('@cloudscape-design/component-toolkit/internal'), + getIsRtl: jest.fn().mockReturnValue(false), +})); + +const i18n: TabsProps.I18nStrings = { + reorderDragHandleAriaLabel: 'Drag handle', + reorderDragHandleAriaDescription: 'Use arrow keys to reorder', + liveAnnouncementTabMovedAcrossLists: (targetPosition, targetTotal) => + `Moved to position ${targetPosition} of ${targetTotal}`, +}; + +function tab(id: string, extra: Partial = {}): TabsProps.Tab { + return { id, label: id.toUpperCase(), content: `${id} content`, ...extra }; +} + +interface HarnessProps { + leftInitial: Array; + rightInitial: Array; + leftGroup?: string; + rightGroup?: string; + leftActive?: string; + rightActive?: string; + onLeftMove?: (detail: TabsProps.TabMoveDetail) => void; + onRightMove?: (detail: TabsProps.TabMoveDetail) => void; +} + +/** + * A controlled two-list consumer that mirrors the intended provider-less API: two + * `reorderable` Tabs sharing a `reorderGroup`, each owning its own `tabs`/`activeTabId` + * state. `onTabMove` fires on both instances with the same detail, so either handler can + * reconcile BOTH lists from the ids carried in the detail (idempotent when called twice). + */ +function TwoLists({ + leftInitial, + rightInitial, + leftGroup = 'group', + rightGroup = 'group', + leftActive: leftActiveInitial = leftInitial[0]?.id, + rightActive: rightActiveInitial = rightInitial[0]?.id, + onLeftMove, + onRightMove, +}: HarnessProps) { + const byId = useMemo(() => { + const map = new Map(); + [...leftInitial, ...rightInitial].forEach(item => map.set(item.id, item)); + return map; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const [left, setLeft] = useState(leftInitial); + const [right, setRight] = useState(rightInitial); + const [leftActive, setLeftActive] = useState(leftActiveInitial); + const [rightActive, setRightActive] = useState(rightActiveInitial); + + const reconcile = (detail: TabsProps.TabMoveDetail) => { + const rebuild = (ids: Array) => ids.map(id => byId.get(id)!); + const leftIds = + detail.sourceGroupTabsId === 'left' + ? detail.sourceTabIds + : detail.targetGroupTabsId === 'left' + ? detail.targetTabIds + : null; + const rightIds = + detail.sourceGroupTabsId === 'right' + ? detail.sourceTabIds + : detail.targetGroupTabsId === 'right' + ? detail.targetTabIds + : null; + if (leftIds) { + setLeft(rebuild(leftIds)); + } + if (rightIds) { + setRight(rebuild(rightIds)); + } + }; + + return ( + <> + setLeftActive(event.detail.activeTabId)} + onReorder={({ detail }) => setLeft(detail.tabIds.map(id => byId.get(id)!))} + onTabMove={({ detail }) => { + onLeftMove?.(detail); + reconcile(detail); + }} + /> + setRightActive(event.detail.activeTabId)} + onReorder={({ detail }) => setRight(detail.tabIds.map(id => byId.get(id)!))} + onTabMove={({ detail }) => { + onRightMove?.(detail); + reconcile(detail); + }} + /> + + ); +} + +function renderTwoLists(props: HarnessProps) { + const result = render(); + const [leftWrapper, rightWrapper] = createWrapper(result.container).findAllTabs(); + return { ...result, leftWrapper, rightWrapper }; +} + +function dragHandleButton(wrapper: TabsWrapper, tabId: string): HTMLElement { + const handle = wrapper.findTabDragHandleByTabId(tabId); + if (!handle) { + throw new Error(`No drag handle rendered for tab "${tabId}"`); + } + const button = handle.getElement().querySelector('[role="button"]'); + if (!button) { + throw new Error(`No interactive drag handle inside the handle span for "${tabId}"`); + } + return button; +} + +function ctrlArrow(el: HTMLElement, key: 'ArrowLeft' | 'ArrowRight') { + fireEvent.keyDown(el, { key, code: key, ctrlKey: true }); +} + +// The moved tab's drag handle is focused inside requestAnimationFrame, after both lists have +// re-rendered; wait a frame's worth of time so the handoff completes. +async function flushFrame() { + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 30)); + }); +} + +function tabIds(wrapper: TabsWrapper): Array { + return wrapper.findTabLinks().map(link => link.getElement().dataset.testid!); +} + +describe('cross-list Tab reorder (provider-less, registry-coordinated)', () => { + beforeEach(() => { + jest.mocked(getIsRtl).mockReturnValue(false); + }); + + test('Ctrl+ArrowRight moves the tab from the left list into the start of the right list, firing onTabMove on both instances', () => { + const onLeftMove = jest.fn(); + const onRightMove = jest.fn(); + const { leftWrapper, rightWrapper } = renderTwoLists({ + leftInitial: [tab('a'), tab('b'), tab('c')], + rightInitial: [tab('x'), tab('y')], + leftActive: 'c', // move a non-active tab, to isolate this from selection handoff + onLeftMove, + onRightMove, + }); + + ctrlArrow(dragHandleButton(leftWrapper, 'a'), 'ArrowRight'); + + // onTabMove is a controlled event that fires on BOTH participating instances with the same detail. + expect(onLeftMove).toHaveBeenCalledTimes(1); + expect(onRightMove).toHaveBeenCalledTimes(1); + const detail: TabsProps.TabMoveDetail = onLeftMove.mock.calls[0][0]; + expect(onRightMove.mock.calls[0][0]).toEqual(detail); + expect(detail).toEqual({ + tabId: 'a', + sourceGroupTabsId: 'left', + targetGroupTabsId: 'right', + targetIndex: 0, + sourceTabIds: ['b', 'c'], + targetTabIds: ['a', 'x', 'y'], + }); + + // Both controlled lists reflect the move. + expect(tabIds(leftWrapper)).toEqual(['b', 'c']); + expect(tabIds(rightWrapper)).toEqual(['a', 'x', 'y']); + }); + + test('hands focus to the moved tab’s drag handle in the target list', async () => { + const { leftWrapper, rightWrapper } = renderTwoLists({ + leftInitial: [tab('a'), tab('b')], + rightInitial: [tab('x')], + leftActive: 'b', + }); + + ctrlArrow(dragHandleButton(leftWrapper, 'a'), 'ArrowRight'); + await flushFrame(); + + expect(dragHandleButton(rightWrapper, 'a')).toHaveFocus(); + }); + + test('selects an adjacent tab in the source list when its active tab is moved away', () => { + const { leftWrapper } = renderTwoLists({ + leftInitial: [tab('a'), tab('b'), tab('c')], + rightInitial: [tab('x')], + leftActive: 'a', // the moved tab is the active one + }); + + ctrlArrow(dragHandleButton(leftWrapper, 'a'), 'ArrowRight'); + + // Selection never lands on nothing: the source falls back to the next adjacent tab. + expect(leftWrapper.findActiveTab()!.getElement().dataset.testid).toBe('b'); + }); + + test('Ctrl+ArrowLeft moves a tab from the right list to the END of the left list', () => { + const onLeftMove = jest.fn(); + const { leftWrapper, rightWrapper } = renderTwoLists({ + leftInitial: [tab('a'), tab('b')], + rightInitial: [tab('x'), tab('y')], + rightActive: 'y', + onLeftMove, + }); + + ctrlArrow(dragHandleButton(rightWrapper, 'x'), 'ArrowLeft'); + + const detail: TabsProps.TabMoveDetail = onLeftMove.mock.calls[0][0]; + expect(detail.sourceGroupTabsId).toBe('right'); + expect(detail.targetGroupTabsId).toBe('left'); + expect(detail.targetTabIds).toEqual(['a', 'b', 'x']); // entered at the far (inline-end) edge + expect(detail.targetIndex).toBe(2); + expect(tabIds(leftWrapper)).toEqual(['a', 'b', 'x']); + expect(tabIds(rightWrapper)).toEqual(['y']); + }); + + test('re-locks a pinned tab in the target list so the moved tab lands after it', () => { + const onRightMove = jest.fn(); + const { leftWrapper, rightWrapper } = renderTwoLists({ + leftInitial: [tab('a')], + rightInitial: [tab('x', { disableReorder: true }), tab('y')], + onRightMove, + }); + + ctrlArrow(dragHandleButton(leftWrapper, 'a'), 'ArrowRight'); + + const detail: TabsProps.TabMoveDetail = onRightMove.mock.calls[0][0]; + // 'x' is pinned at index 0 and stays there; 'a' lands after it. + expect(detail.targetTabIds).toEqual(['x', 'a', 'y']); + expect(detail.targetIndex).toBe(1); + expect(tabIds(rightWrapper)).toEqual(['x', 'a', 'y']); + }); + + test('plain ArrowRight without a modifier key does not cross lists', () => { + const onLeftMove = jest.fn(); + const { leftWrapper } = renderTwoLists({ + leftInitial: [tab('a'), tab('b')], + rightInitial: [tab('x')], + onLeftMove, + }); + + fireEvent.keyDown(dragHandleButton(leftWrapper, 'a'), { key: 'ArrowRight', code: 'ArrowRight' }); + + expect(onLeftMove).not.toHaveBeenCalled(); + }); + + test('tabs in different reorder groups do not exchange', () => { + const onLeftMove = jest.fn(); + const onRightMove = jest.fn(); + const { leftWrapper } = renderTwoLists({ + leftInitial: [tab('a'), tab('b')], + rightInitial: [tab('x')], + leftGroup: 'alpha', + rightGroup: 'beta', + onLeftMove, + onRightMove, + }); + + ctrlArrow(dragHandleButton(leftWrapper, 'a'), 'ArrowRight'); + + expect(onLeftMove).not.toHaveBeenCalled(); + expect(onRightMove).not.toHaveBeenCalled(); + }); + + test('is RTL-aware: Ctrl+ArrowRight enters the sibling list at its inline-end', () => { + jest.mocked(getIsRtl).mockReturnValue(true); + const onRightMove = jest.fn(); + const { leftWrapper, rightWrapper } = renderTwoLists({ + leftInitial: [tab('a')], + rightInitial: [tab('x'), tab('y')], + onRightMove, + }); + + ctrlArrow(dragHandleButton(leftWrapper, 'a'), 'ArrowRight'); + + // In RTL the near edge for a rightward move is the inline-end, so the tab lands at the end. + const detail: TabsProps.TabMoveDetail = onRightMove.mock.calls[0][0]; + expect(detail.targetTabIds).toEqual(['x', 'y', 'a']); + expect(detail.targetIndex).toBe(2); + expect(tabIds(rightWrapper)).toEqual(['x', 'y', 'a']); + }); +}); diff --git a/src/tabs/__tests__/reorder-group-registry.test.ts b/src/tabs/__tests__/reorder-group-registry.test.ts new file mode 100644 index 0000000000..b778b10f9e --- /dev/null +++ b/src/tabs/__tests__/reorder-group-registry.test.ts @@ -0,0 +1,230 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { TabsProps } from '../../../lib/components/tabs/interfaces'; +import { + commitCrossListMove, + getDragState, + getGroupStore, + getMembers, + getSiblings, + registerMember, + ReorderGroupMember, + setDragState, +} from '../../../lib/components/tabs/reorder-group-registry'; + +function tabs(...ids: string[]): TabsProps.Tab[] { + return ids.map(id => ({ id, label: id.toUpperCase() })); +} + +function makeMember( + tabsId: string, + tabList: TabsProps.Tab[], + overrides: Partial = {} +): ReorderGroupMember { + return { + tabsId, + getTabs: () => tabList, + getContainerRect: () => null, + getDropIndex: () => 0, + getOnTabMove: () => undefined, + selectAdjacentAfterRemoval: () => {}, + focusDragHandle: () => {}, + announceReceived: () => {}, + ...overrides, + }; +} + +describe('reorder-group-registry', () => { + beforeEach(() => { + // commitCrossListMove focuses the moved tab's handle inside requestAnimationFrame; + // run it synchronously so the callback can be asserted. + jest.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => { + cb(0); + return 0; + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('registerMember creates the group store on first use and adds the member', () => { + expect(getGroupStore('g-create')).toBeUndefined(); + + const cleanup = registerMember('g-create', makeMember('t1', tabs('a', 'b'))); + + expect(getGroupStore('g-create')).toBeDefined(); + expect(getMembers('g-create').map(member => member.tabsId)).toEqual(['t1']); + + cleanup(); + }); + + test('deregistering the last member deletes the group store (no leak)', () => { + const cleanup = registerMember('g-empty', makeMember('t1', tabs('a'))); + expect(getGroupStore('g-empty')).toBeDefined(); + + cleanup(); + + expect(getGroupStore('g-empty')).toBeUndefined(); + expect(getMembers('g-empty')).toEqual([]); + }); + + test('re-registering the same tabsId replaces (does not duplicate) the member', () => { + const cleanup1 = registerMember('g-dup', makeMember('t1', tabs('a'))); + const cleanup2 = registerMember('g-dup', makeMember('t1', tabs('a', 'b'))); + + expect(getMembers('g-dup').map(member => member.tabsId)).toEqual(['t1']); + expect( + getMembers('g-dup')[0] + .getTabs() + .map(tab => tab.id) + ).toEqual(['a', 'b']); + + cleanup1(); + cleanup2(); + }); + + test('two members in the same group share a single store; getSiblings excludes self', () => { + const cleanup1 = registerMember('g-share', makeMember('left', tabs('a'))); + const store1 = getGroupStore('g-share'); + + const cleanup2 = registerMember('g-share', makeMember('right', tabs('x'))); + const store2 = getGroupStore('g-share'); + + expect(store1).toBe(store2); + expect(getMembers('g-share').map(member => member.tabsId)).toEqual(['left', 'right']); + expect(getSiblings('g-share', 'left').map(member => member.tabsId)).toEqual(['right']); + expect(getSiblings('g-share', 'right').map(member => member.tabsId)).toEqual(['left']); + + // The group survives while one member remains, then is cleaned up. + cleanup1(); + expect(getGroupStore('g-share')).toBeDefined(); + expect(getMembers('g-share').map(member => member.tabsId)).toEqual(['right']); + + cleanup2(); + expect(getGroupStore('g-share')).toBeUndefined(); + }); + + test('setDragState / getDragState round-trip', () => { + const cleanup1 = registerMember('g-drag', makeMember('left', tabs('a'))); + const cleanup2 = registerMember('g-drag', makeMember('right', tabs('x'))); + + expect(getDragState('g-drag')).toBeNull(); + setDragState('g-drag', { sourceTabsId: 'left', draggedTabId: 'a', targetTabsId: 'right', targetIndex: 0 }); + expect(getDragState('g-drag')).toEqual({ + sourceTabsId: 'left', + draggedTabId: 'a', + targetTabsId: 'right', + targetIndex: 0, + }); + + cleanup1(); + cleanup2(); + }); + + test('deregistering a member referenced by an in-progress drag clears the drag state', () => { + const cleanup1 = registerMember('g-drag-clear', makeMember('left', tabs('a'))); + const cleanup2 = registerMember('g-drag-clear', makeMember('right', tabs('x'))); + setDragState('g-drag-clear', { sourceTabsId: 'left', draggedTabId: 'a', targetTabsId: 'right', targetIndex: 0 }); + + cleanup2(); // the drag target leaves mid-drag + + expect(getDragState('g-drag-clear')).toBeNull(); + cleanup1(); + }); + + test('commitCrossListMove moves the tab, returns the detail, and fires source + target callbacks', () => { + const onTabMoveSource = jest.fn(); + const onTabMoveTarget = jest.fn(); + const selectAdjacent = jest.fn(); + const focusHandle = jest.fn(); + const announce = jest.fn(); + + const cleanup1 = registerMember( + 'g-commit', + makeMember('src', tabs('a', 'b', 'c'), { + getOnTabMove: () => onTabMoveSource, + selectAdjacentAfterRemoval: selectAdjacent, + }) + ); + const cleanup2 = registerMember( + 'g-commit', + makeMember('dst', tabs('x', 'y'), { + getOnTabMove: () => onTabMoveTarget, + focusDragHandle: focusHandle, + announceReceived: announce, + }) + ); + + const detail = commitCrossListMove('g-commit', { + sourceTabsId: 'src', + targetTabsId: 'dst', + movedTabId: 'b', + desiredIndex: 1, + }); + + expect(detail).not.toBeNull(); + expect(detail!.tabId).toBe('b'); + expect(detail!.sourceGroupTabsId).toBe('src'); + expect(detail!.targetGroupTabsId).toBe('dst'); + expect(detail!.sourceTabIds).toEqual(['a', 'c']); + expect(detail!.targetTabIds).toEqual(['x', 'b', 'y']); + expect(detail!.targetIndex).toBe(1); + + // onTabMove fires on BOTH instances with the same detail (controlled: consumer updates both lists). + expect(onTabMoveSource).toHaveBeenCalledTimes(1); + expect(onTabMoveTarget).toHaveBeenCalledTimes(1); + expect(onTabMoveSource.mock.calls[0][0].detail).toEqual(detail); + expect(onTabMoveTarget.mock.calls[0][0].detail).toEqual(detail); + + // Source selects an adjacent tab; target announces + focuses the moved tab's handle. + expect(selectAdjacent).toHaveBeenCalledWith('b', ['a', 'c']); + expect(announce).toHaveBeenCalledWith(2, 3); // targetIndex + 1, targetTotal + expect(focusHandle).toHaveBeenCalledWith('b'); + + cleanup1(); + cleanup2(); + }); + + test('commitCrossListMove re-locks pinned tabs in the target list', () => { + const target: TabsProps.Tab[] = [ + { id: 'x', label: 'X', disableReorder: true }, + { id: 'y', label: 'Y' }, + ]; + const cleanup1 = registerMember('g-pinned', makeMember('src', tabs('a', 'b'))); + const cleanup2 = registerMember('g-pinned', makeMember('dst', target)); + + // Try to drop at index 0 — the pinned "x" must stay at index 0, so "a" lands at index 1. + const detail = commitCrossListMove('g-pinned', { + sourceTabsId: 'src', + targetTabsId: 'dst', + movedTabId: 'a', + desiredIndex: 0, + }); + + expect(detail!.targetTabIds).toEqual(['x', 'a', 'y']); + expect(detail!.targetIndex).toBe(1); + + cleanup1(); + cleanup2(); + }); + + test('commitCrossListMove returns null when a member or tab is missing (no-op)', () => { + const cleanup1 = registerMember('g-missing', makeMember('src', tabs('a'))); + + // Unknown target member. + expect( + commitCrossListMove('g-missing', { sourceTabsId: 'src', targetTabsId: 'nope', movedTabId: 'a', desiredIndex: 0 }) + ).toBeNull(); + + const cleanup2 = registerMember('g-missing', makeMember('dst', tabs('x'))); + // Moved tab not present in the source. + expect( + commitCrossListMove('g-missing', { sourceTabsId: 'src', targetTabsId: 'dst', movedTabId: 'zzz', desiredIndex: 0 }) + ).toBeNull(); + + cleanup1(); + cleanup2(); + }); +}); diff --git a/src/tabs/cross-list-move.ts b/src/tabs/cross-list-move.ts new file mode 100644 index 0000000000..e51e9d6e46 --- /dev/null +++ b/src/tabs/cross-list-move.ts @@ -0,0 +1,89 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { TabsProps } from './interfaces'; + +export interface CrossListMoveResult { + /** Source list after the moved tab has been removed. */ + sourceTabs: TabsProps.Tab[]; + /** Target list after the moved tab has been inserted (pinned tabs stay locked). */ + targetTabs: TabsProps.Tab[]; + /** Final index of the moved tab inside `targetTabs`. */ + targetIndex: number; +} + +/** + * Rebuilds `sequence` so that every pinned (`disableReorder`) tab from `originalTarget` + * is restored to its original absolute index, while all non-pinned tabs (including a + * newly inserted one) fill the remaining slots in their current relative order. + * + * This is the cross-list analogue of `reorderWithPinnedLocked`, but the resulting array + * may be one item longer than `originalTarget` because a tab was inserted. + */ +function relockPinned(originalTarget: readonly TabsProps.Tab[], sequence: readonly TabsProps.Tab[]): TabsProps.Tab[] { + const pinnedByIndex = new Map(); + originalTarget.forEach((tab, index) => { + if (tab.disableReorder) { + pinnedByIndex.set(index, tab); + } + }); + if (pinnedByIndex.size === 0) { + return [...sequence]; + } + const pinnedIds = new Set(); + pinnedByIndex.forEach(tab => pinnedIds.add(tab.id)); + const nonPinnedSequence = sequence.filter(tab => !pinnedIds.has(tab.id)); + + const result: TabsProps.Tab[] = new Array(sequence.length); + let cursor = 0; + for (let i = 0; i < result.length; i++) { + const pinned = pinnedByIndex.get(i); + if (pinned) { + result[i] = pinned; + } else { + result[i] = nonPinnedSequence[cursor++]; + } + } + return result; +} + +/** + * Moves a tab from a source list into a target list at a desired insertion index. + * + * - The moved tab is removed from `sourceTabs` (pinned tabs are never draggable, so the + * moved tab is always non-pinned; source pinned tabs keep their positions). + * - The moved tab is inserted into `targetTabs` at `desiredIndex`, then pinned tabs in the + * target are re-locked to their original indices so a pinned tab is never displaced and + * never acts as a drop slot. + * + * Both lists are returned as new arrays; the caller (a controlled consumer) updates the + * corresponding `tabs` props. Returns `null` when the move is a no-op (moved tab not found). + */ +export function moveTabAcrossLists({ + sourceTabs, + targetTabs, + movedTabId, + desiredIndex, +}: { + sourceTabs: readonly TabsProps.Tab[]; + targetTabs: readonly TabsProps.Tab[]; + movedTabId: string; + desiredIndex: number; +}): CrossListMoveResult | null { + const movedTab = sourceTabs.find(tab => tab.id === movedTabId); + if (!movedTab) { + return null; + } + + const sourceTabsAfter = sourceTabs.filter(tab => tab.id !== movedTabId); + + const clampedIndex = Math.max(0, Math.min(desiredIndex, targetTabs.length)); + const naiveInsertion = [...targetTabs.slice(0, clampedIndex), movedTab, ...targetTabs.slice(clampedIndex)]; + const targetTabsAfter = relockPinned(targetTabs, naiveInsertion); + + return { + sourceTabs: sourceTabsAfter, + targetTabs: targetTabsAfter, + targetIndex: targetTabsAfter.findIndex(tab => tab.id === movedTabId), + }; +} diff --git a/src/tabs/index.tsx b/src/tabs/index.tsx index 57f6d1f5f0..30da80e5a3 100644 --- a/src/tabs/index.tsx +++ b/src/tabs/index.tsx @@ -60,6 +60,8 @@ export default function Tabs({ onReorder, addTabButton, onAddTab, + reorderGroup, + onTabMove, ...rest }: TabsProps) { for (const tab of tabs) { @@ -93,6 +95,11 @@ export default function Tabs({ const baseProps = getBaseProps(rest); + // Stable, app-correlatable id for this Tabs instance within a cross-list reorder group. + // Prefer a consumer-provided `id` (so `onTabMove` details are meaningful to the app), + // falling back to the generated namespace when none is set. + const tabsId = (baseProps.id as string | undefined) ?? idNamespace; + const analyticsComponentMetadata: GeneratedAnalyticsMetadataTabsComponent = { name: 'awsui.Tabs', label: `.${analyticsSelectors['tabs-header-list']}`, @@ -167,6 +174,9 @@ export default function Tabs({ onReorder={onReorder} addTabButton={addTabButton} onAddTab={onAddTab} + tabsId={tabsId} + reorderGroup={reorderGroup} + onTabMove={onTabMove} /> ); diff --git a/src/tabs/interfaces.ts b/src/tabs/interfaces.ts index 70ecb34172..c398c6df58 100644 --- a/src/tabs/interfaces.ts +++ b/src/tabs/interfaces.ts @@ -138,6 +138,23 @@ export interface TabsProps extends BaseComponentProps { * Called when the user activates the add-tab button (mouse click, Enter, or Space). */ onAddTab?: NonCancelableEventHandler; + + /** + * Opts this Tabs instance into cross-list reordering. When two or more `reorderable` + * Tabs instances share the same non-empty `reorderGroup` value, tabs can be dragged + * (pointer or keyboard) from one instance into another — no wrapper or provider is + * needed. Has no effect unless the instance is also `reorderable`. + */ + reorderGroup?: string; + + /** + * Called when a tab is moved from one list to another within the same reorder group. + * The event `detail` (`TabMoveDetail`) identifies the moved tab, the source and target + * instances, the target insertion index, and the resulting id order of both lists. + * This is a controlled event: the consumer MUST update BOTH lists' `tabs` arrays in + * response (remove from source, insert into target). Fires on both instances involved. + */ + onTabMove?: NonCancelableEventHandler; } export namespace TabsProps { export type Variant = 'default' | 'container' | 'stacked'; @@ -220,6 +237,35 @@ export namespace TabsProps { // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface AddTabDetail {} + export interface TabMoveDetail { + /** + * The id of the tab that was moved. + */ + tabId: string; + /** + * The `id` of the source Tabs instance the tab was moved out of. + * This is the Tabs instance's own component id (its `BaseComponentProps` id / analytics id). + */ + sourceGroupTabsId: string; + /** + * The `id` of the target Tabs instance the tab was moved into. + */ + targetGroupTabsId: string; + /** + * The index at which the moved tab should be inserted into the target list + * (respecting any pinned/`disableReorder` tabs in the target). + */ + targetIndex: number; + /** + * The resulting id order of the SOURCE list after removing the moved tab. + */ + sourceTabIds: string[]; + /** + * The resulting id order of the TARGET list after inserting the moved tab. + */ + targetTabIds: string[]; + } + export interface ChangeDetail { /** * The ID of the clicked tab. @@ -270,6 +316,13 @@ export namespace TabsProps { * Announced when the user cancels a reorder (e.g. via Escape). */ liveAnnouncementReorderDiscarded?: string; + /** + * Announced when a tab is moved across lists (cross-list reorder). + * Receives the moved tab's target position, the total count in the target list, + * and both source and target list labels are the app's responsibility to convey + * via the provided numbers. + */ + liveAnnouncementTabMovedAcrossLists?: (targetPosition: number, targetTotal: number) => string; /** * ARIA label for the add-tab ("+") button rendered when `addTabButton` is `true`. */ diff --git a/src/tabs/reorder-group-registry.ts b/src/tabs/reorder-group-registry.ts new file mode 100644 index 0000000000..93fabb54f8 --- /dev/null +++ b/src/tabs/reorder-group-registry.ts @@ -0,0 +1,167 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import AsyncStore, { ReadonlyAsyncStore } from '../area-chart/async-store'; +import { fireNonCancelableEvent } from '../internal/events'; +import { moveTabAcrossLists } from './cross-list-move'; +import { TabsProps } from './interfaces'; + +/** + * State of an in-progress cross-list drag, shared between the source list (which owns + * the pointer drag and its `DndContext`) and the target list (which renders a drop + * indicator). `targetTabsId`/`targetIndex` are `null` while the dragged tab is not over + * any sibling list. + */ +export interface CrossListDragState { + sourceTabsId: string; + draggedTabId: string; + targetTabsId: string | null; + targetIndex: number | null; +} + +/** + * A single `reorderable` Tabs instance participating in a cross-list reorder group. + * All value accessors are functions so the registry always reads the instance's latest + * props/DOM without the member needing to re-register on every render. + */ +export interface ReorderGroupMember { + /** Stable id of the Tabs instance (its consumer id or generated namespace). */ + tabsId: string; + /** Current tabs of this instance. */ + getTabs: () => readonly TabsProps.Tab[]; + /** Bounding rect of this instance's tab list container (viewport coords), or null. */ + getContainerRect: () => DOMRect | null; + /** Given a viewport x-coordinate, the insertion index among this instance's tabs. */ + getDropIndex: (clientX: number) => number; + /** The instance's current `onTabMove` handler (may be undefined). */ + getOnTabMove: () => TabsProps['onTabMove']; + /** Fired on the SOURCE instance after one of its tabs leaves, so it can select an adjacent tab. */ + selectAdjacentAfterRemoval: (removedTabId: string, remainingTabIds: string[]) => void; + /** Fired on the TARGET instance after a tab arrives, so it can move focus to that tab's drag handle. */ + focusDragHandle: (tabId: string) => void; + /** Fired on the TARGET instance after a tab arrives, so it can announce the move to screen readers. */ + announceReceived: (targetPosition: number, targetTotal: number) => void; +} + +export interface ReorderGroupState { + /** Members in registration order (used as the left-to-right fallback when rects are unavailable). */ + members: readonly ReorderGroupMember[]; + drag: CrossListDragState | null; +} + +const groups = new Map>(); + +function createState(): ReorderGroupState { + return { members: [], drag: null }; +} + +/** Exposed for tests: the number of live groups (a group is deleted once its last member leaves). */ +export function getGroupCount(): number { + return groups.size; +} + +export function getGroupStore(reorderGroup: string): ReadonlyAsyncStore | undefined { + return groups.get(reorderGroup); +} + +export function getMembers(reorderGroup: string): readonly ReorderGroupMember[] { + return groups.get(reorderGroup)?.get().members ?? []; +} + +export function getSiblings(reorderGroup: string, excludeTabsId: string): ReorderGroupMember[] { + return getMembers(reorderGroup).filter(member => member.tabsId !== excludeTabsId); +} + +/** + * Registers a member with its reorder group, creating the group's shared store on first use. + * Returns a cleanup function that removes the member and deletes the group once it is empty, + * so the module never leaks stores for unmounted Tabs. + */ +export function registerMember(reorderGroup: string, member: ReorderGroupMember): () => void { + let store = groups.get(reorderGroup); + if (!store) { + store = new AsyncStore(createState()); + groups.set(reorderGroup, store); + } + const groupStore = store; + groupStore.set(prev => ({ + ...prev, + members: [...prev.members.filter(existing => existing.tabsId !== member.tabsId), member], + })); + + return () => { + groupStore.set(prev => { + const members = prev.members.filter(existing => existing.tabsId !== member.tabsId); + // Drop any in-progress drag that referenced the leaving member. + const drag = + prev.drag && (prev.drag.sourceTabsId === member.tabsId || prev.drag.targetTabsId === member.tabsId) + ? null + : prev.drag; + return { members, drag }; + }); + if (groupStore.get().members.length === 0 && groups.get(reorderGroup) === groupStore) { + groups.delete(reorderGroup); + } + }; +} + +export function setDragState(reorderGroup: string, drag: CrossListDragState | null): void { + groups.get(reorderGroup)?.set(prev => ({ ...prev, drag })); +} + +export function getDragState(reorderGroup: string): CrossListDragState | null { + return groups.get(reorderGroup)?.get().drag ?? null; +} + +/** + * Commits a cross-list move: removes the tab from the source list and inserts it into the + * target list at `desiredIndex` (pinned tabs stay locked). Fires `onTabMove` on both + * instances (controlled — the app updates both `tabs` arrays), tells the source to select an + * adjacent tab, announces the move on the target, and moves focus to the moved tab's drag + * handle in the target once both lists have re-rendered. Returns the move detail, or `null` + * when the move is a no-op (member missing or tab not found). + */ +export function commitCrossListMove( + reorderGroup: string, + { + sourceTabsId, + targetTabsId, + movedTabId, + desiredIndex, + }: { sourceTabsId: string; targetTabsId: string; movedTabId: string; desiredIndex: number } +): TabsProps.TabMoveDetail | null { + const members = getMembers(reorderGroup); + const source = members.find(member => member.tabsId === sourceTabsId); + const target = members.find(member => member.tabsId === targetTabsId); + if (!source || !target || source.tabsId === target.tabsId) { + return null; + } + const result = moveTabAcrossLists({ + sourceTabs: source.getTabs(), + targetTabs: target.getTabs(), + movedTabId, + desiredIndex, + }); + if (!result) { + return null; + } + const detail: TabsProps.TabMoveDetail = { + tabId: movedTabId, + sourceGroupTabsId: sourceTabsId, + targetGroupTabsId: targetTabsId, + targetIndex: result.targetIndex, + sourceTabIds: result.sourceTabs.map(tab => tab.id), + targetTabIds: result.targetTabs.map(tab => tab.id), + }; + fireNonCancelableEvent(source.getOnTabMove(), detail); + fireNonCancelableEvent(target.getOnTabMove(), detail); + source.selectAdjacentAfterRemoval(movedTabId, detail.sourceTabIds); + target.announceReceived(detail.targetIndex + 1, detail.targetTabIds.length); + // Focus the moved tab's drag handle in the target after both lists have re-rendered. + if (typeof requestAnimationFrame !== 'undefined') { + requestAnimationFrame(() => target.focusDragHandle(movedTabId)); + } else { + target.focusDragHandle(movedTabId); + } + return detail; +} diff --git a/src/tabs/reorder-live-announcements.ts b/src/tabs/reorder-live-announcements.ts index 29b145ba7d..250c62468e 100644 --- a/src/tabs/reorder-live-announcements.ts +++ b/src/tabs/reorder-live-announcements.ts @@ -26,3 +26,8 @@ export const formatTabReorderCommitted: CustomHandler< total, isInitialPosition: `${initialPosition === finalPosition}`, }); + +export const formatTabMovedAcrossLists: CustomHandler< + TabsProps.I18nStrings['liveAnnouncementTabMovedAcrossLists'], + I18nFormatArgTypes['tabs']['i18nStrings.liveAnnouncementTabMovedAcrossLists'] +> = format => (targetPosition, targetTotal) => format({ targetPosition, targetTotal }); diff --git a/src/tabs/tab-header-bar.scss b/src/tabs/tab-header-bar.scss index 6eb2c6c797..883d595886 100644 --- a/src/tabs/tab-header-bar.scss +++ b/src/tabs/tab-header-bar.scss @@ -26,6 +26,20 @@ $label-horizontal-spacing: awsui.$space-xs; display: flex; flex-grow: 1; max-inline-size: 100%; + position: relative; +} + +.tabs-drop-indicator { + position: absolute; + inset-block: awsui.$space-scaled-xs; + inline-size: 2px; + border-start-start-radius: 2px; + border-start-end-radius: 2px; + border-end-start-radius: 2px; + border-end-end-radius: 2px; + background-color: awsui.$color-drag-placeholder-hover; + pointer-events: none; + z-index: 1; } .tabs-header-list { diff --git a/src/tabs/tab-header-bar.tsx b/src/tabs/tab-header-bar.tsx index 16536279bd..d31b8097df 100644 --- a/src/tabs/tab-header-bar.tsx +++ b/src/tabs/tab-header-bar.tsx @@ -27,6 +27,7 @@ import { KeyCode } from '../internal/keycode'; import { circleIndex } from '../internal/utils/circle-index'; import { isHTMLElement } from '../internal/utils/dom'; import handleKey from '../internal/utils/handle-key'; +import InternalLiveRegion from '../live-region/internal'; import Tooltip from '../tooltip/internal.js'; import { GeneratedAnalyticsMetadataTabsComponent, @@ -35,6 +36,7 @@ import { } from './analytics-metadata/interfaces'; import { TabsProps } from './interfaces'; import { + formatTabMovedAcrossLists, formatTabReorderCommitted, formatTabReorderMoved, formatTabReorderStarted, @@ -47,6 +49,7 @@ import { scrollIntoView, } from './scroll-utils'; import { getTabContainerStyles, getTabStyles } from './styles'; +import useCrossListReorder from './use-cross-list-reorder'; import analyticsSelectors from './analytics-metadata/styles.css.js'; import styles from './styles.css.js'; @@ -97,6 +100,9 @@ interface TabHeaderBarProps { onReorder?: TabsProps['onReorder']; addTabButton?: boolean; onAddTab?: TabsProps['onAddTab']; + tabsId: string; + reorderGroup?: string; + onTabMove?: TabsProps['onTabMove']; } export function TabHeaderBar({ @@ -115,6 +121,9 @@ export function TabHeaderBar({ onReorder, addTabButton = false, onAddTab, + tabsId, + reorderGroup, + onTabMove, }: TabHeaderBarProps) { const headerBarRef = useRef(null); const activeTabHeaderRef = useRef(null); @@ -137,6 +146,57 @@ export function TabHeaderBar({ const hasActionOrDismissible = tabs.some(tab => tab.action || tab.dismissible); const isApplicationMode = hasActionOrDismissible || reorderable; const hadApplicationMode = usePrevious(isApplicationMode); + + // Selects an adjacent tab after the active tab leaves this (source) list in a cross-list move, + // so focus/selection never lands on nothing. + const selectAdjacentAfterRemoval = (removedTabId: string, remainingTabIds: string[]) => { + if (activeTabId !== removedTabId || remainingTabIds.length === 0) { + return; + } + const removedIndex = tabs.findIndex(tab => tab.id === removedTabId); + const nextIndex = Math.min(Math.max(removedIndex, 0), remainingTabIds.length - 1); + const nextTabId = remainingTabIds[nextIndex]; + if (nextTabId) { + setPreviousActiveTabId(nextTabId); + setFocusedTabId(nextTabId); + onChange({ activeTabId: nextTabId, activeTabHref: tabs.find(tab => tab.id === nextTabId)?.href }); + } + }; + + const focusTabDragHandle = (tabId: string) => { + const handle = containerObjectRef.current?.querySelector( + `[data-testid="awsui-tab-drag-handle-${tabId}"] [role="button"]` + ); + handle?.focus(); + }; + + const resolvedMovedAcrossLists = i18n( + 'i18nStrings.liveAnnouncementTabMovedAcrossLists', + i18nStrings?.liveAnnouncementTabMovedAcrossLists, + formatTabMovedAcrossLists + ); + + const { + active: crossListActive, + dropIndicatorOffset, + liveMessage: crossListLiveMessage, + onReorderStart, + onReorderMove, + onReorderEnd, + onReorderCancel, + handleDragHandleKeyDown, + } = useCrossListReorder({ + reorderable, + reorderGroup, + tabsId, + containerRef: containerObjectRef, + tabRefs, + tabs, + onTabMove, + selectAdjacentAfterRemoval, + focusTabDragHandle, + formatMovedAcrossLists: (targetPosition, targetTotal) => resolvedMovedAcrossLists?.(targetPosition, targetTotal), + }); const tabActionAttributes = isApplicationMode ? { role: 'application', @@ -378,6 +438,10 @@ export function TabHeaderBar({ items={tabs} direction="horizontal" + onReorderStart={onReorderStart} + onReorderMove={onReorderMove} + onReorderEnd={onReorderEnd} + onReorderCancel={onReorderCancel} itemDefinition={{ id: tab => tab.id, label: tab => (typeof tab.label === 'string' ? tab.label : tab.id), @@ -457,6 +521,18 @@ export function TabHeaderBar({ /> )} + {crossListActive && dropIndicatorOffset !== null && ( +