convex-feedback-ui 0.1.6 → 0.1.7

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.
Files changed (98) hide show
  1. package/README.md +19 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/native/expo/FeedbackRoutes.d.ts.map +1 -1
  4. package/dist/native/expo/FeedbackRoutes.js +10 -5
  5. package/dist/native/expo/FeedbackRoutes.js.map +1 -1
  6. package/dist/native/expo/FeedbackScreen.d.ts +1 -1
  7. package/dist/native/expo/FeedbackScreen.d.ts.map +1 -1
  8. package/dist/native/expo/FeedbackScreen.js +2 -2
  9. package/dist/native/expo/FeedbackScreen.js.map +1 -1
  10. package/dist/native/expo/FeedbackStack.d.ts.map +1 -1
  11. package/dist/native/expo/FeedbackStack.js +7 -4
  12. package/dist/native/expo/FeedbackStack.js.map +1 -1
  13. package/dist/native/expo/FeedbackStackLayout.d.ts +1 -1
  14. package/dist/native/expo/FeedbackStackLayout.d.ts.map +1 -1
  15. package/dist/native/expo/FeedbackStackLayout.js +2 -2
  16. package/dist/native/expo/FeedbackStackLayout.js.map +1 -1
  17. package/dist/native/expo/types.d.ts +2 -0
  18. package/dist/native/expo/types.d.ts.map +1 -1
  19. package/dist/native/native/FeedbackScreen.d.ts +1 -1
  20. package/dist/native/native/FeedbackScreen.d.ts.map +1 -1
  21. package/dist/native/native/FeedbackScreen.js +2 -2
  22. package/dist/native/native/FeedbackScreen.js.map +1 -1
  23. package/dist/native/shared/ui/BodyContent.d.ts +1 -1
  24. package/dist/native/shared/ui/BodyContent.d.ts.map +1 -1
  25. package/dist/native/shared/ui/BodyContent.js +2 -2
  26. package/dist/native/shared/ui/BodyContent.js.map +1 -1
  27. package/dist/native/shared/ui/Button.d.ts +2 -1
  28. package/dist/native/shared/ui/Button.d.ts.map +1 -1
  29. package/dist/native/shared/ui/Button.js +9 -4
  30. package/dist/native/shared/ui/Button.js.map +1 -1
  31. package/dist/native/shared/ui/FeedbackScreenHeader.d.ts.map +1 -1
  32. package/dist/native/shared/ui/FeedbackScreenHeader.js +8 -4
  33. package/dist/native/shared/ui/FeedbackScreenHeader.js.map +1 -1
  34. package/dist/native/shared/ui/FeedbackScreenList.d.ts +1 -1
  35. package/dist/native/shared/ui/FeedbackScreenList.d.ts.map +1 -1
  36. package/dist/native/shared/ui/FeedbackScreenList.js +18 -5
  37. package/dist/native/shared/ui/FeedbackScreenList.js.map +1 -1
  38. package/dist/native/shared/ui/NewEntry.d.ts.map +1 -1
  39. package/dist/native/shared/ui/NewEntry.js +5 -12
  40. package/dist/native/shared/ui/NewEntry.js.map +1 -1
  41. package/dist/native/shared/ui/primitives.d.ts +7 -1
  42. package/dist/native/shared/ui/primitives.d.ts.map +1 -1
  43. package/dist/native/shared/ui/primitives.js +38 -0
  44. package/dist/native/shared/ui/primitives.js.map +1 -1
  45. package/dist/react/primitives.d.ts +9 -1
  46. package/dist/react/primitives.d.ts.map +1 -1
  47. package/dist/react/primitives.js +12 -0
  48. package/dist/react/primitives.js.map +1 -1
  49. package/dist/react/screen.d.ts +1 -1
  50. package/dist/react/screen.d.ts.map +1 -1
  51. package/dist/react/screen.js +21 -10
  52. package/dist/react/screen.js.map +1 -1
  53. package/dist/react/styles.css +38 -1
  54. package/dist/shared/context/FeedbackBodyProvider.d.ts +1 -1
  55. package/dist/shared/context/FeedbackBodyProvider.d.ts.map +1 -1
  56. package/dist/shared/context/FeedbackBodyProvider.js +5 -1
  57. package/dist/shared/context/FeedbackBodyProvider.js.map +1 -1
  58. package/dist/shared/helpers.d.ts +5 -1
  59. package/dist/shared/helpers.d.ts.map +1 -1
  60. package/dist/shared/helpers.js +11 -0
  61. package/dist/shared/helpers.js.map +1 -1
  62. package/dist/shared/messages.d.ts.map +1 -1
  63. package/dist/shared/messages.js +1 -0
  64. package/dist/shared/messages.js.map +1 -1
  65. package/dist/shared/types/choice.d.ts +10 -0
  66. package/dist/shared/types/choice.d.ts.map +1 -0
  67. package/dist/shared/types/choice.js +2 -0
  68. package/dist/shared/types/choice.js.map +1 -0
  69. package/dist/shared/types/index.d.ts +1 -0
  70. package/dist/shared/types/index.d.ts.map +1 -1
  71. package/dist/shared/types/messages.d.ts +2 -0
  72. package/dist/shared/types/messages.d.ts.map +1 -1
  73. package/dist/shared/types/screen.d.ts +9 -2
  74. package/dist/shared/types/screen.d.ts.map +1 -1
  75. package/package.json +6 -2
  76. package/src/native/expo/FeedbackRoutes.tsx +37 -3
  77. package/src/native/expo/FeedbackScreen.tsx +2 -0
  78. package/src/native/expo/FeedbackStack.tsx +25 -2
  79. package/src/native/expo/FeedbackStackLayout.tsx +2 -0
  80. package/src/native/expo/README.md +2 -2
  81. package/src/native/expo/types.ts +2 -0
  82. package/src/native/native/FeedbackScreen.tsx +2 -0
  83. package/src/native/shared/ui/BodyContent.tsx +2 -0
  84. package/src/native/shared/ui/Button.tsx +12 -3
  85. package/src/native/shared/ui/FeedbackScreenHeader.tsx +25 -4
  86. package/src/native/shared/ui/FeedbackScreenList.tsx +47 -8
  87. package/src/native/shared/ui/NewEntry.tsx +10 -28
  88. package/src/native/shared/ui/primitives.tsx +90 -0
  89. package/src/react/primitives.tsx +55 -0
  90. package/src/react/screen.tsx +67 -33
  91. package/src/react/styles.css +38 -1
  92. package/src/shared/context/FeedbackBodyProvider.tsx +6 -0
  93. package/src/shared/helpers.ts +21 -1
  94. package/src/shared/messages.ts +1 -0
  95. package/src/shared/types/choice.ts +10 -0
  96. package/src/shared/types/index.ts +1 -0
  97. package/src/shared/types/messages.ts +3 -0
  98. package/src/shared/types/screen.ts +11 -1
@@ -15,7 +15,7 @@ import { useFeedbackUi } from "../../../shared/context/FeedbackProvider";
15
15
  import type { FeedbackScreenEntryModalProps } from "../../../shared/types";
16
16
  import { collectEntryMetadata } from "../../../shared/metadata";
17
17
  import { EntryDetail } from "./EntryDetail";
18
- import { FeedbackForm } from "./primitives";
18
+ import { ChoiceChips, FeedbackForm } from "./primitives";
19
19
 
20
20
  export function CreateEntryModal({
21
21
  onRequestClose,
@@ -247,33 +247,15 @@ export function CreateEntryForm({
247
247
  <Text style={{ color: theme.colors.text, fontWeight: "600" }}>
248
248
  {messages.form.kind}
249
249
  </Text>
250
- <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 7 }}>
251
- {enabledKinds.map((value) => (
252
- <Pressable
253
- key={value}
254
- onPress={() => onKindChange(value)}
255
- style={{
256
- paddingVertical: 6,
257
- paddingHorizontal: 9,
258
- borderRadius: 999,
259
- borderWidth: 1,
260
- borderColor:
261
- value === kind ? theme.colors.primary : theme.colors.border,
262
- }}
263
- >
264
- <Text
265
- style={{
266
- color:
267
- value === kind
268
- ? theme.colors.primary
269
- : theme.colors.mutedText,
270
- }}
271
- >
272
- {messages.kinds[value]}
273
- </Text>
274
- </Pressable>
275
- ))}
276
- </View>
250
+ <ChoiceChips
251
+ accessibilityLabel={messages.form.kind}
252
+ options={enabledKinds.map((value) => ({
253
+ value,
254
+ label: messages.kinds[value],
255
+ }))}
256
+ value={kind}
257
+ onValueChange={onKindChange}
258
+ />
277
259
  </>
278
260
  )}
279
261
  <FeedbackForm.Input
@@ -32,6 +32,7 @@ import {
32
32
  import type {
33
33
  BoardSearchBaseProps,
34
34
  BoardSearchState,
35
+ ChoiceChipsBaseProps,
35
36
  CommentActionBaseProps,
36
37
  CommentLikeBaseProps,
37
38
  CommentLikeState,
@@ -182,14 +183,103 @@ const BoardList = forwardRef<ScrollView, ScrollViewProps>(
182
183
  },
183
184
  );
184
185
 
186
+ function BoardState({ style, ...props }: ViewProps) {
187
+ const { theme, unstyled } = useFeedbackUi();
188
+ return (
189
+ <View
190
+ {...props}
191
+ style={combineStyle<ViewStyle>(
192
+ !unstyled,
193
+ {
194
+ flex: 1,
195
+ alignItems: "center",
196
+ justifyContent: "center",
197
+ gap: theme.spacing,
198
+ padding: theme.spacing,
199
+ },
200
+ style,
201
+ )}
202
+ />
203
+ );
204
+ }
205
+
185
206
  export const FeedbackBoard = {
186
207
  Root: BoardRoot,
187
208
  Header: BoardHeader,
188
209
  Title: BoardTitle,
189
210
  Search: BoardSearch,
190
211
  List: BoardList,
212
+ State: BoardState,
191
213
  };
192
214
 
215
+ /** Props for the reusable native `ChoiceChips` primitive. */
216
+ export interface ChoiceChipsProps<Value extends string = string>
217
+ extends Omit<ViewProps, "children">, ChoiceChipsBaseProps<Value> {}
218
+
219
+ export function ChoiceChips<Value extends string>({
220
+ options,
221
+ value,
222
+ onValueChange,
223
+ style,
224
+ ...props
225
+ }: ChoiceChipsProps<Value>) {
226
+ const { theme, unstyled } = useFeedbackUi();
227
+
228
+ return (
229
+ <View
230
+ {...props}
231
+ style={combineStyle<ViewStyle>(
232
+ !unstyled,
233
+ { flexDirection: "row", flexWrap: "wrap", gap: 7 },
234
+ style,
235
+ )}
236
+ >
237
+ {options.map((option) => {
238
+ const selected = option.value === value;
239
+ return (
240
+ <Pressable
241
+ key={option.value}
242
+ accessibilityRole="radio"
243
+ accessibilityState={{ selected }}
244
+ onPress={() => onValueChange(option.value)}
245
+ style={
246
+ unstyled
247
+ ? undefined
248
+ : {
249
+ paddingVertical: 6,
250
+ paddingHorizontal: 9,
251
+ borderRadius: 999,
252
+ borderWidth: 1,
253
+ borderColor: selected
254
+ ? theme.colors.primary
255
+ : theme.colors.border,
256
+ backgroundColor: selected
257
+ ? theme.colors.primary
258
+ : "transparent",
259
+ }
260
+ }
261
+ >
262
+ <Text
263
+ style={
264
+ unstyled
265
+ ? undefined
266
+ : {
267
+ color: selected
268
+ ? theme.colors.primaryForeground
269
+ : theme.colors.mutedText,
270
+ fontWeight: "600",
271
+ }
272
+ }
273
+ >
274
+ {option.label}
275
+ </Text>
276
+ </Pressable>
277
+ );
278
+ })}
279
+ </View>
280
+ );
281
+ }
282
+
193
283
  const EntryContext = createContext<FeedbackEntryData | null>(null);
194
284
  function useEntryContext(): FeedbackEntryData {
195
285
  const value = useContext(EntryContext);
@@ -25,6 +25,7 @@ import type {
25
25
  CommentLikeBaseProps,
26
26
  CommentLikeState,
27
27
  CommentRootBaseProps,
28
+ ChoiceChipsBaseProps,
28
29
  EntryRootBaseProps,
29
30
  EntryUpvoteBaseProps,
30
31
  EntryUpvoteState,
@@ -147,14 +148,68 @@ function BoardList(props: HTMLAttributes<HTMLDivElement>) {
147
148
  );
148
149
  }
149
150
 
151
+ function BoardState(props: HTMLAttributes<HTMLDivElement>) {
152
+ const { unstyled } = useFeedbackUi();
153
+ return (
154
+ <div
155
+ {...props}
156
+ className={classes(unstyled, "cf-state", props.className)}
157
+ />
158
+ );
159
+ }
160
+
150
161
  export const FeedbackBoard = {
151
162
  Root: BoardRoot,
152
163
  Header: BoardHeader,
153
164
  Title: BoardTitle,
154
165
  Search: BoardSearch,
155
166
  List: BoardList,
167
+ State: BoardState,
156
168
  };
157
169
 
170
+ /**
171
+ * Props for the reusable web `ChoiceChips` primitive.
172
+ */
173
+ export interface ChoiceChipsProps<Value extends string = string>
174
+ extends
175
+ Omit<HTMLAttributes<HTMLDivElement>, "children">,
176
+ ChoiceChipsBaseProps<Value> {}
177
+
178
+ export function ChoiceChips<Value extends string>({
179
+ options,
180
+ value,
181
+ onValueChange,
182
+ className,
183
+ role,
184
+ ...props
185
+ }: ChoiceChipsProps<Value>) {
186
+ const { unstyled } = useFeedbackUi();
187
+
188
+ return (
189
+ <div
190
+ {...props}
191
+ role={role ?? "group"}
192
+ className={classes(unstyled, "cf-choice-chips", className)}
193
+ >
194
+ {options.map((option) => {
195
+ const selected = option.value === value;
196
+ return (
197
+ <button
198
+ key={option.value}
199
+ type="button"
200
+ className={classes(unstyled, "cf-choice-chip")}
201
+ data-selected={selected ? "true" : "false"}
202
+ aria-pressed={selected}
203
+ onClick={() => onValueChange(option.value)}
204
+ >
205
+ {option.label}
206
+ </button>
207
+ );
208
+ })}
209
+ </div>
210
+ );
211
+ }
212
+
158
213
  const EntryContext = createContext<FeedbackEntryData | null>(null);
159
214
 
160
215
  function useEntryContext(): FeedbackEntryData {
@@ -11,6 +11,7 @@ import {
11
11
  FeedbackProvider,
12
12
  useFeedbackUi,
13
13
  } from "../shared/context/FeedbackProvider";
14
+ import { createEntryLabel, entryStatusChoices } from "../shared/helpers.js";
14
15
  import type {
15
16
  FeedbackScreenCommentBranchProps,
16
17
  FeedbackScreenContentProps,
@@ -22,6 +23,7 @@ import type {
22
23
  } from "../shared/types/";
23
24
  import {
24
25
  Comment,
26
+ ChoiceChips,
25
27
  FeedbackBoard,
26
28
  FeedbackEntry,
27
29
  FeedbackForm,
@@ -52,6 +54,7 @@ export function FeedbackScreen({
52
54
  renderActor,
53
55
  debounceDuration = 300,
54
56
  collectMetadata,
57
+ emptyState,
55
58
  ...props
56
59
  }: FeedbackScreenProps) {
57
60
  return (
@@ -64,6 +67,7 @@ export function FeedbackScreen({
64
67
  maxCommentDepth={maxCommentDepth}
65
68
  debounceDuration={debounceDuration}
66
69
  collectMetadata={collectMetadata}
70
+ emptyState={emptyState}
67
71
  collectStandardMetadata={collectWebMetadata}
68
72
  transformComments={transformComments}
69
73
  renderActor={renderActor}
@@ -90,16 +94,24 @@ function FeedbackScreenInner({
90
94
  setShowForm,
91
95
  setSelectedEntryId,
92
96
  selectedEntryId,
97
+ statusFilter,
98
+ setStatusFilter,
93
99
  query,
94
100
  setQuery,
95
101
  debouncedQuery,
102
+ emptyState,
96
103
  } = useFeedbackBody();
97
104
  const { messages } = useFeedbackUi();
98
105
 
99
- const list = hooks.useEntries({ sort: entrySort, kinds: enabledKinds });
106
+ const list = hooks.useEntries({
107
+ sort: entrySort,
108
+ kinds: enabledKinds,
109
+ statusFilter,
110
+ });
100
111
  const searchResults = hooks.useSearchEntries({
101
112
  searchQuery: debouncedQuery,
102
113
  kinds: enabledKinds,
114
+ statusFilter,
103
115
  });
104
116
 
105
117
  if (selectedEntryId !== null) {
@@ -125,13 +137,21 @@ function FeedbackScreenInner({
125
137
  <div>
126
138
  <FeedbackBoard.Title />
127
139
  <p className="cf-board__subtitle">{messages.board.subtitle}</p>
140
+ <ChoiceChips
141
+ aria-label={messages.board.statusFilter}
142
+ options={entryStatusChoices(messages)}
143
+ value={statusFilter}
144
+ onValueChange={setStatusFilter}
145
+ />
128
146
  </div>
129
147
  <button
130
148
  type="button"
131
149
  className="cf-button cf-button--primary"
132
150
  onClick={() => setShowForm((current) => !current)}
133
151
  >
134
- {showForm ? messages.form.cancel : messages.board.createEntry}
152
+ {showForm
153
+ ? messages.form.cancel
154
+ : createEntryLabel(enabledKinds, messages)}
135
155
  </button>
136
156
  </FeedbackBoard.Header>
137
157
 
@@ -146,26 +166,39 @@ function FeedbackScreenInner({
146
166
 
147
167
  <FeedbackBoard.Search value={query} onValueChange={setQuery} />
148
168
 
149
- {loading && <p className="cf-state">{messages.board.loading}</p>}
150
- {!loading && entries !== undefined && entries.length === 0 && (
151
- <p className="cf-state">
152
- {searching
153
- ? messages.board.noSearchResults
154
- : messages.board.noEntries}
155
- </p>
169
+ {loading ? (
170
+ <FeedbackBoard.State>
171
+ <span className="cf-state__message">{messages.board.loading}</span>
172
+ </FeedbackBoard.State>
173
+ ) : entries !== undefined && entries.length === 0 ? (
174
+ <FeedbackBoard.State>
175
+ {emptyState}
176
+ <span className="cf-state__message">
177
+ {searching
178
+ ? messages.board.noSearchResults
179
+ : messages.board.noEntries}
180
+ </span>
181
+ <button
182
+ type="button"
183
+ className="cf-button cf-button--primary"
184
+ onClick={() => setShowForm(true)}
185
+ >
186
+ {createEntryLabel(enabledKinds, messages)}
187
+ </button>
188
+ </FeedbackBoard.State>
189
+ ) : (
190
+ <FeedbackBoard.List>
191
+ {(entries ?? []).map((entry) => (
192
+ <EntryCard
193
+ key={entry.id}
194
+ entry={entry}
195
+ hooks={hooks}
196
+ onOpen={() => setSelectedEntryId(entry.id)}
197
+ />
198
+ ))}
199
+ </FeedbackBoard.List>
156
200
  )}
157
201
 
158
- <FeedbackBoard.List>
159
- {(entries ?? []).map((entry) => (
160
- <EntryCard
161
- key={entry.id}
162
- entry={entry}
163
- hooks={hooks}
164
- onOpen={() => setSelectedEntryId(entry.id)}
165
- />
166
- ))}
167
- </FeedbackBoard.List>
168
-
169
202
  {!searching && list.status === "CanLoadMore" && (
170
203
  <button
171
204
  type="button"
@@ -273,19 +306,20 @@ function CreateEntryForm({ onCreated }: FeedbackScreenEntryModalProps) {
273
306
 
274
307
  return (
275
308
  <FeedbackForm.Root onSubmit={submit}>
276
- <label className="cf-field">
277
- <span>{messages.form.kind}</span>
278
- <FeedbackForm.Select
279
- value={kind}
280
- onChange={(event) => setKind(event.currentTarget.value as EntryKind)}
281
- >
282
- {enabledKinds.map((value) => (
283
- <option key={value} value={value}>
284
- {messages.kinds[value]}
285
- </option>
286
- ))}
287
- </FeedbackForm.Select>
288
- </label>
309
+ {enabledKinds.length > 1 && (
310
+ <label className="cf-field">
311
+ <span>{messages.form.kind}</span>
312
+ <ChoiceChips
313
+ aria-label={messages.form.kind}
314
+ options={enabledKinds.map((value) => ({
315
+ value,
316
+ label: messages.kinds[value],
317
+ }))}
318
+ value={kind}
319
+ onValueChange={setKind}
320
+ />
321
+ </label>
322
+ )}
289
323
  <label className="cf-field">
290
324
  <span>{messages.form.title}</span>
291
325
  <FeedbackForm.Input
@@ -30,6 +30,33 @@
30
30
  margin: 0.4rem 0 0;
31
31
  color: var(--cf-muted);
32
32
  }
33
+ :where(.cf-board) :where(.cf-choice-chips) {
34
+ display: flex;
35
+ flex-wrap: wrap;
36
+ align-items: center;
37
+ gap: 7px;
38
+ margin-top: 0.75rem;
39
+ }
40
+ :where(.cf-board) :where(.cf-choice-chip) {
41
+ border: 1px solid var(--cf-border);
42
+ border-radius: 999px;
43
+ padding: 6px 10px;
44
+ background: var(--cf-surface);
45
+ color: var(--cf-muted);
46
+ font: inherit;
47
+ font-size: 0.85rem;
48
+ font-weight: 600;
49
+ cursor: pointer;
50
+ }
51
+ :where(.cf-board) :where(.cf-choice-chip[data-selected="true"]) {
52
+ border-color: var(--cf-primary);
53
+ background: color-mix(in srgb, var(--cf-primary) 10%, var(--cf-surface));
54
+ color: var(--cf-primary);
55
+ }
56
+ :where(.cf-board) :where(.cf-choice-chip:focus-visible) {
57
+ outline: 3px solid color-mix(in srgb, var(--cf-primary) 25%, transparent);
58
+ outline-offset: 2px;
59
+ }
33
60
  :where(.cf-board) :where(.cf-board__list),
34
61
  :where(.cf-board) :where(.cf-comments) {
35
62
  display: grid;
@@ -198,8 +225,18 @@
198
225
  -webkit-box-orient: vertical;
199
226
  }
200
227
  :where(.cf-board) :where(.cf-state) {
228
+ display: flex;
229
+ min-height: 180px;
230
+ flex-direction: column;
231
+ align-items: center;
232
+ justify-content: center;
233
+ gap: var(--cf-space);
234
+ padding: calc(var(--cf-space) * 1.5);
201
235
  color: var(--cf-muted);
202
- padding: 12px 2px;
236
+ text-align: center;
237
+ }
238
+ :where(.cf-board) :where(.cf-state__message) {
239
+ margin: 0;
203
240
  }
204
241
  :where(.cf-board) :where(.cf-detail),
205
242
  :where(.cf-board) :where(.cf-discussion) {
@@ -6,6 +6,7 @@ import {
6
6
  useMemo,
7
7
  useState,
8
8
  } from "react";
9
+ import type { EntryStatusFilter } from "convex-feedback";
9
10
  import { debounce } from "../helpers";
10
11
  import type {
11
12
  FeedbackScreenBodyContextValue,
@@ -23,6 +24,7 @@ export function FeedbackBodyProvider({
23
24
  maxCommentDepth,
24
25
  debounceDuration,
25
26
  collectMetadata,
27
+ emptyState,
26
28
  collectStandardMetadata,
27
29
  transformComments,
28
30
  renderActor,
@@ -33,6 +35,7 @@ export function FeedbackBodyProvider({
33
35
  const [isSearching, setIsSearching] = useState(false);
34
36
  const [query, setQuery] = useState("");
35
37
  const [debouncedQuery, setDebouncedQuery] = useState("");
38
+ const [statusFilter, setStatusFilter] = useState<EntryStatusFilter>("open");
36
39
 
37
40
  const updateDebouncedQuery = useMemo(
38
41
  () => debounce(setDebouncedQuery, debounceDuration),
@@ -51,12 +54,14 @@ export function FeedbackBodyProvider({
51
54
  showForm,
52
55
  isSearching,
53
56
  selectedEntryId,
57
+ statusFilter,
54
58
 
55
59
  setQuery,
56
60
  setDebouncedQuery,
57
61
  setShowForm,
58
62
  setIsSearching,
59
63
  setSelectedEntryId,
64
+ setStatusFilter,
60
65
 
61
66
  hooks,
62
67
  entrySort,
@@ -65,6 +70,7 @@ export function FeedbackBodyProvider({
65
70
  maxCommentDepth,
66
71
  debounceDuration,
67
72
  collectMetadata,
73
+ emptyState,
68
74
  collectStandardMetadata,
69
75
  transformComments,
70
76
  renderActor,
@@ -1,4 +1,6 @@
1
- import type { EntryKind } from "convex-feedback";
1
+ import type { EntryKind, EntryStatusFilter } from "convex-feedback";
2
+ import type { FeedbackMessages } from "./types/messages.js";
3
+ import type { ChoiceChipOption } from "./types/choice.js";
2
4
 
3
5
  export const kinds: readonly EntryKind[] = [
4
6
  "feedback",
@@ -6,6 +8,24 @@ export const kinds: readonly EntryKind[] = [
6
8
  "bug_report",
7
9
  ];
8
10
 
11
+ export function createEntryLabel(
12
+ enabledKinds: readonly EntryKind[],
13
+ messages: FeedbackMessages,
14
+ ): string {
15
+ return enabledKinds.length === 1
16
+ ? messages.newFeedback[enabledKinds[0]!]
17
+ : messages.board.createEntry;
18
+ }
19
+
20
+ export function entryStatusChoices(
21
+ messages: FeedbackMessages,
22
+ ): readonly ChoiceChipOption<EntryStatusFilter>[] {
23
+ return [
24
+ { value: "open", label: messages.statuses.open },
25
+ { value: "closed", label: messages.statuses.closed },
26
+ ];
27
+ }
28
+
9
29
  export function debounce<TArgs extends unknown[]>(
10
30
  callback: (...args: TArgs) => void,
11
31
  delay: number,
@@ -5,6 +5,7 @@ export const englishFeedbackMessages: FeedbackMessages = {
5
5
  title: "Feedback",
6
6
  subtitle: "Share ideas, report problems, and help shape what comes next.",
7
7
  searchPlaceholder: "Search feedback…",
8
+ statusFilter: "Filter by status",
8
9
  createEntry: "Create feedback",
9
10
  noEntries: "No feedback yet.",
10
11
  noSearchResults: "No matching feedback found.",
@@ -0,0 +1,10 @@
1
+ export interface ChoiceChipOption<Value extends string = string> {
2
+ value: Value;
3
+ label: string;
4
+ }
5
+
6
+ export interface ChoiceChipsBaseProps<Value extends string = string> {
7
+ options: readonly ChoiceChipOption<Value>[];
8
+ value: Value;
9
+ onValueChange: (value: Value) => void;
10
+ }
@@ -1,5 +1,6 @@
1
1
  export type * from "./board.js";
2
2
  export type * from "./comment.js";
3
+ export type * from "./choice.js";
3
4
  export type * from "./context.js";
4
5
  export type * from "./entry.js";
5
6
  export type * from "./form.js";
@@ -18,6 +18,9 @@ export interface FeedbackMessages {
18
18
  /** Search input placeholder. */
19
19
  searchPlaceholder: string;
20
20
 
21
+ /** Accessible label for the open/closed entry status filter. */
22
+ statusFilter: string;
23
+
21
24
  /** CTA for opening the create-entry flow.
22
25
  * This is shown when enabled kinds are two or more.
23
26
  * Use messages.newFeedback for a single-kind entry.
@@ -2,6 +2,7 @@ import type {
2
2
  CommentSort,
3
3
  EntryKind,
4
4
  EntrySort,
5
+ EntryStatusFilter,
5
6
  FeedbackComment,
6
7
  FeedbackEntry,
7
8
  } from "convex-feedback";
@@ -95,6 +96,9 @@ export interface FeedbackScreenBaseProps {
95
96
  * entry kind. Disabled by default.
96
97
  */
97
98
  collectMetadata?: CollectMetadata;
99
+
100
+ /** Optional host-owned content rendered above the empty-state message. */
101
+ emptyState?: ReactNode;
98
102
  }
99
103
 
100
104
  export interface FeedbackScreenRootProps
@@ -106,9 +110,10 @@ export interface FeedbackScreenRootProps
106
110
 
107
111
  export interface FeedbackScreenProviderProps
108
112
  extends
109
- Required<Omit<FeedbackScreenBaseProps, "collectMetadata">>,
113
+ Required<Omit<FeedbackScreenBaseProps, "collectMetadata" | "emptyState">>,
110
114
  FeedbackScreenTransformationProps {
111
115
  collectMetadata?: CollectMetadata;
116
+ emptyState?: ReactNode;
112
117
  collectStandardMetadata: StandardMetadataCollector;
113
118
  }
114
119
 
@@ -119,11 +124,13 @@ export interface FeedbackScreenBodyContextValue
119
124
  showForm: boolean;
120
125
  isSearching: boolean;
121
126
  selectedEntryId: string | null;
127
+ statusFilter: EntryStatusFilter;
122
128
  setQuery: React.Dispatch<React.SetStateAction<string>>;
123
129
  setDebouncedQuery: React.Dispatch<React.SetStateAction<string>>;
124
130
  setShowForm: React.Dispatch<React.SetStateAction<boolean>>;
125
131
  setIsSearching: React.Dispatch<React.SetStateAction<boolean>>;
126
132
  setSelectedEntryId: React.Dispatch<React.SetStateAction<string | null>>;
133
+ setStatusFilter: React.Dispatch<React.SetStateAction<EntryStatusFilter>>;
127
134
  }
128
135
 
129
136
  export type FeedbackScreenBodyProps = FeedbackColorProps;
@@ -143,6 +150,9 @@ export interface FeedbackScreenListProps {
143
150
  * @default true
144
151
  */
145
152
  showSelectedEntry?: boolean;
153
+
154
+ /** Callback for the empty-state create-entry action. */
155
+ onCreateEntry?: () => void;
146
156
  }
147
157
 
148
158
  export interface FeedbackScreenContentProps