diff --git a/apps/mobile/src/MobileEditablePropertyPickers.tsx b/apps/mobile/src/MobileEditablePropertyPickers.tsx new file mode 100644 index 00000000..ac6beb63 --- /dev/null +++ b/apps/mobile/src/MobileEditablePropertyPickers.tsx @@ -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 ( + <> + onSelectPicker('type')} + > + + {mobileNoteTypeOptions.map((option) => ( + onChangeProperties?.({ type })} + /> + ))} + + + onSelectPicker('status')} + > + + {mobileNoteStatusOptions.map((option) => ( + onChangeProperties?.({ status })} + /> + ))} + + + onSelectPicker('icon')} + > + + {mobileNoteIconOptions.map((option) => ( + onChangeProperties?.({ icon })} + /> + ))} + + + 0 ? `${note.tags.length} selected` : 'None'} + onOpen={() => onSelectPicker('tags')} + > + + {mobileNoteTagOptions.map((option) => ( + onChangeProperties?.({ tags: toggleMobileNoteTag(note.tags, tag) })} + /> + ))} + + + + ) +} + +function PropertyPickerSection({ + children, + disabled, + isOpen, + label, + onOpen, + value, +}: { + children: ReactNode + disabled: boolean + isOpen: boolean + label: string + onOpen: () => void + value: string +}) { + return ( + <> + + {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 ( + [styles.propertyRow, disabled ? styles.propertyDisabled : null, pressed ? styles.pressed : null]} + > + {label} + {value} + + + ) +} + +function PropertyIconChip({ + disabled, + onSelect, + option, + value, +}: { + disabled: boolean + onSelect: (option: string) => void + option: string + value: string | undefined +}) { + const isSelected = isMobileNotePropertySelected({ current: value, option }) + + return ( + onSelect(option)} + style={styles.propertyIconChip} + > + + + ) +} + +function PropertyChipOptions({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ) +} + +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 ( + onSelect(option)} + style={styles.propertyChip} + > + + {option || 'None'} + + + ) +} + +function SelectablePropertyChip({ + children, + disabled, + isSelected, + onPress, + style, +}: { + children: ReactNode + disabled: boolean + isSelected: boolean + onPress: () => void + style: StyleProp +}) { + return ( + [ + style, + isSelected ? styles.propertyChipSelected : null, + disabled ? styles.propertyDisabled : null, + pressed ? styles.pressed : null, + ]} + > + {children} + + ) +} diff --git a/apps/mobile/src/MobilePropertiesPanel.tsx b/apps/mobile/src/MobilePropertiesPanel.tsx index b7ecc6e5..a509edac 100644 --- a/apps/mobile/src/MobilePropertiesPanel.tsx +++ b/apps/mobile/src/MobilePropertiesPanel.tsx @@ -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(null) + const selectPicker = (selected: MobilePropertyPickerKey) => { + setOpenPicker((current) => nextMobilePropertyPicker({ current, selected })) + } return ( {failed ? Could not save property. : null} - onChangeProperties?.({ type })} - /> - onChangeProperties?.({ status })} - /> - onChangeProperties?.({ icon })} + note={note} + openPicker={openPicker} + onChangeProperties={onChangeProperties} + onSelectPicker={selectPicker} /> - Tags - onChangeProperties?.({ tags: toggleMobileNoteTag(note.tags, tag) })} - /> History eb373865c - Updated 1 note 5e853fdfe - Updated 1 note @@ -135,126 +111,3 @@ function PropertyRow({ {content} ) } - -function PropertyOptionGroup({ - disabled, - label, - onSelect, - options, - value, -}: { - disabled: boolean - label: string - onSelect: (option: string) => void - options: readonly string[] - value: string | undefined -}) { - return ( - - {label} - - - ) -} - -function PropertyIconOptionGroup({ - disabled, - label, - onSelect, - options, - value, -}: { - disabled: boolean - label: string - onSelect: (option: string) => void - options: readonly string[] - value: string | undefined -}) { - return ( - - {label} - - {options.map((option) => { - const isSelected = isMobileNotePropertySelected({ current: value, option }) - - return ( - onSelect(option)} - style={styles.propertyIconChip} - > - - - ) - })} - - - ) -} - -function PropertyOptionChips({ - disabled, - onSelect, - options, - value, -}: { - disabled: boolean - onSelect: (option: string) => void - options: readonly string[] - value: readonly string[] | string | undefined -}) { - return ( - - {options.map((option) => { - const isSelected = Array.isArray(value) - ? value.includes(option) - : isMobileNotePropertySelected({ current: typeof value === 'string' ? value : undefined, option }) - - return ( - onSelect(option)} - style={styles.propertyChip} - > - - {option || 'None'} - - - ) - })} - - ) -} - -function SelectablePropertyChip({ - children, - disabled, - isSelected, - onPress, - style, -}: { - children: ReactNode - disabled: boolean - isSelected: boolean - onPress: () => void - style: StyleProp -}) { - return ( - [ - style, - isSelected ? styles.propertyChipSelected : null, - disabled ? styles.propertyDisabled : null, - pressed ? styles.pressed : null, - ]} - > - {children} - - ) -} diff --git a/apps/mobile/src/mobilePropertyPicker.test.ts b/apps/mobile/src/mobilePropertyPicker.test.ts new file mode 100644 index 00000000..5afd6d32 --- /dev/null +++ b/apps/mobile/src/mobilePropertyPicker.test.ts @@ -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') + }) +}) diff --git a/apps/mobile/src/mobilePropertyPicker.ts b/apps/mobile/src/mobilePropertyPicker.ts new file mode 100644 index 00000000..5a047457 --- /dev/null +++ b/apps/mobile/src/mobilePropertyPicker.ts @@ -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 +} diff --git a/apps/mobile/src/styles/propertyChipStyles.ts b/apps/mobile/src/styles/propertyChipStyles.ts index 317bd3c9..a3da1cea 100644 --- a/apps/mobile/src/styles/propertyChipStyles.ts +++ b/apps/mobile/src/styles/propertyChipStyles.ts @@ -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', diff --git a/docs/MOBILE_PROGRESS.md b/docs/MOBILE_PROGRESS.md index ac889a29..d5c67016 100644 --- a/docs/MOBILE_PROGRESS.md +++ b/docs/MOBILE_PROGRESS.md @@ -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