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,222 @@
1
+ <template>
2
+ <div
3
+ class="field-value"
4
+ :class="{
5
+ 'is-inline': hasInlineEdit,
6
+ 'is-outline': hasOutlineEdit,
7
+ 'is-readonly': readOnly,
8
+ }"
9
+ >
10
+ <div
11
+ v-if="editing"
12
+ ref="editRoot"
13
+ class="field-value-edit"
14
+ @click.stop
15
+ >
16
+ <slot
17
+ name="edit"
18
+ :value="draft"
19
+ :update="updateDraft"
20
+ :commit="commit"
21
+ :cancel="cancel"
22
+ />
23
+ </div>
24
+ <div
25
+ v-else
26
+ class="field-value-display"
27
+ :class="{ editable: hasInlineEdit }"
28
+ @click="startEdit"
29
+ >
30
+ <span class="field-value-content">
31
+ <slot>{{ displayText }}</slot>
32
+ </span>
33
+ <HugeiconsIcon
34
+ v-if="hasInlineEdit"
35
+ :icon="Edit03Icon"
36
+ class="field-value-icon"
37
+ width="0.9em"
38
+ height="0.9em"
39
+ aria-hidden="true"
40
+ />
41
+ <router-link
42
+ v-else-if="to"
43
+ :to="to"
44
+ class="field-value-icon field-value-outline"
45
+ :title="editLabel"
46
+ :aria-label="editLabel"
47
+ @click.stop
48
+ >
49
+ <HugeiconsIcon
50
+ :icon="FileEditIcon"
51
+ width="0.9em"
52
+ height="0.9em"
53
+ aria-hidden="true"
54
+ />
55
+ </router-link>
56
+ <a
57
+ v-else-if="href"
58
+ :href="href"
59
+ class="field-value-icon field-value-outline"
60
+ :title="editLabel"
61
+ :aria-label="editLabel"
62
+ @click.stop
63
+ >
64
+ <HugeiconsIcon
65
+ :icon="FileEditIcon"
66
+ width="0.9em"
67
+ height="0.9em"
68
+ aria-hidden="true"
69
+ />
70
+ </a>
71
+ <span
72
+ v-else
73
+ class="field-value-icon field-value-readonly"
74
+ title="Read only"
75
+ aria-label="Read only"
76
+ >
77
+ <HugeiconsIcon
78
+ :icon="SquareLock01Icon"
79
+ width="0.9em"
80
+ height="0.9em"
81
+ aria-hidden="true"
82
+ />
83
+ </span>
84
+ </div>
85
+ </div>
86
+ </template>
87
+
88
+ <script setup>
89
+ import { computed, nextTick, ref, useSlots } from 'vue'
90
+ import { HugeiconsIcon } from '@hugeicons/vue'
91
+ import { Edit03Icon, FileEditIcon, SquareLock01Icon } from '@hugeicons/core-free-icons'
92
+
93
+ const props = defineProps({
94
+ modelValue: {
95
+ type: [String, Number, Boolean],
96
+ default: '',
97
+ },
98
+ /**
99
+ * Route for outline edit. Shown when no edit template is provided.
100
+ */
101
+ to: {
102
+ type: [String, Object],
103
+ default: null,
104
+ },
105
+ /**
106
+ * URL for outline edit when a router location is not used.
107
+ */
108
+ href: {
109
+ type: String,
110
+ default: '',
111
+ },
112
+ editLabel: {
113
+ type: String,
114
+ default: 'Edit',
115
+ },
116
+ })
117
+
118
+ const emit = defineEmits(['update:modelValue', 'commit', 'cancel'])
119
+
120
+ const slots = useSlots()
121
+ const editing = ref(false)
122
+ const draft = ref('')
123
+ const editRoot = ref(null)
124
+
125
+ const hasInlineEdit = computed(() => typeof slots.edit === 'function')
126
+ const hasOutlineEdit = computed(() => !hasInlineEdit.value && Boolean(props.to || props.href))
127
+ const readOnly = computed(() => !hasInlineEdit.value && !hasOutlineEdit.value)
128
+ const displayText = computed(() => (props.modelValue == null ? '' : String(props.modelValue)))
129
+
130
+ function startEdit() {
131
+ if (!hasInlineEdit.value || editing.value) {
132
+ return
133
+ }
134
+ draft.value = displayText.value
135
+ editing.value = true
136
+ nextTick(() => {
137
+ const control = editRoot.value?.querySelector('input, select, textarea')
138
+ if (!control) {
139
+ return
140
+ }
141
+ control.focus()
142
+ if (control instanceof HTMLInputElement && control.type !== 'checkbox') {
143
+ control.select()
144
+ }
145
+ })
146
+ }
147
+
148
+ function updateDraft(value) {
149
+ draft.value = value == null ? '' : String(value)
150
+ }
151
+
152
+ function commit() {
153
+ if (!editing.value) {
154
+ return
155
+ }
156
+ emit('update:modelValue', draft.value)
157
+ emit('commit', draft.value)
158
+ editing.value = false
159
+ }
160
+
161
+ function cancel() {
162
+ if (!editing.value) {
163
+ return
164
+ }
165
+ editing.value = false
166
+ draft.value = displayText.value
167
+ emit('cancel')
168
+ }
169
+ </script>
170
+
171
+ <style scoped>
172
+ .field-value-display,
173
+ .field-value-edit {
174
+ display: inline-flex;
175
+ align-items: center;
176
+ gap: 0.35rem;
177
+ min-height: 1.5em;
178
+ }
179
+
180
+ .field-value-display.editable {
181
+ cursor: pointer;
182
+ padding: 0.25rem;
183
+ border-radius: 3px;
184
+ }
185
+
186
+ .field-value-display.editable:hover {
187
+ background-color: var(--hover-background-color, #f8f9fa);
188
+ }
189
+
190
+ .field-value-content {
191
+ min-width: 0;
192
+ }
193
+
194
+ .field-value-icon {
195
+ flex-shrink: 0;
196
+ color: var(--muted-text-color, #8a8a8a);
197
+ opacity: 0.55;
198
+ }
199
+
200
+ .field-value-outline,
201
+ .field-value-readonly {
202
+ display: inline-flex;
203
+ line-height: 0;
204
+ }
205
+
206
+ .field-value-outline {
207
+ text-decoration: none;
208
+ }
209
+
210
+ .field-value-display.editable:hover .field-value-icon,
211
+ .field-value-outline:hover {
212
+ opacity: 0.9;
213
+ }
214
+
215
+ .field-value-edit :deep(input),
216
+ .field-value-edit :deep(select),
217
+ .field-value-edit :deep(textarea) {
218
+ width: 100%;
219
+ max-width: 28rem;
220
+ box-sizing: border-box;
221
+ }
222
+ </style>
@@ -124,6 +124,7 @@ describe('FormField', () => {
124
124
 
125
125
  const errorBlock = wrapper.find('.form-field-error')
126
126
  expect(errorBlock.exists()).toBe(true)
127
+ expect(errorBlock.classes()).toContain('attached-below')
127
128
  expect(errorBlock.attributes('role')).toBe('alert')
128
129
  expect(errorBlock.text()).toContain('This field is required.')
129
130
  })
@@ -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>