gantry-web 0.3.6 → 0.4.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/package.json +43 -43
- package/src/ResizeFrame.tsx +43 -40
- package/src/app.tsx +368 -276
- package/src/bridge.ts +95 -94
- package/src/index.ts +32 -32
- package/src/router.tsx +180 -160
- package/src/socket.ts +227 -209
package/src/bridge.ts
CHANGED
|
@@ -1,94 +1,95 @@
|
|
|
1
|
-
// The native bridge: typed access to the window.<prefix>* functions the
|
|
2
|
-
// Go appshell binds. In a plain browser tab none of them exist, so
|
|
3
|
-
// every method is safe to call anywhere - it just does nothing outside
|
|
4
|
-
// the native window - and `available` tells you which world you are in.
|
|
5
|
-
|
|
6
|
-
export interface ShellCaps {
|
|
7
|
-
minimize: boolean;
|
|
8
|
-
maximize: boolean;
|
|
9
|
-
close: boolean;
|
|
10
|
-
alwaysOnTop: boolean;
|
|
11
|
-
/** "windows" | "linux" - which native host is running. */
|
|
12
|
-
platform?: string;
|
|
13
|
-
/** True when the window is frameless (custom chrome active). */
|
|
14
|
-
frameless?: boolean;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export interface ShellBridge {
|
|
18
|
-
/** True when running inside a Gantry native window. */
|
|
19
|
-
available: boolean;
|
|
20
|
-
close(): void;
|
|
21
|
-
minimize(): void;
|
|
22
|
-
/** Start the native window drag loop (call on mousedown). */
|
|
23
|
-
drag(): void;
|
|
24
|
-
/** System notification sound + taskbar flash. */
|
|
25
|
-
attention(): void;
|
|
26
|
-
maximize(): void;
|
|
27
|
-
restore(): void;
|
|
28
|
-
isMaximized(): Promise<boolean>;
|
|
29
|
-
/** Which window buttons the Go side enabled (all false in a browser). */
|
|
30
|
-
caps(): Promise<ShellCaps>;
|
|
31
|
-
/** Widgets and popups: show or hide the native window. */
|
|
32
|
-
setVisible(show: boolean): void;
|
|
33
|
-
/** Widgets: resize the native window in place. */
|
|
34
|
-
resize(width: number, height: number): void;
|
|
35
|
-
setAlwaysOnTop(on: boolean): void;
|
|
36
|
-
/** Open a URL in the user's default browser (never in the app). */
|
|
37
|
-
openExternal(url: string): void;
|
|
38
|
-
/**
|
|
39
|
-
*
|
|
40
|
-
* ("n","s","e","w","ne","nw","se","sw").
|
|
41
|
-
* native hit-test
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
*
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}
|
|
1
|
+
// The native bridge: typed access to the window.<prefix>* functions the
|
|
2
|
+
// Go appshell binds. In a plain browser tab none of them exist, so
|
|
3
|
+
// every method is safe to call anywhere - it just does nothing outside
|
|
4
|
+
// the native window - and `available` tells you which world you are in.
|
|
5
|
+
|
|
6
|
+
export interface ShellCaps {
|
|
7
|
+
minimize: boolean;
|
|
8
|
+
maximize: boolean;
|
|
9
|
+
close: boolean;
|
|
10
|
+
alwaysOnTop: boolean;
|
|
11
|
+
/** "windows" | "linux" - which native host is running. */
|
|
12
|
+
platform?: string;
|
|
13
|
+
/** True when the window is frameless (custom chrome active). */
|
|
14
|
+
frameless?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ShellBridge {
|
|
18
|
+
/** True when running inside a Gantry native window. */
|
|
19
|
+
available: boolean;
|
|
20
|
+
close(): void;
|
|
21
|
+
minimize(): void;
|
|
22
|
+
/** Start the native window drag loop (call on mousedown). */
|
|
23
|
+
drag(): void;
|
|
24
|
+
/** System notification sound + taskbar flash. */
|
|
25
|
+
attention(): void;
|
|
26
|
+
maximize(): void;
|
|
27
|
+
restore(): void;
|
|
28
|
+
isMaximized(): Promise<boolean>;
|
|
29
|
+
/** Which window buttons the Go side enabled (all false in a browser). */
|
|
30
|
+
caps(): Promise<ShellCaps>;
|
|
31
|
+
/** Widgets and popups: show or hide the native window. */
|
|
32
|
+
setVisible(show: boolean): void;
|
|
33
|
+
/** Widgets: resize the native window in place. */
|
|
34
|
+
resize(width: number, height: number): void;
|
|
35
|
+
setAlwaysOnTop(on: boolean): void;
|
|
36
|
+
/** Open a URL in the user's default browser (never in the app). */
|
|
37
|
+
openExternal(url: string): void;
|
|
38
|
+
/**
|
|
39
|
+
* Frameless windows: start an interactive resize from an edge
|
|
40
|
+
* ("n","s","e","w","ne","nw","se","sw"). Bound on both Windows and
|
|
41
|
+
* Linux; on Windows it posts WM_NCLBUTTONDOWN (the native hit-test
|
|
42
|
+
* margin remains only as a backstop).
|
|
43
|
+
*/
|
|
44
|
+
resizeEdge(edge: string): void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type AnyFn = (...args: unknown[]) => unknown;
|
|
48
|
+
|
|
49
|
+
function fn(prefix: string, name: string): AnyFn | undefined {
|
|
50
|
+
const w = window as unknown as Record<string, unknown>;
|
|
51
|
+
const f = w[prefix + name];
|
|
52
|
+
return typeof f === "function" ? (f as AnyFn) : undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const NO_CAPS: ShellCaps = {
|
|
56
|
+
minimize: false,
|
|
57
|
+
maximize: false,
|
|
58
|
+
close: false,
|
|
59
|
+
alwaysOnTop: false,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* getShell returns the bridge for the given binding prefix. The prefix
|
|
64
|
+
* must match the Go side's BindingPrefix; both default to "gantry".
|
|
65
|
+
*/
|
|
66
|
+
export function getShell(prefix = "gantry"): ShellBridge {
|
|
67
|
+
const call = (name: string, ...args: unknown[]) => {
|
|
68
|
+
const f = fn(prefix, name);
|
|
69
|
+
if (f) void f(...args);
|
|
70
|
+
};
|
|
71
|
+
return {
|
|
72
|
+
// Minimize is bound on every main window unless disabled, and Close
|
|
73
|
+
// exists on every window kind, so either marks a native host.
|
|
74
|
+
available: !!(fn(prefix, "Minimize") ?? fn(prefix, "Close")),
|
|
75
|
+
close: () => call("Close"),
|
|
76
|
+
minimize: () => call("Minimize"),
|
|
77
|
+
drag: () => call("Drag"),
|
|
78
|
+
attention: () => call("Attention"),
|
|
79
|
+
maximize: () => call("Maximize"),
|
|
80
|
+
restore: () => call("Restore"),
|
|
81
|
+
isMaximized: async () => {
|
|
82
|
+
const f = fn(prefix, "IsMaximized");
|
|
83
|
+
return f ? ((await f()) as boolean) : false;
|
|
84
|
+
},
|
|
85
|
+
caps: async () => {
|
|
86
|
+
const f = fn(prefix, "Caps");
|
|
87
|
+
return f ? ((await f()) as ShellCaps) : NO_CAPS;
|
|
88
|
+
},
|
|
89
|
+
setVisible: (show) => call("Visible", show),
|
|
90
|
+
resize: (w, h) => call("Resize", w, h),
|
|
91
|
+
setAlwaysOnTop: (on) => call("SetAlwaysOnTop", on),
|
|
92
|
+
openExternal: (url) => call("OpenExternal", url),
|
|
93
|
+
resizeEdge: (edge) => call("ResizeEdge", edge),
|
|
94
|
+
};
|
|
95
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,32 +1,32 @@
|
|
|
1
|
-
export { getShell } from "./bridge";
|
|
2
|
-
export type { ShellBridge, ShellCaps } from "./bridge";
|
|
3
|
-
export { useShell, useShellCaps } from "./hooks";
|
|
4
|
-
export { installZoomGuard } from "./zoom";
|
|
5
|
-
export { TitleBar } from "./TitleBar";
|
|
6
|
-
export type { TitleBarProps } from "./TitleBar";
|
|
7
|
-
export { DragStrip } from "./DragStrip";
|
|
8
|
-
export type { DragStripProps } from "./DragStrip";
|
|
9
|
-
export { ResizeFrame } from "./ResizeFrame";
|
|
10
|
-
export { createApp } from "./app";
|
|
11
|
-
export type { CreateAppOptions, GantryAppModule, GantryPage, GantryPageModule } from "./app";
|
|
12
|
-
export { navigate, redirect, goBack, goForward, useRoute, isActive, Link, ExternalLink } from "./router";
|
|
13
|
-
export type { LinkProps, ExternalLinkProps } from "./router";
|
|
14
|
-
export { usePaired } from "./paired";
|
|
15
|
-
export type { Paired } from "./paired";
|
|
16
|
-
export { service, useService, useCall } from "./service";
|
|
17
|
-
export type { Service, CallResult } from "./service";
|
|
18
|
-
export { useGoState } from "./gostate";
|
|
19
|
-
export { resourceURL } from "./resources";
|
|
20
|
-
export { useAppInfo, fetchAppInfo } from "./appinfo";
|
|
21
|
-
export type { AppInfo } from "./appinfo";
|
|
22
|
-
export { useEnv, useMode, useArg, fetchEnv } from "./env";
|
|
23
|
-
export type { AppEnv, AppMode } from "./env";
|
|
24
|
-
export { reportError, addBreadcrumb, useGantryErrors, dismissNotice, clearFatal } from "./errors";
|
|
25
|
-
export type { GantryErrorInfo, GantryCrumb, ErrorHandlingOptions } from "./errors";
|
|
26
|
-
export { ErrorScreen } from "./ErrorScreen";
|
|
27
|
-
export type { ErrorScreenProps } from "./ErrorScreen";
|
|
28
|
-
export { GantryCallError } from "./socket";
|
|
29
|
-
export { Await, Skeleton } from "./Await";
|
|
30
|
-
export type { AwaitProps, SkeletonProps } from "./Await";
|
|
31
|
-
export { TeaView } from "./tea/Runtime";
|
|
32
|
-
export type { TeaComponentProps, ComponentRegistry } from "./tea/Runtime";
|
|
1
|
+
export { getShell } from "./bridge";
|
|
2
|
+
export type { ShellBridge, ShellCaps } from "./bridge";
|
|
3
|
+
export { useShell, useShellCaps } from "./hooks";
|
|
4
|
+
export { installZoomGuard } from "./zoom";
|
|
5
|
+
export { TitleBar } from "./TitleBar";
|
|
6
|
+
export type { TitleBarProps } from "./TitleBar";
|
|
7
|
+
export { DragStrip } from "./DragStrip";
|
|
8
|
+
export type { DragStripProps } from "./DragStrip";
|
|
9
|
+
export { ResizeFrame } from "./ResizeFrame";
|
|
10
|
+
export { createApp } from "./app";
|
|
11
|
+
export type { CreateAppOptions, GantryAppModule, GantryPage, GantryPageModule } from "./app";
|
|
12
|
+
export { navigate, redirect, goBack, goForward, useRoute, isActive, useParams, Link, ExternalLink } from "./router";
|
|
13
|
+
export type { LinkProps, ExternalLinkProps, RouteParams } from "./router";
|
|
14
|
+
export { usePaired } from "./paired";
|
|
15
|
+
export type { Paired } from "./paired";
|
|
16
|
+
export { service, useService, useCall } from "./service";
|
|
17
|
+
export type { Service, CallResult } from "./service";
|
|
18
|
+
export { useGoState } from "./gostate";
|
|
19
|
+
export { resourceURL } from "./resources";
|
|
20
|
+
export { useAppInfo, fetchAppInfo } from "./appinfo";
|
|
21
|
+
export type { AppInfo } from "./appinfo";
|
|
22
|
+
export { useEnv, useMode, useArg, fetchEnv } from "./env";
|
|
23
|
+
export type { AppEnv, AppMode } from "./env";
|
|
24
|
+
export { reportError, addBreadcrumb, useGantryErrors, dismissNotice, clearFatal } from "./errors";
|
|
25
|
+
export type { GantryErrorInfo, GantryCrumb, ErrorHandlingOptions } from "./errors";
|
|
26
|
+
export { ErrorScreen } from "./ErrorScreen";
|
|
27
|
+
export type { ErrorScreenProps } from "./ErrorScreen";
|
|
28
|
+
export { GantryCallError } from "./socket";
|
|
29
|
+
export { Await, Skeleton } from "./Await";
|
|
30
|
+
export type { AwaitProps, SkeletonProps } from "./Await";
|
|
31
|
+
export { TeaView } from "./tea/Runtime";
|
|
32
|
+
export type { TeaComponentProps, ComponentRegistry } from "./tea/Runtime";
|
package/src/router.tsx
CHANGED
|
@@ -1,160 +1,180 @@
|
|
|
1
|
-
// Tiny pathname router shared by createApp, Link and useRoute. No
|
|
2
|
-
// dependency, history-API based: navigate() pushes, back/forward work,
|
|
3
|
-
// and every subscriber re-renders on change.
|
|
4
|
-
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
export function
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}
|
|
1
|
+
// Tiny pathname router shared by createApp, Link and useRoute. No
|
|
2
|
+
// dependency, history-API based: navigate() pushes, back/forward work,
|
|
3
|
+
// and every subscriber re-renders on change.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
createContext,
|
|
7
|
+
useContext,
|
|
8
|
+
useSyncExternalStore,
|
|
9
|
+
type AnchorHTMLAttributes,
|
|
10
|
+
type ReactNode,
|
|
11
|
+
} from "react";
|
|
12
|
+
import { getShell } from "./bridge";
|
|
13
|
+
|
|
14
|
+
/** Params captured from a dynamic route: a string for a [id] segment, a
|
|
15
|
+
* string[] for a [...slug] catch-all. */
|
|
16
|
+
export type RouteParams = Record<string, string | string[]>;
|
|
17
|
+
|
|
18
|
+
// The active page's captured params, provided by createApp around the
|
|
19
|
+
// page. Empty for static routes.
|
|
20
|
+
export const ParamsContext = createContext<RouteParams>({});
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* useParams returns the params captured from the current dynamic route.
|
|
24
|
+
* For pages/examples/page1/[id] on /examples/page1/7 it is { id: "7" };
|
|
25
|
+
* for a [...slug] catch-all the value is a string[]. Empty on static
|
|
26
|
+
* pages. Type it with the shape you expect: useParams<{ id: string }>().
|
|
27
|
+
*/
|
|
28
|
+
export function useParams<T extends RouteParams = RouteParams>(): T {
|
|
29
|
+
return useContext(ParamsContext) as T;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Subscriptions go through a window-scoped event, NOT a module-local
|
|
33
|
+
// set: if bundling ever instantiates this module twice (it happened -
|
|
34
|
+
// Vite served excluded node_modules source under ?v=hash and bare URLs
|
|
35
|
+
// at once), a local set splits the subscribers and navigation silently
|
|
36
|
+
// stops re-rendering half the app. The window survives any number of
|
|
37
|
+
// module copies; the route itself already lives in location.pathname.
|
|
38
|
+
const NAV_EVENT = "gantry:navigate";
|
|
39
|
+
|
|
40
|
+
function notify() {
|
|
41
|
+
window.dispatchEvent(new Event(NAV_EVENT));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (typeof window !== "undefined") {
|
|
45
|
+
window.addEventListener("popstate", notify);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** navigate switches pages without a full reload. */
|
|
49
|
+
export function navigate(path: string): void {
|
|
50
|
+
if (location.pathname !== path) {
|
|
51
|
+
history.pushState(null, "", path);
|
|
52
|
+
}
|
|
53
|
+
notify();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** redirect replaces the current URL (no history entry) - used when a
|
|
57
|
+
* route no longer exists and the app falls back to another page. */
|
|
58
|
+
export function redirect(path: string): void {
|
|
59
|
+
if (location.pathname !== path) {
|
|
60
|
+
history.replaceState(null, "", path);
|
|
61
|
+
}
|
|
62
|
+
notify();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** goBack navigates back through the page history (popstate re-renders). */
|
|
66
|
+
export function goBack(): void {
|
|
67
|
+
history.back();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** goForward navigates forward through the page history. */
|
|
71
|
+
export function goForward(): void {
|
|
72
|
+
history.forward();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function subscribe(fn: () => void): () => void {
|
|
76
|
+
window.addEventListener(NAV_EVENT, fn);
|
|
77
|
+
return () => window.removeEventListener(NAV_EVENT, fn);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** useRoute returns the current pathname; re-renders on navigation. */
|
|
81
|
+
export function useRoute(): string {
|
|
82
|
+
return useSyncExternalStore(subscribe, () => location.pathname);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** isActive: exact match, or prefix match when exact is false. */
|
|
86
|
+
export function isActive(path: string, to: string, exact = true): boolean {
|
|
87
|
+
const clean = path !== "/" && path.endsWith("/") ? path.slice(0, -1) : path;
|
|
88
|
+
if (exact || to === "/") return clean === to;
|
|
89
|
+
return clean === to || clean.startsWith(to + "/");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface LinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
|
|
93
|
+
/** Route to navigate to, e.g. "/settings". */
|
|
94
|
+
to: string;
|
|
95
|
+
/** Extra class applied while the link's route is current. */
|
|
96
|
+
activeClassName?: string;
|
|
97
|
+
/** Match route prefixes too ("/docs" active on "/docs/intro"). */
|
|
98
|
+
matchPrefix?: boolean;
|
|
99
|
+
children?: ReactNode;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Link navigates between pages and knows when it is the current one:
|
|
104
|
+
* it sets data-active="true"/"false" (style with
|
|
105
|
+
* [data-active="true"] in css, or data-[active=true]: variants in
|
|
106
|
+
* Tailwind), aria-current="page", and appends activeClassName when
|
|
107
|
+
* given.
|
|
108
|
+
*/
|
|
109
|
+
export interface ExternalLinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
|
|
110
|
+
/** The external URL to open. */
|
|
111
|
+
href: string;
|
|
112
|
+
prefix?: string;
|
|
113
|
+
children?: ReactNode;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* ExternalLink opens a URL in the user's DEFAULT BROWSER instead of
|
|
118
|
+
* navigating the app's webview, and (in the native window) renders
|
|
119
|
+
* without an href so no URL-preview bubble ever appears. In a plain
|
|
120
|
+
* browser tab it falls back to a normal new-tab link.
|
|
121
|
+
*/
|
|
122
|
+
export function ExternalLink({ href, prefix, children, onClick, ...rest }: ExternalLinkProps) {
|
|
123
|
+
const shell = getShell(prefix);
|
|
124
|
+
if (!shell.available) {
|
|
125
|
+
return (
|
|
126
|
+
<a href={href} target="_blank" rel="noreferrer" onClick={onClick} {...rest}>
|
|
127
|
+
{children}
|
|
128
|
+
</a>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
return (
|
|
132
|
+
<a
|
|
133
|
+
role="link"
|
|
134
|
+
tabIndex={0}
|
|
135
|
+
onClick={(e) => {
|
|
136
|
+
onClick?.(e);
|
|
137
|
+
if (!e.defaultPrevented) shell.openExternal(href);
|
|
138
|
+
}}
|
|
139
|
+
onKeyDown={(e) => {
|
|
140
|
+
if (e.key === "Enter") shell.openExternal(href);
|
|
141
|
+
}}
|
|
142
|
+
{...rest}
|
|
143
|
+
>
|
|
144
|
+
{children}
|
|
145
|
+
</a>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function Link({ to, activeClassName, matchPrefix, className, children, onClick, ...rest }: LinkProps) {
|
|
150
|
+
const path = useRoute();
|
|
151
|
+
const active = isActive(path, to, !matchPrefix);
|
|
152
|
+
const cls = [className, active ? activeClassName : undefined].filter(Boolean).join(" ");
|
|
153
|
+
// Deliberately NO href: navigation is client-side anyway, and an
|
|
154
|
+
// href makes the webview show the URL preview bubble in the bottom
|
|
155
|
+
// corner on hover - a browser artifact that looks wrong in a
|
|
156
|
+
// desktop app. role/tabIndex/keyboard keep it accessible.
|
|
157
|
+
return (
|
|
158
|
+
<a
|
|
159
|
+
role="link"
|
|
160
|
+
tabIndex={0}
|
|
161
|
+
className={cls || undefined}
|
|
162
|
+
data-active={active ? "true" : "false"}
|
|
163
|
+
aria-current={active ? "page" : undefined}
|
|
164
|
+
onClick={(e) => {
|
|
165
|
+
onClick?.(e);
|
|
166
|
+
if (e.defaultPrevented || e.button !== 0) return;
|
|
167
|
+
navigate(to);
|
|
168
|
+
}}
|
|
169
|
+
onKeyDown={(e) => {
|
|
170
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
171
|
+
e.preventDefault();
|
|
172
|
+
navigate(to);
|
|
173
|
+
}
|
|
174
|
+
}}
|
|
175
|
+
{...rest}
|
|
176
|
+
>
|
|
177
|
+
{children}
|
|
178
|
+
</a>
|
|
179
|
+
);
|
|
180
|
+
}
|