adobe / react-spectrum

A collection of libraries and tools that help you build adaptive, accessible, and robust user experiences.
https://react-spectrum.adobe.com
Apache License 2.0
12.21k stars 1.07k forks source link

React Aria Components: rendering as custom components #5476

Open chaance opened 7 months ago

chaance commented 7 months ago

Provide a general summary of the feature here

We should have a simpler way to render components other than what is returned from the React Aria component by default. This would be either some sort of polymorphic component rendering or a higher level hook.

In the MenuItem component (and possibly some others), the current way to render a link is to pass an href prop. This simply renders an a link instead of a div. For a library like React Aria, which is typically used as a primitive to create design-system level components, this is quite limiting. There should be some API for rendering another component without dropping down to the lowest-level APIs with React Aria hooks.

🤔 Expected Behavior?

TBD (exact behavior depends on the solution)

😯 Current Behavior

Currently I can't get the functionality of a component like MenuItem without either rendering that component or recreating it entirely with the much lower-level React Aria hooks.

There are a few challenges with the current implementation:

TypeScript. Particularly when using forwardRef, the ref's type is always assumed to be HTMLDivElement. This is a common theme in pretty much every component lib that embraces the "polymorphic" component pattern, and it's difficult to solve without introducing a number of other tradeoffs.

Custom link components. React Aria basically assumes you either want a regular a for external links or you simply want to navigate using your app's client-side router, in which case it uses the navigator provided to RouterProvider. I think this leaves a lot to be desired, as router and/or framework link components often bake in other features we lose if rendered in the context of a RA component.

💁 Possible Solution

  1. Allow for some sort of "slot" based solution that allows you to directly render a specific component, similar to asChild in Radix UI. This is probably not a great one for React Aria, since its interaction event props (onPress and family) heavily abstract and hide the underlying DOM props which a lot of components (including RR links) rely on. Leaving React Aria to decide how to resolve likely conflicts is probably going to be a mess.
  2. Utility hooks with a higher level of abstraction. I think this would basically look like the lower level hooks in React Aria, but you get all of the props that React Aria would otherwise pass to its own components. This would allow consumers to decide how to resolve any conflicts and render whatever they'd like. This would be a sharp knife potentially but would allow us to drop down to a lower level without having to completely rewrite all of the component's functionality using the base React Aria hooks.
    function MenuItemLink({ to, prefetch, ...props }) {
    // this would give you all of the props that are
    // passed into `ElementType` in your `MenuItemInner`
    // component, but leaves rendering to the consumer.
    const ref = React.useRef();
    const menuItemProps = useMenuItemProps(ref, props);
    return <Link prefetch={prefetch} to={to} {...menuItemProps} />;
    }

🔦 Context + Examples

Assume here we're talking about rendering a link in MenuItem.

When the Link component in React Router resolves its to prop it can determine whether or not a URL value is internal (for client-side routing) or external (leave it to the browser). To do this well it needs to use internal context for apps with a basename. Recreating this behavior requires that you import non-public context modules and dupe their validation code, which isn't ideal.

Another example in React Router is NavLink. This component also relies on internal context to determine the link's current status and supports functional props so you can switch on it.

And in Remix, it's pretty much impossible to support their Link's prefetch functionality without actually rendering their Link.

🧢 Your Company/Team

Me personally

🕷 Tracking Issue

Couldn't find one!

devongovett commented 7 months ago

Pasting what I said on Twitter for posterity:

It’s very difficult if not impossible to correctly compose components with built in behaviors like this, as I’m sure you know. For example Remix links have a built in click handler, but when used in another component, like a combobox or even a menu, that might not be the only/right interaction. That’s why we haven’t exposed an as prop, for example. Doing so would only work correctly 80% of the time, not 100%. This can have downsides for accessibility and cause hard to spot bugs.

We think there are usually better ways to solve individual issues that come up than a blunt instrument like as or asChild. In the case of pre-fetch, using a hook in your own wrapper component would probably be a better approach. We’ll definitely add more features to the API as they come up though!

The ideal solution in my view for this case in particular would be if Remix exported their internal usePrefetchBehavior hook, or something like it. Then you could just do something like this:

function MyMenuItem({prefetch, ...props}) {
  let [ref, prefetchProps] = usePrefetch(ref);
  return <ReactAriaMenuItem {...props} {...prefetchProps} ref={ref} />;
}

This separation of behavior from elements makes sense especially because pre-fetching has nothing to do with the underlying element that gets rendered. Sometimes we actually have to render a different element than a native <a> due to ARIA/HTML spec limitations, and separating the behavior out would make those work with prefetch too. Remix/React Router normally does this well, e.g. exporting a useLinkClickHandler that you can use to build custom links, but unfortunately not in this case.

Aside from this one in particular, I think we are planning on adding some additional options to pass to the router's navigate function (e.g. preventScrollReset) and support base path (#5395) in the short term. Longer term we can see what other use cases come up, but I'll definitely discuss your ideas with the team next week. Would be amazing to come up with a solution without the downsides of as. Thanks for writing this up, I really appreciate it! 😃

jamesopstad commented 6 months ago

I've been struggling to integrate React Router with React Aria Components and agree with the points raised in this issue. I have encountered most of the examples given and they are all quite common use-cases. Another issue I have come up against is working with different kinds of router e.g. hash router or memory router. With a hash router, for example, the RAC Link component doesn't include the hash in the URL. If I use the useHref hook from React Router to resolve the URL then the href is correct but the link no longer functions correctly because the navigate function is called with two hashes.

Ultimately, I think the way client side routing works in React Aria needs a bit of a rethink. Simply providing the navigate function to the RouterProvider without any control over how it is called is too simplistic. Adding support for additional props at the library level (e.g. preventScrollReset), however, will always lag behind the features provided by individual routing libraries that users need to access. I am not sure what the best solution is but feel that it's important to be able to easily access all the features that the underlying routing library offers. If it helps, these are some examples of use cases that I feel need elegant solutions:

devongovett commented 6 months ago

Current plan for router props is to have a single object that gets passed through to the underlying router, rather than individual props. That way it isn't specific to one router and you don't need to wait when new props are added by your framework.

<MenuItem href="..." routerOptions={{preventScrollReset: true}} />

There is also a way to configure TypeScript to support autocomplete on these that we'll provide docs for.

Something like NavLink I think should be handled by React Aria instead. For example, depending on the component it's used within, the accessibility properties should be different (ie aria-selected in Tabs rather than aria-current). We had discussed adding a feature at some point for React Aria link components to know whether they are selected automatically based on the current URL as well, but haven't gotten around to implementing it yet. For now, you should set the selectedKey prop yourself based on the current url so that the item gets the right states. There's an example in the docs of that.

The href prop needs to be set to whatever the native URL would be. The hash issue is sort of related to the basepath one (#5395). Somehow the native URL needs to be backwards resolved to the router one. Still thinking about this.

The prefetching one is definitely more complex because it needs actual behavior from the router link, not just settings for the native URL. Somehow we need to separate the individual behaviors from the rendering so that React Aria can do the event handling, and delegate the routing. Without the router separating this out into a hook, it's a bit of a challenge.

jamesopstad commented 6 months ago

Current plan for router props is to have a single object that gets passed through to the underlying router, rather than individual props. That way it isn't specific to one router and you don't need to wait when new props are added by your framework.

This sounds like a good solution and would help a lot.

The href prop needs to be set to whatever the native URL would be. The hash issue is sort of related to the basepath one (https://github.com/adobe/react-spectrum/pull/5395). Somehow the native URL needs to be backwards resolved to the router one. Still thinking about this.

Yes, I think resolving back to the router URL is the important bit.

How would you suggest using React Aria links so that you can provide relative URLs in the same way that you can with the routing library. For React Router, would it be to wrap the components that have an href prop with custom components that internally use the useHref hook to determine the URL? My worry is that you still end up having to recreate a lot of the implementation that exists in the router components.

It seems like adding more documentation for how to integrate deeper router functionality with React Aria would be really useful. This may also highlight what can already be achieved in a straightforward way and what needs changes at the library level. Thanks for thinking about this.

jamesopstad commented 6 months ago

Just a thought about resolving the URLs. What if the RouterProvider had a useHref prop that takes a hook provided by the router? This hook would resolve the href prop that the user provides to the native URL in all React Aria components. The user provided href would be passed through as is to the navigate function, however, without being transformed. With React Router it would look like this:

import { RouterProvider } from 'react-aria-components';
import { useNavigate, useHref } from 'react-router-dom';

function App() {
  const navigate = useNavigate()

  return (
    <RouterProvider navigate={navigate} useHref={useHref}>
      {/* ... */}
    </RouterProvider>
  );
}

It's a bit unusual to pass a hook as a prop like this but it would resolve the issues with relative paths, base paths and hash routers.

devongovett commented 6 months ago

I like that idea! Do you know if next.js has a similar API to resolve URLs? I couldn't find one in their docs.

jamesopstad commented 6 months ago

I like that idea! Do you know if next.js has a similar API to resolve URLs? I couldn't find one in their docs.

I don't think they do. It's worth considering TanStack Router too as the usage will likely grow now that it's reached 1.0 (https://tanstack.com/router/v1). That has the added challenge of providing the same level of type inference via the React Aria link components. It's hard to think of a single approach that will integrate fully with different routing libraries.

devongovett commented 5 months ago

FYI I implemented the useHref idea as well as routerOptions in https://github.com/adobe/react-spectrum/pull/5864. This should solve most of the issues with links here. The preload case is a remaining problem, until Remix offers hooks or some other programmatic way to trigger preloads.