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.
@@ -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",
@@ -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 active:text-blue-800 underline focus-visible:outline-dashed focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-blue-500 cursor-pointer;
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
+ }