Skip to content

Commit 1902ff7

Browse files
authored
Merge pull request #233 from graphieros/trunk
Trunk
2 parents 738e414 + 60a8b34 commit 1902ff7

File tree

12 files changed

+752
-20
lines changed

12 files changed

+752
-20
lines changed

README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1079,3 +1079,46 @@ Below is a table of the places where such line breaks can be used:
10791079
## PDF generation
10801080

10811081
This package requires jspdf as a peer dependency. Please install it in your project if you intend on using the PDF printing feature.
1082+
1083+
## `useObjectBindings`
1084+
1085+
A composable that **flattens** a reactive object into a set of computed refs—one for each “leaf” property—so you can easily bind to deeply nested values by their string paths.
1086+
1087+
### Why use it?
1088+
1089+
- **Automatic reactivity**: Every nested property becomes a `ComputedRef`, with automatic getters/setters that keep your source object in sync.
1090+
- **Flat API surface**: Access or update any nested field by its dot‑delimited path, without writing deep destructuring or `ref` plumbing.
1091+
- **Dynamic path support**: New paths added at runtime are discovered automatically (and proxied), so you never lose reactivity when mutating the structure.
1092+
1093+
```js
1094+
import { useObjectBindings } from "vue-data-ui";
1095+
import type { Ref, ComputedRef } from "vue";
1096+
1097+
const config = ref({
1098+
customPalette: ["#CCCCCC", "#1A1A1A"],
1099+
style: {
1100+
chart: {
1101+
backgroundColor: "#FFFFFF",
1102+
color: "#1A1A1A",
1103+
},
1104+
},
1105+
});
1106+
1107+
const bindings = useObjectBindings(config);
1108+
// Now `bindings` has computed refs for each leaf path:
1109+
// bindings["style.chart.backgroundColor"] → ComputedRef<string>
1110+
// bindings["style.chart.color"] → ComputedRef<string>
1111+
// bindings["customPalette"] → ComputedRef<boolean>
1112+
// // by default, arrays are skipped:
1113+
// // no "customPalette.0", unless you disable skipArrays
1114+
```
1115+
1116+
You can then use these in your template:
1117+
1118+
```html
1119+
<template>
1120+
<div>
1121+
<input type="color" v-model="bindings['style.chart.backgroundColor']" />
1122+
</div>
1123+
</template>
1124+
```

TestingArena/ArenaVueUiXy.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -376,8 +376,8 @@ const model = ref([
376376
{ key: 'useCanvas', def: false, type: 'checkbox' }, // DEPRECATED (removed)
377377
{ key: 'useCssAnimation', def: true, type: 'checkbox', label: 'useCssAnimation', category: 'general' },
378378
{ key: 'chart.fontFamily', def: 'inherit', type: 'text', label: 'fontFamily', category: 'general' },
379-
{ key: 'chart.backgroundColor', def: 'transparent', type: 'color', label: 'backgroundColor', category: 'general' },
380-
{ key: 'chart.color', def: '#FFFFFF', type: 'color', label: 'textColor', category: 'general' },
379+
{ key: 'chart.backgroundColor', def: '#FFF', type: 'color', label: 'backgroundColor', category: 'general' },
380+
{ key: 'chart.color', def: '#111', type: 'color', label: 'textColor', category: 'general' },
381381
{ key: 'chart.height', def: 600, type: 'range', min: 300, max: 1000, label: 'height', category: 'general' },
382382
{ key: 'chart.width', def: 1000, type: 'range', min: 300, max: 2000, label: 'width', category: 'general' },
383383
{ key: 'chart.zoom.show', def: true, type: 'checkbox', label: 'zoom', category: 'general' },

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "vue-data-ui",
33
"private": false,
4-
"version": "2.15.5",
4+
"version": "2.15.6-beta.3",
55
"type": "module",
66
"description": "A user-empowering data visualization Vue 3 components library for eloquent data storytelling",
77
"keywords": [

src/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import getVueDataUiConfig from "./getVueDataUiConfig";
33
import getThemeConfig from "./getThemeConfig";
44
import { getPalette, createWordCloudDatasetFromPlainText, abbreviate, createTSpans, createStraightPath, createSmoothPath, getCumulativeAverage, getCumulativeMedian } from "./lib";
55
import { lightenColor, darkenColor, shiftColorHue, mergeConfigs } from "./exposedLib";
6+
import { useObjectBindings } from "./useObjectBindings";
67

78
export const Arrow = defineAsyncComponent(() => import("./atoms/Arrow.vue"))
89
export const VueDataUi = defineAsyncComponent(() => import("./components/vue-data-ui.vue"))
@@ -85,4 +86,5 @@ export {
8586
lightenColor,
8687
mergeConfigs,
8788
shiftColorHue,
89+
useObjectBindings
8890
}

src/lib.js

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -393,19 +393,25 @@ export const opacity = ["00", "03", "05", "08", "0A", "0D", "0F", "12", "14", "1
393393

394394
export function convertColorToHex(color) {
395395
const hexRegex = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})?$/i;
396+
const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])([a-f\d])?$/i;
396397
const rgbRegex = /^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)$/i;
397398
const hslRegex = /^hsla?\((\d+),\s*([\d.]+)%,\s*([\d.]+)%(?:,\s*([\d.]+))?\)$/i;
398399

399-
if ([undefined, null, NaN].includes(color)) {
400+
if (color === undefined || color === null || (typeof color === 'number' && isNaN(color))) {
400401
return null;
401402
}
402403

403404
color = convertNameColorToHex(color);
404405

406+
405407
if (color === 'transparent') {
406408
return "#FFFFFF00";
407409
}
408410

411+
color = color.replace(shorthandRegex, (_, r, g, b, a) => {
412+
return `#${r}${r}${g}${g}${b}${b}${a ? a + a : ''}`;
413+
});
414+
409415
let match;
410416
let alpha = 1;
411417

@@ -420,8 +426,8 @@ export function convertColorToHex(color) {
420426
} else if ((match = color.match(hslRegex))) {
421427
const [, h, s, l, a] = match;
422428
alpha = a ? parseFloat(a) : 1;
423-
const rgb = hslToRgba(Number(h), Number(s), Number(l));
424-
return `#${decimalToHex(rgb[0])}${decimalToHex(rgb[1])}${decimalToHex(rgb[2])}${decimalToHex(Math.round(alpha * 255))}`;
429+
const [rr, gg, bb] = hslToRgba(Number(h), Number(s), Number(l));
430+
return `#${decimalToHex(rr)}${decimalToHex(gg)}${decimalToHex(bb)}${decimalToHex(Math.round(alpha * 255))}`;
425431
}
426432

427433
return null;

src/useConfig.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5605,6 +5605,7 @@ export function useConfig() {
56055605
buttons: {
56065606
...vue_ui_xy.chart.userOptions.buttons,
56075607
pdf: false,
5608+
fullscreen: false,
56085609
}
56095610
},
56105611
zoom: {
@@ -5836,4 +5837,4 @@ export function useConfig() {
58365837
vue_ui_digits,
58375838
vue_ui_circle_pack
58385839
}
5839-
}
5840+
}

src/useObjectBindings.js

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { computed, isRef, markRaw, watch, watchEffect } from 'vue';
2+
3+
/**
4+
* Recursively extract all dot-paths to leaf values in an object.
5+
* Skips arrays entirely when skipArrays = true.
6+
*
7+
* @param {unknown} obj
8+
* @param {string[]} [current=[]]
9+
* @param {boolean} [skipArrays=true]
10+
* @returns {string[][]}
11+
*/
12+
export function extractAllPaths(obj, current = [], skipArrays = true) {
13+
/** @type {string[][]} */
14+
const paths = [];
15+
16+
if (obj && typeof obj === 'object') {
17+
if (Array.isArray(obj) && skipArrays) {
18+
return [];
19+
}
20+
for (const key of Object.keys(obj)) {
21+
const val = /** @type {any} */ (obj)[key];
22+
if (Array.isArray(val) && skipArrays) {
23+
continue;
24+
}
25+
const next = current.concat(key);
26+
if (val && typeof val === 'object') {
27+
paths.push(...extractAllPaths(val, next, skipArrays));
28+
} else {
29+
paths.push(next);
30+
}
31+
}
32+
}
33+
return paths;
34+
}
35+
36+
/**
37+
* Get nested value by path.
38+
* @param {any} obj
39+
* @param {string[]} path
40+
* @returns {any}
41+
*/
42+
export function getValue(obj, path) {
43+
return path.reduce((o, k) => o?.[k], obj);
44+
}
45+
46+
/**
47+
* Set nested value by path, creating intermediate objects as needed.
48+
* @param {any} obj
49+
* @param {string[]} path
50+
* @param {any} value
51+
*/
52+
export function setValue(obj, path, value) {
53+
let target = isRef(obj) ? obj.value : obj;
54+
for (let i = 0; i < path.length - 1; i += 1) {
55+
const key = path[i];
56+
if (
57+
!Object.prototype.hasOwnProperty.call(target, key) ||
58+
typeof target[key] !== 'object'
59+
) {
60+
target[key] = {};
61+
}
62+
target = target[key];
63+
}
64+
target[path[path.length - 1]] = value;
65+
}
66+
67+
/**
68+
* Set nested property on an object by a dot-delimited path, creating intermediate
69+
* objects as needed. Similar to setValue but accepts a string path.
70+
*
71+
* @param {object} obj - The object to modify.
72+
* @param {string} path - Dot-delimited string path.
73+
* @param {*} value - The value to set at the target path.
74+
* @param {string} delimiter - The delimiter used to split the path.
75+
*/
76+
function setPropertyByPath(obj, path, value, delimiter) {
77+
const keys = path.split(delimiter);
78+
let current = obj;
79+
for (let i = 0; i < keys.length - 1; i += 1) {
80+
const key = keys[i];
81+
if (!current[key]) {
82+
current[key] = {};
83+
}
84+
current = current[key];
85+
}
86+
current[keys[keys.length - 1]] = value;
87+
}
88+
89+
export function useObjectBindings(configRef, options) {
90+
const { delimiter = '.', skipArrays = true } = options || {};
91+
const bindings = {};
92+
93+
function build() {
94+
Object.keys(bindings).forEach((k) => delete bindings[k]);
95+
const paths = extractAllPaths(configRef.value, [], skipArrays);
96+
for (const path of paths) {
97+
const key = path.join(delimiter)
98+
bindings[key] = computed({
99+
get: () => getValue(configRef.value, path),
100+
set: (val) => setValue(configRef.value, path, val),
101+
});
102+
}
103+
}
104+
105+
watchEffect(build);
106+
build();
107+
108+
const handler = {
109+
get(target, prop) {
110+
// let Vue's private props and symbols through
111+
if (typeof prop === 'string' || prop.startsWith('__v_')) {
112+
if (prop in target) {
113+
return Reflect.get(target, prop);
114+
} else {
115+
// prop doesn't exist on target, add it and return
116+
setPropertyByPath(configRef.value, prop, undefined, delimiter);
117+
bindings[prop] = computed({
118+
get: () => getValue(configRef.value, prop),
119+
set: (val) => setValue(configRef.value, prop, val)
120+
});
121+
if (!prop.startsWith('__v_')) {
122+
console.warn(`Vue Data UI - useObjectBindings: no binding found for key "${prop}". Please verify you are binding to a property path which exists on the object.`);
123+
}
124+
return ''; // Signals to Vue there is something to be tracked, so to hand the computed on the next read
125+
}
126+
}
127+
return true;
128+
},
129+
set(target, prop, value) {
130+
if (typeof prop === 'string' || prop.startsWith('__v_')) {
131+
if (prop in target) {
132+
return Reflect.set(target, prop, value);
133+
} else {
134+
// prop doesn't exist on target, add it and return
135+
setPropertyByPath(configRef.value, prop, value, delimiter);
136+
bindings[prop] = computed({
137+
get: () => getValue(configRef.value, prop),
138+
set: (val) => setValue(configRef.value, prop, val)
139+
});
140+
if(!prop.startsWith('__v_')) {
141+
console.warn(`Vue Data UI - useObjectBindings: cannot set unknown binding "${prop}".`);
142+
}
143+
return true;
144+
}
145+
}
146+
return true;
147+
}
148+
};
149+
150+
return markRaw(new Proxy(bindings, handler));
151+
}

tests/lib.test.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,23 @@ describe("convertColorToHex", () => {
399399
const result = convertColorToHex("hsla(0, 100%, 50%, 0.5)");
400400
expect(result).toBe("#ff000080");
401401
});
402+
403+
test("returns HEX color format from shorthand hex codes (#RGB, #RGBA)", () => {
404+
expect(convertColorToHex("#ABC")).toBe("#AABBCCff");
405+
expect(convertColorToHex("ABC")).toBe("#AABBCCff");
406+
expect(convertColorToHex("#abc")).toBe("#aabbccff");
407+
expect(convertColorToHex("abc")).toBe("#aabbccff");
408+
expect(convertColorToHex("#ABC8")).toBe("#AABBCC88");
409+
expect(convertColorToHex("ABC8")).toBe("#AABBCC88");
410+
expect(convertColorToHex("#abc8")).toBe("#aabbcc88");
411+
expect(convertColorToHex("abc8")).toBe("#aabbcc88");
412+
});
413+
414+
test("returns null for invalid or empty input", () => {
415+
expect(convertColorToHex(null)).toBeNull();
416+
expect(convertColorToHex(undefined)).toBeNull();
417+
expect(convertColorToHex("not-a-color")).toBeNull();
418+
});
402419
});
403420

404421
describe("shiftHue", () => {

0 commit comments

Comments
 (0)