@easemate/web-kit 0.1.5 → 0.2.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.
Files changed (46) hide show
  1. package/README.md +360 -168
  2. package/build/elements/index.cjs +5 -2
  3. package/build/elements/index.d.cts +2 -1
  4. package/build/elements/index.d.ts +2 -1
  5. package/build/elements/index.js +2 -1
  6. package/build/elements/panel/index.cjs +496 -0
  7. package/build/elements/panel/index.d.cts +67 -0
  8. package/build/elements/panel/index.d.ts +67 -0
  9. package/build/elements/panel/index.js +492 -0
  10. package/build/elements/state/index.cjs +57 -464
  11. package/build/elements/state/index.d.cts +34 -25
  12. package/build/elements/state/index.d.ts +34 -25
  13. package/build/elements/state/index.js +59 -466
  14. package/build/internal/component-loaders.cjs +2 -0
  15. package/build/internal/component-loaders.d.cts +2 -2
  16. package/build/internal/component-loaders.d.ts +2 -2
  17. package/build/internal/component-loaders.js +2 -0
  18. package/build/react/events.cjs +25 -0
  19. package/build/react/events.d.cts +39 -0
  20. package/build/react/events.d.ts +39 -0
  21. package/build/react/events.js +22 -0
  22. package/build/react/index.cjs +19 -0
  23. package/build/react/index.d.cts +13 -0
  24. package/build/react/index.d.ts +13 -0
  25. package/build/react/index.js +12 -0
  26. package/build/react/provider.cjs +134 -0
  27. package/build/react/provider.d.cts +81 -0
  28. package/build/react/provider.d.ts +81 -0
  29. package/build/react/provider.js +98 -0
  30. package/build/react/types.cjs +8 -0
  31. package/build/react/types.d.cts +55 -0
  32. package/build/react/types.d.ts +55 -0
  33. package/build/react/types.js +7 -0
  34. package/build/react/use-ease-state.cjs +129 -0
  35. package/build/react/use-ease-state.d.cts +95 -0
  36. package/build/react/use-ease-state.d.ts +95 -0
  37. package/build/react/use-ease-state.js +126 -0
  38. package/build/react/use-web-kit.cjs +150 -0
  39. package/build/react/use-web-kit.d.cts +80 -0
  40. package/build/react/use-web-kit.d.ts +80 -0
  41. package/build/react/use-web-kit.js +114 -0
  42. package/build/register.cjs +1 -0
  43. package/build/register.d.cts +1 -0
  44. package/build/register.d.ts +1 -0
  45. package/build/register.js +1 -0
  46. package/package.json +15 -1
@@ -0,0 +1,67 @@
1
+ import { type TemplateResult } from 'lit-html';
2
+ /**
3
+ * Event detail for tab change events
4
+ */
5
+ export interface TabChangeEventDetail {
6
+ /** The index of the active tab */
7
+ index: number;
8
+ /** The tab id */
9
+ id: string;
10
+ /** The original event */
11
+ event: Event;
12
+ }
13
+ /**
14
+ * Panel component - visual container with optional tabs and header actions.
15
+ *
16
+ * Use this component when you want the panel UI without state management,
17
+ * or wrap it around `<ease-state>` for full functionality.
18
+ *
19
+ * @tag ease-panel
20
+ *
21
+ * @slot headline - Panel title text (hidden when tabs are present)
22
+ * @slot actions - Header action buttons, links, or dropdowns
23
+ * @slot - Default slot for main content
24
+ * @slot tab-{id} - Tab panel content (use `data-tab-label` for display name)
25
+ * @slot footer - Footer content below all panels
26
+ *
27
+ * @csspart section - Outer container
28
+ * @csspart header - Header row containing headline/tabs and actions
29
+ * @csspart headline - Title element
30
+ * @csspart tabs - Tab button container
31
+ * @csspart tab - Individual tab button
32
+ * @csspart actions - Actions container
33
+ * @csspart content - Content wrapper (handles height animations)
34
+ * @csspart body - Inner body container
35
+ * @csspart tab-panel - Individual tab panel
36
+ * @csspart footer - Footer container
37
+ *
38
+ * @fires tab-change - Fired when the active tab changes
39
+ */
40
+ export declare class Panel extends HTMLElement {
41
+ #private;
42
+ requestRender: () => void;
43
+ accessor activeTab: number;
44
+ /** @internal */
45
+ handleActiveTabChange(previous: number, next: number): void;
46
+ accessor contentElement: HTMLElement | null;
47
+ accessor bodyElement: HTMLElement | null;
48
+ /**
49
+ * Get the tab configuration
50
+ */
51
+ get tabs(): ReadonlyArray<{
52
+ id: string;
53
+ label: string;
54
+ }>;
55
+ /**
56
+ * Switch to a specific tab by index
57
+ * @param index - The tab index (0-based)
58
+ */
59
+ setTab(index: number): void;
60
+ connectedCallback(): void;
61
+ afterRender(): void;
62
+ render(): TemplateResult;
63
+ performTabAnimation(fromIndex: number, toIndex: number): Promise<void>;
64
+ onFooterSlotChange(): void;
65
+ onDefaultSlotChange(): void;
66
+ private updateFooterAttribute;
67
+ }
@@ -0,0 +1,492 @@
1
+ import { html, nothing } from 'lit-html';
2
+ import { setBooleanAttribute } from "../shared.js";
3
+ import { Component } from '../../decorators/Component';
4
+ import { Listen } from '../../decorators/Listen';
5
+ import { Prop } from '../../decorators/Prop';
6
+ import { Query } from '../../decorators/Query';
7
+ /**
8
+ * Panel component - visual container with optional tabs and header actions.
9
+ *
10
+ * Use this component when you want the panel UI without state management,
11
+ * or wrap it around `<ease-state>` for full functionality.
12
+ *
13
+ * @tag ease-panel
14
+ *
15
+ * @slot headline - Panel title text (hidden when tabs are present)
16
+ * @slot actions - Header action buttons, links, or dropdowns
17
+ * @slot - Default slot for main content
18
+ * @slot tab-{id} - Tab panel content (use `data-tab-label` for display name)
19
+ * @slot footer - Footer content below all panels
20
+ *
21
+ * @csspart section - Outer container
22
+ * @csspart header - Header row containing headline/tabs and actions
23
+ * @csspart headline - Title element
24
+ * @csspart tabs - Tab button container
25
+ * @csspart tab - Individual tab button
26
+ * @csspart actions - Actions container
27
+ * @csspart content - Content wrapper (handles height animations)
28
+ * @csspart body - Inner body container
29
+ * @csspart tab-panel - Individual tab panel
30
+ * @csspart footer - Footer container
31
+ *
32
+ * @fires tab-change - Fired when the active tab changes
33
+ */
34
+ @Component({
35
+ tag: 'ease-panel',
36
+ shadowMode: 'open',
37
+ styles: `
38
+ :host {
39
+ --ease-panel-transition-duration: 120ms;
40
+ --ease-panel-transition-easing: cubic-bezier(.25, 0, .5, 1);
41
+ }
42
+
43
+ [part="section"] {
44
+ display: block;
45
+ width: 100%;
46
+ max-width: var(--ease-panel-max-width, 332px);
47
+ border-radius: var(--ease-panel-radius, 12px);
48
+ border: 1px solid var(--ease-panel-border-color, var(--color-white-6));
49
+ background-clip: padding-box;
50
+ background: var(--ease-panel-background, var(--color-gray-1000));
51
+ box-shadow: var(--ease-panel-shadow, 0 0 40px 0 var(--color-white-2) inset);
52
+ box-sizing: border-box;
53
+ padding: var(--ease-panel-padding, 12px);
54
+ margin: auto;
55
+ }
56
+
57
+ [part="header"] {
58
+ display: flex;
59
+ align-items: center;
60
+ gap: 8px;
61
+ width: 100%;
62
+ margin-bottom: 12px;
63
+ }
64
+
65
+ [part="header"]:not(:has([part="headline"] slot[name="headline"]::slotted(*))):not(:has([part="tabs"]:not(:empty))):not(:has([part="actions"] slot[name="actions"]::slotted(*))) {
66
+ display: none;
67
+ margin-bottom: 0;
68
+ }
69
+
70
+ [part="headline"] {
71
+ font-size: var(--ease-panel-title-font-size, 14px);
72
+ font-weight: var(--ease-panel-title-font-weight, 500);
73
+ line-height: var(--ease-panel-title-line-height, 24px);
74
+ font-family: var(--ease-font-family, "Instrument Sans", sans-serif);
75
+ color: var(--ease-panel-title-color, var(--color-blue-100));
76
+ margin: 0 0 0 4px;
77
+ flex-grow: 1;
78
+ text-ellipsis: ellipsis;
79
+ overflow: hidden;
80
+ white-space: nowrap;
81
+ }
82
+
83
+ [part="headline"]:has(+ [part="tabs"]:not(:empty)) {
84
+ display: none;
85
+ }
86
+
87
+ [part="tabs"] {
88
+ display: flex;
89
+ align-items: center;
90
+ gap: 2px;
91
+ flex-grow: 1;
92
+ margin: 0 0 0 4px;
93
+ }
94
+
95
+ [part="tabs"]:empty {
96
+ display: none;
97
+ }
98
+
99
+ [part="tab"] {
100
+ appearance: none;
101
+ font-size: var(--ease-panel-tab-font-size, 13px);
102
+ font-weight: var(--ease-panel-tab-font-weight, 500);
103
+ line-height: var(--ease-panel-tab-line-height, 24px);
104
+ font-family: var(--ease-font-family, "Instrument Sans", sans-serif);
105
+ color: var(--ease-panel-tab-color, var(--color-gray-600));
106
+ background: transparent;
107
+ border: none;
108
+ padding: 4px 8px;
109
+ margin: 0;
110
+ cursor: pointer;
111
+ border-radius: var(--ease-panel-tab-radius, 6px);
112
+ transition: color 0.2s, background-color 0.2s;
113
+ }
114
+
115
+ [part="tab"]:hover {
116
+ color: var(--ease-panel-tab-color-hover, var(--color-blue-100));
117
+ }
118
+
119
+ [part="tab"][aria-selected="true"] {
120
+ color: var(--ease-panel-tab-color-active, var(--color-blue-100));
121
+ background: var(--ease-panel-tab-background-active, var(--color-white-4));
122
+ }
123
+
124
+ [part="actions"] {
125
+ display: flex;
126
+ align-items: center;
127
+ gap: 4px;
128
+ margin-left: auto;
129
+ }
130
+
131
+ slot[name="actions"]::slotted(button),
132
+ slot[name="actions"]::slotted(a) {
133
+ --ease-icon-size: var(--ease-panel-action-icon-size, 16px);
134
+
135
+ appearance: none;
136
+ flex: 0 0 24px;
137
+ border: none;
138
+ outline: none;
139
+ background-color: transparent;
140
+ padding: 4px;
141
+ margin: 0;
142
+ cursor: pointer;
143
+ color: var(--color-gray-600);
144
+ transition: color 0.2s;
145
+ text-decoration: none;
146
+ display: flex;
147
+ align-items: center;
148
+ justify-content: center;
149
+ }
150
+
151
+ slot[name="actions"]::slotted(button:hover),
152
+ slot[name="actions"]::slotted(button:focus-visible),
153
+ slot[name="actions"]::slotted(a:hover),
154
+ slot[name="actions"]::slotted(a:focus-visible) {
155
+ color: var(--color-blue-100);
156
+ }
157
+
158
+ slot[name="actions"]::slotted(ease-dropdown) {
159
+ flex: 0 0 auto;
160
+ width: auto;
161
+
162
+ --ease-icon-size: var(--ease-panel-action-icon-size, 16px);
163
+ --ease-dropdown-trigger-padding: 4px;
164
+ --ease-dropdown-radius: 6px;
165
+ --ease-dropdown-background: transparent;
166
+ --ease-dropdown-background-hover: transparent;
167
+ --ease-dropdown-shadow: none;
168
+ --ease-dropdown-color: var(--color-gray-600);
169
+ --ease-popover-placement: bottom-end;
170
+ }
171
+
172
+ slot[name="actions"]::slotted(ease-dropdown:hover),
173
+ slot[name="actions"]::slotted(ease-dropdown:focus-within) {
174
+ --ease-dropdown-color: var(--color-blue-100);
175
+ }
176
+
177
+ [part="content"] {
178
+ display: block;
179
+ width: 100%;
180
+ box-sizing: border-box;
181
+ margin: auto;
182
+ overflow: hidden;
183
+ }
184
+
185
+ [part="content"][data-animating="true"] {
186
+ transition: height var(--ease-panel-transition-duration) var(--ease-panel-transition-easing);
187
+ }
188
+
189
+ [part="body"] {
190
+ width: 100%;
191
+ position: relative;
192
+ }
193
+
194
+ [part="tab-panel"] {
195
+ width: 100%;
196
+ pointer-events: none;
197
+ display: none;
198
+ }
199
+
200
+ [part="tab-panel"][data-state="active"] {
201
+ display: block;
202
+ pointer-events: auto;
203
+ }
204
+
205
+ [part="tab-panel"][data-state="hidden"] {
206
+ display: none;
207
+ pointer-events: none;
208
+ }
209
+
210
+ [part="footer"] {
211
+ display: flex;
212
+ align-items: center;
213
+ justify-content: space-between;
214
+ width: 100%;
215
+ padding: var(--ease-panel-footer-padding, 12px);
216
+ box-sizing: border-box;
217
+ border-top: 1px solid var(--color-white-4);
218
+
219
+ &:not(:has([data-has-content="true"])) {
220
+ display: none;
221
+ }
222
+ }
223
+
224
+ ::slotted(:not([slot])),
225
+ ::slotted([slot^="tab-"]) {
226
+ display: grid;
227
+ gap: 12px;
228
+ box-sizing: border-box;
229
+ width: 100%;
230
+ }
231
+ `
232
+ })
233
+ export class Panel extends HTMLElement {
234
+ #tabs = [];
235
+ #isAnimating = false;
236
+ @Prop({
237
+ type: Number,
238
+ reflect: true,
239
+ attribute: 'active-tab',
240
+ defaultValue: 0,
241
+ onChange(next, previous) {
242
+ const self = this;
243
+ if (next !== previous && previous !== undefined) {
244
+ self.handleActiveTabChange(previous, next);
245
+ }
246
+ }
247
+ })
248
+ accessor activeTab = 0;
249
+ /** @internal */
250
+ handleActiveTabChange(previous, next) {
251
+ this.performTabAnimation(previous, next);
252
+ }
253
+ @Query('[part="content"]')
254
+ accessor contentElement;
255
+ @Query('[part="body"]')
256
+ accessor bodyElement;
257
+ /**
258
+ * Get the tab configuration
259
+ */
260
+ get tabs() {
261
+ return this.#tabs;
262
+ }
263
+ /**
264
+ * Switch to a specific tab by index
265
+ * @param index - The tab index (0-based)
266
+ */
267
+ setTab(index) {
268
+ if (index >= 0 && index < this.#tabs.length && index !== this.activeTab) {
269
+ this.activeTab = index;
270
+ }
271
+ }
272
+ connectedCallback() {
273
+ this.#syncTabs();
274
+ }
275
+ afterRender() {
276
+ this.#syncTabs();
277
+ }
278
+ render() {
279
+ const hasTabs = this.#tabs.length > 0;
280
+ return html `
281
+ <section part="section">
282
+ <div part="header">
283
+ <h3 part="headline"><slot name="headline"></slot></h3>
284
+ ${this.#renderTabs()}
285
+ <div part="actions">
286
+ <slot name="actions"></slot>
287
+ </div>
288
+ </div>
289
+ <div part="content">
290
+ <div part="body">
291
+ ${hasTabs ? this.#renderTabPanels() : html `<slot></slot>`}
292
+ </div>
293
+ </div>
294
+ <div part="footer">
295
+ <slot name="footer"></slot>
296
+ </div>
297
+ </section>
298
+ `;
299
+ }
300
+ #renderTabs() {
301
+ if (this.#tabs.length === 0) {
302
+ return nothing;
303
+ }
304
+ return html `
305
+ <div part="tabs" role="tablist">
306
+ ${this.#tabs.map((tab, index) => html `
307
+ <button
308
+ part="tab"
309
+ role="tab"
310
+ aria-selected=${index === this.activeTab ? 'true' : 'false'}
311
+ aria-controls=${`panel-${tab.id}`}
312
+ tabindex=${index === this.activeTab ? 0 : -1}
313
+ @click=${(e) => this.#handleTabClick(index, tab.id, e)}
314
+ @keydown=${(e) => this.#handleTabKeydown(e, index)}
315
+ >
316
+ ${tab.label}
317
+ </button>
318
+ `)}
319
+ </div>
320
+ `;
321
+ }
322
+ #renderTabPanels() {
323
+ return html `
324
+ ${this.#tabs.map((tab, index) => html `
325
+ <div
326
+ part="tab-panel"
327
+ role="tabpanel"
328
+ id=${`panel-${tab.id}`}
329
+ aria-labelledby=${`tab-${tab.id}`}
330
+ data-state=${index === this.activeTab ? 'active' : 'hidden'}
331
+ data-index=${index}
332
+ >
333
+ <slot name=${`tab-${tab.id}`}></slot>
334
+ </div>
335
+ `)}
336
+ `;
337
+ }
338
+ #handleTabClick(index, id, event) {
339
+ if (index === this.activeTab) {
340
+ return;
341
+ }
342
+ this.activeTab = index;
343
+ this.dispatchEvent(new CustomEvent('tab-change', {
344
+ detail: { index, id, event },
345
+ bubbles: true,
346
+ composed: true
347
+ }));
348
+ }
349
+ #handleTabKeydown(event, currentIndex) {
350
+ let newIndex = currentIndex;
351
+ switch (event.key) {
352
+ case 'ArrowLeft':
353
+ event.preventDefault();
354
+ newIndex = currentIndex > 0 ? currentIndex - 1 : this.#tabs.length - 1;
355
+ break;
356
+ case 'ArrowRight':
357
+ event.preventDefault();
358
+ newIndex = currentIndex < this.#tabs.length - 1 ? currentIndex + 1 : 0;
359
+ break;
360
+ case 'Home':
361
+ event.preventDefault();
362
+ newIndex = 0;
363
+ break;
364
+ case 'End':
365
+ event.preventDefault();
366
+ newIndex = this.#tabs.length - 1;
367
+ break;
368
+ default:
369
+ return;
370
+ }
371
+ if (newIndex !== currentIndex) {
372
+ this.activeTab = newIndex;
373
+ // Focus the new tab button
374
+ queueMicrotask(() => {
375
+ const tabButtons = this.shadowRoot?.querySelectorAll('[part="tab"]');
376
+ const newTabButton = tabButtons?.[newIndex];
377
+ newTabButton?.focus();
378
+ });
379
+ }
380
+ }
381
+ async performTabAnimation(fromIndex, toIndex) {
382
+ if (this.#isAnimating) {
383
+ return;
384
+ }
385
+ this.#isAnimating = true;
386
+ const duration = 120;
387
+ const easing = 'cubic-bezier(.25, 0, .5, 1)';
388
+ const content = this.contentElement;
389
+ if (!content) {
390
+ this.#isAnimating = false;
391
+ this.requestRender();
392
+ return;
393
+ }
394
+ // Get the panels by data-index attribute for reliability
395
+ const fromPanel = this.shadowRoot?.querySelector(`[part="tab-panel"][data-index="${fromIndex}"]`);
396
+ const toPanel = this.shadowRoot?.querySelector(`[part="tab-panel"][data-index="${toIndex}"]`);
397
+ if (!fromPanel || !toPanel) {
398
+ this.#isAnimating = false;
399
+ this.requestRender();
400
+ return;
401
+ }
402
+ // Lock the current height
403
+ const startHeight = content.getBoundingClientRect().height;
404
+ content.style.height = `${startHeight}px`;
405
+ // FIX: Ensure the new panel is hidden immediately.
406
+ toPanel.style.display = 'none';
407
+ toPanel.style.opacity = '0';
408
+ // Fade out old content via WAAPI
409
+ try {
410
+ const fadeOut = fromPanel.animate([{ opacity: 1 }, { opacity: 0 }], { duration, easing, fill: 'forwards' });
411
+ await fadeOut.finished;
412
+ fadeOut.cancel();
413
+ }
414
+ catch {
415
+ // ignore
416
+ }
417
+ fromPanel.setAttribute('data-state', 'hidden');
418
+ // Prepare and measure new panel while completely invisible
419
+ const previousToState = toPanel.getAttribute('data-state');
420
+ toPanel.style.display = 'block';
421
+ toPanel.style.visibility = 'hidden';
422
+ toPanel.style.opacity = '0';
423
+ // Force layout, then measure
424
+ void toPanel.offsetHeight;
425
+ const endHeight = toPanel.getBoundingClientRect().height;
426
+ // Animate height
427
+ if (startHeight !== endHeight) {
428
+ content.setAttribute('data-animating', 'true');
429
+ void content.offsetHeight;
430
+ content.style.height = `${endHeight}px`;
431
+ await this.#wait(duration);
432
+ }
433
+ // Show panel but keep opacity at 0, then fade in
434
+ toPanel.style.visibility = 'visible';
435
+ toPanel.style.opacity = '0';
436
+ void toPanel.offsetHeight;
437
+ try {
438
+ const fadeIn = toPanel.animate([{ opacity: 0 }, { opacity: 1 }], { duration, easing, fill: 'forwards' });
439
+ await fadeIn.finished;
440
+ fadeIn.cancel();
441
+ }
442
+ catch {
443
+ // ignore
444
+ }
445
+ // Finalize new tab state and cleanup
446
+ toPanel.style.display = '';
447
+ toPanel.style.visibility = '';
448
+ toPanel.style.opacity = '';
449
+ if (previousToState !== 'active') {
450
+ toPanel.setAttribute('data-state', 'active');
451
+ }
452
+ content.style.height = '';
453
+ content.removeAttribute('data-animating');
454
+ this.#isAnimating = false;
455
+ }
456
+ #wait(ms) {
457
+ return new Promise((resolve) => setTimeout(resolve, ms));
458
+ }
459
+ #syncTabs() {
460
+ const tabs = [];
461
+ for (const child of Array.from(this.children)) {
462
+ const slot = child.getAttribute('slot');
463
+ if (slot?.startsWith('tab-')) {
464
+ const id = slot.replace('tab-', '');
465
+ const label = child.getAttribute('data-tab-label') || id;
466
+ tabs.push({ id, label });
467
+ }
468
+ }
469
+ this.#tabs = tabs.slice(0, 3);
470
+ if (this.activeTab >= this.#tabs.length && this.#tabs.length > 0) {
471
+ this.activeTab = 0;
472
+ }
473
+ }
474
+ @Listen('slotchange', { selector: 'slot[name="footer"]' })
475
+ onFooterSlotChange() {
476
+ this.updateFooterAttribute();
477
+ }
478
+ @Listen('slotchange', { selector: 'slot:not([name])' })
479
+ onDefaultSlotChange() {
480
+ this.#syncTabs();
481
+ this.requestRender();
482
+ }
483
+ updateFooterAttribute() {
484
+ const footer = this.shadowRoot?.querySelector('[part="footer"]');
485
+ if (!footer) {
486
+ return;
487
+ }
488
+ const footerSlot = this.shadowRoot?.querySelector('slot[name="footer"]');
489
+ const hasFooter = Boolean(footerSlot?.assignedNodes({ flatten: true }).length > 0);
490
+ setBooleanAttribute(footer, 'data-has-content', hasFooter);
491
+ }
492
+ }