@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,326 @@
1
+ <script setup>
2
+ import { ref, computed, watch, nextTick, onMounted, onUnmounted } from "vue"
3
+ import { Icon } from "@iconify/vue"
4
+ import Checkbox from "./Checkbox.vue"
5
+ import Input from "./Input.vue"
6
+ import NumberInput from "./NumberInput.vue"
7
+ import Select from "./Select.vue"
8
+ import ScrollBox from "./ScrollBox.vue"
9
+
10
+ const props = defineProps({
11
+ columns: { type: Array, required: true },
12
+ rows: { type: Array, required: true },
13
+ rowKey: { type: String, default: "id" },
14
+ pageSize: { type: Number, default: 10 },
15
+ total: { type: Number, default: undefined },
16
+ filterable: { type: Boolean, default: false },
17
+ filterPlaceholder: { type: String, default: "search..." },
18
+ loading: { type: Boolean, default: false },
19
+ striped: { type: Boolean, default: false }
20
+ })
21
+
22
+ const emit = defineEmits(["update:row", "sort", "page", "filter"])
23
+
24
+ const filterQuery = ref("")
25
+ const sortKey = ref(null)
26
+ const sortDir = ref(null)
27
+ const currentPage = ref(1)
28
+ const editingCell = ref(null)
29
+ const editValue = ref(null)
30
+
31
+ const filteredRows = computed(() => {
32
+ if (!filterQuery.value) return props.rows
33
+ const q = filterQuery.value.toLowerCase()
34
+ return props.rows.filter(row =>
35
+ props.columns.some(col =>
36
+ String(row[col.key] ?? "").toLowerCase().includes(q)
37
+ )
38
+ )
39
+ })
40
+
41
+ const sortedRows = computed(() => {
42
+ if (!sortKey.value || !sortDir.value) return filteredRows.value
43
+ const key = sortKey.value
44
+ const dir = sortDir.value === "desc" ? -1 : 1
45
+ return [...filteredRows.value].sort((a, b) => {
46
+ const va = a[key] ?? ""
47
+ const vb = b[key] ?? ""
48
+ if (va < vb) return -1 * dir
49
+ if (va > vb) return 1 * dir
50
+ return 0
51
+ })
52
+ })
53
+
54
+ const totalCount = computed(() => props.total ?? sortedRows.value.length)
55
+ const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / props.pageSize)))
56
+
57
+ const displayedRows = computed(() => {
58
+ if (props.total != null) return props.rows
59
+ const start = (currentPage.value - 1) * props.pageSize
60
+ return sortedRows.value.slice(start, start + props.pageSize)
61
+ })
62
+
63
+ const rangeStart = computed(() => Math.min((currentPage.value - 1) * props.pageSize + 1, totalCount.value))
64
+ const rangeEnd = computed(() => Math.min(currentPage.value * props.pageSize, totalCount.value))
65
+
66
+ watch(filterQuery, q => {
67
+ currentPage.value = 1
68
+ editingCell.value = null
69
+ emit("filter", q)
70
+ })
71
+
72
+ watch([sortKey, sortDir], () => {
73
+ editingCell.value = null
74
+ })
75
+
76
+ function toggleSort(colKey) {
77
+ if (sortKey.value === colKey) {
78
+ if (sortDir.value === "asc") sortDir.value = "desc"
79
+ else { sortKey.value = null; sortDir.value = null }
80
+ } else {
81
+ sortKey.value = colKey
82
+ sortDir.value = "asc"
83
+ }
84
+ currentPage.value = 1
85
+ emit("sort", { key: sortKey.value, direction: sortDir.value })
86
+ }
87
+
88
+ function goToPage(p) {
89
+ currentPage.value = Math.max(1, Math.min(p, totalPages.value))
90
+ emit("page", currentPage.value)
91
+ }
92
+
93
+ function handleCellClick(row, col) {
94
+ if (!col.editable) return
95
+ if (col.type === "boolean") {
96
+ emit("update:row", { key: row[props.rowKey], column: col.key, value: !row[col.key] })
97
+ return
98
+ }
99
+ startEdit(row[props.rowKey], col.key, row[col.key])
100
+ }
101
+
102
+ function startEdit(rk, colKey, currentVal) {
103
+ editingCell.value = { rowKey: rk, colKey }
104
+ editValue.value = currentVal
105
+ nextTick(() => {
106
+ const input = document.querySelector("[data-edit-input]")
107
+ if (input) { input.focus(); input.select?.() }
108
+ })
109
+ }
110
+
111
+ function commitEdit(rk, colKey) {
112
+ emit("update:row", { key: rk, column: colKey, value: editValue.value })
113
+ editingCell.value = null
114
+ }
115
+
116
+ function handleNumberUpdate(rk, colKey, value) {
117
+ if (!editingCell.value) return
118
+ editValue.value = value
119
+ emit("update:row", { key: rk, column: colKey, value })
120
+ }
121
+
122
+ function handleDocClick(e) {
123
+ if (!editingCell.value) return
124
+ const numEdit = document.querySelector("[data-number-edit]")
125
+ if (!numEdit) return
126
+ if (numEdit.contains(e.target)) return
127
+ editingCell.value = null
128
+ }
129
+
130
+ onMounted(() => document.addEventListener("mousedown", handleDocClick, true))
131
+ onUnmounted(() => document.removeEventListener("mousedown", handleDocClick, true))
132
+
133
+ function cancelEdit() {
134
+ editingCell.value = null
135
+ }
136
+
137
+ function handleEnumSelect(rk, colKey, value) {
138
+ editValue.value = value
139
+ emit("update:row", { key: rk, column: colKey, value })
140
+ editingCell.value = null
141
+ }
142
+
143
+ function isEditing(rk, colKey) {
144
+ return editingCell.value?.rowKey === rk && editingCell.value?.colKey === colKey
145
+ }
146
+
147
+ function skeletonWidth(row, col) {
148
+ return 35 + ((row * 7 + col * 13) % 45) + "%"
149
+ }
150
+ </script>
151
+
152
+ <template>
153
+ <div class="flex flex-col border border-line rounded-sm overflow-hidden">
154
+ <div v-if="filterable" class="px-1.5 py-1.5 border-b border-line-subtle">
155
+ <Input
156
+ :model-value="filterQuery"
157
+ @update:model-value="v => filterQuery = v"
158
+ :placeholder="filterPlaceholder"
159
+ />
160
+ </div>
161
+
162
+ <ScrollBox horizontal class="flex-1">
163
+ <table class="w-full border-collapse font-mono text-base table-fixed">
164
+ <thead>
165
+ <tr>
166
+ <th
167
+ v-for="col in columns"
168
+ :key="col.key"
169
+ class="px-2 py-1 text-left text-fg-2 font-normal border-b border-line-subtle bg-1 whitespace-nowrap sticky top-0 z-10"
170
+ :class="col.sortable ? 'cursor-pointer hover:text-fg-0 select-none' : ''"
171
+ :style="col.width ? { width: col.width } : {}"
172
+ @click="col.sortable && toggleSort(col.key)"
173
+ >
174
+ <span class="inline-flex items-center gap-0.5">
175
+ <slot :name="`header-${col.key}`" :column="col">
176
+ {{ col.label }}
177
+ </slot>
178
+ <Icon
179
+ v-if="col.sortable && sortKey === col.key"
180
+ :icon="sortDir === 'asc' ? 'material-symbols:arrow-upward' : 'material-symbols:arrow-downward'"
181
+ class="text-xs text-accent"
182
+ />
183
+ </span>
184
+ </th>
185
+ </tr>
186
+ </thead>
187
+ <tbody>
188
+ <template v-if="loading">
189
+ <tr v-for="r in pageSize" :key="`sk-${r}`">
190
+ <td
191
+ v-for="(col, c) in columns"
192
+ :key="col.key"
193
+ class="px-2 py-1 border-b border-line-subtle"
194
+ >
195
+ <div class="skeleton-bar" :style="{ width: skeletonWidth(r, c) }" />
196
+ </td>
197
+ </tr>
198
+ </template>
199
+
200
+ <template v-else-if="displayedRows.length">
201
+ <tr
202
+ v-for="(row, rowIdx) in displayedRows"
203
+ :key="row[rowKey]"
204
+ :class="striped && rowIdx % 2 ? 'bg-1' : ''"
205
+ >
206
+ <td
207
+ v-for="col in columns"
208
+ :key="col.key"
209
+ class="px-2 py-1 border-b border-line-subtle"
210
+ :class="col.editable ? 'hover:bg-3 cursor-pointer' : ''"
211
+ @click="handleCellClick(row, col)"
212
+ >
213
+ <slot :name="`cell-${col.key}`" :value="row[col.key]" :row="row">
214
+ <template v-if="col.type === 'boolean'">
215
+ <div class="flex items-center min-h-[1.5em]">
216
+ <Checkbox
217
+ v-if="col.editable"
218
+ :model-value="!!row[col.key]"
219
+ />
220
+ <Icon
221
+ v-else-if="row[col.key]"
222
+ icon="material-symbols:check"
223
+ class="text-base text-fg-2"
224
+ />
225
+ </div>
226
+ </template>
227
+
228
+ <template v-else-if="isEditing(row[rowKey], col.key) && col.type === 'enum'">
229
+ <Select
230
+ inline
231
+ default-open
232
+ :model-value="editValue"
233
+ :options="col.options"
234
+ @update:model-value="v => handleEnumSelect(row[rowKey], col.key, v)"
235
+ @close="cancelEdit"
236
+ />
237
+ </template>
238
+
239
+ <template v-else-if="isEditing(row[rowKey], col.key) && col.type === 'number'">
240
+ <div data-number-edit>
241
+ <NumberInput
242
+ inline
243
+ :model-value="editValue"
244
+ :step="col.step ?? 1"
245
+ :min="col.min ?? -Infinity"
246
+ :max="col.max ?? Infinity"
247
+ :precision="col.precision ?? null"
248
+ @update:model-value="v => handleNumberUpdate(row[rowKey], col.key, v)"
249
+ @keydown.enter="editingCell = null"
250
+ @keydown.escape="cancelEdit"
251
+ />
252
+ </div>
253
+ </template>
254
+
255
+ <template v-else-if="isEditing(row[rowKey], col.key)">
256
+ <Input
257
+ inline
258
+ data-edit-input
259
+ :model-value="editValue"
260
+ @update:model-value="v => editValue = v"
261
+ @blur="commitEdit(row[rowKey], col.key)"
262
+ @keydown.enter="commitEdit(row[rowKey], col.key)"
263
+ @keydown.escape="cancelEdit"
264
+ />
265
+ </template>
266
+
267
+ <span
268
+ v-else
269
+ class="block truncate"
270
+ :class="col.editable ? 'underline decoration-dashed underline-offset-2 decoration-fg-3' : ''"
271
+ >
272
+ {{ row[col.key] ?? "" }}
273
+ </span>
274
+ </slot>
275
+ </td>
276
+ </tr>
277
+ </template>
278
+
279
+ <tr v-else>
280
+ <td :colspan="columns.length" class="px-2 py-4 text-center text-fg-3">
281
+ {{ filterQuery ? "no results" : "no data" }}
282
+ </td>
283
+ </tr>
284
+ </tbody>
285
+ </table>
286
+ </ScrollBox>
287
+
288
+ <div
289
+ v-if="totalCount > 0"
290
+ class="flex items-center justify-between px-2 py-1 border-t border-line-subtle text-xs text-fg-2"
291
+ >
292
+ <span>{{ rangeStart }}-{{ rangeEnd }} of {{ totalCount }}</span>
293
+ <div v-if="totalPages > 1" class="flex items-center gap-0.5">
294
+ <button
295
+ class="p-0.5 bg-transparent border border-transparent rounded-sm cursor-pointer text-fg-2 hover:bg-2 hover:text-fg-0 disabled:opacity-30 disabled:cursor-default"
296
+ :disabled="currentPage <= 1"
297
+ @click="goToPage(currentPage - 1)"
298
+ >
299
+ <Icon icon="material-symbols:chevron-left" class="text-base" />
300
+ </button>
301
+ <span class="px-1">{{ currentPage }} / {{ totalPages }}</span>
302
+ <button
303
+ class="p-0.5 bg-transparent border border-transparent rounded-sm cursor-pointer text-fg-2 hover:bg-2 hover:text-fg-0 disabled:opacity-30 disabled:cursor-default"
304
+ :disabled="currentPage >= totalPages"
305
+ @click="goToPage(currentPage + 1)"
306
+ >
307
+ <Icon icon="material-symbols:chevron-right" class="text-base" />
308
+ </button>
309
+ </div>
310
+ </div>
311
+ </div>
312
+ </template>
313
+
314
+ <style scoped>
315
+ .skeleton-bar {
316
+ height: 12px;
317
+ background: var(--bg-3);
318
+ border-radius: 2px;
319
+ animation: skeleton-pulse 1.5s ease-in-out infinite;
320
+ }
321
+
322
+ @keyframes skeleton-pulse {
323
+ 0%, 100% { opacity: 0.4; }
324
+ 50% { opacity: 1; }
325
+ }
326
+ </style>
@@ -0,0 +1,127 @@
1
+ <script setup>
2
+ import { ref, computed } 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
+
7
+ const props = defineProps({
8
+ modelValue: { type: [String, Number, null], default: null },
9
+ options: { type: Array, required: true },
10
+ icon: { type: String, default: null },
11
+ label: { type: String, default: null },
12
+ header: { type: String, default: null },
13
+ placement: { type: String, default: "bottom-start" },
14
+ inline: { type: Boolean, default: false },
15
+ defaultOpen: { type: Boolean, default: false }
16
+ })
17
+
18
+ const emit = defineEmits(["update:modelValue", "select", "close"])
19
+
20
+ const open = ref(props.defaultOpen)
21
+ const trigger = 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: props.placement,
28
+ strategy: "fixed",
29
+ middleware: [
30
+ offset(4),
31
+ flip(),
32
+ shift({ padding: 0 }),
33
+ size({
34
+ padding: 8,
35
+ apply({ availableWidth, availableHeight }) {
36
+ maxWidth.value = availableWidth
37
+ maxHeight.value = Math.min(availableHeight, 300)
38
+ }
39
+ })
40
+ ],
41
+ open
42
+ })
43
+
44
+ const displayLabel = computed(() => {
45
+ if (props.label) return props.label
46
+ const found = props.options.find(o => o.value === props.modelValue)
47
+ return found?.label ?? "select..."
48
+ })
49
+
50
+ function close() {
51
+ if (!open.value) return
52
+ open.value = false
53
+ emit("close")
54
+ }
55
+
56
+ function toggle() {
57
+ if (open.value) close()
58
+ else open.value = true
59
+ }
60
+
61
+ function select(option) {
62
+ emit("update:modelValue", option.value)
63
+ emit("select", option)
64
+ close()
65
+ }
66
+
67
+ useClickOutside([trigger, menu], close)
68
+ </script>
69
+
70
+ <template>
71
+ <div class="inline-flex">
72
+ <button
73
+ ref="trigger"
74
+ type="button"
75
+ class="flex items-center gap-1.5 font-mono text-base text-fg-0 cursor-pointer"
76
+ :class="inline
77
+ ? 'bg-transparent border-none p-0 m-0'
78
+ : ['px-2 py-1 bg-transparent border border-transparent rounded-sm hover:bg-2', open ? 'bg-3' : '']"
79
+ @click.stop="toggle"
80
+ >
81
+ <Icon v-if="icon" :icon="icon" class="text-xl text-fg-2" />
82
+ <span v-if="!inline || label" class="font-medium">{{ displayLabel }}</span>
83
+ <Icon
84
+ v-if="!inline || label"
85
+ icon="material-symbols:expand-more"
86
+ class="text-xl text-fg-3 transition-transform duration-150"
87
+ :class="open ? 'rotate-180' : ''"
88
+ />
89
+ </button>
90
+
91
+ <Teleport to="body">
92
+ <div
93
+ v-if="open"
94
+ ref="menu"
95
+ class="bg-1 border border-line rounded-sm z-[1000] overflow-y-auto min-w-[180px] py-1"
96
+ :style="{
97
+ ...floatingStyles,
98
+ maxWidth: maxWidth + 'px',
99
+ maxHeight: maxHeight + 'px'
100
+ }"
101
+ >
102
+ <div v-if="header" class="px-2 py-1 text-xs text-fg-3 uppercase tracking-wide">
103
+ {{ header }}
104
+ </div>
105
+ <button
106
+ v-for="option in options"
107
+ :key="option.value"
108
+ type="button"
109
+ class="flex items-center gap-2 w-full px-2 py-1 bg-transparent border-none font-mono text-base cursor-pointer text-left hover:bg-3 hover:text-fg-0"
110
+ :class="[
111
+ option.value === modelValue ? 'text-fg-0' : 'text-fg-1',
112
+ option.action ? 'border-t border-line-subtle mt-1 pt-1.5 text-fg-2' : ''
113
+ ]"
114
+ @click="option.action ? (option.action(), close()) : select(option)"
115
+ >
116
+ <Icon v-if="option.icon" :icon="option.icon" class="text-xl text-fg-3 shrink-0" />
117
+ <span class="flex-1 whitespace-nowrap overflow-hidden text-ellipsis">{{ option.label }}</span>
118
+ <span v-if="option.meta" class="text-xs text-fg-3 shrink-0">{{ option.meta }}</span>
119
+ <span class="w-[14px] shrink-0 text-accent text-xl flex items-center justify-end">
120
+ <Icon v-if="option.value === modelValue" icon="material-symbols:check" />
121
+ </span>
122
+ </button>
123
+ <slot />
124
+ </div>
125
+ </Teleport>
126
+ </div>
127
+ </template>
@@ -0,0 +1,29 @@
1
+ <script setup>
2
+ defineProps({
3
+ modelValue: { type: String, default: "" },
4
+ placeholder: { type: String, default: "" },
5
+ size: {
6
+ type: String,
7
+ default: "base",
8
+ validator: v => ["base", "lg"].includes(v)
9
+ }
10
+ })
11
+
12
+ defineEmits(["update:modelValue"])
13
+
14
+ const sizeClasses = {
15
+ lg: "text-lg font-medium text-fg-0",
16
+ base: "text-base text-fg-2 focus:text-fg-1"
17
+ }
18
+ </script>
19
+
20
+ <template>
21
+ <input
22
+ :value="modelValue"
23
+ :placeholder="placeholder"
24
+ spellcheck="false"
25
+ class="bg-transparent border-none p-0 font-mono focus:outline-none w-full placeholder:text-fg-3"
26
+ :class="sizeClasses[size]"
27
+ @input="$emit('update:modelValue', $event.target.value)"
28
+ />
29
+ </template>
@@ -0,0 +1,23 @@
1
+ <script setup>
2
+ defineProps({
3
+ modelValue: { type: [String, Number], default: "" },
4
+ placeholder: { type: String, default: "" },
5
+ type: { type: String, default: "text" },
6
+ inline: { type: Boolean, default: false }
7
+ })
8
+
9
+ defineEmits(["update:modelValue"])
10
+ </script>
11
+
12
+ <template>
13
+ <input
14
+ :type="type"
15
+ :value="modelValue"
16
+ :placeholder="placeholder"
17
+ class="font-mono text-fg-0 placeholder:text-fg-3 focus:outline-none w-full"
18
+ :class="inline
19
+ ? 'bg-transparent border-none p-0 m-0 text-base'
20
+ : 'px-2 py-1 text-base bg-0 border border-line rounded-sm focus:border-accent'"
21
+ @input="$emit('update:modelValue', $event.target.value)"
22
+ />
23
+ </template>
@@ -0,0 +1,149 @@
1
+ <script setup>
2
+ import { ref, nextTick, onMounted, onUnmounted } from "vue"
3
+ import { Icon } from "@iconify/vue"
4
+ import Checkbox from "./Checkbox.vue"
5
+ import Input from "./Input.vue"
6
+ import NumberInput from "./NumberInput.vue"
7
+ import Select from "./Select.vue"
8
+
9
+ const props = defineProps({
10
+ fields: { type: Array, required: true },
11
+ data: { type: Object, required: true },
12
+ labelWidth: { type: String, default: "120px" }
13
+ })
14
+
15
+ const emit = defineEmits(["update"])
16
+
17
+ const editingKey = ref(null)
18
+ const editValue = ref(null)
19
+
20
+ function handleRowClick(field) {
21
+ if (!field.editable) return
22
+ if (field.type === "boolean") {
23
+ emit("update", { key: field.key, value: !props.data[field.key] })
24
+ return
25
+ }
26
+ editingKey.value = field.key
27
+ editValue.value = props.data[field.key] ?? ""
28
+ nextTick(() => {
29
+ const input = document.querySelector("[data-kv-input]")
30
+ if (input) { input.focus(); input.select?.() }
31
+ })
32
+ }
33
+
34
+ function commit(key) {
35
+ emit("update", { key, value: editValue.value })
36
+ editingKey.value = null
37
+ }
38
+
39
+ function handleNumberUpdate(key, value) {
40
+ if (!editingKey.value) return
41
+ editValue.value = value
42
+ emit("update", { key, value })
43
+ }
44
+
45
+ function handleDocClick(e) {
46
+ if (!editingKey.value) return
47
+ const numEdit = document.querySelector("[data-number-edit]")
48
+ if (!numEdit) return
49
+ if (numEdit.contains(e.target)) return
50
+ editingKey.value = null
51
+ }
52
+
53
+ onMounted(() => document.addEventListener("mousedown", handleDocClick, true))
54
+ onUnmounted(() => document.removeEventListener("mousedown", handleDocClick, true))
55
+
56
+ function cancel() {
57
+ editingKey.value = null
58
+ }
59
+
60
+ function handleEnumSelect(key, value) {
61
+ editValue.value = value
62
+ emit("update", { key, value })
63
+ editingKey.value = null
64
+ }
65
+ </script>
66
+
67
+ <template>
68
+ <div class="flex flex-col">
69
+ <div
70
+ v-for="field in fields"
71
+ :key="field.key"
72
+ class="flex items-center border-b border-line-subtle"
73
+ :class="field.editable ? 'hover:bg-3 cursor-pointer' : ''"
74
+ @click="field.editable && handleRowClick(field)"
75
+ >
76
+ <span
77
+ class="shrink-0 px-2 py-1 text-base text-fg-2 truncate"
78
+ :style="{ width: labelWidth }"
79
+ >
80
+ {{ field.label }}
81
+ </span>
82
+ <div class="flex-1 px-2 py-1 min-w-0">
83
+ <slot :name="`value-${field.key}`" :value="data[field.key]" :field="field">
84
+ <template v-if="field.type === 'boolean'">
85
+ <div class="flex items-center min-h-[1.5em]">
86
+ <Checkbox
87
+ v-if="field.editable"
88
+ :model-value="!!data[field.key]"
89
+ />
90
+ <Icon
91
+ v-else-if="data[field.key]"
92
+ icon="material-symbols:check"
93
+ class="text-base text-fg-2"
94
+ />
95
+ <span v-else class="text-fg-3">-</span>
96
+ </div>
97
+ </template>
98
+
99
+ <template v-else-if="editingKey === field.key && field.type === 'enum'">
100
+ <Select
101
+ inline
102
+ default-open
103
+ :model-value="editValue"
104
+ :options="field.options"
105
+ @update:model-value="v => handleEnumSelect(field.key, v)"
106
+ @close="cancel"
107
+ />
108
+ </template>
109
+
110
+ <template v-else-if="editingKey === field.key && field.type === 'number'">
111
+ <div data-number-edit>
112
+ <NumberInput
113
+ inline
114
+ :model-value="editValue"
115
+ :step="field.step ?? 1"
116
+ :min="field.min ?? -Infinity"
117
+ :max="field.max ?? Infinity"
118
+ :precision="field.precision ?? null"
119
+ @update:model-value="v => handleNumberUpdate(field.key, v)"
120
+ @keydown.enter="editingKey = null"
121
+ @keydown.escape="cancel"
122
+ />
123
+ </div>
124
+ </template>
125
+
126
+ <template v-else-if="editingKey === field.key">
127
+ <Input
128
+ inline
129
+ data-kv-input
130
+ :model-value="editValue"
131
+ @update:model-value="v => editValue = v"
132
+ @blur="commit(field.key)"
133
+ @keydown.enter="commit(field.key)"
134
+ @keydown.escape="cancel"
135
+ />
136
+ </template>
137
+
138
+ <span
139
+ v-else
140
+ class="block text-base text-fg-0 truncate min-h-[1.5em]"
141
+ :class="field.editable ? 'underline decoration-dashed underline-offset-2 decoration-fg-3' : ''"
142
+ >
143
+ {{ data[field.key] ?? "" }}
144
+ </span>
145
+ </slot>
146
+ </div>
147
+ </div>
148
+ </div>
149
+ </template>