This document provides an overview of the library's organization, conventions, and implementation patterns.
- Creating a New Component — start here if you're implementing one
- Project Structure
- Component Architecture
- Props Conventions
- TypeScript Patterns
- Compatibility & Support
- Performance & Bundle Size
- SLDS Alignment
- Testing
- Accessibility
- Best Practices
- Maintainability & Versioning
- Contributing
Everything after Creating a New Component is reference material: the why and the edge cases behind each step. The walkthrough links to the relevant part of it as you go — you don't need to read it all up front.
Follow these steps in order. Each links to the reference section with the full rule and rationale — check it when a decision here is ambiguous, otherwise keep moving.
- Confirm the component maps to an approved SLDS pattern — see Only Propose SLDS-Approved Components. If it doesn't exist in SLDS, stop and raise that before writing code.
- Search
components/for something with an overlapping shape (form element, overlay, composite list, etc.) and follow its conventions rather than inventing new ones — see Do.
components/[component-name]/ # kebab-case
├── index.tsx # public export
├── [component-name].tsx # implementation
├── types.ts # prop types
├── private/ # internal-only sub-components (if any)
├── __docs__/
│ └── [Component].stories.tsx
└── __tests__/
└── [component-name].test.tsx
Full rationale: Project Structure, Component Folder Structure.
Commit to the prop contract before implementing:
- Extend the matching HTML attributes interface — Extending HTML Attributes.
- Every prop with no safe default is required, not optional — Required Props.
- Mutually exclusive forms (e.g.
base/inline/popover) are a discriminated union, never independent booleans — Illegal States Unrepresentable. - Name props by convention —
is*/has*/can*booleans,on*/onRequest*callbacks,className*class targets — Naming Conventions. - Group all visible and assistive text into
labels/assistiveTextobjects typedReactNode— Assistive Text and Labels. - Never default a boolean to
true; adding a default to an existing prop later is a breaking change — Boolean Props & Defaults.
- Functional component,
forwardReftyped to the actual rendered root element (HTMLButtonElement, not genericHTMLElement) — Functional Components with Hooks. classNamelands only on the node carrying.slds-[component]; every other node's class prop is suffixed (classNameMenu) — DOM Node & Ref Conventions.- No local state for anything the parent could hold as a prop, and never mirror a prop into
useState— State & Coupling Principles. - Use SLDS markup/classes, not bespoke CSS; follow the
variant/themesplit and don't render CSS-hidden elements — Converting SLDS Markup to React. - Callbacks are
(event, data) => void, existence-checked before calling, never used for return-value contracts — Callback Details. - If this component composes others, it owns the single tab stop and roving/active-descendant focus — the consumer shouldn't have to — Composed-Component Focus Behavior.
- If rendered content must escape an ancestor's
overflow: hidden(menu, dialog, tooltip, popover), portal it — Portal-Based Overflow Escape.
Wire any deprecated prop, unrepresentable-by-types constraint, or "prototype"-status warning through utilities/warning/*, dev-only — Deprecation Warnings, Development Diagnostics Scope.
- Cover every prop, every callback (assert the data payload, not just that it fired), and all keyboard/mouse interactions — What to Test.
- Target 90%+ coverage — Coverage Expectations.
- Add rendered-DOM accessibility assertions alongside RTL tests if
axe/jest-axe/vitest-axeis available in the repo — Automated Accessibility Assertions.
__docs__/[Component].stories.tsx doubles as the documentation source. Prop descriptions belong as JSDoc comments in types.ts — Write clear prop descriptions — don't hand-duplicate them in the story.
- Add the export to
index.tsx. - Confirm you haven't introduced a module-level side effect (
package.json's"sideEffects": falsemust stay accurate) — Bundle Footprint. - No new runtime dependency without a bundle-size/risk writeup — Runtime Dependency Budget.
A single component showing the conventions above applied together:
// components/rating/types.ts
import type { HTMLAttributes, ReactNode } from 'react';
export interface RatingProps extends Omit<
HTMLAttributes<HTMLDivElement>,
'onChange'
> {
/** Current rating value. */
value: number;
/** Maximum number of stars. */
max: number;
/** Whether the rating can be changed by the user. */
isReadOnly?: boolean;
/** Visible and assistive text, grouped for i18n. */
labels?: {
heading?: ReactNode;
};
assistiveText?: {
star?: (data: { position: number }) => string;
};
/** Fires after the user commits a new value. */
onChange?: (
event: React.MouseEvent<HTMLButtonElement>,
data: { value: number }
) => void;
/** Additional class name for the star row (root carries `.slds-rating`). */
classNameStars?: string;
}// components/rating/rating.tsx
import React, { forwardRef, useCallback } from 'react';
import classNames from 'classnames';
import type { RatingProps } from './types';
const Rating = forwardRef<HTMLDivElement, RatingProps>(
(
{
value,
max,
isReadOnly = false,
labels,
assistiveText,
onChange,
className,
classNameStars,
...rest
},
ref
) => {
const handleStarClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>, position: number) => {
if (!isReadOnly) {
onChange?.(event, { value: position });
}
},
[isReadOnly, onChange]
);
return (
<div ref={ref} className={classNames('slds-rating', className)} {...rest}>
{labels?.heading && (
<span className="slds-rating__heading">{labels.heading}</span>
)}
<div className={classNames('slds-rating__stars', classNameStars)}>
{Array.from({ length: max }, (_, index) => {
const position = index + 1;
return (
<button
key={position}
type="button"
disabled={isReadOnly}
className={classNames('slds-rating__star', {
'slds-is-selected': position <= value,
})}
aria-label={assistiveText?.star?.({ position })}
onClick={(event) => handleStarClick(event, position)}
/>
);
})}
</div>
</div>
);
}
);
Rating.displayName = 'Rating';
export default Rating;Notice what this example does and why, tying back to the steps above: value/max are required (step 2), isReadOnly defaults to false not true (step 2), the ref is typed to HTMLDivElement because that's the actual root node (step 3), className lands on .slds-rating while the star row uses a suffixed classNameStars (step 3), text is passed via labels/assistiveText rather than hardcoded (step 2), and onChange is existence-checked and carries a data payload rather than relying on a return value (step 3).
- Component maps to an approved SLDS pattern
-
types.ts: no unnecessary optional props; mutually exclusive forms are a discriminated union -
forwardReftyped to the real root element, not a generic one -
classNameon the.slds-[component]root only; other targets use suffixedclassName*props - No prop mirrored into state; no
useState(props.x) - All visible/assistive text is a prop, grouped under
labels/assistiveText - Callbacks are
(event, data) => void, existence-checked, no return-value contract - Keyboard interaction and focus management implemented (and owned by the composing component if applicable)
- Overlay content, if any, portals rather than relying on consumer
overflowoverrides - Tests cover every prop, callback, and interaction; ≥90% coverage
- Story HTML snapshots regenerated (
npm test -- --run -u) and the diff reviewed - No new dependency added without a bundle-size/risk writeup
- File(s) under 500 lines; split into
private/sub-components if not - Docs status (
component.json, Storybook) reflects reality (prodvs.prototype)
design-system-react/
├── components/ # React components
│ └── [component]/
│ ├── index.tsx # Public export
│ ├── [component].tsx # Main implementation
│ ├── types.ts # TypeScript interfaces
│ ├── private/ # Internal sub-components
│ └── __docs__/
│ └── Component.stories.tsx
├── utilities/ # Shared utilities (not public API)
│ ├── hooks/ # Custom React hooks
│ └── constants.ts # Component display names
├── types/ # Shared TypeScript types
├── .storybook/ # Storybook configuration
└── docs/ # Documentation
Each component follows this structure:
| File | Purpose |
|---|---|
index.tsx |
Public export, typically re-exports main component |
[component].tsx |
Main component implementation |
types.ts |
TypeScript interfaces for props |
private/ |
Sub-components not part of public API |
__docs__/*.stories.tsx |
Storybook stories |
Components not in a private folder are considered public API and fall within the scope of semantic versioning for breaking changes.
scripts/- Build and release toolingstyles/- Supplemental styles; use sparingly, prefer SLDS classes (see SLDS Alignment)utilities/- Shared, non-component helpers (DOM/event helpers, ID generation, dev-only prop warnings — see Deprecation Warnings)
Components in this library are presentational - they receive data via props and emit events via callbacks. Think of them as templates with event handling.
Core principles:
- Stateless by default: Prefer controlled components where parent manages state
- Props in, events out: Data flows down, events bubble up
- No side effects: Components should be pure functions of their props
All new components should be functional components using React hooks:
import React, { useState, useCallback, forwardRef } from 'react';
import type { MyComponentProps } from './types';
const MyComponent = forwardRef<HTMLDivElement, MyComponentProps>(
({ label, onClick, className, ...rest }, ref) => {
const [isActive, setIsActive] = useState(false);
const handleClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
setIsActive(true);
onClick?.(event, { isActive: true });
},
[onClick]
);
return (
<div ref={ref} className={className} {...rest}>
<button onClick={handleClick}>{label}</button>
</div>
);
}
);
MyComponent.displayName = 'MyComponent';
export default MyComponent;- Use
forwardReffor ref forwarding - Always set
displayNamefor debugging - Use
useCallbackfor event handlers passed to children - Spread remaining props onto the root element
- Limit component state. If the parent application's state can handle it via a prop, don't introduce local state. New components should start out controlled and only add internal (uncontrolled) state if a real use case requires it.
- One stateful component, many stateless sub-components. Prefer a single top-level component that owns state, with child components driven purely by props (e.g. a
Treeholds state, aTreeNodedoes not; frequently reused primitives like badges, pills, and icons generally shouldn't have state). - Group related form elements together. Variants of the same underlying element (e.g. checkbox styles) belong in one component rather than duplicated across components, so fixes in one visual variant carry over to the others.
- Favor loose coupling and weak connascence. A contributor should be able to understand and change one piece of code without having to understand another. Prefer named-key objects over positional parameters (Connascence of Position) to keep coupling weak and refactor-safe.
- Use a single return per function rather than short-circuiting with multiple return statements.
- Prefer functions over imperative loops (e.g.
cats.filter(isKitten).map(getName)) to avoid mutating variables outside functional scope. - Avoid variable mutation. Don't use bare
push,pop,shift,splice,sort,reverse, ordeleteon arrays/objects that are passed by reference — use the spread operator orArray.concat()instead. Unexpected mutation is a common cause of components not re-rendering with new props. - No DOM node queries. Don't reach into the library's rendered output with
querySelectorAll()from application tests. If a DOM node needs to be testable, surface it via aref— once surfaced, that ref becomes part of the public API. - Limit production dependencies. Adding a new external dependency to
dependenciesrequires discussion — always weigh the total cost of ownership. - Don't seed state from props. Initializing
useState(props.x)couples the initial render to a prop value that may change later without the component reacting to it; treat this as an anti-pattern the same waygetInitialStatefrom props was in the class-component era.
| Pattern | Use Case | Example |
|---|---|---|
is*, has*, can* |
Boolean props | isOpen, hasError, canEdit |
on* |
Event callbacks | onClick, onChange, onClose |
onRequest* |
Pre-state-change callbacks | onRequestClose, onRequestOpen |
*Ref |
Ref callbacks | inputRef, buttonRef |
className* |
Additional class names | className, classNameContainer |
Event callbacks should follow this signature:
// Pattern: (event, data) => void
onChange?: (
event: React.ChangeEvent<HTMLInputElement>,
data: { value: string }
) => void;Guidelines:
- First parameter: the event (or
undefinedif no event) - Second parameter: object with named data properties
- Never use return values to communicate back to the component
| Prefix | Timing | Example |
|---|---|---|
onRequest* |
Before state change | onRequestClose - requests close, parent decides |
on* |
After event | onClose - fires after component closed |
Group text props in objects for internationalization:
interface Props {
assistiveText?: {
closeButton?: string;
icon?: string;
};
labels?: {
heading?: string;
cancel?: string;
save?: string;
};
}Don't concatenate strings within a component to build visible text — that assumes you know every language's word order. Pass full strings in via props; only non-visible, programmatic keys may be concatenated. If text mixes data and words, use a before/after key or a callback that returns the composed value. Visible text props should generally accept ReactNode (not just string) so consumers can pass italics, bold, or tooltips.
Use non-optional (required) properties in your types.ts interface whenever a prop has no sensible default. This is the TypeScript equivalent of the old PropTypes.isRequired convention: it keeps a component's API explicit, minimizes internal conditionals, and pushes complexity to the caller rather than hiding it in fallback logic.
- Never default a boolean prop to
true. HTML attributes likecheckeddefault to a truthy presence, but JSX should not force consumers to writepropName={false}; prefer the inverse wording instead (isInlinerather thanisModal, defaulting tofalse). - Adding a default value to an existing prop is a breaking change if it visibly changes existing output. Defaults are safe for brand-new props or props that don't affect current markup.
- The
classNameprop should land on a single, consistent node: the one carrying the root.slds-[COMPONENT]class — never on a wrapping container or a child of it. Any other node's classes should use a suffixed prop, e.g.classNameMenu,classNameContainer. - Limit
refusage to tasks like measuring or focusing a DOM node — not as a general escape hatch. - No global
window/documentaccess without checking for existence first; their presence isn't guaranteed (e.g. SSR). - Don't attach listeners directly to
windowinside a component. If a component needs to respond to viewport changes, let the consuming application listen and drive it via props. - When a component needs to expose multiple internal DOM nodes to a parent, use a
refsobject with semantic keys (refs={{ triggerButton: (el) => {...} }}) rather than overloading the reservedrefprop. This pattern is still used in components likeAccordion.
- Event callbacks should never communicate back to the component via a return value — use
event.preventDefault()or explicit data-object keys instead. - Public callback props should be checked for existence before being invoked (
props.onClick?.(...)). Use required props internally for callbacks a private child component can't function without. - Render props — callbacks that determine what gets rendered instead of responding to an event — should use an
onRenderprefix, e.g.onRenderItem. - All DOM
idattributes should be unique to the page. A top-level component'sidprop should be generated by default (this codebase usesnanoidviautilities/generate-id) but remain overridable by the consumer, which keeps DOM snapshots deterministic. Sub-component IDs should be derived from the parent'sid, e.g.`tab-panel-${componentId}-${panelId}`.
Prefer accepting a pre-configured child component instance over adding new props that just alias an existing component's API — e.g. <SimpleDataTableRowActions dropdown={<Dropdown options={...} />} /> rather than inventing dropdownOptions on the parent. The parent shallow-merges its own props into the passed component, with the caller's props taking precedence. This keeps each piece separately documented and avoids duplicate PropType/interface surface area, at the cost of being easier to misuse — use with caution, since overriding internal logic this way can break a component.
When a component has mutually exclusive forms (e.g. an input that's either base, readonly, or rendered inside a popover), model that as a discriminated union in types.ts rather than a set of independently optional booleans:
type ComboboxVariantProps =
| { variant: 'base' }
| { variant: 'inline-listbox' }
| { variant: 'popover'; popoverProps: PopoverProps }
| { variant: 'readonly' };Independently optional booleans (isPopover?: boolean; isReadonly?: boolean; ...) let invalid combinations compile even though they can't be rendered correctly. A discriminated union makes the illegal combination a type error instead of a runtime bug. This is the same problem the variant/theme conventions in SLDS Alignment exist to prevent — the union is how you enforce it in the type system rather than just in prose.
Expose behavior-determining structure through named props, not through the order of children (e.g. <Tabs><Tab /><Tab /></Tabs> where position implies meaning). Positional contracts are strongly connascent — reordering children silently changes behavior, and you can't deprecate one "slot" independently of the others. Prefer named props (primaryTab, items={[...]}) or named slot props (renderHeader) so each piece of the contract can evolve on its own.
Complex interactive logic — focus management, roving tabindex, keyboard navigation, ARIA state transitions — should be authored once as a shared hook in utilities/hooks/ and consumed by every component that needs it, not reimplemented per component. This keeps behavior consistent across components that share a UX pattern (e.g. menu-like keyboard navigation in Combobox, MenuDropdown, and RadioButtonGroup) and means a fix or an a11y improvement only has to happen in one place.
When sunsetting or renaming a prop, keep the old prop working and emit a development-only console warning rather than removing it outright. This codebase's utilities/warning/* helpers (deprecated-property, sunset-property, only-one-of-properties, if-one-then-both-required-property, has-children-without-display-name-of) exist for exactly this — wire new deprecations through them (guarded by process.env.NODE_ENV !== 'production') instead of inventing ad hoc warnings per component.
Breaking changes to props, or a new SLDS version that changes markup in a breaking way, require a major version release — this library treats backward compatibility seriously and denies breaking changes except in rare, well-considered cases. Note that SLDS markup/class updates within the current design system release cycle are not considered breaking, even though they may break consumers' markup-based test queries.
Define props in a separate types.ts file:
// types.ts
import type { ReactNode, HTMLAttributes } from 'react';
export interface MyComponentProps extends Omit<
HTMLAttributes<HTMLDivElement>,
'onChange'
> {
/** The component label */
label: string;
/** Optional icon to display */
icon?: ReactNode;
/** Whether the component is disabled */
isDisabled?: boolean;
/** Callback when value changes */
onChange?: (
event: React.ChangeEvent<HTMLInputElement>,
data: { value: string }
) => void;
}Extend from appropriate HTML element attributes:
// For button-like components
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
// For input-like components
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
// For div containers
interface ContainerProps extends React.HTMLAttributes<HTMLDivElement> {}For components with generic data:
interface DataTableProps<T extends { id: string }> {
items: T[];
onSelect?: (event: React.MouseEvent, data: { item: T }) => void;
}
function DataTable<T extends { id: string }>({
items,
onSelect,
}: DataTableProps<T>) {
// ...
}Treat "no direct DOM/global access without an existence check" (see State & Coupling Principles and DOM Node & Ref Conventions) as the thing that keeps SSR/RSC compatibility tractable: a component that never unconditionally touches window/document has no structural reason to require a client boundary except where it holds genuine interactivity. If this library is consumed inside an RSC-capable framework (e.g. Next.js App Router), that needs to be exercised by at least one integration test before being claimed as supported — an untested SSR claim fails the same way an untested React-version floor does.
package.json sets "sideEffects": false, which is the tree-shaking signal bundlers rely on to drop unused components — preserve it, and verify with a bundler analysis (not just by inspection) that importing one component doesn't pull in unrelated ones. Adding a top-level side effect anywhere in components/ silently breaks this guarantee for the whole package, not just that component.
The default is zero new runtime dependencies beyond what's already in dependencies — the current lodash single-function packages, classnames, react-modal, nanoid, and similar are the existing budget, not a precedent for adding more of the same kind freely. Anything new (a headless interaction primitive, a date library, etc.) is a per-adoption exception that should come with its own bundle-size and transitive-risk note, not be added as a convenience. Check package.json's dependency count as part of reviewing any PR that touches it, so an exception can't land unreviewed.
This library should include only components that have an approved pattern in Salesforce's Lightning Design System. If a designer's use case conforms to an existing SLDS pattern, it should be implementable with this library. The library does not need to mirror Salesforce production exactly, but is open to customization that supports production patterns as long as they remain generic and flexible. If you're a Salesforce employee and need a component that doesn't exist in SLDS, please follow up with the Design Systems team rather than adding a bespoke, non-SLDS component.
- Use
variantfor structural/markup changes that are mutually exclusive (what SLDS usually calls a variant or modifier). Never let the mere presence of an event callback imply a markup change beyond the event itself — e.g. addingonClickshouldn't turn aspaninto ana. - Use
themefor a single mutually-exclusiveclassNameswap — typically an SLDS state or theme (warning,error,offline,success). - Modifiers/states that can coexist should be separate boolean props, so any combination is possible.
- Controlled/uncontrolled props like
isOpenon dialogs and menus should be settable by the parent. - SLDS hides certain elements purely with CSS (menus, dialogs, tab/accordion panels) — don't render them into the DOM when hidden.
- If a component is used incorrectly (e.g. a required
idis missing from option items), prefer erroring via acheckProps-style warning over silently falling back (e.g. to an array index). - ESLint may flag SLDS markup as suspicious. Ask the SLDS team or core maintainers before changing markup — it's often intentional — and disable the specific rule inline (
eslint-disable-line) rather than removing the check globally. - All filenames use kebab-case (
this-is-the-file); convert spaces and camelCase to hyphens.
Prefer existing SLDS CSS over inline styles or bespoke classes. If SLDS is missing something you need, file a bug against the SLDS repository (or the internal bug system) rather than working around it locally — some rare or temporary exceptions are approved case by case. When inline styles are unavoidable, use the design tokens shipped in this library (see utilities/design-tokens) rather than hardcoded values.
This project uses:
- Vitest - Test runner
- React Testing Library - DOM testing
- @testing-library/user-event - User interaction simulation
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import Button from '../';
describe('Button', () => {
it('renders with label', () => {
render(<Button label="Click me" />);
expect(
screen.getByRole('button', { name: 'Click me' })
).toBeInTheDocument();
});
it('calls onClick with event data', async () => {
const onClick = vi.fn();
render(<Button label="Test" onClick={onClick} />);
await userEvent.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalledWith(
expect.any(Object), // event
expect.objectContaining({/* data */})
);
});
it('is disabled when disabled is true', () => {
render(<Button label="Test" disabled />);
expect(screen.getByRole('button')).toBeDisabled();
});
});- All props render correctly
- Event callbacks fire with correct data
- Keyboard interactions work
- ARIA attributes are present
- Focus management is correct
A breaking change should always cause at least one test to fail — if it wouldn't, add a test for it.
Aim for 90%+ test coverage per component, checked via the coverage summary from npm run test:coverage. High coverage doesn't guarantee correct logic, but low coverage is a reliable signal of insufficient testing. Components should also be able to render without a DOM available — guard any document/window access accordingly.
npm test -- --run # Run all tests once (bare `npm test` is watch mode)
npm test -- --run -u # Also regenerate story HTML snapshots
npm run test:ui # Run with Vitest UI
npm run test:coverage # Run once with coverageEvery Storybook story (except portal-rendered ones, which can't be server-rendered) is also rendered into an HTML snapshot (components/__tests__/__snapshots__/story-snapshots.test.jsx.snap), so stories are test fixtures as well as documentation. A run filtered to one component's folder does not include that suite — finish with an unfiltered run.
All components must be:
- Keyboard navigable
- Screen reader compatible
- WCAG 2.2 AA compliant
- Use semantic HTML elements when possible
- Add ARIA attributes only when needed
- Test with screen readers (NVDA, VoiceOver)
// Use refs for programmatic focus
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isOpen) {
inputRef.current?.focus();
}
}, [isOpen]);Implement standard keyboard patterns:
Enter/Space- Activate buttonsEscape- Close dialogsArrow keys- Navigate menusTab- Move between focusable elements
Components whose rendered output must escape an ancestor's overflow: hidden — menus, dialogs, tooltips, popovers — must render through a portal rather than ask consumers to work around clipping with a manual overflow CSS override. This is already established practice in this library: Modal renders through react-modal's portal (portalClassName prop), DatePicker exposes a portalMount prop so the consumer controls the mount point, and Tooltip's default positioning modes use a portal (its relative mode explicitly opts out). Follow this pattern for any new overlay component instead of introducing a new clipping workaround.
When a component composes others (e.g. a component rendering Icon internally, or a Combobox/MenuDropdown composing list items), the composing component owns keyboard/focus coordination: a single tab stop for the whole composite, plus correct roving/active-descendant behavior for its internal items. Don't push focus-management workarounds onto the consumer to make a composed component behave like one accessible widget — this is the accessibility corollary of one stateful component, many stateless sub-components.
Manual ARIA guidelines and keyboard-pattern review (above) catch source-shape problems, but they can't prove that rendered ARIA relationships (aria-controls, aria-activedescendant, roving tabindex) actually resolve correctly once the DOM is rendered — composition and portals can put the rendered DOM in a different tree than the source JSX. Rendered-DOM accessibility assertions (e.g. axe/jest-axe/vitest-axe) should run as part of a component's test suite in addition to manual review.
Contributions that don't yet follow an approved accessible UX pattern are permitted but discouraged, and should be labeled "prototype" status (see each component's component.json) rather than "prod" — file a follow-up issue to close the accessibility gap.
- ARIA Authoring Practices Guide
- WAI-ARIA
- ARIA in HTML - reference for "should I put ARIA on this?"
- WCAG - SLDS and this library target AA compliance
- Keep components focused on one responsibility
- Use SLDS CSS classes (no custom inline styles)
- Make components work without JavaScript for SSR
- Document all props with JSDoc comments
- Handle edge cases gracefully
- Review similar existing components before proposing a new pattern — familiarize yourself with conventions already used elsewhere in the library
- Name internal (non-prop) event handlers with a
handleprefix followed by the event, present-tense (handleClick,handleNameChange) — if you need to disambiguate two handlers for the same event, consider whether that's a sign you should split the component - When rendering conditionally, prefer in this order: inline ternary, then
&&, then an enum/lookup object, then a dedicated sub-component — each step trades conciseness for readability as the condition gets more complex
- Mutate props or state directly
- Use
findDOMNode(deprecated in React 18+) - Add external dependencies without discussion
- Create components not in SLDS
- Use global DOM queries
- Short-circuit a function with multiple
returnstatements — use a single return - Spread unknown/rest props (
{...rest}) onto a DOM node and then keep referencing the original destructured object elsewhere in the same render — once you've pulled named props out, use those variables consistently rather than reaching back intoprops
Keep files under 500 lines. Split large components into sub-components.
- Use SLDS utility classes
- Use classnames for conditional classes
- Avoid inline styles - use CSS custom properties if needed
import classNames from 'classnames';
const buttonClass = classNames('slds-button', {
'slds-button_brand': variant === 'brand',
'slds-button_destructive': variant === 'destructive',
});Publish this taxonomy alongside any SemVer policy so consumers know what a minor/patch release can and cannot change:
- Breaking: prop rename, prop type narrowing, or prop removal.
- Breaking: removing or renaming a styling hook (a
className*target or documented SLDS class consumers style against). - Breaking: adding a default value to a prop that was previously required or had no default, if it changes rendered output for existing callers (see Boolean Props & Defaults).
- Not breaking: markup/DOM-structure changes, provided any externally exposed
refcontinues to resolve to an equivalent element. This is also why Ref Typing matters — a ref typed to the wrong element makes "equivalent element" ambiguous.
This is the concrete version of the general backward-compatibility policy already stated under Deprecation Warnings — that section covers the process (console warnings, major-version bump); this taxonomy covers what counts.
The private/ folder exemption (see Component Folder Structure) is currently a naming convention, not a lint-enforced boundary — there's no rule today that fails a build if code outside a component imports from another component's private/ path. Treat closing that gap (e.g. an ESLint no-restricted-imports rule scoped to **/private/**) as a maintainability requirement, not a nice-to-have: without it, the exemption can be silently violated and the "private" folder stops meaning what it claims to.
The Deprecation Warnings pattern (utilities/warning/*, gated on process.env.NODE_ENV !== 'production') shouldn't be limited to prop deprecation. The same mechanism should fire for: a prop combination that violates a runtime constraint the type system can't express (e.g. "provide at least one of X or Y"), a violation of an accessibility rule that can't be statically checked, and a consumer using a component still marked "prototype" status (see Non-Accessible Contributions). In every case, the warning must be dev-only — never ship as console noise in a production build.
See CONTRIBUTING.md for detailed contribution guidelines.