diff --git a/.changeset/many-rocks-itch.md b/.changeset/many-rocks-itch.md new file mode 100644 index 000000000..672ebe6bb --- /dev/null +++ b/.changeset/many-rocks-itch.md @@ -0,0 +1,116 @@ +--- +"@obosbbl/grunnmuren-tailwind": minor +"@obosbbl/grunnmuren-react": minor +--- + +# Carousel + +## grunnmuren-react +New `Carousel` component that can be used for any content, all though primarily intended for media such as images and `VideoLoops`. + +## Usage +``` 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 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 + +The component can also be used inside the `` component: +``` tsx + + + Ulven + – et nytt nabolag i Oslo + + + + + + + + + + + + + + + + + + + + + + // This image has a portrait aspect ratio + + + + + + + + + + + + +``` + +## grunnmuren-tailwind + +A new `scrollbar-hidden` utility class has been added which hides scorllbar visually. This is needed in the `` component, as it uses `snap-scroll`. diff --git a/packages/react/package.json b/packages/react/package.json index 7c086adfb..07f833fde 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..95bf9485e --- /dev/null +++ b/packages/react/src/carousel/carousel.stories.tsx @@ -0,0 +1,64 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { + UNSAFE_Carousel as Carousel, + UNSAFE_CarouselItem as CarouselItem, + UNSAFE_CarouselItems as CarouselItems, +} from '../carousel'; +import { Media } from '../content'; + +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 new file mode 100644 index 000000000..e2f25fe13 --- /dev/null +++ b/packages/react/src/carousel/carousel.tsx @@ -0,0 +1,265 @@ +import { ChevronLeft, ChevronRight } from '@obosbbl/grunnmuren-icons-react'; +import { useUpdateEffect } from '@react-aria/utils'; +import { cx } from 'cva'; +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'; + +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) => { + const ref = useRef(null); + const locale = useLocale(); + const { previous, next } = translations; + + const [scrollTargetIndex, setScrollTargetIndex] = useState(0); + + const [hasReachedScrollStart, setHasReachedScrollStart] = useState( + scrollTargetIndex === 0, + ); + + const [hasReachedScrollEnd, setHasReachedScrollEnd] = useState( + !ref.current || ref.current.children.length - 1 === scrollTargetIndex, + ); + + useEffect(() => { + setHasReachedScrollStart(scrollTargetIndex === 0); + setHasReachedScrollEnd( + !ref.current || ref.current.children.length - 1 === scrollTargetIndex, + ); + }, [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]); + + 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); + } + }, + isDisabled: hasReachedScrollStart, + }, + next: { + isIconOnly: true, + 'aria-label': next[locale], + onPress: () => { + if (!ref.current) return; + if (scrollTargetIndex < ref.current.children.length - 1) { + setScrollTargetIndex((prev) => prev + 1); + } + }, + isDisabled: hasReachedScrollEnd, + }, + }, + }, + ], + ]} + > +
(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> + + + +
+
+
+ ); +}; + +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; + /** 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 }) => ( +
+ {children} +
+ )} +
+); + +type CarouselItemProps = { + className?: string; + children: React.ReactNode; +}; + +const CarouselItem = ({ className, children }: CarouselItemProps) => { + return ( +
+ + {children} + +
+ ); +}; + +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/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/react/src/content/content.tsx b/packages/react/src/content/content.tsx index 271e52ea8..ad01341e4 100644 --- a/packages/react/src/content/content.tsx +++ b/packages/react/src/content/content.tsx @@ -85,11 +85,52 @@ 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 +158,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 d41b972a4..a3dbbc4a7 100644 --- a/packages/react/src/hero/hero.stories.tsx +++ b/packages/react/src/hero/hero.stories.tsx @@ -3,6 +3,11 @@ import type { Meta, StoryObj } from '@storybook/react'; import { Group } from 'react-aria-components'; import { Badge } from '../badge'; import { Button } from '../button'; +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'; @@ -27,12 +32,26 @@ const meta: Meta = { personer som vil ta OBOS videre. Søk på våre ledige stillinger!

- - - + + + + + + + + + + + + + + ), @@ -102,6 +121,46 @@ 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+); diff --git a/packages/react/src/hero/hero.tsx b/packages/react/src/hero/hero.tsx index 3bf8252b5..41cf85770 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', - // content takes up the full width on medium screens and above + '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', + // 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 'lg:items-end', ]; @@ -48,13 +49,15 @@ 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-[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]', // 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-[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 content to fill the entire viewport width + '*: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', + '**: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', ], 'two-column': [ 'lg:items-center lg:*:col-span-6', diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index dee421c38..9a9cf3e70 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -27,3 +27,4 @@ export * from './avatar'; export * from './modal'; export * from './tag-group'; export * from './hero'; +export * from './carousel'; 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 }; diff --git a/packages/react/src/video-loop/video-loop.tsx b/packages/react/src/video-loop/video-loop.tsx index 0c0d60d60..8e8a11114 100644 --- a/packages/react/src/video-loop/video-loop.tsx +++ b/packages/react/src/video-loop/video-loop.tsx @@ -87,6 +87,7 @@ export const VideoLoop = ({ src, format, alt, className }: VideoLoopProps) => { {userPrefersReducedMotion !== null && (