Skip to content

Commit c5cd319

Browse files
Add search functionality to Library (#85)
Issue #68 Adds a search feature to the library page that allows users to find sessions by title, description, instructor name, or category. The search box appears in the navigation bar on desktop and as a full-screen dialog on mobile. As users type, sessions are filtered in real-time and displayed in a grid layout. When the search is active, the featured carousel and other library sections are hidden to focus on results. ### Video demo links - [Desktop](https://cap.link/xme9607m1rwnd2h) - [Mobile](https://cap.link/2s6dv5gp9jyr3e9) ### Screenshots **Desktop – light mode** <img width="3468" height="2298" src="https://github.com/user-attachments/assets/2ccf2f37-87c6-4e2c-9c39-9fbe4f1d1df6" /> **Desktop – dark mode** <img width="3468" height="2298" src="https://github.com/user-attachments/assets/68419c27-4e70-495e-ae39-b91d7b7c8334" /> **Mobile – collapsed search box (default)** <img width="380" src="https://github.com/user-attachments/assets/d5af6a96-0f06-4d6e-83c3-35027d90b061" /> **Mobile – expanded search box** <img width="380" src="https://github.com/user-attachments/assets/37363af5-43a9-43c3-9bc1-824a76ef4023" /> ### Changes - Created reusable `Input` and `MaskedIcon` UI components - Built `SearchBox` and `SearchResultsGrid` components for search functionality - Updated library controller to provide search icon asset - Added search container to navigation bar via React portal - Implemented mobile-responsive search dialog with keyboard navigation - Added accessibility features including ARIA labels and screen reader announcements - Search filters across session title, description, creator, and categories using multi-token matching --- AI disclaimer: This PR was implemented with AI assistance (Claude Sonnet 4.5, gpt-5-codex, gpt-5-high) for code generation. All code was self-reviewed and directed throughout the development process.
1 parent 7086dda commit c5cd319

File tree

9 files changed

+431
-9
lines changed

9 files changed

+431
-9
lines changed

app/controllers/library_controller.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ def index
4747
assets: {
4848
downloadIcon: view_context.asset_path("download.svg"),
4949
backIcon: view_context.asset_path("arrow-left.svg"),
50+
searchIcon: view_context.asset_path("search.svg"),
5051
},
5152
featuredHeroImages: featured_hero_images,
5253
initialThumbnails: initial_thumbnails,
@@ -138,7 +139,8 @@ def library_nav_markup
138139
view_context.tag.span(class: "btn btn--reversed btn--faux room--current") do
139140
view_context.tag.h1("Library", class: "room__contents txt-medium overflow-ellipsis")
140141
end,
141-
view_context.link_back
142+
view_context.link_back,
143+
view_context.tag.div("", id: "library-search-root", class: "flex w-full")
142144
].compact
143145
).to_s
144146
end
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { forwardRef, type ReactNode } from "react"
2+
3+
import { cn } from "@/lib/utils"
4+
import { MaskedIcon } from "./masked-icon"
5+
6+
interface InputProps extends React.ComponentProps<"input"> {
7+
icon?: ReactNode | string
8+
}
9+
10+
const Input = forwardRef<HTMLInputElement, InputProps>(
11+
({ className, type, icon, ...props }, ref) => {
12+
const renderIcon = () => {
13+
if (!icon) return null
14+
if (typeof icon === "string") {
15+
return <MaskedIcon src={icon} />
16+
}
17+
return icon
18+
}
19+
20+
return (
21+
<div className="relative flex w-full items-center pt-[1px]">
22+
{icon ? (
23+
<span className="text-muted-foreground pointer-events-none absolute left-3 inline-flex size-4 items-center justify-center">
24+
{renderIcon()}
25+
</span>
26+
) : null}
27+
<input
28+
ref={ref}
29+
type={type}
30+
data-slot="input"
31+
className={cn(
32+
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground bg-background dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md px-3 py-1 text-base shadow-[0_0_0_1px_var(--control-border)] transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
33+
"focus:[box-shadow:0_0_0_1px_var(--color-selected-dark),0_0_0_var(--hover-size)_var(--color-selected-dark)!important] focus:[filter:var(--hover-filter)] focus:[--hover-color:var(--color-selected-dark)] focus-visible:ring-0",
34+
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
35+
icon ? "pl-10" : undefined,
36+
className,
37+
)}
38+
{...props}
39+
/>
40+
</div>
41+
)
42+
},
43+
)
44+
45+
Input.displayName = "Input"
46+
47+
export { Input }
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { forwardRef } from "react"
2+
import { cn } from "@/lib/utils"
3+
4+
interface MaskedIconProps extends React.HTMLAttributes<HTMLSpanElement> {
5+
src?: string
6+
sizeClassName?: string
7+
}
8+
9+
export const MaskedIcon = forwardRef<HTMLSpanElement, MaskedIconProps>(
10+
({ src, sizeClassName = "size-4", className, ...rest }, ref) => {
11+
if (!src) return null
12+
13+
return (
14+
<span
15+
ref={ref}
16+
aria-hidden
17+
className={cn(sizeClassName, className)}
18+
style={{
19+
WebkitMaskImage: `url(${src})`,
20+
maskImage: `url(${src})`,
21+
WebkitMaskRepeat: "no-repeat",
22+
maskRepeat: "no-repeat",
23+
WebkitMaskSize: "contain",
24+
maskSize: "contain",
25+
WebkitMaskPosition: "center",
26+
maskPosition: "center",
27+
backgroundColor: "currentColor",
28+
}}
29+
{...rest}
30+
/>
31+
)
32+
},
33+
)
34+
35+
MaskedIcon.displayName = "MaskedIcon"

app/frontend/pages/library/components/featured-carousel/featured-carousel.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"use client"
22

33
import { useState } from "react"
4+
import type { HTMLAttributes } from "react"
45
import { router } from "@inertiajs/react"
56

67
import {
@@ -11,6 +12,7 @@ import {
1112
} from "@/components/ui/carousel"
1213

1314
import type { LibrarySessionPayload } from "../../types"
15+
import { cn } from "@/lib/utils"
1416
import {
1517
useSlides,
1618
useCarouselState,
@@ -21,14 +23,16 @@ import { Slide } from "./slide"
2123
import { NavButtons } from "./nav-buttons"
2224
import { Indicators } from "./indicators"
2325

24-
export interface FeaturedCarouselProps {
26+
export interface FeaturedCarouselProps extends HTMLAttributes<HTMLElement> {
2527
sessions: LibrarySessionPayload[]
2628
heroImagesById?: Record<string, string>
2729
}
2830

2931
export function FeaturedCarousel({
3032
sessions,
3133
heroImagesById,
34+
className,
35+
...sectionProps
3236
}: FeaturedCarouselProps) {
3337
const [api, setApi] = useState<CarouselApi>()
3438

@@ -80,7 +84,11 @@ export function FeaturedCarousel({
8084
onBlur={onRegionBlur}
8185
onMouseEnter={() => autoplay.pause()}
8286
onMouseLeave={() => autoplay.resume()}
83-
className="relative mx-auto w-full max-w-7xl px-8 pt-8 select-none focus-visible:ring-2 focus-visible:ring-[#00ADEF] focus-visible:ring-offset-2 focus-visible:outline-none sm:px-12 md:px-16 lg:px-20 lg:pt-4 xl:pt-0 dark:focus-visible:ring-[#00ADEF]"
87+
className={cn(
88+
"relative mx-auto w-full max-w-7xl px-8 pt-8 select-none focus-visible:ring-2 focus-visible:ring-[#00ADEF] focus-visible:ring-offset-2 focus-visible:outline-none sm:px-12 md:px-16 lg:px-20 lg:pt-4 xl:pt-0 dark:focus-visible:ring-[#00ADEF]",
89+
className,
90+
)}
91+
{...sectionProps}
8492
>
8593
<div className="relative">
8694
<Carousel
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export { SearchBox } from "./search_box"
2+
export type { SearchBoxProps } from "./search_box"
3+
export { SearchResultsGrid } from "./search_results_grid"
4+
export type { SearchResultsGridProps } from "./search_results_grid"
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { forwardRef, type ChangeEvent, type FormEvent } from "react"
2+
3+
import { cn } from "@/lib/utils"
4+
import { Input } from "@/components/ui/input"
5+
6+
export interface SearchBoxProps {
7+
iconSrc?: string
8+
value: string
9+
onChange: (value: string) => void
10+
onSubmit?: (value: string) => void
11+
containerClassName?: string
12+
inputId?: string
13+
autoFocus?: boolean
14+
}
15+
16+
export const SearchBox = forwardRef<HTMLInputElement, SearchBoxProps>(
17+
function SearchBox(
18+
{
19+
iconSrc,
20+
value,
21+
onChange,
22+
onSubmit,
23+
containerClassName,
24+
inputId,
25+
autoFocus,
26+
},
27+
ref,
28+
) {
29+
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
30+
onChange(event.target.value)
31+
}
32+
33+
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
34+
event.preventDefault()
35+
onSubmit?.(value)
36+
}
37+
38+
return (
39+
<form
40+
role="search"
41+
className={cn(
42+
"relative mr-13 ml-auto flex w-full max-w-xs lg:mr-18",
43+
containerClassName,
44+
)}
45+
onSubmit={handleSubmit}
46+
>
47+
<div className="w-full">
48+
<label className="sr-only" htmlFor={inputId ?? "library-search"}>
49+
Search library
50+
</label>
51+
<Input
52+
id={inputId ?? "library-search"}
53+
type="search"
54+
icon={iconSrc}
55+
placeholder="Search sessions, instructors, topics"
56+
aria-label="Search library"
57+
autoComplete="off"
58+
value={value}
59+
onChange={handleChange}
60+
ref={ref}
61+
autoFocus={autoFocus}
62+
/>
63+
</div>
64+
</form>
65+
)
66+
},
67+
)
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import VideoCard from "../video_card"
2+
import type { LibrarySessionPayload, VimeoThumbnailPayload } from "../../types"
3+
4+
export interface SearchResultsGridProps {
5+
sessions: LibrarySessionPayload[]
6+
thumbnails?: Record<string, VimeoThumbnailPayload>
7+
backIcon?: string
8+
}
9+
10+
export function SearchResultsGrid({
11+
sessions,
12+
thumbnails,
13+
backIcon,
14+
}: SearchResultsGridProps) {
15+
const resultsCount = sessions.length
16+
17+
if (resultsCount === 0) {
18+
return (
19+
<>
20+
<div
21+
className="sr-only"
22+
role="status"
23+
aria-live="polite"
24+
aria-atomic="true"
25+
>
26+
No sessions found. Try a different search.
27+
</div>
28+
<div className="text-muted-foreground mx-auto max-w-6xl px-6 pt-4 text-center text-base">
29+
No sessions found. Try a different search.
30+
</div>
31+
</>
32+
)
33+
}
34+
35+
return (
36+
<>
37+
<div
38+
className="sr-only"
39+
role="status"
40+
aria-live="polite"
41+
aria-atomic="true"
42+
>
43+
{resultsCount === 1
44+
? "1 session found"
45+
: `${resultsCount} sessions found`}
46+
</div>
47+
<div className="mx-auto w-full max-w-7xl px-6 pt-4">
48+
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
49+
{sessions.map((session) => (
50+
<div
51+
key={session.id}
52+
className="flex"
53+
style={{ "--shelf-card-w": "100%" } as React.CSSProperties}
54+
>
55+
<VideoCard
56+
session={session}
57+
backIcon={backIcon}
58+
thumbnail={thumbnails?.[session.vimeoId]}
59+
showProgress={false}
60+
persistPreview={false}
61+
/>
62+
</div>
63+
))}
64+
</div>
65+
</div>
66+
</>
67+
)
68+
}

0 commit comments

Comments
 (0)