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:
parent
83b8ce8869
commit
5b6d5cdac2
6 changed files with 77 additions and 10 deletions
|
|
@ -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; } }
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
|
|
@ -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ç',
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
18
apps/desktop/src/ui/tabs-fit.ts
Normal file
18
apps/desktop/src/ui/tabs-fit.ts
Normal 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);
|
||||
}
|
||||
7
apps/desktop/tests/tabs-fit.test.ts
Normal file
7
apps/desktop/tests/tabs-fit.test.ts
Normal 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]); });
|
||||
Loading…
Add table
Add a link
Reference in a new issue