diff --git a/apps/docs/package.json b/apps/docs/package.json index 5dab22e0..e6253057 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -40,6 +40,7 @@ "@solidjs/start": "0.6.1", "@tanstack/solid-virtual": "3.0.0-beta.6", "clsx": "2.0.0", + "embla-carousel-autoplay": "^8.3.1", "minisearch": "7.1.0", "solid-js": "1.8.15", "undici": "5.23.0", diff --git a/apps/docs/src/examples/carousel.module.css b/apps/docs/src/examples/carousel.module.css new file mode 100644 index 00000000..c09d6e12 --- /dev/null +++ b/apps/docs/src/examples/carousel.module.css @@ -0,0 +1,142 @@ +.item { + width: 400px; + height: 200px; + background-color: hsl(0 0% 98%); + margin: 8px; + flex: 0 0 100%; + min-width: 0; + display: flex; + justify-content: center; + align-items: center; + border-radius: 8px; + border: 2px solid hsl(240 5% 84%); + transition: border-color 250ms; +} + +.button { + appearance: none; + display: inline-flex; + justify-content: center; + align-items: center; + height: 40px; + padding: 0 16px; + margin-inline: 20px; + border-radius: 6px; + background-color: hsl(200 98% 39%); + color: white; + transition: background-color 250ms; +} + +.button:hover { + background-color: hsl(201 96% 32%); +} + +.button:focus-visible { + outline: 2px solid hsl(200 98% 39%); + outline-offset: 2px; +} + +.button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.horizontal { + .root { + max-width: 500px; + overflow: hidden; + margin: auto; + } + .viewport { + display: flex; + max-height: 400px; + gap: 16px; + margin: 16px; + } + + .indicators { + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + margin-top: 16px; + } + + .indicator { + width: 8px; + height: 8px; + border-radius: 50%; + background: hsl(240 5% 84%); + border: none; + cursor: pointer; + padding: 0; + transition: background-color 250ms; + } + + .indicator[data-selected] { + background: hsl(200 98% 39%); + } +} + +/* Dark theme styles */ +[data-kb-theme="dark"] .horizontal, .autoplay, .vertical { + .item { + background-color: hsl(240 4% 16%); + border-color: hsl(240 5% 26%); + } + + .button { + background-color: hsl(201 96% 32%); + color: hsla(0 100% 100% / 0.9); + } + + .button:hover { + background-color: hsl(200 98% 39%); + } + + .button:active { + background-color: hsl(199 89% 48%); + } + + .indicator { + background: hsl(240 5% 26%); + } + + .indicator[data-selected] { + background: hsl(201 96% 32%); + } +} + +.vertical { + .root { + margin: 32px auto; + overflow: hidden; + margin: auto; + } + .viewport { + max-height: 250px; + margin: 16px; + } + + .indicators { + display: flex; + justify-content: center; + gap: 8px; + margin-top: 16px; + } + + .indicator { + width: 8px; + height: 8px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.3); + border: none; + cursor: pointer; + padding: 0; + } + + .indicator[data-selected] { + background: rgba(0, 0, 0, 0.8); + } +} + diff --git a/apps/docs/src/examples/carousel.tsx b/apps/docs/src/examples/carousel.tsx new file mode 100644 index 00000000..72ba2eb9 --- /dev/null +++ b/apps/docs/src/examples/carousel.tsx @@ -0,0 +1,115 @@ +import styles from "./carousel.module.css"; +import { Carousel } from "@kobalte/core/carousel"; +import { Index } from "solid-js"; +import Autoplay from "embla-carousel-autoplay" + +const slides = Array.from({ length: 5 }).map((_, i) => i + 1); + +export function HorrizontalExample() { + return ( +
+ + + + {(_, index) => ( + + Slide {index} + + )} + + + +
+ Prev + + {(_, index) => ( + + )} + + Next +
+
+
+ ); +} + +export function VerticalExample() { + return ( +
+ + + + {(_, index) => ( + + Slide {index} + + )} + + + + { +
+ + {(_, index) => ( + + )} + +
+ } +
+
+ ); +} + +export function AutoPlayExample() { + const autoPlayPlugin = Autoplay({ delay: 1800, stopOnInteraction: true }); + return ( +
+ autoPlayPlugin.play(false)} + class={styles.root} + > + + + {(_, index) => ( + + Slide {index} + + )} + + + +
+ Prev + + {(_, index) => ( + + )} + + Next +
+
+
+ ); +} diff --git a/apps/docs/src/routes/docs/core.tsx b/apps/docs/src/routes/docs/core.tsx index cd2a5c19..e017abd5 100644 --- a/apps/docs/src/routes/docs/core.tsx +++ b/apps/docs/src/routes/docs/core.tsx @@ -61,6 +61,11 @@ const CORE_NAV_SECTIONS: NavSection[] = [ title: "Button", href: "/docs/core/components/button", }, + { + title: "Carousel", + href: "/docs/core/components/carousel", + status: "new" + }, { title: "Checkbox", href: "/docs/core/components/checkbox", diff --git a/apps/docs/src/routes/docs/core/components/carousel.mdx b/apps/docs/src/routes/docs/core/components/carousel.mdx new file mode 100644 index 00000000..ccd33489 --- /dev/null +++ b/apps/docs/src/routes/docs/core/components/carousel.mdx @@ -0,0 +1,348 @@ +import { Preview, TabsSnippets, Kbd } from "../../../../components"; + +import { + HorrizontalExample, + VerticalExample, + AutoPlayExample +} from "../../../../examples/carousel"; + +# Carousel + +A carousel component that allows cycling through items (slides) with navigation controls, indicators, Built on top of [Embla Carousel](https://www.embla-carousel.com/). + +## Import + +```ts +import { Carousel } from "@kobalte/core/carousel"; +// or +import { Root, Viewport, ... } from "@kobalte/core/carousel"; +``` + + +## Features + +- Follow WAI ARIA best practices for carousel components +- Support for horizontal and vertical orientations +- Built-in navigation controls (Previous/Next buttons) +- Support for slide indicators +- Full Embla Carousel configuration support + +## Anatomy + +The carousel consists of: + +- **Carousel:** The root container for the carousel. +- **Carousel.Viewport:** Contains the slides that are visible in the carousel. +- **Carousel.Item:** An individual slide within the carousel. +- **Carousel.Previous:** Button to navigate to the previous slide. +- **Carousel.Next:** Button to navigate to the next slide. +- **Carousel.Indicator:** Visual indicators showing the current slide position. + +```tsx + + + + + + + + + +``` + + +## Example + + + + + + + + index.tsx + style.css + + + ```tsx + import { Carousel } from "@kobalte/core/carousel"; + import "./style.css"; + + const images = [ + "https://picsum.photos/800/400?random=1", + "https://picsum.photos/800/400?random=2", + "https://picsum.photos/800/400?random=3", + "https://picsum.photos/800/400?random=4", + ]; + + function App() { + return ( + + + + {(src, index) => ( + + {`Slide + + )} + + + + + + ); + } + ``` + + + ```css + .carousel { + max-width: 500px; + overflow: hidden; + margin: auto; + } + + .carousel__viewport { + display: flex; + max-height: 400px; + gap: 16px; + margin: 16px; + } + + .carousel__item { + flex: 0 0 100%; + min-width: 0; + display: flex; + justify-content: center; + align-items: center; + border-radius: 8px; + border: 2px solid black; + } + + .carousel__item img { + max-width: 100%; + max-height: 100%; + } + + .carousel__controls { + display: flex; + justify-content: center; + gap: 8px; + margin-top: 16px; + } + + .carousel__previous, + .carousel__next { + padding: 8px; + border-radius: 8px; + background: white; + color: black; + border: none; + cursor: pointer; + } + + .carousel__indicator { + width: 8px; + height: 8px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.3); + border: none; + cursor: pointer; + padding: 0; + } + + .carousel__indicator[data-selected] { + background: rgba(0, 0, 0, 0.8); + } + ``` + + + +## Usage + +### Vertical Orientation + +The carousel can be oriented vertically by setting the `orientation` prop to `vertical`. + + + + + + +### Autoplay + +The carousel supports autoplay functionality using the Embla Carousel autoplay plugin. + + + + + +```tsx +import Autoplay from "embla-carousel-autoplay"; + +function AutoplayExample() { + const autoPlayPlugin = Autoplay({ delay: 2000, stopOnInteraction: true }); + return ( + autoPlayPlugin.play(false)} + > + {/ ... rest of the carousel code ... /} + + ); +} +``` + +## API Reference + +### Carousel.Root + +#### Props + +| Prop | Type | Default | Description | +| :--- | :--- | :------ | :---------- | +| `orientation` | `'horizontal' \| 'vertical'` | `'horizontal'` | The orientation of the carousel. | +| `disabled` | `boolean` | `false` | Whether the carousel is disabled. | +| `defaultSelectedIndex` | `number` | `0` | The index of the slide that should be active when initially rendered. | +| `startIndex` | `number` | - | The controlled value of the selected slide index. | +| `onSelectedIndexChange` | `(index: number) => void` | - | Event handler called when the selected index changes. | +| `align` | `'start' \| 'center' \| 'end'` | `'start'` | The alignment of the slides in the carousel. | +| `loop` | `boolean` | `false` | Whether the carousel should loop. | +| `dragFree` | `boolean` | `false` | Whether the carousel should drag free or snap to slides. | +| `slidesToScroll` | `number` | `1` | The number of slides to scroll at a time. | +| `dragThreshold` | `number` | `10` | The drag threshold in pixels before a drag movement starts. | +| `duration` | `number` | `25` | The duration of the scroll animation in milliseconds. | +| `plugins` | `EmblaPluginType[]` | `[]` | Custom embla carousel plugins. | + +#### Data Attributes + +| Attribute | Description | +| :--- | :---------- | +| `data-orientation` | The orientation of the carousel: `"horizontal"` or `"vertical"` | +| `aria-roledescription` | Set to `"carousel"` | +| `role` | Set to `"region"` | + +### Carousel.Viewport + +#### Props + +| Prop | Type | Description | +| :--- | :--- | :---------- | +| `ref` | `T \| ((el: T) => void)` | A reference to the viewport element. | + +#### Data Attributes + +| Attribute | Description | +| :--- | :---------- | +| `data-orientation` | Matches the parent carousel's orientation | + +### Carousel.Item + +#### Props + +| Prop | Type | Description | +| :--- | :--- | :---------- | +| `index` | `number` | Required. The index of this item in the carousel. | +| `ref` | `T \| ((el: T) => void)` | A reference to the item element. | + +#### Data Attributes + +| Attribute | Description | +| :--- | :---------- | +| `data-orientation` | Matches the parent carousel's orientation | +| `data-selected` | Present when the item is currently selected | +| `role` | Set to `"group"` | +| `aria-roledescription` | Set to `"slide"` | + +### Carousel.Previous + +`Carousel.Previous` use [Button](/docs/core/components/button) component under the hood. + +#### Props + +Inherits all button props plus: + +| Prop | Type | Description | +| :--- | :--- | :---------- | +| `ref` | `T \| ((el: T) => void)` | A reference to the button element. | + +#### Data Attributes + +| Attribute | Description | +| :--- | :---------- | +| `data-orientation` | Matches the parent carousel's orientation | +| `aria-label` | Set to `"Previous slide"` | +| `disabled` | Present when cannot scroll to previous slide | + +### Carousel.Next + +`Carousel.Next` use [Button](/docs/core/components/button) component under the hood. + +#### Props + +Inherits all button props plus: + +| Prop | Type | Description | +| :--- | :--- | :---------- | +| `ref` | `T \| ((el: T) => void)` | A reference to the button element. | + +#### Data Attributes + +| Attribute | Description | +| :--- | :---------- | +| `data-orientation` | Matches the parent carousel's orientation | +| `aria-label` | Set to `"Next slide"` | +| `disabled` | Present when cannot scroll to next slide | + +### Carousel.Indicator + +`Carousel.Indicator` use [Button](/docs/core/components/button) component under the hood. + +#### Props + +Inherits all button props plus: + +| Prop | Type | Description | +| :--- | :--- | :---------- | +| `index` | `number` | Required. The index this indicator represents. | +| `ref` | `T \| ((el: T) => void)` | A reference to the button element. | + +#### Data Attributes + +| Attribute | Description | +| :--- | :---------- | +| `data-orientation` | Matches the parent carousel's orientation | +| `data-selected` | Present when this indicator's slide is selected | +| `role` | Set to `"tab"` | +| `aria-selected` | Set to `true` when this indicator's slide is selected | + +## Accessibility + +### Keyboard Interactions + +| Key | Description | +| :-- | :---------- | +| `ArrowRight` | Moves to the next slide when orientation is horizontal | +| `ArrowLeft` | Moves to the previous slide when orientation is horizontal | +| `ArrowDown` | Moves to the next slide when orientation is vertical | +| `ArrowUp` | Moves to the previous slide when orientation is vertical | +| `Home` | Moves to the first slide | +| `End` | Moves to the last slide | +| `Tab` | Moves focus to the next focusable element | +| `Shift + Tab` | Moves focus to the previous focusable element | + +### ARIA + +The carousel follows the [WAI-ARIA Carousel Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/carousel/). The root element has `role="region"` and `aria-roledescription="carousel"`, while each slide has `role="group"` and `aria-roledescription="slide"`. diff --git a/packages/core/package.json b/packages/core/package.json index edc1870b..01206326 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -76,6 +76,8 @@ "@kobalte/utils": "^0.9.1", "@solid-primitives/props": "^3.1.8", "@solid-primitives/resize-observer": "^2.0.26", + "embla-carousel": "^8.4.0", + "embla-carousel-solid": "^8.4.0", "solid-presence": "^0.1.8", "solid-prevent-scroll": "^0.1.4" }, diff --git a/packages/core/src/carousel/carousel-context.tsx b/packages/core/src/carousel/carousel-context.tsx new file mode 100644 index 00000000..c21f99b8 --- /dev/null +++ b/packages/core/src/carousel/carousel-context.tsx @@ -0,0 +1,31 @@ +import { type Accessor, createContext, useContext } from "solid-js"; +import type { CreateEmblaCarouselType } from "embla-carousel-solid"; +import type { Orientation } from "@kobalte/utils"; + +export type CarouselApi = CreateEmblaCarouselType[1]; + +export interface CarouselContextValue { + isDisabled: Accessor; + orientation: Accessor; + canScrollPrev: Accessor; + canScrollNext: Accessor; + selectedIndex: Accessor; + api: Accessor; + scrollPrev: () => void; + scrollNext: () => void; + scrollTo: (index: number) => void; +} + +export const CarouselContext = createContext(); + +export function useCarouselContext() { + const context = useContext(CarouselContext); + + if (context === undefined) { + throw new Error( + "[kobalte]: `useCarouselContext` must be used within a `Carousel` component" + ); + } + + return context; +} diff --git a/packages/core/src/carousel/carousel-indicator.tsx b/packages/core/src/carousel/carousel-indicator.tsx new file mode 100644 index 00000000..08e56297 --- /dev/null +++ b/packages/core/src/carousel/carousel-indicator.tsx @@ -0,0 +1,51 @@ +import { type ValidComponent, splitProps } from "solid-js"; +import { type ElementOf, type PolymorphicProps } from "../polymorphic"; +import { useCarouselContext } from "./carousel-context"; +import { Button } from "../button"; + +export interface CarouselIndicatorOptions { + /** The index this indicator represents and will navigate to when clicked. */ + index: number; +} + +export interface CarouselIndicatorCommonProps { + ref?: T | ((el: T) => void); +} + +export interface CarouselIndicatorRenderProps extends CarouselIndicatorCommonProps { + role: "tab"; + type: "button"; + "aria-label": string; + "aria-selected": boolean; + "data-orientation": string; + "data-selected": string | undefined; + onClick: () => void; +} + +export type CarouselIndicatorProps = + CarouselIndicatorOptions & Partial>>; + +/** + * A button that allows users to navigate directly to a specific slide. + */ +export function CarouselIndicator( + props: PolymorphicProps> +) { + const context = useCarouselContext(); + const [local, others] = splitProps(props as CarouselIndicatorProps, ["index", "ref"]); + + const isSelected = () => context.selectedIndex() === local.index; + + return ( +