@spaethtech/svelte-ui 0.1.10 → 0.3.1-dev.3.e16df24

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 (208) hide show
  1. package/README.md +180 -42
  2. package/dist/components/Alert.svelte +119 -0
  3. package/dist/components/Alert.svelte.d.ts +29 -0
  4. package/dist/components/Badge/Badge.svelte +142 -69
  5. package/dist/components/Badge/Badge.svelte.d.ts +26 -6
  6. package/dist/components/Badge/index.d.ts +1 -1
  7. package/dist/components/Badge/index.js +1 -1
  8. package/dist/components/Banner.svelte +133 -0
  9. package/dist/components/Banner.svelte.d.ts +31 -0
  10. package/dist/components/Button.svelte +240 -0
  11. package/dist/components/Button.svelte.d.ts +33 -0
  12. package/dist/components/ButtonDropdown.svelte +145 -0
  13. package/dist/components/ButtonDropdown.svelte.d.ts +31 -0
  14. package/dist/components/Calendar.svelte +259 -0
  15. package/dist/components/Calendar.svelte.d.ts +27 -0
  16. package/dist/components/Card.svelte +78 -0
  17. package/dist/components/Card.svelte.d.ts +17 -0
  18. package/dist/components/CardBody.svelte +41 -0
  19. package/dist/components/CardBody.svelte.d.ts +17 -0
  20. package/dist/components/CardFooter.svelte +49 -0
  21. package/dist/components/CardFooter.svelte.d.ts +16 -0
  22. package/dist/components/CardHeader.svelte +49 -0
  23. package/dist/components/CardHeader.svelte.d.ts +16 -0
  24. package/dist/components/Checkbox.svelte +131 -0
  25. package/dist/components/Checkbox.svelte.d.ts +16 -0
  26. package/dist/components/DataTable.svelte +518 -0
  27. package/dist/components/DataTable.svelte.d.ts +74 -0
  28. package/dist/components/DatePicker.svelte +312 -0
  29. package/dist/components/DatePicker.svelte.d.ts +32 -0
  30. package/dist/components/DateTimeInput.svelte +93 -0
  31. package/dist/components/DateTimeInput.svelte.d.ts +20 -0
  32. package/dist/components/Dialog.svelte +132 -0
  33. package/dist/components/Dialog.svelte.d.ts +24 -0
  34. package/dist/components/{EmailInput/EmailInput.svelte → EmailInput.svelte} +17 -8
  35. package/dist/components/EmailInput.svelte.d.ts +21 -0
  36. package/dist/components/Input.svelte +369 -0
  37. package/dist/components/Input.svelte.d.ts +35 -0
  38. package/dist/components/{List/List.svelte → List.svelte} +194 -133
  39. package/dist/components/{List/List.svelte.d.ts → List.svelte.d.ts} +9 -1
  40. package/dist/components/Menu.svelte +117 -0
  41. package/dist/components/Menu.svelte.d.ts +20 -0
  42. package/dist/components/NotesEditor.svelte +127 -0
  43. package/dist/components/NotesEditor.svelte.d.ts +17 -0
  44. package/dist/components/{NumberInput/NumberInput.svelte → NumberInput.svelte} +146 -105
  45. package/dist/components/{NumberInput/NumberInput.svelte.d.ts → NumberInput.svelte.d.ts} +11 -4
  46. package/dist/components/PasswordInput.svelte +52 -0
  47. package/dist/components/PasswordInput.svelte.d.ts +20 -0
  48. package/dist/components/Popup.svelte +140 -0
  49. package/dist/components/Popup.svelte.d.ts +31 -0
  50. package/dist/components/Query.svelte +284 -0
  51. package/dist/components/Query.svelte.d.ts +15 -0
  52. package/dist/components/{Rating/Rating.svelte → Rating.svelte} +19 -10
  53. package/dist/components/Rating.svelte.d.ts +13 -0
  54. package/dist/components/{SearchInput/SearchInput.svelte → SearchInput.svelte} +19 -8
  55. package/dist/components/{SearchInput/SearchInput.svelte.d.ts → SearchInput.svelte.d.ts} +9 -2
  56. package/dist/components/{Select/Select.svelte → Select.svelte} +48 -76
  57. package/dist/components/{Select/Select.svelte.d.ts → Select.svelte.d.ts} +11 -1
  58. package/dist/components/SideBarMenu/SideBarMenu.svelte +724 -0
  59. package/dist/components/SideBarMenu/SideBarMenu.svelte.d.ts +109 -0
  60. package/dist/components/SideBarMenu/index.d.ts +2 -0
  61. package/dist/components/SideBarMenu/index.js +1 -0
  62. package/dist/components/TabStrip/TabStrip.svelte +384 -0
  63. package/dist/components/TabStrip/TabStrip.svelte.d.ts +50 -0
  64. package/dist/components/TabStrip/index.d.ts +3 -0
  65. package/dist/components/TabStrip/index.js +2 -0
  66. package/dist/components/{TextArea/TextArea.svelte → TextArea.svelte} +143 -112
  67. package/dist/components/TextArea.svelte.d.ts +38 -0
  68. package/dist/components/ThemeSelector.svelte +81 -0
  69. package/dist/components/ThemeSelector.svelte.d.ts +10 -0
  70. package/dist/components/TimePicker.svelte +148 -0
  71. package/dist/components/TimePicker.svelte.d.ts +28 -0
  72. package/dist/components/TimeRangeInput.svelte +203 -0
  73. package/dist/components/TimeRangeInput.svelte.d.ts +29 -0
  74. package/dist/components/TimeSpinner.svelte +202 -0
  75. package/dist/components/TimeSpinner.svelte.d.ts +26 -0
  76. package/dist/components/Toast/Toaster.svelte +51 -0
  77. package/dist/components/Toast/Toaster.svelte.d.ts +12 -0
  78. package/dist/components/Toast/toast.svelte.d.ts +29 -0
  79. package/dist/components/Toast/toast.svelte.js +27 -0
  80. package/dist/components/Toggle.svelte +93 -0
  81. package/dist/components/Toggle.svelte.d.ts +15 -0
  82. package/dist/data/dataset.d.ts +42 -0
  83. package/dist/data/dataset.js +3 -0
  84. package/dist/data/index.d.ts +3 -0
  85. package/dist/data/index.js +3 -0
  86. package/dist/data/query/ast.d.ts +62 -0
  87. package/dist/data/query/ast.js +12 -0
  88. package/dist/data/query/index.d.ts +3 -0
  89. package/dist/data/query/index.js +3 -0
  90. package/dist/data/query/lexer.d.ts +33 -0
  91. package/dist/data/query/lexer.js +225 -0
  92. package/dist/data/query/parser.d.ts +11 -0
  93. package/dist/data/query/parser.js +252 -0
  94. package/dist/data/table/grid.svelte.d.ts +57 -0
  95. package/dist/data/table/grid.svelte.js +139 -0
  96. package/dist/data/table/index.d.ts +2 -0
  97. package/dist/data/table/index.js +2 -0
  98. package/dist/data/table/types.d.ts +79 -0
  99. package/dist/index.d.ts +48 -22
  100. package/dist/index.js +45 -21
  101. package/dist/positioning/anchored.d.ts +61 -0
  102. package/dist/positioning/anchored.js +122 -0
  103. package/dist/positioning/tooltip.d.ts +41 -0
  104. package/dist/positioning/tooltip.js +147 -0
  105. package/dist/theme.css +205 -0
  106. package/dist/types/breakpoints.d.ts +24 -0
  107. package/dist/types/breakpoints.js +13 -0
  108. package/dist/types/responsive.d.ts +32 -0
  109. package/dist/types/responsive.js +54 -0
  110. package/dist/types/sizes.d.ts +10 -3
  111. package/dist/types/time.d.ts +19 -0
  112. package/dist/types/time.js +10 -0
  113. package/dist/types/variants.d.ts +8 -1
  114. package/dist/types/variants.js +16 -1
  115. package/package.json +93 -63
  116. package/dist/components/Button/Button.svelte +0 -172
  117. package/dist/components/Button/Button.svelte.d.ts +0 -32
  118. package/dist/components/Button/index.d.ts +0 -1
  119. package/dist/components/Button/index.js +0 -1
  120. package/dist/components/Dialog/Dialog.svelte +0 -101
  121. package/dist/components/Dialog/Dialog.svelte.d.ts +0 -18
  122. package/dist/components/Dialog/index.d.ts +0 -1
  123. package/dist/components/Dialog/index.js +0 -1
  124. package/dist/components/ElementManager/ElementManager.svelte +0 -397
  125. package/dist/components/ElementManager/ElementManager.svelte.d.ts +0 -43
  126. package/dist/components/ElementManager/index.d.ts +0 -1
  127. package/dist/components/ElementManager/index.js +0 -1
  128. package/dist/components/EmailInput/EmailInput.svelte.d.ts +0 -14
  129. package/dist/components/EmailInput/index.d.ts +0 -1
  130. package/dist/components/EmailInput/index.js +0 -1
  131. package/dist/components/Icon/Icon.svelte +0 -74
  132. package/dist/components/Icon/Icon.svelte.d.ts +0 -13
  133. package/dist/components/Icon/index.d.ts +0 -1
  134. package/dist/components/Icon/index.js +0 -1
  135. package/dist/components/Input/Input.svelte +0 -268
  136. package/dist/components/Input/Input.svelte.d.ts +0 -18
  137. package/dist/components/Input/index.d.ts +0 -1
  138. package/dist/components/Input/index.js +0 -1
  139. package/dist/components/List/index.d.ts +0 -1
  140. package/dist/components/List/index.js +0 -1
  141. package/dist/components/ListItem/ListItem.svelte +0 -175
  142. package/dist/components/ListItem/ListItem.svelte.d.ts +0 -24
  143. package/dist/components/ListItem/index.d.ts +0 -2
  144. package/dist/components/ListItem/index.js +0 -2
  145. package/dist/components/ListView/ListView.svelte +0 -463
  146. package/dist/components/ListView/ListView.svelte.d.ts +0 -37
  147. package/dist/components/ListView/index.d.ts +0 -2
  148. package/dist/components/ListView/index.js +0 -2
  149. package/dist/components/NodeGraph/BaseNode.d.ts +0 -57
  150. package/dist/components/NodeGraph/BaseNode.js +0 -30
  151. package/dist/components/NodeGraph/Edge.svelte +0 -60
  152. package/dist/components/NodeGraph/Edge.svelte.d.ts +0 -16
  153. package/dist/components/NodeGraph/ExpressionEvaluator.d.ts +0 -82
  154. package/dist/components/NodeGraph/ExpressionEvaluator.js +0 -152
  155. package/dist/components/NodeGraph/Node.svelte +0 -100
  156. package/dist/components/NodeGraph/Node.svelte.d.ts +0 -10
  157. package/dist/components/NodeGraph/NodeGraph.svelte +0 -52
  158. package/dist/components/NodeGraph/NodeGraph.svelte.d.ts +0 -14
  159. package/dist/components/NodeGraph/NodeInstance.svelte.d.ts +0 -80
  160. package/dist/components/NodeGraph/NodeInstance.svelte.js +0 -241
  161. package/dist/components/NodeGraph/NodePort.svelte +0 -75
  162. package/dist/components/NodeGraph/NodePort.svelte.d.ts +0 -11
  163. package/dist/components/NodeGraph/decorators.d.ts +0 -81
  164. package/dist/components/NodeGraph/decorators.js +0 -148
  165. package/dist/components/NodeGraph/index.d.ts +0 -9
  166. package/dist/components/NodeGraph/index.js +0 -9
  167. package/dist/components/NodeGraph/types.d.ts +0 -94
  168. package/dist/components/NodeGraph/types.js +0 -33
  169. package/dist/components/NotesEditor/NotesEditor.svelte +0 -203
  170. package/dist/components/NotesEditor/NotesEditor.svelte.d.ts +0 -9
  171. package/dist/components/NotesEditor/index.d.ts +0 -1
  172. package/dist/components/NotesEditor/index.js +0 -1
  173. package/dist/components/NumberInput/index.d.ts +0 -1
  174. package/dist/components/NumberInput/index.js +0 -1
  175. package/dist/components/PasswordInput/PasswordInput.svelte +0 -41
  176. package/dist/components/PasswordInput/PasswordInput.svelte.d.ts +0 -13
  177. package/dist/components/PasswordInput/index.d.ts +0 -1
  178. package/dist/components/PasswordInput/index.js +0 -1
  179. package/dist/components/Popup/index.d.ts +0 -1
  180. package/dist/components/Popup/index.js +0 -1
  181. package/dist/components/Rating/Rating.svelte.d.ts +0 -7
  182. package/dist/components/Rating/index.d.ts +0 -1
  183. package/dist/components/Rating/index.js +0 -1
  184. package/dist/components/ScrollView/ScrollView.svelte +0 -285
  185. package/dist/components/ScrollView/ScrollView.svelte.d.ts +0 -19
  186. package/dist/components/ScrollView/index.d.ts +0 -1
  187. package/dist/components/ScrollView/index.js +0 -1
  188. package/dist/components/SearchInput/index.d.ts +0 -1
  189. package/dist/components/SearchInput/index.js +0 -1
  190. package/dist/components/Select/index.d.ts +0 -1
  191. package/dist/components/Select/index.js +0 -1
  192. package/dist/components/TextArea/TextArea.svelte.d.ts +0 -19
  193. package/dist/components/TextArea/index.d.ts +0 -1
  194. package/dist/components/TextArea/index.js +0 -1
  195. package/dist/components/ThemeSelector/ThemeSelector.svelte +0 -64
  196. package/dist/components/ThemeSelector/ThemeSelector.svelte.d.ts +0 -3
  197. package/dist/components/ThemeSelector/index.d.ts +0 -1
  198. package/dist/components/ThemeSelector/index.js +0 -1
  199. package/dist/components/TooltipHandler/TooltipHandler.svelte +0 -593
  200. package/dist/components/TooltipHandler/TooltipHandler.svelte.d.ts +0 -10
  201. package/dist/components/TooltipHandler/index.d.ts +0 -1
  202. package/dist/components/TooltipHandler/index.js +0 -1
  203. package/dist/styles/sizes.d.ts +0 -78
  204. package/dist/styles/sizes.js +0 -120
  205. package/dist/styles/variants.d.ts +0 -106
  206. package/dist/styles/variants.js +0 -194
  207. package/dist/types/ManagerTypes.d.ts +0 -42
  208. /package/dist/{types/ManagerTypes.js → data/table/types.js} +0 -0
@@ -0,0 +1,131 @@
1
+ <!--
2
+ /**
3
+ * Checkbox — a themed multi-state box (unchecked / checked / indeterminate). Use this, NOT a Toggle,
4
+ * for selection semantics: a row is / isn't selected, a rule does / doesn't apply, a filter is / isn't
5
+ * active. A real `<input type=checkbox>` drives it — laid INVISIBLY over the whole control, so clicking
6
+ * anywhere toggles it (naturally clickable + testable + accessible + form-friendly; `bind:checked`
7
+ * works, and `onchange` fires a real event). Honours the shared {@link Variant} (the "checked" colour)
8
+ * + {@link Size} axes.
9
+ *
10
+ * ALIGNMENT: same story as Toggle — the visible box is deliberately smaller than a text control, but
11
+ * the Checkbox occupies a wrapper sized to the standard control HEIGHT for its `size` (sm 24 / md 32 /
12
+ * lg 40px — same as Input), with the box centred inside. Drop it next to a `size`-matched Input/Select
13
+ * and everything lines up on the same baseline; the full-height wrapper is the click target while the
14
+ * box itself stays box-sized.
15
+ */
16
+ -->
17
+ <script lang="ts">
18
+ import type { HTMLInputAttributes } from "svelte/elements";
19
+ import type { Size } from "../types/sizes.js";
20
+ import { responsiveClasses, type Responsive } from "../types/responsive.js";
21
+ import { variantToken, type Variant } from "../types/variants.js";
22
+
23
+ let {
24
+ checked = $bindable(false),
25
+ indeterminate = false,
26
+ disabled = false,
27
+ size = "md",
28
+ variant = "primary",
29
+ class: cls = "",
30
+ ...rest
31
+ }: {
32
+ checked?: boolean;
33
+ indeterminate?: boolean;
34
+ disabled?: boolean;
35
+ size?: Responsive<Size>;
36
+ /** The colour of the "checked" state (default primary). */
37
+ variant?: Variant;
38
+ class?: string;
39
+ } & Omit<HTMLInputAttributes, "size" | "type" | "class"> = $props();
40
+
41
+ // Per size: visible box dimension. Same scale as icons (14 / 16 / 20 px) so the box aligns with
42
+ // adjacent MDI icons at the matching size.
43
+ const dims: Record<Size, string> = {
44
+ sm: "size-3.5",
45
+ md: "size-4",
46
+ lg: "size-5",
47
+ };
48
+ const iconSize: Record<Size, string> = {
49
+ sm: "size-3",
50
+ md: "size-3.5",
51
+ lg: "size-4",
52
+ };
53
+ // Wrapper height matches Input's h-6/h-8/h-10 so the box centres on the same baseline as sibling
54
+ // controls at the same `size`.
55
+ const heightMap: Record<Size, string> = { sm: "h-6", md: "h-8", lg: "h-10" };
56
+ const heightClass = $derived(responsiveClasses(size, heightMap));
57
+ const boxSize = $derived(responsiveClasses(size, dims));
58
+ const iconSizeCls = $derived(responsiveClasses(size, iconSize));
59
+ const filled = $derived(checked || indeterminate);
60
+ const variantVar = $derived(`var(${variantToken[variant]})`);
61
+ const boxStyle = $derived(
62
+ filled
63
+ ? `background-color: ${variantVar}; border-color: ${variantVar};`
64
+ : `background-color: transparent; border-color: color-mix(in srgb, var(--ui-color-text) 40%, transparent);`,
65
+ );
66
+ // Mark colour = the INVERTED text colour (`--ui-color-background`) with a hint of the variant mixed
67
+ // in — ONE rule for every variant, both themes (matches the Toggle knob). Light: near-white on the
68
+ // fill; dark: flips dark, which reads on the dark theme's brighter accent fills. The 80% background
69
+ // keeps it contrasting even on a neutral (`--ui-color-text`) fill.
70
+ const markColor = $derived(
71
+ `color-mix(in srgb, var(--ui-color-background) 80%, var(${variantToken[variant]}))`,
72
+ );
73
+
74
+ let el = $state<HTMLInputElement>();
75
+ // `indeterminate` is a DOM property, not an attribute — sync it imperatively so the real
76
+ // checkbox reports the tri-state to a11y and form serialization.
77
+ $effect(() => {
78
+ if (el) el.indeterminate = indeterminate;
79
+ });
80
+ </script>
81
+
82
+ <label
83
+ class="relative inline-flex shrink-0 items-center justify-center {heightClass} {disabled
84
+ ? 'cursor-not-allowed opacity-50'
85
+ : 'cursor-pointer'} {cls}"
86
+ >
87
+ <!-- Real checkbox — invisible, covering the whole control so a click anywhere toggles it and
88
+ keyboard/focus/screen-reader behavior is native. -->
89
+ <input
90
+ bind:this={el}
91
+ type="checkbox"
92
+ bind:checked
93
+ {disabled}
94
+ class="peer absolute inset-0 z-10 m-0 cursor-pointer opacity-0 disabled:cursor-not-allowed"
95
+ {...rest}
96
+ />
97
+ <span
98
+ class="relative inline-flex items-center justify-center rounded-[3px] border transition-colors duration-150 peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:[outline-color:var(--ui-color-primary)] {boxSize}"
99
+ style={boxStyle}
100
+ >
101
+ {#if indeterminate}
102
+ <!-- Dash for indeterminate — mid-line, contrasting mark on the filled background. -->
103
+ <svg
104
+ viewBox="0 0 16 16"
105
+ class={iconSizeCls}
106
+ style="color: {markColor}"
107
+ fill="none"
108
+ stroke="currentColor"
109
+ stroke-width="2.5"
110
+ stroke-linecap="round"
111
+ >
112
+ <line x1="4" y1="8" x2="12" y2="8" />
113
+ </svg>
114
+ {:else if checked}
115
+ <!-- Checkmark — sits inside the filled box. Stroke uses currentColor; `markColor` picks white
116
+ on accent fills, the background colour on neutral/ghost (which are text-coloured). -->
117
+ <svg
118
+ viewBox="0 0 16 16"
119
+ class={iconSizeCls}
120
+ style="color: {markColor}"
121
+ fill="none"
122
+ stroke="currentColor"
123
+ stroke-width="2.5"
124
+ stroke-linecap="round"
125
+ stroke-linejoin="round"
126
+ >
127
+ <polyline points="3.5,8.5 6.5,11.5 12.5,5" />
128
+ </svg>
129
+ {/if}
130
+ </span>
131
+ </label>
@@ -0,0 +1,16 @@
1
+ import type { HTMLInputAttributes } from "svelte/elements";
2
+ import type { Size } from "../types/sizes.js";
3
+ import { type Responsive } from "../types/responsive.js";
4
+ import { type Variant } from "../types/variants.js";
5
+ type $$ComponentProps = {
6
+ checked?: boolean;
7
+ indeterminate?: boolean;
8
+ disabled?: boolean;
9
+ size?: Responsive<Size>;
10
+ /** The colour of the "checked" state (default primary). */
11
+ variant?: Variant;
12
+ class?: string;
13
+ } & Omit<HTMLInputAttributes, "size" | "type" | "class">;
14
+ declare const Checkbox: import("svelte").Component<$$ComponentProps, {}, "checked">;
15
+ type Checkbox = ReturnType<typeof Checkbox>;
16
+ export default Checkbox;
@@ -0,0 +1,518 @@
1
+ <!--
2
+ /**
3
+ * DataTable — config-driven, responsive 12-col data table over a DataGrid (svelte-ui/data).
4
+ * Selectable rows, expandable rows, per-row + bulk action menus, sortable headers, paginated footer.
5
+ * Styled on the svelte-ui theme; menus use the Popup/Menu primitives (top layer, flip vs viewport).
6
+ *
7
+ * Compound component: every control is built from our own core components — icon controls and
8
+ * pagination use <Button variant="ghost" size="sm">, per-page uses <Select size="sm">. No raw
9
+ * <button>/<select> (see CLAUDE.md "compound components").
10
+ */
11
+ -->
12
+ <script lang="ts" generics="T">
13
+ import type { Snippet } from "svelte";
14
+ import IconDots from "~icons/mdi/dots-vertical";
15
+ import IconBulk from "~icons/mdi/format-list-checks";
16
+ import IconSortAsc from "~icons/mdi/sort-ascending";
17
+ import IconSortDesc from "~icons/mdi/sort-descending";
18
+ import IconSortNone from "~icons/mdi/sort";
19
+ import IconChevronRight from "~icons/mdi/chevron-right";
20
+ import IconChevronDown from "~icons/mdi/chevron-down";
21
+ import IconChevronUp from "~icons/mdi/chevron-up";
22
+ import IconExpandAll from "~icons/mdi/chevron-double-right";
23
+ import IconCollapseAll from "~icons/mdi/chevron-double-down";
24
+ import IconLoading from "~icons/mdi/loading";
25
+ import IconFirst from "~icons/mdi/page-first";
26
+ import IconPrev from "~icons/mdi/chevron-left";
27
+ import IconNext from "~icons/mdi/chevron-right";
28
+ import IconLast from "~icons/mdi/page-last";
29
+ import Button from "./Button.svelte";
30
+ import Select from "./Select.svelte";
31
+ import Checkbox from "./Checkbox.svelte";
32
+ import Menu from "./Menu.svelte";
33
+ import type { Column, GridPlacement, MenuItem, DataGrid } from "../data/table/index.js";
34
+ import type { Size } from "../types/sizes.js";
35
+ import { resolveScalar, type Responsive } from "../types/responsive.js";
36
+ import { variantToken, type Variant } from "../types/variants.js";
37
+
38
+ let {
39
+ grid,
40
+ selectable = false,
41
+ expandable = false,
42
+ reorderable = false,
43
+ expanded,
44
+ rowActions,
45
+ bulkActions,
46
+ onReorder,
47
+ size = "md",
48
+ truncate = true,
49
+ borderless = false,
50
+ variant,
51
+ }: {
52
+ grid: DataGrid<T>;
53
+ selectable?: boolean;
54
+ expandable?: boolean;
55
+ /** When true, each row gets up/down chevron buttons in the lead
56
+ * column and fires `onReorder(row, 'up'|'down')` when clicked.
57
+ * The consumer owns the actual swap (typically updating the
58
+ * `sortOrder` field of two rows). Buttons auto-disable at the
59
+ * ends of the visible page — with server pagination, this
60
+ * reorders WITHIN the visible page only. */
61
+ reorderable?: boolean;
62
+ expanded?: Snippet<[T]>;
63
+ rowActions?: (row: T) => MenuItem[];
64
+ bulkActions?: (selected: ReadonlySet<string>) => MenuItem[];
65
+ onReorder?: (row: T, direction: "up" | "down") => void;
66
+ /** Overall density tier. Scales the whole table uniformly:
67
+ * font-size (--ui-text-*), row/footer padding, inter-column
68
+ * gap, lead/tail control squares (--ui-height-*), and
69
+ * propagates to every inner Button / Checkbox / Select.
70
+ * Header text inherits the outer font-size — no separate
71
+ * axis. Default `'md'` matches the rest of svelte-ui. */
72
+ size?: Responsive<Size>;
73
+ /** Every header + body cell wrapper gets Tailwind's `truncate`
74
+ * (white-space:nowrap, overflow:hidden, text-overflow:ellipsis).
75
+ * Simple text cells render with ellipsis automatically. Compound
76
+ * cells (inline-flex wrappers, badges, buttons) clip at the
77
+ * right edge — to also get ellipsis on the inner text, the
78
+ * snippet's text span needs its own `truncate` class.
79
+ *
80
+ * Defaults to `true` — the "wrong" default is columns visually
81
+ * bleeding into their neighbours when a value doesn't fit its
82
+ * configured span. Pass `truncate={false}` if the consumer
83
+ * wants wrap-on-overflow (rare — usually you'd fix the span
84
+ * allocation instead). */
85
+ truncate?: boolean;
86
+ /** Drop the visible frame while keeping each section's 1px box: every section's border colour
87
+ * becomes its own background, so it vanishes per-section on a matching surface. Defaults `false`. */
88
+ borderless?: boolean;
89
+ /** Tints the whole table's interactive/section surfaces (row hover,
90
+ * header/footer bands) with a semantic colour instead of the neutral
91
+ * default. Points --ui-accent at the variant's token and re-mixes the
92
+ * --ui-color-hover/surface/active tints via the `.ui-accent` class.
93
+ * `ghost` additionally drops ALL section fills (bands + body) for a bare,
94
+ * see-through table. Omit for the default grey tint. */
95
+ variant?: Variant;
96
+ } = $props();
97
+
98
+ /** When a variant is set, override --ui-accent so the shared interactive
99
+ * tints re-mix from that colour (the `.ui-accent` class on the root does
100
+ * the actual re-mix). Neutral (text) default when unset. */
101
+ const accentVar = $derived(variant ? `--ui-accent: var(${variantToken[variant]});` : "");
102
+
103
+ /** Font size for the WHOLE table. Applied as `font-size` on the
104
+ * outer container; every descendant inherits via standard CSS
105
+ * cascade — headers, cells, footer text all follow. */
106
+ // DataTable's own density is set via inline-style CSS vars + cell padding that already bakes in its
107
+ // own `md:` responsive step — neither can be re-prefixed per size tier. So the TABLE'S density keys
108
+ // off the base tier of a responsive `size` (via resolveScalar); the responsive `size` is still passed
109
+ // through to child controls (Button/Select/Checkbox), which DO resize at every breakpoint.
110
+ const sSize = $derived(resolveScalar(size, "md"));
111
+ const textVar = $derived(
112
+ sSize === "lg" ? "var(--ui-text-lg)" : sSize === "md" ? "var(--ui-text)" : "var(--ui-text-sm)",
113
+ );
114
+ /** Control height for the icon-only Button squares in lead/tail
115
+ * columns AND the row minimum height. Matches Button/Checkbox
116
+ * defaults at each size (sm=24px, md=32px, lg=40px). */
117
+ const heightVar = $derived(
118
+ sSize === "lg"
119
+ ? "var(--ui-height-lg)"
120
+ : sSize === "md"
121
+ ? "var(--ui-height)"
122
+ : "var(--ui-height-sm)",
123
+ );
124
+ /** Cell padding scales with size. Mobile-first: base padding is
125
+ * tight; the md: variant applies at ≥ 768px so tables feel
126
+ * chunkier on desktop but stay dense on phones. */
127
+ const cellPad = $derived(
128
+ sSize === "lg"
129
+ ? "py-3 px-4 md:py-4 md:px-5"
130
+ : sSize === "md"
131
+ ? "py-2 px-3 md:py-3 md:px-4"
132
+ : "py-1 px-2 md:py-2 md:px-3",
133
+ );
134
+ /** Inter-column gap. Tracks the same size axis so lead/cell/tail
135
+ * spacing stays proportional to cell padding. */
136
+ const cellGap = $derived(sSize === "lg" ? "gap-3" : sSize === "md" ? "gap-2" : "gap-1");
137
+
138
+ const columns = $derived(grid.columns);
139
+ const hasLead = $derived(selectable || expandable || reorderable);
140
+ const hasTail = $derived(!!rowActions || !!bulkActions);
141
+
142
+ // Server-controlled fetch (a reactive query); mirror into the grid for selection/expansion logic.
143
+ const result = $derived(grid.fetch(grid.request));
144
+ const rows = $derived(result.current?.rows ?? grid.rows);
145
+ const total = $derived(result.current?.total ?? grid.total);
146
+ const pageCount = $derived(Math.max(1, Math.ceil(total / grid.perPage)));
147
+ // "Settled" = a result came back AND we're not fetching — only then is empty genuinely "No results".
148
+ const settled = $derived(!!result.current && !result.loading);
149
+ $effect(() => {
150
+ grid.loading = result.loading;
151
+ if (result.current) {
152
+ grid.rows = result.current.rows;
153
+ grid.total = result.current.total;
154
+ }
155
+ });
156
+
157
+ const sortKeyOf = (c: Column<T>) => c.sortKey ?? c.id;
158
+
159
+ function placement(bp: string, p: GridPlacement): string {
160
+ const prefix = bp === "base" ? "" : `${bp}:`;
161
+ const out: string[] = [];
162
+ if (p.hidden) out.push(`${prefix}hidden`);
163
+ else if (bp !== "base") out.push(`${prefix}block`);
164
+ if (p.span) out.push(`${prefix}col-span-${p.span}`);
165
+ if (p.start) out.push(`${prefix}col-start-${p.start}`);
166
+ if (p.row) out.push(`${prefix}row-start-${p.row}`);
167
+ return out.join(" ");
168
+ }
169
+ function cellClasses(col: Column<T>): string {
170
+ const bps = col.breakpoints ?? { base: { span: 12 } };
171
+ return Object.entries(bps)
172
+ .map(([bp, p]) => placement(bp, p))
173
+ .join(" ");
174
+ }
175
+ const alignClass = (col: Column<T>) =>
176
+ col.align === "end"
177
+ ? "justify-end text-end"
178
+ : col.align === "center"
179
+ ? "justify-center text-center"
180
+ : "justify-start text-start";
181
+
182
+ function rowClick(e: MouseEvent, id: string) {
183
+ if (!expandable) return;
184
+ const el = e.target as HTMLElement;
185
+ if (el.closest('a, button, input, select, textarea, label, [role="button"]')) return;
186
+ grid.toggleExpand(id);
187
+ }
188
+
189
+ // One shared Menu instance, anchored to whichever kebab/bulk button opened it.
190
+ let menuAnchor = $state<HTMLElement>();
191
+ let menuItems = $state<MenuItem[]>([]);
192
+ let menuOpen = $state(false);
193
+ function openMenu(e: MouseEvent, items: MenuItem[]) {
194
+ menuAnchor = e.currentTarget as HTMLElement;
195
+ menuItems = items;
196
+ menuOpen = true;
197
+ }
198
+
199
+ const rangeText = $derived(
200
+ total === 0
201
+ ? "0 of 0"
202
+ : `${(grid.page - 1) * grid.perPage + 1}–${Math.min(grid.page * grid.perPage, total)} of ${total}`,
203
+ );
204
+
205
+ // Invisible icon-slot sizing for the empty-state spacer. Sized to
206
+ // heightVar via inline style at each use site (kept as a snippet
207
+ // of class + style below).
208
+ const spacerClass =
209
+ "inline-flex shrink-0 items-center justify-center [&_svg]:w-[1em] [&_svg]:h-[1em]";
210
+ const subtle = "[color:color-mix(in_srgb,var(--ui-color-text)_60%,transparent)]";
211
+ // Per-section borders: header, body, and footer each keep a FULL 1px border box, so the table height
212
+ // is fixed no matter the variant/borderless (nothing is ever added or removed — only colours change).
213
+ // Non-borderless → the standard tint calc (secondary @ --ui-tint-border); borderless → the section's
214
+ // own background, so it vanishes per-section on a matching surface (fixes the white-frame-on-a-coloured-
215
+ // surface bug). To avoid DOUBLING the horizontal separators, the body's top/bottom borders match its
216
+ // own background whenever the neighbouring header/footer is present (always, today) — so the header's
217
+ // bottom border and the footer's top border are the only horizontal lines; the body owns left/right.
218
+ // (If a header-less / footer-less mode is ever added, flip that edge to `bodyBorder` + round the corner.)
219
+ // `ghost` makes every section transparent (bands AND body) for a bare, see-through table.
220
+ const sectionBorder =
221
+ "color-mix(in srgb, var(--ui-color-secondary) var(--ui-tint-border), transparent)";
222
+ const bandBg = $derived(variant === "ghost" ? "transparent" : "var(--ui-color-surface)");
223
+ const bodyBg = $derived(variant === "ghost" ? "transparent" : "var(--ui-color-background)");
224
+ const bandBorder = $derived(borderless ? bandBg : sectionBorder); // header + footer, all 4 sides
225
+ const bodyBorder = $derived(borderless ? bodyBg : sectionBorder); // body left/right (top/bottom = bodyBg)
226
+ </script>
227
+
228
+ <div
229
+ role="table"
230
+ class="ui-accent relative [color:var(--ui-color-text)]"
231
+ style="font-size: {textVar}; {accentVar}"
232
+ >
233
+ <!-- Header. Font size inherits from the outer container; weight,
234
+ transform, and colour are library defaults (semibold, uppercase,
235
+ text token). Background is --ui-color-surface — the shared section
236
+ tint (a notch darker than the row-hover token), so header + footer
237
+ read as bands framing the interactive rows. -->
238
+ <div
239
+ role="row"
240
+ class="flex items-center font-semibold uppercase border
241
+ {cellGap} {cellPad}
242
+ [border-top-left-radius:var(--ui-border-radius)] [border-top-right-radius:var(--ui-border-radius)]
243
+ [color:var(--ui-color-text)]"
244
+ style="border-color: {bandBorder}; background-color: {bandBg}"
245
+ >
246
+ {#if hasLead}
247
+ <div class="flex shrink-0 items-center gap-1">
248
+ {#if selectable}
249
+ <Checkbox
250
+ {size}
251
+ aria-label="Select all"
252
+ checked={grid.allSelected}
253
+ indeterminate={grid.someSelected}
254
+ onchange={() => grid.toggleAll()}
255
+ />
256
+ {/if}
257
+ {#if expandable}
258
+ <Button
259
+ variant="ghost"
260
+ {size}
261
+ aria-label={grid.allExpanded ? "Collapse all" : "Expand all"}
262
+ title={grid.allExpanded ? "Collapse all" : "Expand all"}
263
+ onclick={() => grid.toggleExpandAll()}
264
+ >
265
+ {#snippet icon()}{#if grid.allExpanded}<IconCollapseAll />{:else}<IconExpandAll
266
+ />{/if}{/snippet}
267
+ </Button>
268
+ {/if}
269
+ {#if reorderable}
270
+ <!-- Placeholder pair to match the body-row column so header + body align. -->
271
+ <span
272
+ class={spacerClass}
273
+ style="width: {heightVar}; height: {heightVar}"
274
+ aria-hidden="true"
275
+ ></span>
276
+ <span
277
+ class={spacerClass}
278
+ style="width: {heightVar}; height: {heightVar}"
279
+ aria-hidden="true"
280
+ ></span>
281
+ {/if}
282
+ </div>
283
+ {/if}
284
+
285
+ <div class="grid grow grid-cols-12 items-center {cellGap}">
286
+ {#each columns as col (col.id)}
287
+ <div
288
+ role="columnheader"
289
+ class="{cellClasses(col)} flex min-w-0 items-center {alignClass(col)} {truncate
290
+ ? 'truncate'
291
+ : ''}"
292
+ >
293
+ {#if col.sortable}
294
+ <Button
295
+ variant="ghost"
296
+ {size}
297
+ class="!px-0 [font-size:inherit]"
298
+ onclick={() => grid.toggleSort(sortKeyOf(col))}
299
+ >
300
+ <span>{col.header ?? col.id}</span>
301
+ {#if grid.sort?.col === sortKeyOf(col)}
302
+ {#if grid.sort.dir === "asc"}<IconSortAsc
303
+ class="w-[1em] h-[1em]"
304
+ />{:else}<IconSortDesc class="w-[1em] h-[1em]" />{/if}
305
+ {:else}
306
+ <IconSortNone class="w-[1em] h-[1em] opacity-40" />
307
+ {/if}
308
+ </Button>
309
+ {:else}
310
+ <span>{col.header ?? col.id}</span>
311
+ {/if}
312
+ </div>
313
+ {/each}
314
+ </div>
315
+
316
+ {#if hasTail}
317
+ <div class="flex shrink-0 items-center justify-end" style="width: {heightVar}">
318
+ {#if bulkActions && selectable}
319
+ <Button
320
+ variant="ghost"
321
+ {size}
322
+ aria-label="Bulk actions"
323
+ title="Bulk actions"
324
+ disabled={grid.selected.size === 0}
325
+ onclick={(e) => openMenu(e, bulkActions(grid.selected))}
326
+ >
327
+ {#snippet icon()}<IconBulk />{/snippet}
328
+ </Button>
329
+ {/if}
330
+ </div>
331
+ {/if}
332
+ </div>
333
+
334
+ <!-- Body — its own full 1px border box (fixed height). Top/bottom match the body bg so they don't
335
+ double the header's bottom / footer's top border; left/right carry the real border colour. -->
336
+ <div
337
+ class="border"
338
+ style="border-color: {bodyBg}; border-left-color: {bodyBorder}; border-right-color: {bodyBorder}; background-color: {bodyBg}"
339
+ >
340
+ {#if rows.length === 0}
341
+ <div class="flex items-center {cellGap} {cellPad}" style="min-height: {heightVar}">
342
+ {#if hasLead}
343
+ <div class="invisible flex shrink-0 items-center gap-1" aria-hidden="true">
344
+ {#if selectable}<Checkbox {size} tabindex={-1} />{/if}
345
+ {#if expandable}<span
346
+ class={spacerClass}
347
+ style="width: {heightVar}; height: {heightVar}"><IconChevronRight /></span
348
+ >{/if}
349
+ {#if reorderable}
350
+ <span class={spacerClass} style="width: {heightVar}; height: {heightVar}"></span>
351
+ <span class={spacerClass} style="width: {heightVar}; height: {heightVar}"></span>
352
+ {/if}
353
+ </div>
354
+ {/if}
355
+ <div class="grow self-center text-center {subtle}">
356
+ {#if settled}
357
+ No results.
358
+ {:else}
359
+ <span class="inline-flex items-center gap-2"
360
+ ><IconLoading class="w-[1em] h-[1em] animate-spin" /> Loading…</span
361
+ >
362
+ {/if}
363
+ </div>
364
+ {#if hasTail}<div
365
+ class="invisible flex shrink-0 items-center"
366
+ style="width: {heightVar}"
367
+ aria-hidden="true"
368
+ ></div>{/if}
369
+ </div>
370
+ {:else}
371
+ {#each rows as row, rowIndex (grid.getRowId(row))}
372
+ {@const id = grid.getRowId(row)}
373
+ <!-- Row + its expand panel form one hover group, so they always share the SAME background
374
+ (transparent by default; both take the hover tint together). -->
375
+ <div class="group">
376
+ <div
377
+ role="row"
378
+ class="flex items-center {cellGap} {cellPad} group-hover:[background-color:var(--ui-color-hover)]"
379
+ style="min-height: {heightVar}"
380
+ >
381
+ {#if hasLead}
382
+ <div class="flex shrink-0 items-center gap-1">
383
+ {#if selectable}
384
+ <Checkbox
385
+ {size}
386
+ aria-label="Select row"
387
+ checked={grid.isSelected(id)}
388
+ onchange={() => grid.toggleRow(id)}
389
+ />
390
+ {/if}
391
+ {#if expandable}
392
+ <Button
393
+ variant="ghost"
394
+ {size}
395
+ aria-label={grid.isExpanded(id) ? "Collapse" : "Expand"}
396
+ title={grid.isExpanded(id) ? "Collapse" : "Expand"}
397
+ onclick={() => grid.toggleExpand(id)}
398
+ >
399
+ {#snippet icon()}{#if grid.isExpanded(id)}<IconChevronDown
400
+ />{:else}<IconChevronRight />{/if}{/snippet}
401
+ </Button>
402
+ {/if}
403
+ {#if reorderable}
404
+ <Button
405
+ variant="ghost"
406
+ {size}
407
+ aria-label="Move up"
408
+ title="Move up"
409
+ disabled={rowIndex === 0}
410
+ onclick={() => onReorder?.(row, "up")}
411
+ >
412
+ {#snippet icon()}<IconChevronUp />{/snippet}
413
+ </Button>
414
+ <Button
415
+ variant="ghost"
416
+ {size}
417
+ aria-label="Move down"
418
+ title="Move down"
419
+ disabled={rowIndex === rows.length - 1}
420
+ onclick={() => onReorder?.(row, "down")}
421
+ >
422
+ {#snippet icon()}<IconChevronDown />{/snippet}
423
+ </Button>
424
+ {/if}
425
+ </div>
426
+ {/if}
427
+
428
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
429
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
430
+ <div
431
+ class="grid grow grid-cols-12 items-center {cellGap} {expandable
432
+ ? 'cursor-pointer'
433
+ : ''}"
434
+ onclick={(e) => rowClick(e, id)}
435
+ >
436
+ {#each columns as col (col.id)}
437
+ <div
438
+ role="cell"
439
+ class="{cellClasses(col)} flex min-w-0 items-center {alignClass(col)} {truncate
440
+ ? 'truncate'
441
+ : ''}"
442
+ >
443
+ {@render col.cell(row)}
444
+ </div>
445
+ {/each}
446
+ </div>
447
+
448
+ {#if hasTail}
449
+ <div class="flex shrink-0 items-center justify-end" style="width: {heightVar}">
450
+ {#if rowActions}
451
+ <Button
452
+ variant="ghost"
453
+ {size}
454
+ aria-label="Row actions"
455
+ title="Row actions"
456
+ onclick={(e) => openMenu(e, rowActions(row))}
457
+ >
458
+ {#snippet icon()}<IconDots />{/snippet}
459
+ </Button>
460
+ {/if}
461
+ </div>
462
+ {/if}
463
+ </div>
464
+
465
+ {#if expandable && expanded && grid.isExpanded(id)}
466
+ <div class="{cellPad} group-hover:[background-color:var(--ui-color-hover)]">
467
+ {@render expanded(row)}
468
+ </div>
469
+ {/if}
470
+ </div>
471
+ {/each}
472
+ {/if}
473
+ </div>
474
+
475
+ <!-- Footer — its own full 1px border box (matches the header); rounds the bottom corners. Its top
476
+ border is the separator above it. Inherits font-size from the outer container. -->
477
+ <div
478
+ class="flex flex-wrap items-center justify-between border {cellGap} {cellPad}
479
+ [border-bottom-left-radius:var(--ui-border-radius)] [border-bottom-right-radius:var(--ui-border-radius)]"
480
+ style="border-color: {bandBorder}; background-color: {bandBg}"
481
+ >
482
+ <div class="flex items-center gap-2">
483
+ <Select
484
+ class="w-20"
485
+ {size}
486
+ value={String(grid.perPage)}
487
+ options={grid.perPageOptions.map((n) => ({ value: String(n), label: String(n) }))}
488
+ onSelection={(o) => grid.setPerPage(Number(o.value))}
489
+ />
490
+ <span class={subtle}>per page</span>
491
+ </div>
492
+
493
+ <div class="flex items-center gap-3">
494
+ <span class={subtle}>{rangeText}</span>
495
+ <div class="inline-flex items-center gap-0.5">
496
+ {#snippet pageBtn(Icon: typeof IconFirst, to: number, disabled: boolean, aria: string)}
497
+ <Button
498
+ variant="ghost"
499
+ {size}
500
+ {disabled}
501
+ aria-label={aria}
502
+ title={aria}
503
+ onclick={() => grid.setPage(to)}
504
+ >
505
+ {#snippet icon()}<Icon />{/snippet}
506
+ </Button>
507
+ {/snippet}
508
+ {@render pageBtn(IconFirst, 1, grid.page <= 1, "First")}
509
+ {@render pageBtn(IconPrev, grid.page - 1, grid.page <= 1, "Previous")}
510
+ <span class="px-2 {subtle}">{grid.page} / {pageCount}</span>
511
+ {@render pageBtn(IconNext, grid.page + 1, grid.page >= pageCount, "Next")}
512
+ {@render pageBtn(IconLast, pageCount, grid.page >= pageCount, "Last")}
513
+ </div>
514
+ </div>
515
+ </div>
516
+ </div>
517
+
518
+ <Menu bind:open={menuOpen} anchor={menuAnchor} items={menuItems} side="bottom" align="end" />