Skip to content

Commit bdf6e65

Browse files
Refactor Carousel API
1 parent 2b45ddc commit bdf6e65

File tree

5 files changed

+201
-131
lines changed

5 files changed

+201
-131
lines changed

packages/react/src/carousel/carousel.stories.tsx

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
UNSAFE_CarouselItem as CarouselItem,
55
UNSAFE_CarouselItems as CarouselItems,
66
} from '../carousel';
7+
import { Media } from '../content';
78

89
const meta: Meta<typeof Carousel> = {
910
title: 'Carousel',
@@ -17,28 +18,36 @@ const meta: Meta<typeof Carousel> = {
1718
<Carousel>
1819
<CarouselItems>
1920
<CarouselItem>
20-
<img
21-
src="https://res.cloudinary.com/obosit-prd-ch-clry/image/upload/f_auto,c_limit,w_2048,q_auto/v1582122753/Boligprosjekter/Oslo/Ulven/Ulven-N%C3%A6romr%C3%A5de-Oslo-OBOS-Construction-city.jpg"
22-
alt=""
23-
/>
21+
<Media>
22+
<img
23+
src="https://res.cloudinary.com/obosit-prd-ch-clry/image/upload/f_auto,c_limit,w_2048,q_auto/v1582122753/Boligprosjekter/Oslo/Ulven/Ulven-N%C3%A6romr%C3%A5de-Oslo-OBOS-Construction-city.jpg"
24+
alt=""
25+
/>
26+
</Media>
2427
</CarouselItem>
2528
<CarouselItem>
26-
<img
27-
src="https://res.cloudinary.com/obosit-prd-ch-clry/image/upload/v1587988823/Boligprosjekter/Oslo/Frysjaparken/Frysjalia/Frysjaparken_interi%C3%B8r_30.jpg"
28-
alt=""
29-
/>
29+
<Media>
30+
<img
31+
src="https://res.cloudinary.com/obosit-prd-ch-clry/image/upload/v1587988823/Boligprosjekter/Oslo/Frysjaparken/Frysjalia/Frysjaparken_interi%C3%B8r_30.jpg"
32+
alt=""
33+
/>
34+
</Media>
3035
</CarouselItem>
31-
<CarouselItem fit="contain">
32-
<img
33-
src="https://res.cloudinary.com/obosit-prd-ch-clry/image/upload/f_auto,c_limit,w_1080,q_auto:best/t_2_3/v1747985572/Temasider/Folk/Hans%20Petter%20%20-%20Trang%20f%C3%B8dsel/Obos-Hans-Petter-Aaserud-Photo-Einar-Aslaksen-03093_web.jpg"
34-
alt=""
35-
/>
36+
<CarouselItem>
37+
<Media fit="contain">
38+
<img
39+
src="https://res.cloudinary.com/obosit-prd-ch-clry/image/upload/f_auto,c_limit,w_1080,q_auto:best/t_2_3/v1747985572/Temasider/Folk/Hans%20Petter%20%20-%20Trang%20f%C3%B8dsel/Obos-Hans-Petter-Aaserud-Photo-Einar-Aslaksen-03093_web.jpg"
40+
alt=""
41+
/>
42+
</Media>
3643
</CarouselItem>
3744
<CarouselItem>
38-
<img
39-
src="https://res.cloudinary.com/obosit-prd-ch-clry/image/upload/v1699879884/Boligprosjekter/Oslo/Frysjaparken/Ager/Originale%20bilder/OBOS_Frysja-Ager-Illustrasjon_av_Frysja_torg_i_Ager_borettslag.jpg"
40-
alt=""
41-
/>
45+
<Media>
46+
<img
47+
src="https://res.cloudinary.com/obosit-prd-ch-clry/image/upload/v1699879884/Boligprosjekter/Oslo/Frysjaparken/Ager/Originale%20bilder/OBOS_Frysja-Ager-Illustrasjon_av_Frysja_torg_i_Ager_borettslag.jpg"
48+
alt=""
49+
/>
50+
</Media>
4251
</CarouselItem>
4352
</CarouselItems>
4453
</Carousel>

packages/react/src/carousel/carousel.tsx

Lines changed: 72 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { ChevronLeft, ChevronRight } from '@obosbbl/grunnmuren-icons-react';
22
import { useUpdateEffect } from '@react-aria/utils';
3-
import { type VariantProps, cva, cx } from 'cva';
3+
import { cx } from 'cva';
44
import { createContext, useEffect, useRef, useState } from 'react';
55
import { Provider } from 'react-aria-components';
66
import { useDebouncedCallback } from 'use-debounce';
77
import { Button, ButtonContext } from '../button';
88
import { translations } from '../translations';
99
import { useLocale } from '../use-locale';
10+
import { MediaContext } from '../content';
1011

1112
type CarouselProps = {
1213
/** The <CarouselItem/> components to be displayed within the carousel. */
@@ -70,20 +71,7 @@ const Carousel = ({ className, children }: CarouselProps) => {
7071
);
7172

7273
return (
73-
<div
74-
data-slot="carousel"
75-
className={cx(
76-
className,
77-
'relative rounded-3xl',
78-
// If any <CarouselItems/> (the scroll-snap container) or <VideoLoop/> component is focused, apply custom focus styles around the carousel, this makes ensures that the focus outline is visible around the carousel in all cases
79-
'[&:has([data-slot="carousel-items"]:focus-visible,[data-slot="video-loop-button"]:focus-visible)]:outline-focus',
80-
'[&:has([data-slot="carousel-items"]:focus-visible,[data-slot="video-loop-button"]:focus-visible)]:outline-focus-offset',
81-
// Unset the default focus outline for potential video loop buttons, as it interferes with the custom focus styles for the carousel
82-
'**:data-[slot="video-loop-button"]:focus-visible:outline-none',
83-
// biome-ignore lint/nursery/useSortedClasses: biome is unable to sort the custom classes for 3xl and 4xl breakpoints
84-
'h-70 sm:h-[25rem] lg:h-[35rem] xl:h-[40rem] 2xl:h-[42rem] 3xl:h-[48rem] 4xl:h-[53rem]',
85-
)}
86-
>
74+
<div data-slot="carousel">
8775
<Provider
8876
values={[
8977
[
@@ -122,33 +110,45 @@ const Carousel = ({ className, children }: CarouselProps) => {
122110
],
123111
]}
124112
>
125-
{children}
126-
<_CarouselControls>
127-
<Button
128-
isIconOnly
129-
slot="prev"
130-
variant="primary"
131-
color="white"
132-
className={cx(
133-
'group/carousel-previous',
134-
hasReachedScrollStart && 'invisible',
135-
)}
136-
>
137-
<ChevronLeft className="group-hover/carousel-previous:motion-safe:-translate-x-1 transition-transform" />
138-
</Button>
139-
<Button
140-
isIconOnly
141-
slot="next"
142-
variant="primary"
143-
color="white"
144-
className={cx(
145-
'group/carousel-next',
146-
hasReachedScrollEnd && 'invisible',
147-
)}
148-
>
149-
<ChevronRight className="transition-transform group-hover/carousel-next:motion-safe:translate-x-1" />
150-
</Button>
151-
</_CarouselControls>
113+
<div
114+
className={cx(
115+
className,
116+
'relative rounded-3xl',
117+
// If any <CarouselItems/> (the scroll-snap container) or <VideoLoop/> component is focused, apply custom focus styles around the carousel, this makes ensures that the focus outline is visible around the carousel in all cases
118+
'[&:has([data-slot="carousel-items"]:focus-visible,[data-slot="video-loop-button"]:focus-visible)]:outline-focus',
119+
'[&:has([data-slot="carousel-items"]:focus-visible,[data-slot="video-loop-button"]:focus-visible)]:outline-focus-offset',
120+
// Unset the default focus outline for potential video loop buttons, as it interferes with the custom focus styles for the carousel
121+
'**:data-[slot="video-loop-button"]:focus-visible:outline-none',
122+
)}
123+
>
124+
{children}
125+
<_CarouselControls>
126+
<Button
127+
isIconOnly
128+
slot="prev"
129+
variant="primary"
130+
color="white"
131+
className={cx(
132+
'group/carousel-previous',
133+
hasReachedScrollStart && 'invisible',
134+
)}
135+
>
136+
<ChevronLeft className="group-hover/carousel-previous:motion-safe:-translate-x-1 transition-transform" />
137+
</Button>
138+
<Button
139+
isIconOnly
140+
slot="next"
141+
variant="primary"
142+
color="white"
143+
className={cx(
144+
'group/carousel-next',
145+
hasReachedScrollEnd && 'invisible',
146+
)}
147+
>
148+
<ChevronRight className="transition-transform group-hover/carousel-next:motion-safe:translate-x-1" />
149+
</Button>
150+
</_CarouselControls>
151+
</div>
152152
</Provider>
153153
</div>
154154
);
@@ -167,7 +167,12 @@ type _CarouselControlsProps = {
167167
*/
168168
const _CarouselControls = ({ children, className }: _CarouselControlsProps) => (
169169
<div
170-
className={cx(className, 'absolute right-6 bottom-6 flex gap-x-2')}
170+
className={cx(
171+
className,
172+
'absolute right-6 bottom-6 flex gap-x-2',
173+
// Make it easier to position in full-bleed hero variants (these style have no other side effects)
174+
'items-end *:h-fit',
175+
)}
171176
data-slot="carousel-controls"
172177
>
173178
{children}
@@ -202,7 +207,6 @@ const CarouselItems = ({ className, children }: CarouselItemsProps) => (
202207
'snap-mandatory',
203208
'overflow-x-auto',
204209
'outline-none',
205-
'h-full',
206210
'rounded-[inherit]',
207211
])}
208212
ref={ref}
@@ -218,35 +222,35 @@ const CarouselItems = ({ className, children }: CarouselItemsProps) => (
218222
</CarouselItemsContext.Consumer>
219223
);
220224

221-
type CarouselItemProps = VariantProps<typeof carouselItemVariant> & {
225+
type CarouselItemProps = {
222226
className?: string;
223227
children: React.ReactNode;
224228
};
225229

226-
const carouselItemVariant = cva({
227-
base: 'shrink-0 basis-full snap-start *:h-full *:w-full',
228-
variants: {
229-
/**
230-
* Control how the content should be placed with the object-fit property
231-
* You might for example want to use `fit="contain"` portrait images that should not be cropped
232-
* @default cover
233-
* */
234-
fit: {
235-
cover: '*:object-cover',
236-
contain: 'bg-blue-dark *:object-contain',
237-
},
238-
},
239-
defaultVariants: {
240-
fit: 'cover',
241-
},
242-
});
243-
244-
const CarouselItem = ({ fit, className, children }: CarouselItemProps) => {
245-
const _className = carouselItemVariant({ fit });
246-
230+
const CarouselItem = ({ className, children }: CarouselItemProps) => {
247231
return (
248-
<div className={cx(className, _className)} data-slot="carousel-item">
249-
{children}
232+
<div
233+
className={cx(className, 'shrink-0 basis-full snap-start')}
234+
data-slot="carousel-item"
235+
>
236+
<Provider
237+
values={[
238+
[
239+
MediaContext,
240+
{
241+
fit: 'cover',
242+
className: cx(
243+
'bg-blue-dark',
244+
'*:w-full',
245+
// biome-ignore lint/nursery/useSortedClasses: biome is unable to sort the custom classes for 3xl and 4xl breakpoints
246+
'*:h-70 sm:*:h-[25rem] lg:*:h-[35rem] xl:*:h-[40rem] 2xl:*:h-[42rem] 3xl:*:h-[48rem] 4xl:*:h-[53rem]',
247+
),
248+
},
249+
],
250+
]}
251+
>
252+
{children}
253+
</Provider>
250254
</div>
251255
);
252256
};

packages/react/src/content/content.tsx

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,11 +85,49 @@ const Content = ({ ref = null, ...props }: ContentProps) => {
8585
return outerWrapper ? outerWrapper(content) : content;
8686
};
8787

88-
type MediaProps = HTMLProps<HTMLDivElement> & {
89-
children: React.ReactNode;
90-
};
88+
type MediaProps = HTMLProps<HTMLDivElement> &
89+
VariantProps<typeof mediaVariant> & {
90+
children: React.ReactNode;
91+
/** Ref for the element. */
92+
ref?: Ref<HTMLDivElement>;
93+
};
94+
95+
const mediaVariant = cva({
96+
variants: {
97+
/**
98+
* Control how the content should be placed with the object-fit property
99+
* You might for example want to use `fit="contain"` portrait images that should not be cropped
100+
* @default cover
101+
* */
102+
fit: {
103+
cover: '*:object-cover',
104+
contain: '*:object-contain',
105+
},
106+
},
107+
});
91108

92-
const Media = (props: MediaProps) => <div {...props} data-slot="media" />;
109+
const MediaContext = createContext<
110+
ContextValue<Partial<MediaProps>, HTMLDivElement>
111+
>({});
112+
113+
const Media = ({ ref = null, ...props }: MediaProps) => {
114+
[props, ref] = useContextProps(props, ref, MediaContext);
115+
116+
const { className, fit, ...restProps } = props;
117+
118+
const _className = mediaVariant({
119+
fit,
120+
});
121+
122+
return (
123+
<div
124+
ref={ref}
125+
className={cx(className, _className)}
126+
{...restProps}
127+
data-slot="media"
128+
/>
129+
);
130+
};
93131

94132
type FooterProps = HTMLProps<HTMLDivElement> & {
95133
children: React.ReactNode;
@@ -117,6 +155,7 @@ export {
117155
Heading,
118156
HeadingContext,
119157
Media,
158+
MediaContext,
120159
type CaptionProps,
121160
type ContentProps,
122161
type FooterProps,

0 commit comments

Comments
 (0)