@tale-ui/utils 0.0.3 → 0.1.1

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/README.md CHANGED
@@ -1,3 +1,65 @@
1
1
  # @tale-ui/utils
2
2
 
3
- A collection of React utility functions for Base UI.
3
+ Shared utilities for the Tale UI component library. This is an internal package — most consumers will not need to import it directly (it is pulled in automatically by `@tale-ui/react`).
4
+
5
+ ## Colour Utilities
6
+
7
+ ```ts
8
+ import { generatePalette, randomBaseColor, NAMED_SHADES } from '@tale-ui/utils/color';
9
+ ```
10
+
11
+ | Export | Description |
12
+ |--------|------------|
13
+ | `generatePalette(baseHex, mode)` | Generate an 11-shade tonal palette from a base hex colour (OKLCH math via culori) |
14
+ | `randomBaseColor(mode)` | Generate a random base hex that passes WCAG contrast validation |
15
+ | `NAMED_SHADES` | `[5, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]` |
16
+ | `NEUTRAL_SHADES` | Full 27-shade neutral scale |
17
+ | `getContrastRatio(hex1, hex2)` | WCAG contrast ratio between two hex colours |
18
+ | `getRelativeLuminance(hex)` | WCAG relative luminance of a hex colour |
19
+
20
+ ## React Hooks
21
+
22
+ | Hook | Purpose |
23
+ |------|---------|
24
+ | `useControlled` | Manage controlled/uncontrolled component state |
25
+ | `useId` | Generate stable unique IDs |
26
+ | `useMergedRefs` | Merge multiple refs into one |
27
+ | `usePreviousValue` | Track the previous value of a variable |
28
+ | `useScrollLock` | Lock body scroll (for modals/drawers) |
29
+ | `useStableCallback` | Stable reference for callbacks |
30
+ | `useAnimationFrame` | `requestAnimationFrame` wrapper |
31
+ | `useInterval` | `setInterval` wrapper |
32
+ | `useTimeout` | `setTimeout` wrapper |
33
+ | `useOnMount` | Run effect only on mount |
34
+ | `useOnFirstRender` | Run callback on first render |
35
+ | `useIsoLayoutEffect` | SSR-safe `useLayoutEffect` |
36
+ | `useRefWithInit` | Ref with lazy initialiser |
37
+ | `useValueAsRef` | Keep a ref synchronised with a value |
38
+ | `useForcedRerendering` | Force a component re-render |
39
+ | `useEnhancedClickHandler` | Click handler with additional event logic |
40
+
41
+ ## DOM Helpers
42
+
43
+ | Export | Purpose |
44
+ |--------|---------|
45
+ | `owner` | Get the owner document of a node |
46
+ | `isElementDisabled` | Check if an element is disabled |
47
+ | `isMouseWithinBounds` | Check if a mouse event is within an element's bounds |
48
+ | `getReactElementRef` | Extract ref from a React element |
49
+ | `visuallyHidden` | CSS-in-JS styles for visually hidden content |
50
+ | `detectBrowser` | Detect browser environment |
51
+ | `inertValue` | Inert value helper for SSR |
52
+
53
+ ## General
54
+
55
+ | Export | Purpose |
56
+ |--------|---------|
57
+ | `mergeObjects` | Deep merge objects |
58
+ | `fastObjectShallowCompare` | Fast shallow equality check |
59
+ | `formatErrorMessage` | Format error messages with codes |
60
+ | `generateId` | Generate unique string IDs |
61
+ | `warn` | Development-only console warning |
62
+
63
+ ## License
64
+
65
+ MIT
package/color.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ export declare const NAMED_SHADES: readonly [5, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
2
+ /**
3
+ * Generate a tonal palette from a base hex (treated as the -60 shade).
4
+ * Returns Array<{shade, hex}> where shade is one of NAMED_SHADES.
5
+ */
6
+ export declare const generatePalette: (baseHex: string, mode?: "named" | "neutral") => Array<{
7
+ shade: number;
8
+ hex: string;
9
+ }>;
10
+ /**
11
+ * Generate a random BASE hex colour suitable for the given mode.
12
+ * Validates WCAG contrast thresholds against the actual generated palette.
13
+ */
14
+ export declare const randomBaseColor: (mode?: "named" | "neutral") => string;
package/color.js ADDED
@@ -0,0 +1,152 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.randomBaseColor = exports.generatePalette = exports.NAMED_SHADES = void 0;
7
+ var _culori = require("culori");
8
+ // Color palette generation utilities for Tale UI.
9
+ // Ported from the scale project (scale/src/utils.js) which uses culori for OKLCH color math.
10
+
11
+ const toOklch = (0, _culori.converter)('oklch');
12
+ const NAMED_SHADES = exports.NAMED_SHADES = [5, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
13
+
14
+ // Convert OKLCH values to a clamped 6-digit hex string
15
+ const oklchToHex = (l, c, h) => {
16
+ const clamped = (0, _culori.clampChroma)({
17
+ mode: 'oklch',
18
+ l,
19
+ c,
20
+ h: h ?? 0
21
+ }, 'oklch');
22
+ return (0, _culori.formatHex)({
23
+ mode: 'oklch',
24
+ ...clamped
25
+ });
26
+ };
27
+
28
+ /**
29
+ * Generate a tonal palette from a base hex (treated as the -60 shade).
30
+ * Returns Array<{shade, hex}> where shade is one of NAMED_SHADES.
31
+ */
32
+ const generatePalette = (baseHex, mode = 'named') => {
33
+ const base = toOklch(baseHex);
34
+ if (!base) return [];
35
+ const {
36
+ l: L60,
37
+ c: C60,
38
+ h: H60
39
+ } = base;
40
+ const isNeutral = mode === 'neutral';
41
+ const L_MAX = 0.977;
42
+ const DARK_L_RATIO = isNeutral ? 0.45 : 0.55;
43
+ const DARK_C_RATIO = isNeutral ? 0.50 : 0.62;
44
+ const LIGHT_C_TARGET = isNeutral ? 0.004 : 0.008;
45
+ const isTypeB = !isNeutral && L60 > 0.70;
46
+ return NAMED_SHADES.map(shade => {
47
+ if (shade === 60) {
48
+ return {
49
+ shade,
50
+ hex: baseHex.toLowerCase()
51
+ };
52
+ }
53
+ let l, c;
54
+ if (shade < 60) {
55
+ const t = (60 - shade) / 55;
56
+ l = L60 + t * (L_MAX - L60);
57
+ c = C60 + t * (LIGHT_C_TARGET - C60);
58
+ } else {
59
+ const t = (shade - 60) / 40;
60
+ if (isTypeB) {
61
+ const L_MIN = Math.max(0.26, L60 * 0.33);
62
+ l = L60 + t * (L_MIN - L60);
63
+ c = C60 * (1 - t * 0.70);
64
+ } else {
65
+ l = L60 * (DARK_L_RATIO + (1 - DARK_L_RATIO) * (1 - t));
66
+ c = C60 * (DARK_C_RATIO + (1 - DARK_C_RATIO) * (1 - t));
67
+ }
68
+ }
69
+ if (isNeutral) {
70
+ c = Math.min(c, 0.05);
71
+ c = Math.max(c, 0.002);
72
+ }
73
+ const hex = oklchToHex(l, c, H60);
74
+ return {
75
+ shade,
76
+ hex
77
+ };
78
+ });
79
+ };
80
+
81
+ /**
82
+ * WCAG relative luminance for a hex colour.
83
+ */
84
+ exports.generatePalette = generatePalette;
85
+ const getRelativeLuminance = hex => {
86
+ const h = hex.replace('#', '');
87
+ const r = parseInt(h.slice(0, 2), 16) / 255;
88
+ const g = parseInt(h.slice(2, 4), 16) / 255;
89
+ const b = parseInt(h.slice(4, 6), 16) / 255;
90
+ const toLinear = c => c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
91
+ return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
92
+ };
93
+
94
+ /**
95
+ * WCAG contrast ratio between two hex colours (always ≥ 1).
96
+ */
97
+ const getContrastRatio = (hex1, hex2) => {
98
+ const L1 = getRelativeLuminance(hex1);
99
+ const L2 = getRelativeLuminance(hex2);
100
+ const lighter = Math.max(L1, L2);
101
+ const darker = Math.min(L1, L2);
102
+ return (lighter + 0.05) / (darker + 0.05);
103
+ };
104
+
105
+ /**
106
+ * Generate a random BASE hex colour suitable for the given mode.
107
+ * Validates WCAG contrast thresholds against the actual generated palette.
108
+ */
109
+ const randomBaseColor = (mode = 'named') => {
110
+ const isNeutral = mode === 'neutral';
111
+ for (let attempt = 0; attempt < 200; attempt++) {
112
+ const h = Math.random() * 360;
113
+ const c = isNeutral ? 0.01 + Math.random() * 0.03 : 0.10 + Math.random() * 0.15;
114
+ if (isNeutral) {
115
+ const l = 0.35 + Math.random() * 0.20;
116
+ const candidateHex = oklchToHex(l, c, h);
117
+ const palette = generatePalette(candidateHex, mode);
118
+ if (!palette.length) continue;
119
+ const get = shade => palette.find(p => p.shade === shade)?.hex;
120
+ const s5 = get(5);
121
+ const s50 = get(50);
122
+ const s100 = get(100);
123
+ if (s5 && s50 && s100 && getContrastRatio(candidateHex, s5) >= 4.5 && getContrastRatio(s50, s100) >= 4.5) {
124
+ return candidateHex;
125
+ }
126
+ } else {
127
+ const isTypeB = Math.random() < 0.5;
128
+ const l = isTypeB ? 0.71 + Math.random() * 0.17 : 0.38 + Math.random() * 0.32;
129
+ const candidateHex = oklchToHex(l, c, h);
130
+ const palette = generatePalette(candidateHex, mode);
131
+ if (!palette.length) continue;
132
+ const get = shade => palette.find(p => p.shade === shade)?.hex;
133
+ if (isTypeB) {
134
+ const s5 = get(5);
135
+ const s70 = get(70);
136
+ const s80 = get(80);
137
+ if (s5 && s70 && s80 && getContrastRatio(s70, s5) >= 3.0 && getContrastRatio(s80, s5) >= 4.5) {
138
+ return candidateHex;
139
+ }
140
+ } else {
141
+ const s5 = get(5);
142
+ const s50 = get(50);
143
+ const s100 = get(100);
144
+ if (s5 && s50 && s100 && getContrastRatio(candidateHex, s5) >= 4.5 && getContrastRatio(s50, s100) >= 3.0) {
145
+ return candidateHex;
146
+ }
147
+ }
148
+ }
149
+ }
150
+ return isNeutral ? '#79716b' : '#dc2626';
151
+ };
152
+ exports.randomBaseColor = randomBaseColor;
package/culori.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ // culori v4 ships no TypeScript declarations — declare it to suppress TS7016.
2
+ declare module 'culori' {
3
+ export function converter(mode: string): (color: string | object) => any;
4
+ export function formatHex(color: object): string;
5
+ export function clampChroma(color: object, mode: string): object;
6
+ }
package/esm/color.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ export declare const NAMED_SHADES: readonly [5, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
2
+ /**
3
+ * Generate a tonal palette from a base hex (treated as the -60 shade).
4
+ * Returns Array<{shade, hex}> where shade is one of NAMED_SHADES.
5
+ */
6
+ export declare const generatePalette: (baseHex: string, mode?: "named" | "neutral") => Array<{
7
+ shade: number;
8
+ hex: string;
9
+ }>;
10
+ /**
11
+ * Generate a random BASE hex colour suitable for the given mode.
12
+ * Validates WCAG contrast thresholds against the actual generated palette.
13
+ */
14
+ export declare const randomBaseColor: (mode?: "named" | "neutral") => string;
package/esm/color.js ADDED
@@ -0,0 +1,143 @@
1
+ // Color palette generation utilities for Tale UI.
2
+ // Ported from the scale project (scale/src/utils.js) which uses culori for OKLCH color math.
3
+ import { converter, formatHex, clampChroma } from 'culori';
4
+ const toOklch = converter('oklch');
5
+ export const NAMED_SHADES = [5, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
6
+
7
+ // Convert OKLCH values to a clamped 6-digit hex string
8
+ const oklchToHex = (l, c, h) => {
9
+ const clamped = clampChroma({
10
+ mode: 'oklch',
11
+ l,
12
+ c,
13
+ h: h ?? 0
14
+ }, 'oklch');
15
+ return formatHex({
16
+ mode: 'oklch',
17
+ ...clamped
18
+ });
19
+ };
20
+
21
+ /**
22
+ * Generate a tonal palette from a base hex (treated as the -60 shade).
23
+ * Returns Array<{shade, hex}> where shade is one of NAMED_SHADES.
24
+ */
25
+ export const generatePalette = (baseHex, mode = 'named') => {
26
+ const base = toOklch(baseHex);
27
+ if (!base) return [];
28
+ const {
29
+ l: L60,
30
+ c: C60,
31
+ h: H60
32
+ } = base;
33
+ const isNeutral = mode === 'neutral';
34
+ const L_MAX = 0.977;
35
+ const DARK_L_RATIO = isNeutral ? 0.45 : 0.55;
36
+ const DARK_C_RATIO = isNeutral ? 0.50 : 0.62;
37
+ const LIGHT_C_TARGET = isNeutral ? 0.004 : 0.008;
38
+ const isTypeB = !isNeutral && L60 > 0.70;
39
+ return NAMED_SHADES.map(shade => {
40
+ if (shade === 60) {
41
+ return {
42
+ shade,
43
+ hex: baseHex.toLowerCase()
44
+ };
45
+ }
46
+ let l, c;
47
+ if (shade < 60) {
48
+ const t = (60 - shade) / 55;
49
+ l = L60 + t * (L_MAX - L60);
50
+ c = C60 + t * (LIGHT_C_TARGET - C60);
51
+ } else {
52
+ const t = (shade - 60) / 40;
53
+ if (isTypeB) {
54
+ const L_MIN = Math.max(0.26, L60 * 0.33);
55
+ l = L60 + t * (L_MIN - L60);
56
+ c = C60 * (1 - t * 0.70);
57
+ } else {
58
+ l = L60 * (DARK_L_RATIO + (1 - DARK_L_RATIO) * (1 - t));
59
+ c = C60 * (DARK_C_RATIO + (1 - DARK_C_RATIO) * (1 - t));
60
+ }
61
+ }
62
+ if (isNeutral) {
63
+ c = Math.min(c, 0.05);
64
+ c = Math.max(c, 0.002);
65
+ }
66
+ const hex = oklchToHex(l, c, H60);
67
+ return {
68
+ shade,
69
+ hex
70
+ };
71
+ });
72
+ };
73
+
74
+ /**
75
+ * WCAG relative luminance for a hex colour.
76
+ */
77
+ const getRelativeLuminance = hex => {
78
+ const h = hex.replace('#', '');
79
+ const r = parseInt(h.slice(0, 2), 16) / 255;
80
+ const g = parseInt(h.slice(2, 4), 16) / 255;
81
+ const b = parseInt(h.slice(4, 6), 16) / 255;
82
+ const toLinear = c => c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
83
+ return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
84
+ };
85
+
86
+ /**
87
+ * WCAG contrast ratio between two hex colours (always ≥ 1).
88
+ */
89
+ const getContrastRatio = (hex1, hex2) => {
90
+ const L1 = getRelativeLuminance(hex1);
91
+ const L2 = getRelativeLuminance(hex2);
92
+ const lighter = Math.max(L1, L2);
93
+ const darker = Math.min(L1, L2);
94
+ return (lighter + 0.05) / (darker + 0.05);
95
+ };
96
+
97
+ /**
98
+ * Generate a random BASE hex colour suitable for the given mode.
99
+ * Validates WCAG contrast thresholds against the actual generated palette.
100
+ */
101
+ export const randomBaseColor = (mode = 'named') => {
102
+ const isNeutral = mode === 'neutral';
103
+ for (let attempt = 0; attempt < 200; attempt++) {
104
+ const h = Math.random() * 360;
105
+ const c = isNeutral ? 0.01 + Math.random() * 0.03 : 0.10 + Math.random() * 0.15;
106
+ if (isNeutral) {
107
+ const l = 0.35 + Math.random() * 0.20;
108
+ const candidateHex = oklchToHex(l, c, h);
109
+ const palette = generatePalette(candidateHex, mode);
110
+ if (!palette.length) continue;
111
+ const get = shade => palette.find(p => p.shade === shade)?.hex;
112
+ const s5 = get(5);
113
+ const s50 = get(50);
114
+ const s100 = get(100);
115
+ if (s5 && s50 && s100 && getContrastRatio(candidateHex, s5) >= 4.5 && getContrastRatio(s50, s100) >= 4.5) {
116
+ return candidateHex;
117
+ }
118
+ } else {
119
+ const isTypeB = Math.random() < 0.5;
120
+ const l = isTypeB ? 0.71 + Math.random() * 0.17 : 0.38 + Math.random() * 0.32;
121
+ const candidateHex = oklchToHex(l, c, h);
122
+ const palette = generatePalette(candidateHex, mode);
123
+ if (!palette.length) continue;
124
+ const get = shade => palette.find(p => p.shade === shade)?.hex;
125
+ if (isTypeB) {
126
+ const s5 = get(5);
127
+ const s70 = get(70);
128
+ const s80 = get(80);
129
+ if (s5 && s70 && s80 && getContrastRatio(s70, s5) >= 3.0 && getContrastRatio(s80, s5) >= 4.5) {
130
+ return candidateHex;
131
+ }
132
+ } else {
133
+ const s5 = get(5);
134
+ const s50 = get(50);
135
+ const s100 = get(100);
136
+ if (s5 && s50 && s100 && getContrastRatio(candidateHex, s5) >= 4.5 && getContrastRatio(s50, s100) >= 3.0) {
137
+ return candidateHex;
138
+ }
139
+ }
140
+ }
141
+ }
142
+ return isNeutral ? '#79716b' : '#dc2626';
143
+ };
@@ -0,0 +1,6 @@
1
+ // culori v4 ships no TypeScript declarations — declare it to suppress TS7016.
2
+ declare module 'culori' {
3
+ export function converter(mode: string): (color: string | object) => any;
4
+ export function formatHex(color: object): string;
5
+ export function clampChroma(color: object, mode: string): object;
6
+ }
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@tale-ui/utils",
3
- "version": "0.0.3",
3
+ "version": "0.1.1",
4
4
  "description": "A collection of React utility functions for Tale UI.",
5
5
  "repository": {
6
6
  "type": "git",
7
- "url": "git+https://github.com/Tale-UI/react.git",
7
+ "url": "git+https://github.com/Tale-UI/core.git",
8
8
  "directory": "packages/utils"
9
9
  },
10
10
  "license": "MIT",
@@ -12,6 +12,7 @@
12
12
  "dependencies": {
13
13
  "@babel/runtime": "^7.28.6",
14
14
  "@floating-ui/utils": "^0.2.10",
15
+ "culori": "^4.0.2",
15
16
  "reselect": "^5.1.1",
16
17
  "use-sync-external-store": "^1.6.0"
17
18
  },