@carbonid1/design-system 5.7.4 → 5.7.6

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.
@@ -3,10 +3,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { cn } from '../helpers/cn/cn';
4
4
  import { cva } from 'class-variance-authority';
5
5
  import { X } from 'lucide-react';
6
- const badgeVariants = cva('inline-flex max-w-full min-w-0 items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium whitespace-nowrap', {
6
+ const badgeVariants = cva('inline-flex max-w-full min-w-0 items-center gap-1 rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap', {
7
7
  variants: {
8
8
  variant: {
9
- default: 'bg-muted text-muted-foreground',
9
+ default: 'border-muted-foreground bg-muted text-foreground',
10
10
  primary: 'bg-primary-border/30 text-primary',
11
11
  success: 'bg-success/15 text-success-foreground',
12
12
  attention: 'bg-attention-muted text-attention-foreground',
@@ -9,7 +9,7 @@ const buttonVariants = cva("group/button inline-flex shrink-0 items-center justi
9
9
  variant: {
10
10
  ghost: 'hover:bg-accent aria-expanded:bg-accent dark:hover:bg-accent/50',
11
11
  primary: 'bg-primary text-primary-foreground font-medium hover:bg-primary/90',
12
- outline: 'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
12
+ outline: 'border-input-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-input/30 dark:hover:bg-input/50',
13
13
  destructive: 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
14
14
  attention: 'text-attention-foreground hover:bg-attention-muted',
15
15
  subtle: 'text-muted-foreground hover:text-primary',
@@ -11,5 +11,6 @@ type TooltipProps = {
11
11
  'aria-label'?: string;
12
12
  }>;
13
13
  };
14
+ export declare const VIEWPORT_MARGIN = 8;
14
15
  export declare const Tooltip: ({ label, shortcut, position, delay, maxWidth, disabled, className, children, }: TooltipProps) => import("react/jsx-runtime").JSX.Element;
15
16
  export {};
@@ -1,12 +1,22 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { computeTooltipPlacement, } from '../helpers/tooltipPlacement/tooltipPlacement';
3
4
  import { Kbd } from '../Kbd/Kbd';
4
- import { cloneElement, useCallback, useEffect, useRef, useState } from 'react';
5
+ import { cloneElement, useCallback, useEffect, useLayoutEffect, useRef, useState, } from 'react';
5
6
  const DEFAULT_DELAY = 200;
7
+ export const VIEWPORT_MARGIN = 8;
8
+ // Must stay in sync with the mb-2 / mt-2 (0.5rem = 8px) classes below — the flip
9
+ // math subtracts this gap, so a mismatch would mis-predict whether a side fits.
10
+ const TRIGGER_GAP = 8;
11
+ // Position before paint so an edge tooltip never flashes centered-then-shifted.
12
+ // useLayoutEffect warns and no-ops during SSR, so fall back to useEffect there.
13
+ const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;
6
14
  export const Tooltip = ({ label, shortcut, position = 'top', delay = DEFAULT_DELAY, maxWidth, disabled, className, children, }) => {
7
15
  const [visible, setVisible] = useState(false);
16
+ const [placement, setPlacement] = useState({ side: position, offsetX: 0 });
8
17
  const timeoutRef = useRef(null);
9
18
  const wrapperRef = useRef(null);
19
+ const tooltipRef = useRef(null);
10
20
  const show = useCallback(() => {
11
21
  if (disabled)
12
22
  return;
@@ -52,8 +62,37 @@ export const Tooltip = ({ label, shortcut, position = 'top', delay = DEFAULT_DEL
52
62
  document.addEventListener('pointermove', handlePointerMove, { passive: true });
53
63
  return () => document.removeEventListener('pointermove', handlePointerMove);
54
64
  }, [visible, hide]);
55
- const positionClasses = position === 'top' ? 'bottom-full mb-2' : 'top-full mt-2';
65
+ useIsomorphicLayoutEffect(() => {
66
+ if (!visible)
67
+ return;
68
+ const measure = () => {
69
+ const wrapper = wrapperRef.current;
70
+ const tooltip = tooltipRef.current;
71
+ if (!wrapper || !tooltip)
72
+ return;
73
+ const trigger = wrapper.getBoundingClientRect();
74
+ const box = tooltip.getBoundingClientRect();
75
+ const next = computeTooltipPlacement({
76
+ trigger,
77
+ tooltip: { width: box.width, height: box.height },
78
+ viewport: { width: window.innerWidth, height: window.innerHeight },
79
+ preferredSide: position,
80
+ margin: VIEWPORT_MARGIN,
81
+ gap: TRIGGER_GAP,
82
+ });
83
+ // Keep the same object when nothing moved so a show or a no-op resize
84
+ // doesn't trigger a redundant re-render (the common centered case is a no-op).
85
+ setPlacement(prev => (prev.side === next.side && prev.offsetX === next.offsetX ? prev : next));
86
+ };
87
+ measure();
88
+ window.addEventListener('resize', measure, { passive: true });
89
+ return () => window.removeEventListener('resize', measure);
90
+ }, [visible, position]);
91
+ const positionClasses = placement.side === 'top' ? 'bottom-full mb-2' : 'top-full mt-2';
56
92
  return (_jsxs("div", { ref: wrapperRef, className: `relative inline-flex${className ? ` ${className}` : ''}`, onMouseEnter: show, onMouseLeave: hide, onPointerDown: hide, onFocus: show, onBlur: hide, children: [cloneElement(children, {
57
93
  'aria-label': children.props['aria-label'] ?? label,
58
- }), visible && !disabled && (_jsxs("div", { role: "tooltip", style: maxWidth ? { maxWidth } : undefined, className: `absolute left-1/2 z-50 -translate-x-1/2 ${maxWidth ? 'w-max whitespace-normal' : 'whitespace-nowrap'} bg-foreground text-background pointer-events-none flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs shadow-lg ${positionClasses}`, children: [label, shortcut && (_jsx(Kbd, { keys: shortcut, size: "sm", className: "bg-background/15 border-transparent" }))] }))] }));
94
+ }), visible && !disabled && (_jsxs("div", { ref: tooltipRef, role: "tooltip", style: {
95
+ transform: `translateX(calc(-50% + ${placement.offsetX}px))`,
96
+ ...(maxWidth ? { maxWidth } : {}),
97
+ }, className: `absolute left-1/2 z-50 ${maxWidth ? 'w-max whitespace-normal' : 'whitespace-nowrap'} bg-foreground text-background pointer-events-none flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs shadow-lg ${positionClasses}`, children: [label, shortcut && (_jsx(Kbd, { keys: shortcut, size: "sm", className: "bg-background/15 border-transparent" }))] }))] }));
59
98
  };
@@ -0,0 +1,12 @@
1
+ type Oklch = {
2
+ lightness: number;
3
+ chroma: number;
4
+ hue: number;
5
+ };
6
+ /**
7
+ * WCAG 2.x contrast ratio between two oklch colors, in the range [1, 21].
8
+ * Order-independent. Alpha is not modeled — pass an already-composited color if a
9
+ * token is translucent.
10
+ */
11
+ export declare const contrastRatio: (foreground: Oklch, background: Oklch) => number;
12
+ export type { Oklch };
@@ -0,0 +1,37 @@
1
+ const DEGREES_TO_RADIANS = Math.PI / 180;
2
+ const clampUnit = (value) => Math.min(1, Math.max(0, value));
3
+ /**
4
+ * Linear-sRGB channels for an oklch color, via Björn Ottosson's oklab→linear
5
+ * matrices. Channels are clamped into gamut so an out-of-sRGB color folds to its
6
+ * nearest representable luminance rather than producing a nonsense ratio.
7
+ */
8
+ const toLinearSrgb = ({ lightness, chroma, hue }) => {
9
+ const hueRadians = hue * DEGREES_TO_RADIANS;
10
+ const a = chroma * Math.cos(hueRadians);
11
+ const b = chroma * Math.sin(hueRadians);
12
+ const longRoot = lightness + 0.3963377774 * a + 0.2158037573 * b;
13
+ const mediumRoot = lightness - 0.1055613458 * a - 0.0638541728 * b;
14
+ const shortRoot = lightness - 0.0894841775 * a - 1.291485548 * b;
15
+ const long = longRoot ** 3;
16
+ const medium = mediumRoot ** 3;
17
+ const short = shortRoot ** 3;
18
+ return [
19
+ clampUnit(4.0767416621 * long - 3.3077115913 * medium + 0.2309699292 * short),
20
+ clampUnit(-1.2684380046 * long + 2.6097574011 * medium - 0.3413193965 * short),
21
+ clampUnit(-0.0041960863 * long - 0.7034186147 * medium + 1.707614701 * short),
22
+ ];
23
+ };
24
+ const relativeLuminance = (color) => {
25
+ const [red, green, blue] = toLinearSrgb(color);
26
+ return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
27
+ };
28
+ /**
29
+ * WCAG 2.x contrast ratio between two oklch colors, in the range [1, 21].
30
+ * Order-independent. Alpha is not modeled — pass an already-composited color if a
31
+ * token is translucent.
32
+ */
33
+ export const contrastRatio = (foreground, background) => {
34
+ const lighter = Math.max(relativeLuminance(foreground), relativeLuminance(background));
35
+ const darker = Math.min(relativeLuminance(foreground), relativeLuminance(background));
36
+ return (lighter + 0.05) / (darker + 0.05);
37
+ };
@@ -0,0 +1,38 @@
1
+ type Side = 'top' | 'bottom';
2
+ type Rect = {
3
+ left: number;
4
+ top: number;
5
+ bottom: number;
6
+ width: number;
7
+ };
8
+ type Size = {
9
+ width: number;
10
+ height: number;
11
+ };
12
+ type TooltipPlacementInput = {
13
+ /** Trigger (wrapper) rect, viewport-relative — the tooltip is centered on its horizontal midpoint. */
14
+ trigger: Rect;
15
+ /** Measured tooltip box, taken at its un-shifted centered position. */
16
+ tooltip: Size;
17
+ viewport: Size;
18
+ /** Side the caller asked for; kept unless that side lacks room. */
19
+ preferredSide: Side;
20
+ /** Minimum gap to keep between the tooltip and each viewport edge. */
21
+ margin: number;
22
+ /** Gap between the trigger and the tooltip on the chosen side. */
23
+ gap: number;
24
+ };
25
+ export type TooltipPlacement = {
26
+ side: Side;
27
+ /** Pixels to shift the centered tooltip horizontally; 0 leaves it centered. */
28
+ offsetX: number;
29
+ };
30
+ /**
31
+ * Decide where a centered tooltip should sit so it stays within the viewport:
32
+ * shift it horizontally when an edge would cross `margin`, and flip the vertical
33
+ * side when the preferred one lacks room. Pure — the component feeds it measured
34
+ * rects and applies the result. On a viewport too narrow to fit the box, the left
35
+ * edge wins (LTR readers lose the tail, not the start).
36
+ */
37
+ export declare const computeTooltipPlacement: ({ trigger, tooltip, viewport, preferredSide, margin, gap, }: TooltipPlacementInput) => TooltipPlacement;
38
+ export {};
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Decide where a centered tooltip should sit so it stays within the viewport:
3
+ * shift it horizontally when an edge would cross `margin`, and flip the vertical
4
+ * side when the preferred one lacks room. Pure — the component feeds it measured
5
+ * rects and applies the result. On a viewport too narrow to fit the box, the left
6
+ * edge wins (LTR readers lose the tail, not the start).
7
+ */
8
+ export const computeTooltipPlacement = ({ trigger, tooltip, viewport, preferredSide, margin, gap, }) => {
9
+ const triggerCenterX = trigger.left + trigger.width / 2;
10
+ const centeredLeft = triggerCenterX - tooltip.width / 2;
11
+ const centeredRight = centeredLeft + tooltip.width;
12
+ let offsetX = 0;
13
+ if (centeredRight > viewport.width - margin)
14
+ offsetX = viewport.width - margin - centeredRight;
15
+ if (centeredLeft + offsetX < margin)
16
+ offsetX = margin - centeredLeft;
17
+ const spaceAbove = trigger.top;
18
+ const spaceBelow = viewport.height - trigger.bottom;
19
+ const needed = tooltip.height + gap + margin;
20
+ let side = preferredSide;
21
+ if (preferredSide === 'top' && spaceAbove < needed && spaceBelow > spaceAbove)
22
+ side = 'bottom';
23
+ if (preferredSide === 'bottom' && spaceBelow < needed && spaceAbove > spaceBelow)
24
+ side = 'top';
25
+ return { side, offsetX };
26
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carbonid1/design-system",
3
- "version": "5.7.4",
3
+ "version": "5.7.6",
4
4
  "description": "Shared React UI primitives + design tokens (themes, postcss config)",
5
5
  "repository": {
6
6
  "type": "git",
@@ -58,6 +58,7 @@
58
58
  --color-success-foreground: var(--success-foreground);
59
59
  --color-border: var(--border);
60
60
  --color-input: var(--input);
61
+ --color-input-border: var(--input-border);
61
62
  --color-ring: var(--ring);
62
63
  /* Font families. Override a role by setting its `--font-*-custom` variable;
63
64
  * each falls back to a system stack. */
@@ -122,7 +123,14 @@
122
123
  --destructive: oklch(0.58 0.22 27);
123
124
  --border: oklch(0.922 0 0);
124
125
  --input: oklch(0.922 0 0);
125
- --ring: oklch(0.708 0 0);
126
+ /* Resting boundary for form controls (Button outline, inputs) — distinct from
127
+ * decorative --border and the --input fill. Dark enough to clear WCAG 1.4.11
128
+ * (3:1) against the surfaces controls sit on; the contrast test guards it. */
129
+ --input-border: oklch(0.62 0 0);
130
+ /* Neutral focus ring (border-ring + ring-ring/50 halo). Dark enough that the
131
+ * solid border clears WCAG 1.4.11 (3:1 non-text) against the lightest surface
132
+ * — the themes contrast test guards this floor. */
133
+ --ring: oklch(0.6 0 0);
126
134
  --highlight: oklch(0.901 0.082 85.84);
127
135
  --highlight-foreground: oklch(0.344 0.06 58.6);
128
136
  --highlight-muted: oklch(0.962 0.042 95.28);
@@ -159,6 +167,7 @@
159
167
  --destructive: oklch(0.704 0.191 22.216);
160
168
  --border: oklch(0.373 0.031 260 / 50%);
161
169
  --input: oklch(0.373 0.031 260 / 60%);
170
+ --input-border: oklch(0.58 0.025 262);
162
171
  --ring: oklch(0.551 0.023 264);
163
172
  --highlight: oklch(0.55 0.07 268);
164
173
  --highlight-foreground: oklch(0.95 0.02 265);
package/themes/reader.css CHANGED
@@ -80,6 +80,7 @@
80
80
  --color-success-foreground: var(--success-foreground);
81
81
  --color-border: var(--border);
82
82
  --color-input: var(--input);
83
+ --color-input-border: var(--input-border);
83
84
  --color-ring: var(--ring);
84
85
  /* Font families. Override a role by setting its `--font-*-custom` variable;
85
86
  * each falls back to a system stack. */
@@ -147,7 +148,14 @@
147
148
  --destructive: oklch(0.58 0.22 27);
148
149
  --border: oklch(0.922 0 0);
149
150
  --input: oklch(0.922 0 0);
150
- --ring: oklch(0.708 0 0);
151
+ /* Resting boundary for form controls (Button outline, inputs) — distinct from
152
+ * decorative --border and the --input fill. Dark enough to clear WCAG 1.4.11
153
+ * (3:1) against the surfaces controls sit on; the contrast test guards it. */
154
+ --input-border: oklch(0.62 0 0);
155
+ /* Neutral focus ring (border-ring + ring-ring/50 halo). Dark enough that the
156
+ * solid border clears WCAG 1.4.11 (3:1 non-text) against the lightest surface
157
+ * — the themes contrast test guards this floor. */
158
+ --ring: oklch(0.6 0 0);
151
159
  --highlight: oklch(0.901 0.082 85.84);
152
160
  --highlight-foreground: oklch(0.344 0.06 58.6);
153
161
  --highlight-muted: oklch(0.962 0.042 95.28);
@@ -189,6 +197,7 @@
189
197
  --destructive: oklch(0.704 0.191 22.216);
190
198
  --border: oklch(0.373 0.031 260 / 50%);
191
199
  --input: oklch(0.373 0.031 260 / 60%);
200
+ --input-border: oklch(0.58 0.025 262);
192
201
  --ring: oklch(0.551 0.023 264);
193
202
  --highlight: oklch(0.55 0.07 268);
194
203
  --highlight-foreground: oklch(0.95 0.02 265);