@meistrari/tela-build 1.72.0 → 1.73.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/CHANGELOG.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  Latest updates and announcements.
4
4
 
5
+ ## September 17, 2026
6
+
7
+ - **Dropdown menu spacing.** Items with tooltips now use the same icon-to-label gap as other items. See [Dropdown menu](/components/dropdown-menu).
8
+
5
9
  ## September 16, 2026
6
10
 
7
11
  ### Square Loader
@@ -7,6 +7,8 @@ import * as AvatarLabelStories from './avatar-label.stories.ts';
7
7
 
8
8
  An inline avatar + text chip. Pairs a `TelaAvatar` with a text label so any "entity reference" (created-by, assigned-to, owner, reviewer, member, etc.) renders consistently across summary rows, list cells, and metadata strips. The component is named after its **shape**, not a role — pick the role at the call site via the `label`.
9
9
 
10
+ The avatar retains its configured size in narrow containers. The label stays on one line and truncates with an ellipsis when space is limited; hover the component to read the full label.
11
+
10
12
  ## Examples
11
13
 
12
14
  ### Basic Usage
@@ -14,15 +14,37 @@ withDefaults(defineProps<Props>(), {
14
14
  </script>
15
15
 
16
16
  <template>
17
- <div flex items-center gap-4px>
17
+ <div class="avatar-label" :title="label">
18
18
  <TelaAvatar
19
+ class="avatar-label-image"
19
20
  :size="avatarSize"
20
- :src="avatarSrc"
21
+ :image="avatarSrc"
21
22
  :alt="avatarAlt ?? label"
22
23
  rounded-full
23
24
  />
24
- <p body-12-regular text-secondary>
25
+ <p class="avatar-label-text" body-12-regular text-secondary>
25
26
  {{ label }}
26
27
  </p>
27
28
  </div>
28
29
  </template>
30
+
31
+ <style scoped>
32
+ .avatar-label {
33
+ display: flex;
34
+ align-items: center;
35
+ gap: 4px;
36
+ min-width: 0;
37
+ max-width: 100%;
38
+ }
39
+
40
+ .avatar-label-image {
41
+ flex: none;
42
+ }
43
+
44
+ .avatar-label-text {
45
+ min-width: 0;
46
+ overflow: hidden;
47
+ text-overflow: ellipsis;
48
+ white-space: nowrap;
49
+ }
50
+ </style>
@@ -3,6 +3,8 @@ import type { HTMLAttributes } from 'vue'
3
3
 
4
4
  import { UseClipboard } from '@vueuse/components'
5
5
 
6
+ defineOptions({ inheritAttrs: false })
7
+
6
8
  const props = withDefaults(defineProps<{
7
9
  content: string
8
10
  variant?: 'default' | 'ghost'
@@ -73,6 +75,8 @@ const transition = {
73
75
  <slot :copy="() => onClick(copy)">
74
76
  <MotionConfig :transition="transition">
75
77
  <button
78
+ v-bind="$attrs"
79
+ type="button"
76
80
  flex="~ " items-center justify-center
77
81
  ease will-change-transform active:scale-94
78
82
  class="group duration-80 transition-[background-color,border-color,color,transform]"
@@ -82,7 +82,7 @@ const shouldShowTooltip = computed(() => !!tooltipConfig.value)
82
82
  const isIconImage = computed(() => {
83
83
  if (!props.icon)
84
84
  return false
85
- return /\.(jpg|jpeg|png|gif|svg|webp)$/i.test(props.icon) || props.icon.startsWith('data:image/')
85
+ return /\.(?:jpg|jpeg|png|gif|svg|webp)$/i.test(props.icon) || props.icon.startsWith('data:image/')
86
86
  })
87
87
  </script>
88
88
 
@@ -103,7 +103,7 @@ const isIconImage = computed(() => {
103
103
  v-bind="props"
104
104
  :class="cn(
105
105
  'group outline-none',
106
- 'relative flex cursor-pointer select-none items-center rounded-xl px-3 py-1.5',
106
+ 'relative flex gap-10px cursor-pointer select-none items-center rounded-xl px-3 py-1.5',
107
107
  'text-body-14-medium font-460 outline-none focus:bg-gray-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-40',
108
108
  inset && 'pl-8',
109
109
  props.class,
@@ -0,0 +1,60 @@
1
+ // @vitest-environment happy-dom
2
+ import { mount } from '@vue/test-utils'
3
+ import { computed, defineComponent, nextTick, ref, watch } from 'vue'
4
+ import { describe, expect, it, vi } from 'vitest'
5
+ import FilterContent from './filter-content.vue'
6
+
7
+ vi.mock('reka-ui', () => ({
8
+ PopoverContent: defineComponent({
9
+ name: 'PopoverContent',
10
+ props: {
11
+ sideOffset: Number,
12
+ collisionPadding: Number,
13
+ avoidCollisions: Boolean,
14
+ sticky: String,
15
+ },
16
+ template: '<div data-testid="popover-content" :data-side-offset="sideOffset" :data-collision-padding="collisionPadding" :data-avoid-collisions="avoidCollisions" :data-sticky="sticky"><slot /></div>',
17
+ }),
18
+ }))
19
+
20
+ vi.stubGlobal('useTemplateRef', () => ref())
21
+ vi.stubGlobal('useElementSize', () => ({ height: ref(0) }))
22
+ vi.stubGlobal('cn', (...values: unknown[]) => values.filter(Boolean).join(' '))
23
+ vi.stubGlobal('computed', computed)
24
+ vi.stubGlobal('nextTick', nextTick)
25
+ vi.stubGlobal('watch', watch)
26
+
27
+ describe('filterContent', () => {
28
+ it('keeps popover content within an 8px collision boundary', () => {
29
+ // Arrange
30
+ const wrapper = mount(FilterContent, {
31
+ props: {
32
+ configs: [],
33
+ closing: false,
34
+ settled: true,
35
+ modelValue: {},
36
+ width: '340px',
37
+ },
38
+ global: {
39
+ mocks: { cn: (...values: unknown[]) => values.filter(Boolean).join(' ') },
40
+ stubs: {
41
+ AnimatePresence: true,
42
+ Motion: true,
43
+ TelaCheckbox: true,
44
+ TelaFilterContentItem: true,
45
+ TelaRadioGroupItem: true,
46
+ TelaScrollArea: true,
47
+ },
48
+ },
49
+ })
50
+
51
+ // Act
52
+ const content = wrapper.getComponent({ name: 'PopoverContent' })
53
+
54
+ // Assert
55
+ expect(content.props('sideOffset')).toBe(4)
56
+ expect(content.props('collisionPadding')).toBe(8)
57
+ expect(content.props('avoidCollisions')).toBe(true)
58
+ expect(content.props('sticky')).toBe('always')
59
+ })
60
+ })
@@ -186,6 +186,9 @@ function getSlideMotionVariants(index: number, closingState: boolean) {
186
186
  <template>
187
187
  <PopoverContent
188
188
  :side-offset="4"
189
+ :collision-padding="8"
190
+ avoid-collisions
191
+ sticky="always"
189
192
  align="start"
190
193
  data-popover-content
191
194
  :data-settled="settled || undefined"
@@ -24,6 +24,7 @@ defineEmits<{
24
24
  'focus-visible:ring-2 focus-visible:ring-gray-200 [&:not([data-active])]:hover:bg-subtle data-[active]:bg-muted first:rounded-l-[10px] last:rounded-r-[10px] last:border-r-[0.5px]',
25
25
  triggerClass,
26
26
  )"
27
+ :aria-label="filter.showCount && count ? `${filter.label}, ${count} selected` : undefined"
27
28
  @click="$emit('click')"
28
29
  >
29
30
  <div inline-flex items-center>
@@ -31,7 +32,7 @@ defineEmits<{
31
32
 
32
33
  <AnimatePresence>
33
34
  <Motion
34
- v-if="count && filter.selectionMode === 'multiple'"
35
+ v-if="count && (filter.selectionMode === 'multiple' || filter.showCount)"
35
36
  mt--3px ml-5px
36
37
  :initial="{ width: 0, x: -20, scale: 0, opacity: 0 }"
37
38
  :animate="{ width: 'auto', x: 0, scale: 1, opacity: 1 }"
@@ -64,7 +64,7 @@ const selections = ref({
64
64
 
65
65
  ### Single-Select Filter
66
66
 
67
- Set `selectionMode: 'single'` on a filter so it behaves like a radio group — picking an option replaces the prior selection. Options render with `TelaRadioGroupItem` instead of `TelaCheckbox`, and the count badge on the trigger is suppressed (single mode has at most one selection).
67
+ Set `selectionMode: 'single'` on a filter so it behaves like a radio group — picking an option replaces the prior selection. Options render with `TelaRadioGroupItem` instead of `TelaCheckbox`. Single-select triggers hide their count badge by default; set `showCount: true` to show the selected count and include it in the trigger's accessible label.
68
68
 
69
69
  ```vue
70
70
  const filters = [
@@ -112,6 +112,14 @@ const filters = [
112
112
  ]
113
113
  ```
114
114
 
115
+ ### Non-Wrapping Filter Bars
116
+
117
+ The bar wraps by default. Use the optional `bar-class` prop when a compact rail must keep its filter triggers on one line. This is backward-compatible: omitting it preserves the existing bar styling and wrapping behavior.
118
+
119
+ ```vue
120
+ <TelaFilter v-model="selections" :filters="filters" bar-class="!flex-nowrap" />
121
+ ```
122
+
115
123
  ### Custom Option Rendering
116
124
 
117
125
  Use the scoped `option-{label}` slot to override the default option button. The slot receives `{ option, filter, checked, toggle, mode }`. Useful when options need avatars, icons, secondary text, etc.
@@ -224,6 +232,8 @@ interface FilterConfig {
224
232
  options: FilterOption[]
225
233
  /** Defaults to `'multiple'`. Single-mode renders radio indicators and replaces (not toggles) the selection. */
226
234
  selectionMode?: FilterSelectionMode
235
+ /** Show selected count on this trigger. Multi-select shows counts by default; single-select requires this opt-in. */
236
+ showCount?: boolean
227
237
  /** When this filter is active, the popover animates to this width. Falls back to `defaultPopoverWidth`. */
228
238
  popoverWidth?: string
229
239
  /** Class on the options container (forwarded to `TelaScrollArea`). Use `max-h-[Xpx]` for scrollable lists. */
@@ -235,6 +245,8 @@ type FilterSelections = Record<string, string[]>
235
245
  type FilterProps = {
236
246
  modelValue?: FilterSelections
237
247
  filters: FilterConfig[]
248
+ /** Optional class forwarded to the filter bar. Omitting it preserves default wrapping and styling. */
249
+ barClass?: HTMLAttributes['class']
238
250
  triggerClass?: string
239
251
  contentClass?: string
240
252
  /** Fallback width when the active filter has no own `popoverWidth`. Defaults to `170px`. */
@@ -10,6 +10,7 @@ type Phase = 'idle' | 'opening' | 'settled' | 'closing'
10
10
  const props = withDefaults(defineProps<{
11
11
  modelValue?: FilterSelections
12
12
  filters: FilterConfig[]
13
+ barClass?: HTMLAttributes['class']
13
14
  triggerClass?: HTMLAttributes['class']
14
15
  contentClass?: HTMLAttributes['class']
15
16
  defaultPopoverWidth?: string
@@ -180,7 +181,7 @@ watch(phase, (newPhase) => {
180
181
  <MotionConfig :transition="SPRING_CONFIG">
181
182
  <PopoverRoot :open="isOpen" @update:open="onOpenChange">
182
183
  <PopoverAnchor as-child :reference="referenceEl ?? undefined">
183
- <TelaFilterBar ref="filterBar" flex flex-wrap items-center select-none>
184
+ <TelaFilterBar ref="filterBar" :class="barClass">
184
185
  <TelaFilterTrigger
185
186
  v-for="filter in filters"
186
187
  :key="filter.key"
@@ -12,6 +12,7 @@ export interface FilterConfig {
12
12
  label: string
13
13
  options: FilterOption[]
14
14
  selectionMode?: FilterSelectionMode
15
+ showCount?: boolean
15
16
  popoverWidth?: string
16
17
  optionsClass?: HTMLAttributes['class']
17
18
  }
@@ -1,32 +1,48 @@
1
1
  <template>
2
- <div flex="~" rounded-6px items-center w-52px h-24px px-8px py-4px bg="gray-100">
3
- <span body-12-semibold text="emerald-600" mr-4px>
4
- Live
5
- </span>
6
- <div relative w-12px h-12px>
7
- <div
8
- w-12px h-12px absolute rounded-full bg="positive" flex items-center justify-center opacity-20
9
- class="blink top-1/2 translate-y--1/2 left-1/2 translate-x--1/2"
10
- />
11
- <div
12
- w-6px absolute h-6px rounded-full bg="positive"
13
- class="top-1/2 translate-y--1/2 left-1/2 translate-x--1/2"
14
- />
15
- </div>
16
- </div>
2
+ <span class="live-label" inline-flex items-center gap-6px body-12-medium text="blue-600">
3
+ <span class="live-dot" aria-hidden="true" />
4
+ <span><slot>Live</slot></span>
5
+ </span>
17
6
  </template>
18
7
 
19
8
  <style scoped>
20
- .blink {
21
- animation: blink 0.8s linear infinite;
9
+ .live-label {
10
+ white-space: nowrap;
22
11
  }
23
12
 
24
- @keyframes blink {
13
+ .live-dot {
14
+ position: relative;
15
+ width: 6px;
16
+ height: 6px;
17
+ flex: none;
18
+ border-radius: 50%;
19
+ background: currentColor;
20
+ }
21
+
22
+ .live-dot::after {
23
+ content: '';
24
+ position: absolute;
25
+ inset: 0;
26
+ border-radius: inherit;
27
+ background: currentColor;
28
+ animation: live-pulse 1.5s ease-out infinite;
29
+ }
30
+
31
+ @keyframes live-pulse {
25
32
  0% {
26
- opacity: 0;
33
+ transform: scale(1);
34
+ opacity: 0.6;
27
35
  }
28
36
  100% {
29
- opacity: 0.3;
37
+ transform: scale(2.5);
38
+ opacity: 0;
39
+ }
40
+ }
41
+
42
+ @media (prefers-reduced-motion: reduce) {
43
+ .live-dot::after {
44
+ animation: none;
45
+ opacity: 0;
30
46
  }
31
47
  }
32
48
  </style>
@@ -24,6 +24,7 @@ const size = ref(props.size)
24
24
  const collapsedSize = ref(props.collapsedSize ?? 7)
25
25
  const sizeBeforeCollapse = ref(props.size)
26
26
  const isCollapsed = ref(Boolean(props.startCollapsed))
27
+ const paneMinSize = computed(() => isCollapsed.value ? collapsedSize.value : props.minSize)
27
28
 
28
29
  watch(() => props.size, (newValue) => {
29
30
  size.value = newValue
@@ -54,9 +55,13 @@ function toggleCollapse() {
54
55
  <component
55
56
  :is="disableSplit ? 'div' : Pane"
56
57
  :size="disableSplit ? undefined : size"
57
- :min-size="minSize"
58
+ :min-size="paneMinSize"
58
59
  >
59
- <div v-if="title || $slots.actions" flex items-center w-full justify-between relative h-72px z-1>
60
+ <div
61
+ v-if="title || $slots.actions || collapsible"
62
+ flex items-center w-full justify-between relative z-1
63
+ :class="title || $slots.actions ? 'h-72px' : 'h-40px'"
64
+ >
60
65
  <Transition name="fade">
61
66
  <div v-if="title && !isCollapsed" flex flex-col p-24px>
62
67
  <div flex>
@@ -71,7 +76,10 @@ function toggleCollapse() {
71
76
  </div>
72
77
  </div>
73
78
  </Transition>
74
- <div absolute top-16px right-32px flex items-center gap-8px>
79
+ <div
80
+ absolute flex items-center gap-8px
81
+ :class="title || $slots.actions ? 'top-16px right-32px' : 'inset-y-0 right-4px'"
82
+ >
75
83
  <slot name="actions" />
76
84
 
77
85
  <TelaButton
@@ -7,6 +7,8 @@ import * as TimelineWaterfallStories from './timeline-waterfall.stories.ts';
7
7
 
8
8
  A waterfall timeline that visualizes hierarchical execution steps over time. Use it to render run-traces, agent debug timelines, request waterfalls, or any sequence of nested operations. Parent rows can collapse/expand their children, bars are positioned against a shared time axis with grid lines and sub-ticks, and the time axis + label column stay sticky while the user scrolls.
9
9
 
10
+ Rows without children reserve the collapse control's width so icons and labels align with collapsible rows at the same depth.
11
+
10
12
  ## Example
11
13
 
12
14
  <Canvas of={TimelineWaterfallStories.Default} />
@@ -23,6 +25,7 @@ const rows: TimelineRow[] = [
23
25
  label: 'Step 01',
24
26
  color: 'gray',
25
27
  status: 'success',
28
+ scheduledAt: new Date('2026-05-04T11:59:58Z'),
26
29
  startedAt: new Date('2026-05-04T12:00:00Z'),
27
30
  finishedAt: new Date('2026-05-04T12:00:03.9Z'),
28
31
  durationMs: 3900,
@@ -58,10 +61,12 @@ const { t } = useI18n()
58
61
 
59
62
  const labels = computed(() => ({
60
63
  executionTimeline: t('timeline.executionTimeline'),
61
- startedAt: t('timeline.startedAt'),
62
- duration: t('timeline.duration'),
64
+ enqueued: t('timeline.enqueued'),
65
+ executed: t('timeline.executed'),
63
66
  complete: t('timeline.complete'),
64
67
  failed: t('timeline.failed'),
68
+ expand: label => t('timeline.expand', { label }),
69
+ collapse: label => t('timeline.collapse', { label }),
65
70
  }))
66
71
  </script>
67
72
 
@@ -76,6 +81,47 @@ const labels = computed(() => ({
76
81
  <TelaTimelineWaterfall :rows="rows" hide-header />
77
82
  ```
78
83
 
84
+ ### Selection vs hover
85
+
86
+ `hoveredId` is a transient pointer/scrub highlight (subtle row tint). `selectedId` is a
87
+ persistent selection — the row that a caller has pinned (e.g. the graph node the user is
88
+ inspecting). It renders a stronger treatment: raised surface, a left accent on the sticky
89
+ label, and an outline around the bars. When a queue is visible, its outline covers the top, bottom, and left edges; the execution outline covers the top, bottom, and right edges, plus short left edges above and below the thin queue connection. The shared connection has no dividing border, and both segments use the same outline opacity. Execution-only rows keep a complete outline. Pass them independently.
90
+
91
+ ```vue
92
+ <TelaTimelineWaterfall
93
+ :rows="rows"
94
+ :selected-id="selectedNodeId"
95
+ v-model:hovered-id="hoveredId"
96
+ />
97
+ ```
98
+
99
+ Unselected queue and execution bars have a subtle half-pixel border in their status accent. A phased execution bar has one continuous outer outline in its aggregate status color, spanning both phases and any real timing gap; individual phases have no border. Their distinct fills and running animations remain inside the outline, without filling the timing gap. Selected bars retain their stronger outline.
100
+
101
+ ### Status badges and bar tone
102
+
103
+ Every row includes a compact `TelaStatus` badge. Track bars pair colour with opacity:
104
+ completed rows use a muted success tone, failed rows use a full-strength error tone,
105
+ running rows use a blue animated stripe, waiting rows use a muted warning tone, and
106
+ pending or stopped rows stay subdued. The running stripe respects reduced-motion
107
+ preferences.
108
+
109
+ Rows can provide `scheduledAt` to visualize queue wait before execution. The component
110
+ shows a muted queue segment from `scheduledAt` to `startedAt` only when the wait is at
111
+ least one second. Its tooltip groups queue and execution data into Enqueued and Executed
112
+ rows, each with a timestamp and duration. Omit `scheduledAt` (or pass `null`) for legacy
113
+ and execution-only rows.
114
+
115
+ ### Row appearance overrides
116
+
117
+ Set `labelColor: 'primary' | 'secondary'` to control a row label independently of its collapse control. Set `dimmed: true` for a subtle 80% row opacity. Both are optional; callers that omit them keep the existing parent/leaf colors and full row opacity. Workflow Debug uses these overrides to mute pending and skipped rows, and dim skipped rows.
118
+
119
+ ### Execution phases
120
+
121
+ Optionally provide `executionSegments` on a row to break its execution bar into labeled phases using the same status colors as standard bars. Set each segment’s `shade` to `default` for the darker status accent or `muted` for an opaque light fill in the same hue. These use distinct shades rather than fading the same pale background, so even a short phase remains visible. Each segment includes its own start/end timestamps, duration, and status. Set the row's start/end and duration to the overall elapsed interval. Segment positions retain real gaps; short phases keep a four-pixel visual minimum. The tooltip shows both phases with matching color keys, timestamps, durations and status badges, including from the queue segment. Running phases use the existing stripe animation with reduced-motion support. Clicking either phase selects the same row.
122
+
123
+ Workflow Debug uses the default shade for the Condition action and the muted shade for its child execution span. Both phases take their color from their own status. Rows without segments keep the standard status-colored bar and existing tooltip.
124
+
79
125
  ## Props
80
126
 
81
127
  <ArgTypes />
@@ -83,25 +129,43 @@ const labels = computed(() => ({
83
129
  ```typescript
84
130
  type TimelineColor = 'blue' | 'purple' | 'cyan' | 'gray'
85
131
 
86
- interface TimelineChildRow {
132
+ interface TimelineExecutionSegment {
133
+ id: string
134
+ label: string
135
+ shade: 'default' | 'muted'
136
+ status: TimelineRow['status']
137
+ startedAt: Date
138
+ finishedAt: Date
139
+ durationMs: number
140
+ }
141
+
142
+ interface TimelineRow {
143
+ executionSegments?: TimelineExecutionSegment[]
87
144
  id: string
88
145
  label: string
146
+ labelColor?: 'primary' | 'secondary'
147
+ dimmed?: boolean
89
148
  details?: string | null
90
149
  icon?: string | null
91
150
  color: TimelineColor
92
- status: 'success' | 'failed'
151
+ status: 'success' | 'failed' | { variant: TelaStatusVariant, label: string }
152
+ scheduledAt?: Date | null
93
153
  startedAt: Date | null
94
154
  finishedAt: Date | null
95
155
  durationMs: number
156
+ showBar?: boolean // false keeps the row visible without a timing bar or tooltip
96
157
  costLabel?: string | null
97
- }
98
-
99
- interface TimelineRow extends TimelineChildRow {
100
- children: TimelineChildRow[]
158
+ collapsed?: boolean // initial state only; user expansion survives polling updates
159
+ expandable?: boolean // keep a collapse control while lazy children are loading
160
+ children: TimelineRow[]
101
161
  }
102
162
 
103
163
  interface TimelineLabels {
104
164
  executionTimeline?: string
165
+ enqueued?: string
166
+ executed?: string
167
+ queuedFor?: string
168
+ scheduledAt?: string
105
169
  startedAt?: string
106
170
  endedAt?: string
107
171
  duration?: string
@@ -109,6 +173,8 @@ interface TimelineLabels {
109
173
  stepId?: string
110
174
  complete?: string
111
175
  failed?: string
176
+ expand?: (label: string) => string
177
+ collapse?: (label: string) => string
112
178
  }
113
179
 
114
180
  interface TimelineWaterfallProps {
@@ -116,5 +182,16 @@ interface TimelineWaterfallProps {
116
182
  hideHeader?: boolean
117
183
  headerLabel?: string
118
184
  labels?: TimelineLabels
185
+ hoveredId?: string | null
186
+ selectedId?: string | null
187
+ getRowTestId?: (id: string) => string | undefined
188
+ getExpandTestId?: (id: string) => string | undefined
189
+ formatTimestamp?: (timestamp: Date) => string
119
190
  }
120
191
  ```
192
+
193
+ ### Row controls
194
+
195
+ Set `labelWidth` (pixels, default `220`) when the label column needs more room for controls. Debug uses `320` to retain status beside its Map counter.
196
+
197
+ Use the `row-trailing` scoped slot (`{ row }`) to replace the default status beside the row label with compact controls, such as an iteration counter. Controls sit outside the label button; stop click propagation on interactive content to keep it independent of row selection. Set `expandable: true` on a row with lazy children so its collapse control remains available before those children arrive.