@heyramzi/ui 2.0.1 → 2.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 (341) hide show
  1. package/package.json +48 -24
  2. package/scripts/sync-shadcn.mjs +3 -4
  3. package/src/agents/agent-avatar.svelte +604 -575
  4. package/src/agents/agent-cast.svelte +179 -183
  5. package/src/agents/demo.svelte +125 -113
  6. package/src/agents/helpers.ts +23 -17
  7. package/src/agents/index.ts +4 -4
  8. package/src/agents/page-state.ts +6 -6
  9. package/src/agents/types.ts +6 -6
  10. package/src/ai/ai-button/AiButton.svelte +4 -12
  11. package/src/ai/generation-progress/GenerationProgress.svelte +10 -3
  12. package/src/ai/prompt-input/PromptInputTextarea.svelte +1 -2
  13. package/src/ai/response/Response.css +2 -3
  14. package/src/animations/avatar-group-hover.css +2 -16
  15. package/src/animations/card-tilt.css +3 -6
  16. package/src/animations/digit-pop-in.css +2 -15
  17. package/src/animations/dropdown.css +4 -17
  18. package/src/animations/input-clear-dissolve.css +5 -18
  19. package/src/animations/input-shake.css +9 -41
  20. package/src/animations/learn-more-hover.css +17 -9
  21. package/src/animations/like-button.css +2 -4
  22. package/src/animations/page-side-by-side.css +2 -10
  23. package/src/animations/plus-menu-morph.css +2 -5
  24. package/src/animations/shimmer-text.css +5 -18
  25. package/src/animations/spinning-counter.css +2 -6
  26. package/src/animations/success-check.css +2 -15
  27. package/src/animations/thinking-states.css +2 -7
  28. package/src/brand/brand.test.ts +21 -5
  29. package/src/brand/index.ts +4 -10
  30. package/src/canvas/README.md +9 -12
  31. package/src/canvas/card/card-frame.svelte +43 -39
  32. package/src/canvas/card/resize-handles.svelte +89 -80
  33. package/src/canvas/chrome/chrome-zoom.svelte +101 -100
  34. package/src/canvas/chrome/lock-badge.svelte +8 -8
  35. package/src/canvas/chrome/side-handles.svelte +24 -19
  36. package/src/canvas/chrome/size-chip.svelte +30 -30
  37. package/src/canvas/chrome/snap-guides.svelte +20 -20
  38. package/src/canvas/components/icons/ToolIcon.svelte +8 -8
  39. package/src/canvas/components/shared/svg/PreviewSvg.svelte +19 -19
  40. package/src/canvas/components/shared/svg/ShapeOutline.svelte +71 -48
  41. package/src/canvas/components/shared/svg/StrokeNode.svelte +21 -27
  42. package/src/canvas/components/shared/svg/TextRule.svelte +25 -17
  43. package/src/canvas/edges/bend-handle.svelte +46 -46
  44. package/src/canvas/edges/edge-gradient.svelte +10 -10
  45. package/src/canvas/edges/gradient-edge.svelte +45 -29
  46. package/src/canvas/editor/lock.test.ts +6 -1
  47. package/src/canvas/editor/style.ts +7 -1
  48. package/src/canvas/headless/svg-figure.test.ts +5 -1
  49. package/src/canvas/lod/lod-switch.svelte +111 -106
  50. package/src/canvas/pen/PenField.svelte +87 -81
  51. package/src/canvas/pen/PenOverlay.svelte +127 -127
  52. package/src/canvas/pen/ShapeNode.svelte +31 -32
  53. package/src/canvas/pen/TextNode.svelte +54 -53
  54. package/src/canvas/pen/host-field.test.svelte +7 -7
  55. package/src/canvas/pen/shapes.ts +7 -1
  56. package/src/canvas/screen/GlyphIcon.svelte +22 -17
  57. package/src/canvas/screen/PartView.svelte +138 -72
  58. package/src/canvas/screen/ScreenBody.svelte +81 -60
  59. package/src/canvas/screen/ScreenCard.svelte +20 -20
  60. package/src/canvas/ui/CanvasToolbar.svelte +173 -85
  61. package/src/canvas/ui/FrameLayer.svelte +59 -59
  62. package/src/canvas/ui/PenLayer.svelte +39 -30
  63. package/src/canvas/ui/StylePanel.svelte +119 -102
  64. package/src/canvas/ui/canvas-grid.svelte +137 -129
  65. package/src/charts/animated-value.svelte +1 -1
  66. package/src/charts/area-chart.css +2 -5
  67. package/src/charts/area-chart.svelte +1 -1
  68. package/src/charts/bar-chart.css +2 -3
  69. package/src/charts/bar-chart.svelte +1 -1
  70. package/src/charts/charts.css +5 -17
  71. package/src/charts/crosshair.svelte +1 -1
  72. package/src/charts/dashboard/dashboard-empty.svelte +25 -25
  73. package/src/charts/dashboard/dashboard-section.svelte +24 -24
  74. package/src/charts/dashboard/data-panel.svelte +50 -50
  75. package/src/charts/dashboard/data-sort-head.svelte +56 -56
  76. package/src/charts/dashboard/data-table.svelte +138 -138
  77. package/src/charts/dashboard/panel-grid.svelte +30 -30
  78. package/src/charts/dashboard/period-filter.svelte +25 -25
  79. package/src/charts/dashboard/rating-chip.svelte +19 -19
  80. package/src/charts/dashboard/stat-band.svelte +32 -32
  81. package/src/charts/dashboard/stat-tile.svelte +106 -99
  82. package/src/charts/donut-chart.svelte +1 -1
  83. package/src/charts/edge-fade.svelte +1 -1
  84. package/src/charts/figure.svelte +1 -1
  85. package/src/charts/gauge-chart.css +4 -7
  86. package/src/charts/gauge-chart.svelte +1 -1
  87. package/src/charts/hover-focus.svelte +1 -1
  88. package/src/charts/legend.css +1 -3
  89. package/src/charts/legend.svelte +1 -1
  90. package/src/charts/line-chart.svelte +1 -1
  91. package/src/charts/number-chart.css +6 -9
  92. package/src/charts/number-chart.svelte +1 -1
  93. package/src/charts/reference-line.svelte +19 -19
  94. package/src/charts/reveal.svelte +1 -1
  95. package/src/charts/ring-chart.css +2 -4
  96. package/src/charts/ring-chart.svelte +1 -1
  97. package/src/charts/sparkline.svelte +5 -2
  98. package/src/charts/stat-card.svelte +1 -1
  99. package/src/charts/time-series-chart.css +2 -5
  100. package/src/charts/trend-pill.css +2 -4
  101. package/src/charts/trend-pill.svelte +1 -1
  102. package/src/components/copy/copy-button.svelte +93 -98
  103. package/src/components/copy/copy-button.test.ts +10 -2
  104. package/src/components/data-table/infinite-scroll.svelte +42 -42
  105. package/src/components/data-table/pagination.svelte +34 -22
  106. package/src/components/data-table/row-arrow.svelte +19 -16
  107. package/src/components/data-table/row-context-menu.svelte +52 -52
  108. package/src/components/data-table/sortable-head.svelte +25 -25
  109. package/src/components/data-table/table-skeleton.svelte +41 -36
  110. package/src/components/docs/doc-page.svelte +18 -9
  111. package/src/components/docs/on-this-page.svelte +16 -3
  112. package/src/components/docs/scroll-spy.svelte.ts +2 -3
  113. package/src/components/field-row/field-row.svelte +10 -8
  114. package/src/components/hold-to-confirm/hold-to-confirm.svelte +5 -1
  115. package/src/components/icon/icon.svelte +14 -14
  116. package/src/components/language-flag/language-flag.svelte +3 -1
  117. package/src/components/list-page/detail-empty.svelte +34 -34
  118. package/src/components/list-page/detail-header.svelte +55 -47
  119. package/src/components/list-page/detail-panel.svelte +51 -59
  120. package/src/components/list-page/detail-row.svelte +19 -19
  121. package/src/components/list-page/detail-stack.svelte +21 -21
  122. package/src/components/list-page/empty-state.svelte +30 -30
  123. package/src/components/list-page/filter-item.svelte +20 -20
  124. package/src/components/list-page/filter-popover.svelte +60 -55
  125. package/src/components/list-page/grouped-list.svelte +4 -2
  126. package/src/components/list-page/index.ts +8 -4
  127. package/src/components/list-page/list-page-shell.svelte +2 -5
  128. package/src/components/list-page/master-detail.svelte +87 -88
  129. package/src/components/list-page/notice-bar.svelte +38 -38
  130. package/src/components/list-page/prev-next.svelte +63 -63
  131. package/src/components/list-page/rail-body.svelte +18 -18
  132. package/src/components/list-page/rail-empty.svelte +23 -30
  133. package/src/components/list-page/rail-head.svelte +24 -24
  134. package/src/components/list-page/rail-row.svelte +77 -73
  135. package/src/components/list-page/section-host.ts +2 -3
  136. package/src/components/locale-switch/locale-switch.svelte +41 -41
  137. package/src/components/mark/mark.svelte +34 -38
  138. package/src/components/open-in/index.ts +1 -5
  139. package/src/components/open-in/open-in.svelte +40 -40
  140. package/src/components/picker/mark-picker.svelte +49 -49
  141. package/src/components/picker/picker.svelte +5 -3
  142. package/src/components/section-header/index.ts +1 -0
  143. package/src/components/section-header/section-header.svelte +1 -1
  144. package/src/components/section-header/section-header.test.ts +7 -1
  145. package/src/components/section-header/section-label.svelte +39 -0
  146. package/src/components/segmented/animated-tabs.svelte +31 -2
  147. package/src/components/segmented/collapse.test.ts +2 -1
  148. package/src/components/segmented/index.ts +2 -0
  149. package/src/components/segmented/segment.svelte +52 -49
  150. package/src/components/segmented/segmented-filter.svelte +79 -80
  151. package/src/components/segmented/segmented-nav.svelte +34 -40
  152. package/src/components/segmented/segmented-overflow.svelte +53 -50
  153. package/src/components/segmented/segmented-track.svelte +62 -46
  154. package/src/components/segmented/segmented.ts +2 -3
  155. package/src/components/star-rating-input/star-rating-input.svelte +103 -103
  156. package/src/components/svg/svg.svelte +4 -4
  157. package/src/components/table-view/table-view.svelte +17 -2
  158. package/src/components/table-view/table-view.test.ts +7 -1
  159. package/src/components/table-view/types.ts +11 -11
  160. package/src/components/tags-input/index.ts +13 -0
  161. package/src/components/tags-input/tags-input.svelte +305 -0
  162. package/src/components/tags-input/tags.test.ts +64 -0
  163. package/src/components/tags-input/tags.ts +142 -0
  164. package/src/components/theme/index.ts +2 -4
  165. package/src/components/theme/theme-provider.svelte +1 -3
  166. package/src/components/toolbar/index.ts +2 -0
  167. package/src/components/{list-page → toolbar}/toolbar-search.svelte +25 -25
  168. package/src/components/toolbar/toolbar.svelte +13 -9
  169. package/src/components/toolbar/toolbar.test.ts +10 -1
  170. package/src/design/index.css +21 -78
  171. package/src/design/polish.css +26 -35
  172. package/src/editor/editor-toolbar.svelte +104 -99
  173. package/src/editor/editor.svelte +296 -266
  174. package/src/editor/image-toolbar.svelte +20 -22
  175. package/src/editor/index.ts +1 -4
  176. package/src/editor/inline-editor.svelte +280 -238
  177. package/src/editor/markdown.ts +15 -15
  178. package/src/editor/renderer.svelte +71 -71
  179. package/src/editor/slash-menu.svelte +24 -27
  180. package/src/effects/animated-beam/diagrams/BeamSvg.svelte +1 -1
  181. package/src/effects/image-comparison/ImageComparison.svelte +11 -1
  182. package/src/effects/interactive-hover-button/InteractiveHoverButton.svelte +59 -46
  183. package/src/effects/magic-card/MagicCard.css +14 -7
  184. package/src/effects/marquee/Marquee.svelte +3 -5
  185. package/src/effects/reveal/observe.ts +0 -3
  186. package/src/effects/spotlight/Spotlight.svelte +1 -1
  187. package/src/effects/theme-toggle/ThemeToggle.css +25 -22
  188. package/src/effects/theme-toggle/ThemeToggle.svelte +6 -7
  189. package/src/gallery.test.ts +14 -4
  190. package/src/inbox/README.md +27 -0
  191. package/src/inbox/inbox.test.ts +214 -0
  192. package/src/inbox/index.ts +31 -0
  193. package/src/inbox/logic.ts +169 -0
  194. package/src/inbox/thread-inbox.svelte +439 -0
  195. package/src/inbox/types.ts +161 -0
  196. package/src/lib/theme-transition.ts +2 -4
  197. package/src/lib/tone.ts +2 -4
  198. package/src/lib/utils.ts +2 -2
  199. package/src/modules.test.ts +105 -0
  200. package/src/onboarding/define-tours.test.ts +55 -40
  201. package/src/onboarding/define-tours.ts +23 -23
  202. package/src/onboarding/demo.svelte +24 -5
  203. package/src/onboarding/index.ts +1 -8
  204. package/src/onboarding/onboarding-provider.svelte +19 -7
  205. package/src/onboarding/onboarding.svelte.ts +16 -16
  206. package/src/onboarding/pick.test.ts +61 -52
  207. package/src/onboarding/pick.ts +16 -13
  208. package/src/onboarding/stores.test.ts +80 -80
  209. package/src/onboarding/stores.ts +41 -39
  210. package/src/onboarding/tour-card.svelte +30 -5
  211. package/src/onboarding/tour-runner.svelte +12 -3
  212. package/src/onboarding/types.ts +20 -12
  213. package/src/onboarding/wait.ts +22 -22
  214. package/src/panel/README.md +5 -0
  215. package/src/panel/index.ts +2 -0
  216. package/src/panel/side-panel.svelte +194 -0
  217. package/src/record/README.md +3 -0
  218. package/src/record/demo-data.ts +352 -0
  219. package/src/record/field-section.svelte +57 -0
  220. package/src/record/fields.ts +194 -0
  221. package/src/record/format.ts +94 -0
  222. package/src/{tasks/record → record}/index.ts +49 -35
  223. package/src/{tasks/record → record}/info-list.svelte +145 -114
  224. package/src/record/kinds.ts +42 -0
  225. package/src/record/logic.ts +326 -0
  226. package/src/record/next-up.svelte +81 -0
  227. package/src/{tasks/record → record}/project-list.svelte +56 -50
  228. package/src/{tasks/record → record}/record-demo.svelte +130 -110
  229. package/src/{tasks/record → record}/record-header.svelte +37 -28
  230. package/src/{tasks/record → record}/record-shell.svelte +54 -54
  231. package/src/record/record-stats.svelte +48 -0
  232. package/src/record/record-timeline.svelte +251 -0
  233. package/src/record/record.test.ts +471 -0
  234. package/src/{tasks/record → record}/steps-rail.svelte +225 -216
  235. package/src/record/types.ts +299 -0
  236. package/src/record/where-we-are.svelte +125 -0
  237. package/src/shadcn/button/button.svelte +1 -1
  238. package/src/shadcn/card/card-title.svelte +1 -5
  239. package/src/shadcn/hooks/use-highlighted-code.svelte.ts +2 -13
  240. package/src/shadcn/sheet/sheet-content.svelte +2 -4
  241. package/src/shadcn/table/table-cell.svelte +2 -3
  242. package/src/shadcn/utilities.css +3 -8
  243. package/src/slides/Controls.svelte +150 -124
  244. package/src/slides/Deck.svelte +243 -238
  245. package/src/slides/Fragment.svelte +64 -57
  246. package/src/slides/Overview.svelte +106 -101
  247. package/src/slides/Presenter.svelte +300 -266
  248. package/src/slides/Slide.svelte +185 -170
  249. package/src/slides/SlideList.svelte +16 -12
  250. package/src/slides/ambient.d.ts +1 -1
  251. package/src/slides/bridges.ts +42 -23
  252. package/src/slides/deck-controller.svelte.ts +47 -35
  253. package/src/slides/demo-slides.svelte +330 -306
  254. package/src/slides/demo.svelte +164 -160
  255. package/src/slides/export/capture.ts +20 -16
  256. package/src/slides/export/ffmpeg.ts +43 -38
  257. package/src/slides/export/index.ts +5 -5
  258. package/src/slides/export/launch.ts +3 -1
  259. package/src/slides/export/pdf.ts +30 -27
  260. package/src/slides/export/types.ts +10 -8
  261. package/src/slides/index.ts +11 -11
  262. package/src/slides/labels.ts +22 -19
  263. package/src/slides/layout.ts +9 -2
  264. package/src/slides/model.ts +4 -4
  265. package/src/slides/navigation.ts +43 -31
  266. package/src/slides/theme.css +5 -7
  267. package/src/slides/types.ts +9 -10
  268. package/src/status/README.md +18 -0
  269. package/src/status/define-statuses.ts +25 -0
  270. package/src/status/index.ts +11 -0
  271. package/src/{tasks → status}/status-badge.svelte +3 -1
  272. package/src/{tasks → status}/status-dot.svelte +1 -3
  273. package/src/{tasks → status}/status-icon.svelte +1 -1
  274. package/src/status/status-types.ts +40 -0
  275. package/src/tasks/README.md +1 -1
  276. package/src/tasks/board-order.ts +2 -4
  277. package/src/tasks/demo.svelte +192 -34
  278. package/src/tasks/detail/detail-demo.svelte +75 -15
  279. package/src/tasks/detail/detail-types.ts +45 -45
  280. package/src/tasks/detail/task-detail.svelte +40 -10
  281. package/src/tasks/detail/task-property.svelte +8 -2
  282. package/src/tasks/detail/task-thread.svelte +53 -12
  283. package/src/tasks/gantt/gantt-bar.svelte +17 -6
  284. package/src/tasks/gantt/gantt-grid.svelte +40 -9
  285. package/src/tasks/gantt/gantt-group-row.svelte +8 -2
  286. package/src/tasks/gantt/gantt-header.svelte +9 -3
  287. package/src/tasks/gantt/gantt-task-row.svelte +26 -6
  288. package/src/tasks/gantt/gantt.css +2 -1
  289. package/src/tasks/gantt/gantt.test.ts +4 -2
  290. package/src/tasks/gantt/logic.test.ts +110 -20
  291. package/src/tasks/gantt/logic.ts +42 -6
  292. package/src/tasks/inbox/inbox-card.svelte +15 -7
  293. package/src/tasks/inbox/inbox-draft.svelte +1 -1
  294. package/src/tasks/inbox/inbox-logic.ts +6 -40
  295. package/src/tasks/inbox/inbox-statuses.ts +24 -24
  296. package/src/tasks/inbox/inbox-trace.svelte +9 -3
  297. package/src/tasks/inbox/inbox-types.ts +54 -58
  298. package/src/tasks/inbox/index.ts +22 -21
  299. package/src/tasks/index.ts +52 -53
  300. package/src/tasks/kanban-board.svelte +16 -12
  301. package/src/tasks/kanban-types.ts +2 -3
  302. package/src/tasks/presets/sales.ts +7 -7
  303. package/src/tasks/task-gantt.svelte +28 -8
  304. package/src/tasks/task-list.svelte +41 -17
  305. package/src/tasks/time/duration.ts +40 -40
  306. package/src/tasks/time/time-chip.svelte +9 -2
  307. package/src/tasks/time/time-dot.svelte +1 -1
  308. package/src/tasks/time/time-ring.svelte +11 -3
  309. package/src/tasks/time/time-tracker.svelte +57 -15
  310. package/src/tasks/time/time-types.ts +57 -57
  311. package/src/tasks/use-kanban-dnd.svelte.ts +2 -3
  312. package/src/thread/README.md +31 -0
  313. package/src/thread/index.ts +12 -0
  314. package/src/thread/logic.ts +61 -0
  315. package/src/thread/thread.svelte +432 -0
  316. package/src/thread/thread.test.ts +73 -0
  317. package/src/thread/types.ts +93 -0
  318. package/src/tokens/craft.css +17 -53
  319. package/src/tokens/index.css +3 -13
  320. package/src/tokens/palette.css +13 -11
  321. package/src/tokens/primitives.css +10 -23
  322. package/src/components/list-page/section-label.svelte +0 -39
  323. package/src/tasks/define-statuses.ts +0 -32
  324. package/src/tasks/inbox/inbox.svelte +0 -203
  325. package/src/tasks/record/demo-data.ts +0 -286
  326. package/src/tasks/record/field-section.svelte +0 -53
  327. package/src/tasks/record/fields.ts +0 -142
  328. package/src/tasks/record/format.ts +0 -87
  329. package/src/tasks/record/kinds.ts +0 -42
  330. package/src/tasks/record/logic.ts +0 -324
  331. package/src/tasks/record/next-up.svelte +0 -70
  332. package/src/tasks/record/record-stats.svelte +0 -48
  333. package/src/tasks/record/record-timeline.svelte +0 -231
  334. package/src/tasks/record/record.test.ts +0 -350
  335. package/src/tasks/record/types.ts +0 -280
  336. package/src/tasks/record/where-we-are.svelte +0 -121
  337. package/src/tasks/side-panel.svelte +0 -193
  338. package/src/tasks/status-types.ts +0 -40
  339. /package/src/components/{list-page → toolbar}/toolbar-trigger.ts +0 -0
  340. /package/src/{tasks → panel}/side-panel-types.ts +0 -0
  341. /package/src/{tasks/record → record}/gutter.ts +0 -0
@@ -0,0 +1,305 @@
1
+ <script lang="ts">
2
+ // =========================
3
+ // IMPORTS
4
+ // =========================
5
+ import { Plus, X } from "phosphor-svelte";
6
+ import { cn } from "../../lib/utils.js";
7
+ import {
8
+ addTag,
9
+ canCreate,
10
+ findOption,
11
+ matchOptions,
12
+ removeTag,
13
+ splitTags,
14
+ } from "./tags.js";
15
+
16
+ // =========================
17
+ // TYPES
18
+ // =========================
19
+ interface Labels {
20
+ /** Shown in the empty read state and as the input's placeholder. */
21
+ add: string;
22
+ create: (query: string) => string;
23
+ remove: (tag: string) => string;
24
+ max: string;
25
+ /** Shown when what's typed matches nothing and creating isn't allowed. */
26
+ none: string;
27
+ }
28
+
29
+ interface Props {
30
+ /** The tags. Order is kept. */
31
+ value: readonly string[];
32
+ /** Called with the whole next list. Throw to refuse: the list reverts. */
33
+ onchange: (next: string[]) => void | Promise<void>;
34
+ /** The labels people pick from. Picking one stores its exact spelling and case. */
35
+ options?: readonly string[];
36
+ /**
37
+ * Whether a label that isn't an option may be made, through the "Create" row only.
38
+ * Text close to an option never becomes a label unless that row is chosen.
39
+ */
40
+ allowCreate?: boolean;
41
+ /** Most tags the list holds. Past it the input goes away. */
42
+ max?: number;
43
+ /** Accessible name of the field. */
44
+ label: string;
45
+ labels?: Partial<Labels>;
46
+ disabled?: boolean;
47
+ class?: string;
48
+ }
49
+
50
+ // =========================
51
+ // PROPS
52
+ // =========================
53
+ let {
54
+ value,
55
+ onchange,
56
+ options = [],
57
+ allowCreate = true,
58
+ max,
59
+ label,
60
+ labels,
61
+ disabled = false,
62
+ class: className,
63
+ }: Props = $props();
64
+
65
+ // =========================
66
+ // STATE
67
+ // =========================
68
+ let editing = $state(false);
69
+ let query = $state("");
70
+ let active = $state(0);
71
+ // What the list shows while a save is in flight, so a tag doesn't blink out and back.
72
+ let pending = $state<string[] | null>(null);
73
+ let input = $state<HTMLInputElement | null>(null);
74
+ let root = $state<HTMLElement | null>(null);
75
+ const listId = $props.id();
76
+
77
+ // =========================
78
+ // DERIVED
79
+ // =========================
80
+ const text: Labels = $derived({
81
+ add: "Add",
82
+ create: (q) => `Create “${q}”`,
83
+ remove: (tag) => `Remove ${tag}`,
84
+ max: "Limit reached",
85
+ none: "No match",
86
+ ...labels,
87
+ });
88
+ const tags = $derived(pending ?? [...value]);
89
+ const full = $derived(max !== undefined && tags.length >= max);
90
+ const matches = $derived(matchOptions(options, tags, query));
91
+ const creatable = $derived(canCreate(options, tags, query, allowCreate));
92
+ // Options closest first (a typo's fix comes before anything new), then the one row that makes what's typed.
93
+ const rows = $derived([
94
+ ...matches.map((name) => ({ kind: "pick" as const, name })),
95
+ ...(creatable ? [{ kind: "create" as const, name: query.trim() }] : []),
96
+ ]);
97
+ const nothing = $derived(editing && !full && query.trim() !== "" && rows.length === 0);
98
+ const open = $derived(editing && !full && (rows.length > 0 || nothing));
99
+
100
+ // =========================
101
+ // FUNCTIONS
102
+ // =========================
103
+ async function commit(next: readonly string[]) {
104
+ if (next === tags) return;
105
+ pending = [...next];
106
+ try {
107
+ await onchange([...next]);
108
+ } finally {
109
+ pending = null;
110
+ }
111
+ }
112
+
113
+ function start() {
114
+ if (disabled) return;
115
+ editing = true;
116
+ queueMicrotask(() => input?.focus());
117
+ }
118
+
119
+ // Leaving the field keeps what was typed only when it is an existing label said another way.
120
+ // Anything else is dropped: free text is never saved by walking away.
121
+ function stop() {
122
+ const typed = query;
123
+ query = "";
124
+ editing = false;
125
+ const match = findOption(options, typed);
126
+ if (match) void commit(addTag(tags, match, options, max));
127
+ }
128
+
129
+ function add(name: string) {
130
+ query = "";
131
+ active = 0;
132
+ void commit(addTag(tags, name, options, max));
133
+ }
134
+
135
+ function onKey(event: KeyboardEvent) {
136
+ const last = rows.length - 1;
137
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
138
+ if (rows.length === 0) return;
139
+ event.preventDefault();
140
+ const step = event.key === "ArrowDown" ? 1 : -1;
141
+ active = (active + step + rows.length) % rows.length;
142
+ } else if (event.key === "Enter") {
143
+ event.preventDefault();
144
+ // The highlighted row: a close option when there is one, else the visible Create row.
145
+ if (!query.trim()) stop();
146
+ else if (rows.length > 0) add(rows[Math.min(active, last)].name);
147
+ } else if (event.key === "," || (event.key === "Tab" && query.trim())) {
148
+ event.preventDefault();
149
+ if (query.trim() && rows.length > 0) add(rows[Math.min(active, last)].name);
150
+ } else if (event.key === "Backspace" && query === "" && tags.length > 0) {
151
+ void commit(removeTag(tags, tags[tags.length - 1]));
152
+ } else if (event.key === "Escape") {
153
+ event.preventDefault();
154
+ if (query) query = "";
155
+ else {
156
+ editing = false;
157
+ root?.querySelector("button")?.focus();
158
+ }
159
+ }
160
+ }
161
+
162
+ // Pasted words that are existing labels are added. The rest stay in the box to be created on purpose.
163
+ function onPaste(event: ClipboardEvent) {
164
+ const pasted = event.clipboardData?.getData("text") ?? "";
165
+ if (!/[,\n]/.test(pasted)) return;
166
+ event.preventDefault();
167
+ const parts = splitTags(pasted);
168
+ const known = parts.filter((part) => findOption(options, part));
169
+ const rest = parts.filter((part) => !findOption(options, part));
170
+ query = rest.join(", ");
171
+ void commit(
172
+ known.reduce((list, part) => addTag(list, part, options, max), tags as readonly string[]),
173
+ );
174
+ }
175
+
176
+ function onFocusOut(event: FocusEvent) {
177
+ const next = event.relatedTarget as Node | null;
178
+ if (editing && !(next && root?.contains(next))) stop();
179
+ }
180
+
181
+ // A new query starts from its best match.
182
+ $effect(() => {
183
+ void query;
184
+ active = 0;
185
+ });
186
+ </script>
187
+
188
+ <!-- =========================== -->
189
+ <!-- MARKUP -->
190
+ <!-- WHY: the edit frame is --radius-lg with 4px of padding, so the chip inside is --radius-xs
191
+ (8 - 4), and a read-state chip on its own takes --radius-sm. -->
192
+ <!-- WHY text-base on the input: below 16px iOS zooms the page when it gets focus. -->
193
+ <!-- =========================== -->
194
+ {#snippet chip(tag: string, removable: boolean)}
195
+ <span
196
+ class={cn(
197
+ "inline-flex h-5 max-w-full items-center gap-0.5 bg-muted px-1.5 text-xs font-medium text-foreground",
198
+ removable ? "rounded-(--radius-xs) pr-0.5" : "rounded-(--radius-sm)",
199
+ )}
200
+ >
201
+ <span class="truncate">{tag}</span>
202
+ {#if removable}
203
+ <button
204
+ type="button"
205
+ tabindex="-1"
206
+ class="relative grid size-4 shrink-0 place-items-center rounded-(--radius-xs) text-muted-foreground transition-colors hover:bg-foreground/10 hover:text-foreground before:absolute before:-inset-1.5 before:content-['']"
207
+ aria-label={text.remove(tag)}
208
+ onmousedown={(e) => e.preventDefault()}
209
+ onclick={() => commit(removeTag(tags, tag))}
210
+ >
211
+ <X class="size-3" weight="bold" aria-hidden="true" />
212
+ </button>
213
+ {/if}
214
+ </span>
215
+ {/snippet}
216
+
217
+ <div
218
+ bind:this={root}
219
+ class={cn("relative min-w-0", className)}
220
+ onfocusout={onFocusOut}
221
+ role="presentation"
222
+ >
223
+ {#if editing}
224
+ <div
225
+ class="-ml-1.5 flex min-h-7 flex-wrap items-center gap-1 rounded-lg bg-background p-1 ring-1 ring-ring/60 focus-within:ring-2 focus-within:ring-ring"
226
+ >
227
+ {#each tags as tag (tag.toLowerCase())}
228
+ {@render chip(tag, true)}
229
+ {/each}
230
+ {#if full}
231
+ <span class="px-1 text-xs text-muted-foreground">{text.max}</span>
232
+ {:else}
233
+ <input
234
+ bind:this={input}
235
+ bind:value={query}
236
+ type="text"
237
+ role="combobox"
238
+ aria-label={label}
239
+ aria-expanded={open}
240
+ aria-controls={listId}
241
+ aria-autocomplete="list"
242
+ aria-activedescendant={open ? `${listId}-${active}` : undefined}
243
+ autocomplete="off"
244
+ autocapitalize="off"
245
+ spellcheck="false"
246
+ enterkeyhint="done"
247
+ placeholder={tags.length === 0 ? text.add : ""}
248
+ class="h-5 min-w-16 flex-1 bg-transparent px-1 text-base outline-none placeholder:text-muted-foreground sm:text-xs"
249
+ onkeydown={onKey}
250
+ onpaste={onPaste}
251
+ />
252
+ {/if}
253
+ </div>
254
+ {#if open}
255
+ <ul
256
+ id={listId}
257
+ role="listbox"
258
+ aria-label={label}
259
+ class="absolute top-full left-0 z-50 mt-1 max-h-60 w-full min-w-48 overflow-y-auto rounded-xl border border-border bg-popover p-1 text-popover-foreground shadow-(--elev-overlay)"
260
+ >
261
+ {#each rows as row, index (row.kind + row.name)}
262
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
263
+ <li
264
+ id="{listId}-{index}"
265
+ role="option"
266
+ aria-selected={index === active}
267
+ class={cn(
268
+ "flex min-h-8 cursor-default items-center gap-2 rounded-lg px-2 text-sm any-pointer-coarse:min-h-11",
269
+ index === active && "bg-accent text-accent-foreground",
270
+ )}
271
+ onmousedown={(e) => e.preventDefault()}
272
+ onmousemove={() => (active = index)}
273
+ onclick={() => add(row.name)}
274
+ >
275
+ {#if row.kind === "create"}
276
+ <Plus class="size-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
277
+ <span class="truncate">{text.create(row.name)}</span>
278
+ {:else}
279
+ <span class="truncate">{row.name}</span>
280
+ {/if}
281
+ </li>
282
+ {/each}
283
+ {#if nothing}
284
+ <li class="flex min-h-8 items-center px-2 text-sm text-muted-foreground">{text.none}</li>
285
+ {/if}
286
+ </ul>
287
+ {/if}
288
+ {:else}
289
+ <button
290
+ type="button"
291
+ class={cn(
292
+ "-ml-1.5 flex min-h-7 w-full flex-wrap items-center gap-1 rounded-lg px-1.5 py-1 text-left transition-colors outline-none hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none",
293
+ )}
294
+ aria-label={label}
295
+ {disabled}
296
+ onclick={start}
297
+ >
298
+ {#each tags as tag (tag.toLowerCase())}
299
+ {@render chip(tag, false)}
300
+ {:else}
301
+ <span class="text-sm text-muted-foreground">{text.add}</span>
302
+ {/each}
303
+ </button>
304
+ {/if}
305
+ </div>
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from "vite-plus/test";
2
+ import {
3
+ addTag,
4
+ canCreate,
5
+ cleanTags,
6
+ collectOptions,
7
+ editDistance,
8
+ matchOptions,
9
+ removeTag,
10
+ splitTags,
11
+ } from "./tags.js";
12
+
13
+ const options = ["Airtable", "Docusign", "QuickBooks", "SharePoint", "Supabase"];
14
+
15
+ describe("tags as labels", () => {
16
+ it("splits on commas and newlines", () => {
17
+ expect(splitTags(" a, b,,\nc ,")).toEqual(["a", "b", "c"]);
18
+ });
19
+
20
+ it("counts a transposition as one edit", () => {
21
+ expect(editDistance("airtbale", "airtable")).toBe(1);
22
+ expect(editDistance("make", "airtable")).toBeGreaterThan(2);
23
+ });
24
+
25
+ it("picks the option's spelling for a case or spacing difference", () => {
26
+ expect(addTag([], "airtable", options)).toEqual(["Airtable"]);
27
+ expect(addTag([], "share point", options)).toEqual(["SharePoint"]);
28
+ const held = ["Airtable"];
29
+ expect(addTag(held, "AIRTABLE", options)).toBe(held);
30
+ });
31
+
32
+ it("puts a near miss first and never offers a held option", () => {
33
+ expect(matchOptions(options, [], "Airtabel")[0]).toBe("Airtable");
34
+ expect(matchOptions(options, ["Airtable"], "airtable")).toEqual([]);
35
+ expect(matchOptions(options, [], "su")).toEqual(["Supabase"]);
36
+ });
37
+
38
+ it("ranks the same word, a prefix, then a part", () => {
39
+ expect(matchOptions(["QuickBooks", "Books", "Booksy"], [], "books")).toEqual([
40
+ "Books",
41
+ "Booksy",
42
+ "QuickBooks",
43
+ ]);
44
+ });
45
+
46
+ it("offers create only for a new word, and only when allowed", () => {
47
+ expect(canCreate(options, [], "airtable")).toBe(false);
48
+ expect(canCreate(options, [], "Airtabel")).toBe(true);
49
+ expect(canCreate(options, [], "Make", false)).toBe(false);
50
+ });
51
+
52
+ it("stops at max and removes by any casing", () => {
53
+ expect(addTag(["a", "b"], "c", [], 2)).toEqual(["a", "b"]);
54
+ expect(removeTag(["Airtable"], "airtable")).toEqual([]);
55
+ });
56
+
57
+ it("collapses a stored list and collects the most used spelling", () => {
58
+ expect(cleanTags(["docusign", "Docusign"], options)).toEqual(["Docusign"]);
59
+ expect(collectOptions([["Quickbooks"], ["QuickBooks", "Docusign"], ["QuickBooks"]])).toEqual([
60
+ "Docusign",
61
+ "QuickBooks",
62
+ ]);
63
+ });
64
+ });
@@ -0,0 +1,142 @@
1
+ // The tag list's rules, with no DOM. Options are the values already used, so a spelling gets picked,
2
+ // never retyped, and a new tag is a deliberate act.
3
+
4
+ /** Splits typed or pasted text on commas and newlines, trimmed, empties dropped. */
5
+ export function splitTags(text: string): string[] {
6
+ return text
7
+ .split(/[,\n]/)
8
+ .map((part) => part.trim())
9
+ .filter(Boolean);
10
+ }
11
+
12
+ /** What two spellings are compared by: no case, no spaces, no marks ("Share Point" is "SharePoint"). */
13
+ export function fold(text: string): string {
14
+ return text
15
+ .normalize("NFD")
16
+ .replace(/\p{M}/gu, "")
17
+ .toLowerCase()
18
+ .replace(/[\s._-]+/g, "");
19
+ }
20
+
21
+ const same = (a: string, b: string) => fold(a) === fold(b);
22
+
23
+ /** Edits (insert, delete, replace, swap of neighbours) between two strings, stopping past `limit`. */
24
+ export function editDistance(a: string, b: string, limit = 2): number {
25
+ if (Math.abs(a.length - b.length) > limit) return limit + 1;
26
+ let prev2: number[] = [];
27
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
28
+ for (let i = 1; i <= a.length; i++) {
29
+ const row = [i];
30
+ for (let j = 1; j <= b.length; j++) {
31
+ let d = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
32
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1])
33
+ d = Math.min(d, prev2[j - 2] + 1);
34
+ row.push(d);
35
+ }
36
+ prev2 = prev;
37
+ prev = row;
38
+ }
39
+ return prev[b.length];
40
+ }
41
+
42
+ /** How close `query` is to `option`: 0 the same, 1 a prefix, 2 contained, 3 a typo away, else null. */
43
+ function rank(option: string, query: string): number | null {
44
+ const o = fold(option);
45
+ const q = fold(query);
46
+ if (o === q) return 0;
47
+ if (o.startsWith(q)) return 1;
48
+ if (o.includes(q)) return 2;
49
+ // One edit for a short word, two from 7 letters, and not at all for 3 or fewer.
50
+ const limit = q.length >= 7 ? 2 : q.length >= 4 ? 1 : 0;
51
+ return limit > 0 && editDistance(o, q, limit) <= limit ? 3 : null;
52
+ }
53
+
54
+ /**
55
+ * The options to offer for what's typed, closest first, from exact match down to a typo away.
56
+ * Options already on the field are left out.
57
+ */
58
+ export function matchOptions(
59
+ options: readonly string[],
60
+ tags: readonly string[],
61
+ query: string,
62
+ limit = 8,
63
+ ): string[] {
64
+ const open = options.filter((option) => !tags.some((held) => same(held, option)));
65
+ if (!query.trim()) return open.slice(0, limit);
66
+ return open
67
+ .flatMap((option) => {
68
+ const r = rank(option, query);
69
+ return r === null ? [] : [{ option, r }];
70
+ })
71
+ .sort((a, b) => a.r - b.r)
72
+ .map(({ option }) => option)
73
+ .slice(0, limit);
74
+ }
75
+
76
+ /** The existing option that is `input` apart from case, spacing and marks, or undefined. */
77
+ export function findOption(options: readonly string[], input: string): string | undefined {
78
+ return options.find((option) => same(option, input));
79
+ }
80
+
81
+ /**
82
+ * Whether typing `query` may offer "Create": never for a word an option or tag already is. A
83
+ * typo's close option shows above it, never instead.
84
+ */
85
+ export function canCreate(
86
+ options: readonly string[],
87
+ tags: readonly string[],
88
+ query: string,
89
+ allowCreate = true,
90
+ ): boolean {
91
+ return (
92
+ allowCreate && query.trim() !== "" && ![...options, ...tags].some((held) => same(held, query))
93
+ );
94
+ }
95
+
96
+ /**
97
+ * The list after adding the option `name`, in the option's spelling. A repeat returns the same
98
+ * array, so a caller can skip the save.
99
+ */
100
+ export function addTag(
101
+ tags: readonly string[],
102
+ name: string,
103
+ options: readonly string[] = [],
104
+ max?: number,
105
+ ): readonly string[] {
106
+ const tag = findOption(options, name) ?? name.trim();
107
+ if (!tag || tags.some((held) => same(held, tag))) return tags;
108
+ if (max !== undefined && tags.length >= max) return tags;
109
+ return [...tags, tag];
110
+ }
111
+
112
+ /** The list without `tag`, whatever its casing. */
113
+ export function removeTag(tags: readonly string[], tag: string): string[] {
114
+ return tags.filter((held) => !same(held, tag));
115
+ }
116
+
117
+ /**
118
+ * Collapses spellings of one word to the first, and takes an option's spelling when one matches.
119
+ * For a list that was stored before the options existed.
120
+ */
121
+ export function cleanTags(tags: readonly string[], options: readonly string[] = []): string[] {
122
+ return tags.reduce<string[]>((out, tag) => [...addTag(out, tag, options)], []);
123
+ }
124
+
125
+ /**
126
+ * The distinct values across many lists, one spelling each: the most used wins, then the first
127
+ * seen. This is what an "existing values" field offers as its options.
128
+ */
129
+ export function collectOptions(lists: Iterable<readonly string[]>): string[] {
130
+ const seen = new Map<string, Map<string, number>>();
131
+ for (const list of lists)
132
+ for (const raw of list) {
133
+ const tag = raw.trim();
134
+ if (!tag) continue;
135
+ const spellings = seen.get(fold(tag)) ?? new Map<string, number>();
136
+ spellings.set(tag, (spellings.get(tag) ?? 0) + 1);
137
+ seen.set(fold(tag), spellings);
138
+ }
139
+ return [...seen.values()]
140
+ .map((spellings) => [...spellings].sort((a, b) => b[1] - a[1])[0][0])
141
+ .sort((a, b) => a.localeCompare(b));
142
+ }
@@ -1,8 +1,6 @@
1
1
  /**
2
- * The one light and dark system: mode-watcher's store and no-flash script, behind one provider.
3
- * Mount `ThemeProvider` once in the root layout, flip the mode with `toggleMode` or `setMode`, and
4
- * read it from `mode.current` or `userPrefersMode.current`. `ThemeToggle` and `revealTheme`
5
- * draw the control and the wipe.
2
+ * The one light and dark system, wrapping mode-watcher. Mount `ThemeProvider` once in the root
3
+ * layout, or the no-flash script never runs.
6
4
  */
7
5
  export { default as ThemeProvider } from "./theme-provider.svelte";
8
6
  export { revealTheme } from "../../lib/theme-transition.js";
@@ -42,8 +42,6 @@
42
42
  });
43
43
  </script>
44
44
 
45
- <!-- WHY it renders ModeWatcher and nothing else: its inline head script sets the class and
46
- colour-scheme before the first paint, so there is no flash and no cookie to read.
47
- WHY synchronous: `revealTheme` snapshots the page the moment its callback returns, so the
45
+ <!-- WHY synchronous: `revealTheme` snapshots the page the moment its callback returns, so the
48
46
  class has to be on the root by then. -->
49
47
  <ModeWatcher defaultMode={initial} {synchronousModeChanges} {...watcher} />
@@ -3,3 +3,5 @@
3
3
  * each of those folds into a menu and the row never wraps.
4
4
  */
5
5
  export { default as Toolbar } from "./toolbar.svelte";
6
+ export { default as ToolbarSearch } from "./toolbar-search.svelte";
7
+ export { toolbarTriggerClass } from "./toolbar-trigger.js";
@@ -1,30 +1,30 @@
1
1
  <script lang="ts">
2
- // =========================
3
- // IMPORTS
4
- // =========================
5
- import { Input } from "../../shadcn/input/index.js";
6
- import { MagnifyingGlass } from "phosphor-svelte";
7
- import { cn } from "../../lib/utils.js";
2
+ // =========================
3
+ // IMPORTS
4
+ // =========================
5
+ import { Input } from "../../shadcn/input/index.js";
6
+ import { MagnifyingGlass } from "phosphor-svelte";
7
+ import { cn } from "../../lib/utils.js";
8
8
 
9
- // =========================
10
- // TYPES
11
- // =========================
12
- interface Props {
13
- value: string;
14
- placeholder?: string;
15
- size?: "sm" | "default";
16
- class?: string;
17
- };
9
+ // =========================
10
+ // TYPES
11
+ // =========================
12
+ interface Props {
13
+ value: string;
14
+ placeholder?: string;
15
+ size?: "sm" | "default";
16
+ class?: string;
17
+ }
18
18
 
19
- // =========================
20
- // PROPS
21
- // =========================
22
- let {
23
- value = $bindable(""),
24
- placeholder = "Search...",
25
- size = "default",
26
- class: extraClass,
27
- }: Props = $props();
19
+ // =========================
20
+ // PROPS
21
+ // =========================
22
+ let {
23
+ value = $bindable(""),
24
+ placeholder = "Search...",
25
+ size = "default",
26
+ class: extraClass,
27
+ }: Props = $props();
28
28
  </script>
29
29
 
30
30
  <!-- =========================== -->
@@ -39,7 +39,7 @@ let {
39
39
  class={cn(
40
40
  "relative",
41
41
  extraClass,
42
- "@max-3xl/toolbar:w-auto @max-3xl/toolbar:min-w-0 @max-3xl/toolbar:flex-1"
42
+ "@max-3xl/toolbar:w-auto @max-3xl/toolbar:min-w-0 @max-3xl/toolbar:flex-1",
43
43
  )}
44
44
  >
45
45
  <MagnifyingGlass
@@ -6,8 +6,8 @@
6
6
  import { CaretDown, MagnifyingGlass } from "phosphor-svelte";
7
7
  import { SegmentedFilter } from "../segmented/index.js";
8
8
  import type { TabDef } from "../segmented/animated-tabs.svelte";
9
- import { toolbarTriggerClass } from "../list-page/toolbar-trigger.js";
10
- import ToolbarSearch from "../list-page/toolbar-search.svelte";
9
+ import { toolbarTriggerClass } from "./toolbar-trigger.js";
10
+ import ToolbarSearch from "./toolbar-search.svelte";
11
11
  import { Input } from "../../shadcn/input/index.js";
12
12
  import * as DropdownMenu from "../../shadcn/dropdown-menu/index.js";
13
13
  import * as Popover from "../../shadcn/popover/index.js";
@@ -66,11 +66,8 @@
66
66
  <!-- =========================== -->
67
67
  <!-- MARKUP -->
68
68
  <!-- =========================== -->
69
- <!-- WHY a container query: the toolbar sits in a pane that is narrower than the screen, and a
70
- breakpoint reads the screen. WHY nowrap: a second row pushes the content down and moves the
71
- page as a search is opened. Search, tabs and view are the three that fold, each into the one
72
- menu that holds them; both forms are in the DOM and the query shows one, so there is no
73
- measuring and no flash on first paint. -->
69
+ <!-- A container query, not a breakpoint, because the toolbar sits in a pane narrower than the
70
+ screen; both forms stay in the DOM, so nothing flashes. -->
74
71
  <div class={cn("@container/toolbar min-w-0", className)}>
75
72
  <div class="flex flex-nowrap items-center gap-2">
76
73
  {#if search !== undefined}
@@ -85,7 +82,12 @@
85
82
  <MagnifyingGlass class="size-3.5" aria-hidden="true" />
86
83
  </Popover.Trigger>
87
84
  <Popover.Content align="start" class="w-64 p-2">
88
- <Input bind:value={search} placeholder={searchPlaceholder} aria-label={searchLabel} class="h-8" />
85
+ <Input
86
+ bind:value={search}
87
+ placeholder={searchPlaceholder}
88
+ aria-label={searchLabel}
89
+ class="h-8"
90
+ />
89
91
  </Popover.Content>
90
92
  </Popover.Root>
91
93
  {/if}
@@ -112,7 +114,9 @@
112
114
  <DropdownMenu.RadioItem value={item.value}>
113
115
  {item.label}
114
116
  {#if item.count != null}
115
- <span class="ms-auto pe-2 text-xs text-muted-foreground tabular-nums">{item.count}</span>
117
+ <span class="ms-auto pe-2 text-xs text-muted-foreground tabular-nums"
118
+ >{item.count}</span
119
+ >
116
120
  {/if}
117
121
  </DropdownMenu.RadioItem>
118
122
  {/each}
@@ -45,7 +45,16 @@ describe("Toolbar", () => {
45
45
  });
46
46
 
47
47
  it("names the narrow controls so a screen reader can find them", () => {
48
- const root = render({ search: "", tabs, tab: "all", views, view: "list", searchLabel: "Find", tabsLabel: "Status", viewLabel: "Layout" });
48
+ const root = render({
49
+ search: "",
50
+ tabs,
51
+ tab: "all",
52
+ views,
53
+ view: "list",
54
+ searchLabel: "Find",
55
+ tabsLabel: "Status",
56
+ viewLabel: "Layout",
57
+ });
49
58
  for (const label of ["Find", "Status", "Layout"]) {
50
59
  expect(root.querySelector(`button[aria-label="${label}"]`), label).not.toBeNull();
51
60
  }