@demicodes/web-ui 0.3.1

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 (114) hide show
  1. package/package.json +55 -0
  2. package/src/agent/AgentMessageInput.vue +164 -0
  3. package/src/agent/AgentMessageList.vue +124 -0
  4. package/src/agent/AgentPanel.vue +18 -0
  5. package/src/agent/AgentRoot.vue +17 -0
  6. package/src/agent/AgentTabBar.vue +422 -0
  7. package/src/agent/AgentTabItem.vue +150 -0
  8. package/src/agent/ContextUsageIndicator.vue +136 -0
  9. package/src/agent/ConversationListDropdown.vue +71 -0
  10. package/src/agent/ConversationStatusDot.vue +26 -0
  11. package/src/agent/ConversationView.vue +82 -0
  12. package/src/agent/MessageQueueBar.vue +67 -0
  13. package/src/agent/ModelSelector.vue +125 -0
  14. package/src/agent/ReasoningSelector.vue +106 -0
  15. package/src/agent/SelectorTrigger.vue +17 -0
  16. package/src/agent/__tests__/block-helpers.test.ts +73 -0
  17. package/src/agent/__tests__/input-actions.test.ts +130 -0
  18. package/src/agent/__tests__/input-editor.test.ts +15 -0
  19. package/src/agent/__tests__/pending-steers.test.ts +99 -0
  20. package/src/agent/__tests__/queue-submit.test.ts +14 -0
  21. package/src/agent/__tests__/reasoning.test.ts +32 -0
  22. package/src/agent/__tests__/tail-loading.test.ts +131 -0
  23. package/src/agent/__tests__/tool-rendering.test.ts +35 -0
  24. package/src/agent/__tests__/visible-blocks.test.ts +125 -0
  25. package/src/agent/block-helpers.ts +59 -0
  26. package/src/agent/block-types.ts +12 -0
  27. package/src/agent/blocks/AbortedBlock.vue +26 -0
  28. package/src/agent/blocks/AgentMessageVirtualBlock.vue +88 -0
  29. package/src/agent/blocks/AnsiText.vue +75 -0
  30. package/src/agent/blocks/AssistantTextBlock.vue +34 -0
  31. package/src/agent/blocks/CompactionBlock.vue +36 -0
  32. package/src/agent/blocks/ErrorBlock.vue +79 -0
  33. package/src/agent/blocks/FunctionalBlock.vue +118 -0
  34. package/src/agent/blocks/LoadingBlock.vue +28 -0
  35. package/src/agent/blocks/ThinkingBlock.vue +86 -0
  36. package/src/agent/blocks/ToolCallBlock.vue +40 -0
  37. package/src/agent/blocks/ToolGenericBlock.vue +43 -0
  38. package/src/agent/blocks/ToolShellAbortBlock.vue +13 -0
  39. package/src/agent/blocks/ToolShellBlock.vue +47 -0
  40. package/src/agent/blocks/ToolShellControlBlock.vue +51 -0
  41. package/src/agent/blocks/ToolShellStatusBlock.vue +13 -0
  42. package/src/agent/blocks/ToolShellWriteBlock.vue +13 -0
  43. package/src/agent/blocks/ToolStatusBadge.vue +13 -0
  44. package/src/agent/blocks/ToolYieldBlock.vue +13 -0
  45. package/src/agent/blocks/UserBlock.vue +112 -0
  46. package/src/agent/conversation-runtime.ts +205 -0
  47. package/src/agent/conversation-status.ts +12 -0
  48. package/src/agent/message-input/input-model.ts +33 -0
  49. package/src/agent/message-input/useAgentInputActions.ts +96 -0
  50. package/src/agent/message-input/useAgentInputEditor.ts +90 -0
  51. package/src/agent/message-input/useAgentInputSessionState.ts +48 -0
  52. package/src/agent/pending-steers.ts +134 -0
  53. package/src/agent/providers/ProviderIcon.vue +32 -0
  54. package/src/agent/providers/icons/claude.svg +1 -0
  55. package/src/agent/providers/icons/openai.svg +3 -0
  56. package/src/agent/queue-submit.ts +4 -0
  57. package/src/agent/reasoning.ts +45 -0
  58. package/src/agent/tail-loading.ts +31 -0
  59. package/src/agent/thinking-streaming.ts +7 -0
  60. package/src/agent/tool-rendering.ts +59 -0
  61. package/src/agent/types.ts +39 -0
  62. package/src/agent/ui-options.ts +21 -0
  63. package/src/agent/visible-blocks.ts +25 -0
  64. package/src/agent/workspace.ts +355 -0
  65. package/src/composables/useBlockVirtualizer.ts +290 -0
  66. package/src/composables/useContextMenuOwner.ts +44 -0
  67. package/src/composables/useOverlay.ts +18 -0
  68. package/src/composables/useTerminalTheme.ts +75 -0
  69. package/src/env.d.ts +10 -0
  70. package/src/infra/errors.ts +29 -0
  71. package/src/infra/i18n.ts +41 -0
  72. package/src/markdown/filePath.ts +54 -0
  73. package/src/markdown/highlight.ts +139 -0
  74. package/src/markdown/md.ts +58 -0
  75. package/src/markdown/render.ts +117 -0
  76. package/src/markdown/types.ts +20 -0
  77. package/src/overlay/appOverlay.ts +3 -0
  78. package/src/overlay/overlayStore.ts +54 -0
  79. package/src/store/createStore.ts +33 -0
  80. package/src/store/useStore.ts +7 -0
  81. package/src/styles/base.css +342 -0
  82. package/src/theme/appTheme.ts +56 -0
  83. package/src/theme/codeThemes.ts +454 -0
  84. package/src/theme/themeStore.ts +33 -0
  85. package/src/transport/agent-socket.ts +20 -0
  86. package/src/transport/control-client.ts +64 -0
  87. package/src/transport/protocol.ts +57 -0
  88. package/src/ui/Button.vue +30 -0
  89. package/src/ui/Checkbox.vue +25 -0
  90. package/src/ui/ContextMenu.vue +33 -0
  91. package/src/ui/Dialog.vue +48 -0
  92. package/src/ui/DropdownMenu.vue +77 -0
  93. package/src/ui/ErrorBox.vue +43 -0
  94. package/src/ui/HighlightText.vue +39 -0
  95. package/src/ui/IconButton.vue +30 -0
  96. package/src/ui/IndeterminateSpinner.vue +72 -0
  97. package/src/ui/InlineError.vue +25 -0
  98. package/src/ui/LoadMore.vue +32 -0
  99. package/src/ui/MarkdownPreview.vue +31 -0
  100. package/src/ui/Menu.vue +8 -0
  101. package/src/ui/MenuDivider.vue +6 -0
  102. package/src/ui/MenuItem.vue +48 -0
  103. package/src/ui/OptionMenu.vue +8 -0
  104. package/src/ui/OptionMenuGroup.vue +16 -0
  105. package/src/ui/OptionMenuItem.vue +28 -0
  106. package/src/ui/Popover.vue +90 -0
  107. package/src/ui/ScrollToBottomButton.vue +32 -0
  108. package/src/ui/SelectDropdown.vue +231 -0
  109. package/src/ui/Switch.vue +41 -0
  110. package/src/ui/TextInput.vue +44 -0
  111. package/src/ui/ThemeToggle.vue +49 -0
  112. package/src/ui/ToggleSwitch.vue +34 -0
  113. package/src/ui/Tooltip.vue +143 -0
  114. package/src/ui/blankClickGuard.ts +12 -0
@@ -0,0 +1,231 @@
1
+ <script setup lang="ts" generic="T extends { id: string; label: string }">
2
+ import { computed, ref, watch } from 'vue'
3
+ import { useVirtualizer } from '@tanstack/vue-virtual'
4
+ import { SearchLine } from '@mingcute/vue/search'
5
+ import { CloseCircleFill } from '@mingcute/vue/close-circle'
6
+ import { CheckLine } from '@mingcute/vue/check'
7
+ import type { OverlayStore } from '../overlay/overlayStore'
8
+ import DropdownMenu from './DropdownMenu.vue'
9
+ import HighlightText from './HighlightText.vue'
10
+
11
+ type Placement = 'top' | 'top-start' | 'top-end' | 'bottom' | 'bottom-start' | 'bottom-end'
12
+
13
+ const props = withDefaults(defineProps<{
14
+ overlayStore: OverlayStore
15
+ items: T[]
16
+ selectedId?: string
17
+ searchable?: boolean
18
+ searchPlaceholder?: string
19
+ emptyText?: string
20
+ placement?: Placement
21
+ offset?: number
22
+ itemHeight?: number
23
+ matchTriggerWidth?: boolean
24
+ anchorInset?: number
25
+ panelClass?: string
26
+ filterFn?: (item: T, query: string) => boolean
27
+ noAutoFocus?: boolean
28
+ }>(), {
29
+ searchPlaceholder: 'Search...',
30
+ emptyText: 'No items found',
31
+ placement: 'bottom-start',
32
+ offset: 6,
33
+ })
34
+
35
+ const emit = defineEmits<{
36
+ select: [id: string]
37
+ close: []
38
+ }>()
39
+
40
+ defineSlots<{
41
+ trigger(props: { isOpen: boolean }): void
42
+ item(props: { item: T; query: string; isSelected: boolean }): void
43
+ }>()
44
+
45
+ const dropdownRef = ref<InstanceType<typeof DropdownMenu>>()
46
+ const filterQuery = ref('')
47
+ const focusedIndex = ref(-1)
48
+ const inputRef = ref<HTMLInputElement>()
49
+ const panelRef = ref<HTMLElement>()
50
+ const scrollRef = ref<HTMLElement>()
51
+
52
+ const filteredItems = computed(() => {
53
+ const q = filterQuery.value.toLowerCase().trim()
54
+ if (!q) return props.items
55
+ const fn = props.filterFn
56
+ if (fn) return props.items.filter(item => fn(item, q))
57
+ return props.items.filter(item => item.label.toLowerCase().includes(q))
58
+ })
59
+
60
+ const isVirtual = computed(() => props.itemHeight != null && props.itemHeight > 0)
61
+
62
+ const virtualizer = useVirtualizer(computed(() => ({
63
+ count: filteredItems.value.length,
64
+ getScrollElement: () => scrollRef.value ?? null,
65
+ estimateSize: () => props.itemHeight ?? 32,
66
+ overscan: 5,
67
+ })))
68
+
69
+ watch(inputRef, (el) => {
70
+ if (!el) return
71
+ filterQuery.value = ''
72
+ focusedIndex.value = -1
73
+ el.focus()
74
+ })
75
+
76
+ watch(panelRef, (el) => {
77
+ if (!el || props.searchable || props.noAutoFocus) return
78
+ focusedIndex.value = -1
79
+ el.focus()
80
+ })
81
+
82
+ watch(filteredItems, () => {
83
+ focusedIndex.value = 0
84
+ })
85
+
86
+ function handleSelect(id: string) {
87
+ emit('select', id)
88
+ dropdownRef.value?.close()
89
+ }
90
+
91
+ function handleClear() {
92
+ filterQuery.value = ''
93
+ inputRef.value?.focus()
94
+ }
95
+
96
+ function handleKeydown(event: KeyboardEvent) {
97
+ const count = filteredItems.value.length
98
+ if (count === 0) return
99
+
100
+ if (event.key === 'ArrowDown') {
101
+ event.preventDefault()
102
+ focusedIndex.value = focusedIndex.value < count - 1 ? focusedIndex.value + 1 : 0
103
+ if (isVirtual.value) virtualizer.value.scrollToIndex(focusedIndex.value, { align: 'auto' })
104
+ return
105
+ }
106
+
107
+ if (event.key === 'ArrowUp') {
108
+ event.preventDefault()
109
+ focusedIndex.value = focusedIndex.value > 0 ? focusedIndex.value - 1 : count - 1
110
+ if (isVirtual.value) virtualizer.value.scrollToIndex(focusedIndex.value, { align: 'auto' })
111
+ return
112
+ }
113
+
114
+ if (event.key === 'Enter' && focusedIndex.value >= 0) {
115
+ event.preventDefault()
116
+ handleSelect(filteredItems.value[focusedIndex.value]!.id)
117
+ }
118
+ }
119
+
120
+ function open() {
121
+ dropdownRef.value?.open()
122
+ }
123
+
124
+ function close() {
125
+ dropdownRef.value?.close()
126
+ }
127
+
128
+ defineExpose({ open, close, handleKeydown })
129
+ </script>
130
+
131
+ <template>
132
+ <DropdownMenu ref="dropdownRef" :overlay-store="overlayStore" :placement="placement" :offset="offset" :anchor-inset="anchorInset" :shift-padding="anchorInset ? 0 : undefined" @close="emit('close')">
133
+ <template #trigger="{ isOpen }">
134
+ <slot name="trigger" :is-open="isOpen" />
135
+ </template>
136
+ <template #content="{ triggerWidth }">
137
+ <div
138
+ ref="panelRef"
139
+ class="flex flex-col overflow-hidden rounded-xl border border-line bg-surface text-fg shadow-xl outline-none"
140
+ :class="panelClass ?? (!matchTriggerWidth && 'w-72')"
141
+ :style="matchTriggerWidth ? { width: `${triggerWidth}px` } : undefined"
142
+ tabindex="-1"
143
+ @keydown="handleKeydown"
144
+ >
145
+ <div v-if="searchable" class="flex items-center gap-2 border-b border-line-subtle px-3 py-2.5 text-fg-subtle">
146
+ <SearchLine :size="14" class="shrink-0" />
147
+ <input
148
+ ref="inputRef"
149
+ v-model="filterQuery"
150
+ type="text"
151
+ :placeholder="searchPlaceholder"
152
+ class="min-w-0 flex-1 bg-transparent text-[13px] text-fg-body placeholder-fg-subtle outline-none"
153
+ />
154
+ <span
155
+ v-if="filterQuery"
156
+ class="shrink-0 cursor-pointer transition-colors hover:text-fg-body"
157
+ @click="handleClear"
158
+ >
159
+ <CloseCircleFill :size="14" />
160
+ </span>
161
+ </div>
162
+ <div
163
+ v-if="filteredItems.length === 0"
164
+ class="px-3 py-4 text-center text-[13px] text-fg-subtle"
165
+ >
166
+ {{ emptyText }}
167
+ </div>
168
+ <!-- Virtual list -->
169
+ <div v-if="isVirtual" ref="scrollRef" class="max-h-80 overflow-y-auto py-1">
170
+ <div :style="{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }">
171
+ <div
172
+ v-for="vItem in virtualizer.getVirtualItems()"
173
+ :key="String(vItem.key)"
174
+ class="absolute inset-x-0 flex cursor-pointer items-center gap-2 px-3 transition-colors"
175
+ :class="[
176
+ vItem.index === focusedIndex
177
+ ? 'bg-active text-fg'
178
+ : filteredItems[vItem.index]!.id === selectedId
179
+ ? 'bg-overlay/5 text-fg'
180
+ : 'text-fg-muted hover:bg-hover hover:text-fg-body',
181
+ ]"
182
+ :style="{ height: `${vItem.size}px`, transform: `translateY(${vItem.start}px)` }"
183
+ @click="handleSelect(filteredItems[vItem.index]!.id)"
184
+ >
185
+ <slot
186
+ name="item"
187
+ :item="filteredItems[vItem.index]!"
188
+ :query="filterQuery"
189
+ :is-selected="filteredItems[vItem.index]!.id === selectedId"
190
+ >
191
+ <span class="min-w-0 flex-1 truncate text-[13px]">
192
+ <HighlightText v-if="filterQuery" :text="filteredItems[vItem.index]!.label" :query="filterQuery" />
193
+ <template v-else>{{ filteredItems[vItem.index]!.label }}</template>
194
+ </span>
195
+ </slot>
196
+ <span class="ml-1 w-3.5 shrink-0 text-fg-muted">
197
+ <CheckLine v-if="filteredItems[vItem.index]!.id === selectedId" :size="14" />
198
+ </span>
199
+ </div>
200
+ </div>
201
+ </div>
202
+ <!-- Normal list -->
203
+ <div v-else class="max-h-80 overflow-y-auto py-1">
204
+ <div
205
+ v-for="(item, index) in filteredItems"
206
+ :key="item.id"
207
+ class="flex cursor-pointer items-center gap-2 px-3 py-1.5 transition-colors"
208
+ :class="[
209
+ index === focusedIndex
210
+ ? 'bg-active text-fg'
211
+ : item.id === selectedId
212
+ ? 'bg-overlay/5 text-fg'
213
+ : 'text-fg-muted hover:bg-hover hover:text-fg-body',
214
+ ]"
215
+ @click="handleSelect(item.id)"
216
+ >
217
+ <slot name="item" :item="item" :query="filterQuery" :is-selected="item.id === selectedId">
218
+ <span class="min-w-0 flex-1 truncate text-[13px]">
219
+ <HighlightText v-if="filterQuery" :text="item.label" :query="filterQuery" />
220
+ <template v-else>{{ item.label }}</template>
221
+ </span>
222
+ </slot>
223
+ <span class="ml-1 w-3.5 shrink-0 text-fg-muted">
224
+ <CheckLine v-if="item.id === selectedId" :size="14" />
225
+ </span>
226
+ </div>
227
+ </div>
228
+ </div>
229
+ </template>
230
+ </DropdownMenu>
231
+ </template>
@@ -0,0 +1,41 @@
1
+ <script setup lang="ts">
2
+ withDefaults(defineProps<{
3
+ modelValue: boolean
4
+ label?: string
5
+ size?: 'sm' | 'md'
6
+ }>(), {
7
+ size: 'md',
8
+ })
9
+
10
+ const emit = defineEmits<{
11
+ 'update:modelValue': [value: boolean]
12
+ }>()
13
+ </script>
14
+
15
+ <template>
16
+ <span
17
+ class="inline-flex cursor-pointer items-center gap-1.5"
18
+ role="switch"
19
+ :aria-checked="modelValue"
20
+ @click="emit('update:modelValue', !modelValue)"
21
+ >
22
+ <span v-if="label" class="select-none text-[12px] text-fg-subtle">{{ label }}</span>
23
+ <span
24
+ class="relative inline-flex shrink-0 rounded-full transition-colors duration-150"
25
+ :class="[
26
+ modelValue ? 'bg-emerald-500' : 'bg-overlay/10',
27
+ size === 'sm' ? 'h-3.5 w-6' : 'h-5 w-9',
28
+ ]"
29
+ >
30
+ <span
31
+ class="absolute rounded-full bg-white shadow-sm transition-all duration-150"
32
+ :class="[
33
+ size === 'sm' ? 'top-[2px] size-2.5' : 'top-0.5 size-4',
34
+ size === 'sm'
35
+ ? (modelValue ? 'left-[12px]' : 'left-[2px]')
36
+ : (modelValue ? 'left-[18px]' : 'left-0.5'),
37
+ ]"
38
+ />
39
+ </span>
40
+ </span>
41
+ </template>
@@ -0,0 +1,44 @@
1
+ <script setup lang="ts">
2
+ import { ref, useSlots } from 'vue'
3
+
4
+ defineProps<{
5
+ modelValue?: string
6
+ placeholder?: string
7
+ }>()
8
+
9
+ const emit = defineEmits<{
10
+ 'update:modelValue': [value: string]
11
+ }>()
12
+
13
+ const slots = useSlots()
14
+ const inputRef = ref<HTMLInputElement>()
15
+ const isFocused = ref(false)
16
+
17
+ defineExpose({
18
+ focus() { inputRef.value?.focus() },
19
+ select() { inputRef.value?.select() },
20
+ el: inputRef,
21
+ })
22
+ </script>
23
+
24
+ <template>
25
+ <div
26
+ class="flex h-[26px] w-full items-center rounded bg-surface-raised ring-1 transition-shadow"
27
+ :class="isFocused ? 'ring-line-focus' : 'ring-line'"
28
+ @click="inputRef?.focus()"
29
+ >
30
+ <input
31
+ ref="inputRef"
32
+ type="text"
33
+ :value="modelValue"
34
+ :placeholder="placeholder"
35
+ class="h-full min-w-0 flex-1 bg-transparent px-2 text-xs text-fg outline-none placeholder:text-fg-subtle"
36
+ @input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
37
+ @focus="isFocused = true"
38
+ @blur="isFocused = false"
39
+ />
40
+ <div v-if="slots['suffix']" class="flex shrink-0 items-center pr-2">
41
+ <slot name="suffix" />
42
+ </div>
43
+ </div>
44
+ </template>
@@ -0,0 +1,49 @@
1
+ <script setup lang="ts">
2
+ import { useTheme, toggleTheme } from '../theme/appTheme'
3
+
4
+ const { theme } = useTheme()
5
+ </script>
6
+
7
+ <template>
8
+ <button
9
+ type="button"
10
+ class="flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-fg-subtle transition-colors hover:bg-hover hover:text-fg-body"
11
+ :aria-label="theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'"
12
+ :title="theme === 'dark' ? 'Light mode' : 'Dark mode'"
13
+ @click="toggleTheme()"
14
+ >
15
+ <!-- Sun: shown in dark mode (click to go light) -->
16
+ <svg
17
+ v-if="theme === 'dark'"
18
+ width="16"
19
+ height="16"
20
+ viewBox="0 0 24 24"
21
+ fill="none"
22
+ stroke="currentColor"
23
+ stroke-width="2"
24
+ stroke-linecap="round"
25
+ stroke-linejoin="round"
26
+ aria-hidden="true"
27
+ >
28
+ <circle cx="12" cy="12" r="4" />
29
+ <path
30
+ d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"
31
+ />
32
+ </svg>
33
+ <!-- Moon: shown in light mode (click to go dark) -->
34
+ <svg
35
+ v-else
36
+ width="16"
37
+ height="16"
38
+ viewBox="0 0 24 24"
39
+ fill="none"
40
+ stroke="currentColor"
41
+ stroke-width="2"
42
+ stroke-linecap="round"
43
+ stroke-linejoin="round"
44
+ aria-hidden="true"
45
+ >
46
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
47
+ </svg>
48
+ </button>
49
+ </template>
@@ -0,0 +1,34 @@
1
+ <script setup lang="ts">
2
+ import Tooltip from './Tooltip.vue'
3
+
4
+ defineProps<{
5
+ modelValue: boolean
6
+ tooltip?: string
7
+ }>()
8
+
9
+ const emit = defineEmits<{
10
+ 'update:modelValue': [value: boolean]
11
+ }>()
12
+ </script>
13
+
14
+ <template>
15
+ <Tooltip :content="tooltip">
16
+ <div
17
+ class="flex cursor-pointer items-center gap-0.5 rounded bg-surface-raised p-0.5 ring-1 ring-line-subtle hover:ring-line-strong"
18
+ @click="emit('update:modelValue', !modelValue)"
19
+ >
20
+ <span
21
+ class="flex items-center rounded-sm p-1 transition-colors"
22
+ :class="!modelValue ? 'bg-fg-ghost text-fg' : 'text-fg-subtle'"
23
+ >
24
+ <slot name="left" />
25
+ </span>
26
+ <span
27
+ class="flex items-center rounded-sm p-1 transition-colors"
28
+ :class="modelValue ? 'bg-fg-ghost text-fg' : 'text-fg-subtle'"
29
+ >
30
+ <slot name="right" />
31
+ </span>
32
+ </div>
33
+ </Tooltip>
34
+ </template>
@@ -0,0 +1,143 @@
1
+ <script setup lang="ts">
2
+ import { computed, onBeforeUnmount, ref, useAttrs, watch } from 'vue'
3
+ import { useFloating, offset as offsetMiddleware, flip, shift, autoUpdate } from '@floating-ui/vue'
4
+ import type { Placement } from '@floating-ui/vue'
5
+
6
+ defineOptions({
7
+ inheritAttrs: false,
8
+ })
9
+
10
+ const props = withDefaults(defineProps<{
11
+ content: string | undefined
12
+ placement?: Placement
13
+ offset?: number
14
+ disabled?: boolean
15
+ openDelayMs?: number
16
+ closeDelayMs?: number
17
+ tag?: 'span' | 'div'
18
+ }>(), {
19
+ placement: 'top',
20
+ offset: 8,
21
+ disabled: false,
22
+ openDelayMs: 120,
23
+ closeDelayMs: 0,
24
+ tag: 'span',
25
+ })
26
+
27
+ const triggerRef = ref<HTMLElement | null>(null)
28
+ const floatingRef = ref<HTMLElement | null>(null)
29
+ const isOpen = ref(false)
30
+ const attrs = useAttrs()
31
+ const hasContent = computed(() => !!props.content?.trim())
32
+ const canShow = computed(() => hasContent.value && !props.disabled)
33
+
34
+ const { floatingStyles } = useFloating(triggerRef, floatingRef, {
35
+ placement: computed(() => props.placement),
36
+ middleware: computed(() => [
37
+ offsetMiddleware(props.offset),
38
+ flip(),
39
+ shift({ padding: 8 }),
40
+ ]),
41
+ whileElementsMounted: autoUpdate,
42
+ transform: false,
43
+ })
44
+
45
+ let openTimer: ReturnType<typeof setTimeout> | null = null
46
+ let closeTimer: ReturnType<typeof setTimeout> | null = null
47
+
48
+ function clearOpenTimer() {
49
+ if (!openTimer) return
50
+ clearTimeout(openTimer)
51
+ openTimer = null
52
+ }
53
+
54
+ function clearCloseTimer() {
55
+ if (!closeTimer) return
56
+ clearTimeout(closeTimer)
57
+ closeTimer = null
58
+ }
59
+
60
+ function clearTimers() {
61
+ clearOpenTimer()
62
+ clearCloseTimer()
63
+ }
64
+
65
+ function openNow() {
66
+ if (!canShow.value) return
67
+ isOpen.value = true
68
+ }
69
+
70
+ function closeNow() {
71
+ isOpen.value = false
72
+ }
73
+
74
+ function scheduleOpen() {
75
+ clearCloseTimer()
76
+ clearOpenTimer()
77
+ if (!canShow.value) return
78
+ if (props.openDelayMs <= 0) {
79
+ openNow()
80
+ return
81
+ }
82
+ openTimer = setTimeout(() => {
83
+ openTimer = null
84
+ openNow()
85
+ }, props.openDelayMs)
86
+ }
87
+
88
+ function scheduleClose() {
89
+ clearOpenTimer()
90
+ clearCloseTimer()
91
+ if (props.closeDelayMs <= 0) {
92
+ closeNow()
93
+ return
94
+ }
95
+ closeTimer = setTimeout(() => {
96
+ closeTimer = null
97
+ closeNow()
98
+ }, props.closeDelayMs)
99
+ }
100
+
101
+ watch(canShow, (nextCanShow) => {
102
+ if (nextCanShow) return
103
+ clearTimers()
104
+ closeNow()
105
+ })
106
+
107
+ onBeforeUnmount(() => {
108
+ clearTimers()
109
+ })
110
+ </script>
111
+
112
+ <template>
113
+ <component
114
+ :is="props.tag"
115
+ ref="triggerRef"
116
+ v-bind="attrs"
117
+ @mouseenter="scheduleOpen"
118
+ @mouseleave="scheduleClose"
119
+ @focusin="scheduleOpen"
120
+ @focusout="scheduleClose"
121
+ >
122
+ <slot />
123
+ </component>
124
+
125
+ <Teleport to="body">
126
+ <Transition
127
+ enter-active-class="transition-[opacity,transform] duration-120 ease-out"
128
+ leave-active-class="transition-[opacity,transform] duration-100 ease-out"
129
+ enter-from-class="opacity-0 scale-95"
130
+ leave-to-class="opacity-0 scale-95"
131
+ >
132
+ <div
133
+ v-if="isOpen && canShow"
134
+ ref="floatingRef"
135
+ class="pointer-events-none z-80 line-clamp-2 max-w-sm rounded-md bg-surface px-2 py-1 text-[11px] leading-4 text-fg ring-1 ring-line-subtle shadow-lg"
136
+ :style="floatingStyles"
137
+ role="tooltip"
138
+ >
139
+ {{ props.content }}
140
+ </div>
141
+ </Transition>
142
+ </Teleport>
143
+ </template>
@@ -0,0 +1,12 @@
1
+ export function createBlankClickGuard(windowMs = 200) {
2
+ let suppressedUntil = 0
3
+
4
+ return {
5
+ suppress(now = Date.now()) {
6
+ suppressedUntil = now + windowMs
7
+ },
8
+ isSuppressed(now = Date.now()) {
9
+ return now < suppressedUntil
10
+ },
11
+ }
12
+ }