@spaethtech/svelte-ui 0.1.10 → 0.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 (208) hide show
  1. package/README.md +166 -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,109 @@
1
+ /**
2
+ * Visual / interaction mode at a given viewport tier.
3
+ *
4
+ * - `stepped` — `position: absolute`, slides in from the left,
5
+ * full-width. Parent items use click-to-step
6
+ * navigation (no hover popouts). Requires a
7
+ * hamburger trigger. Designed for phones.
8
+ * - `drawer` — `position: absolute`, slides in from the left,
9
+ * `--sbm-expanded-w` wide. Parent items show hover
10
+ * popouts to the right. Requires a hamburger.
11
+ * Designed for tablet portrait / phablet.
12
+ * - `icon` — `position: relative` (in flow), `--sbm-collapsed-w`
13
+ * wide, icon-only rows. Parent items show hover
14
+ * popouts. Designed for tablet landscape / narrow
15
+ * desktop.
16
+ * - `expanded` — `position: relative`, `--sbm-expanded-w` wide,
17
+ * icon + label rows. Parent items show hover popouts.
18
+ * Designed for wide desktop.
19
+ */
20
+ export type SideBarMenuMode = "stepped" | "drawer" | "icon" | "expanded";
21
+ export type SideBarMenuBadgeVariant = "error" | "warning" | "info" | "success";
22
+ export type SideBarMenuBadge = {
23
+ /** Color of the indicator. */
24
+ variant: SideBarMenuBadgeVariant;
25
+ /**
26
+ * Optional numeric count. Present → filled pill with the number.
27
+ * Absent → bare icon (variant-coloured). Useful for "has-any" signals
28
+ * where the precise count isn't meaningful to the user.
29
+ */
30
+ count?: number;
31
+ };
32
+ /**
33
+ * Icon component slot. Typed permissively because `unplugin-icons` emits
34
+ * its own callable shape (`typeof default`) that doesn't satisfy Svelte's
35
+ * strict `Component<…>` primitive in every consumer tsconfig. The runtime
36
+ * shape is always a Svelte component the renderer can `<Icon />` — TS just
37
+ * can't always prove it.
38
+ */
39
+ export type SideBarMenuItemIcon = any;
40
+ export type SideBarMenuItem = {
41
+ /**
42
+ * Icon component constructor — same idiom as `MenuItem.icon`. Pass an
43
+ * imported MDI icon (e.g. `import IconActivity from '~icons/mdi/pulse'`)
44
+ * straight into the data array; no snippet wrapper needed.
45
+ */
46
+ icon?: SideBarMenuItemIcon;
47
+ label: string;
48
+ /** href; absent when the item is a parent that only hosts children. */
49
+ link?: string;
50
+ badge?: SideBarMenuBadge;
51
+ /** v1 renders only the first level; deeper levels emit a DEV warning. */
52
+ children?: SideBarMenuItem[];
53
+ };
54
+ import type { Size } from "../../types/sizes.js";
55
+ import { type Breakpoint } from "../../types/breakpoints.js";
56
+ type ModesMap = Partial<Record<Breakpoint, SideBarMenuMode>>;
57
+ type $$ComponentProps = {
58
+ topItems?: SideBarMenuItem[];
59
+ bottomItems?: SideBarMenuItem[];
60
+ currentPath: string;
61
+ size?: Size;
62
+ /**
63
+ * Per-tier visual mode. Each key in this map sets the mode at and
64
+ * above that breakpoint. The active mode at any viewport is the
65
+ * mode for the highest matching tier.
66
+ *
67
+ * When omitted, defaults to
68
+ * `{ base: 'stepped', '2xs': 'drawer', xs: 'icon', [collapsedAt]: 'expanded' }`
69
+ * so the prop is opt-in for consumers happy with the standard
70
+ * phone→tablet→desktop progression.
71
+ */
72
+ modes?: ModesMap;
73
+ /**
74
+ * Legacy shorthand: which tier the strip expands to icon + label
75
+ * at when `modes` isn't supplied. Equivalent to overriding only
76
+ * the `expanded` slot in the default `modes` map. Ignored when
77
+ * `modes` is set explicitly. Prefer passing `modes` directly.
78
+ */
79
+ collapsedAt?: Exclude<Breakpoint, "base" | "4xs" | "3xs" | "2xs" | "xs">;
80
+ /** Pixel width of the strip in `icon` mode. */
81
+ collapsedWidth?: number;
82
+ /** Pixel width of the strip in `expanded` mode and the slide-in drawer when open. */
83
+ expandedWidth?: number;
84
+ /**
85
+ * Drawer open state when the active mode is `drawer` or `stepped`.
86
+ * Bind from the consumer to drive it from your own header button
87
+ * (set `hamburger={false}` to suppress the built-in trigger).
88
+ * Ignored in `icon` and `expanded` modes (strip is always in-layout).
89
+ */
90
+ open?: boolean;
91
+ /**
92
+ * Step-navigation level inside the `stepped`-mode drawer.
93
+ * `0` = top-level. `1` = inside a parent item's children list.
94
+ * Bind from the consumer so a header button can read it (e.g.
95
+ * to flip X → ← icon) and write it (set to `0` to navigate back).
96
+ */
97
+ stepLevel?: number;
98
+ /**
99
+ * Render the built-in fixed-position hamburger button when the
100
+ * active mode is `drawer` or `stepped`. Set to `false` if the
101
+ * consumer renders its own trigger (typically inside a header)
102
+ * and binds to `open`.
103
+ */
104
+ hamburger?: boolean;
105
+ class?: string;
106
+ };
107
+ declare const SideBarMenu: import("svelte").Component<$$ComponentProps, {}, "open" | "stepLevel">;
108
+ type SideBarMenu = ReturnType<typeof SideBarMenu>;
109
+ export default SideBarMenu;
@@ -0,0 +1,2 @@
1
+ export { default } from "./SideBarMenu.svelte";
2
+ export type { SideBarMenuItem, SideBarMenuBadge, SideBarMenuBadgeVariant, } from "./SideBarMenu.svelte";
@@ -0,0 +1 @@
1
+ export { default } from "./SideBarMenu.svelte";
@@ -0,0 +1,384 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from "svelte";
3
+ import type { HTMLAttributes } from "svelte/elements";
4
+ import type { Size } from "../../types/sizes.js";
5
+ import type { Variant } from "../../types/variants.js";
6
+ import { variantToken } from "../../types/variants.js";
7
+ import { responsiveClasses, resolveScalar, type Responsive } from "../../types/responsive.js";
8
+ import { tooltip } from "../../positioning/tooltip.js";
9
+ import Badge from "../Badge/Badge.svelte";
10
+
11
+ /** Display mode. `expanded` (default) shows icon + label. `collapsed` hides the label so each tab
12
+ * reads as icon-only, with a tooltip below carrying the label — useful on narrow viewports or
13
+ * for long tab lists. Accepts a scalar or `Responsive` map so the same strip can collapse only
14
+ * below a breakpoint (`{ base: 'collapsed', sm: 'expanded' }`). Tabs without an icon fall back
15
+ * to their label so nothing disappears — the mode is a hint, not a hard hide. */
16
+ type TabMode = "expanded" | "collapsed";
17
+
18
+ export interface TabDefinition {
19
+ label: string;
20
+ /** Present → tab renders as `<a href>`; browser handles nav. Absent → `<button type="button">`. */
21
+ href?: string;
22
+ icon?: Snippet;
23
+ badge?: string | number;
24
+ disabled?: boolean;
25
+ position?: "left" | "right";
26
+ }
27
+
28
+ interface Props extends Omit<HTMLAttributes<HTMLUListElement>, "onselect"> {
29
+ tabs: TabDefinition[];
30
+ activeTabIndex?: number;
31
+ size?: Responsive<Size>;
32
+ variant?: Responsive<Variant>;
33
+ /** See TabMode. Default `'expanded'`. */
34
+ mode?: Responsive<TabMode>;
35
+ /** false (default): the base line is drawn — each tab plus the filler/spacer cells contribute a
36
+ * bottom border in the shared border colour, and the selected tab drops its bottom border so the
37
+ * line breaks under it (attached to the content below). true: no base line — tabs float (they
38
+ * keep their top/left/right borders). */
39
+ borderless?: boolean;
40
+ /** Spacer inserted as a bordered cell BETWEEN adjacent tabs, in `rem` (default `0` = tabs flush).
41
+ * Not CSS `gap` — a real cell, so the base line stays continuous through it. Excludes the grow
42
+ * filler and the leading/trailing cells (those are `pad`). */
43
+ gap?: number;
44
+ /** Leading + trailing bordered spacer cells (before the first tab, after the last), in `rem`, AND
45
+ * a `padding-top` of the same size on the strip (default `0`). Cells (not padding) horizontally
46
+ * so the base line runs through them; real padding at the top (no line there). */
47
+ pad?: number;
48
+ onselect?: (index: number) => void;
49
+ right?: Snippet;
50
+ /** Optional aria-label for the strip when it renders as a navigation landmark
51
+ * (i.e. any tab has `href`). Ignored in tablist mode. */
52
+ "aria-label"?: string;
53
+ class?: string;
54
+ }
55
+
56
+ let {
57
+ tabs,
58
+ activeTabIndex = $bindable(0),
59
+ size = "md",
60
+ variant = "neutral",
61
+ mode = "expanded",
62
+ borderless = false,
63
+ gap = 0,
64
+ pad = 0,
65
+ onselect,
66
+ right,
67
+ "aria-label": ariaLabel,
68
+ class: cls = "",
69
+ style: styleProp = "",
70
+ ...restProps
71
+ }: Props = $props();
72
+
73
+ // Navigation mode: manual keyboard activation is required by APG when tabs are anchors, so arrow
74
+ // keys never fire spurious navigations. Button-only strips get automatic (arrow moves selection).
75
+ const anyHref = $derived(tabs.some((t) => t.href != null));
76
+ const semanticMode = $derived<"tablist" | "navigation">(anyHref ? "navigation" : "tablist");
77
+
78
+ // Per-Size class maps → responsiveClasses handles scalar OR { base, xs, … }.
79
+ // Heights track --ui-height-*; horizontal padding + text + inline-icon svg scale with them.
80
+ const heightMap: Record<Size, string> = {
81
+ sm: "h-[var(--ui-height-sm)]",
82
+ md: "h-[var(--ui-height)]",
83
+ lg: "h-[var(--ui-height-lg)]",
84
+ };
85
+ const padMap: Record<Size, string> = { sm: "px-3", md: "px-4", lg: "px-5" };
86
+ const textMap: Record<Size, string> = { sm: "text-xs", md: "text-sm", lg: "text-base" };
87
+ const svgMap: Record<Size, string> = {
88
+ sm: "[&_svg]:w-3.5 [&_svg]:h-3.5",
89
+ md: "[&_svg]:w-4 [&_svg]:h-4",
90
+ lg: "[&_svg]:w-5 [&_svg]:h-5",
91
+ };
92
+ const gapMap: Record<Size, string> = { sm: "gap-1", md: "gap-1.5", lg: "gap-2" };
93
+
94
+ const heightClass = $derived(responsiveClasses(size, heightMap));
95
+ const padClass = $derived(responsiveClasses(size, padMap));
96
+ const textClass = $derived(responsiveClasses(size, textMap));
97
+ const svgClass = $derived(responsiveClasses(size, svgMap));
98
+ const gapClass = $derived(responsiveClasses(size, gapMap));
99
+
100
+ // Mode → label visibility. `expanded` shows the label span inline; `collapsed` hides it. Tabs
101
+ // without an icon show the label regardless (falling back gracefully — nothing disappears).
102
+ // Responsive shape supported: `{ base: 'collapsed', sm: 'expanded' }` collapses on mobile only.
103
+ const modeMap: Record<TabMode, string> = { expanded: "inline-block", collapsed: "hidden" };
104
+ const labelClass = $derived(responsiveClasses(mode, modeMap));
105
+
106
+ // Variant tinting uses ONLY the shared `--ui-accent` mechanism — the exact idiom every other themed
107
+ // component uses (DataTable/List/Menu/CardBody), no per-component custom properties. We point
108
+ // `--ui-accent` at the variant's colour token via inline `style` (only for a real accent — neutral/ghost
109
+ // leave it at the default `--ui-color-text`), and the `.ui-accent` class on the <ul> re-mixes the shared
110
+ // interactive tints (`--ui-color-surface`/`--ui-color-active`) from it. Every colour further down is a
111
+ // STATIC Tailwind class reading `var(--ui-accent)` (or another shared token) — never runtime-interpolated
112
+ // (Tailwind only emits classes found literally in source). `variant` may be a Responsive map; tinting
113
+ // uses its base/scalar value (one inline custom prop can't be per-breakpoint) — same scalar as the badge.
114
+ const stripVariant = $derived(resolveScalar(variant, "neutral"));
115
+ const accentVar = $derived(
116
+ variantToken[stripVariant] === "--ui-color-text"
117
+ ? ""
118
+ : `--ui-accent: var(${variantToken[stripVariant]});`,
119
+ );
120
+
121
+ // Split into left / right clusters, preserving array order within each.
122
+ const leftTabs = $derived(
123
+ tabs.map((t, i) => ({ t, i })).filter(({ t }) => t.position !== "right"),
124
+ );
125
+ const rightTabs = $derived(
126
+ tabs.map((t, i) => ({ t, i })).filter(({ t }) => t.position === "right"),
127
+ );
128
+
129
+ // Roving focus for the tablist keyboard pattern — one tab in the natural tab order at a time.
130
+ // `focusedIndex` shadows activeTabIndex for the automatic mode (where they change together) but
131
+ // diverges for the manual mode (where arrows only move focus).
132
+ let focusedIndex = $state<number | null>(null);
133
+ const rovingIndex = $derived(
134
+ focusedIndex ?? (activeTabIndex >= 0 && activeTabIndex < tabs.length ? activeTabIndex : 0),
135
+ );
136
+
137
+ function activate(i: number) {
138
+ if (tabs[i]?.disabled) return;
139
+ activeTabIndex = i;
140
+ onselect?.(i);
141
+ }
142
+
143
+ // Walk to the next / previous non-disabled tab, wrapping.
144
+ function nextEnabled(from: number, step: 1 | -1): number {
145
+ const n = tabs.length;
146
+ if (n === 0) return from;
147
+ let i = from;
148
+ for (let guard = 0; guard < n; guard++) {
149
+ i = (i + step + n) % n;
150
+ if (!tabs[i]?.disabled) return i;
151
+ }
152
+ return from;
153
+ }
154
+ function firstEnabled(): number {
155
+ const i = tabs.findIndex((t) => !t.disabled);
156
+ return i === -1 ? 0 : i;
157
+ }
158
+ function lastEnabled(): number {
159
+ for (let i = tabs.length - 1; i >= 0; i--) if (!tabs[i]?.disabled) return i;
160
+ return 0;
161
+ }
162
+
163
+ function handleKeydown(e: KeyboardEvent, i: number) {
164
+ let target: number | null = null;
165
+ if (e.key === "ArrowRight" || e.key === "ArrowDown") target = nextEnabled(i, 1);
166
+ else if (e.key === "ArrowLeft" || e.key === "ArrowUp") target = nextEnabled(i, -1);
167
+ else if (e.key === "Home") target = firstEnabled();
168
+ else if (e.key === "End") target = lastEnabled();
169
+ else if ((e.key === "Enter" || e.key === " ") && semanticMode === "tablist") {
170
+ e.preventDefault();
171
+ activate(i);
172
+ return;
173
+ } else return;
174
+ e.preventDefault();
175
+ focusedIndex = target;
176
+ // Automatic activation: tablist mode moves selection with focus. Navigation mode moves focus only.
177
+ if (semanticMode === "tablist") activate(target);
178
+ // Move DOM focus to the newly-focused tab.
179
+ queueMicrotask(() => {
180
+ stripEl?.querySelectorAll<HTMLElement>("[data-tab-el]")?.[target!]?.focus();
181
+ });
182
+ }
183
+
184
+ let stripEl: HTMLUListElement | undefined = $state();
185
+
186
+ // The strip has NO border of its own. The base line is composed from the BOTTOM border of every child
187
+ // (each tab, the grow filler, the right cluster) — they sit on a shared baseline (items-stretch), so
188
+ // their bottom borders join into one continuous 1px line, in the shared border colour (accent at
189
+ // --ui-tint-border). `borderless` makes that bottom border transparent → no line. The selected tab
190
+ // always keeps its bottom transparent, breaking the line so it opens into the content below.
191
+ // NB: these border classes are written out in full — Tailwind only emits arbitrary-property CSS for
192
+ // class strings it finds *literally* in source, so we can't factor the repeated color-mix into a
193
+ // variable and interpolate it (that's the same trap the old per-component var hid). List does the same.
194
+ const lineBottom = $derived(
195
+ borderless
196
+ ? "border-b [border-bottom-color:transparent]"
197
+ : "border-b [border-bottom-color:color-mix(in_srgb,var(--ui-accent)_var(--ui-tint-border),transparent)]",
198
+ );
199
+
200
+ // Tab side borders (top/left/right). `borderless` makes these transparent too, so the whole tab has
201
+ // no visible edge — floating tabs, not open-bottom boxes. The 1px border box is kept (transparent)
202
+ // either way so heights never shift (same principle as Button/Card `borderless`). The BOTTOM border
203
+ // is handled separately by `lineBottom` (unselected/disabled) or the selected state. Written out in
204
+ // full for the same Tailwind-literal reason as `lineBottom` above.
205
+ const sideBorders = $derived(
206
+ borderless
207
+ ? "border [border-top-color:transparent] [border-left-color:transparent] [border-right-color:transparent]"
208
+ : "border [border-top-color:color-mix(in_srgb,var(--ui-accent)_var(--ui-tint-border),transparent)] [border-left-color:color-mix(in_srgb,var(--ui-accent)_var(--ui-tint-border),transparent)] [border-right-color:color-mix(in_srgb,var(--ui-accent)_var(--ui-tint-border),transparent)]",
209
+ );
210
+
211
+ // Tab element base: shared by anchors and buttons. Draws top/left/right borders (the shared border
212
+ // colour) with rounded top corners; the BOTTOM border colour is state-driven (below) — it joins the
213
+ // base line (unselected/disabled) or breaks it (selected). All colours read `var(--ui-accent)`.
214
+ const tabBase = $derived(
215
+ [
216
+ "inline-flex items-center justify-center whitespace-nowrap select-none no-underline cursor-pointer",
217
+ "rounded-t-[var(--ui-border-radius)]",
218
+ `${sideBorders} transition-colors`,
219
+ "focus-visible:[outline:2px_solid_color-mix(in_srgb,var(--ui-color-text)_70%,transparent)] focus-visible:[outline-offset:-2px]",
220
+ heightClass,
221
+ padClass,
222
+ textClass,
223
+ svgClass,
224
+ gapClass,
225
+ ].join(" "),
226
+ );
227
+
228
+ // Selected: transparent fill, so the tab shows whatever surface the strip sits on (page background,
229
+ // a Card/panel, an inverse surface) and reads as one continuous surface with the content below it —
230
+ // the folder-tab effect on ANY background, not just the page. Label + icon take `var(--ui-accent)`
231
+ // (the variant colour, or plain text for neutral/ghost — icons inherit via currentColor), the
232
+ // clearest signal of the strip's variant. Bottom border transparent — the base line breaks under it.
233
+ const selectedStyleClass =
234
+ "[background-color:transparent] [color:var(--ui-accent)] [border-bottom-color:transparent]";
235
+
236
+ // `ghost` is the "transparent, hover-tint only" grey variant (per `types/variants.ts`): unlike
237
+ // `neutral`, its inactive tabs have NO resting fill — they sit on the bare surface and only tint on
238
+ // hover. Every other variant keeps the recessed `--ui-color-surface` section band. (Both greys share
239
+ // `--ui-accent = --ui-color-text`, so the resting fill is the ONLY thing that distinguishes them.)
240
+ const restingFill = $derived(
241
+ stripVariant === "ghost"
242
+ ? "[background-color:transparent]"
243
+ : "[background-color:var(--ui-color-surface)]",
244
+ );
245
+
246
+ // Unselected: the resting fill (recessed band, or transparent for ghost); text/icon are the muted
247
+ // accent (`var(--ui-accent)` at 60%). Hover deepens the fill to --ui-color-active (12%, same for both
248
+ // greys) and lifts text/icon to the full accent. Bottom border joins the base line (via `lineBottom`).
249
+ const unselectedStyleClass = $derived(
250
+ `${restingFill} [color:color-mix(in_srgb,var(--ui-accent)_60%,transparent)]` +
251
+ " hover:[background-color:var(--ui-color-active)] hover:[color:var(--ui-accent)]",
252
+ );
253
+
254
+ // Disabled: the resting fill, faded + inert; bottom border joins the base line (via `lineBottom`).
255
+ const disabledStyleClass = $derived(
256
+ `opacity-50 cursor-not-allowed pointer-events-none ${restingFill}`,
257
+ );
258
+ </script>
259
+
260
+ {#snippet spacer(rem: number)}
261
+ <!-- A bordered spacer cell (gap between tabs, or pad at the ends). Carries the base line via
262
+ `lineBottom`; fixed width so it never collapses under overflow. Nothing to interact with. -->
263
+ <li aria-hidden="true" class={`shrink-0 ${lineBottom}`} style={`width:${rem}rem`}></li>
264
+ {/snippet}
265
+
266
+ {#snippet tabItem({ t, i }: { t: TabDefinition; i: number })}
267
+ {@const isActive = i === activeTabIndex}
268
+ {@const isRoving = i === rovingIndex}
269
+ {@const stateClass = t.disabled
270
+ ? `${disabledStyleClass} ${lineBottom}`
271
+ : isActive
272
+ ? selectedStyleClass
273
+ : `${unselectedStyleClass} ${lineBottom}`}
274
+ {@const tabTabIndex = t.disabled ? -1 : semanticMode === "tablist" ? (isRoving ? 0 : -1) : 0}
275
+ {@const tabAriaCurrent =
276
+ semanticMode === "navigation" && isActive ? ("page" as const) : undefined}
277
+ {@const badgeVariant = stripVariant}
278
+ <!-- Each tab is a list item. In tablist mode the <li> is presentational so the tablist/tab roles
279
+ on the <ul>/<button> aren't disturbed; the real <button>/<a> carries the styling + the base-line
280
+ bottom border. Using `contents` so the button/anchor stays the flex item (layout unchanged). -->
281
+ <li role={semanticMode === "tablist" ? "presentation" : undefined} class="contents">
282
+ {#if t.href}
283
+ <a
284
+ href={t.href}
285
+ data-tab-el
286
+ title={t.label}
287
+ use:tooltip={{ side: "bottom" }}
288
+ aria-disabled={t.disabled ? true : undefined}
289
+ aria-current={tabAriaCurrent}
290
+ tabindex={tabTabIndex}
291
+ onclick={(e) => {
292
+ if (t.disabled) {
293
+ e.preventDefault();
294
+ return;
295
+ }
296
+ activate(i);
297
+ }}
298
+ onkeydown={(e) => handleKeydown(e, i)}
299
+ onfocus={() => {
300
+ focusedIndex = i;
301
+ }}
302
+ class={`${tabBase} ${stateClass}`}
303
+ >
304
+ {#if t.icon}{@render t.icon()}{/if}
305
+ <span class={`truncate ${t.icon ? labelClass : "inline-block"}`}>{t.label}</span>
306
+ {#if t.badge != null}
307
+ <Badge variant={badgeVariant} {size} borderless>{t.badge}</Badge>
308
+ {/if}
309
+ </a>
310
+ {:else}
311
+ <button
312
+ type="button"
313
+ data-tab-el
314
+ title={t.label}
315
+ use:tooltip={{ side: "bottom" }}
316
+ role={semanticMode === "tablist" ? "tab" : undefined}
317
+ aria-selected={semanticMode === "tablist" ? isActive : undefined}
318
+ aria-disabled={t.disabled ? true : undefined}
319
+ tabindex={tabTabIndex}
320
+ onclick={() => activate(i)}
321
+ onkeydown={(e) => handleKeydown(e, i)}
322
+ onfocus={() => {
323
+ focusedIndex = i;
324
+ }}
325
+ class={`${tabBase} ${stateClass}`}
326
+ disabled={t.disabled}
327
+ >
328
+ {#if t.icon}{@render t.icon()}{/if}
329
+ <span class={`truncate ${t.icon ? labelClass : "inline-block"}`}>{t.label}</span>
330
+ {#if t.badge != null}
331
+ <Badge variant={badgeVariant} {size} borderless>{t.badge}</Badge>
332
+ {/if}
333
+ </button>
334
+ {/if}
335
+ </li>
336
+ {/snippet}
337
+
338
+ <ul
339
+ bind:this={stripEl}
340
+ role={semanticMode === "tablist" ? "tablist" : "navigation"}
341
+ aria-orientation={semanticMode === "tablist" ? "horizontal" : undefined}
342
+ aria-label={semanticMode === "navigation" ? ariaLabel : undefined}
343
+ class={`ui-accent flex items-stretch overflow-x-auto list-none m-0 p-0 ${cls}`}
344
+ style={`${accentVar} padding-top:${pad}rem; ${styleProp ?? ""}`}
345
+ {...restProps}
346
+ >
347
+ <!-- Leading pad cell (before the first left tab). -->
348
+ {#if pad > 0 && leftTabs.length > 0}{@render spacer(pad)}{/if}
349
+
350
+ {#each leftTabs as { t, i }, idx (i)}
351
+ {@render tabItem({ t, i })}
352
+ <!-- gap cell BETWEEN adjacent left tabs (never after the last). -->
353
+ {#if gap > 0 && idx < leftTabs.length - 1}{@render spacer(gap)}{/if}
354
+ {/each}
355
+
356
+ <!-- Trailing pad cell when the left cluster is the last thing (no right tabs). -->
357
+ {#if pad > 0 && leftTabs.length > 0 && rightTabs.length === 0}{@render spacer(pad)}{/if}
358
+
359
+ <!-- Grow filler (the "+1"): an empty list item that stretches to eat the free space and carry the
360
+ base line across it. It sits BETWEEN the left and right clusters, so it lands last (only left
361
+ tabs), in the middle (both clusters), or first (only right tabs) — pushing the right cluster to
362
+ the edge in every case. Nothing to interact with, so it's aria-hidden. -->
363
+ <li aria-hidden="true" class={`flex-1 ${lineBottom}`}></li>
364
+
365
+ {#if right}
366
+ <li
367
+ role="presentation"
368
+ class={`inline-flex items-center ${padClass} ${textClass} ${lineBottom} [color:color-mix(in_srgb,var(--ui-color-text)_60%,transparent)]`}
369
+ >
370
+ {@render right()}
371
+ </li>
372
+ {/if}
373
+
374
+ <!-- Leading pad cell for a right-only strip (no left cluster). -->
375
+ {#if pad > 0 && rightTabs.length > 0 && leftTabs.length === 0}{@render spacer(pad)}{/if}
376
+
377
+ {#each rightTabs as { t, i }, idx (i)}
378
+ {@render tabItem({ t, i })}
379
+ {#if gap > 0 && idx < rightTabs.length - 1}{@render spacer(gap)}{/if}
380
+ {/each}
381
+
382
+ <!-- Trailing pad cell (after the last right tab). -->
383
+ {#if pad > 0 && rightTabs.length > 0}{@render spacer(pad)}{/if}
384
+ </ul>
@@ -0,0 +1,50 @@
1
+ import type { Snippet } from "svelte";
2
+ import type { HTMLAttributes } from "svelte/elements";
3
+ import type { Size } from "../../types/sizes.js";
4
+ import type { Variant } from "../../types/variants.js";
5
+ import { type Responsive } from "../../types/responsive.js";
6
+ /** Display mode. `expanded` (default) shows icon + label. `collapsed` hides the label so each tab
7
+ * reads as icon-only, with a tooltip below carrying the label — useful on narrow viewports or
8
+ * for long tab lists. Accepts a scalar or `Responsive` map so the same strip can collapse only
9
+ * below a breakpoint (`{ base: 'collapsed', sm: 'expanded' }`). Tabs without an icon fall back
10
+ * to their label so nothing disappears — the mode is a hint, not a hard hide. */
11
+ type TabMode = "expanded" | "collapsed";
12
+ export interface TabDefinition {
13
+ label: string;
14
+ /** Present → tab renders as `<a href>`; browser handles nav. Absent → `<button type="button">`. */
15
+ href?: string;
16
+ icon?: Snippet;
17
+ badge?: string | number;
18
+ disabled?: boolean;
19
+ position?: "left" | "right";
20
+ }
21
+ interface Props extends Omit<HTMLAttributes<HTMLUListElement>, "onselect"> {
22
+ tabs: TabDefinition[];
23
+ activeTabIndex?: number;
24
+ size?: Responsive<Size>;
25
+ variant?: Responsive<Variant>;
26
+ /** See TabMode. Default `'expanded'`. */
27
+ mode?: Responsive<TabMode>;
28
+ /** false (default): the base line is drawn — each tab plus the filler/spacer cells contribute a
29
+ * bottom border in the shared border colour, and the selected tab drops its bottom border so the
30
+ * line breaks under it (attached to the content below). true: no base line — tabs float (they
31
+ * keep their top/left/right borders). */
32
+ borderless?: boolean;
33
+ /** Spacer inserted as a bordered cell BETWEEN adjacent tabs, in `rem` (default `0` = tabs flush).
34
+ * Not CSS `gap` — a real cell, so the base line stays continuous through it. Excludes the grow
35
+ * filler and the leading/trailing cells (those are `pad`). */
36
+ gap?: number;
37
+ /** Leading + trailing bordered spacer cells (before the first tab, after the last), in `rem`, AND
38
+ * a `padding-top` of the same size on the strip (default `0`). Cells (not padding) horizontally
39
+ * so the base line runs through them; real padding at the top (no line there). */
40
+ pad?: number;
41
+ onselect?: (index: number) => void;
42
+ right?: Snippet;
43
+ /** Optional aria-label for the strip when it renders as a navigation landmark
44
+ * (i.e. any tab has `href`). Ignored in tablist mode. */
45
+ "aria-label"?: string;
46
+ class?: string;
47
+ }
48
+ declare const TabStrip: import("svelte").Component<Props, {}, "activeTabIndex">;
49
+ type TabStrip = ReturnType<typeof TabStrip>;
50
+ export default TabStrip;
@@ -0,0 +1,3 @@
1
+ export { default } from "./TabStrip.svelte";
2
+ export { default as TabStrip } from "./TabStrip.svelte";
3
+ export type { TabDefinition } from "./TabStrip.svelte";
@@ -0,0 +1,2 @@
1
+ export { default } from "./TabStrip.svelte";
2
+ export { default as TabStrip } from "./TabStrip.svelte";