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
@@ -80,14 +80,18 @@ function handleLinkClick(callback = null) {
80
80
 
81
81
  <style scoped>
82
82
  .topbar {
83
- width: 100%;
83
+ flex: 1 1 auto;
84
+ min-width: 0;
85
+ max-width: 100%;
86
+ overflow: hidden;
84
87
  }
85
88
 
86
89
  .topbar-links {
87
90
  display: flex;
88
91
  align-items: center;
89
92
  gap: 0.125rem;
90
- flex-wrap: wrap;
93
+ flex-wrap: nowrap;
94
+ overflow: hidden;
91
95
  }
92
96
 
93
97
  .topbar-link {
@@ -0,0 +1 @@
1
+ export const accordionContextKey = Symbol('accordion')
@@ -2,6 +2,7 @@ import {
2
2
  normalizeColumnFilterEntries,
3
3
  normalizeStoredFilterEntry,
4
4
  } from './tableFilters.js'
5
+ import { isHeaderGroupable } from './tableGroupBy.js'
5
6
 
6
7
  export const PRESET_STORE_VERSION = 1
7
8
  export const MAX_PRESETS_PER_TABLE = 20
@@ -181,6 +182,13 @@ export function sanitizePresetState(state, headers = [], rows = []) {
181
182
  }
182
183
  }
183
184
 
185
+ if (state.groupBy) {
186
+ const header = headerByKey.get(state.groupBy)
187
+ if (isHeaderGroupable(header)) {
188
+ sanitized.groupBy = state.groupBy
189
+ }
190
+ }
191
+
184
192
  const rawPageSize = Number(state.pageSize)
185
193
  if (Number.isFinite(rawPageSize)) {
186
194
  const normalizedPageSize = Math.floor(rawPageSize)
@@ -225,6 +233,30 @@ export function savePreset(tableId, { name, state, setAsDefault = false }) {
225
233
  return { ok: true, preset, defaultPresetId: store.defaultPresetId }
226
234
  }
227
235
 
236
+ export function overwritePreset(tableId, presetId, { state }) {
237
+ if (!tableId || !presetId) {
238
+ return { ok: false, error: 'Preset not found.' }
239
+ }
240
+ if (!state || typeof state !== 'object') {
241
+ return { ok: false, error: 'Nothing to save.' }
242
+ }
243
+
244
+ const store = readPresetStore(tableId)
245
+ const index = store.presets.findIndex((item) => item.id === presetId)
246
+ if (index === -1) {
247
+ return { ok: false, error: 'Preset not found.' }
248
+ }
249
+
250
+ const preset = {
251
+ ...store.presets[index],
252
+ savedAt: new Date().toISOString(),
253
+ state: JSON.parse(JSON.stringify(state)),
254
+ }
255
+ store.presets[index] = preset
256
+ writePresetStore(tableId, store)
257
+ return { ok: true, preset, defaultPresetId: store.defaultPresetId }
258
+ }
259
+
228
260
  export function deletePreset(tableId, presetId) {
229
261
  if (!tableId || !presetId) {
230
262
  return { ok: false, error: 'Preset not found.' }
@@ -286,6 +318,7 @@ export function normalizeLayoutStateForCompare(state, headers = [], rows = []) {
286
318
  filters: sanitized.filters ?? {},
287
319
  sortBy: sanitized.sortBy ?? null,
288
320
  sortDir: sanitized.sortDir === 'desc' ? 'desc' : 'asc',
321
+ groupBy: sanitized.groupBy ?? null,
289
322
  pageSize: sanitized.pageSize ?? ALLOWED_PAGE_SIZES[0],
290
323
  }
291
324
  }
@@ -387,7 +387,7 @@ export function cloneColumnFilterEntries(value, header = null, rows = []) {
387
387
  return normalizeColumnFilterEntries(value, header, rows).map((entry) => ({ ...entry }))
388
388
  }
389
389
 
390
- export function sortRows(rows, sortBy, sortDir) {
390
+ export function sortRows(rows, sortBy, sortDir, comparator = null) {
391
391
  if (!sortBy) {
392
392
  return [...rows]
393
393
  }
@@ -399,6 +399,11 @@ export function sortRows(rows, sortBy, sortDir) {
399
399
  if (av === bv) return 0
400
400
  if (av === null || av === undefined) return 1
401
401
  if (bv === null || bv === undefined) return -1
402
+ if (typeof comparator === 'function') {
403
+ const result = Number(comparator(av, bv, a, b))
404
+ if (!Number.isFinite(result) || result === 0) return 0
405
+ return sortDir === 'asc' ? result : -result
406
+ }
402
407
  if (typeof av === 'string' && typeof bv === 'string') {
403
408
  return sortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av)
404
409
  }
@@ -0,0 +1,173 @@
1
+ export const EMPTY_GROUP_LABEL = '(empty)'
2
+
3
+ export function isHeaderGroupable(header, tableGroupable = true) {
4
+ if (!tableGroupable || !header?.key) {
5
+ return false
6
+ }
7
+ if (header.groupable === false) {
8
+ return false
9
+ }
10
+ return true
11
+ }
12
+
13
+ export function groupableHeaders(headers = [], tableGroupable = true) {
14
+ return headers.filter((header) => isHeaderGroupable(header, tableGroupable))
15
+ }
16
+
17
+ export function formatGroupValue(value) {
18
+ if (value === null || value === undefined || value === '') {
19
+ return EMPTY_GROUP_LABEL
20
+ }
21
+ return String(value)
22
+ }
23
+
24
+ export function normalizeGroupValue(value) {
25
+ if (value === null || value === undefined || value === '') {
26
+ return null
27
+ }
28
+ return value
29
+ }
30
+
31
+ export function buildGroupId(columnKey, value) {
32
+ const normalized = normalizeGroupValue(value)
33
+ return `${columnKey}::${normalized === null ? '' : String(normalized)}`
34
+ }
35
+
36
+ export function compareGroupValues(a, b) {
37
+ const aEmpty = a === null || a === undefined || a === ''
38
+ const bEmpty = b === null || b === undefined || b === ''
39
+ if (aEmpty && bEmpty) {
40
+ return 0
41
+ }
42
+ if (aEmpty) {
43
+ return 1
44
+ }
45
+ if (bEmpty) {
46
+ return -1
47
+ }
48
+ if (typeof a === 'number' && typeof b === 'number') {
49
+ return a - b
50
+ }
51
+ if (typeof a === 'boolean' && typeof b === 'boolean') {
52
+ return Number(a) - Number(b)
53
+ }
54
+ return String(a).localeCompare(String(b))
55
+ }
56
+
57
+ export function groupRowsByColumn(rows, columnKey) {
58
+ if (!columnKey || rows.length === 0) {
59
+ return []
60
+ }
61
+
62
+ const groups = new Map()
63
+ const order = []
64
+
65
+ for (const row of rows) {
66
+ const value = normalizeGroupValue(row?.[columnKey])
67
+ const groupId = buildGroupId(columnKey, value)
68
+ if (!groups.has(groupId)) {
69
+ const group = {
70
+ columnKey,
71
+ value,
72
+ groupId,
73
+ rows: [],
74
+ }
75
+ groups.set(groupId, group)
76
+ order.push(groupId)
77
+ }
78
+ groups.get(groupId).rows.push(row)
79
+ }
80
+
81
+ return order.map((id) => groups.get(id))
82
+ }
83
+
84
+ export function paginateGroupedRows(rows, columnKey, page, pageSize) {
85
+ if (!columnKey || pageSize <= 0) {
86
+ return []
87
+ }
88
+
89
+ const groups = groupRowsByColumn(rows, columnKey)
90
+ const start = (page - 1) * pageSize
91
+ const end = start + pageSize
92
+ let globalIndex = 0
93
+ const pageGroups = []
94
+
95
+ for (const group of groups) {
96
+ const groupStart = globalIndex
97
+ const groupEnd = globalIndex + group.rows.length
98
+ const overlapStart = Math.max(start, groupStart)
99
+ const overlapEnd = Math.min(end, groupEnd)
100
+
101
+ if (overlapStart < overlapEnd) {
102
+ const sliceStart = overlapStart - groupStart
103
+ const sliceEnd = overlapEnd - groupStart
104
+ pageGroups.push({
105
+ ...group,
106
+ rows: group.rows.slice(sliceStart, sliceEnd),
107
+ count: group.rows.length,
108
+ })
109
+ }
110
+
111
+ globalIndex = groupEnd
112
+ }
113
+
114
+ return pageGroups
115
+ }
116
+
117
+ export function flattenGroups(groups, collapsedGroupIds = new Set(), sourceRows = null) {
118
+ const rowIndexByReference = sourceRows
119
+ ? new Map(sourceRows.map((row, index) => [row, index]))
120
+ : null
121
+ const items = []
122
+
123
+ for (const group of groups) {
124
+ const collapsed = collapsedGroupIds.has(group.groupId)
125
+ items.push({
126
+ kind: 'group',
127
+ columnKey: group.columnKey,
128
+ value: group.value,
129
+ groupId: group.groupId,
130
+ count: group.count ?? group.rows.length,
131
+ collapsed,
132
+ })
133
+
134
+ if (!collapsed) {
135
+ for (const row of group.rows) {
136
+ items.push({
137
+ kind: 'row',
138
+ row,
139
+ sourceIndex: rowIndexByReference?.get(row) ?? null,
140
+ })
141
+ }
142
+ }
143
+ }
144
+
145
+ return items
146
+ }
147
+
148
+ export function buildDisplayBodyItems(rows, {
149
+ groupBy = null,
150
+ page = 1,
151
+ pageSize = 10,
152
+ paginate = true,
153
+ collapsedGroupIds = new Set(),
154
+ } = {}) {
155
+ if (!groupBy) {
156
+ const dataRows = paginate
157
+ ? rows.slice((page - 1) * pageSize, ((page - 1) * pageSize) + pageSize)
158
+ : rows
159
+
160
+ const startIndex = paginate ? (page - 1) * pageSize : 0
161
+ return dataRows.map((row, index) => ({
162
+ kind: 'row',
163
+ row,
164
+ sourceIndex: startIndex + index,
165
+ }))
166
+ }
167
+
168
+ const groups = paginate
169
+ ? paginateGroupedRows(rows, groupBy, page, pageSize)
170
+ : groupRowsByColumn(rows, groupBy)
171
+
172
+ return flattenGroups(groups, collapsedGroupIds, rows)
173
+ }
@@ -0,0 +1,69 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ buildDisplayBodyItems,
4
+ buildGroupId,
5
+ compareGroupValues,
6
+ formatGroupValue,
7
+ groupRowsByColumn,
8
+ paginateGroupedRows,
9
+ } from './tableGroupBy.js'
10
+
11
+ const rows = [
12
+ { name: 'Ada', city: 'London' },
13
+ { name: 'Grace', city: 'New York' },
14
+ { name: 'Katherine', city: 'London' },
15
+ { name: 'Alan', city: '' },
16
+ ]
17
+
18
+ describe('tableGroupBy', () => {
19
+ it('formats empty group values', () => {
20
+ expect(formatGroupValue(null)).toBe('(empty)')
21
+ expect(formatGroupValue('Paris')).toBe('Paris')
22
+ })
23
+
24
+ it('groups rows by column while preserving row order within groups', () => {
25
+ const groups = groupRowsByColumn(rows, 'city')
26
+
27
+ expect(groups.map((group) => group.value)).toEqual(['London', 'New York', null])
28
+ expect(groups[0].rows.map((row) => row.name)).toEqual(['Ada', 'Katherine'])
29
+ expect(buildGroupId('city', 'London')).toBe('city::London')
30
+ expect(buildGroupId('city', null)).toBe('city::')
31
+ })
32
+
33
+ it('compares group values with empty values last', () => {
34
+ expect(compareGroupValues('London', 'New York')).toBeLessThan(0)
35
+ expect(compareGroupValues(null, 'London')).toBeGreaterThan(0)
36
+ })
37
+
38
+ it('paginates grouped rows by leaf row count', () => {
39
+ const pageOne = paginateGroupedRows(rows, 'city', 1, 2)
40
+ const pageTwo = paginateGroupedRows(rows, 'city', 2, 2)
41
+
42
+ expect(pageOne).toHaveLength(1)
43
+ expect(pageOne[0].value).toBe('London')
44
+ expect(pageOne[0].rows.map((row) => row.name)).toEqual(['Ada', 'Katherine'])
45
+ expect(pageTwo.map((group) => group.value)).toEqual(['New York', null])
46
+ })
47
+
48
+ it('builds display items with group headers and source indexes', () => {
49
+ const items = buildDisplayBodyItems(rows, {
50
+ groupBy: 'city',
51
+ paginate: false,
52
+ })
53
+
54
+ expect(items.filter((item) => item.kind === 'group').map((item) => item.count)).toEqual([2, 1, 1])
55
+ expect(items.find((item) => item.kind === 'row' && item.row.name === 'Ada')?.sourceIndex).toBe(0)
56
+ })
57
+
58
+ it('omits collapsed group rows from display items', () => {
59
+ const londonGroupId = buildGroupId('city', 'London')
60
+ const items = buildDisplayBodyItems(rows, {
61
+ groupBy: 'city',
62
+ paginate: false,
63
+ collapsedGroupIds: new Set([londonGroupId]),
64
+ })
65
+
66
+ expect(items.some((item) => item.kind === 'row' && item.row.name === 'Ada')).toBe(false)
67
+ expect(items.some((item) => item.kind === 'group' && item.groupId === londonGroupId && item.collapsed)).toBe(true)
68
+ })
69
+ })
@@ -0,0 +1,34 @@
1
+ /** Saved layouts and column view state use browser localStorage (requires `tableId`). */
2
+ export const VIEW_CONTROL_LOCAL_STORAGE = 'localStorageControlled'
3
+
4
+ /** Saved layouts and view persistence are driven by `viewSettings` callbacks. */
5
+ export const VIEW_CONTROL_CALLBACK = 'callbackControlled'
6
+
7
+ export const VIEW_CONTROL_MODES = [
8
+ VIEW_CONTROL_LOCAL_STORAGE,
9
+ VIEW_CONTROL_CALLBACK,
10
+ ]
11
+
12
+ /**
13
+ * Callback API for {@link VIEW_CONTROL_CALLBACK} (same surface as legacy `layoutPresets`).
14
+ *
15
+ * @typedef {object} TableViewSettingsCallbacks
16
+ * @property {() => Promise<{ presets?: object[], defaultPresetId?: string|null }>|{ presets?: object[], defaultPresetId?: string|null }} list
17
+ * @property {() => Promise<{ ok: boolean, preset?: object|null }>|{ ok: boolean, preset?: object|null }} [getDefault]
18
+ * @property {(args: { name: string, state: object, setAsDefault?: boolean }) => Promise<{ ok: boolean, preset?: object, error?: string }>} save
19
+ * @property {(args: { presetId: string, state: object }) => Promise<{ ok: boolean, preset?: object, error?: string }>} [overwrite]
20
+ * @property {(presetId: string) => Promise<{ ok: boolean, preset?: object, error?: string }>} load
21
+ * @property {(presetId: string) => Promise<{ ok: boolean, error?: string }>} delete
22
+ * @property {(presetId: string|null) => Promise<{ ok: boolean, error?: string }>} setDefault
23
+ */
24
+
25
+ export function getViewSettingsHandlers(viewSettings, layoutPresets) {
26
+ return viewSettings ?? layoutPresets ?? null
27
+ }
28
+
29
+ export function isViewSettingsControlEnabled(viewControlMode, tableId, handlers) {
30
+ if (viewControlMode === VIEW_CONTROL_CALLBACK) {
31
+ return handlers != null
32
+ }
33
+ return Boolean(tableId)
34
+ }
@@ -0,0 +1,21 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ getViewSettingsHandlers,
4
+ isViewSettingsControlEnabled,
5
+ VIEW_CONTROL_CALLBACK,
6
+ VIEW_CONTROL_LOCAL_STORAGE,
7
+ } from './tableViewSettings.js'
8
+
9
+ describe('tableViewSettings', () => {
10
+ it('prefers viewSettings over layoutPresets', () => {
11
+ const handlers = { list: () => {} }
12
+ expect(getViewSettingsHandlers(handlers, { list: () => 'legacy' })).toBe(handlers)
13
+ })
14
+
15
+ it('enables control per mode', () => {
16
+ expect(isViewSettingsControlEnabled(VIEW_CONTROL_LOCAL_STORAGE, 'users', null)).toBe(true)
17
+ expect(isViewSettingsControlEnabled(VIEW_CONTROL_LOCAL_STORAGE, '', null)).toBe(false)
18
+ expect(isViewSettingsControlEnabled(VIEW_CONTROL_CALLBACK, '', { list: () => {} })).toBe(true)
19
+ expect(isViewSettingsControlEnabled(VIEW_CONTROL_CALLBACK, 'users', null)).toBe(false)
20
+ })
21
+ })
@@ -0,0 +1,91 @@
1
+ import { nextTick, onMounted, onUnmounted, ref, toValue, watch } from 'vue'
2
+
3
+ function headerGapTotal(headerEl) {
4
+ const style = getComputedStyle(headerEl)
5
+ const gap = parseFloat(style.columnGap || style.gap) || 0
6
+ return gap * Math.max(0, headerEl.children.length - 1)
7
+ }
8
+
9
+ function childrenWidth(headerEl, except = null) {
10
+ let width = 0
11
+ for (const child of headerEl.children) {
12
+ if (child === except) {
13
+ continue
14
+ }
15
+ width += child.getBoundingClientRect().width
16
+ }
17
+ return width
18
+ }
19
+
20
+ /**
21
+ * Hides the header top bar when link row would wrap or clip (measures against header width).
22
+ * `active` should stay true while the top bar is eligible (enabled and not mobile-only hidden).
23
+ */
24
+ export function useHeaderTopBarFit(headerRef, active, spaceLimitedOut) {
25
+ const internalLimited = ref(false)
26
+ const spaceLimited = spaceLimitedOut ?? internalLimited
27
+ const requiredWidth = ref(0)
28
+ let observer = null
29
+
30
+ function update() {
31
+ if (!toValue(active)) {
32
+ spaceLimited.value = false
33
+ requiredWidth.value = 0
34
+ return
35
+ }
36
+
37
+ const headerEl = headerRef.value
38
+ if (!headerEl) {
39
+ return
40
+ }
41
+
42
+ const topBarNav = headerEl.querySelector(':scope > nav.topbar')
43
+
44
+ if (topBarNav) {
45
+ const links = topBarNav.querySelector('.topbar-links')
46
+ if (!links) {
47
+ return
48
+ }
49
+
50
+ requiredWidth.value = Math.ceil(links.scrollWidth)
51
+ const fitsInRow = links.scrollWidth <= links.clientWidth + 1
52
+ const others = childrenWidth(headerEl, topBarNav)
53
+ const available = headerEl.clientWidth - others - headerGapTotal(headerEl)
54
+ const fitsInHeader = requiredWidth.value <= available + 1
55
+ spaceLimited.value = !fitsInRow || !fitsInHeader
56
+ return
57
+ }
58
+
59
+ if (requiredWidth.value <= 0) {
60
+ spaceLimited.value = false
61
+ return
62
+ }
63
+
64
+ const others = childrenWidth(headerEl)
65
+ const available = headerEl.clientWidth - others - headerGapTotal(headerEl)
66
+ spaceLimited.value = requiredWidth.value > available + 1
67
+ }
68
+
69
+ watch(
70
+ () => toValue(active),
71
+ () => nextTick(update),
72
+ )
73
+
74
+ onMounted(() => {
75
+ const headerEl = headerRef.value
76
+ if (!headerEl) {
77
+ return
78
+ }
79
+
80
+ observer = new ResizeObserver(() => update())
81
+ observer.observe(headerEl)
82
+ nextTick(update)
83
+ })
84
+
85
+ onUnmounted(() => {
86
+ observer?.disconnect()
87
+ observer = null
88
+ })
89
+
90
+ return { spaceLimited, remeasure: update }
91
+ }
@@ -13,7 +13,7 @@ export const MOBILE_NAV_QUERY = '(max-width: 768px)'
13
13
  * Explicit sidebar mounting stays with the app. Header owns the top-bar-only
14
14
  * mobile fallback via `needsFallbackSidebar`.
15
15
  */
16
- export function useResponsiveNav(sidebarEnabled, topBarEnabled) {
16
+ export function useResponsiveNav(sidebarEnabled, topBarEnabled, topBarSpaceLimited) {
17
17
  const isMobile = useMediaQuery(MOBILE_NAV_QUERY)
18
18
 
19
19
  const hasExplicitSidebar = computed(() => !!toValue(sidebarEnabled))
@@ -21,8 +21,10 @@ export function useResponsiveNav(sidebarEnabled, topBarEnabled) {
21
21
  () => !!toValue(topBarEnabled) && !toValue(sidebarEnabled),
22
22
  )
23
23
 
24
+ const topBarCrowded = computed(() => !!toValue(topBarSpaceLimited))
25
+
24
26
  const needsFallbackSidebar = computed(
25
- () => topBarOnly.value && isMobile.value,
27
+ () => topBarOnly.value && (isMobile.value || topBarCrowded.value),
26
28
  )
27
29
 
28
30
  const showSidebarChrome = computed(
@@ -30,7 +32,7 @@ export function useResponsiveNav(sidebarEnabled, topBarEnabled) {
30
32
  )
31
33
 
32
34
  const showTopBar = computed(
33
- () => !!toValue(topBarEnabled) && !isMobile.value,
35
+ () => !!toValue(topBarEnabled) && !isMobile.value && !topBarCrowded.value,
34
36
  )
35
37
 
36
38
  return {
@@ -46,11 +46,16 @@ export const navSectionHubs = {
46
46
  title: 'Tabs',
47
47
  options: { description: 'Tabbed content regions' },
48
48
  },
49
+ {
50
+ name: 'AccordionExample',
51
+ title: 'Accordion',
52
+ options: { description: 'Collapsible subsections' },
53
+ },
49
54
  ],
50
55
  },
51
56
  NavDataDisplayHub: {
52
57
  title: 'Data display',
53
- subtitle: 'Tables, calendars, and read-only output',
58
+ subtitle: 'Tables, calendars, row lists, and read-only output',
54
59
  sectionId: 'nav-data',
55
60
  links: [
56
61
  {
@@ -68,6 +73,11 @@ export const navSectionHubs = {
68
73
  title: 'Calendar',
69
74
  options: { description: 'Month view with events' },
70
75
  },
76
+ {
77
+ name: 'ReorderListExample',
78
+ title: 'Reorder list',
79
+ options: { description: 'Drag and drop to reorder row components' },
80
+ },
71
81
  {
72
82
  name: 'ReadOnlyTextAreaExample',
73
83
  title: 'Read-only output',
@@ -90,6 +100,11 @@ export const navSectionHubs = {
90
100
  title: 'Forms',
91
101
  options: { description: 'Labels, inputs, and fieldsets' },
92
102
  },
103
+ {
104
+ name: 'FieldValueExample',
105
+ title: 'Field value',
106
+ options: { description: 'Read-only, inline edit, and outline edit' },
107
+ },
93
108
  {
94
109
  name: 'LoginExample',
95
110
  title: 'Login',
@@ -0,0 +1,33 @@
1
+ <template>
2
+ <Section
3
+ title="Accordion"
4
+ :icon="ArrowDown01Icon"
5
+ subtitle="Collapsible subsections inside a section."
6
+ :padding="false"
7
+ >
8
+ <p class="padding">
9
+ <code>Accordion</code> tracks which panels are open.
10
+ Each <code>AccordionItem</code> is a subsection: a
11
+ <code>section-subheader</code> trigger and a padded content region.
12
+ Pass ids in <code>default-open-ids</code> for panels that start expanded.
13
+ </p>
14
+
15
+ <Accordion :default-open-ids="['details']">
16
+ <AccordionItem item-id="details" title="Details">
17
+ <p>This subsection starts open.</p>
18
+ </AccordionItem>
19
+ <AccordionItem item-id="notes" title="Notes">
20
+ <p>This subsection starts closed. Select the heading to expand it.</p>
21
+ </AccordionItem>
22
+ <AccordionItem item-id="history" title="History">
23
+ <p>Several subsections can be open at the same time.</p>
24
+ </AccordionItem>
25
+ </Accordion>
26
+ </Section>
27
+ </template>
28
+
29
+ <script setup>
30
+ import { ArrowDown01Icon } from '@hugeicons/core-free-icons'
31
+ import Accordion from '../components/Accordion.vue'
32
+ import AccordionItem from '../components/AccordionItem.vue'
33
+ </script>