@descope-ui/descope-image 0.0.1

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/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ This file was generated using [@jscutlery/semver](https://github.com/jscutlery/semver).
4
+
5
+ ## 0.0.1 (2025-05-28)
6
+
7
+ ### Dependency Updates
8
+
9
+ * `e2e-utils` updated to version `0.0.1`
10
+ * `test-assets` updated to version `0.0.1`
11
+ * `@descope-ui/common` updated to version `0.0.12`
12
+ * `@descope-ui/theme-globals` updated to version `0.0.12`
13
+ # Changelog
@@ -0,0 +1,59 @@
1
+ import { test, expect } from '@playwright/test';
2
+ import { getStoryUrl, loopConfig, loopPresets } from 'e2e-utils';
3
+
4
+ const storyName = 'descope-image';
5
+ const componentName = 'descope-image';
6
+
7
+ const componentAttributes = {
8
+ 'st-width': ['', '50px', '200px'],
9
+ 'st-height': ['', '50px', '200px'],
10
+ };
11
+
12
+ test.describe('theme', () => {
13
+ loopConfig(componentAttributes, (attr, value) => {
14
+ test.describe(`${attr}: ${value}`, () => {
15
+ test.beforeEach(async ({ page }) => {
16
+ await page.goto(getStoryUrl(storyName, { [attr]: value }), {
17
+ waitUntil: 'networkidle',
18
+ });
19
+ });
20
+ test('style', async ({ page }) => {
21
+ const component = page.locator(componentName);
22
+
23
+ expect(await component.screenshot()).toMatchSnapshot();
24
+ });
25
+ });
26
+ });
27
+
28
+ test('render image for dark theme', async ({ page }) => {
29
+ await page.goto(getStoryUrl(storyName, {}, { theme: 'dark' }), {
30
+ waitUntil: 'networkidle',
31
+ });
32
+ const component = page.locator(componentName);
33
+ expect(await component.screenshot()).toMatchSnapshot();
34
+ });
35
+ });
36
+
37
+ test.describe('presets', () => {
38
+ loopPresets(
39
+ {
40
+ 'portrait image': {
41
+ 'st-width': '50px',
42
+ 'st-height': '200px',
43
+ },
44
+ 'landscape image': {
45
+ 'st-width': '200px',
46
+ 'st-height': '50px',
47
+ },
48
+ },
49
+ (preset, name) => {
50
+ test(name, async ({ page }) => {
51
+ await page.goto(getStoryUrl(storyName, preset), {
52
+ waitUntil: 'networkidle',
53
+ });
54
+ const component = page.locator(componentName);
55
+ expect(await component.screenshot()).toMatchSnapshot();
56
+ });
57
+ },
58
+ );
59
+ });
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@descope-ui/descope-image",
3
+ "version": "0.0.1",
4
+ "exports": {
5
+ ".": {
6
+ "import": "./src/component/index.js"
7
+ },
8
+ "./theme": {
9
+ "import": "./src/theme.js"
10
+ },
11
+ "./class": {
12
+ "import": "./src/component/ImageClass.js"
13
+ }
14
+ },
15
+ "devDependencies": {
16
+ "@playwright/test": "1.38.1",
17
+ "e2e-utils": "0.0.1",
18
+ "test-assets": "0.0.1"
19
+ },
20
+ "dependencies": {
21
+ "dompurify": "^3.1.6",
22
+ "@descope-ui/common": "0.0.12",
23
+ "@descope-ui/theme-globals": "0.0.12"
24
+ },
25
+ "publishConfig": {
26
+ "link-workspace-packages": false
27
+ },
28
+ "scripts": {
29
+ "test": "echo 'No tests defined' && exit 0",
30
+ "test:e2e": "echo 'No e2e tests defined' && exit 0"
31
+ }
32
+ }
package/project.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@descope-ui/descope-image",
3
+ "$schema": "../../node_modules/nx/schemas/project-schema.json",
4
+ "sourceRoot": "packages/web-components/components/descope-image/src",
5
+ "projectType": "library",
6
+ "targets": {
7
+ "version": {
8
+ "executor": "@jscutlery/semver:version",
9
+ "options": {
10
+ "trackDeps": true,
11
+ "push": false,
12
+ "preset": "conventional"
13
+ }
14
+ }
15
+ },
16
+ "tags": []
17
+ }
@@ -0,0 +1,140 @@
1
+ /* eslint-disable no-use-before-define */
2
+ import {
3
+ createStyleMixin,
4
+ draggableMixin,
5
+ componentNameValidationMixin,
6
+ } from '@descope-ui/common/components-mixins';
7
+ import { createBaseClass } from '@descope-ui/common/base-classes';
8
+ import { compose } from '@descope-ui/common/utils';
9
+ import { getComponentName } from '@descope-ui/common/components-helpers';
10
+ import { createImage } from './helpers';
11
+ import { injectStyle } from '@descope-ui/common/components-helpers';
12
+
13
+ export const componentName = getComponentName('image');
14
+
15
+ const srcAttrs = ['src', 'src-dark'];
16
+
17
+ class RawImage extends createBaseClass({
18
+ componentName,
19
+ baseSelector: 'slot',
20
+ }) {
21
+ static get observedAttributes() {
22
+ return srcAttrs;
23
+ }
24
+
25
+ constructor() {
26
+ super();
27
+
28
+ this.attachShadow({ mode: 'open' }).innerHTML = `
29
+ <slot></slot>
30
+ `;
31
+
32
+ injectStyle(
33
+ `
34
+ :host {
35
+ display: inline-flex;
36
+ }
37
+ :host > slot {
38
+ width: 100%;
39
+ height: 100%;
40
+ box-sizing: border-box;
41
+ display: flex;
42
+ overflow: hidden;
43
+ }
44
+
45
+ ::slotted(*) {
46
+ width: 100%;
47
+ }
48
+
49
+ .hidden {
50
+ display: none;
51
+ }
52
+ `,
53
+ this,
54
+ );
55
+ }
56
+
57
+ init() {
58
+ super.init?.();
59
+ this.toggleVisibility(this.src);
60
+ }
61
+
62
+ onThemeChange() {
63
+ this.renderImage();
64
+ }
65
+
66
+ toggleVisibility(isVisible) {
67
+ if (isVisible) {
68
+ this.classList.remove('hidden');
69
+ } else {
70
+ this.classList.add('hidden');
71
+ }
72
+ }
73
+
74
+ get legacySrc() {
75
+ return this.getAttribute('src');
76
+ }
77
+
78
+ get themeSrc() {
79
+ return this.getAttribute(`src-${this.currentThemeName}`);
80
+ }
81
+
82
+ get src() {
83
+ return this.themeSrc || this.legacySrc;
84
+ }
85
+
86
+ // in order to fill an SVG with `currentColor` override all of its `fill` and `path` nodes
87
+ // with the value from the `st-fill` attribute
88
+ // eslint-disable-next-line class-methods-use-this
89
+ updateFillColor(node) {
90
+ // set fill to root node and all its relevant selectors
91
+ const elementsToReplace = [node, ...node.querySelectorAll('*[fill]')];
92
+
93
+ elementsToReplace.forEach((ele) => {
94
+ ele.setAttribute(
95
+ 'fill',
96
+ `var(${ImageClass.cssVarList.fill}, ${ele.getAttribute('fill') || "''"})`,
97
+ );
98
+ });
99
+ }
100
+
101
+ renderImage() {
102
+ this.toggleVisibility(this.src);
103
+
104
+ createImage(this.src).then((res) => {
105
+ this.innerHTML = '';
106
+ if (res) {
107
+ this.updateFillColor(res);
108
+ this.appendChild(res);
109
+ }
110
+ });
111
+ }
112
+
113
+ // render only when src attribute matches current theme
114
+ shouldRender(src) {
115
+ const srcVal = this.getAttribute(src);
116
+ return this.src === srcVal;
117
+ }
118
+
119
+ attributeChangedCallback(attrName, oldValue, newValue) {
120
+ super.attributeChangedCallback?.(attrName, oldValue, newValue);
121
+
122
+ if (oldValue === newValue) return;
123
+
124
+ if (this.shouldRender(attrName)) {
125
+ this.renderImage();
126
+ }
127
+ }
128
+ }
129
+
130
+ export const ImageClass = compose(
131
+ createStyleMixin({
132
+ mappings: {
133
+ fill: {},
134
+ height: { selector: () => ':host' },
135
+ width: { selector: () => ':host' },
136
+ },
137
+ }),
138
+ draggableMixin,
139
+ componentNameValidationMixin,
140
+ )(RawImage);
@@ -0,0 +1,55 @@
1
+ import DOMPurify from 'dompurify';
2
+
3
+ const getFileExtension = (path) => {
4
+ const match = path.match(/\.([0-9a-z]+)(?:[\\?#]|$)/i);
5
+ return match ? match[1] : null;
6
+ };
7
+
8
+ const base64Prefix = 'data:image/svg+xml;base64,';
9
+
10
+ const isBase64Svg = (src) => src.startsWith(base64Prefix);
11
+
12
+ const createImgEle = (src) => {
13
+ const ele = document.createElement('img');
14
+ ele.setAttribute('src', src);
15
+ return ele;
16
+ };
17
+
18
+ const createSvgEle = (text) => {
19
+ // we want to purify the SVG to avoid XSS attacks
20
+ const clean = DOMPurify.sanitize(text, {
21
+ USE_PROFILES: { svg: true, svgFilters: true },
22
+ });
23
+
24
+ const parser = new DOMParser();
25
+ const ele = parser
26
+ .parseFromString(clean, 'image/svg+xml')
27
+ .querySelector('svg');
28
+ return ele;
29
+ };
30
+
31
+ export const createImage = async (src) => {
32
+ try {
33
+ let ele;
34
+ if (isBase64Svg(src)) {
35
+ // handle base64 source
36
+ const svgXml = atob(src.slice(base64Prefix.length));
37
+ ele = createSvgEle(svgXml);
38
+ } else if (getFileExtension(src) === 'svg') {
39
+ // handle urls
40
+ const fetchedSrc = await fetch(src);
41
+ const text = await fetchedSrc.text();
42
+ ele = createSvgEle(text);
43
+ } else {
44
+ // handle binary
45
+ ele = createImgEle(src);
46
+ }
47
+
48
+ ele.style.setProperty('max-width', '100%');
49
+ ele.style.setProperty('max-height', '100%');
50
+
51
+ return ele;
52
+ } catch {
53
+ return null;
54
+ }
55
+ };
@@ -0,0 +1,5 @@
1
+ import { componentName, ImageClass } from './ImageClass';
2
+
3
+ customElements.define(componentName, ImageClass);
4
+
5
+ export { ImageClass, componentName };
package/src/theme.js ADDED
@@ -0,0 +1,7 @@
1
+ import { ImageClass } from './component/ImageClass';
2
+
3
+ export const vars = ImageClass.cssVarList;
4
+
5
+ const image = {};
6
+
7
+ export default image;
@@ -0,0 +1,10 @@
1
+ <svg
2
+ width="1.5em"
3
+ height="1.5em"
4
+ viewBox="0 0 800 1000"
5
+ xmlns="http://www.w3.org/2000/svg"
6
+ fill="#000000"
7
+ data-icon="apple"
8
+ >
9
+ <path d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z" />
10
+ </svg>
Binary file
@@ -0,0 +1,63 @@
1
+ import { componentName } from '../src/component';
2
+ import {
3
+ customHeightControl,
4
+ customWidthControl,
5
+ fillControl,
6
+ } from '@descope-ui/common/sb-controls';
7
+
8
+ import demoImageLight from './demo.jpg';
9
+ import demoImageDark from './game.png';
10
+ import fromUrlLight from './google.svg?no-inline';
11
+ import fromUrlDark from './apple.svg?no-inline';
12
+
13
+ const images = {
14
+ fromUrlLight,
15
+ fromUrlDark,
16
+ demoImageLight,
17
+ demoImageDark,
18
+ };
19
+
20
+ const Template = ({
21
+ src,
22
+ srcDark,
23
+ fill,
24
+ 'st-width': customWidth,
25
+ 'st-height': customHeight,
26
+ }) => {
27
+ return `
28
+ <descope-image
29
+ src="${images[src]}"
30
+ src-dark="${images[srcDark]}"
31
+ ${fill ? `st-fill="${fill}"` : ''}
32
+ st-height="${customHeight || ''}"
33
+ st-width="${customWidth || ''}"
34
+ ></descope-image>
35
+ `;
36
+ };
37
+
38
+ export default {
39
+ component: componentName,
40
+ title: 'descope-image',
41
+ argTypes: {
42
+ ...fillControl,
43
+ src: {
44
+ options: Object.keys(images),
45
+ control: { type: 'select' },
46
+ },
47
+ 'src-dark': {
48
+ options: Object.keys(images),
49
+ control: { type: 'select' },
50
+ },
51
+ 'st-height': customHeightControl['st-host-height'],
52
+ 'st-width': customWidthControl['st-host-width'],
53
+ },
54
+ };
55
+
56
+ export const Default = Template.bind({});
57
+
58
+ Default.args = {
59
+ src: 'demoImageLight',
60
+ srcDark: 'demoImageDark',
61
+ 'st-width': '',
62
+ 'st-height': '',
63
+ };
Binary file
@@ -0,0 +1,25 @@
1
+ <svg
2
+ width="1.5em"
3
+ height="1.5em"
4
+ viewBox="0 0 20 20"
5
+ xmlns="http://www.w3.org/2000/svg"
6
+ data-icon="google"
7
+ >
8
+ <path
9
+ d="m19.6 10.2274c0-.70912-.0636-1.39092-.1818-2.04552h-9.4182v3.86822h5.3818c-.2318 1.25-.9363 2.3091-1.9954 3.0182v2.5091h3.2318c1.8909-1.7409 2.9818-4.3046 2.9818-7.35z"
10
+ fill="#4285f4"
11
+ />
12
+ <path
13
+ d="m10 19.9999c2.7 0 4.9636-.8955 6.6181-2.4227l-3.2318-2.5091c-.8954.6-2.0409.9545-3.3863.9545-2.6046 0-4.8091-1.7591-5.5955-4.1227h-3.3409v2.5909c1.6455 3.2682 5.0273 5.5091 8.9364 5.5091z"
14
+ fill="#34a853"
15
+ />
16
+ <path
17
+ d="m4.4045 11.8999c-.2-.6-.3136-1.2409-.3136-1.89997 0-.6591.1136-1.3.3136-1.9v-2.5909h-3.3409c-.6772 1.35-1.0636 2.8773-1.0636 4.4909 0 1.61357.3864 3.14087 1.0636 4.49087z"
18
+ fill="#fbbc04"
19
+ />
20
+ <path
21
+ d="m10 3.9773c1.4681 0 2.7863.5045 3.8227 1.4954l2.8682-2.8682c-1.7318-1.6136-3.9955-2.6045-6.6909-2.6045-3.9091 0-7.2909 2.2409-8.9364 5.5091l3.3409 2.5909c.7864-2.3636 2.9909-4.1227 5.5955-4.1227z"
22
+ fill="#e94235"
23
+ />
24
+ <script>alert(1)</script>
25
+ </svg>