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
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue