fix(ui): better handling of new folder creation

issue: on folder creation view was wiped and displaying only the new folder
    fix: add a "new" swimlane if in group mode and scroll up to the new created folder
This commit is contained in:
Edouard Vanbelle
2026-06-05 11:15:44 +02:00
parent ca0f5b3fc5
commit 540c947e61
19 changed files with 233 additions and 24 deletions
+39
View File
@@ -41,6 +41,45 @@
background-color: var(--color-item);
}
/* Brief pulse on rows that were just optimistically inserted (e.g.
newly created folder, upload completion, drag-drop move into the
current folder). Pure CSS so timing is deterministic — the JS adds
the class, the animation auto-clears the background, and a
single `animationend` listener removes the class.
`scroll-margin-top` reserves space above the row for the sticky
page header (`.page-sticky-header` ≈ 80 px). Without it,
`scrollIntoView({ block: 'nearest' })` aligns the row's top edge
against the viewport's top edge — which the sticky header is
currently covering — so the user only sees the row's bottom edge.
`scroll-margin-bottom` gives a touch of breathing room when the
scroll happens to land the row near the viewport bottom. */
.file-item.resource-row--just-added {
animation: resource-row-just-added 1.5s ease-out;
scroll-margin-top: 100px;
scroll-margin-bottom: 24px;
}
@keyframes resource-row-just-added {
0% {
background-color: var(--color-success-bg);
}
100% {
background-color: transparent;
}
}
/* Client-only "New" swimlane created on the fly by `addItem()` when
the view is grouped. Subtler styling than a natural-group lane: the
user understands the pin is temporary (it dissolves on next full
reload), so we don't want the bar to dominate the list. The pinned
placement at the top of the container is what makes it
discoverable; the header just confirms the intent. */
.resource-list__swimlane-group--just-added > .resource-list__swimlane-header {
color: var(--color-success-text);
}
.file-item.selected {
background-color: var(--color-item-selected);
}
+11 -3
View File
@@ -366,9 +366,17 @@ async function _loadPage({ isFirstPage = false } = {}) {
function addItem(item) {
const component = _ensureComponent();
if (!component) return;
// Reveal the list if the empty-state is showing
ui.resetFilesList();
component.addItem(item);
// Hide the empty-state placeholder if it's currently showing —
// creating a folder in an empty directory should reveal the new
// row, not display both states side-by-side. (The component will
// un-hide `#files-list` itself when the item is inserted.)
document.getElementById('files-container-error')?.classList.add('hidden');
// Hand the item to the component so it can place it in the right
// swimlane (when the current view is grouped) and pulse-highlight
// + smooth-scroll it into view. We deliberately do NOT call
// `ui.resetFilesList()` here — that wipes the rendered DOM,
// defeating the whole point of an optimistic single-item insert.
component.addItem(item, { scroll: true, highlight: true });
}
/**
+150 -4
View File
@@ -28,6 +28,15 @@ import { createUserVignette } from './userVignette.js';
* @import {FileItem, FolderItem} from '../core/types.js'
*/
/**
* Reusable swimlane key for the client-side "just added" lane that
* `addItem()` opens at the top of the list in grouped views. Distinct
* from any natural group key the server might produce so the lookup
* can't collide with a real bucket whose label happens to read "New".
* @type {string}
*/
const JUST_ADDED_KEY = '__oxicloud_just_added__';
/**
* @typedef {Object} CustomAction
* @property {string} iconHtml - Inner HTML for the button icon (e.g. `<i class="fas fa-undo"></i>`).
@@ -131,6 +140,16 @@ export class ResourceListComponent {
*/
this._lastGroupEl = null;
/**
* Optional grouping-key resolver stored between `render()` / `append()`
* calls so `addItem()` can place a new row in the correct swimlane
* without the caller having to re-supply it. `undefined` means the
* current view is flat (no group-by); `null` is never stored — only
* function or `undefined`.
* @type {((item: FileItem|FolderItem) => string|null) | undefined}
*/
this._groupFn = undefined;
/**
* Optional label-resolver stored between `render()` and `append()` calls.
* @type {((key: string) => string) | undefined}
@@ -184,6 +203,7 @@ export class ResourceListComponent {
// Reset group tracking for the fresh render
this._lastGroupKey = undefined;
this._lastGroupEl = null;
this._groupFn = groupFn;
this._groupLabelFn = groupLabelFn;
this._headerNodeFn = headerNodeFn;
@@ -205,7 +225,13 @@ export class ResourceListComponent {
* @param {((key: string) => HTMLElement)=} headerNodeFn
*/
append(items, groupFn, groupLabelFn, headerNodeFn) {
this._appendItems(items, groupFn, groupLabelFn ?? this._groupLabelFn, headerNodeFn ?? this._headerNodeFn);
// Persist the latest non-undefined callbacks so `addItem()` can
// reuse them without the caller having to re-supply them on every
// optimistic insertion.
if (groupFn !== undefined) this._groupFn = groupFn;
if (groupLabelFn !== undefined) this._groupLabelFn = groupLabelFn;
if (headerNodeFn !== undefined) this._headerNodeFn = headerNodeFn;
this._appendItems(items, groupFn ?? this._groupFn, groupLabelFn ?? this._groupLabelFn, headerNodeFn ?? this._headerNodeFn);
}
/** Remove all items (but keep `.list-header` if present). */
@@ -218,6 +244,9 @@ export class ResourceListComponent {
this._lastClickedIndex = -1;
this._lastGroupKey = undefined;
this._lastGroupEl = null;
this._groupFn = undefined;
this._groupLabelFn = undefined;
this._headerNodeFn = undefined;
// Hand delegation back to ui.js
delete this._container.dataset.managedBy;
}
@@ -276,16 +305,128 @@ export class ResourceListComponent {
/**
* Append a single item, skipping silently if already present (duplicate guard).
* Clears the empty-state placeholder when the first item is added.
*
* Group-by aware: if the current view is grouped, the row goes into
* a dedicated **"New" swimlane pinned at the top of the list** that
* is created on first call and reused across subsequent inserts in
* the same session. This deliberately sidesteps re-computing the
* item's natural bucket on the client:
*
* - Different group-by dimensions (date, type, size, …) would each
* need their own resolver, and date-bucket math is sensitive to
* clock skew between client and server.
* - Cross-swimlane sort-position is impossible to mirror exactly
* without re-implementing the server's tiebreaker chain.
*
* The "New" lane is purely client-side and dissolves on the next
* full reload (when the server's authoritative grouping reasserts).
* Predictable and uniform across every group-by mode.
*
* @param {FileItem|FolderItem} item
* @param {{ scroll?: boolean, highlight?: boolean }} [opts]
* - `scroll`: smooth-scroll the new row into view. The
* `.resource-row--just-added` class also sets `scroll-margin`
* so the sticky page header doesn't cover the row.
* - `highlight`: flash a brief CSS pulse on the new row so the
* user can spot it amid similar siblings.
* @returns {HTMLElement | null} The inserted row, or `null` when the
* item was deduped.
*/
addItem(item) {
if (this._items.has(item.id)) return;
addItem(item, opts = {}) {
if (this._items.has(item.id)) return null;
// Also guard against stale DOM remnants not tracked in _items
const isFile = 'mime_type' in item;
const attr = isFile ? `data-file-id="${item.id}"` : `data-folder-id="${item.id}"`;
if (this._container.querySelector(`.file-item[${attr}]`)) return;
if (this._container.querySelector(`.file-item[${attr}]`)) return null;
this._container.classList.remove('hidden');
/** @type {HTMLElement | null} */
let row = null;
if (this._groupFn) {
// Grouped view → drop the new row into the top-of-list
// "New" swimlane, creating it on first call.
const lane = this._ensureJustAddedLane();
this._items.set(item.id, item);
row = isFile ? this._createFileItem(/** @type {FileItem} */ (item)) : this._createFolderItem(/** @type {FolderItem} */ (item));
lane.appendChild(row);
} else {
// Flat list (no grouping) — append at the end like before.
this._appendItems([item]);
row = /** @type {HTMLElement | null} */ (this._container.querySelector(`.file-item[${attr}]`));
}
if (!row) return null;
if (opts.highlight) {
row.classList.add('resource-row--just-added');
// Self-cleaning: drop the class once the keyframe completes
// so a future re-render starts from a neutral baseline.
row.addEventListener('animationend', () => row?.classList.remove('resource-row--just-added'), { once: true });
}
if (opts.scroll) {
// `block: 'nearest'` is a no-op when the row is already in
// view. `.resource-row--just-added` sets `scroll-margin-top`
// so the sticky page header doesn't clip the row when the
// scroll lands.
row.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
return row;
}
/**
* Ensure the top-of-list "New" swimlane exists and return its
* wrapper. Created on first call after a render; reused across
* subsequent `addItem()` calls in the same session. The lane
* dissolves on the next `render()` / `clear()`, at which point
* the server's authoritative grouping reasserts.
*
* Header label uses i18n key `groupby.justAdded` with an English
* fallback so views that haven't translated it still read sensibly.
*
* @returns {HTMLElement}
*/
_ensureJustAddedLane() {
const existing = this._findLaneByKey(JUST_ADDED_KEY);
if (existing) return existing;
const lane = document.createElement('div');
lane.className = 'resource-list__swimlane-group resource-list__swimlane-group--just-added';
lane.dataset.groupKey = JUST_ADDED_KEY;
const header = document.createElement('div');
header.className = 'resource-list__swimlane-header';
header.dataset.swimlaneHeader = 'true';
header.textContent = i18n.t('groupby.justAdded', 'New');
lane.appendChild(header);
// Insert at the very top of the container, immediately after
// the optional `.list-header` row, so the affordance is
// discoverable and the row's scroll-into-view brings the
// swimlane header into view too.
const listHeader = this._container.querySelector('.list-header');
if (listHeader?.nextSibling) {
this._container.insertBefore(lane, listHeader.nextSibling);
} else if (listHeader) {
this._container.appendChild(lane);
} else {
this._container.prepend(lane);
}
return lane;
}
/**
* Locate an on-screen swimlane wrapper by its group key. Returns
* `null` when no swimlane currently matches.
*
* @param {string} key
* @returns {HTMLElement | null}
*/
_findLaneByKey(key) {
// CSS.escape covers arbitrary key shapes (dates with colons,
// UUIDs with dashes, etc.) so the attribute selector is safe.
return /** @type {HTMLElement | null} */ (this._container.querySelector(`.resource-list__swimlane-group[data-group-key="${CSS.escape(String(key))}"]`));
}
/**
@@ -404,6 +545,11 @@ export class ResourceListComponent {
if (key !== null) {
fragmentGroup = document.createElement('div');
fragmentGroup.className = 'resource-list__swimlane-group';
// Stamp the group key on the wrapper so `addItem()`
// can locate this swimlane later via
// `_findLaneByKey()` and append into it without a
// full re-render.
fragmentGroup.dataset.groupKey = key;
fragmentGroup.appendChild(this._createGroupHeader(key, groupLabelFn, headerNodeFn));
fragment.appendChild(fragmentGroup);
}
+2 -1
View File
@@ -835,7 +835,8 @@
"size": "الحجم",
"favoriteDate": "تاريخ المفضلة",
"byFiles": "By files",
"sharedWith": "Shared with"
"sharedWith": "Shared with",
"justAdded": "جديد"
},
"dateBucket": {
"today": "اليوم",
+2 -1
View File
@@ -835,7 +835,8 @@
"size": "Größe",
"favoriteDate": "Datum der Markierung",
"byFiles": "By files",
"sharedWith": "Shared with"
"sharedWith": "Shared with",
"justAdded": "Neu"
},
"dateBucket": {
"today": "Heute",
+2 -1
View File
@@ -845,7 +845,8 @@
"accessedAt": "Accessed date",
"modifiedAt": "Modified date",
"createdAt": "Created date",
"size": "Size"
"size": "Size",
"justAdded": "New"
},
"dateBucket": {
"today": "Today",
+2 -1
View File
@@ -835,7 +835,8 @@
"size": "Tamaño",
"favoriteDate": "Fecha de favorito",
"byFiles": "By files",
"sharedWith": "Shared with"
"sharedWith": "Shared with",
"justAdded": "Nuevo"
},
"dateBucket": {
"today": "Hoy",
+2 -1
View File
@@ -835,7 +835,8 @@
"size": "اندازه",
"favoriteDate": "تاریخ مورد علاقه",
"byFiles": "By files",
"sharedWith": "Shared with"
"sharedWith": "Shared with",
"justAdded": "جدید"
},
"dateBucket": {
"today": "امروز",
+2 -1
View File
@@ -835,7 +835,8 @@
"createdAt": "Date de création",
"size": "Taille",
"byFiles": "By files",
"sharedWith": "Shared with"
"sharedWith": "Shared with",
"justAdded": "Nouveau"
},
"dateBucket": {
"today": "Aujourd'hui",
+2 -1
View File
@@ -835,7 +835,8 @@
"size": "आकार",
"favoriteDate": "पसंदीदा की तारीख",
"byFiles": "By files",
"sharedWith": "Shared with"
"sharedWith": "Shared with",
"justAdded": "नया"
},
"dateBucket": {
"today": "आज",
+2 -1
View File
@@ -835,7 +835,8 @@
"size": "Dimensione",
"favoriteDate": "Data preferito",
"byFiles": "By files",
"sharedWith": "Shared with"
"sharedWith": "Shared with",
"justAdded": "Nuovo"
},
"dateBucket": {
"today": "Oggi",
+2 -1
View File
@@ -835,7 +835,8 @@
"size": "サイズ",
"favoriteDate": "お気に入り登録日",
"byFiles": "By files",
"sharedWith": "Shared with"
"sharedWith": "Shared with",
"justAdded": "新規"
},
"dateBucket": {
"today": "今日",
+2 -1
View File
@@ -835,7 +835,8 @@
"size": "크기",
"favoriteDate": "즐겨찾기 날짜",
"byFiles": "By files",
"sharedWith": "Shared with"
"sharedWith": "Shared with",
"justAdded": "새 항목"
},
"dateBucket": {
"today": "오늘",
+2 -1
View File
@@ -835,7 +835,8 @@
"size": "Grootte",
"favoriteDate": "Favoritendatum",
"byFiles": "By files",
"sharedWith": "Shared with"
"sharedWith": "Shared with",
"justAdded": "Nieuw"
},
"dateBucket": {
"today": "Vandaag",
+2 -1
View File
@@ -835,7 +835,8 @@
"size": "Rozmiar",
"favoriteDate": "Data dodania do ulubionych",
"byFiles": "By files",
"sharedWith": "Shared with"
"sharedWith": "Shared with",
"justAdded": "Nowe"
},
"dateBucket": {
"today": "Dzisiaj",
+2 -1
View File
@@ -835,7 +835,8 @@
"size": "Tamanho",
"favoriteDate": "Data de favorito",
"byFiles": "By files",
"sharedWith": "Shared with"
"sharedWith": "Shared with",
"justAdded": "Novo"
},
"dateBucket": {
"today": "Hoje",
+2 -1
View File
@@ -835,7 +835,8 @@
"size": "Размер",
"favoriteDate": "Дата добавления в избранное",
"byFiles": "By files",
"sharedWith": "Shared with"
"sharedWith": "Shared with",
"justAdded": "Новые"
},
"dateBucket": {
"today": "Сегодня",
+2 -1
View File
@@ -835,7 +835,8 @@
"size": "大小",
"favoriteDate": "收藏日期",
"byFiles": "By files",
"sharedWith": "Shared with"
"sharedWith": "Shared with",
"justAdded": "新增"
},
"dateBucket": {
"today": "今天",
+2 -1
View File
@@ -835,7 +835,8 @@
"size": "大小",
"favoriteDate": "收藏日期",
"byFiles": "By files",
"sharedWith": "Shared with"
"sharedWith": "Shared with",
"justAdded": "新建"
},
"dateBucket": {
"today": "今天",