@remit/web-client 0.0.9 → 0.0.11
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 +1 -1
- package/src/components/mail/ConversationView.tsx +2 -1
- package/src/components/settings/AccountFormPanel.tsx +1 -2
- package/src/hooks/useMarkAsRead.test.ts +53 -1
- package/src/hooks/useMarkAsRead.ts +65 -28
- package/src/hooks/useToggleStar.test.ts +39 -1
- package/src/hooks/useToggleStar.ts +34 -2
- package/src/index.css +11 -2
- package/src/index.css.test.ts +97 -0
- package/src/components/ui/SlidePanel.tsx +0 -72
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.11",
|
|
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": {
|
|
@@ -163,7 +163,7 @@ export const ConversationView = ({
|
|
|
163
163
|
} = useQuery({
|
|
164
164
|
...threadDetailOperationsListThreadMessagesOptions({
|
|
165
165
|
path: { threadId },
|
|
166
|
-
query: { order: "desc"
|
|
166
|
+
query: { order: "desc" },
|
|
167
167
|
}),
|
|
168
168
|
});
|
|
169
169
|
|
|
@@ -266,6 +266,7 @@ export const ConversationView = ({
|
|
|
266
266
|
} = useToggleStar({
|
|
267
267
|
threadId,
|
|
268
268
|
mailboxId,
|
|
269
|
+
messages,
|
|
269
270
|
});
|
|
270
271
|
|
|
271
272
|
// Compose state for inline reply/forward.
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
configOperationsGetConfigQueryKey,
|
|
7
7
|
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
8
8
|
import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
|
|
9
|
-
import { Button, Input, Select, securityToApi } from "@remit/ui";
|
|
9
|
+
import { Button, Input, Select, SlidePanel, securityToApi } from "@remit/ui";
|
|
10
10
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
11
11
|
import { Check, Loader2, X } from "lucide-react";
|
|
12
12
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
@@ -19,7 +19,6 @@ import {
|
|
|
19
19
|
type ProviderPreset,
|
|
20
20
|
} from "../../lib/provider-presets.js";
|
|
21
21
|
import { cn } from "../../lib/utils";
|
|
22
|
-
import { SlidePanel } from "../ui/SlidePanel";
|
|
23
22
|
import {
|
|
24
23
|
appendAppPasswordHint,
|
|
25
24
|
computeSmtpAutoFill,
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import assert from "node:assert";
|
|
2
2
|
import { describe, test } from "node:test";
|
|
3
3
|
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
resolveMailboxesForMessages,
|
|
6
|
+
selectMessagesToMarkRead,
|
|
7
|
+
} from "./useMarkAsRead.js";
|
|
5
8
|
|
|
6
9
|
const make = (
|
|
7
10
|
overrides: Partial<RemitImapThreadMessageResponse> & {
|
|
@@ -163,3 +166,52 @@ describe("selectMessagesToMarkRead", () => {
|
|
|
163
166
|
assert.deepStrictEqual(got, ["m-older"]);
|
|
164
167
|
});
|
|
165
168
|
});
|
|
169
|
+
|
|
170
|
+
describe("resolveMailboxesForMessages", () => {
|
|
171
|
+
const messages = [
|
|
172
|
+
make({
|
|
173
|
+
messageId: "m-received",
|
|
174
|
+
threadMessageId: "tm1",
|
|
175
|
+
isRead: false,
|
|
176
|
+
mailboxId: "mb-inbox",
|
|
177
|
+
}),
|
|
178
|
+
make({
|
|
179
|
+
messageId: "m-sent",
|
|
180
|
+
threadMessageId: "tm2",
|
|
181
|
+
isRead: false,
|
|
182
|
+
mailboxId: "mb-sent",
|
|
183
|
+
}),
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
test("names every mailbox the batch touches, not the browsed one", () => {
|
|
187
|
+
const got = resolveMailboxesForMessages(
|
|
188
|
+
["m-received", "m-sent"],
|
|
189
|
+
messages,
|
|
190
|
+
"mb-inbox",
|
|
191
|
+
);
|
|
192
|
+
assert.deepStrictEqual([...got].sort(), ["mb-inbox", "mb-sent"]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("marking only the sent reply leaves the browsed mailbox alone", () => {
|
|
196
|
+
const got = resolveMailboxesForMessages(["m-sent"], messages, "mb-inbox");
|
|
197
|
+
assert.deepStrictEqual(got, ["mb-sent"]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("names each mailbox once however many messages it holds", () => {
|
|
201
|
+
const got = resolveMailboxesForMessages(
|
|
202
|
+
["m-received", "m-received"],
|
|
203
|
+
messages,
|
|
204
|
+
"mb-inbox",
|
|
205
|
+
);
|
|
206
|
+
assert.deepStrictEqual(got, ["mb-inbox"]);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("falls back to the browsed mailbox for an unknown message", () => {
|
|
210
|
+
const got = resolveMailboxesForMessages(
|
|
211
|
+
["m-elsewhere"],
|
|
212
|
+
messages,
|
|
213
|
+
"mb-inbox",
|
|
214
|
+
);
|
|
215
|
+
assert.deepStrictEqual(got, ["mb-inbox"]);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
@@ -41,12 +41,35 @@ interface SnapshotEntry<T> {
|
|
|
41
41
|
|
|
42
42
|
interface MarkAsReadContext {
|
|
43
43
|
threadMessagesPrefix: readonly unknown[];
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
threadsListPrefixes: ReadonlyArray<readonly unknown[]>;
|
|
45
|
+
threadsSearchPrefixes: ReadonlyArray<readonly unknown[]>;
|
|
46
46
|
previousThreadMessages: SnapshotEntry<ThreadMessagesData>[];
|
|
47
47
|
previousThreadsList: SnapshotEntry<ThreadsListData>[];
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* The mailboxes whose cached listings a batch of message mutations affects.
|
|
52
|
+
*
|
|
53
|
+
* A conversation spans mailboxes (#46), so marking a thread read can touch a
|
|
54
|
+
* received message in INBOX and the user's own reply in Sent at once, and each
|
|
55
|
+
* one's lists live under its own mailbox key. Falls back to the browsed mailbox
|
|
56
|
+
* for ids the thread's messages do not cover.
|
|
57
|
+
*/
|
|
58
|
+
export const resolveMailboxesForMessages = (
|
|
59
|
+
messageIds: Iterable<string>,
|
|
60
|
+
messages: RemitImapThreadMessageResponse[],
|
|
61
|
+
fallbackMailboxId: string,
|
|
62
|
+
): string[] => {
|
|
63
|
+
const byMessageId = new Map(
|
|
64
|
+
messages.map((message) => [message.messageId, message.mailboxId]),
|
|
65
|
+
);
|
|
66
|
+
const mailboxIds = new Set<string>();
|
|
67
|
+
for (const messageId of messageIds) {
|
|
68
|
+
mailboxIds.add(byMessageId.get(messageId) ?? fallbackMailboxId);
|
|
69
|
+
}
|
|
70
|
+
return [...mailboxIds];
|
|
71
|
+
};
|
|
72
|
+
|
|
50
73
|
const setReadOnItems = (
|
|
51
74
|
items: RemitImapThreadMessageResponse[],
|
|
52
75
|
messageIds: Set<string>,
|
|
@@ -101,17 +124,26 @@ export const useMarkAsRead = ({
|
|
|
101
124
|
threadDetailOperationsListThreadMessagesQueryKey({
|
|
102
125
|
path: { threadId },
|
|
103
126
|
});
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
127
|
+
const affectedMailboxIds = resolveMailboxesForMessages(
|
|
128
|
+
messageIds,
|
|
129
|
+
messages,
|
|
130
|
+
mailboxId,
|
|
131
|
+
);
|
|
132
|
+
const threadsListPrefixes = affectedMailboxIds.map((id) =>
|
|
133
|
+
threadOperationsListThreadsQueryKey({ path: { mailboxId: id } }),
|
|
134
|
+
);
|
|
135
|
+
const threadsSearchPrefixes = affectedMailboxIds.map((id) =>
|
|
136
|
+
threadOperationsSearchThreadsQueryKey({ path: { mailboxId: id } }),
|
|
137
|
+
);
|
|
110
138
|
|
|
111
139
|
await Promise.all([
|
|
112
140
|
queryClient.cancelQueries({ queryKey: threadMessagesPrefix }),
|
|
113
|
-
|
|
114
|
-
|
|
141
|
+
...threadsListPrefixes.map((queryKey) =>
|
|
142
|
+
queryClient.cancelQueries({ queryKey }),
|
|
143
|
+
),
|
|
144
|
+
...threadsSearchPrefixes.map((queryKey) =>
|
|
145
|
+
queryClient.cancelQueries({ queryKey }),
|
|
146
|
+
),
|
|
115
147
|
]);
|
|
116
148
|
|
|
117
149
|
const previousThreadMessages = queryClient
|
|
@@ -122,12 +154,12 @@ export const useMarkAsRead = ({
|
|
|
122
154
|
)
|
|
123
155
|
.map(([queryKey, data]) => ({ queryKey, data }));
|
|
124
156
|
|
|
125
|
-
const previousThreadsList =
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}),
|
|
157
|
+
const previousThreadsList = [
|
|
158
|
+
...threadsListPrefixes,
|
|
159
|
+
...threadsSearchPrefixes,
|
|
160
|
+
]
|
|
161
|
+
.flatMap((queryKey) =>
|
|
162
|
+
queryClient.getQueriesData<ThreadsListData>({ queryKey }),
|
|
131
163
|
)
|
|
132
164
|
.filter(
|
|
133
165
|
(entry): entry is [readonly unknown[], ThreadsListData] =>
|
|
@@ -157,19 +189,20 @@ export const useMarkAsRead = ({
|
|
|
157
189
|
};
|
|
158
190
|
};
|
|
159
191
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
)
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
192
|
+
for (const queryKey of [
|
|
193
|
+
...threadsListPrefixes,
|
|
194
|
+
...threadsSearchPrefixes,
|
|
195
|
+
]) {
|
|
196
|
+
queryClient.setQueriesData<ThreadsListData>(
|
|
197
|
+
{ queryKey },
|
|
198
|
+
patchListData,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
168
201
|
|
|
169
202
|
return {
|
|
170
203
|
threadMessagesPrefix,
|
|
171
|
-
|
|
172
|
-
|
|
204
|
+
threadsListPrefixes,
|
|
205
|
+
threadsSearchPrefixes,
|
|
173
206
|
previousThreadMessages,
|
|
174
207
|
previousThreadsList,
|
|
175
208
|
};
|
|
@@ -203,8 +236,12 @@ export const useMarkAsRead = ({
|
|
|
203
236
|
onSettled: (_data, _err, _vars, context) => {
|
|
204
237
|
if (!context) return;
|
|
205
238
|
queryClient.invalidateQueries({ queryKey: context.threadMessagesPrefix });
|
|
206
|
-
|
|
207
|
-
|
|
239
|
+
for (const queryKey of [
|
|
240
|
+
...context.threadsListPrefixes,
|
|
241
|
+
...context.threadsSearchPrefixes,
|
|
242
|
+
]) {
|
|
243
|
+
queryClient.invalidateQueries({ queryKey });
|
|
244
|
+
}
|
|
208
245
|
// Refresh the sidebar mailbox list so the unread badge picks up the
|
|
209
246
|
// next backend `unseenCount` (which is owned by IMAP sync).
|
|
210
247
|
if (accountId) {
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import assert from "node:assert";
|
|
2
2
|
import { describe, test } from "node:test";
|
|
3
3
|
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
resolveMailboxForMessage,
|
|
6
|
+
toggleStarsInItems,
|
|
7
|
+
} from "./useToggleStar.js";
|
|
5
8
|
|
|
6
9
|
const make = (
|
|
7
10
|
messageId: string,
|
|
@@ -55,3 +58,38 @@ describe("toggleStarsInItems", () => {
|
|
|
55
58
|
);
|
|
56
59
|
});
|
|
57
60
|
});
|
|
61
|
+
|
|
62
|
+
describe("resolveMailboxForMessage", () => {
|
|
63
|
+
const messages = [
|
|
64
|
+
{ ...make("m-received", false), mailboxId: "mb-inbox" },
|
|
65
|
+
{ ...make("m-sent", false), mailboxId: "mb-sent" },
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
test("starring a sent reply patches the sent mailbox, not the browsed one", () => {
|
|
69
|
+
assert.strictEqual(
|
|
70
|
+
resolveMailboxForMessage("m-sent", messages, "mb-inbox"),
|
|
71
|
+
"mb-sent",
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("starring a message in the browsed mailbox is unchanged", () => {
|
|
76
|
+
assert.strictEqual(
|
|
77
|
+
resolveMailboxForMessage("m-received", messages, "mb-inbox"),
|
|
78
|
+
"mb-inbox",
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("falls back to the browsed mailbox when the thread is unknown", () => {
|
|
83
|
+
assert.strictEqual(
|
|
84
|
+
resolveMailboxForMessage("m-sent", undefined, "mb-inbox"),
|
|
85
|
+
"mb-inbox",
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("falls back to the browsed mailbox for a message not in the thread", () => {
|
|
90
|
+
assert.strictEqual(
|
|
91
|
+
resolveMailboxForMessage("m-elsewhere", messages, "mb-inbox"),
|
|
92
|
+
"mb-inbox",
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -12,9 +12,35 @@ import { formatErrorDetail } from "@/components/ui/error-banners";
|
|
|
12
12
|
|
|
13
13
|
interface UseToggleStarOptions {
|
|
14
14
|
threadId: string;
|
|
15
|
+
/**
|
|
16
|
+
* The mailbox whose lists to patch when the starred message is not in
|
|
17
|
+
* `messages` — the browsed mailbox, which is where a list row lives.
|
|
18
|
+
*/
|
|
15
19
|
mailboxId: string;
|
|
20
|
+
/**
|
|
21
|
+
* The thread's messages, when the caller has them. A conversation spans
|
|
22
|
+
* mailboxes (#46), so the lists to patch are the ones for the mailbox the
|
|
23
|
+
* starred message is actually in, not the one being browsed.
|
|
24
|
+
*/
|
|
25
|
+
messages?: RemitImapThreadMessageResponse[];
|
|
16
26
|
}
|
|
17
27
|
|
|
28
|
+
/**
|
|
29
|
+
* The mailbox whose cached listings a mutation on `messageId` affects.
|
|
30
|
+
*
|
|
31
|
+
* The message's own mailbox, falling back to the browsed one when the thread's
|
|
32
|
+
* messages are unknown or do not contain it. Starring a reply in Sent from a
|
|
33
|
+
* conversation opened in INBOX has to patch Sent's lists; INBOX's do not hold
|
|
34
|
+
* that message and patching them changes nothing.
|
|
35
|
+
*/
|
|
36
|
+
export const resolveMailboxForMessage = (
|
|
37
|
+
messageId: string,
|
|
38
|
+
messages: RemitImapThreadMessageResponse[] | undefined,
|
|
39
|
+
fallbackMailboxId: string,
|
|
40
|
+
): string =>
|
|
41
|
+
messages?.find((message) => message.messageId === messageId)?.mailboxId ??
|
|
42
|
+
fallbackMailboxId;
|
|
43
|
+
|
|
18
44
|
interface ThreadMessagesData {
|
|
19
45
|
items: RemitImapThreadMessageResponse[];
|
|
20
46
|
[key: string]: unknown;
|
|
@@ -57,6 +83,7 @@ export const toggleStarsInItems = (
|
|
|
57
83
|
export const useToggleStar = ({
|
|
58
84
|
threadId,
|
|
59
85
|
mailboxId,
|
|
86
|
+
messages,
|
|
60
87
|
}: UseToggleStarOptions) => {
|
|
61
88
|
const queryClient = useQueryClient();
|
|
62
89
|
const { pushError } = useErrorBanners();
|
|
@@ -75,11 +102,16 @@ export const useToggleStar = ({
|
|
|
75
102
|
threadDetailOperationsListThreadMessagesQueryKey({
|
|
76
103
|
path: { threadId },
|
|
77
104
|
});
|
|
105
|
+
const affectedMailboxId = resolveMailboxForMessage(
|
|
106
|
+
messageId,
|
|
107
|
+
messages,
|
|
108
|
+
mailboxId,
|
|
109
|
+
);
|
|
78
110
|
const threadsListPrefix = threadOperationsListThreadsQueryKey({
|
|
79
|
-
path: { mailboxId },
|
|
111
|
+
path: { mailboxId: affectedMailboxId },
|
|
80
112
|
});
|
|
81
113
|
const threadsSearchPrefix = threadOperationsSearchThreadsQueryKey({
|
|
82
|
-
path: { mailboxId },
|
|
114
|
+
path: { mailboxId: affectedMailboxId },
|
|
83
115
|
});
|
|
84
116
|
// The unified cross-account listing backs the daily brief and the
|
|
85
117
|
// Starred mailbox, so a star toggled from an inbox has to land there
|
package/src/index.css
CHANGED
|
@@ -4,9 +4,18 @@
|
|
|
4
4
|
the .dark ancestor scope used everywhere in this app. */
|
|
5
5
|
@import "@remit/ui/tokens.css";
|
|
6
6
|
|
|
7
|
-
/* Tailwind v4 content scanning:
|
|
8
|
-
|
|
7
|
+
/* Tailwind v4 content scanning: name every source Tailwind must inspect for
|
|
8
|
+
utility class usage. Automatic detection is rooted at the Vite root, which
|
|
9
|
+
for the distributor harness build is a throwaway directory holding only the
|
|
10
|
+
entry — so nothing here may rely on it. Both this package's own components
|
|
11
|
+
and the linked workspace UI package are declared explicitly.
|
|
12
|
+
|
|
13
|
+
Tests are excluded: they assert on class strings, and a scanned assertion
|
|
14
|
+
emits the utility it names into the shipped stylesheet. */
|
|
15
|
+
@source "./**/*.{ts,tsx}";
|
|
9
16
|
@source "../../ui/src/**/*.{ts,tsx}";
|
|
17
|
+
@source not "./**/*.test.{ts,tsx}";
|
|
18
|
+
@source not "../../ui/src/**/*.test.{ts,tsx}";
|
|
10
19
|
|
|
11
20
|
/* Hide scrollbars while maintaining scroll functionality */
|
|
12
21
|
.overflow-y-auto {
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { describe, it } from "node:test";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Tailwind v4 roots automatic source detection at the bundler's root. The
|
|
9
|
+
* distributor harness builds from a throwaway root holding only the entry, so
|
|
10
|
+
* nothing this app renders is discovered automatically: every directory whose
|
|
11
|
+
* components carry utility classes has to be named by an `@source` rule. When
|
|
12
|
+
* one is missing the build still succeeds — it just ships a stylesheet with
|
|
13
|
+
* those utilities absent, and the screens using them lay out as unstyled boxes
|
|
14
|
+
* (#57: the settings slide-over covered the whole viewport).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const cssPath = resolve(here, "index.css");
|
|
19
|
+
const css = readFileSync(cssPath, "utf8");
|
|
20
|
+
|
|
21
|
+
/** Globs Tailwind scans, and globs it is told to skip. */
|
|
22
|
+
const globs = (kind: "include" | "exclude"): string[] =>
|
|
23
|
+
[...css.matchAll(/@source\s+(not\s+)?"([^"]+)"/g)]
|
|
24
|
+
.filter(([, negated]) => (kind === "exclude") === Boolean(negated))
|
|
25
|
+
.map(([, , glob]) => glob);
|
|
26
|
+
|
|
27
|
+
/** The directory an `@source` glob starts scanning from. */
|
|
28
|
+
const sourceRoots = (): string[] =>
|
|
29
|
+
globs("include").map((glob) => {
|
|
30
|
+
const literal = glob.split("*")[0] ?? glob;
|
|
31
|
+
return resolve(dirname(cssPath), literal);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/** Every directory below `root` that holds a component file. */
|
|
35
|
+
const componentDirs = (root: string): string[] => {
|
|
36
|
+
const found: string[] = [];
|
|
37
|
+
const walk = (dir: string): void => {
|
|
38
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
39
|
+
if (entries.some((e) => e.isFile() && e.name.endsWith(".tsx"))) {
|
|
40
|
+
found.push(dir);
|
|
41
|
+
}
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
if (entry.isDirectory() && entry.name !== "node_modules") {
|
|
44
|
+
walk(resolve(dir, entry.name));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
walk(root);
|
|
49
|
+
return found;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const isCovered = (dir: string, roots: string[]): boolean =>
|
|
53
|
+
roots.some((root) => {
|
|
54
|
+
const rel = relative(root, dir);
|
|
55
|
+
return rel === "" || (!rel.startsWith("..") && !rel.startsWith(sep));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("tailwind source coverage (#57)", () => {
|
|
59
|
+
const roots = sourceRoots();
|
|
60
|
+
|
|
61
|
+
it("declares at least one source", () => {
|
|
62
|
+
assert.ok(roots.length > 0);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("covers every directory in this package that renders components", () => {
|
|
66
|
+
const uncovered = componentDirs(here).filter((d) => !isCovered(d, roots));
|
|
67
|
+
assert.deepEqual(
|
|
68
|
+
uncovered.map((d) => relative(here, d)),
|
|
69
|
+
[],
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("covers the linked design system package", () => {
|
|
74
|
+
const ui = resolve(here, "../../ui/src");
|
|
75
|
+
const uncovered = componentDirs(ui).filter((d) => !isCovered(d, roots));
|
|
76
|
+
assert.deepEqual(
|
|
77
|
+
uncovered.map((d) => relative(ui, d)),
|
|
78
|
+
[],
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A test asserting on a class string is a class string in a scanned file, so
|
|
84
|
+
* without this every utility a test names is emitted into the shipped CSS.
|
|
85
|
+
*/
|
|
86
|
+
it("excludes test files from every source it scans", () => {
|
|
87
|
+
const uncovered = globs("include").filter(
|
|
88
|
+
(glob) =>
|
|
89
|
+
!globs("exclude").some(
|
|
90
|
+
(excluded) =>
|
|
91
|
+
excluded ===
|
|
92
|
+
glob.replace(/\/\*\*\/\*\.\{ts,tsx\}$/, "/**/*.test.{ts,tsx}"),
|
|
93
|
+
),
|
|
94
|
+
);
|
|
95
|
+
assert.deepEqual(uncovered, []);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
import { X } from "lucide-react";
|
|
2
|
-
import type { ReactNode } from "react";
|
|
3
|
-
import { cn } from "../../lib/utils";
|
|
4
|
-
|
|
5
|
-
interface SlidePanelProps {
|
|
6
|
-
isOpen: boolean;
|
|
7
|
-
onClose: () => void;
|
|
8
|
-
title: string;
|
|
9
|
-
children: ReactNode;
|
|
10
|
-
footer?: ReactNode;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export const SlidePanel = ({
|
|
14
|
-
isOpen,
|
|
15
|
-
onClose,
|
|
16
|
-
title,
|
|
17
|
-
children,
|
|
18
|
-
footer,
|
|
19
|
-
}: SlidePanelProps) => (
|
|
20
|
-
<>
|
|
21
|
-
{/* Backdrop overlay */}
|
|
22
|
-
{/* biome-ignore lint/a11y/useSemanticElements: full-screen backdrop overlay; <button> default styles would break fixed inset positioning */}
|
|
23
|
-
<div
|
|
24
|
-
className={cn(
|
|
25
|
-
"fixed inset-0 bg-black/30 z-40 transition-opacity",
|
|
26
|
-
isOpen ? "opacity-100" : "opacity-0 pointer-events-none",
|
|
27
|
-
)}
|
|
28
|
-
onClick={onClose}
|
|
29
|
-
onKeyDown={(e) => e.key === "Escape" && onClose()}
|
|
30
|
-
role="button"
|
|
31
|
-
tabIndex={0}
|
|
32
|
-
aria-label="Close panel"
|
|
33
|
-
/>
|
|
34
|
-
|
|
35
|
-
{/* Slide-in panel */}
|
|
36
|
-
<div
|
|
37
|
-
className={cn(
|
|
38
|
-
"fixed top-0 right-0 h-full w-full sm:w-[400px] sm:max-w-[90vw] bg-canvas border-l border-line shadow-xl z-50",
|
|
39
|
-
"transform transition-transform duration-200 ease-out",
|
|
40
|
-
isOpen ? "translate-x-0" : "translate-x-full",
|
|
41
|
-
)}
|
|
42
|
-
role="dialog"
|
|
43
|
-
aria-modal="true"
|
|
44
|
-
aria-labelledby="slide-panel-title"
|
|
45
|
-
>
|
|
46
|
-
{/* Panel header */}
|
|
47
|
-
<div className="flex items-center justify-between px-4 h-14 border-b border-line">
|
|
48
|
-
<h2 id="slide-panel-title" className="font-semibold">
|
|
49
|
-
{title}
|
|
50
|
-
</h2>
|
|
51
|
-
<button
|
|
52
|
-
type="button"
|
|
53
|
-
onClick={onClose}
|
|
54
|
-
className="p-1.5 rounded-md hover:bg-surface-raised transition-colors"
|
|
55
|
-
aria-label="Close"
|
|
56
|
-
>
|
|
57
|
-
<X className="size-5" />
|
|
58
|
-
</button>
|
|
59
|
-
</div>
|
|
60
|
-
|
|
61
|
-
{/* Panel content */}
|
|
62
|
-
<div className="h-[calc(100%-3.5rem)] flex flex-col">
|
|
63
|
-
<div className="flex-1 overflow-auto p-4">{children}</div>
|
|
64
|
-
{footer && (
|
|
65
|
-
<div className="flex justify-end gap-3 p-4 border-t border-line bg-canvas">
|
|
66
|
-
{footer}
|
|
67
|
-
</div>
|
|
68
|
-
)}
|
|
69
|
-
</div>
|
|
70
|
-
</div>
|
|
71
|
-
</>
|
|
72
|
-
);
|