feat: add expandable mobile property pickers

This commit is contained in:
lucaronin
2026-05-05 12:46:24 +02:00
parent 1861721e9a
commit a337ddc9ea
6 changed files with 325 additions and 164 deletions

View File

@@ -0,0 +1,256 @@
import { CaretDown, CaretRight } from 'phosphor-react-native'
import type { ReactNode } from 'react'
import { Pressable, Text, View, type StyleProp, type ViewStyle } from 'react-native'
import type { MobileNote } from './demoData'
import { NamedIcon, type IconName } from './NamedIcon'
import {
isMobileNotePropertySelected,
mobileNoteIconOptions,
mobileNoteStatusOptions,
mobileNoteTagOptions,
mobileNoteTypeOptions,
toggleMobileNoteTag,
type MobileNotePropertyPatch,
} from './mobileNoteProperties'
import { mobilePropertyDisplayValue, type MobilePropertyPickerKey } from './mobilePropertyPicker'
import { styles } from './styles'
import { colors } from './theme'
export function MobileEditablePropertyPickers({
disabled,
note,
onChangeProperties,
onSelectPicker,
openPicker,
}: {
disabled: boolean
note: MobileNote
onChangeProperties?: (patch: MobileNotePropertyPatch) => void
onSelectPicker: (selected: MobilePropertyPickerKey) => void
openPicker: MobilePropertyPickerKey | null
}) {
return (
<>
<PropertyPickerSection
disabled={disabled}
isOpen={openPicker === 'type'}
label="Type"
value={mobilePropertyDisplayValue({ value: note.type })}
onOpen={() => onSelectPicker('type')}
>
<PropertyChipOptions>
{mobileNoteTypeOptions.map((option) => (
<PropertyTextChip
disabled={disabled}
key={option}
option={option}
value={note.type}
onSelect={(type) => onChangeProperties?.({ type })}
/>
))}
</PropertyChipOptions>
</PropertyPickerSection>
<PropertyPickerSection
disabled={disabled}
isOpen={openPicker === 'status'}
label="Status"
value={mobilePropertyDisplayValue({ value: note.status })}
onOpen={() => onSelectPicker('status')}
>
<PropertyChipOptions>
{mobileNoteStatusOptions.map((option) => (
<PropertyTextChip
disabled={disabled}
key={option || 'none'}
option={option}
value={note.status}
onSelect={(status) => onChangeProperties?.({ status })}
/>
))}
</PropertyChipOptions>
</PropertyPickerSection>
<PropertyPickerSection
disabled={disabled}
isOpen={openPicker === 'icon'}
label="Icon"
value={mobilePropertyDisplayValue({ value: note.icon })}
onOpen={() => onSelectPicker('icon')}
>
<PropertyChipOptions>
{mobileNoteIconOptions.map((option) => (
<PropertyIconChip
disabled={disabled}
key={option}
option={option}
value={note.icon}
onSelect={(icon) => onChangeProperties?.({ icon })}
/>
))}
</PropertyChipOptions>
</PropertyPickerSection>
<PropertyPickerSection
disabled={disabled}
isOpen={openPicker === 'tags'}
label="Tags"
value={note.tags.length > 0 ? `${note.tags.length} selected` : 'None'}
onOpen={() => onSelectPicker('tags')}
>
<PropertyChipOptions>
{mobileNoteTagOptions.map((option) => (
<PropertyTextChip
disabled={disabled}
key={option}
option={option}
value={note.tags}
onSelect={(tag) => onChangeProperties?.({ tags: toggleMobileNoteTag(note.tags, tag) })}
/>
))}
</PropertyChipOptions>
</PropertyPickerSection>
</>
)
}
function PropertyPickerSection({
children,
disabled,
isOpen,
label,
onOpen,
value,
}: {
children: ReactNode
disabled: boolean
isOpen: boolean
label: string
onOpen: () => void
value: string
}) {
return (
<>
<PropertyPickerRow disabled={disabled} isOpen={isOpen} label={label} value={value} onPress={onOpen} />
{isOpen ? children : null}
</>
)
}
function PropertyPickerRow({
disabled,
isOpen,
label,
onPress,
value,
}: {
disabled: boolean
isOpen: boolean
label: string
onPress: () => void
value: string
}) {
const Caret = isOpen ? CaretDown : CaretRight
return (
<Pressable
disabled={disabled}
onPress={onPress}
style={({ pressed }) => [styles.propertyRow, disabled ? styles.propertyDisabled : null, pressed ? styles.pressed : null]}
>
<Text style={styles.propertyLabel}>{label}</Text>
<Text style={styles.propertyValue}>{value}</Text>
<Caret size={16} color={colors.textSoft} />
</Pressable>
)
}
function PropertyIconChip({
disabled,
onSelect,
option,
value,
}: {
disabled: boolean
onSelect: (option: string) => void
option: string
value: string | undefined
}) {
const isSelected = isMobileNotePropertySelected({ current: value, option })
return (
<SelectablePropertyChip
disabled={disabled}
isSelected={isSelected}
onPress={() => onSelect(option)}
style={styles.propertyIconChip}
>
<NamedIcon color={isSelected ? colors.primary : colors.textSoft} name={option as IconName} size={20} />
</SelectablePropertyChip>
)
}
function PropertyChipOptions({ children }: { children: ReactNode }) {
return (
<View style={styles.propertyPickerOptions}>
<View style={styles.propertyChipRow}>
{children}
</View>
</View>
)
}
function PropertyTextChip({
disabled,
onSelect,
option,
value,
}: {
disabled: boolean
onSelect: (option: string) => void
option: string
value: readonly string[] | string | undefined
}) {
const isSelected = Array.isArray(value)
? value.includes(option)
: isMobileNotePropertySelected({ current: typeof value === 'string' ? value : undefined, option })
return (
<SelectablePropertyChip
disabled={disabled}
isSelected={isSelected}
onPress={() => onSelect(option)}
style={styles.propertyChip}
>
<Text style={[styles.propertyChipText, isSelected ? styles.propertyChipTextSelected : null]}>
{option || 'None'}
</Text>
</SelectablePropertyChip>
)
}
function SelectablePropertyChip({
children,
disabled,
isSelected,
onPress,
style,
}: {
children: ReactNode
disabled: boolean
isSelected: boolean
onPress: () => void
style: StyleProp<ViewStyle>
}) {
return (
<Pressable
disabled={disabled}
onPress={onPress}
style={({ pressed }) => [
style,
isSelected ? styles.propertyChipSelected : null,
disabled ? styles.propertyDisabled : null,
pressed ? styles.pressed : null,
]}
>
{children}
</Pressable>
)
}

View File

@@ -1,17 +1,10 @@
import { CaretLeft } from 'phosphor-react-native'
import type { ReactNode } from 'react'
import { Pressable, ScrollView, Text, View, type StyleProp, type ViewStyle } from 'react-native'
import { useState } from 'react'
import { Pressable, ScrollView, Text, View } from 'react-native'
import type { MobileNote } from './demoData'
import { NamedIcon, type IconName } from './NamedIcon'
import {
isMobileNotePropertySelected,
mobileNoteIconOptions,
mobileNoteStatusOptions,
mobileNoteTagOptions,
mobileNoteTypeOptions,
toggleMobileNoteTag,
type MobileNotePropertyPatch,
} from './mobileNoteProperties'
import { MobileEditablePropertyPickers } from './MobileEditablePropertyPickers'
import type { MobileNotePropertyPatch } from './mobileNoteProperties'
import { nextMobilePropertyPicker, type MobilePropertyPickerKey } from './mobilePropertyPicker'
import { styles } from './styles'
import { colors } from './theme'
@@ -29,32 +22,22 @@ export function MobilePropertiesPanel({
onClose?: () => void
}) {
const today = formatMobilePropertyDate(new Date())
const [openPicker, setOpenPicker] = useState<MobilePropertyPickerKey | null>(null)
const selectPicker = (selected: MobilePropertyPickerKey) => {
setOpenPicker((current) => nextMobilePropertyPicker({ current, selected }))
}
return (
<View style={styles.properties}>
<PanelToolbar onClose={onClose} />
<ScrollView contentContainerStyle={styles.propertiesContent}>
{failed ? <Text style={styles.propertyError}>Could not save property.</Text> : null}
<PropertyOptionGroup
<MobileEditablePropertyPickers
disabled={isSaving}
label="Type"
options={mobileNoteTypeOptions}
value={note.type}
onSelect={(type) => onChangeProperties?.({ type })}
/>
<PropertyOptionGroup
disabled={isSaving}
label="Status"
options={mobileNoteStatusOptions}
value={note.status}
onSelect={(status) => onChangeProperties?.({ status })}
/>
<PropertyIconOptionGroup
disabled={isSaving}
label="Icon"
options={mobileNoteIconOptions}
value={note.icon}
onSelect={(icon) => onChangeProperties?.({ icon })}
note={note}
openPicker={openPicker}
onChangeProperties={onChangeProperties}
onSelectPicker={selectPicker}
/>
<PropertyRow
actionLabel="Today"
@@ -65,13 +48,6 @@ export function MobilePropertiesPanel({
/>
<PropertyRow label="Words" value={String(note.words)} />
<PropertyRow label="Modified" value={note.modified} />
<Text style={styles.propertyGroupTitle}>Tags</Text>
<PropertyOptionChips
disabled={isSaving}
options={mobileNoteTagOptions}
value={note.tags}
onSelect={(tag) => onChangeProperties?.({ tags: toggleMobileNoteTag(note.tags, tag) })}
/>
<Text style={styles.propertyGroupTitle}>History</Text>
<Text style={styles.historyItem}>eb373865c - Updated 1 note</Text>
<Text style={styles.historyItem}>5e853fdfe - Updated 1 note</Text>
@@ -135,126 +111,3 @@ function PropertyRow({
<View style={styles.propertyRow}>{content}</View>
)
}
function PropertyOptionGroup({
disabled,
label,
onSelect,
options,
value,
}: {
disabled: boolean
label: string
onSelect: (option: string) => void
options: readonly string[]
value: string | undefined
}) {
return (
<View style={styles.propertyOptionGroup}>
<Text style={styles.propertyLabel}>{label}</Text>
<PropertyOptionChips disabled={disabled} options={options} value={value} onSelect={onSelect} />
</View>
)
}
function PropertyIconOptionGroup({
disabled,
label,
onSelect,
options,
value,
}: {
disabled: boolean
label: string
onSelect: (option: string) => void
options: readonly string[]
value: string | undefined
}) {
return (
<View style={styles.propertyOptionGroup}>
<Text style={styles.propertyLabel}>{label}</Text>
<View style={styles.propertyChipRow}>
{options.map((option) => {
const isSelected = isMobileNotePropertySelected({ current: value, option })
return (
<SelectablePropertyChip
disabled={disabled}
isSelected={isSelected}
key={option}
onPress={() => onSelect(option)}
style={styles.propertyIconChip}
>
<NamedIcon color={isSelected ? colors.primary : colors.textSoft} name={option as IconName} size={20} />
</SelectablePropertyChip>
)
})}
</View>
</View>
)
}
function PropertyOptionChips({
disabled,
onSelect,
options,
value,
}: {
disabled: boolean
onSelect: (option: string) => void
options: readonly string[]
value: readonly string[] | string | undefined
}) {
return (
<View style={styles.propertyChipRow}>
{options.map((option) => {
const isSelected = Array.isArray(value)
? value.includes(option)
: isMobileNotePropertySelected({ current: typeof value === 'string' ? value : undefined, option })
return (
<SelectablePropertyChip
disabled={disabled}
isSelected={isSelected}
key={option || 'none'}
onPress={() => onSelect(option)}
style={styles.propertyChip}
>
<Text style={[styles.propertyChipText, isSelected ? styles.propertyChipTextSelected : null]}>
{option || 'None'}
</Text>
</SelectablePropertyChip>
)
})}
</View>
)
}
function SelectablePropertyChip({
children,
disabled,
isSelected,
onPress,
style,
}: {
children: ReactNode
disabled: boolean
isSelected: boolean
onPress: () => void
style: StyleProp<ViewStyle>
}) {
return (
<Pressable
disabled={disabled}
onPress={onPress}
style={({ pressed }) => [
style,
isSelected ? styles.propertyChipSelected : null,
disabled ? styles.propertyDisabled : null,
pressed ? styles.pressed : null,
]}
>
{children}
</Pressable>
)
}

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { mobilePropertyDisplayValue, nextMobilePropertyPicker } from './mobilePropertyPicker'
describe('nextMobilePropertyPicker', () => {
it('opens a selected picker and closes it when selected again', () => {
expect(nextMobilePropertyPicker({ current: null, selected: 'type' })).toBe('type')
expect(nextMobilePropertyPicker({ current: 'type', selected: 'type' })).toBeNull()
})
it('switches between editable property pickers', () => {
expect(nextMobilePropertyPicker({ current: 'type', selected: 'status' })).toBe('status')
})
})
describe('mobilePropertyDisplayValue', () => {
it('normalizes missing property values for compact property rows', () => {
expect(mobilePropertyDisplayValue({ value: undefined })).toBe('None')
expect(mobilePropertyDisplayValue({ value: '' })).toBe('None')
expect(mobilePropertyDisplayValue({ value: 'Essay' })).toBe('Essay')
})
})

View File

@@ -0,0 +1,21 @@
export type MobilePropertyPickerKey = 'icon' | 'status' | 'tags' | 'type'
export function nextMobilePropertyPicker({
current,
selected,
}: {
current: MobilePropertyPickerKey | null
selected: MobilePropertyPickerKey
}) {
return current === selected ? null : selected
}
export function mobilePropertyDisplayValue({
fallback = 'None',
value,
}: {
fallback?: string
value: string | undefined
}) {
return value?.trim() || fallback
}

View File

@@ -29,6 +29,11 @@ export const propertyChipStyles = StyleSheet.create({
gap: spacing.sm,
marginTop: spacing.sm,
},
propertyPickerOptions: {
paddingBottom: spacing.md,
borderBottomColor: colors.border,
borderBottomWidth: StyleSheet.hairlineWidth,
},
propertyChip: {
minHeight: 30,
justifyContent: 'center',

View File

@@ -85,14 +85,15 @@ This file is the resumable working log for Tolaria mobile. The strategy and road
- Created [ADR-0112](./adr/0112-expo-development-client-for-mobile-native-qa.md) for the mobile development-client QA path.
- Added safe TenTap image serialization for persisted relative/remote image sources while continuing to block transient image sources such as `blob:` URLs.
- Added simple TenTap table serialization for rectangular tables, including entity decoding and escaped pipe characters, while continuing to block malformed table shapes.
- Reworked the mobile properties panel into expandable Type/Status/Icon/Tags picker rows that show current values first and reveal chip choices on demand.
## Next Action
Continue Phase 4 with editor durability:
1. Replace the first property chips with richer desktop-compatible pickers once the mobile metadata schema is finalized.
2. Continue TenTap Markdown serialization coverage for any editor output observed in simulator QA.
3. Retry the iOS development-client build after installing an iOS 26.2 simulator runtime in Xcode.
1. Continue TenTap Markdown serialization coverage for any editor output observed in simulator QA.
2. Retry the iOS development-client build after installing an iOS 26.2 simulator runtime in Xcode.
3. Start the Git sync/auth vertical slice for app-local vaults.
## Verification Log
@@ -312,6 +313,10 @@ Continue Phase 4 with editor durability:
- `pnpm --filter @tolaria/mobile typecheck` passed after simple table serialization.
- CodeScene after simple table serialization: `apps/mobile/src/mobileEditorHtmlMarkdown.ts`, `apps/mobile/src/mobileEditorTableMarkdown.ts`, `apps/mobile/src/mobileEditorDraft.test.ts`, and `apps/mobile/src/mobileEditorDraftSave.test.ts` scored `10`.
- `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export` passed after simple table serialization.
- `pnpm --filter @tolaria/mobile test -- src/mobilePropertyPicker.test.ts src/mobileNoteProperties.test.ts` passed after expandable mobile property pickers: 28 files / 93 tests.
- `pnpm --filter @tolaria/mobile typecheck` passed after expandable mobile property pickers.
- CodeScene after expandable mobile property pickers: `apps/mobile/src/MobilePropertiesPanel.tsx`, `apps/mobile/src/MobileEditablePropertyPickers.tsx`, `apps/mobile/src/mobilePropertyPicker.ts`, `apps/mobile/src/mobilePropertyPicker.test.ts`, and `apps/mobile/src/styles/propertyChipStyles.ts` scored `10`.
- `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export` passed after expandable mobile property pickers.
## Risks / Watch Items