carbon-design-system / ibm-products

A Carbon-powered React component library for IBM Products
https://ibm-products.carbondesignsystem.com
Apache License 2.0
97 stars 138 forks source link

Explore: Creating a Utility section in Storybook #5341

Open aubrey-oneal opened 5 months ago

aubrey-oneal commented 5 months ago

We are looking for a home for the two potential utilities on the product site which are currently listed as a pattern

Rather than cluttering the left hand nav, is it a good idea to have this only live in Storybook?

Screenshot 2024-05-24 at 11 48 22 AM
### Tasks
- [ ] Explore new section within storybook, nested?
### Utilities
- [ ] Action bar
- [ ] Delimited list
- [ ] Scroll gradient
- [ ] String formatter
- [ ] Tag overflow
- [ ] Truncated list
- [ ] ~Cascade~
ljcarot commented 3 months ago

@ljcarot

elycheea commented 1 week ago

I’ve updated the current task list to include the experimental components that we identified as utilities. Cascade is currently considered stable so will wait until after #6165 to determine if there’s any additional requirements for stable to utility.

elycheea commented 1 week ago

@aubrey-oneal As a follow up to some of our experimental component audit, we also felt like we should add a utility section, but I do think we should have more discussion on if/how we document utilities. #6157

davidmenendez commented 5 days ago

here are some notes i've compiled while looking into this.

the main thing is that because storybook is first and foremost a library for displaying UI components. it's not really equipped to showcase non-UI elements like general utils and hooks. so while keeping this in mind, i think the best way to work around this is ensure stories are written in a way that illustrates what the utils do by creating some basic example code. we had a similar concept with storybook only components, but to lessen file clutter i think you can get away with something as simple as creating the example implementation directly in the stories file.

consider this example: here is a simple string reversal utility

// reverse.ts
const reverse = (str: string): string => {
  return str.split("").reverse().join("");
};

export default reverse;

here is what a accompanying story could look like:

// reverse.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";
import reverse from "./reverse";

export interface ReverseProps {
  text: string;
}

const ReverseDemo = (props: ReverseProps) => {
  const { text } = props;
  return (
    <div>
      <p>{reverse(text)}</p>
    </div>
  );
};

const meta: Meta<typeof ReverseDemo> = {
  component: ReverseDemo,
  tags: ["autodocs"],
};

export default meta;
type Story = StoryObj<typeof ReverseDemo>;

export const Default: Story = {
  args: {
    text: "a test string",
  },
};

what we end up with is a simple story that displays the functionality of the utility while also allowing the user to interact with it directly via the args

Image

the only caveat i can see with this approach is that there may be some manual documenting necessary to explain function parameters if the args table isn't enough. not the end of the world, but something to keep in mind if further explanation is necessary.

core appears to do something similar in their hooks section.

davidmenendez commented 5 days ago

as per our discussion earlier today: we should also take a look at moving storybook config from core and into ibm-products as well revisiting the config and preview template to see if we still need all the customization that is built into it. it may be that a lot of the stuff in there is dated and not necessary. we might be able to remove a bunch of that code in order to simplify our storybook config.

davidmenendez commented 2 days ago

the overflow logic from TagOverflow can easily be extracted into a reusable hook

import { useState, useEffect, useCallback, useRef } from 'react';
import { useResizeObserver } from '../../global/js/hooks/useResizeObserver';

const useOverflow = ({
  items,
  containingElementRef,
  containerRef,
  multiline,
  measurementOffset,
  overflowRef,
  maxVisible,
  onOverflowTagChange,
}) => {
  const itemRefs = useRef<Map<string, string> | null>(null);
  const itemOffset = 4;
  const overflowIndicatorWidth = 40;

  const resizeElm =
    containingElementRef && containingElementRef.current
      ? containingElementRef
      : containerRef;
  const [containerWidth, setContainerWidth] = useState(0);
  const [visibleItems, setVisibleItems] = useState([]);
  const [overflowItems, setOverflowItems] = useState([]);

  const handleResize = () => {
    if (typeof resizeElm !== 'function' && resizeElm.current) {
      setContainerWidth(resizeElm.current.offsetWidth);
    }
  };

  useResizeObserver(resizeElm, handleResize);

  const getMap = () => {
    if (!itemRefs.current) {
      itemRefs.current = new Map();
    }
    return itemRefs.current;
  };

  const itemRefHandler = (id, node) => {
    const map = getMap();
    if (id && node && map.get(id) !== node.offsetWidth) {
      map.set(id, node.offsetWidth);
    }
  };

  const getVisibleItems = useCallback(() => {
    if (!itemRefs.current) {
      return items;
    }

    if (multiline) {
      const visibleItems = maxVisible ? items?.slice(0, maxVisible) : items;
      return visibleItems;
    }

    const map = getMap();
    const optionalContainingElement = containingElementRef?.current;
    const measurementOffsetValue =
      typeof measurementOffset === 'number' ? measurementOffset : 0;
    const spaceAvailable = optionalContainingElement
      ? optionalContainingElement.offsetWidth - measurementOffsetValue
      : containerWidth;

    const overflowContainerWidth =
      overflowRef &&
      overflowRef.current &&
      overflowRef.current.offsetWidth > overflowIndicatorWidth
        ? overflowRef.current.offsetWidth
        : overflowIndicatorWidth;
    const maxWidth = spaceAvailable - overflowContainerWidth;

    let childrenWidth = 0;
    let maxReached = false;

    return items.reduce((prev, cur) => {
      if (!maxReached) {
        const itemWidth = (map ? Number(map.get(cur.id)) : 0) + itemOffset;
        const fits = itemWidth + childrenWidth < maxWidth;

        if (fits) {
          childrenWidth += itemWidth;
          prev.push(cur);
        } else {
          maxReached = true;
        }
      }
      return prev;
    }, []);
  }, [
    containerWidth,
    containingElementRef,
    items,
    maxVisible,
    measurementOffset,
    multiline,
    overflowRef,
  ]);

  useEffect(() => {
    let visibleItemsArr = getVisibleItems();

    if (maxVisible && maxVisible < visibleItemsArr.length) {
      visibleItemsArr = visibleItemsArr?.slice(0, maxVisible);
    }

    const hiddenItems = items?.slice(visibleItemsArr.length);
    const overflowItemsArr = hiddenItems?.map(({ tagType, ...other }) => {
      return { type: tagType, ...other };
    });

    setVisibleItems(visibleItemsArr);
    setOverflowItems(overflowItemsArr);
    onOverflowTagChange?.(overflowItemsArr);
  }, [getVisibleItems, items, maxVisible, onOverflowTagChange]);

  return {
    visibleItems,
    itemRefHandler,
    overflowItems,
  };
};

export default useOverflow;
...
const { visibleItems, overflowItems, itemRefHandler } = useOverflow({
  items,
  containingElementRef,
  containerRef,
  multiline,
  measurementOffset,
  overflowRef,
  maxVisible,
  onOverflowTagChange,
});

this logic could easily be retrofitted to work with;

so basically a single hook could be used to satisfy the needs of all our various patterns and components that have some kind of interaction with overflow.