From c58a366cf3e11052cec9dd457c2c58f522487fe7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Wed, 4 Jun 2025 21:00:53 +0200 Subject: [PATCH 01/24] Add carousel component --- packages/react/src/carousel/carousel.tsx | 64 ++++++++++++++++++++++++ packages/react/src/carousel/index.ts | 1 + packages/tailwind/tailwind-base.css | 12 +++++ 3 files changed, 77 insertions(+) create mode 100644 packages/react/src/carousel/carousel.tsx create mode 100644 packages/react/src/carousel/index.ts diff --git a/packages/react/src/carousel/carousel.tsx b/packages/react/src/carousel/carousel.tsx new file mode 100644 index 000000000..b44bd3ce6 --- /dev/null +++ b/packages/react/src/carousel/carousel.tsx @@ -0,0 +1,64 @@ +import { ArrowLeft, ArrowRight } from '@obosbbl/grunnmuren-icons-react'; +import { cx } from 'cva'; +import { Button } from '../button'; + +type CarouselProps = { + /** The components to be displayed within the carousel. */ + children: React.ReactNode; + /** Additional CSS className for the element. */ + className?: string; +}; + +const Carousel = ({ className, children }: CarouselProps) => ( +
+
+ {children} +
+
+ + +
+
+); + +type CarouselItemProps = { + className?: string; + children: React.ReactNode; +}; + +const CarouselItem = ({ className, children }: CarouselItemProps) => ( +
+ {children} +
+); + +export { + Carousel as UNSAFE_Carousel, + CarouselItem as UNSAFE_CarouselItem, + type CarouselItemProps as UNSAFE_CarouselItemProps, + type CarouselProps as UNSAFE_CarouselProps, +}; diff --git a/packages/react/src/carousel/index.ts b/packages/react/src/carousel/index.ts new file mode 100644 index 000000000..5f6643893 --- /dev/null +++ b/packages/react/src/carousel/index.ts @@ -0,0 +1 @@ +export * from './carousel'; diff --git a/packages/tailwind/tailwind-base.css b/packages/tailwind/tailwind-base.css index b846549b4..fa3bd3371 100644 --- a/packages/tailwind/tailwind-base.css +++ b/packages/tailwind/tailwind-base.css @@ -174,3 +174,15 @@ @utility ring-focus-offset { @apply ring-focus ring-offset-2; } + +/** Hides the scrollbar visually */ +@utility scrollbar-hidden { + /* For IE, Edge and Firefox */ + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ + + /* For Webkit-based browsers (Chrome, Safari and Opera) */ + &::-webkit-scrollbar { + display: none; + } +} From 265c16c7362957486ef5d5df442c28abc96b183d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Wed, 4 Jun 2025 21:01:04 +0200 Subject: [PATCH 02/24] Add examples with carousel --- packages/react/src/hero/hero.stories.tsx | 88 ++++++++++++++++++++++-- 1 file changed, 84 insertions(+), 4 deletions(-) diff --git a/packages/react/src/hero/hero.stories.tsx b/packages/react/src/hero/hero.stories.tsx index d41b972a4..0c0199499 100644 --- a/packages/react/src/hero/hero.stories.tsx +++ b/packages/react/src/hero/hero.stories.tsx @@ -7,6 +7,10 @@ import { Content, Heading, Media } from '../content'; import { Description } from '../label'; import { VideoLoop } from '../video-loop'; import { UNSAFE_Hero as Hero } from './hero'; +import { + UNSAFE_Carousel as Carousel, + UNSAFE_CarouselItem as CarouselItem, +} from '../carousel'; const meta: Meta = { title: 'Hero', @@ -28,10 +32,20 @@ const meta: Meta = {

- + + + + + + + + @@ -102,6 +116,33 @@ export const StandardPageWithCTA = () => ( ); +export const StandardWithCarousel = () => ( +
+ + + OBOS-butikken + – din lokale OBOS-butikk i Oslo sentrum + + + + + + + + + + + + +
+); + const Logo = () => ( ( ); + +export const FullBleedWithCarousel = () => ( +
+ + + Ulven + – et nytt nabolag i Oslo + + + + + + + + + + + + + + + + + + +
+); From 9defe0956e75bbad431533b2bfa892c4b9cd2b74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Thu, 5 Jun 2025 12:18:44 +0200 Subject: [PATCH 03/24] Fix prev/next buttons --- packages/react/src/carousel/carousel.tsx | 119 +++++++++++++++++------ packages/react/src/translations.ts | 10 ++ 2 files changed, 101 insertions(+), 28 deletions(-) diff --git a/packages/react/src/carousel/carousel.tsx b/packages/react/src/carousel/carousel.tsx index b44bd3ce6..00f577775 100644 --- a/packages/react/src/carousel/carousel.tsx +++ b/packages/react/src/carousel/carousel.tsx @@ -1,6 +1,10 @@ import { ArrowLeft, ArrowRight } from '@obosbbl/grunnmuren-icons-react'; import { cx } from 'cva'; +import { useEffect, useRef, useState } from 'react'; import { Button } from '../button'; +import { useLocale } from '../use-locale'; +import { translations } from '../translations'; +import { useUpdateEffect } from '@react-aria/utils'; type CarouselProps = { /** The components to be displayed within the carousel. */ @@ -9,35 +13,94 @@ type CarouselProps = { className?: string; }; -const Carousel = ({ className, children }: CarouselProps) => ( -
-
- {children} -
-
- - +const Carousel = ({ className, children }: CarouselProps) => { + const ref = useRef(null); + const locale = useLocale(); + const { previous, next } = translations; + + const [scrollTargetIndex, setScrollTargetIndex] = useState(0); + + const hasReachedScrollStart = scrollTargetIndex === 0; + + // const hasReachedScrollEnd = !ref.current || ref.current.children.length - 1 === scrollTargetIndex; + const [hasReachedScrollEnd, setHasReachedScrollEnd] = useState( + !ref.current || ref.current.children.length - 1 === scrollTargetIndex, + ); + + useEffect(() => { + // Check if the current scroll target index is the last item + setHasReachedScrollEnd( + !ref.current || ref.current.children.length - 1 === scrollTargetIndex, + ); + }); + + // Handle scrolling when user clicks the arrow icons + useUpdateEffect(() => { + if (!ref.current) return; + + ref.current.children[scrollTargetIndex]?.scrollIntoView({ + behavior: 'smooth', + inline: 'start', + block: 'nearest', + }); + }, [scrollTargetIndex]); + + return ( +
+
+ {children} +
+
+ + +
-
-); + ); +}; type CarouselItemProps = { className?: string; diff --git a/packages/react/src/translations.ts b/packages/react/src/translations.ts index e7569be09..8df4a8da6 100644 --- a/packages/react/src/translations.ts +++ b/packages/react/src/translations.ts @@ -29,6 +29,16 @@ const translations: Translations = { sv: 'Dölj', en: 'Show less', }, + previous: { + nb: 'Forrige', + sv: 'Föregående', + en: 'Previous', + }, + next: { + nb: 'Neste', + sv: 'Nästa', + en: 'Next', + }, }; export { translations, type Translation, type Translations }; From c6f63048a6baec76875aea4bc6221154b78bfbf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Thu, 5 Jun 2025 12:18:50 +0200 Subject: [PATCH 04/24] Update examples --- packages/react/src/hero/hero.stories.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/react/src/hero/hero.stories.tsx b/packages/react/src/hero/hero.stories.tsx index 0c0199499..d86850595 100644 --- a/packages/react/src/hero/hero.stories.tsx +++ b/packages/react/src/hero/hero.stories.tsx @@ -203,9 +203,10 @@ export const FullBleedWithCarousel = () => ( - From d2dcb282610011f8c945caff2496b8a0b9a80720 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Fri, 6 Jun 2025 09:05:34 +0200 Subject: [PATCH 05/24] Sync scoll snap with buttons --- packages/react/package.json | 3 +- packages/react/src/button/button.tsx | 14 +- .../react/src/carousel/carousel.stories.tsx | 57 ++++++ packages/react/src/carousel/carousel.tsx | 178 +++++++++++++----- packages/react/src/hero/hero.stories.tsx | 125 ++++++------ pnpm-lock.yaml | 13 ++ 6 files changed, 287 insertions(+), 103 deletions(-) create mode 100644 packages/react/src/carousel/carousel.stories.tsx diff --git a/packages/react/package.json b/packages/react/package.json index 4d6c5b072..962ffeb1b 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -31,7 +31,8 @@ "cva": "^1.0.0-0", "react-aria": "^3.38.1", "react-aria-components": "^1.7.1", - "react-stately": "^3.35.0" + "react-stately": "^3.35.0", + "use-debounce": "^10.0.4" }, "peerDependencies": { "react": "^19" diff --git a/packages/react/src/button/button.tsx b/packages/react/src/button/button.tsx index c5c5acb95..4c457db14 100644 --- a/packages/react/src/button/button.tsx +++ b/packages/react/src/button/button.tsx @@ -1,12 +1,14 @@ import { LoadingSpinner } from '@obosbbl/grunnmuren-icons-react'; import { type VariantProps, cva } from 'cva'; -import type { Ref } from 'react'; +import { type Ref, createContext } from 'react'; import { useProgressBar } from 'react-aria'; import { + type ContextValue, Button as RACButton, type ButtonProps as RACButtonProps, Link as RACLink, type LinkProps as RACLinkProps, + useContextProps, } from 'react-aria-components'; import { translations } from '../translations'; import { useLocale } from '../use-locale'; @@ -130,13 +132,18 @@ type ButtonOrLinkProps = VariantProps & { type ButtonProps = (RACButtonProps | RACLinkProps) & ButtonOrLinkProps; +const ButtonContext = createContext< + ContextValue +>({}); + function isLinkProps( props: ButtonProps, ): props is ButtonOrLinkProps & RACLinkProps { return !!props.href; } -function Button(props: ButtonProps) { +function Button({ ref = null, ...props }: ButtonProps) { + [props, ref] = useContextProps(props, ref, ButtonContext); const { children: _children, color, @@ -144,7 +151,6 @@ function Button(props: ButtonProps) { isLoading, variant, isPending: _isPending, - ref, ...restProps } = props; @@ -197,4 +203,4 @@ function Button(props: ButtonProps) { ); } -export { Button, type ButtonProps }; +export { Button, ButtonContext, type ButtonProps }; diff --git a/packages/react/src/carousel/carousel.stories.tsx b/packages/react/src/carousel/carousel.stories.tsx new file mode 100644 index 000000000..f1ab5dabd --- /dev/null +++ b/packages/react/src/carousel/carousel.stories.tsx @@ -0,0 +1,57 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Group } from 'react-aria-components'; +import { Button } from '../button'; +import { + UNSAFE_Carousel as Carousel, + UNSAFE_CarouselItem as CarouselItem, + UNSAFE_CarouselItems as CarouselItems, +} from '../carousel'; +import { VideoLoop } from '../video-loop'; + +const meta: Meta = { + title: 'Carousel', + component: Carousel, + parameters: { + // disable built in padding in story, because we provide our own + variant: 'fullscreen', + }, + render: () => ( +
+ + + + + + + + + + + + + +
+ ), +}; + +export default meta; + +type Story = StoryObj; + +export const StandardWithLeadAndImage: Story = { + args: {}, +}; diff --git a/packages/react/src/carousel/carousel.tsx b/packages/react/src/carousel/carousel.tsx index 00f577775..74e016c84 100644 --- a/packages/react/src/carousel/carousel.tsx +++ b/packages/react/src/carousel/carousel.tsx @@ -1,10 +1,12 @@ -import { ArrowLeft, ArrowRight } from '@obosbbl/grunnmuren-icons-react'; import { cx } from 'cva'; -import { useEffect, useRef, useState } from 'react'; -import { Button } from '../button'; +import { createContext, useEffect, useRef, useState } from 'react'; +import { ButtonContext } from '../button'; import { useLocale } from '../use-locale'; import { translations } from '../translations'; import { useUpdateEffect } from '@react-aria/utils'; +import { GroupContext, Provider } from 'react-aria-components'; +import { ArrowLeft, ArrowRight } from '@obosbbl/grunnmuren-icons-react'; +import { useDebouncedCallback } from 'use-debounce'; type CarouselProps = { /** The components to be displayed within the carousel. */ @@ -20,19 +22,20 @@ const Carousel = ({ className, children }: CarouselProps) => { const [scrollTargetIndex, setScrollTargetIndex] = useState(0); - const hasReachedScrollStart = scrollTargetIndex === 0; + const [hasReachedScrollStart, setHasReachedScrollStart] = useState( + scrollTargetIndex === 0, + ); - // const hasReachedScrollEnd = !ref.current || ref.current.children.length - 1 === scrollTargetIndex; const [hasReachedScrollEnd, setHasReachedScrollEnd] = useState( !ref.current || ref.current.children.length - 1 === scrollTargetIndex, ); useEffect(() => { - // Check if the current scroll target index is the last item + setHasReachedScrollStart(scrollTargetIndex === 0); setHasReachedScrollEnd( !ref.current || ref.current.children.length - 1 === scrollTargetIndex, ); - }); + }, [scrollTargetIndex]); // Handle scrolling when user clicks the arrow icons useUpdateEffect(() => { @@ -45,10 +48,121 @@ const Carousel = ({ className, children }: CarouselProps) => { }); }, [scrollTargetIndex]); + const onScroll = useDebouncedCallback( + (event: React.UIEvent) => { + const target = event.target as HTMLDivElement; + + // Calculate the index of the item that is currently in view + const newScrollTargetIndex = Array.from(target.children).findIndex( + (child) => { + const rect = child.getBoundingClientRect(); + return ( + rect.left >= 0 && rect.right <= window.innerWidth && rect.top >= 0 + ); + }, + ); + + if (newScrollTargetIndex !== -1) { + setScrollTargetIndex(newScrollTargetIndex); + } + }, + 100, + ); + return (
+ { + if (scrollTargetIndex > 0) { + setScrollTargetIndex((prev) => prev - 1); + } + }, + className: cx( + 'group/carousel-previous', + hasReachedScrollStart && 'invisible', + ), + isDisabled: hasReachedScrollStart, + children: ( + + ), + }, + next: { + isIconOnly: true, + 'aria-label': next[locale], + variant: 'primary', + color: 'white', + onPress: () => { + if (!ref.current) return; + if (scrollTargetIndex < ref.current.children.length - 1) { + setScrollTargetIndex((prev) => prev + 1); + } + }, + className: cx( + 'group/carousel-next', + hasReachedScrollEnd && 'invisible', + ), + isDisabled: hasReachedScrollEnd, + children: ( + + ), + }, + }, + }, + ], + ]} + > + {children} + +
+ ); +}; + +type CarouselItemsProps = { + /** Additional CSS className for the element. */ + children: React.ReactNode; + /** The components to be displayed within the carousel. */ + className?: string; +}; + +type CarouselItemsContextValue = { + ref: React.Ref; + onScroll?: (event: React.UIEvent) => void; +}; + +const CarouselItemsContext = createContext({ + ref: null, +} as CarouselItemsContextValue); + +const CarouselItems = ({ className, children }: CarouselItemsProps) => ( + + {({ ref, onScroll }) => (
{ 'h-full', 'rounded-[inherit]', ])} - data-slot="carousel" + ref={ref} + // When the SnapEvent is supported: https://developer.mozilla.org/en-US/docs/Web/API/SnapEvent + // We can use the scrollsnapchange event to detect when the user has scrolled to a new item. + // We can then use Array.from(event.target.children).indexOf(event.snapTargetInline) to calculate the index of the item that is currently in view. + // Another option is to use the scrollEnd event, when Safiri supports it: https://developer.apple.com/documentation/webkitjs/snap_event/scrollend_event + onScroll={onScroll} > {children}
-
- - -
-
- ); -}; + )} + +); type CarouselItemProps = { className?: string; @@ -122,6 +208,8 @@ const CarouselItem = ({ className, children }: CarouselItemProps) => ( export { Carousel as UNSAFE_Carousel, CarouselItem as UNSAFE_CarouselItem, + CarouselItems as UNSAFE_CarouselItems, + type CarouselItemsProps as UNSAFE_CarouselItemsProps, type CarouselItemProps as UNSAFE_CarouselItemProps, type CarouselProps as UNSAFE_CarouselProps, }; diff --git a/packages/react/src/hero/hero.stories.tsx b/packages/react/src/hero/hero.stories.tsx index d86850595..e80ca11ab 100644 --- a/packages/react/src/hero/hero.stories.tsx +++ b/packages/react/src/hero/hero.stories.tsx @@ -3,14 +3,15 @@ import type { Meta, StoryObj } from '@storybook/react'; import { Group } from 'react-aria-components'; import { Badge } from '../badge'; import { Button } from '../button'; -import { Content, Heading, Media } from '../content'; -import { Description } from '../label'; -import { VideoLoop } from '../video-loop'; -import { UNSAFE_Hero as Hero } from './hero'; import { UNSAFE_Carousel as Carousel, UNSAFE_CarouselItem as CarouselItem, + UNSAFE_CarouselItems as CarouselItems, } from '../carousel'; +import { Content, Heading, Media } from '../content'; +import { Description } from '../label'; +import { VideoLoop } from '../video-loop'; +import { UNSAFE_Hero as Hero } from './hero'; const meta: Meta = { title: 'Hero', @@ -33,18 +34,24 @@ const meta: Meta = { - - - - - - + + + + + + + + + + + + ); }; +type _CarouselControlsProps = { + /** The components to be displayed within the carousel. */ + children: React.ReactNode; + /** Additional CSS className for the element. */ + className?: string; +}; + +/** + * This is internal for now, but we will expose it in the future when we support more flexible positioning of prev/next and other actions. + * It is used to render the prev/next buttons in the carousel for now. + */ +const _CarouselControls = ({ children, className }: _CarouselControlsProps) => ( +
+ {children} +
+); + type CarouselItemsProps = { /** Additional CSS className for the element. */ children: React.ReactNode; diff --git a/packages/react/src/hero/hero.stories.tsx b/packages/react/src/hero/hero.stories.tsx index f7d5cc8e6..d7827fa6a 100644 --- a/packages/react/src/hero/hero.stories.tsx +++ b/packages/react/src/hero/hero.stories.tsx @@ -48,10 +48,6 @@ const meta: Meta = { />
- - - - +
(the scroll-snap container) or 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 + '[&:has([data-slot="carousel-items"]:focus-visible,[data-slot="video-loop-button"]:focus-visible)]:outline-focus', + '[&:has([data-slot="carousel-items"]:focus-visible,[data-slot="video-loop-button"]:focus-visible)]:outline-focus-offset', + // Unset the default focus outline for potential video loop buttons, as it interferes with the custom focus styles for the carousel + '**:data-[slot="video-loop-button"]:focus-visible:outline-none', + )} + > + {children} + <_CarouselControls> + + + +
); @@ -167,7 +167,12 @@ type _CarouselControlsProps = { */ const _CarouselControls = ({ children, className }: _CarouselControlsProps) => (
{children} @@ -202,7 +207,6 @@ const CarouselItems = ({ className, children }: CarouselItemsProps) => ( 'snap-mandatory', 'overflow-x-auto', 'outline-none', - 'h-full', 'rounded-[inherit]', ])} ref={ref} @@ -218,35 +222,35 @@ const CarouselItems = ({ className, children }: CarouselItemsProps) => ( ); -type CarouselItemProps = VariantProps & { +type CarouselItemProps = { className?: string; children: React.ReactNode; }; -const carouselItemVariant = cva({ - base: 'shrink-0 basis-full snap-start *:h-full *:w-full', - variants: { - /** - * Control how the content should be placed with the object-fit property - * You might for example want to use `fit="contain"` portrait images that should not be cropped - * @default cover - * */ - fit: { - cover: '*:object-cover', - contain: 'bg-blue-dark *:object-contain', - }, - }, - defaultVariants: { - fit: 'cover', - }, -}); - -const CarouselItem = ({ fit, className, children }: CarouselItemProps) => { - const _className = carouselItemVariant({ fit }); - +const CarouselItem = ({ className, children }: CarouselItemProps) => { return ( -
- {children} +
+ + {children} +
); }; diff --git a/packages/react/src/content/content.tsx b/packages/react/src/content/content.tsx index 271e52ea8..78d697f82 100644 --- a/packages/react/src/content/content.tsx +++ b/packages/react/src/content/content.tsx @@ -85,11 +85,49 @@ const Content = ({ ref = null, ...props }: ContentProps) => { return outerWrapper ? outerWrapper(content) : content; }; -type MediaProps = HTMLProps & { - children: React.ReactNode; -}; +type MediaProps = HTMLProps & + VariantProps & { + children: React.ReactNode; + /** Ref for the element. */ + ref?: Ref; + }; + +const mediaVariant = cva({ + variants: { + /** + * Control how the content should be placed with the object-fit property + * You might for example want to use `fit="contain"` portrait images that should not be cropped + * @default cover + * */ + fit: { + cover: '*:object-cover', + contain: '*:object-contain', + }, + }, +}); -const Media = (props: MediaProps) =>
; +const MediaContext = createContext< + ContextValue, HTMLDivElement> +>({}); + +const Media = ({ ref = null, ...props }: MediaProps) => { + [props, ref] = useContextProps(props, ref, MediaContext); + + const { className, fit, ...restProps } = props; + + const _className = mediaVariant({ + fit, + }); + + return ( +
+ ); +}; type FooterProps = HTMLProps & { children: React.ReactNode; @@ -117,6 +155,7 @@ export { Heading, HeadingContext, Media, + MediaContext, type CaptionProps, type ContentProps, type FooterProps, diff --git a/packages/react/src/hero/hero.stories.tsx b/packages/react/src/hero/hero.stories.tsx index d7827fa6a..a3dbbc4a7 100644 --- a/packages/react/src/hero/hero.stories.tsx +++ b/packages/react/src/hero/hero.stories.tsx @@ -32,24 +32,26 @@ const meta: Meta = { personer som vil ta OBOS videre. Søk på våre ledige stillinger!

- - - - + + + + - - + + + + - - - - + + + + ), @@ -126,31 +128,35 @@ export const StandardWithCarousel = () => ( OBOS-butikken – din lokale OBOS-butikk i Oslo sentrum - - - - + + + + - - + + + + - - + + + + - - - - + + + + ); @@ -212,45 +218,53 @@ export const FullBleedWithCarousel = () => ( Ulven – et nytt nabolag i Oslo - - - + + + - + - + - + + + - - + + + + - - + + + + - - + + + + - - - - + + + + ); diff --git a/packages/react/src/hero/hero.tsx b/packages/react/src/hero/hero.tsx index a1e918ca6..e411ee00f 100644 --- a/packages/react/src/hero/hero.tsx +++ b/packages/react/src/hero/hero.tsx @@ -19,9 +19,10 @@ const oneColumnLayout = [ // Make sure other elements than and (i.e CTA) does not span the full width on small screens '*:not-data-[slot="content"]:not-data-[slot="media"]:w-fit', // Other elements than and (e.g. CTA, SVG logo or Badge) take up 3 columns on medium screens and above, and are right aligned - 'lg:*:not-data-[slot="content"]:not-data-[slot="media"]:col-span-3 lg:*:not-data-[slot="content"]:not-data-[slot="media"]:justify-self-end', + 'lg:*:not-data-[slot="content"]:not-data-[slot="media"]:not-data-[slot="carousel"]:col-span-3 lg:*:not-data-[slot="content"]:not-data-[slot="media"]:justify-self-end', // content takes up the full width on medium screens and above 'lg:*:data-[slot="media"]:col-span-full *:data-[slot="media"]:*:w-full', + 'lg:*:data-[slot="carousel"]:col-span-full *:data-[slot="carousel"]:*:w-full', // Aligns and any element beside it (e.g. , , etc.) to the bottom of the container 'lg:items-end', ]; @@ -37,6 +38,7 @@ const variants = cva({ // Vertical spacing in the '*:data-[slot="content"]:gap-y-3', // Make sure content fills any available vertical and horizontal space + // TODO move to context '*:data-[slot="media"]:*:object-cover', ], variants: { @@ -49,14 +51,16 @@ const variants = cva({ 'full-bleed': [ oneColumnLayout, // biome-ignore lint/nursery/useSortedClasses: biome is unable to sort the custom classes for 3xl and 4xl breakpoints - '*:data-[slot="media"]:h-70 sm:*:data-[slot="media"]:h-[25rem] lg:*:data-[slot="media"]:h-[30rem] lg:*:data-[slot="media"]:h-[35rem] xl:*:data-[slot="media"]:h-[40rem] 2xl:*:data-[slot="media"]:h-[42rem] 3xl:*:data-[slot="media"]:h-[48rem] 4xl:*:data-[slot="media"]:h-[53rem]', + '*:data-[slot="media"]:h-70 sm:*:data-[slot="media"]:h-[25rem] lg:*:data-[slot="media"]:h-[35rem] xl:*:data-[slot="media"]:h-[40rem] 2xl:*:data-[slot="media"]:h-[42rem] 3xl:*:data-[slot="media"]:h-[48rem] 4xl:*:data-[slot="media"]:h-[53rem]', // Match the heights of the wrapper for the Media content (e.g. image, VideoLoop, video etc.) // biome-ignore lint/nursery/useSortedClasses: biome is unable to sort the custom classes for 3xl and 4xl breakpoints '*:data-[slot="media"]:*:h-70 sm:*:data-[slot="media"]:*:h-[25rem] lg:*:data-[slot="media"]:*:h-[35rem] xl:*:data-[slot="media"]:*:h-[40rem] 2xl:*:data-[slot="media"]:*:h-[42rem] 3xl:*:data-[slot="media"]:*:h-[48rem] 4xl:*:data-[slot="media"]:*:h-[53rem]', - // Position the media content to fill the entire viewport width + // Position the media and carousel content to fill the entire viewport width '*:data-[slot="media"]:*:absolute *:data-[slot="media"]:*:left-0', + '*:data-[slot="carousel"]:*:absolute *:data-[slot="carousel"]:*:left-0', + '**:data-[slot="carousel-controls"]:container **:data-[slot="carousel-controls"]:right-0 **:data-[slot="carousel-controls"]:bottom-4 **:data-[slot="carousel-controls"]:left-0 **:data-[slot="carousel-controls"]:justify-end', // Override rounded corners of Carousel slots - '**:data-[slot="carousel"]:rounded-none', + '*:data-[slot="carousel"]:*:rounded-none', ], 'two-column': [ 'lg:items-center lg:*:col-span-6', From adc0a25fc74112edf409606726a9a45472ba0abc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Tue, 10 Jun 2025 14:37:34 +0200 Subject: [PATCH 18/24] Remove redundant CSS classes --- packages/react/src/hero/hero.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/react/src/hero/hero.tsx b/packages/react/src/hero/hero.tsx index e411ee00f..296d10931 100644 --- a/packages/react/src/hero/hero.tsx +++ b/packages/react/src/hero/hero.tsx @@ -50,11 +50,9 @@ const variants = cva({ standard: [roundedMediaCorners, oneColumnLayout], 'full-bleed': [ oneColumnLayout, - // biome-ignore lint/nursery/useSortedClasses: biome is unable to sort the custom classes for 3xl and 4xl breakpoints - '*:data-[slot="media"]:h-70 sm:*:data-[slot="media"]:h-[25rem] lg:*:data-[slot="media"]:h-[35rem] xl:*:data-[slot="media"]:h-[40rem] 2xl:*:data-[slot="media"]:h-[42rem] 3xl:*:data-[slot="media"]:h-[48rem] 4xl:*:data-[slot="media"]:h-[53rem]', // Match the heights of the wrapper for the Media content (e.g. image, VideoLoop, video etc.) // biome-ignore lint/nursery/useSortedClasses: biome is unable to sort the custom classes for 3xl and 4xl breakpoints - '*:data-[slot="media"]:*:h-70 sm:*:data-[slot="media"]:*:h-[25rem] lg:*:data-[slot="media"]:*:h-[35rem] xl:*:data-[slot="media"]:*:h-[40rem] 2xl:*:data-[slot="media"]:*:h-[42rem] 3xl:*:data-[slot="media"]:*:h-[48rem] 4xl:*:data-[slot="media"]:*:h-[53rem]', + '*:data-[slot="media"]:*:h-70 sm:*:data-[slot="media"]:*:h-[25rem] md:*:data-[slot="media"]:*:h-[30rem] lg:*:data-[slot="media"]:*:h-[35rem] xl:*:data-[slot="media"]:*:h-[40rem] 2xl:*:data-[slot="media"]:*:h-[42rem] 3xl:*:data-[slot="media"]:*:h-[48rem] 4xl:*:data-[slot="media"]:*:h-[53rem]', // Position the media and carousel content to fill the entire viewport width '*:data-[slot="media"]:*:absolute *:data-[slot="media"]:*:left-0', '*:data-[slot="carousel"]:*:absolute *:data-[slot="carousel"]:*:left-0', From e4737047e2023f18aa6980d0c347b847e265b53a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Tue, 10 Jun 2025 14:38:16 +0200 Subject: [PATCH 19/24] Remove comment --- packages/react/src/hero/hero.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/react/src/hero/hero.tsx b/packages/react/src/hero/hero.tsx index 296d10931..134acf575 100644 --- a/packages/react/src/hero/hero.tsx +++ b/packages/react/src/hero/hero.tsx @@ -38,7 +38,6 @@ const variants = cva({ // Vertical spacing in the '*:data-[slot="content"]:gap-y-3', // Make sure content fills any available vertical and horizontal space - // TODO move to context '*:data-[slot="media"]:*:object-cover', ], variants: { From 8c0053fa560ec95090613396a17eaa4bbdf2be1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Tue, 10 Jun 2025 14:40:07 +0200 Subject: [PATCH 20/24] Fix comment --- packages/react/src/hero/hero.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/hero/hero.tsx b/packages/react/src/hero/hero.tsx index 134acf575..41cf85770 100644 --- a/packages/react/src/hero/hero.tsx +++ b/packages/react/src/hero/hero.tsx @@ -20,7 +20,7 @@ const oneColumnLayout = [ '*:not-data-[slot="content"]:not-data-[slot="media"]:w-fit', // Other elements than and (e.g. CTA, SVG logo or Badge) take up 3 columns on medium screens and above, and are right aligned 'lg:*:not-data-[slot="content"]:not-data-[slot="media"]:not-data-[slot="carousel"]:col-span-3 lg:*:not-data-[slot="content"]:not-data-[slot="media"]:justify-self-end', - // content takes up the full width on medium screens and above + // and content takes up the full width on medium screens and above 'lg:*:data-[slot="media"]:col-span-full *:data-[slot="media"]:*:w-full', 'lg:*:data-[slot="carousel"]:col-span-full *:data-[slot="carousel"]:*:w-full', // Aligns and any element beside it (e.g. , , etc.) to the bottom of the container From baa0d8ab3f1f09966110ead7da38c9d40b8452bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Tue, 10 Jun 2025 14:45:41 +0200 Subject: [PATCH 21/24] Update code examples --- .changeset/many-rocks-itch.md | 114 +++++++++++++++++++--------------- 1 file changed, 65 insertions(+), 49 deletions(-) diff --git a/.changeset/many-rocks-itch.md b/.changeset/many-rocks-itch.md index 406cdec64..89d0486d7 100644 --- a/.changeset/many-rocks-itch.md +++ b/.changeset/many-rocks-itch.md @@ -12,36 +12,44 @@ New `Carousel` component that can be used for any content, all though primarily ``` tsx - + + - - + + + + - + tem> + // This image has a portrait aspect ratio - + - - + + + + - + + ``` -Use the `fit` prop on the `` primitive to control the `object-fit` (`cover` | `contain`) behavior of the item, this is a way to prevent cropping of images in portrait format. This defaults to `cover`, so for portrait images set it to `contain`. +Use the `fit` prop on the `` primitive to control the `object-fit` (`cover` | `contain`) behavior of the item, this is a way to prevent cropping of images in portrait format. This defaults to `cover`, so for portrait images set it to `contain`. ### In Hero @@ -52,46 +60,54 @@ The component can also be used inside the `` component: Ulven – et nytt nabolag i Oslo - - - - - - - - - - - - - - - // This image has a portrait aspect ratio - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + // This image has a portrait aspect ratio + + + + + + + + + + + ``` From 08c0a453f9692af87df091d4ff1dd14fcf9fb850 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Tue, 10 Jun 2025 14:47:43 +0200 Subject: [PATCH 22/24] Organize imports --- packages/react/src/carousel/carousel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/carousel/carousel.tsx b/packages/react/src/carousel/carousel.tsx index ebc119a0c..e40845ed1 100644 --- a/packages/react/src/carousel/carousel.tsx +++ b/packages/react/src/carousel/carousel.tsx @@ -5,9 +5,9 @@ import { createContext, useEffect, useRef, useState } from 'react'; import { Provider } from 'react-aria-components'; import { useDebouncedCallback } from 'use-debounce'; import { Button, ButtonContext } from '../button'; +import { MediaContext } from '../content'; import { translations } from '../translations'; import { useLocale } from '../use-locale'; -import { MediaContext } from '../content'; type CarouselProps = { /** The components to be displayed within the carousel. */ From b4464733ae85689bea5917036f35ae653b812973 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Tue, 10 Jun 2025 14:57:58 +0200 Subject: [PATCH 23/24] Update notes --- .changeset/many-rocks-itch.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/many-rocks-itch.md b/.changeset/many-rocks-itch.md index 89d0486d7..672ebe6bb 100644 --- a/.changeset/many-rocks-itch.md +++ b/.changeset/many-rocks-itch.md @@ -49,7 +49,7 @@ New `Carousel` component that can be used for any content, all though primarily ``` -Use the `fit` prop on the `` primitive to control the `object-fit` (`cover` | `contain`) behavior of the item, this is a way to prevent cropping of images in portrait format. This defaults to `cover`, so for portrait images set it to `contain`. +Use the `fit` prop on the `` primitive to control the `object-fit` (`cover` | `contain`) behavior of it's children, this is a way to prevent cropping of images in portrait format. This defaults to `cover`, so for portrait images set it to `contain`. ### In Hero From 2db7498521fe682c9c944eab6f9bd830851557dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Tue, 10 Jun 2025 15:48:44 +0200 Subject: [PATCH 24/24] Only apply bg color to object-contain images --- packages/react/src/carousel/carousel.tsx | 2 +- packages/react/src/content/content.tsx | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/react/src/carousel/carousel.tsx b/packages/react/src/carousel/carousel.tsx index e40845ed1..e2f25fe13 100644 --- a/packages/react/src/carousel/carousel.tsx +++ b/packages/react/src/carousel/carousel.tsx @@ -240,7 +240,7 @@ const CarouselItem = ({ className, children }: CarouselItemProps) => { { fit: 'cover', className: cx( - 'bg-blue-dark', + 'data-[fit="contain"]:bg-blue-dark', '*:w-full', // biome-ignore lint/nursery/useSortedClasses: biome is unable to sort the custom classes for 3xl and 4xl breakpoints '*:h-70 sm:*:h-[25rem] lg:*:h-[35rem] xl:*:h-[40rem] 2xl:*:h-[42rem] 3xl:*:h-[48rem] 4xl:*:h-[53rem]', diff --git a/packages/react/src/content/content.tsx b/packages/react/src/content/content.tsx index 78d697f82..ad01341e4 100644 --- a/packages/react/src/content/content.tsx +++ b/packages/react/src/content/content.tsx @@ -123,6 +123,9 @@ const Media = ({ ref = null, ...props }: MediaProps) => {