diff --git a/README.md b/README.md index 37c765f..223b759 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/package.json b/package.json index aabb42e..5537f94 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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" ], diff --git a/src/components/ScrollArea/README.md b/src/components/ScrollArea/README.md new file mode 100644 index 0000000..4ea7534 --- /dev/null +++ b/src/components/ScrollArea/README.md @@ -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'; + + + + +``` + +### Horizontal scroll + +```jsx +import { BBBScrollArea } from 'bbb-ui-components-react'; + + + + +``` + +### Without edge fade + +```jsx +import { BBBScrollArea } from 'bbb-ui-components-react'; + + + + +``` + +### With edge padding + +```jsx +import { BBBScrollArea } from 'bbb-ui-components-react'; + + + + +``` + +## 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. diff --git a/src/components/ScrollArea/component.stories.tsx b/src/components/ScrollArea/component.stories.tsx new file mode 100644 index 0000000..a6184e7 --- /dev/null +++ b/src/components/ScrollArea/component.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +const VERTICAL_ITEMS = Array.from({ length: 20 }, (_, index) => `Message ${index + 1}`); +const HORIZONTAL_ITEMS = Array.from({ length: 12 }, (_, index) => `Card ${index + 1}`); + +const VerticalList = () => ( +
+ {VERTICAL_ITEMS.map((item) => ( +
+ {item} +
+ ))} +
+); + +const HorizontalList = () => ( +
+ {HORIZONTAL_ITEMS.map((item) => ( +
+ {item} +
+ ))} +
+); + +/** Default vertical scroll area with the chat-style scrollbar and top/bottom edge fade. */ +export const Default: Story = { + args: { + maxHeight: '250px', + fadeColor: colorWhite, + children: , + }, +}; + +/** 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: , + }, +}; + +/** Same vertical list with `fadeEdges` off — only the scrollbar styling remains. */ +export const NoFade: Story = { + args: { + maxHeight: '250px', + fadeEdges: false, + children: , + }, +}; + +/** 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: , + }, +}; diff --git a/src/components/ScrollArea/component.tsx b/src/components/ScrollArea/component.tsx new file mode 100644 index 0000000..db46434 --- /dev/null +++ b/src/components/ScrollArea/component.tsx @@ -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 ( + + {children} + + ); +} + +export default ScrollArea; diff --git a/src/components/ScrollArea/constants.ts b/src/components/ScrollArea/constants.ts new file mode 100644 index 0000000..1fb2cfc --- /dev/null +++ b/src/components/ScrollArea/constants.ts @@ -0,0 +1,3 @@ +export const DEFAULT_VERTICAL_SCROLL = true; +export const DEFAULT_HORIZONTAL_SCROLL = false; +export const DEFAULT_FADE_EDGES = true; diff --git a/src/components/ScrollArea/index.ts b/src/components/ScrollArea/index.ts new file mode 100644 index 0000000..25d0af5 --- /dev/null +++ b/src/components/ScrollArea/index.ts @@ -0,0 +1 @@ +export { default as BBBScrollArea } from './component'; diff --git a/src/components/ScrollArea/styles.ts b/src/components/ScrollArea/styles.ts new file mode 100644 index 0000000..da1ed03 --- /dev/null +++ b/src/components/ScrollArea/styles.ts @@ -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` + 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; } +`; diff --git a/src/components/ScrollArea/types.ts b/src/components/ScrollArea/types.ts new file mode 100644 index 0000000..02196c9 --- /dev/null +++ b/src/components/ScrollArea/types.ts @@ -0,0 +1,49 @@ +import React from 'react'; + +export interface StyledScrollAreaWrapperProps { + $verticalScroll: boolean; + $horizontalScroll: boolean; + $maxHeight?: string; + $maxWidth?: string; + $fadeEdges: boolean; + $fadeColor?: string; + $paddingTop?: string; + $paddingRight?: string; + $paddingBottom?: string; + $paddingLeft?: string; +} + +export interface ScrollAreaProps { + /** Scrollable content. */ + children: React.ReactNode; + + /** Enables vertical scrolling. @default true */ + verticalScroll?: boolean; + + /** Enables horizontal scrolling. @default false */ + horizontalScroll?: boolean; + + /** Caps the content height; scrolling kicks in past this value (e.g. '400px', '50vh'). */ + maxHeight?: string; + + /** Caps the content width; scrolling kicks in past this value (e.g. '600px', '100%'). */ + maxWidth?: string; + + /** Fades the scrollable edges with a color mask + shadow, matching the chat's scroll style. @default true */ + fadeEdges?: boolean; + + /** Background color the fade blends into. Required in practice when `fadeEdges` is `true` — if omitted, the fade is silently skipped. */ + fadeColor?: string; + + /** Space reserved above the content, before the scrollable area's top edge (e.g. '1rem'). Keeps the first item clear of the top fade instead of butting against it. */ + paddingTop?: string; + + /** Space reserved to the right of the content, before the scrollable area's right edge (e.g. '1rem'). Keeps the last item clear of the right fade instead of butting against it. */ + paddingRight?: string; + + /** Space reserved below the content, before the scrollable area's bottom edge (e.g. '1rem'). Keeps the last item clear of the bottom fade instead of butting against it. */ + paddingBottom?: string; + + /** Space reserved to the left of the content, before the scrollable area's left edge (e.g. '1rem'). Keeps the first item clear of the left fade instead of butting against it. */ + paddingLeft?: string; +} diff --git a/src/components/index.ts b/src/components/index.ts index b5e6250..230d706 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -6,6 +6,7 @@ export { BBBHint } from './Hint'; export { BBBInput } from './Input'; export { BBBModal } from './Modal'; export { BBBNavigation } from './Navigation'; +export { BBBScrollArea } from './ScrollArea'; export { BBBSearch } from './Search'; export { BBBSelect } from './Select'; export { BBBSpinner } from './Spinner'; diff --git a/webpack.config.babel.js b/webpack.config.babel.js index 61ffdf7..461ae7f 100644 --- a/webpack.config.babel.js +++ b/webpack.config.babel.js @@ -10,6 +10,7 @@ export default { Input: './src/components/Input/index.ts', Modal: './src/components/Modal/index.ts', Navigation: './src/components/Navigation/index.ts', + ScrollArea: './src/components/ScrollArea/index.ts', Search: './src/components/Search/index.ts', Select: './src/components/Select/index.ts', Spinner: './src/components/Spinner/index.ts',