From cd82e946e0be5dda22f9aaef3c3b119adc083d48 Mon Sep 17 00:00:00 2001 From: Michael Shafir Date: Tue, 14 Jan 2025 12:02:12 -0500 Subject: [PATCH 1/9] add wrapper chaining --- .../apps/contact-list-basic-async.tsx | 4 +- .../src/examples/apps/contact-list.tsx | 6 +- .../src/examples/contact-list-data-fetch.tsx | 4 +- .../src/pages/starter/index.tsx | 36 ++- .../src/pages/todo-list/index.tsx | 35 +-- libs/core/src/builtins/changed.ts | 48 ++++ libs/core/src/builtins/display.tsx | 104 ++++++++ libs/core/src/builtins/internal-state.ts | 25 ++ libs/core/src/builtins/set.ts | 22 ++ libs/core/src/builtins/types.ts | 66 +++++ libs/core/src/builtins/view.ts | 51 ++++ libs/core/src/hooks/use-reactlit.spec.tsx | 44 +--- libs/core/src/hooks/use-reactlit.tsx | 4 +- libs/core/src/index.ts | 6 +- .../inputs/{form.input.tsx => form.view.tsx} | 16 +- libs/core/src/inputs/layout.view.tsx | 94 +++++++ libs/core/src/plugins/layout.tsx | 96 ------- libs/core/src/reactlit.tsx | 244 ++---------------- libs/core/src/utils/apply-wrapper.tsx | 21 +- libs/core/src/utils/tail.ts | 3 + libs/radix/src/inputs.ts | 4 +- libs/radix/src/inputs/text.input.tsx | 2 +- 22 files changed, 524 insertions(+), 411 deletions(-) create mode 100644 libs/core/src/builtins/changed.ts create mode 100644 libs/core/src/builtins/display.tsx create mode 100644 libs/core/src/builtins/internal-state.ts create mode 100644 libs/core/src/builtins/set.ts create mode 100644 libs/core/src/builtins/types.ts create mode 100644 libs/core/src/builtins/view.ts rename libs/core/src/inputs/{form.input.tsx => form.view.tsx} (73%) create mode 100644 libs/core/src/inputs/layout.view.tsx delete mode 100644 libs/core/src/plugins/layout.tsx create mode 100644 libs/core/src/utils/tail.ts diff --git a/apps/reactlit-docs/src/examples/apps/contact-list-basic-async.tsx b/apps/reactlit-docs/src/examples/apps/contact-list-basic-async.tsx index 87f1168..b2d2ff0 100644 --- a/apps/reactlit-docs/src/examples/apps/contact-list-basic-async.tsx +++ b/apps/reactlit-docs/src/examples/apps/contact-list-basic-async.tsx @@ -1,5 +1,5 @@ import { Button } from '@radix-ui/themes'; -import { FormInput, type ReactlitContext } from '@reactlit/core'; +import { FormView, type ReactlitContext } from '@reactlit/core'; import { Inputs } from '@reactlit/radix'; import { TopRightLoader } from '../components/loader'; import { ContactsMockService } from '../mocks/contacts'; @@ -36,7 +36,7 @@ export async function ContactListApp(app: ReactlitContext) { } const updates = app.view( 'updates', - FormInput({ + FormView({ name: Inputs.Text({ label: 'Name' }), email: Inputs.Text({ label: 'Email' }), }) diff --git a/apps/reactlit-docs/src/examples/apps/contact-list.tsx b/apps/reactlit-docs/src/examples/apps/contact-list.tsx index fe8d78b..1fe9f17 100644 --- a/apps/reactlit-docs/src/examples/apps/contact-list.tsx +++ b/apps/reactlit-docs/src/examples/apps/contact-list.tsx @@ -1,4 +1,4 @@ -import { FormInput, type ReactlitContext } from '@reactlit/core'; +import { FormView, type ReactlitContext } from '@reactlit/core'; import { Inputs } from '@reactlit/radix'; import { ContactMockApi as api } from '../mocks/contacts'; import { Button } from '@radix-ui/themes'; @@ -27,10 +27,10 @@ export async function ContactListApp(app: ReactlitContext) { if (app.changed('selectedContact')) { app.set('updates', selectedContact); } - // the built-in FormInput allows you to group inputs together + // the built-in FormView allows you to group inputs together const updates = app.view( 'updates', - FormInput({ + FormView({ name: Inputs.Text({ label: 'Name' }), email: Inputs.Text({ label: 'Email' }), }) diff --git a/apps/reactlit-docs/src/examples/contact-list-data-fetch.tsx b/apps/reactlit-docs/src/examples/contact-list-data-fetch.tsx index aac70a5..090717b 100644 --- a/apps/reactlit-docs/src/examples/contact-list-data-fetch.tsx +++ b/apps/reactlit-docs/src/examples/contact-list-data-fetch.tsx @@ -1,6 +1,6 @@ import { Button, Theme } from '@radix-ui/themes'; import '@radix-ui/themes/styles.css'; -import { DataFetchingPlugin, FormInput, useReactlit } from '@reactlit/core'; +import { DataFetchingPlugin, FormView, useReactlit } from '@reactlit/core'; import { Inputs } from '@reactlit/radix'; import { TopRightLoader } from './components/loader'; import { ContactsMockService } from './mocks/contacts'; @@ -72,7 +72,7 @@ const ContactListApp = () => { } const updates = app.view( 'updates', - FormInput({ + FormView({ name: Inputs.Text({ label: 'Name' }), email: Inputs.Text({ label: 'Email' }), }) diff --git a/apps/reactlit-examples/src/pages/starter/index.tsx b/apps/reactlit-examples/src/pages/starter/index.tsx index 7ca5d9b..f810f88 100644 --- a/apps/reactlit-examples/src/pages/starter/index.tsx +++ b/apps/reactlit-examples/src/pages/starter/index.tsx @@ -2,29 +2,33 @@ import { Box, Text } from '@radix-ui/themes'; import { textPropDefs } from '@radix-ui/themes/props'; import { DataFetchingPlugin, - LayoutPlugin, useReactlit, useReactlitState, + LayoutView, + Wrapper, } from '@reactlit/core'; -import { Inputs, DefaultRadixWrapper } from '@reactlit/radix'; +import { DefaultRadixWrapper, Inputs } from '@reactlit/radix'; + +const TwoColWrapper: Wrapper = ({ children }) => ( +
{children}
+); export default function Starter() { - const [appState, setAppState] = useReactlitState({ + const [appState, setAppState] = useReactlitState({ name: '', weight: 'regular', size: 1, }); - const Reactlit = useReactlit( - LayoutPlugin, - DataFetchingPlugin - ); + const Reactlit = useReactlit(DataFetchingPlugin); return ( - {async ({ display, view }) => { + {async (ctx) => { + const { display, view } = ctx; const name = view( 'name', Inputs.Text({ @@ -63,6 +67,22 @@ export default function Starter() { ); + + if (view('show', Inputs.Radio(['show', 'hide'] as const)) === 'show') { + const [col1, col2] = view('l1', TwoColWrapper, LayoutView(2)); + const v1 = view( + 'leftInput', + col1, + Inputs.Text({ label: 'Column Left' }) + ); + display(col1, v1); + view('rightInput', col2, Inputs.Text({ label: 'Column Right' })); + } + + display('Separator'); + const [col1B, col2B] = view('l2', TwoColWrapper, LayoutView(2)); + view('c1B', col1B, Inputs.Text({ label: 'Column 1 B' })); + view('c2B', col2B, Inputs.Text({ label: 'Column 2 B' })); }} ); diff --git a/apps/reactlit-examples/src/pages/todo-list/index.tsx b/apps/reactlit-examples/src/pages/todo-list/index.tsx index 89c3d10..5ccf454 100644 --- a/apps/reactlit-examples/src/pages/todo-list/index.tsx +++ b/apps/reactlit-examples/src/pages/todo-list/index.tsx @@ -1,15 +1,6 @@ import { Button, Callout, Spinner } from '@radix-ui/themes'; -import { - DataFetchingPlugin, - defineLayout, - LayoutPlugin, - useReactlit, -} from '@reactlit/core'; -import { - BoxContainerWrapper, - DefaultRadixWrapper, - Inputs, -} from '@reactlit/radix'; +import { DataFetchingPlugin, useReactlit } from '@reactlit/core'; +import { DefaultRadixWrapper, Inputs } from '@reactlit/radix'; import { InfoIcon } from 'lucide-react'; import { TodoService } from '../../mocks/todos'; @@ -23,23 +14,8 @@ export const Loader = ({ message }: { message: string }) => { const api = new TodoService([], 1000); -const TwoColumnLayout = defineLayout(2, ({ slots: [Slot1, Slot2] }) => { - return ( - -
- - - - - - -
-
- ); -}); - export default function TodoList() { - const Reactlit = useReactlit(LayoutPlugin, DataFetchingPlugin); + const Reactlit = useReactlit(DataFetchingPlugin); return ( {async ({ display, view, set, changed, fetcher, layout }) => { @@ -90,14 +66,13 @@ export default function TodoList() { set('task', selectedTodo.task); set('completed', selectedTodo.completed); } - const [c1, c2] = layout(TwoColumnLayout); - const task = c1.view( + const task = view( 'task', Inputs.Text({ label: 'Task', }) ); - const completed = c2.view( + const completed = view( 'completed', Inputs.Switch({ label: 'Completed', diff --git a/libs/core/src/builtins/changed.ts b/libs/core/src/builtins/changed.ts new file mode 100644 index 0000000..ca1c324 --- /dev/null +++ b/libs/core/src/builtins/changed.ts @@ -0,0 +1,48 @@ +import { useCallback } from 'react'; +import { ReactlitContext, StateBase } from './types'; +import { InternalReactlitState } from './internal-state'; +import { deepEqual } from '../utils/deep-equal'; + +function deltas( + state: T, + previousState: T | undefined +): (keyof T)[] { + return Object.keys(state).filter( + (key: string) => + !previousState || !deepEqual(state[key], previousState[key]) + ); +} + +export function useReactlitChanged( + { state, previousState, setPreviousState }: InternalReactlitState, + debug: boolean +) { + return useCallback['changed']>( + (...keys) => { + const changedKeys = deltas(state, previousState); + const selectedChangedKeys = keys.filter((k) => + changedKeys.includes(k as string) + ); + const isChanged = selectedChangedKeys.length > 0; + if (isChanged) { + if (debug) { + for (const k of selectedChangedKeys) { + // eslint-disable-next-line no-console + console.debug( + `changed ${String(k)}: ${previousState?.[k]} -> ${state[k]}` + ); + } + } + setPreviousState((prev) => { + let newState = prev; + for (const k of selectedChangedKeys) { + newState = { ...newState, [k]: state[k] }; + } + return newState; + }); + } + return isChanged; + }, + [state, previousState, setPreviousState, debug] + ); +} diff --git a/libs/core/src/builtins/display.tsx b/libs/core/src/builtins/display.tsx new file mode 100644 index 0000000..25b920f --- /dev/null +++ b/libs/core/src/builtins/display.tsx @@ -0,0 +1,104 @@ +import { ReactNode, useCallback, useState } from 'react'; +import { ErrorBoundary as ReactErrorBoundary } from 'react-error-boundary'; +import { ApplyWrappers, Wrapper } from '../utils/apply-wrapper'; +import { tail } from '../utils/tail'; +import { ReactlitContext, ReactlitProps, StateBase } from './types'; + +interface DisplayState { + position: number; + elements: [string, React.ReactNode][]; +} + +type KeyedDisplayArgs = [string, ...Wrapper[], ReactNode]; +type UnkeyedDisplayArgs = [...Wrapper[], ReactNode]; + +export type DisplayArgs = KeyedDisplayArgs | UnkeyedDisplayArgs; + +function isKeyedDisplayArgs(args: DisplayArgs): args is KeyedDisplayArgs { + return args.length > 1 && typeof args[0] === 'string'; +} + +export function useReactlitDisplay({ + renderError, + wrapper, +}: Pick, 'renderError' | 'wrapper'>) { + const [renderState, setRenderState] = useState({ + position: 0, + elements: [], + }); + + const display = useCallback['display']>( + (...args: DisplayArgs) => { + const manualKey = isKeyedDisplayArgs(args) ? args[0] : undefined; + const restArgs = isKeyedDisplayArgs(args) + ? (args.slice(1) as UnkeyedDisplayArgs) + : args; + const [wrappers, node] = tail(restArgs); + + setRenderState(({ position, elements }) => { + const key = manualKey ?? `${position}`; + const keyIndex = elements + .slice(0, position) + .findIndex(([k]) => manualKey && k === manualKey); + const element = ( + + + {node} + + + ); + const newEntry = [key, element] as [string, React.ReactNode]; + + if (keyIndex !== -1) { + return { + position, + elements: [ + ...elements.slice(0, keyIndex), + newEntry, + ...elements.slice(keyIndex + 1), + ], + }; + } else if (position < elements.length) { + return { + position: position + 1, + elements: [ + ...elements.slice(0, position), + newEntry, + // for manual keys that haven't been found by the above case + // we don't want to overwrite the index element because + // it's likely a different element + ...elements + .slice(position + (manualKey ? 0 : 1)) + .filter((e) => !manualKey || e[0] !== manualKey), + ], + }; + } else { + return { + position: elements.length + 1, + elements: [...elements, newEntry], + }; + } + }); + }, + [setRenderState, renderError, wrapper] + ); + + const resetRenderPosition = useCallback(() => { + setRenderState(({ elements }) => ({ elements, position: 0 })); + }, [setRenderState]); + + // truncates stranded elements after the last position + const finalizeRender = useCallback(() => { + setRenderState(({ elements, position }) => ({ + position, + elements: elements.slice(0, position), + })); + }, [setRenderState]); + + return { + renderState, + display, + resetRenderPosition, + finalizeRender, + }; +} diff --git a/libs/core/src/builtins/internal-state.ts b/libs/core/src/builtins/internal-state.ts new file mode 100644 index 0000000..6465651 --- /dev/null +++ b/libs/core/src/builtins/internal-state.ts @@ -0,0 +1,25 @@ +import { useState } from 'react'; +import { useDeepMemo } from '../hooks/use-deep-memo'; +import { ReactlitStateSetter, StateBase } from './types'; + +export interface InternalReactlitState { + state: T; + setState: ReactlitStateSetter; + previousState: T; + setPreviousState: React.Dispatch>; +} + +export function useInternalReactlitState( + rawState: T, + setState: ReactlitStateSetter +): InternalReactlitState { + const state = useDeepMemo(() => rawState, [rawState]); + const [previousState, setPreviousState] = useState(state); + + return { + state, + setState, + previousState, + setPreviousState, + }; +} diff --git a/libs/core/src/builtins/set.ts b/libs/core/src/builtins/set.ts new file mode 100644 index 0000000..8c4a192 --- /dev/null +++ b/libs/core/src/builtins/set.ts @@ -0,0 +1,22 @@ +import { useCallback } from 'react'; +import { deepEqual } from '../utils/deep-equal'; +import { ReactlitContext, StateBase } from './types'; +import { InternalReactlitState } from './internal-state'; + +export function useReactlitSet({ + state, + setState, + setPreviousState, +}: InternalReactlitState) { + return useCallback['set']>( + (key, value) => { + setState(key, (prev) => { + if (deepEqual(prev, value)) return prev; + return value; + }); + setPreviousState(state); + return value; + }, + [setState, state, setPreviousState] + ); +} diff --git a/libs/core/src/builtins/types.ts b/libs/core/src/builtins/types.ts new file mode 100644 index 0000000..2b357d4 --- /dev/null +++ b/libs/core/src/builtins/types.ts @@ -0,0 +1,66 @@ +import { Dispatch, ReactNode, SetStateAction } from 'react'; +import { Wrapper } from '../utils/apply-wrapper'; +import { DisplayArgs } from './display'; +import { ViewArgs } from './view'; + +export type StateBase = Record; + +export interface ViewComponentProps { + stateKey: string; + value: T; + setValue: Dispatch; +} + +export type ViewComponent = React.FC>; + +export interface ViewDefinition { + component: ViewComponent; + getReturnValue?: (props: ViewComponentProps) => ReturnType; +} + +export interface ReactlitContext { + view: (...args: ViewArgs) => R; + set: (key: K, value: T[K]) => T[K]; + display: (...args: DisplayArgs) => void; + changed: (...keys: (keyof T)[]) => boolean; + trigger: () => void; + state: T; +} + +export type ReactlitStateSetter = ( + key: K, + value: SetStateAction +) => void; + +export type ReactlitFunction< + T extends StateBase = any, + C extends ReactlitContext = ReactlitContext +> = (ctx: C) => Promise; + +export type ReactlitProps = { + state?: T; + setState?: ReactlitStateSetter; + /** + * Render function to display a loading message + */ + renderLoading?: (rendering: boolean) => ReactNode; + /** + * Render function to display an error message + */ + renderError?: (props: { + error: any; + resetErrorBoundary: (...args: any[]) => void; + }) => ReactNode; + /** + * Whether to log debug messages to the console + */ + debug?: boolean; + /** + * Wrapper to apply around all displayed elements + */ + wrapper?: Wrapper; + /** + * Function for the Reactlit rendering logic + */ + children: ReactlitFunction; +}; diff --git a/libs/core/src/builtins/view.ts b/libs/core/src/builtins/view.ts new file mode 100644 index 0000000..5fb3f99 --- /dev/null +++ b/libs/core/src/builtins/view.ts @@ -0,0 +1,51 @@ +import { useCallback } from 'react'; +import { + ReactlitContext, + StateBase, + ViewComponent, + ViewComponentProps, + ViewDefinition, +} from './types'; +import { Wrapper } from '../utils/apply-wrapper'; +import { tail } from '../utils/tail'; + +export function defineView( + component: ViewComponent +): ViewDefinition { + return { component }; +} + +export function defineTransformView( + component: ViewComponent, + getReturnValue: (props: ViewComponentProps) => ReturnType +): ViewDefinition { + return { component, getReturnValue }; +} + +export type ViewArgs = [ + key: K, + ...wrappers: Wrapper[], + def: ViewDefinition +]; + +export function useReactlitView({ + set, + display, + state, +}: Pick, 'set' | 'display' | 'state'>) { + return useCallback['view']>( + (...args: ViewArgs) => { + const [key, ...restArgs] = args; + const [wrappers, def] = tail(restArgs); + const { component, getReturnValue } = def; + const props: ViewComponentProps = { + stateKey: key, + value: state[key] as V, + setValue: (value: any) => set(key, value), + }; + display(key, ...wrappers, component(props)); + return getReturnValue ? getReturnValue(props) : (state[key] as R); + }, + [state, set, display] + ); +} diff --git a/libs/core/src/hooks/use-reactlit.spec.tsx b/libs/core/src/hooks/use-reactlit.spec.tsx index fe6206d..c86bdb3 100644 --- a/libs/core/src/hooks/use-reactlit.spec.tsx +++ b/libs/core/src/hooks/use-reactlit.spec.tsx @@ -1,48 +1,32 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom'; -import { useReactlit } from './use-reactlit'; -import { LayoutPlugin, makeLayoutPlugin } from '../plugins/layout'; +import { render, screen } from '@testing-library/react'; +import { definePlugin, useReactlit } from './use-reactlit'; import { useReactlitState } from './use-reactlit-state'; -import { defineLayout } from '../plugins/layout'; -import { TextInput } from '../reactlit.spec'; - -export const TwoColumnLayout = defineLayout(2, ({ slots: [Slot1, Slot2] }) => ( -
-
- -
-
- -
-
-)); test('reactlit plugin', async () => { + let pluginRan = false; + const TestPlugin = definePlugin(({ display }) => ({ + runPlugin: () => { + pluginRan = true; + return display('Hello from plugin'); + }, + })); + function PluginTest() { const [state, setState] = useReactlitState({ firstName: 'John', lastName: 'Doe', }); - const Reactlit = useReactlit(LayoutPlugin); + const Reactlit = useReactlit(TestPlugin); return ( {async (ctx) => { - const [col1, col2] = ctx.layout(TwoColumnLayout); - col1.view('firstName', TextInput); - col1.display(`First Name: ${state.firstName}`); - col2.display(`Last Name: ${state.lastName}`); + ctx.runPlugin(); }} ); } render(); - expect(await screen.findByText('First Name: John')).toBeVisible(); - await userEvent.clear(screen.getByPlaceholderText('Enter firstName')); - await userEvent.type( - screen.getByPlaceholderText('Enter firstName'), - 'Joseph' - ); - expect(await screen.findByText('First Name: Joseph')).toBeVisible(); - expect(await screen.findByText('Last Name: Doe')).toBeVisible(); + expect(pluginRan).toBe(true); + expect(await screen.findByText('Hello from plugin')).toBeVisible(); }); diff --git a/libs/core/src/hooks/use-reactlit.tsx b/libs/core/src/hooks/use-reactlit.tsx index 3d8b2b6..06bae6a 100644 --- a/libs/core/src/hooks/use-reactlit.tsx +++ b/libs/core/src/hooks/use-reactlit.tsx @@ -1,11 +1,11 @@ import { useCallback, useMemo } from 'react'; +import { Reactlit } from '../reactlit'; import { - Reactlit, ReactlitContext, ReactlitFunction, ReactlitProps, StateBase, -} from '../reactlit'; +} from '../builtins/types'; export type ReactlitPlugin = (ctx: ReactlitContext) => C; diff --git a/libs/core/src/index.ts b/libs/core/src/index.ts index e03c77b..b16d31e 100644 --- a/libs/core/src/index.ts +++ b/libs/core/src/index.ts @@ -1,10 +1,12 @@ export * from './reactlit'; +export * from './builtins/types'; +export { defineView, defineTransformView } from './builtins/view'; export * from './hooks/use-reactlit'; export * from './hooks/use-reactlit-state'; export * from './utils/apply-wrapper'; -export * from './inputs/form.input'; +export * from './inputs/form.view'; +export * from './inputs/layout.view'; export * from './plugins/data-fetching'; -export * from './plugins/layout'; // see https://github.com/mdx-js/mdx/issues/2487 import type { JSX as Jsx } from 'react/jsx-runtime'; diff --git a/libs/core/src/inputs/form.input.tsx b/libs/core/src/inputs/form.view.tsx similarity index 73% rename from libs/core/src/inputs/form.input.tsx rename to libs/core/src/inputs/form.view.tsx index be3e9c1..4c61d6b 100644 --- a/libs/core/src/inputs/form.input.tsx +++ b/libs/core/src/inputs/form.view.tsx @@ -1,25 +1,25 @@ import { Fragment, ReactNode, SetStateAction, useMemo } from 'react'; -import { defineView, ViewDefinition } from '../reactlit'; -import { ViewComponentProps } from '../reactlit'; import { applyWrapper, Wrapper } from '../utils/apply-wrapper'; import { isSetStateFunction } from '../hooks/use-reactlit-state'; +import { ViewComponentProps, ViewDefinition } from '../builtins/types'; +import { defineView } from '../builtins/view'; export type FormDefMap = { [K in keyof T]: ViewDefinition; }; -export interface FormInputProps { +export interface FormViewProps { form: FormDefMap; wrapper?: Wrapper; } -export function FormInputViewComponent({ +export function FormViewComponent({ form, value, stateKey, setValue, wrapper, -}: FormInputProps & ViewComponentProps) { +}: FormViewProps & ViewComponentProps) { const views = useMemo(() => { const views: ReactNode[] = []; for (const key in form) { @@ -40,11 +40,11 @@ export function FormInputViewComponent({ return applyWrapper(<>{views}, wrapper); } -export function FormInput( +export function FormView( form: FormDefMap, - props?: Omit, 'form'> + props?: Omit, 'form'> ) { return defineView((viewProps) => ( - + )); } diff --git a/libs/core/src/inputs/layout.view.tsx b/libs/core/src/inputs/layout.view.tsx new file mode 100644 index 0000000..fd13205 --- /dev/null +++ b/libs/core/src/inputs/layout.view.tsx @@ -0,0 +1,94 @@ +import { useEffect, useRef } from 'react'; +import tunnel from 'tunnel-rat'; +import { ViewComponentProps } from '../builtins/types'; +import { defineTransformView } from '../builtins/view'; +import { Wrapper } from '../utils/apply-wrapper'; + +type Tunnel = ReturnType; + +type Repeat< + T, + C extends number, + Result extends T[] = [], + Counter extends any[] = [] +> = Counter['length'] extends C + ? Result + : Repeat; + +// export function createLayoutSlot( +// ctx: ReactlitContext, +// t: ReturnType +// ): LayoutSlot { +// return { +// display(...args) { +// const node = args.length === 1 ? args[0] : args[1]; +// const manualKey = args.length === 1 ? undefined : args[0]; +// const wrappedNode = {node}; +// const passArgs = +// manualKey === undefined +// ? ([wrappedNode] as const) +// : ([manualKey, wrappedNode] as const); +// ctx.display(...passArgs); +// }, +// view(key: K, def: ViewDefinition) { +// return ctx.view(key, { +// ...def, +// component: (props) => { +// return {def.component(props)}; +// }, +// }); +// }, +// }; +// } + +export function LayoutViewComponent({ + slots, + value, + setValue, + slotProps, +}: { + slots: N; + slotProps: React.DetailedHTMLProps< + React.HTMLAttributes, + HTMLDivElement + >; +} & ViewComponentProps>) { + const tunnels = useRef([]); + useEffect(() => { + if (tunnels.current.length !== slots) { + tunnels.current = Array.from({ length: slots }, () => tunnel()); + setValue(tunnels.current as Repeat); + } + }, [slots, setValue]); + if (!value) return null; + return ( + <> + {(value as Tunnel[]) + .map((t) => t.Out) + .map((Slot, index) => ( +
+ +
+ ))} + + ); +} + +export function LayoutView( + slots: N, + slotProps?: React.DetailedHTMLProps< + React.HTMLAttributes, + HTMLDivElement + > +) { + return defineTransformView | undefined, Repeat>( + (viewProps) => ( + + ), + ({ value }) => { + const tunnels = (value ?? []) as Tunnel[]; + const wrappers = tunnels.map((t) => t.In); + return wrappers as Repeat; + } + ); +} diff --git a/libs/core/src/plugins/layout.tsx b/libs/core/src/plugins/layout.tsx deleted file mode 100644 index c9bd53b..0000000 --- a/libs/core/src/plugins/layout.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import tunnel from 'tunnel-rat'; -import { ReactlitContext, StateBase, ViewDefinition } from '../reactlit'; - -type Repeat< - T, - C extends number, - Result extends T[] = [], - Counter extends any[] = [] -> = Counter['length'] extends C - ? Result - : Repeat; - -export type LayoutSlot = Pick< - ReactlitContext, - 'display' | 'view' ->; - -type SlotComponent = React.ComponentType<{}>; - -type Tunnel = ReturnType; - -export type LayoutComponent = React.FC<{ - slots: Repeat; -}>; - -type LayoutDefinition = { - layout: React.ReactNode; - tunnels: Repeat; -}; - -export function defineLayout( - slots: N, - component: LayoutComponent -): LayoutDefinition { - const tunnels = Array.from({ length: slots }, () => tunnel()); - return { - layout: component({ - slots: tunnels.map((t) => t.Out) as Repeat, - }), - tunnels: tunnels as Repeat, - }; -} - -export interface LayoutPluginContext { - layout( - definition: LayoutDefinition - ): Repeat, N>; -} - -function createLayoutSlot( - ctx: ReactlitContext, - t: ReturnType -): LayoutSlot { - return { - display(...args) { - const node = args.length === 1 ? args[0] : args[1]; - const manualKey = args.length === 1 ? undefined : args[0]; - const wrappedNode = {node}; - const passArgs = - manualKey === undefined - ? ([wrappedNode] as const) - : ([manualKey, wrappedNode] as const); - ctx.display(...passArgs); - }, - view(key: K, def: ViewDefinition) { - return ctx.view(key, { - ...def, - component: (props) => { - return {def.component(props)}; - }, - }); - }, - }; -} - -export function makeLayoutPlugin(ctx: ReactlitContext) { - return function layout({ - layout, - tunnels, - }: LayoutDefinition): Repeat, N> { - const inputSlots: LayoutSlot[] = []; - for (const t of tunnels as Tunnel[]) { - inputSlots.push(createLayoutSlot(ctx, t)); - } - ctx.display(layout); - return inputSlots as Repeat, N>; - }; -} - -export function LayoutPlugin( - ctx: ReactlitContext -) { - return { - layout: makeLayoutPlugin(ctx), - }; -} diff --git a/libs/core/src/reactlit.tsx b/libs/core/src/reactlit.tsx index d587f1b..d05361d 100644 --- a/libs/core/src/reactlit.tsx +++ b/libs/core/src/reactlit.tsx @@ -1,114 +1,20 @@ import { - Dispatch, Fragment, - ReactNode, - SetStateAction, useCallback, useEffect, useMemo, useRef, useState, } from 'react'; -import { ErrorBoundary as ReactErrorBoundary } from 'react-error-boundary'; -import { useDeepMemo } from './hooks/use-deep-memo'; -import { useReactlitState } from './hooks/use-reactlit-state'; -import { deepEqual } from './utils/deep-equal'; -import { applyWrapper, Wrapper } from './utils/apply-wrapper'; - -export interface ViewComponentProps { - stateKey: string; - value: T; - setValue: Dispatch; -} - -export type ViewComponent = React.FC>; - -export interface ViewDefinition { - component: ViewComponent; - getReturnValue?: (props: ViewComponentProps) => ReturnType; -} - -export function defineView( - component: ViewComponent -): ViewDefinition { - return { component }; -} - -export function defineTransformView( - component: ViewComponent, - getReturnValue: (props: ViewComponentProps) => ReturnType -): ViewDefinition { - return { component, getReturnValue }; -} - -export type StateBase = Record; - -export type DisplayArgs = [string, ReactNode] | [ReactNode]; - -export interface ReactlitContext { - view: ( - key: K, - def: ViewDefinition - ) => R; - set: (key: K, value: T[K]) => T[K]; - display: (...args: DisplayArgs) => void; - changed: (...keys: (keyof T)[]) => boolean; - trigger: () => void; - state: T; -} - -export type ReactlitFunction< - T extends StateBase = any, - C extends ReactlitContext = ReactlitContext -> = (ctx: C) => Promise; - -export type ReactlitStateSetter = ( - key: K, - value: SetStateAction -) => void; - -export type ReactlitProps = { - state?: T; - setState?: ReactlitStateSetter; - /** - * Render function to display a loading message - */ - renderLoading?: (rendering: boolean) => ReactNode; - /** - * Render function to display an error message - */ - renderError?: (props: { - error: any; - resetErrorBoundary: (...args: any[]) => void; - }) => ReactNode; - /** - * Whether to log debug messages to the console - */ - debug?: boolean; - /** - * Wrapper to apply around all displayed elements - */ - wrapper?: Wrapper; - /** - * Function for the Reactlit rendering logic - */ - children: ReactlitFunction; -}; - -function deltas( - state: T, - previousState: T | undefined -): (keyof T)[] { - return Object.keys(state).filter( - (key: string) => - !previousState || !deepEqual(state[key], previousState[key]) - ); -} -interface DisplayState { - position: number; - elements: [string, React.ReactNode][]; -} +import { useReactlitChanged } from './builtins/changed'; +import { useReactlitDisplay } from './builtins/display'; +import { useInternalReactlitState } from './builtins/internal-state'; +import { useReactlitSet } from './builtins/set'; +import { ReactlitProps, StateBase } from './builtins/types'; +import { useReactlitView } from './builtins/view'; +import { useReactlitState } from './hooks/use-reactlit-state'; +import { Wrapper } from './utils/apply-wrapper'; const defaultRenderError = ({ error }) => (
@@ -116,6 +22,8 @@ const defaultRenderError = ({ error }) => (
); +const DefaultWrapper: Wrapper = ({ children }) => children; + export function Reactlit({ state: rawState, setState, @@ -123,125 +31,19 @@ export function Reactlit({ renderError = defaultRenderError, debug, children, - wrapper, + wrapper = DefaultWrapper, }: ReactlitProps) { const [defaultRawState, defaultSetState] = useReactlitState({} as T); rawState = rawState ?? defaultRawState; setState = setState ?? defaultSetState; - const [renderState, setRenderState] = useState({ - position: 0, - elements: [], - }); - const state = useDeepMemo(() => rawState, [rawState]); - const [previousState, setPreviousState] = useState(state); - - const set = useCallback['set']>( - (key, value) => { - setState(key, (prev) => { - if (deepEqual(prev, value)) return prev; - return value; - }); - setPreviousState(state); - return value; - }, - [setState, state, setPreviousState] - ); - - const display = useCallback['display']>( - (...args: DisplayArgs) => { - const node = args.length === 1 ? args[0] : args[1]; - const manualKey = args.length === 1 ? undefined : args[0]; - setRenderState(({ position, elements }) => { - const key = manualKey ?? `${position}`; - const keyIndex = elements - .slice(0, position) - .findIndex(([k]) => manualKey && k === manualKey); - const element = ( - - {applyWrapper(node, wrapper)} - - ); - const newEntry = [key, element] as [string, React.ReactNode]; + const internalState = useInternalReactlitState(rawState, setState); - if (keyIndex !== -1) { - return { - position, - elements: [ - ...elements.slice(0, keyIndex), - newEntry, - ...elements.slice(keyIndex + 1), - ], - }; - } else if (position < elements.length) { - return { - position: position + 1, - elements: [ - ...elements.slice(0, position), - newEntry, - // for manual keys that haven't been found by the above case - // we don't want to overwrite the index element because - // it's likely a different element - ...elements - .slice(position + (manualKey ? 0 : 1)) - .filter((e) => !manualKey || e[0] !== manualKey), - ], - }; - } else { - return { - position: elements.length + 1, - elements: [...elements, newEntry], - }; - } - }); - }, - [setRenderState, renderError, wrapper] - ); - - const view = useCallback['view']>( - ( - key: K, - { component, getReturnValue }: ViewDefinition - ) => { - const value = state[key] as V; - const props: ViewComponentProps = { - stateKey: key, - value, - setValue: (value: any) => set(key, value), - }; - display(key, component(props)); - return getReturnValue ? getReturnValue(props) : (state[key] as R); - }, - [state, set, display] - ); - - const changed = useCallback['changed']>( - (...keys) => { - const changedKeys = deltas(state, previousState); - const selectedChangedKeys = keys.filter((k) => - changedKeys.includes(k as string) - ); - const isChanged = selectedChangedKeys.length > 0; - if (isChanged) { - if (debug) { - for (const k of selectedChangedKeys) { - // eslint-disable-next-line no-console - console.debug( - `changed ${String(k)}: ${previousState?.[k]} -> ${state[k]}` - ); - } - } - setPreviousState((prev) => { - let newState = prev; - for (const k of selectedChangedKeys) { - newState = { ...newState, [k]: state[k] }; - } - return newState; - }); - } - return isChanged; - }, - [state, previousState, debug] - ); + const { state } = internalState; + const set = useReactlitSet(internalState); + const changed = useReactlitChanged(internalState, debug); + const { renderState, resetRenderPosition, finalizeRender, display } = + useReactlitDisplay({ renderError, wrapper }); + const view = useReactlitView({ set, display, state }); const [triggerCounter, setTriggerCounter] = useState(0); const trigger = useCallback(() => { @@ -268,7 +70,7 @@ export function Reactlit({ try { // eslint-disable-next-line no-console debug && console.debug('reactlit rendering:', childArgs.state); - setRenderState(({ elements }) => ({ elements, position: 0 })); + resetRenderPosition(); await children(childArgs); } catch (e: any) { // eslint-disable-next-line no-console @@ -279,10 +81,7 @@ export function Reactlit({ ) ); } finally { - setRenderState(({ elements, position }) => ({ - position, - elements: elements.slice(0, position), - })); + finalizeRender(); renderLock.current = false; if (renderAfter.current) { renderAfter.current = false; @@ -300,7 +99,8 @@ export function Reactlit({ trigger, display, setRendering, - setRenderState, + resetRenderPosition, + finalizeRender, renderError, debug, ]); diff --git a/libs/core/src/utils/apply-wrapper.tsx b/libs/core/src/utils/apply-wrapper.tsx index 0ae99c0..2d1668e 100644 --- a/libs/core/src/utils/apply-wrapper.tsx +++ b/libs/core/src/utils/apply-wrapper.tsx @@ -1,10 +1,25 @@ -export type Wrapper = (props: { children: React.ReactNode }) => React.ReactNode; +import { ReactNode, useMemo } from 'react'; -export function applyWrapper(node: React.ReactNode, Wrapper?: Wrapper) { - return Wrapper ? {node} : node; +export type Wrapper = (props: { children: ReactNode }) => ReactNode; + +export function applyWrapper(node: ReactNode, Wrap?: Wrapper) { + return Wrap ? {node} : node; } export function combineWrappers(...wrappers: Wrapper[]): Wrapper { return ({ children }) => wrappers.reduce((acc, Wrapper) => {acc}, children); } + +export function ApplyWrappers({ + wrappers, + children, +}: { + wrappers: Wrapper[]; + children: ReactNode; +}) { + return useMemo(() => { + console.log('apply wrappers', wrappers, children); + return wrappers.reduce((acc, W) => applyWrapper(acc, W), children); + }, [children, wrappers]); +} diff --git a/libs/core/src/utils/tail.ts b/libs/core/src/utils/tail.ts new file mode 100644 index 0000000..c622e6a --- /dev/null +++ b/libs/core/src/utils/tail.ts @@ -0,0 +1,3 @@ +export function tail(args: [...T, U]): [T, U] { + return [args.slice(0, -1) as T, args.at(-1) as U]; +} diff --git a/libs/radix/src/inputs.ts b/libs/radix/src/inputs.ts index ef66426..dcea6dc 100644 --- a/libs/radix/src/inputs.ts +++ b/libs/radix/src/inputs.ts @@ -1,4 +1,4 @@ -import { FormInput } from '@reactlit/core'; +import { FormView } from '@reactlit/core'; import { AsyncButton } from './inputs/async-button.input'; import { CheckInput } from './inputs/check.input'; import { RadioInput } from './inputs/radio.input'; @@ -17,7 +17,7 @@ export const Inputs = { Switch: SwitchInput, Radio: RadioInput, Select: SelectInput, - Form: FormInput, + Form: FormView, Slider: SliderInput, RangeSlider: RangeSliderInput, AsyncButton: AsyncButton, diff --git a/libs/radix/src/inputs/text.input.tsx b/libs/radix/src/inputs/text.input.tsx index 29160e2..606993a 100644 --- a/libs/radix/src/inputs/text.input.tsx +++ b/libs/radix/src/inputs/text.input.tsx @@ -1,6 +1,6 @@ import { Text, TextField } from '@radix-ui/themes'; import { defineView, ViewComponentProps } from '@reactlit/core'; -import { isValidElement, ReactNode, useEffect, useState } from 'react'; +import { isValidElement, ReactNode, useEffect, useRef, useState } from 'react'; import { useDebouncedCallback } from 'use-debounce'; import { LabelType, renderLabel } from '../label'; From b923a5a1b15bb78f1d6cb46f0159bb8b67052f8c Mon Sep 17 00:00:00 2001 From: Michael Shafir Date: Wed, 15 Jan 2025 21:19:20 -0500 Subject: [PATCH 2/9] better layout wrapping, so layout results can have mini-contexts again --- .../src/examples/layout-example.tsx | 9 +- .../src/pages/starter/index.tsx | 121 +++++++++--------- libs/core/src/builtins/display.tsx | 41 ++++-- libs/core/src/builtins/types.ts | 6 +- libs/core/src/builtins/view.ts | 51 ++++++-- libs/core/src/index.ts | 2 +- libs/core/src/inputs/form.view.tsx | 20 ++- libs/core/src/inputs/layout.view.tsx | 79 +++++++----- libs/core/src/reactlit.tsx | 2 +- libs/core/src/utils/apply-wrapper.tsx | 25 ---- libs/core/src/wrappers.tsx | 54 ++++++++ libs/radix/src/radix-wrapper.tsx | 26 +++- 12 files changed, 277 insertions(+), 159 deletions(-) delete mode 100644 libs/core/src/utils/apply-wrapper.tsx create mode 100644 libs/core/src/wrappers.tsx diff --git a/apps/reactlit-docs/src/examples/layout-example.tsx b/apps/reactlit-docs/src/examples/layout-example.tsx index c46e9d5..d04880d 100644 --- a/apps/reactlit-docs/src/examples/layout-example.tsx +++ b/apps/reactlit-docs/src/examples/layout-example.tsx @@ -1,13 +1,12 @@ -import { LayoutPlugin, useReactlit } from '@reactlit/core'; +import { LayoutView, useReactlit } from '@reactlit/core'; import { TextInput } from './inputs/basic-text-input'; -import { ThreeColumnLayout } from './layouts/three-column-layout'; export default function LayoutExample() { - const Reactlit = useReactlit(LayoutPlugin); + const Reactlit = useReactlit(); return ( - {async ({ layout }) => { - const [col1, col2, col3] = layout(ThreeColumnLayout); + {async ({ display, view }) => { + const [col1, col2, col3] = view('cols', LayoutView(3)); col1.display('First Name'); const first = col1.view('first', TextInput); diff --git a/apps/reactlit-examples/src/pages/starter/index.tsx b/apps/reactlit-examples/src/pages/starter/index.tsx index f810f88..5997cbb 100644 --- a/apps/reactlit-examples/src/pages/starter/index.tsx +++ b/apps/reactlit-examples/src/pages/starter/index.tsx @@ -2,16 +2,21 @@ import { Box, Text } from '@radix-ui/themes'; import { textPropDefs } from '@radix-ui/themes/props'; import { DataFetchingPlugin, + LayoutView, useReactlit, useReactlitState, - LayoutView, Wrapper, } from '@reactlit/core'; -import { DefaultRadixWrapper, Inputs } from '@reactlit/radix'; +import { Inputs, RadixTheme } from '@reactlit/radix'; -const TwoColWrapper: Wrapper = ({ children }) => ( -
{children}
-); +const StarterWrapper: Wrapper = ({ children, stateKey }) => { + return ( +
+
{stateKey}
+
{children}
+
+ ); +}; export default function Starter() { const [appState, setAppState] = useReactlitState({ @@ -21,43 +26,45 @@ export default function Starter() { }); const Reactlit = useReactlit(DataFetchingPlugin); return ( - - {async (ctx) => { - const { display, view } = ctx; - const name = view( - 'name', - Inputs.Text({ - label: 'What is your name?', - placeholder: 'Enter name', - }) - ); - const weight = view( - 'weight', - Inputs.Radio(['light', 'regular', 'medium', 'bold'] as const, { - label: 'Weight', - }) - ); - const size = view( - 'size', - Inputs.Slider({ - label: 'Size', - min: 1, - max: 9, - }) - ); + + + {async (ctx) => { + const { display, view } = ctx; + const name = view( + 'name', + Inputs.Text({ + label: 'What is your name?', + placeholder: 'Enter name', + }) + ); + const weight = view( + 'weight', + Inputs.Radio(['light', 'regular', 'medium', 'bold'] as const, { + label: 'Weight', + }) + ); + const size = view( + 'size', + Inputs.Slider({ + label: 'Size', + min: 1, + max: 9, + }) + ); - display( - -
-
- ); - display( - + display( + +
+
+ ); + display( + StarterWrapper, + , Enter Name} from Reactlit! - - ); + ); - if (view('show', Inputs.Radio(['show', 'hide'] as const)) === 'show') { - const [col1, col2] = view('l1', TwoColWrapper, LayoutView(2)); - const v1 = view( + const [col1, col2] = view( + 'l1', +
, + LayoutView(2) + ); + const v1 = col1.view( 'leftInput', - col1, Inputs.Text({ label: 'Column Left' }) ); - display(col1, v1); - view('rightInput', col2, Inputs.Text({ label: 'Column Right' })); - } - - display('Separator'); - const [col1B, col2B] = view('l2', TwoColWrapper, LayoutView(2)); - view('c1B', col1B, Inputs.Text({ label: 'Column 1 B' })); - view('c2B', col2B, Inputs.Text({ label: 'Column 2 B' })); - }} - + col1.display(v1); + const v2 = col2.view( + 'rightInput', + Inputs.Text({ label: 'Column Right' }) + ); + col2.display(v2); + }} + + ); } diff --git a/libs/core/src/builtins/display.tsx b/libs/core/src/builtins/display.tsx index 25b920f..c0b225f 100644 --- a/libs/core/src/builtins/display.tsx +++ b/libs/core/src/builtins/display.tsx @@ -1,6 +1,6 @@ -import { ReactNode, useCallback, useState } from 'react'; +import { Fragment, ReactNode, useCallback, useState } from 'react'; import { ErrorBoundary as ReactErrorBoundary } from 'react-error-boundary'; -import { ApplyWrappers, Wrapper } from '../utils/apply-wrapper'; +import { ApplyWrappers, Wrapper } from '../wrappers'; import { tail } from '../utils/tail'; import { ReactlitContext, ReactlitProps, StateBase } from './types'; @@ -9,15 +9,30 @@ interface DisplayState { elements: [string, React.ReactNode][]; } -type KeyedDisplayArgs = [string, ...Wrapper[], ReactNode]; -type UnkeyedDisplayArgs = [...Wrapper[], ReactNode]; +type KeyedDisplayArgs = [string, ...(Wrapper | 'default')[], ReactNode]; +type UnkeyedDisplayArgs = [...(Wrapper | 'default')[], ReactNode]; export type DisplayArgs = KeyedDisplayArgs | UnkeyedDisplayArgs; -function isKeyedDisplayArgs(args: DisplayArgs): args is KeyedDisplayArgs { +export function isKeyedDisplayArgs( + args: DisplayArgs +): args is KeyedDisplayArgs { return args.length > 1 && typeof args[0] === 'string'; } +export function normalizeDisplayArgs(args: DisplayArgs) { + const manualKey = isKeyedDisplayArgs(args) ? args[0] : undefined; + const restArgs = isKeyedDisplayArgs(args) + ? (args.slice(1) as UnkeyedDisplayArgs) + : args; + const [wrappers, node] = tail(restArgs); + return { + manualKey, + wrappers, + node, + }; +} + export function useReactlitDisplay({ renderError, wrapper, @@ -29,20 +44,24 @@ export function useReactlitDisplay({ const display = useCallback['display']>( (...args: DisplayArgs) => { - const manualKey = isKeyedDisplayArgs(args) ? args[0] : undefined; - const restArgs = isKeyedDisplayArgs(args) - ? (args.slice(1) as UnkeyedDisplayArgs) - : args; - const [wrappers, node] = tail(restArgs); + const { manualKey, wrappers, node } = normalizeDisplayArgs(args); setRenderState(({ position, elements }) => { const key = manualKey ?? `${position}`; const keyIndex = elements .slice(0, position) .findIndex(([k]) => manualKey && k === manualKey); + const element = ( - + {node} diff --git a/libs/core/src/builtins/types.ts b/libs/core/src/builtins/types.ts index 2b357d4..4096c0e 100644 --- a/libs/core/src/builtins/types.ts +++ b/libs/core/src/builtins/types.ts @@ -1,5 +1,5 @@ import { Dispatch, ReactNode, SetStateAction } from 'react'; -import { Wrapper } from '../utils/apply-wrapper'; +import { Wrapper } from '../wrappers'; import { DisplayArgs } from './display'; import { ViewArgs } from './view'; @@ -9,6 +9,10 @@ export interface ViewComponentProps { stateKey: string; value: T; setValue: Dispatch; + display: (...args: DisplayArgs) => void; + view: ( + ...args: ViewArgs + ) => R; } export type ViewComponent = React.FC>; diff --git a/libs/core/src/builtins/view.ts b/libs/core/src/builtins/view.ts index 5fb3f99..7ba2857 100644 --- a/libs/core/src/builtins/view.ts +++ b/libs/core/src/builtins/view.ts @@ -6,7 +6,7 @@ import { ViewComponentProps, ViewDefinition, } from './types'; -import { Wrapper } from '../utils/apply-wrapper'; +import { Wrapper } from '../wrappers'; import { tail } from '../utils/tail'; export function defineView( @@ -24,28 +24,51 @@ export function defineTransformView( export type ViewArgs = [ key: K, - ...wrappers: Wrapper[], + ...wrappers: (Wrapper | 'default')[], def: ViewDefinition ]; +export function normalizeViewArgs< + T extends StateBase, + K extends keyof T & string, + V, + R +>(args: ViewArgs) { + const [key, ...restArgs] = args; + const [wrappers, def] = tail(restArgs); + return { key, wrappers, def }; +} + +function makeViewFunction({ + set, + display, + state, +}: Pick, 'set' | 'display' | 'state'>) { + return function view( + ...args: ViewArgs + ) { + const { key, wrappers, def } = normalizeViewArgs(args); + const { component, getReturnValue } = def; + const props: ViewComponentProps = { + stateKey: key, + value: state[key] as V, + setValue: (value: any) => set(key, value), + display, + view, + }; + display(key, ...wrappers, component(props)); + return getReturnValue ? getReturnValue(props) : (state[key] as R); + }; +} + export function useReactlitView({ set, display, state, }: Pick, 'set' | 'display' | 'state'>) { return useCallback['view']>( - (...args: ViewArgs) => { - const [key, ...restArgs] = args; - const [wrappers, def] = tail(restArgs); - const { component, getReturnValue } = def; - const props: ViewComponentProps = { - stateKey: key, - value: state[key] as V, - setValue: (value: any) => set(key, value), - }; - display(key, ...wrappers, component(props)); - return getReturnValue ? getReturnValue(props) : (state[key] as R); - }, + (...args: ViewArgs) => + makeViewFunction({ set, display, state })(...args), [state, set, display] ); } diff --git a/libs/core/src/index.ts b/libs/core/src/index.ts index b16d31e..44b4c54 100644 --- a/libs/core/src/index.ts +++ b/libs/core/src/index.ts @@ -3,7 +3,7 @@ export * from './builtins/types'; export { defineView, defineTransformView } from './builtins/view'; export * from './hooks/use-reactlit'; export * from './hooks/use-reactlit-state'; -export * from './utils/apply-wrapper'; +export * from './wrappers'; export * from './inputs/form.view'; export * from './inputs/layout.view'; export * from './plugins/data-fetching'; diff --git a/libs/core/src/inputs/form.view.tsx b/libs/core/src/inputs/form.view.tsx index 4c61d6b..e620d5c 100644 --- a/libs/core/src/inputs/form.view.tsx +++ b/libs/core/src/inputs/form.view.tsx @@ -1,8 +1,7 @@ import { Fragment, ReactNode, SetStateAction, useMemo } from 'react'; -import { applyWrapper, Wrapper } from '../utils/apply-wrapper'; -import { isSetStateFunction } from '../hooks/use-reactlit-state'; import { ViewComponentProps, ViewDefinition } from '../builtins/types'; import { defineView } from '../builtins/view'; +import { isSetStateFunction } from '../hooks/use-reactlit-state'; export type FormDefMap = { [K in keyof T]: ViewDefinition; @@ -10,7 +9,10 @@ export type FormDefMap = { export interface FormViewProps { form: FormDefMap; - wrapper?: Wrapper; + wrapperProps?: React.DetailedHTMLProps< + React.HTMLAttributes, + HTMLDivElement + >; } export function FormViewComponent({ @@ -18,7 +20,9 @@ export function FormViewComponent({ value, stateKey, setValue, - wrapper, + display, + view, + wrapperProps, }: FormViewProps & ViewComponentProps) { const views = useMemo(() => { const views: ReactNode[] = []; @@ -32,17 +36,19 @@ export function FormViewComponent({ ...value, [key]: isSetStateFunction(v) ? v(value?.[key]) : v, }), + display, + view, }; views.push({def.component(props)}); } return views; - }, [form, value, setValue, stateKey]); - return applyWrapper(<>{views}, wrapper); + }, [form, value, setValue, stateKey, display, view]); + return
{views}
; } export function FormView( form: FormDefMap, - props?: Omit, 'form'> + props?: FormViewProps['wrapperProps'] ) { return defineView((viewProps) => ( diff --git a/libs/core/src/inputs/layout.view.tsx b/libs/core/src/inputs/layout.view.tsx index fd13205..60bc899 100644 --- a/libs/core/src/inputs/layout.view.tsx +++ b/libs/core/src/inputs/layout.view.tsx @@ -1,8 +1,19 @@ import { useEffect, useRef } from 'react'; import tunnel from 'tunnel-rat'; -import { ViewComponentProps } from '../builtins/types'; -import { defineTransformView } from '../builtins/view'; -import { Wrapper } from '../utils/apply-wrapper'; +import { + ReactlitContext, + StateBase, + ViewComponentProps, + ViewDefinition, +} from '../builtins/types'; +import { + defineTransformView, + normalizeViewArgs, + ViewArgs, +} from '../builtins/view'; +import { Wrapper } from '../wrappers'; +import { isKeyedDisplayArgs, normalizeDisplayArgs } from '../builtins/display'; +import { tail } from '../utils/tail'; type Tunnel = ReturnType; @@ -15,31 +26,30 @@ type Repeat< ? Result : Repeat; -// export function createLayoutSlot( -// ctx: ReactlitContext, -// t: ReturnType -// ): LayoutSlot { -// return { -// display(...args) { -// const node = args.length === 1 ? args[0] : args[1]; -// const manualKey = args.length === 1 ? undefined : args[0]; -// const wrappedNode = {node}; -// const passArgs = -// manualKey === undefined -// ? ([wrappedNode] as const) -// : ([manualKey, wrappedNode] as const); -// ctx.display(...passArgs); -// }, -// view(key: K, def: ViewDefinition) { -// return ctx.view(key, { -// ...def, -// component: (props) => { -// return {def.component(props)}; -// }, -// }); -// }, -// }; -// } +export type LayoutSlot = Pick< + ReactlitContext, + 'display' | 'view' +>; + +function createLayoutSlot( + ctx: Pick, 'display' | 'view'>, + t: ReturnType +): LayoutSlot { + return { + display(...args) { + const { manualKey, wrappers, node } = normalizeDisplayArgs(args); + if (manualKey) { + ctx.display(manualKey, t.In as Wrapper, 'default', ...wrappers, node); + } else { + ctx.display(t.In as Wrapper, 'default', ...wrappers, node); + } + }, + view(...args: ViewArgs) { + const { key, wrappers, def } = normalizeViewArgs(args); + return ctx.view(key, t.In as Wrapper, 'default', ...wrappers, def); + }, + }; +} export function LayoutViewComponent({ slots, @@ -81,14 +91,19 @@ export function LayoutView( HTMLDivElement > ) { - return defineTransformView | undefined, Repeat>( + return defineTransformView< + Repeat | undefined, + Repeat, N> + >( (viewProps) => ( ), - ({ value }) => { + ({ value, display, view }) => { const tunnels = (value ?? []) as Tunnel[]; - const wrappers = tunnels.map((t) => t.In); - return wrappers as Repeat; + const subContext = tunnels.map((t) => + createLayoutSlot({ display, view }, t) + ); + return subContext as Repeat, N>; } ); } diff --git a/libs/core/src/reactlit.tsx b/libs/core/src/reactlit.tsx index d05361d..d79202c 100644 --- a/libs/core/src/reactlit.tsx +++ b/libs/core/src/reactlit.tsx @@ -14,7 +14,7 @@ import { useReactlitSet } from './builtins/set'; import { ReactlitProps, StateBase } from './builtins/types'; import { useReactlitView } from './builtins/view'; import { useReactlitState } from './hooks/use-reactlit-state'; -import { Wrapper } from './utils/apply-wrapper'; +import { Wrapper } from './wrappers'; const defaultRenderError = ({ error }) => (
diff --git a/libs/core/src/utils/apply-wrapper.tsx b/libs/core/src/utils/apply-wrapper.tsx deleted file mode 100644 index 2d1668e..0000000 --- a/libs/core/src/utils/apply-wrapper.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { ReactNode, useMemo } from 'react'; - -export type Wrapper = (props: { children: ReactNode }) => ReactNode; - -export function applyWrapper(node: ReactNode, Wrap?: Wrapper) { - return Wrap ? {node} : node; -} - -export function combineWrappers(...wrappers: Wrapper[]): Wrapper { - return ({ children }) => - wrappers.reduce((acc, Wrapper) => {acc}, children); -} - -export function ApplyWrappers({ - wrappers, - children, -}: { - wrappers: Wrapper[]; - children: ReactNode; -}) { - return useMemo(() => { - console.log('apply wrappers', wrappers, children); - return wrappers.reduce((acc, W) => applyWrapper(acc, W), children); - }, [children, wrappers]); -} diff --git a/libs/core/src/wrappers.tsx b/libs/core/src/wrappers.tsx new file mode 100644 index 0000000..4c052a5 --- /dev/null +++ b/libs/core/src/wrappers.tsx @@ -0,0 +1,54 @@ +import { + cloneElement, + Fragment, + isValidElement, + PropsWithChildren, + ReactNode, + useMemo, +} from 'react'; + +export interface ReactlitWrapperProps { + position: number; + stateKey: string; +} + +export type ReactlitWrapperComponent = React.FC< + PropsWithChildren +>; + +export type Wrapper = ReactlitWrapperComponent | React.ReactElement; + +function applyWrapper( + node: ReactNode, + Wrap?: Wrapper, + props?: ReactlitWrapperProps +) { + if (!Wrap) return node; + if (isValidElement(Wrap)) return cloneElement(Wrap, {}, node); + return {node}; +} +export function ApplyWrappers({ + wrappers, + defaultWrapper, + children, + props, +}: { + wrappers: (Wrapper | 'default')[]; + defaultWrapper: Wrapper; + children: ReactNode; + props: ReactlitWrapperProps; +}) { + const wrappedContent = useMemo(() => { + const base = wrappers.includes('default') + ? [...wrappers] + : [defaultWrapper, ...wrappers]; + return base.reverse().reduce( + (acc, W) => + applyWrapper(acc, W === 'default' ? defaultWrapper : W, props), + // this extra Fragment wrapper at the end is necessary for some + // very mysterious reason to keep plain string nodes from shifting positions around + {children} + ); + }, [children, wrappers, defaultWrapper, props]); + return wrappedContent; +} diff --git a/libs/radix/src/radix-wrapper.tsx b/libs/radix/src/radix-wrapper.tsx index abb2c26..aaffaba 100644 --- a/libs/radix/src/radix-wrapper.tsx +++ b/libs/radix/src/radix-wrapper.tsx @@ -1,5 +1,6 @@ import { Box, Container, Flex, Theme, ThemeProps } from '@radix-ui/themes'; -import { combineWrappers, Wrapper } from '@reactlit/core'; +import { Wrapper } from '@reactlit/core'; +import { PropsWithChildren } from 'react'; export const BoxContainerWrapper: Wrapper = ({ children }) => ( @@ -11,17 +12,32 @@ export const BoxContainerWrapper: Wrapper = ({ children }) => ( ); -export const RadixThemeWrapper = (theme?: ThemeProps) => { - const RadixThemeWrapper: Wrapper = ({ children }) => ( - +export const RadixTheme = ({ + children, + ...props +}: PropsWithChildren) => { + return ( + {children} ); +}; + +export const RadixThemeWrapper = (theme?: ThemeProps) => { + const RadixThemeWrapper: Wrapper = ({ children }) => ( + {children} + ); return RadixThemeWrapper; }; export const RadixWrapper = (theme?: ThemeProps) => { - return combineWrappers(BoxContainerWrapper, RadixThemeWrapper(theme)); + const ThemeWrapper = RadixThemeWrapper(theme); + const RadixWrapper: Wrapper = ({ children, ...props }) => ( + + {children} + + ); + return RadixWrapper; }; export const DefaultRadixWrapper = RadixWrapper(); From 9b9abf36ea9fa1ce8a9cd86a9dcb6df2f1770260 Mon Sep 17 00:00:00 2001 From: Andy Date: Thu, 23 Jan 2025 20:09:49 +0100 Subject: [PATCH 3/9] use layout for input label of vanilla --- .../src/pages/hello-world-vanilla/index.tsx | 81 +++++++++++++++---- libs/vanilla/src/inputs/check.input.tsx | 81 ++++++++----------- libs/vanilla/src/inputs/radio.input.tsx | 75 +++++++---------- libs/vanilla/src/inputs/text.input.tsx | 23 +++--- 4 files changed, 138 insertions(+), 122 deletions(-) diff --git a/apps/reactlit-examples/src/pages/hello-world-vanilla/index.tsx b/apps/reactlit-examples/src/pages/hello-world-vanilla/index.tsx index 9f0d928..d37f4b9 100644 --- a/apps/reactlit-examples/src/pages/hello-world-vanilla/index.tsx +++ b/apps/reactlit-examples/src/pages/hello-world-vanilla/index.tsx @@ -1,8 +1,17 @@ -import { Reactlit, useReactlitState } from '@reactlit/core'; +import { + LayoutView, + Reactlit, + useReactlitState, + Wrapper, +} from '@reactlit/core'; import { Inputs } from '@reactlit/vanilla'; +const InputWrapper: Wrapper = ({ children }) => { + return
{children}
; +}; + export default function HelloWorldVanilla() { - const [appState, setAppState] = useReactlitState({ + const [appState, setAppState] = useReactlitState({ name: '', pickedNumbers: [], pickedColors: [], @@ -12,32 +21,58 @@ export default function HelloWorldVanilla() { return ( {async ({ display, view }) => { - display(
Hello World Vanilla
); - const name = view( + display(
Hello World Vanilla
); + + const [labelElement, inputElement] = view( + 'nameWrapper', +
, + LayoutView(2) + ); + + labelElement.display(); + + const name = inputElement.view( 'name', Inputs.Text({ id: 'name-input', className: 'border p-0.5', - label: 'Name', placeholder: 'Enter your name', }) ); + display(
Hello {name}!
); - const picked = view( + + const [checkLabelElement, checkElement] = view( + 'pickedNumbersWrapper', +
, + LayoutView(2) + ); + + checkLabelElement.display( + + ); + const picked = checkElement.view( 'pickedNumbers', Inputs.Check(['One', 'Two', 'Three'], { className: { - container: 'flex gap-2 items-center', wrapper: 'flex gap-2', item: { input: 'border p-0.5 mr-1', }, }, - label: 'Pick any number', }) ); display(
Picked: {picked.join(', ')}!
); - const pickedColors = view( + + const [pickedColorsLabelElement, pickedColorsElement] = view( + 'pickedColorsWrapper', +
, + LayoutView(2) + ); + pickedColorsLabelElement.display( + + ); + const pickedColors = pickedColorsElement.view( 'pickedColors', Inputs.Check( [ @@ -48,13 +83,11 @@ export default function HelloWorldVanilla() { ], { className: { - container: 'flex gap-2 items-center', wrapper: 'flex gap-2', item: { input: 'border p-0.5 mr-1', }, }, - label: 'Pick any color', valueof: (item) => item.value, format: (item) => ( Colors: {JSON.stringify(pickedColors)}!
); - const chosenNumber = view( + + const [chosenNumberLabelElement, chosenNumberElement] = view( + 'chosenNumberWrapper', +
, + LayoutView(2) + ); + chosenNumberLabelElement.display( + + ); + const chosenNumber = chosenNumberElement.view( 'chosenNumber', Inputs.Radio(['One', 'Two', 'Three'], { className: { - container: 'flex gap-2 items-center', wrapper: 'flex gap-2', item: { input: 'border p-0.5 mr-1', }, }, - label: 'Choose a number', }) ); display(
Chosen Number: {chosenNumber}!
); - const chosenColor = view( + + const [chosenColorLabelElement, chosenColorElement] = view( + 'chosenColorWrapper', +
, + LayoutView(2) + ); + chosenColorLabelElement.display( + + ); + const chosenColor = chosenColorElement.view( 'chosenColor', Inputs.Radio( [ @@ -98,13 +147,11 @@ export default function HelloWorldVanilla() { ], { className: { - container: 'flex gap-2 items-center', wrapper: 'flex gap-2', item: { input: 'border p-0.5 mr-1', }, }, - label: 'Choose a color', valueof: (item) => item.value, format: (item) => ( = Omit< 'value' | 'disabled' | 'className' > & { data: T[]; - label?: string | React.ReactNode; className?: { - container?: string; wrapper?: string; - label?: string; item?: { wrapper?: string; input?: string; @@ -40,55 +37,45 @@ export const CheckInputComponent = ({ format, valueof, disabled, - label, ...props }: CheckInputProps & ViewComponentProps<(string | T)[]>) => { return ( -
- {label && ( - - )} -
- {data.map((item) => { - const isChecked = !!value.find((v) => - valueof ? valueof(item) === v : item === v - ); - const itemKey = `${stateKey}-${valueof?.(item) ?? item.toString()}`; - let disabledValue = false; +
+ {data.map((item) => { + const isChecked = !!value.find((v) => + valueof ? valueof(item) === v : item === v + ); + const itemKey = `${stateKey}-${valueof?.(item) ?? item.toString()}`; + let disabledValue = false; - if (typeof disabled === 'function') { - disabledValue = disabled(item); - } else if (Array.isArray(disabled)) { - disabledValue = disabled.includes( - valueof?.(item) ?? item.toString() - ); - } + if (typeof disabled === 'function') { + disabledValue = disabled(item); + } else if (Array.isArray(disabled)) { + disabledValue = disabled.includes(valueof?.(item) ?? item.toString()); + } - return ( -
- { - const _value = valueof?.(item) ?? item; - if (e.target.checked) setValue([...value, _value]); - else setValue(value.filter((v) => v !== _value)); - }} - disabled={disabledValue} - {...props} - /> - -
- ); - })} -
+ return ( +
+ { + const _value = valueof?.(item) ?? item; + if (e.target.checked) setValue([...value, _value]); + else setValue(value.filter((v) => v !== _value)); + }} + disabled={disabledValue} + {...props} + /> + +
+ ); + })}
); }; diff --git a/libs/vanilla/src/inputs/radio.input.tsx b/libs/vanilla/src/inputs/radio.input.tsx index 06100b0..5b1ceb6 100644 --- a/libs/vanilla/src/inputs/radio.input.tsx +++ b/libs/vanilla/src/inputs/radio.input.tsx @@ -14,11 +14,8 @@ export type RadioInputProps = Omit< 'value' | 'disabled' | 'className' > & { data: T[]; - label?: string | React.ReactNode; className?: { - container?: string; wrapper?: string; - label?: string; item?: { wrapper?: string; input?: string; @@ -40,52 +37,42 @@ export const RadioInputComponent = ({ format, valueof, disabled, - label, ...props }: RadioInputProps & ViewComponentProps) => { return ( -
- {label && ( - - )} -
- {data.map((item) => { - const isChecked = valueof ? valueof(item) === value : item === value; - const itemKey = `${stateKey}-${valueof?.(item) ?? item.toString()}`; - let disabledValue = false; +
+ {data.map((item) => { + const isChecked = valueof ? valueof(item) === value : item === value; + const itemKey = `${stateKey}-${valueof?.(item) ?? item.toString()}`; + let disabledValue = false; - if (typeof disabled === 'function') { - disabledValue = disabled(item); - } else if (Array.isArray(disabled)) { - disabledValue = disabled.includes( - valueof?.(item) ?? item.toString() - ); - } + if (typeof disabled === 'function') { + disabledValue = disabled(item); + } else if (Array.isArray(disabled)) { + disabledValue = disabled.includes(valueof?.(item) ?? item.toString()); + } - return ( -
- { - const _value = valueof?.(item) ?? item; - if (e.target.checked) setValue(_value); - }} - disabled={disabledValue} - {...props} - /> - -
- ); - })} -
+ return ( +
+ { + const _value = valueof?.(item) ?? item; + if (e.target.checked) setValue(_value); + }} + disabled={disabledValue} + {...props} + /> + +
+ ); + })}
); }; diff --git a/libs/vanilla/src/inputs/text.input.tsx b/libs/vanilla/src/inputs/text.input.tsx index 0a050ab..5a6e5ef 100644 --- a/libs/vanilla/src/inputs/text.input.tsx +++ b/libs/vanilla/src/inputs/text.input.tsx @@ -9,7 +9,6 @@ export type TextInputProps = Omit< >, 'value' > & { - label?: string | React.ReactNode; debounceDelay?: number; }; @@ -19,25 +18,21 @@ export const TextInputComponent = ({ setValue, onChange, debounceDelay = 200, - label, ...props }: TextInputProps & ViewComponentProps) => { const debouncedSetValue = useDebouncedCallback((value) => { setValue(value); }, debounceDelay); return ( -
- {label && } - { - debouncedSetValue(e.target.value); - onChange?.(e); - }} - {...props} - /> -
+ { + debouncedSetValue(e.target.value); + onChange?.(e); + }} + {...props} + /> ); }; From fcd6b655b0c1d21bd92ac26fc3cc3e12576f6a94 Mon Sep 17 00:00:00 2001 From: Andy Date: Fri, 24 Jan 2025 15:59:55 +0100 Subject: [PATCH 4/9] fix layout with wrapper --- .../src/pages/hello-world-vanilla/index.tsx | 69 ++++++------------- 1 file changed, 20 insertions(+), 49 deletions(-) diff --git a/apps/reactlit-examples/src/pages/hello-world-vanilla/index.tsx b/apps/reactlit-examples/src/pages/hello-world-vanilla/index.tsx index d37f4b9..23e89b9 100644 --- a/apps/reactlit-examples/src/pages/hello-world-vanilla/index.tsx +++ b/apps/reactlit-examples/src/pages/hello-world-vanilla/index.tsx @@ -6,9 +6,16 @@ import { } from '@reactlit/core'; import { Inputs } from '@reactlit/vanilla'; -const InputWrapper: Wrapper = ({ children }) => { - return
{children}
; -}; +const InputLabelWrapper = + (label: string): Wrapper => + ({ children }) => { + return ( +
+ +
{children}
+
+ ); + }; export default function HelloWorldVanilla() { const [appState, setAppState] = useReactlitState({ @@ -23,16 +30,9 @@ export default function HelloWorldVanilla() { {async ({ display, view }) => { display(
Hello World Vanilla
); - const [labelElement, inputElement] = view( - 'nameWrapper', -
, - LayoutView(2) - ); - - labelElement.display(); - - const name = inputElement.view( + const name = view( 'name', + InputLabelWrapper('Name'), Inputs.Text({ id: 'name-input', className: 'border p-0.5', @@ -42,17 +42,9 @@ export default function HelloWorldVanilla() { display(
Hello {name}!
); - const [checkLabelElement, checkElement] = view( - 'pickedNumbersWrapper', -
, - LayoutView(2) - ); - - checkLabelElement.display( - - ); - const picked = checkElement.view( + const picked = view( 'pickedNumbers', + InputLabelWrapper('Pick any number'), Inputs.Check(['One', 'Two', 'Three'], { className: { wrapper: 'flex gap-2', @@ -64,16 +56,9 @@ export default function HelloWorldVanilla() { ); display(
Picked: {picked.join(', ')}!
); - const [pickedColorsLabelElement, pickedColorsElement] = view( - 'pickedColorsWrapper', -
, - LayoutView(2) - ); - pickedColorsLabelElement.display( - - ); - const pickedColors = pickedColorsElement.view( + const pickedColors = view( 'pickedColors', + InputLabelWrapper('Pick any color'), Inputs.Check( [ { label: 'Red', value: '#FF0000' }, @@ -107,16 +92,9 @@ export default function HelloWorldVanilla() { ); display(
Colors: {JSON.stringify(pickedColors)}!
); - const [chosenNumberLabelElement, chosenNumberElement] = view( - 'chosenNumberWrapper', -
, - LayoutView(2) - ); - chosenNumberLabelElement.display( - - ); - const chosenNumber = chosenNumberElement.view( + const chosenNumber = view( 'chosenNumber', + InputLabelWrapper('Choose a number'), Inputs.Radio(['One', 'Two', 'Three'], { className: { wrapper: 'flex gap-2', @@ -128,16 +106,9 @@ export default function HelloWorldVanilla() { ); display(
Chosen Number: {chosenNumber}!
); - const [chosenColorLabelElement, chosenColorElement] = view( - 'chosenColorWrapper', -
, - LayoutView(2) - ); - chosenColorLabelElement.display( - - ); - const chosenColor = chosenColorElement.view( + const chosenColor = view( 'chosenColor', + InputLabelWrapper('Choose a color'), Inputs.Radio( [ { label: 'Red', value: '#FF0000' }, From 8d1e90647b714848702c51d0abe0d69c5fa9d6cd Mon Sep 17 00:00:00 2001 From: Andy Date: Fri, 24 Jan 2025 22:31:23 +0100 Subject: [PATCH 5/9] fix rerender for wrapper --- .../src/pages/hello-world-vanilla/index.tsx | 41 +++++++++++++------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/apps/reactlit-examples/src/pages/hello-world-vanilla/index.tsx b/apps/reactlit-examples/src/pages/hello-world-vanilla/index.tsx index 23e89b9..dd87459 100644 --- a/apps/reactlit-examples/src/pages/hello-world-vanilla/index.tsx +++ b/apps/reactlit-examples/src/pages/hello-world-vanilla/index.tsx @@ -1,10 +1,6 @@ -import { - LayoutView, - Reactlit, - useReactlitState, - Wrapper, -} from '@reactlit/core'; +import { Reactlit, useReactlitState, Wrapper } from '@reactlit/core'; import { Inputs } from '@reactlit/vanilla'; +import { useMemo } from 'react'; const InputLabelWrapper = (label: string): Wrapper => @@ -25,6 +21,27 @@ export default function HelloWorldVanilla() { chosenNumber: '', chosenColor: '', }); + + const inputLabel = useMemo(() => { + return InputLabelWrapper('Name'); + }, []); + + const checkboxNumberLabel = useMemo(() => { + return InputLabelWrapper('Pick any number'); + }, []); + + const checkboxColorLabel = useMemo(() => { + return InputLabelWrapper('Pick any color'); + }, []); + + const radioNumberLabel = useMemo(() => { + return InputLabelWrapper('Choose a number'); + }, []); + + const radioColorLabel = useMemo(() => { + return InputLabelWrapper('Choose a color'); + }, []); + return ( {async ({ display, view }) => { @@ -32,9 +49,9 @@ export default function HelloWorldVanilla() { const name = view( 'name', - InputLabelWrapper('Name'), + inputLabel, Inputs.Text({ - id: 'name-input', + id: 'name', className: 'border p-0.5', placeholder: 'Enter your name', }) @@ -44,7 +61,7 @@ export default function HelloWorldVanilla() { const picked = view( 'pickedNumbers', - InputLabelWrapper('Pick any number'), + checkboxNumberLabel, Inputs.Check(['One', 'Two', 'Three'], { className: { wrapper: 'flex gap-2', @@ -58,7 +75,7 @@ export default function HelloWorldVanilla() { const pickedColors = view( 'pickedColors', - InputLabelWrapper('Pick any color'), + checkboxColorLabel, Inputs.Check( [ { label: 'Red', value: '#FF0000' }, @@ -94,7 +111,7 @@ export default function HelloWorldVanilla() { const chosenNumber = view( 'chosenNumber', - InputLabelWrapper('Choose a number'), + radioNumberLabel, Inputs.Radio(['One', 'Two', 'Three'], { className: { wrapper: 'flex gap-2', @@ -108,7 +125,7 @@ export default function HelloWorldVanilla() { const chosenColor = view( 'chosenColor', - InputLabelWrapper('Choose a color'), + radioColorLabel, Inputs.Radio( [ { label: 'Red', value: '#FF0000' }, From 8000d655e3139ed43b915c01b16131731f64e74b Mon Sep 17 00:00:00 2001 From: Andy Date: Tue, 28 Jan 2025 08:23:39 +0100 Subject: [PATCH 6/9] fix rerendering for wrapper --- .../src/pages/hello-world-vanilla/index.tsx | 39 +++++-------------- libs/core/src/wrappers.tsx | 2 +- 2 files changed, 10 insertions(+), 31 deletions(-) diff --git a/apps/reactlit-examples/src/pages/hello-world-vanilla/index.tsx b/apps/reactlit-examples/src/pages/hello-world-vanilla/index.tsx index dd87459..09f1b2f 100644 --- a/apps/reactlit-examples/src/pages/hello-world-vanilla/index.tsx +++ b/apps/reactlit-examples/src/pages/hello-world-vanilla/index.tsx @@ -1,14 +1,13 @@ import { Reactlit, useReactlitState, Wrapper } from '@reactlit/core'; import { Inputs } from '@reactlit/vanilla'; -import { useMemo } from 'react'; const InputLabelWrapper = (label: string): Wrapper => - ({ children }) => { + ({ children, stateKey }) => { return ( -
- -
{children}
+
+ + {children}
); }; @@ -22,26 +21,6 @@ export default function HelloWorldVanilla() { chosenColor: '', }); - const inputLabel = useMemo(() => { - return InputLabelWrapper('Name'); - }, []); - - const checkboxNumberLabel = useMemo(() => { - return InputLabelWrapper('Pick any number'); - }, []); - - const checkboxColorLabel = useMemo(() => { - return InputLabelWrapper('Pick any color'); - }, []); - - const radioNumberLabel = useMemo(() => { - return InputLabelWrapper('Choose a number'); - }, []); - - const radioColorLabel = useMemo(() => { - return InputLabelWrapper('Choose a color'); - }, []); - return ( {async ({ display, view }) => { @@ -49,7 +28,7 @@ export default function HelloWorldVanilla() { const name = view( 'name', - inputLabel, + InputLabelWrapper('Name'), Inputs.Text({ id: 'name', className: 'border p-0.5', @@ -61,7 +40,7 @@ export default function HelloWorldVanilla() { const picked = view( 'pickedNumbers', - checkboxNumberLabel, + InputLabelWrapper('Pick any number'), Inputs.Check(['One', 'Two', 'Three'], { className: { wrapper: 'flex gap-2', @@ -75,7 +54,7 @@ export default function HelloWorldVanilla() { const pickedColors = view( 'pickedColors', - checkboxColorLabel, + InputLabelWrapper('Pick any color'), Inputs.Check( [ { label: 'Red', value: '#FF0000' }, @@ -111,7 +90,7 @@ export default function HelloWorldVanilla() { const chosenNumber = view( 'chosenNumber', - radioNumberLabel, + InputLabelWrapper('Choose a number'), Inputs.Radio(['One', 'Two', 'Three'], { className: { wrapper: 'flex gap-2', @@ -125,7 +104,7 @@ export default function HelloWorldVanilla() { const chosenColor = view( 'chosenColor', - radioColorLabel, + InputLabelWrapper('Choose a color'), Inputs.Radio( [ { label: 'Red', value: '#FF0000' }, diff --git a/libs/core/src/wrappers.tsx b/libs/core/src/wrappers.tsx index 4c052a5..d026f7c 100644 --- a/libs/core/src/wrappers.tsx +++ b/libs/core/src/wrappers.tsx @@ -25,7 +25,7 @@ function applyWrapper( ) { if (!Wrap) return node; if (isValidElement(Wrap)) return cloneElement(Wrap, {}, node); - return {node}; + return Wrap({ children: node, ...props }) as JSX.Element; } export function ApplyWrappers({ wrappers, From f3952eeabc070ee8652870baa202d190a420b15e Mon Sep 17 00:00:00 2001 From: Michael Shafir Date: Mon, 24 Feb 2025 17:23:29 -0500 Subject: [PATCH 7/9] roll out new wrapper system and layout views --- apps/reactlit-docs/astro.config.mjs | 3 +- .../src/content/docs/guides/data-fetching.mdx | 4 +- .../src/content/docs/guides/layout.mdx | 15 +- .../src/content/docs/guides/wrappers.mdx | 64 +++++ .../apps/contact-list-basic-async.tsx | 18 +- .../src/examples/apps/contact-list.tsx | 20 +- .../src/examples/contact-list-data-fetch.tsx | 18 +- .../src/examples/contact-list-react.tsx | 6 + .../src/examples/layout-example.tsx | 17 +- .../src/components/debug-toggle.tsx | 46 ++++ .../reactlit-examples/src/components/main.tsx | 6 +- .../reactlit-examples/src/components/menu.tsx | 1 + apps/reactlit-examples/src/pages/_app.tsx | 9 +- .../src/pages/hello-world-vanilla/index.tsx | 29 +- .../src/pages/layout-test/index.tsx | 47 ++++ .../src/pages/radix-inputs/index.tsx | 259 ++++++++++-------- .../src/pages/starter/index.tsx | 63 +---- .../src/pages/todo-list/index.tsx | 146 +++++----- apps/reactlit-examples/src/styles/globals.css | 33 +++ libs/core/package.json | 2 +- libs/core/src/builtins/display.tsx | 20 +- libs/core/src/builtins/types.ts | 5 - libs/core/src/builtins/view.ts | 2 +- libs/core/src/index.ts | 1 - libs/core/src/inputs/form.view.tsx | 56 ---- libs/core/src/inputs/layout.view.tsx | 76 +++-- libs/core/src/reactlit.tsx | 6 +- libs/core/src/utils/tunnel.tsx | 75 +++++ .../utils/use-isomorphic-layout-effect.tsx | 25 ++ libs/core/src/wrappers.tsx | 65 +++-- libs/radix/src/index.ts | 1 + libs/radix/src/inputs.ts | 2 - libs/radix/src/inputs/async-button.input.tsx | 2 + libs/radix/src/inputs/check.input.tsx | 25 +- libs/radix/src/inputs/radio.input.tsx | 21 +- libs/radix/src/inputs/select.input.tsx | 27 +- libs/radix/src/inputs/slider.input.tsx | 40 ++- libs/radix/src/inputs/switch.input.tsx | 11 +- libs/radix/src/inputs/table.input.tsx | 2 + libs/radix/src/inputs/text.input.tsx | 43 ++- libs/radix/src/inputs/textarea.input.tsx | 29 +- libs/radix/src/label.tsx | 21 +- libs/radix/src/radix-wrapper.tsx | 27 +- libs/vanilla/src/index.ts | 1 + libs/vanilla/src/inputs/check.input.tsx | 2 + libs/vanilla/src/inputs/radio.input.tsx | 2 + libs/vanilla/src/inputs/text.input.tsx | 2 + libs/vanilla/src/label-wrapper.tsx | 18 ++ pnpm-lock.yaml | 207 ++++++++++++-- 49 files changed, 1022 insertions(+), 598 deletions(-) create mode 100644 apps/reactlit-docs/src/content/docs/guides/wrappers.mdx create mode 100644 apps/reactlit-examples/src/components/debug-toggle.tsx create mode 100644 apps/reactlit-examples/src/pages/layout-test/index.tsx delete mode 100644 libs/core/src/inputs/form.view.tsx create mode 100644 libs/core/src/utils/tunnel.tsx create mode 100644 libs/core/src/utils/use-isomorphic-layout-effect.tsx create mode 100644 libs/vanilla/src/label-wrapper.tsx diff --git a/apps/reactlit-docs/astro.config.mjs b/apps/reactlit-docs/astro.config.mjs index 361c131..2b64793 100644 --- a/apps/reactlit-docs/astro.config.mjs +++ b/apps/reactlit-docs/astro.config.mjs @@ -31,7 +31,8 @@ export default defineConfig({ { label: 'Installation', slug: 'guides/installation' }, { label: 'Basics', slug: 'guides/basics' }, { label: 'Data Fetching', slug: 'guides/data-fetching' }, - { label: 'Layout Plugin', slug: 'guides/layout' }, + { label: 'Wrappers', slug: 'guides/wrappers' }, + { label: 'Layout', slug: 'guides/layout' }, { label: 'Managed State', slug: 'guides/managed-state' }, { label: 'Defining Views', slug: 'guides/defining-views' }, ], diff --git a/apps/reactlit-docs/src/content/docs/guides/data-fetching.mdx b/apps/reactlit-docs/src/content/docs/guides/data-fetching.mdx index 9ad5757..0178a53 100644 --- a/apps/reactlit-docs/src/content/docs/guides/data-fetching.mdx +++ b/apps/reactlit-docs/src/content/docs/guides/data-fetching.mdx @@ -85,8 +85,8 @@ import ContactListDataFetchApp from '/src/examples/contact-list-data-fetch.tsx'; { range: '38-39', label: 'get data' }, { range: '44-50' }, { range: '51-52', label: 'refetch' }, - { range: '87-98', label: 'update' }, - { range: '102', label: 'disable' }, + { range: '85-96', label: 'update' }, + { range: '100', label: 'disable' }, ]} /> diff --git a/apps/reactlit-docs/src/content/docs/guides/layout.mdx b/apps/reactlit-docs/src/content/docs/guides/layout.mdx index 550b0e9..3f4f38e 100644 --- a/apps/reactlit-docs/src/content/docs/guides/layout.mdx +++ b/apps/reactlit-docs/src/content/docs/guides/layout.mdx @@ -1,24 +1,19 @@ --- -title: Layout Plugin -description: How to use the layout plugin +title: Layout +description: How to use the layout view --- -By default, Reactlit uses a simple layout system where each `display` call +By default, Reactlit uses a simple flat layout system where each `display` call concatenates a `ReactNode` to the DOM. This works great for simple applications, where you are ok just showing your content in a single column, but as your application grows, you may want to have more control over the layout. -The plugin provides a `layout` helper function to let you define more custom layouts with +The built-in layout view lets you define more custom layouts with your Reactlit script. import { Card, Aside } from '@astrojs/starlight/components'; - - -The `layout` function works via the [tunnel-rat](https://github.com/pmndrs/tunnel-rat) +The `layout` view works via a modified version of the [tunnel-rat](https://github.com/pmndrs/tunnel-rat) library. It takes a number of slots to expose and a render function that defines how those slots are layed out. Then, for each slot, it returns a mini-context with `view` and `display` functions that render specifically within that slot. diff --git a/apps/reactlit-docs/src/content/docs/guides/wrappers.mdx b/apps/reactlit-docs/src/content/docs/guides/wrappers.mdx new file mode 100644 index 0000000..84ff9a5 --- /dev/null +++ b/apps/reactlit-docs/src/content/docs/guides/wrappers.mdx @@ -0,0 +1,64 @@ +--- +title: Wrappers +--- + +You might encounter use cases that make Reactlit feel limiting when it comes to the heirarchical nature +of UI. The fact that the render is a flat list of elements can make it hard to build even moderately complex +interfaces. + +To solve this, Reactlit provides a way to wrap elements in custom components. These wrappers can be used to +apply styles and add structure to your UI. + +Any `display` or `view` call actually allows a varidic number of arguments. Penultmate arguments are considered wrappers +and will automatically `wrap` their successors. The structure can look a bit unusual coming from standard React, but +it does allow for a lot of flexibility in a very succinct format. + +Wrappers come in two flavors. + +1. They can be static React elements with their children omitted: + +```tsx +const name = view( + 'name', +
, + Inputs.Text({ placeholder: 'Enter your name' }) +); +display( +
, +
+

Hello World

+
+); +``` + +2. They can react components of type `WrapperComponent`. + +define the component builder. This is a function that creates the wrapper component +you want to use. + +```tsx +export const Label = (label: string) => { + const LabelComponent: WrapperComponent = ({ children, stateKey }) => { + return ( +
+ + {children} +
+ ); + }; + return LabelComponent; +}; +``` + +use the component: + +```tsx +const name = view( + 'name', + Label('Name'), + Inputs.Text({ placeholder: 'Enter your name' }) +); +``` + +This makes views and displays much more composable allowing you to build +custom interfaces without a lot of boilerplate. diff --git a/apps/reactlit-docs/src/examples/apps/contact-list-basic-async.tsx b/apps/reactlit-docs/src/examples/apps/contact-list-basic-async.tsx index b2d2ff0..40c2af5 100644 --- a/apps/reactlit-docs/src/examples/apps/contact-list-basic-async.tsx +++ b/apps/reactlit-docs/src/examples/apps/contact-list-basic-async.tsx @@ -1,6 +1,6 @@ import { Button } from '@radix-ui/themes'; -import { FormView, type ReactlitContext } from '@reactlit/core'; -import { Inputs } from '@reactlit/radix'; +import { type ReactlitContext } from '@reactlit/core'; +import { Inputs, Label } from '@reactlit/radix'; import { TopRightLoader } from '../components/loader'; import { ContactsMockService } from '../mocks/contacts'; @@ -32,15 +32,13 @@ export async function ContactListApp(app: ReactlitContext) { if (!selectedContact) return; app.display(

Selected Contact Details

); if (app.changed('selectedContact')) { - app.set('updates', selectedContact); + app.set('name', selectedContact.name); + app.set('email', selectedContact.email); } - const updates = app.view( - 'updates', - FormView({ - name: Inputs.Text({ label: 'Name' }), - email: Inputs.Text({ label: 'Email' }), - }) - ); + const updates = { + name: app.view('name', Label('Name'), Inputs.Text()), + email: app.view('email', Label('Email'), Inputs.Text()), + }; // if you wish, you can use an AsyncButton view for a button that // has a loading state during async operations app.view( diff --git a/apps/reactlit-docs/src/examples/apps/contact-list.tsx b/apps/reactlit-docs/src/examples/apps/contact-list.tsx index 1fe9f17..3397c8d 100644 --- a/apps/reactlit-docs/src/examples/apps/contact-list.tsx +++ b/apps/reactlit-docs/src/examples/apps/contact-list.tsx @@ -1,7 +1,7 @@ -import { FormView, type ReactlitContext } from '@reactlit/core'; -import { Inputs } from '@reactlit/radix'; -import { ContactMockApi as api } from '../mocks/contacts'; import { Button } from '@radix-ui/themes'; +import { type ReactlitContext } from '@reactlit/core'; +import { Inputs, Label } from '@reactlit/radix'; +import { ContactMockApi as api } from '../mocks/contacts'; export async function ContactListApp(app: ReactlitContext) { const contacts = await api.getContacts(); @@ -25,16 +25,14 @@ export async function ContactListApp(app: ReactlitContext) { if (!selectedContact) return; app.display(

Selected Contact Details

); if (app.changed('selectedContact')) { - app.set('updates', selectedContact); + app.set('name', selectedContact.name); + app.set('email', selectedContact.email); } // the built-in FormView allows you to group inputs together - const updates = app.view( - 'updates', - FormView({ - name: Inputs.Text({ label: 'Name' }), - email: Inputs.Text({ label: 'Email' }), - }) - ); + const updates = { + name: app.view('name', Label('Name'), Inputs.Text()), + email: app.view('email', Label('Email'), Inputs.Text()), + }; app.display( + + ); +}; + +export const DebugProvider = ({ children }: { children: React.ReactNode }) => { + const [debug, setDebug] = useState(false); + return ( + + {children} + + ); +}; + +export function useDebug() { + const { debug } = useContext(DebugContext); + return debug; +} + +export const Debug: Wrapper = ({ children, stateKey }) => { + const debug = useDebug(); + if (!debug) return children; + return ( +
+
{stateKey}
+
{children}
+
+ ); +}; diff --git a/apps/reactlit-examples/src/components/main.tsx b/apps/reactlit-examples/src/components/main.tsx index e40a416..75f8553 100644 --- a/apps/reactlit-examples/src/components/main.tsx +++ b/apps/reactlit-examples/src/components/main.tsx @@ -1,8 +1,10 @@ -import { Box, Container, Flex, Text } from '@radix-ui/themes'; +import { Box, Button, Container, Flex, Text } from '@radix-ui/themes'; import { Geist, Geist_Mono } from 'next/font/google'; import Head from 'next/head'; import { Menu } from './menu'; import { ThemeToggle } from './theme-toggle'; +import React, { useContext } from 'react'; +import { DebugToggle } from './debug-toggle'; const geistSans = Geist({ variable: '--font-geist-sans', @@ -35,6 +37,7 @@ export function Main({ width="100%" p="4" align="center" + gap="4" style={{ background: 'var(--color-background)', borderBottom: '1px solid var(--gray-4)', @@ -44,6 +47,7 @@ export function Main({ {title} + diff --git a/apps/reactlit-examples/src/components/menu.tsx b/apps/reactlit-examples/src/components/menu.tsx index ead74f7..682d58c 100644 --- a/apps/reactlit-examples/src/components/menu.tsx +++ b/apps/reactlit-examples/src/components/menu.tsx @@ -23,6 +23,7 @@ export function Menu() { Radix Inputs Todo List Starter + Layout Test ); } diff --git a/apps/reactlit-examples/src/pages/_app.tsx b/apps/reactlit-examples/src/pages/_app.tsx index e0d118d..7aa93eb 100644 --- a/apps/reactlit-examples/src/pages/_app.tsx +++ b/apps/reactlit-examples/src/pages/_app.tsx @@ -4,14 +4,17 @@ import { Theme } from '@radix-ui/themes'; import { ThemeProvider } from 'next-themes'; import type { AppProps } from 'next/app'; import { Main } from '@/components/main'; +import { DebugProvider } from '@/components/debug-toggle'; export default function App({ Component, pageProps }: AppProps) { return ( -
- -
+ +
+ +
+
); diff --git a/apps/reactlit-examples/src/pages/hello-world-vanilla/index.tsx b/apps/reactlit-examples/src/pages/hello-world-vanilla/index.tsx index 09f1b2f..b844d1b 100644 --- a/apps/reactlit-examples/src/pages/hello-world-vanilla/index.tsx +++ b/apps/reactlit-examples/src/pages/hello-world-vanilla/index.tsx @@ -1,16 +1,7 @@ -import { Reactlit, useReactlitState, Wrapper } from '@reactlit/core'; -import { Inputs } from '@reactlit/vanilla'; +import { Reactlit, useReactlitState } from '@reactlit/core'; +import { Inputs, Label } from '@reactlit/vanilla'; -const InputLabelWrapper = - (label: string): Wrapper => - ({ children, stateKey }) => { - return ( -
- - {children} -
- ); - }; +const LabelProps = { className: 'flex items-center gap-2 mb-2' }; export default function HelloWorldVanilla() { const [appState, setAppState] = useReactlitState({ @@ -28,7 +19,7 @@ export default function HelloWorldVanilla() { const name = view( 'name', - InputLabelWrapper('Name'), + Label('Name', LabelProps), Inputs.Text({ id: 'name', className: 'border p-0.5', @@ -40,7 +31,7 @@ export default function HelloWorldVanilla() { const picked = view( 'pickedNumbers', - InputLabelWrapper('Pick any number'), + Label('Pick any number', LabelProps), Inputs.Check(['One', 'Two', 'Three'], { className: { wrapper: 'flex gap-2', @@ -54,13 +45,12 @@ export default function HelloWorldVanilla() { const pickedColors = view( 'pickedColors', - InputLabelWrapper('Pick any color'), + Label('Pick any color', LabelProps), Inputs.Check( [ { label: 'Red', value: '#FF0000' }, { label: 'Green', value: '#00FF00' }, { label: 'Blue', value: '#0000FF' }, - { label: 'White', value: '#FFFFFF' }, ], { className: { @@ -74,7 +64,6 @@ export default function HelloWorldVanilla() { ({ + name: '', + weight: 'regular', + size: 1, + }); + const Reactlit = useReactlit(DataFetchingPlugin); + const debug = useDebug(); + return ( + + + {async (ctx) => { + const { view } = ctx; + const [col1, col2] = view( + 'layout1', + Debug, +
, + LayoutView(2,
) + ); + const v1 = col1.view( + 'leftInput', + Debug, + Label('Column Left'), + Inputs.Text() + ); + col1.display(Debug, v1); + const v2 = col2.view( + 'rightInput', + Debug, + Label('Column Right'), + Inputs.Text() + ); + col2.display(Debug, v2); + }} + + + ); +} diff --git a/apps/reactlit-examples/src/pages/radix-inputs/index.tsx b/apps/reactlit-examples/src/pages/radix-inputs/index.tsx index 32482dd..558adde 100644 --- a/apps/reactlit-examples/src/pages/radix-inputs/index.tsx +++ b/apps/reactlit-examples/src/pages/radix-inputs/index.tsx @@ -1,6 +1,19 @@ -import { Badge, DataList } from '@radix-ui/themes'; -import { Reactlit, useReactlitState } from '@reactlit/core'; -import { DefaultRadixWrapper, Inputs } from '@reactlit/radix'; +import { useDebug } from '@/components/debug-toggle'; +import { + Badge, + ChevronDownIcon, + DataList, + ThickChevronRightIcon, +} from '@radix-ui/themes'; +import { + defaultLayoutState, + LayoutView, + Reactlit, + useReactlitState, + Wrapper, +} from '@reactlit/core'; +import { DefaultRadixWrapper, Inputs, Label } from '@reactlit/radix'; +import { ReactNode, useState } from 'react'; interface Country { name: string; @@ -12,7 +25,7 @@ interface Country { export async function fetchCountries(): Promise { const results = await fetch( - 'https://restcountries.com/v3.1/all?fields=name,region,subregion,population,cca3', + 'https://restcountries.com/v3.1/all?fields=name,region,subregion,population,cca2', { cache: 'force-cache', next: { @@ -28,132 +41,146 @@ export async function fetchCountries(): Promise { region: r.region, subregion: r.subregion, population: r.population, - code: r.cca3, + code: r.cca2, })); } +const DisplayLabel = + (label: string): Wrapper => + ({ children }) => + ( + + {label} + {children} + + ); + +const ResultsWrapper: Wrapper = ({ children }) => { + const [open, setOpen] = useState(true); + return ( +
setOpen(!open)} + > +

+ {open ? : } + Results +

+ {children} +
+ ); +}; + export default function RadixInputs() { const [appState, setAppState] = useReactlitState({ countrySearch: '', country: undefined as Country | undefined, - form: { - name: '', - bio: '', - number: [], - letter: undefined as string | undefined, - color: 'red', - slider: 0, - rangeSlider: [20, 80], - }, + results: defaultLayoutState(1), + name: '', + bio: '', + number: [], + letter: undefined as string | undefined, + color: 'red', + slider: 0, + rangeSlider: [20, 80], }); + const debug = useDebug(); return ( - - {async ({ display, view }) => { - display(
Inputs test
); - const results = view( - 'form', - Inputs.Form({ - name: Inputs.Text({ - label: 'Name', + + + {async ({ display, view }) => { + display(
Inputs test
); + const [results] = view('results', ResultsWrapper, LayoutView(1)); + const name = view( + 'name', + Label('Name'), + Inputs.Text({ placeholder: 'Enter your name', - }), - bio: Inputs.TextArea({ - label: 'Bio', + }) + ); + results.display(DisplayLabel('Name'), name); + const bio = view( + 'bio', + Label('Bio'), + Inputs.TextArea({ placeholder: 'Enter your bio', - }), - number: Inputs.Check( - { one: '1', two: '2', three: '3' }, - { - label: 'Pick any numbers', - } - ), - letter: Inputs.Radio(['A', 'B', 'C'], { - label: 'Pick one Letter', - }), - color: Inputs.Select(['red', 'blue', 'green'] as const, { - label: 'Pick a color', - }), - slider: Inputs.Slider({ - label: 'Slider', + }) + ); + results.display(DisplayLabel('Bio'), bio); + const number = view( + 'number', + Label('Pick any numbers'), + Inputs.Check({ one: '1', two: '2', three: '3' }) + ); + results.display(DisplayLabel('Numbers'), number); + const letter = view( + 'letter', + Label('Pick one Letter'), + Inputs.Radio(['A', 'B', 'C']) + ); + results.display(DisplayLabel('Letter'), letter); + const color = view( + 'color', + Label('Pick a color'), +
, + Inputs.Select(['red', 'blue', 'green'] as const) + ); + results.display( + DisplayLabel('Color'), + {color} + ); + const slider = view( + 'slider', + Label('Slider'), + Inputs.Slider({ min: 0, max: 100, - }), - rangeSlider: Inputs.RangeSlider({ - label: 'Range Slider', + }) + ); + results.display(DisplayLabel('Slider'), slider); + const rangeSlider = view( + 'rangeSlider', + Label('Range Slider'), + Inputs.RangeSlider({ min: 0, max: 100, - }), - }) - ); - - const countries = await fetchCountries(); - display(
Select a country
); - const filteredCountries = view( - 'countrySearch', - Inputs.Search(countries, { - label: 'Search', - placeholder: 'Search countries...', - }) - ); - const selectedCountry = view( - 'country', - Inputs.Table(filteredCountries, { - getRowId: (country) => country.code, - className: 'h-[300px]', - label: 'Countries', - }) - ); - - const { name, bio, number, letter, color, slider, rangeSlider } = - results; - display( - <> -
Results
- - - Name - {name} - - - Bio - -
{bio}
-
-
- - Numbers - {number.join(', ')} - - - Letter - {letter} - - - Color - - {color} - - - - Slider - {slider} - - - Range Slider - {rangeSlider.join(' - ')} - - - Country - {selectedCountry?.name} - -
- - ); - }} - + }) + ); + results.display( + DisplayLabel('Range Slider'), + rangeSlider.join(' - ') + ); + const countries = await fetchCountries(); + display(
Select a country
); + const filteredCountries = view( + 'countrySearch', + Label('Search'), + Inputs.Search(countries, { + placeholder: 'Search countries...', + }) + ); + const selectedCountry = view( + 'country', + Label('Countries'), + Inputs.Table(filteredCountries, { + getRowId: (country) => country.code, + className: 'h-[300px]', + }) + ); + results.display( + DisplayLabel('Country'), + <> + {selectedCountry?.code ? ( + + ) : ( + 'Select a country' + )} + + ); + }} + + ); } diff --git a/apps/reactlit-examples/src/pages/starter/index.tsx b/apps/reactlit-examples/src/pages/starter/index.tsx index 5997cbb..72c99b9 100644 --- a/apps/reactlit-examples/src/pages/starter/index.tsx +++ b/apps/reactlit-examples/src/pages/starter/index.tsx @@ -1,22 +1,12 @@ +import { Debug, useDebug } from '@/components/debug-toggle'; import { Box, Text } from '@radix-ui/themes'; import { textPropDefs } from '@radix-ui/themes/props'; import { DataFetchingPlugin, - LayoutView, useReactlit, useReactlitState, - Wrapper, } from '@reactlit/core'; -import { Inputs, RadixTheme } from '@reactlit/radix'; - -const StarterWrapper: Wrapper = ({ children, stateKey }) => { - return ( -
-
{stateKey}
-
{children}
-
- ); -}; +import { DefaultRadixWrapper, Inputs, Label } from '@reactlit/radix'; export default function Starter() { const [appState, setAppState] = useReactlitState({ @@ -25,46 +15,39 @@ export default function Starter() { size: 1, }); const Reactlit = useReactlit(DataFetchingPlugin); + const debug = useDebug(); return ( - - + + {async (ctx) => { const { display, view } = ctx; const name = view( 'name', + Debug, + Label('What is your name?'), Inputs.Text({ - label: 'What is your name?', placeholder: 'Enter name', }) ); const weight = view( 'weight', - Inputs.Radio(['light', 'regular', 'medium', 'bold'] as const, { - label: 'Weight', - }) + Debug, + Label('Weight'), + Inputs.Radio(['light', 'regular', 'medium', 'bold'] as const) ); const size = view( 'size', + Debug, + Label('Size'), Inputs.Slider({ - label: 'Size', min: 1, max: 9, }) ); + display(
); display( - -
-
- ); - display( - StarterWrapper, - , + Debug, ); - - const [col1, col2] = view( - 'l1', -
, - LayoutView(2) - ); - const v1 = col1.view( - 'leftInput', - Inputs.Text({ label: 'Column Left' }) - ); - col1.display(v1); - const v2 = col2.view( - 'rightInput', - Inputs.Text({ label: 'Column Right' }) - ); - col2.display(v2); }} - + ); } diff --git a/apps/reactlit-examples/src/pages/todo-list/index.tsx b/apps/reactlit-examples/src/pages/todo-list/index.tsx index 5ccf454..0744108 100644 --- a/apps/reactlit-examples/src/pages/todo-list/index.tsx +++ b/apps/reactlit-examples/src/pages/todo-list/index.tsx @@ -17,89 +17,91 @@ const api = new TodoService([], 1000); export default function TodoList() { const Reactlit = useReactlit(DataFetchingPlugin); return ( - - {async ({ display, view, set, changed, fetcher, layout }) => { - display( - - - - - - This app is purposely slow to show how Reactlit handles loading - states. - - - ); - const todosFetcher = fetcher(['todos'], () => api.getTodos()); - display( -
- -
- ); - view( - 'adding', - Inputs.AsyncButton( - async () => { - const newTodo = await api.addTodo(); - await todosFetcher.refetch(); - set('selectedTodo', newTodo.id); - }, - { - disabled: todosFetcher.isFetching(), - content: 'Add Todo', - } - ) - ); - const todos = todosFetcher.get() ?? []; - const selectedTodo = view( - 'selectedTodo', - Inputs.Table(todos, { - getRowId: (todo) => todo.id, - columns: ['task', 'completed'], - format: { - completed: (completed) => (completed ? '☑️' : ''), - }, - }) - ); - if (selectedTodo) { - if (changed('selectedTodo')) { - set('task', selectedTodo.task); - set('completed', selectedTodo.completed); - } - const task = view( - 'task', - Inputs.Text({ - label: 'Task', - }) + + + {async ({ display, view, set, changed, fetcher }) => { + display( + + + + + + This app is purposely slow to show how Reactlit handles loading + states. + + ); - const completed = view( - 'completed', - Inputs.Switch({ - label: 'Completed', - }) + const todosFetcher = fetcher(['todos'], () => api.getTodos()); + display( +
+ +
); view( - 'updaing', + 'adding', Inputs.AsyncButton( async () => { - // todosFetcher.update((todos) => { - // return todos.map((todo) => - // todo.id === selectedTodo.id - // ? { ...todo, task, completed } - // : todo - // ); - // }); - await api.updateTodo(selectedTodo.id, { task, completed }); + const newTodo = await api.addTodo(); await todosFetcher.refetch(); + set('selectedTodo', newTodo.id); }, { disabled: todosFetcher.isFetching(), - content: 'Update', + content: 'Add Todo', } ) ); - } - }} -
+ const todos = todosFetcher.get() ?? []; + const selectedTodo = view( + 'selectedTodo', + Inputs.Table(todos, { + getRowId: (todo) => todo.id, + columns: ['task', 'completed'], + format: { + completed: (completed) => (completed ? '☑️' : ''), + }, + }) + ); + if (selectedTodo) { + if (changed('selectedTodo')) { + set('task', selectedTodo.task); + set('completed', selectedTodo.completed); + } + const task = view( + 'task', + Inputs.Text({ + label: 'Task', + }) + ); + const completed = view( + 'completed', + Inputs.Switch({ + label: 'Completed', + }) + ); + view( + 'updaing', + Inputs.AsyncButton( + async () => { + // todosFetcher.update((todos) => { + // return todos.map((todo) => + // todo.id === selectedTodo.id + // ? { ...todo, task, completed } + // : todo + // ); + // }); + await api.updateTodo(selectedTodo.id, { task, completed }); + await todosFetcher.refetch(); + }, + { + disabled: todosFetcher.isFetching(), + content: 'Update', + } + ) + ); + } + }} +
+ ); } diff --git a/apps/reactlit-examples/src/styles/globals.css b/apps/reactlit-examples/src/styles/globals.css index 8e4ea0d..ef253ea 100644 --- a/apps/reactlit-examples/src/styles/globals.css +++ b/apps/reactlit-examples/src/styles/globals.css @@ -23,3 +23,36 @@ body { tr.rt-TableRow:nth-child(even) { background-color: var(--gray-2); } + +.overlay { + position: fixed; + cursor: pointer; + top: 4rem; + right: 2rem; + padding: 1rem; + background: var(--gray-2); + border: 1px solid #bbb; + border-radius: 0.5rem; + opacity: 0.8; + transition: all 0.2s; + z-index: 100; + width: 25%; + overflow-x: hidden; + overflow-y: hidden; + max-height: 3.5rem; +} + +.overlay.open { + max-height: 45rem; +} + +.overlay-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.overlay:hover { + opacity: 1; +} diff --git a/libs/core/package.json b/libs/core/package.json index 06420f3..a84f30c 100644 --- a/libs/core/package.json +++ b/libs/core/package.json @@ -34,7 +34,7 @@ "@tanstack/react-query": "^5.62.3", "fast-deep-equal": "^3.1.3", "react-error-boundary": "^4.1.2", - "tunnel-rat": "^0.1.2" + "zustand": "^5.0.3" }, "devDependencies": { "@mollycule/vigilante": "^1.0.2", diff --git a/libs/core/src/builtins/display.tsx b/libs/core/src/builtins/display.tsx index c0b225f..df7a660 100644 --- a/libs/core/src/builtins/display.tsx +++ b/libs/core/src/builtins/display.tsx @@ -1,7 +1,7 @@ -import { Fragment, ReactNode, useCallback, useState } from 'react'; +import { ReactNode, useCallback, useState } from 'react'; import { ErrorBoundary as ReactErrorBoundary } from 'react-error-boundary'; -import { ApplyWrappers, Wrapper } from '../wrappers'; import { tail } from '../utils/tail'; +import { ApplyWrappers, Wrapper } from '../wrappers'; import { ReactlitContext, ReactlitProps, StateBase } from './types'; interface DisplayState { @@ -9,8 +9,8 @@ interface DisplayState { elements: [string, React.ReactNode][]; } -type KeyedDisplayArgs = [string, ...(Wrapper | 'default')[], ReactNode]; -type UnkeyedDisplayArgs = [...(Wrapper | 'default')[], ReactNode]; +type KeyedDisplayArgs = [string, ...Wrapper[], ReactNode]; +type UnkeyedDisplayArgs = [...Wrapper[], ReactNode]; export type DisplayArgs = KeyedDisplayArgs | UnkeyedDisplayArgs; @@ -35,8 +35,7 @@ export function normalizeDisplayArgs(args: DisplayArgs) { export function useReactlitDisplay({ renderError, - wrapper, -}: Pick, 'renderError' | 'wrapper'>) { +}: Pick, 'renderError'>) { const [renderState, setRenderState] = useState({ position: 0, elements: [], @@ -56,11 +55,8 @@ export function useReactlitDisplay({ {node} @@ -99,7 +95,7 @@ export function useReactlitDisplay({ } }); }, - [setRenderState, renderError, wrapper] + [setRenderState, renderError] ); const resetRenderPosition = useCallback(() => { diff --git a/libs/core/src/builtins/types.ts b/libs/core/src/builtins/types.ts index 4096c0e..e7dca15 100644 --- a/libs/core/src/builtins/types.ts +++ b/libs/core/src/builtins/types.ts @@ -1,5 +1,4 @@ import { Dispatch, ReactNode, SetStateAction } from 'react'; -import { Wrapper } from '../wrappers'; import { DisplayArgs } from './display'; import { ViewArgs } from './view'; @@ -59,10 +58,6 @@ export type ReactlitProps = { * Whether to log debug messages to the console */ debug?: boolean; - /** - * Wrapper to apply around all displayed elements - */ - wrapper?: Wrapper; /** * Function for the Reactlit rendering logic */ diff --git a/libs/core/src/builtins/view.ts b/libs/core/src/builtins/view.ts index 7ba2857..7c0259e 100644 --- a/libs/core/src/builtins/view.ts +++ b/libs/core/src/builtins/view.ts @@ -24,7 +24,7 @@ export function defineTransformView( export type ViewArgs = [ key: K, - ...wrappers: (Wrapper | 'default')[], + ...wrappers: Wrapper[], def: ViewDefinition ]; diff --git a/libs/core/src/index.ts b/libs/core/src/index.ts index 44b4c54..76fd3bb 100644 --- a/libs/core/src/index.ts +++ b/libs/core/src/index.ts @@ -4,7 +4,6 @@ export { defineView, defineTransformView } from './builtins/view'; export * from './hooks/use-reactlit'; export * from './hooks/use-reactlit-state'; export * from './wrappers'; -export * from './inputs/form.view'; export * from './inputs/layout.view'; export * from './plugins/data-fetching'; diff --git a/libs/core/src/inputs/form.view.tsx b/libs/core/src/inputs/form.view.tsx deleted file mode 100644 index e620d5c..0000000 --- a/libs/core/src/inputs/form.view.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Fragment, ReactNode, SetStateAction, useMemo } from 'react'; -import { ViewComponentProps, ViewDefinition } from '../builtins/types'; -import { defineView } from '../builtins/view'; -import { isSetStateFunction } from '../hooks/use-reactlit-state'; - -export type FormDefMap = { - [K in keyof T]: ViewDefinition; -}; - -export interface FormViewProps { - form: FormDefMap; - wrapperProps?: React.DetailedHTMLProps< - React.HTMLAttributes, - HTMLDivElement - >; -} - -export function FormViewComponent({ - form, - value, - stateKey, - setValue, - display, - view, - wrapperProps, -}: FormViewProps & ViewComponentProps) { - const views = useMemo(() => { - const views: ReactNode[] = []; - for (const key in form) { - const def = form[key]; - const props = { - stateKey: `${stateKey}.${key}`, - value: value?.[key], - setValue: (v: SetStateAction) => - setValue({ - ...value, - [key]: isSetStateFunction(v) ? v(value?.[key]) : v, - }), - display, - view, - }; - views.push({def.component(props)}); - } - return views; - }, [form, value, setValue, stateKey, display, view]); - return
{views}
; -} - -export function FormView( - form: FormDefMap, - props?: FormViewProps['wrapperProps'] -) { - return defineView((viewProps) => ( - - )); -} diff --git a/libs/core/src/inputs/layout.view.tsx b/libs/core/src/inputs/layout.view.tsx index 60bc899..80888e2 100644 --- a/libs/core/src/inputs/layout.view.tsx +++ b/libs/core/src/inputs/layout.view.tsx @@ -1,23 +1,21 @@ -import { useEffect, useRef } from 'react'; -import tunnel from 'tunnel-rat'; +import { Fragment, useEffect, useRef } from 'react'; +import { normalizeDisplayArgs } from '../builtins/display'; import { ReactlitContext, StateBase, ViewComponentProps, - ViewDefinition, } from '../builtins/types'; import { defineTransformView, normalizeViewArgs, ViewArgs, } from '../builtins/view'; -import { Wrapper } from '../wrappers'; -import { isKeyedDisplayArgs, normalizeDisplayArgs } from '../builtins/display'; -import { tail } from '../utils/tail'; +import tunnel from '../utils/tunnel'; +import { applySimpleWrapper, SimpleWrapper, Wrapper } from '../wrappers'; -type Tunnel = ReturnType; +export type Tunnel = ReturnType; -type Repeat< +export type Repeat< T, C extends number, Result extends T[] = [], @@ -31,22 +29,38 @@ export type LayoutSlot = Pick< 'display' | 'view' >; -function createLayoutSlot( +// during initialization we create empty layout slots, these are only temporary +// until the state gets set up +export function createEmptyLayoutSlot< + T extends StateBase = StateBase +>(): LayoutSlot { + return { + display: () => <>, + view(...args: ViewArgs) { + return undefined as R; + }, + }; +} + +export function createLayoutSlot( ctx: Pick, 'display' | 'view'>, t: ReturnType ): LayoutSlot { + const TunnelWrapper: Wrapper = ({ stateKey, children }) => { + return {children}; + }; return { display(...args) { const { manualKey, wrappers, node } = normalizeDisplayArgs(args); if (manualKey) { - ctx.display(manualKey, t.In as Wrapper, 'default', ...wrappers, node); + ctx.display(manualKey, TunnelWrapper, ...wrappers, node); } else { - ctx.display(t.In as Wrapper, 'default', ...wrappers, node); + ctx.display(TunnelWrapper, ...wrappers, node); } }, view(...args: ViewArgs) { const { key, wrappers, def } = normalizeViewArgs(args); - return ctx.view(key, t.In as Wrapper, 'default', ...wrappers, def); + return ctx.view(key, TunnelWrapper, ...wrappers, def); }, }; } @@ -55,13 +69,10 @@ export function LayoutViewComponent({ slots, value, setValue, - slotProps, + slotWrapper, }: { slots: N; - slotProps: React.DetailedHTMLProps< - React.HTMLAttributes, - HTMLDivElement - >; + slotWrapper?: SimpleWrapper; } & ViewComponentProps>) { const tunnels = useRef([]); useEffect(() => { @@ -76,30 +87,45 @@ export function LayoutViewComponent({ {(value as Tunnel[]) .map((t) => t.Out) .map((Slot, index) => ( -
- -
+ + {applySimpleWrapper(, slotWrapper)} + ))} ); } +export type LayoutViewType = Repeat | undefined; + +export function defaultLayoutState( + slots: N +): LayoutViewType { + return undefined; +} + export function LayoutView( slots: N, - slotProps?: React.DetailedHTMLProps< - React.HTMLAttributes, - HTMLDivElement - > + slotWrapper?: SimpleWrapper ) { return defineTransformView< Repeat | undefined, Repeat, N> >( (viewProps) => ( - + ), ({ value, display, view }) => { const tunnels = (value ?? []) as Tunnel[]; + if (tunnels.length !== slots) { + return Array.from({ length: slots }, createEmptyLayoutSlot) as Repeat< + LayoutSlot, + N + >; + } const subContext = tunnels.map((t) => createLayoutSlot({ display, view }, t) ); diff --git a/libs/core/src/reactlit.tsx b/libs/core/src/reactlit.tsx index d79202c..b09209e 100644 --- a/libs/core/src/reactlit.tsx +++ b/libs/core/src/reactlit.tsx @@ -14,7 +14,6 @@ import { useReactlitSet } from './builtins/set'; import { ReactlitProps, StateBase } from './builtins/types'; import { useReactlitView } from './builtins/view'; import { useReactlitState } from './hooks/use-reactlit-state'; -import { Wrapper } from './wrappers'; const defaultRenderError = ({ error }) => (
@@ -22,8 +21,6 @@ const defaultRenderError = ({ error }) => (
); -const DefaultWrapper: Wrapper = ({ children }) => children; - export function Reactlit({ state: rawState, setState, @@ -31,7 +28,6 @@ export function Reactlit({ renderError = defaultRenderError, debug, children, - wrapper = DefaultWrapper, }: ReactlitProps) { const [defaultRawState, defaultSetState] = useReactlitState({} as T); rawState = rawState ?? defaultRawState; @@ -42,7 +38,7 @@ export function Reactlit({ const set = useReactlitSet(internalState); const changed = useReactlitChanged(internalState, debug); const { renderState, resetRenderPosition, finalizeRender, display } = - useReactlitDisplay({ renderError, wrapper }); + useReactlitDisplay({ renderError }); const view = useReactlitView({ set, display, state }); const [triggerCounter, setTriggerCounter] = useState(0); diff --git a/libs/core/src/utils/tunnel.tsx b/libs/core/src/utils/tunnel.tsx new file mode 100644 index 0000000..3be4fda --- /dev/null +++ b/libs/core/src/utils/tunnel.tsx @@ -0,0 +1,75 @@ +import React, { Fragment } from 'react'; +import { create, StoreApi } from 'zustand'; +import { useIsomorphicLayoutEffect } from './use-isomorphic-layout-effect'; + +// modified from tunnel-rat + +type Props = { childKey: string; children: React.ReactNode }; + +type State = { + current: Array<{ childKey: string; node: React.ReactNode }>; + version: number; + set: StoreApi['setState']; +}; + +export default function tunnel() { + const useStore = create((set) => ({ + current: new Array<{ childKey: string; node: React.ReactNode }>(), + version: 0, + set, + })); + + return { + In: ({ childKey, children }: Props) => { + const set = useStore((state) => state.set); + const version = useStore((state) => state.version); + + /* When this component mounts, we increase the store's version number. + This will cause all existing rats to re-render (just like if the Out component + were mapping items to a list.) The re-rendering will cause the final + order of rendered components to match what the user is expecting. */ + useIsomorphicLayoutEffect(() => { + set((state) => ({ + version: state.version + 1, + })); + }, []); + + /* Any time the children _or_ the store's version number change, insert + the specified React children into the list of rats. */ + useIsomorphicLayoutEffect(() => { + set(({ current }) => { + const existing = current.findIndex((c) => c.childKey === childKey); + return { + current: + existing !== -1 + ? [ + ...current.slice(0, existing), + { childKey, node: children }, + ...current.slice(existing + 1), + ] + : [...current, { childKey, node: children }], + }; + }); + + // remove the cleanup logic so that nodes stay in position, the key logic keeps things from getting too messy + // return () => + // set(({ current }) => ({ + // current: current.filter((c) => c.node !== children), + // })); + }, [children, version]); + + return null; + }, + + Out: () => { + const current = useStore((state) => state.current); + return ( + <> + {current.map((c) => ( + {c.node} + ))} + + ); + }, + }; +} diff --git a/libs/core/src/utils/use-isomorphic-layout-effect.tsx b/libs/core/src/utils/use-isomorphic-layout-effect.tsx new file mode 100644 index 0000000..00fb045 --- /dev/null +++ b/libs/core/src/utils/use-isomorphic-layout-effect.tsx @@ -0,0 +1,25 @@ +import React from 'react'; + +// taken from tunnel-rat + +/** + * An SSR-friendly useLayoutEffect. + * + * React currently throws a warning when using useLayoutEffect on the server. + * To get around it, we can conditionally useEffect on the server (no-op) and + * useLayoutEffect elsewhere. + * + * @see https://github.com/facebook/react/issues/14927 + */ +export const useIsomorphicLayoutEffect = + typeof window !== 'undefined' && + (window.document?.createElement || + window.navigator?.product === 'ReactNative') + ? React.useLayoutEffect + : React.useEffect; + +export function useMutableCallback(fn: T) { + const ref = React.useRef(fn); + useIsomorphicLayoutEffect(() => void (ref.current = fn), [fn]); + return ref; +} diff --git a/libs/core/src/wrappers.tsx b/libs/core/src/wrappers.tsx index d026f7c..7eff308 100644 --- a/libs/core/src/wrappers.tsx +++ b/libs/core/src/wrappers.tsx @@ -1,10 +1,11 @@ import { cloneElement, + createElement, Fragment, isValidElement, PropsWithChildren, + ReactElement, ReactNode, - useMemo, } from 'react'; export interface ReactlitWrapperProps { @@ -12,43 +13,53 @@ export interface ReactlitWrapperProps { stateKey: string; } -export type ReactlitWrapperComponent = React.FC< +export type WrapperComponent = React.FC< PropsWithChildren >; -export type Wrapper = ReactlitWrapperComponent | React.ReactElement; +export type Wrapper = WrapperComponent | React.ReactElement; -function applyWrapper( - node: ReactNode, +export type SimpleWrapperComponent = React.FC; + +export type SimpleWrapper = SimpleWrapperComponent | React.ReactElement; + +export function applySimpleWrapper( + children: ReactElement, + Wrap?: SimpleWrapper +): ReactNode { + if (!Wrap) return children; + if (isValidElement(Wrap)) return cloneElement(Wrap, {}, children); + return Wrap({ children }); +} + +export function applyWrapper( + children: ReactElement, Wrap?: Wrapper, props?: ReactlitWrapperProps -) { - if (!Wrap) return node; - if (isValidElement(Wrap)) return cloneElement(Wrap, {}, node); - return Wrap({ children: node, ...props }) as JSX.Element; +): ReactElement { + if (!Wrap) return children; + if (isValidElement(Wrap)) return cloneElement(Wrap, {}, children); + return Wrap({ children, ...props }) as ReactElement; + // return createElement(Wrap, props, children); } export function ApplyWrappers({ wrappers, - defaultWrapper, children, - props, + ...props }: { - wrappers: (Wrapper | 'default')[]; - defaultWrapper: Wrapper; + wrappers: Wrapper[]; children: ReactNode; - props: ReactlitWrapperProps; -}) { - const wrappedContent = useMemo(() => { - const base = wrappers.includes('default') - ? [...wrappers] - : [defaultWrapper, ...wrappers]; - return base.reverse().reduce( - (acc, W) => - applyWrapper(acc, W === 'default' ? defaultWrapper : W, props), - // this extra Fragment wrapper at the end is necessary for some - // very mysterious reason to keep plain string nodes from shifting positions around - {children} - ); - }, [children, wrappers, defaultWrapper, props]); +} & ReactlitWrapperProps) { + const base = [...wrappers]; + const wrappedContent = base.reverse().reduce( + (acc, W) => applyWrapper(acc, W, props), + // this extra Fragment wrapper at the end is necessary for some + // very mysterious reason to keep plain string nodes from shifting positions around + {children} + ); return wrappedContent; } + +export const FragmentWrapper: Wrapper = ({ children, stateKey }) => { + return {children}; +}; diff --git a/libs/radix/src/index.ts b/libs/radix/src/index.ts index 3058839..ff151d2 100644 --- a/libs/radix/src/index.ts +++ b/libs/radix/src/index.ts @@ -10,3 +10,4 @@ export * from './inputs/text.input'; export * from './inputs/textarea.input'; export * from './inputs/switch.input'; export * from './radix-wrapper'; +export * from './label'; diff --git a/libs/radix/src/inputs.ts b/libs/radix/src/inputs.ts index dcea6dc..040790f 100644 --- a/libs/radix/src/inputs.ts +++ b/libs/radix/src/inputs.ts @@ -1,4 +1,3 @@ -import { FormView } from '@reactlit/core'; import { AsyncButton } from './inputs/async-button.input'; import { CheckInput } from './inputs/check.input'; import { RadioInput } from './inputs/radio.input'; @@ -17,7 +16,6 @@ export const Inputs = { Switch: SwitchInput, Radio: RadioInput, Select: SelectInput, - Form: FormView, Slider: SliderInput, RangeSlider: RangeSliderInput, AsyncButton: AsyncButton, diff --git a/libs/radix/src/inputs/async-button.input.tsx b/libs/radix/src/inputs/async-button.input.tsx index 88d885a..0df7c72 100644 --- a/libs/radix/src/inputs/async-button.input.tsx +++ b/libs/radix/src/inputs/async-button.input.tsx @@ -16,6 +16,8 @@ export const AsyncButtonViewComponent = ({ setValue, value, stateKey, + display, + view, ...props }: AsyncButtonInputProps & ViewComponentProps) => { return ( diff --git a/libs/radix/src/inputs/check.input.tsx b/libs/radix/src/inputs/check.input.tsx index c45bbc6..ca03fbf 100644 --- a/libs/radix/src/inputs/check.input.tsx +++ b/libs/radix/src/inputs/check.input.tsx @@ -1,7 +1,7 @@ import { CheckboxGroup } from '@radix-ui/themes'; import { defineView, ViewComponentProps } from '@reactlit/core'; import { useMemo } from 'react'; -import { LabelType, renderLabel } from '../label'; +import { LabelType } from '../label'; export type CheckOptionsType = T[] | Record; @@ -19,6 +19,8 @@ export const CheckInputComponent = ({ setValue, label, options, + display, + view, ...props }: BaseCheckInputProps & ViewComponentProps) => { const optionsEntries = useMemo(() => { @@ -28,20 +30,13 @@ export const CheckInputComponent = ({ return Object.entries(options) as [string, T][]; }, [options]); return ( - <> - {renderLabel(label)} - - {optionsEntries.map(([label, value], i) => ( - - {label} - - ))} - - + + {optionsEntries.map(([label, value], i) => ( + + {label} + + ))} + ); }; diff --git a/libs/radix/src/inputs/radio.input.tsx b/libs/radix/src/inputs/radio.input.tsx index 7d3c1ec..171a2dc 100644 --- a/libs/radix/src/inputs/radio.input.tsx +++ b/libs/radix/src/inputs/radio.input.tsx @@ -1,7 +1,7 @@ import { RadioGroup } from '@radix-ui/themes'; import { defineView, ViewComponentProps } from '@reactlit/core'; import { useMemo } from 'react'; -import { LabelType, renderLabel } from '../label'; +import { LabelType } from '../label'; export type RadioOptionsType = T[] | Record; @@ -23,6 +23,8 @@ export const RadioInputComponent = ({ setValue, label, options, + display, + view, ...props }: BaseRadioInputProps & ViewComponentProps) => { const optionsEntries = useMemo(() => { @@ -32,16 +34,13 @@ export const RadioInputComponent = ({ return Object.entries(options) as [string, T][]; }, [options]); return ( - <> - {renderLabel(label)} - - {optionsEntries.map(([label, value], i) => ( - - {label} - - ))} - - + + {optionsEntries.map(([label, value], i) => ( + + {label} + + ))} + ); }; diff --git a/libs/radix/src/inputs/select.input.tsx b/libs/radix/src/inputs/select.input.tsx index 5ffa192..fc96ba9 100644 --- a/libs/radix/src/inputs/select.input.tsx +++ b/libs/radix/src/inputs/select.input.tsx @@ -1,7 +1,7 @@ import { Select } from '@radix-ui/themes'; import { defineView, ViewComponentProps } from '@reactlit/core'; import { useMemo } from 'react'; -import { LabelType, renderLabel } from '../label'; +import { LabelType } from '../label'; export type SelectOptionsType = T[] | Record; @@ -20,6 +20,8 @@ export const SelectInputComponent = ({ setValue, label, options, + display, + view, ...props }: BaseSelectInputProps & ViewComponentProps) => { const optionsEntries = useMemo(() => { @@ -29,19 +31,16 @@ export const SelectInputComponent = ({ return Object.entries(options) as [string, T][]; }, [options]); return ( - <> - {renderLabel(label)} - - - - {optionsEntries.map(([label, value], i) => ( - - {label} - - ))} - - - + + + + {optionsEntries.map(([label, value], i) => ( + + {label} + + ))} + + ); }; diff --git a/libs/radix/src/inputs/slider.input.tsx b/libs/radix/src/inputs/slider.input.tsx index 2eacfcd..47c9ab3 100644 --- a/libs/radix/src/inputs/slider.input.tsx +++ b/libs/radix/src/inputs/slider.input.tsx @@ -1,6 +1,6 @@ import { Slider, SliderProps } from '@radix-ui/themes'; import { defineView, ViewComponentProps } from '@reactlit/core'; -import { LabelType, renderLabel } from '../label'; +import { LabelType } from '../label'; export type SliderInputProps = Omit & { label?: LabelType; @@ -12,19 +12,18 @@ export const SliderInputComponent = ({ setValue, onChange, label, + display, + view, ...props }: SliderInputProps & ViewComponentProps) => { return ( - <> - {renderLabel(label)} - { - setValue(v[0]); - }} - {...props} - /> - + { + setValue(v[0]); + }} + {...props} + /> ); }; @@ -34,19 +33,18 @@ export const RangeSliderInputComponent = ({ setValue, onChange, label, + display, + view, ...props }: SliderInputProps & ViewComponentProps<[number, number]>) => { return ( - <> - {renderLabel(label)} - { - setValue(v as [number, number]); - }} - {...props} - /> - + { + setValue(v as [number, number]); + }} + {...props} + /> ); }; diff --git a/libs/radix/src/inputs/switch.input.tsx b/libs/radix/src/inputs/switch.input.tsx index e19b036..b692135 100644 --- a/libs/radix/src/inputs/switch.input.tsx +++ b/libs/radix/src/inputs/switch.input.tsx @@ -1,6 +1,6 @@ import { Switch, SwitchProps } from '@radix-ui/themes'; import { defineView, ViewComponentProps } from '@reactlit/core'; -import { LabelType, renderLabel } from '../label'; +import { LabelType } from '../label'; export type SwitchInputProps = Omit< SwitchProps, @@ -14,14 +14,11 @@ export const SwitchInputComponent = ({ stateKey, setValue, label, + display, + view, ...props }: SwitchInputProps & ViewComponentProps) => { - return ( - <> - {renderLabel(label)} - - - ); + return ; }; export const SwitchInput = (props?: SwitchInputProps) => diff --git a/libs/radix/src/inputs/table.input.tsx b/libs/radix/src/inputs/table.input.tsx index 1f5bcae..799c8b2 100644 --- a/libs/radix/src/inputs/table.input.tsx +++ b/libs/radix/src/inputs/table.input.tsx @@ -45,6 +45,8 @@ export function TableInputViewComponent({ getRowId, maxHeight = '300px', loading, + display, + view, ...props }: TableInputProps & ViewComponentProps) { const colDefs = useMemo[]>(() => { diff --git a/libs/radix/src/inputs/text.input.tsx b/libs/radix/src/inputs/text.input.tsx index 606993a..f431a68 100644 --- a/libs/radix/src/inputs/text.input.tsx +++ b/libs/radix/src/inputs/text.input.tsx @@ -1,8 +1,8 @@ -import { Text, TextField } from '@radix-ui/themes'; +import { TextField } from '@radix-ui/themes'; import { defineView, ViewComponentProps } from '@reactlit/core'; -import { isValidElement, ReactNode, useEffect, useRef, useState } from 'react'; +import { isValidElement, ReactNode, useEffect, useState } from 'react'; import { useDebouncedCallback } from 'use-debounce'; -import { LabelType, renderLabel } from '../label'; +import { LabelType } from '../label'; export type TextInputProps = Omit & { label?: LabelType; @@ -20,6 +20,8 @@ export const TextInputComponent = ({ label, debounceDelay = 200, children, + display, + view, ...props }: TextInputProps & ViewComponentProps) => { const [rawValue, setRawValue] = useState(value ?? ''); @@ -30,28 +32,25 @@ export const TextInputComponent = ({ setRawValue(value ?? ''); }, [value]); return ( - - {renderLabel(label)} - { - setRawValue(e.target.value); - debouncedSetValue(e.target.value); - onChange?.(e); - }} - {...props} - > - {isValidElement(children) - ? (children as ReactNode) - : typeof children === 'function' - ? children({ value, stateKey, setValue }) - : undefined} - - + { + setRawValue(e.target.value); + debouncedSetValue(e.target.value); + onChange?.(e); + }} + {...props} + > + {isValidElement(children) + ? (children as ReactNode) + : typeof children === 'function' + ? children({ value, display, view, stateKey, setValue }) + : undefined} + ); }; -export const TextInput = (props: TextInputProps) => +export const TextInput = (props?: TextInputProps) => defineView((viewProps) => ( )); diff --git a/libs/radix/src/inputs/textarea.input.tsx b/libs/radix/src/inputs/textarea.input.tsx index c0c3e62..e5668f6 100644 --- a/libs/radix/src/inputs/textarea.input.tsx +++ b/libs/radix/src/inputs/textarea.input.tsx @@ -1,8 +1,8 @@ -import { Text, TextArea, TextAreaProps } from '@radix-ui/themes'; +import { TextArea, TextAreaProps } from '@radix-ui/themes'; import { defineView, ViewComponentProps } from '@reactlit/core'; -import { useDebouncedCallback } from 'use-debounce'; -import { LabelType, renderLabel } from '../label'; import { useEffect, useState } from 'react'; +import { useDebouncedCallback } from 'use-debounce'; +import { LabelType } from '../label'; export type TextAreaInputProps = Omit & { label?: LabelType; @@ -15,6 +15,8 @@ export const TextAreaInputComponent = ({ setValue, onChange, label, + display, + view, debounceDelay = 300, ...props }: TextAreaInputProps & ViewComponentProps) => { @@ -26,18 +28,15 @@ export const TextAreaInputComponent = ({ setRawValue(value ?? ''); }, [value]); return ( - - {renderLabel(label)} -