@nqmcreative/ui 0.2.3 → 0.4.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 (232) hide show
  1. package/README.md +209 -15
  2. package/cli/index.mjs +328 -112
  3. package/dist/core/color.d.ts +24 -0
  4. package/dist/core/color.js +39 -0
  5. package/dist/core/index.d.ts +8 -1
  6. package/dist/core/index.js +5 -1
  7. package/dist/core/locale.svelte.d.ts +8 -0
  8. package/dist/core/locale.svelte.js +14 -0
  9. package/dist/core/number.d.ts +22 -0
  10. package/dist/core/number.js +33 -0
  11. package/dist/core/pin.d.ts +23 -0
  12. package/dist/core/pin.js +38 -0
  13. package/dist/core/tags.d.ts +37 -0
  14. package/dist/core/tags.js +51 -0
  15. package/dist/core/time.d.ts +30 -0
  16. package/dist/core/time.js +78 -0
  17. package/dist/core/toast.svelte.d.ts +3 -0
  18. package/dist/styles/matte/AccordionItem.svelte +5 -0
  19. package/dist/styles/matte/AccordionItem.svelte.d.ts +2 -0
  20. package/dist/styles/matte/Avatar.svelte +15 -0
  21. package/dist/styles/matte/Avatar.svelte.d.ts +3 -0
  22. package/dist/styles/matte/Badge.svelte +5 -0
  23. package/dist/styles/matte/Badge.svelte.d.ts +2 -0
  24. package/dist/styles/matte/Breadcrumb.svelte +11 -4
  25. package/dist/styles/matte/Breadcrumb.svelte.d.ts +3 -0
  26. package/dist/styles/matte/Button.svelte +58 -7
  27. package/dist/styles/matte/Button.svelte.d.ts +14 -2
  28. package/dist/styles/matte/Card.svelte +6 -1
  29. package/dist/styles/matte/Card.svelte.d.ts +2 -0
  30. package/dist/styles/matte/CheckboxGroup.svelte +6 -0
  31. package/dist/styles/matte/CheckboxGroup.svelte.d.ts +2 -0
  32. package/dist/styles/matte/ColorInput.svelte +110 -0
  33. package/dist/styles/matte/ColorInput.svelte.d.ts +16 -0
  34. package/dist/styles/matte/Combobox.svelte +5 -0
  35. package/dist/styles/matte/Combobox.svelte.d.ts +3 -0
  36. package/dist/styles/matte/CommandPalette.svelte +6 -0
  37. package/dist/styles/matte/CommandPalette.svelte.d.ts +3 -0
  38. package/dist/styles/matte/CurrencyInput.svelte +108 -0
  39. package/dist/styles/matte/CurrencyInput.svelte.d.ts +26 -0
  40. package/dist/styles/matte/Drawer.svelte +5 -0
  41. package/dist/styles/matte/Drawer.svelte.d.ts +2 -0
  42. package/dist/styles/matte/FileInput.svelte +194 -0
  43. package/dist/styles/matte/FileInput.svelte.d.ts +32 -0
  44. package/dist/styles/matte/Footer.svelte +6 -2
  45. package/dist/styles/matte/Footer.svelte.d.ts +2 -0
  46. package/dist/styles/matte/InputAddon.svelte +36 -0
  47. package/dist/styles/matte/InputAddon.svelte.d.ts +13 -0
  48. package/dist/styles/matte/Link.svelte +9 -0
  49. package/dist/styles/matte/Link.svelte.d.ts +4 -0
  50. package/dist/styles/matte/MenuItem.svelte +2 -1
  51. package/dist/styles/matte/Modal.svelte +5 -0
  52. package/dist/styles/matte/Modal.svelte.d.ts +2 -0
  53. package/dist/styles/matte/MultiSelect.svelte +7 -1
  54. package/dist/styles/matte/MultiSelect.svelte.d.ts +3 -0
  55. package/dist/styles/matte/Navbar.svelte +18 -4
  56. package/dist/styles/matte/Navbar.svelte.d.ts +2 -0
  57. package/dist/styles/matte/PinInput.svelte +137 -0
  58. package/dist/styles/matte/PinInput.svelte.d.ts +25 -0
  59. package/dist/styles/matte/PricingCard.svelte +16 -8
  60. package/dist/styles/matte/PricingCard.svelte.d.ts +2 -0
  61. package/dist/styles/matte/RadioGroup.svelte +6 -0
  62. package/dist/styles/matte/RadioGroup.svelte.d.ts +2 -0
  63. package/dist/styles/matte/SearchInput.svelte +87 -0
  64. package/dist/styles/matte/SearchInput.svelte.d.ts +24 -0
  65. package/dist/styles/matte/SegmentedControl.svelte +16 -2
  66. package/dist/styles/matte/SegmentedControl.svelte.d.ts +5 -0
  67. package/dist/styles/matte/Sidebar.svelte +2 -1
  68. package/dist/styles/matte/Stat.svelte +8 -1
  69. package/dist/styles/matte/Stat.svelte.d.ts +3 -0
  70. package/dist/styles/matte/Steps.svelte +7 -0
  71. package/dist/styles/matte/Steps.svelte.d.ts +3 -0
  72. package/dist/styles/matte/Table.svelte +153 -97
  73. package/dist/styles/matte/Table.svelte.d.ts +12 -0
  74. package/dist/styles/matte/Tabs.svelte +5 -0
  75. package/dist/styles/matte/Tabs.svelte.d.ts +3 -0
  76. package/dist/styles/matte/TagsInput.svelte +173 -0
  77. package/dist/styles/matte/TagsInput.svelte.d.ts +29 -0
  78. package/dist/styles/matte/TimeInput.svelte +129 -0
  79. package/dist/styles/matte/TimeInput.svelte.d.ts +21 -0
  80. package/dist/styles/matte/Toaster.svelte +3 -0
  81. package/dist/styles/matte/icon.d.ts +21 -0
  82. package/dist/styles/matte/icon.js +21 -0
  83. package/dist/styles/matte/index.d.ts +8 -2
  84. package/dist/styles/matte/index.js +8 -2
  85. package/dist/styles/paper/AccordionItem.svelte +5 -0
  86. package/dist/styles/paper/AccordionItem.svelte.d.ts +2 -0
  87. package/dist/styles/paper/Avatar.svelte +15 -0
  88. package/dist/styles/paper/Avatar.svelte.d.ts +3 -0
  89. package/dist/styles/paper/Badge.svelte +5 -0
  90. package/dist/styles/paper/Badge.svelte.d.ts +2 -0
  91. package/dist/styles/paper/Breadcrumb.svelte +8 -4
  92. package/dist/styles/paper/Breadcrumb.svelte.d.ts +3 -0
  93. package/dist/styles/paper/Button.svelte +58 -7
  94. package/dist/styles/paper/Button.svelte.d.ts +14 -2
  95. package/dist/styles/paper/Card.svelte +6 -1
  96. package/dist/styles/paper/Card.svelte.d.ts +2 -0
  97. package/dist/styles/paper/CheckboxGroup.svelte +6 -0
  98. package/dist/styles/paper/CheckboxGroup.svelte.d.ts +2 -0
  99. package/dist/styles/paper/ColorInput.svelte +112 -0
  100. package/dist/styles/paper/ColorInput.svelte.d.ts +16 -0
  101. package/dist/styles/paper/Combobox.svelte +5 -0
  102. package/dist/styles/paper/Combobox.svelte.d.ts +3 -0
  103. package/dist/styles/paper/CommandPalette.svelte +6 -0
  104. package/dist/styles/paper/CommandPalette.svelte.d.ts +3 -0
  105. package/dist/styles/paper/CurrencyInput.svelte +108 -0
  106. package/dist/styles/paper/CurrencyInput.svelte.d.ts +26 -0
  107. package/dist/styles/paper/Drawer.svelte +5 -0
  108. package/dist/styles/paper/Drawer.svelte.d.ts +2 -0
  109. package/dist/styles/paper/FileInput.svelte +204 -0
  110. package/dist/styles/paper/FileInput.svelte.d.ts +32 -0
  111. package/dist/styles/paper/Footer.svelte +6 -2
  112. package/dist/styles/paper/Footer.svelte.d.ts +2 -0
  113. package/dist/styles/paper/InputAddon.svelte +36 -0
  114. package/dist/styles/paper/InputAddon.svelte.d.ts +13 -0
  115. package/dist/styles/paper/Link.svelte +9 -0
  116. package/dist/styles/paper/Link.svelte.d.ts +4 -0
  117. package/dist/styles/paper/MenuItem.svelte +2 -1
  118. package/dist/styles/paper/Modal.svelte +5 -0
  119. package/dist/styles/paper/Modal.svelte.d.ts +2 -0
  120. package/dist/styles/paper/MultiSelect.svelte +7 -1
  121. package/dist/styles/paper/MultiSelect.svelte.d.ts +3 -0
  122. package/dist/styles/paper/Navbar.svelte +18 -4
  123. package/dist/styles/paper/Navbar.svelte.d.ts +2 -0
  124. package/dist/styles/paper/PinInput.svelte +137 -0
  125. package/dist/styles/paper/PinInput.svelte.d.ts +25 -0
  126. package/dist/styles/paper/PricingCard.svelte +23 -15
  127. package/dist/styles/paper/PricingCard.svelte.d.ts +2 -0
  128. package/dist/styles/paper/RadioGroup.svelte +6 -0
  129. package/dist/styles/paper/RadioGroup.svelte.d.ts +2 -0
  130. package/dist/styles/paper/SearchInput.svelte +92 -0
  131. package/dist/styles/paper/SearchInput.svelte.d.ts +24 -0
  132. package/dist/styles/paper/SegmentedControl.svelte +16 -2
  133. package/dist/styles/paper/SegmentedControl.svelte.d.ts +5 -0
  134. package/dist/styles/paper/Sidebar.svelte +2 -1
  135. package/dist/styles/paper/Stat.svelte +8 -1
  136. package/dist/styles/paper/Stat.svelte.d.ts +3 -0
  137. package/dist/styles/paper/Steps.svelte +7 -0
  138. package/dist/styles/paper/Steps.svelte.d.ts +3 -0
  139. package/dist/styles/paper/Table.svelte +153 -97
  140. package/dist/styles/paper/Table.svelte.d.ts +12 -0
  141. package/dist/styles/paper/Tabs.svelte +5 -0
  142. package/dist/styles/paper/Tabs.svelte.d.ts +3 -0
  143. package/dist/styles/paper/TagsInput.svelte +178 -0
  144. package/dist/styles/paper/TagsInput.svelte.d.ts +29 -0
  145. package/dist/styles/paper/TimeInput.svelte +155 -0
  146. package/dist/styles/paper/TimeInput.svelte.d.ts +21 -0
  147. package/dist/styles/paper/Toaster.svelte +3 -0
  148. package/dist/styles/paper/icon.d.ts +21 -0
  149. package/dist/styles/paper/icon.js +21 -0
  150. package/dist/styles/paper/index.d.ts +8 -2
  151. package/dist/styles/paper/index.js +8 -2
  152. package/dist/styles/sprout/AccordionItem.svelte +5 -0
  153. package/dist/styles/sprout/AccordionItem.svelte.d.ts +2 -0
  154. package/dist/styles/sprout/Avatar.svelte +15 -0
  155. package/dist/styles/sprout/Avatar.svelte.d.ts +3 -0
  156. package/dist/styles/sprout/Badge.svelte +5 -0
  157. package/dist/styles/sprout/Badge.svelte.d.ts +2 -0
  158. package/dist/styles/sprout/Breadcrumb.svelte +8 -4
  159. package/dist/styles/sprout/Breadcrumb.svelte.d.ts +3 -0
  160. package/dist/styles/sprout/Button.svelte +58 -7
  161. package/dist/styles/sprout/Button.svelte.d.ts +14 -2
  162. package/dist/styles/sprout/Card.svelte +6 -1
  163. package/dist/styles/sprout/Card.svelte.d.ts +2 -0
  164. package/dist/styles/sprout/CheckboxGroup.svelte +6 -0
  165. package/dist/styles/sprout/CheckboxGroup.svelte.d.ts +2 -0
  166. package/dist/styles/sprout/ColorInput.svelte +112 -0
  167. package/dist/styles/sprout/ColorInput.svelte.d.ts +16 -0
  168. package/dist/styles/sprout/Combobox.svelte +5 -0
  169. package/dist/styles/sprout/Combobox.svelte.d.ts +3 -0
  170. package/dist/styles/sprout/CommandPalette.svelte +6 -0
  171. package/dist/styles/sprout/CommandPalette.svelte.d.ts +3 -0
  172. package/dist/styles/sprout/CurrencyInput.svelte +108 -0
  173. package/dist/styles/sprout/CurrencyInput.svelte.d.ts +26 -0
  174. package/dist/styles/sprout/Drawer.svelte +5 -0
  175. package/dist/styles/sprout/Drawer.svelte.d.ts +2 -0
  176. package/dist/styles/sprout/FileInput.svelte +205 -0
  177. package/dist/styles/sprout/FileInput.svelte.d.ts +32 -0
  178. package/dist/styles/sprout/Footer.svelte +6 -2
  179. package/dist/styles/sprout/Footer.svelte.d.ts +2 -0
  180. package/dist/styles/sprout/InputAddon.svelte +37 -0
  181. package/dist/styles/sprout/InputAddon.svelte.d.ts +13 -0
  182. package/dist/styles/sprout/Link.svelte +9 -0
  183. package/dist/styles/sprout/Link.svelte.d.ts +4 -0
  184. package/dist/styles/sprout/MenuItem.svelte +2 -1
  185. package/dist/styles/sprout/Modal.svelte +5 -0
  186. package/dist/styles/sprout/Modal.svelte.d.ts +2 -0
  187. package/dist/styles/sprout/MultiSelect.svelte +7 -1
  188. package/dist/styles/sprout/MultiSelect.svelte.d.ts +3 -0
  189. package/dist/styles/sprout/Navbar.svelte +18 -4
  190. package/dist/styles/sprout/Navbar.svelte.d.ts +2 -0
  191. package/dist/styles/sprout/PinInput.svelte +138 -0
  192. package/dist/styles/sprout/PinInput.svelte.d.ts +25 -0
  193. package/dist/styles/sprout/PricingCard.svelte +23 -15
  194. package/dist/styles/sprout/PricingCard.svelte.d.ts +2 -0
  195. package/dist/styles/sprout/RadioGroup.svelte +6 -0
  196. package/dist/styles/sprout/RadioGroup.svelte.d.ts +2 -0
  197. package/dist/styles/sprout/SearchInput.svelte +92 -0
  198. package/dist/styles/sprout/SearchInput.svelte.d.ts +24 -0
  199. package/dist/styles/sprout/SegmentedControl.svelte +16 -2
  200. package/dist/styles/sprout/SegmentedControl.svelte.d.ts +5 -0
  201. package/dist/styles/sprout/Sidebar.svelte +2 -1
  202. package/dist/styles/sprout/Stat.svelte +8 -1
  203. package/dist/styles/sprout/Stat.svelte.d.ts +3 -0
  204. package/dist/styles/sprout/Steps.svelte +7 -0
  205. package/dist/styles/sprout/Steps.svelte.d.ts +3 -0
  206. package/dist/styles/sprout/Table.svelte +153 -97
  207. package/dist/styles/sprout/Table.svelte.d.ts +12 -0
  208. package/dist/styles/sprout/Tabs.svelte +5 -0
  209. package/dist/styles/sprout/Tabs.svelte.d.ts +3 -0
  210. package/dist/styles/sprout/TagsInput.svelte +179 -0
  211. package/dist/styles/sprout/TagsInput.svelte.d.ts +29 -0
  212. package/dist/styles/sprout/TimeInput.svelte +155 -0
  213. package/dist/styles/sprout/TimeInput.svelte.d.ts +21 -0
  214. package/dist/styles/sprout/Toaster.svelte +3 -0
  215. package/dist/styles/sprout/icon.d.ts +21 -0
  216. package/dist/styles/sprout/icon.js +21 -0
  217. package/dist/styles/sprout/index.d.ts +8 -2
  218. package/dist/styles/sprout/index.js +8 -2
  219. package/package.json +98 -26
  220. package/registry.json +143 -35
  221. package/dist/styles/matte/NumberInput.svelte +0 -96
  222. package/dist/styles/matte/NumberInput.svelte.d.ts +0 -15
  223. package/dist/styles/matte/PasswordInput.svelte +0 -95
  224. package/dist/styles/matte/PasswordInput.svelte.d.ts +0 -12
  225. package/dist/styles/paper/NumberInput.svelte +0 -96
  226. package/dist/styles/paper/NumberInput.svelte.d.ts +0 -15
  227. package/dist/styles/paper/PasswordInput.svelte +0 -100
  228. package/dist/styles/paper/PasswordInput.svelte.d.ts +0 -12
  229. package/dist/styles/sprout/NumberInput.svelte +0 -97
  230. package/dist/styles/sprout/NumberInput.svelte.d.ts +0 -15
  231. package/dist/styles/sprout/PasswordInput.svelte +0 -101
  232. package/dist/styles/sprout/PasswordInput.svelte.d.ts +0 -12
@@ -1,6 +1,8 @@
1
1
  <script lang="ts">
2
+ import type { Snippet } from 'svelte';
2
3
  import type { HTMLAttributes } from 'svelte/elements';
3
4
  import { toneText, type Tone } from '../../core/tones.js';
5
+ import { iconMd } from './icon.js';
4
6
 
5
7
  export type StatTrend = 'up' | 'down' | 'flat';
6
8
 
@@ -11,6 +13,8 @@
11
13
  delta?: string;
12
14
  trend?: StatTrend;
13
15
  hint?: string;
16
+ /** Leading icon on the label row. */
17
+ icon?: Snippet;
14
18
  /** Colour of the value itself. Leave unset for plain `text`. */
15
19
  tone?: Tone;
16
20
  }
@@ -21,6 +25,7 @@
21
25
  delta,
22
26
  trend = 'flat',
23
27
  hint,
28
+ icon,
24
29
  tone,
25
30
  class: className = '',
26
31
  ...rest
@@ -31,7 +36,9 @@
31
36
  </script>
32
37
 
33
38
  <div class="flex flex-col gap-1 font-sans {className}" {...rest}>
34
- <span class="text-sm font-medium text-text-muted">{label}</span>
39
+ <span class="flex items-center gap-2 text-sm font-medium text-text-muted"
40
+ >{#if icon}<span class={iconMd}>{@render icon()}</span>{/if}{label}</span
41
+ >
35
42
  <div class="flex items-baseline gap-2">
36
43
  <span
37
44
  class="font-heading text-3xl font-semibold tracking-tight tabular-nums {tone
@@ -1,3 +1,4 @@
1
+ import type { Snippet } from 'svelte';
1
2
  import type { HTMLAttributes } from 'svelte/elements';
2
3
  import { type Tone } from '../../core/tones.js';
3
4
  export type StatTrend = 'up' | 'down' | 'flat';
@@ -8,6 +9,8 @@ interface Props extends HTMLAttributes<HTMLDivElement> {
8
9
  delta?: string;
9
10
  trend?: StatTrend;
10
11
  hint?: string;
12
+ /** Leading icon on the label row. */
13
+ icon?: Snippet;
11
14
  /** Colour of the value itself. Leave unset for plain `text`. */
12
15
  tone?: Tone;
13
16
  }
@@ -1,7 +1,11 @@
1
1
  <script module lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+
2
4
  export interface StepItem {
3
5
  label: string;
4
6
  description?: string;
7
+ /** Replaces the step number. A finished step still shows its check. */
8
+ icon?: Snippet;
5
9
  /** Marks the step as failed — shown in `danger` regardless of position. */
6
10
  error?: boolean;
7
11
  disabled?: boolean;
@@ -10,6 +14,7 @@
10
14
 
11
15
  <script lang="ts">
12
16
  import type { HTMLAttributes } from 'svelte/elements';
17
+ import { iconMd } from './icon.js';
13
18
  import { focusRing, toneFill, toneRing, toneText, type Tone } from '../../core/tones.js';
14
19
 
15
20
  interface Props extends Omit<HTMLAttributes<HTMLElement>, 'onchange'> {
@@ -73,6 +78,8 @@
73
78
  stroke-linejoin="round"
74
79
  />
75
80
  </svg>
81
+ {:else if item.icon}
82
+ <span class={iconMd}>{@render item.icon()}</span>
76
83
  {:else}
77
84
  {index + 1}
78
85
  {/if}
@@ -1,6 +1,9 @@
1
+ import type { Snippet } from 'svelte';
1
2
  export interface StepItem {
2
3
  label: string;
3
4
  description?: string;
5
+ /** Replaces the step number. A finished step still shows its check. */
6
+ icon?: Snippet;
4
7
  /** Marks the step as failed — shown in `danger` regardless of position. */
5
8
  error?: boolean;
6
9
  disabled?: boolean;
@@ -32,6 +32,16 @@
32
32
  /** Header stays put while the body scrolls — pair with a `max-h-*` class. */
33
33
  stickyHeader?: boolean;
34
34
 
35
+ /** Bar above the table — a title, a search field, a filter row. */
36
+ header?: Snippet;
37
+ /** Bar below the table — pagination, a total, a summary line. */
38
+ footer?: Snippet;
39
+ /**
40
+ * Shown in place of `header` while rows are ticked, after the count and the
41
+ * clear button. Passing it is what turns the bulk bar on.
42
+ */
43
+ bulkActions?: Snippet<[{ selected: string[]; clear: () => void }]>;
44
+
35
45
  /** Bindable. Set it to sort; leave `sortable` columns to drive it. */
36
46
  sort?: TableSort | null;
37
47
  /** Sort the rows here rather than refetching them server-side. Default true. */
@@ -57,6 +67,9 @@
57
67
  hoverable = true,
58
68
  compact = false,
59
69
  stickyHeader = false,
70
+ header,
71
+ footer,
72
+ bulkActions,
60
73
  sort = $bindable(null),
61
74
  sortLocally = true,
62
75
  onsort,
@@ -106,112 +119,155 @@
106
119
  selected = toggleKey(selected, keyOf(row));
107
120
  onselect?.(selected);
108
121
  }
122
+
123
+ function clearSelection() {
124
+ selected = [];
125
+ onselect?.(selected);
126
+ }
127
+
128
+ /** The bulk bar takes the header's place, so only one of the two is up. */
129
+ const bulkBar = $derived(selectable && bulkActions !== undefined && selected.length > 0);
109
130
  </script>
110
131
 
111
132
  <div
112
- class="w-full overflow-auto rounded-lg border border-hairline bg-bg shadow-xs {className}"
133
+ class="flex w-full flex-col overflow-hidden rounded-lg border border-hairline bg-bg shadow-xs {className}"
113
134
  {...rest}
114
135
  >
115
- <table class="w-full border-collapse font-sans text-sm">
116
- {#if caption}
117
- <caption
118
- class="border-b border-hairline px-4 py-3 text-left font-sans text-[13px] text-text-muted"
136
+ {#if bulkBar}
137
+ <div
138
+ class="flex flex-wrap items-center gap-3 border-b border-hairline bg-brand-light px-4 py-3"
139
+ >
140
+ <span class="font-sans text-[13px] font-semibold text-text">
141
+ {selected.length}
142
+ {t.current.selected}
143
+ </span>
144
+ <button
145
+ type="button"
146
+ onclick={clearSelection}
147
+ class="rounded font-sans text-[13px] font-medium text-text-muted transition-colors duration-150 hover:text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current"
119
148
  >
120
- {caption}
121
- </caption>
122
- {/if}
123
- <thead class={stickyHeader ? 'sticky top-0 z-10' : ''}>
124
- <tr class="border-b border-hairline bg-bg-alt">
125
- {#if selectable}
126
- <th scope="col" class="w-10 {pad}">
127
- <Checkbox
128
- checked={allSelected}
129
- indeterminate={someSelected}
130
- {tone}
131
- aria-label={t.current.selectAllRows}
132
- onchange={toggleAll}
133
- />
134
- </th>
135
- {/if}
136
- {#each columns as column (column.key)}
137
- <th
138
- scope="col"
139
- aria-sort={sort?.key === column.key
140
- ? sort.direction === 'asc'
141
- ? 'ascending'
142
- : 'descending'
143
- : undefined}
144
- class="text-[13px] font-semibold text-text-muted {pad} {aligns[
145
- column.align ?? 'left'
146
- ]} {column.class ?? ''}"
147
- >
148
- {#if column.sortable}
149
- <button
150
- type="button"
151
- onclick={() => toggleSort(column)}
152
- class="inline-flex items-center gap-1.5 rounded transition-colors duration-150 hover:text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current
149
+ {t.current.clear}
150
+ </button>
151
+ <div class="ml-auto flex flex-wrap items-center gap-2">
152
+ {@render bulkActions?.({ selected, clear: clearSelection })}
153
+ </div>
154
+ </div>
155
+ {:else if header}
156
+ <div
157
+ class="flex flex-wrap items-center gap-3 border-b border-hairline px-4 py-3 font-sans text-[13px] text-text-secondary"
158
+ >
159
+ {@render header()}
160
+ </div>
161
+ {/if}
162
+ <div class="min-h-0 w-full flex-1 overflow-auto">
163
+ <table class="w-full border-collapse font-sans text-sm">
164
+ {#if caption}
165
+ <caption
166
+ class="border-b border-hairline px-4 py-3 text-left font-sans text-[13px] text-text-muted"
167
+ >
168
+ {caption}
169
+ </caption>
170
+ {/if}
171
+ <thead class={stickyHeader ? 'sticky top-0 z-10' : ''}>
172
+ <tr class="border-b border-hairline bg-bg-alt">
173
+ {#if selectable}
174
+ <th scope="col" class="w-10 {pad}">
175
+ <Checkbox
176
+ checked={allSelected}
177
+ indeterminate={someSelected}
178
+ {tone}
179
+ aria-label={t.current.selectAllRows}
180
+ onchange={toggleAll}
181
+ />
182
+ </th>
183
+ {/if}
184
+ {#each columns as column (column.key)}
185
+ <th
186
+ scope="col"
187
+ aria-sort={sort?.key === column.key
188
+ ? sort.direction === 'asc'
189
+ ? 'ascending'
190
+ : 'descending'
191
+ : undefined}
192
+ class="text-[13px] font-semibold text-text-muted {pad} {aligns[
193
+ column.align ?? 'left'
194
+ ]} {column.class ?? ''}"
195
+ >
196
+ {#if column.sortable}
197
+ <button
198
+ type="button"
199
+ onclick={() => toggleSort(column)}
200
+ class="inline-flex items-center gap-1.5 rounded transition-colors duration-150 hover:text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current
153
201
  {sort?.key === column.key ? 'text-text' : ''}"
154
- >
202
+ >
203
+ {column.label}
204
+ <span class="text-[9px] leading-none" aria-hidden="true">
205
+ {#if sort?.key === column.key}
206
+ {sort.direction === 'asc' ? '▲' : '▼'}
207
+ {:else}
208
+
209
+ {/if}
210
+ </span>
211
+ </button>
212
+ {:else}
155
213
  {column.label}
156
- <span class="text-[9px] leading-none" aria-hidden="true">
157
- {#if sort?.key === column.key}
158
- {sort.direction === 'asc' ? '▲' : '▼'}
159
- {:else}
160
-
161
- {/if}
162
- </span>
163
- </button>
164
- {:else}
165
- {column.label}
166
- {/if}
167
- </th>
168
- {/each}
169
- </tr>
170
- </thead>
171
- <tbody>
172
- {#if sorted.length === 0}
173
- <tr>
174
- <td
175
- colspan={columns.length + (selectable ? 1 : 0)}
176
- class="px-4 py-10 text-center text-sm text-text-muted"
177
- >
178
- {#if empty}{@render empty()}{:else}{t.current.noData}{/if}
179
- </td>
214
+ {/if}
215
+ </th>
216
+ {/each}
180
217
  </tr>
181
- {:else}
182
- {#each sorted as row, i (selectable ? keyOf(row) : i)}
183
- {@const isSelected = selectable && selected.includes(keyOf(row))}
184
- <tr
185
- aria-selected={selectable ? isSelected : undefined}
186
- class="border-b border-hairline last:border-b-0
218
+ </thead>
219
+ <tbody>
220
+ {#if sorted.length === 0}
221
+ <tr>
222
+ <td
223
+ colspan={columns.length + (selectable ? 1 : 0)}
224
+ class="px-4 py-10 text-center text-sm text-text-muted"
225
+ >
226
+ {#if empty}{@render empty()}{:else}{t.current.noData}{/if}
227
+ </td>
228
+ </tr>
229
+ {:else}
230
+ {#each sorted as row, i (selectable ? keyOf(row) : i)}
231
+ {@const isSelected = selectable && selected.includes(keyOf(row))}
232
+ <tr
233
+ aria-selected={selectable ? isSelected : undefined}
234
+ class="border-b border-hairline last:border-b-0
187
235
  {isSelected ? 'bg-brand-light' : striped && i % 2 === 1 ? 'bg-bg-alt' : ''}
188
236
  {hoverable ? 'transition-colors duration-150 hover:bg-bg-alt' : ''}"
189
- >
190
- {#if selectable}
191
- <td class={pad}>
192
- <Checkbox
193
- checked={isSelected}
194
- {tone}
195
- aria-label={t.current.selectRow}
196
- onchange={() => toggleRow(row)}
197
- />
198
- </td>
199
- {/if}
200
- {#each columns as column (column.key)}
201
- <td
202
- class="text-text-secondary {pad} {aligns[column.align ?? 'left']} {column.class ??
203
- ''}"
204
- >
205
- {#if cell}
206
- {@render cell({ row, column, value: row[column.key] })}
207
- {:else}
208
- {row[column.key] ?? ''}
209
- {/if}
210
- </td>
211
- {/each}
212
- </tr>
213
- {/each}
214
- {/if}
215
- </tbody>
216
- </table>
237
+ >
238
+ {#if selectable}
239
+ <td class={pad}>
240
+ <Checkbox
241
+ checked={isSelected}
242
+ {tone}
243
+ aria-label={t.current.selectRow}
244
+ onchange={() => toggleRow(row)}
245
+ />
246
+ </td>
247
+ {/if}
248
+ {#each columns as column (column.key)}
249
+ <td
250
+ class="text-text-secondary {pad} {aligns[column.align ?? 'left']} {column.class ??
251
+ ''}"
252
+ >
253
+ {#if cell}
254
+ {@render cell({ row, column, value: row[column.key] })}
255
+ {:else}
256
+ {row[column.key] ?? ''}
257
+ {/if}
258
+ </td>
259
+ {/each}
260
+ </tr>
261
+ {/each}
262
+ {/if}
263
+ </tbody>
264
+ </table>
265
+ </div>
266
+ {#if footer}
267
+ <div
268
+ class="flex flex-wrap items-center gap-3 border-t border-hairline px-4 py-3 font-sans text-[13px] text-text-secondary"
269
+ >
270
+ {@render footer()}
271
+ </div>
272
+ {/if}
217
273
  </div>
@@ -29,6 +29,18 @@ declare function $$render<T extends Record<string, unknown>>(): {
29
29
  compact?: boolean;
30
30
  /** Header stays put while the body scrolls — pair with a `max-h-*` class. */
31
31
  stickyHeader?: boolean;
32
+ /** Bar above the table — a title, a search field, a filter row. */
33
+ header?: Snippet;
34
+ /** Bar below the table — pagination, a total, a summary line. */
35
+ footer?: Snippet;
36
+ /**
37
+ * Shown in place of `header` while rows are ticked, after the count and the
38
+ * clear button. Passing it is what turns the bulk bar on.
39
+ */
40
+ bulkActions?: Snippet<[{
41
+ selected: string[];
42
+ clear: () => void;
43
+ }]>;
32
44
  /** Bindable. Set it to sort; leave `sortable` columns to drive it. */
33
45
  sort?: TableSort | null;
34
46
  /** Sort the rows here rather than refetching them server-side. Default true. */
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import type { Snippet } from 'svelte';
2
3
  import type { HTMLAttributes } from 'svelte/elements';
3
4
  import {
4
5
  focusRing,
@@ -8,10 +9,13 @@
8
9
  toneText,
9
10
  type Tone
10
11
  } from '../../core/tones.js';
12
+ import { iconMd } from './icon.js';
11
13
 
12
14
  export interface TabItem {
13
15
  value: string;
14
16
  label: string;
17
+ /** Leading icon, 16px. */
18
+ icon?: Snippet;
15
19
  disabled?: boolean;
16
20
  /** Small count/label rendered after the tab title. */
17
21
  badge?: string | number;
@@ -89,6 +93,7 @@
89
93
  onclick={() => select(item)}
90
94
  class={tabClass(value === item.value)}
91
95
  >
96
+ {#if item.icon}<span class={iconMd}>{@render item.icon()}</span>{/if}
92
97
  {item.label}
93
98
  {#if item.badge !== undefined}
94
99
  <span
@@ -1,8 +1,11 @@
1
+ import type { Snippet } from 'svelte';
1
2
  import type { HTMLAttributes } from 'svelte/elements';
2
3
  import { type Tone } from '../../core/tones.js';
3
4
  export interface TabItem {
4
5
  value: string;
5
6
  label: string;
7
+ /** Leading icon, 16px. */
8
+ icon?: Snippet;
6
9
  disabled?: boolean;
7
10
  /** Small count/label rendered after the tab title. */
8
11
  badge?: string | number;
@@ -0,0 +1,178 @@
1
+ <script lang="ts">
2
+ import { useLocale } from '../../core/locale.svelte.js';
3
+ import { addTag, splitTags, type TagRejection } from '../../core/tags.js';
4
+ import { toneFocusWithinBorder, toneSoft, type Tone } from '../../core/tones.js';
5
+
6
+ interface Props {
7
+ /** Bindable list of tags, in the order they were added. */
8
+ tags?: string[];
9
+ placeholder?: string;
10
+ /** Cap on how many may be held. `0` means no limit. */
11
+ max?: number;
12
+ /** Shortest a tag may be, after trimming. */
13
+ minLength?: number;
14
+ /** Keys that commit the tag under the caret, besides Enter. */
15
+ separators?: string[];
16
+ /** Refuse a tag that differs from an existing one only in case. */
17
+ caseInsensitive?: boolean;
18
+ /** Commit whatever is half-typed when the field loses focus. */
19
+ commitOnBlur?: boolean;
20
+ disabled?: boolean;
21
+ invalid?: boolean;
22
+ tone?: Tone;
23
+ /** `name` for a plain form submit — one hidden field, comma-joined. */
24
+ name?: string;
25
+ onadd?: (tag: string) => void;
26
+ onremove?: (tag: string) => void;
27
+ onreject?: (reason: TagRejection, raw: string) => void;
28
+ class?: string;
29
+ }
30
+
31
+ let {
32
+ tags = $bindable([]),
33
+ placeholder,
34
+ max = 0,
35
+ minLength = 1,
36
+ separators = [','],
37
+ caseInsensitive = true,
38
+ commitOnBlur = true,
39
+ disabled = false,
40
+ invalid = false,
41
+ tone = 'brand',
42
+ name,
43
+ onadd,
44
+ onremove,
45
+ onreject,
46
+ class: className = ''
47
+ }: Props = $props();
48
+
49
+ const t = useLocale();
50
+
51
+ let draft = $state('');
52
+ let field: HTMLInputElement | null = $state(null);
53
+ /** The last refusal, cleared as soon as the next keystroke lands. */
54
+ let refused = $state<TagRejection | null>(null);
55
+
56
+ const full = $derived(max > 0 && tags.length >= max);
57
+ const rules = $derived({ max, minLength, caseInsensitive });
58
+
59
+ function commit(raw: string) {
60
+ const { tags: next, rejected } = addTag(tags, raw, rules);
61
+ if (rejected) {
62
+ // An empty commit is the Enter key on an empty field, not a mistake.
63
+ if (rejected !== 'empty') {
64
+ refused = rejected;
65
+ onreject?.(rejected, raw);
66
+ }
67
+ return false;
68
+ }
69
+ tags = next;
70
+ onadd?.(next[next.length - 1]);
71
+ return true;
72
+ }
73
+
74
+ function remove(index: number) {
75
+ const [gone] = tags.slice(index, index + 1);
76
+ tags = tags.filter((_, at) => at !== index);
77
+ if (gone !== undefined) onremove?.(gone);
78
+ }
79
+
80
+ function onKeydown(event: KeyboardEvent) {
81
+ refused = null;
82
+
83
+ if (event.key === 'Enter' || separators.includes(event.key)) {
84
+ event.preventDefault();
85
+ if (commit(draft)) draft = '';
86
+ return;
87
+ }
88
+
89
+ // Empty field: Backspace peels the last chip off, the way chips behave
90
+ // everywhere else in this library.
91
+ if (event.key === 'Backspace' && draft === '' && tags.length) {
92
+ event.preventDefault();
93
+ remove(tags.length - 1);
94
+ }
95
+ }
96
+
97
+ function onPaste(event: ClipboardEvent) {
98
+ const text = event.clipboardData?.getData('text') ?? '';
99
+ const parts = splitTags(text, separators);
100
+ if (parts.length <= 1) return;
101
+
102
+ event.preventDefault();
103
+ for (const part of parts) commit(part);
104
+ draft = '';
105
+ }
106
+
107
+ function onBlur() {
108
+ if (commitOnBlur && draft.trim() && commit(draft)) draft = '';
109
+ }
110
+
111
+ const messages: Record<TagRejection, string> = $derived({
112
+ empty: '',
113
+ short: `${minLength}+`,
114
+ duplicate: t.current.duplicateTag,
115
+ full: `${t.current.selected} ${tags.length}/${max}`
116
+ });
117
+ </script>
118
+
119
+ <div class="flex w-full flex-col gap-1.5 {className}">
120
+ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
121
+ <div
122
+ onclick={() => field?.focus()}
123
+ class="flex w-full flex-wrap items-center gap-1.5 rounded-md border bg-bg py-1.5 pr-1.5 pl-2 shadow-xs transition-colors duration-150 ease-brand-out
124
+ {invalid
125
+ ? 'border-danger focus-within:border-danger'
126
+ : `border-hairline-strong ${toneFocusWithinBorder[tone]}`}
127
+ {disabled ? 'pointer-events-none opacity-50' : ''}"
128
+ >
129
+ {#each tags as tag, index (tag)}
130
+ <span
131
+ class="inline-flex items-center gap-1 rounded-md py-0.5 pr-0.5 pl-2 text-xs font-medium {toneSoft[
132
+ tone
133
+ ]}"
134
+ >
135
+ {tag}
136
+ <button
137
+ type="button"
138
+ onclick={() => remove(index)}
139
+ aria-label="{t.current.removeItem} {tag}"
140
+ class="inline-flex size-4 items-center justify-center rounded transition-colors duration-150 hover:bg-bg/70 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-current"
141
+ >
142
+ <svg class="size-2.5" viewBox="0 0 10 10" fill="none" aria-hidden="true">
143
+ <path
144
+ d="m1.5 1.5 7 7M8.5 1.5l-7 7"
145
+ stroke="currentColor"
146
+ stroke-width="1.6"
147
+ stroke-linecap="round"
148
+ />
149
+ </svg>
150
+ </button>
151
+ </span>
152
+ {/each}
153
+
154
+ <input
155
+ bind:this={field}
156
+ bind:value={draft}
157
+ {disabled}
158
+ readonly={full}
159
+ placeholder={tags.length === 0 ? (placeholder ?? t.current.addTag) : ''}
160
+ aria-label={placeholder ?? t.current.addTag}
161
+ aria-invalid={invalid ? 'true' : undefined}
162
+ onkeydown={onKeydown}
163
+ onpaste={onPaste}
164
+ onblur={onBlur}
165
+ class="h-7 min-w-24 flex-1 bg-transparent font-sans text-sm text-text placeholder:text-text-muted focus:outline-none"
166
+ />
167
+
168
+ {#if name}
169
+ <input type="hidden" {name} value={tags.join(',')} />
170
+ {/if}
171
+ </div>
172
+
173
+ {#if refused && messages[refused]}
174
+ <span class="text-xs font-medium text-danger">{messages[refused]}</span>
175
+ {:else if max}
176
+ <span class="text-xs text-text-muted tabular-nums">{tags.length}/{max}</span>
177
+ {/if}
178
+ </div>
@@ -0,0 +1,29 @@
1
+ import { type TagRejection } from '../../core/tags.js';
2
+ import { type Tone } from '../../core/tones.js';
3
+ interface Props {
4
+ /** Bindable list of tags, in the order they were added. */
5
+ tags?: string[];
6
+ placeholder?: string;
7
+ /** Cap on how many may be held. `0` means no limit. */
8
+ max?: number;
9
+ /** Shortest a tag may be, after trimming. */
10
+ minLength?: number;
11
+ /** Keys that commit the tag under the caret, besides Enter. */
12
+ separators?: string[];
13
+ /** Refuse a tag that differs from an existing one only in case. */
14
+ caseInsensitive?: boolean;
15
+ /** Commit whatever is half-typed when the field loses focus. */
16
+ commitOnBlur?: boolean;
17
+ disabled?: boolean;
18
+ invalid?: boolean;
19
+ tone?: Tone;
20
+ /** `name` for a plain form submit — one hidden field, comma-joined. */
21
+ name?: string;
22
+ onadd?: (tag: string) => void;
23
+ onremove?: (tag: string) => void;
24
+ onreject?: (reason: TagRejection, raw: string) => void;
25
+ class?: string;
26
+ }
27
+ declare const TagsInput: import("svelte").Component<Props, {}, "tags">;
28
+ type TagsInput = ReturnType<typeof TagsInput>;
29
+ export default TagsInput;