create-feedbax 0.1.0 → 0.2.0

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 (210) hide show
  1. package/LICENSE +201 -21
  2. package/README.md +11 -1
  3. package/dist/cli.js +1261 -26
  4. package/dist/index.js +1402 -160
  5. package/package.json +40 -14
  6. package/template/.feedbax-template.sha256 +1 -0
  7. package/template/.gitignore.template +6 -0
  8. package/template/.npmignore.template +5 -0
  9. package/template/.prettierignore.template +4 -0
  10. package/template/LICENSE +201 -0
  11. package/template/components.json +25 -0
  12. package/template/eslint.config.mjs +25 -0
  13. package/template/package.json +70 -14
  14. package/template/public/feedbax-mark.svg +4 -0
  15. package/template/public/robots.txt +3 -0
  16. package/template/src/authorized-draft-post.test.tsx +212 -0
  17. package/template/src/authorized-draft-post.tsx +204 -0
  18. package/template/src/authorized-draft-query.ts +74 -0
  19. package/template/src/browser-post-state.test.ts +120 -0
  20. package/template/src/browser-post-state.ts +134 -0
  21. package/template/src/browser-vote-state.test.ts +14 -0
  22. package/template/src/browser-vote-state.ts +27 -0
  23. package/template/src/capability-authorization-error.ts +3 -0
  24. package/template/src/changelog-page.test.tsx +139 -0
  25. package/template/src/changelog-page.tsx +219 -0
  26. package/template/src/changelog-query.ts +13 -0
  27. package/template/src/changelog-runtime.ts +27 -0
  28. package/template/src/cloudflare-turnstile.ts +31 -0
  29. package/template/src/cloudflare-workers-test.ts +3 -0
  30. package/template/src/cloudflare-workers.d.ts +18 -0
  31. package/template/src/comment-creation.test.tsx +270 -0
  32. package/template/src/comment-profile.ts +5 -0
  33. package/template/src/comment-thread-page.ts +18 -0
  34. package/template/src/components/command-palette-context.tsx +71 -0
  35. package/template/src/components/command-palette.tsx +246 -0
  36. package/template/src/components/create-post-trigger.tsx +44 -0
  37. package/template/src/components/device-profile-provider.tsx +309 -0
  38. package/template/src/components/header.tsx +95 -0
  39. package/template/src/components/loader.tsx +10 -0
  40. package/template/src/components/profile-menu.tsx +89 -0
  41. package/template/src/components/ui/components/alert-dialog.tsx +185 -0
  42. package/template/src/components/ui/components/attachment.tsx +207 -0
  43. package/template/src/components/ui/components/avatar.tsx +107 -0
  44. package/template/src/components/ui/components/badge.tsx +52 -0
  45. package/template/src/components/ui/components/bubble.tsx +128 -0
  46. package/template/src/components/ui/components/button-group.tsx +87 -0
  47. package/template/src/components/ui/components/button.tsx +56 -0
  48. package/template/src/components/ui/components/card.tsx +103 -0
  49. package/template/src/components/ui/components/checkbox.tsx +26 -0
  50. package/template/src/components/ui/components/command.tsx +194 -0
  51. package/template/src/components/ui/components/dialog.tsx +154 -0
  52. package/template/src/components/ui/components/drawer.tsx +229 -0
  53. package/template/src/components/ui/components/dropdown-menu.tsx +270 -0
  54. package/template/src/components/ui/components/empty.tsx +101 -0
  55. package/template/src/components/ui/components/field.tsx +236 -0
  56. package/template/src/components/ui/components/input-group.tsx +155 -0
  57. package/template/src/components/ui/components/input.tsx +20 -0
  58. package/template/src/components/ui/components/item.tsx +201 -0
  59. package/template/src/components/ui/components/kbd.tsx +26 -0
  60. package/template/src/components/ui/components/label.tsx +18 -0
  61. package/template/src/components/ui/components/marker.tsx +71 -0
  62. package/template/src/components/ui/components/message-scroller.tsx +128 -0
  63. package/template/src/components/ui/components/message.tsx +92 -0
  64. package/template/src/components/ui/components/native-select.tsx +65 -0
  65. package/template/src/components/ui/components/popover.tsx +88 -0
  66. package/template/src/components/ui/components/portal-primitives.test.tsx +85 -0
  67. package/template/src/components/ui/components/radio-group.tsx +36 -0
  68. package/template/src/components/ui/components/scroll-area.tsx +52 -0
  69. package/template/src/components/ui/components/secondary-form-actions.test.tsx +23 -0
  70. package/template/src/components/ui/components/select.tsx +199 -0
  71. package/template/src/components/ui/components/separator.tsx +23 -0
  72. package/template/src/components/ui/components/sheet.tsx +135 -0
  73. package/template/src/components/ui/components/skeleton.tsx +13 -0
  74. package/template/src/components/ui/components/sonner.tsx +43 -0
  75. package/template/src/components/ui/components/spinner.tsx +16 -0
  76. package/template/src/components/ui/components/tabs.tsx +80 -0
  77. package/template/src/components/ui/components/textarea.tsx +18 -0
  78. package/template/src/components/ui/components/tooltip.tsx +64 -0
  79. package/template/src/components/ui/hooks/.gitkeep +0 -0
  80. package/template/src/components/ui/lib/utils.ts +6 -0
  81. package/template/src/components/ui/styles/globals.css +151 -0
  82. package/template/src/create-post-overlay.tsx +82 -0
  83. package/template/src/draft-post-list.tsx +39 -0
  84. package/template/src/feedback-cache-invalidation.test.ts +185 -0
  85. package/template/src/feedback-cache-invalidation.ts +97 -0
  86. package/template/src/feedback-runtime.ts +38 -0
  87. package/template/src/feedbax/changelog/index.test.ts +156 -0
  88. package/template/src/feedbax/changelog/index.ts +206 -0
  89. package/template/src/feedbax/changelog/notion-changelog.test.ts +395 -0
  90. package/template/src/feedbax/changelog/notion-changelog.ts +400 -0
  91. package/template/src/feedbax/config/index.test.ts +111 -0
  92. package/template/src/feedbax/config/index.ts +271 -0
  93. package/template/src/feedbax/feedback/feedback-storage.ts +43 -0
  94. package/template/src/feedbax/feedback/index.test.ts +900 -0
  95. package/template/src/feedbax/feedback/index.ts +942 -0
  96. package/template/src/feedbax/feedback/notion-comments.test.ts +171 -0
  97. package/template/src/feedbax/feedback/notion-comments.ts +231 -0
  98. package/template/src/feedbax/feedback/notion-data-source.test.ts +364 -0
  99. package/template/src/feedbax/feedback/notion-data-source.ts +458 -0
  100. package/template/src/feedbax/feedback/notion-feedback.test.ts +998 -0
  101. package/template/src/feedbax/feedback/notion-feedback.ts +651 -0
  102. package/template/src/feedbax/feedback/notion-request.ts +74 -0
  103. package/template/src/feedbax.test-fixture.ts +64 -0
  104. package/template/src/feedbax.ts +69 -0
  105. package/template/src/index.css +2138 -0
  106. package/template/src/lazy-create-post-overlay.tsx +6 -0
  107. package/template/src/lazy-post-detail-overlay.tsx +10 -0
  108. package/template/src/masked-post-link.tsx +27 -0
  109. package/template/src/overlay-post-detail.test.tsx +130 -0
  110. package/template/src/overlay-post-detail.tsx +92 -0
  111. package/template/src/portal-comments.test.ts +375 -0
  112. package/template/src/portal-comments.ts +384 -0
  113. package/template/src/portal-features.test.tsx +150 -0
  114. package/template/src/portal-feedback-form.tsx +515 -0
  115. package/template/src/portal-feedback-runtime.test.ts +32 -0
  116. package/template/src/portal-feedback-runtime.ts +55 -0
  117. package/template/src/portal-feedback-server-function.ts +93 -0
  118. package/template/src/portal-feedback-submission.test.ts +307 -0
  119. package/template/src/portal-feedback-submission.ts +214 -0
  120. package/template/src/portal-head.ts +58 -0
  121. package/template/src/portal-server-entry.ts +64 -0
  122. package/template/src/portal-voting.test.ts +106 -0
  123. package/template/src/portal-voting.ts +149 -0
  124. package/template/src/post-badges.tsx +27 -0
  125. package/template/src/post-creation-flow.test.tsx +230 -0
  126. package/template/src/post-creation-flow.tsx +88 -0
  127. package/template/src/post-detail-overlay.test.tsx +114 -0
  128. package/template/src/post-detail-overlay.tsx +67 -0
  129. package/template/src/post-feed-controls.test.tsx +239 -0
  130. package/template/src/post-feed-controls.tsx +298 -0
  131. package/template/src/post-presentation.ts +37 -0
  132. package/template/src/post-queries.test.ts +158 -0
  133. package/template/src/post-queries.ts +26 -0
  134. package/template/src/post-results.tsx +165 -0
  135. package/template/src/public-cache-policy.test.ts +141 -0
  136. package/template/src/public-cache-policy.ts +119 -0
  137. package/template/src/public-cache-tags.ts +7 -0
  138. package/template/src/public-changelog-page.ts +15 -0
  139. package/template/src/public-changelog-server-function.ts +27 -0
  140. package/template/src/public-date.ts +11 -0
  141. package/template/src/public-failure-state.test.tsx +69 -0
  142. package/template/src/public-feedback-index.tsx +124 -0
  143. package/template/src/public-feedback-page.test.tsx +290 -0
  144. package/template/src/public-feedback-page.ts +75 -0
  145. package/template/src/public-feedback-server-function.ts +22 -0
  146. package/template/src/public-portal-error.tsx +27 -0
  147. package/template/src/public-post-detail.tsx +682 -0
  148. package/template/src/public-post-page.test.tsx +173 -0
  149. package/template/src/public-post-page.ts +15 -0
  150. package/template/src/public-post-route.test.tsx +214 -0
  151. package/template/src/public-post-route.tsx +171 -0
  152. package/template/src/public-post-server-function.ts +48 -0
  153. package/template/src/public-post-unavailable.tsx +10 -0
  154. package/template/src/public-roadmap-page.test.tsx +139 -0
  155. package/template/src/public-roadmap-page.ts +24 -0
  156. package/template/src/public-roadmap-route.test.tsx +218 -0
  157. package/template/src/public-roadmap-route.tsx +50 -0
  158. package/template/src/public-roadmap-server-function.ts +44 -0
  159. package/template/src/public-roadmap-view.tsx +378 -0
  160. package/template/src/public-shell.test.tsx +518 -0
  161. package/template/src/roadmap-query.ts +21 -0
  162. package/template/src/root-branding.test.ts +53 -0
  163. package/template/src/routeTree.gen.ts +131 -0
  164. package/template/src/routed-post-creation-form.tsx +28 -0
  165. package/template/src/router.tsx +25 -0
  166. package/template/src/routes/__root.tsx +116 -0
  167. package/template/src/routes/changelog.tsx +39 -0
  168. package/template/src/routes/index.tsx +61 -0
  169. package/template/src/routes/p.$slug.tsx +16 -0
  170. package/template/src/routes/roadmap.tsx +11 -0
  171. package/template/src/routes/submit.tsx +28 -0
  172. package/template/src/safe-public-failure.ts +45 -0
  173. package/template/src/server.ts +111 -0
  174. package/template/src/similar-posts.tsx +45 -0
  175. package/template/src/submit-route.test.tsx +134 -0
  176. package/template/src/trusted-feedback-handler.test.ts +425 -0
  177. package/template/src/trusted-feedback-handler.ts +181 -0
  178. package/template/src/trusted-feedback-runtime.ts +17 -0
  179. package/template/src/turnstile-configuration.test.ts +37 -0
  180. package/template/src/turnstile-configuration.ts +19 -0
  181. package/template/src/use-media-query.ts +17 -0
  182. package/template/src/vote-toggle.test.tsx +171 -0
  183. package/template/src/vote-toggle.tsx +211 -0
  184. package/template/tsconfig.json +45 -0
  185. package/template/vite.config.ts +20 -0
  186. package/template/vitest.config.ts +4 -0
  187. package/template/vitest.setup.ts +3 -0
  188. package/template/wrangler.jsonc +35 -0
  189. package/dist/cli.d.ts +0 -3
  190. package/dist/cli.d.ts.map +0 -1
  191. package/dist/cli.js.map +0 -1
  192. package/dist/index.d.ts +0 -21
  193. package/dist/index.d.ts.map +0 -1
  194. package/dist/index.js.map +0 -1
  195. package/template/.env.example +0 -4
  196. package/template/README.md +0 -7
  197. package/template/apps/portal/components.json +0 -20
  198. package/template/apps/portal/package.json +0 -31
  199. package/template/apps/portal/src/lib/utils.ts +0 -6
  200. package/template/apps/portal/src/routeTree.gen.ts +0 -59
  201. package/template/apps/portal/src/router.tsx +0 -10
  202. package/template/apps/portal/src/routes/__root.tsx +0 -31
  203. package/template/apps/portal/src/routes/index.tsx +0 -35
  204. package/template/apps/portal/src/styles.css +0 -91
  205. package/template/apps/portal/tsconfig.json +0 -15
  206. package/template/apps/portal/vite.config.ts +0 -13
  207. package/template/feedbax.config.ts +0 -13
  208. package/template/feedbax.jsonc +0 -11
  209. package/template/pnpm-workspace.yaml +0 -3
  210. package/template/turbo.json +0 -8
@@ -0,0 +1,114 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
4
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
5
+ import {
6
+ Link,
7
+ Outlet,
8
+ RouterProvider,
9
+ createMemoryHistory,
10
+ createRootRoute,
11
+ createRoute,
12
+ createRouter,
13
+ } from "@tanstack/react-router";
14
+ import { afterEach, expect, it, vi } from "vitest";
15
+
16
+ import { PostDetailOverlay } from "./post-detail-overlay";
17
+
18
+ afterEach(() => {
19
+ cleanup();
20
+ vi.unstubAllGlobals();
21
+ });
22
+
23
+ it("masks a Post detail over its originating route and Back restores that route", async () => {
24
+ const history = createMemoryHistory({ initialEntries: ["/?query=search"] });
25
+ const router = createTestRouter(history);
26
+
27
+ render(
28
+ <QueryClientProvider client={new QueryClient()}>
29
+ <RouterProvider router={router} />
30
+ </QueryClientProvider>,
31
+ );
32
+ const origin = await screen.findByRole("link", { name: "Open Post" });
33
+ origin.focus();
34
+ fireEvent.click(origin);
35
+
36
+ expect(
37
+ await screen.findByRole("dialog", { name: "Post details" }),
38
+ ).toBeTruthy();
39
+ expect(document.body.textContent).toContain("Feed remains visible");
40
+ expect(history.location.pathname).toBe("/p/masked-post");
41
+
42
+ fireEvent.click(screen.getByRole("button", { name: "Close" }));
43
+ await vi.waitFor(() => expect(history.location.pathname).toBe("/"));
44
+ expect(history.location.search).toBe("?query=search");
45
+ await vi.waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
46
+ await vi.waitFor(() => expect(document.activeElement).toBe(origin));
47
+ });
48
+
49
+ it("uses a Drawer on mobile and follows browser Back and Forward history", async () => {
50
+ vi.stubGlobal("matchMedia", (query: string) => ({
51
+ matches: query === "(max-width: 720px)",
52
+ media: query,
53
+ addEventListener: vi.fn(),
54
+ removeEventListener: vi.fn(),
55
+ }));
56
+ const history = createMemoryHistory({ initialEntries: ["/?query=search"] });
57
+ const router = createTestRouter(history);
58
+
59
+ render(
60
+ <QueryClientProvider client={new QueryClient()}>
61
+ <RouterProvider router={router} />
62
+ </QueryClientProvider>,
63
+ );
64
+ fireEvent.click(await screen.findByRole("link", { name: "Open Post" }));
65
+
66
+ const drawer = await screen.findByRole("dialog", { name: "Post details" });
67
+ expect(drawer.closest('[data-slot="drawer-popup"]')).toBeTruthy();
68
+ history.back();
69
+ await vi.waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
70
+ expect(history.location.search).toBe("?query=search");
71
+
72
+ history.forward();
73
+ expect(
74
+ await screen.findByRole("dialog", { name: "Post details" }),
75
+ ).toBeTruthy();
76
+ expect(history.location.pathname).toBe("/p/masked-post");
77
+ });
78
+
79
+ function createTestRouter(history: ReturnType<typeof createMemoryHistory>) {
80
+ const root = createRootRoute({
81
+ component: () => (
82
+ <>
83
+ <Outlet />
84
+ <PostDetailOverlay renderDetail={(slug) => <article>{slug}</article>} />
85
+ </>
86
+ ),
87
+ });
88
+ const index = createRoute({
89
+ getParentRoute: () => root,
90
+ path: "/",
91
+ component: () => (
92
+ <main>
93
+ Feed remains visible
94
+ <Link
95
+ to="."
96
+ state={{ postDetailOverlay: { slug: "masked-post" } }}
97
+ mask={{
98
+ to: "/p/$slug",
99
+ params: { slug: "masked-post" },
100
+ unmaskOnReload: true,
101
+ }}
102
+ >
103
+ Open Post
104
+ </Link>
105
+ </main>
106
+ ),
107
+ });
108
+ const post = createRoute({
109
+ getParentRoute: () => root,
110
+ path: "/p/$slug",
111
+ component: () => <main>Full page</main>,
112
+ });
113
+ return createRouter({ history, routeTree: root.addChildren([index, post]) });
114
+ }
@@ -0,0 +1,67 @@
1
+ import {
2
+ Dialog,
3
+ DialogContent,
4
+ DialogDescription,
5
+ DialogHeader,
6
+ DialogTitle,
7
+ } from "@feedbax/ui/components/dialog";
8
+ import {
9
+ Drawer,
10
+ DrawerContent,
11
+ DrawerDescription,
12
+ DrawerHeader,
13
+ DrawerTitle,
14
+ } from "@feedbax/ui/components/drawer";
15
+ import { useRouter, useRouterState } from "@tanstack/react-router";
16
+ import { type ReactNode } from "react";
17
+ import { useMediaQuery } from "./use-media-query";
18
+
19
+ declare module "@tanstack/react-router" {
20
+ interface HistoryState {
21
+ postDetailOverlay?: { slug: string };
22
+ }
23
+ }
24
+
25
+ export function PostDetailOverlay({
26
+ renderDetail,
27
+ }: {
28
+ renderDetail: (slug: string) => ReactNode;
29
+ }) {
30
+ const router = useRouter();
31
+ const request = useRouterState({
32
+ select: (state) => state.location.state.postDetailOverlay,
33
+ });
34
+ const mobile = useMediaQuery("(max-width: 720px)");
35
+ const close = (open: boolean) => {
36
+ if (!open) router.history.back();
37
+ };
38
+
39
+ if (!request) return null;
40
+ const detail = renderDetail(request.slug);
41
+
42
+ if (mobile) {
43
+ return (
44
+ <Drawer open onOpenChange={close}>
45
+ <DrawerContent className="post-detail-drawer">
46
+ <DrawerHeader className="sr-only">
47
+ <DrawerTitle>Post details</DrawerTitle>
48
+ <DrawerDescription>View and manage this Post.</DrawerDescription>
49
+ </DrawerHeader>
50
+ {detail}
51
+ </DrawerContent>
52
+ </Drawer>
53
+ );
54
+ }
55
+
56
+ return (
57
+ <Dialog open onOpenChange={close}>
58
+ <DialogContent className="post-detail-dialog">
59
+ <DialogHeader className="sr-only">
60
+ <DialogTitle>Post details</DialogTitle>
61
+ <DialogDescription>View and manage this Post.</DialogDescription>
62
+ </DialogHeader>
63
+ {detail}
64
+ </DialogContent>
65
+ </Dialog>
66
+ );
67
+ }
@@ -0,0 +1,239 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import {
4
+ cleanup,
5
+ fireEvent,
6
+ render,
7
+ screen,
8
+ within,
9
+ } from "@testing-library/react";
10
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
11
+ import { useState } from "react";
12
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
13
+
14
+ import {
15
+ BoardNavigation,
16
+ MobilePostFilters,
17
+ PostFeedControls,
18
+ StatusNavigation,
19
+ } from "./post-feed-controls";
20
+ import {
21
+ CommandPalette,
22
+ CommandPaletteProvider,
23
+ } from "./components/command-palette";
24
+ import type { PublicPostQuery } from "@feedbax/feedback";
25
+
26
+ beforeEach(() => {
27
+ window.ResizeObserver = class {
28
+ observe() {}
29
+ unobserve() {}
30
+ disconnect() {}
31
+ } as unknown as typeof ResizeObserver;
32
+ window.HTMLElement.prototype.scrollIntoView = function () {};
33
+ });
34
+
35
+ afterEach(() => {
36
+ cleanup();
37
+ vi.restoreAllMocks();
38
+ vi.unstubAllGlobals();
39
+ });
40
+
41
+ describe("Post feed controls", () => {
42
+ it("opens the mobile filters sheet with every Board and Post Status", async () => {
43
+ render(<MobileFiltersHarness />);
44
+
45
+ fireEvent.click(screen.getByRole("button", { name: "Filters" }));
46
+
47
+ const sheet = await screen.findByRole("dialog", {
48
+ name: "Filter Posts",
49
+ });
50
+ expect(
51
+ screen.getByRole("navigation", { name: "Mobile Boards" }),
52
+ ).toBeTruthy();
53
+ expect(
54
+ screen.getByRole("group", { name: "Mobile Status filters" }),
55
+ ).toBeTruthy();
56
+ expect(screen.getByRole("button", { name: "All posts" })).toBeTruthy();
57
+ for (const status of [
58
+ "New",
59
+ "Reviewing",
60
+ "Planned",
61
+ "In progress",
62
+ "Shipped",
63
+ "Closed",
64
+ ]) {
65
+ expect(screen.getByRole("button", { name: status })).toBeTruthy();
66
+ }
67
+ expect(sheet).toBeTruthy();
68
+ });
69
+
70
+ it("applies mobile Board and Post Status selections to route query state", async () => {
71
+ render(<MobileFiltersHarness />);
72
+ fireEvent.click(screen.getByRole("button", { name: "Filters" }));
73
+ await screen.findByRole("dialog", { name: "Filter Posts" });
74
+
75
+ fireEvent.click(screen.getByRole("button", { name: "Feature requests" }));
76
+ fireEvent.click(screen.getByRole("button", { name: "New" }));
77
+ fireEvent.click(screen.getByRole("button", { name: "Closed" }));
78
+
79
+ expect(screen.getByTestId("route-state").textContent).toContain(
80
+ '"types":["Feature Request"]',
81
+ );
82
+ expect(screen.getByTestId("route-state").textContent).toContain(
83
+ '"statuses":["New","Closed"]',
84
+ );
85
+
86
+ fireEvent.click(screen.getByRole("button", { name: "New" }));
87
+ expect(screen.getByTestId("route-state").textContent).toContain(
88
+ '"statuses":["Closed"]',
89
+ );
90
+ });
91
+
92
+ it("switches sort from visible tabs immediately", () => {
93
+ render(<Harness />);
94
+
95
+ fireEvent.click(screen.getByRole("button", { name: "New" }));
96
+ expect(screen.getByTestId("route-state").textContent).toContain(
97
+ '"sort":"new"',
98
+ );
99
+ });
100
+
101
+ it("exposes the mobile sort, create, search, and filter controls in tap order", async () => {
102
+ stubViewport(true);
103
+ render(<Harness />);
104
+
105
+ const controls = screen.getByRole("group", {
106
+ name: "Post feed controls",
107
+ });
108
+ await screen.findByRole("button", { name: "Filters" });
109
+ expect(
110
+ within(controls)
111
+ .getAllByRole("button")
112
+ .map((button) => button.textContent),
113
+ ).toEqual(["Top", "New", "New post", "Search", "Filters"]);
114
+ });
115
+
116
+ it("keeps the mobile Filters control out of the desktop breakpoint", () => {
117
+ stubViewport(false);
118
+ render(<Harness />);
119
+
120
+ expect(screen.queryByRole("button", { name: "Filters" })).toBeNull();
121
+ });
122
+
123
+ it("opens the command palette from the Search button", async () => {
124
+ render(<Harness withPalette />);
125
+
126
+ fireEvent.click(screen.getByRole("button", { name: "Search" }));
127
+
128
+ expect(await screen.findByRole("dialog")).toBeTruthy();
129
+ });
130
+
131
+ it("selects one Board at a time and reselecting it returns to All posts", () => {
132
+ render(<Harness />);
133
+
134
+ fireEvent.click(screen.getByRole("button", { name: "Feature requests" }));
135
+ expect(screen.getByTestId("route-state").textContent).toContain(
136
+ '"types":["Feature Request"]',
137
+ );
138
+
139
+ fireEvent.click(screen.getByRole("button", { name: "Bug reports" }));
140
+ expect(screen.getByTestId("route-state").textContent).toContain(
141
+ '"types":["Bug Report"]',
142
+ );
143
+
144
+ fireEvent.click(screen.getByRole("button", { name: "Bug reports" }));
145
+ expect(screen.getByTestId("route-state").textContent).not.toContain(
146
+ '"types"',
147
+ );
148
+ expect(
149
+ screen
150
+ .getByRole("button", { name: "All posts" })
151
+ .getAttribute("aria-pressed"),
152
+ ).toBe("true");
153
+ });
154
+
155
+ it("combines and toggles roadmap Post Status filters", () => {
156
+ render(<Harness />);
157
+
158
+ fireEvent.click(screen.getByRole("button", { name: "Planned" }));
159
+ fireEvent.click(screen.getByRole("button", { name: "Shipped" }));
160
+ expect(screen.getByTestId("route-state").textContent).toContain(
161
+ '"statuses":["Planned","Shipped"]',
162
+ );
163
+
164
+ fireEvent.click(screen.getByRole("button", { name: "Planned" }));
165
+ expect(screen.getByTestId("route-state").textContent).toContain(
166
+ '"statuses":["Shipped"]',
167
+ );
168
+
169
+ fireEvent.click(screen.getByRole("button", { name: "Shipped" }));
170
+ expect(screen.getByTestId("route-state").textContent).not.toContain(
171
+ '"statuses"',
172
+ );
173
+ });
174
+
175
+ it("announces route updates locally from the feed controls", () => {
176
+ render(<Harness pending />);
177
+
178
+ expect(
179
+ screen
180
+ .getByRole("group", { name: "Post feed controls" })
181
+ .getAttribute("aria-busy"),
182
+ ).toBe("true");
183
+ expect(screen.getByText("Updating Posts…").className).toBe("sr-only");
184
+ });
185
+ });
186
+
187
+ function MobileFiltersHarness() {
188
+ const [search, setSearch] = useState<PublicPostQuery>({ sort: "trending" });
189
+ return (
190
+ <>
191
+ <MobilePostFilters search={search} onSearchChange={setSearch} />
192
+ <output data-testid="route-state">{JSON.stringify(search)}</output>
193
+ </>
194
+ );
195
+ }
196
+
197
+ function stubViewport(mobile: boolean) {
198
+ vi.stubGlobal("matchMedia", (query: string) => ({
199
+ matches: mobile && query === "(max-width: 1023px)",
200
+ media: query,
201
+ addEventListener: vi.fn(),
202
+ removeEventListener: vi.fn(),
203
+ }));
204
+ }
205
+
206
+ function Harness({
207
+ pending = false,
208
+ withPalette = false,
209
+ }: {
210
+ pending?: boolean;
211
+ withPalette?: boolean;
212
+ } = {}) {
213
+ const [search, setSearch] = useState<PublicPostQuery>({
214
+ sort: "trending",
215
+ });
216
+ return (
217
+ <QueryClientProvider
218
+ client={
219
+ new QueryClient({ defaultOptions: { queries: { retry: false } } })
220
+ }
221
+ >
222
+ <CommandPaletteProvider>
223
+ <PostFeedControls
224
+ search={search}
225
+ pending={pending}
226
+ onSearchChange={(next) => setSearch(next)}
227
+ />
228
+ <BoardNavigation search={search} onSearchChange={setSearch} />
229
+ <StatusNavigation search={search} onSearchChange={setSearch} />
230
+ {withPalette ? (
231
+ <CommandPalette
232
+ searchPosts={async () => ({ items: [], nextCursor: undefined })}
233
+ />
234
+ ) : null}
235
+ </CommandPaletteProvider>
236
+ <output data-testid="route-state">{JSON.stringify(search)}</output>
237
+ </QueryClientProvider>
238
+ );
239
+ }