diff --git a/README.md b/README.md index 223b759..594178a 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ This repository contains a library of reusable React components extracted from t Below is a list of the components available in this library. Each component has its own detailed documentation with usage examples and a complete list of props. - [BBBAccordion](./src/components/Accordion/README.md) +- [BBBAvatar](./src/components/Avatar/README.md) - [BBButton](./src/components/Button/README.md) - [BBBCheckbox](./src/components/Checkbox/README.md) - [BBBDivider](./src/components/Divider/README.md) @@ -17,6 +18,7 @@ Below is a list of the components available in this library. Each component has - [BBBScrollArea](./src/components/ScrollArea/README.md) - [BBBSearch](./src/components/Search/README.md) - [BBBSelect](./src/components/Select/README.md) +- [BBBSlider](./src/components/Slider/README.md) - [BBBSpinner](./src/components/Spinner//README.md) - [BBBTextAreaInput](./src/components/TextAreaInput/README.md) - [BBBTextInput](./src/components/TextInput/README.md) @@ -101,6 +103,7 @@ The following table lists the supported CSS variables for color overriding, extr | `--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%) | +| `--color-user-you` | No | #19237C | **Example Usage**: ```css @@ -132,7 +135,7 @@ const StyledDiv = styled.div` ``` `colors` is grouped the same way as the table above: `neutral`, `brand`, `semantic`, `background`, -`border`, `text`, `icon`, `hover`. +`border`, `text`, `icon`, `hover`, `overlay`, `shadow`, `user`. ## Installation diff --git a/package.json b/package.json index bea56d7..b74503a 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,13 @@ "require": "./dist/components/Accordion.js", "default": "./dist/components/Accordion.js" }, + "./Avatar": { + "types": "./dist/types/components/Avatar/index.d.ts", + "node": "./dist/components/Avatar.js", + "import": "./dist/esm/components/Avatar/index.js", + "require": "./dist/components/Avatar.js", + "default": "./dist/components/Avatar.js" + }, "./Button": { "types": "./dist/types/components/Button/index.d.ts", "node": "./dist/components/Button.js", @@ -92,6 +99,13 @@ "require": "./dist/components/Select.js", "default": "./dist/components/Select.js" }, + "./Slider": { + "types": "./dist/types/components/Slider/index.d.ts", + "node": "./dist/components/Slider.js", + "import": "./dist/esm/components/Slider/index.js", + "require": "./dist/components/Slider.js", + "default": "./dist/components/Slider.js" + }, "./Spinner": { "types": "./dist/types/components/Spinner/index.d.ts", "node": "./dist/components/Spinner.js", @@ -181,6 +195,9 @@ "Accordion": [ "dist/types/components/Accordion/index.d.ts" ], + "Avatar": [ + "dist/types/components/Avatar/index.d.ts" + ], "Button": [ "dist/types/components/Button/index.d.ts" ], @@ -211,6 +228,9 @@ "Select": [ "dist/types/components/Select/index.d.ts" ], + "Slider": [ + "dist/types/components/Slider/index.d.ts" + ], "Spinner": [ "dist/types/components/Spinner/index.d.ts" ], diff --git a/src/components/Avatar/README.md b/src/components/Avatar/README.md new file mode 100644 index 0000000..1d98e3f --- /dev/null +++ b/src/components/Avatar/README.md @@ -0,0 +1,83 @@ +# BBBAvatar + +The `BBBAvatar` component renders a user's avatar image, falling back to their initials on a deterministically-colored background when no image is available or the image fails to load. It shows a tooltip with the full name on hover, and can highlight the current user or a speaking user, matching BBB's own avatar treatment. + +![Demo](assets/example.png) + +## Usage Example + +### Avatar with image +```jsx +import { BBBAvatar } from 'bbb-ui-components-react'; + + +``` + +### Avatar with initials fallback +```jsx +import { BBBAvatar } from 'bbb-ui-components-react'; + + +``` + +### Avatar with a custom color +```jsx +import { BBBAvatar } from 'bbb-ui-components-react'; + + +``` + +### Medium avatar +```jsx +import { BBBAvatar } from 'bbb-ui-components-react'; + + +``` + +### Large avatar +```jsx +import { BBBAvatar } from 'bbb-ui-components-react'; + + +``` + +### Moderator avatar +```jsx +import { BBBAvatar } from 'bbb-ui-components-react'; + + +``` + +### Current user's avatar +```jsx +import { BBBAvatar } from 'bbb-ui-components-react'; + + +``` + +### Talking indicator +```jsx +import { BBBAvatar } from 'bbb-ui-components-react'; + + +``` + +### Avatar without the hover tooltip +```jsx +import { BBBAvatar } from 'bbb-ui-components-react'; + + +``` + +## Props + +| Property | Type | Default | Description | +| ---------------- | -------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `name` | `string` | | Full name of the user; used to render initials, to derive a deterministic fallback color, and as the tooltip content. | +| `avatarUrl` | `string` | | URL of the user's avatar image. Falls back to initials when omitted or if the image fails to load. | +| `color` | `string` | color deterministically derived from `name` | Background color behind the initials (and border color on the image). Overrides `isYou`. | +| `size` | `'small' \| 'medium' \| 'large'` | `'medium'` | Size variant of the avatar. | +| `isModerator` | `boolean` | `false` | Renders a rounded-square shape instead of a circle, matching BBB's moderator avatar treatment. | +| `isYou` | `boolean` | `false` | Marks this avatar as belonging to the current user, applying BBB's "you" color in place of the fallback/computed color. Ignored when `color` is set. | +| `isTalking` | `boolean` | `false` | Shows a pulsing ring around the avatar, in its own color, matching BBB's talking indicator. | +| `disableTooltip` | `boolean` | `false` | Disables the tooltip that shows the full `name` on hover. | diff --git a/src/components/Avatar/assets/example.png b/src/components/Avatar/assets/example.png new file mode 100644 index 0000000..7a9463a Binary files /dev/null and b/src/components/Avatar/assets/example.png differ diff --git a/src/components/Avatar/component.stories.tsx b/src/components/Avatar/component.stories.tsx new file mode 100644 index 0000000..94bd44a --- /dev/null +++ b/src/components/Avatar/component.stories.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import BBBAvatar from './component'; +import { AVATAR_SIZE_VALUES, DEFAULT_AVATAR_SIZE } from './constants'; + +const meta = { + title: 'BBBAvatar', + component: BBBAvatar, + tags: ['autodocs'], + argTypes: { + name: { + control: 'text', + description: 'Full name of the user; used to render initials and to derive a deterministic fallback color.', + }, + avatarUrl: { + control: 'text', + description: "URL of the user's avatar image. Falls back to initials when omitted or if the image fails to load.", + }, + color: { + control: 'color', + description: 'Background color behind the initials (and border color on the image).', + }, + size: { + control: 'select', + options: AVATAR_SIZE_VALUES, + description: 'Size variant of the avatar.', + table: { defaultValue: { summary: `${DEFAULT_AVATAR_SIZE}` } }, + }, + isModerator: { + control: 'boolean', + description: "Renders a rounded-square shape instead of a circle, matching BBB's moderator avatar treatment.", + table: { defaultValue: { summary: 'false' } }, + }, + isYou: { + control: 'boolean', + description: 'Marks this avatar as belonging to the current user, applying BBB\'s "you" color in place of the fallback/computed color. Ignored when `color` is set.', + table: { defaultValue: { summary: 'false' } }, + }, + isTalking: { + control: 'boolean', + description: "Shows a pulsing ring around the avatar, in its own color, matching BBB's talking indicator.", + table: { defaultValue: { summary: 'false' } }, + }, + disableTooltip: { + control: 'boolean', + description: 'Disables the tooltip that shows the full `name` on hover.', + table: { defaultValue: { summary: 'false' } }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** Falls back to initials when no image is given, next to an avatar rendering a provided image. */ +export const Default: Story = { + render: (args) => ( +
+ + +
+ ), +}; + +/** All size variants rendered side by side. */ +export const Sizes: Story = { + render: (args) => ( +
+ {AVATAR_SIZE_VALUES.map((size) => ( + + ))} +
+ ), +}; + +/** The default circular shape next to the rounded-square shape used for moderators. */ +export const Types: Story = { + render: (args) => ( +
+ + +
+ ), +}; + +/** Different names deriving different colors from the deterministic fallback, next to an explicit custom color override. */ +export const Colors: Story = { + render: (args) => ( +
+ + + + + + + + + + +
+ ), +}; + +/** Not talking next to the pulsing ring shown while the user is speaking. */ +export const Talking: Story = { + render: (args) => ( +
+ + +
+ ), +}; diff --git a/src/components/Avatar/component.tsx b/src/components/Avatar/component.tsx new file mode 100644 index 0000000..fe2e161 --- /dev/null +++ b/src/components/Avatar/component.tsx @@ -0,0 +1,72 @@ +import React, { JSX, useEffect, useState } from 'react'; +import Tippy from '@tippyjs/react'; +import 'tippy.js/dist/tippy.css'; +import * as Styled from './styles'; +import { AvatarProps } from './types'; +import { DEFAULT_AVATAR_SIZE, AVATAR_FALLBACK_COLORS } from './constants'; +import { colorUserYou } from '../../stylesheets/palette'; + +function getInitials(name: string): string { + const words = name.trim().split(/\s+/).filter(Boolean); + if (words.length === 0) return ''; + if (words.length === 1) return words[0].slice(0, 2).toUpperCase(); + return `${words[0][0]}${words[words.length - 1][0]}`.toUpperCase(); +} + +function getFallbackColor(name: string): string { + const hash = name.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); + return AVATAR_FALLBACK_COLORS[hash % AVATAR_FALLBACK_COLORS.length]; +} + +/** + * A user avatar component. + * + * Renders the user's avatar image when available, falling back to their initials on a + * deterministically-colored background when no image is provided or it fails to load. + * + */ +function Avatar({ + name, + avatarUrl, + color, + size = DEFAULT_AVATAR_SIZE, + isModerator = false, + isYou = false, + isTalking = false, + disableTooltip = false, +}: AvatarProps): JSX.Element { + const [hasImageError, setHasImageError] = useState(false); + const resolvedColor = color || (isYou ? colorUserYou : getFallbackColor(name)); + + useEffect(() => { + setHasImageError(false); + }, [avatarUrl]); + + const avatarElement = avatarUrl && !hasImageError ? ( + setHasImageError(true)} + /> + ) : ( + + {getInitials(name)} + + ); + + if (disableTooltip) { + return avatarElement; + } + + return ( + + {avatarElement} + + ); +} + +export default Avatar; diff --git a/src/components/Avatar/constants.ts b/src/components/Avatar/constants.ts new file mode 100644 index 0000000..7e9e260 --- /dev/null +++ b/src/components/Avatar/constants.ts @@ -0,0 +1,15 @@ +export const AVATAR_SIZES = { + SMALL: 'small', + MEDIUM: 'medium', + LARGE: 'large', +} as const; + +export const AVATAR_SIZE_VALUES = Object.values(AVATAR_SIZES); +export const DEFAULT_AVATAR_SIZE = AVATAR_SIZES.MEDIUM; + +// Mirrors the palette akka-bbb-apps' ColorPicker assigns to users server-side (round-robin per +// meeting), so a name-hash fallback here lands on the same colors real BBB users get. +export const AVATAR_FALLBACK_COLORS = [ + '#7b1fa2', '#6a1b9a', '#4a148c', '#5e35b1', '#512da8', '#4527a0', '#311b92', + '#3949ab', '#303f9f', '#283593', '#1a237e', '#1976d2', '#1565c0', '#0d47a1', '#0277bd', '#01579b', +]; diff --git a/src/components/Avatar/index.ts b/src/components/Avatar/index.ts new file mode 100644 index 0000000..3d4170d --- /dev/null +++ b/src/components/Avatar/index.ts @@ -0,0 +1 @@ +export { default as BBBAvatar } from './component'; diff --git a/src/components/Avatar/styles.ts b/src/components/Avatar/styles.ts new file mode 100644 index 0000000..71ab5f7 --- /dev/null +++ b/src/components/Avatar/styles.ts @@ -0,0 +1,48 @@ +import styled, { css, keyframes } from 'styled-components'; +import { colorWhite } from '../../stylesheets/palette'; +import { AVATAR_SIZES } from './constants'; +import { StyledAvatarProps } from './types'; + +const DIMENSIONS = { + [AVATAR_SIZES.SMALL]: { dimension: '1.625rem', fontSize: '0.625rem', fontWeight: '600' }, + [AVATAR_SIZES.MEDIUM]: { dimension: '3rem', fontSize: '1.125rem', fontWeight: '500' }, + [AVATAR_SIZES.LARGE]: { dimension: '6rem', fontSize: '2.75rem', fontWeight: '400' }, +}; + +// Mirrors BBB's talking indicator (a ring, in the user's own color, that spreads and fades out). +const talkingPulse = (color: string) => keyframes` + 0% { box-shadow: 0 0 0 0 ${color}; } + 100% { box-shadow: 0 0 0 4px transparent; } +`; + +const talkingStyles = css` + ${({ $isTalking, $color }) => $isTalking && css` + animation: ${talkingPulse($color)} 1s infinite ease-in; + `} +`; + +export const AvatarInitials = styled.div` + width: ${({ $size }) => DIMENSIONS[$size].dimension}; + height: ${({ $size }) => DIMENSIONS[$size].dimension}; + border-radius: ${({ $isModerator }) => ($isModerator ? '20%' : '50%')}; + flex-shrink: 0; + background: ${({ $color }) => $color}; + display: flex; + align-items: center; + justify-content: center; + font-size: ${({ $size }) => DIMENSIONS[$size].fontSize}; + font-weight: ${({ $size }) => DIMENSIONS[$size].fontWeight}; + color: ${colorWhite}; + text-transform: uppercase; + ${talkingStyles} +`; + +export const AvatarImage = styled.img` + width: ${({ $size }) => DIMENSIONS[$size].dimension}; + height: ${({ $size }) => DIMENSIONS[$size].dimension}; + border-radius: ${({ $isModerator }) => ($isModerator ? '20%' : '50%')}; + flex-shrink: 0; + object-fit: cover; + border: 2px solid ${({ $color }) => $color}; + ${talkingStyles} +`; diff --git a/src/components/Avatar/types.ts b/src/components/Avatar/types.ts new file mode 100644 index 0000000..978b74f --- /dev/null +++ b/src/components/Avatar/types.ts @@ -0,0 +1,36 @@ +import { AVATAR_SIZE_VALUES } from './constants'; + +export type AvatarSize = typeof AVATAR_SIZE_VALUES[number]; + +export interface StyledAvatarProps { + $size: AvatarSize; + $color: string; + $isModerator: boolean; + $isTalking: boolean; +} + +export interface AvatarProps { + /** Full name of the user; used to render initials, to derive a deterministic fallback color, and as the tooltip content. */ + name: string; + + /** URL of the user's avatar image. Falls back to initials when omitted or if the image fails to load. */ + avatarUrl?: string; + + /** Background color behind the initials (and border color on the image). @default a color deterministically derived from `name`, or BBB's "you" color when `isYou` is set */ + color?: string; + + /** Size variant of the avatar. @default 'medium' */ + size?: AvatarSize; + + /** Renders a rounded-square shape instead of a circle, matching BBB's moderator avatar treatment. @default false */ + isModerator?: boolean; + + /** Marks this avatar as belonging to the current user, applying BBB's "you" color in place of the fallback/computed color. Ignored when `color` is set. @default false */ + isYou?: boolean; + + /** Shows a pulsing ring around the avatar, in its own color, matching BBB's talking indicator. @default false */ + isTalking?: boolean; + + /** Disables the tooltip that shows the full `name` on hover. @default false */ + disableTooltip?: boolean; +} diff --git a/src/components/Button/component.stories.tsx b/src/components/Button/component.stories.tsx index 8020311..83d251f 100644 --- a/src/components/Button/component.stories.tsx +++ b/src/components/Button/component.stories.tsx @@ -55,7 +55,7 @@ const meta = { size: { control: 'select', options: SIZE_VALUES, - description: `Button size. Allowed values: ${SIZE_VALUES.join(', ')}. Note: size affects padding and, for \`circle\` layout, the overall diameter.`, + description: `Button size. Allowed values: ${SIZE_VALUES.join(', ')}. Note: size affects padding and, for \`circle\`/\`squared\` layouts, the overall box size (both render at the same size).`, table: { defaultValue: { summary: `${DEFAULT_SIZE}` } }, }, layout: { @@ -323,6 +323,44 @@ export const AllSizes: Story = { ), }; +/** Renders the icon-only `circle` and `squared` layouts across every available `size`, showing that both render at the same box size for a given `size`. */ +export const IconOnlySizes: Story = { + name: 'Icon-Only Layout Sizes', + args: { + variant: 'primary', + }, + render: (args) => ( +
+
+ {SIZE_VALUES.map((size) => ( + } + ariaLabel={`Circle ${size}`} + tooltipLabel={`Circle ${size}`} + /> + ))} +
+
+ {SIZE_VALUES.map((size) => ( + } + ariaLabel={`Squared ${size}`} + tooltipLabel={`Squared ${size}`} + /> + ))} +
+
+ ), +}; + /** Shows the button in its disabled state. */ export const Disabled: Story = { args: { diff --git a/src/components/Button/constants.ts b/src/components/Button/constants.ts index 22517be..5df5521 100644 --- a/src/components/Button/constants.ts +++ b/src/components/Button/constants.ts @@ -15,7 +15,9 @@ import { colorGray, colorBrandAux, } from '../../stylesheets/palette'; -import { spacingMedium, spacingSmall, spacingSmallMedium } from '../../stylesheets/sizing'; +import { + spacingSmall, spacingSmallMedium, spacingMedium, spacingXLarge, spacingXXLarge, +} from '../../stylesheets/sizing'; import { CSSColorPropertiesType, CSSSizePropertiesType, EssentialColorProperties } from './type'; const VARIANTS = { @@ -193,9 +195,11 @@ const CSS_COLOR_PROPERTIES_DISABLED: EssentialColorProperties = { const CSS_SIZE_PROPERTIES: CSSSizePropertiesType = { sm: { padding: `${spacingSmall} ${spacingSmallMedium}`, + iconBoxSize: spacingXLarge, }, md: { - padding: spacingMedium + padding: spacingMedium, + iconBoxSize: spacingXXLarge, }, }; diff --git a/src/components/Button/styles.ts b/src/components/Button/styles.ts index d384e80..b44c596 100644 --- a/src/components/Button/styles.ts +++ b/src/components/Button/styles.ts @@ -119,10 +119,15 @@ const stackedLayoutStyles = css` align-items: center; `; -const circleLayoutStyles = css` - border-radius: 50%; - width: 3rem; - height: 3rem; +// Shared by `circle` and `squared`: both are icon-only, fixed-box layouts that +// must render at the same width/height for a given `size` — they differ only +// in border-radius (round vs. slightly rounded square). +const iconOnlyBoxStyles = css` + ${({ $size }) => { + const safeSize = $size ?? DEFAULT_SIZE; + const { iconBoxSize } = CSS_SIZE_PROPERTIES[safeSize] ?? CSS_SIZE_PROPERTIES[DEFAULT_SIZE]; + return `width: ${iconBoxSize}; height: ${iconBoxSize};`; + }} aspect-ratio: 1; padding: 0; display: flex; @@ -130,16 +135,14 @@ const circleLayoutStyles = css` align-items: center; `; -// Icon-only, sized by its own (equal, non-text) padding rather than a fixed -// box — stays compact next to a single line of text instead of forcing a -// fixed height like circle/stacked do. -const squaredLayoutStyles = css` +const circleLayoutStyles = css` + ${iconOnlyBoxStyles} + border-radius: 50%; +`; + +const squaredLayoutStyles = css` + ${iconOnlyBoxStyles} border-radius: ${borderRadiusSmall}; - aspect-ratio: 1; - padding: ${spacingSmall}; - display: flex; - justify-content: center; - align-items: center; `; const defaultLayoutStyles = css` diff --git a/src/components/Button/type.ts b/src/components/Button/type.ts index 30c9281..bc38ae0 100644 --- a/src/components/Button/type.ts +++ b/src/components/Button/type.ts @@ -30,6 +30,7 @@ export type CSSColorPropertiesType = { export type SizeProperties = { padding: string; + iconBoxSize: string; } export type CSSSizePropertiesType = { @@ -97,7 +98,7 @@ type BaseButtonProps = { /** Visual variant controlling emphasis and background/border styling. @default 'primary' */ variant?: VariantType; - /** Button size; affects padding and, for the `circle` layout, the overall diameter. @default 'md' */ + /** Button size; affects padding and, for the `circle`/`squared` layouts, the overall box size (both render at the same size). @default 'md' */ size?: SizeType; /** Disables interaction and applies disabled styling. @default false */ diff --git a/src/components/Checkbox/README.md b/src/components/Checkbox/README.md index 5f219a0..4464c4a 100644 --- a/src/components/Checkbox/README.md +++ b/src/components/Checkbox/README.md @@ -28,4 +28,5 @@ import { BBBCheckbox } from 'bbb-ui-components'; | `layout` | `keyof typeof LAYOUTS` | `'right'` | The position of the label relative to the checkbox. | | `round` | `boolean` | `false` | If `true`, the checkbox will be rendered as a round radio-style button. | | `ariaLabel`| `string` | | The accessible name for the checkbox. | +| `dataTest` | `string` | | The `data-test` attribute on the underlying `` element. | | `...props` | `any` | | Any other props will be passed down to the underlying Material-UI Checkbox component. | diff --git a/src/components/Checkbox/component.stories.tsx b/src/components/Checkbox/component.stories.tsx index d5d14ac..c6f7d22 100644 --- a/src/components/Checkbox/component.stories.tsx +++ b/src/components/Checkbox/component.stories.tsx @@ -25,6 +25,10 @@ const meta = { control: 'text', description: 'Accessible name for the checkbox.', }, + dataTest: { + control: 'text', + description: '`data-test` attribute on the underlying `` element.', + }, }, } satisfies Meta; diff --git a/src/components/Checkbox/component.tsx b/src/components/Checkbox/component.tsx index 808740e..4e52e96 100644 --- a/src/components/Checkbox/component.tsx +++ b/src/components/Checkbox/component.tsx @@ -17,16 +17,22 @@ function Checkbox({ layout = DEFAULT_LAYOUT, round = false, ariaLabel, + id, + dataTest, + inputProps, ...props }: CheckboxProps): JSX.Element { - const id = useId(); + const generatedId = useId(); + const checkboxId = id ?? generatedId; + const mergedInputProps = { 'data-test': dataTest, ...inputProps }; const checkbox = ( : undefined} checkedIcon={round ? : undefined} + inputProps={mergedInputProps} /> ); @@ -36,9 +42,9 @@ function Checkbox({ return ( - {layout === LAYOUTS.LEFT && {label}} + {layout === LAYOUTS.LEFT && {label}} {checkbox} - {layout === LAYOUTS.RIGHT && {label}} + {layout === LAYOUTS.RIGHT && {label}} ); } diff --git a/src/components/Checkbox/types.ts b/src/components/Checkbox/types.ts index bd7298c..c61e065 100644 --- a/src/components/Checkbox/types.ts +++ b/src/components/Checkbox/types.ts @@ -15,4 +15,7 @@ export interface CheckboxProps extends MuiCheckboxProps { /** Accessible name for the checkbox. */ ariaLabel?: string; + + /** `data-test` attribute on the underlying `` element. */ + dataTest?: string; } diff --git a/src/components/Modal/README.md b/src/components/Modal/README.md index 0558370..769c640 100644 --- a/src/components/Modal/README.md +++ b/src/components/Modal/README.md @@ -39,6 +39,7 @@ import { BBBModal } from 'bbb-ui-components-react'; | `shouldCloseOnEsc` | `boolean` | `false` | Allows closing with ESC key. | | `allowScroll` | `boolean` | `true` | Enables scroll in the modal body. | | `noFooter` | `boolean` | `false` | Hides the modal footer. | +| `hideCloseButton` | `boolean` | `false` | Hides the header close button. | | `footerContent` | `React.ReactNode` | `null` | Custom content for the footer. | | `stickyFooter` | `boolean` | `true` | Makes the footer sticky. | | `testId` | `string` | — | Test identifier applied to the modal element (native `ReactModal` prop). | diff --git a/src/components/Modal/component.stories.tsx b/src/components/Modal/component.stories.tsx index 9367b1e..731eb33 100644 --- a/src/components/Modal/component.stories.tsx +++ b/src/components/Modal/component.stories.tsx @@ -59,6 +59,10 @@ const meta = { control: 'boolean', description: 'Hide the footer section.', }, + hideCloseButton: { + control: 'boolean', + description: 'Hide the header close button.', + }, footerContent: { control: false, description: 'Custom React node to render inside the footer.', @@ -190,3 +194,12 @@ export const Default: Story = { }, render: (args) => , }; + +/** Modal without a header close button, for mandatory-action or externally-controlled flows. */ +export const HideCloseButton: Story = { + args: { + ...Default.args, + hideCloseButton: true, + }, + render: (args) => , +}; diff --git a/src/components/Modal/component.tsx b/src/components/Modal/component.tsx index b1d9d8b..6376cf1 100644 --- a/src/components/Modal/component.tsx +++ b/src/components/Modal/component.tsx @@ -25,6 +25,7 @@ const Modal: React.FC = ({ shouldCloseOnEsc = false, allowScroll = true, noFooter = false, + hideCloseButton = false, footerContent = null, stickyFooter = true, testId, @@ -65,14 +66,16 @@ const Modal: React.FC = ({ > {title} - } - onClick={onRequestClose} - variant="subtle" - ariaLabel="close" - {...(_closeButtonDataTest ? { dataTest: _closeButtonDataTest } : {})} - /> + {!hideCloseButton && ( + } + onClick={onRequestClose} + variant="subtle" + ariaLabel="close" + {...(_closeButtonDataTest ? { dataTest: _closeButtonDataTest } : {})} + /> + )} {showDividers && } diff --git a/src/components/Modal/styles.ts b/src/components/Modal/styles.ts index 708c871..1002ac9 100644 --- a/src/components/Modal/styles.ts +++ b/src/components/Modal/styles.ts @@ -45,15 +45,6 @@ export const ModalHeader = styled.div` align-items: center; `; -export const CloseButton = styled.button` - background: none; - border: none; - font-size: 1.5rem; - cursor: pointer; - padding: 0; - line-height: 1; -`; - export const ModalScrollArea = styled.div` flex-grow: 1; overflow-y: ${({ $allowScroll }) => $allowScroll ? 'auto' : 'hidden'}; diff --git a/src/components/Modal/types.ts b/src/components/Modal/types.ts index d82a8f3..5cc7b41 100644 --- a/src/components/Modal/types.ts +++ b/src/components/Modal/types.ts @@ -28,6 +28,9 @@ export interface ModalProps extends Omit { /** Hide the footer section. @default false */ noFooter?: boolean; + /** Hide the header close button. @default false */ + hideCloseButton?: boolean; + /** When true, the footer is sticky to the bottom instead of scrolling with the body. @default true */ stickyFooter?: boolean; diff --git a/src/components/Slider/README.md b/src/components/Slider/README.md new file mode 100644 index 0000000..ae036d9 --- /dev/null +++ b/src/components/Slider/README.md @@ -0,0 +1,137 @@ +# BBBSlider + +The `BBBSlider` component provides a horizontal, single-thumb slider for picking a value inside a +range. It supports labels (`label` and `helperText`), decorative end icons, marks with or without +tick dots, and a value tooltip. + +![Demo](assets/example.png) + +## Usage Example + +### Slider with a label + +```jsx +import { BBBSlider } from 'bbb-ui-components-react'; + + +``` + +### Volume, where the number matters + +```jsx +import { BBBSlider } from 'bbb-ui-components-react'; + + `${value}%`} + getAriaValueText={(value) => `${value} percent`} + dataTest="volume-slider" + onChange={(event, value) => setVolume(value)} +/> +``` + +### Brightness, where the number does not matter + +```jsx +import { BBBSlider } from 'bbb-ui-components-react'; +import { MdBrightnessLow, MdBrightnessHigh } from 'react-icons/md'; + +} + iconEnd={} + valueLabelDisplay="off" + onChange={(event, value) => setBrightness(value)} +/> +``` + +### Discrete stages with labels only + +```jsx +import { BBBSlider } from 'bbb-ui-components-react'; + + setGridSize(value)} +/> +``` + +## Accessibility + +- Give every slider an accessible name: either `label` (which also renders visible text and is + wired to the input with `htmlFor`) or `ariaLabel` when the design has no visible label. +- `helperText` is referenced by the input's `aria-describedby` automatically; `ariaDescribedBy` + overrides it when the description lives elsewhere on the page. +- When `valueLabelFormat` shows something other than the raw value, pass the matching + `getAriaValueText` so screen readers announce what the user sees. +- Keyboard support comes from MUI: arrows move by `step`, Page Up/Page Down jump, Home/End go to + the ends. + +## Testing + +`dataTest` lands on the root element, and the focusable range input gets the same value suffixed +with `-input`. Drive the input, not the root: + +```js +await page.locator('[data-test="volume-slider-input"]').fill('80'); +``` + +## Notes + +The value tooltip is rendered inside the slider, so a container with `overflow: hidden` can clip it +when the slider sits at the very top of the container. Use `valueLabelDisplay="off"` or reserve +vertical space above the slider in that case. + +Mark labels are centred on their tick, so the labels at `min` and `max` extend half their width past +each end of the track. Reserve horizontal padding around the slider, or avoid labelling the extreme +marks, when the container clips. + +`iconStart`/`iconEnd` render at `1.5rem` by default, matching a circle `BBButton`; pass a `size` on +the icon itself to override it. + +## Props + +| Property | Type | Default | Description | +| ------------------- | ------------------------------------------------------------------ | -------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `label` | `string` | | The main label text displayed above the slider. | +| `helperText` | `string` | | The helper text displayed below the label. | +| `value` | `number` | | The current value; providing it makes the slider controlled. | +| `defaultValue` | `number` | | The initial value used while the slider is uncontrolled. | +| `min` | `number` | `0` | The minimum allowed value. | +| `max` | `number` | `100` | The maximum allowed value. | +| `step` | `number \| null` | `1` | The granularity the value moves by; `null` snaps to `marks`. | +| `marks` | `boolean \| { value: number, label?: ReactNode }[]` | `false` | The marks rendered on the track. | +| `showMarkTicks` | `boolean` | `true` | If `false`, renders the mark labels without their tick dots. | +| `animate` | `boolean` | `true` | If `false`, disables transitions and hover/drag visual effects so state changes render instantly. | +| `valueLabelDisplay` | `'auto' \| 'on' \| 'off'` | `'auto'` | When the value tooltip is shown. | +| `valueLabelFormat` | `string \| ((value: number, index: number) => ReactNode)` | | Formats the value shown in the tooltip. | +| `getAriaValueText` | `(value: number, index: number) => string` | | The text screen readers announce for the current value. | +| `iconStart` | `React.ReactNode` | | A decorative node rendered at the minimum end; it is hidden from screen readers. | +| `iconEnd` | `React.ReactNode` | | A decorative node rendered at the maximum end; it is hidden from screen readers. | +| `disabled` | `boolean` | `false` | If `true`, the slider is disabled and unresponsive. | +| `onChange` | `(event: Event, value: number) => void` | | Fired continuously while the user drags or presses a key. | +| `onChangeCommitted` | `(event: React.SyntheticEvent \| Event, value: number) => void` | | Fired once, when the interaction ends; prefer it for expensive updates. | +| `id` | `string` | | The HTML `id` of the underlying range input; auto-generated when omitted. | +| `dataTest` | `string` | | The `data-test` attribute for the root; the range input gets `${dataTest}-input`. | +| `ariaLabel` | `string` | | The accessible name for the slider. | +| `ariaLabelledBy` | `string` | | The ID of the element that labels the slider; ignored when `ariaLabel` is set, and falls back to the rendered label's id when omitted. | +| `ariaDescribedBy` | `string` | | The ID of the element that describes the slider; falls back to the rendered helper text's id when omitted. | +| `...props` | `MuiSliderProps` | | Any other props are passed down to the underlying Material-UI Slider component. | diff --git a/src/components/Slider/assets/example.png b/src/components/Slider/assets/example.png new file mode 100644 index 0000000..b606678 Binary files /dev/null and b/src/components/Slider/assets/example.png differ diff --git a/src/components/Slider/component.stories.tsx b/src/components/Slider/component.stories.tsx new file mode 100644 index 0000000..c329062 --- /dev/null +++ b/src/components/Slider/component.stories.tsx @@ -0,0 +1,232 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { MdBrightnessLow, MdBrightnessHigh } from 'react-icons/md'; +import BBBSlider from './component'; +import { VALUE_LABEL_DISPLAY_VALUES, DEFAULT_VALUE_LABEL_DISPLAY } from './constants'; + +const meta = { + title: 'BBBSlider', + component: BBBSlider, + tags: ['autodocs'], + argTypes: { + label: { + control: 'text', + description: 'Main label text displayed above the slider.', + }, + helperText: { + control: 'text', + description: 'Helper text displayed below the label.', + }, + min: { + control: 'number', + description: 'Minimum allowed value.', + }, + max: { + control: 'number', + description: 'Maximum allowed value.', + }, + step: { + control: 'number', + description: 'Granularity the value can move by; use null with marks to snap to them.', + }, + marks: { + control: 'object', + description: 'Marks to render on the track; true spreads them by step, or pass an array with labels.', + }, + showMarkTicks: { + control: 'boolean', + description: 'Renders the tick dots of `marks`; set to false to keep the mark labels only.', + table: { defaultValue: { summary: 'true' } }, + }, + animate: { + control: 'boolean', + description: 'Enables transitions and hover/drag visual effects; set to false for instant state changes.', + table: { defaultValue: { summary: 'true' } }, + }, + valueLabelDisplay: { + control: 'select', + options: VALUE_LABEL_DISPLAY_VALUES, + description: 'Controls when the value tooltip is shown.', + table: { defaultValue: { summary: `${DEFAULT_VALUE_LABEL_DISPLAY}` } }, + }, + iconStart: { + control: false, + description: 'Decorative node rendered at the minimum end of the track.', + }, + iconEnd: { + control: false, + description: 'Decorative node rendered at the maximum end of the track.', + }, + valueLabelFormat: { + control: false, + description: 'Formats the value shown in the tooltip; receives the raw value.', + }, + getAriaValueText: { + control: false, + description: 'Returns the text screen readers announce for the current value.', + }, + disabled: { + control: 'boolean', + description: 'Disable the slider when true.', + }, + dataTest: { + control: 'text', + description: 'Value for the data-test attribute on the root; the range input gets `${dataTest}-input`.', + }, + ariaLabel: { + control: 'text', + description: 'Accessible name for the slider.', + }, + onChange: { + control: false, + description: 'Change handler fired continuously while the user drags or presses a key.', + }, + onChangeCommitted: { + control: false, + description: 'Change handler fired once, when the interaction ends.', + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** Bare slider with no label, covering the default 0-100 range. */ +export const Default: Story = { + args: { defaultValue: 50, ariaLabel: 'Volume' }, +}; + +/** Visible label and helper text; clicking the label focuses the slider. */ +export const WithLabel: Story = { + args: { label: 'Brightness', helperText: 'Applies to the whole image', defaultValue: 50 }, +}; + +/** Disabled state, which keeps the label readable but blocks interaction. */ +export const Disabled: Story = { + args: { label: 'Brightness', defaultValue: 50, disabled: true }, +}; + +/** Decorative icons at both ends, the brightness pattern where the number itself is irrelevant. */ +export const WithIcons: Story = { + args: { + label: 'Brightness', + iconStart: , + iconEnd: , + valueLabelDisplay: 'off', + min: -100, + max: 100, + defaultValue: 0, + ariaLabel: 'Camera brightness', + }, +}; + +/** Discrete stages with labelled marks, snapping to each step. */ +export const WithMarks: Story = { + args: { + label: 'Webcam grid size', + min: 1, + max: 4, + step: 1, + marks: [ + { value: 1, label: '1x1' }, + { value: 2, label: '2x2' }, + { value: 3, label: '3x3' }, + { value: 4, label: '4x4' }, + ], + defaultValue: 2, + valueLabelDisplay: 'off', + ariaLabel: 'Webcam grid size', + }, + decorators: [ + (StoryComponent) => ( +
+ +
+ ), + ], +}; + +/** Mark labels without the tick dots, reproducing the camera modal's -100/0/100 scale. */ +export const MarksWithoutTicks: Story = { + args: { + label: 'Brightness', + min: -100, + max: 100, + showMarkTicks: false, + marks: [ + { value: -100, label: '-100' }, + { value: 0, label: '0' }, + { value: 100, label: '100' }, + ], + defaultValue: 0, + ariaLabel: 'Camera brightness', + }, + decorators: [ + (StoryComponent) => ( +
+ +
+ ), + ], +}; + +/** Tooltip pinned open, for cases where the number must stay visible while adjusting. */ +export const ValueLabelAlwaysOn: Story = { + args: { label: 'Volume', defaultValue: 70, valueLabelDisplay: 'on', ariaLabel: 'Volume' }, +}; + +/** Percentage formatting through valueLabelFormat, the volume pattern where the number matters. */ +export const PercentageFormat: Story = { + args: { + label: 'Volume', + defaultValue: 70, + valueLabelDisplay: 'on', + valueLabelFormat: (value: number) => `${value}%`, + getAriaValueText: (value: number) => `${value} percent`, + ariaLabel: 'Volume', + }, +}; + +/** Stored value and displayed value differ: the range is 0-200 but the user sees -100 to 100. */ +export const OffsetFormat: Story = { + args: { + label: 'Brightness', + min: 0, + max: 200, + defaultValue: 100, + valueLabelDisplay: 'on', + valueLabelFormat: (value: number) => `${value - 100}`, + getAriaValueText: (value: number) => `${value - 100}`, + ariaLabel: 'Camera brightness', + }, +}; + +/** Animation disabled: dragging the thumb and viewing the tooltip show no transitions or hover glow. */ +export const WithoutAnimation: Story = { + args: { + label: 'Volume', + defaultValue: 70, + valueLabelDisplay: 'on', + animate: false, + ariaLabel: 'Volume', + }, +}; + +/** Same slider under a right-to-left ancestor: the track fills from the right. */ +export const RightToLeft: Story = { + args: { + label: 'Brightness', + min: -100, + max: 100, + defaultValue: 50, + valueLabelDisplay: 'on', + ariaLabel: 'Camera brightness', + }, + decorators: [ + (StoryComponent) => ( +
+ +
+ ), + ], +}; diff --git a/src/components/Slider/component.tsx b/src/components/Slider/component.tsx new file mode 100644 index 0000000..6f21302 --- /dev/null +++ b/src/components/Slider/component.tsx @@ -0,0 +1,110 @@ +import React, { JSX, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; +import { createTheme, ThemeProvider, useTheme } from '@mui/material/styles'; +import { SliderProps } from './types'; +import * as Styled from './styles'; +import { DEFAULT_ANIMATE, DEFAULT_SHOW_MARK_TICKS, DEFAULT_VALUE_LABEL_DISPLAY } from './constants'; + +/** + * A horizontal, single-thumb slider for picking a value inside a range. + * + * It supports labels (label and helperText), decorative end icons, marks and a value tooltip, + * and keeps its accessible name and description on the underlying range input. + * + */ +function Slider ({ + label, + helperText, + iconStart, + iconEnd, + showMarkTicks = DEFAULT_SHOW_MARK_TICKS, + animate = DEFAULT_ANIMATE, + valueLabelDisplay = DEFAULT_VALUE_LABEL_DISPLAY, + onChange, + onChangeCommitted, + id: providedId, + dataTest, + ariaLabel, + ariaLabelledBy, + ariaDescribedBy, + slotProps, + ...rest +}: SliderProps): JSX.Element { + const generatedUniqueId = useId(); + const id = providedId ?? generatedUniqueId; + const labelId = label ? `${id}-label` : undefined; + const helperTextId = helperText ? `${id}-helper` : undefined; + + const wrapperRef = useRef(null); + const [isRtl, setIsRtl] = useState(false); + + useEffect(() => { + const wrapper = wrapperRef.current; + if (!wrapper) return undefined; + + const readDirection = () => { + setIsRtl(window.getComputedStyle(wrapper).direction === 'rtl'); + }; + + readDirection(); + const observer = new MutationObserver(readDirection); + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['dir'] }); + + return () => observer.disconnect(); + }, []); + + const outerTheme = useTheme(); + const theme = useMemo( + () => (isRtl && outerTheme.direction !== 'rtl' + ? createTheme(outerTheme, { direction: 'rtl' }) + : outerTheme), + [outerTheme, isRtl], + ); + + const handleChange = useCallback((event: Event, value: number | number[]) => { + onChange?.(event, Array.isArray(value) ? value[0] : value); + }, [onChange]); + + const handleChangeCommitted = useCallback(( + event: React.SyntheticEvent | Event, + value: number | number[], + ) => { + onChangeCommitted?.(event, Array.isArray(value) ? value[0] : value); + }, [onChangeCommitted]); + + return ( + + {(label || helperText) && ( + + {label && {label}} + {helperText && {helperText}} + + )} + + {iconStart && {iconStart}} + + + + {iconEnd && {iconEnd}} + + + ); +} + +export default Slider; diff --git a/src/components/Slider/constants.ts b/src/components/Slider/constants.ts new file mode 100644 index 0000000..628d7fa --- /dev/null +++ b/src/components/Slider/constants.ts @@ -0,0 +1,10 @@ +export const VALUE_LABEL_DISPLAYS = { + AUTO: 'auto', + ON: 'on', + OFF: 'off', +} as const; + +export const VALUE_LABEL_DISPLAY_VALUES = Object.values(VALUE_LABEL_DISPLAYS); +export const DEFAULT_VALUE_LABEL_DISPLAY = VALUE_LABEL_DISPLAYS.AUTO; +export const DEFAULT_SHOW_MARK_TICKS = true; +export const DEFAULT_ANIMATE = true; diff --git a/src/components/Slider/index.ts b/src/components/Slider/index.ts new file mode 100644 index 0000000..58fd15e --- /dev/null +++ b/src/components/Slider/index.ts @@ -0,0 +1 @@ +export { default as BBBSlider } from './component'; diff --git a/src/components/Slider/styles.ts b/src/components/Slider/styles.ts new file mode 100644 index 0000000..edd3929 --- /dev/null +++ b/src/components/Slider/styles.ts @@ -0,0 +1,167 @@ +import { Slider } from '@mui/material'; +import { styled as materialStyled } from '@mui/material/styles'; +import styled from 'styled-components'; +import { + colorBrand1, + colorNeutral3, + colorWhite, + colorShadowDefault, + colorTextDefault, + colorTextLight, + colorIconDefault, + colorBorderFocusRing, +} from '../../stylesheets/palette'; +import { + fontSizeXSmall, + fontSizeDefault, + fontSizeBig, + fontWeightDefault, + fontWeightLight, +} from '../../stylesheets/typography'; +import { + spacingXSmall, + spacingSmall, + spacingXLarge, + borderRadiusSmall, +} from '../../stylesheets/sizing'; +import { StyledSliderRowProps, StyledSliderWrapperProps } from './types'; + +const THUMB_SIZE = '1rem'; +const TRACK_HEIGHT = '0.3125rem'; +const MARK_SIZE = '0.25rem'; +const ICON_SIZE = '1.5rem'; + +export const SliderWrapper = styled.div` + display: flex; + flex-direction: column; + width: 100%; + + ${({ $valueLabelSpace }) => $valueLabelSpace && ` + gap: ${spacingXLarge}; + `} +`; + +export const TextWrapper = styled.label` + display: flex; + flex-direction: column; + cursor: pointer; +`; + +export const Title = styled.span` + color: ${colorTextDefault}; + font-size: ${fontSizeBig}; + font-weight: ${fontWeightLight}; +`; + +export const HelperText = styled.span` + color: ${colorTextLight}; + font-size: ${fontSizeDefault}; + font-weight: ${fontWeightLight}; +`; + +export const SliderRow = styled.div` + display: flex; + align-items: center; + gap: ${spacingSmall}; + width: 100%; + + ${({ $showMarkTicks }) => !$showMarkTicks && ` + & .MuiSlider-mark { + display: none; + } + `} + + ${({ $animate }) => !$animate && ` + & .MuiSlider-thumb, + & .MuiSlider-track, + & .MuiSlider-valueLabel { + transition: none; + } + + & .MuiSlider-thumb:hover:not(.Mui-focusVisible), + & .MuiSlider-thumb.Mui-active:not(.Mui-focusVisible) { + box-shadow: 0 2px 4px 0 ${colorShadowDefault}; + } + `} + + ${({ $isRtl }) => $isRtl && ` + /* MUI only mirrors right:-anchored positioning under RTL, not the LTR transforms paired + with it (that half normally comes from stylis-plugin-rtl, which this library can't add) */ + && .MuiSlider-thumb { + transform: translate(50%, -50%); + } + + && .MuiSlider-mark { + transform: translate(1px, -50%); + } + + && .MuiSlider-markLabel { + transform: translateX(50%); + } + `} +`; + +export const IconWrapper = styled.span` + display: flex; + align-items: center; + color: ${colorIconDefault}; + font-size: ${ICON_SIZE}; +`; + +export const MaterialSlider = materialStyled(Slider)({ + color: colorBrand1, + height: TRACK_HEIGHT, + '& .MuiSlider-rail': { + backgroundColor: 'currentColor', + opacity: 0.38, + }, + '& .MuiSlider-track': { + border: '1px solid currentColor', + backgroundColor: colorBrand1, + }, + '& .MuiSlider-thumb': { + height: THUMB_SIZE, + width: THUMB_SIZE, + backgroundColor: 'currentColor', + boxShadow: `0 2px 4px 0 ${colorShadowDefault}`, + '&:hover, &.Mui-focusVisible': { + boxShadow: `0 0 0 0.5rem ${colorBorderFocusRing}`, + }, + '&.Mui-active': { + boxShadow: `0 0 0 0.875rem ${colorBorderFocusRing}`, + }, + }, + '& .MuiSlider-mark': { + width: MARK_SIZE, + height: MARK_SIZE, + borderRadius: '50%', + backgroundColor: colorNeutral3, + '&.MuiSlider-markActive': { + backgroundColor: colorWhite, + }, + }, + '& .MuiSlider-markLabel': { + color: colorTextLight, + fontSize: fontSizeXSmall, + // RTL: resolve each label's own direction so a leading minus sign doesn't get reordered + unicodeBidi: 'plaintext', + }, + '& .MuiSlider-valueLabel': { + backgroundColor: colorTextDefault, + color: colorWhite, + fontSize: fontSizeXSmall, + fontWeight: fontWeightDefault, + borderRadius: borderRadiusSmall, + padding: `${spacingXSmall} ${spacingSmall}`, + '& .MuiSlider-valueLabelLabel': { + // RTL: resolve each label's own direction so a leading minus sign doesn't get reordered + unicodeBidi: 'plaintext', + }, + }, + '&.Mui-disabled': { + color: colorNeutral3, + '& .MuiSlider-track': { + backgroundColor: colorNeutral3, + }, + }, +}); diff --git a/src/components/Slider/types.ts b/src/components/Slider/types.ts new file mode 100644 index 0000000..6e1a6a7 --- /dev/null +++ b/src/components/Slider/types.ts @@ -0,0 +1,67 @@ +import React from 'react'; +import { SliderProps as MuiSliderProps } from '@mui/material'; +import { VALUE_LABEL_DISPLAY_VALUES } from './constants'; + +export type ValueLabelDisplay = typeof VALUE_LABEL_DISPLAY_VALUES[number]; + +export interface StyledSliderRowProps { + $showMarkTicks: boolean; + $animate: boolean; + $isRtl: boolean; +} + +export interface StyledSliderWrapperProps { + $valueLabelSpace: boolean; +} + +export interface SliderProps extends Omit { + /** Main label text displayed above the slider. */ + label?: string; + + /** Helper text displayed below the label. */ + helperText?: string; + + /** Current value; providing it makes the slider controlled. */ + value?: number; + + /** Initial value used while the slider is uncontrolled. */ + defaultValue?: number; + + /** Change handler fired continuously while the user drags or presses a key. */ + onChange?: (event: Event, value: number) => void; + + /** Change handler fired once, when the interaction ends; prefer it for expensive updates. */ + onChangeCommitted?: (event: React.SyntheticEvent | Event, value: number) => void; + + /** Decorative node rendered at the minimum end of the track. */ + iconStart?: React.ReactNode; + + /** Decorative node rendered at the maximum end of the track. */ + iconEnd?: React.ReactNode; + + /** Controls when the value tooltip is shown. @default 'auto' */ + valueLabelDisplay?: ValueLabelDisplay; + + /** Renders the tick dots of `marks`; set to false to keep the mark labels only. @default true */ + showMarkTicks?: boolean; + + /** Enables transitions and hover/drag visual effects; set to false for instant state changes. @default true */ + animate?: boolean; + + /** HTML `id` applied to the underlying range input; auto-generated via `useId` when omitted. */ + id?: string; + + /** Value for the `data-test` attribute on the root; the range input gets `${dataTest}-input`. */ + dataTest?: string; + + /** Accessible name for the slider. */ + ariaLabel?: string; + + /** ID of the element that labels the slider; ignored when `ariaLabel` is set, and falls back to the rendered label's id when omitted. */ + ariaLabelledBy?: string; + + /** ID of the element that describes the slider; falls back to the rendered helper text's id when omitted. */ + ariaDescribedBy?: string; +} diff --git a/src/components/index.ts b/src/components/index.ts index 230d706..1a25978 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,4 +1,5 @@ export { BBBAccordion } from './Accordion'; +export { BBBAvatar } from './Avatar'; export { BBButton } from './Button'; export { BBBCheckbox } from './Checkbox'; export { BBBDivider } from './Divider'; @@ -9,6 +10,7 @@ export { BBBNavigation } from './Navigation'; export { BBBScrollArea } from './ScrollArea'; export { BBBSearch } from './Search'; export { BBBSelect } from './Select'; +export { BBBSlider } from './Slider'; export { BBBSpinner } from './Spinner'; export { BBBTextAreaInput } from './TextAreaInput'; export { BBBTextInput } from './TextInput'; diff --git a/src/stylesheets/colors.ts b/src/stylesheets/colors.ts index 84251c7..fc5341a 100644 --- a/src/stylesheets/colors.ts +++ b/src/stylesheets/colors.ts @@ -9,6 +9,7 @@ import { colorHoverDark, colorHoverLight, colorHoverNeutral, colorOverlay, colorShadowDefault, + colorUserYou, } from './palette'; export const colors = { @@ -66,6 +67,9 @@ export const colors = { shadow: { default: colorShadowDefault, }, + user: { + you: colorUserYou, + }, } as const; export type Colors = typeof colors; diff --git a/src/stylesheets/palette.ts b/src/stylesheets/palette.ts index 1867278..6b43f8e 100644 --- a/src/stylesheets/palette.ts +++ b/src/stylesheets/palette.ts @@ -66,3 +66,6 @@ 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%))'; + +// User colors +export const colorUserYou = 'var(--color-user-you, #19237C)'; diff --git a/src/stylesheets/sizing.ts b/src/stylesheets/sizing.ts index 1e0c76a..af3ef60 100644 --- a/src/stylesheets/sizing.ts +++ b/src/stylesheets/sizing.ts @@ -8,6 +8,7 @@ export const spacingMedium = '1rem'; // 16px export const spacingMediumLarge = '1.25rem'; // 20px export const spacingLarge = '1.5rem'; // 24px export const spacingXLarge = '2rem'; // 32px +export const spacingXXLarge = '3rem'; // 48px // Border sizes export const borderRadiusDefault = '1rem'; //16px diff --git a/webpack.config.babel.js b/webpack.config.babel.js index 461ae7f..a00e59b 100644 --- a/webpack.config.babel.js +++ b/webpack.config.babel.js @@ -3,6 +3,7 @@ import path from 'path'; export default { entry: { Accordion: './src/components/Accordion/index.ts', + Avatar: './src/components/Avatar/index.ts', Button: './src/components/Button/index.ts', Checkbox: './src/components/Checkbox/index.ts', Divider: './src/components/Divider/index.ts', @@ -13,6 +14,7 @@ export default { ScrollArea: './src/components/ScrollArea/index.ts', Search: './src/components/Search/index.ts', Select: './src/components/Select/index.ts', + Slider: './src/components/Slider/index.ts', Spinner: './src/components/Spinner/index.ts', TextAreaInput: './src/components/TextAreaInput/index.ts', TextInput: './src/components/TextInput/index.ts',