@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.
Files changed (35) hide show
  1. package/LICENSE +8 -0
  2. package/package.json +66 -0
  3. package/src/SimpleLayoutPlugin.ts +31 -0
  4. package/src/capabilities/index.ts +7 -0
  5. package/src/capabilities/operation-resolver/index.ts +10 -0
  6. package/src/capabilities/operation-resolver/operation-resolver.ts +135 -0
  7. package/src/capabilities/react-root/index.ts +7 -0
  8. package/src/capabilities/react-root/react-root.tsx +22 -0
  9. package/src/capabilities/state/index.ts +9 -0
  10. package/src/capabilities/state/state.tsx +60 -0
  11. package/src/components/ContentError.stories.tsx +41 -0
  12. package/src/components/ContentError.tsx +23 -0
  13. package/src/components/ContentLoading.stories.tsx +24 -0
  14. package/src/components/ContentLoading.tsx +10 -0
  15. package/src/components/Dialog/Dialog.tsx +38 -0
  16. package/src/components/Dialog/index.ts +5 -0
  17. package/src/components/Home/Home.tsx +138 -0
  18. package/src/components/Home/index.ts +5 -0
  19. package/src/components/Popover/Popover.tsx +102 -0
  20. package/src/components/Popover/index.ts +5 -0
  21. package/src/components/SimpleLayout/Banner.tsx +60 -0
  22. package/src/components/SimpleLayout/Main.tsx +85 -0
  23. package/src/components/SimpleLayout/NavBar.tsx +97 -0
  24. package/src/components/SimpleLayout/NavBarstories.tsx +59 -0
  25. package/src/components/SimpleLayout/SimpleLayout.stories.tsx +107 -0
  26. package/src/components/SimpleLayout/SimpleLayout.tsx +21 -0
  27. package/src/components/SimpleLayout/index.ts +5 -0
  28. package/src/components/index.ts +8 -0
  29. package/src/hooks/index.ts +5 -0
  30. package/src/hooks/useSpotlightDismiss.ts +95 -0
  31. package/src/index.ts +5 -0
  32. package/src/meta.ts +16 -0
  33. package/src/translations.ts +28 -0
  34. package/src/types/capabilities.ts +37 -0
  35. 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,5 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './Popover';
@@ -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,5 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './SimpleLayout';
@@ -0,0 +1,8 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ export * from './ContentLoading';
6
+ export * from './Home';
7
+ export * from './Popover';
8
+ export * from './SimpleLayout';
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ export * from './useSpotlightDismiss';
@@ -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
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ export * from './SimpleLayoutPlugin';
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
+ };