@heyramzi/ui 1.54.2 → 2.0.1

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 (363) hide show
  1. package/COMPONENTS.md +3 -2
  2. package/README.md +34 -9
  3. package/package.json +205 -13
  4. package/scripts/sync-shadcn.mjs +2 -0
  5. package/src/ai/generation-progress/GenerationProgress.svelte +12 -12
  6. package/src/ambient.d.ts +8 -0
  7. package/src/brand/brand.test.ts +67 -0
  8. package/src/brand/index.ts +137 -0
  9. package/src/canvas/README.md +236 -0
  10. package/src/canvas/card/card-frame.svelte +61 -0
  11. package/src/canvas/card/card.css +39 -0
  12. package/src/canvas/card/resize-handles.svelte +119 -0
  13. package/src/canvas/card/resize.ts +33 -0
  14. package/src/canvas/chrome/chrome-zoom.svelte +120 -0
  15. package/src/canvas/chrome/chrome.css +231 -0
  16. package/src/canvas/chrome/lock-badge.svelte +18 -0
  17. package/src/canvas/chrome/side-handles.svelte +30 -0
  18. package/src/canvas/chrome/size-chip.svelte +45 -0
  19. package/src/canvas/chrome/snap-guides.svelte +69 -0
  20. package/src/canvas/components/icons/ToolIcon.svelte +30 -0
  21. package/src/canvas/components/shared/svg/PreviewSvg.svelte +67 -0
  22. package/src/canvas/components/shared/svg/ShapeOutline.svelte +76 -0
  23. package/src/canvas/components/shared/svg/StrokeNode.svelte +69 -0
  24. package/src/canvas/components/shared/svg/TextRule.svelte +37 -0
  25. package/src/canvas/edges/bend-handle.svelte +76 -0
  26. package/src/canvas/edges/edge-gradient.svelte +30 -0
  27. package/src/canvas/edges/edge-path.test.ts +58 -0
  28. package/src/canvas/edges/edge-path.ts +293 -0
  29. package/src/canvas/edges/gradient-edge.svelte +43 -0
  30. package/src/canvas/editor/context-menu.test.ts +65 -0
  31. package/src/canvas/editor/context-menu.ts +48 -0
  32. package/src/canvas/editor/dispatch.svelte.test.ts +243 -0
  33. package/src/canvas/editor/dispatch.svelte.ts +205 -0
  34. package/src/canvas/editor/editor-context.ts +62 -0
  35. package/src/canvas/editor/editor-mode.ts +67 -0
  36. package/src/canvas/editor/frame.ts +98 -0
  37. package/src/canvas/editor/hand-context.ts +15 -0
  38. package/src/canvas/editor/history-core.test.ts +239 -0
  39. package/src/canvas/editor/history-core.ts +247 -0
  40. package/src/canvas/editor/keymap.test.ts +161 -0
  41. package/src/canvas/editor/keymap.ts +151 -0
  42. package/src/canvas/editor/layout-actions.test.ts +156 -0
  43. package/src/canvas/editor/layout-actions.ts +219 -0
  44. package/src/canvas/editor/lock.test.ts +68 -0
  45. package/src/canvas/editor/lock.ts +87 -0
  46. package/src/canvas/editor/rebase.test.ts +208 -0
  47. package/src/canvas/editor/rebase.ts +272 -0
  48. package/src/canvas/editor/style.ts +184 -0
  49. package/src/canvas/editor/text-field-context.ts +41 -0
  50. package/src/canvas/editor/z-order.test.ts +75 -0
  51. package/src/canvas/editor/z-order.ts +87 -0
  52. package/src/canvas/headless/alignment.test.ts +59 -0
  53. package/src/canvas/headless/alignment.ts +23 -0
  54. package/src/canvas/headless/break-apart-find.ts +418 -0
  55. package/src/canvas/headless/break-apart-pieces.ts +104 -0
  56. package/src/canvas/headless/break-apart-plan.ts +439 -0
  57. package/src/canvas/headless/break-apart-style.ts +106 -0
  58. package/src/canvas/headless/break-apart.ts +244 -0
  59. package/src/canvas/headless/canvas-cleanup.test.ts +181 -0
  60. package/src/canvas/headless/canvas-cleanup.ts +102 -0
  61. package/src/canvas/headless/edge-anchors.test.ts +70 -0
  62. package/src/canvas/headless/edge-anchors.ts +143 -0
  63. package/src/canvas/headless/edge-memo.test.ts +58 -0
  64. package/src/canvas/headless/edge-memo.ts +32 -0
  65. package/src/canvas/headless/edge-serialization.test.ts +55 -0
  66. package/src/canvas/headless/edge-serialization.ts +32 -0
  67. package/src/canvas/headless/figure-geometry.ts +293 -0
  68. package/src/canvas/headless/fit-viewport.test.ts +85 -0
  69. package/src/canvas/headless/fit-viewport.ts +137 -0
  70. package/src/canvas/headless/grid.test.ts +34 -0
  71. package/src/canvas/headless/grid.ts +94 -0
  72. package/src/canvas/headless/icon-node.test.ts +23 -0
  73. package/src/canvas/headless/icon-node.ts +66 -0
  74. package/src/canvas/headless/index.ts +9 -0
  75. package/src/canvas/headless/linkify.test.ts +23 -0
  76. package/src/canvas/headless/linkify.ts +25 -0
  77. package/src/canvas/headless/pen-marks.ts +133 -0
  78. package/src/canvas/headless/sketch.test.ts +99 -0
  79. package/src/canvas/headless/sketch.ts +302 -0
  80. package/src/canvas/headless/snap-guides.test.ts +61 -0
  81. package/src/canvas/headless/snap-guides.ts +146 -0
  82. package/src/canvas/headless/svg-figure.test.ts +49 -0
  83. package/src/canvas/headless/svg-figure.ts +176 -0
  84. package/src/canvas/headless/table-layout.test.ts +158 -0
  85. package/src/canvas/headless/table-layout.ts +309 -0
  86. package/src/canvas/index.ts +35 -0
  87. package/src/canvas/lod/lod-mode.svelte.ts +30 -0
  88. package/src/canvas/lod/lod-switch.svelte +140 -0
  89. package/src/canvas/lod/render-all.svelte.ts +52 -0
  90. package/src/canvas/ops/arrange.ts +108 -0
  91. package/src/canvas/ops/ops.test.ts +98 -0
  92. package/src/canvas/ops/ops.ts +347 -0
  93. package/src/canvas/pen/PenField.svelte +128 -0
  94. package/src/canvas/pen/PenField.test.ts +87 -0
  95. package/src/canvas/pen/PenOverlay.svelte +172 -0
  96. package/src/canvas/pen/ShapeNode.svelte +64 -0
  97. package/src/canvas/pen/TextNode.svelte +97 -0
  98. package/src/canvas/pen/colors.ts +28 -0
  99. package/src/canvas/pen/eraser.ts +46 -0
  100. package/src/canvas/pen/geometry.ts +148 -0
  101. package/src/canvas/pen/host-field.test.svelte +13 -0
  102. package/src/canvas/pen/index.ts +40 -0
  103. package/src/canvas/pen/ops.ts +16 -0
  104. package/src/canvas/pen/pen-session.ts +177 -0
  105. package/src/canvas/pen/pen.css +149 -0
  106. package/src/canvas/pen/plugin.ts +139 -0
  107. package/src/canvas/pen/shapes.ts +104 -0
  108. package/src/canvas/pen/stroke-path.ts +54 -0
  109. package/src/canvas/pen/types.ts +96 -0
  110. package/src/canvas/pen/ui.ts +26 -0
  111. package/src/canvas/plugin.test.ts +45 -0
  112. package/src/canvas/plugin.ts +159 -0
  113. package/src/canvas/screen/GlyphIcon.svelte +25 -0
  114. package/src/canvas/screen/PartView.svelte +176 -0
  115. package/src/canvas/screen/ScreenBody.svelte +142 -0
  116. package/src/canvas/screen/ScreenCard.svelte +32 -0
  117. package/src/canvas/screen/glyphs/button.svg +1 -0
  118. package/src/canvas/screen/glyphs/close.svg +1 -0
  119. package/src/canvas/screen/glyphs/down.svg +1 -0
  120. package/src/canvas/screen/glyphs/header.svg +1 -0
  121. package/src/canvas/screen/glyphs/hills.svg +1 -0
  122. package/src/canvas/screen/glyphs/image.svg +1 -0
  123. package/src/canvas/screen/glyphs/input.svg +1 -0
  124. package/src/canvas/screen/glyphs/list.svg +1 -0
  125. package/src/canvas/screen/glyphs/modal.svg +1 -0
  126. package/src/canvas/screen/glyphs/plus.svg +1 -0
  127. package/src/canvas/screen/glyphs/up.svg +1 -0
  128. package/src/canvas/screen/glyphs.ts +40 -0
  129. package/src/canvas/screen/labels.ts +41 -0
  130. package/src/canvas/screen/parts.ts +164 -0
  131. package/src/canvas/screen/plugin.ts +67 -0
  132. package/src/canvas/screen/screen.css +593 -0
  133. package/src/canvas/screen/types.ts +47 -0
  134. package/src/canvas/screen/ui.ts +13 -0
  135. package/src/canvas/test-utils.ts +18 -0
  136. package/src/canvas/types.ts +53 -0
  137. package/src/canvas/ui/CanvasToolbar.svelte +169 -0
  138. package/src/canvas/ui/FrameLayer.svelte +116 -0
  139. package/src/canvas/ui/PenLayer.svelte +39 -0
  140. package/src/canvas/ui/StylePanel.svelte +268 -0
  141. package/src/canvas/ui/canvas-grid.svelte +156 -0
  142. package/src/canvas/ui/canvas-ui.css +350 -0
  143. package/src/canvas/ui/forward-wheel.ts +29 -0
  144. package/src/canvas/ui/icons.ts +124 -0
  145. package/src/canvas/ui/place-panel.ts +80 -0
  146. package/src/canvas/ui/shortcut-tip.test.ts +16 -0
  147. package/src/canvas/ui/shortcut-tip.ts +17 -0
  148. package/src/canvas/ui/tools.ts +146 -0
  149. package/src/charts/README.md +104 -0
  150. package/src/charts/ambient.d.ts +4 -0
  151. package/src/charts/animated-value.css +7 -0
  152. package/src/charts/animated-value.svelte +110 -0
  153. package/src/charts/area-chart.css +29 -0
  154. package/src/charts/area-chart.svelte +204 -0
  155. package/src/charts/bar-chart.css +119 -0
  156. package/src/charts/bar-chart.svelte +275 -0
  157. package/src/charts/charts.css +201 -0
  158. package/src/charts/crosshair.svelte +35 -0
  159. package/src/charts/dashboard/chart.ts +5 -0
  160. package/src/charts/dashboard/dashboard-empty.svelte +55 -0
  161. package/src/charts/dashboard/dashboard-section.svelte +58 -0
  162. package/src/charts/dashboard/data-panel.svelte +81 -0
  163. package/src/charts/dashboard/data-sort-head.svelte +102 -0
  164. package/src/charts/dashboard/data-table.svelte +278 -0
  165. package/src/charts/dashboard/data-table.ts +26 -0
  166. package/src/charts/dashboard/index.ts +25 -0
  167. package/src/charts/dashboard/panel-grid.svelte +43 -0
  168. package/src/charts/dashboard/period-filter.svelte +53 -0
  169. package/src/charts/dashboard/rating-chip.svelte +35 -0
  170. package/src/charts/dashboard/stat-band.svelte +47 -0
  171. package/src/charts/dashboard/stat-tile.svelte +176 -0
  172. package/src/charts/dashboard/windows.ts +8 -0
  173. package/src/charts/donut-chart.css +56 -0
  174. package/src/charts/donut-chart.svelte +158 -0
  175. package/src/charts/edge-fade.svelte +43 -0
  176. package/src/charts/edge-fade.ts +16 -0
  177. package/src/charts/figure.css +100 -0
  178. package/src/charts/figure.svelte +122 -0
  179. package/src/charts/gauge-chart.css +94 -0
  180. package/src/charts/gauge-chart.svelte +302 -0
  181. package/src/charts/hover-band.test.ts +30 -0
  182. package/src/charts/hover-band.ts +21 -0
  183. package/src/charts/hover-focus.svelte +85 -0
  184. package/src/charts/index.ts +31 -0
  185. package/src/charts/legend.css +136 -0
  186. package/src/charts/legend.svelte +112 -0
  187. package/src/charts/line-chart.css +26 -0
  188. package/src/charts/line-chart.svelte +256 -0
  189. package/src/charts/motion.ts +19 -0
  190. package/src/charts/number-chart.css +179 -0
  191. package/src/charts/number-chart.svelte +187 -0
  192. package/src/charts/primitives/chart-container.svelte +57 -0
  193. package/src/charts/primitives/chart-style.svelte +36 -0
  194. package/src/charts/primitives/chart-tooltip.svelte +161 -0
  195. package/src/charts/primitives/chart-utils.ts +54 -0
  196. package/src/charts/primitives/index.ts +11 -0
  197. package/src/charts/reference-line.css +18 -0
  198. package/src/charts/reference-line.svelte +31 -0
  199. package/src/charts/reveal.svelte +61 -0
  200. package/src/charts/ring-chart.css +104 -0
  201. package/src/charts/ring-chart.svelte +166 -0
  202. package/src/charts/sparkline.css +45 -0
  203. package/src/charts/sparkline.svelte +141 -0
  204. package/src/charts/stat-card.css +105 -0
  205. package/src/charts/stat-card.svelte +267 -0
  206. package/src/charts/time-series-chart.css +28 -0
  207. package/src/charts/time-series-chart.svelte +179 -0
  208. package/src/charts/trend-pill.css +38 -0
  209. package/src/charts/trend-pill.svelte +48 -0
  210. package/src/components/copy/copy-button.svelte +1 -1
  211. package/src/components/docs/doc-page.svelte +1 -1
  212. package/src/components/docs/on-this-page.svelte +2 -2
  213. package/src/components/list-page/empty-state.svelte +1 -1
  214. package/src/components/list-page/filter-popover.svelte +1 -1
  215. package/src/components/list-page/section-label.svelte +1 -1
  216. package/src/components/segmented/segment.svelte +4 -4
  217. package/src/components/theme/theme-provider.svelte +6 -4
  218. package/src/components/toolbar/index.ts +5 -0
  219. package/src/components/toolbar/toolbar.svelte +168 -0
  220. package/src/components/toolbar/toolbar.test.ts +66 -0
  221. package/src/editor/README.md +58 -0
  222. package/src/editor/ambient.d.ts +1 -0
  223. package/src/editor/block-id.ts +77 -0
  224. package/src/editor/block-ops.test.ts +237 -0
  225. package/src/editor/block-ops.ts +157 -0
  226. package/src/editor/content.css +418 -0
  227. package/src/editor/editor-toolbar.svelte +303 -0
  228. package/src/editor/editor.svelte +469 -0
  229. package/src/editor/extensions.ts +145 -0
  230. package/src/editor/image-toolbar.svelte +123 -0
  231. package/src/editor/index.ts +17 -0
  232. package/src/editor/inline-editor.svelte +261 -0
  233. package/src/editor/markdown-escaping.test.ts +158 -0
  234. package/src/editor/markdown-marks.test.ts +89 -0
  235. package/src/editor/markdown-test-helpers.ts +13 -0
  236. package/src/editor/markdown-youtube.test.ts +93 -0
  237. package/src/editor/markdown.test.ts +250 -0
  238. package/src/editor/markdown.ts +511 -0
  239. package/src/editor/renderer.svelte +92 -0
  240. package/src/editor/serializer.test.ts +133 -0
  241. package/src/editor/serializer.ts +370 -0
  242. package/src/editor/slash-items.ts +115 -0
  243. package/src/editor/slash-menu.svelte +159 -0
  244. package/src/editor/youtube-url.ts +35 -0
  245. package/src/effects/interactive-hover-button/InteractiveHoverButton.svelte +13 -13
  246. package/src/gallery.test.ts +20 -1
  247. package/src/lib/result.ts +19 -0
  248. package/src/onboarding/README.md +120 -0
  249. package/src/onboarding/define-tours.test.ts +50 -0
  250. package/src/onboarding/define-tours.ts +31 -0
  251. package/src/onboarding/demo.svelte +86 -0
  252. package/src/onboarding/index.ts +12 -0
  253. package/src/onboarding/onboarding-provider.svelte +120 -0
  254. package/src/onboarding/onboarding.svelte.ts +24 -0
  255. package/src/onboarding/pick.test.ts +79 -0
  256. package/src/onboarding/pick.ts +36 -0
  257. package/src/onboarding/stores.test.ts +103 -0
  258. package/src/onboarding/stores.ts +58 -0
  259. package/src/onboarding/tour-card.svelte +70 -0
  260. package/src/onboarding/tour-runner.svelte +197 -0
  261. package/src/onboarding/types.ts +34 -0
  262. package/src/onboarding/wait.ts +29 -0
  263. package/src/shadcn/command/command-input.svelte +1 -1
  264. package/src/slides/Controls.svelte +138 -0
  265. package/src/slides/Deck.svelte +271 -0
  266. package/src/slides/Fragment.svelte +71 -0
  267. package/src/slides/Overview.svelte +113 -0
  268. package/src/slides/Presenter.svelte +292 -0
  269. package/src/slides/README.md +89 -0
  270. package/src/slides/Slide.svelte +179 -0
  271. package/src/slides/SlideList.svelte +19 -0
  272. package/src/slides/ambient.d.ts +1 -0
  273. package/src/slides/bridges.ts +69 -0
  274. package/src/slides/deck-controller.svelte.ts +292 -0
  275. package/src/slides/demo-slides.svelte +358 -0
  276. package/src/slides/demo.svelte +176 -0
  277. package/src/slides/export/capture.ts +113 -0
  278. package/src/slides/export/ffmpeg.ts +120 -0
  279. package/src/slides/export/index.ts +9 -0
  280. package/src/slides/export/launch.ts +10 -0
  281. package/src/slides/export/pdf.ts +152 -0
  282. package/src/slides/export/types.ts +48 -0
  283. package/src/slides/index.ts +19 -0
  284. package/src/slides/labels.ts +48 -0
  285. package/src/slides/layout.ts +51 -0
  286. package/src/slides/model.ts +47 -0
  287. package/src/slides/navigation.ts +100 -0
  288. package/src/slides/theme.css +205 -0
  289. package/src/slides/types.ts +86 -0
  290. package/src/tasks/README.md +312 -0
  291. package/src/tasks/ambient.d.ts +3 -0
  292. package/src/tasks/board-order.ts +137 -0
  293. package/src/tasks/dates.ts +31 -0
  294. package/src/tasks/define-statuses.ts +32 -0
  295. package/src/tasks/demo.svelte +389 -0
  296. package/src/tasks/detail/detail-demo.svelte +186 -0
  297. package/src/tasks/detail/detail-types.ts +54 -0
  298. package/src/tasks/detail/task-chip.svelte +28 -0
  299. package/src/tasks/detail/task-detail.svelte +315 -0
  300. package/src/tasks/detail/task-property.svelte +17 -0
  301. package/src/tasks/detail/task-thread.svelte +258 -0
  302. package/src/tasks/gantt/gantt-bar.svelte +243 -0
  303. package/src/tasks/gantt/gantt-grid.svelte +94 -0
  304. package/src/tasks/gantt/gantt-group-row.svelte +55 -0
  305. package/src/tasks/gantt/gantt-header.svelte +71 -0
  306. package/src/tasks/gantt/gantt-task-row.svelte +186 -0
  307. package/src/tasks/gantt/gantt.css +22 -0
  308. package/src/tasks/gantt/gantt.test.ts +77 -0
  309. package/src/tasks/gantt/logic.test.ts +163 -0
  310. package/src/tasks/gantt/logic.ts +248 -0
  311. package/src/tasks/inbox/inbox-card.svelte +283 -0
  312. package/src/tasks/inbox/inbox-draft.svelte +84 -0
  313. package/src/tasks/inbox/inbox-logic.ts +55 -0
  314. package/src/tasks/inbox/inbox-statuses.ts +48 -0
  315. package/src/tasks/inbox/inbox-trace.svelte +45 -0
  316. package/src/tasks/inbox/inbox-types.ts +108 -0
  317. package/src/tasks/inbox/inbox.svelte +203 -0
  318. package/src/tasks/inbox/index.ts +23 -0
  319. package/src/tasks/index.ts +85 -0
  320. package/src/tasks/kanban-board.svelte +561 -0
  321. package/src/tasks/kanban-column.svelte +117 -0
  322. package/src/tasks/kanban-drag.ts +38 -0
  323. package/src/tasks/kanban-types.ts +65 -0
  324. package/src/tasks/presets/sales.ts +16 -0
  325. package/src/tasks/record/demo-data.ts +286 -0
  326. package/src/tasks/record/field-section.svelte +53 -0
  327. package/src/tasks/record/fields.ts +142 -0
  328. package/src/tasks/record/format.ts +87 -0
  329. package/src/tasks/record/gutter.ts +2 -0
  330. package/src/tasks/record/index.ts +54 -0
  331. package/src/tasks/record/info-list.svelte +316 -0
  332. package/src/tasks/record/kinds.ts +42 -0
  333. package/src/tasks/record/logic.ts +324 -0
  334. package/src/tasks/record/next-up.svelte +70 -0
  335. package/src/tasks/record/project-list.svelte +133 -0
  336. package/src/tasks/record/record-demo.svelte +324 -0
  337. package/src/tasks/record/record-header.svelte +70 -0
  338. package/src/tasks/record/record-shell.svelte +138 -0
  339. package/src/tasks/record/record-stats.svelte +48 -0
  340. package/src/tasks/record/record-timeline.svelte +231 -0
  341. package/src/tasks/record/record.test.ts +350 -0
  342. package/src/tasks/record/steps-rail.svelte +474 -0
  343. package/src/tasks/record/types.ts +280 -0
  344. package/src/tasks/record/where-we-are.svelte +121 -0
  345. package/src/tasks/reorder.ts +19 -0
  346. package/src/tasks/side-panel-types.ts +16 -0
  347. package/src/tasks/side-panel.svelte +193 -0
  348. package/src/tasks/status-badge.svelte +63 -0
  349. package/src/tasks/status-dot.svelte +228 -0
  350. package/src/tasks/status-icon.svelte +30 -0
  351. package/src/tasks/status-types.ts +40 -0
  352. package/src/tasks/task-gantt.svelte +230 -0
  353. package/src/tasks/task-list.svelte +470 -0
  354. package/src/tasks/task-types.ts +33 -0
  355. package/src/tasks/time/duration.ts +69 -0
  356. package/src/tasks/time/time-chip.svelte +62 -0
  357. package/src/tasks/time/time-dot.svelte +58 -0
  358. package/src/tasks/time/time-ring.svelte +74 -0
  359. package/src/tasks/time/time-tracker.svelte +565 -0
  360. package/src/tasks/time/time-types.ts +74 -0
  361. package/src/tasks/use-kanban-dnd.svelte.ts +173 -0
  362. package/src/tokens/base.css +5 -5
  363. package/src/tokens/palette.css +115 -120
@@ -0,0 +1,77 @@
1
+ import { mount, tick, unmount } from "svelte";
2
+ import { afterEach, describe, expect, it } from "vite-plus/test";
3
+ import TaskGantt from "../task-gantt.svelte";
4
+ import type { GanttChange } from "../task-types.js";
5
+
6
+ type Task = { id: string; title: string; start?: string; end?: string; parent?: string };
7
+ const items: Task[] = [
8
+ { id: "a", title: "Design", start: "2026-10-05", end: "2026-10-09" },
9
+ { id: "p", title: "Parent", parent: undefined },
10
+ { id: "c", title: "Child", start: "2026-10-14", end: "2026-10-15", parent: "p" },
11
+ ];
12
+
13
+ let target: HTMLElement;
14
+ let app: Record<string, unknown> | undefined;
15
+
16
+ function render(extra: Record<string, unknown> = {}) {
17
+ target = document.createElement("div");
18
+ document.body.append(target);
19
+ app = mount(TaskGantt, {
20
+ target,
21
+ props: {
22
+ items,
23
+ getId: (t: Task) => t.id,
24
+ getTitle: (t: Task) => t.title,
25
+ getStart: (t: Task) => t.start,
26
+ getEnd: (t: Task) => t.end,
27
+ today: "2026-10-16",
28
+ ...extra,
29
+ },
30
+ }) as Record<string, unknown>;
31
+ return target;
32
+ }
33
+
34
+ afterEach(() => {
35
+ if (app) void unmount(app);
36
+ target?.remove();
37
+ app = undefined;
38
+ });
39
+
40
+ describe("TaskGantt", () => {
41
+ it("draws a bar per dated task with its dates in the label", () => {
42
+ const root = render();
43
+ const bars = root.querySelectorAll("[data-bar]");
44
+ expect(bars).toHaveLength(2);
45
+ expect(bars[0]?.getAttribute("aria-label")).toContain("Oct 5 to Oct 9, 5 days");
46
+ });
47
+
48
+ it("moves a bar a day on an arrow key, and grows it on shift plus arrow", async () => {
49
+ const changes: GanttChange[] = [];
50
+ const root = render({ onChange: (_id: string, change: GanttChange) => changes.push(change) });
51
+ const bar = root.querySelector("[data-bar]");
52
+ bar?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
53
+ bar?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", shiftKey: true, bubbles: true }));
54
+ await tick();
55
+ expect(changes).toEqual([
56
+ { start: "2026-10-06", end: "2026-10-10" },
57
+ { start: "2026-10-05", end: "2026-10-10" },
58
+ ]);
59
+ });
60
+
61
+ it("selects on Enter and stays read-only without onChange", async () => {
62
+ const picked: string[] = [];
63
+ const root = render({ onSelect: (id: string) => picked.push(id) });
64
+ const bar = root.querySelector("[data-bar]");
65
+ bar?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
66
+ bar?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
67
+ await tick();
68
+ expect(picked).toEqual(["a"]);
69
+ expect(root.querySelectorAll(".g-handle")).toHaveLength(0);
70
+ });
71
+
72
+ it("hides a folded parent's children", () => {
73
+ const nested = render({ getParent: (t: Task) => t.parent, collapsed: ["p"] });
74
+ expect(nested.querySelectorAll('[role="listitem"]')).toHaveLength(2);
75
+ expect(nested.querySelector('button[aria-expanded="false"]')).not.toBeNull();
76
+ });
77
+ });
@@ -0,0 +1,163 @@
1
+ import { describe, expect, it } from "vite-plus/test";
2
+ import { dayNumber } from "../dates.js";
3
+ import { barWidth, buildArrows, buildMonths, buildDays, buildRows, liveSpan, rangeLabel, rangeOf, roundedPath, spanOf, type Drag, type Layout } from "./logic.js";
4
+
5
+ type Task = { id: string; start?: string; end?: string; parent?: string; group?: string; after?: string[] };
6
+
7
+ const dates = { getStart: (t: Task) => t.start, getEnd: (t: Task) => t.end };
8
+ const source = (overrides: Partial<Parameters<typeof buildRows<Task>>[1]> = {}) => ({
9
+ getId: (t: Task) => t.id,
10
+ ...dates,
11
+ folded: new Set<string>(),
12
+ drag: null,
13
+ ...overrides,
14
+ });
15
+
16
+ describe("spanOf", () => {
17
+ it("reads inclusive dates", () => {
18
+ const span = spanOf({ id: "a", start: "2026-10-05", end: "2026-10-07" }, dates);
19
+ expect(span && span.end - span.start).toBe(2);
20
+ });
21
+ it("lets a lone date stand for a one-day task", () => {
22
+ expect(spanOf({ id: "a", end: "2026-10-05" }, dates)).toEqual({ start: dayNumber("2026-10-05"), end: dayNumber("2026-10-05") });
23
+ expect(spanOf({ id: "a", start: "2026-10-05" }, dates)).toEqual({ start: dayNumber("2026-10-05"), end: dayNumber("2026-10-05") });
24
+ });
25
+ it("gives an undated task no span, and never an end before the start", () => {
26
+ expect(spanOf({ id: "a" }, dates)).toBeNull();
27
+ expect(spanOf({ id: "a", start: "2026-10-09", end: "2026-10-05" }, dates)?.end).toBe(dayNumber("2026-10-09"));
28
+ });
29
+ });
30
+
31
+ describe("liveSpan", () => {
32
+ const span = { start: 100, end: 104 };
33
+ const drag = (mode: Drag["mode"], dx: number): Drag => ({ id: "a", mode, originX: 0, dx });
34
+ it("moves, resizes and clamps at the other edge", () => {
35
+ expect(liveSpan(span, drag("move", 3), "a")).toEqual({ start: 103, end: 107 });
36
+ expect(liveSpan(span, drag("start", 9), "a")).toEqual({ start: 104, end: 104 });
37
+ expect(liveSpan(span, drag("end", -9), "a")).toEqual({ start: 100, end: 100 });
38
+ });
39
+ it("leaves other rows and idle drags alone", () => {
40
+ expect(liveSpan(span, drag("move", 3), "b")).toBe(span);
41
+ expect(liveSpan(span, drag("move", 0), "a")).toBe(span);
42
+ expect(liveSpan(null, drag("move", 3), "a")).toBeNull();
43
+ });
44
+ });
45
+
46
+ describe("rangeOf", () => {
47
+ const today = dayNumber("2026-10-21");
48
+ it("pads the tasks' span and starts on a Monday", () => {
49
+ const { first, last } = rangeOf([{ id: "a", start: "2026-10-14", end: "2026-10-30" }], dates, today);
50
+ expect(new Date(first * 86_400_000).getUTCDay()).toBe(1);
51
+ expect(first).toBeLessThanOrEqual(dayNumber("2026-10-07"));
52
+ expect(last).toBe(dayNumber("2026-10-30") + 21);
53
+ });
54
+ it("honours from and to, and never ends before it starts", () => {
55
+ expect(rangeOf([], dates, today, "2026-10-21", "2026-10-01")).toEqual({ first: today, last: today });
56
+ });
57
+ });
58
+
59
+ describe("buildRows", () => {
60
+ const tasks: Task[] = [
61
+ { id: "p", parent: undefined },
62
+ { id: "c1", parent: "p", start: "2026-10-05", end: "2026-10-06" },
63
+ { id: "c2", parent: "p", start: "2026-10-08", end: "2026-10-09" },
64
+ { id: "solo", start: "2026-10-01", end: "2026-10-02" },
65
+ ];
66
+ it("is flat without a parent accessor", () => {
67
+ const rows = buildRows(tasks, source());
68
+ expect(rows.map((r) => (r.kind === "task" ? r.depth : -1))).toEqual([0, 0, 0, 0]);
69
+ });
70
+ it("nests children under their parent and draws the span below an undated parent", () => {
71
+ const rows = buildRows(tasks, source({ getParent: (t) => t.parent }));
72
+ const ids = rows.map((r) => (r.kind === "task" ? `${r.id}:${r.depth}` : "g"));
73
+ expect(ids).toEqual(["p:0", "c1:1", "c2:1", "solo:0"]);
74
+ const parent = rows[0];
75
+ if (parent.kind !== "task") throw new Error("expected a task row");
76
+ expect(parent.own).toBeNull();
77
+ expect(parent.span).toEqual({ start: dayNumber("2026-10-05"), end: dayNumber("2026-10-09") });
78
+ expect(parent.hasChildren).toBe(true);
79
+ });
80
+ it("hides the children of a folded parent but keeps its span", () => {
81
+ const rows = buildRows(tasks, source({ getParent: (t) => t.parent, folded: new Set(["p"]) }));
82
+ expect(rows.map((r) => (r.kind === "task" ? r.id : "g"))).toEqual(["p", "solo"]);
83
+ const parent = rows[0];
84
+ if (parent.kind !== "task") throw new Error("expected a task row");
85
+ expect(parent.open).toBe(false);
86
+ expect(parent.span).not.toBeNull();
87
+ });
88
+ it("puts a group header before its tasks, in the order given", () => {
89
+ const grouped: Task[] = [
90
+ { id: "a", group: "two", start: "2026-10-05", end: "2026-10-06" },
91
+ { id: "b", group: "one", start: "2026-10-01", end: "2026-10-02" },
92
+ { id: "c", group: "one", start: "2026-10-10", end: "2026-10-12" },
93
+ ];
94
+ const rows = buildRows(grouped, source({
95
+ groups: [{ id: "one", label: "One" }, { id: "two", label: "Two" }],
96
+ getGroup: (t) => t.group ?? "",
97
+ }));
98
+ expect(rows.map((r) => (r.kind === "group" ? `#${r.group.id}:${r.count}` : r.id))).toEqual(["#one:2", "b", "c", "#two:1", "a"]);
99
+ expect(rows[0].span).toEqual({ start: dayNumber("2026-10-01"), end: dayNumber("2026-10-12") });
100
+ });
101
+ it("applies a live drag to the row it moves", () => {
102
+ const rows = buildRows([{ id: "a", start: "2026-10-05", end: "2026-10-06" }], source({ drag: { id: "a", mode: "move", originX: 0, dx: 2 } }));
103
+ const row = rows[0];
104
+ if (row.kind !== "task") throw new Error("expected a task row");
105
+ expect(row.span).toEqual({ start: dayNumber("2026-10-07"), end: dayNumber("2026-10-08") });
106
+ });
107
+ });
108
+
109
+ describe("buildArrows", () => {
110
+ const first = dayNumber("2026-10-05");
111
+ const layout: Layout = { first, last: first + 30, dayWidth: 32, rowHeight: 40, inset: 3, timelineWidth: 31 * 32, todayDay: first };
112
+ const rows = (tasks: Task[]) => buildRows(tasks, source());
113
+ it("draws one arrow per known dependency and skips unknown or undated ones", () => {
114
+ const tasks: Task[] = [
115
+ { id: "a", start: "2026-10-05", end: "2026-10-06" },
116
+ { id: "b", start: "2026-10-12", end: "2026-10-13", after: ["a", "ghost"] },
117
+ { id: "c", after: ["a"] },
118
+ ];
119
+ const arrows = buildArrows(rows(tasks), layout, (t) => tasks.find((x) => x.id === t.id)?.after);
120
+ expect(arrows.map((a) => a.key)).toEqual(["a->b"]);
121
+ });
122
+ it("bends once in the gap when there's room", () => {
123
+ const tasks: Task[] = [
124
+ { id: "a", start: "2026-10-05", end: "2026-10-05" },
125
+ { id: "b", start: "2026-10-12", end: "2026-10-12", after: ["a"] },
126
+ ];
127
+ const [arrow] = buildArrows(rows(tasks), layout, (t) => tasks.find((x) => x.id === t.id)?.after);
128
+ expect(arrow.d.match(/Q/g)).toHaveLength(2);
129
+ });
130
+ it("routes round the row gap when the bars touch", () => {
131
+ const tasks: Task[] = [
132
+ { id: "a", start: "2026-10-05", end: "2026-10-08" },
133
+ { id: "b", start: "2026-10-06", end: "2026-10-09", after: ["a"] },
134
+ ];
135
+ const [arrow] = buildArrows(rows(tasks), layout, (t) => tasks.find((x) => x.id === t.id)?.after);
136
+ expect(arrow.d.match(/Q/g)).toHaveLength(4);
137
+ });
138
+ });
139
+
140
+ describe("roundedPath", () => {
141
+ it("rounds each corner by up to the radius and ends on the last point", () => {
142
+ expect(roundedPath([[0, 0], [20, 0], [20, 20]], 6)).toBe("M0,0 L14,0 Q20,0 20,6 L20,20");
143
+ });
144
+ });
145
+
146
+ describe("timeline helpers", () => {
147
+ it("groups days under their month", () => {
148
+ const first = dayNumber("2026-10-30");
149
+ const months = buildMonths(buildDays(first, 4), new Intl.DateTimeFormat("en-US", { month: "long", year: "numeric", timeZone: "UTC" }));
150
+ expect(months).toEqual([
151
+ { key: "2026-10", label: "October 2026", i: 0, length: 2 },
152
+ { key: "2026-11", label: "November 2026", i: 2, length: 2 },
153
+ ]);
154
+ });
155
+ it("never draws a bar under 8px, and labels the span", () => {
156
+ const layout: Layout = { first: 0, last: 9, dayWidth: 4, rowHeight: 40, inset: 0, timelineWidth: 40, todayDay: 0 };
157
+ expect(barWidth({ start: 1, end: 1 }, layout)).toBe(8);
158
+ const format = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
159
+ const start = dayNumber("2026-10-05");
160
+ expect(rangeLabel({ start, end: start }, format)).toBe("Oct 5 to Oct 5, 1 day");
161
+ expect(rangeLabel({ start, end: start + 2 }, format)).toBe("Oct 5 to Oct 7, 3 days");
162
+ });
163
+ });
@@ -0,0 +1,248 @@
1
+ import type { KanbanColumn } from "../kanban-types.js";
2
+ import { dayNumber, isoFromDay, weekday } from "../dates.js";
3
+
4
+ export type Span = { start: number; end: number };
5
+ export type Drag = { id: string; mode: "move" | "start" | "end"; originX: number; dx: number };
6
+
7
+ export type TaskRow<T> = {
8
+ kind: "task";
9
+ item: T;
10
+ id: string;
11
+ order: number;
12
+ depth: number;
13
+ hasChildren: boolean;
14
+ open: boolean;
15
+ /** The task's own dates, with a live drag applied. Null when undated. */
16
+ own: Span | null;
17
+ /** What the row draws: its own span, else the span of what is below it. */
18
+ span: Span | null;
19
+ };
20
+ export type GroupRow = { kind: "group"; group: KanbanColumn; count: number; span: Span | null };
21
+ export type Row<T> = GroupRow | TaskRow<T>;
22
+
23
+ export type Arrow = { key: string; d: string; head: string; from: string; to: string };
24
+
25
+ /** The numbers every part of the chart measures from. */
26
+ export type Layout = {
27
+ /** The first day drawn, as a day number. */
28
+ first: number;
29
+ last: number;
30
+ dayWidth: number;
31
+ rowHeight: number;
32
+ /** Gap either side of a bar. Under 18px a day is too narrow for one. */
33
+ inset: number;
34
+ timelineWidth: number;
35
+ todayDay: number;
36
+ };
37
+
38
+ type Dates<T> = {
39
+ getStart: (item: T) => string | null | undefined;
40
+ getEnd: (item: T) => string | null | undefined;
41
+ };
42
+
43
+ export type RowSource<T> = Dates<T> & {
44
+ getId: (item: T) => string;
45
+ getParent?: (item: T) => string | null | undefined;
46
+ getGroup?: (item: T) => string;
47
+ groups?: KanbanColumn[];
48
+ folded: Set<string>;
49
+ drag: Drag | null;
50
+ };
51
+
52
+ const DAY_MS = 86_400_000;
53
+
54
+ export function dayX(layout: Layout, day: number): number {
55
+ return (day - layout.first) * layout.dayWidth;
56
+ }
57
+
58
+ /** The task's own dates. A lone date stands for a one-day task. */
59
+ export function spanOf<T>(item: T, { getStart, getEnd }: Dates<T>): Span | null {
60
+ const first = getStart(item) || getEnd(item);
61
+ const last = getEnd(item) || getStart(item);
62
+ if (!first || !last) return null;
63
+ const start = dayNumber(first);
64
+ return { start, end: Math.max(start, dayNumber(last)) };
65
+ }
66
+
67
+ /** A bar's span, with the live drag applied. Resizes never cross the other edge. */
68
+ export function liveSpan(span: Span | null, drag: Drag | null, id: string): Span | null {
69
+ if (!span || drag?.id !== id || drag.dx === 0) return span;
70
+ if (drag.mode === "move") return { start: span.start + drag.dx, end: span.end + drag.dx };
71
+ if (drag.mode === "start") return { start: Math.min(span.start + drag.dx, span.end), end: span.end };
72
+ return { start: span.start, end: Math.max(span.end + drag.dx, span.start) };
73
+ }
74
+
75
+ function join(a: Span | null, b: Span | null): Span | null {
76
+ return !a ? b : !b ? a : { start: Math.min(a.start, b.start), end: Math.max(a.end, b.end) };
77
+ }
78
+
79
+ /** The days shown: the tasks' span, padded, starting on a Monday, unless `from` and `to` say otherwise. */
80
+ export function rangeOf<T>(
81
+ items: T[],
82
+ source: Dates<T>,
83
+ todayDay: number,
84
+ from?: string,
85
+ to?: string,
86
+ ): { first: number; last: number } {
87
+ let lo = todayDay;
88
+ let hi = todayDay;
89
+ for (const item of items) {
90
+ const span = spanOf(item, source);
91
+ if (!span) continue;
92
+ lo = Math.min(lo, span.start);
93
+ hi = Math.max(hi, span.end);
94
+ }
95
+ let first = from ? dayNumber(from) : lo - 7;
96
+ // Day 0 was a Thursday. Step back to the Monday on or before `first`.
97
+ if (!from) first -= (weekday(first) + 6) % 7;
98
+ const last = to ? dayNumber(to) : hi + 21;
99
+ return { first, last: Math.max(last, first) };
100
+ }
101
+
102
+ export function buildDays(first: number, count: number) {
103
+ return Array.from({ length: count }, (_, i) => {
104
+ const day = first + i;
105
+ const wd = weekday(day);
106
+ return { day, i, date: Number(isoFromDay(day).slice(8, 10)), wd, weekend: wd === 0 || wd === 6 };
107
+ });
108
+ }
109
+ export type GanttDay = ReturnType<typeof buildDays>[number];
110
+
111
+ export function buildMonths(days: GanttDay[], format: Intl.DateTimeFormat) {
112
+ const out: { key: string; label: string; i: number; length: number }[] = [];
113
+ for (const d of days) {
114
+ const key = isoFromDay(d.day).slice(0, 7);
115
+ const last = out.at(-1);
116
+ if (last?.key === key) last.length++;
117
+ else out.push({ key, label: format.format(new Date(d.day * DAY_MS)), i: d.i, length: 1 });
118
+ }
119
+ return out;
120
+ }
121
+
122
+ /** Rows in draw order. Groups come first, then their tasks, a parent before its children. */
123
+ export function buildRows<T>(items: T[], source: RowSource<T>): Row<T>[] {
124
+ const { getId, getParent, groups, getGroup, folded, drag } = source;
125
+ const out: Row<T>[] = [];
126
+ let order = 0;
127
+ const live = (item: T, id: string) => liveSpan(spanOf(item, source), drag, id);
128
+
129
+ // Depth first, so a parent's row comes before its children. A parent's span is known
130
+ // only after its children are, so the row is pushed first and filled in after.
131
+ const emit = (members: T[]) => {
132
+ if (!getParent) {
133
+ for (const item of members) {
134
+ const id = getId(item);
135
+ const own = live(item, id);
136
+ out.push({ kind: "task", item, id, order: order++, depth: 0, hasChildren: false, open: true, own, span: own });
137
+ }
138
+ return;
139
+ }
140
+ const ids = new Set(members.map((item) => getId(item)));
141
+ const kids = new Map<string | null, T[]>();
142
+ for (const item of members) {
143
+ const parent = getParent(item);
144
+ const key = parent && ids.has(parent) ? parent : null;
145
+ kids.set(key, [...(kids.get(key) ?? []), item]);
146
+ }
147
+ const walk = (item: T, depth: number, visible: boolean): Span | null => {
148
+ const id = getId(item);
149
+ const children = kids.get(id) ?? [];
150
+ const open = !folded.has(id);
151
+ const own = live(item, id);
152
+ const row: TaskRow<T> = { kind: "task", item, id, order: 0, depth, hasChildren: children.length > 0, open, own, span: own };
153
+ if (visible) {
154
+ row.order = order++;
155
+ out.push(row);
156
+ }
157
+ let below: Span | null = null;
158
+ for (const child of children) below = join(below, walk(child, depth + 1, visible && open));
159
+ if (!own) row.span = below;
160
+ return join(own, below);
161
+ };
162
+ for (const item of kids.get(null) ?? []) walk(item, 0, true);
163
+ };
164
+
165
+ if (!groups || !getGroup) {
166
+ emit(items);
167
+ return out;
168
+ }
169
+ for (const group of groups) {
170
+ const members = items.filter((item) => getGroup(item) === group.id);
171
+ let span: Span | null = null;
172
+ for (const item of members) span = join(span, live(item, getId(item)));
173
+ out.push({ kind: "group", group, count: members.length, span });
174
+ emit(members);
175
+ }
176
+ return out;
177
+ }
178
+
179
+ /** A right-angled path through `points`, each corner rounded by up to `radius`. */
180
+ export function roundedPath(points: [number, number][], radius: number): string {
181
+ let d = `M${points[0][0]},${points[0][1]}`;
182
+ for (let i = 1; i < points.length - 1; i++) {
183
+ const [px, py] = points[i - 1];
184
+ const [cx, cy] = points[i];
185
+ const [nx, ny] = points[i + 1];
186
+ const r = Math.min(radius, Math.hypot(cx - px, cy - py) / 2, Math.hypot(nx - cx, ny - cy) / 2);
187
+ const inX = cx - Math.sign(cx - px) * r;
188
+ const inY = cy - Math.sign(cy - py) * r;
189
+ const outX = cx + Math.sign(nx - cx) * r;
190
+ const outY = cy + Math.sign(ny - cy) * r;
191
+ d += ` L${inX},${inY} Q${cx},${cy} ${outX},${outY}`;
192
+ }
193
+ const [lx, ly] = points[points.length - 1];
194
+ return `${d} L${lx},${ly}`;
195
+ }
196
+
197
+ /** Each arrow runs from the end of the task waited on to the start of the one waiting. */
198
+ export function buildArrows<T>(rows: Row<T>[], layout: Layout, getDependsOn: (item: T) => string[] | undefined): Arrow[] {
199
+ const { rowHeight, inset } = layout;
200
+ const at = new Map<string, { row: number; span: Span }>();
201
+ rows.forEach((row, index) => {
202
+ if (row.kind === "task" && row.span) at.set(row.id, { row: index, span: row.span });
203
+ });
204
+ const out: Arrow[] = [];
205
+ for (const row of rows) {
206
+ if (row.kind !== "task") continue;
207
+ const target = at.get(row.id);
208
+ if (!target) continue;
209
+ for (const dep of getDependsOn(row.item) ?? []) {
210
+ const source = at.get(dep);
211
+ if (!source) continue;
212
+ const x1 = dayX(layout, source.span.end + 1) - inset;
213
+ const y1 = source.row * rowHeight + rowHeight / 2;
214
+ const x2 = dayX(layout, target.span.start) + inset;
215
+ const y2 = target.row * rowHeight + rowHeight / 2;
216
+ // Room between the bars: one bend in the gap. No room: out, round the row gap, and back in.
217
+ const points: [number, number][] =
218
+ x2 - x1 >= 24
219
+ ? [[x1, y1], [x1 + 12, y1], [x1 + 12, y2], [x2, y2]]
220
+ : [
221
+ [x1, y1],
222
+ [x1 + 10, y1],
223
+ [x1 + 10, y1 + (y2 > y1 ? rowHeight / 2 : -rowHeight / 2)],
224
+ [x2 - 12, y1 + (y2 > y1 ? rowHeight / 2 : -rowHeight / 2)],
225
+ [x2 - 12, y2],
226
+ [x2, y2],
227
+ ];
228
+ out.push({
229
+ key: `${dep}->${row.id}`,
230
+ d: roundedPath(points, 6),
231
+ head: `M${x2 - 5},${y2 - 3.5} L${x2},${y2} L${x2 - 5},${y2 + 3.5}`,
232
+ from: dep,
233
+ to: row.id,
234
+ });
235
+ }
236
+ }
237
+ return out;
238
+ }
239
+
240
+ /** The pixel width of a span's bar, never under 8px. */
241
+ export function barWidth(span: Span, layout: Layout): number {
242
+ return Math.max((span.end - span.start + 1) * layout.dayWidth - layout.inset * 2, 8);
243
+ }
244
+
245
+ export function rangeLabel(span: Span, format: Intl.DateTimeFormat): string {
246
+ const length = span.end - span.start + 1;
247
+ return `${format.format(new Date(span.start * DAY_MS))} to ${format.format(new Date(span.end * DAY_MS))}, ${length} ${length === 1 ? "day" : "days"}`;
248
+ }