@inglorious/web 2.5.0 → 2.6.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 +218 -6
- package/package.json +5 -3
- package/src/form.js +15 -11
- package/src/form.test.js +17 -17
- package/src/index.js +1 -0
- package/src/list.js +39 -5
- package/src/list.test.js +7 -7
- package/src/router.js +5 -0
- package/src/router.test.js +6 -14
- package/src/select/base.css +52 -0
- package/src/select/logic.js +332 -0
- package/src/select/rendering.js +352 -0
- package/src/select/theme.css +133 -0
- package/src/select.js +7 -0
- package/src/select.test.js +415 -0
- package/src/table/logic.js +177 -5
- package/src/table.test.js +24 -3
- package/types/form.d.ts +2 -12
- package/types/index.d.ts +4 -0
- package/types/list.d.ts +76 -0
- package/types/select.d.ts +136 -0
- package/types/table.d.ts +232 -0
package/README.md
CHANGED
|
@@ -19,6 +19,9 @@ Unlike modern frameworks that invent their own languages or rely on signals, pro
|
|
|
19
19
|
Each entity type defines its own `render(entity, api)` method.
|
|
20
20
|
`api.render(id)` composes the UI by invoking the correct renderer for each entity.
|
|
21
21
|
|
|
22
|
+
- **Type Composition**
|
|
23
|
+
Types can be composed as arrays of behaviors, enabling reusable patterns like authentication guards, logging, or any cross-cutting concern.
|
|
24
|
+
|
|
22
25
|
- **Simple and Predictable API**
|
|
23
26
|
Zero magic, zero reactivity graphs, zero compiler.
|
|
24
27
|
Just JavaScript functions and store events.
|
|
@@ -55,7 +58,7 @@ Use the scaffolder to create a starter app tailored to your workflow.
|
|
|
55
58
|
|
|
56
59
|
### ✨ **Inglorious Web re-renders the whole template tree on each state change.**
|
|
57
60
|
|
|
58
|
-
Thanks to lit-html
|
|
61
|
+
Thanks to lit-html's optimized diffing, this is fast, predictable, and surprisingly efficient.
|
|
59
62
|
|
|
60
63
|
This means:
|
|
61
64
|
|
|
@@ -66,7 +69,7 @@ This means:
|
|
|
66
69
|
|
|
67
70
|
You get Svelte-like ergonomic simplicity, but with no compiler and no magic.
|
|
68
71
|
|
|
69
|
-
>
|
|
72
|
+
> "Re-render everything → let lit-html update only what changed."
|
|
70
73
|
|
|
71
74
|
It's that simple — and surprisingly fast in practice.
|
|
72
75
|
|
|
@@ -136,7 +139,7 @@ This makes it especially suitable for:
|
|
|
136
139
|
|
|
137
140
|
# Comparison with Other Frameworks
|
|
138
141
|
|
|
139
|
-
Here
|
|
142
|
+
Here's how @inglorious/web compares to the major players:
|
|
140
143
|
|
|
141
144
|
---
|
|
142
145
|
|
|
@@ -213,7 +216,7 @@ Inglorious Web is minimal, predictable, and tiny.
|
|
|
213
216
|
|
|
214
217
|
## **HTMX / Alpine / Vanilla DOM**
|
|
215
218
|
|
|
216
|
-
|
|
219
|
+
Inglorious Web is closer philosophically to **HTMX** and **vanilla JS**, but with a declarative rendering model and entity-based state.
|
|
217
220
|
|
|
218
221
|
---
|
|
219
222
|
|
|
@@ -348,7 +351,7 @@ export const store = createStore({
|
|
|
348
351
|
|
|
349
352
|
Now your application state is fully visible in the Redux DevTools browser extension.
|
|
350
353
|
|
|
351
|
-
### What You
|
|
354
|
+
### What You'll See in DevTools
|
|
352
355
|
|
|
353
356
|
- Each event you dispatch via `api.notify(event, payload)` will appear as an action in the DevTools timeline.
|
|
354
357
|
- The entire store is visible under the _State_ tab.
|
|
@@ -446,7 +449,7 @@ api.notify("navigate", -1)
|
|
|
446
449
|
api.notify("navigate", {
|
|
447
450
|
to: "/users/456",
|
|
448
451
|
replace: true, // Replace current history entry
|
|
449
|
-
force: true, // Force navigation even if path is identical
|
|
452
|
+
force: true, // Force navigation even if path is identical (useful after logout)
|
|
450
453
|
})
|
|
451
454
|
```
|
|
452
455
|
|
|
@@ -480,6 +483,157 @@ export const adminPage = {
|
|
|
480
483
|
}
|
|
481
484
|
```
|
|
482
485
|
|
|
486
|
+
### 5. Route Guards (Type Composition)
|
|
487
|
+
|
|
488
|
+
Route guards are implemented using **type composition** — a powerful feature of `@inglorious/store` where types can be defined as arrays of behaviors that wrap and extend each other.
|
|
489
|
+
|
|
490
|
+
Guards are simply behaviors that intercept events (like `routeChange`) and can prevent navigation, redirect, or pass through to the protected page.
|
|
491
|
+
|
|
492
|
+
#### Example: Authentication Guard
|
|
493
|
+
|
|
494
|
+
```javascript
|
|
495
|
+
// guards/require-auth.js
|
|
496
|
+
export const requireAuth = (type) => ({
|
|
497
|
+
routeChange(entity, payload, api) {
|
|
498
|
+
// Only act when navigating to this specific route
|
|
499
|
+
if (payload.route !== entity.type) return
|
|
500
|
+
|
|
501
|
+
// Check authentication
|
|
502
|
+
const user = localStorage.getItem("user")
|
|
503
|
+
if (!user) {
|
|
504
|
+
// Redirect to login, preserving the intended destination
|
|
505
|
+
api.notify("navigate", {
|
|
506
|
+
to: "/login",
|
|
507
|
+
redirectTo: window.location.pathname,
|
|
508
|
+
replace: true,
|
|
509
|
+
})
|
|
510
|
+
return
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// User is authenticated - pass through to the actual page handler
|
|
514
|
+
type.routeChange?.(entity, payload, api)
|
|
515
|
+
},
|
|
516
|
+
})
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
#### Using Guards with Type Composition
|
|
520
|
+
|
|
521
|
+
```javascript
|
|
522
|
+
// store.js
|
|
523
|
+
import { createStore, router } from "@inglorious/web"
|
|
524
|
+
import { requireAuth } from "./guards/require-auth.js"
|
|
525
|
+
import { adminPage } from "./pages/admin.js"
|
|
526
|
+
import { loginPage } from "./pages/login.js"
|
|
527
|
+
|
|
528
|
+
const types = {
|
|
529
|
+
router,
|
|
530
|
+
|
|
531
|
+
// Public page - no guard
|
|
532
|
+
loginPage,
|
|
533
|
+
|
|
534
|
+
// Protected page - composed with requireAuth guard
|
|
535
|
+
adminPage: [adminPage, requireAuth],
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const entities = {
|
|
539
|
+
router: {
|
|
540
|
+
type: "router",
|
|
541
|
+
routes: {
|
|
542
|
+
"/login": "loginPage",
|
|
543
|
+
"/admin": "adminPage",
|
|
544
|
+
},
|
|
545
|
+
},
|
|
546
|
+
adminPage: {
|
|
547
|
+
type: "adminPage",
|
|
548
|
+
},
|
|
549
|
+
loginPage: {
|
|
550
|
+
type: "loginPage",
|
|
551
|
+
},
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
export const store = createStore({ types, entities })
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
#### How Type Composition Works
|
|
558
|
+
|
|
559
|
+
When you define a type as an array like `[adminPage, requireAuth]`:
|
|
560
|
+
|
|
561
|
+
1. The behaviors compose in order (left to right)
|
|
562
|
+
2. Each behavior can intercept events before they reach the next behavior
|
|
563
|
+
3. Guards can choose to:
|
|
564
|
+
- **Block** by returning early (not calling the next handler)
|
|
565
|
+
- **Redirect** by triggering navigation to a different route
|
|
566
|
+
- **Pass through** by calling the next behavior's handler
|
|
567
|
+
|
|
568
|
+
This pattern is extremely flexible and can be used for:
|
|
569
|
+
|
|
570
|
+
- **Authentication** - Check if user is logged in
|
|
571
|
+
- **Authorization** - Check user roles or permissions
|
|
572
|
+
- **Analytics** - Log page views
|
|
573
|
+
- **Redirects** - Redirect logged-in users away from login page
|
|
574
|
+
- **Loading states** - Show loading UI while checking async permissions
|
|
575
|
+
- **Any cross-cutting concern** you can think of
|
|
576
|
+
|
|
577
|
+
#### Multiple Guards
|
|
578
|
+
|
|
579
|
+
You can compose multiple guards for fine-grained control:
|
|
580
|
+
|
|
581
|
+
```javascript
|
|
582
|
+
const types = {
|
|
583
|
+
// Require authentication AND admin role
|
|
584
|
+
adminPage: [adminPage, requireAuth, requireAdmin],
|
|
585
|
+
|
|
586
|
+
// Require authentication AND resource ownership
|
|
587
|
+
userProfile: [userProfile, requireAuth, requireOwnership],
|
|
588
|
+
}
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
Guards execute in order, so earlier guards can block navigation before later guards even run.
|
|
592
|
+
|
|
593
|
+
---
|
|
594
|
+
|
|
595
|
+
## Type Composition
|
|
596
|
+
|
|
597
|
+
One of the most powerful features of `@inglorious/store` (and therefore `@inglorious/web`) is **type composition**. Types can be defined as arrays of behaviors that wrap each other, enabling elegant solutions to cross-cutting concerns.
|
|
598
|
+
|
|
599
|
+
### Basic Composition
|
|
600
|
+
|
|
601
|
+
```javascript
|
|
602
|
+
const logging = (type) => ({
|
|
603
|
+
// Intercept the render method
|
|
604
|
+
render(entity, api) {
|
|
605
|
+
console.log(`Rendering ${entity.id}`)
|
|
606
|
+
return type.render(entity, api)
|
|
607
|
+
},
|
|
608
|
+
|
|
609
|
+
// Intercept any event
|
|
610
|
+
someEvent(entity, payload, api) {
|
|
611
|
+
console.log(`Event triggered on ${entity.id}`)
|
|
612
|
+
type.someEvent?.(entity, payload, api)
|
|
613
|
+
},
|
|
614
|
+
})
|
|
615
|
+
|
|
616
|
+
const types = {
|
|
617
|
+
// Compose the counter type with logging
|
|
618
|
+
counter: [counterBase, logging],
|
|
619
|
+
}
|
|
620
|
+
```
|
|
621
|
+
|
|
622
|
+
### Use Cases
|
|
623
|
+
|
|
624
|
+
Type composition enables elegant solutions for:
|
|
625
|
+
|
|
626
|
+
- **Route guards** - Authentication, authorization, redirects
|
|
627
|
+
- **Logging/debugging** - Trace renders and events
|
|
628
|
+
- **Analytics** - Track user interactions
|
|
629
|
+
- **Error boundaries** - Catch and handle render errors gracefully
|
|
630
|
+
- **Loading states** - Show spinners during async operations
|
|
631
|
+
- **Caching/memoization** - Cache expensive computations
|
|
632
|
+
- **Validation** - Validate entity state before operations
|
|
633
|
+
- **Any cross-cutting concern**
|
|
634
|
+
|
|
635
|
+
The composition pattern keeps your code modular and reusable without introducing framework magic.
|
|
636
|
+
|
|
483
637
|
---
|
|
484
638
|
|
|
485
639
|
## Table
|
|
@@ -542,6 +696,62 @@ The table comes with a base stylesheet (`@inglorious/web/table/base.css`) and a
|
|
|
542
696
|
|
|
543
697
|
---
|
|
544
698
|
|
|
699
|
+
## Select
|
|
700
|
+
|
|
701
|
+
`@inglorious/web` includes a robust `select` type for handling dropdowns, supporting single/multi-select, filtering, and keyboard navigation.
|
|
702
|
+
|
|
703
|
+
### 1. Add the `select` type
|
|
704
|
+
|
|
705
|
+
Import the `select` type and its CSS, then create an entity.
|
|
706
|
+
|
|
707
|
+
```javascript
|
|
708
|
+
import { createStore, select } from "@inglorious/web"
|
|
709
|
+
// Import base styles and theme
|
|
710
|
+
import "@inglorious/web/select/base.css"
|
|
711
|
+
import "@inglorious/web/select/theme.css"
|
|
712
|
+
|
|
713
|
+
const types = { select }
|
|
714
|
+
|
|
715
|
+
const entities = {
|
|
716
|
+
countrySelect: {
|
|
717
|
+
type: "select",
|
|
718
|
+
options: [
|
|
719
|
+
{ value: "us", label: "United States" },
|
|
720
|
+
{ value: "ca", label: "Canada" },
|
|
721
|
+
{ value: "fr", label: "France" },
|
|
722
|
+
],
|
|
723
|
+
// Configuration
|
|
724
|
+
isMulti: false,
|
|
725
|
+
isSearchable: true,
|
|
726
|
+
placeholder: "Select a country...",
|
|
727
|
+
},
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const store = createStore({ types, entities })
|
|
731
|
+
```
|
|
732
|
+
|
|
733
|
+
### 2. Render
|
|
734
|
+
|
|
735
|
+
Render it like any other entity.
|
|
736
|
+
|
|
737
|
+
```javascript
|
|
738
|
+
const renderApp = (api) => {
|
|
739
|
+
return html` <div class="my-form">${api.render("countrySelect")}</div> `
|
|
740
|
+
}
|
|
741
|
+
```
|
|
742
|
+
|
|
743
|
+
### 3. State & Events
|
|
744
|
+
|
|
745
|
+
The `select` entity maintains its own state:
|
|
746
|
+
|
|
747
|
+
- `selectedValue`: The current value (single value or array if `isMulti: true`).
|
|
748
|
+
- `isOpen`: Whether the dropdown is open.
|
|
749
|
+
- `searchTerm`: Current search input.
|
|
750
|
+
|
|
751
|
+
It listens to internal events like `#<id>:toggle`, `#<id>:optionSelect`, etc. You typically don't need to manually dispatch these unless you are building custom controls around it.
|
|
752
|
+
|
|
753
|
+
---
|
|
754
|
+
|
|
545
755
|
## Forms
|
|
546
756
|
|
|
547
757
|
`@inglorious/web` includes a small but powerful `form` type for managing form state inside your entity store. It offers:
|
|
@@ -691,6 +901,8 @@ const store = createStore({ types, entities })
|
|
|
691
901
|
|
|
692
902
|
See `src/list.js` in the package for the implementation details and the `examples/apps/web-list` demo for a complete working example. In the demo the `productList` type extends the `list` type and provides `renderItem(item, index)` to render each visible item — see `examples/apps/web-list/src/product-list/product-list.js`.
|
|
693
903
|
|
|
904
|
+
---
|
|
905
|
+
|
|
694
906
|
## API Reference
|
|
695
907
|
|
|
696
908
|
**`mount(store, renderFn, element)`**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inglorious/web",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.1",
|
|
4
4
|
"description": "A new web framework that leverages the power of the Inglorious Store combined with the performance and simplicity of lit-html.",
|
|
5
5
|
"author": "IceOnFire <antony.mistretta@gmail.com> (https://ingloriouscoderz.it)",
|
|
6
6
|
"license": "MIT",
|
|
@@ -27,7 +27,9 @@
|
|
|
27
27
|
"import": "./src/index.js"
|
|
28
28
|
},
|
|
29
29
|
"./table/base.css": "./src/table/base.css",
|
|
30
|
-
"./table/theme.css": "./src/table/theme.css"
|
|
30
|
+
"./table/theme.css": "./src/table/theme.css",
|
|
31
|
+
"./select/base.css": "./src/select/base.css",
|
|
32
|
+
"./select/theme.css": "./src/select/theme.css"
|
|
31
33
|
},
|
|
32
34
|
"files": [
|
|
33
35
|
"src",
|
|
@@ -38,7 +40,7 @@
|
|
|
38
40
|
},
|
|
39
41
|
"dependencies": {
|
|
40
42
|
"lit-html": "^3.3.1",
|
|
41
|
-
"@inglorious/store": "
|
|
43
|
+
"@inglorious/store": "8.0.0",
|
|
42
44
|
"@inglorious/utils": "3.7.0"
|
|
43
45
|
},
|
|
44
46
|
"devDependencies": {
|
package/src/form.js
CHANGED
|
@@ -26,6 +26,8 @@ import { clone, get, set } from "@inglorious/utils/data-structures/object.js"
|
|
|
26
26
|
const NO_ITEMS_REMOVED = 0
|
|
27
27
|
const ONE_ITEM_REMOVED = 1
|
|
28
28
|
|
|
29
|
+
let areListenersInitialized = false
|
|
30
|
+
|
|
29
31
|
/**
|
|
30
32
|
* A type definition for managing form state within an entity-based system.
|
|
31
33
|
* It handles initialization, field changes, validation, and submission.
|
|
@@ -37,30 +39,30 @@ export const form = {
|
|
|
37
39
|
* Initializes the form entity by resetting it to its initial state.
|
|
38
40
|
* @param {FormEntity} entity - The form entity.
|
|
39
41
|
*/
|
|
40
|
-
init(
|
|
41
|
-
|
|
42
|
+
init() {
|
|
43
|
+
if (areListenersInitialized) return
|
|
42
44
|
|
|
43
|
-
document.addEventListener("click", (
|
|
44
|
-
const button =
|
|
45
|
+
document.addEventListener("click", (event) => {
|
|
46
|
+
const button = event.target.closest("button")
|
|
45
47
|
|
|
46
48
|
if (!button || button.getAttribute("type") === "submit") return
|
|
47
49
|
|
|
48
|
-
|
|
50
|
+
event.preventDefault()
|
|
49
51
|
})
|
|
50
52
|
|
|
51
|
-
document.addEventListener("submit", (
|
|
52
|
-
const form =
|
|
53
|
+
document.addEventListener("submit", (event) => {
|
|
54
|
+
const form = event.target.closest("form")
|
|
53
55
|
|
|
54
56
|
if (!form) return
|
|
55
|
-
|
|
56
|
-
e.preventDefault()
|
|
57
|
+
event.preventDefault()
|
|
57
58
|
})
|
|
59
|
+
|
|
60
|
+
areListenersInitialized = true
|
|
58
61
|
},
|
|
59
62
|
|
|
60
63
|
/**
|
|
61
|
-
* Resets the form entity
|
|
64
|
+
* Resets the form entity with default state.
|
|
62
65
|
* @param {FormEntity} entity - The form entity.
|
|
63
|
-
* @param {string|number} entityId - The ID from the create event.
|
|
64
66
|
*/
|
|
65
67
|
create(entity) {
|
|
66
68
|
resetForm(entity)
|
|
@@ -266,6 +268,8 @@ export const form = {
|
|
|
266
268
|
},
|
|
267
269
|
}
|
|
268
270
|
|
|
271
|
+
// Helper functions
|
|
272
|
+
|
|
269
273
|
/**
|
|
270
274
|
* Retrieves the validation error for a specific form field.
|
|
271
275
|
* @param {FormEntity} form - The form entity object.
|
package/src/form.test.js
CHANGED
|
@@ -22,12 +22,28 @@ describe("form", () => {
|
|
|
22
22
|
})
|
|
23
23
|
|
|
24
24
|
describe("init()", () => {
|
|
25
|
+
it("should add global event listeners for click and submit", () => {
|
|
26
|
+
const spy = vi.spyOn(document, "addEventListener")
|
|
27
|
+
form.init(entity)
|
|
28
|
+
expect(spy).toHaveBeenCalledWith("click", expect.any(Function))
|
|
29
|
+
expect(spy).toHaveBeenCalledWith("submit", expect.any(Function))
|
|
30
|
+
spy.mockRestore()
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe("create()", () => {
|
|
35
|
+
it("should initialize the form", () => {
|
|
36
|
+
entity.values = {}
|
|
37
|
+
form.create(entity)
|
|
38
|
+
expect(entity.values).toEqual(entity.initialValues)
|
|
39
|
+
})
|
|
40
|
+
|
|
25
41
|
it("should reset the form to its initial state", () => {
|
|
26
42
|
// mess up the state first
|
|
27
43
|
entity.values = {}
|
|
28
44
|
entity.isPristine = false
|
|
29
45
|
|
|
30
|
-
form.
|
|
46
|
+
form.create(entity)
|
|
31
47
|
|
|
32
48
|
expect(entity.values).toEqual(entity.initialValues)
|
|
33
49
|
expect(entity.values).not.toBe(entity.initialValues) // should be a clone
|
|
@@ -44,22 +60,6 @@ describe("form", () => {
|
|
|
44
60
|
tags: [null, null],
|
|
45
61
|
})
|
|
46
62
|
})
|
|
47
|
-
|
|
48
|
-
it("should add global event listeners for click and submit", () => {
|
|
49
|
-
const spy = vi.spyOn(document, "addEventListener")
|
|
50
|
-
form.init(entity)
|
|
51
|
-
expect(spy).toHaveBeenCalledWith("click", expect.any(Function))
|
|
52
|
-
expect(spy).toHaveBeenCalledWith("submit", expect.any(Function))
|
|
53
|
-
spy.mockRestore()
|
|
54
|
-
})
|
|
55
|
-
})
|
|
56
|
-
|
|
57
|
-
describe("create()", () => {
|
|
58
|
-
it("should reset the form", () => {
|
|
59
|
-
entity.values = {}
|
|
60
|
-
form.create(entity)
|
|
61
|
-
expect(entity.values).toEqual(entity.initialValues)
|
|
62
|
-
})
|
|
63
63
|
})
|
|
64
64
|
|
|
65
65
|
describe("reset()", () => {
|
package/src/index.js
CHANGED
|
@@ -2,6 +2,7 @@ export { form, getFieldError, getFieldValue, isFieldTouched } from "./form.js"
|
|
|
2
2
|
export { list } from "./list.js"
|
|
3
3
|
export { mount } from "./mount.js"
|
|
4
4
|
export { router } from "./router.js"
|
|
5
|
+
export { select } from "./select.js"
|
|
5
6
|
export { table } from "./table.js"
|
|
6
7
|
export { createStore } from "@inglorious/store"
|
|
7
8
|
export { createDevtools } from "@inglorious/store/client/devtools.js"
|
package/src/list.js
CHANGED
|
@@ -5,15 +5,26 @@ import { styleMap } from "lit-html/directives/style-map.js"
|
|
|
5
5
|
const LIST_START = 0
|
|
6
6
|
const PRETTY_INDEX = 1
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {import('../types/list').ListEntity} ListEntity
|
|
10
|
+
* @typedef {import('../types/mount').Api} Api
|
|
11
|
+
* @typedef {import('lit-html').TemplateResult} TemplateResult
|
|
12
|
+
*/
|
|
12
13
|
|
|
14
|
+
export const list = {
|
|
15
|
+
/**
|
|
16
|
+
* Initializes the list entity with default state.
|
|
17
|
+
* @param {ListEntity} entity
|
|
18
|
+
*/
|
|
13
19
|
create(entity) {
|
|
14
20
|
resetList(entity)
|
|
15
21
|
},
|
|
16
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Handles the scroll event to update the visible range.
|
|
25
|
+
* @param {ListEntity} entity
|
|
26
|
+
* @param {HTMLElement} containerEl
|
|
27
|
+
*/
|
|
17
28
|
scroll(entity, containerEl) {
|
|
18
29
|
const scrollTop = containerEl.scrollTop
|
|
19
30
|
const { items, bufferSize, itemHeight, estimatedHeight, viewportHeight } =
|
|
@@ -38,6 +49,11 @@ export const list = {
|
|
|
38
49
|
entity.visibleRange = { start, end }
|
|
39
50
|
},
|
|
40
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Mounts the list, measuring the first item to determine item height.
|
|
54
|
+
* @param {ListEntity} entity
|
|
55
|
+
* @param {HTMLElement} containerEl
|
|
56
|
+
*/
|
|
41
57
|
mount(entity, containerEl) {
|
|
42
58
|
const firstItem = containerEl.querySelector("[data-index]")
|
|
43
59
|
if (!firstItem) return
|
|
@@ -49,6 +65,12 @@ export const list = {
|
|
|
49
65
|
}
|
|
50
66
|
},
|
|
51
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Renders the virtualized list component.
|
|
70
|
+
* @param {ListEntity} entity
|
|
71
|
+
* @param {Api} api
|
|
72
|
+
* @returns {TemplateResult}
|
|
73
|
+
*/
|
|
52
74
|
render(entity, api) {
|
|
53
75
|
const { items, visibleRange, viewportHeight, itemHeight, estimatedHeight } =
|
|
54
76
|
entity
|
|
@@ -108,11 +130,23 @@ export const list = {
|
|
|
108
130
|
`
|
|
109
131
|
},
|
|
110
132
|
|
|
111
|
-
|
|
133
|
+
/**
|
|
134
|
+
* Default item renderer.
|
|
135
|
+
* @param {any} item
|
|
136
|
+
* @param {number} index
|
|
137
|
+
* @param {Api} api
|
|
138
|
+
* @returns {TemplateResult}
|
|
139
|
+
*/
|
|
140
|
+
// eslint-disable-next-line no-unused-vars
|
|
141
|
+
renderItem(item, index, api) {
|
|
112
142
|
return html`<div>${index + PRETTY_INDEX}. ${JSON.stringify(item)}</div>`
|
|
113
143
|
},
|
|
114
144
|
}
|
|
115
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Resets the list entity state.
|
|
148
|
+
* @param {ListEntity} entity
|
|
149
|
+
*/
|
|
116
150
|
function resetList(entity) {
|
|
117
151
|
entity.scrollTop = 0
|
|
118
152
|
entity.visibleRange ??= { start: 0, end: 20 }
|
package/src/list.test.js
CHANGED
|
@@ -20,9 +20,9 @@ describe("list", () => {
|
|
|
20
20
|
}
|
|
21
21
|
})
|
|
22
22
|
|
|
23
|
-
describe("
|
|
23
|
+
describe("and create()", () => {
|
|
24
24
|
it("should set default list properties on init", () => {
|
|
25
|
-
list.
|
|
25
|
+
list.create(entity)
|
|
26
26
|
expect(entity.scrollTop).toBe(0)
|
|
27
27
|
expect(entity.visibleRange).toEqual({ start: 0, end: 20 })
|
|
28
28
|
expect(entity.viewportHeight).toBe(600)
|
|
@@ -35,7 +35,7 @@ describe("list", () => {
|
|
|
35
35
|
entity.viewportHeight = 800
|
|
36
36
|
entity.visibleRange = { start: 10, end: 30 }
|
|
37
37
|
|
|
38
|
-
list.
|
|
38
|
+
list.create(entity)
|
|
39
39
|
|
|
40
40
|
expect(entity.viewportHeight).toBe(800)
|
|
41
41
|
expect(entity.visibleRange).toEqual({ start: 10, end: 30 })
|
|
@@ -51,7 +51,7 @@ describe("list", () => {
|
|
|
51
51
|
|
|
52
52
|
describe("scroll()", () => {
|
|
53
53
|
beforeEach(() => {
|
|
54
|
-
list.
|
|
54
|
+
list.create(entity)
|
|
55
55
|
})
|
|
56
56
|
|
|
57
57
|
it("should calculate visible range based on itemHeight", () => {
|
|
@@ -116,7 +116,7 @@ describe("list", () => {
|
|
|
116
116
|
|
|
117
117
|
describe("mount()", () => {
|
|
118
118
|
it("should measure and set itemHeight and update visibleRange", () => {
|
|
119
|
-
list.
|
|
119
|
+
list.create(entity)
|
|
120
120
|
const itemEl = document.createElement("div")
|
|
121
121
|
vi.spyOn(itemEl, "offsetHeight", "get").mockReturnValue(40)
|
|
122
122
|
|
|
@@ -133,7 +133,7 @@ describe("list", () => {
|
|
|
133
133
|
})
|
|
134
134
|
|
|
135
135
|
it("should do nothing if no item element is found", () => {
|
|
136
|
-
list.
|
|
136
|
+
list.create(entity)
|
|
137
137
|
const originalEntity = { ...entity }
|
|
138
138
|
const containerEl = {
|
|
139
139
|
querySelector: vi.fn().mockReturnValue(null),
|
|
@@ -149,7 +149,7 @@ describe("list", () => {
|
|
|
149
149
|
let api
|
|
150
150
|
|
|
151
151
|
beforeEach(() => {
|
|
152
|
-
list.
|
|
152
|
+
list.create(entity)
|
|
153
153
|
api = {
|
|
154
154
|
notify: vi.fn(),
|
|
155
155
|
getType: vi.fn().mockReturnValue({
|
package/src/router.js
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
const SKIP_FULL_MATCH_GROUP = 1 // .match() result at index 0 is the full string
|
|
8
8
|
const REMOVE_COLON_PREFIX = 1
|
|
9
9
|
|
|
10
|
+
let areListenersInitialized = false
|
|
11
|
+
|
|
10
12
|
/**
|
|
11
13
|
* Client-side router for entity-based systems. Handles URL changes, link interception, and browser history management.
|
|
12
14
|
* @type {RouterType}
|
|
@@ -34,6 +36,9 @@ export const router = {
|
|
|
34
36
|
})
|
|
35
37
|
}
|
|
36
38
|
|
|
39
|
+
if (areListenersInitialized) return
|
|
40
|
+
areListenersInitialized = true
|
|
41
|
+
|
|
37
42
|
// Listen for browser back/forward
|
|
38
43
|
window.addEventListener("popstate", () => {
|
|
39
44
|
const path = window.location.pathname + window.location.search
|
package/src/router.test.js
CHANGED
|
@@ -44,33 +44,25 @@ describe("router", () => {
|
|
|
44
44
|
})
|
|
45
45
|
|
|
46
46
|
describe("init()", () => {
|
|
47
|
-
it("should initialize with the current window.location", () => {
|
|
47
|
+
it("should initialize with the current window.location, set up a popstate listener, and set up a click listener for link interception", () => {
|
|
48
48
|
vi.spyOn(window, "location", "get").mockReturnValue({
|
|
49
49
|
pathname: "/users/123",
|
|
50
50
|
search: "?sort=asc",
|
|
51
51
|
hash: "#details",
|
|
52
52
|
origin: "http://localhost:3000",
|
|
53
53
|
})
|
|
54
|
+
const windowSpy = vi.spyOn(window, "addEventListener")
|
|
55
|
+
const documentSpy = vi.spyOn(document, "addEventListener")
|
|
54
56
|
|
|
55
|
-
router.init(entity,
|
|
57
|
+
router.init(entity, undefined, api)
|
|
56
58
|
|
|
57
59
|
expect(api.notify).toHaveBeenCalledWith("#router:navigate", {
|
|
58
60
|
to: "/users/123?sort=asc",
|
|
59
61
|
params: { id: "123" },
|
|
60
62
|
replace: true,
|
|
61
63
|
})
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
it("should set up a popstate listener", () => {
|
|
65
|
-
const spy = vi.spyOn(window, "addEventListener")
|
|
66
|
-
router.init(entity, {}, api)
|
|
67
|
-
expect(spy).toHaveBeenCalledWith("popstate", expect.any(Function))
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
it("should set up a click listener for link interception", () => {
|
|
71
|
-
const spy = vi.spyOn(document, "addEventListener")
|
|
72
|
-
router.init(entity, {}, api)
|
|
73
|
-
expect(spy).toHaveBeenCalledWith("click", expect.any(Function))
|
|
64
|
+
expect(windowSpy).toHaveBeenCalledWith("popstate", expect.any(Function))
|
|
65
|
+
expect(documentSpy).toHaveBeenCalledWith("click", expect.any(Function))
|
|
74
66
|
})
|
|
75
67
|
})
|
|
76
68
|
|
|
@@ -0,0 +1,52 @@
|
|
|
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
|
+
}
|