diff --git a/packages/core/src/router.ts b/packages/core/src/router.ts index 2851b2aa6..ba09e386f 100644 --- a/packages/core/src/router.ts +++ b/packages/core/src/router.ts @@ -20,6 +20,7 @@ import { GlobalEventNames, GlobalEventResult, LocationVisit, + Method, Page, PageHandler, PageResolver, @@ -89,6 +90,21 @@ export class Router { protected setupEventListeners(): void { window.addEventListener('popstate', this.handlePopstateEvent.bind(this)) document.addEventListener('scroll', debounce(this.handleScrollEvent.bind(this), 100), true) + document.addEventListener('click', (event) => { + const target = event.target as Element + const anchorElement = target.closest('a') + const frameId = (target.closest('[data-inertia-frame-id]') as HTMLElement)?.dataset.inertiaFrameId + if (!anchorElement || anchorElement.rel == 'external' || anchorElement.target == '_blank') return + + if (anchorElement.href && anchorElement.href.startsWith(location.origin)) { + event.preventDefault() + event.stopPropagation() + this.visit(anchorElement.href, { + method: anchorElement.dataset['method'] as Method, + target: anchorElement.dataset['target'] || frameId, + }) + } + }) } protected scrollRegions(): NodeListOf { @@ -263,6 +279,7 @@ export class Router { headers = {}, errorBag = '', forceFormData = false, + target = null, onCancelToken = () => {}, onBefore = () => {}, onStart = () => {}, @@ -296,6 +313,7 @@ export class Router { only, headers, errorBag, + target, forceFormData, queryStringArrayFormat, cancelled: false, @@ -373,9 +391,16 @@ export class Router { } const pageResponse: Page = response.data + + // if an X-Inertia-Frame header is present, use its value to override target frame id + if (response.headers['x-inertia-frame']) { + target = response.headers['x-inertia-frame'] + } + if (only.length && pageResponse.component === this.page.component) { pageResponse.props = { ...this.page.props, ...pageResponse.props } } + preserveScroll = this.resolvePreserveOption(preserveScroll, pageResponse) as boolean preserveState = this.resolvePreserveOption(preserveState, pageResponse) if (preserveState && window.history.state?.rememberedState && pageResponse.component === this.page.component) { @@ -387,17 +412,17 @@ export class Router { responseUrl.hash = requestUrl.hash pageResponse.url = responseUrl.href } - return this.setPage(pageResponse, { visitId, replace, preserveScroll, preserveState }) + return this.setPage(pageResponse, { target, visitId, replace, preserveScroll, preserveState }) }) - .then(() => { - const errors = this.page.props.errors || {} + .then((page: Page) => { + const errors = page.props.errors || {} if (Object.keys(errors).length > 0) { const scopedErrors = errorBag ? (errors[errorBag] ? errors[errorBag] : {}) : errors fireErrorEvent(scopedErrors) return onError(scopedErrors) } - fireSuccessEvent(this.page) - return onSuccess(this.page) + fireSuccessEvent(page) + return onSuccess(page) }) .catch((error) => { if (this.isInertiaResponse(error.response)) { @@ -442,19 +467,27 @@ export class Router { replace = false, preserveScroll = false, preserveState = false, + target = null, }: { visitId?: VisitId replace?: boolean preserveScroll?: PreserveStateOption preserveState?: PreserveStateOption + target?: string | null } = {}, - ): Promise { + ): Promise { return Promise.resolve(this.resolveComponent(page.component)).then((component) => { if (visitId === this.visitId) { page.scrollRegions = page.scrollRegions || [] page.rememberedState = page.rememberedState || {} - replace = replace || hrefToUrl(page.url).href === window.location.href - replace ? this.replaceState(page) : this.pushState(page) + if (!target || target === '_top' || target === '_parent' || target === 'main') { + replace = replace || hrefToUrl(page.url).href === window.location.href + replace ? this.replaceState(page) : this.pushState(page) + } + else { + page.target = target + } + this.swapComponent({ component, page, preserveState }).then(() => { if (!preserveScroll) { this.resetScrollPositions() @@ -464,6 +497,7 @@ export class Router { } }) } + return page }) } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 2fc1d6025..dbf8c151a 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -31,12 +31,13 @@ export interface PageProps { export interface Page { component: string props: PageProps & - SharedProps & { - errors: Errors & ErrorBag - } + SharedProps & { + errors: Errors & ErrorBag + } url: string + target?: string | null version: string | null - + /** @internal */ scrollRegions: Array<{ top: number; left: number }> /** @internal */ @@ -52,7 +53,7 @@ export type PageHandler = ({ }: { component: Component page: Page - preserveState: PreserveStateOption + preserveState: PreserveStateOption, }) => Promise export type PreserveStateOption = boolean | string | ((page: Page) => boolean) @@ -73,6 +74,7 @@ export type Visit = { headers: Record errorBag: string | null forceFormData: boolean + target: string | null queryStringArrayFormat: 'indices' | 'brackets' } diff --git a/packages/react/src/App.ts b/packages/react/src/App.ts index c073d3b8a..0acdb472e 100755 --- a/packages/react/src/App.ts +++ b/packages/react/src/App.ts @@ -2,6 +2,7 @@ import { createHeadManager, router } from '@inertiajs/core' import { createElement, useEffect, useMemo, useState } from 'react' import HeadContext from './HeadContext' import PageContext from './PageContext' +import FrameContext from "./FrameContext"; export default function App({ children, @@ -14,6 +15,7 @@ export default function App({ const [current, setCurrent] = useState({ component: initialComponent || null, page: initialPage, + frames: null, key: null, }) @@ -21,7 +23,8 @@ export default function App({ return createHeadManager( typeof window === 'undefined', titleCallback || ((title) => title), - onHeadUpdate || (() => {}), + onHeadUpdate || (() => { + }), ) }, []) @@ -29,12 +32,21 @@ export default function App({ router.init({ initialPage, resolveComponent, - swapComponent: async ({ component, page, preserveState }) => { - setCurrent((current) => ({ - component, - page, - key: preserveState ? current.key : Date.now(), - })) + swapComponent: async ({component, page, preserveState}) => { + const targetFrame = page.target; + if (targetFrame) { + setCurrent((current) => ({ + ...current, + frames: {...current.frames, [targetFrame]: {component, props: page.props}}, + })) + } else { + setCurrent((current) => ({ + component, + page, + frames: current.frames, + key: preserveState ? current.key : Date.now(), + })) + } }, }) @@ -74,11 +86,15 @@ export default function App({ createElement( PageContext.Provider, { value: current.page }, - renderChildren({ - Component: current.component, - key: current.key, - props: current.page.props, - }), + createElement( + FrameContext.Provider, + { value: current.frames }, + renderChildren({ + Component: current.component, + key: current.key, + props: current.page.props, + }), + ) ), ) } diff --git a/packages/react/src/Frame.ts b/packages/react/src/Frame.ts new file mode 100755 index 000000000..5db99b485 --- /dev/null +++ b/packages/react/src/Frame.ts @@ -0,0 +1,24 @@ +import {useEffect, createElement} from 'react'; +import {router} from './index'; +import useFrame from "./useFrame"; + +const Frame = ({src, id = Math.random(), children}) => { + const frames = useFrame() + const component = frames?.[id] && frames[id].component; + + useEffect(() => { + // inertia.set('frame-id', id); + // inertia.set('frame-src', src); + + router.visit(src, { + target: id.toString(), + }); + }, []); + + return createElement("div", + {'data-inertia-frame-id': id}, + component ? createElement(component, frames[id].props) : children) +}; + +Frame.displayName = 'InertiaFrame'; +export default Frame; \ No newline at end of file diff --git a/packages/react/src/FrameContext.ts b/packages/react/src/FrameContext.ts new file mode 100755 index 000000000..4c97d6672 --- /dev/null +++ b/packages/react/src/FrameContext.ts @@ -0,0 +1,6 @@ +import { createContext } from 'react' + +const frameContext = createContext(undefined) +frameContext.displayName = 'InertiaFrameContext' + +export default frameContext diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 6b13ec581..aacd453cd 100755 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -7,3 +7,4 @@ export { default as Link, InertiaLinkProps } from './Link' export { default as useForm } from './useForm' export { default as usePage } from './usePage' export { default as useRemember } from './useRemember' +export { default as Frame } from './Frame' diff --git a/packages/react/src/useFrame.ts b/packages/react/src/useFrame.ts new file mode 100755 index 000000000..3aa55001f --- /dev/null +++ b/packages/react/src/useFrame.ts @@ -0,0 +1,6 @@ +import { useContext } from 'react' +import FrameContext from './FrameContext' + +export default function useFrame(): any { + return useContext(FrameContext) +} diff --git a/packages/svelte/src/Frame.svelte b/packages/svelte/src/Frame.svelte new file mode 100755 index 000000000..7cbd59237 --- /dev/null +++ b/packages/svelte/src/Frame.svelte @@ -0,0 +1,26 @@ + + +
+ +
diff --git a/packages/svelte/src/createInertiaApp.js b/packages/svelte/src/createInertiaApp.js index facb5e625..7f1647522 100644 --- a/packages/svelte/src/createInertiaApp.js +++ b/packages/svelte/src/createInertiaApp.js @@ -21,9 +21,15 @@ export default async function createInertiaApp({ id = 'app', resolve, setup, pro initialPage, resolveComponent, swapComponent: async ({ component, page, preserveState }) => { - store.update((current) => ({ + const targetFrame = page.target + if (targetFrame) store.update((current) => ({ + ...current, + frames: { ...current.frames, [targetFrame]: {component, props: page.props} } + })) + else store.update((current) => ({ component, page, + frames: current.frames, key: preserveState ? current.key : Date.now(), })) }, @@ -44,11 +50,14 @@ export default async function createInertiaApp({ id = 'app', resolve, setup, pro } if (isServer) { - const { html, head } = SSR.render({ id, initialPage }) + const { html, head, css } = SSR.render({ id, initialPage }) return { body: html, - head: [head], + head: [ + head, + ``, + ], } } } diff --git a/packages/svelte/src/index.js b/packages/svelte/src/index.js index 0a886940b..dd6c05461 100755 --- a/packages/svelte/src/index.js +++ b/packages/svelte/src/index.js @@ -2,6 +2,7 @@ export { router } from '@inertiajs/core' export { default as createInertiaApp } from './createInertiaApp' export { default as inertia } from './link' export { default as Link } from './Link.svelte' +export { default as Frame } from './Frame.svelte' export { default as page } from './page' export { default as remember } from './remember' export { default as useForm } from './useForm' diff --git a/packages/svelte/src/store.js b/packages/svelte/src/store.js index abe2cdeae..489bb1a8d 100755 --- a/packages/svelte/src/store.js +++ b/packages/svelte/src/store.js @@ -5,6 +5,7 @@ const store = writable({ layout: [], page: {}, key: null, + frames: {} }) export default store diff --git a/packages/svelte/src/useForm.js b/packages/svelte/src/useForm.js index d624f65d6..e2163bdb4 100644 --- a/packages/svelte/src/useForm.js +++ b/packages/svelte/src/useForm.js @@ -2,11 +2,14 @@ import { router } from '@inertiajs/core' import isEqual from 'lodash.isequal' import cloneDeep from 'lodash.clonedeep' import { writable } from 'svelte/store' +import { getContext } from 'svelte' function useForm(...args) { const rememberKey = typeof args[0] === 'string' ? args[0] : null const data = (typeof args[0] === 'string' ? args[1] : args[0]) || {} const restored = rememberKey ? router.restore(rememberKey) : null + const frameId = getContext('inertia:frame-id') + const frameSrc = getContext('inertia:frame-src') let defaults = cloneDeep(data) let cancelToken = null let recentlySuccessfulTimeoutId = null @@ -89,8 +92,10 @@ function useForm(...args) { }, submit(method, url, options = {}) { const data = transform(this.data()) + if (frameSrc) data.frameSrc = frameSrc const _options = { ...options, + target: typeof(options.target) !== 'undefined' ? options.target : frameId, onCancelToken: (token) => { cancelToken = token diff --git a/readme.md b/readme.md index 1d4b676ad..480d2eb8a 100644 --- a/readme.md +++ b/readme.md @@ -1,19 +1,51 @@ -[![Inertia.js](https://raw.githubusercontent.com/inertiajs/inertia/master/.github/LOGO.png)](https://inertiajs.com/) +# Inertia with Frames -Inertia.js lets you quickly build modern single-page React, Vue and Svelte apps using classic server-side routing and controllers. Find full documentation at [inertiajs.com](https://inertiajs.com/). +This is a modified version of [Inertia](https://github.com/inertiajs/inertia) that adds support for Frames. It's currently supported in Svelte and React. -## Contributing +## Frames -If you're interested in contributing to Inertia.js, please read our [contributing guide](https://github.com/inertiajs/inertia/blob/master/.github/CONTRIBUTING.md). +This fork introduces the `` component. This component is used to encapsulate an Inertia page within another Inertia page. This is useful for creating modal dialogs, wizards, search sidebars, popovers, etc. Don't worry: Besides the name, it has nothing to do with conventional browser frames. -## Sponsors +By default, hyperlinks and form submissions will render the response within the frame that contains the link or the form. To change the frame in which an Inertia response is rendered, do one of the following: -A huge thanks to all [our sponsors](https://inertiajs.com/sponsors) who help push Inertia.js development forward! In particular, we'd like to say a special thank you to our partners: +- Add a `data-target="frame-id"` attribute to an `a` tag. +- Pass a `{target: frameId}` to `router.visit()` or `form.submit()` +- Specify the frame ID in an `X-Inertia-Frame` header from the server. -

- - Laravel Forge - -

+To target the top (main) frame, use `_top` as the frame ID. -If you'd like to become a sponsor, please [see here](https://github.com/sponsors/reinink) for more information. 💜 +Navigation within frames does not create new history entries. To enable this, a more substantial rewrite of the Inertia router would be required. + +Frames are loaded when the component is mounted. That means, that only the initial frame placeholder content will be rendered during SSR. + +### Try locally + +Clone this repo, [build it](https://github.com/inertiajs/inertia/blob/master/.github/CONTRIBUTING.md#packages), and in your `package.json`, link it like this: + +```js +{ + "devDependencies": { + '@inertiajs/core': 'file:./repo/packages/core', + '@inertiajs/svelte': 'file:./repo/packages/svelte', + '@inertiajs/react': 'file:./repo/packages/react' + } +} +``` + +Then run `npm install` again. + +### Example + +```html + + + + Loading... + + + + Edit a different user + +```