@inglorious/web 4.4.0 → 4.5.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.
@@ -1,11 +1,5 @@
1
- /**
2
- * @typedef {import("../../types/router").RouterType} RouterType
3
- * @typedef {import("../../types/router").RouterEntity} RouterEntity
4
- * @typedef {import("../../types/router").Api} Api
5
- */
6
-
7
- const SKIP_FULL_MATCH_GROUP = 1 // .match() result at index 0 is the full string
8
- const REMOVE_COLON_PREFIX = 1
1
+ import { createHandlers } from "./handlers.js"
2
+ import { createHelpers } from "./helpers.js"
9
3
 
10
4
  /**
11
5
  * Route configuration map. Keys are route patterns (e.g. `/users/:id`) and
@@ -15,380 +9,7 @@ const REMOVE_COLON_PREFIX = 1
15
9
  */
16
10
  const routeConfig = {}
17
11
 
18
- /**
19
- * Guard to ensure global listeners are only attached once.
20
- * @type {boolean}
21
- */
22
- let areListenersInitialized = false
23
-
24
- /**
25
- * Client-side router for entity-based systems. Handles URL changes, link interception, and browser history management.
26
- * @type {RouterType}
27
- */
28
- export const router = {
29
- /**
30
- * Initializes the router entity.
31
- * Sets up the initial route and event listeners for navigation.
32
- * @param {RouterEntity} entity - The router entity.
33
- * @param {void} payload - Unused payload.
34
- * @param {Api} api - The application API.
35
- */
36
- init(entity, payload, api) {
37
- // Handle initial route
38
- const { pathname, search } = window.location
39
- const initialPath = pathname + search
40
- const route = findRoute(routeConfig, initialPath)
41
- const entityId = entity.id
42
-
43
- if (route) {
44
- api.notify(`#${entityId}:navigate`, {
45
- to: initialPath,
46
- params: route.params,
47
- replace: true,
48
- })
49
- }
50
-
51
- if (areListenersInitialized) return
52
- areListenersInitialized = true
53
-
54
- // Listen for browser back/forward
55
- window.addEventListener("popstate", () =>
56
- api.notify(`#${entityId}:popstate`, payload),
57
- )
58
-
59
- // Intercept link clicks
60
- document.addEventListener("click", (event) => {
61
- // Find the closest <a> tag (handles clicks on children)
62
- const link = event.target.closest("a")
63
-
64
- if (!link) return
65
-
66
- // Skip external links
67
- if (link.target === "_blank") return
68
- if (link.rel === "external") return
69
- if (link.hasAttribute("data-external")) return
70
-
71
- // Skip different origin links
72
- if (link.origin !== window.location.origin) return
73
-
74
- // Skip links with download attribute
75
- if (link.hasAttribute("download")) return
76
-
77
- // Skip mailto:, tel:, etc.
78
- if (!["http:", "https:"].includes(link.protocol)) return
79
-
80
- // Prevent default and use router
81
- event.preventDefault()
82
-
83
- const path = link.pathname + link.search + link.hash
84
- api.notify(`#${entityId}:navigate`, path)
85
- })
86
- },
87
-
88
- /**
89
- * Handles browser `popstate` events.
90
- * Attempts to match the current location to a route and updates the router
91
- * entity state. If the matched route is lazy (a loader function) it will
92
- * attempt to load the module first.
93
- * @param {RouterEntity} entity
94
- * @param {Object} payload
95
- * @param {Api} api
96
- */
97
- async popstate(entity, payload, api) {
98
- const path = window.location.pathname + window.location.search
99
- const route = findRoute(routeConfig, path)
100
- const entityId = entity.id
101
-
102
- if (route) {
103
- if (typeof route.entityType === "function") {
104
- entity.isLoading = true
105
- entity.error = null
106
-
107
- try {
108
- const module = await route.entityType()
109
- api.notify(`#${entityId}:routeLoadSuccess`, { module, route })
110
- api.notify(`#${entityId}:popstate`, payload)
111
- } catch (error) {
112
- api.notify(`#${entityId}:routeLoadError`, { error, path })
113
- }
114
-
115
- return
116
- }
117
-
118
- updateRouter(entity, route)
119
-
120
- api.notify("routeChange", {
121
- route: entity.route,
122
- params: entity.params,
123
- query: entity.query,
124
- path: entity.path,
125
- })
126
- }
127
- },
128
-
129
- /**
130
- * Handles navigation to a new route.
131
- * @param {RouterEntity} entity - The router entity.
132
- * @param {string|number|{to: string, params?: object, replace?: boolean, state?: object}} payload - The navigation target or options.
133
- * @param {Api} api - The application API.
134
- */
135
- async navigate(entity, payload, api) {
136
- const options = ["number", "string"].includes(typeof payload)
137
- ? { to: payload }
138
- : payload
139
- const { to, params, replace, force, state = {} } = options
140
-
141
- // Numeric navigation (back/forward)
142
- if (typeof to === "number") {
143
- history.go(to)
144
- return
145
- }
146
-
147
- // Build final path
148
- let path = to
149
-
150
- // If params provided and "to" looks like a pattern, build the path
151
- if (params && to.includes(":")) {
152
- path = buildPath(to, params)
153
- }
154
-
155
- // If "to" is already a final path (like "/users/1"), use it directly
156
- // The router will match it against patterns in routeConfig
157
-
158
- const route = findRoute(routeConfig, path)
159
- const entityId = entity.id
160
-
161
- if (!route) {
162
- console.warn(`No route matches path: ${path}`)
163
- return
164
- }
165
-
166
- if (!force) {
167
- // Prevent navigation if the full path (including query/hash) is identical.
168
- const currentFullPath =
169
- entity.path + window.location.search + window.location.hash
170
- if (path === currentFullPath) {
171
- return
172
- }
173
- }
174
-
175
- // Asynchronous navigation
176
- if (typeof route.entityType === "function") {
177
- entity.isLoading = true
178
- entity.error = null
179
-
180
- try {
181
- const module = await route.entityType()
182
- api.notify(`#${entityId}:routeLoadSuccess`, { module, route })
183
- api.notify(`#${entityId}:navigate`, payload)
184
- } catch (error) {
185
- api.notify(`#${entityId}:routeLoadError`, { error, path })
186
- }
187
-
188
- return
189
- }
190
-
191
- updateRouter(entity, route)
192
-
193
- // Prepare history state
194
- const historyState = {
195
- ...state,
196
- route: entity.route,
197
- params: entity.params,
198
- query: entity.query,
199
- path: entity.path,
200
- }
201
-
202
- // Navigate
203
- const method = replace ? "replaceState" : "pushState"
204
- history[method](historyState, "", path)
205
-
206
- api.notify("routeChange", historyState)
207
- },
208
-
209
- /**
210
- * Handles successful loading of a lazily-loaded route module.
211
- * Registers the loaded type in the runtime type registry via `api.setType`
212
- * and updates the `routeConfig` entry for the pattern.
213
- * @param {RouterEntity} entity
214
- * @param {{module: object, route: {pattern: string, entityType: string}}} payload
215
- * @param {Api} api
216
- */
217
- routeLoadSuccess(entity, payload, api) {
218
- const { module, route } = payload
219
-
220
- const [typeName, type] = Object.entries(module).find(
221
- ([, type]) => type?.render,
222
- )
12
+ export const router = { ...createHandlers(routeConfig) }
223
13
 
224
- api.setType(typeName, type)
225
- routeConfig[route.pattern] = typeName
226
-
227
- entity.isLoading = false
228
- },
229
-
230
- /**
231
- * Handles errors during lazy route loading.
232
- * @param {RouterEntity} entity - The router entity.
233
- * @param {{error: Error, path: string}} payload - The error payload.
234
- */
235
- routeLoadError(entity, payload) {
236
- const { error, path } = payload
237
- console.error(`Failed to load route ${path}:`, error)
238
- entity.path = path
239
- entity.isLoading = false
240
- entity.error = error
241
- },
242
- }
243
-
244
- /**
245
- * Retrieves the current route configuration.
246
- * @returns {Record<string, string|function>} The current route configuration.
247
- */
248
- export function getRoutes() {
249
- return routeConfig
250
- }
251
-
252
- /**
253
- * Retrieves a single route configuration given its path.
254
- * @param {string} path - The path of the route to retrieve.
255
- * @returns {string|function|undefined} The route configuration or undefined if not found.
256
- */
257
- export function getRoute(path) {
258
- return routeConfig[path]
259
- }
260
-
261
- /**
262
- * Sets or updates routes in the route configuration.
263
- * Can be used both during initialization and at any point to add or update routes dynamically.
264
- * @param {Record<string, string|function>} routes - An object mapping route paths/patterns to entity type names or loader functions.
265
- */
266
- export function setRoutes(routes) {
267
- Object.assign(routeConfig, routes)
268
- }
269
-
270
- /**
271
- * Adds a single route to the route configuration.
272
- * @param {string} path - The route path or pattern (e.g., "/users/:userId").
273
- * @param {string|function} route - The entity type name or a function that dynamically loads it.
274
- */
275
- export function addRoute(path, route) {
276
- routeConfig[path] = route
277
- }
278
-
279
- /**
280
- * Removes a route from the route configuration.
281
- * @param {string} path - The route path or pattern to remove.
282
- */
283
- export function removeRoute(path) {
284
- delete routeConfig[path]
285
- }
286
-
287
- /**
288
- * Builds a URL path by substituting parameters into a route pattern.
289
- * @param {string} pattern - The route pattern (e.g., "/users/:userId").
290
- * @param {Record<string, string>} [params={}] - The parameters to substitute.
291
- * @returns {string} The constructed path.
292
- * @example
293
- * buildPath("/users/:userId", { userId: "123" }) // returns "/users/123"
294
- */
295
- function buildPath(pattern, params = {}) {
296
- let path = pattern
297
- Object.entries(params).forEach(([key, value]) => {
298
- path = path.replace(`:${key}`, value)
299
- })
300
- return path
301
- }
302
-
303
- /**
304
- * Finds a matching route configuration for a given URL path.
305
- * It supports parameterized routes and a fallback "*" route.
306
- * @param {Record<string, string>} routeConfig - The routes configuration map.
307
- * @param {string} pathname - The URL path to match.
308
- * @returns {{pattern: string, entityType: string, params: Record<string, string>, path: string}|null}
309
- * The matched route object or null if no match is found.
310
- */
311
- function findRoute(routeConfig, pathname) {
312
- const [path, search] = pathname.split("?")
313
- let fallbackRoute = null
314
-
315
- for (const [pattern, entityType] of Object.entries(routeConfig)) {
316
- if (pattern === "*") {
317
- fallbackRoute = { pattern, entityType, params: {}, path }
318
- continue
319
- }
320
-
321
- const params = matchRoute(pattern, path)
322
- if (params !== null) {
323
- const query = search
324
- ? Object.fromEntries(new URLSearchParams(search))
325
- : {}
326
- return { pattern, entityType, params, path, query }
327
- }
328
- }
329
-
330
- return fallbackRoute
331
- }
332
-
333
- /**
334
- * Matches a URL path against a route pattern and extracts any parameters.
335
- * @param {string} pattern - The route pattern (e.g., "/users/:userId").
336
- * @param {string} path - The URL path to match (e.g., "/users/123").
337
- * @returns {Record<string, string>|null} An object of extracted parameters,
338
- * or null if the path does not match the pattern.
339
- */
340
- function matchRoute(pattern, path) {
341
- const paramNames = getParamNames(pattern)
342
- const regex = patternToRegex(pattern)
343
- const match = path.match(regex)
344
-
345
- if (!match) return null
346
-
347
- const params = {}
348
- paramNames.forEach((name, i) => {
349
- params[name] = match[i + SKIP_FULL_MATCH_GROUP]
350
- })
351
-
352
- return params
353
- }
354
-
355
- /**
356
- * Parses a route pattern and extracts the names of its parameters.
357
- * @param {string} pattern - The route pattern.
358
- * @returns {string[]} An array of parameter names.
359
- * @example
360
- * getParamNames("/users/:userId/posts/:postId") // returns ["userId", "postId"]
361
- */
362
- function getParamNames(pattern) {
363
- const matches = pattern.match(/:(\w+)/g)
364
- return matches ? matches.map((m) => m.slice(REMOVE_COLON_PREFIX)) : []
365
- }
366
-
367
- /**
368
- * Converts a route pattern into a regular expression for matching URL paths.
369
- * @param {string} pattern - The route pattern (e.g., "/users/:userId").
370
- * @returns {RegExp} The corresponding regular expression.
371
- */
372
- function patternToRegex(pattern) {
373
- const regexPattern = pattern
374
- .replace(/\//g, "\\/")
375
- .replace(/:(\w+)/g, "([^\\/]+)")
376
- return new RegExp(`^${regexPattern}$`)
377
- }
378
-
379
- /**
380
- * Updates the router entity's internal state.
381
- * @param {RouterEntity} entity - The router entity.
382
- * @param {object} options - The update options.
383
- * @param {string} options.entityType - The matched entity type.
384
- * @param {string} options.path - The full path (pathname only, no query).
385
- * @param {object} options.params - The extracted route parameters.
386
- * @param {object} [options.query] - The parsed query parameters.
387
- */
388
- function updateRouter(entity, { entityType, path, params, query }) {
389
- entity.route = entityType
390
- entity.path = path
391
- entity.params = params
392
- entity.query = query
393
- entity.hash = window.location.hash
394
- }
14
+ export const { getRoutes, getRoute, setRoutes, addRoute, removeRoute } =
15
+ createHelpers(routeConfig)
package/src/test.js CHANGED
@@ -1,2 +1,2 @@
1
- export { trigger } from "@inglorious/store/test"
1
+ export { createMockApi, trigger } from "@inglorious/store/test"
2
2
  export { render } from "lit-html"
package/types/index.d.ts CHANGED
@@ -1,8 +1,5 @@
1
1
  export * from "./form"
2
- export * from "./list"
3
2
  export * from "./lit-html"
4
3
  export * from "./mount"
5
4
  export * from "./router"
6
- export * from "./select"
7
5
  export * from "./store"
8
- export * from "./table"
package/src/list/index.js DELETED
@@ -1,7 +0,0 @@
1
- import { logic } from "./logic.js"
2
- import { rendering } from "./rendering.js"
3
-
4
- export const list = {
5
- ...logic,
6
- ...rendering,
7
- }
package/src/list/logic.js DELETED
@@ -1,75 +0,0 @@
1
- /**
2
- * @typedef {import('../../types/list').ListEntity} ListEntity
3
- * @typedef {import('../../types/mount').Api} Api
4
- * @typedef {import('lit-html').TemplateResult} TemplateResult
5
- */
6
-
7
- const LIST_START = 0
8
-
9
- export const logic = {
10
- /**
11
- * Initializes the list entity with default state.
12
- * @param {ListEntity} entity
13
- */
14
- create(entity) {
15
- resetList(entity)
16
- },
17
-
18
- /**
19
- * Handles the scroll event to update the visible range.
20
- * @param {ListEntity} entity
21
- * @param {HTMLElement} containerEl
22
- */
23
- scroll(entity, containerEl) {
24
- const scrollTop = containerEl.scrollTop
25
- const { items, bufferSize, itemHeight, estimatedHeight, viewportHeight } =
26
- entity
27
- const height = itemHeight || estimatedHeight
28
-
29
- const start = Math.max(
30
- LIST_START,
31
- Math.floor(scrollTop / height) - bufferSize,
32
- )
33
- const visibleCount = Math.ceil(viewportHeight / height)
34
- const end = Math.min(start + visibleCount + bufferSize, items.length)
35
-
36
- if (
37
- entity.visibleRange.start === start &&
38
- entity.visibleRange.end === end
39
- ) {
40
- return
41
- }
42
-
43
- entity.scrollTop = scrollTop
44
- entity.visibleRange = { start, end }
45
- },
46
-
47
- /**
48
- * Mounts the list, measuring the first item to determine item height.
49
- * @param {ListEntity} entity
50
- * @param {HTMLElement} containerEl
51
- */
52
- mount(entity, containerEl) {
53
- const firstItem = containerEl.querySelector("[data-index]")
54
- if (!firstItem) return
55
-
56
- entity.itemHeight = firstItem.offsetHeight
57
- entity.visibleRange = {
58
- start: 0,
59
- end: Math.ceil(entity.viewportHeight / entity.itemHeight),
60
- }
61
- },
62
- }
63
-
64
- /**
65
- * Resets the list entity state.
66
- * @param {ListEntity} entity
67
- */
68
- function resetList(entity) {
69
- entity.scrollTop = 0
70
- entity.visibleRange ??= { start: 0, end: 20 }
71
- entity.viewportHeight ??= 600
72
- entity.bufferSize ??= 5
73
- entity.itemHeight ??= null
74
- entity.estimatedHeight ??= 50
75
- }
@@ -1,105 +0,0 @@
1
- /**
2
- * @typedef {import('../../types/mount').Api} Api
3
- * @typedef {import('../../types/list').ListEntity} ListEntity
4
- * @typedef {import('lit-html').TemplateResult} TemplateResult
5
- */
6
-
7
- import { html } from "lit-html"
8
- import { ref } from "lit-html/directives/ref.js"
9
- import { repeat } from "lit-html/directives/repeat.js"
10
- import { styleMap } from "lit-html/directives/style-map.js"
11
-
12
- const PRETTY_INDEX = 1
13
- /**
14
- * Rendering logic for the virtualized list component.
15
- */
16
- export const rendering = {
17
- /**
18
- * Renders the virtualized list component.
19
- * @param {ListEntity} entity The list entity state.
20
- * @param {Api} api The API object.
21
- * @returns {TemplateResult} The rendered list.
22
- */
23
- render(entity, api) {
24
- const { items, visibleRange, viewportHeight, itemHeight, estimatedHeight } =
25
- entity
26
- const type = api.getType(entity.type)
27
-
28
- if (!items) {
29
- console.warn(`list entity ${entity.id} needs 'items'`)
30
- return html``
31
- }
32
-
33
- if (!type.renderItem) {
34
- console.warn(`type ${entity.type} needs 'renderItem' method`)
35
- return html``
36
- }
37
-
38
- const visibleItems = items.slice(visibleRange.start, visibleRange.end)
39
- const height = itemHeight || estimatedHeight
40
- const totalHeight = items.length * height
41
-
42
- return html`
43
- <div
44
- style=${styleMap({ height: `${viewportHeight}px`, overflow: "auto" })}
45
- @scroll=${(e) => api.notify(`#${entity.id}:scroll`, e.target)}
46
- ${ref((el) => {
47
- if (el && !itemHeight) {
48
- queueMicrotask(() => {
49
- api.notify(`#${entity.id}:mount`, el)
50
- })
51
- }
52
- })}
53
- >
54
- <div
55
- style=${styleMap({
56
- height: `${totalHeight}px`,
57
- position: "relative",
58
- })}
59
- >
60
- ${repeat(
61
- visibleItems,
62
- (item) => item.id,
63
- (item, index) => {
64
- const absoluteIndex = visibleRange.start + index
65
- const top = absoluteIndex * height
66
-
67
- return html`
68
- <div
69
- style=${styleMap({
70
- position: "absolute",
71
- top: `${top}px`,
72
- width: "100%",
73
- })}
74
- data-index=${absoluteIndex}
75
- >
76
- ${type.renderItem(
77
- entity,
78
- { item, index: absoluteIndex },
79
- api,
80
- )}
81
- </div>
82
- `
83
- },
84
- )}
85
- </div>
86
- </div>
87
- `
88
- },
89
-
90
- /**
91
- * Default item renderer.
92
- * @param {ListEntity} entity The list entity.
93
- * @param {object} payload The payload for rendering the item.
94
- * @param {any} payload.item The item data.
95
- * @param {number} payload.index The item's absolute index in the full list.
96
- * @param {Api} api The API object.
97
- * @returns {TemplateResult} The rendered list item.
98
- */
99
- // eslint-disable-next-line no-unused-vars
100
- renderItem(entity, { item, index }, api) {
101
- return html`<div class="iw-list-item">
102
- ${index + PRETTY_INDEX}. ${JSON.stringify(item)}
103
- </div>`
104
- },
105
- }
@@ -1,52 +0,0 @@
1
- .iw-select {
2
- position: relative;
3
- display: inline-block;
4
- width: 100%;
5
-
6
- .iw-select-control {
7
- display: flex;
8
- align-items: center;
9
-
10
- cursor: pointer;
11
- user-select: none;
12
-
13
- .iw-select-value,
14
- .iw-select-placeholder,
15
- .iw-select-multi-value {
16
- flex: 1;
17
- }
18
-
19
- .iw-select-multi-value {
20
- display: flex;
21
- flex-wrap: wrap;
22
-
23
- .iw-select-multi-value-tag {
24
- display: flex;
25
- align-items: center;
26
- }
27
- }
28
- }
29
-
30
- .iw-select-dropdown {
31
- position: absolute;
32
- top: 100%;
33
- left: 0;
34
- right: 0;
35
- z-index: 1000;
36
- display: flex;
37
- flex-direction: column;
38
-
39
- .iw-select-dropdown-search {
40
- width: 100%;
41
- }
42
-
43
- .iw-select-dropdown-options {
44
- .iw-select-dropdown-options-option {
45
- display: flex;
46
- align-items: center;
47
- cursor: pointer;
48
- user-select: none;
49
- }
50
- }
51
- }
52
- }
@@ -1,7 +0,0 @@
1
- import { logic } from "./logic.js"
2
- import { rendering } from "./rendering.js"
3
-
4
- export const select = {
5
- ...logic,
6
- ...rendering,
7
- }