@prsm/mono-components 0.1.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.
Files changed (90) hide show
  1. package/.claude/settings.local.json +13 -0
  2. package/.lore +83 -0
  3. package/histoire.config.js +43 -0
  4. package/package.json +39 -0
  5. package/postcss.config.js +6 -0
  6. package/src/components/Badge.vue +36 -0
  7. package/src/components/Button.vue +44 -0
  8. package/src/components/Checkbox.vue +51 -0
  9. package/src/components/CheckboxCards.vue +61 -0
  10. package/src/components/CodeEditor.vue +299 -0
  11. package/src/components/Collapsible.vue +69 -0
  12. package/src/components/CollapsibleGroup.vue +38 -0
  13. package/src/components/Combobox.vue +179 -0
  14. package/src/components/ContextMenu.vue +65 -0
  15. package/src/components/ContextMenuPanel.vue +115 -0
  16. package/src/components/DataTable.vue +326 -0
  17. package/src/components/Dropdown.vue +127 -0
  18. package/src/components/GhostInput.vue +29 -0
  19. package/src/components/Input.vue +23 -0
  20. package/src/components/KeyValue.vue +149 -0
  21. package/src/components/LabeledTextarea.vue +64 -0
  22. package/src/components/LabeledTextareaGroup.vue +14 -0
  23. package/src/components/Mention.vue +79 -0
  24. package/src/components/Modal.vue +109 -0
  25. package/src/components/MultiCombobox.vue +209 -0
  26. package/src/components/NavTree.vue +98 -0
  27. package/src/components/NumberInput.vue +128 -0
  28. package/src/components/PopConfirm.vue +94 -0
  29. package/src/components/Popover.vue +53 -0
  30. package/src/components/RadioCards.vue +37 -0
  31. package/src/components/RadioGroup.vue +57 -0
  32. package/src/components/RangeSlider.vue +165 -0
  33. package/src/components/ScrollBox.vue +78 -0
  34. package/src/components/SectionHeader.vue +18 -0
  35. package/src/components/Select.vue +187 -0
  36. package/src/components/Switch.vue +85 -0
  37. package/src/components/Tabs.vue +34 -0
  38. package/src/components/TagInput.vue +80 -0
  39. package/src/components/Textarea.vue +97 -0
  40. package/src/components/ToastContainer.vue +104 -0
  41. package/src/components/ToggleButtons.vue +45 -0
  42. package/src/components/ToggleGroup.vue +30 -0
  43. package/src/components/Tooltip.vue +56 -0
  44. package/src/components/Tree.vue +188 -0
  45. package/src/composables/toast.js +54 -0
  46. package/src/composables/useClickOutside.js +23 -0
  47. package/src/composables/useMention.js +291 -0
  48. package/src/composables/usePointerDrag.js +39 -0
  49. package/src/histoire-setup.js +1 -0
  50. package/src/index.js +43 -0
  51. package/src/style.css +96 -0
  52. package/stories/Badge.story.vue +24 -0
  53. package/stories/Button.story.vue +45 -0
  54. package/stories/Checkbox.story.vue +31 -0
  55. package/stories/CheckboxCards.story.vue +51 -0
  56. package/stories/CodeEditor.story.vue +71 -0
  57. package/stories/Collapsible.story.vue +84 -0
  58. package/stories/Combobox.story.vue +44 -0
  59. package/stories/ContextMenu.story.vue +59 -0
  60. package/stories/DataTable.story.vue +185 -0
  61. package/stories/Dropdown.story.vue +49 -0
  62. package/stories/GhostInput.story.vue +24 -0
  63. package/stories/Input.story.vue +23 -0
  64. package/stories/KeyValue.story.vue +104 -0
  65. package/stories/LabeledTextarea.story.vue +44 -0
  66. package/stories/Mention.story.vue +166 -0
  67. package/stories/Modal.story.vue +86 -0
  68. package/stories/MultiCombobox.story.vue +76 -0
  69. package/stories/NavTree.story.vue +184 -0
  70. package/stories/NumberInput.story.vue +31 -0
  71. package/stories/Overview.story.vue +85 -0
  72. package/stories/PopConfirm.story.vue +39 -0
  73. package/stories/RadioCards.story.vue +66 -0
  74. package/stories/RadioGroup.story.vue +52 -0
  75. package/stories/RangeSlider.story.vue +75 -0
  76. package/stories/ScrollBox.story.vue +54 -0
  77. package/stories/SectionHeader.story.vue +22 -0
  78. package/stories/Select.story.vue +34 -0
  79. package/stories/Switch.story.vue +42 -0
  80. package/stories/Tabs.story.vue +34 -0
  81. package/stories/TagInput.story.vue +54 -0
  82. package/stories/Textarea.story.vue +28 -0
  83. package/stories/Toast.story.vue +28 -0
  84. package/stories/ToggleButtons.story.vue +57 -0
  85. package/stories/ToggleGroup.story.vue +34 -0
  86. package/stories/Tooltip.story.vue +55 -0
  87. package/stories/Tree.story.vue +115 -0
  88. package/tailwind.config.js +9 -0
  89. package/tailwind.preset.js +79 -0
  90. package/vite.config.js +6 -0
@@ -0,0 +1,64 @@
1
+ <script setup>
2
+ import { inject, ref } from "vue"
3
+ import { Icon } from "@iconify/vue"
4
+ import Textarea from "./Textarea.vue"
5
+
6
+ defineProps({
7
+ label: { type: String, required: true },
8
+ icon: { type: String, default: null },
9
+ modelValue: { type: String, default: "" },
10
+ placeholder: { type: String, default: "" },
11
+ autoGrow: { type: Boolean, default: false },
12
+ rows: { type: Number, default: 3 }
13
+ })
14
+
15
+ const emit = defineEmits(["update:modelValue"])
16
+ const grouped = inject("labeled-textarea-grouped", false)
17
+ const wrapper = ref(null)
18
+
19
+ function focusTextarea() {
20
+ wrapper.value?.querySelector("textarea")?.focus()
21
+ }
22
+ </script>
23
+
24
+ <template>
25
+ <div
26
+ ref="wrapper"
27
+ class="labeled-textarea"
28
+ :class="[
29
+ grouped
30
+ ? 'grouped'
31
+ : 'border border-line rounded-sm overflow-hidden focus-within:border-accent'
32
+ ]"
33
+ >
34
+ <div class="px-2 py-1.5 bg-1 border-b border-line flex items-center gap-1.5" @mousedown.prevent="focusTextarea">
35
+ <Icon v-if="icon" :icon="icon" class="text-sm text-fg-3" />
36
+ <span class="text-xs text-fg-3 uppercase tracking-wide cursor-default">{{ label }}</span>
37
+ </div>
38
+ <Textarea
39
+ :model-value="modelValue"
40
+ :placeholder="placeholder"
41
+ :auto-grow="autoGrow"
42
+ :rows="rows"
43
+ class="!border-0 !rounded-none block"
44
+ @update:model-value="emit('update:modelValue', $event)"
45
+ />
46
+ </div>
47
+ </template>
48
+
49
+ <style scoped>
50
+ .labeled-textarea.grouped {
51
+ position: relative;
52
+ border-top: 1px solid var(--border);
53
+ }
54
+
55
+ .labeled-textarea.grouped:first-child {
56
+ border-top: none;
57
+ }
58
+
59
+ .labeled-textarea.grouped:focus-within {
60
+ z-index: 1;
61
+ outline: 1px solid var(--accent);
62
+ outline-offset: -1px;
63
+ }
64
+ </style>
@@ -0,0 +1,14 @@
1
+ <script setup>
2
+ import { provide } from "vue"
3
+
4
+ provide("labeled-textarea-grouped", true)
5
+ </script>
6
+
7
+ <template>
8
+ <div class="border border-line rounded-sm overflow-hidden">
9
+ <slot />
10
+ <div v-if="$slots.footer" class="px-3 py-1.5 bg-1 border-t border-line">
11
+ <slot name="footer" />
12
+ </div>
13
+ </div>
14
+ </template>
@@ -0,0 +1,79 @@
1
+ <script setup>
2
+ import { ref, computed, watch, nextTick } from "vue"
3
+ import { useFloating, flip, shift, offset, size } from "@floating-ui/vue"
4
+ import ScrollBox from "./ScrollBox.vue"
5
+
6
+ const props = defineProps({
7
+ open: { type: Boolean, default: false },
8
+ items: { type: Array, default: () => [] },
9
+ activeIndex: { type: Number, default: 0 },
10
+ loading: { type: Boolean, default: false },
11
+ anchor: { type: Object, default: null }
12
+ })
13
+
14
+ const emit = defineEmits(["select", "hover"])
15
+
16
+ const panel = ref(null)
17
+ const maxHeight = ref(null)
18
+
19
+ const anchorRef = computed(() => props.anchor)
20
+
21
+ const { floatingStyles } = useFloating(anchorRef, panel, {
22
+ placement: "bottom-start",
23
+ strategy: "fixed",
24
+ middleware: [
25
+ offset(4),
26
+ flip(),
27
+ shift({ crossAxis: true, padding: 8 }),
28
+ size({
29
+ padding: 8,
30
+ apply({ availableHeight }) {
31
+ maxHeight.value = Math.min(availableHeight, 200)
32
+ }
33
+ })
34
+ ],
35
+ open: computed(() => props.open)
36
+ })
37
+
38
+ watch(() => props.activeIndex, () => {
39
+ nextTick(() => {
40
+ const el = panel.value?.querySelector("[data-active]")
41
+ if (el) el.scrollIntoView({ block: "nearest" })
42
+ })
43
+ })
44
+ </script>
45
+
46
+ <template>
47
+ <Teleport to="body">
48
+ <div
49
+ v-if="open && (items.length > 0 || loading)"
50
+ ref="panel"
51
+ class="bg-1 border border-line rounded-sm z-[1000] overflow-hidden flex flex-col min-w-[160px]"
52
+ :style="{ ...floatingStyles, maxHeight: maxHeight + 'px' }"
53
+ >
54
+ <ScrollBox class="flex-1 py-0.5">
55
+ <button
56
+ v-for="(item, i) in items"
57
+ :key="item.value"
58
+ type="button"
59
+ class="flex items-center w-full py-1 bg-transparent font-mono text-base text-left cursor-pointer whitespace-nowrap overflow-hidden text-ellipsis"
60
+ :class="i === activeIndex
61
+ ? 'bg-3 text-fg-0 mention-item-active'
62
+ : 'text-fg-2 hover:bg-2 hover:text-fg-0'"
63
+ :data-active="i === activeIndex ? '' : undefined"
64
+ @mousedown.prevent="emit('select', item)"
65
+ @mouseenter="emit('hover', i)"
66
+ >
67
+ <span
68
+ class="w-0.5 self-stretch shrink-0 rounded-full"
69
+ :class="i === activeIndex ? 'bg-accent' : 'bg-transparent'"
70
+ />
71
+ <span class="px-2 flex-1 overflow-hidden text-ellipsis">{{ item.label }}</span>
72
+ </button>
73
+ </ScrollBox>
74
+ <div v-if="loading && items.length === 0" class="px-2 py-1.5 text-base text-fg-3">
75
+ loading...
76
+ </div>
77
+ </div>
78
+ </Teleport>
79
+ </template>
@@ -0,0 +1,109 @@
1
+ <script setup>
2
+ import { ref, onUnmounted } from "vue"
3
+ import ScrollBox from "./ScrollBox.vue"
4
+
5
+ const props = defineProps({
6
+ open: { type: Boolean, default: false },
7
+ closeOnBackdrop: { type: Boolean, default: true },
8
+ closeOnEscape: { type: Boolean, default: true }
9
+ })
10
+
11
+ const emit = defineEmits(["update:open"])
12
+
13
+ const panelRef = ref(null)
14
+ let previouslyFocused = null
15
+
16
+ const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
17
+
18
+ function close() {
19
+ emit("update:open", false)
20
+ }
21
+
22
+ function handleBackdropClick() {
23
+ if (props.closeOnBackdrop) close()
24
+ }
25
+
26
+ function handleKeydown(e) {
27
+ if (e.key === "Escape" && props.closeOnEscape) {
28
+ close()
29
+ return
30
+ }
31
+ const panel = panelRef.value
32
+ if (e.key === "Tab" && panel) {
33
+ const focusable = Array.from(panel.querySelectorAll(FOCUSABLE))
34
+ if (!focusable.length) return
35
+ const first = focusable[0]
36
+ const last = focusable[focusable.length - 1]
37
+ if (e.shiftKey && document.activeElement === first) {
38
+ e.preventDefault()
39
+ last.focus()
40
+ } else if (!e.shiftKey && document.activeElement === last) {
41
+ e.preventDefault()
42
+ first.focus()
43
+ }
44
+ }
45
+ }
46
+
47
+ function activate() {
48
+ previouslyFocused = document.activeElement
49
+ document.body.style.overflow = "hidden"
50
+ document.addEventListener("keydown", handleKeydown)
51
+
52
+ const panel = panelRef.value
53
+ if (!panel) return
54
+ const target = panel.querySelector("[autofocus]")
55
+ || panel.querySelector(FOCUSABLE)
56
+ if (target) target.focus({ preventScroll: true })
57
+ panel.scrollTop = 0
58
+ }
59
+
60
+ function deactivate() {
61
+ document.removeEventListener("keydown", handleKeydown)
62
+ }
63
+
64
+ function restoreFocus() {
65
+ document.body.style.overflow = ""
66
+ if (previouslyFocused?.focus) previouslyFocused.focus()
67
+ previouslyFocused = null
68
+ }
69
+
70
+ onUnmounted(() => {
71
+ document.body.style.overflow = ""
72
+ document.removeEventListener("keydown", handleKeydown)
73
+ })
74
+ </script>
75
+
76
+ <template>
77
+ <Teleport to="body">
78
+ <Transition
79
+ name="modal"
80
+ @after-enter="activate"
81
+ @before-leave="deactivate"
82
+ @after-leave="restoreFocus"
83
+ >
84
+ <div
85
+ v-if="open"
86
+ class="fixed inset-0 z-[1000] flex items-center justify-center"
87
+ >
88
+ <div
89
+ class="modal-backdrop absolute inset-0 bg-black/40"
90
+ @click="handleBackdropClick"
91
+ />
92
+ <div
93
+ ref="panelRef"
94
+ class="modal-panel relative bg-1 border rounded-sm max-w-[90vw] max-h-[85vh] flex flex-col"
95
+ >
96
+ <div v-if="$slots.header" class="shrink-0 border-b border-line-subtle">
97
+ <slot name="header" />
98
+ </div>
99
+ <ScrollBox class="flex-1 min-h-0">
100
+ <slot />
101
+ </ScrollBox>
102
+ <div v-if="$slots.footer" class="shrink-0 border-t border-line-subtle">
103
+ <slot name="footer" />
104
+ </div>
105
+ </div>
106
+ </div>
107
+ </Transition>
108
+ </Teleport>
109
+ </template>
@@ -0,0 +1,209 @@
1
+ <script setup>
2
+ import { ref, computed, watch, nextTick } from "vue"
3
+ import { useFloating, flip, shift, offset, size } from "@floating-ui/vue"
4
+ import { Icon } from "@iconify/vue"
5
+ import { useClickOutside } from "../composables/useClickOutside.js"
6
+ import ScrollBox from "./ScrollBox.vue"
7
+
8
+ const props = defineProps({
9
+ modelValue: { type: Array, default: () => [] },
10
+ options: { type: Array, required: true },
11
+ placeholder: { type: String, default: "select..." },
12
+ filterPlaceholder: { type: String, default: "search..." }
13
+ })
14
+
15
+ const emit = defineEmits(["update:modelValue"])
16
+
17
+ const open = ref(false)
18
+ const query = ref("")
19
+ const activeIndex = ref(-1)
20
+ const trigger = ref(null)
21
+ const inputRef = ref(null)
22
+ const menu = ref(null)
23
+ const maxWidth = ref(null)
24
+ const maxHeight = ref(null)
25
+
26
+ const { floatingStyles } = useFloating(trigger, menu, {
27
+ placement: "bottom-start",
28
+ strategy: "fixed",
29
+ middleware: [
30
+ offset(4),
31
+ flip(),
32
+ shift({ crossAxis: true, padding: 0 }),
33
+ size({
34
+ padding: 8,
35
+ apply({ availableWidth, availableHeight }) {
36
+ maxWidth.value = availableWidth
37
+ maxHeight.value = Math.min(availableHeight, 200)
38
+ }
39
+ })
40
+ ],
41
+ open
42
+ })
43
+
44
+ const normalizedOptions = computed(() => {
45
+ return props.options.map(opt => {
46
+ if (typeof opt === "object" && opt !== null) {
47
+ return { value: opt.value, label: opt.label ?? String(opt.value) }
48
+ }
49
+ return { value: opt, label: String(opt) }
50
+ })
51
+ })
52
+
53
+ const filtered = computed(() => {
54
+ if (!query.value) return normalizedOptions.value
55
+ const q = query.value.toLowerCase()
56
+ return normalizedOptions.value.filter(o => o.label.toLowerCase().includes(q))
57
+ })
58
+
59
+ const selectedSet = computed(() => new Set(props.modelValue))
60
+
61
+ const selectedLabels = computed(() => {
62
+ return props.modelValue.map(v => {
63
+ const found = normalizedOptions.value.find(o => o.value === v)
64
+ return found?.label ?? String(v)
65
+ })
66
+ })
67
+
68
+ watch(open, (isOpen) => {
69
+ if (isOpen) {
70
+ query.value = ""
71
+ activeIndex.value = -1
72
+ nextTick(() => inputRef.value?.focus())
73
+ }
74
+ })
75
+
76
+ function close() {
77
+ if (!open.value) return
78
+ open.value = false
79
+ }
80
+
81
+ function toggle(value) {
82
+ const next = selectedSet.value.has(value)
83
+ ? props.modelValue.filter(v => v !== value)
84
+ : [...props.modelValue, value]
85
+ emit("update:modelValue", next)
86
+ }
87
+
88
+ function remove(value) {
89
+ emit("update:modelValue", props.modelValue.filter(v => v !== value))
90
+ }
91
+
92
+ function clearAll() {
93
+ emit("update:modelValue", [])
94
+ }
95
+
96
+ function handleKeydown(e) {
97
+ if (e.key === "ArrowDown") {
98
+ e.preventDefault()
99
+ activeIndex.value = Math.min(activeIndex.value + 1, filtered.value.length - 1)
100
+ scrollToActive()
101
+ } else if (e.key === "ArrowUp") {
102
+ e.preventDefault()
103
+ activeIndex.value = Math.max(activeIndex.value - 1, 0)
104
+ scrollToActive()
105
+ } else if (e.key === "Enter" && activeIndex.value >= 0) {
106
+ e.preventDefault()
107
+ toggle(filtered.value[activeIndex.value].value)
108
+ } else if (e.key === "Backspace" && !query.value && props.modelValue.length) {
109
+ remove(props.modelValue[props.modelValue.length - 1])
110
+ }
111
+ }
112
+
113
+ function scrollToActive() {
114
+ nextTick(() => {
115
+ const el = menu.value?.querySelector("[data-active]")
116
+ if (el) el.scrollIntoView({ block: "nearest" })
117
+ })
118
+ }
119
+
120
+ watch(query, () => {
121
+ activeIndex.value = -1
122
+ })
123
+
124
+ useClickOutside([trigger, menu], close)
125
+ </script>
126
+
127
+ <template>
128
+ <div ref="trigger" class="relative inline-flex">
129
+ <div
130
+ class="flex items-center gap-1 p-1 bg-0 border rounded-sm font-mono text-base cursor-pointer min-w-0 w-full"
131
+ :class="open ? 'border-accent' : 'border-line'"
132
+ @click="open = true"
133
+ >
134
+ <div class="flex-1 flex items-center flex-wrap gap-1 min-w-0 mr-2">
135
+ <span
136
+ v-for="(label, i) in selectedLabels"
137
+ :key="modelValue[i]"
138
+ class="inline-flex items-center gap-0.5 px-1 py-px bg-3 text-fg-0 text-xs rounded-sm whitespace-nowrap"
139
+ >
140
+ {{ label }}
141
+ <Icon
142
+ icon="material-symbols:close"
143
+ class="text-xs text-fg-2 hover:text-fg-0 cursor-pointer"
144
+ @click.stop="remove(modelValue[i])"
145
+ />
146
+ </span>
147
+ <span v-if="!modelValue.length" class="inline-flex items-center px-1 py-px text-fg-3 text-xs">{{ placeholder }}</span>
148
+ </div>
149
+ <Icon
150
+ v-if="modelValue.length"
151
+ icon="material-symbols:close"
152
+ class="text-lg text-fg-2 hover:text-fg-0 shrink-0 cursor-pointer"
153
+ @click.stop="clearAll"
154
+ />
155
+ <Icon
156
+ v-else
157
+ icon="material-symbols:expand-more"
158
+ class="text-xl text-fg-2 shrink-0 transition-transform duration-150"
159
+ :class="open ? 'rotate-180' : ''"
160
+ />
161
+ </div>
162
+ <Teleport to="body">
163
+ <div
164
+ v-if="open"
165
+ ref="menu"
166
+ class="bg-1 border border-line rounded-sm z-[1000] overflow-hidden flex flex-col w-max"
167
+ :style="{ ...floatingStyles, maxWidth: maxWidth + 'px', maxHeight: maxHeight + 'px' }"
168
+ >
169
+ <div class="px-1.5 py-1.5 border-b border-line-subtle">
170
+ <input
171
+ ref="inputRef"
172
+ v-model="query"
173
+ type="text"
174
+ class="w-full px-1.5 py-0.5 text-base bg-0 border border-line rounded-sm font-mono text-fg-0 outline-none focus:border-accent"
175
+ :placeholder="filterPlaceholder"
176
+ @keydown="handleKeydown"
177
+ />
178
+ </div>
179
+ <ScrollBox class="flex-1">
180
+ <button
181
+ v-for="(opt, i) in filtered"
182
+ :key="opt.value"
183
+ type="button"
184
+ class="flex items-center gap-1.5 w-full px-2 py-1 bg-transparent border-none font-mono text-base text-left cursor-pointer whitespace-nowrap hover:bg-3"
185
+ :class="[
186
+ i === activeIndex ? 'bg-3 text-fg-0' : 'text-fg-1 hover:text-fg-0'
187
+ ]"
188
+ :data-active="i === activeIndex ? '' : undefined"
189
+ @click="toggle(opt.value)"
190
+ @mouseenter="activeIndex = i"
191
+ >
192
+ <span
193
+ class="w-[14px] h-[14px] rounded-sm border flex items-center justify-center shrink-0"
194
+ :class="selectedSet.has(opt.value) ? 'bg-accent border-accent' : 'bg-0 border-line'"
195
+ >
196
+ <svg v-if="selectedSet.has(opt.value)" width="10" height="10" viewBox="0 0 10 10" fill="none">
197
+ <path d="M2 5L4 7L8 3" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
198
+ </svg>
199
+ </span>
200
+ {{ opt.label }}
201
+ </button>
202
+ <div v-if="filtered.length === 0" class="px-2.5 py-2 text-base text-fg-3">
203
+ no matches
204
+ </div>
205
+ </ScrollBox>
206
+ </div>
207
+ </Teleport>
208
+ </div>
209
+ </template>
@@ -0,0 +1,98 @@
1
+ <script setup>
2
+ import { reactive, computed } from "vue"
3
+ import { Icon } from "@iconify/vue"
4
+
5
+ const props = defineProps({
6
+ items: { type: Array, required: true },
7
+ modelValue: { type: [String, Number, null], default: null },
8
+ defaultExpanded: { type: [Array, Boolean], default: () => [] }
9
+ })
10
+
11
+ const emit = defineEmits(["update:modelValue", "select"])
12
+
13
+ const expandedKeys = reactive(new Set(
14
+ props.defaultExpanded === true ? collectGroupKeys(props.items) : props.defaultExpanded
15
+ ))
16
+
17
+ function collectGroupKeys(nodes) {
18
+ const keys = []
19
+ for (const n of nodes) {
20
+ if (n.children?.length) {
21
+ keys.push(n.key)
22
+ keys.push(...collectGroupKeys(n.children))
23
+ }
24
+ }
25
+ return keys
26
+ }
27
+
28
+ const flatVisible = computed(() => {
29
+ const result = []
30
+
31
+ function walk(nodes, depth) {
32
+ for (const node of nodes) {
33
+ const isGroup = node.children?.length > 0
34
+ const expanded = isGroup && expandedKeys.has(node.key)
35
+ result.push({ node, depth, isGroup, expanded })
36
+ if (expanded) walk(node.children, depth + 1)
37
+ }
38
+ }
39
+
40
+ walk(props.items, 0)
41
+ return result
42
+ })
43
+
44
+ function toggle(key) {
45
+ if (expandedKeys.has(key)) expandedKeys.delete(key)
46
+ else expandedKeys.add(key)
47
+ }
48
+
49
+ function handleClick(node, isGroup) {
50
+ if (isGroup) {
51
+ toggle(node.key)
52
+ return
53
+ }
54
+ emit("update:modelValue", node.key)
55
+ emit("select", node.key)
56
+ }
57
+ </script>
58
+
59
+ <template>
60
+ <div>
61
+ <div
62
+ v-for="{ node, depth, isGroup, expanded } in flatVisible"
63
+ :key="node.key"
64
+ class="flex items-center gap-1 py-1 pr-2 cursor-pointer font-mono text-base select-none"
65
+ :class="[
66
+ isGroup
67
+ ? 'text-fg-2 hover:text-fg-1'
68
+ : node.key === modelValue
69
+ ? 'text-fg-0 bg-3'
70
+ : 'text-fg-1 hover:bg-2 hover:text-fg-0'
71
+ ]"
72
+ :style="{ paddingLeft: (depth * 20 + 8) + 'px' }"
73
+ @click="handleClick(node, isGroup)"
74
+ >
75
+ <span v-if="isGroup" class="w-[16px] shrink-0 flex items-center justify-center">
76
+ <Icon
77
+ icon="material-symbols:chevron-right"
78
+ class="text-sm transition-transform duration-150"
79
+ :class="expanded ? 'rotate-90' : ''"
80
+ />
81
+ </span>
82
+ <Icon
83
+ v-if="node.icon"
84
+ :icon="node.icon"
85
+ class="text-base shrink-0"
86
+ :class="node.key === modelValue ? 'text-accent' : 'text-fg-2'"
87
+ />
88
+ <span class="truncate flex-1">{{ node.label }}</span>
89
+ <slot :name="`trailing-${node.key}`" :node="node" />
90
+ <span v-if="node.badge != null" class="text-xs text-fg-3 bg-4 px-1 py-px rounded-sm shrink-0">
91
+ {{ node.badge }}
92
+ </span>
93
+ </div>
94
+ <div v-if="flatVisible.length === 0" class="px-3 py-2 text-base text-fg-3">
95
+ no items
96
+ </div>
97
+ </div>
98
+ </template>
@@ -0,0 +1,128 @@
1
+ <script setup>
2
+ import { ref, computed, watch } from "vue"
3
+ import { Icon } from "@iconify/vue"
4
+ import { usePointerDrag } from "../composables/usePointerDrag.js"
5
+
6
+ const props = defineProps({
7
+ modelValue: { type: Number, default: 0 },
8
+ min: { type: Number, default: -Infinity },
9
+ max: { type: Number, default: Infinity },
10
+ step: { type: Number, default: 1 },
11
+ precision: { type: Number, default: null },
12
+ inline: { type: Boolean, default: false },
13
+ sensitivity: { type: Number, default: 0.15 }
14
+ })
15
+
16
+ const emit = defineEmits(["update:modelValue"])
17
+
18
+ const displayValue = ref(String(props.modelValue))
19
+ const dragging = ref(false)
20
+
21
+ watch(() => props.modelValue, v => {
22
+ if (!dragging.value) {
23
+ displayValue.value = formatValue(v)
24
+ }
25
+ })
26
+
27
+ const effectivePrecision = computed(() => {
28
+ if (props.precision != null) return props.precision
29
+ const s = String(props.step)
30
+ const dot = s.indexOf(".")
31
+ return dot === -1 ? 0 : s.length - dot - 1
32
+ })
33
+
34
+ function formatValue(v) {
35
+ const clamped = clamp(v)
36
+ return effectivePrecision.value > 0
37
+ ? clamped.toFixed(effectivePrecision.value)
38
+ : String(Math.round(clamped))
39
+ }
40
+
41
+ function clamp(v) {
42
+ return Math.min(props.max, Math.max(props.min, v))
43
+ }
44
+
45
+ function nudge(delta) {
46
+ const current = parseFloat(displayValue.value)
47
+ const base = isNaN(current) ? props.modelValue : current
48
+ const next = clamp(base + delta)
49
+ emit("update:modelValue", Number(formatValue(next)))
50
+ displayValue.value = formatValue(next)
51
+ }
52
+
53
+ function getStep(e) {
54
+ if (e.shiftKey) return props.step * 10
55
+ if (e.metaKey || e.ctrlKey) return props.step * 0.1
56
+ return props.step
57
+ }
58
+
59
+ function handleKeydown(e) {
60
+ if (e.key === "ArrowUp") {
61
+ e.preventDefault()
62
+ nudge(getStep(e))
63
+ } else if (e.key === "ArrowDown") {
64
+ e.preventDefault()
65
+ nudge(-getStep(e))
66
+ } else if (e.key === "Enter") {
67
+ handleBlur()
68
+ }
69
+ }
70
+
71
+ function handleBlur() {
72
+ const parsed = parseFloat(displayValue.value)
73
+ if (isNaN(parsed)) {
74
+ displayValue.value = formatValue(props.modelValue)
75
+ return
76
+ }
77
+ const clamped = clamp(parsed)
78
+ const formatted = formatValue(clamped)
79
+ displayValue.value = formatted
80
+ emit("update:modelValue", Number(formatted))
81
+ }
82
+
83
+ function handleInput(e) {
84
+ displayValue.value = e.target.value
85
+ }
86
+
87
+ const dragAccum = ref(0)
88
+
89
+ const { start: startDrag } = usePointerDrag({
90
+ onStart() {
91
+ dragging.value = true
92
+ const current = parseFloat(displayValue.value)
93
+ dragAccum.value = isNaN(current) ? props.modelValue : current
94
+ },
95
+ onDrag(dx) {
96
+ dragAccum.value = clamp(dragAccum.value + dx * props.step * props.sensitivity)
97
+ const rounded = Number(formatValue(dragAccum.value))
98
+ if (rounded !== Number(displayValue.value)) {
99
+ displayValue.value = formatValue(dragAccum.value)
100
+ emit("update:modelValue", rounded)
101
+ }
102
+ },
103
+ onEnd() {
104
+ dragging.value = false
105
+ }
106
+ })
107
+ </script>
108
+
109
+ <template>
110
+ <div :class="inline ? 'flex items-center relative w-full' : 'inline-flex items-center relative'">
111
+ <input
112
+ :value="displayValue"
113
+ class="font-mono text-fg-0 focus:outline-none w-full"
114
+ :class="inline
115
+ ? 'bg-transparent border-none p-0 m-0 pr-5 text-base'
116
+ : 'px-2 py-1 pr-7 text-base bg-0 border border-line rounded-sm focus:border-accent'"
117
+ @input="handleInput"
118
+ @blur="handleBlur"
119
+ @keydown="handleKeydown"
120
+ />
121
+ <div
122
+ class="absolute right-0 top-0 bottom-0 flex items-center justify-center w-6 cursor-ew-resize text-fg-3 hover:text-accent"
123
+ @mousedown.prevent="startDrag"
124
+ >
125
+ <Icon icon="material-symbols:swap-horiz" class="text-base" />
126
+ </div>
127
+ </div>
128
+ </template>