@remit/web-client 0.0.141 → 0.0.143

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.141",
3
+ "version": "0.0.143",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -1,11 +1,11 @@
1
1
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
2
2
  import {
3
+ DialogBackdrop,
3
4
  IntelligencePanel,
4
5
  type IntelligenceQuickActions,
5
6
  type SimilarMessageLinkComponent,
6
7
  type SimilarState,
7
8
  } from "@remit/ui";
8
- import { Link } from "@tanstack/react-router";
9
9
  import { Sparkles } from "lucide-react";
10
10
  import { useCallback, useState } from "react";
11
11
  import { useIntelligenceData } from "@/hooks/useIntelligenceData";
@@ -14,6 +14,7 @@ import { useUpdateAddressFlags } from "@/hooks/useUpdateAddressFlags";
14
14
  import { isRescueCandidate } from "@/lib/rescue-candidates";
15
15
  import { recordRescueSentToJunk } from "@/lib/rescue-telemetry";
16
16
  import { useTelemetry } from "@/lib/telemetry-context";
17
+ import { NavLink } from "@/routing";
17
18
 
18
19
  export interface IntelligencePaneProps {
19
20
  onClose: () => void;
@@ -154,19 +155,17 @@ function ReclassifyDialog({
154
155
  }) {
155
156
  if (!isOpen) return null;
156
157
  return (
157
- <div
158
- className="fixed inset-0 z-50 flex items-center justify-center"
159
- aria-hidden="true"
160
- onClick={onCancel}
161
- >
162
- <div className="absolute inset-0 bg-canvas/80 backdrop-blur-sm" />
158
+ <div className="fixed inset-0 z-50 flex items-center justify-center">
159
+ <DialogBackdrop
160
+ label="Dismiss reclassify sender"
161
+ onDismiss={onCancel}
162
+ className="backdrop-blur-sm"
163
+ />
163
164
  <div
164
165
  role="dialog"
165
166
  aria-modal="true"
166
167
  aria-label="Reclassify sender"
167
168
  className="relative z-10 w-full max-w-sm rounded-sm border border-line bg-surface p-6 shadow-lg"
168
- onClick={(e) => e.stopPropagation()}
169
- onKeyDown={(e) => e.stopPropagation()}
170
169
  >
171
170
  <h2 className="text-lg font-semibold">Reclassify this sender</h2>
172
171
  <p className="mt-2 text-sm text-fg-muted">
@@ -316,15 +315,16 @@ function WiredPanel({
316
315
  ariaLabel,
317
316
  children,
318
317
  }) => (
319
- <Link
318
+ <NavLink
320
319
  to="/mail/$mailboxId"
321
320
  params={{ mailboxId: rowMailboxId }}
322
321
  search={(prev) => ({ ...prev, selectedMessageId: messageId })}
322
+ variant="row"
323
323
  className={className}
324
324
  aria-label={ariaLabel}
325
325
  >
326
326
  {children}
327
- </Link>
327
+ </NavLink>
328
328
  );
329
329
 
330
330
  if (!data) {
@@ -10,11 +10,11 @@
10
10
  */
11
11
  import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
12
12
  import { useAppShellLayout } from "@remit/ui";
13
- import { Link } from "@tanstack/react-router";
14
13
  import { LogOut, Settings } from "lucide-react";
15
14
  import { AccountSession } from "@/auth/AccountSession";
16
15
  import { MailSidebarAdapter } from "@/components/mail/MailSidebarAdapter";
17
16
  import { BugReportButton } from "@/components/ui/BugReportButton";
17
+ import { NavLink } from "@/routing";
18
18
 
19
19
  interface MailNavProps {
20
20
  accounts: RemitImapAccountResponse[];
@@ -39,14 +39,10 @@ export function MailNav({ accounts, onMailboxSelect }: MailNavProps) {
39
39
  />
40
40
  </div>
41
41
  <div className="border-t border-line px-2 py-2">
42
- <Link
43
- to="/settings/accounts"
44
- onClick={onMailboxSelect}
45
- className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-sm text-fg-muted transition-colors hover:bg-surface hover:text-fg"
46
- >
42
+ <NavLink to="/settings/accounts" onClick={onMailboxSelect}>
47
43
  <Settings className="size-4 shrink-0" />
48
44
  <span className="flex-1 truncate text-left">Settings</span>
49
- </Link>
45
+ </NavLink>
50
46
  <BugReportButton />
51
47
  <AccountSession>
52
48
  {({ signOut }) => (
@@ -10,12 +10,7 @@ import type {
10
10
  import type { NavAccount, NavLinkComponent, NavMailboxRole } from "@remit/ui";
11
11
  import { NavSidebar } from "@remit/ui";
12
12
  import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
13
- import {
14
- Link,
15
- useLocation,
16
- useNavigate,
17
- useParams,
18
- } from "@tanstack/react-router";
13
+ import { useLocation, useNavigate, useParams } from "@tanstack/react-router";
19
14
  import { useCallback, useMemo, useState } from "react";
20
15
  import { useTranslation } from "react-i18next";
21
16
  import {
@@ -30,6 +25,7 @@ import {
30
25
  removeSavedSearch,
31
26
  saveSearch,
32
27
  } from "@/lib/saved-searches";
28
+ import { NavLink } from "@/routing";
33
29
 
34
30
  interface MailSidebarAdapterProps {
35
31
  accounts: RemitImapAccountResponse[];
@@ -232,21 +228,24 @@ export function MailSidebarAdapter({
232
228
  }) => {
233
229
  if (navId === "brief") {
234
230
  return (
235
- <Link
231
+ <NavLink
236
232
  to="/mail"
237
233
  search={{ q: undefined, selectedMessageId: undefined }}
234
+ // Every other mail list lives under /mail, so the default prefix
235
+ // match would leave the brief marked current on all of them.
236
+ activeOptions={{ exact: true, includeSearch: false }}
238
237
  onClick={() => onClick?.()}
239
238
  className={className}
240
239
  aria-label={ariaLabel}
241
240
  title={title}
242
241
  >
243
242
  {children}
244
- </Link>
243
+ </NavLink>
245
244
  );
246
245
  }
247
246
  if (navId === "outbox") {
248
247
  return (
249
- <Link
248
+ <NavLink
250
249
  to="/mail/outbox"
251
250
  search={{ q: undefined, selectedOutboxMessageId: undefined }}
252
251
  onClick={() => onClick?.()}
@@ -255,12 +254,12 @@ export function MailSidebarAdapter({
255
254
  title={title}
256
255
  >
257
256
  {children}
258
- </Link>
257
+ </NavLink>
259
258
  );
260
259
  }
261
260
  if (navId === "flagged") {
262
261
  return (
263
- <Link
262
+ <NavLink
264
263
  to="/mail/flagged"
265
264
  search={{ q: undefined, selectedMessageId: undefined }}
266
265
  onClick={() => onClick?.()}
@@ -269,12 +268,12 @@ export function MailSidebarAdapter({
269
268
  title={title}
270
269
  >
271
270
  {children}
272
- </Link>
271
+ </NavLink>
273
272
  );
274
273
  }
275
274
  if (navId === "settings") {
276
275
  return (
277
- <Link
276
+ <NavLink
278
277
  to="/settings/accounts"
279
278
  onClick={() => onClick?.()}
280
279
  className={className}
@@ -282,11 +281,11 @@ export function MailSidebarAdapter({
282
281
  title={title}
283
282
  >
284
283
  {children}
285
- </Link>
284
+ </NavLink>
286
285
  );
287
286
  }
288
287
  return (
289
- <Link
288
+ <NavLink
290
289
  to="/mail/$mailboxId"
291
290
  params={{ mailboxId: navId }}
292
291
  // Drop any stale search query / selected message when switching mailbox.
@@ -300,7 +299,7 @@ export function MailSidebarAdapter({
300
299
  title={title}
301
300
  >
302
301
  {children}
303
- </Link>
302
+ </NavLink>
304
303
  );
305
304
  };
306
305
 
@@ -25,17 +25,12 @@ import {
25
25
  useLongPress,
26
26
  } from "@remit/ui";
27
27
  import { useQueryClient } from "@tanstack/react-query";
28
- import { Link } from "@tanstack/react-router";
29
28
  import { type MouseEvent, memo, type ReactNode, useCallback } from "react";
30
29
  import { cn } from "@/lib/utils";
30
+ import { NavLink } from "@/routing";
31
31
  import { useThreadRowInteraction } from "./ThreadListInteraction";
32
32
  import { useModifierSelect } from "./useModifierSelect";
33
33
 
34
- interface MailboxLinkSearch {
35
- selectedMessageId?: string;
36
- q?: string;
37
- }
38
-
39
34
  export interface MessageRowSelection {
40
35
  isChecked: boolean;
41
36
  onToggleCheck: (messageId: string) => void;
@@ -254,18 +249,16 @@ const MessageRowComponent = ({
254
249
  }
255
250
 
256
251
  return (
257
- <Link
252
+ <NavLink
258
253
  to="/mail/$mailboxId"
259
254
  params={{ mailboxId: linkMailboxId }}
260
- search={(prev: MailboxLinkSearch) => ({
261
- ...prev,
262
- selectedMessageId: messageId,
263
- })}
255
+ search={(prev) => ({ ...prev, selectedMessageId: messageId })}
256
+ variant="row"
264
257
  {...interactionProps}
265
258
  className={className}
266
259
  >
267
260
  {body}
268
- </Link>
261
+ </NavLink>
269
262
  );
270
263
  };
271
264
 
@@ -1,4 +1,4 @@
1
- import { Kbd, KEY_HINT_GROUPS } from "@remit/ui";
1
+ import { DialogBackdrop, Kbd, KEY_HINT_GROUPS } from "@remit/ui";
2
2
  import { X } from "lucide-react";
3
3
  import { Fragment, useCallback, useEffect } from "react";
4
4
  import { cn } from "@/lib/utils";
@@ -37,29 +37,23 @@ export const KeyboardShortcutsModal = ({
37
37
  if (!isOpen) return null;
38
38
 
39
39
  return (
40
- <div
41
- className="fixed inset-0 z-50 flex items-center justify-center"
42
- role="dialog"
43
- aria-modal="true"
44
- aria-label="Keyboard shortcuts"
45
- onClick={onClose}
46
- onKeyDown={(e) => e.key === "Escape" && onClose()}
47
- >
48
- {/* Backdrop */}
49
- <div className="absolute inset-0 bg-canvas/80 backdrop-blur-sm" />
40
+ <div className="fixed inset-0 z-50 flex items-center justify-center">
41
+ <DialogBackdrop
42
+ label="Dismiss keyboard shortcuts"
43
+ onDismiss={onClose}
44
+ className="backdrop-blur-sm"
45
+ />
50
46
 
51
47
  {/* Modal */}
52
- {/* biome-ignore lint/a11y/noStaticElementInteractions: stops event propagation from the modal content; keyboard is handled by the outer wrapper */}
53
48
  <div
49
+ role="dialog"
50
+ aria-modal="true"
51
+ aria-label="Keyboard shortcuts"
54
52
  className={cn(
55
53
  "relative z-10 max-h-[85vh] w-full max-w-2xl overflow-y-auto",
56
54
  "rounded-lg border border-line bg-surface shadow-lg",
57
55
  "p-6",
58
56
  )}
59
- onClick={(e) => e.stopPropagation()}
60
- onKeyDown={(e) => {
61
- if (e.key !== "Escape") e.stopPropagation();
62
- }}
63
57
  >
64
58
  {/* Header */}
65
59
  <div className="mb-6 flex items-center justify-between">
@@ -0,0 +1,35 @@
1
+ import { useLocation } from "@tanstack/react-router";
2
+ import { z } from "zod";
3
+
4
+ /**
5
+ * The fragment tier carries panel visibility and nothing else — no identity, no
6
+ * payload, no selection. A panel qualifies only when its own copy is
7
+ * independent of in-memory data, so a cold load of the URL reproduces it
8
+ * exactly.
9
+ */
10
+ export const panelFragments = [
11
+ "intelligence",
12
+ "nav",
13
+ "shortcuts",
14
+ "filters",
15
+ ] as const;
16
+
17
+ export type PanelFragment = (typeof panelFragments)[number];
18
+
19
+ const panelFragmentSchema = z.enum(panelFragments);
20
+
21
+ /**
22
+ * A hand-edited or stale URL is a normal thing to receive, so an unrecognised
23
+ * fragment reads as "no panel open" instead of throwing.
24
+ */
25
+ export function parsePanelFragment(hash: string): PanelFragment | undefined {
26
+ const parsed = panelFragmentSchema.safeParse(hash);
27
+ return parsed.success ? parsed.data : undefined;
28
+ }
29
+
30
+ /** The panel the URL currently asks for, if any. */
31
+ export function useOpenPanel(): PanelFragment | undefined {
32
+ return useLocation({
33
+ select: (location) => parsePanelFragment(location.hash),
34
+ });
35
+ }
@@ -0,0 +1,7 @@
1
+ export {
2
+ type PanelFragment,
3
+ panelFragments,
4
+ parsePanelFragment,
5
+ useOpenPanel,
6
+ } from "./fragment";
7
+ export { NavLink, type NavLinkProps } from "./nav-link";
@@ -0,0 +1,58 @@
1
+ import { NavLinkSurface } from "@remit/ui";
2
+ import type {
3
+ AnyRouter,
4
+ CreateLinkProps,
5
+ LinkComponentProps,
6
+ RegisteredRouter,
7
+ } from "@tanstack/react-router";
8
+ import { createLink } from "@tanstack/react-router";
9
+ import type { ReactElement } from "react";
10
+ import type { PanelFragment } from "./fragment";
11
+
12
+ const RouterNavLink = createLink(NavLinkSurface);
13
+
14
+ /**
15
+ * The router's own link props, minus the raw `hash`: the fragment tier is a
16
+ * closed union of panel names, so `fragment` is the only way to write it.
17
+ */
18
+ export type NavLinkProps<
19
+ TRouter extends AnyRouter = RegisteredRouter,
20
+ TFrom extends string = string,
21
+ TTo extends string | undefined = undefined,
22
+ TMaskFrom extends string = TFrom,
23
+ TMaskTo extends string = "",
24
+ > = LinkComponentProps<
25
+ typeof NavLinkSurface,
26
+ TRouter,
27
+ TFrom,
28
+ TTo,
29
+ TMaskFrom,
30
+ TMaskTo
31
+ > & {
32
+ fragment?: PanelFragment;
33
+ /**
34
+ * Closed off rather than omitted: `Omit` over these props is a mapped type,
35
+ * and the router's `to` / `params` / `search` inference cannot see through
36
+ * one.
37
+ */
38
+ hash?: never;
39
+ };
40
+
41
+ /**
42
+ * The application's only navigation link. `createLink` keeps the router's `to` /
43
+ * `params` / `search` inference, so a route that does not exist or a param set
44
+ * that does not match it fails to compile instead of rendering a blank pane.
45
+ */
46
+ export function NavLink<
47
+ TRouter extends AnyRouter = RegisteredRouter,
48
+ const TFrom extends string = string,
49
+ const TTo extends string | undefined = undefined,
50
+ const TMaskFrom extends string = TFrom,
51
+ const TMaskTo extends string = "",
52
+ >(props: NavLinkProps<TRouter, TFrom, TTo, TMaskFrom, TMaskTo>): ReactElement;
53
+ export function NavLink({
54
+ fragment,
55
+ ...props
56
+ }: CreateLinkProps & { fragment?: PanelFragment }): ReactElement {
57
+ return <RouterNavLink {...props} hash={fragment} />;
58
+ }