picocrank 1.35.0 → 1.36.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "picocrank",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.36.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A set of zero dependency Vue components built on the Femtocrank global CSS theme.",
|
|
6
6
|
"main": "vite.config.js",
|
|
@@ -24,25 +24,25 @@
|
|
|
24
24
|
"url": "https://github.com/jamesread/picocrank/issues"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@hugeicons/core-free-icons": "^4.3.
|
|
27
|
+
"@hugeicons/core-free-icons": "^4.3.5",
|
|
28
28
|
"@hugeicons/vue": "^1.0.8",
|
|
29
|
-
"@vitejs/plugin-vue": "^6.0.
|
|
30
|
-
"femtocrank": "^2.7.
|
|
29
|
+
"@vitejs/plugin-vue": "^6.0.10",
|
|
30
|
+
"femtocrank": "^2.7.2",
|
|
31
31
|
"unplugin-vue-components": "^32.1.0",
|
|
32
|
-
"vite": "^8.
|
|
33
|
-
"vue": "^3.5.
|
|
34
|
-
"vue-router": "^5.
|
|
32
|
+
"vite": "^8.3.4",
|
|
33
|
+
"vue": "^3.5.43",
|
|
34
|
+
"vue-router": "^5.4.0"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@eslint/js": "^10.0.1",
|
|
38
|
-
"@vitest/coverage-v8": "^5.0.
|
|
39
|
-
"@vue/test-utils": "^2.5.
|
|
40
|
-
"eslint": "^10.
|
|
41
|
-
"eslint-plugin-vue": "^10.11.
|
|
42
|
-
"globals": "^17.
|
|
43
|
-
"happy-dom": "^20.14.
|
|
38
|
+
"@vitest/coverage-v8": "^5.0.3",
|
|
39
|
+
"@vue/test-utils": "^2.5.1",
|
|
40
|
+
"eslint": "^10.12.0",
|
|
41
|
+
"eslint-plugin-vue": "^10.11.1",
|
|
42
|
+
"globals": "^17.13.0",
|
|
43
|
+
"happy-dom": "^20.14.6",
|
|
44
44
|
"semantic-release": "^25.0.9",
|
|
45
|
-
"vitest": "^5.0.
|
|
45
|
+
"vitest": "^5.0.3",
|
|
46
46
|
"vue-eslint-parser": "^10.4.1"
|
|
47
47
|
},
|
|
48
48
|
"homepage": "https://github.com/jamesread/picocrank#readme"
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { mount } from '@vue/test-utils'
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import NotificationBlock from './NotificationBlock.vue'
|
|
4
|
+
|
|
5
|
+
const routerLinkStub = {
|
|
6
|
+
name: 'RouterLink',
|
|
7
|
+
template: '<a><slot /></a>',
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function mountBlock(props = {}, options = {}) {
|
|
11
|
+
return mount(NotificationBlock, {
|
|
12
|
+
props,
|
|
13
|
+
global: {
|
|
14
|
+
stubs: {
|
|
15
|
+
RouterLink: routerLinkStub,
|
|
16
|
+
HugeiconsIcon: true,
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
...options,
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('NotificationBlock', () => {
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
vi.restoreAllMocks()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('shows a copy control for critical, error, and bad types', () => {
|
|
29
|
+
for (const type of ['critical', 'error', 'bad']) {
|
|
30
|
+
const wrapper = mountBlock({ type, message: 'Something failed.' })
|
|
31
|
+
expect(wrapper.find('.notification-copy').exists()).toBe(true)
|
|
32
|
+
wrapper.unmount()
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('does not show a copy control for non-critical types', () => {
|
|
37
|
+
for (const type of ['info', 'note', 'success', 'warning', 'severe', 'old']) {
|
|
38
|
+
const wrapper = mountBlock({ type, message: 'Status update.' })
|
|
39
|
+
expect(wrapper.find('.notification-copy').exists()).toBe(false)
|
|
40
|
+
wrapper.unmount()
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('copies the body text to the clipboard', async () => {
|
|
45
|
+
const writeText = vi.fn().mockResolvedValue(undefined)
|
|
46
|
+
vi.stubGlobal('navigator', {
|
|
47
|
+
clipboard: { writeText },
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
const wrapper = mountBlock({
|
|
51
|
+
type: 'critical',
|
|
52
|
+
message: 'Database unreachable.',
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
await wrapper.find('.notification-copy').trigger('click')
|
|
56
|
+
|
|
57
|
+
expect(writeText).toHaveBeenCalledWith('Database unreachable.')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('does not dismiss when the copy control is clicked', async () => {
|
|
61
|
+
const wrapper = mountBlock({
|
|
62
|
+
type: 'error',
|
|
63
|
+
message: 'Boom.',
|
|
64
|
+
dismissible: true,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
await wrapper.find('.notification-copy').trigger('click')
|
|
68
|
+
|
|
69
|
+
expect(wrapper.emitted('dismiss')).toBeUndefined()
|
|
70
|
+
})
|
|
71
|
+
})
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
@click="handleClick"
|
|
6
6
|
>
|
|
7
7
|
<span class="notification-key">{{ displayLabel }}</span>
|
|
8
|
-
<span class="notification-body">
|
|
8
|
+
<span ref="bodyRef" class="notification-body">
|
|
9
9
|
<slot>
|
|
10
10
|
<template v-if="hasLink">
|
|
11
11
|
<template v-if="messagePrefix">{{ messagePrefix }}</template><RouterLink
|
|
@@ -21,12 +21,29 @@
|
|
|
21
21
|
<template v-else>{{ message }}</template>
|
|
22
22
|
</slot>
|
|
23
23
|
</span>
|
|
24
|
+
<button
|
|
25
|
+
v-if="showCopyButton"
|
|
26
|
+
type="button"
|
|
27
|
+
class="notification-copy"
|
|
28
|
+
:title="copied ? 'Copied' : 'Copy to clipboard'"
|
|
29
|
+
:aria-label="copied ? 'Copied' : 'Copy to clipboard'"
|
|
30
|
+
@click.stop="copyToClipboard"
|
|
31
|
+
>
|
|
32
|
+
<HugeiconsIcon
|
|
33
|
+
:icon="copied ? CopyCheckIcon : Copy01Icon"
|
|
34
|
+
width="1em"
|
|
35
|
+
height="1em"
|
|
36
|
+
aria-hidden="true"
|
|
37
|
+
/>
|
|
38
|
+
</button>
|
|
24
39
|
</div>
|
|
25
40
|
</template>
|
|
26
41
|
|
|
27
42
|
<script setup>
|
|
28
|
-
import { computed } from 'vue'
|
|
43
|
+
import { computed, ref } from 'vue'
|
|
29
44
|
import { RouterLink } from 'vue-router'
|
|
45
|
+
import { HugeiconsIcon } from '@hugeicons/vue'
|
|
46
|
+
import { Copy01Icon, CopyCheckIcon } from '@hugeicons/core-free-icons'
|
|
30
47
|
|
|
31
48
|
const TYPE_LABELS = {
|
|
32
49
|
info: 'INFO:',
|
|
@@ -41,6 +58,8 @@ const TYPE_LABELS = {
|
|
|
41
58
|
old: 'OLD:',
|
|
42
59
|
}
|
|
43
60
|
|
|
61
|
+
const COPYABLE_TYPES = new Set(['critical', 'error', 'bad'])
|
|
62
|
+
|
|
44
63
|
const props = defineProps({
|
|
45
64
|
type: {
|
|
46
65
|
type: String,
|
|
@@ -101,6 +120,10 @@ const props = defineProps({
|
|
|
101
120
|
|
|
102
121
|
const emit = defineEmits(['click', 'dismiss'])
|
|
103
122
|
|
|
123
|
+
const bodyRef = ref(null)
|
|
124
|
+
const copied = ref(false)
|
|
125
|
+
let copiedTimer
|
|
126
|
+
|
|
104
127
|
const displayLabel = computed(() => {
|
|
105
128
|
const trimmed = props.label.trim()
|
|
106
129
|
if (trimmed) {
|
|
@@ -113,6 +136,8 @@ const hasLink = computed(() => Boolean(
|
|
|
113
136
|
props.linkLabel && (props.linkHref || props.linkTo)
|
|
114
137
|
))
|
|
115
138
|
|
|
139
|
+
const showCopyButton = computed(() => COPYABLE_TYPES.has(props.type))
|
|
140
|
+
|
|
116
141
|
const rootClasses = computed(() => ({
|
|
117
142
|
notification: true,
|
|
118
143
|
[props.type]: true,
|
|
@@ -126,6 +151,49 @@ function handleClick(event) {
|
|
|
126
151
|
emit('dismiss', event)
|
|
127
152
|
}
|
|
128
153
|
}
|
|
154
|
+
|
|
155
|
+
function copyText() {
|
|
156
|
+
const fromBody = bodyRef.value?.textContent?.trim()
|
|
157
|
+
if (fromBody) {
|
|
158
|
+
return fromBody
|
|
159
|
+
}
|
|
160
|
+
return props.message.trim()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function markCopied() {
|
|
164
|
+
copied.value = true
|
|
165
|
+
clearTimeout(copiedTimer)
|
|
166
|
+
copiedTimer = setTimeout(() => {
|
|
167
|
+
copied.value = false
|
|
168
|
+
}, 1500)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function copyToClipboard() {
|
|
172
|
+
const text = copyText()
|
|
173
|
+
if (!text) {
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
await navigator.clipboard.writeText(text)
|
|
179
|
+
markCopied()
|
|
180
|
+
} catch (_error) {
|
|
181
|
+
try {
|
|
182
|
+
const textarea = document.createElement('textarea')
|
|
183
|
+
textarea.value = text
|
|
184
|
+
textarea.setAttribute('readonly', '')
|
|
185
|
+
textarea.style.position = 'fixed'
|
|
186
|
+
textarea.style.opacity = '0'
|
|
187
|
+
document.body.appendChild(textarea)
|
|
188
|
+
textarea.select()
|
|
189
|
+
document.execCommand('copy')
|
|
190
|
+
document.body.removeChild(textarea)
|
|
191
|
+
markCopied()
|
|
192
|
+
} catch (_fallbackError) {
|
|
193
|
+
// Clipboard unavailable; leave UI unchanged.
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
129
197
|
</script>
|
|
130
198
|
|
|
131
199
|
<style scoped>
|
|
@@ -142,4 +210,27 @@ function handleClick(event) {
|
|
|
142
210
|
.notification.attached-below.critical {
|
|
143
211
|
border-color: var(--karma-bad-border);
|
|
144
212
|
}
|
|
213
|
+
|
|
214
|
+
.notification-copy {
|
|
215
|
+
display: inline-flex;
|
|
216
|
+
align-items: center;
|
|
217
|
+
justify-content: center;
|
|
218
|
+
flex: 0 0 auto;
|
|
219
|
+
align-self: center;
|
|
220
|
+
width: 1.75rem;
|
|
221
|
+
height: 1.75rem;
|
|
222
|
+
margin: 0 0.35rem 0 0;
|
|
223
|
+
padding: 0;
|
|
224
|
+
border: 0;
|
|
225
|
+
border-radius: 0.35rem;
|
|
226
|
+
background: transparent;
|
|
227
|
+
color: var(--text-color);
|
|
228
|
+
cursor: pointer;
|
|
229
|
+
opacity: 0.7;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.notification-copy:hover {
|
|
233
|
+
opacity: 1;
|
|
234
|
+
background: var(--hover-background-color, color-mix(in srgb, var(--text-color) 10%, transparent));
|
|
235
|
+
}
|
|
145
236
|
</style>
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
Each block uses a karma type class for the key tint
|
|
8
8
|
(<code>.notification.info</code>, <code>.notification.warning</code>, …).
|
|
9
9
|
Add <code>clickable</code> when the whole notification is interactive.
|
|
10
|
+
Critical / error / bad blocks include a small copy-to-clipboard control for the body text.
|
|
10
11
|
</p>
|
|
11
12
|
|
|
12
13
|
<FormLayout class="notification-builder" @submit.prevent="addBuiltNotification">
|