@remit/ui 0.0.74 → 0.0.75
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
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
import type { BriefRowComponent } from "./message-row.js";
|
|
17
17
|
|
|
18
18
|
/* Composable brief filters — each is an additive predicate over a thread row. */
|
|
19
|
-
type BriefFilterId = "unread" | "attachment" | "contacts" | "today";
|
|
19
|
+
export type BriefFilterId = "unread" | "attachment" | "contacts" | "today";
|
|
20
20
|
|
|
21
21
|
/* "Today" prefers the real `sentDate` timestamp; it falls back to the fixture
|
|
22
22
|
convention that same-day rows render a HH:MM timeLabel (fixtures carry no
|
|
@@ -56,7 +56,40 @@ export const briefFilterChips: FilterSheetFilter[] = briefFilterDefs.map(
|
|
|
56
56
|
({ id, label }) => ({ id, label }),
|
|
57
57
|
);
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
/**
|
|
60
|
+
* Whether a thread survives a set of attribute chips, as the brief's own list
|
|
61
|
+
* applies them. Exported so a consumer narrowing the same rows on another
|
|
62
|
+
* surface — the phone search takeover — reads one definition of what "Unread" or
|
|
63
|
+
* "Today" means.
|
|
64
|
+
*/
|
|
65
|
+
export function matchesBriefFilters(
|
|
66
|
+
thread: ThreadRowData,
|
|
67
|
+
activeFilters: ReadonlySet<BriefFilterId>,
|
|
68
|
+
): boolean {
|
|
69
|
+
return briefFilterDefs.every(
|
|
70
|
+
(f) => !activeFilters.has(f.id) || f.match(thread),
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The attribute chips are either this component's own or entirely the
|
|
76
|
+
* consumer's. A consumer narrowing the same rows on a second surface (the phone
|
|
77
|
+
* search takeover) holds the set so both surfaces answer to one selection, and
|
|
78
|
+
* takes every control over it with the set.
|
|
79
|
+
*/
|
|
80
|
+
type BriefFilterControl =
|
|
81
|
+
| {
|
|
82
|
+
activeFilters: ReadonlySet<BriefFilterId>;
|
|
83
|
+
onToggleFilter: (id: BriefFilterId) => void;
|
|
84
|
+
onClearFilters: () => void;
|
|
85
|
+
}
|
|
86
|
+
| {
|
|
87
|
+
activeFilters?: never;
|
|
88
|
+
onToggleFilter?: never;
|
|
89
|
+
onClearFilters?: never;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
interface BriefSectionsBaseProps {
|
|
60
93
|
sections: ThreadSection[];
|
|
61
94
|
briefCategory?: BriefCategoryFilter;
|
|
62
95
|
selectedThreadId?: string;
|
|
@@ -73,10 +106,17 @@ export interface BriefSectionsProps {
|
|
|
73
106
|
sourcesNote?: string;
|
|
74
107
|
/** Called when the user selects a source/account pill. */
|
|
75
108
|
onSelectSource?: (id: string) => void;
|
|
109
|
+
/**
|
|
110
|
+
* Drop the filter row and its panel, keeping the rows where they are. See
|
|
111
|
+
* `FilterSheetProps`.
|
|
112
|
+
*/
|
|
113
|
+
hideChrome?: boolean;
|
|
76
114
|
/** Seeds the filter panel open on first render (stories / deep links). */
|
|
77
115
|
defaultExpanded?: boolean;
|
|
78
116
|
}
|
|
79
117
|
|
|
118
|
+
export type BriefSectionsProps = BriefSectionsBaseProps & BriefFilterControl;
|
|
119
|
+
|
|
80
120
|
/**
|
|
81
121
|
* The daily-brief list body: category pills (single-select) + attribute chips
|
|
82
122
|
* (additive) + one capped section per category (see {@link BriefSection}). Owns
|
|
@@ -95,9 +135,15 @@ export function BriefSections({
|
|
|
95
135
|
sources,
|
|
96
136
|
sourcesNote,
|
|
97
137
|
onSelectSource,
|
|
138
|
+
activeFilters,
|
|
139
|
+
onToggleFilter,
|
|
140
|
+
onClearFilters,
|
|
141
|
+
hideChrome,
|
|
98
142
|
defaultExpanded = false,
|
|
99
143
|
}: BriefSectionsProps) {
|
|
100
|
-
const [
|
|
144
|
+
const [ownFilters, setOwnFilters] = useState<ReadonlySet<BriefFilterId>>(
|
|
145
|
+
new Set(),
|
|
146
|
+
);
|
|
101
147
|
const [sheetExpanded, setSheetExpanded] = useState(defaultExpanded);
|
|
102
148
|
const listRef = useRef<HTMLDivElement>(null);
|
|
103
149
|
useRovingFocus({
|
|
@@ -105,8 +151,14 @@ export function BriefSections({
|
|
|
105
151
|
itemSelector: LIST_ROW_SELECTOR,
|
|
106
152
|
});
|
|
107
153
|
|
|
154
|
+
const active = activeFilters ?? ownFilters;
|
|
155
|
+
|
|
108
156
|
const toggleFilter = (id: BriefFilterId) => {
|
|
109
|
-
|
|
157
|
+
if (onToggleFilter) {
|
|
158
|
+
onToggleFilter(id);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
setOwnFilters((prev) => {
|
|
110
162
|
const next = new Set(prev);
|
|
111
163
|
if (next.has(id)) next.delete(id);
|
|
112
164
|
else next.add(id);
|
|
@@ -114,10 +166,9 @@ export function BriefSections({
|
|
|
114
166
|
});
|
|
115
167
|
};
|
|
116
168
|
|
|
117
|
-
const predicates = briefFilterDefs.filter((f) => active.has(f.id));
|
|
118
169
|
const matches = (t: ThreadRowData) =>
|
|
119
170
|
(briefCategory === "all" || t.category === briefCategory) &&
|
|
120
|
-
|
|
171
|
+
matchesBriefFilters(t, active);
|
|
121
172
|
|
|
122
173
|
// One section per category only earns its keep at the "all" scope. Narrow to
|
|
123
174
|
// a single category and the headers are redundant: render a plain flat list.
|
|
@@ -142,7 +193,11 @@ export function BriefSections({
|
|
|
142
193
|
|
|
143
194
|
const clearFilters = () => {
|
|
144
195
|
onSelectBriefCategory?.("all");
|
|
145
|
-
|
|
196
|
+
if (onClearFilters) {
|
|
197
|
+
onClearFilters();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
setOwnFilters(new Set());
|
|
146
201
|
};
|
|
147
202
|
|
|
148
203
|
const empty = showSections ? filtered.length === 0 : flatRows.length === 0;
|
|
@@ -200,6 +255,7 @@ export function BriefSections({
|
|
|
200
255
|
onSelectSource={onSelectSource}
|
|
201
256
|
onToggleFilter={(id) => toggleFilter(id as BriefFilterId)}
|
|
202
257
|
onClear={clearFilters}
|
|
258
|
+
hideChrome={hideChrome}
|
|
203
259
|
>
|
|
204
260
|
{listBody}
|
|
205
261
|
</FilterSheet>
|
|
@@ -121,6 +121,8 @@ interface FilterPanelState {
|
|
|
121
121
|
/** Whether the sheet under this provider is narrowing the list right now. */
|
|
122
122
|
active: boolean;
|
|
123
123
|
setActive: (active: boolean) => void;
|
|
124
|
+
/** Whether a sheet is mounted for the caret to open. */
|
|
125
|
+
hasSheet: boolean;
|
|
124
126
|
}
|
|
125
127
|
|
|
126
128
|
const FilterPanelCtx = createContext<FilterPanelState | null>(null);
|
|
@@ -130,29 +132,52 @@ const FilterPanelCtx = createContext<FilterPanelState | null>(null);
|
|
|
130
132
|
* under it, which are siblings in the tree. Wrap both: the header renders a
|
|
131
133
|
* `FilterToggle`, the sheet drops its own trigger row, and the sheet keeps its
|
|
132
134
|
* filter state exactly where it already lives.
|
|
135
|
+
*
|
|
136
|
+
* A view whose body is sometimes something other than the filtered list — a
|
|
137
|
+
* skeleton, an empty state, an error — says so with `hasSheet`, and the caret
|
|
138
|
+
* stands down for as long as there is no panel behind it.
|
|
133
139
|
*/
|
|
134
|
-
export function FilterPanelProvider({
|
|
140
|
+
export function FilterPanelProvider({
|
|
141
|
+
children,
|
|
142
|
+
hasSheet = true,
|
|
143
|
+
}: {
|
|
144
|
+
children: ReactNode;
|
|
145
|
+
hasSheet?: boolean;
|
|
146
|
+
}) {
|
|
135
147
|
const [open, setOpen] = useState(false);
|
|
136
148
|
const [active, setActive] = useState(false);
|
|
137
149
|
const value = useMemo(
|
|
138
|
-
() => ({ open, setOpen, active, setActive }),
|
|
139
|
-
[open, active],
|
|
150
|
+
() => ({ open, setOpen, active, setActive, hasSheet }),
|
|
151
|
+
[open, active, hasSheet],
|
|
140
152
|
);
|
|
141
153
|
return (
|
|
142
154
|
<FilterPanelCtx.Provider value={value}>{children}</FilterPanelCtx.Provider>
|
|
143
155
|
);
|
|
144
156
|
}
|
|
145
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Detaches whatever it wraps from an enclosing `FilterPanelProvider`. A
|
|
160
|
+
* full-screen surface that covers the list — the phone search takeover — carries
|
|
161
|
+
* its own filter chrome, and the caret it would otherwise borrow belongs to a
|
|
162
|
+
* header that is not on screen.
|
|
163
|
+
*/
|
|
164
|
+
export function FilterPanelBoundary({ children }: { children: ReactNode }) {
|
|
165
|
+
return (
|
|
166
|
+
<FilterPanelCtx.Provider value={null}>{children}</FilterPanelCtx.Provider>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
146
170
|
/**
|
|
147
171
|
* The filter caret, for a list header to render beside its unread count. It is
|
|
148
172
|
* the view's whole filter affordance; the panel it opens still belongs to the
|
|
149
173
|
* sheet, inline above the rows.
|
|
150
174
|
*
|
|
151
|
-
* Renders nothing outside a `FilterPanelProvider
|
|
175
|
+
* Renders nothing outside a `FilterPanelProvider`, and nothing while that
|
|
176
|
+
* provider reports no sheet — there is no panel to open.
|
|
152
177
|
*/
|
|
153
178
|
export function FilterToggle() {
|
|
154
179
|
const panel = useContext(FilterPanelCtx);
|
|
155
|
-
if (!panel) return null;
|
|
180
|
+
if (!panel?.hasSheet) return null;
|
|
156
181
|
const { open, active, setOpen } = panel;
|
|
157
182
|
return (
|
|
158
183
|
<button
|
|
@@ -225,7 +250,9 @@ export function FilterSheet({
|
|
|
225
250
|
|
|
226
251
|
const setPanelActive = panel?.setActive;
|
|
227
252
|
useEffect(() => {
|
|
228
|
-
setPanelActive
|
|
253
|
+
if (!setPanelActive) return;
|
|
254
|
+
setPanelActive(hasActive);
|
|
255
|
+
return () => setPanelActive(false);
|
|
229
256
|
}, [hasActive, setPanelActive]);
|
|
230
257
|
|
|
231
258
|
const clearSource = useCallback(() => {
|
|
@@ -2,6 +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 { FilterPanelProvider } from "./filter-sheet.js";
|
|
5
6
|
import { MobileSearchView } from "./mobile-search-view.js";
|
|
6
7
|
import type { SearchResult } from "./search-result-row.js";
|
|
7
8
|
|
|
@@ -66,6 +67,26 @@ describe("MobileSearchView filter chrome", () => {
|
|
|
66
67
|
);
|
|
67
68
|
assert.match(html, /Expand filters/);
|
|
68
69
|
});
|
|
70
|
+
|
|
71
|
+
// The takeover covers the list whose header carries the caret, so it cannot
|
|
72
|
+
// borrow that caret: it renders over the header, not under it. Without the
|
|
73
|
+
// boundary the sheet reads the list's panel and drops its own trigger row,
|
|
74
|
+
// leaving the takeover with no way to open its filters at all.
|
|
75
|
+
it("keeps its own filter row over a list that has a filter panel", () => {
|
|
76
|
+
const html = renderToString(
|
|
77
|
+
createElement(
|
|
78
|
+
FilterPanelProvider,
|
|
79
|
+
null,
|
|
80
|
+
createElement(MobileSearchView, {
|
|
81
|
+
...base,
|
|
82
|
+
value: "",
|
|
83
|
+
sections: [],
|
|
84
|
+
filter,
|
|
85
|
+
}),
|
|
86
|
+
),
|
|
87
|
+
);
|
|
88
|
+
assert.match(html, /Expand filters/);
|
|
89
|
+
});
|
|
69
90
|
});
|
|
70
91
|
|
|
71
92
|
describe("MobileSearchView search scope", () => {
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { X } from "lucide-react";
|
|
2
2
|
import type { ReactNode } from "react";
|
|
3
3
|
import { Button } from "./button.js";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
FilterPanelBoundary,
|
|
6
|
+
FilterSheet,
|
|
7
|
+
type FilterSheetProps,
|
|
8
|
+
} from "./filter-sheet.js";
|
|
5
9
|
import { SearchBar } from "./search-bar.js";
|
|
6
10
|
import type { SearchChip, SearchFieldSuggest } from "./search-chip-input.js";
|
|
7
11
|
import type { SearchResult } from "./search-result-row.js";
|
|
@@ -146,7 +150,9 @@ export function MobileSearchView({
|
|
|
146
150
|
{suggestList}
|
|
147
151
|
|
|
148
152
|
{filter && value.trim().length === 0 ? (
|
|
149
|
-
<
|
|
153
|
+
<FilterPanelBoundary>
|
|
154
|
+
<FilterSheet {...filter}>{body}</FilterSheet>
|
|
155
|
+
</FilterPanelBoundary>
|
|
150
156
|
) : (
|
|
151
157
|
<div className="flex-1 overflow-y-auto">{body}</div>
|
|
152
158
|
)}
|
package/src/index.ts
CHANGED