picocrank 1.17.0 → 1.18.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 +9 -9
- package/router.js +10 -0
- package/vue/components/ReadOnlyTextArea.vue +216 -0
- package/vue/components/Tabs.vue +6 -3
- package/vue/views/App.vue +1 -0
- package/vue/views/ReadOnlyTextAreaExample.vue +139 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "picocrank",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.18.1",
|
|
4
4
|
"description": "A set of zero dependency Vue components built on the Femtocrank global CSS theme.",
|
|
5
5
|
"main": "vite.config.js",
|
|
6
6
|
"scripts": {
|
|
@@ -18,17 +18,17 @@
|
|
|
18
18
|
"url": "https://github.com/jamesread/picocrank/issues"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@hugeicons/core-free-icons": "^4.
|
|
22
|
-
"@hugeicons/vue": "^1.0.
|
|
23
|
-
"@vitejs/plugin-vue": "^6.0.
|
|
21
|
+
"@hugeicons/core-free-icons": "^4.2.2",
|
|
22
|
+
"@hugeicons/vue": "^1.0.7",
|
|
23
|
+
"@vitejs/plugin-vue": "^6.0.7",
|
|
24
24
|
"femtocrank": "^2.5.0",
|
|
25
|
-
"unplugin-vue-components": "^32.
|
|
26
|
-
"vite": "^8.
|
|
27
|
-
"vue": "^3.5.
|
|
28
|
-
"vue-router": "^5.0
|
|
25
|
+
"unplugin-vue-components": "^32.1.0",
|
|
26
|
+
"vite": "^8.1.2",
|
|
27
|
+
"vue": "^3.5.39",
|
|
28
|
+
"vue-router": "^5.1.0"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
|
-
"semantic-release": "^25.0.
|
|
31
|
+
"semantic-release": "^25.0.5"
|
|
32
32
|
},
|
|
33
33
|
"homepage": "https://github.com/jamesread/picocrank#readme"
|
|
34
34
|
}
|
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,216 @@
|
|
|
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, watch } 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', 'update:modelValue'])
|
|
87
|
+
|
|
88
|
+
const generatedId = useId()
|
|
89
|
+
const textareaId = props.id || `readonly-textarea-${generatedId}`
|
|
90
|
+
const textareaRef = ref(null)
|
|
91
|
+
const copied = ref(false)
|
|
92
|
+
const content = ref(props.modelValue)
|
|
93
|
+
let copiedTimer = null
|
|
94
|
+
|
|
95
|
+
watch(() => props.modelValue, (value) => {
|
|
96
|
+
content.value = value
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
const displayValue = computed(() => {
|
|
100
|
+
if (!props.markdownTicks || !content.value) {
|
|
101
|
+
return content.value
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const openingFence = props.markdownLang ? `\`\`\`${props.markdownLang}` : '```'
|
|
105
|
+
return `${openingFence}\n${content.value}\n\`\`\``
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
function append(text) {
|
|
109
|
+
content.value += text
|
|
110
|
+
emit('update:modelValue', content.value)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function appendYamlProperty(k, v) {
|
|
114
|
+
const line = `${k}: ${v}`
|
|
115
|
+
if (content.value && !content.value.endsWith('\n')) {
|
|
116
|
+
content.value += '\n'
|
|
117
|
+
}
|
|
118
|
+
content.value += line
|
|
119
|
+
emit('update:modelValue', content.value)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function appendSection(sectionName) {
|
|
123
|
+
content.value += `\n# ${sectionName} ####\n`
|
|
124
|
+
emit('update:modelValue', content.value)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function clear() {
|
|
128
|
+
content.value = ''
|
|
129
|
+
emit('update:modelValue', content.value)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function getContentAsString() {
|
|
133
|
+
return content.value
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function copy() {
|
|
137
|
+
if (!content.value) {
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const text = displayValue.value
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
await navigator.clipboard.writeText(text)
|
|
145
|
+
markCopied()
|
|
146
|
+
emit('copy', text)
|
|
147
|
+
} catch (error) {
|
|
148
|
+
try {
|
|
149
|
+
textareaRef.value?.select()
|
|
150
|
+
document.execCommand('copy')
|
|
151
|
+
markCopied()
|
|
152
|
+
emit('copy', text)
|
|
153
|
+
} catch (fallbackError) {
|
|
154
|
+
emit('copy-error', fallbackError)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function markCopied() {
|
|
160
|
+
copied.value = true
|
|
161
|
+
clearTimeout(copiedTimer)
|
|
162
|
+
copiedTimer = setTimeout(() => {
|
|
163
|
+
copied.value = false
|
|
164
|
+
}, props.copiedFeedbackMs)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
defineExpose({
|
|
168
|
+
copy,
|
|
169
|
+
append,
|
|
170
|
+
appendYamlProperty,
|
|
171
|
+
appendSection,
|
|
172
|
+
clear,
|
|
173
|
+
getContentAsString,
|
|
174
|
+
})
|
|
175
|
+
</script>
|
|
176
|
+
|
|
177
|
+
<style scoped>
|
|
178
|
+
.readonly-textarea {
|
|
179
|
+
display: flex;
|
|
180
|
+
flex-direction: column;
|
|
181
|
+
gap: 0.5rem;
|
|
182
|
+
width: 100%;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.readonly-textarea-header {
|
|
186
|
+
display: flex;
|
|
187
|
+
align-items: center;
|
|
188
|
+
justify-content: space-between;
|
|
189
|
+
gap: 0.75rem;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.readonly-textarea-header label {
|
|
193
|
+
margin: 0;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.fg1 {
|
|
197
|
+
flex: 1;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.readonly-textarea-copy {
|
|
201
|
+
display: inline-flex;
|
|
202
|
+
align-items: center;
|
|
203
|
+
gap: 0.35em;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.readonly-textarea textarea {
|
|
207
|
+
width: 100%;
|
|
208
|
+
resize: vertical;
|
|
209
|
+
margin: 0;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.readonly-textarea textarea.monospace {
|
|
213
|
+
font-family: monospace;
|
|
214
|
+
white-space: pre;
|
|
215
|
+
}
|
|
216
|
+
</style>
|
package/vue/components/Tabs.vue
CHANGED
|
@@ -143,6 +143,7 @@ defineExpose({
|
|
|
143
143
|
background: transparent;
|
|
144
144
|
border: none;
|
|
145
145
|
border-bottom: 2px solid transparent;
|
|
146
|
+
border-radius: 0;
|
|
146
147
|
cursor: pointer;
|
|
147
148
|
font-size: 1em;
|
|
148
149
|
color: var(--text-muted, #666);
|
|
@@ -165,7 +166,7 @@ defineExpose({
|
|
|
165
166
|
.tab-button:focus {
|
|
166
167
|
outline: 2px solid var(--primary-color, #007bff);
|
|
167
168
|
outline-offset: -2px;
|
|
168
|
-
border-radius:
|
|
169
|
+
border-radius: 0;
|
|
169
170
|
}
|
|
170
171
|
|
|
171
172
|
.tabs-content {
|
|
@@ -174,7 +175,8 @@ defineExpose({
|
|
|
174
175
|
}
|
|
175
176
|
|
|
176
177
|
.tab-panel {
|
|
177
|
-
padding: 1.5rem;
|
|
178
|
+
padding-block: 1.5rem;
|
|
179
|
+
padding-inline: 0.75rem;
|
|
178
180
|
display: none;
|
|
179
181
|
}
|
|
180
182
|
|
|
@@ -227,7 +229,8 @@ defineExpose({
|
|
|
227
229
|
}
|
|
228
230
|
|
|
229
231
|
.tab-panel {
|
|
230
|
-
padding: 1rem;
|
|
232
|
+
padding-block: 1rem;
|
|
233
|
+
padding-inline: 0.5rem;
|
|
231
234
|
}
|
|
232
235
|
}
|
|
233
236
|
</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,139 @@
|
|
|
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
|
+
ref="yamlConfigRef"
|
|
33
|
+
v-model="yamlConfig"
|
|
34
|
+
label="Configuration (YAML)"
|
|
35
|
+
:rows="12"
|
|
36
|
+
markdown-ticks
|
|
37
|
+
markdown-lang="yaml"
|
|
38
|
+
/>
|
|
39
|
+
|
|
40
|
+
<ReadOnlyTextArea
|
|
41
|
+
:rows="4"
|
|
42
|
+
placeholder="No diagnostics available."
|
|
43
|
+
/>
|
|
44
|
+
|
|
45
|
+
<p v-if="lastCopied" class="copy-feedback subtle">
|
|
46
|
+
Copied {{ lastCopied.length > 60 ? `${lastCopied.slice(0, 60)}…` : lastCopied }}
|
|
47
|
+
</p>
|
|
48
|
+
</div>
|
|
49
|
+
</Section>
|
|
50
|
+
</template>
|
|
51
|
+
|
|
52
|
+
<script setup>
|
|
53
|
+
import { ref, nextTick } from 'vue'
|
|
54
|
+
import Section from '../components/Section.vue'
|
|
55
|
+
import ReadOnlyTextArea from '../components/ReadOnlyTextArea.vue'
|
|
56
|
+
|
|
57
|
+
const diagnosticLog = ref('')
|
|
58
|
+
const environmentInfo = ref('')
|
|
59
|
+
const yamlConfig = ref('')
|
|
60
|
+
const lastCopied = ref('')
|
|
61
|
+
const yamlConfigRef = ref(null)
|
|
62
|
+
|
|
63
|
+
function buildDiagnosticLog() {
|
|
64
|
+
const now = new Date().toISOString()
|
|
65
|
+
return [
|
|
66
|
+
`[${now}] INFO Service started`,
|
|
67
|
+
`[${now}] INFO Listening on http://localhost:8080`,
|
|
68
|
+
`[${now}] DEBUG Database connection pool ready (size=5)`,
|
|
69
|
+
`[${now}] WARN Cache miss for key: user:session:abc123`,
|
|
70
|
+
`[${now}] INFO GET /api/health 200 12ms`,
|
|
71
|
+
`[${now}] ERROR Failed to reach upstream metrics collector`,
|
|
72
|
+
`[${now}] INFO Retrying metrics collector in 30s`,
|
|
73
|
+
].join('\n')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function buildEnvironmentInfo() {
|
|
77
|
+
return JSON.stringify({
|
|
78
|
+
app: 'picocrank',
|
|
79
|
+
version: '1.0.0',
|
|
80
|
+
environment: 'development',
|
|
81
|
+
timestamp: new Date().toISOString(),
|
|
82
|
+
runtime: {
|
|
83
|
+
userAgent: navigator.userAgent,
|
|
84
|
+
language: navigator.language,
|
|
85
|
+
online: navigator.onLine,
|
|
86
|
+
},
|
|
87
|
+
features: {
|
|
88
|
+
clipboard: Boolean(navigator.clipboard),
|
|
89
|
+
serviceWorker: 'serviceWorker' in navigator,
|
|
90
|
+
},
|
|
91
|
+
}, null, 2)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function buildYamlConfig() {
|
|
95
|
+
yamlConfig.value = ''
|
|
96
|
+
const area = yamlConfigRef.value
|
|
97
|
+
if (!area) {
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
area.appendSection('Application')
|
|
102
|
+
area.appendYamlProperty('name', 'picocrank')
|
|
103
|
+
area.appendYamlProperty('version', '1.0.0')
|
|
104
|
+
area.appendYamlProperty('environment', 'development')
|
|
105
|
+
area.appendSection('Runtime')
|
|
106
|
+
area.appendYamlProperty('language', navigator.language)
|
|
107
|
+
area.appendYamlProperty('online', navigator.onLine)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function refreshDiagnostics() {
|
|
111
|
+
diagnosticLog.value = buildDiagnosticLog()
|
|
112
|
+
environmentInfo.value = buildEnvironmentInfo()
|
|
113
|
+
lastCopied.value = ''
|
|
114
|
+
nextTick(() => buildYamlConfig())
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function onCopy(value) {
|
|
118
|
+
lastCopied.value = value.split('\n')[0]
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
refreshDiagnostics()
|
|
122
|
+
</script>
|
|
123
|
+
|
|
124
|
+
<style scoped>
|
|
125
|
+
.readonly-textarea-examples {
|
|
126
|
+
display: flex;
|
|
127
|
+
flex-direction: column;
|
|
128
|
+
gap: 1.5rem;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.readonly-textarea-examples p {
|
|
132
|
+
margin: 0;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.copy-feedback {
|
|
136
|
+
margin: 0;
|
|
137
|
+
font-size: 0.875rem;
|
|
138
|
+
}
|
|
139
|
+
</style>
|