@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.
Files changed (85) hide show
  1. package/README.md +6 -3
  2. package/dist/UiTimelineItem.vue_vue_type_style_index_0_lang-LpdvGf5X.mjs +6093 -0
  3. package/dist/components.mjs +105 -96
  4. package/dist/index.mjs +68 -64
  5. package/dist/layouts.mjs +15 -15
  6. package/dist/types/index.d.ts +5 -1
  7. package/dist/types/src/components/UiActionBar/UiActionBar.vue.d.ts +3 -1
  8. package/dist/types/src/components/UiActionBar/UiActionBarItem.vue.d.ts +18 -3
  9. package/dist/types/src/components/UiActionBar/types.d.ts +3 -1
  10. package/dist/types/src/components/UiButton/UiButton.vue.d.ts +17 -13
  11. package/dist/types/src/components/UiButtonGroup/UiButtonGroupItem.vue.d.ts +3 -0
  12. package/dist/types/src/components/UiButtonGroup/types.d.ts +1 -1
  13. package/dist/types/src/components/UiDropdown/UiDropdownItem.vue.d.ts +6 -4
  14. package/dist/types/src/components/UiInputEmoji/UiInputEmoji.vue.d.ts +45 -0
  15. package/dist/types/src/components/UiInputEmoji/index.d.ts +1 -0
  16. package/dist/types/src/components/UiInputRadio/UiInputRadio.vue.d.ts +67 -0
  17. package/dist/types/src/components/UiInputRadio/UiInputRadioGroup.vue.d.ts +81 -0
  18. package/dist/types/src/components/UiInputRadio/index.d.ts +3 -0
  19. package/dist/types/src/components/UiInputRadio/types.d.ts +19 -0
  20. package/dist/types/src/components/UiLoader/UiLoader.vue.d.ts +6 -5
  21. package/dist/types/src/components/UiLogo/UiLogo.vue.d.ts +9 -3
  22. package/dist/types/src/components/UiPage/UiPage.vue.d.ts +34 -0
  23. package/dist/types/src/components/UiProgress/UiProgress.vue.d.ts +1 -1
  24. package/dist/types/src/components/UiSwitch/UiSwitch.vue.d.ts +109 -0
  25. package/dist/types/src/components/UiSwitch/index.d.ts +2 -0
  26. package/dist/types/src/components/UiSwitch/types.d.ts +3 -0
  27. package/dist/types/src/components/UiTabs/UiTabs.vue.d.ts +5 -2
  28. package/dist/types/src/composables/useContentMode.d.ts +25 -0
  29. package/dist/types/src/composables/useIconSize.d.ts +11 -0
  30. package/dist/types/src/constants/sizes.d.ts +1 -1
  31. package/dist/ui-vue.css +1 -1
  32. package/index.d.ts +4 -0
  33. package/index.js +8 -0
  34. package/package.json +6 -3
  35. package/src/components/Toast/ToastMessage.vue +6 -6
  36. package/src/components/UiActionBar/UiActionBar.vue +11 -2
  37. package/src/components/UiActionBar/UiActionBarItem.vue +56 -32
  38. package/src/components/UiActionBar/types.ts +3 -1
  39. package/src/components/UiAvatar/UiAvatar.vue +1 -1
  40. package/src/components/UiBanner/UiBanner.vue +7 -7
  41. package/src/components/UiButton/UiButton.vue +9 -9
  42. package/src/components/UiButtonGroup/UiButtonGroup.vue +2 -0
  43. package/src/components/UiButtonGroup/UiButtonGroupItem.vue +12 -0
  44. package/src/components/UiButtonGroup/types.ts +1 -1
  45. package/src/components/UiCarousel/UiCarousel.vue +2 -2
  46. package/src/components/UiColorPicker/UiColorPicker.vue +5 -5
  47. package/src/components/UiDialog/MarketplaceHeaderExample.vue +2 -2
  48. package/src/components/UiDialog/UiDialogClose.vue +1 -1
  49. package/src/components/UiDropdown/UiDropdownContent.vue +2 -2
  50. package/src/components/UiDropdown/UiDropdownItem.vue +8 -1
  51. package/src/components/UiDropdown/UiDropdownTrigger.vue +6 -7
  52. package/src/components/UiFieldWrapper/UiFieldWrapper.vue +1 -1
  53. package/src/components/UiInputCheckbox/UiInputCheckbox.vue +2 -2
  54. package/src/components/UiInputEmoji/UiInputEmoji.vue +173 -0
  55. package/src/components/UiInputEmoji/index.ts +1 -0
  56. package/src/components/UiInputRadio/UiInputRadio.vue +374 -0
  57. package/src/components/UiInputRadio/UiInputRadioGroup.vue +156 -0
  58. package/src/components/UiInputRadio/index.ts +3 -0
  59. package/src/components/UiInputRadio/types.ts +25 -0
  60. package/src/components/UiLoader/UiLoader.vue +8 -10
  61. package/src/components/UiLogo/UiLogo.vue +41 -7
  62. package/src/components/UiMenu/UiMenuContent.vue +1 -1
  63. package/src/components/UiMenu/UiMenuGroup.vue +1 -1
  64. package/src/components/UiMenu/UiMenuItem.vue +1 -1
  65. package/src/components/UiMenu/UiMenuSubContent.vue +1 -1
  66. package/src/components/UiMenu/UiMenuSubTrigger.vue +2 -2
  67. package/src/components/UiPage/UiPage.vue +102 -34
  68. package/src/components/UiPageHeader/UiPageHeader.vue +29 -9
  69. package/src/components/UiRoomButton/UiRoomButton.vue +4 -4
  70. package/src/components/UiShell/UiShell.vue +1 -1
  71. package/src/components/UiSideMenu/UiSideMenu.vue +13 -10
  72. package/src/components/UiSideMenu/UiSideMenuItemContent.vue +2 -2
  73. package/src/components/UiStepper/UiStepperIndicator.vue +8 -11
  74. package/src/components/UiSwitch/UiSwitch.vue +359 -0
  75. package/src/components/UiSwitch/index.ts +2 -0
  76. package/src/components/UiSwitch/types.ts +5 -0
  77. package/src/components/UiTabs/UiTabs.vue +7 -4
  78. package/src/components/UiTextField/UiTextField.vue +6 -11
  79. package/src/components/UiTimeline/README.md +6 -6
  80. package/src/components/UiTimeline/UiTimelineItem.vue +3 -3
  81. package/src/composables/useContentMode.ts +53 -0
  82. package/src/composables/useIconSize.ts +28 -0
  83. package/src/constants/sizes.ts +1 -1
  84. package/src/global.d.ts +8 -0
  85. 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>