Skip to content

Commit 585b2aa

Browse files
committed
feat(ui): tri-state action button display mode setting
Add a persistent tri-state setting for how row action buttons appear throughout the app: icons (default), text, or both (icon + label). - useActionMode hook wraps useLocalStorage with key 'action-mode' and a typed ActionMode union; default is 'icons' - Settings page gains an 'Action buttons' section with three segmented toggle buttons (icons / text / icons + text); selection is highlighted with the ink background - TeamList and PlayerList row actions (QR, Edit, Archive) respect the mode: icon-only, text-only, or icon + text side-by-side - aria-label on every action button is always present regardless of display mode, so assistive technology is unaffected Closes #138
1 parent f896e9c commit 585b2aa

6 files changed

Lines changed: 137 additions & 15 deletions

File tree

src/components/players-teams/PlayerList.tsx

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import { resolveIcon } from './teamIcons'
77
import PlayerForm from './PlayerForm'
88
import BulkActionBar from './BulkActionBar'
99
import PlayerQrModal from './PlayerQrModal'
10+
import { useActionMode } from '@/hooks/useActionMode'
11+
import type { ActionMode } from '@/hooks/useActionMode'
12+
import { QrCode, Pencil, Archive } from 'lucide-react'
1013

1114
type LabelFilter = Record<string, 'include' | 'exclude'>
1215

@@ -32,6 +35,7 @@ export default function PlayerList({ filterTeamId, search = '', labelFilter = {}
3235
const players = useLiveQuery(() => db.managedPlayers.orderBy('name').toArray(), [])
3336
const teams = useLiveQuery(() => db.managedTeams.toArray(), [])
3437
const labels = useLiveQuery(() => db.managedLabels.toArray(), [])
38+
const [actionMode] = useActionMode()
3539

3640
const [editing, setEditing] = useState<ManagedPlayer | null | undefined>(undefined)
3741
const [qrTarget, setQrTarget] = useState<ManagedPlayer | null>(null)
@@ -124,6 +128,7 @@ export default function PlayerList({ filterTeamId, search = '', labelFilter = {}
124128
onEdit={() => setEditing(player)}
125129
onArchive={() => archive(player)}
126130
onQr={() => setQrTarget(player)}
131+
actionMode={actionMode}
127132
/>
128133
))}
129134

@@ -135,6 +140,7 @@ export default function PlayerList({ filterTeamId, search = '', labelFilter = {}
135140
labelMap={labelMap}
136141
archived
137142
onRestore={() => restore(player)}
143+
actionMode={actionMode}
138144
/>
139145
))}
140146
</div>
@@ -164,6 +170,7 @@ interface RowProps {
164170
onArchive?: () => void
165171
onQr?: () => void
166172
onRestore?: () => void
173+
actionMode: ActionMode
167174
}
168175

169176
function PlayerRow({
@@ -177,10 +184,14 @@ function PlayerRow({
177184
onArchive,
178185
onQr,
179186
onRestore,
187+
actionMode,
180188
}: RowProps) {
181189
const playerTeams = player.teamIds.map(id => teamMap[id]).filter(Boolean)
182190
const playerLabels = player.labelIds.map(id => labelMap[id]).filter(Boolean)
183191

192+
const showIcon = actionMode === 'icons' || actionMode === 'both'
193+
const showText = actionMode === 'text' || actionMode === 'both'
194+
184195
return (
185196
<div
186197
className="flex items-center gap-3 px-3 py-2.5 border-b last:border-b-0"
@@ -265,27 +276,30 @@ function PlayerRow({
265276
<>
266277
<button
267278
onClick={onQr}
268-
className="text-xs px-2 py-1 rounded transition-colors hover:bg-black/5"
279+
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded transition-colors hover:bg-black/5"
269280
style={{ color: 'var(--color-muted)' }}
270281
aria-label={`Show QR for ${player.name}`}
271282
>
272-
QR
283+
{showIcon && <Icon icon={QrCode} size="sm" aria-hidden />}
284+
{showText && 'QR'}
273285
</button>
274286
<button
275287
onClick={onEdit}
276-
className="text-xs px-2 py-1 rounded transition-colors hover:bg-black/5"
288+
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded transition-colors hover:bg-black/5"
277289
style={{ color: 'var(--color-muted)' }}
278290
aria-label={`Edit ${player.name}`}
279291
>
280-
Edit
292+
{showIcon && <Icon icon={Pencil} size="sm" aria-hidden />}
293+
{showText && 'Edit'}
281294
</button>
282295
<button
283296
onClick={onArchive}
284-
className="text-xs px-2 py-1 rounded transition-colors hover:bg-black/5"
297+
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded transition-colors hover:bg-black/5"
285298
style={{ color: 'var(--color-red)' }}
286299
aria-label={`Archive ${player.name}`}
287300
>
288-
Archive
301+
{showIcon && <Icon icon={Archive} size="sm" aria-hidden />}
302+
{showText && 'Archive'}
289303
</button>
290304
</>
291305
)}

src/components/players-teams/TeamList.tsx

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import { resolveIcon } from './teamIcons'
77
import TeamForm from './TeamForm'
88
import TeamQrModal from './TeamQrModal'
99
import TeamBulkActionBar from './TeamBulkActionBar'
10+
import { useActionMode } from '@/hooks/useActionMode'
11+
import type { ActionMode } from '@/hooks/useActionMode'
12+
import { QrCode, Pencil, Archive } from 'lucide-react'
1013

1114
type LabelFilter = Record<string, 'include' | 'exclude'>
1215

@@ -34,6 +37,7 @@ export default function TeamList({
3437
selectedTeamId,
3538
}: Props) {
3639
const teams = useLiveQuery(() => db.managedTeams.orderBy('name').toArray(), [])
40+
const [actionMode] = useActionMode()
3741

3842
const visible = (teams ?? []).filter(t => {
3943
if (search && !t.name.toLowerCase().includes(search.toLowerCase())) return false
@@ -119,11 +123,12 @@ export default function TeamList({
119123
onEdit={() => setEditing(team)}
120124
onArchive={() => archive(team)}
121125
onQr={() => setQrTarget(team)}
126+
actionMode={actionMode}
122127
/>
123128
))}
124129

125130
{archived.map(team => (
126-
<TeamRow key={team.id} team={team} archived onRestore={() => restore(team)} />
131+
<TeamRow key={team.id} team={team} archived onRestore={() => restore(team)} actionMode={actionMode} />
127132
))}
128133
</div>
129134

@@ -154,6 +159,7 @@ interface RowProps {
154159
onArchive?: () => void
155160
onQr?: () => void
156161
onRestore?: () => void
162+
actionMode: ActionMode
157163
}
158164

159165
function TeamRow({
@@ -167,9 +173,13 @@ function TeamRow({
167173
onArchive,
168174
onQr,
169175
onRestore,
176+
actionMode,
170177
}: RowProps) {
171178
const TeamIcon = resolveIcon(team.icon)
172179

180+
const showIcon = actionMode === 'icons' || actionMode === 'both'
181+
const showText = actionMode === 'text' || actionMode === 'both'
182+
173183
return (
174184
<div
175185
className="flex items-center gap-3 px-3 py-2.5 border-b last:border-b-0 transition-colors"
@@ -250,33 +260,36 @@ function TeamRow({
250260
e.stopPropagation()
251261
onQr?.()
252262
}}
253-
className="text-xs px-2 py-1 rounded transition-colors hover:bg-black/5"
263+
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded transition-colors hover:bg-black/5"
254264
style={{ color: 'var(--color-muted)' }}
255265
aria-label={`Show QR for ${team.name}`}
256266
>
257-
QR
267+
{showIcon && <Icon icon={QrCode} size="sm" aria-hidden />}
268+
{showText && 'QR'}
258269
</button>
259270
<button
260271
onClick={e => {
261272
e.stopPropagation()
262273
onEdit?.()
263274
}}
264-
className="text-xs px-2 py-1 rounded transition-colors hover:bg-black/5"
275+
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded transition-colors hover:bg-black/5"
265276
style={{ color: 'var(--color-muted)' }}
266277
aria-label={`Edit ${team.name}`}
267278
>
268-
Edit
279+
{showIcon && <Icon icon={Pencil} size="sm" aria-hidden />}
280+
{showText && 'Edit'}
269281
</button>
270282
<button
271283
onClick={e => {
272284
e.stopPropagation()
273285
onArchive?.()
274286
}}
275-
className="text-xs px-2 py-1 rounded transition-colors hover:bg-black/5"
287+
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded transition-colors hover:bg-black/5"
276288
style={{ color: 'var(--color-red)' }}
277289
aria-label={`Archive ${team.name}`}
278290
>
279-
Archive
291+
{showIcon && <Icon icon={Archive} size="sm" aria-hidden />}
292+
{showText && 'Archive'}
280293
</button>
281294
</>
282295
)}

src/hooks/useActionMode.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { useLocalStorage } from './useLocalStorage'
2+
3+
/**
4+
* Tri-state display mode for row action buttons.
5+
*
6+
* icons — icon only (default)
7+
* text — text label only
8+
* both — icon + text label
9+
*/
10+
export type ActionMode = 'icons' | 'text' | 'both'
11+
12+
export function useActionMode() {
13+
return useLocalStorage<ActionMode>('action-mode', 'icons')
14+
}

src/pages/admin/Settings.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,22 @@ import { purgeDatabase, seedDefaults } from '@/db'
77
import { useState } from 'react'
88
import { Button, Modal, Input, Icon } from '@/components/ui'
99
import { Download, Trash2 } from 'lucide-react'
10+
import { useActionMode } from '@/hooks/useActionMode'
11+
import type { ActionMode } from '@/hooks/useActionMode'
12+
13+
const ACTION_MODE_OPTIONS: { value: ActionMode; label: string; description: string }[] = [
14+
{ value: 'icons', label: 'Icons', description: 'Show icon only' },
15+
{ value: 'text', label: 'Text', description: 'Show label only' },
16+
{ value: 'both', label: 'Icons + text', description: 'Show icon and label' },
17+
]
1018

1119
export default function Settings() {
1220
const [importing, setImporting] = useState(false)
1321
const [msg, setMsg] = useState<string | null>(null)
1422
const [purgeOpen, setPurgeOpen] = useState(false)
1523
const [purgeConfirm, setPurgeConfirm] = useState('')
1624
const [purging, setPurging] = useState(false)
25+
const [actionMode, setActionMode] = useActionMode()
1726

1827
async function handleImport(e: React.ChangeEvent<HTMLInputElement>) {
1928
const file = e.target.files?.[0]
@@ -67,6 +76,39 @@ export default function Settings() {
6776
{/* ── Labels ───────────────────────────────────────────── */}
6877
<ManageLabels />
6978

79+
{/* ── Action buttons ───────────────────────────────────── */}
80+
<section>
81+
<div className="mb-3">
82+
<h2 className="font-semibold text-base" style={{ color: 'var(--color-ink)' }}>
83+
Action buttons
84+
</h2>
85+
<p className="text-xs mt-0.5" style={{ color: 'var(--color-muted)' }}>
86+
How row actions are displayed throughout the app
87+
</p>
88+
</div>
89+
90+
<div className="flex gap-2">
91+
{ACTION_MODE_OPTIONS.map(opt => (
92+
<button
93+
key={opt.value}
94+
onClick={() => setActionMode(opt.value)}
95+
className="flex-1 flex flex-col items-center gap-1 rounded-lg border py-3 px-2 text-xs font-medium transition-all"
96+
style={{
97+
borderColor:
98+
actionMode === opt.value ? 'var(--color-ink)' : 'var(--color-border)',
99+
background:
100+
actionMode === opt.value ? 'var(--color-ink)' : 'var(--color-surface)',
101+
color: actionMode === opt.value ? 'var(--color-cream)' : 'var(--color-muted)',
102+
}}
103+
aria-pressed={actionMode === opt.value}
104+
aria-label={opt.description}
105+
>
106+
{opt.label}
107+
</button>
108+
))}
109+
</div>
110+
</section>
111+
70112
{/* ── Data ─────────────────────────────────────────────── */}
71113
<section>
72114
<div className="mb-3">

src/test/dashboard.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,8 @@ describe('Dashboard', () => {
6363
it('shows correct question count and round sub-label', async () => {
6464
await act(async () => {
6565
await db.rounds.bulkAdd([
66-
{ id: 'r1', title: 'R1', order: 0, createdAt: Date.now() },
67-
{ id: 'r2', title: 'R2', order: 1, createdAt: Date.now() },
66+
{ id: 'r1', name: 'R1', description: '', questionIds: [], createdAt: Date.now() },
67+
{ id: 'r2', name: 'R2', description: '', questionIds: [], createdAt: Date.now() },
6868
])
6969
await db.questions.bulkAdd([
7070
{

src/test/useActionMode.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { describe, it, expect, beforeEach } from 'vitest'
2+
import { renderHook, act } from '@testing-library/react'
3+
import { useActionMode } from '@/hooks/useActionMode'
4+
5+
beforeEach(() => {
6+
localStorage.clear()
7+
})
8+
9+
describe('useActionMode', () => {
10+
it('defaults to icons', () => {
11+
const { result } = renderHook(() => useActionMode())
12+
expect(result.current[0]).toBe('icons')
13+
})
14+
15+
it('persists to localStorage on change', () => {
16+
const { result } = renderHook(() => useActionMode())
17+
act(() => {
18+
result.current[1]('text')
19+
})
20+
expect(result.current[0]).toBe('text')
21+
expect(localStorage.getItem('action-mode')).toBe('"text"')
22+
})
23+
24+
it('reads back persisted value on re-mount', () => {
25+
localStorage.setItem('action-mode', '"both"')
26+
const { result } = renderHook(() => useActionMode())
27+
expect(result.current[0]).toBe('both')
28+
})
29+
30+
it('accepts all three valid modes', () => {
31+
const { result } = renderHook(() => useActionMode())
32+
for (const mode of ['icons', 'text', 'both'] as const) {
33+
act(() => {
34+
result.current[1](mode)
35+
})
36+
expect(result.current[0]).toBe(mode)
37+
}
38+
})
39+
})

0 commit comments

Comments
 (0)