@thinkpixellab-public/px-vue 4.1.23 → 4.1.24
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/composables/useCornerSmoothing.js +264 -0
- package/composables/useVariants.js +66 -11
- package/package.json +2 -1
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useCornerSmoothing()
|
|
3
|
+
*
|
|
4
|
+
* A Vue 3 composable for creating smooth, squircle-like corners using CSS clip-path.
|
|
5
|
+
* Based on Figma's corner smoothing approach.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* ```vue
|
|
9
|
+
* <script>
|
|
10
|
+
* import { ref } from 'vue'
|
|
11
|
+
* import { useCornerSmoothing } from '@thinkpixellab-public/px-vue/composables/useCornerSmoothing'
|
|
12
|
+
*
|
|
13
|
+
* export default {
|
|
14
|
+
* setup() {
|
|
15
|
+
* const myElement = ref(null)
|
|
16
|
+
* const { clipPath } = useCornerSmoothing(myElement, {
|
|
17
|
+
* radius: 16,
|
|
18
|
+
* smoothing: 0.8
|
|
19
|
+
* })
|
|
20
|
+
*
|
|
21
|
+
* return {
|
|
22
|
+
* myElement,
|
|
23
|
+
* clipPath // already reactive (computed)
|
|
24
|
+
* }
|
|
25
|
+
* }
|
|
26
|
+
* }
|
|
27
|
+
* </script>
|
|
28
|
+
*
|
|
29
|
+
* <template>
|
|
30
|
+
* <div
|
|
31
|
+
* ref="myElement"
|
|
32
|
+
* :style="{ clipPath }"
|
|
33
|
+
* >
|
|
34
|
+
* Content with smooth corners
|
|
35
|
+
* </div>
|
|
36
|
+
* </template>
|
|
37
|
+
* ```
|
|
38
|
+
*
|
|
39
|
+
* @param {element} elementRef Vue ref to the target element
|
|
40
|
+
* @param {number|Array|string} options.radius number, array [tl,tr,br,bl], or percentage string
|
|
41
|
+
* @param {number} options.smoothing smoothing amount 0-1 (default: 0.8)
|
|
42
|
+
* @param {number} options.borderWidth border width to account for (default: 0)
|
|
43
|
+
* @param {number} options.minRadius min radius in px (to constrain percentages)
|
|
44
|
+
* @param {number} options.maxRadius max radius in px (to constrain percentages)
|
|
45
|
+
* @param {boolean} options.autoApply Auto-apply clip-path to element style
|
|
46
|
+
* @param {boolean} options.preserveSmoothing Maintain smoothing at larger radii (See figma-squircle docs)
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
import { computed, onMounted, onUnmounted, nextTick, reactive, watch } from 'vue';
|
|
50
|
+
import { getSvgPath } from 'figma-squircle';
|
|
51
|
+
|
|
52
|
+
function useCornerSmoothing(elementRef, options = {}) {
|
|
53
|
+
const dimensions = reactive({ width: 0, height: 0 });
|
|
54
|
+
|
|
55
|
+
let resizeObserver = null;
|
|
56
|
+
|
|
57
|
+
const config = reactive({
|
|
58
|
+
radius: 8,
|
|
59
|
+
smoothing: 0.8,
|
|
60
|
+
borderWidth: 0,
|
|
61
|
+
minRadius: 0,
|
|
62
|
+
maxRadius: Infinity,
|
|
63
|
+
autoApply: false,
|
|
64
|
+
preserveSmoothing: true,
|
|
65
|
+
...options,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* parse percentage string to number
|
|
70
|
+
*/
|
|
71
|
+
function parsePercentage(value, reference) {
|
|
72
|
+
if (typeof value === 'string' && value.endsWith('%')) {
|
|
73
|
+
const percent = parseFloat(value) / 100;
|
|
74
|
+
return reference * percent;
|
|
75
|
+
}
|
|
76
|
+
return parseFloat(value) || 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* expand radius array using css border-radius rules
|
|
81
|
+
*/
|
|
82
|
+
function expandRadiusArray(radius) {
|
|
83
|
+
if (!Array.isArray(radius)) {
|
|
84
|
+
return [radius, radius, radius, radius];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const [tl, tr = tl, br = tl, bl = tr] = radius;
|
|
88
|
+
return [tl, tr, br, bl];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* calculate corner radii with percentage support and clamping
|
|
93
|
+
*/
|
|
94
|
+
function calculateCornerRadii(radius, width, height, minRadius, maxRadius) {
|
|
95
|
+
const reference = Math.max(width, height);
|
|
96
|
+
const expanded = expandRadiusArray(radius);
|
|
97
|
+
|
|
98
|
+
return expanded.map(r => {
|
|
99
|
+
const parsed = parsePercentage(r, reference);
|
|
100
|
+
return Math.max(minRadius, Math.min(maxRadius, parsed));
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* generates squircle path using figma-squircle library
|
|
106
|
+
*/
|
|
107
|
+
function generateSquirclePath(width, height, radii, smoothing, preserveSmoothing) {
|
|
108
|
+
if (width <= 0 || height <= 0) return '';
|
|
109
|
+
|
|
110
|
+
const [tl, tr, br, bl] = radii;
|
|
111
|
+
|
|
112
|
+
// check if all corners are the same (uniform)
|
|
113
|
+
const isUniform = tl === tr && tr === br && br === bl;
|
|
114
|
+
|
|
115
|
+
if (isUniform) {
|
|
116
|
+
// use simple API for uniform corners
|
|
117
|
+
return getSvgPath({
|
|
118
|
+
width,
|
|
119
|
+
height,
|
|
120
|
+
cornerRadius: tl,
|
|
121
|
+
cornerSmoothing: smoothing,
|
|
122
|
+
preserveSmoothing,
|
|
123
|
+
});
|
|
124
|
+
} else {
|
|
125
|
+
// use individual corner API for different corners
|
|
126
|
+
return getSvgPath({
|
|
127
|
+
width,
|
|
128
|
+
height,
|
|
129
|
+
cornerRadius: Math.max(tl, tr, br, bl), // fallback base radius
|
|
130
|
+
cornerSmoothing: smoothing,
|
|
131
|
+
preserveSmoothing,
|
|
132
|
+
topLeftCornerRadius: tl,
|
|
133
|
+
topRightCornerRadius: tr,
|
|
134
|
+
bottomRightCornerRadius: br,
|
|
135
|
+
bottomLeftCornerRadius: bl,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Computed clip-path value
|
|
142
|
+
*/
|
|
143
|
+
const clipPath = computed(() => {
|
|
144
|
+
if (dimensions.width <= 0 || dimensions.height <= 0) return null;
|
|
145
|
+
|
|
146
|
+
const adjustedWidth = Math.max(0, dimensions.width - config.borderWidth * 2);
|
|
147
|
+
const adjustedHeight = Math.max(0, dimensions.height - config.borderWidth * 2);
|
|
148
|
+
|
|
149
|
+
if (adjustedWidth <= 0 || adjustedHeight <= 0) return null;
|
|
150
|
+
|
|
151
|
+
// calculate corner radii with percentage support and clamping
|
|
152
|
+
const radii = calculateCornerRadii(
|
|
153
|
+
config.radius,
|
|
154
|
+
adjustedWidth,
|
|
155
|
+
adjustedHeight,
|
|
156
|
+
config.minRadius,
|
|
157
|
+
config.maxRadius,
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
const path = generateSquirclePath(
|
|
161
|
+
adjustedWidth,
|
|
162
|
+
adjustedHeight,
|
|
163
|
+
radii,
|
|
164
|
+
config.smoothing,
|
|
165
|
+
config.preserveSmoothing,
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
if (!path) return null;
|
|
169
|
+
|
|
170
|
+
// offset path if border width is specified
|
|
171
|
+
const offsetX = config.borderWidth;
|
|
172
|
+
const offsetY = config.borderWidth;
|
|
173
|
+
|
|
174
|
+
if (offsetX > 0 || offsetY > 0) {
|
|
175
|
+
// transform path to account for border offset
|
|
176
|
+
const translatedPath = path
|
|
177
|
+
.replace(/([ML])\s*([0-9.]+)\s+([0-9.]+)/g, (_, command, x, y) => {
|
|
178
|
+
return `${command} ${parseFloat(x) + offsetX} ${parseFloat(y) + offsetY}`;
|
|
179
|
+
})
|
|
180
|
+
.replace(
|
|
181
|
+
/([C])\s*([0-9.]+)\s+([0-9.]+)\s+([0-9.]+)\s+([0-9.]+)\s+([0-9.]+)\s+([0-9.]+)/g,
|
|
182
|
+
(_, command, x1, y1, x2, y2, x3, y3) => {
|
|
183
|
+
return `${command} ${parseFloat(x1) + offsetX} ${parseFloat(y1) + offsetY} ${parseFloat(x2) + offsetX} ${parseFloat(y2) + offsetY} ${parseFloat(x3) + offsetX} ${parseFloat(y3) + offsetY}`;
|
|
184
|
+
},
|
|
185
|
+
);
|
|
186
|
+
return `path('${translatedPath}')`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return `path('${path}')`;
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Update element dimensions
|
|
194
|
+
*/
|
|
195
|
+
function updateDimensions() {
|
|
196
|
+
if (!elementRef.value) return;
|
|
197
|
+
|
|
198
|
+
const rect = elementRef.value.getBoundingClientRect();
|
|
199
|
+
dimensions.width = rect.width;
|
|
200
|
+
dimensions.height = rect.height;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Apply clip-path directly to element style
|
|
205
|
+
*/
|
|
206
|
+
function applyClipPath() {
|
|
207
|
+
if (!elementRef.value || !config.autoApply) return;
|
|
208
|
+
|
|
209
|
+
if (clipPath.value) {
|
|
210
|
+
elementRef.value.style.clipPath = clipPath.value;
|
|
211
|
+
} else {
|
|
212
|
+
elementRef.value.style.clipPath = '';
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// watch for auto-apply changes
|
|
217
|
+
watch([clipPath, () => config.autoApply], applyClipPath, { immediate: true });
|
|
218
|
+
|
|
219
|
+
onMounted(() => {
|
|
220
|
+
nextTick(() => {
|
|
221
|
+
if (!elementRef.value) return;
|
|
222
|
+
|
|
223
|
+
// initial dimension update
|
|
224
|
+
updateDimensions();
|
|
225
|
+
|
|
226
|
+
// set up resize observer
|
|
227
|
+
resizeObserver = new ResizeObserver(entries => {
|
|
228
|
+
for (const entry of entries) {
|
|
229
|
+
dimensions.width = entry.contentRect.width;
|
|
230
|
+
dimensions.height = entry.contentRect.height;
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
resizeObserver.observe(elementRef.value);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
onUnmounted(() => {
|
|
239
|
+
resizeObserver?.disconnect();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Update configuration reactively
|
|
244
|
+
*/
|
|
245
|
+
function updateConfig(newConfig) {
|
|
246
|
+
Object.assign(config, newConfig);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Manual update trigger
|
|
251
|
+
*/
|
|
252
|
+
function forceUpdate() {
|
|
253
|
+
updateDimensions();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
clipPath,
|
|
258
|
+
updateConfig,
|
|
259
|
+
forceUpdate,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export { useCornerSmoothing };
|
|
264
|
+
export { useCornerSmoothing as default };
|
|
@@ -1,5 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useVariants()
|
|
3
|
+
*
|
|
4
|
+
* Handles component variants as a replacement for PxBaseVariants. Provides reactive variant
|
|
5
|
+
* processing with support for defaults and base variants.
|
|
6
|
+
*
|
|
7
|
+
* Usage with Options API setup():
|
|
8
|
+
*
|
|
9
|
+
* ```vue
|
|
10
|
+
* <script>
|
|
11
|
+
* import useVariants from '@thinkpixellab-public/px-vue/composables/useVariants'
|
|
12
|
+
* import { inject, computed } from 'vue'
|
|
13
|
+
*
|
|
14
|
+
* export default {
|
|
15
|
+
* props: {
|
|
16
|
+
* variant: { type: String, default: null },
|
|
17
|
+
* },
|
|
18
|
+
* setup(props) {
|
|
19
|
+
* const fallback = inject('defaultVariant', null)
|
|
20
|
+
*
|
|
21
|
+
* const { variantsMap, isVariant, variantsToValue } = useVariants(
|
|
22
|
+
* props.variant, // main variant prop
|
|
23
|
+
* fallback, // default/fallback variant
|
|
24
|
+
* 'base-class' // base variant (optional)
|
|
25
|
+
* )
|
|
26
|
+
*
|
|
27
|
+
* const isSpecial = computed(() => isVariant('special'))
|
|
28
|
+
* const buttonColor = variantsToValue({ primary: 'blue', secondary: 'gray' }, 'default')
|
|
29
|
+
*
|
|
30
|
+
* return {
|
|
31
|
+
* variantsMap, // for :class="bem(variantsMap)"
|
|
32
|
+
* isSpecial,
|
|
33
|
+
* buttonColor,
|
|
34
|
+
* }
|
|
35
|
+
* }
|
|
36
|
+
* }
|
|
37
|
+
* </script>
|
|
38
|
+
*
|
|
39
|
+
* <template>
|
|
40
|
+
* <div :class="bem(variantsMap)">
|
|
41
|
+
* Content with variants applied
|
|
42
|
+
* </div>
|
|
43
|
+
* </template>
|
|
44
|
+
* ```
|
|
45
|
+
*
|
|
46
|
+
* @param {string|Array|Ref} variant Main variant value (reactive)
|
|
47
|
+
* @param {string|Array|Ref} variantDefault Default variant when main is empty (reactive)
|
|
48
|
+
* @param {string|Array|Ref} variantBase Base variant always included (reactive)
|
|
49
|
+
* @returns {Object} { variants, variantArray, variantString, variantsMap, isVariant,
|
|
50
|
+
* variantsToValue }
|
|
51
|
+
*/
|
|
52
|
+
|
|
1
53
|
import { computed, toValue } from 'vue';
|
|
2
|
-
import
|
|
54
|
+
import variantsUtils from '../utils/variants.js';
|
|
3
55
|
import { arrayHasCommonItem, ensureArray } from '../utils/utils.js';
|
|
4
56
|
|
|
5
57
|
export default function useVariants(variant, variantDefault, variantBase) {
|
|
@@ -11,12 +63,12 @@ export default function useVariants(variant, variantDefault, variantBase) {
|
|
|
11
63
|
* Returns the variant as an array. This is also the primary source of state for the variant and
|
|
12
64
|
* should be used by all other methods, etc.
|
|
13
65
|
*/
|
|
14
|
-
const
|
|
15
|
-
const variantArray =
|
|
16
|
-
const baseArray =
|
|
17
|
-
const defaultArray =
|
|
66
|
+
const variantArray = computed(() => {
|
|
67
|
+
const variantArray = variantsUtils.toVariantsArray(toValue(variant));
|
|
68
|
+
const baseArray = variantsUtils.toVariantsArray(toValue(variantBase));
|
|
69
|
+
const defaultArray = variantsUtils.toVariantsArray(toValue(variantDefault));
|
|
18
70
|
|
|
19
|
-
return
|
|
71
|
+
return variantsUtils.toVariantsArray([
|
|
20
72
|
...baseArray,
|
|
21
73
|
...(variantArray.length ? variantArray : defaultArray),
|
|
22
74
|
]);
|
|
@@ -26,7 +78,7 @@ export default function useVariants(variant, variantDefault, variantBase) {
|
|
|
26
78
|
* Returns the variant as a string
|
|
27
79
|
*/
|
|
28
80
|
const variantString = computed(() => {
|
|
29
|
-
return
|
|
81
|
+
return variantArray.value.join(' ');
|
|
30
82
|
});
|
|
31
83
|
|
|
32
84
|
/**
|
|
@@ -34,9 +86,12 @@ export default function useVariants(variant, variantDefault, variantBase) {
|
|
|
34
86
|
* methods, etc.)
|
|
35
87
|
*/
|
|
36
88
|
const variantsMap = computed(() => {
|
|
37
|
-
return
|
|
89
|
+
return variantsUtils.toVariantsMap(variantArray.value);
|
|
38
90
|
});
|
|
39
91
|
|
|
92
|
+
// alias for backwards compatibility with PxBaseVariants
|
|
93
|
+
const variants = variantArray;
|
|
94
|
+
|
|
40
95
|
// -------------------------------------------------------------------------------
|
|
41
96
|
// Methods
|
|
42
97
|
// -------------------------------------------------------------------------------
|
|
@@ -46,7 +101,7 @@ export default function useVariants(variant, variantDefault, variantBase) {
|
|
|
46
101
|
*/
|
|
47
102
|
const isVariant = variantStringOrArray => {
|
|
48
103
|
const array = ensureArray(variantStringOrArray);
|
|
49
|
-
return arrayHasCommonItem(
|
|
104
|
+
return arrayHasCommonItem(variantArray.value, array);
|
|
50
105
|
};
|
|
51
106
|
|
|
52
107
|
/**
|
|
@@ -56,12 +111,12 @@ export default function useVariants(variant, variantDefault, variantBase) {
|
|
|
56
111
|
*/
|
|
57
112
|
const variantsToValue = (valueMap, fallback) => {
|
|
58
113
|
for (let key in valueMap) {
|
|
59
|
-
if (
|
|
114
|
+
if (isVariant(key)) {
|
|
60
115
|
return valueMap[key];
|
|
61
116
|
}
|
|
62
117
|
}
|
|
63
118
|
return fallback;
|
|
64
119
|
};
|
|
65
120
|
|
|
66
|
-
return { variants, variantString, variantsMap, isVariant, variantsToValue };
|
|
121
|
+
return { variants, variantArray, variantString, variantsMap, isVariant, variantsToValue };
|
|
67
122
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thinkpixellab-public/px-vue",
|
|
3
|
-
"version": "4.1.
|
|
3
|
+
"version": "4.1.24",
|
|
4
4
|
"description": "General purpose Vue components and helpers that can be used across projects.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Pixel Lab"
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"body-scroll-lock": "^3.1.5",
|
|
29
29
|
"chrono-node": "^2.7.8",
|
|
30
30
|
"dateformat": "^5.0.3",
|
|
31
|
+
"figma-squircle": "^1.1.0",
|
|
31
32
|
"gsap": "^3.12.7",
|
|
32
33
|
"highlight.js": "^11.11.1",
|
|
33
34
|
"mitt": "^3.0.1",
|