convex-feedback-ui 0.1.5 → 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 (131) hide show
  1. package/README.md +143 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/native/expo/ExpoFeedbackBody.d.ts +1 -1
  4. package/dist/native/expo/ExpoFeedbackBody.d.ts.map +1 -1
  5. package/dist/native/expo/ExpoFeedbackBody.js +2 -2
  6. package/dist/native/expo/ExpoFeedbackBody.js.map +1 -1
  7. package/dist/native/expo/FeedbackCreateStackLayout.d.ts +3 -0
  8. package/dist/native/expo/FeedbackCreateStackLayout.d.ts.map +1 -0
  9. package/dist/native/expo/FeedbackCreateStackLayout.js +35 -0
  10. package/dist/native/expo/FeedbackCreateStackLayout.js.map +1 -0
  11. package/dist/native/expo/FeedbackRoutes.d.ts +4 -0
  12. package/dist/native/expo/FeedbackRoutes.d.ts.map +1 -0
  13. package/dist/native/expo/FeedbackRoutes.js +81 -0
  14. package/dist/native/expo/FeedbackRoutes.js.map +1 -0
  15. package/dist/native/expo/FeedbackScreen.d.ts +1 -1
  16. package/dist/native/expo/FeedbackScreen.d.ts.map +1 -1
  17. package/dist/native/expo/FeedbackScreen.js +2 -2
  18. package/dist/native/expo/FeedbackScreen.js.map +1 -1
  19. package/dist/native/expo/FeedbackStack.d.ts +1 -1
  20. package/dist/native/expo/FeedbackStack.d.ts.map +1 -1
  21. package/dist/native/expo/FeedbackStack.js +10 -5
  22. package/dist/native/expo/FeedbackStack.js.map +1 -1
  23. package/dist/native/expo/FeedbackStackLayout.d.ts +3 -0
  24. package/dist/native/expo/FeedbackStackLayout.d.ts.map +1 -0
  25. package/dist/native/expo/FeedbackStackLayout.js +54 -0
  26. package/dist/native/expo/FeedbackStackLayout.js.map +1 -0
  27. package/dist/native/expo/RoutedFeedbackContext.d.ts +4 -0
  28. package/dist/native/expo/RoutedFeedbackContext.d.ts.map +1 -0
  29. package/dist/native/expo/RoutedFeedbackContext.js +11 -0
  30. package/dist/native/expo/RoutedFeedbackContext.js.map +1 -0
  31. package/dist/native/expo/RoutedFeedbackModalContext.d.ts +7 -0
  32. package/dist/native/expo/RoutedFeedbackModalContext.d.ts.map +1 -0
  33. package/dist/native/expo/RoutedFeedbackModalContext.js +7 -0
  34. package/dist/native/expo/RoutedFeedbackModalContext.js.map +1 -0
  35. package/dist/native/expo/index.d.ts +4 -0
  36. package/dist/native/expo/index.d.ts.map +1 -1
  37. package/dist/native/expo/index.js +4 -0
  38. package/dist/native/expo/index.js.map +1 -1
  39. package/dist/native/expo/routes.d.ts +16 -0
  40. package/dist/native/expo/routes.d.ts.map +1 -0
  41. package/dist/native/expo/routes.js +23 -0
  42. package/dist/native/expo/routes.js.map +1 -0
  43. package/dist/native/expo/types.d.ts +53 -1
  44. package/dist/native/expo/types.d.ts.map +1 -1
  45. package/dist/native/native/FeedbackScreen.d.ts +1 -1
  46. package/dist/native/native/FeedbackScreen.d.ts.map +1 -1
  47. package/dist/native/native/FeedbackScreen.js +2 -2
  48. package/dist/native/native/FeedbackScreen.js.map +1 -1
  49. package/dist/native/shared/ui/BodyContent.d.ts +1 -1
  50. package/dist/native/shared/ui/BodyContent.d.ts.map +1 -1
  51. package/dist/native/shared/ui/BodyContent.js +2 -2
  52. package/dist/native/shared/ui/BodyContent.js.map +1 -1
  53. package/dist/native/shared/ui/Button.d.ts +2 -1
  54. package/dist/native/shared/ui/Button.d.ts.map +1 -1
  55. package/dist/native/shared/ui/Button.js +9 -4
  56. package/dist/native/shared/ui/Button.js.map +1 -1
  57. package/dist/native/shared/ui/FeedbackScreenHeader.d.ts.map +1 -1
  58. package/dist/native/shared/ui/FeedbackScreenHeader.js +8 -4
  59. package/dist/native/shared/ui/FeedbackScreenHeader.js.map +1 -1
  60. package/dist/native/shared/ui/FeedbackScreenList.d.ts +1 -1
  61. package/dist/native/shared/ui/FeedbackScreenList.d.ts.map +1 -1
  62. package/dist/native/shared/ui/FeedbackScreenList.js +18 -5
  63. package/dist/native/shared/ui/FeedbackScreenList.js.map +1 -1
  64. package/dist/native/shared/ui/NewEntry.d.ts +10 -0
  65. package/dist/native/shared/ui/NewEntry.d.ts.map +1 -1
  66. package/dist/native/shared/ui/NewEntry.js +7 -14
  67. package/dist/native/shared/ui/NewEntry.js.map +1 -1
  68. package/dist/native/shared/ui/primitives.d.ts +7 -1
  69. package/dist/native/shared/ui/primitives.d.ts.map +1 -1
  70. package/dist/native/shared/ui/primitives.js +38 -0
  71. package/dist/native/shared/ui/primitives.js.map +1 -1
  72. package/dist/react/primitives.d.ts +9 -1
  73. package/dist/react/primitives.d.ts.map +1 -1
  74. package/dist/react/primitives.js +12 -0
  75. package/dist/react/primitives.js.map +1 -1
  76. package/dist/react/screen.d.ts +1 -1
  77. package/dist/react/screen.d.ts.map +1 -1
  78. package/dist/react/screen.js +21 -10
  79. package/dist/react/screen.js.map +1 -1
  80. package/dist/react/styles.css +38 -1
  81. package/dist/shared/context/FeedbackBodyProvider.d.ts +1 -1
  82. package/dist/shared/context/FeedbackBodyProvider.d.ts.map +1 -1
  83. package/dist/shared/context/FeedbackBodyProvider.js +5 -1
  84. package/dist/shared/context/FeedbackBodyProvider.js.map +1 -1
  85. package/dist/shared/helpers.d.ts +5 -1
  86. package/dist/shared/helpers.d.ts.map +1 -1
  87. package/dist/shared/helpers.js +11 -0
  88. package/dist/shared/helpers.js.map +1 -1
  89. package/dist/shared/messages.d.ts.map +1 -1
  90. package/dist/shared/messages.js +1 -0
  91. package/dist/shared/messages.js.map +1 -1
  92. package/dist/shared/types/choice.d.ts +10 -0
  93. package/dist/shared/types/choice.d.ts.map +1 -0
  94. package/dist/shared/types/choice.js +2 -0
  95. package/dist/shared/types/choice.js.map +1 -0
  96. package/dist/shared/types/index.d.ts +1 -0
  97. package/dist/shared/types/index.d.ts.map +1 -1
  98. package/dist/shared/types/messages.d.ts +2 -0
  99. package/dist/shared/types/messages.d.ts.map +1 -1
  100. package/dist/shared/types/screen.d.ts +13 -2
  101. package/dist/shared/types/screen.d.ts.map +1 -1
  102. package/package.json +12 -8
  103. package/src/native/expo/ExpoFeedbackBody.tsx +6 -1
  104. package/src/native/expo/FeedbackCreateStackLayout.tsx +62 -0
  105. package/src/native/expo/FeedbackRoutes.tsx +238 -0
  106. package/src/native/expo/FeedbackScreen.tsx +2 -0
  107. package/src/native/expo/FeedbackStack.tsx +40 -5
  108. package/src/native/expo/FeedbackStackLayout.tsx +118 -0
  109. package/src/native/expo/README.md +24 -0
  110. package/src/native/expo/RoutedFeedbackContext.tsx +20 -0
  111. package/src/native/expo/RoutedFeedbackModalContext.tsx +14 -0
  112. package/src/native/expo/index.ts +4 -0
  113. package/src/native/expo/routes.ts +41 -0
  114. package/src/native/expo/types.ts +65 -1
  115. package/src/native/native/FeedbackScreen.tsx +2 -0
  116. package/src/native/shared/ui/BodyContent.tsx +2 -0
  117. package/src/native/shared/ui/Button.tsx +12 -3
  118. package/src/native/shared/ui/FeedbackScreenHeader.tsx +25 -4
  119. package/src/native/shared/ui/FeedbackScreenList.tsx +48 -8
  120. package/src/native/shared/ui/NewEntry.tsx +12 -30
  121. package/src/native/shared/ui/primitives.tsx +90 -0
  122. package/src/react/primitives.tsx +55 -0
  123. package/src/react/screen.tsx +67 -33
  124. package/src/react/styles.css +38 -1
  125. package/src/shared/context/FeedbackBodyProvider.tsx +6 -0
  126. package/src/shared/helpers.ts +21 -1
  127. package/src/shared/messages.ts +1 -0
  128. package/src/shared/types/choice.ts +10 -0
  129. package/src/shared/types/index.ts +1 -0
  130. package/src/shared/types/messages.ts +3 -0
  131. package/src/shared/types/screen.ts +15 -1
@@ -0,0 +1,41 @@
1
+ import type { Href } from "expo-router";
2
+ import type { FeedbackRouteNames } from "./types.js";
3
+
4
+ export const defaultFeedbackRoutes: FeedbackRouteNames = {
5
+ board: "index",
6
+ entry: "[entryId]",
7
+ create: "new",
8
+ };
9
+
10
+ export function resolveFeedbackRoutes(
11
+ routes: Partial<FeedbackRouteNames> = {},
12
+ ): FeedbackRouteNames {
13
+ const resolved = { ...defaultFeedbackRoutes, ...routes };
14
+
15
+ if (!resolved.entry.includes("[entryId]")) {
16
+ throw new Error(
17
+ 'The routed feedback entry route must contain the "[entryId]" dynamic segment.',
18
+ );
19
+ }
20
+
21
+ return resolved;
22
+ }
23
+
24
+ export function createFeedbackStackSettings(
25
+ routes?: Partial<FeedbackRouteNames>,
26
+ ) {
27
+ return { anchor: resolveFeedbackRoutes(routes).board };
28
+ }
29
+
30
+ export const feedbackStackSettings = createFeedbackStackSettings();
31
+
32
+ /** Anchor the create form beneath suggested-entry detail screens. */
33
+ export const feedbackCreateStackSettings = { anchor: "index" };
34
+
35
+ export function feedbackRouteHref(
36
+ route: string,
37
+ params?: Record<string, string>,
38
+ ): Href {
39
+ const pathname = route.startsWith(".") ? route : `./${route}`;
40
+ return params === undefined ? pathname : { pathname, params };
41
+ }
@@ -1,14 +1,21 @@
1
1
  import type { StackScreenProps } from "expo-router";
2
+ import type { ImageSourcePropType } from "react-native";
2
3
  import type { SearchBarCommands } from "react-native-screens";
3
- import type { FeedbackScreenRootProps } from "../../shared/types";
4
+ import type {
5
+ FeedbackColorProps,
6
+ FeedbackScreenRootProps,
7
+ } from "../../shared/types";
4
8
 
5
9
  interface FeedbackScreenWithStack {
6
10
  useStack?: true;
7
11
  StackOptions?: StackScreenProps["options"];
12
+ /** Android image sources for toolbar actions. iOS continues to use SF Symbols. */
13
+ androidToolbarIcons?: FeedbackAndroidToolbarIcons;
8
14
  }
9
15
  interface FeedbackScreenWithoutStack {
10
16
  useStack?: false;
11
17
  StackOptions?: never;
18
+ androidToolbarIcons?: never;
12
19
  }
13
20
  export type FeedbackScreenStackProps =
14
21
  FeedbackScreenWithStack | FeedbackScreenWithoutStack;
@@ -19,5 +26,62 @@ export type ExpoFeedbackScreenProps = FeedbackScreenStackProps &
19
26
  export interface FeedbackStackProps {
20
27
  searchRef: React.RefObject<SearchBarCommands | null>;
21
28
  stackOptions?: StackScreenProps["options"];
29
+ androidToolbarIcons?: FeedbackAndroidToolbarIcons;
22
30
  children: React.ReactNode;
23
31
  }
32
+
33
+ /** File names used by the routed feedback stack. */
34
+ export interface FeedbackRouteNames {
35
+ /** Board route. @default "index" */
36
+ board: string;
37
+ /** Entry route. Must contain `[entryId]`. @default "[entryId]" */
38
+ entry: string;
39
+ /** Create-entry modal route. @default "new" */
40
+ create: string;
41
+ }
42
+
43
+ export type FeedbackStackScreenOptions = Exclude<
44
+ NonNullable<StackScreenProps["options"]>,
45
+ (...args: never[]) => unknown
46
+ >;
47
+
48
+ export interface FeedbackAndroidToolbarIcons {
49
+ /** Android image source for the create-entry action. */
50
+ create?: ImageSourcePropType;
51
+ /** Android image source for the back action. */
52
+ back?: ImageSourcePropType;
53
+ /** Android image source for modal close actions. */
54
+ close?: ImageSourcePropType;
55
+ /** Android image source for the open/closed status filter. */
56
+ filter?: ImageSourcePropType;
57
+ }
58
+
59
+ export interface FeedbackStackLayoutProps extends FeedbackScreenRootProps {
60
+ /** Partial overrides for the prescribed route file names. */
61
+ routes?: Partial<FeedbackRouteNames>;
62
+ /** Options shared by every feedback screen. */
63
+ screenOptions?: FeedbackStackScreenOptions;
64
+ /** Board screen option overrides. */
65
+ boardOptions?: FeedbackStackScreenOptions;
66
+ /** Entry-detail screen option overrides. */
67
+ entryOptions?: FeedbackStackScreenOptions;
68
+ /** Create-entry modal option overrides. */
69
+ createOptions?: FeedbackStackScreenOptions;
70
+ /** Android image sources for toolbar actions. iOS continues to use SF Symbols. */
71
+ androidToolbarIcons?: FeedbackAndroidToolbarIcons;
72
+ }
73
+
74
+ export interface FeedbackCreateStackLayoutProps {
75
+ /** Options shared by the create form and suggested-entry screens. */
76
+ screenOptions?: FeedbackStackScreenOptions;
77
+ /** Create-form screen option overrides. */
78
+ createOptions?: FeedbackStackScreenOptions;
79
+ /** Suggested-entry screen option overrides. */
80
+ entryOptions?: FeedbackStackScreenOptions;
81
+ }
82
+
83
+ export interface RoutedFeedbackContextValue {
84
+ routes: FeedbackRouteNames;
85
+ colors: FeedbackColorProps;
86
+ androidToolbarIcons: FeedbackAndroidToolbarIcons;
87
+ }
@@ -16,6 +16,7 @@ export function FeedbackScreen({
16
16
  maxCommentDepth = 5,
17
17
  debounceDuration = 300,
18
18
  collectMetadata,
19
+ emptyState,
19
20
  transformComments,
20
21
  renderActor,
21
22
  ...props
@@ -30,6 +31,7 @@ export function FeedbackScreen({
30
31
  maxCommentDepth={maxCommentDepth}
31
32
  debounceDuration={debounceDuration}
32
33
  collectMetadata={collectMetadata}
34
+ emptyState={emptyState}
33
35
  collectStandardMetadata={collectNativeMetadata}
34
36
  transformComments={transformComments}
35
37
  renderActor={renderActor}
@@ -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 { 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";
@@ -8,47 +11,84 @@ import { FeedbackBoard } from "./primitives.js";
8
11
 
9
12
  export function FeedbackScreenList({
10
13
  hideBackButton = false,
14
+ showSelectedEntry = true,
11
15
  onEntryOpen,
16
+ onCreateEntry,
12
17
  }: FeedbackScreenListProps) {
13
18
  const {
14
19
  hooks,
15
20
  entrySort,
16
21
  isSearching,
17
22
  enabledKinds,
23
+ statusFilter,
18
24
  selectedEntryId,
19
25
  debouncedQuery,
20
26
  setSelectedEntryId,
27
+ setShowForm,
28
+ emptyState,
21
29
  } = useFeedbackBody();
22
30
  const { messages, theme } = useFeedbackUi();
23
31
 
24
- const list = hooks.useEntries({ sort: entrySort, kinds: enabledKinds });
32
+ const list = hooks.useEntries({
33
+ sort: entrySort,
34
+ kinds: enabledKinds,
35
+ statusFilter,
36
+ });
25
37
  const search = hooks.useSearchEntries({
26
38
  searchQuery: debouncedQuery,
27
39
  kinds: enabledKinds,
40
+ statusFilter,
28
41
  });
29
42
 
30
43
  const entries = isSearching ? search : list.results;
44
+ const createEntry = onCreateEntry ?? (() => setShowForm(true));
45
+
46
+ const loading = isSearching
47
+ ? search === undefined
48
+ : list.status === "LoadingFirstPage";
31
49
 
32
50
  return (
33
51
  <>
34
- <FeedbackBoard.List style={{ padding: theme.spacing }}>
35
- {selectedEntryId ? (
52
+ {showSelectedEntry && selectedEntryId ? (
53
+ <FeedbackBoard.List style={{ padding: theme.spacing }}>
36
54
  <EntryDetail
37
55
  entryId={selectedEntryId}
38
56
  hideBackButton={hideBackButton}
39
57
  onBack={() => setSelectedEntryId(null)}
40
58
  />
41
- ) : (
42
- (entries ?? []).map((entry) => (
59
+ </FeedbackBoard.List>
60
+ ) : loading ? (
61
+ <FeedbackBoard.State>
62
+ <Text style={{ color: theme.colors.mutedText }}>
63
+ {messages.board.loading}
64
+ </Text>
65
+ </FeedbackBoard.State>
66
+ ) : entries !== undefined && entries.length === 0 ? (
67
+ <FeedbackBoard.State>
68
+ {emptyState}
69
+ <Text style={{ color: theme.colors.mutedText, textAlign: "center" }}>
70
+ {isSearching
71
+ ? messages.board.noSearchResults
72
+ : messages.board.noEntries}
73
+ </Text>
74
+ <Button
75
+ label={createEntryLabel(enabledKinds, messages)}
76
+ onPress={createEntry}
77
+ variant="primary"
78
+ />
79
+ </FeedbackBoard.State>
80
+ ) : (
81
+ <FeedbackBoard.List style={{ padding: theme.spacing }}>
82
+ {(entries ?? []).map((entry) => (
43
83
  <EntryCard
44
84
  key={entry.id}
45
85
  entry={entry}
46
86
  hooks={hooks}
47
87
  onOpen={() => onEntryOpen(entry.id)}
48
88
  />
49
- ))
50
- )}
51
- </FeedbackBoard.List>
89
+ ))}
90
+ </FeedbackBoard.List>
91
+ )}
52
92
 
53
93
  {!isSearching && list.status === "CanLoadMore" && (
54
94
  <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,
@@ -131,7 +131,7 @@ export function CreateEntryModal({
131
131
  onBack={() => setPreviewEntryId(null)}
132
132
  />
133
133
  ) : (
134
- <CreateForm
134
+ <CreateEntryForm
135
135
  kind={kind}
136
136
  onKindChange={setKind}
137
137
  title={title}
@@ -149,7 +149,7 @@ export function CreateEntryModal({
149
149
  );
150
150
  }
151
151
 
152
- function CreateForm({
152
+ export function CreateEntryForm({
153
153
  kind,
154
154
  onKindChange,
155
155
  title,
@@ -247,33 +247,15 @@ function CreateForm({
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 {