diff --git a/.changeset/witty-tips-admire.md b/.changeset/witty-tips-admire.md new file mode 100644 index 0000000..2e29ed9 --- /dev/null +++ b/.changeset/witty-tips-admire.md @@ -0,0 +1,7 @@ +--- +'@reactlit/vanilla': minor +'@reactlit/radix': minor +'@reactlit/core': minor +--- + +Make layout plugin into a view, wrapper functionality 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 87f1168..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 { FormInput, 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', - FormInput({ - 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 fe8d78b..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 { FormInput, 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 FormInput allows you to group inputs together - const updates = app.view( - 'updates', - FormInput({ - name: Inputs.Text({ label: 'Name' }), - email: Inputs.Text({ label: 'Email' }), - }) - ); + // the built-in FormView allows you to group inputs together + 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..bcd6cb0 100644 --- a/apps/reactlit-examples/src/components/main.tsx +++ b/apps/reactlit-examples/src/components/main.tsx @@ -1,6 +1,8 @@ import { Box, Container, Flex, Text } from '@radix-ui/themes'; import { Geist, Geist_Mono } from 'next/font/google'; import Head from 'next/head'; +import React from 'react'; +import { DebugToggle } from './debug-toggle'; import { Menu } from './menu'; import { ThemeToggle } from './theme-toggle'; @@ -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 9f0d928..180bcfb 100644 --- a/apps/reactlit-examples/src/pages/hello-world-vanilla/index.tsx +++ b/apps/reactlit-examples/src/pages/hello-world-vanilla/index.tsx @@ -1,5 +1,7 @@ import { Reactlit, useReactlitState } from '@reactlit/core'; -import { Inputs } from '@reactlit/vanilla'; +import { Inputs, Label } from '@reactlit/vanilla'; + +const LabelProps = { className: 'flex items-center gap-2 mb-2' }; export default function HelloWorldVanilla() { const [appState, setAppState] = useReactlitState({ @@ -9,58 +11,59 @@ export default function HelloWorldVanilla() { chosenNumber: '', chosenColor: '', }); + return ( {async ({ display, view }) => { - display(
Hello World Vanilla
); + display(
Hello World Vanilla
); + const name = view( 'name', + Label('Name', LabelProps), Inputs.Text({ - id: 'name-input', + id: 'name', className: 'border p-0.5', - label: 'Name', placeholder: 'Enter your name', }) ); + display(
Hello {name}!
); + const picked = view( 'pickedNumbers', + Label('Pick any number', LabelProps), 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( 'pickedColors', + Label('Pick any color', LabelProps), Inputs.Check( [ { label: 'Red', value: '#FF0000' }, { label: 'Green', value: '#00FF00' }, { label: 'Blue', value: '#0000FF' }, - { label: 'White', value: '#FFFFFF' }, ], { 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( 'chosenNumber', + Label('Choose a number', LabelProps), 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( 'chosenColor', + Label('Choose a color', LabelProps), Inputs.Radio( [ { label: 'Red', value: '#FF0000' }, { label: 'Green', value: '#00FF00' }, { label: 'Blue', value: '#0000FF' }, - { label: 'White', value: '#FFFFFF' }, ], { 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) => ( + + {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..7a37cbc 100644 --- a/apps/reactlit-examples/src/pages/radix-inputs/index.tsx +++ b/apps/reactlit-examples/src/pages/radix-inputs/index.tsx @@ -1,6 +1,20 @@ -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, + WrapperComponent, +} from '@reactlit/core'; +import { DefaultRadixWrapper, Inputs, Label } from '@reactlit/radix'; +import { useState } from 'react'; interface Country { name: string; @@ -12,7 +26,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 +42,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) => { + const DisplayLabelComponent: WrapperComponent = ({ children }) => ( + + {label} + {children} + + ); + return DisplayLabelComponent; +}; + +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 7ca5d9b..67fcf6f 100644 --- a/apps/reactlit-examples/src/pages/starter/index.tsx +++ b/apps/reactlit-examples/src/pages/starter/index.tsx @@ -1,12 +1,12 @@ -import { Box, Text } from '@radix-ui/themes'; +import { Debug, useDebug } from '@/components/debug-toggle'; +import { Text } from '@radix-ui/themes'; import { textPropDefs } from '@radix-ui/themes/props'; import { DataFetchingPlugin, - LayoutPlugin, useReactlit, useReactlitState, } from '@reactlit/core'; -import { Inputs, DefaultRadixWrapper } from '@reactlit/radix'; +import { DefaultRadixWrapper, Inputs, Label } from '@reactlit/radix'; export default function Starter() { const [appState, setAppState] = useReactlitState({ @@ -14,46 +14,40 @@ export default function Starter() { weight: 'regular', size: 1, }); - const Reactlit = useReactlit( - LayoutPlugin, - DataFetchingPlugin - ); + const Reactlit = useReactlit(DataFetchingPlugin); + const debug = useDebug(); return ( - - {async ({ display, view }) => { - 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', + Debug, + Label('What is your name?'), + Inputs.Text({ + placeholder: 'Enter name', + }) + ); + const weight = view( + 'weight', + Debug, + Label('Weight'), + Inputs.Radio(['light', 'regular', 'medium', 'bold'] as const) + ); + const size = view( + 'size', + Debug, + Label('Size'), + Inputs.Slider({ + min: 1, + max: 9, + }) + ); - display( - -
-
- ); - display( - + display(
); + display( + Debug, Enter Name} from Reactlit! -
- ); - }} -
+ ); + }} +
+ ); } diff --git a/apps/reactlit-examples/src/pages/todo-list/index.tsx b/apps/reactlit-examples/src/pages/todo-list/index.tsx index 89c3d10..0744108 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,108 +14,94 @@ 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 }) => { - 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 [c1, c2] = layout(TwoColumnLayout); - const task = c1.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 = c2.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/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..df7a660 --- /dev/null +++ b/libs/core/src/builtins/display.tsx @@ -0,0 +1,119 @@ +import { ReactNode, useCallback, useState } from 'react'; +import { ErrorBoundary as ReactErrorBoundary } from 'react-error-boundary'; +import { tail } from '../utils/tail'; +import { ApplyWrappers, Wrapper } from '../wrappers'; +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; + +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, +}: Pick, 'renderError'>) { + const [renderState, setRenderState] = useState({ + position: 0, + elements: [], + }); + + const display = useCallback['display']>( + (...args: DisplayArgs) => { + 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} + + + ); + 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] + ); + + 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..e7dca15 --- /dev/null +++ b/libs/core/src/builtins/types.ts @@ -0,0 +1,65 @@ +import { Dispatch, ReactNode, SetStateAction } from 'react'; +import { DisplayArgs } from './display'; +import { ViewArgs } from './view'; + +export type StateBase = Record; + +export interface ViewComponentProps { + stateKey: string; + value: T; + setValue: Dispatch; + display: (...args: DisplayArgs) => void; + view: ( + ...args: ViewArgs + ) => R; +} + +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; + /** + * 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..7c0259e --- /dev/null +++ b/libs/core/src/builtins/view.ts @@ -0,0 +1,74 @@ +import { useCallback } from 'react'; +import { + ReactlitContext, + StateBase, + ViewComponent, + ViewComponentProps, + ViewDefinition, +} from './types'; +import { Wrapper } from '../wrappers'; +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 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) => + makeViewFunction({ set, display, state })(...args), + [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..76fd3bb 100644 --- a/libs/core/src/index.ts +++ b/libs/core/src/index.ts @@ -1,10 +1,11 @@ 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 './wrappers'; +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.input.tsx deleted file mode 100644 index be3e9c1..0000000 --- a/libs/core/src/inputs/form.input.tsx +++ /dev/null @@ -1,50 +0,0 @@ -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'; - -export type FormDefMap = { - [K in keyof T]: ViewDefinition; -}; - -export interface FormInputProps { - form: FormDefMap; - wrapper?: Wrapper; -} - -export function FormInputViewComponent({ - form, - value, - stateKey, - setValue, - wrapper, -}: FormInputProps & 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, - }), - }; - views.push({def.component(props)}); - } - return views; - }, [form, value, setValue, stateKey]); - return applyWrapper(<>{views}, wrapper); -} - -export function FormInput( - form: FormDefMap, - 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..80888e2 --- /dev/null +++ b/libs/core/src/inputs/layout.view.tsx @@ -0,0 +1,135 @@ +import { Fragment, useEffect, useRef } from 'react'; +import { normalizeDisplayArgs } from '../builtins/display'; +import { + ReactlitContext, + StateBase, + ViewComponentProps, +} from '../builtins/types'; +import { + defineTransformView, + normalizeViewArgs, + ViewArgs, +} from '../builtins/view'; +import tunnel from '../utils/tunnel'; +import { applySimpleWrapper, SimpleWrapper, Wrapper } from '../wrappers'; + +export type Tunnel = ReturnType; + +export 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' +>; + +// 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, TunnelWrapper, ...wrappers, node); + } else { + ctx.display(TunnelWrapper, ...wrappers, node); + } + }, + view(...args: ViewArgs) { + const { key, wrappers, def } = normalizeViewArgs(args); + return ctx.view(key, TunnelWrapper, ...wrappers, def); + }, + }; +} + +export function LayoutViewComponent({ + slots, + value, + setValue, + slotWrapper, +}: { + slots: N; + slotWrapper?: SimpleWrapper; +} & 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) => ( + + {applySimpleWrapper(, slotWrapper)} + + ))} + + ); +} + +export type LayoutViewType = Repeat | undefined; + +export function defaultLayoutState( + slots: N +): LayoutViewType { + return undefined; +} + +export function LayoutView( + slots: N, + 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) + ); + return subContext as Repeat, N>; + } + ); +} 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..b09209e 100644 --- a/libs/core/src/reactlit.tsx +++ b/libs/core/src/reactlit.tsx @@ -1,114 +1,19 @@ 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'; const defaultRenderError = ({ error }) => (
@@ -123,125 +28,18 @@ export function Reactlit({ renderError = defaultRenderError, debug, children, - wrapper, }: 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 internalState = useInternalReactlitState(rawState, setState); - 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]; - - 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 }); + const view = useReactlitView({ set, display, state }); const [triggerCounter, setTriggerCounter] = useState(0); const trigger = useCallback(() => { @@ -268,7 +66,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 +77,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 +95,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 deleted file mode 100644 index 0ae99c0..0000000 --- a/libs/core/src/utils/apply-wrapper.tsx +++ /dev/null @@ -1,10 +0,0 @@ -export type Wrapper = (props: { children: React.ReactNode }) => React.ReactNode; - -export function applyWrapper(node: React.ReactNode, Wrapper?: Wrapper) { - return Wrapper ? {node} : node; -} - -export function combineWrappers(...wrappers: Wrapper[]): Wrapper { - return ({ children }) => - wrappers.reduce((acc, Wrapper) => {acc}, children); -} 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/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 new file mode 100644 index 0000000..7eff308 --- /dev/null +++ b/libs/core/src/wrappers.tsx @@ -0,0 +1,65 @@ +import { + cloneElement, + createElement, + Fragment, + isValidElement, + PropsWithChildren, + ReactElement, + ReactNode, +} from 'react'; + +export interface ReactlitWrapperProps { + position: number; + stateKey: string; +} + +export type WrapperComponent = React.FC< + PropsWithChildren +>; + +export type Wrapper = WrapperComponent | React.ReactElement; + +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 +): 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, + children, + ...props +}: { + wrappers: Wrapper[]; + children: ReactNode; +} & 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 ef66426..040790f 100644 --- a/libs/radix/src/inputs.ts +++ b/libs/radix/src/inputs.ts @@ -1,4 +1,3 @@ -import { FormInput } 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: FormInput, 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 29160e2..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, 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)} -