@livestorm/ui-vue 2.0.5 → 3.0.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 +6 -3
- package/dist/UiTimelineItem.vue_vue_type_style_index_0_lang-LpdvGf5X.mjs +6093 -0
- package/dist/components.mjs +105 -96
- package/dist/index.mjs +68 -64
- package/dist/layouts.mjs +15 -15
- package/dist/types/index.d.ts +5 -1
- package/dist/types/src/components/UiActionBar/UiActionBar.vue.d.ts +3 -1
- package/dist/types/src/components/UiActionBar/UiActionBarItem.vue.d.ts +18 -3
- package/dist/types/src/components/UiActionBar/types.d.ts +3 -1
- package/dist/types/src/components/UiButton/UiButton.vue.d.ts +17 -13
- package/dist/types/src/components/UiButtonGroup/UiButtonGroupItem.vue.d.ts +3 -0
- package/dist/types/src/components/UiButtonGroup/types.d.ts +1 -1
- package/dist/types/src/components/UiDropdown/UiDropdownItem.vue.d.ts +6 -4
- package/dist/types/src/components/UiInputEmoji/UiInputEmoji.vue.d.ts +45 -0
- package/dist/types/src/components/UiInputEmoji/index.d.ts +1 -0
- package/dist/types/src/components/UiInputRadio/UiInputRadio.vue.d.ts +67 -0
- package/dist/types/src/components/UiInputRadio/UiInputRadioGroup.vue.d.ts +81 -0
- package/dist/types/src/components/UiInputRadio/index.d.ts +3 -0
- package/dist/types/src/components/UiInputRadio/types.d.ts +19 -0
- package/dist/types/src/components/UiLoader/UiLoader.vue.d.ts +6 -5
- package/dist/types/src/components/UiLogo/UiLogo.vue.d.ts +9 -3
- package/dist/types/src/components/UiPage/UiPage.vue.d.ts +34 -0
- package/dist/types/src/components/UiProgress/UiProgress.vue.d.ts +1 -1
- package/dist/types/src/components/UiSwitch/UiSwitch.vue.d.ts +109 -0
- package/dist/types/src/components/UiSwitch/index.d.ts +2 -0
- package/dist/types/src/components/UiSwitch/types.d.ts +3 -0
- package/dist/types/src/components/UiTabs/UiTabs.vue.d.ts +5 -2
- package/dist/types/src/composables/useContentMode.d.ts +25 -0
- package/dist/types/src/composables/useIconSize.d.ts +11 -0
- package/dist/types/src/constants/sizes.d.ts +1 -1
- package/dist/ui-vue.css +1 -1
- package/index.d.ts +4 -0
- package/index.js +8 -0
- package/package.json +6 -3
- package/src/components/Toast/ToastMessage.vue +6 -6
- package/src/components/UiActionBar/UiActionBar.vue +11 -2
- package/src/components/UiActionBar/UiActionBarItem.vue +56 -32
- package/src/components/UiActionBar/types.ts +3 -1
- package/src/components/UiAvatar/UiAvatar.vue +1 -1
- package/src/components/UiBanner/UiBanner.vue +7 -7
- package/src/components/UiButton/UiButton.vue +9 -9
- package/src/components/UiButtonGroup/UiButtonGroup.vue +2 -0
- package/src/components/UiButtonGroup/UiButtonGroupItem.vue +12 -0
- package/src/components/UiButtonGroup/types.ts +1 -1
- package/src/components/UiCarousel/UiCarousel.vue +2 -2
- package/src/components/UiColorPicker/UiColorPicker.vue +5 -5
- package/src/components/UiDialog/MarketplaceHeaderExample.vue +2 -2
- package/src/components/UiDialog/UiDialogClose.vue +1 -1
- package/src/components/UiDropdown/UiDropdownContent.vue +2 -2
- package/src/components/UiDropdown/UiDropdownItem.vue +8 -1
- package/src/components/UiDropdown/UiDropdownTrigger.vue +6 -7
- package/src/components/UiFieldWrapper/UiFieldWrapper.vue +1 -1
- package/src/components/UiInputCheckbox/UiInputCheckbox.vue +2 -2
- package/src/components/UiInputEmoji/UiInputEmoji.vue +173 -0
- package/src/components/UiInputEmoji/index.ts +1 -0
- package/src/components/UiInputRadio/UiInputRadio.vue +374 -0
- package/src/components/UiInputRadio/UiInputRadioGroup.vue +156 -0
- package/src/components/UiInputRadio/index.ts +3 -0
- package/src/components/UiInputRadio/types.ts +25 -0
- package/src/components/UiLoader/UiLoader.vue +8 -10
- package/src/components/UiLogo/UiLogo.vue +41 -7
- package/src/components/UiMenu/UiMenuContent.vue +1 -1
- package/src/components/UiMenu/UiMenuGroup.vue +1 -1
- package/src/components/UiMenu/UiMenuItem.vue +1 -1
- package/src/components/UiMenu/UiMenuSubContent.vue +1 -1
- package/src/components/UiMenu/UiMenuSubTrigger.vue +2 -2
- package/src/components/UiPage/UiPage.vue +102 -34
- package/src/components/UiPageHeader/UiPageHeader.vue +29 -9
- package/src/components/UiRoomButton/UiRoomButton.vue +4 -4
- package/src/components/UiShell/UiShell.vue +1 -1
- package/src/components/UiSideMenu/UiSideMenu.vue +13 -10
- package/src/components/UiSideMenu/UiSideMenuItemContent.vue +2 -2
- package/src/components/UiStepper/UiStepperIndicator.vue +8 -11
- package/src/components/UiSwitch/UiSwitch.vue +359 -0
- package/src/components/UiSwitch/index.ts +2 -0
- package/src/components/UiSwitch/types.ts +5 -0
- package/src/components/UiTabs/UiTabs.vue +7 -4
- package/src/components/UiTextField/UiTextField.vue +6 -11
- package/src/components/UiTimeline/README.md +6 -6
- package/src/components/UiTimeline/UiTimelineItem.vue +3 -3
- package/src/composables/useContentMode.ts +53 -0
- package/src/composables/useIconSize.ts +28 -0
- package/src/constants/sizes.ts +1 -1
- package/src/global.d.ts +8 -0
- package/dist/UiTimelineItem.vue_vue_type_style_index_0_lang-DJFdCn2R.mjs +0 -5456
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref } from 'vue'
|
|
3
|
+
import { useStorage } from '@vueuse/core'
|
|
4
|
+
import EmojiPicker from 'vue-frimousse'
|
|
5
|
+
import UiPopover from '../UiPopover/UiPopover.vue'
|
|
6
|
+
import UiPopoverContent from '../UiPopover/UiPopoverContent.vue'
|
|
7
|
+
import UiPopoverTrigger from '../UiPopover/UiPopoverTrigger.vue'
|
|
8
|
+
|
|
9
|
+
interface UiInputEmojiProps {
|
|
10
|
+
disabled?: boolean
|
|
11
|
+
label?: string
|
|
12
|
+
locale?: string
|
|
13
|
+
searchPlaceholder?: string
|
|
14
|
+
loadingLabel?: string
|
|
15
|
+
emptyLabel?: string
|
|
16
|
+
previewPlaceholder?: string
|
|
17
|
+
skinToneLabel?: string
|
|
18
|
+
sideOffset?: number
|
|
19
|
+
open?: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const props = withDefaults(defineProps<UiInputEmojiProps>(), {
|
|
23
|
+
disabled: false,
|
|
24
|
+
label: 'Emoji',
|
|
25
|
+
locale: 'en',
|
|
26
|
+
searchPlaceholder: 'Search emoji...',
|
|
27
|
+
loadingLabel: 'Loading...',
|
|
28
|
+
emptyLabel: 'No emoji found',
|
|
29
|
+
previewPlaceholder: 'Select an emoji',
|
|
30
|
+
skinToneLabel: 'Change skin tone',
|
|
31
|
+
sideOffset: 8,
|
|
32
|
+
open: undefined,
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const emit = defineEmits<{
|
|
36
|
+
select: [emoji: string]
|
|
37
|
+
'update:open': [value: boolean]
|
|
38
|
+
}>()
|
|
39
|
+
|
|
40
|
+
const uncontrolledOpen = ref(false)
|
|
41
|
+
type SkinTone = 'none' | 'light' | 'medium-light' | 'medium' | 'medium-dark' | 'dark'
|
|
42
|
+
const skinTones: SkinTone[] = ['none', 'light', 'medium-light', 'medium', 'medium-dark', 'dark']
|
|
43
|
+
const preferredSkinTone = useStorage<SkinTone>('livestorm:emoji-skin-tone', 'none')
|
|
44
|
+
|
|
45
|
+
const open = computed({
|
|
46
|
+
get: () => props.open ?? uncontrolledOpen.value,
|
|
47
|
+
set: (value: boolean) => {
|
|
48
|
+
uncontrolledOpen.value = value
|
|
49
|
+
emit('update:open', value)
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const locale = computed(() => props.locale.toLowerCase().replace('_', '-'))
|
|
54
|
+
|
|
55
|
+
function selectEmoji (emoji: { emoji: string }): void {
|
|
56
|
+
emit('select', emoji.emoji)
|
|
57
|
+
open.value = false
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function cycleSkinTone (): void {
|
|
61
|
+
const currentIndex = skinTones.indexOf(preferredSkinTone.value)
|
|
62
|
+
preferredSkinTone.value = skinTones[(currentIndex + 1) % skinTones.length] ?? 'none'
|
|
63
|
+
}
|
|
64
|
+
</script>
|
|
65
|
+
|
|
66
|
+
<template>
|
|
67
|
+
<UiPopover v-model:open="open">
|
|
68
|
+
<UiPopoverTrigger
|
|
69
|
+
as-child
|
|
70
|
+
:disabled="disabled"
|
|
71
|
+
>
|
|
72
|
+
<slot
|
|
73
|
+
:open="open"
|
|
74
|
+
:disabled="disabled"
|
|
75
|
+
>
|
|
76
|
+
<button
|
|
77
|
+
type="button"
|
|
78
|
+
:aria-label="label"
|
|
79
|
+
:disabled="disabled"
|
|
80
|
+
class="inline-flex size-8 items-center justify-center rounded-lg text-actions-neutral-idle hover:bg-actions-neutral-hover-overlay disabled:pointer-events-none disabled:opacity-50"
|
|
81
|
+
>
|
|
82
|
+
<span
|
|
83
|
+
class="i-ph-smiley size-4"
|
|
84
|
+
aria-hidden="true"
|
|
85
|
+
/>
|
|
86
|
+
</button>
|
|
87
|
+
</slot>
|
|
88
|
+
</UiPopoverTrigger>
|
|
89
|
+
|
|
90
|
+
<UiPopoverContent
|
|
91
|
+
side="top"
|
|
92
|
+
:side-offset="sideOffset"
|
|
93
|
+
class="w-[min(320px,calc(100vw-16px))] overflow-hidden border-borders-neutral-default bg-surface-neutral-bg-main p-0"
|
|
94
|
+
:aria-label="label"
|
|
95
|
+
>
|
|
96
|
+
<EmojiPicker.Root
|
|
97
|
+
:locale="locale"
|
|
98
|
+
:columns="9"
|
|
99
|
+
:skin-tone="preferredSkinTone"
|
|
100
|
+
:on-emoji-select="selectEmoji"
|
|
101
|
+
class="flex flex-col gap-2 p-3"
|
|
102
|
+
>
|
|
103
|
+
<EmojiPicker.Search
|
|
104
|
+
class="h-8 w-full rounded-md border border-borders-neutral-default bg-surface-neutral-bg-main px-2 text-content-legends-medium-md text-text-neutral-base outline-none placeholder:text-text-neutral-tertiary focus:border-actions-primary-idle-alpha-strong focus:ring-1 focus:ring-focus-ring"
|
|
105
|
+
:placeholder="searchPlaceholder"
|
|
106
|
+
/>
|
|
107
|
+
|
|
108
|
+
<EmojiPicker.Viewport class="box-border h-64 pr-2">
|
|
109
|
+
<EmojiPicker.Loading class="flex h-64 items-center justify-center text-content-legends-medium-md text-text-neutral-tertiary">
|
|
110
|
+
{{ loadingLabel }}
|
|
111
|
+
</EmojiPicker.Loading>
|
|
112
|
+
<EmojiPicker.Empty class="flex h-64 items-center justify-center text-content-legends-medium-md text-text-neutral-tertiary">
|
|
113
|
+
{{ emptyLabel }}
|
|
114
|
+
</EmojiPicker.Empty>
|
|
115
|
+
<EmojiPicker.List class="flex w-full flex-col gap-2">
|
|
116
|
+
<template #category-header="{ category }">
|
|
117
|
+
<div class="bg-surface-neutral-bg-main py-1 text-content-legends-bold-md text-text-neutral-secondary">
|
|
118
|
+
{{ category.label }}
|
|
119
|
+
</div>
|
|
120
|
+
</template>
|
|
121
|
+
|
|
122
|
+
<template #emoji="{ emoji }">
|
|
123
|
+
<button
|
|
124
|
+
type="button"
|
|
125
|
+
:aria-label="emoji.label"
|
|
126
|
+
class="flex size-8 w-full! items-center justify-center rounded-md text-xl leading-none hover:bg-actions-neutral-hover-overlay data-[active]:bg-actions-neutral-hover-overlay"
|
|
127
|
+
>
|
|
128
|
+
{{ emoji.emoji }}
|
|
129
|
+
</button>
|
|
130
|
+
</template>
|
|
131
|
+
</EmojiPicker.List>
|
|
132
|
+
</EmojiPicker.Viewport>
|
|
133
|
+
|
|
134
|
+
<div class="flex h-10 items-center justify-between gap-2 border-t border-borders-neutral-default pt-2">
|
|
135
|
+
<EmojiPicker.ActiveEmoji>
|
|
136
|
+
<template #default="{ emoji }">
|
|
137
|
+
<span
|
|
138
|
+
v-if="emoji"
|
|
139
|
+
class="flex h-6 min-w-0 flex-1 items-center gap-2 text-content-legends-medium-md text-text-neutral-secondary"
|
|
140
|
+
>
|
|
141
|
+
<span class="flex size-6 shrink-0 items-center justify-center text-xl leading-none">{{ emoji.emoji }}</span>
|
|
142
|
+
<span class="min-w-0 truncate">{{ emoji.label }}</span>
|
|
143
|
+
</span>
|
|
144
|
+
<span
|
|
145
|
+
v-else
|
|
146
|
+
class="flex h-6 min-w-0 flex-1 items-center text-content-legends-medium-md text-text-neutral-secondary"
|
|
147
|
+
>{{ previewPlaceholder }}</span>
|
|
148
|
+
</template>
|
|
149
|
+
</EmojiPicker.ActiveEmoji>
|
|
150
|
+
|
|
151
|
+
<EmojiPicker.SkinToneSelector
|
|
152
|
+
class="flex size-8 shrink-0 items-center justify-center rounded-md text-xl leading-none hover:bg-actions-neutral-hover-overlay"
|
|
153
|
+
:aria-label="skinToneLabel"
|
|
154
|
+
@click="cycleSkinTone"
|
|
155
|
+
/>
|
|
156
|
+
</div>
|
|
157
|
+
</EmojiPicker.Root>
|
|
158
|
+
</UiPopoverContent>
|
|
159
|
+
</UiPopover>
|
|
160
|
+
</template>
|
|
161
|
+
|
|
162
|
+
<style scoped>
|
|
163
|
+
:deep([frimousse-row]) {
|
|
164
|
+
display: grid !important;
|
|
165
|
+
width: 100%;
|
|
166
|
+
grid-template-columns: repeat(9, minmax(0, 1fr));
|
|
167
|
+
gap: 0.125rem;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
:deep([frimousse-row] > [frimousse-emoji]) {
|
|
171
|
+
min-width: 0;
|
|
172
|
+
}
|
|
173
|
+
</style>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as UiInputEmoji } from './UiInputEmoji.vue'
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, inject, ref } from 'vue'
|
|
3
|
+
import { Label, RadioGroupItem, RadioGroupIndicator, useId } from 'reka-ui'
|
|
4
|
+
import type { RadioValue, RadioVariant } from './types'
|
|
5
|
+
import { RADIO_GROUP_KEY } from './types'
|
|
6
|
+
import UiTooltip from '../UiTooltip/UiTooltip.vue'
|
|
7
|
+
|
|
8
|
+
interface UiInputRadioProps {
|
|
9
|
+
/**
|
|
10
|
+
* The value this option represents. `null` is a valid value;
|
|
11
|
+
* object values must be reference-stable.
|
|
12
|
+
*/
|
|
13
|
+
value: RadioValue
|
|
14
|
+
/**
|
|
15
|
+
* The label text
|
|
16
|
+
*/
|
|
17
|
+
label?: string
|
|
18
|
+
/**
|
|
19
|
+
* Description text below the label
|
|
20
|
+
*/
|
|
21
|
+
description?: string
|
|
22
|
+
/**
|
|
23
|
+
* Info tooltip for the label
|
|
24
|
+
*/
|
|
25
|
+
labelInfo?: string
|
|
26
|
+
/**
|
|
27
|
+
* Whether this option is disabled
|
|
28
|
+
* @default false
|
|
29
|
+
*/
|
|
30
|
+
disabled?: boolean
|
|
31
|
+
/**
|
|
32
|
+
* Layout orientation of the radio relative to the label
|
|
33
|
+
* @default 'horizontal'
|
|
34
|
+
*/
|
|
35
|
+
orientation?: 'horizontal' | 'vertical'
|
|
36
|
+
/**
|
|
37
|
+
* Visual variant: 'card' renders a selectable tile
|
|
38
|
+
* @default 'default'
|
|
39
|
+
*/
|
|
40
|
+
variant?: RadioVariant
|
|
41
|
+
/**
|
|
42
|
+
* Radio id attribute
|
|
43
|
+
*/
|
|
44
|
+
id?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const props = withDefaults(defineProps<UiInputRadioProps>(), {
|
|
48
|
+
label: undefined,
|
|
49
|
+
description: undefined,
|
|
50
|
+
labelInfo: undefined,
|
|
51
|
+
id: undefined,
|
|
52
|
+
disabled: false,
|
|
53
|
+
orientation: 'horizontal',
|
|
54
|
+
variant: 'default',
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const emit = defineEmits<{
|
|
58
|
+
select: [event: Event]
|
|
59
|
+
}>()
|
|
60
|
+
|
|
61
|
+
// Group context: UiInputRadio is only usable inside UiInputRadioGroup
|
|
62
|
+
// (reka's RadioGroupItem enforces this too)
|
|
63
|
+
const group = inject(RADIO_GROUP_KEY, undefined)
|
|
64
|
+
|
|
65
|
+
const generatedId = useId()
|
|
66
|
+
const inputId = computed(() => props.id ?? generatedId)
|
|
67
|
+
|
|
68
|
+
// Styling state. Comparison is strict equality: matches reka's isEqual for
|
|
69
|
+
// primitives and null; object values must be reference-stable.
|
|
70
|
+
const isChecked = computed(() => {
|
|
71
|
+
if (!group || group.modelValue.value === undefined) {
|
|
72
|
+
return false
|
|
73
|
+
}
|
|
74
|
+
return group.modelValue.value === props.value
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const isDisabled = computed(() => props.disabled || (group?.disabled.value ?? false))
|
|
78
|
+
const error = computed(() => group?.error.value ?? false)
|
|
79
|
+
const size = computed(() => group?.size.value ?? 'md')
|
|
80
|
+
|
|
81
|
+
const descriptionId = computed(() => props.description ? `${inputId.value}-description` : undefined)
|
|
82
|
+
|
|
83
|
+
const ariaDescribedby = computed(() => {
|
|
84
|
+
const ids = [descriptionId.value, group?.messageId.value].filter(Boolean)
|
|
85
|
+
return ids.length ? ids.join(' ') : undefined
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const radioClasses = computed(() => {
|
|
89
|
+
const baseClasses = [
|
|
90
|
+
'relative shrink-0 grid place-content-center',
|
|
91
|
+
'rounded-full',
|
|
92
|
+
'focus:outline-hidden',
|
|
93
|
+
'cursor-pointer disabled:cursor-not-allowed',
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
// Size classes
|
|
97
|
+
const sizeClasses = {
|
|
98
|
+
sm: 'w-3.5 h-3.5',
|
|
99
|
+
md: 'w-4 h-4',
|
|
100
|
+
lg: 'w-5 h-5',
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
baseClasses.push(sizeClasses[size.value])
|
|
104
|
+
|
|
105
|
+
// Selected is a full fill (the light inner circle turns it into a thick
|
|
106
|
+
// ring); unselected keeps a border that thickens on hover
|
|
107
|
+
if (isChecked.value) {
|
|
108
|
+
baseClasses.push('border-none')
|
|
109
|
+
baseClasses.push(error.value ? 'bg-actions-danger-idle' : 'bg-actions-primary-idle')
|
|
110
|
+
} else {
|
|
111
|
+
baseClasses.push('bg-surface-neutral-bg-main border')
|
|
112
|
+
baseClasses.push(error.value ? 'border-borders-danger-strong' : 'border-borders-neutral-strong')
|
|
113
|
+
if (!isDisabled.value) {
|
|
114
|
+
baseClasses.push('group-hover:border-2')
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Focus ring
|
|
119
|
+
if (error.value) {
|
|
120
|
+
baseClasses.push('focus-visible:ring focus-visible:ring-borders-danger-strong')
|
|
121
|
+
} else if (isChecked.value) {
|
|
122
|
+
baseClasses.push('focus-visible:ring focus-visible:ring-borders-primary-strong')
|
|
123
|
+
} else {
|
|
124
|
+
baseClasses.push('focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-focus-ring')
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return baseClasses
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
// Light inner circle of the selected state; grows on hover (ring thins) and
|
|
131
|
+
// stays grown when disabled or errored — an errored selected radio is always
|
|
132
|
+
// the thin ring. Fraction sizes scale with the sm/md/lg glyph.
|
|
133
|
+
const indicatorClasses = computed(() => {
|
|
134
|
+
// Transform centering (not inset/margins): layout boxes get snapped to
|
|
135
|
+
// device pixels, which renders fractional gaps visibly off-center
|
|
136
|
+
const baseClasses = [
|
|
137
|
+
'absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full z-1',
|
|
138
|
+
'bg-surface-neutral-bg-main transition-all',
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
if (isDisabled.value || error.value) {
|
|
142
|
+
baseClasses.push('size-3/4')
|
|
143
|
+
} else {
|
|
144
|
+
baseClasses.push('size-1/2 group-hover:size-3/4')
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return baseClasses
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
// Preview dot shown while hovering an unselected, enabled radio
|
|
151
|
+
const previewDotClasses = computed(() => {
|
|
152
|
+
const baseClasses = [
|
|
153
|
+
'absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 size-1/2 rounded-full z-1',
|
|
154
|
+
'opacity-0 group-hover:opacity-50 transition-opacity pointer-events-none',
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
baseClasses.push(error.value ? 'bg-actions-danger-idle' : 'bg-actions-neutral-idle')
|
|
158
|
+
|
|
159
|
+
return baseClasses
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
const hoverOverlayClasses = computed(() => ({
|
|
163
|
+
'bg-actions-neutral-hover-overlay': !isChecked.value && !error.value,
|
|
164
|
+
'bg-actions-danger-hover-overlay': error.value,
|
|
165
|
+
'bg-actions-primary-hover-overlay': isChecked.value && !error.value,
|
|
166
|
+
}))
|
|
167
|
+
|
|
168
|
+
const labelClasses = computed(() => ({
|
|
169
|
+
'text-text-neutral-base': !error.value && !isChecked.value,
|
|
170
|
+
'text-text-danger-base': error.value,
|
|
171
|
+
'text-text-primary-base': isChecked.value && !error.value,
|
|
172
|
+
'cursor-not-allowed': isDisabled.value,
|
|
173
|
+
'cursor-pointer': !isDisabled.value,
|
|
174
|
+
}))
|
|
175
|
+
|
|
176
|
+
const descriptionClasses = computed(() => ({
|
|
177
|
+
'text-center': props.orientation === 'vertical',
|
|
178
|
+
'text-text-neutral-tertiary': !error.value && !isChecked.value,
|
|
179
|
+
'text-text-danger-tertiary': error.value,
|
|
180
|
+
'text-text-primary-tertiary': isChecked.value && !error.value,
|
|
181
|
+
'cursor-not-allowed': isDisabled.value,
|
|
182
|
+
'cursor-pointer': !isDisabled.value,
|
|
183
|
+
}))
|
|
184
|
+
|
|
185
|
+
const innerWrapperClasses = computed(() => {
|
|
186
|
+
// h-full + items-center let a consumer-sized card (e.g. h-[76px])
|
|
187
|
+
// center the glyph and text block vertically
|
|
188
|
+
const classes: string[] = ['group relative rounded-md flex h-full']
|
|
189
|
+
|
|
190
|
+
if (props.orientation === 'vertical') {
|
|
191
|
+
classes.push('flex-col items-center')
|
|
192
|
+
} else {
|
|
193
|
+
classes.push('items-center')
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (isDisabled.value) {
|
|
197
|
+
classes.push('opacity-50 cursor-not-allowed')
|
|
198
|
+
} else {
|
|
199
|
+
classes.push('cursor-pointer')
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (props.variant === 'card') {
|
|
203
|
+
classes.push('p-2 ring-inset')
|
|
204
|
+
if (isChecked.value && !error.value) {
|
|
205
|
+
// checked
|
|
206
|
+
classes.push('bg-actions-primary-idle-alpha-light ring-2 ring-actions-primary-idle')
|
|
207
|
+
} else if (!isChecked.value && !error.value) {
|
|
208
|
+
// idle
|
|
209
|
+
classes.push('bg-actions-neutral-idle-alpha-light ring-transparent')
|
|
210
|
+
} else if (isChecked.value && error.value) {
|
|
211
|
+
// error + checked
|
|
212
|
+
classes.push('bg-actions-danger-idle-alpha-light ring-2 ring-actions-danger-idle')
|
|
213
|
+
} else {
|
|
214
|
+
// error + unchecked
|
|
215
|
+
classes.push('bg-actions-danger-idle-alpha-light')
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return classes
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
const itemRef = ref<InstanceType<typeof RadioGroupItem> | null>(null)
|
|
223
|
+
|
|
224
|
+
function radioElement (): HTMLElement | undefined {
|
|
225
|
+
return (itemRef.value?.$el as HTMLElement | undefined) ?? undefined
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Make the whole wrapper (description, padding, card surface) select the radio.
|
|
229
|
+
// Clicks on the button itself or on the label are skipped: the button handles
|
|
230
|
+
// its own clicks and the label forwards to the button natively via `for`.
|
|
231
|
+
function onWrapperClick (event: MouseEvent) {
|
|
232
|
+
if (isDisabled.value) {
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const target = event.target as HTMLElement
|
|
237
|
+
const radioEl = radioElement()
|
|
238
|
+
|
|
239
|
+
if (!radioEl || target === radioEl || radioEl.contains(target)) {
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
if (target.closest('label')) {
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
radioEl.click()
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
defineExpose({
|
|
250
|
+
focus: () => radioElement()?.focus(),
|
|
251
|
+
blur: () => radioElement()?.blur(),
|
|
252
|
+
})
|
|
253
|
+
</script>
|
|
254
|
+
|
|
255
|
+
<template>
|
|
256
|
+
<!-- Cards are edge-to-edge interactive surfaces, so they fill their slot;
|
|
257
|
+
the default variant shrink-wraps to keep the click zone on content -->
|
|
258
|
+
<div
|
|
259
|
+
class="ui-input-radio-wrapper"
|
|
260
|
+
:class="variant === 'card' ? 'w-full' : 'w-fit'"
|
|
261
|
+
>
|
|
262
|
+
<div
|
|
263
|
+
:class="innerWrapperClasses"
|
|
264
|
+
@click="onWrapperClick"
|
|
265
|
+
>
|
|
266
|
+
<!-- Radio - horizontal only, centered against the whole text block -->
|
|
267
|
+
<span
|
|
268
|
+
v-if="orientation === 'horizontal'"
|
|
269
|
+
class="flex w-5 items-center justify-center shrink-0 mr-2"
|
|
270
|
+
>
|
|
271
|
+
<RadioGroupItem
|
|
272
|
+
:id="inputId"
|
|
273
|
+
ref="itemRef"
|
|
274
|
+
:value="value"
|
|
275
|
+
:disabled="disabled"
|
|
276
|
+
:class="radioClasses"
|
|
277
|
+
:aria-invalid="error || undefined"
|
|
278
|
+
:aria-describedby="ariaDescribedby"
|
|
279
|
+
@select="emit('select', $event)"
|
|
280
|
+
>
|
|
281
|
+
<RadioGroupIndicator :class="indicatorClasses" />
|
|
282
|
+
<span
|
|
283
|
+
v-if="!isChecked && !isDisabled"
|
|
284
|
+
:class="previewDotClasses"
|
|
285
|
+
/>
|
|
286
|
+
<div
|
|
287
|
+
v-if="!isDisabled"
|
|
288
|
+
class="absolute inset-0 rounded-full opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
|
|
289
|
+
:class="hoverOverlayClasses"
|
|
290
|
+
/>
|
|
291
|
+
</RadioGroupItem>
|
|
292
|
+
</span>
|
|
293
|
+
|
|
294
|
+
<div
|
|
295
|
+
class="flex flex-col min-w-0"
|
|
296
|
+
:class="orientation === 'vertical' ? 'items-center' : 'flex-1'"
|
|
297
|
+
>
|
|
298
|
+
<div class="flex items-center">
|
|
299
|
+
<Label
|
|
300
|
+
:for="inputId"
|
|
301
|
+
class="text-content-text-md-medium"
|
|
302
|
+
:class="labelClasses"
|
|
303
|
+
>
|
|
304
|
+
<slot name="label">
|
|
305
|
+
{{ label }}
|
|
306
|
+
</slot>
|
|
307
|
+
</Label>
|
|
308
|
+
|
|
309
|
+
<!-- info tooltip -->
|
|
310
|
+
<UiTooltip
|
|
311
|
+
v-if="labelInfo"
|
|
312
|
+
:content="labelInfo"
|
|
313
|
+
>
|
|
314
|
+
<div
|
|
315
|
+
class="flex items-center justify-center size-4 cursor-help shrink-0 ml-1"
|
|
316
|
+
:class="{
|
|
317
|
+
'text-text-neutral-secondary': !error && !isChecked,
|
|
318
|
+
'text-text-danger-secondary': error,
|
|
319
|
+
'text-text-primary-secondary': isChecked && !error,
|
|
320
|
+
}"
|
|
321
|
+
@click.stop
|
|
322
|
+
>
|
|
323
|
+
<span class="i-ph-info-fill size-4" />
|
|
324
|
+
</div>
|
|
325
|
+
</UiTooltip>
|
|
326
|
+
</div>
|
|
327
|
+
|
|
328
|
+
<span
|
|
329
|
+
v-if="description"
|
|
330
|
+
:id="descriptionId"
|
|
331
|
+
class="text-content-text-md-medium"
|
|
332
|
+
:class="descriptionClasses"
|
|
333
|
+
>
|
|
334
|
+
{{ description }}
|
|
335
|
+
</span>
|
|
336
|
+
</div>
|
|
337
|
+
|
|
338
|
+
<!-- Radio - vertical only -->
|
|
339
|
+
<span
|
|
340
|
+
v-if="orientation === 'vertical'"
|
|
341
|
+
class="flex justify-center mt-2"
|
|
342
|
+
>
|
|
343
|
+
<RadioGroupItem
|
|
344
|
+
:id="inputId"
|
|
345
|
+
ref="itemRef"
|
|
346
|
+
:value="value"
|
|
347
|
+
:disabled="disabled"
|
|
348
|
+
:class="radioClasses"
|
|
349
|
+
:aria-invalid="error || undefined"
|
|
350
|
+
:aria-describedby="ariaDescribedby"
|
|
351
|
+
@select="emit('select', $event)"
|
|
352
|
+
>
|
|
353
|
+
<RadioGroupIndicator :class="indicatorClasses" />
|
|
354
|
+
<span
|
|
355
|
+
v-if="!isChecked && !isDisabled"
|
|
356
|
+
:class="previewDotClasses"
|
|
357
|
+
/>
|
|
358
|
+
<div
|
|
359
|
+
v-if="!isDisabled"
|
|
360
|
+
class="absolute inset-0 rounded-full opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
|
|
361
|
+
:class="hoverOverlayClasses"
|
|
362
|
+
/>
|
|
363
|
+
</RadioGroupItem>
|
|
364
|
+
</span>
|
|
365
|
+
|
|
366
|
+
<!-- Card hover overlay -->
|
|
367
|
+
<div
|
|
368
|
+
v-if="variant === 'card' && !isDisabled"
|
|
369
|
+
class="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none rounded-md"
|
|
370
|
+
:class="hoverOverlayClasses"
|
|
371
|
+
/>
|
|
372
|
+
</div>
|
|
373
|
+
</div>
|
|
374
|
+
</template>
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, provide, ref, toRef, watch } from 'vue'
|
|
3
|
+
import { RadioGroupRoot, useId } from 'reka-ui'
|
|
4
|
+
import type { RadioSize, RadioValue } from './types'
|
|
5
|
+
import { RADIO_GROUP_KEY } from './types'
|
|
6
|
+
|
|
7
|
+
interface UiInputRadioGroupProps {
|
|
8
|
+
/**
|
|
9
|
+
* The selected value. `undefined` means no selection;
|
|
10
|
+
* `null` is a valid option value.
|
|
11
|
+
*/
|
|
12
|
+
modelValue?: RadioValue
|
|
13
|
+
/**
|
|
14
|
+
* Name attribute, submitted with the form via a hidden input
|
|
15
|
+
*/
|
|
16
|
+
name?: string
|
|
17
|
+
/**
|
|
18
|
+
* Whether the whole group is disabled
|
|
19
|
+
* @default false
|
|
20
|
+
*/
|
|
21
|
+
disabled?: boolean
|
|
22
|
+
/**
|
|
23
|
+
* Whether arrow-key navigation loops from last item to first
|
|
24
|
+
* @default true
|
|
25
|
+
*/
|
|
26
|
+
loop?: boolean
|
|
27
|
+
/**
|
|
28
|
+
* Whether a selection is required
|
|
29
|
+
* @default false
|
|
30
|
+
*/
|
|
31
|
+
required?: boolean
|
|
32
|
+
/**
|
|
33
|
+
* Whether the group is in an error state
|
|
34
|
+
* @default false
|
|
35
|
+
*/
|
|
36
|
+
error?: boolean
|
|
37
|
+
/**
|
|
38
|
+
* Error message displayed below the group
|
|
39
|
+
*/
|
|
40
|
+
errorMessage?: string
|
|
41
|
+
/**
|
|
42
|
+
* Size of the radio items
|
|
43
|
+
* @default 'md'
|
|
44
|
+
*/
|
|
45
|
+
size?: RadioSize
|
|
46
|
+
/**
|
|
47
|
+
* Layout direction of the items. Also sets the group's `aria-orientation`
|
|
48
|
+
* and the arrow-key navigation axis to match.
|
|
49
|
+
* @default 'vertical'
|
|
50
|
+
*/
|
|
51
|
+
orientation?: 'vertical' | 'horizontal'
|
|
52
|
+
/**
|
|
53
|
+
* Whether the group stretches to fill its container instead of
|
|
54
|
+
* shrink-wrapping its items
|
|
55
|
+
* @default false
|
|
56
|
+
*/
|
|
57
|
+
fullWidth?: boolean
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const props = withDefaults(defineProps<UiInputRadioGroupProps>(), {
|
|
61
|
+
modelValue: undefined,
|
|
62
|
+
name: undefined,
|
|
63
|
+
errorMessage: undefined,
|
|
64
|
+
disabled: false,
|
|
65
|
+
loop: true,
|
|
66
|
+
required: false,
|
|
67
|
+
error: false,
|
|
68
|
+
size: 'md',
|
|
69
|
+
orientation: 'vertical',
|
|
70
|
+
fullWidth: false,
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const emit = defineEmits<{
|
|
74
|
+
'update:modelValue': [value: RadioValue]
|
|
75
|
+
}>()
|
|
76
|
+
|
|
77
|
+
// Track the current value locally so items can style themselves even when
|
|
78
|
+
// the consumer does not bind v-model (uncontrolled usage)
|
|
79
|
+
const currentValue = ref<RadioValue | undefined>(props.modelValue)
|
|
80
|
+
|
|
81
|
+
watch(() => props.modelValue, (value) => {
|
|
82
|
+
currentValue.value = value
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
function onUpdateModelValue (value: RadioValue) {
|
|
86
|
+
currentValue.value = value
|
|
87
|
+
emit('update:modelValue', value)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const groupId = useId()
|
|
91
|
+
|
|
92
|
+
const displayMessage = computed(() => {
|
|
93
|
+
if (props.error && props.errorMessage) {
|
|
94
|
+
return props.errorMessage
|
|
95
|
+
}
|
|
96
|
+
return undefined
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
const messageId = computed(() => displayMessage.value ? `${groupId}-message` : undefined)
|
|
100
|
+
|
|
101
|
+
// items-stretch keeps card tiles equal-height when they share a row
|
|
102
|
+
const rootClasses = computed(() =>
|
|
103
|
+
props.orientation === 'horizontal'
|
|
104
|
+
? 'flex flex-row flex-wrap items-stretch gap-2'
|
|
105
|
+
: 'flex flex-col gap-2',
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
const displayMessageClasses = computed(() => {
|
|
109
|
+
const baseClasses = ['text-content-legends-bold-md mt-1']
|
|
110
|
+
|
|
111
|
+
if (props.error) {
|
|
112
|
+
baseClasses.push('text-text-danger-secondary')
|
|
113
|
+
} else {
|
|
114
|
+
baseClasses.push('text-text-neutral-secondary')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return baseClasses
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
provide(RADIO_GROUP_KEY, {
|
|
121
|
+
modelValue: currentValue,
|
|
122
|
+
error: toRef(props, 'error'),
|
|
123
|
+
size: toRef(props, 'size'),
|
|
124
|
+
disabled: toRef(props, 'disabled'),
|
|
125
|
+
messageId,
|
|
126
|
+
})
|
|
127
|
+
</script>
|
|
128
|
+
|
|
129
|
+
<template>
|
|
130
|
+
<div
|
|
131
|
+
class="ui-input-radio-group-wrapper"
|
|
132
|
+
:class="fullWidth ? 'w-full' : 'w-fit'"
|
|
133
|
+
>
|
|
134
|
+
<RadioGroupRoot
|
|
135
|
+
:model-value="currentValue"
|
|
136
|
+
:name="name"
|
|
137
|
+
:disabled="disabled"
|
|
138
|
+
:loop="loop"
|
|
139
|
+
:required="required"
|
|
140
|
+
:orientation="orientation"
|
|
141
|
+
:class="rootClasses"
|
|
142
|
+
@update:model-value="onUpdateModelValue"
|
|
143
|
+
>
|
|
144
|
+
<slot />
|
|
145
|
+
</RadioGroupRoot>
|
|
146
|
+
|
|
147
|
+
<!-- Error Message -->
|
|
148
|
+
<p
|
|
149
|
+
v-if="displayMessage"
|
|
150
|
+
:id="messageId"
|
|
151
|
+
:class="displayMessageClasses"
|
|
152
|
+
>
|
|
153
|
+
{{ displayMessage }}
|
|
154
|
+
</p>
|
|
155
|
+
</div>
|
|
156
|
+
</template>
|