@cosxai/ui 0.8.1 → 0.8.3

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosxai/ui",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "description": "COSX design system — React 19 component primitives shared across product-meta and other consumers",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -29,23 +29,43 @@ export interface TooltipProps {
29
29
  }>;
30
30
  // ms before showing. Default 120.
31
31
  delay?: number;
32
- // "top" hovers above the trigger, "bottom" below. Default "top".
33
- placement?: "top" | "bottom";
32
+ // "top" hovers above the trigger, "bottom" below, "auto" prefers
33
+ // top but flips to bottom when the trigger sits near the viewport
34
+ // top edge (would otherwise clip against the browser chrome).
35
+ // Default "auto".
36
+ placement?: "top" | "bottom" | "auto";
34
37
  // Render even if `content` is falsy. Default false — callers can
35
38
  // wrap unconditionally without branching JSX.
36
39
  alwaysRender?: boolean;
40
+ // When true, force the tooltip to a single line and truncate
41
+ // overflow with an ellipsis. Default false — content wraps to
42
+ // multiple lines within `maxWidth`. Existing single-line labels
43
+ // (scan-status pill, date row) render identically because they
44
+ // fit within maxWidth without wrapping; longer hints (explanatory
45
+ // text under form fields, etc.) now display in full.
46
+ truncate?: boolean;
37
47
  }
38
48
 
49
+ // AUTO_FLIP_THRESHOLD_PX = the vertical space "top" placement needs
50
+ // above the trigger (tooltip height + gap + a bit of breathing room).
51
+ // Trigger positioned closer than this to the viewport top gets a
52
+ // bottom-flip.
53
+ const AUTO_FLIP_THRESHOLD_PX = 48;
54
+
39
55
  export function Tooltip({
40
56
  content,
41
57
  children,
42
58
  delay = 120,
43
- placement = "top",
59
+ placement = "auto",
44
60
  alwaysRender = false,
61
+ truncate = false,
45
62
  }: TooltipProps) {
46
63
  const triggerRef = useRef<HTMLElement | null>(null);
47
64
  const [open, setOpen] = useState(false);
48
65
  const [pos, setPos] = useState<{ x: number; y: number } | null>(null);
66
+ const [resolvedPlacement, setResolvedPlacement] = useState<"top" | "bottom">(
67
+ placement === "bottom" ? "bottom" : "top",
68
+ );
49
69
  const timerRef = useRef<number | null>(null);
50
70
 
51
71
  const cancelTimer = useCallback(() => {
@@ -59,9 +79,18 @@ export function Tooltip({
59
79
  const el = triggerRef.current;
60
80
  if (!el) return;
61
81
  const r = el.getBoundingClientRect();
82
+ // "auto" resolves to top when there's enough room above, else
83
+ // bottom. Explicit "top" / "bottom" are honoured verbatim.
84
+ let effective: "top" | "bottom";
85
+ if (placement === "auto") {
86
+ effective = r.top < AUTO_FLIP_THRESHOLD_PX ? "bottom" : "top";
87
+ } else {
88
+ effective = placement;
89
+ }
90
+ setResolvedPlacement(effective);
62
91
  setPos({
63
92
  x: r.left + r.width / 2,
64
- y: placement === "top" ? r.top : r.bottom,
93
+ y: effective === "top" ? r.top : r.bottom,
65
94
  });
66
95
  }, [placement]);
67
96
 
@@ -137,7 +166,7 @@ export function Tooltip({
137
166
  left: pos?.x ?? -9999,
138
167
  top: pos?.y ?? -9999,
139
168
  transform:
140
- placement === "top"
169
+ resolvedPlacement === "top"
141
170
  ? "translate(-50%, calc(-100% - 8px))"
142
171
  : "translate(-50%, 8px)",
143
172
  background: "var(--ck-bg-surface)",
@@ -153,9 +182,22 @@ export function Tooltip({
153
182
  pointerEvents: "none",
154
183
  zIndex: 9999,
155
184
  maxWidth: 320,
156
- whiteSpace: "nowrap",
157
- overflow: "hidden",
158
- textOverflow: "ellipsis",
185
+ // Default: wrap long hints. Opt-in `truncate` restores the old
186
+ // single-line-with-ellipsis behaviour for compact labels where
187
+ // horizontal overflow is preferable to a taller tooltip (e.g.
188
+ // dense toolbars). Single-line labels shorter than maxWidth
189
+ // render identically in both modes — the branch only matters
190
+ // when content would need multiple lines.
191
+ ...(truncate
192
+ ? {
193
+ whiteSpace: "nowrap" as const,
194
+ overflow: "hidden" as const,
195
+ textOverflow: "ellipsis" as const,
196
+ }
197
+ : {
198
+ whiteSpace: "normal" as const,
199
+ wordBreak: "break-word" as const,
200
+ }),
159
201
  opacity: open && pos ? 1 : 0,
160
202
  transition: "opacity 120ms ease",
161
203
  };