@mkatogui/uds-vue 0.2.1 → 0.5.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @mkatogui/uds-vue
2
2
 
3
- Vue 3 Composition API components for the Universal Design System. 32 accessible, themeable components built with `<script setup lang="ts">` and `defineProps`.
3
+ Vue 3 Composition API components for the Universal Design System. 43 accessible, themeable components built with `<script setup lang="ts">` and `defineProps`.
4
4
 
5
5
  ## Installation
6
6
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mkatogui/uds-vue",
3
- "version": "0.2.1",
4
- "description": "Vue 3 components for Universal Design System — 31 accessible, themeable components",
3
+ "version": "0.5.0",
4
+ "description": "Vue 3 components for Universal Design System — 43 accessible, themeable components",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
7
7
  "types": "dist/index.d.ts",
@@ -0,0 +1,28 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+
4
+ interface Props {
5
+ padding?: string | number
6
+ margin?: string | number
7
+ class?: string
8
+ }
9
+
10
+ const props = defineProps<Props>()
11
+
12
+ const classes = computed(() => ['uds-box', props.class].filter(Boolean).join(' '))
13
+
14
+ const style = computed(() => {
15
+ const s: Record<string, string> = {}
16
+ if (props.padding != null)
17
+ s.padding = typeof props.padding === 'number' ? `${props.padding}px` : `var(--space-${props.padding}, ${props.padding})`
18
+ if (props.margin != null)
19
+ s.margin = typeof props.margin === 'number' ? `${props.margin}px` : `var(--space-${props.margin}, ${props.margin})`
20
+ return s
21
+ })
22
+ </script>
23
+
24
+ <template>
25
+ <div :class="classes" :style="style">
26
+ <slot />
27
+ </div>
28
+ </template>
@@ -0,0 +1,113 @@
1
+ <script setup lang="ts">
2
+ import { ref, watch, onUnmounted } from 'vue'
3
+
4
+ interface Props {
5
+ items: unknown[]
6
+ autoPlay?: boolean
7
+ interval?: number
8
+ showDots?: boolean
9
+ showArrows?: boolean
10
+ ariaLabel?: string
11
+ }
12
+
13
+ const props = withDefaults(defineProps<Props>(), {
14
+ autoPlay: false,
15
+ interval: 5000,
16
+ showDots: true,
17
+ showArrows: true,
18
+ ariaLabel: 'Content carousel',
19
+ })
20
+
21
+ const emit = defineEmits<{
22
+ slideChange: [index: number]
23
+ }>()
24
+
25
+ const current = ref(0)
26
+ const isPaused = ref(false)
27
+ let timer: ReturnType<typeof setInterval> | null = null
28
+
29
+ function goTo(index: number) {
30
+ const next = Math.max(0, Math.min(index, props.items.length - 1))
31
+ current.value = next
32
+ emit('slideChange', next)
33
+ }
34
+
35
+ function next() {
36
+ goTo(current.value + 1)
37
+ }
38
+ function prev() {
39
+ goTo(current.value - 1)
40
+ }
41
+
42
+ watch([() => props.autoPlay, isPaused], ([autoPlay, paused]) => {
43
+ if (timer) clearInterval(timer)
44
+ if (autoPlay && !paused && props.items.length > 1) {
45
+ timer = setInterval(() => {
46
+ current.value = (current.value + 1) % props.items.length
47
+ emit('slideChange', current.value)
48
+ }, props.interval)
49
+ }
50
+ return () => { if (timer) clearInterval(timer) }
51
+ })
52
+
53
+ onUnmounted(() => { if (timer) clearInterval(timer) })
54
+ </script>
55
+
56
+ <template>
57
+ <section
58
+ v-if="items.length"
59
+ class="uds-carousel"
60
+ role="region"
61
+ aria-roledescription="carousel"
62
+ :aria-label="ariaLabel"
63
+ @focus="isPaused = true"
64
+ @blur="isPaused = false"
65
+ @mouseenter="isPaused = true"
66
+ @mouseleave="isPaused = false"
67
+ >
68
+ <div class="uds-carousel__track" :style="{ transform: `translateX(-${current * 100}%)` }">
69
+ <div
70
+ v-for="(item, i) in items"
71
+ :key="i"
72
+ class="uds-carousel__slide"
73
+ role="group"
74
+ aria-roledescription="slide"
75
+ :aria-label="`Slide ${i + 1} of ${items.length}`"
76
+ >
77
+ <slot name="item" :item="item" :index="i" />
78
+ </div>
79
+ </div>
80
+ <template v-if="showArrows && items.length > 1">
81
+ <button
82
+ type="button"
83
+ class="uds-carousel__arrow uds-carousel__arrow--prev"
84
+ aria-label="Previous slide"
85
+ :disabled="current === 0"
86
+ @click="prev"
87
+ >
88
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M15 18l-6-6 6-6" /></svg>
89
+ </button>
90
+ <button
91
+ type="button"
92
+ class="uds-carousel__arrow uds-carousel__arrow--next"
93
+ aria-label="Next slide"
94
+ :disabled="current === items.length - 1"
95
+ @click="next"
96
+ >
97
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M9 18l6-6-6-6" /></svg>
98
+ </button>
99
+ </template>
100
+ <div v-if="showDots && items.length > 1" class="uds-carousel__dots" role="tablist" aria-label="Slide indicators">
101
+ <button
102
+ v-for="(_, i) in items"
103
+ :key="i"
104
+ type="button"
105
+ role="tab"
106
+ :aria-selected="i === current"
107
+ :aria-label="`Slide ${i + 1}`"
108
+ :class="['uds-carousel__dot', i === current && 'uds-carousel__dot--active'].filter(Boolean).join(' ')"
109
+ @click="goTo(i)"
110
+ />
111
+ </div>
112
+ </section>
113
+ </template>
@@ -0,0 +1,105 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from 'vue'
3
+
4
+ interface Props {
5
+ value?: string[]
6
+ defaultValue?: string[]
7
+ maxChips?: number
8
+ placeholder?: string
9
+ disabled?: boolean
10
+ }
11
+
12
+ const props = withDefaults(defineProps<Props>(), {
13
+ defaultValue: () => [],
14
+ placeholder: 'Add...',
15
+ disabled: false,
16
+ })
17
+
18
+ const emit = defineEmits<{
19
+ change: [chips: string[]]
20
+ add: [chip: string]
21
+ remove: [index: number]
22
+ }>()
23
+
24
+ const internalValue = ref<string[]>([...(props.defaultValue ?? [])])
25
+ const inputValue = ref('')
26
+ const inputRef = ref<HTMLInputElement | null>(null)
27
+
28
+ const chips = computed(() => props.value ?? internalValue.value)
29
+
30
+ watch(() => props.value, (v) => { if (v !== undefined) internalValue.value = [...v] })
31
+
32
+ const classes = computed(() =>
33
+ ['uds-chip-input', props.disabled && 'uds-chip-input--disabled'].filter(Boolean).join(' ')
34
+ )
35
+
36
+ function updateChips(next: string[]) {
37
+ if (props.value === undefined) internalValue.value = next
38
+ emit('change', next)
39
+ }
40
+
41
+ function handleAdd() {
42
+ const trimmed = inputValue.value.trim()
43
+ if (!trimmed || (props.maxChips != null && chips.value.length >= props.maxChips)) return
44
+ if (chips.value.includes(trimmed)) return
45
+ updateChips([...chips.value, trimmed])
46
+ emit('add', trimmed)
47
+ inputValue.value = ''
48
+ }
49
+
50
+ function handleRemove(index: number) {
51
+ updateChips(chips.value.filter((_, i) => i !== index))
52
+ emit('remove', index)
53
+ }
54
+
55
+ function handleKeydown(e: KeyboardEvent) {
56
+ if (e.key === 'Enter') {
57
+ e.preventDefault()
58
+ handleAdd()
59
+ }
60
+ if (e.key === 'Backspace' && inputValue.value === '' && chips.value.length > 0) {
61
+ handleRemove(chips.value.length - 1)
62
+ }
63
+ }
64
+ </script>
65
+
66
+ <template>
67
+ <div
68
+ :class="classes"
69
+ role="listbox"
70
+ aria-label="Chips"
71
+ :aria-disabled="disabled"
72
+ @click="inputRef?.focus()"
73
+ >
74
+ <span
75
+ v-for="(chip, index) in chips"
76
+ :key="`${chip}-${index}`"
77
+ class="uds-chip-input__chip"
78
+ role="option"
79
+ >
80
+ <span class="uds-chip-input__chip-label">{{ chip }}</span>
81
+ <button
82
+ type="button"
83
+ class="uds-chip-input__chip-remove"
84
+ :aria-label="`Remove ${chip}`"
85
+ :disabled="disabled"
86
+ @click.stop="handleRemove(index)"
87
+ >
88
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
89
+ <path d="M18 6L6 18M6 6l12 12" />
90
+ </svg>
91
+ </button>
92
+ </span>
93
+ <input
94
+ v-if="maxChips == null || chips.length < maxChips"
95
+ ref="inputRef"
96
+ v-model="inputValue"
97
+ type="text"
98
+ class="uds-chip-input__input"
99
+ :placeholder="placeholder"
100
+ aria-label="Add chip"
101
+ :disabled="disabled"
102
+ @keydown="handleKeydown"
103
+ >
104
+ </div>
105
+ </template>
@@ -0,0 +1,47 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+
4
+ interface Props {
5
+ modelValue?: string
6
+ label?: string
7
+ showHexInput?: boolean
8
+ disabled?: boolean
9
+ }
10
+
11
+ const props = withDefaults(defineProps<Props>(), {
12
+ modelValue: '#000000',
13
+ showHexInput: true,
14
+ })
15
+
16
+ const emit = defineEmits<{ 'update:modelValue': [v: string] }>()
17
+
18
+ const id = 'uds-color-picker-' + Math.random().toString(36).slice(2, 9)
19
+ const classes = computed(() => ['uds-color-picker'].filter(Boolean).join(' '))
20
+ </script>
21
+
22
+ <template>
23
+ <div :class="classes">
24
+ <label v-if="label" :for="id" class="uds-color-picker__label">{{ label }}</label>
25
+ <div class="uds-color-picker__row">
26
+ <input
27
+ :id="id"
28
+ type="color"
29
+ :value="modelValue"
30
+ :disabled="disabled"
31
+ class="uds-color-picker__swatch"
32
+ :aria-describedby="showHexInput ? `${id}-hex` : undefined"
33
+ @input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
34
+ >
35
+ <input
36
+ v-if="showHexInput"
37
+ :id="`${id}-hex`"
38
+ type="text"
39
+ :value="modelValue"
40
+ :disabled="disabled"
41
+ class="uds-color-picker__hex"
42
+ aria-label="Hex color"
43
+ @input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
44
+ >
45
+ </div>
46
+ </div>
47
+ </template>
@@ -0,0 +1,22 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+
4
+ interface Props {
5
+ size?: 'sm' | 'md' | 'lg' | 'full'
6
+ class?: string
7
+ }
8
+
9
+ const props = withDefaults(defineProps<Props>(), {
10
+ size: 'lg',
11
+ })
12
+
13
+ const classes = computed(() =>
14
+ ['uds-container', `uds-container--${props.size}`, props.class].filter(Boolean).join(' ')
15
+ )
16
+ </script>
17
+
18
+ <template>
19
+ <div :class="classes">
20
+ <slot />
21
+ </div>
22
+ </template>
@@ -0,0 +1,29 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+
4
+ interface Props {
5
+ orientation?: 'horizontal' | 'vertical'
6
+ variant?: 'line' | 'dashed'
7
+ class?: string
8
+ }
9
+
10
+ const props = withDefaults(defineProps<Props>(), {
11
+ orientation: 'horizontal',
12
+ variant: 'line',
13
+ })
14
+
15
+ const classes = computed(() =>
16
+ [
17
+ 'uds-divider',
18
+ `uds-divider--${props.orientation}`,
19
+ props.variant === 'dashed' && 'uds-divider--dashed',
20
+ props.class,
21
+ ]
22
+ .filter(Boolean)
23
+ .join(' ')
24
+ )
25
+ </script>
26
+
27
+ <template>
28
+ <hr :class="classes" role="separator" />
29
+ </template>
@@ -0,0 +1,14 @@
1
+ <script setup lang="ts">
2
+ interface Props {
3
+ id?: string
4
+ class?: string
5
+ }
6
+
7
+ const props = defineProps<Props>()
8
+ </script>
9
+
10
+ <template>
11
+ <form class="uds-form" :class="props.class" :id="props.id">
12
+ <slot />
13
+ </form>
14
+ </template>
@@ -0,0 +1,24 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+
4
+ interface Props {
5
+ columns?: 1 | 2 | 3 | 4 | 12
6
+ gap?: 'sm' | 'md' | 'lg'
7
+ class?: string
8
+ }
9
+
10
+ const props = withDefaults(defineProps<Props>(), {
11
+ columns: 1,
12
+ gap: 'md',
13
+ })
14
+
15
+ const classes = computed(() =>
16
+ ['uds-grid', `uds-grid--cols-${props.columns}`, `uds-grid--gap-${props.gap}`, props.class].filter(Boolean).join(' ')
17
+ )
18
+ </script>
19
+
20
+ <template>
21
+ <div :class="classes">
22
+ <slot />
23
+ </div>
24
+ </template>
@@ -0,0 +1,28 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+
4
+ interface Props {
5
+ href: string
6
+ variant?: 'default' | 'muted' | 'primary'
7
+ external?: boolean
8
+ class?: string
9
+ }
10
+
11
+ const props = withDefaults(defineProps<Props>(), {
12
+ variant: 'primary',
13
+ external: false,
14
+ })
15
+
16
+ const classes = computed(() =>
17
+ ['uds-link', `uds-link--${props.variant}`, props.class].filter(Boolean).join(' ')
18
+ )
19
+
20
+ const rel = computed(() => (props.external ? 'noopener noreferrer' : undefined))
21
+ const target = computed(() => (props.external ? '_blank' : undefined))
22
+ </script>
23
+
24
+ <template>
25
+ <a :class="classes" :href="href" :rel="rel" :target="target">
26
+ <slot />
27
+ </a>
28
+ </template>
@@ -0,0 +1,76 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+
4
+ interface Props {
5
+ modelValue?: number | string
6
+ min?: number
7
+ max?: number
8
+ step?: number
9
+ showStepper?: boolean
10
+ size?: 'sm' | 'md' | 'lg'
11
+ disabled?: boolean
12
+ }
13
+
14
+ const props = withDefaults(defineProps<Props>(), {
15
+ step: 1,
16
+ showStepper: false,
17
+ size: 'md',
18
+ })
19
+
20
+ const emit = defineEmits<{ 'update:modelValue': [v: number | string] }>()
21
+
22
+ const classes = computed(() =>
23
+ [
24
+ 'uds-number-input',
25
+ `uds-number-input--${props.size}`,
26
+ props.showStepper && 'uds-number-input--stepper',
27
+ ]
28
+ .filter(Boolean)
29
+ .join(' ')
30
+ )
31
+
32
+ function handleStep(delta: number) {
33
+ const next = (Number(props.modelValue) || 0) + delta
34
+ const clamped =
35
+ props.min != null && next < props.min ? props.min : props.max != null && next > props.max ? props.max : next
36
+ emit('update:modelValue', clamped)
37
+ }
38
+ </script>
39
+
40
+ <template>
41
+ <div :class="classes">
42
+ <button
43
+ v-if="showStepper"
44
+ type="button"
45
+ class="uds-number-input__stepper uds-number-input__stepper--minus"
46
+ aria-label="Decrease"
47
+ :disabled="disabled || (min != null && Number(modelValue) <= min)"
48
+ @click="handleStep(-step)"
49
+ >
50
+
51
+ </button>
52
+ <input
53
+ type="number"
54
+ :value="modelValue"
55
+ :min="min"
56
+ :max="max"
57
+ :step="step"
58
+ :disabled="disabled"
59
+ class="uds-number-input__input"
60
+ :aria-valuenow="modelValue != null ? Number(modelValue) : undefined"
61
+ :aria-valuemin="min"
62
+ :aria-valuemax="max"
63
+ @input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
64
+ >
65
+ <button
66
+ v-if="showStepper"
67
+ type="button"
68
+ class="uds-number-input__stepper uds-number-input__stepper--plus"
69
+ aria-label="Increase"
70
+ :disabled="disabled || (max != null && Number(modelValue) >= max)"
71
+ @click="handleStep(step)"
72
+ >
73
+ +
74
+ </button>
75
+ </div>
76
+ </template>
@@ -0,0 +1,73 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from 'vue'
3
+
4
+ interface Props {
5
+ length?: 4 | 6
6
+ value?: string
7
+ defaultValue?: string
8
+ autoFocus?: boolean
9
+ inputMode?: 'numeric' | 'text'
10
+ disabled?: boolean
11
+ }
12
+
13
+ const props = withDefaults(defineProps<Props>(), {
14
+ length: 4,
15
+ defaultValue: '',
16
+ autoFocus: false,
17
+ inputMode: 'numeric',
18
+ disabled: false,
19
+ })
20
+
21
+ const emit = defineEmits<{
22
+ change: [value: string]
23
+ }>()
24
+
25
+ const internalValue = ref(props.defaultValue.slice(0, props.length))
26
+ const value = computed(() => (props.value ?? internalValue.value).slice(0, props.length).padEnd(props.length, ''))
27
+
28
+ watch(() => props.value, (v) => { if (v !== undefined) internalValue.value = v.slice(0, props.length) })
29
+
30
+ const classes = computed(() =>
31
+ ['uds-otp-input', props.disabled && 'uds-otp-input--disabled'].filter(Boolean).join(' ')
32
+ )
33
+
34
+ function setValue(next: string) {
35
+ const s = next.slice(0, props.length)
36
+ if (props.value === undefined) internalValue.value = s
37
+ emit('change', s)
38
+ }
39
+
40
+ function handleChange(index: number, digit: string) {
41
+ const char = props.inputMode === 'numeric' ? digit.replace(/\D/g, '').slice(-1) : digit.slice(-1)
42
+ const arr = value.value.split('')
43
+ arr[index] = char
44
+ setValue(arr.join(''))
45
+ }
46
+
47
+ function handleKeydown(e: KeyboardEvent, index: number) {
48
+ if (e.key === 'Backspace' && value.value[index] === '' && index > 0) {
49
+ const inputs = document.querySelectorAll<HTMLInputElement>('.uds-otp-input__digit')
50
+ inputs[index - 1]?.focus()
51
+ }
52
+ }
53
+ </script>
54
+
55
+ <template>
56
+ <div :class="classes" role="group" aria-label="One-time code">
57
+ <input
58
+ v-for="i in length"
59
+ :key="i"
60
+ type="text"
61
+ :inputmode="inputMode"
62
+ maxlength="1"
63
+ autocomplete="one-time-code"
64
+ :class="['uds-otp-input__digit']"
65
+ :value="value[i - 1] ?? ''"
66
+ :aria-label="`Digit ${i}`"
67
+ :disabled="disabled"
68
+ :autofocus="autoFocus && i === 1"
69
+ @input="(e: Event) => handleChange(i - 1, (e.target as HTMLInputElement).value)"
70
+ @keydown="(e: KeyboardEvent) => handleKeydown(e, i - 1)"
71
+ />
72
+ </div>
73
+ </template>
@@ -0,0 +1,85 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from 'vue'
3
+
4
+ interface Props {
5
+ open?: boolean
6
+ placement?: 'top' | 'bottom' | 'left' | 'right' | 'auto'
7
+ size?: 'sm' | 'md'
8
+ class?: string
9
+ }
10
+
11
+ const props = withDefaults(defineProps<Props>(), {
12
+ placement: 'bottom',
13
+ size: 'md',
14
+ })
15
+
16
+ const emit = defineEmits<{
17
+ 'open-change': [open: boolean]
18
+ }>()
19
+
20
+ const internalOpen = ref(false)
21
+ const open = computed(() => props.open ?? internalOpen.value)
22
+
23
+ const contentRef = ref<HTMLElement | null>(null)
24
+ const triggerRef = ref<HTMLElement | null>(null)
25
+ const contentStyle = ref<Record<string, string>>({ position: 'fixed', left: '0', top: '0', zIndex: '9999' })
26
+
27
+ watch(open, (isOpen) => {
28
+ if (isOpen && triggerRef.value && contentRef.value) {
29
+ requestAnimationFrame(() => {
30
+ if (!triggerRef.value || !contentRef.value) return
31
+ const tr = triggerRef.value.getBoundingClientRect()
32
+ const cr = contentRef.value.getBoundingClientRect()
33
+ let top = tr.bottom + 8
34
+ let left = tr.left + (tr.width - cr.width) / 2
35
+ if (props.placement === 'top') {
36
+ top = tr.top - cr.height - 8
37
+ }
38
+ left = Math.max(8, Math.min(window.innerWidth - cr.width - 8, left))
39
+ top = Math.max(8, Math.min(window.innerHeight - cr.height - 8, top))
40
+ contentStyle.value = { position: 'fixed', left: `${left}px`, top: `${top}px`, zIndex: '9999' }
41
+ })
42
+ }
43
+ })
44
+
45
+ const classes = computed(() =>
46
+ ['uds-popover', `uds-popover--${props.size}`, `uds-popover--${props.placement}`, props.class].filter(Boolean).join(' ')
47
+ )
48
+
49
+ function setOpen(v: boolean) {
50
+ if (props.open === undefined) internalOpen.value = v
51
+ emit('open-change', v)
52
+ }
53
+
54
+ function handleEscape(e: KeyboardEvent) {
55
+ if (e.key === 'Escape') setOpen(false)
56
+ }
57
+
58
+ watch(open, (isOpen) => {
59
+ if (isOpen) document.addEventListener('keydown', handleEscape)
60
+ else document.removeEventListener('keydown', handleEscape)
61
+ return () => document.removeEventListener('keydown', handleEscape)
62
+ })
63
+ </script>
64
+
65
+ <template>
66
+ <div class="uds-popover__wrapper">
67
+ <div ref="triggerRef" @click="setOpen(!open)">
68
+ <slot name="trigger" :open="open" />
69
+ </div>
70
+ <Teleport to="body">
71
+ <div
72
+ v-if="open"
73
+ ref="contentRef"
74
+ :class="classes"
75
+ role="dialog"
76
+ aria-modal="false"
77
+ :style="contentStyle"
78
+ >
79
+ <div class="uds-popover__content">
80
+ <slot />
81
+ </div>
82
+ </div>
83
+ </Teleport>
84
+ </div>
85
+ </template>
@@ -0,0 +1,36 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+
4
+ interface Props {
5
+ modelValue?: number
6
+ max?: number
7
+ size?: 'sm' | 'md' | 'lg'
8
+ disabled?: boolean
9
+ }
10
+
11
+ const props = withDefaults(defineProps<Props>(), {
12
+ modelValue: 0,
13
+ max: 5,
14
+ size: 'md',
15
+ })
16
+
17
+ const emit = defineEmits<{ 'update:modelValue': [v: number] }>()
18
+
19
+ const classes = computed(() => ['uds-rating', `uds-rating--${props.size}`].filter(Boolean).join(' '))
20
+ </script>
21
+
22
+ <template>
23
+ <div :class="classes" role="group" :aria-label="`Rating ${modelValue} of ${max}`">
24
+ <button
25
+ v-for="i in max"
26
+ :key="i"
27
+ type="button"
28
+ :class="['uds-rating__star', modelValue >= i && 'uds-rating__star--filled']"
29
+ :aria-label="`${i} star${i > 1 ? 's' : ''}`"
30
+ :disabled="disabled"
31
+ @click="emit('update:modelValue', i)"
32
+ >
33
+ <span aria-hidden="true">{{ modelValue >= i ? '★' : '☆' }}</span>
34
+ </button>
35
+ </div>
36
+ </template>
@@ -0,0 +1,92 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from 'vue'
3
+
4
+ export interface SegmentedControlOption {
5
+ value: string
6
+ label: string
7
+ icon?: unknown
8
+ }
9
+
10
+ interface Props {
11
+ options: SegmentedControlOption[]
12
+ value?: string
13
+ defaultValue?: string
14
+ size?: 'sm' | 'md' | 'lg'
15
+ iconOnly?: boolean
16
+ disabled?: boolean
17
+ }
18
+
19
+ const props = withDefaults(defineProps<Props>(), {
20
+ size: 'md',
21
+ iconOnly: false,
22
+ disabled: false,
23
+ })
24
+
25
+ const emit = defineEmits<{
26
+ change: [value: string]
27
+ }>()
28
+
29
+ const internalValue = ref(props.defaultValue ?? props.options[0]?.value ?? '')
30
+ const value = computed(() => props.value ?? internalValue.value)
31
+
32
+ const classes = computed(() =>
33
+ [
34
+ 'uds-segmented-control',
35
+ `uds-segmented-control--${props.size}`,
36
+ props.iconOnly && 'uds-segmented-control--icon-only',
37
+ props.disabled && 'uds-segmented-control--disabled',
38
+ ]
39
+ .filter(Boolean)
40
+ .join(' ')
41
+ )
42
+
43
+ function select(optionValue: string) {
44
+ if (props.disabled) return
45
+ if (props.value === undefined) internalValue.value = optionValue
46
+ emit('change', optionValue)
47
+ }
48
+
49
+ function handleKeydown(e: KeyboardEvent, currentIndex: number) {
50
+ if (props.disabled) return
51
+ let nextIndex = currentIndex
52
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
53
+ e.preventDefault()
54
+ nextIndex = Math.max(0, currentIndex - 1)
55
+ } else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
56
+ e.preventDefault()
57
+ nextIndex = Math.min(props.options.length - 1, currentIndex + 1)
58
+ } else if (e.key === 'Home') {
59
+ e.preventDefault()
60
+ nextIndex = 0
61
+ } else if (e.key === 'End') {
62
+ e.preventDefault()
63
+ nextIndex = props.options.length - 1
64
+ } else return
65
+ const nextValue = props.options[nextIndex]?.value
66
+ if (nextValue != null) select(nextValue)
67
+ }
68
+ </script>
69
+
70
+ <template>
71
+ <div
72
+ :class="classes"
73
+ role="radiogroup"
74
+ aria-label="Options"
75
+ :aria-disabled="disabled"
76
+ >
77
+ <button
78
+ v-for="(option, index) in options"
79
+ :key="option.value"
80
+ type="button"
81
+ role="radio"
82
+ :aria-checked="value === option.value"
83
+ :disabled="disabled"
84
+ :class="['uds-segmented-control__option', value === option.value && 'uds-segmented-control__option--selected'].filter(Boolean).join(' ')"
85
+ @click="select(option.value)"
86
+ @keydown="(e: KeyboardEvent) => handleKeydown(e, index)"
87
+ >
88
+ <span v-if="option.icon" class="uds-segmented-control__icon" aria-hidden="true"><slot name="icon" :option="option" /></span>
89
+ <span v-if="!iconOnly" class="uds-segmented-control__label">{{ option.label }}</span>
90
+ </button>
91
+ </div>
92
+ </template>
@@ -0,0 +1,43 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+
4
+ interface Props {
5
+ modelValue?: number
6
+ min?: number
7
+ max?: number
8
+ step?: number
9
+ size?: 'sm' | 'md' | 'lg'
10
+ disabled?: boolean
11
+ }
12
+
13
+ const props = withDefaults(defineProps<Props>(), {
14
+ min: 0,
15
+ max: 100,
16
+ step: 1,
17
+ size: 'md',
18
+ })
19
+
20
+ const emit = defineEmits<{ 'update:modelValue': [v: number] }>()
21
+
22
+ const value = computed(() => props.modelValue ?? props.min)
23
+ const classes = computed(() => ['uds-slider', `uds-slider--${props.size}`].filter(Boolean).join(' '))
24
+ </script>
25
+
26
+ <template>
27
+ <div :class="classes">
28
+ <input
29
+ type="range"
30
+ :value="value"
31
+ :min="min"
32
+ :max="max"
33
+ :step="step"
34
+ :disabled="disabled"
35
+ role="slider"
36
+ :aria-valuemin="min"
37
+ :aria-valuemax="max"
38
+ :aria-valuenow="value"
39
+ class="uds-slider__input"
40
+ @input="emit('update:modelValue', Number(($event.target as HTMLInputElement).value))"
41
+ >
42
+ </div>
43
+ </template>
@@ -0,0 +1,37 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+
4
+ interface Props {
5
+ direction?: 'row' | 'column'
6
+ gap?: 'sm' | 'md' | 'lg'
7
+ align?: 'start' | 'center' | 'end' | 'stretch'
8
+ wrap?: boolean
9
+ class?: string
10
+ }
11
+
12
+ const props = withDefaults(defineProps<Props>(), {
13
+ direction: 'column',
14
+ gap: 'md',
15
+ align: 'stretch',
16
+ wrap: false,
17
+ })
18
+
19
+ const classes = computed(() =>
20
+ [
21
+ 'uds-stack',
22
+ `uds-stack--${props.direction}`,
23
+ `uds-stack--gap-${props.gap}`,
24
+ props.align !== 'stretch' && `uds-stack--align-${props.align}`,
25
+ props.wrap && 'uds-stack--wrap',
26
+ props.class,
27
+ ]
28
+ .filter(Boolean)
29
+ .join(' ')
30
+ )
31
+ </script>
32
+
33
+ <template>
34
+ <div :class="classes">
35
+ <slot />
36
+ </div>
37
+ </template>
@@ -0,0 +1,85 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from 'vue'
3
+
4
+ export interface StepperStep {
5
+ id: string
6
+ label: string
7
+ optional?: boolean
8
+ }
9
+
10
+ interface Props {
11
+ steps: StepperStep[]
12
+ activeStep?: number
13
+ defaultActiveStep?: number
14
+ orientation?: 'horizontal' | 'vertical'
15
+ linear?: boolean
16
+ }
17
+
18
+ const props = withDefaults(defineProps<Props>(), {
19
+ defaultActiveStep: 0,
20
+ orientation: 'horizontal',
21
+ linear: true,
22
+ })
23
+
24
+ const emit = defineEmits<{
25
+ change: [index: number]
26
+ stepClick: [index: number]
27
+ }>()
28
+
29
+ const internalStep = ref(props.defaultActiveStep)
30
+ const activeStep = computed(() => props.activeStep ?? internalStep.value)
31
+
32
+ watch(
33
+ () => props.activeStep,
34
+ (v) => { if (v !== undefined) internalStep.value = v },
35
+ )
36
+
37
+ const classes = computed(() =>
38
+ ['uds-stepper', `uds-stepper--${props.orientation}`].filter(Boolean).join(' ')
39
+ )
40
+
41
+ function handleStepClick(index: number) {
42
+ if (props.linear && index > activeStep.value) return
43
+ if (props.activeStep === undefined) internalStep.value = index
44
+ emit('change', index)
45
+ emit('stepClick', index)
46
+ }
47
+ </script>
48
+
49
+ <template>
50
+ <nav :class="classes" aria-label="Progress">
51
+ <template v-for="(step, index) in steps" :key="step.id">
52
+ <div
53
+ :class="[
54
+ 'uds-stepper__step',
55
+ index < activeStep && 'uds-stepper__step--completed',
56
+ index === activeStep && 'uds-stepper__step--active',
57
+ index > activeStep && 'uds-stepper__step--pending',
58
+ ].filter(Boolean).join(' ')"
59
+ :aria-current="index === activeStep ? 'step' : undefined"
60
+ :aria-disabled="index > activeStep && linear ? 'true' : undefined"
61
+ role="button"
62
+ :tabindex="!linear || index <= activeStep ? 0 : -1"
63
+ @click="(!linear || index <= activeStep) && handleStepClick(index)"
64
+ @keydown.enter.prevent="(!linear || index <= activeStep) && handleStepClick(index)"
65
+ @keydown.space.prevent="(!linear || index <= activeStep) && handleStepClick(index)"
66
+ >
67
+ <span class="uds-stepper__step-indicator">
68
+ <template v-if="index < activeStep">
69
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
70
+ <path d="M20 6L9 17l-5-5" />
71
+ </svg>
72
+ </template>
73
+ <template v-else>{{ index + 1 }}</template>
74
+ </span>
75
+ <span class="uds-stepper__step-label">{{ step.label }}</span>
76
+ <span v-if="step.optional" class="uds-stepper__step-optional">(optional)</span>
77
+ </div>
78
+ <span
79
+ v-if="index < steps.length - 1"
80
+ :class="['uds-stepper__connector', index < activeStep && 'uds-stepper__connector--completed'].filter(Boolean).join(' ')"
81
+ aria-hidden="true"
82
+ />
83
+ </template>
84
+ </nav>
85
+ </template>
@@ -0,0 +1,29 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+
4
+ interface Props {
5
+ modelValue?: string
6
+ size?: 'md' | 'lg'
7
+ disabled?: boolean
8
+ }
9
+
10
+ const props = withDefaults(defineProps<Props>(), {
11
+ modelValue: '',
12
+ size: 'md',
13
+ })
14
+
15
+ const emit = defineEmits<{ 'update:modelValue': [v: string] }>()
16
+
17
+ const classes = computed(() => ['uds-time-picker', `uds-time-picker--${props.size}`].filter(Boolean).join(' '))
18
+ </script>
19
+
20
+ <template>
21
+ <input
22
+ type="time"
23
+ :value="modelValue"
24
+ :class="classes"
25
+ :disabled="disabled"
26
+ aria-label="Time"
27
+ @input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
28
+ >
29
+ </template>
@@ -0,0 +1,33 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+
4
+ interface Props {
5
+ ariaLabel: string
6
+ orientation?: 'horizontal' | 'vertical'
7
+ class?: string
8
+ }
9
+
10
+ const props = withDefaults(defineProps<Props>(), {
11
+ orientation: 'horizontal',
12
+ })
13
+
14
+ const classes = computed(() =>
15
+ [
16
+ 'uds-toolbar',
17
+ props.orientation === 'vertical' && 'uds-toolbar--vertical',
18
+ props.class,
19
+ ]
20
+ .filter(Boolean)
21
+ .join(' ')
22
+ )
23
+ </script>
24
+
25
+ <template>
26
+ <div
27
+ :class="classes"
28
+ role="toolbar"
29
+ :aria-label="ariaLabel"
30
+ >
31
+ <slot />
32
+ </div>
33
+ </template>
@@ -0,0 +1,82 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from 'vue'
3
+
4
+ export interface TreeNode {
5
+ id: string
6
+ label: string
7
+ children?: TreeNode[]
8
+ }
9
+
10
+ interface Props {
11
+ nodes: TreeNode[]
12
+ selectedIds?: string | string[]
13
+ selectionMode?: 'single' | 'multi' | 'none'
14
+ defaultExpandedIds?: string[]
15
+ ariaLabel?: string
16
+ }
17
+
18
+ const props = withDefaults(defineProps<Props>(), {
19
+ selectionMode: 'single',
20
+ ariaLabel: 'Tree',
21
+ })
22
+
23
+ const emit = defineEmits<{
24
+ select: [ids: string[]]
25
+ expand: [id: string, expanded: boolean]
26
+ }>()
27
+
28
+ const expanded = ref<Set<string>>(new Set(props.defaultExpandedIds ?? []))
29
+
30
+ const selectedArray = computed(() =>
31
+ Array.isArray(props.selectedIds) ? props.selectedIds : props.selectedIds != null ? [props.selectedIds] : []
32
+ )
33
+ const selectedSet = computed(() => new Set(selectedArray.value))
34
+
35
+ function toggle(id: string, isExp: boolean) {
36
+ const next = new Set(expanded.value)
37
+ if (isExp) next.add(id)
38
+ else next.delete(id)
39
+ expanded.value = next
40
+ emit('expand', id, isExp)
41
+ }
42
+
43
+ function handleSelect(ids: string[]) {
44
+ emit('select', ids)
45
+ }
46
+ </script>
47
+
48
+ <template>
49
+ <div class="uds-tree" role="tree" :aria-label="ariaLabel" :aria-multiselectable="selectionMode === 'multi'">
50
+ <template v-for="node in nodes" :key="node.id">
51
+ <div
52
+ :class="[
53
+ 'uds-tree__item',
54
+ node.children?.length && 'uds-tree__item--branch',
55
+ selectedSet.has(node.id) && 'uds-tree__item--selected',
56
+ ].filter(Boolean).join(' ')"
57
+ role="treeitem"
58
+ :aria-expanded="node.children?.length ? expanded.has(node.id) : undefined"
59
+ aria-level="1"
60
+ :aria-selected="selectionMode !== 'none' ? selectedSet.has(node.id) : undefined"
61
+ tabindex="0"
62
+ @click="node.children?.length && toggle(node.id, !expanded.has(node.id)); selectionMode !== 'none' && handleSelect(selectionMode === 'single' ? [node.id] : selectedSet.has(node.id) ? [...selectedArray].filter((x) => x !== node.id) : [...selectedArray, node.id])"
63
+ >
64
+ <span class="uds-tree__item-label">{{ node.label }}</span>
65
+ <div v-if="node.children?.length && expanded.has(node.id)" role="group" class="uds-tree__group">
66
+ <div
67
+ v-for="child in node.children"
68
+ :key="child.id"
69
+ :class="['uds-tree__item', selectedSet.has(child.id) && 'uds-tree__item--selected'].filter(Boolean).join(' ')"
70
+ role="treeitem"
71
+ aria-level="2"
72
+ :aria-selected="selectionMode !== 'none' ? selectedSet.has(child.id) : undefined"
73
+ tabindex="0"
74
+ @click="selectionMode !== 'none' && handleSelect(selectionMode === 'single' ? [child.id] : [])"
75
+ >
76
+ <span class="uds-tree__item-label">{{ child.label }}</span>
77
+ </div>
78
+ </div>
79
+ </div>
80
+ </template>
81
+ </div>
82
+ </template>
@@ -0,0 +1,36 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+
4
+ type Variant = 'h1' | 'h2' | 'h3' | 'body' | 'caption' | 'code'
5
+
6
+ interface Props {
7
+ variant?: Variant
8
+ class?: string
9
+ }
10
+
11
+ const props = withDefaults(defineProps<Props>(), {
12
+ variant: 'body',
13
+ })
14
+
15
+ const tag = computed(() => {
16
+ const map: Record<Variant, string> = {
17
+ h1: 'h1',
18
+ h2: 'h2',
19
+ h3: 'h3',
20
+ body: 'p',
21
+ caption: 'span',
22
+ code: 'code',
23
+ }
24
+ return map[props.variant]
25
+ })
26
+
27
+ const classes = computed(() =>
28
+ ['uds-typography', `uds-typography--${props.variant}`, props.class].filter(Boolean).join(' ')
29
+ )
30
+ </script>
31
+
32
+ <template>
33
+ <component :is="tag" :class="classes">
34
+ <slot />
35
+ </component>
36
+ </template>
package/src/index.ts CHANGED
@@ -15,6 +15,13 @@ export { default as UdsRadio } from './components/UdsRadio.vue'
15
15
  export { default as UdsToggle } from './components/UdsToggle.vue'
16
16
  export { default as UdsAlert } from './components/UdsAlert.vue'
17
17
  export { default as UdsBadge } from './components/UdsBadge.vue'
18
+ export { default as UdsBox } from './components/UdsBox.vue'
19
+ export { default as UdsContainer } from './components/UdsContainer.vue'
20
+ export { default as UdsDivider } from './components/UdsDivider.vue'
21
+ export { default as UdsGrid } from './components/UdsGrid.vue'
22
+ export { default as UdsLink } from './components/UdsLink.vue'
23
+ export { default as UdsStack } from './components/UdsStack.vue'
24
+ export { default as UdsTypography } from './components/UdsTypography.vue'
18
25
  export { default as UdsTabs } from './components/UdsTabs.vue'
19
26
  export { default as UdsAccordion } from './components/UdsAccordion.vue'
20
27
  export { default as UdsBreadcrumb } from './components/UdsBreadcrumb.vue'
@@ -30,3 +37,17 @@ export { default as UdsCommandPalette } from './components/UdsCommandPalette.vue
30
37
  export { default as UdsProgress } from './components/UdsProgress.vue'
31
38
  export { default as UdsSideNav } from './components/UdsSideNav.vue'
32
39
  export { default as UdsFileUpload } from './components/UdsFileUpload.vue'
40
+ export { default as UdsToolbar } from './components/UdsToolbar.vue'
41
+ export { default as UdsStepper } from './components/UdsStepper.vue'
42
+ export { default as UdsSegmentedControl } from './components/UdsSegmentedControl.vue'
43
+ export { default as UdsOTPInput } from './components/UdsOTPInput.vue'
44
+ export { default as UdsChipInput } from './components/UdsChipInput.vue'
45
+ export { default as UdsColorPicker } from './components/UdsColorPicker.vue'
46
+ export { default as UdsForm } from './components/UdsForm.vue'
47
+ export { default as UdsNumberInput } from './components/UdsNumberInput.vue'
48
+ export { default as UdsPopover } from './components/UdsPopover.vue'
49
+ export { default as UdsRating } from './components/UdsRating.vue'
50
+ export { default as UdsSlider } from './components/UdsSlider.vue'
51
+ export { default as UdsTimePicker } from './components/UdsTimePicker.vue'
52
+ export { default as UdsCarousel } from './components/UdsCarousel.vue'
53
+ export { default as UdsTreeView } from './components/UdsTreeView.vue'