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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "picocrank",
3
- "version": "1.17.0",
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.1.1",
22
- "@hugeicons/vue": "^1.0.5",
23
- "@vitejs/plugin-vue": "^6.0.6",
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.0.0",
26
- "vite": "^8.0.10",
27
- "vue": "^3.5.33",
28
- "vue-router": "^5.0.6"
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.3"
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>
@@ -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: 4px 4px 0 0;
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>