@iankibetsh/sh-tailwind 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/README.md +177 -0
- package/dist/sh-tailwind.cjs.js +1 -0
- package/dist/sh-tailwind.es.js +3695 -0
- package/package.json +56 -0
- package/src/components/actions/ShConfirmAction.vue +78 -0
- package/src/components/actions/ShSilentAction.vue +66 -0
- package/src/components/actions/ShSpinner.vue +6 -0
- package/src/components/form/ShForm.vue +272 -0
- package/src/components/form/ShFormSteps.vue +30 -0
- package/src/components/form/inputs/DateInput.vue +29 -0
- package/src/components/form/inputs/EmailInput.vue +27 -0
- package/src/components/form/inputs/NumberInput.vue +32 -0
- package/src/components/form/inputs/PasswordInput.vue +47 -0
- package/src/components/form/inputs/PhoneInput.vue +190 -0
- package/src/components/form/inputs/SelectInput.vue +50 -0
- package/src/components/form/inputs/ShSuggest.vue +198 -0
- package/src/components/form/inputs/TextAreaInput.vue +27 -0
- package/src/components/form/inputs/TextInput.vue +26 -0
- package/src/components/overlay/ShDialog.vue +143 -0
- package/src/components/overlay/ShDialogBtn.vue +41 -0
- package/src/components/overlay/ShDialogForm.vue +80 -0
- package/src/components/overlay/ShDrawer.vue +129 -0
- package/src/components/overlay/ShDrawerBtn.vue +40 -0
- package/src/components/table/ShTable.vue +472 -0
- package/src/components/table/ShTablePagination.vue +96 -0
- package/src/composables/useDialog.js +68 -0
- package/src/composables/useScrollLock.js +19 -0
- package/src/data/countries.js +1474 -0
- package/src/index.js +45 -0
- package/src/plugin/ShTailwind.js +36 -0
- package/src/table/localQuery.js +60 -0
- package/src/table/tableCache.js +116 -0
- package/src/table/useTableData.js +125 -0
- package/src/theme/defaultTheme.js +148 -0
- package/src/theme/keys.js +3 -0
- package/src/theme/useTheme.js +11 -0
- package/src/utils/deepMerge.js +19 -0
- package/src/utils/normalizeField.js +61 -0
- package/src/utils/normalizeOptions.js +18 -0
- package/src/utils/strings.js +11 -0
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed, getCurrentInstance, onBeforeUnmount, ref, watch } from 'vue'
|
|
3
|
+
import { shRepo, shApis, useUserStore, getShConfig, shStorage } from '@iankibetsh/sh-core'
|
|
4
|
+
import { useTheme } from '../../theme/useTheme.js'
|
|
5
|
+
import { useTableData } from '../../table/useTableData.js'
|
|
6
|
+
import { getPath } from '../../table/localQuery.js'
|
|
7
|
+
import { startCase } from '../../utils/strings.js'
|
|
8
|
+
import ShTablePagination from './ShTablePagination.vue'
|
|
9
|
+
import ShSpinner from '../actions/ShSpinner.vue'
|
|
10
|
+
|
|
11
|
+
const props = defineProps({
|
|
12
|
+
endpoint: { type: String, required: true },
|
|
13
|
+
/**
|
|
14
|
+
* Column schema: strings or objects
|
|
15
|
+
* { name (dot-path ok), label, format ('date'|'datetime'|'number'|'money'),
|
|
16
|
+
* sortable (default true), component (cell component, gets :row),
|
|
17
|
+
* show (fn -> bool), class }
|
|
18
|
+
*/
|
|
19
|
+
columns: { type: Array, required: true },
|
|
20
|
+
/**
|
|
21
|
+
* Row actions:
|
|
22
|
+
* { label, emit ('edit' -> @edit(row)), handler (fn(row)),
|
|
23
|
+
* link ('/users/{id}' -> router.push), url ('users/{id}/x' -> POST),
|
|
24
|
+
* confirm ('message' -> swal confirm before POST),
|
|
25
|
+
* permission, show (fn(row) -> bool), class }
|
|
26
|
+
*/
|
|
27
|
+
actions: { type: Array, default: () => [] },
|
|
28
|
+
// Bulk actions over selected rows: { label, handler(rows), permission, class }
|
|
29
|
+
multiActions: { type: Array, default: () => [] },
|
|
30
|
+
searchable: { type: Boolean, default: true },
|
|
31
|
+
searchPlaceholder: { type: String, default: 'Search' },
|
|
32
|
+
// date range filter (sends from/to like the classic ShTable)
|
|
33
|
+
hasRange: Boolean,
|
|
34
|
+
perPage: Number,
|
|
35
|
+
sortBy: String,
|
|
36
|
+
sortMethod: { type: String, default: 'desc' },
|
|
37
|
+
paginationStyle: String, // 'pages' | 'loadMore'; falls back to ShConfig tablePaginationStyle
|
|
38
|
+
rowLink: String, // '/users/{id}'
|
|
39
|
+
// offline-first IndexedDB cache; null respects ShConfig 'enableTableCache'
|
|
40
|
+
cache: { type: Boolean, default: null },
|
|
41
|
+
networkTimeout: { type: Number, default: 10000 },
|
|
42
|
+
// bump to force a reload
|
|
43
|
+
reload: [Number, String, Boolean],
|
|
44
|
+
emptyMessage: { type: String, default: 'No records found' },
|
|
45
|
+
classes: Object
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const emit = defineEmits(['rowClick', 'loaded', 'action'])
|
|
49
|
+
|
|
50
|
+
const t = useTheme('table', computed(() => props.classes))
|
|
51
|
+
const userStore = useUserStore()
|
|
52
|
+
|
|
53
|
+
// vue-router is optional: use the app's router when installed, otherwise
|
|
54
|
+
// fall back to a full page navigation
|
|
55
|
+
const router = getCurrentInstance()?.appContext.config.globalProperties.$router ?? null
|
|
56
|
+
const navigate = (path) => {
|
|
57
|
+
if (router) {
|
|
58
|
+
router.push(path)
|
|
59
|
+
} else {
|
|
60
|
+
window.location.href = path
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// --- columns -------------------------------------------------------------
|
|
65
|
+
const cols = computed(() => props.columns.map(col => {
|
|
66
|
+
const column = typeof col === 'string' ? { name: col } : { ...col }
|
|
67
|
+
column.label = column.label ?? startCase(column.name.split('.').pop())
|
|
68
|
+
column.sortable = column.sortable ?? !column.component
|
|
69
|
+
return column
|
|
70
|
+
}).filter(column => (column.show ? column.show() : true)))
|
|
71
|
+
|
|
72
|
+
// --- query state ----------------------------------------------------------
|
|
73
|
+
const perPageStorageKey = () => {
|
|
74
|
+
const url = typeof window !== 'undefined' ? window.location.pathname : 'server'
|
|
75
|
+
return `sh_table_per_page_${url}_${props.endpoint}`.replace(/[^a-z0-9]+/gi, '_').toLowerCase()
|
|
76
|
+
}
|
|
77
|
+
const initialPerPage = () => {
|
|
78
|
+
const saved = Number(shStorage.getItem(perPageStorageKey()))
|
|
79
|
+
if (saved > 0) return saved
|
|
80
|
+
return Number(props.perPage ?? getShConfig('tablePerPage', 10))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const page = ref(1)
|
|
84
|
+
const perPage = ref(initialPerPage())
|
|
85
|
+
const search = ref('')
|
|
86
|
+
const exact = ref(false)
|
|
87
|
+
const orderBy = ref(props.sortBy)
|
|
88
|
+
const orderMethod = ref(props.sortMethod)
|
|
89
|
+
const from = ref(null)
|
|
90
|
+
const to = ref(null)
|
|
91
|
+
const fromInput = ref('')
|
|
92
|
+
const toInput = ref('')
|
|
93
|
+
|
|
94
|
+
const pageStyle = computed(() =>
|
|
95
|
+
props.paginationStyle ?? getShConfig('tablePaginationStyle', 'pages')
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
const cacheEnabled = computed(() => {
|
|
99
|
+
if (props.cache !== null) return props.cache
|
|
100
|
+
return !!getShConfig('enableTableCache', false)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const buildQuery = () => {
|
|
104
|
+
const params = {
|
|
105
|
+
order_by: orderBy.value,
|
|
106
|
+
order_method: orderMethod.value,
|
|
107
|
+
per_page: perPage.value,
|
|
108
|
+
page: page.value,
|
|
109
|
+
filter_value: search.value,
|
|
110
|
+
paginated: true,
|
|
111
|
+
from: from.value,
|
|
112
|
+
to: to.value,
|
|
113
|
+
exact: exact.value || null
|
|
114
|
+
}
|
|
115
|
+
Object.keys(params).forEach(key => {
|
|
116
|
+
if (params[key] === null || params[key] === '' || typeof params[key] === 'undefined') {
|
|
117
|
+
delete params[key]
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
return { endpoint: props.endpoint, params }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const { records, meta, status, error, offline, fromCache, load } =
|
|
124
|
+
useTableData({
|
|
125
|
+
query: buildQuery,
|
|
126
|
+
cacheEnabled: () => cacheEnabled.value,
|
|
127
|
+
networkTimeout: () => props.networkTimeout
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
const reloadData = async ({ append = false } = {}) => {
|
|
131
|
+
const response = await load({ append })
|
|
132
|
+
if (response) {
|
|
133
|
+
emit('loaded', response)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
reloadData()
|
|
137
|
+
|
|
138
|
+
// --- search / filters ------------------------------------------------------
|
|
139
|
+
let searchTimer = null
|
|
140
|
+
const onSearchInput = () => {
|
|
141
|
+
clearTimeout(searchTimer)
|
|
142
|
+
searchTimer = setTimeout(() => {
|
|
143
|
+
page.value = 1
|
|
144
|
+
reloadData()
|
|
145
|
+
}, 500)
|
|
146
|
+
}
|
|
147
|
+
onBeforeUnmount(() => clearTimeout(searchTimer))
|
|
148
|
+
|
|
149
|
+
// HTML date input (yyyy-mm-dd) -> backend format (MM/dd/yyyy)
|
|
150
|
+
const toBackendDate = (value) => {
|
|
151
|
+
if (!value) return null
|
|
152
|
+
const [y, m, d] = value.split('-')
|
|
153
|
+
return `${m}/${d}/${y}`
|
|
154
|
+
}
|
|
155
|
+
const rangeChanged = () => {
|
|
156
|
+
from.value = toBackendDate(fromInput.value)
|
|
157
|
+
to.value = toBackendDate(toInput.value)
|
|
158
|
+
page.value = 1
|
|
159
|
+
reloadData()
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const sortBy = (column) => {
|
|
163
|
+
if (!column.sortable) return
|
|
164
|
+
if (orderBy.value === column.name) {
|
|
165
|
+
orderMethod.value = orderMethod.value === 'desc' ? 'asc' : 'desc'
|
|
166
|
+
} else {
|
|
167
|
+
orderBy.value = column.name
|
|
168
|
+
orderMethod.value = 'desc'
|
|
169
|
+
}
|
|
170
|
+
reloadData()
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const changePage = (newPage) => {
|
|
174
|
+
page.value = newPage
|
|
175
|
+
reloadData()
|
|
176
|
+
}
|
|
177
|
+
const changePerPage = (value) => {
|
|
178
|
+
perPage.value = value
|
|
179
|
+
page.value = 1
|
|
180
|
+
shStorage.setItem(perPageStorageKey(), value)
|
|
181
|
+
reloadData()
|
|
182
|
+
}
|
|
183
|
+
const loadMore = () => {
|
|
184
|
+
page.value++
|
|
185
|
+
reloadData({ append: true })
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
watch(() => props.reload, () => reloadData())
|
|
189
|
+
watch(() => props.endpoint, () => {
|
|
190
|
+
page.value = 1
|
|
191
|
+
perPage.value = initialPerPage()
|
|
192
|
+
reloadData()
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
// --- cell rendering ---------------------------------------------------------
|
|
196
|
+
const cellValue = (row, column) => {
|
|
197
|
+
const value = getPath(row, column.name)
|
|
198
|
+
if (value === null || typeof value === 'undefined' || value === '') {
|
|
199
|
+
return ''
|
|
200
|
+
}
|
|
201
|
+
switch (column.format) {
|
|
202
|
+
case 'date':
|
|
203
|
+
return shRepo.formatDate(value, 'll')
|
|
204
|
+
case 'datetime':
|
|
205
|
+
return shRepo.formatDate(value)
|
|
206
|
+
case 'number':
|
|
207
|
+
return shRepo.formatNumber(value)
|
|
208
|
+
case 'money':
|
|
209
|
+
return shRepo.formatNumber(value, 2)
|
|
210
|
+
default:
|
|
211
|
+
return value
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// --- actions -----------------------------------------------------------------
|
|
216
|
+
const allowed = (item) => !item.permission || userStore.isAllowedTo(item.permission)
|
|
217
|
+
const visibleActions = (row) =>
|
|
218
|
+
props.actions.filter(action => allowed(action) && (action.show ? action.show(row) : true))
|
|
219
|
+
const activeMultiActions = computed(() => props.multiActions.filter(allowed))
|
|
220
|
+
|
|
221
|
+
const fillPlaceholders = (template, row) =>
|
|
222
|
+
String(template).replace(/\{(.*?)\}/g, (_, key) => getPath(row, key))
|
|
223
|
+
|
|
224
|
+
const runAction = async (action, row) => {
|
|
225
|
+
if (action.handler) {
|
|
226
|
+
return action.handler(row)
|
|
227
|
+
}
|
|
228
|
+
if (action.emit) {
|
|
229
|
+
emit('action', action.emit, row)
|
|
230
|
+
emit(action.emit, row)
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
if (action.link) {
|
|
234
|
+
return navigate(fillPlaceholders(action.link, row))
|
|
235
|
+
}
|
|
236
|
+
if (action.url) {
|
|
237
|
+
const url = fillPlaceholders(action.url, row)
|
|
238
|
+
if (action.confirm) {
|
|
239
|
+
const res = await shRepo.runPlainRequest(url, action.confirm, action.label, action.data)
|
|
240
|
+
if (res.isConfirmed && res.value?.success) {
|
|
241
|
+
shRepo.showToast(res.value.response?.message ?? 'Action successful')
|
|
242
|
+
reloadData()
|
|
243
|
+
} else if (res.isConfirmed) {
|
|
244
|
+
shRepo.showToast(action.failMessage ?? 'Action failed', 'error')
|
|
245
|
+
}
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
const res = await shApis.doPost(url, action.data)
|
|
250
|
+
shRepo.showToast(res.data?.message ?? 'Action successful')
|
|
251
|
+
reloadData()
|
|
252
|
+
} catch (reason) {
|
|
253
|
+
shRepo.showToast(reason.response?.data?.message ?? 'Action failed', 'error')
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const onRowClick = (row) => {
|
|
259
|
+
emit('rowClick', row)
|
|
260
|
+
if (props.rowLink) {
|
|
261
|
+
navigate(fillPlaceholders(props.rowLink, row))
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// --- multi select ---------------------------------------------------------------
|
|
266
|
+
const selected = ref([])
|
|
267
|
+
const allSelected = computed(() =>
|
|
268
|
+
records.value.length > 0 && selected.value.length === records.value.length
|
|
269
|
+
)
|
|
270
|
+
const toggleAll = () => {
|
|
271
|
+
selected.value = allSelected.value ? [] : records.value.map(r => r.id)
|
|
272
|
+
}
|
|
273
|
+
const toggleOne = (id) => {
|
|
274
|
+
const index = selected.value.indexOf(id)
|
|
275
|
+
if (index > -1) {
|
|
276
|
+
selected.value.splice(index, 1)
|
|
277
|
+
} else {
|
|
278
|
+
selected.value.push(id)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const runMultiAction = (action) => {
|
|
282
|
+
const rows = records.value.filter(r => selected.value.includes(r.id))
|
|
283
|
+
action.handler?.(rows)
|
|
284
|
+
selected.value = []
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const colSpan = computed(() =>
|
|
288
|
+
cols.value.length +
|
|
289
|
+
(activeMultiActions.value.length ? 1 : 0) +
|
|
290
|
+
(props.actions.length ? 1 : 0)
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
defineExpose({ reload: () => reloadData(), records })
|
|
294
|
+
</script>
|
|
295
|
+
|
|
296
|
+
<template>
|
|
297
|
+
<div :class="t.wrapper">
|
|
298
|
+
<div v-if="searchable || hasRange" :class="t.toolbar">
|
|
299
|
+
<div v-if="searchable" class="flex w-full items-center gap-3 md:w-auto">
|
|
300
|
+
<input
|
|
301
|
+
v-model="search"
|
|
302
|
+
type="search"
|
|
303
|
+
:placeholder="searchPlaceholder"
|
|
304
|
+
:class="t.search"
|
|
305
|
+
@input="onSearchInput"
|
|
306
|
+
>
|
|
307
|
+
<label v-if="search.length > 1" :class="t.exactLabel">
|
|
308
|
+
<input v-model="exact" type="checkbox" :class="t.checkbox" @change="reloadData()">
|
|
309
|
+
Exact
|
|
310
|
+
</label>
|
|
311
|
+
</div>
|
|
312
|
+
<div v-if="hasRange" :class="t.rangeWrapper">
|
|
313
|
+
<input v-model="fromInput" type="date" :class="t.rangeInput" @change="rangeChanged">
|
|
314
|
+
<span class="text-xs text-gray-400">to</span>
|
|
315
|
+
<input v-model="toInput" type="date" :class="t.rangeInput" @change="rangeChanged">
|
|
316
|
+
</div>
|
|
317
|
+
</div>
|
|
318
|
+
|
|
319
|
+
<div v-if="offline" :class="t.offline">
|
|
320
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="size-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
|
321
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m0 3.75h.008v.008H12v-.008zM3.34 17.25h17.32c1.16 0 1.88-1.25 1.3-2.25L13.3 4.5c-.58-1-2.02-1-2.6 0L2.04 15c-.58 1 .14 2.25 1.3 2.25z" />
|
|
322
|
+
</svg>
|
|
323
|
+
<span>You appear to be offline — showing cached results. Search and pagination run on cached data.</span>
|
|
324
|
+
</div>
|
|
325
|
+
|
|
326
|
+
<div v-if="status === 'error' && !records.length" :class="t.error">{{ error }}</div>
|
|
327
|
+
|
|
328
|
+
<div v-else-if="status === 'loading' && !records.length" :class="t.loading">
|
|
329
|
+
<ShSpinner class="size-6" />
|
|
330
|
+
</div>
|
|
331
|
+
|
|
332
|
+
<template v-else>
|
|
333
|
+
<!-- desktop table -->
|
|
334
|
+
<div :class="t.container">
|
|
335
|
+
<table :class="t.table">
|
|
336
|
+
<thead :class="t.thead">
|
|
337
|
+
<tr>
|
|
338
|
+
<th v-if="activeMultiActions.length" :class="t.th" class="w-10">
|
|
339
|
+
<input type="checkbox" :class="t.checkbox" :checked="allSelected" @change="toggleAll">
|
|
340
|
+
</th>
|
|
341
|
+
<th v-for="column in cols" :key="column.name" :class="t.th">
|
|
342
|
+
<a v-if="column.sortable" :class="t.sortBtn" @click="sortBy(column)">
|
|
343
|
+
{{ column.label }}
|
|
344
|
+
<span v-if="orderBy === column.name">{{ orderMethod === 'desc' ? '↓' : '↑' }}</span>
|
|
345
|
+
</a>
|
|
346
|
+
<template v-else>{{ column.label }}</template>
|
|
347
|
+
</th>
|
|
348
|
+
<th v-if="actions.length" :class="t.th" class="text-right">Actions</th>
|
|
349
|
+
</tr>
|
|
350
|
+
</thead>
|
|
351
|
+
<tbody :class="t.tbody">
|
|
352
|
+
<tr v-if="!records.length">
|
|
353
|
+
<td :colspan="colSpan" :class="t.empty">
|
|
354
|
+
<slot name="empty">{{ emptyMessage }}</slot>
|
|
355
|
+
</td>
|
|
356
|
+
</tr>
|
|
357
|
+
<tr
|
|
358
|
+
v-for="(row, index) in records"
|
|
359
|
+
:key="row.id ?? index"
|
|
360
|
+
:class="[t.tr, rowLink ? t.trClickable : '']"
|
|
361
|
+
@click="onRowClick(row)"
|
|
362
|
+
>
|
|
363
|
+
<td v-if="activeMultiActions.length" :class="t.td" @click.stop>
|
|
364
|
+
<input
|
|
365
|
+
type="checkbox"
|
|
366
|
+
:class="t.checkbox"
|
|
367
|
+
:checked="selected.includes(row.id)"
|
|
368
|
+
@change="toggleOne(row.id)"
|
|
369
|
+
>
|
|
370
|
+
</td>
|
|
371
|
+
<td v-for="column in cols" :key="column.name" :class="[t.td, column.class]">
|
|
372
|
+
<slot :name="`cell-${column.name}`" :row="row" :value="cellValue(row, column)" :index="index">
|
|
373
|
+
<component :is="column.component" v-if="column.component" :row="row" :value="getPath(row, column.name)" />
|
|
374
|
+
<span v-else-if="column.format === 'money'" :class="t.money">{{ cellValue(row, column) }}</span>
|
|
375
|
+
<span v-else v-html="cellValue(row, column)" />
|
|
376
|
+
</slot>
|
|
377
|
+
</td>
|
|
378
|
+
<td v-if="actions.length" :class="t.actionsCell" @click.stop>
|
|
379
|
+
<slot name="actions" :row="row">
|
|
380
|
+
<a
|
|
381
|
+
v-for="action in visibleActions(row)"
|
|
382
|
+
:key="action.label"
|
|
383
|
+
:class="[t.actionBtn, action.class]"
|
|
384
|
+
@click="runAction(action, row)"
|
|
385
|
+
>
|
|
386
|
+
{{ action.label }}
|
|
387
|
+
</a>
|
|
388
|
+
</slot>
|
|
389
|
+
</td>
|
|
390
|
+
</tr>
|
|
391
|
+
</tbody>
|
|
392
|
+
</table>
|
|
393
|
+
</div>
|
|
394
|
+
|
|
395
|
+
<!-- mobile cards -->
|
|
396
|
+
<div :class="t.cards">
|
|
397
|
+
<p v-if="!records.length" :class="t.empty">
|
|
398
|
+
<slot name="empty">{{ emptyMessage }}</slot>
|
|
399
|
+
</p>
|
|
400
|
+
<div
|
|
401
|
+
v-for="(row, index) in records"
|
|
402
|
+
:key="row.id ?? index"
|
|
403
|
+
:class="[t.card, rowLink ? t.trClickable : '']"
|
|
404
|
+
@click="onRowClick(row)"
|
|
405
|
+
>
|
|
406
|
+
<label v-if="activeMultiActions.length" :class="t.exactLabel" class="mb-2" @click.stop>
|
|
407
|
+
<input
|
|
408
|
+
type="checkbox"
|
|
409
|
+
:class="t.checkbox"
|
|
410
|
+
:checked="selected.includes(row.id)"
|
|
411
|
+
@change="toggleOne(row.id)"
|
|
412
|
+
>
|
|
413
|
+
Select
|
|
414
|
+
</label>
|
|
415
|
+
<template v-for="column in cols" :key="column.name">
|
|
416
|
+
<p :class="t.cardLabel">{{ column.label }}</p>
|
|
417
|
+
<div :class="t.cardValue">
|
|
418
|
+
<slot :name="`cell-${column.name}`" :row="row" :value="cellValue(row, column)" :index="index">
|
|
419
|
+
<component :is="column.component" v-if="column.component" :row="row" :value="getPath(row, column.name)" />
|
|
420
|
+
<span v-else-if="column.format === 'money'" :class="t.money">{{ cellValue(row, column) }}</span>
|
|
421
|
+
<span v-else v-html="cellValue(row, column)" />
|
|
422
|
+
</slot>
|
|
423
|
+
</div>
|
|
424
|
+
</template>
|
|
425
|
+
<div v-if="actions.length" class="mt-2" @click.stop>
|
|
426
|
+
<slot name="actions" :row="row">
|
|
427
|
+
<a
|
|
428
|
+
v-for="action in visibleActions(row)"
|
|
429
|
+
:key="action.label"
|
|
430
|
+
:class="[t.actionBtn, action.class]"
|
|
431
|
+
@click="runAction(action, row)"
|
|
432
|
+
>
|
|
433
|
+
{{ action.label }}
|
|
434
|
+
</a>
|
|
435
|
+
</slot>
|
|
436
|
+
</div>
|
|
437
|
+
</div>
|
|
438
|
+
</div>
|
|
439
|
+
|
|
440
|
+
<ShTablePagination
|
|
441
|
+
v-if="meta && records.length"
|
|
442
|
+
:meta="meta"
|
|
443
|
+
:per-page="perPage"
|
|
444
|
+
:mode="pageStyle"
|
|
445
|
+
:loading="status === 'loading'"
|
|
446
|
+
:theme="t.pagination"
|
|
447
|
+
@page="changePage"
|
|
448
|
+
@per-page="changePerPage"
|
|
449
|
+
@load-more="loadMore"
|
|
450
|
+
/>
|
|
451
|
+
</template>
|
|
452
|
+
|
|
453
|
+
<div v-if="selected.length && activeMultiActions.length" :class="t.multiBar">
|
|
454
|
+
<div class="text-sm text-gray-700 dark:text-gray-200">
|
|
455
|
+
<span :class="t.multiCount">{{ selected.length }}</span>
|
|
456
|
+
selected
|
|
457
|
+
</div>
|
|
458
|
+
<div class="flex items-center gap-2">
|
|
459
|
+
<button
|
|
460
|
+
v-for="action in activeMultiActions"
|
|
461
|
+
:key="action.label"
|
|
462
|
+
type="button"
|
|
463
|
+
:class="[t.multiBtn, action.class]"
|
|
464
|
+
@click="runMultiAction(action)"
|
|
465
|
+
>
|
|
466
|
+
{{ action.label }}
|
|
467
|
+
</button>
|
|
468
|
+
<button type="button" :class="t.multiBtn" @click="selected = []">Cancel</button>
|
|
469
|
+
</div>
|
|
470
|
+
</div>
|
|
471
|
+
</div>
|
|
472
|
+
</template>
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
|
|
4
|
+
const props = defineProps({
|
|
5
|
+
meta: { type: Object, required: true }, // { total, per_page, current_page, last_page, from }
|
|
6
|
+
perPage: Number,
|
|
7
|
+
mode: { type: String, default: 'pages' }, // pages | loadMore
|
|
8
|
+
loading: Boolean,
|
|
9
|
+
theme: { type: Object, required: true } // theme.table.pagination
|
|
10
|
+
})
|
|
11
|
+
const emit = defineEmits(['page', 'perPage', 'loadMore'])
|
|
12
|
+
|
|
13
|
+
const shown = computed(() => {
|
|
14
|
+
const end = Math.min(props.meta.current_page * props.meta.per_page, props.meta.total)
|
|
15
|
+
return end
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
// Windowed page list: 1 ... around-current ... last
|
|
19
|
+
const pages = computed(() => {
|
|
20
|
+
const last = props.meta.last_page
|
|
21
|
+
const current = props.meta.current_page
|
|
22
|
+
if (last <= 7) {
|
|
23
|
+
return Array.from({ length: last }, (_, i) => i + 1)
|
|
24
|
+
}
|
|
25
|
+
const items = [1]
|
|
26
|
+
if (current > 3) {
|
|
27
|
+
items.push('...')
|
|
28
|
+
}
|
|
29
|
+
for (let p = Math.max(2, current - 1); p <= Math.min(last - 1, current + 1); p++) {
|
|
30
|
+
items.push(p)
|
|
31
|
+
}
|
|
32
|
+
if (current < last - 2) {
|
|
33
|
+
items.push('...')
|
|
34
|
+
}
|
|
35
|
+
items.push(last)
|
|
36
|
+
return items
|
|
37
|
+
})
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<template>
|
|
41
|
+
<div :class="theme.wrapper">
|
|
42
|
+
<div :class="theme.info">
|
|
43
|
+
Showing {{ shown }} of {{ meta.total }}
|
|
44
|
+
<select
|
|
45
|
+
:value="perPage"
|
|
46
|
+
:class="theme.perPage"
|
|
47
|
+
class="ml-2"
|
|
48
|
+
@change="emit('perPage', Number($event.target.value))"
|
|
49
|
+
>
|
|
50
|
+
<option v-for="n in [10, 20, 30, 50, 100]" :key="n" :value="n">{{ n }} / page</option>
|
|
51
|
+
</select>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<button
|
|
55
|
+
v-if="mode === 'loadMore'"
|
|
56
|
+
type="button"
|
|
57
|
+
:class="theme.loadMore"
|
|
58
|
+
:disabled="loading || meta.current_page >= meta.last_page"
|
|
59
|
+
@click="emit('loadMore')"
|
|
60
|
+
>
|
|
61
|
+
{{ meta.current_page >= meta.last_page ? 'All loaded' : 'Load more' }}
|
|
62
|
+
</button>
|
|
63
|
+
|
|
64
|
+
<div v-else :class="theme.pages">
|
|
65
|
+
<button
|
|
66
|
+
type="button"
|
|
67
|
+
:class="theme.pageBtn"
|
|
68
|
+
:disabled="meta.current_page <= 1"
|
|
69
|
+
aria-label="Previous page"
|
|
70
|
+
@click="emit('page', meta.current_page - 1)"
|
|
71
|
+
>
|
|
72
|
+
‹
|
|
73
|
+
</button>
|
|
74
|
+
<template v-for="(p, i) in pages" :key="i">
|
|
75
|
+
<span v-if="p === '...'" :class="theme.ellipsis">…</span>
|
|
76
|
+
<button
|
|
77
|
+
v-else
|
|
78
|
+
type="button"
|
|
79
|
+
:class="p === meta.current_page ? theme.pageBtnActive : theme.pageBtn"
|
|
80
|
+
@click="emit('page', p)"
|
|
81
|
+
>
|
|
82
|
+
{{ p }}
|
|
83
|
+
</button>
|
|
84
|
+
</template>
|
|
85
|
+
<button
|
|
86
|
+
type="button"
|
|
87
|
+
:class="theme.pageBtn"
|
|
88
|
+
:disabled="meta.current_page >= meta.last_page"
|
|
89
|
+
aria-label="Next page"
|
|
90
|
+
@click="emit('page', meta.current_page + 1)"
|
|
91
|
+
>
|
|
92
|
+
›
|
|
93
|
+
</button>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
</template>
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { ref, onScopeDispose } from 'vue'
|
|
2
|
+
import { lockScroll, unlockScroll } from './useScrollLock.js'
|
|
3
|
+
|
|
4
|
+
const Z_BASE = 50
|
|
5
|
+
// Stack of open dialog instances; only the topmost reacts to Escape.
|
|
6
|
+
const openStack = []
|
|
7
|
+
|
|
8
|
+
export function useDialog ({ isStatic, onClose, onOpen } = {}) {
|
|
9
|
+
const isOpen = ref(false)
|
|
10
|
+
const zIndex = ref(Z_BASE)
|
|
11
|
+
let restoreFocusEl = null
|
|
12
|
+
|
|
13
|
+
const instance = {}
|
|
14
|
+
|
|
15
|
+
const onKeydown = (e) => {
|
|
16
|
+
if (e.key !== 'Escape') {
|
|
17
|
+
return
|
|
18
|
+
}
|
|
19
|
+
if (openStack[openStack.length - 1] !== instance) {
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
if (!isStatic?.()) {
|
|
23
|
+
close('escape')
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function show () {
|
|
28
|
+
if (isOpen.value) {
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
restoreFocusEl = document.activeElement
|
|
32
|
+
zIndex.value = Z_BASE + openStack.length * 10
|
|
33
|
+
openStack.push(instance)
|
|
34
|
+
isOpen.value = true
|
|
35
|
+
lockScroll()
|
|
36
|
+
document.addEventListener('keydown', onKeydown)
|
|
37
|
+
onOpen?.()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function close (reason = 'api') {
|
|
41
|
+
if (!isOpen.value) {
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
isOpen.value = false
|
|
45
|
+
const index = openStack.indexOf(instance)
|
|
46
|
+
if (index >= 0) {
|
|
47
|
+
openStack.splice(index, 1)
|
|
48
|
+
}
|
|
49
|
+
unlockScroll()
|
|
50
|
+
document.removeEventListener('keydown', onKeydown)
|
|
51
|
+
restoreFocusEl?.focus?.()
|
|
52
|
+
onClose?.(reason)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function onBackdrop () {
|
|
56
|
+
if (!isStatic?.()) {
|
|
57
|
+
close('backdrop')
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
onScopeDispose(() => {
|
|
62
|
+
if (isOpen.value) {
|
|
63
|
+
close('unmount')
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
return { isOpen, zIndex, show, close, onBackdrop }
|
|
68
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Ref-counted body scroll lock so stacked overlays don't fight each other.
|
|
2
|
+
let locks = 0
|
|
3
|
+
|
|
4
|
+
export function lockScroll () {
|
|
5
|
+
if (++locks === 1) {
|
|
6
|
+
const gap = window.innerWidth - document.documentElement.clientWidth
|
|
7
|
+
if (gap > 0) {
|
|
8
|
+
document.body.style.paddingRight = `${gap}px`
|
|
9
|
+
}
|
|
10
|
+
document.body.style.overflow = 'hidden'
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function unlockScroll () {
|
|
15
|
+
if (locks > 0 && --locks === 0) {
|
|
16
|
+
document.body.style.overflow = ''
|
|
17
|
+
document.body.style.paddingRight = ''
|
|
18
|
+
}
|
|
19
|
+
}
|