picocrank 1.32.0 → 1.34.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.
Files changed (37) hide show
  1. package/docs/filter-query-v1.md +5 -1
  2. package/docs/table.md +99 -0
  3. package/package.json +1 -1
  4. package/router.js +33 -0
  5. package/styles/picocrank-extensions.css +23 -2
  6. package/vue/components/Accordion.spec.js +48 -0
  7. package/vue/components/Accordion.vue +46 -0
  8. package/vue/components/AccordionItem.vue +103 -0
  9. package/vue/components/FieldValue.spec.js +88 -0
  10. package/vue/components/FieldValue.vue +222 -0
  11. package/vue/components/FormField.spec.js +1 -0
  12. package/vue/components/FormField.vue +38 -10
  13. package/vue/components/Header.vue +24 -2
  14. package/vue/components/NotificationBlock.vue +25 -0
  15. package/vue/components/ReorderList.spec.js +318 -0
  16. package/vue/components/ReorderList.vue +614 -0
  17. package/vue/components/Table.spec.js +490 -1
  18. package/vue/components/Table.vue +1038 -111
  19. package/vue/components/TableColumnFilterPopover.vue +63 -19
  20. package/vue/components/TableColumnOptionsPopover.vue +248 -273
  21. package/vue/components/TopBar.vue +6 -2
  22. package/vue/components/accordionContext.js +1 -0
  23. package/vue/composables/tableColumnPresets.js +33 -0
  24. package/vue/composables/tableFilters.js +6 -1
  25. package/vue/composables/tableGroupBy.js +173 -0
  26. package/vue/composables/tableGroupBy.spec.js +69 -0
  27. package/vue/composables/tableViewSettings.js +34 -0
  28. package/vue/composables/tableViewSettings.spec.js +21 -0
  29. package/vue/composables/useHeaderTopBarFit.js +91 -0
  30. package/vue/composables/useResponsiveNav.js +5 -3
  31. package/vue/data/navSectionHubs.js +16 -1
  32. package/vue/examples/AccordionExample.vue +33 -0
  33. package/vue/examples/FieldValueExample.vue +143 -0
  34. package/vue/examples/FormExample.vue +11 -6
  35. package/vue/examples/ReorderListExample.vue +236 -0
  36. package/vue/examples/TableExample.vue +5 -1
  37. package/vue/examples/TableRemoteExample.vue +28 -4
@@ -0,0 +1,143 @@
1
+ <template>
2
+ <Section
3
+ title="Field value"
4
+ :icon="Edit03Icon"
5
+ subtitle="A displayed value with read-only, inline edit, or outline edit."
6
+ >
7
+ <p>
8
+ Pass an <code>#edit</code> template to edit in place. Without that template, set
9
+ <code>to</code> or <code>href</code> so the icon opens another page. Otherwise the
10
+ value is read-only. Use the <code>#title</code> slot on <code>Section</code> for an
11
+ editable heading.
12
+ </p>
13
+ </Section>
14
+
15
+ <Section subtitle="FieldValue in the Section title slot — click the heading to rename">
16
+ <template #title>
17
+ <FieldValue v-model="sectionTitle">
18
+ <template #edit="{ value, update, commit, cancel }">
19
+ <input
20
+ :value="value"
21
+ type="text"
22
+ class="field-value-title-input"
23
+ @input="update($event.target.value)"
24
+ @keyup.enter="commit"
25
+ @keyup.escape="cancel"
26
+ @blur="commit"
27
+ />
28
+ </template>
29
+ </FieldValue>
30
+ </template>
31
+ <p>
32
+ The value above is the section heading. It uses the same inline edit pattern as
33
+ definition-list fields below.
34
+ </p>
35
+ </Section>
36
+
37
+ <Section title="Read only" subtitle="No edit template and no outline target">
38
+ <dl class="field-value-dl">
39
+ <dt>Created</dt>
40
+ <dd>
41
+ <FieldValue model-value="2 hours ago" />
42
+ </dd>
43
+ </dl>
44
+ </Section>
45
+
46
+ <Section title="Inline edit" subtitle="An edit template replaces the value until commit or cancel">
47
+ <dl class="field-value-dl">
48
+ <dt>Name</dt>
49
+ <dd>
50
+ <FieldValue v-model="name" @commit="lastCommit = $event">
51
+ <template #edit="{ value, update, commit, cancel }">
52
+ <input
53
+ :value="value"
54
+ type="text"
55
+ @input="update($event.target.value)"
56
+ @keyup.enter="commit"
57
+ @keyup.escape="cancel"
58
+ @blur="commit"
59
+ />
60
+ </template>
61
+ </FieldValue>
62
+ </dd>
63
+
64
+ <dt>Active</dt>
65
+ <dd>
66
+ <FieldValue v-model="active">
67
+ <template #default>{{ active === '1' ? 'Yes' : 'No' }}</template>
68
+ <template #edit="{ value, update, commit }">
69
+ <input
70
+ type="checkbox"
71
+ :checked="value === '1'"
72
+ @change="event => {
73
+ update(event.target.checked ? '1' : '0')
74
+ commit()
75
+ }"
76
+ />
77
+ </template>
78
+ </FieldValue>
79
+ </dd>
80
+ </dl>
81
+ <p v-if="lastCommit" class="subtle">Last saved name: {{ lastCommit }}</p>
82
+ </Section>
83
+
84
+ <Section title="Outline edit" subtitle="The icon navigates; the value itself stays read-only">
85
+ <dl class="field-value-dl">
86
+ <dt>Website</dt>
87
+ <dd>
88
+ <FieldValue
89
+ model-value="example.com"
90
+ to="/form-example"
91
+ edit-label="Edit on the form page"
92
+ />
93
+ </dd>
94
+
95
+ <dt>Docs</dt>
96
+ <dd>
97
+ <FieldValue
98
+ model-value="femtocrank"
99
+ href="https://github.com/jamesread/femtocrank"
100
+ edit-label="Open documentation"
101
+ />
102
+ </dd>
103
+ </dl>
104
+ </Section>
105
+ </template>
106
+
107
+ <script setup>
108
+ import { ref } from 'vue'
109
+ import { Edit03Icon } from '@hugeicons/core-free-icons'
110
+ import Section from '../components/Section.vue'
111
+ import FieldValue from '../components/FieldValue.vue'
112
+
113
+ const sectionTitle = ref('Project settings')
114
+ const name = ref('Ada Lovelace')
115
+ const active = ref('1')
116
+ const lastCommit = ref('')
117
+ </script>
118
+
119
+ <style scoped>
120
+ .field-value-dl {
121
+ grid-template-columns: 12rem minmax(0, 1fr);
122
+ }
123
+
124
+ .field-value-dl dt {
125
+ grid-column: 1;
126
+ box-sizing: border-box;
127
+ min-width: 0;
128
+ }
129
+
130
+ .field-value-dl dd {
131
+ grid-column: 2;
132
+ box-sizing: border-box;
133
+ min-width: 0;
134
+ }
135
+
136
+ .field-value-title-input {
137
+ font: inherit;
138
+ font-weight: inherit;
139
+ color: inherit;
140
+ min-width: 12rem;
141
+ max-width: 100%;
142
+ }
143
+ </style>
@@ -101,12 +101,17 @@
101
101
  />
102
102
  </FormField>
103
103
 
104
- <FormField label="Is Administrator?">
105
- <input
106
- type="checkbox"
107
- id="is-admin"
108
- v-model="formData.isAdmin"
109
- />
104
+ <FormField label="Is Administrator?" component-has-label>
105
+ <div>
106
+ <label>
107
+ <input
108
+ id="is-admin"
109
+ v-model="formData.isAdmin"
110
+ type="checkbox"
111
+ />
112
+ <span>Administrator</span>
113
+ </label>
114
+ </div>
110
115
  </FormField>
111
116
 
112
117
  <FormField
@@ -0,0 +1,236 @@
1
+ <template>
2
+ <Section
3
+ title="Reorder list"
4
+ :icon="DragDropVerticalIcon"
5
+ subtitle="Drag the handle, or use the arrows, to change the order of any components shown as rows."
6
+ >
7
+ <p>
8
+ <code>ReorderList</code> is a vertical list, separate from <code>Table</code>.
9
+ Each row’s body is the <code>#item</code> slot, so the reordered content can be
10
+ any component. The list emits <code>update:modelValue</code> with a new array.
11
+ While dragging, a line marks the gap where the row will be inserted (native HTML5 drag-and-drop).
12
+ <code>showMoveButtons</code>, <code>showDragHandles</code>, and <code>dragWholeRow</code>
13
+ control how rows are reordered.
14
+ </p>
15
+ </Section>
16
+
17
+ <Section title="Checklist" subtitle="Status cards in a row list">
18
+ <template #toolbar>
19
+ <label class="reorder-example-toggle">
20
+ <input v-model="showMoveButtons" type="checkbox" />
21
+ Move buttons
22
+ </label>
23
+ <label class="reorder-example-toggle">
24
+ <input v-model="showDragHandles" type="checkbox" />
25
+ Drag handles
26
+ </label>
27
+ <label class="reorder-example-toggle">
28
+ <input v-model="dragWholeRow" type="checkbox" />
29
+ Whole row drag
30
+ </label>
31
+ <button type="button" class="neutral" @click="resetTasks">
32
+ Reset order
33
+ </button>
34
+ </template>
35
+
36
+ <ReorderList
37
+ v-model="tasks"
38
+ item-key="id"
39
+ aria-label="Checklist order"
40
+ :show-move-buttons="showMoveButtons"
41
+ :show-drag-handles="showDragHandles"
42
+ :drag-whole-row="dragWholeRow"
43
+ >
44
+ <template #item="{ item }">
45
+ <div class="task-row">
46
+ <div class="task-copy">
47
+ <strong>{{ item.title }}</strong>
48
+ <p class="subtle">{{ item.detail }}</p>
49
+ </div>
50
+ <span class="tag">{{ item.tag }}</span>
51
+ </div>
52
+ </template>
53
+ </ReorderList>
54
+ </Section>
55
+
56
+ <Section title="Current order" subtitle="Bound array after each move">
57
+ <ol class="order-readout">
58
+ <li v-for="task in tasks" :key="task.id">{{ task.title }}</li>
59
+ </ol>
60
+ </Section>
61
+
62
+ <Section
63
+ title="Nested lists"
64
+ subtitle="Reorder phases, then reorder steps inside each phase — each list is its own ReorderList."
65
+ >
66
+ <template #toolbar>
67
+ <button type="button" class="neutral" @click="resetPhases">
68
+ Reset nested demo
69
+ </button>
70
+ </template>
71
+
72
+ <ReorderList
73
+ v-model="phases"
74
+ item-key="id"
75
+ aria-label="Release phases"
76
+ :show-drag-handles="true"
77
+ >
78
+ <template #item="{ item }">
79
+ <div class="nested-phase">
80
+ <p class="nested-phase-title">{{ item.title }}</p>
81
+ <ReorderList
82
+ v-model="item.steps"
83
+ item-key="id"
84
+ class="nested-phase-steps"
85
+ :aria-label="`${item.title} steps`"
86
+ drag-whole-row
87
+ >
88
+ <template #item="{ item: step }">
89
+ <span class="nested-step-label">{{ step.label }}</span>
90
+ </template>
91
+ </ReorderList>
92
+ </div>
93
+ </template>
94
+ </ReorderList>
95
+ </Section>
96
+
97
+ <Section title="Nested order" subtitle="Phases and their step arrays after moves">
98
+ <ul class="nested-readout">
99
+ <li v-for="phase in phases" :key="phase.id">
100
+ <strong>{{ phase.title }}</strong>
101
+ <ol>
102
+ <li v-for="step in phase.steps" :key="step.id">{{ step.label }}</li>
103
+ </ol>
104
+ </li>
105
+ </ul>
106
+ </Section>
107
+ </template>
108
+
109
+ <script setup>
110
+ import { ref } from 'vue'
111
+ import { DragDropVerticalIcon } from '@hugeicons/core-free-icons'
112
+ import Section from '../components/Section.vue'
113
+ import ReorderList from '../components/ReorderList.vue'
114
+
115
+ const initialTasks = [
116
+ { id: 'deploy', title: 'Deploy service', detail: 'Production rollout window', tag: 'Ops' },
117
+ { id: 'review', title: 'Review access', detail: 'Quarterly IAM check', tag: 'Security' },
118
+ { id: 'backup', title: 'Verify backups', detail: 'Nightly snapshot', tag: 'Ops' },
119
+ { id: 'notes', title: 'Publish notes', detail: 'Changelog for 2.4', tag: 'Docs' },
120
+ ]
121
+
122
+ const tasks = ref(initialTasks.map((task) => ({ ...task })))
123
+ const showMoveButtons = ref(false)
124
+ const showDragHandles = ref(true)
125
+ const dragWholeRow = ref(false)
126
+
127
+ function resetTasks() {
128
+ tasks.value = initialTasks.map((task) => ({ ...task }))
129
+ }
130
+
131
+ const initialPhases = [
132
+ {
133
+ id: 'build',
134
+ title: 'Build',
135
+ steps: [
136
+ { id: 'build-lint', label: 'Run linters' },
137
+ { id: 'build-test', label: 'Unit tests' },
138
+ { id: 'build-image', label: 'Container image' },
139
+ ],
140
+ },
141
+ {
142
+ id: 'staging',
143
+ title: 'Staging',
144
+ steps: [
145
+ { id: 'staging-deploy', label: 'Deploy to staging' },
146
+ { id: 'staging-smoke', label: 'Smoke tests' },
147
+ ],
148
+ },
149
+ {
150
+ id: 'production',
151
+ title: 'Production',
152
+ steps: [
153
+ { id: 'prod-approve', label: 'Change approval' },
154
+ { id: 'prod-deploy', label: 'Rolling deploy' },
155
+ { id: 'prod-verify', label: 'Post-deploy checks' },
156
+ ],
157
+ },
158
+ ]
159
+
160
+ const phases = ref(clonePhases(initialPhases))
161
+
162
+ function clonePhases(source) {
163
+ return source.map((phase) => ({
164
+ ...phase,
165
+ steps: phase.steps.map((step) => ({ ...step })),
166
+ }))
167
+ }
168
+
169
+ function resetPhases() {
170
+ phases.value = clonePhases(initialPhases)
171
+ }
172
+ </script>
173
+
174
+ <style scoped>
175
+ .task-row {
176
+ display: flex;
177
+ align-items: center;
178
+ gap: 0.75rem;
179
+ min-width: 0;
180
+ }
181
+
182
+ .task-copy {
183
+ flex: 1 1 auto;
184
+ min-width: 0;
185
+ }
186
+
187
+ .task-copy p {
188
+ margin: 0.15rem 0 0;
189
+ }
190
+
191
+ .order-readout {
192
+ margin: 0;
193
+ padding-left: 1.25rem;
194
+ }
195
+
196
+ .reorder-example-toggle {
197
+ display: inline-flex;
198
+ align-items: center;
199
+ gap: 0.4em;
200
+ margin-right: 0.75em;
201
+ cursor: pointer;
202
+ user-select: none;
203
+ }
204
+
205
+ .nested-phase {
206
+ flex: 1 1 auto;
207
+ min-width: 0;
208
+ }
209
+
210
+ .nested-phase-title {
211
+ margin: 0 0 0.5rem;
212
+ font-weight: bold;
213
+ }
214
+
215
+ .nested-phase-steps {
216
+ margin-top: 0.25rem;
217
+ }
218
+
219
+ .nested-phase-steps :deep(.reorder-list-row) {
220
+ padding: 0.4rem 0.55rem;
221
+ }
222
+
223
+ .nested-step-label {
224
+ display: block;
225
+ }
226
+
227
+ .nested-readout {
228
+ margin: 0;
229
+ padding-left: 1.25rem;
230
+ }
231
+
232
+ .nested-readout ol {
233
+ margin: 0.35rem 0 0.75rem;
234
+ padding-left: 1.25rem;
235
+ }
236
+ </style>
@@ -2,7 +2,7 @@
2
2
  <Section
3
3
  :title="tableSectionTitle"
4
4
  :icon="TableIcon"
5
- subtitle="Sortable columns, pagination, and column filters. Right-click or long-press a column header to filter. Enable “Row context menu” to right-click rows."
5
+ subtitle="Sortable columns, pagination, column filters, and group-by. Right-click or long-press a column header to filter or group. Use Column options for group-by as well."
6
6
  :padding="false"
7
7
  >
8
8
  <template v-if="multiSelectEnabled" #toolbar>
@@ -66,6 +66,7 @@
66
66
  :sticky-cols="stickyCols"
67
67
  v-model:column-visibility="columnVisibility"
68
68
  v-model:layout-label="tableLayoutLabel"
69
+ v-model:group-by="tableGroupBy"
69
70
  :default-column-visibility="defaultColumnVisibility"
70
71
  v-bind="tableListeners"
71
72
  >
@@ -163,6 +164,7 @@
163
164
  <strong>Query:</strong>
164
165
  page {{ lastQuery.page }},
165
166
  sort {{ lastQuery.sortBy ?? 'none' }} {{ lastQuery.sortDir }},
167
+ group {{ lastQuery.groupBy ?? 'none' }},
166
168
  {{ activeFilterCount }} active filter(s)
167
169
  </p>
168
170
  <p v-else class="event-line subtle">Query: waiting for the first table update…</p>
@@ -256,6 +258,7 @@ const lastBulkAction = ref(null)
256
258
  const lastContextMenuAction = ref(null)
257
259
  const selectedKeys = ref([])
258
260
  const tableLayoutLabel = ref('')
261
+ const tableGroupBy = ref(null)
259
262
  const rowDialogRef = ref(null)
260
263
 
261
264
  const multiSelectEnabled = computed(() => enabledFeatures.value.includes('multiSelect'))
@@ -471,6 +474,7 @@ function resetControls() {
471
474
  lastBulkAction.value = null
472
475
  lastContextMenuAction.value = null
473
476
  selectedKeys.value = []
477
+ tableGroupBy.value = null
474
478
  if (rowDialogRef.value?.open) {
475
479
  rowDialogRef.value.close()
476
480
  }
@@ -2,7 +2,7 @@
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
+ subtitle="Server-style pagination with fetchRows. Filter queries, sort, and page changes are sent to the callback. Saved views use callbackControlled view settings (not localStorage)."
6
6
  :padding="false"
7
7
  >
8
8
  <p v-if="lastQuerySummary" class="subtle query-summary">{{ lastQuerySummary }}</p>
@@ -11,7 +11,8 @@
11
11
  row-key="name"
12
12
  :headers="headers"
13
13
  :fetch-rows="fetchRows"
14
- :layout-presets="layoutPresets"
14
+ view-control-mode="callbackControlled"
15
+ :view-settings="viewSettings"
15
16
  :show-pagination="true"
16
17
  :remote-debounce-ms="250"
17
18
  />
@@ -61,7 +62,7 @@ function createRemotePresetId() {
61
62
  return `remote-preset-${remotePresetCounter}`
62
63
  }
63
64
 
64
- const layoutPresets = {
65
+ const viewSettings = {
65
66
  async list() {
66
67
  return {
67
68
  presets: remoteLayoutStore.value.presets.map((preset) => ({ ...preset })),
@@ -105,6 +106,29 @@ const layoutPresets = {
105
106
  }
106
107
  },
107
108
 
109
+ async overwrite({ presetId, state }) {
110
+ const index = remoteLayoutStore.value.presets.findIndex((item) => item.id === presetId)
111
+ if (index === -1) {
112
+ return { ok: false, error: 'Preset not found.' }
113
+ }
114
+
115
+ const preset = {
116
+ ...remoteLayoutStore.value.presets[index],
117
+ savedAt: new Date().toISOString(),
118
+ state: JSON.parse(JSON.stringify(state)),
119
+ }
120
+ remoteLayoutStore.value.presets = remoteLayoutStore.value.presets.map((item, i) => (
121
+ i === index ? preset : item
122
+ ))
123
+
124
+ console.info('[remote table] overwrote layout preset', preset)
125
+ return {
126
+ ok: true,
127
+ preset,
128
+ defaultPresetId: remoteLayoutStore.value.defaultPresetId,
129
+ }
130
+ },
131
+
108
132
  async load(presetId) {
109
133
  const preset = remoteLayoutStore.value.presets.find((item) => item.id === presetId)
110
134
  if (!preset) {
@@ -171,7 +195,7 @@ async function fetchRows(query, { signal } = {}) {
171
195
  const start = (query.page - 1) * query.pageSize
172
196
  const rows = sorted.slice(start, start + query.pageSize)
173
197
 
174
- lastQuerySummary.value = `Last query: page ${query.page}, ${countActiveFilterQuery(query.filterQuery, headers.value, allRows)} active filter(s), ${sorted.length} matching row(s).`
198
+ lastQuerySummary.value = `Last query: page ${query.page}, group ${query.groupBy ?? 'none'}, ${countActiveFilterQuery(query.filterQuery, headers.value, allRows)} active filter(s), ${sorted.length} matching row(s).`
175
199
 
176
200
  return {
177
201
  rows,