Skip to content

Commit

Permalink
link options + listbox component
Browse files Browse the repository at this point in the history
  • Loading branch information
pogseal committed Oct 14, 2024
1 parent 5621d81 commit 5b1a49f
Show file tree
Hide file tree
Showing 4 changed files with 602 additions and 49 deletions.
219 changes: 219 additions & 0 deletions app/components/Listbox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import * as Headless from "@headlessui/react";
import clsx from "clsx";
import { Fragment } from "react";

export function Listbox<T>({
className,
placeholder,
autoFocus,
"aria-label": ariaLabel,
children: options,
...props
}: {
className?: string;
placeholder?: React.ReactNode;
autoFocus?: boolean;
"aria-label"?: string;
children?: React.ReactNode;
} & Omit<Headless.ListboxProps<typeof Fragment, T>, "as" | "multiple">) {
return (
<Headless.Listbox {...props} multiple={false}>
<Headless.ListboxButton
autoFocus={autoFocus}
data-slot="control"
aria-label={ariaLabel}
className={clsx([
className,
// Basic layout
"group relative block w-full",
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
"before:absolute before:inset-px before:rounded-[calc(theme(borderRadius.lg)-1px)] before:bg-white before:shadow",
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
"dark:before:hidden",
// Hide default focus styles
"focus:outline-none",
// Focus ring
"after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-inset after:ring-transparent after:data-[focus]:ring-2 after:data-[focus]:ring-blue-500",
// Disabled state
"data-[disabled]:opacity-50 before:data-[disabled]:bg-zinc-950/5 before:data-[disabled]:shadow-none",
])}
>
<Headless.ListboxSelectedOption
as="span"
options={options}
placeholder={
placeholder && (
<span className="block truncate text-zinc-500">
{placeholder}
</span>
)
}
className={clsx([
// Basic layout
"relative block w-full appearance-none rounded-lg py-[calc(theme(spacing[2.5])-1px)] tablet:py-[calc(theme(spacing[1.5])-1px)]",
// Set minimum height for when no value is selected
"min-h-11 tablet:min-h-9",
// Horizontal padding
"pl-[calc(theme(spacing[3.5])-1px)] pr-[calc(theme(spacing.7)-1px)] tablet:pl-[calc(theme(spacing.3)-1px)]",
// Typography
"text-left text-base/6 text-zinc-950 placeholder:text-zinc-500 tablet:text-sm/6 dark:text-white forced-colors:text-[CanvasText]",
// Border
"border border-zinc-950/10 group-data-[active]:border-zinc-950/20 group-data-[hover]:border-zinc-950/20 dark:border-white/10 dark:group-data-[active]:border-white/20 dark:group-data-[hover]:border-white/20",
// Background color
"bg-transparent dark:bg-white/5",
// Invalid state
"group-data-[invalid]:border-red-500 group-data-[invalid]:group-data-[hover]:border-red-500 group-data-[invalid]:dark:border-red-600 group-data-[invalid]:data-[hover]:dark:border-red-600",
// Disabled state
"group-data-[disabled]:border-zinc-950/20 group-data-[disabled]:opacity-100 group-data-[disabled]:dark:border-white/15 group-data-[disabled]:dark:bg-white/[2.5%] dark:data-[hover]:group-data-[disabled]:border-white/15",
])}
/>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<svg
className="size-5 stroke-zinc-500 group-data-[disabled]:stroke-zinc-600 tablet:size-4 dark:stroke-zinc-400 forced-colors:stroke-[CanvasText]"
viewBox="0 0 16 16"
aria-hidden="true"
fill="none"
>
<path
d="M5.75 10.75L8 13L10.25 10.75"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M10.25 5.25L8 3L5.75 5.25"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
</Headless.ListboxButton>
<Headless.ListboxOptions
transition
anchor="selection start"
className={clsx(
// Anchor positioning
"[--anchor-offset:-1.625rem] [--anchor-padding:theme(spacing.4)] tablet:[--anchor-offset:-1.375rem]",
// Base styles
"isolate w-max min-w-[calc(var(--button-width)+1.75rem)] select-none scroll-py-1 rounded-xl p-1",
// Invisible border that is only visible in `forced-colors` mode for accessibility purposes
"outline outline-1 outline-transparent focus:outline-none",
// Handle scrolling when menu won't fit in viewport
"overflow-y-scroll overscroll-contain",
// Popover background
"bg-white/75 backdrop-blur-xl dark:bg-zinc-800/75",
// Shadows
"shadow-lg ring-1 ring-zinc-950/10 dark:ring-inset dark:ring-white/10 z-50",
// Transitions
"transition-opacity duration-100 ease-in data-[transition]:pointer-events-none data-[closed]:data-[leave]:opacity-0",
)}
>
{options}
</Headless.ListboxOptions>
</Headless.Listbox>
);
}

export function ListboxOption<T>({
children,
className,
...props
}: { className?: string; children?: React.ReactNode } & Omit<
Headless.ListboxOptionProps<"div", T>,
"as" | "className"
>) {
let sharedClasses = clsx(
// Base
"flex min-w-0 items-center",
// Icons
"[&>[data-slot=icon]]:size-5 [&>[data-slot=icon]]:shrink-0 tablet:[&>[data-slot=icon]]:size-4",
"[&>[data-slot=icon]]:text-zinc-500 [&>[data-slot=icon]]:group-data-[focus]/option:text-white [&>[data-slot=icon]]:dark:text-zinc-400",
"forced-colors:[&>[data-slot=icon]]:text-[CanvasText] forced-colors:[&>[data-slot=icon]]:group-data-[focus]/option:text-[Canvas]",
// Avatars
"[&>[data-slot=avatar]]:-mx-0.5 [&>[data-slot=avatar]]:size-6 tablet:[&>[data-slot=avatar]]:size-5",
);

return (
<Headless.ListboxOption as={Fragment} {...props}>
{({ selectedOption }) => {
if (selectedOption) {
return (
<div className={clsx(className, sharedClasses)}>
{children}
</div>
);
}

return (
<div
className={clsx(
// Basic layout
"group/option grid cursor-default grid-cols-[theme(spacing.5),1fr] items-baseline gap-x-2 rounded-lg py-2.5 pl-2 pr-3.5 tablet:grid-cols-[theme(spacing.4),1fr] tablet:py-1.5 tablet:pl-1.5 tablet:pr-3",
// Typography
"text-base/6 text-zinc-950 tablet:text-sm/6 dark:text-white forced-colors:text-[CanvasText]",
// Focus
"outline-none data-[focus]:bg-blue-500 data-[focus]:text-white",
// Forced colors mode
"forced-color-adjust-none forced-colors:data-[focus]:bg-[Highlight] forced-colors:data-[focus]:text-[HighlightText]",
// Disabled
"data-[disabled]:opacity-50",
)}
>
<svg
className="relative hidden size-5 self-center stroke-current group-data-[selected]/option:inline tablet:size-4"
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
>
<path
d="M4 8.5l3 3L12 4"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span
className={clsx(className, sharedClasses, "col-start-2")}
>
{children}
</span>
</div>
);
}}
</Headless.ListboxOption>
);
}

export function ListboxLabel({
className,
...props
}: React.ComponentPropsWithoutRef<"span">) {
return (
<span
{...props}
className={clsx(
className,
"ml-2.5 truncate first:ml-0 tablet:ml-2 tablet:first:ml-0",
)}
/>
);
}

export function ListboxDescription({
className,
children,
...props
}: React.ComponentPropsWithoutRef<"span">) {
return (
<span
{...props}
className={clsx(
className,
"flex flex-1 overflow-hidden text-zinc-500 before:w-2 before:min-w-0 before:shrink group-data-[focus]/option:text-white dark:text-zinc-400",
)}
>
<span className="flex-1 truncate">{children}</span>
</span>
);
}
Loading

0 comments on commit 5b1a49f

Please sign in to comment.