Skip to content

Commit f9ffdad

Browse files
committed
Feat: M6 - Filter & Search Improvements
- [x] Remove filter Effect Quality functionality and remove the filter option. - [x] Ingredients filter should have an option to filter for potions with all of the ingredients or any of the ingredients as a clickable toggle next to the Ingredients label. - [x] When an ingredient filter is selected, the applicable ingredient in the potion card should be highlighted. - [x] The Text Search box should include a small X inside the input that clears the search text when clicked. - [x] Update PRD.md as nessisary to relfect these changes and improvements.
1 parent 2548819 commit f9ffdad

14 files changed

Lines changed: 146 additions & 128 deletions

File tree

‎src/features/tools/kcd2_alchemy/__tests__/a11y.test.tsx‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,10 @@ describe('KCD2 Alchemy — Accessibility', () => {
1818
{ id: 'mint', name: 'Mint' },
1919
{ id: 'belladonna', name: 'Belladonna' },
2020
]}
21-
effectOptions={['Weak', 'Strong']}
2221
selectedIngredientIds={['mint']}
23-
selectedEffects={['weak']}
22+
ingredientMode="any"
2423
onChangeIngredients={() => {}}
25-
onChangeEffects={() => {}}
24+
onChangeIngredientMode={() => {}}
2625
/>,
2726
)
2827
const results = await axe(container)

‎src/features/tools/kcd2_alchemy/__tests__/filter.test.ts‎

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from 'vitest'
2-
import { applyFilters, textMatch, ingredientMatch, effectQualityMatch } from '../lib/filter'
2+
import { applyFilters, textMatch, ingredientMatch } from '../lib/filter'
33
import type { NormalizedPotionRecipe } from '../types'
44

55
const sample: NormalizedPotionRecipe[] = [
@@ -45,33 +45,29 @@ describe('filter predicates', () => {
4545
expect(textMatch(sample[0], 'xyz')).toBe(false)
4646
})
4747

48-
it('ingredientMatch matches any selected ingredient id', () => {
49-
expect(ingredientMatch(sample[0], [])).toBe(true)
50-
expect(ingredientMatch(sample[0], ['marigold'])).toBe(true)
51-
expect(ingredientMatch(sample[0], ['wormwood'])).toBe(false)
52-
})
48+
it('ingredientMatch matches any or all selected ids', () => {
49+
expect(ingredientMatch(sample[0], [], 'any')).toBe(true)
50+
expect(ingredientMatch(sample[0], ['marigold'], 'any')).toBe(true)
51+
expect(ingredientMatch(sample[0], ['wormwood'], 'any')).toBe(false)
5352

54-
it('effectQualityMatch matches any selected quality', () => {
55-
expect(effectQualityMatch(sample[0], [])).toBe(true)
56-
expect(effectQualityMatch(sample[0], ['weak'])).toBe(true)
57-
expect(effectQualityMatch(sample[0], ['strong'])).toBe(true)
58-
expect(effectQualityMatch(sample[0], ['standard'])).toBe(false)
53+
// all mode
54+
expect(ingredientMatch(sample[0], ['marigold', 'dandelion'], 'all')).toBe(true)
55+
expect(ingredientMatch(sample[0], ['marigold', 'wormwood'], 'all')).toBe(false)
5956
})
6057
})
6158

6259
describe('applyFilters', () => {
63-
it('applies text + ingredient + effect filters and sorts by name', () => {
60+
it('applies text + ingredient filters and sorts by name', () => {
6461
const results = applyFilters(sample, {
6562
query: 'increases',
6663
ingredientIds: ['wormwood'],
67-
effectQualities: ['standard'],
64+
ingredientMode: 'any',
6865
})
6966
expect(results.map((r) => r.id)).toEqual(['p2'])
7067
})
7168

7269
it('returns all when filters are empty', () => {
73-
const results = applyFilters(sample, { query: '', ingredientIds: [], effectQualities: [] })
70+
const results = applyFilters(sample, { query: '', ingredientIds: [], ingredientMode: 'any' })
7471
expect(results.length).toBe(2)
7572
})
7673
})
77-

‎src/features/tools/kcd2_alchemy/__tests__/query-state.test.tsx‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ function Harness() {
99
<div>
1010
<div data-testid="q">{state.q}</div>
1111
<div data-testid="ingredients">{state.ingredients.join(',')}</div>
12-
<div data-testid="effects">{state.effects.join(',')}</div>
13-
<button onClick={() => setState({ q: 'mint', ingredients: ['mint'], effects: ['weak'] })}>set</button>
12+
<div data-testid="ingMode">{state.ingMode}</div>
13+
<button onClick={() => setState({ q: 'mint', ingredients: ['mint'], ingMode: 'all' })}>set</button>
1414
</div>
1515
)
1616
}
@@ -29,7 +29,6 @@ describe('useQueryState', () => {
2929
expect(screen.getByTestId('q').textContent).toBe('mint')
3030
expect(window.location.search).toContain('q=mint')
3131
expect(window.location.search).toContain('ingredients=mint')
32-
expect(window.location.search).toContain('effects=weak')
32+
expect(window.location.search).toContain('ingMode=all')
3333
})
3434
})
35-

‎src/features/tools/kcd2_alchemy/components/FiltersPanel.tsx‎

Lines changed: 34 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
'use client'
22

33
import type { IngredientId } from '../types'
4+
import type { IngredientMatchMode } from '../lib/filter'
45

56
export interface FiltersPanelProps {
67
ingredientOptions: { id: IngredientId; name: string }[]
7-
effectOptions: string[]
88
selectedIngredientIds: IngredientId[]
9-
selectedEffects: string[]
9+
ingredientMode: IngredientMatchMode
1010
onChangeIngredients: (ids: IngredientId[]) => void
11-
onChangeEffects: (effects: string[]) => void
11+
onChangeIngredientMode: (mode: IngredientMatchMode) => void
1212
onClearAll?: () => void
1313
}
1414

@@ -28,11 +28,10 @@ function ToggleChip({ active, label }: { active: boolean; label: string }) {
2828

2929
export function FiltersPanel({
3030
ingredientOptions,
31-
effectOptions,
3231
selectedIngredientIds,
33-
selectedEffects,
32+
ingredientMode,
3433
onChangeIngredients,
35-
onChangeEffects,
34+
onChangeIngredientMode,
3635
onClearAll,
3736
}: FiltersPanelProps) {
3837
const toggleIngredient = (id: IngredientId) => {
@@ -43,14 +42,6 @@ export function FiltersPanel({
4342
onChangeIngredients(Array.from(set))
4443
}
4544

46-
const toggleEffect = (label: string) => {
47-
const set = new Set(selectedEffects.map((s) => s.toLowerCase()))
48-
const key = label.toLowerCase()
49-
if (set.has(key)) set.delete(key)
50-
else set.add(key)
51-
onChangeEffects(Array.from(set))
52-
}
53-
5445
return (
5546
<section className="bg-[var(--card)] border border-[var(--border)] rounded-md p-4">
5647
<div className="flex items-center justify-between mb-3">
@@ -68,7 +59,35 @@ export function FiltersPanel({
6859

6960
<div className="space-y-4">
7061
<div>
71-
<h3 className="text-sm font-medium mb-2">Ingredients</h3>
62+
<div className="flex items-center justify-between mb-2">
63+
<h3 className="text-sm font-medium m-0">Ingredients</h3>
64+
<div className="flex items-center gap-1 text-xs" role="group" aria-label="Ingredient match mode">
65+
<button
66+
type="button"
67+
className={`px-2 py-0.5 rounded border transition-colors ${
68+
ingredientMode === 'any'
69+
? 'border-[var(--link)] text-[var(--link)] bg-[color-mix(in_oklab,var(--link)_12%,transparent)]'
70+
: 'border-[var(--border)] text-[var(--muted)]'
71+
}`}
72+
onClick={() => onChangeIngredientMode('any')}
73+
aria-pressed={ingredientMode === 'any'}
74+
>
75+
Any
76+
</button>
77+
<button
78+
type="button"
79+
className={`px-2 py-0.5 rounded border transition-colors ${
80+
ingredientMode === 'all'
81+
? 'border-[var(--link)] text-[var(--link)] bg-[color-mix(in_oklab,var(--link)_12%,transparent)]'
82+
: 'border-[var(--border)] text-[var(--muted)]'
83+
}`}
84+
onClick={() => onChangeIngredientMode('all')}
85+
aria-pressed={ingredientMode === 'all'}
86+
>
87+
All
88+
</button>
89+
</div>
90+
</div>
7291
<div className="flex flex-wrap gap-2">
7392
{ingredientOptions.map((opt) => {
7493
const active = selectedIngredientIds.map(String).includes(String(opt.id))
@@ -87,27 +106,6 @@ export function FiltersPanel({
87106
})}
88107
</div>
89108
</div>
90-
91-
<div>
92-
<h3 className="text-sm font-medium mb-2">Effect Quality</h3>
93-
<div className="flex flex-wrap gap-2">
94-
{effectOptions.map((label) => {
95-
const active = selectedEffects.map((s) => s.toLowerCase()).includes(label.toLowerCase())
96-
return (
97-
<button
98-
key={label}
99-
type="button"
100-
onClick={() => toggleEffect(label)}
101-
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--link)] rounded"
102-
aria-pressed={active}
103-
data-active={active}
104-
>
105-
<ToggleChip active={active} label={label} />
106-
</button>
107-
)
108-
})}
109-
</div>
110-
</div>
111109
</div>
112110
</section>
113111
)

‎src/features/tools/kcd2_alchemy/components/PotionCard.tsx‎

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import type { NormalizedPotionRecipe } from '../types'
1+
import type { IngredientId, NormalizedPotionRecipe } from '../types'
22
import { useState } from 'react'
33
import { QuantityTable } from './QuantityTable'
44

5-
export function PotionCard({ potion }: { potion: NormalizedPotionRecipe }) {
5+
export function PotionCard({ potion, highlightIngredientIds = [] }: { potion: NormalizedPotionRecipe; highlightIngredientIds?: IngredientId[] }) {
66
const [tab, setTab] = useState<'default' | 'optimized'>('default')
77
const hasOptimized = Boolean(potion.instructions.optimized)
88

@@ -26,9 +26,25 @@ export function PotionCard({ potion }: { potion: NormalizedPotionRecipe }) {
2626
<div className="flex items-baseline gap-2 flex-wrap">
2727
<h4 className="font-medium shrink-0">Ingredients:</h4>
2828
<ul className="list-none p-0 m-0 text-sm">
29-
{potion.ingredients.items.map((it) => (
30-
<li className="inline after:content-[',_'] last:after:content-['']" key={`${potion.id}-${String(it.id)}`}>{it.name} ({it.quantity})</li>
31-
))}
29+
{potion.ingredients.items.map((it) => {
30+
const isHighlighted = highlightIngredientIds.map(String).includes(String(it.id))
31+
return (
32+
<li
33+
className="inline after:content-[',_'] last:after:content-['']"
34+
key={`${potion.id}-${String(it.id)}`}
35+
>
36+
<span
37+
className={
38+
isHighlighted
39+
? 'px-1 rounded border border-[var(--link)] text-[var(--link)] bg-[color-mix(in_oklab,var(--link)_12%,transparent)]'
40+
: ''
41+
}
42+
>
43+
{it.name} ({it.quantity})
44+
</span>
45+
</li>
46+
)
47+
})}
3248
</ul>
3349
</div>
3450
</section>
Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import type { NormalizedPotionRecipe } from '../types'
1+
import type { IngredientId, NormalizedPotionRecipe } from '../types'
22
import { PotionCard } from './PotionCard'
33

4-
export function PotionList({ potions }: { potions: NormalizedPotionRecipe[] }) {
4+
export function PotionList({ potions, selectedIngredientIds = [] }: { potions: NormalizedPotionRecipe[]; selectedIngredientIds?: IngredientId[] }) {
55
if (!potions.length) {
66
return (
77
<div className="text-sm text-[var(--muted-2)] border border-[var(--border)] rounded-md p-6 bg-[var(--card)]">
@@ -12,9 +12,8 @@ export function PotionList({ potions }: { potions: NormalizedPotionRecipe[] }) {
1212
return (
1313
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
1414
{potions.map((p) => (
15-
<PotionCard key={p.id} potion={p} />
15+
<PotionCard key={p.id} potion={p} highlightIngredientIds={selectedIngredientIds} />
1616
))}
1717
</div>
1818
)
1919
}
20-

‎src/features/tools/kcd2_alchemy/components/SearchBar.tsx‎

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { useEffect, useMemo, useState } from 'react'
3+
import { useEffect, useMemo, useState, useId } from 'react'
44

55
export interface SearchBarProps {
66
value: string
@@ -10,6 +10,7 @@ export interface SearchBarProps {
1010

1111
export function SearchBar({ value, onChange, placeholder = 'Search potions…' }: SearchBarProps) {
1212
const [text, setText] = useState(value)
13+
const inputId = useId()
1314

1415
useEffect(() => {
1516
setText(value)
@@ -27,15 +28,33 @@ export function SearchBar({ value, onChange, placeholder = 'Search potions…' }
2728
onChange: (e: React.ChangeEvent<HTMLInputElement>) => setText(e.target.value),
2829
placeholder,
2930
className:
30-
'block w-full border border-[var(--border)] rounded-md bg-[var(--card)] text-[var(--foreground)] px-3 py-2',
31+
'block w-full border border-[var(--border)] rounded-md bg-[var(--card)] text-[var(--foreground)] pr-9 pl-3 py-2',
3132
}),
3233
[text, placeholder],
3334
)
3435

3536
return (
36-
<div className="w-full">
37-
<input type="text" aria-label="Search potions" {...inputProps} />
37+
<div className="w-full relative">
38+
<label htmlFor={inputId} className="sr-only">Search potions</label>
39+
<input id={inputId} name="q" type="text" {...inputProps} />
40+
{text && (
41+
<button
42+
type="button"
43+
aria-label="Clear search"
44+
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 w-5 h-5 inline-flex items-center justify-center rounded border border-[var(--border)] text-[color-mix(in_oklab,var(--foreground)_80%,transparent)] hover:text-[var(--foreground)] bg-[var(--surfaceHover)] hover:bg-[var(--surface)] shadow-sm transition-colors"
45+
onClick={() => {
46+
setText('')
47+
onChange('')
48+
}}
49+
>
50+
<svg aria-hidden="true" viewBox="0 0 20 20" className="w-3.5 h-3.5">
51+
<path
52+
fill="currentColor"
53+
d="M14.348 5.652a.8.8 0 0 0-1.132 0L10 8.868 6.784 5.652a.8.8 0 1 0-1.132 1.132L8.868 10l-3.216 3.216a.8.8 0 1 0 1.132 1.132L10 11.132l3.216 3.216a.8.8 0 0 0 1.132-1.132L11.132 10l3.216-3.216a.8.8 0 0 0 0-1.132z"
54+
/>
55+
</svg>
56+
</button>
57+
)}
3858
</div>
3959
)
4060
}
41-

‎src/features/tools/kcd2_alchemy/docs/PLAN.md‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -168,11 +168,11 @@ M5 — Integration & Polish
168168
- [x] Validate `npm run test:all` and `npm run build` locally per STANDARDS.md.
169169

170170
M6 - Filter & Search Improvements
171-
- [ ] Remove filter Effect Quality functionality and remove the filter option.
172-
- [ ] Ingredients filter should have an option to filter for potions with all of the ingredients or any of the ingredients as a clickable toggle next to the Ingredients label.
173-
- [ ] When an ingredient filter is selected, the applicable ingredient in the potion card should be highlighted.
174-
- [ ] The Text Search box should include a small X inside the input that clears the search text when clicked.
175-
- [ ] Update PRD.md as nessisary to relfect these changes and improvements.
171+
- [x] Remove filter Effect Quality functionality and remove the filter option.
172+
- [x] Ingredients filter should have an option to filter for potions with all of the ingredients or any of the ingredients as a clickable toggle next to the Ingredients label.
173+
- [x] When an ingredient filter is selected, the applicable ingredient in the potion card should be highlighted.
174+
- [x] The Text Search box should include a small X inside the input that clears the search text when clicked.
175+
- [x] Update PRD.md as nessisary to relfect these changes and improvements.
176176

177177
## 10) Acceptance Criteria (DoD)
178178

‎src/features/tools/kcd2_alchemy/docs/PRD.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,12 @@ Note: Recipes.ingredents.items.id is a foreign key to Ingredients.id
6262

6363
## 5. UX & Flows
6464

65-
- Search and filters: query (text), level, ingredients, effects.
65+
- Search and filters: query (text), ingredients (multi‑select) with match mode toggle (Any/All).
66+
- Text search clear: input includes an inline clear (X) control.
6667
- Results list: shows matching potions, count, empty state with guidance.
6768
- Details: each recipe card shows ingredients, effects, quantities with perk toggles, and instructions with Default/Optimized tabs.
68-
- URL state: `?q=&level=&ingredients=&effects=`; reload/links preserve filters.
69+
- Ingredient highlight: when ingredient filters are active, matching ingredients in each card are highlighted.
70+
- URL state: `?q=&ingredients=&ingMode=`; reload/links preserve filters. `ingMode` defaults to `any` and may be omitted when at default.
6971

7072
## 6. Accessibility
7173

@@ -81,7 +83,7 @@ Note: Recipes.ingredents.items.id is a foreign key to Ingredients.id
8183

8284
- Tool loads client-side at `/tools/kcd2_alchemy/` without SSR.
8385
- Loads data from JSON and renders a list with basic fields.
84-
- Filters operate per spec, with URL query param sync.
86+
- Filters operate per spec, with URL query param sync. Effect quality filtering is removed. Ingredient filtering supports Any/All mode.
8587
- A11y smoke tests pass; no console errors; build succeeds via `npm run build`.
8688

8789
## 9. Risks & Open Questions

‎src/features/tools/kcd2_alchemy/hooks/useAlchemyData.ts‎

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,14 @@
33
import { useEffect, useMemo, useState } from 'react'
44
import type { Ingredient, NormalizedPotionRecipe } from '../types'
55
import { parseIngredients, parsePotions, validateReferentialIntegrity } from '../lib/schema'
6-
import {
7-
buildIngredientIndex,
8-
deriveEffectQualities,
9-
deriveIngredientOptions,
10-
resolvePotions,
11-
} from '../lib/normalize'
6+
import { buildIngredientIndex, deriveIngredientOptions, resolvePotions } from '../lib/normalize'
127

138
export interface AlchemyDataState {
149
loading: boolean
1510
error: string | null
1611
potions: NormalizedPotionRecipe[]
1712
ingredients: Ingredient[]
1813
ingredientOptions: { id: string | number; name: string }[]
19-
effectQualities: string[]
2014
}
2115

2216
export function useAlchemyData(): AlchemyDataState {
@@ -75,7 +69,5 @@ export function useAlchemyData(): AlchemyDataState {
7569
}, [])
7670

7771
const ingredientOptions = useMemo(() => deriveIngredientOptions(ingredients), [ingredients])
78-
const effectQualities = useMemo(() => deriveEffectQualities(potions), [potions])
79-
80-
return { loading, error, potions, ingredients, ingredientOptions, effectQualities }
72+
return { loading, error, potions, ingredients, ingredientOptions }
8173
}

0 commit comments

Comments
 (0)