kempo-ui 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/components/Context.js +1 -0
  2. package/dist/components/Import.js +1 -1
  3. package/docs/components/aside-items.html +1 -0
  4. package/docs/components/aside-menu.html +1 -0
  5. package/docs/components/aside-push.html +1 -0
  6. package/docs/components/aside.html +6 -2
  7. package/docs/components/context.html +231 -0
  8. package/docs/components/import.html +16 -2
  9. package/docs/components/nav.html +7 -2
  10. package/docs/components/table.html +1 -0
  11. package/docs/components/tableControls.html +1 -0
  12. package/docs/components/tableCustomFields.html +1 -0
  13. package/docs/components/tableFetchRecords.html +1 -0
  14. package/docs/components/tableFieldSortHide.html +1 -0
  15. package/docs/components/tablePagination.html +1 -0
  16. package/docs/components/tableRecordEditing.html +1 -0
  17. package/docs/components/tableRecordFiltering.html +1 -0
  18. package/docs/components/tableRecordHiding.html +1 -0
  19. package/docs/components/tableRecordSearching.html +1 -0
  20. package/docs/components/tableRecordSelection.html +1 -0
  21. package/docs/components/tableRowControls.html +1 -0
  22. package/docs/components/tableServerSync.html +1 -0
  23. package/docs/components/tableSorting.html +1 -0
  24. package/docs/icons/npm.svg +1 -0
  25. package/docs/index.html +6 -0
  26. package/docs/nav-1.inc.html +5 -2
  27. package/docs/nav.inc.html +4 -1
  28. package/docs/src/components/Context.js +1 -0
  29. package/docs/src/components/Import.js +1 -1
  30. package/docs/utils/elevation.html +6 -2
  31. package/icons/npm.svg +1 -0
  32. package/llm.txt +1 -0
  33. package/package.json +1 -1
  34. package/src/components/Context.js +84 -0
  35. package/src/components/Import.js +15 -7
  36. package/tests/components/Context.browser-test.js +238 -0
  37. package/tests/components/Import.browser-test.js +212 -0
package/docs/nav.inc.html CHANGED
@@ -25,6 +25,7 @@
25
25
  <k-filter-item filter-keywords="card components"><a href="./components/card.html">Card<br><small>Component</small></a></k-filter-item>
26
26
  <k-filter-item filter-keywords="color picker colorpicker components"><a href="./components/color-picker.html">Color Picker<br><small>Component</small></a></k-filter-item>
27
27
  <k-filter-item filter-keywords="content slider components"><a href="./components/content-slider.html">Content Slider<br><small>Component</small></a></k-filter-item>
28
+ <k-filter-item filter-keywords="context state global components"><a href="./components/context.html">Context<br><small>Component</small></a></k-filter-item>
28
29
  <k-filter-item filter-keywords="dialog modal popup components"><a href="./components/dialog.html">Dialog<br><small>Component</small></a></k-filter-item>
29
30
  <k-filter-item filter-keywords="dropdown select components"><a href="./components/dropdown.html">Dropdown<br><small>Component</small></a></k-filter-item>
30
31
  <k-filter-item filter-keywords="filter list filterlist components"><a href="./components/filter-list.html">Filter List<br><small>Component</small></a></k-filter-item>
@@ -80,7 +81,8 @@
80
81
  </div>
81
82
  </div>
82
83
  <div class="flex"></div>
83
- <a href="https://github.com/dustinpoissant/kempo-ui?tab=License-1-ov-file#creative-commons-attribution-noncommercial-sharealike-20" target="_blank"><k-icon name="license"></k-icont></a>
84
+ <a href="https://github.com/dustinpoissant/kempo-ui?tab=License-1-ov-file#creative-commons-attribution-noncommercial-sharealike-20" target="_blank" target="_blank"><k-icon name="license"></k-icont></a>
85
+ <a href="https://www.npmjs.com/package/kempo-ui" target="_blank"><k-icon name="npm"></k-icont></a>
84
86
  <a href="https://github.com/dustinpoissant/kempo-ui" target="_blank"><k-icon name="github-mark"></k-icont></a>
85
87
  <k-theme-switcher></k-theme-switcher>
86
88
  </k-nav>
@@ -103,6 +105,7 @@
103
105
  <a href="./components/card.html">Card</a>
104
106
  <a href="./components/color-picker.html">ColorPicker</a>
105
107
  <a href="./components/content-slider.html">Content Slider</a>
108
+ <a href="./components/context.html">Context</a>
106
109
  <a href="./components/dialog.html">Dialog</a>
107
110
  <a href="./components/dropdown.html">Dropdown</a>
108
111
  <a href="./components/filter-list.html">Filter List</a>
@@ -0,0 +1 @@
1
+ import{html}from"../lit-all.min.js";import LightComponent from"./LightComponent.js";export default class Context extends LightComponent{static properties={data:{type:String,reflect:!0}};constructor(){super(),this.data="{}"}connectedCallback(){super.connectedCallback(),this.style.display="contents"}set(t,e){const s=JSON.parse(this.data||"{}"),a=!(t in s),n=s[t];s[t]=e,this.data=JSON.stringify(s),this.dispatchEvent(new CustomEvent(a?"context:create":"context:set",{detail:a?{key:t,value:e}:{key:t,value:e,oldValue:n},bubbles:!0,composed:!0}))}get(t){return JSON.parse(this.data||"{}")[t]}has(t){return t in JSON.parse(this.data||"{}")}delete(t){const e=JSON.parse(this.data||"{}");if(!(t in e))return;const s=e[t];delete e[t],this.data=JSON.stringify(e),this.dispatchEvent(new CustomEvent("context:delete",{detail:{key:t,value:s},bubbles:!0,composed:!0}))}clear(){for(const t of Object.keys(JSON.parse(this.data||"{}")))this.delete(t)}getData(){return JSON.parse(this.data||"{}")}renderLightDom(){return html``}}customElements.define("k-context",Context);
@@ -1 +1 @@
1
- import LightComponent from"./LightComponent.js";import{html,render,unsafeHTML}from"../lit-all.min.js";export default class Import extends LightComponent{static properties={src:{type:String,reflect:!0},content:{type:String},scripts:{type:Array}};static replacements={};constructor(){super(),this.src="",this.content="",this.scripts=[]}updated(t){super.updated(),t.has("src")&&this.fetch(),t.has("content")&&this.scripts.length>0&&setTimeout(()=>this.executeScripts(),0)}async fetch(){let t=await(await fetch(this.src)).text();for(const[e,r]of Object.entries(Import.replacements))t=t.replace(new RegExp(`%%${e}%%`,"g"),r);const e=(new DOMParser).parseFromString(t,"text/html"),r=e.querySelectorAll("script");this.scripts=Array.from(r).map(t=>({src:t.src,text:t.textContent,type:t.type||"text/javascript"})),r.forEach(t=>t.remove()),this.content=e.body?e.body.innerHTML:t}executeScripts(){this.scripts.forEach(t=>{const e=document.createElement("script");e.type=t.type,t.src?e.src=t.src:t.text&&(e.textContent=t.text),document.head.appendChild(e)}),this.scripts=[]}renderLightDom(){return this.content?html`${unsafeHTML(this.content)}`:html``}}window.customElements.define("k-import",Import);
1
+ import LightComponent from"./LightComponent.js";import{html,render,unsafeHTML}from"../lit-all.min.js";export default class Import extends LightComponent{static properties={src:{type:String,reflect:!0},content:{type:String},scripts:{type:Array}};static replacements={};constructor(){super(),this.src="",this.content="",this.scripts=[]}updated(t){super.updated(),t.has("src")&&(void 0===t.get("src")&&""===this.src||this.dispatchEvent(new CustomEvent("src-change",{detail:{src:this.src},bubbles:!0,composed:!0})),this.fetch()),t.has("content")&&this.content&&setTimeout(()=>{this.scripts.length>0&&this.executeScripts(),this.dispatchEvent(new CustomEvent("content-rendered",{bubbles:!0,composed:!0}))},0)}async fetch(){if(!this.src)return;this.dispatchEvent(new CustomEvent("fetch-start",{detail:{src:this.src},bubbles:!0,composed:!0}));const t=await fetch(this.src);this.dispatchEvent(new CustomEvent("fetch-response",{detail:{src:this.src,status:t.status},bubbles:!0,composed:!0}));let e=await t.text();for(const[t,s]of Object.entries(Import.replacements))e=e.replace(new RegExp(`%%${t}%%`,"g"),s);const s=(new DOMParser).parseFromString(e,"text/html"),r=s.querySelectorAll("script");this.scripts=Array.from(r).map(t=>({src:t.src,text:t.textContent,type:t.type||"text/javascript"})),r.forEach(t=>t.remove()),this.content=s.body?s.body.innerHTML:e}executeScripts(){this.scripts.forEach(t=>{const e=document.createElement("script");e.type=t.type,t.src?e.src=t.src:t.text&&(e.textContent=t.text),document.head.appendChild(e)}),this.scripts=[]}renderLightDom(){return this.content?html`${unsafeHTML(this.content)}`:html``}}window.customElements.define("k-import",Import);
@@ -50,7 +50,8 @@
50
50
  </ul>
51
51
 
52
52
  <h3 id="levels"><a href="#levels" class="no-link">Elevation Levels</a></h3>
53
- <table class="mb">
53
+ <div class="table-wrapper mb">
54
+ <table>
54
55
  <thead>
55
56
  <tr>
56
57
  <th>Level</th>
@@ -72,10 +73,12 @@
72
73
  <tr><td>10</td><td>100</td><td><em>Reserved — use only when absolutely necessary</em></td></tr>
73
74
  </tbody>
74
75
  </table>
76
+ </div>
75
77
 
76
78
  <h3 id="components"><a href="#components" class="no-link">Component Assignments</a></h3>
77
79
  <p>Every Kempo UI component that uses <code>position: fixed</code> is assigned an elevation level:</p>
78
- <table class="mb">
80
+ <div class="table-wrapper mb">
81
+ <table>
79
82
  <thead>
80
83
  <tr>
81
84
  <th>Level</th>
@@ -123,6 +126,7 @@
123
126
  </tr>
124
127
  </tbody>
125
128
  </table>
129
+ </div>
126
130
  <p>
127
131
  The <strong>Aside</strong> component uses two different levels depending on its <code>main</code> attribute.
128
132
  When <code>main="push"</code>, it shifts the page layout and should sit <em>below</em> your site navbar (level 3).
package/icons/npm.svg ADDED
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M1.763 0C.786 0 0 .786 0 1.763v20.474C0 23.214.786 24 1.763 24h20.474c.977 0 1.763-.786 1.763-1.763V1.763C24 .786 23.214 0 22.237 0zM5.13 5.323l13.837.019-.009 13.836h-3.464l.01-10.382h-3.456L12.04 19.17H5.113z"/></svg>
package/llm.txt CHANGED
@@ -57,6 +57,7 @@ import { html, css, LitElement } from 'kempo-ui/src/lit-all.min.js';
57
57
  | `<k-card>` | `Card.js` | Bordered container; optional `label` attribute | [card.html](https://dustinpoissant.github.io/kempo-ui/components/card.html) |
58
58
  | `<k-color-picker>` | `ColorPicker.js` | Full color picker (hex, rgb, hsl, hsv, alpha); `value` property | [color-picker.html](https://dustinpoissant.github.io/kempo-ui/components/color-picker.html) |
59
59
  | `<k-content-slider>` | `ContentSlider.js` | Swipeable carousel | [content-slider.html](https://dustinpoissant.github.io/kempo-ui/components/content-slider.html) |
60
+ | `<k-context>` | `Context.js` | Non-rendering state container; `set(key,value)`, `get(key)`, `delete(key)`, `clear()`, `getData()`; fires `context:create`, `context:set`, `context:delete` | [context.html](https://dustinpoissant.github.io/kempo-ui/components/context.html) |
60
61
  | `<k-dialog>` | `Dialog.js` | Modal dialog with focus trapping; call `.open()` / `.close()` | [dialog.html](https://dustinpoissant.github.io/kempo-ui/components/dialog.html) |
61
62
  | `<k-dropdown>` | `Dropdown.js` | Action menu with keyboard navigation | [dropdown.html](https://dustinpoissant.github.io/kempo-ui/components/dropdown.html) |
62
63
  | `<k-filter-list>` `<k-filter-item>` | `FilterList.js` `FilterItem.js` | Filter/search list; items have `filter` attribute with keywords | [filter-list.html](https://dustinpoissant.github.io/kempo-ui/components/filter-list.html) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kempo-ui",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "type": "module",
5
5
  "description": "A Lit based web-component library",
6
6
  "main": "index.js",
@@ -0,0 +1,84 @@
1
+ /*
2
+ Context Component
3
+
4
+ A non-rendering state container for sharing data between sibling and descendant
5
+ components, similar in purpose to React's useContext. Place it as an ancestor
6
+ of components that need shared state; children locate it via closest('k-context').
7
+ */
8
+ import { html } from '../lit-all.min.js';
9
+ import LightComponent from './LightComponent.js';
10
+
11
+ export default class Context extends LightComponent {
12
+ static properties = {
13
+ data: { type: String, reflect: true }
14
+ };
15
+
16
+ constructor() {
17
+ super();
18
+ this.data = '{}';
19
+ }
20
+
21
+ /*
22
+ Lifecycle Callbacks
23
+ */
24
+ connectedCallback() {
25
+ super.connectedCallback();
26
+ this.style.display = 'contents';
27
+ }
28
+
29
+ /*
30
+ Public Methods
31
+ */
32
+ set(key, value) {
33
+ const obj = JSON.parse(this.data || '{}');
34
+ const isNew = !(key in obj);
35
+ const oldValue = obj[key];
36
+ obj[key] = value;
37
+ this.data = JSON.stringify(obj);
38
+ this.dispatchEvent(new CustomEvent(isNew ? 'context:create' : 'context:set', {
39
+ detail: isNew ? { key, value } : { key, value, oldValue },
40
+ bubbles: true,
41
+ composed: true
42
+ }));
43
+ }
44
+
45
+ get(key) {
46
+ return JSON.parse(this.data || '{}')[key];
47
+ }
48
+
49
+ has(key) {
50
+ return key in JSON.parse(this.data || '{}');
51
+ }
52
+
53
+ delete(key) {
54
+ const obj = JSON.parse(this.data || '{}');
55
+ if(!(key in obj)) return;
56
+ const value = obj[key];
57
+ delete obj[key];
58
+ this.data = JSON.stringify(obj);
59
+ this.dispatchEvent(new CustomEvent('context:delete', {
60
+ detail: { key, value },
61
+ bubbles: true,
62
+ composed: true
63
+ }));
64
+ }
65
+
66
+ clear() {
67
+ for(const key of Object.keys(JSON.parse(this.data || '{}'))){
68
+ this.delete(key);
69
+ }
70
+ }
71
+
72
+ getData() {
73
+ return JSON.parse(this.data || '{}');
74
+ }
75
+
76
+ /*
77
+ Rendering
78
+ */
79
+ renderLightDom() {
80
+ return html``;
81
+ }
82
+ }
83
+
84
+ customElements.define('k-context', Context);
@@ -29,14 +29,18 @@ export default class Import extends LightComponent {
29
29
  */
30
30
  updated(changedProperties){
31
31
  super.updated(); // Important: call super for LightComponent
32
-
32
+
33
33
  if(changedProperties.has('src')){
34
+ if(changedProperties.get('src') !== undefined || this.src !== '')
35
+ this.dispatchEvent(new CustomEvent('src-change', { detail: { src: this.src }, bubbles: true, composed: true }));
34
36
  this.fetch();
35
37
  }
36
-
37
- if(changedProperties.has('content') && this.scripts.length > 0){
38
- // Execute scripts after DOM content is rendered
39
- setTimeout(() => this.executeScripts(), 0);
38
+
39
+ if(changedProperties.has('content') && this.content){
40
+ setTimeout(() => {
41
+ if(this.scripts.length > 0) this.executeScripts();
42
+ this.dispatchEvent(new CustomEvent('content-rendered', { bubbles: true, composed: true }));
43
+ }, 0);
40
44
  }
41
45
  }
42
46
 
@@ -44,7 +48,11 @@ export default class Import extends LightComponent {
44
48
  Methods
45
49
  */
46
50
  async fetch(){
47
- let contents = await (await fetch(this.src)).text();
51
+ if(!this.src) return;
52
+ this.dispatchEvent(new CustomEvent('fetch-start', { detail: { src: this.src }, bubbles: true, composed: true }));
53
+ const response = await fetch(this.src);
54
+ this.dispatchEvent(new CustomEvent('fetch-response', { detail: { src: this.src, status: response.status }, bubbles: true, composed: true }));
55
+ let contents = await response.text();
48
56
  for (const [name, value] of Object.entries(Import.replacements)) {
49
57
  contents = contents.replace(new RegExp(`%%${name}%%`, 'g'), value);
50
58
  }
@@ -82,7 +90,7 @@ export default class Import extends LightComponent {
82
90
  // Append to head to execute
83
91
  document.head.appendChild(scriptElement);
84
92
  });
85
-
93
+
86
94
  // Clear scripts after execution to avoid re-execution
87
95
  this.scripts = [];
88
96
  }
@@ -0,0 +1,238 @@
1
+ import Context from '../../src/components/Context.js';
2
+ import LightComponent from '../../src/components/LightComponent.js';
3
+
4
+ const createContext = async () => {
5
+ const container = document.createElement('div');
6
+ container.innerHTML = `<k-context></k-context>`;
7
+ document.body.appendChild(container);
8
+ const el = container.querySelector('k-context');
9
+ await el.updateComplete;
10
+ return { container, el };
11
+ };
12
+
13
+ const cleanup = (container) => {
14
+ if(container && container.parentNode){
15
+ container.parentNode.removeChild(container);
16
+ }
17
+ };
18
+
19
+ export default {
20
+ /*
21
+ Element Creation
22
+ */
23
+ 'should create context element': async ({pass, fail}) => {
24
+ const { container, el } = await createContext();
25
+ if(!(el instanceof Context)){
26
+ cleanup(container);
27
+ return fail('Element should be instance of Context');
28
+ }
29
+ cleanup(container);
30
+ pass('Context element created correctly');
31
+ },
32
+
33
+ 'should extend LightComponent': async ({pass, fail}) => {
34
+ const { container, el } = await createContext();
35
+ if(!(el instanceof LightComponent)){
36
+ cleanup(container);
37
+ return fail('Context should extend LightComponent');
38
+ }
39
+ cleanup(container);
40
+ pass('Context extends LightComponent');
41
+ },
42
+
43
+ 'should have display contents': async ({pass, fail}) => {
44
+ const { container, el } = await createContext();
45
+ const display = getComputedStyle(el).display;
46
+ if(display !== 'contents'){
47
+ cleanup(container);
48
+ return fail(`Expected display to be contents, got "${display}"`);
49
+ }
50
+ cleanup(container);
51
+ pass('Context element has display contents');
52
+ },
53
+
54
+ 'should have default data of \'{}\'': async ({pass, fail}) => {
55
+ const { container, el } = await createContext();
56
+ if(el.data !== '{}'){
57
+ cleanup(container);
58
+ return fail(`Expected data to be '{}', got '${el.data}'`);
59
+ }
60
+ cleanup(container);
61
+ pass('Default data is \'{}\'');
62
+ },
63
+
64
+ 'data attribute reflects JSON state': async ({pass, fail}) => {
65
+ const { container, el } = await createContext();
66
+ el.set('count', 5);
67
+ await el.updateComplete;
68
+ let parsed;
69
+ try { parsed = JSON.parse(el.getAttribute('data')); } catch(e) {
70
+ cleanup(container);
71
+ return fail('data attribute is not valid JSON');
72
+ }
73
+ if(parsed.count !== 5){
74
+ cleanup(container);
75
+ return fail(`Expected data attribute to contain count:5, got ${el.getAttribute('data')}`);
76
+ }
77
+ cleanup(container);
78
+ pass('data attribute reflects JSON state');
79
+ },
80
+
81
+ /*
82
+ set() and get()
83
+ */
84
+ 'should set and get a value': async ({pass, fail}) => {
85
+ const { container, el } = await createContext();
86
+ el.set('user', { name: 'Alice' });
87
+ const value = el.get('user');
88
+ if(!value || value.name !== 'Alice'){
89
+ cleanup(container);
90
+ return fail(`Expected {name:'Alice'}, got ${JSON.stringify(value)}`);
91
+ }
92
+ cleanup(container);
93
+ pass('set and get work correctly');
94
+ },
95
+
96
+ /*
97
+ has()
98
+ */
99
+ 'has() returns true for existing key': async ({pass, fail}) => {
100
+ const { container, el } = await createContext();
101
+ el.set('theme', 'dark');
102
+ if(!el.has('theme')){
103
+ cleanup(container);
104
+ return fail('has() should return true for existing key');
105
+ }
106
+ cleanup(container);
107
+ pass('has() returns true for existing key');
108
+ },
109
+
110
+ 'has() returns false for missing key': async ({pass, fail}) => {
111
+ const { container, el } = await createContext();
112
+ if(el.has('missing')){
113
+ cleanup(container);
114
+ return fail('has() should return false for missing key');
115
+ }
116
+ cleanup(container);
117
+ pass('has() returns false for missing key');
118
+ },
119
+
120
+ /*
121
+ Events
122
+ */
123
+ 'set() fires context:create for new key': async ({pass, fail}) => {
124
+ const { container, el } = await createContext();
125
+ let received = null;
126
+ el.addEventListener('context:create', e => { received = e.detail; });
127
+ el.set('count', 0);
128
+ if(!received || received.key !== 'count' || received.value !== 0){
129
+ cleanup(container);
130
+ return fail(`Expected context:create with {key:'count',value:0}, got ${JSON.stringify(received)}`);
131
+ }
132
+ cleanup(container);
133
+ pass('context:create fired for new key');
134
+ },
135
+
136
+ 'set() fires context:set for existing key': async ({pass, fail}) => {
137
+ const { container, el } = await createContext();
138
+ el.set('count', 0);
139
+ let received = null;
140
+ el.addEventListener('context:set', e => { received = e.detail; });
141
+ el.set('count', 1);
142
+ if(!received || received.key !== 'count' || received.value !== 1 || received.oldValue !== 0){
143
+ cleanup(container);
144
+ return fail(`Expected context:set with {key:'count',value:1,oldValue:0}, got ${JSON.stringify(received)}`);
145
+ }
146
+ cleanup(container);
147
+ pass('context:set fired for existing key update');
148
+ },
149
+
150
+ 'set() does not fire context:create for second set': async ({pass, fail}) => {
151
+ const { container, el } = await createContext();
152
+ el.set('count', 0);
153
+ let createFired = false;
154
+ el.addEventListener('context:create', () => { createFired = true; });
155
+ el.set('count', 1);
156
+ if(createFired){
157
+ cleanup(container);
158
+ return fail('context:create should not fire when updating existing key');
159
+ }
160
+ cleanup(container);
161
+ pass('context:create not fired on update');
162
+ },
163
+
164
+ 'delete() removes a key and fires context:delete': async ({pass, fail}) => {
165
+ const { container, el } = await createContext();
166
+ el.set('temp', 42);
167
+ let received = null;
168
+ el.addEventListener('context:delete', e => { received = e.detail; });
169
+ el.delete('temp');
170
+ if(el.has('temp')){
171
+ cleanup(container);
172
+ return fail('Key should be removed after delete()');
173
+ }
174
+ if(!received || received.key !== 'temp' || received.value !== 42){
175
+ cleanup(container);
176
+ return fail(`Expected context:delete with {key:'temp',value:42}, got ${JSON.stringify(received)}`);
177
+ }
178
+ cleanup(container);
179
+ pass('delete() removes key and fires context:delete');
180
+ },
181
+
182
+ 'delete() on missing key does nothing': async ({pass, fail}) => {
183
+ const { container, el } = await createContext();
184
+ let fired = false;
185
+ el.addEventListener('context:delete', () => { fired = true; });
186
+ el.delete('nonexistent');
187
+ if(fired){
188
+ cleanup(container);
189
+ return fail('context:delete should not fire for missing key');
190
+ }
191
+ cleanup(container);
192
+ pass('delete() on missing key is a no-op');
193
+ },
194
+
195
+ /*
196
+ clear()
197
+ */
198
+ 'clear() removes all keys and fires context:delete for each': async ({pass, fail}) => {
199
+ const { container, el } = await createContext();
200
+ el.set('a', 1);
201
+ el.set('b', 2);
202
+ const deleted = [];
203
+ el.addEventListener('context:delete', e => deleted.push(e.detail.key));
204
+ el.clear();
205
+ if(Object.keys(el.getData()).length !== 0){
206
+ cleanup(container);
207
+ return fail('getData() should return empty object after clear()');
208
+ }
209
+ if(deleted.length !== 2 || !deleted.includes('a') || !deleted.includes('b')){
210
+ cleanup(container);
211
+ return fail(`Expected context:delete for both keys, got: ${JSON.stringify(deleted)}`);
212
+ }
213
+ cleanup(container);
214
+ pass('clear() removes all keys');
215
+ },
216
+
217
+ /*
218
+ getData()
219
+ */
220
+ 'getData() returns a copy of all data': async ({pass, fail}) => {
221
+ const { container, el } = await createContext();
222
+ el.set('x', 10);
223
+ el.set('y', 20);
224
+ const data = el.getData();
225
+ if(data.x !== 10 || data.y !== 20){
226
+ cleanup(container);
227
+ return fail(`Expected {x:10,y:20}, got ${JSON.stringify(data)}`);
228
+ }
229
+ // ensure it's a copy
230
+ data.x = 999;
231
+ if(el.get('x') !== 10){
232
+ cleanup(container);
233
+ return fail('getData() should return a copy, not a reference');
234
+ }
235
+ cleanup(container);
236
+ pass('getData() returns a shallow copy of all data');
237
+ }
238
+ };
@@ -308,5 +308,217 @@ export default {
308
308
  // LightComponent sets display: contents
309
309
  cleanup(container);
310
310
  pass('Import has appropriate display');
311
+ },
312
+
313
+ /*
314
+ Event Tests
315
+ */
316
+ 'should not dispatch src-change event on initialization with empty src': async ({pass, fail}) => {
317
+ let eventFired = false;
318
+ const container = document.createElement('div');
319
+ const importEl = document.createElement('k-import');
320
+ importEl.addEventListener('src-change', () => { eventFired = true; });
321
+ container.appendChild(importEl);
322
+ document.body.appendChild(container);
323
+ await importEl.updateComplete;
324
+
325
+ if(eventFired){
326
+ cleanup(container);
327
+ return fail('src-change should not fire during initialization with empty src');
328
+ }
329
+ cleanup(container);
330
+ pass('src-change not fired on initialization with empty src');
331
+ },
332
+
333
+ 'should dispatch src-change event when src is set': async ({pass, fail}) => {
334
+ const { container, importEl } = await createImport();
335
+ let eventFired = false;
336
+ let eventDetail = null;
337
+ importEl.addEventListener('src-change', e => {
338
+ eventFired = true;
339
+ eventDetail = e.detail;
340
+ });
341
+
342
+ importEl.src = '/some-file.html';
343
+ await importEl.updateComplete;
344
+
345
+ if(!eventFired){
346
+ cleanup(container);
347
+ return fail('src-change event should be fired when src changes');
348
+ }
349
+ if(eventDetail.src !== '/some-file.html'){
350
+ cleanup(container);
351
+ return fail(`Expected detail.src "/some-file.html", got "${eventDetail.src}"`);
352
+ }
353
+ cleanup(container);
354
+ pass('src-change event dispatched with correct detail');
355
+ },
356
+
357
+ 'src-change event should bubble': async ({pass, fail}) => {
358
+ const { container, importEl } = await createImport();
359
+ let bubbled = false;
360
+ container.addEventListener('src-change', () => { bubbled = true; });
361
+
362
+ importEl.src = '/bubble-test.html';
363
+ await importEl.updateComplete;
364
+
365
+ if(!bubbled){
366
+ cleanup(container);
367
+ return fail('src-change event should bubble');
368
+ }
369
+ cleanup(container);
370
+ pass('src-change event bubbles');
371
+ },
372
+
373
+ 'should dispatch fetch-start event when fetch begins': async ({pass, fail}) => {
374
+ const { container, importEl } = await createImport();
375
+ const originalFetch = window.fetch;
376
+ let fetchStartFired = false;
377
+ let fetchStartDetail = null;
378
+
379
+ importEl.addEventListener('fetch-start', e => {
380
+ fetchStartFired = true;
381
+ fetchStartDetail = e.detail;
382
+ });
383
+
384
+ window.fetch = () => Promise.resolve({ status: 200, text: () => Promise.resolve('<p>test</p>') });
385
+
386
+ importEl.src = '/mock-file.html';
387
+ await importEl.updateComplete;
388
+ await new Promise(resolve => setTimeout(resolve, 50));
389
+
390
+ window.fetch = originalFetch;
391
+
392
+ if(!fetchStartFired){
393
+ cleanup(container);
394
+ return fail('fetch-start event should be fired');
395
+ }
396
+ if(fetchStartDetail.src !== '/mock-file.html'){
397
+ cleanup(container);
398
+ return fail(`Expected detail.src "/mock-file.html", got "${fetchStartDetail.src}"`);
399
+ }
400
+ cleanup(container);
401
+ pass('fetch-start event dispatched with correct detail');
402
+ },
403
+
404
+ 'should dispatch fetch-response event after response is received': async ({pass, fail}) => {
405
+ const { container, importEl } = await createImport();
406
+ const originalFetch = window.fetch;
407
+ let fetchResponseFired = false;
408
+ let fetchResponseDetail = null;
409
+
410
+ importEl.addEventListener('fetch-response', e => {
411
+ fetchResponseFired = true;
412
+ fetchResponseDetail = e.detail;
413
+ });
414
+
415
+ window.fetch = () => Promise.resolve({ status: 200, text: () => Promise.resolve('<p>test</p>') });
416
+
417
+ importEl.src = '/mock-file.html';
418
+ await importEl.updateComplete;
419
+ await new Promise(resolve => setTimeout(resolve, 50));
420
+
421
+ window.fetch = originalFetch;
422
+
423
+ if(!fetchResponseFired){
424
+ cleanup(container);
425
+ return fail('fetch-response event should be fired');
426
+ }
427
+ if(fetchResponseDetail.src !== '/mock-file.html'){
428
+ cleanup(container);
429
+ return fail(`Expected detail.src "/mock-file.html", got "${fetchResponseDetail.src}"`);
430
+ }
431
+ if(fetchResponseDetail.status !== 200){
432
+ cleanup(container);
433
+ return fail(`Expected detail.status 200, got ${fetchResponseDetail.status}`);
434
+ }
435
+ cleanup(container);
436
+ pass('fetch-response event dispatched with correct detail');
437
+ },
438
+
439
+ 'fetch-start should fire before fetch-response': async ({pass, fail}) => {
440
+ const { container, importEl } = await createImport();
441
+ const originalFetch = window.fetch;
442
+ const order = [];
443
+
444
+ importEl.addEventListener('fetch-start', () => order.push('fetch-start'));
445
+ importEl.addEventListener('fetch-response', () => order.push('fetch-response'));
446
+
447
+ window.fetch = () => Promise.resolve({ status: 200, text: () => Promise.resolve('<p>test</p>') });
448
+
449
+ importEl.src = '/mock-file.html';
450
+ await importEl.updateComplete;
451
+ await new Promise(resolve => setTimeout(resolve, 50));
452
+
453
+ window.fetch = originalFetch;
454
+
455
+ if(order[0] !== 'fetch-start' || order[1] !== 'fetch-response'){
456
+ cleanup(container);
457
+ return fail(`Expected [fetch-start, fetch-response], got [${order.join(', ')}]`);
458
+ }
459
+ cleanup(container);
460
+ pass('fetch-start fires before fetch-response');
461
+ },
462
+
463
+ 'should dispatch content-rendered event after content is set': async ({pass, fail}) => {
464
+ const { container, importEl } = await createImport();
465
+ let eventFired = false;
466
+
467
+ importEl.addEventListener('content-rendered', () => { eventFired = true; });
468
+
469
+ importEl.content = '<p>rendered content</p>';
470
+ await importEl.updateComplete;
471
+ await new Promise(resolve => setTimeout(resolve, 50));
472
+
473
+ if(!eventFired){
474
+ cleanup(container);
475
+ return fail('content-rendered event should be fired after content changes');
476
+ }
477
+ cleanup(container);
478
+ pass('content-rendered event dispatched after content is set');
479
+ },
480
+
481
+ 'content-rendered event should bubble': async ({pass, fail}) => {
482
+ const { container, importEl } = await createImport();
483
+ let bubbled = false;
484
+ container.addEventListener('content-rendered', () => { bubbled = true; });
485
+
486
+ importEl.content = '<p>bubble test</p>';
487
+ await importEl.updateComplete;
488
+ await new Promise(resolve => setTimeout(resolve, 50));
489
+
490
+ if(!bubbled){
491
+ cleanup(container);
492
+ return fail('content-rendered event should bubble');
493
+ }
494
+ cleanup(container);
495
+ pass('content-rendered event bubbles');
496
+ },
497
+
498
+ 'should dispatch all four events in order during a full fetch cycle': async ({pass, fail}) => {
499
+ const { container, importEl } = await createImport();
500
+ const originalFetch = window.fetch;
501
+ const order = [];
502
+
503
+ importEl.addEventListener('src-change', () => order.push('src-change'));
504
+ importEl.addEventListener('fetch-start', () => order.push('fetch-start'));
505
+ importEl.addEventListener('fetch-response', () => order.push('fetch-response'));
506
+ importEl.addEventListener('content-rendered', () => order.push('content-rendered'));
507
+
508
+ window.fetch = () => Promise.resolve({ status: 200, text: () => Promise.resolve('<p>full cycle</p>') });
509
+
510
+ importEl.src = '/full-cycle.html';
511
+ await importEl.updateComplete;
512
+ await new Promise(resolve => setTimeout(resolve, 100));
513
+
514
+ window.fetch = originalFetch;
515
+
516
+ const expected = ['src-change', 'fetch-start', 'fetch-response', 'content-rendered'];
517
+ if(JSON.stringify(order) !== JSON.stringify(expected)){
518
+ cleanup(container);
519
+ return fail(`Expected [${expected.join(', ')}], got [${order.join(', ')}]`);
520
+ }
521
+ cleanup(container);
522
+ pass('All four events fire in correct order');
311
523
  }
312
524
  };