@fiscozen/thumbnail 0.2.0

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.
@@ -0,0 +1,94 @@
1
+ import { computed as e, createCommentVNode as t, createElementBlock as n, createVNode as r, defineComponent as i, mergeProps as a, normalizeClass as o, normalizeStyle as s, openBlock as c, ref as l, renderSlot as u, unref as d, watch as f } from "vue";
2
+ import { FzIcon as p } from "@fiscozen/icons";
3
+ //#region src/FzThumbnail.vue?vue&type=script&setup=true&lang.ts
4
+ var m = [
5
+ "src",
6
+ "alt",
7
+ "loading"
8
+ ], h = ["role", "aria-label"], g = {
9
+ key: 2,
10
+ class: "fz-thumbnail__scrim absolute inset-0",
11
+ "data-testid": "fz-thumbnail-scrim"
12
+ }, _ = /* @__PURE__ */ ((e, t) => {
13
+ let n = e.__vccOpts || e;
14
+ for (let [e, r] of t) n[e] = r;
15
+ return n;
16
+ })(/* @__PURE__ */ i({
17
+ __name: "FzThumbnail",
18
+ props: {
19
+ src: {},
20
+ alt: {},
21
+ width: {},
22
+ height: {},
23
+ aspectRatio: {},
24
+ radius: { default: "base" },
25
+ bordered: {
26
+ type: Boolean,
27
+ default: !1
28
+ },
29
+ scrim: {
30
+ type: Boolean,
31
+ default: !1
32
+ },
33
+ overlayPosition: { default: "bottom-end" },
34
+ placeholderIcon: { default: "file" },
35
+ loading: { default: "lazy" },
36
+ imgProps: {}
37
+ },
38
+ emits: ["error"],
39
+ setup(i, { emit: _ }) {
40
+ let v = i, y = _, b = l(!1);
41
+ f(() => v.src, () => {
42
+ b.value = !1;
43
+ });
44
+ let x = e(() => !!v.src && !b.value), S = e(() => b.value && v.alt ? v.alt : void 0), C = {
45
+ none: "rounded-none",
46
+ sm: "rounded-sm",
47
+ base: "rounded",
48
+ lg: "rounded-lg",
49
+ xl: "rounded-xl"
50
+ }, w = {
51
+ "top-start": "items-start justify-start",
52
+ "top-end": "items-start justify-end",
53
+ "bottom-start": "items-end justify-start",
54
+ "bottom-end": "items-end justify-end",
55
+ center: "items-center justify-center"
56
+ }, T = e(() => ({
57
+ ...v.width ? { width: v.width } : {},
58
+ ...v.height ? { height: v.height } : {},
59
+ ...v.aspectRatio ? { aspectRatio: v.aspectRatio } : {}
60
+ })), E = () => {
61
+ b.value = !0, y("error", v.src);
62
+ };
63
+ return (e, l) => (c(), n("div", {
64
+ class: o(["fz-thumbnail bg-core-white relative overflow-hidden", [C[i.radius], { "border-1 border-grey-100 border-solid": i.bordered }]]),
65
+ style: s(T.value)
66
+ }, [
67
+ x.value ? (c(), n("img", a({ key: 0 }, i.imgProps, {
68
+ src: i.src,
69
+ alt: i.alt,
70
+ loading: i.loading,
71
+ class: "block size-full object-cover",
72
+ onError: E
73
+ }), null, 16, m)) : (c(), n("div", {
74
+ key: 1,
75
+ class: "bg-grey-100 grid size-full place-content-center",
76
+ role: S.value ? "img" : void 0,
77
+ "aria-label": S.value,
78
+ "data-testid": "fz-thumbnail-placeholder"
79
+ }, [r(d(p), {
80
+ name: i.placeholderIcon,
81
+ size: "lg",
82
+ class: "text-grey-300"
83
+ }, null, 8, ["name"])], 8, h)),
84
+ i.scrim && x.value ? (c(), n("div", g)) : t("", !0),
85
+ e.$slots.overlay ? (c(), n("div", {
86
+ key: 3,
87
+ class: o(["pointer-events-none absolute inset-0 flex p-8 [&>*]:pointer-events-auto", w[i.overlayPosition]]),
88
+ "data-testid": "fz-thumbnail-overlay"
89
+ }, [u(e.$slots, "overlay", {}, void 0, !0)], 2)) : t("", !0)
90
+ ], 6));
91
+ }
92
+ }), [["__scopeId", "data-v-a2d500cf"]]);
93
+ //#endregion
94
+ export { _ as FzThumbnail };
@@ -0,0 +1 @@
1
+ (function(e,t){typeof exports==`object`&&typeof module<`u`?t(exports,require(`vue`),require(`@fiscozen/icons`)):typeof define==`function`&&define.amd?define([`exports`,`vue`,`@fiscozen/icons`],t):(e=typeof globalThis<`u`?globalThis:e||self,t(e.FzThumbnail={},e.Vue,e._fiscozen_icons))})(this,function(e,t,n){Object.defineProperty(e,Symbol.toStringTag,{value:`Module`});var r=[`src`,`alt`,`loading`],i=[`role`,`aria-label`],a={key:2,class:`fz-thumbnail__scrim absolute inset-0`,"data-testid":`fz-thumbnail-scrim`};e.FzThumbnail=((e,t)=>{let n=e.__vccOpts||e;for(let[e,r]of t)n[e]=r;return n})((0,t.defineComponent)({__name:`FzThumbnail`,props:{src:{},alt:{},width:{},height:{},aspectRatio:{},radius:{default:`base`},bordered:{type:Boolean,default:!1},scrim:{type:Boolean,default:!1},overlayPosition:{default:`bottom-end`},placeholderIcon:{default:`file`},loading:{default:`lazy`},imgProps:{}},emits:[`error`],setup(e,{emit:o}){let s=e,c=o,l=(0,t.ref)(!1);(0,t.watch)(()=>s.src,()=>{l.value=!1});let u=(0,t.computed)(()=>!!s.src&&!l.value),d=(0,t.computed)(()=>l.value&&s.alt?s.alt:void 0),f={none:`rounded-none`,sm:`rounded-sm`,base:`rounded`,lg:`rounded-lg`,xl:`rounded-xl`},p={"top-start":`items-start justify-start`,"top-end":`items-start justify-end`,"bottom-start":`items-end justify-start`,"bottom-end":`items-end justify-end`,center:`items-center justify-center`},m=(0,t.computed)(()=>({...s.width?{width:s.width}:{},...s.height?{height:s.height}:{},...s.aspectRatio?{aspectRatio:s.aspectRatio}:{}})),h=()=>{l.value=!0,c(`error`,s.src)};return(o,s)=>((0,t.openBlock)(),(0,t.createElementBlock)(`div`,{class:(0,t.normalizeClass)([`fz-thumbnail bg-core-white relative overflow-hidden`,[f[e.radius],{"border-1 border-grey-100 border-solid":e.bordered}]]),style:(0,t.normalizeStyle)(m.value)},[u.value?((0,t.openBlock)(),(0,t.createElementBlock)(`img`,(0,t.mergeProps)({key:0},e.imgProps,{src:e.src,alt:e.alt,loading:e.loading,class:`block size-full object-cover`,onError:h}),null,16,r)):((0,t.openBlock)(),(0,t.createElementBlock)(`div`,{key:1,class:`bg-grey-100 grid size-full place-content-center`,role:d.value?`img`:void 0,"aria-label":d.value,"data-testid":`fz-thumbnail-placeholder`},[(0,t.createVNode)((0,t.unref)(n.FzIcon),{name:e.placeholderIcon,size:`lg`,class:`text-grey-300`},null,8,[`name`])],8,i)),e.scrim&&u.value?((0,t.openBlock)(),(0,t.createElementBlock)(`div`,a)):(0,t.createCommentVNode)(``,!0),o.$slots.overlay?((0,t.openBlock)(),(0,t.createElementBlock)(`div`,{key:3,class:(0,t.normalizeClass)([`pointer-events-none absolute inset-0 flex p-8 [&>*]:pointer-events-auto`,p[e.overlayPosition]]),"data-testid":`fz-thumbnail-overlay`},[(0,t.renderSlot)(o.$slots,`overlay`,{},void 0,!0)],2)):(0,t.createCommentVNode)(``,!0)],6))}}),[[`__scopeId`,`data-v-a2d500cf`]])});
package/eslintrc.cjs ADDED
@@ -0,0 +1,18 @@
1
+ /* eslint-env node */
2
+ require('@rushstack/eslint-patch/modern-module-resolution')
3
+
4
+ module.exports = {
5
+ root: true,
6
+ 'extends': ['@fiscozen/eslint-config', 'plugin:storybook/recommended'],
7
+ overrides: [
8
+ {
9
+ files: [
10
+ 'cypress/e2e/**/*.{cy,spec}.{js,ts,jsx,tsx}',
11
+ 'cypress/support/**/*.{js,ts,jsx,tsx}'
12
+ ],
13
+ 'extends': [
14
+ 'plugin:cypress/recommended'
15
+ ]
16
+ }
17
+ ]
18
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@fiscozen/thumbnail",
3
+ "version": "0.2.0",
4
+ "description": "Design System Thumbnail component",
5
+ "main": "src/index.ts",
6
+ "type": "module",
7
+ "keywords": [],
8
+ "author": "Riccardo Agnoletto",
9
+ "peerDependencies": {
10
+ "tailwindcss": "^3.4.1",
11
+ "vue": "^3.4.13",
12
+ "@fiscozen/icons": "^1.0.8"
13
+ },
14
+ "devDependencies": {
15
+ "@rushstack/eslint-patch": "^1.3.3",
16
+ "@types/jsdom": "^21.1.6",
17
+ "@types/node": "^18.19.3",
18
+ "@vitejs/plugin-vue": "^6.0.5",
19
+ "@vitest/coverage-v8": "^4.1.1",
20
+ "@vue/test-utils": "^2.4.3",
21
+ "@vue/tsconfig": "^0.5.0",
22
+ "eslint": "^8.49.0",
23
+ "jsdom": "^23.0.1",
24
+ "prettier": "^3.0.3",
25
+ "typescript": "~5.7.0",
26
+ "vite": "^8.0.0",
27
+ "vite-plugin-dts": "^4.5.0",
28
+ "vitest": "^4.1.1",
29
+ "vue-tsc": "^2.2.12",
30
+ "@fiscozen/eslint-config": "^0.1.0",
31
+ "@fiscozen/tsconfig": "^0.1.0",
32
+ "@fiscozen/prettier-config": "^0.1.0"
33
+ },
34
+ "license": "MIT",
35
+ "scripts": {
36
+ "coverage": "vitest run --coverage",
37
+ "format": "prettier --write src/",
38
+ "test:unit": "vitest run",
39
+ "build": "vue-tsc && vite build"
40
+ }
41
+ }
package/prettierrc.js ADDED
@@ -0,0 +1,5 @@
1
+ import fiscozenPrettierConfig from '@fiscozen/prettier-config';
2
+
3
+ export default {
4
+ ...fiscozenPrettierConfig
5
+ };
@@ -0,0 +1,178 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * FzThumbnail – shows an image from a URL inside a box the caller sizes.
4
+ *
5
+ * The design system had no way to render an arbitrary image (LIB-2918): the only
6
+ * three components that render an `<img>` are `FzAvatar` — round, with hardwired
7
+ * sizes — and `FzRadioCard` / `FzCheckboxCard`, where the image is an
8
+ * illustration inside a selectable card. This generalises that second pattern.
9
+ *
10
+ * **The caller imposes the dimensions.** This component hardwires none, so that
11
+ * it does not repeat `FzAvatar`'s mistake of owning a size — but it takes them as
12
+ * *props* rather than leaving them to a class, because the consuming apps forbid
13
+ * `class` at the organism, template and page layers and `style` above an atom.
14
+ * Give it `width` + `height`, or one of them + `aspectRatio`. With no dimension
15
+ * at all the box takes its container's width and the image's *natural* aspect
16
+ * ratio: visible, but at a size neither the caller nor the design chose — and a
17
+ * load failure then collapses it, because the placeholder has no natural ratio
18
+ * to fall back on. Size it.
19
+ *
20
+ * @component
21
+ * @example
22
+ * <FzThumbnail src="/receipt.jpg" alt="Scontrino di marzo" width="158px" height="108px" />
23
+ *
24
+ * @example A download action over a scrim, as in the customer chat feed.
25
+ * <FzThumbnail :src="a.url" alt="" scrim bordered width="100%" height="168px">
26
+ * <template #overlay>
27
+ * <FzIconButton iconName="arrow-down-to-line" ariaLabel="Scarica" @click="download(a)" />
28
+ * </template>
29
+ * </FzThumbnail>
30
+ */
31
+ import { computed, ref, watch } from 'vue'
32
+ import { FzIcon } from '@fiscozen/icons'
33
+ import type { FzThumbnailOverlayPosition, FzThumbnailProps, FzThumbnailRadius } from './types'
34
+
35
+ const props = withDefaults(defineProps<FzThumbnailProps>(), {
36
+ radius: 'base',
37
+ bordered: false,
38
+ scrim: false,
39
+ overlayPosition: 'bottom-end',
40
+ placeholderIcon: 'file',
41
+ loading: 'lazy'
42
+ })
43
+
44
+ const emit = defineEmits<{
45
+ /**
46
+ * The image could not be loaded and the placeholder is showing. The caller can
47
+ * use it to fall back to something else entirely — a file card, say.
48
+ */
49
+ error: [src: string]
50
+ }>()
51
+
52
+ const failed = ref(false)
53
+
54
+ // A new URL deserves a new attempt: without this, one broken image would pin the
55
+ // placeholder for every later `src` the same instance is given.
56
+ watch(
57
+ () => props.src,
58
+ () => {
59
+ failed.value = false
60
+ }
61
+ )
62
+
63
+ const showImage = computed(() => !!props.src && !failed.value)
64
+
65
+ // The placeholder speaks only when a *named* image failed. Three cases, and they
66
+ // are not the same thing:
67
+ // - `src` empty → nothing was ever meant to be here; stay silent.
68
+ // - failed, alt "" → the caller called it decorative; stay silent.
69
+ // - failed, alt set → the caller named it, and a native <img> whose resource
70
+ // 404s would still expose that name in the accessibility
71
+ // tree. Swapping the element out must not throw it away.
72
+ const placeholderLabel = computed(() => (failed.value && props.alt ? props.alt : undefined))
73
+
74
+ const radiusClasses: Record<FzThumbnailRadius, string> = {
75
+ none: 'rounded-none',
76
+ sm: 'rounded-sm',
77
+ base: 'rounded',
78
+ lg: 'rounded-lg',
79
+ xl: 'rounded-xl'
80
+ }
81
+
82
+ const overlayPositionClasses: Record<FzThumbnailOverlayPosition, string> = {
83
+ 'top-start': 'items-start justify-start',
84
+ 'top-end': 'items-start justify-end',
85
+ 'bottom-start': 'items-end justify-start',
86
+ 'bottom-end': 'items-end justify-end',
87
+ center: 'items-center justify-center'
88
+ }
89
+
90
+ // Only the keys the caller actually set, so an unset dimension stays absent from
91
+ // the style attribute rather than landing there as `undefined`.
92
+ const boxStyle = computed(() => ({
93
+ ...(props.width ? { width: props.width } : {}),
94
+ ...(props.height ? { height: props.height } : {}),
95
+ ...(props.aspectRatio ? { aspectRatio: props.aspectRatio } : {})
96
+ }))
97
+
98
+ const onError = () => {
99
+ failed.value = true
100
+ emit('error', props.src)
101
+ }
102
+ </script>
103
+
104
+ <template>
105
+ <div
106
+ class="fz-thumbnail bg-core-white relative overflow-hidden"
107
+ :class="[radiusClasses[radius], { 'border-1 border-grey-100 border-solid': bordered }]"
108
+ :style="boxStyle"
109
+ >
110
+ <img
111
+ v-if="showImage"
112
+ v-bind="imgProps"
113
+ :src="src"
114
+ :alt="alt"
115
+ :loading="loading"
116
+ class="block size-full object-cover"
117
+ @error="onError"
118
+ />
119
+
120
+ <!-- Placeholder. Fills the same box, so a broken URL leaves the layout
121
+ alone instead of collapsing the row around it — as long as the box has
122
+ a size of its own. An unsized box is only as tall as its content, and
123
+ a placeholder, unlike an image, has no intrinsic ratio to supply that
124
+ height with, so it collapses to the icon. Deliberately not given a
125
+ min-height: that would hand the component a size of its own, which is
126
+ the thing it refuses. Measured in the `Unsized` story. -->
127
+ <div
128
+ v-else
129
+ class="bg-grey-100 grid size-full place-content-center"
130
+ :role="placeholderLabel ? 'img' : undefined"
131
+ :aria-label="placeholderLabel"
132
+ data-testid="fz-thumbnail-placeholder"
133
+ >
134
+ <FzIcon :name="placeholderIcon" size="lg" class="text-grey-300" />
135
+ </div>
136
+
137
+ <div
138
+ v-if="scrim && showImage"
139
+ class="fz-thumbnail__scrim absolute inset-0"
140
+ data-testid="fz-thumbnail-scrim"
141
+ />
142
+
143
+ <!-- Overlay layer. Inert itself so it does not swallow clicks meant for the
144
+ image, with pointer events handed back to whatever the caller puts in
145
+ it. A flex box rather than bare `absolute inset-0`, so that dropping a
146
+ button in the slot places it without the call site writing a class —
147
+ which the organism, template and page layers may not do. -->
148
+ <div
149
+ v-if="$slots.overlay"
150
+ class="pointer-events-none absolute inset-0 flex p-8 [&>*]:pointer-events-auto"
151
+ :class="overlayPositionClasses[overlayPosition]"
152
+ data-testid="fz-thumbnail-overlay"
153
+ >
154
+ <slot name="overlay" />
155
+ </div>
156
+ </div>
157
+ </template>
158
+
159
+ <style scoped>
160
+ /* Why this is not `bg-grey-500/20`: the design system's colours resolve to
161
+ `var(--grey-500, #596167)`, and Tailwind cannot decompose a `var()` into
162
+ channels — so an opacity modifier on a token colour generates *no rule at
163
+ all*, silently. Verified against the DS preset with the Tailwind CLI. The
164
+ value is `grey-500` at 20%, matching the design's `rgba(74, 85, 101, 0.2)`
165
+ re-expressed on the DS palette.
166
+
167
+ And why the 20% is `opacity` rather than baked into an `rgba()`: writing
168
+ `rgba(89, 97, 103, 0.2)` freezes `--grey-500`'s *fallback*, so a consumer who
169
+ rethemes the token gets every other grey following and the scrim staying put.
170
+ The scrim is an empty overlay div, so `opacity` has nothing to bleed onto —
171
+ the `dialog::backdrop` rule in `FzDialog` is the same trick, and the better
172
+ of that file's two. Both custom properties are the consumer's escape hatch,
173
+ as with `--fz-navbar-shadow`. */
174
+ .fz-thumbnail__scrim {
175
+ background-color: var(--fz-thumbnail-scrim, var(--grey-500, #596167));
176
+ opacity: var(--fz-thumbnail-scrim-opacity, 0.2);
177
+ }
178
+ </style>