From 18b7c466cff3ddbae977b48a6c8d3146c378f3fa Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Thu, 6 Jun 2024 11:29:50 -0700 Subject: [PATCH] Appcom: initial commit --- src/Navigation.tsx | 6 + src/components/appcom/AppCom.tsx | 43 ++++++ src/components/appcom/types.ts | 6 + src/components/appcom/vocabulary/Box.tsx | 71 +++++++++ .../appcom/vocabulary/Expandable.tsx | 58 +++++++ src/components/appcom/vocabulary/Label.tsx | 45 ++++++ src/components/appcom/vocabulary/Stack.tsx | 67 ++++++++ src/components/appcom/vocabulary/Tabs.tsx | 68 +++++++++ src/components/appcom/vocabulary/common.ts | 32 ++++ src/components/appcom/vocabulary/hooks.ts | 66 ++++++++ src/components/appcom/vocabulary/index.tsx | 33 ++++ src/lib/routes/types.ts | 1 + src/routes.ts | 1 + src/view/screens/DebugAppcom.tsx | 143 ++++++++++++++++++ 14 files changed, 640 insertions(+) create mode 100644 src/components/appcom/AppCom.tsx create mode 100644 src/components/appcom/types.ts create mode 100644 src/components/appcom/vocabulary/Box.tsx create mode 100644 src/components/appcom/vocabulary/Expandable.tsx create mode 100644 src/components/appcom/vocabulary/Label.tsx create mode 100644 src/components/appcom/vocabulary/Stack.tsx create mode 100644 src/components/appcom/vocabulary/Tabs.tsx create mode 100644 src/components/appcom/vocabulary/common.ts create mode 100644 src/components/appcom/vocabulary/hooks.ts create mode 100644 src/components/appcom/vocabulary/index.tsx create mode 100644 src/view/screens/DebugAppcom.tsx diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 4ecf3fff8c..e5858e4bd4 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -65,6 +65,7 @@ import { import {AccessibilitySettingsScreen} from './view/screens/AccessibilitySettings' import {CommunityGuidelinesScreen} from './view/screens/CommunityGuidelines' import {CopyrightPolicyScreen} from './view/screens/CopyrightPolicy' +import {DebugAppcomScreen} from './view/screens/DebugAppcom' import {DebugModScreen} from './view/screens/DebugMod' import {FeedsScreen} from './view/screens/Feeds' import {HomeScreen} from './view/screens/Home' @@ -233,6 +234,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { getComponent={() => DebugModScreen} options={{title: title(msg`Moderation states`), requireAuth: true}} /> + DebugAppcomScreen} + options={{title: title(msg`Application components`), requireAuth: true}} + /> LogScreen} diff --git a/src/components/appcom/AppCom.tsx b/src/components/appcom/AppCom.tsx new file mode 100644 index 0000000000..38a604b244 --- /dev/null +++ b/src/components/appcom/AppCom.tsx @@ -0,0 +1,43 @@ +import React from 'react' +import {View} from 'react-native' + +import {atoms as a, useTheme} from '#/alf' +import {Text} from '../Typography' +import type {AppComNode} from './types' +import {AppComponent} from './vocabulary' + +export function AppComponentRegion({ + tree, + origin, +}: { + tree: AppComNode + origin: string +}) { + const t = useTheme() + return ( + + + + {origin} + + + + + + + ) +} diff --git a/src/components/appcom/types.ts b/src/components/appcom/types.ts new file mode 100644 index 0000000000..93faa666a6 --- /dev/null +++ b/src/components/appcom/types.ts @@ -0,0 +1,6 @@ +export interface AppComNode { + type: string + key?: string + props?: Record + children?: Array +} diff --git a/src/components/appcom/vocabulary/Box.tsx b/src/components/appcom/vocabulary/Box.tsx new file mode 100644 index 0000000000..4ba0c38b2e --- /dev/null +++ b/src/components/appcom/vocabulary/Box.tsx @@ -0,0 +1,71 @@ +import React from 'react' +import {View} from 'react-native' +import {z} from 'zod' + +import {pad} from './common' +import {useBackgroundColor, useBorderColor} from './hooks' + +const boxProps = z.object({ + pad, + + corner: z + .union([ + z + .number() + .positive() + .transform(v => ({borderRadius: v})), + z + .object({ + tl: z.number().positive().optional(), + tr: z.number().positive().optional(), + bl: z.number().positive().optional(), + br: z.number().positive().optional(), + }) + .transform(obj => ({ + borderTopLeftRadius: obj.tl, + borderTopRightRadius: obj.tr, + borderBottomLeftRadius: obj.bl, + borderBottomRightRadius: obj.br, + })), + ]) + .optional(), + + background: z + .enum([ + 'default', + 'primary', + 'secondary', + 'positive', + 'negative', + 'inverted', + 'none', + ]) + .optional(), + + border: z + .enum([ + 'default', + 'primary', + 'secondary', + 'positive', + 'negative', + 'inverted', + 'none', + ]) + .optional(), +}) + +export type BoxProps = z.infer + +export function Box(props: React.PropsWithChildren) { + const styles = boxProps.parse(props) + + const backgroundColor = useBackgroundColor(styles.background) + const borderColor = useBorderColor(styles.border) + + return ( + + {props.children} + + ) +} diff --git a/src/components/appcom/vocabulary/Expandable.tsx b/src/components/appcom/vocabulary/Expandable.tsx new file mode 100644 index 0000000000..b8d37d62f2 --- /dev/null +++ b/src/components/appcom/vocabulary/Expandable.tsx @@ -0,0 +1,58 @@ +import React from 'react' +import {View} from 'react-native' +import {z} from 'zod' + +import {atoms as a, useTheme} from '#/alf' +import {Button} from '#/components/Button' +import { + ChevronBottom_Stroke2_Corner0_Rounded as ChevronBottom, + ChevronRight_Stroke2_Corner0_Rounded as ChevronRight, +} from '#/components/icons/Chevron' +import {Text} from '#/components/Typography' + +const expandableProps = z.object({ + label: z.string(), + defaultExpanded: z.boolean().default(false), +}) + +export type ExpandableProps = z.infer + +export function Expandable(props: React.PropsWithChildren) { + const t = useTheme() + + const propsParsed = expandableProps.parse(props) + const [expanded, setExpanded] = React.useState(propsParsed.defaultExpanded) + + return ( + + + + {props.children} + + + ) +} diff --git a/src/components/appcom/vocabulary/Label.tsx b/src/components/appcom/vocabulary/Label.tsx new file mode 100644 index 0000000000..6d11f07f9e --- /dev/null +++ b/src/components/appcom/vocabulary/Label.tsx @@ -0,0 +1,45 @@ +import React from 'react' +import {z} from 'zod' + +import {Text} from '#/components/Typography' +import {useFontColor} from './hooks' + +const labelProps = z.object({ + text: z.string(), + color: z + .enum([ + 'default', + 'primary', + 'secondary', + 'positive', + 'negative', + 'inverted', + ]) + .default('default'), + size: z + .number() + .positive() + .default(16) + .transform(v => ({fontSize: v})), + lineHeight: z + .number() + .positive() + .default(1) + .transform(v => ({lineHeight: v})), + weight: z + .enum(['normal', 'semibold', 'bold']) + .default('normal') + .transform(v => ({fontWeight: v})), +}) + +export type LabelProps = z.infer + +export function Label(props: React.PropsWithChildren) { + const styles = labelProps.parse(props) + const fontColor = useFontColor(styles.color) + return ( + + {props.text} + + ) +} diff --git a/src/components/appcom/vocabulary/Stack.tsx b/src/components/appcom/vocabulary/Stack.tsx new file mode 100644 index 0000000000..6a1a93a62d --- /dev/null +++ b/src/components/appcom/vocabulary/Stack.tsx @@ -0,0 +1,67 @@ +import React from 'react' +import {FlexAlignType, FlexStyle, View} from 'react-native' +import {z} from 'zod' + +import {gap, pad} from './common' + +const stackProps = z.object({ + gap, + pad, + + direction: z + .enum(['row', 'column']) + .default('column') + .transform(v => ({flexDirection: v})), + + align: z + .enum(['start', 'center', 'end', 'stretch']) + .default('stretch') + .transform(v => { + if (v === 'start') { + return {alignItems: 'flex-start' as FlexAlignType} + } + if (v === 'end') { + return {alignItems: 'flex-end' as FlexAlignType} + } + return {alignItems: v as FlexAlignType} + }), + + justify: z + .enum([ + 'start', + 'center', + 'end', + 'space-between', + 'space-around', + 'space-evenly', + ]) + .default('start') + .transform(v => { + if (v === 'start') { + return {justifyContent: 'flex-start' as FlexStyle['justifyContent']} + } + if (v === 'end') { + return {justifyContent: 'flex-end' as FlexStyle['justifyContent']} + } + return {justifyContent: v as FlexStyle['justifyContent']} + }), +}) + +export type StackProps = z.infer + +export function Stack(props: React.PropsWithChildren) { + const styles = stackProps.parse(props) + return ( + + {props.children} + + ) +} diff --git a/src/components/appcom/vocabulary/Tabs.tsx b/src/components/appcom/vocabulary/Tabs.tsx new file mode 100644 index 0000000000..a1a6f076ed --- /dev/null +++ b/src/components/appcom/vocabulary/Tabs.tsx @@ -0,0 +1,68 @@ +import React from 'react' +import {View} from 'react-native' +import {z} from 'zod' + +import {atoms as a, useTheme} from '#/alf' +import {Button} from '#/components/Button' +import {Text} from '#/components/Typography' + +const tabsProps = z.object({ + labels: z.array(z.string()), +}) + +export type TabsProps = z.infer + +export function Tabs(props: React.PropsWithChildren) { + const t = useTheme() + + const [selected, setSelected] = React.useState(0) + const propsParsed = tabsProps.parse(props) + + return ( + + + {propsParsed.labels.map((label, i) => ( + + ))} + + {arr(props.children).map((child, i) => ( + + {child} + + ))} + + ) +} + +function arr(v: any): Array { + if (Array.isArray(v)) { + return v + } + if (!v) { + return [] + } + return [v] +} diff --git a/src/components/appcom/vocabulary/common.ts b/src/components/appcom/vocabulary/common.ts new file mode 100644 index 0000000000..e894a7aa02 --- /dev/null +++ b/src/components/appcom/vocabulary/common.ts @@ -0,0 +1,32 @@ +import {z} from 'zod' + +export const gap = z + .number() + .optional() + .transform(v => ({gap: v})) + +export const pad = z + .union([ + z + .number() + .positive() + .transform(v => ({padding: v})), + z + .object({ + x: z.number().positive().optional(), + y: z.number().positive().optional(), + t: z.number().positive().optional(), + b: z.number().positive().optional(), + l: z.number().positive().optional(), + r: z.number().positive().optional(), + }) + .transform(obj => ({ + paddingHorizontal: obj.x, + paddingVertical: obj.y, + paddingTop: obj.t, + paddingBottom: obj.b, + paddingLeft: obj.l, + paddingRight: obj.r, + })), + ]) + .optional() diff --git a/src/components/appcom/vocabulary/hooks.ts b/src/components/appcom/vocabulary/hooks.ts new file mode 100644 index 0000000000..8517d09302 --- /dev/null +++ b/src/components/appcom/vocabulary/hooks.ts @@ -0,0 +1,66 @@ +import {useTheme} from '#/alf' + +export function useBackgroundColor(value: string | undefined) { + const t = useTheme() + + let background + if (value === 'default') { + background = t.atoms.bg + } else if (value === 'primary') { + background = {backgroundColor: t.palette.primary_500} + } else if (value === 'secondary') { + background = t.atoms.bg_contrast_100 + } else if (value === 'positive') { + background = {backgroundColor: t.palette.positive_500} + } else if (value === 'negative') { + background = {backgroundColor: t.palette.negative_500} + } else if (value === 'inverted') { + background = t.atoms.bg_contrast_950 + } + return background +} + +export function useBorderColor(value: string | undefined) { + const t = useTheme() + + let borderColor + if (value === 'default') { + borderColor = { + borderWidth: 1, + borderColor: t.atoms.border_contrast_medium.borderColor, + } + } else if (value === 'primary') { + borderColor = {borderWidth: 1, borderColor: t.palette.primary_500} + } else if (value === 'secondary') { + borderColor = { + borderWidth: 1, + borderColor: t.atoms.border_contrast_low.borderColor, + } + } else if (value === 'positive') { + borderColor = {borderWidth: 1, borderColor: t.palette.positive_500} + } else if (value === 'negative') { + borderColor = {borderWidth: 1, borderColor: t.palette.negative_500} + } + + return borderColor +} + +export function useFontColor(value: string | undefined) { + const t = useTheme() + + let fontColor + if (value === 'default') { + fontColor = t.atoms.text + } else if (value === 'primary') { + fontColor = {color: t.palette.primary_500} + } else if (value === 'secondary') { + fontColor = t.atoms.text_contrast_medium + } else if (value === 'positive') { + fontColor = {color: t.palette.positive_500} + } else if (value === 'negative') { + fontColor = {color: t.palette.negative_500} + } else if (value === 'inverted') { + fontColor = {color: t.atoms.bg.backgroundColor} + } + return fontColor +} diff --git a/src/components/appcom/vocabulary/index.tsx b/src/components/appcom/vocabulary/index.tsx new file mode 100644 index 0000000000..7e44c38045 --- /dev/null +++ b/src/components/appcom/vocabulary/index.tsx @@ -0,0 +1,33 @@ +import React from 'react' + +import type {AppComNode} from '../types' +import {Box} from './Box' +import {Expandable} from './Expandable' +import {Label} from './Label' +import {Stack} from './Stack' +import {Tabs} from './Tabs' + +export const VOCAB: Record> = { + Box, + Expandable, + Label, + Stack, + Tabs, +} + +export function AppComponent({node}: {node: AppComNode}) { + const Com = VOCAB[node.type] + if (!Com) { + console.error('Unknown component:', node.type) + return <> + } + return ( + + {node.children?.length + ? node.children.map((child, i) => ( + + )) + : null} + + ) +} diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 9d102f2483..0eca20478d 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -25,6 +25,7 @@ export type CommonNavigatorParams = { ProfileLabelerLikedBy: {name: string} Debug: undefined DebugMod: undefined + DebugAppcom: undefined Log: undefined Support: undefined PrivacyPolicy: undefined diff --git a/src/routes.ts b/src/routes.ts index a76d8c4ce9..cfd332c318 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -25,6 +25,7 @@ export const router = new Router({ ProfileLabelerLikedBy: '/profile/:name/labeler/liked-by', Debug: '/sys/debug', DebugMod: '/sys/debug-mod', + DebugAppcom: '/sys/debug-appcom', Log: '/sys/log', AppPasswords: '/settings/app-passwords', PreferencesFollowingFeed: '/settings/following-feed', diff --git a/src/view/screens/DebugAppcom.tsx b/src/view/screens/DebugAppcom.tsx new file mode 100644 index 0000000000..7276538938 --- /dev/null +++ b/src/view/screens/DebugAppcom.tsx @@ -0,0 +1,143 @@ +import React from 'react' +import {View} from 'react-native' + +import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' +import {CenteredView, ScrollView} from '#/view/com/util/Views' +import {atoms as a, useTheme} from '#/alf' +import {AppComponentRegion} from '#/components/appcom/AppCom' +import * as TextField from '#/components/forms/TextField' +import {Text} from '#/components/Typography' + +const DEFAULT_AC = h('Stack', {gap: 10}, [ + h('Label', { + size: 36, + lineHeight: 1.2, + weight: 'bold', + text: 'Hello, world!', + }), + h('Label', { + lineHeight: 1.2, + text: 'This is the initial version of a fantastic new feature of the AT Protocol called "application components."', + }), + h('Tabs', {labels: ['Layout', 'Display', 'Inputs', 'Forms', 'ATProto']}, [ + h('Stack', {gap: 10, pad: {t: 20}}, [ + h('Box', {pad: {x: 10}}, [ + h('Label', {text: 'Stack', size: 10, weight: 'bold'}), + ]), + h('Box', {border: 'default', corner: 10, pad: {x: 2, y: 6}}, [ + h( + 'Stack', + { + direction: 'row', + align: 'center', + gap: 10, + pad: {x: 10}, + }, + [ + h('Label', {text: 'One'}), + h('Label', {text: 'Two', color: 'positive'}), + h('Box', {border: 'default', corner: 4, pad: 10}, [ + {type: 'Label', props: {text: 'Three'}}, + ]), + h('Box', {background: 'secondary', corner: 4, pad: 10}, [ + {type: 'Label', props: {text: 'Four'}}, + ]), + h('Label', {text: 'Five', color: 'secondary'}), + ], + ), + ]), + h('Box', {pad: {x: 10}}, [ + h('Label', {text: 'Box', size: 10, weight: 'bold'}), + ]), + h('Box', {border: 'default', corner: 10, pad: {x: 10, y: 14}}, [ + h('Label', { + text: 'You can use the Box element as somewhat similar to a div.', + }), + h('Label', {text: 'Its children flow vertically with no gap.'}), + h('Label', { + text: 'What it offers is styling -- border, corner radius, background, and padding.', + }), + h('Label', { + text: 'If you want to control layout then you use a Stack, then if you want styling you use a Box.', + }), + h('Label', { + text: 'Boxes also make good neutral containers.', + }), + ]), + h('Box', {pad: {x: 10}}, [ + h('Label', {text: 'Expandable', size: 10, weight: 'bold'}), + ]), + h('Box', {border: 'default', corner: 10, pad: 6}, [ + h('Expandable', {label: 'Expandable'}, [h('Label', {text: 'Content'})]), + ]), + ]), + h('Label', {text: 'Display TODO'}), + h('Label', {text: 'Inputs TODO'}), + h('Label', {text: 'Forms TODO'}), + h('Label', {text: 'ATProto TODO'}), + ]), +]) + +export const DebugAppcomScreen = ({}: NativeStackScreenProps< + CommonNavigatorParams, + 'DebugAppcom' +>) => { + const t = useTheme() + const [acJson, setAcJson] = React.useState( + JSON.stringify(DEFAULT_AC, null, 2), + ) + const [acObj, setAcObj] = React.useState(undefined) + const [error, setError] = React.useState('') + + React.useEffect(() => { + try { + setAcObj(JSON.parse(acJson)) + setError('') + } catch (e: any) { + setError(e.toString()) + } + }, [acJson, setAcObj, setError]) + + return ( + + + + Application components + + + Application JSON component + + + {error && ( + + {error} + + )} + + + + {acObj && } + + + ) +} + +function h(type: string, props?: any | Array, children?: Array) { + return { + type, + props: Array.isArray(props) ? undefined : props, + children: Array.isArray(props) ? props : children, + } +}