@thinkpixellab-public/px-vue 4.1.23 → 4.1.26
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/bases/PxBaseItemsSelect.vue +7 -3
- package/components/PxCard.vue +1 -1
- package/components/PxCorners.vue +233 -0
- package/components/PxDatePicker.vue +1 -1
- package/components/PxMenu.vue +2 -2
- package/components/PxScrubber.vue +8 -8
- package/composables/useCornerSmoothing.js +253 -0
- package/composables/useVariants.js +66 -11
- package/package.json +2 -1
- package/stories/PxCorners.stories.js +187 -0
- package/storybook/sb-styles.scss +11 -6
- package/utils/smootherCorners.js +395 -0
|
@@ -139,15 +139,19 @@ export default {
|
|
|
139
139
|
immediate: true,
|
|
140
140
|
handler(nv, ov) {
|
|
141
141
|
// check for matching values
|
|
142
|
-
|
|
142
|
+
const nvArray = ensureSet(nv);
|
|
143
|
+
const ovArray = ensureSet(ov);
|
|
144
|
+
|
|
145
|
+
if (nvArray.length === ovArray.length) {
|
|
143
146
|
let match = true;
|
|
144
|
-
for (let i = 0; i <
|
|
145
|
-
match = match &&
|
|
147
|
+
for (let i = 0; i < nvArray.length; i++) {
|
|
148
|
+
match = match && nvArray[i] === ovArray[i];
|
|
146
149
|
}
|
|
147
150
|
if (match) {
|
|
148
151
|
return;
|
|
149
152
|
}
|
|
150
153
|
}
|
|
154
|
+
|
|
151
155
|
this.setSelection(nv);
|
|
152
156
|
},
|
|
153
157
|
},
|
package/components/PxCard.vue
CHANGED
|
@@ -21,7 +21,7 @@ export default {
|
|
|
21
21
|
.px-card {
|
|
22
22
|
/// Configuration defautls for px-calendar
|
|
23
23
|
$-defaults: (
|
|
24
|
-
'px-card.background-color':
|
|
24
|
+
'px-card.background-color': site-var(page-bg),
|
|
25
25
|
'px-card.padding': 1rem,
|
|
26
26
|
'px-card.border-radius': 0.5rem,
|
|
27
27
|
'px-card.box-shadow': depth-shadow(20, 0.1, black),
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Describe this component...
|
|
3
|
+
-->
|
|
4
|
+
<template>
|
|
5
|
+
<div
|
|
6
|
+
v-if="smoothingEnabled"
|
|
7
|
+
:class="bem()"
|
|
8
|
+
ref="el"
|
|
9
|
+
:style="{
|
|
10
|
+
position: 'relative',
|
|
11
|
+
clipPath: renderMode === 'clip' ? smoothCornersPath : null,
|
|
12
|
+
mask: renderMode === 'mask' ? smoothCornersPath : null,
|
|
13
|
+
...overrideStyles,
|
|
14
|
+
}"
|
|
15
|
+
>
|
|
16
|
+
<div
|
|
17
|
+
v-if="hasBorder"
|
|
18
|
+
:style="{
|
|
19
|
+
position: 'absolute',
|
|
20
|
+
inset: 0,
|
|
21
|
+
clipPath: renderMode === 'clip' ? borderMaskPath : null,
|
|
22
|
+
mask: renderMode === 'mask' ? borderMaskPath : null,
|
|
23
|
+
backgroundColor: computedBorderColor,
|
|
24
|
+
touchAction: 'none',
|
|
25
|
+
}"
|
|
26
|
+
></div>
|
|
27
|
+
|
|
28
|
+
<slot />
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<div v-else>
|
|
32
|
+
<slot />
|
|
33
|
+
</div>
|
|
34
|
+
</template>
|
|
35
|
+
|
|
36
|
+
<script>
|
|
37
|
+
import { getSmoothCornersPath, getBorderMaskClipPath } from '../utils/smootherCorners';
|
|
38
|
+
|
|
39
|
+
export default {
|
|
40
|
+
name: 'px-corners',
|
|
41
|
+
props: {
|
|
42
|
+
smoothingEnabled: { type: Boolean, default: true },
|
|
43
|
+
smoothing: { type: Number, default: 1 },
|
|
44
|
+
maxRadius: { type: [Number, String], default: Infinity },
|
|
45
|
+
minRadius: { type: [Number, String], default: 0 },
|
|
46
|
+
radius: { type: [Array, Number, String], default: 'auto' },
|
|
47
|
+
borderWidth: { type: [Number, String], default: 'auto' },
|
|
48
|
+
borderColor: { type: String, default: 'auto' },
|
|
49
|
+
|
|
50
|
+
// clip or mask
|
|
51
|
+
renderMode: { type: String, default: 'clip' },
|
|
52
|
+
},
|
|
53
|
+
data() {
|
|
54
|
+
return {
|
|
55
|
+
dimensions: {
|
|
56
|
+
width: 0,
|
|
57
|
+
height: 0,
|
|
58
|
+
},
|
|
59
|
+
computedRadius: null,
|
|
60
|
+
computedBorderWidth: 0,
|
|
61
|
+
computedBorderColor: null,
|
|
62
|
+
resizeObserver: null,
|
|
63
|
+
|
|
64
|
+
// flag that we've detected auto styles
|
|
65
|
+
autoStylesDetected: false,
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
computed: {
|
|
69
|
+
smoothCornersPath() {
|
|
70
|
+
if (
|
|
71
|
+
!this.smoothingEnabled ||
|
|
72
|
+
this.dimensions.width <= 0 ||
|
|
73
|
+
this.dimensions.height <= 0
|
|
74
|
+
) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return getSmoothCornersPath({
|
|
79
|
+
width: this.dimensions.width,
|
|
80
|
+
height: this.dimensions.height,
|
|
81
|
+
radius: this.computedRadius || this.radius,
|
|
82
|
+
smoothing: this.smoothing,
|
|
83
|
+
borderWidth: 0,
|
|
84
|
+
minRadius: this.minRadius,
|
|
85
|
+
maxRadius: this.maxRadius,
|
|
86
|
+
preserveSmoothing: true,
|
|
87
|
+
renderMode: this.renderMode,
|
|
88
|
+
});
|
|
89
|
+
},
|
|
90
|
+
borderMaskPath() {
|
|
91
|
+
if (
|
|
92
|
+
!this.smoothingEnabled ||
|
|
93
|
+
this.dimensions.width <= 0 ||
|
|
94
|
+
this.dimensions.height <= 0 ||
|
|
95
|
+
this.computedBorderWidth <= 0
|
|
96
|
+
) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return getBorderMaskClipPath({
|
|
101
|
+
width: this.dimensions.width,
|
|
102
|
+
height: this.dimensions.height,
|
|
103
|
+
radius: this.computedRadius || this.radius,
|
|
104
|
+
smoothing: this.smoothing,
|
|
105
|
+
borderWidth: this.computedBorderWidth,
|
|
106
|
+
minRadius: this.minRadius,
|
|
107
|
+
maxRadius: this.maxRadius,
|
|
108
|
+
preserveSmoothing: true,
|
|
109
|
+
renderMode: this.renderMode,
|
|
110
|
+
});
|
|
111
|
+
},
|
|
112
|
+
hasBorder() {
|
|
113
|
+
return !!this.borderMaskPath;
|
|
114
|
+
},
|
|
115
|
+
overrideStyles() {
|
|
116
|
+
if (!this.smoothingEnabled || !this.autoStylesDetected) return {};
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
borderRadius: 'unset',
|
|
120
|
+
borderTopLeftRadius: 'unset',
|
|
121
|
+
borderTopRightRadius: 'unset',
|
|
122
|
+
borderBottomRightRadius: 'unset',
|
|
123
|
+
borderBottomLeftRadius: 'unset',
|
|
124
|
+
border: 'unset',
|
|
125
|
+
borderWidth: 'unset',
|
|
126
|
+
borderStyle: 'unset',
|
|
127
|
+
borderColor: 'unset',
|
|
128
|
+
};
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
mounted() {
|
|
132
|
+
this.$nextTick(() => {
|
|
133
|
+
if (!this.$refs.el) return;
|
|
134
|
+
if (!this.smoothingEnabled) return;
|
|
135
|
+
|
|
136
|
+
this.detectAutoValues();
|
|
137
|
+
this.updateDimensions();
|
|
138
|
+
this.setupResizeObserver();
|
|
139
|
+
});
|
|
140
|
+
},
|
|
141
|
+
beforeUnmount() {
|
|
142
|
+
if (this.resizeObserver) {
|
|
143
|
+
this.resizeObserver.disconnect();
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
methods: {
|
|
147
|
+
parseCssDimension(value) {
|
|
148
|
+
if (typeof value === 'string' && value.length > 0) {
|
|
149
|
+
value = value.trim().split(/\s+/)[0];
|
|
150
|
+
|
|
151
|
+
if (value.endsWith('px')) return parseFloat(value);
|
|
152
|
+
if (value.endsWith('rem')) {
|
|
153
|
+
return (
|
|
154
|
+
parseFloat(value) *
|
|
155
|
+
parseFloat(getComputedStyle(document.documentElement).fontSize)
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
if (value.endsWith('em')) {
|
|
159
|
+
return parseFloat(value) * parseFloat(getComputedStyle(this.$refs.el).fontSize);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return value;
|
|
164
|
+
},
|
|
165
|
+
detectAutoValues() {
|
|
166
|
+
if (!this.$refs.el) return;
|
|
167
|
+
|
|
168
|
+
const cs = getComputedStyle(this.$refs.el);
|
|
169
|
+
|
|
170
|
+
// auto radius detection
|
|
171
|
+
if (this.radius === 'auto') {
|
|
172
|
+
const radiusTopLeft = cs.getPropertyValue('border-top-left-radius') || '0';
|
|
173
|
+
const radiusTopRight = cs.getPropertyValue('border-top-right-radius') || '0';
|
|
174
|
+
const radiusBottomRight = cs.getPropertyValue('border-bottom-right-radius') || '0';
|
|
175
|
+
const radiusBottomLeft = cs.getPropertyValue('border-bottom-left-radius') || '0';
|
|
176
|
+
|
|
177
|
+
this.computedRadius = [
|
|
178
|
+
radiusTopLeft,
|
|
179
|
+
radiusTopRight,
|
|
180
|
+
radiusBottomRight,
|
|
181
|
+
radiusBottomLeft,
|
|
182
|
+
].map(r => this.parseCssDimension(r));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// auto border width detection
|
|
186
|
+
if (this.borderWidth === 'auto') {
|
|
187
|
+
// try border-width first, fallback to border-top-width
|
|
188
|
+
let borderWidth = cs.getPropertyValue('border-width') || '0';
|
|
189
|
+
if (borderWidth === '0' || borderWidth === '0px') {
|
|
190
|
+
borderWidth = cs.getPropertyValue('border-top-width') || '0';
|
|
191
|
+
}
|
|
192
|
+
this.computedBorderWidth = this.parseCssDimension(borderWidth);
|
|
193
|
+
} else {
|
|
194
|
+
this.computedBorderWidth = parseFloat(this.borderWidth) || 0;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// auto border color detection
|
|
198
|
+
if (this.borderColor === 'auto') {
|
|
199
|
+
this.computedBorderColor = cs.getPropertyValue('border-color') || 'transparent';
|
|
200
|
+
} else {
|
|
201
|
+
this.computedBorderColor = this.borderColor;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// flag that we've detected styles, now we can apply overrides
|
|
205
|
+
this.autoStylesDetected = true;
|
|
206
|
+
},
|
|
207
|
+
updateDimensions() {
|
|
208
|
+
if (!this.$refs.el) return;
|
|
209
|
+
|
|
210
|
+
const rect = this.$refs.el.getBoundingClientRect();
|
|
211
|
+
this.dimensions.width = rect.width;
|
|
212
|
+
this.dimensions.height = rect.height;
|
|
213
|
+
},
|
|
214
|
+
setupResizeObserver() {
|
|
215
|
+
if (!this.$refs.el) return;
|
|
216
|
+
|
|
217
|
+
this.resizeObserver = new ResizeObserver(entries => {
|
|
218
|
+
for (const entry of entries) {
|
|
219
|
+
this.dimensions.width = entry.contentRect.width;
|
|
220
|
+
this.dimensions.height = entry.contentRect.height;
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
this.resizeObserver.observe(this.$refs.el);
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
</script>
|
|
229
|
+
|
|
230
|
+
<!-- <style lang="scss" scoped>
|
|
231
|
+
// @use '@/styles/include.scss' as *;
|
|
232
|
+
@use '../styles/px.scss' as *;
|
|
233
|
+
</style> -->
|
|
@@ -190,7 +190,7 @@ export default {
|
|
|
190
190
|
$popup: get(
|
|
191
191
|
'controls.popup',
|
|
192
192
|
(
|
|
193
|
-
background-color:
|
|
193
|
+
background-color: site-var(page-bg),
|
|
194
194
|
border-radius: get('controls.base.border-radius'),
|
|
195
195
|
box-shadow: shadow(15),
|
|
196
196
|
font-size: get('controls.base.font-size'),
|
package/components/PxMenu.vue
CHANGED
|
@@ -198,8 +198,8 @@ export {
|
|
|
198
198
|
|
|
199
199
|
.px-menu {
|
|
200
200
|
font-family: get('font-family');
|
|
201
|
-
background-color:
|
|
202
|
-
color:
|
|
201
|
+
background-color: site-var(page-bg);
|
|
202
|
+
color: site-var(page-fg);
|
|
203
203
|
border-radius: get('controls.border-radius');
|
|
204
204
|
overflow: hidden;
|
|
205
205
|
display: flex;
|
|
@@ -121,15 +121,15 @@ function mouseMove(e) {
|
|
|
121
121
|
<style lang="scss">
|
|
122
122
|
@use '../styles/px.scss' as *;
|
|
123
123
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
124
|
+
@at-root :root {
|
|
125
|
+
--px-scrubber-height: 4px;
|
|
126
|
+
--px-scrubber-bg: #{clr(accent, $alpha: 0.1)};
|
|
127
|
+
--px-scrubber-padding: 1em 0;
|
|
128
|
+
--px-scrubber-progress-bg: #{clr(accent, 1)};
|
|
129
|
+
--px-scrubber-preview-bg: #{clr(accent, $alpha: 0.33)};
|
|
130
|
+
}
|
|
132
131
|
|
|
132
|
+
.px-scrubber {
|
|
133
133
|
width: 100%;
|
|
134
134
|
padding: var(--px-scrubber-padding);
|
|
135
135
|
cursor: pointer;
|
|
@@ -0,0 +1,253 @@
|
|
|
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 { getSmoothCornersPath, getBorderMaskClipPath } from '../utils/smootherCorners';
|
|
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: 'auto',
|
|
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
|
+
* Computed clip-path value for the outer container (full size, ignoring borderWidth)
|
|
70
|
+
*/
|
|
71
|
+
const clipPath = computed(() => {
|
|
72
|
+
if (dimensions.width <= 0 || dimensions.height <= 0) return null;
|
|
73
|
+
|
|
74
|
+
return getSmoothCornersPath({
|
|
75
|
+
width: dimensions.width,
|
|
76
|
+
height: dimensions.height,
|
|
77
|
+
radius: config.radius,
|
|
78
|
+
smoothing: config.smoothing,
|
|
79
|
+
borderWidth: 0, // always 0 for outer container
|
|
80
|
+
minRadius: config.minRadius,
|
|
81
|
+
maxRadius: config.maxRadius,
|
|
82
|
+
preserveSmoothing: config.preserveSmoothing,
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Computed clip-path value for inner content (accounting for borderWidth to create border space)
|
|
88
|
+
*/
|
|
89
|
+
const innerClipPath = computed(() => {
|
|
90
|
+
if (dimensions.width <= 0 || dimensions.height <= 0) return null;
|
|
91
|
+
if (config.borderWidth <= 0) return clipPath.value; // no border, use full path
|
|
92
|
+
|
|
93
|
+
return getSmoothCornersPath({
|
|
94
|
+
width: dimensions.width,
|
|
95
|
+
height: dimensions.height,
|
|
96
|
+
radius: config.radius,
|
|
97
|
+
smoothing: config.smoothing,
|
|
98
|
+
borderWidth: config.borderWidth, // reduce for border space
|
|
99
|
+
minRadius: config.minRadius,
|
|
100
|
+
maxRadius: config.maxRadius,
|
|
101
|
+
preserveSmoothing: config.preserveSmoothing,
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Computed border mask clip-path (full rectangle with smooth hole cut out)
|
|
107
|
+
*/
|
|
108
|
+
const borderMaskClipPath = computed(() => {
|
|
109
|
+
if (dimensions.width <= 0 || dimensions.height <= 0) return null;
|
|
110
|
+
if (config.borderWidth <= 0) return null;
|
|
111
|
+
|
|
112
|
+
return getBorderMaskClipPath({
|
|
113
|
+
width: dimensions.width,
|
|
114
|
+
height: dimensions.height,
|
|
115
|
+
radius: config.radius,
|
|
116
|
+
smoothing: config.smoothing,
|
|
117
|
+
borderWidth: config.borderWidth,
|
|
118
|
+
minRadius: config.minRadius,
|
|
119
|
+
maxRadius: config.maxRadius,
|
|
120
|
+
preserveSmoothing: config.preserveSmoothing,
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Update element dimensions
|
|
126
|
+
*/
|
|
127
|
+
function updateDimensions() {
|
|
128
|
+
if (!elementRef.value) return;
|
|
129
|
+
|
|
130
|
+
const rect = elementRef.value.getBoundingClientRect();
|
|
131
|
+
dimensions.width = rect.width;
|
|
132
|
+
dimensions.height = rect.height;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Apply clip-path directly to element style
|
|
137
|
+
*/
|
|
138
|
+
function applyClipPath() {
|
|
139
|
+
if (!elementRef.value || !config.autoApply) return;
|
|
140
|
+
|
|
141
|
+
if (clipPath.value) {
|
|
142
|
+
elementRef.value.style.clipPath = clipPath.value;
|
|
143
|
+
} else {
|
|
144
|
+
elementRef.value.style.clipPath = '';
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// watch for auto-apply changes
|
|
149
|
+
watch([clipPath, () => config.autoApply], applyClipPath, { immediate: true });
|
|
150
|
+
|
|
151
|
+
onMounted(() => {
|
|
152
|
+
nextTick(() => {
|
|
153
|
+
if (!elementRef.value) return;
|
|
154
|
+
|
|
155
|
+
function parseCssDimension(value) {
|
|
156
|
+
if (typeof value === 'string' && value.length > 0) {
|
|
157
|
+
value = value.trim().split(/\s+/)[0];
|
|
158
|
+
|
|
159
|
+
if (value.endsWith('px')) return parseFloat(value);
|
|
160
|
+
if (value.endsWith('rem')) {
|
|
161
|
+
return (
|
|
162
|
+
parseFloat(value) *
|
|
163
|
+
parseFloat(getComputedStyle(document.documentElement).fontSize)
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
if (value.endsWith('em')) {
|
|
167
|
+
return (
|
|
168
|
+
parseFloat(value) *
|
|
169
|
+
parseFloat(getComputedStyle(elementRef.value).fontSize)
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return value;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let cs = null;
|
|
178
|
+
|
|
179
|
+
// auto radius
|
|
180
|
+
|
|
181
|
+
if (config.radius === 'auto') {
|
|
182
|
+
// auto-detect radius based on current styles
|
|
183
|
+
cs = getComputedStyle(elementRef.value);
|
|
184
|
+
const radiusTopLeft = cs.getPropertyValue('border-top-left-radius') || '0';
|
|
185
|
+
const radiusTopRight = cs.getPropertyValue('border-top-right-radius') || '0';
|
|
186
|
+
const radiusBottomRight = cs.getPropertyValue('border-bottom-right-radius') || '0';
|
|
187
|
+
const radiusBottomLeft = cs.getPropertyValue('border-bottom-left-radius') || '0';
|
|
188
|
+
config.radius = [
|
|
189
|
+
radiusTopLeft,
|
|
190
|
+
radiusTopRight,
|
|
191
|
+
radiusBottomRight,
|
|
192
|
+
radiusBottomLeft,
|
|
193
|
+
].map(r => {
|
|
194
|
+
return parseCssDimension(r, true);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// auto border width
|
|
199
|
+
if (config.borderWidth === 'auto') {
|
|
200
|
+
cs = cs || getComputedStyle(elementRef.value);
|
|
201
|
+
const borderWidth = cs.getPropertyValue('border-thickness') || '0';
|
|
202
|
+
|
|
203
|
+
console.log('bw' + cs.getPropertyValue('border-thickness'));
|
|
204
|
+
|
|
205
|
+
config.borderWidth = parseCssDimension(borderWidth, true);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
console.log(config.borderWidth);
|
|
209
|
+
|
|
210
|
+
// initial dimension update
|
|
211
|
+
updateDimensions();
|
|
212
|
+
|
|
213
|
+
// set up resize observer
|
|
214
|
+
resizeObserver = new ResizeObserver(entries => {
|
|
215
|
+
for (const entry of entries) {
|
|
216
|
+
dimensions.width = entry.contentRect.width;
|
|
217
|
+
dimensions.height = entry.contentRect.height;
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
resizeObserver.observe(elementRef.value);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
onUnmounted(() => {
|
|
226
|
+
resizeObserver?.disconnect();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Update configuration reactively
|
|
231
|
+
*/
|
|
232
|
+
function updateConfig(newConfig) {
|
|
233
|
+
Object.assign(config, newConfig);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Manual update trigger
|
|
238
|
+
*/
|
|
239
|
+
function forceUpdate() {
|
|
240
|
+
updateDimensions();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
clipPath,
|
|
245
|
+
innerClipPath,
|
|
246
|
+
borderMaskClipPath,
|
|
247
|
+
updateConfig,
|
|
248
|
+
forceUpdate,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export { useCornerSmoothing };
|
|
253
|
+
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.26",
|
|
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",
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Reference:
|
|
2
|
+
// https://storybook.js.org/blog/storybook-7-docs/
|
|
3
|
+
// https://storybook.js.org/docs/7.5/writing-docs/autodocs
|
|
4
|
+
|
|
5
|
+
import PxCorners from '@/components/PxCorners.vue';
|
|
6
|
+
import { extractArgTypes } from '@/storybook/sb-utils.js';
|
|
7
|
+
|
|
8
|
+
export default {
|
|
9
|
+
component: PxCorners,
|
|
10
|
+
// decorators: [ ... ],
|
|
11
|
+
// parameters: { ... },
|
|
12
|
+
|
|
13
|
+
// https://storybook.js.org/docs/7.0/vue/essentials/controls#annotation
|
|
14
|
+
// https://storybook.js.org/docs/7.0/vue/api/argtypes#available-properties
|
|
15
|
+
argTypes: {
|
|
16
|
+
...extractArgTypes(PxCorners),
|
|
17
|
+
// someProp: { type: 'select', options: ['one', 'two', 'three'] }
|
|
18
|
+
},
|
|
19
|
+
args: {
|
|
20
|
+
// label: 'Hello',
|
|
21
|
+
// onClick: () => {}),
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const Compare = {
|
|
26
|
+
// name: 'Story Name',
|
|
27
|
+
// parameters: {
|
|
28
|
+
// customCSS: `.myclass { border: 4px solid red; }`,
|
|
29
|
+
// },
|
|
30
|
+
render: (args, { argTypes }) => ({
|
|
31
|
+
components: { PxCorners },
|
|
32
|
+
setup() {
|
|
33
|
+
return { args };
|
|
34
|
+
},
|
|
35
|
+
template: `
|
|
36
|
+
<div class="story-flex" style=" position: relative;">
|
|
37
|
+
<div v-bind="args" class="story-bg-blue" style="border-radius: 50px; width: 200px; height: 200px;" />
|
|
38
|
+
<PxCorners v-bind="args" class="story-bg-orange" :radius="50" style="width: 200px; height: 200px;" />
|
|
39
|
+
</div>
|
|
40
|
+
`,
|
|
41
|
+
}),
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const Basic = {
|
|
45
|
+
// name: 'Story Name',
|
|
46
|
+
// parameters: {
|
|
47
|
+
// customCSS: `.myclass { border: 4px solid red; }`,
|
|
48
|
+
// },
|
|
49
|
+
render: (args, { argTypes }) => ({
|
|
50
|
+
components: { PxCorners },
|
|
51
|
+
setup() {
|
|
52
|
+
return { args };
|
|
53
|
+
},
|
|
54
|
+
template: `<PxCorners v-bind="args" class="story-size-md story-bg-orange" :radius="40" :borderWidth="4" borderColor="black" />`,
|
|
55
|
+
}),
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const Slot = {
|
|
59
|
+
// name: 'Story Name',
|
|
60
|
+
// parameters: {
|
|
61
|
+
// customCSS: `.myclass { border: 4px solid red; }`,
|
|
62
|
+
// },
|
|
63
|
+
render: (args, { argTypes }) => ({
|
|
64
|
+
components: { PxCorners },
|
|
65
|
+
setup() {
|
|
66
|
+
return { args };
|
|
67
|
+
},
|
|
68
|
+
template: `<PxCorners v-bind="args" class="story-size-md story-bg-blue story-center" :radius="40" > Slot content </PxCorners>`,
|
|
69
|
+
}),
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const SlotWithBorder = {
|
|
73
|
+
// name: 'Story Name',
|
|
74
|
+
// parameters: {
|
|
75
|
+
// customCSS: `.myclass { border: 4px solid red; }`,
|
|
76
|
+
// },
|
|
77
|
+
render: (args, { argTypes }) => ({
|
|
78
|
+
components: { PxCorners },
|
|
79
|
+
setup() {
|
|
80
|
+
return { args };
|
|
81
|
+
},
|
|
82
|
+
template: `<PxCorners v-bind="args" class="story-size-md story-bg-blue story-center" :radius="40" :borderWidth="4"> Slot content </PxCorners>`,
|
|
83
|
+
}),
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const HoverWithBorder = {
|
|
87
|
+
// name: 'Story Name',
|
|
88
|
+
// parameters: {
|
|
89
|
+
// customCSS: `.myclass { border: 4px solid red; }`,
|
|
90
|
+
// },
|
|
91
|
+
render: (args, { argTypes }) => ({
|
|
92
|
+
components: { PxCorners },
|
|
93
|
+
setup() {
|
|
94
|
+
return { args };
|
|
95
|
+
},
|
|
96
|
+
template: `<PxCorners v-bind="args" class="story-size-md story-bg-orange story-hover-bg-blue story-center" :radius="40" :borderWidth="10"> Slot content </PxCorners>`,
|
|
97
|
+
}),
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export const AutoFromStyle = {
|
|
101
|
+
// name: 'Story Name',
|
|
102
|
+
// parameters: {
|
|
103
|
+
// customCSS: `.myclass { border: 4px solid red; }`,
|
|
104
|
+
// },
|
|
105
|
+
render: (args, { argTypes }) => ({
|
|
106
|
+
components: { PxCorners },
|
|
107
|
+
setup() {
|
|
108
|
+
return { args };
|
|
109
|
+
},
|
|
110
|
+
template: `<PxCorners :smoothingEnabled="true" v-bind="args" class="story-size-md story-bg-orange" style="border-radius: 40px; border: 4px solid black;" />`,
|
|
111
|
+
}),
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export const AutoPercent = {
|
|
115
|
+
// name: 'Story Name',
|
|
116
|
+
// parameters: {
|
|
117
|
+
// customCSS: `.myclass { border: 4px solid red; }`,
|
|
118
|
+
// },
|
|
119
|
+
render: (args, { argTypes }) => ({
|
|
120
|
+
components: { PxCorners },
|
|
121
|
+
setup() {
|
|
122
|
+
return { args };
|
|
123
|
+
},
|
|
124
|
+
template: `
|
|
125
|
+
<div class="story-flex" style=" position: relative;">
|
|
126
|
+
<PxCorners :smoothingEnabled="true" v-bind="args" class="story-bg-orange story-center" style="width: 300px; height: 200px; border-radius: 20%;">
|
|
127
|
+
Style 20%
|
|
128
|
+
</PxCorners>
|
|
129
|
+
<PxCorners :smoothingEnabled="true" v-bind="args" class="story-bg-orange story-center" style="width: 300px; height: 200px;" :radius="60">
|
|
130
|
+
:radius 60px
|
|
131
|
+
</PxCorners>
|
|
132
|
+
<div class="story-bg-orange story-center" style="width: 300px; height: 200px; border-radius: 20%;">
|
|
133
|
+
Div 20%
|
|
134
|
+
</div>
|
|
135
|
+
<div class="story-bg-orange story-center" style="width: 300px; height: 200px; border-radius: 60px;">
|
|
136
|
+
Div 60px
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
`,
|
|
140
|
+
}),
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export const Disabled = {
|
|
144
|
+
// name: 'Story Name',
|
|
145
|
+
// parameters: {
|
|
146
|
+
// customCSS: `.myclass { border: 4px solid red; }`,
|
|
147
|
+
// },
|
|
148
|
+
render: (args, { argTypes }) => ({
|
|
149
|
+
components: { PxCorners },
|
|
150
|
+
setup() {
|
|
151
|
+
return { args };
|
|
152
|
+
},
|
|
153
|
+
template: `<PxCorners :smoothingEnabled="false" v-bind="args" class="story-size-md story-bg-orange" :radius="40" :borderWidth="4" borderColor="black" />`,
|
|
154
|
+
}),
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
export const DisabledAutoFromStyle = {
|
|
158
|
+
// name: 'Story Name',
|
|
159
|
+
// parameters: {
|
|
160
|
+
// customCSS: `.myclass { border: 4px solid red; }`,
|
|
161
|
+
// },
|
|
162
|
+
render: (args, { argTypes }) => ({
|
|
163
|
+
components: { PxCorners },
|
|
164
|
+
setup() {
|
|
165
|
+
return { args };
|
|
166
|
+
},
|
|
167
|
+
template: `<PxCorners :smoothingEnabled="false" v-bind="args" class="story-size-md story-bg-orange" style="border-radius: 40px; border: 4px solid black;" />`,
|
|
168
|
+
}),
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
export const BasicMask = {
|
|
172
|
+
// name: 'Story Name',
|
|
173
|
+
// parameters: {
|
|
174
|
+
// customCSS: `.myclass { border: 4px solid red; }`,
|
|
175
|
+
// },
|
|
176
|
+
render: (args, { argTypes }) => ({
|
|
177
|
+
components: { PxCorners },
|
|
178
|
+
setup() {
|
|
179
|
+
return { args };
|
|
180
|
+
},
|
|
181
|
+
template: `<PxCorners v-bind="args" renderMode="mask" class="story-size-md story-bg-orange" :radius="40" :borderWidth="4" borderColor="black" />`,
|
|
182
|
+
}),
|
|
183
|
+
};
|
|
184
|
+
// export const Advanced = {
|
|
185
|
+
// ...Basic,
|
|
186
|
+
// // args: { ... },
|
|
187
|
+
// };
|
package/storybook/sb-styles.scss
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
@use '@thinkpixellab-public/px-styles/all' as *;
|
|
2
|
+
@use 'sass:color' as color;
|
|
2
3
|
|
|
3
4
|
// Update this to test font inheritance
|
|
4
5
|
.sbdocs {
|
|
@@ -91,11 +92,8 @@ body {
|
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
.story-gradient {
|
|
94
|
-
background-image:
|
|
95
|
-
|
|
96
|
-
rgba(black, 0.1) 0 20px,
|
|
97
|
-
rgba(black, 0) 20px 40px
|
|
98
|
-
),
|
|
95
|
+
background-image:
|
|
96
|
+
repeating-linear-gradient(-45deg, rgba(black, 0.1) 0 20px, rgba(black, 0) 20px 40px),
|
|
99
97
|
linear-gradient(135deg, tomato, purple);
|
|
100
98
|
width: 100%;
|
|
101
99
|
height: 100%;
|
|
@@ -145,6 +143,13 @@ body {
|
|
|
145
143
|
background-color: gold;
|
|
146
144
|
}
|
|
147
145
|
|
|
146
|
+
.story-hover-bg-blue {
|
|
147
|
+
cursor: pointer;
|
|
148
|
+
}
|
|
149
|
+
.story-hover-bg-blue:hover {
|
|
150
|
+
background-color: dodgerblue;
|
|
151
|
+
}
|
|
152
|
+
|
|
148
153
|
.story-size-xs {
|
|
149
154
|
width: 2em;
|
|
150
155
|
height: 2em;
|
|
@@ -176,7 +181,7 @@ body {
|
|
|
176
181
|
font-family: sans-serif,
|
|
177
182
|
padding: 0.5em,
|
|
178
183
|
font-size: 14px,
|
|
179
|
-
background-color: mix(dodgerblue, white, 15%),
|
|
184
|
+
background-color: color.mix(dodgerblue, white, 15%),
|
|
180
185
|
)
|
|
181
186
|
);
|
|
182
187
|
}
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* smootherCorners.js
|
|
3
|
+
*
|
|
4
|
+
* Utility functions for creating smooth, squircle-like corners using CSS clip-path.
|
|
5
|
+
* Based on Figma's corner smoothing approach.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* ```js
|
|
9
|
+
* import { getSmoothCornersPath } from '@thinkpixellab-public/px-vue/utils/smootherCorners'
|
|
10
|
+
*
|
|
11
|
+
* const clipPath = getSmoothCornersPath({
|
|
12
|
+
* width: 200,
|
|
13
|
+
* height: 100,
|
|
14
|
+
* radius: 16,
|
|
15
|
+
* smoothing: 0.8
|
|
16
|
+
* })
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { getSvgPath } from 'figma-squircle';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parse percentage string to number
|
|
24
|
+
* @param {string|number} value - Value to parse
|
|
25
|
+
* @param {number} reference - Reference value for percentage calculation
|
|
26
|
+
* @returns {number}
|
|
27
|
+
*/
|
|
28
|
+
export function parsePercentage(value, reference) {
|
|
29
|
+
if (typeof value === 'string' && value.endsWith('%')) {
|
|
30
|
+
const percent = parseFloat(value) / 100;
|
|
31
|
+
return reference * percent;
|
|
32
|
+
}
|
|
33
|
+
return parseFloat(value) || 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Expand radius array using CSS border-radius rules
|
|
38
|
+
* @param {number|Array} radius - Single radius or array of radii
|
|
39
|
+
* @returns {Array<number>} Array of four corner radii [tl, tr, br, bl]
|
|
40
|
+
*/
|
|
41
|
+
export function expandRadiusArray(radius) {
|
|
42
|
+
if (!Array.isArray(radius)) {
|
|
43
|
+
return [radius, radius, radius, radius];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const [tl, tr = tl, br = tl, bl = tr] = radius;
|
|
47
|
+
return [tl, tr, br, bl];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Calculate corner radii with percentage support and clamping
|
|
52
|
+
* @param {number|Array|string} radius - Radius value(s)
|
|
53
|
+
* @param {number} width - Element width
|
|
54
|
+
* @param {number} height - Element height
|
|
55
|
+
* @param {number} minRadius - Minimum radius constraint
|
|
56
|
+
* @param {number} maxRadius - Maximum radius constraint
|
|
57
|
+
* @returns {Array<number>} Calculated radii for each corner
|
|
58
|
+
*/
|
|
59
|
+
export function calculateCornerRadii(radius, width, height, minRadius = 0, maxRadius = Infinity) {
|
|
60
|
+
const reference = Math.max(width, height);
|
|
61
|
+
const expanded = expandRadiusArray(radius);
|
|
62
|
+
|
|
63
|
+
return expanded.map(r => {
|
|
64
|
+
const parsed = parsePercentage(r, reference);
|
|
65
|
+
return Math.max(minRadius, Math.min(maxRadius, parsed));
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Generate squircle path using figma-squircle library
|
|
71
|
+
* @param {number} width - Path width
|
|
72
|
+
* @param {number} height - Path height
|
|
73
|
+
* @param {Array<number>} radii - Corner radii [tl, tr, br, bl]
|
|
74
|
+
* @param {number} smoothing - Smoothing amount (0-1)
|
|
75
|
+
* @param {boolean} preserveSmoothing - Maintain smoothing at larger radii
|
|
76
|
+
* @returns {string} SVG path string
|
|
77
|
+
*/
|
|
78
|
+
export function generateSquirclePath(
|
|
79
|
+
width,
|
|
80
|
+
height,
|
|
81
|
+
radii,
|
|
82
|
+
smoothing = 0.8,
|
|
83
|
+
preserveSmoothing = true,
|
|
84
|
+
) {
|
|
85
|
+
if (width <= 0 || height <= 0) return '';
|
|
86
|
+
|
|
87
|
+
const [tl, tr, br, bl] = radii;
|
|
88
|
+
|
|
89
|
+
// check if all corners are the same (uniform)
|
|
90
|
+
const isUniform = tl === tr && tr === br && br === bl;
|
|
91
|
+
|
|
92
|
+
if (isUniform) {
|
|
93
|
+
// use simple API for uniform corners
|
|
94
|
+
return getSvgPath({
|
|
95
|
+
width,
|
|
96
|
+
height,
|
|
97
|
+
cornerRadius: tl,
|
|
98
|
+
cornerSmoothing: smoothing,
|
|
99
|
+
preserveSmoothing,
|
|
100
|
+
});
|
|
101
|
+
} else {
|
|
102
|
+
// use individual corner API for different corners
|
|
103
|
+
return getSvgPath({
|
|
104
|
+
width,
|
|
105
|
+
height,
|
|
106
|
+
cornerRadius: Math.max(tl, tr, br, bl), // fallback base radius
|
|
107
|
+
cornerSmoothing: smoothing,
|
|
108
|
+
preserveSmoothing,
|
|
109
|
+
topLeftCornerRadius: tl,
|
|
110
|
+
topRightCornerRadius: tr,
|
|
111
|
+
bottomRightCornerRadius: br,
|
|
112
|
+
bottomLeftCornerRadius: bl,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Apply border offset to SVG path
|
|
119
|
+
* @param {string} path - SVG path string
|
|
120
|
+
* @param {number} offsetX - X offset
|
|
121
|
+
* @param {number} offsetY - Y offset
|
|
122
|
+
* @returns {string} Transformed path string
|
|
123
|
+
*/
|
|
124
|
+
export function applyPathOffset(path, offsetX, offsetY) {
|
|
125
|
+
if (!path || (offsetX === 0 && offsetY === 0)) return path;
|
|
126
|
+
|
|
127
|
+
// transform path to account for offset
|
|
128
|
+
const translatedPath = path
|
|
129
|
+
.replace(/([ML])\s*([0-9.]+)\s+([0-9.]+)/g, (_, command, x, y) => {
|
|
130
|
+
return `${command} ${parseFloat(x) + offsetX} ${parseFloat(y) + offsetY}`;
|
|
131
|
+
})
|
|
132
|
+
.replace(
|
|
133
|
+
/([C])\s*([0-9.]+)\s+([0-9.]+)\s+([0-9.]+)\s+([0-9.]+)\s+([0-9.]+)\s+([0-9.]+)/g,
|
|
134
|
+
(_, command, x1, y1, x2, y2, x3, y3) => {
|
|
135
|
+
return `${command} ${parseFloat(x1) + offsetX} ${parseFloat(y1) + offsetY} ${parseFloat(x2) + offsetX} ${parseFloat(y2) + offsetY} ${parseFloat(x3) + offsetX} ${parseFloat(y3) + offsetY}`;
|
|
136
|
+
},
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
return translatedPath;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Generate smooth corners clip-path value or SVG mask
|
|
144
|
+
* @param {Object} options - Configuration options
|
|
145
|
+
* @param {number} options.width - Element width
|
|
146
|
+
* @param {number} options.height - Element height
|
|
147
|
+
* @param {number|Array|string} options.radius - Corner radius (number, array, or percentage)
|
|
148
|
+
* @param {number} options.smoothing - Smoothing amount 0-1 (default: 0.8)
|
|
149
|
+
* @param {number} options.borderWidth - Border width to account for (default: 0)
|
|
150
|
+
* @param {number} options.minRadius - Min radius in px (default: 0)
|
|
151
|
+
* @param {number} options.maxRadius - Max radius in px (default: Infinity)
|
|
152
|
+
* @param {boolean} options.preserveSmoothing - Maintain smoothing at larger radii (default: true)
|
|
153
|
+
* @param {string} options.renderMode - Render mode: 'clip-path' | 'mask' (default: 'clip-path')
|
|
154
|
+
* @returns {string|null} CSS clip-path value, SVG mask data URL, or null if invalid dimensions
|
|
155
|
+
*/
|
|
156
|
+
export function getSmoothCornersPath(options = {}) {
|
|
157
|
+
const {
|
|
158
|
+
width,
|
|
159
|
+
height,
|
|
160
|
+
radius = 8,
|
|
161
|
+
smoothing = 0.8,
|
|
162
|
+
borderWidth = 0,
|
|
163
|
+
minRadius = 0,
|
|
164
|
+
maxRadius = Infinity,
|
|
165
|
+
preserveSmoothing = true,
|
|
166
|
+
renderMode = 'clip-path',
|
|
167
|
+
} = options;
|
|
168
|
+
|
|
169
|
+
if (width <= 0 || height <= 0) return null;
|
|
170
|
+
|
|
171
|
+
const adjustedWidth = Math.max(0, width - borderWidth * 2);
|
|
172
|
+
const adjustedHeight = Math.max(0, height - borderWidth * 2);
|
|
173
|
+
|
|
174
|
+
if (adjustedWidth <= 0 || adjustedHeight <= 0) return null;
|
|
175
|
+
|
|
176
|
+
// calculate corner radii with percentage support and clamping
|
|
177
|
+
const radii = calculateCornerRadii(radius, adjustedWidth, adjustedHeight, minRadius, maxRadius);
|
|
178
|
+
|
|
179
|
+
// check if all radii are 0 - no need for clipping/masking
|
|
180
|
+
if (radii.every(r => r === 0)) {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// adjust radii for inner path when borderWidth is present
|
|
185
|
+
const adjustedRadii = borderWidth > 0
|
|
186
|
+
? radii.map(r => Math.max(0, r - borderWidth))
|
|
187
|
+
: radii;
|
|
188
|
+
|
|
189
|
+
const path = generateSquirclePath(
|
|
190
|
+
adjustedWidth,
|
|
191
|
+
adjustedHeight,
|
|
192
|
+
adjustedRadii,
|
|
193
|
+
smoothing,
|
|
194
|
+
preserveSmoothing,
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
if (!path) return null;
|
|
198
|
+
|
|
199
|
+
// apply border offset if needed
|
|
200
|
+
const finalPath = borderWidth > 0 ? applyPathOffset(path, borderWidth, borderWidth) : path;
|
|
201
|
+
|
|
202
|
+
// return format based on render mode
|
|
203
|
+
if (renderMode === 'mask') {
|
|
204
|
+
return createSvgMaskDataUrl(finalPath, width, height);
|
|
205
|
+
} else {
|
|
206
|
+
return `path('${finalPath}')`;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Generate border mask using CSS masks instead of complex path manipulation
|
|
212
|
+
* @param {Object} options - Configuration options
|
|
213
|
+
* @returns {Object} Object with CSS mask properties for creating border effect
|
|
214
|
+
*/
|
|
215
|
+
export function getBorderMaskStyles(options = {}) {
|
|
216
|
+
const { width, height, borderWidth = 0 } = options;
|
|
217
|
+
|
|
218
|
+
if (width <= 0 || height <= 0 || borderWidth <= 0) return null;
|
|
219
|
+
|
|
220
|
+
// Create the inner path (smaller for content area)
|
|
221
|
+
const innerPath = getSmoothCornersPath({
|
|
222
|
+
width: width - (borderWidth * 2),
|
|
223
|
+
height: height - (borderWidth * 2),
|
|
224
|
+
radius: options.radius,
|
|
225
|
+
smoothing: options.smoothing,
|
|
226
|
+
borderWidth: 0,
|
|
227
|
+
minRadius: options.minRadius,
|
|
228
|
+
maxRadius: options.maxRadius,
|
|
229
|
+
preserveSmoothing: options.preserveSmoothing,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
if (!innerPath) return null;
|
|
233
|
+
|
|
234
|
+
// Position the inner mask to create border space
|
|
235
|
+
const maskPosition = `${borderWidth}px ${borderWidth}px`;
|
|
236
|
+
const maskSize = `${width - (borderWidth * 2)}px ${height - (borderWidth * 2)}px`;
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
WebkitMask: `radial-gradient(white, white), ${innerPath}`,
|
|
240
|
+
mask: `radial-gradient(white, white), ${innerPath}`,
|
|
241
|
+
WebkitMaskComposite: 'xor',
|
|
242
|
+
maskComposite: 'subtract',
|
|
243
|
+
WebkitMaskPosition: `0 0, ${maskPosition}`,
|
|
244
|
+
maskPosition: `0 0, ${maskPosition}`,
|
|
245
|
+
WebkitMaskSize: `100% 100%, ${maskSize}`,
|
|
246
|
+
maskSize: `100% 100%, ${maskSize}`,
|
|
247
|
+
WebkitMaskRepeat: 'no-repeat, no-repeat',
|
|
248
|
+
maskRepeat: 'no-repeat, no-repeat'
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Generate border mask clip path using compound path approach or SVG mask
|
|
254
|
+
* @param {Object} options - Configuration options
|
|
255
|
+
* @param {string} options.renderMode - Render mode: 'clip-path' | 'mask' (default: 'clip-path')
|
|
256
|
+
* @returns {string|null} CSS clip-path value, SVG mask data URL, or null if invalid
|
|
257
|
+
*/
|
|
258
|
+
export function getBorderMaskClipPath(options = {}) {
|
|
259
|
+
const { width, height, borderWidth = 0, renderMode = 'clip-path' } = options;
|
|
260
|
+
|
|
261
|
+
if (width <= 0 || height <= 0 || borderWidth <= 0) return null;
|
|
262
|
+
|
|
263
|
+
// Check if the border is too large for the element
|
|
264
|
+
if (borderWidth * 2 >= width || borderWidth * 2 >= height) return null;
|
|
265
|
+
|
|
266
|
+
// Create outer rectangle path (clockwise)
|
|
267
|
+
const rectPath = `M 0 0 H ${width} V ${height} H 0 Z`;
|
|
268
|
+
|
|
269
|
+
// Generate the inner smooth path directly at the correct size and position
|
|
270
|
+
const innerWidth = width - (borderWidth * 2);
|
|
271
|
+
const innerHeight = height - (borderWidth * 2);
|
|
272
|
+
|
|
273
|
+
// Calculate radii for the outer dimensions first
|
|
274
|
+
const outerRadii = calculateCornerRadii(
|
|
275
|
+
options.radius,
|
|
276
|
+
width,
|
|
277
|
+
height,
|
|
278
|
+
options.minRadius || 0,
|
|
279
|
+
options.maxRadius || Infinity
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
// check if all radii are 0 - no need for border masking
|
|
283
|
+
if (outerRadii.every(r => r === 0)) {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Adjust radii for the inner path by subtracting border width
|
|
288
|
+
// This ensures consistent border thickness around corners
|
|
289
|
+
const innerRadii = outerRadii.map(r => Math.max(0, r - borderWidth));
|
|
290
|
+
|
|
291
|
+
// Generate the inner path with adjusted radii
|
|
292
|
+
const innerSmoothPath = generateSquirclePath(
|
|
293
|
+
innerWidth,
|
|
294
|
+
innerHeight,
|
|
295
|
+
innerRadii,
|
|
296
|
+
options.smoothing || 0.8,
|
|
297
|
+
options.preserveSmoothing !== false
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
if (!innerSmoothPath) return null;
|
|
301
|
+
|
|
302
|
+
// Apply offset to position the inner path
|
|
303
|
+
const offsetInnerPath = applyPathOffset(innerSmoothPath, borderWidth, borderWidth);
|
|
304
|
+
|
|
305
|
+
// Combine the paths
|
|
306
|
+
const combinedPath = `${rectPath} ${offsetInnerPath}`;
|
|
307
|
+
|
|
308
|
+
// Return format based on render mode
|
|
309
|
+
if (renderMode === 'mask') {
|
|
310
|
+
return createSvgBorderMaskDataUrl(combinedPath, width, height);
|
|
311
|
+
} else {
|
|
312
|
+
return `path(evenodd, '${combinedPath}')`;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Create SVG mask data URL from path string
|
|
319
|
+
* @param {string} pathString - SVG path string
|
|
320
|
+
* @param {number} width - Mask width
|
|
321
|
+
* @param {number} height - Mask height
|
|
322
|
+
* @returns {string} Data URL for SVG mask
|
|
323
|
+
*/
|
|
324
|
+
export function createSvgMaskDataUrl(pathString, width, height) {
|
|
325
|
+
const svgContent = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
|
|
326
|
+
<defs>
|
|
327
|
+
<mask id="smooth-mask">
|
|
328
|
+
<rect width="100%" height="100%" fill="black"/>
|
|
329
|
+
<path d="${pathString}" fill="white"/>
|
|
330
|
+
</mask>
|
|
331
|
+
</defs>
|
|
332
|
+
<rect width="100%" height="100%" fill="white" mask="url(#smooth-mask)"/>
|
|
333
|
+
</svg>`;
|
|
334
|
+
|
|
335
|
+
return `url('data:image/svg+xml;utf8,${encodeURIComponent(svgContent)}')`;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Create SVG border mask data URL using compound path approach
|
|
340
|
+
* @param {string} compoundPathString - Compound path string (outer rect + inner hole)
|
|
341
|
+
* @param {number} width - Mask width
|
|
342
|
+
* @param {number} height - Mask height
|
|
343
|
+
* @returns {string} Data URL for SVG border mask
|
|
344
|
+
*/
|
|
345
|
+
export function createSvgBorderMaskDataUrl(compoundPathString, width, height) {
|
|
346
|
+
const svgContent = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
|
|
347
|
+
<defs>
|
|
348
|
+
<mask id="border-mask">
|
|
349
|
+
<path d="${compoundPathString}" fill="white" fill-rule="evenodd"/>
|
|
350
|
+
</mask>
|
|
351
|
+
</defs>
|
|
352
|
+
<rect width="100%" height="100%" fill="white" mask="url(#border-mask)"/>
|
|
353
|
+
</svg>`;
|
|
354
|
+
|
|
355
|
+
return `url('data:image/svg+xml;utf8,${encodeURIComponent(svgContent)}')`;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Generate smooth corners configuration object
|
|
360
|
+
* @param {Object} options - Configuration options (same as getSmoothCornersPath)
|
|
361
|
+
* @returns {Object} Configuration object with computed values
|
|
362
|
+
*/
|
|
363
|
+
export function generateSmoothCornersConfig(options = {}) {
|
|
364
|
+
const {
|
|
365
|
+
width,
|
|
366
|
+
height,
|
|
367
|
+
radius = 8,
|
|
368
|
+
smoothing = 0.8,
|
|
369
|
+
borderWidth = 0,
|
|
370
|
+
minRadius = 0,
|
|
371
|
+
maxRadius = Infinity,
|
|
372
|
+
preserveSmoothing = true,
|
|
373
|
+
} = options;
|
|
374
|
+
|
|
375
|
+
const adjustedWidth = Math.max(0, width - borderWidth * 2);
|
|
376
|
+
const adjustedHeight = Math.max(0, height - borderWidth * 2);
|
|
377
|
+
|
|
378
|
+
const radii = calculateCornerRadii(radius, adjustedWidth, adjustedHeight, minRadius, maxRadius);
|
|
379
|
+
|
|
380
|
+
// adjust radii for inner path when borderWidth is present
|
|
381
|
+
const adjustedRadii = borderWidth > 0
|
|
382
|
+
? radii.map(r => Math.max(0, r - borderWidth))
|
|
383
|
+
: radii;
|
|
384
|
+
|
|
385
|
+
return {
|
|
386
|
+
width: adjustedWidth,
|
|
387
|
+
height: adjustedHeight,
|
|
388
|
+
radii: adjustedRadii,
|
|
389
|
+
originalRadii: radii,
|
|
390
|
+
smoothing,
|
|
391
|
+
preserveSmoothing,
|
|
392
|
+
borderWidth,
|
|
393
|
+
clipPath: getSmoothCornersPath(options),
|
|
394
|
+
};
|
|
395
|
+
}
|