@heyramzi/ui 2.1.0 → 2.3.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 (317) hide show
  1. package/COMPONENTS.md +1 -1
  2. package/package.json +7 -8
  3. package/scripts/sync-shadcn.mjs +3 -4
  4. package/src/agents/agent-avatar.svelte +604 -575
  5. package/src/agents/agent-cast.svelte +179 -183
  6. package/src/agents/demo.svelte +125 -113
  7. package/src/agents/helpers.ts +23 -17
  8. package/src/agents/index.ts +4 -4
  9. package/src/agents/page-state.ts +6 -6
  10. package/src/agents/types.ts +6 -6
  11. package/src/ai/ai-button/AiButton.svelte +4 -12
  12. package/src/ai/generation-progress/GenerationProgress.svelte +10 -3
  13. package/src/ai/prompt-input/PromptInputTextarea.svelte +1 -2
  14. package/src/ai/response/Response.css +2 -3
  15. package/src/animations/avatar-group-hover.css +2 -16
  16. package/src/animations/card-tilt.css +3 -6
  17. package/src/animations/digit-pop-in.css +2 -15
  18. package/src/animations/dropdown.css +4 -17
  19. package/src/animations/input-clear-dissolve.css +5 -18
  20. package/src/animations/input-shake.css +9 -41
  21. package/src/animations/learn-more-hover.css +17 -9
  22. package/src/animations/like-button.css +2 -4
  23. package/src/animations/page-side-by-side.css +2 -10
  24. package/src/animations/plus-menu-morph.css +2 -5
  25. package/src/animations/shimmer-text.css +5 -18
  26. package/src/animations/spinning-counter.css +2 -6
  27. package/src/animations/success-check.css +2 -15
  28. package/src/animations/thinking-states.css +2 -7
  29. package/src/brand/brand.test.ts +21 -5
  30. package/src/brand/index.ts +4 -10
  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/canvas-cleanup.test.ts +1 -1
  49. package/src/canvas/headless/svg-figure.test.ts +5 -1
  50. package/src/canvas/lod/lod-switch.svelte +111 -106
  51. package/src/canvas/ops/ops.ts +1 -1
  52. package/src/canvas/pen/PenField.svelte +87 -81
  53. package/src/canvas/pen/PenOverlay.svelte +127 -127
  54. package/src/canvas/pen/ShapeNode.svelte +31 -32
  55. package/src/canvas/pen/TextNode.svelte +54 -53
  56. package/src/canvas/pen/host-field.test.svelte +7 -7
  57. package/src/canvas/pen/shapes.ts +7 -1
  58. package/src/canvas/screen/GlyphIcon.svelte +22 -17
  59. package/src/canvas/screen/PartView.svelte +138 -72
  60. package/src/canvas/screen/ScreenBody.svelte +81 -60
  61. package/src/canvas/screen/ScreenCard.svelte +20 -20
  62. package/src/canvas/ui/CanvasToolbar.svelte +173 -85
  63. package/src/canvas/ui/FrameLayer.svelte +59 -59
  64. package/src/canvas/ui/PenLayer.svelte +39 -30
  65. package/src/canvas/ui/StylePanel.svelte +119 -102
  66. package/src/canvas/ui/canvas-grid.svelte +137 -129
  67. package/src/charts/animated-value.svelte +1 -1
  68. package/src/charts/area-chart.css +2 -5
  69. package/src/charts/area-chart.svelte +1 -1
  70. package/src/charts/bar-chart.css +2 -3
  71. package/src/charts/bar-chart.svelte +1 -1
  72. package/src/charts/charts.css +5 -17
  73. package/src/charts/crosshair.svelte +1 -1
  74. package/src/charts/dashboard/dashboard-empty.svelte +25 -25
  75. package/src/charts/dashboard/dashboard-section.svelte +24 -24
  76. package/src/charts/dashboard/data-panel.svelte +50 -50
  77. package/src/charts/dashboard/data-sort-head.svelte +56 -56
  78. package/src/charts/dashboard/data-table.svelte +138 -138
  79. package/src/charts/dashboard/panel-grid.svelte +30 -30
  80. package/src/charts/dashboard/period-filter.svelte +25 -25
  81. package/src/charts/dashboard/rating-chip.svelte +19 -19
  82. package/src/charts/dashboard/stat-band.svelte +32 -32
  83. package/src/charts/dashboard/stat-tile.svelte +106 -99
  84. package/src/charts/donut-chart.svelte +1 -1
  85. package/src/charts/edge-fade.svelte +1 -1
  86. package/src/charts/figure.svelte +1 -1
  87. package/src/charts/gauge-chart.css +4 -7
  88. package/src/charts/gauge-chart.svelte +1 -1
  89. package/src/charts/hover-focus.svelte +1 -1
  90. package/src/charts/legend.css +1 -3
  91. package/src/charts/legend.svelte +1 -1
  92. package/src/charts/line-chart.svelte +1 -1
  93. package/src/charts/number-chart.css +6 -9
  94. package/src/charts/number-chart.svelte +1 -1
  95. package/src/charts/reference-line.svelte +19 -19
  96. package/src/charts/reveal.svelte +1 -1
  97. package/src/charts/ring-chart.css +2 -4
  98. package/src/charts/ring-chart.svelte +1 -1
  99. package/src/charts/sparkline.svelte +5 -2
  100. package/src/charts/stat-card.svelte +1 -1
  101. package/src/charts/time-series-chart.css +2 -5
  102. package/src/charts/trend-pill.css +2 -4
  103. package/src/charts/trend-pill.svelte +1 -1
  104. package/src/components/copy/copy-button.svelte +93 -98
  105. package/src/components/copy/copy-button.test.ts +10 -2
  106. package/src/components/data-table/infinite-scroll.svelte +42 -42
  107. package/src/components/data-table/pagination.svelte +34 -22
  108. package/src/components/data-table/row-arrow.svelte +19 -16
  109. package/src/components/data-table/row-context-menu.svelte +52 -52
  110. package/src/components/data-table/sortable-head.svelte +25 -25
  111. package/src/components/data-table/table-skeleton.svelte +41 -36
  112. package/src/components/docs/doc-page.svelte +18 -9
  113. package/src/components/docs/on-this-page.svelte +16 -3
  114. package/src/components/docs/scroll-spy.svelte.ts +2 -3
  115. package/src/components/field-row/field-row.svelte +10 -8
  116. package/src/components/hold-to-confirm/hold-to-confirm.svelte +5 -1
  117. package/src/components/icon/icon.svelte +14 -14
  118. package/src/components/language-flag/language-flag.svelte +3 -1
  119. package/src/components/list-page/detail-empty.svelte +34 -34
  120. package/src/components/list-page/detail-header.svelte +55 -47
  121. package/src/components/list-page/detail-panel.svelte +51 -59
  122. package/src/components/list-page/detail-row.svelte +19 -19
  123. package/src/components/list-page/detail-stack.svelte +21 -21
  124. package/src/components/list-page/empty-state.svelte +30 -30
  125. package/src/components/list-page/filter-item.svelte +20 -20
  126. package/src/components/list-page/filter-popover.svelte +60 -55
  127. package/src/components/list-page/grouped-list.svelte +4 -2
  128. package/src/components/list-page/index.ts +5 -1
  129. package/src/components/list-page/list-page-shell.svelte +2 -5
  130. package/src/components/list-page/master-detail.svelte +87 -88
  131. package/src/components/list-page/notice-bar.svelte +38 -38
  132. package/src/components/list-page/prev-next.svelte +63 -63
  133. package/src/components/list-page/rail-body.svelte +18 -18
  134. package/src/components/list-page/rail-empty.svelte +23 -30
  135. package/src/components/list-page/rail-head.svelte +24 -24
  136. package/src/components/list-page/rail-row.svelte +77 -73
  137. package/src/components/list-page/section-host.ts +2 -3
  138. package/src/components/locale-switch/locale-switch.svelte +41 -41
  139. package/src/components/mark/mark.svelte +34 -38
  140. package/src/components/open-in/index.ts +1 -5
  141. package/src/components/open-in/open-in.svelte +40 -40
  142. package/src/components/picker/mark-picker.svelte +49 -49
  143. package/src/components/picker/picker.svelte +5 -3
  144. package/src/components/section-header/section-header.test.ts +7 -1
  145. package/src/components/section-header/section-label.svelte +20 -20
  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/index.ts +1 -1
  158. package/src/components/table-view/table-view.svelte +352 -13
  159. package/src/components/table-view/table-view.test.ts +108 -1
  160. package/src/components/table-view/types.ts +22 -11
  161. package/src/components/tags-input/index.ts +13 -0
  162. package/src/components/tags-input/tags-input.svelte +305 -0
  163. package/src/components/tags-input/tags.test.ts +64 -0
  164. package/src/components/tags-input/tags.ts +142 -0
  165. package/src/components/theme/index.ts +2 -4
  166. package/src/components/theme/theme-provider.svelte +1 -3
  167. package/src/components/toolbar/toolbar-search.svelte +25 -25
  168. package/src/components/toolbar/toolbar.svelte +11 -7
  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/icons/trades.ts +1 -1
  191. package/src/inbox/README.md +1 -3
  192. package/src/inbox/inbox.test.ts +181 -138
  193. package/src/inbox/index.ts +20 -20
  194. package/src/inbox/logic.ts +114 -111
  195. package/src/inbox/thread-inbox.svelte +227 -151
  196. package/src/inbox/types.ts +130 -108
  197. package/src/lib/theme-transition.ts +2 -4
  198. package/src/lib/tone.ts +2 -4
  199. package/src/lib/utils.ts +2 -2
  200. package/src/modules.test.ts +79 -69
  201. package/src/onboarding/define-tours.test.ts +55 -40
  202. package/src/onboarding/define-tours.ts +23 -23
  203. package/src/onboarding/demo.svelte +24 -5
  204. package/src/onboarding/index.ts +1 -8
  205. package/src/onboarding/onboarding-provider.svelte +19 -7
  206. package/src/onboarding/onboarding.svelte.ts +16 -16
  207. package/src/onboarding/pick.test.ts +61 -52
  208. package/src/onboarding/pick.ts +16 -13
  209. package/src/onboarding/stores.test.ts +80 -80
  210. package/src/onboarding/stores.ts +41 -39
  211. package/src/onboarding/tour-card.svelte +30 -5
  212. package/src/onboarding/tour-runner.svelte +12 -3
  213. package/src/onboarding/types.ts +20 -12
  214. package/src/onboarding/wait.ts +22 -22
  215. package/src/panel/side-panel.svelte +94 -93
  216. package/src/record/demo-data.ts +291 -225
  217. package/src/record/field-section.svelte +38 -34
  218. package/src/record/fields.ts +148 -96
  219. package/src/record/format.ts +63 -56
  220. package/src/record/index.ts +49 -35
  221. package/src/record/info-list.svelte +145 -114
  222. package/src/record/kinds.ts +31 -31
  223. package/src/record/logic.ts +216 -214
  224. package/src/record/next-up.svelte +45 -34
  225. package/src/record/project-list.svelte +56 -50
  226. package/src/record/record-demo.svelte +130 -110
  227. package/src/record/record-header.svelte +37 -28
  228. package/src/record/record-shell.svelte +54 -54
  229. package/src/record/record-stats.svelte +25 -25
  230. package/src/record/record-timeline.svelte +134 -114
  231. package/src/record/record.test.ts +430 -309
  232. package/src/record/steps-rail.svelte +225 -216
  233. package/src/record/types.ts +234 -215
  234. package/src/record/where-we-are.svelte +62 -58
  235. package/src/shadcn/button/button.svelte +1 -1
  236. package/src/shadcn/card/card-title.svelte +1 -5
  237. package/src/shadcn/hooks/use-highlighted-code.svelte.ts +2 -13
  238. package/src/shadcn/sheet/sheet-content.svelte +2 -4
  239. package/src/shadcn/table/table-cell.svelte +2 -3
  240. package/src/shadcn/utilities.css +3 -8
  241. package/src/slides/Controls.svelte +150 -124
  242. package/src/slides/Deck.svelte +243 -238
  243. package/src/slides/Fragment.svelte +64 -57
  244. package/src/slides/Overview.svelte +106 -101
  245. package/src/slides/Presenter.svelte +300 -266
  246. package/src/slides/Slide.svelte +185 -170
  247. package/src/slides/SlideList.svelte +16 -12
  248. package/src/slides/ambient.d.ts +1 -1
  249. package/src/slides/bridges.ts +42 -23
  250. package/src/slides/deck-controller.svelte.ts +47 -35
  251. package/src/slides/demo-slides.svelte +330 -306
  252. package/src/slides/demo.svelte +164 -160
  253. package/src/slides/export/capture.ts +20 -16
  254. package/src/slides/export/ffmpeg.ts +43 -38
  255. package/src/slides/export/index.ts +5 -5
  256. package/src/slides/export/launch.ts +3 -1
  257. package/src/slides/export/pdf.ts +30 -27
  258. package/src/slides/export/types.ts +10 -8
  259. package/src/slides/index.ts +11 -11
  260. package/src/slides/labels.ts +22 -19
  261. package/src/slides/layout.ts +9 -2
  262. package/src/slides/model.ts +4 -4
  263. package/src/slides/navigation.ts +43 -31
  264. package/src/slides/theme.css +5 -7
  265. package/src/slides/types.ts +9 -10
  266. package/src/status/define-statuses.ts +17 -24
  267. package/src/status/index.ts +5 -5
  268. package/src/status/status-badge.svelte +3 -1
  269. package/src/status/status-dot.svelte +1 -3
  270. package/src/status/status-icon.svelte +1 -1
  271. package/src/status/status-types.ts +21 -21
  272. package/src/tasks/board-order.ts +2 -4
  273. package/src/tasks/demo.svelte +189 -31
  274. package/src/tasks/detail/detail-demo.svelte +73 -13
  275. package/src/tasks/detail/detail-types.ts +45 -45
  276. package/src/tasks/detail/task-detail.svelte +40 -10
  277. package/src/tasks/detail/task-property.svelte +8 -2
  278. package/src/tasks/detail/task-thread.svelte +53 -12
  279. package/src/tasks/gantt/gantt-bar.svelte +17 -6
  280. package/src/tasks/gantt/gantt-grid.svelte +40 -9
  281. package/src/tasks/gantt/gantt-group-row.svelte +8 -2
  282. package/src/tasks/gantt/gantt-header.svelte +9 -3
  283. package/src/tasks/gantt/gantt-task-row.svelte +26 -6
  284. package/src/tasks/gantt/gantt.css +2 -1
  285. package/src/tasks/gantt/gantt.test.ts +3 -1
  286. package/src/tasks/gantt/logic.test.ts +110 -20
  287. package/src/tasks/gantt/logic.ts +42 -6
  288. package/src/tasks/inbox/inbox-card.svelte +13 -5
  289. package/src/tasks/inbox/inbox-draft.svelte +1 -1
  290. package/src/tasks/inbox/inbox-logic.ts +6 -40
  291. package/src/tasks/inbox/inbox-statuses.ts +23 -23
  292. package/src/tasks/inbox/inbox-trace.svelte +9 -3
  293. package/src/tasks/inbox/inbox-types.ts +54 -58
  294. package/src/tasks/inbox/index.ts +22 -21
  295. package/src/tasks/index.ts +49 -52
  296. package/src/tasks/kanban-board.svelte +16 -12
  297. package/src/tasks/kanban-types.ts +2 -3
  298. package/src/tasks/presets/sales.ts +6 -6
  299. package/src/tasks/task-gantt.svelte +28 -8
  300. package/src/tasks/task-list.svelte +41 -17
  301. package/src/tasks/time/duration.ts +40 -40
  302. package/src/tasks/time/time-chip.svelte +9 -2
  303. package/src/tasks/time/time-dot.svelte +1 -1
  304. package/src/tasks/time/time-ring.svelte +11 -3
  305. package/src/tasks/time/time-tracker.svelte +57 -15
  306. package/src/tasks/time/time-types.ts +57 -57
  307. package/src/tasks/use-kanban-dnd.svelte.ts +2 -3
  308. package/src/thread/index.ts +7 -7
  309. package/src/thread/logic.ts +36 -33
  310. package/src/thread/thread.svelte +82 -16
  311. package/src/thread/thread.test.ts +58 -47
  312. package/src/thread/types.ts +68 -68
  313. package/src/tokens/craft.css +17 -53
  314. package/src/tokens/index.css +3 -13
  315. package/src/tokens/palette.css +13 -11
  316. package/src/tokens/primitives.css +10 -23
  317. package/src/tasks/inbox/inbox.svelte +0 -203
@@ -3,6 +3,7 @@
3
3
  import type { TableColumn } from "./types.js";
4
4
 
5
5
  export type TableCell<Row> = { row: Row; column: TableColumn<Row>; value: unknown; text: string };
6
+ export type TableGroup<Row> = { key: string; rows: Row[] };
6
7
  export type { TableColumn };
7
8
  </script>
8
9
 
@@ -10,8 +11,13 @@
10
11
  // =========================
11
12
  // IMPORTS
12
13
  // =========================
14
+ import { CaretDown, CaretUp, CaretUpDown } from "phosphor-svelte";
13
15
  import { cn } from "../../lib/utils.js";
14
16
  import * as Table from "../../shadcn/table/index.js";
17
+ import * as ContextMenu from "../../shadcn/context-menu/index.js";
18
+ import { Checkbox } from "../../shadcn/checkbox/index.js";
19
+ import { TableSkeleton } from "../data-table/index.js";
20
+ import { toggleSort, type SortState } from "../data-table/data-table.js";
15
21
 
16
22
  // =========================
17
23
  // TYPES
@@ -30,13 +36,111 @@
30
36
  rowClass?: (row: Row) => string | undefined;
31
37
  /** Below this width the table scrolls sideways inside its frame. */
32
38
  minWidth?: string;
39
+ /** No outer border or radius, for a table inside a card. */
40
+ bare?: boolean;
33
41
  class?: string;
42
+ /** The sorted column and its direction. Bind it; columns with `sortable` set it. */
43
+ sort?: SortState<Row>;
44
+ /** Where a row goes. Draws a real link over the row, so middle-click and cmd-click work. */
45
+ rowHref?: (row: Row) => string | undefined;
46
+ /** The link's accessible name. Defaults to the row's first column text. */
47
+ rowLabel?: (row: Row) => string;
48
+ /** Adds a checkbox column with a select-all in the head. */
49
+ selectable?: boolean;
50
+ /** The `rowKey` of every checked row. Bind it. */
51
+ selected?: Set<string>;
52
+ selectAllLabel?: string;
53
+ selectRowLabel?: (row: Row) => string;
54
+ /** The items of a right-click menu for one row: `ContextMenu.Item`s. */
55
+ rowMenu?: Snippet<[Row]>;
56
+ /** Rows that return the same key sit under one header row. Order `rows` so a group is contiguous. */
57
+ group?: (row: Row) => string;
58
+ /** Draws a group's header. Prints the key when left out. */
59
+ groupHeader?: Snippet<[TableGroup<Row>]>;
60
+ /** Makes each group header a button that folds its rows. */
61
+ collapsible?: boolean;
62
+ /** The keys of the folded groups. Bind it. */
63
+ collapsed?: Set<string>;
64
+ /** Draws a column head its own way, an icon or a colour. Print `column.label` for the plain case. */
65
+ head?: Snippet<[TableColumn<Row>]>;
66
+ /** The row opens in place. Click or Enter on the row, unless the target is a link, button or input inside it. */
67
+ onRowClick?: (row: Row) => void;
68
+ /** Whether a row shows its `subRow`. */
69
+ expanded?: (row: Row) => boolean;
70
+ /** A full-width row under the row, for detail. */
71
+ subRow?: Snippet<[Row]>;
72
+ /** Drawn across every column when there are no rows. */
73
+ empty?: Snippet;
74
+ /** Drawn across every column under the last row: a load-more sentinel or a spinner. */
75
+ trailing?: Snippet;
76
+ /** Draws placeholder rows in the same frame instead of `rows`. */
77
+ loading?: boolean;
78
+ skeletonRows?: number;
34
79
  }
35
80
 
36
81
  // =========================
37
82
  // PROPS
38
83
  // =========================
39
- let { columns, rows, rowKey, label, footer, cell, rowClass, minWidth = "0", class: className }: Props = $props();
84
+ let {
85
+ columns,
86
+ rows,
87
+ rowKey,
88
+ label,
89
+ footer,
90
+ cell,
91
+ rowClass,
92
+ minWidth = "0",
93
+ bare = false,
94
+ class: className,
95
+ sort = $bindable(null),
96
+ rowHref,
97
+ rowLabel,
98
+ selectable = false,
99
+ selected = $bindable(new Set<string>()),
100
+ selectAllLabel = "Select all rows",
101
+ selectRowLabel,
102
+ rowMenu,
103
+ group,
104
+ groupHeader,
105
+ collapsible = false,
106
+ collapsed = $bindable(new Set<string>()),
107
+ head,
108
+ onRowClick,
109
+ expanded,
110
+ subRow,
111
+ empty,
112
+ trailing,
113
+ loading = false,
114
+ skeletonRows = 5,
115
+ }: Props = $props();
116
+
117
+ // =========================
118
+ // DERIVED
119
+ // =========================
120
+ let span = $derived(columns.length + (selectable ? 1 : 0));
121
+ let selectedCount = $derived(rows.filter((row) => selected.has(rowKey(row))).length);
122
+ let allSelected = $derived(rows.length > 0 && selectedCount === rows.length);
123
+ let someSelected = $derived(selectedCount > 0 && !allSelected);
124
+ let skeletonColumns = $derived([
125
+ ...(selectable ? [{ width: "w-4" }] : []),
126
+ ...columns.map((column) => ({
127
+ align: column.numeric ? ("right" as const) : ("left" as const),
128
+ })),
129
+ ]);
130
+ let entries = $derived.by<{ group: TableGroup<Row> }[]>(() => {
131
+ if (!group) return [];
132
+ const out: { group: TableGroup<Row> }[] = [];
133
+ let current: TableGroup<Row> | undefined;
134
+ for (const row of rows) {
135
+ const groupKey = group(row);
136
+ if (!current || current.key !== groupKey) {
137
+ current = { key: groupKey, rows: [] };
138
+ out.push({ group: current });
139
+ }
140
+ current.rows.push(row);
141
+ }
142
+ return out;
143
+ });
40
144
 
41
145
  // =========================
42
146
  // FUNCTIONS
@@ -48,6 +152,61 @@
48
152
  if (column.format) return column.format(value, row);
49
153
  return value === null || value === undefined ? "" : String(value);
50
154
  }
155
+
156
+ function ariaSort(column: TableColumn<Row>): "ascending" | "descending" | "none" | undefined {
157
+ if (!column.sortable) return undefined;
158
+ if (sort?.key !== column.key) return "none";
159
+ return sort.direction === "asc" ? "ascending" : "descending";
160
+ }
161
+
162
+ function toggleRow(row: Row, on: boolean) {
163
+ const next = new Set(selected);
164
+ if (on) next.add(rowKey(row));
165
+ else next.delete(rowKey(row));
166
+ selected = next;
167
+ }
168
+
169
+ function toggleAll(on: boolean) {
170
+ const next = new Set(selected);
171
+ for (const row of rows) {
172
+ if (on) next.add(rowKey(row));
173
+ else next.delete(rowKey(row));
174
+ }
175
+ selected = next;
176
+ }
177
+
178
+ const HIDE = {
179
+ sm: "hidden sm:table-cell",
180
+ md: "hidden md:table-cell",
181
+ lg: "hidden lg:table-cell",
182
+ };
183
+
184
+ function toggleGroup(groupKey: string) {
185
+ const next = new Set(collapsed);
186
+ if (next.has(groupKey)) next.delete(groupKey);
187
+ else next.add(groupKey);
188
+ collapsed = next;
189
+ }
190
+
191
+ // WHY the target check: a row can hold its own link, button or checkbox, and a click on one of those belongs to it.
192
+ const OWN_CLICK =
193
+ "a, button, input, select, textarea, label, [role=checkbox], [role=button], [role=menuitem]";
194
+
195
+ function rowClick(event: MouseEvent, row: Row) {
196
+ if ((event.target as Element).closest(OWN_CLICK)) return;
197
+ onRowClick?.(row);
198
+ }
199
+
200
+ function rowKeydown(event: KeyboardEvent, row: Row) {
201
+ if (event.target !== event.currentTarget) return;
202
+ if (event.key !== "Enter" && event.key !== " ") return;
203
+ event.preventDefault();
204
+ onRowClick?.(row);
205
+ }
206
+
207
+ function linkName(row: Row): string {
208
+ return rowLabel?.(row) ?? (columns[0] ? textOf(columns[0], row) : label);
209
+ }
51
210
  </script>
52
211
 
53
212
  <!-- =========================== -->
@@ -60,10 +219,19 @@
60
219
  "align-top",
61
220
  column.numeric ? "text-end tabular-nums" : "whitespace-normal",
62
221
  column.muted && "text-muted-foreground",
222
+ column.interactive && "relative z-10",
223
+ column.hideBelow && HIDE[column.hideBelow],
224
+ column.sticky && "bg-background sticky left-0 z-10",
225
+ column.class,
63
226
  )}
64
227
  >
65
228
  {#if cell}
66
- {@render cell({ row, column, value: (row as Record<string, unknown>)[column.key], text: textOf(column, row) })}
229
+ {@render cell({
230
+ row,
231
+ column,
232
+ value: (row as Record<string, unknown>)[column.key],
233
+ text: textOf(column, row),
234
+ })}
67
235
  {:else}
68
236
  {textOf(column, row)}
69
237
  {/if}
@@ -71,31 +239,202 @@
71
239
  {/each}
72
240
  {/snippet}
73
241
 
74
- <div class={cn("overflow-hidden rounded-xl border", className)}>
75
- <Table.Root aria-label={label} style={`min-width: ${minWidth}`} class="min-w-0">
242
+ {#snippet rowOrMenu(row: Row)}
243
+ {#if rowMenu}
244
+ <ContextMenu.Root>
245
+ <ContextMenu.Trigger>
246
+ {#snippet child({ props })}
247
+ {@render dataRow(row, props)}
248
+ {/snippet}
249
+ </ContextMenu.Trigger>
250
+ <ContextMenu.Content class="w-48">
251
+ {@render rowMenu(row)}
252
+ </ContextMenu.Content>
253
+ </ContextMenu.Root>
254
+ {:else}
255
+ {@render dataRow(row, {})}
256
+ {/if}
257
+ {/snippet}
258
+
259
+ {#snippet dataRow(row: Row, triggerProps: Record<string, unknown>)}
260
+ {@const href = rowHref?.(row)}
261
+ {@const isSelected = selectable && selected.has(rowKey(row))}
262
+ <Table.Row
263
+ {...triggerProps}
264
+ data-state={isSelected ? "selected" : undefined}
265
+ tabindex={onRowClick ? 0 : undefined}
266
+ onclick={onRowClick ? (event: MouseEvent) => rowClick(event, row) : undefined}
267
+ onkeydown={onRowClick ? (event: KeyboardEvent) => rowKeydown(event, row) : undefined}
268
+ class={cn(
269
+ href && "relative t-press active:bg-muted/50",
270
+ onRowClick &&
271
+ "t-press active:bg-muted/50 focus-visible:ring-ring cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-inset",
272
+ rowClass?.(row),
273
+ )}
274
+ >
275
+ {#if selectable}
276
+ <Table.Cell class="relative z-10 w-10 align-top">
277
+ <Checkbox
278
+ checked={isSelected}
279
+ onCheckedChange={(on) => toggleRow(row, on === true)}
280
+ aria-label={selectRowLabel?.(row) ?? `Select ${linkName(row)}`}
281
+ />
282
+ </Table.Cell>
283
+ {/if}
284
+ {@render body(row)}
285
+ {#if href}
286
+ <!-- WHY an empty anchor and not a wrapped cell: cells hold buttons and links of their own, and an anchor can't nest one. -->
287
+ <td class="p-0">
288
+ <a
289
+ {href}
290
+ aria-label={linkName(row)}
291
+ class="absolute inset-0 z-0 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
292
+ ></a>
293
+ </td>
294
+ {/if}
295
+ </Table.Row>
296
+ {#if subRow && expanded?.(row)}
297
+ <Table.Row class="bg-muted/20 hover:bg-muted/20 hover:translate-y-0 hover:shadow-none">
298
+ <Table.Cell colspan={span + (rowHref ? 1 : 0)} class="whitespace-normal">
299
+ {@render subRow(row)}
300
+ </Table.Cell>
301
+ </Table.Row>
302
+ {/if}
303
+ {/snippet}
304
+
305
+ <div class={cn(!bare && "overflow-hidden rounded-xl border", className)}>
306
+ <Table.Root
307
+ aria-label={label}
308
+ aria-busy={loading || undefined}
309
+ style={`min-width: ${minWidth}`}
310
+ class="min-w-0"
311
+ >
76
312
  <Table.Header>
77
313
  <Table.Row>
314
+ {#if selectable}
315
+ <Table.Head class="w-10">
316
+ <Checkbox
317
+ checked={allSelected}
318
+ indeterminate={someSelected}
319
+ onCheckedChange={(on) => toggleAll(on === true)}
320
+ aria-label={selectAllLabel}
321
+ disabled={loading || rows.length === 0}
322
+ />
323
+ </Table.Head>
324
+ {/if}
78
325
  {#each columns as column (column.key)}
79
326
  <Table.Head
80
- class={cn("whitespace-normal", column.numeric && "text-end")}
327
+ class={cn(
328
+ "whitespace-normal",
329
+ column.numeric && "text-end",
330
+ column.sortable && "h-10 p-0",
331
+ column.hideBelow && HIDE[column.hideBelow],
332
+ column.sticky && "bg-background sticky left-0 z-20",
333
+ column.headClass ?? column.class,
334
+ )}
81
335
  style={column.width ? `width: ${column.width}` : undefined}
336
+ aria-sort={ariaSort(column)}
82
337
  >
83
- {column.label}
338
+ {#if column.sortable}
339
+ {@const dir = sort?.key === column.key ? sort.direction : null}
340
+ <button
341
+ type="button"
342
+ class={cn(
343
+ "t-press hover:bg-muted/30 focus-visible:ring-ring flex h-10 w-full items-center gap-1 px-2 text-xs font-medium outline-none focus-visible:ring-2 focus-visible:ring-inset",
344
+ column.numeric ? "justify-end" : "justify-start",
345
+ )}
346
+ onclick={() => (sort = toggleSort(sort, column.key as keyof Row & string))}
347
+ >
348
+ {#if head}{@render head(column)}{:else}{column.label}{/if}
349
+ {#if dir === "asc"}
350
+ <CaretUp class="text-foreground size-3.5" weight="bold" aria-hidden="true" />
351
+ {:else if dir === "desc"}
352
+ <CaretDown class="text-foreground size-3.5" weight="bold" aria-hidden="true" />
353
+ {:else}
354
+ <CaretUpDown class="text-muted-foreground/75 size-3.5" aria-hidden="true" />
355
+ {/if}
356
+ </button>
357
+ {:else if head}
358
+ {@render head(column)}
359
+ {:else}
360
+ {column.label}
361
+ {/if}
84
362
  </Table.Head>
85
363
  {/each}
364
+ {#if rowHref}
365
+ <th class="w-0 p-0"><span class="sr-only">Open</span></th>
366
+ {/if}
86
367
  </Table.Row>
87
368
  </Table.Header>
88
- <Table.Body>
89
- {#each rows as row (rowKey(row))}
90
- <Table.Row class={rowClass?.(row)}>
91
- {@render body(row)}
92
- </Table.Row>
93
- {/each}
94
- </Table.Body>
369
+ {#if loading}
370
+ <TableSkeleton rows={skeletonRows} columns={skeletonColumns} />
371
+ {:else}
372
+ {#if group}
373
+ {#each entries as entry (entry.group.key)}
374
+ {@const folded = collapsible && collapsed.has(entry.group.key)}
375
+ <Table.Body>
376
+ <Table.Row class="bg-muted/40 hover:bg-muted/40 hover:translate-y-0 hover:shadow-none">
377
+ <Table.Head
378
+ colspan={span + (rowHref ? 1 : 0)}
379
+ scope="colgroup"
380
+ class="text-foreground h-10 p-0"
381
+ >
382
+ {#if collapsible}
383
+ <button
384
+ type="button"
385
+ aria-expanded={!folded}
386
+ class="t-press focus-visible:ring-ring flex h-10 w-full items-center gap-2 px-2 text-start outline-none focus-visible:ring-2 focus-visible:ring-inset"
387
+ onclick={() => toggleGroup(entry.group.key)}
388
+ >
389
+ <CaretDown
390
+ class={cn(
391
+ "size-3.5 shrink-0 transition-transform duration-[var(--duration-quick)] ease-[var(--ease-smooth-out)] motion-reduce:transition-none",
392
+ folded && "-rotate-90",
393
+ )}
394
+ aria-hidden="true"
395
+ />
396
+ {#if groupHeader}{@render groupHeader(entry.group)}{:else}{entry.group.key}{/if}
397
+ </button>
398
+ {:else}
399
+ <span class="flex h-10 items-center px-2">
400
+ {#if groupHeader}{@render groupHeader(entry.group)}{:else}{entry.group.key}{/if}
401
+ </span>
402
+ {/if}
403
+ </Table.Head>
404
+ </Table.Row>
405
+ {#if !folded}
406
+ {#each entry.group.rows as row (rowKey(row))}
407
+ {@render rowOrMenu(row)}
408
+ {/each}
409
+ {/if}
410
+ </Table.Body>
411
+ {/each}
412
+ {:else}
413
+ <Table.Body>
414
+ {#each rows as row (rowKey(row))}
415
+ {@render rowOrMenu(row)}
416
+ {/each}
417
+ {#if rows.length === 0 && empty}
418
+ <Table.Row class="hover:bg-transparent hover:translate-y-0 hover:shadow-none">
419
+ <Table.Cell colspan={span + (rowHref ? 1 : 0)} class="whitespace-normal"
420
+ >{@render empty()}</Table.Cell
421
+ >
422
+ </Table.Row>
423
+ {/if}
424
+ </Table.Body>
425
+ {/if}
426
+ {#if trailing}
427
+ <tbody>
428
+ <tr><td colspan={span + (rowHref ? 1 : 0)} class="p-0">{@render trailing()}</td></tr>
429
+ </tbody>
430
+ {/if}
431
+ {/if}
95
432
  {#if footer}
96
433
  <Table.Footer>
97
434
  <Table.Row>
435
+ {#if selectable}<Table.Cell></Table.Cell>{/if}
98
436
  {@render body(footer)}
437
+ {#if rowHref}<td></td>{/if}
99
438
  </Table.Row>
100
439
  </Table.Footer>
101
440
  {/if}
@@ -25,7 +25,13 @@ function render(extra: Record<string, unknown> = {}) {
25
25
  },
26
26
  });
27
27
  flushSync();
28
- return { target, done: () => { void unmount(app); target.remove(); } };
28
+ return {
29
+ target,
30
+ done: () => {
31
+ void unmount(app);
32
+ target.remove();
33
+ },
34
+ };
29
35
  }
30
36
 
31
37
  describe("TableView", () => {
@@ -51,3 +57,104 @@ describe("TableView", () => {
51
57
  done();
52
58
  });
53
59
  });
60
+
61
+ describe("TableView extensions", () => {
62
+ const sortable = [
63
+ { key: "name", label: "Name", sortable: true },
64
+ { key: "total", label: "Total", numeric: true, sortable: true },
65
+ ];
66
+
67
+ it("sorts from a head button and sets aria-sort", () => {
68
+ const { target, done } = render({ columns: sortable, sort: { key: "name", direction: "asc" } });
69
+ const heads = target.querySelectorAll("thead th");
70
+ expect(heads[0].getAttribute("aria-sort")).toBe("ascending");
71
+ expect(heads[1].getAttribute("aria-sort")).toBe("none");
72
+ (heads[1].querySelector("button") as HTMLButtonElement).click();
73
+ flushSync();
74
+ expect(target.querySelectorAll("thead th")[1].getAttribute("aria-sort")).toBe("ascending");
75
+ done();
76
+ });
77
+
78
+ it("draws a real link per row", () => {
79
+ const { target, done } = render({ rowHref: (row: Row) => `/lines/${row.id}` });
80
+ const links = [...target.querySelectorAll("tbody a")].map((a) => a.getAttribute("href"));
81
+ expect(links).toEqual(["/lines/a", "/lines/b"]);
82
+ expect(target.querySelector("tbody a")?.getAttribute("aria-label")).toBe("Audit");
83
+ done();
84
+ });
85
+
86
+ it("selects rows and shows select-all as indeterminate", () => {
87
+ const selected = new Set<string>(["a"]);
88
+ const { target, done } = render({ selectable: true, selected });
89
+ const boxes = target.querySelectorAll<HTMLElement>("[role=checkbox]");
90
+ expect(boxes.length).toBe(3);
91
+ expect(boxes[0].getAttribute("aria-checked")).toBe("mixed");
92
+ expect(target.querySelectorAll("tbody tr")[0].getAttribute("data-state")).toBe("selected");
93
+ done();
94
+ });
95
+
96
+ it("puts a header row over each group, spanning every column", () => {
97
+ const { target, done } = render({ group: (row: Row) => (row.id === "a" ? "One" : "Two") });
98
+ const heads = target.querySelectorAll("tbody th[colspan]");
99
+ expect([...heads].map((h) => h.textContent?.trim())).toEqual(["One", "Two"]);
100
+ expect(heads[0].getAttribute("colspan")).toBe("2");
101
+ done();
102
+ });
103
+
104
+ it("swaps rows for skeletons inside the same frame while loading", () => {
105
+ const { target, done } = render({ loading: true, skeletonRows: 3 });
106
+ expect(target.querySelector("table")?.getAttribute("aria-busy")).toBe("true");
107
+ expect(target.querySelectorAll("tbody tr").length).toBe(3);
108
+ expect(target.querySelector("tbody td")?.textContent?.trim()).toBe("");
109
+ done();
110
+ });
111
+
112
+ it("drops the frame when bare", () => {
113
+ const { target, done } = render({ bare: true });
114
+ expect((target.firstElementChild as HTMLElement).className).not.toContain("border");
115
+ done();
116
+ });
117
+ });
118
+
119
+ describe("TableView row actions", () => {
120
+ it("opens a row on click and Enter, but leaves a nested button alone", () => {
121
+ const calls: string[] = [];
122
+ const cell = createRawSnippet<[{ text: string }]>((cellProps) => ({
123
+ render: () => `<span><button>${cellProps().text}</button></span>`,
124
+ }));
125
+ const { target, done } = render({ onRowClick: (row: Row) => calls.push(row.id), cell });
126
+ const tr = target.querySelector("tbody tr") as HTMLElement;
127
+ tr.click();
128
+ tr.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
129
+ (tr.querySelector("button") as HTMLButtonElement).click();
130
+ expect(calls).toEqual(["a", "a"]);
131
+ done();
132
+ });
133
+
134
+ it("folds a group, hides a column below a breakpoint and shows a sub-row", () => {
135
+ const subRow = createRawSnippet(() => ({ render: () => `<i>detail</i>` }));
136
+ const { target, done } = render({
137
+ group: () => "All",
138
+ collapsible: true,
139
+ subRow,
140
+ expanded: (row: Row) => row.id === "a",
141
+ columns: [
142
+ { key: "name", label: "Name" },
143
+ { key: "total", label: "Total", hideBelow: "md" },
144
+ ],
145
+ });
146
+ expect(target.querySelector("tbody i")).not.toBeNull();
147
+ expect(target.querySelector("thead th:last-child")?.className).toContain("md:table-cell");
148
+ (target.querySelector("tbody th button") as HTMLButtonElement).click();
149
+ flushSync();
150
+ expect(target.querySelectorAll("tbody tr").length).toBe(1);
151
+ done();
152
+ });
153
+
154
+ it("draws the empty snippet across the columns", () => {
155
+ const empty = createRawSnippet(() => ({ render: () => `<p>Nothing</p>` }));
156
+ const { target, done } = render({ rows: [], empty });
157
+ expect(target.querySelector("tbody td")?.getAttribute("colspan")).toBe("2");
158
+ done();
159
+ });
160
+ });
@@ -1,14 +1,25 @@
1
1
  /** One column of a `TableView`. */
2
2
  export interface TableColumn<Row> {
3
- /** The key of the value in the row. */
4
- key: string;
5
- label: string;
6
- /** A number or an amount: right-aligned, tabular figures, never wraps. */
7
- numeric?: boolean;
8
- /** Turns the raw value into the text shown. A string value that has no `format` prints as it is. A money column passes its own currency formatter. */
9
- format?: (value: unknown, row: Row) => string;
10
- /** A column width such as `"30%"` or `"8rem"`. Leave it out and the table shares the rest. */
11
- width?: string;
12
- /** Draws the column in the muted colour. */
13
- muted?: boolean;
3
+ /** The key of the value in the row. */
4
+ key: string;
5
+ label: string;
6
+ /** A number or an amount: right-aligned, tabular figures, never wraps. */
7
+ numeric?: boolean;
8
+ /** Turns the raw value into the text shown. A string value that has no `format` prints as it is. A money column passes its own currency formatter. */
9
+ format?: (value: unknown, row: Row) => string;
10
+ /** A column width such as `"30%"` or `"8rem"`. Leave it out and the table shares the rest. */
11
+ width?: string;
12
+ /** Draws the column in the muted colour. */
13
+ muted?: boolean;
14
+ /** Click or press Enter on the head to sort. The table sorts nothing itself: bind `sort` and order `rows` with `sortRows`. */
15
+ sortable?: boolean;
16
+ /** Lifts the column's cells above the row link, so a button or a link inside it takes its own click. */
17
+ interactive?: boolean;
18
+ /** Hidden below this breakpoint, head and cells both. */
19
+ hideBelow?: "sm" | "md" | "lg";
20
+ /** Pins the column to the left edge while the table scrolls sideways. */
21
+ sticky?: boolean;
22
+ /** Extra classes on the column's cells (and its head, unless `headClass` is set). */
23
+ class?: string;
24
+ headClass?: string;
14
25
  }
@@ -0,0 +1,13 @@
1
+ export { default as TagsInput } from "./tags-input.svelte";
2
+ export {
3
+ addTag,
4
+ canCreate,
5
+ cleanTags,
6
+ collectOptions,
7
+ editDistance,
8
+ findOption,
9
+ fold,
10
+ matchOptions,
11
+ removeTag,
12
+ splitTags,
13
+ } from "./tags.js";