@livestorm/ui-vue 2.0.4 → 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 +8 -5
- package/dist/UiTimelineItem.vue_vue_type_style_index_0_lang-LpdvGf5X.mjs +6093 -0
- package/dist/a11y-BhF_nl7L.mjs +159 -0
- package/dist/components.mjs +105 -96
- package/dist/index.mjs +68 -64
- package/dist/layouts.mjs +16 -16
- 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/dist/{useThemeAndModes-CUK56j0z.mjs → useThemeAndModes-BzYwSOEv.mjs} +1 -1
- package/dist/utils.mjs +154 -99
- package/index.d.ts +4 -0
- package/index.js +8 -0
- package/package.json +11 -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 +4 -0
- package/src/components/UiButtonGroup/UiButtonGroupItem.vue +14 -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 -29
- 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-BtG5oABt.mjs +0 -5454
- package/dist/a11y--y7uaY4j.mjs +0 -217
|
@@ -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>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { InjectionKey, Ref } from 'vue'
|
|
2
|
+
import type { AcceptableValue } from 'reka-ui'
|
|
3
|
+
|
|
4
|
+
export type RadioSize = 'sm' | 'md' | 'lg'
|
|
5
|
+
|
|
6
|
+
export type RadioVariant = 'default' | 'card'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Valid radio option values. `null` is a supported option value;
|
|
10
|
+
* `undefined` means "no selection". Object values must be reference-stable.
|
|
11
|
+
*/
|
|
12
|
+
export type RadioValue = AcceptableValue
|
|
13
|
+
|
|
14
|
+
export interface RadioGroupContext {
|
|
15
|
+
/** Current group value (kept in sync in both controlled and uncontrolled mode) */
|
|
16
|
+
modelValue: Readonly<Ref<RadioValue | undefined>>
|
|
17
|
+
error: Readonly<Ref<boolean>>
|
|
18
|
+
size: Readonly<Ref<RadioSize>>
|
|
19
|
+
disabled: Readonly<Ref<boolean>>
|
|
20
|
+
/** id of the group error message, for aria-describedby on items */
|
|
21
|
+
messageId: Readonly<Ref<string | undefined>>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const RADIO_GROUP_KEY: InjectionKey<RadioGroupContext> =
|
|
25
|
+
Symbol('ui-input-radio-group')
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
<svg
|
|
4
4
|
:class="[
|
|
5
5
|
sizeClass,
|
|
6
|
-
'text-
|
|
6
|
+
'text-actions-neutral-idle animate-spin',
|
|
7
7
|
]"
|
|
8
8
|
xmlns="http://www.w3.org/2000/svg"
|
|
9
9
|
fill="none"
|
|
@@ -26,20 +26,18 @@
|
|
|
26
26
|
</div>
|
|
27
27
|
</template>
|
|
28
28
|
<script lang="ts" setup>
|
|
29
|
-
import {
|
|
29
|
+
import { SIZES, type Size } from '../../constants'
|
|
30
|
+
import { useIconSize } from '../../composables/useIconSize'
|
|
30
31
|
|
|
31
32
|
const props = defineProps({
|
|
32
33
|
size: {
|
|
33
|
-
type: String,
|
|
34
|
+
type: String as () => Size,
|
|
34
35
|
default: 'md',
|
|
35
|
-
validator: (value:
|
|
36
|
+
validator: (value: Size) => SIZES.includes(value),
|
|
36
37
|
},
|
|
37
38
|
})
|
|
38
39
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if (props.size === 'lg') return 'size-8'
|
|
43
|
-
return 'size-6'
|
|
44
|
-
})
|
|
40
|
+
// Same ladder as the icons the spinner stands in for, so a button swapping its
|
|
41
|
+
// icon for the loader doesn't resize the content and shift the label.
|
|
42
|
+
const sizeClass = useIconSize(() => props.size)
|
|
45
43
|
</script>
|
|
@@ -5,6 +5,7 @@ import PulseVariant from "./variants/PulseVariant.vue";
|
|
|
5
5
|
|
|
6
6
|
// CSS/svg remake of the lottie tornado logo (UiAnimatedLogo), with themable
|
|
7
7
|
// colors. Animations live in variants/.
|
|
8
|
+
// The viewBox hugs the badge, so height/width size the visible logo.
|
|
8
9
|
// A variant renders the .badge/.mark paths, keeps its keyframes in its own
|
|
9
10
|
// scoped style, and tags animated elements with .motion — then goes in the
|
|
10
11
|
// VARIANTS map.
|
|
@@ -17,10 +18,15 @@ interface Props {
|
|
|
17
18
|
width: number;
|
|
18
19
|
/** Follow the brand palette (--color-brand-800) instead of the Livestorm navy. */
|
|
19
20
|
themed?: boolean;
|
|
20
|
-
/**
|
|
21
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Dark badge + white mark, for light backgrounds. `"auto"` inverts
|
|
23
|
+
* whenever the logo sits inside a light `data-colors-semantic` scope.
|
|
24
|
+
*/
|
|
25
|
+
inverted?: boolean | "auto";
|
|
22
26
|
/** When false, renders the static logo (the resting pose). */
|
|
23
27
|
animated?: boolean;
|
|
28
|
+
/** Accessible name announced for the logo. */
|
|
29
|
+
label?: string;
|
|
24
30
|
}
|
|
25
31
|
|
|
26
32
|
// vue casts absent boolean props to false, hence the explicit defaults
|
|
@@ -29,6 +35,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
29
35
|
themed: false,
|
|
30
36
|
inverted: false,
|
|
31
37
|
animated: true,
|
|
38
|
+
label: "Livestorm",
|
|
32
39
|
});
|
|
33
40
|
|
|
34
41
|
const VARIANTS = {
|
|
@@ -39,10 +46,17 @@ const VARIANTS = {
|
|
|
39
46
|
const variantComponent = computed(() => VARIANTS[props.variant]);
|
|
40
47
|
|
|
41
48
|
const markColor = computed(() =>
|
|
42
|
-
props.themed ? "var(--color-brand-800, #
|
|
49
|
+
props.themed ? "var(--color-brand-800, #12262B)" : "#12262B",
|
|
43
50
|
);
|
|
44
51
|
|
|
45
52
|
const colorVars = computed(() => {
|
|
53
|
+
if (props.inverted === "auto") {
|
|
54
|
+
// colors live in the scope-aware rules below
|
|
55
|
+
return props.themed
|
|
56
|
+
? { "--ui-logo-auto-mark": "var(--color-brand-800, #12262B)" }
|
|
57
|
+
: {};
|
|
58
|
+
}
|
|
59
|
+
|
|
46
60
|
if (props.inverted) {
|
|
47
61
|
return {
|
|
48
62
|
"--ui-logo-badge": markColor.value,
|
|
@@ -63,15 +77,18 @@ const colorVars = computed(() => {
|
|
|
63
77
|
<div class="ui-logo-loader">
|
|
64
78
|
<svg
|
|
65
79
|
class="ui-logo"
|
|
66
|
-
:class="{
|
|
80
|
+
:class="{
|
|
81
|
+
'ui-logo--static': !props.animated,
|
|
82
|
+
'ui-logo--invert-auto': props.inverted === 'auto',
|
|
83
|
+
}"
|
|
67
84
|
:style="{
|
|
68
85
|
height: props.height + 'rem',
|
|
69
86
|
width: props.width + 'rem',
|
|
70
87
|
...colorVars,
|
|
71
88
|
}"
|
|
72
|
-
viewBox="
|
|
89
|
+
viewBox="67.5 67.5 465 465"
|
|
73
90
|
role="img"
|
|
74
|
-
aria-label="
|
|
91
|
+
:aria-label="props.label"
|
|
75
92
|
>
|
|
76
93
|
<component :is="variantComponent" />
|
|
77
94
|
</svg>
|
|
@@ -84,13 +101,30 @@ const colorVars = computed(() => {
|
|
|
84
101
|
}
|
|
85
102
|
|
|
86
103
|
.ui-logo :deep(.mark) {
|
|
87
|
-
fill: var(--ui-logo-mark, #
|
|
104
|
+
fill: var(--ui-logo-mark, #12262B);
|
|
88
105
|
}
|
|
89
106
|
|
|
90
107
|
.ui-logo--static :deep(.motion) {
|
|
91
108
|
animation: none;
|
|
92
109
|
}
|
|
93
110
|
|
|
111
|
+
/* inverted="auto": the nearest data-colors-semantic scope decides.
|
|
112
|
+
Handles one level of scope nesting — all the app has. */
|
|
113
|
+
.ui-logo--invert-auto {
|
|
114
|
+
--ui-logo-badge: #ffffff;
|
|
115
|
+
--ui-logo-mark: var(--ui-logo-auto-mark, #12262B);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
[data-colors-semantic="light"] .ui-logo--invert-auto {
|
|
119
|
+
--ui-logo-badge: var(--ui-logo-auto-mark, #12262B);
|
|
120
|
+
--ui-logo-mark: #ffffff;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
[data-colors-semantic="light"] [data-colors-semantic="dark"] .ui-logo--invert-auto {
|
|
124
|
+
--ui-logo-badge: #ffffff;
|
|
125
|
+
--ui-logo-mark: var(--ui-logo-auto-mark, #12262B);
|
|
126
|
+
}
|
|
127
|
+
|
|
94
128
|
@media (prefers-reduced-motion: reduce) {
|
|
95
129
|
.ui-logo :deep(.motion) {
|
|
96
130
|
animation: none;
|