Desktop: pill tabs stay one row; tabs that do not fit go into a More menu, the active tab always stays visible

This commit is contained in:
CRTkafa 2026-10-23 09:44:43 +08:00
parent 83b8ce8869
commit 5b6d5cdac2
6 changed files with 77 additions and 10 deletions

View file

@ -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; } }

View file

@ -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',

View file

@ -71,6 +71,8 @@ export const tr: Record<keyof typeof en, string> = {
'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ç',

View file

@ -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<T extends string> { 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<T extends string>({ items, value, onChange, label, panelId = 'tabpanel' }: { items: TabItem<T>[]; value: T; onChange: (v: T) => void; label: string; panelId?: string }) {
const refs = useRef<(HTMLButtonElement | null)[]>([]);
const row = useRef<HTMLDivElement>(null);
const widths = useRef<number[]>([]);
const [shown, setShown] = useState<number[] | null>(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 (
<div className="ds-tabs" role="tablist" aria-label={label} onKeyDown={onKey}>
<div className="ds-tabs" ref={row} role="tablist" aria-label={label} onKeyDown={onKey}>
{items.map((x, i) => (
<button key={x.id} ref={el => { refs.current[i] = el; }} role="tab" id={`tab-${x.id}`} aria-selected={value === x.id} aria-controls={panelId}
tabIndex={value === x.id ? 0 : -1} className="ds-tab" onClick={() => onChange(x.id)}>
tabIndex={value === x.id ? 0 : -1} className="ds-tab" onClick={() => onChange(x.id)} hidden={!visible(i)}>
{x.icon && <Icon name={x.icon} weight={value === x.id ? 'fill' : 'regular'} />}{x.label}
{x.count != null && x.count > 0 && <span className="ds-tab-n num">{x.count}</span>}
{x.dot && <span className="ed-dot" title={x.dotTitle} />}
</button>
))}
{hidden.length > 0 && (
<button type="button" className="ds-tab ds-tab-more" tabIndex={-1} {...more(() => hidden.map(x => ({ label: x.count ? `${x.label} (${x.count})` : x.label, icon: x.icon, onSelect: () => onChange(x.id) })))}
aria-label={t('tabs.more', { count: hidden.length })}>
<Icon name="more" />{t('tabs.moreShort')}{hidden.some(x => x.count) && <span className="ds-tab-n num">{hidden.reduce((a, x) => a + (x.count ?? 0), 0)}</span>}<Icon name="chevronDown" />
</button>
)}
</div>
);
}
/** 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<HTMLDivElement>(null);
const hid = useId();

View file

@ -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);
}

View file

@ -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]); });