@thinkpixellab-public/px-vue 4.1.6 → 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.
@@ -120,7 +120,7 @@ export default {
120
120
  }
121
121
 
122
122
  // fallback is 1:1
123
- return 16 / 9;
123
+ return 1 / 1;
124
124
  },
125
125
  calcHeight() {
126
126
  if (!this.size && this.svgHeight) {
@@ -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>
@@ -30,41 +30,29 @@ PxVue contains the full set of [feather icons](https://feathericons.com/) and a
30
30
  those available by name within the application. See the plugin reference below.
31
31
 
32
32
  ```js
33
- // px-vue feather icons helper
33
+
34
+ // icons.js (add to plugins folder)
35
+
36
+ import { defineNuxtPlugin } from '#app';
37
+ import getIcons from '@thinkpixellab-public/px-vue/utils/getIcons.js';
34
38
  import getFeatherIcons from '@thinkpixellab-public/px-vue/utils/getFeatherIcons.js';
35
39
 
36
40
  export default defineNuxtPlugin(nuxtApp => {
37
- const vueApp = nuxtApp.vueApp;
38
-
39
- // search for icons in the @/assets/feather folder
40
- const icons = Object.values(
41
- import.meta.globEager('@thinkpixellab-public/px-vue/assets/feather/*.svg')
42
- ).reduce((obj, value) => {
43
- const url = value.default;
44
- const name = url.match(/([^\/]+)(?=\.\w+$)/)[0];
45
- obj[name] = url;
46
- return obj;
47
- }, {});
48
-
49
- vueApp.provide('PxIcon.icons', {
50
- // local icons
51
- ...icons,
52
-
53
- // feather icons from px-vue
41
+ nuxtApp.vueApp?.provide('PxIcon.icons', {
42
+
43
+ // use getFeatherIcons to get a filtered map of px-vue feather icons
54
44
  ...getFeatherIcons([
55
- 'chevron-left',
56
- 'chevron-right',
57
- 'chevron-up',
58
- 'chevron-down',
59
45
  'arrow-left',
60
46
  'arrow-right',
61
- 'arrow-up',
62
- 'arrow-down',
63
- 'settings',
64
47
  'folder',
65
48
  ]),
49
+
50
+ // use getIcons to get a map of custom icons
51
+ ...getIcons(import.meta.glob('~/assets/icons/*.svg', { eager: true })),
66
52
  });
67
53
  });
54
+
55
+
68
56
  ```
69
57
  */
70
58
 
@@ -1,5 +1,6 @@
1
1
  <template>
2
- <button
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
- </button>
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() {
@@ -127,7 +130,7 @@ export default {
127
130
  .px-icon-button {
128
131
  // VARIANTS
129
132
 
130
- @include control-reset();
133
+ @include controls.control-reset();
131
134
 
132
135
  &--default {
133
136
  @include controls.button-icon(
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thinkpixellab-public/px-vue",
3
- "version": "4.1.6",
3
+ "version": "4.1.8",
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,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
@@ -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;
@@ -0,0 +1,46 @@
1
+ import { objectToArray } from '../utils/utils.js';
2
+
3
+ test('objectToArray (defaults with value type value)', () => {
4
+ expect(objectToArray({ one: 1, two: 2, three: 3 })).toEqual([
5
+ { key: 'one', value: 1 },
6
+ { key: 'two', value: 2 },
7
+ { key: 'three', value: 3 },
8
+ ]);
9
+ });
10
+
11
+ test('objectToArray (override naming params)', () => {
12
+ expect(objectToArray({ one: 1, two: 2, three: 3 }, 'id', 'number')).toEqual([
13
+ { id: 'one', number: 1 },
14
+ { id: 'two', number: 2 },
15
+ { id: 'three', number: 3 },
16
+ ]);
17
+ });
18
+
19
+ test('objectToArray (defaults with object type value)', () => {
20
+ expect(objectToArray({ one: { number: 1 }, two: { number: 2 }, three: { number: 3 } })).toEqual(
21
+ [
22
+ { key: 'one', value: { number: 1 } },
23
+ { key: 'two', value: { number: 2 } },
24
+ { key: 'three', value: { number: 3 } },
25
+ ],
26
+ );
27
+ });
28
+
29
+ test('objectToArray (expand objects)', () => {
30
+ expect(
31
+ objectToArray(
32
+ {
33
+ one: { number: 1, ordinal: 'first' },
34
+ two: { number: 2, ordinal: 'second' },
35
+ three: { number: 3, ordinal: 'third' },
36
+ },
37
+ undefined,
38
+ undefined,
39
+ true,
40
+ ),
41
+ ).toEqual([
42
+ { key: 'one', number: 1, ordinal: 'first' },
43
+ { key: 'two', number: 2, ordinal: 'second' },
44
+ { key: 'three', number: 3, ordinal: 'third' },
45
+ ]);
46
+ });
@@ -1,25 +1,18 @@
1
+ import getIcons from './getIcons.js';
2
+ import { filterObjectByKeys } from './utils.js';
1
3
  /**
2
4
  * Returns an object enumerating feather icons that can be used with a PxIcons provide function to
3
5
  * make feather icons available. If the names array non-null is provided, only icons with those
4
6
  * names will be included.
5
7
  */
6
8
 
7
- const getFeatherIcons = names => {
8
- const icons = Object.values(import.meta.globEager('../assets/feather/*.svg')).reduce(
9
- (obj, value) => {
10
- const url = value.default;
11
- const name = url.match(/([^\/]+)(?=\.\w+$)/)[0];
9
+ export default function getFeatherIcons(names) {
10
+ const importIcons = import.meta.glob('../assets/feather/*.svg', { eager: true });
11
+ const icons = getIcons(importIcons);
12
12
 
13
- // filter if names is provided
14
- if ((names && names.includes(name)) || !names) {
15
- obj[name] = url;
16
- }
17
- return obj;
18
- },
19
- {}
20
- );
21
-
22
- return icons;
23
- };
24
-
25
- export default getFeatherIcons;
13
+ if (names && names.length) {
14
+ return filterObjectByKeys(icons, names);
15
+ } else {
16
+ return icons;
17
+ }
18
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Processes a raw icon map (e.g. from import.meta.glob) and returns a formatted map.
3
+ *
4
+ * @param {object} iconsImport - The raw object returned by import.meta.glob.
5
+ * @returns {object} iconMap - An object mapping icon names to their corresponding modules.
6
+ *
7
+ * @example
8
+ * // In your file (for example, in nuxt.config.js or another module processed by Vite):
9
+ * // Use a literal glob pattern to ensure Vite can statically analyze the files.
10
+ *
11
+ * import { getIcons } from './iconsUtil.js';
12
+ * const importIcons = import.meta.glob('~/assets/icons/*.svg', { eager: true });
13
+ * const icons = getIcons(importIcons);
14
+ *
15
+ */
16
+
17
+ export default function getIcons(iconsImport) {
18
+ const iconMap = Object.fromEntries(
19
+ Object.entries(iconsImport).map(([path, module]) => {
20
+ const parts = path.split('/');
21
+ const fileName = parts[parts.length - 1];
22
+ const iconName = fileName.replace('.svg', '');
23
+ return [iconName, module.default || module];
24
+ }),
25
+ );
26
+
27
+ return iconMap;
28
+ }
package/utils/utils.js CHANGED
@@ -204,11 +204,20 @@ export function arrayMoveItem(arr, fromIndex, toIndex) {
204
204
  arr.splice(toIndex, 0, arr.splice(fromIndex, 1)[0]);
205
205
  }
206
206
 
207
+ export function objectToArray(object, keyName = 'key', valueName = 'value', expandObjects = false) {
208
+ return Object.keys(object).map(key => {
209
+ if (expandObjects && typeof object[key] === 'object') {
210
+ return { [keyName]: key, ...object[key] };
211
+ }
212
+ return { [keyName]: key, [valueName]: object[key] };
213
+ });
214
+ }
215
+
207
216
  /**
208
- * Returns a version with only values that past the test defined in filterFunction.
217
+ * Returns a version of an object with only values that past the test defined in filterFunction.
209
218
  *
210
219
  * @param {object} obj
211
- * @param {functintion} filterFunction
220
+ * @param {function} filterFunction
212
221
  * @return {object}
213
222
  */
214
223
  export function filterObject(obj, filterFunction) {
@@ -220,6 +229,28 @@ export function filterObject(obj, filterFunction) {
220
229
  }, {});
221
230
  }
222
231
 
232
+ /**
233
+ * Filters an object to only include keys that are present in the allowedKeys array.
234
+ *
235
+ * @param {Object} obj - The object to filter.
236
+ * @param {string[]} allowedKeys - An array of keys that should be retained in the result.
237
+ * @returns {Object} A new object containing only the key/value pairs for keys in allowedKeys.
238
+ *
239
+ * @example
240
+ * const data = { a: 1, b: 2, c: 3 };
241
+ * const result = filterObjectByKeys(data, ['a', 'c']);
242
+ * // result is { a: 1, c: 3 }
243
+ */
244
+
245
+ export function filterObjectByKeys(obj, allowedKeys) {
246
+ return Object.keys(obj)
247
+ .filter(key => allowedKeys.includes(key))
248
+ .reduce((result, key) => {
249
+ result[key] = obj[key];
250
+ return result;
251
+ }, {});
252
+ }
253
+
223
254
  /**
224
255
  * Returns an object representing the current query string parameters.
225
256
  */