@thinkpixellab-public/px-vue 4.1.24 → 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.
@@ -139,15 +139,19 @@ export default {
139
139
  immediate: true,
140
140
  handler(nv, ov) {
141
141
  // check for matching values
142
- if (nv && ov && nv.length == ov.length) {
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 < nv.length; i++) {
145
- match = match && nv[i] == ov[i];
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
  },
@@ -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': color(page-bg),
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: color(page-bg),
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'),
@@ -198,8 +198,8 @@ export {
198
198
 
199
199
  .px-menu {
200
200
  font-family: get('font-family');
201
- background-color: color(page-bg);
202
- color: color(page-fg);
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
- .px-scrubber {
125
- @at-root :root {
126
- --px-scrubber-height: 4px;
127
- --px-scrubber-bg: #{clr(accent, $alpha: 0.1)};
128
- --px-scrubber-padding: 1em 0;
129
- --px-scrubber-progress-bg: #{clr(accent, 1)};
130
- --px-scrubber-preview-bg: #{clr(accent, $alpha: 0.33)};
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;
@@ -47,7 +47,7 @@
47
47
  */
48
48
 
49
49
  import { computed, onMounted, onUnmounted, nextTick, reactive, watch } from 'vue';
50
- import { getSvgPath } from 'figma-squircle';
50
+ import { getSmoothCornersPath, getBorderMaskClipPath } from '../utils/smootherCorners';
51
51
 
52
52
  function useCornerSmoothing(elementRef, options = {}) {
53
53
  const dimensions = reactive({ width: 0, height: 0 });
@@ -55,7 +55,7 @@ function useCornerSmoothing(elementRef, options = {}) {
55
55
  let resizeObserver = null;
56
56
 
57
57
  const config = reactive({
58
- radius: 8,
58
+ radius: 'auto',
59
59
  smoothing: 0.8,
60
60
  borderWidth: 0,
61
61
  minRadius: 0,
@@ -66,127 +66,59 @@ function useCornerSmoothing(elementRef, options = {}) {
66
66
  });
67
67
 
68
68
  /**
69
- * parse percentage string to number
69
+ * Computed clip-path value for the outer container (full size, ignoring borderWidth)
70
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);
71
+ const clipPath = computed(() => {
72
+ if (dimensions.width <= 0 || dimensions.height <= 0) return null;
97
73
 
98
- return expanded.map(r => {
99
- const parsed = parsePercentage(r, reference);
100
- return Math.max(minRadius, Math.min(maxRadius, parsed));
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,
101
83
  });
102
- }
84
+ });
103
85
 
104
86
  /**
105
- * generates squircle path using figma-squircle library
87
+ * Computed clip-path value for inner content (accounting for borderWidth to create border space)
106
88
  */
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
- }
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
+ });
139
104
 
140
105
  /**
141
- * Computed clip-path value
106
+ * Computed border mask clip-path (full rectangle with smooth hole cut out)
142
107
  */
143
- const clipPath = computed(() => {
108
+ const borderMaskClipPath = computed(() => {
144
109
  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}')`;
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
+ });
190
122
  });
191
123
 
192
124
  /**
@@ -220,6 +152,61 @@ function useCornerSmoothing(elementRef, options = {}) {
220
152
  nextTick(() => {
221
153
  if (!elementRef.value) return;
222
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
+
223
210
  // initial dimension update
224
211
  updateDimensions();
225
212
 
@@ -255,6 +242,8 @@ function useCornerSmoothing(elementRef, options = {}) {
255
242
 
256
243
  return {
257
244
  clipPath,
245
+ innerClipPath,
246
+ borderMaskClipPath,
258
247
  updateConfig,
259
248
  forceUpdate,
260
249
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thinkpixellab-public/px-vue",
3
- "version": "4.1.24",
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"
@@ -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
+ // };
@@ -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: repeating-linear-gradient(
95
- -45deg,
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
+ }