picocrank 1.17.0 → 1.18.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/package.json +1 -1
- package/router.js +10 -0
- package/vue/components/ReadOnlyTextArea.vue +178 -0
- package/vue/views/App.vue +1 -0
- package/vue/views/ReadOnlyTextAreaExample.vue +112 -0
package/package.json
CHANGED
package/router.js
CHANGED
|
@@ -7,6 +7,7 @@ import { SecurityValidationIcon } from '@hugeicons/core-free-icons';
|
|
|
7
7
|
import { CalendarIcon } from '@hugeicons/core-free-icons';
|
|
8
8
|
import { EditIcon } from '@hugeicons/core-free-icons';
|
|
9
9
|
import { Menu01Icon } from '@hugeicons/core-free-icons';
|
|
10
|
+
import { ClipboardCopyIcon } from '@hugeicons/core-free-icons';
|
|
10
11
|
|
|
11
12
|
const routes = [
|
|
12
13
|
{
|
|
@@ -75,6 +76,15 @@ const routes = [
|
|
|
75
76
|
icon: EditIcon,
|
|
76
77
|
}
|
|
77
78
|
},
|
|
79
|
+
{
|
|
80
|
+
name: 'ReadOnlyTextAreaExample',
|
|
81
|
+
path: '/readonly-textarea-example',
|
|
82
|
+
component: () => import('./vue/views/ReadOnlyTextAreaExample.vue'),
|
|
83
|
+
meta: {
|
|
84
|
+
title: 'ReadOnlyTextArea Example',
|
|
85
|
+
icon: ClipboardCopyIcon,
|
|
86
|
+
}
|
|
87
|
+
},
|
|
78
88
|
{
|
|
79
89
|
name: 'TabsExample',
|
|
80
90
|
path: '/tabs-example',
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="readonly-textarea">
|
|
3
|
+
<div v-if="label || showCopyButton" class="readonly-textarea-header">
|
|
4
|
+
<label v-if="label" :for="textareaId">{{ label }}</label>
|
|
5
|
+
<div v-else class="fg1" />
|
|
6
|
+
<button
|
|
7
|
+
v-if="showCopyButton"
|
|
8
|
+
type="button"
|
|
9
|
+
class="readonly-textarea-copy"
|
|
10
|
+
:title="copied ? 'Copied' : 'Copy to clipboard'"
|
|
11
|
+
:disabled="!modelValue"
|
|
12
|
+
@click="copy"
|
|
13
|
+
>
|
|
14
|
+
<HugeiconsIcon :icon="copied ? CopyCheckIcon : Copy01Icon" width="1em" height="1em" />
|
|
15
|
+
<span>{{ copied ? copiedLabel : copyLabel }}</span>
|
|
16
|
+
</button>
|
|
17
|
+
</div>
|
|
18
|
+
<textarea
|
|
19
|
+
:id="textareaId"
|
|
20
|
+
ref="textareaRef"
|
|
21
|
+
:value="displayValue"
|
|
22
|
+
readonly
|
|
23
|
+
:rows="rows"
|
|
24
|
+
:placeholder="placeholder"
|
|
25
|
+
:class="{ monospace }"
|
|
26
|
+
/>
|
|
27
|
+
</div>
|
|
28
|
+
</template>
|
|
29
|
+
|
|
30
|
+
<script setup>
|
|
31
|
+
import { ref, computed, useId } from 'vue'
|
|
32
|
+
import { HugeiconsIcon } from '@hugeicons/vue'
|
|
33
|
+
import { Copy01Icon, CopyCheckIcon } from '@hugeicons/core-free-icons'
|
|
34
|
+
|
|
35
|
+
const props = defineProps({
|
|
36
|
+
modelValue: {
|
|
37
|
+
type: String,
|
|
38
|
+
default: '',
|
|
39
|
+
},
|
|
40
|
+
label: {
|
|
41
|
+
type: String,
|
|
42
|
+
default: '',
|
|
43
|
+
},
|
|
44
|
+
id: {
|
|
45
|
+
type: String,
|
|
46
|
+
default: '',
|
|
47
|
+
},
|
|
48
|
+
rows: {
|
|
49
|
+
type: Number,
|
|
50
|
+
default: 8,
|
|
51
|
+
},
|
|
52
|
+
placeholder: {
|
|
53
|
+
type: String,
|
|
54
|
+
default: '',
|
|
55
|
+
},
|
|
56
|
+
monospace: {
|
|
57
|
+
type: Boolean,
|
|
58
|
+
default: true,
|
|
59
|
+
},
|
|
60
|
+
showCopyButton: {
|
|
61
|
+
type: Boolean,
|
|
62
|
+
default: true,
|
|
63
|
+
},
|
|
64
|
+
copyLabel: {
|
|
65
|
+
type: String,
|
|
66
|
+
default: 'Copy',
|
|
67
|
+
},
|
|
68
|
+
copiedLabel: {
|
|
69
|
+
type: String,
|
|
70
|
+
default: 'Copied',
|
|
71
|
+
},
|
|
72
|
+
copiedFeedbackMs: {
|
|
73
|
+
type: Number,
|
|
74
|
+
default: 2000,
|
|
75
|
+
},
|
|
76
|
+
markdownTicks: {
|
|
77
|
+
type: Boolean,
|
|
78
|
+
default: false,
|
|
79
|
+
},
|
|
80
|
+
markdownLang: {
|
|
81
|
+
type: String,
|
|
82
|
+
default: '',
|
|
83
|
+
},
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const emit = defineEmits(['copy', 'copy-error'])
|
|
87
|
+
|
|
88
|
+
const generatedId = useId()
|
|
89
|
+
const textareaId = props.id || `readonly-textarea-${generatedId}`
|
|
90
|
+
const textareaRef = ref(null)
|
|
91
|
+
const copied = ref(false)
|
|
92
|
+
let copiedTimer = null
|
|
93
|
+
|
|
94
|
+
const displayValue = computed(() => {
|
|
95
|
+
if (!props.markdownTicks || !props.modelValue) {
|
|
96
|
+
return props.modelValue
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const openingFence = props.markdownLang ? `\`\`\`${props.markdownLang}` : '```'
|
|
100
|
+
return `${openingFence}\n${props.modelValue}\n\`\`\``
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
async function copy() {
|
|
104
|
+
if (!props.modelValue) {
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const text = displayValue.value
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
await navigator.clipboard.writeText(text)
|
|
112
|
+
markCopied()
|
|
113
|
+
emit('copy', text)
|
|
114
|
+
} catch (error) {
|
|
115
|
+
try {
|
|
116
|
+
textareaRef.value?.select()
|
|
117
|
+
document.execCommand('copy')
|
|
118
|
+
markCopied()
|
|
119
|
+
emit('copy', text)
|
|
120
|
+
} catch (fallbackError) {
|
|
121
|
+
emit('copy-error', fallbackError)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function markCopied() {
|
|
127
|
+
copied.value = true
|
|
128
|
+
clearTimeout(copiedTimer)
|
|
129
|
+
copiedTimer = setTimeout(() => {
|
|
130
|
+
copied.value = false
|
|
131
|
+
}, props.copiedFeedbackMs)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
defineExpose({
|
|
135
|
+
copy,
|
|
136
|
+
})
|
|
137
|
+
</script>
|
|
138
|
+
|
|
139
|
+
<style scoped>
|
|
140
|
+
.readonly-textarea {
|
|
141
|
+
display: flex;
|
|
142
|
+
flex-direction: column;
|
|
143
|
+
gap: 0.5rem;
|
|
144
|
+
width: 100%;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.readonly-textarea-header {
|
|
148
|
+
display: flex;
|
|
149
|
+
align-items: center;
|
|
150
|
+
justify-content: space-between;
|
|
151
|
+
gap: 0.75rem;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.readonly-textarea-header label {
|
|
155
|
+
margin: 0;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.fg1 {
|
|
159
|
+
flex: 1;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.readonly-textarea-copy {
|
|
163
|
+
display: inline-flex;
|
|
164
|
+
align-items: center;
|
|
165
|
+
gap: 0.35em;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.readonly-textarea textarea {
|
|
169
|
+
width: 100%;
|
|
170
|
+
resize: vertical;
|
|
171
|
+
margin: 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.readonly-textarea textarea.monospace {
|
|
175
|
+
font-family: monospace;
|
|
176
|
+
white-space: pre;
|
|
177
|
+
}
|
|
178
|
+
</style>
|
package/vue/views/App.vue
CHANGED
|
@@ -93,6 +93,7 @@
|
|
|
93
93
|
navigation.value.addRouterLink('TableExample')
|
|
94
94
|
navigation.value.addRouterLink('CalendarExample')
|
|
95
95
|
navigation.value.addRouterLink('FormExample')
|
|
96
|
+
navigation.value.addRouterLink('ReadOnlyTextAreaExample')
|
|
96
97
|
navigation.value.addRouterLink('TabsExample')
|
|
97
98
|
navigation.value.addSeparator('separator-1');
|
|
98
99
|
navigation.value.addRouterLink('ViewItem', 'View Awesome Item', { params: { id: 1 } })
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<Section
|
|
3
|
+
title="ReadOnlyTextArea Example"
|
|
4
|
+
subtitle="Read-only text areas with copy-to-clipboard, for diagnostics and similar output"
|
|
5
|
+
>
|
|
6
|
+
<template #toolbar>
|
|
7
|
+
<button type="button" @click="refreshDiagnostics">Refresh diagnostics</button>
|
|
8
|
+
</template>
|
|
9
|
+
|
|
10
|
+
<div class="readonly-textarea-examples">
|
|
11
|
+
<p>
|
|
12
|
+
Use <code>ReadOnlyTextArea</code> when you need to show log output, API responses,
|
|
13
|
+
or other text that users should read and copy but not edit.
|
|
14
|
+
</p>
|
|
15
|
+
|
|
16
|
+
<ReadOnlyTextArea
|
|
17
|
+
v-model="diagnosticLog"
|
|
18
|
+
label="Application log"
|
|
19
|
+
:rows="10"
|
|
20
|
+
@copy="onCopy"
|
|
21
|
+
/>
|
|
22
|
+
|
|
23
|
+
<ReadOnlyTextArea
|
|
24
|
+
v-model="environmentInfo"
|
|
25
|
+
label="Environment (JSON)"
|
|
26
|
+
:rows="12"
|
|
27
|
+
markdown-ticks
|
|
28
|
+
markdown-lang="json"
|
|
29
|
+
/>
|
|
30
|
+
|
|
31
|
+
<ReadOnlyTextArea
|
|
32
|
+
label="Empty output"
|
|
33
|
+
:rows="4"
|
|
34
|
+
placeholder="No diagnostics available."
|
|
35
|
+
/>
|
|
36
|
+
|
|
37
|
+
<p v-if="lastCopied" class="copy-feedback subtle">
|
|
38
|
+
Copied {{ lastCopied.length > 60 ? `${lastCopied.slice(0, 60)}…` : lastCopied }}
|
|
39
|
+
</p>
|
|
40
|
+
</div>
|
|
41
|
+
</Section>
|
|
42
|
+
</template>
|
|
43
|
+
|
|
44
|
+
<script setup>
|
|
45
|
+
import { ref } from 'vue'
|
|
46
|
+
import Section from '../components/Section.vue'
|
|
47
|
+
import ReadOnlyTextArea from '../components/ReadOnlyTextArea.vue'
|
|
48
|
+
|
|
49
|
+
const diagnosticLog = ref('')
|
|
50
|
+
const environmentInfo = ref('')
|
|
51
|
+
const lastCopied = ref('')
|
|
52
|
+
|
|
53
|
+
function buildDiagnosticLog() {
|
|
54
|
+
const now = new Date().toISOString()
|
|
55
|
+
return [
|
|
56
|
+
`[${now}] INFO Service started`,
|
|
57
|
+
`[${now}] INFO Listening on http://localhost:8080`,
|
|
58
|
+
`[${now}] DEBUG Database connection pool ready (size=5)`,
|
|
59
|
+
`[${now}] WARN Cache miss for key: user:session:abc123`,
|
|
60
|
+
`[${now}] INFO GET /api/health 200 12ms`,
|
|
61
|
+
`[${now}] ERROR Failed to reach upstream metrics collector`,
|
|
62
|
+
`[${now}] INFO Retrying metrics collector in 30s`,
|
|
63
|
+
].join('\n')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function buildEnvironmentInfo() {
|
|
67
|
+
return JSON.stringify({
|
|
68
|
+
app: 'picocrank',
|
|
69
|
+
version: '1.0.0',
|
|
70
|
+
environment: 'development',
|
|
71
|
+
timestamp: new Date().toISOString(),
|
|
72
|
+
runtime: {
|
|
73
|
+
userAgent: navigator.userAgent,
|
|
74
|
+
language: navigator.language,
|
|
75
|
+
online: navigator.onLine,
|
|
76
|
+
},
|
|
77
|
+
features: {
|
|
78
|
+
clipboard: Boolean(navigator.clipboard),
|
|
79
|
+
serviceWorker: 'serviceWorker' in navigator,
|
|
80
|
+
},
|
|
81
|
+
}, null, 2)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function refreshDiagnostics() {
|
|
85
|
+
diagnosticLog.value = buildDiagnosticLog()
|
|
86
|
+
environmentInfo.value = buildEnvironmentInfo()
|
|
87
|
+
lastCopied.value = ''
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function onCopy(value) {
|
|
91
|
+
lastCopied.value = value.split('\n')[0]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
refreshDiagnostics()
|
|
95
|
+
</script>
|
|
96
|
+
|
|
97
|
+
<style scoped>
|
|
98
|
+
.readonly-textarea-examples {
|
|
99
|
+
display: flex;
|
|
100
|
+
flex-direction: column;
|
|
101
|
+
gap: 1.5rem;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.readonly-textarea-examples p {
|
|
105
|
+
margin: 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.copy-feedback {
|
|
109
|
+
margin: 0;
|
|
110
|
+
font-size: 0.875rem;
|
|
111
|
+
}
|
|
112
|
+
</style>
|