@mkatogui/uds-vue 0.2.1

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 (35) hide show
  1. package/README.md +102 -0
  2. package/package.json +27 -0
  3. package/src/components/UdsAccordion.vue +82 -0
  4. package/src/components/UdsAlert.vue +61 -0
  5. package/src/components/UdsAvatar.vue +59 -0
  6. package/src/components/UdsBadge.vue +48 -0
  7. package/src/components/UdsBreadcrumb.vue +73 -0
  8. package/src/components/UdsButton.vue +43 -0
  9. package/src/components/UdsCard.vue +49 -0
  10. package/src/components/UdsCheckbox.vue +56 -0
  11. package/src/components/UdsCodeBlock.vue +86 -0
  12. package/src/components/UdsCommandPalette.vue +144 -0
  13. package/src/components/UdsDataTable.vue +142 -0
  14. package/src/components/UdsDatePicker.vue +69 -0
  15. package/src/components/UdsDropdown.vue +132 -0
  16. package/src/components/UdsFileUpload.vue +148 -0
  17. package/src/components/UdsFooter.vue +39 -0
  18. package/src/components/UdsHero.vue +44 -0
  19. package/src/components/UdsInput.vue +95 -0
  20. package/src/components/UdsModal.vue +114 -0
  21. package/src/components/UdsNavbar.vue +57 -0
  22. package/src/components/UdsPagination.vue +96 -0
  23. package/src/components/UdsPricing.vue +58 -0
  24. package/src/components/UdsProgress.vue +92 -0
  25. package/src/components/UdsRadio.vue +56 -0
  26. package/src/components/UdsSelect.vue +84 -0
  27. package/src/components/UdsSideNav.vue +102 -0
  28. package/src/components/UdsSkeleton.vue +51 -0
  29. package/src/components/UdsSocialProof.vue +58 -0
  30. package/src/components/UdsTabs.vue +106 -0
  31. package/src/components/UdsTestimonial.vue +57 -0
  32. package/src/components/UdsToast.vue +70 -0
  33. package/src/components/UdsToggle.vue +60 -0
  34. package/src/components/UdsTooltip.vue +62 -0
  35. package/src/index.ts +32 -0
@@ -0,0 +1,144 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch, onMounted, onUnmounted, nextTick, useId } from 'vue'
3
+
4
+ interface Action {
5
+ id: string
6
+ label: string
7
+ group?: string
8
+ shortcut?: string
9
+ }
10
+
11
+ interface Props {
12
+ open?: boolean
13
+ actions?: Action[]
14
+ placeholder?: string
15
+ groups?: string[]
16
+ recentLimit?: number
17
+ }
18
+
19
+ const props = withDefaults(defineProps<Props>(), {
20
+ actions: () => [],
21
+ placeholder: 'Type a command...',
22
+ groups: () => [],
23
+ recentLimit: 5,
24
+ })
25
+
26
+ const emit = defineEmits<{
27
+ select: [action: Action]
28
+ close: []
29
+ }>()
30
+
31
+ const query = ref('')
32
+ const activeIndex = ref(0)
33
+ const inputRef = ref<HTMLInputElement | null>(null)
34
+ const listId = useId()
35
+
36
+ const classes = computed(() =>
37
+ [
38
+ 'uds-command-palette',
39
+ props.open && 'uds-command-palette--open',
40
+ ]
41
+ .filter(Boolean)
42
+ .join(' ')
43
+ )
44
+
45
+ const filteredActions = computed(() => {
46
+ if (!query.value) return props.actions
47
+ const q = query.value.toLowerCase()
48
+ return props.actions.filter((a) => a.label.toLowerCase().includes(q))
49
+ })
50
+
51
+ function selectAction(action: Action) {
52
+ emit('select', action)
53
+ query.value = ''
54
+ }
55
+
56
+ function handleKeydown(e: KeyboardEvent) {
57
+ if (e.key === 'ArrowDown') {
58
+ e.preventDefault()
59
+ activeIndex.value = (activeIndex.value + 1) % filteredActions.value.length
60
+ } else if (e.key === 'ArrowUp') {
61
+ e.preventDefault()
62
+ activeIndex.value = (activeIndex.value - 1 + filteredActions.value.length) % filteredActions.value.length
63
+ } else if (e.key === 'Enter') {
64
+ e.preventDefault()
65
+ if (filteredActions.value[activeIndex.value]) {
66
+ selectAction(filteredActions.value[activeIndex.value])
67
+ }
68
+ } else if (e.key === 'Escape') {
69
+ emit('close')
70
+ }
71
+ }
72
+
73
+ function handleGlobalKeydown(e: KeyboardEvent) {
74
+ if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
75
+ e.preventDefault()
76
+ if (!props.open) {
77
+ // Parent controls open state
78
+ } else {
79
+ emit('close')
80
+ }
81
+ }
82
+ }
83
+
84
+ watch(() => props.open, async (isOpen) => {
85
+ if (isOpen) {
86
+ await nextTick()
87
+ inputRef.value?.focus()
88
+ activeIndex.value = 0
89
+ }
90
+ })
91
+
92
+ watch(query, () => {
93
+ activeIndex.value = 0
94
+ })
95
+
96
+ onMounted(() => {
97
+ document.addEventListener('keydown', handleGlobalKeydown)
98
+ })
99
+
100
+ onUnmounted(() => {
101
+ document.removeEventListener('keydown', handleGlobalKeydown)
102
+ })
103
+ </script>
104
+
105
+ <template>
106
+ <Teleport to="body">
107
+ <div v-if="open" class="uds-command-palette-overlay" role="presentation" @click.self="emit('close')">
108
+ <div :class="classes" role="combobox" aria-expanded="true" :aria-owns="listId">
109
+ <div class="uds-command-palette__input-wrapper">
110
+ <input
111
+ ref="inputRef"
112
+ v-model="query"
113
+ class="uds-command-palette__input"
114
+ :placeholder="placeholder"
115
+ role="searchbox"
116
+ aria-autocomplete="list"
117
+ :aria-controls="listId"
118
+ :aria-activedescendant="filteredActions.length ? `cmd-item-${activeIndex}` : undefined"
119
+ @keydown="handleKeydown"
120
+ />
121
+ </div>
122
+ <ul :id="listId" class="uds-command-palette__list" role="listbox">
123
+ <li
124
+ v-for="(action, i) in filteredActions"
125
+ :key="action.id"
126
+ :id="`cmd-item-${i}`"
127
+ class="uds-command-palette__item"
128
+ :class="{ 'uds-command-palette__item--active': activeIndex === i }"
129
+ role="option"
130
+ :aria-selected="activeIndex === i"
131
+ @click="selectAction(action)"
132
+ @mouseenter="activeIndex = i"
133
+ >
134
+ <span class="uds-command-palette__label">{{ action.label }}</span>
135
+ <kbd v-if="action.shortcut" class="uds-command-palette__shortcut">{{ action.shortcut }}</kbd>
136
+ </li>
137
+ <li v-if="filteredActions.length === 0" class="uds-command-palette__empty" role="option" aria-disabled="true">
138
+ No results found
139
+ </li>
140
+ </ul>
141
+ </div>
142
+ </div>
143
+ </Teleport>
144
+ </template>
@@ -0,0 +1,142 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from 'vue'
3
+
4
+ interface Column {
5
+ key: string
6
+ label: string
7
+ sortable?: boolean
8
+ }
9
+
10
+ interface Props {
11
+ variant?: 'basic' | 'sortable' | 'selectable' | 'expandable'
12
+ density?: 'compact' | 'default' | 'comfortable'
13
+ columns?: Column[]
14
+ data?: Record<string, unknown>[]
15
+ sortable?: boolean
16
+ selectable?: boolean
17
+ }
18
+
19
+ const props = withDefaults(defineProps<Props>(), {
20
+ variant: 'basic',
21
+ density: 'default',
22
+ columns: () => [],
23
+ data: () => [],
24
+ })
25
+
26
+ const emit = defineEmits<{
27
+ sort: [key: string, direction: 'asc' | 'desc']
28
+ select: [selectedRows: number[]]
29
+ }>()
30
+
31
+ const sortKey = ref('')
32
+ const sortDir = ref<'asc' | 'desc'>('asc')
33
+ const selectedRows = ref<Set<number>>(new Set())
34
+
35
+ const classes = computed(() =>
36
+ [
37
+ 'uds-data-table',
38
+ `uds-data-table--${props.variant}`,
39
+ `uds-data-table--${props.density}`,
40
+ ]
41
+ .filter(Boolean)
42
+ .join(' ')
43
+ )
44
+
45
+ const sortedData = computed(() => {
46
+ if (!sortKey.value) return props.data
47
+ return [...props.data].sort((a, b) => {
48
+ const aVal = String(a[sortKey.value] ?? '')
49
+ const bVal = String(b[sortKey.value] ?? '')
50
+ const cmp = aVal.localeCompare(bVal)
51
+ return sortDir.value === 'asc' ? cmp : -cmp
52
+ })
53
+ })
54
+
55
+ function handleSort(key: string) {
56
+ if (sortKey.value === key) {
57
+ sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
58
+ } else {
59
+ sortKey.value = key
60
+ sortDir.value = 'asc'
61
+ }
62
+ emit('sort', sortKey.value, sortDir.value)
63
+ }
64
+
65
+ function toggleRow(index: number) {
66
+ const next = new Set(selectedRows.value)
67
+ if (next.has(index)) {
68
+ next.delete(index)
69
+ } else {
70
+ next.add(index)
71
+ }
72
+ selectedRows.value = next
73
+ emit('select', Array.from(next))
74
+ }
75
+
76
+ function toggleAll() {
77
+ if (selectedRows.value.size === props.data.length) {
78
+ selectedRows.value = new Set()
79
+ } else {
80
+ selectedRows.value = new Set(props.data.map((_, i) => i))
81
+ }
82
+ emit('select', Array.from(selectedRows.value))
83
+ }
84
+ </script>
85
+
86
+ <template>
87
+ <div :class="classes">
88
+ <table class="uds-data-table__table" role="table">
89
+ <thead>
90
+ <tr>
91
+ <th v-if="selectable || variant === 'selectable'" class="uds-data-table__select-all">
92
+ <input
93
+ type="checkbox"
94
+ :checked="selectedRows.size === data.length && data.length > 0"
95
+ aria-label="Select all rows"
96
+ @change="toggleAll"
97
+ />
98
+ </th>
99
+ <th
100
+ v-for="col in columns"
101
+ :key="col.key"
102
+ scope="col"
103
+ :aria-sort="sortKey === col.key ? (sortDir === 'asc' ? 'ascending' : 'descending') : undefined"
104
+ :class="col.sortable || sortable ? 'uds-data-table__th--sortable' : ''"
105
+ >
106
+ <button
107
+ v-if="col.sortable || sortable"
108
+ class="uds-data-table__sort-btn"
109
+ @click="handleSort(col.key)"
110
+ >
111
+ {{ col.label }}
112
+ <span v-if="sortKey === col.key" aria-hidden="true">
113
+ {{ sortDir === 'asc' ? '&#9650;' : '&#9660;' }}
114
+ </span>
115
+ </button>
116
+ <template v-else>{{ col.label }}</template>
117
+ </th>
118
+ </tr>
119
+ </thead>
120
+ <tbody>
121
+ <tr v-for="(row, i) in sortedData" :key="i" :class="selectedRows.has(i) ? 'uds-data-table__row--selected' : ''">
122
+ <td v-if="selectable || variant === 'selectable'">
123
+ <input
124
+ type="checkbox"
125
+ :checked="selectedRows.has(i)"
126
+ :aria-label="`Select row ${i + 1}`"
127
+ @change="toggleRow(i)"
128
+ />
129
+ </td>
130
+ <td v-for="col in columns" :key="col.key">
131
+ <slot :name="`cell-${col.key}`" :row="row" :value="row[col.key]">
132
+ {{ row[col.key] }}
133
+ </slot>
134
+ </td>
135
+ </tr>
136
+ </tbody>
137
+ </table>
138
+ <div v-if="data.length === 0" class="uds-data-table__empty">
139
+ <slot name="empty">No data available</slot>
140
+ </div>
141
+ </div>
142
+ </template>
@@ -0,0 +1,69 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, useId } from 'vue'
3
+
4
+ interface Props {
5
+ variant?: 'single' | 'range' | 'with-time'
6
+ size?: 'md' | 'lg'
7
+ modelValue?: string
8
+ min?: string
9
+ max?: string
10
+ disabled?: boolean
11
+ label?: string
12
+ locale?: string
13
+ }
14
+
15
+ const props = withDefaults(defineProps<Props>(), {
16
+ variant: 'single',
17
+ size: 'md',
18
+ locale: 'en-US',
19
+ })
20
+
21
+ const emit = defineEmits<{
22
+ 'update:modelValue': [value: string]
23
+ }>()
24
+
25
+ const isOpen = ref(false)
26
+ const inputId = useId()
27
+
28
+ const classes = computed(() =>
29
+ [
30
+ 'uds-date-picker',
31
+ `uds-date-picker--${props.variant}`,
32
+ `uds-date-picker--${props.size}`,
33
+ isOpen.value && 'uds-date-picker--open',
34
+ props.disabled && 'uds-date-picker--disabled',
35
+ ]
36
+ .filter(Boolean)
37
+ .join(' ')
38
+ )
39
+
40
+ const inputType = computed(() => {
41
+ if (props.variant === 'with-time') return 'datetime-local'
42
+ return 'date'
43
+ })
44
+
45
+ function handleInput(e: Event) {
46
+ const target = e.target as HTMLInputElement
47
+ emit('update:modelValue', target.value)
48
+ }
49
+ </script>
50
+
51
+ <template>
52
+ <div :class="classes">
53
+ <label v-if="label" :for="inputId" class="uds-date-picker__label">{{ label }}</label>
54
+ <input
55
+ :id="inputId"
56
+ class="uds-date-picker__input"
57
+ :type="inputType"
58
+ :value="modelValue"
59
+ :min="min"
60
+ :max="max"
61
+ :disabled="disabled"
62
+ :aria-label="label || 'Select date'"
63
+ role="grid"
64
+ @input="handleInput"
65
+ @focus="isOpen = true"
66
+ @blur="isOpen = false"
67
+ />
68
+ </div>
69
+ </template>
@@ -0,0 +1,132 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, onMounted, onUnmounted, useId } from 'vue'
3
+
4
+ interface DropdownItem {
5
+ label: string
6
+ value: string
7
+ disabled?: boolean
8
+ icon?: string
9
+ }
10
+
11
+ interface Props {
12
+ variant?: 'action' | 'context' | 'nav-sub'
13
+ size?: 'sm' | 'md' | 'lg'
14
+ items?: DropdownItem[]
15
+ position?: 'bottom-start' | 'bottom-end'
16
+ }
17
+
18
+ const props = withDefaults(defineProps<Props>(), {
19
+ variant: 'action',
20
+ size: 'md',
21
+ items: () => [],
22
+ position: 'bottom-start',
23
+ })
24
+
25
+ const emit = defineEmits<{
26
+ select: [value: string]
27
+ }>()
28
+
29
+ const isOpen = ref(false)
30
+ const menuId = useId()
31
+ const activeIndex = ref(-1)
32
+ const menuRef = ref<HTMLElement | null>(null)
33
+ const itemRefs = ref<(HTMLElement | null)[]>([])
34
+
35
+ const classes = computed(() =>
36
+ [
37
+ 'uds-dropdown',
38
+ `uds-dropdown--${props.variant}`,
39
+ `uds-dropdown--${props.size}`,
40
+ isOpen.value && 'uds-dropdown--open',
41
+ ]
42
+ .filter(Boolean)
43
+ .join(' ')
44
+ )
45
+
46
+ function toggle() {
47
+ isOpen.value = !isOpen.value
48
+ if (isOpen.value) {
49
+ activeIndex.value = 0
50
+ }
51
+ }
52
+
53
+ function close() {
54
+ isOpen.value = false
55
+ activeIndex.value = -1
56
+ }
57
+
58
+ function selectItem(item: DropdownItem) {
59
+ if (item.disabled) return
60
+ emit('select', item.value)
61
+ close()
62
+ }
63
+
64
+ function handleKeydown(e: KeyboardEvent) {
65
+ if (!isOpen.value) return
66
+ const enabledItems = props.items.filter((i) => !i.disabled)
67
+
68
+ if (e.key === 'ArrowDown') {
69
+ e.preventDefault()
70
+ activeIndex.value = (activeIndex.value + 1) % enabledItems.length
71
+ itemRefs.value[activeIndex.value]?.focus()
72
+ } else if (e.key === 'ArrowUp') {
73
+ e.preventDefault()
74
+ activeIndex.value = (activeIndex.value - 1 + enabledItems.length) % enabledItems.length
75
+ itemRefs.value[activeIndex.value]?.focus()
76
+ } else if (e.key === 'Escape') {
77
+ close()
78
+ } else if (e.key === 'Enter' || e.key === ' ') {
79
+ e.preventDefault()
80
+ if (activeIndex.value >= 0) {
81
+ selectItem(enabledItems[activeIndex.value])
82
+ }
83
+ }
84
+ }
85
+
86
+ function handleClickOutside(e: MouseEvent) {
87
+ if (menuRef.value && !menuRef.value.contains(e.target as Node)) {
88
+ close()
89
+ }
90
+ }
91
+
92
+ onMounted(() => {
93
+ document.addEventListener('click', handleClickOutside)
94
+ })
95
+
96
+ onUnmounted(() => {
97
+ document.removeEventListener('click', handleClickOutside)
98
+ })
99
+ </script>
100
+
101
+ <template>
102
+ <div ref="menuRef" :class="classes" @keydown="handleKeydown">
103
+ <button
104
+ class="uds-dropdown__trigger"
105
+ :aria-haspopup="true"
106
+ :aria-expanded="isOpen"
107
+ :aria-controls="menuId"
108
+ @click="toggle"
109
+ >
110
+ <slot name="trigger">Menu</slot>
111
+ </button>
112
+ <ul
113
+ v-if="isOpen"
114
+ :id="menuId"
115
+ class="uds-dropdown__menu"
116
+ role="menu"
117
+ >
118
+ <li
119
+ v-for="(item, i) in items"
120
+ :key="item.value"
121
+ :ref="(el) => { itemRefs[i] = el as HTMLElement }"
122
+ class="uds-dropdown__item"
123
+ role="menuitem"
124
+ :tabindex="activeIndex === i ? 0 : -1"
125
+ :aria-disabled="item.disabled || undefined"
126
+ @click="selectItem(item)"
127
+ >
128
+ {{ item.label }}
129
+ </li>
130
+ </ul>
131
+ </div>
132
+ </template>
@@ -0,0 +1,148 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from 'vue'
3
+
4
+ interface Props {
5
+ variant?: 'dropzone' | 'button' | 'avatar-upload'
6
+ size?: 'sm' | 'md' | 'lg'
7
+ accept?: string
8
+ maxSize?: number
9
+ maxFiles?: number
10
+ multiple?: boolean
11
+ disabled?: boolean
12
+ }
13
+
14
+ const props = withDefaults(defineProps<Props>(), {
15
+ variant: 'dropzone',
16
+ size: 'md',
17
+ maxFiles: 1,
18
+ })
19
+
20
+ const emit = defineEmits<{
21
+ upload: [files: File[]]
22
+ remove: [file: File]
23
+ error: [message: string]
24
+ }>()
25
+
26
+ const dragOver = ref(false)
27
+ const uploading = ref(false)
28
+ const fileInputRef = ref<HTMLInputElement | null>(null)
29
+
30
+ const classes = computed(() =>
31
+ [
32
+ 'uds-file-upload',
33
+ `uds-file-upload--${props.variant}`,
34
+ `uds-file-upload--${props.size}`,
35
+ dragOver.value && 'uds-file-upload--drag-over',
36
+ uploading.value && 'uds-file-upload--uploading',
37
+ props.disabled && 'uds-file-upload--disabled',
38
+ ]
39
+ .filter(Boolean)
40
+ .join(' ')
41
+ )
42
+
43
+ function validateFiles(files: File[]): File[] {
44
+ return files.filter((file) => {
45
+ if (props.maxSize && file.size > props.maxSize) {
46
+ emit('error', `File "${file.name}" exceeds max size`)
47
+ return false
48
+ }
49
+ return true
50
+ })
51
+ }
52
+
53
+ function handleFiles(fileList: FileList | null) {
54
+ if (!fileList) return
55
+ let files = Array.from(fileList)
56
+ if (props.maxFiles) {
57
+ files = files.slice(0, props.maxFiles)
58
+ }
59
+ const valid = validateFiles(files)
60
+ if (valid.length) {
61
+ emit('upload', valid)
62
+ }
63
+ }
64
+
65
+ function handleDrop(e: DragEvent) {
66
+ e.preventDefault()
67
+ dragOver.value = false
68
+ if (props.disabled) return
69
+ handleFiles(e.dataTransfer?.files || null)
70
+ }
71
+
72
+ function handleDragOver(e: DragEvent) {
73
+ e.preventDefault()
74
+ if (!props.disabled) dragOver.value = true
75
+ }
76
+
77
+ function handleDragLeave() {
78
+ dragOver.value = false
79
+ }
80
+
81
+ function openFileDialog() {
82
+ fileInputRef.value?.click()
83
+ }
84
+
85
+ function handleInputChange(e: Event) {
86
+ const target = e.target as HTMLInputElement
87
+ handleFiles(target.files)
88
+ target.value = ''
89
+ }
90
+ </script>
91
+
92
+ <template>
93
+ <div :class="classes">
94
+ <input
95
+ ref="fileInputRef"
96
+ type="file"
97
+ class="uds-file-upload__input"
98
+ :accept="accept"
99
+ :multiple="multiple"
100
+ :disabled="disabled"
101
+ aria-label="Upload file"
102
+ @change="handleInputChange"
103
+ />
104
+
105
+ <div
106
+ v-if="variant === 'dropzone'"
107
+ class="uds-file-upload__dropzone"
108
+ role="button"
109
+ tabindex="0"
110
+ :aria-disabled="disabled || undefined"
111
+ aria-live="polite"
112
+ @drop="handleDrop"
113
+ @dragover="handleDragOver"
114
+ @dragleave="handleDragLeave"
115
+ @click="openFileDialog"
116
+ @keydown.enter="openFileDialog"
117
+ @keydown.space.prevent="openFileDialog"
118
+ >
119
+ <slot>
120
+ <p class="uds-file-upload__text">Drag and drop files here, or click to browse</p>
121
+ </slot>
122
+ </div>
123
+
124
+ <button
125
+ v-else-if="variant === 'button'"
126
+ class="uds-file-upload__button"
127
+ :disabled="disabled"
128
+ @click="openFileDialog"
129
+ >
130
+ <slot>Choose file</slot>
131
+ </button>
132
+
133
+ <div
134
+ v-else-if="variant === 'avatar-upload'"
135
+ class="uds-file-upload__avatar"
136
+ role="button"
137
+ tabindex="0"
138
+ :aria-disabled="disabled || undefined"
139
+ @click="openFileDialog"
140
+ @keydown.enter="openFileDialog"
141
+ @keydown.space.prevent="openFileDialog"
142
+ >
143
+ <slot>
144
+ <span class="uds-file-upload__avatar-placeholder" aria-hidden="true">+</span>
145
+ </slot>
146
+ </div>
147
+ </div>
148
+ </template>
@@ -0,0 +1,39 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+
4
+ interface Props {
5
+ variant?: 'simple' | 'multi-column' | 'newsletter' | 'mega-footer'
6
+ size?: 'standard' | 'compact'
7
+ copyright?: string
8
+ }
9
+
10
+ const props = withDefaults(defineProps<Props>(), {
11
+ variant: 'simple',
12
+ size: 'standard',
13
+ })
14
+
15
+ const classes = computed(() =>
16
+ [
17
+ 'uds-footer',
18
+ `uds-footer--${props.variant}`,
19
+ `uds-footer--${props.size}`,
20
+ ]
21
+ .filter(Boolean)
22
+ .join(' ')
23
+ )
24
+ </script>
25
+
26
+ <template>
27
+ <footer :class="classes" aria-label="Site footer">
28
+ <nav class="uds-footer__columns" aria-label="Footer navigation">
29
+ <slot />
30
+ </nav>
31
+ <div v-if="variant === 'newsletter'" class="uds-footer__newsletter">
32
+ <slot name="newsletter" />
33
+ </div>
34
+ <div class="uds-footer__legal">
35
+ <slot name="legal" />
36
+ <p v-if="copyright" class="uds-footer__copyright">{{ copyright }}</p>
37
+ </div>
38
+ </footer>
39
+ </template>