Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[joy-ui] Add Carousel component #33392

Open
2 tasks done
Rafcin opened this issue Jul 4, 2022 · 9 comments
Open
2 tasks done

[joy-ui] Add Carousel component #33392

Rafcin opened this issue Jul 4, 2022 · 9 comments
Labels
new feature New feature or request package: joy-ui Specific to @mui/joy waiting for 👍 Waiting for upvotes

Comments

@Rafcin
Copy link

Rafcin commented Jul 4, 2022

Duplicates

  • I have searched the existing issues

Latest version

  • I have tested the latest version

Summary 💡

@siriwatknp @danilo-leal

So standard Material UI doesn't provide any carousel component out of the box, however after playing around with the new Joy UI components I started writing a CSS snap carousel component that could possibly be useful for Joy. The idea partially stems from the Google news feed and a few other apps I commonly use.

I'll provide the src files below as this isn't a ready component in any way, it still doesn't have the forward back scroll functions in place, a lot of the type names need to be fixed, and I'm unsure if the virtual library I use is appropriate for this. It definitely needs a bit of work but I'd be really happy to hear people's thoughts on how a carousel component should be designed.

BUGS
So far the only bug I've run into is when you scroll to the last element, the first element in the list beings to render, then disappear in a loop. I honestly don't know if virtual makes sense for this, however major carousel libraries have this kind of support and there are cases where it could be needed. Perhaps you are rendering a gallery then it could make sense to have, images that wouldn't all be loaded at once, etc.

NOTES
There is also a prop called isSSR to switch between standard rendering and using the virtual hook (@tanstack/react-virtual), I still need to devise a solution to allow users to programmatically scroll regardless of the method used.

(Also the file structure is slightly dif from MUI. Would be fixed later.)
Index

Carousel Root w/ Virtual Hook

import { mergeRefs } from '@fox-dls/utils';
import { unstable_composeClasses as composeClasses } from '@mui/base';
import { Box, Button, useThemeProps } from '@mui/material';
import { OverridableComponent } from '@mui/types';
import clsx from 'clsx';
import PropTypes from 'prop-types';
import * as React from 'react';
import { CarouselContext } from './context';
import { CarouselRoot } from './styles';
import {
  getCarouselUtilityClass,
  CarouselProps,
  CarouselTypeMap
} from './types';
import { useVirtualizer } from '@tanstack/react-virtual';

const useUtilityClasses = (ownerState: CarouselProps) => {
  const slots = {
    root: []
  };

  return composeClasses(slots, getCarouselUtilityClass, {});
};

export const Carousel = React.forwardRef(function Carousel(inProps, ref) {
  const props = useThemeProps({
    props: inProps,
    name: 'MuiCarousel'
  });

  const {
    className,
    //@ts-ignore
    component = 'div',
    children,
    visibleItems,
    gap,
    isControls,
    vertical,
    viewConfig,
    placeholder,
    isSSR,
    ...other
  } = props;

  const ownerState = {
    ...props,
    component
  };

  const classes = useUtilityClasses(ownerState);

  const scrollerRef = React.useRef();

  const scrollerVirtualizer = useVirtualizer({
    horizontal: vertical ? false : true,
    count: React.Children.count(children),
    getScrollElement: () => scrollerRef.current,
    estimateSize: () => 0,
    ...viewConfig
  });

  return (
    <CarouselContext.Provider value={null}>
      <CarouselRoot
        as={component}
        ownerState={ownerState}
        className={clsx(classes.root, className)}
        ref={mergeRefs([ref, scrollerRef])}
        {...other}
      >
        {isSSR
          ? scrollerVirtualizer.getVirtualItems().map(virtual => (
              <Box key={virtual.index} ref={virtual.measureElement}>
                {placeholder
                  ? React.Children.toArray(children)[virtual.index] ??
                    placeholder
                  : React.Children.toArray(children)[virtual.index]}
              </Box>
            ))
          : React.Children.map(children, (child, index) => {
              if (!React.isValidElement(child)) {
                return child;
              }
              if (index === 0) {
                return React.cloneElement(child, {
                  'carousel-first-child': ''
                });
              }
              if (index === React.Children.count(children) - 1) {
                return React.cloneElement(child, { 'carousel-last-child': '' });
              }
              return child;
            })}
      </CarouselRoot>
      <button onClick={() => scrollerVirtualizer.scrollToIndex(4)}>
        Scroll To Index 2
      </button>
    </CarouselContext.Provider>
  );
}) as OverridableComponent<CarouselTypeMap>;

Carousel.propTypes /* remove-proptypes */ = {
  /**
   * Used to render icon or text elements inside the Carousel if `src` is not set.
   * This can be an element, or just a string.
   */
  children: PropTypes.node,
  /**
   * @ignore
   */
  className: PropTypes.string,
  /**
   * The component used for the root node.
   * Either a string to use a HTML element or a component.
   */
  component: PropTypes.elementType,
  /**
   * The number of items visible at a given time. Defaults to 1
   */
  visibleItems: PropTypes.number,
  /**
   * Item peek in px
   */
  peek: PropTypes.string,
  /**
   * The gap between items in px. Defaults to 0
   */
  gap: PropTypes.string,
  /**
   * Is Verical or Horizontal
   */
  vertical: PropTypes.bool,
  /**
   * Config for view observer
   */
  viewConfig: PropTypes.object,
  /**
   * Children props
   */
  itemProps: PropTypes.shape({}),
  /**
   * Placeholder node to load
   */
  placeholder: PropTypes.node,
  /**
   * Is SSR
   */
  isSSR: PropTypes.bool,
  sx: PropTypes.oneOfType([
    PropTypes.arrayOf(
      PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])
    ),
    PropTypes.func,
    PropTypes.object
  ])
} as any;

Types & Classes

Types & Classes

import * as React from 'react';
import { OverridableStringUnion, OverrideProps } from '@mui/types';
import { SxProps } from '@mui/system';
import { generateUtilityClass, generateUtilityClasses } from '@mui/base';

export type CarouselSlot = 'root';

export interface CarouselTypeMap<P = {}, D extends React.ElementType = 'div'> {
  props: P & {
    /**
     * Used to render icon or text elements inside the Carousel if `src` is not set.
     * This can be an element, or just a string.
     */
    children?: React.ReactNode;
    /**
     * The system prop that allows defining system overrides as well as additional CSS styles.
     */
    sx?: SxProps;
    /**
     * The number of items visible at a given time. Defaults to 1
     */
    visibleItems?: number;
    /**
     * Item peek in px
     */
    peek?: string;
    /**
     * The gap between items in px. Defaults to 0
     */
    gap?: string;
    /**
     * Are the overlay controls available
     */
    isControls?: boolean;
    /**
     * Is Horizontal or Vertical
     */
    vertical?: boolean;
    /**
     * Config for view observer
     */
    viewConfig?: object;
    /**
     * Placeholder node to load
     */
    placeholder?: React.ReactNode;
    /**
     * Is SSR
     */
    isSSR?: boolean;
  };
  defaultComponent: D;
}

export type CarouselProps<
  D extends React.ElementType = CarouselTypeMap['defaultComponent'],
  P = { component?: React.ElementType }
> = OverrideProps<CarouselTypeMap<P, D>, D>;

export interface CarouselClasses {
  /** Styles applied to the root element. */
  root: string;
}

export type CarouselClassKey = keyof CarouselClasses;

export function getCarouselUtilityClass(slot: string): string {
  return generateUtilityClass('MuiCarousel', slot);
}

const CarouselClasses: CarouselClasses = generateUtilityClasses('MuiCarousel', [
  'root'
]);

export default CarouselClasses;

Styles

Carousel Style

import { styled } from '@mui/system';
import { CarouselProps } from '../types';
import { resolveSxValue } from '../../theme/mui/utils';

export const CarouselRoot = styled('div', {
  name: 'MuiCarousel',
  slot: 'Root',
  overridesResolver: (props, styles) => styles.root
})<{ theme?: any; ownerState: CarouselProps }>(({ theme, ownerState }) => [
  {
    '--Carousel-contain': 'layout paint size style',
    '--Carousel-overflow': 'visible hidden',
    '--Carousel-content-visibility': 'unset',
    '--Carousel-controls-z-index': 1,
    '--Carousel-gap': ownerState.gap ?? '0px',
    '--Carousel-height': '100%',
    '--Carousel-peek': ownerState.peek ?? '0px',
    '--Carousel-scrollsnapstop': 'always',
    '--Carousel-visible-items': ownerState.visibleItems ?? 1,

    '&::-webkit-scrollbar': {
      display: 'none'
    },

    '& > *': {
      scrollSnapAlign: 'start'
    },

    ['@supports (-webkit-scroll-behavior:smooth) or (-moz-scroll-behavior:smooth) or (-ms-scroll-behavior:smooth) or (scroll-behavior:smooth)']:
      {
        scrollSnapType: 'var(--Carousel-scroll-snap-type,inline mandatory);'
      },
    margin: '0px !important',
    width: '100% !important',
    height: 'var(--Carousel-height)',
    contain: 'var(--Carousel-contain)',
    containIntrinsicSize: 'var(--Carousel-contain-intrinsic)',
    //contentVisibility: 'var(--Carousel-content-visibility)',
    display: 'grid',
    gridAutoFlow: 'column',
    gridAutoColumns:
      'var( --Carousel-auto-columns, calc( ( 100% - var(--Carousel-gap,16px) * (var(--Carousel-visible-items,unset) - 1) ) / var(--Carousel-visible-items,unset) ) )',
    gap: 'var(--Carousel-gap,16px)',
    gridTemplateRows: 'var(--Carousel-rows,none)',
    justifyContent: 'flex-start',
    minHeight: 'var(--Carousel-min-height)',
    overflow: 'var(--Carousel-overflow,auto hidden)',
    overscrollBehaviorInline: 'contain',
    paddingBlockStart: 'var(--Carousel-padding-block-start,unset)',
    paddingBlockEnd: 'var(--Carousel-padding-block-end,unset)',
    paddingInlineStart:
      'var( --Carousel-padding-inline-start, var(--Carousel-peek,32px) )',
    paddingInlineEnd:
      'var( --Carousel-padding-inline-end, var(--Carousel-peek,32px) )',
    scrollPaddingInline: 'var(--Carousel-peek,32px)',
    touchAction: 'var(--Carousel-touch-action,pan-x pan-y pinch-zoom)',
    scrollbarWidth: 'none'
  }
]);

Examples 🌈

I still need to move all this to its own sandbox.

<AspectRatio ratio="20/19" sx={{ width: '100%', height: '100%' }}>
        <CardContainer>
          <CardContent>
            <Carousel
              gap="0px"
              visibleItems={2}
              gap="10px"
              placeholder={<Loading />}
              isSSR={true}
            >
              <CardContainer sx={{ height: '100%', borderRadius: '0px' }}>
                <CardCover>
                  <img
                    src="https://images.unsplash.com/photo-1656536665880-2cb1ab7d54e3?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=774&q=80"
                    alt=""
                  />
                </CardCover>
              </CardContainer>
              <CardContainer sx={{ height: '100%', borderRadius: '0px' }}>
                <CardCover>
                  <img
                    src="https://images.unsplash.com/photo-1656231934649-c476090deb59?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1524&q=80"
                    alt=""
                  />
                </CardCover>
              </CardContainer>
              <CardContainer sx={{ height: '100%', borderRadius: '0px' }}>
                <CardCover>
                  <img
                    src="https://images.unsplash.com/photo-1656501854040-2850f4a37562?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=774&q=80"
                    alt=""
                  />
                </CardCover>
              </CardContainer>
              <CardContainer sx={{ height: '100%', borderRadius: '0px' }}>
                <CardCover>
                  <img
                    src="https://images.unsplash.com/photo-1656501854040-2850f4a37562?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=774&q=80"
                    alt=""
                  />
                </CardCover>
              </CardContainer>
            </Carousel>
          </CardContent>
        </CardContainer>
      </AspectRatio>

Motivation 🔦

It looks to me as if Joy might be the next big thing for MUI and I would like to attempt to provide useful components so that other developers will be able to make cool things!

@Rafcin Rafcin added the status: waiting for maintainer These issues haven't been looked at yet by a maintainer label Jul 4, 2022
@danilo-leal danilo-leal changed the title MUI [Joy] Carousel proposal [Joy] Add Carousel component Jul 4, 2022
@danilo-leal danilo-leal added package: joy-ui Specific to @mui/joy new feature New feature or request waiting for 👍 Waiting for upvotes and removed status: waiting for maintainer These issues haven't been looked at yet by a maintainer labels Jul 4, 2022
@danilo-leal
Copy link
Contributor

Hey there @Rafcin, thanks for opening up the issue! I labeled it as waiting for 👍 so we track how many other community members resonate with the request. Also thanks for the files! Would you mind sharing a working CodeSandbox with them, though? It would be great to see in action.

Also, any benchmarks you'd recommend to checkout?

@Rafcin
Copy link
Author

Rafcin commented Jul 4, 2022

@danilo-leal Sure I'll set it all up in a sandbox when I get the chance! At the moment the carousel is really funky, I'm going to spend the next few hours on it, I'll come back with a stable version as soon as I can!

@Rafcin
Copy link
Author

Rafcin commented Jul 6, 2022

So I smashed my head against my desk for a good day now, and I came up with this.

https://codesandbox.io/s/mui-carousel-proposal-c8m0rs?file=/Carousel/index.tsx

It's a hook called useCarousel that returns dimensions (not needed really can be removed as a dep), Carousel, next, previous, and the refs for the container + the carousel.

There is also an additional component called Slide that uses react-cool-inview and the idea is you can optionally wrap your child components with a slide and using the carousel ref you can determine when to show images or something. I had this whole idea of wanting to use a virtualized hook-like react-virtual or react-cool-virtual but it was too much of a pain in the ass to do and the idea here was to use the CSS snap instead of transform.

I haven't fully tested this although it's kind of starting to look like what I want it to be!

NOTE: Open the Codesandbox page in a new tab and switch to mobile view so you can swipe.

@Rafcin
Copy link
Author

Rafcin commented Jul 6, 2022

It should allow for some freedom, with next and previous coming from the hook it's much easier to implement your own carousel buttons as opposed to having them overlayed on the left and right. If anyone has any feedback I would greatly appreciate it, optimizations, best practices, etc, I would really like to try to get a carousel into Mui so other developers have more in their arsenal!

@Rafcin
Copy link
Author

Rafcin commented Jul 7, 2022

Update. I changed it around a bit. Now the component is a carousel component and a useCarousel hook.

@Rafcin
Copy link
Author

Rafcin commented Jul 8, 2022

I think this solution is much better, the Carousel object itself is quite simple and honestly lets users decide how they want to handle the navigation aspect. Included is the hook that takes the ref and lets you move forward, back, etc. I'm already making a slightly different hook with better functions but I think the carousel and hook idea might be good. It's no swiper JS but it's simple and I think the CSS snap feature is great for most use cases!

@oliviertassinari
Copy link
Member

oliviertassinari commented Jan 29, 2023

Same as mui/mui-x#3601? The issue was initially on MUI Core, but I transferred it to MUI X per https://mui-org.notion.site/X-FAQ-c33e9a7eabba4da1ad7f8c04f99044cc.

@mguinhos
Copy link

Still does not have a native carousel? Bruh

@MJUIUC
Copy link

MJUIUC commented Sep 19, 2023

I could really use a carousel from MUI. Y'all drowning in tech debt or something?

@danilo-leal danilo-leal changed the title [Joy] Add Carousel component [joy-ui] Add Carousel component Sep 19, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
new feature New feature or request package: joy-ui Specific to @mui/joy waiting for 👍 Waiting for upvotes
Projects
None yet
Development

No branches or pull requests

5 participants