@eifi1/ui-kit 0.10.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 (162) hide show
  1. package/README.md +20 -16
  2. package/dist/components/alert-banner.d.ts +26 -1
  3. package/dist/components/alert-banner.js +10 -7
  4. package/dist/components/alert-banner.js.map +1 -1
  5. package/dist/components/amount-input.d.ts +2 -0
  6. package/dist/components/bulk-action-bar.d.ts +51 -5
  7. package/dist/components/bulk-action-bar.js +88 -41
  8. package/dist/components/bulk-action-bar.js.map +1 -1
  9. package/dist/components/button-group.d.ts +58 -3
  10. package/dist/components/button-group.js +56 -5
  11. package/dist/components/button-group.js.map +1 -1
  12. package/dist/components/calculator.d.ts +2 -0
  13. package/dist/components/calendar-heatmap.d.ts +124 -0
  14. package/dist/components/calendar-heatmap.js +295 -0
  15. package/dist/components/calendar-heatmap.js.map +1 -0
  16. package/dist/components/choice-card.d.ts +34 -2
  17. package/dist/components/choice-card.js +49 -0
  18. package/dist/components/choice-card.js.map +1 -1
  19. package/dist/components/copy-button.d.ts +1 -0
  20. package/dist/components/data-table.js +2 -0
  21. package/dist/components/data-table.js.map +1 -1
  22. package/dist/components/field.d.ts +43 -0
  23. package/dist/components/field.js +52 -0
  24. package/dist/components/field.js.map +1 -0
  25. package/dist/components/file-button.d.ts +1 -0
  26. package/dist/components/file-dropzone.d.ts +1 -0
  27. package/dist/components/floating-panel.d.ts +147 -4
  28. package/dist/components/floating-panel.js +188 -28
  29. package/dist/components/floating-panel.js.map +1 -1
  30. package/dist/components/list.d.ts +14 -1
  31. package/dist/components/list.js +7 -1
  32. package/dist/components/list.js.map +1 -1
  33. package/dist/components/menu-item.d.ts +11 -1
  34. package/dist/components/menu-item.js +6 -1
  35. package/dist/components/menu-item.js.map +1 -1
  36. package/dist/components/mini-calendar.d.ts +70 -5
  37. package/dist/components/mini-calendar.js +166 -62
  38. package/dist/components/mini-calendar.js.map +1 -1
  39. package/dist/components/nav-pills.d.ts +66 -0
  40. package/dist/components/nav-pills.js +65 -0
  41. package/dist/components/nav-pills.js.map +1 -0
  42. package/dist/components/number-field.d.ts +2 -0
  43. package/dist/components/number-input.d.ts +2 -0
  44. package/dist/components/numpad-sheet.d.ts +2 -0
  45. package/dist/components/progress-bar.d.ts +31 -2
  46. package/dist/components/progress-bar.js +73 -7
  47. package/dist/components/progress-bar.js.map +1 -1
  48. package/dist/components/series-chart.d.ts +16 -0
  49. package/dist/components/series-chart.js +4 -1
  50. package/dist/components/series-chart.js.map +1 -1
  51. package/dist/components/settings-fields.d.ts +1 -0
  52. package/dist/components/status-dot.d.ts +6 -2
  53. package/dist/components/status-dot.js +6 -1
  54. package/dist/components/status-dot.js.map +1 -1
  55. package/dist/components/table.d.ts +40 -8
  56. package/dist/components/table.js +41 -7
  57. package/dist/components/table.js.map +1 -1
  58. package/dist/components/text.d.ts +8 -1
  59. package/dist/components/text.js +1 -0
  60. package/dist/components/text.js.map +1 -1
  61. package/dist/components/time-input.d.ts +1 -0
  62. package/dist/components/toggle-group.d.ts +15 -0
  63. package/dist/components/toggle-group.js +23 -3
  64. package/dist/components/toggle-group.js.map +1 -1
  65. package/dist/components/tooltip.d.ts +11 -3
  66. package/dist/components/tooltip.js +2 -0
  67. package/dist/components/tooltip.js.map +1 -1
  68. package/dist/components/ui.d.ts +55 -4
  69. package/dist/components/ui.js +84 -7
  70. package/dist/components/ui.js.map +1 -1
  71. package/dist/components/user-avatar.d.ts +25 -3
  72. package/dist/components/user-avatar.js +32 -3
  73. package/dist/components/user-avatar.js.map +1 -1
  74. package/dist/hooks/use-file-drop.d.ts +1 -0
  75. package/dist/i18n/defaults.d.ts +2 -0
  76. package/dist/i18n/defaults.js +2 -0
  77. package/dist/i18n/defaults.js.map +1 -1
  78. package/dist/i18n/kit-labels.d.ts +3 -0
  79. package/dist/i18n/kit-labels.js.map +1 -1
  80. package/dist/i18n/locales/de-CH-informal.d.ts +2 -0
  81. package/dist/i18n/locales/de-CH.d.ts +2 -0
  82. package/dist/i18n/locales/de-informal.d.ts +2 -0
  83. package/dist/i18n/locales/de.d.ts +2 -0
  84. package/dist/i18n/locales/de.js +9 -1
  85. package/dist/i18n/locales/de.js.map +1 -1
  86. package/dist/i18n/locales/es.d.ts +2 -0
  87. package/dist/i18n/locales/es.js +9 -1
  88. package/dist/i18n/locales/es.js.map +1 -1
  89. package/dist/i18n/locales/fr.d.ts +2 -0
  90. package/dist/i18n/locales/fr.js +9 -1
  91. package/dist/i18n/locales/fr.js.map +1 -1
  92. package/dist/i18n/locales/hu.d.ts +2 -0
  93. package/dist/i18n/locales/hu.js +9 -1
  94. package/dist/i18n/locales/hu.js.map +1 -1
  95. package/dist/i18n/locales/it.d.ts +2 -0
  96. package/dist/i18n/locales/it.js +9 -1
  97. package/dist/i18n/locales/it.js.map +1 -1
  98. package/dist/i18n/locales/zh.d.ts +2 -0
  99. package/dist/i18n/locales/zh.js +9 -1
  100. package/dist/i18n/locales/zh.js.map +1 -1
  101. package/dist/index.d.ts +14 -10
  102. package/dist/index.js +3 -0
  103. package/dist/index.js.map +1 -1
  104. package/dist/lib/clipping.d.ts +21 -5
  105. package/dist/lib/clipping.js +3 -0
  106. package/dist/lib/clipping.js.map +1 -1
  107. package/dist/lib/dates.d.ts +17 -1
  108. package/dist/lib/dates.js +19 -0
  109. package/dist/lib/dates.js.map +1 -1
  110. package/dist/rhf/form.d.ts +1 -0
  111. package/dist/rhf/use-rhf-wizard-step.d.ts +22 -0
  112. package/dist/rhf/use-rhf-wizard-step.js +38 -0
  113. package/dist/rhf/use-rhf-wizard-step.js.map +1 -0
  114. package/dist/rhf.d.ts +2 -0
  115. package/dist/rhf.js +1 -0
  116. package/dist/rhf.js.map +1 -1
  117. package/dist/shell/topbar-action-menu.d.ts +73 -3
  118. package/dist/shell/topbar-action-menu.js +100 -27
  119. package/dist/shell/topbar-action-menu.js.map +1 -1
  120. package/dist/shell.d.ts +2 -1
  121. package/dist/wizard/stepper-nav.d.ts +1 -0
  122. package/dist/wizard/wizard-context.d.ts +7 -1
  123. package/dist/wizard/wizard-context.js +4 -0
  124. package/dist/wizard/wizard-context.js.map +1 -1
  125. package/dist/wizard.d.ts +2 -1
  126. package/package.json +1 -1
  127. package/src/components/alert-banner.tsx +36 -7
  128. package/src/components/bulk-action-bar.tsx +163 -57
  129. package/src/components/button-group.tsx +124 -4
  130. package/src/components/calendar-heatmap.tsx +504 -0
  131. package/src/components/choice-card.tsx +97 -1
  132. package/src/components/data-table.tsx +7 -0
  133. package/src/components/field.tsx +137 -0
  134. package/src/components/floating-panel.tsx +366 -15
  135. package/src/components/list.tsx +28 -2
  136. package/src/components/menu-item.tsx +22 -1
  137. package/src/components/mini-calendar.tsx +245 -54
  138. package/src/components/nav-pills.tsx +145 -0
  139. package/src/components/progress-bar.tsx +129 -11
  140. package/src/components/series-chart.tsx +21 -0
  141. package/src/components/status-dot.tsx +10 -2
  142. package/src/components/table.tsx +85 -11
  143. package/src/components/text.tsx +9 -1
  144. package/src/components/toggle-group.tsx +48 -3
  145. package/src/components/tooltip.tsx +17 -3
  146. package/src/components/ui.tsx +149 -11
  147. package/src/components/user-avatar.tsx +57 -3
  148. package/src/i18n/defaults.ts +2 -0
  149. package/src/i18n/kit-labels.tsx +2 -0
  150. package/src/i18n/locales/de.ts +9 -0
  151. package/src/i18n/locales/es.ts +9 -0
  152. package/src/i18n/locales/fr.ts +9 -0
  153. package/src/i18n/locales/hu.ts +9 -0
  154. package/src/i18n/locales/it.ts +9 -0
  155. package/src/i18n/locales/zh.ts +9 -0
  156. package/src/index.ts +5 -0
  157. package/src/lib/clipping.ts +22 -4
  158. package/src/lib/dates.ts +37 -0
  159. package/src/rhf/use-rhf-wizard-step.ts +113 -0
  160. package/src/rhf.ts +2 -0
  161. package/src/shell/topbar-action-menu.tsx +239 -49
  162. package/src/wizard/wizard-context.tsx +9 -0
@@ -254,12 +254,15 @@ export function Button({
254
254
  const ICON_BUTTON_BASE =
255
255
  "inline-flex items-center justify-center rounded-md transition-colors focus:outline-none focus:ring-2 disabled:opacity-50 disabled:cursor-not-allowed";
256
256
 
257
- export type IconButtonSize = "lg" | "md" | "sm" | "xs" | "2xs";
257
+ export type IconButtonSize = "xl" | "lg" | "md" | "sm" | "xs" | "2xs";
258
258
 
259
259
  // Box and glyph together, so a 24px chip action cannot end up holding a 20px icon
260
260
  // that touches its edges. The two small steps are lenkbank's list-row (28px) and
261
261
  // chip (24px) actions, which it had hand-rolled beside the kit's 32/36px ones.
262
262
  const ICON_BUTTON_SIZES: Record<IconButtonSize, string> = {
263
+ // 48px with a 24px glyph: the one primary action of a phone screen or sheet, a step
264
+ // past the 44px minimum rather than on it.
265
+ xl: "size-12 [&_svg]:size-6",
263
266
  // The 44px touch target (WCAG 2.5.5's size) with the same 20px glyph — keksdose's
264
267
  // bulk-action bars write `size-11` by hand over an `md` button to get it on phones.
265
268
  lg: "size-11 [&_svg]:size-5",
@@ -269,6 +272,18 @@ const ICON_BUTTON_SIZES: Record<IconButtonSize, string> = {
269
272
  "2xs": "size-6 rounded [&_svg]:size-3.5",
270
273
  };
271
274
 
275
+ // `stretch`: the same WIDTH and glyph, and a height taken from the row instead of
276
+ // stated — `size-*` sets a height that `self-stretch` cannot override, so the box is
277
+ // written as a width plus a minimum height (the square it would otherwise be).
278
+ const ICON_BUTTON_STRETCH_SIZES: Record<IconButtonSize, string> = {
279
+ xl: "w-12 min-h-12 self-stretch [&_svg]:size-6",
280
+ lg: "w-11 min-h-11 self-stretch [&_svg]:size-5",
281
+ md: "w-9 min-h-9 self-stretch [&_svg]:size-5",
282
+ sm: "w-8 min-h-8 self-stretch [&_svg]:size-5",
283
+ xs: "w-7 min-h-7 self-stretch rounded [&_svg]:size-4",
284
+ "2xs": "w-6 min-h-6 self-stretch rounded [&_svg]:size-3.5",
285
+ };
286
+
272
287
  // A tone re-colours the glyph without changing what the variant draws around it.
273
288
  // Each coloured tone comes in two resting looks, picked by `quiet` (see the prop):
274
289
  // QUIET is placeholder grey until the pointer or focus arrives, then the tone's
@@ -279,9 +294,14 @@ const ICON_BUTTON_SIZES: Record<IconButtonSize, string> = {
279
294
  // on the row to notice (keksdose's "needs review" flag, its reconcile action at
280
295
  // accounts-page:867), and quiet grey would hide the very thing they point out.
281
296
  // `muted` is quiet by definition and has no toned look; `default` has no tone.
282
- export type IconButtonTone = "default" | "muted" | "danger" | "warning" | "info";
297
+ //
298
+ // `success` is toned at rest too: the green is the news (keksdose's "in sync" chip).
299
+ // `custom` paints from `--icon-button-tone`, which `toneColor` sets — for a glyph whose
300
+ // colour FOLLOWS STATE across tones (keksdose's sync chip: rose, amber, sky, green as
301
+ // the sync moves), where swapping `tone` would do but the colour is one variable.
302
+ export type IconButtonTone = "default" | "muted" | "danger" | "warning" | "info" | "success" | "custom";
283
303
 
284
- type ColouredTone = "danger" | "warning" | "info";
304
+ type ColouredTone = "danger" | "warning" | "info" | "success" | "custom";
285
305
 
286
306
  const ICON_BUTTON_TONES: Record<ColouredTone, { quiet: string; toned: string }> = {
287
307
  danger: {
@@ -299,13 +319,33 @@ const ICON_BUTTON_TONES: Record<ColouredTone, { quiet: string; toned: string }>
299
319
  "text-[var(--text-placeholder)] hover:bg-[var(--info-bg)] hover:text-[var(--info)] focus:ring-[var(--info-border)]",
300
320
  toned: "text-[var(--info)] hover:bg-[var(--info-bg)] focus:ring-[var(--info-border)]",
301
321
  },
322
+ success: {
323
+ quiet:
324
+ "text-[var(--text-placeholder)] hover:bg-[var(--success-bg)] hover:text-[var(--success)] focus:ring-[var(--success-border)]",
325
+ toned: "text-[var(--success)] hover:bg-[var(--success-bg)] focus:ring-[var(--success-border)]",
326
+ },
327
+ // The hover fill and the ring are MIXED from the one colour, since a custom tone
328
+ // brings no `-bg` / `-border` pair of its own. The fallback is the body colour, so a
329
+ // `custom` tone with no colour set is the default look rather than invisible.
330
+ custom: {
331
+ quiet:
332
+ "text-[var(--text-placeholder)] hover:bg-[color-mix(in_srgb,var(--icon-button-tone,var(--text-primary))_12%,transparent)] hover:text-[var(--icon-button-tone,var(--text-primary))] focus:ring-[color-mix(in_srgb,var(--icon-button-tone,var(--text-primary))_40%,transparent)]",
333
+ toned:
334
+ "text-[var(--icon-button-tone,var(--text-primary))] hover:bg-[color-mix(in_srgb,var(--icon-button-tone,var(--text-primary))_12%,transparent)] focus:ring-[color-mix(in_srgb,var(--icon-button-tone,var(--text-primary))_40%,transparent)]",
335
+ },
302
336
  };
303
337
 
304
338
  const ICON_BUTTON_MUTED =
305
339
  "text-[var(--text-placeholder)] hover:bg-[var(--bg-surface-2)] hover:text-[var(--text-primary)]";
306
340
 
307
341
  /** Which tones sit quiet at rest when `quiet` is left out. */
308
- const QUIET_BY_DEFAULT: Record<ColouredTone, boolean> = { danger: true, warning: false, info: false };
342
+ const QUIET_BY_DEFAULT: Record<ColouredTone, boolean> = {
343
+ danger: true,
344
+ warning: false,
345
+ info: false,
346
+ success: false,
347
+ custom: false,
348
+ };
309
349
 
310
350
  function iconButtonToneClass(tone: IconButtonTone, quiet: boolean | undefined): string {
311
351
  if (tone === "default") return "";
@@ -362,11 +402,31 @@ export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>
362
402
  * xs = 28px with a 16px icon (an action in a list row), 2xs = 24px with a 14px
363
403
  * icon (an action on a chip or a tab). */
364
404
  size?: IconButtonSize;
405
+ /**
406
+ * Fill the height of the flex row it stands in, keeping its width — the delete at
407
+ * the end of keksdose's split line (transaction-fields:162), level with the labelled
408
+ * fields beside it rather than floating at half their height. {@link Button}'s
409
+ * `stretch`; no effect outside a flex row. With a `label`, the tooltip's wrapper is
410
+ * what stretches.
411
+ */
412
+ stretch?: boolean;
413
+ /** `round`: a circle instead of the rounded square — a compact status chip in a top
414
+ * bar (keksdose's sync indicator). `overlay` is always round. */
415
+ shape?: "square" | "round";
416
+ /**
417
+ * The colour of `tone="custom"`, as any CSS colour — a token (`var(--warning)`),
418
+ * ideally. Sets `--icon-button-tone` on the button, so passing a different one per
419
+ * state re-colours the glyph, its hover fill and its focus ring together. Passing it
420
+ * implies `tone="custom"`. Or leave it out and set the variable yourself from a class
421
+ * (`className="[--icon-button-tone:var(--success)]"`).
422
+ */
423
+ toneColor?: string;
365
424
  /** Glyph colour over the variant. `muted`: placeholder grey, full text colour on
366
425
  * hover. `danger`: the same grey at rest, `--danger` on hover and focus — for a
367
426
  * remove/delete that repeats down a list. `warning`: amber at rest — a flag that
368
427
  * wants attention. `info`: sky at rest — a notice-worthy but harmless action
369
- * (keksdose's reconcile). Default: the variant's own colours. See `quiet` for
428
+ * (keksdose's reconcile). `success`: green at rest — a good state worth seeing.
429
+ * `custom`: the colour of `toneColor` (see there). Default: the variant's own colours. See `quiet` for
370
430
  * turning a coloured tone's resting look the other way. */
371
431
  tone?: IconButtonTone;
372
432
  /**
@@ -448,35 +508,73 @@ export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>
448
508
  tooltipSide?: TooltipSide;
449
509
  /** Passed to the `label` tooltip's `portal`. Left out, Tooltip decides (see there). */
450
510
  tooltipPortal?: boolean;
511
+ /**
512
+ * Why the action is not available — {@link Button}'s `disabledReason`, on the icon
513
+ * button: keksdose's accounts page cannot hide an account with a balance or delete
514
+ * one with bookings (accounts-page:879/913 — "hide requires zero", "delete blocked"),
515
+ * nor its budgets page delete the only budget (budgets-page:330), and each wrapped a
516
+ * natively disabled IconButton in a Tooltip that swaps its label for the reason — a
517
+ * bubble a keyboard never opens, since `disabled` leaves the tab order, and a reason
518
+ * a screen reader never hears.
519
+ *
520
+ * The same contract as Button's: `aria-disabled` instead of `disabled`, so it stays
521
+ * focusable and hoverable; clicks (and the Enter/Space they stand for) are swallowed
522
+ * — `stopPropagation` still applies, so a locked action in a clickable row does not
523
+ * open the row either; the reason shows in the kit {@link Tooltip} and is attached
524
+ * through `aria-describedby` as a `hidden` copy. The NAME stays `aria-label` /
525
+ * `label` — "Delete", described by "The active budget cannot be deleted" — and the
526
+ * bubble shows the reason in place of the label, since the glyph already says what
527
+ * the button is and the reason is the news. Shown even with `tooltip={false}`: a
528
+ * reason nobody can see is not one. Wins over `disabled`, as on Button.
529
+ */
530
+ disabledReason?: ReactNode;
451
531
  }
452
532
 
453
533
  export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(
454
534
  {
455
535
  variant = "ghost",
456
536
  size = "md",
457
- tone = "default",
537
+ tone: toneProp,
458
538
  quiet,
459
539
  pressed,
460
540
  stopPropagation,
541
+ stretch,
542
+ shape = "square",
543
+ toneColor,
461
544
  label,
462
545
  tooltip = true,
463
546
  tooltipSide,
464
547
  tooltipPortal,
548
+ disabledReason,
465
549
  className,
550
+ style,
466
551
  onClick,
467
552
  onKeyDown,
468
553
  ...rest
469
554
  },
470
555
  ref,
471
556
  ) {
557
+ const tone = toneProp ?? (toneColor !== undefined ? "custom" : "default");
558
+ const reasonId = useId();
559
+ const locked = hasContent(disabledReason);
560
+ const ownDescribedBy = rest["aria-describedby"];
472
561
  const button = (
473
562
  <button
474
563
  ref={ref}
475
564
  {...rest}
565
+ disabled={locked ? undefined : rest.disabled}
566
+ aria-disabled={locked || rest["aria-disabled"]}
567
+ aria-describedby={locked ? (ownDescribedBy ? `${ownDescribedBy} ${reasonId}` : reasonId) : ownDescribedBy}
476
568
  aria-label={rest["aria-label"] ?? label}
477
569
  aria-pressed={pressed ?? rest["aria-pressed"]}
570
+ style={toneColor !== undefined ? { ...style, ["--icon-button-tone" as string]: toneColor } : style}
478
571
  onClick={(e) => {
479
572
  if (stopPropagation) e.stopPropagation();
573
+ // `preventDefault` too: a locked submit must not submit its form.
574
+ if (locked) {
575
+ e.preventDefault();
576
+ return;
577
+ }
480
578
  onClick?.(e);
481
579
  }}
482
580
  onKeyDown={(e) => {
@@ -487,21 +585,43 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
487
585
  }}
488
586
  className={cn(
489
587
  ICON_BUTTON_BASE,
490
- ICON_BUTTON_SIZES[size],
588
+ stretch ? ICON_BUTTON_STRETCH_SIZES[size] : ICON_BUTTON_SIZES[size],
491
589
  // After the size, so the overlay's `rounded-full` beats the small sizes' `rounded`.
492
590
  variant === "overlay" ? ICON_BUTTON_OVERLAY : buttonVariantClasses[variant],
591
+ shape === "round" && "rounded-full",
493
592
  ICON_BUTTON_DISABLED_REST[variant],
494
593
  iconButtonToneClass(tone, quiet),
495
594
  pressed && ICON_BUTTON_PRESSED,
595
+ // The disabled look for the focusable kind of disabled, as on Button.
596
+ locked && "cursor-not-allowed opacity-50",
496
597
  className,
497
598
  )}
498
599
  />
499
600
  );
601
+ if (locked) {
602
+ return (
603
+ // Button's shape: the button and the hidden reason in a FRAGMENT, so Tooltip does
604
+ // not add its bubble to the description as well.
605
+ <Tooltip
606
+ label={disabledReason}
607
+ side={tooltipSide}
608
+ portal={tooltipPortal}
609
+ className={stretch ? "self-stretch" : undefined}
610
+ >
611
+ <>
612
+ {button}
613
+ <span id={reasonId} hidden>
614
+ {disabledReason}
615
+ </span>
616
+ </>
617
+ </Tooltip>
618
+ );
619
+ }
500
620
  if (!tooltip || label === undefined || label === "") return button;
501
621
  return (
502
622
  // A fragment, so Tooltip leaves the button's description alone: the bubble says
503
623
  // exactly what `aria-label` already does.
504
- <Tooltip label={label} side={tooltipSide} portal={tooltipPortal}>
624
+ <Tooltip label={label} side={tooltipSide} portal={tooltipPortal} className={stretch ? "self-stretch" : undefined}>
505
625
  <>{button}</>
506
626
  </Tooltip>
507
627
  );
@@ -1590,6 +1710,16 @@ export interface EmptyStateProps extends Omit<ComponentPropsWithoutRef<"div">, "
1590
1710
  * verdict.
1591
1711
  */
1592
1712
  tone?: "danger" | "success";
1713
+ /**
1714
+ * `inline` only (0.11.0). `sm`: 12px, start-aligned and tight (`py-1`) — the empty
1715
+ * line INSIDE a detail panel, under a panel heading and among `text-xs` rows, where
1716
+ * the default's 14px centred line with `py-4` read as a section of its own.
1717
+ * keksdose's holdings panel (holdings-panel:563), asset-loan panel
1718
+ * (asset-loan-panel:108) and loan-payment panel (loan-payment-panel:191) each wrote
1719
+ * `className="justify-start py-1"` over it and still got body-size text. Default `md`,
1720
+ * the look it had. Ignored by the box.
1721
+ */
1722
+ size?: "sm" | "md";
1593
1723
  }
1594
1724
 
1595
1725
  const EMPTY_STATE_TONE: Record<"danger" | "success", string> = {
@@ -1605,6 +1735,7 @@ export function EmptyState({
1605
1735
  headingAs,
1606
1736
  variant = "box",
1607
1737
  tone,
1738
+ size = "md",
1608
1739
  className,
1609
1740
  ...rest
1610
1741
  }: EmptyStateProps) {
@@ -1612,22 +1743,29 @@ export function EmptyState({
1612
1743
  const toneClass = tone ? EMPTY_STATE_TONE[tone] : undefined;
1613
1744
  const hasHint = hint != null && hint !== false && hint !== "";
1614
1745
  if (variant === "inline") {
1746
+ const sm = size === "sm";
1615
1747
  return (
1616
1748
  <div
1617
1749
  {...rest}
1618
1750
  className={cn(
1619
- "flex flex-wrap items-center justify-center gap-x-2 gap-y-1 py-4 text-center text-sm text-[var(--text-muted)]",
1751
+ "flex flex-wrap items-center text-[var(--text-muted)]",
1752
+ sm
1753
+ ? "justify-start gap-x-1.5 gap-y-0.5 py-1 text-start text-xs"
1754
+ : "justify-center gap-x-2 gap-y-1 py-4 text-center text-sm",
1620
1755
  className,
1621
1756
  )}
1622
1757
  >
1623
1758
  {icon != null && (
1624
- <span aria-hidden className={cn("flex text-[var(--text-placeholder)] [&_svg]:size-4", toneClass)}>
1759
+ <span
1760
+ aria-hidden
1761
+ className={cn("flex text-[var(--text-placeholder)]", sm ? "[&_svg]:size-3.5" : "[&_svg]:size-4", toneClass)}
1762
+ >
1625
1763
  {icon}
1626
1764
  </span>
1627
1765
  )}
1628
1766
  {/* No weight of its own: one muted line is what these sites were, and a
1629
1767
  bold title would turn a quiet "nothing here" into a heading. */}
1630
- <Title className={cn("text-sm", toneClass)}>{title}</Title>
1768
+ <Title className={cn(sm ? "text-xs" : "text-sm", toneClass)}>{title}</Title>
1631
1769
  {hasHint && <span className="text-xs">{hint}</span>}
1632
1770
  {action != null && <span className="flex flex-wrap items-center gap-2">{action}</span>}
1633
1771
  </div>
@@ -1,5 +1,7 @@
1
- import type { ComponentPropsWithoutRef } from "react";
1
+ import type { ComponentPropsWithoutRef, ReactNode } from "react";
2
2
  import { cn } from "../lib/cn";
3
+ import { StatusDot } from "./status-dot";
4
+ import type { StatusDotSize, StatusDotTone } from "./status-dot";
3
5
 
4
6
  /** Initials from a display name (first + last) or, failing that, an email —
5
7
  * e.g. "Marcel Eifert" → "ME", "marcel@x.com" → "MA". App-agnostic.
@@ -25,19 +27,71 @@ const AVATAR_SIZES = {
25
27
  lg: "size-12 text-base",
26
28
  } as const;
27
29
 
30
+ const CHIP =
31
+ "inline-flex shrink-0 items-center justify-center rounded-full bg-[var(--bg-inverse)] font-semibold text-[var(--text-inverse)]";
32
+
33
+ /** The dot grows with the avatar: 8px on `sm`, 10px on `md` (keksdose's), 12px on `lg`. */
34
+ const BADGE_SIZES: Record<keyof typeof AVATAR_SIZES, StatusDotSize> = { sm: "sm", md: "md", lg: "lg" };
35
+
36
+ /** A status dot in the avatar's top corner — an unread count, presence. */
37
+ export interface UserAvatarBadge {
38
+ /** What the dot MEANS, for a screen reader ("3 unread"). Required: a coloured circle
39
+ * says nothing to someone who cannot see it. */
40
+ label: ReactNode;
41
+ /** Default `danger`, the unread dot both apps draw by hand. */
42
+ tone?: StatusDotTone;
43
+ }
44
+
28
45
  /** A `<span>`'s props, minus `children`: the content is the initials this computes
29
46
  * from `name`/`email`, so there is nothing for a caller to put inside. */
30
47
  export interface UserAvatarProps extends Omit<ComponentPropsWithoutRef<"span">, "children"> {
31
48
  name?: string | null;
32
49
  email?: string | null;
33
50
  size?: keyof typeof AVATAR_SIZES;
51
+ /**
52
+ * A status dot in the top-end corner (the top-right in LTR, top-left in RTL), ringed
53
+ * in the surface colour so it reads as its own mark (keksdose account-menu ~385,
54
+ * top-bar :152). Its `label` is read as text where the dot sits — inside a button
55
+ * named by its content it joins that name. In `TopBarActionMenu`'s `trigger` the
56
+ * button is named by the menu's `ariaLabel` followed by this label: "Account menu
57
+ * 3 unread". The initials stay hidden either way, so the person's name is NOT part
58
+ * of it — put it in `ariaLabel` ("Marcel Eifert, account menu") if it should be.
59
+ */
60
+ badge?: UserAvatarBadge | null;
34
61
  }
35
62
 
36
63
  /**
37
64
  * A round initials avatar — the common user chip shared across apps (feedback
38
65
  * #333). Purely presentational; wrap it in a button for the account menu trigger.
39
66
  */
40
- export function UserAvatar({ name, email, size = "md", className, ...rest }: UserAvatarProps) {
67
+ export function UserAvatar({ name, email, size = "md", badge, className, ...rest }: UserAvatarProps) {
68
+ if (badge) {
69
+ // A wrapper, because the chip itself is `aria-hidden` and the badge label must not
70
+ // be. `className` stays on the chip (a size override still sizes the circle); the
71
+ // other attributes go on the wrapper, which is what a caller addresses. `relative`
72
+ // also contains the sr-only text.
73
+ return (
74
+ <span {...rest} className="relative inline-flex shrink-0">
75
+ <span
76
+ aria-hidden
77
+ className={cn(
78
+ CHIP,
79
+ AVATAR_SIZES[size],
80
+ className,
81
+ )}
82
+ >
83
+ {avatarInitials(name, email)}
84
+ </span>
85
+ <StatusDot
86
+ ring
87
+ tone={badge.tone ?? "danger"}
88
+ size={BADGE_SIZES[size]}
89
+ className="absolute -end-0.5 -top-0.5"
90
+ />
91
+ <span className="sr-only">{badge.label}</span>
92
+ </span>
93
+ );
94
+ }
41
95
  return (
42
96
  <span
43
97
  // `aria-hidden` sits BEFORE the spread, unlike the structural attributes on the
@@ -50,7 +104,7 @@ export function UserAvatar({ name, email, size = "md", className, ...rest }: Use
50
104
  aria-hidden
51
105
  {...rest}
52
106
  className={cn(
53
- "inline-flex shrink-0 items-center justify-center rounded-full bg-[var(--bg-inverse)] font-semibold text-[var(--text-inverse)]",
107
+ CHIP,
54
108
  AVATAR_SIZES[size],
55
109
  className,
56
110
  )}
@@ -1,5 +1,6 @@
1
1
  import { DEFAULT_DATA_TABLE_LABELS } from "../components/data-table-labels";
2
2
  import { DEFAULT_MINI_CALENDAR_LABELS } from "../components/mini-calendar";
3
+ import { DEFAULT_CALENDAR_HEATMAP_LABELS } from "../components/calendar-heatmap";
3
4
  import { DEFAULT_MONTH_PICKER_LABELS } from "../components/month-picker";
4
5
  import { DEFAULT_PAGE_CONTENTS_LABELS } from "../components/page-contents";
5
6
  import { DEFAULT_POPOVER_LABELS } from "../components/popover";
@@ -56,6 +57,7 @@ export const DEFAULT_UI_KIT_LABELS: UiKitLabels = {
56
57
  common: DEFAULT_COMMON_LABELS,
57
58
  dataTable: DEFAULT_DATA_TABLE_LABELS,
58
59
  miniCalendar: DEFAULT_MINI_CALENDAR_LABELS,
60
+ calendarHeatmap: DEFAULT_CALENDAR_HEATMAP_LABELS,
59
61
  datePicker: DEFAULT_DATE_PICKER_LABELS,
60
62
  monthPicker: DEFAULT_MONTH_PICKER_LABELS,
61
63
  popover: DEFAULT_POPOVER_LABELS,
@@ -2,6 +2,7 @@ import { createContext, useContext, useMemo } from "react";
2
2
  import type { ReactNode } from "react";
3
3
  import type { DataTableLabels } from "../components/data-table-labels";
4
4
  import type { MiniCalendarLabels, WeekDay } from "../components/mini-calendar";
5
+ import type { CalendarHeatmapLabels } from "../components/calendar-heatmap";
5
6
  import type { PopoverLabels } from "../components/popover";
6
7
  import type { ChipInputLabels } from "../components/chip";
7
8
  import type { FieldSyncLabels } from "../components/field-sync";
@@ -197,6 +198,7 @@ export interface UiKitLabels {
197
198
  common: CommonLabels;
198
199
  dataTable: DataTableLabels;
199
200
  miniCalendar: MiniCalendarLabels;
201
+ calendarHeatmap: CalendarHeatmapLabels;
200
202
  datePicker: DatePickerLabels;
201
203
  monthPicker: MonthPickerLabels;
202
204
  popover: PopoverLabels;
@@ -118,6 +118,14 @@ export function uiKitLabelsDe(numberLocale = "de-DE"): UiKitLabels {
118
118
  rangeSelected: (from, to) =>
119
119
  `Zeitraum ${from} bis ${to} gewählt. Wählen Sie ein Startdatum, um neu zu beginnen.`,
120
120
  },
121
+ calendarHeatmap: {
122
+ grid: "Tageswerte",
123
+ day: (date, value) => `${date}: ${value}`,
124
+ less: "Weniger",
125
+ more: "Mehr",
126
+ truncated: (count) =>
127
+ `Die neuesten Tage werden angezeigt; ${n(count)} ${count === 1 ? "früherer Tag wird" : "frühere Tage werden"} nicht angezeigt.`,
128
+ },
121
129
  datePicker: {
122
130
  apply: "Übernehmen",
123
131
  cancel: "Abbrechen",
@@ -392,6 +400,7 @@ export function uiKitLabelsDe(numberLocale = "de-DE"): UiKitLabels {
392
400
  },
393
401
  floatingPanel: {
394
402
  close: "Schließen",
403
+ badge: (count) => `${n(count)} neu`,
395
404
  },
396
405
  copyButton: {
397
406
  copy: "Kopieren",
@@ -125,6 +125,14 @@ export function uiKitLabelsEs(numberLocale = "es-ES"): UiKitLabels {
125
125
  panel: "Elegir un mes",
126
126
  month: (monthYear) => monthYear,
127
127
  },
128
+ calendarHeatmap: {
129
+ grid: "Valores diarios",
130
+ day: (date, value) => `${date}: ${value}`,
131
+ less: "Menos",
132
+ more: "Más",
133
+ truncated: (count) =>
134
+ `Se muestran los días más recientes; ${count === 1 ? "no se muestra 1 día anterior" : `no se muestran ${n(count)} días anteriores`}.`,
135
+ },
128
136
  datePicker: {
129
137
  apply: "Aplicar",
130
138
  cancel: "Cancelar",
@@ -385,6 +393,7 @@ export function uiKitLabelsEs(numberLocale = "es-ES"): UiKitLabels {
385
393
  },
386
394
  floatingPanel: {
387
395
  close: "Cerrar",
396
+ badge: (count) => `${n(count)} ${plural(count, "nuevo", "nuevos")}`,
388
397
  },
389
398
  copyButton: {
390
399
  copy: "Copiar",
@@ -122,6 +122,14 @@ export function uiKitLabelsFr(numberLocale = "fr-FR"): UiKitLabels {
122
122
  rangeSelected: (from, to) =>
123
123
  `Période du ${from} au ${to} sélectionnée. Choisissez une date de début pour recommencer.`,
124
124
  },
125
+ calendarHeatmap: {
126
+ grid: "Valeurs quotidiennes",
127
+ day: (date, value) => `${date} : ${value}`,
128
+ less: "Moins",
129
+ more: "Plus",
130
+ truncated: (count) =>
131
+ `Affichage des jours les plus récents ; ${n(count)} ${count === 1 ? "jour antérieur n’est pas affiché" : "jours antérieurs ne sont pas affichés"}.`,
132
+ },
125
133
  datePicker: {
126
134
  apply: "Appliquer",
127
135
  cancel: "Annuler",
@@ -398,6 +406,7 @@ export function uiKitLabelsFr(numberLocale = "fr-FR"): UiKitLabels {
398
406
  },
399
407
  floatingPanel: {
400
408
  close: "Fermer",
409
+ badge: (count) => `${n(count)} ${plural(count, "nouveau", "nouveaux")}`,
401
410
  },
402
411
  copyButton: {
403
412
  copy: "Copier",
@@ -121,6 +121,14 @@ export function uiKitLabelsHu(numberLocale = "hu-HU"): UiKitLabels {
121
121
  panel: "Hónap kiválasztása",
122
122
  month: (monthYear) => monthYear,
123
123
  },
124
+ calendarHeatmap: {
125
+ grid: "Napi értékek",
126
+ day: (date, value) => `${date}: ${value}`,
127
+ less: "Kevesebb",
128
+ more: "Több",
129
+ truncated: (count) =>
130
+ `A legutóbbi napok láthatók; ${n(count)} korábbi nap nem látható.`,
131
+ },
124
132
  datePicker: {
125
133
  apply: "Alkalmaz",
126
134
  cancel: "Mégse",
@@ -386,6 +394,7 @@ export function uiKitLabelsHu(numberLocale = "hu-HU"): UiKitLabels {
386
394
  },
387
395
  floatingPanel: {
388
396
  close: "Bezárás",
397
+ badge: (count) => `${n(count)} új`,
389
398
  },
390
399
  copyButton: {
391
400
  copy: "Másolás",
@@ -125,6 +125,14 @@ export function uiKitLabelsIt(numberLocale = "it-IT"): UiKitLabels {
125
125
  panel: "Scegli un mese",
126
126
  month: (monthYear) => monthYear,
127
127
  },
128
+ calendarHeatmap: {
129
+ grid: "Valori giornalieri",
130
+ day: (date, value) => `${date}: ${value}`,
131
+ less: "Meno",
132
+ more: "Più",
133
+ truncated: (count) =>
134
+ `Vengono mostrati i giorni più recenti; ${count === 1 ? "1 giorno precedente non è mostrato" : `${n(count)} giorni precedenti non sono mostrati`}.`,
135
+ },
128
136
  datePicker: {
129
137
  apply: "Applica",
130
138
  cancel: "Annulla",
@@ -387,6 +395,7 @@ export function uiKitLabelsIt(numberLocale = "it-IT"): UiKitLabels {
387
395
  },
388
396
  floatingPanel: {
389
397
  close: "Chiudi",
398
+ badge: (count) => `${n(count)} ${plural(count, "nuovo", "nuovi")}`,
390
399
  },
391
400
  copyButton: {
392
401
  copy: "Copia",
@@ -120,6 +120,14 @@ export function uiKitLabelsZh(numberLocale = "zh-CN"): UiKitLabels {
120
120
  panel: "选择月份",
121
121
  month: (monthYear) => monthYear,
122
122
  },
123
+ calendarHeatmap: {
124
+ grid: "每日数值",
125
+ day: (date, value) => `${date}:${value}`,
126
+ less: "较少",
127
+ more: "较多",
128
+ truncated: (count) =>
129
+ `仅显示最近的日期;另有 ${n(count)} 天未显示。`,
130
+ },
123
131
  datePicker: {
124
132
  apply: "应用",
125
133
  cancel: "取消",
@@ -375,6 +383,7 @@ export function uiKitLabelsZh(numberLocale = "zh-CN"): UiKitLabels {
375
383
  },
376
384
  floatingPanel: {
377
385
  close: "关闭",
386
+ badge: (count) => `${n(count)} 条新内容`,
378
387
  },
379
388
  copyButton: {
380
389
  copy: "复制",
package/src/index.ts CHANGED
@@ -56,6 +56,8 @@ export * from "./theme/palette-store";
56
56
 
57
57
  // ── components ───────────────────────────────────────────────────────────────
58
58
  export * from "./components/ui";
59
+ // The label-ABOVE field group (label, hint, error, required), inside wizards or out.
60
+ export * from "./components/field";
59
61
  export * from "./components/search-field";
60
62
  export * from "./components/dropdown";
61
63
  export * from "./components/popover";
@@ -126,6 +128,8 @@ export * from "./components/series-chart-labels";
126
128
  export * from "./components/account-settings";
127
129
  export * from "./components/alert-banner";
128
130
  export * from "./components/toggle-group";
131
+ // A wrapping set of links/buttons marked `aria-current` — Tabs-like looks, not tabs.
132
+ export * from "./components/nav-pills";
129
133
  // A pill carrying one VALUE — inert, a link, or a toggle, depending on which prop it is
130
134
  // given — and the list field built from it. Distinct from Button on purpose: a row of
131
135
  // buttons reads as "choose an action", a row of chips as "here are the things".
@@ -137,6 +141,7 @@ export * from "./components/full-bleed-dialog";
137
141
  export * from "./components/grouped-picker";
138
142
  export * from "./components/file-dropzone";
139
143
  export * from "./components/mini-calendar";
144
+ export * from "./components/calendar-heatmap";
140
145
  export * from "./components/date-picker";
141
146
  export * from "./components/tree-view";
142
147
  export * from "./components/chart";
@@ -1,14 +1,32 @@
1
- /** Internal (not exported from the package): the Tooltip's auto-portal check. */
1
+ /** Internal (not exported from the package): the Tooltip's auto-portal check. The
2
+ * attribute name itself IS exported, as `CLIPS_ATTRIBUTE` from the tooltip module. */
3
+
4
+ /**
5
+ * The explicit "this element clips or scrolls" marker. A container carrying it counts
6
+ * as clipping whatever its computed `overflow` says.
7
+ *
8
+ * Why a marker as well as the computed style: the kit's containers get their
9
+ * `overflow` from Tailwind classes, and jsdom computes no stylesheet — so under test
10
+ * every scroller reads `overflow: visible`, the Tooltip stays in place, and its always-
11
+ * mounted bubble shows up in the cell's text (keksdose 0.10: "CheckingChecking"). The
12
+ * browser and the test then disagreed about where the bubble lives, and apps pinned
13
+ * `portal` on every tooltip in a table to make them agree. The kit's own scrollers
14
+ * (DataTable's body, Table's wrapper) carry the marker, so the answer is the same in
15
+ * both; an app marks its own with `{ [CLIPS_ATTRIBUTE]: "" }` or a literal `data-clips`.
16
+ */
17
+ export const CLIPS_ATTRIBUTE = "data-clips";
2
18
 
3
19
  /** Whether anything between `el` and `<body>` clips or scrolls its content — the
4
20
  * containers an absolutely positioned bubble would be cut off by, or would widen.
5
- * `<body>` and `<html>` are not counted: their `overflow` belongs to the viewport (a
6
- * dialog's scroll lock sets it), and the viewport clips a portalled bubble just the
7
- * same. */
21
+ * Either the computed `overflow` says so or the element carries
22
+ * {@link CLIPS_ATTRIBUTE}. `<body>` and `<html>` are not counted: their `overflow`
23
+ * belongs to the viewport (a dialog's scroll lock sets it), and the viewport clips a
24
+ * portalled bubble just the same. */
8
25
  export function hasClippingAncestor(el: Element | null): boolean {
9
26
  if (typeof window === "undefined" || !el) return false;
10
27
  for (let node = el.parentElement; node; node = node.parentElement) {
11
28
  if (node === document.body || node === document.documentElement) return false;
29
+ if (node.hasAttribute(CLIPS_ATTRIBUTE)) return true;
12
30
  const style = window.getComputedStyle(node);
13
31
  if (style.overflowX !== "visible" || style.overflowY !== "visible") return true;
14
32
  }