From b35bee6cd67922c58276536ea255cd5881d71d68 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:01:05 +0100 Subject: [PATCH 1/3] feat(log-levels): redesign header levels as responsive dropdown chips - render each captured debug level as a read-only VS Code dropdown-face chip using --vscode-settings-dropdown* tokens (filtering not wired yet; structured so the chip becomes the real dropdown later) - collapse overflow into a hard-right +N chevron control + menu panel of the hidden levels, computing how-many-fit synchronously from cached chip widths so the row reflows live during resize - align the log-title, first chip and first tab on a shared header guide via AppHeader inset, and bound width with LogViewer overflow-x: clip (avoids forcing an overflow-y scroll container) --- log-viewer/src/components/LogLevels.ts | 384 ++++++++++++++++-- log-viewer/src/components/NavBar.ts | 7 +- .../__tests__/logLevelsOverflow.test.ts | 35 ++ .../src/components/logLevelsOverflow.ts | 29 ++ log-viewer/src/features/app/AppHeader.ts | 4 + log-viewer/src/features/app/LogViewer.ts | 5 + 6 files changed, 426 insertions(+), 38 deletions(-) create mode 100644 log-viewer/src/components/__tests__/logLevelsOverflow.test.ts create mode 100644 log-viewer/src/components/logLevelsOverflow.ts diff --git a/log-viewer/src/components/LogLevels.ts b/log-viewer/src/components/LogLevels.ts index 6b0ccef3..fbe007ab 100644 --- a/log-viewer/src/components/LogLevels.ts +++ b/log-viewer/src/components/LogLevels.ts @@ -1,81 +1,391 @@ /* * Copyright (c) 2023 Certinia Inc. All rights reserved. */ -import '#vscode-elements/vscode-badge.js'; -import { LitElement, css, html } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; +import { LitElement, css, html, type PropertyValues, type TemplateResult } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; import { repeat } from 'lit/directives/repeat.js'; import type { DebugLevel } from 'apex-log-parser'; +// web components +import '#vscode-elements/vscode-icon.js'; +import './Divider.js'; + // styles import { globalStyles } from '../styles/global.styles.js'; import { skeletonStyles } from '../styles/skeleton.styles.js'; +import { computeVisibleCount } from './logLevelsOverflow.js'; + +/** Space reserved for the overflow button when the row can't fit every item (px). */ +const OVERFLOW_RESERVE = 56; +/** Gap between chips, in px — the source of truth for both the `.items` CSS gap and the fit math. */ +const ITEMS_GAP = 6; + +/** + * Read-only display of the log's captured debug levels in the app header: one chip per + * category (`CATEGORY LEVEL`), styled as a VS Code dropdown face (no chevron). As width + * shrinks, chips hide from the right one-by-one behind an overflow button (pinned + * hard-right) that reveals the hidden chips in a menu panel — updated live on resize. + * + * This is display only — filtering is not wired yet. When it is, these chips become + * interactive level dropdowns (see `_renderItem`). + * + * Chips stay mounted; overflow is hidden via CSS (never unmounted). How-many-fit is + * computed synchronously from cached chip widths on each resize, so the bar + overflow + * control update live during a drag (no async measure pass that blanks the row). + */ @customElement('log-levels') export class LogLevels extends LitElement { @property() logSettings: DebugLevel[] | null = null; - constructor() { - super(); - } + @state() + private open = false; + + /** How many leading items fit inline; the rest are CSS-hidden and shown in the panel. */ + @state() + private visibleCount = Number.POSITIVE_INFINITY; + + private resizeObserver?: ResizeObserver; + private lastWidth = -1; + /** Per-chip widths (px), measured once with all chips visible; drives sync overflow. */ + private itemWidths: number[] | null = null; + + private readonly _onDocumentClick = (event: MouseEvent): void => { + if (!event.composedPath().includes(this)) { + this.open = false; + } + }; static styles = [ globalStyles, skeletonStyles, css` :host { + display: block; + min-width: 0; + font-size: 11px; + } + + .container { + position: relative; + } + + .bar { display: flex; - flex-wrap: wrap; - gap: 4px; align-items: center; - font-family: var(--vscode-editor-font-family); + gap: 6px; + width: 100%; + } + + /* Items take the remaining space and clip; the overflow button is pushed hard-right. */ + .items { + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: ${ITEMS_GAP}px; + min-width: 0; + overflow: hidden; + flex: 1 1 auto; + } + + /* Matches the resting vscode-single-select face exactly (same tokens / 4px radius / 1px + border), so when filtering lands this chip gains a chevron + interactivity to become + that dropdown. */ + .lvl { + display: inline-flex; + align-items: baseline; + gap: 6px; + padding: 2px 6px; + border: 1px solid var(--vscode-settings-dropdownBorder, #3c3c3c); + border-radius: 4px; + background-color: var(--vscode-settings-dropdownBackground, #313131); + white-space: nowrap; + flex: 0 0 auto; + } + + .lvl.is-hidden { + display: none; + } + + .lvl__cat { + font-size: 10px; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--vscode-descriptionForeground); + } + + .lvl__val { + font-family: var(--vscode-editor-font-family, monospace); + font-weight: 600; + color: var(--vscode-foreground); + } + + /* Short rule separating the display chips from the interactive overflow control. */ + .sep { + height: 16px; + margin: 0 2px; + flex: 0 0 auto; + } + + /* The one interactive control in the row: same dropdown-face chip as the levels, but + with a count + chevron (the chevron marks it, and only it, as clickable). */ + .overflow { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 6px; + border: 1px solid var(--vscode-settings-dropdownBorder, #3c3c3c); + border-radius: 4px; + background-color: var(--vscode-settings-dropdownBackground, #313131); + color: var(--vscode-foreground); + font: inherit; + font-size: 11px; + line-height: 1; + white-space: nowrap; + cursor: pointer; + flex: 0 0 auto; + } + + .overflow:hover { + background-color: var(--vscode-list-hoverBackground); + } + + .overflow:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 1px; + } + + .overflow__count { + font-weight: 600; } - vscode-badge { - --vscode-badge-background: var(--vscode-textBlockQuote-background); - --vscode-badge-foreground: var(--vscode-editor-foreground); - --vscode-font-family: var(--vscode-editor-font-family); + .overflow__chevron { + color: var(--vscode-descriptionForeground); + transition: transform 120ms ease; + } + + .overflow.is-open .overflow__chevron { + transform: rotate(180deg); + } + + @media (prefers-reduced-motion: reduce) { + .overflow__chevron { + transition: none; + } + } - font-family: var(--vscode-editor-font-family); - font-size: 0.9rem; + /* Native-menu chrome for the pop-out. */ + .panel { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 999; + min-width: 200px; + max-width: min(92vw, 340px); + padding: 6px; + background-color: var(--vscode-menu-background, var(--vscode-editor-background)); + border: 1px solid var(--vscode-menu-border, var(--divider-background)); + border-radius: 6px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); + color: var(--vscode-menu-foreground, var(--vscode-foreground)); + font-family: var(--vscode-font-family); } - .setting__title { + .panel__head { + padding: 2px 10px 6px; font-weight: 600; - opacity: 0.9; + font-size: 12px; + color: var(--vscode-foreground); + } + + .row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + padding: 4px 10px; + border-radius: 4px; + } + + .row__cat { + color: var(--vscode-descriptionForeground); } - .setting__level { - color: var(--vscode-descriptionForeground, #808080); - font-weight: 500; + .row__val { + font-family: var(--vscode-editor-font-family, monospace); + font-weight: 600; + color: var(--vscode-foreground); } - .setting-skeleton { - min-width: 5ch; - width: 100px; - height: 1.5rem; + .item-skeleton { + width: 92px; + height: 18px; + border-radius: 4px; + flex: 0 0 auto; } `, ]; + connectedCallback(): void { + super.connectedCallback(); + document.addEventListener('click', this._onDocumentClick); + // Re-measure once webview fonts finish loading (chip widths can shift). + void document.fonts?.ready.then(() => { + this._resetMeasurement(); + this.requestUpdate(); + }); + this.resizeObserver = new ResizeObserver((entries) => { + const width = entries[0]?.contentRect.width ?? 0; + if (width === this.lastWidth) { + return; + } + this.lastWidth = width; + // Synchronous recompute from cached widths → live overflow on every resize frame. + if (this.itemWidths) { + this._recompute(width); + } else { + this.requestUpdate(); // no cache yet → trigger the initial measure in updated() + } + }); + this.resizeObserver.observe(this); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + document.removeEventListener('click', this._onDocumentClick); + this.resizeObserver?.disconnect(); + } + + /** Drop cached widths and show every chip, so the next render can re-measure them. */ + private _resetMeasurement(): void { + this.itemWidths = null; + this.visibleCount = Number.POSITIVE_INFINITY; + this.open = false; // nothing is hidden until re-measured, so close any open panel + } + + protected willUpdate(changed: PropertyValues): void { + if (changed.has('logSettings') && this.logSettings) { + this._resetMeasurement(); + } + } + + protected updated(): void { + if (this.itemWidths || !this.logSettings) { + return; + } + const items = this.renderRoot.querySelector('.items'); + const cells = items ? [...items.querySelectorAll('.lvl')] : []; + if (!cells.length) { + return; + } + // Plain spans render synchronously, so offsetWidth is accurate here (no rAF needed). + this.itemWidths = cells.map((el) => el.offsetWidth); + this._recompute(items!.clientWidth); + } + + /** Set `visibleCount` = how many leading chips fit in `avail` px, from cached widths. */ + private _recompute(avail: number): void { + const widths = this.itemWidths; + if (!widths) { + return; + } + this.visibleCount = computeVisibleCount(widths, avail, ITEMS_GAP, OVERFLOW_RESERVE); + if (this.visibleCount >= widths.length) { + this.open = false; // everything fits → no overflow control, so close the panel + } + } + + private _toggle(): void { + this.open = !this.open; + } + render() { if (!this.logSettings) { - const logLevels = []; - for (let i = 0; i < 8; i++) { - logLevels.push(html`
`); - } - return logLevels; + return html`
+
+ ${repeat( + Array.from({ length: 6 }), + (_, i) => i, + () => html`
`, + )} +
+
`; } - return html`${repeat( - this.logSettings, - ({ logCategory, logLevel }) => - html` - ${logCategory}: - ${logLevel} - `, - )}`; + const total = this.logSettings.length; + const visible = Math.min(this.visibleCount, total); + const hiddenItems = this.logSettings.slice(visible); + const hiddenCount = hiddenItems.length; + + return html`
+
+
+ ${repeat( + this.logSettings, + (s) => s.logCategory, + (s, i) => this._renderItem(s, i >= visible), + )} +
+ ${ + hiddenCount > 0 + ? html` + ` + : '' + } +
+ ${ + this.open && hiddenCount > 0 + ? html`
+
Log levels
+ ${repeat( + hiddenItems, + (s) => s.logCategory, + (s) => this._renderRow(s), + )} +
` + : '' + } +
`; + } + + /** + * MIGRATION TO FILTERS: each item is a read-only chip styled as a VS Code dropdown face + * (no chevron). To make levels filterable, this is the only render seam that changes: + * 1. swap the `.lvl` chip for `` (chevron returns) — both here and in + * `_renderRow` (the overflow panel is the controls' second home); + * 2. reintroduce level ordering + ceiling helpers (removed `logLevelsFormat.ts`: + * `LEVEL_ORDER`/`rankOf`/`ceilingHint`) and the `VsSelect` compact + disabled-option + * tooltip additions; + * 3. add a `filters` Map (per-category chosen level) + a `@change` handler that emits + * `log-levels-change`, and have views subscribe to hide events above the threshold. + * The category/level markup stays the same, so overflow measurement + alignment are unaffected. + */ + private _renderItem(setting: DebugLevel, hidden: boolean): TemplateResult { + return html` + ${setting.logCategory} + ${setting.logLevel} + `; + } + + private _renderRow(setting: DebugLevel): TemplateResult { + return html`
+ ${setting.logCategory} + ${setting.logLevel} +
`; } } diff --git a/log-viewer/src/components/NavBar.ts b/log-viewer/src/components/NavBar.ts index 8cbcae17..517caaba 100644 --- a/log-viewer/src/components/NavBar.ts +++ b/log-viewer/src/components/NavBar.ts @@ -71,6 +71,12 @@ export class NavBar extends LitElement { flex: 1 1 auto; } + /* Cancel log-title's inner 6px so its text sits on the shared header left guide + (the hover background keeps its padding). */ + .navbar--left > log-title { + margin-left: -6px; + } + .navbar--left-meta { display: flex; align-items: center; @@ -83,7 +89,6 @@ export class NavBar extends LitElement { display: flex; align-items: center; gap: 4px; - padding-right: 4px; flex: 0 0 auto; } `, diff --git a/log-viewer/src/components/__tests__/logLevelsOverflow.test.ts b/log-viewer/src/components/__tests__/logLevelsOverflow.test.ts new file mode 100644 index 00000000..82ca8c37 --- /dev/null +++ b/log-viewer/src/components/__tests__/logLevelsOverflow.test.ts @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { computeVisibleCount } from '../logLevelsOverflow.js'; + +describe('computeVisibleCount', () => { + const widths = [50, 50, 50]; // gap 10 → cumulative right edges: 50, 110, 170 + + it('shows all chips (no reserve) when they all fit', () => { + expect(computeVisibleCount(widths, 200, 10, 20)).toBe(3); + }); + + it('reserves space for the overflow control once something overflows', () => { + // fits(150) = 2, but not all → recompute against 150 - reserve(20) = 130 → still 2 + expect(computeVisibleCount(widths, 150, 10, 20)).toBe(2); + // a tighter reserve can drop another chip: fits(150)=2, fits(150-45=105)=2? 50,110>105 → 1 + expect(computeVisibleCount(widths, 150, 10, 45)).toBe(1); + }); + + it('can hide every chip when nothing fits', () => { + expect(computeVisibleCount(widths, 40, 10, 20)).toBe(0); + }); + + it('handles a single chip and an empty list', () => { + expect(computeVisibleCount([50], 100, 10, 20)).toBe(1); + expect(computeVisibleCount([], 100, 10, 20)).toBe(0); + }); + + it('treats an exact fit as fitting (inclusive boundary)', () => { + // cumulative for all three is exactly 170 + expect(computeVisibleCount(widths, 170, 10, 20)).toBe(3); + // one px short → last chip overflows, reserve applies + expect(computeVisibleCount(widths, 169, 10, 0)).toBe(2); + }); +}); diff --git a/log-viewer/src/components/logLevelsOverflow.ts b/log-viewer/src/components/logLevelsOverflow.ts new file mode 100644 index 00000000..9181a7bf --- /dev/null +++ b/log-viewer/src/components/logLevelsOverflow.ts @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +/** + * How many leading chips fit in `avail` px, given their measured `widths` and the `gap` + * between them. If they don't all fit, `reserve` px are held back for the overflow + * control before counting. Pure (no DOM) so it can be unit-tested. + */ +export function computeVisibleCount( + widths: readonly number[], + avail: number, + gap: number, + reserve: number, +): number { + const fits = (limit: number): number => { + let used = 0; + let n = 0; + for (const w of widths) { + used += (n > 0 ? gap : 0) + w; + if (used > limit) { + break; + } + n++; + } + return n; + }; + return fits(avail) === widths.length ? widths.length : fits(avail - reserve); +} diff --git a/log-viewer/src/features/app/AppHeader.ts b/log-viewer/src/features/app/AppHeader.ts index 034006af..214c04e1 100644 --- a/log-viewer/src/features/app/AppHeader.ts +++ b/log-viewer/src/features/app/AppHeader.ts @@ -44,6 +44,10 @@ export class AppHeader extends LitElement { display: flex; flex-direction: column; gap: 2px; + min-width: 0; + /* Match the tabs' box inset (LogViewer 8px + this 8px == tabs' 8px + their internal + 8px) so the log-title, first level chip, and first tab share one left/right guide. */ + padding: 0 8px; } `, ]; diff --git a/log-viewer/src/features/app/LogViewer.ts b/log-viewer/src/features/app/LogViewer.ts index ae91b225..caee28c6 100644 --- a/log-viewer/src/features/app/LogViewer.ts +++ b/log-viewer/src/features/app/LogViewer.ts @@ -70,6 +70,11 @@ export class LogViewer extends LitElement { display: flex; flex-direction: column; height: 100%; + min-width: 0; + /* keep the layout bounded to the viewport so header children (e.g. the + log-levels row) can detect overflow rather than widening the page. + clip (not hidden) avoids forcing overflow-y to auto. */ + overflow-x: clip; padding: 0px 8px 0px 8px; } From da861848dffee80042c0bacdf65bd91cc84a7ab6 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:28:35 +0100 Subject: [PATCH 2/3] refactor(log-levels): replace panel toggle JS with native popover - swap hand-rolled open state + _toggle + document click-outside for the native popover API (popovertarget/[popover]), gaining light-dismiss, Escape and a11y for free - position the pop-out via CSS anchor positioning (anchor-name/position-area) in the top layer, escaping the header's overflow-x: clip - drive the chevron via .container:has([popover]:popover-open) instead of an is-open class - hoist the shared popover id to PANEL_ID so button/panel stay in sync --- log-viewer/src/components/LogLevels.ts | 55 ++++++++++---------------- 1 file changed, 20 insertions(+), 35 deletions(-) diff --git a/log-viewer/src/components/LogLevels.ts b/log-viewer/src/components/LogLevels.ts index fbe007ab..4e729f4d 100644 --- a/log-viewer/src/components/LogLevels.ts +++ b/log-viewer/src/components/LogLevels.ts @@ -21,6 +21,8 @@ import { computeVisibleCount } from './logLevelsOverflow.js'; const OVERFLOW_RESERVE = 56; /** Gap between chips, in px — the source of truth for both the `.items` CSS gap and the fit math. */ const ITEMS_GAP = 6; +/** Shared by the overflow button's `popovertarget` and the panel's `id` — must match. */ +const PANEL_ID = 'log-levels-panel'; /** * Read-only display of the log's captured debug levels in the app header: one chip per @@ -40,9 +42,6 @@ export class LogLevels extends LitElement { @property() logSettings: DebugLevel[] | null = null; - @state() - private open = false; - /** How many leading items fit inline; the rest are CSS-hidden and shown in the panel. */ @state() private visibleCount = Number.POSITIVE_INFINITY; @@ -52,12 +51,6 @@ export class LogLevels extends LitElement { /** Per-chip widths (px), measured once with all chips visible; drives sync overflow. */ private itemWidths: number[] | null = null; - private readonly _onDocumentClick = (event: MouseEvent): void => { - if (!event.composedPath().includes(this)) { - this.open = false; - } - }; - static styles = [ globalStyles, skeletonStyles, @@ -68,10 +61,6 @@ export class LogLevels extends LitElement { font-size: 11px; } - .container { - position: relative; - } - .bar { display: flex; align-items: center; @@ -146,6 +135,7 @@ export class LogLevels extends LitElement { white-space: nowrap; cursor: pointer; flex: 0 0 auto; + anchor-name: --log-levels-overflow; } .overflow:hover { @@ -166,7 +156,8 @@ export class LogLevels extends LitElement { transition: transform 120ms ease; } - .overflow.is-open .overflow__chevron { + /* Native popover drives open/close; :has() flips the chevron while it's shown. */ + .container:has([popover]:popover-open) .overflow__chevron { transform: rotate(180deg); } @@ -176,12 +167,14 @@ export class LogLevels extends LitElement { } } - /* Native-menu chrome for the pop-out. */ + /* Native-menu chrome for the pop-out. Top-layer popover, anchored bottom-right to + the overflow button (escapes the header's overflow-x: clip). */ .panel { - position: absolute; - top: calc(100% + 6px); - right: 0; - z-index: 999; + position: fixed; + position-anchor: --log-levels-overflow; + position-area: bottom span-left; + inset: auto; + margin: 6px 0 0 0; min-width: 200px; max-width: min(92vw, 340px); padding: 6px; @@ -230,7 +223,6 @@ export class LogLevels extends LitElement { connectedCallback(): void { super.connectedCallback(); - document.addEventListener('click', this._onDocumentClick); // Re-measure once webview fonts finish loading (chip widths can shift). void document.fonts?.ready.then(() => { this._resetMeasurement(); @@ -254,7 +246,6 @@ export class LogLevels extends LitElement { disconnectedCallback(): void { super.disconnectedCallback(); - document.removeEventListener('click', this._onDocumentClick); this.resizeObserver?.disconnect(); } @@ -262,7 +253,8 @@ export class LogLevels extends LitElement { private _resetMeasurement(): void { this.itemWidths = null; this.visibleCount = Number.POSITIVE_INFINITY; - this.open = false; // nothing is hidden until re-measured, so close any open panel + // Nothing is hidden until re-measured, so the overflow button (and its popover) + // unmount — the native popover closes itself when its invoker leaves the DOM. } protected willUpdate(changed: PropertyValues): void { @@ -292,13 +284,6 @@ export class LogLevels extends LitElement { return; } this.visibleCount = computeVisibleCount(widths, avail, ITEMS_GAP, OVERFLOW_RESERVE); - if (this.visibleCount >= widths.length) { - this.open = false; // everything fits → no overflow control, so close the panel - } - } - - private _toggle(): void { - this.open = !this.open; } render() { @@ -318,6 +303,7 @@ export class LogLevels extends LitElement { const visible = Math.min(this.visibleCount, total); const hiddenItems = this.logSettings.slice(visible); const hiddenCount = hiddenItems.length; + const hasOverflow = hiddenCount > 0; return html`
@@ -329,14 +315,13 @@ export class LogLevels extends LitElement { )}
${ - hiddenCount > 0 + hasOverflow ? html` ` - : '' - } -
- ${ - hasOverflow - ? html`
-
Log levels
- ${repeat( - hiddenItems, - (s) => s.logCategory, - (s) => this._renderRow(s), - )} -
` - : '' - } - `; - } - - /** - * MIGRATION TO FILTERS: each item is a read-only chip styled as a VS Code dropdown face - * (no chevron). To make levels filterable, this is the only render seam that changes: - * 1. swap the `.lvl` chip for `` (chevron returns) — both here and in - * `_renderRow` (the overflow panel is the controls' second home); - * 2. reintroduce level ordering + ceiling helpers (removed `logLevelsFormat.ts`: - * `LEVEL_ORDER`/`rankOf`/`ceilingHint`) and the `VsSelect` compact + disabled-option - * tooltip additions; - * 3. add a `filters` Map (per-category chosen level) + a `@change` handler that emits - * `log-levels-change`, and have views subscribe to hide events above the threshold. - * The category/level markup stays the same, so overflow measurement + alignment are unaffected. - */ - private _renderItem(setting: DebugLevel, hidden: boolean): TemplateResult { - return html` - ${setting.logCategory} - ${setting.logLevel} - `; - } - - private _renderRow(setting: DebugLevel): TemplateResult { - return html`
- ${setting.logCategory} - ${setting.logLevel} -
`; + return html` + ${repeat( + this.logSettings, + (s) => s.logCategory, + (s) => html`${s.logCategory}${s.logLevel}`, + )} + `; } } diff --git a/log-viewer/src/components/OverflowList.ts b/log-viewer/src/components/OverflowList.ts new file mode 100644 index 00000000..ce1079b0 --- /dev/null +++ b/log-viewer/src/components/OverflowList.ts @@ -0,0 +1,331 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { LitElement, css, html, type PropertyValues } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; + +// web components +import '#vscode-elements/vscode-icon.js'; +import './Divider.js'; + +// styles +import { globalStyles } from '../styles/global.styles.js'; + +import { computeVisibleCount } from './overflowFit.js'; + +/** Space reserved for the overflow toggle once the row can't fit every item (px). */ +const OVERFLOW_RESERVE = 56; +/** Gap between items, in px — the source of truth for both the `.items` CSS gap and the fit math. */ +const ITEMS_GAP = 6; +/** Shared by the toggle's `popovertarget`/`aria-controls` and the panel's `id` — must match. */ +const PANEL_ID = 'overflow-list-panel'; + +/** + * Slot-based responsive overflow row (a.k.a. Priority+ navigation): renders slotted children + * inline, and as width shrinks collapses the ones that don't fit behind a `+N` toggle that + * reveals them in a native popover menu — updated live on resize. + * + * Slotted children are the items. Because an element can occupy only one slot, overflowing + * children are *moved* into the popover (via `slot="overflow"`) rather than duplicated, so + * the menu shows exactly the hidden items. How-many-fit is computed synchronously from cached + * child widths (measured once, all inline), so the row updates live during a drag with no + * reflow loop. + * + * API mirrors Blueprint/Mantine `OverflowList`: `collapse-from`, `min-visible`, `menu-heading`. + * `part="toggle"`/`part="menu"` expose the control + panel for external styling. + */ +@customElement('overflow-list') +export class OverflowList extends LitElement { + /** Popover title and panel `aria-label`. */ + @property({ attribute: 'menu-heading' }) + menuHeading = ''; + + /** Which end collapses into the menu. Static per instance. */ + @property({ attribute: 'collapse-from' }) + collapseFrom: 'end' | 'start' = 'end'; + + /** Never collapse this many items, even if they clip. */ + @property({ attribute: 'min-visible', type: Number }) + minVisible = 0; + + /** How many items fit inline; the rest are moved into the popover menu. */ + @state() + private visibleCount = Number.POSITIVE_INFINITY; + + private resizeObserver?: ResizeObserver; + private mutationObserver?: MutationObserver; + private lastWidth = -1; + /** Per-item widths (px), measured once with all items inline; drives sync overflow. */ + private itemWidths: number[] | null = null; + + static styles = [ + globalStyles, + css` + :host { + display: block; + min-width: 0; + font-size: 11px; + } + + .bar { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + } + + /* Items take the remaining space and clip; the toggle is pushed to the collapse end. */ + .items { + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: ${ITEMS_GAP}px; + min-width: 0; + overflow: hidden; + flex: 1 1 auto; + } + + /* Short rule separating the items from the interactive toggle. */ + .sep { + height: 16px; + margin: 0 2px; + flex: 0 0 auto; + } + + /* The one interactive control: a dropdown-face chip with a count + chevron. */ + .overflow { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 6px; + border: 1px solid var(--vscode-settings-dropdownBorder, #3c3c3c); + border-radius: 4px; + background-color: var(--vscode-settings-dropdownBackground, #313131); + color: var(--vscode-foreground); + font: inherit; + font-size: 11px; + line-height: 1; + white-space: nowrap; + cursor: pointer; + flex: 0 0 auto; + anchor-name: --overflow-list-toggle; + } + + .overflow:hover { + background-color: var(--vscode-list-hoverBackground); + } + + .overflow:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 1px; + } + + .overflow__count { + font-weight: 600; + } + + .overflow__chevron { + color: var(--vscode-descriptionForeground); + transition: transform 120ms ease; + } + + /* Native popover drives open/close; :has() flips the chevron while it's shown. */ + .container:has([popover]:popover-open) .overflow__chevron { + transform: rotate(180deg); + } + + @media (prefers-reduced-motion: reduce) { + .overflow__chevron { + transition: none; + } + } + + /* Native-menu chrome for the pop-out. Top-layer popover anchored to the toggle + (escapes any ancestor overflow clip); side follows collapse-from. */ + .panel { + position: fixed; + position-anchor: --overflow-list-toggle; + inset: auto; + margin: 6px 0 0 0; + min-width: 200px; + max-width: min(92vw, 340px); + padding: 6px; + background-color: var(--vscode-menu-background, var(--vscode-editor-background)); + border: 1px solid var(--vscode-menu-border, var(--divider-background)); + border-radius: 6px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); + color: var(--vscode-menu-foreground, var(--vscode-foreground)); + font-family: var(--vscode-font-family); + } + + .container.end .panel { + position-area: bottom span-left; + } + + .container.start .panel { + position-area: bottom span-right; + } + + .panel__head { + padding: 2px 10px 6px; + font-weight: 600; + font-size: 12px; + color: var(--vscode-foreground); + } + + .menu-items { + display: flex; + flex-direction: column; + gap: 4px; + align-items: flex-start; + padding: 0 4px 2px; + } + `, + ]; + + connectedCallback(): void { + super.connectedCallback(); + // Re-measure once webview fonts finish loading (item widths can shift). + void document.fonts?.ready.then(() => { + this._resetMeasurement(); + this.requestUpdate(); + }); + this.resizeObserver = new ResizeObserver((entries) => { + const width = entries[0]?.contentRect.width ?? 0; + if (width === this.lastWidth) { + return; + } + this.lastWidth = width; + // Synchronous recompute from cached widths → live overflow on every resize frame. + if (this.itemWidths) { + this._recompute(width); + } else { + this.requestUpdate(); // no cache yet → trigger the initial measure in updated() + } + }); + this.resizeObserver.observe(this); + // Items are light-DOM children, not a reactive property, so watch for set changes here. + this.mutationObserver = new MutationObserver(() => { + this._resetMeasurement(); + this.requestUpdate(); + }); + this.mutationObserver.observe(this, { childList: true }); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + this.resizeObserver?.disconnect(); + this.mutationObserver?.disconnect(); + } + + /** Drop cached widths and show every item, so the next render can re-measure them. */ + private _resetMeasurement(): void { + this.itemWidths = null; + this.visibleCount = Number.POSITIVE_INFINITY; + for (const child of this.children) { + child.removeAttribute('slot'); // all inline for the re-measure pass + } + } + + protected updated(changed: PropertyValues): void { + // The MutationObserver reset is async; if a reactive update lands first after a child + // add/remove, drop the now-stale width cache here so this pass re-measures. + if (this.itemWidths && this.itemWidths.length !== this.childElementCount) { + this.itemWidths = null; + } + if (!this.itemWidths) { + this._measure(); + } else if (changed.has('collapseFrom') || changed.has('minVisible')) { + this._recompute(this.lastWidth); + } + } + + /** Measure every item's natural width (all inline), cache it, then compute the fit. */ + private _measure(): void { + const items = this.renderRoot.querySelector('.items'); + const children = [...this.children] as HTMLElement[]; + if (!items || !children.length) { + return; + } + for (const child of children) { + child.removeAttribute('slot'); + } + // Custom elements upgrade synchronously (defined before use), so offsetWidth is accurate. + this.itemWidths = children.map((el) => el.offsetWidth); + this.lastWidth = items.clientWidth; + this._recompute(this.lastWidth); + } + + /** Set `visibleCount` from cached widths + `collapse-from`/`min-visible`, then re-slot. */ + private _recompute(avail: number): void { + const widths = this.itemWidths; + if (!widths || avail < 0) { + return; + } + // Fit the visible run from the appropriate end (reverse for start-collapse). + const ordered = this.collapseFrom === 'start' ? [...widths].reverse() : widths; + const fit = computeVisibleCount(ordered, avail, ITEMS_GAP, OVERFLOW_RESERVE); + this.visibleCount = Math.max(fit, Math.min(this.minVisible, widths.length)); + this._applyOverflow(); + } + + /** Move the hidden children into the overflow slot; keep the visible ones inline. */ + private _applyOverflow(): void { + const total = this.childElementCount; + const visible = Math.min(this.visibleCount, total); + const hidden = total - visible; + const children = [...this.children]; + children.forEach((child, i) => { + const isHidden = this.collapseFrom === 'end' ? i >= visible : i < hidden; + if (isHidden) { + child.setAttribute('slot', 'overflow'); + } else { + child.removeAttribute('slot'); + } + }); + } + + render() { + const total = this.childElementCount; + const hiddenCount = total - Math.min(this.visibleCount, total); + const fromStart = this.collapseFrom === 'start'; + const sep = html``; + const button = html``; + // Toggle pins to the collapse end, with the divider always adjacent to the items. + const toggle = + hiddenCount > 0 ? (fromStart ? html`${button}${sep}` : html`${sep}${button}`) : ''; + + return html`
+
+ ${fromStart ? toggle : ''} +
+ ${fromStart ? '' : toggle} +
+ ${ + hiddenCount > 0 + ? html`
+ ${this.menuHeading ? html`
${this.menuHeading}
` : ''} + +
` + : '' + } +
`; + } +} diff --git a/log-viewer/src/components/VsChip.ts b/log-viewer/src/components/VsChip.ts new file mode 100644 index 00000000..f6ed819b --- /dev/null +++ b/log-viewer/src/components/VsChip.ts @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { LitElement, css, html } from 'lit'; +import { customElement } from 'lit/decorators.js'; + +import { globalStyles } from '../styles/global.styles.js'; + +/** + * Compact chip styled as a resting VS Code dropdown face (1px border, 4px radius, dropdown + * tokens). Two slots: `lead` for a small uppercase label, and the default slot for the + * value (mono). Presentational only — no interactivity. + * + * The host is the face; slotted value text inherits the host's mono font, while the `lead` + * slot is restyled via `::slotted`. + */ +@customElement('vs-chip') +export class VsChip extends LitElement { + static styles = [ + globalStyles, + css` + :host { + display: inline-flex; + flex: 0 0 auto; + align-items: baseline; + gap: 6px; + padding: 2px 6px; + border: 1px solid var(--vscode-settings-dropdownBorder, #3c3c3c); + border-radius: 4px; + background-color: var(--vscode-settings-dropdownBackground, #313131); + white-space: nowrap; + font-family: var(--vscode-editor-font-family, monospace); + font-weight: 600; + font-size: 11px; + color: var(--vscode-foreground); + } + + /* Small uppercase leading label; overrides the host's mono/value styling. */ + ::slotted([slot='lead']) { + font-family: var(--vscode-font-family); + font-size: 10px; + font-weight: 400; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--vscode-descriptionForeground); + } + `, + ]; + + render() { + return html``; + } +} diff --git a/log-viewer/src/components/__tests__/logLevelsOverflow.test.ts b/log-viewer/src/components/__tests__/overflowFit.test.ts similarity index 95% rename from log-viewer/src/components/__tests__/logLevelsOverflow.test.ts rename to log-viewer/src/components/__tests__/overflowFit.test.ts index 82ca8c37..2bc11e63 100644 --- a/log-viewer/src/components/__tests__/logLevelsOverflow.test.ts +++ b/log-viewer/src/components/__tests__/overflowFit.test.ts @@ -1,7 +1,7 @@ /* * Copyright (c) 2026 Certinia Inc. All rights reserved. */ -import { computeVisibleCount } from '../logLevelsOverflow.js'; +import { computeVisibleCount } from '../overflowFit.js'; describe('computeVisibleCount', () => { const widths = [50, 50, 50]; // gap 10 → cumulative right edges: 50, 110, 170 diff --git a/log-viewer/src/components/logLevelsOverflow.ts b/log-viewer/src/components/overflowFit.ts similarity index 91% rename from log-viewer/src/components/logLevelsOverflow.ts rename to log-viewer/src/components/overflowFit.ts index 9181a7bf..e32aa87a 100644 --- a/log-viewer/src/components/logLevelsOverflow.ts +++ b/log-viewer/src/components/overflowFit.ts @@ -3,7 +3,7 @@ */ /** - * How many leading chips fit in `avail` px, given their measured `widths` and the `gap` + * How many leading items fit in `avail` px, given their measured `widths` and the `gap` * between them. If they don't all fit, `reserve` px are held back for the overflow * control before counting. Pure (no DOM) so it can be unit-tested. */