diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 19f8df4..b3cae4b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -2,9 +2,6 @@ name: Publish to NPM on: workflow_dispatch: - push: - tags: - - 'v*' permissions: contents: read @@ -17,6 +14,14 @@ jobs: - name: Checkout uses: actions/checkout@v5 + - name: Require a release tag matching package.json + run: | + VERSION="v$(node -p "require('./package.json').version")" + if [ "${{ github.ref_type }}" != "tag" ] || [ "${{ github.ref_name }}" != "$VERSION" ]; then + echo "::error::Run this workflow from the release tag (expected $VERSION, got ${{ github.ref_type }} ${{ github.ref_name }})." + exit 1 + fi + - name: Setup Node.js uses: actions/setup-node@v5 with: diff --git a/.github/workflows/storybook-deploy.yml b/.github/workflows/storybook-deploy.yml index 9d9af42..c41fdbe 100644 --- a/.github/workflows/storybook-deploy.yml +++ b/.github/workflows/storybook-deploy.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - develop workflow_dispatch: {} permissions: @@ -13,6 +14,13 @@ permissions: jobs: build-and-deploy: + if: ${{ github.ref_name == 'main' || github.ref_name == 'develop' }} + # Both branches push to gh-pages; overlapping runs would reject each other's + # push, and the default single pending slot would drop a queued deploy. + concurrency: + group: gh-pages-deploy + cancel-in-progress: false + queue: max runs-on: ubuntu-latest steps: @@ -44,10 +52,20 @@ jobs: echo "storybook-static does not exist at ./storybook-static" fi + # The main deploy cleans the whole gh-pages root, develop/ included. + - name: Preserve the develop Storybook + if: ${{ github.ref_name == 'main' }} + run: | + git fetch --depth=1 origin gh-pages || exit 0 + if git ls-tree -d --name-only FETCH_HEAD develop | grep -q .; then + git archive FETCH_HEAD develop | tar -x -C ./storybook-static + fi + - name: Deploy to GitHub Pages uses: peaceiris/actions-gh-pages@v4 with: publish_dir: ./storybook-static + destination_dir: ${{ github.ref_name == 'develop' && 'develop' || '' }} github_token: ${{ secrets.GITHUB_TOKEN }} publish_branch: gh-pages keep_files: false diff --git a/.github/workflows/tag_and_release.yml b/.github/workflows/tag_and_release.yml index fb4a636..19bd989 100644 --- a/.github/workflows/tag_and_release.yml +++ b/.github/workflows/tag_and_release.yml @@ -1,4 +1,4 @@ -name: Manual Tag & Release +name: Tag & Release (Manual Trigger) on: workflow_dispatch: @@ -8,6 +8,21 @@ on: required: true type: boolean default: false + release_type: + description: 'Version bump: auto (infer from commits) or force patch/minor/major' + required: true + type: choice + default: 'auto' + options: + - auto + - patch + - minor + - major + generate_changelog: + description: 'Generate/update CHANGELOG.md for this release?' + required: true + type: boolean + default: false jobs: release: @@ -25,6 +40,24 @@ jobs: with: node-version-file: '.nvmrc' + # The conventionalcommits preset bumps breaking changes to major even on + # 0.x, so an inferred bump could ship 1.0.0 by accident. + - name: Require an explicit bump during initial development + if: ${{ inputs.release_type == 'auto' }} + run: | + if node -p "require('./package.json').version" | grep -q '^0\.'; then + echo "::error::release_type must be patch, minor or major while the version is 0.x." + exit 1 + fi + + - name: Ensure CHANGELOG.md exists + if: ${{ inputs.generate_changelog }} + run: touch CHANGELOG.md + + - name: Force version bump + if: ${{ inputs.release_type != 'auto' }} + run: npm version ${{ inputs.release_type }} --no-git-tag-version + - name: Generate Changelog and Tag id: changelog uses: TriPSs/conventional-changelog-action@v5 @@ -34,15 +67,18 @@ jobs: git-user-email: 'github-actions[bot]@users.noreply.github.com' git-message: 'chore(release): {version}' skip-on-empty: 'false' + skip-bump: ${{ inputs.release_type != 'auto' }} preset: 'conventionalcommits' skip-ci: 'false' tag-prefix: 'v' + input-file: ${{ inputs.generate_changelog && './CHANGELOG.md' || '' }} + output-file: ${{ inputs.generate_changelog && './CHANGELOG.md' || 'false' }} version-file: './package.json, ./package-lock.json' pre-commit: './.github/scripts/pre-commit.js' - name: Create GitHub Release - if: ${{ github.event.inputs.create_github_release == 'true' && steps.changelog.outputs.skipped == 'false' }} - uses: softprops/action-gh-release@v1 + if: ${{ inputs.create_github_release && steps.changelog.outputs.skipped == 'false' }} + uses: softprops/action-gh-release@v3 with: tag_name: ${{ steps.changelog.outputs.tag }} body: ${{ steps.changelog.outputs.clean_changelog }} diff --git a/README.md b/README.md index 808fddb..b980398 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Below is a list of the components available in this library. Each component has - [BBBSpinner](./src/components/Spinner//README.md) - [BBBTextAreaInput](./src/components/TextAreaInput/README.md) - [BBBTextInput](./src/components/TextInput/README.md) +- [BBBToast](./src/components/Toast/README.md) - [BBBToggle](./src/components/Toggle/README.md) - [BBBTypography](./src/components/Typography/README.md) diff --git a/package.json b/package.json index e216a1c..cbe8410 100644 --- a/package.json +++ b/package.json @@ -127,6 +127,13 @@ "require": "./dist/components/TextInput.js", "default": "./dist/components/TextInput.js" }, + "./Toast": { + "types": "./dist/types/components/Toast/index.d.ts", + "node": "./dist/components/Toast.js", + "import": "./dist/esm/components/Toast/index.js", + "require": "./dist/components/Toast.js", + "default": "./dist/components/Toast.js" + }, "./Toggle": { "types": "./dist/types/components/Toggle/index.d.ts", "node": "./dist/components/Toggle.js", @@ -240,6 +247,9 @@ "TextInput": [ "dist/types/components/TextInput/index.d.ts" ], + "Toast": [ + "dist/types/components/Toast/index.d.ts" + ], "Toggle": [ "dist/types/components/Toggle/index.d.ts" ], diff --git a/src/components/Spinner/styles.ts b/src/components/Spinner/styles.ts index 90b86aa..9a03022 100644 --- a/src/components/Spinner/styles.ts +++ b/src/components/Spinner/styles.ts @@ -18,6 +18,7 @@ export const Wrapper = styled.div` height: ${({ $size }) => typeof $size === 'number' ? `${$size}px` : $size}; display: inline-grid; place-items: center; + overflow: hidden; `; export const StyledSvg = styled.svg` diff --git a/src/components/Toast/README.md b/src/components/Toast/README.md new file mode 100644 index 0000000..4d87cb4 --- /dev/null +++ b/src/components/Toast/README.md @@ -0,0 +1,276 @@ +# BBBToast + +The `BBBToast` component is the presentational card for transient notifications. It is laid out in two columns: the icon badge owns a full-height section on the left, and to its right sit the title (`message`, on a row it shares with the close button) and the `content` area for the description, links or action buttons. + +![Demo](assets/example.png) + +`BBBToast` never hides itself. Both the close button and the `autoClose` timer call `onRequestClose`, and removing the card is up to whoever owns the toast stack. That keeps the card usable inside an external toast engine (such as `react-toastify`), which would otherwise be left rendering an empty slot. + +## Usage Example + +### Basic Toast + +```jsx +import { BBBToast } from 'bbb-ui-components-react'; + + +``` + +### Toast With Content + +Pass `content` for the description below the title. It shares the title's left edge, so the two read as one block. A divider separates them unless `showSeparator` is `false`. + +```jsx +import { BBBToast } from 'bbb-ui-components-react'; + + +``` + +### Dismissing the Toast + +Wire `onRequestClose` to remove the card. It fires both when the close button is clicked and when `autoClose` elapses. + +```jsx +import { useState } from 'react'; +import { BBBToast } from 'bbb-ui-components-react'; + +const [visible, setVisible] = useState(true); + +{visible && ( + setVisible(false)} + /> +)} +``` + +The countdown pauses while the pointer is over the card or focus is inside it, and resumes with the time that was left, so the toast never disappears under someone reading it or reaching for one of its buttons. A bar along the bottom of the card shows the time left and pauses with the countdown; pass `hideProgressBar` to drop it. Set `paused` to hold the countdown from outside as well, for example while the toast engine's stack is hovered. + +Pass `autoClose={false}` for a toast that persists until it is dismissed manually, and `hideCloseButton` to drop the close button as well. + +### Clickable Toast + +`onClick` covers the whole card, and the cursor turns into a pointer to show it. Pass `disablePointer` to keep the default cursor anyway, or `disablePointer={false}` to show the pointer on a card without `onClick` — for example when the toast engine closes it on click. + +```jsx +import { BBBToast } from 'bbb-ui-components-react'; + + openChatPanel()} +/> +``` + +The card stays a live region, so the click is exposed through a real button wrapping `message`: screen readers announce it as a button named after the message, it is reachable with Tab and activates with Enter or Space, and its focus ring outlines the whole card. Pass `clickAriaLabel` when the message alone doesn't describe what the click does: + +```jsx + openChatPanel()} +/> +``` + +Without a `message`, the button is visually hidden and takes its name from `clickAriaLabel`, falling back to the `content` text. + +Clicks on interactive elements passed in `message` or `content` (links, buttons, inputs) stay with those elements and don't fire `onClick`. + +### Toast With an Action + +```jsx +import { BBBToast } from 'bbb-ui-components-react'; + + window.open(helpLink)} +/> +``` + +### Rich Content + +`content` takes an arbitrary node, so links, buttons and inline media all render. Use it when a notification needs more than one action — for example a confirmation toast with both a cancel and a confirm button, which `actionLabel` alone does not cover. + +```jsx +import { BBBToast, BBButton } from 'bbb-ui-components-react'; + + +

Participants will be notified.

+ + + + )} +/> +``` + +### Custom or Hidden Icon + +Pass `icon` to replace the icon matched to `variant`, or `icon={false}` to drop the badge entirely. + +```jsx +import { MdCampaign } from 'react-icons/md'; +import { BBBToast } from 'bbb-ui-components-react'; + +} /> + +``` + +## Props + +| Property | Type | Default | Description | +| ---------------- | ----------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------- | +| `message` | `React.ReactNode` | | The title of the notification, rendered on its own line. Optional — omit it for cards whose `content` carries the whole notification. Usually plain text; takes a node for inline i18n markup (e.g. a ``). | +| `variant` | `'default' \| 'info' \| 'success' \| 'warning' \| 'error'` | `'default'` | Visual variant driving the default icon and the icon badge color. | +| `icon` | `React.ReactNode \| false` | icon matched to `variant` | A custom icon replacing the one matched to `variant`, or `false` to hide the icon badge entirely. | +| `content` | `React.ReactNode` | | The description or secondary content (text, links, action buttons), rendered below the title and sharing its left edge. | +| `showSeparator` | `boolean` | `true` | Shows the divider between the title and `content`; only rendered when both are present. | +| `small` | `boolean` | `false` | Compact variant for space-constrained placements. | +| `disablePointer` | `boolean` | `!onClick` | Keeps the default cursor instead of showing a pointer one, so the toast doesn't read as clickable; defaults to `false` when `onClick` is set and `true` otherwise. | +| `onClick` | `React.MouseEventHandler` | | A click handler for the whole toast (e.g. open a related chat or panel); exposed to assistive technology as a button wrapping `message`. | +| `clickAriaLabel` | `string` | | The accessible name for the `onClick` action, overriding the `message` text; needed when `onClick` is set without a `message`, else the `content` text is used. | +| `actionLabel` | `string` | | The label for an optional action button rendered below the content. | +| `onActionClick` | `React.MouseEventHandler` | | A handler for the action button; only used when `actionLabel` is set. | +| `autoClose` | `number \| false` | `5000` | The auto-dismiss delay in milliseconds, paused while the toast is hovered or focused, or `false` to persist until dismissed manually. | +| `paused` | `boolean` | `false` | Pauses the `autoClose` countdown from outside, on top of the card's own pause on hover and focus (e.g. while a toast engine's stack is hovered). | +| `hideProgressBar` | `boolean` | `false` | Hides the bar at the bottom of the card that counts down `autoClose`; never shown when `autoClose` is `false`. | +| `hideCloseButton` | `boolean` | `false` | Hides the close (X) button, for toasts that shouldn't be manually dismissed. | +| `closeButtonAriaLabel` | `string` | `'Close'` | The accessible name for the close button; pass a translated string in localized apps. | +| `onRequestClose` | `() => void` | | A callback fired when the toast is dismissed, by the close button or by `autoClose`. | +| `maxHeight` | `string` | `'70vh'` | Caps the card's height, scrolling its body past that point. Any CSS length. | +| `dataTest` | `string` | | The value for the `data-test` attribute on the container; also the base for the three derived ids below. | +| `messageDataTest` | `string` | `${dataTest}-message` | A test identifier applied to the message. | +| `closeButtonDataTest` | `string` | `${dataTest}-close-button` | A test identifier applied to the close button. | +| `actionButtonDataTest` | `string` | `${dataTest}-action-button` | A test identifier applied to the action button. | +| `...props` | `HTMLAttributes` | | Any other props will be passed down to the underlying container div. They can override the live-region attributes (`role`, `aria-live`, `aria-atomic`), but not the card's own handlers or `data-test`. | + +## Layout + +The icon badge sits in a section that spans the card's full height, and the title and `content` share the column to its right, aligned to the same left edge. The close button shares a row with the title only, so it stays on the title's line instead of drifting down past the description. + +When `icon` is `false` the icon section is not rendered at all, and the title and content take the whole card width. + +In right-to-left documents the layout mirrors: the icon badge moves to the right and the close button to the left. + +## Height and Scrolling + +`maxHeight` caps the card and its body scrolls past that point, so tall content is never clipped without a scrollbar. This matters because toast engines cap their own containers — `react-toastify`'s is `max-height: 75vh; overflow: hidden` — and would otherwise cut the card off in silence. + +The default of `70vh` sits just under that, so the card scrolls before the engine clips it. Lower it for a card that should stay small: + +```jsx + +``` + +When only one region should scroll and the rest of the card should stay put, cap that region instead with `BBBScrollArea`, and raise `maxHeight` so the card itself is not the limit: + +```jsx +import { BBBToast, BBBScrollArea } from 'bbb-ui-components-react'; + +{fileRows}} +/> +``` + +## Cards Without a Title + +`message` is optional. Omit it when the notification is a single line or a row that reads on its own — a file with its status, say — and `content` carries it: + +```jsx +} /> +``` + +The title row is still rendered for the close button, which keeps its place at the end of the row; drop it too with `hideCloseButton` and the row goes away entirely. + +## Test Identifiers + +Set `dataTest` and the toast derives stable identifiers for its inner parts: + +```jsx + +``` + +```html +
+
Recording started
+ + +
+``` + +Override any of them individually when a selector has to keep an existing name: + +```jsx + +``` + +Setting `dataTest` matters for more than convenience. With no value of its own, the underlying `BBButton` derives one from its label — and since `actionLabel` is usually a translated string, the resulting selector changes with the interface language. The derived ids above are what keep it stable. + +`actionLabel` renders a button the toast owns, so arbitrary props cannot be forwarded to it; the identifiers above are the supported hook. When the action needs more than that — extra ARIA attributes, a different variant, or more than one button — render your own `BBButton` inside `content` instead, which is also how BBB builds its two-button confirmation toasts. + +## Using It With a Toast Engine + +`BBBToast` is the card, not the stack. Pair it with whatever engine positions, stacks and dismisses toasts — in `bigbluebutton-html5` that is `react-toastify`. + +The engine wraps every toast in its own element, and `react-toastify`'s default wrapper styles a card of its own. Left alone, the two cards stack up: two backgrounds, two paddings, two shadows, mismatched corners and two close buttons. Turn the wrapper into a plain box and let `BBBToast` do the styling: + +```css +.Toastify__toast.bbbToast { + background: none; /* the theme class paints #fff behind the card */ + padding: 0; /* wrapper adds 14px on top of the card's own */ + border-radius: 0; /* wrapper rounds to 6px, the card to 8px */ + box-shadow: none; /* wrapper adds its own 0 4px 12px shadow */ + min-height: 0; /* wrapper floors the height at 64px */ + font-family: inherit; /* wrapper forces sans-serif over the app font */ +} +``` + +```jsx +toast( + toast.dismiss(id)} />, + { className: 'bbbToast', closeButton: false, toastId: id }, +); +``` + +`closeButton: false` is what drops the engine's own X, since `BBBToast` already renders one. Keep the engine's `autoClose` and the card's `autoClose` from both running — pass the delay to whichever one owns dismissal and set the other to `false`. The card's own timer already pauses on hover and focus, so letting it own dismissal doesn't depend on the engine's `pauseOnHover`. + +If the engine's wrapper is already a live region — `bigbluebutton-html5` wraps every toast in a `role="alert"` element — pass `role="none"` and `aria-live="off"` to the card so the notification isn't announced twice. + +`react-toastify`'s container defaults to a 320px width, which is the width the Storybook stories render at (`20rem`). + +## Accessibility + +The card renders as a live region so screen readers announce it as it appears: `role="status"` with `aria-live="polite"` for the `default`, `info` and `success` variants, and `role="alert"` with `aria-live="assertive"` for `warning` and `error`. + +These live-region attributes can be overridden through the rest props, for when the surrounding toast engine already provides the live region (see [Using It With a Toast Engine](#using-it-with-a-toast-engine)). + +With `onClick`, the click is exposed as a real button wrapping `message` (or a visually hidden one named by `clickAriaLabel` when there is no message), so it is announced as a button, reachable with Tab and activated with Enter or Space. See [Clickable Toast](#clickable-toast). + +The `autoClose` countdown pauses while the card is hovered or has focus inside it, so it never disappears under someone reading it or reaching for one of its buttons. + +The close button's accessible name defaults to the English `'Close'`; pass `closeButtonAriaLabel` with a translated string in localized apps. + +Every icon glyph is white, and each badge fill keeps at least the 3:1 non-text contrast ratio against it, which is why `success` and `warning` use the darker shades of their colors (`--color-success-dark`, `--color-warning-dark`). diff --git a/src/components/Toast/assets/example.png b/src/components/Toast/assets/example.png new file mode 100644 index 0000000..7f903c3 Binary files /dev/null and b/src/components/Toast/assets/example.png differ diff --git a/src/components/Toast/component.stories.tsx b/src/components/Toast/component.stories.tsx new file mode 100644 index 0000000..43c39df --- /dev/null +++ b/src/components/Toast/component.stories.tsx @@ -0,0 +1,305 @@ +import React, { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { MdCampaign } from 'react-icons/md'; +import BBBToast from './component'; +import { BBButton } from '../Button'; +import { TOAST_VARIANT_VALUES } from './constants'; + +const meta = { + title: 'BBBToast', + component: BBBToast, + tags: ['autodocs'], + argTypes: { + message: { + control: 'text', + description: 'Title of the notification, rendered on its own line; usually plain text, but takes a node for inline i18n markup.', + }, + variant: { + control: 'select', + options: TOAST_VARIANT_VALUES, + description: 'Visual variant driving the default icon and the icon badge color.', + }, + icon: { + control: false, + description: 'Custom icon replacing the one matched to variant, or false to hide the icon badge entirely.', + }, + content: { + control: false, + description: 'Description or secondary content (text, links, action buttons) rendered below the title, sharing its left edge.', + }, + showSeparator: { + control: 'boolean', + description: 'Shows the divider between the title and content; only rendered when both are present.', + }, + small: { + control: 'boolean', + description: 'Compact variant for space-constrained placements.', + }, + disablePointer: { + control: 'boolean', + description: "Keeps the default cursor instead of showing a pointer one, so the toast doesn't read as clickable; defaults to false when onClick is set and true otherwise.", + }, + onClick: { + control: false, + description: 'Click handler for the whole toast (e.g. open a related chat or panel); exposed to assistive technology as a button wrapping message.', + }, + clickAriaLabel: { + control: 'text', + description: 'Accessible name for the onClick action, overriding the message text; needed when onClick is set without a message, else the content text is used.', + }, + actionLabel: { + control: 'text', + description: 'Label for an optional action button rendered below the content.', + }, + onActionClick: { + control: false, + description: 'Handler for the action button; only used when actionLabel is set.', + }, + autoClose: { + control: 'number', + description: 'Auto-dismiss delay in milliseconds, paused while the toast is hovered or focused, or false to persist until dismissed manually.', + }, + paused: { + control: 'boolean', + description: "Pauses the `autoClose` countdown from outside, on top of the card's own pause on hover and focus (e.g. while a toast engine's stack is hovered).", + }, + hideProgressBar: { + control: 'boolean', + description: 'Hides the bar at the bottom of the card that counts down `autoClose`; never shown when `autoClose` is `false`.', + }, + hideCloseButton: { + control: 'boolean', + description: "Hides the close (X) button, for toasts that shouldn't be manually dismissed.", + }, + closeButtonAriaLabel: { + control: 'text', + description: 'Accessible name for the close button; pass a translated string in localized apps.', + }, + onRequestClose: { + control: false, + description: 'Callback fired when the toast is dismissed, by the close button or by autoClose.', + }, + maxHeight: { + control: 'text', + description: "Caps the card's height, scrolling its body past that point; any CSS length.", + }, + dataTest: { + control: 'text', + description: 'Value for the data-test attribute on the container; also the base for the derived test ids below.', + }, + messageDataTest: { + control: 'text', + description: 'Test identifier applied to the message. Defaults to `${dataTest}-message` when dataTest is set.', + }, + closeButtonDataTest: { + control: 'text', + description: 'Test identifier applied to the close button. Defaults to `${dataTest}-close-button` when dataTest is set.', + }, + actionButtonDataTest: { + control: 'text', + description: 'Test identifier applied to the action button. Defaults to `${dataTest}-action-button` when dataTest is set.', + }, + }, + decorators: [ + // The library inherits its font from the consuming app; without this the stories + // fall back to the browser's default serif, which misrepresents how the toast + // reads in BBB. 20rem matches the width BBB gives its toast container. + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** + * Wrapper component so hooks can be used inside Storybook's render function, + * showing that the card leaves removal to its owner: both the close button and the + * `autoClose` timer only call `onRequestClose`. + */ +const DismissibleToastStory: React.FC> = (args) => { + const [visible, setVisible] = useState(true); + + return ( +
+ {!visible && setVisible(true)} />} + {visible && setVisible(false)} />} +
+ ); +}; + +/** Basic toast with only a message, the default variant and its matching icon. */ +export const Default: Story = { + args: { + message: 'You are muted', + }, +}; + +/** Toast with secondary `content` below the message, separated by the default divider. */ +export const WithContent: Story = { + args: { + message: 'Recording started', + content: 'This session is being recorded and will be available after the meeting ends.', + }, +}; + +/** Every `variant`, showing the icon and badge color each one applies. */ +export const Variants: Story = { + args: { + message: 'Notification', + }, + render: (args) => ( +
+ {TOAST_VARIANT_VALUES.map((variant) => ( + + ))} +
+ ), +}; + +/** The compact `small` variant, for space-constrained placements like chat push alerts. */ +export const Small: Story = { + args: { + message: 'New message from Arthur', + content: 'Are we still on for the 3pm call?', + small: true, + }, +}; + +/** Toast with `icon={false}`, dropping the badge for messages that carry their own visual. */ +export const WithoutIcon: Story = { + args: { + message: 'Start recording?', + content: 'Participants will be notified that the session is being recorded.', + icon: false, + }, +}; + +/** Toast with a custom `icon` node replacing the one matched to `variant`. */ +export const CustomIcon: Story = { + args: { + message: 'Presenter changed', + content: 'Arthur is now presenting.', + icon: , + }, +}; + +/** Toast with `showSeparator={false}`, for content that reads as one block with the message. */ +export const WithoutSeparator: Story = { + args: { + message: 'Connection unstable', + content: 'Your audio may be affected.', + variant: 'warning', + showSeparator: false, + }, +}; + +/** Toast with an action button, rendered from `actionLabel` and wired to `onActionClick`. */ +export const WithAction: Story = { + args: { + message: 'Screenshare failed', + content: 'Your browser blocked the screen sharing request.', + variant: 'error', + actionLabel: 'Learn more', + onActionClick: () => window.alert('Action clicked'), + }, +}; + +/** Test identifiers: `dataTest` seeds the container and derives the message, close and action ids. */ +export const WithDataTest: Story = { + args: { + message: 'Reload required', + content: 'Inspect the DOM to see the derived data-test attributes.', + actionLabel: 'Reload', + onActionClick: () => {}, + dataTest: 'notificationBanner', + actionButtonDataTest: 'notificationBannerReloadButton', + }, +}; + +/** Titleless card: with no `message`, the content carries the notification on its own. */ +export const WithoutMessage: Story = { + args: { + content: 'presentation.pdf — upload complete', + }, +}; + +/** Tall content scrolls inside the card instead of being clipped, capped by `maxHeight`. */ +export const ScrollingContent: Story = { + args: { + message: 'Uploading presentations', + maxHeight: '12rem', + autoClose: false, + content: ( + <> + {Array.from({ length: 12 }, (_, i) => ( +

{`slide-deck-${i + 1}.pdf — processing`}

+ ))} + + ), + }, +}; + +/** Clickable toast: `onClick` on the whole card, announced as a button and shown with a pointer cursor. */ +export const Clickable: Story = { + args: { + message: 'New message in Public Chat', + content: 'Click to open the chat panel.', + variant: 'info', + small: true, + onClick: () => window.alert('Toast clicked'), + }, +}; + +/** Toast with `autoClose={false}` and `hideCloseButton`, persisting until its owner removes it. */ +export const Persistent: Story = { + args: { + message: 'You are listening to a breakout room', + variant: 'info', + autoClose: false, + hideCloseButton: true, + }, +}; + +/** Rich `content` (links, buttons, inline media), as in BBB's two-button confirmation toasts. */ +export const RichContent: Story = { + args: { + message: 'Start recording?', + icon: false, + autoClose: false, + showSeparator: false, + content: ( + <> +

+ Participants will be notified. See the{' '} + + recording docs + {' '} + for details. +

+
+ {}} /> + {}} /> +
+ + ), + }, +}; + +/** Dismissal in practice: the close button and the `autoClose` timer (paused on hover) both ask the owner to remove the card. */ +export const Dismissible: Story = { + args: { + message: 'This toast asks to be closed after 4 seconds', + content: 'Its owner is what actually removes it.', + autoClose: 4000, + }, + render: (args) => , +}; diff --git a/src/components/Toast/component.tsx b/src/components/Toast/component.tsx new file mode 100644 index 0000000..09291a1 --- /dev/null +++ b/src/components/Toast/component.tsx @@ -0,0 +1,266 @@ +import React, { + JSX, useEffect, useId, useRef, useState, +} from 'react'; +import { MdClose } from 'react-icons/md'; +import * as Styled from './styles'; +import { BBBDivider } from '../Divider'; +import { BBButton } from '../Button'; +import { ToastProps } from './types'; +import { + DEFAULT_AUTO_CLOSE, DEFAULT_CLOSE_BUTTON_ARIA_LABEL, DEFAULT_ICONS, + DEFAULT_MAX_HEIGHT, DEFAULT_TOAST_VARIANT, INTERACTIVE_SELECTOR, TOAST_VARIANTS, +} from './constants'; + +/** + * A notification card for transient messages. + * + * This component renders the presentational part of a toast notification: an icon badge + * matched to `variant`, the message, optional secondary content and an optional action button. + * + * It never hides itself. Both the close button and the `autoClose` timer call `onRequestClose`, + * leaving removal to whoever owns the toast stack, so the card can live inside an external + * toast engine without leaving an empty slot behind. + */ +function Toast({ + message, + variant = DEFAULT_TOAST_VARIANT, + icon, + content, + showSeparator = true, + small = false, + disablePointer, + onClick, + clickAriaLabel, + actionLabel, + onActionClick, + autoClose = DEFAULT_AUTO_CLOSE, + paused = false, + hideProgressBar = false, + hideCloseButton = false, + closeButtonAriaLabel = DEFAULT_CLOSE_BUTTON_ARIA_LABEL, + onRequestClose, + maxHeight = DEFAULT_MAX_HEIGHT, + dataTest, + messageDataTest, + closeButtonDataTest, + actionButtonDataTest, + onMouseEnter, + onMouseLeave, + onFocus, + onBlur, + ...rest +}: ToastProps): JSX.Element { + // Without an explicit value BBButton derives its own from the label, which is + // translated at every call site — so the derived ids below are what keep test + // selectors stable across locales. + const derive = (explicit: string | undefined, suffix: string): string | undefined => + explicit ?? (dataTest ? `${dataTest}-${suffix}` : undefined); + + const testIds = { + message: derive(messageDataTest, 'message'), + closeButton: derive(closeButtonDataTest, 'close-button'), + actionButton: derive(actionButtonDataTest, 'action-button'), + }; + + // Held in a ref so an inline `onRequestClose` doesn't restart the timer on every + // render, which would keep the toast alive forever. + const onRequestCloseRef = useRef(onRequestClose); + + useEffect(() => { + onRequestCloseRef.current = onRequestClose; + }, [onRequestClose]); + + // Paused while hovered or focused so the toast doesn't vanish under a user who is + // reading it or about to press one of its buttons (WCAG 2.2.1, Timing Adjustable). + const [isHovered, setIsHovered] = useState(false); + const [isFocused, setIsFocused] = useState(false); + const isPaused = paused || isHovered || isFocused; + const remainingRef = useRef(autoClose === false ? 0 : autoClose); + + useEffect(() => { + if (autoClose !== false) remainingRef.current = autoClose; + }, [autoClose]); + + useEffect(() => { + if (autoClose === false || isPaused) return undefined; + + const startedAt = Date.now(); + const delay = remainingRef.current; + const timeout = setTimeout(() => onRequestCloseRef.current?.(), delay); + return () => { + clearTimeout(timeout); + remainingRef.current = Math.max(0, delay - (Date.now() - startedAt)); + }; + }, [autoClose, isPaused]); + + const handleMouseEnter: React.MouseEventHandler = (event) => { + setIsHovered(true); + onMouseEnter?.(event); + }; + + const handleMouseLeave: React.MouseEventHandler = (event) => { + setIsHovered(false); + onMouseLeave?.(event); + }; + + const handleFocus: React.FocusEventHandler = (event) => { + setIsFocused(true); + onFocus?.(event); + }; + + const handleBlur: React.FocusEventHandler = (event) => { + if (!event.currentTarget.contains(event.relatedTarget)) setIsFocused(false); + onBlur?.(event); + }; + + const isAssertive = variant === TOAST_VARIANTS.WARNING || variant === TOAST_VARIANTS.ERROR; + + const contentId = useId(); + const cardActionRef = useRef(null); + + // Every click on the card lands here, including the card action's own (keyboard + // activation of a button dispatches a click too). Clicks on other interactive + // elements passed through `message` or `content` belong to those elements. + const handleCardClick: React.MouseEventHandler = (event) => { + if (!onClick) return; + + const interactive = (event.target as Element).closest(INTERACTIVE_SELECTOR); + const isOwnedElsewhere = interactive + && interactive !== cardActionRef.current + && event.currentTarget.contains(interactive); + if (isOwnedElsewhere) return; + + onClick(event); + }; + + const handleCloseClick: React.MouseEventHandler = (event) => { + event.stopPropagation(); + onRequestClose?.(); + }; + + const handleActionClick: React.MouseEventHandler = (event) => { + event.stopPropagation(); + onActionClick?.(event); + }; + + const renderIconSection = (): JSX.Element | null => { + if (icon === false) return null; + + const DefaultIcon = DEFAULT_ICONS[variant]; + return ( + + + + + + ); + }; + + return ( + + {renderIconSection()} + + {onClick && !message && ( + + )} + + + {(message || !hideCloseButton) && ( + + {message && ( + + {onClick ? ( + + {message} + + ) : message} + + )} + + {!hideCloseButton && ( + + } + ariaLabel={closeButtonAriaLabel} + onClick={handleCloseClick} + {...(testIds.closeButton ? { dataTest: testIds.closeButton } : {})} + /> + + )} + + )} + + {content && ( + <> + {showSeparator && message && } + {content} + + )} + + {actionLabel && ( + + + + )} + + + {autoClose !== false && !hideProgressBar && ( + + )} + + ); +} + +export default Toast; diff --git a/src/components/Toast/constants.ts b/src/components/Toast/constants.ts new file mode 100644 index 0000000..c2a401c --- /dev/null +++ b/src/components/Toast/constants.ts @@ -0,0 +1,45 @@ +import { MdCheck, MdError, MdHelp, MdInfo, MdWarning } from 'react-icons/md'; +import { + colorBrand1, colorSuccessDark, colorWarningDark, colorError, colorWhite, +} from '../../stylesheets/palette'; + +export const TOAST_VARIANTS = { + DEFAULT: 'default', + INFO: 'info', + SUCCESS: 'success', + WARNING: 'warning', + ERROR: 'error', +} as const; +export const TOAST_VARIANT_VALUES = Object.values(TOAST_VARIANTS); +export const DEFAULT_TOAST_VARIANT = TOAST_VARIANTS.DEFAULT; + +export const DEFAULT_AUTO_CLOSE = 5000; + +export const DEFAULT_CLOSE_BUTTON_ARIA_LABEL = 'Close'; + +export const INTERACTIVE_SELECTOR = [ + 'a[href]', 'button', 'input', 'select', 'textarea', 'summary', 'label', + '[role="button"]', '[role="link"]', '[tabindex]:not([tabindex="-1"])', +].join(', '); + +// Caps the card so tall content scrolls inside it instead of being clipped in silence by +// the toast engine's own container (react-toastify's is `max-height: 75vh; overflow: hidden`). +export const DEFAULT_MAX_HEIGHT = '70vh'; + +export const DEFAULT_ICONS = { + default: MdInfo, + info: MdHelp, + success: MdCheck, + warning: MdWarning, + error: MdError, +} as const; + +// The success and warning badges take the dark shades of their colors so the white glyph +// keeps at least the 3:1 non-text contrast WCAG asks for; it fails on the lighter fills. +export const VARIANT_COLORS = { + default: { background: colorBrand1, glyph: colorWhite }, + info: { background: colorBrand1, glyph: colorWhite }, + success: { background: colorSuccessDark, glyph: colorWhite }, + warning: { background: colorWarningDark, glyph: colorWhite }, + error: { background: colorError, glyph: colorWhite }, +} as const; diff --git a/src/components/Toast/index.ts b/src/components/Toast/index.ts new file mode 100644 index 0000000..0f44552 --- /dev/null +++ b/src/components/Toast/index.ts @@ -0,0 +1 @@ +export { default as BBBToast } from './component'; diff --git a/src/components/Toast/styles.ts b/src/components/Toast/styles.ts new file mode 100644 index 0000000..990f746 --- /dev/null +++ b/src/components/Toast/styles.ts @@ -0,0 +1,211 @@ +import styled, { css, keyframes } from 'styled-components'; +import { + colorBackgroundWhite, colorBrand1, colorShadowDefault, + colorTextDefault, colorTextLight, +} from '../../stylesheets/palette'; +import { + borderRadiusSmall, spacingMediumLarge, + spacingSmall, spacingSmallMedium, +} from '../../stylesheets/sizing'; +import { + fontSizeBig, fontSizeDefault, fontSizeSmall, fontSizeXSmall, + fontWeightLight, +} from '../../stylesheets/typography'; +import { + StyledCardActionProps, StyledIconBadgeProps, StyledProgressBarProps, StyledTextProps, + StyledToastProps, +} from './types'; +import { VARIANT_COLORS } from './constants'; + +// `message` and `content` take arbitrary nodes, so any media a consumer passes is +// capped to the card instead of forcing it wider than its container. +const richContent = css` + img, + svg, + video { + max-width: 100%; + } +`; + +const visuallyHidden = css` + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +`; + +// Unstyled so the message reads as plain text; its focus ring is drawn on the whole card. +export const CardAction = styled.button` + margin: 0; + padding: 0; + border: 0; + background: none; + color: inherit; + font: inherit; + line-height: inherit; + text-align: start; + cursor: inherit; + + &:focus { + outline: none; + } + + ${({ $visuallyHidden }) => $visuallyHidden && visuallyHidden} +`; + +export const Container = styled.div` + position: relative; + display: flex; + /* Fills the slot it is given even when that slot is a flex container, as toast engines' + wrappers usually are, so every toast in a stack has the same width. */ + width: 100%; + flex-direction: row; + align-items: stretch; + gap: ${spacingSmallMedium}; + padding: ${spacingMediumLarge}; + background-color: ${colorBackgroundWhite}; + border-radius: ${borderRadiusSmall}; + box-shadow: 0 2px 8px ${colorShadowDefault}; + overflow-wrap: break-word; + /* So maxHeight caps the whole card, padding included, rather than just its content box. */ + box-sizing: border-box; + max-height: ${({ $maxHeight }) => $maxHeight}; + cursor: ${({ $disablePointer }) => ($disablePointer ? 'auto' : 'pointer')}; + + &:has(${CardAction}:focus-visible) { + outline: 2px solid ${colorBrand1}; + } +`; + +// Mirrors the close button's box. The title row is sized by that button rather than by +// the text, so pinning the badge to the top left it riding high by half the difference — +// worse the smaller the badge, which is why it showed up first in the `small` variant. +// Giving the badge and the title row the same band centers them on one line at any size. +const titleRowHeight = '2.375rem'; + +// The width of BBButton's focus outline, which is drawn outside its box. +const focusRingWidth = '2px'; + +// The icon owns a full-height section of its own on the left; inside it, the badge is +// centered on the title row so it stays on the title's line as content grows. +export const IconSection = styled.div` + display: flex; + align-items: flex-start; + flex-shrink: 0; +`; + +export const IconBand = styled.div` + display: flex; + align-items: center; + min-height: ${titleRowHeight}; +`; + +export const Body = styled.div` + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + gap: ${spacingSmall}; + + /* min-height lets this flex child shrink below its content so the overflow can scroll. */ + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + + /* As a scroll container this clips whatever overflows it, including the focus ring of + the close button, which spans the full height of the title row. The padding leaves + room for the ring and the negative margin cancels it out of the layout. */ + padding: ${focusRingWidth}; + margin: -${focusRingWidth}; +`; + +// The close button shares this row with the title only, so it stays on the title's line +// instead of centering against the content below it. +export const Header = styled.div` + display: flex; + flex-direction: row; + align-items: center; + min-height: ${titleRowHeight}; + gap: ${spacingSmall}; +`; + +export const IconBadge = styled.div` + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + border-radius: 50%; + aspect-ratio: 1; + width: ${({ $small }) => ($small ? '1.5rem' : '2rem')}; + font-size: ${({ $small }) => ($small ? fontSizeXSmall : fontSizeBig)}; + background-color: ${({ $variant }) => VARIANT_COLORS[$variant].background}; + color: ${({ $variant }) => VARIANT_COLORS[$variant].glyph}; +`; + +export const Message = styled.div` + flex: 1; + min-width: 0; + color: ${colorTextDefault}; + font-size: ${({ $small }) => ($small ? fontSizeSmall : fontSizeDefault)}; + font-weight: ${fontWeightLight}; + line-height: normal; + + ${richContent} +`; + +export const Content = styled.div` + color: ${colorTextLight}; + font-size: ${({ $small }) => ($small ? fontSizeXSmall : fontSizeSmall)}; + font-weight: ${fontWeightLight}; + line-height: normal; + + ${richContent} +`; + +export const ActionWrapper = styled.div` + display: flex; + justify-content: flex-end; +`; + +export const CloseButtonWrapper = styled.div` + display: flex; + flex-shrink: 0; + margin-inline-start: auto; +`; + +const countdown = keyframes` + from { + transform: scaleX(1); + } + + to { + transform: scaleX(0); + } +`; + +// Clips the bar to the card's rounded bottom corners without clipping the card's content. +export const ProgressTrack = styled.div` + position: absolute; + inset-inline: 0; + bottom: 0; + height: 0.25rem; + overflow: hidden; + border-end-start-radius: ${borderRadiusSmall}; + border-end-end-radius: ${borderRadiusSmall}; +`; + +// Pausing the animation together with the timer keeps the two in step: both resume from +// where they stopped instead of restarting. +export const ProgressBar = styled.div` + height: 100%; + background-color: ${({ $variant }) => VARIANT_COLORS[$variant].background}; + transform-origin: left; + animation: ${countdown} ${({ $duration }) => $duration}ms linear forwards; + animation-play-state: ${({ $paused }) => ($paused ? 'paused' : 'running')}; + + [dir='rtl'] & { + transform-origin: right; + } +`; diff --git a/src/components/Toast/types.ts b/src/components/Toast/types.ts new file mode 100644 index 0000000..e9c1d1f --- /dev/null +++ b/src/components/Toast/types.ts @@ -0,0 +1,96 @@ +import React from 'react'; +import { TOAST_VARIANT_VALUES } from './constants'; + +export type ToastVariant = (typeof TOAST_VARIANT_VALUES)[number]; + +export interface StyledToastProps { + $disablePointer: boolean; + $maxHeight: string; +} + +export interface StyledProgressBarProps { + $variant: ToastVariant; + $duration: number; + $paused: boolean; +} + +export interface StyledIconBadgeProps { + $variant: ToastVariant; + $small: boolean; +} + +export interface StyledCardActionProps { + $visuallyHidden: boolean; +} + +export interface StyledTextProps { + $small: boolean; +} + +export interface ToastProps extends Omit, 'content'> { + /** Title of the notification, rendered on its own line; usually plain text, but takes a node for inline i18n markup. */ + message?: React.ReactNode; + + /** Visual variant driving the default icon and the icon badge color. @default 'default' */ + variant?: ToastVariant; + + /** Custom icon replacing the one matched to `variant`, or `false` to hide the icon badge entirely. */ + icon?: React.ReactNode | false; + + /** Description or secondary content (text, links, action buttons) rendered below the title, sharing its left edge. */ + content?: React.ReactNode; + + /** Shows the divider between the title and `content`; only rendered when both are present. @default true */ + showSeparator?: boolean; + + /** Compact variant for space-constrained placements. @default false */ + small?: boolean; + + /** Keeps the default cursor instead of showing a pointer one, so the toast doesn't read as clickable; defaults to `false` when `onClick` is set and `true` otherwise. */ + disablePointer?: boolean; + + /** Click handler for the whole toast (e.g. open a related chat or panel); exposed to assistive technology as a button wrapping `message`. */ + onClick?: React.MouseEventHandler; + + /** Accessible name for the `onClick` action, overriding the `message` text; needed when `onClick` is set without a `message`, else the `content` text is used. */ + clickAriaLabel?: string; + + /** Label for an optional action button rendered below the content. */ + actionLabel?: string; + + /** Handler for the action button; only used when `actionLabel` is set. */ + onActionClick?: React.MouseEventHandler; + + /** Auto-dismiss delay in milliseconds, paused while the toast is hovered or focused, or `false` to persist until dismissed manually. @default 5000 */ + autoClose?: number | false; + + /** Pauses the `autoClose` countdown from outside, on top of the card's own pause on hover and focus (e.g. while a toast engine's stack is hovered). @default false */ + paused?: boolean; + + /** Hides the bar at the bottom of the card that counts down `autoClose`; never shown when `autoClose` is `false`. @default false */ + hideProgressBar?: boolean; + + /** Hides the close (X) button, for toasts that shouldn't be manually dismissed. @default false */ + hideCloseButton?: boolean; + + /** Accessible name for the close button; pass a translated string in localized apps. @default 'Close' */ + closeButtonAriaLabel?: string; + + /** Callback fired when the toast is dismissed, by the close button or by `autoClose`. */ + onRequestClose?: () => void; + + /** Caps the card's height, scrolling its body past that point; any CSS length. @default '70vh' */ + maxHeight?: string; + + /** Value for the `data-test` attribute on the container; also the base for the derived test ids below. */ + dataTest?: string; + + /** Test identifier applied to the message. Defaults to `${dataTest}-message` when `dataTest` is set. */ + messageDataTest?: string; + + /** Test identifier applied to the close button. Defaults to `${dataTest}-close-button` when `dataTest` is set. */ + closeButtonDataTest?: string; + + /** Test identifier applied to the action button. Defaults to `${dataTest}-action-button` when `dataTest` is set. */ + actionButtonDataTest?: string; +} diff --git a/src/components/Toggle/README.md b/src/components/Toggle/README.md index 9cffe0a..3d59e07 100644 --- a/src/components/Toggle/README.md +++ b/src/components/Toggle/README.md @@ -27,6 +27,13 @@ import { BBBToggle } from 'bbb-ui-components-react'; ``` +### Full-width toggle with the label at the start and the switch at the end +```jsx +import { BBBToggle } from 'bbb-ui-components-react'; + + +``` + ### Toggle with label and helper text on the top ```jsx import { BBBToggle } from 'bbb-ui-components-react'; @@ -41,6 +48,7 @@ import { BBBToggle } from 'bbb-ui-components-react'; | `label` | `string` | | The main label text displayed next to the toggle. | | `helperText` | `string` | | The helper text displayed below the label. | | `textPosition` | `keyof typeof TEXT_POSITIONS` | `'right'` | The position of the text labels relative to the toggle switch. | +| `fullWidth` | `boolean` | `false` | If `true`, the toggle stretches to its container, putting the text and the switch at opposite ends. Only used with `textPosition` `'left'` (text at the start) or `'right'` (text at the end). | | `disabled` | `boolean` | `false` | If `true`, the toggle will be disabled and unresponsive. | | `onChange` | `(event: React.ChangeEvent, checked: boolean) => void` | | Callback function that is fired when the toggle state changes. | | `ariaLabel` | `string` | | The accessible name for the toggle. | diff --git a/src/components/Toggle/component.stories.tsx b/src/components/Toggle/component.stories.tsx index aecdce5..2f32c4e 100644 --- a/src/components/Toggle/component.stories.tsx +++ b/src/components/Toggle/component.stories.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import BBBToggle from './component'; import { TEXT_POSITIONS_VALUES, DEFAULT_TEXT_POSITION } from './constants'; @@ -13,6 +14,11 @@ const meta = { description: 'Position of label/helper relative to the toggle.', table: { defaultValue: { summary: `${DEFAULT_TEXT_POSITION}` } }, }, + fullWidth: { + control: 'boolean', + description: `Stretches the row to its container, putting the text and the switch at opposite ends. Only used with textPosition 'left' or 'right'.`, + table: { defaultValue: { summary: 'false' } }, + }, label: { control: 'text', description: 'Main label text displayed next to the toggle.', @@ -43,3 +49,22 @@ type Story = StoryObj; export const Default: Story = { args: { label: 'Enable feature' } }; /** Adds helper text below the label, with the label/helper positioned to the right of the toggle. */ export const WithHelper: Story = { args: { label: 'Enable', helperText: 'Recommended', textPosition: 'right' } }; +/** Shows a `fullWidth` toggle in a settings row: label and helper text at the start, switch at the end. */ +export const FullWidth: Story = { + args: { + label: 'Join muted', + helperText: 'Joining users are muted by default', + textPosition: 'left', + fullWidth: true, + }, + render: (args) => ( +
+ +
+ ), +}; +/** Shows a `fullWidth` toggle with the text on the right: switch at the start, label and helper text at the end. */ +export const FullWidthTextRight: Story = { + ...FullWidth, + args: { ...FullWidth.args, textPosition: 'right' }, +}; diff --git a/src/components/Toggle/component.tsx b/src/components/Toggle/component.tsx index ae7afb7..21f7899 100644 --- a/src/components/Toggle/component.tsx +++ b/src/components/Toggle/component.tsx @@ -2,7 +2,7 @@ import React, { JSX, useId } from 'react'; import { SwitchOwnerState } from '@mui/material'; import { ToggleProps } from './types'; import * as Styled from './styles'; -import { DEFAULT_TEXT_POSITION } from './constants'; +import { DEFAULT_TEXT_POSITION, TEXT_POSITIONS } from './constants'; /** * A customizable Toggle switch component. @@ -15,6 +15,7 @@ function Toggle ({ label, helperText, textPosition = DEFAULT_TEXT_POSITION, + fullWidth = false, onChange, ariaLabel, ariaLabelledBy, @@ -27,6 +28,7 @@ function Toggle ({ const id = providedId ?? generatedUniqueId; const labelId = label ? `${id}-label` : undefined; const helperTextId = helperText ? `${id}-helper` : undefined; + const isFullWidth = fullWidth && (textPosition === TEXT_POSITIONS.LEFT || textPosition === TEXT_POSITIONS.RIGHT); const ariaLabelledByValue = ariaLabel ? undefined : ariaLabelledBy || labelId; // Without a name source, the