@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.
- package/README.md +102 -0
- package/package.json +27 -0
- package/src/components/UdsAccordion.vue +82 -0
- package/src/components/UdsAlert.vue +61 -0
- package/src/components/UdsAvatar.vue +59 -0
- package/src/components/UdsBadge.vue +48 -0
- package/src/components/UdsBreadcrumb.vue +73 -0
- package/src/components/UdsButton.vue +43 -0
- package/src/components/UdsCard.vue +49 -0
- package/src/components/UdsCheckbox.vue +56 -0
- package/src/components/UdsCodeBlock.vue +86 -0
- package/src/components/UdsCommandPalette.vue +144 -0
- package/src/components/UdsDataTable.vue +142 -0
- package/src/components/UdsDatePicker.vue +69 -0
- package/src/components/UdsDropdown.vue +132 -0
- package/src/components/UdsFileUpload.vue +148 -0
- package/src/components/UdsFooter.vue +39 -0
- package/src/components/UdsHero.vue +44 -0
- package/src/components/UdsInput.vue +95 -0
- package/src/components/UdsModal.vue +114 -0
- package/src/components/UdsNavbar.vue +57 -0
- package/src/components/UdsPagination.vue +96 -0
- package/src/components/UdsPricing.vue +58 -0
- package/src/components/UdsProgress.vue +92 -0
- package/src/components/UdsRadio.vue +56 -0
- package/src/components/UdsSelect.vue +84 -0
- package/src/components/UdsSideNav.vue +102 -0
- package/src/components/UdsSkeleton.vue +51 -0
- package/src/components/UdsSocialProof.vue +58 -0
- package/src/components/UdsTabs.vue +106 -0
- package/src/components/UdsTestimonial.vue +57 -0
- package/src/components/UdsToast.vue +70 -0
- package/src/components/UdsToggle.vue +60 -0
- package/src/components/UdsTooltip.vue +62 -0
- 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' ? '▲' : '▼' }}
|
|
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>
|