@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.
Files changed (40) hide show
  1. package/README.md +177 -0
  2. package/dist/sh-tailwind.cjs.js +1 -0
  3. package/dist/sh-tailwind.es.js +3695 -0
  4. package/package.json +56 -0
  5. package/src/components/actions/ShConfirmAction.vue +78 -0
  6. package/src/components/actions/ShSilentAction.vue +66 -0
  7. package/src/components/actions/ShSpinner.vue +6 -0
  8. package/src/components/form/ShForm.vue +272 -0
  9. package/src/components/form/ShFormSteps.vue +30 -0
  10. package/src/components/form/inputs/DateInput.vue +29 -0
  11. package/src/components/form/inputs/EmailInput.vue +27 -0
  12. package/src/components/form/inputs/NumberInput.vue +32 -0
  13. package/src/components/form/inputs/PasswordInput.vue +47 -0
  14. package/src/components/form/inputs/PhoneInput.vue +190 -0
  15. package/src/components/form/inputs/SelectInput.vue +50 -0
  16. package/src/components/form/inputs/ShSuggest.vue +198 -0
  17. package/src/components/form/inputs/TextAreaInput.vue +27 -0
  18. package/src/components/form/inputs/TextInput.vue +26 -0
  19. package/src/components/overlay/ShDialog.vue +143 -0
  20. package/src/components/overlay/ShDialogBtn.vue +41 -0
  21. package/src/components/overlay/ShDialogForm.vue +80 -0
  22. package/src/components/overlay/ShDrawer.vue +129 -0
  23. package/src/components/overlay/ShDrawerBtn.vue +40 -0
  24. package/src/components/table/ShTable.vue +472 -0
  25. package/src/components/table/ShTablePagination.vue +96 -0
  26. package/src/composables/useDialog.js +68 -0
  27. package/src/composables/useScrollLock.js +19 -0
  28. package/src/data/countries.js +1474 -0
  29. package/src/index.js +45 -0
  30. package/src/plugin/ShTailwind.js +36 -0
  31. package/src/table/localQuery.js +60 -0
  32. package/src/table/tableCache.js +116 -0
  33. package/src/table/useTableData.js +125 -0
  34. package/src/theme/defaultTheme.js +148 -0
  35. package/src/theme/keys.js +3 -0
  36. package/src/theme/useTheme.js +11 -0
  37. package/src/utils/deepMerge.js +19 -0
  38. package/src/utils/normalizeField.js +61 -0
  39. package/src/utils/normalizeOptions.js +18 -0
  40. 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
+ &lsaquo;
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
+ &rsaquo;
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
+ }