@blorkfield/blork-tabs 0.3.1 → 0.4.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.
package/README.md CHANGED
@@ -8,6 +8,7 @@ A framework-agnostic tab/panel management system with snapping and docking capab
8
8
  - **Anchor Docking** - Dock panels to predefined screen positions
9
9
  - **Drag Modes** - Drag entire groups or detach individual panels
10
10
  - **Collapse/Expand** - Panels can be collapsed with automatic repositioning
11
+ - **Pin** - Pin panels to prevent dragging and opt out of auto-hide
11
12
  - **Auto-Hide** - Panels can hide after inactivity and show on interaction
12
13
  - **Event System** - Subscribe to drag, snap, and collapse events
13
14
  - **Fully Typed** - Complete TypeScript support
@@ -54,6 +55,7 @@ manager.registerPanel('my-panel', document.getElementById('my-panel'), {
54
55
  });
55
56
 
56
57
  // Position panels and create snap chains
58
+ // positionPanelsFromRight takes IDs right-to-left; createSnapChain takes them left-to-right
57
59
  manager.positionPanelsFromRight(['tools', 'settings']);
58
60
  manager.createSnapChain(['settings', 'tools']);
59
61
 
@@ -99,6 +101,40 @@ const manager = new TabManager({
99
101
  });
100
102
  ```
101
103
 
104
+ ## Panel Configuration
105
+
106
+ Each panel accepts the following options via `addPanel(config)`:
107
+
108
+ ```typescript
109
+ manager.addPanel({
110
+ id: 'my-panel', // Required. Unique identifier.
111
+ title: 'My Panel', // Header text.
112
+ width: 300, // Panel width in px (default: 300).
113
+ initialPosition: { x: 100, y: 100 }, // Starting position. Auto-placed if omitted.
114
+ content: '<div>...</div>', // HTML string or HTMLElement for the content area.
115
+
116
+ // Collapse
117
+ startCollapsed: true, // Whether the panel starts collapsed (default: true).
118
+ collapsible: true, // Show collapse button (default: true).
119
+
120
+ // Pin
121
+ pinnable: false, // Show pin button in header (default: false).
122
+ startPinned: false, // Initial pin state (default: false).
123
+
124
+ // Drag
125
+ draggable: true, // Allow dragging (default: true).
126
+ detachable: true, // Show detach grip for single-panel drag (default: true).
127
+
128
+ // Auto-hide overrides (see Auto-Hide section)
129
+ startHidden: false, // Override global startHidden for this panel.
130
+ autoHideDelay: undefined, // Override global autoHideDelay (0 = disable auto-hide).
131
+
132
+ // Z-index
133
+ zIndex: 1000, // Default z-index (default: 1000).
134
+ dragZIndex: 1002, // Z-index while dragging (default: 1002).
135
+ });
136
+ ```
137
+
102
138
  ## API Reference
103
139
 
104
140
  ### TabManager
@@ -115,12 +151,23 @@ const manager = new TabManager({
115
151
  - `getSnapChain(panelId)` - Get all panels in same chain
116
152
  - `snap(leftPanelId, rightPanelId)` - Manually snap two panels
117
153
  - `detach(panelId)` - Detach panel from chain
118
- - `createSnapChain(panelIds)` - Create chain from panel IDs
154
+ - `createSnapChain(panelIds)` - Create chain from panel IDs (left-to-right order)
119
155
  - `updatePositions()` - Recalculate snapped positions
120
156
 
121
157
  #### Positioning
122
- - `positionPanelsFromRight(panelIds, gap?)` - Position from right edge
123
- - `positionPanelsFromLeft(panelIds, gap?)` - Position from left edge
158
+
159
+ `positionPanelsFromRight` and `positionPanelsFromLeft` take panel IDs in **opposite orders**:
160
+
161
+ - `positionPanelsFromRight(panelIds, gap?)` — first ID is placed at the **right edge**, rest go leftward. Pass IDs **right-to-left**.
162
+ - `positionPanelsFromLeft(panelIds, gap?)` — first ID is placed at the **left edge**, rest go rightward. Pass IDs **left-to-right**.
163
+
164
+ Because `createSnapChain` uses left-to-right order, you'll typically reverse the array between the two calls:
165
+
166
+ ```typescript
167
+ // Visual order left-to-right: properties, tools, settings
168
+ manager.positionPanelsFromRight(['settings', 'tools', 'properties']); // right-to-left
169
+ manager.createSnapChain(['properties', 'tools', 'settings']); // left-to-right
170
+ ```
124
171
 
125
172
  #### Anchors
126
173
  - `addAnchor(config)` - Add custom anchor
@@ -149,10 +196,54 @@ const manager = new TabManager({
149
196
  | `snap:panel` | Panels snapped together |
150
197
  | `snap:anchor` | Panels snapped to anchor |
151
198
  | `panel:detached` | Panel detached from chain |
199
+ | `panel:pin` | Panel pinned/unpinned |
152
200
  | `panel:collapse` | Panel collapsed/expanded |
153
201
  | `panel:show` | Panel became visible (auto-hide) |
154
202
  | `panel:hide` | Panel became hidden (auto-hide) |
155
203
 
204
+ ## Drag Modes
205
+
206
+ Each panel header has two drag zones:
207
+
208
+ - **Detach grip** (small handle on the left of the header) — drags only that panel. It is detached from its snap chain and moved independently.
209
+ - **Title / header area** — drags the entire connected snap chain as a group.
210
+
211
+ Set `detachable: false` to hide the grip and make the header always drag the group.
212
+
213
+ ## Pinning
214
+
215
+ Pinning locks a panel in place and exempts it from auto-hide. Enable the pin button with `pinnable: true`:
216
+
217
+ ```typescript
218
+ manager.addPanel({
219
+ id: 'hud',
220
+ title: 'HUD',
221
+ pinnable: true,
222
+ startPinned: true, // start already pinned
223
+ autoHideDelay: 5000, // pin will override this
224
+ });
225
+ ```
226
+
227
+ ### What pinning does
228
+
229
+ - **Prevents dragging** — a pinned panel cannot be moved, even when dragging its header.
230
+ - **Immune to auto-hide** — a pinned panel is always visible regardless of the `autoHideDelay`. Pinning cancels any pending hide timer and shows the panel if it was hidden. Unpinning restarts the timer.
231
+ - **Splits snap chains on drag** — if a pinned panel sits in the middle of a chain and you drag a neighbour, the chain severs at the pin boundary. Only the panels on the same side as the grabbed panel move; the pinned panel and everything beyond it stay put.
232
+
233
+ ```typescript
234
+ // Chain: A — B — [P pinned] — C — D
235
+ // Grabbing B moves [A, B] and severs the B↔P bond.
236
+ // Grabbing C moves [C, D] and severs the P↔C bond.
237
+ ```
238
+
239
+ ### Events
240
+
241
+ ```typescript
242
+ manager.on('panel:pin', ({ panel, isPinned }) => {
243
+ console.log(`${panel.id} is now ${isPinned ? 'pinned' : 'unpinned'}`);
244
+ });
245
+ ```
246
+
156
247
  ## Multi-Section Panel Content
157
248
 
158
249
  When creating panels with multiple sections (like a command menu with categories), put all sections within a **single `content` string**. Do not use multiple panels or multiple content wrappers—this breaks scrolling and causes content cutoff.
@@ -231,6 +322,10 @@ manager.addPanel({
231
322
  | `false` | `3000` | Starts visible, hides after 3s of inactivity |
232
323
  | `true` | `3000` | Starts hidden, shows on activity, hides after 3s of inactivity |
233
324
 
325
+ ### Pin interaction
326
+
327
+ Pinning a panel overrides auto-hide entirely for that panel — it stays visible regardless of inactivity. Unpinning re-enables the timer. Each panel is tracked independently; pinning one panel in a group has no effect on its neighbours.
328
+
234
329
  ### Events
235
330
 
236
331
  ```typescript
package/dist/index.cjs CHANGED
@@ -39,6 +39,7 @@ __export(index_exports, {
39
39
  getDefaultZIndex: () => getDefaultZIndex,
40
40
  getDragZIndex: () => getDragZIndex,
41
41
  getLeftmostPanel: () => getLeftmostPanel,
42
+ getMovingGroupRespectingPins: () => getMovingGroupRespectingPins,
42
43
  getPanelDimensions: () => getPanelDimensions,
43
44
  getPanelPosition: () => getPanelPosition,
44
45
  getRightmostPanel: () => getRightmostPanel,
@@ -50,6 +51,7 @@ __export(index_exports, {
50
51
  snapPanels: () => snapPanels,
51
52
  snapPanelsToTarget: () => snapPanelsToTarget,
52
53
  toggleCollapse: () => toggleCollapse,
54
+ togglePin: () => togglePin,
53
55
  unsnap: () => unsnap,
54
56
  updateSnappedPositions: () => updateSnappedPositions
55
57
  });
@@ -199,6 +201,8 @@ function setupHoverEnlarge(config) {
199
201
  }
200
202
 
201
203
  // src/Panel.ts
204
+ var PIN_ICON_UNPINNED = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block"><line x1="12" y1="17" x2="12" y2="22"/><path d="M5 17H19V16L17 11V4H7L5 11V16Z"/><line x1="5" y1="11" x2="19" y2="11"/></svg>`;
205
+ var PIN_ICON_PINNED = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="14" height="14" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block;transform:rotate(90deg)"><line x1="12" y1="17" x2="12" y2="22"/><path d="M5 17H19V16L17 11V4H7L5 11V16Z"/><line x1="5" y1="11" x2="19" y2="11"/></svg>`;
202
206
  function createPanelElement(config, classes) {
203
207
  const element = document.createElement("div");
204
208
  element.className = classes.panel;
@@ -221,6 +225,14 @@ function createPanelElement(config, classes) {
221
225
  title.textContent = config.title;
222
226
  header.appendChild(title);
223
227
  }
228
+ let pinButton = null;
229
+ if (config.pinnable === true) {
230
+ pinButton = document.createElement("button");
231
+ pinButton.className = classes.pinButton;
232
+ pinButton.id = `${config.id}-pin-btn`;
233
+ pinButton.innerHTML = config.startPinned === true ? PIN_ICON_PINNED : PIN_ICON_UNPINNED;
234
+ header.appendChild(pinButton);
235
+ }
224
236
  let collapseButton = null;
225
237
  if (config.collapsible !== false) {
226
238
  collapseButton = document.createElement("button");
@@ -247,6 +259,7 @@ function createPanelElement(config, classes) {
247
259
  return {
248
260
  element,
249
261
  dragHandle: header,
262
+ pinButton,
250
263
  collapseButton,
251
264
  contentWrapper,
252
265
  detachGrip
@@ -255,12 +268,14 @@ function createPanelElement(config, classes) {
255
268
  function createPanelState(config, classes, globalConfig) {
256
269
  let element;
257
270
  let dragHandle;
271
+ let pinButton;
258
272
  let collapseButton;
259
273
  let contentWrapper;
260
274
  let detachGrip;
261
275
  if (config.element) {
262
276
  element = config.element;
263
277
  dragHandle = config.dragHandle ?? element.querySelector(`.${classes.panelHeader}`);
278
+ pinButton = config.pinButton ?? element.querySelector(`.${classes.pinButton}`);
264
279
  collapseButton = config.collapseButton ?? element.querySelector(`.${classes.collapseButton}`);
265
280
  contentWrapper = config.contentWrapper ?? element.querySelector(`.${classes.panelContent}`);
266
281
  detachGrip = config.detachGrip ?? element.querySelector(`.${classes.detachGrip}`);
@@ -268,6 +283,7 @@ function createPanelState(config, classes, globalConfig) {
268
283
  const created = createPanelElement(config, classes);
269
284
  element = created.element;
270
285
  dragHandle = created.dragHandle;
286
+ pinButton = created.pinButton;
271
287
  collapseButton = created.collapseButton;
272
288
  contentWrapper = created.contentWrapper;
273
289
  detachGrip = created.detachGrip;
@@ -281,9 +297,11 @@ function createPanelState(config, classes, globalConfig) {
281
297
  id: config.id,
282
298
  element,
283
299
  dragHandle,
300
+ pinButton,
284
301
  collapseButton,
285
302
  contentWrapper,
286
303
  detachGrip,
304
+ isPinned: config.startPinned === true,
287
305
  isCollapsed: config.startCollapsed !== false,
288
306
  snappedTo: null,
289
307
  snappedFrom: null,
@@ -305,6 +323,14 @@ function toggleCollapse(state, classes, collapsed) {
305
323
  }
306
324
  return newState;
307
325
  }
326
+ function togglePin(state, pinned) {
327
+ const newState = pinned ?? !state.isPinned;
328
+ state.isPinned = newState;
329
+ if (state.pinButton) {
330
+ state.pinButton.innerHTML = newState ? PIN_ICON_PINNED : PIN_ICON_UNPINNED;
331
+ }
332
+ return newState;
333
+ }
308
334
  function showPanel(state, classes) {
309
335
  if (!state.isHidden) return;
310
336
  state.isHidden = false;
@@ -482,6 +508,28 @@ function snapPanels(leftPanel, rightPanel) {
482
508
  leftPanel.snappedTo = rightPanel.id;
483
509
  rightPanel.snappedFrom = leftPanel.id;
484
510
  }
511
+ function getMovingGroupRespectingPins(grabbedPanel, panels) {
512
+ if (grabbedPanel.isPinned) return [];
513
+ const fullGroup = getConnectedGroup(grabbedPanel, panels);
514
+ const grabbedIndex = fullGroup.indexOf(grabbedPanel);
515
+ const leftPanels = [];
516
+ for (let i = grabbedIndex; i >= 0; i--) {
517
+ if (fullGroup[i].isPinned) {
518
+ unsnap(fullGroup[i], fullGroup[i + 1]);
519
+ break;
520
+ }
521
+ leftPanels.unshift(fullGroup[i]);
522
+ }
523
+ const rightPanels = [];
524
+ for (let i = grabbedIndex + 1; i < fullGroup.length; i++) {
525
+ if (fullGroup[i].isPinned) {
526
+ unsnap(fullGroup[i - 1], fullGroup[i]);
527
+ break;
528
+ }
529
+ rightPanels.push(fullGroup[i]);
530
+ }
531
+ return [...leftPanels, ...rightPanels];
532
+ }
485
533
  function unsnap(leftPanel, rightPanel) {
486
534
  if (leftPanel.snappedTo === rightPanel.id) {
487
535
  leftPanel.snappedTo = null;
@@ -509,18 +557,18 @@ var DragManager = class {
509
557
  startDrag(e, panel, mode) {
510
558
  e.preventDefault();
511
559
  e.stopPropagation();
512
- const connectedPanels = getConnectedGroup(panel, this.panels);
513
- const initialGroupPositions = /* @__PURE__ */ new Map();
514
- for (const p of connectedPanels) {
515
- const rect2 = p.element.getBoundingClientRect();
516
- initialGroupPositions.set(p.id, { x: rect2.left, y: rect2.top });
517
- }
518
560
  let movingPanels;
519
561
  if (mode === "single") {
520
562
  detachFromGroup(panel, this.panels);
521
563
  movingPanels = [panel];
522
564
  } else {
523
- movingPanels = connectedPanels;
565
+ movingPanels = getMovingGroupRespectingPins(panel, this.panels);
566
+ if (movingPanels.length === 0) return;
567
+ }
568
+ const initialGroupPositions = /* @__PURE__ */ new Map();
569
+ for (const p of movingPanels) {
570
+ const rect2 = p.element.getBoundingClientRect();
571
+ initialGroupPositions.set(p.id, { x: rect2.left, y: rect2.top });
524
572
  }
525
573
  const rect = panel.element.getBoundingClientRect();
526
574
  this.activeDrag = {
@@ -1010,6 +1058,7 @@ var AutoHideManager = class {
1010
1058
  */
1011
1059
  handleActivity() {
1012
1060
  for (const panel of this.panels.values()) {
1061
+ if (panel.isPinned) continue;
1013
1062
  if (this.pausedPanels.has(panel.id)) continue;
1014
1063
  if (panel.resolvedAutoHideDelay !== void 0 || panel.isHidden) {
1015
1064
  this.show(panel, "activity");
@@ -1070,9 +1119,27 @@ var AutoHideManager = class {
1070
1119
  */
1071
1120
  hide(panel, trigger) {
1072
1121
  if (panel.isHidden) return;
1122
+ if (panel.isPinned) return;
1073
1123
  hidePanel(panel, this.classes);
1074
1124
  this.callbacks.onHide?.(panel, trigger);
1075
1125
  }
1126
+ /**
1127
+ * Called when a panel's pin state changes.
1128
+ * Pinning cancels the hide timer and reveals the panel if hidden.
1129
+ * Unpinning restarts the timer if the panel participates in auto-hide.
1130
+ */
1131
+ onPanelPinChanged(panel) {
1132
+ if (panel.isPinned) {
1133
+ this.clearTimer(panel.id);
1134
+ if (panel.isHidden) {
1135
+ this.show(panel, "api");
1136
+ }
1137
+ } else {
1138
+ if (!panel.isHidden && panel.resolvedAutoHideDelay !== void 0) {
1139
+ this.scheduleHide(panel);
1140
+ }
1141
+ }
1142
+ }
1076
1143
  /**
1077
1144
  * Initialize a newly added panel's auto-hide state
1078
1145
  */
@@ -1127,6 +1194,7 @@ function generateClasses(prefix) {
1127
1194
  panelContent: `${prefix}-content`,
1128
1195
  panelContentCollapsed: `${prefix}-content-collapsed`,
1129
1196
  detachGrip: `${prefix}-detach-grip`,
1197
+ pinButton: `${prefix}-pin-btn`,
1130
1198
  collapseButton: `${prefix}-collapse-btn`,
1131
1199
  snapPreview: `${prefix}-snap-preview`,
1132
1200
  snapPreviewVisible: `${prefix}-snap-preview-visible`,
@@ -1217,6 +1285,7 @@ var TabManager = class {
1217
1285
  id,
1218
1286
  element,
1219
1287
  dragHandle: options.dragHandle,
1288
+ pinButton: options.pinButton,
1220
1289
  collapseButton: options.collapseButton,
1221
1290
  contentWrapper: options.contentWrapper,
1222
1291
  detachGrip: options.detachGrip,
@@ -1306,15 +1375,25 @@ var TabManager = class {
1306
1375
  this.emit("panel:collapse", { panel: state, isCollapsed: newState });
1307
1376
  });
1308
1377
  }
1378
+ if (state.pinButton) {
1379
+ state.pinButton.addEventListener("click", () => {
1380
+ const isPinned = togglePin(state);
1381
+ this.autoHideManager.onPanelPinChanged(state);
1382
+ this.emit("panel:pin", { panel: state, isPinned });
1383
+ });
1384
+ }
1309
1385
  if (state.detachGrip) {
1310
1386
  state.detachGrip.addEventListener("mousedown", (e) => {
1387
+ if (state.isPinned) return;
1311
1388
  this.dragManager.startDrag(e, state, "single");
1312
1389
  });
1313
1390
  }
1314
1391
  state.dragHandle.addEventListener("mousedown", (e) => {
1315
- if (e.target === state.collapseButton || e.target === state.detachGrip) {
1392
+ const target = e.target;
1393
+ if (e.target === state.collapseButton || e.target === state.detachGrip || state.pinButton && state.pinButton.contains(target)) {
1316
1394
  return;
1317
1395
  }
1396
+ if (state.isPinned) return;
1318
1397
  this.dragManager.startDrag(e, state, "group");
1319
1398
  });
1320
1399
  }
@@ -1575,6 +1654,7 @@ var TabManager = class {
1575
1654
  getDefaultZIndex,
1576
1655
  getDragZIndex,
1577
1656
  getLeftmostPanel,
1657
+ getMovingGroupRespectingPins,
1578
1658
  getPanelDimensions,
1579
1659
  getPanelPosition,
1580
1660
  getRightmostPanel,
@@ -1586,6 +1666,7 @@ var TabManager = class {
1586
1666
  snapPanels,
1587
1667
  snapPanelsToTarget,
1588
1668
  toggleCollapse,
1669
+ togglePin,
1589
1670
  unsnap,
1590
1671
  updateSnappedPositions
1591
1672
  });
package/dist/index.d.cts CHANGED
@@ -52,6 +52,8 @@ interface PanelConfig {
52
52
  title?: string;
53
53
  /** Initial width (default: 300) */
54
54
  width?: number;
55
+ /** Initial pin state (default: false) */
56
+ startPinned?: boolean;
55
57
  /** Initial collapsed state (default: true) */
56
58
  startCollapsed?: boolean;
57
59
  /** Initial position (optional - will be auto-positioned if not provided) */
@@ -65,12 +67,16 @@ interface PanelConfig {
65
67
  element?: HTMLDivElement;
66
68
  /** Custom drag handle element */
67
69
  dragHandle?: HTMLDivElement;
70
+ /** Custom pin button element */
71
+ pinButton?: HTMLButtonElement;
68
72
  /** Custom collapse button element */
69
73
  collapseButton?: HTMLButtonElement;
70
74
  /** Custom content wrapper element */
71
75
  contentWrapper?: HTMLDivElement;
72
76
  /** Custom detach grip element */
73
77
  detachGrip?: HTMLDivElement;
78
+ /** Whether panel can be pinned (default: false) */
79
+ pinnable?: boolean;
74
80
  /** Whether panel can be collapsed (default: true) */
75
81
  collapsible?: boolean;
76
82
  /** Whether panel can be detached from group (default: true) */
@@ -111,12 +117,16 @@ interface PanelState {
111
117
  element: HTMLDivElement;
112
118
  /** Drag handle element */
113
119
  dragHandle: HTMLDivElement;
120
+ /** Pin button element (if pinable) */
121
+ pinButton: HTMLButtonElement | null;
114
122
  /** Collapse button element (if collapsible) */
115
123
  collapseButton: HTMLButtonElement | null;
116
124
  /** Content wrapper element */
117
125
  contentWrapper: HTMLDivElement;
118
126
  /** Detach grip element (if detachable) */
119
127
  detachGrip: HTMLDivElement | null;
128
+ /** Whether panel is currently pinned */
129
+ isPinned: boolean;
120
130
  /** Whether panel is currently collapsed */
121
131
  isCollapsed: boolean;
122
132
  /** ID of panel this is snapped to on its right (outgoing link) */
@@ -226,6 +236,8 @@ interface TabManagerEvents {
226
236
  'snap:anchor': AnchorSnapEvent;
227
237
  /** Fired when a panel is detached from group */
228
238
  'panel:detached': PanelDetachedEvent;
239
+ /** Fired when panel pin state changes */
240
+ 'panel:pin': PanelPinEvent;
229
241
  /** Fired when panel collapse state changes */
230
242
  'panel:collapse': PanelCollapseEvent;
231
243
  /** Fired when a panel becomes visible (auto-hide) */
@@ -269,6 +281,10 @@ interface PanelDetachedEvent {
269
281
  panel: PanelState;
270
282
  previousGroup: PanelState[];
271
283
  }
284
+ interface PanelPinEvent {
285
+ panel: PanelState;
286
+ isPinned: boolean;
287
+ }
272
288
  interface PanelCollapseEvent {
273
289
  panel: PanelState;
274
290
  isCollapsed: boolean;
@@ -295,6 +311,7 @@ interface CSSClasses {
295
311
  panelContent: string;
296
312
  panelContentCollapsed: string;
297
313
  detachGrip: string;
314
+ pinButton: string;
298
315
  collapseButton: string;
299
316
  snapPreview: string;
300
317
  snapPreviewVisible: string;
@@ -403,6 +420,7 @@ declare class TabManager {
403
420
  */
404
421
  registerPanel(id: string, element: HTMLDivElement, options?: {
405
422
  dragHandle?: HTMLDivElement;
423
+ pinButton?: HTMLButtonElement;
406
424
  collapseButton?: HTMLButtonElement;
407
425
  contentWrapper?: HTMLDivElement;
408
426
  detachGrip?: HTMLDivElement;
@@ -734,6 +752,12 @@ declare class AutoHideManager {
734
752
  * Hide a panel
735
753
  */
736
754
  hide(panel: PanelState, trigger: 'timeout' | 'api'): void;
755
+ /**
756
+ * Called when a panel's pin state changes.
757
+ * Pinning cancels the hide timer and reveals the panel if hidden.
758
+ * Unpinning restarts the timer if the panel participates in auto-hide.
759
+ */
760
+ onPanelPinChanged(panel: PanelState): void;
737
761
  /**
738
762
  * Initialize a newly added panel's auto-hide state
739
763
  */
@@ -810,6 +834,7 @@ declare function setupHoverEnlarge(config: HoverEnlargeConfig): void;
810
834
  declare function createPanelElement(config: PanelConfig, classes: CSSClasses): {
811
835
  element: HTMLDivElement;
812
836
  dragHandle: HTMLDivElement;
837
+ pinButton: HTMLButtonElement | null;
813
838
  collapseButton: HTMLButtonElement | null;
814
839
  contentWrapper: HTMLDivElement;
815
840
  detachGrip: HTMLDivElement | null;
@@ -825,6 +850,10 @@ declare function createPanelState(config: PanelConfig, classes: CSSClasses, glob
825
850
  * Toggle panel collapse state
826
851
  */
827
852
  declare function toggleCollapse(state: PanelState, classes: CSSClasses, collapsed?: boolean): boolean;
853
+ /**
854
+ * Toggle panel pin state
855
+ */
856
+ declare function togglePin(state: PanelState, pinned?: boolean): boolean;
828
857
  /**
829
858
  * Show a hidden panel
830
859
  */
@@ -909,9 +938,21 @@ declare function areInSameChain(panel1: PanelState, panel2: PanelState, panels:
909
938
  * Establish a snap relationship between two panels
910
939
  */
911
940
  declare function snapPanels(leftPanel: PanelState, rightPanel: PanelState): void;
941
+ /**
942
+ * Get the subset of connected panels that should move when a panel is grabbed,
943
+ * stopping at any pinned panel in the chain.
944
+ *
945
+ * Pinned panels act as immoveable barriers — the chain splits at each one,
946
+ * and only the panels on the same side as the grabbed panel are returned.
947
+ * The snap bonds at each pin boundary are severed as a side effect.
948
+ *
949
+ * Example: chain [A, B, P(pinned), C, D], grab B → returns [A, B], unseats B↔P
950
+ * Example: chain [A, P(pinned), B, C], grab C → returns [B, C], unseats P↔B
951
+ */
952
+ declare function getMovingGroupRespectingPins(grabbedPanel: PanelState, panels: Map<string, PanelState>): PanelState[];
912
953
  /**
913
954
  * Break the snap relationship between two specific panels
914
955
  */
915
956
  declare function unsnap(leftPanel: PanelState, rightPanel: PanelState): void;
916
957
 
917
- export { type AnchorConfig, AnchorManager, type AnchorPreset, type AnchorSnapEvent, type AnchorSnapResult, type AnchorState, type AutoHideCallbacks, AutoHideManager, type Bounds, type CSSClasses, type DebugLog, type DebugLogConfig, type DebugLogLevel, type DebugPanel, type DebugPanelConfig, type DragEndEvent, DragManager, type DragMode, type DragMoveEvent, type DragStartEvent, type DragState, type EventListener, type PanelAddedEvent, type PanelCollapseEvent, type PanelConfig, type PanelDetachedEvent, type PanelHideEvent, type PanelRemovedEvent, type PanelShowEvent, type PanelSnapEvent, type PanelState, type Position, type ResolvedTabManagerConfig, SnapPreview, type SnapSide, type SnapTarget, TabManager, type TabManagerConfig, type TabManagerEvents, areInSameChain, createDebugLog, createDebugPanelContent, createDebugPanelInterface, createPanelElement, createPanelState, createPresetAnchor, detachFromGroup, findSnapTarget, getConnectedGroup, getDefaultAnchorConfigs, getDefaultZIndex, getDragZIndex, getLeftmostPanel, getPanelDimensions, getPanelPosition, getRightmostPanel, hidePanel, setPanelPosition, setPanelZIndex, setupHoverEnlarge, showPanel, snapPanels, snapPanelsToTarget, toggleCollapse, unsnap, updateSnappedPositions };
958
+ export { type AnchorConfig, AnchorManager, type AnchorPreset, type AnchorSnapEvent, type AnchorSnapResult, type AnchorState, type AutoHideCallbacks, AutoHideManager, type Bounds, type CSSClasses, type DebugLog, type DebugLogConfig, type DebugLogLevel, type DebugPanel, type DebugPanelConfig, type DragEndEvent, DragManager, type DragMode, type DragMoveEvent, type DragStartEvent, type DragState, type EventListener, type PanelAddedEvent, type PanelCollapseEvent, type PanelConfig, type PanelDetachedEvent, type PanelHideEvent, type PanelPinEvent, type PanelRemovedEvent, type PanelShowEvent, type PanelSnapEvent, type PanelState, type Position, type ResolvedTabManagerConfig, SnapPreview, type SnapSide, type SnapTarget, TabManager, type TabManagerConfig, type TabManagerEvents, areInSameChain, createDebugLog, createDebugPanelContent, createDebugPanelInterface, createPanelElement, createPanelState, createPresetAnchor, detachFromGroup, findSnapTarget, getConnectedGroup, getDefaultAnchorConfigs, getDefaultZIndex, getDragZIndex, getLeftmostPanel, getMovingGroupRespectingPins, getPanelDimensions, getPanelPosition, getRightmostPanel, hidePanel, setPanelPosition, setPanelZIndex, setupHoverEnlarge, showPanel, snapPanels, snapPanelsToTarget, toggleCollapse, togglePin, unsnap, updateSnappedPositions };
package/dist/index.d.ts CHANGED
@@ -52,6 +52,8 @@ interface PanelConfig {
52
52
  title?: string;
53
53
  /** Initial width (default: 300) */
54
54
  width?: number;
55
+ /** Initial pin state (default: false) */
56
+ startPinned?: boolean;
55
57
  /** Initial collapsed state (default: true) */
56
58
  startCollapsed?: boolean;
57
59
  /** Initial position (optional - will be auto-positioned if not provided) */
@@ -65,12 +67,16 @@ interface PanelConfig {
65
67
  element?: HTMLDivElement;
66
68
  /** Custom drag handle element */
67
69
  dragHandle?: HTMLDivElement;
70
+ /** Custom pin button element */
71
+ pinButton?: HTMLButtonElement;
68
72
  /** Custom collapse button element */
69
73
  collapseButton?: HTMLButtonElement;
70
74
  /** Custom content wrapper element */
71
75
  contentWrapper?: HTMLDivElement;
72
76
  /** Custom detach grip element */
73
77
  detachGrip?: HTMLDivElement;
78
+ /** Whether panel can be pinned (default: false) */
79
+ pinnable?: boolean;
74
80
  /** Whether panel can be collapsed (default: true) */
75
81
  collapsible?: boolean;
76
82
  /** Whether panel can be detached from group (default: true) */
@@ -111,12 +117,16 @@ interface PanelState {
111
117
  element: HTMLDivElement;
112
118
  /** Drag handle element */
113
119
  dragHandle: HTMLDivElement;
120
+ /** Pin button element (if pinable) */
121
+ pinButton: HTMLButtonElement | null;
114
122
  /** Collapse button element (if collapsible) */
115
123
  collapseButton: HTMLButtonElement | null;
116
124
  /** Content wrapper element */
117
125
  contentWrapper: HTMLDivElement;
118
126
  /** Detach grip element (if detachable) */
119
127
  detachGrip: HTMLDivElement | null;
128
+ /** Whether panel is currently pinned */
129
+ isPinned: boolean;
120
130
  /** Whether panel is currently collapsed */
121
131
  isCollapsed: boolean;
122
132
  /** ID of panel this is snapped to on its right (outgoing link) */
@@ -226,6 +236,8 @@ interface TabManagerEvents {
226
236
  'snap:anchor': AnchorSnapEvent;
227
237
  /** Fired when a panel is detached from group */
228
238
  'panel:detached': PanelDetachedEvent;
239
+ /** Fired when panel pin state changes */
240
+ 'panel:pin': PanelPinEvent;
229
241
  /** Fired when panel collapse state changes */
230
242
  'panel:collapse': PanelCollapseEvent;
231
243
  /** Fired when a panel becomes visible (auto-hide) */
@@ -269,6 +281,10 @@ interface PanelDetachedEvent {
269
281
  panel: PanelState;
270
282
  previousGroup: PanelState[];
271
283
  }
284
+ interface PanelPinEvent {
285
+ panel: PanelState;
286
+ isPinned: boolean;
287
+ }
272
288
  interface PanelCollapseEvent {
273
289
  panel: PanelState;
274
290
  isCollapsed: boolean;
@@ -295,6 +311,7 @@ interface CSSClasses {
295
311
  panelContent: string;
296
312
  panelContentCollapsed: string;
297
313
  detachGrip: string;
314
+ pinButton: string;
298
315
  collapseButton: string;
299
316
  snapPreview: string;
300
317
  snapPreviewVisible: string;
@@ -403,6 +420,7 @@ declare class TabManager {
403
420
  */
404
421
  registerPanel(id: string, element: HTMLDivElement, options?: {
405
422
  dragHandle?: HTMLDivElement;
423
+ pinButton?: HTMLButtonElement;
406
424
  collapseButton?: HTMLButtonElement;
407
425
  contentWrapper?: HTMLDivElement;
408
426
  detachGrip?: HTMLDivElement;
@@ -734,6 +752,12 @@ declare class AutoHideManager {
734
752
  * Hide a panel
735
753
  */
736
754
  hide(panel: PanelState, trigger: 'timeout' | 'api'): void;
755
+ /**
756
+ * Called when a panel's pin state changes.
757
+ * Pinning cancels the hide timer and reveals the panel if hidden.
758
+ * Unpinning restarts the timer if the panel participates in auto-hide.
759
+ */
760
+ onPanelPinChanged(panel: PanelState): void;
737
761
  /**
738
762
  * Initialize a newly added panel's auto-hide state
739
763
  */
@@ -810,6 +834,7 @@ declare function setupHoverEnlarge(config: HoverEnlargeConfig): void;
810
834
  declare function createPanelElement(config: PanelConfig, classes: CSSClasses): {
811
835
  element: HTMLDivElement;
812
836
  dragHandle: HTMLDivElement;
837
+ pinButton: HTMLButtonElement | null;
813
838
  collapseButton: HTMLButtonElement | null;
814
839
  contentWrapper: HTMLDivElement;
815
840
  detachGrip: HTMLDivElement | null;
@@ -825,6 +850,10 @@ declare function createPanelState(config: PanelConfig, classes: CSSClasses, glob
825
850
  * Toggle panel collapse state
826
851
  */
827
852
  declare function toggleCollapse(state: PanelState, classes: CSSClasses, collapsed?: boolean): boolean;
853
+ /**
854
+ * Toggle panel pin state
855
+ */
856
+ declare function togglePin(state: PanelState, pinned?: boolean): boolean;
828
857
  /**
829
858
  * Show a hidden panel
830
859
  */
@@ -909,9 +938,21 @@ declare function areInSameChain(panel1: PanelState, panel2: PanelState, panels:
909
938
  * Establish a snap relationship between two panels
910
939
  */
911
940
  declare function snapPanels(leftPanel: PanelState, rightPanel: PanelState): void;
941
+ /**
942
+ * Get the subset of connected panels that should move when a panel is grabbed,
943
+ * stopping at any pinned panel in the chain.
944
+ *
945
+ * Pinned panels act as immoveable barriers — the chain splits at each one,
946
+ * and only the panels on the same side as the grabbed panel are returned.
947
+ * The snap bonds at each pin boundary are severed as a side effect.
948
+ *
949
+ * Example: chain [A, B, P(pinned), C, D], grab B → returns [A, B], unseats B↔P
950
+ * Example: chain [A, P(pinned), B, C], grab C → returns [B, C], unseats P↔B
951
+ */
952
+ declare function getMovingGroupRespectingPins(grabbedPanel: PanelState, panels: Map<string, PanelState>): PanelState[];
912
953
  /**
913
954
  * Break the snap relationship between two specific panels
914
955
  */
915
956
  declare function unsnap(leftPanel: PanelState, rightPanel: PanelState): void;
916
957
 
917
- export { type AnchorConfig, AnchorManager, type AnchorPreset, type AnchorSnapEvent, type AnchorSnapResult, type AnchorState, type AutoHideCallbacks, AutoHideManager, type Bounds, type CSSClasses, type DebugLog, type DebugLogConfig, type DebugLogLevel, type DebugPanel, type DebugPanelConfig, type DragEndEvent, DragManager, type DragMode, type DragMoveEvent, type DragStartEvent, type DragState, type EventListener, type PanelAddedEvent, type PanelCollapseEvent, type PanelConfig, type PanelDetachedEvent, type PanelHideEvent, type PanelRemovedEvent, type PanelShowEvent, type PanelSnapEvent, type PanelState, type Position, type ResolvedTabManagerConfig, SnapPreview, type SnapSide, type SnapTarget, TabManager, type TabManagerConfig, type TabManagerEvents, areInSameChain, createDebugLog, createDebugPanelContent, createDebugPanelInterface, createPanelElement, createPanelState, createPresetAnchor, detachFromGroup, findSnapTarget, getConnectedGroup, getDefaultAnchorConfigs, getDefaultZIndex, getDragZIndex, getLeftmostPanel, getPanelDimensions, getPanelPosition, getRightmostPanel, hidePanel, setPanelPosition, setPanelZIndex, setupHoverEnlarge, showPanel, snapPanels, snapPanelsToTarget, toggleCollapse, unsnap, updateSnappedPositions };
958
+ export { type AnchorConfig, AnchorManager, type AnchorPreset, type AnchorSnapEvent, type AnchorSnapResult, type AnchorState, type AutoHideCallbacks, AutoHideManager, type Bounds, type CSSClasses, type DebugLog, type DebugLogConfig, type DebugLogLevel, type DebugPanel, type DebugPanelConfig, type DragEndEvent, DragManager, type DragMode, type DragMoveEvent, type DragStartEvent, type DragState, type EventListener, type PanelAddedEvent, type PanelCollapseEvent, type PanelConfig, type PanelDetachedEvent, type PanelHideEvent, type PanelPinEvent, type PanelRemovedEvent, type PanelShowEvent, type PanelSnapEvent, type PanelState, type Position, type ResolvedTabManagerConfig, SnapPreview, type SnapSide, type SnapTarget, TabManager, type TabManagerConfig, type TabManagerEvents, areInSameChain, createDebugLog, createDebugPanelContent, createDebugPanelInterface, createPanelElement, createPanelState, createPresetAnchor, detachFromGroup, findSnapTarget, getConnectedGroup, getDefaultAnchorConfigs, getDefaultZIndex, getDragZIndex, getLeftmostPanel, getMovingGroupRespectingPins, getPanelDimensions, getPanelPosition, getRightmostPanel, hidePanel, setPanelPosition, setPanelZIndex, setupHoverEnlarge, showPanel, snapPanels, snapPanelsToTarget, toggleCollapse, togglePin, unsnap, updateSnappedPositions };
package/dist/index.js CHANGED
@@ -142,6 +142,8 @@ function setupHoverEnlarge(config) {
142
142
  }
143
143
 
144
144
  // src/Panel.ts
145
+ var PIN_ICON_UNPINNED = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block"><line x1="12" y1="17" x2="12" y2="22"/><path d="M5 17H19V16L17 11V4H7L5 11V16Z"/><line x1="5" y1="11" x2="19" y2="11"/></svg>`;
146
+ var PIN_ICON_PINNED = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="14" height="14" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block;transform:rotate(90deg)"><line x1="12" y1="17" x2="12" y2="22"/><path d="M5 17H19V16L17 11V4H7L5 11V16Z"/><line x1="5" y1="11" x2="19" y2="11"/></svg>`;
145
147
  function createPanelElement(config, classes) {
146
148
  const element = document.createElement("div");
147
149
  element.className = classes.panel;
@@ -164,6 +166,14 @@ function createPanelElement(config, classes) {
164
166
  title.textContent = config.title;
165
167
  header.appendChild(title);
166
168
  }
169
+ let pinButton = null;
170
+ if (config.pinnable === true) {
171
+ pinButton = document.createElement("button");
172
+ pinButton.className = classes.pinButton;
173
+ pinButton.id = `${config.id}-pin-btn`;
174
+ pinButton.innerHTML = config.startPinned === true ? PIN_ICON_PINNED : PIN_ICON_UNPINNED;
175
+ header.appendChild(pinButton);
176
+ }
167
177
  let collapseButton = null;
168
178
  if (config.collapsible !== false) {
169
179
  collapseButton = document.createElement("button");
@@ -190,6 +200,7 @@ function createPanelElement(config, classes) {
190
200
  return {
191
201
  element,
192
202
  dragHandle: header,
203
+ pinButton,
193
204
  collapseButton,
194
205
  contentWrapper,
195
206
  detachGrip
@@ -198,12 +209,14 @@ function createPanelElement(config, classes) {
198
209
  function createPanelState(config, classes, globalConfig) {
199
210
  let element;
200
211
  let dragHandle;
212
+ let pinButton;
201
213
  let collapseButton;
202
214
  let contentWrapper;
203
215
  let detachGrip;
204
216
  if (config.element) {
205
217
  element = config.element;
206
218
  dragHandle = config.dragHandle ?? element.querySelector(`.${classes.panelHeader}`);
219
+ pinButton = config.pinButton ?? element.querySelector(`.${classes.pinButton}`);
207
220
  collapseButton = config.collapseButton ?? element.querySelector(`.${classes.collapseButton}`);
208
221
  contentWrapper = config.contentWrapper ?? element.querySelector(`.${classes.panelContent}`);
209
222
  detachGrip = config.detachGrip ?? element.querySelector(`.${classes.detachGrip}`);
@@ -211,6 +224,7 @@ function createPanelState(config, classes, globalConfig) {
211
224
  const created = createPanelElement(config, classes);
212
225
  element = created.element;
213
226
  dragHandle = created.dragHandle;
227
+ pinButton = created.pinButton;
214
228
  collapseButton = created.collapseButton;
215
229
  contentWrapper = created.contentWrapper;
216
230
  detachGrip = created.detachGrip;
@@ -224,9 +238,11 @@ function createPanelState(config, classes, globalConfig) {
224
238
  id: config.id,
225
239
  element,
226
240
  dragHandle,
241
+ pinButton,
227
242
  collapseButton,
228
243
  contentWrapper,
229
244
  detachGrip,
245
+ isPinned: config.startPinned === true,
230
246
  isCollapsed: config.startCollapsed !== false,
231
247
  snappedTo: null,
232
248
  snappedFrom: null,
@@ -248,6 +264,14 @@ function toggleCollapse(state, classes, collapsed) {
248
264
  }
249
265
  return newState;
250
266
  }
267
+ function togglePin(state, pinned) {
268
+ const newState = pinned ?? !state.isPinned;
269
+ state.isPinned = newState;
270
+ if (state.pinButton) {
271
+ state.pinButton.innerHTML = newState ? PIN_ICON_PINNED : PIN_ICON_UNPINNED;
272
+ }
273
+ return newState;
274
+ }
251
275
  function showPanel(state, classes) {
252
276
  if (!state.isHidden) return;
253
277
  state.isHidden = false;
@@ -425,6 +449,28 @@ function snapPanels(leftPanel, rightPanel) {
425
449
  leftPanel.snappedTo = rightPanel.id;
426
450
  rightPanel.snappedFrom = leftPanel.id;
427
451
  }
452
+ function getMovingGroupRespectingPins(grabbedPanel, panels) {
453
+ if (grabbedPanel.isPinned) return [];
454
+ const fullGroup = getConnectedGroup(grabbedPanel, panels);
455
+ const grabbedIndex = fullGroup.indexOf(grabbedPanel);
456
+ const leftPanels = [];
457
+ for (let i = grabbedIndex; i >= 0; i--) {
458
+ if (fullGroup[i].isPinned) {
459
+ unsnap(fullGroup[i], fullGroup[i + 1]);
460
+ break;
461
+ }
462
+ leftPanels.unshift(fullGroup[i]);
463
+ }
464
+ const rightPanels = [];
465
+ for (let i = grabbedIndex + 1; i < fullGroup.length; i++) {
466
+ if (fullGroup[i].isPinned) {
467
+ unsnap(fullGroup[i - 1], fullGroup[i]);
468
+ break;
469
+ }
470
+ rightPanels.push(fullGroup[i]);
471
+ }
472
+ return [...leftPanels, ...rightPanels];
473
+ }
428
474
  function unsnap(leftPanel, rightPanel) {
429
475
  if (leftPanel.snappedTo === rightPanel.id) {
430
476
  leftPanel.snappedTo = null;
@@ -452,18 +498,18 @@ var DragManager = class {
452
498
  startDrag(e, panel, mode) {
453
499
  e.preventDefault();
454
500
  e.stopPropagation();
455
- const connectedPanels = getConnectedGroup(panel, this.panels);
456
- const initialGroupPositions = /* @__PURE__ */ new Map();
457
- for (const p of connectedPanels) {
458
- const rect2 = p.element.getBoundingClientRect();
459
- initialGroupPositions.set(p.id, { x: rect2.left, y: rect2.top });
460
- }
461
501
  let movingPanels;
462
502
  if (mode === "single") {
463
503
  detachFromGroup(panel, this.panels);
464
504
  movingPanels = [panel];
465
505
  } else {
466
- movingPanels = connectedPanels;
506
+ movingPanels = getMovingGroupRespectingPins(panel, this.panels);
507
+ if (movingPanels.length === 0) return;
508
+ }
509
+ const initialGroupPositions = /* @__PURE__ */ new Map();
510
+ for (const p of movingPanels) {
511
+ const rect2 = p.element.getBoundingClientRect();
512
+ initialGroupPositions.set(p.id, { x: rect2.left, y: rect2.top });
467
513
  }
468
514
  const rect = panel.element.getBoundingClientRect();
469
515
  this.activeDrag = {
@@ -953,6 +999,7 @@ var AutoHideManager = class {
953
999
  */
954
1000
  handleActivity() {
955
1001
  for (const panel of this.panels.values()) {
1002
+ if (panel.isPinned) continue;
956
1003
  if (this.pausedPanels.has(panel.id)) continue;
957
1004
  if (panel.resolvedAutoHideDelay !== void 0 || panel.isHidden) {
958
1005
  this.show(panel, "activity");
@@ -1013,9 +1060,27 @@ var AutoHideManager = class {
1013
1060
  */
1014
1061
  hide(panel, trigger) {
1015
1062
  if (panel.isHidden) return;
1063
+ if (panel.isPinned) return;
1016
1064
  hidePanel(panel, this.classes);
1017
1065
  this.callbacks.onHide?.(panel, trigger);
1018
1066
  }
1067
+ /**
1068
+ * Called when a panel's pin state changes.
1069
+ * Pinning cancels the hide timer and reveals the panel if hidden.
1070
+ * Unpinning restarts the timer if the panel participates in auto-hide.
1071
+ */
1072
+ onPanelPinChanged(panel) {
1073
+ if (panel.isPinned) {
1074
+ this.clearTimer(panel.id);
1075
+ if (panel.isHidden) {
1076
+ this.show(panel, "api");
1077
+ }
1078
+ } else {
1079
+ if (!panel.isHidden && panel.resolvedAutoHideDelay !== void 0) {
1080
+ this.scheduleHide(panel);
1081
+ }
1082
+ }
1083
+ }
1019
1084
  /**
1020
1085
  * Initialize a newly added panel's auto-hide state
1021
1086
  */
@@ -1070,6 +1135,7 @@ function generateClasses(prefix) {
1070
1135
  panelContent: `${prefix}-content`,
1071
1136
  panelContentCollapsed: `${prefix}-content-collapsed`,
1072
1137
  detachGrip: `${prefix}-detach-grip`,
1138
+ pinButton: `${prefix}-pin-btn`,
1073
1139
  collapseButton: `${prefix}-collapse-btn`,
1074
1140
  snapPreview: `${prefix}-snap-preview`,
1075
1141
  snapPreviewVisible: `${prefix}-snap-preview-visible`,
@@ -1160,6 +1226,7 @@ var TabManager = class {
1160
1226
  id,
1161
1227
  element,
1162
1228
  dragHandle: options.dragHandle,
1229
+ pinButton: options.pinButton,
1163
1230
  collapseButton: options.collapseButton,
1164
1231
  contentWrapper: options.contentWrapper,
1165
1232
  detachGrip: options.detachGrip,
@@ -1249,15 +1316,25 @@ var TabManager = class {
1249
1316
  this.emit("panel:collapse", { panel: state, isCollapsed: newState });
1250
1317
  });
1251
1318
  }
1319
+ if (state.pinButton) {
1320
+ state.pinButton.addEventListener("click", () => {
1321
+ const isPinned = togglePin(state);
1322
+ this.autoHideManager.onPanelPinChanged(state);
1323
+ this.emit("panel:pin", { panel: state, isPinned });
1324
+ });
1325
+ }
1252
1326
  if (state.detachGrip) {
1253
1327
  state.detachGrip.addEventListener("mousedown", (e) => {
1328
+ if (state.isPinned) return;
1254
1329
  this.dragManager.startDrag(e, state, "single");
1255
1330
  });
1256
1331
  }
1257
1332
  state.dragHandle.addEventListener("mousedown", (e) => {
1258
- if (e.target === state.collapseButton || e.target === state.detachGrip) {
1333
+ const target = e.target;
1334
+ if (e.target === state.collapseButton || e.target === state.detachGrip || state.pinButton && state.pinButton.contains(target)) {
1259
1335
  return;
1260
1336
  }
1337
+ if (state.isPinned) return;
1261
1338
  this.dragManager.startDrag(e, state, "group");
1262
1339
  });
1263
1340
  }
@@ -1517,6 +1594,7 @@ export {
1517
1594
  getDefaultZIndex,
1518
1595
  getDragZIndex,
1519
1596
  getLeftmostPanel,
1597
+ getMovingGroupRespectingPins,
1520
1598
  getPanelDimensions,
1521
1599
  getPanelPosition,
1522
1600
  getRightmostPanel,
@@ -1528,6 +1606,7 @@ export {
1528
1606
  snapPanels,
1529
1607
  snapPanelsToTarget,
1530
1608
  toggleCollapse,
1609
+ togglePin,
1531
1610
  unsnap,
1532
1611
  updateSnappedPositions
1533
1612
  };
package/dist/styles.css CHANGED
@@ -83,6 +83,26 @@
83
83
  background: var(--blork-tabs-accent, #4a90d9);
84
84
  }
85
85
 
86
+ /* Pin Button */
87
+ .blork-tabs-pin-btn {
88
+ width: 24px;
89
+ height: 24px;
90
+ border: none;
91
+ background: transparent;
92
+ color: var(--blork-tabs-header-color, #e0e0e0);
93
+ cursor: pointer;
94
+ border-radius: 4px;
95
+ display: flex;
96
+ align-items: center;
97
+ justify-content: center;
98
+ padding: 0;
99
+ transition: background 0.2s;
100
+ }
101
+
102
+ .blork-tabs-pin-btn:hover {
103
+ background: rgba(255, 255, 255, 0.1);
104
+ }
105
+
86
106
  /* Collapse Button */
87
107
  .blork-tabs-collapse-btn {
88
108
  width: 24px;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blorkfield/blork-tabs",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "A framework-agnostic tab/panel management system with snapping and docking",
5
5
  "packageManager": "pnpm@10.28.2",
6
6
  "type": "module",
@@ -50,9 +50,10 @@ export class AutoHideManager {
50
50
  */
51
51
  private handleActivity(): void {
52
52
  for (const panel of this.panels.values()) {
53
+ // Pinned panels are immune to activity-based show/hide
54
+ if (panel.isPinned) continue;
53
55
  // Skip paused panels - they handle their own timing
54
56
  if (this.pausedPanels.has(panel.id)) continue;
55
-
56
57
  // Only process panels that participate in auto-hide
57
58
  if (panel.resolvedAutoHideDelay !== undefined || panel.isHidden) {
58
59
  this.show(panel, 'activity');
@@ -120,10 +121,29 @@ export class AutoHideManager {
120
121
  */
121
122
  hide(panel: PanelState, trigger: 'timeout' | 'api'): void {
122
123
  if (panel.isHidden) return;
124
+ if (panel.isPinned) return; // Pinned panels are always visible
123
125
  hidePanel(panel, this.classes);
124
126
  this.callbacks.onHide?.(panel, trigger);
125
127
  }
126
128
 
129
+ /**
130
+ * Called when a panel's pin state changes.
131
+ * Pinning cancels the hide timer and reveals the panel if hidden.
132
+ * Unpinning restarts the timer if the panel participates in auto-hide.
133
+ */
134
+ onPanelPinChanged(panel: PanelState): void {
135
+ if (panel.isPinned) {
136
+ this.clearTimer(panel.id);
137
+ if (panel.isHidden) {
138
+ this.show(panel, 'api');
139
+ }
140
+ } else {
141
+ if (!panel.isHidden && panel.resolvedAutoHideDelay !== undefined) {
142
+ this.scheduleHide(panel);
143
+ }
144
+ }
145
+ }
146
+
127
147
  /**
128
148
  * Initialize a newly added panel's auto-hide state
129
149
  */
@@ -13,7 +13,7 @@ import type {
13
13
  ResolvedTabManagerConfig,
14
14
  } from './types';
15
15
  import {
16
- getConnectedGroup,
16
+ getMovingGroupRespectingPins,
17
17
  detachFromGroup,
18
18
  findSnapTarget,
19
19
  snapPanelsToTarget,
@@ -76,24 +76,24 @@ export class DragManager {
76
76
  e.preventDefault();
77
77
  e.stopPropagation();
78
78
 
79
- const connectedPanels = getConnectedGroup(panel, this.panels);
80
-
81
- // Store initial positions
82
- const initialGroupPositions = new Map<string, Position>();
83
- for (const p of connectedPanels) {
84
- const rect = p.element.getBoundingClientRect();
85
- initialGroupPositions.set(p.id, { x: rect.left, y: rect.top });
86
- }
87
-
88
79
  let movingPanels: PanelState[];
89
80
 
90
81
  if (mode === 'single') {
91
- // Detach this panel from its group
82
+ // Detach this panel from its group and move it alone
92
83
  detachFromGroup(panel, this.panels);
93
84
  movingPanels = [panel];
94
85
  } else {
95
- // Move entire group
96
- movingPanels = connectedPanels;
86
+ // Group drag: collect panels on either side of the grabbed panel,
87
+ // splitting at any pinned panel and severing those bonds
88
+ movingPanels = getMovingGroupRespectingPins(panel, this.panels);
89
+ if (movingPanels.length === 0) return;
90
+ }
91
+
92
+ // Store initial positions for the panels that will actually move
93
+ const initialGroupPositions = new Map<string, Position>();
94
+ for (const p of movingPanels) {
95
+ const rect = p.element.getBoundingClientRect();
96
+ initialGroupPositions.set(p.id, { x: rect.left, y: rect.top });
97
97
  }
98
98
 
99
99
  const rect = panel.element.getBoundingClientRect();
package/src/Panel.ts CHANGED
@@ -9,6 +9,9 @@ import type {
9
9
  CSSClasses,
10
10
  } from './types';
11
11
 
12
+ const PIN_ICON_UNPINNED = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block"><line x1="12" y1="17" x2="12" y2="22"/><path d="M5 17H19V16L17 11V4H7L5 11V16Z"/><line x1="5" y1="11" x2="19" y2="11"/></svg>`;
13
+ const PIN_ICON_PINNED = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="14" height="14" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block;transform:rotate(90deg)"><line x1="12" y1="17" x2="12" y2="22"/><path d="M5 17H19V16L17 11V4H7L5 11V16Z"/><line x1="5" y1="11" x2="19" y2="11"/></svg>`;
14
+
12
15
  /**
13
16
  * Creates the default panel DOM structure
14
17
  */
@@ -18,6 +21,7 @@ export function createPanelElement(
18
21
  ): {
19
22
  element: HTMLDivElement;
20
23
  dragHandle: HTMLDivElement;
24
+ pinButton: HTMLButtonElement | null;
21
25
  collapseButton: HTMLButtonElement | null;
22
26
  contentWrapper: HTMLDivElement;
23
27
  detachGrip: HTMLDivElement | null;
@@ -50,6 +54,16 @@ export function createPanelElement(
50
54
  header.appendChild(title);
51
55
  }
52
56
 
57
+ // Pin button (only if explicitly pinnable)
58
+ let pinButton: HTMLButtonElement | null = null;
59
+ if (config.pinnable === true) {
60
+ pinButton = document.createElement('button');
61
+ pinButton.className = classes.pinButton;
62
+ pinButton.id = `${config.id}-pin-btn`;
63
+ pinButton.innerHTML = config.startPinned === true ? PIN_ICON_PINNED : PIN_ICON_UNPINNED;
64
+ header.appendChild(pinButton);
65
+ }
66
+
53
67
  // Collapse button (if collapsible)
54
68
  let collapseButton: HTMLButtonElement | null = null;
55
69
  if (config.collapsible !== false) {
@@ -84,6 +98,7 @@ export function createPanelElement(
84
98
  return {
85
99
  element,
86
100
  dragHandle: header,
101
+ pinButton,
87
102
  collapseButton,
88
103
  contentWrapper,
89
104
  detachGrip,
@@ -100,6 +115,7 @@ export function createPanelState(
100
115
  ): PanelState {
101
116
  let element: HTMLDivElement;
102
117
  let dragHandle: HTMLDivElement;
118
+ let pinButton: HTMLButtonElement | null;
103
119
  let collapseButton: HTMLButtonElement | null;
104
120
  let contentWrapper: HTMLDivElement;
105
121
  let detachGrip: HTMLDivElement | null;
@@ -108,6 +124,7 @@ export function createPanelState(
108
124
  // Use existing DOM elements
109
125
  element = config.element;
110
126
  dragHandle = config.dragHandle ?? (element.querySelector(`.${classes.panelHeader}`) as HTMLDivElement);
127
+ pinButton = config.pinButton ?? (element.querySelector(`.${classes.pinButton}`) as HTMLButtonElement | null);
111
128
  collapseButton = config.collapseButton ?? (element.querySelector(`.${classes.collapseButton}`) as HTMLButtonElement | null);
112
129
  contentWrapper = config.contentWrapper ?? (element.querySelector(`.${classes.panelContent}`) as HTMLDivElement);
113
130
  detachGrip = config.detachGrip ?? (element.querySelector(`.${classes.detachGrip}`) as HTMLDivElement | null);
@@ -116,6 +133,7 @@ export function createPanelState(
116
133
  const created = createPanelElement(config, classes);
117
134
  element = created.element;
118
135
  dragHandle = created.dragHandle;
136
+ pinButton = created.pinButton;
119
137
  collapseButton = created.collapseButton;
120
138
  contentWrapper = created.contentWrapper;
121
139
  detachGrip = created.detachGrip;
@@ -136,9 +154,11 @@ export function createPanelState(
136
154
  id: config.id,
137
155
  element,
138
156
  dragHandle,
157
+ pinButton,
139
158
  collapseButton,
140
159
  contentWrapper,
141
160
  detachGrip,
161
+ isPinned: config.startPinned === true,
142
162
  isCollapsed: config.startCollapsed !== false,
143
163
  snappedTo: null,
144
164
  snappedFrom: null,
@@ -172,6 +192,23 @@ export function toggleCollapse(
172
192
  return newState;
173
193
  }
174
194
 
195
+ /**
196
+ * Toggle panel pin state
197
+ */
198
+ export function togglePin(
199
+ state: PanelState,
200
+ pinned?: boolean
201
+ ): boolean {
202
+ const newState = pinned ?? !state.isPinned;
203
+ state.isPinned = newState;
204
+
205
+ if (state.pinButton) {
206
+ state.pinButton.innerHTML = newState ? PIN_ICON_PINNED : PIN_ICON_UNPINNED;
207
+ }
208
+
209
+ return newState;
210
+ }
211
+
175
212
  /**
176
213
  * Show a hidden panel
177
214
  */
package/src/SnapChain.ts CHANGED
@@ -273,6 +273,51 @@ export function snapPanels(
273
273
  rightPanel.snappedFrom = leftPanel.id;
274
274
  }
275
275
 
276
+ /**
277
+ * Get the subset of connected panels that should move when a panel is grabbed,
278
+ * stopping at any pinned panel in the chain.
279
+ *
280
+ * Pinned panels act as immoveable barriers — the chain splits at each one,
281
+ * and only the panels on the same side as the grabbed panel are returned.
282
+ * The snap bonds at each pin boundary are severed as a side effect.
283
+ *
284
+ * Example: chain [A, B, P(pinned), C, D], grab B → returns [A, B], unseats B↔P
285
+ * Example: chain [A, P(pinned), B, C], grab C → returns [B, C], unseats P↔B
286
+ */
287
+ export function getMovingGroupRespectingPins(
288
+ grabbedPanel: PanelState,
289
+ panels: Map<string, PanelState>
290
+ ): PanelState[] {
291
+ if (grabbedPanel.isPinned) return [];
292
+
293
+ const fullGroup = getConnectedGroup(grabbedPanel, panels);
294
+ const grabbedIndex = fullGroup.indexOf(grabbedPanel);
295
+
296
+ // Walk left from the grabbed panel, stopping before any pinned panel
297
+ const leftPanels: PanelState[] = [];
298
+ for (let i = grabbedIndex; i >= 0; i--) {
299
+ if (fullGroup[i].isPinned) {
300
+ // Sever the bond between this pinned panel and the next moveable panel
301
+ unsnap(fullGroup[i], fullGroup[i + 1]);
302
+ break;
303
+ }
304
+ leftPanels.unshift(fullGroup[i]);
305
+ }
306
+
307
+ // Walk right from the grabbed panel, stopping before any pinned panel
308
+ const rightPanels: PanelState[] = [];
309
+ for (let i = grabbedIndex + 1; i < fullGroup.length; i++) {
310
+ if (fullGroup[i].isPinned) {
311
+ // Sever the bond between the last moveable panel and this pinned panel
312
+ unsnap(fullGroup[i - 1], fullGroup[i]);
313
+ break;
314
+ }
315
+ rightPanels.push(fullGroup[i]);
316
+ }
317
+
318
+ return [...leftPanels, ...rightPanels];
319
+ }
320
+
276
321
  /**
277
322
  * Break the snap relationship between two specific panels
278
323
  */
package/src/TabManager.ts CHANGED
@@ -24,7 +24,7 @@ import type {
24
24
  DebugLog,
25
25
  } from './types';
26
26
  import { createDebugPanelContent, createDebugPanelInterface, createDebugLog, setupHoverEnlarge, DebugPanelElements } from './DebugPanel';
27
- import { createPanelState, toggleCollapse, setPanelPosition } from './Panel';
27
+ import { createPanelState, toggleCollapse, togglePin, setPanelPosition } from './Panel';
28
28
  import { getConnectedGroup, detachFromGroup, updateSnappedPositions, snapPanels } from './SnapChain';
29
29
  import { DragManager } from './DragManager';
30
30
  import { AnchorManager } from './AnchorManager';
@@ -58,6 +58,7 @@ function generateClasses(prefix: string): CSSClasses {
58
58
  panelContent: `${prefix}-content`,
59
59
  panelContentCollapsed: `${prefix}-content-collapsed`,
60
60
  detachGrip: `${prefix}-detach-grip`,
61
+ pinButton: `${prefix}-pin-btn`,
61
62
  collapseButton: `${prefix}-collapse-btn`,
62
63
  snapPreview: `${prefix}-snap-preview`,
63
64
  snapPreviewVisible: `${prefix}-snap-preview-visible`,
@@ -181,6 +182,7 @@ export class TabManager {
181
182
  element: HTMLDivElement,
182
183
  options: {
183
184
  dragHandle?: HTMLDivElement;
185
+ pinButton?: HTMLButtonElement;
184
186
  collapseButton?: HTMLButtonElement;
185
187
  contentWrapper?: HTMLDivElement;
186
188
  detachGrip?: HTMLDivElement;
@@ -191,6 +193,7 @@ export class TabManager {
191
193
  id,
192
194
  element,
193
195
  dragHandle: options.dragHandle,
196
+ pinButton: options.pinButton,
194
197
  collapseButton: options.collapseButton,
195
198
  contentWrapper: options.contentWrapper,
196
199
  detachGrip: options.detachGrip,
@@ -309,22 +312,35 @@ export class TabManager {
309
312
  });
310
313
  }
311
314
 
315
+ // Pin button
316
+ if (state.pinButton) {
317
+ state.pinButton.addEventListener('click', () => {
318
+ const isPinned = togglePin(state);
319
+ this.autoHideManager.onPanelPinChanged(state);
320
+ this.emit('panel:pin', { panel: state, isPinned });
321
+ });
322
+ }
323
+
312
324
  // Detach grip - single panel drag
313
325
  if (state.detachGrip) {
314
326
  state.detachGrip.addEventListener('mousedown', (e) => {
327
+ if (state.isPinned) return;
315
328
  this.dragManager.startDrag(e, state, 'single');
316
329
  });
317
330
  }
318
331
 
319
332
  // Main drag handle - group drag
320
333
  state.dragHandle.addEventListener('mousedown', (e) => {
321
- // Ignore if clicking on collapse button or detach grip
334
+ // Ignore if clicking on collapse button, pin button, or detach grip
335
+ const target = e.target as Node;
322
336
  if (
323
337
  e.target === state.collapseButton ||
324
- e.target === state.detachGrip
338
+ e.target === state.detachGrip ||
339
+ (state.pinButton && state.pinButton.contains(target))
325
340
  ) {
326
341
  return;
327
342
  }
343
+ if (state.isPinned) return;
328
344
  this.dragManager.startDrag(e, state, 'group');
329
345
  });
330
346
  }
package/src/index.ts CHANGED
@@ -48,6 +48,7 @@ export {
48
48
  createPanelElement,
49
49
  createPanelState,
50
50
  toggleCollapse,
51
+ togglePin,
51
52
  showPanel,
52
53
  hidePanel,
53
54
  setPanelPosition,
@@ -59,6 +60,7 @@ export {
59
60
  } from './Panel';
60
61
  export {
61
62
  getConnectedGroup,
63
+ getMovingGroupRespectingPins,
62
64
  detachFromGroup,
63
65
  findSnapTarget,
64
66
  snapPanelsToTarget,
@@ -103,6 +105,7 @@ export type {
103
105
  PanelSnapEvent,
104
106
  AnchorSnapEvent,
105
107
  PanelDetachedEvent,
108
+ PanelPinEvent,
106
109
  PanelCollapseEvent,
107
110
  PanelShowEvent,
108
111
  PanelHideEvent,
package/src/types.ts CHANGED
@@ -57,6 +57,8 @@ export interface PanelConfig {
57
57
  title?: string;
58
58
  /** Initial width (default: 300) */
59
59
  width?: number;
60
+ /** Initial pin state (default: false) */
61
+ startPinned?: boolean;
60
62
  /** Initial collapsed state (default: true) */
61
63
  startCollapsed?: boolean;
62
64
  /** Initial position (optional - will be auto-positioned if not provided) */
@@ -67,12 +69,16 @@ export interface PanelConfig {
67
69
  element?: HTMLDivElement;
68
70
  /** Custom drag handle element */
69
71
  dragHandle?: HTMLDivElement;
72
+ /** Custom pin button element */
73
+ pinButton?: HTMLButtonElement;
70
74
  /** Custom collapse button element */
71
75
  collapseButton?: HTMLButtonElement;
72
76
  /** Custom content wrapper element */
73
77
  contentWrapper?: HTMLDivElement;
74
78
  /** Custom detach grip element */
75
79
  detachGrip?: HTMLDivElement;
80
+ /** Whether panel can be pinned (default: false) */
81
+ pinnable?: boolean;
76
82
  /** Whether panel can be collapsed (default: true) */
77
83
  collapsible?: boolean;
78
84
  /** Whether panel can be detached from group (default: true) */
@@ -126,12 +132,16 @@ export interface PanelState {
126
132
  element: HTMLDivElement;
127
133
  /** Drag handle element */
128
134
  dragHandle: HTMLDivElement;
135
+ /** Pin button element (if pinable) */
136
+ pinButton: HTMLButtonElement | null;
129
137
  /** Collapse button element (if collapsible) */
130
138
  collapseButton: HTMLButtonElement | null;
131
139
  /** Content wrapper element */
132
140
  contentWrapper: HTMLDivElement;
133
141
  /** Detach grip element (if detachable) */
134
142
  detachGrip: HTMLDivElement | null;
143
+ /** Whether panel is currently pinned */
144
+ isPinned: boolean;
135
145
  /** Whether panel is currently collapsed */
136
146
  isCollapsed: boolean;
137
147
  /** ID of panel this is snapped to on its right (outgoing link) */
@@ -254,6 +264,8 @@ export interface TabManagerEvents {
254
264
  'snap:anchor': AnchorSnapEvent;
255
265
  /** Fired when a panel is detached from group */
256
266
  'panel:detached': PanelDetachedEvent;
267
+ /** Fired when panel pin state changes */
268
+ 'panel:pin': PanelPinEvent;
257
269
  /** Fired when panel collapse state changes */
258
270
  'panel:collapse': PanelCollapseEvent;
259
271
  /** Fired when a panel becomes visible (auto-hide) */
@@ -306,6 +318,11 @@ export interface PanelDetachedEvent {
306
318
  previousGroup: PanelState[];
307
319
  }
308
320
 
321
+ export interface PanelPinEvent {
322
+ panel: PanelState;
323
+ isPinned: boolean;
324
+ }
325
+
309
326
  export interface PanelCollapseEvent {
310
327
  panel: PanelState;
311
328
  isCollapsed: boolean;
@@ -338,6 +355,7 @@ export interface CSSClasses {
338
355
  panelContent: string;
339
356
  panelContentCollapsed: string;
340
357
  detachGrip: string;
358
+ pinButton: string;
341
359
  collapseButton: string;
342
360
  snapPreview: string;
343
361
  snapPreviewVisible: string;