Using the shad/cn accordion with state in the URL
Using the shad/cn accordion with state in the URL
Today we are going to be thinking about ways to deep link using an accordion from shad/cn. There are a few tasks that I will be looking to accomplish.
- Accordion should exist on the page.
- The user should be able to interact with it like normal.
- The url should change to match the open state of the accordions.
- The user should be able to copy and past the URL. Open accordions will stay open.
The URL should be something like /accordion/{open}
where open can
possibly be a list of all the open accordion items. We can also use
something like /accordion?open={open}
What is the choice in URL path that we would like to use and why?
we can just make a helper function that can breakdown the data with just the comma separated list.
/accordion/name/place/zip/city
This would require another file path that would take in the data. This doesn't seem like the best idea to make the component easily moved somewhere else. So we we need this to be build in a way that is dependent on Next.js file paths? Or do I need it to be a bit easier to drag and drop with a query parameter option.
This is a solution, but I'm not sure it's the solution that I will be choosing on this one. There is a high likelihood that it will be easier to setup with the query parameter. While also maintaining the ability to use it on different pages (possibly?).
/accordion?open=name,place,zip,city
Old school and how data has been passed around the URL for a long time. Honestly both methods allow for data to be shared. This query method. Is just easy to pull out and easy to put in. We can make a hook to allow for the conversion. Move this component into it's own block of code as a page.
How can we start to think about how this can be built?
Currently we have a basic setup for a project that involves having a shad/cn setup for components. While also having a nextjs project built. What we have to do next. Is figure out how we are going to build a form with a whole bunch of accordions that have to also have different state associated with them.
We are starting this off in it's own component. That will exists on a page. That passes down data. So we won't worry about how the url will be parsed at this point. We will be worried about the props being passed into the component. That have already been processed from the page itself. Not the component itself. There is no url state there.
First we need to understand what the props could be. We want something
that can be passed in to change the Url query parameters. We will start
with a simple changeUrlQuery
. This will be a function that is used to
take the open array and place it into the URL based on the component one
level up.
export type DataAccordionFormProps = {
changeUrlQuery: (newUrlCsv: string) => Promise<void>;
};
Now we want to extend our context state for what we want to control inside of the component. This will include the accordion open state and the useState set method.
type DataAccordionFormContextValue = DataAccordionFormProps & {
homeDetailsOpenSections: string[];
setHomeDetailsOpenSections: React.Dispatch<React.SetStateAction<string[]>>;
planeDetailsOpenSections: string[];
setPlaneDetailsOpenSections: React.Dispatch<React.SetStateAction<string[]>>;
};
Now that we have a basic setup for context that we can use. Let's create the context to use. Then also export a hook for accessing the content in the file.
const DataAccordionFormContext = createContext<DataAccordionFormContextValue>({} as DataAccordionFormContextValue);
export const useDataAccordionForm = () => {
return useContext(DataAccordionFormContext);
};
Now that we have the context setup and typed at the most basic setup. We will start to fill in the component that we can use elsewhere. There will be some states being created and some handler functions to edit the state based on a change and to change the URL query on easy set call.
The strings need to be encoded and decoded for the URL use.
export function DataAccordionForm(props: DataAccordionFormProps) {
const [homeDetailsOpenSections, setHomeDetailsOpenSections] = useState([]);
const [planeDetailsOpenSections, setPlaneDetailsOpenSections] = useState([]);
const contextValue: DataAccordionFormContextValue = useMemo(() => {
const handleSetHomeDetailsOpenSections = (value: string[]) => {
const homeDetailsOpenEncoded = encodeURIComponent(value.toString());
const planeDetailsOpenEncoded = encodeURIComponent(planeDetailsOpenSections.toString());
props.changeUrlQuery(
`homeDetailsOpen=${homeDetailsOpenEncoded}&planeDetailsOpen=${planeDetailsOpenEncoded}`
);
setHomeDetailsOpenSections(value);
};
const handleSetPlaneDetailsOpenSections = (value: string[]) => {
const homeDetailsOpenEncoded = encodeURIComponent(homeDetailsOpenSections.toString());
const planeDetailsOpenEncoded = encodeURIComponent(value.toString());
props.changeUrlQuery(
`homeDetailsOpen=${homeDetailsOpenEncoded}&planeDetailsOpen=${planeDetailsOpenEncoded}`
);
setPlaneDetailsOpenSections(value);
};
return {
...props,
homeDetailsOpenSections,
setHomeDetailsOpenSections: handleSetHomeDetailsOpenSections,
planeDetailsOpenSections,
setPlaneDetailsOpenSections: handleSetPlaneDetailsOpenSections,
};
}, [homeDetailsOpenSections, planeDetailsOpenSections, props]);
return (
<DataAccordionFormContext.Provider value={contextValue}>
<HomeDetailsSection />
<PlaneDetailsSection />
</DataAccordionFormContext.Provider>
);
}
After the basic context setup
Now we are setup on the sending out a new URL when the state changes. Now we have to figure out the way that we will take in the URL from the first render. To allow for use to setup the form when it's open where it's told to be based on the query params.
Let's go over some of the pieces from the code above. Since there was a whole lot of lines that are unexplained.
The basic state of this component. We control which sections are open from the top of the component.
const [homeDetailsOpenSections, setHomeDetailsOpenSections] = useState([]);
const [planeDetailsOpenSections, setPlaneDetailsOpenSections] = useState([]);
We now are going to create the contextValue. Which are going to have a few addition that process the arrays and send out the new query params trigger. The props will be passed directly into this, but the set methods will have a few extra changes.
const contextValue: DataAccordionFormContextValue = useMemo(() => {
const handleSetHomeDetailsOpenSections = (value: string[]) => {/*...*/};
const handleSetPlaneDetailsOpenSections = (value: string[]) => {/*...*/};
return {
...props,
homeDetailsOpenSections,
setHomeDetailsOpenSections: handleSetHomeDetailsOpenSections,
planeDetailsOpenSections,
setPlaneDetailsOpenSections: handleSetPlaneDetailsOpenSections,
};
}, [homeDetailsOpenSections, planeDetailsOpenSections, props]);
These changes will take in a value. Then encode the value with
encodeURIComponent. If the value is updated. It will send a query string
update to the changeUrlQuery
prop. From the main component in the app.
const handleSetHomeDetailsOpenSections = (value: string[]) => {
const homeDetailsOpenEncoded = encodeURIComponent(value.toString());
const planeDetailsOpenEncoded = encodeURIComponent(planeDetailsOpenSections.toString());
props.changeUrlQuery(
`homeDetailsOpen=${homeDetailsOpenEncoded}&planeDetailsOpen=${planeDetailsOpenEncoded}`
);
setHomeDetailsOpenSections(value);
};
const handleSetPlaneDetailsOpenSections = (value: string[]) => {
const homeDetailsOpenEncoded = encodeURIComponent(homeDetailsOpenSections.toString());
const planeDetailsOpenEncoded = encodeURIComponent(value.toString());
props.changeUrlQuery(
`homeDetailsOpen=${homeDetailsOpenEncoded}&planeDetailsOpen=${planeDetailsOpenEncoded}`
);
setPlaneDetailsOpenSections(value);
};
We would have the return function like all components need. That will wrap the provider and add the context value.
return (
<DataAccordionFormContext.Provider value={contextValue}>
<HomeDetailsSection />
<PlaneDetailsSection />
</DataAccordionFormContext.Provider>
);
Now we have the basic setup for handling the array and the sending out a URL updated based on a prop function. So this can be used on different pages as long as there will be privilege.
How do we process the data pulled down from the URL query params?
This is something that might be wrong at first. The main idea is going to be coming up with a solution. Then optimizing the solution. Sometimes you just have to think about all of the rendering capabilities and how to set things up correctly. After you've already had something working.
Let's add new types to our props. That will allow for the open array passed in from the URL query params of the page. To see if this will allow for us to control the page on load.
homeDetailsOpenArray?: string[];
planeDetailsOpenArray?: string[];
Now with this addition. We can place these arrays into the state that we created earlier.
const [homeDetailsOpenSections, setHomeDetailsOpenSections] =
useState(props.homeDetailsOpenArray || []);
const [planeDetailsOpenSections, setPlaneDetailsOpenSections] =
useState(props.planeDetailsOpenArray || []);
So this will add the passed in values. Into the state from the beginning. Which will allow for the open array to be passed around.
Just a reminder the accordion that we will be using is from the shad/cn components and it looks like:
"use client"
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "@radix-ui/react-icons"
import { cn } from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
So when we want to understand how the functionality that I'm building is working out. This is the accordion that we will be playing with.
So if we create a card component that has a Accordion wrapper. We can use the multiple type and have and array of values. Depending on the AccordionItem value.
<Accordion type="multiple" onValueChange={setOpenSections} value={openSections}>
<AccordionItem value={accordionValue}>
<AccordionTrigger className="py-0 pr-6">
<CardHeader className="flex flex-col gap-y-3">
<CardTitle className="flex gap-x-2">
{title}
</CardTitle>
<CardDescription className="text-left">{description}</CardDescription>
</CardHeader>
</AccordionTrigger>
<AccordionContent className="">{props.children}</AccordionContent>
</AccordionItem>
</Accordion>
At the point we will just layer in other AccordionItem components. This will still work with the open and close. An example of a child of this component would be.
export function AccordionItem({ title, description, children, isCompleted }: AccordionItemProps) {
return (
<AccordionItem value={title}>
<AccordionTrigger className="border-border bg-surface-subtle border-y px-4 md:px-6">
<div className="flex gap-x-2">
<div className="">
<h2 className="flex gap-x-2">{title}</h2>
<p className="text-fg-muted text-left text-sm">{description}</p>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="p-4">{children}</AccordionContent>
</AccordionItem>
);
}
With these two components. We will be able to make layers accordions. That allow you to control the path of the url from an array of strings. Which is a powerful function of the accordion.
Let's use this component in the application
Let's build out the component inside of the Nextjs app.
- Create a client component because we will be using hooks that require it.
- Pull out the query parameters with
useSearchParams()
. - Pull out path name from
usePathName()
. -
We will get the values out and
decodeURIComponent
to create a string of csv values. - Split those csv strings on comma.
- Pull in router to change the page URL query params.
- Create a function to construct the URL path with the pathname and a query string.
- Router push the new string URL with query string.
- Now pass in the decoded arrays from the query params and pass down the changeUrlQuery function to the AccordionForm component.
'use client';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { AccordionForm } from '@../ui/AccordionForm/AccordionForm';
export default function Accordion() {
const searchParams = useSearchParams();
const pathname = usePathname();
const homeDetailsOpen = searchParams.get('homeDetailsOpen') || '';
const planeDetailsOpen = searchParams.get('planeDetailsOpen') || '';
const homeDetailsOpenArray = decodeURIComponent(homeDetailsOpen).split(',');
const planeDetailsOpenArray = decodeURIComponent(planeDetailsOpen).split(',');
const router = useRouter();
const handleUrlQueryChange = (newUrlQueryString: string) => {
const url = `${pathname}?${newUrlQueryString}`;
router.push(url);
};
return (
<div className="p-20">
<AccordionForm
changeUrlQuery={handleUrlQueryChange}
homeDetailsOpenArray={homeDetailsOpenArray}
planeDetailsOpenArray={planeDetailsOpenArray}
/>
</div>
);
}
Now our component is being pulled altogether. We separated the component into it's own separate piece. To allow for an easy use inside of a page that has access to the router and navigation. So all that needs to be passed down in the data than the internal component can build.
Why do we want to use the accordion?
It opens smooth and it's got a bit of animation build in from the tailwind setup. So being able to use this and have the state stored in the URL is something that can work.
💀 There is an error above and we should talk about it.
We included state inside of the component. When the state should be coming from the URL itself. This way we can allow for the forward and back keys to work.
const [homeDetailsOpenSections, setHomeDetailsOpenSections] =
useState(props.homeDetailsOpenArray || []);
const [planeDetailsOpenSections, setPlaneDetailsOpenSections] =
useState(props.planeDetailsOpenArray || []);
These state sections would have to be deleted. Then some of the prop values will need to replace the occurrences of the values being deleted.
homeDetailsOpenArray: string[];
planeDetailsOpenArray: string[];
homeDetailsOpenSections
would be props.homeDetailsOpenArray
and
planeDetailsOpenSections
would be props.planeDetailsOpenArray
. In
the code above. This will allow for the URL query string to pass down
every time the URL changes. Completely pushing up the state to the URL.
We also want to use the scroll false in the router.push(val, { scroll: false})
. So that the screen doesn't jump around every time we use the
router push call. With the state being updated to allow for the page to
always stay current.
This seems to be the solution that works so far.
Could we bring state into the component with a hook?
Would we want to have this structured a bit differently. When this page was being build. There was the need for the state to be lifted up to the top. Since the way we were building was in a storybook component. To allow for editing and having a separate work environment. Where I could control all state around render.
Now we want to use the state from the router. Which is something that was build for nextjs. That will allow us to set a static state. To load and have us see the page.
export const Default: Story = {
args: {},
parameters: {
nextjs: {
navigation: {
pathname: '/',
query: {
foo: 'something',
bar: 'something else',
},
},
},
},
};
This works in nextjs because there is a decorator around the loading. So adding into the navigation query object. Brings those into the "fake" container. This is only static though.
Now that we allowed query state in storybook
How can we change our form component to interact directly with the router and paths. So let's move what we have from the component into a hook. That will allow us to use this functionality around the application more. This will always have to be on a top layer component. With only a single state from the query. Or it will replace it completely with the hook we build.
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
type QueryParams = Record<string, string[]>;
export const useQueryParameterState = (
parameterNames: string[]
): [QueryParams, (newUrlQueryString: string) => void] => {
const searchParams = useSearchParams();
const pathname = usePathname();
const router = useRouter();
const getDecodedParams = (paramName: string): string[] => {
const paramValue = searchParams.get(paramName) || '';
return decodeURIComponent(paramValue).split(',');
};
const extractedParams = parameterNames.reduce((acc, paramName) => {
acc[paramName] = getDecodedParams(paramName);
return acc;
}, {} as QueryParams);
const handleUrlQueryChange = (newUrlQueryString: string) => {
const url = `${pathname}?${newUrlQueryString}`;
router.push(url, { scroll: false });
};
return [extractedParams, handleUrlQueryChange];
};
const [extractedParams, handleUrlQueryChange] = useQueryParameterState(['homeDetailsOpen', 'planeDetailsOpen']);
// To access you can use the parameter name
// extractedParams.homeDetailsOpen
// extractedParams.planeDetailsOpen
We could keep going with this and we will offline. It's being refactored a few more times. Unfortunately I will not be running through the next iterations here. It's time to write about something else for a bit. Hope you've learned a few things from this. 🚀✅