fds-vue-core 8.3.2 → 8.4.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/components.d.ts +2 -0
- package/dist/components/FdsDrawer/FdsDrawer.vue.d.ts +35 -0
- package/dist/components/FdsDrawer/types.d.ts +11 -0
- package/dist/composables/useRouteFilters.d.ts +73 -0
- package/dist/fds-vue-core.cjs.js +757 -228
- package/dist/fds-vue-core.cjs.js.map +1 -1
- package/dist/fds-vue-core.css +1 -1
- package/dist/fds-vue-core.es.js +758 -229
- package/dist/fds-vue-core.es.js.map +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/slot-styles.css +40 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +8 -4
- package/src/components/FdsDrawer/FdsDrawer.stories.ts +155 -0
- package/src/components/FdsDrawer/FdsDrawer.vue +283 -0
- package/src/components/FdsDrawer/types.ts +12 -0
- package/src/composables/useRouteFilters.spec.ts +157 -0
- package/src/composables/useRouteFilters.ts +500 -0
- package/src/index.ts +26 -0
- package/src/lang/en.json +1 -0
- package/src/lang/sv.json +1 -0
- package/src/slot-styles.css +40 -0
- package/src/style.css +45 -1
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
import { reactive, ref, watch, type Ref } from 'vue'
|
|
2
|
+
import { useRoute, useRouter, type LocationQuery, type LocationQueryRaw } from 'vue-router'
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Public schema types
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
export type RouteFilterValue = string | number | boolean | string[]
|
|
9
|
+
|
|
10
|
+
export type RouteFilterRecord = Record<string, RouteFilterValue>
|
|
11
|
+
|
|
12
|
+
export interface RouteSortDefault {
|
|
13
|
+
field?: string
|
|
14
|
+
dir?: 'asc' | 'desc' | ''
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface UseRouteFiltersSchema<F extends RouteFilterRecord, D extends RouteFilterRecord> {
|
|
18
|
+
filter: F
|
|
19
|
+
draft?: D
|
|
20
|
+
sort?: RouteSortDefault
|
|
21
|
+
search?: string
|
|
22
|
+
page?: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Return types
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
export type RouteFilterState<F extends RouteFilterRecord> = {
|
|
30
|
+
[K in keyof F]: F[K] extends string[] ? string[] : F[K]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface RouteSort {
|
|
34
|
+
/** Current sort value, e.g. `'-created'`, `'+name'`, or `''` */
|
|
35
|
+
readonly value: string
|
|
36
|
+
/** Current field being sorted (without sign prefix) */
|
|
37
|
+
readonly field: string
|
|
38
|
+
/** Current direction */
|
|
39
|
+
readonly dir: 'asc' | 'desc' | ''
|
|
40
|
+
/**
|
|
41
|
+
* Toggle sort for `field`.
|
|
42
|
+
* - none / other field → desc (`-field`)
|
|
43
|
+
* - `-field` → asc (`+field`)
|
|
44
|
+
* - `+field` → none (`''`)
|
|
45
|
+
*/
|
|
46
|
+
toggle: (field: string) => void
|
|
47
|
+
set: (field: string, dir: 'asc' | 'desc') => void
|
|
48
|
+
clear: () => void
|
|
49
|
+
/** Icon hint for table header */
|
|
50
|
+
iconFor: (field: string) => 'ascending' | 'descending' | 'sort'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface RouteDraftControl<D extends RouteFilterRecord> {
|
|
54
|
+
/** Reactive draft state — edit directly without .value */
|
|
55
|
+
readonly state: RouteFilterState<D>
|
|
56
|
+
/** Copy active filter values into draft (call before opening modal) */
|
|
57
|
+
open: () => void
|
|
58
|
+
/** Apply draft values to active filter (call on save) */
|
|
59
|
+
apply: () => void
|
|
60
|
+
/** Discard draft changes (call on cancel) */
|
|
61
|
+
cancel: () => void
|
|
62
|
+
/** Reset draft to schema defaults */
|
|
63
|
+
reset: () => void
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface UseRouteFiltersReturn<F extends RouteFilterRecord, D extends RouteFilterRecord> {
|
|
67
|
+
/** Reactive filter state — read/write directly without .value */
|
|
68
|
+
filter: RouteFilterState<F>
|
|
69
|
+
/** Draft state + controls (only when `draft` was provided in schema) */
|
|
70
|
+
draft: RouteDraftControl<D>
|
|
71
|
+
/** Sort controls */
|
|
72
|
+
sort: RouteSort
|
|
73
|
+
/** Reactive search ref (for v-model on input) */
|
|
74
|
+
search: Ref<string>
|
|
75
|
+
/** Reactive page ref (for v-model / pagination) */
|
|
76
|
+
page: Ref<number>
|
|
77
|
+
/** Reset filter, search, page and sort to defaults */
|
|
78
|
+
reset: () => Promise<void>
|
|
79
|
+
/** Clear all schema-managed query params */
|
|
80
|
+
clear: () => Promise<void>
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Internal helpers
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
const isArr = (v: RouteFilterValue): v is string[] => Array.isArray(v)
|
|
88
|
+
|
|
89
|
+
const clone = (v: RouteFilterValue): RouteFilterValue => (isArr(v) ? [...v] : v)
|
|
90
|
+
|
|
91
|
+
const cloneRecord = <F extends RouteFilterRecord>(defaults: F): RouteFilterState<F> => {
|
|
92
|
+
const out = {} as RouteFilterState<F>
|
|
93
|
+
for (const k in defaults) {
|
|
94
|
+
out[k] = clone(defaults[k]) as RouteFilterState<F>[typeof k]
|
|
95
|
+
}
|
|
96
|
+
return out
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const isDefault = (v: RouteFilterValue, def: RouteFilterValue): boolean => {
|
|
100
|
+
if (isArr(def)) return isArr(v) && v.length === 0
|
|
101
|
+
return v === def
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const parseValue = (key: string, query: LocationQuery, def: RouteFilterValue): RouteFilterValue => {
|
|
105
|
+
const raw = query[key]
|
|
106
|
+
|
|
107
|
+
if (isArr(def)) {
|
|
108
|
+
if (raw === undefined || raw === null) return []
|
|
109
|
+
if (Array.isArray(raw)) return raw.filter((x): x is string => x != null).map(String)
|
|
110
|
+
return [String(raw)]
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (typeof def === 'boolean') return raw === 'true'
|
|
114
|
+
|
|
115
|
+
if (typeof def === 'number') {
|
|
116
|
+
if (raw === undefined || raw === null || raw === '') return def
|
|
117
|
+
const n = Number(Array.isArray(raw) ? raw[0] : raw)
|
|
118
|
+
return Number.isFinite(n) ? n : def
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (raw === undefined || raw === null) return def
|
|
122
|
+
return String(Array.isArray(raw) ? raw[0] : raw)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const serializeValue = (v: RouteFilterValue, def: RouteFilterValue): LocationQueryRaw[string] | undefined => {
|
|
126
|
+
if (isDefault(v, def)) return undefined
|
|
127
|
+
if (isArr(def)) return isArr(v) && v.length > 0 ? v : undefined
|
|
128
|
+
if (typeof def === 'boolean') return v === true ? 'true' : undefined
|
|
129
|
+
if (typeof def === 'number') return String(v)
|
|
130
|
+
if (typeof v === 'string' && v !== '') return v
|
|
131
|
+
return undefined
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const normalizeQuery = (q: LocationQuery | LocationQueryRaw): Record<string, string | string[]> => {
|
|
135
|
+
const out: Record<string, string | string[]> = {}
|
|
136
|
+
for (const k of Object.keys(q).sort()) {
|
|
137
|
+
const v = q[k]
|
|
138
|
+
if (v === undefined || v === null) continue
|
|
139
|
+
if (Array.isArray(v)) {
|
|
140
|
+
const items = v.filter((x) => x != null).map(String)
|
|
141
|
+
if (items.length) out[k] = items
|
|
142
|
+
} else {
|
|
143
|
+
out[k] = String(v)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return out
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const queriesEqual = (a: LocationQuery | LocationQueryRaw, b: LocationQuery | LocationQueryRaw) =>
|
|
150
|
+
JSON.stringify(normalizeQuery(a)) === JSON.stringify(normalizeQuery(b))
|
|
151
|
+
|
|
152
|
+
const statesEqual = <F extends RouteFilterRecord>(a: RouteFilterState<F>, b: RouteFilterState<F>): boolean => {
|
|
153
|
+
for (const k in a) {
|
|
154
|
+
const av = a[k] as RouteFilterValue
|
|
155
|
+
const bv = b[k] as RouteFilterValue
|
|
156
|
+
if (isArr(av)) {
|
|
157
|
+
if (!isArr(bv) || av.length !== bv.length || av.some((x, i) => x !== bv[i])) return false
|
|
158
|
+
} else if (av !== bv) {
|
|
159
|
+
return false
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return true
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const applyInPlace = <F extends RouteFilterRecord>(
|
|
166
|
+
target: RouteFilterState<F>,
|
|
167
|
+
source: RouteFilterState<F>,
|
|
168
|
+
defaults: F,
|
|
169
|
+
) => {
|
|
170
|
+
for (const k in defaults) {
|
|
171
|
+
const next = source[k] as RouteFilterValue
|
|
172
|
+
const current = target[k] as RouteFilterValue
|
|
173
|
+
if (isArr(defaults[k])) {
|
|
174
|
+
const na = next as string[]
|
|
175
|
+
const ca = current as string[]
|
|
176
|
+
if (na.length !== ca.length || na.some((x, i) => x !== ca[i])) {
|
|
177
|
+
ca.splice(0, ca.length, ...na)
|
|
178
|
+
}
|
|
179
|
+
} else if (current !== next) {
|
|
180
|
+
target[k] = next as RouteFilterState<F>[typeof k]
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Works on plain `Record<string, RouteFilterValue>` — used for draft operations */
|
|
186
|
+
const copyPlain = (
|
|
187
|
+
source: Record<string, RouteFilterValue>,
|
|
188
|
+
target: Record<string, RouteFilterValue>,
|
|
189
|
+
keys: string[],
|
|
190
|
+
) => {
|
|
191
|
+
for (const k of keys) {
|
|
192
|
+
const v = source[k]
|
|
193
|
+
if (isArr(v)) {
|
|
194
|
+
const ca = target[k]
|
|
195
|
+
if (Array.isArray(ca)) {
|
|
196
|
+
ca.splice(0, ca.length, ...v)
|
|
197
|
+
} else {
|
|
198
|
+
target[k] = [...v]
|
|
199
|
+
}
|
|
200
|
+
} else {
|
|
201
|
+
target[k] = clone(v)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// URL constants
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
const SEARCH_KEY = '_search'
|
|
211
|
+
const PAGE_KEY = '_page'
|
|
212
|
+
const SORT_KEY = '_sort'
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// Main composable
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
export function useRouteFilters<F extends RouteFilterRecord, D extends RouteFilterRecord = RouteFilterRecord>(
|
|
219
|
+
schema: UseRouteFiltersSchema<F, D>,
|
|
220
|
+
): UseRouteFiltersReturn<F, D> {
|
|
221
|
+
const route = useRoute()
|
|
222
|
+
const router = useRouter()
|
|
223
|
+
|
|
224
|
+
const filterDefaults = schema.filter
|
|
225
|
+
const draftDefaults = (schema.draft ?? {}) as D
|
|
226
|
+
const sortDefault = schema.sort ?? {}
|
|
227
|
+
const searchDefault = schema.search ?? ''
|
|
228
|
+
const pageDefault = schema.page ?? 0
|
|
229
|
+
|
|
230
|
+
const draftKeys = Object.keys(draftDefaults) as (keyof D & keyof F)[]
|
|
231
|
+
const schemaQueryKeys = new Set([...Object.keys(filterDefaults), ...draftKeys, SEARCH_KEY, PAGE_KEY, SORT_KEY])
|
|
232
|
+
|
|
233
|
+
// ── reactive state ────────────────────────────────────────────────────────
|
|
234
|
+
const filterState = reactive(cloneRecord(filterDefaults)) as RouteFilterState<F>
|
|
235
|
+
const draftState = reactive(cloneRecord(draftDefaults as RouteFilterRecord)) as RouteFilterState<D>
|
|
236
|
+
const searchRef = ref(searchDefault)
|
|
237
|
+
const pageRef = ref(pageDefault)
|
|
238
|
+
|
|
239
|
+
// ── sort internal ref ─────────────────────────────────────────────────────
|
|
240
|
+
const defaultSortValue =
|
|
241
|
+
sortDefault.field && sortDefault.dir ? `${sortDefault.dir === 'desc' ? '-' : '+'}${sortDefault.field}` : ''
|
|
242
|
+
const sortValueRef = ref(defaultSortValue)
|
|
243
|
+
|
|
244
|
+
// ── convenience plain-object aliases for draft ops ───────────────────────
|
|
245
|
+
const filterRaw = filterState as unknown as Record<string, RouteFilterValue>
|
|
246
|
+
const draftRaw = draftState as unknown as Record<string, RouteFilterValue>
|
|
247
|
+
const draftKeyStrings = draftKeys as string[]
|
|
248
|
+
|
|
249
|
+
// ── loop guards ───────────────────────────────────────────────────────────
|
|
250
|
+
let syncingFromRoute = false
|
|
251
|
+
let syncingToRoute = false
|
|
252
|
+
|
|
253
|
+
// ── init from URL ─────────────────────────────────────────────────────────
|
|
254
|
+
const readFromRoute = () => {
|
|
255
|
+
const q = route.query
|
|
256
|
+
|
|
257
|
+
// filter
|
|
258
|
+
const parsed = cloneRecord(filterDefaults)
|
|
259
|
+
for (const k in filterDefaults) {
|
|
260
|
+
parsed[k] = parseValue(k, q, filterDefaults[k]) as RouteFilterState<F>[typeof k]
|
|
261
|
+
}
|
|
262
|
+
if (!statesEqual(filterState, parsed)) {
|
|
263
|
+
applyInPlace(filterState, parsed, filterDefaults)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// search
|
|
267
|
+
const rawSearch = q[SEARCH_KEY]
|
|
268
|
+
const nextSearch =
|
|
269
|
+
rawSearch !== undefined && rawSearch !== null
|
|
270
|
+
? String(Array.isArray(rawSearch) ? rawSearch[0] : rawSearch)
|
|
271
|
+
: searchDefault
|
|
272
|
+
if (searchRef.value !== nextSearch) searchRef.value = nextSearch
|
|
273
|
+
|
|
274
|
+
// page
|
|
275
|
+
const rawPage = q[PAGE_KEY]
|
|
276
|
+
const nextPage =
|
|
277
|
+
rawPage !== undefined && rawPage !== null
|
|
278
|
+
? Number(Array.isArray(rawPage) ? rawPage[0] : rawPage) || pageDefault
|
|
279
|
+
: pageDefault
|
|
280
|
+
if (pageRef.value !== nextPage) pageRef.value = nextPage
|
|
281
|
+
|
|
282
|
+
// sort
|
|
283
|
+
const rawSort = q[SORT_KEY]
|
|
284
|
+
const nextSort = typeof rawSort === 'string' && rawSort !== '' ? rawSort : defaultSortValue
|
|
285
|
+
if (sortValueRef.value !== nextSort) sortValueRef.value = nextSort
|
|
286
|
+
|
|
287
|
+
// draft: initialise from filter
|
|
288
|
+
if (draftKeys.length > 0) {
|
|
289
|
+
copyPlain(
|
|
290
|
+
filterState as unknown as Record<string, RouteFilterValue>,
|
|
291
|
+
draftState as unknown as Record<string, RouteFilterValue>,
|
|
292
|
+
draftKeys as string[],
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
readFromRoute()
|
|
298
|
+
|
|
299
|
+
// ── build query from state ────────────────────────────────────────────────
|
|
300
|
+
const buildQuery = (): LocationQueryRaw => {
|
|
301
|
+
const q: LocationQueryRaw = {}
|
|
302
|
+
|
|
303
|
+
// preserve non-schema keys
|
|
304
|
+
for (const k in route.query) {
|
|
305
|
+
if (!schemaQueryKeys.has(k)) q[k] = route.query[k]
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// filter
|
|
309
|
+
for (const k in filterDefaults) {
|
|
310
|
+
const s = serializeValue(filterState[k] as RouteFilterValue, filterDefaults[k])
|
|
311
|
+
if (s !== undefined) q[k] = s
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// search
|
|
315
|
+
if (searchRef.value !== searchDefault && searchRef.value !== '') {
|
|
316
|
+
q[SEARCH_KEY] = searchRef.value
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// page
|
|
320
|
+
if (pageRef.value !== pageDefault) {
|
|
321
|
+
q[PAGE_KEY] = String(pageRef.value)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// sort
|
|
325
|
+
if (sortValueRef.value !== '' && sortValueRef.value !== defaultSortValue) {
|
|
326
|
+
q[SORT_KEY] = sortValueRef.value
|
|
327
|
+
} else if (defaultSortValue !== '' && sortValueRef.value === defaultSortValue) {
|
|
328
|
+
// default sort — omit from URL
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return q
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const pushRoute = (): Promise<void> | void => {
|
|
335
|
+
if (syncingFromRoute || syncingToRoute) return
|
|
336
|
+
const next = buildQuery()
|
|
337
|
+
if (queriesEqual(route.query, next)) return
|
|
338
|
+
syncingToRoute = true
|
|
339
|
+
return router
|
|
340
|
+
.replace({ query: next })
|
|
341
|
+
.then(() => undefined)
|
|
342
|
+
.finally(() => {
|
|
343
|
+
syncingToRoute = false
|
|
344
|
+
})
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ── watchers ──────────────────────────────────────────────────────────────
|
|
348
|
+
watch(
|
|
349
|
+
() => route.query,
|
|
350
|
+
() => {
|
|
351
|
+
if (syncingToRoute) return
|
|
352
|
+
syncingFromRoute = true
|
|
353
|
+
readFromRoute()
|
|
354
|
+
syncingFromRoute = false
|
|
355
|
+
},
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
watch(
|
|
359
|
+
filterState,
|
|
360
|
+
() => {
|
|
361
|
+
pushRoute()
|
|
362
|
+
},
|
|
363
|
+
{ deep: true },
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
watch(searchRef, () => {
|
|
367
|
+
pushRoute()
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
watch(pageRef, () => {
|
|
371
|
+
pushRoute()
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
watch(sortValueRef, () => {
|
|
375
|
+
pushRoute()
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
// ── sort control ──────────────────────────────────────────────────────────
|
|
379
|
+
const sort: RouteSort = {
|
|
380
|
+
get value() {
|
|
381
|
+
return sortValueRef.value
|
|
382
|
+
},
|
|
383
|
+
get field() {
|
|
384
|
+
return sortValueRef.value.length > 1 ? sortValueRef.value.slice(1) : ''
|
|
385
|
+
},
|
|
386
|
+
get dir(): 'asc' | 'desc' | '' {
|
|
387
|
+
if (!sortValueRef.value) return ''
|
|
388
|
+
return sortValueRef.value[0] === '-' ? 'desc' : 'asc'
|
|
389
|
+
},
|
|
390
|
+
toggle(field: string) {
|
|
391
|
+
if (sortValueRef.value === `-${field}`) {
|
|
392
|
+
sortValueRef.value = `+${field}`
|
|
393
|
+
} else if (sortValueRef.value === `+${field}`) {
|
|
394
|
+
sortValueRef.value = ''
|
|
395
|
+
} else {
|
|
396
|
+
sortValueRef.value = `-${field}`
|
|
397
|
+
}
|
|
398
|
+
},
|
|
399
|
+
set(field: string, dir: 'asc' | 'desc') {
|
|
400
|
+
sortValueRef.value = `${dir === 'desc' ? '-' : '+'}${field}`
|
|
401
|
+
},
|
|
402
|
+
clear() {
|
|
403
|
+
sortValueRef.value = ''
|
|
404
|
+
},
|
|
405
|
+
iconFor(field: string): 'ascending' | 'descending' | 'sort' {
|
|
406
|
+
if (sortValueRef.value.slice(1) !== field) return 'sort'
|
|
407
|
+
return sortValueRef.value[0] === '-' ? 'descending' : 'ascending'
|
|
408
|
+
},
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ── draft control ─────────────────────────────────────────────────────────
|
|
412
|
+
const draft: RouteDraftControl<D> = {
|
|
413
|
+
state: draftState,
|
|
414
|
+
open() {
|
|
415
|
+
copyPlain(filterRaw, draftRaw, draftKeyStrings)
|
|
416
|
+
},
|
|
417
|
+
apply() {
|
|
418
|
+
copyPlain(draftRaw, filterRaw, draftKeyStrings)
|
|
419
|
+
},
|
|
420
|
+
cancel() {
|
|
421
|
+
copyPlain(filterRaw, draftRaw, draftKeyStrings)
|
|
422
|
+
},
|
|
423
|
+
reset() {
|
|
424
|
+
const defClone = cloneRecord(draftDefaults as RouteFilterRecord)
|
|
425
|
+
copyPlain(defClone as Record<string, RouteFilterValue>, draftRaw, draftKeyStrings)
|
|
426
|
+
},
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ── reset / clear ─────────────────────────────────────────────────────────
|
|
430
|
+
const reset = async () => {
|
|
431
|
+
syncingFromRoute = true
|
|
432
|
+
applyInPlace(filterState, cloneRecord(filterDefaults), filterDefaults)
|
|
433
|
+
searchRef.value = searchDefault
|
|
434
|
+
pageRef.value = pageDefault
|
|
435
|
+
sortValueRef.value = defaultSortValue
|
|
436
|
+
if (draftKeys.length > 0) {
|
|
437
|
+
copyPlain(filterRaw, draftRaw, draftKeyStrings)
|
|
438
|
+
}
|
|
439
|
+
syncingFromRoute = false
|
|
440
|
+
await pushRoute()
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const clear = async () => {
|
|
444
|
+
syncingToRoute = true
|
|
445
|
+
applyInPlace(filterState, cloneRecord(filterDefaults), filterDefaults)
|
|
446
|
+
searchRef.value = searchDefault
|
|
447
|
+
pageRef.value = pageDefault
|
|
448
|
+
sortValueRef.value = defaultSortValue
|
|
449
|
+
if (draftKeys.length > 0) {
|
|
450
|
+
copyPlain(filterRaw, draftRaw, draftKeyStrings)
|
|
451
|
+
}
|
|
452
|
+
await router.replace({ query: {} }).finally(() => {
|
|
453
|
+
syncingToRoute = false
|
|
454
|
+
})
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
return {
|
|
458
|
+
filter: filterState,
|
|
459
|
+
draft,
|
|
460
|
+
sort,
|
|
461
|
+
search: searchRef,
|
|
462
|
+
page: pageRef,
|
|
463
|
+
reset,
|
|
464
|
+
clear,
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// ---------------------------------------------------------------------------
|
|
469
|
+
// Legacy exports kept for backwards compat (re-exported under old names)
|
|
470
|
+
// ---------------------------------------------------------------------------
|
|
471
|
+
|
|
472
|
+
/** @deprecated Use `useRouteFilters` with the new schema API instead */
|
|
473
|
+
export type RouteFilterSchema = RouteFilterRecord
|
|
474
|
+
|
|
475
|
+
/** @deprecated */
|
|
476
|
+
export const parseRouteFiltersFromQuery = <T extends RouteFilterRecord>(
|
|
477
|
+
query: LocationQuery,
|
|
478
|
+
defaults: T,
|
|
479
|
+
): RouteFilterState<T> => {
|
|
480
|
+
const out = cloneRecord(defaults)
|
|
481
|
+
for (const k in defaults) {
|
|
482
|
+
out[k] = parseValue(k, query, defaults[k]) as RouteFilterState<T>[typeof k]
|
|
483
|
+
}
|
|
484
|
+
return out
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** @deprecated */
|
|
488
|
+
export const serializeRouteFiltersToQuery = <T extends RouteFilterRecord>(
|
|
489
|
+
state: RouteFilterState<T>,
|
|
490
|
+
defaults: T,
|
|
491
|
+
): LocationQueryRaw => {
|
|
492
|
+
const q: LocationQueryRaw = {}
|
|
493
|
+
for (const k in defaults) {
|
|
494
|
+
const s = serializeValue(state[k] as RouteFilterValue, defaults[k])
|
|
495
|
+
if (s !== undefined) q[k] = s
|
|
496
|
+
}
|
|
497
|
+
return q
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
export type { RouteFilterRecord as RouteFilterRefs }
|
package/src/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ import FdsButtonSecondary from './components/Buttons/FdsButtonSecondary/FdsButto
|
|
|
17
17
|
import FdsDevMode from './components/FdsDevMode/FdsDevMode.vue'
|
|
18
18
|
import FdsExitModal from './components/FdsExitModal/FdsExitModal.vue'
|
|
19
19
|
import FdsIcon from './components/FdsIcon/FdsIcon.vue'
|
|
20
|
+
import FdsDrawer from './components/FdsDrawer/FdsDrawer.vue'
|
|
20
21
|
import FdsModal from './components/FdsModal/FdsModal.vue'
|
|
21
22
|
import FdsPagination from './components/FdsPagination/FdsPagination.vue'
|
|
22
23
|
import FdsPopover from './components/FdsPopover/FdsPopover.vue'
|
|
@@ -78,6 +79,11 @@ import {
|
|
|
78
79
|
setupRouterLinkInterceptor,
|
|
79
80
|
teardownRouterLinkInterceptor,
|
|
80
81
|
} from './composables/setupRouterLinkInterceptor'
|
|
82
|
+
import {
|
|
83
|
+
parseRouteFiltersFromQuery,
|
|
84
|
+
serializeRouteFiltersToQuery,
|
|
85
|
+
useRouteFilters,
|
|
86
|
+
} from './composables/useRouteFilters'
|
|
81
87
|
import { useRouteScrollPositions } from './composables/useRouteScrollPositions'
|
|
82
88
|
import { useViewportBreakpoint } from './composables/useViewportBreakpoint'
|
|
83
89
|
import { capitalizeFirstLetter } from './helpers/capitalizeFirstLetter'
|
|
@@ -100,6 +106,7 @@ export {
|
|
|
100
106
|
FdsButtonSecondary,
|
|
101
107
|
FdsCheckbox,
|
|
102
108
|
FdsDevMode,
|
|
109
|
+
FdsDrawer,
|
|
103
110
|
FdsExitModal,
|
|
104
111
|
FdsH1,
|
|
105
112
|
FdsH2,
|
|
@@ -161,6 +168,9 @@ export {
|
|
|
161
168
|
useFileSizeValidation,
|
|
162
169
|
useFileValidation,
|
|
163
170
|
useIsPid,
|
|
171
|
+
parseRouteFiltersFromQuery,
|
|
172
|
+
serializeRouteFiltersToQuery,
|
|
173
|
+
useRouteFilters,
|
|
164
174
|
useRouteScrollPositions,
|
|
165
175
|
useTreeState,
|
|
166
176
|
useViewportBreakpoint,
|
|
@@ -211,6 +221,7 @@ const FdsVueCorePlugin: Plugin = {
|
|
|
211
221
|
app.component('FdsSticker', FdsSticker)
|
|
212
222
|
app.component('FdsTabs', FdsTabs)
|
|
213
223
|
app.component('FdsTabsItem', FdsTabsItem)
|
|
224
|
+
app.component('FdsDrawer', FdsDrawer)
|
|
214
225
|
app.component('FdsModal', FdsModal)
|
|
215
226
|
app.component('FdsPagination', FdsPagination)
|
|
216
227
|
app.component('FdsPopover', FdsPopover)
|
|
@@ -343,6 +354,8 @@ export type { FdsMetaProps } from './components/Typography/FdsMeta/types'
|
|
|
343
354
|
// Other component types
|
|
344
355
|
export type { FdsIconName, FdsIconProps } from './components/FdsIcon/types'
|
|
345
356
|
|
|
357
|
+
export type { FdsDrawerProps } from './components/FdsDrawer/types'
|
|
358
|
+
|
|
346
359
|
export type { FdsModalProps } from './components/FdsModal/types'
|
|
347
360
|
|
|
348
361
|
export type { FdsPaginationProps } from './components/FdsPagination/types'
|
|
@@ -366,6 +379,19 @@ export type { FdsWizardGoPayload, FdsWizardProps, FdsWizardRoute } from './compo
|
|
|
366
379
|
// Composable types
|
|
367
380
|
export type { BreakpointKey } from './composables/useViewportBreakpoint'
|
|
368
381
|
|
|
382
|
+
export type {
|
|
383
|
+
RouteFilterRecord,
|
|
384
|
+
RouteFilterRefs,
|
|
385
|
+
RouteFilterSchema,
|
|
386
|
+
RouteFilterState,
|
|
387
|
+
RouteFilterValue,
|
|
388
|
+
RouteDraftControl,
|
|
389
|
+
RouteSort,
|
|
390
|
+
RouteSortDefault,
|
|
391
|
+
UseRouteFiltersSchema,
|
|
392
|
+
UseRouteFiltersReturn,
|
|
393
|
+
} from './composables/useRouteFilters'
|
|
394
|
+
|
|
369
395
|
export type { DevModeEnvironmentKey } from './composables/useDevModeEnvironment'
|
|
370
396
|
|
|
371
397
|
// DevMode types
|
package/src/lang/en.json
CHANGED
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"FdsInput.today": "(today)",
|
|
40
40
|
"FdsListHeading.loading": "Loading...",
|
|
41
41
|
"FdsListHeading.separator": "of",
|
|
42
|
+
"FdsDrawer.closeDrawer": "Close drawer",
|
|
42
43
|
"FdsModal.closeModal": "Close modal window",
|
|
43
44
|
"FdsPagination.currentPage": "Current page",
|
|
44
45
|
"FdsPagination.firstPage": "Go to first page",
|
package/src/lang/sv.json
CHANGED
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"FdsInput.today": "(idag)",
|
|
40
40
|
"FdsListHeading.loading": "Laddar...",
|
|
41
41
|
"FdsListHeading.separator": "av",
|
|
42
|
+
"FdsDrawer.closeDrawer": "Stäng sidopanel",
|
|
42
43
|
"FdsModal.closeModal": "Stäng dialogruta",
|
|
43
44
|
"FdsPagination.currentPage": "Aktuell sida",
|
|
44
45
|
"FdsPagination.firstPage": "Gå till första sidan",
|
package/src/slot-styles.css
CHANGED
|
@@ -100,3 +100,43 @@ input[type='search']::-ms-clear {
|
|
|
100
100
|
.fds-tabs-item--disabled {
|
|
101
101
|
@apply cursor-not-allowed pointer-events-none opacity-35 select-none;
|
|
102
102
|
}
|
|
103
|
+
|
|
104
|
+
/* FdsDrawer slide transitions (consumers import slot-styles.css, not fds-vue-core.css) */
|
|
105
|
+
.fds-drawer-enter-active,
|
|
106
|
+
.fds-drawer-leave-active {
|
|
107
|
+
transition: visibility 280ms;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.fds-drawer-enter-active .fds-drawer-inner {
|
|
111
|
+
transition: transform 280ms cubic-bezier(0.22, 0.61, 0.36, 1);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.fds-drawer-leave-active .fds-drawer-inner {
|
|
115
|
+
transition: transform 180ms cubic-bezier(0.4, 0, 1, 1);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.fds-drawer-enter-from .fds-drawer-inner,
|
|
119
|
+
.fds-drawer-leave-to .fds-drawer-inner {
|
|
120
|
+
transform: translateY(100%);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.fds-drawer-enter-active .fds-drawer-backdrop,
|
|
124
|
+
.fds-drawer-leave-active .fds-drawer-backdrop {
|
|
125
|
+
transition: opacity 280ms ease;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.fds-drawer-leave-active .fds-drawer-backdrop {
|
|
129
|
+
transition-duration: 180ms;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.fds-drawer-enter-from .fds-drawer-backdrop,
|
|
133
|
+
.fds-drawer-leave-to .fds-drawer-backdrop {
|
|
134
|
+
opacity: 0;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
@media (min-width: 588px) {
|
|
138
|
+
.fds-drawer-enter-from .fds-drawer-inner,
|
|
139
|
+
.fds-drawer-leave-to .fds-drawer-inner {
|
|
140
|
+
transform: translateX(100%);
|
|
141
|
+
}
|
|
142
|
+
}
|
package/src/style.css
CHANGED
|
@@ -49,7 +49,11 @@
|
|
|
49
49
|
}
|
|
50
50
|
.link,
|
|
51
51
|
.html a {
|
|
52
|
-
@apply text-blue-600 hover:text-blue-700
|
|
52
|
+
@apply text-blue-600 hover:text-blue-700 underline focus-visible:outline-dashed focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-blue-500 cursor-pointer;
|
|
53
|
+
}
|
|
54
|
+
.link:active,
|
|
55
|
+
.html a:active {
|
|
56
|
+
@apply text-blue-800;
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
.capitalize-first::first-letter {
|
|
@@ -87,3 +91,43 @@
|
|
|
87
91
|
animation: spin-reverse 1s linear infinite;
|
|
88
92
|
}
|
|
89
93
|
}
|
|
94
|
+
|
|
95
|
+
/* FdsDrawer slide transitions (global, outside layers so consumers always get them) */
|
|
96
|
+
.fds-drawer-enter-active,
|
|
97
|
+
.fds-drawer-leave-active {
|
|
98
|
+
transition: visibility 280ms;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.fds-drawer-enter-active .fds-drawer-inner {
|
|
102
|
+
transition: transform 280ms cubic-bezier(0.22, 0.61, 0.36, 1);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.fds-drawer-leave-active .fds-drawer-inner {
|
|
106
|
+
transition: transform 180ms cubic-bezier(0.4, 0, 1, 1);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.fds-drawer-enter-from .fds-drawer-inner,
|
|
110
|
+
.fds-drawer-leave-to .fds-drawer-inner {
|
|
111
|
+
transform: translateY(100%);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.fds-drawer-enter-active .fds-drawer-backdrop,
|
|
115
|
+
.fds-drawer-leave-active .fds-drawer-backdrop {
|
|
116
|
+
transition: opacity 280ms ease;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.fds-drawer-leave-active .fds-drawer-backdrop {
|
|
120
|
+
transition-duration: 180ms;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.fds-drawer-enter-from .fds-drawer-backdrop,
|
|
124
|
+
.fds-drawer-leave-to .fds-drawer-backdrop {
|
|
125
|
+
opacity: 0;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
@media (min-width: 588px) {
|
|
129
|
+
.fds-drawer-enter-from .fds-drawer-inner,
|
|
130
|
+
.fds-drawer-leave-to .fds-drawer-inner {
|
|
131
|
+
transform: translateX(100%);
|
|
132
|
+
}
|
|
133
|
+
}
|