@blorkfield/blork-tabs 0.3.1 → 0.5.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/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
  */
@@ -799,6 +823,67 @@ interface HoverEnlargeConfig {
799
823
  */
800
824
  declare function setupHoverEnlarge(config: HoverEnlargeConfig): void;
801
825
 
826
+ /**
827
+ * Tag button: a toggleable pill-shaped button that optionally reveals
828
+ * inline inputs (numbers, selects) when active.
829
+ *
830
+ * @example
831
+ * ```typescript
832
+ * // Simple toggle
833
+ * const staticBtn = createTagButton('static');
834
+ * container.appendChild(staticBtn.element);
835
+ *
836
+ * // Toggle with parameterized inputs
837
+ * const govBtn = createTagButton('gravity_override', {
838
+ * inputs: [
839
+ * { label: 'x', defaultValue: 0, step: 0.1 },
840
+ * { label: 'y', defaultValue: -1, step: 0.1 },
841
+ * ],
842
+ * });
843
+ * container.appendChild(govBtn.element);
844
+ *
845
+ * // Read state when needed
846
+ * if (govBtn.isActive()) {
847
+ * const gx = parseFloat(govBtn.getValue(0));
848
+ * const gy = parseFloat(govBtn.getValue(1));
849
+ * }
850
+ * ```
851
+ */
852
+ interface TagButtonNumberInputConfig {
853
+ type?: 'number';
854
+ /** Short label rendered before the input (e.g. 'x', 'y') */
855
+ label?: string;
856
+ defaultValue?: number;
857
+ step?: number;
858
+ min?: number;
859
+ max?: number;
860
+ }
861
+ interface TagButtonSelectInputConfig {
862
+ type: 'select';
863
+ label?: string;
864
+ options?: Array<{
865
+ value: string;
866
+ label: string;
867
+ }>;
868
+ }
869
+ type TagButtonInputConfig = TagButtonNumberInputConfig | TagButtonSelectInputConfig;
870
+ interface TagButtonConfig {
871
+ defaultActive?: boolean;
872
+ inputs?: TagButtonInputConfig[];
873
+ onChange?: (active: boolean) => void;
874
+ }
875
+ interface TagButton {
876
+ element: HTMLElement;
877
+ isActive(): boolean;
878
+ setActive(active: boolean): void;
879
+ toggle(): void;
880
+ /** Get the string value of the input at position index */
881
+ getValue(index: number): string;
882
+ /** Direct access to the underlying input or select element */
883
+ getInput(index: number): HTMLInputElement | HTMLSelectElement | undefined;
884
+ }
885
+ declare function createTagButton(label: string, config?: TagButtonConfig): TagButton;
886
+
802
887
  /**
803
888
  * @blorkfield/blork-tabs - Panel
804
889
  * Individual panel component with collapse/expand functionality
@@ -810,6 +895,7 @@ declare function setupHoverEnlarge(config: HoverEnlargeConfig): void;
810
895
  declare function createPanelElement(config: PanelConfig, classes: CSSClasses): {
811
896
  element: HTMLDivElement;
812
897
  dragHandle: HTMLDivElement;
898
+ pinButton: HTMLButtonElement | null;
813
899
  collapseButton: HTMLButtonElement | null;
814
900
  contentWrapper: HTMLDivElement;
815
901
  detachGrip: HTMLDivElement | null;
@@ -825,6 +911,10 @@ declare function createPanelState(config: PanelConfig, classes: CSSClasses, glob
825
911
  * Toggle panel collapse state
826
912
  */
827
913
  declare function toggleCollapse(state: PanelState, classes: CSSClasses, collapsed?: boolean): boolean;
914
+ /**
915
+ * Toggle panel pin state
916
+ */
917
+ declare function togglePin(state: PanelState, pinned?: boolean): boolean;
828
918
  /**
829
919
  * Show a hidden panel
830
920
  */
@@ -909,9 +999,21 @@ declare function areInSameChain(panel1: PanelState, panel2: PanelState, panels:
909
999
  * Establish a snap relationship between two panels
910
1000
  */
911
1001
  declare function snapPanels(leftPanel: PanelState, rightPanel: PanelState): void;
1002
+ /**
1003
+ * Get the subset of connected panels that should move when a panel is grabbed,
1004
+ * stopping at any pinned panel in the chain.
1005
+ *
1006
+ * Pinned panels act as immoveable barriers — the chain splits at each one,
1007
+ * and only the panels on the same side as the grabbed panel are returned.
1008
+ * The snap bonds at each pin boundary are severed as a side effect.
1009
+ *
1010
+ * Example: chain [A, B, P(pinned), C, D], grab B → returns [A, B], unseats B↔P
1011
+ * Example: chain [A, P(pinned), B, C], grab C → returns [B, C], unseats P↔B
1012
+ */
1013
+ declare function getMovingGroupRespectingPins(grabbedPanel: PanelState, panels: Map<string, PanelState>): PanelState[];
912
1014
  /**
913
1015
  * Break the snap relationship between two specific panels
914
1016
  */
915
1017
  declare function unsnap(leftPanel: PanelState, rightPanel: PanelState): void;
916
1018
 
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 };
1019
+ 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, type TagButton, type TagButtonConfig, type TagButtonInputConfig, type TagButtonNumberInputConfig, type TagButtonSelectInputConfig, areInSameChain, createDebugLog, createDebugPanelContent, createDebugPanelInterface, createPanelElement, createPanelState, createPresetAnchor, createTagButton, 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
  }
@@ -1497,6 +1574,80 @@ var TabManager = class {
1497
1574
  this.debugPanelElements.clear();
1498
1575
  }
1499
1576
  };
1577
+
1578
+ // src/TagButton.ts
1579
+ function createTagButton(label, config = {}) {
1580
+ const { defaultActive = false, inputs = [], onChange } = config;
1581
+ const hasInputs = inputs.length > 0;
1582
+ const el = document.createElement(hasInputs ? "div" : "button");
1583
+ el.className = "blork-tabs-tag-btn";
1584
+ if (hasInputs) {
1585
+ el.setAttribute("role", "button");
1586
+ el.setAttribute("tabindex", "0");
1587
+ }
1588
+ el.appendChild(document.createTextNode(label));
1589
+ const inputElements = [];
1590
+ if (hasInputs) {
1591
+ const inputsContainer = document.createElement("span");
1592
+ inputsContainer.className = "blork-tabs-tag-inputs";
1593
+ for (const inputConfig of inputs) {
1594
+ if (inputConfig.label) {
1595
+ const labelSpan = document.createElement("span");
1596
+ labelSpan.textContent = inputConfig.label;
1597
+ inputsContainer.appendChild(labelSpan);
1598
+ }
1599
+ if (inputConfig.type === "select") {
1600
+ const select = document.createElement("select");
1601
+ select.className = "blork-tabs-tag-select";
1602
+ for (const opt of inputConfig.options ?? []) {
1603
+ const option = document.createElement("option");
1604
+ option.value = opt.value;
1605
+ option.textContent = opt.label;
1606
+ select.appendChild(option);
1607
+ }
1608
+ select.addEventListener("click", (e) => e.stopPropagation());
1609
+ select.addEventListener("mousedown", (e) => e.stopPropagation());
1610
+ inputsContainer.appendChild(select);
1611
+ inputElements.push(select);
1612
+ } else {
1613
+ const input = document.createElement("input");
1614
+ input.type = "number";
1615
+ input.className = "blork-tabs-tag-input";
1616
+ if (inputConfig.defaultValue !== void 0) input.value = String(inputConfig.defaultValue);
1617
+ if (inputConfig.step !== void 0) input.step = String(inputConfig.step);
1618
+ if (inputConfig.min !== void 0) input.min = String(inputConfig.min);
1619
+ if (inputConfig.max !== void 0) input.max = String(inputConfig.max);
1620
+ input.addEventListener("click", (e) => e.stopPropagation());
1621
+ inputsContainer.appendChild(input);
1622
+ inputElements.push(input);
1623
+ }
1624
+ }
1625
+ el.appendChild(inputsContainer);
1626
+ }
1627
+ let active = defaultActive;
1628
+ if (active) el.classList.add("active");
1629
+ const setActive = (value) => {
1630
+ active = value;
1631
+ el.classList.toggle("active", active);
1632
+ onChange?.(active);
1633
+ };
1634
+ el.addEventListener("click", () => setActive(!active));
1635
+ el.addEventListener("keydown", (e) => {
1636
+ const ke = e;
1637
+ if (ke.key === "Enter" || ke.key === " ") {
1638
+ ke.preventDefault();
1639
+ setActive(!active);
1640
+ }
1641
+ });
1642
+ return {
1643
+ element: el,
1644
+ isActive: () => active,
1645
+ setActive,
1646
+ toggle: () => setActive(!active),
1647
+ getValue: (index) => inputElements[index]?.value ?? "",
1648
+ getInput: (index) => inputElements[index]
1649
+ };
1650
+ }
1500
1651
  export {
1501
1652
  AnchorManager,
1502
1653
  AutoHideManager,
@@ -1510,6 +1661,7 @@ export {
1510
1661
  createPanelElement,
1511
1662
  createPanelState,
1512
1663
  createPresetAnchor,
1664
+ createTagButton,
1513
1665
  detachFromGroup,
1514
1666
  findSnapTarget,
1515
1667
  getConnectedGroup,
@@ -1517,6 +1669,7 @@ export {
1517
1669
  getDefaultZIndex,
1518
1670
  getDragZIndex,
1519
1671
  getLeftmostPanel,
1672
+ getMovingGroupRespectingPins,
1520
1673
  getPanelDimensions,
1521
1674
  getPanelPosition,
1522
1675
  getRightmostPanel,
@@ -1528,6 +1681,7 @@ export {
1528
1681
  snapPanels,
1529
1682
  snapPanelsToTarget,
1530
1683
  toggleCollapse,
1684
+ togglePin,
1531
1685
  unsnap,
1532
1686
  updateSnappedPositions
1533
1687
  };
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;
@@ -200,6 +220,88 @@
200
220
  transition: none !important;
201
221
  }
202
222
 
223
+ /* Tag Button */
224
+ .blork-tabs-tag-btn {
225
+ padding: 4px 10px;
226
+ background: var(--blork-tabs-panel-bg, #1a1a2e);
227
+ border: 1px solid #3a3a5a;
228
+ border-radius: 4px;
229
+ color: #aaa;
230
+ cursor: pointer;
231
+ font-size: 11px;
232
+ font-family: monospace;
233
+ transition: all 0.15s;
234
+ user-select: none;
235
+ display: inline-flex;
236
+ align-items: center;
237
+ gap: 6px;
238
+ }
239
+
240
+ .blork-tabs-tag-btn:hover {
241
+ border-color: #5a5a7a;
242
+ color: #ccc;
243
+ }
244
+
245
+ .blork-tabs-tag-btn.active {
246
+ border-color: var(--blork-tabs-accent, #4a90d9);
247
+ color: var(--blork-tabs-accent, #4a90d9);
248
+ box-shadow: 0 0 8px rgba(74, 144, 217, 0.5), 0 0 2px rgba(74, 144, 217, 0.8);
249
+ text-shadow: 0 0 8px rgba(74, 144, 217, 0.9);
250
+ }
251
+
252
+ /* Tag inputs (hidden until button is active) */
253
+ .blork-tabs-tag-inputs {
254
+ display: none;
255
+ align-items: center;
256
+ gap: 3px;
257
+ font-size: 10px;
258
+ }
259
+
260
+ .blork-tabs-tag-btn.active .blork-tabs-tag-inputs {
261
+ display: inline-flex;
262
+ }
263
+
264
+ .blork-tabs-tag-input {
265
+ width: 36px;
266
+ background: transparent;
267
+ border: none;
268
+ border-bottom: 1px solid #555;
269
+ color: inherit;
270
+ font-size: 10px;
271
+ font-family: monospace;
272
+ text-align: center;
273
+ padding: 0 2px;
274
+ outline: none;
275
+ -moz-appearance: textfield;
276
+ }
277
+
278
+ .blork-tabs-tag-input::-webkit-outer-spin-button,
279
+ .blork-tabs-tag-input::-webkit-inner-spin-button {
280
+ -webkit-appearance: none;
281
+ margin: 0;
282
+ }
283
+
284
+ .blork-tabs-tag-btn.active .blork-tabs-tag-input {
285
+ border-bottom-color: rgba(74, 144, 217, 0.6);
286
+ }
287
+
288
+ .blork-tabs-tag-select {
289
+ background: var(--blork-tabs-panel-bg, #1a1a2e);
290
+ border: none;
291
+ border-bottom: 1px solid #555;
292
+ color: #ccc;
293
+ font-size: 10px;
294
+ font-family: monospace;
295
+ outline: none;
296
+ cursor: pointer;
297
+ max-width: 80px;
298
+ }
299
+
300
+ .blork-tabs-tag-btn.active .blork-tabs-tag-select {
301
+ border-bottom-color: rgba(74, 144, 217, 0.6);
302
+ color: var(--blork-tabs-accent, #4a90d9);
303
+ }
304
+
203
305
  /* Debug Panel Log */
204
306
  .blork-tabs-debug-log {
205
307
  min-height: 100px;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blorkfield/blork-tabs",
3
- "version": "0.3.1",
3
+ "version": "0.5.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
  */