picocrank 1.31.0 → 1.32.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.
@@ -0,0 +1,267 @@
1
+ <template>
2
+ <Teleport to="body">
3
+ <nav
4
+ v-if="open"
5
+ ref="menuRef"
6
+ class="menu context-menu table-row-context-menu"
7
+ role="menu"
8
+ tabindex="-1"
9
+ :style="style"
10
+ aria-label="Row actions"
11
+ @click.stop
12
+ @contextmenu.prevent
13
+ @keydown="onMenuKeyDown"
14
+ >
15
+ <ul class="noListStyle">
16
+ <li>
17
+ <div>
18
+ <ul>
19
+ <template v-for="(item, index) in items" :key="itemKey(item, index)">
20
+ <li
21
+ v-if="item.divider"
22
+ role="separator"
23
+ />
24
+ <li v-else>
25
+ <button
26
+ type="button"
27
+ role="menuitem"
28
+ :class="{
29
+ 'inline-icon': Boolean(item.icon),
30
+ bad: item.danger,
31
+ }"
32
+ :disabled="item.disabled"
33
+ @click="onSelect(item)"
34
+ >
35
+ <HugeiconsIcon
36
+ v-if="item.icon"
37
+ :icon="item.icon"
38
+ width="1em"
39
+ height="1em"
40
+ :strokeWidth="2.5"
41
+ aria-hidden="true"
42
+ />
43
+ <span>{{ itemLabel(item) }}</span>
44
+ </button>
45
+ </li>
46
+ </template>
47
+ </ul>
48
+ </div>
49
+ </li>
50
+ </ul>
51
+ </nav>
52
+ </Teleport>
53
+ </template>
54
+
55
+ <script setup>
56
+ import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
57
+ import { HugeiconsIcon } from '@hugeicons/vue'
58
+
59
+ const props = defineProps({
60
+ open: {
61
+ type: Boolean,
62
+ default: false,
63
+ },
64
+ items: {
65
+ type: Array,
66
+ default: () => [],
67
+ },
68
+ clientX: {
69
+ type: Number,
70
+ default: 0,
71
+ },
72
+ clientY: {
73
+ type: Number,
74
+ default: 0,
75
+ },
76
+ targetCount: {
77
+ type: Number,
78
+ default: 1,
79
+ },
80
+ })
81
+
82
+ const emit = defineEmits(['update:open', 'select', 'cancel'])
83
+
84
+ const MENU_MARGIN_PX = 8
85
+ const menuRef = ref(null)
86
+ const style = ref({ top: '0px', left: '0px' })
87
+
88
+ function itemKey(item, index) {
89
+ if (item.divider) {
90
+ return `divider-${index}`
91
+ }
92
+ return item.id ?? item.label ?? index
93
+ }
94
+
95
+ function itemLabel(item) {
96
+ if (typeof item.label === 'function') {
97
+ return item.label({ count: props.targetCount })
98
+ }
99
+ const label = item.label ?? ''
100
+ if (props.targetCount > 1 && item.showCount !== false) {
101
+ return `${label} (${props.targetCount})`
102
+ }
103
+ return label
104
+ }
105
+
106
+ function positionMenu() {
107
+ const menu = menuRef.value
108
+ if (!menu) {
109
+ return
110
+ }
111
+
112
+ const rect = menu.getBoundingClientRect()
113
+ const margin = MENU_MARGIN_PX
114
+ let top = props.clientY
115
+ let left = props.clientX
116
+
117
+ if (left + rect.width > window.innerWidth - margin) {
118
+ left = window.innerWidth - rect.width - margin
119
+ }
120
+ if (top + rect.height > window.innerHeight - margin) {
121
+ top = window.innerHeight - rect.height - margin
122
+ }
123
+ if (left < margin) {
124
+ left = margin
125
+ }
126
+ if (top < margin) {
127
+ top = margin
128
+ }
129
+
130
+ style.value = {
131
+ top: `${top}px`,
132
+ left: `${left}px`,
133
+ }
134
+ }
135
+
136
+ function close() {
137
+ emit('update:open', false)
138
+ emit('cancel')
139
+ }
140
+
141
+ function onSelect(item) {
142
+ if (item.disabled) {
143
+ return
144
+ }
145
+ emit('select', item)
146
+ emit('update:open', false)
147
+ }
148
+
149
+ function enabledMenuItems() {
150
+ if (!menuRef.value) {
151
+ return []
152
+ }
153
+ return [...menuRef.value.querySelectorAll('[role="menuitem"]:not([disabled])')]
154
+ }
155
+
156
+ function onMenuKeyDown(event) {
157
+ const items = enabledMenuItems()
158
+ const active = document.activeElement
159
+ const currentIndex = items.indexOf(active)
160
+ const focusIsOnMenuShell = active === menuRef.value
161
+
162
+ if (event.key === 'Escape') {
163
+ event.preventDefault()
164
+ close()
165
+ return
166
+ }
167
+
168
+ if (event.key === 'ArrowDown') {
169
+ event.preventDefault()
170
+ const nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % items.length
171
+ items[nextIndex]?.focus()
172
+ return
173
+ }
174
+
175
+ if (event.key === 'ArrowUp') {
176
+ event.preventDefault()
177
+ const nextIndex = currentIndex < 0
178
+ ? items.length - 1
179
+ : (currentIndex - 1 + items.length) % items.length
180
+ items[nextIndex]?.focus()
181
+ return
182
+ }
183
+
184
+ if (event.key === 'Home') {
185
+ event.preventDefault()
186
+ items[0]?.focus()
187
+ return
188
+ }
189
+
190
+ if (event.key === 'End') {
191
+ event.preventDefault()
192
+ items[items.length - 1]?.focus()
193
+ return
194
+ }
195
+
196
+ if (event.key !== 'Tab' || items.length === 0) {
197
+ return
198
+ }
199
+
200
+ // Tab through items; leave (and close) rather than jumping to the rest of the page.
201
+ if (event.shiftKey) {
202
+ if (focusIsOnMenuShell || currentIndex <= 0) {
203
+ event.preventDefault()
204
+ close()
205
+ }
206
+ return
207
+ }
208
+
209
+ if (currentIndex === items.length - 1) {
210
+ event.preventDefault()
211
+ close()
212
+ }
213
+ }
214
+
215
+ function onDocumentPointerDown(event) {
216
+ if (!props.open) {
217
+ return
218
+ }
219
+ if (menuRef.value?.contains(event.target)) {
220
+ return
221
+ }
222
+ close()
223
+ }
224
+
225
+ function onDocumentKeyDown(event) {
226
+ if (!props.open) {
227
+ return
228
+ }
229
+ if (event.key === 'Escape') {
230
+ close()
231
+ }
232
+ }
233
+
234
+ function attachListeners() {
235
+ window.addEventListener('pointerdown', onDocumentPointerDown, true)
236
+ window.addEventListener('keydown', onDocumentKeyDown)
237
+ }
238
+
239
+ function detachListeners() {
240
+ window.removeEventListener('pointerdown', onDocumentPointerDown, true)
241
+ window.removeEventListener('keydown', onDocumentKeyDown)
242
+ }
243
+
244
+ watch(
245
+ () => props.open,
246
+ async (isOpen) => {
247
+ detachListeners()
248
+ if (!isOpen) {
249
+ return
250
+ }
251
+ style.value = {
252
+ top: `${props.clientY}px`,
253
+ left: `${props.clientX}px`,
254
+ }
255
+ await nextTick()
256
+ positionMenu()
257
+ // Focus the menu shell so keyboard users can Tab/arrow into items,
258
+ // without visually selecting the first action on open.
259
+ menuRef.value?.focus({ preventScroll: true })
260
+ attachListeners()
261
+ },
262
+ )
263
+
264
+ onBeforeUnmount(() => {
265
+ detachListeners()
266
+ })
267
+ </script>
@@ -112,6 +112,11 @@ export const navSectionHubs = {
112
112
  title: 'Dialog',
113
113
  options: { description: 'Modal dialogs and confirmations' },
114
114
  },
115
+ {
116
+ name: 'LoadingAreaExample',
117
+ title: 'Loading area',
118
+ options: { description: 'Async placeholder with spinner' },
119
+ },
115
120
  ],
116
121
  },
117
122
  NavPatternsHub: {
@@ -8,67 +8,99 @@
8
8
  <div class="admin-section">
9
9
  <h3>System Overview</h3>
10
10
  <p>Welcome to the administrative panel. This is a placeholder for administrative functionality.</p>
11
+
12
+ <FormLayout @submit.prevent>
13
+ <FormField
14
+ label="Grid"
15
+ component-has-label
16
+ description="How many StatusCard columns to show."
17
+ >
18
+ <RadioGroup
19
+ v-model="gridLayout"
20
+ name="status-card-grid"
21
+ variant="boolean"
22
+ :options="gridLayoutOptions"
23
+ />
24
+ </FormField>
25
+
26
+ <FormField label="Content align" component-has-label>
27
+ <RadioGroup
28
+ v-model="cardAlign"
29
+ name="status-card-align"
30
+ variant="boolean"
31
+ :options="alignOptions"
32
+ />
33
+ </FormField>
34
+
35
+ <FormField label="Bar labels" component-has-label>
36
+ <RadioGroup
37
+ v-model="showBarLabels"
38
+ name="status-card-bar-labels"
39
+ variant="boolean"
40
+ :options="showHideOptions"
41
+ />
42
+ </FormField>
43
+
44
+ <FormField label="Compact" component-has-label>
45
+ <RadioGroup
46
+ v-model="cardCompact"
47
+ name="status-card-compact"
48
+ variant="boolean"
49
+ :options="onOffOptions"
50
+ />
51
+ </FormField>
52
+ </FormLayout>
11
53
 
12
- <div class="grid-boxed">
13
- <div class="stat-display good">
14
- <h4>Total Users</h4>
15
- <span class="stat">1,247</span>
16
- <span class="subtle">+12% from last month</span>
17
- </div>
18
-
19
- <div class="stat-display bad">
20
- <h4>Active Sessions</h4>
21
- <span class="stat">89</span>
22
- <span class="subtle">-3% from last hour</span>
23
- </div>
24
-
25
- <div class="stat-display warning">
26
- <h4>System Load</h4>
27
- <span class="stat">67%</span>
28
- <span class="subtle">Moderate</span>
29
- </div>
30
-
31
- <div class="stat-display good">
32
- <h4>Storage Used</h4>
33
- <span class="stat">2.4 GB</span>
34
- <span class="subtle">45% of total</span>
35
- </div>
54
+ <div :class="gridClass">
55
+ <StatusCard
56
+ v-for="card in overviewCards"
57
+ :key="card.title"
58
+ :karma="card.karma"
59
+ :bar-label="showBarLabels ? card.barLabel : ''"
60
+ :align="cardAlign"
61
+ :compact="cardCompact"
62
+ :to="card.to"
63
+ >
64
+ <h4>{{ card.title }}</h4>
65
+ <span class="stat">{{ card.stat }}</span>
66
+ <span class="subtle">{{ card.subtle }}</span>
67
+ </StatusCard>
36
68
  </div>
37
69
  </div>
38
70
 
39
71
  <div class="admin-section">
40
72
  <h3>Recent Activity</h3>
41
- <div class="activity-list">
42
- <div class="activity-item">
43
- <span class="activity-time">2 minutes ago</span>
44
- <span class="activity-text">User "john.doe" logged in</span>
45
- </div>
46
- <div class="activity-item">
47
- <span class="activity-time">5 minutes ago</span>
48
- <span class="activity-text">New user account created</span>
49
- </div>
50
- <div class="activity-item">
51
- <span class="activity-time">12 minutes ago</span>
52
- <span class="activity-text">System backup completed</span>
53
- </div>
54
- <div class="activity-item">
55
- <span class="activity-time">1 hour ago</span>
56
- <span class="activity-text">Database maintenance performed</span>
57
- </div>
58
- <div class="activity-item">
59
- <span class="activity-time">2 hours ago</span>
60
- <span class="activity-text">User "admin" updated settings</span>
61
- </div>
62
- </div>
73
+ <ul>
74
+ <li>
75
+ <span class="subtle">2 minutes ago</span>
76
+ — User "john.doe" logged in
77
+ </li>
78
+ <li>
79
+ <span class="subtle">5 minutes ago</span>
80
+ — New user account created
81
+ </li>
82
+ <li>
83
+ <span class="subtle">12 minutes ago</span>
84
+ — System backup completed
85
+ </li>
86
+ <li>
87
+ <span class="subtle">1 hour ago</span>
88
+ — Database maintenance performed
89
+ </li>
90
+ <li>
91
+ <span class="subtle">2 hours ago</span>
92
+ — User "admin" updated settings
93
+ </li>
94
+ </ul>
63
95
  </div>
64
96
 
65
97
  <div class="admin-section">
66
98
  <h3>Quick Actions</h3>
67
- <div role = "toolbar">
68
- <button class="action-btn">Create User</button>
69
- <button class="action-btn">System Backup</button>
70
- <button class="action-btn">View Logs</button>
71
- <button class="action-btn">Settings</button>
99
+ <div role="toolbar">
100
+ <button type="button">Create User</button>
101
+ <button type="button">System Backup</button>
102
+ <button type="button">View Logs</button>
103
+ <button type="button">Settings</button>
72
104
  </div>
73
105
  </div>
74
106
  </div>
@@ -76,6 +108,69 @@
76
108
  </template>
77
109
 
78
110
  <script setup>
79
- // This is a placeholder admin view with dummy content
80
- // No functionality implemented yet
111
+ import { computed, ref } from 'vue'
112
+
113
+ const showBarLabels = ref(true)
114
+ const cardCompact = ref(false)
115
+ const cardAlign = ref('start')
116
+ const gridLayout = ref('3')
117
+
118
+ const showHideOptions = [
119
+ { label: 'Shown', value: true },
120
+ { label: 'Hidden', value: false },
121
+ ]
122
+
123
+ const onOffOptions = [
124
+ { label: 'On', value: true },
125
+ { label: 'Off', value: false },
126
+ ]
127
+
128
+ const alignOptions = [
129
+ { value: 'start', label: 'Start' },
130
+ { value: 'center', label: 'Center' },
131
+ ]
132
+
133
+ const gridLayoutOptions = [
134
+ { value: '3', label: '3 columns' },
135
+ { value: '2', label: '2 columns' },
136
+ ]
137
+
138
+ const gridClass = computed(() => (
139
+ gridLayout.value === '2' ? 'grid-boxed-2' : 'grid-boxed'
140
+ ))
141
+
142
+ const overviewCards = [
143
+ {
144
+ karma: 'good',
145
+ barLabel: 'Users',
146
+ title: 'Total Users',
147
+ stat: '1,247',
148
+ subtle: '+12% from last month',
149
+ to: { name: 'UserDetails' },
150
+ },
151
+ {
152
+ karma: 'bad',
153
+ barLabel: 'Live',
154
+ title: 'Active Sessions',
155
+ stat: '89',
156
+ subtle: '-3% from last hour',
157
+ to: { name: 'TableExample' },
158
+ },
159
+ {
160
+ karma: 'warning',
161
+ barLabel: 'Load',
162
+ title: 'System Load',
163
+ stat: '67%',
164
+ subtle: 'Moderate',
165
+ to: { name: 'StatusExample' },
166
+ },
167
+ {
168
+ karma: 'good',
169
+ barLabel: 'Disk',
170
+ title: 'Storage Used',
171
+ stat: '2.4 GB',
172
+ subtle: '45% of total',
173
+ to: { name: 'ViewItem', params: { id: '1' } },
174
+ },
175
+ ]
81
176
  </script>
@@ -0,0 +1,175 @@
1
+ <template>
2
+ <Section
3
+ title="Loading area"
4
+ :icon="Loading03Icon"
5
+ subtitle="Placeholder for async regions — wrap content or use as a standalone status block."
6
+ >
7
+ <p>
8
+ Bind <code>:loading</code> around a default slot for the usual Vue pattern, or
9
+ render <code>&lt;LoadingArea /&gt;</code> on its own while data is pending.
10
+ The spinner uses Hugeicons <code>Loading03Icon</code> with a CSS rotation
11
+ (respects <code>prefers-reduced-motion</code>).
12
+ </p>
13
+ </Section>
14
+
15
+ <Section title="Wrap content" subtitle="Toggle loading to swap between placeholder and content">
16
+ <template #toolbar>
17
+ <button type="button" class="neutral" @click="contentLoading = !contentLoading">
18
+ {{ contentLoading ? 'Show content' : 'Show loading' }}
19
+ </button>
20
+ </template>
21
+
22
+ <LoadingArea :loading="contentLoading" message="Fetching table rows…" min-height="10rem">
23
+ <div class="demo-panel">
24
+ <p>Content is ready. This block is the default slot.</p>
25
+ <ul>
26
+ <li>Ada — London</li>
27
+ <li>Grace — New York</li>
28
+ <li>Katherine — Newport News</li>
29
+ </ul>
30
+ </div>
31
+ </LoadingArea>
32
+ </Section>
33
+
34
+ <Section title="Overlay mode" subtitle="Keep layout size while covering content">
35
+ <template #toolbar>
36
+ <button type="button" class="neutral" @click="runOverlayFetch">
37
+ {{ overlayLoading ? 'Loading…' : 'Simulate refresh' }}
38
+ </button>
39
+ </template>
40
+
41
+ <LoadingArea
42
+ :loading="overlayLoading"
43
+ overlay
44
+ message="Refreshing…"
45
+ min-height="8rem"
46
+ >
47
+ <div class="demo-panel">
48
+ <p>
49
+ With <code>overlay</code>, the default slot stays mounted so the area does
50
+ not collapse. Useful for refresh-in-place.
51
+ </p>
52
+ <p class="subtle">Last refreshed: {{ lastRefreshedLabel }}</p>
53
+ </div>
54
+ </LoadingArea>
55
+ </Section>
56
+
57
+ <Section title="Standalone placeholder" subtitle="No default slot — just the loading state">
58
+ <LoadingArea message="Connecting to server…" min-height="8rem" />
59
+ </Section>
60
+
61
+ <Section title="Custom placeholder slot" subtitle="Replace the spinner with your own markup">
62
+ <LoadingArea :loading="true" min-height="8rem">
63
+ <template #placeholder>
64
+ <p class="custom-placeholder">Preparing preview…</p>
65
+ </template>
66
+ </LoadingArea>
67
+ </Section>
68
+
69
+ <Section title="Idiomatic async example" subtitle="ref + async function with try/finally">
70
+ <template #toolbar>
71
+ <button type="button" class="good" :disabled="peopleLoading" @click="loadPeople">
72
+ {{ peopleLoading ? 'Loading…' : 'Load people' }}
73
+ </button>
74
+ <button type="button" class="neutral" :disabled="!people.length" @click="people = []">
75
+ Clear
76
+ </button>
77
+ </template>
78
+
79
+ <LoadingArea :loading="peopleLoading" message="Loading people…" min-height="10rem">
80
+ <div v-if="peopleError" class="demo-panel">
81
+ <p class="annotation bad">{{ peopleError }}</p>
82
+ </div>
83
+ <div v-else-if="people.length" class="demo-panel">
84
+ <ul>
85
+ <li v-for="person in people" :key="person.name">
86
+ {{ person.name }} — {{ person.city }}
87
+ </li>
88
+ </ul>
89
+ </div>
90
+ <p v-else class="subtle">No people loaded yet. Click “Load people”.</p>
91
+ </LoadingArea>
92
+ </Section>
93
+ </template>
94
+
95
+ <script setup>
96
+ import { computed, onBeforeUnmount, ref } from 'vue'
97
+ import { Loading03Icon } from '@hugeicons/core-free-icons'
98
+ import { getExampleTableRows } from '../data/examplePeople.js'
99
+
100
+ const contentLoading = ref(true)
101
+ const overlayLoading = ref(false)
102
+ const lastRefreshedAt = ref(Date.now())
103
+ const people = ref([])
104
+ const peopleLoading = ref(false)
105
+ const peopleError = ref('')
106
+
107
+ let overlayTimer = null
108
+ let peopleTimer = null
109
+
110
+ const lastRefreshedLabel = computed(() =>
111
+ new Date(lastRefreshedAt.value).toLocaleTimeString(),
112
+ )
113
+
114
+ function runOverlayFetch() {
115
+ if (overlayLoading.value) {
116
+ return
117
+ }
118
+ overlayLoading.value = true
119
+ clearTimeout(overlayTimer)
120
+ overlayTimer = setTimeout(() => {
121
+ lastRefreshedAt.value = Date.now()
122
+ overlayLoading.value = false
123
+ overlayTimer = null
124
+ }, 1500)
125
+ }
126
+
127
+ async function loadPeople() {
128
+ peopleLoading.value = true
129
+ peopleError.value = ''
130
+ try {
131
+ people.value = await fetchPeople()
132
+ } catch (error) {
133
+ peopleError.value = error?.message || 'Failed to load people.'
134
+ people.value = []
135
+ } finally {
136
+ peopleLoading.value = false
137
+ }
138
+ }
139
+
140
+ function fetchPeople() {
141
+ return new Promise((resolve, reject) => {
142
+ clearTimeout(peopleTimer)
143
+ peopleTimer = setTimeout(() => {
144
+ peopleTimer = null
145
+ if (Math.random() < 0.15) {
146
+ reject(new Error('Simulated network failure. Try again.'))
147
+ return
148
+ }
149
+ resolve(getExampleTableRows().slice(0, 5))
150
+ }, 1200)
151
+ })
152
+ }
153
+
154
+ onBeforeUnmount(() => {
155
+ clearTimeout(overlayTimer)
156
+ clearTimeout(peopleTimer)
157
+ })
158
+ </script>
159
+
160
+ <style scoped>
161
+ .demo-panel {
162
+ padding: 1rem;
163
+ }
164
+
165
+ .demo-panel ul {
166
+ margin: 0.5rem 0 0;
167
+ padding-left: 1.25rem;
168
+ }
169
+
170
+ .custom-placeholder {
171
+ margin: 0;
172
+ font-style: italic;
173
+ color: var(--muted-text-color);
174
+ }
175
+ </style>