Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Below is a list of the components available in this library. Each component has
- [BBBInput](./src/components/Input/README.md)
- [BBBModal](./src/components/Modal//README.md)
- [BBBNavigation](./src/components/Navigation/README.md)
- [BBBScrollArea](./src/components/ScrollArea/README.md)
- [BBBSearch](./src/components/Search/README.md)
- [BBBSelect](./src/components/Select/README.md)
- [BBBSpinner](./src/components/Spinner//README.md)
Expand Down
10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,13 @@
"require": "./dist/components/Navigation.js",
"default": "./dist/components/Navigation.js"
},
"./ScrollArea": {
"types": "./dist/types/components/ScrollArea/index.d.ts",
"node": "./dist/components/ScrollArea.js",
"import": "./dist/esm/components/ScrollArea/index.js",
"require": "./dist/components/ScrollArea.js",
"default": "./dist/components/ScrollArea.js"
},
"./Search": {
"types": "./dist/types/components/Search/index.d.ts",
"node": "./dist/components/Search.js",
Expand Down Expand Up @@ -195,6 +202,9 @@
"Navigation": [
"dist/types/components/Navigation/index.d.ts"
],
"ScrollArea": [
"dist/types/components/ScrollArea/index.d.ts"
],
"Search": [
"dist/types/components/Search/index.d.ts"
],
Expand Down
67 changes: 67 additions & 0 deletions src/components/ScrollArea/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# BBBScrollArea

`BBBScrollArea` is a wrapper that applies standardized scrollbar styling to its content — based on the chat's scrollbar style — so that chat, lists, side panels, and any other scrollable content look consistent throughout the application.

## Usage Example

### Basic usage

```jsx
import { BBBScrollArea } from 'bbb-ui-components-react';

<BBBScrollArea maxHeight="400px" fadeColor="#FFFFFF">
<MessageList />
</BBBScrollArea>
```

### Horizontal scroll

```jsx
import { BBBScrollArea } from 'bbb-ui-components-react';

<BBBScrollArea verticalScroll={false} horizontalScroll maxWidth="600px" fadeColor="#FFFFFF">
<CardRow />
</BBBScrollArea>
```

### Without edge fade

```jsx
import { BBBScrollArea } from 'bbb-ui-components-react';

<BBBScrollArea maxHeight="400px" fadeEdges={false}>
<MessageList />
</BBBScrollArea>
```

### With edge padding

```jsx
import { BBBScrollArea } from 'bbb-ui-components-react';

<BBBScrollArea maxHeight="400px" fadeColor="#FFFFFF" paddingTop="1rem" paddingBottom="1rem">
<MessageList />
</BBBScrollArea>
```

## Props

| Property | Type | Default | Description |
| -------------------- | ----------------- | ------- | --------------------------------------------------------------------------------------------------------------- |
| `children` | `React.ReactNode` | | Scrollable content. |
| `verticalScroll` | `boolean` | `true` | Enables vertical scrolling. |
| `horizontalScroll` | `boolean` | `false` | Enables horizontal scrolling. |
| `maxHeight` | `string` | | Caps the content height; scrolling kicks in past this value (e.g. `'400px'`, `'50vh'`). |
| `maxWidth` | `string` | | Caps the content width; scrolling kicks in past this value (e.g. `'600px'`, `'100%'`). |
| `fadeEdges` | `boolean` | `true` | Fades the scrollable edges with a color mask + shadow, matching the chat's scroll style. |
| `fadeColor` | `string` | | Background color the fade blends into. Required in practice when `fadeEdges` is `true` — if omitted, the fade is silently skipped. |
| `paddingTop` | `string` | | Space reserved above the content, before the scrollable area's top edge (e.g. `'1rem'`). |
| `paddingRight` | `string` | | Space reserved to the right of the content, before the scrollable area's right edge (e.g. `'1rem'`). |
| `paddingBottom` | `string` | | Space reserved below the content, before the scrollable area's bottom edge (e.g. `'1rem'`). |
| `paddingLeft` | `string` | | Space reserved to the left of the content, before the scrollable area's left edge (e.g. `'1rem'`). |

## Notes

- Without `maxHeight`/`maxWidth`, the area grows with its content — scrolling only kicks in once a parent container or one of these props constrains its size, same as native `overflow: auto`.
- `fadeColor` must match the background behind `BBBScrollArea` (e.g. the surrounding panel color) so the fade blends in seamlessly instead of showing a mismatched edge.
- The fade mask overlays directly on top of the content near each active edge; set the matching `padding*` prop (e.g. `paddingTop`/`paddingBottom` for a vertical area) so the first/last item isn't partially covered by it.
130 changes: 130 additions & 0 deletions src/components/ScrollArea/component.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import React from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import BBBScrollArea from './component';
import { colorWhite } from '../../stylesheets/palette';

const meta = {
title: 'BBBScrollArea',
component: BBBScrollArea,
tags: ['autodocs'],
argTypes: {
children: {
control: false,
description: 'Scrollable content.',
},
verticalScroll: {
control: 'boolean',
description: 'Enables vertical scrolling.',
table: { defaultValue: { summary: 'true' } },
},
horizontalScroll: {
control: 'boolean',
description: 'Enables horizontal scrolling.',
table: { defaultValue: { summary: 'false' } },
},
maxHeight: {
control: 'text',
description: 'Caps the content height; scrolling kicks in past this value.',
},
maxWidth: {
control: 'text',
description: 'Caps the content width; scrolling kicks in past this value.',
},
fadeEdges: {
control: 'boolean',
description: "Fades the scrollable edges with a color mask + shadow, matching the chat's scroll style.",
table: { defaultValue: { summary: 'true' } },
},
fadeColor: {
control: 'color',
description: 'Background color the fade blends into. Required in practice when fadeEdges is true.',
},
paddingTop: {
control: 'text',
description: "Space reserved above the content, before the scrollable area's top edge.",
},
paddingRight: {
control: 'text',
description: "Space reserved to the right of the content, before the scrollable area's right edge.",
},
paddingBottom: {
control: 'text',
description: "Space reserved below the content, before the scrollable area's bottom edge.",
},
paddingLeft: {
control: 'text',
description: "Space reserved to the left of the content, before the scrollable area's left edge.",
},
},
} satisfies Meta<typeof BBBScrollArea>;

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

const VERTICAL_ITEMS = Array.from({ length: 20 }, (_, index) => `Message ${index + 1}`);
const HORIZONTAL_ITEMS = Array.from({ length: 12 }, (_, index) => `Card ${index + 1}`);

const VerticalList = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{VERTICAL_ITEMS.map((item) => (
<div key={item} style={{ padding: '0.5rem 0.75rem', background: '#F4F6FA', borderRadius: '0.5rem' }}>
{item}
</div>
))}
</div>
);

const HorizontalList = () => (
<div style={{ display: 'flex', gap: '0.5rem', width: 'max-content' }}>
{HORIZONTAL_ITEMS.map((item) => (
<div
key={item}
style={{
padding: '1rem', background: '#F4F6FA', borderRadius: '0.5rem', minWidth: '120px', textAlign: 'center',
}}
>
{item}
</div>
))}
</div>
);

/** Default vertical scroll area with the chat-style scrollbar and top/bottom edge fade. */
export const Default: Story = {
args: {
maxHeight: '250px',
fadeColor: colorWhite,
children: <VerticalList />,
},
};

/** Vertical list with top/bottom padding, so the fade blends over empty space instead of the first/last item. */
export const WithEdgePadding: Story = {
args: {
maxHeight: '250px',
fadeColor: colorWhite,
paddingTop: '1rem',
paddingBottom: '1rem',
children: <VerticalList />,
},
};

/** Same vertical list with `fadeEdges` off — only the scrollbar styling remains. */
export const NoFade: Story = {
args: {
maxHeight: '250px',
fadeEdges: false,
children: <VerticalList />,
},
};

/** Horizontal scroll area with left/right edge fade instead of top/bottom. */
export const Horizontal: Story = {
args: {
verticalScroll: false,
horizontalScroll: true,
maxWidth: '400px',
fadeColor: colorWhite,
children: <HorizontalList />,
},
};
47 changes: 47 additions & 0 deletions src/components/ScrollArea/component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import React, { JSX } from 'react';
import { ScrollAreaProps } from './types';
import * as Styled from './styles';
import { DEFAULT_VERTICAL_SCROLL, DEFAULT_HORIZONTAL_SCROLL, DEFAULT_FADE_EDGES } from './constants';

/**
* A wrapper that applies standardized scrollbar styling to its content.
*
* Use it around any scrollable area (chat, lists, side panels) to get a consistent
* scrollbar look, with optional axis control, size capping, and edge fading.
*
*/
function ScrollArea({
children,
verticalScroll = DEFAULT_VERTICAL_SCROLL,
horizontalScroll = DEFAULT_HORIZONTAL_SCROLL,
maxHeight,
maxWidth,
fadeEdges = DEFAULT_FADE_EDGES,
fadeColor,
paddingTop,
paddingRight,
paddingBottom,
paddingLeft,
}: ScrollAreaProps): JSX.Element {
const isScrollable = verticalScroll || horizontalScroll;

return (
<Styled.ScrollAreaWrapper
$verticalScroll={verticalScroll}
$horizontalScroll={horizontalScroll}
$maxHeight={maxHeight}
$maxWidth={maxWidth}
$fadeEdges={fadeEdges}
$fadeColor={fadeColor}
$paddingTop={paddingTop}
$paddingRight={paddingRight}
$paddingBottom={paddingBottom}
$paddingLeft={paddingLeft}
tabIndex={isScrollable ? 0 : undefined}
>
{children}
</Styled.ScrollAreaWrapper>
);
}

export default ScrollArea;
3 changes: 3 additions & 0 deletions src/components/ScrollArea/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const DEFAULT_VERTICAL_SCROLL = true;
export const DEFAULT_HORIZONTAL_SCROLL = false;
export const DEFAULT_FADE_EDGES = true;
1 change: 1 addition & 0 deletions src/components/ScrollArea/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as BBBScrollArea } from './component';
84 changes: 84 additions & 0 deletions src/components/ScrollArea/styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import styled, { css } from 'styled-components';
import { StyledScrollAreaWrapperProps } from './types';

const FADE_MASK_SIZE = '40px';
const FADE_SHADOW_SIZE = '14px';
const FADE_SHADOW_COLOR = 'rgba(0, 0, 0, .2)';

interface FadeLayer {
image: string;
position: string;
size: string;
attachment: string;
}

const verticalFadeLayers = (color: string): FadeLayer[] => [
{ image: `linear-gradient(${color} 30%, transparent)`, position: '0 0', size: `100% ${FADE_MASK_SIZE}`, attachment: 'local' },
{ image: `linear-gradient(transparent, ${color} 70%)`, position: '0 100%', size: `100% ${FADE_MASK_SIZE}`, attachment: 'local' },
{ image: `radial-gradient(farthest-side at 50% 0, ${FADE_SHADOW_COLOR}, transparent)`, position: '0 0', size: `100% ${FADE_SHADOW_SIZE}`, attachment: 'scroll' },
{ image: `radial-gradient(farthest-side at 50% 100%, ${FADE_SHADOW_COLOR}, transparent)`, position: '0 100%', size: `100% ${FADE_SHADOW_SIZE}`, attachment: 'scroll' },
];

const horizontalFadeLayers = (color: string): FadeLayer[] => [
{ image: `linear-gradient(to right, ${color} 30%, transparent)`, position: '0 0', size: `${FADE_MASK_SIZE} 100%`, attachment: 'local' },
{ image: `linear-gradient(to right, transparent, ${color} 70%)`, position: '100% 0', size: `${FADE_MASK_SIZE} 100%`, attachment: 'local' },
{ image: `radial-gradient(farthest-side at 0 50%, ${FADE_SHADOW_COLOR}, transparent)`, position: '0 0', size: `${FADE_SHADOW_SIZE} 100%`, attachment: 'scroll' },
{ image: `radial-gradient(farthest-side at 100% 50%, ${FADE_SHADOW_COLOR}, transparent)`, position: '100% 0', size: `${FADE_SHADOW_SIZE} 100%`, attachment: 'scroll' },
];

const fadeBackground = ({
$verticalScroll, $horizontalScroll, $fadeEdges, $fadeColor,
}: StyledScrollAreaWrapperProps) => {
if (!$fadeEdges || !$fadeColor) return '';

const layers = [
...($verticalScroll ? verticalFadeLayers($fadeColor) : []),
...($horizontalScroll ? horizontalFadeLayers($fadeColor) : []),
];
if (!layers.length) return '';

return css`
background-image: ${layers.map((layer) => layer.image).join(', ')};
background-position: ${layers.map((layer) => layer.position).join(', ')};
background-size: ${layers.map((layer) => layer.size).join(', ')};
background-attachment: ${layers.map((layer) => layer.attachment).join(', ')};
background-repeat: no-repeat;
background-color: transparent;
`;
};

export const ScrollAreaWrapper = styled.div<StyledScrollAreaWrapperProps>`
overflow-y: ${({ $verticalScroll }) => ($verticalScroll ? 'auto' : 'hidden')};
overflow-x: ${({ $horizontalScroll }) => ($horizontalScroll ? 'auto' : 'hidden')};
${({ $maxHeight }) => $maxHeight && css`max-height: ${$maxHeight};`}
${({ $maxWidth }) => $maxWidth && css`max-width: ${$maxWidth};`}
${({ $paddingTop }) => $paddingTop && css`padding-top: ${$paddingTop};`}
${({ $paddingRight }) => $paddingRight && css`padding-right: ${$paddingRight};`}
${({ $paddingBottom }) => $paddingBottom && css`padding-bottom: ${$paddingBottom};`}
${({ $paddingLeft }) => $paddingLeft && css`padding-left: ${$paddingLeft};`}
${fadeBackground}

&::-webkit-scrollbar {
width: 5px;
height: 5px;
}
&::-webkit-scrollbar-button {
width: 0;
height: 0;
}
&::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, .25);
border: none;
border-radius: 50px;
}
&::-webkit-scrollbar-thumb:hover { background: rgba(0, 0, 0, .5); }
&::-webkit-scrollbar-thumb:active { background: rgba(0, 0, 0, .25); }
&::-webkit-scrollbar-track {
background: rgba(0, 0, 0, .25);
border: none;
border-radius: 50px;
}
&::-webkit-scrollbar-track:hover { background: rgba(0, 0, 0, .25); }
&::-webkit-scrollbar-track:active { background: rgba(0, 0, 0, .25); }
&::-webkit-scrollbar-corner { background: 0 0; }
`;
Loading
Loading