@dxos/plugin-simple-layout 0.0.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 +8 -0
- package/package.json +66 -0
- package/src/SimpleLayoutPlugin.ts +31 -0
- package/src/capabilities/index.ts +7 -0
- package/src/capabilities/operation-resolver/index.ts +10 -0
- package/src/capabilities/operation-resolver/operation-resolver.ts +135 -0
- package/src/capabilities/react-root/index.ts +7 -0
- package/src/capabilities/react-root/react-root.tsx +22 -0
- package/src/capabilities/state/index.ts +9 -0
- package/src/capabilities/state/state.tsx +60 -0
- package/src/components/ContentError.stories.tsx +41 -0
- package/src/components/ContentError.tsx +23 -0
- package/src/components/ContentLoading.stories.tsx +24 -0
- package/src/components/ContentLoading.tsx +10 -0
- package/src/components/Dialog/Dialog.tsx +38 -0
- package/src/components/Dialog/index.ts +5 -0
- package/src/components/Home/Home.tsx +138 -0
- package/src/components/Home/index.ts +5 -0
- package/src/components/Popover/Popover.tsx +102 -0
- package/src/components/Popover/index.ts +5 -0
- package/src/components/SimpleLayout/Banner.tsx +60 -0
- package/src/components/SimpleLayout/Main.tsx +85 -0
- package/src/components/SimpleLayout/NavBar.tsx +97 -0
- package/src/components/SimpleLayout/NavBarstories.tsx +59 -0
- package/src/components/SimpleLayout/SimpleLayout.stories.tsx +107 -0
- package/src/components/SimpleLayout/SimpleLayout.tsx +21 -0
- package/src/components/SimpleLayout/index.ts +5 -0
- package/src/components/index.ts +8 -0
- package/src/hooks/index.ts +5 -0
- package/src/hooks/useSpotlightDismiss.ts +95 -0
- package/src/index.ts +5 -0
- package/src/meta.ts +16 -0
- package/src/translations.ts +28 -0
- package/src/types/capabilities.ts +37 -0
- package/src/types/index.ts +5 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { createContext } from '@radix-ui/react-context';
|
|
6
|
+
import React, { type PropsWithChildren, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
7
|
+
|
|
8
|
+
import { Surface, useCapability } from '@dxos/app-framework/react';
|
|
9
|
+
import { Popover, type PopoverContentInteractOutsideEvent } from '@dxos/react-ui';
|
|
10
|
+
|
|
11
|
+
import { SimpleLayoutState } from '../../types';
|
|
12
|
+
|
|
13
|
+
const DEBOUNCE_DELAY = 40;
|
|
14
|
+
|
|
15
|
+
type LayoutPopoverContextValue = {
|
|
16
|
+
setOpen: (open: boolean) => void;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const [LayoutPopoverProvider, useLayoutPopoverContext] = createContext<LayoutPopoverContextValue>('LayoutPopover');
|
|
20
|
+
|
|
21
|
+
export const PopoverRoot = ({ children }: PropsWithChildren) => {
|
|
22
|
+
const layout = useCapability(SimpleLayoutState);
|
|
23
|
+
const [open, setOpen] = useState(false);
|
|
24
|
+
const virtualRef = useRef<HTMLButtonElement | null>(null);
|
|
25
|
+
const [virtualIter, setVirtualIter] = useState(0);
|
|
26
|
+
const debounceRef = useRef<NodeJS.Timeout | null>(null);
|
|
27
|
+
|
|
28
|
+
// TODO(thure): This is a workaround for the race condition between displaying a Popover and either rendering
|
|
29
|
+
// the anchor further down the tree or measuring the virtual trigger's client rect.
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
setOpen(false);
|
|
32
|
+
if (layout.popoverOpen) {
|
|
33
|
+
if (debounceRef.current) {
|
|
34
|
+
clearTimeout(debounceRef.current);
|
|
35
|
+
}
|
|
36
|
+
if (layout.popoverAnchor && virtualRef.current !== layout.popoverAnchor) {
|
|
37
|
+
virtualRef.current = layout.popoverAnchor ?? null;
|
|
38
|
+
setVirtualIter((iter) => iter + 1);
|
|
39
|
+
}
|
|
40
|
+
debounceRef.current = setTimeout(() => setOpen(true), DEBOUNCE_DELAY);
|
|
41
|
+
}
|
|
42
|
+
}, [layout.popoverOpen, layout.popoverAnchorId, layout.popoverAnchor, layout.popoverContent]);
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<LayoutPopoverProvider setOpen={setOpen}>
|
|
46
|
+
<Popover.Root modal={false} open={open}>
|
|
47
|
+
{layout.popoverAnchor && <Popover.VirtualTrigger key={virtualIter} virtualRef={virtualRef} />}
|
|
48
|
+
{children}
|
|
49
|
+
</Popover.Root>
|
|
50
|
+
</LayoutPopoverProvider>
|
|
51
|
+
);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const PopoverContent = () => {
|
|
55
|
+
const layout = useCapability(SimpleLayoutState);
|
|
56
|
+
const { setOpen } = useLayoutPopoverContext('PopoverContent');
|
|
57
|
+
|
|
58
|
+
const handleClose = useCallback(
|
|
59
|
+
(event: KeyboardEvent | PopoverContentInteractOutsideEvent) => {
|
|
60
|
+
if (
|
|
61
|
+
// TODO(thure): CodeMirror should not focus itself when it updates.
|
|
62
|
+
event.type === 'dismissableLayer.focusOutside' &&
|
|
63
|
+
(event.currentTarget as HTMLElement | undefined)?.classList.contains('cm-content')
|
|
64
|
+
) {
|
|
65
|
+
event.preventDefault();
|
|
66
|
+
} else {
|
|
67
|
+
setOpen(false);
|
|
68
|
+
layout.popoverOpen = false;
|
|
69
|
+
layout.popoverAnchor = undefined;
|
|
70
|
+
layout.popoverAnchorId = undefined;
|
|
71
|
+
layout.popoverSide = undefined;
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
[setOpen],
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const collisionBoundaries: HTMLElement[] = useMemo(() => {
|
|
78
|
+
const closest = layout.popoverAnchor?.closest('[data-popover-collision-boundary]') as
|
|
79
|
+
| HTMLElement
|
|
80
|
+
| null
|
|
81
|
+
| undefined;
|
|
82
|
+
return closest ? [closest] : [];
|
|
83
|
+
}, [layout.popoverAnchor]);
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<Popover.Portal>
|
|
87
|
+
<Popover.Content
|
|
88
|
+
side={layout.popoverSide}
|
|
89
|
+
onInteractOutside={handleClose}
|
|
90
|
+
onEscapeKeyDown={handleClose}
|
|
91
|
+
collisionBoundary={collisionBoundaries}
|
|
92
|
+
sticky='always'
|
|
93
|
+
hideWhenDetached
|
|
94
|
+
>
|
|
95
|
+
<Popover.Viewport>
|
|
96
|
+
<Surface role='card--popover' data={layout.popoverContent} limit={1} />
|
|
97
|
+
</Popover.Viewport>
|
|
98
|
+
<Popover.Arrow />
|
|
99
|
+
</Popover.Content>
|
|
100
|
+
</Popover.Portal>
|
|
101
|
+
);
|
|
102
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import React, { useCallback } from 'react';
|
|
6
|
+
|
|
7
|
+
import { Common } from '@dxos/app-framework';
|
|
8
|
+
import { useCapability, useOperationInvoker } from '@dxos/app-framework/react';
|
|
9
|
+
import { type Node } from '@dxos/plugin-graph';
|
|
10
|
+
import { IconButton, toLocalizedString, useTranslation } from '@dxos/react-ui';
|
|
11
|
+
import { mx, osTranslations, surfaceZIndex } from '@dxos/ui-theme';
|
|
12
|
+
|
|
13
|
+
import { meta } from '../../meta';
|
|
14
|
+
import { SimpleLayoutState } from '../../types';
|
|
15
|
+
|
|
16
|
+
export type BannerProps = {
|
|
17
|
+
node?: Node.Node;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const Banner = ({ node }: BannerProps) => {
|
|
21
|
+
const { t } = useTranslation(meta.id);
|
|
22
|
+
const layout = useCapability(SimpleLayoutState);
|
|
23
|
+
const { invokePromise } = useOperationInvoker();
|
|
24
|
+
const label = node ? toLocalizedString(node.properties.label, t) : t('current app name', { ns: osTranslations });
|
|
25
|
+
|
|
26
|
+
const handleClick = useCallback(async () => {
|
|
27
|
+
if (layout.active) {
|
|
28
|
+
await invokePromise(Common.LayoutOperation.Close, { subject: [layout.active] });
|
|
29
|
+
} else {
|
|
30
|
+
await invokePromise(Common.LayoutOperation.SwitchWorkspace, { subject: 'default' });
|
|
31
|
+
}
|
|
32
|
+
}, [invokePromise, layout.active]);
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
// Note that the HTML5 element `header` has a default role of `banner`, hence the name of this component.
|
|
36
|
+
// It should not be confused with the `heading` role (elements h1-6).
|
|
37
|
+
// TODO(burdon): Fixed or not?
|
|
38
|
+
<header
|
|
39
|
+
className={mx(
|
|
40
|
+
'_fixed flex items-center gap-2 pli-2 block-start-0 inset-inline-0 bs-[--dx-mobile-topbar-content-height,48px] bg-baseSurface border-be border-separator',
|
|
41
|
+
'grid grid-cols-[min-content_1fr_min-content]',
|
|
42
|
+
surfaceZIndex({ level: 'menu' }),
|
|
43
|
+
)}
|
|
44
|
+
>
|
|
45
|
+
{node ? (
|
|
46
|
+
<IconButton
|
|
47
|
+
iconOnly
|
|
48
|
+
variant='ghost'
|
|
49
|
+
icon='ph--caret-left--regular'
|
|
50
|
+
label={t('back label')}
|
|
51
|
+
onClick={handleClick}
|
|
52
|
+
/>
|
|
53
|
+
) : (
|
|
54
|
+
<div />
|
|
55
|
+
)}
|
|
56
|
+
<h1 className={'grow text-center truncate font-medium'}>{label}</h1>
|
|
57
|
+
{/* TODO(burdon): Menu. */}
|
|
58
|
+
</header>
|
|
59
|
+
);
|
|
60
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import React, { Activity, useMemo } from 'react';
|
|
6
|
+
|
|
7
|
+
import { Surface, useAppGraph, useCapability } from '@dxos/app-framework/react';
|
|
8
|
+
import { useNode } from '@dxos/plugin-graph';
|
|
9
|
+
import { Main as NaturalMain } from '@dxos/react-ui';
|
|
10
|
+
import { ATTENDABLE_PATH_SEPARATOR } from '@dxos/react-ui-attention';
|
|
11
|
+
import { mx } from '@dxos/ui-theme';
|
|
12
|
+
|
|
13
|
+
import { SimpleLayoutState } from '../../types';
|
|
14
|
+
import { ContentError } from '../ContentError';
|
|
15
|
+
import { ContentLoading } from '../ContentLoading';
|
|
16
|
+
import { Home } from '../Home';
|
|
17
|
+
|
|
18
|
+
import { Banner } from './Banner';
|
|
19
|
+
import { NavBar } from './NavBar';
|
|
20
|
+
|
|
21
|
+
export const Main = () => {
|
|
22
|
+
const layout = useCapability(SimpleLayoutState);
|
|
23
|
+
const id = layout.active ?? layout.workspace;
|
|
24
|
+
const { graph } = useAppGraph();
|
|
25
|
+
const node = useNode(graph, id);
|
|
26
|
+
|
|
27
|
+
const placeholder = useMemo(() => <ContentLoading />, []);
|
|
28
|
+
|
|
29
|
+
const { variant } = parseEntryId(id);
|
|
30
|
+
const data = useMemo(
|
|
31
|
+
() =>
|
|
32
|
+
node && {
|
|
33
|
+
attendableId: id,
|
|
34
|
+
subject: node.data,
|
|
35
|
+
properties: node.properties,
|
|
36
|
+
variant,
|
|
37
|
+
popoverAnchorId: layout.popoverAnchorId,
|
|
38
|
+
},
|
|
39
|
+
[node, node?.data, node?.properties, layout.popoverAnchorId, variant, id],
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const handleActiveIdChange = (nextActiveId: string | null) => {
|
|
43
|
+
// eslint-disable-next-line no-console
|
|
44
|
+
console.log('[navigate]', nextActiveId);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const showNavBar = !layout.isPopover;
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<NaturalMain.Root complementarySidebarState='closed' navigationSidebarState='closed'>
|
|
51
|
+
<NaturalMain.Content bounce classNames='dx-mobile-main dx-mobile-main-scroll-area--flush !overflow-y-auto'>
|
|
52
|
+
<div
|
|
53
|
+
className={mx(
|
|
54
|
+
'bs-full overflow-hidden grid',
|
|
55
|
+
showNavBar ? 'grid-rows-[min-content_1fr_min-content]' : 'grid-rows-[min-content_1fr]',
|
|
56
|
+
)}
|
|
57
|
+
>
|
|
58
|
+
<Banner node={node} />
|
|
59
|
+
<Activity mode={id === 'default' ? 'visible' : 'hidden'}>
|
|
60
|
+
<Home />
|
|
61
|
+
</Activity>
|
|
62
|
+
<Activity mode={id !== 'default' ? 'visible' : 'hidden'}>
|
|
63
|
+
<section>
|
|
64
|
+
<Surface
|
|
65
|
+
key={id}
|
|
66
|
+
role='article'
|
|
67
|
+
data={data}
|
|
68
|
+
limit={1}
|
|
69
|
+
fallback={ContentError}
|
|
70
|
+
placeholder={placeholder}
|
|
71
|
+
/>
|
|
72
|
+
</section>
|
|
73
|
+
</Activity>
|
|
74
|
+
{showNavBar && <NavBar activeId={id} onActiveIdChange={handleActiveIdChange} />}
|
|
75
|
+
</div>
|
|
76
|
+
</NaturalMain.Content>
|
|
77
|
+
</NaturalMain.Root>
|
|
78
|
+
);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// TODO(wittjosiah): Factor out. Copied from deck plugin.
|
|
82
|
+
const parseEntryId = (entryId: string) => {
|
|
83
|
+
const [id, variant] = entryId.split(ATTENDABLE_PATH_SEPARATOR);
|
|
84
|
+
return { id, variant };
|
|
85
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import React from 'react';
|
|
6
|
+
|
|
7
|
+
import { useAppGraph } from '@dxos/app-framework/react';
|
|
8
|
+
import { Node, useActionRunner, useConnections } from '@dxos/plugin-graph';
|
|
9
|
+
import {
|
|
10
|
+
Avatar,
|
|
11
|
+
Button,
|
|
12
|
+
ButtonGroup,
|
|
13
|
+
DensityProvider,
|
|
14
|
+
IconButton,
|
|
15
|
+
type Size,
|
|
16
|
+
Tooltip,
|
|
17
|
+
useTranslation,
|
|
18
|
+
} from '@dxos/react-ui';
|
|
19
|
+
import { DropdownMenu, MenuProvider } from '@dxos/react-ui-menu';
|
|
20
|
+
import { mx, surfaceZIndex } from '@dxos/ui-theme';
|
|
21
|
+
|
|
22
|
+
import { meta } from '../../meta';
|
|
23
|
+
|
|
24
|
+
const buttonProps = {
|
|
25
|
+
iconOnly: true,
|
|
26
|
+
size: 6 as Size,
|
|
27
|
+
classNames: 'aspect-square',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type NavBarProps = {
|
|
31
|
+
activeId?: string;
|
|
32
|
+
onActiveIdChange?: (nextActiveId: string | null) => void;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const NavBar = ({ activeId, onActiveIdChange }: NavBarProps) => {
|
|
36
|
+
const { t } = useTranslation(meta.id);
|
|
37
|
+
const { graph } = useAppGraph();
|
|
38
|
+
const runAction = useActionRunner();
|
|
39
|
+
|
|
40
|
+
const connections = useConnections(graph, Node.RootId);
|
|
41
|
+
const menuActions = connections.filter((node) => node.properties.disposition === 'menu');
|
|
42
|
+
|
|
43
|
+
const isBrowseActive = activeId !== 'notifications' && activeId !== 'profile';
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<DensityProvider density='coarse'>
|
|
47
|
+
<nav
|
|
48
|
+
className={mx(
|
|
49
|
+
'fixed inset-inline-0',
|
|
50
|
+
'grid grid-cols-[min-content_min-content] gap-2 place-content-center',
|
|
51
|
+
'block-end-[--dx-mobile-bottombar-inset-bottom,0px] bs-[--dx-mobile-bottombar-content-height,64px]',
|
|
52
|
+
'bg-baseSurface border-bs border-separator',
|
|
53
|
+
surfaceZIndex({ level: 'menu' }),
|
|
54
|
+
)}
|
|
55
|
+
>
|
|
56
|
+
<ButtonGroup>
|
|
57
|
+
<IconButton
|
|
58
|
+
{...buttonProps}
|
|
59
|
+
label={t('browse label')}
|
|
60
|
+
icon='ph--squares-four--regular'
|
|
61
|
+
onClick={() => onActiveIdChange?.(null)}
|
|
62
|
+
variant={isBrowseActive ? 'primary' : 'default'}
|
|
63
|
+
{...(isBrowseActive && { 'aria-current': 'location' })}
|
|
64
|
+
/>
|
|
65
|
+
<IconButton
|
|
66
|
+
{...buttonProps}
|
|
67
|
+
label={t('notifications label')}
|
|
68
|
+
icon='ph--bell-simple--regular'
|
|
69
|
+
onClick={() => onActiveIdChange?.('notifications')}
|
|
70
|
+
variant={activeId === 'notifications' ? 'primary' : 'default'}
|
|
71
|
+
{...(activeId === 'notifications' && { 'aria-current': 'location' })}
|
|
72
|
+
/>
|
|
73
|
+
<Button
|
|
74
|
+
variant={activeId === 'profile' ? 'primary' : 'default'}
|
|
75
|
+
onClick={() => onActiveIdChange?.('profile')}
|
|
76
|
+
classNames={buttonProps.classNames}
|
|
77
|
+
>
|
|
78
|
+
<span className='sr-only'>{t('profile label')}</span>
|
|
79
|
+
<Avatar.Root>
|
|
80
|
+
<Avatar.Label classNames='sr-only'>Profile display name</Avatar.Label>
|
|
81
|
+
<Avatar.Content size={8} status='active' hue='cyan' fallback='🗿' />
|
|
82
|
+
</Avatar.Root>
|
|
83
|
+
</Button>
|
|
84
|
+
</ButtonGroup>
|
|
85
|
+
<MenuProvider onAction={runAction}>
|
|
86
|
+
<DropdownMenu.Root items={menuActions}>
|
|
87
|
+
<Tooltip.Trigger asChild content={t('app menu label')} side='right'>
|
|
88
|
+
<DropdownMenu.Trigger asChild data-testid='spacePlugin.addSpace'>
|
|
89
|
+
<IconButton {...buttonProps} icon='ph--plus--regular' label={t('main menu label')} />
|
|
90
|
+
</DropdownMenu.Trigger>
|
|
91
|
+
</Tooltip.Trigger>
|
|
92
|
+
</DropdownMenu.Root>
|
|
93
|
+
</MenuProvider>
|
|
94
|
+
</nav>
|
|
95
|
+
</DensityProvider>
|
|
96
|
+
);
|
|
97
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
|
6
|
+
|
|
7
|
+
import { withPluginManager } from '@dxos/app-framework/testing';
|
|
8
|
+
import { corePlugins } from '@dxos/plugin-testing';
|
|
9
|
+
import { withTheme } from '@dxos/react-ui/testing';
|
|
10
|
+
|
|
11
|
+
import { translations } from '../../translations';
|
|
12
|
+
|
|
13
|
+
import { NavBar } from './NavBar';
|
|
14
|
+
|
|
15
|
+
const meta = {
|
|
16
|
+
title: 'plugins/plugin-simple-layout/NavBar',
|
|
17
|
+
component: NavBar,
|
|
18
|
+
decorators: [
|
|
19
|
+
withTheme,
|
|
20
|
+
withPluginManager({
|
|
21
|
+
plugins: [...corePlugins()],
|
|
22
|
+
}),
|
|
23
|
+
],
|
|
24
|
+
parameters: {
|
|
25
|
+
layout: 'fullscreen',
|
|
26
|
+
translations,
|
|
27
|
+
},
|
|
28
|
+
argTypes: {
|
|
29
|
+
onActiveIdChange: { action: 'activeIdChanged' },
|
|
30
|
+
},
|
|
31
|
+
} satisfies Meta<typeof NavBar>;
|
|
32
|
+
|
|
33
|
+
export default meta;
|
|
34
|
+
|
|
35
|
+
type Story = StoryObj<typeof meta>;
|
|
36
|
+
|
|
37
|
+
export const Default: Story = {
|
|
38
|
+
args: {
|
|
39
|
+
activeId: undefined,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const BrowseActive: Story = {
|
|
44
|
+
args: {
|
|
45
|
+
activeId: 'some-document',
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const NotificationsActive: Story = {
|
|
50
|
+
args: {
|
|
51
|
+
activeId: 'notifications',
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const ProfileActive: Story = {
|
|
56
|
+
args: {
|
|
57
|
+
activeId: 'profile',
|
|
58
|
+
},
|
|
59
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
|
6
|
+
import * as Effect from 'effect/Effect';
|
|
7
|
+
|
|
8
|
+
import { Capability, Common, Plugin } from '@dxos/app-framework';
|
|
9
|
+
import { withPluginManager } from '@dxos/app-framework/testing';
|
|
10
|
+
import { ClientOperation, ClientPlugin } from '@dxos/plugin-client';
|
|
11
|
+
import { SearchPlugin } from '@dxos/plugin-search';
|
|
12
|
+
import { SpacePlugin } from '@dxos/plugin-space';
|
|
13
|
+
import { SpaceOperation } from '@dxos/plugin-space/types';
|
|
14
|
+
import { corePlugins } from '@dxos/plugin-testing';
|
|
15
|
+
import { withTheme } from '@dxos/react-ui/testing';
|
|
16
|
+
import { translations as searchTranslation } from '@dxos/react-ui-searchlist';
|
|
17
|
+
import { Collection } from '@dxos/schema';
|
|
18
|
+
|
|
19
|
+
import { OperationResolver, type SimpleLayoutStateOptions, State } from '../../capabilities';
|
|
20
|
+
import { meta as pluginMeta } from '../../meta';
|
|
21
|
+
import { type SimpleLayoutPluginOptions } from '../../SimpleLayoutPlugin';
|
|
22
|
+
import { translations } from '../../translations';
|
|
23
|
+
|
|
24
|
+
import { SimpleLayout } from './SimpleLayout';
|
|
25
|
+
|
|
26
|
+
const TestPlugin = Plugin.define<SimpleLayoutPluginOptions>(pluginMeta).pipe(
|
|
27
|
+
Plugin.addModule(({ isPopover = false }) => ({
|
|
28
|
+
id: Capability.getModuleTag(State),
|
|
29
|
+
activatesOn: Common.ActivationEvent.Startup,
|
|
30
|
+
activatesAfter: [Common.ActivationEvent.LayoutReady],
|
|
31
|
+
activate: () => State({ initialState: { isPopover } } satisfies SimpleLayoutStateOptions),
|
|
32
|
+
})),
|
|
33
|
+
Common.Plugin.addOperationResolverModule({ activate: OperationResolver }),
|
|
34
|
+
Plugin.addModule({
|
|
35
|
+
id: 'setup',
|
|
36
|
+
activatesOn: Common.ActivationEvent.OperationInvokerReady,
|
|
37
|
+
activate: Effect.fnUntraced(function* () {
|
|
38
|
+
const { invoke } = yield* Capability.get(Common.Capability.OperationInvoker);
|
|
39
|
+
yield* invoke(ClientOperation.CreateIdentity, {});
|
|
40
|
+
const { space: work } = yield* invoke(SpaceOperation.Create, { name: 'Work Space' });
|
|
41
|
+
const { space: sharedProject } = yield* invoke(SpaceOperation.Create, { name: 'Shared Project' });
|
|
42
|
+
|
|
43
|
+
// Add collections to Work Space.
|
|
44
|
+
yield* invoke(SpaceOperation.AddObject, {
|
|
45
|
+
target: work.db,
|
|
46
|
+
object: Collection.make({ name: 'Projects', objects: [] }),
|
|
47
|
+
});
|
|
48
|
+
yield* invoke(SpaceOperation.AddObject, {
|
|
49
|
+
target: work.db,
|
|
50
|
+
object: Collection.make({ name: 'Documents', objects: [] }),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Add collections to Shared Project.
|
|
54
|
+
yield* invoke(SpaceOperation.AddObject, {
|
|
55
|
+
target: sharedProject.db,
|
|
56
|
+
object: Collection.make({ name: 'Tasks', objects: [] }),
|
|
57
|
+
});
|
|
58
|
+
yield* invoke(SpaceOperation.AddObject, {
|
|
59
|
+
target: sharedProject.db,
|
|
60
|
+
object: Collection.make({ name: 'Notes', objects: [] }),
|
|
61
|
+
});
|
|
62
|
+
}),
|
|
63
|
+
}),
|
|
64
|
+
Plugin.make,
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const createPluginManager = ({ isPopover }: { isPopover: boolean }) => {
|
|
68
|
+
return withPluginManager({
|
|
69
|
+
plugins: [
|
|
70
|
+
...corePlugins(),
|
|
71
|
+
ClientPlugin({
|
|
72
|
+
onClientInitialized: ({ client }) =>
|
|
73
|
+
Effect.gen(function* () {
|
|
74
|
+
yield* Effect.promise(() => client.halo.createIdentity());
|
|
75
|
+
yield* Effect.promise(async () => {
|
|
76
|
+
await client.spaces.create({ name: 'Work Space' });
|
|
77
|
+
await client.spaces.create({ name: 'Shared Project' });
|
|
78
|
+
});
|
|
79
|
+
}),
|
|
80
|
+
}),
|
|
81
|
+
SpacePlugin({}),
|
|
82
|
+
SearchPlugin(),
|
|
83
|
+
TestPlugin({ isPopover }),
|
|
84
|
+
],
|
|
85
|
+
});
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const meta = {
|
|
89
|
+
title: 'plugins/plugin-simple-layout/SimpleLayout',
|
|
90
|
+
component: SimpleLayout,
|
|
91
|
+
parameters: {
|
|
92
|
+
layout: 'fullscreen',
|
|
93
|
+
translations: [...translations, ...searchTranslation],
|
|
94
|
+
},
|
|
95
|
+
} satisfies Meta<typeof SimpleLayout>;
|
|
96
|
+
|
|
97
|
+
export default meta;
|
|
98
|
+
|
|
99
|
+
type Story = StoryObj<typeof meta>;
|
|
100
|
+
|
|
101
|
+
export const Default: Story = {
|
|
102
|
+
decorators: [withTheme, createPluginManager({ isPopover: false })],
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export const Popover: Story = {
|
|
106
|
+
decorators: [withTheme, createPluginManager({ isPopover: true })],
|
|
107
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import React from 'react';
|
|
6
|
+
|
|
7
|
+
import { Dialog } from '../Dialog';
|
|
8
|
+
import { PopoverContent, PopoverRoot } from '../Popover';
|
|
9
|
+
|
|
10
|
+
import { Main } from './Main';
|
|
11
|
+
|
|
12
|
+
// TODO(wittjosiah): Support toast.
|
|
13
|
+
export const SimpleLayout = () => {
|
|
14
|
+
return (
|
|
15
|
+
<PopoverRoot>
|
|
16
|
+
<Main />
|
|
17
|
+
<Dialog />
|
|
18
|
+
<PopoverContent />
|
|
19
|
+
</PopoverRoot>
|
|
20
|
+
);
|
|
21
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
// Based on the frontend-driven dismiss pattern from:
|
|
6
|
+
// https://github.com/Jedliu/tauri-template-demo
|
|
7
|
+
|
|
8
|
+
import { useEffect } from 'react';
|
|
9
|
+
|
|
10
|
+
import { log } from '@dxos/log';
|
|
11
|
+
import { isTauri } from '@dxos/util';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Get the Tauri window API from the global object.
|
|
15
|
+
* Returns undefined if not running in Tauri.
|
|
16
|
+
*/
|
|
17
|
+
const getTauriWindow = (): any => {
|
|
18
|
+
const tauri = (globalThis as any).__TAURI__;
|
|
19
|
+
return tauri?.window;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Get the Tauri core API (invoke) from the global object.
|
|
24
|
+
* Returns undefined if not running in Tauri.
|
|
25
|
+
*/
|
|
26
|
+
const getTauriCore = (): any => {
|
|
27
|
+
const tauri = (globalThis as any).__TAURI__;
|
|
28
|
+
return tauri?.core;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Hook to set up spotlight panel dismiss behavior.
|
|
33
|
+
* When running in Tauri popover mode, listens for focus loss and Escape key
|
|
34
|
+
* to dismiss the spotlight panel.
|
|
35
|
+
*/
|
|
36
|
+
export const useSpotlightDismiss = (isPopover: boolean | undefined) => {
|
|
37
|
+
// Handle blur (click outside) to dismiss spotlight.
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!isPopover || !isTauri()) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let cleanup: (() => void) | undefined;
|
|
44
|
+
|
|
45
|
+
const setup = async () => {
|
|
46
|
+
try {
|
|
47
|
+
const tauriWindow = getTauriWindow();
|
|
48
|
+
const tauriCore = getTauriCore();
|
|
49
|
+
if (!tauriWindow || !tauriCore) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const win = tauriWindow.getCurrentWindow();
|
|
54
|
+
const unlisten = await win.onFocusChanged(async ({ payload }: { payload: boolean }) => {
|
|
55
|
+
if (!payload) {
|
|
56
|
+
await tauriCore.invoke('hide_spotlight');
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
cleanup = unlisten;
|
|
60
|
+
} catch (err) {
|
|
61
|
+
log.catch(err);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
void setup();
|
|
66
|
+
|
|
67
|
+
return () => {
|
|
68
|
+
cleanup?.();
|
|
69
|
+
};
|
|
70
|
+
}, [isPopover]);
|
|
71
|
+
|
|
72
|
+
// Handle Escape key to dismiss spotlight.
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (!isPopover || !isTauri()) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const handleKeyDown = async (event: KeyboardEvent) => {
|
|
79
|
+
if (event.key === 'Escape') {
|
|
80
|
+
event.preventDefault();
|
|
81
|
+
try {
|
|
82
|
+
const tauriCore = getTauriCore();
|
|
83
|
+
if (tauriCore) {
|
|
84
|
+
await tauriCore.invoke('hide_spotlight');
|
|
85
|
+
}
|
|
86
|
+
} catch (err) {
|
|
87
|
+
log.catch(err);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
93
|
+
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
94
|
+
}, [isPopover]);
|
|
95
|
+
};
|
package/src/index.ts
ADDED
package/src/meta.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type Plugin } from '@dxos/app-framework';
|
|
6
|
+
import { trim } from '@dxos/util';
|
|
7
|
+
|
|
8
|
+
export const meta: Plugin.Meta = {
|
|
9
|
+
id: 'dxos.org/plugin/simple-layout',
|
|
10
|
+
name: 'Simple Layout',
|
|
11
|
+
description: trim`
|
|
12
|
+
Minimal layout plugin for simplified UI contexts like popover windows.
|
|
13
|
+
Provides basic content rendering without sidebars or complex navigation.
|
|
14
|
+
`,
|
|
15
|
+
icon: 'ph--layout--regular',
|
|
16
|
+
};
|