diff --git a/.storybook/images/logo-remix.svg b/.storybook/images/logo-remix.svg new file mode 100644 index 00000000000..ad6b857518d --- /dev/null +++ b/.storybook/images/logo-remix.svg @@ -0,0 +1,45 @@ + + Remix Logo + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/ProjectTemplates.mdx b/docs/ProjectTemplates.mdx index ac24d57bf8f..5a094643789 100644 --- a/docs/ProjectTemplates.mdx +++ b/docs/ProjectTemplates.mdx @@ -3,6 +3,7 @@ import { Meta } from '@storybook/blocks'; import { FlexBox, FlexBoxJustifyContent, FlexBoxWrap, Label, Link, WrappingType } from '@ui5/webcomponents-react'; import NextLogo from '@sb/images/logo-nextjs.svg'; import ViteLogo from '@sb/images/logo-vitejs.svg'; +import RemixLogo from '@sb/images/logo-remix.svg'; @@ -108,6 +109,24 @@ A curated list of minimal project templates and examples to get started using UI + + + ## Community Templates & Examples diff --git a/examples/remix-ts/.eslintrc.cjs b/examples/remix-ts/.eslintrc.cjs new file mode 100644 index 00000000000..4f6f59eee1e --- /dev/null +++ b/examples/remix-ts/.eslintrc.cjs @@ -0,0 +1,84 @@ +/** + * This is intended to be a basic starting point for linting in your app. + * It relies on recommended configs out of the box for simplicity, but you can + * and should modify this configuration to best suit your team's needs. + */ + +/** @type {import('eslint').Linter.Config} */ +module.exports = { + root: true, + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + ecmaFeatures: { + jsx: true, + }, + }, + env: { + browser: true, + commonjs: true, + es6: true, + }, + ignorePatterns: ["!**/.server", "!**/.client"], + + // Base config + extends: ["eslint:recommended"], + + overrides: [ + // React + { + files: ["**/*.{js,jsx,ts,tsx}"], + plugins: ["react", "jsx-a11y"], + extends: [ + "plugin:react/recommended", + "plugin:react/jsx-runtime", + "plugin:react-hooks/recommended", + "plugin:jsx-a11y/recommended", + ], + settings: { + react: { + version: "detect", + }, + formComponents: ["Form"], + linkComponents: [ + { name: "Link", linkAttribute: "to" }, + { name: "NavLink", linkAttribute: "to" }, + ], + "import/resolver": { + typescript: {}, + }, + }, + }, + + // Typescript + { + files: ["**/*.{ts,tsx}"], + plugins: ["@typescript-eslint", "import"], + parser: "@typescript-eslint/parser", + settings: { + "import/internal-regex": "^~/", + "import/resolver": { + node: { + extensions: [".ts", ".tsx"], + }, + typescript: { + alwaysTryTypes: true, + }, + }, + }, + extends: [ + "plugin:@typescript-eslint/recommended", + "plugin:import/recommended", + "plugin:import/typescript", + ], + }, + + // Node + { + files: [".eslintrc.cjs"], + env: { + node: true, + }, + }, + ], +}; diff --git a/examples/remix-ts/.gitignore b/examples/remix-ts/.gitignore new file mode 100644 index 00000000000..80ec311f4ff --- /dev/null +++ b/examples/remix-ts/.gitignore @@ -0,0 +1,5 @@ +node_modules + +/.cache +/build +.env diff --git a/examples/remix-ts/README.md b/examples/remix-ts/README.md new file mode 100644 index 00000000000..14d5cf4d3fd --- /dev/null +++ b/examples/remix-ts/README.md @@ -0,0 +1,53 @@ +# UI5 Web Components React - Remix Example + +This example shows how to use the [Remix](https://remix.run) with UI5 Web Components for React. + +## How to use this template + +```bash +npx degit SAP/ui5-webcomponents-react/examples/remix-ts#main my-project +cd my-project +``` + +## Getting Started + +First, install the node_modules: + +```bash +npm install +``` + +Then, run the development server: + +```bash +npm run dev +``` + +## Deployment + +First, build your app for production: + +```sh +npm run build +``` + +Then run the app in production mode: + +```sh +npm start +``` + +Now you'll need to pick a host to deploy it to. + +### DIY + +If you're familiar with deploying Node applications, the built-in Remix app server is production-ready. + +Make sure to deploy the output of `npm run build` + +- `build/server` +- `build/client` + +## Learn More + +- 📖 See the [Remix docs](https://remix.run/docs) and the [Remix Vite docs](https://remix.run/docs/en/main/guides/vite) for details on supported features. diff --git a/examples/remix-ts/app/components/AppShell.tsx b/examples/remix-ts/app/components/AppShell.tsx new file mode 100644 index 00000000000..4c408e3d84f --- /dev/null +++ b/examples/remix-ts/app/components/AppShell.tsx @@ -0,0 +1,17 @@ +import '@ui5/webcomponents-react/dist/Assets.js'; +import { ThemeProvider } from '@ui5/webcomponents-react'; +import { ReactNode } from 'react'; +import { AppShellBar } from './AppShellBar'; + +interface AppShellProps { + children?: ReactNode | ReactNode[]; +} + +export function AppShell({ children }: AppShellProps) { + return ( + + +
{children}
+
+ ); +} diff --git a/examples/remix-ts/app/components/AppShellBar.module.css b/examples/remix-ts/app/components/AppShellBar.module.css new file mode 100644 index 00000000000..1b1cf9bfa6b --- /dev/null +++ b/examples/remix-ts/app/components/AppShellBar.module.css @@ -0,0 +1,5 @@ +.popover { +} +.popover::part(content) { + padding: 0; +} diff --git a/examples/remix-ts/app/components/AppShellBar.tsx b/examples/remix-ts/app/components/AppShellBar.tsx new file mode 100644 index 00000000000..4e3f83c79c9 --- /dev/null +++ b/examples/remix-ts/app/components/AppShellBar.tsx @@ -0,0 +1,71 @@ +import navBackIcon from '@ui5/webcomponents-icons/dist/nav-back.js'; +import paletteIcon from '@ui5/webcomponents-icons/dist/palette.js'; +import { + Button, + List, + ListMode, + ListPropTypes, + ResponsivePopover, + ResponsivePopoverDomRef, + ShellBar, + ShellBarItem, + ShellBarItemPropTypes, + StandardListItem +} from '@ui5/webcomponents-react'; +import { useRef, useState } from 'react'; +import classes from './AppShellBar.module.css'; +import { getTheme, setTheme } from '@ui5/webcomponents-base/dist/config/Theme.js'; +import { useLocation, useNavigate } from '@remix-run/react'; + +const THEMES = [ + { key: 'sap_horizon', value: 'Morning Horizon (Light)' }, + { key: 'sap_horizon_dark', value: 'Evening Horizon (Dark)' }, + { key: 'sap_horizon_hcb', value: 'Horizon High Contrast Black' }, + { key: 'sap_horizon_hcw', value: 'Horizon High Contrast White' } +]; + +export function AppShellBar() { + const navigate = useNavigate(); + const { pathname } = useLocation(); + const popoverRef = useRef(null); + const [currentTheme, setCurrentTheme] = useState(getTheme); + + const handleThemeSwitchItemClick: ShellBarItemPropTypes['onClick'] = (e) => { + popoverRef.current?.showAt(e.detail.targetRef); + }; + const handleThemeSwitch: ListPropTypes['onSelectionChange'] = (e) => { + const { targetItem } = e.detail; + setTheme(targetItem.dataset.key!); + setCurrentTheme(targetItem.dataset.key!); + }; + + return ( + <> + { + navigate(-1); + }} + /> + ) + } + > + + + + + {THEMES.map((theme) => ( + + {theme.value} + + ))} + + + + ); +} diff --git a/examples/remix-ts/app/components/TodoList.tsx b/examples/remix-ts/app/components/TodoList.tsx new file mode 100644 index 00000000000..34fa740da44 --- /dev/null +++ b/examples/remix-ts/app/components/TodoList.tsx @@ -0,0 +1,32 @@ +import { useNavigate } from '@remix-run/react'; +import { List, ListItemType, ListPropTypes, StandardListItem, ValueState } from '@ui5/webcomponents-react'; +import { Todo } from '~/mockData/todos'; + +interface TodoListProps { + items: Todo[]; +} + +export function TodoList({ items }: TodoListProps) { + const navigate = useNavigate(); + const handleTodoClick: ListPropTypes['onItemClick'] = (event) => { + navigate(`/todos/${event.detail.item.dataset.id}`); + }; + + return ( + + {items.map((todo) => { + return ( + + {todo.title} + + ); + })} + + ); +} diff --git a/examples/remix-ts/app/entry.client.tsx b/examples/remix-ts/app/entry.client.tsx new file mode 100644 index 00000000000..966fbc13309 --- /dev/null +++ b/examples/remix-ts/app/entry.client.tsx @@ -0,0 +1,22 @@ +/** + * By default, Remix will handle hydrating your app on the client for you. + * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ + * For more information, see https://remix.run/file-conventions/entry.client + */ + +import { RemixBrowser } from '@remix-run/react'; +import { startTransition, StrictMode } from 'react'; +import { hydrateRoot } from 'react-dom/client'; + +import './globals.css'; +import '@ui5/webcomponents-react/styles.css'; +import '@ui5/webcomponents-icons/dist/Assets.js'; + +startTransition(() => { + hydrateRoot( + document.getElementById('root')!, + + + + ); +}); diff --git a/examples/remix-ts/app/entry.server.tsx b/examples/remix-ts/app/entry.server.tsx new file mode 100644 index 00000000000..206cc4ebe4b --- /dev/null +++ b/examples/remix-ts/app/entry.server.tsx @@ -0,0 +1,130 @@ +/** + * By default, Remix will handle generating the HTTP Response for you. + * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ + * For more information, see https://remix.run/file-conventions/entry.server + */ + +import { PassThrough } from 'node:stream'; + +import type { AppLoadContext, EntryContext } from '@remix-run/node'; +import { createReadableStreamFromReadable } from '@remix-run/node'; +import { RemixServer } from '@remix-run/react'; +import { isbot } from 'isbot'; +import { renderToPipeableStream } from 'react-dom/server'; +import { renderHeadToString } from 'remix-island'; +import { Head } from './root'; + +const ABORT_DELAY = 5_000; + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, + // This is ignored so we can keep it in the template for visibility. Feel + // free to delete this parameter in your app if you're not using it! + // eslint-disable-next-line @typescript-eslint/no-unused-vars + loadContext: AppLoadContext +) { + return isbot(request.headers.get('user-agent') || '') + ? handleBotRequest(request, responseStatusCode, responseHeaders, remixContext) + : handleBrowserRequest(request, responseStatusCode, responseHeaders, remixContext); +} + +function handleBotRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext +) { + return new Promise((resolve, reject) => { + let shellRendered = false; + const { pipe, abort } = renderToPipeableStream( + , + { + onAllReady() { + const head = renderHeadToString({ request, remixContext, Head }); + shellRendered = true; + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode + }) + ); + + responseHeaders.set('Content-Type', 'text/html'); + body.write(`${head}
`); + pipe(body); + body.write('
'); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error); + } + } + } + ); + + setTimeout(abort, ABORT_DELAY); + }); +} + +function handleBrowserRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext +) { + return new Promise((resolve, reject) => { + let shellRendered = false; + const { pipe, abort } = renderToPipeableStream( + , + { + onShellReady() { + const head = renderHeadToString({ request, remixContext, Head }); + shellRendered = true; + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set('Content-Type', 'text/html'); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode + }) + ); + + body.write(`${head}
`); + + pipe(body); + body.write('
'); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error); + } + } + } + ); + + setTimeout(abort, ABORT_DELAY); + }); +} diff --git a/examples/remix-ts/app/globals.css b/examples/remix-ts/app/globals.css new file mode 100644 index 00000000000..68f152dfda3 --- /dev/null +++ b/examples/remix-ts/app/globals.css @@ -0,0 +1,25 @@ +/* to prevent flickering, only show the web-component when its custom-element is defined */ +:not(:defined) { + display: none; +} + +html, +body { + max-width: 100vw; + overflow-x: hidden; + padding: 0; + margin: 0; +} + +.appShell { + height: 100vh; + width: 100vw; + overflow: hidden; +} + +.appScrollContainer { + height: calc(100vh - 3.25rem); + width: 100vw; + overflow-y: auto; + position: relative; +} \ No newline at end of file diff --git a/examples/remix-ts/app/mockData/todos.ts b/examples/remix-ts/app/mockData/todos.ts new file mode 100644 index 00000000000..9674227bbad --- /dev/null +++ b/examples/remix-ts/app/mockData/todos.ts @@ -0,0 +1,149 @@ +export type Todo = { + id: number; + title: string; + details?: string; + completed: boolean; +}; + +export const todos: Todo[] = [ + { + id: 1, + title: 'Do something nice for someone I care about', + details: + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et', + completed: true + }, + { + id: 2, + title: 'Memorize the fifty states and their capitals', + details: + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et', + completed: false + }, + { + id: 3, + title: 'Watch a classic movie', + details: + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et', + completed: false + }, + { + id: 4, + title: 'Contribute code or a monetary donation to an open-source software project', + details: + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et', + completed: false + }, + { + id: 5, + title: "Solve a Rubik's cube", + details: + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et', + completed: false + }, + { + id: 6, + title: 'Bake pastries for me and neighbor', + details: + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et', + completed: false + }, + { + id: 7, + title: 'Go see a Broadway production', + details: + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et', + completed: false + }, + { + id: 8, + title: 'Write a thank you letter to an influential person in my life', + details: + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et', + completed: true + }, + { + id: 9, + title: 'Invite some friends over for a game night', + details: + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et', + completed: false + }, + { + id: 10, + title: 'Have a football scrimmage with some friends', + details: + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et', + completed: false + }, + { + id: 11, + title: "Text a friend I haven't talked to in a long time", + details: + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et', + completed: false + }, + { + id: 12, + title: 'Organize pantry', + details: + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et', + completed: true + }, + { + id: 13, + title: 'Buy a new house decoration', + details: + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et', + completed: false + }, + { + id: 14, + title: "Plan a vacation I've always wanted to take", + details: + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et', + completed: false + }, + { + id: 15, + title: 'Clean out car', + details: + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et', + completed: false + }, + { + id: 16, + title: 'Draw and color a Mandala', + details: + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et', + completed: true + }, + { + id: 17, + title: 'Create a cookbook with favorite recipes', + details: + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et', + completed: false + }, + { + id: 18, + title: 'Bake a pie with some friends', + details: + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et', + completed: false + }, + { + id: 19, + title: 'Create a compost pile', + details: + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et', + completed: true + }, + { + id: 20, + title: 'Take a hike at a local park', + details: + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et', + completed: true + } +]; diff --git a/examples/remix-ts/app/root.tsx b/examples/remix-ts/app/root.tsx new file mode 100644 index 00000000000..fc5d7b3b373 --- /dev/null +++ b/examples/remix-ts/app/root.tsx @@ -0,0 +1,28 @@ +import { Links, Meta, Outlet, Scripts, ScrollRestoration } from '@remix-run/react'; +import { createHead } from 'remix-island'; +import { AppShell } from './components/AppShell'; + +export const Head = createHead(() => { + return ( + <> + + + + + + ); +}); + +export default function App() { + // this will be rendered inside a node + return ( + <> + + + + + + + + ); +} diff --git a/examples/remix-ts/app/routes/_index.tsx b/examples/remix-ts/app/routes/_index.tsx new file mode 100644 index 00000000000..e71e7f495e9 --- /dev/null +++ b/examples/remix-ts/app/routes/_index.tsx @@ -0,0 +1,31 @@ +import type { MetaFunction } from '@remix-run/node'; +import { useLoaderData } from '@remix-run/react'; +import { Bar, Page, Title } from '@ui5/webcomponents-react'; +import { Todo, todos } from '~/mockData/todos'; +import { json } from '@remix-run/node'; +import { TodoList } from '~/components/TodoList'; + +export const meta: MetaFunction = () => { + return [{ title: 'New Remix App' }, { name: 'description', content: 'Welcome to Remix!' }]; +}; + +export const loader = async () => { + const todoList = await new Promise((resolve) => { + setTimeout(() => { + resolve(todos); + }, 1500); + }); + + return json({ data: { todos } }); +}; + +export default function Index() { + const { + data: { todos } + } = useLoaderData(); + return ( + My Todos} />}> + + + ); +} diff --git a/examples/remix-ts/app/routes/todos.$id.tsx b/examples/remix-ts/app/routes/todos.$id.tsx new file mode 100644 index 00000000000..8e678c2a75c --- /dev/null +++ b/examples/remix-ts/app/routes/todos.$id.tsx @@ -0,0 +1,56 @@ +import { LoaderFunctionArgs, json } from '@remix-run/node'; +import { useLoaderData } from '@remix-run/react'; +import { + DatePicker, + DynamicPage, + DynamicPageTitle, + Form, + FormItem, + Input, + MessageStrip, + MessageStripDesign, + Switch, + TextArea +} from '@ui5/webcomponents-react'; +import { Todo, todos } from '~/mockData/todos'; + +export const loader = async ({ params }: LoaderFunctionArgs) => { + const todo = await new Promise((resolve) => { + setTimeout(() => { + resolve(todos.at(Number(params.id))); + }, 500); + }); + + return json({ data: { todo } }); +}; + +export default function TodoDetails() { + const { + data: { todo } + } = useLoaderData(); + + return ( + <> + }> + + {`Since this is only a demo app, adjustments made here on this page won't be reflected in the todo list.`} + +
+ + + + +