@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,33 @@
1
+ <script setup lang="ts">
2
+ import type { OverlayStore } from '../overlay/overlayStore'
3
+ import { useContextMenuOwner } from '../composables/useContextMenuOwner'
4
+ import Popover from './Popover.vue'
5
+ import Menu from './Menu.vue'
6
+
7
+ defineProps<{
8
+ overlayStore: OverlayStore
9
+ }>()
10
+
11
+ const emit = defineEmits<{
12
+ open: [event: MouseEvent]
13
+ close: []
14
+ }>()
15
+
16
+ const { isOpen, anchorX, anchorY, menuKey, open, close } = useContextMenuOwner(() => emit('close'))
17
+
18
+ function handleContextMenu(event: MouseEvent) {
19
+ open(event)
20
+ emit('open', event)
21
+ }
22
+ </script>
23
+
24
+ <template>
25
+ <div @contextmenu="handleContextMenu">
26
+ <slot name="trigger" />
27
+ <Popover :key="menuKey" :overlay-store="overlayStore" :is-open="isOpen" :anchor-x="anchorX" :anchor-y="anchorY" :offset="0" @close="close">
28
+ <Menu @click="close">
29
+ <slot name="menu" />
30
+ </Menu>
31
+ </Popover>
32
+ </div>
33
+ </template>
@@ -0,0 +1,48 @@
1
+ <script setup lang="ts">
2
+ import type { OverlayStore } from '../overlay/overlayStore'
3
+ import { useOverlay } from '../composables/useOverlay'
4
+
5
+ const props = defineProps<{
6
+ isOpen: boolean
7
+ overlayStore: OverlayStore
8
+ }>()
9
+
10
+ const emit = defineEmits<{
11
+ close: []
12
+ }>()
13
+
14
+ useOverlay(props.overlayStore, () => props.isOpen, () => {
15
+ if (props.isOpen) emit('close')
16
+ })
17
+ </script>
18
+
19
+ <template>
20
+ <Teleport to="body">
21
+ <Transition
22
+ enter-active-class="transition-opacity duration-150 ease-out"
23
+ leave-active-class="transition-opacity duration-150 ease-out"
24
+ enter-from-class="opacity-0"
25
+ leave-to-class="opacity-0"
26
+ >
27
+ <div
28
+ v-if="isOpen"
29
+ class="fixed inset-0 z-50 grid place-items-center bg-black/60"
30
+ @click.self="emit('close')"
31
+ >
32
+ <Transition
33
+ appear
34
+ enter-active-class="transition-[opacity,transform] duration-150 ease-out"
35
+ leave-active-class="transition-[opacity,transform] duration-150 ease-out"
36
+ enter-from-class="opacity-0 scale-95"
37
+ leave-to-class="opacity-0 scale-95"
38
+ >
39
+ <div
40
+ class="w-full max-w-md rounded-xl border border-line bg-surface shadow-2xl"
41
+ >
42
+ <slot />
43
+ </div>
44
+ </Transition>
45
+ </div>
46
+ </Transition>
47
+ </Teleport>
48
+ </template>
@@ -0,0 +1,77 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from 'vue'
3
+ import type { OverlayStore } from '../overlay/overlayStore'
4
+ import Popover from './Popover.vue'
5
+
6
+ const props = withDefaults(defineProps<{
7
+ overlayStore: OverlayStore
8
+ placement?: 'top' | 'top-start' | 'top-end' | 'bottom' | 'bottom-start' | 'bottom-end'
9
+ offset?: number
10
+ anchorInset?: number
11
+ shiftPadding?: number
12
+ }>(), {
13
+ placement: 'top',
14
+ offset: 8,
15
+ })
16
+
17
+ const emit = defineEmits<{
18
+ close: []
19
+ }>()
20
+
21
+ const isOpen = ref(false)
22
+ const anchorX = ref(0)
23
+ const anchorY = ref(0)
24
+ const anchorWidth = ref(0)
25
+ const anchorHeight = ref(0)
26
+ const triggerRef = ref<HTMLDivElement>()
27
+ const ignoreEls = computed(() => triggerRef.value ? [triggerRef.value] : [])
28
+
29
+ function updateAnchor() {
30
+ if (!triggerRef.value) return
31
+ const rect = triggerRef.value.getBoundingClientRect()
32
+ const inset = props.anchorInset ?? 0
33
+ anchorX.value = rect.left + inset
34
+ anchorY.value = rect.top
35
+ anchorWidth.value = rect.width - inset * 2
36
+ anchorHeight.value = rect.height
37
+ }
38
+
39
+ function handleClick() {
40
+ updateAnchor()
41
+ isOpen.value = !isOpen.value
42
+ }
43
+
44
+ function open() {
45
+ updateAnchor()
46
+ isOpen.value = true
47
+ }
48
+
49
+ function close() {
50
+ if (!isOpen.value) return
51
+ isOpen.value = false
52
+ emit('close')
53
+ }
54
+
55
+ defineExpose({ open, close })
56
+ </script>
57
+
58
+ <template>
59
+ <div ref="triggerRef" @click="handleClick">
60
+ <slot name="trigger" :is-open="isOpen" />
61
+ </div>
62
+ <Popover
63
+ :overlay-store="props.overlayStore"
64
+ :is-open="isOpen"
65
+ :anchor-x="anchorX"
66
+ :anchor-y="anchorY"
67
+ :anchor-width="anchorWidth"
68
+ :anchor-height="anchorHeight"
69
+ :placement="props.placement"
70
+ :offset="props.offset"
71
+ :shift-padding="props.shiftPadding"
72
+ :ignore-els="ignoreEls"
73
+ @close="close"
74
+ >
75
+ <slot name="content" :close="close" :trigger-width="anchorWidth" />
76
+ </Popover>
77
+ </template>
@@ -0,0 +1,43 @@
1
+ <script setup lang="ts">
2
+ import { useClipboard } from '@vueuse/core'
3
+ import { CheckLine } from '@mingcute/vue/check'
4
+ import { CopyLine } from '@mingcute/vue/copy'
5
+ import { CloseLine } from '@mingcute/vue/close'
6
+
7
+ const props = defineProps<{
8
+ message: string
9
+ copyable?: boolean
10
+ dismissible?: boolean
11
+ }>()
12
+
13
+ const emit = defineEmits<{
14
+ dismiss: []
15
+ }>()
16
+
17
+ const { copy, copied } = useClipboard({ copiedDuring: 1500 })
18
+ </script>
19
+
20
+ <template>
21
+ <div class="rounded-lg border border-on-danger-muted bg-tint-danger px-3 py-2">
22
+ <div class="flex items-start gap-2">
23
+ <pre class="m-0 min-w-0 flex-1 whitespace-pre-wrap break-words font-mono text-[12px] leading-5 text-on-danger">{{ message }}</pre>
24
+ <div v-if="copyable || dismissible" class="flex shrink-0 items-center gap-0.5">
25
+ <span
26
+ v-if="copyable"
27
+ class="flex cursor-pointer items-center rounded-md p-1 text-on-danger-muted transition-colors hover:bg-tint-danger-strong hover:text-on-danger"
28
+ @click="copy(props.message)"
29
+ >
30
+ <CheckLine v-if="copied" :size="12" class="text-on-success" />
31
+ <CopyLine v-else :size="12" />
32
+ </span>
33
+ <div
34
+ v-if="dismissible"
35
+ class="flex cursor-pointer items-center justify-center rounded p-1 text-on-danger-muted transition-colors hover:bg-tint-danger-strong hover:text-on-danger"
36
+ @click="emit('dismiss')"
37
+ >
38
+ <CloseLine :size="12" />
39
+ </div>
40
+ </div>
41
+ </div>
42
+ </div>
43
+ </template>
@@ -0,0 +1,39 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+
4
+ const props = defineProps<{
5
+ text: string
6
+ query: string
7
+ }>()
8
+
9
+ const segments = computed(() => {
10
+ const q = props.query.trim().toLowerCase()
11
+ if (!q) return [{ text: props.text, isMatch: false }]
12
+
13
+ const result: { text: string; isMatch: boolean }[] = []
14
+ const lower = props.text.toLowerCase()
15
+ let cursor = 0
16
+
17
+ while (cursor < props.text.length) {
18
+ const matchIdx = lower.indexOf(q, cursor)
19
+ if (matchIdx === -1) {
20
+ result.push({ text: props.text.slice(cursor), isMatch: false })
21
+ break
22
+ }
23
+ if (matchIdx > cursor) {
24
+ result.push({ text: props.text.slice(cursor, matchIdx), isMatch: false })
25
+ }
26
+ result.push({ text: props.text.slice(matchIdx, matchIdx + q.length), isMatch: true })
27
+ cursor = matchIdx + q.length
28
+ }
29
+
30
+ return result
31
+ })
32
+ </script>
33
+
34
+ <template>
35
+ <template v-for="(seg, i) in segments" :key="i">
36
+ <mark v-if="seg.isMatch" class="bg-tint-highlight text-on-highlight">{{ seg.text }}</mark>
37
+ <template v-else>{{ seg.text }}</template>
38
+ </template>
39
+ </template>
@@ -0,0 +1,30 @@
1
+ <script setup lang="ts">
2
+ import type { Component } from 'vue'
3
+
4
+ withDefaults(defineProps<{
5
+ icon: Component
6
+ iconSize?: number
7
+ size?: 'xs' | 'sm' | 'md' | 'lg'
8
+ variant?: 'default' | 'danger'
9
+ disabled?: boolean
10
+ }>(), {
11
+ size: 'md',
12
+ variant: 'default',
13
+ })
14
+ </script>
15
+
16
+ <template>
17
+ <span
18
+ class="flex shrink-0 cursor-pointer items-center justify-center transition-colors"
19
+ :class="[
20
+ size === 'xs' ? 'size-4 rounded-sm' : size === 'sm' ? 'size-5 rounded' : size === 'lg' ? 'size-8 rounded-lg' : 'size-6 rounded-md',
21
+ disabled
22
+ ? 'pointer-events-none text-fg-ghost'
23
+ : variant === 'danger'
24
+ ? 'text-fg-subtle hover:bg-hover hover:text-on-danger'
25
+ : 'text-fg-subtle hover:bg-hover hover:text-fg-body',
26
+ ]"
27
+ >
28
+ <component :is="icon" :size="iconSize ?? (size === 'xs' ? 10 : size === 'sm' ? 14 : size === 'lg' ? 18 : 16)" />
29
+ </span>
30
+ </template>
@@ -0,0 +1,72 @@
1
+ <script setup lang="ts">
2
+ import { onMounted, ref } from 'vue'
3
+
4
+ const props = defineProps<{
5
+ size?: number
6
+ strokeWidth?: number
7
+ trackClass?: string
8
+ arcClass?: string
9
+ }>()
10
+
11
+ const svgSize = props.size ?? 14
12
+ const stroke = props.strokeWidth ?? 2.5
13
+ const radius = (svgSize - stroke) / 2
14
+ const circumference = 2 * Math.PI * radius
15
+ const cx = svgSize / 2
16
+ const cy = svgSize / 2
17
+
18
+ const arcRef = ref<SVGCircleElement>()
19
+
20
+ const dashMin = circumference * 0.1
21
+ const dashMax = circumference * 0.45
22
+
23
+ onMounted(() => {
24
+ arcRef.value?.animate([
25
+ { strokeDasharray: `${dashMin} ${circumference - dashMin}`, strokeDashoffset: '0' },
26
+ { strokeDasharray: `${dashMax} ${circumference - dashMax}`, strokeDashoffset: `${circumference * -0.2}` },
27
+ { strokeDasharray: `${dashMin} ${circumference - dashMin}`, strokeDashoffset: `${-circumference}` },
28
+ ], {
29
+ duration: 1500,
30
+ easing: 'ease-in-out',
31
+ iterations: Infinity,
32
+ })
33
+ })
34
+ </script>
35
+
36
+ <template>
37
+ <svg
38
+ :width="svgSize" :height="svgSize"
39
+ :viewBox="`0 0 ${svgSize} ${svgSize}`"
40
+ class="spinner-rotate"
41
+ >
42
+ <circle
43
+ :cx="cx" :cy="cy" :r="radius"
44
+ fill="none"
45
+ stroke="currentColor"
46
+ :stroke-width="stroke"
47
+ :class="trackClass ?? 'text-overlay/8'"
48
+ />
49
+ <circle
50
+ ref="arcRef"
51
+ :cx="cx" :cy="cy" :r="radius"
52
+ fill="none"
53
+ stroke="currentColor"
54
+ :stroke-width="stroke"
55
+ stroke-linecap="round"
56
+ :class="arcClass ?? 'text-fg-muted'"
57
+ :stroke-dasharray="`${dashMin} ${circumference - dashMin}`"
58
+ :style="{ transformOrigin: `${cx}px ${cy}px` }"
59
+ />
60
+ </svg>
61
+ </template>
62
+
63
+ <style scoped>
64
+ .spinner-rotate {
65
+ animation: spinner-rotate 2s linear infinite;
66
+ }
67
+
68
+ @keyframes spinner-rotate {
69
+ 0% { transform: rotate(0deg); }
70
+ 100% { transform: rotate(360deg); }
71
+ }
72
+ </style>
@@ -0,0 +1,25 @@
1
+ <script setup lang="ts">
2
+ import { CloseLine } from '@mingcute/vue/close'
3
+
4
+ defineProps<{
5
+ message: string
6
+ dismissible?: boolean
7
+ }>()
8
+
9
+ const emit = defineEmits<{
10
+ dismiss: []
11
+ }>()
12
+ </script>
13
+
14
+ <template>
15
+ <div class="flex items-start gap-1.5">
16
+ <p class="min-w-0 flex-1 text-[12px] leading-4 text-on-danger">{{ message }}</p>
17
+ <div
18
+ v-if="dismissible"
19
+ class="flex shrink-0 cursor-pointer items-center justify-center rounded p-0.5 text-on-danger-muted transition-colors hover:bg-hover hover:text-on-danger"
20
+ @click="emit('dismiss')"
21
+ >
22
+ <CloseLine :size="12" />
23
+ </div>
24
+ </div>
25
+ </template>
@@ -0,0 +1,32 @@
1
+ <script setup lang="ts">
2
+ import { ref } from 'vue'
3
+ import { useIntersectionObserver } from '@vueuse/core'
4
+
5
+ const props = withDefaults(defineProps<{
6
+ hasMore: boolean
7
+ isLoading: boolean
8
+ loadingLabel?: string
9
+ }>(), {
10
+ loadingLabel: 'Loading...',
11
+ })
12
+
13
+ const emit = defineEmits<{
14
+ load: []
15
+ }>()
16
+
17
+ const sentinel = ref<HTMLElement | null>(null)
18
+
19
+ useIntersectionObserver(sentinel, ([entry]) => {
20
+ if (entry?.isIntersecting && props.hasMore && !props.isLoading) {
21
+ emit('load')
22
+ }
23
+ })
24
+ </script>
25
+
26
+ <template>
27
+ <div ref="sentinel">
28
+ <slot v-if="isLoading" name="loading">
29
+ <div class="py-2 text-center text-[12px] text-fg-subtle">{{ loadingLabel }}</div>
30
+ </slot>
31
+ </div>
32
+ </template>
@@ -0,0 +1,31 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import type { MarkdownLinkHandler, MarkdownRenderer } from '../markdown/types'
4
+
5
+ const props = defineProps<{
6
+ content: string
7
+ basePath?: string
8
+ renderMarkdown: MarkdownRenderer
9
+ onLinkClick?: MarkdownLinkHandler
10
+ }>()
11
+
12
+ const renderedHtml = computed(() =>
13
+ props.renderMarkdown(props.content, props.basePath ? { basePath: props.basePath } : undefined),
14
+ )
15
+
16
+ function handleClick(event: MouseEvent) {
17
+ const target = (event.target as HTMLElement).closest('a')
18
+ if (!target) return
19
+ const href = target.getAttribute('href')
20
+ if (!href) return
21
+ props.onLinkClick?.({ href, event, basePath: props.basePath })
22
+ }
23
+ </script>
24
+
25
+ <template>
26
+ <div
27
+ class="markdown-body h-full overflow-y-auto bg-surface-editor p-4 text-sm leading-relaxed text-fg-body select-text"
28
+ v-html="renderedHtml"
29
+ @click="handleClick"
30
+ />
31
+ </template>
@@ -0,0 +1,8 @@
1
+ <script setup lang="ts">
2
+ </script>
3
+
4
+ <template>
5
+ <div class="min-w-[160px] rounded-lg border border-line-strong bg-surface p-1 shadow-lg">
6
+ <slot />
7
+ </div>
8
+ </template>
@@ -0,0 +1,6 @@
1
+ <script setup lang="ts">
2
+ </script>
3
+
4
+ <template>
5
+ <div class="my-1 h-px bg-overlay/8" />
6
+ </template>
@@ -0,0 +1,48 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import type { Component } from 'vue'
4
+ import Tooltip from './Tooltip.vue'
5
+
6
+ const props = defineProps<{
7
+ icon?: Component
8
+ label: string
9
+ isDanger?: boolean
10
+ disabled?: boolean
11
+ disabledReason?: string
12
+ shortcut?: string
13
+ }>()
14
+
15
+ const emit = defineEmits<{
16
+ select: []
17
+ }>()
18
+
19
+ const displayShortcut = computed(() => props.shortcut ?? '')
20
+ const isDisabled = computed(() => props.disabled || !!props.disabledReason)
21
+ const tooltipContent = computed(() => props.disabledReason?.trim() || undefined)
22
+
23
+ function handleClick(event: MouseEvent) {
24
+ if (isDisabled.value) {
25
+ event.stopPropagation()
26
+ return
27
+ }
28
+ emit('select')
29
+ }
30
+ </script>
31
+
32
+ <template>
33
+ <Tooltip :content="tooltipContent" :disabled="!tooltipContent" placement="bottom" :open-delay-ms="80" tag="div">
34
+ <div
35
+ class="flex select-none items-center gap-2 rounded-md px-2.5 py-1.5 text-[13px] transition-colors"
36
+ :class="isDisabled
37
+ ? 'cursor-not-allowed text-fg-faint'
38
+ : isDanger
39
+ ? 'cursor-pointer text-on-danger hover:bg-tint-danger-strong hover:text-on-danger'
40
+ : 'cursor-pointer text-fg-body hover:bg-active hover:text-fg-emphasis'"
41
+ @click="handleClick"
42
+ >
43
+ <component :is="icon" v-if="icon" :size="15" class="shrink-0" />
44
+ <span class="flex-1">{{ label }}</span>
45
+ <span class="w-8 shrink-0 text-right text-[11px] text-fg-faint">{{ displayShortcut }}</span>
46
+ </div>
47
+ </Tooltip>
48
+ </template>
@@ -0,0 +1,8 @@
1
+ <script setup lang="ts">
2
+ </script>
3
+
4
+ <template>
5
+ <div class="flex min-w-[140px] flex-col gap-0.5 rounded-lg border border-line-strong bg-surface p-1 shadow-lg">
6
+ <slot />
7
+ </div>
8
+ </template>
@@ -0,0 +1,16 @@
1
+ <script setup lang="ts">
2
+ defineProps<{
3
+ label: string
4
+ }>()
5
+ </script>
6
+
7
+ <template>
8
+ <div class="border-t border-line pt-0.5 first:border-t-0 first:pt-0">
9
+ <div class="px-2.5 pt-1.5 pb-1 text-[11px] font-medium text-fg-subtle">
10
+ {{ label }}
11
+ </div>
12
+ <div class="flex flex-col gap-0.5">
13
+ <slot />
14
+ </div>
15
+ </div>
16
+ </template>
@@ -0,0 +1,28 @@
1
+ <script setup lang="ts">
2
+ import type { Component } from 'vue'
3
+ import { CheckLine } from '@mingcute/vue/check'
4
+
5
+ defineProps<{
6
+ icon?: Component
7
+ label: string
8
+ isSelected?: boolean
9
+ }>()
10
+
11
+ const emit = defineEmits<{
12
+ select: []
13
+ }>()
14
+ </script>
15
+
16
+ <template>
17
+ <div
18
+ class="flex cursor-pointer items-center gap-2 rounded-md px-2.5 py-1.5 text-[13px] transition-colors"
19
+ :class="isSelected
20
+ ? 'bg-active text-fg-emphasis'
21
+ : 'text-fg-muted hover:bg-hover hover:text-fg'"
22
+ @click="emit('select')"
23
+ >
24
+ <component :is="icon" v-if="icon" :size="15" class="shrink-0" />
25
+ <span class="flex-1">{{ label }}</span>
26
+ <CheckLine v-if="isSelected" :size="14" class="shrink-0 text-fg-body" />
27
+ </div>
28
+ </template>
@@ -0,0 +1,90 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from 'vue'
3
+ import { useFloating, offset as offsetMiddleware, flip, shift, autoUpdate } from '@floating-ui/vue'
4
+ import type { Placement } from '@floating-ui/vue'
5
+ import { onClickOutside } from '@vueuse/core'
6
+ import type { OverlayStore } from '../overlay/overlayStore'
7
+ import { useOverlay } from '../composables/useOverlay'
8
+
9
+ const props = withDefaults(defineProps<{
10
+ isOpen: boolean
11
+ overlayStore: OverlayStore
12
+ anchorX: number
13
+ anchorY: number
14
+ anchorWidth?: number
15
+ anchorHeight?: number
16
+ placement?: Placement
17
+ offset?: number
18
+ shiftPadding?: number
19
+ ignoreEls?: HTMLElement[]
20
+ }>(), {
21
+ anchorWidth: 0,
22
+ anchorHeight: 0,
23
+ placement: 'bottom-start',
24
+ offset: 6,
25
+ shiftPadding: 8,
26
+ })
27
+
28
+ const emit = defineEmits<{
29
+ close: []
30
+ }>()
31
+
32
+ const floatingRef = ref<HTMLElement | null>(null)
33
+
34
+ const virtualRef = computed(() => ({
35
+ getBoundingClientRect: () => ({
36
+ x: props.anchorX,
37
+ y: props.anchorY,
38
+ width: props.anchorWidth,
39
+ height: props.anchorHeight,
40
+ top: props.anchorY,
41
+ left: props.anchorX,
42
+ right: props.anchorX + props.anchorWidth,
43
+ bottom: props.anchorY + props.anchorHeight,
44
+ }),
45
+ }))
46
+
47
+ const { floatingStyles, placement: resolvedPlacement } = useFloating(virtualRef, floatingRef, {
48
+ placement: computed(() => props.placement),
49
+ middleware: computed(() => [
50
+ offsetMiddleware(props.offset),
51
+ flip(),
52
+ shift({ padding: props.shiftPadding }),
53
+ ]),
54
+ whileElementsMounted: autoUpdate,
55
+ transform: false,
56
+ })
57
+
58
+ const transformOrigin = computed(() => {
59
+ const p = resolvedPlacement.value
60
+ const y = p.startsWith('top') ? 'bottom' : 'top'
61
+ const x = p.endsWith('start') ? 'left' : p.endsWith('end') ? 'right' : 'center'
62
+ return `${y} ${x}`
63
+ })
64
+
65
+ onClickOutside(floatingRef, () => {
66
+ if (props.isOpen) emit('close')
67
+ }, { ignore: computed(() => props.ignoreEls ?? []) })
68
+
69
+ useOverlay(props.overlayStore, () => props.isOpen, () => emit('close'))
70
+ </script>
71
+
72
+ <template>
73
+ <Teleport to="body">
74
+ <Transition
75
+ enter-active-class="transition-[opacity,transform] duration-150 ease-out"
76
+ leave-active-class="transition-[opacity,transform] duration-150 ease-out"
77
+ enter-from-class="opacity-0 scale-95"
78
+ leave-to-class="opacity-0 scale-95"
79
+ >
80
+ <div
81
+ v-if="isOpen"
82
+ ref="floatingRef"
83
+ class="popover-floating z-50"
84
+ :style="{ ...floatingStyles, transformOrigin: transformOrigin }"
85
+ >
86
+ <slot />
87
+ </div>
88
+ </Transition>
89
+ </Teleport>
90
+ </template>
@@ -0,0 +1,32 @@
1
+ <script setup lang="ts">
2
+ import { DownLine } from '@mingcute/vue/down'
3
+
4
+ const props = withDefaults(defineProps<{
5
+ visible: boolean
6
+ bottomOffset?: number
7
+ }>(), {
8
+ bottomOffset: 12,
9
+ })
10
+
11
+ const emit = defineEmits<{
12
+ click: []
13
+ }>()
14
+ </script>
15
+
16
+ <template>
17
+ <Transition
18
+ enter-active-class="transition duration-200 ease-out"
19
+ leave-active-class="transition duration-200 ease-in"
20
+ enter-from-class="translate-y-2 opacity-0"
21
+ leave-to-class="translate-y-2 opacity-0"
22
+ >
23
+ <div
24
+ v-if="visible"
25
+ class="absolute right-5 z-20 cursor-pointer rounded-full border border-fg-ghost bg-surface-raised p-1.5 text-fg-muted shadow-lg transition-colors hover:border-fg-faint hover:bg-fg-ghost hover:text-fg"
26
+ :style="{ bottom: `${props.bottomOffset}px` }"
27
+ @click="emit('click')"
28
+ >
29
+ <DownLine :size="16" />
30
+ </div>
31
+ </Transition>
32
+ </template>