Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -735,6 +735,21 @@ drag in this application — HTML5 drag and drop does not work in this WebView
a `role="separator"` so the arrow keys move it too. It is clamped on the way in _and_ on the
way out (`RAIL_WIDTH`), because a preferences file written by hand is an input like any other.

**The floor is the tree's own, not the panels'.** It stopped at 220px, and that number had
nothing to do with the rows: `space-editor` and `folder-editor` carry a `min-width` of 240px,
which is what gives a _dropdown_ a width at all. Projected inline between the rows they need no
such thing, so the minimum is handed down as `--editor-min-width` and the rail sets it to `0` —
a custom property, because a rule spelled in the rail's stylesheet is rewritten with the rail's
own `_ngcontent` and can never reach into a component's. The rows inside then **wrap rather than
squeeze**: under about 230px the submit drops below the field. The floor is 160, and so is the
default, the width being remembered like the window's own geometry — the default is a first
launch and nothing else.

⚠️ **A row's name is ellipsised by `.node-label`, a box of its own.** `text-overflow` is a
property of a block container and `.node-name` is a flex one, which ignores it outright: the
name was cut mid-letter. Nothing said so for as long as the rail could not be narrow enough to
cut one.

### Managing spaces from the switcher

The space switcher's dropdown has three mutually exclusive states: the menu, the creation
Expand Down
25 changes: 25 additions & 0 deletions e2e/pageobjects/overlays.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,31 @@ export const rail = {
);
await browser.keys(['ArrowRight', 'ArrowRight']);
},

/** The same edge the other way, far enough to reach the floor from any width. */
async narrow(): Promise<void> {
await browser.execute(
(selector: string) => (document.querySelector(selector) as HTMLElement | null)?.focus(),
testid('library-rail-edge'),
);
await browser.keys(Array.from({ length: 30 }, () => 'ArrowLeft'));
},

/**
* How far a control sticks out past the rail's right edge. The panels a ⋯ opens are
* projected inline between the rows, so one holding a minimum of its own would spill
* over the edge instead of following it.
*/
overflowOf: (selector: string): Promise<number> =>
browser.execute(
(railSelector: string, controlSelector: string) => {
const edge = document.querySelector(railSelector)?.getBoundingClientRect().right;
const control = document.querySelector(controlSelector)?.getBoundingClientRect().right;
return edge === undefined || control === undefined ? -1 : Math.round(Math.max(0, control - edge));
},
testid('library-rail'),
selector,
),
};

/**
Expand Down
23 changes: 23 additions & 0 deletions e2e/specs/05-spaces.e2e.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { browser, expect } from '@wdio/globals';

import { RAIL_WIDTH } from '@core/services/settings/app-settings.model';
import { canvas } from '../pageobjects/canvas.page.js';
import { rail, spaces } from '../pageobjects/overlays.page.js';
import { eventually, press, reloadCanvas, testid } from '../support/app.js';
Expand Down Expand Up @@ -207,6 +208,28 @@ describe('Spaces', () => {
expect(await rail.width()).toBe(widened);
});

/**
* ⚠️ The two panels a ⋯ opens used to hold the floor up with a `min-width` of their
* own, and releasing it is the whole of #212: at the floor the delete control has to
* still be inside the rail, which only a laid-out window can say.
*/
it('narrows to its floor with the space panel still inside it', async () => {
await rail.show();
await rail.narrow();
const narrowed = await eventually(
() => rail.width(),
(width) => width === RAIL_WIDTH.min,
'the rail never reached its floor',
);

await browser.$(testid('space-edit')).click();

expect(narrowed).toBe(RAIL_WIDTH.min);
// The rename submit and not the delete: the delete only exists while another space
// stands to take the notes in, and it is the same row either way.
expect(await rail.overflowOf(testid('space-rename-submit'))).toBe(0);
});

/** Hidden or shown is a preference, so it has to survive the page it was set on. */
it('gives the switchers back when it is put away, and is remembered', async () => {
await rail.hide();
Expand Down
9 changes: 5 additions & 4 deletions src/app/core/services/settings/app-settings.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,12 @@ export const DENSITIES = ['comfortable', 'compact'] as const;
export type Density = (typeof DENSITIES)[number];

/**
* ⚠️ Narrow enough to leave the canvas usable on a small window, wide enough for the
* panels a row opens — they are 240px of form, and a rail narrower than that would cut
* the delete button off.
* ⚠️ The floor is the tree's own, not the panels': `--editor-min-width` is what lets
* `space-editor` and `folder-editor` follow the rail rather than hold it open at their
* 240px. It opens at that floor — the width is remembered, so the default is a first
* launch and nothing else.
*/
export const RAIL_WIDTH = { min: 220, max: 520, default: 288 } as const;
export const RAIL_WIDTH = { min: 160, max: 520, default: 160 } as const;

export interface AppSettings {
readonly locale: LocaleChoice;
Expand Down
10 changes: 8 additions & 2 deletions src/app/notes/header/folder-editor/folder-editor.component.scss
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
@use 'mixins' as *;

// A dropdown needs the 240px to have a width at all; the rail hands down 0, and the
// panel follows the rail rather than holding it open.
.editor-panel {
display: flex;
flex-direction: column;
gap: 8px;
padding: 4px;
min-width: 240px;
min-width: var(--editor-min-width, 240px);
}

.editor-title {
Expand All @@ -20,6 +22,7 @@

.editor-colours {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 0 4px;
}
Expand Down Expand Up @@ -49,6 +52,7 @@

.editor-form {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 0 4px;
}
Expand All @@ -57,7 +61,9 @@
@include text-field;
@include surface(var(--bg-1), 6px);

flex: 1;
// A basis and not 0: it is what makes the row wrap once the button no longer fits
// beside the field, instead of squeezing the two of them.
flex: 1 1 112px;
min-width: 0;
padding: 6px 8px;
font-size: 13px;
Expand Down
10 changes: 8 additions & 2 deletions src/app/notes/header/space-editor/space-editor.component.scss
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
@use 'mixins' as *;

// A dropdown needs the 240px to have a width at all; the rail hands down 0, and the
// panel follows the rail rather than holding it open.
.editor-panel {
display: flex;
flex-direction: column;
gap: 8px;
padding: 4px;
min-width: 240px;
min-width: var(--editor-min-width, 240px);
}

.editor-title {
Expand Down Expand Up @@ -45,6 +47,7 @@

.editor-form {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 0 4px;
}
Expand All @@ -53,7 +56,9 @@
@include text-field;
@include surface(var(--bg-1), 6px);

flex: 1;
// A basis and not 0: it is what makes the row wrap once the button no longer fits
// beside the field, instead of squeezing the two of them.
flex: 1 1 112px;
min-width: 0;
padding: 6px 8px;
font-size: 13px;
Expand Down Expand Up @@ -89,6 +94,7 @@

.editor-delete-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 0 4px;
}
Expand Down
12 changes: 6 additions & 6 deletions src/app/notes/sidebar/library-tree/library-tree.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ <h2 id="library-rail-title" class="rail-title">{{ 'sidebar.library' | transloco
<ul class="tree">
<li>
<div class="node-row">
<span class="node-twisty-space" aria-hidden="true"></span>
<span class="node-twisty-space node-icon" aria-hidden="true"></span>
<button
type="button"
class="node-name"
Expand All @@ -41,8 +41,7 @@ <h2 id="library-rail-title" class="rail-title">{{ 'sidebar.library' | transloco
[attr.aria-current]="activeSpaceId() === null ? 'true' : null"
(click)="chooseSpace(null)"
>
<span class="node-icon" aria-hidden="true">✦</span>
{{ 'notes.allSpaces' | transloco }}
<span class="node-label">{{ 'notes.allSpaces' | transloco }}</span>
</button>
</div>
</li>
Expand Down Expand Up @@ -81,7 +80,7 @@ <h2 id="library-rail-title" class="rail-title">{{ 'sidebar.library' | transloco
<span class="node-pin" aria-hidden="true">📌</span>
<span class="visually-hidden">{{ 'notes.pinnedSpace' | transloco }}</span>
}
{{ space.name }}
<span class="node-label">{{ space.name }}</span>
</button>
<button
type="button"
Expand Down Expand Up @@ -126,7 +125,7 @@ <h2 id="library-rail-title" class="rail-title">{{ 'sidebar.library' | transloco
[class]="'folder-swatch is-' + folder.colour"
aria-hidden="true"
></i>
{{ folder.name }}
<span class="node-label">{{ folder.name }}</span>
</button>
<button
type="button"
Expand Down Expand Up @@ -178,7 +177,8 @@ <h2 id="library-rail-title" class="rail-title">{{ 'sidebar.library' | transloco
data-testid="folder-create-open"
(click)="startCreatingFolder()"
>
<span aria-hidden="true">+</span> {{ 'folders.newFolder' | transloco }}
<span aria-hidden="true">+</span>
<span class="node-label">{{ 'folders.newFolder' | transloco }}</span>
</button>
}
</li>
Expand Down
48 changes: 41 additions & 7 deletions src/app/notes/sidebar/library-tree/library-tree.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@

.rail-form {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 2px 4px;
}
Expand All @@ -83,7 +84,8 @@
@include text-field;
@include surface(var(--bg-0), 6px);

flex: 1;
// A basis and not 0: what makes the row wrap rather than squeeze both onto one line.
flex: 1 1 96px;
min-width: 0;
padding: 5px 7px;
font-size: 12.5px;
Expand Down Expand Up @@ -140,6 +142,15 @@
// A name longer than the rail is cut, never wrapped: a wrapped row breaks the levels.
white-space: nowrap;
overflow: hidden;
}

// ⚠️ The ellipsis belongs to a child and not to the row: `text-overflow` is a property of
// a block container, and a flex one simply ignores it — the name was cut mid-letter. It
// went unseen until the rail could be narrow enough to cut one (#212).
.node-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}

Expand All @@ -156,7 +167,7 @@
.node-icon,
.node-pin {
flex-shrink: 0;
font-size: 10px;
font-size: 12px;
color: var(--text-2);
}

Expand All @@ -178,7 +189,7 @@
flex-shrink: 0;
padding: 4px 3px;
border-radius: 5px;
font-size: 10px;
font-size: 12px;
line-height: 1;
color: var(--text-2);
cursor: pointer;
Expand All @@ -203,10 +214,23 @@
color: var(--text-0);
}

// The "all spaces" row has no folders to open, and its name still has to line up.
// ⚠️ The twisty and the ✦ that stands in for it are one column, sized once: it is what
// puts the name of "all spaces" on the same edge as every space under it. The ✦ used to
// sit inside the name, which pushed that one label right by its own width.
.node-twisty,
.node-twisty-space {
width: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 18px;
}

// ⚠️ ▾ is a *small* triangle by design: at the 12px the row's other marks use it draws a
// speck of ink, and nobody could tell what it was. 16px is where it reads as a triangle —
// measured against the rendered pixels, not guessed, and still lighter than the label.
.node-twisty {
font-size: 16px;
}

.folder-swatch {
Expand All @@ -219,14 +243,24 @@
background: var(--folder-hue);
}

// ⚠️ Not indented with its row: the panel is 240px of form, and the rail has exactly
// that much room once its own padding is taken out.
// Not indented with its row: the rail is narrow enough as it is. ⚠️ And its minimum is
// released here, so the panel follows the rail where a dropdown needs 240px to have a
// width at all — a custom property because a rule spelled here would be rewritten with
// the rail's own `_ngcontent` and never reach the panel (#207).
app-space-editor,
app-folder-editor {
--editor-min-width: 0;

display: block;
margin: 2px 0 6px;
}

// Back out of the folder indent, which the row above needs and a form does not: at the
// floor those 16px are the five colours on one line rather than four and one.
.folders app-folder-editor {
margin-left: -16px;
}

.node-name:focus-visible,
.node-menu:focus-visible,
.node-twisty:focus-visible,
Expand Down
24 changes: 21 additions & 3 deletions src/app/notes/sidebar/library-tree/library-tree.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { Folder } from '@core/model/folder.model';
import { Space } from '@core/model/space.model';
import { RAIL_WIDTH } from '@core/services/settings/app-settings.model';
import { provideTranslocoTesting } from '@testing/provide-transloco-testing';
import { LibraryTreeComponent } from './library-tree.component';

Expand Down Expand Up @@ -70,6 +71,23 @@ describe('LibraryTreeComponent', () => {
expect(names('[data-testid="folder-option"]')).toEqual(['Perf', 'Migrations', 'Async']);
});

/**
* ⚠️ The name sits in a box of its own rather than loose in the row: `text-overflow` is
* a block container's property and the row is a flex one, which ignores it — a name too
* long for the rail was cut mid-letter instead of ellipsised.
*/
it('gives every row name a box the ellipsis can apply to', () => {
expect(names('.node-label')).toEqual([
'Tous les espaces',
'SQL',
'Perf',
'Migrations',
'Nouveau dossier',
'Rust',
'Async',
]);
});

it('opens on the whole library rather than on what was expanded before', () => {
expect(each('[data-testid="space-twisty"]').map((t) => t.getAttribute('aria-expanded'))).toEqual([
'true',
Expand Down Expand Up @@ -165,15 +183,15 @@ describe('LibraryTreeComponent', () => {

edge.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
await fixture.whenStable();
expect(widths).toEqual([304]);
expect(widths).toEqual([RAIL_WIDTH.default + 16]);

fixture.componentRef.setInput('width', 220);
fixture.componentRef.setInput('width', RAIL_WIDTH.min);
await fixture.whenStable();
edge.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
await fixture.whenStable();

// Already at the minimum: clamped to the same number, so nothing is emitted.
expect(widths).toEqual([304]);
expect(widths).toEqual([RAIL_WIDTH.default + 16]);
});

it('offers every other space as a refuge when a space is being deleted', async () => {
Expand Down