@dxos/react-ui-mosaic 0.8.4-main.abd8ff62ef → 0.8.4-main.bc2380dfbc
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +102 -5
- package/dist/lib/browser/{chunk-TY4RYZ3H.mjs → chunk-3BMPGIWZ.mjs} +20 -10
- package/dist/lib/browser/chunk-3BMPGIWZ.mjs.map +7 -0
- package/dist/lib/browser/index.mjs +22 -24
- package/dist/lib/browser/index.mjs.map +3 -3
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/browser/playwright/index.mjs +90 -7
- package/dist/lib/browser/playwright/index.mjs.map +3 -3
- package/dist/lib/browser/testing/index.mjs +1 -4
- package/dist/lib/browser/testing/index.mjs.map +2 -2
- package/dist/lib/node-esm/{chunk-25RXZ2RH.mjs → chunk-2UCLTU76.mjs} +20 -10
- package/dist/lib/node-esm/chunk-2UCLTU76.mjs.map +7 -0
- package/dist/lib/node-esm/index.mjs +22 -24
- package/dist/lib/node-esm/index.mjs.map +3 -3
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/lib/node-esm/playwright/index.mjs +90 -7
- package/dist/lib/node-esm/playwright/index.mjs.map +3 -3
- package/dist/lib/node-esm/testing/index.mjs +1 -4
- package/dist/lib/node-esm/testing/index.mjs.map +2 -2
- package/dist/types/src/components/Board/Board.d.ts +1 -1
- package/dist/types/src/components/Board/Column.d.ts +3 -3
- package/dist/types/src/components/Board/Column.d.ts.map +1 -1
- package/dist/types/src/components/Board/Item.d.ts +1 -1
- package/dist/types/src/components/Board/Item.d.ts.map +1 -1
- package/dist/types/src/components/Mosaic/Container.d.ts +8 -0
- package/dist/types/src/components/Mosaic/Container.d.ts.map +1 -1
- package/dist/types/src/components/Mosaic/Mosaic.d.ts +1 -0
- package/dist/types/src/components/Mosaic/Mosaic.d.ts.map +1 -1
- package/dist/types/src/components/Mosaic/Placeholder.d.ts.map +1 -1
- package/dist/types/src/components/Mosaic/Stack.d.ts.map +1 -1
- package/dist/types/src/components/Mosaic/Tile.d.ts +4 -0
- package/dist/types/src/components/Mosaic/Tile.d.ts.map +1 -1
- package/dist/types/src/playwright/board-manager.d.ts +12 -2
- package/dist/types/src/playwright/board-manager.d.ts.map +1 -1
- package/dist/types/src/testing/CardContainer.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +31 -30
- package/src/components/Board/Board.stories.tsx +1 -1
- package/src/components/Board/Column.tsx +17 -15
- package/src/components/Board/Item.tsx +10 -7
- package/src/components/Mosaic/Container.tsx +17 -0
- package/src/components/Mosaic/Placeholder.tsx +6 -0
- package/src/components/Mosaic/Stack.tsx +5 -1
- package/src/components/Mosaic/Tile.tsx +8 -4
- package/src/hooks/useContainerDebug.tsx +1 -1
- package/src/playwright/board-manager.ts +138 -10
- package/src/playwright/smoke.spec.ts +3 -3
- package/src/testing/CardContainer.tsx +2 -5
- package/dist/lib/browser/chunk-TY4RYZ3H.mjs.map +0 -7
- package/dist/lib/node-esm/chunk-25RXZ2RH.mjs.map +0 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dxos/react-ui-mosaic",
|
|
3
|
-
"version": "0.8.4-main.
|
|
3
|
+
"version": "0.8.4-main.bc2380dfbc",
|
|
4
4
|
"description": "Modular UI components.",
|
|
5
5
|
"homepage": "https://dxos.org",
|
|
6
6
|
"bugs": "https://github.com/dxos/dxos/issues",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"type": "git",
|
|
9
9
|
"url": "https://github.com/dxos/dxos"
|
|
10
10
|
},
|
|
11
|
-
"license": "
|
|
11
|
+
"license": "FSL-1.1-Apache-2.0",
|
|
12
12
|
"author": "DXOS.org",
|
|
13
13
|
"sideEffects": false,
|
|
14
14
|
"type": "module",
|
|
@@ -52,8 +52,9 @@
|
|
|
52
52
|
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.1.0",
|
|
53
53
|
"@atlaskit/pragmatic-drag-and-drop-react-drop-indicator": "3.2.10",
|
|
54
54
|
"@codemirror/state": "^6.5.2",
|
|
55
|
-
"@codemirror/view": "^6.38.
|
|
55
|
+
"@codemirror/view": "^6.38.5",
|
|
56
56
|
"@fluentui/react-tabster": "9.26.11",
|
|
57
|
+
"@playwright/test": "1.57.0",
|
|
57
58
|
"@radix-ui/react-compose-refs": "1.1.1",
|
|
58
59
|
"@radix-ui/react-context": "1.1.1",
|
|
59
60
|
"@radix-ui/react-primitive": "2.0.2",
|
|
@@ -61,20 +62,20 @@
|
|
|
61
62
|
"@radix-ui/react-use-controllable-state": "1.1.0",
|
|
62
63
|
"@tanstack/react-virtual": "^3.13.18",
|
|
63
64
|
"bind-event-listener": "^3.0.0",
|
|
64
|
-
"@dxos/async": "0.8.4-main.
|
|
65
|
-
"@dxos/
|
|
66
|
-
"@dxos/
|
|
67
|
-
"@dxos/
|
|
68
|
-
"@dxos/
|
|
69
|
-
"@dxos/
|
|
70
|
-
"@dxos/react-ui-
|
|
71
|
-
"@dxos/react-ui-
|
|
72
|
-
"@dxos/react-ui-
|
|
73
|
-
"@dxos/react-ui-
|
|
74
|
-
"@dxos/react-ui-
|
|
75
|
-
"@dxos/ui-theme": "0.8.4-main.
|
|
76
|
-
"@dxos/util": "0.8.4-main.
|
|
77
|
-
"@dxos/react-ui-
|
|
65
|
+
"@dxos/async": "0.8.4-main.bc2380dfbc",
|
|
66
|
+
"@dxos/echo": "0.8.4-main.bc2380dfbc",
|
|
67
|
+
"@dxos/keys": "0.8.4-main.bc2380dfbc",
|
|
68
|
+
"@dxos/log": "0.8.4-main.bc2380dfbc",
|
|
69
|
+
"@dxos/react-client": "0.8.4-main.bc2380dfbc",
|
|
70
|
+
"@dxos/invariant": "0.8.4-main.bc2380dfbc",
|
|
71
|
+
"@dxos/react-ui-attention": "0.8.4-main.bc2380dfbc",
|
|
72
|
+
"@dxos/react-ui-dnd": "0.8.4-main.bc2380dfbc",
|
|
73
|
+
"@dxos/react-ui-list": "0.8.4-main.bc2380dfbc",
|
|
74
|
+
"@dxos/react-ui-search": "0.8.4-main.bc2380dfbc",
|
|
75
|
+
"@dxos/react-ui-syntax-highlighter": "0.8.4-main.bc2380dfbc",
|
|
76
|
+
"@dxos/ui-theme": "0.8.4-main.bc2380dfbc",
|
|
77
|
+
"@dxos/util": "0.8.4-main.bc2380dfbc",
|
|
78
|
+
"@dxos/react-ui-menu": "0.8.4-main.bc2380dfbc"
|
|
78
79
|
},
|
|
79
80
|
"devDependencies": {
|
|
80
81
|
"@effect-atom/atom-react": "^0.5.0",
|
|
@@ -83,25 +84,25 @@
|
|
|
83
84
|
"effect": "3.20.0",
|
|
84
85
|
"react": "~19.2.3",
|
|
85
86
|
"react-dom": "~19.2.3",
|
|
86
|
-
"vite": "^8.0.
|
|
87
|
-
"@dxos/echo-atom": "0.8.4-main.
|
|
88
|
-
"@dxos/random": "0.8.4-main.
|
|
89
|
-
"@dxos/react-ui": "0.8.4-main.
|
|
90
|
-
"@dxos/react-ui-syntax-highlighter": "0.8.4-main.
|
|
91
|
-
"@dxos/
|
|
92
|
-
"@dxos/
|
|
93
|
-
"@dxos/
|
|
94
|
-
"@dxos/
|
|
95
|
-
"@dxos/
|
|
87
|
+
"vite": "^8.0.13",
|
|
88
|
+
"@dxos/echo-atom": "0.8.4-main.bc2380dfbc",
|
|
89
|
+
"@dxos/random": "0.8.4-main.bc2380dfbc",
|
|
90
|
+
"@dxos/react-ui": "0.8.4-main.bc2380dfbc",
|
|
91
|
+
"@dxos/react-ui-syntax-highlighter": "0.8.4-main.bc2380dfbc",
|
|
92
|
+
"@dxos/storybook-utils": "0.8.4-main.bc2380dfbc",
|
|
93
|
+
"@dxos/schema": "0.8.4-main.bc2380dfbc",
|
|
94
|
+
"@dxos/test-utils": "0.8.4-main.bc2380dfbc",
|
|
95
|
+
"@dxos/types": "0.8.4-main.bc2380dfbc",
|
|
96
|
+
"@dxos/ui-theme": "0.8.4-main.bc2380dfbc"
|
|
96
97
|
},
|
|
97
98
|
"peerDependencies": {
|
|
98
99
|
"@effect-atom/atom-react": "^0.5.0",
|
|
99
100
|
"effect": "3.20.0",
|
|
100
101
|
"react": "~19.2.3",
|
|
101
102
|
"react-dom": "~19.2.3",
|
|
102
|
-
"@dxos/random": "0.8.4-main.
|
|
103
|
-
"@dxos/
|
|
104
|
-
"@dxos/ui
|
|
103
|
+
"@dxos/random": "0.8.4-main.bc2380dfbc",
|
|
104
|
+
"@dxos/ui-theme": "0.8.4-main.bc2380dfbc",
|
|
105
|
+
"@dxos/react-ui": "0.8.4-main.bc2380dfbc"
|
|
105
106
|
},
|
|
106
107
|
"publishConfig": {
|
|
107
108
|
"access": "public"
|
|
@@ -175,7 +175,7 @@ const DefaultStory = ({ debug = false, columns: columnsProp = 0 }: DefaultStoryP
|
|
|
175
175
|
|
|
176
176
|
return (
|
|
177
177
|
<Mosaic.Root asChild debug={debug}>
|
|
178
|
-
<div
|
|
178
|
+
<div className={mx('grid md:p-2 overflow-hidden', debug && 'grid-cols-[1fr_20rem] gap-2')}>
|
|
179
179
|
<Board.Root model={model}>
|
|
180
180
|
<Board.Content id='board' debug={debug} eventHandler={eventHandler} Tile={DefaultBoardColumn} />
|
|
181
181
|
</Board.Root>
|
|
@@ -8,10 +8,8 @@ import React, {
|
|
|
8
8
|
type PropsWithChildren,
|
|
9
9
|
type ReactElement,
|
|
10
10
|
type Ref as ReactRef,
|
|
11
|
-
type RefObject,
|
|
12
11
|
forwardRef,
|
|
13
12
|
useMemo,
|
|
14
|
-
useRef,
|
|
15
13
|
useState,
|
|
16
14
|
} from 'react';
|
|
17
15
|
|
|
@@ -50,7 +48,10 @@ export function useBoardColumn<TColumn = unknown>(): TColumn | undefined {
|
|
|
50
48
|
return value?.column as TColumn | undefined;
|
|
51
49
|
}
|
|
52
50
|
|
|
53
|
-
type BoardColumnProps<TColumn = any> = Pick<
|
|
51
|
+
type BoardColumnProps<TColumn = any> = Pick<
|
|
52
|
+
MosaicTileProps<TColumn>,
|
|
53
|
+
'classNames' | 'location' | 'data' | 'debug' | 'draggable'
|
|
54
|
+
>;
|
|
54
55
|
|
|
55
56
|
//
|
|
56
57
|
// Column Root
|
|
@@ -59,17 +60,16 @@ type BoardColumnProps<TColumn = any> = Pick<MosaicTileProps<TColumn>, 'className
|
|
|
59
60
|
const BOARD_COLUMN_ROOT_NAME = 'Board.Column.Root';
|
|
60
61
|
|
|
61
62
|
type BoardColumnRootProps<TColumn = any> = PropsWithChildren<BoardColumnProps<TColumn>> & {
|
|
62
|
-
|
|
63
|
+
// Pass the actual button element (not a ref). Refs don't trigger re-renders, so reading
|
|
64
|
+
// `ref.current` at render time leaves Mosaic.Tile's `dragHandle` prop null forever.
|
|
65
|
+
// Consumers should track this with `useState` and a callback ref on `Board.Column.Header`.
|
|
66
|
+
dragHandle?: HTMLButtonElement | null;
|
|
63
67
|
};
|
|
64
68
|
|
|
65
69
|
const BoardColumnRootInner = composable<HTMLDivElement, BoardColumnRootProps>(
|
|
66
|
-
({ classNames, children, location, data, debug,
|
|
70
|
+
({ classNames, children, location, data, debug, draggable, dragHandle, ...rest }, forwardedRef) => {
|
|
67
71
|
const { model } = useBoard(BOARD_COLUMN_ROOT_NAME);
|
|
68
72
|
|
|
69
|
-
// TODO(burdon): Use merge ref hook (see react-hooks).
|
|
70
|
-
const internalRef = useRef<HTMLButtonElement>(null);
|
|
71
|
-
const dragHandleRef = dragHandleRefProp ?? internalRef;
|
|
72
|
-
|
|
73
73
|
return (
|
|
74
74
|
<Mosaic.Tile
|
|
75
75
|
asChild
|
|
@@ -77,7 +77,8 @@ const BoardColumnRootInner = composable<HTMLDivElement, BoardColumnRootProps>(
|
|
|
77
77
|
id={model.getColumnId(data)}
|
|
78
78
|
data={data}
|
|
79
79
|
debug={debug}
|
|
80
|
-
|
|
80
|
+
draggable={draggable}
|
|
81
|
+
dragHandle={dragHandle}
|
|
81
82
|
>
|
|
82
83
|
<Focus.Group
|
|
83
84
|
{...rest}
|
|
@@ -240,10 +241,10 @@ const BOARD_DEFAULT_COLUMN_NAME = 'Board.DefaultColumn';
|
|
|
240
241
|
type DefaultBoardColumnProps = BoardColumnProps & Pick<BoardColumnBodyProps, 'Tile'>;
|
|
241
242
|
|
|
242
243
|
const DefaultBoardColumn = forwardRef<HTMLDivElement, DefaultBoardColumnProps>(
|
|
243
|
-
({ classNames, location, data, debug, Tile = BoardItem }, forwardedRef) => {
|
|
244
|
+
({ classNames, location, data, debug, draggable, Tile = BoardItem }, forwardedRef) => {
|
|
244
245
|
const { model } = useBoard(BOARD_DEFAULT_COLUMN_NAME);
|
|
245
246
|
const [DebugInfo, debugHandler] = useContainerDebug(debug);
|
|
246
|
-
const
|
|
247
|
+
const [dragHandle, setDragHandle] = useState<HTMLButtonElement | null>(null);
|
|
247
248
|
const [column, updateColumn] = useObject(data);
|
|
248
249
|
|
|
249
250
|
const eventHandler = useEventHandlerAdapter<Ref.Unknown>({
|
|
@@ -270,12 +271,13 @@ const DefaultBoardColumn = forwardRef<HTMLDivElement, DefaultBoardColumnProps>(
|
|
|
270
271
|
)}
|
|
271
272
|
location={location}
|
|
272
273
|
data={data}
|
|
273
|
-
|
|
274
|
+
draggable={draggable}
|
|
275
|
+
dragHandle={dragHandle}
|
|
274
276
|
ref={forwardedRef}
|
|
275
277
|
>
|
|
276
|
-
<BoardColumnHeader label={Obj.getLabel(data) ?? data.id} dragHandleRef={
|
|
278
|
+
<BoardColumnHeader label={Obj.getLabel(data) ?? data.id} dragHandleRef={setDragHandle} />
|
|
277
279
|
<BoardColumnBody data={data} eventHandler={eventHandler} debug={debugHandler} Tile={Tile} />
|
|
278
|
-
<div
|
|
280
|
+
<div className='flex flex-col col-span-full'>
|
|
279
281
|
<BoardColumnFooter data={data} />
|
|
280
282
|
<DebugInfo />
|
|
281
283
|
</div>
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
//
|
|
4
4
|
|
|
5
5
|
import { useComposedRefs } from '@radix-ui/react-compose-refs';
|
|
6
|
-
import React, { type ReactElement, type Ref as ReactRef, forwardRef, useMemo, useRef } from 'react';
|
|
6
|
+
import React, { type ReactElement, type Ref as ReactRef, forwardRef, useMemo, useRef, useState } from 'react';
|
|
7
7
|
|
|
8
8
|
import { Obj } from '@dxos/echo';
|
|
9
9
|
import { Card, Tag, Toolbar, useTranslation } from '@dxos/react-ui';
|
|
@@ -21,15 +21,17 @@ const BOARD_ITEM_NAME = 'Board.Item';
|
|
|
21
21
|
|
|
22
22
|
type BoardItemProps<TItem extends Obj.Unknown = any> = Pick<
|
|
23
23
|
MosaicTileProps<TItem>,
|
|
24
|
-
'classNames' | 'location' | 'data' | 'debug'
|
|
24
|
+
'classNames' | 'location' | 'data' | 'debug' | 'draggable'
|
|
25
25
|
>;
|
|
26
26
|
|
|
27
27
|
const BoardItemInner = forwardRef<HTMLDivElement, BoardItemProps>(
|
|
28
|
-
({ classNames, data, location, debug }, forwardedRef) => {
|
|
28
|
+
({ classNames, data, location, debug, draggable }, forwardedRef) => {
|
|
29
29
|
const { t } = useTranslation(translationKey);
|
|
30
30
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
31
31
|
const composedRef = useComposedRefs<HTMLDivElement>(rootRef, forwardedRef);
|
|
32
|
-
|
|
32
|
+
// Use state (callback ref) so the dragHandle prop updates when the button mounts.
|
|
33
|
+
// Refs don't trigger re-renders, so reading `.current` at render time leaves the prop null.
|
|
34
|
+
const [dragHandle, setDragHandle] = useState<HTMLButtonElement | null>(null);
|
|
33
35
|
|
|
34
36
|
const { model } = useBoard(BOARD_ITEM_NAME);
|
|
35
37
|
const column = useBoardColumn();
|
|
@@ -58,7 +60,8 @@ const BoardItemInner = forwardRef<HTMLDivElement, BoardItemProps>(
|
|
|
58
60
|
<Mosaic.Tile
|
|
59
61
|
ref={rootRef}
|
|
60
62
|
asChild
|
|
61
|
-
|
|
63
|
+
draggable={draggable}
|
|
64
|
+
dragHandle={dragHandle}
|
|
62
65
|
id={data.id}
|
|
63
66
|
data={data}
|
|
64
67
|
location={location}
|
|
@@ -72,7 +75,7 @@ const BoardItemInner = forwardRef<HTMLDivElement, BoardItemProps>(
|
|
|
72
75
|
onClick={(event) => event.currentTarget.focus()}
|
|
73
76
|
>
|
|
74
77
|
<Card.Toolbar>
|
|
75
|
-
<Card.DragHandle ref={
|
|
78
|
+
<Card.DragHandle ref={setDragHandle} testId='mosaicBoard.cardDragHandle' />
|
|
76
79
|
<Card.Title data-testid='mosaicBoard.cardTitle'>{label}</Card.Title>
|
|
77
80
|
{/* TODO(wittjosiah): Reconcile with Card.Menu. */}
|
|
78
81
|
<Menu.Trigger asChild disabled={!items?.length}>
|
|
@@ -91,7 +94,7 @@ const BoardItemInner = forwardRef<HTMLDivElement, BoardItemProps>(
|
|
|
91
94
|
</Card.Row>
|
|
92
95
|
<Card.Row icon='ph--tag--regular'>
|
|
93
96
|
{label && (
|
|
94
|
-
<div
|
|
97
|
+
<div className='shrink-0 flex gap-1 items-center text-xs'>
|
|
95
98
|
<Tag palette={getHashStyles(label).hue}>{label}</Tag>
|
|
96
99
|
</div>
|
|
97
100
|
)}
|
|
@@ -60,6 +60,11 @@ type MosaicContainerContextValue<TData = any, Location = LocationType> = {
|
|
|
60
60
|
/** Set the current item by ID. */
|
|
61
61
|
setCurrentId: (id: string | undefined) => void;
|
|
62
62
|
|
|
63
|
+
/** IDs of selected (aria-selected) items. */
|
|
64
|
+
selectedIds?: ReadonlySet<string>;
|
|
65
|
+
/** Request to set or unset selection on an item by ID. */
|
|
66
|
+
setSelected: (id: string, selected: boolean) => void;
|
|
67
|
+
|
|
63
68
|
/** Register a scroll-to-item callback (provided by Stack/VirtualStack). */
|
|
64
69
|
registerScrollTo: (fn: ((id: string) => void) | undefined) => void;
|
|
65
70
|
};
|
|
@@ -86,6 +91,10 @@ type MosaicContainerProps = PropsWithChildren<
|
|
|
86
91
|
currentId?: string;
|
|
87
92
|
/** Called when a tile requests to become current. */
|
|
88
93
|
onCurrentChange?: (id: string | undefined) => void;
|
|
94
|
+
/** Controlled set of selected item IDs. */
|
|
95
|
+
selectedIds?: ReadonlySet<string>;
|
|
96
|
+
/** Called when a tile requests to toggle selection. */
|
|
97
|
+
onSelectionChange?: (id: string, selected: boolean) => void;
|
|
89
98
|
debug?: () => ReactNode;
|
|
90
99
|
}
|
|
91
100
|
>;
|
|
@@ -102,6 +111,8 @@ const MosaicContainer = composable<HTMLDivElement, MosaicContainerProps>(
|
|
|
102
111
|
withFocus,
|
|
103
112
|
currentId,
|
|
104
113
|
onCurrentChange,
|
|
114
|
+
selectedIds,
|
|
115
|
+
onSelectionChange,
|
|
105
116
|
debug,
|
|
106
117
|
...props
|
|
107
118
|
},
|
|
@@ -126,6 +137,10 @@ const MosaicContainer = composable<HTMLDivElement, MosaicContainerProps>(
|
|
|
126
137
|
const [activeLocation, setActiveLocation] = useState<LocationType | undefined>();
|
|
127
138
|
const [scrolling, setScrolling] = useState(false);
|
|
128
139
|
const setCurrentId = useCallback((id: string | undefined) => onCurrentChange?.(id), [onCurrentChange]);
|
|
140
|
+
const setSelected = useCallback(
|
|
141
|
+
(id: string, selected: boolean) => onSelectionChange?.(id, selected),
|
|
142
|
+
[onSelectionChange],
|
|
143
|
+
);
|
|
129
144
|
|
|
130
145
|
// Scroll-to-item: Stack/VirtualStack registers its implementation.
|
|
131
146
|
const scrollToRef = useRef<((id: string) => void) | undefined>(undefined);
|
|
@@ -283,6 +298,8 @@ const MosaicContainer = composable<HTMLDivElement, MosaicContainerProps>(
|
|
|
283
298
|
setActiveLocation={setActiveLocation}
|
|
284
299
|
currentId={currentId}
|
|
285
300
|
setCurrentId={setCurrentId}
|
|
301
|
+
selectedIds={selectedIds}
|
|
302
|
+
setSelected={setSelected}
|
|
286
303
|
registerScrollTo={registerScrollTo}
|
|
287
304
|
>
|
|
288
305
|
<Comp
|
|
@@ -30,6 +30,11 @@ const MOSAIC_PLACEHOLDER_ORIENTATION_ATTR = 'mosaic-placeholder-orientation';
|
|
|
30
30
|
// State attribute: data-[mosaic-placeholder-state=active]
|
|
31
31
|
const MOSAIC_PLACEHOLDER_STATE_ATTR = 'mosaic-placeholder-state';
|
|
32
32
|
|
|
33
|
+
// Location attribute: data-[mosaic-placeholder-location=0.5]. Identifies the
|
|
34
|
+
// placeholder's slot in its container (0.5, 1.5, 2.5, …) so tests can target
|
|
35
|
+
// a specific gap unambiguously without relying on layout-dependent indices.
|
|
36
|
+
const MOSAIC_PLACEHOLDER_LOCATION_ATTR = 'mosaic-placeholder-location';
|
|
37
|
+
|
|
33
38
|
type MosaicPlaceholderProps<Location = LocationType> = ThemedClassName<
|
|
34
39
|
PropsWithChildren<{
|
|
35
40
|
asChild?: boolean;
|
|
@@ -94,6 +99,7 @@ const MosaicPlaceholder = <Location extends LocationType = LocationType>({
|
|
|
94
99
|
{...{
|
|
95
100
|
[`data-${MOSAIC_PLACEHOLDER_ORIENTATION_ATTR}`]: orientation,
|
|
96
101
|
[`data-${MOSAIC_PLACEHOLDER_STATE_ATTR}`]: data.location === activeLocation ? 'active' : 'idle',
|
|
102
|
+
[`data-${MOSAIC_PLACEHOLDER_LOCATION_ATTR}`]: String(location),
|
|
97
103
|
}}
|
|
98
104
|
role='none'
|
|
99
105
|
className={mx('relative', classNames)}
|
|
@@ -85,6 +85,7 @@ const MosaicStackInner = composable<HTMLDivElement, MosaicStackProps>(
|
|
|
85
85
|
orientation = orientationProp,
|
|
86
86
|
dragging,
|
|
87
87
|
currentId,
|
|
88
|
+
selectedIds,
|
|
88
89
|
registerScrollTo,
|
|
89
90
|
} = useMosaicContainerContext(MOSAIC_STACK_NAME);
|
|
90
91
|
const visibleItems = useVisibleItems({ id, items, dragging: dragging?.source.data, getId });
|
|
@@ -130,6 +131,7 @@ const MosaicStackInner = composable<HTMLDivElement, MosaicStackProps>(
|
|
|
130
131
|
location={index + 1}
|
|
131
132
|
draggable={draggable}
|
|
132
133
|
current={getId(item) === currentId}
|
|
134
|
+
selected={selectedIds?.has(getId(item))}
|
|
133
135
|
debug={debug}
|
|
134
136
|
/>
|
|
135
137
|
{draggable && <InternalPlaceholder orientation={orientation} location={index + 1.5} />}
|
|
@@ -178,7 +180,8 @@ const MosaicVirtualStackInner = forwardRef<HTMLDivElement, MosaicVirtualStackPro
|
|
|
178
180
|
forwardedRef,
|
|
179
181
|
) => {
|
|
180
182
|
invariant(Tile);
|
|
181
|
-
const { id, dragging, currentId, registerScrollTo } =
|
|
183
|
+
const { id, dragging, currentId, selectedIds, registerScrollTo } =
|
|
184
|
+
useMosaicContainerContext(MOSAIC_VIRTUAL_STACK_NAME);
|
|
182
185
|
const visibleItems = useVisibleItems({ id, items, dragging: dragging?.source.data, getId });
|
|
183
186
|
// In draggable mode virtual indices alternate: even=placeholder, odd=tile.
|
|
184
187
|
// Wrap estimateSize so placeholders get 0 (their actual negligible height) and
|
|
@@ -293,6 +296,7 @@ const MosaicVirtualStackInner = forwardRef<HTMLDivElement, MosaicVirtualStackPro
|
|
|
293
296
|
location={location}
|
|
294
297
|
draggable={draggable}
|
|
295
298
|
current={getId(data) === currentId}
|
|
299
|
+
selected={selectedIds?.has(getId(data))}
|
|
296
300
|
debug={debug}
|
|
297
301
|
/>
|
|
298
302
|
) : (
|
|
@@ -61,6 +61,8 @@ type MosaicTileProps<TData = any, TLocation = LocationType> = ThemedClassName<
|
|
|
61
61
|
draggable?: boolean;
|
|
62
62
|
/** Whether this tile is the current (aria-current) item. */
|
|
63
63
|
current?: boolean;
|
|
64
|
+
/** Whether this tile is selected (aria-selected). */
|
|
65
|
+
selected?: boolean;
|
|
64
66
|
debug?: boolean;
|
|
65
67
|
}>
|
|
66
68
|
>;
|
|
@@ -77,6 +79,7 @@ const MosaicTile = slottable<HTMLDivElement, MosaicTileProps>(
|
|
|
77
79
|
data: dataProp,
|
|
78
80
|
draggable: draggableProp,
|
|
79
81
|
current,
|
|
82
|
+
selected,
|
|
80
83
|
debug: _,
|
|
81
84
|
...props
|
|
82
85
|
},
|
|
@@ -211,11 +214,12 @@ const MosaicTile = slottable<HTMLDivElement, MosaicTileProps>(
|
|
|
211
214
|
[`data-${MOSAIC_TILE_STATE_ATTR}`]: state.type,
|
|
212
215
|
}}
|
|
213
216
|
role='listitem'
|
|
214
|
-
// Pair `current`
|
|
215
|
-
// `dx-current`
|
|
216
|
-
// actually
|
|
217
|
-
//
|
|
217
|
+
// Pair `current` / `selected` props with `aria-current` / `aria-selected`
|
|
218
|
+
// so the `dx-current` / `dx-selected` / `dx-hover` utilities (which key
|
|
219
|
+
// off those attributes) actually fire. Without this the props are purely
|
|
220
|
+
// advisory and the corresponding styling silently no-ops.
|
|
218
221
|
aria-current={current ? 'true' : undefined}
|
|
222
|
+
aria-selected={selected ? 'true' : undefined}
|
|
219
223
|
className={className}
|
|
220
224
|
ref={composedRef}
|
|
221
225
|
>
|
|
@@ -25,7 +25,7 @@ export const useContainerDebug = (debug?: boolean): UseContainerDebug => {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
return [
|
|
28
|
-
({ classNames }) => <div
|
|
28
|
+
({ classNames }) => <div className={mx('overflow-hidden', classNames)} ref={debugRef} />,
|
|
29
29
|
() => debugRef.current && createPortal(<ContainerInfo />, debugRef.current),
|
|
30
30
|
];
|
|
31
31
|
}, [debug, debugRef]);
|
|
@@ -2,7 +2,17 @@
|
|
|
2
2
|
// Copyright 2025 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import type
|
|
5
|
+
import { type Locator, type Page, expect } from '@playwright/test';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Edge of the target tile to drop on. Pragmatic-dnd assigns the closest edge
|
|
9
|
+
* based on cursor position; a stale (pre-layout-shift) cursor can land in the
|
|
10
|
+
* wrong half. When `dragTo` is called with an `edge`, the helper waits for the
|
|
11
|
+
* target tile to register the drag, re-measures the target after the
|
|
12
|
+
* placeholder has expanded, repositions the cursor near the requested edge,
|
|
13
|
+
* and confirms via `data-mosaic-tile-edge` before releasing.
|
|
14
|
+
*/
|
|
15
|
+
export type DragEdge = 'top' | 'bottom' | 'left' | 'right';
|
|
6
16
|
|
|
7
17
|
export class BoardManager {
|
|
8
18
|
private readonly _page: Page;
|
|
@@ -84,19 +94,137 @@ export class ItemManager {
|
|
|
84
94
|
return this.locator.getByTestId('mosaicBoard.cardTitle');
|
|
85
95
|
}
|
|
86
96
|
|
|
87
|
-
async dragTo(target: Locator, offset = { x: 0, y: 0 }): Promise<void> {
|
|
97
|
+
async dragTo(target: Locator, offset: { x: number; y: number } = { x: 0, y: 0 }, edge?: DragEdge): Promise<void> {
|
|
88
98
|
const handle = this.locator.getByTestId('mosaicBoard.cardDragHandle');
|
|
89
|
-
const
|
|
90
|
-
if (
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
99
|
+
const handleBox = await handle.boundingBox();
|
|
100
|
+
if (!handleBox) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Capture stable ElementHandles to source and target before the drag
|
|
105
|
+
// begins. Both `nth(N)` locators shift when `useVisibleItems` filters the
|
|
106
|
+
// source out of its column, so we need raw DOM references that survive
|
|
107
|
+
// the reconciliation.
|
|
108
|
+
const targetHandle = await target.elementHandle();
|
|
109
|
+
const box = targetHandle ? await targetHandle.boundingBox() : null;
|
|
110
|
+
if (!targetHandle || !box) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const sourceHandle = await this.locator.elementHandle();
|
|
114
|
+
|
|
115
|
+
await handle.hover();
|
|
116
|
+
await this._page.mouse.down();
|
|
117
|
+
// Allow the drag monitor to register the grab before moving.
|
|
118
|
+
await this._page.waitForTimeout(100);
|
|
119
|
+
|
|
120
|
+
if (edge) {
|
|
121
|
+
// Stage 1: trigger the dragstart. A 1-pixel nudge near the press
|
|
122
|
+
// point primes pragmatic-dnd's HTML5 dragstart, then a long move
|
|
123
|
+
// over the target tile commits the drag past every browser's
|
|
124
|
+
// "click vs drag" threshold.
|
|
125
|
+
await this._page.mouse.move(handleBox.x + handleBox.width / 2 + 1, handleBox.y + handleBox.height / 2, {
|
|
126
|
+
steps: 1,
|
|
127
|
+
});
|
|
128
|
+
await this._page.mouse.move(box.x + box.width / 2, box.y + box.height / 2, { steps: 4 });
|
|
129
|
+
|
|
130
|
+
// Wait for `data-mosaic-container-state="active"` so we know the
|
|
131
|
+
// dragstart has been processed. This alone is *not* enough: pragmatic-
|
|
132
|
+
// dnd's `monitorForElements.onDragStart` (Root.tsx) and the
|
|
133
|
+
// `dropTargetForElements.onDragStart` (Container.tsx) write into
|
|
134
|
+
// independent React state, and only the latter drives the attribute.
|
|
135
|
+
// The former drives `useVisibleItems` filtering, which is what
|
|
136
|
+
// physically reflows the column. So we ALSO wait for the source tile
|
|
137
|
+
// to detach from the DOM — that's the unambiguous signal that the
|
|
138
|
+
// reflow has happened and any cached bbox is stale.
|
|
139
|
+
await expect
|
|
140
|
+
.poll(() => this._page.locator('[data-mosaic-container-state="active"]').count(), { timeout: 2000 })
|
|
141
|
+
.toBeGreaterThan(0);
|
|
142
|
+
if (sourceHandle) {
|
|
143
|
+
await this._page.waitForFunction((el) => !el || !el.isConnected, sourceHandle, { timeout: 2000 });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Stage 2: walk siblings on the (now reflowed) drag-time DOM and aim
|
|
147
|
+
// at the placeholder representing the requested edge. The Fragment
|
|
148
|
+
// keying on item id keeps the target tile's adjacent placeholder DOM
|
|
149
|
+
// node stable across reconciliation, but its `location` prop and bbox
|
|
150
|
+
// both change once the source is filtered out — measure after the
|
|
151
|
+
// reflow, never before.
|
|
152
|
+
const placeholderHandle = await this._adjacentPlaceholder(targetHandle, edge);
|
|
153
|
+
if (placeholderHandle) {
|
|
154
|
+
const phBox = await placeholderHandle.boundingBox();
|
|
155
|
+
if (phBox) {
|
|
156
|
+
const aimX = phBox.x + phBox.width / 2;
|
|
157
|
+
const aimY = phBox.y + Math.max(phBox.height / 2, 1);
|
|
158
|
+
await this._page.mouse.move(aimX, aimY, { steps: 4 });
|
|
159
|
+
|
|
160
|
+
// Some browser/CDP combinations need a follow-up nudge to push
|
|
161
|
+
// the dragOver event through — re-aim with a 1px offset and poll.
|
|
162
|
+
const isActive = () =>
|
|
163
|
+
placeholderHandle.evaluate((el) => el.getAttribute('data-mosaic-placeholder-state') === 'active');
|
|
164
|
+
if (!(await isActive())) {
|
|
165
|
+
const start = Date.now();
|
|
166
|
+
let nudge = 0;
|
|
167
|
+
while (Date.now() - start < 3000) {
|
|
168
|
+
await this._page.waitForTimeout(100);
|
|
169
|
+
nudge = (nudge + 1) % 4;
|
|
170
|
+
const dx = nudge === 0 || nudge === 2 ? 1 : -1;
|
|
171
|
+
const dy = nudge === 1 || nudge === 2 ? 1 : -1;
|
|
172
|
+
await this._page.mouse.move(aimX + dx, aimY + dy, { steps: 1 });
|
|
173
|
+
if (await isActive()) {
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
await expect
|
|
178
|
+
.poll(() => placeholderHandle.evaluate((el) => el.getAttribute('data-mosaic-placeholder-state')), {
|
|
179
|
+
timeout: 1000,
|
|
180
|
+
})
|
|
181
|
+
.toBe('active');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Re-centre on the now-expanded placeholder so the drop lands
|
|
185
|
+
// solidly inside it.
|
|
186
|
+
const expanded = await placeholderHandle.boundingBox();
|
|
187
|
+
if (expanded) {
|
|
188
|
+
await this._page.mouse.move(expanded.x + expanded.width / 2, expanded.y + expanded.height / 2, {
|
|
189
|
+
steps: 2,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
} else {
|
|
195
|
+
// Legacy path: no explicit edge — rely on the offset placement and a
|
|
196
|
+
// small settle delay before releasing.
|
|
95
197
|
await this._page.mouse.move(offset.x + box.x + box.width / 2, offset.y + box.y + box.height / 2, { steps: 4 });
|
|
96
|
-
// Allow the drop target to process the hover before releasing.
|
|
97
198
|
await this._page.waitForTimeout(100);
|
|
98
|
-
await this._page.mouse.up();
|
|
99
199
|
}
|
|
200
|
+
await this._page.mouse.up();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Find the placeholder element adjacent to `target` on the requested
|
|
205
|
+
* side via DOM walk. Returns `null` if the target has no matching
|
|
206
|
+
* sibling (e.g. when the caller passes a non-tile target).
|
|
207
|
+
*/
|
|
208
|
+
private async _adjacentPlaceholder(
|
|
209
|
+
targetHandle: import('@playwright/test').ElementHandle<Element | SVGElement | HTMLElement>,
|
|
210
|
+
edge: DragEdge,
|
|
211
|
+
): Promise<import('@playwright/test').ElementHandle<HTMLElement> | null> {
|
|
212
|
+
const before = edge === 'top' || edge === 'left';
|
|
213
|
+
const result = await targetHandle.evaluateHandle((el, beforeArg) => {
|
|
214
|
+
let sibling: Element | null = beforeArg ? el.previousElementSibling : el.nextElementSibling;
|
|
215
|
+
while (sibling && !sibling.hasAttribute('data-mosaic-placeholder-location')) {
|
|
216
|
+
sibling = beforeArg ? sibling.previousElementSibling : sibling.nextElementSibling;
|
|
217
|
+
}
|
|
218
|
+
return sibling as HTMLElement | null;
|
|
219
|
+
}, before);
|
|
220
|
+
const handle = result.asElement() as import('@playwright/test').ElementHandle<HTMLElement> | null;
|
|
221
|
+
if (!handle) {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
// `evaluateHandle` returning `null` still produces a non-null JSHandle —
|
|
225
|
+
// verify the underlying element actually exists.
|
|
226
|
+
const exists = await handle.evaluate((el) => el !== null);
|
|
227
|
+
return exists ? handle : null;
|
|
100
228
|
}
|
|
101
229
|
|
|
102
230
|
/**
|
|
@@ -52,7 +52,7 @@ test.describe('Board', () => {
|
|
|
52
52
|
const secondLabel = await column.item(1).title().textContent();
|
|
53
53
|
|
|
54
54
|
// Drag first item below the second item.
|
|
55
|
-
await column.item(0).dragTo(column.item(1).locator, { x: 0, y: BELOW_OFFSET });
|
|
55
|
+
await column.item(0).dragTo(column.item(1).locator, { x: 0, y: BELOW_OFFSET }, 'bottom');
|
|
56
56
|
|
|
57
57
|
// Item count should stay the same.
|
|
58
58
|
await expect(column.items()).toHaveCount(countBefore);
|
|
@@ -73,7 +73,7 @@ test.describe('Board', () => {
|
|
|
73
73
|
const draggedLabel = await col0.item(0).title().textContent();
|
|
74
74
|
|
|
75
75
|
// Drag above the first item in the target column.
|
|
76
|
-
await col0.item(0).dragTo(col1.item(0).locator, { x: 0, y: ABOVE_OFFSET });
|
|
76
|
+
await col0.item(0).dragTo(col1.item(0).locator, { x: 0, y: ABOVE_OFFSET }, 'top');
|
|
77
77
|
|
|
78
78
|
await expect(col0.items()).toHaveCount(col0CountBefore - 1);
|
|
79
79
|
await expect(col1.items()).toHaveCount(col1CountBefore + 1);
|
|
@@ -89,7 +89,7 @@ test.describe('Board', () => {
|
|
|
89
89
|
const draggedLabel = await col0.item(0).title().textContent();
|
|
90
90
|
|
|
91
91
|
// Drag to below the first item in the target column.
|
|
92
|
-
await col0.item(0).dragTo(col1.item(0).locator, { x: 0, y: BELOW_OFFSET });
|
|
92
|
+
await col0.item(0).dragTo(col1.item(0).locator, { x: 0, y: BELOW_OFFSET }, 'bottom');
|
|
93
93
|
|
|
94
94
|
await expect(col0.items()).toHaveCount(col0CountBefore - 1);
|
|
95
95
|
await expect(col1.items()).toHaveCount(col1CountBefore + 1);
|
|
@@ -21,7 +21,7 @@ export const CardContainer = ({ children, role, icon = 'ph--arrow-line-down--reg
|
|
|
21
21
|
switch (role) {
|
|
22
22
|
case 'popover':
|
|
23
23
|
return (
|
|
24
|
-
<div
|
|
24
|
+
<div className='flex justify-center'>
|
|
25
25
|
<PopoverCardContainer icon={icon}>{children}</PopoverCardContainer>
|
|
26
26
|
</div>
|
|
27
27
|
);
|
|
@@ -80,14 +80,11 @@ export const IntrinsicCardContainer = ({
|
|
|
80
80
|
|
|
81
81
|
return (
|
|
82
82
|
<div
|
|
83
|
-
role='none'
|
|
84
83
|
className='relative p-2 grid overflow-hidden border-2 border-dashed border-green-500 rounded-lg'
|
|
85
84
|
style={sizeStyle(size, 'horizontal')}
|
|
86
85
|
{...resizeAttributes}
|
|
87
86
|
>
|
|
88
|
-
<div
|
|
89
|
-
{children}
|
|
90
|
-
</div>
|
|
87
|
+
<div className='dx-container flex flex-col'>{children}</div>
|
|
91
88
|
<ResizeHandle
|
|
92
89
|
side='inline-end'
|
|
93
90
|
fallbackSize={DEFAULT_BLOCK_SIZE}
|