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.35.0",
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.0",
27
+ "@hugeicons/core-free-icons": "^4.3.5",
28
28
  "@hugeicons/vue": "^1.0.8",
29
- "@vitejs/plugin-vue": "^6.0.8",
30
- "femtocrank": "^2.7.1",
29
+ "@vitejs/plugin-vue": "^6.0.10",
30
+ "femtocrank": "^2.7.2",
31
31
  "unplugin-vue-components": "^32.1.0",
32
- "vite": "^8.2.2",
33
- "vue": "^3.5.42",
34
- "vue-router": "^5.3.1"
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.0",
39
- "@vue/test-utils": "^2.5.0",
40
- "eslint": "^10.10.0",
41
- "eslint-plugin-vue": "^10.11.0",
42
- "globals": "^17.12.0",
43
- "happy-dom": "^20.14.0",
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.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>
@@ -73,6 +73,8 @@ const isExternalLink = computed(() =>
73
73
 
74
74
  .notification-popup :deep(.notification) {
75
75
  margin: 0;
76
+ /* Room for the absolute dismiss control (and copy on error/critical). */
77
+ padding-right: 2rem;
76
78
  }
77
79
 
78
80
  .notification-popup-content {
@@ -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">