@meistrari/tela-build 1.67.1 → 1.69.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/components/tela/combobox/combobox-input.vue +1 -1
- package/components/tela/combobox/combobox.mdx +18 -0
- package/components/tela/combobox/combobox.vue +178 -9
- package/components/tela/icon-button/icon-button.vue +1 -1
- package/components/tela/presence/presence-avatars.mdx +123 -0
- package/components/tela/presence/presence-avatars.vue +192 -0
- package/components/tela/tooltip-group/tooltip-group-trigger.vue +2 -1
- package/composables/__tests__/presence.test.ts +632 -0
- package/composables/presence.ts +336 -0
- package/package.json +5 -2
|
@@ -32,7 +32,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
|
|
32
32
|
>
|
|
33
33
|
<slot />
|
|
34
34
|
</ComboboxInput>
|
|
35
|
-
<div v-if="$slots.deleteSearchValue" class="absolute inset-y-0 end-0 flex items-center justify-center px-
|
|
35
|
+
<div v-if="$slots.deleteSearchValue" class="absolute inset-y-0 end-0 flex items-center justify-center px-1">
|
|
36
36
|
<slot name="deleteSearchValue" />
|
|
37
37
|
</div>
|
|
38
38
|
</div>
|
|
@@ -173,6 +173,23 @@ const options = [
|
|
|
173
173
|
/>
|
|
174
174
|
```
|
|
175
175
|
|
|
176
|
+
### Allow Create
|
|
177
|
+
|
|
178
|
+
Pins a "create new" action at the top of the list. It is never filtered out by the search — reka-ui registers an item's filter text once on mount, so the row is keyed by the search term to re-register as `textValue` and always match. When the user has typed something, `labelCreateWithSearch` renders the label with that term.
|
|
179
|
+
|
|
180
|
+
Picking it turns the row into a text input seeded with the current search term instead of closing the list. `create` is emitted with the confirmed name once the user presses Enter or clicks the check, so it never carries an empty name. Escape returns the row to the action.
|
|
181
|
+
|
|
182
|
+
```vue
|
|
183
|
+
<TelaCombobox
|
|
184
|
+
v-model="selected"
|
|
185
|
+
:options="projects"
|
|
186
|
+
allow-create
|
|
187
|
+
label-create="Create a new project"
|
|
188
|
+
:label-create-with-search="(search) => `Create \"${search}\"`"
|
|
189
|
+
@create="(search) => startCreating(search)"
|
|
190
|
+
/>
|
|
191
|
+
```
|
|
192
|
+
|
|
176
193
|
### Disable Portal
|
|
177
194
|
|
|
178
195
|
```vue
|
|
@@ -318,6 +335,7 @@ The Combobox system consists of these sub-components:
|
|
|
318
335
|
|
|
319
336
|
- `update:modelValue` - Emitted when selection changes with new value
|
|
320
337
|
- `select` - Emitted when an option is selected with the value
|
|
338
|
+
- `create` - Emitted with the name typed into the `allow-create` row, once the user confirms it
|
|
321
339
|
|
|
322
340
|
## Keyboard Shortcuts
|
|
323
341
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { Component, ComponentPublicInstance, Ref } from 'vue'
|
|
3
|
-
import { useMagicKeys } from '@vueuse/core'
|
|
3
|
+
import { useEventListener, useMagicKeys } from '@vueuse/core'
|
|
4
4
|
|
|
5
5
|
interface ComboboxOption {
|
|
6
6
|
value: string
|
|
@@ -63,6 +63,11 @@ const props = withDefaults(defineProps<{
|
|
|
63
63
|
labelMultimodal?: string
|
|
64
64
|
labelInputMax?: string
|
|
65
65
|
labelOutputMax?: string
|
|
66
|
+
allowCreate?: boolean
|
|
67
|
+
labelCreate?: string
|
|
68
|
+
labelCreateDescription?: string
|
|
69
|
+
labelCreateWithSearch?: (search: string) => string
|
|
70
|
+
labelCreatePlaceholder?: string
|
|
66
71
|
}>(), {
|
|
67
72
|
compact: true,
|
|
68
73
|
align: 'start',
|
|
@@ -87,17 +92,27 @@ const props = withDefaults(defineProps<{
|
|
|
87
92
|
labelMultimodal: 'Multimodal',
|
|
88
93
|
labelInputMax: 'Input max',
|
|
89
94
|
labelOutputMax: 'Output max',
|
|
95
|
+
allowCreate: false,
|
|
96
|
+
labelCreate: 'Create new',
|
|
97
|
+
labelCreatePlaceholder: 'Name',
|
|
90
98
|
})
|
|
91
99
|
|
|
92
100
|
const emit = defineEmits<{
|
|
93
101
|
'update:modelValue': [value: string]
|
|
94
102
|
'select': [value: string]
|
|
103
|
+
'create': [search: string]
|
|
95
104
|
}>()
|
|
96
105
|
|
|
106
|
+
// Per-instance so a consumer-supplied option value can never be mistaken for the synthetic create row.
|
|
107
|
+
const createOptionValue = `__tela-combobox-create__${useId()}`
|
|
108
|
+
|
|
97
109
|
const selectedTab = ref('all')
|
|
98
110
|
const search = ref('')
|
|
99
111
|
const innerValue = ref<string>('')
|
|
100
112
|
const isOpen = ref(false)
|
|
113
|
+
const isCreating = ref(false)
|
|
114
|
+
const createName = ref('')
|
|
115
|
+
const createInputEl = ref<HTMLInputElement>()
|
|
101
116
|
|
|
102
117
|
const currentOption = computed(() => {
|
|
103
118
|
if (!innerValue.value && !props.modelValue && props.placeholder) {
|
|
@@ -170,7 +185,7 @@ const groupedOptions = computed(() => {
|
|
|
170
185
|
}
|
|
171
186
|
|
|
172
187
|
if (!hasGroups.value) {
|
|
173
|
-
return [{ heading: props.labelOptionsGroup, children: filteredOptions }]
|
|
188
|
+
return filteredOptions.length ? [{ heading: props.labelOptionsGroup, children: filteredOptions }] : []
|
|
174
189
|
}
|
|
175
190
|
|
|
176
191
|
const groups: Record<string, typeof filteredOptions> = {}
|
|
@@ -193,6 +208,88 @@ const groupedOptions = computed(() => {
|
|
|
193
208
|
|
|
194
209
|
const isEmpty = computed(() => groupedOptions.value.every(group => group.children.length === 0))
|
|
195
210
|
|
|
211
|
+
const showCreateOption = computed(() => props.allowCreate && !isCreating.value && search.value.trim().length > 0 && isEmpty.value)
|
|
212
|
+
|
|
213
|
+
const createOption = computed(() => {
|
|
214
|
+
const term = search.value.trim()
|
|
215
|
+
return {
|
|
216
|
+
value: createOptionValue,
|
|
217
|
+
label: term && props.labelCreateWithSearch ? props.labelCreateWithSearch(term) : props.labelCreate,
|
|
218
|
+
}
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
let clearSearchOnOpen = false
|
|
222
|
+
|
|
223
|
+
function startCreating() {
|
|
224
|
+
createName.value = search.value.trim()
|
|
225
|
+
isCreating.value = true
|
|
226
|
+
nextTick(() => createInputEl.value?.focus())
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function resetCreating() {
|
|
230
|
+
isCreating.value = false
|
|
231
|
+
createName.value = ''
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function cancelCreating() {
|
|
235
|
+
if (createInputEl.value && document.activeElement === createInputEl.value) {
|
|
236
|
+
const searchInput = createInputEl.value.closest('.ComboboxContent')?.querySelector('input')
|
|
237
|
+
searchInput?.focus()
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
resetCreating()
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function confirmCreating() {
|
|
244
|
+
const name = createName.value.trim()
|
|
245
|
+
if (!name)
|
|
246
|
+
return
|
|
247
|
+
|
|
248
|
+
clearSearchOnOpen = true
|
|
249
|
+
isOpen.value = false
|
|
250
|
+
emit('create', name)
|
|
251
|
+
|
|
252
|
+
// Reka refocuses the trigger when the list unmounts; after a keyboard confirm that paints
|
|
253
|
+
// a focus-visible ring on it, which reads as if the field were still active.
|
|
254
|
+
nextTick(() => {
|
|
255
|
+
const active = document.activeElement
|
|
256
|
+
if (active instanceof HTMLElement && active.getAttribute('aria-haspopup') === 'listbox')
|
|
257
|
+
active.blur()
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function onCreateKeydown(event: KeyboardEvent) {
|
|
262
|
+
// The list owns arrow/enter navigation, so the input has to keep its keys to itself.
|
|
263
|
+
event.stopPropagation()
|
|
264
|
+
|
|
265
|
+
if (event.key === 'Enter') {
|
|
266
|
+
event.preventDefault()
|
|
267
|
+
confirmCreating()
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Escape is handled here, at window capture, instead of being left to Reka: TelaModal is a
|
|
272
|
+
// radix-vue Dialog with its own dismissable-layer stack, so both libraries believe they own the
|
|
273
|
+
// top layer and a single Esc would close the list and the modal together. Owning the key while
|
|
274
|
+
// the list is open gives the expected sequence: leave create mode, close the list, close the modal.
|
|
275
|
+
useEventListener(window, 'keydown', (event) => {
|
|
276
|
+
if (event.key !== 'Escape' || !isOpen.value)
|
|
277
|
+
return
|
|
278
|
+
|
|
279
|
+
event.preventDefault()
|
|
280
|
+
event.stopImmediatePropagation()
|
|
281
|
+
|
|
282
|
+
if (isCreating.value)
|
|
283
|
+
cancelCreating()
|
|
284
|
+
else
|
|
285
|
+
onOpenChange(false)
|
|
286
|
+
}, { capture: true })
|
|
287
|
+
|
|
288
|
+
watch(search, () => {
|
|
289
|
+
if (isCreating.value)
|
|
290
|
+
cancelCreating()
|
|
291
|
+
})
|
|
292
|
+
|
|
196
293
|
const handleDeleteSearchValue = () => search.value = ''
|
|
197
294
|
|
|
198
295
|
const keys = useMagicKeys()
|
|
@@ -261,7 +358,15 @@ function updateSubmenuPosition(itemValue: string) {
|
|
|
261
358
|
|
|
262
359
|
function onOpenChange(open: boolean) {
|
|
263
360
|
isOpen.value = open
|
|
264
|
-
|
|
361
|
+
|
|
362
|
+
if (open) {
|
|
363
|
+
resetCreating()
|
|
364
|
+
if (clearSearchOnOpen) {
|
|
365
|
+
search.value = ''
|
|
366
|
+
clearSearchOnOpen = false
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
else {
|
|
265
370
|
expandedItem.value = null
|
|
266
371
|
}
|
|
267
372
|
}
|
|
@@ -287,6 +392,11 @@ function handleChildSelect(child: ComboboxOption, event: Event) {
|
|
|
287
392
|
|
|
288
393
|
function handleSelect(option: any) {
|
|
289
394
|
const value = typeof option === 'string' ? option : option?.value
|
|
395
|
+
if (value === createOptionValue) {
|
|
396
|
+
innerValue.value = ''
|
|
397
|
+
startCreating()
|
|
398
|
+
return
|
|
399
|
+
}
|
|
290
400
|
const found = props.options.find(o => o.value === value)
|
|
291
401
|
if (found?.children?.length)
|
|
292
402
|
return
|
|
@@ -299,7 +409,7 @@ watch(innerValue, (raw) => {
|
|
|
299
409
|
return
|
|
300
410
|
|
|
301
411
|
const value = typeof raw === 'string' ? raw : (raw as any)?.value
|
|
302
|
-
if (!value)
|
|
412
|
+
if (!value || value === createOptionValue)
|
|
303
413
|
return
|
|
304
414
|
|
|
305
415
|
const option = props.options.find(o => o.value === value)
|
|
@@ -355,7 +465,16 @@ watch(innerValue, (raw) => {
|
|
|
355
465
|
v-if="hasSearchbar"
|
|
356
466
|
v-model="search"
|
|
357
467
|
:placeholder="inputPlaceholder || labelSearchPlaceholder"
|
|
358
|
-
|
|
468
|
+
>
|
|
469
|
+
<template v-if="allowCreate && !isCreating" #deleteSearchValue>
|
|
470
|
+
<TelaIconButton
|
|
471
|
+
icon="i-ph-plus"
|
|
472
|
+
size="sm"
|
|
473
|
+
color="secondary"
|
|
474
|
+
@click="startCreating"
|
|
475
|
+
/>
|
|
476
|
+
</template>
|
|
477
|
+
</TelaComboboxInput>
|
|
359
478
|
<TelaTabsRoot v-if="hasTabs" v-model="selectedTab">
|
|
360
479
|
<TelaTabsList class="sticky z-10 top-0 shrink-0 flex gap-2 w-full">
|
|
361
480
|
<TelaTabsIndicator />
|
|
@@ -365,8 +484,58 @@ watch(innerValue, (raw) => {
|
|
|
365
484
|
</TelaTabsList>
|
|
366
485
|
</TelaTabsRoot>
|
|
367
486
|
<div :class="cn('max-h-262px overflow-y-auto no-scrollbar', hasTabs && 'pt-2')">
|
|
487
|
+
<div v-if="isCreating || showCreateOption" :class="cn('px-4px pt-4px', isEmpty && 'pb-4px')">
|
|
488
|
+
<div
|
|
489
|
+
v-if="isCreating"
|
|
490
|
+
flex items-center h-36px rounded-12px
|
|
491
|
+
:class="cn(
|
|
492
|
+
'outline-solid outline-[1px] outline-offset-[-1px] outline-border',
|
|
493
|
+
!compact && 'px-6px',
|
|
494
|
+
compact ? 'gap-2' : 'gap-1.5',
|
|
495
|
+
)"
|
|
496
|
+
>
|
|
497
|
+
<div w-5 h-5 shrink-0 flex items-center justify-center>
|
|
498
|
+
<TelaIcon name="i-ph-plus" color="icon-tertiary" />
|
|
499
|
+
</div>
|
|
500
|
+
<input
|
|
501
|
+
ref="createInputEl"
|
|
502
|
+
v-model="createName"
|
|
503
|
+
:placeholder="labelCreatePlaceholder"
|
|
504
|
+
min-w-0 flex-1 bg-transparent text-primary outline-none
|
|
505
|
+
placeholder:text-tertiary
|
|
506
|
+
:class="compact ? 'body-12-regular font-540' : 'body-14-regular'"
|
|
507
|
+
@keydown="onCreateKeydown"
|
|
508
|
+
>
|
|
509
|
+
<TelaIconButton
|
|
510
|
+
icon="i-ph-check"
|
|
511
|
+
size="xs"
|
|
512
|
+
:disabled="!createName.trim()"
|
|
513
|
+
@click="confirmCreating"
|
|
514
|
+
/>
|
|
515
|
+
</div>
|
|
516
|
+
<TelaComboboxItem
|
|
517
|
+
v-else-if="showCreateOption"
|
|
518
|
+
:key="search"
|
|
519
|
+
:value="createOption"
|
|
520
|
+
:text-value="search"
|
|
521
|
+
:class="cn('py-2', !compact && '!px-1.5')"
|
|
522
|
+
@select="(e) => { e.preventDefault(); startCreating() }"
|
|
523
|
+
>
|
|
524
|
+
<div flex items-center min-w-0 :class="compact ? 'gap-2' : 'gap-1.5'">
|
|
525
|
+
<div w-5 h-5 shrink-0 flex items-center justify-center>
|
|
526
|
+
<TelaIcon name="i-ph-plus" color="icon-tertiary" />
|
|
527
|
+
</div>
|
|
528
|
+
<div flex flex-col min-w-0>
|
|
529
|
+
<span font-medium truncate text-tertiary :class="cn(compact ? 'text-sm font-540' : 'text-body-14-regular', labelItemClass)">{{ createOption.label }}</span>
|
|
530
|
+
<span v-if="labelCreateDescription" font-normal text-sm leading-none text-tertiary truncate :class="descriptionClass">
|
|
531
|
+
{{ labelCreateDescription }}
|
|
532
|
+
</span>
|
|
533
|
+
</div>
|
|
534
|
+
</div>
|
|
535
|
+
</TelaComboboxItem>
|
|
536
|
+
</div>
|
|
368
537
|
<TelaComboboxEmpty
|
|
369
|
-
v-if="isEmpty"
|
|
538
|
+
v-if="isEmpty && !showCreateOption && !isCreating"
|
|
370
539
|
class="flex flex-col items-center justify-center gap-5 px-7"
|
|
371
540
|
>
|
|
372
541
|
<div class="flex flex-col items-center justify-center gap-3">
|
|
@@ -425,11 +594,11 @@ watch(innerValue, (raw) => {
|
|
|
425
594
|
</div>
|
|
426
595
|
<div class="flex flex-col">
|
|
427
596
|
<div class="flex items-center gap-1">
|
|
428
|
-
<span :class="cn('font-medium truncate max-w-140px', compact ? 'text-sm font-
|
|
597
|
+
<span :class="cn('font-medium truncate max-w-140px', compact ? 'text-sm font-540' : 'text-body-14-regular', labelItemClass)">{{ item.label }}</span>
|
|
429
598
|
<TelaBadge v-if="item.isMultiModal" variant="filled" class="py-[2px] bg-muted" text-class="leading-none">
|
|
430
599
|
{{ labelMultimodal }}
|
|
431
600
|
</TelaBadge>
|
|
432
|
-
<span v-if="item.cost !== undefined" class="text-10px font-
|
|
601
|
+
<span v-if="item.cost !== undefined" class="text-10px font-540 ml-1px">
|
|
433
602
|
<span class="text-primary">{{ renderCostIndicator(item.cost)?.blackSymbols }}</span>
|
|
434
603
|
<span class="text-neutral-300">{{ renderCostIndicator(item.cost)?.graySymbols }}</span>
|
|
435
604
|
</span>
|
|
@@ -494,7 +663,7 @@ watch(innerValue, (raw) => {
|
|
|
494
663
|
<Component :is="child.icon" v-else />
|
|
495
664
|
</div>
|
|
496
665
|
<div class="flex flex-col">
|
|
497
|
-
<span :class="cn('font-medium truncate max-w-140px', compact ? 'text-sm font-
|
|
666
|
+
<span :class="cn('font-medium truncate max-w-140px', compact ? 'text-sm font-540' : 'text-body-14-regular', labelItemClass)">{{ child.label }}</span>
|
|
498
667
|
<span v-if="child.description" :class="cn('font-normal text-sm leading-none text-secondary', descriptionClass)">
|
|
499
668
|
{{ child.description }}
|
|
500
669
|
</span>
|
|
@@ -33,7 +33,7 @@ const style = computed(() => {
|
|
|
33
33
|
const size = ({
|
|
34
34
|
'3xs': 'text-6px p-1 rounded-8px',
|
|
35
35
|
'2xs': 'text-8px p-4px rounded-4px', // 16px
|
|
36
|
-
'xs': 'text-12px p-6px rounded-
|
|
36
|
+
'xs': 'text-12px p-6px rounded-8px', // 26px
|
|
37
37
|
'sm': 'text-16px p-6px rounded-10px', // 32px
|
|
38
38
|
'md': 'text-20px p-6.4px rounded-10px', // 44px
|
|
39
39
|
} as Record<typeof props.size, string>)[props.size]
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# TelaPresenceAvatars
|
|
2
|
+
|
|
3
|
+
Shows who else is currently viewing the same page. Two variants:
|
|
4
|
+
|
|
5
|
+
- **`pill`** (default): a "N Viewing" count followed by up to `max` overlapping round avatars on a `bg-muted` pill (8px radius, 4px vertical / 10px left / 8px right padding). Hovering the count lists viewer names (up to `tooltipMax`, then a "x more" line).
|
|
6
|
+
- **`avatars`**: just the overlapping avatar stack — no count label, no pill surface — at a larger default size (`xs`, 24px). Meant for page headers (canvas, workflow, agent) where the count pill is too heavy. When more than `max` viewers are present, the last visible avatar gets an 80% `neutral-950` mask with a white "+N" count on top (no extra bubble); hovering it shows the same tooltip as the pill count (up to `tooltipMax` names, then the "x more" line).
|
|
7
|
+
|
|
8
|
+
Hovering an avatar shows the viewer's name and live status on separate lines: `Viewing now`, or for away viewers a relative time like "3 min ago" when `awaySinceLabel` is provided (falling back to the plain `awayLabel`). All tooltips share one `TelaTooltipGroup`, so moving between triggers skips the open delay instead of re-animating each tooltip. Viewers with `active: false` (page open, tab hidden) render dimmed instead of disappearing. Renders nothing when the viewers list is empty, so mount points need no guards.
|
|
9
|
+
|
|
10
|
+
Overlapping avatars are separated with a `mask-image` radial-gradient (the tags-select dot technique): each avatar except the last carves a transparent 2px crescent where the next avatar overlaps, so the gap shows whatever is behind the stack instead of a hardcoded ring color. Overlap scales with avatar size (16px→4px, 24px→6px, 32px→8px, 40px→10px).
|
|
11
|
+
|
|
12
|
+
Pair it with the `usePresence` composable, which joins a Yjs awareness room over websockets (channel switching, hard stop on repeated connection failures) and exposes the other viewers as `PresenceViewer[]` — deduped per user across tabs, sorted with active viewers first, then by join time, so people who tab away drop to the back of the stack and active viewers hold the visible slots. The connection stays open while the tab is hidden; the composable publishes `active: false` so peers dim and demote the avatar rather than dropping it. The consuming app provides the websocket `url`, the auth `getToken`, and the local `self` identity.
|
|
13
|
+
|
|
14
|
+
The component is i18n-free: pass pre-translated `countLabel`, `viewingLabel`, `awayLabel`, and `moreLabel` strings, and the `awaySinceLabel` formatter, from the app layer.
|
|
15
|
+
|
|
16
|
+
## Examples
|
|
17
|
+
|
|
18
|
+
### Basic Usage
|
|
19
|
+
|
|
20
|
+
```vue
|
|
21
|
+
<TelaPresenceAvatars
|
|
22
|
+
:viewers="[
|
|
23
|
+
{ name: 'Ada Lovelace', email: 'ada@example.com', image: 'https://example.com/ada.jpg' },
|
|
24
|
+
{ name: 'Grace Hopper', email: 'grace@example.com' },
|
|
25
|
+
]"
|
|
26
|
+
count-label="2 Viewing"
|
|
27
|
+
/>
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Avatars-only variant
|
|
31
|
+
|
|
32
|
+
No count label, no pill chrome, larger avatars (defaults to `xs` / 24px). Used inside canvas, workflow, and agent headers. Overflow beyond `max` darkens the last visible avatar with an 80% mask and a "+N" count, with the name-list tooltip on hover.
|
|
33
|
+
|
|
34
|
+
```vue
|
|
35
|
+
<TelaPresenceAvatars
|
|
36
|
+
variant="avatars"
|
|
37
|
+
:viewers="others"
|
|
38
|
+
label="Viewing this canvas"
|
|
39
|
+
/>
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Away viewers
|
|
43
|
+
|
|
44
|
+
A viewer with `active: false` renders at reduced opacity, moves behind the active viewers (`usePresence` sorts active-first), and their tooltip status switches to `awaySinceLabel(viewer.awaySince)` (e.g. "3 min ago") or the `awayLabel` when no timestamp/formatter is available. `usePresence` publishes `awaySince` when the tab goes hidden and clears it on return; pass a formatter that reads a reactive clock (e.g. `useNow`) so the label stays fresh while the tooltip is open.
|
|
45
|
+
|
|
46
|
+
```vue
|
|
47
|
+
<TelaPresenceAvatars
|
|
48
|
+
:viewers="[
|
|
49
|
+
{ name: 'Ada Lovelace', email: 'ada@example.com' },
|
|
50
|
+
{ name: 'Grace Hopper', email: 'grace@example.com', active: false },
|
|
51
|
+
]"
|
|
52
|
+
count-label="2 Viewing"
|
|
53
|
+
viewing-label="Viewing now"
|
|
54
|
+
away-label="Away"
|
|
55
|
+
/>
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Overflow
|
|
59
|
+
|
|
60
|
+
Only the first `max` viewers render as avatars (priority to active viewers, then the earliest joiners — the input order). The count tooltip lists up to `tooltipMax` emails, then a final "x more" line built with `moreLabel`.
|
|
61
|
+
|
|
62
|
+
```vue
|
|
63
|
+
<TelaPresenceAvatars
|
|
64
|
+
:viewers="manyViewers"
|
|
65
|
+
:max="3"
|
|
66
|
+
:tooltip-max="7"
|
|
67
|
+
count-label="9 Viewing"
|
|
68
|
+
:more-label="count => `${count} more`"
|
|
69
|
+
/>
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### With usePresence
|
|
73
|
+
|
|
74
|
+
The composable joins the live-collaboration room for the channel; `others` is already sorted (active first, then join time) and feeds straight into the component.
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
const { others } = usePresence({
|
|
78
|
+
channel: computed(() => `prompt:${route.params.promptId}`),
|
|
79
|
+
url: 'wss://collab.example.com',
|
|
80
|
+
self: computed(() => ({ id: user.id, email: user.email, name: user.name, image: user.image })),
|
|
81
|
+
getToken: async () => await auth.getToken(),
|
|
82
|
+
})
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
```vue
|
|
86
|
+
<TelaPresenceAvatars
|
|
87
|
+
:viewers="others"
|
|
88
|
+
:count-label="`${others.length} Viewing`"
|
|
89
|
+
label="Viewing this canvas"
|
|
90
|
+
/>
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Props
|
|
94
|
+
|
|
95
|
+
| Prop | Type | Default | Description |
|
|
96
|
+
| -------------- | ------------------------------------------------------------------------------------------- | --------------------- | ------------------------------------------------------------------------ |
|
|
97
|
+
| `viewers` | `{ name?: string, email?: string, image?: string, active?: boolean, joinedAt?: number \| null }[]` | required | The other people in the room, pre-sorted (`usePresence` output) |
|
|
98
|
+
| `max` | `number` | `3` | Visible avatar slots |
|
|
99
|
+
| `tooltipMax` | `number` | `7` | Names listed in the count tooltip before the "x more" line |
|
|
100
|
+
| `variant` | `'pill' \| 'avatars'` | `'pill'` | `pill` shows the count on a muted surface; `avatars` is just the stack |
|
|
101
|
+
| `size` | `'2xs' \| 'xs' \| 'sm' \| 'md'` | `'2xs'` pill / `'xs'` avatars | Avatar size |
|
|
102
|
+
| `label` | `string` | `'Viewing this page'` | aria-label suffix; pass a translated string |
|
|
103
|
+
| `countLabel` | `string` | viewer count | Pre-translated count text, e.g. "3 Viewing" (pill variant only) |
|
|
104
|
+
| `viewingLabel` | `string` | `'Viewing now'` | Status word in the avatar tooltip for active viewers |
|
|
105
|
+
| `awayLabel` | `string` | `'Away'` | Status word in the avatar tooltip for hidden-tab viewers |
|
|
106
|
+
| `awaySinceLabel` | `(awaySince: number) => string` | — | Formats the away timestamp (e.g. "3 min ago"); falls back to `awayLabel` |
|
|
107
|
+
| `moreLabel` | `(count: number) => string` | `` count => `${count} more` `` | Builds the overflow line in the count tooltip |
|
|
108
|
+
|
|
109
|
+
## Features
|
|
110
|
+
|
|
111
|
+
- Pill variant surface: `bg-muted`, 8px radius, 4px vertical / 10px left / 8px right padding, `body-12-medium` count text
|
|
112
|
+
- Overlapping avatars separated by a mask-carved 2px transparent crescent (no hardcoded ring color), overlap proportional to avatar size
|
|
113
|
+
- Falls back to initials when a viewer has no image (via `TelaAvatar`)
|
|
114
|
+
- Count tooltip lists viewer names (capped at `tooltipMax` + "x more"); avatar tooltip stacks the name and Viewing/Away status on separate lines
|
|
115
|
+
- Shared `TelaTooltipGroup`: hovering across triggers skips the tooltip delay/animation
|
|
116
|
+
- Away viewers (`active: false`) dim the avatar to 50% with a 160ms transition and reorder to the back of the stack instead of leaving
|
|
117
|
+
- Subtle 160ms enter/leave/move transitions when viewers join, drop off, or reorder
|
|
118
|
+
- Empty viewers list renders nothing
|
|
119
|
+
|
|
120
|
+
## Accessibility
|
|
121
|
+
|
|
122
|
+
- Container exposes `role="group"` with an `aria-label` of `"{count} {label}"`
|
|
123
|
+
- Each avatar's accessible name comes from its `alt` (viewer name or email)
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
export type PresenceAvatarSize = '2xs' | 'xs' | 'sm' | 'md'
|
|
3
|
+
export type PresenceAvatarsVariant = 'pill' | 'avatars'
|
|
4
|
+
|
|
5
|
+
export interface PresenceAvatarViewer {
|
|
6
|
+
name?: string
|
|
7
|
+
email?: string
|
|
8
|
+
image?: string
|
|
9
|
+
active?: boolean
|
|
10
|
+
joinedAt?: number | null
|
|
11
|
+
awaySince?: number | null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const props = withDefaults(defineProps<{
|
|
15
|
+
viewers: PresenceAvatarViewer[]
|
|
16
|
+
max?: number
|
|
17
|
+
tooltipMax?: number
|
|
18
|
+
size?: PresenceAvatarSize
|
|
19
|
+
variant?: PresenceAvatarsVariant
|
|
20
|
+
label?: string
|
|
21
|
+
countLabel?: string
|
|
22
|
+
viewingLabel?: string
|
|
23
|
+
awayLabel?: string
|
|
24
|
+
awaySinceLabel?: (awaySince: number) => string
|
|
25
|
+
moreLabel?: (count: number) => string
|
|
26
|
+
}>(), {
|
|
27
|
+
max: 3,
|
|
28
|
+
tooltipMax: 7,
|
|
29
|
+
variant: 'pill',
|
|
30
|
+
label: 'Viewing this page',
|
|
31
|
+
viewingLabel: 'Viewing now',
|
|
32
|
+
awayLabel: 'Away',
|
|
33
|
+
moreLabel: (count: number) => `${count} more`,
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const STACK_METRICS: Record<PresenceAvatarSize, { size: number, overlap: number, fontSize: number }> = {
|
|
37
|
+
'2xs': { size: 16, overlap: 4, fontSize: 8 },
|
|
38
|
+
'xs': { size: 24, overlap: 6, fontSize: 10 },
|
|
39
|
+
'sm': { size: 32, overlap: 8, fontSize: 12 },
|
|
40
|
+
'md': { size: 40, overlap: 10, fontSize: 14 },
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const resolvedSize = computed<PresenceAvatarSize>(() => props.size ?? (props.variant === 'avatars' ? 'xs' : '2xs'))
|
|
44
|
+
|
|
45
|
+
// Overlapped avatars get a crescent carved out with a mask (same technique as
|
|
46
|
+
// tags-select dots): the mask circle is centered on the next avatar's center,
|
|
47
|
+
// with a 2px ring of transparency around it.
|
|
48
|
+
const stackStyle = computed(() => {
|
|
49
|
+
const { size, overlap } = STACK_METRICS[resolvedSize.value]
|
|
50
|
+
return {
|
|
51
|
+
'--presence-overlap': `${overlap}px`,
|
|
52
|
+
'--presence-mask-radius': `${size / 2 + 2}px`,
|
|
53
|
+
'--presence-mask-x': `calc(100% + ${size / 2 - overlap}px)`,
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const visibleViewers = computed(() => props.viewers.slice(0, props.max))
|
|
58
|
+
const tooltipViewers = computed(() => props.viewers.slice(0, props.tooltipMax))
|
|
59
|
+
const hiddenTooltipCount = computed(() => Math.max(props.viewers.length - props.tooltipMax, 0))
|
|
60
|
+
const resolvedCountLabel = computed(() => props.countLabel ?? `${props.viewers.length}`)
|
|
61
|
+
|
|
62
|
+
const overflowCount = computed(() => props.variant === 'avatars' ? Math.max(props.viewers.length - props.max, 0) : 0)
|
|
63
|
+
|
|
64
|
+
const overflowTextStyle = computed(() => ({ fontSize: `${STACK_METRICS[resolvedSize.value].fontSize}px` }))
|
|
65
|
+
|
|
66
|
+
function isOverflowSlot(index: number) {
|
|
67
|
+
return overflowCount.value > 0 && index === visibleViewers.value.length - 1
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isActive(viewer: PresenceAvatarViewer) {
|
|
71
|
+
return viewer.active !== false
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function displayName(viewer: PresenceAvatarViewer) {
|
|
75
|
+
return viewer.name || viewer.email || 'Unknown'
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function viewerKey(viewer: PresenceAvatarViewer, index: number) {
|
|
79
|
+
return viewer.email || viewer.name || `viewer-${index}`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function statusLabel(viewer: PresenceAvatarViewer) {
|
|
83
|
+
if (isActive(viewer))
|
|
84
|
+
return props.viewingLabel
|
|
85
|
+
if (viewer.awaySince != null && props.awaySinceLabel)
|
|
86
|
+
return props.awaySinceLabel(viewer.awaySince)
|
|
87
|
+
return props.awayLabel
|
|
88
|
+
}
|
|
89
|
+
</script>
|
|
90
|
+
|
|
91
|
+
<template>
|
|
92
|
+
<TelaTooltipGroup v-if="viewers.length > 0">
|
|
93
|
+
<div
|
|
94
|
+
role="group"
|
|
95
|
+
:aria-label="`${viewers.length} ${label}`"
|
|
96
|
+
flex items-center
|
|
97
|
+
:class="variant === 'pill' ? 'gap-6px rounded-8px bg-muted py-4px pl-10px pr-8px' : ''"
|
|
98
|
+
>
|
|
99
|
+
<TelaTooltipGroupTrigger v-if="variant === 'pill'" side="bottom" trigger-class="flex">
|
|
100
|
+
<span body-12-medium text-primary leading-16px>{{ resolvedCountLabel }}</span>
|
|
101
|
+
<template #content>
|
|
102
|
+
<div flex="~ col" gap-4px text-left>
|
|
103
|
+
<p
|
|
104
|
+
v-for="(viewer, index) in tooltipViewers"
|
|
105
|
+
:key="viewerKey(viewer, index)"
|
|
106
|
+
body-12-regular text-white leading-16px
|
|
107
|
+
>
|
|
108
|
+
{{ displayName(viewer) }}
|
|
109
|
+
</p>
|
|
110
|
+
<p v-if="hiddenTooltipCount > 0" body-12-regular text-white leading-16px>
|
|
111
|
+
{{ moreLabel(hiddenTooltipCount) }}
|
|
112
|
+
</p>
|
|
113
|
+
</div>
|
|
114
|
+
</template>
|
|
115
|
+
</TelaTooltipGroupTrigger>
|
|
116
|
+
|
|
117
|
+
<TransitionGroup name="tela-presence" tag="div" flex items-center :style="stackStyle">
|
|
118
|
+
<div
|
|
119
|
+
v-for="(viewer, index) in visibleViewers"
|
|
120
|
+
:key="viewerKey(viewer, index)"
|
|
121
|
+
class="tela-presence-avatar"
|
|
122
|
+
flex
|
|
123
|
+
>
|
|
124
|
+
<TelaTooltipGroupTrigger side="bottom" trigger-class="flex">
|
|
125
|
+
<div relative flex>
|
|
126
|
+
<TelaAvatar
|
|
127
|
+
:image="viewer.image"
|
|
128
|
+
:alt="displayName(viewer)"
|
|
129
|
+
:size="resolvedSize"
|
|
130
|
+
rounded-full transition-opacity duration-160 ease-out
|
|
131
|
+
:class="isActive(viewer) || isOverflowSlot(index) ? '' : 'op-50'"
|
|
132
|
+
/>
|
|
133
|
+
<div
|
|
134
|
+
v-if="isOverflowSlot(index)"
|
|
135
|
+
absolute inset-0 rounded-full
|
|
136
|
+
class="bg-neutral-950/80"
|
|
137
|
+
flex items-center justify-center
|
|
138
|
+
text-white font-500 leading-none select-none
|
|
139
|
+
:style="overflowTextStyle"
|
|
140
|
+
>
|
|
141
|
+
+{{ overflowCount }}
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
<template #content>
|
|
145
|
+
<div v-if="isOverflowSlot(index)" flex="~ col" gap-4px text-left>
|
|
146
|
+
<p
|
|
147
|
+
v-for="(tooltipViewer, tooltipIndex) in tooltipViewers"
|
|
148
|
+
:key="viewerKey(tooltipViewer, tooltipIndex)"
|
|
149
|
+
body-12-regular text-white leading-16px
|
|
150
|
+
>
|
|
151
|
+
{{ displayName(tooltipViewer) }}
|
|
152
|
+
</p>
|
|
153
|
+
<p v-if="hiddenTooltipCount > 0" body-12-regular text-white leading-16px>
|
|
154
|
+
{{ moreLabel(hiddenTooltipCount) }}
|
|
155
|
+
</p>
|
|
156
|
+
</div>
|
|
157
|
+
<div v-else flex="~ col" gap-2px text-left>
|
|
158
|
+
<span body-12-medium text-white>{{ displayName(viewer) }}</span>
|
|
159
|
+
<span body-12-regular text-neutral-200>{{ statusLabel(viewer) }}</span>
|
|
160
|
+
</div>
|
|
161
|
+
</template>
|
|
162
|
+
</TelaTooltipGroupTrigger>
|
|
163
|
+
</div>
|
|
164
|
+
</TransitionGroup>
|
|
165
|
+
</div>
|
|
166
|
+
</TelaTooltipGroup>
|
|
167
|
+
</template>
|
|
168
|
+
|
|
169
|
+
<style scoped>
|
|
170
|
+
.tela-presence-avatar:not(:first-child) {
|
|
171
|
+
margin-left: calc(var(--presence-overlap) * -1);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.tela-presence-avatar:not(:last-child) {
|
|
175
|
+
mask-image: radial-gradient(circle var(--presence-mask-radius) at var(--presence-mask-x) center, transparent var(--presence-mask-radius), #fff var(--presence-mask-radius));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.tela-presence-enter-active,
|
|
179
|
+
.tela-presence-leave-active {
|
|
180
|
+
transition: opacity 160ms cubic-bezier(0.215, 0.61, 0.355, 1), transform 160ms cubic-bezier(0.215, 0.61, 0.355, 1);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.tela-presence-enter-from,
|
|
184
|
+
.tela-presence-leave-to {
|
|
185
|
+
opacity: 0;
|
|
186
|
+
transform: scale(0.8);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.tela-presence-move {
|
|
190
|
+
transition: transform 160ms cubic-bezier(0.215, 0.61, 0.355, 1);
|
|
191
|
+
}
|
|
192
|
+
</style>
|
|
@@ -19,6 +19,7 @@ export type TooltipProps = {
|
|
|
19
19
|
title?: string
|
|
20
20
|
description?: string
|
|
21
21
|
disableClosingTrigger?: boolean
|
|
22
|
+
triggerClass?: string
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
const props = withDefaults(defineProps<TooltipProps>(), {
|
|
@@ -54,7 +55,7 @@ const variantClasses = computed(() => {
|
|
|
54
55
|
|
|
55
56
|
<template>
|
|
56
57
|
<TelaTooltipRoot v-bind="tooltipRootProps">
|
|
57
|
-
<TelaTooltipTrigger>
|
|
58
|
+
<TelaTooltipTrigger :class="triggerClass">
|
|
58
59
|
<slot />
|
|
59
60
|
</TelaTooltipTrigger>
|
|
60
61
|
<TelaTooltipContent
|