@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.74",
3
+ "version": "0.0.75",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -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
- export interface BriefSectionsProps {
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 [active, setActive] = useState<ReadonlySet<BriefFilterId>>(new Set());
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
- setActive((prev) => {
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
- predicates.every((f) => f.match(t));
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
- setActive(new Set());
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({ children }: { children: ReactNode }) {
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` there is no panel to open.
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?.(hasActive);
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 { FilterSheet, type FilterSheetProps } from "./filter-sheet.js";
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
- <FilterSheet {...filter}>{body}</FilterSheet>
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
@@ -86,8 +86,10 @@ export {
86
86
  SECTION_ROW_CAP,
87
87
  } from "./components/brief-section.js";
88
88
  export {
89
+ type BriefFilterId,
89
90
  BriefSections,
90
91
  type BriefSectionsProps,
92
+ matchesBriefFilters,
91
93
  } from "./components/brief-sections.js";
92
94
  export {
93
95
  Button,