@thinkpixellab-public/px-vue 4.1.7 → 4.1.8
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/components/PxBackgroundFader.vue +137 -0
- package/components/PxIconButton.vue +5 -2
- package/components/PxImage.vue +399 -0
- package/package.json +1 -1
- package/stories/PxBackgroundFader.stories.js +42 -0
- package/stories/PxImage.stories.js +113 -0
- package/stories/assets/px-01.jpg +0 -0
- package/stories/assets/px-02.jpg +0 -0
- package/stories/assets/px-03.jpg +0 -0
- package/stories/assets/px-04.jpg +0 -0
- package/storybook/sb-styles.scss +25 -4
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Describe this component...
|
|
3
|
+
-->
|
|
4
|
+
<template>
|
|
5
|
+
<div :class="bem()">
|
|
6
|
+
<transition-group :class="bem('group')" name="px-background-fader__fade" tag="div">
|
|
7
|
+
<div
|
|
8
|
+
v-for="background in backgrounds"
|
|
9
|
+
:key="background.id"
|
|
10
|
+
:class="bem('background')"
|
|
11
|
+
:style="{
|
|
12
|
+
transitionDuration: duration ? duration + 's' : null,
|
|
13
|
+
}"
|
|
14
|
+
>
|
|
15
|
+
<slot name="background" v-bind="background">
|
|
16
|
+
<div
|
|
17
|
+
:class="[background.value, ...backgroundClassArray]"
|
|
18
|
+
:style="backgroundStyle"
|
|
19
|
+
/>
|
|
20
|
+
</slot>
|
|
21
|
+
<slot name="default" />
|
|
22
|
+
</div>
|
|
23
|
+
</transition-group>
|
|
24
|
+
</div>
|
|
25
|
+
</template>
|
|
26
|
+
|
|
27
|
+
<script>
|
|
28
|
+
export default {
|
|
29
|
+
name: 'px-background-fader',
|
|
30
|
+
props: {
|
|
31
|
+
/** A background items can be anything. This will be made available to the background slot
|
|
32
|
+
* and the slot can render accordingly. The default slot assumes that the background is a
|
|
33
|
+
* class names and creates a div and sets the class. When the background is set, we will
|
|
34
|
+
* crossfade between the contents of the two slots. */
|
|
35
|
+
background: { default: null },
|
|
36
|
+
|
|
37
|
+
/** An additional class that gets applied to the background element in the default slot. */
|
|
38
|
+
backgroundClass: { default: 'px-background-fader__bg' },
|
|
39
|
+
|
|
40
|
+
/** Value for the style property that gets applied to the background element in th default slot */
|
|
41
|
+
backgroundStyle: { default: null },
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The duration of the fade animation in seconds.
|
|
45
|
+
*/
|
|
46
|
+
duration: { type: Number, default: 0 },
|
|
47
|
+
},
|
|
48
|
+
data() {
|
|
49
|
+
const backgroundId = 0;
|
|
50
|
+
const backgrounds = this.background ? [{ id: backgroundId, value: this.background }] : [];
|
|
51
|
+
return {
|
|
52
|
+
backgrounds,
|
|
53
|
+
backgroundId,
|
|
54
|
+
fadeLeaveDelay: this.duration ? this.duration : '',
|
|
55
|
+
};
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
computed: {
|
|
59
|
+
backgroundClassArray() {
|
|
60
|
+
if (Array.isArray(this.backgroundClass)) {
|
|
61
|
+
return this.backgroundClass;
|
|
62
|
+
}
|
|
63
|
+
return [this.backgroundClass];
|
|
64
|
+
},
|
|
65
|
+
fadeLeaveDelayCalc() {
|
|
66
|
+
if (this.fadeLeaveDelay !== null) {
|
|
67
|
+
return this.fadeLeaveDelay + 's';
|
|
68
|
+
}
|
|
69
|
+
return '--fade-leave-delay-def';
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
watch: {
|
|
73
|
+
background(nv, ov) {
|
|
74
|
+
if (this.compareItems(nv, ov)) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
this.fadeLeaveDelay == !!ov ? this.duration : 0;
|
|
79
|
+
|
|
80
|
+
if (ov && this.backgrounds.length) {
|
|
81
|
+
this.backgrounds.shift();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (nv) {
|
|
85
|
+
this.backgroundId++;
|
|
86
|
+
this.backgrounds.push({ id: this.backgroundId, value: nv });
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
methods: {
|
|
91
|
+
compareItems(a, b) {
|
|
92
|
+
if (a == b) {
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (typeof a == 'object' && typeof b == 'object') {
|
|
97
|
+
return JSON.stringify(a) == JSON.stringify(b);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return false;
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
</script>
|
|
105
|
+
|
|
106
|
+
<style lang="scss">
|
|
107
|
+
@use '../styles/px.scss' as *;
|
|
108
|
+
|
|
109
|
+
@include vue-transition(px-background-fader__fade) {
|
|
110
|
+
$dur: 0.5s;
|
|
111
|
+
|
|
112
|
+
@include vt-transition {
|
|
113
|
+
@include transition(opacity, $dur: $dur);
|
|
114
|
+
}
|
|
115
|
+
@include vt-leaving() {
|
|
116
|
+
--fade-leave-delay-def: $dur;
|
|
117
|
+
transition-delay: v-bind(fadeLeaveDelayCalc);
|
|
118
|
+
}
|
|
119
|
+
@include vt-hidden {
|
|
120
|
+
opacity: 0;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.px-background-fader {
|
|
125
|
+
width: 100%;
|
|
126
|
+
height: 100%;
|
|
127
|
+
position: relative;
|
|
128
|
+
|
|
129
|
+
&__group {
|
|
130
|
+
display: contents;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
&__bg {
|
|
134
|
+
@include abs();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
</style>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<
|
|
2
|
+
<component
|
|
3
|
+
:is="tag"
|
|
3
4
|
:class="
|
|
4
5
|
bem({
|
|
5
6
|
...variantsMap,
|
|
@@ -10,7 +11,7 @@
|
|
|
10
11
|
@click="tryToggleChecked"
|
|
11
12
|
>
|
|
12
13
|
<px-icon :class="iconClass" v-bind="calcIconAttrs" />
|
|
13
|
-
</
|
|
14
|
+
</component>
|
|
14
15
|
</template>
|
|
15
16
|
|
|
16
17
|
<script>
|
|
@@ -67,6 +68,8 @@ export default {
|
|
|
67
68
|
toggles: { type: Boolean, default: false },
|
|
68
69
|
|
|
69
70
|
iconClass: { type: String, default: null },
|
|
71
|
+
|
|
72
|
+
tag: { type: String, default: 'button' },
|
|
70
73
|
},
|
|
71
74
|
computed: {
|
|
72
75
|
calcIcon() {
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div :class="[bem({ fit }), isEmpty ? emptyClass : '', isError ? errorClass : '']">
|
|
3
|
+
<transition :name="transitionName">
|
|
4
|
+
<img
|
|
5
|
+
v-if="imageA"
|
|
6
|
+
:key="'A'"
|
|
7
|
+
:class="
|
|
8
|
+
bem('img', {
|
|
9
|
+
[imageA.state]:
|
|
10
|
+
imageA.state === IMAGE_STATES.LOADING ||
|
|
11
|
+
imageA.state === IMAGE_STATES.UNLOADING
|
|
12
|
+
? loadingVisible
|
|
13
|
+
: true,
|
|
14
|
+
})
|
|
15
|
+
"
|
|
16
|
+
:src="imageA.src"
|
|
17
|
+
@load="imageLoad(imageA)"
|
|
18
|
+
@error="imageError($event, imageA)"
|
|
19
|
+
/>
|
|
20
|
+
</transition>
|
|
21
|
+
<transition :name="transitionName">
|
|
22
|
+
<img
|
|
23
|
+
v-if="imageB"
|
|
24
|
+
:key="'B'"
|
|
25
|
+
:class="
|
|
26
|
+
bem('img', {
|
|
27
|
+
[imageB.state]:
|
|
28
|
+
imageB.state === IMAGE_STATES.LOADING ||
|
|
29
|
+
imageB.state === IMAGE_STATES.UNLOADING
|
|
30
|
+
? loadingVisible
|
|
31
|
+
: true,
|
|
32
|
+
})
|
|
33
|
+
"
|
|
34
|
+
:src="imageB.src"
|
|
35
|
+
@load="imageLoad(imageB)"
|
|
36
|
+
@error="imageError($event, imageB)"
|
|
37
|
+
/>
|
|
38
|
+
</transition>
|
|
39
|
+
|
|
40
|
+
<slot name="placeholder" v-if="isEmpty"></slot>
|
|
41
|
+
|
|
42
|
+
<slot name="error" v-if="isError">
|
|
43
|
+
<PxIcon :class="bem('error-icon')" size="1.5em" :src="AlertTriangleSvgUrl" />
|
|
44
|
+
</slot>
|
|
45
|
+
|
|
46
|
+
<slot name="loading" v-if="loadingVisible">
|
|
47
|
+
<PxSpinner v-if="showLoadingSpinner" :visible="true" />
|
|
48
|
+
</slot>
|
|
49
|
+
</div>
|
|
50
|
+
</template>
|
|
51
|
+
|
|
52
|
+
<script>
|
|
53
|
+
import PxSpinner from './PxSpinner.vue';
|
|
54
|
+
import PxIcon from './PxIcon.vue';
|
|
55
|
+
import AlertTriangleSvgUrl from '../assets/feather/alert-triangle.svg?url';
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
|
|
59
|
+
An image component that can be used to easily access advanced functionality for displaying images.
|
|
60
|
+
|
|
61
|
+
Current functionality includes:
|
|
62
|
+
|
|
63
|
+
- Monitory image load state and displaying a loading indicator
|
|
64
|
+
- Cusotmization (via css) of a background for the empty state
|
|
65
|
+
- Fade in on load
|
|
66
|
+
- Crossfade between images when source changes
|
|
67
|
+
- Customizable error state
|
|
68
|
+
- Handles background-like layout scenarios with cover and contain natively
|
|
69
|
+
|
|
70
|
+
Future functionality:
|
|
71
|
+
|
|
72
|
+
- Customization of previous (unloaded) image state when source changes (via class)
|
|
73
|
+
- srcset (with img tag) / image-set (with background)
|
|
74
|
+
- Prioritized / depriotized loading (relative to other images)
|
|
75
|
+
- Only load on active state (via active property) or when in viewport
|
|
76
|
+
- Deal with natural block layout (that allows the container to grow with the image data)
|
|
77
|
+
|
|
78
|
+
IMPLEMENTATION NOTE:
|
|
79
|
+
|
|
80
|
+
We ended up with the two image solution after trying a number of different approaches. The
|
|
81
|
+
primary advantage of this approach is that it keeps image data and loading attached to an img
|
|
82
|
+
elmement in the DOM which seems to simplify a lot of corner conditions like cancelling loads if the
|
|
83
|
+
src changes partway through loading, etc. and issues with flickering when loading large image files.
|
|
84
|
+
Mentioning here so that future me can avoid trying to "optimize" this later and making the same
|
|
85
|
+
mistakes.
|
|
86
|
+
|
|
87
|
+
*/
|
|
88
|
+
|
|
89
|
+
const IMAGE_STATES = {
|
|
90
|
+
// inactive so it won't be rendered
|
|
91
|
+
INACTIVE: 'inactive',
|
|
92
|
+
|
|
93
|
+
// src has set but image isn't loaded
|
|
94
|
+
LOADING: 'loading',
|
|
95
|
+
|
|
96
|
+
// image has loaded with valid source
|
|
97
|
+
LOADED: 'loaded',
|
|
98
|
+
|
|
99
|
+
// image is unloading (previous image)
|
|
100
|
+
UNLOADING: 'unloading',
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export default {
|
|
104
|
+
name: 'px-image',
|
|
105
|
+
components: { PxSpinner, PxIcon },
|
|
106
|
+
// mixins: [ Mixin ],
|
|
107
|
+
props: {
|
|
108
|
+
/**
|
|
109
|
+
* The image source.
|
|
110
|
+
*/
|
|
111
|
+
src: { type: String, default: null },
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The fit property specifies how the image should be sized to fit its container.
|
|
115
|
+
*/
|
|
116
|
+
fit: { type: String, default: 'contain' },
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Class that gets applied to the component if there is no current src (typically the
|
|
120
|
+
* initial state). Default behaviorOverridden is to set the background to a semi-transparent
|
|
121
|
+
* gray.
|
|
122
|
+
*/
|
|
123
|
+
emptyClass: { type: [String, Array, Object], default: 'px-image--empty' },
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Class that gets applied to the component if there is an error state. Default
|
|
127
|
+
* behaviorOverridden is to set the background to a semi-transparent gray like the empty
|
|
128
|
+
* state (and show the default error slot but that is controlled by the slot, not this
|
|
129
|
+
* prop).
|
|
130
|
+
*/
|
|
131
|
+
errorClass: { type: [String, Array, Object], default: 'px-image--error' },
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* When true, we'll restore an "unloading" image when possible if the new image fails to
|
|
135
|
+
* load. In this case, we'll continue to raise an event but we won't show the error state or
|
|
136
|
+
* set the error class.
|
|
137
|
+
*/
|
|
138
|
+
retorePreviousOnError: { type: Boolean, default: false },
|
|
139
|
+
|
|
140
|
+
// /**
|
|
141
|
+
// * Class that gets applied to the component when an image is loading. No default.
|
|
142
|
+
// */
|
|
143
|
+
// loadingClass: { type: [String, Array, Object], default: null },
|
|
144
|
+
|
|
145
|
+
// /**
|
|
146
|
+
// * Class that gets applied to all image elements. The default sets the opacity transition.
|
|
147
|
+
// */
|
|
148
|
+
// imageClass: { type: [String, Array, Object], default: 'px-image__image' },
|
|
149
|
+
|
|
150
|
+
// /**
|
|
151
|
+
// * Class that gets applied to all image elements while loading. Default set opacity to 0.
|
|
152
|
+
// */
|
|
153
|
+
// imageLoadingClass: { type: [String, Array, Object], default: 'px-image__image--loading' },
|
|
154
|
+
|
|
155
|
+
// /**
|
|
156
|
+
// * Class that gets applied to all image elements while unloading. Default sets opacity to 0.5.
|
|
157
|
+
// */
|
|
158
|
+
// imageUnloadingClass: {
|
|
159
|
+
// type: [String, Array, Object],
|
|
160
|
+
// default: 'px-image__image--unloading',
|
|
161
|
+
// },
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* A delay (in seconds) before showing loading state to avoid flickering when loading is fast.
|
|
165
|
+
*/
|
|
166
|
+
loadingVisibleDelay: { type: Number, default: 0.5 },
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Whether to show a loading spinner when loading in the default loading slot.
|
|
170
|
+
*/
|
|
171
|
+
showLoadingSpinner: { type: Boolean, default: true },
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Transition that gets applied when the src changes (we will generate multiple img elements
|
|
175
|
+
* in this case and apply this transition between them, leave as null to use the default
|
|
176
|
+
* which will fade.)
|
|
177
|
+
*/
|
|
178
|
+
transitionName: { type: String, default: 'px-image__fade' },
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Duration of the transition in seconds.
|
|
182
|
+
*/
|
|
183
|
+
transitionDuration: { type: Number, default: 0.5 },
|
|
184
|
+
},
|
|
185
|
+
data() {
|
|
186
|
+
const image = {
|
|
187
|
+
state: IMAGE_STATES.INACTIVE,
|
|
188
|
+
src: null,
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
// we manage the data for two images of these (one for the loaded image and one for the
|
|
193
|
+
// next image so we can fade on load) but only render as needed
|
|
194
|
+
images: [{ ...image }, { ...image }],
|
|
195
|
+
|
|
196
|
+
// reference to the last loading error if there is one (otherwise null)
|
|
197
|
+
error: null,
|
|
198
|
+
|
|
199
|
+
// whether to show the loading state (baed on loadingVisibleDelay)
|
|
200
|
+
loadingVisible: false,
|
|
201
|
+
|
|
202
|
+
// so we can access from the template
|
|
203
|
+
IMAGE_STATES,
|
|
204
|
+
AlertTriangleSvgUrl,
|
|
205
|
+
};
|
|
206
|
+
},
|
|
207
|
+
computed: {
|
|
208
|
+
imageA() {
|
|
209
|
+
return this.images[0]?.state !== IMAGE_STATES.INACTIVE ? this.images[0] : null;
|
|
210
|
+
},
|
|
211
|
+
imageB() {
|
|
212
|
+
return this.images[1]?.state !== IMAGE_STATES.INACTIVE ? this.images[1] : null;
|
|
213
|
+
},
|
|
214
|
+
isError() {
|
|
215
|
+
return !!this.error;
|
|
216
|
+
},
|
|
217
|
+
isEmpty() {
|
|
218
|
+
return !this.isError && !this.imageA?.src && !this.imageB?.src;
|
|
219
|
+
},
|
|
220
|
+
isLoading() {
|
|
221
|
+
return !this.isError && this.images.some(i => i.state === IMAGE_STATES.LOADING);
|
|
222
|
+
},
|
|
223
|
+
cssTransitionDuration() {
|
|
224
|
+
return this.transitionDuration + 's';
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
watch: {
|
|
228
|
+
src: {
|
|
229
|
+
immediate: true,
|
|
230
|
+
handler(nv) {
|
|
231
|
+
// clear previous errors
|
|
232
|
+
this.error = null;
|
|
233
|
+
|
|
234
|
+
// trim src (just in case but also to prevent false positives on empty string)
|
|
235
|
+
nv = typeof nv === 'string' ? nv.trim() : null;
|
|
236
|
+
|
|
237
|
+
if (!nv) {
|
|
238
|
+
// if we have no src, just mark both images as inactive
|
|
239
|
+
this.images.forEach(i => (i.state = IMAGE_STATES.INACTIVE));
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// find an an inactive, unloaded or loading image to update (priotize loading so we don't end
|
|
244
|
+
// up with two loading)
|
|
245
|
+
|
|
246
|
+
const nextImage =
|
|
247
|
+
this.images.find(i => i.state === IMAGE_STATES.LOADING) ||
|
|
248
|
+
this.images.find(i => i.state === IMAGE_STATES.INACTIVE);
|
|
249
|
+
|
|
250
|
+
if (nextImage) {
|
|
251
|
+
nextImage.src = nv;
|
|
252
|
+
nextImage.state = IMAGE_STATES.LOADING;
|
|
253
|
+
} else {
|
|
254
|
+
console.warn('No image available to update');
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// if we have a loaded image, mark it as unloading
|
|
259
|
+
const prevImage = this.images.find(i => i.state === IMAGE_STATES.LOADED);
|
|
260
|
+
|
|
261
|
+
if (prevImage) {
|
|
262
|
+
prevImage.state = IMAGE_STATES.UNLOADING;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
//image.state = IMAGE_STATES.LOADING;
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
isLoading(nv) {
|
|
269
|
+
clearTimeout(this.loadingTimeout);
|
|
270
|
+
|
|
271
|
+
if (!nv) {
|
|
272
|
+
this.loadingVisible = false;
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
this.loadingTimeout = setTimeout(() => {
|
|
277
|
+
this.loadingVisible = this.isLoading;
|
|
278
|
+
}, this.loadingVisibleDelay * 1000);
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
mounted() {
|
|
282
|
+
this.nextId = 0;
|
|
283
|
+
this.invalidIds = [];
|
|
284
|
+
},
|
|
285
|
+
methods: {
|
|
286
|
+
resetImages() {
|
|
287
|
+
this.images[0].state = IMAGE_STATES.INACTIVE;
|
|
288
|
+
this.images[0].src = null;
|
|
289
|
+
this.images[1].state = IMAGE_STATES.INACTIVE;
|
|
290
|
+
this.images[1].src = null;
|
|
291
|
+
},
|
|
292
|
+
imageLoad(image) {
|
|
293
|
+
this.$emit('load', image.src);
|
|
294
|
+
|
|
295
|
+
// mark the current image as loaded
|
|
296
|
+
image.state = IMAGE_STATES.LOADED;
|
|
297
|
+
|
|
298
|
+
// mark the previous image as inactive so we can hide remove it from the DOM
|
|
299
|
+
const prevImage = this.images.find(i => i.state === IMAGE_STATES.UNLOADING);
|
|
300
|
+
if (prevImage) {
|
|
301
|
+
prevImage.state = IMAGE_STATES.INACTIVE;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// we shouldn't have only have loaded or inactive images at this point
|
|
305
|
+
const loadingImage = this.images.find(
|
|
306
|
+
i => !(i.state == IMAGE_STATES.LOADED || i.state == IMAGE_STATES.INACTIVE),
|
|
307
|
+
);
|
|
308
|
+
if (loadingImage) {
|
|
309
|
+
console.warn('Loading image not properly cleared');
|
|
310
|
+
}
|
|
311
|
+
},
|
|
312
|
+
imageError(e, image) {
|
|
313
|
+
const error = { event: e, image };
|
|
314
|
+
this.$emit('error', error);
|
|
315
|
+
|
|
316
|
+
image.state = IMAGE_STATES.INACTIVE;
|
|
317
|
+
|
|
318
|
+
// try to restore a previous image if we have one and retorePreviousOnError is true
|
|
319
|
+
if (this.retorePreviousOnError) {
|
|
320
|
+
const prevImage = this.images.find(i => i.state === IMAGE_STATES.UNLOADING);
|
|
321
|
+
if (prevImage) {
|
|
322
|
+
prevImage.state = IMAGE_STATES.LOADED;
|
|
323
|
+
prevImage.src = image.src;
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// otherwise, set the error and reset images
|
|
329
|
+
this.error = error;
|
|
330
|
+
this.resetImages();
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
</script>
|
|
335
|
+
|
|
336
|
+
<style lang="scss">
|
|
337
|
+
@use '../styles/px.scss' as *;
|
|
338
|
+
|
|
339
|
+
$ease: $ease-out-cubic;
|
|
340
|
+
$dur: v-bind(cssTransitionDuration);
|
|
341
|
+
$name: px-image__fade;
|
|
342
|
+
|
|
343
|
+
.px-image {
|
|
344
|
+
width: 100%;
|
|
345
|
+
height: 100%;
|
|
346
|
+
position: relative;
|
|
347
|
+
|
|
348
|
+
// so that placeholder and loading are centered
|
|
349
|
+
display: flex;
|
|
350
|
+
align-items: center;
|
|
351
|
+
justify-content: center;
|
|
352
|
+
|
|
353
|
+
// for the error and empty states
|
|
354
|
+
@include transition(background-color);
|
|
355
|
+
|
|
356
|
+
img {
|
|
357
|
+
@include abs-fill();
|
|
358
|
+
object-position: center center;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
&--fit-cover {
|
|
362
|
+
img {
|
|
363
|
+
object-fit: cover;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
&--fit-contain {
|
|
368
|
+
img {
|
|
369
|
+
object-fit: contain;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
&--error,
|
|
374
|
+
&--empty {
|
|
375
|
+
background-color: rgba(#444, 0.1);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
&__img {
|
|
379
|
+
transition: opacity $dur $ease;
|
|
380
|
+
|
|
381
|
+
// default transition is to fade
|
|
382
|
+
|
|
383
|
+
&.#{$name}-enter,
|
|
384
|
+
&.#{$name}-enter-from,
|
|
385
|
+
&.#{$name}-leave-to {
|
|
386
|
+
opacity: 0;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
&--loading {
|
|
390
|
+
visibility: hidden;
|
|
391
|
+
opacity: 0 !important;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
&--unloading {
|
|
395
|
+
opacity: 0.5;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
</style>
|
package/package.json
CHANGED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import PxBackgroundFader from '@/components/PxBackgroundFader.vue';
|
|
2
|
+
import PxButton from '@/components/PxButton.vue';
|
|
3
|
+
import { extractArgTypes } from '@/storybook/sb-utils.js';
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
title: 'PxBackgroundFader',
|
|
7
|
+
component: PxBackgroundFader,
|
|
8
|
+
|
|
9
|
+
argTypes: {
|
|
10
|
+
...extractArgTypes(PxBackgroundFader),
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const Basic = {
|
|
15
|
+
render: (args, { argTypes }) => ({
|
|
16
|
+
components: { PxBackgroundFader, PxButton },
|
|
17
|
+
|
|
18
|
+
data() {
|
|
19
|
+
return {
|
|
20
|
+
args,
|
|
21
|
+
currentBackground: 'story-bg-blue',
|
|
22
|
+
};
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
template: `
|
|
26
|
+
<div class="story-aspect-16-9" style="overflow: hidden;">
|
|
27
|
+
<PxBackgroundFader :background="currentBackground"></PxBackgroundFader>
|
|
28
|
+
<PxButton style="position: absolute; top: 1em; left: 1em; z-index: 1" @click="changeBackground">Fade</PxButton>
|
|
29
|
+
</div>
|
|
30
|
+
`,
|
|
31
|
+
|
|
32
|
+
methods: {
|
|
33
|
+
changeBackground() {
|
|
34
|
+
const backgrounds = 'story-bg-orange|story-bg-blue|story-bg-green|story-bg-gold'
|
|
35
|
+
.split('|')
|
|
36
|
+
.filter(c => c !== this.currentBackground);
|
|
37
|
+
this.currentBackground =
|
|
38
|
+
backgrounds[Math.floor(Math.random() * backgrounds.length)];
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
}),
|
|
42
|
+
};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import PxImage from '@/components/PxImage.vue';
|
|
2
|
+
import PxButton from '@/components/PxButton.vue';
|
|
3
|
+
import { extractArgTypes } from '@/storybook/sb-utils.js';
|
|
4
|
+
import px01jpg from '@/stories/assets/px-01.jpg';
|
|
5
|
+
import px02jpg from '@/stories/assets/px-02.jpg';
|
|
6
|
+
import px03jpg from '@/stories/assets/px-03.jpg';
|
|
7
|
+
import px04jpg from '@/stories/assets/px-04.jpg';
|
|
8
|
+
|
|
9
|
+
const localImages = [px01jpg, px02jpg, px03jpg, px04jpg];
|
|
10
|
+
|
|
11
|
+
const largeImages = [
|
|
12
|
+
'https://images.unsplash.com/photo-1738969773091-abcf274f7e0a?q=80&w=3000',
|
|
13
|
+
'https://images.unsplash.com/photo-1738940251292-49709608c8aa?q=80&w=5970',
|
|
14
|
+
'https://images.unsplash.com/photo-1551692554-d83872f34d55?q=80&w=3226',
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const images = localImages;
|
|
18
|
+
|
|
19
|
+
// REFERENCE:
|
|
20
|
+
//
|
|
21
|
+
// https://storybook.js.org/docs/get-started/frameworks/vue3-vite?renderer=vue
|
|
22
|
+
// https://storybook.js.org/docs/vue/essentials/controls#annotation?renderer=vue
|
|
23
|
+
// https://storybook.js.org/docs/vue/api/argtypes#available-properties?renderer=vue
|
|
24
|
+
//
|
|
25
|
+
// See preview.scss for helper styles.
|
|
26
|
+
|
|
27
|
+
export default {
|
|
28
|
+
title: 'PxImage',
|
|
29
|
+
component: PxImage,
|
|
30
|
+
|
|
31
|
+
// decorators: [ ... ],
|
|
32
|
+
|
|
33
|
+
// parameters: {
|
|
34
|
+
// layout: 'fullscreen', // centered, fullscreen, padded (default)
|
|
35
|
+
// },
|
|
36
|
+
|
|
37
|
+
argTypes: {
|
|
38
|
+
...extractArgTypes(PxImage),
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
args: {
|
|
42
|
+
fit: 'contain',
|
|
43
|
+
position: null,
|
|
44
|
+
cacheBuster: 'cb',
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const Basic = {
|
|
49
|
+
render: (args, { argTypes }) => ({
|
|
50
|
+
components: { PxImage, PxButton },
|
|
51
|
+
|
|
52
|
+
data() {
|
|
53
|
+
return {
|
|
54
|
+
args,
|
|
55
|
+
currentSourceIndex: -1,
|
|
56
|
+
};
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
template: `
|
|
60
|
+
|
|
61
|
+
<div class="story-aspect-16-9" style="overflow: hidden;">
|
|
62
|
+
<PxImage :src="currentSrc" v-bind="args"></PxImage>
|
|
63
|
+
</div>
|
|
64
|
+
<br>
|
|
65
|
+
<p style="max-width: 50em">NOTE: when running with cache disabled, cross fades may appear jumpy because the image will be reloaded while it's fading in. To allow easier visualization of loading (without cache disabled), we a cache buster to the urls when testing (so don't run with cache disabled).</p>
|
|
66
|
+
<div style="display: flex; gap: 1em;">
|
|
67
|
+
<PxButton @click="nextSource">Next Source</PxButton>
|
|
68
|
+
<PxButton @click="clearSource">Clear</PxButton>
|
|
69
|
+
<PxButton @click="invalidSource">Error</PxButton>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
`,
|
|
73
|
+
|
|
74
|
+
computed: {
|
|
75
|
+
currentSrc() {
|
|
76
|
+
if (this.currentSourceIndex == -999) {
|
|
77
|
+
return 'https://thisurldoesnotexist.com/faker-mc-fakerson-face.jpg';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (this.currentSourceIndex == -1) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const src = images[this.currentSourceIndex];
|
|
85
|
+
const sep = src.includes('?') ? '&' : '?';
|
|
86
|
+
const cacheBuster = `${sep}${this.cacheBuster}=${Date.now()}`;
|
|
87
|
+
|
|
88
|
+
return src + cacheBuster;
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
methods: {
|
|
92
|
+
nextSource() {
|
|
93
|
+
const current = Math.max(this.currentSourceIndex, -1);
|
|
94
|
+
this.currentSourceIndex = (current + 1) % images.length;
|
|
95
|
+
},
|
|
96
|
+
clearSource() {
|
|
97
|
+
this.currentSourceIndex = -1;
|
|
98
|
+
},
|
|
99
|
+
invalidSource() {
|
|
100
|
+
this.currentSourceIndex = -999;
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
}),
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// export const CovertBottomRight = {
|
|
107
|
+
// ...Basic,
|
|
108
|
+
// args: { fit: 'cover', position: 'bottom right' },
|
|
109
|
+
// };
|
|
110
|
+
|
|
111
|
+
// export const Advanced = {
|
|
112
|
+
// ...Basic,
|
|
113
|
+
// };
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/storybook/sb-styles.scss
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
|
|
2
1
|
@use '@thinkpixellab-public/px-styles/all' as *;
|
|
3
2
|
|
|
4
|
-
|
|
5
3
|
// Update this to test font inheritance
|
|
6
4
|
.sbdocs {
|
|
7
5
|
font-family: sans-serif;
|
|
@@ -88,9 +86,7 @@ body {
|
|
|
88
86
|
}
|
|
89
87
|
|
|
90
88
|
.story-aspect-16-9 {
|
|
91
|
-
background-color: tomato;
|
|
92
89
|
width: 100%;
|
|
93
|
-
border-radius: 8px;
|
|
94
90
|
aspect-ratio: 16/9;
|
|
95
91
|
}
|
|
96
92
|
|
|
@@ -124,6 +120,31 @@ body {
|
|
|
124
120
|
padding: 1em;
|
|
125
121
|
}
|
|
126
122
|
|
|
123
|
+
.story-state-blue {
|
|
124
|
+
background-color: dodgerblue;
|
|
125
|
+
color: white;
|
|
126
|
+
width: 3em;
|
|
127
|
+
height: 3em;
|
|
128
|
+
border-radius: 8px;
|
|
129
|
+
padding: 1em;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.story-bg-orange {
|
|
133
|
+
background-color: tomato;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.story-bg-blue {
|
|
137
|
+
background-color: dodgerblue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.story-bg-green {
|
|
141
|
+
background-color: yellowgreen;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.story-bg-gold {
|
|
145
|
+
background-color: gold;
|
|
146
|
+
}
|
|
147
|
+
|
|
127
148
|
.story-size-xs {
|
|
128
149
|
width: 2em;
|
|
129
150
|
height: 2em;
|