picocrank 1.23.1 → 1.25.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/AGENTS.md +2 -0
- package/docs/filter-query-v1.md +180 -0
- package/logo.svg +7 -7
- package/package.json +7 -5
- package/public/themes/README.md +19 -2
- package/public/themes/index.json +1 -5
- package/styles/dark-theme.css +16 -1
- package/supplemental-themes/README.md +9 -0
- package/supplemental-themes/catppuccin-latte-frappe/theme.css +276 -0
- package/supplemental-themes/dracula-alucard/theme.css +258 -0
- package/supplemental-themes/gruvbox-dark-light/theme.css +264 -0
- package/supplemental-themes/index.json +17 -0
- package/{public/themes → supplemental-themes}/waffles/theme.css +99 -0
- package/vite.config.js +91 -0
- package/vue/components/FormField.vue +69 -1
- package/vue/components/Header.vue +3 -3
- package/vue/components/Login.vue +1 -3
- package/vue/components/Section.vue +45 -14
- package/vue/components/Sidebar.vue +2 -1
- package/vue/components/SidebarNavLink.vue +6 -0
- package/vue/components/Table.vue +1113 -73
- package/vue/components/TableColumnFilterPopover.vue +263 -44
- package/vue/components/TableColumnOptionsPopover.vue +1116 -0
- package/vue/components/ThemeSwitcher.vue +119 -0
- package/vue/components/TopBar.vue +2 -2
- package/vue/composables/tableColumnPresets.js +296 -0
- package/vue/composables/tableFilterQuery.js +202 -0
- package/vue/composables/tableFilters.js +167 -3
- package/vue/composables/useCustomTheme.js +167 -24
- package/vue/data/examplePeople.js +2 -6
- package/vue/examples/App.vue +1 -1
- package/vue/examples/FormExample.vue +40 -6
- package/vue/examples/LoginExample.vue +1 -3
- package/vue/examples/TableExample.vue +329 -11
- package/vue/examples/TableRemoteExample.vue +112 -4
- package/vue/examples/ThemePreview.vue +17 -31
- package/public/themes/catppuccin/theme.css +0 -195
- package/public/themes/dracula/theme.css +0 -181
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<Section
|
|
3
3
|
title="Table Example"
|
|
4
|
+
:icon="TableIcon"
|
|
4
5
|
subtitle="Sortable columns, pagination, and column filters. Right-click or long-press a column header to filter."
|
|
5
6
|
:padding="false"
|
|
6
7
|
>
|
|
7
|
-
<template #toolbar>
|
|
8
|
-
<button>Add Item</button>
|
|
9
|
-
</template>
|
|
10
|
-
|
|
11
8
|
<Table
|
|
12
|
-
|
|
9
|
+
table-id="table-example"
|
|
10
|
+
row-key="name"
|
|
11
|
+
:data="tableData"
|
|
13
12
|
:headers="headers"
|
|
14
|
-
:show-pagination="
|
|
13
|
+
:show-pagination="enabledFeatures.includes('pagination')"
|
|
14
|
+
:filterable="enabledFeatures.includes('filters')"
|
|
15
|
+
:column-options="enabledFeatures.includes('columnOptions')"
|
|
16
|
+
:loading="enabledFeatures.includes('loading')"
|
|
17
|
+
:horizontal-scroll="layout === 'scroll'"
|
|
18
|
+
:sticky-cols="stickyCols"
|
|
19
|
+
v-model:column-visibility="columnVisibility"
|
|
20
|
+
:default-column-visibility="defaultColumnVisibility"
|
|
21
|
+
v-bind="tableListeners"
|
|
15
22
|
>
|
|
16
23
|
<template #cell-name="{ row, value }">
|
|
17
24
|
<router-link :to="{ name: 'ViewItem', params: { id: row.name } }">
|
|
@@ -24,23 +31,334 @@
|
|
|
24
31
|
{{ value }}
|
|
25
32
|
</span>
|
|
26
33
|
</template>
|
|
34
|
+
|
|
35
|
+
<template #cell-status="{ value }">
|
|
36
|
+
<span class="tag fg-good">{{ value }}</span>
|
|
37
|
+
</template>
|
|
27
38
|
</Table>
|
|
28
39
|
</Section>
|
|
40
|
+
|
|
41
|
+
<Section
|
|
42
|
+
title="Explore table options"
|
|
43
|
+
:icon="Settings01Icon"
|
|
44
|
+
subtitle="Adjust layout and behaviour for the table above."
|
|
45
|
+
>
|
|
46
|
+
<template #toolbar>
|
|
47
|
+
<button type="button" @click="resetControls">Reset options</button>
|
|
48
|
+
</template>
|
|
49
|
+
|
|
50
|
+
<FormLayout>
|
|
51
|
+
<FormField label="Data" fake description="Connect or disconnect row data for the table above. Saved column layouts persist in localStorage for this table.">
|
|
52
|
+
<div class="table-data-actions">
|
|
53
|
+
<button type="button" :disabled="dataConnected" @click="dataConnected = true">
|
|
54
|
+
Connect data
|
|
55
|
+
</button>
|
|
56
|
+
<button type="button" class="neutral" :disabled="!dataConnected" @click="dataConnected = false">
|
|
57
|
+
Disconnect data
|
|
58
|
+
</button>
|
|
59
|
+
</div>
|
|
60
|
+
</FormField>
|
|
61
|
+
|
|
62
|
+
<FormField label="Features" fake description="Toggle core table behaviour.">
|
|
63
|
+
<CheckGroup
|
|
64
|
+
v-model="enabledFeatures"
|
|
65
|
+
name="table-features"
|
|
66
|
+
:options="featureOptions"
|
|
67
|
+
/>
|
|
68
|
+
</FormField>
|
|
69
|
+
|
|
70
|
+
<FormField
|
|
71
|
+
label="Responsive layout"
|
|
72
|
+
fake
|
|
73
|
+
description="Column priorities hide lower-priority columns as the table narrows. Horizontal scroll keeps every column visible with optional sticky leading columns."
|
|
74
|
+
>
|
|
75
|
+
<RadioGroup
|
|
76
|
+
v-model="layout"
|
|
77
|
+
name="table-layout"
|
|
78
|
+
:options="layoutOptions"
|
|
79
|
+
/>
|
|
80
|
+
</FormField>
|
|
81
|
+
|
|
82
|
+
<FormField
|
|
83
|
+
v-if="layout === 'scroll'"
|
|
84
|
+
label="Sticky columns"
|
|
85
|
+
fake
|
|
86
|
+
description="Pin the first columns while scrolling horizontally."
|
|
87
|
+
>
|
|
88
|
+
<RadioGroup
|
|
89
|
+
v-model="stickyColsOption"
|
|
90
|
+
name="table-sticky-cols"
|
|
91
|
+
:options="stickyColOptions"
|
|
92
|
+
/>
|
|
93
|
+
</FormField>
|
|
94
|
+
|
|
95
|
+
<FormField
|
|
96
|
+
v-if="layout === 'scroll'"
|
|
97
|
+
label="Wide table"
|
|
98
|
+
fake
|
|
99
|
+
description="Adds extra columns so horizontal scroll is easier to see."
|
|
100
|
+
>
|
|
101
|
+
<label>
|
|
102
|
+
<input v-model="wideTable" type="checkbox" />
|
|
103
|
+
Show extended columns
|
|
104
|
+
</label>
|
|
105
|
+
</FormField>
|
|
106
|
+
</FormLayout>
|
|
107
|
+
|
|
108
|
+
<div class="table-events">
|
|
109
|
+
<h4>Events</h4>
|
|
110
|
+
<p v-if="lastRowClick" class="event-line">
|
|
111
|
+
<strong>Row click:</strong>
|
|
112
|
+
index {{ lastRowClick.index }}, name “{{ lastRowClick.row.name }}”
|
|
113
|
+
</p>
|
|
114
|
+
<p v-else class="event-line subtle">Row click: enable “Row click” above, then click a row to open the detail dialog.</p>
|
|
115
|
+
|
|
116
|
+
<p v-if="lastQuery" class="event-line">
|
|
117
|
+
<strong>Query:</strong>
|
|
118
|
+
page {{ lastQuery.page }},
|
|
119
|
+
sort {{ lastQuery.sortBy ?? 'none' }} {{ lastQuery.sortDir }},
|
|
120
|
+
{{ activeFilterCount }} active filter(s)
|
|
121
|
+
</p>
|
|
122
|
+
<p v-else class="event-line subtle">Query: waiting for the first table update…</p>
|
|
123
|
+
</div>
|
|
124
|
+
</Section>
|
|
125
|
+
|
|
126
|
+
<dialog
|
|
127
|
+
ref="rowDialogRef"
|
|
128
|
+
class="row-detail-dialog"
|
|
129
|
+
@click="onRowDialogBackdropClick"
|
|
130
|
+
>
|
|
131
|
+
<div v-if="lastRowClick" class="dialog-panel" @click.stop>
|
|
132
|
+
<h3>{{ lastRowClick.row.name }}</h3>
|
|
133
|
+
<p class="subtle">Row index {{ lastRowClick.index }} in the current filtered and sorted result set.</p>
|
|
134
|
+
|
|
135
|
+
<dl>
|
|
136
|
+
<dt>Index</dt>
|
|
137
|
+
<dd>{{ lastRowClick.index }}</dd>
|
|
138
|
+
|
|
139
|
+
<template v-for="header in headers" :key="header.key">
|
|
140
|
+
<dt>{{ header.label || header.key }}</dt>
|
|
141
|
+
<dd>
|
|
142
|
+
<span v-if="header.key === 'status'" class="tag fg-good">
|
|
143
|
+
{{ lastRowClick.row[header.key] }}
|
|
144
|
+
</span>
|
|
145
|
+
<template v-else>
|
|
146
|
+
{{ lastRowClick.row[header.key] }}
|
|
147
|
+
</template>
|
|
148
|
+
</dd>
|
|
149
|
+
</template>
|
|
150
|
+
</dl>
|
|
151
|
+
|
|
152
|
+
<form method="dialog">
|
|
153
|
+
<button type="submit" class="neutral">Close</button>
|
|
154
|
+
</form>
|
|
155
|
+
</div>
|
|
156
|
+
</dialog>
|
|
29
157
|
</template>
|
|
30
158
|
|
|
31
159
|
<script setup>
|
|
32
|
-
import { ref, onMounted } from 'vue'
|
|
160
|
+
import { ref, computed, onMounted } from 'vue'
|
|
161
|
+
import { TableIcon, Settings01Icon } from '@hugeicons/core-free-icons'
|
|
33
162
|
import { getExampleTableRows } from '../data/examplePeople.js'
|
|
163
|
+
import { countActiveFilters } from '../composables/tableFilters.js'
|
|
164
|
+
|
|
165
|
+
const defaultFeatures = ['pagination', 'filters', 'columnOptions', 'rowClick']
|
|
34
166
|
|
|
35
|
-
const
|
|
36
|
-
|
|
167
|
+
const defaultColumnVisibility = {
|
|
168
|
+
birthYear: false,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const enabledFeatures = ref([...defaultFeatures])
|
|
172
|
+
const columnVisibility = ref({ ...defaultColumnVisibility })
|
|
173
|
+
const layout = ref('default')
|
|
174
|
+
const stickyColsOption = ref('2')
|
|
175
|
+
const wideTable = ref(true)
|
|
176
|
+
const dataConnected = ref(true)
|
|
177
|
+
const data = ref([])
|
|
178
|
+
const lastRowClick = ref(null)
|
|
179
|
+
const lastQuery = ref(null)
|
|
180
|
+
const rowDialogRef = ref(null)
|
|
181
|
+
|
|
182
|
+
const featureOptions = [
|
|
183
|
+
{ value: 'pagination', label: 'Pagination' },
|
|
184
|
+
{ value: 'filters', label: 'Column filters' },
|
|
185
|
+
{ value: 'columnOptions', label: 'Column options' },
|
|
186
|
+
{ value: 'rowClick', label: 'Row click' },
|
|
187
|
+
{ value: 'loading', label: 'Loading state' },
|
|
188
|
+
]
|
|
189
|
+
|
|
190
|
+
const layoutOptions = [
|
|
191
|
+
{ value: 'default', label: 'Default' },
|
|
192
|
+
{ value: 'priorities', label: 'Column priorities' },
|
|
193
|
+
{ value: 'scroll', label: 'Horizontal scroll' },
|
|
194
|
+
]
|
|
195
|
+
|
|
196
|
+
const stickyColOptions = [
|
|
197
|
+
{ value: 'none', label: 'None' },
|
|
198
|
+
{ value: '1', label: '1 column' },
|
|
199
|
+
{ value: '2', label: '2 columns' },
|
|
200
|
+
{ value: '3', label: '3 columns' },
|
|
201
|
+
]
|
|
202
|
+
|
|
203
|
+
const baseHeaders = [
|
|
204
|
+
{ key: 'name', label: 'Name', sortable: true, filterable: true, filterType: 'text', width: '20%' },
|
|
37
205
|
{ key: 'age', label: 'Age', sortable: true, filterable: true, filterType: 'number' },
|
|
206
|
+
{ key: 'birthYear', label: 'Birth year', sortable: true, filterable: true, filterType: 'number' },
|
|
38
207
|
{ key: 'city', label: 'City', sortable: true, filterable: true, filterType: 'select', width: '200px' },
|
|
39
|
-
]
|
|
208
|
+
]
|
|
40
209
|
|
|
41
|
-
const
|
|
210
|
+
const extendedHeaders = [
|
|
211
|
+
{ key: 'email', label: 'Email', sortable: true, filterable: true, filterType: 'text' },
|
|
212
|
+
{ key: 'department', label: 'Department', sortable: true, filterable: true, filterType: 'select' },
|
|
213
|
+
{ key: 'role', label: 'Role', sortable: true, filterable: true, filterType: 'text' },
|
|
214
|
+
{ key: 'status', label: 'Status', sortable: true, filterable: true, filterType: 'select' },
|
|
215
|
+
]
|
|
216
|
+
|
|
217
|
+
const priorityByKey = {
|
|
218
|
+
age: 2,
|
|
219
|
+
birthYear: 3,
|
|
220
|
+
city: 1,
|
|
221
|
+
department: 2,
|
|
222
|
+
role: 1,
|
|
223
|
+
status: 1,
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const stickyCols = computed(() => {
|
|
227
|
+
if (layout.value !== 'scroll' || stickyColsOption.value === 'none') {
|
|
228
|
+
return null
|
|
229
|
+
}
|
|
230
|
+
return Number(stickyColsOption.value)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
const useExtendedColumns = computed(() => layout.value === 'scroll' && wideTable.value)
|
|
234
|
+
|
|
235
|
+
const headers = computed(() => {
|
|
236
|
+
const columns = useExtendedColumns.value
|
|
237
|
+
? [...baseHeaders, ...extendedHeaders]
|
|
238
|
+
: [...baseHeaders]
|
|
239
|
+
|
|
240
|
+
if (layout.value !== 'priorities') {
|
|
241
|
+
return columns
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return columns.map((header) => {
|
|
245
|
+
const colPriority = priorityByKey[header.key]
|
|
246
|
+
if (!colPriority) {
|
|
247
|
+
return { ...header }
|
|
248
|
+
}
|
|
249
|
+
return { ...header, colPriority }
|
|
250
|
+
})
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
const tableData = computed(() => {
|
|
254
|
+
if (!dataConnected.value) {
|
|
255
|
+
return []
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const birthYear = new Date().getFullYear()
|
|
259
|
+
|
|
260
|
+
return data.value.map((row) => {
|
|
261
|
+
const withBirthYear = {
|
|
262
|
+
...row,
|
|
263
|
+
birthYear: birthYear - row.age,
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (!useExtendedColumns.value) {
|
|
267
|
+
return withBirthYear
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
...withBirthYear,
|
|
272
|
+
email: `${String(row.name).toLowerCase()}@example.com`,
|
|
273
|
+
department: row.age >= 30 ? 'Operations' : 'Engineering',
|
|
274
|
+
role: row.age >= 35 ? 'Manager' : 'Member',
|
|
275
|
+
status: row.age % 2 === 0 ? 'Active' : 'Pending',
|
|
276
|
+
}
|
|
277
|
+
})
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
const activeFilterCount = computed(() => {
|
|
281
|
+
if (!lastQuery.value?.filters) {
|
|
282
|
+
return 0
|
|
283
|
+
}
|
|
284
|
+
return countActiveFilters(lastQuery.value.filters, headers.value, tableData.value)
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
const tableListeners = computed(() => {
|
|
288
|
+
const listeners = {
|
|
289
|
+
onQueryChange: handleQueryChange,
|
|
290
|
+
}
|
|
291
|
+
if (enabledFeatures.value.includes('rowClick')) {
|
|
292
|
+
listeners.onRowClick = handleRowClick
|
|
293
|
+
}
|
|
294
|
+
return listeners
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
function handleRowClick(payload) {
|
|
298
|
+
lastRowClick.value = payload
|
|
299
|
+
rowDialogRef.value?.showModal()
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function onRowDialogBackdropClick(event) {
|
|
303
|
+
if (event.target === rowDialogRef.value) {
|
|
304
|
+
rowDialogRef.value.close()
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function handleQueryChange(query) {
|
|
309
|
+
lastQuery.value = query
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function resetControls() {
|
|
313
|
+
enabledFeatures.value = [...defaultFeatures]
|
|
314
|
+
columnVisibility.value = { ...defaultColumnVisibility }
|
|
315
|
+
layout.value = 'default'
|
|
316
|
+
stickyColsOption.value = '2'
|
|
317
|
+
wideTable.value = true
|
|
318
|
+
dataConnected.value = true
|
|
319
|
+
lastRowClick.value = null
|
|
320
|
+
if (rowDialogRef.value?.open) {
|
|
321
|
+
rowDialogRef.value.close()
|
|
322
|
+
}
|
|
323
|
+
}
|
|
42
324
|
|
|
43
325
|
onMounted(() => {
|
|
44
326
|
data.value = getExampleTableRows()
|
|
45
327
|
})
|
|
46
328
|
</script>
|
|
329
|
+
|
|
330
|
+
<style scoped>
|
|
331
|
+
.table-events {
|
|
332
|
+
margin-top: 1.5rem;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
.table-data-actions {
|
|
336
|
+
display: flex;
|
|
337
|
+
flex-wrap: wrap;
|
|
338
|
+
gap: 0.5rem;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
.table-events h4 {
|
|
342
|
+
margin-top: 0;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
.event-line {
|
|
346
|
+
margin: 0.35rem 0;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
.row-detail-dialog .dialog-panel h3 {
|
|
350
|
+
margin: 0 0 0.5rem;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
.row-detail-dialog .dialog-panel p {
|
|
354
|
+
margin: 0 0 1rem;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
.row-detail-dialog dl {
|
|
358
|
+
margin: 0 0 1rem;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
.row-detail-dialog form {
|
|
362
|
+
margin: 0;
|
|
363
|
+
}
|
|
364
|
+
</style>
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<Section
|
|
3
3
|
title="Remote table"
|
|
4
|
-
|
|
4
|
+
:icon="CloudServerIcon"
|
|
5
|
+
subtitle="Server-style pagination with fetchRows. Filter queries, sort, and page changes are sent to the callback. Right-click or long-press a column header to filter. Layout save/load uses server callbacks instead of localStorage."
|
|
5
6
|
:padding="false"
|
|
6
7
|
>
|
|
7
8
|
<p v-if="lastQuerySummary" class="subtle query-summary">{{ lastQuerySummary }}</p>
|
|
8
9
|
|
|
9
10
|
<Table
|
|
11
|
+
row-key="name"
|
|
10
12
|
:headers="headers"
|
|
11
13
|
:fetch-rows="fetchRows"
|
|
14
|
+
:layout-presets="layoutPresets"
|
|
12
15
|
:show-pagination="true"
|
|
13
16
|
:remote-debounce-ms="250"
|
|
14
17
|
/>
|
|
@@ -17,8 +20,13 @@
|
|
|
17
20
|
|
|
18
21
|
<script setup>
|
|
19
22
|
import { ref } from 'vue'
|
|
23
|
+
import { CloudServerIcon } from '@hugeicons/core-free-icons'
|
|
20
24
|
import { getExampleTableRows } from '../data/examplePeople.js'
|
|
21
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
applyFilterQuery,
|
|
27
|
+
countActiveFilterQuery,
|
|
28
|
+
} from '../composables/tableFilterQuery.js'
|
|
29
|
+
import { sortRows } from '../composables/tableFilters.js'
|
|
22
30
|
|
|
23
31
|
const allRows = getExampleTableRows()
|
|
24
32
|
|
|
@@ -41,7 +49,107 @@ const headers = ref([
|
|
|
41
49
|
|
|
42
50
|
const lastQuerySummary = ref('')
|
|
43
51
|
|
|
52
|
+
const remoteLayoutStore = ref({
|
|
53
|
+
defaultPresetId: null,
|
|
54
|
+
presets: [],
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
let remotePresetCounter = 0
|
|
58
|
+
|
|
59
|
+
function createRemotePresetId() {
|
|
60
|
+
remotePresetCounter += 1
|
|
61
|
+
return `remote-preset-${remotePresetCounter}`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const layoutPresets = {
|
|
65
|
+
async list() {
|
|
66
|
+
return {
|
|
67
|
+
presets: remoteLayoutStore.value.presets.map((preset) => ({ ...preset })),
|
|
68
|
+
defaultPresetId: remoteLayoutStore.value.defaultPresetId,
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
async getDefault() {
|
|
73
|
+
const { defaultPresetId, presets } = remoteLayoutStore.value
|
|
74
|
+
if (!defaultPresetId) {
|
|
75
|
+
return { ok: true, preset: null }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const preset = presets.find((item) => item.id === defaultPresetId) ?? null
|
|
79
|
+
return { ok: true, preset: preset ? { ...preset } : null }
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
async save({ name, state, setAsDefault = false }) {
|
|
83
|
+
const trimmedName = String(name ?? '').trim()
|
|
84
|
+
if (!trimmedName) {
|
|
85
|
+
return { ok: false, error: 'Preset name is required.' }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const preset = {
|
|
89
|
+
id: createRemotePresetId(),
|
|
90
|
+
name: trimmedName,
|
|
91
|
+
savedAt: new Date().toISOString(),
|
|
92
|
+
state: JSON.parse(JSON.stringify(state)),
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
remoteLayoutStore.value.presets = [...remoteLayoutStore.value.presets, preset]
|
|
96
|
+
if (setAsDefault) {
|
|
97
|
+
remoteLayoutStore.value.defaultPresetId = preset.id
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
console.info('[remote table] saved layout preset', preset)
|
|
101
|
+
return {
|
|
102
|
+
ok: true,
|
|
103
|
+
preset,
|
|
104
|
+
defaultPresetId: remoteLayoutStore.value.defaultPresetId,
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
async load(presetId) {
|
|
109
|
+
const preset = remoteLayoutStore.value.presets.find((item) => item.id === presetId)
|
|
110
|
+
if (!preset) {
|
|
111
|
+
return { ok: false, error: 'Preset not found.' }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
console.info('[remote table] loaded layout preset', preset)
|
|
115
|
+
return { ok: true, preset: { ...preset } }
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
async delete(presetId) {
|
|
119
|
+
const nextPresets = remoteLayoutStore.value.presets.filter((item) => item.id !== presetId)
|
|
120
|
+
if (nextPresets.length === remoteLayoutStore.value.presets.length) {
|
|
121
|
+
return { ok: false, error: 'Preset not found.' }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
remoteLayoutStore.value.presets = nextPresets
|
|
125
|
+
if (remoteLayoutStore.value.defaultPresetId === presetId) {
|
|
126
|
+
remoteLayoutStore.value.defaultPresetId = null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
console.info('[remote table] deleted layout preset', presetId)
|
|
130
|
+
return { ok: true, defaultPresetId: remoteLayoutStore.value.defaultPresetId }
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
async setDefault(presetId) {
|
|
134
|
+
if (presetId === null) {
|
|
135
|
+
remoteLayoutStore.value.defaultPresetId = null
|
|
136
|
+
return { ok: true, defaultPresetId: null }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const preset = remoteLayoutStore.value.presets.find((item) => item.id === presetId)
|
|
140
|
+
if (!preset) {
|
|
141
|
+
return { ok: false, error: 'Preset not found.' }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
remoteLayoutStore.value.defaultPresetId = presetId
|
|
145
|
+
console.info('[remote table] set default layout preset', presetId)
|
|
146
|
+
return { ok: true, defaultPresetId: presetId }
|
|
147
|
+
},
|
|
148
|
+
}
|
|
149
|
+
|
|
44
150
|
async function fetchRows(query, { signal } = {}) {
|
|
151
|
+
console.log('[remote table] filterQuery', query.filterQuery)
|
|
152
|
+
|
|
45
153
|
await new Promise((resolve, reject) => {
|
|
46
154
|
const timer = window.setTimeout(resolve, 200)
|
|
47
155
|
if (!signal) {
|
|
@@ -58,12 +166,12 @@ async function fetchRows(query, { signal } = {}) {
|
|
|
58
166
|
signal.addEventListener('abort', onAbort, { once: true })
|
|
59
167
|
})
|
|
60
168
|
|
|
61
|
-
const filtered =
|
|
169
|
+
const filtered = applyFilterQuery(allRows, headers.value, query.filterQuery)
|
|
62
170
|
const sorted = sortRows(filtered, query.sortBy, query.sortDir)
|
|
63
171
|
const start = (query.page - 1) * query.pageSize
|
|
64
172
|
const rows = sorted.slice(start, start + query.pageSize)
|
|
65
173
|
|
|
66
|
-
lastQuerySummary.value = `Last query: page ${query.page}, ${
|
|
174
|
+
lastQuerySummary.value = `Last query: page ${query.page}, ${countActiveFilterQuery(query.filterQuery, headers.value, allRows)} active filter(s), ${sorted.length} matching row(s).`
|
|
67
175
|
|
|
68
176
|
return {
|
|
69
177
|
rows,
|
|
@@ -4,32 +4,27 @@
|
|
|
4
4
|
subtitle="Drop-in themes layered on Femtocrank — not part of the Vite CSS/JS bundle"
|
|
5
5
|
>
|
|
6
6
|
<p>
|
|
7
|
-
Themes live under <code>public/themes/<name>/theme.css</code
|
|
7
|
+
Themes live under <code>public/themes/<name>/theme.css</code> (app themes) and
|
|
8
|
+
optionally <code>supplemental-themes/</code> (Catppuccin, Dracula / Alucard, Gruvbox, waffles). Drop a folder
|
|
8
9
|
in and restart the dev server (or rebuild); PicoCrank discovers themes from
|
|
9
|
-
<code>index.json</code> and applies them with <code>useCustomTheme()</code
|
|
10
|
+
<code>index.json</code> and applies them with <code>useCustomTheme()</code> or
|
|
11
|
+
<code>ThemeSwitcher</code>. Supplemental themes are bundled by default but only
|
|
12
|
+
listed when <code>include-supplemental-themes</code> is enabled.
|
|
10
13
|
</p>
|
|
11
14
|
|
|
12
|
-
<
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
v-for="name in availableThemes"
|
|
18
|
-
:key="name"
|
|
19
|
-
:value="name"
|
|
20
|
-
>
|
|
21
|
-
{{ name }}
|
|
22
|
-
</option>
|
|
23
|
-
</select>
|
|
24
|
-
</label>
|
|
15
|
+
<ThemeSwitcher
|
|
16
|
+
class="theme-switcher-block"
|
|
17
|
+
:include-supplemental-themes="true"
|
|
18
|
+
@change="onThemeChange"
|
|
19
|
+
/>
|
|
25
20
|
|
|
26
21
|
<p v-if="availableThemes.length === 0" class="subtle">
|
|
27
22
|
No drop-in themes found. Add <code>public/themes/<name>/theme.css</code>
|
|
28
|
-
|
|
23
|
+
or enable supplemental themes on <code>ThemeSwitcher</code>.
|
|
29
24
|
</p>
|
|
30
25
|
<p v-else class="subtle">
|
|
31
26
|
Preference is stored in localStorage and applies across the whole examples site.
|
|
32
|
-
Light/dark mode (header sun/moon)
|
|
27
|
+
Light/dark mode (header sun/moon) switches Femtocrank defaults, or the dual-variant supplemental themes when active.
|
|
33
28
|
</p>
|
|
34
29
|
</Section>
|
|
35
30
|
|
|
@@ -118,32 +113,23 @@
|
|
|
118
113
|
|
|
119
114
|
<script setup>
|
|
120
115
|
import Section from '../components/Section.vue'
|
|
116
|
+
import ThemeSwitcher from '../components/ThemeSwitcher.vue'
|
|
121
117
|
import FormLayout from '../components/FormLayout.vue'
|
|
122
118
|
import FormField from '../components/FormField.vue'
|
|
123
119
|
import { useCustomTheme } from '../composables/useCustomTheme.js'
|
|
124
120
|
|
|
125
|
-
const { availableThemes
|
|
121
|
+
const { availableThemes } = useCustomTheme()
|
|
126
122
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
function onThemeChange(event) {
|
|
130
|
-
setTheme(event.target.value)
|
|
123
|
+
function onThemeChange() {
|
|
124
|
+
// ThemeSwitcher updates shared useCustomTheme state.
|
|
131
125
|
}
|
|
132
126
|
</script>
|
|
133
127
|
|
|
134
128
|
<style scoped>
|
|
135
|
-
.theme-
|
|
136
|
-
display: flex;
|
|
137
|
-
flex-wrap: wrap;
|
|
138
|
-
align-items: center;
|
|
139
|
-
gap: 0.75rem;
|
|
129
|
+
.theme-switcher-block {
|
|
140
130
|
margin: 1rem 0;
|
|
141
131
|
}
|
|
142
132
|
|
|
143
|
-
.theme-picker select {
|
|
144
|
-
min-width: 14rem;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
133
|
.preview-row {
|
|
148
134
|
display: flex;
|
|
149
135
|
flex-wrap: wrap;
|