convex-feedback-ui 0.1.6 → 0.1.8

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 (102) hide show
  1. package/README.md +21 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/native/expo/FeedbackCreateStackLayout.d.ts.map +1 -1
  4. package/dist/native/expo/FeedbackCreateStackLayout.js +1 -0
  5. package/dist/native/expo/FeedbackCreateStackLayout.js.map +1 -1
  6. package/dist/native/expo/FeedbackRoutes.d.ts.map +1 -1
  7. package/dist/native/expo/FeedbackRoutes.js +12 -7
  8. package/dist/native/expo/FeedbackRoutes.js.map +1 -1
  9. package/dist/native/expo/FeedbackScreen.d.ts +1 -1
  10. package/dist/native/expo/FeedbackScreen.d.ts.map +1 -1
  11. package/dist/native/expo/FeedbackScreen.js +2 -2
  12. package/dist/native/expo/FeedbackScreen.js.map +1 -1
  13. package/dist/native/expo/FeedbackStack.d.ts.map +1 -1
  14. package/dist/native/expo/FeedbackStack.js +10 -6
  15. package/dist/native/expo/FeedbackStack.js.map +1 -1
  16. package/dist/native/expo/FeedbackStackLayout.d.ts +1 -1
  17. package/dist/native/expo/FeedbackStackLayout.d.ts.map +1 -1
  18. package/dist/native/expo/FeedbackStackLayout.js +3 -2
  19. package/dist/native/expo/FeedbackStackLayout.js.map +1 -1
  20. package/dist/native/expo/types.d.ts +2 -0
  21. package/dist/native/expo/types.d.ts.map +1 -1
  22. package/dist/native/native/FeedbackScreen.d.ts +1 -1
  23. package/dist/native/native/FeedbackScreen.d.ts.map +1 -1
  24. package/dist/native/native/FeedbackScreen.js +2 -2
  25. package/dist/native/native/FeedbackScreen.js.map +1 -1
  26. package/dist/native/shared/ui/BodyContent.d.ts +1 -1
  27. package/dist/native/shared/ui/BodyContent.d.ts.map +1 -1
  28. package/dist/native/shared/ui/BodyContent.js +2 -2
  29. package/dist/native/shared/ui/BodyContent.js.map +1 -1
  30. package/dist/native/shared/ui/Button.d.ts +2 -1
  31. package/dist/native/shared/ui/Button.d.ts.map +1 -1
  32. package/dist/native/shared/ui/Button.js +9 -4
  33. package/dist/native/shared/ui/Button.js.map +1 -1
  34. package/dist/native/shared/ui/FeedbackScreenHeader.d.ts.map +1 -1
  35. package/dist/native/shared/ui/FeedbackScreenHeader.js +8 -4
  36. package/dist/native/shared/ui/FeedbackScreenHeader.js.map +1 -1
  37. package/dist/native/shared/ui/FeedbackScreenList.d.ts +1 -1
  38. package/dist/native/shared/ui/FeedbackScreenList.d.ts.map +1 -1
  39. package/dist/native/shared/ui/FeedbackScreenList.js +18 -5
  40. package/dist/native/shared/ui/FeedbackScreenList.js.map +1 -1
  41. package/dist/native/shared/ui/NewEntry.d.ts.map +1 -1
  42. package/dist/native/shared/ui/NewEntry.js +5 -12
  43. package/dist/native/shared/ui/NewEntry.js.map +1 -1
  44. package/dist/native/shared/ui/primitives.d.ts +7 -1
  45. package/dist/native/shared/ui/primitives.d.ts.map +1 -1
  46. package/dist/native/shared/ui/primitives.js +38 -0
  47. package/dist/native/shared/ui/primitives.js.map +1 -1
  48. package/dist/react/primitives.d.ts +9 -1
  49. package/dist/react/primitives.d.ts.map +1 -1
  50. package/dist/react/primitives.js +12 -0
  51. package/dist/react/primitives.js.map +1 -1
  52. package/dist/react/screen.d.ts +1 -1
  53. package/dist/react/screen.d.ts.map +1 -1
  54. package/dist/react/screen.js +22 -11
  55. package/dist/react/screen.js.map +1 -1
  56. package/dist/react/styles.css +38 -1
  57. package/dist/shared/context/FeedbackBodyProvider.d.ts +1 -1
  58. package/dist/shared/context/FeedbackBodyProvider.d.ts.map +1 -1
  59. package/dist/shared/context/FeedbackBodyProvider.js +6 -1
  60. package/dist/shared/context/FeedbackBodyProvider.js.map +1 -1
  61. package/dist/shared/helpers.d.ts +5 -1
  62. package/dist/shared/helpers.d.ts.map +1 -1
  63. package/dist/shared/helpers.js +11 -0
  64. package/dist/shared/helpers.js.map +1 -1
  65. package/dist/shared/messages.d.ts.map +1 -1
  66. package/dist/shared/messages.js +1 -0
  67. package/dist/shared/messages.js.map +1 -1
  68. package/dist/shared/types/choice.d.ts +10 -0
  69. package/dist/shared/types/choice.d.ts.map +1 -0
  70. package/dist/shared/types/choice.js +2 -0
  71. package/dist/shared/types/choice.js.map +1 -0
  72. package/dist/shared/types/index.d.ts +1 -0
  73. package/dist/shared/types/index.d.ts.map +1 -1
  74. package/dist/shared/types/messages.d.ts +2 -0
  75. package/dist/shared/types/messages.d.ts.map +1 -1
  76. package/dist/shared/types/screen.d.ts +12 -2
  77. package/dist/shared/types/screen.d.ts.map +1 -1
  78. package/package.json +6 -2
  79. package/src/native/expo/FeedbackCreateStackLayout.tsx +1 -0
  80. package/src/native/expo/FeedbackRoutes.tsx +41 -3
  81. package/src/native/expo/FeedbackScreen.tsx +4 -0
  82. package/src/native/expo/FeedbackStack.tsx +28 -6
  83. package/src/native/expo/FeedbackStackLayout.tsx +5 -0
  84. package/src/native/expo/README.md +2 -2
  85. package/src/native/expo/types.ts +2 -0
  86. package/src/native/native/FeedbackScreen.tsx +4 -0
  87. package/src/native/shared/ui/BodyContent.tsx +2 -0
  88. package/src/native/shared/ui/Button.tsx +12 -3
  89. package/src/native/shared/ui/FeedbackScreenHeader.tsx +25 -4
  90. package/src/native/shared/ui/FeedbackScreenList.tsx +48 -8
  91. package/src/native/shared/ui/NewEntry.tsx +10 -28
  92. package/src/native/shared/ui/primitives.tsx +90 -0
  93. package/src/react/primitives.tsx +55 -0
  94. package/src/react/screen.tsx +73 -34
  95. package/src/react/styles.css +38 -1
  96. package/src/shared/context/FeedbackBodyProvider.tsx +8 -0
  97. package/src/shared/helpers.ts +21 -1
  98. package/src/shared/messages.ts +1 -0
  99. package/src/shared/types/choice.ts +10 -0
  100. package/src/shared/types/index.ts +1 -0
  101. package/src/shared/types/messages.ts +3 -0
  102. package/src/shared/types/screen.ts +20 -1
@@ -9,6 +9,7 @@ export function FeedbackScreenContent({
9
9
  showHeader = true,
10
10
  hideBackButton = false,
11
11
  onEntryOpen,
12
+ onCreateEntry,
12
13
  ...colors
13
14
  }: FeedbackScreenContentProps) {
14
15
  const { showForm, setShowForm, setSelectedEntryId } = useFeedbackBody();
@@ -26,6 +27,7 @@ export function FeedbackScreenContent({
26
27
  <FeedbackScreenList
27
28
  hideBackButton={hideBackButton}
28
29
  onEntryOpen={handleEntryOpen}
30
+ onCreateEntry={onCreateEntry}
29
31
  />
30
32
  </FeedbackBoard.Root>
31
33
 
@@ -4,24 +4,33 @@ import { useFeedbackUi } from "../../../shared/context/FeedbackProvider.js";
4
4
  export function Button({
5
5
  label,
6
6
  onPress,
7
+ variant = "default",
7
8
  }: {
8
9
  label: string;
9
10
  onPress: () => void;
11
+ variant?: "default" | "primary";
10
12
  }) {
11
13
  const { theme } = useFeedbackUi();
14
+ const primary = variant === "primary";
12
15
  return (
13
16
  <Pressable
14
17
  onPress={onPress}
15
18
  style={{
16
- alignSelf: "flex-start",
19
+ alignSelf: primary ? "center" : "flex-start",
17
20
  paddingVertical: 8,
18
21
  paddingHorizontal: 11,
19
22
  borderRadius: 8,
20
23
  borderWidth: 1,
21
- borderColor: theme.colors.border,
24
+ borderColor: primary ? theme.colors.primary : theme.colors.border,
25
+ backgroundColor: primary ? theme.colors.primary : undefined,
22
26
  }}
23
27
  >
24
- <Text style={{ color: theme.colors.text, fontWeight: "600" }}>
28
+ <Text
29
+ style={{
30
+ color: primary ? theme.colors.primaryForeground : theme.colors.text,
31
+ fontWeight: "600",
32
+ }}
33
+ >
25
34
  {label}
26
35
  </Text>
27
36
  </Pressable>
@@ -2,10 +2,19 @@ import { Pressable, Text } from "react-native";
2
2
  import { useSafeAreaInsets } from "react-native-safe-area-context";
3
3
  import { useFeedbackBody } from "../../../shared/context/FeedbackBodyProvider";
4
4
  import { useFeedbackUi } from "../../../shared/context/FeedbackProvider";
5
- import { FeedbackBoard } from "./primitives";
5
+ import { createEntryLabel, entryStatusChoices } from "../../../shared/helpers";
6
+ import { ChoiceChips, FeedbackBoard } from "./primitives";
6
7
 
7
8
  export function FeedbackScreenHeader() {
8
- const { query, setShowForm, setQuery, enabledKinds } = useFeedbackBody();
9
+ const {
10
+ query,
11
+ setShowForm,
12
+ setQuery,
13
+ setIsSearching,
14
+ enabledKinds,
15
+ statusFilter,
16
+ setStatusFilter,
17
+ } = useFeedbackBody();
9
18
  const { messages, theme } = useFeedbackUi();
10
19
  const { top } = useSafeAreaInsets();
11
20
 
@@ -20,6 +29,12 @@ export function FeedbackScreenHeader() {
20
29
  <Text style={{ color: theme.colors.mutedText }}>
21
30
  {messages.board.subtitle}
22
31
  </Text>
32
+ <ChoiceChips
33
+ accessibilityLabel={messages.board.statusFilter}
34
+ options={entryStatusChoices(messages)}
35
+ value={statusFilter}
36
+ onValueChange={setStatusFilter}
37
+ />
23
38
  <Pressable
24
39
  onPress={() => setShowForm(true)}
25
40
  style={{
@@ -33,10 +48,16 @@ export function FeedbackScreenHeader() {
33
48
  <Text
34
49
  style={{ color: theme.colors.primaryForeground, fontWeight: "700" }}
35
50
  >
36
- {messages.board.createEntry}
51
+ {createEntryLabel(enabledKinds, messages)}
37
52
  </Text>
38
53
  </Pressable>
39
- <FeedbackBoard.Search value={query} onValueChange={(q) => setQuery(q)} />
54
+ <FeedbackBoard.Search
55
+ value={query}
56
+ onValueChange={(q) => {
57
+ setQuery(q);
58
+ setIsSearching(q.trim().length > 0);
59
+ }}
60
+ />
40
61
  </FeedbackBoard.Header>
41
62
  );
42
63
  }
@@ -1,5 +1,8 @@
1
+ import { ActivityIndicator, Text } from "react-native";
2
+
1
3
  import { useFeedbackBody } from "../../../shared/context/FeedbackBodyProvider.js";
2
4
  import { useFeedbackUi } from "../../../shared/context/FeedbackProvider.js";
5
+ import { createEntryLabel } from "../../../shared/helpers.js";
3
6
  import type { FeedbackScreenListProps } from "../../../shared/types/index.js";
4
7
  import { Button } from "./Button.js";
5
8
  import { EntryCard } from "./EntryCard.js";
@@ -10,46 +13,83 @@ export function FeedbackScreenList({
10
13
  hideBackButton = false,
11
14
  showSelectedEntry = true,
12
15
  onEntryOpen,
16
+ onCreateEntry,
13
17
  }: FeedbackScreenListProps) {
14
18
  const {
15
19
  hooks,
16
20
  entrySort,
17
21
  isSearching,
18
22
  enabledKinds,
23
+ statusFilter,
19
24
  selectedEntryId,
20
25
  debouncedQuery,
21
26
  setSelectedEntryId,
27
+ setShowForm,
28
+ emptyState,
29
+ loading: loadingIndicator,
22
30
  } = useFeedbackBody();
23
31
  const { messages, theme } = useFeedbackUi();
24
32
 
25
- const list = hooks.useEntries({ sort: entrySort, kinds: enabledKinds });
33
+ const list = hooks.useEntries({
34
+ sort: entrySort,
35
+ kinds: enabledKinds,
36
+ statusFilter,
37
+ });
26
38
  const search = hooks.useSearchEntries({
27
39
  searchQuery: debouncedQuery,
28
40
  kinds: enabledKinds,
41
+ statusFilter,
29
42
  });
30
43
 
31
44
  const entries = isSearching ? search : list.results;
45
+ const createEntry = onCreateEntry ?? (() => setShowForm(true));
46
+
47
+ const loading = isSearching
48
+ ? search === undefined
49
+ : list.status === "LoadingFirstPage";
32
50
 
33
51
  return (
34
52
  <>
35
- <FeedbackBoard.List style={{ padding: theme.spacing }}>
36
- {showSelectedEntry && selectedEntryId ? (
53
+ {showSelectedEntry && selectedEntryId ? (
54
+ <FeedbackBoard.List style={{ padding: theme.spacing }}>
37
55
  <EntryDetail
38
56
  entryId={selectedEntryId}
39
57
  hideBackButton={hideBackButton}
40
58
  onBack={() => setSelectedEntryId(null)}
41
59
  />
42
- ) : (
43
- (entries ?? []).map((entry) => (
60
+ </FeedbackBoard.List>
61
+ ) : loading ? (
62
+ <FeedbackBoard.State>
63
+ {loadingIndicator ?? (
64
+ <ActivityIndicator size="large" color={theme.colors.primary} />
65
+ )}
66
+ </FeedbackBoard.State>
67
+ ) : entries !== undefined && entries.length === 0 ? (
68
+ <FeedbackBoard.State>
69
+ {emptyState}
70
+ <Text style={{ color: theme.colors.mutedText, textAlign: "center" }}>
71
+ {isSearching
72
+ ? messages.board.noSearchResults
73
+ : messages.board.noEntries}
74
+ </Text>
75
+ <Button
76
+ label={createEntryLabel(enabledKinds, messages)}
77
+ onPress={createEntry}
78
+ variant="primary"
79
+ />
80
+ </FeedbackBoard.State>
81
+ ) : (
82
+ <FeedbackBoard.List style={{ padding: theme.spacing }}>
83
+ {(entries ?? []).map((entry) => (
44
84
  <EntryCard
45
85
  key={entry.id}
46
86
  entry={entry}
47
87
  hooks={hooks}
48
88
  onOpen={() => onEntryOpen(entry.id)}
49
89
  />
50
- ))
51
- )}
52
- </FeedbackBoard.List>
90
+ ))}
91
+ </FeedbackBoard.List>
92
+ )}
53
93
 
54
94
  {!isSearching && list.status === "CanLoadMore" && (
55
95
  <Button
@@ -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,8 @@ export function FeedbackScreen({
52
54
  renderActor,
53
55
  debounceDuration = 300,
54
56
  collectMetadata,
57
+ emptyState,
58
+ loading,
55
59
  ...props
56
60
  }: FeedbackScreenProps) {
57
61
  return (
@@ -64,6 +68,8 @@ export function FeedbackScreen({
64
68
  maxCommentDepth={maxCommentDepth}
65
69
  debounceDuration={debounceDuration}
66
70
  collectMetadata={collectMetadata}
71
+ emptyState={emptyState}
72
+ loading={loading}
67
73
  collectStandardMetadata={collectWebMetadata}
68
74
  transformComments={transformComments}
69
75
  renderActor={renderActor}
@@ -90,16 +96,25 @@ function FeedbackScreenInner({
90
96
  setShowForm,
91
97
  setSelectedEntryId,
92
98
  selectedEntryId,
99
+ statusFilter,
100
+ setStatusFilter,
93
101
  query,
94
102
  setQuery,
95
103
  debouncedQuery,
104
+ emptyState,
105
+ loading: loadingIndicator,
96
106
  } = useFeedbackBody();
97
107
  const { messages } = useFeedbackUi();
98
108
 
99
- const list = hooks.useEntries({ sort: entrySort, kinds: enabledKinds });
109
+ const list = hooks.useEntries({
110
+ sort: entrySort,
111
+ kinds: enabledKinds,
112
+ statusFilter,
113
+ });
100
114
  const searchResults = hooks.useSearchEntries({
101
115
  searchQuery: debouncedQuery,
102
116
  kinds: enabledKinds,
117
+ statusFilter,
103
118
  });
104
119
 
105
120
  if (selectedEntryId !== null) {
@@ -115,7 +130,7 @@ function FeedbackScreenInner({
115
130
 
116
131
  const searching = query.trim().length > 0;
117
132
  const entries = searching ? searchResults : list.results;
118
- const loading = searching
133
+ const isLoading = searching
119
134
  ? searchResults === undefined
120
135
  : list.status === "LoadingFirstPage";
121
136
 
@@ -125,13 +140,21 @@ function FeedbackScreenInner({
125
140
  <div>
126
141
  <FeedbackBoard.Title />
127
142
  <p className="cf-board__subtitle">{messages.board.subtitle}</p>
143
+ <ChoiceChips
144
+ aria-label={messages.board.statusFilter}
145
+ options={entryStatusChoices(messages)}
146
+ value={statusFilter}
147
+ onValueChange={setStatusFilter}
148
+ />
128
149
  </div>
129
150
  <button
130
151
  type="button"
131
152
  className="cf-button cf-button--primary"
132
153
  onClick={() => setShowForm((current) => !current)}
133
154
  >
134
- {showForm ? messages.form.cancel : messages.board.createEntry}
155
+ {showForm
156
+ ? messages.form.cancel
157
+ : createEntryLabel(enabledKinds, messages)}
135
158
  </button>
136
159
  </FeedbackBoard.Header>
137
160
 
@@ -146,26 +169,41 @@ function FeedbackScreenInner({
146
169
 
147
170
  <FeedbackBoard.Search value={query} onValueChange={setQuery} />
148
171
 
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>
172
+ {isLoading ? (
173
+ <FeedbackBoard.State>
174
+ {loadingIndicator ?? (
175
+ <span className="cf-state__message">{messages.board.loading}</span>
176
+ )}
177
+ </FeedbackBoard.State>
178
+ ) : entries !== undefined && entries.length === 0 ? (
179
+ <FeedbackBoard.State>
180
+ {emptyState}
181
+ <span className="cf-state__message">
182
+ {searching
183
+ ? messages.board.noSearchResults
184
+ : messages.board.noEntries}
185
+ </span>
186
+ <button
187
+ type="button"
188
+ className="cf-button cf-button--primary"
189
+ onClick={() => setShowForm(true)}
190
+ >
191
+ {createEntryLabel(enabledKinds, messages)}
192
+ </button>
193
+ </FeedbackBoard.State>
194
+ ) : (
195
+ <FeedbackBoard.List>
196
+ {(entries ?? []).map((entry) => (
197
+ <EntryCard
198
+ key={entry.id}
199
+ entry={entry}
200
+ hooks={hooks}
201
+ onOpen={() => setSelectedEntryId(entry.id)}
202
+ />
203
+ ))}
204
+ </FeedbackBoard.List>
156
205
  )}
157
206
 
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
207
  {!searching && list.status === "CanLoadMore" && (
170
208
  <button
171
209
  type="button"
@@ -273,19 +311,20 @@ function CreateEntryForm({ onCreated }: FeedbackScreenEntryModalProps) {
273
311
 
274
312
  return (
275
313
  <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>
314
+ {enabledKinds.length > 1 && (
315
+ <label className="cf-field">
316
+ <span>{messages.form.kind}</span>
317
+ <ChoiceChips
318
+ aria-label={messages.form.kind}
319
+ options={enabledKinds.map((value) => ({
320
+ value,
321
+ label: messages.kinds[value],
322
+ }))}
323
+ value={kind}
324
+ onValueChange={setKind}
325
+ />
326
+ </label>
327
+ )}
289
328
  <label className="cf-field">
290
329
  <span>{messages.form.title}</span>
291
330
  <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) {