@window-splitter/web-component 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,660 @@
1
+ import {
2
+ groupMachine,
3
+ initializePanel,
4
+ initializePanelHandleData,
5
+ buildTemplate,
6
+ getCursor,
7
+ PanelHandleData,
8
+ SendFn,
9
+ GroupMachineContextValue,
10
+ Unit,
11
+ PixelUnit,
12
+ isPanelData,
13
+ getGroupSize,
14
+ PanelData,
15
+ Orientation,
16
+ prepareSnapshot,
17
+ getCollapsiblePanelForHandleId,
18
+ OnResizeCallback,
19
+ getPanelPixelSize,
20
+ getPanelPercentageSize,
21
+ getPanelGroupPixelSizes,
22
+ getPanelGroupPercentageSizes,
23
+ prepareItems,
24
+ State,
25
+ haveConstraintsChangedForPanel,
26
+ haveConstraintsChangedForPanelHandle,
27
+ } from "@window-splitter/state";
28
+ import { html, LitElement, PropertyValues } from "lit";
29
+ import { property } from "lit/decorators.js";
30
+ import {
31
+ getPanelDomAttributes,
32
+ getPanelResizerDomAttributes,
33
+ move,
34
+ MoveEvents,
35
+ SharedPanelProps,
36
+ } from "@window-splitter/interface";
37
+ import { consume, createContext, provide } from "@lit/context";
38
+
39
+ const isPrerenderContext = createContext<boolean>("isPrerender");
40
+ const sendContext = createContext<SendFn>("send");
41
+ const contextContext = createContext<GroupMachineContextValue>("context");
42
+ const stateContext = createContext<{ current: State }>("state");
43
+
44
+ let _id = 0;
45
+
46
+ const getNextId = () => {
47
+ _id++;
48
+ return `${_id}`;
49
+ };
50
+
51
+ function updateAttributes(
52
+ element: HTMLElement,
53
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
54
+ attributes: Record<string, any> | { style: Record<string, any> }
55
+ ) {
56
+ for (const attr of Object.keys(attributes)) {
57
+ if (attr === "style") {
58
+ for (const style of Object.keys(attributes[attr])) {
59
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
60
+ (element.style as any)[style] = attributes[attr][style];
61
+ }
62
+ } else {
63
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
64
+ element.setAttribute(attr, (attributes as any)[attr]);
65
+ }
66
+ }
67
+ }
68
+
69
+ function isBooleanPropValue(value: string | null) {
70
+ if (value === "true" || value === "") return true;
71
+ if (value === "false") return false;
72
+ return undefined;
73
+ }
74
+
75
+ export class PanelGroup extends LitElement {
76
+ private observer: ResizeObserver;
77
+ private cleanupChildrenObserver: () => void;
78
+ private groupId: string;
79
+
80
+ @provide({ context: contextContext })
81
+ private context: GroupMachineContextValue;
82
+ @provide({ context: sendContext })
83
+ private send: SendFn;
84
+ @provide({ context: isPrerenderContext })
85
+ private isPrerender = true;
86
+ @provide({ context: stateContext })
87
+ private state: { current: State };
88
+
89
+ constructor() {
90
+ super();
91
+
92
+ this.observer = new ResizeObserver(() => {});
93
+ this.cleanupChildrenObserver = () => {};
94
+
95
+ const autosaveId = this.getAttribute("autosaveId");
96
+ const autosaveStrategy =
97
+ (this.getAttribute(
98
+ "autosaveStrategy"
99
+ ) as GroupMachineContextValue["autosaveStrategy"]) || "localStorage";
100
+
101
+ this.groupId = this.getAttribute("id") || autosaveId || getNextId();
102
+
103
+ let snapshot = this.getAttribute("snapshot");
104
+
105
+ if (
106
+ !snapshot &&
107
+ typeof window !== "undefined" &&
108
+ autosaveId &&
109
+ autosaveStrategy === "localStorage"
110
+ ) {
111
+ const localSnapshot = localStorage.getItem(autosaveId);
112
+
113
+ if (localSnapshot) {
114
+ snapshot = localSnapshot;
115
+ }
116
+ }
117
+
118
+ const [initialState, send, machineState] = groupMachine(
119
+ {
120
+ orientation:
121
+ (this.getAttribute("orientation") as Orientation) || "horizontal",
122
+ groupId: this.groupId,
123
+ autosaveStrategy,
124
+ ...(snapshot ? prepareSnapshot(JSON.parse(snapshot)) : undefined),
125
+ },
126
+ (s) => {
127
+ const oldContext = this.context;
128
+ this.context = s;
129
+
130
+ if (oldContext.items.length !== s.items.length) {
131
+ this.cleanupChildrenObserver?.();
132
+ this.measureChildren();
133
+ }
134
+
135
+ this.requestUpdate();
136
+ }
137
+ );
138
+
139
+ this.state = machineState;
140
+ this.send = send;
141
+ this.context = initialState;
142
+ }
143
+
144
+ public getId() {
145
+ return this.groupId;
146
+ }
147
+
148
+ public getPixelSizes() {
149
+ return getPanelGroupPixelSizes(this.context);
150
+ }
151
+
152
+ public getPercentageSizes() {
153
+ return getPanelGroupPercentageSizes(this.context);
154
+ }
155
+
156
+ public getTemplate() {
157
+ return buildTemplate({
158
+ ...this.context,
159
+ items: prepareItems(this.context),
160
+ });
161
+ }
162
+
163
+ public getState() {
164
+ return this.state.current === "idle" ? "idle" : "dragging";
165
+ }
166
+
167
+ public setSizes(updates: Unit[]) {
168
+ for (let index = 0; index < updates.length; index++) {
169
+ const item = this.context.items[index];
170
+ const update = updates[index];
171
+
172
+ if (item && isPanelData(item) && update) {
173
+ this.send({
174
+ type: "setPanelPixelSize",
175
+ panelId: item.id,
176
+ size: update,
177
+ });
178
+ }
179
+ }
180
+ }
181
+
182
+ private measureSize() {
183
+ if (this.observer) {
184
+ this.observer.disconnect();
185
+ }
186
+
187
+ this.observer = new ResizeObserver(([entry]) => {
188
+ if (!entry) return;
189
+ if (entry.contentRect.width > 0 && entry.contentRect.height > 0) {
190
+ this.send({ type: "setSize", size: entry.contentRect });
191
+ this.measureChildren();
192
+ this.isPrerender = false;
193
+ }
194
+ });
195
+
196
+ this.observer.observe(this.renderRoot.querySelector("div")!);
197
+ }
198
+
199
+ private measureChildren() {
200
+ if (this.cleanupChildrenObserver) {
201
+ this.cleanupChildrenObserver();
202
+ }
203
+
204
+ const childrenObserver = new ResizeObserver((childrenEntries) => {
205
+ const childrenSizes: Record<string, { width: number; height: number }> =
206
+ {};
207
+
208
+ for (const childEntry of childrenEntries) {
209
+ const child = childEntry.target as HTMLElement;
210
+ const childId = child.getAttribute("data-splitter-id");
211
+ const childSize = childEntry.borderBoxSize[0];
212
+
213
+ if (childId && childSize) {
214
+ childrenSizes[childId] = {
215
+ width: childSize.inlineSize,
216
+ height: childSize.blockSize,
217
+ };
218
+ }
219
+ }
220
+
221
+ this.send({ type: "setActualItemsSize", childrenSizes });
222
+ });
223
+
224
+ if (!this.shadowRoot) return;
225
+
226
+ const slot = this.shadowRoot.querySelector("slot");
227
+ if (!slot) return;
228
+
229
+ const elements = slot.assignedElements();
230
+
231
+ for (const child of elements) {
232
+ childrenObserver.observe(child);
233
+ }
234
+
235
+ this.cleanupChildrenObserver = () => {
236
+ childrenObserver.disconnect();
237
+ };
238
+ }
239
+
240
+ firstUpdated() {
241
+ this.send({ type: "unlockGroup" });
242
+
243
+ if (this.observer) {
244
+ this.observer.disconnect();
245
+ }
246
+
247
+ this.measureSize();
248
+ this.setAttribute("data-panel-group-wrapper", "");
249
+ this.setAttribute("id", this.groupId);
250
+ }
251
+
252
+ disconnectedCallback() {
253
+ this.observer?.disconnect();
254
+ this.cleanupChildrenObserver?.();
255
+ this.send({ type: "lockGroup" });
256
+ }
257
+
258
+ render() {
259
+ const template =
260
+ this.context.orientation === "horizontal"
261
+ ? `grid-template-columns: ${buildTemplate(this.context)};`
262
+ : `grid-template-rows: ${buildTemplate(this.context)}`;
263
+
264
+ return html`
265
+ <div style="display: grid;height: 100%;${template}">
266
+ <slot></slot>
267
+ </div>
268
+ `;
269
+ }
270
+ }
271
+
272
+ export class Panel extends LitElement {
273
+ static observedContexts = ["send"];
274
+
275
+ public onCollapseChange?: (newCollapsed: boolean, el: Panel) => void;
276
+ public onResize?: OnResizeCallback;
277
+ public collapseAnimation?: SharedPanelProps<boolean>["collapseAnimation"];
278
+
279
+ @property({ type: Boolean, reflect: true })
280
+ collapsed?: boolean;
281
+ @property({ type: String, reflect: true })
282
+ min?: string;
283
+ @property({ type: String, reflect: true })
284
+ max?: string;
285
+ @property({ type: String, reflect: true })
286
+ default?: string;
287
+ @property({ type: String, reflect: true })
288
+ collapsible?: string;
289
+ @property({ type: String, reflect: true })
290
+ defaultCollapsed?: string;
291
+ @property({ type: String, reflect: true })
292
+ collapsedSize?: string;
293
+ @property({ type: String, reflect: true })
294
+ isStaticAtRest?: string;
295
+
296
+ @consume({ context: contextContext })
297
+ @property({ attribute: false })
298
+ public context: GroupMachineContextValue;
299
+
300
+ @consume({ context: sendContext })
301
+ @property({ attribute: false })
302
+ public send: SendFn;
303
+
304
+ @consume({ context: isPrerenderContext })
305
+ @property({ attribute: false })
306
+ public isPrerender = true;
307
+
308
+ id: string;
309
+
310
+ constructor() {
311
+ super();
312
+
313
+ this.id ||= getNextId();
314
+ this.send = () => {};
315
+ this.context = {} as GroupMachineContextValue;
316
+ }
317
+
318
+ public collapse() {
319
+ if (!this.getPanelData()?.collapsible) return;
320
+ this.send({ type: "collapsePanel", panelId: this.id, controlled: true });
321
+ }
322
+
323
+ public isCollapsed() {
324
+ return Boolean(
325
+ this.getPanelData()?.collapsible && this.getPanelData()?.collapsed
326
+ );
327
+ }
328
+
329
+ public expand() {
330
+ if (!this.getPanelData()?.collapsible) return;
331
+ this.send({ type: "expandPanel", panelId: this.id, controlled: true });
332
+ }
333
+
334
+ public isExpanded() {
335
+ return Boolean(
336
+ this.getPanelData()?.collapsible && !this.getPanelData()?.collapsed
337
+ );
338
+ }
339
+
340
+ public getPixelSize() {
341
+ return getPanelPixelSize(this.context, this.id);
342
+ }
343
+
344
+ public getPercentageSize() {
345
+ return getPanelPercentageSize(this.context, this.id);
346
+ }
347
+
348
+ public setSize(size: Unit) {
349
+ this.send({ type: "setPanelPixelSize", panelId: this.id, size });
350
+ }
351
+
352
+ private initPanel() {
353
+ return initializePanel({
354
+ id: this.id,
355
+ min: this.getAttribute("min") as Unit | undefined,
356
+ max: this.getAttribute("max") as Unit | undefined,
357
+ collapsible: isBooleanPropValue(this.getAttribute("collapsible")),
358
+ collapsed: isBooleanPropValue(this.getAttribute("collapsed")),
359
+ collapsedSize: this.getAttribute("collapsedSize") as Unit | undefined,
360
+ onCollapseChange: this.onCollapseChange
361
+ ? { current: (e) => this.onCollapseChange?.(e, this) }
362
+ : undefined,
363
+ collapseAnimation: this.collapseAnimation,
364
+ onResize: this.onResize ? { current: this.onResize } : undefined,
365
+ defaultCollapsed: isBooleanPropValue(
366
+ this.getAttribute("defaultCollapsed")
367
+ ),
368
+ default: this.getAttribute("default") as Unit | undefined,
369
+ isStaticAtRest: isBooleanPropValue(this.getAttribute("isStaticAtRest")),
370
+ });
371
+ }
372
+
373
+ private getPanelData() {
374
+ return this.context?.items.find((item) => item.id === this.id) as
375
+ | PanelData
376
+ | undefined;
377
+ }
378
+
379
+ private getAttributes() {
380
+ return {
381
+ ...getPanelDomAttributes({
382
+ groupId: this.context.groupId,
383
+ id: this.id,
384
+ collapsible: this.getAttribute("collapsible") === "true",
385
+ collapsed:
386
+ this.getAttribute("collapsed") === "true"
387
+ ? true
388
+ : this.getAttribute("collapsed") === "false"
389
+ ? false
390
+ : undefined,
391
+ }),
392
+ style: {
393
+ minWidth: 0,
394
+ minHeight: 0,
395
+ overflow: "hidden",
396
+ },
397
+ };
398
+ }
399
+
400
+ protected firstUpdated(_changedProperties: PropertyValues): void {
401
+ updateAttributes(this, this.getAttributes());
402
+
403
+ if (this.getPanelData()) {
404
+ this.send?.({
405
+ type: "rebindPanelCallbacks",
406
+ data: {
407
+ id: this.id,
408
+ onCollapseChange: this.onCollapseChange
409
+ ? { current: (e) => this.onCollapseChange?.(e, this) }
410
+ : undefined,
411
+ onResize: this.onResize ? { current: this.onResize } : undefined,
412
+ },
413
+ });
414
+ } else {
415
+ if (this.isPrerender) {
416
+ this.send({ type: "registerPanel", data: this.initPanel() });
417
+ } else {
418
+ const groupElement = this.closest("window-splitter");
419
+ if (!groupElement) return;
420
+
421
+ const order = Array.from(groupElement.children).indexOf(this);
422
+ if (typeof order !== "number") return;
423
+
424
+ this.send({
425
+ type: "registerDynamicPanel",
426
+ data: { ...this.initPanel(), order },
427
+ });
428
+ }
429
+ }
430
+ }
431
+
432
+ disconnectedCallback(): void {
433
+ requestAnimationFrame(() =>
434
+ this.send({ type: "unregisterPanel", id: this.id })
435
+ );
436
+ }
437
+
438
+ updated(changedProperties: PropertyValues) {
439
+ if (changedProperties.has("collapsed")) {
440
+ const isControlled = this.getPanelData()?.collapseIsControlled;
441
+
442
+ if (isControlled) {
443
+ if (this.collapsed) {
444
+ this.send({
445
+ type: "collapsePanel",
446
+ panelId: this.id,
447
+ controlled: true,
448
+ });
449
+ } else {
450
+ this.send({
451
+ type: "expandPanel",
452
+ panelId: this.id,
453
+ controlled: true,
454
+ });
455
+ }
456
+ }
457
+ }
458
+
459
+ if (
460
+ changedProperties.has("min") ||
461
+ changedProperties.has("max") ||
462
+ changedProperties.has("default") ||
463
+ changedProperties.has("collapsible") ||
464
+ changedProperties.has("collapsedSize") ||
465
+ changedProperties.has("defaultCollapsed") ||
466
+ changedProperties.has("isStaticAtRest")
467
+ ) {
468
+ const currentPanelData = this.getPanelData();
469
+
470
+ if (
471
+ currentPanelData &&
472
+ haveConstraintsChangedForPanel(currentPanelData, this.initPanel())
473
+ ) {
474
+ this.send({ type: "updateConstraints", data: this.initPanel() });
475
+ }
476
+ }
477
+ }
478
+
479
+ render() {
480
+ return html`<slot></slot>`;
481
+ }
482
+ }
483
+
484
+ export class PanelResizer extends LitElement {
485
+ static observedContexts = ["send"];
486
+
487
+ public onDragStart?: () => void;
488
+ public onDrag?: (e: Parameters<NonNullable<MoveEvents["onMove"]>>[0]) => void;
489
+ public onDragEnd?: () => void;
490
+
491
+ @consume({ context: contextContext })
492
+ @property({ attribute: false })
493
+ public context: GroupMachineContextValue;
494
+ @consume({ context: sendContext })
495
+ @property({ attribute: false })
496
+ public send: SendFn;
497
+ @consume({ context: isPrerenderContext })
498
+ @property({ attribute: false })
499
+ public isPrerender = true;
500
+
501
+ @property({ type: String, reflect: true })
502
+ size?: string;
503
+ @property({ type: Boolean, reflect: true })
504
+ disabled?: boolean;
505
+
506
+ constructor() {
507
+ super();
508
+
509
+ this.id ||= getNextId();
510
+ this.send = () => {};
511
+ this.context = {} as GroupMachineContextValue;
512
+ }
513
+
514
+ private initPanelResizer() {
515
+ return initializePanelHandleData({
516
+ id: this.id,
517
+ size: (this.getAttribute("size") as PixelUnit | undefined) || "0px",
518
+ });
519
+ }
520
+
521
+ private getHandleData() {
522
+ return this.context?.items.find((item) => item.id === this.id) as
523
+ | PanelHandleData
524
+ | undefined;
525
+ }
526
+
527
+ private getAttributes() {
528
+ const handleIndex = this.context?.items.findIndex(
529
+ (item) => item.id === this.id
530
+ );
531
+
532
+ const panelBeforeHandle =
533
+ handleIndex !== undefined &&
534
+ isPanelData(this.context?.items[handleIndex - 1])
535
+ ? (this.context?.items[handleIndex - 1] as PanelData | undefined)
536
+ : undefined;
537
+ const handleData = this.getHandleData() || this.initPanelResizer();
538
+
539
+ return {
540
+ ...getPanelResizerDomAttributes({
541
+ groupId: this.context?.groupId,
542
+ id: this.id,
543
+ orientation: this.context?.orientation || "horizontal",
544
+ isDragging: this.context?.activeDragHandleId === this.id,
545
+ activeDragHandleId: this.context?.activeDragHandleId,
546
+ disabled: this.disabled,
547
+ controlsId: panelBeforeHandle?.id,
548
+ min: panelBeforeHandle?.min,
549
+ max: panelBeforeHandle?.max,
550
+ currentValue: panelBeforeHandle?.currentValue,
551
+ groupSize: getGroupSize(this.context),
552
+ }),
553
+ tabIndex: this.disabled ? -1 : 0,
554
+ style: {
555
+ cursor: this.context ? getCursor(this.context) : undefined,
556
+ width:
557
+ this.context?.orientation === "horizontal"
558
+ ? `${handleData.size.value.toNumber()}px`
559
+ : "100%",
560
+ height:
561
+ this.context?.orientation === "vertical"
562
+ ? `${handleData.size.value.toNumber()}px`
563
+ : "100%",
564
+ },
565
+ };
566
+ }
567
+
568
+ protected firstUpdated(_changedProperties: PropertyValues): void {
569
+ const { moveProps } = move({
570
+ onMoveStart: () => {
571
+ this.send({
572
+ type: "dragHandleStart",
573
+ handleId: this.id,
574
+ });
575
+ this.onDragStart?.();
576
+ document.body.style.cursor = getCursor(this.context) || "auto";
577
+ },
578
+ onMove: (e) => {
579
+ this.send({
580
+ type: "dragHandle",
581
+ handleId: this.id,
582
+ value: e,
583
+ });
584
+ this.onDrag?.(e);
585
+ },
586
+ onMoveEnd: () => {
587
+ this.send({ type: "dragHandleEnd", handleId: this.id });
588
+ this.onDragEnd?.();
589
+ document.body.style.cursor = "auto";
590
+ },
591
+ });
592
+
593
+ this.addEventListener("pointerdown", (e) => {
594
+ if (this.disabled) return;
595
+ moveProps.onPointerDown(e);
596
+ });
597
+ this.addEventListener("keydown", (e) => {
598
+ moveProps.onKeyDown(e);
599
+
600
+ const collapsiblePanel = getCollapsiblePanelForHandleId(
601
+ this.context,
602
+ this.id
603
+ );
604
+
605
+ if (e.key === "Enter" && collapsiblePanel) {
606
+ if (collapsiblePanel.collapsed) {
607
+ this.send({ type: "expandPanel", panelId: collapsiblePanel.id });
608
+ } else {
609
+ this.send({ type: "collapsePanel", panelId: collapsiblePanel.id });
610
+ }
611
+ }
612
+ });
613
+
614
+ updateAttributes(this, this.getAttributes());
615
+
616
+ if (!this.getHandleData()) {
617
+ if (this.isPrerender) {
618
+ this.send({
619
+ type: "registerPanelHandle",
620
+ data: this.initPanelResizer(),
621
+ });
622
+ } else {
623
+ const groupElement = this.closest("window-splitter");
624
+ if (!groupElement) return;
625
+
626
+ const order = Array.from(groupElement.children).indexOf(this);
627
+ if (typeof order !== "number") return;
628
+
629
+ this.send({
630
+ type: "registerPanelHandle",
631
+ data: { ...this.initPanelResizer(), order },
632
+ });
633
+ }
634
+ }
635
+ }
636
+
637
+ updated(changedProperties: PropertyValues) {
638
+ if (changedProperties.has("size")) {
639
+ const currentHandleData = this.getHandleData();
640
+ if (
641
+ currentHandleData &&
642
+ haveConstraintsChangedForPanelHandle(
643
+ currentHandleData,
644
+ this.initPanelResizer()
645
+ )
646
+ ) {
647
+ this.send({ type: "updateConstraints", data: this.initPanelResizer() });
648
+ }
649
+ }
650
+ }
651
+ disconnectedCallback(): void {
652
+ requestAnimationFrame(() =>
653
+ this.send({ type: "unregisterPanelHandle", id: this.id })
654
+ );
655
+ }
656
+
657
+ render() {
658
+ return html`<slot></slot>`;
659
+ }
660
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "experimentalDecorators": true,
5
+ "target": "ES2021",
6
+ "types": ["@testing-library/jest-dom"]
7
+ }
8
+ }
@@ -0,0 +1,17 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ coverage: {
6
+ provider: "istanbul",
7
+ include: ["**/*.vue", "!**/*.stories.ts", "!**/*.test.ts"],
8
+ reporter: ["text", "html", "json-summary", "json"],
9
+ reportOnFailure: true,
10
+ },
11
+ browser: {
12
+ enabled: true,
13
+ provider: "playwright",
14
+ instances: [{ browser: "chromium" }],
15
+ },
16
+ },
17
+ });