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
@@ -160,7 +160,11 @@ To mirror client semantics in Node without reimplementing operators, reuse `appl
160
160
 
161
161
  ## Layout presets vs filterQuery
162
162
 
163
- Saved layouts (localStorage or `layoutPresets` callbacks) store **`filters`** (UI storage shape) inside `state`, not `filterQuery`. When a preset is applied, the table rebuilds `filterQuery` automatically. Server-side layout persistence can store either:
163
+ Saved layouts store **`filters`** (UI storage shape) inside `state`, not `filterQuery`. When a preset is applied, the table rebuilds `filterQuery` automatically.
164
+
165
+ Table **`viewControlMode`**: `localStorageControlled` (default; needs `tableId`) or `callbackControlled` (needs `viewSettings` with `list` / `save` / `load` / etc.). Legacy `layoutPresets` is an alias for `viewSettings`.
166
+
167
+ Callback-backed persistence can store either:
164
168
 
165
169
  - **`state.filters`** — round-trips the table UI exactly, or
166
170
  - **`filterQuery`** — if you control load/save; convert with `filterQueryToFilters` before `setFilters` if needed.
package/docs/table.md ADDED
@@ -0,0 +1,99 @@
1
+ # Table
2
+
3
+ `Table` supports local data and remote `fetchRows` data. Existing usages remain
4
+ uncontrolled; pass any query-state prop to control that field independently.
5
+
6
+ ## Query state
7
+
8
+ ```vue
9
+ <Table
10
+ v-model:sort-by="sortBy"
11
+ v-model:sort-dir="sortDir"
12
+ v-model:page="page"
13
+ v-model:page-size="pageSize"
14
+ />
15
+ ```
16
+
17
+ `sortBy`, `sortDir`, `page`, and `pageSize` default to internal state when
18
+ omitted. Controlled values may be changed after mount. User actions emit the
19
+ corresponding `update:*` event. Sorting resets the page by default; set
20
+ `:reset-page-on-sort="false"` to preserve the current page. Page-size, data, and
21
+ filter changes continue to reset to page 1.
22
+
23
+ A sortable header can provide a value comparator:
24
+
25
+ ```js
26
+ {
27
+ key: 'version',
28
+ sortable: true,
29
+ comparator: (left, right, leftRow, rightRow) =>
30
+ left.localeCompare(right, undefined, { numeric: true }),
31
+ }
32
+ ```
33
+
34
+ The comparator defines ascending order and receives non-null cell values plus
35
+ their rows. `Table` reverses its result for descending order and keeps null
36
+ values last. `sortRows(rows, sortBy, sortDir, comparator)` accepts the same
37
+ optional comparator.
38
+
39
+ ## Cells and display hooks
40
+
41
+ Named `#cell-{key}` slots and the generic `#cell` slot receive:
42
+
43
+ ```ts
44
+ { row, value, header, rowIndex, sourceIndex }
45
+ ```
46
+
47
+ `rowIndex` is the index on the displayed page. For local data, `sourceIndex` is
48
+ the index in `data`; for remote data it is the index in the current response.
49
+
50
+ The `rowClass` and `rowStyle` callbacks receive
51
+ `{ row, rowIndex, sourceIndex }`. `cellClass`, `cellStyle`, and `cellAttrs`
52
+ receive that context plus `{ value, header }`. Return Vue-compatible class or
53
+ style values. `cellAttrs` is intended for attributes such as `title`, `aria-*`,
54
+ and `data-*`; use the dedicated class/style callbacks for those fields.
55
+ Header `class` continues to apply to both its header and body cells.
56
+
57
+ ## Selection and active cells
58
+
59
+ `selectionClickMode` controls selectable-row clicks:
60
+
61
+ - `row` (default): row clicks toggle selection; Ctrl/Cmd also toggles and Shift
62
+ selects a range on the displayed page.
63
+ - `checkbox`: only checkboxes change selection; row clicks emit `row-click`.
64
+ - `modifier`: Ctrl/Cmd toggles, Shift selects a displayed-page range, and an
65
+ unmodified click emits `row-click`.
66
+ - `extended`: unmodified clicks replace the selection, Ctrl/Cmd toggles, and
67
+ Shift adds a range on the displayed page. This matches desktop-style
68
+ selection while leaving the default behavior unchanged.
69
+
70
+ The additive `row-click` payload is `{ row, index, event }`, where `event` is
71
+ the native click event.
72
+
73
+ `activeCell` is an optional controlled `{ rowKey, columnKey }` value:
74
+
75
+ ```vue
76
+ <Table
77
+ v-model:active-cell="activeCell"
78
+ @cell-click="onCellClick"
79
+ />
80
+ ```
81
+
82
+ Clicking a data cell emits `cell-click` with the enriched cell context and adds
83
+ the visibly outlined `table-active-cell` class. The component ref exposes:
84
+
85
+ - `clearFilters()`
86
+ - `focusActiveCell(options?)` (returns whether a displayed active cell was focused)
87
+ - `clearActiveCell()`
88
+ - `activateCellAt(rowIndex, columnIndex, options?)`
89
+ - `moveActiveCell(rowDelta, columnDelta, options?)`
90
+ - `getActiveCellContext()`
91
+ - the existing `loadDeveloperDefaults()` and `layoutLabel`
92
+
93
+ Cell indices use `displayRows` and `visibleHeaders` and are clamped to the
94
+ displayed grid. Pass `{ wrapColumns: true }` to `moveActiveCell` to move from
95
+ the last column of one row to the first column of the next (or in reverse),
96
+ which is suitable for Tab/Shift+Tab. Pass `{ focus: true, focusOptions? }` to
97
+ activation or movement methods to focus the destination after Vue renders.
98
+ `getActiveCellContext()` returns the cell slot context plus `rowKey`,
99
+ `columnKey`, and `columnIndex`, or `null` when the active cell is not displayed.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "picocrank",
3
- "version": "1.32.0",
3
+ "version": "1.34.0",
4
4
  "type": "module",
5
5
  "description": "A set of zero dependency Vue components built on the Femtocrank global CSS theme.",
6
6
  "main": "vite.config.js",
package/router.js CHANGED
@@ -6,6 +6,7 @@ import { ViewIcon } from '@hugeicons/core-free-icons';
6
6
  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
+ import { Edit03Icon } from '@hugeicons/core-free-icons';
9
10
  import { Menu01Icon } from '@hugeicons/core-free-icons';
10
11
  import { Navigation01Icon } from '@hugeicons/core-free-icons';
11
12
  import { ClipboardCopyIcon } from '@hugeicons/core-free-icons';
@@ -16,6 +17,8 @@ import { PaintBoardIcon } from '@hugeicons/core-free-icons';
16
17
  import { UserIcon } from '@hugeicons/core-free-icons';
17
18
  import { InformationCircleIcon } from '@hugeicons/core-free-icons';
18
19
  import { Loading03Icon } from '@hugeicons/core-free-icons';
20
+ import { DragDropVerticalIcon } from '@hugeicons/core-free-icons';
21
+ import { ArrowDown01Icon } from '@hugeicons/core-free-icons';
19
22
 
20
23
  import {
21
24
  hubChildBreadcrumbs,
@@ -117,6 +120,16 @@ const routes = [
117
120
  breadcrumbs: hubChildBreadcrumbs('NavDataDisplayHub', 'CalendarExample', 'Calendar'),
118
121
  }
119
122
  },
123
+ {
124
+ name: 'ReorderListExample',
125
+ path: '/reorder-list-example',
126
+ component: () => import('./vue/examples/ReorderListExample.vue'),
127
+ meta: {
128
+ title: 'Reorder list',
129
+ icon: DragDropVerticalIcon,
130
+ breadcrumbs: hubChildBreadcrumbs('NavDataDisplayHub', 'ReorderListExample', 'Reorder list'),
131
+ }
132
+ },
120
133
  {
121
134
  name: 'Login',
122
135
  path: '/login',
@@ -147,6 +160,16 @@ const routes = [
147
160
  breadcrumbs: hubChildBreadcrumbs('NavFormsInputHub', 'FormExample', 'Forms'),
148
161
  }
149
162
  },
163
+ {
164
+ name: 'FieldValueExample',
165
+ path: '/field-value-example',
166
+ component: () => import('./vue/examples/FieldValueExample.vue'),
167
+ meta: {
168
+ title: 'Field value',
169
+ icon: Edit03Icon,
170
+ breadcrumbs: hubChildBreadcrumbs('NavFormsInputHub', 'FieldValueExample', 'Field value'),
171
+ }
172
+ },
150
173
  {
151
174
  name: 'ReadOnlyTextAreaExample',
152
175
  path: '/readonly-textarea-example',
@@ -187,6 +210,16 @@ const routes = [
187
210
  breadcrumbs: hubChildBreadcrumbs('NavFeedbackStatusHub', 'LoadingAreaExample', 'Loading area'),
188
211
  }
189
212
  },
213
+ {
214
+ name: 'AccordionExample',
215
+ path: '/accordion-example',
216
+ component: () => import('./vue/examples/AccordionExample.vue'),
217
+ meta: {
218
+ title: 'Accordion',
219
+ icon: ArrowDown01Icon,
220
+ breadcrumbs: hubChildBreadcrumbs('NavLayoutsHub', 'AccordionExample', 'Accordion'),
221
+ }
222
+ },
190
223
  {
191
224
  name: 'TabsExample',
192
225
  path: '/tabs-example',
@@ -93,6 +93,8 @@
93
93
 
94
94
  /* Matches Femtocrank header min-height (logo 2em + .5em padding). */
95
95
  --picocrank-fixed-header-offset: 3em;
96
+
97
+ --field-invalid-outline: 0 0 0 3px color-mix(in srgb, var(--karma-bad) 35%, transparent);
96
98
  }
97
99
 
98
100
  /* No fixed header → no sidebar/main offset. */
@@ -133,11 +135,18 @@ section .section-header [role="toolbar"] .button {
133
135
  flex-shrink: 0;
134
136
  }
135
137
 
136
- section .section-header [role="toolbar"] > :not(button):not(.button) {
138
+ /* Grow search/filter controls only — toolbar labels stay content-sized. */
139
+ section .section-header [role="toolbar"] > :not(button):not(.button):not(label):not(span) {
137
140
  flex: 1 1 auto;
138
141
  min-width: 0;
139
142
  }
140
143
 
144
+ section .section-header [role="toolbar"] > label {
145
+ flex: 0 0 auto;
146
+ width: auto;
147
+ min-width: auto;
148
+ }
149
+
141
150
  section.narrow {
142
151
  max-width: 480px;
143
152
  margin-left: auto;
@@ -157,6 +166,10 @@ form > .form-field-label-skip {
157
166
  grid-column: 1 / -1;
158
167
  }
159
168
 
169
+ label > input[type="checkbox"] {
170
+ margin-right: 0.35em;
171
+ }
172
+
160
173
  /*
161
174
  * FormField applies .field-invalid when an error is set programmatically.
162
175
  * Mirrors femtocrank :user-invalid (which only applies after user interaction).
@@ -167,7 +180,11 @@ select.field-invalid {
167
180
  border-color: var(--karma-bad-border);
168
181
  background-color: var(--karma-bad-wash);
169
182
  color: var(--text-color);
170
- box-shadow: 0 0 0 3px color-mix(in srgb, var(--karma-bad) 35%, transparent);
183
+ box-shadow: var(--field-invalid-outline);
184
+ }
185
+
186
+ .notification.attached-below {
187
+ margin: 0;
171
188
  }
172
189
 
173
190
  input.field-invalid:focus-visible,
@@ -184,6 +201,10 @@ select.field-invalid:focus-visible {
184
201
  * (unlayered) so they are not overridden by stuck-mode defaults.
185
202
  * Alternatively wrap with .layout-fixed-header when header and #layout are direct children.
186
203
  */
204
+ header {
205
+ flex-wrap: nowrap;
206
+ }
207
+
187
208
  header.fixed {
188
209
  top: 0;
189
210
  left: 0;
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import { nextTick } from 'vue'
4
+ import Accordion from './Accordion.vue'
5
+ import AccordionItem from './AccordionItem.vue'
6
+
7
+ const globalStubs = {
8
+ HugeiconsIcon: true,
9
+ }
10
+
11
+ describe('Accordion', () => {
12
+ it('opens the default subsection and toggles the rest', async () => {
13
+ const wrapper = mount(Accordion, {
14
+ props: {
15
+ defaultOpenIds: ['details'],
16
+ },
17
+ slots: {
18
+ default: `
19
+ <AccordionItem item-id="details" title="Details"><p class="details">Open</p></AccordionItem>
20
+ <AccordionItem item-id="notes" title="Notes"><p class="notes">Closed</p></AccordionItem>
21
+ `,
22
+ },
23
+ global: {
24
+ stubs: globalStubs,
25
+ components: { AccordionItem },
26
+ },
27
+ })
28
+
29
+ await nextTick()
30
+
31
+ const details = wrapper.get('#accordion-item-details')
32
+ const notes = wrapper.get('#accordion-item-notes')
33
+ expect(details.attributes('open')).toBeDefined()
34
+ expect(notes.attributes('open')).toBeUndefined()
35
+
36
+ notes.element.open = true
37
+ await notes.trigger('toggle')
38
+ await nextTick()
39
+
40
+ expect(wrapper.get('#accordion-item-notes').attributes('open')).toBeDefined()
41
+
42
+ details.element.open = false
43
+ await details.trigger('toggle')
44
+ await nextTick()
45
+
46
+ expect(wrapper.get('#accordion-item-details').attributes('open')).toBeUndefined()
47
+ })
48
+ })
@@ -0,0 +1,46 @@
1
+ <template>
2
+ <div class="accordion">
3
+ <slot />
4
+ </div>
5
+ </template>
6
+
7
+ <script setup>
8
+ import { provide, ref, watch } from 'vue'
9
+ import { accordionContextKey } from './accordionContext.js'
10
+
11
+ const props = defineProps({
12
+ /** Panel ids that start expanded. */
13
+ defaultOpenIds: {
14
+ type: Array,
15
+ default: () => [],
16
+ },
17
+ })
18
+
19
+ const openIds = ref(new Set())
20
+
21
+ function syncDefaultOpen() {
22
+ openIds.value = new Set(props.defaultOpenIds.filter(Boolean))
23
+ }
24
+
25
+ watch(() => props.defaultOpenIds, syncDefaultOpen, { immediate: true, deep: true })
26
+
27
+ function isOpen(id) {
28
+ return openIds.value.has(id)
29
+ }
30
+
31
+ function setOpen(id, isOpenNow) {
32
+ const currently = openIds.value.has(id)
33
+ if (currently === isOpenNow) {
34
+ return
35
+ }
36
+ const next = new Set(openIds.value)
37
+ if (isOpenNow) {
38
+ next.add(id)
39
+ } else {
40
+ next.delete(id)
41
+ }
42
+ openIds.value = next
43
+ }
44
+
45
+ provide(accordionContextKey, { isOpen, setOpen })
46
+ </script>
@@ -0,0 +1,103 @@
1
+ <template>
2
+ <details
3
+ :id="`accordion-item-${itemId}`"
4
+ class="accordion-item"
5
+ :open="open"
6
+ @toggle="onToggle"
7
+ >
8
+ <summary
9
+ class="section-subheader accordion-trigger flex-row"
10
+ :id="`accordion-trigger-${itemId}`"
11
+ >
12
+ <HugeiconsIcon
13
+ class="accordion-chevron"
14
+ :icon="ArrowDown01Icon"
15
+ width="1em"
16
+ height="1em"
17
+ aria-hidden="true"
18
+ />
19
+ <h3>{{ title }}</h3>
20
+ </summary>
21
+ <div
22
+ :id="`accordion-panel-${itemId}`"
23
+ class="section-content padding"
24
+ >
25
+ <slot />
26
+ </div>
27
+ </details>
28
+ </template>
29
+
30
+ <script setup>
31
+ import { computed, inject } from 'vue'
32
+ import { HugeiconsIcon } from '@hugeicons/vue'
33
+ import { ArrowDown01Icon } from '@hugeicons/core-free-icons'
34
+ import { accordionContextKey } from './accordionContext.js'
35
+
36
+ const props = defineProps({
37
+ itemId: {
38
+ type: String,
39
+ required: true,
40
+ },
41
+ title: {
42
+ type: String,
43
+ required: true,
44
+ },
45
+ })
46
+
47
+ const accordion = inject(accordionContextKey, null)
48
+
49
+ const open = computed(() => accordion?.isOpen(props.itemId) ?? false)
50
+
51
+ function onToggle(event) {
52
+ accordion?.setOpen(props.itemId, event.currentTarget.open)
53
+ }
54
+ </script>
55
+
56
+ <style scoped>
57
+ .accordion-trigger {
58
+ width: 100%;
59
+ margin: 0;
60
+ padding-right: 1em;
61
+ border: none;
62
+ background: none;
63
+ cursor: pointer;
64
+ font: inherit;
65
+ color: inherit;
66
+ text-align: left;
67
+ list-style: none;
68
+ gap: 0.35rem;
69
+ box-sizing: border-box;
70
+ transition: background-color 0.15s ease, color 0.15s ease;
71
+ }
72
+
73
+ .accordion-trigger:hover {
74
+ background-color: var(--hover-background-color);
75
+ color: var(--hover-text-color, inherit);
76
+ }
77
+
78
+ .accordion-trigger:focus-visible {
79
+ outline: 2px solid var(--focus-outline-color, var(--text-color));
80
+ outline-offset: -2px;
81
+ }
82
+
83
+ .accordion-trigger::-webkit-details-marker {
84
+ display: none;
85
+ }
86
+
87
+ .accordion-trigger::marker {
88
+ content: '';
89
+ }
90
+
91
+ .accordion-trigger h3 {
92
+ margin: 0;
93
+ }
94
+
95
+ .accordion-chevron {
96
+ flex-shrink: 0;
97
+ transition: transform 0.15s ease;
98
+ }
99
+
100
+ .accordion-item[open] .accordion-chevron {
101
+ transform: rotate(180deg);
102
+ }
103
+ </style>
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import { nextTick } from 'vue'
4
+ import FieldValue from './FieldValue.vue'
5
+
6
+ const globalStubs = {
7
+ HugeiconsIcon: true,
8
+ RouterLink: {
9
+ props: ['to'],
10
+ template: '<a class="outline-link" :href="typeof to === \'string\' ? to : \'#\'"><slot /></a>',
11
+ },
12
+ }
13
+
14
+ describe('FieldValue', () => {
15
+ it('shows a read-only icon when no edit template or outline target is provided', () => {
16
+ const wrapper = mount(FieldValue, {
17
+ props: { modelValue: 'Yesterday' },
18
+ global: { stubs: globalStubs },
19
+ })
20
+
21
+ expect(wrapper.text()).toContain('Yesterday')
22
+ expect(wrapper.find('[aria-label="Read only"]').exists()).toBe(true)
23
+ expect(wrapper.find('.field-value-outline').exists()).toBe(false)
24
+ })
25
+
26
+ it('shows an inline-edit icon and the edit template after click', async () => {
27
+ const wrapper = mount(FieldValue, {
28
+ props: { modelValue: 'Ada' },
29
+ slots: {
30
+ edit: '<input class="edit-box" :value="value" />',
31
+ },
32
+ global: { stubs: globalStubs },
33
+ })
34
+
35
+ expect(wrapper.find('[aria-label="Read only"]').exists()).toBe(false)
36
+ expect(wrapper.find('.field-value-display.editable').exists()).toBe(true)
37
+
38
+ await wrapper.find('.field-value-display').trigger('click')
39
+ await nextTick()
40
+
41
+ expect(wrapper.find('.edit-box').exists()).toBe(true)
42
+ expect(wrapper.find('.edit-box').element.value).toBe('Ada')
43
+ })
44
+
45
+ it('commits the edited value from the template', async () => {
46
+ const wrapper = mount(FieldValue, {
47
+ props: { modelValue: 'Ada' },
48
+ slots: {
49
+ edit: `
50
+ <input
51
+ class="edit-box"
52
+ :value="value"
53
+ @input="update($event.target.value)"
54
+ @keyup.enter="commit"
55
+ />
56
+ `,
57
+ },
58
+ global: { stubs: globalStubs },
59
+ })
60
+
61
+ await wrapper.find('.field-value-display').trigger('click')
62
+ await nextTick()
63
+ const input = wrapper.find('.edit-box')
64
+ await input.setValue('Grace')
65
+ await input.trigger('keyup.enter')
66
+
67
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['Grace'])
68
+ expect(wrapper.emitted('commit')?.[0]).toEqual(['Grace'])
69
+ expect(wrapper.text()).toContain('Ada')
70
+ })
71
+
72
+ it('shows an outline-edit link when a route is provided and no edit template', () => {
73
+ const wrapper = mount(FieldValue, {
74
+ props: {
75
+ modelValue: 'Website',
76
+ to: '/customers/1/edit',
77
+ editLabel: 'Edit customer',
78
+ },
79
+ global: { stubs: globalStubs },
80
+ })
81
+
82
+ const link = wrapper.find('.field-value-outline')
83
+ expect(link.exists()).toBe(true)
84
+ expect(link.attributes('aria-label')).toBe('Edit customer')
85
+ expect(link.attributes('href')).toBe('/customers/1/edit')
86
+ expect(wrapper.find('[aria-label="Read only"]').exists()).toBe(false)
87
+ })
88
+ })