@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.
@@ -0,0 +1,303 @@
1
+ import { Fragment, type ReactNode } from "react";
2
+ import { BottomSheet } from "./bottom-sheet.js";
3
+ import { Button } from "./button.js";
4
+ import { Dialog } from "./dialog.js";
5
+ import {
6
+ AddChipButton,
7
+ ClauseChip,
8
+ type ClauseDraft,
9
+ ClauseEditor,
10
+ WidenChip,
11
+ } from "./filter-clause-chip.js";
12
+ import { FilterPreviewCount } from "./filter-preview-count.js";
13
+ import {
14
+ type ClauseField,
15
+ commitBlockedReason,
16
+ commitLabel,
17
+ type FilterRule,
18
+ type FolderOption,
19
+ type MatchOperator,
20
+ matchJoinWord,
21
+ matchOperatorLabel,
22
+ type PreviewCount,
23
+ type RuleScope,
24
+ } from "./filter-rule.js";
25
+ import { Input } from "./input.js";
26
+ import { SegmentedControl } from "./segmented-control.js";
27
+ import { Select } from "./select.js";
28
+
29
+ export interface ClauseEditState {
30
+ mode: "add" | "edit";
31
+ /** Present when amending an existing clause; absent when adding a new one. */
32
+ clauseId?: string;
33
+ draft: ClauseDraft;
34
+ }
35
+
36
+ export interface FilterRuleEditorProps {
37
+ rule: FilterRule;
38
+ folders: FolderOption[];
39
+ preview: PreviewCount;
40
+ /**
41
+ * Whether the deployment can serve the semantic widen (RFC 038 D4). When
42
+ * false the "…and anything similar" chip is never offered — an already-present
43
+ * widen still renders, marked inactive.
44
+ */
45
+ semanticAvailable?: boolean;
46
+ /** The inline clause form, when adding or editing a clause. */
47
+ clauseEdit?: ClauseEditState;
48
+ onStartAddClause?: () => void;
49
+ onStartEditClause?: (clauseId: string) => void;
50
+ onRemoveClause?: (clauseId: string) => void;
51
+ onChangeDraftField?: (field: ClauseField) => void;
52
+ onChangeDraftValue?: (value: string) => void;
53
+ onSubmitClause?: () => void;
54
+ onCancelClause?: () => void;
55
+ onAddWiden?: () => void;
56
+ onRemoveWiden?: () => void;
57
+ onChangeMatchOperator?: (operator: MatchOperator) => void;
58
+ onChangeMove?: (mailboxId: string) => void;
59
+ onChangeScope?: (scope: RuleScope) => void;
60
+ onChangeName?: (name: string) => void;
61
+ onChangeUntil?: (date: string) => void;
62
+ onCommit?: () => void;
63
+ onCancel?: () => void;
64
+ }
65
+
66
+ const matchOptions: { value: MatchOperator; label: string }[] = [
67
+ { value: "all", label: matchOperatorLabel("all") },
68
+ { value: "any", label: matchOperatorLabel("any") },
69
+ ];
70
+
71
+ const scopeOptions: { value: RuleScope; label: string }[] = [
72
+ { value: "once", label: "Just once" },
73
+ { value: "standing", label: "Keep doing this" },
74
+ { value: "until", label: "Until a date" },
75
+ ];
76
+
77
+ export function FilterRuleEditor({
78
+ rule,
79
+ folders,
80
+ preview,
81
+ semanticAvailable = false,
82
+ clauseEdit,
83
+ onStartAddClause,
84
+ onStartEditClause,
85
+ onRemoveClause,
86
+ onChangeDraftField,
87
+ onChangeDraftValue,
88
+ onSubmitClause,
89
+ onCancelClause,
90
+ onAddWiden,
91
+ onRemoveWiden,
92
+ onChangeMatchOperator,
93
+ onChangeMove,
94
+ onChangeScope,
95
+ onChangeName,
96
+ onChangeUntil,
97
+ onCommit,
98
+ onCancel,
99
+ }: FilterRuleEditorProps) {
100
+ const activeWiden = rule.widen && !rule.widen.inactive ? 1 : 0;
101
+ const matcherCount = rule.clauses.length + activeWiden;
102
+ const showOperator = matcherCount >= 2;
103
+ const join = matchJoinWord(rule.matchOperator);
104
+ const blockedReason = commitBlockedReason(rule, preview);
105
+ const needsName = rule.scope === "standing" || rule.scope === "until";
106
+
107
+ return (
108
+ <div className="flex min-h-0 flex-col">
109
+ <div className="border-b border-line px-5 py-3">
110
+ <h2 className="text-sm font-semibold text-fg">Filter rule</h2>
111
+ <p className="mt-0.5 text-xs text-fg-muted">
112
+ These chips are the whole rule — what you see is what applies.
113
+ </p>
114
+ </div>
115
+
116
+ <div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-5 py-4">
117
+ <section className="space-y-3">
118
+ <div className="flex flex-wrap items-center gap-1.5">
119
+ {rule.clauses.map((clause, index) => (
120
+ <Fragment key={clause.id}>
121
+ {index > 0 && (
122
+ <span className="text-2xs font-medium uppercase text-fg-subtle">
123
+ {join}
124
+ </span>
125
+ )}
126
+ <ClauseChip
127
+ clause={clause}
128
+ onEdit={() => onStartEditClause?.(clause.id)}
129
+ onRemove={() => onRemoveClause?.(clause.id)}
130
+ />
131
+ </Fragment>
132
+ ))}
133
+
134
+ {rule.widen && (
135
+ <>
136
+ {rule.clauses.length > 0 && !rule.widen.inactive && (
137
+ <span className="text-2xs font-medium uppercase text-fg-subtle">
138
+ {join}
139
+ </span>
140
+ )}
141
+ <WidenChip widen={rule.widen} onRemove={onRemoveWiden} />
142
+ </>
143
+ )}
144
+
145
+ {!clauseEdit && (
146
+ <AddChipButton label="Add clause" onClick={onStartAddClause} />
147
+ )}
148
+ {!rule.widen && semanticAvailable && !clauseEdit && (
149
+ <AddChipButton label="…and similar" onClick={onAddWiden} />
150
+ )}
151
+ </div>
152
+
153
+ {clauseEdit && (
154
+ <ClauseEditor
155
+ draft={clauseEdit.draft}
156
+ mode={clauseEdit.mode}
157
+ onChangeField={onChangeDraftField}
158
+ onChangeValue={onChangeDraftValue}
159
+ onSubmit={onSubmitClause}
160
+ onCancel={onCancelClause}
161
+ />
162
+ )}
163
+
164
+ {showOperator && (
165
+ <div className="flex items-center gap-2">
166
+ <span className="text-xs text-fg-muted">Match</span>
167
+ <SegmentedControl
168
+ name="match-operator"
169
+ size="sm"
170
+ aria-label="Match operator"
171
+ options={matchOptions}
172
+ value={rule.matchOperator}
173
+ onChange={(value) => onChangeMatchOperator?.(value)}
174
+ />
175
+ </div>
176
+ )}
177
+ </section>
178
+
179
+ <section className="space-y-2">
180
+ <p className="text-xs font-medium text-fg-muted">Move matches to</p>
181
+ <Select
182
+ aria-label="Destination folder"
183
+ value={rule.moveMailboxId ?? ""}
184
+ onChange={(e) => onChangeMove?.(e.target.value)}
185
+ >
186
+ <option value="">Choose a folder…</option>
187
+ {folders.map((folder) => (
188
+ <option key={folder.id} value={folder.id}>
189
+ {folder.label}
190
+ </option>
191
+ ))}
192
+ </Select>
193
+ <div className="flex items-center gap-2 pt-0.5">
194
+ <span className="inline-flex items-center gap-1.5 rounded-full bg-surface-sunken px-2 py-0.5 text-2xs font-medium text-fg-muted">
195
+ label them…
196
+ </span>
197
+ <span className="text-2xs text-fg-subtle">
198
+ Labeling isn't available yet — arrives with mail-labeling (RFC
199
+ 031).
200
+ </span>
201
+ </div>
202
+ </section>
203
+
204
+ <section className="space-y-2">
205
+ <p className="text-xs font-medium text-fg-muted">How long</p>
206
+ <SegmentedControl
207
+ name="rule-scope"
208
+ size="sm"
209
+ aria-label="Rule scope"
210
+ options={scopeOptions}
211
+ value={rule.scope}
212
+ onChange={(value) => onChangeScope?.(value)}
213
+ />
214
+ {needsName && (
215
+ <Input
216
+ value={rule.name ?? ""}
217
+ onChange={(e) => onChangeName?.(e.target.value)}
218
+ placeholder="Name this rule (e.g. Receipts)"
219
+ aria-label="Rule name"
220
+ className="w-full"
221
+ />
222
+ )}
223
+ {rule.scope === "until" && (
224
+ <div className="flex items-center gap-2 text-xs text-fg-muted">
225
+ <span>Until</span>
226
+ <Input
227
+ type="date"
228
+ value={rule.until ?? ""}
229
+ onChange={(e) => onChangeUntil?.(e.target.value)}
230
+ aria-label="Expiry date"
231
+ className="flex-1"
232
+ />
233
+ </div>
234
+ )}
235
+ </section>
236
+
237
+ <div className="border-t border-line pt-3">
238
+ <FilterPreviewCount preview={preview} />
239
+ </div>
240
+ </div>
241
+
242
+ <div className="space-y-2 border-t border-line px-5 py-3">
243
+ {blockedReason && (
244
+ <p className="text-xs text-fg-subtle" role="status">
245
+ {blockedReason}
246
+ </p>
247
+ )}
248
+ <Button
249
+ variant="primary"
250
+ onClick={onCommit}
251
+ disabled={blockedReason !== undefined}
252
+ className="w-full"
253
+ >
254
+ {commitLabel(rule.scope)}
255
+ </Button>
256
+ <Button variant="ghost" onClick={onCancel} className="w-full">
257
+ Not now
258
+ </Button>
259
+ </div>
260
+ </div>
261
+ );
262
+ }
263
+
264
+ export interface FilterRuleDialogProps extends FilterRuleEditorProps {
265
+ open: boolean;
266
+ onClose: () => void;
267
+ }
268
+
269
+ /** Desktop home for the rule editor — the centered modal (RFC 038 D1). */
270
+ export function FilterRuleDialog({
271
+ open,
272
+ onClose,
273
+ ...editor
274
+ }: FilterRuleDialogProps) {
275
+ if (!open) return null;
276
+ return (
277
+ <Dialog open={open} onClose={onClose} title="Filter rule">
278
+ <FilterRuleEditor {...editor} onCancel={onClose} />
279
+ </Dialog>
280
+ );
281
+ }
282
+
283
+ export interface FilterRuleSheetProps extends FilterRuleEditorProps {
284
+ open: boolean;
285
+ onClose: () => void;
286
+ }
287
+
288
+ /** Mobile home for the rule editor — the bottom sheet (RFC 038 D1). */
289
+ export function FilterRuleSheet({
290
+ open,
291
+ onClose,
292
+ ...editor
293
+ }: FilterRuleSheetProps): ReactNode {
294
+ return (
295
+ <BottomSheet
296
+ open={open}
297
+ onClose={onClose}
298
+ dismissLabel="Dismiss filter rule"
299
+ >
300
+ <FilterRuleEditor {...editor} onCancel={onClose} />
301
+ </BottomSheet>
302
+ );
303
+ }