@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,186 @@
1
+ /**
2
+ * @blorkfield/blork-tabs - Default Styles
3
+ *
4
+ * These styles provide a sensible default appearance for blork-tabs panels.
5
+ * You can override these styles or use your own by not importing this file.
6
+ *
7
+ * CSS Custom Properties (variables) you can override:
8
+ * --blork-tabs-panel-bg: Panel background color
9
+ * --blork-tabs-panel-border: Panel border
10
+ * --blork-tabs-panel-radius: Panel border radius
11
+ * --blork-tabs-panel-shadow: Panel box shadow
12
+ * --blork-tabs-header-bg: Header background color
13
+ * --blork-tabs-header-color: Header text color
14
+ * --blork-tabs-content-bg: Content background color
15
+ * --blork-tabs-accent: Accent color for interactive elements
16
+ */
17
+
18
+ /* Panel Container */
19
+ .blork-tabs-panel {
20
+ position: fixed;
21
+ width: 300px;
22
+ background: var(--blork-tabs-panel-bg, #1a1a2e);
23
+ border: var(--blork-tabs-panel-border, 1px solid #2a2a4a);
24
+ border-radius: var(--blork-tabs-panel-radius, 8px);
25
+ box-shadow: var(--blork-tabs-panel-shadow, 0 4px 20px rgba(0, 0, 0, 0.4));
26
+ z-index: 1000;
27
+ overflow: hidden;
28
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
29
+ font-size: 14px;
30
+ color: var(--blork-tabs-header-color, #e0e0e0);
31
+ }
32
+
33
+ /* Panel Header */
34
+ .blork-tabs-header {
35
+ display: flex;
36
+ align-items: center;
37
+ padding: 8px 12px;
38
+ background: var(--blork-tabs-header-bg, #2a2a4a);
39
+ cursor: grab;
40
+ user-select: none;
41
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
42
+ }
43
+
44
+ .blork-tabs-header:active {
45
+ cursor: grabbing;
46
+ }
47
+
48
+ /* Title */
49
+ .blork-tabs-title {
50
+ flex: 1;
51
+ font-weight: 500;
52
+ font-size: 13px;
53
+ text-transform: uppercase;
54
+ letter-spacing: 0.5px;
55
+ }
56
+
57
+ /* Detach Grip */
58
+ .blork-tabs-detach-grip {
59
+ width: 12px;
60
+ height: 100%;
61
+ margin-right: 8px;
62
+ cursor: grab;
63
+ display: flex;
64
+ flex-direction: column;
65
+ justify-content: center;
66
+ align-items: center;
67
+ gap: 2px;
68
+ }
69
+
70
+ .blork-tabs-detach-grip::before,
71
+ .blork-tabs-detach-grip::after {
72
+ content: '';
73
+ width: 8px;
74
+ height: 2px;
75
+ background: rgba(255, 255, 255, 0.3);
76
+ border-radius: 1px;
77
+ }
78
+
79
+ .blork-tabs-detach-grip:hover::before,
80
+ .blork-tabs-detach-grip:hover::after {
81
+ background: var(--blork-tabs-accent, #4a90d9);
82
+ }
83
+
84
+ /* Collapse Button */
85
+ .blork-tabs-collapse-btn {
86
+ width: 24px;
87
+ height: 24px;
88
+ border: none;
89
+ background: transparent;
90
+ color: var(--blork-tabs-header-color, #e0e0e0);
91
+ font-size: 16px;
92
+ font-weight: bold;
93
+ cursor: pointer;
94
+ border-radius: 4px;
95
+ display: flex;
96
+ align-items: center;
97
+ justify-content: center;
98
+ transition: background 0.2s;
99
+ }
100
+
101
+ .blork-tabs-collapse-btn:hover {
102
+ background: rgba(255, 255, 255, 0.1);
103
+ }
104
+
105
+ /* Content Area */
106
+ .blork-tabs-content {
107
+ background: var(--blork-tabs-content-bg, #1a1a2e);
108
+ padding: 12px;
109
+ max-height: 400px;
110
+ overflow-y: auto;
111
+ }
112
+
113
+ .blork-tabs-content-collapsed {
114
+ display: none;
115
+ }
116
+
117
+ /* Snap Preview */
118
+ .blork-tabs-snap-preview {
119
+ position: fixed;
120
+ width: 4px;
121
+ background: var(--blork-tabs-accent, #4a90d9);
122
+ border-radius: 2px;
123
+ pointer-events: none;
124
+ z-index: 2000;
125
+ opacity: 0;
126
+ transition: opacity 0.15s;
127
+ }
128
+
129
+ .blork-tabs-snap-preview-visible {
130
+ opacity: 1;
131
+ }
132
+
133
+ /* Anchor Indicator */
134
+ .blork-tabs-anchor-indicator {
135
+ position: fixed;
136
+ width: 40px;
137
+ height: 40px;
138
+ border: 2px dashed rgba(255, 255, 255, 0.2);
139
+ border-radius: 8px;
140
+ pointer-events: none;
141
+ z-index: 999;
142
+ opacity: 0;
143
+ transition: opacity 0.15s, border-color 0.15s, background 0.15s;
144
+ }
145
+
146
+ .blork-tabs-anchor-indicator-visible {
147
+ opacity: 1;
148
+ }
149
+
150
+ .blork-tabs-anchor-indicator-active {
151
+ border-color: var(--blork-tabs-accent, #4a90d9);
152
+ background: rgba(74, 144, 217, 0.1);
153
+ }
154
+
155
+ /* Scrollbar styling for content */
156
+ .blork-tabs-content::-webkit-scrollbar {
157
+ width: 6px;
158
+ }
159
+
160
+ .blork-tabs-content::-webkit-scrollbar-track {
161
+ background: transparent;
162
+ }
163
+
164
+ .blork-tabs-content::-webkit-scrollbar-thumb {
165
+ background: rgba(255, 255, 255, 0.2);
166
+ border-radius: 3px;
167
+ }
168
+
169
+ .blork-tabs-content::-webkit-scrollbar-thumb:hover {
170
+ background: rgba(255, 255, 255, 0.3);
171
+ }
172
+
173
+ /* Light theme variant - add class 'blork-tabs-light' to container */
174
+ .blork-tabs-light .blork-tabs-panel {
175
+ --blork-tabs-panel-bg: #ffffff;
176
+ --blork-tabs-panel-border: 1px solid #e0e0e0;
177
+ --blork-tabs-header-bg: #f5f5f5;
178
+ --blork-tabs-header-color: #333333;
179
+ --blork-tabs-content-bg: #ffffff;
180
+ --blork-tabs-panel-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
181
+ }
182
+
183
+ /* Utility: Disable transitions during drag for better performance */
184
+ .blork-tabs-dragging .blork-tabs-panel {
185
+ transition: none !important;
186
+ }
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@blorkfield/blork-tabs",
3
+ "version": "0.1.3",
4
+ "description": "A framework-agnostic tab/panel management system with snapping and docking",
5
+ "type": "module",
6
+ "main": "dist/index.cjs",
7
+ "module": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "require": "./dist/index.cjs"
14
+ },
15
+ "./styles.css": "./dist/styles.css"
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "src"
20
+ ],
21
+ "scripts": {
22
+ "build": "tsup src/index.ts --format cjs,esm --dts --clean && cp styles.css dist/",
23
+ "dev": "tsup src/index.ts --format cjs,esm --dts --watch",
24
+ "lint": "eslint src/",
25
+ "test": "vitest run",
26
+ "test:watch": "vitest",
27
+ "prepublishOnly": "npm run build"
28
+ },
29
+ "keywords": [
30
+ "tabs",
31
+ "panels",
32
+ "docking",
33
+ "snapping",
34
+ "ui",
35
+ "drag-and-drop",
36
+ "window-management"
37
+ ],
38
+ "author": "Blorkfield",
39
+ "license": "MIT",
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "https://github.com/blorkfield/blork-tabs.git"
43
+ },
44
+ "bugs": {
45
+ "url": "https://github.com/blorkfield/blork-tabs/issues"
46
+ },
47
+ "homepage": "https://github.com/blorkfield/blork-tabs#readme",
48
+ "devDependencies": {
49
+ "@changesets/cli": "^2.27.0",
50
+ "@eslint/js": "^9.0.0",
51
+ "@types/node": "^20.0.0",
52
+ "eslint": "^9.0.0",
53
+ "happy-dom": "^20.1.0",
54
+ "tsup": "^8.0.0",
55
+ "typescript": "^5.3.0",
56
+ "typescript-eslint": "^8.0.0",
57
+ "vitest": "^1.0.0"
58
+ },
59
+ "engines": {
60
+ "node": ">=18.0.0"
61
+ }
62
+ }
@@ -0,0 +1,395 @@
1
+ /**
2
+ * @blorkfield/blork-tabs - AnchorManager
3
+ * Manages screen anchor points for panel docking
4
+ */
5
+
6
+ import type {
7
+ AnchorConfig,
8
+ AnchorState,
9
+ AnchorSnapResult,
10
+ AnchorPreset,
11
+ PanelState,
12
+ Position,
13
+ ResolvedTabManagerConfig,
14
+ CSSClasses,
15
+ } from './types';
16
+
17
+ /**
18
+ * Creates the default anchor positions
19
+ */
20
+ export function getDefaultAnchorConfigs(
21
+ config: ResolvedTabManagerConfig
22
+ ): AnchorConfig[] {
23
+ const { panelMargin, defaultPanelWidth } = config;
24
+
25
+ return [
26
+ {
27
+ id: 'top-left',
28
+ getPosition: () => ({ x: panelMargin, y: panelMargin }),
29
+ },
30
+ {
31
+ id: 'top-right',
32
+ getPosition: () => ({
33
+ x: window.innerWidth - panelMargin - defaultPanelWidth,
34
+ y: panelMargin,
35
+ }),
36
+ },
37
+ {
38
+ id: 'bottom-left',
39
+ getPosition: () => ({
40
+ x: panelMargin,
41
+ y: window.innerHeight - panelMargin,
42
+ }),
43
+ },
44
+ {
45
+ id: 'bottom-right',
46
+ getPosition: () => ({
47
+ x: window.innerWidth - panelMargin - defaultPanelWidth,
48
+ y: window.innerHeight - panelMargin,
49
+ }),
50
+ },
51
+ {
52
+ id: 'top-center',
53
+ getPosition: () => ({
54
+ x: (window.innerWidth - defaultPanelWidth) / 2,
55
+ y: panelMargin,
56
+ }),
57
+ },
58
+ ];
59
+ }
60
+
61
+ /**
62
+ * Create an anchor preset config
63
+ */
64
+ export function createPresetAnchor(
65
+ preset: AnchorPreset,
66
+ config: ResolvedTabManagerConfig
67
+ ): AnchorConfig {
68
+ const { panelMargin, defaultPanelWidth } = config;
69
+
70
+ const presets: Record<AnchorPreset, () => Position> = {
71
+ 'top-left': () => ({ x: panelMargin, y: panelMargin }),
72
+ 'top-right': () => ({
73
+ x: window.innerWidth - panelMargin - defaultPanelWidth,
74
+ y: panelMargin,
75
+ }),
76
+ 'top-center': () => ({
77
+ x: (window.innerWidth - defaultPanelWidth) / 2,
78
+ y: panelMargin,
79
+ }),
80
+ 'bottom-left': () => ({
81
+ x: panelMargin,
82
+ y: window.innerHeight - panelMargin,
83
+ }),
84
+ 'bottom-right': () => ({
85
+ x: window.innerWidth - panelMargin - defaultPanelWidth,
86
+ y: window.innerHeight - panelMargin,
87
+ }),
88
+ 'bottom-center': () => ({
89
+ x: (window.innerWidth - defaultPanelWidth) / 2,
90
+ y: window.innerHeight - panelMargin,
91
+ }),
92
+ 'center-left': () => ({
93
+ x: panelMargin,
94
+ y: window.innerHeight / 2,
95
+ }),
96
+ 'center-right': () => ({
97
+ x: window.innerWidth - panelMargin - defaultPanelWidth,
98
+ y: window.innerHeight / 2,
99
+ }),
100
+ };
101
+
102
+ return {
103
+ id: preset,
104
+ getPosition: presets[preset],
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Manages anchor points and docking behavior
110
+ */
111
+ export class AnchorManager {
112
+ private anchors: AnchorState[] = [];
113
+ private config: ResolvedTabManagerConfig;
114
+ private classes: CSSClasses;
115
+ private container: HTMLElement;
116
+
117
+ constructor(
118
+ config: ResolvedTabManagerConfig,
119
+ classes: CSSClasses
120
+ ) {
121
+ this.config = config;
122
+ this.classes = classes;
123
+ this.container = config.container;
124
+
125
+ // Set up resize handler
126
+ window.addEventListener('resize', this.updateIndicatorPositions.bind(this));
127
+ }
128
+
129
+ /**
130
+ * Create an anchor indicator element
131
+ */
132
+ private createIndicator(): HTMLDivElement {
133
+ const indicator = document.createElement('div');
134
+ indicator.className = this.classes.anchorIndicator;
135
+ this.container.appendChild(indicator);
136
+ return indicator;
137
+ }
138
+
139
+ /**
140
+ * Add an anchor point
141
+ */
142
+ addAnchor(anchorConfig: AnchorConfig): AnchorState {
143
+ const indicator =
144
+ anchorConfig.showIndicator !== false ? this.createIndicator() : null;
145
+
146
+ const state: AnchorState = {
147
+ config: anchorConfig,
148
+ indicator,
149
+ };
150
+
151
+ this.anchors.push(state);
152
+ this.updateIndicatorPosition(state);
153
+
154
+ return state;
155
+ }
156
+
157
+ /**
158
+ * Add multiple default anchors
159
+ */
160
+ addDefaultAnchors(): void {
161
+ const defaults = getDefaultAnchorConfigs(this.config);
162
+ for (const config of defaults) {
163
+ this.addAnchor(config);
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Add anchor from preset
169
+ */
170
+ addPresetAnchor(preset: AnchorPreset): AnchorState {
171
+ return this.addAnchor(createPresetAnchor(preset, this.config));
172
+ }
173
+
174
+ /**
175
+ * Remove an anchor
176
+ */
177
+ removeAnchor(id: string): boolean {
178
+ const index = this.anchors.findIndex((a) => a.config.id === id);
179
+ if (index === -1) return false;
180
+
181
+ const anchor = this.anchors[index];
182
+ anchor.indicator?.remove();
183
+ this.anchors.splice(index, 1);
184
+
185
+ return true;
186
+ }
187
+
188
+ /**
189
+ * Update position of a single indicator
190
+ */
191
+ private updateIndicatorPosition(anchor: AnchorState): void {
192
+ if (!anchor.indicator) return;
193
+
194
+ const pos = anchor.config.getPosition();
195
+ const indicator = anchor.indicator;
196
+
197
+ // Anchor marks where grip (left edge) lands
198
+ indicator.style.left = `${pos.x - 20}px`; // Center indicator on anchor point
199
+
200
+ if (anchor.config.id.includes('bottom')) {
201
+ indicator.style.top = `${pos.y - 40}px`;
202
+ } else {
203
+ indicator.style.top = `${pos.y}px`;
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Update all indicator positions (call on window resize)
209
+ */
210
+ updateIndicatorPositions(): void {
211
+ for (const anchor of this.anchors) {
212
+ this.updateIndicatorPosition(anchor);
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Find the nearest anchor for a group of moving panels
218
+ */
219
+ findNearestAnchor(movingPanels: PanelState[]): AnchorSnapResult | null {
220
+ if (movingPanels.length === 0 || this.anchors.length === 0) return null;
221
+
222
+ let bestResult: AnchorSnapResult | null = null;
223
+ let bestDist = Infinity;
224
+
225
+ // Get bounding box of the entire group
226
+ const firstRect = movingPanels[0].element.getBoundingClientRect();
227
+ const lastRect = movingPanels[movingPanels.length - 1].element.getBoundingClientRect();
228
+ const groupLeft = firstRect.left;
229
+ const groupRight = lastRect.right;
230
+ const groupTop = firstRect.top;
231
+ const groupBottom = firstRect.bottom;
232
+
233
+ for (const anchor of this.anchors) {
234
+ const anchorPos = anchor.config.getPosition();
235
+
236
+ // Check if ANY part of the group is near this anchor
237
+ const nearGroup =
238
+ anchorPos.x >= groupLeft - this.config.anchorThreshold &&
239
+ anchorPos.x <= groupRight + this.config.anchorThreshold &&
240
+ anchorPos.y >= groupTop - this.config.anchorThreshold &&
241
+ anchorPos.y <= groupBottom + this.config.anchorThreshold;
242
+
243
+ if (!nearGroup) continue;
244
+
245
+ // Find which panel's GRIP (left edge) is closest to this anchor
246
+ let closestPanelIdx = 0;
247
+ let closestDist = Infinity;
248
+
249
+ for (let i = 0; i < movingPanels.length; i++) {
250
+ const rect = movingPanels[i].element.getBoundingClientRect();
251
+
252
+ // Distance from anchor to the GRIP (left edge) of this panel
253
+ const gripX = rect.left;
254
+ const gripY = rect.top;
255
+ const dist = Math.sqrt(
256
+ Math.pow(gripX - anchorPos.x, 2) + Math.pow(gripY - anchorPos.y, 2)
257
+ );
258
+
259
+ if (dist < closestDist) {
260
+ closestDist = dist;
261
+ closestPanelIdx = i;
262
+ }
263
+ }
264
+
265
+ // Calculate final positions with this panel's grip docking to anchor
266
+ const anchorX = anchorPos.x;
267
+ const anchorY = anchor.config.id.includes('bottom')
268
+ ? anchorPos.y - movingPanels[closestPanelIdx].element.offsetHeight
269
+ : anchorPos.y;
270
+
271
+ // Calculate positions for all panels
272
+ const positions: Position[] = [];
273
+
274
+ // Find where leftmost panel would be (panels to left of docked one)
275
+ let leftX = anchorX;
276
+ for (let i = closestPanelIdx - 1; i >= 0; i--) {
277
+ leftX -= movingPanels[i].element.offsetWidth + this.config.panelGap;
278
+ }
279
+
280
+ // Fill in all positions left to right
281
+ let currentX = leftX;
282
+ for (let i = 0; i < movingPanels.length; i++) {
283
+ positions.push({ x: currentX, y: anchorY });
284
+ currentX += movingPanels[i].element.offsetWidth + this.config.panelGap;
285
+ }
286
+
287
+ // Check if all panels fit on screen
288
+ const leftmostX = positions[0].x;
289
+ const rightmostX =
290
+ positions[positions.length - 1].x +
291
+ movingPanels[movingPanels.length - 1].element.offsetWidth;
292
+
293
+ if (leftmostX < 0 || rightmostX > window.innerWidth) {
294
+ // Try to find a panel that WOULD fit
295
+ for (let tryIdx = 0; tryIdx < movingPanels.length; tryIdx++) {
296
+ const tryPositions: Position[] = [];
297
+ let tryLeftX = anchorX;
298
+ for (let i = tryIdx - 1; i >= 0; i--) {
299
+ tryLeftX -= movingPanels[i].element.offsetWidth + this.config.panelGap;
300
+ }
301
+
302
+ let tryCurrentX = tryLeftX;
303
+ for (let i = 0; i < movingPanels.length; i++) {
304
+ tryPositions.push({ x: tryCurrentX, y: anchorY });
305
+ tryCurrentX +=
306
+ movingPanels[i].element.offsetWidth + this.config.panelGap;
307
+ }
308
+
309
+ const tryLeftmostX = tryPositions[0].x;
310
+ const tryRightmostX =
311
+ tryPositions[tryPositions.length - 1].x +
312
+ movingPanels[movingPanels.length - 1].element.offsetWidth;
313
+
314
+ if (tryLeftmostX >= 0 && tryRightmostX <= window.innerWidth) {
315
+ if (closestDist < bestDist) {
316
+ bestDist = closestDist;
317
+ bestResult = {
318
+ anchor,
319
+ dockPanelIndex: tryIdx,
320
+ positions: tryPositions,
321
+ };
322
+ }
323
+ break;
324
+ }
325
+ }
326
+ continue;
327
+ }
328
+
329
+ // This is a valid option
330
+ if (closestDist < bestDist) {
331
+ bestDist = closestDist;
332
+ bestResult = { anchor, dockPanelIndex: closestPanelIdx, positions };
333
+ }
334
+ }
335
+
336
+ return bestResult;
337
+ }
338
+
339
+ /**
340
+ * Show anchor indicators during drag
341
+ */
342
+ showIndicators(activeAnchor: AnchorState | null): void {
343
+ for (const anchor of this.anchors) {
344
+ if (!anchor.indicator) continue;
345
+
346
+ if (activeAnchor === anchor) {
347
+ anchor.indicator.classList.add(
348
+ this.classes.anchorIndicatorVisible,
349
+ this.classes.anchorIndicatorActive
350
+ );
351
+ } else {
352
+ anchor.indicator.classList.add(this.classes.anchorIndicatorVisible);
353
+ anchor.indicator.classList.remove(this.classes.anchorIndicatorActive);
354
+ }
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Hide all anchor indicators
360
+ */
361
+ hideIndicators(): void {
362
+ for (const anchor of this.anchors) {
363
+ if (!anchor.indicator) continue;
364
+ anchor.indicator.classList.remove(
365
+ this.classes.anchorIndicatorVisible,
366
+ this.classes.anchorIndicatorActive
367
+ );
368
+ }
369
+ }
370
+
371
+ /**
372
+ * Get all anchors
373
+ */
374
+ getAnchors(): AnchorState[] {
375
+ return [...this.anchors];
376
+ }
377
+
378
+ /**
379
+ * Get anchor by ID
380
+ */
381
+ getAnchor(id: string): AnchorState | undefined {
382
+ return this.anchors.find((a) => a.config.id === id);
383
+ }
384
+
385
+ /**
386
+ * Clean up
387
+ */
388
+ destroy(): void {
389
+ window.removeEventListener('resize', this.updateIndicatorPositions.bind(this));
390
+ for (const anchor of this.anchors) {
391
+ anchor.indicator?.remove();
392
+ }
393
+ this.anchors = [];
394
+ }
395
+ }