diff --git a/docs/architecture.md b/docs/architecture.md index 07667bf..9a63532 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/e2e/pageobjects/overlays.page.ts b/e2e/pageobjects/overlays.page.ts index 5ef0268..f2e7d0d 100644 --- a/e2e/pageobjects/overlays.page.ts +++ b/e2e/pageobjects/overlays.page.ts @@ -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 { + 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 => + 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, + ), }; /** diff --git a/e2e/specs/05-spaces.e2e.ts b/e2e/specs/05-spaces.e2e.ts index 7328f17..a6b3248 100644 --- a/e2e/specs/05-spaces.e2e.ts +++ b/e2e/specs/05-spaces.e2e.ts @@ -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'; @@ -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(); diff --git a/src/app/core/services/settings/app-settings.model.ts b/src/app/core/services/settings/app-settings.model.ts index 79f2995..c62a3d4 100644 --- a/src/app/core/services/settings/app-settings.model.ts +++ b/src/app/core/services/settings/app-settings.model.ts @@ -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; diff --git a/src/app/notes/header/folder-editor/folder-editor.component.scss b/src/app/notes/header/folder-editor/folder-editor.component.scss index 17b1aa0..61b1ef8 100644 --- a/src/app/notes/header/folder-editor/folder-editor.component.scss +++ b/src/app/notes/header/folder-editor/folder-editor.component.scss @@ -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 { @@ -20,6 +22,7 @@ .editor-colours { display: flex; + flex-wrap: wrap; gap: 6px; padding: 0 4px; } @@ -49,6 +52,7 @@ .editor-form { display: flex; + flex-wrap: wrap; gap: 6px; padding: 0 4px; } @@ -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; diff --git a/src/app/notes/header/space-editor/space-editor.component.scss b/src/app/notes/header/space-editor/space-editor.component.scss index e8059f9..bb2bbb6 100644 --- a/src/app/notes/header/space-editor/space-editor.component.scss +++ b/src/app/notes/header/space-editor/space-editor.component.scss @@ -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 { @@ -45,6 +47,7 @@ .editor-form { display: flex; + flex-wrap: wrap; gap: 6px; padding: 0 4px; } @@ -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; @@ -89,6 +94,7 @@ .editor-delete-row { display: flex; + flex-wrap: wrap; gap: 6px; padding: 0 4px; } diff --git a/src/app/notes/sidebar/library-tree/library-tree.component.html b/src/app/notes/sidebar/library-tree/library-tree.component.html index 1761a76..dcd1063 100644 --- a/src/app/notes/sidebar/library-tree/library-tree.component.html +++ b/src/app/notes/sidebar/library-tree/library-tree.component.html @@ -32,7 +32,7 @@

{{ 'sidebar.library' | transloco
  • - +
  • @@ -81,7 +80,7 @@

    {{ 'sidebar.library' | transloco {{ 'notes.pinnedSpace' | transloco }} } - {{ space.name }} + {{ space.name }} } diff --git a/src/app/notes/sidebar/library-tree/library-tree.component.scss b/src/app/notes/sidebar/library-tree/library-tree.component.scss index 6f76f73..9c1be7c 100644 --- a/src/app/notes/sidebar/library-tree/library-tree.component.scss +++ b/src/app/notes/sidebar/library-tree/library-tree.component.scss @@ -75,6 +75,7 @@ .rail-form { display: flex; + flex-wrap: wrap; gap: 6px; padding: 2px 4px; } @@ -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; @@ -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; } @@ -156,7 +167,7 @@ .node-icon, .node-pin { flex-shrink: 0; - font-size: 10px; + font-size: 12px; color: var(--text-2); } @@ -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; @@ -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 { @@ -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, diff --git a/src/app/notes/sidebar/library-tree/library-tree.component.spec.ts b/src/app/notes/sidebar/library-tree/library-tree.component.spec.ts index 54f76d2..bc18702 100644 --- a/src/app/notes/sidebar/library-tree/library-tree.component.spec.ts +++ b/src/app/notes/sidebar/library-tree/library-tree.component.spec.ts @@ -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'; @@ -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', @@ -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 () => {