@anchrd/intel-ui 0.12.0 → 0.14.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 +3 -2
- package/src/agent/agent-calendar/agent-calendar.tsx +32 -14
- package/src/agent/agent-chat/agent-chat.tsx +3 -1
- package/src/agent/agent-cron/agent-cron.ts +49 -113
- package/src/agent/agent-definition/agent-definition.ts +18 -9
- package/src/agent/agent-entry-title/agent-entry-title.ts +3 -1
- package/src/agent/agent-log/agent-log.tsx +6 -3
- package/src/agent/agent-profile/agent-profile.tsx +37 -12
- package/src/agent/agent.tsx +17 -9
- package/src/app/app-sidebar/app-sidebar.tsx +2 -2
- package/src/app/app-tree/app-tree.tsx +5 -3
- package/src/app/app.tsx +2 -2
- package/src/app/header-search/header-search.tsx +3 -1
- package/src/app/settings-dialog/settings-dialog.tsx +115 -0
- package/src/app/tree-move/tree-move.tsx +3 -1
- package/src/app/user-footer/user-footer.tsx +25 -2
- package/src/app/view-toggle/view-toggle.tsx +2 -2
- package/src/archive/archive.tsx +3 -1
- package/src/components/ui/popover.tsx +41 -0
- package/src/entry-picker/entry-picker.tsx +3 -1
- package/src/flow-runs/flow-runs.tsx +6 -3
- package/src/flows/flows.tsx +13 -9
- package/src/flows/node-palette/node-palette.tsx +2 -2
- package/src/folder-contents/folder-contents.tsx +3 -1
- package/src/graph-pane/graph-pane.tsx +2 -2
- package/src/i18n/de.json +406 -0
- package/src/i18n/en.json +20 -5
- package/src/i18n/es.json +406 -0
- package/src/i18n/i18n-context.tsx +65 -0
- package/src/i18n/i18n-languages/i18n-languages.ts +83 -0
- package/src/i18n/i18n.ts +15 -11
- package/src/i18n/i18n.types.ts +43 -0
- package/src/main.tsx +33 -12
- package/src/modal/modal.tsx +2 -2
- package/src/node-table/node-table.tsx +3 -1
- package/src/nodes/nodes.tsx +7 -3
- package/src/resource-menu/resource-menu.tsx +8 -4
- package/src/router/router.types.ts +3 -2
- package/src/save-button/save-button.tsx +3 -3
- package/src/sign-in-refused/sign-in-refused.tsx +3 -2
- package/src/theme/theme-context.tsx +89 -0
- package/src/theme/theme.ts +51 -21
- package/src/timezone/timezone-combobox/timezone-combobox.tsx +149 -0
- package/src/timezone/timezone-context.tsx +45 -0
- package/src/timezone/timezone.ts +159 -0
- package/src/tools/tools.tsx +3 -1
package/src/i18n/i18n.types.ts
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
|
+
// A catalog is a flat file of dotted keys → text. Flat, because the lookup is then a map read and
|
|
2
|
+
// the completeness test compares two key sets instead of walking two trees. The reserved `$locale`
|
|
3
|
+
// key carries the file's BCP-47 locale (the sigil keeps it out of the UI key namespace), so every
|
|
4
|
+
// language file is self-describing and date/number formats need no second configuration.
|
|
1
5
|
export type Catalog = Record<string, string>;
|
|
2
6
|
|
|
7
|
+
// What `intel build` writes into src/i18n/custom.json: the starting language plus every language
|
|
8
|
+
// file listed in the customer's intel.json. The runtime loads nothing on demand — WHICH languages
|
|
9
|
+
// exist is decided at build time; only which one runs is the reader's choice.
|
|
10
|
+
export interface CatalogBundle {
|
|
11
|
+
defaultLanguage: string;
|
|
12
|
+
catalogs: Record<string, Catalog>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface I18nDeps {
|
|
16
|
+
// The active language file. Without one the UI runs on the English catalog (the default).
|
|
17
|
+
catalog?: Catalog;
|
|
18
|
+
}
|
|
19
|
+
|
|
3
20
|
export interface I18n {
|
|
4
21
|
locale: string;
|
|
5
22
|
t(key: string, variables?: Record<string, string | number>): string;
|
|
6
23
|
}
|
|
24
|
+
|
|
25
|
+
// The slice of the Web Storage API the preferences need — as a port, not a global, so a choice is
|
|
26
|
+
// testable without a DOM and localStorage is touched in exactly one place per preference.
|
|
27
|
+
export interface PreferenceStore {
|
|
28
|
+
getItem(key: string): string | null;
|
|
29
|
+
setItem(key: string, value: string): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface LanguagesDeps {
|
|
33
|
+
store?: PreferenceStore;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface Languages {
|
|
37
|
+
// Every selectable code: the built-in ones first, then whatever the customer added.
|
|
38
|
+
codes: string[];
|
|
39
|
+
// The language the app starts in: the remembered choice, otherwise the bundle's default.
|
|
40
|
+
initial: string;
|
|
41
|
+
i18n(code: string): I18n;
|
|
42
|
+
remember(code: string): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface LanguageChoice {
|
|
46
|
+
codes: string[];
|
|
47
|
+
current: string;
|
|
48
|
+
select(code: string): void;
|
|
49
|
+
}
|
package/src/main.tsx
CHANGED
|
@@ -7,18 +7,30 @@ import {
|
|
|
7
7
|
loginPath,
|
|
8
8
|
} from "@/data/intel-data-provider/intel-data-provider.ts";
|
|
9
9
|
import { createBrowserSignIn, createSignInGuard } from "@/data/sign-in/sign-in.ts";
|
|
10
|
-
import {
|
|
10
|
+
import { I18nProvider } from "@/i18n/i18n-context.tsx";
|
|
11
|
+
import { createLanguages } from "@/i18n/i18n-languages/i18n-languages.ts";
|
|
11
12
|
import { createIntelRouter } from "@/router/router.tsx";
|
|
12
13
|
import { SignInRefused } from "@/sign-in-refused/sign-in-refused.tsx";
|
|
13
|
-
import { SystemThemeQuery,
|
|
14
|
+
import { applyTheme, readThemeChoice, SystemThemeQuery, themeFor } from "@/theme/theme.ts";
|
|
15
|
+
import { ThemeProvider } from "@/theme/theme-context.tsx";
|
|
16
|
+
import { TimezoneProvider } from "@/timezone/timezone-context.tsx";
|
|
14
17
|
import "./styles.css";
|
|
15
18
|
import "./theme/custom.css";
|
|
16
19
|
|
|
17
|
-
|
|
20
|
+
// The first paint, BEFORE React — otherwise the light shell flashes until the provider mounts. Only
|
|
21
|
+
// applied here and deliberately NOT subscribed: the subscription belongs to the ThemeProvider, and
|
|
22
|
+
// two of them would overwrite one another.
|
|
23
|
+
applyTheme(
|
|
24
|
+
document.documentElement,
|
|
25
|
+
themeFor(readThemeChoice(window.localStorage), window.matchMedia(SystemThemeQuery).matches),
|
|
26
|
+
);
|
|
18
27
|
|
|
19
28
|
const root = document.getElementById("root");
|
|
20
29
|
if (!root) throw new Error("Missing #root element");
|
|
21
|
-
|
|
30
|
+
|
|
31
|
+
// Which languages exist is decided at build time and lies ready in the bundle; only which one runs
|
|
32
|
+
// is the reader's choice, and that choice is remembered in localStorage.
|
|
33
|
+
const languages = createLanguages({ store: window.localStorage });
|
|
22
34
|
const signIn = createBrowserSignIn(loginPath);
|
|
23
35
|
const mounted = createRoot(root);
|
|
24
36
|
const queryClient = new QueryClient();
|
|
@@ -33,18 +45,27 @@ const data = createIntelDataProvider({
|
|
|
33
45
|
paint();
|
|
34
46
|
}),
|
|
35
47
|
});
|
|
36
|
-
const router = createIntelRouter({ data
|
|
48
|
+
const router = createIntelRouter({ data });
|
|
37
49
|
|
|
38
50
|
function paint(): void {
|
|
39
51
|
mounted.render(
|
|
40
52
|
<StrictMode>
|
|
41
|
-
{
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
53
|
+
{/* The refusal screen sits INSIDE the providers too: it is text like any other, and it is the
|
|
54
|
+
one screen a reader sees when nothing else works — English-only there would be the worst
|
|
55
|
+
place for it. */}
|
|
56
|
+
<I18nProvider languages={languages}>
|
|
57
|
+
<ThemeProvider store={window.localStorage}>
|
|
58
|
+
<TimezoneProvider store={window.localStorage}>
|
|
59
|
+
{refused ? (
|
|
60
|
+
<SignInRefused />
|
|
61
|
+
) : (
|
|
62
|
+
<QueryClientProvider client={queryClient}>
|
|
63
|
+
<RouterProvider router={router} />
|
|
64
|
+
</QueryClientProvider>
|
|
65
|
+
)}
|
|
66
|
+
</TimezoneProvider>
|
|
67
|
+
</ThemeProvider>
|
|
68
|
+
</I18nProvider>
|
|
48
69
|
</StrictMode>,
|
|
49
70
|
);
|
|
50
71
|
}
|
package/src/modal/modal.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useId } from "react";
|
|
2
2
|
import { Modal as AriaModal, Dialog, ModalOverlay } from "react-aria-components";
|
|
3
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
3
4
|
import { cn } from "@/lib/utils.ts";
|
|
4
|
-
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
5
5
|
|
|
6
6
|
export function Modal({
|
|
7
7
|
title,
|
|
@@ -14,7 +14,7 @@ export function Modal({
|
|
|
14
14
|
children: React.ReactNode;
|
|
15
15
|
className?: string;
|
|
16
16
|
}) {
|
|
17
|
-
const
|
|
17
|
+
const i18n = useI18n();
|
|
18
18
|
const titleId = useId();
|
|
19
19
|
return (
|
|
20
20
|
<ModalOverlay
|
|
@@ -2,6 +2,7 @@ import type { Node } from "@anchrd/intel-contract";
|
|
|
2
2
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
3
3
|
import { Download } from "lucide-react";
|
|
4
4
|
import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
|
|
5
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
5
6
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -16,7 +17,8 @@ import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
|
16
17
|
* the export does not contain.
|
|
17
18
|
*/
|
|
18
19
|
export function NodeTablePanel({ node }: { node: Node }) {
|
|
19
|
-
const { data
|
|
20
|
+
const { data } = useIntelRouterContext();
|
|
21
|
+
const i18n = useI18n();
|
|
20
22
|
const table = useQuery({
|
|
21
23
|
queryKey: ["node-table", node.id],
|
|
22
24
|
queryFn: () => data.getNodeTable(node.id),
|
package/src/nodes/nodes.tsx
CHANGED
|
@@ -9,6 +9,7 @@ import { ViewToggle } from "@/app/view-toggle/view-toggle.tsx";
|
|
|
9
9
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
10
10
|
import { FolderContents } from "@/folder-contents/folder-contents.tsx";
|
|
11
11
|
import { GraphPane } from "@/graph-pane/graph-pane.tsx";
|
|
12
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
12
13
|
import { NodeTablePanel } from "@/node-table/node-table.tsx";
|
|
13
14
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
14
15
|
import { graphViewFrom, selectedFrom } from "@/router/selection-search.ts";
|
|
@@ -24,7 +25,8 @@ const AgentPanel = lazy(async () => ({
|
|
|
24
25
|
}));
|
|
25
26
|
|
|
26
27
|
export function Nodes() {
|
|
27
|
-
const { data
|
|
28
|
+
const { data } = useIntelRouterContext();
|
|
29
|
+
const i18n = useI18n();
|
|
28
30
|
const queryClient = useQueryClient();
|
|
29
31
|
const navigate = useNavigate();
|
|
30
32
|
// The tree in the sidebar and the header search both name what to open through `?select=`, so this
|
|
@@ -187,7 +189,8 @@ export function Nodes() {
|
|
|
187
189
|
}
|
|
188
190
|
|
|
189
191
|
function AttachmentPanel({ node }: { node: Node }) {
|
|
190
|
-
const { data
|
|
192
|
+
const { data } = useIntelRouterContext();
|
|
193
|
+
const i18n = useI18n();
|
|
191
194
|
const download = useMutation({
|
|
192
195
|
mutationFn: () => data.getNodeAttachment(node.id),
|
|
193
196
|
onSuccess: (blob) => {
|
|
@@ -227,7 +230,8 @@ function AttachmentPanel({ node }: { node: Node }) {
|
|
|
227
230
|
}
|
|
228
231
|
|
|
229
232
|
function VersionHistory({ nodeId, close }: { nodeId: string; close(): void }) {
|
|
230
|
-
const { data
|
|
233
|
+
const { data } = useIntelRouterContext();
|
|
234
|
+
const i18n = useI18n();
|
|
231
235
|
const versions = useQuery({
|
|
232
236
|
queryKey: ["node-versions", nodeId],
|
|
233
237
|
queryFn: () => data.listNodeVersions(nodeId),
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
} from "@/components/ui/dropdown-menu";
|
|
32
32
|
import { flowEntry } from "@/data/intel-data-provider/intel-data-provider.ts";
|
|
33
33
|
import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
|
|
34
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
34
35
|
import { Modal } from "@/modal/modal.tsx";
|
|
35
36
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
36
37
|
import { selectedFrom } from "@/router/selection-search.ts";
|
|
@@ -133,7 +134,8 @@ export function ResourceMenu({
|
|
|
133
134
|
target: ResourceTarget;
|
|
134
135
|
variant: "row" | "title";
|
|
135
136
|
}) {
|
|
136
|
-
const { data
|
|
137
|
+
const { data } = useIntelRouterContext();
|
|
138
|
+
const i18n = useI18n();
|
|
137
139
|
const queryClient = useQueryClient();
|
|
138
140
|
const navigate = useNavigate();
|
|
139
141
|
const [renaming, setRenaming] = useState(false);
|
|
@@ -417,7 +419,7 @@ function RenameDialog({
|
|
|
417
419
|
close(): void;
|
|
418
420
|
submit(title: string): void;
|
|
419
421
|
}) {
|
|
420
|
-
const
|
|
422
|
+
const i18n = useI18n();
|
|
421
423
|
const [next, setNext] = useState(title);
|
|
422
424
|
const trimmed = next.trim();
|
|
423
425
|
return (
|
|
@@ -469,7 +471,8 @@ function RenameDialog({
|
|
|
469
471
|
* not enter is absent rather than shown without a name.
|
|
470
472
|
*/
|
|
471
473
|
function NodeLinksPanel({ node, close }: { node: Node; close(): void }) {
|
|
472
|
-
const { data
|
|
474
|
+
const { data } = useIntelRouterContext();
|
|
475
|
+
const i18n = useI18n();
|
|
473
476
|
const navigate = useNavigate();
|
|
474
477
|
const links = useQuery({
|
|
475
478
|
queryKey: ["node-links", node.id],
|
|
@@ -528,7 +531,8 @@ function NodeLinksPanel({ node, close }: { node: Node; close(): void }) {
|
|
|
528
531
|
// server's answer, not this component's: `execute` never appears on a document, because a document
|
|
529
532
|
// has nothing to run.
|
|
530
533
|
function SharePanel({ node, close }: { node: Node; close(): void }) {
|
|
531
|
-
const { data
|
|
534
|
+
const { data } = useIntelRouterContext();
|
|
535
|
+
const i18n = useI18n();
|
|
532
536
|
const queryClient = useQueryClient();
|
|
533
537
|
const [email, setEmail] = useState("");
|
|
534
538
|
const [verbs, setVerbs] = useState<ResourceVerb[]>(["read"]);
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { IntelDataProvider } from "@/data/intel-data-provider/intel-data-provider.types.ts";
|
|
2
|
-
import type { I18n } from "@/i18n/i18n.types.ts";
|
|
3
2
|
|
|
3
|
+
// ⚠️ No `i18n` here, and it must not come back (#227). The router context is static: an instance
|
|
4
|
+
// travelling in it can never change, which is why the language was unswitchable before. Text comes
|
|
5
|
+
// from `useI18n()` and from nowhere else — two ways to the same string are two states that drift.
|
|
4
6
|
export interface RouterContext {
|
|
5
7
|
data: IntelDataProvider;
|
|
6
|
-
i18n: I18n;
|
|
7
8
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { useBlocker } from "@tanstack/react-router";
|
|
2
2
|
import { Save } from "lucide-react";
|
|
3
3
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
4
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
4
5
|
import { Modal } from "@/modal/modal.tsx";
|
|
5
|
-
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Saving, in the title line beside the name of the thing being saved (#24).
|
|
@@ -24,7 +24,7 @@ export function SaveButton({
|
|
|
24
24
|
saving: boolean;
|
|
25
25
|
onSave(): void;
|
|
26
26
|
}) {
|
|
27
|
-
const
|
|
27
|
+
const i18n = useI18n();
|
|
28
28
|
const label = i18n.t(dirty ? "common.saveDirty" : "common.saved");
|
|
29
29
|
return (
|
|
30
30
|
<TooltipProvider delayDuration={300}>
|
|
@@ -71,7 +71,7 @@ export function SaveButton({
|
|
|
71
71
|
* the other exit, the one the router never sees: closing the tab or reloading.
|
|
72
72
|
*/
|
|
73
73
|
export function UnsavedChangesGuard({ dirty }: { dirty: boolean }) {
|
|
74
|
-
const
|
|
74
|
+
const i18n = useI18n();
|
|
75
75
|
const blocker = useBlocker({
|
|
76
76
|
shouldBlockFn: () => true,
|
|
77
77
|
disabled: !dirty,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ShieldAlert } from "lucide-react";
|
|
2
|
-
import
|
|
2
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* What stands on the screen when signing in worked and the surface still says no (#118).
|
|
@@ -13,7 +13,8 @@ import type { I18n } from "@/i18n/i18n.types.ts";
|
|
|
13
13
|
* the credential a second ago will refuse it again, and a page that keeps trying by itself is the
|
|
14
14
|
* behaviour being fixed. Retrying is the reader's decision, and it costs them one click.
|
|
15
15
|
*/
|
|
16
|
-
export function SignInRefused(
|
|
16
|
+
export function SignInRefused() {
|
|
17
|
+
const i18n = useI18n();
|
|
17
18
|
return (
|
|
18
19
|
<div role="alert" className="grid min-h-dvh place-items-center bg-background p-8">
|
|
19
20
|
<div className="max-w-prose text-center">
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
type ReactNode,
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useState,
|
|
8
|
+
useSyncExternalStore,
|
|
9
|
+
} from "react";
|
|
10
|
+
import {
|
|
11
|
+
applyTheme,
|
|
12
|
+
readThemeChoice,
|
|
13
|
+
rememberThemeChoice,
|
|
14
|
+
SystemThemeQuery,
|
|
15
|
+
type Theme,
|
|
16
|
+
type ThemeChoice,
|
|
17
|
+
themeFor,
|
|
18
|
+
} from "./theme.ts";
|
|
19
|
+
|
|
20
|
+
// The React seam of the theme, the same shape as i18n-context: the choice is client state (a
|
|
21
|
+
// preference), so it lives locally and is remembered in the store.
|
|
22
|
+
|
|
23
|
+
export interface ThemeSelection {
|
|
24
|
+
// What the reader chose — including "system", which is not a colour.
|
|
25
|
+
current: ThemeChoice;
|
|
26
|
+
// What is painted right now. The one consumer a CSS class cannot serve is a foreign library that
|
|
27
|
+
// paints from a prop instead of from our tokens (React Flow in flows.tsx), and it needs this
|
|
28
|
+
// resolved value rather than the choice.
|
|
29
|
+
resolved: Theme;
|
|
30
|
+
select(choice: ThemeChoice): void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const ThemeContext = createContext<ThemeSelection | null>(null);
|
|
34
|
+
|
|
35
|
+
function subscribeSystemTheme(onChange: () => void): () => void {
|
|
36
|
+
const media = window.matchMedia(SystemThemeQuery);
|
|
37
|
+
media.addEventListener("change", onChange);
|
|
38
|
+
return () => media.removeEventListener("change", onChange);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function systemPrefersDark(): boolean {
|
|
42
|
+
return window.matchMedia(SystemThemeQuery).matches;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface ThemeProviderProps {
|
|
46
|
+
store?: Pick<Storage, "getItem" | "setItem">;
|
|
47
|
+
children: ReactNode;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function ThemeProvider({ store, children }: ThemeProviderProps) {
|
|
51
|
+
const [choice, setChoice] = useState<ThemeChoice>(() => readThemeChoice(store));
|
|
52
|
+
// A subscription rather than a read, so a system switch mid-session moves the class AND the
|
|
53
|
+
// resolved value at once, without a reload.
|
|
54
|
+
const prefersDark = useSyncExternalStore(subscribeSystemTheme, systemPrefersDark, () => false);
|
|
55
|
+
const resolved = themeFor(choice, prefersDark);
|
|
56
|
+
|
|
57
|
+
// ⚠️ The class is written from the resolved value, so a fixed choice simply ignores `prefersDark`
|
|
58
|
+
// changing. That is why there is no second effect subscribing to the media query here: one source,
|
|
59
|
+
// one writer.
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
applyTheme(document.documentElement, resolved);
|
|
62
|
+
}, [resolved]);
|
|
63
|
+
|
|
64
|
+
const selection = useMemo<ThemeSelection>(
|
|
65
|
+
() => ({
|
|
66
|
+
current: choice,
|
|
67
|
+
resolved,
|
|
68
|
+
select: (next) => {
|
|
69
|
+
rememberThemeChoice(next, store);
|
|
70
|
+
setChoice(next);
|
|
71
|
+
},
|
|
72
|
+
}),
|
|
73
|
+
[choice, resolved, store],
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
return <ThemeContext value={selection}>{children}</ThemeContext>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// `null` without a provider — not a substitute value: the settings dialog asks precisely whether
|
|
80
|
+
// there is anything to choose.
|
|
81
|
+
export function useTheme(): ThemeSelection | null {
|
|
82
|
+
return useContext(ThemeContext);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// The painted theme as a value, for a library that takes it as a prop. Never null: a component tree
|
|
86
|
+
// without a provider still has to draw something, and "light" is what an unstyled page is.
|
|
87
|
+
export function useResolvedTheme(): Theme {
|
|
88
|
+
return useContext(ThemeContext)?.resolved ?? "light";
|
|
89
|
+
}
|
package/src/theme/theme.ts
CHANGED
|
@@ -1,40 +1,70 @@
|
|
|
1
|
-
import { useSyncExternalStore } from "react";
|
|
2
|
-
|
|
3
1
|
export type Theme = "dark" | "light";
|
|
4
2
|
|
|
3
|
+
// What a reader chooses is one level above what gets painted: "system" is not a colour, it is the
|
|
4
|
+
// instruction to keep following the media query. That distinction is the reason for two types —
|
|
5
|
+
// storing only `Theme` would make "light" and "light because the system is light" the same value,
|
|
6
|
+
// with no way back.
|
|
7
|
+
export type ThemeChoice = Theme | "system";
|
|
8
|
+
|
|
9
|
+
// The order in the switcher. "system" first, because it is the shipped state.
|
|
10
|
+
export const ThemeChoices: readonly ThemeChoice[] = ["system", "light", "dark"];
|
|
11
|
+
|
|
5
12
|
// The one query the whole theme hangs off. Exported so that the `.dark` class and everything that
|
|
6
13
|
// has to agree with it read the same string rather than two copies of it.
|
|
7
14
|
export const SystemThemeQuery = "(prefers-color-scheme: dark)";
|
|
8
15
|
|
|
16
|
+
// Next to `intel.language`: the same kind of preference, the same place.
|
|
17
|
+
const STORE_KEY = "intel.theme";
|
|
18
|
+
|
|
9
19
|
export function resolveTheme(prefersDark: boolean): Theme {
|
|
10
20
|
return prefersDark ? "dark" : "light";
|
|
11
21
|
}
|
|
12
22
|
|
|
13
|
-
|
|
14
|
-
|
|
23
|
+
// What a choice means right now. The only place that resolves "system" — main.tsx and the provider
|
|
24
|
+
// both ask here, so the first paint and every later one cannot drift apart.
|
|
25
|
+
export function themeFor(choice: ThemeChoice, prefersDark: boolean): Theme {
|
|
26
|
+
return choice === "system" ? resolveTheme(prefersDark) : choice;
|
|
15
27
|
}
|
|
16
28
|
|
|
17
|
-
export function
|
|
18
|
-
|
|
19
|
-
const onChange = (event: MediaQueryListEvent) => applyTheme(root, resolveTheme(event.matches));
|
|
20
|
-
media.addEventListener("change", onChange);
|
|
21
|
-
return () => media.removeEventListener("change", onChange);
|
|
29
|
+
export function applyTheme(root: HTMLElement, theme: Theme): void {
|
|
30
|
+
root.classList.toggle("dark", theme === "dark");
|
|
22
31
|
}
|
|
23
32
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
33
|
+
// Read tolerantly: anything that is not one of the three choices is "system". A hand-edited value,
|
|
34
|
+
// or one left behind by an older version, must not leave the UI without a theme — and "system" is
|
|
35
|
+
// the answer that would apply with no stored choice at all.
|
|
36
|
+
export function readThemeChoice(store?: Pick<Storage, "getItem">): ThemeChoice {
|
|
37
|
+
let stored: string | null = null;
|
|
38
|
+
// Reading `localStorage` throws outright in a few privacy modes, so the guard is a try, not a
|
|
39
|
+
// typeof check. Losing the preference is acceptable; losing the shell is not.
|
|
40
|
+
try {
|
|
41
|
+
stored = store?.getItem(STORE_KEY) ?? null;
|
|
42
|
+
} catch {
|
|
43
|
+
return "system";
|
|
44
|
+
}
|
|
45
|
+
const choices: readonly string[] = ThemeChoices;
|
|
46
|
+
return stored !== null && choices.includes(stored) ? (stored as ThemeChoice) : "system";
|
|
28
47
|
}
|
|
29
48
|
|
|
30
|
-
function
|
|
31
|
-
|
|
49
|
+
export function rememberThemeChoice(choice: ThemeChoice, store?: Pick<Storage, "setItem">): void {
|
|
50
|
+
try {
|
|
51
|
+
store?.setItem(STORE_KEY, choice);
|
|
52
|
+
} catch {
|
|
53
|
+
// A preference that cannot be written is still a preference for this session.
|
|
54
|
+
}
|
|
32
55
|
}
|
|
33
56
|
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
57
|
+
// Wiring: apply the choice and — only for "system" — react to every system change. A fixed choice
|
|
58
|
+
// deliberately does NOT subscribe: a listener that overrides the chosen theme the moment the
|
|
59
|
+
// operating system switches is exactly the bug the choice removes.
|
|
60
|
+
export function watchTheme(
|
|
61
|
+
root: HTMLElement,
|
|
62
|
+
media: MediaQueryList,
|
|
63
|
+
choice: ThemeChoice,
|
|
64
|
+
): () => void {
|
|
65
|
+
applyTheme(root, themeFor(choice, media.matches));
|
|
66
|
+
if (choice !== "system") return () => {};
|
|
67
|
+
const onChange = (event: MediaQueryListEvent) => applyTheme(root, resolveTheme(event.matches));
|
|
68
|
+
media.addEventListener("change", onChange);
|
|
69
|
+
return () => media.removeEventListener("change", onChange);
|
|
40
70
|
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { Check, ChevronsUpDown } from "lucide-react";
|
|
2
|
+
import { useMemo, useState } from "react";
|
|
3
|
+
import {
|
|
4
|
+
Command,
|
|
5
|
+
CommandEmpty,
|
|
6
|
+
CommandGroup,
|
|
7
|
+
CommandInput,
|
|
8
|
+
CommandItem,
|
|
9
|
+
CommandList,
|
|
10
|
+
} from "@/components/ui/command";
|
|
11
|
+
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
12
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
13
|
+
import { cn } from "@/lib/utils";
|
|
14
|
+
import {
|
|
15
|
+
browserTimezone,
|
|
16
|
+
type TimezoneOption,
|
|
17
|
+
timezoneOption,
|
|
18
|
+
timezoneOptions,
|
|
19
|
+
} from "../timezone.ts";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Picking one of 418 timezones.
|
|
23
|
+
*
|
|
24
|
+
* ⚠️ A `Select` was the wrong shape and this replaces it (#243). Four hundred entries cannot be
|
|
25
|
+
* scrolled to, and a bare zone name does not say whether you got the right one — `Europe/Berlin`
|
|
26
|
+
* and `Europe/Madrid` are the same clock, `America/New_York` is not. So: type to search, and the
|
|
27
|
+
* current offset stands in front of every name.
|
|
28
|
+
*
|
|
29
|
+
* ⚠️ The label is computed here, at render time, and never stored. A zone's offset AND its
|
|
30
|
+
* abbreviation move with daylight saving, so a written-down label is wrong for half of every year.
|
|
31
|
+
*/
|
|
32
|
+
interface TimezoneComboboxProps {
|
|
33
|
+
id: string;
|
|
34
|
+
value: string;
|
|
35
|
+
onChange(zone: string): void;
|
|
36
|
+
/** Handed in rather than read from the clock, so a test does not have to travel in time. */
|
|
37
|
+
now?: Date;
|
|
38
|
+
describedBy?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function OptionLabel({ option }: { option: TimezoneOption }) {
|
|
42
|
+
return (
|
|
43
|
+
<>
|
|
44
|
+
{/* Fixed width, so the zone names line up into a column instead of stepping left and right
|
|
45
|
+
with every offset. Tabular figures keep "GMT+05:30" and "GMT-04:00" the same width, and
|
|
46
|
+
⚠️ `whitespace-nowrap` is not decoration: the longest label ("GMT-09:00 · HADT") wrapped
|
|
47
|
+
onto a second line and pushed its own row out of the column. */}
|
|
48
|
+
<span className="w-36 shrink-0 whitespace-nowrap text-xs tabular-nums text-muted-foreground">
|
|
49
|
+
{option.offsetLabel}
|
|
50
|
+
{option.abbreviation ? ` · ${option.abbreviation}` : ""}
|
|
51
|
+
</span>
|
|
52
|
+
<span className="truncate">{option.zone.replace(/_/g, " ")}</span>
|
|
53
|
+
</>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function TimezoneCombobox({ id, value, onChange, now, describedBy }: TimezoneComboboxProps) {
|
|
58
|
+
const i18n = useI18n();
|
|
59
|
+
const [open, setOpen] = useState(false);
|
|
60
|
+
const at = now ?? new Date();
|
|
61
|
+
// The clock is a dependency on purpose: without it in the key, a session that crossed a DST
|
|
62
|
+
// boundary would keep showing yesterday's offsets.
|
|
63
|
+
const at_ = at.getTime();
|
|
64
|
+
|
|
65
|
+
const options = useMemo(
|
|
66
|
+
() => timezoneOptions(value, new Date(at_), i18n.locale),
|
|
67
|
+
[value, at_, i18n.locale],
|
|
68
|
+
);
|
|
69
|
+
const selected = useMemo(
|
|
70
|
+
() => timezoneOption(value, new Date(at_), i18n.locale),
|
|
71
|
+
[value, at_, i18n.locale],
|
|
72
|
+
);
|
|
73
|
+
// The reader's own zone, pinned above the list: it is the answer in the overwhelming majority of
|
|
74
|
+
// cases, and finding it in a sorted list of 418 is the work this saves.
|
|
75
|
+
const own = useMemo(
|
|
76
|
+
() => timezoneOption(browserTimezone(), new Date(at_), i18n.locale),
|
|
77
|
+
[at_, i18n.locale],
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const choose = (zone: string) => {
|
|
81
|
+
onChange(zone);
|
|
82
|
+
setOpen(false);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
87
|
+
<PopoverTrigger asChild>
|
|
88
|
+
<button
|
|
89
|
+
id={id}
|
|
90
|
+
type="button"
|
|
91
|
+
role="combobox"
|
|
92
|
+
aria-expanded={open}
|
|
93
|
+
aria-describedby={describedBy}
|
|
94
|
+
className="flex w-full items-center gap-2 rounded-md border bg-background px-3 py-2 text-left text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
95
|
+
>
|
|
96
|
+
<OptionLabel option={selected} />
|
|
97
|
+
<ChevronsUpDown aria-hidden="true" className="ml-auto size-4 shrink-0 opacity-50" />
|
|
98
|
+
</button>
|
|
99
|
+
</PopoverTrigger>
|
|
100
|
+
<PopoverContent className="w-(--radix-popover-trigger-width) p-0" align="start">
|
|
101
|
+
{/* ⚠️ A substring filter, not cmdk's default fuzzy one. Fuzzy matches a scattered
|
|
102
|
+
subsequence, so over 418 zones "berlin" also surfaced Brisbane, Dublin and Lisbon —
|
|
103
|
+
technically a match, useless as an answer. Prefix beats contains, so typing a city puts
|
|
104
|
+
that city first. An empty search returns 1 for everything, which leaves the list in DOM
|
|
105
|
+
order: sorted west to east. */}
|
|
106
|
+
<Command
|
|
107
|
+
filter={(value, search, keywords) => {
|
|
108
|
+
const needle = search.trim().toLowerCase();
|
|
109
|
+
if (!needle) return 1;
|
|
110
|
+
const haystack = [value.toLowerCase(), ...(keywords ?? [])];
|
|
111
|
+
if (haystack.some((word) => word.startsWith(needle))) return 2;
|
|
112
|
+
return haystack.some((word) => word.includes(needle)) ? 1 : 0;
|
|
113
|
+
}}
|
|
114
|
+
>
|
|
115
|
+
<CommandInput placeholder={i18n.t("settings.timezoneSearch")} />
|
|
116
|
+
<CommandList>
|
|
117
|
+
<CommandEmpty>{i18n.t("settings.timezoneNoMatch")}</CommandEmpty>
|
|
118
|
+
{own.zone !== value ? (
|
|
119
|
+
<CommandGroup heading={i18n.t("settings.timezoneOwn")}>
|
|
120
|
+
<CommandItem value={own.zone} keywords={own.keywords} onSelect={choose}>
|
|
121
|
+
<OptionLabel option={own} />
|
|
122
|
+
</CommandItem>
|
|
123
|
+
</CommandGroup>
|
|
124
|
+
) : null}
|
|
125
|
+
<CommandGroup heading={i18n.t("settings.timezoneAll")}>
|
|
126
|
+
{options.map((option) => (
|
|
127
|
+
<CommandItem
|
|
128
|
+
key={option.zone}
|
|
129
|
+
value={option.zone}
|
|
130
|
+
keywords={option.keywords}
|
|
131
|
+
onSelect={choose}
|
|
132
|
+
>
|
|
133
|
+
<OptionLabel option={option} />
|
|
134
|
+
<Check
|
|
135
|
+
aria-hidden="true"
|
|
136
|
+
className={cn(
|
|
137
|
+
"ml-auto size-4 shrink-0",
|
|
138
|
+
option.zone === value ? "opacity-100" : "opacity-0",
|
|
139
|
+
)}
|
|
140
|
+
/>
|
|
141
|
+
</CommandItem>
|
|
142
|
+
))}
|
|
143
|
+
</CommandGroup>
|
|
144
|
+
</CommandList>
|
|
145
|
+
</Command>
|
|
146
|
+
</PopoverContent>
|
|
147
|
+
</Popover>
|
|
148
|
+
);
|
|
149
|
+
}
|