feat: add <A>

This commit is contained in:
Greg Johnston 2024-04-24 08:16:14 -04:00
parent 8642c563d8
commit 782cb93743
10 changed files with 429 additions and 40 deletions

View File

@ -17,6 +17,7 @@ use log::{debug, info};
use routing::{
components::{ParentRoute, Redirect, Route, Router, Routes},
hooks::{use_location, use_navigate, use_params},
link::A,
location::{BrowserUrl, Location},
params::Params,
MatchNestedRoutes, NestedRoute, Outlet, ParamSegment, StaticSegment,
@ -35,17 +36,15 @@ pub fn RouterExample() -> impl IntoView {
view! {
<Router>
<nav>
// TODO <A>
// ordinary <a> elements can be used for client-side navigation
// using <A> has two effects:
// 1) ensuring that relative routing works properly for nested routes
// 2) setting the `aria-current` attribute on the current link,
// for a11y and styling purposes
<a href="/contacts">"Contacts"</a>
<a href="/about">"About"</a>
<a href="/settings">"Settings"</a>
<a href="/redirect-home">"Redirect to Home"</a>
<A href="/contacts">"Contacts"</A>
<A href="/about">"About"</A>
<A href="/settings">"Settings"</A>
<A href="/redirect-home">"Redirect to Home"</A>
</nav>
<main>
<Routes fallback=|| "This page could not be found.">
@ -67,7 +66,7 @@ pub fn RouterExample() -> impl IntoView {
#[component]
pub fn ContactRoutes() -> impl MatchNestedRoutes<Dom> + Clone {
view! {
<ParentRoute path=StaticSegment("") view=ContactList>
<ParentRoute path=StaticSegment("contacts") view=ContactList>
<Route path=StaticSegment("") view=|| "Select a contact."/>
<Route path=ParamSegment("id") view=Contact/>
</ParentRoute>
@ -93,9 +92,8 @@ pub fn ContactList() -> impl IntoView {
contacts.await
.into_iter()
.map(|contact| {
// TODO <A>
view! {
<li><a href=contact.id.to_string()><span>{contact.first_name} " " {contact.last_name}</span></a></li>
<li><A href=contact.id.to_string()><span>{contact.first_name} " " {contact.last_name}</span></A></li>
}
})
.collect::<Vec<_>>()

View File

@ -272,6 +272,24 @@ where
}
}
}
/// Converts the value into its cheaply-clonable form in place.
/// In other words, if it is currently [`Oco::Owned`], converts into [`Oco::Counted`]
/// in an `O(n)` operation, so that all future clones are `O(1)`.
///
/// # Examples
/// ```
/// # use leptos_reactive::oco::Oco;
/// let mut oco = Oco::<str>::Owned("Hello".to_string());
/// oco.upgrade_inplace();
/// assert!(oco1.is_counted());
/// ```
pub fn upgrade_inplace(&mut self) {
if let Self::Owned(v) = &*self {
let rc = Arc::from(v.borrow());
*self = Self::Counted(rc);
}
}
}
impl<T: ?Sized> Default for Oco<'_, T>

View File

@ -9,7 +9,9 @@ use crate::{
};
use leptos::{
children::{ToChildren, TypedChildren},
component, IntoView,
component,
oco::Oco,
IntoView,
};
use reactive_graph::{
computed::ArcMemo,
@ -141,6 +143,15 @@ impl RouterContext {
state: options.state,
});
}
pub fn resolve_path<'a>(
&'a self,
path: &'a str,
from: Option<&'a str>,
) -> Option<Cow<'a, str>> {
let base = self.base.as_deref().unwrap_or_default();
resolve_path(base, path, from)
}
}
impl Debug for RouterContext {
@ -173,7 +184,6 @@ where
#[component]
pub fn Routes<Defs, FallbackFn, Fallback>(
#[prop(optional, into)] base: Option<Cow<'static, str>>,
fallback: FallbackFn,
children: RouteChildren<Defs>,
) -> impl IntoView
@ -182,8 +192,15 @@ where
FallbackFn: Fn() -> Fallback + Send + 'static,
Fallback: IntoView + 'static,
{
let RouterContext { current_url, .. } = use_context()
let RouterContext {
current_url, base, ..
} = use_context()
.expect("<Routes> should be used inside a <Router> component");
let base = base.map(|base| {
let mut base = Oco::from(base);
base.upgrade_inplace();
base
});
let routes = Routes::new(children.into_inner());
let path = ArcMemo::new({
let url = current_url.clone();
@ -201,7 +218,7 @@ where
url: current_url.clone(),
path: path.clone(),
search_params: search_params.clone(),
base: base.clone(), // TODO is this necessary?
base: base.clone(),
fallback: fallback(),
rndr: PhantomData,
}

View File

@ -3,15 +3,17 @@ use crate::{
location::{Location, Url},
navigate::{NavigateOptions, UseNavigate},
params::{Params, ParamsError, ParamsMap},
RouteContext,
};
use leptos::{leptos_dom::helpers::window, oco::Oco};
use reactive_graph::{
computed::Memo,
computed::{ArcMemo, Memo},
owner::use_context,
signal::{ArcReadSignal, ArcRwSignal, ReadSignal},
traits::{Get, With},
traits::{Get, Read, With},
};
use std::{rc::Rc, str::FromStr};
use tachys::renderer::Renderer;
/*
/// Constructs a signal synchronized with a specific URL query parameter.
///
@ -182,23 +184,28 @@ where
Memo::new(move |_| url.with(|url| T::from_map(url.search_params())))
}
/*
/// Resolves the given path relative to the current route.
#[track_caller]
pub fn use_resolved_path(
path: impl Fn() -> String + 'static,
) -> Memo<Option<String>> {
let route = use_route();
create_memo(move |_| {
pub(crate) fn use_resolved_path<R: Renderer + 'static>(
path: impl Fn() -> String + Send + Sync + 'static,
) -> ArcMemo<Option<String>> {
let router = use_context::<RouterContext>()
.expect("called use_resolved_path outside a <Router>");
let matched = use_context::<RouteContext<R>>().map(|route| route.matched);
ArcMemo::new(move |_| {
let path = path();
if path.starts_with('/') {
Some(path)
} else {
route.resolve_path_tracked(&path)
router
.resolve_path(
&path,
matched.as_ref().map(|n| n.get()).as_deref(),
)
.map(|n| n.to_string())
}
})
}*/
}
/// Returns a function that can be used to navigate to a new route.
///

View File

@ -5,6 +5,7 @@
pub mod components;
mod generate_route_list;
pub mod hooks;
pub mod link;
pub mod location;
mod matching;
mod method;

186
routing/src/link.rs Normal file
View File

@ -0,0 +1,186 @@
use crate::{
components::RouterContext,
hooks::{use_location, use_resolved_path},
location::State,
};
use either_of::Either;
use leptos::{
children::{Children, TypedChildren},
oco::Oco,
prelude::*,
*,
};
use reactive_graph::{computed::ArcMemo, effect::Effect, owner::use_context};
use std::borrow::Cow;
/// Describes a value that is either a static or a reactive URL, i.e.,
/// a [`String`], a [`&str`], or a reactive `Fn() -> String`.
pub trait ToHref {
/// Converts the (static or reactive) URL into a function that can be called to
/// return the URL.
fn to_href(&self) -> Box<dyn Fn() -> String + '_>;
}
impl ToHref for &str {
fn to_href(&self) -> Box<dyn Fn() -> String> {
let s = self.to_string();
Box::new(move || s.clone())
}
}
impl ToHref for String {
fn to_href(&self) -> Box<dyn Fn() -> String> {
let s = self.clone();
Box::new(move || s.clone())
}
}
impl ToHref for Cow<'_, str> {
fn to_href(&self) -> Box<dyn Fn() -> String + '_> {
let s = self.to_string();
Box::new(move || s.clone())
}
}
impl ToHref for Oco<'_, str> {
fn to_href(&self) -> Box<dyn Fn() -> String + '_> {
let s = self.to_string();
Box::new(move || s.clone())
}
}
impl<F> ToHref for F
where
F: Fn() -> String + 'static,
{
fn to_href(&self) -> Box<dyn Fn() -> String + '_> {
Box::new(self)
}
}
/// An HTML [`a`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a)
/// progressively enhanced to use client-side routing.
///
/// Client-side routing also works with ordinary HTML `<a>` tags, but `<A>` does two additional things:
/// 1) Correctly resolves relative nested routes. Relative routing with ordinary `<a>` tags can be tricky.
/// For example, if you have a route like `/post/:id`, `<A href="1">` will generate the correct relative
/// route, but `<a href="1">` likely will not (depending on where it appears in your view.)
/// 2) Sets the `aria-current` attribute if this link is the active link (i.e., its a link to the page youre on).
/// This is helpful for accessibility and for styling. For example, maybe you want to set the link a
/// different color if its a link to the page youre currently on.
#[component]
pub fn A<H>(
/// Used to calculate the link's `href` attribute. Will be resolved relative
/// to the current route.
href: H,
/// Where to display the linked URL, as the name for a browsing context (a tab, window, or `<iframe>`).
#[prop(optional, into)]
target: Option<Oco<'static, str>>,
/// If `true`, the link is marked active when the location matches exactly;
/// if false, link is marked active if the current route starts with it.
#[prop(optional)]
exact: bool,
/// Provides a class to be added when the link is active. If provided, it will
/// be added at the same time that the `aria-current` attribute is set.
///
/// This supports multiple space-separated class names.
///
/// **Performance**: If its possible to style the link using the CSS with the
/// `[aria-current=page]` selector, you should prefer that, as it enables significant
/// SSR optimizations.
#[prop(optional, into)]
active_class: Option<Oco<'static, str>>,
/// An object of any type that will be pushed to router state
#[prop(optional)]
state: Option<State>,
/// If `true`, the link will not add to the browser's history (so, pressing `Back`
/// will skip this page.)
#[prop(optional)]
replace: bool,
// TODO arbitrary attributes
/*/// Sets the `class` attribute on the underlying `<a>` tag, making it easier to style.
#[prop(optional, into)]
class: Option<AttributeValue>,
/// Sets the `id` attribute on the underlying `<a>` tag, making it easier to target.
#[prop(optional, into)]
id: Option<Oco<'static, str>>,
/// Arbitrary attributes to add to the `<a>`. Attributes can be added with the
/// `attr:` syntax in the `view` macro.
#[prop(attrs)]
attributes: Vec<(&'static str, Attribute)>,*/
/// The nodes or elements to be shown inside the link.
children: Children,
) -> impl IntoView
where
H: ToHref + Send + Sync + 'static,
{
fn inner(
href: ArcMemo<Option<String>>,
target: Option<Oco<'static, str>>,
exact: bool,
#[allow(unused)] state: Option<State>,
#[allow(unused)] replace: bool,
#[allow(unused)] active_class: Option<Oco<'static, str>>,
children: Children,
) -> impl IntoView {
let RouterContext { current_url, .. } =
use_context().expect("tried to use <A/> outside a <Router/>.");
let is_active = ArcMemo::new({
let href = href.clone();
move |_| {
href.read().as_deref().is_some_and(|to| {
let path = to.split(['?', '#']).next().unwrap_or_default();
current_url.with(|loc| {
let loc = loc.path();
if exact {
loc == path
} else {
std::iter::zip(loc.split('/'), path.split('/'))
.all(|(loc_p, path_p)| loc_p == path_p)
}
})
})
}
});
let mut a = view! {
<a
href=move || href.get().unwrap_or_default()
target=target
prop:state=state.map(|s| s.to_js_value())
prop:replace=replace
aria-current={
let is_active = is_active.clone();
move || if is_active.get() { Some("page") } else { None }
}
>
// TODO attributes
// class=class
// id=id
{children()}
</a>
};
/*if let Some(active_class) = active_class {
let classes = active_class
.split_ascii_whitespace()
.map(|class| Cow::Owned(class.to_string()))
.collect::<Vec<_>>();
Either::Left(a.class((classes, move || is_active.get())))
} else {
Either::Right(a)
}*/
a
// TODO attributes
/*for (attr_name, attr_value) in attributes {
a = a.attr(attr_name, attr_value);
}*/
}
let href = use_resolved_path::<Dom>(move || href.to_href()());
inner(href, target, exact, state, replace, active_class, children)
}

View File

@ -2,16 +2,17 @@ use crate::{
location::{Location, Url},
matching::Routes,
params::ParamsMap,
resolve_path::resolve_path,
ChooseView, MatchInterface, MatchNestedRoutes, MatchParams, RouteMatchId,
};
use either_of::Either;
use leptos::{component, IntoView};
use leptos::{component, oco::Oco, IntoView};
use or_poisoned::OrPoisoned;
use reactive_graph::{
computed::{ArcMemo, Memo},
owner::{provide_context, use_context, Owner},
signal::{ArcRwSignal, ArcTrigger},
traits::{Get, Read, Set, Track, Trigger},
traits::{Get, Read, ReadUntracked, Set, Track, Trigger},
};
use std::{
borrow::Cow,
@ -41,7 +42,7 @@ pub(crate) struct NestedRoutesView<Defs, Fal, R> {
pub url: ArcRwSignal<Url>,
pub path: ArcMemo<String>,
pub search_params: ArcMemo<ParamsMap>,
pub base: Option<Cow<'static, str>>,
pub base: Option<Oco<'static, str>>,
pub fallback: Fal,
pub rndr: PhantomData<R>,
}
@ -84,7 +85,7 @@ where
let view = match new_match {
None => Either::Left(fallback),
Some(route) => {
route.build_nested_route(&mut outlets, &outer_owner);
route.build_nested_route(base, &mut outlets, &outer_owner);
outer_owner.with(|| {
Either::Right(
Outlet(OutletProps::builder().build()).into_any(),
@ -115,6 +116,7 @@ where
}
Some(route) => {
route.rebuild_nested_route(
self.base,
&mut 0,
&mut state.outlets,
&self.outer_owner,
@ -168,7 +170,7 @@ where
type OutletViewFn<R> = Box<dyn FnOnce() -> AnyView<R> + Send>;
#[derive(Debug)]
pub struct RouteContext<R>
pub(crate) struct RouteContext<R>
where
R: Renderer,
{
@ -176,6 +178,8 @@ where
trigger: ArcTrigger,
params: ArcRwSignal<ParamsMap>,
owner: Owner,
pub matched: ArcRwSignal<String>,
base: Option<Oco<'static, str>>,
tx: Sender<OutletViewFn<R>>,
rx: Arc<Mutex<Option<Receiver<OutletViewFn<R>>>>>,
}
@ -200,6 +204,8 @@ where
trigger: self.trigger.clone(),
params: self.params.clone(),
owner: self.owner.clone(),
matched: self.matched.clone(),
base: self.base.clone(),
tx: self.tx.clone(),
rx: self.rx.clone(),
}
@ -212,12 +218,14 @@ where
{
fn build_nested_route(
self,
base: Option<Oco<'static, str>>,
outlets: &mut Vec<RouteContext<R>>,
parent: &Owner,
);
fn rebuild_nested_route(
self,
base: Option<Oco<'static, str>>,
items: &mut usize,
outlets: &mut Vec<RouteContext<R>>,
parent: &Owner,
@ -231,6 +239,7 @@ where
{
fn build_nested_route(
self,
base: Option<Oco<'static, str>>,
outlets: &mut Vec<RouteContext<R>>,
parent: &Owner,
) {
@ -243,6 +252,10 @@ where
// params, even if there's not a route match change
let params = ArcRwSignal::new(self.to_params().into_iter().collect());
// the matched signal will also be updated on every match
// it's used for relative route resolution
let matched = ArcRwSignal::new(self.as_matched().to_string());
// the trigger and channel will be used to send new boxed AnyViews to the Outlet;
// whenever we match a different route, the trigger will be triggered and a new view will
// be sent through the channel to be rendered by the Outlet
@ -259,8 +272,10 @@ where
trigger,
params,
owner: owner.clone(),
matched: ArcRwSignal::new(self.as_matched().to_string()),
tx: tx.clone(),
rx: Arc::new(Mutex::new(Some(rx))),
base: base.clone(),
};
outlets.push(outlet.clone());
@ -281,12 +296,13 @@ where
// this is important because to build the view, we need access to the outlet
// and the outlet will be returned from building this child
if let Some(child) = child {
child.build_nested_route(outlets, &owner);
child.build_nested_route(base, outlets, &owner);
}
}
fn rebuild_nested_route(
self,
base: Option<Oco<'static, str>>,
items: &mut usize,
outlets: &mut Vec<RouteContext<R>>,
parent: &Owner,
@ -295,7 +311,7 @@ where
match current {
// if there's nothing currently in the routes at this point, build from here
None => {
self.build_nested_route(outlets, parent);
self.build_nested_route(base, outlets, parent);
}
Some(current) => {
// a unique ID for each route, which allows us to compare when we get new matches
@ -304,11 +320,20 @@ where
let id = self.as_id();
// whether the route is the same or different, we always need to
// 1) update the params, and
// 1) update the params (if they've changed),
// 2) update the matched path (if it's changed),
// 2) access the view and children
current
.params
.set(self.to_params().into_iter().collect::<ParamsMap>());
let new_params =
self.to_params().into_iter().collect::<ParamsMap>();
if current.params.read() != new_params {
current.params.set(new_params);
}
let new_match = self.as_matched();
if &*current.matched.read() != new_match {
current.matched.set(new_match);
}
let (view, child) = self.into_view_and_child();
// if the IDs don't match, everything below in the tree needs to be swapped:
@ -345,7 +370,11 @@ where
// if this children has matches, then rebuild the lower section of the tree
if let Some(child) = child {
let mut new_outlets = Vec::new();
child.build_nested_route(&mut new_outlets, &owner);
child.build_nested_route(
base,
&mut new_outlets,
&owner,
);
outlets.extend(new_outlets);
}
@ -357,7 +386,7 @@ where
if let Some(child) = child {
let owner = current.owner.clone();
*items += 1;
child.rebuild_nested_route(items, outlets, &owner);
child.rebuild_nested_route(base, items, outlets, &owner);
}
}
}
@ -401,6 +430,7 @@ where
owner,
tx,
rx,
..
} = ctx;
let rx = rx.lock().or_poisoned().take().expect(
"Tried to render <Outlet/> but could not find the view receiver. Are \

View File

@ -323,11 +323,16 @@ where
let (el, prev) = state;
match (self, prev.as_mut()) {
(None, None) => {}
(None, Some(_)) => R::remove_attribute(el, key),
(None, Some(_)) => {
R::remove_attribute(el, key);
*prev = None;
}
(Some(value), None) => {
*prev = Some(value.build(el, key));
}
(Some(new), Some(old)) => new.rebuild(key, old),
(Some(new), Some(old)) => {
new.rebuild(key, old);
}
}
}
}

View File

@ -150,6 +150,44 @@ macro_rules! prop_type {
*prev = value;
}
}
impl<R> IntoProperty<R> for Option<$prop_type>
where
R: DomRenderer,
{
type State = (R::Element, JsValue);
fn hydrate<const FROM_SERVER: bool>(
self,
el: &R::Element,
key: &str,
) -> Self::State {
let was_some = self.is_some();
let value = self.into();
if was_some {
R::set_property(el, key, &value);
}
(el.clone(), value)
}
fn build(self, el: &R::Element, key: &str) -> Self::State {
let was_some = self.is_some();
let value = self.into();
if was_some {
R::set_property(el, key, &value);
}
(el.clone(), value)
}
fn rebuild(self, state: &mut Self::State, key: &str) {
let (el, prev) = state;
let value = self.into();
if value != *prev {
R::set_property(el, key, &value);
}
*prev = value;
}
}
};
}

View File

@ -1,7 +1,10 @@
use super::RenderEffectState;
use crate::{html::class::IntoClass, renderer::DomRenderer};
use reactive_graph::{effect::RenderEffect, signal::guards::ReadGuard};
use std::{borrow::Borrow, ops::Deref};
use std::{
borrow::{Borrow, Cow},
ops::Deref,
};
impl<F, C, R> IntoClass<R> for F
where
@ -148,6 +151,92 @@ where
}
}
impl<F, T, R> IntoClass<R> for (Vec<Cow<'static, str>>, F)
where
F: FnMut() -> T + Send + 'static,
T: Borrow<bool>,
R: DomRenderer,
{
type State = RenderEffectState<(R::ClassList, bool)>;
fn html_len(&self) -> usize {
self.0.iter().map(|n| n.len()).sum()
}
fn to_html(self, class: &mut String) {
let (names, mut f) = self;
let include = *f().borrow();
if include {
for name in names {
<&str as IntoClass<R>>::to_html(&name, class);
}
}
}
fn hydrate<const FROM_SERVER: bool>(self, el: &R::Element) -> Self::State {
// TODO FROM_SERVER vs template
let (names, mut f) = self;
let class_list = R::class_list(el);
RenderEffect::new(move |prev: Option<(R::ClassList, bool)>| {
let include = *f().borrow();
if let Some((class_list, prev)) = prev {
if include {
if !prev {
for name in &names {
// TODO multi-class optimizations here
R::add_class(&class_list, name);
}
}
} else if prev {
for name in &names {
R::remove_class(&class_list, name);
}
}
}
(class_list.clone(), include)
})
.into()
}
fn build(self, el: &R::Element) -> Self::State {
let (names, mut f) = self;
let class_list = R::class_list(el);
RenderEffect::new(move |prev: Option<(R::ClassList, bool)>| {
let include = *f().borrow();
match prev {
Some((class_list, prev)) => {
if include {
for name in &names {
if !prev {
R::add_class(&class_list, name);
}
}
} else if prev {
for name in &names {
R::remove_class(&class_list, name);
}
}
}
None => {
if include {
for name in &names {
R::add_class(&class_list, name);
}
}
}
}
(class_list.clone(), include)
})
.into()
}
fn rebuild(self, _state: &mut Self::State) {
// TODO rebuild?
}
}
impl<G, R> IntoClass<R> for ReadGuard<String, G>
where
G: Deref<Target = String> + Send,