@remit/ui 0.0.50 → 0.0.52
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/app-shell-types.ts +8 -0
- package/src/components/filter-rule-editor.stories.tsx +87 -0
- package/src/components/filter-rule-editor.tsx +172 -9
- package/src/components/filter-rule.render.test.ts +45 -6
- package/src/components/filter-rule.ts +26 -9
- package/src/components/filter-sheet.stories.tsx +16 -0
- package/src/components/label-chip.render.test.ts +45 -0
- package/src/components/label-chip.tsx +49 -0
- package/src/components/message-list-pane.tsx +17 -1
- package/src/components/message-list-state.render.test.ts +168 -7
- package/src/components/message-list-state.stories.tsx +328 -0
- package/src/components/message-list-state.tsx +136 -12
- package/src/components/message-row.render.test.ts +67 -1
- package/src/components/message-row.tsx +4 -0
- package/src/filter-presets.test.ts +25 -0
- package/src/filter-presets.ts +21 -0
- package/src/index.ts +17 -0
- package/src/lib/label-color.test.ts +25 -0
- package/src/lib/label-color.ts +46 -0
- package/src/stories-viewport-globals.test.ts +24 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { AlertCircle } from "lucide-react";
|
|
1
|
+
import { AlertCircle, Loader2 } from "lucide-react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
2
3
|
import { Button } from "./button.js";
|
|
3
4
|
|
|
4
5
|
/** Non-ready states the flat mailbox list can be in. "ready" renders rows. */
|
|
@@ -34,25 +35,148 @@ export function MessageListLoading() {
|
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
38
|
+
* How much of the collection the filter was applied to.
|
|
39
|
+
*
|
|
40
|
+
* `whole-folder` is a SQL `where` over the mailbox — an empty result means the
|
|
41
|
+
* folder holds no matching mail. `loaded-pages` is a criterion evaluated over
|
|
42
|
+
* the rows fetched so far, which D19-S3 keeps alive for the off-row criteria
|
|
43
|
+
* (`senderTrust`, `dkimMismatch`) that page with a continuation token: an empty
|
|
44
|
+
* result there means nothing matched yet, not that nothing matches.
|
|
45
|
+
*
|
|
46
|
+
* A value rather than a `complete` flag, so the day a third reach exists the
|
|
47
|
+
* copy is a case to add and not a boolean to reinterpret.
|
|
40
48
|
*/
|
|
41
|
-
export
|
|
42
|
-
|
|
49
|
+
export type FilterReach = "whole-folder" | "loaded-pages";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The active category filter, the way out of it, and how far it reached. All
|
|
53
|
+
* three together: the label cannot arrive without its escape, and the reach
|
|
54
|
+
* cannot be left to a default, because it decides which completeness sentence
|
|
55
|
+
* is true.
|
|
56
|
+
*/
|
|
57
|
+
export interface MessageListFilter {
|
|
58
|
+
/** Display label of the active category, e.g. "Personal". */
|
|
59
|
+
label: string;
|
|
60
|
+
reach: FilterReach;
|
|
61
|
+
onClear: () => void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface MessageListEmptyProps {
|
|
65
|
+
/** Absent when no category filter is active. */
|
|
66
|
+
filter?: MessageListFilter;
|
|
67
|
+
/**
|
|
68
|
+
* Name of the collection being listed, e.g. "Inbox". Absent for a plain
|
|
69
|
+
* mailbox, which keeps the generic copy. Cross-account collections that are
|
|
70
|
+
* not mailboxes (Flagged) name themselves here rather than being called one.
|
|
71
|
+
*/
|
|
72
|
+
scopeLabel?: string;
|
|
73
|
+
searchQuery?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Empty list state. Unfiltered copy mirrors the live MessageList EmptyState: a
|
|
78
|
+
* plain "No messages…" line, switching to the search variant when a query is
|
|
79
|
+
* active.
|
|
80
|
+
*
|
|
81
|
+
* Under a filter it says how much was read (design D19). An empty list looks
|
|
82
|
+
* identical whether it is correct or broken, which is how #315's bug survived
|
|
83
|
+
* a mailbox holding thousands of matching messages. The sentence comes from the
|
|
84
|
+
* filter's own reach, so a filtered empty state always carries one and never
|
|
85
|
+
* claims more than the query did.
|
|
86
|
+
*/
|
|
87
|
+
export function MessageListEmpty({
|
|
88
|
+
filter,
|
|
89
|
+
scopeLabel,
|
|
90
|
+
searchQuery,
|
|
91
|
+
}: MessageListEmptyProps) {
|
|
92
|
+
const query = searchQuery?.trim();
|
|
93
|
+
|
|
94
|
+
if (!filter) {
|
|
95
|
+
return (
|
|
96
|
+
<EmptyFrame>
|
|
97
|
+
<p className="text-fg-muted">{unfilteredCopy(query, scopeLabel)}</p>
|
|
98
|
+
</EmptyFrame>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<EmptyFrame>
|
|
104
|
+
<p className="font-medium text-fg">
|
|
105
|
+
{filteredHeadline(filter.label, scopeLabel, query)}
|
|
106
|
+
</p>
|
|
107
|
+
<p className="mt-1 text-sm text-fg-muted">
|
|
108
|
+
{COMPLETENESS_COPY[filter.reach]}
|
|
109
|
+
</p>
|
|
110
|
+
<Button
|
|
111
|
+
variant="secondary"
|
|
112
|
+
size="sm"
|
|
113
|
+
className="mt-4"
|
|
114
|
+
onClick={filter.onClear}
|
|
115
|
+
>
|
|
116
|
+
Clear filter
|
|
117
|
+
</Button>
|
|
118
|
+
</EmptyFrame>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The one sentence a filtered empty list always carries. `loaded-pages` states
|
|
124
|
+
* what is true of a bounded read and nothing more; the wording it should settle
|
|
125
|
+
* on belongs with whoever moves the off-row criteria on-row (D7).
|
|
126
|
+
*/
|
|
127
|
+
const COMPLETENESS_COPY: Record<FilterReach, string> = {
|
|
128
|
+
"whole-folder": "Every message in this folder was checked.",
|
|
129
|
+
"loaded-pages": "Only the messages loaded so far were checked.",
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
function unfilteredCopy(
|
|
133
|
+
query: string | undefined,
|
|
134
|
+
scopeLabel: string | undefined,
|
|
135
|
+
): string {
|
|
136
|
+
if (query) return "No messages match your search";
|
|
137
|
+
if (scopeLabel) return `No messages in ${scopeLabel}`;
|
|
138
|
+
return "No messages in this mailbox";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** One string, not JSX, so the copy stays a single text node and reads whole. */
|
|
142
|
+
function filteredHeadline(
|
|
143
|
+
filterLabel: string,
|
|
144
|
+
scopeLabel: string | undefined,
|
|
145
|
+
query: string | undefined,
|
|
146
|
+
): string {
|
|
147
|
+
if (query) return `No results for “${query}” in ${filterLabel}`;
|
|
148
|
+
if (scopeLabel) return `No ${filterLabel} mail in ${scopeLabel}`;
|
|
149
|
+
return `No ${filterLabel} mail`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function EmptyFrame({ children }: { children: ReactNode }) {
|
|
43
153
|
return (
|
|
44
154
|
<div className="flex h-full flex-1 items-center justify-center">
|
|
45
|
-
<div className="flex flex-col items-center justify-center p-8 text-center">
|
|
46
|
-
|
|
47
|
-
{isSearching
|
|
48
|
-
? "No messages match your search"
|
|
49
|
-
: "No messages in this mailbox"}
|
|
50
|
-
</p>
|
|
155
|
+
<div className="flex max-w-sm flex-col items-center justify-center p-8 text-center">
|
|
156
|
+
{children}
|
|
51
157
|
</div>
|
|
52
158
|
</div>
|
|
53
159
|
);
|
|
54
160
|
}
|
|
55
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Another page in flight below rows already rendered. Says so in words: a bare
|
|
164
|
+
* spinner (`MessageList.tsx:1429-1433`) is one of the ways "not fetched yet"
|
|
165
|
+
* reads as "nothing there".
|
|
166
|
+
*/
|
|
167
|
+
export function MessageListLoadingMore() {
|
|
168
|
+
return (
|
|
169
|
+
<div
|
|
170
|
+
className="flex items-center justify-center gap-2 py-4 text-sm text-fg-muted"
|
|
171
|
+
role="status"
|
|
172
|
+
aria-live="polite"
|
|
173
|
+
>
|
|
174
|
+
<Loader2 className="size-4 animate-spin" aria-hidden="true" />
|
|
175
|
+
<span>Loading more…</span>
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
56
180
|
/**
|
|
57
181
|
* Fail-hard list error (ux.md): a centered, blocking message that stops the
|
|
58
182
|
* list — never a toast, never a control left looking healthy. States plainly
|
|
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
3
|
import { createElement } from "react";
|
|
4
4
|
import { renderToString } from "react-dom/server";
|
|
5
|
-
import type
|
|
5
|
+
import { categoryTone, type ThreadRowData } from "./app-shell-types.js";
|
|
6
6
|
import { ComfortableRow, CompactRow } from "./message-row.js";
|
|
7
7
|
|
|
8
8
|
const base: ThreadRowData = {
|
|
@@ -35,6 +35,72 @@ describe("ComfortableRow", () => {
|
|
|
35
35
|
assert.notEqual(unread, read);
|
|
36
36
|
assert.match(unread, /font-semibold/);
|
|
37
37
|
});
|
|
38
|
+
|
|
39
|
+
it("renders a chip for every label applied to the message (issue #26)", () => {
|
|
40
|
+
const html = renderToString(
|
|
41
|
+
createElement(ComfortableRow, {
|
|
42
|
+
thread: {
|
|
43
|
+
...base,
|
|
44
|
+
isRead: true,
|
|
45
|
+
labels: [
|
|
46
|
+
{ labelId: "l1", name: "Receipts", color: "Blue" },
|
|
47
|
+
{ labelId: "l2", name: "Travel", color: "Green" },
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
assert.match(html, /Receipts/);
|
|
53
|
+
assert.match(html, /Travel/);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("renders no label chip when the message carries none", () => {
|
|
57
|
+
const html = renderToString(
|
|
58
|
+
createElement(ComfortableRow, { thread: { ...base, isRead: true } }),
|
|
59
|
+
);
|
|
60
|
+
assert.doesNotMatch(html, /Remove label/);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("ComfortableRow category presentation", () => {
|
|
65
|
+
const render = (category: ThreadRowData["category"]): string =>
|
|
66
|
+
renderToString(
|
|
67
|
+
createElement(ComfortableRow, {
|
|
68
|
+
thread: { ...base, isRead: true, category },
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
/** The category Badge's own class signature, so no other span matches. */
|
|
73
|
+
const badgeClass = (html: string): string | undefined =>
|
|
74
|
+
html.match(
|
|
75
|
+
/class="([^"]*rounded-full px-2 py-0\.5 text-2xs font-medium[^"]*)"/,
|
|
76
|
+
)?.[1];
|
|
77
|
+
|
|
78
|
+
it("renders no category badge for personal", () => {
|
|
79
|
+
assert.equal(badgeClass(render("personal")), undefined);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("renders uncategorized as neither personal nor nothing (#45)", () => {
|
|
83
|
+
const uncategorized = render("uncategorized");
|
|
84
|
+
assert.notEqual(uncategorized, render("personal"));
|
|
85
|
+
assert.ok(
|
|
86
|
+
badgeClass(uncategorized),
|
|
87
|
+
"a message the classifier has not reached carries its own badge",
|
|
88
|
+
);
|
|
89
|
+
assert.match(uncategorized, /uncategorized|unclassified/i);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("keeps the uncategorized badge off personal's tone (#45)", () => {
|
|
93
|
+
assert.notEqual(
|
|
94
|
+
categoryTone.uncategorized,
|
|
95
|
+
categoryTone.personal,
|
|
96
|
+
"the tone table must not give unclassified mail personal's colour",
|
|
97
|
+
);
|
|
98
|
+
assert.doesNotMatch(
|
|
99
|
+
badgeClass(render("uncategorized")) ?? "",
|
|
100
|
+
/accent/,
|
|
101
|
+
"the row badge must not render personal's accent tone",
|
|
102
|
+
);
|
|
103
|
+
});
|
|
38
104
|
});
|
|
39
105
|
|
|
40
106
|
describe("ComfortableRow selection slot", () => {
|
|
@@ -5,6 +5,7 @@ import { LIST_ROW_ATTRIBUTE } from "../lib/roving-focus.js";
|
|
|
5
5
|
import { categoryTone, type ThreadRowData } from "./app-shell-types.js";
|
|
6
6
|
import { Avatar } from "./avatar.js";
|
|
7
7
|
import { Badge } from "./badge.js";
|
|
8
|
+
import { LabelChip } from "./label-chip.js";
|
|
8
9
|
|
|
9
10
|
/** Visible keyboard-focus ring for a row reached by the list's arrow-key cursor. */
|
|
10
11
|
const ROW_FOCUS_RING =
|
|
@@ -166,6 +167,9 @@ export function ComfortableRowTextContent({
|
|
|
166
167
|
{thread.category}
|
|
167
168
|
</Badge>
|
|
168
169
|
)}
|
|
170
|
+
{thread.labels?.map((label) => (
|
|
171
|
+
<LabelChip key={label.labelId} label={label} className="max-w-20" />
|
|
172
|
+
))}
|
|
169
173
|
{badge}
|
|
170
174
|
</span>
|
|
171
175
|
</span>
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
type FilterAccount,
|
|
6
6
|
flaggedFilterConfig,
|
|
7
7
|
inboxFilterConfig,
|
|
8
|
+
UNCLASSIFIED_CATEGORY,
|
|
8
9
|
} from "./filter-presets.js";
|
|
9
10
|
|
|
10
11
|
const accounts: FilterAccount[] = [
|
|
@@ -98,3 +99,27 @@ describe("flaggedFilterConfig", () => {
|
|
|
98
99
|
assert.equal(flaggedFilterConfig().sources, undefined);
|
|
99
100
|
});
|
|
100
101
|
});
|
|
102
|
+
|
|
103
|
+
describe("UNCLASSIFIED_CATEGORY", () => {
|
|
104
|
+
it("is held out of every shipped preset until the server filters it", () => {
|
|
105
|
+
for (const preset of [
|
|
106
|
+
briefFilterConfig(),
|
|
107
|
+
inboxFilterConfig(),
|
|
108
|
+
flaggedFilterConfig(),
|
|
109
|
+
]) {
|
|
110
|
+
assert.equal(
|
|
111
|
+
preset.categories.some((c) => c.id === UNCLASSIFIED_CATEGORY.id),
|
|
112
|
+
false,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("carries its own label and tone, never personal's (#45)", () => {
|
|
118
|
+
const personal = briefFilterConfig().categories.find(
|
|
119
|
+
(c) => c.id === "personal",
|
|
120
|
+
);
|
|
121
|
+
assert.equal(UNCLASSIFIED_CATEGORY.label, "Unclassified");
|
|
122
|
+
assert.notEqual(UNCLASSIFIED_CATEGORY.label, personal?.label);
|
|
123
|
+
assert.notEqual(UNCLASSIFIED_CATEGORY.tone, personal?.tone);
|
|
124
|
+
});
|
|
125
|
+
});
|
package/src/filter-presets.ts
CHANGED
|
@@ -27,10 +27,31 @@ export interface FilterPreset {
|
|
|
27
27
|
sources?: FilterSheetSource[];
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* The Unclassified chip D6 requires, built and storied but not offered yet.
|
|
32
|
+
*
|
|
33
|
+
* This module is a runtime dependency of three live surfaces that filter over
|
|
34
|
+
* the loaded window, so a chip in the list below is a chip a user can click
|
|
35
|
+
* today. Offering it before #306 makes the predicate a server-side `where`
|
|
36
|
+
* would add a fresh instance of the bug this epic exists to fix: a category
|
|
37
|
+
* whose mail sits below the newest page reads as a category with no mail.
|
|
38
|
+
*
|
|
39
|
+
* #306 splices this entry into `MESSAGE_CATEGORIES` after `Personal` — matching
|
|
40
|
+
* `briefCategories`' order — and deletes this constant.
|
|
41
|
+
*/
|
|
42
|
+
export const UNCLASSIFIED_CATEGORY: FilterSheetCategory = {
|
|
43
|
+
id: "uncategorized",
|
|
44
|
+
label: "Unclassified",
|
|
45
|
+
tone: "neutral",
|
|
46
|
+
};
|
|
47
|
+
|
|
30
48
|
/**
|
|
31
49
|
* Content-type categories, mirroring the `MessageCategory` enum
|
|
32
50
|
* (@remit/remit-imap) by value. The leading "all" clears the category. Per
|
|
33
51
|
* message, not per mailbox — so they apply in the brief and an inbox alike.
|
|
52
|
+
*
|
|
53
|
+
* `uncategorized` is absent on purpose and is not folded into `personal`
|
|
54
|
+
* (issue #45) — see `UNCLASSIFIED_CATEGORY`.
|
|
34
55
|
*/
|
|
35
56
|
const MESSAGE_CATEGORIES: FilterSheetCategory[] = [
|
|
36
57
|
{ id: "all", label: "All", tone: "neutral" },
|
package/src/index.ts
CHANGED
|
@@ -144,11 +144,13 @@ export {
|
|
|
144
144
|
commitBlockedReason,
|
|
145
145
|
commitLabel,
|
|
146
146
|
demoFolders,
|
|
147
|
+
demoLabels,
|
|
147
148
|
demoRule,
|
|
148
149
|
demoSenderFallbackRule,
|
|
149
150
|
demoVocabularyRule,
|
|
150
151
|
type FilterRule,
|
|
151
152
|
type FolderOption,
|
|
153
|
+
type LabelOption,
|
|
152
154
|
type MatchOperator,
|
|
153
155
|
matchJoinWord,
|
|
154
156
|
matchOperatorLabel,
|
|
@@ -221,6 +223,11 @@ export {
|
|
|
221
223
|
KeyboardHintBar,
|
|
222
224
|
type KeyboardHintBarProps,
|
|
223
225
|
} from "./components/keyboard-hint-bar.js";
|
|
226
|
+
export {
|
|
227
|
+
LabelChip,
|
|
228
|
+
type LabelChipData,
|
|
229
|
+
type LabelChipProps,
|
|
230
|
+
} from "./components/label-chip.js";
|
|
224
231
|
export { ListItem, type ListItemProps } from "./components/list-item.js";
|
|
225
232
|
export {
|
|
226
233
|
type MailAction,
|
|
@@ -244,8 +251,11 @@ export { MessageListPane } from "./components/message-list-pane.js";
|
|
|
244
251
|
export {
|
|
245
252
|
type ListState,
|
|
246
253
|
MessageListEmpty,
|
|
254
|
+
type MessageListEmptyProps,
|
|
247
255
|
MessageListError,
|
|
256
|
+
type MessageListFilter,
|
|
248
257
|
MessageListLoading,
|
|
258
|
+
MessageListLoadingMore,
|
|
249
259
|
} from "./components/message-list-state.js";
|
|
250
260
|
export {
|
|
251
261
|
type BriefRowComponent,
|
|
@@ -535,6 +545,7 @@ export {
|
|
|
535
545
|
type FilterPreset,
|
|
536
546
|
flaggedFilterConfig,
|
|
537
547
|
inboxFilterConfig,
|
|
548
|
+
UNCLASSIFIED_CATEGORY,
|
|
538
549
|
} from "./filter-presets.js";
|
|
539
550
|
export {
|
|
540
551
|
buildCidResolver,
|
|
@@ -555,6 +566,12 @@ export {
|
|
|
555
566
|
sanitizeInlineStyle,
|
|
556
567
|
sanitizeStyleElementCss,
|
|
557
568
|
} from "./lib/email-sanitizer.js";
|
|
569
|
+
export {
|
|
570
|
+
isLabelColorValue,
|
|
571
|
+
type LabelColorValue,
|
|
572
|
+
labelColorOptions,
|
|
573
|
+
labelDotClass,
|
|
574
|
+
} from "./lib/label-color.js";
|
|
558
575
|
export {
|
|
559
576
|
LIST_ROW_ATTRIBUTE,
|
|
560
577
|
LIST_ROW_SELECTOR,
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
isLabelColorValue,
|
|
5
|
+
labelColorOptions,
|
|
6
|
+
labelDotClass,
|
|
7
|
+
} from "./label-color.js";
|
|
8
|
+
|
|
9
|
+
describe("label-color", () => {
|
|
10
|
+
it("every listed option resolves to a dot class", () => {
|
|
11
|
+
for (const color of labelColorOptions) {
|
|
12
|
+
assert.ok(labelDotClass[color]);
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("recognizes every named color value", () => {
|
|
17
|
+
for (const color of labelColorOptions) {
|
|
18
|
+
assert.ok(isLabelColorValue(color));
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("rejects a value outside the named set", () => {
|
|
23
|
+
assert.equal(isLabelColorValue("Chartreuse"), false);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A user-picked label color (RFC 030, issue #26). Unlike the app's status
|
|
3
|
+
* tones (positive/warning/danger/…), a label's color carries no meaning the
|
|
4
|
+
* design system assigns — the user picked it to tell their own labels apart —
|
|
5
|
+
* so it renders from the literal Tailwind palette rather than a semantic
|
|
6
|
+
* token.
|
|
7
|
+
*/
|
|
8
|
+
export type LabelColorValue =
|
|
9
|
+
| "Default"
|
|
10
|
+
| "Red"
|
|
11
|
+
| "Orange"
|
|
12
|
+
| "Yellow"
|
|
13
|
+
| "Green"
|
|
14
|
+
| "Teal"
|
|
15
|
+
| "Blue"
|
|
16
|
+
| "Purple"
|
|
17
|
+
| "Gray";
|
|
18
|
+
|
|
19
|
+
/** Solid dot color for a label swatch or chip. */
|
|
20
|
+
export const labelDotClass: Record<LabelColorValue, string> = {
|
|
21
|
+
Default: "bg-fg-subtle",
|
|
22
|
+
Red: "bg-red-500",
|
|
23
|
+
Orange: "bg-orange-500",
|
|
24
|
+
Yellow: "bg-yellow-500",
|
|
25
|
+
Green: "bg-green-500",
|
|
26
|
+
Teal: "bg-teal-500",
|
|
27
|
+
Blue: "bg-blue-500",
|
|
28
|
+
Purple: "bg-purple-500",
|
|
29
|
+
Gray: "bg-gray-500",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Every color a label can be assigned, in picker order. */
|
|
33
|
+
export const labelColorOptions: LabelColorValue[] = [
|
|
34
|
+
"Default",
|
|
35
|
+
"Red",
|
|
36
|
+
"Orange",
|
|
37
|
+
"Yellow",
|
|
38
|
+
"Green",
|
|
39
|
+
"Teal",
|
|
40
|
+
"Blue",
|
|
41
|
+
"Purple",
|
|
42
|
+
"Gray",
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
export const isLabelColorValue = (value: string): value is LabelColorValue =>
|
|
46
|
+
Object.hasOwn(labelDotClass, value);
|
|
@@ -51,3 +51,27 @@ describe("story files use the viewport globals pattern (#68)", () => {
|
|
|
51
51
|
assert.deepEqual(offenders, []);
|
|
52
52
|
});
|
|
53
53
|
});
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Storybook composes decorators story → component → global, so a story-level
|
|
57
|
+
* `decorators: []` adds nothing. It reads as "this story opts out of the meta's
|
|
58
|
+
* frame" and silently does the opposite, which clipped a story down to half its
|
|
59
|
+
* content. A story that needs a different frame carries the frame itself.
|
|
60
|
+
*/
|
|
61
|
+
describe("story files never claim to shed a meta decorator", () => {
|
|
62
|
+
const roots = [
|
|
63
|
+
here,
|
|
64
|
+
resolve(here, "../../workbench/src"),
|
|
65
|
+
resolve(here, "../../web-client/src"),
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
it("never uses an empty story-level decorators array", () => {
|
|
69
|
+
const offenders = roots
|
|
70
|
+
.flatMap((root) => storyFiles(root))
|
|
71
|
+
.filter((file) =>
|
|
72
|
+
/decorators:\s*\[\s*\]/.test(readFileSync(file, "utf8")),
|
|
73
|
+
)
|
|
74
|
+
.map((file) => relative(here, file));
|
|
75
|
+
assert.deepEqual(offenders, []);
|
|
76
|
+
});
|
|
77
|
+
});
|