@codesuma/baseline 1.0.1
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 +648 -0
- package/components/advanced/page.module.css +57 -0
- package/components/advanced/page.ts +53 -0
- package/components/base.ts +45 -0
- package/components/native/a.ts +15 -0
- package/components/native/button.ts +17 -0
- package/components/native/canvas.ts +15 -0
- package/components/native/div.ts +11 -0
- package/components/native/h.ts +17 -0
- package/components/native/img.ts +17 -0
- package/components/native/input.ts +29 -0
- package/components/native/option.ts +8 -0
- package/components/native/p.ts +11 -0
- package/components/native/select.ts +15 -0
- package/components/native/span.ts +11 -0
- package/components/native/svg.ts +18 -0
- package/helpers/date.ts +17 -0
- package/helpers/device.ts +13 -0
- package/helpers/format.ts +13 -0
- package/helpers/locales.ts +15 -0
- package/helpers/regex.ts +12 -0
- package/index.ts +35 -0
- package/lib/http.ts +96 -0
- package/lib/idb.ts +135 -0
- package/lib/ldb.ts +44 -0
- package/lib/router.ts +179 -0
- package/lib/state.ts +35 -0
- package/package.json +35 -0
- package/utils/appender.ts +141 -0
- package/utils/emitter.ts +63 -0
- package/utils/id.ts +13 -0
- package/utils/mounter.ts +8 -0
- package/utils/ripple.ts +59 -0
- package/utils/styler.ts +56 -0
- package/utils/wait.ts +3 -0
package/lib/ldb.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
function save(value: any): { as: (key: any) => void }
|
|
2
|
+
function save(...args: any[]): void
|
|
3
|
+
function save(...args: any[]) {
|
|
4
|
+
if (args.length > 1) {
|
|
5
|
+
let [key, value] = args
|
|
6
|
+
if (typeof value === 'object') value = JSON.stringify(value)
|
|
7
|
+
localStorage.setItem(key, value)
|
|
8
|
+
return
|
|
9
|
+
}
|
|
10
|
+
let [value] = args
|
|
11
|
+
if (typeof value === 'object') value = JSON.stringify(value)
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
as(key: string) {
|
|
15
|
+
localStorage.setItem(key, value)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function get(key: string) {
|
|
21
|
+
const raw = String(localStorage.getItem(key) || '')
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(raw)
|
|
24
|
+
} catch (err) {
|
|
25
|
+
return raw
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function remove(key: string) {
|
|
30
|
+
localStorage.removeItem(key)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function clear() {
|
|
34
|
+
localStorage.clear()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export default {
|
|
38
|
+
get,
|
|
39
|
+
set: save,
|
|
40
|
+
save,
|
|
41
|
+
remove,
|
|
42
|
+
clear
|
|
43
|
+
}
|
|
44
|
+
|
package/lib/router.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Router - SPA navigation with page lifecycle (Hide/Show pattern)
|
|
2
|
+
// All pages stay in DOM, only visibility toggles for smooth transitions
|
|
3
|
+
|
|
4
|
+
import { createEmitter } from '../utils/emitter'
|
|
5
|
+
import { IBaseComponent } from '../components/base'
|
|
6
|
+
|
|
7
|
+
// Event payload for enter/exit events
|
|
8
|
+
export interface IRouteEvent {
|
|
9
|
+
params: Record<string, string>
|
|
10
|
+
query: Record<string, string>
|
|
11
|
+
from: string
|
|
12
|
+
data?: any
|
|
13
|
+
isBack?: boolean // True if navigating via back button
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface RouteConfig {
|
|
17
|
+
page: () => IBaseComponent<any>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type Routes = Record<string, RouteConfig | (() => IBaseComponent<any>)>
|
|
21
|
+
|
|
22
|
+
const emitter = createEmitter()
|
|
23
|
+
let currentPath = ''
|
|
24
|
+
let currentPage: IBaseComponent<any> | null = null
|
|
25
|
+
let viewContainer: IBaseComponent<any> | null = null
|
|
26
|
+
const pageCache: Record<string, IBaseComponent<any>> = {}
|
|
27
|
+
let routeConfigs: Routes = {}
|
|
28
|
+
let initialized = false
|
|
29
|
+
let isBackNavigation = false
|
|
30
|
+
|
|
31
|
+
// Extract params from path like /users/:id
|
|
32
|
+
const extractParams = (pattern: string, path: string): Record<string, string> | null => {
|
|
33
|
+
const patternParts = pattern.split('/')
|
|
34
|
+
const pathParts = path.split('/')
|
|
35
|
+
if (patternParts.length !== pathParts.length) return null
|
|
36
|
+
|
|
37
|
+
const params: Record<string, string> = {}
|
|
38
|
+
for (let i = 0; i < patternParts.length; i++) {
|
|
39
|
+
if (patternParts[i].startsWith(':')) {
|
|
40
|
+
params[patternParts[i].slice(1)] = pathParts[i]
|
|
41
|
+
} else if (patternParts[i] !== pathParts[i]) {
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return params
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Find matching route
|
|
49
|
+
const matchRoute = (path: string): { pattern: string; params: Record<string, string> } | null => {
|
|
50
|
+
if (routeConfigs[path]) return { pattern: path, params: {} }
|
|
51
|
+
for (const pattern of Object.keys(routeConfigs)) {
|
|
52
|
+
const params = extractParams(pattern, path)
|
|
53
|
+
if (params) return { pattern, params }
|
|
54
|
+
}
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Get query params
|
|
59
|
+
const getQuery = (key?: string) => {
|
|
60
|
+
const params = new URLSearchParams(window.location.search)
|
|
61
|
+
return key ? params.get(key) : Object.fromEntries(params)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Handle navigation
|
|
65
|
+
const navigate = async (to: string, data?: any) => {
|
|
66
|
+
const url = new URL(to, window.location.origin)
|
|
67
|
+
const path = url.pathname
|
|
68
|
+
const from = currentPath
|
|
69
|
+
const isBack = isBackNavigation
|
|
70
|
+
|
|
71
|
+
if (path === currentPath) return
|
|
72
|
+
|
|
73
|
+
const match = matchRoute(path)
|
|
74
|
+
if (!match) {
|
|
75
|
+
console.warn(`No route found for: ${path}`)
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Exit current page with direction
|
|
80
|
+
if (currentPage) {
|
|
81
|
+
currentPage.emit('exit', {
|
|
82
|
+
params: {},
|
|
83
|
+
query: getQuery(),
|
|
84
|
+
from: currentPath,
|
|
85
|
+
data,
|
|
86
|
+
isBack
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Update state
|
|
91
|
+
currentPath = path
|
|
92
|
+
if (!isBack) {
|
|
93
|
+
history.pushState(data, '', to)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Get or create page
|
|
97
|
+
let page = pageCache[path]
|
|
98
|
+
if (!page) {
|
|
99
|
+
const config = routeConfigs[match.pattern]
|
|
100
|
+
const pageFactory = typeof config === 'function' ? config : config.page
|
|
101
|
+
page = pageFactory()
|
|
102
|
+
pageCache[path] = page
|
|
103
|
+
if (viewContainer) {
|
|
104
|
+
viewContainer.append(page)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
currentPage = page
|
|
109
|
+
|
|
110
|
+
// Enter the new page with direction
|
|
111
|
+
page.emit('enter', {
|
|
112
|
+
params: match.params,
|
|
113
|
+
query: getQuery(),
|
|
114
|
+
from,
|
|
115
|
+
data,
|
|
116
|
+
isBack
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
emitter.emit('change', {
|
|
120
|
+
path,
|
|
121
|
+
params: match.params,
|
|
122
|
+
query: getQuery(),
|
|
123
|
+
from,
|
|
124
|
+
data,
|
|
125
|
+
isBack
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
// Reset back flag
|
|
129
|
+
isBackNavigation = false
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Initialize router
|
|
133
|
+
const init = () => {
|
|
134
|
+
if (initialized) return
|
|
135
|
+
initialized = true
|
|
136
|
+
|
|
137
|
+
window.addEventListener('popstate', (e) => {
|
|
138
|
+
isBackNavigation = true // Mark as back navigation
|
|
139
|
+
const path = window.location.pathname
|
|
140
|
+
if (path !== currentPath) {
|
|
141
|
+
navigate(path, e.state)
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
if (currentPath === '' && viewContainer) {
|
|
146
|
+
navigate(window.location.pathname + window.location.search)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Configure routes
|
|
151
|
+
const routes = (config: Routes, view: IBaseComponent<any>) => {
|
|
152
|
+
routeConfigs = config
|
|
153
|
+
viewContainer = view
|
|
154
|
+
init()
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Manually destroy a cached page
|
|
158
|
+
const destroyPage = (path: string) => {
|
|
159
|
+
const page = pageCache[path]
|
|
160
|
+
if (page) {
|
|
161
|
+
page.remove()
|
|
162
|
+
delete pageCache[path]
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export default {
|
|
167
|
+
routes,
|
|
168
|
+
init,
|
|
169
|
+
goto: (path: string, data?: any) => navigate(path, data),
|
|
170
|
+
back: () => history.back(),
|
|
171
|
+
forward: () => history.forward(),
|
|
172
|
+
getPath: () => currentPath,
|
|
173
|
+
getQuery,
|
|
174
|
+
getParams: () => matchRoute(currentPath)?.params || {},
|
|
175
|
+
destroyPage,
|
|
176
|
+
on: emitter.on.bind(emitter),
|
|
177
|
+
off: emitter.off.bind(emitter),
|
|
178
|
+
once: emitter.once.bind(emitter),
|
|
179
|
+
}
|
package/lib/state.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Global state with optional change notifications
|
|
2
|
+
|
|
3
|
+
import { createEmitter } from '../utils/emitter'
|
|
4
|
+
|
|
5
|
+
const data: Record<string, any> = {}
|
|
6
|
+
const emitter = createEmitter()
|
|
7
|
+
|
|
8
|
+
export const get = <T = any>(key: string): T | undefined => data[key]
|
|
9
|
+
|
|
10
|
+
export const set = <T>(key: string, value: T): T => {
|
|
11
|
+
data[key] = value
|
|
12
|
+
emitter.emit(key, value) // Emit specific key
|
|
13
|
+
emitter.emit('change', { key, value }) // Emit generic 'change'
|
|
14
|
+
return value
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const remove = (key: string) => {
|
|
18
|
+
delete data[key]
|
|
19
|
+
emitter.emit(key, undefined)
|
|
20
|
+
emitter.emit('change', { key, value: undefined })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const clear = () => {
|
|
24
|
+
Object.keys(data).forEach(k => delete data[k])
|
|
25
|
+
emitter.emit('change', { key: '*', value: undefined })
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const all = () => ({ ...data })
|
|
29
|
+
|
|
30
|
+
// Re-export emitter methods for subscriptions
|
|
31
|
+
export const on = emitter.on.bind(emitter)
|
|
32
|
+
export const off = emitter.off.bind(emitter)
|
|
33
|
+
export const once = emitter.once.bind(emitter)
|
|
34
|
+
|
|
35
|
+
export default { get, set, remove, clear, all, on, off, once }
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@codesuma/baseline",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "A minimal, imperative UI framework for building fast web apps. No virtual DOM, no magic, no dependencies.",
|
|
5
|
+
"main": "index.ts",
|
|
6
|
+
"types": "index.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"components/**/*",
|
|
9
|
+
"helpers/**/*",
|
|
10
|
+
"lib/**/*",
|
|
11
|
+
"utils/**/*",
|
|
12
|
+
"index.ts",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"keywords": [
|
|
16
|
+
"ui",
|
|
17
|
+
"framework",
|
|
18
|
+
"spa",
|
|
19
|
+
"router",
|
|
20
|
+
"imperative",
|
|
21
|
+
"minimal",
|
|
22
|
+
"typescript"
|
|
23
|
+
],
|
|
24
|
+
"author": "ardeshirvalipoor",
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "https://github.com/ardeshirvalipoor/base-ui.git"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {}
|
|
35
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { IBaseComponent, IBaseSVGComponent } from '../components/base'
|
|
2
|
+
|
|
3
|
+
type AnyComponent = IBaseComponent<any> | IBaseSVGComponent<any>
|
|
4
|
+
|
|
5
|
+
// Lightweight observer - only checks if element entered document.body
|
|
6
|
+
let observer: MutationObserver | null = null
|
|
7
|
+
const pendingRoots: Set<AnyComponent> = new Set()
|
|
8
|
+
|
|
9
|
+
const initObserver = () => {
|
|
10
|
+
if (observer) return
|
|
11
|
+
observer = new MutationObserver(() => {
|
|
12
|
+
pendingRoots.forEach(component => {
|
|
13
|
+
if (document.body.contains(component.el)) {
|
|
14
|
+
pendingRoots.delete(component)
|
|
15
|
+
markMounted(component)
|
|
16
|
+
}
|
|
17
|
+
})
|
|
18
|
+
if (pendingRoots.size === 0 && observer) {
|
|
19
|
+
observer.disconnect()
|
|
20
|
+
observer = null
|
|
21
|
+
}
|
|
22
|
+
})
|
|
23
|
+
observer.observe(document.body, { childList: true, subtree: true })
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const markMounted = (component: AnyComponent) => {
|
|
27
|
+
if (component.isMounted) return
|
|
28
|
+
component.isMounted = true
|
|
29
|
+
component.emit('mounted')
|
|
30
|
+
component.getChildren().forEach(markMounted)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createAppender(base: AnyComponent): IAppender {
|
|
34
|
+
let children: AnyComponent[] = []
|
|
35
|
+
|
|
36
|
+
// Register for root detection (will be cleaned up once mounted)
|
|
37
|
+
pendingRoots.add(base)
|
|
38
|
+
initObserver()
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
children,
|
|
42
|
+
|
|
43
|
+
getChildren: () => children,
|
|
44
|
+
|
|
45
|
+
setChildren: (c: AnyComponent[]) => { children = c },
|
|
46
|
+
|
|
47
|
+
append(...args: (AnyComponent | false | null | undefined)[]) {
|
|
48
|
+
for (const c of args) {
|
|
49
|
+
if (!c) continue
|
|
50
|
+
if (c instanceof Promise) {
|
|
51
|
+
c.then(resolved => {
|
|
52
|
+
if (!resolved?.el) return
|
|
53
|
+
base.el.appendChild(resolved.el)
|
|
54
|
+
children.push(resolved)
|
|
55
|
+
resolved.parent = base
|
|
56
|
+
pendingRoots.delete(resolved) // No longer pending
|
|
57
|
+
if (base.isMounted) markMounted(resolved)
|
|
58
|
+
})
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
if (!c.el) continue
|
|
62
|
+
base.el.appendChild(c.el)
|
|
63
|
+
children.push(c)
|
|
64
|
+
c.parent = base
|
|
65
|
+
pendingRoots.delete(c) // No longer pending - has parent
|
|
66
|
+
if (base.isMounted) markMounted(c)
|
|
67
|
+
}
|
|
68
|
+
base.emit('append', args)
|
|
69
|
+
return base
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
prepend(...args: AnyComponent[]) {
|
|
73
|
+
for (const c of args) {
|
|
74
|
+
if (!c?.el) continue
|
|
75
|
+
base.el.insertBefore(c.el, base.el.firstChild)
|
|
76
|
+
children.unshift(c)
|
|
77
|
+
c.parent = base
|
|
78
|
+
pendingRoots.delete(c)
|
|
79
|
+
if (base.isMounted) markMounted(c)
|
|
80
|
+
}
|
|
81
|
+
base.emit('prepend', args)
|
|
82
|
+
return base
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
appendBefore(ref: AnyComponent, ...args: AnyComponent[]) {
|
|
86
|
+
for (const c of args) {
|
|
87
|
+
if (!c?.el) continue
|
|
88
|
+
base.el.insertBefore(c.el, ref.el)
|
|
89
|
+
const idx = children.indexOf(ref)
|
|
90
|
+
children.splice(idx, 0, c)
|
|
91
|
+
c.parent = base
|
|
92
|
+
pendingRoots.delete(c)
|
|
93
|
+
if (base.isMounted) markMounted(c)
|
|
94
|
+
}
|
|
95
|
+
return base
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
appendAfter(ref: AnyComponent, ...args: AnyComponent[]) {
|
|
99
|
+
for (const c of args) {
|
|
100
|
+
if (!c?.el) continue
|
|
101
|
+
base.el.insertBefore(c.el, ref.el.nextSibling)
|
|
102
|
+
const idx = children.indexOf(ref)
|
|
103
|
+
children.splice(idx + 1, 0, c)
|
|
104
|
+
c.parent = base
|
|
105
|
+
pendingRoots.delete(c)
|
|
106
|
+
if (base.isMounted) markMounted(c)
|
|
107
|
+
}
|
|
108
|
+
return base
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
empty() {
|
|
112
|
+
children.forEach(c => c.remove())
|
|
113
|
+
children = []
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
remove() {
|
|
117
|
+
children.forEach(c => c.remove())
|
|
118
|
+
if (base.parent) {
|
|
119
|
+
base.parent.setChildren(base.parent.getChildren().filter(c => c !== base))
|
|
120
|
+
}
|
|
121
|
+
base.removeAllListeners()
|
|
122
|
+
pendingRoots.delete(base)
|
|
123
|
+
base.el.remove()
|
|
124
|
+
base.emit('unmounted')
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface IAppender {
|
|
130
|
+
children: AnyComponent[]
|
|
131
|
+
getChildren(): AnyComponent[]
|
|
132
|
+
setChildren(c: AnyComponent[]): void
|
|
133
|
+
append(...args: (AnyComponent | false | null | undefined)[]): AnyComponent
|
|
134
|
+
prepend(...args: AnyComponent[]): AnyComponent
|
|
135
|
+
appendBefore(ref: AnyComponent, ...args: AnyComponent[]): AnyComponent
|
|
136
|
+
appendAfter(ref: AnyComponent, ...args: AnyComponent[]): AnyComponent
|
|
137
|
+
empty(): void
|
|
138
|
+
remove(): void
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export default createAppender
|
package/utils/emitter.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export function createEmitter<T extends Record<string, any> = Record<string, any>>(): IEmitter<T> {
|
|
2
|
+
const listeners: Record<string, Function[]> = {}
|
|
3
|
+
|
|
4
|
+
return {
|
|
5
|
+
on(event, handler) {
|
|
6
|
+
const events = Array.isArray(event) ? event : [event]
|
|
7
|
+
for (const e of events) {
|
|
8
|
+
const key = e as string
|
|
9
|
+
listeners[key] = listeners[key] || []
|
|
10
|
+
listeners[key].push(handler)
|
|
11
|
+
}
|
|
12
|
+
return this
|
|
13
|
+
},
|
|
14
|
+
|
|
15
|
+
once(event, handler) {
|
|
16
|
+
const wrapper = (payload: any) => {
|
|
17
|
+
handler(payload)
|
|
18
|
+
this.off(event, wrapper as any)
|
|
19
|
+
}
|
|
20
|
+
return this.on(event, wrapper as any)
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
off(event, handler) {
|
|
24
|
+
const key = event as string
|
|
25
|
+
if (!handler) {
|
|
26
|
+
delete listeners[key]
|
|
27
|
+
} else if (listeners[key]) {
|
|
28
|
+
listeners[key] = listeners[key].filter(h => h !== handler)
|
|
29
|
+
}
|
|
30
|
+
return this
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
emit(event, payload) {
|
|
34
|
+
const key = event as string
|
|
35
|
+
if (listeners[key]) {
|
|
36
|
+
for (const handler of listeners[key]) {
|
|
37
|
+
handler(payload)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return this
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
removeAllListeners() {
|
|
44
|
+
for (const key in listeners) delete listeners[key]
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
getListeners() {
|
|
48
|
+
return listeners
|
|
49
|
+
}
|
|
50
|
+
} as IEmitter<T>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Global emitter for framework internals (mounter, theme, db-ready)
|
|
54
|
+
export const emitter = createEmitter()
|
|
55
|
+
|
|
56
|
+
export interface IEmitter<T extends Record<string, any> = Record<string, any>> {
|
|
57
|
+
on<K extends keyof T>(event: K | K[], handler: (payload: T[K]) => void): this
|
|
58
|
+
once<K extends keyof T>(event: K, handler: (payload: T[K]) => void): this
|
|
59
|
+
off<K extends keyof T>(event: K, handler?: (payload: T[K]) => void): this
|
|
60
|
+
emit<K extends keyof T>(event: K, payload?: T[K]): this
|
|
61
|
+
removeAllListeners(): void
|
|
62
|
+
getListeners(): Record<string, Function[]>
|
|
63
|
+
}
|
package/utils/id.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export const idGenerator = (id = 0) => () => (id++).toString()
|
|
2
|
+
export const nextId = idGenerator()
|
|
3
|
+
|
|
4
|
+
export const shortUUID = () => {
|
|
5
|
+
return (new Date().valueOf().toString(36).slice(2) + Math.random().toString(36).slice(2)).slice(-12)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// Credits: https://stackoverflow.com/users/109538/broofa
|
|
9
|
+
export const uuidv4 = () => {
|
|
10
|
+
return (String(1e7) + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c =>
|
|
11
|
+
(+c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> +c / 4).toString(16)
|
|
12
|
+
)
|
|
13
|
+
}
|
package/utils/mounter.ts
ADDED
package/utils/ripple.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Ripple effect - Material Design style touch feedback
|
|
2
|
+
|
|
3
|
+
import { Base, IBaseComponent } from '../components/base'
|
|
4
|
+
import { injectCSS } from '../utils/styler'
|
|
5
|
+
|
|
6
|
+
const RIPPLE_CSS = `
|
|
7
|
+
[data-ripple] .ripple {
|
|
8
|
+
position: absolute;
|
|
9
|
+
border-radius: 50%;
|
|
10
|
+
background: currentColor;
|
|
11
|
+
opacity: 0.1;
|
|
12
|
+
transform: scale(0);
|
|
13
|
+
pointer-events: none;
|
|
14
|
+
}
|
|
15
|
+
[data-ripple] .ripple.animate {
|
|
16
|
+
animation: ripple 1s ease-out;
|
|
17
|
+
}
|
|
18
|
+
@keyframes ripple {
|
|
19
|
+
to { transform: scale(4); opacity: 0; }
|
|
20
|
+
}
|
|
21
|
+
`
|
|
22
|
+
|
|
23
|
+
export const withRipple = <T extends IBaseComponent<any>>(component: T): T => {
|
|
24
|
+
injectCSS('base-ripple', RIPPLE_CSS)
|
|
25
|
+
|
|
26
|
+
component.el.setAttribute('data-ripple', '')
|
|
27
|
+
|
|
28
|
+
const createRipple = (x: number, y: number) => {
|
|
29
|
+
const ripple = Base('span')
|
|
30
|
+
ripple.el.className = 'ripple'
|
|
31
|
+
|
|
32
|
+
const rect = component.el.getBoundingClientRect()
|
|
33
|
+
const size = Math.max(rect.width, rect.height) * 2
|
|
34
|
+
|
|
35
|
+
ripple.style({
|
|
36
|
+
width: size + 'px',
|
|
37
|
+
height: size + 'px',
|
|
38
|
+
left: (x - rect.left - size / 2) + 'px',
|
|
39
|
+
top: (y - rect.top - size / 2) + 'px'
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
component.el.appendChild(ripple.el)
|
|
43
|
+
ripple.el.classList.add('animate')
|
|
44
|
+
|
|
45
|
+
setTimeout(() => ripple.el.remove(), 600)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
component.el.addEventListener('touchstart', (e: TouchEvent) => {
|
|
49
|
+
createRipple(e.touches[0].clientX, e.touches[0].clientY)
|
|
50
|
+
}, { passive: true })
|
|
51
|
+
|
|
52
|
+
component.el.addEventListener('mousedown', (e: MouseEvent) => {
|
|
53
|
+
createRipple(e.clientX, e.clientY)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
return component
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export default withRipple
|
package/utils/styler.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { IBaseComponent, IBaseSVGComponent } from '../components/base'
|
|
2
|
+
|
|
3
|
+
type Component = IBaseComponent<any> | IBaseSVGComponent<any>
|
|
4
|
+
type StyleObj = Partial<CSSStyleDeclaration> & Record<string, any>
|
|
5
|
+
|
|
6
|
+
export function createStyler(base: Component): IStyler {
|
|
7
|
+
return {
|
|
8
|
+
style(s: StyleObj, delay?: number) {
|
|
9
|
+
const apply = () => {
|
|
10
|
+
const keys = Object.keys(s)
|
|
11
|
+
for (let i = 0; i < keys.length; i++) {
|
|
12
|
+
const k = keys[i]
|
|
13
|
+
const v = s[k]
|
|
14
|
+
; (base.el.style as any)[k] = typeof v === 'function' ? v() : v
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
delay ? setTimeout(apply, delay) : apply()
|
|
18
|
+
return base
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
addClass(...classes: string[]) {
|
|
22
|
+
base.el.classList.add.apply(base.el.classList, classes)
|
|
23
|
+
return base
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
removeClass(...classes: string[]) {
|
|
27
|
+
base.el.classList.remove.apply(base.el.classList, classes)
|
|
28
|
+
return base
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
toggleClass(cls: string, force?: boolean) {
|
|
32
|
+
base.el.classList.toggle(cls, force)
|
|
33
|
+
return base
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Inject CSS once - for components that need self-contained styles
|
|
39
|
+
const injected: Record<string, boolean> = {}
|
|
40
|
+
export function injectCSS(id: string, css: string) {
|
|
41
|
+
if (injected[id]) return
|
|
42
|
+
injected[id] = true
|
|
43
|
+
const el = document.createElement('style')
|
|
44
|
+
el.id = id
|
|
45
|
+
el.textContent = css
|
|
46
|
+
document.head.appendChild(el)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface IStyler {
|
|
50
|
+
style(s: StyleObj, delay?: number): Component
|
|
51
|
+
addClass(...classes: string[]): Component
|
|
52
|
+
removeClass(...classes: string[]): Component
|
|
53
|
+
toggleClass(cls: string, force?: boolean): Component
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export default createStyler
|
package/utils/wait.ts
ADDED