diff --git a/apps/desktop/src/design.css b/apps/desktop/src/design.css index 1cbc416..5e7a471 100644 --- a/apps/desktop/src/design.css +++ b/apps/desktop/src/design.css @@ -110,7 +110,7 @@ .ds-hero.compact .ds-hero-ids .store-badge { height: 26px; } .ds-hero-cta { margin-left: auto; flex: none; } @media (max-width: 1500px) { .ds-hero { grid-template-columns: minmax(0, 1fr); } .ds-hero-shots { display: none; } } -@media (max-width: 1560px) { .ds-tabs .ds-tab { padding: 0 10px; gap: 6px; } .ds-tabs .ds-tab svg { width: 15px; height: 15px; } } + @media (max-width: 900px) { .ds-hero-pipe { grid-template-columns: minmax(0, 1fr); } .ds-hero-foot { justify-items: start; } } /* ---------- app page shell ---------- */ @@ -119,7 +119,7 @@ .ds-app-sticky { position: sticky; top: -1px; z-index: 4; margin: 0 -28px; padding: 8px 28px; background: color-mix(in srgb, var(--bg) 82%, transparent); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); } /* ---------- Tabs v2 (pill) ---------- */ -.ds-tabs { display: flex; gap: 4px; padding: 4px; border-radius: var(--ds-r); background: var(--ds-inset); border: 1px solid var(--ds-stroke); flex-wrap: wrap; width: max-content; max-width: 100%; box-sizing: border-box; } +.ds-tabs { display: flex; gap: 4px; padding: 4px; border-radius: var(--ds-r); background: var(--ds-inset); border: 1px solid var(--ds-stroke); flex-wrap: nowrap; overflow: hidden; width: max-content; max-width: 100%; box-sizing: border-box; } .ds-tabs::-webkit-scrollbar { display: none; } .ds-tab { all: unset; flex: none; display: inline-flex; align-items: center; gap: 7px; height: 34px; padding: 0 14px; border-radius: 10px; font-weight: 600; color: var(--muted); cursor: pointer; white-space: nowrap; transition: background var(--speed) var(--ease), color var(--speed) var(--ease), box-shadow var(--speed) var(--ease); } .ds-tab svg { width: 16px; height: 16px; } @@ -233,3 +233,7 @@ :root[data-motion="reduce"] .ds-menu { transition: none; transform: none; } /* headings focused by code (dialog / wizard step titles) for screen readers: no ring */ [tabindex="-1"]:focus:not(:focus-visible), h1[tabindex="-1"]:focus, h2[tabindex="-1"]:focus { outline: none; } + +.ds-tab[hidden] { display: none; } +.ds-tab-more svg:last-child { width: 12px; height: 12px; } +@media (max-width: 1560px) { .ds-tabs .ds-tab { padding: 0 10px; gap: 6px; } .ds-tabs .ds-tab svg { width: 15px; height: 15px; } } diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 075a4a0..492149c 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -69,6 +69,8 @@ export const en = { 'kpi.more': "+{count} more", 'pf.noShots': "No screenshots in the app folder yet", 'pf.noShotsStore': "Store only: no app folder with screenshots", + 'tabs.more': "{count} more tabs", + 'tabs.moreShort': "More", 'palette.gActions': 'Actions', 'palette.openApp': 'Open', diff --git a/apps/desktop/src/i18n/tr.ts b/apps/desktop/src/i18n/tr.ts index 0b94e93..1d73311 100644 --- a/apps/desktop/src/i18n/tr.ts +++ b/apps/desktop/src/i18n/tr.ts @@ -71,6 +71,8 @@ export const tr: Record = { 'kpi.more': "+{count} tane daha", 'pf.noShots': "Uygulama klasöründe henüz ekran görüntüsü yok", 'pf.noShotsStore': "Yalnız mağazada: ekran görüntülü uygulama klasörü yok", + 'tabs.more': "{count} sekme daha", + 'tabs.moreShort': "Daha fazla", 'palette.gActions': 'İşlemler', 'palette.openApp': 'Aç', diff --git a/apps/desktop/src/ui/ds.tsx b/apps/desktop/src/ui/ds.tsx index bc36d96..bff3d8a 100644 --- a/apps/desktop/src/ui/ds.tsx +++ b/apps/desktop/src/ui/ds.tsx @@ -2,7 +2,7 @@ // the portfolio home as reusable parts. PageHeader, Hero (app hero with screenshots, two-store pipeline, next step), // Section, StatTile, Pipeline, StatusPill, EmptyState, Tabs, Toolbar, Dialog, plus the per-app tint (useTint / tintStyle) // and the rendered-screenshot loader (useShots) the cards and the hero share. -import { type CSSProperties, type KeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactNode, useEffect, useId, useRef, useState } from 'react'; +import { type CSSProperties, type KeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactNode, useEffect, useId, useLayoutEffect, useRef, useState } from 'react'; import { api, reads, type RenderImage, type StoreRelease, type ToolResult } from '../api'; import { type Key, t } from '../i18n'; import { share } from '../lib/format'; @@ -10,6 +10,8 @@ import { type Attention, hueOf, laneOf, STAGES, storeNow, type Tone } from '../l import type { AppTab } from '../nav'; import { Icon, type IconName, StoreGlyph } from './icons'; import { AppIcon } from './kit'; +import { useMenuButton } from './ContextMenu'; +import { fitTabs } from './tabs-fit'; // ---- per-app tint ---- @@ -275,33 +277,65 @@ export function Toolbar({ label, children }: { label: string; children: ReactNod export interface TabItem { id: T; label: string; icon?: IconName; count?: number | null; dot?: boolean; dotTitle?: string } -/** Pill tabs with icons and counts; arrows / Home / End move between them (roving tabindex). */ +/** Pill tabs with icons and counts; arrows / Home / End move between them (roving tabindex). One row always: tabs that + * do not fit go into a "More" menu at the end, the active tab always stays in the row. */ export function Tabs({ items, value, onChange, label, panelId = 'tabpanel' }: { items: TabItem[]; value: T; onChange: (v: T) => void; label: string; panelId?: string }) { const refs = useRef<(HTMLButtonElement | null)[]>([]); + const row = useRef(null); + const widths = useRef([]); + const [shown, setShown] = useState(null); + const more = useMenuButton(); + const active = items.findIndex(x => x.id === value); + const ids = items.map(x => x.id).join('|'); + useLayoutEffect(() => { + const el = row.current; + if (!el) return; + const fit = () => { + // widths are measured while a tab is in the row; a hidden one keeps its last width + refs.current.forEach((b, i) => { if (b && b.offsetParent !== null) widths.current[i] = b.getBoundingClientRect().width; }); + if (widths.current.length < items.length || widths.current.some(w => w === undefined)) { setShown(null); return; } + const cs = getComputedStyle(el); + const avail = (el.parentElement?.clientWidth ?? el.clientWidth) - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight) - parseFloat(cs.borderLeftWidth) - parseFloat(cs.borderRightWidth); + const next = fitTabs(widths.current, avail, active, parseFloat(cs.columnGap) || 4, 96); + setShown(prev => (prev && prev.join() === next.join() ? prev : next.length === items.length ? null : next)); + }; + fit(); + const ro = new ResizeObserver(fit); + if (el.parentElement) ro.observe(el.parentElement); + return () => ro.disconnect(); + }, [ids, active, items.length]); + const visible = (i: number) => !shown || shown.includes(i); + const order = items.map((_, i) => i).filter(visible); const onKey = (ev: KeyboardEvent) => { - const i = items.findIndex(x => x.id === value); - const j = ev.key === 'ArrowRight' ? i + 1 : ev.key === 'ArrowLeft' ? i - 1 : ev.key === 'Home' ? 0 : ev.key === 'End' ? items.length - 1 : null; + const i = order.indexOf(active); + const j = ev.key === 'ArrowRight' ? i + 1 : ev.key === 'ArrowLeft' ? i - 1 : ev.key === 'Home' ? 0 : ev.key === 'End' ? order.length - 1 : null; if (j === null) return; ev.preventDefault(); - const n = (j + items.length) % items.length; + const n = order[(j + order.length) % order.length]!; onChange(items[n]!.id); refs.current[n]?.focus(); }; + const hidden = items.filter((_, i) => !visible(i)); return ( -
+
{items.map((x, i) => ( ))} + {hidden.length > 0 && ( + + )}
); } -/** Dialog v2: modal panel with a title, body and footer actions; Escape and the scrim close it, focus moves in. */ export function Dialog({ title, icon, children, actions, onClose, wide }: { title: ReactNode; icon?: IconName; children: ReactNode; actions?: ReactNode; onClose: () => void; wide?: boolean }) { const root = useRef(null); const hid = useId(); diff --git a/apps/desktop/src/ui/tabs-fit.ts b/apps/desktop/src/ui/tabs-fit.ts new file mode 100644 index 0000000..c1a219e --- /dev/null +++ b/apps/desktop/src/ui/tabs-fit.ts @@ -0,0 +1,18 @@ +// Which pill tabs fit in one row: tabs in order until the row is full, the active tab always (it takes the place of +// the last one that would fit), the rest go into a "More" menu. Pure, for the tests. +export function fitTabs(widths: number[], avail: number, active: number, gap: number, moreW: number): number[] { + const total = widths.reduce((a, w) => a + w, 0) + gap * Math.max(0, widths.length - 1); + if (total <= avail) return widths.map((_, i) => i); + const room = avail - moreW - gap; + const out: number[] = []; + let used = active >= 0 ? (widths[active] ?? 0) : 0; + for (let i = 0; i < widths.length; i++) { + if (i === active) continue; + const w = widths[i]! + gap; + if (used + w > room) break; + used += w; + out.push(i); + } + if (active >= 0) out.push(active); + return out.sort((a, b) => a - b); +} diff --git a/apps/desktop/tests/tabs-fit.test.ts b/apps/desktop/tests/tabs-fit.test.ts new file mode 100644 index 0000000..a121e68 --- /dev/null +++ b/apps/desktop/tests/tabs-fit.test.ts @@ -0,0 +1,7 @@ +import { expect, test } from 'bun:test'; +import { fitTabs } from '../src/ui/tabs-fit'; + +test('all fit: every tab, no More', () => { expect(fitTabs([100, 100, 100], 400, 0, 4, 80)).toEqual([0, 1, 2]); }); +test('overflow: leading tabs until full, then More', () => { expect(fitTabs([100, 100, 100, 100], 330, 0, 4, 80)).toEqual([0, 1]); }); +test('the active tab stays visible even when it is late', () => { expect(fitTabs([100, 100, 100, 100], 330, 3, 4, 80)).toEqual([0, 3]); }); +test('tiny row: only the active tab', () => { expect(fitTabs([100, 100, 100], 150, 2, 4, 80)).toEqual([2]); });