@dxos/plugin-simple-layout 0.0.0 → 0.8.4-main.52d7546f51

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 (191) hide show
  1. package/dist/lib/browser/chunk-7VLT3S46.mjs +29 -0
  2. package/dist/lib/browser/chunk-7VLT3S46.mjs.map +7 -0
  3. package/dist/lib/browser/chunk-O3BQBYMW.mjs +1165 -0
  4. package/dist/lib/browser/chunk-O3BQBYMW.mjs.map +7 -0
  5. package/dist/lib/browser/index.mjs +101 -0
  6. package/dist/lib/browser/index.mjs.map +7 -0
  7. package/dist/lib/browser/meta.json +1 -0
  8. package/dist/lib/browser/operation-resolver-BYRIQOQT.mjs +205 -0
  9. package/dist/lib/browser/operation-resolver-BYRIQOQT.mjs.map +7 -0
  10. package/dist/lib/browser/react-root-GPTKI5H2.mjs +21 -0
  11. package/dist/lib/browser/react-root-GPTKI5H2.mjs.map +7 -0
  12. package/dist/lib/browser/react-surface-LT5JJTPR.mjs +41 -0
  13. package/dist/lib/browser/react-surface-LT5JJTPR.mjs.map +7 -0
  14. package/dist/lib/browser/spotlight-dismiss-67PHYS5B.mjs +66 -0
  15. package/dist/lib/browser/spotlight-dismiss-67PHYS5B.mjs.map +7 -0
  16. package/dist/lib/browser/state-A3PGDWWZ.mjs +48 -0
  17. package/dist/lib/browser/state-A3PGDWWZ.mjs.map +7 -0
  18. package/dist/lib/browser/url-handler-HTIUY6WL.mjs +152 -0
  19. package/dist/lib/browser/url-handler-HTIUY6WL.mjs.map +7 -0
  20. package/dist/lib/node-esm/chunk-UAWM4B2S.mjs +1166 -0
  21. package/dist/lib/node-esm/chunk-UAWM4B2S.mjs.map +7 -0
  22. package/dist/lib/node-esm/chunk-VIDE5UMB.mjs +31 -0
  23. package/dist/lib/node-esm/chunk-VIDE5UMB.mjs.map +7 -0
  24. package/dist/lib/node-esm/index.mjs +102 -0
  25. package/dist/lib/node-esm/index.mjs.map +7 -0
  26. package/dist/lib/node-esm/meta.json +1 -0
  27. package/dist/lib/node-esm/operation-resolver-BDTFNCS2.mjs +206 -0
  28. package/dist/lib/node-esm/operation-resolver-BDTFNCS2.mjs.map +7 -0
  29. package/dist/lib/node-esm/react-root-GRG2OAI2.mjs +22 -0
  30. package/dist/lib/node-esm/react-root-GRG2OAI2.mjs.map +7 -0
  31. package/dist/lib/node-esm/react-surface-TCUSDIN2.mjs +42 -0
  32. package/dist/lib/node-esm/react-surface-TCUSDIN2.mjs.map +7 -0
  33. package/dist/lib/node-esm/spotlight-dismiss-RMLRZUVY.mjs +68 -0
  34. package/dist/lib/node-esm/spotlight-dismiss-RMLRZUVY.mjs.map +7 -0
  35. package/dist/lib/node-esm/state-ZCFZTTPL.mjs +49 -0
  36. package/dist/lib/node-esm/state-ZCFZTTPL.mjs.map +7 -0
  37. package/dist/lib/node-esm/url-handler-WBVVKVPC.mjs +153 -0
  38. package/dist/lib/node-esm/url-handler-WBVVKVPC.mjs.map +7 -0
  39. package/dist/types/src/SimpleLayoutPlugin.d.ts +7 -0
  40. package/dist/types/src/SimpleLayoutPlugin.d.ts.map +1 -0
  41. package/dist/types/src/capabilities/index.d.ts +7 -0
  42. package/dist/types/src/capabilities/index.d.ts.map +1 -0
  43. package/dist/types/src/capabilities/operation-resolver/index.d.ts +3 -0
  44. package/dist/types/src/capabilities/operation-resolver/index.d.ts.map +1 -0
  45. package/dist/types/src/capabilities/operation-resolver/operation-resolver.d.ts +5 -0
  46. package/dist/types/src/capabilities/operation-resolver/operation-resolver.d.ts.map +1 -0
  47. package/dist/types/src/capabilities/react-root/index.d.ts +6 -0
  48. package/dist/types/src/capabilities/react-root/index.d.ts.map +1 -0
  49. package/dist/types/src/capabilities/react-root/react-root.d.ts +9 -0
  50. package/dist/types/src/capabilities/react-root/react-root.d.ts.map +1 -0
  51. package/dist/types/src/capabilities/react-surface/index.d.ts +3 -0
  52. package/dist/types/src/capabilities/react-surface/index.d.ts.map +1 -0
  53. package/dist/types/src/capabilities/react-surface/react-surface.d.ts +5 -0
  54. package/dist/types/src/capabilities/react-surface/react-surface.d.ts.map +1 -0
  55. package/dist/types/src/capabilities/spotlight-dismiss/index.d.ts +3 -0
  56. package/dist/types/src/capabilities/spotlight-dismiss/index.d.ts.map +1 -0
  57. package/dist/types/src/capabilities/spotlight-dismiss/spotlight-dismiss.d.ts +14 -0
  58. package/dist/types/src/capabilities/spotlight-dismiss/spotlight-dismiss.d.ts.map +1 -0
  59. package/dist/types/src/capabilities/state/index.d.ts +13 -0
  60. package/dist/types/src/capabilities/state/index.d.ts.map +1 -0
  61. package/dist/types/src/capabilities/state/state.d.ts +19 -0
  62. package/dist/types/src/capabilities/state/state.d.ts.map +1 -0
  63. package/dist/types/src/capabilities/url-handler/index.d.ts +3 -0
  64. package/dist/types/src/capabilities/url-handler/index.d.ts.map +1 -0
  65. package/dist/types/src/capabilities/url-handler/url-handler.d.ts +12 -0
  66. package/dist/types/src/capabilities/url-handler/url-handler.d.ts.map +1 -0
  67. package/dist/types/src/components/ContentError.d.ts +5 -0
  68. package/dist/types/src/components/ContentError.d.ts.map +1 -0
  69. package/dist/types/src/components/ContentError.stories.d.ts +41 -0
  70. package/dist/types/src/components/ContentError.stories.d.ts.map +1 -0
  71. package/dist/types/src/components/ContentLoading.d.ts +3 -0
  72. package/dist/types/src/components/ContentLoading.d.ts.map +1 -0
  73. package/dist/types/src/components/ContentLoading.stories.d.ts +13 -0
  74. package/dist/types/src/components/ContentLoading.stories.d.ts.map +1 -0
  75. package/dist/types/src/components/Dialog/Dialog.d.ts +3 -0
  76. package/dist/types/src/components/Dialog/Dialog.d.ts.map +1 -0
  77. package/dist/types/src/components/Dialog/index.d.ts +2 -0
  78. package/dist/types/src/components/Dialog/index.d.ts.map +1 -0
  79. package/dist/types/src/components/Home/Home.d.ts +7 -0
  80. package/dist/types/src/components/Home/Home.d.ts.map +1 -0
  81. package/dist/types/src/components/Home/index.d.ts +2 -0
  82. package/dist/types/src/components/Home/index.d.ts.map +1 -0
  83. package/dist/types/src/components/MobileLayout/MobileLayout.d.ts +35 -0
  84. package/dist/types/src/components/MobileLayout/MobileLayout.d.ts.map +1 -0
  85. package/dist/types/src/components/MobileLayout/MobileLayout.stories.d.ts +7 -0
  86. package/dist/types/src/components/MobileLayout/MobileLayout.stories.d.ts.map +1 -0
  87. package/dist/types/src/components/MobileLayout/index.d.ts +2 -0
  88. package/dist/types/src/components/MobileLayout/index.d.ts.map +1 -0
  89. package/dist/types/src/components/Popover/Popover.d.ts +4 -0
  90. package/dist/types/src/components/Popover/Popover.d.ts.map +1 -0
  91. package/dist/types/src/components/Popover/index.d.ts +2 -0
  92. package/dist/types/src/components/Popover/index.d.ts.map +1 -0
  93. package/dist/types/src/components/SimpleLayout/AppBar.d.ts +26 -0
  94. package/dist/types/src/components/SimpleLayout/AppBar.d.ts.map +1 -0
  95. package/dist/types/src/components/SimpleLayout/AppBar.stories.d.ts +47 -0
  96. package/dist/types/src/components/SimpleLayout/AppBar.stories.d.ts.map +1 -0
  97. package/dist/types/src/components/SimpleLayout/Drawer.d.ts +9 -0
  98. package/dist/types/src/components/SimpleLayout/Drawer.d.ts.map +1 -0
  99. package/dist/types/src/components/SimpleLayout/Main.d.ts +9 -0
  100. package/dist/types/src/components/SimpleLayout/Main.d.ts.map +1 -0
  101. package/dist/types/src/components/SimpleLayout/NavBar.d.ts +18 -0
  102. package/dist/types/src/components/SimpleLayout/NavBar.d.ts.map +1 -0
  103. package/dist/types/src/components/SimpleLayout/NavBar.stories.d.ts +43 -0
  104. package/dist/types/src/components/SimpleLayout/NavBar.stories.d.ts.map +1 -0
  105. package/dist/types/src/components/SimpleLayout/SimpleLayout.d.ts +3 -0
  106. package/dist/types/src/components/SimpleLayout/SimpleLayout.d.ts.map +1 -0
  107. package/dist/types/src/components/SimpleLayout/SimpleLayout.stories.d.ts +47 -0
  108. package/dist/types/src/components/SimpleLayout/SimpleLayout.stories.d.ts.map +1 -0
  109. package/dist/types/src/components/SimpleLayout/index.d.ts +5 -0
  110. package/dist/types/src/components/SimpleLayout/index.d.ts.map +1 -0
  111. package/dist/types/src/components/Workspace/Workspace.d.ts +11 -0
  112. package/dist/types/src/components/Workspace/Workspace.d.ts.map +1 -0
  113. package/dist/types/src/components/Workspace/index.d.ts +2 -0
  114. package/dist/types/src/components/Workspace/index.d.ts.map +1 -0
  115. package/dist/types/src/components/hooks.d.ts +5 -0
  116. package/dist/types/src/components/hooks.d.ts.map +1 -0
  117. package/dist/types/src/components/index.d.ts +7 -0
  118. package/dist/types/src/components/index.d.ts.map +1 -0
  119. package/dist/types/src/hooks/actions.d.ts +20 -0
  120. package/dist/types/src/hooks/actions.d.ts.map +1 -0
  121. package/dist/types/src/hooks/index.d.ts +7 -0
  122. package/dist/types/src/hooks/index.d.ts.map +1 -0
  123. package/dist/types/src/hooks/useAppBarProps.d.ts +7 -0
  124. package/dist/types/src/hooks/useAppBarProps.d.ts.map +1 -0
  125. package/dist/types/src/hooks/useCompanions.d.ts +12 -0
  126. package/dist/types/src/hooks/useCompanions.d.ts.map +1 -0
  127. package/dist/types/src/hooks/useDrawerActions.d.ts +13 -0
  128. package/dist/types/src/hooks/useDrawerActions.d.ts.map +1 -0
  129. package/dist/types/src/hooks/useNavbarActions.d.ts +14 -0
  130. package/dist/types/src/hooks/useNavbarActions.d.ts.map +1 -0
  131. package/dist/types/src/hooks/useSimpleLayoutState.d.ts +7 -0
  132. package/dist/types/src/hooks/useSimpleLayoutState.d.ts.map +1 -0
  133. package/dist/types/src/index.d.ts +2 -0
  134. package/dist/types/src/index.d.ts.map +1 -0
  135. package/dist/types/src/meta.d.ts +3 -0
  136. package/dist/types/src/meta.d.ts.map +1 -0
  137. package/dist/types/src/translations.d.ts +26 -0
  138. package/dist/types/src/translations.d.ts.map +1 -0
  139. package/dist/types/src/types/capabilities.d.ts +36 -0
  140. package/dist/types/src/types/capabilities.d.ts.map +1 -0
  141. package/dist/types/src/types/events.d.ts +6 -0
  142. package/dist/types/src/types/events.d.ts.map +1 -0
  143. package/dist/types/src/types/index.d.ts +3 -0
  144. package/dist/types/src/types/index.d.ts.map +1 -0
  145. package/dist/types/tsconfig.tsbuildinfo +1 -0
  146. package/package.json +39 -29
  147. package/src/SimpleLayoutPlugin.ts +25 -8
  148. package/src/capabilities/index.ts +3 -0
  149. package/src/capabilities/operation-resolver/operation-resolver.ts +135 -53
  150. package/src/capabilities/react-root/react-root.tsx +2 -2
  151. package/src/capabilities/react-surface/index.ts +7 -0
  152. package/src/capabilities/react-surface/react-surface.tsx +41 -0
  153. package/src/capabilities/spotlight-dismiss/index.ts +7 -0
  154. package/src/{hooks/useSpotlightDismiss.ts → capabilities/spotlight-dismiss/spotlight-dismiss.ts} +31 -40
  155. package/src/capabilities/state/state.tsx +25 -33
  156. package/src/capabilities/url-handler/index.ts +7 -0
  157. package/src/capabilities/url-handler/url-handler.ts +157 -0
  158. package/src/components/ContentError.stories.tsx +1 -1
  159. package/src/components/ContentLoading.stories.tsx +1 -1
  160. package/src/components/Dialog/Dialog.tsx +14 -14
  161. package/src/components/Home/Home.tsx +64 -70
  162. package/src/components/MobileLayout/MobileLayout.stories.tsx +125 -0
  163. package/src/components/MobileLayout/MobileLayout.tsx +305 -0
  164. package/src/components/MobileLayout/index.ts +5 -0
  165. package/src/components/Popover/Popover.tsx +45 -27
  166. package/src/components/SimpleLayout/AppBar.stories.tsx +144 -0
  167. package/src/components/SimpleLayout/AppBar.tsx +101 -0
  168. package/src/components/SimpleLayout/Drawer.tsx +102 -0
  169. package/src/components/SimpleLayout/Main.tsx +53 -57
  170. package/src/components/SimpleLayout/NavBar.stories.tsx +164 -0
  171. package/src/components/SimpleLayout/NavBar.tsx +29 -86
  172. package/src/components/SimpleLayout/SimpleLayout.stories.tsx +24 -18
  173. package/src/components/SimpleLayout/SimpleLayout.tsx +45 -7
  174. package/src/components/SimpleLayout/index.ts +3 -0
  175. package/src/components/Workspace/Workspace.tsx +119 -0
  176. package/src/components/Workspace/index.ts +5 -0
  177. package/src/components/hooks.ts +26 -0
  178. package/src/components/index.ts +2 -0
  179. package/src/hooks/actions.ts +85 -0
  180. package/src/hooks/index.ts +6 -1
  181. package/src/hooks/useAppBarProps.ts +112 -0
  182. package/src/hooks/useCompanions.ts +22 -0
  183. package/src/hooks/useDrawerActions.ts +98 -0
  184. package/src/hooks/useNavbarActions.ts +86 -0
  185. package/src/hooks/useSimpleLayoutState.ts +30 -0
  186. package/src/translations.ts +6 -0
  187. package/src/types/capabilities.ts +20 -4
  188. package/src/types/events.ts +15 -0
  189. package/src/types/index.ts +1 -0
  190. package/src/components/SimpleLayout/Banner.tsx +0 -60
  191. package/src/components/SimpleLayout/NavBarstories.tsx +0 -59
@@ -2,19 +2,24 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
+ import { Atom } from '@effect-atom/atom-react';
5
6
  import * as Effect from 'effect/Effect';
6
7
 
7
- import { Capability, Common } from '@dxos/app-framework';
8
- import { live } from '@dxos/live-object';
8
+ import { Capability } from '@dxos/app-framework';
9
+ import { AppCapabilities } from '@dxos/app-toolkit';
10
+ import { Node } from '@dxos/plugin-graph';
9
11
 
10
12
  import { type SimpleLayoutState } from '../../types';
11
13
  import { SimpleLayoutState as SimpleLayoutStateCapability } from '../../types';
12
14
 
13
15
  const defaultState: SimpleLayoutState = {
14
16
  dialogOpen: false,
15
- workspace: 'default',
16
- previousWorkspace: 'default',
17
+ workspace: Node.RootId,
18
+ previousWorkspace: Node.RootId,
19
+ history: [],
17
20
  isPopover: false,
21
+ companionVariant: undefined,
22
+ drawerState: 'closed',
18
23
  };
19
24
 
20
25
  export type SimpleLayoutStateOptions = {
@@ -23,38 +28,25 @@ export type SimpleLayoutStateOptions = {
23
28
 
24
29
  export default Capability.makeModule(({ initialState }: SimpleLayoutStateOptions = {}) =>
25
30
  Effect.sync(() => {
26
- const state = live<SimpleLayoutState>({ ...defaultState, ...initialState });
27
-
28
- const layout = live<Common.Capability.Layout>({
29
- get mode() {
30
- return 'simple';
31
- },
32
- get dialogOpen() {
33
- return state.dialogOpen;
34
- },
35
- get sidebarOpen() {
36
- return false;
37
- },
38
- get complementarySidebarOpen() {
39
- return false;
40
- },
41
- get workspace() {
42
- return state.workspace;
43
- },
44
- get active() {
45
- return state.active ? [state.active] : [];
46
- },
47
- get inactive() {
48
- return [];
49
- },
50
- get scrollIntoView() {
51
- return undefined;
52
- },
31
+ const stateAtom = Atom.make<SimpleLayoutState>({ ...defaultState, ...initialState });
32
+
33
+ const layoutAtom = Atom.make((get): AppCapabilities.Layout => {
34
+ const state = get(stateAtom);
35
+ return {
36
+ mode: 'simple',
37
+ dialogOpen: state.dialogOpen,
38
+ sidebarOpen: false,
39
+ complementarySidebarOpen: false,
40
+ workspace: state.workspace,
41
+ active: state.active ? [state.active] : [],
42
+ inactive: [],
43
+ scrollIntoView: undefined,
44
+ };
53
45
  });
54
46
 
55
47
  return [
56
- Capability.contributes(SimpleLayoutStateCapability, state),
57
- Capability.contributes(Common.Capability.Layout, layout),
48
+ Capability.contributes(SimpleLayoutStateCapability, stateAtom),
49
+ Capability.contributes(AppCapabilities.Layout, layoutAtom),
58
50
  ];
59
51
  }),
60
52
  );
@@ -0,0 +1,7 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { Capability } from '@dxos/app-framework';
6
+
7
+ export const UrlHandler = Capability.lazy('UrlHandler', () => import('./url-handler'));
@@ -0,0 +1,157 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import * as Effect from 'effect/Effect';
6
+
7
+ import { Capabilities, Capability } from '@dxos/app-framework';
8
+ import { LayoutOperation } from '@dxos/app-toolkit';
9
+ import { log } from '@dxos/log';
10
+ import { Node } from '@dxos/plugin-graph';
11
+ import { isTauri } from '@dxos/util';
12
+
13
+ import { type SimpleLayoutState, SimpleLayoutState as SimpleLayoutStateCapability } from '../../types';
14
+
15
+ /**
16
+ * URL handler for simple layout that syncs browser URL with layout state.
17
+ * URL format: /{workspace} or /{workspace}/{active}
18
+ * Root is represented as / or /root.
19
+ *
20
+ * On mobile Tauri, also listens for deep links via the deep-link plugin.
21
+ */
22
+ export default Capability.makeModule(
23
+ Effect.fnUntraced(function* () {
24
+ const { invokeSync } = yield* Capability.get(Capabilities.OperationInvoker);
25
+
26
+ /**
27
+ * Handle navigation from a pathname.
28
+ * Parses path and updates state accordingly.
29
+ */
30
+ const handlePathNavigation = (pathname: string) => {
31
+ log.info('[UrlHandler] Navigating to path', { pathname });
32
+
33
+ // Parse URL segments: /{workspace}/{active}
34
+ const [_, nextWorkspace, nextActive] = pathname.split('/');
35
+
36
+ // Determine target workspace (empty or 'root' means Node.RootId).
37
+ const targetWorkspace = !nextWorkspace || nextWorkspace === 'root' ? Node.RootId : nextWorkspace;
38
+
39
+ // Navigate via operations (they will update state accordingly).
40
+ invokeSync(LayoutOperation.SwitchWorkspace, { subject: targetWorkspace });
41
+ if (nextActive) {
42
+ invokeSync(LayoutOperation.Open, { subject: [nextActive] });
43
+ }
44
+ };
45
+
46
+ const onNavigation = handleNavigation(handlePathNavigation);
47
+
48
+ // Handle initial URL and listen for browser navigation.
49
+ yield* Effect.sync(() => onNavigation());
50
+ window.addEventListener('popstate', onNavigation);
51
+
52
+ // Set up deep link listener for mobile Tauri.
53
+ let unlistenDeepLink: (() => void) | undefined;
54
+ if (isTauri()) {
55
+ yield* Effect.tryPromise({
56
+ try: async () => {
57
+ const { getCurrent, onOpenUrl } = await import('@tauri-apps/plugin-deep-link');
58
+
59
+ // Check if app was launched via deep link (cold start).
60
+ const launchUrls = await getCurrent();
61
+ if (launchUrls && launchUrls.length > 0) {
62
+ log.info('[UrlHandler] App launched with deep links', { urls: launchUrls });
63
+ for (const url of launchUrls) {
64
+ handleDeepLink(url, handlePathNavigation);
65
+ }
66
+ }
67
+
68
+ // Listen for deep links while app is running.
69
+ unlistenDeepLink = await onOpenUrl((urls) => {
70
+ log.info('[UrlHandler] Deep links received', { urls });
71
+ for (const url of urls) {
72
+ handleDeepLink(url, handlePathNavigation);
73
+ }
74
+ });
75
+
76
+ log.info('[UrlHandler] Deep link listener initialized');
77
+ },
78
+ catch: (error) => {
79
+ log.warn('[UrlHandler] Failed to initialize deep link listener', { error });
80
+ return error;
81
+ },
82
+ }).pipe(Effect.catchAll(() => Effect.void));
83
+ }
84
+
85
+ // Subscribe to state changes to update the URL.
86
+ let lastWorkspace: string | undefined;
87
+ let lastActive: string | undefined;
88
+ const unsubscribe = yield* Capabilities.subscribeAtom(SimpleLayoutStateCapability, (state: SimpleLayoutState) => {
89
+ const { workspace, active } = state;
90
+
91
+ // Only update URL if relevant state changed.
92
+ if (workspace !== lastWorkspace || active !== lastActive) {
93
+ lastWorkspace = workspace;
94
+ lastActive = active;
95
+
96
+ const path = pathFromState(workspace, active);
97
+ if (window.location.pathname !== path) {
98
+ history.pushState(null, '', `${path}${window.location.search}`);
99
+ }
100
+ }
101
+ });
102
+
103
+ return Capability.contributes(Capabilities.Null, null, () =>
104
+ Effect.sync(() => {
105
+ window.removeEventListener('popstate', onNavigation);
106
+ unsubscribe();
107
+ unlistenDeepLink?.();
108
+ }),
109
+ );
110
+ }),
111
+ );
112
+
113
+ // TODO(wittjosiah): Instead of hardcoding redirect paths, we should either:
114
+ // 1. Validate that the workspace exists in the graph before navigating.
115
+ // 2. Implement more structured routing with explicit route definitions.
116
+ /**
117
+ * Check if a path is a special redirect path that shouldn't be navigated to.
118
+ * These paths are handled by other systems (e.g., OAuth).
119
+ */
120
+ const isRedirectPath = (pathname: string): boolean => pathname.startsWith('/redirect/');
121
+
122
+ /**
123
+ * Build pathname from layout state. Root workspace is / or /root/{active}.
124
+ */
125
+ const pathFromState = (workspace: string, active: string | undefined): string =>
126
+ workspace === Node.RootId
127
+ ? active
128
+ ? `/${Node.RootId}/${active}`
129
+ : '/'
130
+ : active
131
+ ? `/${workspace}/${active}`
132
+ : `/${workspace}`;
133
+
134
+ /**
135
+ * Returns a handler for navigation events (initial load and popstate) that navigates to current pathname.
136
+ */
137
+ const handleNavigation =
138
+ (navigate: (pathname: string) => void): (() => void) =>
139
+ () =>
140
+ navigate(window.location.pathname);
141
+
142
+ /**
143
+ * Handle deep link URL from Tauri. Parses the URL and calls navigate unless it's a redirect path.
144
+ */
145
+ const handleDeepLink = (urlString: string, navigate: (pathname: string) => void): void => {
146
+ log.info('[UrlHandler] Deep link received', { url: urlString });
147
+ try {
148
+ const url = new URL(urlString);
149
+ if (isRedirectPath(url.pathname)) {
150
+ log.info('[UrlHandler] Skipping redirect path (handled elsewhere)', { pathname: url.pathname });
151
+ return;
152
+ }
153
+ navigate(url.pathname);
154
+ } catch (error) {
155
+ log.warn('[UrlHandler] Failed to parse deep link URL', { urlString, error });
156
+ }
157
+ };
@@ -13,7 +13,7 @@ import { ContentError } from './ContentError';
13
13
  const meta = {
14
14
  title: 'plugins/plugin-simple-layout/ContentError',
15
15
  component: ContentError,
16
- decorators: [withTheme],
16
+ decorators: [withTheme()],
17
17
  parameters: {
18
18
  layout: 'centered',
19
19
  translations,
@@ -11,7 +11,7 @@ import { ContentLoading } from './ContentLoading';
11
11
  const meta = {
12
12
  title: 'plugins/plugin-simple-layout/ContentLoading',
13
13
  component: ContentLoading,
14
- decorators: [withTheme],
14
+ decorators: [withTheme()],
15
15
  parameters: {
16
16
  layout: 'centered',
17
17
  },
@@ -4,33 +4,33 @@
4
4
 
5
5
  import React from 'react';
6
6
 
7
- import { Surface, useCapability } from '@dxos/app-framework/react';
7
+ import { Surface } from '@dxos/app-framework/ui';
8
8
  import { AlertDialog, Dialog as NaturalDialog } from '@dxos/react-ui';
9
9
 
10
- import { SimpleLayoutState } from '../../types';
10
+ import { useSimpleLayoutState } from '../../hooks';
11
11
  import { ContentError } from '../ContentError';
12
12
 
13
13
  export const Dialog = () => {
14
- const layout = useCapability(SimpleLayoutState);
14
+ const { state, updateState } = useSimpleLayoutState();
15
15
 
16
- const DialogRoot = layout.dialogType === 'alert' ? AlertDialog.Root : NaturalDialog.Root;
17
- const DialogOverlay = layout.dialogType === 'alert' ? AlertDialog.Overlay : NaturalDialog.Overlay;
16
+ const DialogRoot = state.dialogType === 'alert' ? AlertDialog.Root : NaturalDialog.Root;
17
+ const DialogOverlay = state.dialogType === 'alert' ? AlertDialog.Overlay : NaturalDialog.Overlay;
18
18
 
19
19
  return (
20
20
  <DialogRoot
21
- modal={layout.dialogBlockAlign !== 'end'}
22
- open={layout.dialogOpen}
23
- onOpenChange={(nextOpen) => (layout.dialogOpen = nextOpen)}
21
+ modal={state.dialogBlockAlign !== 'end'}
22
+ open={state.dialogOpen}
23
+ onOpenChange={(nextOpen) => updateState((state) => ({ ...state, dialogOpen: nextOpen }))}
24
24
  >
25
- {layout.dialogBlockAlign === 'end' ? (
26
- <Surface role='dialog' data={layout.dialogContent} limit={1} fallback={ContentError} placeholder={<div />} />
25
+ {state.dialogBlockAlign === 'end' ? (
26
+ <Surface.Surface role='dialog' data={state.dialogContent} limit={1} fallback={ContentError} />
27
27
  ) : (
28
28
  <DialogOverlay
29
- blockAlign={layout.dialogBlockAlign}
30
- classNames={layout.dialogOverlayClasses}
31
- style={layout.dialogOverlayStyle}
29
+ blockAlign={state.dialogBlockAlign}
30
+ classNames={state.dialogOverlayClasses}
31
+ style={state.dialogOverlayStyle}
32
32
  >
33
- <Surface role='dialog' data={layout.dialogContent} limit={1} fallback={ContentError} />
33
+ <Surface.Surface role='dialog' data={state.dialogContent} limit={1} fallback={ContentError} />
34
34
  </DialogOverlay>
35
35
  )}
36
36
  </DialogRoot>
@@ -4,94 +4,108 @@
4
4
 
5
5
  import React, { useCallback, useEffect, useMemo, useRef } from 'react';
6
6
 
7
- import { Common } from '@dxos/app-framework';
8
- import { useAppGraph, useOperationInvoker } from '@dxos/app-framework/react';
9
- import { Graph, Node, useConnections } from '@dxos/plugin-graph';
10
- import { Avatar, Icon, type ThemedClassName, toLocalizedString, useTranslation } from '@dxos/react-ui';
11
- import { Card } from '@dxos/react-ui-mosaic';
7
+ import { useOperationInvoker } from '@dxos/app-framework/ui';
8
+ import { LayoutOperation } from '@dxos/app-toolkit';
9
+ import { useAppGraph } from '@dxos/app-toolkit/ui';
10
+ import { Node, useConnections } from '@dxos/plugin-graph';
11
+ import { Avatar, Icon, Layout, ScrollArea, Toolbar, toLocalizedString, useTranslation } from '@dxos/react-ui';
12
+ import { Card, Mosaic, type StackTileComponent } from '@dxos/react-ui-mosaic';
12
13
  import { SearchList, useSearchListItem, useSearchListResults } from '@dxos/react-ui-searchlist';
13
14
  import { mx } from '@dxos/ui-theme';
15
+ import { byPosition } from '@dxos/util';
14
16
 
15
17
  import { meta } from '../../meta';
18
+ import { useLoadDescendents } from '../hooks';
16
19
 
17
- type HomeProps = ThemedClassName;
20
+ export type HomeProps = {};
18
21
 
19
- export const Home = ({ classNames }: HomeProps) => {
22
+ /**
23
+ * Home screen.
24
+ */
25
+ export const Home = (_: HomeProps) => {
20
26
  const { t } = useTranslation(meta.id);
21
- const workspaces = useWorkspaces();
27
+ const userAccountItem = useItemsByDisposition('user-account')[0];
28
+ const pinnedItems = useItemsByDisposition('pin-end', true);
29
+ const workspaceItems = useItemsByDisposition('workspace');
22
30
  useLoadDescendents(Node.RootId);
23
31
 
32
+ const items = useMemo(
33
+ () => [...(userAccountItem ? [userAccountItem] : []), ...pinnedItems, ...workspaceItems],
34
+ [userAccountItem, pinnedItems, workspaceItems],
35
+ );
36
+
24
37
  const { results, handleSearch } = useSearchListResults({
25
- items: workspaces,
38
+ items,
26
39
  extract: (node) => toLocalizedString(node.properties.label, t),
27
40
  });
28
41
 
29
42
  return (
30
- <div className={mx('flex flex-col pli-3', classNames)}>
31
- {/* <div className='container-max-width'>{t('workspaces heading')}</div> */}
32
- <SearchList.Root onSearch={handleSearch} classNames='container-max-width'>
33
- <div className='plb-3'>
43
+ <Layout.Main toolbar>
44
+ <SearchList.Root onSearch={handleSearch}>
45
+ <Toolbar.Root>
34
46
  <SearchList.Input placeholder={t('search placeholder')} autoFocus />
35
- </div>
47
+ </Toolbar.Root>
36
48
  <SearchList.Content>
37
- <SearchList.Viewport classNames='flex flex-col gap-1'>
38
- {results.map((node) => (
39
- <Workspace key={node.id} node={node} />
40
- ))}
41
- </SearchList.Viewport>
49
+ <Mosaic.Container asChild>
50
+ <ScrollArea.Root orientation='vertical'>
51
+ <ScrollArea.Viewport classNames='p-2'>
52
+ <Mosaic.Stack items={results} getId={(node) => node.id} Tile={WorkspaceTile} />
53
+ </ScrollArea.Viewport>
54
+ </ScrollArea.Root>
55
+ </Mosaic.Container>
42
56
  </SearchList.Content>
43
57
  </SearchList.Root>
44
- </div>
58
+ </Layout.Main>
45
59
  );
46
60
  };
47
61
 
48
- const Workspace = ({ node }: { node: Node.Node }) => {
62
+ const WorkspaceTile: StackTileComponent<Node.Node> = ({ data }) => {
49
63
  const { t } = useTranslation(meta.id);
50
64
  const { invokePromise } = useOperationInvoker();
51
65
  const { selectedValue, registerItem, unregisterItem } = useSearchListItem();
52
- const ref = useRef<HTMLDivElement>(null);
66
+ const name = toLocalizedString(data.properties.label, t);
67
+ const isSelected = selectedValue === data.id;
68
+ const cardRef = useRef<HTMLDivElement>(null);
69
+
70
+ useLoadDescendents(data.id);
53
71
 
54
72
  const handleSelect = useCallback(
55
- () => invokePromise(Common.LayoutOperation.SwitchWorkspace, { subject: node.id }),
56
- [invokePromise, node.id],
73
+ () => invokePromise(LayoutOperation.SwitchWorkspace, { subject: data.id }),
74
+ [invokePromise, data.id],
57
75
  );
58
76
 
59
- useLoadDescendents(node.id);
60
-
61
- const name = toLocalizedString(node.properties.label, t);
62
- const isSelected = selectedValue === node.id;
63
-
64
77
  // Register this workspace with the search context.
65
78
  useEffect(() => {
66
- if (ref.current) {
67
- registerItem(node.id, ref.current, handleSelect);
79
+ if (cardRef.current) {
80
+ registerItem(data.id, cardRef.current, handleSelect);
68
81
  }
69
82
 
70
- return () => unregisterItem(node.id);
71
- }, [node.id, handleSelect, registerItem, unregisterItem]);
83
+ return () => unregisterItem(data.id);
84
+ }, [data.id, handleSelect, registerItem, unregisterItem]);
72
85
 
73
86
  // Scroll into view when selected.
74
87
  useEffect(() => {
75
- if (isSelected && ref.current) {
76
- ref.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
88
+ if (isSelected && cardRef.current) {
89
+ cardRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
77
90
  }
78
91
  }, [isSelected]);
79
92
 
80
93
  return (
81
94
  <Card.Root
82
- ref={ref}
83
95
  role='button'
84
- tabIndex={-1}
96
+ fullWidth
97
+ tabIndex={-1} // TODO(burdon): Use Mosaic.Focus.
85
98
  data-selected={isSelected}
86
99
  classNames={mx('dx-focus-ring', isSelected && 'bg-hoverOverlay')}
87
100
  onClick={handleSelect}
101
+ ref={cardRef}
88
102
  >
89
- <Card.Chrome classNames='grid grid-cols-[min-content_1fr_min-content] items-center gap-cardSpacingInline pie-cardSpacingInline'>
103
+ <Card.Toolbar density='coarse'>
90
104
  <Avatar.Root>
91
105
  <Avatar.Content
92
- hue={node.properties.hue}
93
- icon={node.properties.icon}
94
- hueVariant='surface'
106
+ icon={data.properties.icon}
107
+ hue={data.properties.hue}
108
+ hueVariant='transparent'
95
109
  variant='square'
96
110
  size={12}
97
111
  fallback={name}
@@ -99,40 +113,20 @@ const Workspace = ({ node }: { node: Node.Node }) => {
99
113
  <Avatar.Label>{name}</Avatar.Label>
100
114
  <Icon icon='ph--caret-right--regular' />
101
115
  </Avatar.Root>
102
- </Card.Chrome>
116
+ </Card.Toolbar>
103
117
  </Card.Root>
104
118
  );
105
119
  };
106
120
 
107
- const useLoadDescendents = (nodeId?: string) => {
108
- const { graph } = useAppGraph();
109
-
110
- useEffect(() => {
111
- const frame = requestAnimationFrame(() => {
112
- if (nodeId) {
113
- Graph.expand(graph, nodeId, 'outbound');
114
- Graph.getConnections(graph, nodeId, 'outbound').forEach((child) => {
115
- Graph.expand(graph, child.id, 'outbound');
116
- });
117
- }
118
- });
119
-
120
- return () => cancelAnimationFrame(frame);
121
- }, [nodeId, graph]);
121
+ /** Filters nodes by disposition. */
122
+ const filterItems = (node: Node.Node, disposition: string) => {
123
+ return node.properties.disposition === disposition;
122
124
  };
123
125
 
124
- const useWorkspaces = () => {
126
+ /** Returns root-level items filtered by disposition. */
127
+ const useItemsByDisposition = (disposition: string, sort = false) => {
125
128
  const { graph } = useAppGraph();
126
-
127
- // Get root connections to find collections.
128
- const rootConnections = useConnections(graph, Node.RootId);
129
- const collections = useMemo(
130
- () => rootConnections.filter((node) => node.properties.disposition === 'collection'),
131
- [rootConnections],
132
- );
133
-
134
- // Get first collection's children as workspaces.
135
- // TODO(wittjosiah): Support multiple collections or nested workspaces if needed.
136
- const firstCollection = collections[0];
137
- return useConnections(graph, firstCollection?.id);
129
+ const connections = useConnections(graph, Node.RootId);
130
+ const filtered = connections.filter((node) => filterItems(node, disposition));
131
+ return sort ? filtered.toSorted((a, b) => byPosition(a.properties, b.properties)) : filtered;
138
132
  };
@@ -0,0 +1,125 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
6
+ import React, { type PropsWithChildren, useEffect, useState } from 'react';
7
+
8
+ import { addEventListener, combine } from '@dxos/async';
9
+ import { Flex, Input, Layout, Splitter, type SplitterMode, Toolbar } from '@dxos/react-ui';
10
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
11
+
12
+ import { MobileLayout, type MobileLayoutRootProps } from './MobileLayout';
13
+
14
+ /**
15
+ * Simulate ios keyboard.
16
+ */
17
+ const WithKeyboard = ({ children }: PropsWithChildren) => {
18
+ const [keyboardOpen, setKeyboardOpen] = useState(false);
19
+
20
+ useEffect(() => {
21
+ return combine(
22
+ addEventListener(document, 'focusin', (event: FocusEvent) => {
23
+ const target = event.target as HTMLElement;
24
+ if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
25
+ setKeyboardOpen(true);
26
+ }
27
+ }),
28
+ addEventListener(document, 'focusout', (event: FocusEvent) => {
29
+ const target = event.target as HTMLElement;
30
+ if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
31
+ setKeyboardOpen(false);
32
+ }
33
+ }),
34
+ );
35
+ }, []);
36
+
37
+ useEffect(() => {
38
+ const keyboardHeight = keyboardOpen ? 300 : 0;
39
+ document.documentElement.style.setProperty('--kb-height', `${keyboardHeight}px`);
40
+ document.documentElement.style.setProperty('--kb-open', keyboardOpen ? '1' : '0');
41
+
42
+ // Dispatch custom keyboard event that useIOSKeyboard listens for.
43
+ window.dispatchEvent(
44
+ new CustomEvent('keyboard', {
45
+ detail: {
46
+ type: keyboardOpen ? 'show' : 'hide',
47
+ height: keyboardHeight,
48
+ duration: 300,
49
+ },
50
+ }),
51
+ );
52
+ }, [keyboardOpen]);
53
+
54
+ return <div className='h-screen relative'>{children}</div>;
55
+ };
56
+
57
+ const Panel = ({ children, label }: PropsWithChildren<{ label: string }>) => {
58
+ return (
59
+ <Layout.Main toolbar>
60
+ <Toolbar.Root>
61
+ {label}
62
+ <Toolbar.Separator variant='gap' />
63
+ {children}
64
+ </Toolbar.Root>
65
+ <Flex column classNames='p-1'>
66
+ <Input.Root>
67
+ <Input.TextInput />
68
+ </Input.Root>
69
+ </Flex>
70
+ </Layout.Main>
71
+ );
72
+ };
73
+
74
+ const DefaultStory = () => {
75
+ const [splitterMode, setSplitterMode] = useState<SplitterMode>('upper');
76
+ const [keyboardOpen, setKeyboardOpen] = useState(false);
77
+
78
+ useEffect(() => {
79
+ setSplitterMode(splitterMode === 'both' ? 'lower' : splitterMode);
80
+ }, [keyboardOpen]);
81
+
82
+ return (
83
+ <WithKeyboard>
84
+ <MobileLayout.Root onKeyboardOpenChange={setKeyboardOpen}>
85
+ <MobileLayout.Panel safe={{ top: true, bottom: splitterMode === 'upper' }}>
86
+ <Splitter.Root mode={splitterMode} ratio={0.5}>
87
+ <Splitter.Panel position='upper'>
88
+ <Panel label='Main'>
89
+ {splitterMode === 'upper' && (
90
+ <Toolbar.IconButton icon='ph--plus--regular' label='Open' onClick={() => setSplitterMode('both')} />
91
+ )}
92
+ </Panel>
93
+ </Splitter.Panel>
94
+ <Splitter.Panel position='lower'>
95
+ <Panel label='Drawer'>
96
+ <Toolbar.IconButton
97
+ icon={splitterMode === 'lower' ? 'ph--arrow-down--regular' : 'ph--arrow-up--regular'}
98
+ label={splitterMode === 'lower' ? 'Collapse' : 'Expand'}
99
+ onClick={() => setSplitterMode((splitterMode) => (splitterMode === 'both' ? 'lower' : 'both'))}
100
+ />
101
+ <Toolbar.IconButton icon='ph--x--regular' label='Close' onClick={() => setSplitterMode('upper')} />
102
+ </Panel>
103
+ </Splitter.Panel>
104
+ </Splitter.Root>
105
+ </MobileLayout.Panel>
106
+ </MobileLayout.Root>
107
+ </WithKeyboard>
108
+ );
109
+ };
110
+
111
+ const meta: Meta<MobileLayoutRootProps> = {
112
+ title: 'plugins/plugin-simple-layout/MobileLayout',
113
+ component: MobileLayout.Root,
114
+ render: DefaultStory,
115
+ decorators: [withTheme(), withLayout({ layout: 'column', classNames: 'relative' })],
116
+ parameters: {
117
+ layout: 'fullscreen',
118
+ },
119
+ };
120
+
121
+ export default meta;
122
+
123
+ type Story = StoryObj<MobileLayoutRootProps>;
124
+
125
+ export const Default: Story = {};