@blorkfield/blork-tabs 0.1.3

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.
@@ -0,0 +1,507 @@
1
+ /**
2
+ * @blorkfield/blork-tabs - TabManager
3
+ * Main orchestrator for the tab/panel management system
4
+ */
5
+
6
+ import type {
7
+ TabManagerConfig,
8
+ ResolvedTabManagerConfig,
9
+ PanelConfig,
10
+ PanelState,
11
+ AnchorConfig,
12
+ AnchorState,
13
+ AnchorPreset,
14
+ CSSClasses,
15
+ TabManagerEvents,
16
+ EventListener,
17
+ SnapTarget,
18
+ AnchorSnapResult,
19
+ DragState,
20
+ Position,
21
+ } from './types';
22
+ import { createPanelState, toggleCollapse, setPanelPosition } from './Panel';
23
+ import { getConnectedGroup, detachFromGroup, updateSnappedPositions, snapPanels } from './SnapChain';
24
+ import { DragManager } from './DragManager';
25
+ import { AnchorManager } from './AnchorManager';
26
+ import { SnapPreview } from './SnapPreview';
27
+
28
+ /**
29
+ * Default configuration values
30
+ */
31
+ const DEFAULT_CONFIG: ResolvedTabManagerConfig = {
32
+ snapThreshold: 50,
33
+ panelGap: 0,
34
+ panelMargin: 16,
35
+ anchorThreshold: 80,
36
+ defaultPanelWidth: 300,
37
+ container: document.body,
38
+ initializeDefaultAnchors: true,
39
+ classPrefix: 'blork-tabs',
40
+ };
41
+
42
+ /**
43
+ * Generate CSS class names from prefix
44
+ */
45
+ function generateClasses(prefix: string): CSSClasses {
46
+ return {
47
+ panel: `${prefix}-panel`,
48
+ panelHeader: `${prefix}-header`,
49
+ panelTitle: `${prefix}-title`,
50
+ panelContent: `${prefix}-content`,
51
+ panelContentCollapsed: `${prefix}-content-collapsed`,
52
+ detachGrip: `${prefix}-detach-grip`,
53
+ collapseButton: `${prefix}-collapse-btn`,
54
+ snapPreview: `${prefix}-snap-preview`,
55
+ snapPreviewVisible: `${prefix}-snap-preview-visible`,
56
+ anchorIndicator: `${prefix}-anchor-indicator`,
57
+ anchorIndicatorVisible: `${prefix}-anchor-indicator-visible`,
58
+ anchorIndicatorActive: `${prefix}-anchor-indicator-active`,
59
+ dragging: `${prefix}-dragging`,
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Main TabManager class - orchestrates all panel, snap, and anchor functionality
65
+ */
66
+ export class TabManager {
67
+ private config: ResolvedTabManagerConfig;
68
+ private classes: CSSClasses;
69
+ private panels: Map<string, PanelState> = new Map();
70
+ private dragManager: DragManager;
71
+ private anchorManager: AnchorManager;
72
+ private snapPreview: SnapPreview;
73
+ private eventListeners: Map<string, Set<EventListener<unknown>>> = new Map();
74
+
75
+ constructor(userConfig: TabManagerConfig = {}) {
76
+ // Merge user config with defaults
77
+ this.config = {
78
+ ...DEFAULT_CONFIG,
79
+ ...userConfig,
80
+ container: userConfig.container ?? document.body,
81
+ };
82
+
83
+ this.classes = generateClasses(this.config.classPrefix);
84
+
85
+ // Initialize managers
86
+ this.anchorManager = new AnchorManager(this.config, this.classes);
87
+ this.snapPreview = new SnapPreview(
88
+ this.config.container,
89
+ this.classes,
90
+ this.panels
91
+ );
92
+ this.dragManager = new DragManager(
93
+ this.panels,
94
+ this.config,
95
+ {
96
+ onDragStart: this.handleDragStart.bind(this),
97
+ onDragMove: this.handleDragMove.bind(this),
98
+ onDragEnd: this.handleDragEnd.bind(this),
99
+ findAnchorTarget: (panels) => this.anchorManager.findNearestAnchor(panels),
100
+ }
101
+ );
102
+
103
+ // Initialize default anchors if configured
104
+ if (this.config.initializeDefaultAnchors) {
105
+ this.anchorManager.addDefaultAnchors();
106
+ }
107
+ }
108
+
109
+ // ==================== Panel Management ====================
110
+
111
+ /**
112
+ * Add a new panel
113
+ */
114
+ addPanel(panelConfig: PanelConfig): PanelState {
115
+ const state = createPanelState(panelConfig, this.classes);
116
+
117
+ // Add to container if new element
118
+ if (!panelConfig.element && !this.config.container.contains(state.element)) {
119
+ this.config.container.appendChild(state.element);
120
+ }
121
+
122
+ // Set up event handlers
123
+ this.setupPanelEvents(state);
124
+
125
+ // Store panel
126
+ this.panels.set(state.id, state);
127
+
128
+ // Set initial position if provided
129
+ if (panelConfig.initialPosition) {
130
+ setPanelPosition(state, panelConfig.initialPosition.x, panelConfig.initialPosition.y);
131
+ }
132
+
133
+ this.emit('panel:added', { panel: state });
134
+
135
+ return state;
136
+ }
137
+
138
+ /**
139
+ * Register an existing panel element
140
+ */
141
+ registerPanel(
142
+ id: string,
143
+ element: HTMLDivElement,
144
+ options: {
145
+ dragHandle?: HTMLDivElement;
146
+ collapseButton?: HTMLButtonElement;
147
+ contentWrapper?: HTMLDivElement;
148
+ detachGrip?: HTMLDivElement;
149
+ startCollapsed?: boolean;
150
+ } = {}
151
+ ): PanelState {
152
+ return this.addPanel({
153
+ id,
154
+ element,
155
+ dragHandle: options.dragHandle,
156
+ collapseButton: options.collapseButton,
157
+ contentWrapper: options.contentWrapper,
158
+ detachGrip: options.detachGrip,
159
+ startCollapsed: options.startCollapsed,
160
+ });
161
+ }
162
+
163
+ /**
164
+ * Remove a panel
165
+ */
166
+ removePanel(id: string): boolean {
167
+ const panel = this.panels.get(id);
168
+ if (!panel) return false;
169
+
170
+ // Detach from any snap chain
171
+ detachFromGroup(panel, this.panels);
172
+
173
+ // Remove from DOM if we created it
174
+ if (!panel.config.element) {
175
+ panel.element.remove();
176
+ }
177
+
178
+ this.panels.delete(id);
179
+ this.emit('panel:removed', { panelId: id });
180
+
181
+ return true;
182
+ }
183
+
184
+ /**
185
+ * Get a panel by ID
186
+ */
187
+ getPanel(id: string): PanelState | undefined {
188
+ return this.panels.get(id);
189
+ }
190
+
191
+ /**
192
+ * Get all panels
193
+ */
194
+ getAllPanels(): PanelState[] {
195
+ return Array.from(this.panels.values());
196
+ }
197
+
198
+ /**
199
+ * Set up event handlers for a panel
200
+ */
201
+ private setupPanelEvents(state: PanelState): void {
202
+ // Collapse button
203
+ if (state.collapseButton) {
204
+ state.collapseButton.addEventListener('click', () => {
205
+ const newState = toggleCollapse(state, this.classes);
206
+ updateSnappedPositions(this.panels, this.config);
207
+ this.emit('panel:collapse', { panel: state, isCollapsed: newState });
208
+ });
209
+ }
210
+
211
+ // Detach grip - single panel drag
212
+ if (state.detachGrip) {
213
+ state.detachGrip.addEventListener('mousedown', (e) => {
214
+ this.dragManager.startDrag(e, state, 'single');
215
+ });
216
+ }
217
+
218
+ // Main drag handle - group drag
219
+ state.dragHandle.addEventListener('mousedown', (e) => {
220
+ // Ignore if clicking on collapse button or detach grip
221
+ if (
222
+ e.target === state.collapseButton ||
223
+ e.target === state.detachGrip
224
+ ) {
225
+ return;
226
+ }
227
+ this.dragManager.startDrag(e, state, 'group');
228
+ });
229
+ }
230
+
231
+ // ==================== Snap Chain Management ====================
232
+
233
+ /**
234
+ * Get all panels in the same snap chain as the given panel
235
+ */
236
+ getSnapChain(panelId: string): PanelState[] {
237
+ const panel = this.panels.get(panelId);
238
+ if (!panel) return [];
239
+ return getConnectedGroup(panel, this.panels);
240
+ }
241
+
242
+ /**
243
+ * Manually snap two panels together
244
+ */
245
+ snap(leftPanelId: string, rightPanelId: string): boolean {
246
+ const leftPanel = this.panels.get(leftPanelId);
247
+ const rightPanel = this.panels.get(rightPanelId);
248
+
249
+ if (!leftPanel || !rightPanel) return false;
250
+
251
+ snapPanels(leftPanel, rightPanel);
252
+ updateSnappedPositions(this.panels, this.config);
253
+
254
+ return true;
255
+ }
256
+
257
+ /**
258
+ * Detach a panel from its snap chain
259
+ */
260
+ detach(panelId: string): boolean {
261
+ const panel = this.panels.get(panelId);
262
+ if (!panel) return false;
263
+
264
+ const previousGroup = getConnectedGroup(panel, this.panels);
265
+ detachFromGroup(panel, this.panels);
266
+
267
+ this.emit('panel:detached', { panel, previousGroup });
268
+
269
+ return true;
270
+ }
271
+
272
+ /**
273
+ * Update snapped positions (call after collapse/expand or resize)
274
+ */
275
+ updatePositions(): void {
276
+ updateSnappedPositions(this.panels, this.config);
277
+ }
278
+
279
+ // ==================== Anchor Management ====================
280
+
281
+ /**
282
+ * Add a custom anchor
283
+ */
284
+ addAnchor(config: AnchorConfig): AnchorState {
285
+ return this.anchorManager.addAnchor(config);
286
+ }
287
+
288
+ /**
289
+ * Add a preset anchor
290
+ */
291
+ addPresetAnchor(preset: AnchorPreset): AnchorState {
292
+ return this.anchorManager.addPresetAnchor(preset);
293
+ }
294
+
295
+ /**
296
+ * Remove an anchor
297
+ */
298
+ removeAnchor(id: string): boolean {
299
+ return this.anchorManager.removeAnchor(id);
300
+ }
301
+
302
+ /**
303
+ * Get all anchors
304
+ */
305
+ getAnchors(): AnchorState[] {
306
+ return this.anchorManager.getAnchors();
307
+ }
308
+
309
+ // ==================== Drag Callbacks ====================
310
+
311
+ private handleDragStart(state: DragState): void {
312
+ this.anchorManager.showIndicators(null);
313
+ this.emit('drag:start', {
314
+ panel: state.grabbedPanel,
315
+ mode: state.mode,
316
+ movingPanels: state.movingPanels,
317
+ });
318
+ }
319
+
320
+ private handleDragMove(
321
+ state: DragState,
322
+ position: Position,
323
+ snapTarget: SnapTarget | null,
324
+ anchorResult: AnchorSnapResult | null
325
+ ): void {
326
+ // Update snap preview
327
+ this.snapPreview.update(snapTarget);
328
+
329
+ // Update anchor indicators
330
+ this.anchorManager.showIndicators(anchorResult?.anchor ?? null);
331
+
332
+ this.emit('drag:move', {
333
+ panel: state.grabbedPanel,
334
+ position,
335
+ snapTarget,
336
+ anchorTarget: anchorResult,
337
+ });
338
+ }
339
+
340
+ private handleDragEnd(
341
+ state: DragState,
342
+ snapTarget: SnapTarget | null,
343
+ anchorResult: AnchorSnapResult | null
344
+ ): void {
345
+ this.snapPreview.hide();
346
+ this.anchorManager.hideIndicators();
347
+
348
+ const rect = state.grabbedPanel.element.getBoundingClientRect();
349
+
350
+ if (snapTarget) {
351
+ const targetPanel = this.panels.get(snapTarget.targetId);
352
+ if (targetPanel) {
353
+ this.emit('snap:panel', {
354
+ movingPanels: state.movingPanels,
355
+ targetPanel,
356
+ side: snapTarget.side,
357
+ });
358
+ }
359
+ } else if (anchorResult) {
360
+ this.emit('snap:anchor', {
361
+ movingPanels: state.movingPanels,
362
+ anchor: anchorResult.anchor,
363
+ });
364
+ }
365
+
366
+ this.emit('drag:end', {
367
+ panel: state.grabbedPanel,
368
+ finalPosition: { x: rect.left, y: rect.top },
369
+ snappedToPanel: snapTarget !== null,
370
+ snappedToAnchor: anchorResult !== null,
371
+ });
372
+ }
373
+
374
+ // ==================== Event System ====================
375
+
376
+ /**
377
+ * Subscribe to an event
378
+ */
379
+ on<K extends keyof TabManagerEvents>(
380
+ event: K,
381
+ listener: EventListener<TabManagerEvents[K]>
382
+ ): () => void {
383
+ if (!this.eventListeners.has(event)) {
384
+ this.eventListeners.set(event, new Set());
385
+ }
386
+ this.eventListeners.get(event)!.add(listener as EventListener<unknown>);
387
+
388
+ // Return unsubscribe function
389
+ return () => this.off(event, listener);
390
+ }
391
+
392
+ /**
393
+ * Unsubscribe from an event
394
+ */
395
+ off<K extends keyof TabManagerEvents>(
396
+ event: K,
397
+ listener: EventListener<TabManagerEvents[K]>
398
+ ): void {
399
+ this.eventListeners.get(event)?.delete(listener as EventListener<unknown>);
400
+ }
401
+
402
+ /**
403
+ * Emit an event
404
+ */
405
+ private emit<K extends keyof TabManagerEvents>(
406
+ event: K,
407
+ data: TabManagerEvents[K]
408
+ ): void {
409
+ const listeners = this.eventListeners.get(event);
410
+ if (listeners) {
411
+ for (const listener of listeners) {
412
+ listener(data);
413
+ }
414
+ }
415
+ }
416
+
417
+ // ==================== Positioning ====================
418
+
419
+ /**
420
+ * Position panels in a row from right edge
421
+ */
422
+ positionPanelsFromRight(panelIds: string[], gap = 0): void {
423
+ let rightEdge = window.innerWidth - this.config.panelMargin;
424
+
425
+ for (const id of panelIds) {
426
+ const state = this.panels.get(id);
427
+ if (!state) continue;
428
+
429
+ const width = state.element.offsetWidth;
430
+ setPanelPosition(state, rightEdge - width, this.config.panelMargin);
431
+ rightEdge -= width + gap;
432
+ }
433
+ }
434
+
435
+ /**
436
+ * Position panels in a row from left edge
437
+ */
438
+ positionPanelsFromLeft(panelIds: string[], gap = 0): void {
439
+ let leftEdge = this.config.panelMargin;
440
+
441
+ for (const id of panelIds) {
442
+ const state = this.panels.get(id);
443
+ if (!state) continue;
444
+
445
+ setPanelPosition(state, leftEdge, this.config.panelMargin);
446
+ leftEdge += state.element.offsetWidth + gap;
447
+ }
448
+ }
449
+
450
+ /**
451
+ * Set up initial snap chain for a list of panels (left to right)
452
+ */
453
+ createSnapChain(panelIds: string[]): void {
454
+ for (let i = 0; i < panelIds.length - 1; i++) {
455
+ const leftPanel = this.panels.get(panelIds[i]);
456
+ const rightPanel = this.panels.get(panelIds[i + 1]);
457
+
458
+ if (leftPanel && rightPanel) {
459
+ snapPanels(leftPanel, rightPanel);
460
+ }
461
+ }
462
+ }
463
+
464
+ // ==================== Configuration ====================
465
+
466
+ /**
467
+ * Get the current configuration
468
+ */
469
+ getConfig(): ResolvedTabManagerConfig {
470
+ return { ...this.config };
471
+ }
472
+
473
+ /**
474
+ * Get the CSS classes used
475
+ */
476
+ getClasses(): CSSClasses {
477
+ return { ...this.classes };
478
+ }
479
+
480
+ /**
481
+ * Check if dragging is currently active
482
+ */
483
+ isDragging(): boolean {
484
+ return this.dragManager.isActive();
485
+ }
486
+
487
+ // ==================== Cleanup ====================
488
+
489
+ /**
490
+ * Destroy the TabManager and clean up resources
491
+ */
492
+ destroy(): void {
493
+ this.dragManager.destroy();
494
+ this.anchorManager.destroy();
495
+ this.snapPreview.destroy();
496
+
497
+ // Remove panels we created
498
+ for (const panel of this.panels.values()) {
499
+ if (!panel.config.element) {
500
+ panel.element.remove();
501
+ }
502
+ }
503
+
504
+ this.panels.clear();
505
+ this.eventListeners.clear();
506
+ }
507
+ }
@@ -0,0 +1,9 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { TabManager } from './index';
3
+
4
+ describe('TabManager', () => {
5
+ it('exports TabManager class', () => {
6
+ expect(TabManager).toBeDefined();
7
+ expect(typeof TabManager).toBe('function');
8
+ });
9
+ });
package/src/index.ts ADDED
@@ -0,0 +1,105 @@
1
+ /**
2
+ * @blorkfield/blork-tabs
3
+ * A framework-agnostic tab/panel management system with snapping and docking
4
+ *
5
+ * @example
6
+ * ```typescript
7
+ * import { TabManager } from '@blorkfield/blork-tabs';
8
+ * import '@blorkfield/blork-tabs/styles.css';
9
+ *
10
+ * const manager = new TabManager({
11
+ * snapThreshold: 50,
12
+ * panelGap: 0,
13
+ * });
14
+ *
15
+ * // Create panels programmatically
16
+ * manager.addPanel({
17
+ * id: 'settings',
18
+ * title: 'Settings',
19
+ * content: '<div>Settings content</div>',
20
+ * });
21
+ *
22
+ * // Or register existing DOM elements
23
+ * manager.registerPanel('my-panel', document.getElementById('my-panel'), {
24
+ * dragHandle: document.getElementById('my-panel-header'),
25
+ * });
26
+ *
27
+ * // Set up snap chains
28
+ * manager.createSnapChain(['panel1', 'panel2', 'panel3']);
29
+ *
30
+ * // Listen to events
31
+ * manager.on('snap:panel', ({ movingPanels, targetPanel }) => {
32
+ * console.log('Panels snapped!');
33
+ * });
34
+ * ```
35
+ */
36
+
37
+ // Main class export
38
+ export { TabManager } from './TabManager';
39
+
40
+ // Sub-module exports for advanced usage
41
+ export { AnchorManager, getDefaultAnchorConfigs, createPresetAnchor } from './AnchorManager';
42
+ export { DragManager } from './DragManager';
43
+ export { SnapPreview } from './SnapPreview';
44
+ export {
45
+ createPanelElement,
46
+ createPanelState,
47
+ toggleCollapse,
48
+ setPanelPosition,
49
+ getPanelPosition,
50
+ getPanelDimensions,
51
+ setPanelZIndex,
52
+ getDefaultZIndex,
53
+ getDragZIndex,
54
+ } from './Panel';
55
+ export {
56
+ getConnectedGroup,
57
+ detachFromGroup,
58
+ findSnapTarget,
59
+ snapPanelsToTarget,
60
+ updateSnappedPositions,
61
+ getLeftmostPanel,
62
+ getRightmostPanel,
63
+ areInSameChain,
64
+ snapPanels,
65
+ unsnap,
66
+ } from './SnapChain';
67
+
68
+ // Type exports
69
+ export type {
70
+ // Configuration
71
+ TabManagerConfig,
72
+ ResolvedTabManagerConfig,
73
+ PanelConfig,
74
+ AnchorConfig,
75
+ AnchorPreset,
76
+
77
+ // State
78
+ PanelState,
79
+ DragState,
80
+ DragMode,
81
+ AnchorState,
82
+
83
+ // Results
84
+ SnapTarget,
85
+ SnapSide,
86
+ AnchorSnapResult,
87
+ Position,
88
+ Bounds,
89
+
90
+ // Events
91
+ TabManagerEvents,
92
+ PanelAddedEvent,
93
+ PanelRemovedEvent,
94
+ DragStartEvent,
95
+ DragMoveEvent,
96
+ DragEndEvent,
97
+ PanelSnapEvent,
98
+ AnchorSnapEvent,
99
+ PanelDetachedEvent,
100
+ PanelCollapseEvent,
101
+ EventListener,
102
+
103
+ // CSS
104
+ CSSClasses,
105
+ } from './types';