@remit/ui 0.0.20 → 0.0.22

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/ui",
3
- "version": "0.0.20",
3
+ "version": "0.0.22",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -42,8 +42,7 @@ describe("MobileSearchView search scope", () => {
42
42
  const html = renderToString(
43
43
  createElement(MobileSearchView, {
44
44
  ...base,
45
- scope: { kind: "global" as const },
46
- onScopeToSpam: noop,
45
+ scope: { kind: "global" as const, onScopeToSpam: noop },
47
46
  }),
48
47
  );
49
48
  assert.doesNotMatch(html, /unknown-vendor/);
@@ -56,7 +55,6 @@ describe("MobileSearchView search scope", () => {
56
55
  createElement(MobileSearchView, {
57
56
  ...base,
58
57
  scope: { kind: "folder" as const, role: "inbox" as const },
59
- onScopeToSpam: noop,
60
58
  }),
61
59
  );
62
60
  assert.doesNotMatch(html, /unknown-vendor/);
@@ -64,15 +62,14 @@ describe("MobileSearchView search scope", () => {
64
62
  assert.doesNotMatch(html, /Archive/);
65
63
  });
66
64
 
67
- it("passes a caller-supplied spam total through", () => {
65
+ it("counts the spam it held out, on the phone tier too", () => {
68
66
  const html = renderToString(
69
67
  createElement(MobileSearchView, {
70
68
  ...base,
71
- scope: { kind: "global" as const },
72
- spamMatchCount: 42,
73
- onScopeToSpam: noop,
69
+ scope: { kind: "global" as const, onScopeToSpam: noop },
74
70
  }),
75
71
  );
76
- assert.match(html, />42</);
72
+ assert.match(html, /from Spam/);
73
+ assert.match(html, /Go to Spam/);
77
74
  });
78
75
  });
@@ -156,8 +156,6 @@ function Harness({
156
156
  sections,
157
157
  preset,
158
158
  scope,
159
- spamMatchCount,
160
- onScopeToSpam,
161
159
  }: {
162
160
  initialValue?: string;
163
161
  initialChips?: SearchChip[];
@@ -165,8 +163,6 @@ function Harness({
165
163
  sections?: SearchResultSection[];
166
164
  preset: Preset;
167
165
  scope?: SearchScope;
168
- spamMatchCount?: number;
169
- onScopeToSpam?: () => void;
170
166
  }) {
171
167
  const [value, setValue] = useState(initialValue);
172
168
  const [chips, setChips] = useState<SearchChip[]>(initialChips);
@@ -240,8 +236,6 @@ function Harness({
240
236
  loading={loading}
241
237
  onSelectResult={setOpened}
242
238
  scope={scope}
243
- spamMatchCount={spamMatchCount}
244
- onScopeToSpam={onScopeToSpam}
245
239
  />
246
240
  );
247
241
  }
@@ -343,8 +337,7 @@ export const GlobalAcrossFolders: Story = {
343
337
  <Harness
344
338
  initialValue="invoice"
345
339
  sections={acrossFoldersSections}
346
- scope={{ kind: "global" }}
347
- onScopeToSpam={() => {}}
340
+ scope={{ kind: "global", onScopeToSpam: () => {} }}
348
341
  preset="brief"
349
342
  />
350
343
  ),
@@ -362,7 +355,6 @@ export const ScopedToInbox: Story = {
362
355
  initialChips={[{ id: "in:inbox", label: "in:inbox" }]}
363
356
  sections={acrossFoldersSections}
364
357
  scope={{ kind: "folder", role: "inbox" }}
365
- onScopeToSpam={() => {}}
366
358
  preset="inbox"
367
359
  />
368
360
  ),
@@ -377,8 +369,25 @@ export const GlobalOnlySpamMatches: Story = {
377
369
  <Harness
378
370
  initialValue="invoice"
379
371
  sections={[{ id: "top", label: "Top matches", results: spamMatches }]}
380
- scope={{ kind: "global" }}
381
- onScopeToSpam={() => {}}
372
+ scope={{ kind: "global", onScopeToSpam: () => {} }}
373
+ preset="brief"
374
+ />
375
+ ),
376
+ };
377
+
378
+ /**
379
+ * The same rows under a starred search. Starring spans folders, so the rows
380
+ * keep their provenance labels, and the spam among them stays in the list — the
381
+ * user starred it themselves, so there is nothing to hold back and no offer to
382
+ * make.
383
+ */
384
+ export const StarredCollection: Story = {
385
+ render: () => (
386
+ <Harness
387
+ initialValue="invoice"
388
+ initialChips={[{ id: "is:starred", label: "is:starred" }]}
389
+ sections={acrossFoldersSections}
390
+ scope={{ kind: "collection" }}
382
391
  preset="brief"
383
392
  />
384
393
  ),
@@ -46,10 +46,6 @@ export interface MobileSearchViewProps {
46
46
  onRemoveChip?: (id: string) => void;
47
47
  /** What the search covers; see `SearchResultsProps`. Defaults to global. */
48
48
  scope?: SearchScope;
49
- /** Total spam matches found; see `SearchResultsProps`. */
50
- spamMatchCount?: number;
51
- /** Scope the search to Spam; see `SearchResultsProps`. */
52
- onScopeToSpam?: () => void;
53
49
  }
54
50
 
55
51
  /**
@@ -81,8 +77,6 @@ export function MobileSearchView({
81
77
  chips,
82
78
  onRemoveChip,
83
79
  scope,
84
- spamMatchCount,
85
- onScopeToSpam,
86
80
  }: MobileSearchViewProps) {
87
81
  const body = (
88
82
  <SearchResults
@@ -94,8 +88,6 @@ export function MobileSearchView({
94
88
  onSelectResult={onSelectResult}
95
89
  tokens={tokens}
96
90
  scope={scope}
97
- spamMatchCount={spamMatchCount}
98
- onScopeToSpam={onScopeToSpam}
99
91
  />
100
92
  );
101
93
 
@@ -114,8 +114,7 @@ describe("SearchResults spam handling", () => {
114
114
  createElement(SearchResults, {
115
115
  value: "invoice",
116
116
  sections: mixed,
117
- scope: { kind: "global" },
118
- onScopeToSpam: noop,
117
+ scope: { kind: "global", onScopeToSpam: noop },
119
118
  }),
120
119
  );
121
120
  assert.doesNotMatch(html, /unknown-vendor/);
@@ -130,8 +129,7 @@ describe("SearchResults spam handling", () => {
130
129
  sections: [
131
130
  { id: "results", label: "Results", results: [archivedResult] },
132
131
  ],
133
- scope: { kind: "global" },
134
- onScopeToSpam: noop,
132
+ scope: { kind: "global", onScopeToSpam: noop },
135
133
  }),
136
134
  );
137
135
  assert.doesNotMatch(html, /from Spam/);
@@ -142,8 +140,7 @@ describe("SearchResults spam handling", () => {
142
140
  createElement(SearchResults, {
143
141
  value: "invoice",
144
142
  sections: [{ id: "results", label: "Results", results: [spamResult] }],
145
- scope: { kind: "global" },
146
- onScopeToSpam: noop,
143
+ scope: { kind: "global", onScopeToSpam: noop },
147
144
  }),
148
145
  );
149
146
  assert.match(html, /No matches for/);
@@ -156,7 +153,6 @@ describe("SearchResults spam handling", () => {
156
153
  value: "invoice",
157
154
  sections: mixed,
158
155
  scope: { kind: "folder", role: "inbox" },
159
- onScopeToSpam: noop,
160
156
  }),
161
157
  );
162
158
  assert.doesNotMatch(html, /unknown-vendor/);
@@ -170,7 +166,6 @@ describe("SearchResults spam handling", () => {
170
166
  value: "invoice",
171
167
  sections: mixed,
172
168
  scope: { kind: "folder", role: "junk" },
173
- onScopeToSpam: noop,
174
169
  }),
175
170
  );
176
171
  assert.match(html, /unknown-vendor/);
@@ -187,19 +182,6 @@ describe("SearchResults spam handling", () => {
187
182
  );
188
183
  assert.doesNotMatch(html, /from Spam/);
189
184
  });
190
-
191
- it("prefers a caller-supplied total over the rows held out of this page", () => {
192
- const html = renderToString(
193
- createElement(SearchResults, {
194
- value: "invoice",
195
- sections: mixed,
196
- scope: { kind: "global" },
197
- spamMatchCount: 42,
198
- onScopeToSpam: noop,
199
- }),
200
- );
201
- assert.match(html, />42</);
202
- });
203
185
  });
204
186
 
205
187
  describe("SearchResults provenance labels", () => {
@@ -268,3 +250,86 @@ describe("SearchResults provenance labels", () => {
268
250
  assert.doesNotMatch(html, /Important/);
269
251
  });
270
252
  });
253
+
254
+ describe("SearchResults scoped to a collection", () => {
255
+ const spamRow: SearchResult = {
256
+ id: "s1",
257
+ sender: "billing@unknown-vendor.test",
258
+ subject: "URGENT invoice attached",
259
+ snippet: "Wire the amount below.",
260
+ date: "Feb 11",
261
+ folder: { role: "junk" },
262
+ };
263
+ const archivedRow: SearchResult = {
264
+ ...result,
265
+ id: "a1",
266
+ folder: { role: "archive" },
267
+ };
268
+ const collectionSections: SearchResultSection[] = [
269
+ { id: "results", label: "Results", results: [archivedRow, spamRow] },
270
+ ];
271
+
272
+ it("keeps a starred spam match in the list", () => {
273
+ const html = renderToString(
274
+ createElement(SearchResults, {
275
+ value: "invoice",
276
+ sections: collectionSections,
277
+ scope: { kind: "collection" },
278
+ }),
279
+ );
280
+ assert.match(html, /billing@unknown-vendor\.test/);
281
+ assert.doesNotMatch(html, /from Spam/);
282
+ });
283
+
284
+ it("still names the folder each row came from", () => {
285
+ const html = renderToString(
286
+ createElement(SearchResults, {
287
+ value: "invoice",
288
+ sections: collectionSections,
289
+ scope: { kind: "collection" },
290
+ }),
291
+ );
292
+ assert.match(html, /Archive/);
293
+ assert.match(html, /Spam/);
294
+ });
295
+
296
+ it("holds the same spam row out when the search is scoped to a folder", () => {
297
+ const html = renderToString(
298
+ createElement(SearchResults, {
299
+ value: "invoice",
300
+ sections: collectionSections,
301
+ scope: { kind: "folder", role: "inbox" },
302
+ }),
303
+ );
304
+ assert.doesNotMatch(html, /billing@unknown-vendor\.test/);
305
+ });
306
+ });
307
+
308
+ describe("SearchResults spam count", () => {
309
+ const spamIn = (id: string): SearchResult => ({
310
+ id,
311
+ sender: "spammer",
312
+ subject: "Offer",
313
+ snippet: "",
314
+ date: "",
315
+ folder: { role: "junk" },
316
+ });
317
+
318
+ it("counts every held-out row, not just one account's", () => {
319
+ const html = renderToString(
320
+ createElement(SearchResults, {
321
+ value: "invoice",
322
+ sections: [
323
+ {
324
+ id: "results",
325
+ label: "Results",
326
+ results: [result, spamIn("a1"), spamIn("b1"), spamIn("b2")],
327
+ },
328
+ ],
329
+ scope: { kind: "global", onScopeToSpam: noop },
330
+ }),
331
+ );
332
+ assert.match(html, />3</);
333
+ assert.match(html, /results from Spam/);
334
+ });
335
+ });
@@ -190,8 +190,7 @@ export const GlobalAcrossFolders: Story = {
190
190
  render: () => (
191
191
  <Harness
192
192
  value="invoice"
193
- scope={{ kind: "global" }}
194
- onScopeToSpam={() => {}}
193
+ scope={{ kind: "global", onScopeToSpam: () => {} }}
195
194
  sections={[
196
195
  { id: "top", label: "Top matches", results: globalTopMatches },
197
196
  { id: "related", label: "Related", results: related },
@@ -209,8 +208,7 @@ export const GlobalWithoutSpamMatches: Story = {
209
208
  render: () => (
210
209
  <Harness
211
210
  value="invoice"
212
- scope={{ kind: "global" }}
213
- onScopeToSpam={() => {}}
211
+ scope={{ kind: "global", onScopeToSpam: () => {} }}
214
212
  sections={[
215
213
  {
216
214
  id: "top",
@@ -234,8 +232,7 @@ export const GlobalAccountWithoutSpamFolder: Story = {
234
232
  render: () => (
235
233
  <Harness
236
234
  value="invoice"
237
- scope={{ kind: "global" }}
238
- onScopeToSpam={() => {}}
235
+ scope={{ kind: "global", onScopeToSpam: () => {} }}
239
236
  sections={[
240
237
  { id: "top", label: "Top matches", results: crossFolderMatches },
241
238
  ]}
@@ -252,8 +249,7 @@ export const GlobalOnlySpamMatches: Story = {
252
249
  render: () => (
253
250
  <Harness
254
251
  value="invoice"
255
- scope={{ kind: "global" }}
256
- onScopeToSpam={() => {}}
252
+ scope={{ kind: "global", onScopeToSpam: () => {} }}
257
253
  sections={[{ id: "top", label: "Top matches", results: spamMatches }]}
258
254
  />
259
255
  ),
@@ -273,7 +269,6 @@ export const ScopedToInbox: Story = {
273
269
  <Harness
274
270
  value="invoice"
275
271
  scope={{ kind: "folder", role: "inbox" }}
276
- onScopeToSpam={() => {}}
277
272
  sections={[
278
273
  { id: "top", label: "Top matches", results: globalTopMatches },
279
274
  { id: "related", label: "Related", results: related },
@@ -293,7 +288,6 @@ export const ScopedToSpam: Story = {
293
288
  <Harness
294
289
  value="invoice"
295
290
  scope={{ kind: "folder", role: "junk" }}
296
- onScopeToSpam={() => {}}
297
291
  sections={[{ id: "top", label: "Top matches", results: spamMatches }]}
298
292
  />
299
293
  ),
@@ -343,3 +337,23 @@ export const WithFilterTokens: Story = {
343
337
  />
344
338
  ),
345
339
  };
340
+
341
+ /**
342
+ * Starred search (`is:starred` in the bar), given the same rows as the global
343
+ * story. Starring is not a folder: the rows come from all over, so each keeps
344
+ * its provenance label, and the two Spam matches stay in the list rather than
345
+ * being held out. The user starred them; holding their own mail back from them
346
+ * would be the surprising behaviour, and there is no offer because nothing was
347
+ * taken away.
348
+ */
349
+ export const StarredCollection: Story = {
350
+ render: () => (
351
+ <Harness
352
+ value="invoice"
353
+ scope={{ kind: "collection" }}
354
+ sections={[
355
+ { id: "top", label: "Top matches", results: globalTopMatches },
356
+ ]}
357
+ />
358
+ ),
359
+ };
@@ -18,12 +18,22 @@ export interface SearchResultSection {
18
18
  }
19
19
 
20
20
  /**
21
- * What the search currently covers. `global` is the unscoped search the daily
22
- * brief runs — every account, every folder, no chip in the bar. `folder` is a
23
- * search the sidebar narrowed to one place, which the bar shows as a chip.
21
+ * What the search currently covers.
22
+ *
23
+ * - `global` the unscoped search the daily brief runs: every account, every
24
+ * folder, no chip in the bar. It is the only scope that holds spam out, so it
25
+ * is the only one that carries a way back to it: without `onScopeToSpam` there
26
+ * is nowhere to send the user and no offer is made.
27
+ * - `folder` — narrowed to one place by the sidebar, which the bar shows as a
28
+ * chip.
29
+ * - `collection` — narrowed by something that is not a folder, `is:starred`
30
+ * being the one that exists. It still spans folders, so rows carry their
31
+ * provenance; and because the narrowing is the user's own (they starred the
32
+ * mail), spam is not held back from them.
24
33
  */
25
34
  export type SearchScope =
26
- | { kind: "global" }
35
+ | { kind: "global"; onScopeToSpam?: () => void }
36
+ | { kind: "collection" }
27
37
  | { kind: "folder"; role?: FolderRole };
28
38
 
29
39
  const GLOBAL_SCOPE: SearchScope = { kind: "global" };
@@ -74,17 +84,6 @@ export interface SearchResultsProps {
74
84
  tokens?: { label: string; onRemove: () => void }[];
75
85
  /** What the search covers. Defaults to the unscoped, global search. */
76
86
  scope?: SearchScope;
77
- /**
78
- * Total spam matches the search found, for when the app knows more than the
79
- * rows on this page. Defaults to the number of spam rows held out of
80
- * {@link sections}.
81
- */
82
- spamMatchCount?: number;
83
- /**
84
- * Scope the search to Spam. Without it there is no offer, so an app that has
85
- * nowhere to navigate simply never makes one.
86
- */
87
- onScopeToSpam?: () => void;
88
87
  }
89
88
 
90
89
  /**
@@ -177,12 +176,15 @@ function CollapsibleResultSection({
177
176
  *
178
177
  * - **Global** — spam rows are held out of the sections and offered instead, as
179
178
  * a count with a way into a Spam-scoped search.
180
- * - **Scoped to anything else** — spam rows are held out and nothing is offered.
181
- * A scoped search shows its own scope and no more.
179
+ * - **Scoped to a folder** — spam rows are held out and nothing is offered. A
180
+ * scoped search shows its own scope and no more.
182
181
  * - **Scoped to Spam** — ordinary rows, rendered normally, no offer.
182
+ * - **A collection** (`is:starred`) — ordinary rows, spam included. The user
183
+ * picked this mail out by hand; there is nothing to protect them from.
183
184
  *
184
- * Provenance labels follow the same logic: only a global search names the
185
- * folder each row came from, because a scoped one would repeat itself.
185
+ * Provenance labels follow whether the search reaches more than one folder: a
186
+ * global or collection search names the folder each row came from, a
187
+ * folder-scoped one would only repeat its own chip.
186
188
  */
187
189
  export function SearchResults({
188
190
  value,
@@ -193,8 +195,6 @@ export function SearchResults({
193
195
  onSelectResult,
194
196
  tokens,
195
197
  scope = GLOBAL_SCOPE,
196
- spamMatchCount,
197
- onScopeToSpam,
198
198
  }: SearchResultsProps) {
199
199
  const hasQuery = value.trim().length > 0;
200
200
 
@@ -249,11 +249,17 @@ export function SearchResults({
249
249
  );
250
250
  }
251
251
 
252
- const spamScoped = isSpamScope(scope);
253
252
  const isGlobal = scope.kind === "global";
253
+ // Only a folder search holds spam back. Scoped to Spam there is nothing to
254
+ // hold back, and a collection is the user's own selection of mail rather than
255
+ // a place, so a starred spam message stays where the user put it.
256
+ const spamInline = isSpamScope(scope) || scope.kind === "collection";
257
+ // A search that reaches more than one folder names the folder each row came
258
+ // from; one confined to a single folder would repeat its own chip.
259
+ const spansFolders = isGlobal || scope.kind === "collection";
254
260
 
255
261
  const partitioned = (sections ?? []).map((section) => {
256
- if (spamScoped) return { section, spam: [] as SearchResult[] };
262
+ if (spamInline) return { section, spam: [] as SearchResult[] };
257
263
  const { kept, spam } = partitionSpamResults(section.results);
258
264
  return { section: { ...section, results: kept }, spam };
259
265
  });
@@ -263,12 +269,15 @@ export function SearchResults({
263
269
  (total, entry) => total + entry.spam.length,
264
270
  0,
265
271
  );
266
- const spamCount = spamMatchCount ?? heldOutSpamCount;
267
- const spamOffer = isGlobal &&
268
- spamCount > 0 &&
269
- onScopeToSpam !== undefined && (
270
- <SpamResultsOffer count={spamCount} onScopeToSpam={onScopeToSpam} />
271
- );
272
+ const spamOffer =
273
+ scope.kind === "global" &&
274
+ heldOutSpamCount > 0 &&
275
+ scope.onScopeToSpam !== undefined ? (
276
+ <SpamResultsOffer
277
+ count={heldOutSpamCount}
278
+ onScopeToSpam={scope.onScopeToSpam}
279
+ />
280
+ ) : undefined;
272
281
 
273
282
  const hasResults = visibleSections.some(
274
283
  (section) => section.results.length > 0,
@@ -302,7 +311,7 @@ export function SearchResults({
302
311
  key={section.id}
303
312
  section={section}
304
313
  query={value}
305
- showFolder={isGlobal}
314
+ showFolder={spansFolders}
306
315
  onSelectResult={onSelectResult}
307
316
  />
308
317
  ))}
@@ -0,0 +1,86 @@
1
+ import { Download, ExternalLink } from "lucide-react";
2
+ import { Button, ButtonLink } from "./button.js";
3
+ import { Dialog } from "./dialog.js";
4
+ import { formatReleaseDate, type ReleaseInfo } from "./self-update.js";
5
+
6
+ export interface SelfUpdateConfirmDialogProps {
7
+ open: boolean;
8
+ currentVersion: string;
9
+ release: ReleaseInfo;
10
+ onClose: () => void;
11
+ onConfirm: () => void;
12
+ }
13
+
14
+ const consequences = [
15
+ "Remit restarts. Mail stops loading for about a minute and this page will lose its connection while that happens.",
16
+ "Nothing is sent, deleted or moved. Your mail stays at your provider.",
17
+ "If the new version does not come back up, the version you are running now is restored automatically.",
18
+ ];
19
+
20
+ /**
21
+ * Consent before the server replaces itself. Reflects what will happen in the
22
+ * user's terms, in the order they will feel it, and leaves the way back open
23
+ * until the moment they commit.
24
+ */
25
+ export function SelfUpdateConfirmDialog({
26
+ open,
27
+ currentVersion,
28
+ release,
29
+ onClose,
30
+ onConfirm,
31
+ }: SelfUpdateConfirmDialogProps) {
32
+ if (!open) return null;
33
+
34
+ return (
35
+ <Dialog open onClose={onClose} title={`Install Remit ${release.version}`}>
36
+ <div className="flex max-h-[80vh] flex-col">
37
+ <header className="space-y-1 border-b border-line px-4 py-3">
38
+ <h3 className="text-sm font-semibold text-fg">
39
+ Install Remit {release.version}?
40
+ </h3>
41
+ <p className="text-xs text-fg-muted">
42
+ You are on {currentVersion}. {release.version} was released{" "}
43
+ {formatReleaseDate(release.releasedAt)}.
44
+ </p>
45
+ </header>
46
+
47
+ <div className="flex-1 space-y-3 overflow-auto px-4 py-3">
48
+ <p className="text-sm text-fg-muted">{release.summary}</p>
49
+ <ul className="space-y-2">
50
+ {consequences.map((line) => (
51
+ <li key={line} className="flex gap-2 text-sm text-fg-muted">
52
+ <span className="mt-1.5 size-1.5 shrink-0 rounded-full bg-fg-subtle" />
53
+ <span>{line}</span>
54
+ </li>
55
+ ))}
56
+ </ul>
57
+ <p className="text-xs text-fg-subtle">
58
+ Good moment for this: when you are not waiting on a message.
59
+ </p>
60
+ </div>
61
+
62
+ <footer className="flex flex-wrap items-center justify-end gap-2 border-t border-line px-4 py-3">
63
+ <Button variant="ghost" size="sm" onClick={onClose}>
64
+ Not now
65
+ </Button>
66
+ <ButtonLink
67
+ variant="secondary"
68
+ size="sm"
69
+ external
70
+ href={release.releaseNotesUrl}
71
+ icon={<ExternalLink className="size-3.5" />}
72
+ >
73
+ Release notes
74
+ </ButtonLink>
75
+ <Button
76
+ size="sm"
77
+ icon={<Download className="size-3.5" />}
78
+ onClick={onConfirm}
79
+ >
80
+ Install and restart
81
+ </Button>
82
+ </footer>
83
+ </div>
84
+ </Dialog>
85
+ );
86
+ }
@@ -0,0 +1,32 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ export interface UpdateAvailableDotProps {
4
+ /** The icon or control the hint is attached to. */
5
+ children?: ReactNode;
6
+ show: boolean;
7
+ /** Read out to assistive tech in place of the bare dot. */
8
+ label?: string;
9
+ }
10
+
11
+ /**
12
+ * The only place an available update is allowed to reach outside settings: a
13
+ * dot on the way there. It carries no count, no copy and no action, so it can
14
+ * never grow into a prompt over someone's mail.
15
+ */
16
+ export function UpdateAvailableDot({
17
+ children,
18
+ show,
19
+ label = "Update available",
20
+ }: UpdateAvailableDotProps) {
21
+ return (
22
+ <span className="relative inline-flex">
23
+ {children}
24
+ {show && (
25
+ <>
26
+ <span className="absolute -right-1 -top-1 size-2 rounded-full bg-accent-2" />
27
+ <span className="sr-only">{label}</span>
28
+ </>
29
+ )}
30
+ </span>
31
+ );
32
+ }