@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.
Files changed (89) hide show
  1. package/README.md +8 -5
  2. package/dist/UiTimelineItem.vue_vue_type_style_index_0_lang-LpdvGf5X.mjs +6093 -0
  3. package/dist/a11y-BhF_nl7L.mjs +159 -0
  4. package/dist/components.mjs +105 -96
  5. package/dist/index.mjs +68 -64
  6. package/dist/layouts.mjs +16 -16
  7. package/dist/types/index.d.ts +5 -1
  8. package/dist/types/src/components/UiActionBar/UiActionBar.vue.d.ts +3 -1
  9. package/dist/types/src/components/UiActionBar/UiActionBarItem.vue.d.ts +18 -3
  10. package/dist/types/src/components/UiActionBar/types.d.ts +3 -1
  11. package/dist/types/src/components/UiButton/UiButton.vue.d.ts +17 -13
  12. package/dist/types/src/components/UiButtonGroup/UiButtonGroupItem.vue.d.ts +3 -0
  13. package/dist/types/src/components/UiButtonGroup/types.d.ts +1 -1
  14. package/dist/types/src/components/UiDropdown/UiDropdownItem.vue.d.ts +6 -4
  15. package/dist/types/src/components/UiInputEmoji/UiInputEmoji.vue.d.ts +45 -0
  16. package/dist/types/src/components/UiInputEmoji/index.d.ts +1 -0
  17. package/dist/types/src/components/UiInputRadio/UiInputRadio.vue.d.ts +67 -0
  18. package/dist/types/src/components/UiInputRadio/UiInputRadioGroup.vue.d.ts +81 -0
  19. package/dist/types/src/components/UiInputRadio/index.d.ts +3 -0
  20. package/dist/types/src/components/UiInputRadio/types.d.ts +19 -0
  21. package/dist/types/src/components/UiLoader/UiLoader.vue.d.ts +6 -5
  22. package/dist/types/src/components/UiLogo/UiLogo.vue.d.ts +9 -3
  23. package/dist/types/src/components/UiPage/UiPage.vue.d.ts +34 -0
  24. package/dist/types/src/components/UiProgress/UiProgress.vue.d.ts +1 -1
  25. package/dist/types/src/components/UiSwitch/UiSwitch.vue.d.ts +109 -0
  26. package/dist/types/src/components/UiSwitch/index.d.ts +2 -0
  27. package/dist/types/src/components/UiSwitch/types.d.ts +3 -0
  28. package/dist/types/src/components/UiTabs/UiTabs.vue.d.ts +5 -2
  29. package/dist/types/src/composables/useContentMode.d.ts +25 -0
  30. package/dist/types/src/composables/useIconSize.d.ts +11 -0
  31. package/dist/types/src/constants/sizes.d.ts +1 -1
  32. package/dist/ui-vue.css +1 -1
  33. package/dist/{useThemeAndModes-CUK56j0z.mjs → useThemeAndModes-BzYwSOEv.mjs} +1 -1
  34. package/dist/utils.mjs +154 -99
  35. package/index.d.ts +4 -0
  36. package/index.js +8 -0
  37. package/package.json +11 -3
  38. package/src/components/Toast/ToastMessage.vue +6 -6
  39. package/src/components/UiActionBar/UiActionBar.vue +11 -2
  40. package/src/components/UiActionBar/UiActionBarItem.vue +56 -32
  41. package/src/components/UiActionBar/types.ts +3 -1
  42. package/src/components/UiAvatar/UiAvatar.vue +1 -1
  43. package/src/components/UiBanner/UiBanner.vue +7 -7
  44. package/src/components/UiButton/UiButton.vue +9 -9
  45. package/src/components/UiButtonGroup/UiButtonGroup.vue +4 -0
  46. package/src/components/UiButtonGroup/UiButtonGroupItem.vue +14 -0
  47. package/src/components/UiButtonGroup/types.ts +1 -1
  48. package/src/components/UiCarousel/UiCarousel.vue +2 -2
  49. package/src/components/UiColorPicker/UiColorPicker.vue +5 -5
  50. package/src/components/UiDialog/MarketplaceHeaderExample.vue +2 -2
  51. package/src/components/UiDialog/UiDialogClose.vue +1 -1
  52. package/src/components/UiDropdown/UiDropdownContent.vue +2 -2
  53. package/src/components/UiDropdown/UiDropdownItem.vue +8 -1
  54. package/src/components/UiDropdown/UiDropdownTrigger.vue +6 -7
  55. package/src/components/UiFieldWrapper/UiFieldWrapper.vue +1 -1
  56. package/src/components/UiInputCheckbox/UiInputCheckbox.vue +2 -2
  57. package/src/components/UiInputEmoji/UiInputEmoji.vue +173 -0
  58. package/src/components/UiInputEmoji/index.ts +1 -0
  59. package/src/components/UiInputRadio/UiInputRadio.vue +374 -0
  60. package/src/components/UiInputRadio/UiInputRadioGroup.vue +156 -0
  61. package/src/components/UiInputRadio/index.ts +3 -0
  62. package/src/components/UiInputRadio/types.ts +25 -0
  63. package/src/components/UiLoader/UiLoader.vue +8 -10
  64. package/src/components/UiLogo/UiLogo.vue +41 -7
  65. package/src/components/UiMenu/UiMenuContent.vue +1 -1
  66. package/src/components/UiMenu/UiMenuGroup.vue +1 -1
  67. package/src/components/UiMenu/UiMenuItem.vue +1 -1
  68. package/src/components/UiMenu/UiMenuSubContent.vue +1 -1
  69. package/src/components/UiMenu/UiMenuSubTrigger.vue +2 -2
  70. package/src/components/UiPage/UiPage.vue +102 -29
  71. package/src/components/UiPageHeader/UiPageHeader.vue +29 -9
  72. package/src/components/UiRoomButton/UiRoomButton.vue +4 -4
  73. package/src/components/UiShell/UiShell.vue +1 -1
  74. package/src/components/UiSideMenu/UiSideMenu.vue +13 -10
  75. package/src/components/UiSideMenu/UiSideMenuItemContent.vue +2 -2
  76. package/src/components/UiStepper/UiStepperIndicator.vue +8 -11
  77. package/src/components/UiSwitch/UiSwitch.vue +359 -0
  78. package/src/components/UiSwitch/index.ts +2 -0
  79. package/src/components/UiSwitch/types.ts +5 -0
  80. package/src/components/UiTabs/UiTabs.vue +7 -4
  81. package/src/components/UiTextField/UiTextField.vue +6 -11
  82. package/src/components/UiTimeline/README.md +6 -6
  83. package/src/components/UiTimeline/UiTimelineItem.vue +3 -3
  84. package/src/composables/useContentMode.ts +53 -0
  85. package/src/composables/useIconSize.ts +28 -0
  86. package/src/constants/sizes.ts +1 -1
  87. package/src/global.d.ts +8 -0
  88. package/dist/UiTimelineItem.vue_vue_type_style_index_0_lang-BtG5oABt.mjs +0 -5454
  89. 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,3 @@
1
+ export { default as UiInputRadio } from './UiInputRadio.vue'
2
+ export { default as UiInputRadioGroup } from './UiInputRadioGroup.vue'
3
+ export type { RadioSize, RadioValue, RadioVariant } from './types'
@@ -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-action-neutral-idle animate-spin',
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 { computed } from 'vue'
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: string) => ['sm', 'md', 'lg'].includes(value),
36
+ validator: (value: Size) => SIZES.includes(value),
36
37
  },
37
38
  })
38
39
 
39
- const sizeClass = computed(() => {
40
- if (props.size === 'sm') return 'size-4'
41
- if (props.size === 'md') return 'size-6'
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
- /** Dark badge + white mark, for light backgrounds. */
21
- inverted?: boolean;
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, #112660)" : "#112660",
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="{ 'ui-logo--static': !props.animated }"
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="0 0 600 600"
89
+ viewBox="67.5 67.5 465 465"
73
90
  role="img"
74
- aria-label="Livestorm"
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, #112660);
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;