@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.
- package/.claude/settings.local.json +13 -0
- package/.lore +83 -0
- package/histoire.config.js +43 -0
- package/package.json +39 -0
- package/postcss.config.js +6 -0
- package/src/components/Badge.vue +36 -0
- package/src/components/Button.vue +44 -0
- package/src/components/Checkbox.vue +51 -0
- package/src/components/CheckboxCards.vue +61 -0
- package/src/components/CodeEditor.vue +299 -0
- package/src/components/Collapsible.vue +69 -0
- package/src/components/CollapsibleGroup.vue +38 -0
- package/src/components/Combobox.vue +179 -0
- package/src/components/ContextMenu.vue +65 -0
- package/src/components/ContextMenuPanel.vue +115 -0
- package/src/components/DataTable.vue +326 -0
- package/src/components/Dropdown.vue +127 -0
- package/src/components/GhostInput.vue +29 -0
- package/src/components/Input.vue +23 -0
- package/src/components/KeyValue.vue +149 -0
- package/src/components/LabeledTextarea.vue +64 -0
- package/src/components/LabeledTextareaGroup.vue +14 -0
- package/src/components/Mention.vue +79 -0
- package/src/components/Modal.vue +109 -0
- package/src/components/MultiCombobox.vue +209 -0
- package/src/components/NavTree.vue +98 -0
- package/src/components/NumberInput.vue +128 -0
- package/src/components/PopConfirm.vue +94 -0
- package/src/components/Popover.vue +53 -0
- package/src/components/RadioCards.vue +37 -0
- package/src/components/RadioGroup.vue +57 -0
- package/src/components/RangeSlider.vue +165 -0
- package/src/components/ScrollBox.vue +78 -0
- package/src/components/SectionHeader.vue +18 -0
- package/src/components/Select.vue +187 -0
- package/src/components/Switch.vue +85 -0
- package/src/components/Tabs.vue +34 -0
- package/src/components/TagInput.vue +80 -0
- package/src/components/Textarea.vue +97 -0
- package/src/components/ToastContainer.vue +104 -0
- package/src/components/ToggleButtons.vue +45 -0
- package/src/components/ToggleGroup.vue +30 -0
- package/src/components/Tooltip.vue +56 -0
- package/src/components/Tree.vue +188 -0
- package/src/composables/toast.js +54 -0
- package/src/composables/useClickOutside.js +23 -0
- package/src/composables/useMention.js +291 -0
- package/src/composables/usePointerDrag.js +39 -0
- package/src/histoire-setup.js +1 -0
- package/src/index.js +43 -0
- package/src/style.css +96 -0
- package/stories/Badge.story.vue +24 -0
- package/stories/Button.story.vue +45 -0
- package/stories/Checkbox.story.vue +31 -0
- package/stories/CheckboxCards.story.vue +51 -0
- package/stories/CodeEditor.story.vue +71 -0
- package/stories/Collapsible.story.vue +84 -0
- package/stories/Combobox.story.vue +44 -0
- package/stories/ContextMenu.story.vue +59 -0
- package/stories/DataTable.story.vue +185 -0
- package/stories/Dropdown.story.vue +49 -0
- package/stories/GhostInput.story.vue +24 -0
- package/stories/Input.story.vue +23 -0
- package/stories/KeyValue.story.vue +104 -0
- package/stories/LabeledTextarea.story.vue +44 -0
- package/stories/Mention.story.vue +166 -0
- package/stories/Modal.story.vue +86 -0
- package/stories/MultiCombobox.story.vue +76 -0
- package/stories/NavTree.story.vue +184 -0
- package/stories/NumberInput.story.vue +31 -0
- package/stories/Overview.story.vue +85 -0
- package/stories/PopConfirm.story.vue +39 -0
- package/stories/RadioCards.story.vue +66 -0
- package/stories/RadioGroup.story.vue +52 -0
- package/stories/RangeSlider.story.vue +75 -0
- package/stories/ScrollBox.story.vue +54 -0
- package/stories/SectionHeader.story.vue +22 -0
- package/stories/Select.story.vue +34 -0
- package/stories/Switch.story.vue +42 -0
- package/stories/Tabs.story.vue +34 -0
- package/stories/TagInput.story.vue +54 -0
- package/stories/Textarea.story.vue +28 -0
- package/stories/Toast.story.vue +28 -0
- package/stories/ToggleButtons.story.vue +57 -0
- package/stories/ToggleGroup.story.vue +34 -0
- package/stories/Tooltip.story.vue +55 -0
- package/stories/Tree.story.vue +115 -0
- package/tailwind.config.js +9 -0
- package/tailwind.preset.js +79 -0
- 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>
|