From 4b015585ae9921ea763864d94c343ed8aa7dd758 Mon Sep 17 00:00:00 2001 From: Simon Nodel Date: Sat, 5 Sep 2026 16:56:51 -0700 Subject: [PATCH 01/16] feat(tracker): make the folder tree collapsible and add a collection root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidebar tree rendered fully expanded and could never be closed, because `loaded` doubled as the expansion flag and the API indexes at depth Infinity — so every folder arrived loaded on the first payload. Expansion now has its own state and the tree starts collapsed. - Chevron toggles a folder; the row selects and opens but never closes, so drilling down stays one click while the chevron gives precise control. - ArrowRight opens the focused row, ArrowLeft closes it. The twisty is not a tab stop, per the ARIA tree pattern. - A root row stands for the collection itself: it lists every resource across every folder, owns the expand/collapse-all toggle, and accepts folder drops so a nested folder can be moved back out to the top level. - Filtering opens the branches holding matches and restores the previous expansion when cleared. - Expansion follows its folders: pruned on delete, rebased on move, and the ancestors of the selection are always revealed. - Drops the redundant per-click subtree refetch, which the first payload had already delivered in full. The API's empty-path branch returned early before honouring `includeNested`, so the root could not list resources recursively. Both branches now share one mapping path, which also restores `inheritedTags` on nested resources. An empty `currentFolderPath` used to mean "no folder selected", which the root row makes a real selection. The content area's "select a folder" card covered even search results, and the add-translation button stayed disabled; both gates are gone, along with the now-unused `browser.selectFolderFirst` resource. Co-Authored-By: Claude Opus 5 --- .../resources/resources.controller.spec.ts | 31 ++++ .../resources/resources.controller.ts | 49 +------ .../folder-tree/folder-node/folder-node.html | 61 +++++--- .../folder-tree/folder-node/folder-node.scss | 40 +++--- .../folder-node/folder-node.spec.ts | 129 ++++++++++++++--- .../folder-tree/folder-node/folder-node.ts | 74 +++++++--- .../sidebar/folder-tree/folder-tree.html | 132 ++++++++++++----- .../sidebar/folder-tree/folder-tree.scss | 134 +++++++++++++++++- .../sidebar/folder-tree/folder-tree.ts | 93 +++++++++++- .../app/browser/store/browser.store.spec.ts | 134 ++++++++++++++++++ .../src/app/browser/store/browser.store.ts | 2 + .../features/with-folder-tree.feature.ts | 99 ++++++++++++- .../browser/store/folder-tree.utils.spec.ts | 84 ++++++++++- .../app/browser/store/folder-tree.utils.ts | 82 +++++++++++ .../header/translation-main-header.html | 4 +- .../translations/list/translation-list.html | 11 +- .../list/translation-list.spec.ts | 10 +- .../src/app/shared/_toggle-animation.scss | 47 ++++++ .../browser/folderTree/resource_entries.json | 30 ++++ .../i18n/browser/folderTree/tracker_meta.json | 90 ++++++++++++ .../src/i18n/browser/resource_entries.json | 10 -- .../src/i18n/browser/tracker_meta.json | 30 ---- .../src/testing/transloco-testing.module.ts | 1 - 23 files changed, 1137 insertions(+), 240 deletions(-) diff --git a/apps/api/src/app/collections/resources/resources.controller.spec.ts b/apps/api/src/app/collections/resources/resources.controller.spec.ts index a1a3b168..953bb0fd 100644 --- a/apps/api/src/app/collections/resources/resources.controller.spec.ts +++ b/apps/api/src/app/collections/resources/resources.controller.spec.ts @@ -1056,6 +1056,37 @@ describe('ResourcesController', () => { expect(getCache).toHaveBeenCalledWith('test-collection'); }); + it('should list every resource recursively at the root when includeNested is set', async () => { + const getCacheStatus = cacheService.getCacheStatus as jest.Mock; + const getCache = cacheService.getCache as jest.Mock; + const extractSubtree = core.extractSubtree as jest.Mock; + const extractResourcesRecursively = core.extractResourcesRecursively as jest.Mock; + + getCacheStatus.mockReturnValue(CacheStatus.READY); + getCache.mockReturnValue(mockTreeNode); + extractResourcesRecursively.mockReturnValue([ + ...mockTreeNode.resources, + { + key: 'save', + source: 'Save', + translations: { es: 'Guardar' }, + metadata: { + en: { checksum: 'b' }, + es: { status: 'new', checksum: '', baseChecksum: 'b' }, + }, + }, + ]); + + const result = await resourcesController.getTree('test-collection', '', 'true'); + const tree = result as ResourceTreeDto; + + // The root is a folder like any other: it honours includeNested and never goes + // through extractSubtree, which has no path to extract. + expect(extractSubtree).not.toHaveBeenCalled(); + expect(extractResourcesRecursively).toHaveBeenCalledWith(mockTreeNode); + expect(tree.resources.map((r) => r.key)).toEqual(['title', 'save']); + }); + it('should extract and return subtree when cache is READY and path is provided', async () => { const getCacheStatus = cacheService.getCacheStatus as jest.Mock; const getCache = cacheService.getCache as jest.Mock; diff --git a/apps/api/src/app/collections/resources/resources.controller.ts b/apps/api/src/app/collections/resources/resources.controller.ts index 7eac8622..0cbce4df 100644 --- a/apps/api/src/app/collections/resources/resources.controller.ts +++ b/apps/api/src/app/collections/resources/resources.controller.ts @@ -42,7 +42,6 @@ import type { UpdateResourceResponseDto, ResourceTreeDto, ResourceSummaryDto, - TranslationStatus, SearchTranslationsDto, SearchResultsDto, CacheStatusDto, @@ -555,18 +554,10 @@ export class ResourcesController { throw new HttpException('Cache is marked as ready but tree is not available', HttpStatus.INTERNAL_SERVER_ERROR); } - // If no path specified, return full tree - if (!path || path.trim() === '') { - const treeDto = mapResourceTreeToDto(cachedTree, collection.tags); - if (responseObj) { - responseObj.status(HttpStatus.OK).json(treeDto); - return treeDto; - } - return treeDto; - } - - // Extract subtree at specified path - const subtree = extractSubtree(cachedTree, path); + // An empty path addresses the collection root, which the artificial root node in the + // Tracker sidebar selects. It is a folder like any other here, so it honours + // includeNested too and can list every resource in the collection. + const subtree = !path || path.trim() === '' ? cachedTree : extractSubtree(cachedTree, path); if (!subtree) { throw new NotFoundException(`Path "${path}" not found in collection tree`); @@ -575,35 +566,9 @@ export class ResourcesController { const treeDto = mapResourceTreeToDto(subtree, collection.tags); if (isIncludeNested) { - const nestedResources = extractResourcesRecursively(subtree); - treeDto.resources = nestedResources.map((res) => { - // Find base locale - let baseLocale: string | undefined; - for (const [locale, meta] of Object.entries(res.metadata)) { - if (meta.status === undefined && meta.baseChecksum === undefined) { - baseLocale = locale; - break; - } - } - - const translations: Record = { ...res.translations }; - if (baseLocale) { - translations[baseLocale] = res.source; - } - - const status: Record = {}; - for (const [locale, meta] of Object.entries(res.metadata)) { - status[locale] = meta.status; - } - - return { - key: res.key, - translations, - status, - comment: res.comment, - tags: res.tags, - }; - }); + treeDto.resources = extractResourcesRecursively(subtree).map((res) => + mapResourceEntryToSummary(res, collection.tags), + ); } if (responseObj) { diff --git a/apps/tracker/src/app/browser/sidebar/folder-tree/folder-node/folder-node.html b/apps/tracker/src/app/browser/sidebar/folder-tree/folder-node/folder-node.html index 0c491801..5bc6df80 100644 --- a/apps/tracker/src/app/browser/sidebar/folder-tree/folder-node/folder-node.html +++ b/apps/tracker/src/app/browser/sidebar/folder-tree/folder-node/folder-node.html @@ -20,15 +20,16 @@ class="folder-header" [class.selected]="isSelected()" [class.descendant-selected]="!isSelected() && showNestedResources() && isDescendantOfSelected()" - [class.unloaded]="!folder().loaded" (click)="onFolderClick()" [attr.role]="disabled() ? 'presentation' : 'treeitem'" [attr.tabindex]="disabled() ? -1 : 0" [attr.aria-label]="TOKENS.BROWSER.FOLDERNODE.FOLDERARIALABELX | transloco : { name: folder().name }" - [attr.aria-expanded]="folder().loaded ? 'true' : 'false'" + [attr.aria-expanded]="hasChildren() ? isExpanded() : null" [attr.aria-selected]="isSelected()" (keydown.enter)="onFolderClick()" (keydown.space)="onFolderClick()" + (keydown.arrowright)="onExpandKeydown($event)" + (keydown.arrowleft)="onCollapseKeydown($event)" (keydown.delete)="onDeleteKeydown($event)" cdkDrag [cdkDragData]="dragData()" @@ -36,6 +37,20 @@ (cdkDragStarted)="onDragStarted()" (cdkDragEnded)="onDragEnded()" > + @if (hasChildren()) { + + } @else { + + } {{ folderIcon() }} {{ folder().name }} @if (isNewlyCreated()) { @@ -57,9 +72,9 @@ - - @if (folder().loaded && folder().tree) { -
+ + @if (isExpanded() || shouldShowInlineInput()) { +
@if (shouldShowInlineInput()) { } - @for (child of folder().tree!.children; track child.fullPath) { - + @if (isExpanded()) { + @for (child of childFolders(); track child.fullPath) { + + } }
} diff --git a/apps/tracker/src/app/browser/sidebar/folder-tree/folder-node/folder-node.scss b/apps/tracker/src/app/browser/sidebar/folder-tree/folder-node/folder-node.scss index f0bda97a..776a571b 100644 --- a/apps/tracker/src/app/browser/sidebar/folder-tree/folder-node/folder-node.scss +++ b/apps/tracker/src/app/browser/sidebar/folder-tree/folder-node/folder-node.scss @@ -1,3 +1,5 @@ +@use '../../../../shared/toggle-animation' as *; + :host { display: block; } @@ -48,7 +50,8 @@ background-color: var(--color-primary-subtle); color: var(--color-primary); - .folder-icon { + .folder-icon, + .expand-toggle { color: var(--color-primary); } } @@ -59,27 +62,6 @@ } } - &.unloaded { - color: var(--color-text-tertiary); - - .folder-icon { - color: var(--color-text-tertiary); - } - - .folder-name { - color: var(--color-text-tertiary); - } - - &:hover { - color: var(--color-text-secondary); - - .folder-icon, - .folder-name { - color: var(--color-text-secondary); - } - } - } - &:focus { outline: 2px solid var(--color-primary); outline-offset: -2px; @@ -200,9 +182,19 @@ } } +.expand-toggle { + @extend %tree-expand-toggle; +} + +.expand-spacer { + @extend %tree-expand-spacer; +} + .folder-children { - margin-left: var(--spacing-4); - padding-left: var(--spacing-2); + // 12px + 16px = the twisty width plus the row gap, so each child's twisty sits directly + // under its parent's folder icon and the guide rule runs between the two. + margin-left: var(--spacing-3); + padding-left: var(--spacing-4); border-left: 1px solid var(--color-border); } diff --git a/apps/tracker/src/app/browser/sidebar/folder-tree/folder-node/folder-node.spec.ts b/apps/tracker/src/app/browser/sidebar/folder-tree/folder-node/folder-node.spec.ts index 0d4f20b0..ca35fa7a 100644 --- a/apps/tracker/src/app/browser/sidebar/folder-tree/folder-node/folder-node.spec.ts +++ b/apps/tracker/src/app/browser/sidebar/folder-tree/folder-node/folder-node.spec.ts @@ -10,6 +10,17 @@ describe('FolderNode', () => { let component: FolderNode; let fixture: ComponentFixture; + const folderWithChildren: FolderNodeDto = { + name: 'common', + fullPath: 'common', + loaded: true, + tree: { + path: 'common', + resources: [], + children: [{ name: 'buttons', fullPath: 'common.buttons', loaded: true }], + }, + }; + beforeEach(async () => { await TestBed.configureTestingModule({ imports: [FolderNode, getTranslocoTestingModule()], @@ -52,21 +63,109 @@ describe('FolderNode', () => { expect(emitSpy).toHaveBeenCalledWith(folder); }); - it('should emit loadFolder when unloaded folder is clicked', () => { + it('should request expansion when a collapsed folder with children is clicked', () => { + fixture.componentRef.setInput('folder', folderWithChildren); + + const emitSpy = vi.fn(); + component.expandRequested.subscribe(emitSpy); + + component.onFolderClick(); + + expect(emitSpy).toHaveBeenCalledWith('common'); + }); + + it('should not request expansion when the folder is already expanded', () => { + fixture.componentRef.setInput('folder', folderWithChildren); + fixture.componentRef.setInput('expandedPaths', new Set(['common'])); + + const emitSpy = vi.fn(); + component.expandRequested.subscribe(emitSpy); + + component.onFolderClick(); + + expect(emitSpy).not.toHaveBeenCalled(); + }); + + it('should not request expansion for a folder without children', () => { const folder: FolderNodeDto = { name: 'common', fullPath: 'common', - loaded: false, + loaded: true, + tree: { path: 'common', resources: [], children: [] }, }; fixture.componentRef.setInput('folder', folder); const emitSpy = vi.fn(); - component.loadFolder.subscribe(emitSpy); + component.expandRequested.subscribe(emitSpy); component.onFolderClick(); - expect(emitSpy).toHaveBeenCalledWith('common'); + expect(emitSpy).not.toHaveBeenCalled(); + }); + + it('should emit toggleExpanded from the chevron without selecting the folder', () => { + fixture.componentRef.setInput('folder', folderWithChildren); + + const toggleSpy = vi.fn(); + const clickSpy = vi.fn(); + component.toggleExpanded.subscribe(toggleSpy); + component.folderClick.subscribe(clickSpy); + + component.onToggleExpandedClick(new MouseEvent('click')); + + expect(toggleSpy).toHaveBeenCalledWith('common'); + expect(clickSpy).not.toHaveBeenCalled(); + }); + + it('should render a chevron only for folders with children', () => { + fixture.componentRef.setInput('folder', folderWithChildren); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.expand-toggle')).toBeTruthy(); + expect(fixture.nativeElement.querySelector('.expand-spacer')).toBeFalsy(); + + fixture.componentRef.setInput('folder', { + name: 'common', + fullPath: 'common', + loaded: true, + tree: { path: 'common', resources: [], children: [] }, + } satisfies FolderNodeDto); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.expand-toggle')).toBeFalsy(); + expect(fixture.nativeElement.querySelector('.expand-spacer')).toBeTruthy(); + }); + + it('should render child folders only while expanded', () => { + fixture.componentRef.setInput('folder', folderWithChildren); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.folder-children')).toBeFalsy(); + + fixture.componentRef.setInput('expandedPaths', new Set(['common'])); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.folder-children')).toBeTruthy(); + }); + + it('should expand on ArrowRight and collapse on ArrowLeft', () => { + fixture.componentRef.setInput('folder', folderWithChildren); + + const expandSpy = vi.fn(); + const toggleSpy = vi.fn(); + component.expandRequested.subscribe(expandSpy); + component.toggleExpanded.subscribe(toggleSpy); + + // Collapsed: ArrowRight opens, ArrowLeft does nothing. + component.onExpandKeydown(new KeyboardEvent('keydown')); + component.onCollapseKeydown(new KeyboardEvent('keydown')); + expect(expandSpy).toHaveBeenCalledWith('common'); + expect(toggleSpy).not.toHaveBeenCalled(); + + // Expanded: ArrowLeft closes, ArrowRight does nothing more. + expandSpy.mockClear(); + fixture.componentRef.setInput('expandedPaths', new Set(['common'])); + component.onExpandKeydown(new KeyboardEvent('keydown')); + component.onCollapseKeydown(new KeyboardEvent('keydown')); + expect(expandSpy).not.toHaveBeenCalled(); + expect(toggleSpy).toHaveBeenCalledWith('common'); }); it('should render folder name', () => { @@ -85,14 +184,8 @@ describe('FolderNode', () => { expect(folderNameElement?.textContent?.trim()).toBe('common'); }); - it('should show closed folder icon for unloaded folders', () => { - const folder: FolderNodeDto = { - name: 'common', - fullPath: 'common', - loaded: false, - }; - - fixture.componentRef.setInput('folder', folder); + it('should show closed folder icon while collapsed', () => { + fixture.componentRef.setInput('folder', folderWithChildren); fixture.detectChanges(); const compiled = fixture.nativeElement; @@ -101,15 +194,9 @@ describe('FolderNode', () => { expect(folderIcon?.textContent?.trim()).toBe('folder'); }); - it('should show open folder icon for loaded folders', () => { - const folder: FolderNodeDto = { - name: 'common', - fullPath: 'common', - loaded: true, - tree: { path: 'common', resources: [], children: [] }, - }; - - fixture.componentRef.setInput('folder', folder); + it('should show open folder icon while expanded', () => { + fixture.componentRef.setInput('folder', folderWithChildren); + fixture.componentRef.setInput('expandedPaths', new Set(['common'])); fixture.detectChanges(); const compiled = fixture.nativeElement; diff --git a/apps/tracker/src/app/browser/sidebar/folder-tree/folder-node/folder-node.ts b/apps/tracker/src/app/browser/sidebar/folder-tree/folder-node/folder-node.ts index 4a814b1b..ec46699b 100644 --- a/apps/tracker/src/app/browser/sidebar/folder-tree/folder-node/folder-node.ts +++ b/apps/tracker/src/app/browser/sidebar/folder-tree/folder-node/folder-node.ts @@ -15,9 +15,9 @@ import type { DragData } from '../../../types/drag-data'; * * Features: * - Displays folder icon and name - * - Shows "click to load" for unloaded folders - * - Recursively renders child folders - * - Emits events for folder clicks and load requests + * - Chevron toggles expansion independently of selection + * - Recursively renders child folders while expanded + * - Emits events for folder clicks and expansion requests */ @Component({ selector: 'app-folder-node', @@ -48,6 +48,9 @@ export class FolderNode { /** Whether nested resources mode is active */ showNestedResources = input(false); + /** Paths of every folder currently expanded, shared by the whole tree */ + expandedPaths = input>(new Set()); + /** Whether the tree is disabled (transient: search/operation in progress). Blocks navigation too. */ disabled = input(false); @@ -57,8 +60,11 @@ export class FolderNode { /** Emitted when a folder is clicked */ folderClick = output(); - /** Emitted when "load" is clicked for an unloaded folder */ - loadFolder = output(); + /** Emitted when the chevron is activated, flipping this folder open or shut */ + toggleExpanded = output(); + + /** Emitted when a folder should open without closing if already open */ + expandRequested = output(); /** Emitted when delete button is clicked or Delete key is pressed */ deleteFolder = output(); @@ -86,6 +92,15 @@ export class FolderNode { /** Mutation affordances (drag, drop, delete) are disabled when the tree is busy OR read-only. */ readonly mutationDisabled = computed(() => this.disabled() || this.readOnly()); + /** Child folders of this node, empty when the folder has none. */ + readonly childFolders = computed(() => this.folder().tree?.children ?? []); + + /** Only a folder with children gets a chevron; the rest get a spacer that keeps names aligned. */ + readonly hasChildren = computed(() => this.childFolders().length > 0); + + /** Whether this folder is currently expanded. */ + readonly isExpanded = computed(() => this.expandedPaths().has(this.folder().fullPath)); + /** Timer for auto-expand on hover */ #expandTimer: ReturnType | null = null; @@ -125,27 +140,48 @@ export class FolderNode { if (this.showNestedResources() && this.isDescendantOfSelected()) { return 'folder_check'; } - return this.folder().loaded ? 'folder_open' : 'folder'; + return this.isExpanded() ? 'folder_open' : 'folder'; }); /** - * Handles folder click. - * Single click both selects the folder AND loads it (if not already loaded). + * Handles folder click: selects the folder and, if it was shut, opens it so a single + * click still drills down. Clicking an already-open folder leaves it open — collapsing + * is the chevron's job. */ onFolderClick(): void { - if (!this.disabled()) { - const currentFolder = this.folder(); + if (this.disabled()) return; - // Always emit folderClick to select the folder - this.folderClick.emit(currentFolder); + this.folderClick.emit(this.folder()); - // If folder is not loaded, also emit loadFolder to trigger loading - if (!currentFolder.loaded) { - this.loadFolder.emit(currentFolder.fullPath); - } + if (this.hasChildren() && !this.isExpanded()) { + this.expandRequested.emit(this.folder().fullPath); } } + /** + * Handles chevron activation. Stops propagation so opening a folder does not also + * select it — the chevron is the one control that touches expansion alone. + */ + onToggleExpandedClick(event: Event): void { + event.stopPropagation(); + if (this.disabled()) return; + this.toggleExpanded.emit(this.folder().fullPath); + } + + /** ArrowRight opens a shut folder; on an open one it does nothing. */ + onExpandKeydown(event: Event): void { + if (this.disabled() || !this.hasChildren() || this.isExpanded()) return; + event.preventDefault(); + this.expandRequested.emit(this.folder().fullPath); + } + + /** ArrowLeft shuts an open folder; on a shut one it does nothing. */ + onCollapseKeydown(event: Event): void { + if (this.disabled() || !this.isExpanded()) return; + event.preventDefault(); + this.toggleExpanded.emit(this.folder().fullPath); + } + /** * Checks if this folder is currently selected. */ @@ -289,10 +325,10 @@ export class FolderNode { onDropListEntered(): void { this.isHoveredDuringDrag.set(true); - // Only auto-expand if folder is not already loaded and is a valid drop target - if (!this.folder().loaded && this.isValidDropTarget()) { + // Hovering a shut folder mid-drag opens it, so a drop can reach nested targets + if (this.hasChildren() && !this.isExpanded() && this.isValidDropTarget()) { this.#expandTimer = setTimeout(() => { - this.loadFolder.emit(this.folder().fullPath); + this.expandRequested.emit(this.folder().fullPath); }, 500); } } diff --git a/apps/tracker/src/app/browser/sidebar/folder-tree/folder-tree.html b/apps/tracker/src/app/browser/sidebar/folder-tree/folder-tree.html index a8dc1ee9..86f21f61 100644 --- a/apps/tracker/src/app/browser/sidebar/folder-tree/folder-tree.html +++ b/apps/tracker/src/app/browser/sidebar/folder-tree/folder-tree.html @@ -49,50 +49,106 @@
} - - @if (!store.isFolderTreeLoading() && !store.error() && store.filteredFolders().length === 0 && !isAddingFolder()) { -
- folder_off -

- {{ (store.folderTreeFilter() ? TOKENS.BROWSER.NOFOLDERSMATCHSEARCH : TOKENS.BROWSER.NOFOLDERSFOUND) | transloco }} -

-
- } - - - @if (isAddingFolder() && addFolderParentPath() === null) { - - } - - - @if (store.filteredFolders().length > 0) { + @if (!store.error() && !(store.isFolderTreeLoading() && store.rootFolders().length === 0)) {
- @for (folder of store.filteredFolders(); track folder.fullPath) { - + +
+
+ + {{ store.isReadOnly() ? 'lock' : 'folder_special' }} + {{ collectionName() }} + +
+
+ + @if (store.isRootExpanded()) { +
+ + @if (isAddingFolder() && addFolderParentPath() === null) { + + } + + @if (store.filteredFolders().length === 0 && !isAddingFolder()) { +
+ folder_off +

+ {{ (store.folderTreeFilter() ? TOKENS.BROWSER.NOFOLDERSMATCHSEARCH : TOKENS.BROWSER.NOFOLDERSFOUND) | transloco }} +

+
+ } + + @for (folder of store.filteredFolders(); track folder.fullPath) { + + } +
}
} diff --git a/apps/tracker/src/app/browser/sidebar/folder-tree/folder-tree.scss b/apps/tracker/src/app/browser/sidebar/folder-tree/folder-tree.scss index a61fc97c..1ea0ee94 100644 --- a/apps/tracker/src/app/browser/sidebar/folder-tree/folder-tree.scss +++ b/apps/tracker/src/app/browser/sidebar/folder-tree/folder-tree.scss @@ -96,13 +96,12 @@ align-items: center; justify-content: center; gap: var(--spacing-3); - padding: var(--spacing-8); - flex: 1; + padding: var(--spacing-6) var(--spacing-4); .empty-icon { - font-size: 48px; - width: 48px; - height: 48px; + font-size: 32px; + width: 32px; + height: 32px; color: var(--color-text-tertiary); } @@ -137,3 +136,128 @@ } } } + +// The collection root. It stands for the whole collection rather than a folder in it, so it +// stays pinned while a deep tree scrolls beneath, carries a heavier label, and owns the +// expand/collapse-all control whose scope is exactly the tree below it. +.root-drop-zone { + position: sticky; + top: calc(var(--spacing-2) * -1); + z-index: 1; + margin: calc(var(--spacing-2) * -1) calc(var(--spacing-2) * -1) 0; + padding: var(--spacing-2) var(--spacing-2) var(--spacing-1); + background: var(--color-background-subtle); + border-bottom: 1px solid var(--color-border); + border-radius: 0; + transition: background-color var(--transition-normal), border-color var(--transition-normal); + + &.drop-target-valid { + background-color: color-mix(in srgb, var(--color-success) 12%, var(--color-background-subtle)); + border-bottom-color: var(--color-success); + } + + &.drop-target-invalid { + background-color: color-mix(in srgb, var(--color-error) 12%, var(--color-background-subtle)); + border-bottom-color: var(--color-error); + } +} + +.root-row { + display: flex; + align-items: center; + gap: var(--spacing-2); + padding: var(--spacing-2) var(--spacing-3); + cursor: pointer; + border-radius: var(--border-radius-sm); + transition: background-color var(--transition-fast), color var(--transition-fast); + user-select: none; + + &:hover:not(.selected) { + background-color: var(--color-background-muted); + } + + &.selected { + background-color: var(--color-primary-subtle); + color: var(--color-primary); + + .root-icon, + .expand-toggle, + .expand-all-toggle { + color: var(--color-primary); + } + } + + &:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: -2px; + } + + .root-icon { + font-size: 20px; + width: 20px; + height: 20px; + color: var(--color-text-secondary); + flex-shrink: 0; + } + + .root-name { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.expand-toggle { + @extend %tree-expand-toggle; +} + +// Always visible, unlike the per-folder delete button: this is the tree's only +// whole-tree control and hiding it behind hover would make it unfindable. +.expand-all-toggle { + align-items: center; + background: none; + border: none; + border-radius: var(--border-radius-sm); + color: var(--color-text-secondary); + cursor: pointer; + display: flex; + flex-shrink: 0; + height: 24px; + justify-content: center; + padding: 0; + transition: background-color var(--transition-fast), color var(--transition-fast); + width: 24px; + + mat-icon { + font-size: 18px; + height: 18px; + line-height: 18px; + width: 18px; + } + + &:hover:not(:disabled) { + background-color: var(--color-background-muted); + color: var(--color-text-primary); + } + + &:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 1px; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.4; + } +} + +// A hair of indent under the root — enough to read as nested without charging every folder +// a full indent level, which would cost real width in a narrow sidebar. +.root-children { + padding-top: var(--spacing-1); + padding-left: var(--spacing-1); +} diff --git a/apps/tracker/src/app/browser/sidebar/folder-tree/folder-tree.ts b/apps/tracker/src/app/browser/sidebar/folder-tree/folder-tree.ts index 70e16f0d..64e4d43f 100644 --- a/apps/tracker/src/app/browser/sidebar/folder-tree/folder-tree.ts +++ b/apps/tracker/src/app/browser/sidebar/folder-tree/folder-tree.ts @@ -27,6 +27,7 @@ import { TRACKER_TOKENS } from '../../../../i18n-types/tracker-resources'; import { TranslocoPipe, TranslocoService } from '@jsverse/transloco'; import { SearchInput } from '../../../shared/components/search-input'; import { MatIconModule } from '@angular/material/icon'; +import { CdkDropList, type CdkDrag, type CdkDragDrop } from '@angular/cdk/drag-drop'; import type { DragData } from '../../types/drag-data'; import { extractFolderNameFromPath } from '../../utils/folder-path.utils'; @@ -39,8 +40,10 @@ const SCROLL_INTERVAL_MS = 50; * FolderTree component for hierarchical folder navigation. * * Features: - * - Search/filter folders - * - Progressive loading (click to load) + * - Search/filter folders, which opens the branches holding matches + * - Collapsible folders, plus expand/collapse-all on the root row + * - An artificial root row standing for the collection itself, so the content area can + * list every resource across every folder * - Folder selection * - Toggle between current folder and nested resources view * - Disabled state during search @@ -59,6 +62,7 @@ const SCROLL_INTERVAL_MS = 50; InlineFolderInput, TranslocoPipe, SearchInput, + CdkDropList, ], templateUrl: './folder-tree.html', styleUrl: './folder-tree.scss', @@ -90,6 +94,20 @@ export class FolderTree { /** Signal exposing nested resources visibility from store */ readonly showNestedResources = this.store.showNestedResources; + /** Whether the root row — the collection itself — is the current selection */ + readonly isRootSelected = computed(() => this.store.currentFolderPath() === ''); + + /** True while a drag hovers the root row, for drop-target styling */ + readonly isRootHoveredDuringDrag = signal(false); + + /** Root accepts folders only: a resource is moved between folders, never onto the collection. */ + readonly isValidRootDropTarget = computed(() => { + const dragData = this.activeDragData(); + if (!dragData || dragData.type !== 'folder' || !dragData.path) return false; + // A folder already sitting at root has nowhere to go. + return dragData.path.includes('.'); + }); + /** Drives the icon flip animation — true for one animation frame when toggled */ readonly isNestedToggleFlipping = signal(false); @@ -153,13 +171,74 @@ export class FolderTree { this.folderSelected.emit(folder.fullPath); } + /** Selects the collection root, whose resource list spans every folder. */ + onRootClick(): void { + if (this.store.isDisabled()) return; + this.store.selectFolder(''); + this.folderSelected.emit(''); + } + + /** Flips one folder open or shut from its chevron. */ + onToggleExpanded(folderPath: string): void { + this.store.toggleFolderExpanded(folderPath); + } + + /** Opens a folder that may already be open — from selection, ArrowRight, or a drag hover. */ + onExpandRequested(folderPath: string): void { + this.store.expandFolder(folderPath); + } + + /** Flips the root row itself, hiding or revealing the whole tree. */ + onToggleRootExpanded(event: Event): void { + event.stopPropagation(); + if (this.store.isDisabled()) return; + this.store.toggleRootExpanded(); + } + + /** ArrowRight on the root row opens it. */ + onRootExpandKeydown(event: Event): void { + if (this.store.isDisabled() || this.store.isRootExpanded()) return; + event.preventDefault(); + this.store.toggleRootExpanded(); + } + + /** ArrowLeft on the root row shuts it. */ + onRootCollapseKeydown(event: Event): void { + if (this.store.isDisabled() || !this.store.isRootExpanded()) return; + event.preventDefault(); + this.store.toggleRootExpanded(); + } + /** - * Handles load folder requests from child nodes. - * Loads the folder's children for expansion. - * Note: Selection is handled separately by onFolderClick. + * Opens or shuts every folder in view. Scoped to the filtered subtree while a filter is + * active, and the root row stays open either way so the top level remains reachable. */ - onLoadFolder(folderPath: string): void { - this.store.loadFolderChildren(folderPath); + onToggleExpandAll(event: Event): void { + event.stopPropagation(); + if (this.store.isDisabled()) return; + + if (this.store.areAllFoldersExpanded()) this.store.collapseAllFolders(); + else this.store.expandAllFolders(); + } + + /** Predicate for the root drop list: folders only, and only ones not already at root. */ + canDropOnRoot = (drag: CdkDrag): boolean => { + if (this.store.isReadOnly()) return false; + + const dragData = drag.data; + if (!dragData || dragData.type !== 'folder' || !dragData.path) return false; + + return dragData.path.includes('.'); + }; + + /** Moves a folder dropped on the root row out to the top level. */ + onRootDrop(event: CdkDragDrop): void { + this.isRootHoveredDuringDrag.set(false); + + const dragData = event.item.data as DragData; + if (dragData.type !== 'folder' || !dragData.path) return; + + this.store.moveFolder({ sourceFolderPath: dragData.path, destinationFolderPath: '' }); } /** diff --git a/apps/tracker/src/app/browser/store/browser.store.spec.ts b/apps/tracker/src/app/browser/store/browser.store.spec.ts index 3a4ba98f..e4de2c79 100644 --- a/apps/tracker/src/app/browser/store/browser.store.spec.ts +++ b/apps/tracker/src/app/browser/store/browser.store.spec.ts @@ -46,6 +46,33 @@ describe('BrowserStore', () => { children: [{ name: 'buttons', fullPath: 'common.buttons', loaded: false }], }; + const mockTreeWithNesting: ResourceTreeDto = { + path: '', + resources: [], + children: [ + { + name: 'common', + fullPath: 'common', + loaded: true, + tree: { + path: 'common', + resources: [], + children: [{ name: 'buttons', fullPath: 'common.buttons', loaded: true }], + }, + }, + { + name: 'errors', + fullPath: 'errors', + loaded: true, + tree: { + path: 'errors', + resources: [], + children: [{ name: 'http', fullPath: 'errors.http', loaded: true }], + }, + }, + ], + }; + const mockCacheReady: CacheStatusDto = { status: 'ready', stats: { @@ -391,6 +418,113 @@ describe('BrowserStore', () => { expect(store.expandedFolders().has('common')).toBe(true); expect(store.expandedFolders().has('errors')).toBe(true); }); + + it('should open a folder without closing it when already open', () => { + store.expandFolder('common'); + store.expandFolder('common'); + + expect(store.expandedFolders().has('common')).toBe(true); + }); + + it('should reveal the selected folder by expanding its ancestors only', async () => { + vi.spyOn(apiService, 'getCacheStatus').mockReturnValue(of(mockCacheReady)); + vi.spyOn(apiService, 'getResourceTree').mockReturnValue(of(mockTreeCommon)); + + store.setSelectedCollection({ collectionName: 'app-translations', locales: [] }); + await waitForSignals(); + + store.selectFolder('common.buttons.primary'); + await waitForSignals(); + + // Ancestors are revealed; the selection itself is not opened, and the stored set is untouched. + expect(store.visibleExpandedFolders().has('common')).toBe(true); + expect(store.visibleExpandedFolders().has('common.buttons')).toBe(true); + expect(store.visibleExpandedFolders().has('common.buttons.primary')).toBe(false); + expect(store.expandedFolders().size).toBe(0); + }); + + it('should start with the root expanded', () => { + expect(store.isRootExpanded()).toBe(true); + }); + + it('should toggle the root row', () => { + store.toggleRootExpanded(); + expect(store.isRootExpanded()).toBe(false); + + store.toggleRootExpanded(); + expect(store.isRootExpanded()).toBe(true); + }); + + it('should expand every folder in view and leave the root open', async () => { + vi.spyOn(apiService, 'getCacheStatus').mockReturnValue(of(mockCacheReady)); + vi.spyOn(apiService, 'getResourceTree').mockReturnValue(of(mockTreeWithNesting)); + + store.setSelectedCollection({ collectionName: 'app-translations', locales: [] }); + await waitForSignals(); + + expect(store.areAllFoldersExpanded()).toBe(false); + + store.expandAllFolders(); + + expect(store.expandedFolders().has('common')).toBe(true); + expect(store.expandedFolders().has('errors')).toBe(true); + // A folder with no child folders has nothing to open, so it never enters the set. + expect(store.expandedFolders().has('common.buttons')).toBe(false); + expect(store.areAllFoldersExpanded()).toBe(true); + expect(store.isRootExpanded()).toBe(true); + }); + + it('should collapse every folder but keep the root open', async () => { + vi.spyOn(apiService, 'getCacheStatus').mockReturnValue(of(mockCacheReady)); + vi.spyOn(apiService, 'getResourceTree').mockReturnValue(of(mockTreeWithNesting)); + + store.setSelectedCollection({ collectionName: 'app-translations', locales: [] }); + await waitForSignals(); + + store.expandAllFolders(); + store.collapseAllFolders(); + + expect(store.expandedFolders().size).toBe(0); + expect(store.isRootExpanded()).toBe(true); + }); + + it('should open branches holding matches while filtering and restore expansion on clear', async () => { + vi.spyOn(apiService, 'getCacheStatus').mockReturnValue(of(mockCacheReady)); + vi.spyOn(apiService, 'getResourceTree').mockReturnValue(of(mockTreeWithNesting)); + + store.setSelectedCollection({ collectionName: 'app-translations', locales: [] }); + await waitForSignals(); + + store.toggleFolderExpanded('errors'); + + store.setFolderTreeFilter('buttons'); + + // The branch leading to the match is open; the user's unrelated branch is set aside. + expect(store.expandedFolders().has('common')).toBe(true); + expect(store.expandedFolders().has('errors')).toBe(false); + + store.setFolderTreeFilter(''); + + expect(store.expandedFolders().has('errors')).toBe(true); + expect(store.expandedFolders().has('common')).toBe(false); + }); + + it('should prune expanded paths under a deleted folder', async () => { + vi.spyOn(apiService, 'getCacheStatus').mockReturnValue(of(mockCacheReady)); + vi.spyOn(apiService, 'getResourceTree').mockReturnValue(of(mockTreeWithNesting)); + vi.spyOn(apiService, 'deleteFolder').mockReturnValue(of({ deleted: true, path: 'common' })); + + store.setSelectedCollection({ collectionName: 'app-translations', locales: [] }); + await waitForSignals(); + + store.expandAllFolders(); + store.deleteFolder('common'); + await waitForSignals(); + + expect(store.expandedFolders().has('common')).toBe(false); + expect(store.expandedFolders().has('common.buttons')).toBe(false); + expect(store.expandedFolders().has('errors')).toBe(true); + }); }); describe('Computed Signals', () => { diff --git a/apps/tracker/src/app/browser/store/browser.store.ts b/apps/tracker/src/app/browser/store/browser.store.ts index f13c96a8..37cdb5fc 100644 --- a/apps/tracker/src/app/browser/store/browser.store.ts +++ b/apps/tracker/src/app/browser/store/browser.store.ts @@ -151,6 +151,8 @@ export const BrowserStore = signalStore( collectionStats: null, currentFolderPath: '', expandedFolders: new Set(), + preFilterExpandedFolders: null, + isRootExpanded: true, showNestedResources: loaded?.showNestedResources ?? true, compactLocale: loaded?.compactLocale ?? null, compactLocaleManuallyChanged: loaded?.compactLocaleManuallyChanged ?? false, diff --git a/apps/tracker/src/app/browser/store/features/with-folder-tree.feature.ts b/apps/tracker/src/app/browser/store/features/with-folder-tree.feature.ts index 3ff1619e..aaa20573 100644 --- a/apps/tracker/src/app/browser/store/features/with-folder-tree.feature.ts +++ b/apps/tracker/src/app/browser/store/features/with-folder-tree.feature.ts @@ -7,7 +7,16 @@ import { TranslocoService } from '@jsverse/transloco'; import { NotificationService } from '../../../shared/notification'; import { BrowserApiService } from '../../services/browser-api.service'; import { extractFolderNameFromPath, extractParentFolderPath } from '../../utils/folder-path.utils'; -import { insertFolderIntoTree, removeFolderFromTree, findFolderInTree, rebaseFolderPaths } from '../folder-tree.utils'; +import { + insertFolderIntoTree, + removeFolderFromTree, + findFolderInTree, + rebaseFolderPaths, + collectExpandablePaths, + collectAncestorPaths, + prunePathsUnder, + rebaseExpandedPaths, +} from '../folder-tree.utils'; import { toErrorMessage } from '../async-error.utils'; import { TRACKER_TOKENS } from '../../../../i18n-types/tracker-resources'; import type { FolderNodeDto, CreateFolderResponseDto, ResourceSummaryDto } from '@simoncodes-ca/data-transfer'; @@ -16,6 +25,9 @@ import type { Observable } from 'rxjs'; interface FolderTreeState { rootFolders: FolderNodeDto[]; expandedFolders: Set; + /** Expansion as it stood before a filter took over; restored when the filter clears. */ + preFilterExpandedFolders: Set | null; + isRootExpanded: boolean; folderTreeFilter: string; isFolderTreeLoading: boolean; isAddingFolder: boolean; @@ -28,6 +40,8 @@ interface FolderTreeState { const initialFolderTreeState: FolderTreeState = { rootFolders: [], expandedFolders: new Set(), + preFilterExpandedFolders: null, + isRootExpanded: true, folderTreeFilter: '', isFolderTreeLoading: false, isAddingFolder: false, @@ -91,6 +105,28 @@ export function withFolderTreeFeature<_>() { }), ), + // Second computed block: derives from filteredFolders, declared above. + withComputed(({ filteredFolders, expandedFolders, currentFolderPath }) => ({ + /** + * Expansion as the tree renders it: what the user opened, plus the ancestors of the + * selected folder, so the selection can never hide inside a closed parent after a + * move, a delete, or a reload. + */ + visibleExpandedFolders: computed(() => { + const visible = new Set(expandedFolders()); + for (const ancestor of collectAncestorPaths(currentFolderPath())) visible.add(ancestor); + return visible; + }), + + /** Drives the expand/collapse-all toggle, scoped to the filtered subtree when filtering. */ + areAllFoldersExpanded: computed(() => { + const expandable = collectExpandablePaths(filteredFolders()); + if (expandable.length === 0) return false; + const expanded = expandedFolders(); + return expandable.every((path) => expanded.has(path)); + }), + })), + // First methods block: core loading operations (loadRootFolders, loadFolderChildren, etc.) // Kept separate so the second block can reference these methods via the store ref. withMethods((store) => { @@ -106,8 +142,30 @@ export function withFolderTreeFeature<_>() { } return { + /** + * Applies the folder filter and takes expansion with it: a filter that hid its own + * matches inside collapsed parents would be useless, so matching branches open + * automatically. The pre-filter expansion is stashed and restored on clear, which + * keeps chevrons working normally while a filter is active. + */ setFolderTreeFilter(filter: string): void { - patchState(store, { folderTreeFilter: filter }); + const wasFiltering = store.folderTreeFilter().trim().length > 0; + const isFiltering = filter.trim().length > 0; + + if (isFiltering) { + patchState(store, { + folderTreeFilter: filter, + preFilterExpandedFolders: wasFiltering ? store.preFilterExpandedFolders() : store.expandedFolders(), + }); + patchState(store, { expandedFolders: new Set(collectExpandablePaths(store.filteredFolders())) }); + return; + } + + patchState(store, { + folderTreeFilter: filter, + expandedFolders: store.preFilterExpandedFolders() ?? store.expandedFolders(), + preFilterExpandedFolders: null, + }); }, toggleFolderExpanded(path: string): void { @@ -117,6 +175,31 @@ export function withFolderTreeFeature<_>() { patchState(store, { expandedFolders: newExpanded }); }, + /** Opens a folder without closing it if it is already open — used when selecting a row. */ + expandFolder(path: string): void { + if (!path || store.expandedFolders().has(path)) return; + patchState(store, { expandedFolders: new Set(store.expandedFolders()).add(path) }); + }, + + toggleRootExpanded(): void { + patchState(store, { isRootExpanded: !store.isRootExpanded() }); + }, + + /** + * Opens every folder in view. Scoped to the filtered subtree when a filter is active, + * so it never expands branches the user has just filtered away. + */ + expandAllFolders(): void { + const expanded = new Set(store.expandedFolders()); + for (const path of collectExpandablePaths(store.filteredFolders())) expanded.add(path); + patchState(store, { expandedFolders: expanded, isRootExpanded: true }); + }, + + /** Closes every folder but leaves the root open, so the top level stays reachable. */ + collapseAllFolders(): void { + patchState(store, { expandedFolders: new Set(), isRootExpanded: true }); + }, + startAddingFolder(parentPath: string | null): void { patchState(store, { isAddingFolder: true, addFolderParentPath: parentPath }); }, @@ -312,6 +395,7 @@ export function withFolderTreeFeature<_>() { isDeletingFolder: false, deletingFolderPath: null, rootFolders: updatedFolders, + expandedFolders: prunePathsUnder(store.expandedFolders(), folderPath), error: null, }); @@ -409,11 +493,18 @@ export function withFolderTreeFeature<_>() { ? `${destinationFolderPath}.${folderName}` : folderName; + // Carry the moved subtree's own expansion across, then open the + // destination so the folder is visible where it landed. + const expanded = rebaseExpandedPaths( + store.expandedFolders(), + sourceFolderPath, + destinationFolderPath, + ); if (destinationFolderPath) { - const expanded = new Set(store.expandedFolders()); expanded.add(destinationFolderPath); - patchState(store, { expandedFolders: expanded }); + for (const ancestor of collectAncestorPaths(destinationFolderPath)) expanded.add(ancestor); } + patchState(store, { expandedFolders: expanded }); const includeNested = store.showNestedResources(); patchState(store, { currentFolderPath: movedFolderPath }); diff --git a/apps/tracker/src/app/browser/store/folder-tree.utils.spec.ts b/apps/tracker/src/app/browser/store/folder-tree.utils.spec.ts index f71b413c..80a14a01 100644 --- a/apps/tracker/src/app/browser/store/folder-tree.utils.spec.ts +++ b/apps/tracker/src/app/browser/store/folder-tree.utils.spec.ts @@ -1,5 +1,14 @@ import { describe, it, expect } from 'vitest'; -import { insertFolderIntoTree, removeFolderFromTree, findFolderInTree, rebaseFolderPaths } from './folder-tree.utils'; +import { + insertFolderIntoTree, + removeFolderFromTree, + findFolderInTree, + rebaseFolderPaths, + collectExpandablePaths, + collectAncestorPaths, + prunePathsUnder, + rebaseExpandedPaths, +} from './folder-tree.utils'; import type { FolderNodeDto } from '@simoncodes-ca/data-transfer'; const leaf = (name: string, fullPath: string): FolderNodeDto => ({ @@ -225,3 +234,76 @@ describe('rebaseFolderPaths', () => { expect(bTree.children[0].fullPath).toBe('root.a.b.deep'); }); }); + +describe('collectExpandablePaths', () => { + it('returns every folder that has children, depth first', () => { + const tree = [ + withChildren(leaf('apps', 'apps'), [ + withChildren(leaf('common', 'apps.common'), [leaf('buttons', 'apps.common.buttons')]), + ]), + leaf('errors', 'errors'), + ]; + + expect(collectExpandablePaths(tree)).toEqual(['apps', 'apps.common']); + }); + + it('skips folders whose children array is empty', () => { + expect(collectExpandablePaths([withChildren(leaf('apps', 'apps'), [])])).toEqual([]); + }); + + it('returns nothing for an empty tree', () => { + expect(collectExpandablePaths([])).toEqual([]); + }); +}); + +describe('collectAncestorPaths', () => { + it('returns strict ancestors outermost first', () => { + expect(collectAncestorPaths('apps.common.buttons')).toEqual(['apps', 'apps.common']); + }); + + it('excludes the path itself, so revealing a folder does not open it', () => { + expect(collectAncestorPaths('apps')).toEqual([]); + }); + + it('returns nothing for the root path', () => { + expect(collectAncestorPaths('')).toEqual([]); + }); +}); + +describe('prunePathsUnder', () => { + it('drops the removed path and everything beneath it', () => { + const paths = new Set(['apps', 'apps.common', 'apps.common.buttons', 'errors']); + + expect([...prunePathsUnder(paths, 'apps.common')]).toEqual(['apps', 'errors']); + }); + + it('does not drop a sibling that merely shares a prefix', () => { + const paths = new Set(['apps', 'appsettings']); + + expect([...prunePathsUnder(paths, 'apps')]).toEqual(['appsettings']); + }); +}); + +describe('rebaseExpandedPaths', () => { + it('moves the subtree paths under the new parent', () => { + const paths = new Set(['apps', 'apps.common', 'apps.common.buttons', 'errors']); + + const result = rebaseExpandedPaths(paths, 'apps.common', 'errors'); + + expect([...result].sort()).toEqual(['apps', 'errors', 'errors.common', 'errors.common.buttons']); + }); + + it('moves a subtree out to the root', () => { + const paths = new Set(['apps', 'apps.common', 'apps.common.buttons']); + + const result = rebaseExpandedPaths(paths, 'apps.common', ''); + + expect([...result].sort()).toEqual(['apps', 'common', 'common.buttons']); + }); + + it('leaves the set alone when the path does not change', () => { + const paths = new Set(['apps', 'apps.common']); + + expect([...rebaseExpandedPaths(paths, 'apps', '')]).toEqual(['apps', 'apps.common']); + }); +}); diff --git a/apps/tracker/src/app/browser/store/folder-tree.utils.ts b/apps/tracker/src/app/browser/store/folder-tree.utils.ts index 53381605..e925b943 100644 --- a/apps/tracker/src/app/browser/store/folder-tree.utils.ts +++ b/apps/tracker/src/app/browser/store/folder-tree.utils.ts @@ -99,3 +99,85 @@ export function rebaseFolderPaths(folder: FolderNodeDto, newParentPath: string): : undefined, }; } + +/** + * Collects the full paths of every folder in the tree that has at least one child, + * i.e. every folder that expand-all has something to open. + */ +export function collectExpandablePaths(folders: FolderNodeDto[]): string[] { + const paths: string[] = []; + + const walk = (nodes: FolderNodeDto[]): void => { + for (const node of nodes) { + const children = node.tree?.children ?? []; + if (children.length > 0) { + paths.push(node.fullPath); + walk(children); + } + } + }; + + walk(folders); + return paths; +} + +/** + * Returns the strict ancestor paths of a dot-delimited folder path, outermost first. + * `apps.common.buttons` yields `['apps', 'apps.common']`. The path itself is excluded: + * revealing a folder does not open it. + */ +export function collectAncestorPaths(path: string): string[] { + if (!path) return []; + + const segments = path.split('.'); + const ancestors: string[] = []; + + for (let i = 1; i < segments.length; i++) { + ancestors.push(segments.slice(0, i).join('.')); + } + + return ancestors; +} + +/** + * Removes a path and everything beneath it from a set of expanded paths. + * Used after a folder is deleted so the set cannot accumulate paths that no longer exist. + */ +export function prunePathsUnder(paths: ReadonlySet, removedPath: string): Set { + const prefix = `${removedPath}.`; + const next = new Set(); + + for (const path of paths) { + if (path === removedPath || path.startsWith(prefix)) continue; + next.add(path); + } + + return next; +} + +/** + * Rewrites expanded paths after a folder moves, so a subtree the user had opened + * stays open where it lands instead of snapping shut. + */ +export function rebaseExpandedPaths( + paths: ReadonlySet, + sourcePath: string, + destinationParentPath: string, +): Set { + const segments = sourcePath.split('.'); + const folderName = segments[segments.length - 1]; + const newSourcePath = destinationParentPath ? `${destinationParentPath}.${folderName}` : folderName; + + if (newSourcePath === sourcePath) return new Set(paths); + + const prefix = `${sourcePath}.`; + const next = new Set(); + + for (const path of paths) { + if (path === sourcePath) next.add(newSourcePath); + else if (path.startsWith(prefix)) next.add(`${newSourcePath}.${path.slice(prefix.length)}`); + else next.add(path); + } + + return next; +} diff --git a/apps/tracker/src/app/browser/translations/header/translation-main-header.html b/apps/tracker/src/app/browser/translations/header/translation-main-header.html index 92e638b3..7839c48f 100644 --- a/apps/tracker/src/app/browser/translations/header/translation-main-header.html +++ b/apps/tracker/src/app/browser/translations/header/translation-main-header.html @@ -22,8 +22,8 @@ mat-raised-button color="primary" (click)="handleAddTranslation()" - [disabled]="store.effectiveDisabled() || !store.currentFolderPath()" - [matTooltip]="(!store.currentFolderPath() ? TOKENS.BROWSER.SELECTFOLDERFIRST : TOKENS.BROWSER.ADDNEWTRANSLATIONTOOLTIP) | transloco" + [disabled]="store.effectiveDisabled()" + [matTooltip]="TOKENS.BROWSER.ADDNEWTRANSLATIONTOOLTIP | transloco" [attr.aria-label]="TOKENS.BROWSER.ADDTRANSLATIONBUTTON | transloco" > add diff --git a/apps/tracker/src/app/browser/translations/list/translation-list.html b/apps/tracker/src/app/browser/translations/list/translation-list.html index fa92ac62..129f14d4 100644 --- a/apps/tracker/src/app/browser/translations/list/translation-list.html +++ b/apps/tracker/src/app/browser/translations/list/translation-list.html @@ -28,16 +28,7 @@ } @if (!store.isLoading() && !store.error()) { - @if (!store.currentFolderPath()) { -
- - - folder_open -

{{ TOKENS.BROWSER.SELECTFOLDERFIRST | transloco }}

-
-
-
- } @else if (store.sortedTranslations().length === 0) { + @if (store.sortedTranslations().length === 0) {
diff --git a/apps/tracker/src/app/browser/translations/list/translation-list.spec.ts b/apps/tracker/src/app/browser/translations/list/translation-list.spec.ts index 8587b17f..c846dbe4 100644 --- a/apps/tracker/src/app/browser/translations/list/translation-list.spec.ts +++ b/apps/tracker/src/app/browser/translations/list/translation-list.spec.ts @@ -196,17 +196,17 @@ describe('TranslationList - Loading and Error States', () => { expect(errorContainer).toBeTruthy(); }); - it('should display "select a folder" empty state when no folder is selected', () => { + it('should show the empty-folder state rather than a select-a-folder prompt at the root', () => { + // The collection root is a real selection now — the sidebar's root row lands on it — + // so there is no "nothing selected" state left to prompt for. fixture.componentRef.setInput('collectionName', 'test'); fixture.detectChanges(); - const emptyState = fixture.nativeElement.querySelector('.empty-state'); const icon = fixture.nativeElement.querySelector('.empty-state__icon'); const text = fixture.nativeElement.querySelector('.empty-state__text'); - expect(emptyState).toBeTruthy(); - expect(icon?.textContent).toContain('folder_open'); - expect(text?.textContent).toContain('Select a folder first'); + expect(icon?.textContent).toContain('translate'); + expect(text?.textContent).toContain('No translations found'); }); it('should display "no translations found" empty state when folder is selected but empty', async () => { diff --git a/apps/tracker/src/app/shared/_toggle-animation.scss b/apps/tracker/src/app/shared/_toggle-animation.scss index 1a32213e..6ef426f9 100644 --- a/apps/tracker/src/app/shared/_toggle-animation.scss +++ b/apps/tracker/src/app/shared/_toggle-animation.scss @@ -56,3 +56,50 @@ cursor: not-allowed; } } + +// Shared twisty for the folder tree: one chevron glyph that rotates rather than swapping, +// so opening and shutting a folder reads as the same object moving. +// +// Deliberately not a tab stop — in a deep tree that would double the number of stops. The +// row carries aria-expanded and ArrowRight/ArrowLeft instead, which is the ARIA tree pattern. +%tree-expand-toggle { + align-items: center; + background: none; + border: none; + border-radius: var(--border-radius-sm); + color: var(--color-text-tertiary); + cursor: pointer; + display: flex; + flex-shrink: 0; + height: 20px; + justify-content: center; + padding: 0; + transition: color var(--transition-fast), background-color var(--transition-fast); + width: 20px; + + mat-icon { + font-size: 18px; + height: 18px; + line-height: 18px; + width: 18px; + transition: transform var(--transition-fast); + } + + &.expanded mat-icon { + transform: rotate(90deg); + } + + &:hover:not(:disabled) { + background-color: var(--color-background-muted); + color: var(--color-text-primary); + } +} + +// Occupies exactly the twisty's footprint so names stay on one vertical rule whether or +// not a folder has children. +%tree-expand-spacer { + display: block; + flex-shrink: 0; + height: 20px; + width: 20px; +} diff --git a/apps/tracker/src/i18n/browser/folderTree/resource_entries.json b/apps/tracker/src/i18n/browser/folderTree/resource_entries.json index 94e405b4..8d5acb3c 100644 --- a/apps/tracker/src/i18n/browser/folderTree/resource_entries.json +++ b/apps/tracker/src/i18n/browser/folderTree/resource_entries.json @@ -48,5 +48,35 @@ "ru": "Добавить папку (Ctrl+Shift+N)", "ja": "フォルダを追加する(Ctrl+Shift+N)", "de": "Ordner hinzufügen (Strg+Umschalt+N)" + }, + "expandAllTooltip": { + "source": "Expand all folders", + "comment": "Tooltip and aria-label for the expand-all control on the collection root row in the folder tree", + "tags": ["browser"], + "es": "Expandir todas las carpetas", + "fr-ca": "Développer tous les dossiers", + "ru": "Развернуть все папки", + "ja": "すべてのフォルダーを展開する", + "de": "Alle Ordner erweitern" + }, + "collapseAllTooltip": { + "source": "Collapse all folders", + "comment": "Tooltip and aria-label for the collapse-all control on the collection root row in the folder tree", + "tags": ["browser"], + "es": "Contraer todas las carpetas", + "fr-ca": "Réduire tous les dossiers", + "ru": "Свернуть все папки", + "ja": "すべてのフォルダーを折りたたむ", + "de": "Alle Ordner ausblenden" + }, + "rootFolderAriaLabelX": { + "source": "{name}, all resources in this collection", + "comment": "Aria-label for the collection root row in the folder tree; name is the collection name", + "tags": ["browser"], + "es": "{name}, todos los recursos de esta colección", + "fr-ca": "{name}, toutes les ressources de cette collection", + "ru": "{name}, все ресурсы в этой коллекции", + "ja": "{name}、このコレクションのすべてのリソース", + "de": "{name}, alle Ressourcen in dieser Sammlung" } } diff --git a/apps/tracker/src/i18n/browser/folderTree/tracker_meta.json b/apps/tracker/src/i18n/browser/folderTree/tracker_meta.json index 288e0ab9..100a9c5f 100644 --- a/apps/tracker/src/i18n/browser/folderTree/tracker_meta.json +++ b/apps/tracker/src/i18n/browser/folderTree/tracker_meta.json @@ -148,5 +148,95 @@ "baseChecksum": "79e6232f92f3cc7bd3d268f4ec3c9e95", "status": "translated" } + }, + "expandAllTooltip": { + "en": { + "checksum": "66a3e59ee3756fc97481ef4132a912cb" + }, + "es": { + "checksum": "cbdae8b669d9194183b88bb698832de1", + "baseChecksum": "66a3e59ee3756fc97481ef4132a912cb", + "status": "translated" + }, + "fr-ca": { + "checksum": "39773825cbfb6136d98255bb954f6e98", + "baseChecksum": "66a3e59ee3756fc97481ef4132a912cb", + "status": "translated" + }, + "ru": { + "checksum": "46f15ab6a3944df683d74384859b60eb", + "baseChecksum": "66a3e59ee3756fc97481ef4132a912cb", + "status": "translated" + }, + "ja": { + "checksum": "a703dd5a34d4ae944157b99e1b905021", + "baseChecksum": "66a3e59ee3756fc97481ef4132a912cb", + "status": "translated" + }, + "de": { + "checksum": "ef9e58f3b95c6befeb06d8496d9c1962", + "baseChecksum": "66a3e59ee3756fc97481ef4132a912cb", + "status": "translated" + } + }, + "collapseAllTooltip": { + "en": { + "checksum": "937e2c347f4b6a8e5875d505ad776945" + }, + "es": { + "checksum": "711baf14fa10d7bf7e5332477fe3f8ee", + "baseChecksum": "937e2c347f4b6a8e5875d505ad776945", + "status": "translated" + }, + "fr-ca": { + "checksum": "3de691daaa5038e2a9c0b072906c95b5", + "baseChecksum": "937e2c347f4b6a8e5875d505ad776945", + "status": "translated" + }, + "ru": { + "checksum": "f23db3f7ac83bbe39ab7392101d1b3d8", + "baseChecksum": "937e2c347f4b6a8e5875d505ad776945", + "status": "translated" + }, + "ja": { + "checksum": "7391e6bc3da1b47eed7752b2fc8ea998", + "baseChecksum": "937e2c347f4b6a8e5875d505ad776945", + "status": "translated" + }, + "de": { + "checksum": "38337ada64ba08916c864a4205c0b8e8", + "baseChecksum": "937e2c347f4b6a8e5875d505ad776945", + "status": "translated" + } + }, + "rootFolderAriaLabelX": { + "en": { + "checksum": "9fc22865b4fc2e63b472368b9a1055ff" + }, + "es": { + "checksum": "57013206ab9a8c138ac478d2af62d898", + "baseChecksum": "9fc22865b4fc2e63b472368b9a1055ff", + "status": "translated" + }, + "fr-ca": { + "checksum": "b2eca591d179255f01734a4e21b0a6d7", + "baseChecksum": "9fc22865b4fc2e63b472368b9a1055ff", + "status": "translated" + }, + "ru": { + "checksum": "0aaf6609a9a2be2776d8482cdc95a685", + "baseChecksum": "9fc22865b4fc2e63b472368b9a1055ff", + "status": "translated" + }, + "ja": { + "checksum": "993646cfe7ddd836cd7c9aa2d9c42eef", + "baseChecksum": "9fc22865b4fc2e63b472368b9a1055ff", + "status": "translated" + }, + "de": { + "checksum": "cad47b2e0974446b892143a5d9552da2", + "baseChecksum": "9fc22865b4fc2e63b472368b9a1055ff", + "status": "translated" + } } } diff --git a/apps/tracker/src/i18n/browser/resource_entries.json b/apps/tracker/src/i18n/browser/resource_entries.json index 428cd258..289cac2c 100644 --- a/apps/tracker/src/i18n/browser/resource_entries.json +++ b/apps/tracker/src/i18n/browser/resource_entries.json @@ -158,16 +158,6 @@ "ja": "降順でソート(Z→A)", "de": "Sortieren absteigend (Z→A)" }, - "selectFolderFirst": { - "source": "Select a folder first", - "comment": "Tooltip shown on Add Translation button when no folder is selected", - "tags": ["browser"], - "es": "Seleccione una carpeta primero", - "fr-ca": "Sélectionnez d'abord un dossier", - "ru": "Сначала выберите папку.", - "ja": "まずフォルダを選択してください。", - "de": "Wählen Sie zuerst einen Ordner aus." - }, "addNewTranslationTooltip": { "source": "Add a new translation", "comment": "Tooltip shown on Add Translation button when a folder is selected", diff --git a/apps/tracker/src/i18n/browser/tracker_meta.json b/apps/tracker/src/i18n/browser/tracker_meta.json index d0f8241f..692055c2 100644 --- a/apps/tracker/src/i18n/browser/tracker_meta.json +++ b/apps/tracker/src/i18n/browser/tracker_meta.json @@ -539,36 +539,6 @@ "status": "translated" } }, - "selectFolderFirst": { - "en": { - "checksum": "addf30815d3754a3170b8d0d9b3f7196" - }, - "es": { - "checksum": "f05d4a9337ae4bbf227281a5375eed29", - "baseChecksum": "addf30815d3754a3170b8d0d9b3f7196", - "status": "translated" - }, - "fr-ca": { - "checksum": "2d975dbf2e3f83915e39ce6f550724cb", - "baseChecksum": "addf30815d3754a3170b8d0d9b3f7196", - "status": "translated" - }, - "ru": { - "checksum": "55a41223d9c642c59c0ff06f085d27a6", - "baseChecksum": "addf30815d3754a3170b8d0d9b3f7196", - "status": "translated" - }, - "ja": { - "checksum": "9586322b0e1c3ac2016a8878266fde6e", - "baseChecksum": "addf30815d3754a3170b8d0d9b3f7196", - "status": "translated" - }, - "de": { - "checksum": "25dd264201a52111b9b20ad7d6e9b37d", - "baseChecksum": "addf30815d3754a3170b8d0d9b3f7196", - "status": "translated" - } - }, "addNewTranslationTooltip": { "en": { "checksum": "e26fd2bf951546994a81c53bc593e520" diff --git a/apps/tracker/src/testing/transloco-testing.module.ts b/apps/tracker/src/testing/transloco-testing.module.ts index 70ccd4a1..d923d7bd 100644 --- a/apps/tracker/src/testing/transloco-testing.module.ts +++ b/apps/tracker/src/testing/transloco-testing.module.ts @@ -22,7 +22,6 @@ export function getTranslocoTestingModule(options: TranslocoTestingOptions = {}) 'browser.sortDescending': 'Sort descending', 'browser.sortAscendingTooltip': 'Sort ascending (A→Z)', 'browser.sortDescendingTooltip': 'Sort descending (Z→A)', - 'browser.selectFolderFirst': 'Select a folder first', 'browser.addNewTranslationTooltip': 'Add a new translation', 'browser.noTranslation': 'No translation', 'browser.commentPrefix': 'Comment:', From 2cd931c023f431609beaecb317a07b6e58836b3e Mon Sep 17 00:00:00 2001 From: Simon Nodel Date: Sun, 6 Sep 2026 08:46:50 -0700 Subject: [PATCH 02/16] fix(tracker): resolve header counts through the transloco pipe The key and locale count chips rendered their raw keys in every non-English locale. `keysText` and `localesText` called `transloco.translate()` inside a computed; `LocaleService` sets the active language synchronously in its constructor, so the computed first ran while the locale file was still in flight, returned the key, and had no remaining dependency to invalidate it. Resolve both through `TranslocoPipe` in the template instead, which re-renders on load, and translate the two ICU strings into the five non-base locales with the plural categories each language actually uses. Co-Authored-By: Claude Opus 5 --- apps/tracker/src/app/header/app-header.html | 4 +- apps/tracker/src/app/header/app-header.ts | 26 +++++------- .../src/i18n/header/resource_entries.json | 20 +++++----- .../tracker/src/i18n/header/tracker_meta.json | 40 +++++++++---------- 4 files changed, 41 insertions(+), 49 deletions(-) diff --git a/apps/tracker/src/app/header/app-header.html b/apps/tracker/src/app/header/app-header.html index e55e16e0..1e4c4adf 100644 --- a/apps/tracker/src/app/header/app-header.html +++ b/apps/tracker/src/app/header/app-header.html @@ -33,8 +33,8 @@

LingoTracker

} @else if (totalKeys() !== null) { - {{ keysText() }} - {{ localesText() }} + {{ TOKENS.HEADER.KEYSCOUNTX | transloco: { count: totalKeys() } }} + {{ TOKENS.HEADER.LOCALESCOUNTX | transloco: { count: localeCount() } }} }
} diff --git a/apps/tracker/src/app/header/app-header.ts b/apps/tracker/src/app/header/app-header.ts index 64c1c395..6f64dafb 100644 --- a/apps/tracker/src/app/header/app-header.ts +++ b/apps/tracker/src/app/header/app-header.ts @@ -6,10 +6,9 @@ import { MatIconModule } from '@angular/material/icon'; import { MatButtonModule } from '@angular/material/button'; import { MatMenuModule } from '@angular/material/menu'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { TranslocoPipe, TranslocoService } from '@jsverse/transloco'; +import { TranslocoPipe } from '@jsverse/transloco'; import { ThemeService, type ThemeMode } from '../shared/services/theme.service'; import { HeaderContextService } from '../shared/services/header-context.service'; -import { LocaleService } from '../shared/services/locale.service'; import { LocalePickerComponent } from './locale-picker/locale-picker'; import { TRACKER_TOKENS } from '../../i18n-types/tracker-resources'; @@ -35,8 +34,6 @@ import { TRACKER_TOKENS } from '../../i18n-types/tracker-resources'; export class AppHeader { readonly #themeService = inject(ThemeService); readonly #headerContext = inject(HeaderContextService); - readonly #transloco = inject(TranslocoService); - readonly #localeService = inject(LocaleService); readonly TOKENS = TRACKER_TOKENS; @@ -47,19 +44,14 @@ export class AppHeader { readonly statsLoading = this.#headerContext.statsLoading; readonly hasCollectionContext = this.#headerContext.hasCollectionContext; - readonly keysText = computed(() => { - const _locale = this.#localeService.currentLocale(); - const k = this.totalKeys(); - if (k === null) return ''; - return this.#transloco.translate(TRACKER_TOKENS.HEADER.KEYSCOUNTX, { count: k }); - }); - - readonly localesText = computed(() => { - const _locale = this.#localeService.currentLocale(); - const l = this.localeCount(); - if (l === null) return ''; - return this.#transloco.translate(TRACKER_TOKENS.HEADER.LOCALESCOUNTX, { count: l }); - }); + /** + * The stat chips resolve through the `transloco` pipe in the template, not + * through `TranslocoService.translate()` in a `computed`. A computed caches + * whatever `translate()` returned on its first read, and that read happens + * before the active language's translation file has finished loading, so it + * would latch the raw key ("header.keysCountX") forever. See `themeModeToken` + * below for the same rule. + */ /** * The switcher wears the *mode* that is selected, not the theme it resolves diff --git a/apps/tracker/src/i18n/header/resource_entries.json b/apps/tracker/src/i18n/header/resource_entries.json index a8885950..b85ae8f5 100644 --- a/apps/tracker/src/i18n/header/resource_entries.json +++ b/apps/tracker/src/i18n/header/resource_entries.json @@ -23,20 +23,20 @@ "source": "{ count, plural, =1 {1 key} other {{count} keys} }", "comment": "Pluralized count of translation keys shown in the app header stats", "tags": ["header"], - "es": "{ count, plural, =1 {1 key} other {{count} keys} }", - "fr-ca": "{ count, plural, =1 {1 key} other {{count} keys} }", - "ru": "{ count, plural, =1 {1 key} other {{count} keys} }", - "ja": "{ count, plural, =1 {1 key} other {{count} keys} }", - "de": "{ count, plural, =1 {1 key} other {{count} keys} }" + "es": "{ count, plural, =1 {1 clave} other {{count} claves} }", + "fr-ca": "{ count, plural, =1 {1 clé} other {{count} clés} }", + "ru": "{ count, plural, one {{count} ключ} few {{count} ключа} many {{count} ключей} other {{count} ключа} }", + "ja": "{ count, plural, other {キー {count} 件} }", + "de": "{ count, plural, =1 {1 Schlüssel} other {{count} Schlüssel} }" }, "localesCountX": { "source": "{ count, plural, =1 {1 locale} other {{count} locales} }", "comment": "Pluralized count of locales shown in the app header stats", "tags": ["header"], - "es": "{ count, plural, =1 {1 locale} other {{count} locales} }", - "fr-ca": "{ count, plural, =1 {1 locale} other {{count} locales} }", - "ru": "{ count, plural, =1 {1 locale} other {{count} locales} }", - "ja": "{ count, plural, =1 {1 locale} other {{count} locales} }", - "de": "{ count, plural, =1 {1 locale} other {{count} locales} }" + "es": "{ count, plural, =1 {1 idioma} other {{count} idiomas} }", + "fr-ca": "{ count, plural, =1 {1 langue} other {{count} langues} }", + "ru": "{ count, plural, one {{count} локаль} few {{count} локали} many {{count} локалей} other {{count} локали} }", + "ja": "{ count, plural, other {ロケール {count} 件} }", + "de": "{ count, plural, =1 {1 Sprache} other {{count} Sprachen} }" } } diff --git a/apps/tracker/src/i18n/header/tracker_meta.json b/apps/tracker/src/i18n/header/tracker_meta.json index 5e3ecdb4..f675186a 100644 --- a/apps/tracker/src/i18n/header/tracker_meta.json +++ b/apps/tracker/src/i18n/header/tracker_meta.json @@ -64,29 +64,29 @@ "checksum": "2ccecfa93e0d0c95b7f229684871db68" }, "es": { - "checksum": "2ccecfa93e0d0c95b7f229684871db68", + "checksum": "b4add8d9375f64e3ce074505a1ec49ed", "baseChecksum": "2ccecfa93e0d0c95b7f229684871db68", - "status": "new" + "status": "translated" }, "fr-ca": { - "checksum": "2ccecfa93e0d0c95b7f229684871db68", + "checksum": "8ad69cbaac79b56e7d979301e33abded", "baseChecksum": "2ccecfa93e0d0c95b7f229684871db68", - "status": "new" + "status": "translated" }, "ru": { - "checksum": "2ccecfa93e0d0c95b7f229684871db68", + "checksum": "84cb422a7d82aa5c6173d70f4fefef32", "baseChecksum": "2ccecfa93e0d0c95b7f229684871db68", - "status": "new" + "status": "translated" }, "ja": { - "checksum": "2ccecfa93e0d0c95b7f229684871db68", + "checksum": "0684e7169b654b80e4e618011cb2169d", "baseChecksum": "2ccecfa93e0d0c95b7f229684871db68", - "status": "new" + "status": "translated" }, "de": { - "checksum": "2ccecfa93e0d0c95b7f229684871db68", + "checksum": "c00cc3e5b357b5b43b42d8b40c65882c", "baseChecksum": "2ccecfa93e0d0c95b7f229684871db68", - "status": "new" + "status": "translated" } }, "localesCountX": { @@ -94,29 +94,29 @@ "checksum": "ab692b642031b8646162616db0a399cf" }, "es": { - "checksum": "ab692b642031b8646162616db0a399cf", + "checksum": "56e43b9de1371eb403e0daceed54a6bc", "baseChecksum": "ab692b642031b8646162616db0a399cf", - "status": "new" + "status": "translated" }, "fr-ca": { - "checksum": "ab692b642031b8646162616db0a399cf", + "checksum": "34130c5c281c87f76f7a15bf6fe1f767", "baseChecksum": "ab692b642031b8646162616db0a399cf", - "status": "new" + "status": "translated" }, "ru": { - "checksum": "ab692b642031b8646162616db0a399cf", + "checksum": "74e5281bd2ef7c22a192f8783230ebc0", "baseChecksum": "ab692b642031b8646162616db0a399cf", - "status": "new" + "status": "translated" }, "ja": { - "checksum": "ab692b642031b8646162616db0a399cf", + "checksum": "25f3eae8130ca224d8b0c54ebd49b889", "baseChecksum": "ab692b642031b8646162616db0a399cf", - "status": "new" + "status": "translated" }, "de": { - "checksum": "ab692b642031b8646162616db0a399cf", + "checksum": "7fd6a4358024b802a67790227a1f9edc", "baseChecksum": "ab692b642031b8646162616db0a399cf", - "status": "new" + "status": "translated" } } } From ae2795ebd04215ac1e9b4d2f2fd6d8e5db3c6885 Mon Sep 17 00:00:00 2001 From: Simon Nodel Date: Sun, 6 Sep 2026 08:47:05 -0700 Subject: [PATCH 03/16] fix(tracker): correct mistranslated status and editor copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The translation editor now renders the status lifecycle through the localized resources rather than the raw enum, which exposed that `stale` was translated in the wrong sense in four locales: de "Abgestanden" (of beer going flat), ru "Залежалый" (of unsold goods), es "Duro" (of bread) and fr-ca "Vicié" (of foul air). None is the software sense of an out-of-date translation. Also corrects, in the same pass: - `saving` in es and fr-ca, which used the money sense on the submit button - `error.createFailed` in fr-ca, which said the translation had failed - `conflict.chooseDifferentKey` in de, which used *Taste*, a keyboard key - `keyHint` and `keyPatternError` in es and fr-ca, which named a tab label that does not exist - `similarTranslations.showMoreX` in de, fr-ca, ja and ru, one untranslated and three with the placeholder stranded by English word order - `localeTranslationX` in es and fr-ca, which used English adjective order Drops `otherLocalesToggleX`, referenced by nothing, across all six locales and its stub in the testing module. Clears the 20 stale and 2 new entries in the dialog scope, whose values still matched their base. Co-Authored-By: Claude Opus 5 --- .../i18n/browser/folderPicker/tracker_meta.json | 8 ++++---- .../similarTranslations/resource_entries.json | 8 ++++---- .../similarTranslations/tracker_meta.json | 12 ++++++------ .../i18n/browser/status/resource_entries.json | 10 +++++----- .../src/i18n/browser/status/tracker_meta.json | 10 +++++----- .../conflict/resource_entries.json | 4 ++-- .../translationEditor/conflict/tracker_meta.json | 8 ++++---- .../error/resource_entries.json | 2 +- .../translationEditor/error/tracker_meta.json | 2 +- .../translationEditor/tab/resource_entries.json | 16 ++++++++-------- .../translationEditor/tab/tracker_meta.json | 16 ++++++++-------- .../translationEditor/tags/tracker_meta.json | 2 +- .../src/testing/transloco-testing.module.ts | 7 ++++--- 13 files changed, 53 insertions(+), 52 deletions(-) diff --git a/apps/tracker/src/i18n/browser/folderPicker/tracker_meta.json b/apps/tracker/src/i18n/browser/folderPicker/tracker_meta.json index b16c0f03..1098e8ec 100644 --- a/apps/tracker/src/i18n/browser/folderPicker/tracker_meta.json +++ b/apps/tracker/src/i18n/browser/folderPicker/tracker_meta.json @@ -6,12 +6,12 @@ "es": { "checksum": "919d3629485e94ca4c13bf8ca40183e6", "baseChecksum": "16708b0cacde01471507891eb88bcf2b", - "status": "stale" + "status": "translated" }, "fr-ca": { "checksum": "579125b969a55e9f1429775d20e2faa5", "baseChecksum": "16708b0cacde01471507891eb88bcf2b", - "status": "stale" + "status": "translated" }, "ru": { "checksum": "91946bcd8bb4cbac5db6c724fee19b1f", @@ -96,12 +96,12 @@ "es": { "checksum": "25077cf7679a114b0de5e0a0a9f3a64e", "baseChecksum": "aa85d08e5c2011ca6a07c0db14c82de2", - "status": "stale" + "status": "translated" }, "fr-ca": { "checksum": "7a3c0707dbd14091260a5aca97d25f02", "baseChecksum": "aa85d08e5c2011ca6a07c0db14c82de2", - "status": "stale" + "status": "translated" }, "ru": { "checksum": "da6ae24598b43f425bbeb36a05a35bed", diff --git a/apps/tracker/src/i18n/browser/similarTranslations/resource_entries.json b/apps/tracker/src/i18n/browser/similarTranslations/resource_entries.json index dab1b7c5..20c47ca0 100644 --- a/apps/tracker/src/i18n/browser/similarTranslations/resource_entries.json +++ b/apps/tracker/src/i18n/browser/similarTranslations/resource_entries.json @@ -44,9 +44,9 @@ "comment": "Button to show remaining similar translation results, count is the number of hidden results", "tags": ["browser"], "es": "Mostrar {count} más", - "fr-ca": "Show {count} more", - "ru": "Показать {count} больше", - "ja": "{count} を表示", - "de": "Mehr anzeigen {count}" + "fr-ca": "Afficher {count} de plus", + "ru": "Показать ещё {count}", + "ja": "他 {count} 件を表示", + "de": "{count} weitere anzeigen" } } diff --git a/apps/tracker/src/i18n/browser/similarTranslations/tracker_meta.json b/apps/tracker/src/i18n/browser/similarTranslations/tracker_meta.json index 15460e94..fb51589f 100644 --- a/apps/tracker/src/i18n/browser/similarTranslations/tracker_meta.json +++ b/apps/tracker/src/i18n/browser/similarTranslations/tracker_meta.json @@ -126,25 +126,25 @@ "es": { "checksum": "551047ecf45228306f8abf35ab6abd06", "baseChecksum": "b39ae93853f9b177650ca742f758312d", - "status": "stale" + "status": "translated" }, "fr-ca": { - "checksum": "b39ae93853f9b177650ca742f758312d", + "checksum": "08c944bcbe3ad180d238254e94399e00", "baseChecksum": "b39ae93853f9b177650ca742f758312d", - "status": "new" + "status": "translated" }, "ru": { - "checksum": "ed667cad5e2af69efc1eca00c60429b8", + "checksum": "895542cd61c4b73d87235bb92cfa02cc", "baseChecksum": "b39ae93853f9b177650ca742f758312d", "status": "translated" }, "ja": { - "checksum": "1a3b363e63a99737336b9f0bcca01221", + "checksum": "7eb4bbd9d4906a48976b73ceebe4f5c0", "baseChecksum": "b39ae93853f9b177650ca742f758312d", "status": "translated" }, "de": { - "checksum": "590a948074ca10fae48086e64291db05", + "checksum": "041d2297b5c24122c4e86aa0ee079abe", "baseChecksum": "b39ae93853f9b177650ca742f758312d", "status": "translated" } diff --git a/apps/tracker/src/i18n/browser/status/resource_entries.json b/apps/tracker/src/i18n/browser/status/resource_entries.json index 66a9803d..e266cb89 100644 --- a/apps/tracker/src/i18n/browser/status/resource_entries.json +++ b/apps/tracker/src/i18n/browser/status/resource_entries.json @@ -6,18 +6,18 @@ "es": "Nuevo", "fr-ca": "Nouveau", "ru": "Новый", - "ja": "新しい", + "ja": "新規", "de": "Neu" }, "stale": { "source": "Stale", "comment": "Translation status label: source has changed and translation is outdated", "tags": ["browser"], - "es": "Duro", - "fr-ca": "Vicié", - "ru": "Залежалый", + "es": "Desactualizado", + "fr-ca": "Obsolète", + "ru": "Устарело", "ja": "古くなった", - "de": "Abgestanden" + "de": "Veraltet" }, "translated": { "source": "Translated", diff --git a/apps/tracker/src/i18n/browser/status/tracker_meta.json b/apps/tracker/src/i18n/browser/status/tracker_meta.json index a08a35a4..08c18d01 100644 --- a/apps/tracker/src/i18n/browser/status/tracker_meta.json +++ b/apps/tracker/src/i18n/browser/status/tracker_meta.json @@ -19,7 +19,7 @@ "status": "translated" }, "ja": { - "checksum": "ad4b0bd55882d4f7164630d4158d3c39", + "checksum": "ef5f9cf6987e3eef306718aa3c48c2f5", "baseChecksum": "03c2e7e41ffc181a4e84080b4710e81e", "status": "translated" }, @@ -34,17 +34,17 @@ "checksum": "f1ac273d3c6d8e6489bf332819a2f650" }, "es": { - "checksum": "605821ebcf6a2e53f03ec6e8e463d1bd", + "checksum": "ef928042b96e611faac34137124515a1", "baseChecksum": "f1ac273d3c6d8e6489bf332819a2f650", "status": "translated" }, "fr-ca": { - "checksum": "3ae88690725fe012389643e0056634b6", + "checksum": "bbf6a331bc2fc35ecc328e2da8c3f7fc", "baseChecksum": "f1ac273d3c6d8e6489bf332819a2f650", "status": "translated" }, "ru": { - "checksum": "0fa45772724dbd2cf9e99b15e04d7efa", + "checksum": "45286731379a1065ab7e4a468985873d", "baseChecksum": "f1ac273d3c6d8e6489bf332819a2f650", "status": "translated" }, @@ -54,7 +54,7 @@ "status": "translated" }, "de": { - "checksum": "e47ee5c96bf1921b48a43365b499d6dc", + "checksum": "2d698d066986540c9b0f8bb98fcb1f45", "baseChecksum": "f1ac273d3c6d8e6489bf332819a2f650", "status": "translated" } diff --git a/apps/tracker/src/i18n/browser/translationEditor/conflict/resource_entries.json b/apps/tracker/src/i18n/browser/translationEditor/conflict/resource_entries.json index 3602db98..3566377c 100644 --- a/apps/tracker/src/i18n/browser/translationEditor/conflict/resource_entries.json +++ b/apps/tracker/src/i18n/browser/translationEditor/conflict/resource_entries.json @@ -14,7 +14,7 @@ "comment": "Conflict dialog message with the conflicting key name", "tags": ["browser"], "es": "La clave de traducción \"{key}\" ya existe en esta colección. ¿Desea editar la traducción existente o elegir una clave diferente?", - "fr-ca": "La clé de traduction « {key} » existe déjà dans cette collection. Souhaitez-vous modifier la traduction existante ou choisir une autre clé ?", + "fr-ca": "La clé de traduction « {key} » existe déjà dans cette collection. Souhaitez-vous modifier la traduction existante ou choisir une autre clé ?", "ru": "Ключ перевода \"{key}\" уже существует в этой коллекции. Хотите отредактировать существующий перевод или выбрать другой ключ?", "ja": "翻訳キー「{key}」は既にこのコレクションに存在します。既存の翻訳を編集しますか、それとも別のキーを選択しますか?", "de": "Der Übersetzungsschlüssel \"{key}\" ist in dieser Sammlung bereits vorhanden. Möchten Sie die bestehende Übersetzung bearbeiten oder einen anderen Schlüssel auswählen?" @@ -37,6 +37,6 @@ "fr-ca": "Choisir une autre clé", "ru": "Выберите другой ключ", "ja": "別のキーを選択してください", - "de": "Wählen Sie eine andere Taste" + "de": "Anderen Schlüssel wählen" } } diff --git a/apps/tracker/src/i18n/browser/translationEditor/conflict/tracker_meta.json b/apps/tracker/src/i18n/browser/translationEditor/conflict/tracker_meta.json index a2197203..aa16ebfd 100644 --- a/apps/tracker/src/i18n/browser/translationEditor/conflict/tracker_meta.json +++ b/apps/tracker/src/i18n/browser/translationEditor/conflict/tracker_meta.json @@ -36,12 +36,12 @@ "es": { "checksum": "da3d1620b019c5340dac24168c4a3721", "baseChecksum": "765a0106f27fce3620418e6a5afece18", - "status": "stale" + "status": "translated" }, "fr-ca": { - "checksum": "1228373b337d25fe96d944ad0103a4bc", + "checksum": "a8487b5a94351a9826d2deeebcf05ec1", "baseChecksum": "765a0106f27fce3620418e6a5afece18", - "status": "stale" + "status": "translated" }, "ru": { "checksum": "5dcee4e9669d158cef1d3bcfbe2d985c", @@ -114,7 +114,7 @@ "status": "translated" }, "de": { - "checksum": "b15777d65f74a76f6e45fe581a06a942", + "checksum": "9db3484f4e345f2c9cfe95a52b04dfd7", "baseChecksum": "5275ecd41038dd5cab36ac1d387e95aa", "status": "translated" } diff --git a/apps/tracker/src/i18n/browser/translationEditor/error/resource_entries.json b/apps/tracker/src/i18n/browser/translationEditor/error/resource_entries.json index b72cbd4d..feaab824 100644 --- a/apps/tracker/src/i18n/browser/translationEditor/error/resource_entries.json +++ b/apps/tracker/src/i18n/browser/translationEditor/error/resource_entries.json @@ -44,7 +44,7 @@ "comment": "Fallback error when resource creation fails", "tags": ["browser"], "es": "No se pudo crear la traducción", - "fr-ca": "Échec de la traduction", + "fr-ca": "Échec de la création de la traduction", "ru": "Не удалось создать перевод", "ja": "翻訳の作成に失敗しました", "de": "Übersetzung konnte nicht erstellt werden" diff --git a/apps/tracker/src/i18n/browser/translationEditor/error/tracker_meta.json b/apps/tracker/src/i18n/browser/translationEditor/error/tracker_meta.json index a9facfc7..2d7c664f 100644 --- a/apps/tracker/src/i18n/browser/translationEditor/error/tracker_meta.json +++ b/apps/tracker/src/i18n/browser/translationEditor/error/tracker_meta.json @@ -129,7 +129,7 @@ "status": "translated" }, "fr-ca": { - "checksum": "fa7979919d970f5fd2df5d63a36735d9", + "checksum": "156f42edc85551feb65103bc5e5f8cc2", "baseChecksum": "20a64455184899c3fb2e93b4566c8860", "status": "translated" }, diff --git a/apps/tracker/src/i18n/browser/translationEditor/tab/resource_entries.json b/apps/tracker/src/i18n/browser/translationEditor/tab/resource_entries.json index 6a94861f..46586e0b 100644 --- a/apps/tracker/src/i18n/browser/translationEditor/tab/resource_entries.json +++ b/apps/tracker/src/i18n/browser/translationEditor/tab/resource_entries.json @@ -15,18 +15,18 @@ "tags": ["browser"], "es": "Cambiar ubicación", "fr-ca": "Changer d'emplacement", - "ru": "Изменить местоположение", - "ja": "場所を変更する", - "de": "Standort ändern" + "ru": "Изменить папку", + "ja": "フォルダーを変更", + "de": "Ordner ändern" }, "otherLocales": { "source": "Other Locales", "comment": "Tab label for the other locales section in the editor dialog", "tags": ["browser"], - "es": "Otros lugares", - "fr-ca": "Autres localités", - "ru": "Другие населенные пункты", - "ja": "その他の地域", - "de": "Andere Orte" + "es": "Otros idiomas", + "fr-ca": "Autres langues", + "ru": "Другие локали", + "ja": "他のロケール", + "de": "Weitere Sprachen" } } diff --git a/apps/tracker/src/i18n/browser/translationEditor/tab/tracker_meta.json b/apps/tracker/src/i18n/browser/translationEditor/tab/tracker_meta.json index 6f9574b9..cda24e20 100644 --- a/apps/tracker/src/i18n/browser/translationEditor/tab/tracker_meta.json +++ b/apps/tracker/src/i18n/browser/translationEditor/tab/tracker_meta.json @@ -44,17 +44,17 @@ "status": "translated" }, "ru": { - "checksum": "86568c4f274d53ddbd5c0ca909838948", + "checksum": "c3460d5f12a50700d1460eaa19a24c41", "baseChecksum": "6dd495f1c2ae29c8e9a6c4405f18e22e", "status": "translated" }, "ja": { - "checksum": "1b8bf802cd4b26490f8d9ea9033c1b10", + "checksum": "c9edd0ef72155d040ebad4056362dc00", "baseChecksum": "6dd495f1c2ae29c8e9a6c4405f18e22e", "status": "translated" }, "de": { - "checksum": "37b9b091bf44c29f032ce905da6fd10f", + "checksum": "a190bb4504ea9e591e957538efe48642", "baseChecksum": "6dd495f1c2ae29c8e9a6c4405f18e22e", "status": "translated" } @@ -64,27 +64,27 @@ "checksum": "aca1284fedfa7431bb8a9dc0b59fe6c6" }, "es": { - "checksum": "d2b581ecac176d7d13962b1b2f1b4da4", + "checksum": "550c7cdfb92362bb233e70dfa2ad509c", "baseChecksum": "aca1284fedfa7431bb8a9dc0b59fe6c6", "status": "translated" }, "fr-ca": { - "checksum": "7d4be744a78b006182b35c9d056b566e", + "checksum": "2e2ee859214fac995e01e6dfa885d4dc", "baseChecksum": "aca1284fedfa7431bb8a9dc0b59fe6c6", "status": "translated" }, "ru": { - "checksum": "dae4ccfa3a748a5236612c9cb07dafac", + "checksum": "acc46f241a646d68c8632fe59c2a2c00", "baseChecksum": "aca1284fedfa7431bb8a9dc0b59fe6c6", "status": "translated" }, "ja": { - "checksum": "5d191996e0ef3a0e2d45658ad517e8c1", + "checksum": "2929efe49206febaad199b1c79eac0a9", "baseChecksum": "aca1284fedfa7431bb8a9dc0b59fe6c6", "status": "translated" }, "de": { - "checksum": "01a98b4b9cfdd78d96ce093d75f2efe6", + "checksum": "6313ed09e8f88fde09840484c4ac2b9f", "baseChecksum": "aca1284fedfa7431bb8a9dc0b59fe6c6", "status": "translated" } diff --git a/apps/tracker/src/i18n/browser/translationEditor/tags/tracker_meta.json b/apps/tracker/src/i18n/browser/translationEditor/tags/tracker_meta.json index fe5c7e94..d3681d79 100644 --- a/apps/tracker/src/i18n/browser/translationEditor/tags/tracker_meta.json +++ b/apps/tracker/src/i18n/browser/translationEditor/tags/tracker_meta.json @@ -26,7 +26,7 @@ "de": { "checksum": "189f63f277cd73395561651753563065", "baseChecksum": "189f63f277cd73395561651753563065", - "status": "new" + "status": "translated" } }, "addPlaceholder": { diff --git a/apps/tracker/src/testing/transloco-testing.module.ts b/apps/tracker/src/testing/transloco-testing.module.ts index d923d7bd..b48e4ee6 100644 --- a/apps/tracker/src/testing/transloco-testing.module.ts +++ b/apps/tracker/src/testing/transloco-testing.module.ts @@ -88,7 +88,9 @@ export function getTranslocoTestingModule(options: TranslocoTestingOptions = {}) 'browser.toast.moveFolderFailed': 'Failed to move folder', 'browser.translationEditor.closeDialog': 'Close dialog', 'browser.translationEditor.translationKey': 'Translation Key', - 'browser.translationEditor.keyHint': 'Use only letters, numbers, underscores, and hyphens', + 'browser.translationEditor.keyHint': + 'Entry name only — paste a full dotted key and its folder part moves to Location', + 'browser.translationEditor.locationFromKeyX': 'Location set to {{ folder }} from the key you entered', 'browser.translationEditor.baseBadge': 'Base', 'browser.translationEditor.localeTranslationX': '{{ locale }} Translation', 'browser.translationEditor.enterTranslationX': 'Enter {{ locale }} translation...', @@ -96,7 +98,6 @@ export function getTranslocoTestingModule(options: TranslocoTestingOptions = {}) 'browser.translationEditor.commentLabel': 'Comment (Optional)', 'browser.translationEditor.commentPlaceholder': 'Add context for translators...', 'browser.translationEditor.commentHint': 'Provide context or instructions for translators', - 'browser.translationEditor.otherLocalesToggleX': 'Add translations for other locales ({{ count }} available)', 'browser.translationEditor.status': 'Status', 'browser.translationEditor.saving': 'Saving...', 'browser.translationEditor.editTitle': 'Edit Translation', @@ -108,7 +109,7 @@ export function getTranslocoTestingModule(options: TranslocoTestingOptions = {}) 'browser.translationEditor.keyCopied': 'Similar resource key copied', 'browser.translationEditor.copyFailed': 'Failed to copy', 'browser.translationEditor.keyRequired': 'Translation key is required', - 'browser.translationEditor.keyPatternError': 'Only letters, numbers, underscores, and hyphens allowed', + 'browser.translationEditor.keyPatternError': 'Use letters, numbers, hyphens and underscores only', 'browser.translationEditor.commentConfirm.title': 'No Comment Added', 'browser.translationEditor.commentConfirm.message': 'Comments help other translators understand context. Are you sure you want to save without a comment?', From 407d9a0e8252a5a69439ce8b615acf885396627a Mon Sep 17 00:00:00 2001 From: Simon Nodel Date: Sun, 6 Sep 2026 08:47:23 -0700 Subject: [PATCH 04/16] feat(tracker): rework the translation editor dialog Closes the gaps a design critique found between the dialog and the promises the product makes elsewhere. Editing safety: - Take ownership of `disableClose` in the component rather than trusting each call site, and route Escape through `dialogRef.keydownEvents()` instead of a window listener that fired through any dialog stacked above it. Confirm before discarding, comparing against captured baselines for the form, the selected folder and the tag list, two of which live outside the form. - Make the key readonly while editing, so a rename is impossible rather than rejected after a round trip, and drop the now unreachable rejection. - Leave Save enabled and diagnose on click: mark the fields touched, jump to the offending tab, focus the control, and mark the tab with an error dot. Keys: - Accept the dot-delimited syntax the product defines. Pasting or typing `apps.common.buttons.ok` now splits at the last dot, sets the folder and keeps the leaf, with the location pill flashing and a live region announcing where the prefix went. The pattern validator stays as the backstop. Auto-translate moves out of the dialog. It persisted to disk on click from inside an unsaved form, so the discard prompt then offered to lose changes that were already committed. It remains available on the translation list item. Layout: - Give the dialog a full-screen treatment below 640px, replacing a 312px column, and size the tab content by viewport instead of a fixed 570px that left 350px empty under the folder tree. - Size form-field subscripts dynamically. German hint text overflowed its 22px box by up to 60px and painted over the base locale card. - Keep the tab strip from paginating at narrow widths, where it scrolled the active tab out of view with nothing to say which was selected. Also renames the read-only banner's colour to the token that exists, adds `--color-warning-text` for the contrast the banner needs on parchment, resolves locale names through `Intl.DisplayNames` while keeping the raw code visible, and extends the reduced-motion guards to the folder tree. Co-Authored-By: Claude Opus 5 --- .../folder-picker/folder-picker.scss | 29 ++ .../picker-folder-node.scss | 27 ++ .../similar-translations.scss | 29 ++ .../translation-editor-dialog.html | 99 +++-- .../translation-editor-dialog.scss | 282 ++++++++++++-- .../translation-editor-dialog.spec.ts | 264 +++++++++++-- .../translation-editor-dialog.ts | 296 ++++++++++++++- .../header/translation-main-header.ts | 7 +- .../list/store/with-item-actions.feature.ts | 10 +- .../translationEditor/resource_entries.json | 138 +++++-- .../translationEditor/tracker_meta.json | 348 +++++++++++++----- .../unsaved/resource_entries.json | 42 +++ .../unsaved/tracker_meta.json | 122 ++++++ apps/tracker/src/styles.scss | 48 +++ apps/tracker/src/styles/tokens.scss | 4 + 15 files changed, 1507 insertions(+), 238 deletions(-) create mode 100644 apps/tracker/src/i18n/browser/translationEditor/unsaved/resource_entries.json create mode 100644 apps/tracker/src/i18n/browser/translationEditor/unsaved/tracker_meta.json diff --git a/apps/tracker/src/app/browser/dialogs/translation-editor/folder-picker/folder-picker.scss b/apps/tracker/src/app/browser/dialogs/translation-editor/folder-picker/folder-picker.scss index 945fd34e..691cf044 100644 --- a/apps/tracker/src/app/browser/dialogs/translation-editor/folder-picker/folder-picker.scss +++ b/apps/tracker/src/app/browser/dialogs/translation-editor/folder-picker/folder-picker.scss @@ -96,3 +96,32 @@ border-radius: var(--border-radius-sm); } } + +// Narrow widths: the picker is the whole tab, so it keeps the content and gives +// back the gutters. The empty state's 64px icon is a desktop flourish. +@media (max-width: 639px) { + .folder-picker-content { + padding: var(--spacing-2); + } + + .folder-header { + padding: var(--spacing-2) var(--spacing-3); + } + + .empty-state { + padding: var(--spacing-5) var(--spacing-3); + + .empty-icon { + font-size: 44px; + height: 44px; + width: 44px; + } + } +} + +@media (prefers-reduced-motion: reduce) { + .folder-header, + .chevron-icon { + transition: none; + } +} diff --git a/apps/tracker/src/app/browser/dialogs/translation-editor/folder-picker/picker-folder-node/picker-folder-node.scss b/apps/tracker/src/app/browser/dialogs/translation-editor/folder-picker/picker-folder-node/picker-folder-node.scss index 88f7e0f6..06122c4e 100644 --- a/apps/tracker/src/app/browser/dialogs/translation-editor/folder-picker/picker-folder-node/picker-folder-node.scss +++ b/apps/tracker/src/app/browser/dialogs/translation-editor/folder-picker/picker-folder-node/picker-folder-node.scss @@ -52,6 +52,13 @@ } } + // `.focused` tracks the tree's roving pseudo-focus, not real DOM focus, so a + // keyboard user tabbing straight into the add button used to land on an + // invisible control. Reveal it whenever anything inside the row truly has focus. + &:focus-within .add-button { + opacity: 1; + } + // Expand/collapse button .expand-button { width: 24px; @@ -158,3 +165,23 @@ } } } + + +@media (prefers-reduced-motion: reduce) { + .folder-row, + .folder-row .add-button, + .folder-row .expand-button, + .folder-row .folder-icon, + .folder-row .folder-name { + transition: none; + } +} + +// A folder row is ~32px tall, which is fine under a mouse and not a tap target. +// Size by input method rather than viewport: a touchscreen laptop needs this at +// 1440 just as much as a phone does at 390. +@media (pointer: coarse) { + .folder-row { + padding-block: var(--spacing-3); + } +} diff --git a/apps/tracker/src/app/browser/dialogs/translation-editor/similar-translations.scss b/apps/tracker/src/app/browser/dialogs/translation-editor/similar-translations.scss index f75e3b5d..678b614c 100644 --- a/apps/tracker/src/app/browser/dialogs/translation-editor/similar-translations.scss +++ b/apps/tracker/src/app/browser/dialogs/translation-editor/similar-translations.scss @@ -119,3 +119,32 @@ color: var(--color-primary); } } + +// At ~390px a single ellipsised line of a suggested translation shows almost +// nothing — and the point of this card is judging whether the suggestion is the +// same string. Give the value two lines and reclaim the gutters. +@media (max-width: 639px) { + .card-header, + .card-content { + padding: var(--spacing-3); + } + + .result-item { + padding: var(--spacing-2) var(--spacing-3); + } + + .result-value { + white-space: normal; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + } +} + +@media (prefers-reduced-motion: reduce) { + .similar-translations-card, + .result-item { + transition: none; + } +} diff --git a/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.html b/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.html index ae3f1cd4..b6fd8980 100644 --- a/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.html +++ b/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.html @@ -1,4 +1,4 @@ -