@rebasepro/ui 0.12.1-canary.gf5f1d39 → 0.13.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 (58) hide show
  1. package/dist/components/Button.d.ts +2 -1
  2. package/dist/components/Menubar.d.ts +9 -2
  3. package/dist/components/SearchBar.d.ts +1 -1
  4. package/dist/components/VirtualTable/VirtualTable.d.ts +12 -6
  5. package/dist/components/VirtualTable/fields/VirtualTableDateField.d.ts +1 -0
  6. package/dist/components/index.d.ts +0 -1
  7. package/dist/icons/GitHubIcon.d.ts +1 -1
  8. package/dist/icons/HandleIcon.d.ts +3 -1
  9. package/dist/index.css +29 -61
  10. package/dist/index.d.ts +2 -1
  11. package/dist/index.es.js +484 -298
  12. package/dist/index.es.js.map +1 -1
  13. package/dist/src/index.css +29 -61
  14. package/dist/src/theme.css +109 -0
  15. package/dist/styles.d.ts +28 -0
  16. package/dist/theme.css +109 -0
  17. package/dist/util/chip_colors.d.ts +29 -3
  18. package/dist/views/CollectionView/index.d.ts +0 -6
  19. package/dist/views/Kanban/BoardColumn.d.ts +1 -0
  20. package/dist/views/Kanban/BoardSortableList.d.ts +2 -1
  21. package/dist/views/Kanban/board_types.d.ts +10 -0
  22. package/dist/views/Kanban/placement.d.ts +26 -0
  23. package/dist/views/index.d.ts +0 -1
  24. package/package.json +31 -29
  25. package/src/components/BooleanSwitch.tsx +44 -17
  26. package/src/components/BooleanSwitchWithLabel.tsx +6 -6
  27. package/src/components/Button.tsx +9 -13
  28. package/src/components/Checkbox.tsx +16 -6
  29. package/src/components/Chip.tsx +5 -2
  30. package/src/components/ColorPicker.tsx +56 -43
  31. package/src/components/DateTimeField.tsx +18 -6
  32. package/src/components/DialogActions.tsx +3 -1
  33. package/src/components/IconButton.tsx +35 -6
  34. package/src/components/Menubar.tsx +19 -3
  35. package/src/components/MultiSelect.tsx +9 -5
  36. package/src/components/SearchBar.tsx +6 -4
  37. package/src/components/Select.tsx +6 -6
  38. package/src/components/TextField.tsx +17 -7
  39. package/src/components/VirtualTable/VirtualTable.performance.test.tsx +35 -341
  40. package/src/components/VirtualTable/VirtualTable.tsx +19 -1
  41. package/src/components/VirtualTable/fields/VirtualTableDateField.tsx +6 -1
  42. package/src/components/index.tsx +2 -1
  43. package/src/icons/GitHubIcon.tsx +11 -5
  44. package/src/icons/HandleIcon.tsx +15 -2
  45. package/src/index.css +29 -61
  46. package/src/index.ts +2 -1
  47. package/src/styles.ts +47 -0
  48. package/src/theme.css +109 -0
  49. package/src/util/chip_colors.ts +109 -67
  50. package/src/views/CollectionView/index.ts +6 -11
  51. package/src/views/Kanban/Board.tsx +80 -117
  52. package/src/views/Kanban/BoardColumn.tsx +3 -0
  53. package/src/views/Kanban/BoardSortableList.tsx +16 -4
  54. package/src/views/Kanban/board_types.ts +10 -0
  55. package/src/views/Kanban/placement.ts +62 -0
  56. package/src/views/index.ts +1 -1
  57. package/dist/views/TableView.d.ts +0 -6
  58. package/src/views/TableView.tsx +0 -7
@@ -2,6 +2,41 @@
2
2
  import React from "react";
3
3
  import { cls } from "../util";
4
4
 
5
+ /**
6
+ * Switch geometry, one row per size: track w x h, knob k, and the knob's
7
+ * off/on x-offsets. `on` is always `w - k - inset` so the knob is inset by the
8
+ * same amount at both ends and vertically centred.
9
+ *
10
+ * This is a table rather than inline `cls({...})` conditions on purpose. The
11
+ * offsets used to be computed object keys — `[value ? "translate-x-[24px]" :
12
+ * "translate-x-[3px]"]: size === "large"` — and `large` and `medium` both
13
+ * produced the key `translate-x-[3px]` when off. The later literal overwrote
14
+ * the earlier, so the off-state class was silently dropped for `large` and the
15
+ * knob sat flush against the edge.
16
+ */
17
+ const SWITCH_GEOMETRY = {
18
+ smallest: {
19
+ track: "w-[34px] h-[18px] min-w-[34px] min-h-[18px]",
20
+ knob: "w-[16px] h-[16px]", off: "translate-x-[1px]", on: "translate-x-[17px]",
21
+ bar: "w-[16px] h-[6px]", barX: "translate-x-[9px]"
22
+ },
23
+ small: {
24
+ track: "w-[38px] h-[22px] min-w-[38px] min-h-[22px]",
25
+ knob: "w-[18px] h-[18px]", off: "translate-x-[2px]", on: "translate-x-[18px]",
26
+ bar: "w-[18px] h-[8px]", barX: "translate-x-[10px]"
27
+ },
28
+ medium: {
29
+ track: "w-[44px] h-[26px] min-w-[44px] min-h-[26px]",
30
+ knob: "w-[20px] h-[20px]", off: "translate-x-[3px]", on: "translate-x-[21px]",
31
+ bar: "w-[20px] h-[10px]", barX: "translate-x-[12px]"
32
+ },
33
+ large: {
34
+ track: "w-[48px] h-[28px] min-w-[48px] min-h-[28px]",
35
+ knob: "w-[22px] h-[22px]", off: "translate-x-[3px]", on: "translate-x-[23px]",
36
+ bar: "w-[22px] h-[11px]", barX: "translate-x-[13px]"
37
+ }
38
+ } as const;
39
+
5
40
  export type BooleanSwitchProps = {
6
41
  value: boolean | null;
7
42
  className?: string;
@@ -44,7 +79,11 @@ export const BooleanSwitch = React.forwardRef(function BooleanSwitch({
44
79
  }
45
80
  }}
46
81
  className={cls(
47
- size === "smallest" ? "w-[34px] h-[18px] min-w-[34px] min-h-[18px]" : size === "small" ? "w-[38px] h-[22px] min-w-[38px] min-h-[22px]" : "w-[44px] h-[26px] min-w-[44px] min-h-[26px]",
82
+ // `large` used to share the `medium` branch, so the two sizes
83
+ // rendered identically and the prop looked inert. Geometry is
84
+ // now derived from one table (see SWITCH_GEOMETRY) so the knob
85
+ // inset is symmetric at every size.
86
+ SWITCH_GEOMETRY[size].track,
48
87
  "outline-none outline-hidden rounded-full relative shadow-sm",
49
88
  value ? (disabled
50
89
  ? "bg-white bg-opacity-54 bg-white/54 dark:bg-surface-accent-950 border-surface-accent-100 dark:border-surface-accent-700 ring-1 ring-surface-accent-200 dark:ring-surface-accent-700"
@@ -58,14 +97,8 @@ export const BooleanSwitch = React.forwardRef(function BooleanSwitch({
58
97
  className={cls(
59
98
  "block rounded-full transition-transform duration-100 ease-out transform will-change-auto shadow-sm",
60
99
  disabled ? "bg-surface-accent-400 dark:bg-surface-accent-600" : "bg-surface-accent-400 dark:bg-surface-accent-600",
61
- {
62
- "w-[21px] h-[10px]": size === "medium" || size === "large",
63
- "w-[19px] h-[8px]": size === "small",
64
- "w-[16px] h-[6px]": size === "smallest",
65
- "translate-x-[10px]": size === "medium" || size === "large",
66
- "translate-x-[9px]": size === "small",
67
- "translate-x-[8px]": size === "smallest"
68
- }
100
+ SWITCH_GEOMETRY[size].bar,
101
+ SWITCH_GEOMETRY[size].barX
69
102
  )}
70
103
  />}
71
104
 
@@ -74,14 +107,8 @@ export const BooleanSwitch = React.forwardRef(function BooleanSwitch({
74
107
  className={cls(
75
108
  "block rounded-full transition-transform duration-100 ease-out transform will-change-auto shadow-sm",
76
109
  disabled ? "bg-surface-accent-300 dark:bg-surface-accent-700" : (value ? "bg-white shadow" : "bg-surface-accent-600 dark:bg-surface-accent-400"),
77
- {
78
- "w-[21px] h-[21px]": size === "medium" || size === "large",
79
- "w-[19px] h-[19px]": size === "small",
80
- "w-[16px] h-[16px]": size === "smallest",
81
- [value ? "translate-x-[19px]" : "translate-x-[3px]"]: size === "medium" || size === "large",
82
- [value ? "translate-x-[17px]" : "translate-x-[2px]"]: size === "small",
83
- [value ? "translate-x-[16px]" : "translate-x-[1px]"]: size === "smallest"
84
- }
110
+ SWITCH_GEOMETRY[size].knob,
111
+ value ? SWITCH_GEOMETRY[size].on : SWITCH_GEOMETRY[size].off
85
112
  )}
86
113
  />}
87
114
  </button>;
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  import React from "react";
3
3
  import {
4
+ controlHeightMixin,
4
5
  fieldBackgroundDisabledMixin,
5
6
  fieldBackgroundHoverMixin,
6
7
  fieldBackgroundMixin,
@@ -72,12 +73,11 @@ export const BooleanSwitchWithLabel = function BooleanSwitchWithLabel({
72
73
  "rounded-lg max-w-full justify-between box-border relative inline-flex items-center",
73
74
  !invisible && focus && !disabled ? focusedClasses : "",
74
75
  error ? "text-red-500 dark:text-red-600" : (focus && !disabled ? "text-primary" : (!disabled ? "text-text-primary dark:text-text-primary-dark" : "text-text-secondary dark:text-text-secondary-dark")),
75
- {
76
- "min-h-[28px]": size === "smallest",
77
- "min-h-[32px]": size === "small",
78
- "min-h-[44px]": size === "medium",
79
- "min-h-[64px]": size === "large"
80
- },
76
+ // The one control size scale — 28/32/40/48. This component still
77
+ // carried its own map (…/44/64), so a switch at `large` stood
78
+ // 16px taller than the text field beside it, which read as a
79
+ // giant empty box once the label moved out of the control.
80
+ controlHeightMixin[size],
81
81
  size === "small" || size === "smallest" ? "pl-2" : "pl-4",
82
82
  size === "small" || size === "smallest" ? "pr-4" : "pr-6",
83
83
  position === "end" ? "flex-row-reverse" : "flex-row",
@@ -1,13 +1,14 @@
1
1
  "use client";
2
2
  import React from "react";
3
3
  import { cls } from "../util";
4
+ import { type ButtonSize, controlHeightMixin, controlPaddingMixin } from "../styles";
4
5
 
5
6
  export type ButtonProps<C extends React.ElementType = "button"> = {
6
7
  children?: React.ReactNode;
7
8
  variant?: "filled" | "outlined" | "text";
8
9
  disabled?: boolean;
9
10
  color?: "primary" | "secondary" | "text" | "error" | "neutral";
10
- size?: "small" | "medium" | "large" | "xl" | "2xl";
11
+ size?: ButtonSize;
11
12
  startIcon?: React.ReactNode;
12
13
  fullWidth?: boolean;
13
14
  className?: string;
@@ -41,21 +42,21 @@ const ButtonInner = React.memo(React.forwardRef<
41
42
  // Filled Variants
42
43
  "border border-primary bg-primary focus:ring-primary text-white hover:text-white hover:brightness-105": variant === "filled" && color === "primary" && !disabled,
43
44
  "border border-secondary bg-secondary focus:ring-secondary text-white hover:text-white hover:brightness-105": variant === "filled" && color === "secondary" && !disabled,
44
- "border border-red-500 bg-red-500 hover:bg-red-600 focus:ring-red-500 text-white hover:text-white": variant === "filled" && color === "error" && !disabled,
45
+ "border border-red-600 bg-red-600 hover:bg-red-700 focus:ring-red-600 text-white hover:text-white": variant === "filled" && color === "error" && !disabled,
45
46
  "border border-surface-accent-200 bg-surface-accent-200 hover:bg-surface-accent-300 focus:ring-surface-accent-400 text-text-primary hover:text-text-primary dark:border-surface-accent-700 dark:bg-surface-accent-700 dark:hover:bg-surface-accent-600 dark:text-text-primary-dark hover:dark:text-text-primary-dark": variant === "filled" && color === "text" && !disabled,
46
47
  "border border-transparent bg-surface-100 hover:bg-surface-accent-200 text-text-primary dark:bg-surface-700 dark:hover:bg-surface-accent-700 dark:text-text-primary-dark hover:text-text-primary dark:text-text-primary-dark hover:dark:text-text-primary-dark": variant === "filled" && color === "neutral" && !disabled,
47
48
 
48
49
  // Text Variants
49
50
  "border border-transparent text-primary hover:text-primary hover:bg-surface-accent-200 hover:bg-opacity-75 hover:bg-surface-accent-200/75 dark:hover:bg-surface-accent-800": variant === "text" && color === "primary" && !disabled,
50
51
  "border border-transparent text-secondary hover:text-secondary hover:bg-surface-accent-200 hover:bg-opacity-75 hover:bg-surface-accent-200/75 dark:hover:bg-surface-accent-800": variant === "text" && color === "secondary" && !disabled,
51
- "border border-transparent text-red-500 hover:text-red-500 hover:bg-red-500 hover:bg-opacity-10 hover:bg-red-500/10": variant === "text" && color === "error" && !disabled,
52
+ "border border-transparent text-red-600 hover:text-red-600 hover:bg-red-600 hover:bg-opacity-10 hover:bg-red-600/10": variant === "text" && color === "error" && !disabled,
52
53
  "border border-transparent text-text-primary hover:text-text-primary dark:text-text-primary-dark hover:dark:text-text-primary-dark hover:bg-surface-accent-200 hover:bg-opacity-75 hover:bg-surface-accent-200/75 dark:hover:bg-surface-accent-800": variant === "text" && color === "text" && !disabled,
53
54
  "border border-transparent text-text-primary hover:text-text-primary hover:bg-surface-accent-200 hover:bg-opacity-75 hover:bg-surface-accent-200/75 dark:text-text-primary-dark dark:hover:text-text-primary-dark dark:hover:bg-surface-accent-800": variant === "text" && color === "neutral" && !disabled,
54
55
 
55
56
  // Outlined Variants
56
57
  "border border-primary text-primary hover:text-primary hover:bg-primary-bg hover:bg-primary/10": variant === "outlined" && color === "primary" && !disabled,
57
58
  "border border-secondary text-secondary hover:text-secondary hover:bg-secondary-bg": variant === "outlined" && color === "secondary" && !disabled,
58
- "border border-red-500 text-red-500 hover:text-red-500 hover:bg-red-500 hover:text-white": variant === "outlined" && color === "error" && !disabled,
59
+ "border border-red-500 text-red-600 hover:text-white hover:bg-red-600": variant === "outlined" && color === "error" && !disabled,
59
60
  "border border-surface-accent-400 text-text-primary hover:text-text-primary dark:text-text-primary-dark dark:border-surface-accent-600 hover:bg-surface-accent-200 hover:bg-opacity-75 hover:bg-surface-accent-200/75 dark:hover:bg-surface-accent-800": variant === "outlined" && color === "text" && !disabled,
60
61
  "border border-surface-300 text-text-primary hover:bg-surface-accent-200 hover:bg-opacity-75 hover:bg-surface-accent-200/75 dark:border-surface-600 dark:text-text-primary-dark dark:hover:bg-surface-accent-800": variant === "outlined" && color === "neutral" && !disabled,
61
62
 
@@ -66,15 +67,10 @@ const ButtonInner = React.memo(React.forwardRef<
66
67
  "border border-transparent bg-surface-300 dark:bg-surface-500 opacity-40 bg-surface-300/40 dark:bg-surface-500/40": variant === "filled" && disabled
67
68
  });
68
69
 
69
- const sizeClasses = cls(
70
- {
71
- "py-1 px-2": size === "small",
72
- "py-2 px-4": size === "medium",
73
- "py-2.5 px-5": size === "large",
74
- "py-3 px-6": size === "xl",
75
- "py-4 px-10": size === "2xl"
76
- }
77
- );
70
+ // Height comes from the shared control scale (see styles.ts) so a Button
71
+ // and a TextField at the same `size` line up. Vertical padding is dropped:
72
+ // the button is an inline-flex box centring its content inside min-height.
73
+ const sizeClasses = cls("py-0", controlHeightMixin[size], controlPaddingMixin[size]);
78
74
 
79
75
  const iconColorClass = (color === "neutral" || color === "text") && !disabled
80
76
  ? "[&>svg]:text-surface-accent-500 dark:[&>svg]:text-surface-accent-300"
@@ -17,19 +17,29 @@ export interface CheckboxProps {
17
17
  "aria-label"?: string;
18
18
  }
19
19
 
20
+ // Radius steps down with the box. `rounded-md` is 6px — on a 16-20px box that
21
+ // is most of the edge, so the control reads as a squircle rather than a
22
+ // checkbox. Box sizes match the icon glyph scale (16/16/20/24) so a Checkbox
23
+ // sitting beside IconButtons in a table row is the same visual size.
20
24
  const sizeClasses = {
21
- large: "w-6 h-6 rounded-md flex items-center justify-center",
22
- medium: "w-5 h-5 rounded-md flex items-center justify-center",
23
- small: "w-4 h-4 rounded-md flex items-center justify-center",
24
- smallest: "w-4 h-4 rounded-md flex items-center justify-center"
25
+ large: "w-6 h-6 rounded flex items-center justify-center",
26
+ medium: "w-5 h-5 rounded flex items-center justify-center",
27
+ small: "w-4 h-4 rounded-sm flex items-center justify-center",
28
+ smallest: "w-4 h-4 rounded-sm flex items-center justify-center"
25
29
  };
26
30
 
31
+ // Outer box is the hit area and sets the control's rendered height, so it
32
+ // tracks the shared 28/32/40/48 scale. `smallest` used to reuse `small`'s
33
+ // 32px box, which made the two sizes identical.
27
34
  const outerSizeClasses = {
28
35
  medium: "w-10 h-10",
29
36
  small: "w-8 h-8",
30
37
  large: "w-12 h-12 ",
31
- smallest: "w-8 h-8"
38
+ smallest: "w-7 h-7"
32
39
  }
40
+ // Flat p-2, as it always was. `shrink-0` on the box below is what stops it
41
+ // being squashed; changing the padding here only shifts the control's optical
42
+ // weight relative to the icons beside it.
33
43
  const paddingClasses = {
34
44
  medium: "p-2",
35
45
  small: "p-2",
@@ -82,7 +92,7 @@ export const Checkbox = React.memo(({
82
92
  )}>
83
93
  <div
84
94
  className={cls(
85
- "border-2 relative transition-colors ease-in-out duration-150",
95
+ "border-2 shrink-0 relative transition-colors ease-in-out duration-150",
86
96
  sizeClasses[size],
87
97
  disabled
88
98
  ? (indeterminate || isChecked ? "bg-surface-accent-400 dark:bg-surface-accent-600" : "bg-surface-accent-400 dark:bg-surface-accent-600")
@@ -16,11 +16,14 @@ export interface ChipProps {
16
16
  style?: React.CSSProperties;
17
17
  }
18
18
 
19
+ // `small` and `medium` used to differ only in horizontal padding, so both
20
+ // rendered at the same height and the step was invisible. Vertical rhythm now
21
+ // increases across the whole scale.
19
22
  const sizeClassNames = {
20
23
  smallest: "px-1.5 py-px text-[10px]",
21
24
  small: "px-2 py-0.5 text-xs",
22
- medium: "px-2.5 py-0.5 text-xs",
23
- large: "px-3 py-1 text-xs"
25
+ medium: "px-2.5 py-1 text-xs",
26
+ large: "px-3 py-1.5 text-sm"
24
27
  }
25
28
 
26
29
  /**
@@ -2,6 +2,7 @@
2
2
  import React from "react";
3
3
  import { CHIP_COLORS, cls } from "../util";
4
4
  import { ChipColorKey, ChipColorScheme } from "./Chip";
5
+ import type { ChipTone } from "../util/chip_colors";
5
6
  import { CheckIcon } from "lucide-react";
6
7
  import { Tooltip } from "./Tooltip";
7
8
 
@@ -28,12 +29,16 @@ export interface ColorPickerProps {
28
29
  disabled?: boolean;
29
30
  }
30
31
 
31
- // Base colors in display order
32
+ // Hues across, tones down — the arrangement the palette is built in.
32
33
  const BASE_COLORS = ["blue", "indigo", "violet", "purple", "fuchsia", "pink", "rose", "red", "orange", "yellow", "green", "emerald", "teal", "cyan", "gray"] as const;
33
34
 
35
+ // Lightest first, matching how the bare hue name reads.
36
+ const TONES: ChipTone[] = ["Lighter", "Light", "Dark", "Darker"];
37
+
34
38
  // Helper to get readable name from color key
35
- function getColorDisplayName(colorKey: ChipColorKey): string {
36
- return `${colorKey.charAt(0).toUpperCase()}${colorKey.slice(1)}`;
39
+ function getColorDisplayName(hue: string, tone: ChipTone): string {
40
+ const hueName = `${hue.charAt(0).toUpperCase()}${hue.slice(1)}`;
41
+ return tone === "Lighter" ? hueName : `${hueName} ${tone.toLowerCase()}`;
37
42
  }
38
43
 
39
44
  /**
@@ -80,48 +85,56 @@ export function ColorPicker({
80
85
  </button>
81
86
  )}
82
87
 
83
- <div className="flex flex-wrap gap-1.5">
84
- {BASE_COLORS.map((base) => {
85
- const colorKey = base as ChipColorKey;
86
- const colorScheme = CHIP_COLORS[colorKey] as ChipColorScheme;
87
- const isSelected = value === colorKey;
88
- const displayName = getColorDisplayName(colorKey);
88
+ {/* One row per tone. Wrapping rather than a fixed 15-column grid:
89
+ the picker also opens inside narrow property panels, where
90
+ fifteen tracks would shrink each swatch to a few pixels. */}
91
+ {TONES.map((tone) => (
92
+ <div key={tone} className="flex flex-wrap gap-1.5">
93
+ {BASE_COLORS.map((base) => {
94
+ // The bare hue and its `Lighter` tone are the same
95
+ // scheme; the picker offers the short name so configs
96
+ // stay readable.
97
+ const colorKey = (tone === "Lighter" ? base : `${base}${tone}`) as ChipColorKey;
98
+ const colorScheme = CHIP_COLORS[colorKey] as ChipColorScheme;
99
+ const isSelected = value === colorKey;
100
+ const displayName = getColorDisplayName(base, tone);
89
101
 
90
- return (
91
- <Tooltip
92
- key={colorKey}
93
- title={displayName}
94
- delayDuration={300}
95
- >
96
- <button
97
- type="button"
98
- disabled={disabled}
99
- onClick={() => onChange(colorKey)}
100
- className={cls(
101
- swatchSize,
102
- "rounded-full transition-all flex items-center justify-center",
103
- "hover:scale-110 hover:shadow-md",
104
- "focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1",
105
- disabled && "opacity-50 cursor-not-allowed hover:scale-100",
106
- isSelected && "ring-2 ring-primary ring-offset-1"
107
- )}
108
- style={{
109
- backgroundColor: colorScheme.color
110
- }}
111
- aria-label={displayName}
112
- aria-pressed={isSelected}
102
+ return (
103
+ <Tooltip
104
+ key={colorKey}
105
+ title={displayName}
106
+ delayDuration={300}
113
107
  >
114
- {isSelected && (
115
- <CheckIcon
116
- size={checkSize}
117
- style={{ color: colorScheme.text }}
118
- />
119
- )}
120
- </button>
121
- </Tooltip>
122
- );
123
- })}
124
- </div>
108
+ <button
109
+ type="button"
110
+ disabled={disabled}
111
+ onClick={() => onChange(colorKey)}
112
+ className={cls(
113
+ swatchSize,
114
+ "rounded-full transition-all flex items-center justify-center",
115
+ "hover:scale-110 hover:shadow-md",
116
+ "focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1",
117
+ disabled && "opacity-50 cursor-not-allowed hover:scale-100",
118
+ isSelected && "ring-2 ring-primary ring-offset-1"
119
+ )}
120
+ style={{
121
+ backgroundColor: colorScheme.color
122
+ }}
123
+ aria-label={displayName}
124
+ aria-pressed={isSelected}
125
+ >
126
+ {isSelected && (
127
+ <CheckIcon
128
+ size={checkSize}
129
+ style={{ color: colorScheme.text }}
130
+ />
131
+ )}
132
+ </button>
133
+ </Tooltip>
134
+ );
135
+ })}
136
+ </div>
137
+ ))}
125
138
  </div>
126
139
  );
127
140
  }
@@ -55,6 +55,11 @@ export const DateTimeField: React.FC<DateTimeFieldProps> = ({
55
55
  const [isTyping, setIsTyping] = useState(false);
56
56
  const invalidValue = value !== undefined && value !== null && (!(value instanceof Date) || isNaN(value.getTime()));
57
57
 
58
+ // The calendar/clear adornments are IconButtons. Left at their default
59
+ // (`medium`, 40px) they floored the whole field at 40px, so smallest/small
60
+ // rendered identically to medium. Keep them a step below the field.
61
+ const adornmentSize = size === "large" ? "medium" : size === "medium" ? "small" : "smallest";
62
+
58
63
  useInjectStyles("DateTimeField", inputStyles);
59
64
 
60
65
  const handleClear = (e: React.MouseEvent) => {
@@ -231,8 +236,8 @@ hour12: false,
231
236
  {
232
237
  "min-h-[28px]": size === "smallest",
233
238
  "min-h-[32px]": size === "small",
234
- "min-h-[44px]": size === "medium",
235
- "min-h-[64px]": size === "large"
239
+ "min-h-[40px]": size === "medium",
240
+ "min-h-[48px]": size === "large"
236
241
  },
237
242
  className
238
243
  )}
@@ -251,7 +256,7 @@ hour12: false,
251
256
  ? focused
252
257
  ? "text-primary"
253
258
  : "text-text-secondary dark:text-text-secondary-dark"
254
- : "text-red-500 dark:text-red-500",
259
+ : "text-red-600 dark:text-red-500",
255
260
  disabled ? "opacity-50" : ""
256
261
  )}
257
262
  shrink={true}
@@ -276,16 +281,22 @@ hour12: false,
276
281
  {
277
282
  "min-h-[28px]": size === "smallest",
278
283
  "min-h-[32px]": size === "small",
279
- "min-h-[44px]": size === "medium",
280
- "min-h-[64px]": size === "large"
284
+ "min-h-[40px]": size === "medium",
285
+ "min-h-[48px]": size === "large"
281
286
  },
282
- label ? "pt-8 pb-2" : "py-2",
287
+ // A flat py-2 (16px) plus the text-base line box floored
288
+ // the field at 40px, so smallest/small/medium all
289
+ // rendered the same height. Scale it with `size`.
290
+ label
291
+ ? "pt-8 pb-2"
292
+ : { smallest: "py-0.5", small: "py-1", medium: "py-2", large: "py-3" }[size],
283
293
  inputClassName,
284
294
  disabled &&
285
295
  "border border-transparent outline-hidden opacity-50 dark:opacity-50 text-surface-accent-600 dark:text-surface-accent-500"
286
296
  )}
287
297
  />
288
298
  <IconButton
299
+ size={adornmentSize}
289
300
  onClick={(e) => {
290
301
  e.stopPropagation();
291
302
  inputRef.current?.showPicker();
@@ -296,6 +307,7 @@ hour12: false,
296
307
  </IconButton>
297
308
  {clearable && value && (
298
309
  <IconButton
310
+ size={adornmentSize}
299
311
  onClick={handleClear}
300
312
  className="absolute right-14 top-1/2 transform -translate-y-1/2 text-surface-accent-400 "
301
313
  >
@@ -17,7 +17,9 @@ export function DialogActions({
17
17
  return <div
18
18
  className={cls(
19
19
  defaultBorderMixin,
20
- "pt-2 pb-4 px-4 border-t flex flex-row items-center justify-end bottom-0 right-0 left-0 text-right z-2 gap-2",
20
+ // `shrink-0`: as a flex child at the foot of a column this bar must
21
+ // keep its height instead of being squeezed by the content above it.
22
+ "pt-2 pb-4 px-4 border-t flex flex-row items-center justify-end shrink-0 bottom-0 right-0 left-0 text-right z-2 gap-2",
21
23
  position,
22
24
  "bg-white bg-opacity-60 bg-white/60 dark:bg-surface-800 dark:bg-opacity-60 dark:bg-surface-800/60",
23
25
  translucent ? "backdrop-blur-sm" : "",
@@ -16,19 +16,35 @@ export type IconButtonProps<C extends React.ElementType> =
16
16
  }
17
17
 
18
18
  const buttonClasses = "hover:bg-surface-accent-200 hover:bg-opacity-75 hover:bg-surface-accent-200/75 dark:hover:bg-surface-accent-800 hover:scale-[1.04] active:scale-95 transition-transform";
19
- const baseClasses = "inline-flex items-center justify-center p-2 text-sm font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-colors ease-in-out duration-150";
19
+ // `[&>svg]:shrink-0` is load-bearing: without it flex compresses the icon to
20
+ // whatever width is left after padding, so an 18px icon in a 28px button
21
+ // rendered 12x18 — visibly squashed rather than merely small.
22
+ const baseClasses = "inline-flex items-center justify-center text-sm font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-colors ease-in-out duration-150 [&>svg]:shrink-0";
20
23
  const colorClasses = "text-surface-accent-500 visited:text-surface-accent-500 dark:text-surface-accent-300 dark:visited:text-surface-accent-300";
24
+ // Padding stays a flat p-2 (as it always was). Callers pass their own icon
25
+ // sizes, so tightening it here shrinks the content box under an icon that was
26
+ // sized for the old one and makes the icon overflow. `shrink-0` above is what
27
+ // actually fixes the squashing — the icon keeps its own dimensions and simply
28
+ // eats into the padding.
21
29
  const sizeClasses = {
22
- medium: "w-10 !h-10 min-w-10 min-h-10",
23
- small: "w-8 !h-8 min-w-8 min-h-8",
24
- smallest: "w-7 !h-7 min-w-7 min-h-7",
25
- large: "w-12 !h-12 min-w-12 min-h-12"
30
+ medium: "w-10 !h-10 min-w-10 min-h-10 p-2",
31
+ small: "w-8 !h-8 min-w-8 min-h-8 p-2",
32
+ smallest: "w-7 !h-7 min-w-7 min-h-7 p-2",
33
+ large: "w-12 !h-12 min-w-12 min-h-12 p-2"
26
34
  }
27
35
  const shapeClasses = {
28
36
  circular: "rounded-full",
29
37
  square: "rounded-md"
30
38
  }
31
39
 
40
+ /** Icon size used when the caller doesn't set one on the child. */
41
+ const defaultIconSize = {
42
+ smallest: 16,
43
+ small: 18,
44
+ medium: 20,
45
+ large: 24
46
+ } as const;
47
+
32
48
  const IconButtonInner = <C extends React.ElementType = "button">({
33
49
  children,
34
50
  className,
@@ -44,6 +60,19 @@ const IconButtonInner = <C extends React.ElementType = "button">({
44
60
  const bgClasses = variant === "ghost" ? "bg-transparent" : "bg-surface-accent-200 bg-opacity-50 bg-surface-accent-200/50 dark:bg-surface-900 dark:bg-opacity-50 dark:bg-surface-900/50";
45
61
  const Component: React.ElementType<any> = component || "button";
46
62
  const isNativeButton = Component === "button";
63
+
64
+ // Give the icon a size that matches the button when the caller didn't pick
65
+ // one. Without this a bare <IconButton size="smallest"><PencilIcon/></…>
66
+ // renders lucide's 24px default inside a 28px button, so icon size ended up
67
+ // depending on whether each call site happened to pass one — which is what
68
+ // made a row of icon buttons look randomly sized. An explicit `size` on the
69
+ // child always wins.
70
+ const sizedChildren = React.Children.map(children, (child) => {
71
+ if (!React.isValidElement(child)) return child;
72
+ if ((child.props as { size?: unknown }).size !== undefined) return child;
73
+ return React.cloneElement(child as React.ReactElement<{ size?: number }>,
74
+ { size: defaultIconSize[size] });
75
+ });
47
76
  return (
48
77
  <Component
49
78
  type={isNativeButton ? "button" : undefined}
@@ -64,7 +93,7 @@ const IconButtonInner = <C extends React.ElementType = "button">({
64
93
  sizeClasses[size],
65
94
  className
66
95
  )}>
67
- {children}
96
+ {sizedChildren}
68
97
  </Component>
69
98
  );
70
99
  };
@@ -10,15 +10,26 @@ import { usePortalContainer } from "../hooks/PortalContainerContext";
10
10
  export function Menubar({
11
11
  children,
12
12
  onSelect,
13
+ value,
14
+ defaultValue,
15
+ onValueChange,
13
16
  className
14
17
  }: {
15
18
  children: React.ReactNode,
16
19
  onSelect?: (event: React.SyntheticEvent) => void,
20
+ /** Value of the open menu, controlled. Pair with `onValueChange`. */
21
+ value?: string,
22
+ /** Value of the menu open on mount, uncontrolled. */
23
+ defaultValue?: string,
24
+ onValueChange?: (value: string) => void,
17
25
  className?: string
18
26
  }) {
19
27
  return (
20
28
  <MenubarPrimitive.Root
21
29
  onSelect={onSelect}
30
+ value={value}
31
+ defaultValue={defaultValue}
32
+ onValueChange={onValueChange}
22
33
  className={cls("z-10 flex bg-white dark:bg-surface-900 p-[3px] rounded-lg shadow-sm", className)}>
23
34
  {children}
24
35
  </MenubarPrimitive.Root>
@@ -26,10 +37,15 @@ export function Menubar({
26
37
  }
27
38
 
28
39
  export function MenubarMenu({
29
- children
30
- }: { children: React.ReactNode }) {
40
+ children,
41
+ value
42
+ }: {
43
+ children: React.ReactNode,
44
+ /** Identifies this menu to the parent Menubar's `value`/`defaultValue`. */
45
+ value?: string
46
+ }) {
31
47
  return (
32
- <MenubarPrimitive.Menu>
48
+ <MenubarPrimitive.Menu value={value}>
33
49
  {children}
34
50
  </MenubarPrimitive.Menu>
35
51
  )
@@ -224,12 +224,16 @@ export const MultiSelect = React.forwardRef<
224
224
  {
225
225
  "min-h-[28px]": size === "smallest",
226
226
  "min-h-[32px]": size === "small",
227
- "min-h-[44px]": size === "medium",
228
- "min-h-[64px]": size === "large"
227
+ "min-h-[40px]": size === "medium",
228
+ "min-h-[48px]": size === "large"
229
229
  },
230
230
  {
231
- "py-1": size === "small" || size === "smallest",
232
- "py-2": size === "medium" || size === "large"
231
+ // Kept tight so the min-height above governs:
232
+ // py-2 plus a medium Chip overflowed it and made
233
+ // smallest/small render at the same height.
234
+ "py-0": size === "smallest",
235
+ "py-0.5": size === "small",
236
+ "py-1": size === "medium" || size === "large"
233
237
  },
234
238
  {
235
239
  "px-2": size === "small" || size === "smallest",
@@ -256,7 +260,7 @@ export const MultiSelect = React.forwardRef<
256
260
  }
257
261
  return (
258
262
  <Chip
259
- size={"medium"}
263
+ size={size === "smallest" || size === "small" ? "smallest" : "small"}
260
264
  key={String(value)}
261
265
  className={cls("flex flex-row items-center p-1")}
262
266
  >
@@ -22,7 +22,7 @@ interface SearchBarProps {
22
22
  * - "medium": 44px height (matches TextField medium)
23
23
  * @default "medium"
24
24
  */
25
- size?: "smallest" | "small" | "medium";
25
+ size?: "smallest" | "small" | "medium" | "large";
26
26
  innerClassName?: string;
27
27
  className?: string;
28
28
  autoFocus?: boolean;
@@ -74,9 +74,11 @@ export function SearchBar({
74
74
  }, [onTextSearch]);
75
75
 
76
76
  // Height classes matching TextField sizes
77
- const heightClass = size === "smallest" ? "h-8" : size === "small" ? "h-[36px]" : "h-[44px]";
78
- const iconPaddingClass = size === "smallest" ? "px-2" : size === "small" ? "px-2" : "px-4";
79
- const inputPaddingClass = size === "smallest" ? "pl-8" : size === "small" ? "pl-8" : "pl-12";
77
+ // Heights come from the shared control scale (styles.ts) so a SearchBar
78
+ // lines up with the Button and Select beside it in a toolbar.
79
+ const heightClass = { smallest: "h-[28px]", small: "h-[32px]", medium: "h-[40px]", large: "h-[48px]" }[size];
80
+ const iconPaddingClass = size === "smallest" || size === "small" ? "px-2" : size === "medium" ? "px-3" : "px-4";
81
+ const inputPaddingClass = size === "smallest" || size === "small" ? "pl-8" : size === "medium" ? "pl-10" : "pl-12";
80
82
 
81
83
  return (
82
84
  <div