@pequity/squirrel 4.0.0 → 4.1.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.
- package/dist/cjs/chunks/p-link.js +37 -0
- package/dist/cjs/index.js +4 -0
- package/dist/cjs/link.js +25 -0
- package/dist/cjs/p-btn.js +8 -5
- package/dist/cjs/p-info-icon.js +6 -3
- package/dist/cjs/p-link.js +3 -0
- package/dist/cjs/p-modal.js +5 -5
- package/dist/cjs/sanitization.js +13 -0
- package/dist/es/chunks/p-link.js +38 -0
- package/dist/es/index.js +18 -14
- package/dist/es/link.js +25 -0
- package/dist/es/p-btn.js +8 -5
- package/dist/es/p-info-icon.js +6 -3
- package/dist/es/p-link.js +4 -0
- package/dist/es/p-modal.js +5 -5
- package/dist/es/sanitization.js +13 -0
- package/dist/squirrel/components/index.d.ts +2 -1
- package/dist/squirrel/components/p-btn/p-btn.vue.d.ts +4 -2
- package/dist/squirrel/components/p-link/p-link.vue.d.ts +22 -0
- package/dist/squirrel/components/p-table/usePTableRowVirtualizer.d.ts +1 -1
- package/dist/squirrel/utils/index.d.ts +2 -1
- package/dist/squirrel/utils/link.d.ts +1 -0
- package/dist/squirrel/utils/sanitization.d.ts +10 -0
- package/dist/style.css +15 -15
- package/package.json +6 -6
- package/squirrel/components/index.ts +2 -0
- package/squirrel/components/p-btn/p-btn.spec.js +29 -1
- package/squirrel/components/p-btn/p-btn.vue +13 -4
- package/squirrel/components/p-close-btn/p-close-btn.spec.js +60 -0
- package/squirrel/components/p-info-icon/p-info-icon.spec.js +21 -0
- package/squirrel/components/p-info-icon/p-info-icon.stories.js +32 -0
- package/squirrel/components/p-info-icon/p-info-icon.vue +1 -1
- package/squirrel/components/p-link/p-link.spec.js +62 -0
- package/squirrel/components/p-link/p-link.stories.js +38 -0
- package/squirrel/components/p-link/p-link.vue +20 -0
- package/squirrel/components/p-modal/p-modal-features.spec.js +23 -1
- package/squirrel/components/p-modal/p-modal.vue +6 -1
- package/squirrel/components/p-table-header-cell/p-table-header-cell.stories.js +2 -2
- package/squirrel/utils/index.ts +2 -0
- package/squirrel/utils/link.spec.js +24 -0
- package/squirrel/utils/link.ts +36 -0
- package/squirrel/utils/sanitization.spec.js +57 -0
- package/squirrel/utils/sanitization.ts +55 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pequity/squirrel",
|
|
3
3
|
"description": "Squirrel component library",
|
|
4
|
-
"version": "4.
|
|
4
|
+
"version": "4.1.0",
|
|
5
5
|
"packageManager": "pnpm@9.7.1",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"scripts": {
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@babel/core": "^7.25.2",
|
|
54
|
-
"@babel/preset-env": "^7.25.
|
|
54
|
+
"@babel/preset-env": "^7.25.4",
|
|
55
55
|
"@commitlint/cli": "^19.4.0",
|
|
56
56
|
"@commitlint/config-conventional": "^19.2.2",
|
|
57
57
|
"@pequity/eslint-config": "^0.0.13",
|
|
@@ -71,11 +71,11 @@
|
|
|
71
71
|
"@storybook/theming": "^8.2.9",
|
|
72
72
|
"@storybook/vue3": "^8.2.9",
|
|
73
73
|
"@storybook/vue3-vite": "^8.2.9",
|
|
74
|
-
"@tanstack/vue-virtual": "3.10.
|
|
74
|
+
"@tanstack/vue-virtual": "3.10.4",
|
|
75
75
|
"@types/jest": "^29.5.12",
|
|
76
76
|
"@types/jsdom": "^21.1.7",
|
|
77
77
|
"@types/lodash-es": "^4.17.12",
|
|
78
|
-
"@types/node": "^22.
|
|
78
|
+
"@types/node": "^22.5.0",
|
|
79
79
|
"@vitejs/plugin-vue": "^5.1.2",
|
|
80
80
|
"@vue/compiler-sfc": "3.4.38",
|
|
81
81
|
"@vue/test-utils": "^2.4.6",
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
"autoprefixer": "^10.4.20",
|
|
84
84
|
"babel-jest": "^29.7.0",
|
|
85
85
|
"concurrently": "^8.2.2",
|
|
86
|
-
"dayjs": "1.11.
|
|
86
|
+
"dayjs": "1.11.13",
|
|
87
87
|
"eslint": "^8.57.0",
|
|
88
88
|
"eslint-plugin-storybook": "^0.8.0",
|
|
89
89
|
"floating-vue": "5.2.2",
|
|
@@ -105,7 +105,7 @@
|
|
|
105
105
|
"storybook": "^8.2.9",
|
|
106
106
|
"svgo": "^3.3.2",
|
|
107
107
|
"tailwindcss": "^3.4.10",
|
|
108
|
-
"ts-jest": "^29.2.
|
|
108
|
+
"ts-jest": "^29.2.5",
|
|
109
109
|
"typescript": "5.5.4",
|
|
110
110
|
"v-calendar": "3.1.2",
|
|
111
111
|
"vite": "^5.4.2",
|
|
@@ -18,6 +18,7 @@ import PInput from '@squirrel/components/p-input/p-input.vue';
|
|
|
18
18
|
import PInputNumber from '@squirrel/components/p-input-number/p-input-number.vue';
|
|
19
19
|
import PInputPercent from '@squirrel/components/p-input-percent/p-input-percent.vue';
|
|
20
20
|
import PInputSearch from '@squirrel/components/p-input-search/p-input-search.vue';
|
|
21
|
+
import PLink from '@squirrel/components/p-link/p-link.vue';
|
|
21
22
|
import PLoading from '@squirrel/components/p-loading/p-loading.vue';
|
|
22
23
|
import PModal from '@squirrel/components/p-modal/p-modal.vue';
|
|
23
24
|
import PPagination from '@squirrel/components/p-pagination/p-pagination.vue';
|
|
@@ -83,6 +84,7 @@ export {
|
|
|
83
84
|
PInputNumber,
|
|
84
85
|
PInputPercent,
|
|
85
86
|
PInputSearch,
|
|
87
|
+
PLink,
|
|
86
88
|
PLoading,
|
|
87
89
|
PModal,
|
|
88
90
|
PPagination,
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import PBtn from '@squirrel/components/p-btn/p-btn.vue';
|
|
2
2
|
import { createWrapperFor } from '@tests/jest.helpers';
|
|
3
|
+
import { sanitizeUrl } from '@squirrel/utils/sanitization';
|
|
4
|
+
|
|
5
|
+
jest.mock('@squirrel/utils/sanitization', () => {
|
|
6
|
+
return {
|
|
7
|
+
sanitizeUrl: jest.fn((str) => `sanitized-${str}`),
|
|
8
|
+
};
|
|
9
|
+
});
|
|
3
10
|
|
|
4
11
|
const ELEMENTS_MAP = {
|
|
5
12
|
button: undefined,
|
|
@@ -8,6 +15,10 @@ const ELEMENTS_MAP = {
|
|
|
8
15
|
};
|
|
9
16
|
|
|
10
17
|
describe('PBtn.vue', () => {
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
jest.clearAllMocks();
|
|
20
|
+
});
|
|
21
|
+
|
|
11
22
|
Object.keys(ELEMENTS_MAP).forEach((el) => {
|
|
12
23
|
const to = ELEMENTS_MAP[el];
|
|
13
24
|
|
|
@@ -147,7 +158,8 @@ describe('PBtn.vue', () => {
|
|
|
147
158
|
});
|
|
148
159
|
|
|
149
160
|
const a = await wrapper.find('a');
|
|
150
|
-
|
|
161
|
+
|
|
162
|
+
expect(a.attributes().href).toBe(`sanitized-https://pequity.com/`);
|
|
151
163
|
expect(a.attributes().target).toBe('_blank');
|
|
152
164
|
expect(a.text()).toBe('This is a button');
|
|
153
165
|
});
|
|
@@ -185,4 +197,20 @@ describe('PBtn.vue', () => {
|
|
|
185
197
|
|
|
186
198
|
expect(button.attributes()['aria-selected']).toBe('true');
|
|
187
199
|
});
|
|
200
|
+
|
|
201
|
+
it('it sanitizes an invalid link', async () => {
|
|
202
|
+
const wrapper = createWrapperFor(PBtn, {
|
|
203
|
+
props: {
|
|
204
|
+
to: 'javascript:evil()',
|
|
205
|
+
},
|
|
206
|
+
slots: {
|
|
207
|
+
default: `This is a button`,
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const a = await wrapper.find('a');
|
|
212
|
+
|
|
213
|
+
expect(a.text()).toBe('This is a button');
|
|
214
|
+
expect(sanitizeUrl).toHaveBeenCalledTimes(1);
|
|
215
|
+
});
|
|
188
216
|
});
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<a
|
|
2
|
+
<a
|
|
3
|
+
v-if="typeof to === 'string' && isExternalLink(to)"
|
|
4
|
+
v-bind="$attrs"
|
|
5
|
+
:href="sanitizeUrl(to)"
|
|
6
|
+
target="_blank"
|
|
7
|
+
:class="classes"
|
|
8
|
+
>
|
|
3
9
|
<slot></slot>
|
|
4
10
|
</a>
|
|
5
11
|
<Component
|
|
@@ -29,6 +35,8 @@ import { type Color, getColorDeep } from '@squirrel/utils/tailwind';
|
|
|
29
35
|
import { type PropType, defineComponent } from 'vue';
|
|
30
36
|
import { type RouteLocationRaw, RouterLink } from 'vue-router';
|
|
31
37
|
import { type Size } from '@squirrel/components/p-btn/p-btn.types';
|
|
38
|
+
import { isExternalLink } from '@squirrel/utils/link';
|
|
39
|
+
import { sanitizeUrl } from '@squirrel/utils/sanitization';
|
|
32
40
|
|
|
33
41
|
const BUTTON_TYPES = {
|
|
34
42
|
PRIMARY: 'primary',
|
|
@@ -173,9 +181,10 @@ export default defineComponent({
|
|
|
173
181
|
|
|
174
182
|
return getColorDeep(type);
|
|
175
183
|
},
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
184
|
+
},
|
|
185
|
+
methods: {
|
|
186
|
+
isExternalLink,
|
|
187
|
+
sanitizeUrl,
|
|
179
188
|
},
|
|
180
189
|
});
|
|
181
190
|
</script>
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import PCloseBtn from '@squirrel/components/p-close-btn/p-close-btn.vue';
|
|
2
|
+
import { createWrapperFor } from '@tests/jest.helpers';
|
|
3
|
+
|
|
4
|
+
const buttonClasses = [
|
|
5
|
+
'inline-flex',
|
|
6
|
+
'h-8',
|
|
7
|
+
'w-8',
|
|
8
|
+
'cursor-pointer',
|
|
9
|
+
'items-center',
|
|
10
|
+
'justify-center',
|
|
11
|
+
'rounded',
|
|
12
|
+
'focus:outline-none',
|
|
13
|
+
'disabled:cursor-default',
|
|
14
|
+
'disabled:opacity-30',
|
|
15
|
+
'disabled:hover:bg-white',
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const iconClasses = ['block', 'h-3', 'w-3', 'bg-center', 'bg-no-repeat'];
|
|
19
|
+
|
|
20
|
+
describe('PCloseBtn.vue', () => {
|
|
21
|
+
it('renders correctly', () => {
|
|
22
|
+
const wrapper = createWrapperFor(PCloseBtn);
|
|
23
|
+
|
|
24
|
+
const button = wrapper.find('button');
|
|
25
|
+
const i = wrapper.find('i');
|
|
26
|
+
|
|
27
|
+
expect(buttonClasses.every((c) => button.classes().includes(c))).toBe(true);
|
|
28
|
+
expect(iconClasses.every((c) => i.classes().includes(c))).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('button inherits attributes', async () => {
|
|
32
|
+
const wrapper = createWrapperFor(PCloseBtn, {
|
|
33
|
+
attrs: {
|
|
34
|
+
disabled: true,
|
|
35
|
+
'data-test': 'test',
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const button = await wrapper.find('button');
|
|
40
|
+
|
|
41
|
+
expect(button.attributes().disabled).toBeDefined();
|
|
42
|
+
expect(button.attributes('data-test')).toBe('test');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it.each([
|
|
46
|
+
['transparent', ['bg-transparent', 'hover:bg-p-gray-20'], ['x-black-icon']],
|
|
47
|
+
['gray', ['bg-p-gray-10', 'hover:bg-p-gray-20'], ['x-black-icon']],
|
|
48
|
+
['dark', ['bg-transparent'], ['x-white-icon']],
|
|
49
|
+
])('renders a PCloseBtn of variant %s', (variant, btnClasses, iClasses) => {
|
|
50
|
+
const wrapper = createWrapperFor(PCloseBtn, {
|
|
51
|
+
props: { variant },
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const button = wrapper.find('button');
|
|
55
|
+
const i = wrapper.find('i');
|
|
56
|
+
|
|
57
|
+
expect(btnClasses.every((c) => button.classes().includes(c))).toBe(true);
|
|
58
|
+
expect(iClasses.every((c) => i.classes().includes(c))).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -55,4 +55,25 @@ describe('PInfoIcon.vue', () => {
|
|
|
55
55
|
|
|
56
56
|
expect(wrapper.text()).toBe('Lorem ipsum dolor sit amet.');
|
|
57
57
|
});
|
|
58
|
+
|
|
59
|
+
it('tooltip should stay visible on hover', async () => {
|
|
60
|
+
const wrapper = createWrapperFor(PInfoIcon, {
|
|
61
|
+
props: {
|
|
62
|
+
text: '',
|
|
63
|
+
},
|
|
64
|
+
slots: {
|
|
65
|
+
default: 'Lorem ipsum dolor sit amet.',
|
|
66
|
+
},
|
|
67
|
+
global: {
|
|
68
|
+
stubs: {
|
|
69
|
+
VTooltip: true,
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const tooltip = wrapper.findComponent('v-tooltip-stub');
|
|
75
|
+
|
|
76
|
+
expect(tooltip.props().popperTriggers).toEqual(['hover']);
|
|
77
|
+
expect(tooltip.props().delay).toEqual({ show: 750, hide: 0 });
|
|
78
|
+
});
|
|
58
79
|
});
|
|
@@ -55,3 +55,35 @@ export const DifferentPlacements = {
|
|
|
55
55
|
},
|
|
56
56
|
},
|
|
57
57
|
};
|
|
58
|
+
|
|
59
|
+
export const WithBigText = {
|
|
60
|
+
render: (args) => ({
|
|
61
|
+
components: { PInfoIcon },
|
|
62
|
+
setup() {
|
|
63
|
+
return { args };
|
|
64
|
+
},
|
|
65
|
+
template: `
|
|
66
|
+
<div class="w-full pt-60 pb-20 flex justify-center">
|
|
67
|
+
<PInfoIcon v-bind="args" />
|
|
68
|
+
</div>`,
|
|
69
|
+
}),
|
|
70
|
+
args: {
|
|
71
|
+
text: `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed malesuada convallis ultricies.
|
|
72
|
+
Pellentesque rhoncus felis et neque pellentesque pretium.
|
|
73
|
+
|
|
74
|
+
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed malesuada convallis ultricies.
|
|
75
|
+
Pellentesque rhoncus felis et neque pellentesque pretium.
|
|
76
|
+
|
|
77
|
+
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed malesuada convallis ultricies.
|
|
78
|
+
Pellentesque rhoncus felis et neque pellentesque pretium.
|
|
79
|
+
`,
|
|
80
|
+
placement: 'top',
|
|
81
|
+
},
|
|
82
|
+
parameters: {
|
|
83
|
+
docs: {
|
|
84
|
+
description: {
|
|
85
|
+
story: `The info tooltip allows for long text and the ability to scroll.`,
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import PLink from '@squirrel/components/p-link/p-link.vue';
|
|
2
|
+
import { createWrapperFor } from '@tests/jest.helpers';
|
|
3
|
+
import { isExternalLink } from '@squirrel/utils/link';
|
|
4
|
+
import { sanitizeUrl } from '@squirrel/utils/sanitization';
|
|
5
|
+
|
|
6
|
+
jest.mock('@squirrel/utils/sanitization');
|
|
7
|
+
|
|
8
|
+
jest.mock('@squirrel/utils/link');
|
|
9
|
+
|
|
10
|
+
const createWrapper = (props, attrs) => {
|
|
11
|
+
return createWrapperFor(PLink, {
|
|
12
|
+
props,
|
|
13
|
+
attrs,
|
|
14
|
+
slots: {
|
|
15
|
+
default: 'Test link',
|
|
16
|
+
},
|
|
17
|
+
global: {
|
|
18
|
+
stubs: {
|
|
19
|
+
RouterLink: true,
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
describe('PLink.vue', () => {
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
jest.clearAllMocks();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('renders a router link when the link is internal', () => {
|
|
31
|
+
isExternalLink.mockReturnValue(false);
|
|
32
|
+
|
|
33
|
+
const wrapper = createWrapper({ to: '/home' }, { class: 'p-link', 'data-test': 'test' });
|
|
34
|
+
|
|
35
|
+
const routerLink = wrapper.findComponent({ name: 'RouterLink' });
|
|
36
|
+
|
|
37
|
+
expect(routerLink.element).toBe(wrapper.element);
|
|
38
|
+
expect(routerLink.text()).toBe('Test link');
|
|
39
|
+
expect(routerLink.props().to).toBe('/home');
|
|
40
|
+
expect(routerLink.classes()).toContain('p-link');
|
|
41
|
+
expect(routerLink.attributes()['data-test']).toBe('test');
|
|
42
|
+
expect(isExternalLink).toHaveBeenCalledTimes(1);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('renders an a href link when the link is external', () => {
|
|
46
|
+
isExternalLink.mockReturnValue(true);
|
|
47
|
+
sanitizeUrl.mockReturnValue('https://www.pequity.com');
|
|
48
|
+
|
|
49
|
+
const wrapper = createWrapper({ to: 'https://www.pequity.com' }, { class: 'p-link', 'data-test': 'test' });
|
|
50
|
+
|
|
51
|
+
const aLink = wrapper.find('a');
|
|
52
|
+
|
|
53
|
+
expect(aLink.element).toBe(wrapper.element);
|
|
54
|
+
expect(aLink.text()).toBe('Test link');
|
|
55
|
+
expect(aLink.attributes().href).toBe('https://www.pequity.com');
|
|
56
|
+
expect(wrapper.classes()).toContain('p-link');
|
|
57
|
+
expect(wrapper.attributes()['data-test']).toBe('test');
|
|
58
|
+
expect(wrapper.attributes().target).toBe('_blank');
|
|
59
|
+
expect(isExternalLink).toHaveBeenCalledTimes(1);
|
|
60
|
+
expect(sanitizeUrl).toHaveBeenCalledTimes(1);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import PLink from '@squirrel/components/p-link/p-link.vue';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
title: 'Components/PLink',
|
|
5
|
+
component: PLink,
|
|
6
|
+
tags: ['autodocs'],
|
|
7
|
+
parameters: {
|
|
8
|
+
docs: {
|
|
9
|
+
description: {
|
|
10
|
+
component: `The \`PLink\` component is a versatile link component designed to seamlessly handle both internal and external links.
|
|
11
|
+
It determines whether a given link is internal (for navigation within the app) or external (leading to a different website) and renders the appropriate link element accordingly.
|
|
12
|
+
In case of external links, it also sanitizes the URL to mitigate potential security risks like URL-based attacks.`,
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const InternalLink = {
|
|
19
|
+
render: (args) => ({
|
|
20
|
+
components: { PLink },
|
|
21
|
+
setup() {
|
|
22
|
+
return { args };
|
|
23
|
+
},
|
|
24
|
+
template: `<PLink v-bind="args" class="text-accent underline hover:text-accent">${args.default}</PLink>`,
|
|
25
|
+
}),
|
|
26
|
+
args: {
|
|
27
|
+
to: '/dummy',
|
|
28
|
+
default: 'Dummy internal link',
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const ExternalLink = {
|
|
33
|
+
...InternalLink,
|
|
34
|
+
args: {
|
|
35
|
+
to: 'https://www.pequity.com',
|
|
36
|
+
default: 'Link to Pequity website',
|
|
37
|
+
},
|
|
38
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<a v-if="typeof to === 'string' && isExternalLink(to)" :href="sanitizeUrl(to)" target="_blank">
|
|
3
|
+
<slot></slot>
|
|
4
|
+
</a>
|
|
5
|
+
<RouterLink v-else v-bind="$props">
|
|
6
|
+
<slot></slot>
|
|
7
|
+
</RouterLink>
|
|
8
|
+
</template>
|
|
9
|
+
|
|
10
|
+
<script setup lang="ts">
|
|
11
|
+
import { RouterLink, type RouterLinkProps } from 'vue-router';
|
|
12
|
+
import { isExternalLink } from '@squirrel/utils/link';
|
|
13
|
+
import { sanitizeUrl } from '@squirrel/utils/sanitization';
|
|
14
|
+
|
|
15
|
+
defineOptions({
|
|
16
|
+
name: 'PLink',
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
defineProps<RouterLinkProps>();
|
|
20
|
+
</script>
|
|
@@ -9,7 +9,7 @@ const ModalParent = {
|
|
|
9
9
|
template: `
|
|
10
10
|
<div>
|
|
11
11
|
<button type="button" class="open-modal-1" @click="showModal1 = true">Open modal 1</button>
|
|
12
|
-
<Modal1 wrapperClass="modal-1" v-model="showModal1" :title="title" :live="live">
|
|
12
|
+
<Modal1 wrapperClass="modal-1" v-model="showModal1" :title="title" :live="live" :enable-close="enableClose">
|
|
13
13
|
<div>
|
|
14
14
|
<input v-if="showInput" type="text" value="" autofocus class="text-input" />
|
|
15
15
|
<button type="button" class="open-modal-2" @click="showModal2 = true">Open modal 2</button>
|
|
@@ -52,6 +52,7 @@ const createModalsWrapper = (data) => {
|
|
|
52
52
|
title: 'Test title',
|
|
53
53
|
showModal1: false,
|
|
54
54
|
showModal2: false,
|
|
55
|
+
enableClose: true,
|
|
55
56
|
},
|
|
56
57
|
...data,
|
|
57
58
|
};
|
|
@@ -321,4 +322,25 @@ describe('Modal features', () => {
|
|
|
321
322
|
|
|
322
323
|
wrapper.unmount();
|
|
323
324
|
});
|
|
325
|
+
|
|
326
|
+
it('makes the close button invisible instead of hidden, when enableClose is false', async () => {
|
|
327
|
+
const wrapper = createModalsWrapper({
|
|
328
|
+
enableClose: false,
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
await waitNT(wrapper.vm);
|
|
332
|
+
|
|
333
|
+
await wrapper.find('.open-modal-1').trigger('click');
|
|
334
|
+
|
|
335
|
+
await sleep(100);
|
|
336
|
+
|
|
337
|
+
const modal1 = wrapper.getComponent('.modal-1');
|
|
338
|
+
|
|
339
|
+
const closeBtn = modal1.find('button[aria-label="Close"]');
|
|
340
|
+
|
|
341
|
+
expect(closeBtn.isVisible()).toBe(true);
|
|
342
|
+
expect(closeBtn.classes()).toContain('invisible');
|
|
343
|
+
|
|
344
|
+
wrapper.unmount();
|
|
345
|
+
});
|
|
324
346
|
});
|
|
@@ -36,7 +36,12 @@
|
|
|
36
36
|
{{ title }}
|
|
37
37
|
</h3>
|
|
38
38
|
<div class="ml-auto">
|
|
39
|
-
<PCloseBtn
|
|
39
|
+
<PCloseBtn
|
|
40
|
+
:disabled="disabled"
|
|
41
|
+
:class="{ invisible: !enableClose }"
|
|
42
|
+
:aria-label="closeLabel"
|
|
43
|
+
@click.prevent="close"
|
|
44
|
+
/>
|
|
40
45
|
</div>
|
|
41
46
|
</div>
|
|
42
47
|
</slot>
|
package/squirrel/utils/index.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
import { createPagingRange } from '@squirrel/utils/pagination';
|
|
24
24
|
import { getNextActiveElement, isElement, isVisible } from '@squirrel/utils/dom';
|
|
25
25
|
import { isObject } from '@squirrel/utils/object';
|
|
26
|
+
import { sanitizeUrl } from '@squirrel/utils/sanitization';
|
|
26
27
|
import { setupListKeyboardNavigation } from '@squirrel/utils/listKeyboardNavigation';
|
|
27
28
|
import { splitStringForHighlight } from '@squirrel/utils/text';
|
|
28
29
|
import { toNumberOrNull } from '@squirrel/utils/number';
|
|
@@ -57,6 +58,7 @@ export {
|
|
|
57
58
|
isElement,
|
|
58
59
|
isVisible,
|
|
59
60
|
isObject,
|
|
61
|
+
sanitizeUrl,
|
|
60
62
|
setupListKeyboardNavigation,
|
|
61
63
|
splitStringForHighlight,
|
|
62
64
|
toNumberOrNull,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { isExternalLink } from '@squirrel/utils/link';
|
|
2
|
+
|
|
3
|
+
describe('isExternalLink', () => {
|
|
4
|
+
it.each(['https://www.example.com', 'http://www.example.com', '//www.example.com'])(
|
|
5
|
+
'should return true for external links (%s)',
|
|
6
|
+
(val) => {
|
|
7
|
+
expect(isExternalLink(val)).toBe(true);
|
|
8
|
+
}
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
it.each(['/home', 'home', '#home', '/home//1/', '/home:1/', '#home:', { name: 'home' }])(
|
|
12
|
+
'should return false for internal links (%s)',
|
|
13
|
+
(val) => {
|
|
14
|
+
expect(isExternalLink(val)).toBe(false);
|
|
15
|
+
}
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
it.each(['ftp://www.example.com', 'mailto:test@example.com', 'tel:+1234567890', 'sms:+1234567890'])(
|
|
19
|
+
'should handle different protocols (%s)',
|
|
20
|
+
(val) => {
|
|
21
|
+
expect(isExternalLink(val)).toBe(true);
|
|
22
|
+
}
|
|
23
|
+
);
|
|
24
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const normalizeUrl = (url: string) => {
|
|
2
|
+
if (url.indexOf('//') === 0) {
|
|
3
|
+
url = location.protocol + url;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
return url;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const isValidUrl = (url: string) => {
|
|
10
|
+
url = normalizeUrl(url);
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
return Boolean(new URL(url));
|
|
14
|
+
} catch (e) {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const checkDomain = function (url: string) {
|
|
20
|
+
url = normalizeUrl(url);
|
|
21
|
+
|
|
22
|
+
return url
|
|
23
|
+
.toLowerCase()
|
|
24
|
+
.replace(/([a-z])?:\/\//, '$1')
|
|
25
|
+
.split('/')[0];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const isExternalLink = function (url: string) {
|
|
29
|
+
url = String(url);
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
isValidUrl(url) &&
|
|
33
|
+
(url.indexOf(':') > -1 || url.indexOf('//') > -1) &&
|
|
34
|
+
checkDomain(location.href) !== checkDomain(url)
|
|
35
|
+
);
|
|
36
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { sanitizeUrl } from '@squirrel/utils/sanitization';
|
|
2
|
+
|
|
3
|
+
describe('sanitizeUrl', () => {
|
|
4
|
+
const consoleMock = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
5
|
+
|
|
6
|
+
afterAll(() => {
|
|
7
|
+
consoleMock.mockReset();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('reports unsafe URLs', () => {
|
|
11
|
+
const unsafeUrl = 'javascript:evil()';
|
|
12
|
+
|
|
13
|
+
expect(sanitizeUrl(unsafeUrl)).toBe(`unsafe:${unsafeUrl}`);
|
|
14
|
+
expect(consoleMock).toHaveBeenCalledWith(
|
|
15
|
+
expect.stringContaining(`WARNING: sanitizing unsafe URL value ${unsafeUrl}`)
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it.each([
|
|
20
|
+
'',
|
|
21
|
+
'http://abc',
|
|
22
|
+
'HTTP://abc',
|
|
23
|
+
'https://abc',
|
|
24
|
+
'HTTPS://abc',
|
|
25
|
+
'ftp://abc',
|
|
26
|
+
'FTP://abc',
|
|
27
|
+
'mailto:me@example.com',
|
|
28
|
+
'MAILTO:me@example.com',
|
|
29
|
+
'tel:123-123-1234',
|
|
30
|
+
'TEL:123-123-1234',
|
|
31
|
+
'#anchor',
|
|
32
|
+
'/page1.md',
|
|
33
|
+
'http://JavaScript/my.js',
|
|
34
|
+
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/', // Truncated.
|
|
35
|
+
'data:video/webm;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/',
|
|
36
|
+
'data:audio/opus;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/',
|
|
37
|
+
'unknown-scheme:abc',
|
|
38
|
+
])('returns the URL if it is valid (%s)', (urlVal) => {
|
|
39
|
+
expect(sanitizeUrl(urlVal)).toBe(urlVal);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it.each([
|
|
43
|
+
'javascript:evil()',
|
|
44
|
+
'JavaScript:abc',
|
|
45
|
+
' javascript:abc',
|
|
46
|
+
' \n Java\n Script:abc',
|
|
47
|
+
'javascript:',
|
|
48
|
+
'javascript:',
|
|
49
|
+
'j avascript:',
|
|
50
|
+
'javascript:',
|
|
51
|
+
'javascript:',
|
|
52
|
+
'jav	ascript:alert();',
|
|
53
|
+
'jav\u0000ascript:alert();',
|
|
54
|
+
])('it adds an "unsafe:" prefix if the URL is invalid (%s)', (urlVal) => {
|
|
55
|
+
expect(sanitizeUrl(urlVal)).toMatch(/^unsafe:/);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This is a port of the Angular url_sanitizer module.
|
|
3
|
+
* https://github.com/angular/angular/blob/main/packages/core/src/sanitization/url_sanitizer.ts
|
|
4
|
+
*
|
|
5
|
+
* TL;DR
|
|
6
|
+
* The function sanitizeUrl is designed to ensure that a given URL is safe,
|
|
7
|
+
* by checking it against a regular expression pattern (SAFE_URL_PATTERN).
|
|
8
|
+
* If the URL is considered unsafe, it returns a version of the URL prefixed with "unsafe:".
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A pattern that recognizes URLs that are safe wrt. XSS in URL navigation
|
|
13
|
+
* contexts.
|
|
14
|
+
*
|
|
15
|
+
* This regular expression matches a subset of URLs that will not cause script
|
|
16
|
+
* execution if used in URL context within a HTML document. Specifically, this
|
|
17
|
+
* regular expression matches if:
|
|
18
|
+
* (1) Either a protocol that is not javascript:, and that has valid characters
|
|
19
|
+
* (alphanumeric or [+-.]).
|
|
20
|
+
* (2) or no protocol. A protocol must be followed by a colon. The below
|
|
21
|
+
* allows that by allowing colons only after one of the characters [/?#].
|
|
22
|
+
* A colon after a hash (#) must be in the fragment.
|
|
23
|
+
* Otherwise, a colon after a (?) must be in a query.
|
|
24
|
+
* Otherwise, a colon after a single solidus (/) must be in a path.
|
|
25
|
+
* Otherwise, a colon after a double solidus (//) must be in the authority
|
|
26
|
+
* (before port).
|
|
27
|
+
*
|
|
28
|
+
* The pattern disallows &, used in HTML entity declarations before
|
|
29
|
+
* one of the characters in [/?#]. This disallows HTML entities used in the
|
|
30
|
+
* protocol name, which should never happen, e.g. "http" for "http".
|
|
31
|
+
* It also disallows HTML entities in the first path part of a relative path,
|
|
32
|
+
* e.g. "foo<bar/baz". Our existing escaping functions should not produce
|
|
33
|
+
* that. More importantly, it disallows masking of a colon,
|
|
34
|
+
* e.g. "javascript:...".
|
|
35
|
+
*
|
|
36
|
+
* This regular expression was taken from the Closure sanitization library.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
const XSS_SECURITY_URL =
|
|
40
|
+
'https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html';
|
|
41
|
+
|
|
42
|
+
// eslint-disable-next-line no-useless-escape
|
|
43
|
+
const SAFE_URL_PATTERN = /^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:\/?#]*(?:[\/?#]|$))/i;
|
|
44
|
+
|
|
45
|
+
export const sanitizeUrl = (url: string) => {
|
|
46
|
+
url = String(url);
|
|
47
|
+
|
|
48
|
+
if (url.match(SAFE_URL_PATTERN)) {
|
|
49
|
+
return url;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
console.warn(`WARNING: sanitizing unsafe URL value ${url} (see ${XSS_SECURITY_URL})`);
|
|
53
|
+
|
|
54
|
+
return 'unsafe:' + url;
|
|
55
|
+
};
|