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.
- package/docs/filter-query-v1.md +5 -1
- package/docs/table.md +99 -0
- package/package.json +1 -1
- package/router.js +33 -0
- package/styles/picocrank-extensions.css +23 -2
- package/vue/components/Accordion.spec.js +48 -0
- package/vue/components/Accordion.vue +46 -0
- package/vue/components/AccordionItem.vue +103 -0
- package/vue/components/FieldValue.spec.js +88 -0
- package/vue/components/FieldValue.vue +222 -0
- package/vue/components/FormField.spec.js +1 -0
- package/vue/components/FormField.vue +38 -10
- package/vue/components/Header.vue +24 -2
- package/vue/components/NotificationBlock.vue +25 -0
- package/vue/components/ReorderList.spec.js +318 -0
- package/vue/components/ReorderList.vue +614 -0
- package/vue/components/Table.spec.js +490 -1
- package/vue/components/Table.vue +1038 -111
- package/vue/components/TableColumnFilterPopover.vue +63 -19
- package/vue/components/TableColumnOptionsPopover.vue +248 -273
- package/vue/components/TopBar.vue +6 -2
- package/vue/components/accordionContext.js +1 -0
- package/vue/composables/tableColumnPresets.js +33 -0
- package/vue/composables/tableFilters.js +6 -1
- package/vue/composables/tableGroupBy.js +173 -0
- package/vue/composables/tableGroupBy.spec.js +69 -0
- package/vue/composables/tableViewSettings.js +34 -0
- package/vue/composables/tableViewSettings.spec.js +21 -0
- package/vue/composables/useHeaderTopBarFit.js +91 -0
- package/vue/composables/useResponsiveNav.js +5 -3
- package/vue/data/navSectionHubs.js +16 -1
- package/vue/examples/AccordionExample.vue +33 -0
- package/vue/examples/FieldValueExample.vue +143 -0
- package/vue/examples/FormExample.vue +11 -6
- package/vue/examples/ReorderListExample.vue +236 -0
- package/vue/examples/TableExample.vue +5 -1
- 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
|
-
|
|
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:
|
|
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>
|