cursedbelt 3.0.2 → 4.1.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 (252) hide show
  1. package/dist/react/components/ListToolbar.d.ts +94 -0
  2. package/dist/react/components/ListToolbar.d.ts.map +1 -0
  3. package/dist/react/components/ListToolbar.js +66 -0
  4. package/dist/react/components/ListToolbar.js.map +1 -0
  5. package/dist/react/file-tree/FileTree.d.ts +282 -3
  6. package/dist/react/file-tree/FileTree.d.ts.map +1 -1
  7. package/dist/react/file-tree/FileTree.js +799 -131
  8. package/dist/react/file-tree/FileTree.js.map +1 -1
  9. package/dist/react/file-tree/fileTree.css +151 -0
  10. package/dist/react/file-tree/fileTreeSelection.d.ts +187 -0
  11. package/dist/react/file-tree/fileTreeSelection.d.ts.map +1 -0
  12. package/dist/react/file-tree/fileTreeSelection.js +264 -0
  13. package/dist/react/file-tree/fileTreeSelection.js.map +1 -0
  14. package/dist/react/file-tree/index.d.ts +1 -0
  15. package/dist/react/file-tree/index.d.ts.map +1 -1
  16. package/dist/react/file-tree/index.js +8 -0
  17. package/dist/react/file-tree/index.js.map +1 -1
  18. package/dist/react/index.d.ts +1 -0
  19. package/dist/react/index.d.ts.map +1 -1
  20. package/dist/react/index.js +1 -0
  21. package/dist/react/index.js.map +1 -1
  22. package/dist/react/lib/form.d.ts +3 -0
  23. package/dist/react/lib/form.d.ts.map +1 -1
  24. package/dist/react/lib/form.js +43 -5
  25. package/dist/react/lib/form.js.map +1 -1
  26. package/dist/react/media/DenseGalleryShell.d.ts +19 -3
  27. package/dist/react/media/DenseGalleryShell.d.ts.map +1 -1
  28. package/dist/react/media/DenseGalleryShell.js +55 -15
  29. package/dist/react/media/DenseGalleryShell.js.map +1 -1
  30. package/dist/react/media/FocusViewer.d.ts.map +1 -1
  31. package/dist/react/media/FocusViewer.js +14 -14
  32. package/dist/react/media/FocusViewer.js.map +1 -1
  33. package/dist/react/media/FrameGrabber.d.ts +23 -0
  34. package/dist/react/media/FrameGrabber.d.ts.map +1 -1
  35. package/dist/react/media/FrameGrabber.js +67 -1
  36. package/dist/react/media/FrameGrabber.js.map +1 -1
  37. package/dist/react/media/MediaResize.d.ts +51 -19
  38. package/dist/react/media/MediaResize.d.ts.map +1 -1
  39. package/dist/react/media/MediaResize.js +20 -13
  40. package/dist/react/media/MediaResize.js.map +1 -1
  41. package/dist/react/media/VideoChapterEditor.d.ts +67 -0
  42. package/dist/react/media/VideoChapterEditor.d.ts.map +1 -0
  43. package/dist/react/media/VideoChapterEditor.js +148 -0
  44. package/dist/react/media/VideoChapterEditor.js.map +1 -0
  45. package/dist/react/media/VideoPlayer.d.ts +46 -4
  46. package/dist/react/media/VideoPlayer.d.ts.map +1 -1
  47. package/dist/react/media/VideoPlayer.js +124 -11
  48. package/dist/react/media/VideoPlayer.js.map +1 -1
  49. package/dist/react/media/VideoTurner.d.ts +15 -2
  50. package/dist/react/media/VideoTurner.d.ts.map +1 -1
  51. package/dist/react/media/VideoTurner.js +29 -2
  52. package/dist/react/media/VideoTurner.js.map +1 -1
  53. package/dist/react/media/denseGallery.css +104 -46
  54. package/dist/react/media/hlsSource.d.ts +57 -0
  55. package/dist/react/media/hlsSource.d.ts.map +1 -1
  56. package/dist/react/media/hlsSource.js +177 -1
  57. package/dist/react/media/hlsSource.js.map +1 -1
  58. package/dist/react/media/index.d.ts +2 -0
  59. package/dist/react/media/index.d.ts.map +1 -1
  60. package/dist/react/media/index.js +6 -0
  61. package/dist/react/media/index.js.map +1 -1
  62. package/dist/react/media/mediaResize.css +127 -54
  63. package/dist/react/media/pictureEditor.css +52 -6
  64. package/dist/react/media/videoChapterEditor.css +203 -0
  65. package/dist/react/media/videoChapters.d.ts +117 -0
  66. package/dist/react/media/videoChapters.d.ts.map +1 -0
  67. package/dist/react/media/videoChapters.js +95 -0
  68. package/dist/react/media/videoChapters.js.map +1 -0
  69. package/dist/react/media-gallery/GalleryTable.d.ts +52 -1
  70. package/dist/react/media-gallery/GalleryTable.d.ts.map +1 -1
  71. package/dist/react/media-gallery/GalleryTable.js +40 -9
  72. package/dist/react/media-gallery/GalleryTable.js.map +1 -1
  73. package/dist/react/media-gallery/MediaGallery.d.ts +107 -4
  74. package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
  75. package/dist/react/media-gallery/MediaGallery.js +484 -104
  76. package/dist/react/media-gallery/MediaGallery.js.map +1 -1
  77. package/dist/react/media-gallery/MediaMetaEditor.d.ts +48 -0
  78. package/dist/react/media-gallery/MediaMetaEditor.d.ts.map +1 -0
  79. package/dist/react/media-gallery/MediaMetaEditor.js +66 -0
  80. package/dist/react/media-gallery/MediaMetaEditor.js.map +1 -0
  81. package/dist/react/media-gallery/index.d.ts +2 -0
  82. package/dist/react/media-gallery/index.d.ts.map +1 -1
  83. package/dist/react/media-gallery/index.js +5 -0
  84. package/dist/react/media-gallery/index.js.map +1 -1
  85. package/dist/react/media-gallery/mediaGallery.css +180 -4
  86. package/dist/react/media-gallery/mediaMeta.d.ts +62 -0
  87. package/dist/react/media-gallery/mediaMeta.d.ts.map +1 -0
  88. package/dist/react/media-gallery/mediaMeta.js +56 -0
  89. package/dist/react/media-gallery/mediaMeta.js.map +1 -0
  90. package/dist/react/media-gallery/playbackPreferences.d.ts +77 -0
  91. package/dist/react/media-gallery/playbackPreferences.d.ts.map +1 -1
  92. package/dist/react/media-gallery/playbackPreferences.js +42 -0
  93. package/dist/react/media-gallery/playbackPreferences.js.map +1 -1
  94. package/dist/react/media-gallery/types.d.ts +34 -0
  95. package/dist/react/media-gallery/types.d.ts.map +1 -1
  96. package/dist/scripts/guardrailsEnforce.d.ts +29 -0
  97. package/dist/scripts/guardrailsEnforce.d.ts.map +1 -0
  98. package/dist/styles-areas/activity-bar.css +30 -0
  99. package/dist/styles-areas/analytics.css +672 -0
  100. package/dist/styles-areas/auth.css +154 -0
  101. package/dist/styles-areas/calendar.css +416 -0
  102. package/dist/styles-areas/chart-table.css +41 -0
  103. package/dist/styles-areas/charts.css +89 -0
  104. package/dist/styles-areas/chat.css +364 -0
  105. package/dist/styles-areas/clipboard.css +26 -0
  106. package/dist/styles-areas/code-editor.css +130 -0
  107. package/dist/styles-areas/companion-link.css +70 -0
  108. package/dist/styles-areas/core.css +2503 -0
  109. package/dist/styles-areas/dashboard-grid.css +84 -0
  110. package/dist/styles-areas/data-table.css +657 -0
  111. package/dist/styles-areas/deep-link.css +26 -0
  112. package/dist/styles-areas/diff-viewer.css +30 -0
  113. package/dist/styles-areas/disk-usage.css +262 -0
  114. package/dist/styles-areas/emoji.css +154 -0
  115. package/dist/styles-areas/fields.css +582 -0
  116. package/dist/styles-areas/file-tree.css +33 -0
  117. package/dist/styles-areas/filter-rail.css +235 -0
  118. package/dist/styles-areas/folder-tree.css +219 -0
  119. package/dist/styles-areas/keep-awake.css +26 -0
  120. package/dist/styles-areas/layout-engine.css +239 -0
  121. package/dist/styles-areas/markdown.css +87 -0
  122. package/dist/styles-areas/master-detail.css +239 -0
  123. package/dist/styles-areas/master-lock.css +26 -0
  124. package/dist/styles-areas/media-gallery.css +351 -0
  125. package/dist/styles-areas/media.css +658 -0
  126. package/dist/styles-areas/nav.css +488 -0
  127. package/dist/styles-areas/notifications.css +245 -0
  128. package/dist/styles-areas/overlays.css +133 -0
  129. package/dist/styles-areas/palette.css +140 -0
  130. package/dist/styles-areas/pdf-viewer.css +47 -0
  131. package/dist/styles-areas/problem-detail.css +97 -0
  132. package/dist/styles-areas/rich-text.css +167 -0
  133. package/dist/styles-areas/sharing.css +153 -0
  134. package/dist/styles-areas/spreadsheet.css +37 -0
  135. package/dist/styles-areas/stats.css +281 -0
  136. package/dist/styles-areas/test-report.css +133 -0
  137. package/dist/styles-areas/virtual.css +30 -0
  138. package/dist/styles-areas/wizard.css +381 -0
  139. package/dist/styles-areas/workbench.css +543 -0
  140. package/dist/styles-areas/workbook-viewer.css +44 -0
  141. package/dist/styles-static.css +34 -23
  142. package/dist/styles.css +34 -23
  143. package/package.json +130 -32
  144. package/scripts/checkDistExports.ts +67 -0
  145. package/scripts/cssRules.ts +125 -0
  146. package/scripts/fixtureAppCss.ts +344 -0
  147. package/scripts/generateAreaStyles.ts +253 -0
  148. package/scripts/guardrailsEnforce.spec.ts +62 -0
  149. package/scripts/guardrailsEnforce.ts +67 -3
  150. package/scripts/paths.ts +106 -0
  151. package/scripts/styleAreas.ts +585 -0
  152. package/scripts/verify.ts +5 -0
  153. package/src/barrelsReachNoOptionalPeer.spec.ts +117 -9
  154. package/src/docsMatchTheSplit.spec.ts +98 -0
  155. package/src/fixtureAppCss.spec.ts +177 -0
  156. package/src/namedSubpathsResolve.spec.ts +35 -0
  157. package/src/publishShape.spec.ts +67 -0
  158. package/src/react/components/ListToolbar.spec.tsx +172 -0
  159. package/src/react/components/ListToolbar.tsx +151 -0
  160. package/src/react/file-tree/FileTree.spec.tsx +1009 -0
  161. package/src/react/file-tree/FileTree.tsx +1459 -309
  162. package/src/react/file-tree/fileTree.css +151 -0
  163. package/src/react/file-tree/fileTreeSelection.spec.ts +327 -0
  164. package/src/react/file-tree/fileTreeSelection.ts +321 -0
  165. package/src/react/file-tree/index.ts +21 -0
  166. package/src/react/index.ts +1 -0
  167. package/src/react/lib/form.ts +51 -8
  168. package/src/react/markdown/MarkdownDoc.spec.tsx +1 -1
  169. package/src/react/media/DenseGalleryShell.tsx +108 -36
  170. package/src/react/media/FocusViewer.tsx +15 -8
  171. package/src/react/media/FrameGrabber.spec.tsx +175 -0
  172. package/src/react/media/FrameGrabber.tsx +67 -0
  173. package/src/react/media/MediaResize.spec.tsx +84 -14
  174. package/src/react/media/MediaResize.tsx +51 -19
  175. package/src/react/media/VideoChapterEditor.tsx +428 -0
  176. package/src/react/media/VideoPlayer.spec.tsx +111 -1
  177. package/src/react/media/VideoPlayer.tsx +158 -11
  178. package/src/react/media/VideoTurner.spec.tsx +67 -0
  179. package/src/react/media/VideoTurner.tsx +70 -15
  180. package/src/react/media/denseGallery.css +104 -46
  181. package/src/react/media/hlsSource.recovery.spec.ts +213 -0
  182. package/src/react/media/hlsSource.spec.ts +8 -0
  183. package/src/react/media/hlsSource.ts +189 -1
  184. package/src/react/media/index.ts +14 -0
  185. package/src/react/media/mediaResize.css +127 -54
  186. package/src/react/media/pictureEditor.css +52 -6
  187. package/src/react/media/videoChapterEditor.css +203 -0
  188. package/src/react/media/videoChapters.spec.ts +176 -0
  189. package/src/react/media/videoChapters.ts +181 -0
  190. package/src/react/media-gallery/GalleryTable.tsx +93 -3
  191. package/src/react/media-gallery/MediaGallery.spec.tsx +1156 -10
  192. package/src/react/media-gallery/MediaGallery.tsx +813 -95
  193. package/src/react/media-gallery/MediaMetaEditor.tsx +237 -0
  194. package/src/react/media-gallery/galleryTable.spec.ts +41 -1
  195. package/src/react/media-gallery/index.ts +7 -0
  196. package/src/react/media-gallery/mediaGallery.css +180 -4
  197. package/src/react/media-gallery/mediaMeta.spec.ts +95 -0
  198. package/src/react/media-gallery/mediaMeta.ts +101 -0
  199. package/src/react/media-gallery/playbackPreferences.spec.ts +172 -0
  200. package/src/react/media-gallery/playbackPreferences.ts +100 -0
  201. package/src/react/media-gallery/types.ts +34 -0
  202. package/src/shippedFilesAreTracked.spec.ts +69 -0
  203. package/src/styles-areas/activity-bar.css +30 -0
  204. package/src/styles-areas/analytics.css +672 -0
  205. package/src/styles-areas/auth.css +154 -0
  206. package/src/styles-areas/calendar.css +416 -0
  207. package/src/styles-areas/chart-table.css +41 -0
  208. package/src/styles-areas/charts.css +89 -0
  209. package/src/styles-areas/chat.css +364 -0
  210. package/src/styles-areas/clipboard.css +26 -0
  211. package/src/styles-areas/code-editor.css +130 -0
  212. package/src/styles-areas/companion-link.css +70 -0
  213. package/src/styles-areas/core.css +2503 -0
  214. package/src/styles-areas/dashboard-grid.css +84 -0
  215. package/src/styles-areas/data-table.css +657 -0
  216. package/src/styles-areas/deep-link.css +26 -0
  217. package/src/styles-areas/diff-viewer.css +30 -0
  218. package/src/styles-areas/disk-usage.css +262 -0
  219. package/src/styles-areas/emoji.css +154 -0
  220. package/src/styles-areas/fields.css +582 -0
  221. package/src/styles-areas/file-tree.css +33 -0
  222. package/src/styles-areas/filter-rail.css +235 -0
  223. package/src/styles-areas/folder-tree.css +219 -0
  224. package/src/styles-areas/keep-awake.css +26 -0
  225. package/src/styles-areas/layout-engine.css +239 -0
  226. package/src/styles-areas/markdown.css +87 -0
  227. package/src/styles-areas/master-detail.css +239 -0
  228. package/src/styles-areas/master-lock.css +26 -0
  229. package/src/styles-areas/media-gallery.css +351 -0
  230. package/src/styles-areas/media.css +658 -0
  231. package/src/styles-areas/nav.css +488 -0
  232. package/src/styles-areas/notifications.css +245 -0
  233. package/src/styles-areas/overlays.css +133 -0
  234. package/src/styles-areas/palette.css +140 -0
  235. package/src/styles-areas/pdf-viewer.css +47 -0
  236. package/src/styles-areas/problem-detail.css +97 -0
  237. package/src/styles-areas/rich-text.css +167 -0
  238. package/src/styles-areas/sharing.css +153 -0
  239. package/src/styles-areas/spreadsheet.css +37 -0
  240. package/src/styles-areas/stats.css +281 -0
  241. package/src/styles-areas/test-report.css +133 -0
  242. package/src/styles-areas/virtual.css +30 -0
  243. package/src/styles-areas/wizard.css +381 -0
  244. package/src/styles-areas/workbench.css +543 -0
  245. package/src/styles-areas/workbook-viewer.css +44 -0
  246. package/src/styles-static.css +34 -23
  247. package/src/styles.css +34 -23
  248. package/src/stylesAreas.spec.ts +247 -0
  249. package/src/stylesUtilitiesMatches.spec.ts +6 -77
  250. package/src/testFilesRunInParallel.spec.ts +79 -0
  251. package/src/typecheckCachesAreSeparate.spec.ts +128 -0
  252. package/src/verifyGraph.spec.ts +6 -0
@@ -0,0 +1,172 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+ import { cleanup, fireEvent, render } from '@testing-library/react';
3
+ import { ListToolbar } from './ListToolbar';
4
+
5
+ afterEach(cleanup);
6
+
7
+ const CHIPS = [
8
+ { value: 'waiting', label: 'Waiting', count: 2 },
9
+ { value: 'taken', label: 'Taken' },
10
+ ];
11
+
12
+ describe('ListToolbar', () => {
13
+ test('renders the search box, named by searchLabel', () => {
14
+ const { getByRole } = render(
15
+ <ListToolbar
16
+ search='draft'
17
+ onSearchChange={() => {}}
18
+ searchPlaceholder='anything in a draft'
19
+ searchLabel='Search the drafts'
20
+ />,
21
+ );
22
+ const box = getByRole('searchbox', { name: 'Search the drafts' });
23
+ expect((box as HTMLInputElement).value).toBe('draft');
24
+ expect(box.getAttribute('placeholder')).toBe('anything in a draft');
25
+ });
26
+
27
+ test('the accessible name falls back to the placeholder, then to "Search"', () => {
28
+ const { getByRole } = render(
29
+ <ListToolbar search='' onSearchChange={() => {}} searchPlaceholder='file name or repo' />,
30
+ );
31
+ expect(getByRole('searchbox', { name: 'file name or repo' })).toBeDefined();
32
+ cleanup();
33
+
34
+ const bare = render(<ListToolbar search='' onSearchChange={() => {}} />);
35
+ expect(bare.getByRole('searchbox', { name: 'Search' })).toBeDefined();
36
+ });
37
+
38
+ test('typing fires onSearchChange with the new text', () => {
39
+ let seen = '';
40
+ const { getByRole } = render(
41
+ <ListToolbar
42
+ search=''
43
+ onSearchChange={(v) => {
44
+ seen = v;
45
+ }}
46
+ />,
47
+ );
48
+ fireEvent.change(getByRole('searchbox'), { target: { value: 'queue' } });
49
+ expect(seen).toBe('queue');
50
+ });
51
+
52
+ test('no search box without BOTH value and handler — a box nobody listens to is worse than none', () => {
53
+ const { queryByRole } = render(<ListToolbar search='orphan' />);
54
+ expect(queryByRole('searchbox')).toBeNull();
55
+ cleanup();
56
+
57
+ const noValue = render(<ListToolbar onSearchChange={() => {}} />);
58
+ expect(noValue.queryByRole('searchbox')).toBeNull();
59
+ });
60
+
61
+ test('renders one chip per option plus the "All" pill, and reports a click', () => {
62
+ let picked: string | string[] | null = null;
63
+ const read = (): string | string[] | null => picked;
64
+ const { getByRole, getAllByRole } = render(
65
+ <ListToolbar
66
+ chips={CHIPS}
67
+ chipValue={null}
68
+ onChipChange={(v) => {
69
+ picked = v;
70
+ }}
71
+ />,
72
+ );
73
+ expect(getAllByRole('button')).toHaveLength(3); // All + 2
74
+ expect(getByRole('button', { name: 'All' }).getAttribute('aria-pressed')).toBe('true');
75
+ fireEvent.click(getByRole('button', { name: /Waiting/ }));
76
+ expect(read()).toBe('waiting');
77
+ });
78
+
79
+ test('chipValue defaults to the "All" state', () => {
80
+ const { getByRole } = render(<ListToolbar chips={CHIPS} onChipChange={() => {}} />);
81
+ expect(getByRole('button', { name: 'All' }).getAttribute('aria-pressed')).toBe('true');
82
+ });
83
+
84
+ test('multiChips accumulates instead of replacing', () => {
85
+ let picked: string | string[] | null = null;
86
+ const read = (): string | string[] | null => picked;
87
+ const { getByRole } = render(
88
+ <ListToolbar
89
+ chips={CHIPS}
90
+ chipValue={['waiting']}
91
+ multiChips
92
+ onChipChange={(v) => {
93
+ picked = v;
94
+ }}
95
+ />,
96
+ );
97
+ fireEvent.click(getByRole('button', { name: /Taken/ }));
98
+ expect(read()).toEqual(['waiting', 'taken']);
99
+ });
100
+
101
+ test('chipsAllLabel renames the clear-all pill', () => {
102
+ const { getByRole } = render(
103
+ <ListToolbar chips={CHIPS} onChipChange={() => {}} chipsAllLabel='Everything' />,
104
+ );
105
+ expect(getByRole('button', { name: 'Everything' })).toBeDefined();
106
+ });
107
+
108
+ test('an EMPTY chip list renders nothing — not a lone "All" pill filtering nothing', () => {
109
+ const { queryByRole } = render(<ListToolbar chips={[]} onChipChange={() => {}} />);
110
+ expect(queryByRole('button', { name: 'All' })).toBeNull();
111
+ expect(queryByRole('group', { name: 'Filter options' })).toBeNull();
112
+ });
113
+
114
+ test('sort, view and actions land in one right-hand cluster, in that order', () => {
115
+ const { container, getByTestId } = render(
116
+ <ListToolbar
117
+ search=''
118
+ onSearchChange={() => {}}
119
+ sort={<span data-testid='sort'>sort</span>}
120
+ view={<span data-testid='view'>view</span>}
121
+ actions={<span data-testid='actions'>actions</span>}
122
+ />,
123
+ );
124
+ const cluster = getByTestId('sort').parentElement;
125
+ expect(cluster).not.toBeNull();
126
+ // One cluster, not three loose children: all three share the same parent…
127
+ expect(getByTestId('view').parentElement).toBe(cluster as HTMLElement);
128
+ expect(getByTestId('actions').parentElement).toBe(cluster as HTMLElement);
129
+ expect([...(cluster as HTMLElement).children].map((c) => c.textContent)).toEqual([
130
+ 'sort',
131
+ 'view',
132
+ 'actions',
133
+ ]);
134
+ // …and that cluster is pushed right, per DESIGN.md §2.
135
+ expect((cluster as HTMLElement).className).toContain('ml-auto');
136
+ expect((cluster as HTMLElement).parentElement).toBe(container.firstChild as HTMLElement);
137
+ });
138
+
139
+ test('no right-hand cluster when there is nothing to put in it', () => {
140
+ const { container } = render(<ListToolbar search='' onSearchChange={() => {}} />);
141
+ expect((container.firstChild as HTMLElement).querySelector('.ml-auto')).toBeNull();
142
+ });
143
+
144
+ test('extra filters sit on the left, outside the right-hand cluster', () => {
145
+ const { getByTestId, container } = render(
146
+ <ListToolbar
147
+ filters={<span data-testid='extra'>extra</span>}
148
+ view={<span data-testid='view'>view</span>}
149
+ />,
150
+ );
151
+ expect(getByTestId('extra').parentElement).toBe(container.firstChild as HTMLElement);
152
+ expect(getByTestId('view').parentElement).not.toBe(container.firstChild as HTMLElement);
153
+ });
154
+
155
+ test('ONE wrapping line (DESIGN.md §2 + §5), never a second row', () => {
156
+ const { container } = render(
157
+ <ListToolbar search='' onSearchChange={() => {}} chips={CHIPS} onChipChange={() => {}} />,
158
+ );
159
+ const row = container.firstChild as HTMLElement;
160
+ // A single flex container: `flex-wrap` is what keeps 44px targets at 390px without
161
+ // a second layout, so it is asserted rather than left to a reviewer's eye.
162
+ expect(row.className).toContain('flex');
163
+ expect(row.className).toContain('flex-wrap');
164
+ expect(container.childElementCount).toBe(1);
165
+ });
166
+
167
+ test('className is merged onto the row, not dropped', () => {
168
+ const { container } = render(<ListToolbar className='mb-4' view={<span>v</span>} />);
169
+ expect((container.firstChild as HTMLElement).className).toContain('mb-4');
170
+ expect((container.firstChild as HTMLElement).className).toContain('flex-wrap');
171
+ });
172
+ });
@@ -0,0 +1,151 @@
1
+ import type { ReactNode } from 'react';
2
+ import { cn } from '../lib/cn';
3
+ import { ChipFilterStrip, type ChipOption } from './ChipFilterStrip';
4
+ import { Input } from './Input';
5
+
6
+ export interface ListToolbarProps {
7
+ /**
8
+ * The search text. The box renders only when BOTH this and `onSearchChange`
9
+ * are given — a search input nobody is listening to is worse than none.
10
+ */
11
+ search?: string;
12
+ onSearchChange?: (value: string) => void;
13
+ /** What the reader can type, in the reader's terms — `'file name, repo or title'`. */
14
+ searchPlaceholder?: string;
15
+ /**
16
+ * The box's accessible name. Say WHAT is being searched (*"Search the queue"*),
17
+ * because a page with two lists gives a screen reader two boxes called "Search".
18
+ * Falls back to the placeholder, then to `'Search'`.
19
+ */
20
+ searchLabel?: string;
21
+ /**
22
+ * The filter chips — one dimension of the list, as data. An EMPTY array renders
23
+ * nothing at all: {@link ChipFilterStrip} always draws its "All" pill, and a lone
24
+ * "All" chip filtering nothing is noise on the line (`ConsoleView` filters its
25
+ * bucket chips down to the buckets that have rows, and can reach zero).
26
+ */
27
+ chips?: readonly ChipOption[];
28
+ /** Selected chip(s). `null` / `[]` is the "All" state. Default `null`. */
29
+ chipValue?: string | string[] | null;
30
+ onChipChange?: (value: string | string[] | null) => void;
31
+ /** Let more than one chip be active at once. Default `false`. */
32
+ multiChips?: boolean;
33
+ /** Label for the chips' clear-all pill. Default `'All'`. */
34
+ chipsAllLabel?: string;
35
+ /** Any further FILTER controls — they sit left, after the chips. */
36
+ filters?: ReactNode;
37
+ /** The sort control (a `NativeSelect`, a `ChoiceMenu`, …). Right-hand cluster. */
38
+ sort?: ReactNode;
39
+ /** The density/view-mode control — normally a `TableViewToggle`. Right-hand cluster. */
40
+ view?: ReactNode;
41
+ /** The far right: the one thing this page DOES. */
42
+ actions?: ReactNode;
43
+ className?: string;
44
+ }
45
+
46
+ /**
47
+ * The toolbar above a list: ONE line, filters left, view controls and actions right.
48
+ *
49
+ * DESIGN.md §2, quoted because this component is nothing but that paragraph:
50
+ * *"Never scatter operational controls or filter tabs across multi-row lines. Merge
51
+ * navigation filters, search boxes, query parameters, execution buttons, and
52
+ * destructive triggers into a single cohesive layout line. Position filters to the
53
+ * left/center and main execution actions to the far right."*
54
+ *
55
+ * ── Why this is in the design system, not in the app ───────────────────────
56
+ * `apps/station` composed exactly this shape inline on BOTH of its lists — the queue
57
+ * (`ConsoleView`) and the intake (`IdeasView`) — about thirty identical lines each,
58
+ * differing only in their options. The retired `cursed-satellites` station had already
59
+ * met that fork and said so in `BoardChrome.tsx`'s header: two boards with the same
60
+ * INTERACTION built twice give you *"two toolbars that drift apart on the first change
61
+ * to either"*. Task `183` was right to forbid a station-local component and `184` is
62
+ * the consequence: the shape belongs to every app's list, so it lives here.
63
+ *
64
+ * ── Layout only. It has no memory ─────────────────────────────────────────
65
+ * 🔴 Search text, the chosen chips, the sort key and the view mode are all the
66
+ * caller's state, and one of them is already solved elsewhere: `useTableViewMode`
67
+ * persists rows ⇄ cards per account, server-side. A toolbar that grew its own
68
+ * storage would either fight that hook or quietly become a second, local, worse copy
69
+ * of it. `sort`/`view`/`actions` are SLOTS for the same reason — this one must work
70
+ * for `MasterDetail` pages and card grids, which is why it is not {@link TableToolbar}
71
+ * (that one reads everything off a TanStack `table` and cannot exist without one).
72
+ *
73
+ * ── `flex-wrap`, and no `role='toolbar'` ──────────────────────────────────
74
+ * Wrapping, not a grid and not a breakpoint swap: the console runs at 1440 on a desk
75
+ * and 390 on a phone, and wrapping is the one behaviour that keeps DESIGN.md §5's 44px
76
+ * targets at both widths without a second layout. `ml-auto` on the right-hand cluster
77
+ * keeps the controls right when everything fits on one line and lets them ride down as
78
+ * a group when it does not.
79
+ *
80
+ * No `role='toolbar'`: that role promises arrow-key roving focus between its controls,
81
+ * which would take the arrow keys away from the text box and the native `<select>`
82
+ * that live on this line. Tab order is the correct affordance here.
83
+ *
84
+ * @example
85
+ * <ListToolbar
86
+ * search={search}
87
+ * onSearchChange={setSearch}
88
+ * searchPlaceholder='file name, repo or title'
89
+ * searchLabel='Search the queue'
90
+ * chips={buckets}
91
+ * chipValue={filter}
92
+ * onChipChange={(v) => setFilter(typeof v === 'string' ? v : null)}
93
+ * sort={<NativeSelect size='sm' aria-label='Sort the queue' … />}
94
+ * view={<TableViewToggle value={view.mode} onChange={view.setMode} />}
95
+ * />
96
+ */
97
+ export function ListToolbar({
98
+ search,
99
+ onSearchChange,
100
+ searchPlaceholder,
101
+ searchLabel,
102
+ chips,
103
+ chipValue = null,
104
+ onChipChange,
105
+ multiChips = false,
106
+ chipsAllLabel,
107
+ filters,
108
+ sort,
109
+ view,
110
+ actions,
111
+ className,
112
+ }: ListToolbarProps) {
113
+ const showSearch = search !== undefined && onSearchChange !== undefined;
114
+ const showChips = chips !== undefined && chips.length > 0 && onChipChange !== undefined;
115
+ const showRight = Boolean(sort) || Boolean(view) || Boolean(actions);
116
+
117
+ return (
118
+ <div className={cn('flex flex-wrap items-center gap-x-3 gap-y-2', className)}>
119
+ {showSearch ? (
120
+ <Input
121
+ type='search'
122
+ value={search}
123
+ onChange={(e) => onSearchChange(e.currentTarget.value)}
124
+ placeholder={searchPlaceholder}
125
+ aria-label={searchLabel ?? searchPlaceholder ?? 'Search'}
126
+ // Full width on a phone (the chips then wrap beneath it, still tappable);
127
+ // a fixed, modest column from `sm:` so the chips share the line rather than
128
+ // being pushed off it by a box that grows with the container.
129
+ className='w-full min-w-40 sm:w-56'
130
+ />
131
+ ) : null}
132
+ {showChips ? (
133
+ <ChipFilterStrip
134
+ options={[...chips]}
135
+ value={chipValue}
136
+ onChange={onChipChange}
137
+ multi={multiChips}
138
+ {...(chipsAllLabel === undefined ? {} : { allLabel: chipsAllLabel })}
139
+ />
140
+ ) : null}
141
+ {filters}
142
+ {showRight ? (
143
+ <span className='ml-auto flex flex-wrap items-center gap-2'>
144
+ {sort}
145
+ {view}
146
+ {actions}
147
+ </span>
148
+ ) : null}
149
+ </div>
150
+ );
151
+ }