diff --git a/CHANGELOG.md b/CHANGELOG.md index b1e51bd..f739b6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,14 @@ version with its date and start a fresh empty `[Unreleased]` above it. ## [Unreleased] +### Fixed + +- The composer now adapts to narrow sidebars: context chips that do + not fit collapse behind a "+N more" pill (click to expand or + collapse), the toolbar wraps instead of clipping, and the permission + mode and model dropdowns shrink to stay inside the sidebar, with + long model names ellipsized. + ## [1.0.4] - 2026-08-12 ### Fixed diff --git a/src/features/chat/controllers/context-row-overflow.ts b/src/features/chat/controllers/context-row-overflow.ts new file mode 100644 index 0000000..77f1382 --- /dev/null +++ b/src/features/chat/controllers/context-row-overflow.ts @@ -0,0 +1,192 @@ +/** + * Collapses context chips that do not fit the current row width behind a + * "+N more" pill, so narrow sidebars degrade gracefully instead of + * clipping chips. Clicking the pill expands the row (wrapped) so hidden + * chips stay reachable; clicking again collapses it. + */ +export class ContextRowOverflowController { + private readonly rowEl: HTMLElement; + private readonly hostEl: HTMLElement; + private readonly pillEl: HTMLElement; + private readonly measureEl: HTMLElement; + private readonly resizeObserver: ResizeObserver; + private readonly mutationObserver: MutationObserver; + private layoutScheduled = false; + private expanded = false; + private destroyed = false; + + constructor(rowEl: HTMLElement) { + this.rowEl = rowEl; + // Measurement surface lives outside the observed subtree so measuring + // never re-triggers the mutation observer. + const host = rowEl.parentElement; + if (!host) throw new Error('ContextRowOverflowController requires an attached context row'); + this.hostEl = host; + + this.pillEl = rowEl.createDiv({ cls: 'qoderian-context-overflow-pill qoderian-hidden' }); + this.pillEl.setAttribute('role', 'button'); + this.pillEl.setAttribute('tabindex', '0'); + this.pillEl.addEventListener('click', () => this.toggleExpanded()); + this.pillEl.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + this.toggleExpanded(); + } + }); + + this.measureEl = this.hostEl.createDiv({ cls: 'qoderian-context-overflow-measure' }); + + this.resizeObserver = new ResizeObserver(() => this.scheduleLayout()); + this.resizeObserver.observe(rowEl); + + this.mutationObserver = new MutationObserver(() => this.scheduleLayout()); + this.mutationObserver.observe(rowEl, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['class'], + characterData: true, + }); + + this.scheduleLayout(); + } + + destroy(): void { + this.destroyed = true; + this.resizeObserver.disconnect(); + this.mutationObserver.disconnect(); + this.pillEl.remove(); + this.measureEl.remove(); + } + + private scheduleLayout(): void { + if (this.layoutScheduled) return; + this.layoutScheduled = true; + window.requestAnimationFrame(() => { + this.layoutScheduled = false; + if (!this.destroyed) this.layout(); + }); + } + + /** Content items are row children that are currently meant to be visible. */ + private contentItems(): HTMLElement[] { + return Array.from(this.rowEl.children).filter( + (el): el is HTMLElement => + el.instanceOf(HTMLElement) && el !== this.pillEl && !el.hasClass('qoderian-hidden') + ); + } + + private layout(): void { + const items = this.contentItems(); + + if (items.length === 0 || !this.rowEl.hasClass('has-content')) { + this.applyState(items, items.length, false); + return; + } + + const rowStyles = getComputedStyle(this.rowEl); + const available = + this.rowEl.clientWidth - + parseFloat(rowStyles.paddingLeft) - + parseFloat(rowStyles.paddingRight); + // Row not rendered (e.g. inactive tab): skip, ResizeObserver re-runs once visible. + if (available <= 0) return; + const gap = parseFloat(rowStyles.columnGap) || 0; + + const widths = this.measureWidths(items); + const total = widths.reduce((sum, width) => sum + width, 0) + gap * (items.length - 1); + + if (this.expanded) { + // Auto-collapse once everything fits on a single line again. + this.applyState(items, items.length, total > available); + return; + } + + if (total <= available) { + this.applyState(items, items.length, false); + return; + } + + // Keep as many leading chips as fit alongside the pill; when even one + // chip cannot fit, collapse everything behind the pill if the pill fits. + let visibleCount = 0; + for (let k = items.length - 1; k >= 1; k--) { + const pillWidth = this.measurePillWidth(items.length - k); + const used = + widths.slice(0, k).reduce((sum, width) => sum + width, 0) + + gap * (k - 1) + + gap + + pillWidth; + if (used <= available) { + visibleCount = k; + break; + } + } + if (visibleCount === 0 && this.measurePillWidth(items.length) > available) { + // Even the pill does not fit: show one chip rather than nothing. + visibleCount = 1; + } + + this.applyState(items, visibleCount, false); + } + + private toggleExpanded(): void { + this.expanded = !this.expanded; + this.layout(); + } + + /** Natural single-line widths, measured on clones so hidden items work too. */ + private measureWidths(items: HTMLElement[]): number[] { + this.measureEl.empty(); + const clones = items.map((item) => { + const clone = item.cloneNode(true) as HTMLElement; + clone.classList.remove('qoderian-context-overflow-hidden'); + this.measureEl.appendChild(clone); + return clone; + }); + const widths = clones.map(clone => clone.offsetWidth); + this.measureEl.empty(); + return widths; + } + + private measurePillWidth(hiddenCount: number): number { + this.measureEl.empty(); + const clone = this.pillEl.cloneNode(false) as HTMLElement; + clone.classList.remove('qoderian-hidden'); + clone.setText(this.pillLabel(hiddenCount)); + this.measureEl.appendChild(clone); + const width = clone.offsetWidth; + this.measureEl.empty(); + return width; + } + + private pillLabel(hiddenCount: number): string { + return this.expanded ? 'Show less' : `+${hiddenCount} more`; + } + + private applyState(items: HTMLElement[], visibleCount: number, expanded: boolean): void { + this.expanded = expanded && items.length > 0; + + items.forEach((el, index) => { + const shouldHide = !this.expanded && index >= visibleCount; + if (shouldHide !== el.hasClass('qoderian-context-overflow-hidden')) { + el.toggleClass('qoderian-context-overflow-hidden', shouldHide); + } + }); + + this.rowEl.toggleClass('qoderian-context-row--expanded', this.expanded); + + const hiddenCount = items.length - (this.expanded ? items.length : visibleCount); + const showPill = this.expanded || hiddenCount > 0; + + if (showPill) { + const label = this.pillLabel(hiddenCount); + if (this.pillEl.textContent !== label) this.pillEl.setText(label); + if (this.pillEl.hasClass('qoderian-hidden')) this.pillEl.removeClass('qoderian-hidden'); + // The pill always trails the chips. + if (this.pillEl.nextElementSibling) this.rowEl.appendChild(this.pillEl); + } else if (!this.pillEl.hasClass('qoderian-hidden')) { + this.pillEl.addClass('qoderian-hidden'); + } + } +} diff --git a/src/features/chat/tabs/tab-lifecycle.ts b/src/features/chat/tabs/tab-lifecycle.ts index ec3eda3..a84a4ef 100644 --- a/src/features/chat/tabs/tab-lifecycle.ts +++ b/src/features/chat/tabs/tab-lifecycle.ts @@ -28,6 +28,8 @@ export async function destroyTab(tab: TabData): Promise { tab.controllers.canvasSelectionController?.stop(); tab.controllers.canvasSelectionController?.clear(); tab.controllers.navigationController?.dispose(); + tab.controllers.contextRowOverflow?.destroy(); + tab.controllers.contextRowOverflow = null; cleanupThinkingBlock(tab.state.currentThinkingState); tab.state.currentThinkingState = null; diff --git a/src/features/chat/tabs/tab.ts b/src/features/chat/tabs/tab.ts index 6227d6b..ca180af 100644 --- a/src/features/chat/tabs/tab.ts +++ b/src/features/chat/tabs/tab.ts @@ -15,6 +15,7 @@ import { } from '../../../shared/components/slash-command-dropdown'; import { BrowserSelectionController } from '../controllers/browser-selection-controller'; import { CanvasSelectionController } from '../controllers/canvas-selection-controller'; +import { ContextRowOverflowController } from '../controllers/context-row-overflow'; import { ConversationController } from '../controllers/conversation-controller'; import { InputController } from '../controllers/input-controller'; import { NavigationController } from '../controllers/navigation-controller'; @@ -135,6 +136,7 @@ export function createTab(options: TabCreateOptions): TabData { streamController: null, inputController: null, navigationController: null, + contextRowOverflow: null, }, services: { subagentManager, @@ -514,6 +516,9 @@ export function initializeTabUI( 'network' ); + // Collapse chips into "+N more" when the sidebar is too narrow. + tab.controllers.contextRowOverflow = new ContextRowOverflowController(dom.contextRowEl); + const catalogInfo = options.getQoderCatalogConfig?.() ?? null; initializeSlashCommands( tab, diff --git a/src/features/chat/tabs/types.ts b/src/features/chat/tabs/types.ts index ce58be2..e1bbcc5 100644 --- a/src/features/chat/tabs/types.ts +++ b/src/features/chat/tabs/types.ts @@ -5,6 +5,7 @@ import type { AppTabManagerState, InstructionRefineService, TitleGenerationServi import type { SlashCommandDropdown } from '../../../shared/components/slash-command-dropdown'; import type { BrowserSelectionController } from '../controllers/browser-selection-controller'; import type { CanvasSelectionController } from '../controllers/canvas-selection-controller'; +import type { ContextRowOverflowController } from '../controllers/context-row-overflow'; import type { ConversationController } from '../controllers/conversation-controller'; import type { InputController } from '../controllers/input-controller'; import type { NavigationController } from '../controllers/navigation-controller'; @@ -97,6 +98,7 @@ export interface TabControllers { streamController: StreamController | null; inputController: InputController | null; navigationController: NavigationController | null; + contextRowOverflow: ContextRowOverflowController | null; } /** diff --git a/src/features/chat/ui/toolbar/toolbar-selectors.ts b/src/features/chat/ui/toolbar/toolbar-selectors.ts index 7a644f9..f5b7b60 100644 --- a/src/features/chat/ui/toolbar/toolbar-selectors.ts +++ b/src/features/chat/ui/toolbar/toolbar-selectors.ts @@ -183,7 +183,7 @@ export class ModelSelector { ownerDocument: option.ownerDocument, width: 12, })); - option.createSpan({ text: model.label }); + option.createSpan({ cls: 'qoderian-model-option-label', text: model.label }); if (model.promotionLabel || model.priceLabel) { const meta = option.createSpan({ cls: 'qoderian-model-meta' }); if (model.promotionLabel) { diff --git a/src/style/accessibility.css b/src/style/accessibility.css index 1c5248a..9899f1b 100644 --- a/src/style/accessibility.css +++ b/src/style/accessibility.css @@ -17,6 +17,7 @@ .qoderian-action-btn:focus-visible, .qoderian-file-chip:focus-visible, .qoderian-image-chip:focus-visible, +.qoderian-context-overflow-pill:focus-visible, .qoderian-file-chip-remove:focus-visible, .qoderian-image-remove:focus-visible, .qoderian-image-modal-close:focus-visible, diff --git a/src/style/components/input.css b/src/style/components/input.css index 85e8ac0..588084c 100644 --- a/src/style/components/input.css +++ b/src/style/components/input.css @@ -33,6 +33,7 @@ /* Collapsed by default; expanded via .has-content class; textarea fills remaining space */ .qoderian-context-row { display: none; + position: relative; align-items: flex-start; justify-content: flex-start; flex-shrink: 0; @@ -45,6 +46,50 @@ display: flex; } +/* Overflow collapse: chips that do not fit a narrow row are parked behind a + "+N more" pill. Hidden items stay measurable (absolute + invisible). */ +.qoderian-context-row > .qoderian-context-overflow-hidden { + position: absolute; + visibility: hidden; + pointer-events: none; +} + +/* Expanded state (pill clicked): wrap so every chip stays reachable. */ +.qoderian-context-row.qoderian-context-row--expanded { + flex-wrap: wrap; +} + +.qoderian-context-overflow-pill { + display: inline-flex; + align-items: center; + flex-shrink: 0; + padding: 3px 8px; + background: var(--background-modifier-hover); + border-radius: 12px; + font-size: 12px; + line-height: 1; + color: var(--text-muted); + cursor: pointer; +} + +.qoderian-context-overflow-pill:hover { + color: var(--text-normal); +} + +/* Off-screen surface used to measure natural chip widths. */ +.qoderian-context-overflow-measure { + position: absolute; + visibility: hidden; + display: flex; + flex-wrap: nowrap; + width: max-content; + pointer-events: none; +} + +.qoderian-context-overflow-measure > * { + flex: none; +} + /* Nav row (tab badges start, action icons end) - above input wrapper */ .qoderian-input-nav-row { display: flex; @@ -156,9 +201,12 @@ /* Input toolbar */ .qoderian-input-toolbar { + position: relative; display: flex; align-items: center; justify-content: flex-start; + flex-wrap: wrap; + row-gap: 2px; flex-shrink: 0; padding: 4px 6px 6px 6px; } diff --git a/src/style/toolbar/model-selector.css b/src/style/toolbar/model-selector.css index f778d99..734a163 100644 --- a/src/style/toolbar/model-selector.css +++ b/src/style/toolbar/model-selector.css @@ -1,12 +1,15 @@ /* Model selector */ .qoderian-model-selector { - position: relative; + min-width: 0; + max-width: 100%; } .qoderian-model-btn { display: flex; align-items: center; gap: 4px; + min-width: 0; + max-width: 100%; padding: 4px 8px; border-radius: 4px; cursor: pointer; @@ -20,6 +23,9 @@ .qoderian-model-label { font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .qoderian-model-chevron { @@ -35,7 +41,7 @@ .qoderian-model-dropdown { position: absolute; bottom: 100%; - left: 0; + right: 8px; margin-bottom: 0; display: flex; flex-direction: column; @@ -46,7 +52,7 @@ box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.15); z-index: 1000; width: max-content; - max-width: min(340px, calc(100vw - 24px)); + max-width: min(340px, calc(100% - 16px)); padding: 4px; opacity: 0; visibility: hidden; @@ -58,7 +64,7 @@ flex-direction: column; align-items: flex-start; gap: 6px; - width: 280px; + width: 100%; padding: 8px; color: var(--text-muted); white-space: normal; @@ -129,6 +135,7 @@ display: flex; align-items: center; gap: 5px; + min-width: 0; padding: 4px 8px; cursor: pointer; font-size: 12px; @@ -138,6 +145,11 @@ white-space: nowrap; } +.qoderian-model-option-label { + overflow: hidden; + text-overflow: ellipsis; +} + .qoderian-model-option:hover { background: var(--background-modifier-hover); color: var(--text-normal); @@ -156,6 +168,7 @@ gap: 5px; margin-left: auto; padding-left: 12px; + flex-shrink: 0; } .qoderian-model-price { diff --git a/src/style/toolbar/permission-toggle.css b/src/style/toolbar/permission-toggle.css index 288dfe3..2f8ac0b 100644 --- a/src/style/toolbar/permission-toggle.css +++ b/src/style/toolbar/permission-toggle.css @@ -1,6 +1,5 @@ /* Permission mode selector */ .qoderian-permission-toggle { - position: relative; display: flex; align-items: center; margin-left: auto; @@ -69,6 +68,7 @@ display: flex; flex-direction: column; width: 250px; + max-width: calc(100% - 16px); margin-bottom: 4px; padding: 4px; border: 1px solid var(--background-modifier-border); diff --git a/tests/unit/features/chat/controllers/context-row-overflow.test.ts b/tests/unit/features/chat/controllers/context-row-overflow.test.ts new file mode 100644 index 0000000..f669bce --- /dev/null +++ b/tests/unit/features/chat/controllers/context-row-overflow.test.ts @@ -0,0 +1,373 @@ +/** @jest-environment jsdom */ + +import { ContextRowOverflowController } from '@/features/chat/controllers/context-row-overflow'; + +/** + * Functional coverage for the narrow-sidebar chip collapse. jsdom has no + * layout, so widths are mocked: every element reports offsetWidth from its + * data-mock-width attribute (clones keep it), and the row reports a + * test-controlled clientWidth. + */ + +let rowClientWidth = 400; +let resizeCallback: (() => void) | null = null; + +class FakeResizeObserver { + constructor(callback: () => void) { + // Captured so tests can simulate a sidebar resize. + resizeCallback = callback; + } + + observe(): void {} + + disconnect(): void {} + + unobserve(): void {} +} + +function installDomMocks(): void { + const proto = HTMLElement.prototype as unknown as Record; + + if (!proto.hasClass) { + proto.hasClass = function hasClass(this: HTMLElement, cls: string) { + return this.classList.contains(cls); + }; + } + if (!proto.addClass) { + proto.addClass = function addClass(this: HTMLElement, cls: string) { + cls.split(/\s+/).filter(Boolean).forEach(c => this.classList.add(c)); + return this; + }; + } + if (!proto.removeClass) { + proto.removeClass = function removeClass(this: HTMLElement, cls: string) { + cls.split(/\s+/).filter(Boolean).forEach(c => this.classList.remove(c)); + return this; + }; + } + if (!proto.toggleClass) { + proto.toggleClass = function toggleClass(this: HTMLElement, cls: string, force: boolean) { + this.classList.toggle(cls, force); + return this; + }; + } + if (!proto.setText) { + proto.setText = function setText(this: HTMLElement, text: string) { + this.textContent = text; + }; + } + if (!proto.empty) { + proto.empty = function empty(this: HTMLElement) { + while (this.firstChild) this.removeChild(this.firstChild); + }; + } + if (!proto.createDiv) { + proto.createDiv = function createDiv(this: HTMLElement, opts?: { cls?: string }) { + const el = this.ownerDocument.createElement('div'); + if (opts?.cls) el.setAttribute('class', opts.cls); + this.appendChild(el); + return el; + }; + } + if (!proto.instanceOf) { + proto.instanceOf = function instanceOf(this: HTMLElement, type: unknown) { + return this instanceof (type as new () => HTMLElement); + }; + } + + Object.defineProperty(proto, 'offsetWidth', { + configurable: true, + get(this: HTMLElement) { + if (this.dataset.mockWidth !== undefined) return Number(this.dataset.mockWidth); + // Pills without an explicit mock width derive theirs from the label, + // so tests exercise the label-dependent measurement path. + if (this.classList.contains('qoderian-context-overflow-pill')) { + return 40 + (this.textContent ?? '').length * 6; + } + return 0; + }, + }); + + (globalThis as Record).ResizeObserver = FakeResizeObserver; +} + +function createChip(width: number): HTMLElement { + const chip = document.createElement('div'); + chip.className = 'qoderian-file-indicator qoderian-visible-flex'; + chip.dataset.mockWidth = String(width); + return chip; +} + +function createRow(): HTMLElement { + const wrapper = document.createElement('div'); + const row = document.createElement('div'); + row.className = 'qoderian-context-row has-content'; + row.style.paddingLeft = '10px'; + row.style.paddingRight = '10px'; + row.style.columnGap = '8px'; + wrapper.appendChild(row); + + Object.defineProperty(row, 'clientWidth', { + configurable: true, + get: () => rowClientWidth, + }); + return row; +} + +async function settle(): Promise { + // rAF-scheduled layout plus mutation-observer reschedules. + await new Promise(resolve => setTimeout(resolve, 40)); +} + +function readGap(row: HTMLElement): number { + return parseFloat(getComputedStyle(row).columnGap) || 0; +} + +describe('ContextRowOverflowController', () => { + beforeAll(installDomMocks); + + beforeEach(() => { + rowClientWidth = 400; + resizeCallback = null; + document.body.textContent = ''; + }); + + it('keeps every chip visible and hides the pill when the row is wide', async () => { + const row = createRow(); + const chips = [createChip(100), createChip(100), createChip(100)]; + chips.forEach(chip => row.appendChild(chip)); + + const controller = new ContextRowOverflowController(row); + await settle(); + + chips.forEach(chip => expect(chip.hasClass('qoderian-context-overflow-hidden')).toBe(false)); + const pill = row.querySelector('.qoderian-context-overflow-pill') as HTMLElement; + expect(pill).not.toBeNull(); + expect(pill.hasClass('qoderian-hidden')).toBe(true); + + controller.destroy(); + }); + + it('collapses overflowing chips into a "+N more" pill when narrow', async () => { + const row = createRow(); + const chips = [createChip(100), createChip(100), createChip(100)]; + chips.forEach(chip => row.appendChild(chip)); + + const controller = new ContextRowOverflowController(row); + await settle(); + + rowClientWidth = 200; // available 180: one 100px chip + gap + 70px pill fits + const pill = row.querySelector('.qoderian-context-overflow-pill') as HTMLElement; + pill.dataset.mockWidth = '70'; + + // Simulate the sidebar resize the ResizeObserver would report. + resizeCallback?.(); + await settle(); + + expect(chips[0].hasClass('qoderian-context-overflow-hidden')).toBe(false); + expect(chips[1].hasClass('qoderian-context-overflow-hidden')).toBe(true); + expect(chips[2].hasClass('qoderian-context-overflow-hidden')).toBe(true); + expect(pill.hasClass('qoderian-hidden')).toBe(false); + expect(pill.textContent).toBe('+2 more'); + + controller.destroy(); + }); + + it('expands on pill click and collapses again on second click', async () => { + const row = createRow(); + const chips = [createChip(100), createChip(100), createChip(100)]; + chips.forEach(chip => row.appendChild(chip)); + + const controller = new ContextRowOverflowController(row); + await settle(); + + rowClientWidth = 200; + const pill = row.querySelector('.qoderian-context-overflow-pill') as HTMLElement; + pill.dataset.mockWidth = '70'; + resizeCallback?.(); + await settle(); + + expect(pill.textContent).toBe('+2 more'); + + pill.click(); + await settle(); + + chips.forEach(chip => expect(chip.hasClass('qoderian-context-overflow-hidden')).toBe(false)); + expect(row.hasClass('qoderian-context-row--expanded')).toBe(true); + expect(pill.textContent).toBe('Show less'); + + pill.click(); + await settle(); + + expect(row.hasClass('qoderian-context-row--expanded')).toBe(false); + expect(chips[1].hasClass('qoderian-context-overflow-hidden')).toBe(true); + expect(pill.textContent).toBe('+2 more'); + + controller.destroy(); + }); + + it('updates the pill when chips are removed', async () => { + const row = createRow(); + const chips = [createChip(100), createChip(100), createChip(100)]; + chips.forEach(chip => row.appendChild(chip)); + + const controller = new ContextRowOverflowController(row); + await settle(); + + rowClientWidth = 200; + const pill = row.querySelector('.qoderian-context-overflow-pill') as HTMLElement; + pill.dataset.mockWidth = '70'; + resizeCallback?.(); + await settle(); + expect(pill.textContent).toBe('+2 more'); + + chips[2].remove(); + await settle(); + + expect(pill.textContent).toBe('+1 more'); + expect(chips[0].hasClass('qoderian-context-overflow-hidden')).toBe(false); + expect(chips[1].hasClass('qoderian-context-overflow-hidden')).toBe(true); + + controller.destroy(); + }); + + it('hides the pill again when the row widens', async () => { + const row = createRow(); + const chips = [createChip(100), createChip(100), createChip(100)]; + chips.forEach(chip => row.appendChild(chip)); + + const controller = new ContextRowOverflowController(row); + await settle(); + + rowClientWidth = 200; + const pill = row.querySelector('.qoderian-context-overflow-pill') as HTMLElement; + pill.dataset.mockWidth = '70'; + resizeCallback?.(); + await settle(); + expect(pill.hasClass('qoderian-hidden')).toBe(false); + + rowClientWidth = 400; + resizeCallback?.(); + await settle(); + + expect(pill.hasClass('qoderian-hidden')).toBe(true); + chips.forEach(chip => expect(chip.hasClass('qoderian-context-overflow-hidden')).toBe(false)); + + controller.destroy(); + }); + + it('reads the row gap from computed style', () => { + const row = createRow(); + expect(readGap(row)).toBe(8); + }); + + it('throws when the row is not attached to a host', () => { + const detached = document.createElement('div'); + expect(() => new ContextRowOverflowController(detached)).toThrow(); + }); + + it('derives pill width from its label and collapses everything when nothing fits', async () => { + const row = createRow(); + const chips = [createChip(100), createChip(100)]; + chips.forEach(chip => row.appendChild(chip)); + + const controller = new ContextRowOverflowController(row); + await settle(); + + // available 185: one chip (100) + gap (8) + "+1 more" pill (82) = 190 > 185, + // so even a single chip cannot share the row with the pill. + rowClientWidth = 205; + resizeCallback?.(); + await settle(); + + expect(chips[0].hasClass('qoderian-context-overflow-hidden')).toBe(true); + expect(chips[1].hasClass('qoderian-context-overflow-hidden')).toBe(true); + const pill = row.querySelector('.qoderian-context-overflow-pill') as HTMLElement; + expect(pill.textContent).toBe('+2 more'); + + controller.destroy(); + }); + + it('toggles expansion from the keyboard', async () => { + const row = createRow(); + const chips = [createChip(100), createChip(100), createChip(100)]; + chips.forEach(chip => row.appendChild(chip)); + + const controller = new ContextRowOverflowController(row); + await settle(); + + rowClientWidth = 200; + const pill = row.querySelector('.qoderian-context-overflow-pill') as HTMLElement; + pill.dataset.mockWidth = '70'; + resizeCallback?.(); + await settle(); + expect(pill.textContent).toBe('+2 more'); + + pill.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + await settle(); + expect(row.hasClass('qoderian-context-row--expanded')).toBe(true); + expect(pill.textContent).toBe('Show less'); + + pill.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + await settle(); + expect(row.hasClass('qoderian-context-row--expanded')).toBe(false); + expect(pill.textContent).toBe('+2 more'); + + controller.destroy(); + }); + + it('leaves state untouched while the row is not rendered', async () => { + const row = createRow(); + const chips = [createChip(100), createChip(100), createChip(100)]; + chips.forEach(chip => row.appendChild(chip)); + + const controller = new ContextRowOverflowController(row); + await settle(); + + rowClientWidth = 200; + const pill = row.querySelector('.qoderian-context-overflow-pill') as HTMLElement; + pill.dataset.mockWidth = '70'; + resizeCallback?.(); + await settle(); + expect(pill.textContent).toBe('+2 more'); + + // Simulate display:none (inactive tab): zero width must not re-collapse. + rowClientWidth = 0; + resizeCallback?.(); + await settle(); + + expect(pill.textContent).toBe('+2 more'); + expect(chips[0].hasClass('qoderian-context-overflow-hidden')).toBe(false); + expect(chips[1].hasClass('qoderian-context-overflow-hidden')).toBe(true); + + controller.destroy(); + }); + + it('auto-collapses an expanded row once everything fits again', async () => { + const row = createRow(); + const chips = [createChip(100), createChip(100), createChip(100)]; + chips.forEach(chip => row.appendChild(chip)); + + const controller = new ContextRowOverflowController(row); + await settle(); + + rowClientWidth = 200; + const pill = row.querySelector('.qoderian-context-overflow-pill') as HTMLElement; + pill.dataset.mockWidth = '70'; + resizeCallback?.(); + await settle(); + + pill.click(); + await settle(); + expect(row.hasClass('qoderian-context-row--expanded')).toBe(true); + + rowClientWidth = 400; + resizeCallback?.(); + await settle(); + + expect(row.hasClass('qoderian-context-row--expanded')).toBe(false); + expect(pill.hasClass('qoderian-hidden')).toBe(true); + + controller.destroy(); + }); +});