diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.html b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.html
index dbd7e6c9491a..dd4817a8538d 100644
--- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.html
+++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.html
@@ -132,7 +132,13 @@
}
}
-
+
@@ -140,22 +146,17 @@
[rounded]="true"
[text]="true"
(click)="$store.toggleSidebar()"
+ [ariaLabel]="
+ (showSidebar ? 'edit.content.sidebar.close' : 'edit.content.sidebar.open') | dm
+ "
size="small"
data-testId="sidebar-toggle-button">
-
-
+
+ dock_to_left
+
diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.spec.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.spec.ts
index 2bea8c846748..d50b28be869c 100644
--- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.spec.ts
+++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.spec.ts
@@ -458,15 +458,67 @@ describe('DotFormComponent', () => {
expect(toggleSidebarSpy).toHaveBeenCalled();
});
- it('should render both open and close sidebar icons, hiding one per state', () => {
- const openIcon = spectator.query(byTestId('sidebar-open-icon'));
- const closeIcon = spectator.query(byTestId('sidebar-close-icon'));
- expect(openIcon).toBeTruthy();
- expect(closeIcon).toBeTruthy();
- // One of the two icons must be hidden at any given time
- const openHidden = openIcon?.classList.contains('hidden');
- const closeHidden = closeIcon?.classList.contains('hidden');
- expect(openHidden).not.toBe(closeHidden);
+ it('should render a single dock_to_left sidebar icon', () => {
+ const icon = spectator.query(byTestId('sidebar-toggle-icon'));
+
+ expect(icon).toBeTruthy();
+ // Material Symbols renders the glyph from the ligature text, so the exact content
+ // matters — a typo would silently render as plain words.
+ expect(icon?.textContent?.trim()).toBe('dock_to_left');
+ expect(icon?.classList.contains('material-symbols-outlined')).toBe(true);
+ // Rendered as-is: the flip the previous SVG pair carried pointed it the wrong way.
+ expect(icon?.classList.contains('-scale-x-100')).toBe(false);
+ // The glyph is decorative; the accessible name lives on the button.
+ expect(icon?.getAttribute('aria-hidden')).toBe('true');
+
+ // The previous two-icon (open/close SVG) markup is gone.
+ expect(spectator.query(byTestId('sidebar-open-icon'))).toBeFalsy();
+ expect(spectator.query(byTestId('sidebar-close-icon'))).toBeFalsy();
+ });
+
+ it('should name the native sidebar-toggle button per open/closed state', () => {
+ // Assert on the DESCENDANT , not the p-button host: the accessible name must
+ // land on the focusable control. `[attr.aria-label]` would sit on the inert host and
+ // leave this button unnamed (its only content is the aria-hidden glyph).
+ const nativeLabel = (): string | null | undefined =>
+ spectator
+ .query(byTestId('sidebar-toggle-button'))
+ ?.querySelector('button')
+ ?.getAttribute('aria-label');
+
+ // DotMessageService is a bare mockProvider (returns undefined), so echo the key back
+ // to make the label observable.
+ (spectator.inject(DotMessageService).get as jest.Mock).mockImplementation(
+ (key: string) => key
+ );
+
+ // Opening the sidebar wakes the information feature's effect, which fetches the
+ // reference-page count. This describe never stubs it, so the bare mockProvider hands
+ // back `undefined` and the effect throws on `.pipe` — asynchronously, landing on
+ // whichever test runs next.
+ dotEditContentService.getReferencePages.mockReturnValue(of(0));
+
+ const expectedLabel = () =>
+ store.isSidebarOpen()
+ ? 'edit.content.sidebar.close'
+ : 'edit.content.sidebar.open';
+
+ // `dm` is a PURE pipe, so it only re-runs when its input key changes — hence both
+ // assertions follow a toggle rather than reading the initial render. Derived from
+ // the live state instead of a hardcoded order: the initial value depends on how this
+ // describe initializes the store, not on the store's own default.
+ store.toggleSidebar();
+ spectator.detectChanges();
+ const afterFirstToggle = nativeLabel();
+ expect(afterFirstToggle).toBe(expectedLabel());
+
+ store.toggleSidebar();
+ spectator.detectChanges();
+ expect(nativeLabel()).toBe(expectedLabel());
+
+ // Both states were actually exercised — otherwise the two assertions above could
+ // both pass against a label that never changed.
+ expect(nativeLabel()).not.toBe(afterFirstToggle);
});
describe('TabView Styling', () => {
diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-side-panel/dot-edit-content-side-panel.component.html b/core-web/libs/edit-content/src/lib/components/dot-edit-content-side-panel/dot-edit-content-side-panel.component.html
index ff61cc726ed3..bd8b7afbabc9 100644
--- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-side-panel/dot-edit-content-side-panel.component.html
+++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-side-panel/dot-edit-content-side-panel.component.html
@@ -3,10 +3,17 @@
`!important` is required to override PrimeNG's default dimmed mask background, which the theme
and the `pt` API do not otherwise let us clear — it is deliberate, not a leftover.
- The left-cast shadow on `pt.root.class` is an arbitrary Tailwind value on purpose: the drawer
- uses `appendTo="body"` (teleported out of this component's DOM), so component-scoped SCSS /
- `:host ::ng-deep` cannot reach it, and no dotCMS `$shadow-*` token is left-directional (they all
- cast downward, but a right-anchored drawer needs the shadow cast to the left).
+ The shadow goes on `pt.root.class` rather than in component SCSS because the drawer uses
+ `appendTo="body"` (teleported out of this component's DOM), so component-scoped SCSS /
+ `:host ::ng-deep` cannot reach it.
+
+ `dismissible` is left OFF even though clicking outside DOES close the panel: PrimeNG's own
+ dismissible tears the drawer down immediately, bypassing the unsaved-changes guard. The mask
+ click is handled by the component instead (see `onMaskClick`).
+
+ The header buttons name themselves through PrimeNG's `[ariaLabel]` input, never
+ `[attr.aria-label]`: the latter lands on the non-focusable `` host, leaving the real
+ `` — whose only content is an icon glyph — with no accessible name.
-->
diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-side-panel/dot-edit-content-side-panel.component.spec.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-side-panel/dot-edit-content-side-panel.component.spec.ts
index cff68740354b..d966a3e0b314 100644
--- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-side-panel/dot-edit-content-side-panel.component.spec.ts
+++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-side-panel/dot-edit-content-side-panel.component.spec.ts
@@ -3,7 +3,8 @@ import { MockComponent, MockPipe } from 'ng-mocks';
import { Subject } from 'rxjs';
import { ButtonModule } from 'primeng/button';
-import { DrawerModule } from 'primeng/drawer';
+import { Drawer, DrawerModule } from 'primeng/drawer';
+import { ZIndexUtils } from 'primeng/utils';
import { DotCMSContentlet } from '@dotcms/dotcms-models';
import { DotMessagePipe } from '@dotcms/ui';
@@ -119,12 +120,21 @@ describe('DotEditContentSidePanelComponent', () => {
?.querySelector('i')
?.textContent?.trim();
- /** Expand button aria-label (i18n key via MockPipe) — user-facing expanded/collapsed cue. */
+ /**
+ * Expand button aria-label (i18n key via MockPipe) — user-facing expanded/collapsed cue.
+ *
+ * Read from the DESCENDANT native ``, not the `p-button` host: the accessible name has
+ * to land on the focusable control. Reading the host would also pass with `[attr.aria-label]`,
+ * which leaves the real button unnamed (its only content is an icon glyph).
+ */
const expandAriaLabel = (): string | null | undefined =>
- spectator.query(byTestId('side-panel-expand'), { root: true })?.getAttribute('aria-label');
+ spectator
+ .query(byTestId('side-panel-expand'), { root: true })
+ ?.querySelector('button')
+ ?.getAttribute('aria-label');
/**
- * Drawer width from the `pt.root.style` binding (`70%` collapsed / `100%` expanded). The drawer
+ * Drawer width from the `pt.root.style` binding (`80%` collapsed / `100%` expanded). The drawer
* is teleported to `document.body`, so query from the document root.
*/
const drawerWidth = (): string =>
@@ -136,7 +146,7 @@ describe('DotEditContentSidePanelComponent', () => {
expect(expandIcon()).toBe('open_in_full');
expect(expandAriaLabel()).toBe('edit.content.side-panel.expand');
- expect(drawerWidth()).toBe('70%');
+ expect(drawerWidth()).toBe('80%');
clickButton('side-panel-expand');
spectator.detectChanges();
@@ -149,7 +159,7 @@ describe('DotEditContentSidePanelComponent', () => {
spectator.detectChanges();
expect(expandIcon()).toBe('open_in_full');
expect(expandAriaLabel()).toBe('edit.content.side-panel.expand');
- expect(drawerWidth()).toBe('70%');
+ expect(drawerWidth()).toBe('80%');
expect(localStorage.getItem('dot-edit-content-side-panel-expanded')).toBe('false');
});
@@ -189,6 +199,30 @@ describe('DotEditContentSidePanelComponent', () => {
expect(closedSpy).toHaveBeenCalledTimes(1);
});
+ it('should ignore Escape while another PrimeNG overlay is stacked above the panel', () => {
+ spectator.setInput('data', EDIT_DATA);
+ spectator.detectChanges();
+
+ const layout = spectator.query(DotEditContentLayoutComponent);
+ const confirmClose = jest.spyOn(layout, 'confirmClose');
+ const closedSpy = jest.fn();
+ spectator.output('closed').subscribe(closedSpy);
+
+ // Stand in for the image editor dialog / a confirm popup opened from inside the panel:
+ // every PrimeNG overlay registers itself in this shared stack when it opens.
+ const stackedOverlay = document.createElement('div');
+ ZIndexUtils.set('modal', stackedOverlay, 2000);
+
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
+
+ // That overlay owns the ESC — the panel underneath must not consume it.
+ expect(confirmClose).not.toHaveBeenCalled();
+ expect(closedSpy).not.toHaveBeenCalled();
+
+ // Shared global stack: leaving the entry behind would leak into every later test.
+ ZIndexUtils.clear(stackedOverlay);
+ });
+
it('should ignore Escape when not the frontmost stacked panel (isTop === false)', () => {
(spectator.inject(DotSidePanelNavController).isTop as jest.Mock).mockReturnValue(false);
spectator.setInput('data', EDIT_DATA);
@@ -206,6 +240,120 @@ describe('DotEditContentSidePanelComponent', () => {
expect(closedSpy).not.toHaveBeenCalled();
});
+ /**
+ * Builds a stand-in for a drawer's modal mask and dispatches a bubbling click from it, as the
+ * browser would. PrimeNG creates the real mask imperatively during the show animation, which
+ * jsdom never runs — hence the stand-in.
+ *
+ * `ownedByPanel` decides whose mask it is: `true` assigns it to this panel's drawer instance
+ * (what the handler compares against), `false` leaves it as a foreign drawer's mask, which must
+ * be ignored.
+ */
+ const clickMask = ({ ownedByPanel }: { ownedByPanel: boolean }): void => {
+ const mask = document.createElement('div');
+ mask.classList.add('p-drawer-mask');
+ document.body.appendChild(mask);
+
+ if (ownedByPanel) {
+ spectator.query(Drawer).mask = mask;
+ }
+
+ mask.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ mask.remove();
+ };
+
+ it('should close through the editor guard when clicking outside the panel', () => {
+ spectator.setInput('data', EDIT_DATA);
+ spectator.detectChanges();
+
+ const layout = spectator.query(DotEditContentLayoutComponent);
+ const confirmClose = jest
+ .spyOn(layout, 'confirmClose')
+ .mockImplementation((onProceed: () => void) => onProceed());
+
+ const closedSpy = jest.fn();
+ spectator.output('closed').subscribe(closedSpy);
+
+ clickMask({ ownedByPanel: true });
+
+ // Same contract as ESC / the X button: the guard runs first, close only follows it.
+ expect(confirmClose).toHaveBeenCalledWith(expect.any(Function));
+ expect(closedSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('should NOT emit `closed` when the guard cancels a click-outside (unsaved changes kept)', () => {
+ spectator.setInput('data', EDIT_DATA);
+ spectator.detectChanges();
+
+ const layout = spectator.query(DotEditContentLayoutComponent);
+ jest.spyOn(layout, 'confirmClose').mockImplementation(() => {
+ /* user chose "Keep editing" → never calls onProceed */
+ });
+
+ const closedSpy = jest.fn();
+ spectator.output('closed').subscribe(closedSpy);
+
+ clickMask({ ownedByPanel: true });
+
+ expect(closedSpy).not.toHaveBeenCalled();
+ });
+
+ it('should ignore a click outside when not the frontmost stacked panel (isTop === false)', () => {
+ (spectator.inject(DotSidePanelNavController).isTop as jest.Mock).mockReturnValue(false);
+ spectator.setInput('data', EDIT_DATA);
+ spectator.detectChanges();
+
+ const layout = spectator.query(DotEditContentLayoutComponent);
+ const confirmClose = jest.spyOn(layout, 'confirmClose');
+ const closedSpy = jest.fn();
+ spectator.output('closed').subscribe(closedSpy);
+
+ clickMask({ ownedByPanel: true });
+
+ // A panel beneath the top one must not react to the shared document-level click.
+ expect(confirmClose).not.toHaveBeenCalled();
+ expect(closedSpy).not.toHaveBeenCalled();
+ });
+
+ it('should NOT close on a click inside the panel', () => {
+ spectator.setInput('data', EDIT_DATA);
+ spectator.detectChanges();
+
+ const layout = spectator.query(DotEditContentLayoutComponent);
+ const confirmClose = jest.spyOn(layout, 'confirmClose');
+ const closedSpy = jest.fn();
+ spectator.output('closed').subscribe(closedSpy);
+
+ // Asserted before dispatching on purpose: with an optional chain, a markup rename would
+ // silently skip the click and leave the two negative assertions below passing anyway.
+ const inside = spectator.query(byTestId('side-panel-title'), { root: true });
+ expect(inside).toBeTruthy();
+
+ // Bubbles up to the same document listener, but its target is not the mask.
+ inside.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+
+ expect(confirmClose).not.toHaveBeenCalled();
+ expect(closedSpy).not.toHaveBeenCalled();
+ });
+
+ it('should NOT close on a click on ANOTHER drawer mask', () => {
+ spectator.setInput('data', EDIT_DATA);
+ spectator.detectChanges();
+
+ const layout = spectator.query(DotEditContentLayoutComponent);
+ const confirmClose = jest.spyOn(layout, 'confirmClose');
+ const closedSpy = jest.fn();
+ spectator.output('closed').subscribe(closedSpy);
+
+ // `p-drawer-mask` is shared by every modal drawer in the app (the UVE block editor sidebar
+ // is one, mounted as a sibling of this panel). Matching on the class instead of the mask's
+ // identity would let a foreign drawer close this panel and pop the unsaved-changes prompt.
+ clickMask({ ownedByPanel: false });
+
+ expect(confirmClose).not.toHaveBeenCalled();
+ expect(closedSpy).not.toHaveBeenCalled();
+ });
+
it('should NOT emit `closed` when the editor guard cancels (unsaved changes kept)', () => {
spectator.setInput('data', EDIT_DATA);
spectator.detectChanges();
@@ -347,9 +495,11 @@ describe('DotEditContentSidePanelComponent — persisted expanded preference', (
?.querySelector('i')
?.textContent?.trim()
).toBe(expected.icon);
+ // Descendant native , not the p-button host — see `expandAriaLabel` above.
expect(
spectator
.query(byTestId('side-panel-expand'), { root: true })
+ ?.querySelector('button')
?.getAttribute('aria-label')
).toBe(expected.ariaLabel);
// Width is the user-visible outcome of reading `$expanded` on construction (`pt.root.style`).
@@ -372,7 +522,7 @@ describe('DotEditContentSidePanelComponent — persisted expanded preference', (
buildAndAssertOpenState({
icon: 'open_in_full',
ariaLabel: 'edit.content.side-panel.expand',
- width: '70%'
+ width: '80%'
});
});
});
diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-side-panel/dot-edit-content-side-panel.component.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-side-panel/dot-edit-content-side-panel.component.ts
index b80ae77a57d5..7cd08f79e9dd 100644
--- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-side-panel/dot-edit-content-side-panel.component.ts
+++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-side-panel/dot-edit-content-side-panel.component.ts
@@ -16,8 +16,9 @@ import {
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ButtonModule } from 'primeng/button';
-import { DrawerModule } from 'primeng/drawer';
+import { Drawer, DrawerModule } from 'primeng/drawer';
import { DialogService, DynamicDialogConfig } from 'primeng/dynamicdialog';
+import { ZIndexUtils } from 'primeng/utils';
import { DotCMSContentlet } from '@dotcms/dotcms-models';
import { popFormBridge, pushFormBridge } from '@dotcms/edit-content-bridge';
@@ -64,7 +65,7 @@ function writeExpandedPreference(expanded: boolean): void {
* It reuses the overlay editor plumbing: it provides {@link OverlayEditContentHost} (identity from
* the dialog config, in-place navigation, chrome no-ops) and, since it is not opened through
* `DialogService`, supplies the {@link DynamicDialogConfig} the host reads identity from — built
- * from the {@link data} input. The header shows the content title plus an expand toggle (70% ↔
+ * from the {@link data} input. The header shows the content title plus an expand toggle (80% ↔
* full width) and a close button.
*
* It also self-provides `DialogService` and {@link IMAGE_EDITOR_LAUNCHER} — the same pair
@@ -99,10 +100,13 @@ function writeExpandedPreference(expanded: boolean): void {
],
templateUrl: './dot-edit-content-side-panel.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
- // ESC closes the panel through the unsaved-changes guard. Bound at document level because
- // `appendTo="body"` moves the drawer out of this component's DOM subtree, so a template
- // `(keydown.escape)` on the drawer would never receive the event.
- host: { '(document:keydown.escape)': 'onEscape()' }
+ // ESC and click-outside close the panel through the unsaved-changes guard. Both are bound at
+ // document level because `appendTo="body"` moves the drawer (and its mask) out of this
+ // component's DOM subtree, so template listeners would never receive the events.
+ host: {
+ '(document:keydown.escape)': 'onEscape()',
+ '(document:click)': 'onMaskClick($event)'
+ }
})
export class DotEditContentSidePanelComponent implements OnDestroy {
readonly #injector = inject(Injector);
@@ -112,6 +116,13 @@ export class DotEditContentSidePanelComponent implements OnDestroy {
/** The hosted editor; used to run its unsaved-changes guard before closing. */
protected readonly $layout = viewChild(DotEditContentLayoutComponent);
+ /**
+ * This panel's own drawer. Needed for its `mask` element: the click-outside handler compares
+ * the clicked node against THIS mask, not against the shared `p-drawer-mask` class, so masks
+ * belonging to other drawers in the document never close this panel (see {@link onMaskClick}).
+ */
+ protected readonly $drawer = viewChild(Drawer);
+
/** Identity (and header title) of the content to create/edit, or `null` when closed. */
readonly data = input(null);
@@ -122,7 +133,7 @@ export class DotEditContentSidePanelComponent implements OnDestroy {
readonly saved = output();
/**
- * Whether the panel is expanded to the full viewport width (vs the default ~70%). Seeded from
+ * Whether the panel is expanded to the full viewport width (vs the default ~80%). Seeded from
* the user's persisted preference so a panel opens in the mode last chosen (see
* {@link toggleExpanded}).
*/
@@ -171,6 +182,53 @@ export class DotEditContentSidePanelComponent implements OnDestroy {
* instead of one panel at a time.
*/
protected onEscape(): void {
+ if (this.#hasOverlayAbove()) {
+ return;
+ }
+
+ if (this.#navController.isTop(this)) {
+ this.requestClose();
+ }
+ }
+
+ /**
+ * Whether another PrimeNG overlay is stacked on top of this panel — the image editor dialog, a
+ * confirm popup, an open select panel, or a second side panel.
+ *
+ * Such an overlay owns the ESC key, but it is appended to `body` outside this component's DOM,
+ * so the document-level listener would still fire and close the panel underneath it. Every
+ * PrimeNG overlay registers itself in the shared `ZIndexUtils` stack when it opens, so comparing
+ * the top of that stack against this drawer's own z-index answers "is something above me?"
+ * without matching on overlay class names, which differ per component and change across versions.
+ */
+ #hasOverlayAbove(): boolean {
+ const container = this.$drawer()?.container;
+
+ return !!container && ZIndexUtils.getCurrent() > ZIndexUtils.get(container);
+ }
+
+ /**
+ * Click-outside handler: clicking the area behind the panel closes it with the exact same
+ * semantics as ESC and the X button — i.e. through the unsaved-changes guard.
+ *
+ * The drawer's own `dismissible` is deliberately left off: it hides the drawer and emits
+ * `visibleChange(false)` the moment the mask is clicked, which both bypasses the guard (unsaved
+ * edits lost silently) and desyncs the one-way `[visible]` binding. Matching the mask ourselves
+ * keeps the panel on screen until the guard says it is safe to close.
+ *
+ * The listener is document-wide because the mask is built imperatively into `document.body`, so
+ * no template binding can reach it. It matches on the mask's IDENTITY rather than its
+ * `p-drawer-mask` class: that class is shared by every modal drawer in the app (the UVE block
+ * editor sidebar is one), and a class check would let a foreign drawer's mask close this panel
+ * and pop the unsaved-changes prompt. Comparing the element also means a click inside the panel,
+ * or a drag that starts inside and ends on the mask, resolves to a different node and is
+ * ignored. As with ESC, only the frontmost stacked panel reacts.
+ */
+ protected onMaskClick(event: MouseEvent): void {
+ if (event.target !== this.$drawer()?.mask) {
+ return;
+ }
+
if (this.#navController.isTop(this)) {
this.requestClose();
}
diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/dot-edit-content-sidebar.component.html b/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/dot-edit-content-sidebar.component.html
index 1b7c2db4c3b2..7dbcc5895384 100644
--- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/dot-edit-content-sidebar.component.html
+++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/dot-edit-content-sidebar.component.html
@@ -128,6 +128,20 @@
(actionFired)="workflowActionFired.emit($event)" />
}
+
+
+
+
+
@@ -156,10 +170,12 @@
}
-
+
+ [title]="'edit.content.sidebar.workflow.title' | dm"
+ class="[&_.dot-section]:border-b-0">
-
-
-
-
-
diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/dot-edit-content-sidebar.component.spec.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/dot-edit-content-sidebar.component.spec.ts
index 05bd3d0a83f3..a665e226b3a3 100644
--- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/dot-edit-content-sidebar.component.spec.ts
+++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/dot-edit-content-sidebar.component.spec.ts
@@ -440,14 +440,15 @@ describe('DotEditContentSidebarComponent', () => {
});
describe('sections', () => {
- it('should carry the expected persistence keys on the three sections', () => {
+ it('should carry the expected persistence keys on the three sections, in display order', () => {
const sections = spectator.queryAll(DotEditContentSidebarSectionComponent);
// The History tab also renders sidebar-sections (history.*); scope to the Actions ones.
const keys = sections
.map((section) => section.key())
.filter((key) => key.startsWith('actions.'));
- expect(keys).toEqual(['actions.locales', 'actions.workflow', 'actions.details']);
+ // queryAll returns DOM order, so this also pins the section order in the tab.
+ expect(keys).toEqual(['actions.details', 'actions.locales', 'actions.workflow']);
});
});
});
diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-preview/dot-file-field-preview.component.scss b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-preview/dot-file-field-preview.component.scss
index 2d307c14781f..c27f2a925985 100644
--- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-preview/dot-file-field-preview.component.scss
+++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-preview/dot-file-field-preview.component.scss
@@ -123,7 +123,11 @@
position: absolute;
right: 0.5rem;
top: 0.5rem;
- z-index: 100;
+ // Kept in the same 1-2 range as the other actions: these only need to sit above the
+ // thumbnail/code preview. A higher value let them paint over the form's sticky tab list
+ // (z-index: 10, carries the status chip + permissions/references), which was visible while
+ // scrolling on narrow containers — the only layout where these filled variants render.
+ z-index: 2;
flex-direction: column;
gap: 0.5rem;
diff --git a/core-web/libs/edit-content/src/lib/services/dot-side-panel-nav.service.spec.ts b/core-web/libs/edit-content/src/lib/services/dot-side-panel-nav.service.spec.ts
index 5e30f30934b7..7acf8589d1a1 100644
--- a/core-web/libs/edit-content/src/lib/services/dot-side-panel-nav.service.spec.ts
+++ b/core-web/libs/edit-content/src/lib/services/dot-side-panel-nav.service.spec.ts
@@ -29,8 +29,12 @@ describe('DotSidePanelNavController', () => {
const setViewportWidth = (width: number) =>
Object.defineProperty(window, 'innerWidth', { value: width, configurable: true });
- /** Simulate a wide viewport (>= threshold → no collapse). */
- const setWideViewport = () => setViewportWidth(1920);
+ /**
+ * Simulate a wide viewport (>= threshold → no collapse). Pinned to the threshold itself
+ * (COLLAPSE_MAX_WIDTH) because the comparison is exclusive (`<`): the boundary width must be
+ * treated as wide. Bumping the threshold in the service must fail here on purpose.
+ */
+ const setWideViewport = () => setViewportWidth(2560);
beforeEach(() => {
// mockProvider's jest.fn()s are shared across tests, so call counts (and any
diff --git a/core-web/libs/edit-content/src/lib/services/dot-side-panel-nav.service.ts b/core-web/libs/edit-content/src/lib/services/dot-side-panel-nav.service.ts
index cf0bca0453ac..68c4f5e98b00 100644
--- a/core-web/libs/edit-content/src/lib/services/dot-side-panel-nav.service.ts
+++ b/core-web/libs/edit-content/src/lib/services/dot-side-panel-nav.service.ts
@@ -38,13 +38,21 @@ function clearPrevNavCollapsed(): void {
}
}
-/** Collapse the navs for the side panel when the viewport is narrower than this (px). */
-const COLLAPSE_MAX_WIDTH = 1800;
+/**
+ * Collapse the navs for the side panel below this viewport width (px).
+ *
+ * Note this makes collapsing the COMMON case, not an edge case: the panel takes 80% of the
+ * viewport, so the strip left beside it only clears the 270px expanded nav by a useful margin on
+ * very wide screens. In practice everything below ultra-wide / native 4K collapses — and since
+ * `innerWidth` is CSS pixels, a scaled or non-maximized 2560 display lands here too.
+ */
+const COLLAPSE_MAX_WIDTH = 2560;
/**
* Collapses the main navigation (logo + menus) while an Edit Content side panel is open, and
* restores it when the last one closes — but only if it was expanded to begin with (a nav the user
- * had already collapsed stays collapsed), and only on small viewports ({@link shouldCollapse}).
+ * had already collapsed stays collapsed), and only below {@link COLLAPSE_MAX_WIDTH}
+ * ({@link shouldCollapse}), which covers most desktop widths.
*
* Ref-counted because the relationship field can stack a second panel on top: the nav must stay
* collapsed until EVERY panel has closed, not just the top one. The pre-panel state is kept in
@@ -62,7 +70,10 @@ export class DotSidePanelNavController {
*/
#stack: object[] = [];
- /** Whether the viewport is narrow enough ({@link COLLAPSE_MAX_WIDTH}) to collapse the navs. */
+ /**
+ * Whether the viewport is below {@link COLLAPSE_MAX_WIDTH}, i.e. whether the navs should
+ * collapse. True for most desktop widths — see the constant for why that is intended.
+ */
shouldCollapse(): boolean {
return window.innerWidth < COLLAPSE_MAX_WIDTH;
}