diff --git a/README.md b/README.md index e9068ae..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) @@ -96,6 +97,10 @@ The following table lists the supported CSS variables for color overriding, extr | `--color-hover-dark` | No | #0C57A7 | | `--color-hover-light` | No | #D4E5FA | | `--color-hover-neutral` | No | #DCE4EC | +| `--color-border-focus-ring`| No | rgba(29, 101, 212, 0.15) | +| `--color-icon-default-dark`| No | rgba(255, 255, 255, 0.35) | +| `--color-overlay` | No | rgba(0, 0, 0, 0.75) | +| `--color-shadow-default` | No | rgb(0 35 11 / 20%) | **Example Usage**: ```css @@ -107,6 +112,28 @@ The following table lists the supported CSS variables for color overriding, extr If you need to override colors for specific components or add new variables, refer to the component's `styles.ts` file for implementation details. +### Importing Color Tokens in JS + +In addition to CSS variables, the same color tokens used internally by every component are also +exported as a nested `colors` object, for use directly in JS/TS (e.g. in your own +styled-components): + +```jsx +// From the package root +import { colors } from '@bigbluebutton/bbb-ui-components-react'; + +// Or from the dedicated, tree-shakeable subpath +import { colors } from '@bigbluebutton/bbb-ui-components-react/colors'; + +const StyledDiv = styled.div` + color: ${colors.text.default}; + background: ${colors.background.white}; +`; +``` + +`colors` is grouped the same way as the table above: `neutral`, `brand`, `semantic`, `background`, +`border`, `text`, `icon`, `hover`. + ## Installation You can install the library directly from npm: diff --git a/package.json b/package.json index d10ece2..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", @@ -120,6 +127,13 @@ "require": "./dist/components/Typography.js", "default": "./dist/components/Typography.js" }, + "./colors": { + "types": "./dist/types/stylesheets/colors.d.ts", + "node": "./dist/components/colors.js", + "import": "./dist/esm/stylesheets/colors.js", + "require": "./dist/components/colors.js", + "default": "./dist/components/colors.js" + }, "./dist/components": { "types": "./dist/types/index.d.ts", "require": "./dist/components/index.js", @@ -188,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" ], @@ -208,6 +225,9 @@ ], "Typography": [ "dist/types/components/Typography/index.d.ts" + ], + "colors": [ + "dist/types/stylesheets/colors.d.ts" ] } }, diff --git a/src/components/Accordion/README.md b/src/components/Accordion/README.md index 3711f94..d110204 100644 --- a/src/components/Accordion/README.md +++ b/src/components/Accordion/README.md @@ -38,15 +38,27 @@ import { MdFavorite } from 'react-icons/md'; ``` +### Accordion with a right-aligned button header + +```jsx +import { BBBAccordion } from 'bbb-ui-components-react'; +import { MdEdit } from 'react-icons/md'; + +} buttonHeaderPosition="right"> +

Content for the accordion.

+
+``` + ## Props -| Property | Type | Default | Description | -| ------------------ | -------------------------------------- | ----------- | ------------------------------------------------------------------------------ | -| `title` | `string` | | The text to be displayed in the accordion header. | -| `tooltipLabel` | `string` | `null` | An optional label for the tooltip that appears on hover. | -| `tooltipPlacement` | `import('@tippyjs/react').Placement` | `'bottom'` | The placement of the tooltip. | -| `ariaLabel` | `string` | | The accessible name for the expand button. | -| `ariaLabelledBy` | `string` | | The ID of the element that labels the expand button. | -| `ariaDescribedBy` | `string` | | The ID of the element that describes the expand button. | -| `buttonHeader` | `React.ReactNode` | `null` | Optional content to be rendered inside the button header. | -| `children` | `React.ReactNode` | | The content to be displayed when the accordion is expanded. | +| Property | Type | Default | Description | +| ----------------------- | -------------------------------------- | ----------- | ------------------------------------------------------------------------------ | +| `title` | `string` | | The text to be displayed in the accordion header. | +| `tooltipLabel` | `string` | `null` | An optional label for the tooltip that appears on hover. | +| `tooltipPlacement` | `import('@tippyjs/react').Placement` | `'bottom'` | The placement of the tooltip. | +| `ariaLabel` | `string` | | The accessible name for the expand button. | +| `ariaLabelledBy` | `string` | | The ID of the element that labels the expand button. | +| `ariaDescribedBy` | `string` | | The ID of the element that describes the expand button. | +| `buttonHeader` | `React.ReactNode` | `null` | Optional content to be rendered inside the button header. | +| `buttonHeaderPosition` | `'left' \| 'right'` | `'left'` | Position of `buttonHeader` within the header row. | +| `children` | `React.ReactNode` | | The content to be displayed when the accordion is expanded. | diff --git a/src/components/Accordion/component.stories.tsx b/src/components/Accordion/component.stories.tsx index 7b54464..7a05344 100644 --- a/src/components/Accordion/component.stories.tsx +++ b/src/components/Accordion/component.stories.tsx @@ -1,7 +1,14 @@ import React from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import BBBAccordion from './component'; -import { TOOLTIP_PLACEMENT_VALUES, DEFAULT_TOOLTIP_PLACEMENT } from './constants'; +import { + TOOLTIP_PLACEMENT_VALUES, + DEFAULT_TOOLTIP_PLACEMENT, + BUTTON_HEADER_POSITIONS, + BUTTON_HEADER_POSITION_VALUES, + DEFAULT_BUTTON_HEADER_POSITION, +} from './constants'; +import { MdEdit } from 'react-icons/md'; import Typography from '../Typography/component'; const meta = { @@ -44,6 +51,14 @@ const meta = { control: false, description: 'Optional React node rendered inside the button header.', }, + buttonHeaderPosition: { + control: 'select', + options: BUTTON_HEADER_POSITION_VALUES, + description: 'Position of `buttonHeader` within the header row.', + table: { + defaultValue: { summary: `${DEFAULT_BUTTON_HEADER_POSITION}` }, + }, + }, children: { control: false, description: 'Content shown when the accordion is expanded.', @@ -79,3 +94,17 @@ export const WithTooltip: Story = { ), }, }; + +/** Shows `buttonHeaderPosition="right"` pushing the button header to the far edge of the header row. */ +export const WithRightAlignedButtonHeader: Story = { + args: { + title: 'Right-aligned Button Header', + buttonHeader: , + buttonHeaderPosition: BUTTON_HEADER_POSITIONS.RIGHT, + children: ( +
+ Accordion content goes here. +
+ ), + }, +}; diff --git a/src/components/Accordion/component.tsx b/src/components/Accordion/component.tsx index 5fc0753..9524240 100644 --- a/src/components/Accordion/component.tsx +++ b/src/components/Accordion/component.tsx @@ -4,7 +4,7 @@ import * as Styled from './styles'; import { MdExpandMore } from 'react-icons/md'; import Tippy from '@tippyjs/react'; import 'tippy.js/dist/tippy.css'; -import { DEFAULT_TOOLTIP_PLACEMENT } from './constants'; +import { DEFAULT_TOOLTIP_PLACEMENT, DEFAULT_BUTTON_HEADER_POSITION } from './constants'; /** * A customizable Accordion component that allows expanding and collapsing content. @@ -21,6 +21,7 @@ function Accordion({ ariaLabelledBy, ariaDescribedBy, buttonHeader = null, + buttonHeaderPosition = DEFAULT_BUTTON_HEADER_POSITION, children, }: AccordionProps): JSX.Element { const [isExpanded, setIsExpanded] = useState(false); @@ -38,7 +39,9 @@ function Accordion({ {title} - {buttonHeader} + + {buttonHeader} + ); diff --git a/src/components/Accordion/constants.ts b/src/components/Accordion/constants.ts index 79011e3..6dff066 100644 --- a/src/components/Accordion/constants.ts +++ b/src/components/Accordion/constants.ts @@ -7,8 +7,18 @@ const TOOLTIP_PLACEMENTS = { const TOOLTIP_PLACEMENT_VALUES = Object.values(TOOLTIP_PLACEMENTS); const DEFAULT_TOOLTIP_PLACEMENT = TOOLTIP_PLACEMENTS.TOP; +const BUTTON_HEADER_POSITIONS = { + LEFT: 'left', + RIGHT: 'right', +} as const; +const BUTTON_HEADER_POSITION_VALUES = Object.values(BUTTON_HEADER_POSITIONS); +const DEFAULT_BUTTON_HEADER_POSITION = BUTTON_HEADER_POSITIONS.LEFT; + export { TOOLTIP_PLACEMENTS, TOOLTIP_PLACEMENT_VALUES, DEFAULT_TOOLTIP_PLACEMENT, + BUTTON_HEADER_POSITIONS, + BUTTON_HEADER_POSITION_VALUES, + DEFAULT_BUTTON_HEADER_POSITION, } \ No newline at end of file diff --git a/src/components/Accordion/styles.ts b/src/components/Accordion/styles.ts index 721cbb7..0850835 100644 --- a/src/components/Accordion/styles.ts +++ b/src/components/Accordion/styles.ts @@ -2,7 +2,7 @@ import styled from 'styled-components'; import { colorBackgroundLight, colorBrand1, colorTextDefault, colorWhite } from '../../stylesheets/palette'; import { fontSizeDefault } from '../../stylesheets/typography'; import { borderRadiusDefault, spacingMedium, spacingSmall } from '../../stylesheets/sizing'; -import { StyledAccordionContent, StyledExpandIcon } from './types'; +import { StyledAccordionContent, StyledExpandIcon, StyledButtonHeaderWrapper } from './types'; export const ExpandButton = styled.button` display: flex; @@ -51,6 +51,12 @@ export const ExpandIcon = styled.div` } `; +export const ButtonHeaderWrapper = styled.span` + display: flex; + align-items: center; + margin-left: ${({ $position }) => ($position === 'right' ? 'auto' : '0')}; +`; + export const TitleText = styled.span` font-size: ${fontSizeDefault}; font-weight: 400; diff --git a/src/components/Accordion/types.ts b/src/components/Accordion/types.ts index 87b09e4..2e61837 100644 --- a/src/components/Accordion/types.ts +++ b/src/components/Accordion/types.ts @@ -1,4 +1,4 @@ -import { TOOLTIP_PLACEMENT_VALUES } from './constants'; +import { TOOLTIP_PLACEMENT_VALUES, BUTTON_HEADER_POSITION_VALUES } from './constants'; import * as React from 'react'; export interface StyledExpandIcon { @@ -10,7 +10,12 @@ export interface StyledAccordionContent { $scrollHeight: number; } +export interface StyledButtonHeaderWrapper { + $position: ButtonHeaderPositionType; +} + type TooltipPlacementType = typeof TOOLTIP_PLACEMENT_VALUES[number]; +type ButtonHeaderPositionType = typeof BUTTON_HEADER_POSITION_VALUES[number]; export interface AccordionProps { /** The text to be displayed in the accordion header. */ @@ -34,6 +39,9 @@ export interface AccordionProps { /** Optional React node rendered inside the button header, alongside the title. @default null */ buttonHeader?: React.ReactNode; + /** Position of `buttonHeader` within the header row. @default 'left' */ + buttonHeaderPosition?: ButtonHeaderPositionType; + /** Content shown when the accordion is expanded. */ children?: React.ReactNode; }; diff --git a/src/components/Hint/README.md b/src/components/Hint/README.md index 33a25ca..c1b1bf5 100644 --- a/src/components/Hint/README.md +++ b/src/components/Hint/README.md @@ -26,13 +26,52 @@ import { BBBHint } from 'bbb-ui-components-react'; /> ``` +### Uncontrolled Hint (default) + +Without an `open` prop, the hint manages its own visibility and closes itself when the close button is clicked — no external state required. + +```jsx +import { BBBHint } from 'bbb-ui-components-react'; + + +``` + +### Controlled Hint + +Pass `open` to drive visibility externally; the hint calls `onRequestClose` instead of hiding itself, leaving the parent in charge of updating `open`. + +```jsx +import { useState } from 'react'; +import { BBBHint } from 'bbb-ui-components-react'; + +const [open, setOpen] = useState(true); + + setOpen(false)} + label="This hint's visibility is controlled externally." +/> +``` + +### Hint Without a Close Button + +Pass `hideCloseButton` for hints that shouldn't be manually dismissed — e.g. ones dismissed by interacting with another UI element, or tooltip-style hints with no explicit dismiss action. + +```jsx +import { BBBHint } from 'bbb-ui-components-react'; + + +``` + ## Props -| Property | Type | Default | Description | -| ---------------- | -------------------------------- | ------- | ------------------------------------------------------------------------------------ | -| `label` | `string` | | The main text content of the hint. | -| `title` | `string` | | An optional title for the hint. If provided, a close button will be displayed. | -| `icon` | `React.ReactNode` | | An optional icon to be displayed next to the title or label. | -| `onRequestClose` | `() => void` | | A callback function to be called when the close button is clicked. | -| `children` | `React.ReactNode` | | Optional additional content to be displayed below the label. | -| `...props` | `HTMLAttributes` | | Any other props will be passed down to the underlying container div. | +| Property | Type | Default | Description | +| ----------------- | -------------------------------- | ------- | ------------------------------------------------------------------------------------ | +| `label` | `string` | | The main text content of the hint. | +| `title` | `string` | | An optional title shown in the header; when set, `label` renders as a separate line below instead of inline. | +| `icon` | `React.ReactNode` | | An optional icon to be displayed next to the title or label. | +| `open` | `boolean` | | Whether the hint is visible. Omit to let the hint manage its own visibility, closing itself when the close button is clicked; pass a boolean to control visibility externally. | +| `onRequestClose` | `() => void` | | A callback function to be called when the close button is clicked, in both controlled and uncontrolled mode. | +| `hideCloseButton` | `boolean` | `false` | Hides the close (X) button, for hints that shouldn't be manually dismissed. | +| `children` | `React.ReactNode` | | Optional additional content to be displayed below the label. | +| `...props` | `HTMLAttributes` | | Any other props will be passed down to the underlying container div. | diff --git a/src/components/Hint/component.stories.tsx b/src/components/Hint/component.stories.tsx index 65a8a14..a8790e9 100644 --- a/src/components/Hint/component.stories.tsx +++ b/src/components/Hint/component.stories.tsx @@ -1,5 +1,7 @@ +import React, { useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import BBBHint from './component'; +import { BBButton } from '../Button'; const meta = { title: 'BBBHint', @@ -12,15 +14,23 @@ const meta = { }, title: { control: 'text', - description: 'Optional title; if provided, shows a close button.', + description: 'Optional title shown in the header; when set, label renders as a separate line below instead of inline.', }, icon: { control: false, description: 'Optional icon node displayed next to the title or label.', }, + open: { + control: 'boolean', + description: 'Whether the hint is visible. Omit to let the hint manage its own visibility, closing itself when the close button is clicked; pass a boolean to control visibility externally.', + }, onRequestClose: { control: false, - description: 'Callback fired when the close button is clicked.', + description: 'Callback fired when the close button is clicked, in both controlled and uncontrolled mode.', + }, + hideCloseButton: { + control: 'boolean', + description: "Hides the close (X) button, for hints that shouldn't be manually dismissed.", }, children: { control: false, @@ -32,9 +42,54 @@ const meta = { export default meta; type Story = StoryObj; +/** + * Wrapper component so hooks can be used inside Storybook's render function, + * demonstrating the `open` prop driving visibility from outside the hint. + */ +const ControlledHintStory: React.FC> = (args) => { + const [open, setOpen] = useState(true); + + return ( +
+ setOpen(true)} /> + setOpen(false)} + /> +
+ ); +}; + /** Basic hint rendering with only a label and the default info icon. */ export const Default: Story = { args: { label: 'Helpful hint', }, }; + +/** Uncontrolled hint (no `open` prop): it manages its own visibility and closes itself when dismissed. */ +export const Uncontrolled: Story = { + args: { + title: 'Uncontrolled', + label: 'This hint manages its own visibility and closes itself when the close button is clicked.', + }, +}; + +/** Controlled hint: visibility is driven by the `open` prop, so the parent decides when it reappears. */ +export const Controlled: Story = { + args: { + title: 'Controlled', + label: "This hint's visibility is controlled externally via the open prop.", + }, + render: (args) => , +}; + +/** Hint with `hideCloseButton`: no close (X) button, for hints that shouldn't be manually dismissed. */ +export const WithoutCloseButton: Story = { + args: { + title: 'No close button', + label: 'This hint cannot be manually dismissed.', + hideCloseButton: true, + }, +}; diff --git a/src/components/Hint/component.tsx b/src/components/Hint/component.tsx index 50bb01b..ee61d3a 100644 --- a/src/components/Hint/component.tsx +++ b/src/components/Hint/component.tsx @@ -1,4 +1,4 @@ -import React, { JSX } from 'react'; +import React, { JSX, useState } from 'react'; import * as Styled from './styles'; import { HintProps } from './types'; import { MdClose, MdInfo } from 'react-icons/md'; @@ -9,15 +9,29 @@ import { MdClose, MdInfo } from 'react-icons/md'; * This component provides a small contextual hint used to surface tips, short help text or dismissible messages. * It can be displayed with a title, an icon, and a close button. * + * Visibility is uncontrolled by default (the hint closes itself when its close button is clicked); pass `open` to control it externally instead. */ function Hint({ title, label, icon = , + open, onRequestClose, + hideCloseButton = false, children, ...rest -}: HintProps): JSX.Element { +}: HintProps): JSX.Element | null { + const isControlled = open !== undefined; + const [internalOpen, setInternalOpen] = useState(true); + const isOpen = isControlled ? open : internalOpen; + + const handleClose = (): void => { + if (!isControlled) setInternalOpen(false); + onRequestClose?.(); + }; + + if (!isOpen) return null; + const renderedLabel = {label}{children}; return ( {title}} {!title && renderedLabel} - {title && ( + {!hideCloseButton && ( diff --git a/src/components/Hint/types.ts b/src/components/Hint/types.ts index eddf53e..f2412d3 100644 --- a/src/components/Hint/types.ts +++ b/src/components/Hint/types.ts @@ -2,15 +2,21 @@ export interface HintProps extends React.HTMLAttributes { /** Main text content of the hint. */ label: string; - /** Optional title; if provided, shows a close button. */ + /** Optional title shown in the header; when set, `label` renders as a separate line below instead of inline. */ title?: string; /** Optional icon node displayed next to the title or label. @default */ icon?: React.ReactNode; - /** Callback fired when the close button is clicked. */ + /** Whether the hint is visible. Omit to let the hint manage its own visibility, closing itself when the close button is clicked; pass a boolean to control visibility externally. */ + open?: boolean; + + /** Callback fired when the close button is clicked, in both controlled and uncontrolled mode. */ onRequestClose?: () => void; + /** Hides the close (X) button, for hints that shouldn't be manually dismissed. @default false */ + hideCloseButton?: boolean; + /** Optional additional content rendered under the label. */ children?: React.ReactNode; } diff --git a/src/components/Input/styles.ts b/src/components/Input/styles.ts index 47afd8d..0afe933 100644 --- a/src/components/Input/styles.ts +++ b/src/components/Input/styles.ts @@ -3,6 +3,7 @@ import { colorBorderDefault, colorBorderSelected, colorBorderError, + colorBorderFocusRing, colorTextDefault, colorTextLight, colorError, @@ -41,7 +42,7 @@ export const FieldContainer = styled.div` &:focus-within { border-color: ${colorBorderSelected}; - box-shadow: 0 0 0 3px var(--color-border-focus-ring, rgba(29, 101, 212, 0.15)); + box-shadow: 0 0 0 3px ${colorBorderFocusRing}; } ${({ $error }) => diff --git a/src/components/Modal/styles.ts b/src/components/Modal/styles.ts index 19c0eb3..708c871 100644 --- a/src/components/Modal/styles.ts +++ b/src/components/Modal/styles.ts @@ -2,7 +2,7 @@ import styled from 'styled-components'; import { Styles } from 'react-modal'; import * as React from 'react'; import { spacingLarge, spacingMedium, spacingSmallMedium, borderRadiusDefault } from '../../stylesheets/sizing'; -import { colorWhite } from '../../stylesheets/palette'; +import { colorWhite, colorOverlay } from '../../stylesheets/palette'; import { StyledModalBodyProps, StyledModalFooterProps } from './types'; export const modalStyles: Styles = { @@ -12,7 +12,7 @@ export const modalStyles: Styles = { left: 0, right: 0, bottom: 0, - backgroundColor: 'rgba(0, 0, 0, 0.75)', + backgroundColor: colorOverlay, zIndex: 100, display: 'flex', alignItems: 'center', 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/Search/styles.ts b/src/components/Search/styles.ts index a076a5a..b8d9788 100644 --- a/src/components/Search/styles.ts +++ b/src/components/Search/styles.ts @@ -2,6 +2,7 @@ import styled, { css } from 'styled-components'; import { colorBorderDefault, colorBorderSelected, + colorBorderFocusRing, colorTextDefault, colorTextLight, colorIconDefault, @@ -36,7 +37,7 @@ export const Container = styled.div` &:focus-within { border-color: ${colorBorderSelected}; - box-shadow: 0 0 0 3px var(--color-border-focus-ring, rgba(29, 101, 212, 0.15)); + box-shadow: 0 0 0 3px ${colorBorderFocusRing}; } ${({ $disabled }) => diff --git a/src/components/TextAreaInput/styles.ts b/src/components/TextAreaInput/styles.ts index ffd9f2d..31d83fc 100644 --- a/src/components/TextAreaInput/styles.ts +++ b/src/components/TextAreaInput/styles.ts @@ -1,5 +1,7 @@ import styled from 'styled-components'; -import { colorBorderDefault, colorBrand1, colorBrand2, colorLightGray, colorWhite } from '../../stylesheets/palette'; +import { + colorBorderDefault, colorBrand1, colorBrand2, colorTextDefault, colorTextLight, colorWhite, +} from '../../stylesheets/palette'; import { borderRadiusDefault } from '../../stylesheets/sizing'; export const TextAreaInput = styled.textarea` @@ -7,7 +9,7 @@ export const TextAreaInput = styled.textarea` background: ${colorWhite}; background-clip: padding-box; margin: 0; - color: ${colorLightGray}; + color: ${colorTextDefault}; padding: calc(.3rem* 2.5) calc(.75rem* 1.25); resize: none; -webkit-transition: none; @@ -20,6 +22,11 @@ export const TextAreaInput = styled.textarea` border: 1px solid ${colorBorderDefault}; overflow-y: hidden; margin: 0.3em; + + &::placeholder { + color: ${colorTextLight}; + } + &:focus { outline: ${colorBrand1} solid 2px; box-shadow: 0 0 0 2px ${colorBrand2} inset 0 0 0 1px ${colorBrand1}; diff --git a/src/components/Toggle/styles.ts b/src/components/Toggle/styles.ts index 5815a1f..9bbf73a 100644 --- a/src/components/Toggle/styles.ts +++ b/src/components/Toggle/styles.ts @@ -1,7 +1,10 @@ import { Switch } from '@mui/material'; import { styled as materialStyled } from '@mui/material/styles'; import styled, { css } from 'styled-components'; -import { colorBrand1, colorIconDefault, colorTextDefault, colorTextLight, colorWhite } from '../../stylesheets/palette'; +import { + colorBrand1, colorIconDefault, colorTextDefault, colorTextLight, colorWhite, + colorShadowDefault, colorIconDefaultDark, +} from '../../stylesheets/palette'; import { TEXT_POSITIONS } from './constants'; import { StyledTextWrapperProps, StyledToggleWrapperProps } from './types'; import { fontSizeBig, fontSizeDefault } from '../../stylesheets/typography'; @@ -103,7 +106,7 @@ export const MaterialToggle = materialStyled(Switch)(({ theme }) => ({ }, }, '& .MuiSwitch-thumb': { - boxShadow: '0 2px 4px 0 rgb(0 35 11 / 20%)', + boxShadow: `0 2px 4px 0 ${colorShadowDefault}`, width: '0.6rem', height: '0.6rem', borderRadius: '0.5rem', @@ -118,7 +121,7 @@ export const MaterialToggle = materialStyled(Switch)(({ theme }) => ({ backgroundColor: colorIconDefault, boxSizing: 'border-box', ...theme.applyStyles('dark', { - backgroundColor: 'rgba(255,255,255,.35)', + backgroundColor: colorIconDefaultDark, }), }, })); \ No newline at end of file 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/src/index.ts b/src/index.ts index 8d8604e..2ca8450 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ export * from './components'; export * from './stylesheets/palette'; export * from './stylesheets/sizing'; +export * from './stylesheets/colors'; diff --git a/src/stylesheets/colors.ts b/src/stylesheets/colors.ts new file mode 100644 index 0000000..84251c7 --- /dev/null +++ b/src/stylesheets/colors.ts @@ -0,0 +1,71 @@ +import { + colorNeutral2, colorNeutral3, colorNeutral4, colorWhite, colorLightGray, colorGray, colorDarkGray, + colorBrand1, colorBrand2, colorBrand3, colorBrandLight, colorBrandAux, + colorSuccess, colorWarning, colorError, colorErrorDark, + colorBackgroundWhite, colorBackgroundLight, colorBackgroundBlue, + colorBorderDefault, colorBorderSelected, colorBorderError, colorBorderFocusRing, + colorTextDefault, colorTextLight, + colorIconDefault, colorIconBlue, colorIconWhite, colorIconDefaultDark, + colorHoverDark, colorHoverLight, colorHoverNeutral, + colorOverlay, + colorShadowDefault, +} from './palette'; + +export const colors = { + neutral: { + neutral2: colorNeutral2, + neutral3: colorNeutral3, + neutral4: colorNeutral4, + white: colorWhite, + lightGray: colorLightGray, + gray: colorGray, + darkGray: colorDarkGray, + }, + brand: { + brand1: colorBrand1, + brand2: colorBrand2, + brand3: colorBrand3, + light: colorBrandLight, + aux: colorBrandAux, + }, + semantic: { + success: colorSuccess, + warning: colorWarning, + error: colorError, + errorDark: colorErrorDark, + }, + background: { + white: colorBackgroundWhite, + light: colorBackgroundLight, + blue: colorBackgroundBlue, + }, + border: { + default: colorBorderDefault, + selected: colorBorderSelected, + error: colorBorderError, + focusRing: colorBorderFocusRing, + }, + text: { + default: colorTextDefault, + light: colorTextLight, + }, + icon: { + default: colorIconDefault, + blue: colorIconBlue, + white: colorIconWhite, + defaultDark: colorIconDefaultDark, + }, + hover: { + dark: colorHoverDark, + light: colorHoverLight, + neutral: colorHoverNeutral, + }, + overlay: { + default: colorOverlay, + }, + shadow: { + default: colorShadowDefault, + }, +} as const; + +export type Colors = typeof colors; diff --git a/src/stylesheets/palette.ts b/src/stylesheets/palette.ts index deaaa9f..1867278 100644 --- a/src/stylesheets/palette.ts +++ b/src/stylesheets/palette.ts @@ -43,6 +43,7 @@ export const colorBorderSelected = `var(--color-border-selected, ${colorBrand1_b export const colorBorderError = `var(--color-border-error, ${colorError_base})`; // Mapped to core css vars export const colorBorderDefault = `var(--default-border, ${colorBorderDefault_base})`; +export const colorBorderFocusRing = 'var(--color-border-focus-ring, rgba(29, 101, 212, 0.15))'; // Text colors @@ -53,8 +54,15 @@ export const colorTextLight = `var(--color-text-light, ${colorNeutral2})`; export const colorIconDefault = `var(--color-icon-default, ${colorNeutral2})`; export const colorIconBlue = `var(--color-icon-blue, ${colorBrand1_base})`; export const colorIconWhite = `var(--color-icon-white, ${colorWhite})`; +export const colorIconDefaultDark = 'var(--color-icon-default-dark, rgba(255, 255, 255, 0.35))'; //Hover colors export const colorHoverDark = 'var(--color-hover-dark, #0C57A7)'; export const colorHoverLight = 'var(--color-hover-light, #D4E5FA)'; export const colorHoverNeutral = `var(--color-hover-neutral, ${colorNeutral4})`; + +//Overlay colors +export const colorOverlay = 'var(--color-overlay, rgba(0, 0, 0, 0.75))'; + +//Shadow colors +export const colorShadowDefault = 'var(--color-shadow-default, rgb(0 35 11 / 20%))'; diff --git a/webpack.config.babel.js b/webpack.config.babel.js index c51f380..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', @@ -17,6 +18,7 @@ export default { TextInput: './src/components/TextInput/index.ts', Toggle: './src/components/Toggle/index.ts', Typography: './src/components/Typography/index.ts', + colors: './src/stylesheets/colors.ts', index: './src/index.ts', }, output: {