72 lines
7.6 KiB
TypeScript
72 lines
7.6 KiB
TypeScript
import { useId, useRef, useState } from 'react';
|
||
import { ArrowLeft, Check, ChevronDown, Filter, Search, X } from 'lucide-react';
|
||
|
||
export type MobileDimension = { key: string; label: string; options: { value: string; label: string; count?: number }[] };
|
||
type Filters = Partial<Record<string, string[]>>;
|
||
type Props = {
|
||
available: MobileDimension[];
|
||
dimensions: string[];
|
||
filters: Filters;
|
||
defaults: string[];
|
||
apply: (dimensions: string[], filters: Filters) => void;
|
||
};
|
||
|
||
/** Draft all mobile settings together; cancelling never changes the current result. */
|
||
export function MobileAnalysisControls({ available, dimensions, filters, defaults, apply }: Props) {
|
||
const [mode, setMode] = useState<'group' | 'filter' | null>(null);
|
||
const [draftDimensions, setDraftDimensions] = useState(dimensions);
|
||
const [draftFilters, setDraftFilters] = useState(filters);
|
||
const [editing, setEditing] = useState<string | null>(null);
|
||
const [query, setQuery] = useState('');
|
||
const trigger = useRef<HTMLButtonElement | null>(null);
|
||
const panel = useRef<HTMLDivElement>(null);
|
||
const id = useId();
|
||
const selected = available.filter(d => filters[d.key]?.length);
|
||
const editor = available.find(d => d.key === editing);
|
||
const options = editor?.options.filter(o => o.label.toLocaleLowerCase().includes(query.toLocaleLowerCase())) || [];
|
||
const close = () => { setMode(null); trigger.current?.focus(); };
|
||
const open = (next: 'group' | 'filter', button: HTMLButtonElement) => {
|
||
trigger.current = button; setDraftDimensions([...dimensions]); setDraftFilters({ ...filters });
|
||
setEditing(null); setQuery(''); setMode(next);
|
||
requestAnimationFrame(() => panel.current?.focus());
|
||
};
|
||
const toggle = (key: string, value: string) => {
|
||
const values = draftFilters[key] || [];
|
||
setDraftFilters({ ...draftFilters, [key]: values.includes(value) ? values.filter(v => v !== value) : [...values, value] });
|
||
};
|
||
return <div className="analysis-mobile-controls">
|
||
<div className="analysis-mobile-dimension-heading"><strong>分析维度</strong><span>可多选 · 左右滑动</span><button onClick={e => open('group', e.currentTarget)} aria-expanded={mode === 'group'} aria-controls={id}>分组设置</button></div>
|
||
<div className="analysis-mobile-toolbar">
|
||
<div className="analysis-dimension-strip" role="group" aria-label="分析维度">
|
||
{available.map(d => <button key={d.key} type="button" aria-pressed={dimensions.includes(d.key)} onClick={() => apply(dimensions.includes(d.key) ? dimensions.filter(key => key !== d.key) : [...dimensions, d.key], filters)}>{d.label}{dimensions.includes(d.key) && <small>{dimensions.indexOf(d.key) + 1}</small>}</button>)}
|
||
<button type="button" aria-pressed={!dimensions.length} onClick={() => apply([], filters)}>仅汇总</button>
|
||
</div>
|
||
<button type="button" onClick={e => open('filter', e.currentTarget)} aria-expanded={mode === 'filter'} aria-controls={id}><Filter size={16} />筛选{selected.length ? ` ${selected.length}` : ''}</button>
|
||
</div>
|
||
{!!selected.length && <div className="analysis-mobile-chips" aria-label="已选筛选条件">{selected.map(d => <button key={d.key} title={(filters[d.key] || []).map(v => d.options.find(o => o.value === v)?.label || v).join('、')} aria-label={`移除${d.label}筛选`} onClick={() => { const next = { ...filters }; delete next[d.key]; apply(dimensions, next); }}><span>{d.label}:{filters[d.key]?.length === 1 ? d.options.find(o => o.value === filters[d.key]?.[0])?.label || filters[d.key]?.[0] : `${filters[d.key]?.length}项`}</span><X size={14} /></button>)}</div>}
|
||
{mode && <div className="analysis-settings-backdrop" onClick={e => { if (e.target === e.currentTarget) close(); }}>
|
||
<div ref={panel} id={id} role="dialog" aria-modal="true" aria-label={mode === 'group' ? '分组设置' : '筛选条件'} tabIndex={-1} className="analysis-settings-panel" onKeyDown={e => {
|
||
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); if (editing) { setEditing(null); setQuery(''); } else close(); }
|
||
if (e.key === 'Tab') {
|
||
e.stopPropagation();
|
||
const items = Array.from(panel.current?.querySelectorAll<HTMLElement>('button:not([disabled]),input') || []).filter(el => el.getClientRects().length);
|
||
const first = items[0], last = items.at(-1);
|
||
if (e.shiftKey && (document.activeElement === first || document.activeElement === panel.current)) { e.preventDefault(); last?.focus(); }
|
||
else if (!e.shiftKey && (document.activeElement === last || document.activeElement === panel.current)) { e.preventDefault(); first?.focus(); }
|
||
}
|
||
}}>
|
||
<header><button aria-label={editing ? '返回筛选条件' : '取消设置'} onClick={() => editing ? (setEditing(null), setQuery('')) : close()}><ArrowLeft size={20} /></button><h3>{editor?.label || (mode === 'group' ? '分组设置' : '筛选条件')}</h3><button aria-label="关闭设置" onClick={close}><X size={20} /></button></header>
|
||
{editor && <label className="analysis-settings-search"><Search size={17} /><input aria-label={`搜索${editor.label}`} placeholder={`搜索${editor.label}`} value={query} onChange={e => setQuery(e.target.value)} /></label>}
|
||
<div className="analysis-settings-content">
|
||
{mode === 'group' ? <><p>可选择多个维度,按选择顺序组合统计。</p><div className="analysis-settings-groups">{available.map(d => <button key={d.key} aria-pressed={draftDimensions.includes(d.key)} onClick={() => setDraftDimensions(draftDimensions.includes(d.key) ? draftDimensions.filter(v => v !== d.key) : [...draftDimensions, d.key])}><span>{d.label}</span>{draftDimensions.includes(d.key) && <span className="analysis-dimension-order">{draftDimensions.indexOf(d.key) + 1}</span>}</button>)}</div><button className="analysis-settings-row" aria-pressed={!draftDimensions.length} onClick={() => setDraftDimensions([])}>仅汇总,不分组{!draftDimensions.length && <Check size={18} />}</button></> : editor ? <>
|
||
<div className="analysis-settings-select"><button onClick={() => setDraftFilters({ ...draftFilters, [editor.key]: [...new Set([...(draftFilters[editor.key] || []), ...options.map(o => o.value)])] })}>全选{query ? '搜索结果' : ''}</button><button onClick={() => setDraftFilters({ ...draftFilters, [editor.key]: [] })}>清除选择</button><span>{draftFilters[editor.key]?.length || 0} 项已选</span></div>
|
||
{options.map(o => <label className="analysis-settings-option" key={o.value}><input type="checkbox" checked={draftFilters[editor.key]?.includes(o.value) || false} onChange={() => toggle(editor.key, o.value)} /><span>{o.label}</span>{o.count !== undefined && <small>{o.count.toLocaleString()}</small>}</label>)}
|
||
{!options.length && <p>没有匹配选项,可调整搜索。</p>}
|
||
</> : <><p>同一维度多选取并集,不同维度同时满足。未选择表示不限。</p>{available.map(d => <button className="analysis-settings-row" key={d.key} onClick={() => { setEditing(d.key); setQuery(''); }}><span>{d.label}</span><small>{draftFilters[d.key]?.length ? `已选 ${draftFilters[d.key]?.length} 项` : '不限'}</small><ChevronDown size={16} /></button>)}</>}
|
||
</div>
|
||
<footer><button onClick={() => mode === 'group' ? setDraftDimensions([...defaults]) : setDraftFilters({})}>重置{mode === 'group' ? '分组' : '筛选'}</button><button className="analysis-settings-apply" onClick={() => { const clean = Object.fromEntries(Object.entries(draftFilters).filter(([, values]) => values?.length)); apply(draftDimensions, clean); close(); }}>查看结果</button></footer>
|
||
</div>
|
||
</div>}
|
||
</div>;
|
||
}
|