@jupyterlab/filebrowser 4.6.0-alpha.4 → 4.6.0-alpha.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/crumbs.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  homeIcon as preferredIcon,
12
12
  folderIcon as rootIcon
13
13
  } from '@jupyterlab/ui-components';
14
+ import { PathNavigator } from './pathnavigator';
14
15
  import { JSONExt } from '@lumino/coreutils';
15
16
  import { Throttler } from '@lumino/polling';
16
17
  import type { Drag } from '@lumino/dragdrop';
@@ -58,6 +59,21 @@ const CONTENTS_MIME = 'application/x-jupyter-icontents';
58
59
  */
59
60
  const DROP_TARGET_CLASS = 'jp-mod-dropTarget';
60
61
 
62
+ /**
63
+ * The class name for the container span that holds all breadcrumb items.
64
+ */
65
+ const BREADCRUMB_CONTAINER_CLASS = 'jp-BreadCrumbs-container';
66
+
67
+ /**
68
+ * The class name for the inner wrapper that hugs breadcrumb content.
69
+ */
70
+ const BREADCRUMB_CONTENT_CLASS = 'jp-BreadCrumbs-content';
71
+
72
+ /**
73
+ * The class name added to the breadcrumb node when in edit mode.
74
+ */
75
+ const BREADCRUMB_EDIT_MODE_CLASS = 'jp-mod-editMode';
76
+
61
77
  /**
62
78
  * A class which hosts folder breadcrumbs.
63
79
  */
@@ -75,15 +91,21 @@ export class BreadCrumbs extends Widget {
75
91
  this._fullPath = options.fullPath || false;
76
92
  this._minimumLeftItems = options.minimumLeftItems ?? 0;
77
93
  this._minimumRightItems = options.minimumRightItems ?? 2;
94
+ this._onPathEdited = options.onPathEdited;
95
+ this._onPathActivated = options.onPathActivated;
78
96
  this.addClass(BREADCRUMB_CLASS);
79
97
  this._crumbs = Private.createCrumbs();
80
98
  const hasPreferred = PageConfig.getOption('preferredPath');
81
99
  this._hasPreferred = hasPreferred && hasPreferred !== '/' ? true : false;
82
- if (this._hasPreferred) {
83
- this.node.appendChild(this._crumbs[Private.Crumb.Preferred]);
84
- }
85
- this.node.appendChild(this._crumbs[Private.Crumb.Home]);
86
- this._model.refreshed.connect(this.update, this);
100
+ this._crumbContainer = document.createElement('span');
101
+ this._crumbContainer.className = BREADCRUMB_CONTAINER_CLASS;
102
+ this._crumbContent = document.createElement('span');
103
+ this._crumbContent.className = BREADCRUMB_CONTENT_CLASS;
104
+ this._crumbContainer.appendChild(this._crumbContent);
105
+
106
+ this.node.appendChild(this._crumbContainer);
107
+
108
+ this._model.refreshed.connect(this._onModelRefreshed, this);
87
109
  this._resizeThrottler = new Throttler(() => this._onResize(), 50);
88
110
  this._resizeObserver = new ResizeObserver(entries => {
89
111
  const entry = entries[0];
@@ -97,6 +119,12 @@ export class BreadCrumbs extends Widget {
97
119
  void this._resizeThrottler.invoke();
98
120
  }
99
121
  });
122
+
123
+ this._pathNavigator = new PathNavigator({
124
+ model: this._model,
125
+ translator: options.translator
126
+ });
127
+ this._pathNavigator.closed.connect(this._exitEditMode, this);
100
128
  }
101
129
 
102
130
  /**
@@ -111,6 +139,9 @@ export class BreadCrumbs extends Widget {
111
139
  */
112
140
  handleEvent(event: Event): void {
113
141
  switch (event.type) {
142
+ case 'keydown':
143
+ this._evtKeyDown(event as KeyboardEvent);
144
+ break;
114
145
  case 'click':
115
146
  this._evtClick(event as MouseEvent);
116
147
  break;
@@ -171,6 +202,9 @@ export class BreadCrumbs extends Widget {
171
202
  if (this.isDisposed) {
172
203
  return;
173
204
  }
205
+ this._model.refreshed.disconnect(this._onModelRefreshed, this);
206
+ this._pathNavigator.closed.disconnect(this._exitEditMode, this);
207
+ this._pathNavigator.dispose();
174
208
  this._resizeObserver.disconnect();
175
209
  this._resizeThrottler.dispose();
176
210
  super.dispose();
@@ -183,20 +217,24 @@ export class BreadCrumbs extends Widget {
183
217
  super.onAfterAttach(msg);
184
218
  this.update();
185
219
  const node = this.node;
220
+ node.addEventListener('keydown', this);
186
221
  node.addEventListener('click', this);
187
222
  node.addEventListener('lm-dragenter', this);
188
223
  node.addEventListener('lm-dragleave', this);
189
224
  node.addEventListener('lm-dragover', this);
190
225
  this._resizeObserver.observe(node);
191
226
  node.addEventListener('lm-drop', this);
227
+ Widget.attach(this._pathNavigator, this.node);
192
228
  }
193
229
 
194
230
  /**
195
231
  * A message handler invoked on a `'before-detach'` message.
196
232
  */
197
233
  protected onBeforeDetach(msg: Message): void {
234
+ Widget.detach(this._pathNavigator);
198
235
  super.onBeforeDetach(msg);
199
236
  const node = this.node;
237
+ node.removeEventListener('keydown', this);
200
238
  node.removeEventListener('click', this);
201
239
  node.removeEventListener('lm-dragenter', this);
202
240
  node.removeEventListener('lm-dragleave', this);
@@ -209,10 +247,21 @@ export class BreadCrumbs extends Widget {
209
247
  * A handler invoked on an `'update-request'` message.
210
248
  */
211
249
  protected onUpdateRequest(msg: Message): void {
250
+ if (this._isEditMode) {
251
+ // In edit mode the CSS class hides breadcrumb content,
252
+ // we do not need to refresh, as only the pathnavigator is visible.
253
+ return;
254
+ }
255
+
212
256
  // Update the breadcrumb list.
213
257
  const contents = this._model.manager.services.contents;
214
258
  const localPath = contents.localPath(this._model.path);
215
259
 
260
+ // We track the path on which we are;
261
+ // this is to make sure we don't exit edit mode on model refresh if
262
+ // the path has not changed.
263
+ this._lastPath = localPath;
264
+
216
265
  // Invalidate cached widths if the path changed
217
266
  if (this._previousState && this._previousState.path !== localPath) {
218
267
  this._cachedWidths = null;
@@ -221,18 +270,224 @@ export class BreadCrumbs extends Widget {
221
270
  // Calculate adaptive items based on available width
222
271
  const adaptiveItems = this._calculateAdaptiveItems(localPath);
223
272
 
224
- const state = {
273
+ const state: Private.ICrumbsState = {
225
274
  path: localPath,
275
+ root: this._model.root,
226
276
  hasPreferred: this._hasPreferred,
227
277
  fullPath: this._fullPath,
228
278
  minimumLeftItems: adaptiveItems.left,
229
279
  minimumRightItems: adaptiveItems.right
230
280
  };
231
- if (this._previousState && JSONExt.deepEqual(state, this._previousState)) {
281
+ const stateUnchanged =
282
+ this._previousState !== null &&
283
+ JSONExt.deepEqual(state, this._previousState);
284
+ if (!stateUnchanged) {
285
+ this._previousState = state;
286
+ Private.updateCrumbs(this._crumbContent, this._crumbs, state);
287
+ this._syncCrumbTabIndices();
288
+ }
289
+
290
+ this._runPostActivationFocus();
291
+ }
292
+
293
+ /**
294
+ * Restore crumb focus after breadcrumb activation and invoke activation callback.
295
+ */
296
+ private _runPostActivationFocus(): void {
297
+ if (!this._restoreBreadcrumbFocusAfterUpdate) {
298
+ return;
299
+ }
300
+ this._restoreBreadcrumbFocusAfterUpdate = false;
301
+ requestAnimationFrame(() => {
302
+ if (this.isDisposed || this._isEditMode) {
303
+ return;
304
+ }
305
+ this._onPathActivated?.();
306
+ });
307
+ }
308
+
309
+ /**
310
+ * Return breadcrumb segments in DOM order that participate in arrow-key focus.
311
+ */
312
+ private _getFocusableCrumbElements(): HTMLElement[] {
313
+ const result: HTMLElement[] = [];
314
+ const children = this._crumbContent.children;
315
+ for (let i = 0; i < children.length; i++) {
316
+ const el = children[i] as HTMLElement;
317
+ if (
318
+ el.classList.contains(BREADCRUMB_PREFERRED_CLASS) ||
319
+ el.classList.contains(BREADCRUMB_ROOT_CLASS) ||
320
+ el.classList.contains(BREADCRUMB_ITEM_CLASS)
321
+ ) {
322
+ result.push(el);
323
+ }
324
+ }
325
+ return result;
326
+ }
327
+
328
+ /**
329
+ * Roving tabindex: one segment is in tab order (`tabIndex` 0), the rest -1.
330
+ */
331
+ private _syncCrumbTabIndices(): void {
332
+ const items = this._getFocusableCrumbElements();
333
+ for (let i = 0; i < items.length; i++) {
334
+ items[i].tabIndex = i === 0 ? 0 : -1;
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Focus a crumb segment and make it the sole tab stop within the trail.
340
+ */
341
+ private _focusCrumb(crumb: HTMLElement): void {
342
+ const items = this._getFocusableCrumbElements();
343
+ const idx = items.indexOf(crumb);
344
+ if (idx === -1) {
345
+ return;
346
+ }
347
+ for (let i = 0; i < items.length; i++) {
348
+ items[i].tabIndex = i === idx ? 0 : -1;
349
+ }
350
+ crumb.focus();
351
+ }
352
+
353
+ /**
354
+ * Focus the last segment in the trail (the current directory), for keyboard UX after navigation.
355
+ */
356
+ private _focusTrailingCrumb(): void {
357
+ const items = this._getFocusableCrumbElements();
358
+ if (items.length === 0) {
359
+ // If there is no focusable crumb segment (e.g. root-restricted path),
360
+ // keep focus inside the breadcrumb widget itself.
361
+ this.node.tabIndex = -1;
362
+ this.node.focus();
363
+ return;
364
+ }
365
+ this._focusCrumb(items[items.length - 1]);
366
+ }
367
+
368
+ /**
369
+ * Walk from an event target to the nearest breadcrumb segment host, if any.
370
+ */
371
+ private _resolveCrumbFromEventTarget(
372
+ target: EventTarget | null
373
+ ): HTMLElement | null {
374
+ let node = target as HTMLElement | null;
375
+ while (node && node !== this.node) {
376
+ if (
377
+ node.classList.contains(BREADCRUMB_PREFERRED_CLASS) ||
378
+ node.classList.contains(BREADCRUMB_ROOT_CLASS) ||
379
+ node.classList.contains(BREADCRUMB_ITEM_CLASS)
380
+ ) {
381
+ return node;
382
+ }
383
+ node = node.parentElement;
384
+ }
385
+ return null;
386
+ }
387
+
388
+ /**
389
+ * Whether a crumb corresponds to the current directory segment.
390
+ */
391
+ private _isCurrentDirectoryCrumb(crumb: HTMLElement): boolean {
392
+ if (
393
+ !crumb.classList.contains(BREADCRUMB_ITEM_CLASS) ||
394
+ crumb.classList.contains(BREADCRUMB_ELLIPSIS_CLASS)
395
+ ) {
396
+ return false;
397
+ }
398
+ const contents = this._model.manager.services.contents;
399
+ const localPath = contents.localPath(this._model.path);
400
+ return crumb.dataset.path === localPath;
401
+ }
402
+
403
+ /**
404
+ * Navigate to the directory represented by a breadcrumb segment.
405
+ */
406
+ private _activateCrumbSegment(crumb: HTMLElement): void {
407
+ this._focusCrumb(crumb);
408
+ const destination = this._destinationForCrumb(crumb);
409
+ if (!destination) {
410
+ return;
411
+ }
412
+
413
+ this._restoreBreadcrumbFocusAfterUpdate = true;
414
+ this._model.cd(destination).catch(error => {
415
+ this._restoreBreadcrumbFocusAfterUpdate = false;
416
+ void showErrorMessage(this._trans.__('Open Error'), error);
417
+ });
418
+ }
419
+
420
+ /**
421
+ * Resolve the destination path for an activated breadcrumb segment.
422
+ */
423
+ private _destinationForCrumb(crumb: HTMLElement): string | null {
424
+ if (crumb.classList.contains(BREADCRUMB_PREFERRED_CLASS)) {
425
+ const preferredPath = PageConfig.getOption('preferredPath');
426
+ return preferredPath ? '/' + preferredPath : '/';
427
+ }
428
+ if (crumb.classList.contains(BREADCRUMB_ROOT_CLASS)) {
429
+ return '/';
430
+ }
431
+ if (crumb.classList.contains(BREADCRUMB_ITEM_CLASS)) {
432
+ return `/${crumb.dataset.path}`;
433
+ }
434
+ return null;
435
+ }
436
+
437
+ /**
438
+ * Handle the `'keydown'` event for the widget.
439
+ */
440
+ private _evtKeyDown(event: KeyboardEvent): void {
441
+ if (this._isEditMode) {
442
+ return;
443
+ }
444
+
445
+ const isActivateKey = event.key === 'Enter' || event.key === ' ';
446
+ const crumb = this._resolveCrumbFromEventTarget(event.target);
447
+ const localPath = this._model.manager.services.contents.localPath(
448
+ this._model.path
449
+ );
450
+
451
+ if (isActivateKey && crumb && this._crumbContent.contains(crumb)) {
452
+ const enterEditModeFromRoot =
453
+ event.key === ' ' &&
454
+ crumb.classList.contains(BREADCRUMB_ROOT_CLASS) &&
455
+ localPath === '';
456
+
457
+ if (this._isCurrentDirectoryCrumb(crumb) || enterEditModeFromRoot) {
458
+ this.enterEditMode();
459
+ } else {
460
+ this._activateCrumbSegment(crumb);
461
+ }
462
+
463
+ event.preventDefault();
464
+ event.stopPropagation();
465
+ return;
466
+ }
467
+
468
+ if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') {
469
+ return;
470
+ }
471
+
472
+ if (!crumb || !this._crumbContent.contains(crumb)) {
473
+ return;
474
+ }
475
+
476
+ const items = this._getFocusableCrumbElements();
477
+ const idx = items.indexOf(crumb);
478
+ if (idx === -1) {
232
479
  return;
233
480
  }
234
- this._previousState = state;
235
- Private.updateCrumbs(this._crumbs, state);
481
+
482
+ const delta = event.key === 'ArrowLeft' ? -1 : 1;
483
+ const nextIdx = idx + delta;
484
+ if (nextIdx < 0 || nextIdx >= items.length) {
485
+ return;
486
+ }
487
+
488
+ this._focusCrumb(items[nextIdx]);
489
+ event.preventDefault();
490
+ event.stopPropagation();
236
491
  }
237
492
 
238
493
  /**
@@ -244,40 +499,20 @@ export class BreadCrumbs extends Widget {
244
499
  return;
245
500
  }
246
501
 
502
+ // In edit mode the PathNavigator owns all interaction.
503
+ if (this._isEditMode) {
504
+ return;
505
+ }
506
+
247
507
  // Find a valid click target.
248
508
  let node = event.target as HTMLElement;
249
509
  while (node && node !== this.node) {
250
- if (node.classList.contains(BREADCRUMB_PREFERRED_CLASS)) {
251
- const preferredPath = PageConfig.getOption('preferredPath');
252
- const path = preferredPath ? '/' + preferredPath : preferredPath;
253
- this._model
254
- .cd(path)
255
- .catch(error =>
256
- showErrorMessage(this._trans.__('Open Error'), error)
257
- );
258
-
259
- // Stop the event propagation.
260
- event.preventDefault();
261
- event.stopPropagation();
262
- return;
263
- }
264
510
  if (
511
+ node.classList.contains(BREADCRUMB_PREFERRED_CLASS) ||
265
512
  node.classList.contains(BREADCRUMB_ITEM_CLASS) ||
266
513
  node.classList.contains(BREADCRUMB_ROOT_CLASS)
267
514
  ) {
268
- let destination: string | undefined;
269
- if (node.classList.contains(BREADCRUMB_ROOT_CLASS)) {
270
- destination = '/';
271
- } else {
272
- destination = `/${node.dataset.path}`;
273
- }
274
- if (destination) {
275
- this._model
276
- .cd(destination)
277
- .catch(error =>
278
- showErrorMessage(this._trans.__('Open Error'), error)
279
- );
280
- }
515
+ this._activateCrumbSegment(node);
281
516
 
282
517
  // Stop the event propagation.
283
518
  event.preventDefault();
@@ -286,6 +521,11 @@ export class BreadCrumbs extends Widget {
286
521
  }
287
522
  node = node.parentElement as HTMLElement;
288
523
  }
524
+
525
+ // Click landed on the breadcrumb background (including separators
526
+ // between crumb items) — enter edit mode. This is intentional: the
527
+ // entire breadcrumb bar acts as a click target for the path editor.
528
+ this.enterEditMode();
289
529
  }
290
530
 
291
531
  /**
@@ -415,7 +655,7 @@ export class BreadCrumbs extends Widget {
415
655
  */
416
656
  private _getBreadcrumbElements(): HTMLElement[] {
417
657
  const elements: HTMLElement[] = [];
418
- const children = this.node.children;
658
+ const children = this._crumbContent.children;
419
659
  for (let i = 0; i < children.length; i++) {
420
660
  const child = children[i] as HTMLElement;
421
661
  if (
@@ -448,7 +688,7 @@ export class BreadCrumbs extends Widget {
448
688
  * including those currently hidden behind the ellipsis.
449
689
  */
450
690
  private _measureAllItemWidths(parts: string[]): void {
451
- const node = this.node;
691
+ const node = this._crumbContent;
452
692
 
453
693
  // Measure fixed elements that are already in the DOM
454
694
  const home = this._crumbs[Private.Crumb.Home];
@@ -542,7 +782,7 @@ export class BreadCrumbs extends Widget {
542
782
  // Calculate available space for items
543
783
  let fixedOverhead = homeWidth + separatorWidth;
544
784
  if (this._hasPreferred) {
545
- fixedOverhead += preferredWidth + separatorWidth;
785
+ fixedOverhead += preferredWidth;
546
786
  }
547
787
  const availableForItems = containerWidth - fixedOverhead;
548
788
 
@@ -600,6 +840,68 @@ export class BreadCrumbs extends Widget {
600
840
  };
601
841
  }
602
842
 
843
+ /**
844
+ * Enter edit mode: show the path input and hide the breadcrumb content.
845
+ */
846
+ enterEditMode(): void {
847
+ this._isEditMode = true;
848
+ // Snapshot the current path so _onModelRefreshed can reliably detect
849
+ // whether the path actually changed while in edit mode.
850
+ const contents = this._model.manager.services.contents;
851
+ this._lastPath = contents.localPath(this._model.path);
852
+ this.node.classList.add(BREADCRUMB_EDIT_MODE_CLASS);
853
+ // Clear cached state so that when we exit edit mode, onUpdateRequest
854
+ // will unconditionally re-render the breadcrumbs (the path may have
855
+ // changed while we were editing). _exitEditMode also clears this for
856
+ // the same reason — the double-null is intentional even if technically
857
+ // unnecessary.
858
+ this._previousState = null;
859
+ this._pathNavigator.open();
860
+ }
861
+
862
+ /**
863
+ * Move focus to the trailing breadcrumb segment.
864
+ */
865
+ focusLastCrumb(): void {
866
+ // Defer to ensure any pending breadcrumb DOM updates are applied.
867
+ requestAnimationFrame(() => {
868
+ this._focusTrailingCrumb();
869
+ });
870
+ }
871
+
872
+ /**
873
+ * Exit edit mode and restore the breadcrumb display.
874
+ */
875
+ private _exitEditMode(): void {
876
+ if (!this._isEditMode) {
877
+ return;
878
+ }
879
+ this._isEditMode = false;
880
+ this.node.classList.remove(BREADCRUMB_EDIT_MODE_CLASS);
881
+ this._previousState = null;
882
+ this.update();
883
+ }
884
+
885
+ /**
886
+ * Handle the model's `refreshed` signal.
887
+ * If we are in edit mode, dismiss it (the model path may have changed).
888
+ */
889
+ private _onModelRefreshed(): void {
890
+ const contents = this._model.manager.services.contents;
891
+ const localPath = contents.localPath(this._model.path);
892
+ if (this._isEditMode) {
893
+ if (
894
+ this._pathNavigator.matchesSubmittedPath(localPath) ||
895
+ localPath !== this._lastPath
896
+ ) {
897
+ this._exitEditMode();
898
+ this._onPathEdited?.();
899
+ }
900
+ return;
901
+ }
902
+ this.update();
903
+ }
904
+
603
905
  protected translator: ITranslator;
604
906
  private _trans: TranslationBundle;
605
907
  private _model: FileBrowserModel;
@@ -619,6 +921,18 @@ export class BreadCrumbs extends Widget {
619
921
  itemWidths: number[];
620
922
  } | null = null;
621
923
  private _lastRenderedWidth = 0;
924
+ private _isEditMode = false;
925
+ private _lastPath = '';
926
+ private _crumbContainer: HTMLElement;
927
+ private _crumbContent: HTMLElement;
928
+ private _pathNavigator: PathNavigator;
929
+ private _onPathEdited?: () => void;
930
+ private _onPathActivated?: () => void;
931
+
932
+ /**
933
+ * After `cd()` rebuilds the trail, restore focus to the current-directory segment.
934
+ */
935
+ private _restoreBreadcrumbFocusAfterUpdate = false;
622
936
  }
623
937
 
624
938
  /**
@@ -653,6 +967,16 @@ export namespace BreadCrumbs {
653
967
  * Number of items to show on right of ellipsis
654
968
  */
655
969
  minimumRightItems?: number;
970
+
971
+ /**
972
+ * Callback invoked after path edit changes directory.
973
+ */
974
+ onPathEdited?: () => void;
975
+
976
+ /**
977
+ * Callback invoked after breadcrumb activation changes directory.
978
+ */
979
+ onPathActivated?: () => void;
656
980
  }
657
981
  }
658
982
 
@@ -675,6 +999,7 @@ namespace Private {
675
999
  export interface ICrumbsState {
676
1000
  [key: string]: string | boolean | number;
677
1001
  path: string;
1002
+ root: string;
678
1003
  hasPreferred: boolean;
679
1004
  fullPath: boolean;
680
1005
  minimumLeftItems: number;
@@ -682,80 +1007,96 @@ namespace Private {
682
1007
  }
683
1008
 
684
1009
  /**
685
- * Populate the breadcrumb node.
1010
+ * Populate the breadcrumb container node.
1011
+ *
1012
+ * @param container - The container element that holds breadcrumb items.
1013
+ * @param breadcrumbs - The reusable breadcrumb elements (Home, Ellipsis, Preferred).
1014
+ * @param state - The current breadcrumb state.
686
1015
  */
687
1016
  export function updateCrumbs(
1017
+ container: HTMLElement,
688
1018
  breadcrumbs: ReadonlyArray<HTMLElement>,
689
1019
  state: ICrumbsState
690
1020
  ): void {
691
- const node = breadcrumbs[0].parentNode as HTMLElement;
692
-
693
- // Remove all but the home or preferred node.
694
- const firstChild = node.firstChild as HTMLElement;
695
- while (firstChild && firstChild.nextSibling) {
696
- node.removeChild(firstChild.nextSibling);
697
- }
698
-
699
- if (state.hasPreferred) {
700
- node.appendChild(breadcrumbs[Crumb.Home]);
701
- node.appendChild(createCrumbSeparator());
1021
+ const nodes: Node[] = [];
1022
+
1023
+ // Calculate the starting index for breadcrumbs based on root restriction
1024
+ const rootParts = state.root
1025
+ ? state.root.split('/').filter(part => part !== '')
1026
+ : [];
1027
+ const rootDepth = rootParts.length;
1028
+
1029
+ // Only show home/preferred if there's no root restriction
1030
+ if (!state.root) {
1031
+ if (state.hasPreferred) {
1032
+ nodes.push(breadcrumbs[Private.Crumb.Preferred]);
1033
+ }
1034
+ nodes.push(breadcrumbs[Crumb.Home], createCrumbSeparator());
702
1035
  } else {
703
- node.appendChild(createCrumbSeparator());
1036
+ // With root restriction, just add an initial separator
1037
+ nodes.push(createCrumbSeparator());
704
1038
  }
705
1039
 
706
1040
  const parts = state.path.split('/').filter(part => part !== '');
707
- if (!state.fullPath && parts.length > 0) {
1041
+ // Filter parts to only show those at or below the root
1042
+ const visibleParts = state.root ? parts.slice(rootDepth) : parts;
1043
+ const visibleStartIndex = rootDepth;
1044
+
1045
+ if (!state.fullPath && visibleParts.length > 0) {
708
1046
  const minimumLeftItems = state.minimumLeftItems;
709
1047
  const minimumRightItems = state.minimumRightItems;
710
1048
 
711
- // Check if we need ellipsis
712
- if (parts.length > minimumLeftItems + minimumRightItems) {
1049
+ // Check if we need ellipsis (based on visible parts)
1050
+ if (visibleParts.length > minimumLeftItems + minimumRightItems) {
713
1051
  // Add left items
714
1052
  for (let i = 0; i < minimumLeftItems; i++) {
715
- const elemPath = parts.slice(0, i + 1).join('/');
716
- const elem = createBreadcrumbElement(parts[i], elemPath);
717
- node.appendChild(elem);
718
- node.appendChild(createCrumbSeparator());
1053
+ const fullIndex = visibleStartIndex + i;
1054
+ const elemPath = parts.slice(0, fullIndex + 1).join('/');
1055
+ nodes.push(createBreadcrumbElement(visibleParts[i], elemPath));
1056
+ nodes.push(createCrumbSeparator());
719
1057
  }
720
1058
 
721
1059
  // Add ellipsis
722
- node.appendChild(breadcrumbs[Crumb.Ellipsis]);
723
1060
  const hiddenStartIndex = minimumLeftItems;
724
- const hiddenEndIndex = parts.length - minimumRightItems;
725
- const hiddenParts = parts.slice(hiddenStartIndex, hiddenEndIndex);
726
- const hiddenFolders = hiddenParts.join('/');
727
- const hiddenPath =
728
- hiddenParts.length > 0
729
- ? parts.slice(0, hiddenEndIndex).join('/')
730
- : parts.slice(0, minimumLeftItems).join('/');
731
- breadcrumbs[Crumb.Ellipsis].title = hiddenFolders;
732
- breadcrumbs[Crumb.Ellipsis].dataset.path = hiddenPath;
733
- node.appendChild(createCrumbSeparator());
1061
+ const hiddenEndIndex = visibleParts.length - minimumRightItems;
1062
+ const hiddenVisibleParts = visibleParts.slice(
1063
+ hiddenStartIndex,
1064
+ hiddenEndIndex
1065
+ );
1066
+ const ellipsis = breadcrumbs[Crumb.Ellipsis];
1067
+ ellipsis.title = hiddenVisibleParts.join('/');
1068
+ ellipsis.dataset.path =
1069
+ hiddenVisibleParts.length > 0
1070
+ ? parts.slice(0, visibleStartIndex + hiddenEndIndex).join('/')
1071
+ : parts.slice(0, visibleStartIndex + minimumLeftItems).join('/');
1072
+ nodes.push(ellipsis, createCrumbSeparator());
734
1073
 
735
1074
  // Add right items
736
- const rightStartIndex = parts.length - minimumRightItems;
737
- for (let i = rightStartIndex; i < parts.length; i++) {
738
- const elemPath = parts.slice(0, i + 1).join('/');
739
- const elem = createBreadcrumbElement(parts[i], elemPath);
740
- node.appendChild(elem);
741
- node.appendChild(createCrumbSeparator());
1075
+ const rightStartIndex = visibleParts.length - minimumRightItems;
1076
+ for (let i = rightStartIndex; i < visibleParts.length; i++) {
1077
+ const fullIndex = visibleStartIndex + i;
1078
+ const elemPath = parts.slice(0, fullIndex + 1).join('/');
1079
+ nodes.push(createBreadcrumbElement(visibleParts[i], elemPath));
1080
+ nodes.push(createCrumbSeparator());
742
1081
  }
743
1082
  } else {
744
- for (let i = 0; i < parts.length; i++) {
745
- const elemPath = parts.slice(0, i + 1).join('/');
746
- const elem = createBreadcrumbElement(parts[i], elemPath);
747
- node.appendChild(elem);
748
- node.appendChild(createCrumbSeparator());
1083
+ for (let i = 0; i < visibleParts.length; i++) {
1084
+ const fullIndex = visibleStartIndex + i;
1085
+ const elemPath = parts.slice(0, fullIndex + 1).join('/');
1086
+ nodes.push(createBreadcrumbElement(visibleParts[i], elemPath));
1087
+ nodes.push(createCrumbSeparator());
749
1088
  }
750
1089
  }
751
- } else if (state.fullPath && parts.length > 0) {
752
- for (let i = 0; i < parts.length; i++) {
753
- const elemPath = parts.slice(0, i + 1).join('/');
754
- const elem = createBreadcrumbElement(parts[i], elemPath);
755
- node.appendChild(elem);
756
- node.appendChild(createCrumbSeparator());
1090
+ } else if (state.fullPath && visibleParts.length > 0) {
1091
+ for (let i = 0; i < visibleParts.length; i++) {
1092
+ const fullIndex = visibleStartIndex + i;
1093
+ const elemPath = parts.slice(0, fullIndex + 1).join('/');
1094
+ nodes.push(createBreadcrumbElement(visibleParts[i], elemPath));
1095
+ nodes.push(createCrumbSeparator());
757
1096
  }
758
1097
  }
1098
+
1099
+ container.replaceChildren(...nodes);
759
1100
  }
760
1101
 
761
1102
  /**
@@ -770,6 +1111,7 @@ namespace Private {
770
1111
  elem.textContent = pathPart;
771
1112
  elem.title = fullPath;
772
1113
  elem.dataset.path = fullPath;
1114
+ elem.tabIndex = -1;
773
1115
  return elem;
774
1116
  }
775
1117
 
@@ -784,12 +1126,14 @@ namespace Private {
784
1126
  stylesheet: 'breadCrumb'
785
1127
  });
786
1128
  home.dataset.path = '/';
1129
+ home.tabIndex = -1;
787
1130
 
788
1131
  const ellipsis = ellipsesIcon.element({
789
1132
  className: `${BREADCRUMB_ITEM_CLASS} ${BREADCRUMB_ELLIPSIS_CLASS}`,
790
1133
  tag: 'span',
791
1134
  stylesheet: 'breadCrumb'
792
1135
  });
1136
+ ellipsis.tabIndex = -1;
793
1137
 
794
1138
  const preferredPath = PageConfig.getOption('preferredPath');
795
1139
  const path = preferredPath ? '/' + preferredPath : preferredPath;
@@ -800,6 +1144,7 @@ namespace Private {
800
1144
  stylesheet: 'breadCrumb'
801
1145
  });
802
1146
  preferred.dataset.path = path || '/';
1147
+ preferred.tabIndex = -1;
803
1148
 
804
1149
  return [home, ellipsis, preferred];
805
1150
  }