@jupyterlab/filebrowser 4.6.0-alpha.3 → 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/lib/browser.d.ts +14 -0
- package/lib/browser.js +48 -1
- package/lib/browser.js.map +1 -1
- package/lib/crumbs.d.ts +76 -0
- package/lib/crumbs.js +362 -81
- package/lib/crumbs.js.map +1 -1
- package/lib/listing.d.ts +84 -11
- package/lib/listing.js +276 -99
- package/lib/listing.js.map +1 -1
- package/lib/model.d.ts +25 -0
- package/lib/model.js +48 -2
- package/lib/model.js.map +1 -1
- package/lib/opendialog.d.ts +7 -0
- package/lib/opendialog.js +5 -4
- package/lib/opendialog.js.map +1 -1
- package/lib/pathnavigator.d.ts +108 -0
- package/lib/pathnavigator.js +389 -0
- package/lib/pathnavigator.js.map +1 -0
- package/lib/tokens.d.ts +14 -0
- package/lib/tokens.js +1 -0
- package/lib/tokens.js.map +1 -1
- package/package.json +11 -11
- package/src/browser.ts +50 -1
- package/src/crumbs.ts +432 -87
- package/src/listing.ts +418 -119
- package/src/model.ts +63 -2
- package/src/opendialog.ts +13 -0
- package/src/pathnavigator.ts +456 -0
- package/src/tokens.ts +20 -0
- package/style/base.css +64 -35
- package/style/pathnavigator.css +72 -0
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
this.
|
|
86
|
-
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
|
-
|
|
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
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
692
|
-
|
|
693
|
-
//
|
|
694
|
-
const
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
if
|
|
700
|
-
|
|
701
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
716
|
-
const
|
|
717
|
-
|
|
718
|
-
|
|
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 =
|
|
725
|
-
const
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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 =
|
|
737
|
-
for (let i = rightStartIndex; i <
|
|
738
|
-
const
|
|
739
|
-
const
|
|
740
|
-
|
|
741
|
-
|
|
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 <
|
|
745
|
-
const
|
|
746
|
-
const
|
|
747
|
-
|
|
748
|
-
|
|
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 &&
|
|
752
|
-
for (let i = 0; i <
|
|
753
|
-
const
|
|
754
|
-
const
|
|
755
|
-
|
|
756
|
-
|
|
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
|
}
|