@dxos/react-ui-mosaic 0.8.4-main.fcfe5033a5 → 0.9.0
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-33NNDFD6.mjs → chunk-I3M5WM6C.mjs} +45 -73
- package/dist/lib/browser/chunk-I3M5WM6C.mjs.map +7 -0
- package/dist/lib/browser/index.mjs +125 -59
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/browser/playwright/index.mjs +118 -10
- package/dist/lib/browser/playwright/index.mjs.map +3 -3
- package/dist/lib/browser/testing/index.mjs +10 -19
- package/dist/lib/browser/testing/index.mjs.map +3 -3
- package/dist/lib/browser/translations.mjs +18 -0
- package/dist/lib/browser/translations.mjs.map +7 -0
- package/dist/lib/node-esm/{chunk-Y4LG7O67.mjs → chunk-DVJLDZLE.mjs} +45 -73
- package/dist/lib/node-esm/chunk-DVJLDZLE.mjs.map +7 -0
- package/dist/lib/node-esm/index.mjs +125 -59
- package/dist/lib/node-esm/index.mjs.map +4 -4
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/lib/node-esm/playwright/index.mjs +118 -10
- package/dist/lib/node-esm/playwright/index.mjs.map +3 -3
- package/dist/lib/node-esm/testing/index.mjs +10 -19
- package/dist/lib/node-esm/testing/index.mjs.map +3 -3
- package/dist/lib/node-esm/translations.mjs +20 -0
- package/dist/lib/node-esm/translations.mjs.map +7 -0
- package/dist/types/src/components/Board/Board.d.ts +12 -4
- package/dist/types/src/components/Board/Board.d.ts.map +1 -1
- package/dist/types/src/components/Board/Board.stories.d.ts +3 -3
- package/dist/types/src/components/Board/Board.stories.d.ts.map +1 -1
- package/dist/types/src/components/Board/Column.d.ts +5 -5
- 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 +9 -1
- package/dist/types/src/components/Mosaic/Container.d.ts.map +1 -1
- package/dist/types/src/components/Mosaic/Mosaic.d.ts +3 -2
- package/dist/types/src/components/Mosaic/Mosaic.d.ts.map +1 -1
- package/dist/types/src/components/Mosaic/Placeholder.d.ts +8 -8
- package/dist/types/src/components/Mosaic/Placeholder.d.ts.map +1 -1
- package/dist/types/src/components/Mosaic/Stack.d.ts +6 -1
- package/dist/types/src/components/Mosaic/Stack.d.ts.map +1 -1
- package/dist/types/src/components/Mosaic/Stack.stories.d.ts.map +1 -1
- package/dist/types/src/components/Mosaic/Tile.d.ts +6 -2
- package/dist/types/src/components/Mosaic/Tile.d.ts.map +1 -1
- package/dist/types/src/components/Mosaic/extension.d.ts.map +1 -1
- package/dist/types/src/components/Mosaic/styles.d.ts.map +1 -1
- package/dist/types/src/components/Mosaic/types.d.ts.map +1 -1
- package/dist/types/src/components/SearchStack/SearchStack.d.ts +20 -0
- package/dist/types/src/components/SearchStack/SearchStack.d.ts.map +1 -0
- package/dist/types/src/components/SearchStack/SearchStack.stories.d.ts +10 -0
- package/dist/types/src/components/SearchStack/SearchStack.stories.d.ts.map +1 -0
- package/dist/types/src/components/SearchStack/index.d.ts +2 -0
- package/dist/types/src/components/SearchStack/index.d.ts.map +1 -0
- package/dist/types/src/components/index.d.ts +1 -0
- package/dist/types/src/components/index.d.ts.map +1 -1
- package/dist/types/src/hooks/useContainerDebug.d.ts.map +1 -1
- package/dist/types/src/hooks/useDefaultColumnEventHandler.d.ts.map +1 -1
- package/dist/types/src/hooks/useEventHandlerAdapter.d.ts +1 -1
- package/dist/types/src/hooks/useEventHandlerAdapter.d.ts.map +1 -1
- package/dist/types/src/hooks/useVisibleItems.d.ts.map +1 -1
- package/dist/types/src/playwright/board-manager.d.ts +20 -5
- package/dist/types/src/playwright/board-manager.d.ts.map +1 -1
- package/dist/types/src/playwright/playwright.config.d.ts.map +1 -1
- package/dist/types/src/testing/CardContainer.d.ts.map +1 -1
- package/dist/types/src/testing/DefaultStackTile.d.ts.map +1 -1
- package/dist/types/src/testing/types.d.ts +7 -9
- package/dist/types/src/testing/types.d.ts.map +1 -1
- package/dist/types/src/translations.d.ts +3 -3
- package/dist/types/src/translations.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +42 -40
- package/src/components/Board/Board.stories.tsx +11 -10
- package/src/components/Board/Board.tsx +4 -3
- package/src/components/Board/Column.tsx +22 -18
- package/src/components/Board/Item.tsx +14 -10
- package/src/components/Mosaic/Container.tsx +18 -2
- package/src/components/Mosaic/Placeholder.tsx +6 -0
- package/src/components/Mosaic/Stack.tsx +20 -5
- package/src/components/Mosaic/Tile.tsx +11 -2
- package/src/components/Mosaic/extension.ts +1 -1
- package/src/components/SearchStack/SearchStack.stories.tsx +47 -0
- package/src/components/SearchStack/SearchStack.tsx +135 -0
- package/src/components/SearchStack/index.ts +5 -0
- package/src/components/index.ts +1 -0
- package/src/hooks/useContainerDebug.tsx +3 -3
- package/src/hooks/useEventHandlerAdapter.ts +1 -1
- package/src/playwright/board-manager.ts +168 -13
- package/src/playwright/smoke.spec.ts +3 -3
- package/src/testing/CardContainer.tsx +2 -5
- package/src/testing/DefaultStackTile.tsx +11 -5
- package/src/testing/types.ts +7 -17
- package/dist/lib/browser/chunk-33NNDFD6.mjs.map +0 -7
- package/dist/lib/node-esm/chunk-Y4LG7O67.mjs.map +0 -7
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
|
6
|
+
import React, { useMemo } from 'react';
|
|
7
|
+
|
|
8
|
+
import { withAttention } from '@dxos/react-ui-attention/testing';
|
|
9
|
+
import { withMosaic } from '@dxos/react-ui-mosaic/testing';
|
|
10
|
+
import { type SearchResult } from '@dxos/react-ui-search';
|
|
11
|
+
import { withLayout, withTheme } from '@dxos/react-ui/testing';
|
|
12
|
+
|
|
13
|
+
import { SearchStack, type SearchStackProps } from './SearchStack';
|
|
14
|
+
|
|
15
|
+
const createMockResults = (count: number): SearchResult[] => {
|
|
16
|
+
return Array.from({ length: count }, (_, index) => ({
|
|
17
|
+
id: `result-${index}`,
|
|
18
|
+
label: `Search Result ${index + 1}`,
|
|
19
|
+
snippet: index % 2 === 0 ? `This is a preview snippet for result ${index + 1}.` : undefined,
|
|
20
|
+
type: index % 3 === 0 ? 'document' : index % 3 === 1 ? 'contact' : 'project',
|
|
21
|
+
}));
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const SearchStackStory = (props: Omit<SearchStackProps, 'id' | 'results'>) => {
|
|
25
|
+
const results = useMemo(() => createMockResults(50), []);
|
|
26
|
+
return <SearchStack id='story' results={results} {...props} />;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const meta: Meta<typeof SearchStackStory> = {
|
|
30
|
+
title: 'ui/react-ui-search/SearchStack',
|
|
31
|
+
component: SearchStackStory,
|
|
32
|
+
parameters: {
|
|
33
|
+
layout: 'fullscreen',
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export default meta;
|
|
38
|
+
|
|
39
|
+
type Story = StoryObj<typeof meta>;
|
|
40
|
+
|
|
41
|
+
export const Default: Story = {
|
|
42
|
+
decorators: [withTheme(), withLayout({ layout: 'column' }), withAttention(), withMosaic()],
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const Responsive: Story = {
|
|
46
|
+
decorators: [withTheme(), withLayout({ layout: 'column', classNames: 'w-[30rem]' }), withAttention(), withMosaic()],
|
|
47
|
+
};
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import React, { type KeyboardEvent, forwardRef, useCallback, useMemo, useState } from 'react';
|
|
6
|
+
|
|
7
|
+
import { Card, ScrollArea } from '@dxos/react-ui';
|
|
8
|
+
import { composable, composableProps } from '@dxos/react-ui';
|
|
9
|
+
import { type SearchResult } from '@dxos/react-ui-search';
|
|
10
|
+
|
|
11
|
+
import { Focus, Mosaic, type MosaicTileProps, useMosaicContainer } from '../..';
|
|
12
|
+
|
|
13
|
+
export type SearchStackAction = {
|
|
14
|
+
type: 'select';
|
|
15
|
+
resultId: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type SearchStackActionHandler = (action: SearchStackAction) => void;
|
|
19
|
+
|
|
20
|
+
//
|
|
21
|
+
// SearchStack
|
|
22
|
+
//
|
|
23
|
+
|
|
24
|
+
export type SearchStackProps = {
|
|
25
|
+
id: string;
|
|
26
|
+
results?: SearchResult[];
|
|
27
|
+
currentId?: string;
|
|
28
|
+
onAction?: SearchStackActionHandler;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Card-based search result stack component using mosaic layout.
|
|
33
|
+
*/
|
|
34
|
+
export const SearchStack = composable<HTMLDivElement, SearchStackProps>(
|
|
35
|
+
({ results = [], currentId, onAction, ...props }, forwardedRef) => {
|
|
36
|
+
const [viewport, setViewport] = useState<HTMLElement | null>(null);
|
|
37
|
+
const items = useMemo(() => results.map((result) => ({ result, onAction })), [results, onAction]);
|
|
38
|
+
|
|
39
|
+
const handleCurrentChange = useCallback(
|
|
40
|
+
(id: string | undefined) => {
|
|
41
|
+
if (id) {
|
|
42
|
+
onAction?.({ type: 'select', resultId: id });
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
[onAction],
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const handleKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
|
|
49
|
+
if (event.key === 'Enter') {
|
|
50
|
+
event.preventDefault();
|
|
51
|
+
(document.activeElement as HTMLElement | null)?.click();
|
|
52
|
+
}
|
|
53
|
+
}, []);
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<Focus.Group asChild {...composableProps(props)} onKeyDown={handleKeyDown} ref={forwardedRef}>
|
|
57
|
+
<Mosaic.Container asChild withFocus currentId={currentId} onCurrentChange={handleCurrentChange}>
|
|
58
|
+
<ScrollArea.Root orientation='vertical' padding centered thin>
|
|
59
|
+
<ScrollArea.Viewport ref={setViewport}>
|
|
60
|
+
<Mosaic.VirtualStack
|
|
61
|
+
Tile={SearchTile}
|
|
62
|
+
classNames='my-2'
|
|
63
|
+
gap={8}
|
|
64
|
+
items={items}
|
|
65
|
+
draggable={false}
|
|
66
|
+
getId={(item) => item.result.id}
|
|
67
|
+
getScrollElement={() => viewport}
|
|
68
|
+
estimateSize={() => 100}
|
|
69
|
+
/>
|
|
70
|
+
</ScrollArea.Viewport>
|
|
71
|
+
</ScrollArea.Root>
|
|
72
|
+
</Mosaic.Container>
|
|
73
|
+
</Focus.Group>
|
|
74
|
+
);
|
|
75
|
+
},
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
SearchStack.displayName = 'SearchStack';
|
|
79
|
+
|
|
80
|
+
//
|
|
81
|
+
// SearchTile
|
|
82
|
+
//
|
|
83
|
+
|
|
84
|
+
type SearchTileData = {
|
|
85
|
+
result: SearchResult;
|
|
86
|
+
onAction?: SearchStackActionHandler;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
type SearchTileProps = Pick<MosaicTileProps<SearchTileData>, 'location' | 'data' | 'current'>;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Default search result tile with a simple Card header.
|
|
93
|
+
*/
|
|
94
|
+
const SearchTile = forwardRef<HTMLDivElement, SearchTileProps>(({ data, location, current }, forwardedRef) => {
|
|
95
|
+
const { result } = data;
|
|
96
|
+
const { setCurrentId } = useMosaicContainer('SearchTile');
|
|
97
|
+
|
|
98
|
+
const handleCurrentChange = useCallback(() => {
|
|
99
|
+
setCurrentId(result.id);
|
|
100
|
+
}, [result.id, setCurrentId]);
|
|
101
|
+
|
|
102
|
+
// Navigation / current pattern: clicking a result navigates to its
|
|
103
|
+
// detail. `dx-current` pairs with the `aria-current` attribute that
|
|
104
|
+
// `Mosaic.Tile` now sets from the `current` prop. `dx-selected`
|
|
105
|
+
// (master/detail option pattern) would be the wrong grammar here.
|
|
106
|
+
// See `ui-theme/src/css/components/selected.md`.
|
|
107
|
+
return (
|
|
108
|
+
<Mosaic.Tile
|
|
109
|
+
asChild
|
|
110
|
+
classNames='dx-hover dx-current'
|
|
111
|
+
id={result.id}
|
|
112
|
+
data={data}
|
|
113
|
+
location={location}
|
|
114
|
+
current={current}
|
|
115
|
+
>
|
|
116
|
+
<Focus.Item asChild current={current} onCurrentChange={handleCurrentChange}>
|
|
117
|
+
<Card.Root fullWidth ref={forwardedRef}>
|
|
118
|
+
<Card.Header>
|
|
119
|
+
<Card.IconBlock />
|
|
120
|
+
<Card.Title>{result.label}</Card.Title>
|
|
121
|
+
</Card.Header>
|
|
122
|
+
{result.snippet && (
|
|
123
|
+
<Card.Body>
|
|
124
|
+
<Card.Row>
|
|
125
|
+
<Card.Text variant='description'>{result.snippet}</Card.Text>
|
|
126
|
+
</Card.Row>
|
|
127
|
+
</Card.Body>
|
|
128
|
+
)}
|
|
129
|
+
</Card.Root>
|
|
130
|
+
</Focus.Item>
|
|
131
|
+
</Mosaic.Tile>
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
SearchTile.displayName = 'SearchTile';
|
package/src/components/index.ts
CHANGED
|
@@ -6,7 +6,7 @@ import React, { type FC, type ReactNode, forwardRef, useMemo, useRef } from 'rea
|
|
|
6
6
|
import { createPortal } from 'react-dom';
|
|
7
7
|
|
|
8
8
|
import { type ThemedClassName } from '@dxos/react-ui';
|
|
9
|
-
import {
|
|
9
|
+
import { JsonHighlighter } from '@dxos/react-ui-syntax-highlighter';
|
|
10
10
|
import { mx } from '@dxos/ui-theme';
|
|
11
11
|
|
|
12
12
|
import { useMosaicContainer } from '../components';
|
|
@@ -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]);
|
|
@@ -37,7 +37,7 @@ const ContainerInfo = forwardRef<HTMLDivElement, ThemedClassName>(({ classNames
|
|
|
37
37
|
const { id, state, activeLocation, scrolling } = useMosaicContainer(CONTAINER_INFO_NAME);
|
|
38
38
|
const counter = useRef(0);
|
|
39
39
|
return (
|
|
40
|
-
<
|
|
40
|
+
<JsonHighlighter
|
|
41
41
|
data={{ id, activeLocation, scrolling, state, count: counter.current++ }}
|
|
42
42
|
classNames={mx('text-xs', classNames)}
|
|
43
43
|
ref={forwardedRef}
|
|
@@ -35,7 +35,7 @@ export type UseEventHandlerProps<TItem = any, TObject extends Obj.Unknown = Obj.
|
|
|
35
35
|
make: (object: TObject) => TItem;
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
|
-
* Optional change callback for wrapping mutations in Obj.
|
|
38
|
+
* Optional change callback for wrapping mutations in Obj.update style.
|
|
39
39
|
* When provided, all array mutations will be wrapped in this callback.
|
|
40
40
|
* When not provided, mutations happen directly on the items array.
|
|
41
41
|
*/
|
|
@@ -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,27 +94,150 @@ 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
|
/**
|
|
103
|
-
* Drag this item to a column's scroll bottom,
|
|
104
|
-
* then
|
|
231
|
+
* Drag this item to a column's scroll bottom, using auto-scroll when needed,
|
|
232
|
+
* then drop on the target item.
|
|
233
|
+
*
|
|
234
|
+
* When the target is already visible in the viewport (no scrolling needed), the
|
|
235
|
+
* item is dragged directly to the target. When the target is below the viewport,
|
|
236
|
+
* the mouse is held near the footer to trigger pragmatic auto-scroll until the
|
|
237
|
+
* target scrolls into view.
|
|
105
238
|
*
|
|
106
239
|
* @param holdTarget - The footer element; mouse is held ~20px above it to trigger auto-scroll.
|
|
107
|
-
* @param dropTarget - The element to drop on
|
|
240
|
+
* @param dropTarget - The element to drop on.
|
|
108
241
|
* @param dropOffset - Offset from the drop target center.
|
|
109
242
|
*/
|
|
110
243
|
async dragToEndWithAutoScroll(holdTarget: Locator, dropTarget: Locator, dropOffset = { x: 0, y: 0 }): Promise<void> {
|
|
@@ -118,11 +251,33 @@ export class ItemManager {
|
|
|
118
251
|
const holdX = holdBox.x + holdBox.width / 2;
|
|
119
252
|
const holdY = holdBox.y - 20;
|
|
120
253
|
|
|
254
|
+
// Check if drop target is already visible before starting the drag.
|
|
255
|
+
// Items may fit in the viewport without scrolling, in which case auto-scroll
|
|
256
|
+
// is not needed and the loop's exit condition would never be satisfied.
|
|
257
|
+
const preDropBox = await dropTarget.boundingBox();
|
|
258
|
+
const needsScroll = !preDropBox || preDropBox.y >= holdBox.y;
|
|
259
|
+
|
|
121
260
|
await handle.hover();
|
|
122
261
|
await this._page.mouse.down();
|
|
123
262
|
// Allow the drag monitor to register the grab before moving.
|
|
124
263
|
await this._page.waitForTimeout(100);
|
|
125
264
|
|
|
265
|
+
if (!needsScroll) {
|
|
266
|
+
// Target is already visible — drag directly without auto-scroll.
|
|
267
|
+
await this._page.mouse.move(holdX, holdY, { steps: 4 });
|
|
268
|
+
const dropBox = await dropTarget.boundingBox();
|
|
269
|
+
if (dropBox) {
|
|
270
|
+
const dropX = dropOffset.x + dropBox.x + dropBox.width / 2;
|
|
271
|
+
const dropY = dropOffset.y + dropBox.y + dropBox.height / 2;
|
|
272
|
+
await this._page.mouse.move(dropX, dropY, { steps: 8 });
|
|
273
|
+
await this._page.waitForTimeout(200);
|
|
274
|
+
await this._page.mouse.up();
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
await this._page.mouse.up();
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
126
281
|
// Move to hold position above the footer to trigger auto-scroll.
|
|
127
282
|
await this._page.mouse.move(holdX, holdY, { steps: 4 });
|
|
128
283
|
|
|
@@ -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}
|
|
@@ -7,7 +7,7 @@ import React, { useMemo, useRef, useState } from 'react';
|
|
|
7
7
|
import { Obj } from '@dxos/echo';
|
|
8
8
|
import { Card, Toolbar } from '@dxos/react-ui';
|
|
9
9
|
import { Menu, createMenuAction } from '@dxos/react-ui-menu';
|
|
10
|
-
import {
|
|
10
|
+
import { JsonHighlighter } from '@dxos/react-ui-syntax-highlighter';
|
|
11
11
|
|
|
12
12
|
import { Focus, Mosaic, type MosaicStackTileComponent } from '../components';
|
|
13
13
|
|
|
@@ -18,7 +18,7 @@ export const DefaultStackTile: MosaicStackTileComponent<Obj.Any> = (props) => {
|
|
|
18
18
|
() => [
|
|
19
19
|
createMenuAction('toggle-details', () => setOpen((prev) => !prev), {
|
|
20
20
|
label: open ? 'Hide details' : 'Show details',
|
|
21
|
-
icon: 'ph--
|
|
21
|
+
icon: 'ph--circle-dashed--regular',
|
|
22
22
|
}),
|
|
23
23
|
],
|
|
24
24
|
[open],
|
|
@@ -26,20 +26,26 @@ export const DefaultStackTile: MosaicStackTileComponent<Obj.Any> = (props) => {
|
|
|
26
26
|
|
|
27
27
|
return (
|
|
28
28
|
<Menu.Root>
|
|
29
|
+
{/*
|
|
30
|
+
* `Mosaic.Tile` sets `aria-current` from `props.current`, which the
|
|
31
|
+
* Slot composition propagates down to `Card.Root`'s div. That's what
|
|
32
|
+
* makes `dx-current` (an `aria-[current=true]:` utility) actually
|
|
33
|
+
* fire here. See `ui-theme/src/css/components/selected.md`.
|
|
34
|
+
*/}
|
|
29
35
|
<Mosaic.Tile {...props} asChild>
|
|
30
36
|
<Focus.Item asChild>
|
|
31
37
|
<Card.Root className='dx-current dx-hover'>
|
|
32
|
-
<Card.
|
|
38
|
+
<Card.Header>
|
|
33
39
|
<Card.DragHandle ref={dragHandleRef} />
|
|
34
40
|
<Card.Title>{Obj.getLabel(props.data) ?? props.data.id}</Card.Title>
|
|
35
41
|
<Menu.Trigger asChild disabled={!menuItems?.length}>
|
|
36
42
|
<Toolbar.IconButton iconOnly variant='ghost' icon='ph--dots-three-vertical--regular' label='Menu' />
|
|
37
43
|
</Menu.Trigger>
|
|
38
44
|
<Menu.Content items={menuItems} />
|
|
39
|
-
</Card.
|
|
45
|
+
</Card.Header>
|
|
40
46
|
{open && (
|
|
41
47
|
<Card.Row>
|
|
42
|
-
<
|
|
48
|
+
<JsonHighlighter data={props.data} classNames='text-xs' />
|
|
43
49
|
</Card.Row>
|
|
44
50
|
)}
|
|
45
51
|
</Card.Root>
|
package/src/testing/types.ts
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
import * as Schema from 'effect/Schema';
|
|
6
6
|
|
|
7
|
-
import { Ref, Type } from '@dxos/echo';
|
|
8
|
-
import {
|
|
7
|
+
import { DXN, Ref, Type } from '@dxos/echo';
|
|
8
|
+
import { EntityId } from '@dxos/keys';
|
|
9
9
|
|
|
10
10
|
//
|
|
11
11
|
// Test Data
|
|
@@ -15,24 +15,14 @@ export const TestItem = Schema.Struct({
|
|
|
15
15
|
name: Schema.String,
|
|
16
16
|
description: Schema.optional(Schema.String),
|
|
17
17
|
label: Schema.optional(Schema.String),
|
|
18
|
-
}).pipe(
|
|
19
|
-
Type.object({
|
|
20
|
-
typename: 'com.example.type.item',
|
|
21
|
-
version: '0.1.0',
|
|
22
|
-
}),
|
|
23
|
-
);
|
|
18
|
+
}).pipe(Type.makeObject(DXN.make('com.example.type.item', '0.1.0')));
|
|
24
19
|
|
|
25
|
-
export
|
|
20
|
+
export type TestItem = Type.InstanceType<typeof TestItem>;
|
|
26
21
|
|
|
27
22
|
export const TestColumn = Schema.Struct({
|
|
28
|
-
id:
|
|
23
|
+
id: EntityId,
|
|
29
24
|
name: Schema.String,
|
|
30
25
|
items: Schema.mutable(Schema.Array(Ref.Ref(TestItem))),
|
|
31
|
-
}).pipe(
|
|
32
|
-
Type.object({
|
|
33
|
-
typename: 'com.example.type.column',
|
|
34
|
-
version: '0.1.0',
|
|
35
|
-
}),
|
|
36
|
-
);
|
|
26
|
+
}).pipe(Type.makeObject(DXN.make('com.example.type.column', '0.1.0')));
|
|
37
27
|
|
|
38
|
-
export
|
|
28
|
+
export type TestColumn = Type.InstanceType<typeof TestColumn>;
|