diff --git a/.eslintrc b/.eslintrc deleted file mode 100644 index 5170ff9fc..000000000 --- a/.eslintrc +++ /dev/null @@ -1,89 +0,0 @@ -{ - "env": { - "es2020": true, - "node": true, - "browser": true - }, - "parser": "@typescript-eslint/parser", - "extends": [ - "eslint:recommended", - "plugin:@typescript-eslint/recommended", - "plugin:react/recommended", - "prettier", - "plugin:mdx/recommended" - ], - "parserOptions": { - "ecmaVersion": 11, - "sourceType": "module", - "ecmaFeatures": { - "modules": true, - "jsx": true - } - }, - "plugins": ["eslint-plugin-react", "@typescript-eslint"], - "settings": { - "react": { - "version": "detect" - }, - "mdx/code-blocks": true - }, - "rules": { - "indent": "off", // Need this else it clashes with @typescript-eslint/indent - "no-empty": "off", - "no-console": "off", - "no-debugger": "off", - "curly": "off", - "max-len": "off", - "semi": ["error", "always"], - "space-before-function-paren": [ - "warn", - { - "anonymous": "always", - "named": "never", - "asyncArrow": "always" - } - ], - "no-trailing-spaces": "off", - "max-classes-per-file": "off", - "no-multiple-empty-lines": "off", - "linebreak-style": ["error", "unix"], - "sort-imports": [ - "warn", - { - "ignoreDeclarationSort": true - } - ], - "@typescript-eslint/member-ordering": "off", - "@typescript-eslint/member-delimiter-style": [ - "warn", - { - "multiline": { - "delimiter": "semi", - "requireLast": true - }, - "singleline": { - "delimiter": "semi", - "requireLast": false - } - } - ], - "@typescript-eslint/no-namespace": "off", - "@typescript-eslint/no-var-requires": "off", - "@typescript-eslint/no-empty-interface": "off", - "@typescript-eslint/naming-convention": [ - "error", - { - "selector": ["interface", "class"], - "format": ["PascalCase"] - } - ], - "@typescript-eslint/explicit-member-accessibility": [ - "warn", - { - "accessibility": "explicit" - } - ], - "react/react-in-jsx-scope": "off", - "react/no-unknown-property": ["error", { "ignore": ["inert"] }] - } -} diff --git a/.github/PULL_REQUEST_TEMPLATE/default.md b/.github/PULL_REQUEST_TEMPLATE/default.md new file mode 100644 index 000000000..18944e878 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/default.md @@ -0,0 +1,26 @@ +**Type of changes** + + + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing apis or functionality to change) + +**Description of changes** + +- Link to [ticket](url) +- Link to [Figma](url) +- Description of changes + +**Checklist** + + + +- [ ] Changes follow the project guidelines in [CONTRIBUTING.md](https://github.com/LifeSG/react-design-system/blob/master/CONTRIBUTING.md) and [CONVENTIONS.md](https://github.com/LifeSG/react-design-system/blob/master/CONVENTIONS.md) +- [ ] Looks good on mobile and tablet +- [ ] Updated documentation +- [ ] Added/updated tests + +**Screenshots** + + diff --git a/.github/PULL_REQUEST_TEMPLATE/v4_component.md b/.github/PULL_REQUEST_TEMPLATE/v4_component.md new file mode 100644 index 000000000..3ffa7a051 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/v4_component.md @@ -0,0 +1,24 @@ +**Checklist** + + + +- [ ] Migrated the component styles + - [ ] `className` is chained correctly with `clsx` + - [ ] User style prop is set as CSS variable +- [ ] Changes follow the project guidelines in [CONVENTIONS_V4.md](https://github.com/LifeSG/react-design-system/blob/master/CONVENTIONS_V4.md) +- [ ] Updated Storybook documentation +- [ ] Added/updated unit tests +- [ ] Added visual tests +- [ ] Listed breaking changes + +**Breaking changes** + + + +List breaking style or api changes to be added to the migration docs + +**Additional information** + + + +Provide additional information that might be useful to the reviewer diff --git a/.github/instructions/design-system-components.instructions.md b/.github/instructions/design-system-components.instructions.md new file mode 100644 index 000000000..b8c67e432 --- /dev/null +++ b/.github/instructions/design-system-components.instructions.md @@ -0,0 +1,108 @@ +--- +description: "Component catalog and usage rules for @lifesg/react-design-system — consult foundations tokens first, then the component catalog, before writing any custom UI code." +applyTo: "**/*.{tsx,ts,jsx,js}" +--- + +This project uses **@lifesg/react-design-system**. + +## Step 1 — Use foundations first + +Before writing any custom styles or reaching for a third-party library, use the design system's foundation tokens. All styling must go through **styled-components** using these foundations. Never use inline styles, plain CSS, hardcoded hex values, or raw px/rem sizes. + +### Colors + +```tsx +import { Color } from "@lifesg/react-design-system/color"; +import { DesignToken } from "@lifesg/react-design-system/design-token"; +import styled from "styled-components"; + +const Card = styled.div` + background: ${Color.Neutral[8]}; + border: 1px solid ${Color.Neutral[5]}; + box-shadow: ${DesignToken.ElevationBoxShadow}; +`; +``` + +### Typography + +```tsx +import { Text, TextStyleHelper } from "@lifesg/react-design-system/text"; +import { Color } from "@lifesg/react-design-system/color"; +import styled from "styled-components"; + +// In JSX — use Text components directly +Heading +Body copy + +// In styled-components — use TextStyleHelper +const Label = styled.span` + ${TextStyleHelper.getTextStyle("Body", "regular")} + color: ${Color.Neutral[1]}; +`; +``` + +### Responsive / media queries + +```tsx +import { MediaQuery } from "@lifesg/react-design-system/media"; +import styled from "styled-components"; + +const Wrapper = styled.div` + padding: 2rem; + + ${MediaQuery.MaxWidth.tablet} { + padding: 1rem; + } +`; +``` + +Available breakpoints (via `MediaQuery.MaxWidth` and `MediaQuery.MinWidth`): `mobileS`, `mobileM`, `mobileL`, `tablet`, `desktopM`, `desktopL`, `desktop4k`. + +### Layout + +```tsx +import { Layout } from "@lifesg/react-design-system/layout"; + + + + ... + +; +``` + +### Spacing / gaps + +There is no spacing token object — use multiples of `0.5rem` (8px grid). Prefer CSS `gap` in flex/grid containers over `margin` on individual children. + +```tsx +const Row = styled.div` + display: flex; + gap: 1rem; /* 16px */ + padding: 1.5rem; /* 24px */ +`; +``` + +--- + +## Step 2 — Use the component catalog + +The machine-readable catalog lives at: + +``` +node_modules/@lifesg/react-design-system/component-catalog.json +``` + +**Always read this file before writing any component code.** Each entry contains: + +| Field | Purpose | +| ------------- | -------------------------------------------------------- | +| `name` | Component name | +| `importPath` | Exact import path to use | +| `description` | What the component does | +| `searchKeys` | Intent keywords — match these against the user's request | + +**Rules:** + +1. Read the catalog and match `searchKeys` against the user's intent to find the right component. +2. Use the exact `importPath` — never import from the package root. +3. Prefer components from this design system over third-party alternatives or custom implementations. diff --git a/.github/prompts/generate-component-jsdoc.prompt.md b/.github/prompts/generate-component-jsdoc.prompt.md new file mode 100644 index 000000000..adc3b9b35 --- /dev/null +++ b/.github/prompts/generate-component-jsdoc.prompt.md @@ -0,0 +1,303 @@ +--- +mode: "agent" +description: "Generates or copies JSDoc comments for component props and fills catalog content gaps (MDX overview, stories tags)." +--- + +# Document Component Props and Fill Catalog Gaps + +You will document all public props in `src/[COMPONENT_NAME]/types.ts` and ensure the +component catalog has a `description` and `searchKeys` by filling the MDX overview and +stories tags. + +The user must specify a component name. If they do not, prompt them for one. + +## Step 0: Decide Which Approach to Use + +Before reading anything else, check whether `stories/[COMPONENT_NAME]/props-table.tsx` +exists. + +- **If it exists** → use the **Props-Table Approach** (Section A). Props-table + descriptions are manually written and more accurate than inferred descriptions. +- **If it does not exist** → use the **Source-Inference Approach** (Section B). + +Either way, always complete **Section C** (MDX overview + stories tags) before +finishing. + +If `src/[COMPONENT_NAME]/types.ts` does not exist, skip Sections A and B entirely — do +not create the file. Still complete Section C. + +--- + +## Section A — Props-Table Approach (props-table.tsx exists) + +### A1: Read Source Files + +1. `stories/[COMPONENT_NAME]/[COMPONENT_NAME].mdx` — extract overview description and + high-level feature headings +2. `stories/[COMPONENT_NAME]/props-table.tsx` — parse the `DATA: ApiTableSectionProps[]` + array for all property descriptions +3. `src/[COMPONENT_NAME]/types.ts` (and any related type files if types are spread across + multiple files — use grep if needed) +4. `src/[COMPONENT_NAME]/[COMPONENT_NAME].style.tsx` (if present) — identify `$`-prefixed + transient props to skip + +### A2: Compose the Interface JSDoc + +Combine the MDX overview and relevant high-level section headings into a concise +description above the main `*Props` interface: + +```typescript +/** + * Primary description from the MDX overview. + * + * Additional capabilities, structure, or modes inferred from MDX section headings. + * + * @keywords keyword1, keyword2, keyword3 + */ +export interface ComponentNameProps { +``` + +For `@keywords`, provide 3–6 comma-separated terms a developer might search for (see +Section B2 for keyword guidelines). These are read by the component catalog and +Storybook — they must be present on the main `*Props` interface. + +**Include** in the additional sentences: + +- Component anatomy (e.g., `Tab` + `Tab.Item` sub-components) +- Key feature capabilities (sort, multi-select, loading states, etc.) +- Controlled vs uncontrolled mode notes + +**Exclude**: code examples, step-by-step usage, edge-case warnings. + +### A3: Write Property JSDoc from Props-Table + +For each `attribute` in `DATA`, add a JSDoc comment above the matching prop in `types.ts`. + +**Finding the right type file**: Props tables often document types spread across multiple +files. Match `section.name` (e.g., `"HeaderItemProps"`) to the interface with that name +wherever it lives in `src/`. Use grep to locate it if not in the main `types.ts`. + +**Converting props-table values to JSDoc**: + +- `description` (string or JSX) → plain-text JSDoc comment + - `value` → `` `value` `` + - HTML entities → appropriate Unicode + - Links → keep URL in parentheses +- `defaultValue` present → append `@default "value"` tag +- `mandatory: true` → do not add `@default` (required prop) +- `name: ""` empty-name attributes → include as a leading comment on the interface body + if relevant + +**Replacing existing JSDoc**: Replace existing JSDoc comments with the props-table +description, even if JSDoc already exists — props-table content is the authoritative +source. + +### A4: Handle Multiple Sections + +A single `props-table.tsx` can document several types: + +- Section with no `name` → describes the main component props (e.g., `DataTableProps`) +- Section with `name: "HeaderItemProps"` → find and update `HeaderItemProps` wherever + it is defined in `src/` + +Process all sections, not just the first. + +--- + +## Section B — Source-Inference Approach (no props-table.tsx) + +### B1: Read Source Files + +1. `src/[COMPONENT_NAME]/[COMPONENT_NAME].tsx` — understand what each prop does at runtime +2. `src/[COMPONENT_NAME]/types.ts` — see existing props and any partial JSDoc +3. `src/[COMPONENT_NAME]/[COMPONENT_NAME].style.tsx` (if present) — identify `$`-prefixed + transient props to skip +4. `stories/[COMPONENT_NAME]/[COMPONENT_NAME].mdx` (if present) — extract overview + +### B2: Document the Main Interface + +Add a JSDoc block above the main exported `*Props` interface: + +````typescript +/** + * Props for the [Component] component - [one-line purpose] + * + * [2-3 sentences: when to use this component, key capabilities, important behaviours] + * + * @keywords keyword1, keyword2, keyword3 + * + * @example + * ```tsx + * content + * ``` + */ +export interface ComponentNameProps { +```` + +For `@keywords`, provide 3–6 comma-separated terms that a developer might search for: + +- Alternative names or common search terms (e.g. `dropdown` for InputSelect) +- Related UI patterns or concepts (e.g. `multi-select`, `chips`, `filter`) +- Focus on terms NOT already present in the component's kebab-case name + +### B3: Document Each Prop + +Apply the pattern that fits each prop type: + +**Boolean prop** + +```typescript +/** + * [What it does — active voice, present tense] + * + * @default false + */ +disabled?: boolean | undefined; +``` + +**Enum / union prop** + +```typescript +/** + * [What this prop controls] + * + * - `value1`: [When to use it, what it does] + * - `value2`: [When to use it, what it does] + * + * @default "value1" + */ +styleType?: "value1" | "value2" | undefined; +``` + +**Callback prop** + +```typescript +/** + * Called when [event occurs] + * + * @param paramName - [Description] + */ +onEvent?: (paramName: Type) => void; +``` + +**Object / config prop** + +```typescript +/** + * [What this configuration controls] + * + * @see RelatedInterfaceName for all available options + */ +config?: RelatedInterfaceName | undefined; +``` + +**Required prop (no `?`)** + +```typescript +/** + * [Description — no @default tag for required props] + */ +children: React.ReactNode; +``` + +**`data-testid` prop** — always document with this standard text: + +```typescript +/** + * Sets the `data-testid` attribute for targeting the element in automated tests. + */ +"data-testid"?: string | undefined; +``` + +**Exported type alias** — document public union type aliases: + +```typescript +/** + * [What this type represents and when each value applies] + * + * - `value1`: [Description] + * - `value2`: [Description] + */ +export type MyType = "value1" | "value2"; +``` + +**Handle / ref type** — document types that expose an imperative API: + +```typescript +/** + * Imperative handle returned by [ComponentName] via `ref`. + * + * Use [Component]'s `ref` prop to access these methods. + */ +export type ComponentHandle = HTMLDivElement & { + expand: () => void; + collapse: () => void; +}; +``` + +--- + +## Section C — Catalog Content (always required) + +The component catalog reads from two specific locations — these are separate from JSDoc +and must be filled regardless of which approach was used above. + +### C1: MDX Overview (`description` field) + +File: `stories/[COMPONENT_NAME]/[COMPONENT_NAME].mdx` + +The catalog reads the text immediately after `Overview`. If this +block is missing or empty, add a concise 1–3 sentence description of the component +directly after that marker. + +If the `Overview` heading itself is absent, locate the first +logical introductory paragraph in the MDX file and add the heading + description there. + +### C2: JSDoc Keywords (`searchKeys` field) + +The component catalog reads `@keywords` from the `@keywords` tag on the main `*Props` +interface in `src/[COMPONENT_NAME]/types.ts`. This is the same tag added in Section A2 +or B2 — no additional action is needed here if that step was completed. + +Verify the `@keywords` tag is present on the main interface before finishing. If it is +missing (e.g. `types.ts` was not modified), add it now. + +--- + +## Writing Standards + +- First line of every JSDoc block: brief description under 80 characters +- Active voice, present tense: "Displays", "Triggers", "Controls" +- Be specific: "Disables interaction and shows loading spinner" not "Loading state" +- Explain every union option — when to choose it +- Add `@default` for optional props (use `undefined` if no meaningful default exists) +- Add `@example` for non-obvious or complex props +- End all sentences with a period +- Do NOT document props inherited from standard HTML element attributes (e.g. `onClick`, + `className` from `React.ButtonHTMLAttributes`) +- If a prop already has JSDoc, improve it if incomplete or unclear; do not discard + accurate existing content (exception: Section A always replaces with props-table data) + +## What NOT to Do + +- Do not document `$`-prefixed transient styled-component props +- Do not document `StyleProps` or `*StyleProps` interfaces — these are internal +- Do not write obvious comments: "The disabled prop disables the component" +- Do not skip enum options — explain each one +- Do not add `@default` to required props +- Do not create `types.ts` if it does not already exist +- Do not modify `props-table.tsx` files + +--- + +## Output Requirements + +Report: + +- Which approach was used (props-table or source-inference) +- Which interfaces and type aliases were documented, with file links +- Count of props documented +- Any props skipped and why +- Any props-table entries that could not be matched to a type (Section A only) +- Whether the MDX overview block was present or added (Section C1) +- Whether the stories `tags` array was present or updated (Section C2) diff --git a/.github/prompts/generate-component-storybook.prompt.md b/.github/prompts/generate-component-storybook.prompt.md new file mode 100644 index 000000000..2aac39cac --- /dev/null +++ b/.github/prompts/generate-component-storybook.prompt.md @@ -0,0 +1,292 @@ +--- +mode: "agent" +description: "Generates a Storybook .stories.tsx, props-table.tsx, and .mdx documentation file for a component, based on its JSDoc." +--- + +# Generate Storybook Story and MDX Documentation + +You will create or update the Storybook files for a component, using its JSDoc-annotated `types.ts` as the source of truth. + +The user must specify a component name. If they do not, prompt them for one. + +## When to Use This Prompt + +Use this prompt after running `generate-component-jsdoc.prompt.md`, once the component's `src/[COMPONENT]/types.ts` has been annotated with JSDoc. This prompt reads those annotations to produce consistent, accurate documentation. + +--- + +## Step 1: Read Source Files + +Read the following files before writing anything: + +1. `src/[COMPONENT_NAME]/types.ts` — JSDoc on interfaces (interface description, `Keywords:` line, prop types) +2. `src/[COMPONENT_NAME]/[COMPONENT_NAME].tsx` — component structure, sub-components (e.g. `Component.Item`) +3. `stories/[COMPONENT_NAME]/[COMPONENT_NAME].stories.tsx` — if it exists, check the current title category and story names + +--- + +## Step 2: Determine the Story Category + +Use the title `"Category/ComponentName"` format. Choose the category that fits the component's purpose: + +| Category | When to use | +| ------------ | ----------------------------------------------------------------- | +| `General` | Buttons, links, icons, basic interactive elements | +| `Form` | Inputs, selects, checkboxes, validation-related components | +| `Modules` | Complex or self-contained UI modules (tables, carousels, drawers) | +| `Layout` | Structural and spacing components | +| `Navigation` | Navbars, breadcrumbs, tabs, pagination | +| `Feedback` | Alerts, toasts, loading indicators, empty states | +| `Overlays` | Modals, popovers, tooltips, drawers | + +--- + +## Step 3: Extract Tags from JSDoc + +Find the `@keywords` tag in the primary `*Props` interface JSDoc block in +`src/[COMPONENT_NAME]/types.ts`. It contains comma-separated search terms: + +```typescript +/** + * ... + * @keywords filter, multi-select, dropdown, search, chips + */ +export interface ComponentNameProps { +``` + +Map those terms to the `tags` array in `Meta`. Do NOT include `"autodocs"` — only +the keyword terms: + +```typescript +// @keywords: filter, multi-select, dropdown, search, chips +tags: ["filter", "multi-select", "dropdown", "search", "chips"], +``` + +Also populate `parameters.keywords` with the same terms as a string array: + +```typescript +parameters: { + docs: { + description: { + component: "One-sentence overview from the top of the interface JSDoc.", + }, + }, + keywords: ["filter", "multi-select", "dropdown", "search", "chips"], +}, +``` + +If `@keywords` is absent from the interface JSDoc, add it before writing the story +(follow the keyword guidelines in `generate-component-jsdoc.prompt.md` Section B2). + +--- + +## Step 4: Write argTypes + +Inspect each prop type in the primary `*Props` interface and generate an appropriate `argTypes` entry: + +| Prop type | argTypes entry | +| ------------------------------------------------- | -------------------------------------------------------------- | +| `boolean` | `control: { type: "boolean" }` | +| Union / enum string | `control: { type: "radio" }` with `options: [...]` | +| Callback (`(...) => void`) | `action: "descriptiveEventName"` | +| Object, array, `React.ReactNode`, render function | `control: false` | +| Reference to another interface | `control: false`, add `description: "See [InterfaceName] tab"` | + +Always include a `description` field drawn from the prop's JSDoc comment. Keep it under 100 characters. + +```typescript +argTypes: { + onItemClick: { action: "itemClicked" }, + items: { + control: false, + description: "Array of items to render. See ItemProps tab for structure.", + }, + disabled: { + control: { type: "boolean" }, + description: "Disables the component and prevents interaction.", + }, + styleType: { + control: { type: "radio" }, + options: ["default", "bordered"], + description: "Visual variant of the component.", + }, +}, +``` + +--- + +## Step 5: Identify Sub-component Interfaces + +Scan `types.ts` for all exported `*Props` interfaces. The primary interface (e.g. `ComponentNameProps`) becomes the first tab; any additional public interfaces become further tabs. + +Exclude interfaces that are internal/style-only — skip those ending in `StyleProps`, `WrapperStyleProps`, `PartialProps`, `WithForwardedRefProps`, `RenderProps`, or starting with `Base`. + +For each public interface (including primary), you will: + +1. Run `npm run props:generate` (or confirm `stories/[COMPONENT]/generated-props.ts` is already up to date) +2. Import the generated `Data` in `props-table.tsx` and render via `` + +Note: properties inherited from HTML elements (e.g. `React.HTMLAttributes`, `React.ButtonHTMLAttributes`) are **automatically filtered out** by the generator. Only component-specific props declared in the local `types.ts` are included. + +--- + +## Step 6: Write `stories/[COMPONENT_NAME]/props-table.tsx` + +If the file does not exist, create it. If it does, update it. + +First, run (or confirm already done): + +```bash +npm run props:generate +``` + +This writes `stories/[COMPONENT_NAME]/generated-props.ts` with a `Data` export for **every** public interface — including the primary one. + +Then write `props-table.tsx` using `` for all tabs: + +```tsx +import { ApiTable, PropTableTabs } from "stories/storybook-common"; +import { ComponentNamePropsData, ItemPropsData } from "./generated-props"; + +export const PropsTableTabs = () => ( + , + }, + { + label: "ItemProps", + content: , + }, + ]} + /> +); +``` + +Key points: + +- All tabs use `` — **no ``**. +- The first tab uses the primary interface data (e.g. `ComponentNamePropsData`). +- Tab labels are the interface names exactly as they appear in `types.ts`. +- HTML-inherited props are excluded automatically; only own props are shown. + +--- + +## Step 7: Write `stories/[COMPONENT_NAME]/[COMPONENT_NAME].stories.tsx` + +Use this exact structure: + +```tsx +import type { Meta, StoryObj } from "@storybook/react-webpack5"; +import { ComponentName } from "src/[component-name]"; + +type Component = typeof ComponentName; + +const meta: Meta = { + title: "Category/ComponentName", + component: ComponentName, + tags: ["keyword1", "keyword2"], + parameters: { + docs: { + description: { + component: "One-sentence description from the interface JSDoc.", + }, + }, + keywords: ["keyword1", "keyword2"], + }, + argTypes: { + // ... as per Step 4 + }, +}; + +export default meta; + +export const Default: StoryObj = { + args: { + // minimum required props with sensible values from JSDoc @example + }, +}; +``` + +Add additional named story exports for each meaningful use case found in JSDoc `@example` tags: + +```tsx +export const WithDisabledState: StoryObj = { + args: { + // ... + disabled: true, + }, +}; +``` + +Use `render: () => (...)` instead of `args` when the story requires composition (e.g. sub-components, wrappers). + +--- + +## Step 8: Write `stories/[COMPONENT_NAME]/[COMPONENT_NAME].mdx` + +Use this template: + +````mdx +import { Canvas, Meta } from "@storybook/blocks"; +import { Heading3, Secondary, Title } from "../storybook-common"; +import * as ComponentNameStories from "./[component-name].stories"; +import { PropsTableTabs } from "./props-table"; + + + +Component Name + +Overview + +One or two sentences from the interface JSDoc. + +```tsx +import { ComponentName } from "@lifesg/react-design-system/[component-name]"; +``` + + + + + +Story title derived from export name + +Brief sentence explaining what this variant demonstrates. + + + +Component API + + +```` + +Rules: + +- `` is defined in `props-table.tsx` (Step 6) and renders all tabs internally. +- All tabs use `` sourced from `generated-props.ts` — **no ``**. +- HTML-inherited props (from React/DOM types) are excluded automatically by the generator. +- The import path in the code snippet must use the published package path: `@lifesg/react-design-system/[component-name]`. + +--- + +## Step 9: Verify + +After creating the files, confirm: + +- `stories/[COMPONENT_NAME]/generated-props.ts` exists and has a `Data` export for the primary interface and each public sub-interface (run `npm run props:generate` if missing) +- `props-table.tsx` exports `PropsTableTabs` using `` for all tabs — no `ArgTypes` imports +- `[component].stories.tsx` imports from `src/[component-name]` (webpack alias), not relative paths +- MDX `import` paths use relative paths (`./`, `../storybook-common`) +- Story export names are PascalCase and match the `Canvas` references in MDX + +--- + +## Output Report + +List: + +- Files created or modified +- Story exports written (names) +- Sub-component interface tabs added to `PropTableTabs` +- Any props skipped in `argTypes` and why diff --git a/.github/prompts/move-api-table-data-to-type.prompt.md b/.github/prompts/move-api-table-data-to-type.prompt.md deleted file mode 100644 index c4713a973..000000000 --- a/.github/prompts/move-api-table-data-to-type.prompt.md +++ /dev/null @@ -1,287 +0,0 @@ ---- -mode: agent ---- - -# Copy Props Table Descriptions to JSDoc Comments - -You will copy property descriptions from Storybook props tables (`stories/**/props-table.tsx`) to JSDoc comments in the actual type definitions (`src/**/types.ts`). - -## Overview - -Almost all React components in this repo have a `props-table.tsx` file in their stories folder. These tables contain detailed descriptions of component props that should be reflected in the actual TypeScript type definitions to enable IDE IntelliSense. - -## Task Requirements - -For the specified component(s): - -1. **Locate the MDX file**: Find `stories/{component}/{component}.mdx` and extract relevant component documentation -2. **Locate the props table**: Find `stories/{component}/props-table.tsx` -3. **Extract descriptions**: Parse the `DATA: ApiTableSectionProps[]` array to get all property descriptions -4. **Find type definitions**: Locate the corresponding type file(s), typically at `src/{component}/types.ts` -5. **Update JSDoc comments**: - - Add/replace the JSDoc comment above the main component Props interface with a comprehensive description from the MDX file - - Add/replace JSDoc comments above each property in all type definitions - -## Extracting Component Description from MDX - -The component description should be extracted from the MDX file and composed into a concise, informative JSDoc comment. - -### Primary Description - -The primary description **usually** appears immediately after `Overview`. This is the core description of what the component does. - -Example from `data-table.mdx`: - -``` -Organises a collection of data into readable rows. -``` - -### Additional High-Level Information - -Look for additional sections in the MDX file that provide high-level understanding of the component's capabilities, structure, or key features. Include relevant information that would be useful to know when using the component. - -**Include information about:** - -- Component anatomy/structure (e.g., Tab has `Tab` and `Tab.Item` sub-components) -- Key capabilities mentioned in section headings (e.g., DataTable supports sort indicators, multi-selection, action bars, alternating rows) -- Important behavioral notes or modes (e.g., controlled vs uncontrolled modes) - -**Exclude:** - -- Implementation examples (code snippets) -- Detailed usage instructions better suited for documentation -- Warnings or notes about specific edge cases -- Information that's already clear from prop descriptions - -**Rewording for JSDoc:** -Keep the primary description the same, and rephrase additional information to fit a JSDoc style. -Keep descriptions concise and reword to suit a JSDoc format. Focus on what the component is and what it can do at a high level. - -**Examples:** - -For DataTable (combining overview + feature headings): - -```typescript -/** - * Organises a collection of data into readable rows. - * - * Supports sort indicators, multi-selection with checkboxes, action bars, - * alternating row colors, loading states, and custom empty views. - */ -export interface DataTableProps { -``` - -For Tab (combining overview + anatomy): - -```typescript -/** - * Used to organise content into multiple panes. Users can toggle between - * different tabs to view different categories of information. - * - * Comprises of `Tab` (main component with selectors) and `Tab.Item` - * (wrapper for tab content). Supports controlled mode for custom behavior. - */ -export interface TabProps { -``` - -## Props Table Structure - -Props tables use `ApiTableSectionProps[]` where: - -- Each section may have an optional `name` field indicating a specific type (e.g., "HeaderItemProps", "RowProps") -- If `name` is absent, the section describes the main component props -- Each section contains `attributes` with: - - `name`: The property name - - `description`: The description to copy (may be string or JSX) - - `propTypes`: Type information (optional) - - `defaultValue`: Default value (optional) - - `mandatory`: Whether the prop is required (optional) - -## Handling Multiple Sections - -Props tables may document multiple related types in one file. For example, `data-table/props-table.tsx` contains sections for: - -- Main component props (section without a name, or named after the component) -- `HeaderItemProps` (sub-type section) -- `RowProps` (sub-type section) - -Match each section to its corresponding type definition by: - -1. If section has a `name`, find the matching type/interface with that exact name -2. If section has no `name`, it describes the main component props (e.g., `DataTableProps`) - -## JSDoc Format - -### Component Interface JSDoc - -Add a comprehensive component description above the main Props interface by combining the overview and relevant high-level information from the MDX file. End with a list of search keywords: - -```typescript -/** - * Primary description of what the component does. - * - * Additional relevant information about capabilities, structure, or modes. - * - * Keywords: keyword1, keyword2, keyword3 - */ -export interface ComponentNameProps { - // properties... -} -``` - -**Search Keywords Guidelines:** - -- Include alternative names or common terms users might search for (e.g., "dropdown" for Select Input) -- Include related UI patterns or concepts -- Keep the list short (3-6 keywords typically) -- Focus on terms NOT already in the component name - -Real-world example for DataTable: - -```typescript -/** - * Organises a collection of data into readable rows. - * - * Supports sort indicators, multi-selection with checkboxes, action bars, - * alternating row colors, loading states, and custom empty views. - * - * Keywords: table, grid, list, rows, columns - */ -export interface DataTableProps { - // properties... -} -``` - -Real-world example for Tab: - -```typescript -/** - * Used to organise content into multiple panes. Users can toggle between - * different tabs to view different categories of information. - * - * Comprises of `Tab` (main component with selectors) and `Tab.Item` - * (wrapper for tab content). Supports controlled mode for custom behavior. - * - * Keywords: tabs, tabbed, navigation, panels, switcher - */ -export interface TabProps { - // properties... -} -``` - -Real-world example for InputSelect: - -```typescript -/** - * Allows users to select a single option from a list. - * - * Keywords: dropdown, select, picker, combobox, chooser - */ -export interface InputSelectProps { - // properties... -} -``` - -### Property JSDoc - -Convert property descriptions to JSDoc comments: - -```typescript -/** Description text here */ -propertyName?: Type | undefined; -``` - -For multi-line descriptions or descriptions with special formatting: - -```typescript -/** - * Description line 1 - * Description line 2 - */ -propertyName?: Type | undefined; -``` - -## Special Cases - -- **JSX Descriptions**: Convert JSX elements to plain text, preserving key information: - - - `value` → `` `value` `` - - HTML entities → appropriate Unicode or text - - Links → Keep URL in parentheses if relevant - - Formatting → Use markdown where appropriate - -- **Empty name**: Some attributes have `name: ""` with general information. Include this in a comment above the interface/type if relevant. - -- **Default values**: If `defaultValue` is provided in the table, append it to the description: `(default: "value")` - -- **Mandatory fields**: Note this if marked in the props table - -- **Existing JSDoc**: Replace existing JSDoc comments with the description from the props table, even if JSDoc already exists - -## Type Location - -Types may not all be defined in `src/{component}/types.ts`. They can be: - -- In the main component's types file -- In shared type files (e.g., `src/shared/types.ts`) -- In related component folders -- Imported from other modules - -Search across the codebase to find the correct type definitions. Use semantic search or grep to locate interfaces and types by name. - -## Constraints - -1. **DO NOT** modify the props table files themselves -2. **DO** update type definition files in `src/` (wherever they are located) -3. **DO** preserve all existing type definitions, only add/update JSDoc comments -4. **DO** maintain proper TypeScript syntax -5. **DO** handle all sections in a props table, not just the main component props - -## Output Requirements - -Provide a **concise final report** with: - -### 1. Updated Types - -List each type/interface that was updated with a clickable file link: - -Format: `[TypeName](file:///absolute/path/to/file.ts)` - -Example output: - -``` -- [DataTableProps](file:///Users/hieu/Sources/react-design-system/src/data-table/types.ts) -- [HeaderItemProps](file:///Users/hieu/Sources/react-design-system/src/data-table/types.ts) -``` - -### 2. Unprocessed Descriptions - -List descriptions that could not be copied because no matching type/property was found: - -Format: - -- **Section**: `SectionName` (or "Main props") - - `propertyName`: Description text... (Reason: type/property not found in codebase) - -Keep this section empty if all descriptions were successfully processed. - -## Example Input - -Component(s) to process: `data-table`, `button` - -## Example Workflow - -For `data-table`: - -1. Read `stories/data-table/data-table.mdx`: - - Extract overview: "Organises a collection of data into readable rows." - - Identify key features from headings: sort indicators, multi-selection, action bars, alternating rows, loading states, empty views - - Compose comprehensive JSDoc description -2. Read `stories/data-table/props-table.tsx` -3. Parse sections: Main props (unnamed), HeaderItemProps, RowProps -4. Read `src/data-table/types.ts` -5. Add comprehensive overview JSDoc to `DataTableProps` interface -6. Update `DataTableProps` properties with descriptions from unnamed section -7. Update `HeaderItemProps` with descriptions from "HeaderItemProps" section -8. Update `RowProps` with descriptions from "RowProps" section -9. Report any mismatches diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index a7be990bd..2cfff10f9 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,14 +1,4 @@ -**Changes** -Description of changes... +Please go to the `Preview` tab and select the appropriate sub-template: -- [keep | delete] branch - - -**Changelog entry** - -- [BREAKING | WARNING | remove if none] Description of change in `Component-name` - -**Additional information** - -- You may refer to this [ticket](url) -- Any other information you would like to include +- **[Default](?expand=1&template=default.md)** - for general enhancements or changes +- **[V4 Component Migration](?expand=1&template=v4_component.md)** - for component-level Linaria migration in V4 diff --git a/.github/workflows/trigger-gitlab-pipeline.yml b/.github/workflows/trigger-gitlab-pipeline.yml index 214e584cb..bed82629b 100644 --- a/.github/workflows/trigger-gitlab-pipeline.yml +++ b/.github/workflows/trigger-gitlab-pipeline.yml @@ -6,6 +6,7 @@ on: - master - "release/**" - "!release/**-published" + - "pre-release/**" pull_request: types: [opened, synchronize, reopened] @@ -63,38 +64,56 @@ jobs: GITLAB_PAT: ${{ secrets.GITLAB_PAT }} GITLAB_ENDPOINT: ${{ vars.GITLAB_ENDPOINT }} GITLAB_PROJECT_ID: ${{ vars.GITLAB_PROJECT_ID }} - SLEEP_DURATION_MINUTES: 15 + SLEEP_DURATION_MINUTES: 20 CHECK_INTERVAL_MINUTES: 5 + MAX_RETRY_ATTEMPTS: 8 DOWNSTREAM_PIPELINE_NAME: library DOWNSTREAM_JOB_NAME: build-dist-job run: | echo "Pipeline ID: $PIPELINE_ID" - echo "[ Running pipline check after $SLEEP_DURATION_MINUTES minutes ]" + echo "[ Running pipeline check after $SLEEP_DURATION_MINUTES minutes ]" sleep $(($SLEEP_DURATION_MINUTES*60)) num_attempts=1 job_status="" - until [[ $num_attempts -gt 5 || $job_status == "success" || $job_status == "failed" || $job_status == "canceled" || $job_status == "skipped" ]] + until [[ $num_attempts -gt $MAX_RETRY_ATTEMPTS || $job_status =~ ^(success|failed|canceled|skipped)$ ]] do echo "Attempt: $num_attempts" - downstream_pipeline_id=$(curl -sS --request GET --header "PRIVATE-TOKEN: $GITLAB_PAT" \ - --url "$GITLAB_ENDPOINT/$GITLAB_PROJECT_ID/pipelines/$PIPELINE_ID/bridges" \ - | jq -r '[ .[] | select( .name | contains("'"$DOWNSTREAM_PIPELINE_NAME"'")) ]' | jq -r '.[].downstream_pipeline.id') + pipeline_result=$(curl -sS --request GET --header "PRIVATE-TOKEN: $GITLAB_PAT" \ + --write-out "\n%{http_code}" --url "$GITLAB_ENDPOINT/$GITLAB_PROJECT_ID/pipelines/$PIPELINE_ID/bridges") + pipeline_result_body=$(echo "$pipeline_result" | sed '$d') + pipeline_result_code=$(echo "$pipeline_result" | tail -n 1) + + if [[ pipeline_result_code -eq 401 ]]; then + echo "Please check if the GITLAB_PAT has sufficient permissions, or regenerate it if it has expired" + exit 1 + fi + + downstream_pipeline_id=$(echo "$pipeline_result_body" | jq -r '[ .[] | select( .name | contains("'"$DOWNSTREAM_PIPELINE_NAME"'")) ]' | jq -r '.[].downstream_pipeline.id') echo "($DOWNSTREAM_PIPELINE_NAME) Downstream pipeline id: $downstream_pipeline_id" - job_status=$(curl -sS --header "PRIVATE-TOKEN: $GITLAB_PAT" \ - --url "$GITLAB_ENDPOINT/$GITLAB_PROJECT_ID/pipelines/$downstream_pipeline_id/jobs" \ - | jq -r '[ .[] | select( .name | contains("'"$DOWNSTREAM_JOB_NAME"'")) ]' | jq -r '.[].status') + job_status_result=$(curl -sS --header "PRIVATE-TOKEN: $GITLAB_PAT" \ + --write-out "\n%{http_code}" --url "$GITLAB_ENDPOINT/$GITLAB_PROJECT_ID/pipelines/$downstream_pipeline_id/jobs") + job_status_result_body=$(echo "$job_status_result" | sed '$d') + job_status_result_code=$(echo "$job_status_result" | tail -n 1) + + if [[ job_status_result_code -eq 401 ]]; then + echo "Please check if the GITLAB_PAT has sufficient permissions, or regenerate it if it has expired" + exit 1 + fi + + job_status=$(echo "$job_status_result_body" | jq -r '[ .[] | select( .name | contains("'"$DOWNSTREAM_JOB_NAME"'")) ]' | jq -r '.[].status') echo "($DOWNSTREAM_JOB_NAME) Job status: $job_status" - if [[ $job_status == "running" ]]; then - num_attempts=$((num_attempts+1)) + num_attempts=$((num_attempts+1)) + + if [[ $num_attempts -lt $MAX_RETRY_ATTEMPTS && ! $job_status =~ ^(success|failed|canceled|skipped)$ ]]; then sleep $(($CHECK_INTERVAL_MINUTES*60)) fi done - [[ $num_attempts -gt 5 ]] && echo "Number of retries exceeded, please head to the GitLab job to check what happened" && exit 1 + [[ $num_attempts -gt $MAX_RETRY_ATTEMPTS ]] && echo "Number of retries exceeded, please head to the GitLab job to check what happened" && exit 1 [[ $job_status == "canceled" ]] && echo "GitLab job was cancelled" diff --git a/.gitignore b/.gitignore index 332362f5d..be7545611 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,13 @@ coverage # misc *.DS_Store +# storybook artifacts storybook-static +*storybook.log -junit.xml \ No newline at end of file +# test artifacts +junit.xml +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..e36ebd52d --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22.13 \ No newline at end of file diff --git a/.storybook/decorators/theme-decorator.tsx b/.storybook/decorators/theme-decorator.tsx new file mode 100644 index 000000000..9dcb95ae4 --- /dev/null +++ b/.storybook/decorators/theme-decorator.tsx @@ -0,0 +1,57 @@ +// adapted from https://github.com/storybookjs/storybook/blob/1a0665ccfe179cc3519e8619fe93dbb15c1ef835/code/addons/themes/src/decorators/provider.decorator.tsx +// integrates with `storybook-dark-mode` for dark mode support +import { useDarkMode } from "@storybook-community/storybook-dark-mode"; +import { DecoratorHelpers } from "@storybook/addon-themes"; +import type { DecoratorFunction, Renderer } from "storybook/internal/types"; + +const { initializeThemeState, pluckThemeFromContext } = DecoratorHelpers; + +type Theme = Record; +type ThemeMap = Record; + +export interface ProviderStrategyConfiguration { + Provider?: any; + GlobalStyles?: any; + defaultTheme?: string; + themes?: ThemeMap; +} + +export const withThemeFromJSXProvider = ({ + Provider, + GlobalStyles, + defaultTheme, + themes = {}, +}: ProviderStrategyConfiguration): DecoratorFunction => { + const themeNames = Object.keys(themes); + const initialTheme = defaultTheme || themeNames[0]; + + initializeThemeState(themeNames, initialTheme); + + // eslint-disable-next-line react/display-name + return (storyFn, context) => { + const mode = useDarkMode(); + const selectedTheme = pluckThemeFromContext(context); + const { themeOverride } = context.parameters.themes ?? {}; + + const selected = themeOverride || selectedTheme || initialTheme; + const pairs = Object.entries(themes); + + const theme = pairs.length === 1 ? pairs[0] : themes[selected]; + + if (!Provider) { + return ( + <> + {GlobalStyles && } + {storyFn()} + + ); + } + + return ( + + {GlobalStyles && } + {storyFn()} + + ); + }; +}; diff --git a/.storybook/main.js b/.storybook/main.js deleted file mode 100644 index 37333f231..000000000 --- a/.storybook/main.js +++ /dev/null @@ -1,46 +0,0 @@ -import path from "path"; -import remarkGfm from "remark-gfm"; - -module.exports = { - stories: [ - "../stories/**/!(*.stories).mdx", - "../stories/**/*.stories.@(ts|tsx)", - ], - addons: [ - "@storybook/addon-links", - "@storybook/addon-essentials", - "@storybook/addon-a11y", - "@storybook/addon-themes", - { - name: "@storybook/addon-docs", - options: { - mdxPluginOptions: { - mdxCompileOptions: { - remarkPlugins: [remarkGfm], - }, - }, - }, - }, - { - name: "@storybook/addon-storysource", - options: { - loaderOptions: { - parser: "typescript", - injectStoryParameters: true, - }, - }, - }, - ], - staticDirs: ["../public"], - webpackFinal: async (config) => { - config.resolve.modules = [ - path.resolve(__dirname, ".."), - "node_modules", - ]; - return config; - }, - framework: "@storybook/react-webpack5", - docs: { - autodocs: true, - }, -}; diff --git a/.storybook/main.ts b/.storybook/main.ts new file mode 100644 index 000000000..ede9f5d9e --- /dev/null +++ b/.storybook/main.ts @@ -0,0 +1,51 @@ +import type { StorybookConfig } from "@storybook/react-webpack5"; +import { fileURLToPath } from "node:url"; +import path, { dirname } from "path"; +import remarkGfm from "remark-gfm"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const config: StorybookConfig = { + stories: ["../stories/**/*.mdx", "../stories/**/*.stories.@(ts|tsx)"], + addons: [ + "@storybook/addon-webpack5-compiler-swc", + "@storybook/addon-links", + "@storybook/addon-a11y", + "@storybook/addon-themes", + "@storybook-community/storybook-dark-mode", + { + name: "@storybook/addon-docs", + options: { + mdxPluginOptions: { + mdxCompileOptions: { + remarkPlugins: [remarkGfm], + }, + }, + }, + }, + ], + features: { interactions: false, sidebarOnboardingChecklist: false }, + staticDirs: ["../public"], + webpackFinal: async (config) => { + config.resolve!.modules = [ + path.resolve(__dirname, ".."), + "node_modules", + ]; + return config; + }, + framework: { + name: "@storybook/react-webpack5", + options: {}, + }, + swc: () => ({ + jsc: { + transform: { + react: { + runtime: "automatic", + }, + }, + }, + }), +}; +export default config; diff --git a/.storybook/manager.js b/.storybook/manager.js index 0a990ef82..67e7f46bf 100644 --- a/.storybook/manager.js +++ b/.storybook/manager.js @@ -1,10 +1,12 @@ -import { addons } from "@storybook/manager-api"; +import { addons } from "storybook/manager-api"; addons.setConfig({ sidebar: { filters: { patterns: (item) => { - return !item.tags.includes("pattern"); + return ( + !item.tags.includes("pattern") && !item.tags.includes("e2e") + ); }, }, }, diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html index 66a8e681d..fc2e1f90b 100644 --- a/.storybook/preview-head.html +++ b/.storybook/preview-head.html @@ -1,26 +1,65 @@ + + + + + + + + + + + + + + + diff --git a/.storybook/preview.js b/.storybook/preview.js deleted file mode 100644 index 08f73625e..000000000 --- a/.storybook/preview.js +++ /dev/null @@ -1,50 +0,0 @@ -import { withThemeFromJSXProvider } from "@storybook/addon-themes"; -import { INITIAL_VIEWPORTS } from "@storybook/addon-viewport"; -import { - BaseTheme, - BookingSGTheme, - CCubeTheme, - MyLegacyTheme, - RBSTheme, -} from "../src/theme"; -import { ThemeProvider } from "styled-components"; -import "./custom-code.css"; - -const preview = { - decorators: [ - withThemeFromJSXProvider({ - themes: { - LifeSG: BaseTheme, - BookingSG: BookingSGTheme, - CCube: CCubeTheme, - MyLegacy: MyLegacyTheme, - RBS: RBSTheme, - }, - Provider: ThemeProvider, - }), - ], - parameters: { - options: { - storySort: { - order: [ - "Getting started", - ["Installation", "Themes", "Media Query", "Layout"], - "General", - "Form", - "Data Input", - "Modules", - ], - }, - }, - actions: { disable: true }, - controls: { - disable: false, - }, - layout: "centered", - viewport: { - viewports: INITIAL_VIEWPORTS, - }, - }, -}; - -export default preview; diff --git a/.storybook/preview.ts b/.storybook/preview.ts new file mode 100644 index 000000000..92b6903c2 --- /dev/null +++ b/.storybook/preview.ts @@ -0,0 +1,101 @@ +import { INITIAL_VIEWPORTS } from "storybook/viewport"; +import type { Preview } from "@storybook/react-webpack5"; +import { ThemeProvider } from "styled-components"; +import { + A11yPlaygroundTheme, + BookingSGTheme, + CCubeTheme, + IMDATheme, + LifeSGTheme, + MyLegacyTheme, + OneServiceTheme, + PATheme, + RBSTheme, + SPFTheme, + SupportGoWhereTheme, + SGWDigitalLobbyTheme, +} from "../src/theme"; +import { withThemeFromJSXProvider } from "./decorators/theme-decorator"; + +const preview: Preview = { + decorators: [ + withThemeFromJSXProvider({ + themes: { + LifeSG: LifeSGTheme, + BookingSG: BookingSGTheme, + CCube: CCubeTheme, + MyLegacy: MyLegacyTheme, + OneService: OneServiceTheme, + PA: PATheme, + SupportGoWhere: SupportGoWhereTheme, + SGWDigitalLobby: SGWDigitalLobbyTheme, + A11yPlayground: A11yPlaygroundTheme, + IMDA: IMDATheme, + RBS: RBSTheme, + SPF: SPFTheme, + }, + Provider: ThemeProvider, + }), + ], + parameters: { + options: { + storySort: { + order: [ + "Getting started", + ["Installation", "Themes", "Media Query", "Layout"], + "Foundations", + [ + "Introduction", + "Themes", + ["Introduction", "Advanced Usage", "Dark Mode", "*"], + "Colours", + "Font", + "Breakpoint", + "Spacing", + "Motion", + "Radius", + "Border", + ], + "Core", + ["Typography", "Layout", "Icon"], + "Content", + "Navigation", + "Selection and input", + ["Button", ["Base", "With Icon"]], + "Feedback indicators", + "Overlays", + "General", + ["Animations", "*"], + "Form", + "Data Input", + "Modules", + "V2", + [ + "Introduction", + "Themes", + "Color", + "Text", + "Media Query", + "Layout", + ], + ], + }, + }, + actions: { disable: true }, + controls: { disable: true }, + layout: "centered", + viewport: { + options: INITIAL_VIEWPORTS, + }, + darkMode: { + stylePreview: true, + darkClass: "storybook-dark-mode", + lightClass: "storybook-light-mode", + }, + docs: { + codePanel: true, + }, + }, +}; + +export default preview; diff --git a/.vscode/settings.json b/.vscode/settings.json index d53376114..245bb14ab 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,10 +1,20 @@ { + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + }, "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.formatOnSave": true, "eslint.options": { - "extensions": [".md", ".mdx"] + "overrideConfigFile": "eslint.config.mjs" }, - "eslint.validate": ["markdown", "mdx"], + "eslint.validate": [ + "javascript", + "javascriptreact", + "markdown", + "mdx", + "typescript", + "typescriptreact" + ], "auto-close-tag.activationOnLanguage": [ "html", "javascriptreact", @@ -12,5 +22,9 @@ "mdx" ], "editor.rulers": [80], - "typescript.preferences.importModuleSpecifier": "relative" + "typescript.preferences.importModuleSpecifier": "relative", + "typescript.tsdk": "node_modules/typescript/lib", + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + } } diff --git a/AGENTS.md b/AGENTS.md index 30d86e4be..ce2239e94 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,79 +13,79 @@ applyTo: "**/*" ### Source Code -- `src/` - Component library source code (70+ UI components) - - Each component in its own directory: `button/`, `input/`, `modal/`, etc. - - Component structure: `component-name.tsx`, `component-name.style.tsx`, `types.ts`, `index.ts` - - `theme/` - Theme definitions and design tokens - - `design-token/` - Design token definitions and types - - `color/` - Color system and palette definitions - - `layout/` - Layout components (Container, Content, Section, ColDiv) - - `text/` - Text component and typography utilities - - `media/` - Responsive media query utilities - - `util/` - Shared utility functions - - `shared/` - Shared component building blocks - - `animations/` - Animation utilities and presets - - `__mocks__/` - Mock implementations for testing - - `index.ts` - Main library entry point +- `src/` - Component library source code (70+ UI components) + - Each component in its own directory: `button/`, `input/`, `modal/`, etc. + - Component structure: `component-name.tsx`, `component-name.style.tsx`, `types.ts`, `index.ts` + - `theme/` - Theme definitions and design tokens + - `design-token/` - Design token definitions and types + - `color/` - Color system and palette definitions + - `layout/` - Layout components (Container, Content, Section, ColDiv) + - `text/` - Text component and typography utilities + - `media/` - Responsive media query utilities + - `util/` - Shared utility functions + - `shared/` - Shared component building blocks + - `animations/` - Animation utilities and presets + - `__mocks__/` - Mock implementations for testing + - `index.ts` - Main library entry point ### Build & Configuration -- `rollup.config.js` - Main build configuration (ESM + CJS outputs, per-component bundles) -- `rollup.check.config.js` - Build verification configuration -- `tsconfig.json` - TypeScript compiler settings -- `babel.config.js` - Babel transpilation for tests -- `jest.config.js` - Jest test runner configuration -- `scripts/` - Build automation scripts - - `build-util.js` - Rollup helper utilities - - `post-build.js` - Post-build processing (package.json generation) - - `build.sh` - Build orchestration script - - `ci.sh` - CI pipeline script +- `rollup.config.js` - Main build configuration (ESM + CJS outputs, per-component bundles) +- `rollup.check.config.js` - Build verification configuration +- `tsconfig.json` - TypeScript compiler settings +- `babel.config.js` - Babel transpilation for tests +- `jest.config.js` - Jest test runner configuration +- `scripts/` - Build automation scripts + - `build-util.js` - Rollup helper utilities + - `post-build.js` - Post-build processing (package.json generation) + - `build.sh` - Build orchestration script + - `ci.sh` - CI pipeline script ### Documentation & Development -- `stories/` - Storybook documentation for all components -- `.storybook/` - Storybook configuration and addons -- `docs/` - Additional documentation and templates -- `public/` - Static assets for Storybook (images, etc.) -- `custom-types/` - TypeScript declaration files for non-TS imports (CSS, SVG, images, MDX) +- `stories/` - Storybook documentation for all components +- `.storybook/` - Storybook configuration and addons +- `docs/` - Additional documentation and templates +- `public/` - Static assets for Storybook (images, etc.) +- `custom-types/` - TypeScript declaration files for non-TS imports (CSS, SVG, images, MDX) ### Testing -- `tests/` - Component unit tests organized by component name +- `tests/` - Component unit tests organized by component name ### Project Management -- `README.md` - Installation, usage, and quick start guide -- `CONTRIBUTING.md` - Component contribution guidelines and structure -- `CHANGELOG.md` - Version history and breaking changes -- `CONVENTIONS.md` - Project-specific conventions -- `CODEOWNERS.md` - Code ownership assignments -- `LICENSE.md` - ISC license -- `package.json` - Dependencies, peer deps, and npm scripts +- `README.md` - Installation, usage, and quick start guide +- `CONTRIBUTING.md` - Component contribution guidelines and structure +- `CHANGELOG.md` - Version history and breaking changes +- `CONVENTIONS.md` - Project-specific conventions +- `CODEOWNERS.md` - Code ownership assignments +- `LICENSE.md` - ISC license +- `package.json` - Dependencies, peer deps, and npm scripts ## Build & Run ### Development -- `npm run storybook` - Start Storybook dev server on port 6006 -- `npm run build` - Build library for distribution (ESM + CJS formats) -- `npm run build-check` - Verify build configuration -- `npm start` - Build in watch mode +- `npm run storybook` - Start Storybook dev server on port 6006 +- `npm run build` - Build library for distribution (ESM + CJS formats) +- `npm run build-check` - Verify build configuration +- `npm start` - Build in watch mode ### Package Distribution -- `npm run pack-package` - Create tarball for testing -- `npm run publish-lib` - Publish to npm registry (from `dist/` directory) +- `npm run pack-package` - Create tarball for testing +- `npm run publish-lib` - Publish to npm registry (from `dist/` directory) ## Testing Approach ### Unit & Integration Tests -- **Framework**: Jest + React Testing Library + jest-styled-components -- **Location**: `tests//.spec.tsx` -- **Run**: `npm test` (with coverage) or `npm run test-watch` -- **Coverage**: Reports generated in `tests/coverage/` -- **Canvas mock**: jest-canvas-mock for components using canvas +- **Framework**: Jest + React Testing Library + jest-styled-components +- **Location**: `tests//.spec.tsx` +- **Run**: `npm test` (with coverage) or `npm run test-watch` +- **Coverage**: Reports generated in `tests/coverage/` +- **Canvas mock**: jest-canvas-mock for components using canvas ## Component Architecture @@ -103,25 +103,56 @@ component-name/ ### Styling Approach -- **Styled Components** for all styling -- Theme-aware via `ThemeProvider` integration -- Design tokens from `theme/` and `design-token/` -- Color system from `color/` -- Responsive utilities from `media/` +- **Styled Components** for all styling +- Theme-aware via `ThemeProvider` integration +- Design tokens from `theme/` and `design-token/` +- Color system from `color/` +- Responsive utilities from `media/` ### Key Dependencies -- **Required Peer Dependencies**: React, React DOM, Styled Components, @lifesg/react-icons -- **Notable Dependencies**: - - `react-spring` - Animation library - - `@floating-ui/react` & `@floating-ui/dom` - Positioning engine for tooltips and popovers - - `react-slider` - Slider component utilities - - `react-zoom-pan-pinch` - Image zoom/pan functionality - - `immer` - Immutable state management +- **Required Peer Dependencies**: React, React DOM, Styled Components, @lifesg/react-icons +- **Notable Dependencies**: + - `react-spring` - Animation library + - `@floating-ui/react` & `@floating-ui/dom` - Positioning engine for tooltips and popovers + - `react-slider` - Slider component utilities + - `react-zoom-pan-pinch` - Image zoom/pan functionality + - `immer` - Immutable state management ## Storybook Documentation -- Stories colocated in `stories//` -- Addons: a11y, docs, interactions, themes, storysource -- Accessibility testing built into development workflow -- Theme switching support for light/dark mode testing +- Stories colocated in `stories//` +- Addons: a11y, docs, links, themes +- Accessibility testing built into development workflow +- Theme switching support for light/dark mode testing + +## AI Component Catalog & Agent Skills + +The library ships a machine-readable component catalog and a Copilot skill file generator that provides per-component usage guidance to GitHub Copilot in any consuming repo. + +- **`component-catalog.json`** - Machine-readable metadata for all 75 components (props, descriptions, search keys, design tokens) +- **`scripts/generate-component-catalog.js`** - Static analysis script that produces `component-catalog.json` from source files +- **`scripts/generate-component-instructions.js`** - Copies the static `.github/instructions/design-system-components.instructions.md` into a consuming repo's `.github/instructions/` folder. Runs automatically as a `postinstall` hook when the package is installed. +- **`.github/instructions/design-system-components.instructions.md`** - Static Copilot instruction file committed to this repo and bundled in dist. Points Copilot to `component-catalog.json` in node_modules. + +The catalog is regenerated by `npm run build`. The instructions file is a static committed file. + +### Component Development Workflow + +| Step | Tool | +| -------------------------------------- | -------------------------------------------------------------------------------- | +| Write component + `types.ts` | _(manual)_ | +| Generate JSDoc (new component) | `.github/prompts/generate-component-jsdoc.prompt.md` | +| Copy props-table descriptions to JSDoc | `.github/prompts/move-api-table-data-to-type.prompt.md` | +| Write Storybook story | `.github/prompts/cc-update-storybook.prompt.md` | +| Build + publish | `npm run build` — catalog regenerates automatically; instructions file is static | + +### Catalog Scripts + +| Script | Purpose | +| -------------------------- | --------------------------------------------------------- | +| `npm run build` | Full build: compiles, generates catalog, packages dist | +| `npm run catalog:generate` | Manually re-generate `component-catalog.json` from source | +| `npm run catalog:check` | Verify catalog is up to date (CI) | + +For full details on the catalog system, cross-repo usage, and how to improve skill quality, see [docs/ai-catalog.md](docs/ai-catalog.md). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a6e17cbb9..e5575c762 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -112,6 +112,94 @@ A suggested folder structure is as such: └── types.ts ``` + +
+ +### **4.1. Documenting components** + +All component props must be documented using JSDoc comments to enable automatic documentation generation in Storybook. + +#### **JSDoc Requirements** + +**For component types** (`types.ts`): + +1. Add interface-level JSDoc with component overview and usage examples +2. Document every public prop with: + - Brief description (first line, under 80 characters) + - Detailed explanation of when/how to use + - `@default` tag for default values + - `@example` tag for complex or non-obvious usage + +**Example:** + +````typescript +/** + * Props for the Button component - primary call-to-action element + * + * Use buttons to trigger immediate actions like form submissions or dialog confirmations. + * Choose the appropriate style type based on action hierarchy. + * + * @example + * ```tsx + * Submit + * Save + * ``` + */ +export interface ButtonProps + extends React.ButtonHTMLAttributes { + /** + * The visual style variant of the button + * + * - `default`: Primary blue button for main CTAs and form submissions + * - `secondary`: Outlined button for secondary actions with less emphasis + * - `light`: Minimal button with transparent background for tertiary actions + * - `link`: Text-only button styled as a hyperlink + * + * @default "default" + */ + styleType?: "default" | "secondary" | "light" | "link"; +} +```` + +#### **Story Tags and Keywords** + +**For component stories** (`.stories.tsx`): + +Add tags and keywords to enable component discovery: + +```typescript +const meta: Meta = { + title: "Category/ComponentName", + component: ComponentName, + tags: ["autodocs", "forms", "input", "validation", "stable"], + parameters: { + docs: { + description: { + component: + "Brief description of the component and its purpose.", + }, + }, + keywords: ["search", "terms", "for", "discoverability"], + }, +}; +``` + +#### **Documentation Standards** + +See [`docs/jsdoc-standards.md`](/docs/jsdoc-standards.md) for detailed guidelines and examples. + +See [`docs/component-taxonomy.md`](/docs/component-taxonomy.md) for tag and keyword conventions. + +#### **Linting** + +Run documentation linting before committing: + +```bash +npm run lint:docs +``` + +This checks that all public props have JSDoc descriptions. +
diff --git a/CONVENTIONS_V4.md b/CONVENTIONS_V4.md new file mode 100644 index 000000000..40c4b6451 --- /dev/null +++ b/CONVENTIONS_V4.md @@ -0,0 +1,332 @@ +# Agreed Conventions + +## Code structure + +To enable ease of understanding and consistency, we recommend following the +structure as such: + +> Note: Add headers to the respective sections + +```tsx +// component-file.tsx + +// Import statements here +/** + * When importing other components, use the relative import path + * to prevent circular dependency issues. + * E.g. import { Text } from "../text/text"; + */ + +/** + * For local props. If the props are to be exported + * add them to types.ts + */ +interface Props { + a: string; +} + +/** + * Refrain from typing React.FC + */ +export const MyComponent = ({ a }: Props) => { + // ========================================================================= + // CONST, STATE, REF + // ========================================================================= + + // ========================================================================= + // EFFECTS + // ========================================================================= + + /** + * When adding event listeners, remember to remove them as well + */ + useEffect(() => { + document.addEventListener("keydown", handleKeyDown); + + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, []); + + /** + * Make sure to add all dependencies to hooks + */ + useEffect(() => { + fetchItems(page); + }, [page]); + + /** + * A polyfill for `useEffectEvent` (React 19+) is available + */ + const fireShowEvent = useEvent(() => { + onOpen?.(); + }); + + useEffect(() => { + if (show) { + fireShowEvent(); + } + }, [show, fireShowEvent]); + + // ========================================================================= + // EVENT HANDLERS + // ========================================================================= + + /** + * Name event handlers using `handle` prefix and the name of the action after. + * E.g. handleClick, handleChange + */ + + const handleClick = (event: React.KeyboardEvent) => { + // do something... + }; + + // ========================================================================= + // HELPER FUNCTIONS + // ========================================================================= + + /** + * If the helper function can be extracted (i.e. doesnt rely on any internal + * constant/state value), then consider putting it outside of the component + * instead + */ + + // ========================================================================= + // RENDER FUNCTIONS + // ========================================================================= + /** + * We recommend doing complex rendering in a render function + * of its own for ease of maintenance + */ + const renderItems = () => { + // Map or complex render logic + }; + + /** + * Remember to pass down standard html element props to the inner component + * especially `className` to allow for external styling to be applied + */ + + return ( + + {renderItems()} + + ); +}; +``` + +## Prop specification + +Here are some guidelines on prop specification: + +- For callbacks, denote with `on` and the action. E.g. `onClick`, `onDismiss` +- For optional props, use a Union type and add `undefined` to it + +```tsx +interface MyComponentProps { + show?: boolean | undefined; + onChange?: ((param) => void) | undefined; +} +``` + +- Avoid usage of enums to ease developer use. Opt for string literals instead + and do them in kebab-case + +```tsx +interface MyComponentProps { + someType: "default" | "light" | "dark"; +} +``` + +- Make sure you specify common props like `id`, `className`, `data-testid`, + and `style` if you are not extending from standard HTML element props +- Breakdown complex props into their own types too +- Extend props whenever possible to avoid rewriting similar props +- Ensure that public component types are uniquely named to avoid collision + +## Usage of useState + +We recommend that the use of state should be kept minimal unless it is meant for +rendering purposes. + +## Styling practices + +### Use design tokens + +Avoid hardcoding CSS properties, use design tokens to get theming capabilities. + +Example: + +```tsx +// Wrong +const styledDiv = css` + font-size: 2.5rem; + font-weight: 100; + background: #edefef; +`; + +// Correct +const styledDiv = css` + ${Font["heading-xxl-light"]} + background: ${Colour["bg-strong"]}; +`; +``` + +### No `styled` helpers + +Do not use Linaria `styled` helpers to avoid accidental function interpolation, +as that does not work with strict CSP configs. Use `css` and `cx` for +conditional styling. + +Example: + +```tsx +// Correct + +/** component.tsx */ +return
; +``` + +### No nested classes + +We should refrain from using nested `className`. Create the corresponding `css` +helpers instead and give them sensible names. + +Example: + +```tsx +// Wrong + +/** component.styles.tsx */ +const wrapper = css` + .label { + // styles here... + } + + .description { + // styles here... + } +`; + +/** component.tsx */ +return ( +
+ + Lorem ipsum dolar sit amet... +
+); +``` + +```tsx +// Correct + +/** component.styles.tsx */ +const wrapper = css` + // styles here... +`; + +const label = css` + // styles here... +`; + +const description = css` + // styles here... +`; + +/** component.tsx */ +return ( +
+ + Lorem ipsum dolar sit amet... +
+); +``` + +### Handling dynamic styles from props + +Avoid setting the `style` attribute as it could be blocked with strict CSP +configs. Set CSS variables through Javascript. Variable names should be +formatted as `fds---` to ensure uniqueness. + +Example: + +```tsx +// Wrong + +/** component.tsx */ +const MyComponent = ({ textColour, ...otherProps }) => { + return
; +}; +``` + +```tsx +// Correct + +/** component.styles.tsx */ +const wrapper = css` + /* make sure to reset the variable */ + --fds-myComponent-wrapper-textColour: initial; + color: var(--fds-myComponent-wrapper-textColour); +` + +/** component.tsx */ +const MyComponent = ({ + textColour, + ...otherProps +}) => { + const ref = useRef(null); + + useLayoutEffect(() => { + const element = ref.current; + if (!element) return; + + element.style.setProperty("--fds-myComponent-wrapper-textColour", ...); + }, [textColour]); + + return
+} +``` + +## Implementation logics + +In cases when you are conditionally rendering based props with multiple variants +or types, we recommend to use `switch-case` rather than `if-else` to ensure all +variations are covered. + +Example: + +```tsx +interface Demo { + variant?: "a" | "b" | "c" | "d" | undefined; +} + +/** In the conditional logic */ + +// Wrong + +if (variant === "a") { + // do something +} else if (variant === "b") { + // do something +} else if (variant === "c") { + // do something +} else if (variant === "d") { + // do something +} else { + // do something +} + +// Correct +switch (variants) { + case "a": + // do something + case "b": + // do something + case "c": + // do something + case "d": + // do something + default: + // do something +} +``` diff --git a/README.md b/README.md index f22d60304..a20d944ed 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,18 @@ A React component library for LifeSG and BookingSG related products. npm i @lifesg/react-design-system ``` +### Peer dependencies + +```json +{ + "@floating-ui/react": ">=0.26.23 <1.0.0", + "@lifesg/react-icons": "^1.5.0", + "react": "^17.0.2 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.2 || ^18.0.0 || ^19.0.0", + "styled-components": "^6.1.19" +} +``` +
## Getting Started diff --git a/codemods/codemod-utils.ts b/codemods/codemod-utils.ts new file mode 100644 index 000000000..ccd7bdcd3 --- /dev/null +++ b/codemods/codemod-utils.ts @@ -0,0 +1,159 @@ +import { + API, + ASTPath, + Collection, + JSCodeshift, + MemberExpression, +} from "jscodeshift"; + +export namespace CodemodUtils { + export function hasImport( + source: Collection, + api: API, + importPath: string | string[], + importSpecifier: string + ) { + const j: JSCodeshift = api.jscodeshift; + + let imported = false; + source.find(j.ImportDeclaration).forEach((path) => { + const currentImportPath = path.node.source.value as string; + + if (Array.isArray(importPath)) { + if (!importPath.includes(currentImportPath)) { + return; + } + } else { + if (currentImportPath !== importPath) { + return; + } + } + + path.node.specifiers?.forEach((specifier) => { + if ( + j.ImportSpecifier.check(specifier) && + specifier.imported.name === importSpecifier + ) { + imported = true; + } + }); + }); + + return imported; + } + + export function addImport( + source: Collection, + api: API, + importPath: string, + importSpecifier: string + ) { + const j: JSCodeshift = api.jscodeshift; + + const hasExistingImport = + source.find(j.ImportDeclaration, { + source: { value: importPath }, + }).length > 0; + + if (hasExistingImport) { + // Append the import + source.find(j.ImportDeclaration).forEach((path) => { + const currentImportPath = path.node.source.value; + + if (currentImportPath === importPath) { + const hasSpecifier = + j(path).find(j.ImportSpecifier, { + imported: { name: importSpecifier }, + }).length > 0; + + if (!hasSpecifier) { + // Add to existing import + path.node.specifiers?.push( + j.importSpecifier(j.identifier(importSpecifier)) + ); + } + } + }); + } else { + // Add the import + const newImportDeclaration = j.importDeclaration( + [j.importSpecifier(j.identifier(importSpecifier))], + j.literal(importPath) + ); + + source.get().node.program.body.unshift(newImportDeclaration); + } + } + + export function removeImport( + source: Collection, + api: API, + importPath: string | string[], + importSpecifier: string + ) { + const j: JSCodeshift = api.jscodeshift; + + source.find(j.ImportDeclaration).forEach((path) => { + const currentImportPath = path.node.source.value as string; + + if (Array.isArray(importPath)) { + if (!importPath.includes(currentImportPath)) { + return; + } + } else { + if (currentImportPath !== importPath) { + return; + } + } + + const specifiers = j(path).find(j.ImportSpecifier, { + imported: { name: importSpecifier }, + }); + + if (specifiers.length) { + specifiers.remove(); + + // Remove entire import if no longer used + const unused = j(path).find(j.ImportSpecifier).length === 0; + if (unused) { + j(path).remove(); + } + } + }); + } + + export function hasReferences( + source: Collection, + api: API, + importPath: string | string[], + importSpecifier: string + ) { + const j: JSCodeshift = api.jscodeshift; + + const imported = hasImport(source, api, importPath, importSpecifier); + if (imported) { + return ( + source + .find(j.Identifier, { name: importSpecifier }) + .filter((id) => { + // found in the import statement, does not count as usage + return !j(id).closest(j.ImportDeclaration).length; + }).length > 0 + ); + } + + return false; + } + + export function getObjectPath( + source: Collection, + api: API, + path: ASTPath + ) { + const j: JSCodeshift = api.jscodeshift; + + if (j.MemberExpression.check(path.node)) { + return j(path.node).toSource(); + } + } +} diff --git a/codemods/common.ts b/codemods/common.ts new file mode 100644 index 000000000..8853c6a80 --- /dev/null +++ b/codemods/common.ts @@ -0,0 +1,8 @@ +export enum Theme { + LifeSG = "lifesg", + BookingSG = "bookingsg", + MyLegacy = "mylegacy", + CCube = "ccube", + RBS = "rbs", + OneService = "oneservice", +} diff --git a/codemods/deprecate-v2-tokens/data.ts b/codemods/deprecate-v2-tokens/data.ts new file mode 100644 index 000000000..c52650f2d --- /dev/null +++ b/codemods/deprecate-v2-tokens/data.ts @@ -0,0 +1,283 @@ +export const componentMap = [ + { + oldName: "DesignToken", + newName: "V2_DesignToken", + }, + { + oldName: "DesignTokenSet", + newName: "V2_DesignTokenSet", + }, + { + oldName: "DesignTokenSetOptions", + newName: "V2_DesignTokenSetOptions", + }, + { + oldName: "MediaQuery", + newName: "V2_MediaQuery", + }, + { + oldName: "MediaWidths", + newName: "V2_MediaWidths", + }, + { + oldName: "MediaWidth", + newName: "V2_MediaWidth", + }, + { + oldName: "MediaType", + newName: "V2_MediaType", + }, + { + oldName: "Color", + newName: "V2_Color", + }, + { + oldName: "ColorSet", + newName: "V2_ColorSet", + }, + { + oldName: "ValidationElementAttributes", + newName: "V2_ValidationElementAttributes", + }, + { + oldName: "ValidationTypes", + newName: "V2_ValidationTypes", + }, + { + oldName: "ColorSetOptions", + newName: "V2_ColorSetOptions", + }, + { + oldName: "Text", + newName: "V2_Text", + }, + { + oldName: "TextStyleHelper", + newName: "V2_TextStyleHelper", + }, + { + oldName: "TextStyle", + newName: "V2_TextStyle", + }, + { + oldName: "TextSizeType", + newName: "V2_TextSizeType", + }, + { + oldName: "TextLinkSizeType", + newName: "V2_TextLinkSizeType", + }, + { + oldName: "TextStyleSpec", + newName: "V2_TextStyleSpec", + }, + { + oldName: "TextStyleSetType", + newName: "V2_TextStyleSetType", + }, + { + oldName: "TextStyleSetOptionsType", + newName: "V2_TextStyleSetOptionsType", + }, + { + oldName: "TextWeight", + newName: "V2_TextWeight", + }, + { + oldName: "TextProps", + newName: "V2_TextProps", + }, + { + oldName: "TextLinkProps", + newName: "V2_TextLinkProps", + }, + { + oldName: "TextLinkStyleProps", + newName: "V2_TextLinkStyleProps", + }, + { + oldName: "Layout", + newName: "V2_Layout", + }, + { + oldName: "ColDiv", + newName: "V2_ColDiv", + }, + { + oldName: "Container", + newName: "V2_Container", + }, + { + oldName: "Content", + newName: "V2_Content", + }, + { + oldName: "Section", + newName: "V2_Section", + }, + { + oldName: "CommonLayoutProps", + newName: "V2_CommonLayoutProps", + }, + { + oldName: "SectionProps", + newName: "V2_SectionProps", + }, + { + oldName: "ContainerType", + newName: "V2_ContainerType", + }, + { + oldName: "ContainerProps", + newName: "V2_ContainerProps", + }, + { + oldName: "ContentProps", + newName: "V2_ContentProps", + }, + { + oldName: "DivRef", + newName: "V2_DivRef", + }, + { + oldName: "ColProps", + newName: "V2_ColProps", + }, + { + oldName: "ColDivProps", + newName: "V2_ColDivProps", + }, + { + oldName: "TextList", + newName: "V2_TextList", + }, + { + oldName: "OrderedListProps", + newName: "V2_OrderedListProps", + }, + { + oldName: "UnorderedListProps", + newName: "V2_UnorderedListProps", + }, + { + oldName: "CounterType", + newName: "V2_CounterType", + }, + { + oldName: "BulletType", + newName: "V2_BulletType", + }, + { + oldName: "Transition", + newName: "V2_Transition", + }, + // Added theme name mappings + { + oldName: "BaseTheme", + newName: "V2_BaseTheme", + }, + { + oldName: "BookingSGTheme", + newName: "V2_BookingSGTheme", + }, + { + oldName: "RBSTheme", + newName: "V2_RBSTheme", + }, + { + oldName: "MyLegacyTheme", + newName: "V2_MyLegacyTheme", + }, + { + oldName: "CCubeTheme", + newName: "V2_CCubeTheme", + }, + { + oldName: "OneServiceTheme", + newName: "V2_OneServiceTheme", + }, + // Added type name mappings + { + oldName: "ThemeSpec", + newName: "V2_ThemeSpec", + }, + { + oldName: "ThemeSpecOptions", + newName: "V2_ThemeSpecOptions", + }, + { + oldName: "ThemeCollectionSpec", + newName: "V2_ThemeCollectionSpec", + }, + { + oldName: "ColorScheme", + newName: "V2_ColorScheme", + }, + { + oldName: "TextStyleScheme", + newName: "V2_TextStyleScheme", + }, + { + oldName: "DesignTokenScheme", + newName: "V2_DesignTokenScheme", + }, + { + oldName: "ResourceScheme", + newName: "V2_ResourceScheme", + }, + { + oldName: "ColorCollectionsMap", + newName: "V2_ColorCollectionsMap", + }, + { + oldName: "FontStyleCollectionsMap", + newName: "V2_FontStyleCollectionsMap", + }, + { + oldName: "DesignTokenCollectionsMap", + newName: "V2_DesignTokenCollectionsMap", + }, + { + oldName: "ThemeContextKeys", + newName: "V2_ThemeContextKeys", + }, + { + oldName: "ThemeLayout", + newName: "V2_ThemeLayout", + }, +]; + +export const pathMap = [ + { + oldPath: "design-token", + newPath: "v2_design-token", + }, + { + oldPath: "color", + newPath: "v2_color", + }, + { + oldPath: "media", + newPath: "v2_media", + }, + { + oldPath: "text", + newPath: "v2_text", + }, + { + oldPath: "layout", + newPath: "v2_layout", + }, + { + oldPath: "text-list", + newPath: "v2_text-list", + }, + { + oldPath: "theme", + newPath: "v2_theme", + }, + { + oldPath: "transition", + newPath: "v2_transition", + }, +]; diff --git a/codemods/deprecate-v2-tokens/index.ts b/codemods/deprecate-v2-tokens/index.ts new file mode 100644 index 000000000..fa380dab2 --- /dev/null +++ b/codemods/deprecate-v2-tokens/index.ts @@ -0,0 +1,88 @@ +import { API, FileInfo, JSCodeshift } from "jscodeshift"; +import { componentMap, pathMap } from "./data"; + +export default function transformer(file: FileInfo, api: API) { + const j: JSCodeshift = api.jscodeshift; + const source = j(file.source); + + const componentsToChange = new Set(); + + // change import declarations and check which components to change + source.find(j.ImportDeclaration).forEach((path) => { + const importPath = path.node.source.value; + + // check if importPath starts with @lifesg/ + const isPathLib = + typeof importPath === "string" && importPath.startsWith("@lifesg/"); + + if (isPathLib) { + componentMap.forEach(({ oldName, newName }) => { + let importChanged = false; + + // change specifiers + if (path.node.specifiers) { + path.node.specifiers.forEach((specifier) => { + if ( + specifier.type === "ImportSpecifier" && + specifier.imported.name === oldName + ) { + specifier.imported.name = newName; + if ( + specifier.local && + specifier.local.name === oldName + ) { + specifier.local.name = newName; + } + importChanged = true; + componentsToChange.add(oldName); + } + }); + } + + // check and update path by replacing the last word with the new path + if ( + importChanged && + importPath !== "@lifesg/react-design-system" && + typeof importPath === "string" + ) { + const pathParts = importPath.split("/"); + if ( + pathParts[pathParts.length - 1] !== + "react-design-system" + ) { + const lastPathPart = pathParts[pathParts.length - 1]; + // map with pathMap to find the new path + pathMap.forEach(({ oldPath, newPath }) => { + if (lastPathPart === oldPath) { + pathParts[pathParts.length - 1] = newPath; + path.node.source.value = pathParts.join("/"); + } + }); + } + } + }); + } + }); + + // change JSX identifiers and normal identifiers + if (componentsToChange.size > 0) { + componentMap.forEach(({ oldName, newName }) => { + if (componentsToChange.has(oldName)) { + source + .find(j.JSXIdentifier, { name: oldName }) + .forEach((path) => { + path.node.name = newName; + }); + + source.find(j.Identifier, { name: oldName }).forEach((path) => { + // only update if it's a parent property + if (!j.MemberExpression.check(path.parent.value.object)) { + path.node.name = newName; + } + }); + } + }); + } + + return source.toSource(); +} diff --git a/codemods/migrate-colour/data.ts b/codemods/migrate-colour/data.ts new file mode 100644 index 000000000..d13d8b582 --- /dev/null +++ b/codemods/migrate-colour/data.ts @@ -0,0 +1,95 @@ +const defaultMapping = { + Primary: "primary-50", + PrimaryDark: "primary-40", + Secondary: "primary-40", + "Brand[1]": "brand-50", + "Brand[2]": "brand-60", + "Brand[3]": "brand-70", + "Brand[4]": "brand-80", + "Brand[5]": "brand-90", + "Brand[6]": "brand-95", + "Accent.Light[1]": "primary-60", + "Accent.Light[2]": "primary-70", + "Accent.Light[3]": "primary-80", + "Accent.Light[4]": "primary-90", + "Accent.Light[5]": "primary-95", + "Accent.Light[6]": "primary-100", + "Accent.Dark[1]": "secondary-40", + "Accent.Dark[2]": "secondary-50", + "Accent.Dark[3]": "secondary-60", + "Neutral[1]": "neutral-20", + "Neutral[2]": "neutral-30", + "Neutral[3]": "neutral-50", + "Neutral[4]": "neutral-60", + "Neutral[5]": "neutral-90", + "Neutral[6]": "neutral-95", + "Neutral[7]": "neutral-100", + "Neutral[8]": "white", + "Validation.Green.Text": "success-40", + "Validation.Green.Icon": "success-70", + "Validation.Green.Border": "success-80", + "Validation.Green.Background": "success-100", + "Validation.Orange.Text": "warning-50", + "Validation.Orange.Icon": "warning-70", + "Validation.Orange.Border": "warning-80", + "Validation.Orange.Background": "warning-100", + "Validation.Orange.Badge": "warning-100", + "Validation.Red.Text": "error-50", + "Validation.Red.Icon": "error-50", + "Validation.Red.Border": "error-60", + "Validation.Red.Background": "error-100", + "Validation.Blue.Text": "info-40", + "Validation.Blue.Icon": "info-50", + "Validation.Blue.Border": "info-70", + "Validation.Blue.Background": "info-95", +}; + +export const lifesgMapping = { + ...defaultMapping, + Primary: "primary-50", + PrimaryDark: "primary-40", + Secondary: "primary-40", +}; + +export const bookingSgMapping = { + ...defaultMapping, + Primary: "primary-50", + PrimaryDark: "primary-40", + Secondary: "primary-40", + "Accent.Dark[1]": "secondary-40", + "Accent.Dark[2]": "secondary-60", + "Accent.Dark[3]": "secondary-70", +}; + +export const mylegacyMapping = { + ...defaultMapping, + PrimaryDark: "primary-40", + Primary: "primary-50", + Secondary: "primary-40", + "Validation.Green.Text": "success-50", + "Validation.Red.Text": "success-40", +}; + +export const ccubeMapping = { + ...defaultMapping, + Primary: "primary-50", + PrimaryDark: "primary-40", + Secondary: "secondary-40", + "Brand[1]": "brand-60", + "Brand[2]": "brand-50", +}; + +export const rbsMapping = { + ...defaultMapping, +}; + +export const oneServiceMapping = { + ...defaultMapping, + Primary: "primary-50", + PrimaryDark: "primary-40", + Secondary: "secondary-40", + "Brand[1]": "brand-60", + "Accent.Dark[1]": "secondary-50", + "Accent.Dark[2]": "secondary-60", + "Accent.Dark[3]": "secondary-70", +}; diff --git a/codemods/migrate-colour/index.ts b/codemods/migrate-colour/index.ts new file mode 100644 index 000000000..83848c9bd --- /dev/null +++ b/codemods/migrate-colour/index.ts @@ -0,0 +1,103 @@ +import { API, FileInfo, JSCodeshift } from "jscodeshift"; +import { CodemodUtils } from "../codemod-utils"; +import { Theme } from "../common"; +import { + bookingSgMapping, + ccubeMapping, + lifesgMapping, + mylegacyMapping, + oneServiceMapping, + rbsMapping, +} from "./data"; + +const IMPORT_PATHS = { + V2_COLOR: "@lifesg/react-design-system/v2_color", + DESIGN_SYSTEM: "@lifesg/react-design-system", + THEME: "@lifesg/react-design-system/theme", +}; + +const IMPORT_SPECIFIERS = { + V2_COLOR: "V2_Color", + COLOUR: "Colour", +}; + +const MEMBER_EXPRESSION_PROPERTIES = { + PRIMITIVE: "Primitive", +}; + +const COLOR_MAPPINGS = { + lifesg: lifesgMapping, + bookingsg: bookingSgMapping, + mylegacy: mylegacyMapping, + ccube: ccubeMapping, + rbs: rbsMapping, + oneservice: oneServiceMapping, +}; + +interface Options { + mapping: Theme; +} + +export default function transformer( + file: FileInfo, + api: API, + options: Options +) { + const j: JSCodeshift = api.jscodeshift; + const source = j(file.source); + + const isV2ColorImport = CodemodUtils.hasImport( + source, + api, + [IMPORT_PATHS.DESIGN_SYSTEM, IMPORT_PATHS.V2_COLOR], + IMPORT_SPECIFIERS.V2_COLOR + ); + + // Determine which Colour mapping to use + const colorMapping = + COLOR_MAPPINGS[options.mapping] || COLOR_MAPPINGS[Theme.LifeSG]; + + const replaceWithColorPrimitive = (path: any, new_color_value: string) => { + path.replace( + j.memberExpression( + j.memberExpression( + j.identifier(IMPORT_SPECIFIERS.COLOUR), + j.identifier(MEMBER_EXPRESSION_PROPERTIES.PRIMITIVE) + ), + j.literal(new_color_value) + ) + ); + }; + + if (isV2ColorImport) { + CodemodUtils.addImport( + source, + api, + IMPORT_PATHS.THEME, + IMPORT_SPECIFIERS.COLOUR + ); + + CodemodUtils.removeImport( + source, + api, + [IMPORT_PATHS.DESIGN_SYSTEM, IMPORT_PATHS.V2_COLOR], + IMPORT_SPECIFIERS.V2_COLOR + ); + + // Map V2 Color usage to V3 Colour + source.find(j.MemberExpression).forEach((path) => { + const objectPath = CodemodUtils.getObjectPath(source, api, path); + const prefix = IMPORT_SPECIFIERS.V2_COLOR + "."; + if (objectPath && objectPath.startsWith(prefix)) { + const colour = objectPath.slice(prefix.length); + const newColorValue = + colorMapping[colour as keyof typeof colorMapping]; + if (newColorValue) { + replaceWithColorPrimitive(path, newColorValue); + } + } + }); + } + + return source.toSource(); +} diff --git a/codemods/migrate-layout/data.ts b/codemods/migrate-layout/data.ts new file mode 100644 index 000000000..4b4274423 --- /dev/null +++ b/codemods/migrate-layout/data.ts @@ -0,0 +1,5 @@ +export const propMapping = { + desktopCols: "xlCols", + tabletCols: "mdCols", + mobileCols: "xxsCols", +}; diff --git a/codemods/migrate-layout/index.ts b/codemods/migrate-layout/index.ts new file mode 100644 index 000000000..253a488ad --- /dev/null +++ b/codemods/migrate-layout/index.ts @@ -0,0 +1,107 @@ +import { API, FileInfo, JSCodeshift } from "jscodeshift"; +import { CodemodUtils } from "../codemod-utils"; +import { propMapping } from "./data"; + +const IMPORT_PATHS = { + V2_LAYOUT: "@lifesg/react-design-system/v2_layout", + DESIGN_SYSTEM: "@lifesg/react-design-system", + LAYOUT: "@lifesg/react-design-system/layout", +}; + +const IMPORT_SPECIFIERS = { + V2_LAYOUT: "V2_Layout", + LAYOUT: "Layout", +}; + +export default function transformer(file: FileInfo, api: API) { + const j: JSCodeshift = api.jscodeshift; + const source = j(file.source); + + const isLifesgImport = CodemodUtils.hasImport( + source, + api, + [IMPORT_PATHS.DESIGN_SYSTEM, IMPORT_PATHS.V2_LAYOUT], + IMPORT_SPECIFIERS.V2_LAYOUT + ); + + if (isLifesgImport) { + CodemodUtils.addImport( + source, + api, + IMPORT_PATHS.LAYOUT, + IMPORT_SPECIFIERS.LAYOUT + ); + + CodemodUtils.removeImport( + source, + api, + [IMPORT_PATHS.DESIGN_SYSTEM, IMPORT_PATHS.V2_LAYOUT], + IMPORT_SPECIFIERS.V2_LAYOUT + ); + + // Update V2_Layout to Layout + source.find(j.JSXMemberExpression).forEach((path) => { + const { object } = path.node; + + if ( + j.JSXIdentifier.check(object) && + object.name === IMPORT_SPECIFIERS.V2_LAYOUT + ) { + object.name = IMPORT_SPECIFIERS.LAYOUT; + } + }); + + source + .find(j.Identifier, { name: IMPORT_SPECIFIERS.V2_LAYOUT }) + .forEach((path) => { + path.node.name = IMPORT_SPECIFIERS.LAYOUT; + }); + + // Update ColDiv column props to its V3 version + source.find(j.JSXOpeningElement).forEach((path) => { + const { name, attributes } = path.node; + + if ( + j.JSXMemberExpression.check(name) && + j.JSXIdentifier.check(name.object) && + name.object.name === IMPORT_SPECIFIERS.LAYOUT && + j.JSXIdentifier.check(name.property) && + name.property.name === "ColDiv" + ) { + if (attributes && attributes.length > 0) { + attributes.forEach((attribute) => { + if ( + j.JSXAttribute.check(attribute) && + j.JSXIdentifier.check(attribute.name) + ) { + const oldPropName = attribute.name.name; + const newPropName = + propMapping[ + oldPropName as keyof typeof propMapping + ]; + + if (newPropName) { + attribute.name.name = newPropName; + } + } + }); + } + } + }); + + source.find(j.JSXElement).forEach((path) => { + const openingElement = path.node.openingElement; + const { name } = openingElement; + if ( + j.JSXMemberExpression.check(name) && + j.JSXIdentifier.check(name.object) + ) { + if (name.object.name === IMPORT_SPECIFIERS.V2_LAYOUT) { + name.object.name = IMPORT_SPECIFIERS.LAYOUT; + } + } + }); + } + + return source.toSource(); +} diff --git a/codemods/migrate-media-query/data.ts b/codemods/migrate-media-query/data.ts new file mode 100644 index 000000000..a923eeec6 --- /dev/null +++ b/codemods/migrate-media-query/data.ts @@ -0,0 +1,20 @@ +export const mediaQueryMap = { + MaxWidth: { + mobileS: "xxs", + mobileM: "xs", + mobileL: "sm", + tablet: "lg", + desktopM: "xl", + desktopL: "xl", + desktop4k: "xl", + }, + MinWidth: { + mobileS: "xs", + mobileM: "sm", + mobileL: "md", + tablet: "xl", + desktopM: "xxl", + desktopL: "xxl", + desktop4k: "xxl", + }, +}; diff --git a/codemods/migrate-media-query/index.ts b/codemods/migrate-media-query/index.ts new file mode 100644 index 000000000..2e09d21e6 --- /dev/null +++ b/codemods/migrate-media-query/index.ts @@ -0,0 +1,112 @@ +import { API, FileInfo, JSCodeshift } from "jscodeshift"; +import { CodemodUtils } from "../codemod-utils"; +import { mediaQueryMap } from "./data"; + +// ======= Constants ======= // + +const IMPORT_PATHS = { + V2_MEDIA: "@lifesg/react-design-system/v2_media", + DESIGN_SYSTEM: "@lifesg/react-design-system", + THEME: "@lifesg/react-design-system/theme", +}; + +const IMPORT_SPECIFIERS = { + V2_MEDIA_QUERY: "V2_MediaQuery", + MEDIA_QUERY: "MediaQuery", + V2_MEDIA_WIDTHS: "V2_MediaWidths", +}; + +const MEMBER_EXPRESSION_PROPERTIES = { + MAX_WIDTH: "MaxWidth", + MIN_WIDTH: "MinWidth", +}; + +const WARNINGS = { + DEPRECATED_USAGE: `\x1b[31m[MIGRATION] V2_MediaWidths requires manual migration to Breakpoint. Refer to <"https://github.com/LifeSG/react-design-system/wiki/Migrating-to-V3">. File:\x1b[0m`, +}; + +const IDENTIFIERS = { + MEDIA_QUERY: "MediaQuery", +}; + +// ======= Transformer Function ======= // +export default function transformer(file: FileInfo, api: API) { + const j: JSCodeshift = api.jscodeshift; + const source = j(file.source); + + const isV2MediaQueryImported = CodemodUtils.hasImport( + source, + api, + [IMPORT_PATHS.DESIGN_SYSTEM, IMPORT_PATHS.V2_MEDIA], + IMPORT_SPECIFIERS.V2_MEDIA_QUERY + ); + const isV2MediaWidthsImported = CodemodUtils.hasImport( + source, + api, + [IMPORT_PATHS.DESIGN_SYSTEM, IMPORT_PATHS.V2_MEDIA], + IMPORT_SPECIFIERS.V2_MEDIA_WIDTHS + ); + + // Detect usage of V2_MediaQuery + // Replace V2_MediaQuery with V3 MediaQuery + if (isV2MediaQueryImported) { + CodemodUtils.addImport( + source, + api, + IMPORT_PATHS.THEME, + IMPORT_SPECIFIERS.MEDIA_QUERY + ); + + CodemodUtils.removeImport( + source, + api, + [IMPORT_PATHS.DESIGN_SYSTEM, IMPORT_PATHS.V2_MEDIA], + IMPORT_SPECIFIERS.V2_MEDIA_QUERY + ); + + // Rename all instances of V2_MediaQuery to MediaQuery + source + .find(j.Identifier, { name: IMPORT_SPECIFIERS.V2_MEDIA_QUERY }) + .forEach((path) => { + path.node.name = IMPORT_SPECIFIERS.MEDIA_QUERY; + }); + + // Map V2 to V3 breakpoints + source.find(j.MemberExpression).forEach((path) => { + const object = path.node.object; + const property = path.node.property; + + if ( + j.MemberExpression.check(object) && + j.Identifier.check(object.object) && + object.object.name === IDENTIFIERS.MEDIA_QUERY && + j.Identifier.check(object.property) && + (object.property.name === + MEMBER_EXPRESSION_PROPERTIES.MAX_WIDTH || + object.property.name === + MEMBER_EXPRESSION_PROPERTIES.MIN_WIDTH) && + j.Identifier.check(property) + ) { + const queryType = object.property + .name as keyof typeof mediaQueryMap; + const mediaKey = + property.name as keyof (typeof mediaQueryMap)[typeof queryType]; + + if ( + mediaQueryMap[queryType] && + mediaQueryMap[queryType][mediaKey] + ) { + const newMediaKey = mediaQueryMap[queryType][mediaKey]; + property.name = newMediaKey; + } + } + }); + } + + // Link to migration docs for V2_MediaWidths deprecation + if (isV2MediaWidthsImported) { + console.error(`${WARNINGS.DEPRECATED_USAGE} ${file.path}`); + } + + return source.toSource(); +} diff --git a/codemods/migrate-text-list/data.ts b/codemods/migrate-text-list/data.ts new file mode 100644 index 000000000..433fbd6b0 --- /dev/null +++ b/codemods/migrate-text-list/data.ts @@ -0,0 +1,16 @@ +export const sizePropMapping = { + D1: "heading-xxl", + D2: "heading-xl", + D3: "heading-md", + D4: "heading-sm", + H1: "heading-lg", + H2: "heading-md", + H3: "heading-sm", + H4: "heading-xs", + H5: "body-md", + H6: "body-sm", + DBody: "heading-sm", + Body: "body-baseline", + BodySmall: "body-md", + XSmall: "body-xs", +}; diff --git a/codemods/migrate-text-list/index.ts b/codemods/migrate-text-list/index.ts new file mode 100644 index 000000000..f5080c297 --- /dev/null +++ b/codemods/migrate-text-list/index.ts @@ -0,0 +1,110 @@ +import { API, FileInfo, JSCodeshift } from "jscodeshift"; +import { CodemodUtils } from "../codemod-utils"; +import { sizePropMapping } from "./data"; + +// ======= Constants ======= // + +const IMPORT_PATHS = { + TEXT_LIST: "@lifesg/react-design-system/text-list", + V2_TEXT_LIST: "@lifesg/react-design-system/v2_text-list", + DESIGN_SYSTEM: "@lifesg/react-design-system", +}; + +const IMPORT_SPECIFIERS = { + TEXT_LIST: "TextList", + V2_TEXT_LIST: "V2_TextList", +}; + +const JSX_IDENTIFIERS = { + TEXT_LIST: "TextList", + V2_TEXT_LIST: "V2_TextList", +}; + +// ======= Transformer Function ======= // + +export default function transformer(file: FileInfo, api: API) { + const j: JSCodeshift = api.jscodeshift; + const source = j(file.source); + + const isV2TextListImport = CodemodUtils.hasImport( + source, + api, + [IMPORT_PATHS.DESIGN_SYSTEM, IMPORT_PATHS.V2_TEXT_LIST], + IMPORT_SPECIFIERS.V2_TEXT_LIST + ); + + if (isV2TextListImport) { + CodemodUtils.addImport( + source, + api, + IMPORT_PATHS.TEXT_LIST, + IMPORT_SPECIFIERS.TEXT_LIST + ); + + CodemodUtils.removeImport( + source, + api, + [IMPORT_PATHS.DESIGN_SYSTEM, IMPORT_PATHS.V2_TEXT_LIST], + IMPORT_SPECIFIERS.V2_TEXT_LIST + ); + + source + .find(j.Identifier, { name: JSX_IDENTIFIERS.V2_TEXT_LIST }) + .forEach((path) => { + path.node.name = JSX_IDENTIFIERS.TEXT_LIST; + }); + + source.find(j.MemberExpression).forEach((path) => { + if ( + j.Identifier.check(path.node.object) && + path.node.object.name === JSX_IDENTIFIERS.V2_TEXT_LIST + ) { + path.node.object.name = JSX_IDENTIFIERS.TEXT_LIST; + } + }); + + source.find(j.JSXMemberExpression).forEach((path) => { + if ( + j.JSXIdentifier.check(path.node.object) && + path.node.object.name === JSX_IDENTIFIERS.V2_TEXT_LIST + ) { + path.node.object.name = JSX_IDENTIFIERS.TEXT_LIST; + } + }); + + source.find(j.JSXOpeningElement).forEach((path) => { + const openingElement = path.node; + let isTextListElement = false; + + if ( + j.JSXMemberExpression.check(openingElement.name) && + j.JSXIdentifier.check(openingElement.name.object) && + openingElement.name.object.name === JSX_IDENTIFIERS.TEXT_LIST + ) { + isTextListElement = true; + } + + if (isTextListElement) { + const attributes = openingElement.attributes; + + attributes?.forEach((attribute) => { + if ( + j.JSXAttribute.check(attribute) && + attribute.name.name === "size" && + j.StringLiteral.check(attribute.value) + ) { + const sizeValue = attribute.value + .value as keyof typeof sizePropMapping; + if (sizePropMapping[sizeValue]) { + attribute.value = j.literal( + sizePropMapping[sizeValue] + ); + } + } + }); + } + }); + } + + return source.toSource(); +} diff --git a/codemods/migrate-text/data.ts b/codemods/migrate-text/data.ts new file mode 100644 index 000000000..e813b097c --- /dev/null +++ b/codemods/migrate-text/data.ts @@ -0,0 +1,48 @@ +export const textComponentMap = { + D1: "HeadingXXL", + D2: "HeadingXL", + D3: "HeadingMD", + D4: "HeadingSM", + H1: "HeadingLG", + H2: "HeadingMD", + H3: "HeadingSM", + H4: "HeadingXS", + H5: "BodyMD", + H6: "BodySM", + DBody: "HeadingSM", + Body: "BodyBL", + BodySmall: "BodyMD", + XSmall: "BodyXS", + "Hyperlink.Default": "LinkBL", + "Hyperlink.Small": "LinkMD", +}; + +export const textStyleFontMap = { + D1: "heading-xxl", + D2: "heading-xl", + D3: "heading-md", + D4: "heading-sm", + H1: "heading-lg", + H2: "heading-md", + H3: "heading-sm", + H4: "heading-xs", + H5: "body-md", + H6: "body-sm", + DBody: "heading-sm", + Body: "body-baseline", + BodySmall: "body-md", + XSmall: "body-xs", +}; + +export const weightMap = { + regular: "regular", + semibold: "semibold", + bold: "bold", + light: "light", + black: "bold", + 400: "regular", + 600: "semibold", + 700: "bold", + 300: "light", + 900: "bold", +}; diff --git a/codemods/migrate-text/index.ts b/codemods/migrate-text/index.ts new file mode 100644 index 000000000..6e9f6367c --- /dev/null +++ b/codemods/migrate-text/index.ts @@ -0,0 +1,195 @@ +import { + API, + ASTPath, + FileInfo, + JSCodeshift, + MemberExpression, +} from "jscodeshift"; +import { CodemodUtils } from "../codemod-utils"; +import { textComponentMap, textStyleFontMap, weightMap } from "./data"; + +// ======= Constants ======= // + +const IMPORT_PATHS = { + V2_TEXT: "@lifesg/react-design-system/v2_text", + DESIGN_SYSTEM: "@lifesg/react-design-system", + TYPOGRAPHY: "@lifesg/react-design-system/typography", + THEME: "@lifesg/react-design-system/theme", +}; + +const IMPORT_SPECIFIERS = { + V2_TEXT: "V2_Text", + V2_TEXT_STYLE_HELPER: "V2_TextStyleHelper", + TYPOGRAPHY: "Typography", + FONT: "Font", +}; + +const JSX_IDENTIFIERS = { + V2_TEXT: "V2_Text", + TYPOGRAPHY: "Typography", +}; + +const IDENTIFIERS = { + V2_GET_TEXT_STYLE: "getTextStyle", +}; + +// ======= Transformer Function ======= // + +export default function transformer(file: FileInfo, api: API) { + const j: JSCodeshift = api.jscodeshift; + const source = j(file.source); + + const importsText = CodemodUtils.hasImport( + source, + api, + [IMPORT_PATHS.DESIGN_SYSTEM, IMPORT_PATHS.V2_TEXT], + IMPORT_SPECIFIERS.V2_TEXT + ); + const importsTextStyleHelper = CodemodUtils.hasImport( + source, + api, + [IMPORT_PATHS.DESIGN_SYSTEM, IMPORT_PATHS.V2_TEXT], + IMPORT_SPECIFIERS.V2_TEXT_STYLE_HELPER + ); + + const replaceWithTypography = ( + path: ASTPath, + newComponentValue: string + ) => { + path.replace( + j.memberExpression( + j.identifier(JSX_IDENTIFIERS.TYPOGRAPHY), + j.identifier(newComponentValue) + ) + ); + }; + + if (importsText) { + CodemodUtils.addImport( + source, + api, + IMPORT_PATHS.TYPOGRAPHY, + IMPORT_SPECIFIERS.TYPOGRAPHY + ); + + CodemodUtils.removeImport( + source, + api, + [IMPORT_PATHS.DESIGN_SYSTEM, IMPORT_PATHS.V2_TEXT], + IMPORT_SPECIFIERS.V2_TEXT + ); + + source + .find(j.Identifier, { name: JSX_IDENTIFIERS.V2_TEXT }) + .forEach((path) => { + // Replace V2_Text with Typography + path.node.name = JSX_IDENTIFIERS.TYPOGRAPHY; + }); + + // Map to respective V3 Typography component usage + source.find(j.MemberExpression).forEach((path) => { + const propertyNameParts: string[] = []; + let currentPath = path.node; + let startsWithTypography = false; + + while (j.MemberExpression.check(currentPath)) { + const property = currentPath.property; + const object = currentPath.object; + + if (j.Identifier.check(property)) { + propertyNameParts.unshift(property.name); + } + + if (j.MemberExpression.check(object)) { + currentPath = object; + } else if (j.Identifier.check(object)) { + if (object.name === JSX_IDENTIFIERS.TYPOGRAPHY) { + startsWithTypography = true; + } + break; + } else { + break; + } + } + + if (startsWithTypography) { + const propertyName = propertyNameParts.join( + "." + ) as keyof typeof textComponentMap; + const newTypographyValue = textComponentMap[propertyName]; + if (newTypographyValue) { + replaceWithTypography(path, newTypographyValue); + } + } + }); + } + + if (importsTextStyleHelper) { + let usesTextStyleHelper = false; + + source.find(j.CallExpression).forEach((path) => { + if ( + !j.CallExpression.check(path.node) || + !j.MemberExpression.check(path.node.callee) || + !j.Identifier.check(path.node.callee.object) || + path.node.callee.object.name !== + IMPORT_SPECIFIERS.V2_TEXT_STYLE_HELPER || + !j.Identifier.check(path.node.callee.property) || + path.node.callee.property.name !== IDENTIFIERS.V2_GET_TEXT_STYLE + ) { + return; + } + + const style = j.Literal.check(path.node.arguments[0]) + ? path.node.arguments[0].value + : undefined; + const weight = j.Literal.check(path.node.arguments[1]) + ? path.node.arguments[1].value + : "regular"; + + if (!style) { + return; + } + + const mappedType = + textStyleFontMap[style as keyof typeof textStyleFontMap]; + const mappedWeight = weightMap[weight as keyof typeof weightMap]; + + j(path).replaceWith(() => { + return j.memberExpression( + j.identifier("Font"), + j.literal(`${mappedType}-${mappedWeight}`) + ); + }); + + usesTextStyleHelper = true; + }); + + if (usesTextStyleHelper) { + CodemodUtils.addImport( + source, + api, + IMPORT_PATHS.THEME, + IMPORT_SPECIFIERS.FONT + ); + } + + if ( + !CodemodUtils.hasReferences( + source, + api, + [IMPORT_PATHS.DESIGN_SYSTEM, IMPORT_PATHS.V2_TEXT], + IMPORT_SPECIFIERS.V2_TEXT_STYLE_HELPER + ) + ) { + CodemodUtils.removeImport( + source, + api, + [IMPORT_PATHS.DESIGN_SYSTEM, IMPORT_PATHS.V2_TEXT], + IMPORT_SPECIFIERS.V2_TEXT_STYLE_HELPER + ); + } + } + + return source.toSource(); +} diff --git a/codemods/run-codemod.ts b/codemods/run-codemod.ts new file mode 100644 index 000000000..8600c6043 --- /dev/null +++ b/codemods/run-codemod.ts @@ -0,0 +1,222 @@ +import { checkbox, confirm, input, select } from "@inquirer/prompts"; +import { execSync } from "child_process"; +import * as fs from "fs"; +import * as path from "path"; +import { Theme } from "./common"; + +const codemodsDir = + process.env.ENV === "dev" + ? path.join(process.cwd(), "codemods") + : path.join( + process.cwd(), + "node_modules/@lifesg/react-design-system/codemods" + ); + +enum Codemod { + DeprecateV2Tokens = "deprecate-v2-tokens", + MigrateColour = "migrate-colour", + MigrateLayout = "migrate-layout", + MigrateMediaQuery = "migrate-media-query", + MigrateText = "migrate-text", + MigrateTextList = "migrate-text-list", +} + +const theme = { + helpMode: "always" as const, +}; + +const CodemodDescriptions: { [key in Codemod]: string } = { + [Codemod.DeprecateV2Tokens]: "Migrate deprecated V2 imports", + [Codemod.MigrateColour]: "Replace V2_Color with new Colour tokens", + [Codemod.MigrateLayout]: "Replace V2_Layout with new Layout components", + [Codemod.MigrateMediaQuery]: + "Replace V2 media queries with new Breakpoint tokens", + [Codemod.MigrateText]: + "Replace V2_Text with new Typography components and V2_TextStyleHelper.getTextStyle() with Font", + [Codemod.MigrateTextList]: + "Replace V2_TextList with new Textlist components", +}; + +const TargetDirectoryPaths = { + src: "src", + codebase: ".", +}; + +interface UserSelection { + selectedCodemods: string[]; + selectedTheme: Theme | null; + targetPath: string; +} + +// Return codemods in codemod folder +function listCodemods(): { name: string; value: string }[] { + const codemods: string[] = fs.readdirSync(codemodsDir).filter((folder) => { + return fs.lstatSync(path.join(codemodsDir, folder)).isDirectory(); + }); + + const options = codemods.map((mod) => ({ + name: mod, + value: mod, + description: CodemodDescriptions[mod as Codemod], + })); + + return options; +} + +function runCodemods(selection: UserSelection): void { + const { selectedCodemods, targetPath, selectedTheme } = selection; + + selectedCodemods.forEach((codemod) => { + const codemodPath = path.join(codemodsDir, codemod, "index.ts"); + let command = `npx --yes jscodeshift --parser=tsx -t ${codemodPath} ${targetPath}`; + + if (codemod === Codemod.MigrateColour && selectedTheme) { + command = `npx --yes jscodeshift --parser=tsx -t ${codemodPath} --mapping=${selectedTheme} ${targetPath}`; + } + + console.log( + `Running codemod: ${codemod} on target path: ${targetPath}` + ); + + try { + execSync(command, { stdio: "inherit" }); + console.log( + `Codemod ${codemod} executed successfully on ${targetPath}` + ); + } catch (error) { + if (error instanceof Error) { + console.error( + `Error executing codemod ${codemod}: ${error.message}` + ); + } + + throw error; + } + }); +} + +async function chooseTargetPath(): Promise { + const options = [ + { + name: "src (./src)", + value: TargetDirectoryPaths.src, + }, + { + name: "current directory (./)", + value: TargetDirectoryPaths.codebase, + }, + { name: "custom", value: "custom" }, + ]; + + const selectedOption = await select({ + message: "Choose a target path:", + choices: options, + }); + + if (selectedOption === "custom") { + const customPath = await input({ + message: "Enter your custom path:", + required: true, + }); + + const resolvedPath = path.resolve(customPath); + + if (!fs.existsSync(resolvedPath)) { + console.error(`The path "${resolvedPath}" does not exist`); + return null; + } + + return resolvedPath; + } + + return selectedOption; +} + +async function chooseTheme(): Promise { + const themeOptions = [ + { name: "LifeSG", value: Theme.LifeSG }, + { name: "BookingSG", value: Theme.BookingSG }, + { name: "MyLegacy", value: Theme.MyLegacy }, + { name: "CCube", value: Theme.CCube }, + { name: "RBS", value: Theme.RBS }, + { name: "OneService", value: Theme.OneService }, + ]; + + const selectedTheme = await select({ + message: "Select the theme that your project is using:", + choices: themeOptions, + }); + + return selectedTheme; +} + +async function getCodemodSelections(): Promise { + const codemods = listCodemods(); + if (codemods.length === 0) { + throw new Error("No codemod scripts found"); + } + + const selectedCodemods = await checkbox({ + required: true, + message: "Select codemods to run:", + choices: codemods, + theme, + }); + + let selectedTheme: Theme | null = null; + if (selectedCodemods.includes(Codemod.MigrateColour)) { + selectedTheme = await chooseTheme(); + } + + const targetPath = await chooseTargetPath(); + if (!targetPath) { + throw new Error("No target path selected or provided"); + } + + return { selectedCodemods, selectedTheme, targetPath }; +} + +async function getConfirmation(selection: UserSelection) { + const { selectedCodemods, selectedTheme, targetPath } = selection; + + const codemods = selectedCodemods.join(", "); + const finalConfirmationMessage = + `\nYou are about to run the following codemods: ${codemods}\n` + + `Target path: ${targetPath}\n` + + `${ + selectedTheme + ? `Selected theme for "migrate-colour": ${selectedTheme}\n` + : "" + }`; + + console.log(finalConfirmationMessage); + + const finalConfirmation = await confirm({ + message: "Do you want to proceed?", + default: true, + }); + + return finalConfirmation; +} + +async function main(): Promise { + try { + const selection = await getCodemodSelections(); + + const finalConfirmation = await getConfirmation(selection); + + if (!finalConfirmation) { + return; + } + + runCodemods(selection); + } catch (error: unknown) { + if (error instanceof Error && error.name === "ExitPromptError") { + // user exited, do nothing + } else { + throw error; + } + } +} + +main(); diff --git a/component-catalog.json b/component-catalog.json new file mode 100644 index 000000000..7a4f46cab --- /dev/null +++ b/component-catalog.json @@ -0,0 +1,860 @@ +{ + "meta": { + "packageName": "@lifesg/react-design-system", + "totalModules": 75 + }, + "components": [ + { + "name": "Accordion", + "importPath": "@lifesg/react-design-system/accordion", + "description": "Props for the Accordion component - collapsible content container. Wraps one or more `Accordion.Item` children in a panel that can be individually expanded or collapsed. Supports a header title and an expand/collapse-all control.", + "searchKeys": [ + "collapsible", + "disclosure", + "expand collapse", + "expandable", + "panel" + ] + }, + { + "name": "Alert", + "importPath": "@lifesg/react-design-system/alert", + "description": "Props for the Alert component - contextual feedback banner. Displays an inline message with an optional icon, action link, and configurable severity type and size.", + "searchKeys": [ + "banner", + "info box", + "notification", + "status message", + "warning message" + ] + }, + { + "name": "Animations", + "importPath": "@lifesg/react-design-system/animations", + "description": "", + "searchKeys": [] + }, + { + "name": "BaseTheme", + "importPath": "@lifesg/react-design-system/theme", + "description": "for color customisation, can specify subset of set", + "searchKeys": [ + "brand theme", + "color scheme", + "design tokens config", + "ThemeProvider", + "theming" + ] + }, + { + "name": "BoxContainer", + "importPath": "@lifesg/react-design-system/box-container", + "description": "Props for the BoxContainer component - collapsible titled card. Renders a bordered box with a header title and optionally a status icon, call-to-action element, and expand/collapse toggle.", + "searchKeys": [ + "bordered box", + "collapsible card", + "expandable section", + "titled panel" + ] + }, + { + "name": "Breadcrumb", + "importPath": "@lifesg/react-design-system/breadcrumb", + "description": "Props for the Breadcrumb component - horizontal navigation trail. Renders an ordered list of anchor links representing the current page hierarchy. When links overflow, a fade gradient is applied at the scrollable edges.", + "searchKeys": [ + "crumb nav", + "hierarchy links", + "navigation trail", + "page path" + ] + }, + { + "name": "Button", + "importPath": "@lifesg/react-design-system/button", + "description": "Props for the Button component - primary call-to-action element Use buttons to trigger immediate actions like form submissions, dialog confirmations, or navigation. Choose the appropriate style type based on action hierarchy. The button comes in two sizes: Default and Small, available via Button.Default and Button.Small.", + "searchKeys": [ + "action trigger", + "click handler", + "CTA", + "primary button", + "submit" + ] + }, + { + "name": "Calendar", + "importPath": "@lifesg/react-design-system/calendar", + "description": "Props for the Calendar component - standalone date picker. Renders a full calendar view for selecting a single date. Extends `CommonCalendarProps` which provides min/max dates, disabled dates, and locale settings.", + "searchKeys": [ + "date grid", + "date picker", + "date selector", + "day picker", + "monthly view" + ] + }, + { + "name": "Card", + "importPath": "@lifesg/react-design-system/card", + "description": "Props for the Card component - elevated content container. Renders a styled box with a white background and rounded corners. Inherits all `HTMLDivElement` attributes.", + "searchKeys": ["content box", "panel", "paper", "surface", "tile"] + }, + { + "name": "Checkbox", + "importPath": "@lifesg/react-design-system/checkbox", + "description": "Props for the Checkbox component - binary selection control. Renders a styled checkbox with optional indeterminate state. Inherits all standard `HTMLInputElement` attributes.", + "searchKeys": [ + "boolean input", + "check mark", + "multi-select control", + "tick box" + ] + }, + { + "name": "Color", + "importPath": "@lifesg/react-design-system/color", + "description": "", + "searchKeys": [] + }, + { + "name": "CountdownTimer", + "importPath": "@lifesg/react-design-system/countdown-timer", + "description": "Props for the CountdownTimer component - floating sticky countdown display. Renders a countdown clock that sticks to the viewport edge as the user scrolls. When the remaining time falls below `notifyTimer`, the `onTick` and `onNotify` callbacks fire. Use `show` to start or pause the timer.", + "searchKeys": [ + "clock", + "count down", + "expiry timer", + "session timeout", + "time remaining" + ] + }, + { + "name": "DataTable", + "importPath": "@lifesg/react-design-system/data-table", + "description": "Props for the DataTable component - tabular data display with selection. DataTable organises row data into columns and supports sortable headers, optional row selection, loading states, sticky headers, and empty states. Use it when users need to scan, compare, and act on structured data.", + "searchKeys": [ + "data grid", + "grid", + "sortable table", + "spreadsheet", + "table" + ] + }, + { + "name": "DateInput", + "importPath": "@lifesg/react-design-system/date-input", + "description": "Props for the DateInput component - single date picker with calendar overlay. Displays a text input that opens an inline calendar for selecting a single date. Controlled via a `\"YYYY-MM-DD\"` string value. Supports optional confirmation buttons, disabled and read-only modes, and focus/blur callbacks.", + "searchKeys": [ + "calendar input", + "date entry", + "date field", + "date selector", + "single date" + ] + }, + { + "name": "DateRangeInput", + "importPath": "@lifesg/react-design-system/date-range-input", + "description": "Props for the DateRangeInput component - date range picker with calendar overlay. Displays two linked text inputs (start and end) that open a shared calendar for selecting a date range. Controlled via `\"YYYY-MM-DD\"` string values. Supports week, month, and fixed-day range variants in addition to the default free-range mode.", + "searchKeys": [ + "date range", + "date span", + "from to date", + "period picker", + "start end date" + ] + }, + { + "name": "DesignToken", + "importPath": "@lifesg/react-design-system/design-token", + "description": "", + "searchKeys": [] + }, + { + "name": "Divider", + "importPath": "@lifesg/react-design-system/divider", + "description": "Props for the Divider component - horizontal rule separator. Renders a styled horizontal rule with configurable thickness, line style, color, and optional grid column span (via `ColDiv` layout props).", + "searchKeys": [ + "horizontal rule", + "hr", + "line break", + "section divider", + "separator" + ] + }, + { + "name": "Drawer", + "importPath": "@lifesg/react-design-system/drawer", + "description": "Props for the Drawer component - slide-in side panel. Renders a panel that slides in from the side of the screen, overlaying the page content. Toggled via the `show` prop and dismissed via the close button or overlay click callbacks.", + "searchKeys": [ + "flyout", + "off-canvas", + "side panel", + "sidebar", + "slide out" + ] + }, + { + "name": "ErrorDisplay", + "importPath": "@lifesg/react-design-system/error-display", + "description": "Base content attributes for the ErrorDisplay component. Shared between `ErrorDisplayProps` and the DataTable `emptyView` override.", + "searchKeys": [ + "404 page", + "empty state", + "error page", + "maintenance screen", + "not found" + ] + }, + { + "name": "FeedbackRating", + "importPath": "@lifesg/react-design-system/feedback-rating", + "description": "Props for the FeedbackRating component - star-rating survey widget. Renders an optional banner image, a description prompt, a row of star buttons, and a submit button. Controlled via the `rating` prop.", + "searchKeys": [ + "NPS", + "rating widget", + "review", + "satisfaction score", + "star rating" + ] + }, + { + "name": "FileUpload", + "importPath": "@lifesg/react-design-system/file-upload", + "description": "Props for an individual file item displayed in the upload list.", + "searchKeys": [ + "attachment", + "drag and drop", + "drop zone", + "file input", + "upload field" + ] + }, + { + "name": "Filter", + "importPath": "@lifesg/react-design-system/filter", + "description": "Props for the Filter component - collapsible filter panel. Renders a panel of `Filter.Item` children with a header title, clear button, and mobile-specific dismiss/done controls.", + "searchKeys": [ + "facet", + "filter drawer", + "refinement panel", + "search filter", + "sidebar filter" + ] + }, + { + "name": "Footer", + "importPath": "@lifesg/react-design-system/footer", + "description": "Any custom attributes you would like to pass to the link", + "searchKeys": [ + "bottom bar", + "copyright bar", + "footer nav", + "page footer links", + "site footer" + ] + }, + { + "name": "Form", + "importPath": "@lifesg/react-design-system/form", + "description": "Configuration for an informational addon attached to a Form label. Renders as a tooltip or popover triggered by an icon next to the label, providing supplementary context without cluttering the form field.", + "searchKeys": [ + "field group", + "form field", + "form layout", + "input wrapper", + "label input pair" + ] + }, + { + "name": "FullscreenImageCarousel", + "importPath": "@lifesg/react-design-system/fullscreen-image-carousel", + "description": "Props for the FullscreenImageCarousel component - fullscreen image gallery modal. Renders a modal overlay with a swipeable image carousel and optional thumbnail strip. Extends key `ModalProps` fields for visibility and animation.", + "searchKeys": [ + "gallery modal", + "image gallery", + "image slider", + "lightbox", + "photo viewer" + ] + }, + { + "name": "HistogramSlider", + "importPath": "@lifesg/react-design-system/histogram-slider", + "description": "Represents a single bin in the histogram distribution chart.", + "searchKeys": [ + "bar chart slider", + "density slider", + "distribution chart", + "price range filter", + "range histogram" + ] + }, + { + "name": "IconButton", + "importPath": "@lifesg/react-design-system/icon-button", + "description": "Props for the IconButton component - icon-only button. Renders a square button containing only an icon child. Extends all `HTMLButtonElement` attributes. Choose the style type based on hierarchy and the size type based on available space.", + "searchKeys": [ + "ghost button", + "icon action", + "square button", + "symbol button", + "toolbar button" + ] + }, + { + "name": "ImageButton", + "importPath": "@lifesg/react-design-system/image-button", + "description": "Props for the ImageButton component - image thumbnail button. Renders a clickable image tile with selected and error visual states. Extends all `HTMLButtonElement` attributes.", + "searchKeys": [ + "image picker", + "image tile", + "photo button", + "picture button", + "thumbnail selector" + ] + }, + { + "name": "Input", + "importPath": "@lifesg/react-design-system/input", + "description": "Props for the Input component - single-line text entry field. Extends all standard HTML `` attributes. Use for free-text, numeric, email, telephone, or search entry in forms. Supports optional clear button, error state, and a borderless style variant.", + "searchKeys": [ + "search field", + "single line input", + "text box", + "text entry", + "text field" + ] + }, + { + "name": "InputGroup", + "importPath": "@lifesg/react-design-system/input-group", + "description": "The type of addon attached to the InputGroup. - `\"label\"`: Fixed text prefix or suffix (e.g., currency symbol, unit). - `\"list\"`: Selectable dropdown addon (e.g., country-code selector). - `\"custom\"`: Arbitrary JSX content as the addon.", + "searchKeys": [ + "addon input", + "currency input", + "inline addon", + "prefixed input", + "suffixed input" + ] + }, + { + "name": "InputMultiSelect", + "importPath": "@lifesg/react-design-system/input-multi-select", + "description": "Props for the InputMultiSelect component - multi-option dropdown selector. Allows the user to choose multiple options from a dropdown list with checkboxes. Supports async option loading, search, and controlled selected state. For single-option selection use `InputSelect`.", + "searchKeys": [ + "checkbox dropdown", + "multi option picker", + "multi-select dropdown", + "multiple choice", + "tag select" + ] + }, + { + "name": "InputNestedMultiSelect", + "importPath": "@lifesg/react-design-system/input-nested-multi-select", + "description": "Props for the InputNestedMultiSelect component - multi-option hierarchical dropdown. Allows the user to drill into nested categories and select multiple values from a three-level hierarchy (L1 → L2 → L3). Use `InputNestedSelect` for single value selection. Supports search and async loading.", + "searchKeys": [ + "cascading checklist", + "hierarchical multi-select", + "nested dropdown multiple", + "tree select multiple" + ] + }, + { + "name": "InputNestedSelect", + "importPath": "@lifesg/react-design-system/input-nested-select", + "description": "Props for the InputNestedSelect component - single-option hierarchical dropdown. Allows the user to drill into nested categories to select a single value from a three-level hierarchy (L1 → L2 → L3). Use `InputNestedMultiSelect` for selecting multiple values. Supports search and async loading.", + "searchKeys": [ + "cascading dropdown", + "category picker", + "hierarchical select", + "nested category", + "tree select" + ] + }, + { + "name": "InputRangeSelect", + "importPath": "@lifesg/react-design-system/input-range-select", + "description": "A generic from/to pair used by InputRangeSelect for options and selected values.", + "searchKeys": [ + "dual select", + "from to dropdown", + "price range select", + "range selector", + "start end picker" + ] + }, + { + "name": "InputRangeSlider", + "importPath": "@lifesg/react-design-system/input-range-slider", + "description": "Shared configuration props used by both `InputSlider` and `InputRangeSlider`.", + "searchKeys": [ + "dual thumb slider", + "min max slider", + "price range slider", + "range slider", + "two handle slider" + ] + }, + { + "name": "InputSelect", + "importPath": "@lifesg/react-design-system/input-select", + "description": "The list of options to display in the dropdown.", + "searchKeys": [ + "combobox", + "dropdown", + "option list", + "picker", + "select box" + ] + }, + { + "name": "InputSlider", + "importPath": "@lifesg/react-design-system/input-slider", + "description": "Props for the InputSlider component - single-thumb numeric slider. Renders a horizontal slider with a single thumb for selecting a numeric value within a given range. Supports step intervals, track colour customisation, and value labels. For a dual-thumb range slider use `InputRangeSlider`.", + "searchKeys": [ + "knob", + "numeric slider", + "range input", + "scrubber", + "single handle slider" + ] + }, + { + "name": "Layout", + "importPath": "@lifesg/react-design-system/layout", + "description": "Shared layout props for all layout container components.", + "searchKeys": [ + "column layout", + "flex layout", + "grid container", + "page wrapper", + "responsive container" + ] + }, + { + "name": "LinkList", + "importPath": "@lifesg/react-design-system/link-list", + "description": "Props for the LinkList component - list of labelled hyperlinks. Renders a vertical list of titled link items with optional descriptions. If `maxShown` is set, extra items collapse behind a \"Show more\" toggle.", + "searchKeys": [ + "hyperlink list", + "link group", + "nav list", + "resource links", + "show more list" + ] + }, + { + "name": "MaskedInput", + "importPath": "@lifesg/react-design-system/masked-input", + "description": "The async load state for a MaskedInput in read-only mode. - `\"loading\"`: Shows a loading indicator. - `\"fail\"`: Shows an error state with a retry action. - `\"success\"`: Value is loaded and displayed.", + "searchKeys": [ + "hide value", + "mask unmask", + "redacted input", + "reveal toggle", + "sensitive field" + ] + }, + { + "name": "Masonry", + "importPath": "@lifesg/react-design-system/masonry", + "description": "number of columns on desktop resolutions", + "searchKeys": [ + "brick layout", + "pinterest grid", + "responsive columns", + "variable height grid", + "waterfall layout" + ] + }, + { + "name": "Masthead", + "importPath": "@lifesg/react-design-system/masthead", + "description": "Note: Had to use this method because we had to create a custom type to handle Web Components (refer to this guide https://blog.devgenius.io/how-to-use-web-components-in-react-54c951399bfd) But we are having troubles exporting the custom type definition via rollup", + "searchKeys": [] + }, + { + "name": "Media", + "importPath": "@lifesg/react-design-system/media", + "description": "", + "searchKeys": [] + }, + { + "name": "Modal", + "importPath": "@lifesg/react-design-system/modal", + "description": "Props for the Modal component - animated overlay dialog. Renders content inside a portal injected into the DOM. Supports directional slide-in animation, optional overlay-click dismissal, and custom z-index for stacked modals. Toggle visibility with the `show` prop.", + "searchKeys": [ + "confirmation dialog", + "dialog", + "lightbox", + "overlay dialog", + "popup" + ] + }, + { + "name": "NavbarHeight", + "importPath": "@lifesg/react-design-system/navbar", + "description": "Navbar action buttons for mobile drawer. Takes desktop if not specified", + "searchKeys": [ + "app bar", + "header nav", + "menu bar", + "navigation header", + "top navigation" + ] + }, + { + "name": "NotificationBanner", + "importPath": "@lifesg/react-design-system/notification-banner", + "description": "Props for the NotificationBanner component - dismissible top-of-page banner. Renders a full-width banner with optional sticky positioning and a dismiss button. Toggle visibility with the `visible` prop.", + "searchKeys": [ + "alert banner", + "announcement bar", + "info bar", + "system message", + "top banner" + ] + }, + { + "name": "OtpInput", + "importPath": "@lifesg/react-design-system/otp-input", + "description": "Props for the OtpInput component - one-time password entry field. Renders a row of individual character inputs for OTP entry, with a configurable submit button and a cooldown timer that re-disables the button after each submission.", + "searchKeys": [ + "6-digit input", + "one-time password", + "PIN entry", + "SMS code", + "verification code" + ] + }, + { + "name": "Overlay", + "importPath": "@lifesg/react-design-system/overlay", + "description": "Props for the Overlay component - backdrop overlay portal. Renders a semi-transparent backdrop injected into the DOM that can optionally blur the content behind it. Used as a layer beneath modals, drawers, and other floating UI elements.", + "searchKeys": [ + "backdrop", + "background layer", + "darkened background", + "dimmer", + "scrim" + ] + }, + { + "name": "Pagination", + "importPath": "@lifesg/react-design-system/pagination", + "description": "Props for the Pagination component - page navigation control. Renders page number buttons and optional first/last navigation, with an optional page-size dropdown. Requires `totalItems` and `activePage` to be controlled by the parent.", + "searchKeys": [ + "next previous", + "page numbers", + "pager", + "paging control", + "results navigation" + ] + }, + { + "name": "PhoneNumberInput", + "importPath": "@lifesg/react-design-system/phone-number-input", + "description": "A country entry used to populate the country-code dropdown.", + "searchKeys": [ + "country code input", + "dialling code", + "international phone", + "mobile number", + "telephone field" + ] + }, + { + "name": "Pill", + "importPath": "@lifesg/react-design-system/pill", + "description": "Props for the Pill component - compact label badge. Renders a small pill-shaped tag with a configurable display style (solid fill or outline) and color scheme. Inherits all `HTMLDivElement` attributes.", + "searchKeys": [ + "badge", + "chip", + "colored label", + "label tag", + "status badge" + ] + }, + { + "name": "Popover", + "importPath": "@lifesg/react-design-system/popover", + "description": "Props for the Popover component - floating content bubble. Renders a floating tooltip-style bubble positioned relative to its trigger element. Controlled via the `visible` prop.", + "searchKeys": [ + "contextual overlay", + "floating hint", + "help popup", + "hover card", + "info bubble" + ] + }, + { + "name": "PopoverV2", + "importPath": "@lifesg/react-design-system/popover-v2", + "description": "Props for the PopoverV2 component - floating content bubble (v2). The base popover bubble controlled via `visible`. For most use cases prefer `PopoverV2.Trigger` which handles positioning automatically.", + "searchKeys": [ + "anchored tooltip", + "floating popover", + "floating-ui popover", + "info bubble v2", + "positioned overlay" + ] + }, + { + "name": "PredictiveTextInput", + "importPath": "@lifesg/react-design-system/predictive-text-input", + "description": "Props for the PredictiveTextInput component - autocomplete text input. Displays suggestions fetched asynchronously as the user types. The suggestions list appears after a configurable minimum number of characters have been entered. Selecting a suggestion calls `onSelectOption`. Used for location search, name lookup, and other typeahead scenarios.", + "searchKeys": [ + "autocomplete", + "autosuggest", + "live search", + "search suggestions", + "typeahead" + ] + }, + { + "name": "ProgressIndicator", + "importPath": "@lifesg/react-design-system/progress-indicator", + "description": "Props for the ProgressIndicator component - step progress tracker. Renders a horizontal row of step labels with the current step highlighted. Supports a custom display extractor for non-string step items.", + "searchKeys": [ + "form progress", + "multi-step progress", + "step tracker", + "stepper", + "wizard steps" + ] + }, + { + "name": "RadioButton", + "importPath": "@lifesg/react-design-system/radio-button", + "description": "Props for the RadioButton component - single-choice input control. Renders a styled radio button. Inherits all standard `HTMLInputElement` attributes including `checked`, `disabled`, `value`, and `onChange`.", + "searchKeys": [ + "choice input", + "exclusive select", + "option button", + "radio", + "single choice" + ] + }, + { + "name": "Sidenav", + "importPath": "@lifesg/react-design-system/sidenav", + "description": "Props for the Sidenav component - vertical side navigation panel. Renders a fixed-left sidebar containing `Sidenav.Group` children with icon-labelled items and optional drawer sub-items.", + "searchKeys": [ + "left navigation", + "navigation panel", + "side menu", + "sidebar menu", + "vertical nav" + ] + }, + { + "name": "SmartAppBanner", + "importPath": "@lifesg/react-design-system/smart-app-banner", + "description": "Props for the SmartAppBanner component - mobile app download prompt. Renders a banner that prompts users to download the mobile app. Supports optional slide-down animation and a dismiss callback.", + "searchKeys": [ + "app download prompt", + "app store banner", + "install banner", + "mobile app CTA", + "open in app" + ] + }, + { + "name": "Tab", + "importPath": "@lifesg/react-design-system/tab", + "description": "Props for the Tab component - tabbed content panel. Renders a row of tab selectors and shows the content of the active tab. Can be used in uncontrolled mode (`initialActive`) or controlled mode (`currentActive`).", + "searchKeys": [ + "content switcher", + "segmented view", + "tab strip", + "tabbed interface", + "tabbed panel" + ] + }, + { + "name": "Tag", + "importPath": "@lifesg/react-design-system/tag", + "description": "Props for the Tag component - interactive label badge. Like `Pill` but supports an interactive state with pointer cursor and press effects. Inherits all `HTMLElement` attributes.", + "searchKeys": [ + "category tag", + "chip", + "clickable badge", + "label badge", + "status chip" + ] + }, + { + "name": "Text", + "importPath": "@lifesg/react-design-system/text", + "description": "Props for the Text component - typography primitives for the design system.", + "searchKeys": [ + "body text", + "heading", + "label text", + "text style", + "typography" + ] + }, + { + "name": "Textarea", + "importPath": "@lifesg/react-design-system/input-textarea", + "description": "Props for the InputTextarea component - multi-line text entry field. Extends all standard HTML `