@remit/ui 0.0.41 → 0.0.42

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.41",
3
+ "version": "0.0.42",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -0,0 +1,211 @@
1
+ import { Plus, Sparkles, X } from "lucide-react";
2
+ import { cn } from "../lib/cn.js";
3
+ import { Button } from "./button.js";
4
+ import {
5
+ type ClauseField,
6
+ clauseFieldLabel,
7
+ clauseFieldOrder,
8
+ type RuleClause,
9
+ type RuleWiden,
10
+ widenChipLabel,
11
+ } from "./filter-rule.js";
12
+ import { Input } from "./input.js";
13
+ import { Select } from "./select.js";
14
+
15
+ const chipShell =
16
+ "inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs";
17
+
18
+ export interface ClauseChipProps {
19
+ clause: RuleClause;
20
+ onEdit?: () => void;
21
+ onRemove?: () => void;
22
+ }
23
+
24
+ export function ClauseChip({ clause, onEdit, onRemove }: ClauseChipProps) {
25
+ return (
26
+ <span
27
+ className={cn(
28
+ chipShell,
29
+ clause.derived
30
+ ? "border-dashed border-line-strong bg-surface-sunken"
31
+ : "border-line bg-surface",
32
+ )}
33
+ >
34
+ <button
35
+ type="button"
36
+ onClick={onEdit}
37
+ aria-label={`Edit ${clauseFieldLabel(clause.field)} clause`}
38
+ className="flex items-center gap-1 text-left"
39
+ >
40
+ <span className="font-medium text-fg-muted">
41
+ {clauseFieldLabel(clause.field)}
42
+ </span>
43
+ <span className="text-fg">{clause.value}</span>
44
+ </button>
45
+ {clause.derived && (
46
+ <span className="text-2xs text-fg-subtle">from sender</span>
47
+ )}
48
+ {onRemove && (
49
+ <button
50
+ type="button"
51
+ onClick={onRemove}
52
+ aria-label={`Remove ${clauseFieldLabel(clause.field)} clause`}
53
+ className="flex size-4 items-center justify-center rounded-full text-fg-subtle hover:bg-surface-sunken hover:text-fg-muted"
54
+ >
55
+ <X className="size-3" />
56
+ </button>
57
+ )}
58
+ </span>
59
+ );
60
+ }
61
+
62
+ export interface WidenChipProps {
63
+ widen: RuleWiden;
64
+ onRemove?: () => void;
65
+ }
66
+
67
+ function WidenRemoveButton({ onRemove }: { onRemove: () => void }) {
68
+ return (
69
+ <button
70
+ type="button"
71
+ onClick={onRemove}
72
+ aria-label="Remove the similar-mail widen"
73
+ className="flex size-4 items-center justify-center rounded-full hover:bg-fg-subtle/20"
74
+ >
75
+ <X className="size-3" />
76
+ </button>
77
+ );
78
+ }
79
+
80
+ /**
81
+ * The semantic widen as one chip (RFC 038 D3). Active it reads "…and anything
82
+ * similar" with the anchor count; inactive it says the deployment cannot
83
+ * evaluate it and the rule matches by its literal clauses only (D4). Either
84
+ * way it is removable like any other chip — a dead anchor is exactly what a
85
+ * user needs to be able to take off a degraded rule.
86
+ */
87
+ export function WidenChip({ widen, onRemove }: WidenChipProps) {
88
+ if (widen.inactive) {
89
+ return (
90
+ <span
91
+ className={cn(
92
+ chipShell,
93
+ "border-dashed border-line-strong bg-surface-sunken text-fg-subtle",
94
+ )}
95
+ >
96
+ <Sparkles className="size-3 shrink-0" aria-hidden="true" />
97
+ <span className="line-through">{widenChipLabel(widen)}</span>
98
+ <span className="text-2xs">not available here</span>
99
+ {onRemove && <WidenRemoveButton onRemove={onRemove} />}
100
+ </span>
101
+ );
102
+ }
103
+
104
+ return (
105
+ <span
106
+ className={cn(
107
+ chipShell,
108
+ "border-accent-2 bg-accent-2-soft text-accent-2",
109
+ )}
110
+ >
111
+ <Sparkles className="size-3 shrink-0" aria-hidden="true" />
112
+ <span className="font-medium">…and anything similar</span>
113
+ <span className="text-2xs opacity-80">{widenChipLabel(widen)}</span>
114
+ {onRemove && <WidenRemoveButton onRemove={onRemove} />}
115
+ </span>
116
+ );
117
+ }
118
+
119
+ export interface AddChipButtonProps {
120
+ label: string;
121
+ onClick?: () => void;
122
+ }
123
+
124
+ export function AddChipButton({ label, onClick }: AddChipButtonProps) {
125
+ return (
126
+ <button
127
+ type="button"
128
+ onClick={onClick}
129
+ className={cn(
130
+ chipShell,
131
+ "border-dashed border-line-strong text-fg-muted hover:border-accent-2 hover:text-accent-2",
132
+ )}
133
+ >
134
+ <Plus className="size-3" aria-hidden="true" />
135
+ {label}
136
+ </button>
137
+ );
138
+ }
139
+
140
+ export interface ClauseDraft {
141
+ field: ClauseField;
142
+ value: string;
143
+ }
144
+
145
+ export interface ClauseEditorProps {
146
+ draft: ClauseDraft;
147
+ /** `add` seeds a new clause; `edit` amends an existing one. */
148
+ mode: "add" | "edit";
149
+ onChangeField?: (field: ClauseField) => void;
150
+ onChangeValue?: (value: string) => void;
151
+ onSubmit?: () => void;
152
+ onCancel?: () => void;
153
+ }
154
+
155
+ /**
156
+ * Inline form for adding or editing one clause — field picker plus a value
157
+ * input, from the design-system primitives so it never drifts from the rest of
158
+ * the editor. The chip it produces renders through {@link ClauseChip}.
159
+ */
160
+ export function ClauseEditor({
161
+ draft,
162
+ mode,
163
+ onChangeField,
164
+ onChangeValue,
165
+ onSubmit,
166
+ onCancel,
167
+ }: ClauseEditorProps) {
168
+ return (
169
+ <div className="flex flex-wrap items-center gap-2 rounded-lg border border-accent-2 bg-surface p-2">
170
+ <Select
171
+ aria-label="Clause field"
172
+ value={draft.field}
173
+ onChange={(e) => onChangeField?.(e.target.value as ClauseField)}
174
+ className="h-8 w-32 shrink-0"
175
+ >
176
+ {clauseFieldOrder.map((field) => (
177
+ <option key={field} value={field}>
178
+ {clauseFieldLabel(field)}
179
+ </option>
180
+ ))}
181
+ </Select>
182
+ <Input
183
+ aria-label="Clause value"
184
+ value={draft.value}
185
+ placeholder="value…"
186
+ onChange={(e) => onChangeValue?.(e.target.value)}
187
+ onKeyDown={(e) => {
188
+ if (e.key === "Enter") onSubmit?.();
189
+ }}
190
+ className="h-8 min-w-40 flex-1"
191
+ />
192
+ <Button
193
+ variant="primary"
194
+ size="sm"
195
+ onClick={onSubmit}
196
+ disabled={draft.value.trim() === ""}
197
+ className="shrink-0"
198
+ >
199
+ {mode === "add" ? "Add" : "Save"}
200
+ </Button>
201
+ <Button
202
+ variant="ghost"
203
+ size="sm"
204
+ onClick={onCancel}
205
+ aria-label="Cancel clause edit"
206
+ icon={<X className="size-4" />}
207
+ className="shrink-0 px-2"
208
+ />
209
+ </div>
210
+ );
211
+ }
@@ -0,0 +1,57 @@
1
+ import { AlertTriangle, Loader2 } from "lucide-react";
2
+ import { cn } from "../lib/cn.js";
3
+ import { type PreviewCount, previewCountSummary } from "./filter-rule.js";
4
+
5
+ export interface FilterPreviewCountProps {
6
+ preview: PreviewCount;
7
+ }
8
+
9
+ /**
10
+ * The live match count (RFC 038 D1). It never goes blank between counts: a
11
+ * clause change flips the last count to `stale` and says it is recounting
12
+ * rather than dropping to zero and reading as "nothing matches".
13
+ */
14
+ export function FilterPreviewCount({ preview }: FilterPreviewCountProps) {
15
+ const summary = previewCountSummary(preview);
16
+
17
+ if (preview.status === "loading") {
18
+ return (
19
+ <p
20
+ className="flex items-center gap-2 text-xs text-fg-muted"
21
+ aria-live="polite"
22
+ >
23
+ <Loader2 className="size-3.5 animate-spin" aria-hidden="true" />
24
+ {summary}
25
+ </p>
26
+ );
27
+ }
28
+
29
+ if (preview.status === "error") {
30
+ return (
31
+ <p className="flex items-center gap-2 text-xs text-danger" role="alert">
32
+ <AlertTriangle className="size-3.5 shrink-0" aria-hidden="true" />
33
+ {summary}
34
+ </p>
35
+ );
36
+ }
37
+
38
+ const empty = preview.count === 0;
39
+ return (
40
+ <p
41
+ className={cn(
42
+ "flex items-center gap-2 text-xs",
43
+ preview.stale
44
+ ? "text-fg-subtle"
45
+ : empty
46
+ ? "text-fg-muted"
47
+ : "font-medium text-fg",
48
+ )}
49
+ aria-live="polite"
50
+ >
51
+ {preview.stale && (
52
+ <Loader2 className="size-3.5 animate-spin" aria-hidden="true" />
53
+ )}
54
+ {summary}
55
+ </p>
56
+ );
57
+ }
@@ -0,0 +1,336 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { useMemo, useState } from "react";
3
+ import { ClauseChip, ClauseEditor, WidenChip } from "./filter-clause-chip.js";
4
+ import { FilterPreviewCount } from "./filter-preview-count.js";
5
+ import {
6
+ type ClauseField,
7
+ clauseFieldLabel,
8
+ demoFolders,
9
+ demoRule,
10
+ demoSenderFallbackRule,
11
+ demoVocabularyRule,
12
+ type FilterRule,
13
+ type PreviewCount,
14
+ type RuleClause,
15
+ } from "./filter-rule.js";
16
+ import {
17
+ type ClauseEditState,
18
+ FilterRuleEditor,
19
+ type FilterRuleEditorProps,
20
+ } from "./filter-rule-editor.js";
21
+
22
+ const meta: Meta<typeof FilterRuleEditor> = {
23
+ title: "FilterRuleEditor",
24
+ component: FilterRuleEditor,
25
+ parameters: { layout: "padded" },
26
+ decorators: [
27
+ (Story) => (
28
+ <div className="mx-auto max-w-md rounded-xl border border-line bg-surface">
29
+ <Story />
30
+ </div>
31
+ ),
32
+ ],
33
+ };
34
+ export default meta;
35
+
36
+ type Story = StoryObj<typeof FilterRuleEditor>;
37
+
38
+ const READY = (count: number, stale?: boolean): PreviewCount => ({
39
+ status: "ready",
40
+ count,
41
+ stale,
42
+ });
43
+
44
+ /**
45
+ * A fully wired editor so every affordance is live in the story — add, edit and
46
+ * remove clauses, toggle the operator and scope, watch the count follow. The
47
+ * count settles instantly here so the commit stays usable; the loading and
48
+ * recounting states have their own stories. State is local; no app, no network.
49
+ */
50
+ function LiveEditor({
51
+ initialRule,
52
+ semanticAvailable = true,
53
+ }: {
54
+ initialRule: FilterRule;
55
+ semanticAvailable?: boolean;
56
+ }) {
57
+ const [rule, setRule] = useState<FilterRule>(initialRule);
58
+ const [clauseEdit, setClauseEdit] = useState<ClauseEditState | undefined>();
59
+
60
+ const preview = useMemo<PreviewCount>(
61
+ () => READY(rule.clauses.length * 23 + (rule.widen ? 40 : 0)),
62
+ [rule],
63
+ );
64
+
65
+ const handlers: Partial<FilterRuleEditorProps> = {
66
+ onStartAddClause: () =>
67
+ setClauseEdit({ mode: "add", draft: { field: "From", value: "" } }),
68
+ onStartEditClause: (clauseId) => {
69
+ const clause = rule.clauses.find((c) => c.id === clauseId);
70
+ if (!clause) return;
71
+ setClauseEdit({
72
+ mode: "edit",
73
+ clauseId,
74
+ draft: { field: clause.field, value: clause.value },
75
+ });
76
+ },
77
+ onRemoveClause: (clauseId) => {
78
+ setRule((r) => ({
79
+ ...r,
80
+ clauses: r.clauses.filter((c) => c.id !== clauseId),
81
+ }));
82
+ },
83
+ onChangeDraftField: (field) =>
84
+ setClauseEdit((e) => (e ? { ...e, draft: { ...e.draft, field } } : e)),
85
+ onChangeDraftValue: (value) =>
86
+ setClauseEdit((e) => (e ? { ...e, draft: { ...e.draft, value } } : e)),
87
+ onSubmitClause: () => {
88
+ setClauseEdit((edit) => {
89
+ if (!edit) return undefined;
90
+ setRule((r) => {
91
+ if (edit.mode === "add") {
92
+ const clause: RuleClause = {
93
+ id: `c-${Date.now()}`,
94
+ field: edit.draft.field,
95
+ value: edit.draft.value,
96
+ };
97
+ return { ...r, clauses: [...r.clauses, clause] };
98
+ }
99
+ return {
100
+ ...r,
101
+ clauses: r.clauses.map((c) =>
102
+ c.id === edit.clauseId
103
+ ? { ...c, field: edit.draft.field, value: edit.draft.value }
104
+ : c,
105
+ ),
106
+ };
107
+ });
108
+ return undefined;
109
+ });
110
+ },
111
+ onCancelClause: () => setClauseEdit(undefined),
112
+ onAddWiden: () => {
113
+ setRule((r) => ({ ...r, widen: { anchorCount: 2 } }));
114
+ },
115
+ onRemoveWiden: () => {
116
+ setRule((r) => ({ ...r, widen: undefined }));
117
+ },
118
+ onChangeMatchOperator: (matchOperator) => {
119
+ setRule((r) => ({ ...r, matchOperator }));
120
+ },
121
+ onChangeMove: (moveMailboxId) =>
122
+ setRule((r) => ({ ...r, moveMailboxId: moveMailboxId || undefined })),
123
+ onChangeScope: (scope) => setRule((r) => ({ ...r, scope })),
124
+ onChangeName: (name) => setRule((r) => ({ ...r, name })),
125
+ onChangeUntil: (until) => setRule((r) => ({ ...r, until })),
126
+ };
127
+
128
+ return (
129
+ <FilterRuleEditor
130
+ rule={rule}
131
+ folders={demoFolders}
132
+ preview={preview}
133
+ semanticAvailable={semanticAvailable}
134
+ clauseEdit={clauseEdit}
135
+ onCommit={() => {}}
136
+ onCancel={() => {}}
137
+ {...handlers}
138
+ />
139
+ );
140
+ }
141
+
142
+ /** The full editor, interactive — the shape ticket B and the app consume. */
143
+ export const Interactive: Story = {
144
+ render: () => <LiveEditor initialRule={demoRule} />,
145
+ };
146
+
147
+ /** Literal clauses joined with "or", including the ticket-B ListId and FromDomain fields. */
148
+ export const AnyOfTheseClauses: Story = {
149
+ render: () => <LiveEditor initialRule={demoVocabularyRule} />,
150
+ };
151
+
152
+ /** A one-time rule — no name, no widen, the commit reads "Apply now". */
153
+ export const OneTimeMove: Story = {
154
+ args: {
155
+ rule: {
156
+ clauses: [{ id: "c1", field: "Subject", value: "receipt" }],
157
+ matchOperator: "all",
158
+ moveMailboxId: "mbx-receipts",
159
+ scope: "once",
160
+ },
161
+ folders: demoFolders,
162
+ preview: READY(12),
163
+ },
164
+ };
165
+
166
+ /** Standing scope with a widen — the "keep doing this" rule. */
167
+ export const StandingWithWiden: Story = {
168
+ args: {
169
+ rule: demoRule,
170
+ folders: demoFolders,
171
+ preview: READY(47),
172
+ semanticAvailable: true,
173
+ },
174
+ };
175
+
176
+ /** Timed scope — the name and a date the rule stops on. */
177
+ export const UntilADate: Story = {
178
+ args: {
179
+ rule: {
180
+ ...demoRule,
181
+ scope: "until",
182
+ until: "2026-09-01",
183
+ name: "Conference",
184
+ },
185
+ folders: demoFolders,
186
+ preview: READY(31),
187
+ semanticAvailable: true,
188
+ },
189
+ };
190
+
191
+ /**
192
+ * The sender-fallback (#251) as chips: the derived From clauses are ordinary
193
+ * visible, editable chips, not an invisible substitution.
194
+ */
195
+ export const SenderFallbackChips: Story = {
196
+ render: () => (
197
+ <LiveEditor
198
+ initialRule={demoSenderFallbackRule}
199
+ semanticAvailable={false}
200
+ />
201
+ ),
202
+ };
203
+
204
+ /**
205
+ * The deployment cannot serve the widen (RFC 038 D4): the "…and similar" add is
206
+ * not offered and the rule matches by its literal clauses only.
207
+ */
208
+ export const SemanticUnavailable: Story = {
209
+ args: {
210
+ rule: { ...demoRule, widen: undefined },
211
+ folders: demoFolders,
212
+ preview: READY(24),
213
+ semanticAvailable: false,
214
+ },
215
+ };
216
+
217
+ /**
218
+ * A standing rule that carries an anchor this deployment cannot evaluate — the
219
+ * widen chip lists as inactive and nothing claims similarity is running.
220
+ */
221
+ export const DegradedStandingWiden: Story = {
222
+ args: {
223
+ rule: { ...demoRule, widen: { anchorCount: 2, inactive: true } },
224
+ folders: demoFolders,
225
+ preview: READY(19),
226
+ semanticAvailable: false,
227
+ },
228
+ };
229
+
230
+ /** Nothing to match yet — the commit says why it is blocked. */
231
+ export const BlockedEmpty: Story = {
232
+ args: {
233
+ rule: {
234
+ clauses: [],
235
+ matchOperator: "all",
236
+ scope: "once",
237
+ },
238
+ folders: demoFolders,
239
+ preview: READY(0),
240
+ semanticAvailable: true,
241
+ },
242
+ };
243
+
244
+ /** The live count while it recomputes — the commit waits for it to settle. */
245
+ export const PreviewLoading: Story = {
246
+ args: {
247
+ rule: demoRule,
248
+ folders: demoFolders,
249
+ preview: { status: "loading" },
250
+ semanticAvailable: true,
251
+ },
252
+ };
253
+
254
+ /**
255
+ * The previewed set changed under the rule — recounting, never blank, and the
256
+ * "Save rule" button is held disabled until the count that will be applied is
257
+ * the count on screen (RFC 038's previewed-set-equals-applied-set contract).
258
+ */
259
+ export const PreviewStale: Story = {
260
+ args: {
261
+ rule: demoRule,
262
+ folders: demoFolders,
263
+ preview: READY(47, true),
264
+ semanticAvailable: true,
265
+ },
266
+ };
267
+
268
+ /** The preview failed — the count region raises it, the editor stays usable. */
269
+ export const PreviewError: Story = {
270
+ args: {
271
+ rule: demoRule,
272
+ folders: demoFolders,
273
+ preview: { status: "error", reason: "Couldn't reach the server to count." },
274
+ semanticAvailable: true,
275
+ },
276
+ };
277
+
278
+ type ChipStory = StoryObj;
279
+
280
+ /** Every clause and widen chip in isolation, including edit and inactive states. */
281
+ export const ChipGallery: ChipStory = {
282
+ render: () => {
283
+ const fields: ClauseField[] = [
284
+ "From",
285
+ "Subject",
286
+ "HasWords",
287
+ "ListId",
288
+ "FromDomain",
289
+ ];
290
+ return (
291
+ <div className="space-y-4 p-4">
292
+ <div className="flex flex-wrap gap-2">
293
+ {fields.map((field) => (
294
+ <ClauseChip
295
+ key={field}
296
+ clause={{ id: field, field, value: clauseFieldLabel(field) }}
297
+ onEdit={() => {}}
298
+ onRemove={() => {}}
299
+ />
300
+ ))}
301
+ </div>
302
+ <div className="flex flex-wrap gap-2">
303
+ <ClauseChip
304
+ clause={{
305
+ id: "d",
306
+ field: "From",
307
+ value: "receipts@stripe.com",
308
+ derived: true,
309
+ }}
310
+ onEdit={() => {}}
311
+ onRemove={() => {}}
312
+ />
313
+ <WidenChip widen={{ anchorCount: 2 }} onRemove={() => {}} />
314
+ <WidenChip widen={{ anchorCount: 2, inactive: true }} />
315
+ </div>
316
+ <ClauseEditor
317
+ draft={{ field: "ListId", value: "python-dev.python.org" }}
318
+ mode="edit"
319
+ onChangeField={() => {}}
320
+ onChangeValue={() => {}}
321
+ onSubmit={() => {}}
322
+ onCancel={() => {}}
323
+ />
324
+ <div className="space-y-2">
325
+ <FilterPreviewCount preview={{ status: "loading" }} />
326
+ <FilterPreviewCount preview={READY(0)} />
327
+ <FilterPreviewCount preview={READY(412)} />
328
+ <FilterPreviewCount preview={READY(47, true)} />
329
+ <FilterPreviewCount
330
+ preview={{ status: "error", reason: "Couldn't count." }}
331
+ />
332
+ </div>
333
+ </div>
334
+ );
335
+ },
336
+ };