Skip to content

Commit ee64dc2

Browse files
committed
fix: make room drawing discoverable in settings
1 parent 2d44767 commit ee64dc2

3 files changed

Lines changed: 161 additions & 10 deletions

File tree

‎apps/web/src/components/room/DoodlePicker.tsx‎

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,8 @@ import { useTranslations } from 'next-intl'
55
import { CustomRoomDrawing } from '@/components/ui/CustomRoomDrawing'
66
import { Doodle } from '@/components/ui/Doodle'
77
import type { DoodleName } from '@/components/ui/doodles'
8-
import { Icon } from '@/components/ui/Icon'
98
import { cn } from '@/lib/cn'
10-
import { decodeRoomDrawing, isRoomDrawing } from '@/lib/room-drawing'
9+
import { decodeRoomDrawing, isRoomDrawing, type RoomDrawing } from '@/lib/room-drawing'
1110
import { RoomDrawingEditor } from './RoomDrawingEditor'
1211

1312
/**
@@ -41,7 +40,24 @@ export const ROOM_DOODLES = [
4140
'cake',
4241
] as const satisfies readonly DoodleName[]
4342

44-
export function DoodlePicker({ value, onChange }: { value: string; onChange: (value: string) => void }) {
43+
const DRAW_YOUR_OWN_PREVIEW: RoomDrawing = [
44+
[
45+
{ x: 0.14, y: 0.64 },
46+
{ x: 0.28, y: 0.36 },
47+
{ x: 0.43, y: 0.62 },
48+
{ x: 0.58, y: 0.32 },
49+
{ x: 0.78, y: 0.57 },
50+
],
51+
]
52+
53+
interface DoodlePickerProps {
54+
value: string
55+
onChange: (value: string) => void
56+
/** Lets a containing picker keep itself mounted while the portaled editor is in use. */
57+
onDrawingOpenChange?: (open: boolean) => void
58+
}
59+
60+
export function DoodlePicker({ value, onChange, onDrawingOpenChange }: DoodlePickerProps) {
4561
const t = useTranslations('room.create')
4662
const [drawingOpen, setDrawingOpen] = useState(false)
4763
const optionRefs = useRef<Array<HTMLButtonElement | null>>([])
@@ -52,6 +68,11 @@ export function DoodlePicker({ value, onChange }: { value: string; onChange: (va
5268
const tabStopIndex = selectedIndex === -1 ? 0 : selectedIndex
5369
const optionCount = ROOM_DOODLES.length + 1
5470

71+
const setEditorOpen = (open: boolean) => {
72+
setDrawingOpen(open)
73+
onDrawingOpenChange?.(open)
74+
}
75+
5576
/**
5677
* Buttons with radio roles do not inherit native radio keyboard behaviour.
5778
* Keep one option in the page's Tab order, then make the radio-group keys
@@ -72,7 +93,7 @@ export function DoodlePicker({ value, onChange }: { value: string; onChange: (va
7293
: (currentIndex + 1) % optionCount
7394
optionRefs.current[nextIndex]?.focus()
7495
if (nextIndex === ROOM_DOODLES.length) {
75-
setDrawingOpen(true)
96+
setEditorOpen(true)
7697
return
7798
}
7899
onChange(ROOM_DOODLES[nextIndex])
@@ -117,7 +138,7 @@ export function DoodlePicker({ value, onChange }: { value: string; onChange: (va
117138
data-doodle="custom"
118139
tabIndex={ROOM_DOODLES.length === tabStopIndex ? 0 : -1}
119140
onKeyDown={(event) => moveSelection(event, ROOM_DOODLES.length)}
120-
onClick={() => setDrawingOpen(true)}
141+
onClick={() => setEditorOpen(true)}
121142
className={cn(
122143
'flex size-11 items-center justify-center rounded-sm border border-dashed border-n-1 bg-primary-2 transition-transform active:translate-y-[2px]',
123144
customSelected && 'shadow-4 border-solid bg-primary-1'
@@ -126,11 +147,16 @@ export function DoodlePicker({ value, onChange }: { value: string; onChange: (va
126147
{customStrokes.length ? (
127148
<CustomRoomDrawing strokes={customStrokes} weight={1.6} className="size-8" />
128149
) : (
129-
<Icon name="pencil" size={24} aria-hidden="true" />
150+
<CustomRoomDrawing
151+
strokes={DRAW_YOUR_OWN_PREVIEW}
152+
weight={2.8}
153+
className="size-8"
154+
aria-hidden="true"
155+
/>
130156
)}
131157
</button>
132158
</div>
133-
<RoomDrawingEditor open={drawingOpen} value={value} onChange={onChange} onOpenChange={setDrawingOpen} />
159+
<RoomDrawingEditor open={drawingOpen} value={value} onChange={onChange} onOpenChange={setEditorOpen} />
134160
</>
135161
)
136162
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { createElement, type ComponentProps, type ReactNode } from 'react'
2+
import { renderToStaticMarkup } from 'react-dom/server'
3+
import { describe, expect, it, vi } from 'vitest'
4+
import type { RoomState } from '@/lib/api-types'
5+
6+
vi.mock('next-intl', () => ({
7+
useLocale: () => 'en',
8+
useTranslations: (namespace: string) => (key: string) => `${namespace}.${key}`,
9+
}))
10+
11+
vi.mock('@/components/pwa/PushOptIn', () => ({ PushOptIn: () => null }))
12+
vi.mock('@/components/room/PeopleSection', () => ({ PeopleSection: () => null }))
13+
vi.mock('@/components/room/RoomEmblem', () => ({
14+
RoomEmblem: ({ value }: { value: string | null }) => createElement('span', { 'data-room-emblem': value ?? 'auto' }),
15+
}))
16+
vi.mock('@/components/room/RoomExport', () => ({ RoomExport: () => null }))
17+
vi.mock('@/components/room/ThemePicker', () => ({ ThemePicker: () => null }))
18+
vi.mock('@/components/ui/BaseInput', () => ({
19+
BaseInput: (props: ComponentProps<'input'>) => createElement('input', props),
20+
}))
21+
vi.mock('@/components/ui/Button', () => ({
22+
Button: ({
23+
children,
24+
icon: _icon,
25+
shadowSize: _shadowSize,
26+
...props
27+
}: ComponentProps<'button'> & { icon?: string; shadowSize?: string }) => createElement('button', props, children),
28+
}))
29+
vi.mock('@/components/ui/CloseButton', () => ({ CloseButton: () => null }))
30+
vi.mock('@/components/ui/Drawer', () => ({
31+
Drawer: ({ children }: { children?: ReactNode }) => children,
32+
DrawerContent: ({ children, ...props }: ComponentProps<'div'>) => createElement('div', props, children),
33+
DrawerHeader: ({ children, ...props }: ComponentProps<'div'>) => createElement('div', props, children),
34+
DrawerTitle: ({ children, ...props }: ComponentProps<'h2'>) => createElement('h2', props, children),
35+
}))
36+
vi.mock('@/components/ui/DrawerLayout', () => ({
37+
DrawerActions: ({ children, ...props }: ComponentProps<'div'>) => createElement('div', props, children),
38+
DrawerBody: ({ children, ...props }: ComponentProps<'div'>) => createElement('div', props, children),
39+
drawerContentClass: '',
40+
drawerHeaderClass: '',
41+
}))
42+
vi.mock('@/components/ui/Doodle', () => ({
43+
Doodle: ({ name }: { name: string }) => createElement('span', { 'data-rendered-doodle': name }),
44+
}))
45+
vi.mock('@/components/ui/Icon', () => ({
46+
Icon: ({ name }: { name: string }) => createElement('span', { 'data-room-picker-icon': name }),
47+
}))
48+
vi.mock('@/components/ui/SettingRow', () => ({
49+
SettingRow: ({ label, trailing }: { label: string; trailing?: ReactNode }) =>
50+
createElement('div', null, label, trailing),
51+
}))
52+
vi.mock('@/components/ui/SlideToConfirm', () => ({ SlideToConfirm: () => null }))
53+
vi.mock('@/lib/analytics', () => ({ roomProps: () => ({}), track: () => undefined }))
54+
vi.mock('@/lib/clipboard', () => ({ copyText: async () => true }))
55+
vi.mock('@/lib/error-messages', () => ({ useErrorMessage: () => () => 'error' }))
56+
vi.mock('@/lib/queries', () => ({
57+
useSetEmblem: () => ({ isPending: false, mutate: vi.fn() }),
58+
useSetRoomName: () => ({ isPending: false, mutate: vi.fn() }),
59+
useSetTheme: () => ({ isPending: false, mutate: vi.fn() }),
60+
}))
61+
vi.mock('@/lib/use-settings', () => ({ useFeedback: () => () => undefined }))
62+
vi.mock('./DeviceSheet', () => ({ DeviceSheet: () => null }))
63+
vi.mock('./HistorySheet', () => ({ HistorySheet: () => null }))
64+
vi.mock('./RoomDrawingEditor', () => ({ RoomDrawingEditor: () => null }))
65+
66+
import { ROOM_DOODLES } from './DoodlePicker'
67+
import { SettingsSheet } from './SettingsSheet'
68+
69+
const state: RoomState = {
70+
room: {
71+
id: 'room-1',
72+
slug: 'summer-trip',
73+
name: 'Summer trip',
74+
emoji: null,
75+
currency: 'USD',
76+
coverUrl: null,
77+
theme: null,
78+
createdAt: '2026-08-03T10:00:00.000Z',
79+
archivedAt: null,
80+
},
81+
members: [],
82+
expenses: [],
83+
settlements: [],
84+
balances: {},
85+
suggestedTransfers: [],
86+
}
87+
88+
const renderSettings = () =>
89+
renderToStaticMarkup(
90+
<SettingsSheet
91+
open
92+
onClose={() => undefined}
93+
room={state.room}
94+
members={state.members}
95+
state={state}
96+
identity={null}
97+
me={null}
98+
onShare={() => undefined}
99+
onForgetIdentity={() => undefined}
100+
onOpenCharacter={() => undefined}
101+
/>
102+
)
103+
104+
describe('SettingsSheet room drawing picker', () => {
105+
it('offers the custom drawing after every preset inside room settings', () => {
106+
const html = renderSettings()
107+
const offeredDrawings = [...html.matchAll(/data-doodle="([^"]+)"/g)].map((match) => match[1])
108+
109+
expect(offeredDrawings).toEqual([...ROOM_DOODLES, 'custom'])
110+
expect(html.indexOf('data-testid="room-card"')).toBeLessThan(html.indexOf('data-doodle="custom"'))
111+
expect(html).toContain('aria-label="room.create.drawYourOwn"')
112+
})
113+
114+
it('does not present the custom room drawing as a pencil room icon', () => {
115+
const html = renderSettings()
116+
117+
expect(html).not.toContain('data-room-picker-icon="pencil"')
118+
expect(html).not.toContain('data-rendered-doodle="iconpencil"')
119+
})
120+
})

‎apps/web/src/components/room/SettingsSheet.tsx‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,20 +83,21 @@ export function SettingsSheet({
8383
const [historyOpen, setHistoryOpen] = useState(false)
8484
const [switchOpen, setSwitchOpen] = useState(false)
8585
const [pickerOpen, setPickerOpen] = useState(false)
86+
const [drawingEditorOpen, setDrawingEditorOpen] = useState(false)
8687
const pickerRef = useRef<HTMLDetailsElement>(null)
8788
const pickerSummaryRef = useRef<HTMLElement>(null)
8889

8990
/** A `<details>` has no light-dismiss of its own, so the grid would sit over the
9091
* card — including over the name field beside it — until it was toggled again.
9192
* Same fix, same reason, as the create form. */
9293
useEffect(() => {
93-
if (!pickerOpen) return
94+
if (!pickerOpen || drawingEditorOpen) return
9495
const onPointerDown = (event: PointerEvent) => {
9596
if (!pickerRef.current?.contains(event.target as Node)) setPickerOpen(false)
9697
}
9798
document.addEventListener('pointerdown', onPointerDown)
9899
return () => document.removeEventListener('pointerdown', onPointerDown)
99-
}, [pickerOpen])
100+
}, [drawingEditorOpen, pickerOpen])
100101

101102
// The rename is optimistic, so the room's name is authoritative the instant
102103
// it commits — and a rename arriving from another device belongs in the
@@ -239,7 +240,11 @@ export function SettingsSheet({
239240
<RoomEmblem value={room.emoji} name={nameForDoodle} size={24} />
240241
</summary>
241242
<div className="shadow-4 absolute left-0 z-20 mt-2 w-64 rounded-sm border border-n-1 bg-white p-3">
242-
<DoodlePicker value={shownEmblem} onChange={chooseEmblem} />
243+
<DoodlePicker
244+
value={shownEmblem}
245+
onChange={chooseEmblem}
246+
onDrawingOpenChange={setDrawingEditorOpen}
247+
/>
243248
</div>
244249
</details>
245250
<BaseInput

0 commit comments

Comments
 (0)