diff --git a/Makefile b/Makefile index 741bc7bfbb8..765305b6111 100644 --- a/Makefile +++ b/Makefile @@ -33,9 +33,15 @@ requirements: ## install ci requirements # Instead of having this directly in the build-docs script # in the top-level package.json, put it here so we can have # a single source of truth and get proper error codes in CI +# +# ✨ For our prototype, we're overriding the docs build so we can see the new storybook on Netlify .PHONY: build-docs build-docs: - npm run build --workspace=www + cd prototype && npm install + cd prototype && npm run build + cd prototype && npm run build-storybook + rm -rf www/public + cp -r prototype/storybook-static www/public # npm swallows errors # see https://github.com/openedx/paragon/issues/3329 diff --git a/docs/decisions/0022-modernization-remove-bootstrap.rst b/docs/decisions/0022-modernization-remove-bootstrap.rst new file mode 100644 index 00000000000..a7e3b63bde5 --- /dev/null +++ b/docs/decisions/0022-modernization-remove-bootstrap.rst @@ -0,0 +1,201 @@ +22. Modernization: Removing Bootstrap and Modernizing the Toolchain +------------------------------------------------------------------- + +Status +------ + +Proposed + +Context +------- + +Paragon is a mature, accessibility-focused React component library. Its public +surface has two stable, widely-depended-upon contracts: + +1. The **React component API** — ~99 exports whose props are consumed by dozens + of Open edX micro-frontends (MFEs). +2. The **design-tokens theming API** — a ``style-dictionary`` pipeline that + compiles DTCG-style token JSON into CSS custom properties, shipped as + ``core.css`` / ``light.css`` and overridden by ``@edx/brand`` packages. + +Underneath those contracts, however, Paragon rests on aging foundations: + +- **Bootstrap 4** provides both a *behavior* layer (34 components wrap + ``react-bootstrap`` sub-components) and a *style* layer (``core.scss`` imports + ``~bootstrap/scss/{reboot,root,mixins,transitions,grid,...}``). Bootstrap 4 is + no longer actively developed, is built on legacy SCSS, and blocks adoption of + modern CSS and tooling. ``react-bootstrap`` v1 targets Bootstrap 4 and is + effectively frozen for our purposes. +- The **build** relies on Babel + ``tsc`` + a hand-written ``Makefile``. +- **Tests** run on Jest with ``ts-jest`` / ``babel-jest``. +- The **documentation site** runs on Gatsby 5 with + ``gatsby-transformer-react-docgen`` and ``react-docgen`` v5, which parses + JavaScript rather than our TypeScript types. This makes props tables + unreliable and the doc pipeline difficult to maintain. + +Two properties of the current codebase make modernization tractable rather than +requiring a hard fork: + +- Paragon components are already **thin adapters**: Paragon owns the public prop + API and delegates internals to ``react-bootstrap``. Swapping the internal + engine does not, by itself, change the public API. +- The **theming/token pipeline is already Bootstrap-independent**. Components + reference token-derived CSS custom properties; the ``source: $scss-var`` field + on each token is only a back-compat bridge to Bootstrap SCSS variables. The + emitted ``core.css`` / ``light.css`` contract does not depend on Bootstrap. + +A TypeScript migration is also already ~half complete (roughly 31 ``index.tsx`` +vs. 27 ``index.jsx`` at time of writing). + +This ADR supersedes the direction of: + +- **ADR 0004** (Usage of Bootstrap) and **ADR 0009** (Usage of React-Bootstrap), + which established Bootstrap and ``react-bootstrap`` as foundations. + +It also revisits **ADR 0006** (Removal of CSS Module Support). ADR 0006 removed +CSS Modules for one specific reason: component SCSS files imported Bootstrap +*partials* (mixins/variables) that drifted between Bootstrap minor versions and +caused compile errors in consuming apps. Once Bootstrap SCSS is removed +entirely, that objection no longer applies — the CSS Modules reintroduced here +consume only Paragon's own token CSS custom properties, never Bootstrap +partials. + +Decision +-------- + +We will re-implement Paragon on modern, fully-typed foundations while preserving +both public contracts (the React component API and the design-tokens theming +API). The change is delivered **incrementally** — swapping engines behind stable +APIs — not as a big-bang rewrite. + +Concretely: + +1. **Behavior layer → React Aria Components.** Replace ``react-bootstrap`` with + `React Aria Components `_. It is + headless (leaving Paragon in full control of DOM and CSS), fully typed, and + provides best-in-class WAI-ARIA patterns plus built-in internationalization + and RTL support — a direct fit for Paragon's accessibility and ``react-intl`` + commitments. Each component's public props are preserved; only the internal + engine changes. This also retires ``uncontrollable``, ``tabbable``, and + ``react-focus-on``. + +2. **Style layer → a two-layer model over token CSS variables.** Remove Bootstrap + SCSS, replacing it with: + + a. A **global, public class layer** — the Bootstrap-compatible class names + Paragon has always shipped and that consumers apply directly to their own + markup: the component classes (``btn``, ``btn-``, ``btn-lg``, + ``btn-group``, ``collapsible-card``, …), the grid (``col-*``), and the + layout/spacing utilities (``d-flex``, ``flex-grow-1``, …). **These class + names are part of Paragon's public API and are preserved**, so existing + consumer markup such as ```` keeps rendering + correctly. They are re-authored from Bootstrap SCSS into plain native CSS + over the same ``--pgn-*`` tokens (``:where()`` for zero-specificity + utilities, logical properties for RTL, container queries), and generated + from the token set rather than hand-maintained. Consumer usage + (``dependent-usage.json``) guides which of Bootstrap's utilities to keep + versus drop via major-version releases. + + b. **CSS Modules for component-internal implementation details** that are *not* + part of the public class contract (e.g. a disclosure's animation wrapper or + a focus-ring hook), so those stay locally scoped and cannot leak. + + The public class layer is the single source of truth for appearance: React + components **apply the same public classes** a consumer would write by hand and + add only behavior (React Aria) on top, so a component and its raw-HTML + equivalent render identically. This supersedes an earlier prototype iteration + that styled ``Button`` with hashed CSS-Module classes plus a ``data-variant`` + attribute; that approach broke the public class contract (a raw + ```` received no styles) and is not carried forward. + +3. **Design-tokens theming API → preserved unchanged.** The ``style-dictionary`` + pipeline and the emitted ``core.css`` / ``light.css`` CSS-variable contract + stay byte-compatible so ``@edx/brand`` themes and consuming MFEs are + unaffected. Additionally, a new build target emits a typed ``tokens.ts`` so + tokens are first-class in TypeScript. The ``source: $scss-var`` back-compat + field is removed only after Bootstrap SCSS is gone. + +4. **Supporting dependencies:** + + - ``react-table`` v7 → **TanStack Table v8** (same author, fully typed). + - ``@popperjs`` / ``react-popper`` → **Floating UI** or React Aria overlays. + - ``axios`` → native ``fetch``. + - ``classnames`` → ``clsx`` (or retain). + - Retain: ``style-dictionary``, ``chroma-js``, ``react-intl``, + ``react-colorful``, ``react-dropzone``, ``react-imask``, + ``react-loading-skeleton``. + +5. **Build → Vite (library mode) + ``vite-plugin-dts``** (or ``tsup``), + replacing Babel + ``tsc`` + ``Makefile``. Emits ESM + ``.d.ts`` and preserves + ``sideEffects``/tree-shaking. + +6. **Tests → Vitest**, keeping ``@testing-library/react`` and the existing + accessibility assertions. + +7. **Documentation → Storybook 10 (Vite builder)**, retiring Gatsby, + ``gatsby-transformer-react-docgen``, ``react-docgen`` v5, ``react-live``, and + Playroom. Props tables are generated from TypeScript types + (``react-docgen-typescript``); existing per-component ``README.md`` / ``.mdx`` + map onto Storybook Docs pages; interactive controls replace Playroom; the a11y + addon and interaction/visual testing reinforce the accessibility mission. + +8. **Complete the TypeScript migration** so the library is 100% typed. + +Migration sequence +~~~~~~~~~~~~~~~~~~~ + +The work ships continuously behind stable public APIs: + +1. Switch build to Vite, tests to Vitest, finish remaining ``.jsx → .tsx``, and + emit a typed ``tokens.ts``. No consumer-visible change. +2. Replace Bootstrap SCSS imports with a token-built reset/grid/utility layer. + Snapshot the full set of emitted CSS custom properties to prove the theming + contract held. +3. Swap the behavior engine component-by-component onto React Aria (and DataTable + onto TanStack Table), leaf components first, each shipping independently. +4. Stand up Storybook 10 alongside Gatsby, port component pages, then retire + Gatsby. +5. Remove the ``source:`` SCSS back-compat from tokens and drop the legacy + dependencies (``bootstrap``, ``react-bootstrap``, ``axios``, + ``uncontrollable``, ``tabbable``, ``react-focus-on``, ``sass``). + +Consequences +------------ + +- **Public contracts are preserved.** Because components are adapters and the + token pipeline is Bootstrap-independent, the React component API and the CDN + ``core.css`` / ``light.css`` theming contract remain stable. ``@edx/brand`` + themes and consuming MFEs upgrade without breaking, likely across one or two + major versions rather than a fork. +- **Accessibility improves**, since React Aria's ARIA/keyboard/focus/i18n + behavior is more rigorous and better maintained than our Bootstrap 4 baseline. +- **The styling model splits into a public class layer and private CSS Modules.** + The Bootstrap-compatible class names remain global and public (see Decision 2a); + component-*internal* styles move to scoped CSS Modules over token variables. + This reintroduces CSS Modules (cf. ADR 0006) but without the Bootstrap-partial + version-drift problem that motivated their removal. +- **Consumers relying on raw Bootstrap 4 class names** (e.g. ``btn``, + ``btn-outline-primary``, ``col-*``, utility classes) rather than Paragon + components or tokens keep working: the class names they depend on are preserved + as the public class layer, re-authored in plain CSS over tokens. Any Bootstrap + classes no consumer uses (per ``dependent-usage.json``) are pruned via + major-version releases rather than silently. +- **Guardrails required before removal steps:** snapshot the emitted CSS + custom-property set (theming regression net), keep per-component + ``@testing-library`` + a11y assertions as the public-API contract test, and use + ``dependent-usage.json`` to confirm which utility classes and props consumers + actually use before pruning. +- **Toolchain is unified** on Vite/Vitest/Storybook, reducing bespoke build code + (Makefile, Babel config, Gatsby plugins) and making the docs site maintainable. + +References +---------- + +* React Aria Components: https://react-spectrum.adobe.com/react-aria/ +* TanStack Table: https://tanstack.com/table/latest +* Floating UI: https://floating-ui.com/ +* Vite library mode: https://vitejs.dev/guide/build.html#library-mode +* Storybook: https://storybook.js.org/ +* ADR 0004 (Usage of Bootstrap), ADR 0006 (Removal of CSS Module Support), + ADR 0009 (Usage of React-Bootstrap), ADR 0018 (Design Tokens / + ``style-dictionary``), ADR 0019 (Scaling Styles with Design Tokens) diff --git a/prototype/.gitignore b/prototype/.gitignore new file mode 100644 index 00000000000..c6be3abe7c2 --- /dev/null +++ b/prototype/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +storybook-static/ +*.tsbuildinfo diff --git a/prototype/.storybook/main.ts b/prototype/.storybook/main.ts new file mode 100644 index 00000000000..9d0c8dd22b5 --- /dev/null +++ b/prototype/.storybook/main.ts @@ -0,0 +1,28 @@ +import { fileURLToPath } from "node:url"; +import { dirname } from "node:path"; +import type { StorybookConfig } from '@storybook/react-vite'; + +const config: StorybookConfig = { + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(ts|tsx)'], + addons: [getAbsolutePath("@storybook/addon-a11y"), getAbsolutePath("@storybook/addon-docs")], + framework: { + name: getAbsolutePath("@storybook/react-vite"), + options: {}, + }, + // Props tables are generated from the TypeScript types below, replacing the + // legacy Gatsby + react-docgen v5 pipeline that parsed JavaScript. + typescript: { + reactDocgen: 'react-docgen-typescript', + reactDocgenTypescriptOptions: { + shouldExtractLiteralValuesFromEnum: true, + shouldRemoveUndefinedFromOptional: true, + propFilter: (prop) => (prop.parent ? !/node_modules/.test(prop.parent.fileName) : true), + }, + }, +}; + +export default config; + +function getAbsolutePath(value: string): any { + return dirname(fileURLToPath(import.meta.resolve(`${value}/package.json`))); +} diff --git a/prototype/.storybook/preview.tsx b/prototype/.storybook/preview.tsx new file mode 100644 index 00000000000..5e51372472c --- /dev/null +++ b/prototype/.storybook/preview.tsx @@ -0,0 +1,26 @@ +import type { Preview } from '@storybook/react-vite'; + +// Load the design-token CSS custom properties globally, exactly as a consuming +// app would. This is the *same* compiled token output the real library ships — +// proof that the CSS Modules approach preserves the theming contract. +import '../src/tokens.css'; +// The reboot-replacement base layer, so story/docs frames inherit Paragon's +// root typography just like a consuming app would. +import '../src/base.css'; +// The global, public class layer (btn / utilities / component classes) so docs +// live examples can use `btn btn-outline-primary`, `d-flex`, `collapsible-card` +// etc. exactly as a consuming app (and the source READMEs) do. +import '../src/styles/index.css'; + +const preview: Preview = { + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + }, +}; + +export default preview; diff --git a/prototype/README.md b/prototype/README.md new file mode 100644 index 00000000000..4bea3353ebd --- /dev/null +++ b/prototype/README.md @@ -0,0 +1,97 @@ +# Paragon modernization prototype — Button module + +This is the **Step 1 reference implementation** for +[ADR 0022](../docs/decisions/0022-modernization-remove-bootstrap.rst). It +migrates the full `Button` module (`Button`, `ButtonGroup`, `ButtonToolbar`) +onto the proposed modern stack, in isolation, without touching the live library +build. + +It is intentionally self-contained and **not published** — it exists to let the +Paragon Working Group evaluate the approach concretely. + +## What it demonstrates + +| Concern | Old | This prototype | +| --- | --- | --- | +| Behavior | `react-bootstrap` | **React Aria** (`useButton`, `useFocusRing`) | +| Styling | Bootstrap 4 SCSS, global classes | Public **global classes** (`btn`, utilities) re-authored in plain CSS + **CSS Modules** for internals, all over the same `--pgn-*` tokens | +| Theming API | `style-dictionary` → CSS vars | **Unchanged** — imported verbatim (`src/tokens.css`) | +| Types | mixed `.jsx`/`.tsx` | **100% TypeScript** | +| Build | Babel + tsc + Makefile | **Vite** library mode + `vite-plugin-dts` | +| Tests | Jest | **Vitest** + Testing Library | +| Docs | Gatsby + react-docgen v5 | **Storybook 10** (props from TS types) | + +The public prop API (`variant`, `size`, `iconBefore/After`, `as`, `disabled`, +`onClick`, …) is preserved from `@openedx/paragon`'s Button, so consuming code +does not change. + +## Running it + +```bash +cd prototype +npm install +npm test # Vitest unit + a11y-ish behavior tests +npm run storybook # interactive workbench at http://localhost:6007 +npm run build # ESM + .d.ts into dist/ +``` + +## Toolchain versions + +The prototype tracks TypeScript 6 and Vite 8 (Vite 8 bundles with Rolldown). + +**We cannot move to TypeScript 7 yet.** Storybook's prop tables are generated by +`@joshwooding/vite-plugin-react-docgen-typescript`, which parses component types +with `react-docgen-typescript`. Both rely on TypeScript's JavaScript Compiler +API. TypeScript 7 is the native (Go) port and does not yet expose that API, so +the plugin does not support it — the docgen step (and therefore Storybook's +auto-generated props docs) breaks under TS 7. Once +`@joshwooding/vite-plugin-react-docgen-typescript` ships TypeScript 7 support we +can bump `typescript` accordingly. + +## Key migration decisions surfaced here (for WG ratification) + +1. **Polymorphic `as` via `useButton`, not ` + + + + + +); + +export const coreButtonsInverse = ( + + + + + + + +); + +export const utilityButtons = ( + <> + + + + + + + + + + + + + + +); + +export const sizes = ( + <> + + + + + + + + + + + + + +); + +export const inlineSize = ( +

+ 2 items selected. + + +

+); + +export const blockButtons = ( + <> + + + +); + +export const disabled = ( + + + + + +); + +export const emptyHref = ( + + + + +); + +export const withIcons = ( + + + + + + + +); + +/** Editable source strings for react-live, extracted verbatim from this file. */ +export const examples = extractExamples(raw); diff --git a/prototype/src/Button/Button.test.tsx b/prototype/src/Button/Button.test.tsx new file mode 100644 index 00000000000..fd07429fa18 --- /dev/null +++ b/prototype/src/Button/Button.test.tsx @@ -0,0 +1,94 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi } from 'vitest'; + +import { Button } from './Button'; +import { ButtonGroup } from './ButtonGroup'; +import { ButtonToolbar } from './ButtonToolbar'; + +describe('Button', () => { + it('renders a native button with its children', () => { + render(); + const btn = screen.getByRole('button', { name: 'Save' }); + expect(btn.tagName).toBe('BUTTON'); + }); + + it('applies the public Bootstrap-compatible variant class', () => { + render(); + const btn = screen.getByRole('button', { name: 'Delete' }); + // Global, stable class names (not hashed) — the same public API a raw + // `
` relies on. + expect(btn).toHaveClass('btn', 'btn-danger'); + }); + + it('forwards the legacy onClick handler', async () => { + const onClick = vi.fn(); + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', { name: 'Click' })); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('fires React Aria onPress for keyboard activation', async () => { + const onPress = vi.fn(); + const user = userEvent.setup(); + render(); + await user.tab(); + await user.keyboard('{Enter}'); + expect(onPress).toHaveBeenCalled(); + }); + + it('does not fire handlers when disabled', async () => { + const onClick = vi.fn(); + // Bypass user-event's pointer-events guard; the CSS disables pointer events, + // but we want to assert the handler itself never fires. + const user = userEvent.setup({ pointerEventsCheck: 0 }); + render(); + await user.click(screen.getByRole('button', { name: 'Nope' })); + expect(onClick).not.toHaveBeenCalled(); + }); + + it('renders as a custom element via `as` while staying an accessible button', () => { + render(); + const el = screen.getByRole('button', { name: 'Docs' }); + expect(el.tagName).toBe('A'); + expect(el).toHaveAttribute('href', 'https://openedx.org'); + }); + + it('marks a disabled anchor as aria-disabled (no native disabled on )', () => { + render(); + expect(screen.getByText('Docs')).toHaveAttribute('aria-disabled', 'true'); + }); + + it('forwards a ref to the underlying element', () => { + const ref = { current: null as HTMLElement | null }; + render(); + expect(ref.current).toBeInstanceOf(HTMLButtonElement); + }); +}); + +describe('ButtonGroup / ButtonToolbar', () => { + it('exposes group and toolbar roles', () => { + render( + + + + + + , + ); + expect(screen.getByRole('toolbar', { name: 'tools' })).toBeInTheDocument(); + expect(screen.getByRole('group', { name: 'group' })).toBeInTheDocument(); + }); + + it('propagates its size to child buttons via context', () => { + render( + + + , + ); + // The size class is applied from context; assert the button rendered. + // (Class hashing is environment-specific, so we assert presence + role.) + expect(screen.getByRole('button', { name: 'Small via group' })).toBeInTheDocument(); + }); +}); diff --git a/prototype/src/Button/Button.tsx b/prototype/src/Button/Button.tsx new file mode 100644 index 00000000000..970ecc93cd7 --- /dev/null +++ b/prototype/src/Button/Button.tsx @@ -0,0 +1,102 @@ +import React, { useContext } from 'react'; +import { + useButton, useFocusRing, useObjectRef, mergeProps, +} from 'react-aria'; +import clsx from 'clsx'; + +import type { ButtonProps } from './types'; +import { ButtonGroupContext } from './ButtonGroupContext'; +import '../styles/button.css'; + +const SIZE_CLASS = { + sm: 'btn-sm', + md: undefined, + lg: 'btn-lg', + inline: 'btn-inline', +} as const; + +/** + * Accessible, themeable Button. + * + * Behaviour comes from React Aria's `useButton` (cross-device press handling, + * correct `disabled` semantics even when rendered as an `` via `as`, and + * focus-ring state) rather than Bootstrap. The public prop API is unchanged + * from `@openedx/paragon`'s Button, and all styling is driven by the existing + * `--pgn-*` design tokens. + */ +export const Button = React.forwardRef(( + { + as, + variant = 'primary', + size, + iconBefore: IconBefore, + iconAfter: IconAfter, + disabled = false, + block = false, + className, + children, + onClick, + onPress, + type = 'button', + ...rest + }, + forwardedRef, +) => { + const ref = useObjectRef(forwardedRef as React.ForwardedRef); + const ElementType: React.ElementType = as ?? 'button'; + const groupSize = useContext(ButtonGroupContext); + const resolvedSize = size ?? groupSize ?? 'md'; + + const { buttonProps, isPressed } = useButton( + { + elementType: ElementType, + isDisabled: disabled, + onPress, + type, + 'aria-label': rest['aria-label'], + }, + ref, + ); + const { isFocusVisible, focusProps } = useFocusRing(); + + return ( + ` (+ size/block). These are the same + // Bootstrap-compatible class names Paragon has always shipped, so a raw + // `` renders identically to this component. + className={clsx( + 'btn', + `btn-${variant}`, + SIZE_CLASS[resolvedSize], + block && 'btn-block', + className, + )} + > + {IconBefore && ( + + + + )} + {children} + {IconAfter && ( + + + + )} + + ); +}); + +Button.displayName = 'Button'; + +export default Button; diff --git a/prototype/src/Button/ButtonGroup.mdx b/prototype/src/Button/ButtonGroup.mdx new file mode 100644 index 00000000000..8ba2f94f7a7 --- /dev/null +++ b/prototype/src/Button/ButtonGroup.mdx @@ -0,0 +1,53 @@ +import { Meta, Title, Subtitle, Story, Controls } from '@storybook/addon-docs/blocks'; + +import * as ButtonGroupStories from './ButtonGroup.stories'; +import { LiveExample } from '../docs/LiveExample'; + +export const examples = ButtonGroupStories.examples; + + + +Button Group + +Group related buttons so they read as a single control; ButtonToolbar arranges multiple groups. + +`ButtonGroup` visually and semantically groups related `Button`s (ARIA +`role="group"`). It renders the public `btn-group` class (part of Paragon's +global class layer) and additionally propagates its `size` to child buttons via +React context — replacing Bootstrap's `.btn-group-sm` / `.btn-group-lg` sizing +classes — while inner border radii are collapsed with logical properties so the +group works in both LTR and RTL. + +`ButtonToolbar` (ARIA `role="toolbar"`) lays out one or more groups. + +## Playground + +Edit the props with the controls and the JSX below regenerates; you can also type +directly in the editor to experiment. Changing a control overwrites the editor. + + + + + +## Sizes + +A group sets the size of every button it contains — set it once on the group +rather than on each `Button`. + + + +## Vertical + + + +## ButtonToolbar + +Compose several groups inside a `ButtonToolbar`. + + + +## Accessibility notes + +- Always provide an `aria-label` on each `ButtonGroup` and `ButtonToolbar`. +- Use `ButtonToolbar` when you have more than one group, so assistive tech + announces the toolbar and its constituent groups correctly. diff --git a/prototype/src/Button/ButtonGroup.stories.tsx b/prototype/src/Button/ButtonGroup.stories.tsx new file mode 100644 index 00000000000..7ac9d17c0a3 --- /dev/null +++ b/prototype/src/Button/ButtonGroup.stories.tsx @@ -0,0 +1,89 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { Button } from './Button'; +import { ButtonGroup } from './ButtonGroup'; +import { ButtonToolbar } from './ButtonToolbar'; +import { LivePlayground, generateCode, playgroundChildren } from '../docs/LivePlayground'; +import { extractExamples } from '../docs/extractExamples'; +import raw from './ButtonGroup.stories.tsx?raw'; + +const meta: Meta = { + // Separate hidden root — see the note in Button.stories.tsx. + title: 'Internal/ButtonGroup', + component: ButtonGroup, + tags: ['!dev'], + // PascalCase exports are stories; camelCase exports below are typechecked + // docs example source (see Button.stories.tsx for the full rationale). + includeStories: /^[A-Z]/, + parameters: { layout: 'padded' }, + args: { size: 'md', vertical: false }, + argTypes: { + size: { control: 'inline-radio', options: ['sm', 'md', 'lg', 'inline'] }, + vertical: { control: 'boolean' }, + // Plain-text children make no sense here (the group holds Buttons); hide the + // control rather than let Storybook auto-generate a ReactNode "Set object" one. + children: { table: { disable: true } }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Playground: Story = { + render: (args) => ( + Bold', + '', + '', + ].join('\n')), + })} + /> + ), +}; + +/* + * Docs live examples — typechecked TSX, source-extracted for react-live. + * camelCase so `includeStories` excludes them from Storybook. See + * Button.stories.tsx for the full rationale. + */ + +export const sizes = ( +
+ + + + + + + + +
+); + +export const vertical = ( + + + + + +); + +export const toolbar = ( + + + + + + + + + + + +); + +/** Editable source strings for react-live, extracted verbatim from this file. */ +export const examples = extractExamples(raw); diff --git a/prototype/src/Button/ButtonGroup.tsx b/prototype/src/Button/ButtonGroup.tsx new file mode 100644 index 00000000000..2760728e94f --- /dev/null +++ b/prototype/src/Button/ButtonGroup.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import clsx from 'clsx'; + +import type { ButtonSize } from './types'; +import { ButtonGroupContext } from './ButtonGroupContext'; +import '../styles/button.css'; + +export interface ButtonGroupProps { + /** Specifies element type for this component (default: `div`). */ + as?: React.ElementType; + /** An ARIA role describing the button group (default: `group`). */ + role?: React.AriaRole; + /** Sets the size for all Buttons in the group (default: `md`). */ + size?: ButtonSize; + /** Stack the Buttons vertically (default: `false`). */ + vertical?: boolean; + className?: string; + children: React.ReactNode; + 'aria-label'?: string; +} + +/** + * Groups related Buttons and propagates a shared `size` to them via context, + * replacing Bootstrap's `.btn-group-*` global-class mechanism. + */ +export const ButtonGroup = React.forwardRef(( + { + as, + role = 'group', + size = 'md', + vertical = false, + className, + children, + ...rest + }, + ref, +) => { + const ElementType: React.ElementType = as ?? 'div'; + return ( + + + {children} + + + ); +}); + +ButtonGroup.displayName = 'ButtonGroup'; + +export default ButtonGroup; diff --git a/prototype/src/Button/ButtonGroupContext.ts b/prototype/src/Button/ButtonGroupContext.ts new file mode 100644 index 00000000000..859d78b2b24 --- /dev/null +++ b/prototype/src/Button/ButtonGroupContext.ts @@ -0,0 +1,9 @@ +import { createContext } from 'react'; +import type { ButtonSize } from './types'; + +/** + * Lets a `ButtonGroup` set the size of all descendant `Button`s, replicating + * Bootstrap's `.btn-group-sm` / `.btn-group-lg` behaviour without global + * class-name coupling. `undefined` means "no group override". + */ +export const ButtonGroupContext = createContext(undefined); diff --git a/prototype/src/Button/ButtonToolbar.tsx b/prototype/src/Button/ButtonToolbar.tsx new file mode 100644 index 00000000000..93025e35b43 --- /dev/null +++ b/prototype/src/Button/ButtonToolbar.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import clsx from 'clsx'; + +import '../styles/button.css'; + +export interface ButtonToolbarProps { + /** Specifies element type for this component (default: `div`). */ + as?: React.ElementType; + /** An ARIA role describing the toolbar (default: `toolbar`). */ + role?: React.AriaRole; + className?: string; + children: React.ReactNode; + 'aria-label'?: string; +} + +/** A flex container that lays out one or more ButtonGroups. */ +export const ButtonToolbar = React.forwardRef(( + { + as, role = 'toolbar', className, children, ...rest + }, + ref, +) => { + const ElementType: React.ElementType = as ?? 'div'; + return ( + + {children} + + ); +}); + +ButtonToolbar.displayName = 'ButtonToolbar'; + +export default ButtonToolbar; diff --git a/prototype/src/Button/index.ts b/prototype/src/Button/index.ts new file mode 100644 index 00000000000..8ddda877fed --- /dev/null +++ b/prototype/src/Button/index.ts @@ -0,0 +1,6 @@ +export { default, Button } from './Button'; +export { ButtonGroup } from './ButtonGroup'; +export { ButtonToolbar } from './ButtonToolbar'; +export type { ButtonProps, ButtonVariant, ButtonSize, BaseVariant } from './types'; +export type { ButtonGroupProps } from './ButtonGroup'; +export type { ButtonToolbarProps } from './ButtonToolbar'; diff --git a/prototype/src/Button/types.ts b/prototype/src/Button/types.ts new file mode 100644 index 00000000000..d771018b024 --- /dev/null +++ b/prototype/src/Button/types.ts @@ -0,0 +1,83 @@ +import type React from 'react'; +import type { PressEvent } from 'react-aria'; + +/** + * The eleven base button variants Paragon ships. Preserved exactly from the + * current `@openedx/paragon` Button API. + */ +export type BaseVariant = ( + | 'primary' + | 'secondary' + | 'tertiary' + | 'brand' + | 'success' + | 'danger' + | 'warning' + | 'info' + | 'dark' + | 'light' + | 'link' +); + +/** + * Every valid `variant` value: each base variant plus its `outline-`, + * `inverse-` and `inverse-outline-` forms. This is the same union the current + * TypeScript Button exposes, so consumers see no change. + */ +export type ButtonVariant = + | BaseVariant + | `inverse-${BaseVariant}` + | `outline-${BaseVariant}` + | `inverse-outline-${BaseVariant}`; + +export type ButtonSize = 'sm' | 'md' | 'lg' | 'inline'; + +export interface ButtonProps { + /** Set a custom element for this component (default: `button`, with `type="button"`). */ + as?: React.ElementType; + /** Specifies variant to use (default: `primary`). */ + variant?: ButtonVariant; + /** Button size. Note `md` and `inline` are Paragon extensions over Bootstrap. */ + size?: ButtonSize; + /** + * An icon component to render before the label. Example: + * ``` + * import { Close } from '@openedx/paragon/icons'; + * + * ``` + */ + iconBefore?: React.ComponentType; + /** An icon component to render after the label. */ + iconAfter?: React.ComponentType; + /** Disables the Button, even when rendered as a non-`', + '', + '', + ].join('\n')), + })} + /> + ), +}; + +/* + * Docs live examples — typechecked TSX, source-extracted for react-live. + * camelCase so `includeStories` excludes them from Storybook. See + * Button.stories.tsx for the full rationale. + */ + +export const vertical = ( + + + + + +); + +export const horizontal = ( + +
first block
+
second block
+
third block
+
+); + +export const reversedVertical = ( + + + + + +); + +export const reversedHorizontal = ( + +
first block
+
second block
+
third block
+
+); + +/** Editable source strings for react-live, extracted verbatim from this file. */ +export const examples = extractExamples(raw); diff --git a/prototype/src/Stack/Stack.test.tsx b/prototype/src/Stack/Stack.test.tsx new file mode 100644 index 00000000000..b92365dc2e8 --- /dev/null +++ b/prototype/src/Stack/Stack.test.tsx @@ -0,0 +1,55 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; + +import { Stack } from './Stack'; + +describe('Stack', () => { + it('renders a vertical stack by default', () => { + render(Content); + const stack = screen.getByTestId('stack'); + expect(stack.tagName).toBe('DIV'); + expect(stack.className).toMatch(/vstack/); + }); + + it('renders a horizontal stack when direction is horizontal', () => { + render(Content); + expect(screen.getByTestId('stack').className).toMatch(/hstack/); + }); + + it('applies the gap class for the matching spacer level', () => { + render(Content); + // Gap is token-driven via a CSS Module class, not an inline style. + expect(screen.getByTestId('stack').className).toMatch(/gap-3/); + expect(screen.getByTestId('stack').getAttribute('style')).toBeNull(); + }); + + it('maps half-step gaps onto the spacer scale (1.5 -> gap-1-5)', () => { + render(Content); + expect(screen.getByTestId('stack').className).toMatch(/gap-1-5/); + }); + + it('applies the reversed modifier', () => { + render(Content); + expect(screen.getByTestId('stack').className).toMatch(/reversed/); + }); + + it('forwards className and DOM attributes', () => { + render(Content); + const stack = screen.getByTestId('stack'); + expect(stack.className).toContain('extra'); + expect(stack).toHaveAttribute('aria-label', 'things'); + }); + + it('forwards a caller-supplied style alongside the gap class', () => { + render(Content); + const stack = screen.getByTestId('stack'); + expect(stack.style.marginTop).toBe('1rem'); + expect(stack.className).toMatch(/gap-2/); + }); + + it('forwards a ref to the underlying element', () => { + const ref = { current: null as HTMLDivElement | null }; + render(Content); + expect(ref.current).toBeInstanceOf(HTMLDivElement); + }); +}); diff --git a/prototype/src/Stack/Stack.tsx b/prototype/src/Stack/Stack.tsx new file mode 100644 index 00000000000..06d13db3bfd --- /dev/null +++ b/prototype/src/Stack/Stack.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import clsx from 'clsx'; + +import styles from './Stack.module.css'; + +export interface StackProps extends React.HTMLAttributes { + /** Specifies the content of the `Stack`. */ + children: React.ReactNode; + /** Specifies direction of the children blocks (default: `vertical`). */ + direction?: 'horizontal' | 'vertical'; + /** + * Inner space between children, on the Paragon spacer scale. + * + * Valid values match the `--pgn-spacing-spacer-*` tokens: + * `0, 1, 2, 3, 4, 5, 6` and the half-steps `1.5, 2.5, 3.5, 4.5, 5.5`. + */ + gap?: number; + /** Reverse the order of the children (default: `false`). */ + reversed?: boolean; + /** Specifies an additional `className` to add to the base element. */ + className?: string; +} + +/** + * Flexbox layout helper. A vertical or horizontal `Stack` that spaces its + * children using Paragon's spacer design tokens. + * + * This is a 1:1 port of `@openedx/paragon`'s `Stack`: the public API + * (`direction`, `gap`, `reversed`, `className`, DOM passthrough) is unchanged. + * The only internal difference is that the global `pgn__*stack` classes become + * locally-scoped CSS Module classes, and the gap is resolved to the matching + * `--pgn-spacing-spacer-*` token via the `--pgn-stack-gap` custom property + * rather than the SCSS `@each` loop. + */ +export const Stack = React.forwardRef(( + { + direction = 'vertical', + gap = 0, + reversed = false, + children, + className, + ...rest + }, + ref, +) => ( +
`gap-1-5`). All styling + // lives in the CSS Module; the component sets no inline styles. + styles[`gap-${String(gap).replace('.', '-')}`], + className, + )} + {...rest} + > + {children} +
+)); + +Stack.displayName = 'Stack'; + +export default Stack; diff --git a/prototype/src/Stack/index.ts b/prototype/src/Stack/index.ts new file mode 100644 index 00000000000..50ad3676f96 --- /dev/null +++ b/prototype/src/Stack/index.ts @@ -0,0 +1,2 @@ +export { default, Stack } from './Stack'; +export type { StackProps } from './Stack'; diff --git a/prototype/src/Tabs/Tab.tsx b/prototype/src/Tabs/Tab.tsx new file mode 100644 index 00000000000..ceb4074f725 --- /dev/null +++ b/prototype/src/Tabs/Tab.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import type { Key } from 'react-stately'; + +export interface TabProps { + /** + * A unique identifier for the `Tab`, distinguishing it from its siblings. + * Selecting a tab reports this value through `Tabs`' `activeKey` / `onSelect`. + */ + eventKey: Key; + /** Specifies the `Tab`'s navigation title. */ + title: React.ReactNode; + /** Specifies the panel content shown when this `Tab` is selected. */ + children?: React.ReactNode; + /** + * Specifies notification bubble content. It appears on the top-right of the + * `Tab`'s title. + */ + notification?: React.ReactNode; + /** Specifies whether the `Tab` is disabled. */ + disabled?: boolean; + /** Specifies an additional class name to append to the `Tab`'s nav link. */ + tabClassName?: string; +} + +/** + * Declarative configuration for a single tab. `Tab` is never rendered to the DOM + * itself — its parent `Tabs` reads these props to build the React Aria / + * react-stately tab collection (the tab button from `title`/`notification`, the + * panel from `children`). This mirrors `@openedx/paragon`'s `Tab`: the public + * props (`eventKey`, `title`, `notification`, `disabled`) are unchanged. + */ +function Tab(_props: TabProps): React.ReactElement | null { + // Rendering is handled by `Tabs`; a stray `` outside `` renders + // nothing rather than leaking an unstyled element into the page. + return null; +} + +export default Tab; diff --git a/prototype/src/Tabs/Tabs.mdx b/prototype/src/Tabs/Tabs.mdx new file mode 100644 index 00000000000..0ba2e88fed6 --- /dev/null +++ b/prototype/src/Tabs/Tabs.mdx @@ -0,0 +1,122 @@ +import { Meta, Title, Subtitle, Story, Controls } from '@storybook/addon-docs/blocks'; + +import * as TabsStories from './Tabs.stories'; +import { LiveExample } from '../docs/LiveExample'; + +export const examples = TabsStories.examples; + + + +Tabs + +Organize content into sections and switch between them — React Aria tab behavior, styled entirely from Paragon design tokens. + +`Tabs` is the prototype re-implementation of Paragon's `Tabs` on the modern stack +proposed in [ADR 0022](https://github.com/openedx/paragon). The declarative +`` API — including `variant`, +`defaultActiveKey` / `activeKey`, `onSelect`, and per-tab `disabled` / +`notification` — is unchanged from `@openedx/paragon`, but the internals no longer +depend on Bootstrap or `react-bootstrap`: + +- **Behavior** comes from [React Aria](https://react-spectrum.adobe.com/react-aria/)'s + `useTabList` / `useTab` / `useTabPanel` plus react-stately's `useTabListState`: + correct `role="tablist"` / `tab` / `tabpanel` semantics, arrow-key navigation, + and `aria-selected` / `aria-controls` linkage — no more manual `MutationObserver` + syncing of `aria-selected`. +- **Styling** is the global, public `nav` / `nav-tabs` / `nav-pills` / + `nav-inverse-*` / `nav-button-group` class layer (`src/styles/tabs.css`) over + the same `--pgn-*` design tokens the library ships today, so a component and a + raw `