@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.
- package/package.json +55 -0
- package/src/agent/AgentMessageInput.vue +164 -0
- package/src/agent/AgentMessageList.vue +124 -0
- package/src/agent/AgentPanel.vue +18 -0
- package/src/agent/AgentRoot.vue +17 -0
- package/src/agent/AgentTabBar.vue +422 -0
- package/src/agent/AgentTabItem.vue +150 -0
- package/src/agent/ContextUsageIndicator.vue +136 -0
- package/src/agent/ConversationListDropdown.vue +71 -0
- package/src/agent/ConversationStatusDot.vue +26 -0
- package/src/agent/ConversationView.vue +82 -0
- package/src/agent/MessageQueueBar.vue +67 -0
- package/src/agent/ModelSelector.vue +125 -0
- package/src/agent/ReasoningSelector.vue +106 -0
- package/src/agent/SelectorTrigger.vue +17 -0
- package/src/agent/__tests__/block-helpers.test.ts +73 -0
- package/src/agent/__tests__/input-actions.test.ts +130 -0
- package/src/agent/__tests__/input-editor.test.ts +15 -0
- package/src/agent/__tests__/pending-steers.test.ts +99 -0
- package/src/agent/__tests__/queue-submit.test.ts +14 -0
- package/src/agent/__tests__/reasoning.test.ts +32 -0
- package/src/agent/__tests__/tail-loading.test.ts +131 -0
- package/src/agent/__tests__/tool-rendering.test.ts +35 -0
- package/src/agent/__tests__/visible-blocks.test.ts +125 -0
- package/src/agent/block-helpers.ts +59 -0
- package/src/agent/block-types.ts +12 -0
- package/src/agent/blocks/AbortedBlock.vue +26 -0
- package/src/agent/blocks/AgentMessageVirtualBlock.vue +88 -0
- package/src/agent/blocks/AnsiText.vue +75 -0
- package/src/agent/blocks/AssistantTextBlock.vue +34 -0
- package/src/agent/blocks/CompactionBlock.vue +36 -0
- package/src/agent/blocks/ErrorBlock.vue +79 -0
- package/src/agent/blocks/FunctionalBlock.vue +118 -0
- package/src/agent/blocks/LoadingBlock.vue +28 -0
- package/src/agent/blocks/ThinkingBlock.vue +86 -0
- package/src/agent/blocks/ToolCallBlock.vue +40 -0
- package/src/agent/blocks/ToolGenericBlock.vue +43 -0
- package/src/agent/blocks/ToolShellAbortBlock.vue +13 -0
- package/src/agent/blocks/ToolShellBlock.vue +47 -0
- package/src/agent/blocks/ToolShellControlBlock.vue +51 -0
- package/src/agent/blocks/ToolShellStatusBlock.vue +13 -0
- package/src/agent/blocks/ToolShellWriteBlock.vue +13 -0
- package/src/agent/blocks/ToolStatusBadge.vue +13 -0
- package/src/agent/blocks/ToolYieldBlock.vue +13 -0
- package/src/agent/blocks/UserBlock.vue +112 -0
- package/src/agent/conversation-runtime.ts +205 -0
- package/src/agent/conversation-status.ts +12 -0
- package/src/agent/message-input/input-model.ts +33 -0
- package/src/agent/message-input/useAgentInputActions.ts +96 -0
- package/src/agent/message-input/useAgentInputEditor.ts +90 -0
- package/src/agent/message-input/useAgentInputSessionState.ts +48 -0
- package/src/agent/pending-steers.ts +134 -0
- package/src/agent/providers/ProviderIcon.vue +32 -0
- package/src/agent/providers/icons/claude.svg +1 -0
- package/src/agent/providers/icons/openai.svg +3 -0
- package/src/agent/queue-submit.ts +4 -0
- package/src/agent/reasoning.ts +45 -0
- package/src/agent/tail-loading.ts +31 -0
- package/src/agent/thinking-streaming.ts +7 -0
- package/src/agent/tool-rendering.ts +59 -0
- package/src/agent/types.ts +39 -0
- package/src/agent/ui-options.ts +21 -0
- package/src/agent/visible-blocks.ts +25 -0
- package/src/agent/workspace.ts +355 -0
- package/src/composables/useBlockVirtualizer.ts +290 -0
- package/src/composables/useContextMenuOwner.ts +44 -0
- package/src/composables/useOverlay.ts +18 -0
- package/src/composables/useTerminalTheme.ts +75 -0
- package/src/env.d.ts +10 -0
- package/src/infra/errors.ts +29 -0
- package/src/infra/i18n.ts +41 -0
- package/src/markdown/filePath.ts +54 -0
- package/src/markdown/highlight.ts +139 -0
- package/src/markdown/md.ts +58 -0
- package/src/markdown/render.ts +117 -0
- package/src/markdown/types.ts +20 -0
- package/src/overlay/appOverlay.ts +3 -0
- package/src/overlay/overlayStore.ts +54 -0
- package/src/store/createStore.ts +33 -0
- package/src/store/useStore.ts +7 -0
- package/src/styles/base.css +342 -0
- package/src/theme/appTheme.ts +56 -0
- package/src/theme/codeThemes.ts +454 -0
- package/src/theme/themeStore.ts +33 -0
- package/src/transport/agent-socket.ts +20 -0
- package/src/transport/control-client.ts +64 -0
- package/src/transport/protocol.ts +57 -0
- package/src/ui/Button.vue +30 -0
- package/src/ui/Checkbox.vue +25 -0
- package/src/ui/ContextMenu.vue +33 -0
- package/src/ui/Dialog.vue +48 -0
- package/src/ui/DropdownMenu.vue +77 -0
- package/src/ui/ErrorBox.vue +43 -0
- package/src/ui/HighlightText.vue +39 -0
- package/src/ui/IconButton.vue +30 -0
- package/src/ui/IndeterminateSpinner.vue +72 -0
- package/src/ui/InlineError.vue +25 -0
- package/src/ui/LoadMore.vue +32 -0
- package/src/ui/MarkdownPreview.vue +31 -0
- package/src/ui/Menu.vue +8 -0
- package/src/ui/MenuDivider.vue +6 -0
- package/src/ui/MenuItem.vue +48 -0
- package/src/ui/OptionMenu.vue +8 -0
- package/src/ui/OptionMenuGroup.vue +16 -0
- package/src/ui/OptionMenuItem.vue +28 -0
- package/src/ui/Popover.vue +90 -0
- package/src/ui/ScrollToBottomButton.vue +32 -0
- package/src/ui/SelectDropdown.vue +231 -0
- package/src/ui/Switch.vue +41 -0
- package/src/ui/TextInput.vue +44 -0
- package/src/ui/ThemeToggle.vue +49 -0
- package/src/ui/ToggleSwitch.vue +34 -0
- package/src/ui/Tooltip.vue +143 -0
- 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>
|