@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
package/src/index.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Plugin + theming
|
|
2
|
+
export { ShTailwind, createShTailwind } from './plugin/ShTailwind.js'
|
|
3
|
+
export { defaultTheme } from './theme/defaultTheme.js'
|
|
4
|
+
export { SH_TW_THEME, SH_TW_COMPONENTS, SH_DIALOG_CONTEXT } from './theme/keys.js'
|
|
5
|
+
export { useTheme } from './theme/useTheme.js'
|
|
6
|
+
|
|
7
|
+
// Composables
|
|
8
|
+
export { useDialog } from './composables/useDialog.js'
|
|
9
|
+
|
|
10
|
+
// Form
|
|
11
|
+
export { default as ShForm } from './components/form/ShForm.vue'
|
|
12
|
+
export { default as ShFormSteps } from './components/form/ShFormSteps.vue'
|
|
13
|
+
|
|
14
|
+
// Overlays
|
|
15
|
+
export { default as ShDialog } from './components/overlay/ShDialog.vue'
|
|
16
|
+
export { default as ShDrawer } from './components/overlay/ShDrawer.vue'
|
|
17
|
+
export { default as ShDialogBtn } from './components/overlay/ShDialogBtn.vue'
|
|
18
|
+
export { default as ShDrawerBtn } from './components/overlay/ShDrawerBtn.vue'
|
|
19
|
+
export { default as ShDialogForm } from './components/overlay/ShDialogForm.vue'
|
|
20
|
+
|
|
21
|
+
// Table
|
|
22
|
+
export { default as ShTable } from './components/table/ShTable.vue'
|
|
23
|
+
export { default as ShTablePagination } from './components/table/ShTablePagination.vue'
|
|
24
|
+
export { useTableData } from './table/useTableData.js'
|
|
25
|
+
export { localQuery } from './table/localQuery.js'
|
|
26
|
+
export { default as shTableCache, clearTableCache } from './table/tableCache.js'
|
|
27
|
+
|
|
28
|
+
// Actions
|
|
29
|
+
export { default as ShConfirmAction } from './components/actions/ShConfirmAction.vue'
|
|
30
|
+
export { default as ShSilentAction } from './components/actions/ShSilentAction.vue'
|
|
31
|
+
export { default as ShSpinner } from './components/actions/ShSpinner.vue'
|
|
32
|
+
|
|
33
|
+
// Inputs (also used by the formComponents override mechanism)
|
|
34
|
+
export { default as TextInput } from './components/form/inputs/TextInput.vue'
|
|
35
|
+
export { default as TextAreaInput } from './components/form/inputs/TextAreaInput.vue'
|
|
36
|
+
export { default as EmailInput } from './components/form/inputs/EmailInput.vue'
|
|
37
|
+
export { default as PasswordInput } from './components/form/inputs/PasswordInput.vue'
|
|
38
|
+
export { default as NumberInput } from './components/form/inputs/NumberInput.vue'
|
|
39
|
+
export { default as DateInput } from './components/form/inputs/DateInput.vue'
|
|
40
|
+
export { default as SelectInput } from './components/form/inputs/SelectInput.vue'
|
|
41
|
+
export { default as PhoneInput } from './components/form/inputs/PhoneInput.vue'
|
|
42
|
+
export { default as ShSuggest } from './components/form/inputs/ShSuggest.vue'
|
|
43
|
+
|
|
44
|
+
// Data
|
|
45
|
+
export { default as countries } from './data/countries.js'
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { ShCore } from '@iankibetsh/sh-core'
|
|
2
|
+
import { defaultTheme } from '../theme/defaultTheme.js'
|
|
3
|
+
import { SH_TW_THEME, SH_TW_COMPONENTS } from '../theme/keys.js'
|
|
4
|
+
import { deepMerge } from '../utils/deepMerge.js'
|
|
5
|
+
|
|
6
|
+
// Tailwind UI layer plugin: installs sh-core (API client, auth strategy,
|
|
7
|
+
// config, session, v-if-user-can) then provides the theme and input
|
|
8
|
+
// component overrides. Registers no routes in v1 (options.router reserved
|
|
9
|
+
// for a future Tailwind ShAuth).
|
|
10
|
+
export const ShTailwind = {
|
|
11
|
+
install (app, options = {}) {
|
|
12
|
+
ShCore.install(app, options)
|
|
13
|
+
|
|
14
|
+
const theme = deepMerge(defaultTheme, options.theme ?? {})
|
|
15
|
+
app.provide(SH_TW_THEME, theme)
|
|
16
|
+
app.provide(SH_TW_COMPONENTS, options.formComponents ?? {})
|
|
17
|
+
|
|
18
|
+
// Compat bridge for ecosystem components that inject the legacy keys
|
|
19
|
+
app.provide('formComponents', options.formComponents ?? {})
|
|
20
|
+
app.provide('shFormElementClasses', {
|
|
21
|
+
formGroup: theme.form.group,
|
|
22
|
+
formLabel: theme.form.label,
|
|
23
|
+
formControl: theme.form.input,
|
|
24
|
+
helperText: theme.form.helper,
|
|
25
|
+
invalidFeedback: theme.form.error,
|
|
26
|
+
formErrorTitle: theme.form.errorTitle,
|
|
27
|
+
actionBtn: theme.form.submitBtn
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const createShTailwind = (options = {}) => ({
|
|
33
|
+
install: (app) => ShTailwind.install(app, options)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
export default ShTailwind
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Pure helpers that run a table query (search/sort/paginate) against the
|
|
2
|
+
// offline row pool, returning the same shape as a Laravel paginator so the
|
|
3
|
+
// table renders identically online and offline.
|
|
4
|
+
|
|
5
|
+
export function getPath (record, path) {
|
|
6
|
+
if (!record || !path || typeof path !== 'string') {
|
|
7
|
+
return ''
|
|
8
|
+
}
|
|
9
|
+
return path.split('.').reduce((obj, key) => (obj ? obj[key] : ''), record)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function rowMatches (row, term, exact, depth = 0) {
|
|
13
|
+
if (row === null || typeof row === 'undefined') {
|
|
14
|
+
return false
|
|
15
|
+
}
|
|
16
|
+
if (typeof row === 'object') {
|
|
17
|
+
if (depth > 2) {
|
|
18
|
+
return false
|
|
19
|
+
}
|
|
20
|
+
return Object.values(row).some(value => rowMatches(value, term, exact, depth + 1))
|
|
21
|
+
}
|
|
22
|
+
const text = String(row).toLowerCase()
|
|
23
|
+
return exact ? text === term : text.includes(term)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function localQuery (rows, { search, exact, orderBy, orderMethod, page, perPage } = {}) {
|
|
27
|
+
let filtered = rows
|
|
28
|
+
|
|
29
|
+
if (search) {
|
|
30
|
+
const term = String(search).toLowerCase()
|
|
31
|
+
filtered = rows.filter(row => rowMatches(row, term, !!exact))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (orderBy) {
|
|
35
|
+
const direction = orderMethod === 'asc' ? 1 : -1
|
|
36
|
+
filtered = [...filtered].sort((a, b) => {
|
|
37
|
+
const left = getPath(a, orderBy)
|
|
38
|
+
const right = getPath(b, orderBy)
|
|
39
|
+
if (typeof left === 'number' && typeof right === 'number') {
|
|
40
|
+
return (left - right) * direction
|
|
41
|
+
}
|
|
42
|
+
return String(left ?? '').localeCompare(String(right ?? ''), undefined, { numeric: true }) * direction
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const total = filtered.length
|
|
47
|
+
const limit = Math.max(1, Number(perPage) || 10)
|
|
48
|
+
const lastPage = Math.max(1, Math.ceil(total / limit))
|
|
49
|
+
const currentPage = Math.min(Math.max(1, Number(page) || 1), lastPage)
|
|
50
|
+
const start = (currentPage - 1) * limit
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
data: filtered.slice(start, start + limit),
|
|
54
|
+
total,
|
|
55
|
+
per_page: limit,
|
|
56
|
+
current_page: currentPage,
|
|
57
|
+
last_page: lastPage,
|
|
58
|
+
from: total === 0 ? 0 : start + 1
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { shStorage } from '@iankibetsh/sh-core'
|
|
2
|
+
|
|
3
|
+
// Revamped table cache. Two stores:
|
|
4
|
+
// - 'pages': exact query snapshots (endpoint+page+search+sort+range) for
|
|
5
|
+
// instant stale-while-revalidate rendering.
|
|
6
|
+
// - 'rows': a merged pool of every row ever fetched per endpoint, so
|
|
7
|
+
// search/sort/pagination can run fully offline when the network is slow
|
|
8
|
+
// or unreachable.
|
|
9
|
+
const DB_NAME = 'sh_tw_table_cache'
|
|
10
|
+
const PAGES_STORE = 'pages'
|
|
11
|
+
const ROWS_STORE = 'rows'
|
|
12
|
+
const DB_VERSION = 1
|
|
13
|
+
// Cap the offline pool per endpoint so the cache can't grow unbounded
|
|
14
|
+
const MAX_POOL_ROWS = 3000
|
|
15
|
+
|
|
16
|
+
let dbPromise = null
|
|
17
|
+
|
|
18
|
+
function getDB () {
|
|
19
|
+
if (dbPromise) {
|
|
20
|
+
return dbPromise
|
|
21
|
+
}
|
|
22
|
+
dbPromise = new Promise((resolve, reject) => {
|
|
23
|
+
const request = indexedDB.open(DB_NAME, DB_VERSION)
|
|
24
|
+
request.onupgradeneeded = (event) => {
|
|
25
|
+
const db = event.target.result
|
|
26
|
+
if (!db.objectStoreNames.contains(PAGES_STORE)) {
|
|
27
|
+
db.createObjectStore(PAGES_STORE)
|
|
28
|
+
}
|
|
29
|
+
if (!db.objectStoreNames.contains(ROWS_STORE)) {
|
|
30
|
+
db.createObjectStore(ROWS_STORE)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
request.onsuccess = (event) => resolve(event.target.result)
|
|
34
|
+
request.onerror = (event) => reject(event.target.error)
|
|
35
|
+
})
|
|
36
|
+
return dbPromise
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Cache entries are scoped per logged-in user so shared devices never leak
|
|
40
|
+
// another account's rows
|
|
41
|
+
function userPrefix () {
|
|
42
|
+
try {
|
|
43
|
+
const user = shStorage.getItem('user')
|
|
44
|
+
return user?.id ? `u${user.id}_` : ''
|
|
45
|
+
} catch (err) {
|
|
46
|
+
return ''
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function tx (storeName, mode, run) {
|
|
51
|
+
return getDB().then(db => new Promise((resolve, reject) => {
|
|
52
|
+
const transaction = db.transaction(storeName, mode)
|
|
53
|
+
const store = transaction.objectStore(storeName)
|
|
54
|
+
const request = run(store)
|
|
55
|
+
request.onsuccess = () => resolve(request.result)
|
|
56
|
+
request.onerror = () => reject(request.error)
|
|
57
|
+
})).catch(error => {
|
|
58
|
+
console.error('[sh-tailwind] table cache error', error)
|
|
59
|
+
return undefined
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function getPage (key) {
|
|
64
|
+
const entry = await tx(PAGES_STORE, 'readonly', store => store.get(userPrefix() + key))
|
|
65
|
+
return entry?.response ?? null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function setPage (key, response) {
|
|
69
|
+
return tx(PAGES_STORE, 'readwrite', store =>
|
|
70
|
+
store.put({ response, timestamp: Date.now() }, userPrefix() + key)
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function getRows (endpoint) {
|
|
75
|
+
const entry = await tx(ROWS_STORE, 'readonly', store => store.get(userPrefix() + endpoint))
|
|
76
|
+
return entry?.rows ?? []
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Upsert freshly fetched rows into the endpoint's offline pool (merged by id)
|
|
80
|
+
export async function mergeRows (endpoint, newRows) {
|
|
81
|
+
if (!Array.isArray(newRows) || !newRows.length) {
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
const existing = await getRows(endpoint)
|
|
85
|
+
const byId = new Map()
|
|
86
|
+
existing.forEach(row => {
|
|
87
|
+
if (row && typeof row.id !== 'undefined') {
|
|
88
|
+
byId.set(row.id, row)
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
newRows.forEach(row => {
|
|
92
|
+
if (row && typeof row.id !== 'undefined') {
|
|
93
|
+
byId.set(row.id, row)
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
let rows = [...byId.values()]
|
|
97
|
+
if (rows.length > MAX_POOL_ROWS) {
|
|
98
|
+
rows = rows.slice(rows.length - MAX_POOL_ROWS)
|
|
99
|
+
}
|
|
100
|
+
return tx(ROWS_STORE, 'readwrite', store =>
|
|
101
|
+
store.put({ rows, timestamp: Date.now() }, userPrefix() + endpoint)
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function clearTableCache () {
|
|
106
|
+
await tx(PAGES_STORE, 'readwrite', store => store.clear())
|
|
107
|
+
await tx(ROWS_STORE, 'readwrite', store => store.clear())
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export default {
|
|
111
|
+
getPage,
|
|
112
|
+
setPage,
|
|
113
|
+
getRows,
|
|
114
|
+
mergeRows,
|
|
115
|
+
clear: clearTableCache
|
|
116
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { ref } from 'vue'
|
|
2
|
+
import { shApis } from '@iankibetsh/sh-core'
|
|
3
|
+
import tableCache from './tableCache.js'
|
|
4
|
+
import { localQuery } from './localQuery.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Table data engine with an offline-first cache:
|
|
8
|
+
* 1. Cached snapshot of the exact query renders instantly (stale-while-revalidate).
|
|
9
|
+
* 2. Network response replaces it, refreshes the snapshot and merges rows
|
|
10
|
+
* into the endpoint's offline pool.
|
|
11
|
+
* 3. When the network fails or times out (no HTTP response), the query -
|
|
12
|
+
* including search, sort and pagination - runs locally against the pool
|
|
13
|
+
* and the result is flagged `offline`.
|
|
14
|
+
*
|
|
15
|
+
* `query()` must return { endpoint, params } where params carry the Laravel
|
|
16
|
+
* contract keys (page, per_page, filter_value, order_by, ...).
|
|
17
|
+
*/
|
|
18
|
+
export function useTableData ({ query, cacheEnabled = () => false, networkTimeout = () => 10000 }) {
|
|
19
|
+
const records = ref([])
|
|
20
|
+
const meta = ref(null) // Laravel paginator shape
|
|
21
|
+
const status = ref('loading') // loading | done | error
|
|
22
|
+
const error = ref('')
|
|
23
|
+
const offline = ref(false)
|
|
24
|
+
const fromCache = ref(false)
|
|
25
|
+
|
|
26
|
+
let requestSeq = 0
|
|
27
|
+
|
|
28
|
+
const queryKey = (endpoint, params) =>
|
|
29
|
+
endpoint + '|' + JSON.stringify(params)
|
|
30
|
+
|
|
31
|
+
const apply = (response, { append = false } = {}) => {
|
|
32
|
+
if (append) {
|
|
33
|
+
records.value.push(...(response.data ?? []))
|
|
34
|
+
} else {
|
|
35
|
+
records.value = response.data ?? []
|
|
36
|
+
}
|
|
37
|
+
meta.value = {
|
|
38
|
+
total: response.total ?? records.value.length,
|
|
39
|
+
per_page: response.per_page,
|
|
40
|
+
current_page: response.current_page ?? 1,
|
|
41
|
+
last_page: response.last_page ?? 1,
|
|
42
|
+
from: response.from ?? 1
|
|
43
|
+
}
|
|
44
|
+
status.value = 'done'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const load = async ({ append = false } = {}) => {
|
|
48
|
+
const seq = ++requestSeq
|
|
49
|
+
const { endpoint, params } = query()
|
|
50
|
+
const key = queryKey(endpoint, params)
|
|
51
|
+
const useCache = !!cacheEnabled()
|
|
52
|
+
let showedCached = false
|
|
53
|
+
|
|
54
|
+
if (!records.value.length) {
|
|
55
|
+
status.value = 'loading'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 1. Instant render from the exact-query snapshot
|
|
59
|
+
if (useCache && !append) {
|
|
60
|
+
const cached = await tableCache.getPage(key)
|
|
61
|
+
if (cached && seq === requestSeq) {
|
|
62
|
+
apply(cached)
|
|
63
|
+
fromCache.value = true
|
|
64
|
+
showedCached = true
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 2. Network attempt (bounded so a dead connection falls back fast)
|
|
69
|
+
try {
|
|
70
|
+
const res = await shApis.doGet(endpoint, params, { timeout: networkTimeout() })
|
|
71
|
+
if (seq !== requestSeq) {
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
const response = res.data?.data ?? res.data
|
|
75
|
+
apply(response, { append })
|
|
76
|
+
offline.value = false
|
|
77
|
+
fromCache.value = false
|
|
78
|
+
error.value = ''
|
|
79
|
+
if (useCache) {
|
|
80
|
+
tableCache.setPage(key, response)
|
|
81
|
+
tableCache.mergeRows(endpoint, response.data)
|
|
82
|
+
}
|
|
83
|
+
return response
|
|
84
|
+
} catch (reason) {
|
|
85
|
+
if (seq !== requestSeq) {
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
// Server answered (4xx/5xx): a real error, not an offline situation
|
|
89
|
+
if (reason.response) {
|
|
90
|
+
if (!showedCached) {
|
|
91
|
+
status.value = 'error'
|
|
92
|
+
error.value = `${reason.response.status}: ${reason.response.statusText} (${endpoint})`
|
|
93
|
+
}
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
// 3. Network unreachable or timed out: run the query locally
|
|
97
|
+
offline.value = true
|
|
98
|
+
if (showedCached) {
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
if (useCache) {
|
|
102
|
+
const pool = await tableCache.getRows(endpoint)
|
|
103
|
+
if (seq !== requestSeq) {
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
if (pool.length) {
|
|
107
|
+
apply(localQuery(pool, {
|
|
108
|
+
search: params.filter_value,
|
|
109
|
+
exact: params.exact,
|
|
110
|
+
orderBy: params.order_by,
|
|
111
|
+
orderMethod: params.order_method,
|
|
112
|
+
page: params.page,
|
|
113
|
+
perPage: params.per_page
|
|
114
|
+
}), { append: false })
|
|
115
|
+
fromCache.value = true
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
status.value = 'error'
|
|
120
|
+
error.value = 'You appear to be offline and there is no cached data yet'
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return { records, meta, status, error, offline, fromCache, load }
|
|
125
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Every value is a complete Tailwind utility string (never interpolated
|
|
2
|
+
// fragments) so consumers' @source extraction always finds the classes.
|
|
3
|
+
export const defaultTheme = {
|
|
4
|
+
form: {
|
|
5
|
+
form: 'space-y-4',
|
|
6
|
+
group: 'space-y-1',
|
|
7
|
+
label: 'block text-sm font-medium text-gray-700 dark:text-gray-200',
|
|
8
|
+
required: 'text-red-500',
|
|
9
|
+
input: 'block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/30 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100',
|
|
10
|
+
inputInvalid: 'block w-full rounded-md border border-red-500 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm placeholder:text-gray-400 focus:border-red-500 focus:outline-none focus:ring-2 focus:ring-red-500/30 dark:bg-gray-800 dark:text-gray-100',
|
|
11
|
+
helper: 'text-xs text-gray-500 dark:text-gray-400',
|
|
12
|
+
error: 'text-xs text-red-600',
|
|
13
|
+
errorTitle: 'rounded-md bg-red-50 px-4 py-3 text-sm text-red-700 dark:bg-red-950 dark:text-red-300',
|
|
14
|
+
nav: 'flex items-center justify-end gap-3 pt-2',
|
|
15
|
+
submitBtn: 'inline-flex items-center justify-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500/40 disabled:cursor-not-allowed disabled:opacity-60',
|
|
16
|
+
prevBtn: 'inline-flex items-center justify-center gap-2 rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200',
|
|
17
|
+
nextBtn: 'inline-flex items-center justify-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500/40',
|
|
18
|
+
steps: {
|
|
19
|
+
wrapper: 'mb-6 flex items-start',
|
|
20
|
+
step: 'relative flex flex-1 flex-col items-center gap-1',
|
|
21
|
+
circle: 'z-10 flex size-9 items-center justify-center rounded-full border-2 border-gray-300 bg-white text-sm font-semibold text-gray-500 dark:border-gray-600 dark:bg-gray-800',
|
|
22
|
+
circleActive: 'z-10 flex size-9 items-center justify-center rounded-full border-2 border-blue-600 bg-blue-600 text-sm font-semibold text-white',
|
|
23
|
+
circleDone: 'z-10 flex size-9 items-center justify-center rounded-full border-2 border-emerald-500 bg-emerald-500 text-sm font-semibold text-white',
|
|
24
|
+
title: 'text-xs text-gray-600 dark:text-gray-300',
|
|
25
|
+
titleActive: 'text-xs font-semibold text-blue-600',
|
|
26
|
+
connector: 'absolute top-4 right-1/2 -z-0 h-0.5 w-full bg-gray-200 dark:bg-gray-700',
|
|
27
|
+
connectorDone: 'absolute top-4 right-1/2 -z-0 h-0.5 w-full bg-emerald-500'
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
inputs: {
|
|
31
|
+
select: 'block w-full appearance-none rounded-md border border-gray-300 bg-white px-3 py-2 pr-8 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/30 disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100',
|
|
32
|
+
passwordWrapper: 'relative',
|
|
33
|
+
passwordToggle: 'absolute inset-y-0 right-2 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-300',
|
|
34
|
+
suggest: {
|
|
35
|
+
wrapper: 'relative',
|
|
36
|
+
badges: 'mb-1 flex flex-wrap gap-1',
|
|
37
|
+
badge: 'inline-flex items-center gap-1 rounded-full bg-gray-200 px-2 py-0.5 text-xs text-gray-700 dark:bg-gray-700 dark:text-gray-200',
|
|
38
|
+
badgeRemove: 'cursor-pointer text-gray-500 hover:text-red-600',
|
|
39
|
+
dropdown: 'absolute z-20 mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-800',
|
|
40
|
+
option: 'cursor-pointer px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700',
|
|
41
|
+
optionActive: 'cursor-pointer bg-blue-50 px-3 py-2 text-sm text-blue-700 dark:bg-gray-700 dark:text-blue-300',
|
|
42
|
+
empty: 'px-3 py-2 text-sm text-gray-400'
|
|
43
|
+
},
|
|
44
|
+
phone: {
|
|
45
|
+
wrapper: 'relative flex items-stretch rounded-md border border-gray-300 bg-white shadow-sm focus-within:border-blue-500 focus-within:ring-2 focus-within:ring-blue-500/30 dark:border-gray-600 dark:bg-gray-800',
|
|
46
|
+
trigger: 'flex shrink-0 cursor-pointer items-center gap-1.5 rounded-l-md border-r border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600',
|
|
47
|
+
flag: 'text-base leading-none',
|
|
48
|
+
dial: 'text-sm font-medium text-gray-600 dark:text-gray-300',
|
|
49
|
+
chevron: 'size-3.5 text-gray-400',
|
|
50
|
+
input: 'block w-full rounded-r-md border-0 bg-transparent px-3 py-2 text-sm text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-0 dark:text-gray-100',
|
|
51
|
+
dropdown: 'absolute left-0 top-full z-20 mt-1 w-72 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800',
|
|
52
|
+
search: 'block w-full border-0 border-b border-gray-100 bg-transparent px-3 py-2.5 text-sm text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-0 dark:border-gray-700 dark:text-gray-100',
|
|
53
|
+
list: 'max-h-60 overflow-y-auto py-1',
|
|
54
|
+
option: 'flex w-full cursor-pointer items-center gap-2.5 px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700',
|
|
55
|
+
optionActive: 'flex w-full cursor-pointer items-center gap-2.5 bg-blue-50 px-3 py-2 text-left text-sm text-blue-700 dark:bg-gray-700 dark:text-blue-300',
|
|
56
|
+
optionName: 'flex-1 truncate',
|
|
57
|
+
optionDial: 'text-xs text-gray-400',
|
|
58
|
+
empty: 'px-3 py-3 text-center text-sm text-gray-400'
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
dialog: {
|
|
62
|
+
backdrop: 'fixed inset-0 bg-black/50',
|
|
63
|
+
wrapper: 'fixed inset-0 flex items-center justify-center overflow-y-auto p-4',
|
|
64
|
+
panel: 'relative flex max-h-[90vh] w-full flex-col rounded-xl bg-white shadow-xl outline-none dark:bg-gray-900',
|
|
65
|
+
header: 'flex items-center justify-between border-b border-gray-100 px-5 py-3.5 dark:border-gray-800',
|
|
66
|
+
title: 'text-base font-semibold text-gray-900 dark:text-gray-100',
|
|
67
|
+
closeBtn: 'rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 focus:outline-none dark:hover:bg-gray-800',
|
|
68
|
+
body: 'overflow-y-auto px-5 py-4',
|
|
69
|
+
footer: 'flex justify-end gap-2 border-t border-gray-100 px-5 py-3 dark:border-gray-800',
|
|
70
|
+
sizes: {
|
|
71
|
+
sm: 'max-w-sm',
|
|
72
|
+
md: 'max-w-lg',
|
|
73
|
+
lg: 'max-w-2xl',
|
|
74
|
+
xl: 'max-w-4xl',
|
|
75
|
+
full: 'h-[95vh] max-w-[95vw]'
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
drawer: {
|
|
79
|
+
backdrop: 'fixed inset-0 bg-black/50',
|
|
80
|
+
panel: 'fixed flex flex-col bg-white shadow-xl outline-none dark:bg-gray-900',
|
|
81
|
+
header: 'flex items-center justify-between border-b border-gray-100 px-5 py-3.5 dark:border-gray-800',
|
|
82
|
+
title: 'text-base font-semibold text-gray-900 dark:text-gray-100',
|
|
83
|
+
closeBtn: 'rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 focus:outline-none dark:hover:bg-gray-800',
|
|
84
|
+
body: 'flex-1 overflow-y-auto px-5 py-4',
|
|
85
|
+
sizes: {
|
|
86
|
+
sm: 'max-w-xs',
|
|
87
|
+
md: 'max-w-md',
|
|
88
|
+
lg: 'max-w-lg',
|
|
89
|
+
xl: 'max-w-2xl',
|
|
90
|
+
full: 'max-w-full'
|
|
91
|
+
},
|
|
92
|
+
sizesVertical: {
|
|
93
|
+
sm: 'max-h-48',
|
|
94
|
+
md: 'max-h-72',
|
|
95
|
+
lg: 'max-h-96',
|
|
96
|
+
xl: 'max-h-[60vh]',
|
|
97
|
+
full: 'max-h-full'
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
table: {
|
|
101
|
+
wrapper: 'space-y-3',
|
|
102
|
+
toolbar: 'flex flex-col gap-3 md:flex-row md:items-center md:justify-between',
|
|
103
|
+
search: 'block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/30 md:max-w-xs dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100',
|
|
104
|
+
exactLabel: 'inline-flex items-center gap-1.5 text-xs text-gray-500',
|
|
105
|
+
rangeWrapper: 'flex items-center gap-2',
|
|
106
|
+
rangeInput: 'rounded-md border border-gray-300 px-2 py-1.5 text-sm text-gray-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200',
|
|
107
|
+
offline: 'flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:border-amber-700 dark:bg-amber-950 dark:text-amber-300',
|
|
108
|
+
container: 'hidden overflow-x-auto rounded-lg border border-gray-200 md:block dark:border-gray-700',
|
|
109
|
+
table: 'w-full min-w-full divide-y divide-gray-200 text-sm dark:divide-gray-700',
|
|
110
|
+
thead: 'bg-gray-50 dark:bg-gray-800',
|
|
111
|
+
th: 'px-4 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400',
|
|
112
|
+
sortBtn: 'inline-flex cursor-pointer items-center gap-1 uppercase hover:text-gray-800 dark:hover:text-gray-200',
|
|
113
|
+
tbody: 'divide-y divide-gray-100 bg-white dark:divide-gray-800 dark:bg-gray-900',
|
|
114
|
+
tr: '',
|
|
115
|
+
trClickable: 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800',
|
|
116
|
+
td: 'px-4 py-2.5 text-gray-700 dark:text-gray-200',
|
|
117
|
+
money: 'font-semibold text-emerald-600',
|
|
118
|
+
empty: 'px-4 py-10 text-center text-sm text-gray-400',
|
|
119
|
+
error: 'rounded-md bg-red-50 px-4 py-3 text-sm text-red-700 dark:bg-red-950 dark:text-red-300',
|
|
120
|
+
loading: 'flex justify-center px-4 py-10 text-gray-400',
|
|
121
|
+
actionsCell: 'whitespace-nowrap px-4 py-2.5 text-right',
|
|
122
|
+
actionBtn: 'ml-3 inline-flex cursor-pointer items-center gap-1 text-sm text-blue-600 hover:underline first:ml-0',
|
|
123
|
+
checkbox: 'size-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500',
|
|
124
|
+
cards: 'space-y-3 md:hidden',
|
|
125
|
+
card: 'rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900',
|
|
126
|
+
cardLabel: 'text-xs font-semibold uppercase tracking-wide text-gray-400',
|
|
127
|
+
cardValue: 'mb-2 text-sm text-gray-700 dark:text-gray-200',
|
|
128
|
+
pagination: {
|
|
129
|
+
wrapper: 'flex flex-col items-center justify-between gap-3 md:flex-row',
|
|
130
|
+
info: 'text-xs text-gray-500',
|
|
131
|
+
perPage: 'rounded-md border border-gray-300 px-2 py-1 text-xs text-gray-600 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300',
|
|
132
|
+
pages: 'flex items-center gap-1',
|
|
133
|
+
pageBtn: 'inline-flex size-8 cursor-pointer items-center justify-center rounded-md border border-gray-300 text-xs text-gray-600 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800',
|
|
134
|
+
pageBtnActive: 'inline-flex size-8 items-center justify-center rounded-md border border-blue-600 bg-blue-600 text-xs font-semibold text-white',
|
|
135
|
+
ellipsis: 'px-1 text-xs text-gray-400',
|
|
136
|
+
loadMore: 'inline-flex items-center justify-center gap-2 rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-60 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200'
|
|
137
|
+
},
|
|
138
|
+
multiBar: 'fixed bottom-5 left-1/2 z-40 flex min-w-80 -translate-x-1/2 items-center justify-between gap-4 rounded-xl border border-gray-200 bg-white p-3 shadow-lg dark:border-gray-700 dark:bg-gray-900',
|
|
139
|
+
multiCount: 'inline-flex items-center justify-center rounded-full bg-blue-600 px-2 py-0.5 text-xs font-semibold text-white',
|
|
140
|
+
multiBtn: 'inline-flex items-center justify-center gap-1 rounded-md border border-blue-200 px-3 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-50 dark:border-blue-800 dark:text-blue-300'
|
|
141
|
+
},
|
|
142
|
+
buttons: {
|
|
143
|
+
primary: 'inline-flex items-center justify-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500/40 disabled:opacity-60',
|
|
144
|
+
secondary: 'inline-flex items-center justify-center gap-2 rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200',
|
|
145
|
+
danger: 'inline-flex items-center justify-center gap-2 rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500/40',
|
|
146
|
+
link: 'inline-flex cursor-pointer items-center gap-1 text-sm text-blue-600 hover:underline'
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { computed, inject, unref } from 'vue'
|
|
2
|
+
import { SH_TW_THEME } from './keys.js'
|
|
3
|
+
import { defaultTheme } from './defaultTheme.js'
|
|
4
|
+
import { deepMerge } from '../utils/deepMerge.js'
|
|
5
|
+
|
|
6
|
+
// Resolves one theme section with optional per-instance overrides.
|
|
7
|
+
// Precedence: instance overrides > plugin theme > defaults.
|
|
8
|
+
export function useTheme (section, overrides = null) {
|
|
9
|
+
const theme = inject(SH_TW_THEME, defaultTheme)
|
|
10
|
+
return computed(() => deepMerge(theme[section] ?? defaultTheme[section], unref(overrides) ?? {}))
|
|
11
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
2
|
+
|
|
3
|
+
export function deepMerge (base, override) {
|
|
4
|
+
if (!isPlainObject(base)) {
|
|
5
|
+
return override ?? base
|
|
6
|
+
}
|
|
7
|
+
const out = { ...base }
|
|
8
|
+
if (!isPlainObject(override)) {
|
|
9
|
+
return out
|
|
10
|
+
}
|
|
11
|
+
Object.keys(override).forEach(key => {
|
|
12
|
+
if (isPlainObject(base[key]) && isPlainObject(override[key])) {
|
|
13
|
+
out[key] = deepMerge(base[key], override[key])
|
|
14
|
+
} else {
|
|
15
|
+
out[key] = override[key]
|
|
16
|
+
}
|
|
17
|
+
})
|
|
18
|
+
return out
|
|
19
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { startCase } from './strings.js'
|
|
2
|
+
|
|
3
|
+
// Exact-name inference carried over from shframework's ShAutoForm
|
|
4
|
+
const NAME_TYPE_MAP = {
|
|
5
|
+
password: 'password',
|
|
6
|
+
pin: 'password',
|
|
7
|
+
password_confirmation: 'password',
|
|
8
|
+
message: 'textarea',
|
|
9
|
+
description: 'textarea',
|
|
10
|
+
comments: 'textarea',
|
|
11
|
+
notes: 'textarea',
|
|
12
|
+
email: 'email',
|
|
13
|
+
phone: 'phone',
|
|
14
|
+
phone_number: 'phone',
|
|
15
|
+
age: 'number',
|
|
16
|
+
date: 'date'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function inferType (field) {
|
|
20
|
+
if (field.type) {
|
|
21
|
+
return field.type
|
|
22
|
+
}
|
|
23
|
+
if (field.component) {
|
|
24
|
+
return 'custom'
|
|
25
|
+
}
|
|
26
|
+
if (field.options) {
|
|
27
|
+
return (field.multiple || field.allowCustom) ? 'suggest' : 'select'
|
|
28
|
+
}
|
|
29
|
+
const name = field.name ?? ''
|
|
30
|
+
if (NAME_TYPE_MAP[name]) {
|
|
31
|
+
return NAME_TYPE_MAP[name]
|
|
32
|
+
}
|
|
33
|
+
if (/_email$/.test(name)) return 'email'
|
|
34
|
+
if (/_phone$/.test(name)) return 'phone'
|
|
35
|
+
if (/(_at|_date|_on)$/.test(name)) return 'date'
|
|
36
|
+
return 'text'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Normalize a fields array (strings or partial objects) into full field
|
|
41
|
+
* objects with reactive-ready `value` seeds.
|
|
42
|
+
*/
|
|
43
|
+
export function normalizeFields (fields, currentData = {}) {
|
|
44
|
+
return (fields ?? []).map(raw => {
|
|
45
|
+
const field = typeof raw === 'string' ? { name: raw } : { ...raw }
|
|
46
|
+
field.name = field.name ?? field.field
|
|
47
|
+
if (!field.name) {
|
|
48
|
+
console.warn('[sh-tailwind] form field without a name was skipped', raw)
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
field.type = inferType(field)
|
|
52
|
+
if (field.label !== false) {
|
|
53
|
+
field.label = field.label ?? startCase(field.name)
|
|
54
|
+
}
|
|
55
|
+
field.placeholder = field.placeholder ?? ''
|
|
56
|
+
field.helper = field.helper ?? ''
|
|
57
|
+
field.required = !!field.required
|
|
58
|
+
field.value = field.value ?? currentData?.[field.name] ?? null
|
|
59
|
+
return field
|
|
60
|
+
}).filter(Boolean)
|
|
61
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Coerce arbitrary backend rows into { id, label } pairs, matching the
|
|
2
|
+
// lenient shapes shframework's SelectInput accepted.
|
|
3
|
+
export function normalizeOptions (items) {
|
|
4
|
+
if (!Array.isArray(items)) {
|
|
5
|
+
return []
|
|
6
|
+
}
|
|
7
|
+
return items.map(item => {
|
|
8
|
+
if (item === null || typeof item === 'undefined') {
|
|
9
|
+
return null
|
|
10
|
+
}
|
|
11
|
+
if (typeof item !== 'object') {
|
|
12
|
+
return { id: item, label: String(item) }
|
|
13
|
+
}
|
|
14
|
+
const id = item.id ?? item.key ?? item.value ?? item.name
|
|
15
|
+
const label = item.label ?? item.name ?? item.option ?? item.value ?? String(id)
|
|
16
|
+
return { id, label, raw: item }
|
|
17
|
+
}).filter(Boolean)
|
|
18
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export const startCase = (value) =>
|
|
2
|
+
String(value ?? '')
|
|
3
|
+
.replace(/[_-]+/g, ' ')
|
|
4
|
+
.replace(/([a-z\d])([A-Z])/g, '$1 $2')
|
|
5
|
+
.trim()
|
|
6
|
+
.split(/\s+/)
|
|
7
|
+
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
|
8
|
+
.join(' ')
|
|
9
|
+
|
|
10
|
+
export const randomId = (prefix = 'sh') =>
|
|
11
|
+
`${prefix}-${Math.random().toString(36).slice(2, 9)}`
|