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 }) => (
+