feat(overview): filter the skill card by name
A case-insensitive substring filter rides the skill card's totals row, narrowing the rows, the summary counts, and the overflow line; the input stays mounted on an empty match, next to a zeroed summary and a dedicated no-match note. The bars keep the scope's heaviest tally as their denominator so they hold still while the filter narrows. A pin the filter hides now falls back to the detached detail block with its head row — previously the head rendered only for pins outside the scope, leaving a filter-hidden (or render-bound-hidden) pin unnamed.
This commit is contained in:
1 parent
f60e1a5643
commit
00df001055
4 files changed
+143
-24
No files matched your search
@@ -6,9 +6,11 @@
|
||||
* in the picked order (the title's segmented toggle: loads / last used): a
|
||||
* name-hashed color dot, the load tally, a
|
||||
* proportional hairline bar (share of the heaviest row's tally), and the last
|
||||
* load's relative time. A row click PINs the session list to the sessions
|
||||
* that loaded the skill (the column's drill-down, beside the heatmap's day
|
||||
* pin — click again to release).
|
||||
* load's relative time. The sub row under the title carries the scope's
|
||||
* totals and a name filter (a case-insensitive substring match narrowing
|
||||
* both the rows and the counts). A row click PINs the session list to the
|
||||
* sessions that loaded the skill (the column's drill-down, beside the
|
||||
* heatmap's day pin — click again to release).
|
||||
*
|
||||
* The card also answers what a skill IS, in two registers:
|
||||
* - a HOVER card (portaled like the page's hoverTip, but structured — the
|
||||
@@ -151,12 +153,23 @@ export function makeOverviewSkills(kit: ViewKit): (props: OverviewSkillsProps) =
|
||||
const { t, fmt } = kit
|
||||
return function OverviewSkills(props: OverviewSkillsProps): ReactElement {
|
||||
const { stats } = props
|
||||
const total = stats.reduce((sum, s) => sum + s.loads, 0)
|
||||
// The bar's denominator is the heaviest row's tally, wherever the sort
|
||||
// put it — keying on the first row overflows the card the moment another
|
||||
// ordering (sessions / recent) leads with a lighter row.
|
||||
// The bar's denominator is the heaviest row's tally across the whole
|
||||
// scope, wherever the sort put it — keying on the first row overflows
|
||||
// the card the moment another ordering (sessions / recent) leads with a
|
||||
// lighter row, and keeping the scope's denominator stops the bars from
|
||||
// jumping while a name filter narrows the rows.
|
||||
const max = stats.reduce((top, s) => Math.max(top, s.loads), 0)
|
||||
|
||||
// ---- the name filter ---------------------------------------------------
|
||||
// A local, case-insensitive substring filter over the skill names; the
|
||||
// summary counts and the rendered rows follow it, so the card always
|
||||
// says what it shows. The input stays mounted on an empty match so the
|
||||
// filter can always be cleared from the UI.
|
||||
const [query, setQuery] = useState('')
|
||||
const needle = query.trim().toLowerCase()
|
||||
const visible = needle === '' ? stats : stats.filter(s => s.name.toLowerCase().includes(needle))
|
||||
const visibleTotal = visible.reduce((sum, s) => sum + s.loads, 0)
|
||||
|
||||
// ---- the hover card ---------------------------------------------------
|
||||
const [hoverRow, setHoverRow] = useState<HoverRow | null>(null)
|
||||
const [spot, setSpot] = useState<{ forRow: HoverRow; left: number; top: number } | null>(null)
|
||||
@@ -213,9 +226,10 @@ export function makeOverviewSkills(kit: ViewKit): (props: OverviewSkillsProps) =
|
||||
// block. The description leads (it answers what the skill IS); the facts
|
||||
// follow as a two-column label grid — origin, usage, last load, and the
|
||||
// full path as a click-to-copy value — so nothing floats unlabeled. A pin
|
||||
// whose row left the visible scope (or the render bound) falls back to a
|
||||
// detached block at the card's bottom, head row included; a catalog-less
|
||||
// pin with in-scope tallies still shows its usage rows.
|
||||
// whose row is not rendered — out of the filtered scope or past the
|
||||
// render bound — falls back to a detached block at the card's bottom,
|
||||
// head row included; a catalog-less pin with in-scope tallies still shows
|
||||
// its usage rows.
|
||||
const selected = props.selected
|
||||
const pinnedStat = selected === null || selected === undefined
|
||||
? undefined
|
||||
@@ -233,9 +247,14 @@ export function makeOverviewSkills(kit: ViewKit): (props: OverviewSkillsProps) =
|
||||
}
|
||||
// The copy value's path, narrowed once (a const's narrowing holds in the closure).
|
||||
const detailPath = detail?.path
|
||||
// Whether the pinned skill's own row renders (in the filtered scope and
|
||||
// inside the render bound) — the inline seat exists only then; a pin the
|
||||
// filter hides falls back to the detached block at the card's bottom.
|
||||
const pinnedRowVisible = selected !== null && selected !== undefined
|
||||
&& visible.slice(0, MAX_ROWS).some(s => s.name === selected)
|
||||
const detailBlock = detail === null ? null : (
|
||||
<div className="lc-ov-skill-detail">
|
||||
{pinnedStat === undefined && <DetailHead name={detail.name} origin="" />}
|
||||
{!pinnedRowVisible && <DetailHead name={detail.name} origin="" />}
|
||||
{detail.description !== '' && <p className="lc-skilld-desc">{detail.description}</p>}
|
||||
<div className="lc-skilld-grid">
|
||||
{detail.origin !== '' && (
|
||||
@@ -269,10 +288,6 @@ export function makeOverviewSkills(kit: ViewKit): (props: OverviewSkillsProps) =
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
// Whether the pinned skill's own row renders (in scope and inside the
|
||||
// render bound) — the inline seat exists only then.
|
||||
const pinnedRowVisible = selected !== null && selected !== undefined
|
||||
&& stats.slice(0, MAX_ROWS).some(s => s.name === selected)
|
||||
|
||||
const hoverTip = hoverStat === undefined ? null : (() => {
|
||||
const info = props.catalog?.get(hoverStat.name)
|
||||
@@ -324,17 +339,33 @@ export function makeOverviewSkills(kit: ViewKit): (props: OverviewSkillsProps) =
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* The scope's totals live under the title, clear of the toggle. */}
|
||||
{/* The scope's totals live under the title, clear of the toggle; the
|
||||
name filter rides the same row, folded right and wrapping below
|
||||
under width pressure. A lone row leaves nothing to filter. */}
|
||||
{stats.length > 0 && (
|
||||
<div className="lc-card-sub lc-ov-skills-sub">
|
||||
{t('ov.skills.summary', { skills: fmt(stats.length), loads: fmt(total) })}
|
||||
<div className="lc-ov-skills-sub">
|
||||
<span className="lc-card-sub lc-ov-skills-sub-n">
|
||||
{t('ov.skills.summary', { skills: fmt(visible.length), loads: fmt(visibleTotal) })}
|
||||
</span>
|
||||
{stats.length > 1 && (
|
||||
<input
|
||||
className="lc-ov-search lc-ov-skills-search"
|
||||
type="search"
|
||||
value={query}
|
||||
placeholder={t('ov.skills.search')}
|
||||
aria-label={t('ov.skills.search')}
|
||||
onChange={(ev) => { setQuery(ev.target.value) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{stats.length === 0 ? (
|
||||
<div className="lc-empty">{t('ov.skills.empty')}</div>
|
||||
) : visible.length === 0 ? (
|
||||
<div className="lc-empty">{t('ov.skills.noMatch')}</div>
|
||||
) : (
|
||||
<div className="lc-ov-skills" role="group" aria-label={t('stats.skills')}>
|
||||
{stats.slice(0, MAX_ROWS).map((s) => {
|
||||
{visible.slice(0, MAX_ROWS).map((s) => {
|
||||
const color = skillColorOf(s.name)
|
||||
const pinned = props.selected === s.name
|
||||
// The bar's percentage resolves against the row's padding box
|
||||
@@ -374,8 +405,8 @@ export function makeOverviewSkills(kit: ViewKit): (props: OverviewSkillsProps) =
|
||||
? <div key={s.name} className="lc-ov-skill-unit">{rowButton}{detailBlock}</div>
|
||||
: <Fragment key={s.name}>{rowButton}</Fragment>
|
||||
})}
|
||||
{stats.length > MAX_ROWS && (
|
||||
<div className="lc-ov-skill-more">{t('ov.skills.more', { n: stats.length - MAX_ROWS })}</div>
|
||||
{visible.length > MAX_ROWS && (
|
||||
<div className="lc-ov-skill-more">{t('ov.skills.more', { n: visible.length - MAX_ROWS })}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -396,6 +396,8 @@ export const DICT_ZH: Record<string, string> = {
|
||||
'ov.heat.mon.11': '11月',
|
||||
'ov.heat.mon.12': '12月',
|
||||
'ov.skills.empty': '统计范围内暂无技能加载 — 加载技能后,这里会按名称列出次数与最近加载',
|
||||
'ov.skills.noMatch': '没有匹配当前筛选的技能',
|
||||
'ov.skills.search': '搜索技能…',
|
||||
'ov.skills.more': '另有 {n} 个技能',
|
||||
'ov.skills.loads': '{n} 次加载',
|
||||
'ov.skills.summary': '{skills} 个技能 · {loads} 次加载',
|
||||
@@ -833,6 +835,8 @@ export const DICT_EN: Record<string, string> = {
|
||||
'ov.heat.mon.11': 'Nov',
|
||||
'ov.heat.mon.12': 'Dec',
|
||||
'ov.skills.empty': 'No skill loads in this range — loaded skills appear here with their counts and last load',
|
||||
'ov.skills.noMatch': 'No skills match the current filter',
|
||||
'ov.skills.search': 'Search skills…',
|
||||
'ov.skills.more': '{n} more skills',
|
||||
'ov.skills.loads': '{n} loads',
|
||||
'ov.skills.summary': '{skills} skills · {loads} loads',
|
||||
|
||||
@@ -224,9 +224,15 @@ button.lc-heat-cell:hover { box-shadow: 0 0 0 1.5px color-mix(in srgb, var(--col
|
||||
so the tally and the time never crush it. ---- */
|
||||
.lc-ov-skills { display: flex; flex-direction: column; gap: 2px; }
|
||||
/* The scope's totals sit under the title, pulled into the title's bottom
|
||||
margin so the pair reads as one head; the sort toggle stays up in the
|
||||
title row's right corner. */
|
||||
.lc-ov-skills-sub { margin: -4px 0 8px; }
|
||||
margin so the pair reads as one head; the name filter rides the same row,
|
||||
folded to the right and wrapping below the totals under width pressure.
|
||||
The sort toggle stays up in the title row's right corner. */
|
||||
.lc-ov-skills-sub { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: -4px 0 8px; }
|
||||
.lc-ov-skills-sub-n { margin-left: 0; }
|
||||
/* The filter keeps the session-list search's look; it shrinks before the
|
||||
totals and, once wrapped, fills its own line so the input stays usable
|
||||
on the narrowest pane. */
|
||||
.lc-ov-skills-search { flex: 0 1 170px; margin-left: auto; }
|
||||
.lc-ov-skill { position: relative; display: flex; align-items: baseline; gap: 7px; min-width: 0; width: 100%; border: 0; border-radius: 6px; padding: 2px 6px 4px; background: transparent; font: inherit; font-size: 12px; line-height: 1.7; color: inherit; text-align: left; cursor: pointer; transition: background-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
|
||||
.lc-ov-skill:hover { background: var(--dsw-alias-bg-layer-2); }
|
||||
/* The pinned row and its detail read as one unit: the fill and the single
|
||||
|
||||
@@ -392,6 +392,75 @@ describe('OverviewSkills', () => {
|
||||
await bare.unmount()
|
||||
})
|
||||
|
||||
test('the name filter narrows the rows and the summary, case-insensitively; clearing restores', async () => {
|
||||
const m = await mount(h(Skills, {
|
||||
stats: [stat('ponytail', 2, NOW), stat('grilling', 1, NOW), stat('grill-me', 1, NOW)],
|
||||
now: NOW,
|
||||
}))
|
||||
const input = query<HTMLInputElement>(m.container, 'input.lc-ov-skills-search')
|
||||
assert.equal(input.getAttribute('aria-label'), 'Search skills…')
|
||||
assert.equal(queryAll(m.container, 'button.lc-ov-skill').length, 3)
|
||||
assert.ok(text(query(m.container, '.lc-ov-skills-sub-n')).includes('3 skills · 4 loads'))
|
||||
await actType(input, 'GRILL')
|
||||
assert.deepEqual(
|
||||
queryAll(m.container, '.lc-ov-skill-name').map(n => text(n)),
|
||||
['grilling', 'grill-me'],
|
||||
'the substring match ignores case',
|
||||
)
|
||||
assert.ok(text(query(m.container, '.lc-ov-skills-sub-n')).includes('2 skills · 2 loads'), 'the counts follow the filter')
|
||||
await actType(input, ' grill-me ')
|
||||
assert.deepEqual(queryAll(m.container, '.lc-ov-skill-name').map(n => text(n)), ['grill-me'], 'the query trims')
|
||||
await actType(input, '')
|
||||
assert.equal(queryAll(m.container, 'button.lc-ov-skill').length, 3, 'clearing restores the scope')
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('an empty match keeps the filter input mounted and swaps the note; a lone row offers nothing to filter', async () => {
|
||||
const m = await mount(h(Skills, { stats: [stat('tdd', 1, NOW), stat('grill-me', 1, NOW)], now: NOW }))
|
||||
const input = query<HTMLInputElement>(m.container, 'input.lc-ov-skills-search')
|
||||
await actType(input, 'zzz')
|
||||
assert.equal(queryAll(m.container, 'button.lc-ov-skill').length, 0)
|
||||
assert.ok(text(query(m.container, '.lc-ov-skills-sub-n')).includes('0 skills · 0 loads'), 'the counts follow the filter down to zero')
|
||||
assert.ok(text(m.container).includes('No skills match the current filter'))
|
||||
await actType(input, 'tdd')
|
||||
assert.equal(queryAll(m.container, 'button.lc-ov-skill').length, 1, 'the input stays mounted and can clear the filter')
|
||||
await m.unmount()
|
||||
const one = await mount(h(Skills, { stats: [stat('tdd', 1, NOW)], now: NOW }))
|
||||
assert.equal(queryAll(one.container, 'input.lc-ov-skills-search').length, 0, 'a lone row has nothing to filter')
|
||||
await one.unmount()
|
||||
const empty = await mount(h(Skills, { stats: [], now: NOW }))
|
||||
assert.equal(queryAll(empty.container, 'input.lc-ov-skills-search').length, 0)
|
||||
await empty.unmount()
|
||||
})
|
||||
|
||||
test('a pin the filter hides falls back to the detached block; the filtered overflow line counts the matches', async () => {
|
||||
const stats = Array.from({ length: 25 }, (_, i) => stat(`skill-${i}`, i + 1, NOW - i * 1000))
|
||||
stats.push(stat('grill-me', 1, NOW))
|
||||
const m = await mount(h(Skills, { stats, selected: 'skill-24', now: NOW }))
|
||||
const input = query<HTMLInputElement>(m.container, 'input.lc-ov-skills-search')
|
||||
await actType(input, 'grill')
|
||||
assert.equal(queryAll(m.container, 'button.lc-ov-skill').length, 1)
|
||||
const detail = query(m.container, '.lc-ov-skill-detail')
|
||||
assert.equal(detail.closest('.lc-ov-skill-unit'), null, 'no inline seat without the pinned row')
|
||||
assert.equal(queryAll(detail, '.lc-skilld-head').length, 1, 'the detached seat carries the head')
|
||||
// All twenty-five generated names match 'skill' — twenty rows plus the
|
||||
// overflow line counting the filtered set.
|
||||
await actType(input, 'skill')
|
||||
assert.equal(queryAll(m.container, 'button.lc-ov-skill').length, 20)
|
||||
assert.ok(text(m.container).includes('5 more skills'), 'the overflow counts the filtered set')
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('the bars keep the scope’s heaviest tally as their denominator under a filter', async () => {
|
||||
const m = await mount(h(Skills, { stats: [stat('heavy', 8, NOW), stat('light', 4, NOW)], now: NOW }))
|
||||
const input = query<HTMLInputElement>(m.container, 'input.lc-ov-skills-search')
|
||||
await actType(input, 'light')
|
||||
const rows = queryAll<HTMLElement>(m.container, 'button.lc-ov-skill')
|
||||
assert.equal(rows.length, 1)
|
||||
assert.ok(query(rows[0], '.lc-ov-skill-bar').getAttribute('style')?.includes('calc(50% - 6px)'), 'the share stays against the scope’s heaviest row')
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('zh locale renders the shipped strings', async () => {
|
||||
const m = await mount(h(SkillsZh, {
|
||||
stats: [stat('tdd', 2, NOW - 2 * 3_600_000)],
|
||||
@@ -416,3 +485,12 @@ describe('OverviewSkills', () => {
|
||||
await empty.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
/** Type into an input through the React change path (native setter + input event, act-wrapped). */
|
||||
async function actType(input: HTMLInputElement, value: string): Promise<void> {
|
||||
await act(async () => {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
|
||||
setter?.call(input, value)
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user