@eifi1/ui-kit 0.9.0 → 0.11.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 (243) hide show
  1. package/README.md +37 -23
  2. package/dist/components/alert-banner.d.ts +57 -5
  3. package/dist/components/alert-banner.js +63 -17
  4. package/dist/components/alert-banner.js.map +1 -1
  5. package/dist/components/amount-input.d.ts +6 -0
  6. package/dist/components/breadcrumbs.d.ts +60 -0
  7. package/dist/components/breadcrumbs.js +88 -0
  8. package/dist/components/breadcrumbs.js.map +1 -0
  9. package/dist/components/bulk-action-bar.d.ts +112 -0
  10. package/dist/components/bulk-action-bar.js +196 -0
  11. package/dist/components/bulk-action-bar.js.map +1 -0
  12. package/dist/components/button-group.d.ts +58 -3
  13. package/dist/components/button-group.js +56 -5
  14. package/dist/components/button-group.js.map +1 -1
  15. package/dist/components/calculator.d.ts +6 -0
  16. package/dist/components/calendar-heatmap.d.ts +124 -0
  17. package/dist/components/calendar-heatmap.js +295 -0
  18. package/dist/components/calendar-heatmap.js.map +1 -0
  19. package/dist/components/chip.d.ts +31 -5
  20. package/dist/components/chip.js +85 -5
  21. package/dist/components/chip.js.map +1 -1
  22. package/dist/components/choice-card.d.ts +34 -2
  23. package/dist/components/choice-card.js +49 -0
  24. package/dist/components/choice-card.js.map +1 -1
  25. package/dist/components/copy-button.d.ts +14 -4
  26. package/dist/components/copy-button.js +17 -4
  27. package/dist/components/copy-button.js.map +1 -1
  28. package/dist/components/data-table.js +2 -0
  29. package/dist/components/data-table.js.map +1 -1
  30. package/dist/components/date-picker.d.ts +22 -2
  31. package/dist/components/date-picker.js +11 -3
  32. package/dist/components/date-picker.js.map +1 -1
  33. package/dist/components/description-list.d.ts +43 -6
  34. package/dist/components/description-list.js +91 -11
  35. package/dist/components/description-list.js.map +1 -1
  36. package/dist/components/dialog-frame.d.ts +7 -0
  37. package/dist/components/dialog-frame.js.map +1 -1
  38. package/dist/components/disclosure.d.ts +57 -2
  39. package/dist/components/disclosure.js +24 -12
  40. package/dist/components/disclosure.js.map +1 -1
  41. package/dist/components/field.d.ts +43 -0
  42. package/dist/components/field.js +52 -0
  43. package/dist/components/field.js.map +1 -0
  44. package/dist/components/file-button.d.ts +1 -0
  45. package/dist/components/file-dropzone.d.ts +4 -4
  46. package/dist/components/file-dropzone.js +2 -1
  47. package/dist/components/file-dropzone.js.map +1 -1
  48. package/dist/components/floating-panel.d.ts +147 -4
  49. package/dist/components/floating-panel.js +188 -28
  50. package/dist/components/floating-panel.js.map +1 -1
  51. package/dist/components/full-bleed-dialog.d.ts +23 -4
  52. package/dist/components/full-bleed-dialog.js +11 -2
  53. package/dist/components/full-bleed-dialog.js.map +1 -1
  54. package/dist/components/list.d.ts +187 -0
  55. package/dist/components/list.js +221 -0
  56. package/dist/components/list.js.map +1 -0
  57. package/dist/components/menu-item.d.ts +109 -0
  58. package/dist/components/menu-item.js +88 -0
  59. package/dist/components/menu-item.js.map +1 -0
  60. package/dist/components/mini-calendar.d.ts +70 -5
  61. package/dist/components/mini-calendar.js +166 -62
  62. package/dist/components/mini-calendar.js.map +1 -1
  63. package/dist/components/modal.d.ts +23 -1
  64. package/dist/components/modal.js +36 -10
  65. package/dist/components/modal.js.map +1 -1
  66. package/dist/components/nav-pills.d.ts +66 -0
  67. package/dist/components/nav-pills.js +65 -0
  68. package/dist/components/nav-pills.js.map +1 -0
  69. package/dist/components/number-field.d.ts +6 -0
  70. package/dist/components/number-input.d.ts +6 -0
  71. package/dist/components/numpad-sheet.d.ts +6 -0
  72. package/dist/components/page-contents.js +1 -2
  73. package/dist/components/page-contents.js.map +1 -1
  74. package/dist/components/page-header.d.ts +37 -0
  75. package/dist/components/page-header.js +36 -0
  76. package/dist/components/page-header.js.map +1 -0
  77. package/dist/components/progress-bar.d.ts +31 -2
  78. package/dist/components/progress-bar.js +73 -7
  79. package/dist/components/progress-bar.js.map +1 -1
  80. package/dist/components/series-chart-labels.d.ts +3 -0
  81. package/dist/components/series-chart-labels.js +2 -1
  82. package/dist/components/series-chart-labels.js.map +1 -1
  83. package/dist/components/series-chart.d.ts +32 -0
  84. package/dist/components/series-chart.js +141 -4
  85. package/dist/components/series-chart.js.map +1 -1
  86. package/dist/components/settings-fields.d.ts +1 -0
  87. package/dist/components/status-dot.d.ts +53 -0
  88. package/dist/components/status-dot.js +57 -0
  89. package/dist/components/status-dot.js.map +1 -0
  90. package/dist/components/table.d.ts +78 -13
  91. package/dist/components/table.js +84 -11
  92. package/dist/components/table.js.map +1 -1
  93. package/dist/components/text.d.ts +70 -0
  94. package/dist/components/text.js +24 -0
  95. package/dist/components/text.js.map +1 -0
  96. package/dist/components/time-input.d.ts +1 -0
  97. package/dist/components/toast.d.ts +167 -0
  98. package/dist/components/toast.js +226 -0
  99. package/dist/components/toast.js.map +1 -0
  100. package/dist/components/toggle-group.d.ts +44 -1
  101. package/dist/components/toggle-group.js +73 -4
  102. package/dist/components/toggle-group.js.map +1 -1
  103. package/dist/components/tooltip.d.ts +46 -15
  104. package/dist/components/tooltip.js +87 -47
  105. package/dist/components/tooltip.js.map +1 -1
  106. package/dist/components/ui.d.ts +158 -7
  107. package/dist/components/ui.js +191 -10
  108. package/dist/components/ui.js.map +1 -1
  109. package/dist/components/user-avatar.d.ts +25 -3
  110. package/dist/components/user-avatar.js +32 -3
  111. package/dist/components/user-avatar.js.map +1 -1
  112. package/dist/hooks/use-close-transition.d.ts +7 -1
  113. package/dist/hooks/use-close-transition.js +5 -2
  114. package/dist/hooks/use-close-transition.js.map +1 -1
  115. package/dist/hooks/use-copy-to-clipboard.js +1 -1
  116. package/dist/hooks/use-copy-to-clipboard.js.map +1 -1
  117. package/dist/hooks/use-file-drop.d.ts +1 -0
  118. package/dist/i18n/defaults.d.ts +6 -0
  119. package/dist/i18n/defaults.js +11 -1
  120. package/dist/i18n/defaults.js.map +1 -1
  121. package/dist/i18n/kit-labels.d.ts +11 -0
  122. package/dist/i18n/kit-labels.js.map +1 -1
  123. package/dist/i18n/locales/de-CH-informal.d.ts +6 -0
  124. package/dist/i18n/locales/de-CH.d.ts +6 -0
  125. package/dist/i18n/locales/de-informal.d.ts +6 -0
  126. package/dist/i18n/locales/de.d.ts +6 -0
  127. package/dist/i18n/locales/de.js +30 -2
  128. package/dist/i18n/locales/de.js.map +1 -1
  129. package/dist/i18n/locales/es.d.ts +6 -0
  130. package/dist/i18n/locales/es.js +30 -2
  131. package/dist/i18n/locales/es.js.map +1 -1
  132. package/dist/i18n/locales/fr.d.ts +6 -0
  133. package/dist/i18n/locales/fr.js +30 -2
  134. package/dist/i18n/locales/fr.js.map +1 -1
  135. package/dist/i18n/locales/hu.d.ts +6 -0
  136. package/dist/i18n/locales/hu.js +30 -2
  137. package/dist/i18n/locales/hu.js.map +1 -1
  138. package/dist/i18n/locales/it.d.ts +6 -0
  139. package/dist/i18n/locales/it.js +30 -2
  140. package/dist/i18n/locales/it.js.map +1 -1
  141. package/dist/i18n/locales/zh.d.ts +6 -0
  142. package/dist/i18n/locales/zh.js +30 -2
  143. package/dist/i18n/locales/zh.js.map +1 -1
  144. package/dist/index.d.ts +27 -15
  145. package/dist/index.js +15 -0
  146. package/dist/index.js.map +1 -1
  147. package/dist/lib/clipping.d.ts +25 -0
  148. package/dist/lib/clipping.js +17 -0
  149. package/dist/lib/clipping.js.map +1 -0
  150. package/dist/lib/dates.d.ts +17 -1
  151. package/dist/lib/dates.js +19 -0
  152. package/dist/lib/dates.js.map +1 -1
  153. package/dist/rhf/form.d.ts +1 -0
  154. package/dist/rhf/use-rhf-wizard-step.d.ts +22 -0
  155. package/dist/rhf/use-rhf-wizard-step.js +38 -0
  156. package/dist/rhf/use-rhf-wizard-step.js.map +1 -0
  157. package/dist/rhf.d.ts +2 -0
  158. package/dist/rhf.js +1 -0
  159. package/dist/rhf.js.map +1 -1
  160. package/dist/search/command-palette.d.ts +19 -2
  161. package/dist/search/command-palette.js +16 -5
  162. package/dist/search/command-palette.js.map +1 -1
  163. package/dist/search/global-search.d.ts +26 -1
  164. package/dist/search/global-search.js +29 -13
  165. package/dist/search/global-search.js.map +1 -1
  166. package/dist/search.d.ts +1 -1
  167. package/dist/shell/topbar-action-menu.d.ts +73 -3
  168. package/dist/shell/topbar-action-menu.js +100 -27
  169. package/dist/shell/topbar-action-menu.js.map +1 -1
  170. package/dist/shell.d.ts +2 -1
  171. package/dist/wizard/stepper-nav.d.ts +1 -0
  172. package/dist/wizard/stepper-nav.js +1 -1
  173. package/dist/wizard/stepper-nav.js.map +1 -1
  174. package/dist/wizard/types.d.ts +2 -2
  175. package/dist/wizard/types.js.map +1 -1
  176. package/dist/wizard/use-wizard.js +1 -1
  177. package/dist/wizard/use-wizard.js.map +1 -1
  178. package/dist/wizard/wizard-context.d.ts +7 -1
  179. package/dist/wizard/wizard-context.js +4 -0
  180. package/dist/wizard/wizard-context.js.map +1 -1
  181. package/dist/wizard/wizard-summary.js +3 -3
  182. package/dist/wizard/wizard-summary.js.map +1 -1
  183. package/dist/wizard.d.ts +2 -1
  184. package/package.json +16 -12
  185. package/src/components/alert-banner.tsx +149 -21
  186. package/src/components/breadcrumbs.tsx +168 -0
  187. package/src/components/bulk-action-bar.tsx +362 -0
  188. package/src/components/button-group.tsx +124 -4
  189. package/src/components/calendar-heatmap.tsx +504 -0
  190. package/src/components/chip.tsx +128 -7
  191. package/src/components/choice-card.tsx +97 -1
  192. package/src/components/copy-button.tsx +29 -7
  193. package/src/components/data-table.tsx +7 -0
  194. package/src/components/date-picker.tsx +49 -1
  195. package/src/components/description-list.tsx +168 -15
  196. package/src/components/dialog-frame.tsx +7 -0
  197. package/src/components/disclosure.tsx +93 -21
  198. package/src/components/field.tsx +137 -0
  199. package/src/components/file-dropzone.tsx +9 -10
  200. package/src/components/floating-panel.tsx +366 -15
  201. package/src/components/full-bleed-dialog.tsx +42 -5
  202. package/src/components/list.tsx +443 -0
  203. package/src/components/menu-item.tsx +235 -0
  204. package/src/components/mini-calendar.tsx +245 -54
  205. package/src/components/modal.tsx +80 -17
  206. package/src/components/nav-pills.tsx +145 -0
  207. package/src/components/page-contents.tsx +4 -4
  208. package/src/components/page-header.tsx +68 -0
  209. package/src/components/progress-bar.tsx +129 -11
  210. package/src/components/series-chart-labels.ts +4 -0
  211. package/src/components/series-chart.tsx +274 -3
  212. package/src/components/status-dot.tsx +107 -0
  213. package/src/components/table.tsx +176 -17
  214. package/src/components/text.tsx +97 -0
  215. package/src/components/toast.tsx +441 -0
  216. package/src/components/toggle-group.tsx +128 -4
  217. package/src/components/tooltip.tsx +200 -100
  218. package/src/components/ui.tsx +381 -14
  219. package/src/components/user-avatar.tsx +57 -3
  220. package/src/hooks/use-close-transition.ts +14 -5
  221. package/src/hooks/use-copy-to-clipboard.ts +1 -1
  222. package/src/i18n/defaults.ts +10 -0
  223. package/src/i18n/kit-labels.tsx +10 -0
  224. package/src/i18n/locales/de.ts +29 -0
  225. package/src/i18n/locales/es.ts +29 -0
  226. package/src/i18n/locales/fr.ts +29 -0
  227. package/src/i18n/locales/hu.ts +29 -0
  228. package/src/i18n/locales/it.ts +29 -0
  229. package/src/i18n/locales/zh.ts +29 -0
  230. package/src/index.ts +31 -0
  231. package/src/lib/clipping.ts +34 -0
  232. package/src/lib/dates.ts +37 -0
  233. package/src/rhf/use-rhf-wizard-step.ts +113 -0
  234. package/src/rhf.ts +2 -0
  235. package/src/search/command-palette.tsx +42 -5
  236. package/src/search/global-search.tsx +59 -18
  237. package/src/shell/topbar-action-menu.tsx +239 -49
  238. package/src/wizard/stepper-nav.tsx +2 -1
  239. package/src/wizard/types.ts +2 -2
  240. package/src/wizard/use-wizard.ts +3 -3
  241. package/src/wizard/wizard-context.tsx +9 -0
  242. package/src/wizard/wizard-summary.tsx +14 -10
  243. package/tokens.css +97 -0
@@ -0,0 +1,145 @@
1
+ import type { ComponentPropsWithoutRef, ComponentType, MouseEvent, ReactElement, ReactNode } from "react";
2
+ import { cn } from "../lib/cn";
3
+
4
+ export interface NavPillItem<T extends string> {
5
+ value: T;
6
+ label: ReactNode;
7
+ /** Where the pill leads. With one it is a link (see `renderLink`); without, a button
8
+ * that calls `onSelect`. */
9
+ href?: string;
10
+ /** A Lucide icon (or any component taking a `className`), before the label. */
11
+ icon?: ComponentType<{ className?: string; "aria-hidden"?: boolean }>;
12
+ /** Buttons only — a link cannot be disabled. */
13
+ disabled?: boolean;
14
+ }
15
+
16
+ /** What {@link NavPillsProps.renderLink} is handed. Spread it onto your router's link
17
+ * — `({ href, ...p }) => <Link to={href} {...p} />`. */
18
+ export interface NavPillLinkProps {
19
+ href: string;
20
+ className: string;
21
+ children: ReactNode;
22
+ "aria-current": NavPillsCurrent | undefined;
23
+ onClick: (e: MouseEvent<HTMLAnchorElement>) => void;
24
+ }
25
+
26
+ /** The `aria-current` token the current pill carries. */
27
+ export type NavPillsCurrent = "page" | "step" | "location" | "true";
28
+
29
+ export interface NavPillsProps<T extends string> extends Omit<ComponentPropsWithoutRef<"nav">, "children" | "onSelect"> {
30
+ items: NavPillItem<T>[];
31
+ /** The pill that is current. `null` or a value not in `items`: none is. */
32
+ current: T | null;
33
+ /** Called with the pill's value on a click — of a button, and of a link too (before
34
+ * the router follows it), for a caller that keeps the choice in state as well. */
35
+ onSelect?: (value: T) => void;
36
+ /** Your router's link. Default `<a>`. The API `Breadcrumbs`, `Chip` and `ListItem` share. */
37
+ renderLink?: (props: NavPillLinkProps) => ReactElement;
38
+ /** What `aria-current` says on the current pill. Default `page` where the items
39
+ * are links and `true` where they are buttons (keksdose's swipe-surface picker
40
+ * switches a panel on the same page, which is not a page). */
41
+ currentType?: NavPillsCurrent;
42
+ /** `sm`: 12px pills — the compact size of a {@link ToggleGroup}. Default `md`. */
43
+ size?: "sm" | "md";
44
+ /** Render a `<nav>` landmark (default) or a plain `<div role="group">` for a picker
45
+ * that is not the page's navigation. Name either with `aria-label`. */
46
+ landmark?: boolean;
47
+ /** Classes for every pill. */
48
+ itemClassName?: string;
49
+ }
50
+
51
+ // The pill is a ToggleGroup segment's (`rounded`, `px-3 py-1.5`, the inverse fill for
52
+ // the current one), so a strip of these beside a segmented control reads as the same
53
+ // family. Unlike the segments, a pill never truncates: the strip WRAPS instead.
54
+ const PILL =
55
+ "inline-flex items-center gap-1.5 whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--border-strong)] [&_svg]:size-4 [&_svg]:shrink-0";
56
+ const PILL_SM = "gap-1 px-2 py-1 text-xs [&_svg]:size-3.5";
57
+ const PILL_CURRENT = "bg-[var(--bg-inverse)] text-[var(--text-inverse)]";
58
+ const PILL_IDLE = "text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]";
59
+ const PILL_DISABLED = "cursor-not-allowed opacity-50 hover:bg-transparent hover:text-[var(--text-secondary)]";
60
+
61
+ /**
62
+ * A set of links or buttons that WRAPS onto as many rows as it needs, the current one
63
+ * filled and marked `aria-current`.
64
+ *
65
+ * keksdose's swipe-settings card (swipe-settings-card:152) hand-rolled this: four
66
+ * surface names that do not fit a 406px phone, and a sideways scroll would hide the
67
+ * ones that do not fit behind a gesture nothing announces (live #332: *"Have the tabs
68
+ * in more rows, rather than scrolling sideways"*). It is not {@link Tabs} with `wrap`
69
+ * because its items are not tabs: a `role="tab"` promises a tabpanel, a roving tab
70
+ * stop and arrow keys, and a set of links to pages (or of buttons that switch the rest
71
+ * of a card) promises none of that. So every pill is its own tab stop, the set is a
72
+ * list (a screen reader hears "list, 4 items"), and "current" is `aria-current` — the
73
+ * attribute a link to where you already are carries.
74
+ */
75
+ export function NavPills<T extends string>({
76
+ items,
77
+ current,
78
+ onSelect,
79
+ renderLink,
80
+ currentType,
81
+ size = "md",
82
+ landmark = true,
83
+ itemClassName,
84
+ className,
85
+ ...rest
86
+ }: NavPillsProps<T>) {
87
+ const Root = landmark ? "nav" : "div";
88
+ return (
89
+ <Root {...rest} role={landmark ? undefined : "group"} className={cn("min-w-0", className)}>
90
+ <ul className="m-0 flex list-none flex-wrap gap-1 p-0">
91
+ {items.map((item) => {
92
+ const isCurrent = item.value === current;
93
+ const Icon = item.icon;
94
+ const pillClass = cn(
95
+ PILL,
96
+ size === "sm" && PILL_SM,
97
+ isCurrent ? PILL_CURRENT : PILL_IDLE,
98
+ item.disabled && item.href === undefined && PILL_DISABLED,
99
+ itemClassName,
100
+ );
101
+ const children = (
102
+ <>
103
+ {Icon && <Icon aria-hidden />}
104
+ {item.label}
105
+ </>
106
+ );
107
+ let pill: ReactNode;
108
+ if (item.href !== undefined) {
109
+ const props: NavPillLinkProps = {
110
+ href: item.href,
111
+ className: pillClass,
112
+ children,
113
+ "aria-current": isCurrent ? (currentType ?? "page") : undefined,
114
+ onClick: () => onSelect?.(item.value),
115
+ };
116
+ pill = renderLink ? (
117
+ renderLink(props)
118
+ ) : (
119
+ <a href={props.href} className={props.className} aria-current={props["aria-current"]} onClick={props.onClick}>
120
+ {props.children}
121
+ </a>
122
+ );
123
+ } else {
124
+ pill = (
125
+ <button
126
+ type="button"
127
+ disabled={item.disabled}
128
+ aria-current={isCurrent ? (currentType ?? "true") : undefined}
129
+ onClick={() => onSelect?.(item.value)}
130
+ className={pillClass}
131
+ >
132
+ {children}
133
+ </button>
134
+ );
135
+ }
136
+ return (
137
+ <li key={item.value} className="flex">
138
+ {pill}
139
+ </li>
140
+ );
141
+ })}
142
+ </ul>
143
+ </Root>
144
+ );
145
+ }
@@ -176,14 +176,14 @@ export function PageContents({
176
176
  <li key={item.id} data-entry={item.id}>
177
177
  <a
178
178
  href={hrefFor(item.id)}
179
- // One line per entry in the rail; past 22rem the ellipsis takes over and
180
- // the full text is on hover. The disclosure has the page's width and wraps.
181
- title={variant === "rail" && typeof item.label === "string" ? item.label : undefined}
179
+ // Entries WRAP in the rail rather than truncate: a cut-off heading needed a
180
+ // native `title` to be read at all, which a keyboard never sees and which
181
+ // broke keksdose's one-tooltip rule (dev#523).
182
182
  aria-current={current ? "location" : undefined}
183
183
  onClick={() => setClicked(item.id)}
184
184
  className={cn(
185
185
  "-ms-px block border-s-2 py-1 pe-1 text-[13px] leading-snug transition-colors duration-150",
186
- variant === "rail" && "truncate",
186
+ variant === "rail" && "[overflow-wrap:anywhere]",
187
187
  item.level === 2 ? "ps-6" : "ps-3",
188
188
  current
189
189
  ? "border-[var(--brand)] font-medium text-[var(--brand)]"
@@ -0,0 +1,68 @@
1
+ import type { ComponentPropsWithoutRef, ElementType, ReactNode } from "react";
2
+ import { cn } from "../lib/cn";
3
+ import { SECTION_LABEL_CLASS } from "./text";
4
+
5
+ /** `md` (default) is kastlan's `text-2xl` page title from `sm` up, a step smaller on a
6
+ * phone; `sm` is lenkbank's `text-lg` title on a dense tool page (projects-page.tsx). */
7
+ export type PageHeaderSize = "sm" | "md";
8
+
9
+ const TITLE: Record<PageHeaderSize, string> = {
10
+ sm: "text-lg font-semibold",
11
+ md: "text-xl font-semibold tracking-tight sm:text-2xl",
12
+ };
13
+
14
+ export interface PageHeaderProps extends Omit<ComponentPropsWithoutRef<"div">, "title"> {
15
+ title: ReactNode;
16
+ /** A sentence under the title. */
17
+ description?: ReactNode;
18
+ /** Buttons for the page — "New", "Export". At the title's end from `sm` up; under it,
19
+ * wrapping, on a phone. */
20
+ actions?: ReactNode;
21
+ /** A `Breadcrumbs` trail, drawn above everything else. */
22
+ breadcrumbs?: ReactNode;
23
+ /** A small uppercase line above the title — the section or the kind of record
24
+ * ("Building", "Settings"). The section-label type. */
25
+ eyebrow?: ReactNode;
26
+ /** The title's element. Default `h1`: a page has one, and this is it. `h2` for a
27
+ * header inside a page that already has its `h1` (a tab's own header). */
28
+ as?: "h1" | "h2" | "h3";
29
+ size?: PageHeaderSize;
30
+ }
31
+
32
+ /**
33
+ * A page's head: optional breadcrumbs, an eyebrow, the title, a description, and the
34
+ * page's actions.
35
+ *
36
+ * kastlan's (shared/components/display/page-header.tsx) is the model — title and
37
+ * description on one side, actions on the other, stacked on a phone — and this adds
38
+ * what its pages kept writing around it: a trail above, an eyebrow, a `ReactNode`
39
+ * title (a status chip beside the name), and actions that WRAP when there are more
40
+ * than fit a phone's width, instead of running off it. No outer margin: the page's
41
+ * own rhythm (`space-y-*`, a grid gap) places it.
42
+ */
43
+ export function PageHeader({
44
+ title,
45
+ description,
46
+ actions,
47
+ breadcrumbs,
48
+ eyebrow,
49
+ as = "h1",
50
+ size = "md",
51
+ className,
52
+ ...rest
53
+ }: PageHeaderProps) {
54
+ const Heading = as as ElementType;
55
+ return (
56
+ <div {...rest} className={cn("flex min-w-0 flex-col gap-2", className)}>
57
+ {breadcrumbs}
58
+ <div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
59
+ <div className="min-w-0 flex-1">
60
+ {eyebrow != null && <p className={cn(SECTION_LABEL_CLASS.xs, "mb-1")}>{eyebrow}</p>}
61
+ <Heading className={cn("break-words text-[var(--text-primary)]", TITLE[size])}>{title}</Heading>
62
+ {description != null && <p className="mt-1 text-sm text-[var(--text-muted)]">{description}</p>}
63
+ </div>
64
+ {actions != null && <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div>}
65
+ </div>
66
+ </div>
67
+ );
68
+ }
@@ -41,6 +41,49 @@ const FILL: Record<ProgressBarTone, string> = {
41
41
 
42
42
  const TRACK_HEIGHT: Record<ProgressBarSize, string> = { sm: "h-1", slim: "h-1.5", md: "h-2", lg: "h-3" };
43
43
 
44
+ // A segment with no tone of its own takes the next categorical chart colour, so a
45
+ // stacked bar of unnamed parts is still a set of tellable parts rather than one fill.
46
+ const SERIES_FILL = [
47
+ "bg-[var(--chart-1)]",
48
+ "bg-[var(--chart-2)]",
49
+ "bg-[var(--chart-3)]",
50
+ "bg-[var(--chart-4)]",
51
+ "bg-[var(--chart-5)]",
52
+ "bg-[var(--chart-6)]",
53
+ "bg-[var(--chart-7)]",
54
+ "bg-[var(--chart-8)]",
55
+ "bg-[var(--chart-9)]",
56
+ ];
57
+
58
+ /** Each segment's fill classes: its own classes, else its tone, else (with no `color`)
59
+ * the next chart colour. "Next" counts the UNCOLOURED segments only — a segment with
60
+ * its own `className`/`tone`/`color` does not use up `--chart-1`, so an "Other" in grey
61
+ * ahead of two plain parts leaves them `--chart-1` and `--chart-2`. */
62
+ function segmentFills(segments: ProgressBarSegment[]): Array<string | undefined> {
63
+ let next = 0;
64
+ return segments.map((seg) => {
65
+ if (seg.className) return seg.className;
66
+ if (seg.tone) return FILL[seg.tone];
67
+ return seg.color ? undefined : SERIES_FILL[next++ % SERIES_FILL.length];
68
+ });
69
+ }
70
+
71
+ /** One part of a stacked bar. See {@link ProgressBarProps.segments}. */
72
+ export interface ProgressBarSegment {
73
+ /** Its size, on the bar's own `min`–`max` scale. */
74
+ value: number;
75
+ /** A kit tone. Left out (and no `color`/`className`), the next `--chart-N` colour. */
76
+ tone?: ProgressBarTone;
77
+ /** Any CSS colour (`var(--token)`), for a palette the tones do not cover. Wins over `tone`. */
78
+ color?: string;
79
+ /** Classes for the segment's fill — keksdose's `STEP_CLASS` ramp. Wins over `tone`. */
80
+ className?: string;
81
+ /** The part's name: said in the bar's `aria-valuetext` and shown in the legend. */
82
+ label?: ReactNode;
83
+ /** A stable React key, when `label` is not a string. */
84
+ key?: string;
85
+ }
86
+
44
87
  export interface ProgressBarProps extends Omit<ComponentPropsWithoutRef<"div">, "children" | "role"> {
45
88
  /** Where it stands. Leave it undefined for an INDETERMINATE bar — work is under way
46
89
  * and nobody knows how much is left (an import that reports no count). */
@@ -69,6 +112,20 @@ export interface ProgressBarProps extends Omit<ComponentPropsWithoutRef<"div">,
69
112
  tone?: ProgressBarTone;
70
113
  size?: ProgressBarSize;
71
114
  locale?: string;
115
+ /**
116
+ * Draw a METER OF PARTS: one bar, several fills side by side in order, a 2px gap
117
+ * between them — keksdose's discretionary-spending bar (cut-card:133), a whole
118
+ * split into the steps of a scale. Forces `variant="meter"`: parts of a whole are
119
+ * never "busy". `aria-valuenow` is the sum; `aria-valuetext` names every part
120
+ * ("Rent: 40%, Food: 25%"), each part's value through `formatValue` and joined in the
121
+ * kit's locale — the words keksdose's hand-drawn bar had to hide from a reader
122
+ * altogether, leaving the legend as the only way in. The sum is clamped to `max`,
123
+ * and parts past it are cut off at the track's end.
124
+ */
125
+ segments?: ProgressBarSegment[];
126
+ /** With `segments`: a list under the bar naming each part with its swatch and value
127
+ * — the table view of the bar, in text tokens rather than the segment colour. */
128
+ legend?: boolean;
72
129
  }
73
130
 
74
131
  /**
@@ -99,6 +156,8 @@ export function ProgressBar({
99
156
  tone = "brand",
100
157
  size = "md",
101
158
  locale,
159
+ segments,
160
+ legend = false,
102
161
  className,
103
162
  "aria-label": ariaLabel,
104
163
  "aria-labelledby": ariaLabelledBy,
@@ -108,19 +167,37 @@ export function ProgressBar({
108
167
  const kitLocale = useKitLocale(locale);
109
168
  const labelId = useId();
110
169
 
111
- const meter = variant === "meter";
112
- const current = value === undefined || !Number.isFinite(value) ? (meter ? min : undefined) : value;
113
- const indeterminate = current === undefined;
170
+ const stacked = segments !== undefined;
171
+ const meter = stacked || variant === "meter";
114
172
  const span = max - min;
173
+ const partValue = (v: number) => (Number.isFinite(v) ? Math.max(0, v - min) : 0);
174
+ const fills = stacked ? segmentFills(segments) : [];
175
+ const partTotal = stacked ? min + segments.reduce((sum, seg) => sum + partValue(seg.value), 0) : undefined;
176
+ const format = (v: number) =>
177
+ formatValue
178
+ ? formatValue(v, max, min)
179
+ : new Intl.NumberFormat(kitLocale, { style: "percent", maximumFractionDigits: 0 }).format(
180
+ span <= 0 ? 0 : (Math.min(max, Math.max(min, v)) - min) / span,
181
+ );
182
+ const partTexts = stacked
183
+ ? segments.map((seg) => {
184
+ const text = format(min + partValue(seg.value));
185
+ return typeof seg.label === "string" || typeof seg.label === "number"
186
+ ? common.fieldValue(String(seg.label), text)
187
+ : text;
188
+ })
189
+ : [];
190
+ const current =
191
+ partTotal ?? (value === undefined || !Number.isFinite(value) ? (meter ? min : undefined) : value);
192
+ const indeterminate = current === undefined;
115
193
  const clamped = indeterminate ? undefined : Math.min(max, Math.max(min, current));
116
194
  const fraction = clamped === undefined || span <= 0 ? 0 : (clamped - min) / span;
117
195
 
118
- const valueText =
119
- clamped === undefined
120
- ? undefined
121
- : formatValue
122
- ? formatValue(clamped, max, min)
123
- : new Intl.NumberFormat(kitLocale, { style: "percent", maximumFractionDigits: 0 }).format(fraction);
196
+ const valueText = clamped === undefined ? undefined : format(clamped);
197
+ const ariaValueText =
198
+ stacked && partTexts.length > 0
199
+ ? new Intl.ListFormat(kitLocale, { style: "short", type: "unit" }).format(partTexts)
200
+ : valueText;
124
201
 
125
202
  const labelledBy = ariaLabelledBy ?? (label != null ? labelId : undefined);
126
203
  const name = labelledBy ? undefined : (ariaLabel ?? (indeterminate ? common.loading : undefined));
@@ -151,13 +228,32 @@ export function ProgressBar({
151
228
  aria-valuemin={indeterminate ? undefined : min}
152
229
  aria-valuemax={indeterminate ? undefined : max}
153
230
  aria-valuenow={clamped}
154
- aria-valuetext={valueText}
231
+ aria-valuetext={ariaValueText}
155
232
  aria-busy={!meter && indeterminate ? true : undefined}
156
233
  // `relative` + `overflow-hidden`: the indeterminate segment is positioned in
157
234
  // here and must not paint past the rounded ends.
158
235
  className={cn("relative w-full overflow-hidden rounded-full bg-[var(--bg-active)]", TRACK_HEIGHT[size])}
159
236
  >
160
- {indeterminate ? (
237
+ {stacked ? (
238
+ // The parts in a flex row with a 2px gap, so two neighbouring steps of one
239
+ // hue are parted by the track rather than by their difference in lightness.
240
+ <div className="flex h-full w-full gap-0.5">
241
+ {segments.map((seg, i) => (
242
+ <div
243
+ key={seg.key ?? i}
244
+ data-part="segment"
245
+ className={cn(
246
+ "h-full shrink-0 rounded-full transition-[width] duration-300 motion-reduce:transition-none",
247
+ fills[i],
248
+ )}
249
+ style={{
250
+ width: `${span <= 0 ? 0 : (partValue(seg.value) / span) * 100}%`,
251
+ backgroundColor: seg.className ? undefined : seg.color,
252
+ }}
253
+ />
254
+ ))}
255
+ </div>
256
+ ) : indeterminate ? (
161
257
  <div
162
258
  data-part="fill"
163
259
  className={cn(
@@ -182,6 +278,28 @@ export function ProgressBar({
182
278
  />
183
279
  )}
184
280
  </div>
281
+ {stacked && legend && segments.length > 0 && (
282
+ <ul className="mt-2 space-y-1 text-sm" data-part="legend">
283
+ {segments.map((seg, i) => (
284
+ <li key={seg.key ?? i} className="flex items-center gap-2">
285
+ <span
286
+ aria-hidden
287
+ className={cn(
288
+ "size-2.5 shrink-0 rounded-sm",
289
+ fills[i],
290
+ )}
291
+ style={{ backgroundColor: seg.className ? undefined : seg.color }}
292
+ />
293
+ {seg.label != null && (
294
+ <span className="min-w-0 flex-1 truncate text-[var(--text-secondary)]">{seg.label}</span>
295
+ )}
296
+ <span className="ms-auto shrink-0 text-xs tabular-nums text-[var(--text-muted)]">
297
+ {format(min + partValue(seg.value))}
298
+ </span>
299
+ </li>
300
+ ))}
301
+ </ul>
302
+ )}
185
303
  </div>
186
304
  );
187
305
  }
@@ -15,6 +15,9 @@ export interface SeriesChartLabels {
15
15
  empty: string;
16
16
  /** Accessible name of a `ToggleLegend` — a group of switches, one per series. */
17
17
  legend: string;
18
+ /** Accessible name of the group of keyboard stops a clickable chart (`onPointClick`)
19
+ * lays over its bars or periods — one tab stop, arrow keys between them. */
20
+ points: string;
18
21
  }
19
22
 
20
23
  export const DEFAULT_SERIES_CHART_LABELS: SeriesChartLabels = {
@@ -23,4 +26,5 @@ export const DEFAULT_SERIES_CHART_LABELS: SeriesChartLabels = {
23
26
  "Drag to zoom: a roughly square selection zooms both axes, a long thin one only its own. Double-click to reset.",
24
27
  empty: "No data",
25
28
  legend: "Series",
29
+ points: "Chart values",
26
30
  };