diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..b869c896 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,25 @@ +{ + "attribution": { + "commit": "", + "pr": "" + }, + "permissions": { + "allow": [ + "Bash(npm run *)", + "Bash(npm install *)", + "Bash(npm view *)", + "Bash(npx tsc *)", + "Bash(npx eslint *)", + "Bash(npx stylelint *)", + "Bash(npx prettier *)", + "Bash(git status)", + "Bash(grep *)", + "Bash(find * -name *)", + "Bash(ls *)" + ] + }, + "statusLine": { + "type": "command", + "command": "bash \"/Users/jaieds/.claude/plugins/cache/caveman/caveman/63e797cd753b/hooks/caveman-statusline.sh\"" + } +} diff --git a/.claude/skills/accessibility b/.claude/skills/accessibility new file mode 120000 index 00000000..33d9d92e --- /dev/null +++ b/.claude/skills/accessibility @@ -0,0 +1 @@ +../../.agents/skills/accessibility \ No newline at end of file diff --git a/.claude/skills/frontend-design b/.claude/skills/frontend-design new file mode 120000 index 00000000..712f694a --- /dev/null +++ b/.claude/skills/frontend-design @@ -0,0 +1 @@ +../../.agents/skills/frontend-design \ No newline at end of file diff --git a/.claude/skills/playwright-best-practices b/.claude/skills/playwright-best-practices new file mode 120000 index 00000000..e0bbc333 --- /dev/null +++ b/.claude/skills/playwright-best-practices @@ -0,0 +1 @@ +../../.agents/skills/playwright-best-practices \ No newline at end of file diff --git a/.claude/skills/seo b/.claude/skills/seo new file mode 120000 index 00000000..946efb74 --- /dev/null +++ b/.claude/skills/seo @@ -0,0 +1 @@ +../../.agents/skills/seo \ No newline at end of file diff --git a/.claude/skills/tailwind-css-patterns b/.claude/skills/tailwind-css-patterns new file mode 120000 index 00000000..03dab7dd --- /dev/null +++ b/.claude/skills/tailwind-css-patterns @@ -0,0 +1 @@ +../../.agents/skills/tailwind-css-patterns \ No newline at end of file diff --git a/.claude/skills/typescript-advanced-types b/.claude/skills/typescript-advanced-types new file mode 120000 index 00000000..4d2e5230 --- /dev/null +++ b/.claude/skills/typescript-advanced-types @@ -0,0 +1 @@ +../../.agents/skills/typescript-advanced-types \ No newline at end of file diff --git a/.claude/skills/vite b/.claude/skills/vite new file mode 120000 index 00000000..1216e951 --- /dev/null +++ b/.claude/skills/vite @@ -0,0 +1 @@ +../../.agents/skills/vite \ No newline at end of file diff --git a/.claude/skills/vitest b/.claude/skills/vitest new file mode 120000 index 00000000..76615364 --- /dev/null +++ b/.claude/skills/vitest @@ -0,0 +1 @@ +../../.agents/skills/vitest \ No newline at end of file diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index b5e8cfd4..800eeadd 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -1,44 +1,44 @@ -name: Claude Code Review - -on: - pull_request: - types: [opened, synchronize, ready_for_review, reopened] - # Optional: Only run on specific file changes - # paths: - # - "src/**/*.ts" - # - "src/**/*.tsx" - # - "src/**/*.js" - # - "src/**/*.jsx" - -jobs: - claude-review: - # Optional: Filter by PR author - # if: | - # github.event.pull_request.user.login == 'external-contributor' || - # github.event.pull_request.user.login == 'new-developer' || - # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' - - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - issues: read - id-token: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Run Claude Code Review - id: claude-review - uses: anthropics/claude-code-action@v1 - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' - plugins: 'code-review@claude-code-plugins' - prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' - # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - # or https://code.claude.com/docs/en/cli-reference for available options - +name: Claude Code Review + +on: + pull_request: + types: [opened, synchronize, ready_for_review, reopened] + # Optional: Only run on specific file changes + # paths: + # - "src/**/*.ts" + # - "src/**/*.tsx" + # - "src/**/*.js" + # - "src/**/*.jsx" + +jobs: + claude-review: + # Optional: Filter by PR author + # if: | + # github.event.pull_request.user.login == 'external-contributor' || + # github.event.pull_request.user.login == 'new-developer' || + # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' + + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code Review + id: claude-review + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' + plugins: 'code-review@claude-code-plugins' + prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index d300267f..8233826b 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -1,50 +1,50 @@ -name: Claude Code - -on: - issue_comment: - types: [created] - pull_request_review_comment: - types: [created] - issues: - types: [opened, assigned] - pull_request_review: - types: [submitted] - -jobs: - claude: - if: | - (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || - (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - issues: read - id-token: write - actions: read # Required for Claude to read CI results on PRs - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Run Claude Code - id: claude - uses: anthropics/claude-code-action@v1 - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - - # This is an optional setting that allows Claude to read CI results on PRs - additional_permissions: | - actions: read - - # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. - # prompt: 'Update the pull request description to include a summary of changes.' - - # Optional: Add claude_args to customize behavior and configuration - # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - # or https://code.claude.com/docs/en/cli-reference for available options - # claude_args: '--allowed-tools Bash(gh pr:*)' - +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + # This is an optional setting that allows Claude to read CI results on PRs + additional_permissions: | + actions: read + + # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. + # prompt: 'Update the pull request description to include a summary of changes.' + + # Optional: Add claude_args to customize behavior and configuration + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + # claude_args: '--allowed-tools Bash(gh pr:*)' + diff --git a/.github/workflows/publish-public-build.yml b/.github/workflows/publish-public-build.yml index daef2f54..0c74e290 100644 --- a/.github/workflows/publish-public-build.yml +++ b/.github/workflows/publish-public-build.yml @@ -1,56 +1,56 @@ -name: Publish Public Build - -on: - push: - branches: - - master - -jobs: - publish-build: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: '18' - - # Build the library - - name: Install and Build - run: | - npm ci - npm run build - - # Push to public mirror repo - - name: Push Build to Public Mirror - env: - FORCE_UI_TOKEN: ${{ secrets.FORCE_UI_TOKEN }} - run: | - # Clone public mirror repo - git clone https://x-access-token:${{ secrets.FORCE_UI_TOKEN }}@github.com/brainstormforce/bsf-admin-ui.git build-repo - - cd build-repo - - # Clear old dist - rm -rf dist - - # Copy new build - cp -r ../dist ./ - - # Update version in package.json - VERSION=$(node -p "require('../package.json').version") - node -e " - const pkg = require('./package.json'); - pkg.version = process.argv[1]; - require('fs').writeFileSync('package.json', JSON.stringify(pkg, null, 2)); - " "$VERSION" - - # Commit and tag - git config user.name github-actions - git config user.email github-actions@github.com - git add dist package.json - git commit -m "Release v$VERSION - build artifacts only" - git tag v$VERSION - git push origin master - git push origin v$VERSION +name: Publish Public Build + +on: + push: + branches: + - master + +jobs: + publish-build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '18' + + # Build the library + - name: Install and Build + run: | + npm ci + npm run build + + # Push to public mirror repo + - name: Push Build to Public Mirror + env: + FORCE_UI_TOKEN: ${{ secrets.FORCE_UI_TOKEN }} + run: | + # Clone public mirror repo + git clone https://x-access-token:${{ secrets.FORCE_UI_TOKEN }}@github.com/brainstormforce/bsf-admin-ui.git build-repo + + cd build-repo + + # Clear old dist + rm -rf dist + + # Copy new build + cp -r ../dist ./ + + # Update version in package.json + VERSION=$(node -p "require('../package.json').version") + node -e " + const pkg = require('./package.json'); + pkg.version = process.argv[1]; + require('fs').writeFileSync('package.json', JSON.stringify(pkg, null, 2)); + " "$VERSION" + + # Commit and tag + git config user.name github-actions + git config user.email github-actions@github.com + git add dist package.json + git commit -m "Release v$VERSION - build artifacts only" + git tag v$VERSION + git push origin master + git push origin v$VERSION diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e287f670..3572b159 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,28 +1,28 @@ -name: Publish Package - -on: - push: - branches: - - master - -jobs: - publish: - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: '18' - registry-url: 'https://npm.pkg.github.com' - scope: '@brainstormforce' - - - run: npm ci - - run: npm run build - - - run: npm publish - env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} +name: Publish Package + +on: + push: + branches: + - master + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '18' + registry-url: 'https://npm.pkg.github.com' + scope: '@brainstormforce' + + - run: npm ci + - run: npm run build + + - run: npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index d0bf53e9..e64a179f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,18 +1,23 @@ -node_modules -dist/* -.DS_Store -.cursor -.vscode -.npmrc -*storybook.log -storybook-static/* -*.zip -/test-results/ -/playwright-report/ -/blob-report/ -/playwright/.cache/ -tsconfig.app.tsbuildinfo -tsconfig.node.tsbuildinfo -*.app.tsbuildinfo -*.node.tsbuildinfo -tsconfig.app.tsbuildinfo +node_modules +.claude/settings.local.json +dist/* +.DS_Store +.cursor +.vscode +.npmrc +*storybook.log +storybook-static/* +*.zip +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +tsconfig.app.tsbuildinfo +tsconfig.node.tsbuildinfo +*.app.tsbuildinfo +*.node.tsbuildinfo +tsconfig.app.tsbuildinfo +.agents/skills +.kiro +.continue +config.bat diff --git a/.storybook/main.ts b/.storybook/main.ts index d6e28d58..8f12c045 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -1,76 +1,76 @@ -// This file has been automatically migrated to valid ESM format by Storybook. -import { fileURLToPath } from "node:url"; -import type { StorybookConfig } from '@storybook/react-vite'; -import path, { dirname } from 'path'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -/** @type { import('@storybook/react-webpack5').StorybookConfig } */ -const config: StorybookConfig = { - stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], - addons: [ - '@storybook/addon-onboarding', - '@storybook/addon-links', - '@chromatic-com/storybook', - '@storybook/addon-a11y', - '@storybook/addon-docs', - '@storybook/addon-mcp', - '@storybook/addon-vitest' - ], - framework: { - name: '@storybook/react-vite', - options: { - builder: { - viteConfigPath: path.resolve(__dirname, '..', 'vite.config.ts'), - }, - }, - }, - core: { - builder: '@storybook/builder-vite', - }, - viteFinal: async (config) => { - // Merge custom configuration into the default config - const { mergeConfig } = await import('vite'); - - // Remove the dts plugin from the default config. - config.plugins = [ - ...(config.plugins ?? []).filter((plugin) => { - return ( - (plugin as typeof plugin & Record).name !== - 'vite:dts' - ); - }), - ]; - - return mergeConfig(config, { - optimizeDeps: { - ...config?.optimizeDeps, - }, - resolve: { - ...config.resolve, - alias: { - ...config.resolve?.alias, - // 👇 Internal modules - '@/icons': path.resolve( - __dirname, - '..', - 'src/ui/icons.jsx' - ), - '@/utilities': path.resolve( - __dirname, - '..', - 'src/utilities' - ), - '@/components': path.resolve( - __dirname, - '..', - 'src/components' - ), - '@': path.resolve(__dirname, '..', 'src'), - }, - }, - }); - }, -}; -export default config; +// This file has been automatically migrated to valid ESM format by Storybook. +import { fileURLToPath } from "node:url"; +import type { StorybookConfig } from '@storybook/react-vite'; +import path, { dirname } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +/** @type { import('@storybook/react-webpack5').StorybookConfig } */ +const config: StorybookConfig = { + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + addons: [ + '@storybook/addon-onboarding', + '@storybook/addon-links', + '@chromatic-com/storybook', + '@storybook/addon-a11y', + '@storybook/addon-docs', + '@storybook/addon-mcp', + '@storybook/addon-vitest' + ], + framework: { + name: '@storybook/react-vite', + options: { + builder: { + viteConfigPath: path.resolve(__dirname, '..', 'vite.config.ts'), + }, + }, + }, + core: { + builder: '@storybook/builder-vite', + }, + viteFinal: async (config) => { + // Merge custom configuration into the default config + const { mergeConfig } = await import('vite'); + + // Remove the dts plugin from the default config. + config.plugins = [ + ...(config.plugins ?? []).filter((plugin) => { + return ( + (plugin as typeof plugin & Record).name !== + 'vite:dts' + ); + }), + ]; + + return mergeConfig(config, { + optimizeDeps: { + ...config?.optimizeDeps, + }, + resolve: { + ...config.resolve, + alias: { + ...config.resolve?.alias, + // 👇 Internal modules + '@/icons': path.resolve( + __dirname, + '..', + 'src/ui/icons.jsx' + ), + '@/utilities': path.resolve( + __dirname, + '..', + 'src/utilities' + ), + '@/components': path.resolve( + __dirname, + '..', + 'src/components' + ), + '@': path.resolve(__dirname, '..', 'src'), + }, + }, + }); + }, +}; +export default config; diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..61205be5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,84 @@ +# force-ui + +React component library for BSF projects. Publishes to GitHub Package Registry as `@bsf/force-ui`. + +## Stack + +- **React 18** + **TypeScript** — component authoring +- **Vite** — build (CJS + ESM outputs) +- **Tailwind CSS 3** — styling via `cn()` utility +- **Storybook 10** — component dev + visual regression (Chromatic) +- **Vitest** — unit/interaction tests (Storybook play functions) +- **ESLint + Prettier + Stylelint** — linting + +## Commands + +```bash +npm run build # production build +npm run start # build in watch mode +npm run storybook # dev server at :6006 +npm run test # vitest (storybook project) +npm run test:watch # vitest in watch mode +npm run lint:js-fix # eslint + prettier fix +npm run lint:css-fix # stylelint fix +npx tsc --noEmit # type-check only +``` + +## Component Conventions + +Each component lives at `src/components/{name}/`: +``` +{name}.tsx # component implementation +{name}.stories.tsx # Storybook stories +index.ts # named exports +readme.md # usage docs +``` + +### Authoring pattern + +```tsx +import { forwardRef } from 'react'; +import { cn } from '@/utilities/functions'; + +export interface FooProps { + /** JSDoc for every prop — Storybook renders these as docs */ + variant?: 'primary' | 'secondary'; + className?: string; +} + +const Foo = forwardRef( + ({ variant = 'primary', className, ...props }, ref) => { + return ( +
+ ); + } +); +Foo.displayName = 'Foo'; +export default Foo; +``` + +- Always `forwardRef` + `displayName` +- Export props interface with component +- `cn()` from `@/utilities/functions` for class merging +- Path alias `@/` → `src/` +- Props interface JSDoc required — Storybook autodocs reads them + +## Exports + +New components go in `src/components/index.ts` and `src/index.ts`. + +## Testing + +Tests via Storybook `play` functions using `@storybook/test`. Run: `npm run test`. Add `play` to stories for interaction coverage. + +## Current Focus + +_Update this section with current sprint/task context._ + +## Gotchas + +- `peerDependencies` for `react`/`react-dom` — consumers supply; don't bundle +- Storybook on `:6006`; `test-storybook` needs Storybook running first +- Chromatic upload needs `CHROMATIC_PROJECT_TOKEN` env var +- GitHub Package Registry — `npm publish` needs `NPM_TOKEN` with `write:packages` scope +- Add `.claude/settings.local.json` to local `.gitignore` for personal settings \ No newline at end of file diff --git a/README.md b/README.md index a06c1cc6..158ae011 100644 --- a/README.md +++ b/README.md @@ -1,339 +1,339 @@ -## Getting Started - Development Repo - -Learn how to use @bsf/force-ui components to quickly and easily create elegant and flexible pages using Tailwind CSS. - -@bsf/force-ui is working with Tailwind CSS classes and you need to have Tailwind CSS installed on your project - Tailwind CSS Installation. - -
- -1. Install `@bsf/force-ui`. - -Force UI library can be installed using npm package manager. Since this library is still in it's alpha phase, we need to use the staging branch. - -Using Force UI as a dependency in package.json - - -```json -"dependencies": { - "@bsf/force-ui": "git+https://github.com/brainstormforce/force-ui#1.7.11" -} -``` - -And run the following command to install the package - - - -```bash -npm install -``` - -Or you can directly run the following command to install the package - - -```bash -npm i -S @bsf/force-ui@git+https://github.com/brainstormforce/force-ui.git#1.7.11 -``` - -
- -2. Once you install @bsf/force-ui you need to wrap your tailwind css configurations with the `withTW()` function coming from @bsf/force-ui/withTW. - -```js -const withTW = require( '@bsf/force-ui/withTW' ); - -module.exports = withTW( { - content: [ './src/**/*.{js, jsx}' ], - theme: { - extend: { - colors: { - 'button-primary': '#6B21A8', - 'button-primary-hover': '#7E22CE', - 'brand-800': '#6B21A8', - 'brand-50': '#FAF5FF', - 'border-interactive': '#6B21A8', - focus: '#9333EA', - 'focus-border': '#D8B4FE', - 'toggle-on': '#6B21A8', - 'toggle-on-border': '#C084FC', - 'toggle-on-hover': '#A855F7', - }, - fontSize: { - xxs: '0.6875rem', // 11px - }, - lineHeight: { - 2.6: '0.6875rem', // 11px - }, - boxShadow: { - 'content-wrapper': - '0px 1px 1px 0px #0000000F, 0px 1px 2px 0px #0000001A', - }, - }, - }, - plugins: [], - corePlugins: { - preflight: false, - }, - important: '.surerank-styles', -} ); -``` - -
- -3. @bsf/force-ui comes with a default tailwind theme settings that set's the default theme/styles for components or to provide your own theme/styles to your components. You can override these give below variables in your tailwind.config.js file. - -```jsx -theme: { - extend: { - colors: { - // brand - 'brand-background-50': '#EFF6FF', - 'brand-background-hover-100': '#DBEAFE', - 'brand-200': '#BFDBFE', - 'brand-border-300': '#93C5FD', - 'brand-400': '#60A5FA', - 'brand-500': '#3B82F6', - 'brand-primary-600': '#2563EB', - 'brand-hover-700': '#1D4ED8', - 'brand-800': '#1E40AF', - 'brand-900': '#1E3A8A', - 'brand-text-950': '#172554', - // background - 'background-primary': '#FFFFFF', - 'background-secondary': '#F3F4F6', - 'background-inverse': '#111827', - 'background-brand': '#2563EB', - 'background-important': '#DC2626', - // field - 'field-primary-background': '#F9FAFB', - 'field-secondary-background': '#FFFFFF', - 'field-primary-hover': '#F3F4F6', - 'field-secondary-hover': '#F3F4F6', - 'field-dropzone-background': '#FFFFFF', - 'field-border': '#E5E7EB', - 'field-dropzone-background-hover': '#F9FAFB', - 'field-dropzone-color': '#2563EB', - 'field-label': '#111827', - 'field-input': '#111827', - 'field-helper': '#9CA3AF', - 'field-background-disabled': '#F9FAFB', - 'field-color-disabled': '#D1D5DB', - 'field-placeholder': '#6B7280', - 'field-border-disabled': '#F3F4F6', - 'field-color-error': '#DC2626', - 'field-border-error': '#FECACA', - 'field-background-error': '#FEF2F2', - 'field-required': '#DC2626', - // border - 'border-interactive': '#2563EB', - 'border-subtle': '#E5E7EB', - 'border-strong': '#6B7280', - 'border-inverse': '#374151', - 'border-disabled': '#E5E7EB', - 'border-muted': '#E5E7EB', - 'border-error': '#DC2626', - 'border-transparent-subtle': '#37415114', - 'border-white': '#FFFFFF', - // text - 'text-primary': '#111827', - 'text-secondary': '#4B5563', - 'text-tertiary': '#9CA3AF', - 'text-on-color': '#FFFFFF', - 'text-error': '#DC2626', - 'text-error-inverse': '#F87171', - 'text-inverse': '#FFFFFF', - 'text-disabled': '#D1D5DB', - 'text-on-button-disabled': '#9CA3AF', - // link - 'link-primary': '#2563EB', - 'link-primary-hover': '#1D4ED8', - 'link-inverse': '#38BDF8', - 'link-visited': '#7C3AED', - 'link-visited-inverse': '#A78BFA', - 'link-inverse-hover': '#7DD3FC', - // icon - 'icon-primary': '#111827', - 'icon-secondary': '#4B5563', - 'icon-on-color': '#FFFFFF', - 'icon-inverse': '#FFFFFF', - 'icon-interactive': '#2563EB', - 'icon-on-color-disabled': '#9CA3AF', - 'icon-disabled': '#D1D5DB', - // support - 'support-error': '#DC2626', - 'support-success': '#16A34A', - 'support-warning': '#EAB308', - 'support-info': '#0284C7', - 'support-error-inverse': '#F87171', - 'support-success-inverse': '#4ADE80', - 'support-warning-inverse': '#FDE047', - 'support-info-inverse': '#38BDF8', - // button - 'button-primary': '#2563EB', - 'button-primary-hover': '#1D4ED8', - 'button-secondary': '#1F2937', - 'button-secondary-hover': '#374151', - 'button-tertiary': '#FFFFFF', - 'button-tertiary-hover': '#F9FAFB', - 'button-danger': '#DC2626', - 'button-danger-secondary': '#DC2626', - 'button-danger-hover': '#B91C1C', - 'button-disabled': '#F3F4F6', - 'button-tertiary-border': '#E5E7EB', - 'button-tertiary-color': '#111827', - // focus - focus: '#2563EB', - 'focus-inset': '#FFFFFF', - 'focus-inverse': '#38BDF8', - 'focus-inverse-inset': '#111827', - 'focus-error': '#DC2626', - 'focus-border': '#BFDBFE', - 'focus-error-border': '#FECACA', - // misc - 'misc-highlight': '#BFDBFE', - 'misc-overlay': '#11182780', - 'misc-skeleton-background': '#F3F4F6', - 'misc-skeleton-element': '#D1D5DB', - 'misc-popup-button-hover': '#1118270D', - 'misc-tab-item-hover': '#E5E7EB', - 'misc-dropdown-hover': '#F3F4F6', - 'misc-loader-base': '#1118270D', - 'misc-loader-color': '#2563EB', - 'misc-progress-background': '#E5E7EB', - // badge - 'badge-background-gray': '#F9FAFB', - 'badge-color-gray': '#1F2937', - 'badge-hover-gray': '#F3F4F6', - 'badge-border-gray': '#E5E7EB', - 'badge-background-red': '#FEF2F2', - 'badge-color-red': '#B91C1C', - 'badge-hover-red': '#FEE2E2', - 'badge-border-red': '#FECACA', - 'badge-background-yellow': '#FEFCE8', - 'badge-color-yellow': '#A16207', - 'badge-hover-yellow': '#FEF9C3', - 'badge-border-yellow': '#FEF08A', - 'badge-hover-green': '#DCFCE7', - 'badge-border-green': '#BBF7D0', - 'badge-background-green': '#F0FDF4', - 'badge-color-green': '#15803D', - 'badge-background-sky': '#F0F9FF', - 'badge-color-sky': '#0369A1', - 'badge-hover-sky': '#E0F2FE', - 'badge-border-sky': '#BAE6FD', - 'badge-background-disabled': '#F3F4F6', - 'badge-color-disabled': '#D1D5DB', - 'badge-hover-disabled': '#F3F4F6', - 'badge-border-disabled': '#E5E7EB', - 'badge-background-important': '#DC2626', - // alert - 'alert-background-neutral': '#FFFFFF', - 'alert-border-neutral': '#E5E7EB', - 'alert-background-danger': '#FEF2F2', - 'alert-border-danger': '#FECACA', - 'alert-background-warning': '#FEFCE8', - 'alert-border-warning': '#FEF08A', - 'alert-background-green': '#F0FDF4', - 'alert-border-green': '#BBF7D0', - 'alert-background-info': '#F0F9FF', - 'alert-border-info': '#BAE6FD', - // tab - 'tab-background': '#F3F4F6', - 'tab-border': '#E5E7EB', - // tooltip - 'tooltip-background-light': '#FFFFFF', - 'tooltip-background-dark': '#111827', - // toggle - 'toggle-off': '#E5E7EB', - 'toggle-on': '#2563EB', - 'toggle-dial-background': '#FFFFFF', - 'toggle-off-hover': '#D1D5DB', - 'toggle-off-border': '#D1D5DB', - 'toggle-on-hover': '#3B82F6', - 'toggle-on-border': '#60A5FA', - 'toggle-off-disabled': '#F3F4F6', - }, - width: { - '1/7': '14.2857143%', - '1/8': '12.5%', - '1/9': '11.1111111%', - '1/10': '10%', - '1/11': '9.0909091%', - '1/12': '8.3333333%', - }, - boxShadow: { - 'soft-shadow-sm': - '0px 6px 32px -12px rgba(149, 160, 178, 0.12)', - 'soft-shadow': '0px 8px 32px -12px rgba(149, 160, 178, 0.16)', - 'soft-shadow-md': - '0px 10px 32px -12px rgba(149, 160, 178, 0.2)', - 'soft-shadow-lg': - '0px 12px 32px -12px rgba(149, 160, 178, 0.24)', - 'soft-shadow-xl': - '0px 16px 32px -12px rgba(149, 160, 178, 0.32)', - 'soft-shadow-2xl': - '0px 24px 64px -12px rgba(149, 160, 178, 0.32)', - 'soft-shadow-inner': '0px 1px 1px 0px rgba(0, 0, 0, 0.05)', - }, - fontSize: { - tiny: '0.625rem', - }, - spacing: { - 120: '30rem', // 480px - 95: '23.75rem', // 380px - 141.5: '35.375rem', // 566px - 188: '47rem', // 752px - }, - zIndex: { - 999999: '999999', - }, - }, -} - -``` - -
- -4. Great 🥳, now you're ready to use @bsf/force-ui. - -```jsx -import { Button } from "@bsf/force-ui"; - -export default function Example() { - return ; -} -``` - -
- -## MCP Setup - -Force UI provides an MCP server that gives AI assistants accurate component usage context correct props, patterns, and examples. So you get reliable implementations without guesswork. - -```bash -npx mcp-add --type http --url "https://brainstormforce.github.io/force-ui/mcp" --scope project # use `global` instead of `project` for making it accessible globally -``` - -When prompted, use the following configuration: - -| Prompt | Value | -| --- | --- | -| **What is the server name?** | `force-ui-mcp` | -| **HTTP headers? (comma-separated Key=value, or leave empty)** | Leave empty | -| **Which clients should be configured?** | Select your preferred AI client(s). Ex. Claude | -| **claude code OAuth client ID? (leave empty if not needed)** | Leave empty | - -
- -Now you are ready to use Force-UI MCP in your project. - -
- -## @bsf/force-ui Documentation - -Visit https://github.com/brainstormforce/force-ui/wiki for full documentation. - - -## Contributing - -Contributions are always welcome! - -See `CONTRIBUTING.md` for ways to get started. - -Please adhere to this project's `CODE_OF_CONDUCT.md`. +## Getting Started - Development Repo + +Learn how to use @bsf/force-ui components to quickly and easily create elegant and flexible pages using Tailwind CSS. + +@bsf/force-ui is working with Tailwind CSS classes and you need to have Tailwind CSS installed on your project - Tailwind CSS Installation. + +
+ +1. Install `@bsf/force-ui`. + +Force UI library can be installed using npm package manager. Since this library is still in it's alpha phase, we need to use the staging branch. + +Using Force UI as a dependency in package.json - + +```json +"dependencies": { + "@bsf/force-ui": "git+https://github.com/brainstormforce/force-ui#1.7.11" +} +``` + +And run the following command to install the package - + + +```bash +npm install +``` + +Or you can directly run the following command to install the package - + +```bash +npm i -S @bsf/force-ui@git+https://github.com/brainstormforce/force-ui.git#1.7.11 +``` + +
+ +2. Once you install @bsf/force-ui you need to wrap your tailwind css configurations with the `withTW()` function coming from @bsf/force-ui/withTW. + +```js +const withTW = require( '@bsf/force-ui/withTW' ); + +module.exports = withTW( { + content: [ './src/**/*.{js, jsx}' ], + theme: { + extend: { + colors: { + 'button-primary': '#6B21A8', + 'button-primary-hover': '#7E22CE', + 'brand-800': '#6B21A8', + 'brand-50': '#FAF5FF', + 'border-interactive': '#6B21A8', + focus: '#9333EA', + 'focus-border': '#D8B4FE', + 'toggle-on': '#6B21A8', + 'toggle-on-border': '#C084FC', + 'toggle-on-hover': '#A855F7', + }, + fontSize: { + xxs: '0.6875rem', // 11px + }, + lineHeight: { + 2.6: '0.6875rem', // 11px + }, + boxShadow: { + 'content-wrapper': + '0px 1px 1px 0px #0000000F, 0px 1px 2px 0px #0000001A', + }, + }, + }, + plugins: [], + corePlugins: { + preflight: false, + }, + important: '.surerank-styles', +} ); +``` + +
+ +3. @bsf/force-ui comes with a default tailwind theme settings that set's the default theme/styles for components or to provide your own theme/styles to your components. You can override these give below variables in your tailwind.config.js file. + +```jsx +theme: { + extend: { + colors: { + // brand + 'brand-background-50': '#EFF6FF', + 'brand-background-hover-100': '#DBEAFE', + 'brand-200': '#BFDBFE', + 'brand-border-300': '#93C5FD', + 'brand-400': '#60A5FA', + 'brand-500': '#3B82F6', + 'brand-primary-600': '#2563EB', + 'brand-hover-700': '#1D4ED8', + 'brand-800': '#1E40AF', + 'brand-900': '#1E3A8A', + 'brand-text-950': '#172554', + // background + 'background-primary': '#FFFFFF', + 'background-secondary': '#F3F4F6', + 'background-inverse': '#111827', + 'background-brand': '#2563EB', + 'background-important': '#DC2626', + // field + 'field-primary-background': '#F9FAFB', + 'field-secondary-background': '#FFFFFF', + 'field-primary-hover': '#F3F4F6', + 'field-secondary-hover': '#F3F4F6', + 'field-dropzone-background': '#FFFFFF', + 'field-border': '#E5E7EB', + 'field-dropzone-background-hover': '#F9FAFB', + 'field-dropzone-color': '#2563EB', + 'field-label': '#111827', + 'field-input': '#111827', + 'field-helper': '#9CA3AF', + 'field-background-disabled': '#F9FAFB', + 'field-color-disabled': '#D1D5DB', + 'field-placeholder': '#6B7280', + 'field-border-disabled': '#F3F4F6', + 'field-color-error': '#DC2626', + 'field-border-error': '#FECACA', + 'field-background-error': '#FEF2F2', + 'field-required': '#DC2626', + // border + 'border-interactive': '#2563EB', + 'border-subtle': '#E5E7EB', + 'border-strong': '#6B7280', + 'border-inverse': '#374151', + 'border-disabled': '#E5E7EB', + 'border-muted': '#E5E7EB', + 'border-error': '#DC2626', + 'border-transparent-subtle': '#37415114', + 'border-white': '#FFFFFF', + // text + 'text-primary': '#111827', + 'text-secondary': '#4B5563', + 'text-tertiary': '#9CA3AF', + 'text-on-color': '#FFFFFF', + 'text-error': '#DC2626', + 'text-error-inverse': '#F87171', + 'text-inverse': '#FFFFFF', + 'text-disabled': '#D1D5DB', + 'text-on-button-disabled': '#9CA3AF', + // link + 'link-primary': '#2563EB', + 'link-primary-hover': '#1D4ED8', + 'link-inverse': '#38BDF8', + 'link-visited': '#7C3AED', + 'link-visited-inverse': '#A78BFA', + 'link-inverse-hover': '#7DD3FC', + // icon + 'icon-primary': '#111827', + 'icon-secondary': '#4B5563', + 'icon-on-color': '#FFFFFF', + 'icon-inverse': '#FFFFFF', + 'icon-interactive': '#2563EB', + 'icon-on-color-disabled': '#9CA3AF', + 'icon-disabled': '#D1D5DB', + // support + 'support-error': '#DC2626', + 'support-success': '#16A34A', + 'support-warning': '#EAB308', + 'support-info': '#0284C7', + 'support-error-inverse': '#F87171', + 'support-success-inverse': '#4ADE80', + 'support-warning-inverse': '#FDE047', + 'support-info-inverse': '#38BDF8', + // button + 'button-primary': '#2563EB', + 'button-primary-hover': '#1D4ED8', + 'button-secondary': '#1F2937', + 'button-secondary-hover': '#374151', + 'button-tertiary': '#FFFFFF', + 'button-tertiary-hover': '#F9FAFB', + 'button-danger': '#DC2626', + 'button-danger-secondary': '#DC2626', + 'button-danger-hover': '#B91C1C', + 'button-disabled': '#F3F4F6', + 'button-tertiary-border': '#E5E7EB', + 'button-tertiary-color': '#111827', + // focus + focus: '#2563EB', + 'focus-inset': '#FFFFFF', + 'focus-inverse': '#38BDF8', + 'focus-inverse-inset': '#111827', + 'focus-error': '#DC2626', + 'focus-border': '#BFDBFE', + 'focus-error-border': '#FECACA', + // misc + 'misc-highlight': '#BFDBFE', + 'misc-overlay': '#11182780', + 'misc-skeleton-background': '#F3F4F6', + 'misc-skeleton-element': '#D1D5DB', + 'misc-popup-button-hover': '#1118270D', + 'misc-tab-item-hover': '#E5E7EB', + 'misc-dropdown-hover': '#F3F4F6', + 'misc-loader-base': '#1118270D', + 'misc-loader-color': '#2563EB', + 'misc-progress-background': '#E5E7EB', + // badge + 'badge-background-gray': '#F9FAFB', + 'badge-color-gray': '#1F2937', + 'badge-hover-gray': '#F3F4F6', + 'badge-border-gray': '#E5E7EB', + 'badge-background-red': '#FEF2F2', + 'badge-color-red': '#B91C1C', + 'badge-hover-red': '#FEE2E2', + 'badge-border-red': '#FECACA', + 'badge-background-yellow': '#FEFCE8', + 'badge-color-yellow': '#A16207', + 'badge-hover-yellow': '#FEF9C3', + 'badge-border-yellow': '#FEF08A', + 'badge-hover-green': '#DCFCE7', + 'badge-border-green': '#BBF7D0', + 'badge-background-green': '#F0FDF4', + 'badge-color-green': '#15803D', + 'badge-background-sky': '#F0F9FF', + 'badge-color-sky': '#0369A1', + 'badge-hover-sky': '#E0F2FE', + 'badge-border-sky': '#BAE6FD', + 'badge-background-disabled': '#F3F4F6', + 'badge-color-disabled': '#D1D5DB', + 'badge-hover-disabled': '#F3F4F6', + 'badge-border-disabled': '#E5E7EB', + 'badge-background-important': '#DC2626', + // alert + 'alert-background-neutral': '#FFFFFF', + 'alert-border-neutral': '#E5E7EB', + 'alert-background-danger': '#FEF2F2', + 'alert-border-danger': '#FECACA', + 'alert-background-warning': '#FEFCE8', + 'alert-border-warning': '#FEF08A', + 'alert-background-green': '#F0FDF4', + 'alert-border-green': '#BBF7D0', + 'alert-background-info': '#F0F9FF', + 'alert-border-info': '#BAE6FD', + // tab + 'tab-background': '#F3F4F6', + 'tab-border': '#E5E7EB', + // tooltip + 'tooltip-background-light': '#FFFFFF', + 'tooltip-background-dark': '#111827', + // toggle + 'toggle-off': '#E5E7EB', + 'toggle-on': '#2563EB', + 'toggle-dial-background': '#FFFFFF', + 'toggle-off-hover': '#D1D5DB', + 'toggle-off-border': '#D1D5DB', + 'toggle-on-hover': '#3B82F6', + 'toggle-on-border': '#60A5FA', + 'toggle-off-disabled': '#F3F4F6', + }, + width: { + '1/7': '14.2857143%', + '1/8': '12.5%', + '1/9': '11.1111111%', + '1/10': '10%', + '1/11': '9.0909091%', + '1/12': '8.3333333%', + }, + boxShadow: { + 'soft-shadow-sm': + '0px 6px 32px -12px rgba(149, 160, 178, 0.12)', + 'soft-shadow': '0px 8px 32px -12px rgba(149, 160, 178, 0.16)', + 'soft-shadow-md': + '0px 10px 32px -12px rgba(149, 160, 178, 0.2)', + 'soft-shadow-lg': + '0px 12px 32px -12px rgba(149, 160, 178, 0.24)', + 'soft-shadow-xl': + '0px 16px 32px -12px rgba(149, 160, 178, 0.32)', + 'soft-shadow-2xl': + '0px 24px 64px -12px rgba(149, 160, 178, 0.32)', + 'soft-shadow-inner': '0px 1px 1px 0px rgba(0, 0, 0, 0.05)', + }, + fontSize: { + tiny: '0.625rem', + }, + spacing: { + 120: '30rem', // 480px + 95: '23.75rem', // 380px + 141.5: '35.375rem', // 566px + 188: '47rem', // 752px + }, + zIndex: { + 999999: '999999', + }, + }, +} + +``` + +
+ +4. Great 🥳, now you're ready to use @bsf/force-ui. + +```jsx +import { Button } from "@bsf/force-ui"; + +export default function Example() { + return ; +} +``` + +
+ +## MCP Setup + +Force UI provides an MCP server that gives AI assistants accurate component usage context correct props, patterns, and examples. So you get reliable implementations without guesswork. + +```bash +npx mcp-add --type http --url "https://brainstormforce.github.io/force-ui/mcp" --scope project # use `global` instead of `project` for making it accessible globally +``` + +When prompted, use the following configuration: + +| Prompt | Value | +| --- | --- | +| **What is the server name?** | `force-ui-mcp` | +| **HTTP headers? (comma-separated Key=value, or leave empty)** | Leave empty | +| **Which clients should be configured?** | Select your preferred AI client(s). Ex. Claude | +| **claude code OAuth client ID? (leave empty if not needed)** | Leave empty | + +
+ +Now you are ready to use Force-UI MCP in your project. + +
+ +## @bsf/force-ui Documentation + +Visit https://github.com/brainstormforce/force-ui/wiki for full documentation. + + +## Contributing + +Contributions are always welcome! + +See `CONTRIBUTING.md` for ways to get started. + +Please adhere to this project's `CODE_OF_CONDUCT.md`. diff --git a/changelog.txt b/changelog.txt index 279c4b11..ccd5ef6c 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,220 +1,223 @@ -Version 1.7.11 - 8th April, 2026 -- New: Added Storybook MCP compatibility support for smoother integration with MCP-based tooling and workflows. -- Improvement: Atom - Alert: Added `role="alert"` and improved focus-visible ring on the close button. -- Improvement: Atom - Badge: Converted closable element to a semantic ` -
- ); -} ); - -export function SelectOptionGroup( { - label, - children, - className, - ...props -}: SelectOptionGroupProps ) { - const { index, totalGroups } = props as { - index: number; - totalGroups: number; - }; - const { sizeValue } = useSelectContext(); - - const groupClassNames = { - sm: 'text-xs', - md: 'text-xs', - lg: 'text-sm', - }; - - return ( - -
-
- { label } -
-
- { children } -
-
- { index < totalGroups && - !! ( children && Children.count( children ) > 0 ) && ( -
- ) } -
- ); -} - -export function SelectOptions( { - children, - className, // Additional class name for the dropdown. -}: SelectOptionsProps ) { - const { - isOpen, - context, - refs, - combobox, - floatingStyles, - getFloatingProps, - sizeValue, - setSearchKeyword, - setActiveIndex, - setSelectedIndex, - value, - selected, - getValues, - searchKeyword, - listContentRef, - by, - searchPlaceholder, - activeIndex, - searchFn, - debounceDelay, - } = useSelectContext(); - - const initialSelectedValueIndex = useMemo( () => { - const currentValue = getValues(); - let indexValue = -1; - - if ( currentValue ) { - // Get all children as an array - let allChildren = Children.toArray( children ); - - // If it's an option group, flatten the children - if ( - allChildren.length > 0 && - isValidElement( allChildren[ 0 ] ) && - allChildren[ 0 ].type === SelectOptionGroup - ) { - allChildren = Children.toArray( children ) - .map( ( group ) => - isValidElement( group ) - ? Children.toArray( group.props.children ) - : [] - ) - .flat(); - } - - indexValue = allChildren.findIndex( ( child: React.ReactNode ) => { - if ( ! isValidElement( child ) ) { - return false; - } - - const childValue = child.props.value; - - if ( - typeof childValue === 'object' && - typeof currentValue === 'object' - ) { - return ( - childValue[ by ] === - ( currentValue as Record )[ by ] - ); - } - - // For non-object values, do a direct comparison - return childValue === currentValue; - } ); - } - - return indexValue; - }, [ value, selected, children, by ] ); - - // Initialize active and selected index. - useLayoutEffect( () => { - if ( isOpen ) { - return; - } - setActiveIndex( initialSelectedValueIndex ); - setSelectedIndex( initialSelectedValueIndex ); - }, [ initialSelectedValueIndex, isOpen ] ); - - // Reset active index when search keyword changes. - useLayoutEffect( () => { - if ( ! isOpen ) { - return; - } - if ( combobox && [ -1, null ].includes( activeIndex ) ) { - return; - } - setActiveIndex( -1 ); - }, [ searchKeyword, isOpen ] ); - - // Render children based on the search keyword. - const renderChildren = useMemo( () => { - // Track actual visible groups after filtering - let visibleGroups = 0; - let totalGroups = 0; - - // First pass - count total visible groups - Children.forEach( children, ( child ) => { - if ( isValidElement( child ) && child.type === SelectOptionGroup ) { - let hasVisibleChildren = false; - - // If there's a search term and no external search function - if ( searchKeyword && ! searchFn ) { - const searchTerm = searchKeyword.toLowerCase(); - const groupLabel = child.props.label?.toLowerCase() || ''; - - // Check if group label matches search term - const groupLabelMatches = groupLabel.includes( searchTerm ); - - // Check if any child option matches search term - const hasMatchingChildren = Children.toArray( - child.props.children - ).some( ( groupChild ) => { - if ( ! isValidElement( groupChild ) ) { - return false; - } - - const textContent = getTextContent( - groupChild.props.children - )?.toLowerCase(); - - return textContent.includes( searchTerm ); - } ); - - // Show group if either group label matches or any child matches - hasVisibleChildren = groupLabelMatches || hasMatchingChildren; - } else { - // No search term, show all groups - hasVisibleChildren = true; - } - - if ( hasVisibleChildren ) { - visibleGroups++; - } - } - } ); - - totalGroups = Math.max( 0, visibleGroups - 1 ); // Subtract 1 since we don't need divider after last group - let childIndex = 0; - let groupIndex = 0; - - // Process child to render - const processChild = ( child: React.ReactNode ): React.ReactNode => { - if ( ! isValidElement( child ) ) { - return null; - } - - // Handle option groups - if ( child.type === SelectOptionGroup ) { - let groupLabelMatches = false; - - // Check if group label matches search term - if ( searchKeyword && ! searchFn ) { - const searchTerm = searchKeyword.toLowerCase(); - const groupLabel = child.props.label?.toLowerCase() || ''; - groupLabelMatches = groupLabel.includes( searchTerm ); - } - - // Recursively process children of the option group - const groupChildren = Children.map( - child.props.children, - ( groupChild ) => { - if ( ! isValidElement( groupChild ) ) { - return null; - } - - // If group label matches, show all children regardless of their content - if ( groupLabelMatches ) { - const childProps = { - ...( groupChild.props as SelectOptionProps ), - index: childIndex++, - }; - - return cloneElement( groupChild, childProps ); - } - - // Otherwise, apply normal filtering to individual options - if ( searchKeyword && ! searchFn ) { - const textContent = getTextContent( - ( groupChild.props as { children?: React.ReactNode } ).children - )?.toLowerCase(); - const searchTerm = searchKeyword.toLowerCase(); - - const textMatch = textContent?.includes( searchTerm ); - - if ( ! textMatch ) { - return null; - } - } - - const childProps = { - ...( groupChild.props as SelectOptionProps ), - index: childIndex++, - }; - - return cloneElement( groupChild, childProps ); - } - ); - - // Only render group if it has visible children - const hasChildren = groupChildren?.some( ( c: React.ReactNode ) => c !== null ); - - if ( ! hasChildren ) { - return null; - } - - const groupProps = { - ...child.props, - children: groupChildren, - index: groupIndex, - totalGroups, - }; - - groupIndex++; - return cloneElement( child, groupProps ); - } - - // Handle regular options when searchFn is not provided - if ( searchKeyword && ! searchFn ) { - const textContent = getTextContent( - child.props?.children - )?.toLowerCase(); - const searchTerm = searchKeyword.toLowerCase(); - - const textMatch = textContent?.includes( searchTerm ); - - if ( ! textMatch ) { - return null; - } - } - - return cloneElement( child, { - ...child.props, - index: childIndex++, - } ); - }; - - return Children.map( children, processChild ); - }, [ searchKeyword, value, selected, children, searchFn ] ); - const childrenCount = Children.count( renderChildren ); - - // Update the content list reference. - useEffect( () => { - listContentRef.current = []; - // Get all children as an array. - let allChildren = Children.toArray( children ); - // If it's an option group and has children. - if ( - allChildren && - isValidElement( allChildren[ 0 ] ) && - allChildren[ 0 ].type === SelectOptionGroup - ) { - allChildren = Children.toArray( allChildren ) - .map( ( child ) => - isValidElement( child ) ? child.props.children : null - ) - .filter( Boolean ); - } - // Update the list content reference. - Children.forEach( allChildren, ( child ) => { - if ( ! isValidElement( child ) ) { - return; - } - - const textContent = getTextContent( - child.props?.children - )?.toLowerCase(); - // Handle regular options when searchFn is not provided - if ( searchKeyword && ! searchFn ) { - const searchTerm = searchKeyword.toLowerCase(); - const textMatch = textContent?.includes( searchTerm ); - - if ( ! textMatch ) { - return; - } - } - - listContentRef.current.push( textContent ); - } ); - }, [ searchKeyword, searchFn ] ); - - const [ searching, setSearching ] = useState( false ); - - // Create a function to handle the search function. - const handleSearchFn = useCallback( async () => { - if ( ! searchFn || typeof searchFn !== 'function' || searching ) { - return; - } - - setSearching( true ); - try { - await searchFn( searchKeyword ); - } catch ( error ) { - // eslint-disable-next-line no-console - console.error( error ); - } finally { - setSearching( false ); - } - }, [ searchKeyword ] ); - - // Debounce the search function. - const initiateSearch = useDebouncedCallback( handleSearchFn, debounceDelay! ); - - // Initiate search when searchFn is a function. - useEffect( () => { - if ( typeof searchFn !== 'function' ) { - return; - } - initiateSearch(); - }, [ initiateSearch ] ); - - return ( - <> - { /* Dropdown */ } - { isOpen && ( - <> - - { /* Dropdown Wrapper */ } -
- { /* Searchbox */ } - { combobox && ( -
- { searching ? ( - - ) : ( - - ) } - - setSearchKeyword( event.target.value ) - } - value={ searchKeyword } - autoComplete="off" - /> -
- ) } - { /* Dropdown Items Wrapper */ } -
- { /* Dropdown Items */ } - { !! childrenCount && renderChildren } - - { /* No items found */ } - { ! childrenCount && ( -
- No items found -
- ) } -
-
-
- - ) } - - ); -} - -export function SelectPortal( { children, root, id }: SelectPortalProps ) { - return ( - - { children } - - ); -} - -export function SelectItem( { - value, - selected, - children, - className, - ...props -}: SelectOptionProps ) { - const { - sizeValue, - getItemProps, - onKeyDownItem, - onClickItem, - activeIndex, - selectedIndex, - updateListRef, - getValues, - by, - multiple, - } = useSelectContext(); - const { index: indx } = props; - const initialIndxRef = useRef( indx ); - - const selectedIconClassName = { - sm: 'size-4', - md: 'size-4', - lg: 'size-5', - }; - - const multipleChecked = useMemo( () => { - if ( ! multiple ) { - return false; - } - const currentValue = getValues(); - if ( ! currentValue ) { - return false; - } - return ( currentValue as SelectOptionValue[] ).some( ( val ) => { - if ( val !== null && value !== null && typeof val === 'object' ) { - return ( - ( val as Record )[ by ] === - ( value as Record )[ by ] - ); - } - return val === value; - } ); - }, [ value, getValues ] ); - - const isChecked = useMemo( () => { - if ( typeof selected === 'boolean' ) { - return selected; - } - - if ( multiple ) { - return multipleChecked; - } - - return indx === selectedIndex; - }, [ multipleChecked, selectedIndex, selected ] ); - - return ( -
{ - updateListRef( indx as number, node as HTMLElement ); - } } - role="option" - tabIndex={ indx === activeIndex ? 0 : -1 } - aria-selected={ isChecked && indx === activeIndex } - { ...getItemProps( { - // Handle pointer select. - onClick() { - onClickItem( initialIndxRef.current as number, value ); - }, - // Handle keyboard select. - onKeyDown( event: React.KeyboardEvent ) { - onKeyDownItem( - event, - initialIndxRef.current as number, - value - ); - }, - } ) } - > - { children } - { isChecked && ( - - ) } -
- ); -} - -const SelectComponent = ( { - id, - size: sizeValue = 'md', // sm, md, lg - value, // Value of the select (for controlled component). - defaultValue, // Default value of the select (for uncontrolled component). - onChange, // Callback function to handle the change event. - by = 'id', // Used to identify the select component. Default is 'id'. - children, - multiple = false, // If true, it will allow multiple selection. - combobox = false, // If true, it will show a search box. - disabled = false, // If true, it will disable the select component. - searchPlaceholder = 'Search...', // Placeholder text for search box. - searchFn, // Function to handle the search. - debounceDelay = 500, // Debounce delay for the search. -}: SelectProps ) => { - const selectId = useMemo( () => id || `select-${ nanoid() }`, [ id ] ); - const isControlled = useMemo( () => typeof value !== 'undefined', [ value ] ); - const [ selected, setSelected ] = useState< - SelectOptionValue | SelectOptionValue[] - >( defaultValue! ); - const [ searchKeyword, setSearchKeyword ] = useState( '' ); - - const getValues = useCallback( () => { - if ( isControlled ) { - return value as string | number | Record; - } - return selected as string | number | Record; - }, [ isControlled, value, selected ] ); - - // Dropdown position related code (Start) - const [ isOpen, setIsOpen ] = useState( false ); - const [ activeIndex, setActiveIndex ] = useState( null ); - const [ selectedIndex, setSelectedIndex ] = useState( null ); - - const dropdownMaxHeightBySize = { - sm: combobox ? 256 : 172, - md: combobox ? 256 : 216, - lg: combobox ? 256 : 216, - }; - - const { refs, floatingStyles, context } = useFloating( { - strategy: 'fixed', - placement: 'bottom-start', - open: isOpen, - onOpenChange: setIsOpen, - whileElementsMounted: autoUpdate, - middleware: [ - offset( 5 ), - flip( { padding: 10 } ), - size( { - apply( { rects, elements, availableHeight } ) { - Object.assign( elements.floating.style, { - maxHeight: `min(${ availableHeight }px, ${ dropdownMaxHeightBySize[ sizeValue as SelectSizes ] }px)`, - maxWidth: `${ rects.reference.width }px`, - } ); - }, - padding: 10, - } ), - ], - } ); - - const listRef = useRef>( [] ); - const listContentRef = useRef( [] ); - const isTypingRef = useRef( false ); - - const click = useClick( context, { event: 'mousedown' } ); - const dismiss = useDismiss( context ); - const role = useRole( context, { role: 'listbox' } ); - const listNav = useListNavigation( context, { - listRef, - activeIndex, - selectedIndex, - onNavigate: setActiveIndex, - // This is a large list, allow looping. - loop: true, - } ); - const typeahead = useTypeahead( context, { - listRef: listContentRef, - activeIndex, - selectedIndex, - onMatch: isOpen ? setActiveIndex : setSelectedIndex, - onTypingChange( isTyping ) { - isTypingRef.current = isTyping; - }, - } ); - - const { getReferenceProps, getFloatingProps, getItemProps } = - useInteractions( [ - dismiss, - role, - listNav, - click, - ...( ! combobox ? [ typeahead ] : [] ), - ] ); - - const handleMultiSelect: OnClick = ( index, newValue ) => { - const selectedValues = [ - ...( ( getValues() as SelectOptionValue[] ) ?? [] ), - ]; - const valueIndex = selectedValues.findIndex( ( selectedValue ) => { - if ( - selectedValue !== null && - newValue !== null && - typeof selectedValue === 'object' - ) { - return ( - ( selectedValue as Record )[ by ] === - ( newValue as Record )[ by ] - ); - } - return selectedValue === newValue; - } ); - - if ( valueIndex !== -1 ) { - return; - } - selectedValues.push( newValue ); - - if ( ! isControlled ) { - setSelected( selectedValues ); - } - setSelectedIndex( index ); - ( refs.reference.current as HTMLElement ).focus(); - setIsOpen( false ); - setSearchKeyword( '' ); - if ( typeof onChange === 'function' ) { - onChange( selectedValues ); - } - }; - - const handleSelect: OnClick = ( index, newValue ) => { - if ( multiple ) { - return handleMultiSelect( index, newValue ); - } - setSelectedIndex( index ); - if ( ! isControlled ) { - setSelected( newValue ); - } - ( refs.reference.current as HTMLElement ).focus(); - setIsOpen( false ); - setSearchKeyword( '' ); - if ( typeof onChange === 'function' ) { - onChange( newValue ); - } - }; - // Dropdown position related code (End) - - const updateListRef = useCallback( ( index: number, node: HTMLElement ) => { - listRef.current[ index ] = node; - }, [] ); - - const onClickItem: OnClick = ( index, newValue ) => { - handleSelect( index, newValue ); - }; - - const onKeyDownItem: OnKeyDown = ( event, index, newValue ) => { - if ( event.key === 'Enter' ) { - event.preventDefault(); - handleSelect( index, newValue ); - } - - if ( event.key === ' ' && ! isTypingRef.current ) { - event.preventDefault(); - handleSelect( index, newValue ); - } - }; - - return ( - - { children } - - ); -}; - -SelectComponent.displayName = 'Select'; - -const Select = Object.assign( SelectComponent, { - Portal: SelectPortal, - Button: SelectButton, - Options: SelectOptions, - Option: SelectItem, - OptionGroup: SelectOptionGroup, -} ); - -SelectPortal.displayName = 'Select.Portal'; -SelectButton.displayName = 'Select.Button'; -SelectOptions.displayName = 'Select.Options'; -SelectItem.displayName = 'Select.Option'; -SelectOptionGroup.displayName = 'Select.OptionGroup'; - -export default Select; +import { + useState, + useCallback, + useMemo, + useRef, + createContext, + useContext, + Children, + cloneElement, + isValidElement, + useEffect, + useLayoutEffect, + Fragment, + forwardRef, + type ReactNode, +} from 'react'; +import { cn } from '@/utilities/functions'; +import { CheckIcon, ChevronDown, ChevronsUpDown, Search } from 'lucide-react'; +import { + useFloating, + useClick, + useDismiss, + useRole, + useListNavigation, + useInteractions, + FloatingFocusManager, + useTypeahead, + offset, + flip, + size, + autoUpdate, + FloatingPortal, +} from '@floating-ui/react'; +import { Badge, Loader } from '@/components'; +import { nanoid } from 'nanoid'; +import { mergeRefs } from '@/components/toaster/utils'; +import { + disabledClassNames, + optionGroupDividerClassNames, + optionGroupDividerSizeClassNames, + selectItemClassNames, + sizeClassNames, +} from './component-style'; +import type { + OnClick, + OnKeyDown, + SelectButtonProps, + SelectContextValue, + SelectGetValues, + SelectOptionProps, + SelectOptionsProps, + SelectOptionValue, + SelectPortalProps, + SelectProps, + SelectSizes, + SelectOptionGroupProps, +} from './select-types'; +import { getTextContent } from './utils'; +import { useDebouncedCallback } from '@/utilities/hooks'; + +// Context to manage the state of the select component. +const SelectContext = createContext( + {} as SelectContextValue +); +const useSelectContext = () => useContext( SelectContext ); + +export const SelectButton = forwardRef( + ( + { + children, + icon = null, // Icon to show in the select button. + placeholder = 'Select an option', // Placeholder text. + optionIcon = null, // Icon to show in the selected option. + render, + label, // Label for the select component. + className, + ...props + }: SelectButtonProps, + ref + ) => { + const { + sizeValue, + getReferenceProps, + getValues, + selectId, + refs, + isOpen, + multiple, + combobox, + inlineSearch, + setSelected, + onChange, + isControlled, + disabled, + by, + searchKeyword, + setSearchKeyword, + searchPlaceholder, + context, + activeIndex, + optionValuesRef, + handleSelect, + } = useSelectContext(); + + const badgeSize = { + sm: 'xs', + md: 'sm', + lg: 'md', + }?.[ sizeValue as SelectSizes ]; + + const inputRef = useRef( null ); + const [ hasTyped, setHasTyped ] = useState( false ); + useEffect( () => { + if ( ! isOpen ) { + setHasTyped( false ); + } + }, [ isOpen ] ); + + // For inlineSearch single mode: derive string label of the selected value for input display. + const singleLabel = useMemo( () => { + if ( ! inlineSearch || multiple ) { + return ''; + } + const val = getValues(); + if ( ! val ) { + return ''; + } + if ( typeof render === 'function' ) { + const rendered = render( val as SelectOptionValue ); + if ( typeof rendered === 'string' ) { + return rendered; + } + } + if ( typeof val === 'string' || typeof val === 'number' ) { + return String( val ); + } + const nameKey = ( val as Record ).name; + return typeof nameKey === 'string' ? nameKey : ''; + }, [ inlineSearch, multiple, getValues, render ] ); + + // Get icon based on the Select component type and user provided icon. + const getIcon = useCallback( () => { + if ( icon ) { + return icon; + } + + const iconClassNames = + 'text-field-placeholder ' + disabledClassNames.icon; + + return combobox ? ( + + ) : ( + + ); + }, [ icon ] ); + + const renderSelected = useCallback( () => { + const selectedValue = getValues(); + + if ( ! selectedValue ) { + return null; + } + + if ( multiple ) { + return ( selectedValue as SelectOptionValue[] ).map( + ( valueItem: SelectOptionValue, index: number ) => ( + + ) + ); + } + + let renderValue: ReactNode = + typeof selectedValue === 'string' ? selectedValue : ''; + + if ( typeof render === 'function' ) { + renderValue = render( selectedValue as SelectOptionValue ); + } + + if ( + typeof children === 'function' && + typeof render !== 'function' + ) { + const childProps = { + value: selectedValue as SelectOptionValue, + ...( multiple + ? { + onClose: handleOnCloseItem( + selectedValue as SelectOptionValue + ), + } + : {} ), + }; + renderValue = children( childProps ); + } + + if ( + ( isValidElement( children ) || typeof children === 'string' ) && + typeof render !== 'function' + ) { + renderValue = children; + } + + return ( + + { renderValue as React.ReactNode } + + ); + }, [ getValues, disabled ] ); + + const handleOnCloseItem = + ( value: SelectOptionValue ) => + ( event?: React.MouseEvent ) => { + event?.preventDefault(); + event?.stopPropagation(); + + const selectedValues = [ + ...( ( getValues() as SelectOptionValue[] ) ?? [] ), + ]; + const selectedIndex = selectedValues.findIndex( ( val ) => { + if ( + val !== null && + value !== null && + typeof val === 'object' + ) { + return ( + ( val as Record )[ by ] === + ( value as Record )[ by ] + ); + } + return val === value; + } ); + + if ( selectedIndex === -1 ) { + return; + } + + selectedValues.splice( selectedIndex, 1 ); + + if ( ! isControlled ) { + setSelected( selectedValues ); + } + if ( typeof onChange === 'function' ) { + onChange( selectedValues ); + } + }; + + if ( inlineSearch ) { + let inputValue = ''; + if ( ! multiple && getValues() ) { + // Single with value: show label until user types, then show query. + inputValue = isOpen && hasTyped ? searchKeyword : singleLabel; + } else if ( isOpen ) { + inputValue = searchKeyword; + } + + const showPlaceholder = multiple + ? ! ( getValues() as SelectOptionValue[] )?.length + : ! getValues() && ! searchKeyword; + + return ( +
+ { !! label && ( + + ) } + { /* Visual chrome wrapper — POSITION reference only, not the interactive reference */ } + { /* eslint-disable-next-line jsx-a11y/no-static-element-interactions */ } +
{ + // Click on chrome (badges/padding/icon) → focus input. + if ( e.target !== inputRef.current ) { + e.preventDefault(); + inputRef.current?.focus(); + } + } } + > + { /* Badges + inline input */ } +
+ { multiple && renderSelected() } + { /* Input IS the floating-ui interactive reference. + useRole adds role/aria-expanded/aria-controls/aria-haspopup. + useListNavigation (virtual) adds aria-activedescendant + arrow key nav. + useDismiss adds Escape handling. */ } + { + if ( ! isOpen ) { + context.onOpenChange( true ); + } + }, + onClick: () => { + if ( ! isOpen ) { + context.onOpenChange( true ); + } + }, + onChange: ( + e: React.ChangeEvent + ) => { + setHasTyped( true ); + setSearchKeyword( e.target.value ); + if ( ! isOpen ) { + context.onOpenChange( true ); + } + }, + onKeyDown: ( e: React.KeyboardEvent ) => { + if ( + e.key === 'Enter' && + activeIndex !== null && + activeIndex >= 0 + ) { + e.preventDefault(); + const val = + optionValuesRef.current[ + activeIndex + ]; + if ( val !== undefined ) { + handleSelect( activeIndex, val ); + } + return; + } + if ( + e.key === 'Backspace' && + ! inputValue && + multiple + ) { + e.preventDefault(); + const arr = + ( getValues() as SelectOptionValue[] ) ?? + []; + if ( arr.length ) { + handleOnCloseItem( + arr[ arr.length - 1 ] + )(); + } + } + }, + } ) } + /> +
+ { /* Suffix Icon */ } +
svg]:shrink-0', + sizeClassNames[ sizeValue as SelectSizes ].icon + ) } + > + { getIcon() } +
+
+
+ ); + } + + return ( +
+ { !! label && ( + + ) } + +
+ ); + } +); + +export function SelectOptionGroup( { + label, + children, + className, + ...props +}: SelectOptionGroupProps ) { + const { index, totalGroups } = props as { + index: number; + totalGroups: number; + }; + const { sizeValue } = useSelectContext(); + + const groupClassNames = { + sm: 'text-xs', + md: 'text-xs', + lg: 'text-sm', + }; + + return ( + +
+
+ { label } +
+
+ { children } +
+
+ { index < totalGroups && + !! ( children && Children.count( children ) > 0 ) && ( +
+ ) } +
+ ); +} + +export function SelectOptions( { + children, + className, // Additional class name for the dropdown. +}: SelectOptionsProps ) { + const { + isOpen, + context, + refs, + combobox, + inlineSearch, + floatingStyles, + getFloatingProps, + sizeValue, + setSearchKeyword, + setActiveIndex, + setSelectedIndex, + value, + selected, + getValues, + searchKeyword, + listContentRef, + by, + searchPlaceholder, + activeIndex, + searchFn, + debounceDelay, + selectId, + optionValuesRef, + } = useSelectContext(); + + const initialSelectedValueIndex = useMemo( () => { + const currentValue = getValues(); + let indexValue = -1; + + if ( currentValue ) { + // Get all children as an array + let allChildren = Children.toArray( children ); + + // If it's an option group, flatten the children + if ( + allChildren.length > 0 && + isValidElement( allChildren[ 0 ] ) && + allChildren[ 0 ].type === SelectOptionGroup + ) { + allChildren = Children.toArray( children ) + .map( ( group ) => + isValidElement( group ) + ? Children.toArray( group.props.children ) + : [] + ) + .flat(); + } + + indexValue = allChildren.findIndex( ( child: React.ReactNode ) => { + if ( ! isValidElement( child ) ) { + return false; + } + + const childValue = child.props.value; + + if ( + typeof childValue === 'object' && + typeof currentValue === 'object' + ) { + return ( + childValue[ by ] === + ( currentValue as Record )[ by ] + ); + } + + // For non-object values, do a direct comparison + return childValue === currentValue; + } ); + } + + return indexValue; + }, [ value, selected, children, by ] ); + + // Initialize active and selected index. + useLayoutEffect( () => { + if ( isOpen ) { + return; + } + setActiveIndex( initialSelectedValueIndex ); + setSelectedIndex( initialSelectedValueIndex ); + }, [ initialSelectedValueIndex, isOpen ] ); + + // Reset active index when search keyword changes. + useLayoutEffect( () => { + if ( ! isOpen ) { + return; + } + if ( combobox && [ -1, null ].includes( activeIndex ) ) { + return; + } + setActiveIndex( -1 ); + }, [ searchKeyword, isOpen ] ); + + // Render children based on the search keyword. + const renderChildren = useMemo( () => { + // Track actual visible groups after filtering + let visibleGroups = 0; + let totalGroups = 0; + + // First pass - count total visible groups + Children.forEach( children, ( child ) => { + if ( isValidElement( child ) && child.type === SelectOptionGroup ) { + let hasVisibleChildren = false; + + // If there's a search term and no external search function + if ( searchKeyword && ! searchFn ) { + const searchTerm = searchKeyword.toLowerCase(); + const groupLabel = child.props.label?.toLowerCase() || ''; + + // Check if group label matches search term + const groupLabelMatches = groupLabel.includes( searchTerm ); + + // Check if any child option matches search term + const hasMatchingChildren = Children.toArray( + child.props.children + ).some( ( groupChild ) => { + if ( ! isValidElement( groupChild ) ) { + return false; + } + + const textContent = getTextContent( + groupChild.props.children + )?.toLowerCase(); + + return textContent.includes( searchTerm ); + } ); + + // Show group if either group label matches or any child matches + hasVisibleChildren = + groupLabelMatches || hasMatchingChildren; + } else { + // No search term, show all groups + hasVisibleChildren = true; + } + + if ( hasVisibleChildren ) { + visibleGroups++; + } + } + } ); + + totalGroups = Math.max( 0, visibleGroups - 1 ); // Subtract 1 since we don't need divider after last group + let childIndex = 0; + let groupIndex = 0; + optionValuesRef.current = []; + + // Process child to render + const processChild = ( child: React.ReactNode ): React.ReactNode => { + if ( ! isValidElement( child ) ) { + return null; + } + + // Handle option groups + if ( child.type === SelectOptionGroup ) { + let groupLabelMatches = false; + + // Check if group label matches search term + if ( searchKeyword && ! searchFn ) { + const searchTerm = searchKeyword.toLowerCase(); + const groupLabel = child.props.label?.toLowerCase() || ''; + groupLabelMatches = groupLabel.includes( searchTerm ); + } + + // Recursively process children of the option group + const groupChildren = Children.map( + child.props.children, + ( groupChild ) => { + if ( ! isValidElement( groupChild ) ) { + return null; + } + + // If group label matches, show all children regardless of their content + if ( groupLabelMatches ) { + const itemIndex = childIndex++; + optionValuesRef.current[ itemIndex ] = ( + groupChild.props as SelectOptionProps + ).value; + const childProps = { + ...( groupChild.props as SelectOptionProps ), + index: itemIndex, + id: `${ selectId }-option-${ itemIndex }`, + }; + + return cloneElement( groupChild, childProps ); + } + + // Otherwise, apply normal filtering to individual options + if ( searchKeyword && ! searchFn ) { + const textContent = getTextContent( + ( + groupChild.props as { + children?: React.ReactNode; + } + ).children + )?.toLowerCase(); + const searchTerm = searchKeyword.toLowerCase(); + + const textMatch = textContent?.includes( searchTerm ); + + if ( ! textMatch ) { + return null; + } + } + + const itemIndex = childIndex++; + optionValuesRef.current[ itemIndex ] = ( + groupChild.props as SelectOptionProps + ).value; + const childProps = { + ...( groupChild.props as SelectOptionProps ), + index: itemIndex, + id: `${ selectId }-option-${ itemIndex }`, + }; + + return cloneElement( groupChild, childProps ); + } + ); + + // Only render group if it has visible children + const hasChildren = groupChildren?.some( + ( c: React.ReactNode ) => c !== null + ); + + if ( ! hasChildren ) { + return null; + } + + const groupProps = { + ...child.props, + children: groupChildren, + index: groupIndex, + totalGroups, + }; + + groupIndex++; + return cloneElement( child, groupProps ); + } + + // Handle regular options when searchFn is not provided + if ( searchKeyword && ! searchFn ) { + const textContent = getTextContent( + child.props?.children + )?.toLowerCase(); + const searchTerm = searchKeyword.toLowerCase(); + + const textMatch = textContent?.includes( searchTerm ); + + if ( ! textMatch ) { + return null; + } + } + + const itemIndex = childIndex++; + optionValuesRef.current[ itemIndex ] = child.props.value; + return cloneElement( child, { + ...child.props, + index: itemIndex, + id: `${ selectId }-option-${ itemIndex }`, + } ); + }; + + return Children.map( children, processChild ); + }, [ + searchKeyword, + value, + selected, + children, + searchFn, + selectId, + optionValuesRef, + ] ); + const childrenCount = Children.count( renderChildren ); + + // Update the content list reference. + useEffect( () => { + listContentRef.current = []; + // Get all children as an array. + let allChildren = Children.toArray( children ); + // If it's an option group and has children. + if ( + allChildren && + isValidElement( allChildren[ 0 ] ) && + allChildren[ 0 ].type === SelectOptionGroup + ) { + allChildren = Children.toArray( allChildren ) + .map( ( child ) => + isValidElement( child ) ? child.props.children : null + ) + .filter( Boolean ); + } + // Update the list content reference. + Children.forEach( allChildren, ( child ) => { + if ( ! isValidElement( child ) ) { + return; + } + + const textContent = getTextContent( + child.props?.children + )?.toLowerCase(); + // Handle regular options when searchFn is not provided + if ( searchKeyword && ! searchFn ) { + const searchTerm = searchKeyword.toLowerCase(); + const textMatch = textContent?.includes( searchTerm ); + + if ( ! textMatch ) { + return; + } + } + + listContentRef.current.push( textContent ); + } ); + }, [ searchKeyword, searchFn ] ); + + const [ searching, setSearching ] = useState( false ); + + // Create a function to handle the search function. + const handleSearchFn = useCallback( async () => { + if ( ! searchFn || typeof searchFn !== 'function' || searching ) { + return; + } + + setSearching( true ); + try { + await searchFn( searchKeyword ); + } catch ( error ) { + // eslint-disable-next-line no-console + console.error( error ); + } finally { + setSearching( false ); + } + }, [ searchKeyword ] ); + + // Debounce the search function. + const initiateSearch = useDebouncedCallback( handleSearchFn, debounceDelay! ); + + // Initiate search when searchFn is a function. + useEffect( () => { + if ( typeof searchFn !== 'function' ) { + return; + } + initiateSearch(); + }, [ initiateSearch ] ); + + const dropdownContent = ( +
+ { /* Searchbox — combobox only; inlineSearch uses trigger input */ } + { combobox && ! inlineSearch && ( +
+ { searching ? ( + + ) : ( + + ) } + + setSearchKeyword( event.target.value ) + } + value={ searchKeyword } + autoComplete="off" + /> +
+ ) } + { /* Dropdown Items Wrapper */ } +
+ { /* Dropdown Items */ } + { !! childrenCount && renderChildren } + + { /* No items found */ } + { ! childrenCount && ( +
+ No items found +
+ ) } +
+
+ ); + + return ( + <> + { /* Dropdown */ } + { isOpen && ( + <> + { inlineSearch ? ( + dropdownContent + ) : ( + + { dropdownContent } + + ) } + + ) } + + ); +} + +export function SelectPortal( { children, root, id }: SelectPortalProps ) { + return ( + + { children } + + ); +} + +export function SelectItem( { + value, + selected, + children, + className, + ...props +}: SelectOptionProps ) { + const { + sizeValue, + getItemProps, + onKeyDownItem, + onClickItem, + activeIndex, + selectedIndex, + updateListRef, + getValues, + by, + multiple, + inlineSearch, + } = useSelectContext(); + const { index: indx, id: optionId } = props as { + index: number; + id?: string; + [key: string]: unknown; + }; + const initialIndxRef = useRef( indx ); + + const selectedIconClassName = { + sm: 'size-4', + md: 'size-4', + lg: 'size-5', + }; + + const multipleChecked = useMemo( () => { + if ( ! multiple ) { + return false; + } + const currentValue = getValues(); + if ( ! currentValue ) { + return false; + } + return ( currentValue as SelectOptionValue[] ).some( ( val ) => { + if ( val !== null && value !== null && typeof val === 'object' ) { + return ( + ( val as Record )[ by ] === + ( value as Record )[ by ] + ); + } + return val === value; + } ); + }, [ value, getValues ] ); + + const isChecked = useMemo( () => { + if ( typeof selected === 'boolean' ) { + return selected; + } + + if ( multiple ) { + return multipleChecked; + } + + return indx === selectedIndex; + }, [ multipleChecked, selectedIndex, selected ] ); + + let itemTabIndex: number | undefined; + if ( ! inlineSearch ) { + itemTabIndex = indx === activeIndex ? 0 : -1; + } + + return ( +
{ + updateListRef( indx as number, node as HTMLElement ); + } } + role="option" + tabIndex={ itemTabIndex } + aria-selected={ isChecked && indx === activeIndex } + { ...getItemProps( { + // Handle pointer select. + onClick() { + onClickItem( initialIndxRef.current as number, value ); + }, + // Handle keyboard select. + onKeyDown( event: React.KeyboardEvent ) { + onKeyDownItem( + event, + initialIndxRef.current as number, + value + ); + }, + } ) } + > + { children } + { isChecked && ( + + ) } +
+ ); +} + +const SelectComponent = ( { + id, + size: sizeValue = 'md', // sm, md, lg + value, // Value of the select (for controlled component). + defaultValue, // Default value of the select (for uncontrolled component). + onChange, // Callback function to handle the change event. + by = 'id', // Used to identify the select component. Default is 'id'. + children, + multiple = false, // If true, it will allow multiple selection. + combobox = false, // If true, it will show a search box. + inlineSearch = false, // If true, renders search input inside the trigger. + disabled = false, // If true, it will disable the select component. + searchPlaceholder = 'Search...', // Placeholder text for search box. + searchFn, // Function to handle the search. + debounceDelay = 500, // Debounce delay for the search. +}: SelectProps ) => { + const selectId = useMemo( () => id || `select-${ nanoid() }`, [ id ] ); + const isControlled = useMemo( () => typeof value !== 'undefined', [ value ] ); + + if ( process.env.NODE_ENV !== 'production' && combobox && inlineSearch ) { + // eslint-disable-next-line no-console + console.warn( + 'force-ui Select: `inlineSearch` and `combobox` are mutually exclusive. `inlineSearch` will take precedence.' + ); + } + const [ selected, setSelected ] = useState< + SelectOptionValue | SelectOptionValue[] + >( defaultValue! ); + const [ searchKeyword, setSearchKeyword ] = useState( '' ); + + const getValues = useCallback( () => { + if ( isControlled ) { + return value as string | number | Record; + } + return selected as string | number | Record; + }, [ isControlled, value, selected ] ); + + // Dropdown position related code (Start) + const [ isOpen, setIsOpen ] = useState( false ); + const [ activeIndex, setActiveIndex ] = useState( null ); + const [ selectedIndex, setSelectedIndex ] = useState( null ); + + const dropdownMaxHeightBySize = { + sm: combobox && ! inlineSearch ? 256 : 172, + md: combobox && ! inlineSearch ? 256 : 216, + lg: combobox && ! inlineSearch ? 256 : 216, + }; + + const { refs, floatingStyles, context } = useFloating( { + strategy: 'fixed', + placement: 'bottom-start', + open: isOpen, + onOpenChange: setIsOpen, + whileElementsMounted: autoUpdate, + middleware: [ + offset( 5 ), + flip( { padding: 10 } ), + size( { + apply( { rects, elements, availableHeight } ) { + Object.assign( elements.floating.style, { + maxHeight: `min(${ availableHeight }px, ${ dropdownMaxHeightBySize[ sizeValue as SelectSizes ] }px)`, + maxWidth: `${ rects.reference.width }px`, + } ); + }, + padding: 10, + } ), + ], + } ); + + const listRef = useRef>( [] ); + const listContentRef = useRef( [] ); + const isTypingRef = useRef( false ); + const optionValuesRef = useRef( [] ); + + // Clear search when dropdown closes (Escape, outside-click, or selection). + useEffect( () => { + if ( ! isOpen ) { + setSearchKeyword( '' ); + } + }, [ isOpen ] ); + + const click = useClick( context, { + event: 'mousedown', + enabled: ! inlineSearch, + } ); + const dismiss = useDismiss( context ); + const role = useRole( context, { role: 'listbox' } ); + const listNav = useListNavigation( context, { + listRef, + activeIndex, + selectedIndex, + onNavigate: setActiveIndex, + loop: true, + // virtual: input is the reference, items use aria-activedescendant rather than DOM focus. + virtual: inlineSearch, + } ); + const typeahead = useTypeahead( context, { + listRef: listContentRef, + activeIndex, + selectedIndex, + onMatch: isOpen ? setActiveIndex : setSelectedIndex, + onTypingChange( isTyping ) { + isTypingRef.current = isTyping; + }, + } ); + + const { getReferenceProps, getFloatingProps, getItemProps } = + useInteractions( [ + dismiss, + role, + listNav, + click, + ...( ! combobox && ! inlineSearch ? [ typeahead ] : [] ), + ] ); + + const handleMultiSelect: OnClick = ( index, newValue ) => { + const selectedValues = [ + ...( ( getValues() as SelectOptionValue[] ) ?? [] ), + ]; + const valueIndex = selectedValues.findIndex( ( selectedValue ) => { + if ( + selectedValue !== null && + newValue !== null && + typeof selectedValue === 'object' + ) { + return ( + ( selectedValue as Record )[ by ] === + ( newValue as Record )[ by ] + ); + } + return selectedValue === newValue; + } ); + + if ( valueIndex !== -1 ) { + return; + } + selectedValues.push( newValue ); + + if ( ! isControlled ) { + setSelected( selectedValues ); + } + setSelectedIndex( index ); + ( + ( refs.domReference.current ?? + refs.reference.current ) as HTMLElement | null + )?.focus(); + setIsOpen( false ); + setSearchKeyword( '' ); + if ( typeof onChange === 'function' ) { + onChange( selectedValues ); + } + }; + + const handleSelect: OnClick = ( index, newValue ) => { + if ( multiple ) { + return handleMultiSelect( index, newValue ); + } + setSelectedIndex( index ); + if ( ! isControlled ) { + setSelected( newValue ); + } + ( + ( refs.domReference.current ?? + refs.reference.current ) as HTMLElement | null + )?.focus(); + setIsOpen( false ); + setSearchKeyword( '' ); + if ( typeof onChange === 'function' ) { + onChange( newValue ); + } + }; + // Dropdown position related code (End) + + const updateListRef = useCallback( ( index: number, node: HTMLElement ) => { + listRef.current[ index ] = node; + }, [] ); + + const onClickItem: OnClick = ( index, newValue ) => { + handleSelect( index, newValue ); + }; + + const onKeyDownItem: OnKeyDown = ( event, index, newValue ) => { + if ( event.key === 'Enter' ) { + event.preventDefault(); + handleSelect( index, newValue ); + } + + if ( event.key === ' ' && ! isTypingRef.current ) { + event.preventDefault(); + handleSelect( index, newValue ); + } + }; + + return ( + + { children } + + ); +}; + +SelectComponent.displayName = 'Select'; + +const Select = Object.assign( SelectComponent, { + Portal: SelectPortal, + Button: SelectButton, + Options: SelectOptions, + Option: SelectItem, + OptionGroup: SelectOptionGroup, +} ); + +SelectPortal.displayName = 'Select.Portal'; +SelectButton.displayName = 'Select.Button'; +SelectOptions.displayName = 'Select.Options'; +SelectItem.displayName = 'Select.Option'; +SelectOptionGroup.displayName = 'Select.OptionGroup'; + +export default Select; diff --git a/src/components/textarea/textarea.stories.tsx b/src/components/textarea/textarea.stories.tsx index 8e55b822..4c2de4a1 100644 --- a/src/components/textarea/textarea.stories.tsx +++ b/src/components/textarea/textarea.stories.tsx @@ -1,62 +1,62 @@ -import type { Meta, StoryFn } from '@storybook/react-vite'; -import TextArea, { TextAreaProps } from './textarea'; - -const meta: Meta = { - title: 'Atoms/TextArea', - component: TextArea, - parameters: { - layout: 'centered', - }, - tags: [ 'autodocs' ], - argTypes: { - size: { - control: 'select', - }, - }, -}; - -export default meta; - -const Template: StoryFn = ( args ) => { - return ( - <> -