feat: add expandable mobile property pickers
This commit is contained in:
256
apps/mobile/src/MobileEditablePropertyPickers.tsx
Normal file
256
apps/mobile/src/MobileEditablePropertyPickers.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
21
apps/mobile/src/mobilePropertyPicker.test.ts
Normal file
21
apps/mobile/src/mobilePropertyPicker.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
21
apps/mobile/src/mobilePropertyPicker.ts
Normal file
21
apps/mobile/src/mobilePropertyPicker.ts
Normal 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
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user