picocrank 1.33.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 (35) hide show
  1. package/docs/filter-query-v1.md +5 -1
  2. package/package.json +1 -1
  3. package/router.js +33 -0
  4. package/styles/picocrank-extensions.css +23 -2
  5. package/vue/components/Accordion.spec.js +48 -0
  6. package/vue/components/Accordion.vue +46 -0
  7. package/vue/components/AccordionItem.vue +103 -0
  8. package/vue/components/FieldValue.spec.js +88 -0
  9. package/vue/components/FieldValue.vue +222 -0
  10. package/vue/components/FormField.spec.js +1 -0
  11. package/vue/components/FormField.vue +38 -10
  12. package/vue/components/Header.vue +24 -2
  13. package/vue/components/NotificationBlock.vue +25 -0
  14. package/vue/components/ReorderList.spec.js +318 -0
  15. package/vue/components/ReorderList.vue +614 -0
  16. package/vue/components/Table.spec.js +27 -0
  17. package/vue/components/Table.vue +458 -112
  18. package/vue/components/TableColumnFilterPopover.vue +63 -19
  19. package/vue/components/TableColumnOptionsPopover.vue +248 -273
  20. package/vue/components/TopBar.vue +6 -2
  21. package/vue/components/accordionContext.js +1 -0
  22. package/vue/composables/tableColumnPresets.js +33 -0
  23. package/vue/composables/tableGroupBy.js +173 -0
  24. package/vue/composables/tableGroupBy.spec.js +69 -0
  25. package/vue/composables/tableViewSettings.js +34 -0
  26. package/vue/composables/tableViewSettings.spec.js +21 -0
  27. package/vue/composables/useHeaderTopBarFit.js +91 -0
  28. package/vue/composables/useResponsiveNav.js +5 -3
  29. package/vue/data/navSectionHubs.js +16 -1
  30. package/vue/examples/AccordionExample.vue +33 -0
  31. package/vue/examples/FieldValueExample.vue +143 -0
  32. package/vue/examples/FormExample.vue +11 -6
  33. package/vue/examples/ReorderListExample.vue +236 -0
  34. package/vue/examples/TableExample.vue +5 -1
  35. package/vue/examples/TableRemoteExample.vue +28 -4
@@ -20,16 +20,19 @@
20
20
  rel="noopener noreferrer"
21
21
  >{{ docsLinkTitle }}</a>
22
22
  </div>
23
- <slot />
24
- <NotificationBlock
25
- v-if="fieldError"
26
- :id="errorId"
27
- class="form-field-error"
28
- type="bad"
29
- label="Error"
30
- :message="fieldError"
31
- role="alert"
32
- />
23
+ <div class="form-field-value-stack">
24
+ <slot />
25
+ <NotificationBlock
26
+ v-if="fieldError"
27
+ :id="errorId"
28
+ class="form-field-error"
29
+ type="bad"
30
+ label="Error"
31
+ :message="fieldError"
32
+ attached-below
33
+ role="alert"
34
+ />
35
+ </div>
33
36
  <div v-if="!descriptionAbove && hasFieldMeta" class="form-field-meta">
34
37
  <p v-if="description" class="subtle form-field-description">{{ description }}</p>
35
38
  <a
@@ -303,10 +306,35 @@ label.disabled,
303
306
  align-self: flex-start;
304
307
  }
305
308
 
309
+ .form-field-value-stack {
310
+ display: flex;
311
+ flex-direction: column;
312
+ gap: 0;
313
+ width: 100%;
314
+ min-width: 0;
315
+ }
316
+
306
317
  .form-field-control :deep(.form-field-error) {
307
318
  font-size: 0.9em;
308
319
  }
309
320
 
321
+ .form-field-value-stack:has(> :is(input, textarea, select).field-invalid + :deep(.form-field-error.attached-below)) {
322
+ box-shadow: var(--field-invalid-outline);
323
+ border-radius: 0.4em;
324
+ overflow: hidden;
325
+ }
326
+
327
+ .form-field-value-stack:has(> :is(input, textarea, select).field-invalid + :deep(.form-field-error.attached-below)) > :is(input, textarea, select).field-invalid {
328
+ border-bottom-left-radius: 0;
329
+ border-bottom-right-radius: 0;
330
+ box-shadow: none;
331
+ }
332
+
333
+ .form-field-value-stack:has(> :is(input, textarea, select).field-invalid + :deep(.form-field-error.attached-below)) :deep(.form-field-error.attached-below) {
334
+ margin: 0;
335
+ box-shadow: none;
336
+ }
337
+
310
338
  .form-field-required {
311
339
  color: var(--form-field-required-fg);
312
340
  margin-left: 0.15em;
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <header :class="{ fixed }">
2
+ <header ref="headerRoot" :class="{ fixed }">
3
3
  <div
4
4
  :id = "showSidebarChrome ? 'sidebar-button' : null"
5
5
  :class="['image-and-title', 'flex-row', { 'disabled-branding': !showBranding, 'logo-home-link': !showSidebarChrome && showBranding }]"
@@ -71,7 +71,7 @@
71
71
  </template>
72
72
 
73
73
  <script setup>
74
- import { computed, ref } from "vue";
74
+ import { computed, nextTick, ref, watch } from "vue";
75
75
  import { RouterLink } from "vue-router";
76
76
  import { HugeiconsIcon } from "@hugeicons/vue";
77
77
  import { BulbIcon, Menu01Icon, Moon02Icon, Sun01Icon, UserIcon } from "@hugeicons/core-free-icons";
@@ -80,6 +80,7 @@
80
80
  import TopBar from "./TopBar.vue";
81
81
  import Sidebar from "./Sidebar.vue";
82
82
  import { useTheme } from '../composables/useTheme.js';
83
+ import { useHeaderTopBarFit } from '../composables/useHeaderTopBarFit.js';
83
84
  import { useResponsiveNav } from '../composables/useResponsiveNav.js';
84
85
 
85
86
  const { theme, nextTheme, toggleTheme } = useTheme();
@@ -161,10 +162,31 @@
161
162
  });
162
163
 
163
164
  const fallbackSidebar = ref(null);
165
+ const headerRoot = ref(null);
166
+ const topBarSpaceLimited = ref(false);
164
167
 
165
168
  const { showTopBar, showSidebarChrome, needsFallbackSidebar, isMobile } = useResponsiveNav(
166
169
  () => props.sidebarEnabled,
167
170
  () => props.topBarEnabled,
171
+ topBarSpaceLimited,
172
+ );
173
+
174
+ const topBarMeasureActive = computed(
175
+ () => props.topBarEnabled && !isMobile.value,
176
+ );
177
+
178
+ const { remeasure: remeasureTopBar } = useHeaderTopBarFit(
179
+ headerRoot,
180
+ topBarMeasureActive,
181
+ topBarSpaceLimited,
182
+ );
183
+
184
+ watch(showTopBar, () => nextTick(() => remeasureTopBar()));
185
+
186
+ watch(
187
+ () => props.topBarNavigation,
188
+ () => remeasureTopBar(),
189
+ { deep: true },
168
190
  );
169
191
 
170
192
  const showBreadcrumbsBar = computed(
@@ -89,6 +89,14 @@ const props = defineProps({
89
89
  type: String,
90
90
  default: 'status',
91
91
  },
92
+ /**
93
+ * Nest the block directly under a control (flat top edge, shared width).
94
+ * Pair with an input, select, or textarea immediately above.
95
+ */
96
+ attachedBelow: {
97
+ type: Boolean,
98
+ default: false,
99
+ },
92
100
  })
93
101
 
94
102
  const emit = defineEmits(['click', 'dismiss'])
@@ -108,6 +116,7 @@ const hasLink = computed(() => Boolean(
108
116
  const rootClasses = computed(() => ({
109
117
  notification: true,
110
118
  [props.type]: true,
119
+ 'attached-below': props.attachedBelow,
111
120
  clickable: props.clickable || props.dismissible,
112
121
  }))
113
122
 
@@ -118,3 +127,19 @@ function handleClick(event) {
118
127
  }
119
128
  }
120
129
  </script>
130
+
131
+ <style scoped>
132
+ .notification.attached-below {
133
+ box-sizing: border-box;
134
+ width: 100%;
135
+ margin: 0;
136
+ border-top: none;
137
+ border-radius: 0 0 0.4em 0.4em;
138
+ }
139
+
140
+ .notification.attached-below.bad,
141
+ .notification.attached-below.error,
142
+ .notification.attached-below.critical {
143
+ border-color: var(--karma-bad-border);
144
+ }
145
+ </style>
@@ -0,0 +1,318 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import { h, nextTick, ref } from 'vue'
4
+ import ReorderList from './ReorderList.vue'
5
+ import ReorderListExample from '../examples/ReorderListExample.vue'
6
+
7
+ const globalStubs = {
8
+ HugeiconsIcon: true,
9
+ }
10
+
11
+ const rows = [
12
+ { id: 'a', title: 'Alpha' },
13
+ { id: 'b', title: 'Beta' },
14
+ { id: 'c', title: 'Gamma' },
15
+ ]
16
+
17
+ function mountList(props = {}, slots = {}) {
18
+ const wrapper = mount(ReorderList, {
19
+ props: {
20
+ modelValue: rows.map((row) => ({ ...row })),
21
+ ariaLabel: 'Queue',
22
+ ...props,
23
+ },
24
+ slots: {
25
+ item: ({ item }) => h('span', { class: 'row-label' }, item.title),
26
+ ...slots,
27
+ },
28
+ global: { stubs: globalStubs },
29
+ })
30
+ return wrapper
31
+ }
32
+
33
+ function dataTransfer() {
34
+ return {
35
+ effectAllowed: '',
36
+ dropEffect: '',
37
+ setData: () => {},
38
+ }
39
+ }
40
+
41
+ describe('ReorderList', () => {
42
+ it('renders row slot content in model order', () => {
43
+ const wrapper = mountList()
44
+ const labels = wrapper.findAll('.row-label').map((node) => node.text())
45
+
46
+ expect(labels).toEqual(['Alpha', 'Beta', 'Gamma'])
47
+ expect(wrapper.attributes('aria-label')).toBe('Queue')
48
+ })
49
+
50
+ it('moves a row down and up with the buttons', async () => {
51
+ const wrapper = mountList({ showMoveButtons: true })
52
+
53
+ await wrapper.find('[aria-label="Move Alpha down"]').trigger('click')
54
+ expect(wrapper.emitted('update:modelValue')[0][0].map((item) => item.id)).toEqual(['b', 'a', 'c'])
55
+ expect(wrapper.emitted('reorder')[0][0]).toMatchObject({ fromIndex: 0, toIndex: 1 })
56
+
57
+ await wrapper.setProps({ modelValue: wrapper.emitted('update:modelValue')[0][0] })
58
+ await wrapper.find('[aria-label="Move Alpha up"]').trigger('click')
59
+ const emitted = wrapper.emitted('update:modelValue')
60
+ expect(emitted[emitted.length - 1][0].map((item) => item.id)).toEqual(['a', 'b', 'c'])
61
+ })
62
+
63
+ it('disables moves past the ends of the list', () => {
64
+ const wrapper = mountList({ showMoveButtons: true })
65
+
66
+ expect(wrapper.find('[aria-label="Move Alpha up"]').attributes('disabled')).toBeDefined()
67
+ expect(wrapper.find('[aria-label="Move Gamma down"]').attributes('disabled')).toBeDefined()
68
+ expect(wrapper.find('[aria-label="Move Beta up"]').attributes('disabled')).toBeUndefined()
69
+ })
70
+
71
+ function layoutRows(wrapper) {
72
+ const list = wrapper.element?.classList?.contains('reorder-list')
73
+ ? wrapper
74
+ : wrapper.get('.reorder-list')
75
+ const rowEls = [...list.element.querySelectorAll(':scope > li.reorder-list-row')]
76
+ rowEls.forEach((rowEl, index) => {
77
+ const top = index * 50
78
+ rowEl.getBoundingClientRect = () => ({
79
+ top,
80
+ bottom: top + 40,
81
+ height: 40,
82
+ left: 0,
83
+ right: 200,
84
+ width: 200,
85
+ x: 0,
86
+ y: top,
87
+ toJSON() { return {} },
88
+ })
89
+ })
90
+ list.element.getBoundingClientRect = () => ({
91
+ top: 0,
92
+ bottom: rowEls.length * 50,
93
+ height: rowEls.length * 50,
94
+ left: 0,
95
+ right: 200,
96
+ width: 200,
97
+ x: 0,
98
+ y: 0,
99
+ toJSON() { return {} },
100
+ })
101
+ return list
102
+ }
103
+
104
+ it('shows a separator in the gap between rows and inserts there', async () => {
105
+ const wrapper = mountList()
106
+ const list = layoutRows(wrapper)
107
+ const handles = wrapper.findAll('.reorder-list-handle')
108
+
109
+ await handles[0].trigger('dragstart', { dataTransfer: dataTransfer() })
110
+ expect(wrapper.find('.reorder-list-row').classes()).toContain('is-dragging')
111
+
112
+ // Lower half of Beta: the gap between Beta and Gamma.
113
+ await list.trigger('dragover', { clientY: 80, dataTransfer: dataTransfer() })
114
+ const separator = wrapper.get('.reorder-list-separator')
115
+ expect(separator.attributes('data-drop-gap')).toBe('2')
116
+ expect(wrapper.find('.is-drag-over').exists()).toBe(false)
117
+
118
+ await list.trigger('drop', { clientY: 80 })
119
+ expect(wrapper.emitted('update:modelValue')[0][0].map((item) => item.id)).toEqual(['b', 'a', 'c'])
120
+ expect(wrapper.emitted('reorder')[0][0]).toMatchObject({ fromIndex: 0, toIndex: 1 })
121
+ expect(wrapper.find('.reorder-list-separator').exists()).toBe(false)
122
+ expect(wrapper.find('.reorder-list-row.is-dragging').exists()).toBe(false)
123
+ })
124
+
125
+ it('places the separator after the last row when the pointer is below it', async () => {
126
+ const wrapper = mountList()
127
+ const list = layoutRows(wrapper)
128
+
129
+ await wrapper.findAll('.reorder-list-handle')[0].trigger('dragstart', { dataTransfer: dataTransfer() })
130
+ await list.trigger('dragover', { clientY: 140, dataTransfer: dataTransfer() })
131
+
132
+ expect(wrapper.get('.reorder-list-separator').attributes('data-drop-gap')).toBe('3')
133
+ await list.trigger('drop', { clientY: 140 })
134
+ expect(wrapper.emitted('update:modelValue')[0][0].map((item) => item.id)).toEqual(['b', 'c', 'a'])
135
+ })
136
+
137
+ it('places the separator before the first row when dragging upward', async () => {
138
+ const wrapper = mountList()
139
+ const list = layoutRows(wrapper)
140
+
141
+ await wrapper.findAll('.reorder-list-handle')[2].trigger('dragstart', { dataTransfer: dataTransfer() })
142
+ await list.trigger('dragover', { clientY: 10, dataTransfer: dataTransfer() })
143
+
144
+ expect(wrapper.get('.reorder-list-separator').attributes('data-drop-gap')).toBe('0')
145
+ await list.trigger('drop', { clientY: 10 })
146
+ expect(wrapper.emitted('update:modelValue')[0][0].map((item) => item.id)).toEqual(['c', 'a', 'b'])
147
+ })
148
+
149
+ it('hides the separator when the gap would not move the row', async () => {
150
+ const wrapper = mountList()
151
+ const list = layoutRows(wrapper)
152
+
153
+ await wrapper.findAll('.reorder-list-handle')[0].trigger('dragstart', { dataTransfer: dataTransfer() })
154
+ await list.trigger('dragover', { clientY: 30, dataTransfer: dataTransfer() })
155
+
156
+ expect(wrapper.find('.reorder-list-separator').exists()).toBe(false)
157
+ await list.trigger('drop', { clientY: 30 })
158
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
159
+ })
160
+
161
+ it('does not reorder when disabled', async () => {
162
+ const wrapper = mountList({ disabled: true })
163
+
164
+ await wrapper.findAll('.reorder-list-handle')[0].trigger('dragstart', { dataTransfer: dataTransfer() })
165
+ await wrapper.findAll('.reorder-list-row')[2].trigger('drop')
166
+
167
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
168
+ expect(wrapper.classes()).toContain('is-disabled')
169
+ })
170
+
171
+ it('moves with the arrow keys when move buttons are hidden', async () => {
172
+ const wrapper = mountList()
173
+ const handle = wrapper.findAll('.reorder-list-handle')[0]
174
+
175
+ expect(handle.attributes('tabindex')).toBe('0')
176
+ await handle.trigger('keydown', { key: 'ArrowDown' })
177
+ await nextTick()
178
+
179
+ expect(wrapper.emitted('update:modelValue')[0][0].map((item) => item.id)).toEqual(['b', 'a', 'c'])
180
+ })
181
+
182
+ it('renders the empty slot', () => {
183
+ const wrapper = mountList(
184
+ { modelValue: [] },
185
+ { empty: () => h('span', { class: 'empty' }, 'No rows') },
186
+ )
187
+
188
+ expect(wrapper.find('.empty').text()).toBe('No rows')
189
+ expect(wrapper.find('.reorder-list-row').exists()).toBe(false)
190
+ })
191
+
192
+ it('hides move buttons and drag handles independently', () => {
193
+ const buttonsOnly = mountList({ showMoveButtons: true, showDragHandles: false })
194
+ expect(buttonsOnly.find('.reorder-list-handle').exists()).toBe(false)
195
+ expect(buttonsOnly.find('.reorder-list-move').exists()).toBe(true)
196
+
197
+ const handlesOnly = mountList()
198
+ expect(handlesOnly.find('.reorder-list-move').exists()).toBe(false)
199
+ expect(handlesOnly.find('.reorder-list-handle').exists()).toBe(true)
200
+
201
+ const neither = mountList({ showDragHandles: false })
202
+ expect(neither.find('.reorder-list-controls').exists()).toBe(false)
203
+ })
204
+
205
+ it('drags from the whole row when dragWholeRow is enabled', async () => {
206
+ const wrapper = mountList({ dragWholeRow: true, showDragHandles: true })
207
+ const list = layoutRows(wrapper)
208
+
209
+ expect(wrapper.find('.reorder-list-handle').exists()).toBe(false)
210
+ expect(wrapper.find('.reorder-list-row.is-drag-whole-row').exists()).toBe(true)
211
+
212
+ await wrapper.findAll('.reorder-list-row')[0].trigger('dragstart', { dataTransfer: dataTransfer() })
213
+ await list.trigger('dragover', { clientY: 80, dataTransfer: dataTransfer() })
214
+ await list.trigger('drop', { clientY: 80 })
215
+
216
+ expect(wrapper.emitted('update:modelValue')[0][0].map((item) => item.id)).toEqual(['b', 'a', 'c'])
217
+ })
218
+
219
+ it('does not start a whole-row drag from move buttons', async () => {
220
+ const wrapper = mountList({ dragWholeRow: true, showMoveButtons: true })
221
+ await wrapper.find('[aria-label="Move Alpha down"]').trigger('dragstart', { dataTransfer: dataTransfer() })
222
+ expect(wrapper.find('.reorder-list-row.is-dragging').exists()).toBe(false)
223
+ })
224
+
225
+ it('does not show drop indicators on nested lists while dragging the parent', async () => {
226
+ const groups = ref([
227
+ {
228
+ id: 'g1',
229
+ title: 'Group 1',
230
+ children: [{ id: 'c1', label: 'Step 1' }, { id: 'c2', label: 'Step 2' }],
231
+ },
232
+ {
233
+ id: 'g2',
234
+ title: 'Group 2',
235
+ children: [{ id: 'c3', label: 'Step 3' }],
236
+ },
237
+ ])
238
+ const wrapper = mount({
239
+ components: { ReorderList },
240
+ setup() {
241
+ return { groups }
242
+ },
243
+ template: `
244
+ <ReorderList v-model="groups" item-key="id" aria-label="Outer">
245
+ <template #item="{ item }">
246
+ <div class="nested-slot">
247
+ <strong>{{ item.title }}</strong>
248
+ <ReorderList
249
+ v-model="item.children"
250
+ item-key="id"
251
+ aria-label="Inner steps"
252
+ />
253
+ </div>
254
+ </template>
255
+ </ReorderList>
256
+ `,
257
+ global: { stubs: globalStubs },
258
+ })
259
+
260
+ const lists = wrapper.findAll('.reorder-list')
261
+ const outer = lists[0]
262
+ const innerLists = lists.slice(1)
263
+
264
+ layoutRows(outer)
265
+ await outer.find('.reorder-list-handle').trigger('dragstart', { dataTransfer: dataTransfer() })
266
+ await innerLists[0].trigger('dragover', { clientY: 120, dataTransfer: dataTransfer() })
267
+
268
+ expect(innerLists[0].find('.reorder-list-separator').exists()).toBe(false)
269
+ expect(innerLists[1].find('.reorder-list-separator').exists()).toBe(false)
270
+ })
271
+
272
+ it('uses a custom item key function', async () => {
273
+ const wrapper = mountList({
274
+ modelValue: [{ slug: 'one' }, { slug: 'two' }],
275
+ itemKey: (item) => item.slug,
276
+ itemLabel: (item) => item.slug,
277
+ showMoveButtons: true,
278
+ }, {
279
+ item: ({ item }) => h('span', { class: 'row-label' }, item.slug),
280
+ })
281
+
282
+ await wrapper.find('[aria-label="Move one down"]').trigger('click')
283
+ expect(wrapper.emitted('update:modelValue')[0][0].map((item) => item.slug)).toEqual(['two', 'one'])
284
+ })
285
+ })
286
+
287
+ describe('ReorderList example', () => {
288
+ it('toggles move buttons and drag handles from the section toolbar', async () => {
289
+ const wrapper = mount(ReorderListExample, {
290
+ global: { stubs: globalStubs },
291
+ })
292
+
293
+ const checklist = wrapper.findAll('section').find((section) => (
294
+ section.find('.reorder-example-toggle').exists()
295
+ ))
296
+ const moveButtons = checklist.get('input[type="checkbox"]')
297
+ const dragHandles = checklist.findAll('input[type="checkbox"]')[1]
298
+
299
+ expect(checklist.find('.reorder-list-move').exists()).toBe(false)
300
+ expect(checklist.find('.reorder-list-handle').exists()).toBe(true)
301
+
302
+ await moveButtons.setValue(true)
303
+ expect(checklist.find('.reorder-list-move').exists()).toBe(true)
304
+ expect(checklist.find('.reorder-list-handle').exists()).toBe(true)
305
+
306
+ await dragHandles.setValue(false)
307
+ expect(checklist.find('.reorder-list-handle').exists()).toBe(false)
308
+ expect(checklist.find('.reorder-list-move').exists()).toBe(true)
309
+
310
+ await moveButtons.setValue(false)
311
+ expect(checklist.find('.reorder-list-controls').exists()).toBe(false)
312
+
313
+ await dragHandles.setValue(true)
314
+ await moveButtons.setValue(false)
315
+ expect(checklist.find('.reorder-list-move').exists()).toBe(false)
316
+ expect(checklist.find('.reorder-list-handle').exists()).toBe(true)
317
+ })
318
+ })