picocrank 1.22.1 → 1.23.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 +5 -5
- package/router.js +19 -0
- package/vue/components/Table.vue +448 -104
- package/vue/components/TableColumnFilterPopover.vue +421 -0
- package/vue/composables/tableFilters.js +251 -0
- package/vue/composables/useLongPress.js +76 -0
- package/vue/views/App.vue +2 -0
- package/vue/views/DialogExample.vue +123 -0
- package/vue/views/TableExample.vue +34 -34
- package/vue/views/TableRemoteExample.vue +79 -0
- package/vue/views/Welcome.vue +5 -2
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
const DEFAULT_DELAY_MS = 500
|
|
4
|
+
const DEFAULT_MOVE_TOLERANCE_PX = 10
|
|
5
|
+
|
|
6
|
+
export function useLongPress(callback, options = {}) {
|
|
7
|
+
const delayMs = options.delayMs ?? DEFAULT_DELAY_MS
|
|
8
|
+
const moveTolerancePx = options.moveTolerancePx ?? DEFAULT_MOVE_TOLERANCE_PX
|
|
9
|
+
|
|
10
|
+
const suppressClick = ref(false)
|
|
11
|
+
let timer = null
|
|
12
|
+
let startX = 0
|
|
13
|
+
let startY = 0
|
|
14
|
+
let pointerId = null
|
|
15
|
+
|
|
16
|
+
function clearTimer() {
|
|
17
|
+
if (timer !== null) {
|
|
18
|
+
clearTimeout(timer)
|
|
19
|
+
timer = null
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function onPointerDown(event) {
|
|
24
|
+
if (typeof callback !== 'function') {
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
clearTimer()
|
|
29
|
+
pointerId = event.pointerId
|
|
30
|
+
startX = event.clientX
|
|
31
|
+
startY = event.clientY
|
|
32
|
+
|
|
33
|
+
timer = window.setTimeout(() => {
|
|
34
|
+
timer = null
|
|
35
|
+
suppressClick.value = true
|
|
36
|
+
callback(event)
|
|
37
|
+
}, delayMs)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function onPointerMove(event) {
|
|
41
|
+
if (timer === null || event.pointerId !== pointerId) {
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const dx = Math.abs(event.clientX - startX)
|
|
46
|
+
const dy = Math.abs(event.clientY - startY)
|
|
47
|
+
if (dx > moveTolerancePx || dy > moveTolerancePx) {
|
|
48
|
+
clearTimer()
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function onPointerUp() {
|
|
53
|
+
clearTimer()
|
|
54
|
+
pointerId = null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function onClick(event) {
|
|
58
|
+
if (!suppressClick.value) {
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
event.preventDefault()
|
|
63
|
+
event.stopPropagation()
|
|
64
|
+
suppressClick.value = false
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
suppressClick,
|
|
69
|
+
onPointerDown,
|
|
70
|
+
onPointerMove,
|
|
71
|
+
onPointerUp,
|
|
72
|
+
onPointerCancel: onPointerUp,
|
|
73
|
+
onPointerLeave: onPointerUp,
|
|
74
|
+
onClick,
|
|
75
|
+
}
|
|
76
|
+
}
|
package/vue/views/App.vue
CHANGED
|
@@ -188,6 +188,7 @@
|
|
|
188
188
|
|
|
189
189
|
navigation.value.addSection('Data display', { name: 'nav-data' })
|
|
190
190
|
navigation.value.addRouterLink('TableExample', 'Table')
|
|
191
|
+
navigation.value.addRouterLink('TableRemoteExample', 'Remote table')
|
|
191
192
|
navigation.value.addRouterLink('CalendarExample', 'Calendar')
|
|
192
193
|
navigation.value.addRouterLink('ReadOnlyTextAreaExample', 'Read-only output')
|
|
193
194
|
|
|
@@ -198,6 +199,7 @@
|
|
|
198
199
|
|
|
199
200
|
navigation.value.addSection('Feedback & status', { name: 'nav-feedback' })
|
|
200
201
|
navigation.value.addRouterLink('StatusExample', 'Status & notifications')
|
|
202
|
+
navigation.value.addRouterLink('DialogExample', 'Dialog')
|
|
201
203
|
|
|
202
204
|
navigation.value.addSection('Patterns', { name: 'nav-patterns' })
|
|
203
205
|
navigation.value.addCallback('Callback example', helloWorld, { icon: Pin02Icon })
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<Section
|
|
3
|
+
title="Dialog"
|
|
4
|
+
subtitle="Native HTML dialog element with showModal()"
|
|
5
|
+
>
|
|
6
|
+
<p>
|
|
7
|
+
The <code><dialog></code> element provides accessible modal and
|
|
8
|
+
non-modal overlays without a third-party library. Femtocrank styles the
|
|
9
|
+
element globally; use <code>showModal()</code> for a modal with backdrop,
|
|
10
|
+
or <code>show()</code> for a non-modal panel.
|
|
11
|
+
</p>
|
|
12
|
+
</Section>
|
|
13
|
+
|
|
14
|
+
<Section title="Modal dialog" subtitle="Opens with showModal() and closes on backdrop click">
|
|
15
|
+
<div role="toolbar" class="dialog-actions">
|
|
16
|
+
<button type="button" @click="openModal">Open modal</button>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<dialog
|
|
20
|
+
ref="modalRef"
|
|
21
|
+
class="example-dialog"
|
|
22
|
+
@close="onModalClose"
|
|
23
|
+
@click="onBackdropClick($event, modalRef)"
|
|
24
|
+
>
|
|
25
|
+
<div class="dialog-panel" @click.stop>
|
|
26
|
+
<h3>Confirm action</h3>
|
|
27
|
+
<p>
|
|
28
|
+
This modal uses the native dialog API. Press Escape or click outside
|
|
29
|
+
to dismiss, or choose an action below.
|
|
30
|
+
</p>
|
|
31
|
+
<form method="dialog" class="dialog-form">
|
|
32
|
+
<div role="toolbar" class="dialog-actions">
|
|
33
|
+
<button type="submit" value="cancel" class="neutral">Cancel</button>
|
|
34
|
+
<button type="submit" value="confirm" class="good">Confirm</button>
|
|
35
|
+
</div>
|
|
36
|
+
</form>
|
|
37
|
+
</div>
|
|
38
|
+
</dialog>
|
|
39
|
+
|
|
40
|
+
<p v-if="modalResult" class="subtle">{{ modalResult }}</p>
|
|
41
|
+
</Section>
|
|
42
|
+
|
|
43
|
+
<Section title="Non-modal dialog" subtitle="Stays open while the page remains interactive">
|
|
44
|
+
<div role="toolbar" class="dialog-actions">
|
|
45
|
+
<button type="button" class="neutral" @click="openPanel">Open panel</button>
|
|
46
|
+
<button type="button" class="bad" :disabled="!panelRef?.open" @click="closePanel">
|
|
47
|
+
Close panel
|
|
48
|
+
</button>
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
<dialog ref="panelRef" class="example-dialog example-dialog-panel">
|
|
52
|
+
<div class="dialog-panel">
|
|
53
|
+
<h3>Side note</h3>
|
|
54
|
+
<p>
|
|
55
|
+
Non-modal dialogs use <code>show()</code> instead of
|
|
56
|
+
<code>showModal()</code>. They do not trap focus or dim the page.
|
|
57
|
+
</p>
|
|
58
|
+
<button type="button" class="neutral" @click="closePanel">Dismiss</button>
|
|
59
|
+
</div>
|
|
60
|
+
</dialog>
|
|
61
|
+
</Section>
|
|
62
|
+
</template>
|
|
63
|
+
|
|
64
|
+
<script setup>
|
|
65
|
+
import { ref } from 'vue'
|
|
66
|
+
import Section from '../components/Section.vue'
|
|
67
|
+
|
|
68
|
+
const modalRef = ref(null)
|
|
69
|
+
const panelRef = ref(null)
|
|
70
|
+
const modalResult = ref('')
|
|
71
|
+
|
|
72
|
+
function openModal() {
|
|
73
|
+
modalRef.value?.showModal()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function onModalClose(event) {
|
|
77
|
+
const value = event.target?.returnValue
|
|
78
|
+
if (value === 'confirm') {
|
|
79
|
+
modalResult.value = 'Action confirmed.'
|
|
80
|
+
} else {
|
|
81
|
+
modalResult.value = 'Dialog closed without confirming.'
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function onBackdropClick(event, dialog) {
|
|
86
|
+
if (event.target === dialog) {
|
|
87
|
+
dialog.close('cancel')
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function openPanel() {
|
|
92
|
+
panelRef.value?.show()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function closePanel() {
|
|
96
|
+
panelRef.value?.close()
|
|
97
|
+
}
|
|
98
|
+
</script>
|
|
99
|
+
|
|
100
|
+
<style scoped>
|
|
101
|
+
.dialog-actions {
|
|
102
|
+
display: flex;
|
|
103
|
+
flex-wrap: wrap;
|
|
104
|
+
gap: 0.5rem;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.dialog-panel h3 {
|
|
108
|
+
margin: 0 0 0.5rem;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.dialog-panel p {
|
|
112
|
+
margin: 0 0 1rem;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.dialog-form {
|
|
116
|
+
margin: 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.example-dialog-panel {
|
|
120
|
+
margin-block-start: 1rem;
|
|
121
|
+
max-width: 24rem;
|
|
122
|
+
}
|
|
123
|
+
</style>
|
|
@@ -1,46 +1,46 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<Section
|
|
3
|
-
title="Table Example"
|
|
4
|
-
subtitle="
|
|
5
|
-
:padding
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
2
|
+
<Section
|
|
3
|
+
title="Table Example"
|
|
4
|
+
subtitle="Sortable columns, pagination, and column filters. Right-click or long-press a column header to filter."
|
|
5
|
+
:padding="false"
|
|
6
|
+
>
|
|
7
|
+
<template #toolbar>
|
|
8
|
+
<button>Add Item</button>
|
|
9
|
+
</template>
|
|
10
10
|
|
|
11
|
-
<Table
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
11
|
+
<Table
|
|
12
|
+
:data="data"
|
|
13
|
+
:headers="headers"
|
|
14
|
+
:show-pagination="true"
|
|
15
|
+
>
|
|
16
|
+
<template #cell-name="{ row, value }">
|
|
17
|
+
<router-link :to="{ name: 'ViewItem', params: { id: row.name } }">
|
|
18
|
+
{{ value }}
|
|
19
|
+
</router-link>
|
|
20
|
+
</template>
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
22
|
+
<template #cell-city="{ row, value }">
|
|
23
|
+
<span class="subtle">
|
|
24
|
+
{{ value }}
|
|
25
|
+
</span>
|
|
26
|
+
</template>
|
|
27
27
|
</Table>
|
|
28
28
|
</Section>
|
|
29
29
|
</template>
|
|
30
30
|
|
|
31
31
|
<script setup>
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
import { ref, onMounted } from 'vue'
|
|
33
|
+
import { getExampleTableRows } from '../data/examplePeople.js'
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
const headers = ref([
|
|
36
|
+
{ key: 'name', label: 'Name', sortable: true, filterable: true, filterType: 'text', hidden: false, width: '20%' },
|
|
37
|
+
{ key: 'age', label: 'Age', sortable: true, filterable: true, filterType: 'number' },
|
|
38
|
+
{ key: 'city', label: 'City', sortable: true, filterable: true, filterType: 'select', width: '200px' },
|
|
39
|
+
])
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
const data = ref([])
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
onMounted(() => {
|
|
44
|
+
data.value = getExampleTableRows()
|
|
45
|
+
})
|
|
46
46
|
</script>
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<Section
|
|
3
|
+
title="Remote table"
|
|
4
|
+
subtitle="Server-style pagination with fetchRows. Filters, sort, and page changes are sent to the callback. Right-click or long-press a column header to filter."
|
|
5
|
+
:padding="false"
|
|
6
|
+
>
|
|
7
|
+
<p v-if="lastQuerySummary" class="subtle query-summary">{{ lastQuerySummary }}</p>
|
|
8
|
+
|
|
9
|
+
<Table
|
|
10
|
+
:headers="headers"
|
|
11
|
+
:fetch-rows="fetchRows"
|
|
12
|
+
:show-pagination="true"
|
|
13
|
+
:remote-debounce-ms="250"
|
|
14
|
+
/>
|
|
15
|
+
</Section>
|
|
16
|
+
</template>
|
|
17
|
+
|
|
18
|
+
<script setup>
|
|
19
|
+
import { ref } from 'vue'
|
|
20
|
+
import { getExampleTableRows } from '../data/examplePeople.js'
|
|
21
|
+
import { applyFilters, sortRows } from '../composables/tableFilters.js'
|
|
22
|
+
|
|
23
|
+
const allRows = getExampleTableRows()
|
|
24
|
+
|
|
25
|
+
const headers = ref([
|
|
26
|
+
{ key: 'name', label: 'Name', sortable: true, filterable: true, filterType: 'text', width: '20%' },
|
|
27
|
+
{ key: 'age', label: 'Age', sortable: true, filterable: true, filterType: 'number' },
|
|
28
|
+
{
|
|
29
|
+
key: 'city',
|
|
30
|
+
label: 'City',
|
|
31
|
+
sortable: true,
|
|
32
|
+
filterable: true,
|
|
33
|
+
filterType: 'select',
|
|
34
|
+
filterOptions: [...new Set(allRows.map((row) => row.city))].sort().map((city) => ({
|
|
35
|
+
value: city,
|
|
36
|
+
label: city,
|
|
37
|
+
})),
|
|
38
|
+
width: '200px',
|
|
39
|
+
},
|
|
40
|
+
])
|
|
41
|
+
|
|
42
|
+
const lastQuerySummary = ref('')
|
|
43
|
+
|
|
44
|
+
async function fetchRows(query, { signal } = {}) {
|
|
45
|
+
await new Promise((resolve, reject) => {
|
|
46
|
+
const timer = window.setTimeout(resolve, 200)
|
|
47
|
+
if (!signal) {
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
const onAbort = () => {
|
|
51
|
+
window.clearTimeout(timer)
|
|
52
|
+
reject(new DOMException('Aborted', 'AbortError'))
|
|
53
|
+
}
|
|
54
|
+
if (signal.aborted) {
|
|
55
|
+
onAbort()
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
const filtered = applyFilters(allRows, headers.value, query.filters)
|
|
62
|
+
const sorted = sortRows(filtered, query.sortBy, query.sortDir)
|
|
63
|
+
const start = (query.page - 1) * query.pageSize
|
|
64
|
+
const rows = sorted.slice(start, start + query.pageSize)
|
|
65
|
+
|
|
66
|
+
lastQuerySummary.value = `Last query: page ${query.page}, ${Object.keys(query.filters ?? {}).length} active filter(s), ${sorted.length} matching row(s).`
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
rows,
|
|
70
|
+
total: sorted.length,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
</script>
|
|
74
|
+
|
|
75
|
+
<style scoped>
|
|
76
|
+
.query-summary {
|
|
77
|
+
margin: 0 0 0.75rem 1rem;
|
|
78
|
+
}
|
|
79
|
+
</style>
|
package/vue/views/Welcome.vue
CHANGED
|
@@ -120,7 +120,7 @@ const popularExamples = [
|
|
|
120
120
|
name: 'TableExample',
|
|
121
121
|
category: 'Data display',
|
|
122
122
|
label: 'Table',
|
|
123
|
-
description: '
|
|
123
|
+
description: 'Sorting, pagination, and column filters',
|
|
124
124
|
},
|
|
125
125
|
{
|
|
126
126
|
name: 'CalendarExample',
|
|
@@ -160,7 +160,10 @@ onMounted(() => {
|
|
|
160
160
|
description: 'Drop-in themes on Femtocrank',
|
|
161
161
|
})
|
|
162
162
|
nav.addRouterLink('TableExample', 'Table', {
|
|
163
|
-
description: 'Lists with sorting and
|
|
163
|
+
description: 'Lists with sorting, pagination, and filters',
|
|
164
|
+
})
|
|
165
|
+
nav.addRouterLink('TableRemoteExample', 'Remote table', {
|
|
166
|
+
description: 'Server-style fetchRows pagination and filters',
|
|
164
167
|
})
|
|
165
168
|
nav.addRouterLink('CalendarExample', 'Calendar', {
|
|
166
169
|
description: 'Month view with events',
|