├── .dev
└── styles.css
├── .eslintignore
├── .eslintrc
├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
├── pull_request_template.md
└── workflows
│ ├── codeql-analysis.yml
│ ├── development.yml
│ └── npm-publish.yml
├── .gitignore
├── .npmrc
├── .prettierignore
├── .prettierrc
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── jest.config.js
├── lib
├── As
│ ├── As.tsx
│ ├── AsClone.tsx
│ ├── index.ts
│ └── utils.ts
├── Breadcrumb
│ ├── Breadcrumb.test.tsx
│ ├── Breadcrumb.tsx
│ ├── components
│ │ ├── Item.tsx
│ │ ├── List.tsx
│ │ ├── SeparatorItem.tsx
│ │ └── index.ts
│ ├── index.ts
│ └── slots.ts
├── Button
│ ├── Button.tsx
│ ├── index.ts
│ └── slots.ts
├── CheckGroup
│ ├── CheckGroup.test.tsx
│ ├── CheckGroup.tsx
│ ├── context.ts
│ ├── index.ts
│ └── slots.ts
├── Checkbox
│ ├── Checkbox.test.tsx
│ ├── Checkbox.tsx
│ ├── index.ts
│ └── slots.ts
├── Dialog
│ ├── Dialog.test.tsx
│ ├── Dialog.tsx
│ ├── components
│ │ ├── Backdrop.tsx
│ │ ├── Content.tsx
│ │ ├── Description.tsx
│ │ ├── Title.tsx
│ │ └── index.ts
│ ├── context.ts
│ ├── index.ts
│ └── slots.ts
├── Expandable
│ ├── Expandable.test.tsx
│ ├── Expandable.tsx
│ ├── components
│ │ ├── Content.tsx
│ │ ├── Trigger.tsx
│ │ └── index.ts
│ ├── context.ts
│ ├── index.ts
│ └── slots.ts
├── InputSlider
│ ├── InputSlider.test.tsx
│ ├── InputSlider.tsx
│ ├── components
│ │ ├── InfimumThumb.tsx
│ │ ├── Range.tsx
│ │ ├── SupremumThumb.tsx
│ │ ├── Thumb.tsx
│ │ ├── Track.tsx
│ │ └── index.ts
│ ├── context.ts
│ ├── index.ts
│ ├── slots.ts
│ ├── types.ts
│ └── utils.ts
├── Menu
│ ├── BaseMenu.tsx
│ ├── Menu.test.tsx
│ ├── Menu.tsx
│ ├── components
│ │ ├── CheckItem.tsx
│ │ ├── Group.tsx
│ │ ├── Item
│ │ │ ├── Item.tsx
│ │ │ ├── context.ts
│ │ │ └── index.ts
│ │ ├── RadioGroup
│ │ │ ├── RadioGroup.tsx
│ │ │ ├── context.ts
│ │ │ └── index.ts
│ │ ├── RadioItem.tsx
│ │ ├── SeparatorItem.tsx
│ │ ├── SubMenu.tsx
│ │ └── index.ts
│ ├── constants.ts
│ ├── context.ts
│ ├── index.ts
│ ├── slots.ts
│ └── utils.ts
├── Meter
│ ├── Meter.tsx
│ ├── index.ts
│ └── slots.ts
├── Popper
│ ├── Popper.tsx
│ ├── index.ts
│ ├── slots.ts
│ ├── types.ts
│ └── utils.ts
├── Portal
│ ├── Portal.tsx
│ ├── index.ts
│ └── utils.ts
├── PortalConfigProvider
│ ├── PortalConfigProvider.tsx
│ ├── context.ts
│ ├── index.ts
│ └── usePortalConfig.ts
├── PreserveAspectRatio
│ ├── PreserveAspectRatio.tsx
│ ├── index.ts
│ └── slots.ts
├── ProgressBar
│ ├── ProgressBar.tsx
│ ├── index.ts
│ └── slots.ts
├── Radio
│ ├── Radio.test.tsx
│ ├── Radio.tsx
│ ├── index.ts
│ └── slots.ts
├── RadioGroup
│ ├── RadioGroup.test.tsx
│ ├── RadioGroup.tsx
│ ├── context.ts
│ ├── index.ts
│ └── slots.ts
├── Select
│ ├── Select.test.tsx
│ ├── Select.tsx
│ ├── components
│ │ ├── Controller
│ │ │ ├── Controller.tsx
│ │ │ ├── index.ts
│ │ │ └── utils.ts
│ │ ├── EmptyStatement.tsx
│ │ ├── Group.tsx
│ │ ├── List
│ │ │ ├── List.tsx
│ │ │ ├── index.ts
│ │ │ └── utils.ts
│ │ ├── Option.tsx
│ │ ├── Trigger.tsx
│ │ └── index.ts
│ ├── context.ts
│ ├── index.ts
│ ├── slots.ts
│ └── utils.ts
├── SpinButton
│ ├── SpinButton.test.tsx
│ ├── SpinButton.tsx
│ ├── components
│ │ ├── DecrementButton.tsx
│ │ ├── IncrementButton.tsx
│ │ └── index.ts
│ ├── context.ts
│ ├── index.ts
│ └── slots.ts
├── Switch
│ ├── Switch.test.tsx
│ ├── Switch.tsx
│ ├── index.ts
│ └── slots.ts
├── TabGroup
│ ├── TabGroup.test.tsx
│ ├── TabGroup.tsx
│ ├── components
│ │ ├── List.tsx
│ │ ├── Panel.tsx
│ │ ├── Tab.tsx
│ │ └── index.ts
│ ├── context.ts
│ ├── index.ts
│ └── slots.ts
├── Toast
│ ├── Toast.test.tsx
│ ├── Toast.tsx
│ ├── components
│ │ ├── Action.tsx
│ │ ├── Content.tsx
│ │ └── index.ts
│ ├── context.ts
│ ├── index.ts
│ └── slots.ts
├── Toggle
│ ├── Toggle.test.tsx
│ ├── Toggle.tsx
│ ├── index.ts
│ └── slots.ts
├── ToggleGroup
│ ├── ToggleGroup.test.tsx
│ ├── ToggleGroup.tsx
│ ├── context.ts
│ ├── index.ts
│ └── slots.ts
├── Tooltip
│ ├── Tooltip.test.tsx
│ ├── Tooltip.tsx
│ └── index.ts
├── TreeView
│ ├── TreeView.test.tsx
│ ├── TreeView.tsx
│ ├── components
│ │ ├── Item
│ │ │ ├── Item.tsx
│ │ │ ├── context.ts
│ │ │ └── index.ts
│ │ ├── SubTree.tsx
│ │ └── index.ts
│ ├── contexts.ts
│ ├── index.ts
│ ├── slots.ts
│ └── utils.ts
├── index.ts
├── internals
│ ├── FocusRedirect
│ │ ├── FocusRedirect.tsx
│ │ └── index.ts
│ ├── FocusTrap
│ │ ├── FocusTrap.tsx
│ │ └── index.ts
│ ├── SystemError.ts
│ ├── get-label-info.ts
│ ├── index.ts
│ ├── keys.ts
│ ├── logger.ts
│ ├── prefix-message.ts
│ ├── resolve-prop-with-render-context.ts
│ ├── styles.ts
│ └── use-jump-to-char.ts
├── types.ts
└── utils
│ ├── component-with-forwarded-ref.ts
│ ├── create-custom-event.ts
│ ├── create-virtual-element.ts
│ ├── dispatch-discrete-custom-event.ts
│ ├── dom.ts
│ ├── fork-refs.ts
│ ├── get-direction.ts
│ ├── get-scrolling-state.ts
│ ├── index.ts
│ ├── is.ts
│ ├── math.ts
│ ├── request-form-submit.ts
│ ├── set-ref.ts
│ ├── use-button-base.ts
│ ├── use-check-base.ts
│ ├── use-event-callback.ts
│ ├── use-is-focus-visible.ts
│ ├── use-is-initial-render-complete.ts
│ ├── use-isomorphic-layout-effect.ts
│ └── use-isomorphic-value.ts
├── next-env.d.ts
├── next.config.js
├── package.json
├── pages
├── _app.tsx
├── _document.tsx
└── index.tsx
├── pnpm-lock.yaml
├── readme-dark-icon.svg
├── readme-light-icon.svg
├── scripts
├── build-package.ts
├── ci
│ └── publish-package.ts
└── minify-package.ts
├── tests
├── jest.setup.ts
└── utils
│ ├── index.ts
│ ├── itIsPolymorphic.tsx
│ ├── itShouldMount.tsx
│ ├── itSupportsClassName.tsx
│ ├── itSupportsDataSetProps.tsx
│ ├── itSupportsFocusEvents.tsx
│ ├── itSupportsRef.tsx
│ ├── itSupportsStyle.tsx
│ └── wait.ts
├── tsconfig.build.json
├── tsconfig.cjs.json
├── tsconfig.esm.json
├── tsconfig.json
└── tsconfig.lint.json
/.dev/styles.css:
--------------------------------------------------------------------------------
1 | body {
2 | font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
3 | Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
4 | }
5 |
6 | *,
7 | *::after,
8 | *::before {
9 | box-sizing: border-box;
10 | }
11 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | .swc
4 | .github
5 | .next
6 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "eslint:recommended",
4 | "plugin:react/recommended",
5 | "plugin:react-hooks/recommended",
6 | "plugin:import/recommended",
7 | "plugin:import/typescript",
8 | "prettier",
9 | "plugin:prettier/recommended",
10 | ],
11 | "env": {
12 | "browser": true,
13 | "es6": true,
14 | "node": true,
15 | "commonjs": true,
16 | },
17 | "globals": {
18 | "Atomics": "readonly",
19 | "SharedArrayBuffer": "readonly",
20 | "JSX": true,
21 | },
22 | "plugins": [
23 | "import",
24 | "react",
25 | "react-hooks",
26 | "@typescript-eslint/eslint-plugin",
27 | ],
28 | "parser": "@typescript-eslint/parser",
29 | "parserOptions": {
30 | "sourceType": "module",
31 | },
32 | "rules": {
33 | "no-alert": "error",
34 | "no-console": "warn",
35 | "prefer-const": "error",
36 | "default-case": "warn",
37 | "eol-last": "error",
38 | "object-shorthand": "error",
39 | "require-atomic-updates": "error",
40 | "no-unused-private-class-members": "warn",
41 | "no-promise-executor-return": "error",
42 | "no-unmodified-loop-condition": "warn",
43 | "eqeqeq": ["error", "smart"],
44 | "no-duplicate-imports": [
45 | "error",
46 | {
47 | "includeExports": true,
48 | },
49 | ],
50 | "@typescript-eslint/consistent-type-imports": [
51 | "error",
52 | {
53 | "fixStyle": "inline-type-imports",
54 | },
55 | ],
56 | "padding-line-between-statements": [
57 | "error",
58 | {
59 | "blankLine": "always",
60 | "prev": [
61 | "const",
62 | "let",
63 | "var",
64 | "directive",
65 | "function",
66 | "class",
67 | "block",
68 | "block-like",
69 | "multiline-block-like",
70 | ],
71 | "next": "*",
72 | },
73 | {
74 | "blankLine": "any",
75 | "prev": ["const", "let", "var", "directive"],
76 | "next": ["const", "let", "var", "directive"],
77 | },
78 | {
79 | "blankLine": "always",
80 | "prev": ["multiline-const", "multiline-let"],
81 | "next": "*",
82 | },
83 | ],
84 | "react/prop-types": "off",
85 | "react/react-in-jsx-scope": "off",
86 | "@typescript-eslint/no-unused-vars": [
87 | "warn",
88 | {
89 | "argsIgnorePattern": "^_",
90 | "varsIgnorePattern": "^_",
91 | },
92 | ],
93 | },
94 | "overrides": [
95 | {
96 | "files": ["*.ts", "*.tsx", "*.d.ts"],
97 | "extends": [
98 | "plugin:import/typescript",
99 | "plugin:@typescript-eslint/recommended",
100 | "plugin:@typescript-eslint/recommended-requiring-type-checking",
101 | ],
102 | "parserOptions": {
103 | "sourceType": "module",
104 | "project": ["tsconfig.json"],
105 | },
106 | },
107 | {
108 | "files": [
109 | "**/__tests__/**/*.[jt]s?(x)",
110 | "**/?(*.)+(spec|test).[jt]s?(x)",
111 | ],
112 | "extends": [
113 | "plugin:testing-library/react",
114 | "plugin:jest-dom/recommended",
115 | "plugin:jest/recommended",
116 | ],
117 | },
118 | ],
119 | "settings": {
120 | "react": {
121 | "version": "detect",
122 | },
123 | },
124 | }
125 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Desktop (please complete the following information):**
27 | - OS: [e.g. iOS]
28 | - Browser [e.g. chrome, safari]
29 | - Version [e.g. 22]
30 |
31 | **Smartphone (please complete the following information):**
32 | - Device: [e.g. iPhone6]
33 | - OS: [e.g. iOS8.1]
34 | - Browser [e.g. stock browser, safari]
35 | - Version [e.g. 22]
36 |
37 | **Additional context**
38 | Add any other context about the problem here.
39 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 |
6 |
7 | ## Bug
8 |
9 | - [ ] Related issues linked using `fixes #number`
10 | - [ ] Tests added
11 |
12 | ## Feature
13 |
14 | - [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR.
15 | - [ ] Related issues linked using `fixes #number`
16 | - [ ] Tests added
17 | - [ ] Documentation added
18 | - [ ] Telemetry added. In case of a feature if it's used or not.
19 |
--------------------------------------------------------------------------------
/.github/workflows/codeql-analysis.yml:
--------------------------------------------------------------------------------
1 | name: CodeQL
2 |
3 | on:
4 | push:
5 | branches:
6 | - "next"
7 | pull_request:
8 | schedule:
9 | # on sunday of each month at 5:55
10 | - cron: "55 5 * * 0"
11 | workflow_call:
12 |
13 | jobs:
14 | analyze:
15 | name: "Analyze"
16 | runs-on: ubuntu-latest
17 | strategy:
18 | fail-fast: false
19 | matrix:
20 | language:
21 | - javascript
22 | steps:
23 | - uses: actions/checkout@v4
24 |
25 | - name: "🎬 Initialize CodeQL"
26 | uses: github/codeql-action/init@v3
27 | with:
28 | languages: ${{ matrix.language }}
29 |
30 | - name: "🏗️ Autobuild"
31 | uses: github/codeql-action/autobuild@v3
32 |
33 | - name: "🧐 Perform CodeQL Analysis"
34 | uses: github/codeql-action/analyze@v3
35 |
--------------------------------------------------------------------------------
/.github/workflows/development.yml:
--------------------------------------------------------------------------------
1 | name: Development
2 |
3 | on:
4 | pull_request:
5 | types:
6 | - opened
7 | - edited
8 | - synchronize
9 | - reopened
10 | workflow_call:
11 |
12 | jobs:
13 | test:
14 | name: Test components
15 | runs-on: ubuntu-latest
16 | timeout-minutes: 10
17 | steps:
18 | - uses: actions/checkout@v4
19 |
20 | - uses: pnpm/action-setup@v3
21 | with:
22 | version: 9
23 |
24 | - uses: actions/setup-node@v4
25 | with:
26 | node-version: 20
27 | cache: "pnpm"
28 |
29 | - name: "📦 install dependencies"
30 | run: pnpm install
31 |
32 | - name: "🔍 run tests"
33 | run: pnpm test
34 |
35 | lint:
36 | name: Code standards
37 | runs-on: ubuntu-latest
38 | timeout-minutes: 10
39 | steps:
40 | - name: "☁️ checkout repository"
41 | uses: actions/checkout@v4
42 |
43 | - name: "🔧 setup pnpm"
44 | uses: pnpm/action-setup@v3
45 | with:
46 | version: 9
47 |
48 | - name: "🔧 setup node"
49 | uses: actions/setup-node@v4
50 | with:
51 | node-version: 20
52 | cache: "pnpm"
53 |
54 | - name: "📦 install dependencies"
55 | run: pnpm install
56 |
57 | - name: "🔍 lint code"
58 | run: pnpm lint
59 |
--------------------------------------------------------------------------------
/.github/workflows/npm-publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish
2 |
3 | on:
4 | release:
5 | types: [created]
6 |
7 | jobs:
8 | build:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - uses: actions/checkout@v4
12 |
13 | - uses: pnpm/action-setup@v3
14 | with:
15 | version: 9
16 |
17 | - uses: actions/setup-node@v4
18 | with:
19 | node-version: 20
20 | cache: "pnpm"
21 |
22 | - name: "📦 install dependencies"
23 | run: pnpm install
24 |
25 | - name: "🧱 build package"
26 | run: pnpm build
27 |
28 | - name: "🗄️ archive package"
29 | uses: actions/upload-artifact@v4
30 | with:
31 | name: dist
32 | path: dist
33 |
34 | publish-npm:
35 | needs: build
36 | runs-on: ubuntu-latest
37 | steps:
38 | - uses: actions/checkout@v4
39 |
40 | - uses: pnpm/action-setup@v3
41 | with:
42 | version: 9
43 |
44 | - uses: actions/setup-node@v4
45 | with:
46 | node-version: 20
47 | cache: "pnpm"
48 | registry-url: https://registry.npmjs.org/
49 |
50 | - name: "📦 install dependencies"
51 | run: pnpm install
52 |
53 | - name: "🚚 download package"
54 | uses: actions/download-artifact@v4
55 | with:
56 | name: dist
57 | path: dist
58 |
59 | - name: "🚀 publish package"
60 | run: npx tsx ./scripts/ci/publish-package.ts
61 | env:
62 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
63 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 |
9 | # Diagnostic reports (https://nodejs.org/api/report.html)
10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
11 |
12 | # Runtime data
13 | pids
14 | *.pid
15 | *.seed
16 | *.pid.lock
17 |
18 | # Directory for instrumented libs generated by jscoverage/JSCover
19 | lib-cov
20 |
21 | # Coverage directory used by tools like istanbul
22 | coverage
23 | *.lcov
24 |
25 | # nyc test coverage
26 | .nyc_output
27 |
28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
29 | .grunt
30 |
31 | # Bower dependency directory (https://bower.io/)
32 | bower_components
33 |
34 | # node-waf configuration
35 | .lock-wscript
36 |
37 | # Compiled binary addons (https://nodejs.org/api/addons.html)
38 | build/Release
39 |
40 | # Dependency directories
41 | node_modules/
42 | jspm_packages/
43 |
44 | # TypeScript v1 declaration files
45 | typings/
46 |
47 | # TypeScript cache
48 | *.tsbuildinfo
49 |
50 | # Optional npm cache directory
51 | .npm
52 |
53 | # Optional eslint cache
54 | .eslintcache
55 |
56 | # Microbundle cache
57 | .rpt2_cache/
58 | .rts2_cache_cjs/
59 | .rts2_cache_es/
60 | .rts2_cache_umd/
61 |
62 | # Optional REPL history
63 | .node_repl_history
64 |
65 | # Output of 'npm pack'
66 | *.tgz
67 |
68 | # Yarn Integrity file
69 | .yarn-integrity
70 |
71 | # dotenv environment variables file
72 | .env
73 | .env.test
74 |
75 | # parcel-bundler cache (https://parceljs.org/)
76 | .cache
77 |
78 | # Next.js build output
79 | .next
80 |
81 | # Nuxt.js build / generate output
82 | .nuxt
83 | dist
84 |
85 | # Gatsby files
86 | .cache/
87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js
88 | # https://nextjs.org/blog/next-9-1#public-directory-support
89 | # public
90 |
91 | # vuepress build output
92 | .vuepress/dist
93 |
94 | # Serverless directories
95 | .serverless/
96 |
97 | # FuseBox cache
98 | .fusebox/
99 |
100 | # DynamoDB Local files
101 | .dynamodb/
102 |
103 | # TernJS port file
104 | .tern-port
105 |
106 | .DS_Store
107 | .vscode
108 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | public-hoist-pattern[]=*eslint*
2 | public-hoist-pattern[]=*prettier*
3 | public-hoist-pattern[]=@types*
4 | enable-pre-post-scripts=true
5 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | .swc
4 | .github
5 | .next
6 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "bracketSpacing": true,
3 | "printWidth": 80,
4 | "semi": true,
5 | "singleQuote": false,
6 | "tabWidth": 2,
7 | "trailingComma": "all",
8 | "arrowParens": "avoid",
9 | "bracketSameLine": false,
10 | "endOfLine": "lf",
11 | "htmlWhitespaceSensitivity": "css",
12 | "jsxSingleQuote": false,
13 | "singleAttributePerLine": true,
14 | "plugins": ["prettier-plugin-organize-imports"]
15 | }
16 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 styleless-ui
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |

3 |

4 |
[ ui | styleless/react ]
5 |
6 |
7 |
8 |
9 | Completely unstyled, headless and accessible [React](https://reactjs.org/) UI components.
10 |
11 |
12 |
13 |
14 |
15 | ## Public Roadmap
16 |
17 | Our project [roadmap](https://github.com/orgs/styleless-ui/projects/1/views/1?visibleFields=%5B%22Title%22%2C%22Assignees%22%2C%22Status%22%2C%22Labels%22%2C%22Repository%22%2C%22Milestone%22%5D) is where you can learn about what features we're working on, what stage they're in, and when we expect to bring them to you. Have any questions or comments about items on the roadmap? Share your feedback via [StylelessUI public feedback discussions](https://github.com/styleless-ui/react-styleless-ui/discussions/categories/feedback).
18 |
19 | ## Contributing
20 |
21 | Read the [contributing guide](https://github.com/styleless-ui/react-styleless-ui/blob/stable/CONTRIBUTING.md) to learn about our development process, how to propose bug fixes and improvements, and how to build and test your changes.
22 |
23 | Contributing to styleless-ui is about more than just issues and pull requests! There are many other ways to support the project beyond contributing to the code base.
24 |
25 | ## License
26 |
27 | This project is licensed under the terms of the [MIT license](https://github.com/styleless-ui/react-styleless-ui/blob/stable/LICENSE).
28 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | import nextJest from "next/jest.js";
2 |
3 | const createJestConfig = nextJest({ dir: "./" });
4 |
5 | /**
6 | * @type {import("jest").Config}
7 | */
8 | const jestConfig = {
9 | verbose: true,
10 | setupFilesAfterEnv: ["/tests/jest.setup.ts"],
11 | testPathIgnorePatterns: ["/.next/", "/node_modules/"],
12 | testRegex: ".*\\.test\\.tsx?$",
13 | testEnvironment: "jest-environment-jsdom",
14 | preset: "ts-jest",
15 | };
16 |
17 | export default createJestConfig(jestConfig);
18 |
--------------------------------------------------------------------------------
/lib/As/As.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { componentWithForwardedRef } from "../utils";
3 | import AsClone from "./AsClone";
4 |
5 | type OwnProps = {
6 | /**
7 | * The content of the component. It should be a single non-fragment React element.
8 | */
9 | children: React.ReactElement;
10 | };
11 |
12 | export type Props = React.HTMLAttributes & OwnProps;
13 |
14 | const AsBase = (props: Props, ref: React.Ref) => {
15 | const { children, ...otherProps } = props;
16 |
17 | return (
18 |
22 | {children}
23 |
24 | );
25 | };
26 |
27 | const As = componentWithForwardedRef(AsBase, "As");
28 |
29 | export default As;
30 |
--------------------------------------------------------------------------------
/lib/As/AsClone.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { SystemError } from "../internals";
3 | import type { UnknownObject } from "../types";
4 | import { componentWithForwardedRef, forkRefs, isFragment } from "../utils";
5 | import { type Props } from "./As";
6 | import { mergeProps } from "./utils";
7 |
8 | const AsCloneBase = (
9 | props: Props & React.RefAttributes,
10 | ref: React.Ref,
11 | ) => {
12 | const { children, ...otherProps } = props;
13 |
14 | if (React.isValidElement(children)) {
15 | type SingleElement = typeof children;
16 |
17 | if (isFragment(children)) {
18 | throw new SystemError(
19 | "The component is not expected to receive a React Fragment child.",
20 | "As",
21 | );
22 | }
23 |
24 | const childProps = (children as SingleElement).props as UnknownObject;
25 | const cloneProps = mergeProps(otherProps, childProps);
26 |
27 | cloneProps.ref = forkRefs(
28 | ref,
29 | (children as SingleElement & { ref: React.Ref }).ref,
30 | );
31 |
32 | return React.cloneElement(children, cloneProps);
33 | }
34 |
35 | try {
36 | return React.Children.only(null);
37 | } catch {
38 | throw new SystemError(
39 | "The component expected to receive a single React element child.",
40 | "As",
41 | );
42 | }
43 | };
44 |
45 | const AsClone = componentWithForwardedRef(AsCloneBase, "AsClone");
46 |
47 | export default AsClone;
48 |
--------------------------------------------------------------------------------
/lib/As/index.ts:
--------------------------------------------------------------------------------
1 | export { default, type Props as AsProps } from "./As";
2 |
--------------------------------------------------------------------------------
/lib/As/utils.ts:
--------------------------------------------------------------------------------
1 | import type { AnyObject } from "../types";
2 |
3 | export const mergeProps = (slotProps: AnyObject, childProps: AnyObject) => {
4 | const overrideProps = Object.keys(childProps).reduce(
5 | (result, key) => {
6 | const slotPropValue = slotProps[key] as unknown;
7 | const childPropValue = childProps[key] as unknown;
8 |
9 | const isEventHandler = /^on[A-Z]/.test(key);
10 | const isStyle = key === "style";
11 | const isClassName = key === "className";
12 |
13 | if (isEventHandler) {
14 | const existsOnBoth = slotPropValue && childPropValue;
15 |
16 | if (existsOnBoth) {
17 | type EventHandler = (...args: unknown[]) => void;
18 |
19 | return {
20 | ...result,
21 | [key]: (...args: unknown[]) => {
22 | (childPropValue as EventHandler)(...args);
23 | (slotPropValue as EventHandler)(...args);
24 | },
25 | };
26 | } else if (slotPropValue) overrideProps[key] = slotPropValue;
27 | } else if (isStyle) {
28 | return {
29 | ...result,
30 | [key]: {
31 | ...(slotPropValue as React.CSSProperties),
32 | ...(childPropValue as React.CSSProperties),
33 | },
34 | };
35 | } else if (isClassName) {
36 | return {
37 | ...result,
38 | [key]: [slotPropValue, childPropValue].filter(Boolean).join(" "),
39 | };
40 | }
41 |
42 | return result;
43 | },
44 | { ...childProps },
45 | );
46 |
47 | return { ...slotProps, ...overrideProps };
48 | };
49 |
--------------------------------------------------------------------------------
/lib/Breadcrumb/Breadcrumb.test.tsx:
--------------------------------------------------------------------------------
1 | import * as Breadcrumb from ".";
2 | import {
3 | itShouldMount,
4 | itSupportsDataSetProps,
5 | itSupportsRef,
6 | itSupportsStyle,
7 | render,
8 | screen,
9 | } from "../../tests/utils";
10 |
11 | const labelText = "Breadcrumb";
12 |
13 | const mockRequiredProps: Breadcrumb.RootProps = {
14 | label: { screenReaderLabel: labelText },
15 | };
16 |
17 | describe("Breadcrumb", () => {
18 | afterEach(jest.clearAllMocks);
19 |
20 | itShouldMount(Breadcrumb.Root, mockRequiredProps);
21 | itSupportsStyle(Breadcrumb.Root, mockRequiredProps, "nav");
22 | itSupportsRef(Breadcrumb.Root, mockRequiredProps, HTMLElement);
23 | itSupportsDataSetProps(Breadcrumb.Root, mockRequiredProps, "nav");
24 |
25 | it("should have the required classNames", () => {
26 | render(
27 |
31 |
35 |
39 |
44 |
45 | ,
46 | );
47 |
48 | const nav = screen.getByRole("navigation");
49 | const list = screen.getByTestId("list");
50 | const item = screen.getByTestId("item");
51 | const separator = screen.getByTestId("separator");
52 |
53 | expect(nav).toHaveClass("root");
54 | expect(list).toHaveClass("list");
55 | expect(item).toHaveClass("item");
56 | expect(separator).toHaveClass("separator");
57 | });
58 |
59 | it("should have `aria-label='label'` property when `label={{ screenReaderLabel: 'label' }}`", () => {
60 | render();
61 |
62 | expect(screen.getByRole("navigation")).toHaveAttribute(
63 | "aria-label",
64 | labelText,
65 | );
66 | });
67 |
68 | it("should have `aria-labelledby='identifier'` property when `label={{ labelledBy: 'identifier' }}`", () => {
69 | render(
70 | ,
74 | );
75 |
76 | expect(screen.getByRole("navigation")).toHaveAttribute(
77 | "aria-labelledby",
78 | "identifier",
79 | );
80 | });
81 | });
82 |
--------------------------------------------------------------------------------
/lib/Breadcrumb/Breadcrumb.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { getLabelInfo, logger } from "../internals";
3 | import type { MergeElementProps } from "../types";
4 | import {
5 | componentWithForwardedRef,
6 | isFragment,
7 | useDeterministicId,
8 | } from "../utils";
9 | import { List } from "./components";
10 | import { Root as RootSlot } from "./slots";
11 |
12 | type OwnProps = {
13 | /**
14 | * The content of the breadcrumb.
15 | */
16 | children?: React.ReactNode;
17 | /**
18 | * The className applied to the component.
19 | */
20 | className?: string;
21 | /**
22 | * The label of the breadcrumb.
23 | */
24 | label:
25 | | {
26 | /**
27 | * The label to use as `aria-label` property.
28 | */
29 | screenReaderLabel: string;
30 | }
31 | | {
32 | /**
33 | * Identifies the element (or elements) that labels the breadcrumb.
34 | *
35 | * @see {@link https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-labelledby MDN Web Docs} for more information.
36 | */
37 | labelledBy: string;
38 | };
39 | };
40 |
41 | export type Props = Omit<
42 | MergeElementProps<"nav", OwnProps>,
43 | "defaultChecked" | "defaultValue"
44 | >;
45 |
46 | const BreadcrumbBase = (props: Props, ref: React.Ref) => {
47 | const {
48 | label,
49 | children: childrenProp,
50 | id: idProp,
51 | className,
52 | ...otherProps
53 | } = props;
54 |
55 | const id = useDeterministicId(idProp, "styleless-ui__breadcrumb");
56 |
57 | const labelInfo = getLabelInfo(label, "Breadcrumb", {
58 | customErrorMessage: [
59 | "Invalid `label` property.",
60 | "The `label` property must be in shape of " +
61 | "`{ screenReaderLabel: string; } | { labelledBy: string; }`",
62 | ].join("\n"),
63 | });
64 |
65 | const children = React.Children.map(childrenProp, child => {
66 | if (!React.isValidElement(child) || isFragment(child)) {
67 | logger(
68 | "The component doesn't accept `Fragment` or any invalid element as children.",
69 | { scope: "Breadcrumb", type: "error" },
70 | );
71 |
72 | return null;
73 | }
74 |
75 | if ((child as React.ReactElement).type !== List) {
76 | logger(
77 | "The component only accepts as a children.",
78 | { scope: "Breadcrumb", type: "error" },
79 | );
80 |
81 | return null;
82 | }
83 |
84 | return child as React.ReactElement;
85 | });
86 |
87 | return (
88 |
99 | );
100 | };
101 |
102 | const Breadcrumb = componentWithForwardedRef(BreadcrumbBase, "Breadcrumb");
103 |
104 | export default Breadcrumb;
105 |
--------------------------------------------------------------------------------
/lib/Breadcrumb/components/Item.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import type { MergeElementProps } from "../../types";
3 | import { componentWithForwardedRef } from "../../utils";
4 | import { ItemRoot as ItemRootSlot } from "../slots";
5 |
6 | type OwnProps = {
7 | /**
8 | * The content of the component.
9 | */
10 | children?: React.ReactNode;
11 | /**
12 | * The className applied to the component.
13 | */
14 | className?: string;
15 | };
16 |
17 | export type Props = Omit<
18 | MergeElementProps<"li", OwnProps>,
19 | "defaultChecked" | "defaultValue"
20 | >;
21 |
22 | const ItemBase = (props: Props, ref: React.Ref) => {
23 | const { className, children, ...otherProps } = props;
24 |
25 | return (
26 |
32 | {children}
33 |
34 | );
35 | };
36 |
37 | const Item = componentWithForwardedRef(ItemBase, "Breadcrumb.Item");
38 |
39 | export default Item;
40 |
--------------------------------------------------------------------------------
/lib/Breadcrumb/components/List.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { logger } from "../../internals";
3 | import type { MergeElementProps } from "../../types";
4 | import { componentWithForwardedRef, isFragment, setRef } from "../../utils";
5 | import { ListRoot as ListRootSlot } from "../slots";
6 | import Item from "./Item";
7 | import SeparatorItem from "./SeparatorItem";
8 |
9 | type OwnProps = {
10 | /**
11 | * The content of the component.
12 | */
13 | children?: React.ReactNode;
14 | /**
15 | * The className applied to the component.
16 | */
17 | className?: string;
18 | };
19 |
20 | export type Props = Omit<
21 | MergeElementProps<"ol", OwnProps>,
22 | "defaultChecked" | "defaultValue"
23 | >;
24 |
25 | const ListBase = (props: Props, ref: React.Ref) => {
26 | const { className, children: childrenProp, ...otherProps } = props;
27 |
28 | const children = React.Children.map(childrenProp, child => {
29 | if (!React.isValidElement(child) || isFragment(child)) {
30 | logger(
31 | "The component doesn't accept `Fragment` or any invalid element as children.",
32 | { scope: "Breadcrumb.List", type: "error" },
33 | );
34 |
35 | return null;
36 | }
37 |
38 | if (
39 | (child as React.ReactElement).type !== Item &&
40 | (child as React.ReactElement).type !== SeparatorItem
41 | ) {
42 | logger(
43 | "The component only accepts and " +
44 | " as a children.",
45 | { scope: "Breadcrumb.List", type: "error" },
46 | );
47 |
48 | return null;
49 | }
50 |
51 | return child as React.ReactElement;
52 | });
53 |
54 | const refCallback = (node: HTMLOListElement | null) => {
55 | setRef(ref, node);
56 |
57 | if (!node) return;
58 |
59 | const lastItem = node.lastElementChild;
60 |
61 | if (!lastItem?.firstElementChild) return;
62 |
63 | if (lastItem.firstElementChild.tagName === "A") {
64 | const anchorLink = lastItem.firstElementChild as HTMLAnchorElement;
65 |
66 | if (anchorLink.hasAttribute("aria-current")) return;
67 |
68 | logger(
69 | [
70 | "The aria attribute `aria-current`" +
71 | " is missing from the last 's anchor element.",
72 | "For more information check out: " +
73 | "https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-current",
74 | ].join("\n"),
75 | { scope: "Breadcrumb.List", type: "warn" },
76 | );
77 | }
78 | };
79 |
80 | return (
81 |
88 | {children}
89 |
90 | );
91 | };
92 |
93 | const List = componentWithForwardedRef(ListBase, "Breadcrumb.List");
94 |
95 | export default List;
96 |
--------------------------------------------------------------------------------
/lib/Breadcrumb/components/SeparatorItem.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import type { MergeElementProps } from "../../types";
3 | import { componentWithForwardedRef } from "../../utils";
4 | import { SeparatorItemRoot as SeparatorItemRootSlot } from "../slots";
5 |
6 | type OwnProps = {
7 | /**
8 | * The symbol which is used as separator.
9 | */
10 | separatorSymbol: JSX.Element | string;
11 | /**
12 | * The className applied to the component.
13 | */
14 | className?: string;
15 | };
16 |
17 | export type Props = Omit<
18 | MergeElementProps<"li", OwnProps>,
19 | "defaultChecked" | "defaultValue" | "children"
20 | >;
21 |
22 | const SeparatorItemBase = (props: Props, ref: React.Ref) => {
23 | const { className, separatorSymbol, ...otherProps } = props;
24 |
25 | return (
26 |
33 | {separatorSymbol}
34 |
35 | );
36 | };
37 |
38 | const SeparatorItem = componentWithForwardedRef(
39 | SeparatorItemBase,
40 | "Breadcrumb.SeparatorItem",
41 | );
42 |
43 | export default SeparatorItem;
44 |
--------------------------------------------------------------------------------
/lib/Breadcrumb/components/index.ts:
--------------------------------------------------------------------------------
1 | export { default as Item, type Props as ItemProps } from "./Item";
2 | export { default as List, type Props as ListProps } from "./List";
3 | export {
4 | default as SeparatorItem,
5 | type Props as SeparatorItemProps,
6 | } from "./SeparatorItem";
7 |
--------------------------------------------------------------------------------
/lib/Breadcrumb/index.ts:
--------------------------------------------------------------------------------
1 | export { default as Root, type Props as RootProps } from "./Breadcrumb";
2 | export * from "./components";
3 |
--------------------------------------------------------------------------------
/lib/Breadcrumb/slots.ts:
--------------------------------------------------------------------------------
1 | export const Root = "Breadcrumb:Root";
2 | export const ListRoot = "Breadcrumb:List:Root";
3 | export const ItemRoot = "Breadcrumb:Item:Root";
4 | export const SeparatorItemRoot = "Breadcrumb:SeparatorItem:Root";
5 |
--------------------------------------------------------------------------------
/lib/Button/index.ts:
--------------------------------------------------------------------------------
1 | export {
2 | default,
3 | type ClassNameProps as ButtonClassNameProps,
4 | type Props as ButtonProps,
5 | type RenderProps as ButtonRenderProps,
6 | } from "./Button";
7 |
--------------------------------------------------------------------------------
/lib/Button/slots.ts:
--------------------------------------------------------------------------------
1 | export const Root = "Button:Root";
2 |
--------------------------------------------------------------------------------
/lib/CheckGroup/context.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import type { PickAsMandatory } from "../types";
3 | import { type Props } from "./CheckGroup";
4 |
5 | type ContextValue = PickAsMandatory &
6 | Pick & {
7 | onChange: (newCheckedState: boolean, inputValue: string) => void;
8 | };
9 |
10 | const Context = React.createContext(null);
11 |
12 | if (process.env.NODE_ENV !== "production") {
13 | Context.displayName = "CheckGroupContext";
14 | }
15 |
16 | export {
17 | Context as CheckGroupContext,
18 | type ContextValue as CheckGroupContextValue,
19 | };
20 |
--------------------------------------------------------------------------------
/lib/CheckGroup/index.ts:
--------------------------------------------------------------------------------
1 | export {
2 | default,
3 | type ClassNameProps as CheckGroupClassNameProps,
4 | type Props as CheckGroupProps,
5 | type RenderProps as CheckGroupRenderProps,
6 | } from "./CheckGroup";
7 |
--------------------------------------------------------------------------------
/lib/CheckGroup/slots.ts:
--------------------------------------------------------------------------------
1 | export const Root = "CheckGroup:Root";
2 |
--------------------------------------------------------------------------------
/lib/Checkbox/index.ts:
--------------------------------------------------------------------------------
1 | export {
2 | default,
3 | type ClassNameProps as CheckboxClassNameProps,
4 | type Props as CheckboxProps,
5 | type RenderProps as CheckboxRenderProps,
6 | } from "./Checkbox";
7 |
--------------------------------------------------------------------------------
/lib/Checkbox/slots.ts:
--------------------------------------------------------------------------------
1 | export const Root = "Checkbox:Root";
2 |
--------------------------------------------------------------------------------
/lib/Dialog/components/Backdrop.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { logger } from "../../internals";
3 | import type { MergeElementProps } from "../../types";
4 | import { componentWithForwardedRef } from "../../utils";
5 | import { DialogContext } from "../context";
6 | import { BackdropRoot as BackdropRootSlot } from "../slots";
7 |
8 | type OwnProps = {
9 | /**
10 | * The className applied to the component.
11 | */
12 | className?: string;
13 | };
14 |
15 | export type Props = Omit<
16 | MergeElementProps<"div", OwnProps>,
17 | "defaultChecked" | "defaultValue" | "children"
18 | >;
19 |
20 | const BackdropBase = (props: Props, ref: React.Ref) => {
21 | const { className, onClick, ...otherProps } = props;
22 |
23 | const ctx = React.useContext(DialogContext);
24 |
25 | if (!ctx) {
26 | logger("You have to use this component as a descendant of .", {
27 | scope: "Dialog.Backdrop",
28 | type: "error",
29 | });
30 |
31 | return null;
32 | }
33 |
34 | const handleClick = (event: React.MouseEvent) => {
35 | onClick?.(event);
36 |
37 | if (event.isDefaultPrevented()) return;
38 |
39 | ctx.emitClose();
40 | };
41 |
42 | return (
43 |
51 | );
52 | };
53 |
54 | const Backdrop = componentWithForwardedRef(BackdropBase, "Dialog.Backdrop");
55 |
56 | export default Backdrop;
57 |
--------------------------------------------------------------------------------
/lib/Dialog/components/Content.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { logger } from "../../internals";
3 | import type { MergeElementProps } from "../../types";
4 | import { componentWithForwardedRef, useDeterministicId } from "../../utils";
5 | import { DialogContext } from "../context";
6 | import { ContentRoot as ContentRootSlot } from "../slots";
7 |
8 | type OwnProps = {
9 | /**
10 | * The content of the component.
11 | */
12 | children?: React.ReactNode;
13 | /**
14 | * The className applied to the component.
15 | */
16 | className?: string;
17 | };
18 |
19 | export type Props = Omit<
20 | MergeElementProps<"div", OwnProps>,
21 | "defaultChecked" | "defaultValue"
22 | >;
23 |
24 | const ContentBase = (props: Props, ref: React.Ref) => {
25 | const { className, children, id: idProp, ...otherProps } = props;
26 |
27 | const ctx = React.useContext(DialogContext);
28 |
29 | const id = useDeterministicId(idProp, "styleless-ui__dialog-content");
30 |
31 | if (!ctx) {
32 | logger("You have to use this component as a descendant of .", {
33 | scope: "Dialog.Content",
34 | type: "error",
35 | });
36 |
37 | return null;
38 | }
39 |
40 | return (
41 |
50 | {children}
51 |
52 | );
53 | };
54 |
55 | const Content = componentWithForwardedRef(ContentBase, "Dialog.Content");
56 |
57 | export default Content;
58 |
--------------------------------------------------------------------------------
/lib/Dialog/components/Description.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { logger } from "../../internals";
3 | import type { PolymorphicComponent, PolymorphicProps } from "../../types";
4 | import {
5 | componentWithForwardedRef,
6 | useDeterministicId,
7 | useForkedRefs,
8 | } from "../../utils";
9 | import {
10 | ContentRoot as ContentRootSlot,
11 | DescriptionRoot as DescriptionRootSlot,
12 | } from "../slots";
13 |
14 | type OwnProps = {
15 | /**
16 | * The content of the component.
17 | */
18 | children?: React.ReactNode;
19 | /**
20 | * The className applied to the component.
21 | */
22 | className?: string;
23 | };
24 |
25 | export type Props = PolymorphicProps<
26 | E,
27 | OwnProps
28 | >;
29 |
30 | const DescriptionBase = <
31 | E extends React.ElementType = "p",
32 | R extends HTMLElement = HTMLParagraphElement,
33 | >(
34 | props: Props,
35 | ref: React.Ref,
36 | ) => {
37 | const {
38 | className,
39 | children,
40 | id: idProp,
41 | as: RootNode = "p",
42 | ...otherProps
43 | } = props as Props<"p">;
44 |
45 | const id = useDeterministicId(idProp, "styleless-ui__dialog-description");
46 |
47 | const rootRef = React.useRef(null);
48 | const handleRef = useForkedRefs(ref, rootRef);
49 |
50 | const refCallback = (node: R | null) => {
51 | handleRef(node);
52 |
53 | if (!node) return;
54 |
55 | const content = node.closest(`[data-slot='${ContentRootSlot}']`);
56 |
57 | if (content) {
58 | content.setAttribute("aria-describedby", id);
59 | } else {
60 | logger(
61 | "You should always wrap your content with to provide " +
62 | "accessibility features.",
63 | { scope: "Dialog", type: "error" },
64 | );
65 | }
66 | };
67 |
68 | return (
69 | }
73 | className={className}
74 | data-slot={DescriptionRootSlot}
75 | >
76 | {children}
77 |
78 | );
79 | };
80 |
81 | const Description: PolymorphicComponent<"p", OwnProps> =
82 | componentWithForwardedRef(DescriptionBase, "Dialog.Description");
83 |
84 | export default Description;
85 |
--------------------------------------------------------------------------------
/lib/Dialog/components/Title.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { logger } from "../../internals";
3 | import type { PolymorphicComponent, PolymorphicProps } from "../../types";
4 | import {
5 | componentWithForwardedRef,
6 | useDeterministicId,
7 | useForkedRefs,
8 | } from "../../utils";
9 | import {
10 | ContentRoot as ContentRootSlot,
11 | TitleRoot as TitleRootSlot,
12 | } from "../slots";
13 |
14 | type OwnProps = {
15 | /**
16 | * The content of the component.
17 | */
18 | children?: React.ReactNode;
19 | /**
20 | * The className applied to the component.
21 | */
22 | className?: string;
23 | };
24 |
25 | export type Props = PolymorphicProps<
26 | E,
27 | OwnProps
28 | >;
29 |
30 | const TitleBase = <
31 | E extends React.ElementType = "h2",
32 | R extends HTMLElement = HTMLHeadingElement,
33 | >(
34 | props: Props,
35 | ref: React.Ref,
36 | ) => {
37 | const {
38 | className,
39 | children,
40 | id: idProp,
41 | as: RootNode = "h2",
42 | ...otherProps
43 | } = props as Props<"h2">;
44 |
45 | const id = useDeterministicId(idProp, "styleless-ui__dialog-title");
46 |
47 | const rootRef = React.useRef(null);
48 | const handleRef = useForkedRefs(ref, rootRef);
49 |
50 | const refCallback = (node: R | null) => {
51 | handleRef(node);
52 |
53 | if (!node) return;
54 |
55 | const content = node.closest(`[data-slot='${ContentRootSlot}']`);
56 |
57 | if (content) {
58 | content.setAttribute("aria-labelledby", id);
59 | } else {
60 | logger(
61 | "You should always wrap your content with to provide " +
62 | "accessibility features.",
63 | { scope: "Dialog", type: "error" },
64 | );
65 | }
66 | };
67 |
68 | return (
69 | }
73 | className={className}
74 | data-slot={TitleRootSlot}
75 | >
76 | {children}
77 |
78 | );
79 | };
80 |
81 | const Title: PolymorphicComponent<"h2", OwnProps> = componentWithForwardedRef(
82 | TitleBase,
83 | "Dialog.Title",
84 | );
85 |
86 | export default Title;
87 |
--------------------------------------------------------------------------------
/lib/Dialog/components/index.ts:
--------------------------------------------------------------------------------
1 | export { default as Backdrop, type Props as BackdropProps } from "./Backdrop";
2 | export { default as Content, type Props as ContentProps } from "./Content";
3 | export {
4 | default as Description,
5 | type Props as DescriptionProps,
6 | } from "./Description";
7 | export { default as Title, type Props as TitleProps } from "./Title";
8 |
--------------------------------------------------------------------------------
/lib/Dialog/context.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | type ContextValue = {
4 | role: "dialog" | "alertdialog";
5 | open: boolean;
6 | emitClose: () => void;
7 | };
8 |
9 | const Context = React.createContext(null);
10 |
11 | if (process.env.NODE_ENV !== "production") {
12 | Context.displayName = "DialogContext";
13 | }
14 |
15 | export { Context as DialogContext, type ContextValue as DialogContextValue };
16 |
--------------------------------------------------------------------------------
/lib/Dialog/index.ts:
--------------------------------------------------------------------------------
1 | export {
2 | default as Root,
3 | type ClassNameProps as RootClassNameProps,
4 | type Props as RootProps,
5 | type RenderProps as RootRenderProps,
6 | } from "./Dialog";
7 | export * from "./components";
8 |
--------------------------------------------------------------------------------
/lib/Dialog/slots.ts:
--------------------------------------------------------------------------------
1 | export const Root = "Dialog:Root";
2 | export const BackdropRoot = "Dialog:Backdrop:Root";
3 | export const TitleRoot = "Dialog:Title:Root";
4 | export const DescriptionRoot = "Dialog:Description:Root";
5 | export const ContentRoot = "Dialog:Content:Root";
6 |
--------------------------------------------------------------------------------
/lib/Expandable/Expandable.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { resolvePropWithRenderContext } from "../internals";
3 | import type { MergeElementProps, PropWithRenderContext } from "../types";
4 | import { componentWithForwardedRef, useControlledProp } from "../utils";
5 | import { ExpandableContext } from "./context";
6 | import { Root as RootSlot } from "./slots";
7 |
8 | export type RenderProps = {
9 | /**
10 | * Determines whether it is expanded or not.
11 | */
12 | expanded: boolean;
13 | };
14 |
15 | export type ClassNameProps = RenderProps;
16 |
17 | type OwnProps = {
18 | /**
19 | * The content of the component.
20 | */
21 | children?: PropWithRenderContext;
22 | /**
23 | * The className applied to the component.
24 | */
25 | className?: PropWithRenderContext;
26 | /**
27 | * If `true`, the panel will be opened.
28 | */
29 | expanded?: boolean;
30 | /**
31 | * The default state of the `expanded`. Use when `expanded` is not controlled.
32 | */
33 | defaultExpanded?: boolean;
34 | /**
35 | * The Callback is fired when the `expand` state changes.
36 | *
37 | * Only updates from `` component trigger the callback.
38 | */
39 | onExpandChange?: (isExpanded: boolean) => void;
40 | };
41 |
42 | export type Props = Omit<
43 | MergeElementProps<"div", OwnProps>,
44 | "defaultChecked" | "defaultValue"
45 | >;
46 |
47 | const ExpandableBase = (props: Props, ref: React.Ref) => {
48 | const {
49 | onExpandChange,
50 | expanded,
51 | defaultExpanded,
52 | children: childrenProp,
53 | className: classNameProp,
54 | ...otherProps
55 | } = props;
56 |
57 | const [isExpanded, setIsExpanded] = useControlledProp(
58 | expanded,
59 | defaultExpanded,
60 | false,
61 | );
62 |
63 | const renderProps: RenderProps = {
64 | expanded: isExpanded,
65 | };
66 |
67 | const classNameProps: ClassNameProps = renderProps;
68 |
69 | const className = resolvePropWithRenderContext(classNameProp, classNameProps);
70 | const children = resolvePropWithRenderContext(childrenProp, renderProps);
71 |
72 | const emitExpandChange = (expandState: boolean) => {
73 | setIsExpanded(expandState);
74 | onExpandChange?.(expandState);
75 | };
76 |
77 | return (
78 |
86 |
92 | {children}
93 |
94 |
95 | );
96 | };
97 |
98 | const Expandable = componentWithForwardedRef(ExpandableBase, "Expandable");
99 |
100 | export default Expandable;
101 |
--------------------------------------------------------------------------------
/lib/Expandable/components/Content.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { logger } from "../../internals";
3 | import type { MergeElementProps } from "../../types";
4 | import {
5 | componentWithForwardedRef,
6 | setRef,
7 | useDeterministicId,
8 | } from "../../utils";
9 | import { ExpandableContext } from "../context";
10 | import {
11 | ContentRoot as ContentRootSlot,
12 | Root as RootSlot,
13 | TriggerRoot as TriggerRootSlot,
14 | } from "../slots";
15 |
16 | type OwnProps = {
17 | /**
18 | * The content of the component.
19 | */
20 | children?: React.ReactNode;
21 | /**
22 | * The className applied to the component.
23 | */
24 | className?: string;
25 | };
26 |
27 | export type Props = Omit<
28 | MergeElementProps<"div", OwnProps>,
29 | "defaultChecked" | "defaultValue"
30 | >;
31 |
32 | const ContentBase = (props: Props, ref: React.Ref) => {
33 | const { children, className, id: idProp, ...otherProps } = props;
34 |
35 | const ctx = React.useContext(ExpandableContext);
36 |
37 | const id = useDeterministicId(idProp, "styleless-ui__expandable-content");
38 |
39 | if (!ctx) {
40 | logger(
41 | "You have to use this component as a descendant of .",
42 | {
43 | scope: "Expandable.Content",
44 | type: "error",
45 | },
46 | );
47 |
48 | return null;
49 | }
50 |
51 | const refCallback = (node: HTMLDivElement | null) => {
52 | setRef(ref, node);
53 |
54 | if (!node) return;
55 |
56 | const parent = node.closest(`[data-slot="${RootSlot}"]`);
57 |
58 | if (!parent) return;
59 |
60 | const trigger = parent.querySelector(
61 | `[data-slot="${TriggerRootSlot}"]`,
62 | );
63 |
64 | if (!trigger) return;
65 |
66 | node.setAttribute("aria-labelledby", trigger.id);
67 | trigger.setAttribute("aria-controls", id);
68 | };
69 |
70 | return (
71 |
83 | {children}
84 |
85 | );
86 | };
87 |
88 | const Content = componentWithForwardedRef(ContentBase, "Expandable.Content");
89 |
90 | export default Content;
91 |
--------------------------------------------------------------------------------
/lib/Expandable/components/Trigger.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import Button from "../../Button";
3 | import { logger } from "../../internals";
4 | import type {
5 | EmptyObjectNotation,
6 | PolymorphicComponent,
7 | PolymorphicProps,
8 | } from "../../types";
9 | import {
10 | componentWithForwardedRef,
11 | setRef,
12 | useDeterministicId,
13 | } from "../../utils";
14 | import { ExpandableContext } from "../context";
15 | import {
16 | ContentRoot as ContentRootSlot,
17 | Root as RootSlot,
18 | TriggerRoot as TriggerRootSlot,
19 | } from "../slots";
20 |
21 | type DefaultElementType = typeof Button<"div">;
22 |
23 | export type Props =
24 | PolymorphicProps;
25 |
26 | const TriggerBase = <
27 | E extends React.ElementType = DefaultElementType,
28 | R extends HTMLElement = HTMLDivElement,
29 | >(
30 | props: Props,
31 | ref: React.Ref,
32 | ) => {
33 | const {
34 | as: RootNode = Button<"div">,
35 | id: idProp,
36 | onClick,
37 | ...otherProps
38 | } = props as Props;
39 |
40 | const ctx = React.useContext(ExpandableContext);
41 |
42 | const id = useDeterministicId(idProp, "styleless-ui__expandable-trigger");
43 |
44 | if (!ctx) {
45 | logger(
46 | "You have to use this component as a descendant of .",
47 | {
48 | scope: "Expandable.Trigger",
49 | type: "error",
50 | },
51 | );
52 |
53 | return null;
54 | }
55 |
56 | const handleClick = (event: React.MouseEvent) => {
57 | ctx.emitExpandChange(!ctx.isExpanded);
58 | onClick?.(event as unknown as React.MouseEvent);
59 | };
60 |
61 | const refCallback = (node: R | null) => {
62 | setRef(ref, node);
63 |
64 | if (!node) return;
65 |
66 | const parent = node.closest(`[data-slot="${RootSlot}"]`);
67 |
68 | if (!parent) return;
69 |
70 | const content = parent.querySelector(
71 | `[data-slot="${ContentRootSlot}"]`,
72 | );
73 |
74 | if (!content) return;
75 |
76 | node.setAttribute("aria-controls", content.id);
77 | content.setAttribute("aria-labelledby", id);
78 | };
79 |
80 | return (
81 | ["onClick"]}
85 | ref={refCallback as Props["ref"]}
86 | data-slot={TriggerRootSlot}
87 | aria-expanded={ctx.isExpanded}
88 | data-expanded={ctx.isExpanded ? "" : undefined}
89 | />
90 | );
91 | };
92 |
93 | const Trigger: PolymorphicComponent =
94 | componentWithForwardedRef(TriggerBase, "Expandable.Trigger");
95 |
96 | export default Trigger;
97 |
--------------------------------------------------------------------------------
/lib/Expandable/components/index.ts:
--------------------------------------------------------------------------------
1 | export { default as Content, type Props as ContentProps } from "./Content";
2 | export { default as Trigger, type Props as TriggerProps } from "./Trigger";
3 |
--------------------------------------------------------------------------------
/lib/Expandable/context.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | type ContextValue = {
4 | isExpanded: boolean;
5 | emitExpandChange: (expandState: boolean) => void;
6 | };
7 |
8 | const Context = React.createContext(null);
9 |
10 | if (process.env.NODE_ENV !== "production") {
11 | Context.displayName = "ExpandableContext";
12 | }
13 |
14 | export {
15 | Context as ExpandableContext,
16 | type ContextValue as ExpandableContextValue,
17 | };
18 |
--------------------------------------------------------------------------------
/lib/Expandable/index.ts:
--------------------------------------------------------------------------------
1 | export {
2 | default as Root,
3 | type ClassNameProps as RootClassNameProps,
4 | type Props as RootProps,
5 | type RenderProps as RootRenderProps,
6 | } from "./Expandable";
7 | export * from "./components";
8 |
--------------------------------------------------------------------------------
/lib/Expandable/slots.ts:
--------------------------------------------------------------------------------
1 | export const Root = "Expandable:Root";
2 | export const TriggerRoot = "Expandable:Trigger:Root";
3 | export const ContentRoot = "Expandable:Content:Root";
4 |
--------------------------------------------------------------------------------
/lib/InputSlider/components/InfimumThumb.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { getLabelInfo, logger } from "../../internals";
3 | import type { MergeElementProps } from "../../types";
4 | import { componentWithForwardedRef } from "../../utils";
5 | import { InputSliderContext } from "../context";
6 | import { InfimumThumbRoot as InfimumThumbRootSlot } from "../slots";
7 | import Thumb, {
8 | type ClassNameProps as ThumbClassNameProps,
9 | type RenderProps as ThumbRenderProps,
10 | type SharedProps as ThumbSharedProps,
11 | } from "./Thumb";
12 |
13 | export type RenderProps = ThumbRenderProps;
14 | export type ClassNameProps = ThumbClassNameProps;
15 |
16 | type OwnProps = ThumbSharedProps & {
17 | /**
18 | * The label of the component.
19 | */
20 | label:
21 | | {
22 | /**
23 | * The label to use as `aria-label` property.
24 | */
25 | screenReaderLabel: string;
26 | }
27 | | {
28 | /**
29 | * Identifies the element (or elements) that labels the component.
30 | *
31 | * @see {@link https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-labelledby MDN Web Docs} for more information.
32 | */
33 | labelledBy: string;
34 | };
35 | };
36 |
37 | export type Props = Omit<
38 | MergeElementProps<"div", OwnProps>,
39 | | "defaultValue"
40 | | "defaultChecked"
41 | | "value"
42 | | "checked"
43 | | "onChange"
44 | | "onChangeCapture"
45 | >;
46 |
47 | const InfimumThumbBase = (props: Props, ref: React.Ref) => {
48 | const { label, onTouchStart, onMouseDown, onKeyDown, ...otherProps } = props;
49 |
50 | const ctx = React.useContext(InputSliderContext);
51 |
52 | if (!ctx) {
53 | logger(
54 | "You have to use this component as a descendant of .",
55 | {
56 | scope: "InputSlider.InfimumThumb",
57 | type: "error",
58 | },
59 | );
60 |
61 | return null;
62 | }
63 |
64 | const {
65 | getPositions,
66 | getThumbsInfo,
67 | handleThumbDragStart,
68 | handleThumbKeyDown,
69 | setThumbValueText,
70 | disabled,
71 | readOnly,
72 | multiThumb,
73 | orientation,
74 | } = ctx;
75 |
76 | if (!multiThumb) return null;
77 |
78 | const labelInfo = getLabelInfo(label, "InputSlider.InfimumThumb", {
79 | customErrorMessage: [
80 | "Invalid `label` property.",
81 | "The `label` property must be in shape of " +
82 | "`{ screenReaderLabel: string; } | { labelledBy: string; }`",
83 | ].join("\n"),
84 | });
85 |
86 | const position = getPositions().infimum;
87 | const thumbInfo = getThumbsInfo().infimum;
88 |
89 | const handleDragStart: typeof handleThumbDragStart = event => {
90 | if (disabled) return;
91 |
92 | handleThumbDragStart(event);
93 |
94 | if (event.nativeEvent instanceof MouseEvent) {
95 | onMouseDown?.(event as React.MouseEvent);
96 | } else onTouchStart?.(event as React.TouchEvent);
97 | };
98 |
99 | const handleKeyDown: typeof handleThumbKeyDown = event => {
100 | if (disabled) return;
101 |
102 | handleThumbKeyDown(event);
103 | onKeyDown?.(event);
104 | };
105 |
106 | return (
107 |
123 | );
124 | };
125 |
126 | const InfimumThumb = componentWithForwardedRef(
127 | InfimumThumbBase,
128 | "InputSlider.InfimumThumb",
129 | );
130 |
131 | export default InfimumThumb;
132 |
--------------------------------------------------------------------------------
/lib/InputSlider/components/Range.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { logger } from "../../internals";
3 | import type { MergeElementProps } from "../../types";
4 | import { componentWithForwardedRef } from "../../utils";
5 | import { InputSliderContext } from "../context";
6 | import { RangeRoot as RangeRootSlot } from "../slots";
7 |
8 | type OwnProps = {
9 | /**
10 | * The content of the component.
11 | */
12 | children?: React.ReactNode;
13 | /**
14 | * The className applied to the component.
15 | */
16 | className?: string;
17 | };
18 |
19 | export type Props = Omit<
20 | MergeElementProps<"div", OwnProps>,
21 | | "defaultValue"
22 | | "value"
23 | | "defaultChecked"
24 | | "checked"
25 | | "onChange"
26 | | "onChangeCapture"
27 | >;
28 |
29 | const RangeBase = (props: Props, ref: React.Ref) => {
30 | const { className, children, style: styleProp, ...otherProps } = props;
31 |
32 | const ctx = React.useContext(InputSliderContext);
33 |
34 | if (!ctx) {
35 | logger(
36 | "You have to use this component as a descendant of .",
37 | {
38 | scope: "InputSlider.Range",
39 | type: "error",
40 | },
41 | );
42 |
43 | return null;
44 | }
45 |
46 | const { orientation, getPositions } = ctx;
47 |
48 | const position = getPositions().range;
49 |
50 | const style: React.CSSProperties = {
51 | ...(styleProp ?? {}),
52 | ...{
53 | horizontal: {
54 | left: `${position.start}%`,
55 | right: `${position.end}%`,
56 | },
57 | vertical: {
58 | top: `${position.end}%`,
59 | bottom: `${position.start}%`,
60 | },
61 | }[orientation],
62 | position: "absolute",
63 | };
64 |
65 | return (
66 |
74 | {children}
75 |
76 | );
77 | };
78 |
79 | const Range = componentWithForwardedRef(RangeBase, "InputSlider.Range");
80 |
81 | export default Range;
82 |
--------------------------------------------------------------------------------
/lib/InputSlider/components/SupremumThumb.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { getLabelInfo, logger } from "../../internals";
3 | import type { MergeElementProps } from "../../types";
4 | import { componentWithForwardedRef } from "../../utils";
5 | import { InputSliderContext } from "../context";
6 | import { SupremumThumbRoot as SupremumThumbRootSlot } from "../slots";
7 | import Thumb, {
8 | type ClassNameProps as ThumbClassNameProps,
9 | type RenderProps as ThumbRenderProps,
10 | type SharedProps as ThumbSharedProps,
11 | } from "./Thumb";
12 |
13 | export type RenderProps = ThumbRenderProps;
14 | export type ClassNameProps = ThumbClassNameProps;
15 |
16 | type OwnProps = ThumbSharedProps & {
17 | /**
18 | * The label of the component.
19 | */
20 | label:
21 | | {
22 | /**
23 | * The label to use as `aria-label` property.
24 | */
25 | screenReaderLabel: string;
26 | }
27 | | {
28 | /**
29 | * Identifies the element (or elements) that labels the component.
30 | *
31 | * @see {@link https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-labelledby MDN Web Docs} for more information.
32 | */
33 | labelledBy: string;
34 | };
35 | };
36 |
37 | export type Props = Omit<
38 | MergeElementProps<"div", OwnProps>,
39 | | "defaultValue"
40 | | "defaultChecked"
41 | | "value"
42 | | "checked"
43 | | "onChange"
44 | | "onChangeCapture"
45 | >;
46 |
47 | const SupremumThumbBase = (props: Props, ref: React.Ref) => {
48 | const { label, onMouseDown, onTouchStart, onKeyDown, ...otherProps } = props;
49 |
50 | const ctx = React.useContext(InputSliderContext);
51 |
52 | if (!ctx) {
53 | logger(
54 | "You have to use this component as a descendant of .",
55 | {
56 | scope: "InputSlider.SupremumThumb",
57 | type: "error",
58 | },
59 | );
60 |
61 | return null;
62 | }
63 |
64 | const labelInfo = getLabelInfo(label, "InputSlider.SupremumThumb", {
65 | customErrorMessage: [
66 | "Invalid `label` property.",
67 | "The `label` property must be in shape of " +
68 | "`{ screenReaderLabel: string; } | { labelledBy: string; }`",
69 | ].join("\n"),
70 | });
71 |
72 | const {
73 | getPositions,
74 | getThumbsInfo,
75 | handleThumbDragStart,
76 | handleThumbKeyDown,
77 | setThumbValueText,
78 | disabled,
79 | readOnly,
80 | orientation,
81 | } = ctx;
82 |
83 | const position = getPositions().supremum;
84 | const thumbInfo = getThumbsInfo().supremum;
85 |
86 | const handleDragStart: typeof handleThumbDragStart = event => {
87 | if (disabled) return;
88 |
89 | handleThumbDragStart(event);
90 |
91 | if (event.nativeEvent instanceof MouseEvent) {
92 | onMouseDown?.(event as React.MouseEvent);
93 | } else onTouchStart?.(event as React.TouchEvent);
94 | };
95 |
96 | const handleKeyDown: typeof handleThumbKeyDown = event => {
97 | if (disabled) return;
98 |
99 | handleThumbKeyDown(event);
100 | onKeyDown?.(event);
101 | };
102 |
103 | return (
104 |
120 | );
121 | };
122 |
123 | const SupremumThumb = componentWithForwardedRef(
124 | SupremumThumbBase,
125 | "InputSlider.SupremumThumb",
126 | );
127 |
128 | export default SupremumThumb;
129 |
--------------------------------------------------------------------------------
/lib/InputSlider/components/Track.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { logger } from "../../internals";
3 | import type { MergeElementProps } from "../../types";
4 | import { componentWithForwardedRef } from "../../utils";
5 | import { InputSliderContext } from "../context";
6 | import { TrackRoot as TrackRootSlot } from "../slots";
7 |
8 | type OwnProps = {
9 | /**
10 | * The content of the component.
11 | */
12 | children?: React.ReactNode;
13 | /**
14 | * The className applied to the component.
15 | */
16 | className?: string;
17 | };
18 |
19 | export type Props = Omit<
20 | MergeElementProps<"div", OwnProps>,
21 | | "defaultValue"
22 | | "value"
23 | | "defaultChecked"
24 | | "checked"
25 | | "onChange"
26 | | "onChangeCapture"
27 | >;
28 |
29 | const TrackBase = (props: Props, ref: React.Ref) => {
30 | const { className, children, style: styleProp, ...otherProps } = props;
31 |
32 | const ctx = React.useContext(InputSliderContext);
33 |
34 | if (!ctx) {
35 | logger(
36 | "You have to use this component as a descendant of .",
37 | {
38 | scope: "InputSlider.Track",
39 | type: "error",
40 | },
41 | );
42 |
43 | return null;
44 | }
45 |
46 | const { orientation } = ctx;
47 |
48 | const style: React.CSSProperties = {
49 | ...(styleProp ?? {}),
50 | ...{ horizontal: { width: "100%" }, vertical: { height: "100%" } }[
51 | orientation
52 | ],
53 | position: "relative",
54 | };
55 |
56 | return (
57 |
65 | {children}
66 |
67 | );
68 | };
69 |
70 | const Track = componentWithForwardedRef(TrackBase, "InputSlider.Track");
71 |
72 | export default Track;
73 |
--------------------------------------------------------------------------------
/lib/InputSlider/components/index.ts:
--------------------------------------------------------------------------------
1 | export {
2 | default as InfimumThumb,
3 | type ClassNameProps as InfimumThumbClassNameProps,
4 | type Props as InfimumThumbProps,
5 | type RenderProps as InfimumThumbRenderProps,
6 | } from "./InfimumThumb";
7 | export { default as Range, type Props as RangeProps } from "./Range";
8 | export {
9 | default as SupremumThumb,
10 | type ClassNameProps as SupremumThumbClassNameProps,
11 | type Props as SupremumThumbProps,
12 | type RenderProps as SupremumThumbRenderProps,
13 | } from "./SupremumThumb";
14 | export { default as Track, type Props as TrackProps } from "./Track";
15 |
--------------------------------------------------------------------------------
/lib/InputSlider/context.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import type { PickAsMandatory } from "../types";
3 | import { type Props } from "./InputSlider";
4 | import type { Positions, ThumbsInfo } from "./types";
5 |
6 | type ContextValue = PickAsMandatory<
7 | Props,
8 | "orientation" | "disabled" | "readOnly" | "setThumbValueText" | "multiThumb"
9 | > & {
10 | getThumbsInfo: () => ThumbsInfo;
11 | getPositions: () => Positions;
12 | handleThumbDragStart: (
13 | event: React.MouseEvent | React.TouchEvent,
14 | ) => void;
15 | handleThumbKeyDown: React.KeyboardEventHandler;
16 | };
17 |
18 | const Context = React.createContext(null);
19 |
20 | if (process.env.NODE_ENV !== "production") {
21 | Context.displayName = "InputSliderContext";
22 | }
23 |
24 | export {
25 | Context as InputSliderContext,
26 | type ContextValue as InputSliderContextValue,
27 | };
28 |
--------------------------------------------------------------------------------
/lib/InputSlider/index.ts:
--------------------------------------------------------------------------------
1 | export {
2 | default as Root,
3 | type ClassNameProps as RootClassNameProps,
4 | type Props as RootProps,
5 | type RenderProps as RootRenderProps,
6 | } from "./InputSlider";
7 | export * from "./components";
8 | export type { StopSegment } from "./types";
9 |
--------------------------------------------------------------------------------
/lib/InputSlider/slots.ts:
--------------------------------------------------------------------------------
1 | export const Root = "InputSlider:Root";
2 | export const TrackRoot = "InputSlider:Track:Root";
3 | export const RangeRoot = "InputSlider:Range:Root";
4 | export const InfimumThumbRoot = "InputSlider:Thumb:Infimum:Root";
5 | export const SupremumThumbRoot = "InputSlider:Thumb:Supremum:Root";
6 |
--------------------------------------------------------------------------------
/lib/InputSlider/types.ts:
--------------------------------------------------------------------------------
1 | export type ThumbNames = "infimum" | "supremum";
2 | export type Entities = ThumbNames | "range";
3 |
4 | export type ThumbState = {
5 | active: boolean;
6 | zIndex: number;
7 | };
8 |
9 | export type Positions = Record & {
10 | range: { start: number; end: number };
11 | };
12 |
13 | export type ThumbInfo = {
14 | index: 0 | 1;
15 | name: ThumbNames;
16 | value: number;
17 | minValue: number;
18 | maxValue: number;
19 | state: ThumbState;
20 | ref: React.RefObject;
21 | setState: React.Dispatch>;
22 | };
23 |
24 | export type ThumbsInfo = Record;
25 |
26 | export type Orientation = "horizontal" | "vertical";
27 |
28 | export type StopSegment = { length: number; index: number };
29 |
--------------------------------------------------------------------------------
/lib/InputSlider/utils.ts:
--------------------------------------------------------------------------------
1 | import { clamp, lerp, remap } from "../utils";
2 | import type { Props } from "./InputSlider";
3 | import type { StopSegment, ThumbInfo } from "./types";
4 |
5 | export const findNearestValue = (vals: number[], target: number) => {
6 | const midIdx = Math.floor(vals.length / 2);
7 |
8 | let diff = Infinity;
9 | let nominee = target;
10 |
11 | /* eslint-disable @typescript-eslint/no-non-null-assertion */
12 | const nominate = (idx: number) => {
13 | const val = vals[idx]!;
14 | const newDiff = Math.abs(target - val);
15 |
16 | if (diff > newDiff) {
17 | diff = newDiff;
18 | nominee = val;
19 | }
20 | };
21 |
22 | if (vals[midIdx]! < target) {
23 | for (let idx = midIdx; idx < vals.length; idx++) nominate(idx);
24 | } else {
25 | for (let idx = 0; idx <= midIdx; idx++) nominate(idx);
26 | }
27 | /* eslint-enable @typescript-eslint/no-non-null-assertion */
28 |
29 | return nominee;
30 | };
31 |
32 | export const getRelativeValue = (
33 | clientXOrY: number,
34 | parentWidthOrHeight: number,
35 | thumbInfo: ThumbInfo,
36 | segments: StopSegment[],
37 | requiredProps: Pick,
38 | ) => {
39 | const { max, min, step, orientation } = requiredProps;
40 |
41 | let newValue: number;
42 |
43 | if (orientation === "horizontal") {
44 | newValue = remap(clientXOrY, 0, parentWidthOrHeight, min, max);
45 | } else {
46 | newValue = remap(clientXOrY, 0, parentWidthOrHeight, max, min);
47 | }
48 |
49 | if (typeof step === "number" && step) {
50 | newValue = Math.floor(newValue / step) * step;
51 | }
52 |
53 | if (step === "snap") {
54 | const stopNums = segments
55 | .sort()
56 | .map(segment => lerp(min, max, segment.length / 100));
57 |
58 | newValue = findNearestValue(stopNums, newValue);
59 | }
60 |
61 | const relativeMin = thumbInfo.minValue;
62 | const relativeMax = thumbInfo.maxValue;
63 |
64 | return clamp(newValue, relativeMin, relativeMax);
65 | };
66 |
67 | export const getNearestThumb = (
68 | value: number,
69 | thumbInfos: { infimum: ThumbInfo | null; supremum: ThumbInfo },
70 | ): ThumbInfo => {
71 | const { infimum, supremum } = thumbInfos;
72 |
73 | if (!infimum) return { ...supremum, index: 1 };
74 |
75 | const infDiff = Math.abs(infimum.value - value);
76 | const supDiff = Math.abs(supremum.value - value);
77 |
78 | return infDiff <= supDiff
79 | ? { ...infimum, index: 0 }
80 | : { ...supremum, index: 1 };
81 | };
82 |
--------------------------------------------------------------------------------
/lib/Menu/BaseMenu.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import Popper, { type PopperProps } from "../Popper";
3 | import { FocusTrap, type LabelInfo } from "../internals";
4 | import type { MergeElementProps } from "../types";
5 | import { componentWithForwardedRef } from "../utils";
6 | import type { Props as MenuProps } from "./Menu";
7 |
8 | type OwnProps = {
9 | className?: string;
10 | children?: React.ReactNode;
11 | open: boolean;
12 | keepMounted: boolean;
13 | trapFocus: boolean;
14 | alignment: NonNullable;
15 | autoPlacement?: PopperProps["autoPlacement"];
16 | activeDescendantId?: string | null;
17 | label: LabelInfo;
18 | onExitTrap?: (event: FocusEvent) => void;
19 | resolveAnchor: NonNullable;
20 | computationMiddleware?: PopperProps["computationMiddleware"];
21 | };
22 |
23 | export type Props = Omit<
24 | MergeElementProps<"div", OwnProps>,
25 | "defaultValue" | "defaultChecked"
26 | >;
27 |
28 | const BaseMenuBase = (props: Props, ref: React.Ref) => {
29 | const {
30 | className,
31 | children,
32 | open,
33 | keepMounted,
34 | trapFocus,
35 | alignment,
36 | activeDescendantId,
37 | label,
38 | autoPlacement = true,
39 | onExitTrap,
40 | resolveAnchor,
41 | computationMiddleware,
42 | ...otherProps
43 | } = props;
44 |
45 | const renderContent = () => {
46 | const renderMenu = () => (
47 |
61 | {children}
62 |
63 | );
64 |
65 | if (trapFocus) {
66 | return (
67 |
71 | {renderMenu()}
72 |
73 | );
74 | }
75 |
76 | return renderMenu();
77 | };
78 |
79 | return (
80 |
90 | {renderContent()}
91 |
92 | );
93 | };
94 |
95 | const BaseMenu = componentWithForwardedRef(BaseMenuBase, "BaseMenu");
96 |
97 | export default BaseMenu;
98 |
--------------------------------------------------------------------------------
/lib/Menu/components/Group.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { getLabelInfo } from "../../internals";
3 | import type { MergeElementProps } from "../../types";
4 | import { componentWithForwardedRef, useDeterministicId } from "../../utils";
5 | import { GroupRoot as GroupRootSlot } from "../slots";
6 |
7 | type OwnProps = {
8 | /**
9 | * The content of the component.
10 | */
11 | children?: React.ReactNode;
12 | /**
13 | * The className applied to the component.
14 | */
15 | className?: string;
16 | /**
17 | * The label of the group.
18 | */
19 | label:
20 | | {
21 | /**
22 | * The label to use as `aria-label` property.
23 | */
24 | screenReaderLabel: string;
25 | }
26 | | {
27 | /**
28 | * Identifies the element (or elements) that labels the group.
29 | *
30 | * @see {@link https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-labelledby MDN Web Docs} for more information.
31 | */
32 | labelledBy: string;
33 | };
34 | };
35 |
36 | export type Props = Omit<
37 | MergeElementProps<"div", OwnProps>,
38 | | "value"
39 | | "defaultValue"
40 | | "defaultChecked"
41 | | "checked"
42 | | "onChange"
43 | | "onChangeCapture"
44 | >;
45 |
46 | const GroupBase = (props: Props, ref: React.Ref) => {
47 | const { children, className, label, id: idProp, ...otherProps } = props;
48 |
49 | const id = useDeterministicId(idProp, "styleless-ui__menu-group");
50 |
51 | const labelInfo = getLabelInfo(label, "Menu.Group", {
52 | customErrorMessage: [
53 | "Invalid `label` property.",
54 | "The `label` property must be in shape of " +
55 | "`{ screenReaderLabel: string; } | { labelledBy: string; }`",
56 | ].join("\n"),
57 | });
58 |
59 | return (
60 |
71 | {children}
72 |
73 | );
74 | };
75 |
76 | const Group = componentWithForwardedRef(GroupBase, "Menu.Group");
77 |
78 | export default Group;
79 |
--------------------------------------------------------------------------------
/lib/Menu/components/Item/context.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | type ContextValue = {
4 | id: string;
5 | isExpanded: boolean;
6 | };
7 |
8 | const Context = React.createContext(null);
9 |
10 | if (process.env.NODE_ENV !== "production") {
11 | Context.displayName = "Menu.Item.Context";
12 | }
13 |
14 | export {
15 | Context as MenuItemContext,
16 | type ContextValue as MenuItemContextValue,
17 | };
18 |
--------------------------------------------------------------------------------
/lib/Menu/components/Item/index.ts:
--------------------------------------------------------------------------------
1 | export {
2 | default,
3 | type ClassNameProps as ItemClassNameProps,
4 | type Props as ItemProps,
5 | type RenderProps as ItemRenderProps,
6 | } from "./Item";
7 |
--------------------------------------------------------------------------------
/lib/Menu/components/RadioGroup/RadioGroup.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { getLabelInfo } from "../../../internals";
3 | import type { MergeElementProps } from "../../../types";
4 | import { componentWithForwardedRef, useDeterministicId } from "../../../utils";
5 | import { RadioGroupRoot as RadioGroupRootSlot } from "../../slots";
6 | import { RadioGroupContext } from "./context";
7 |
8 | type OwnProps = {
9 | /**
10 | * The content of the component.
11 | */
12 | children?: React.ReactNode;
13 | /**
14 | * The className applied to the component.
15 | */
16 | className?: string;
17 | /**
18 | * The label of the group.
19 | */
20 | label:
21 | | {
22 | /**
23 | * The label to use as `aria-label` property.
24 | */
25 | screenReaderLabel: string;
26 | }
27 | | {
28 | /**
29 | * Identifies the element (or elements) that labels the group.
30 | *
31 | * @see {@link https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-labelledby MDN Web Docs} for more information.
32 | */
33 | labelledBy: string;
34 | };
35 | /**
36 | * The value of the selected radio.
37 | */
38 | value: string;
39 | /**
40 | * The Callback is fired when the state changes.
41 | */
42 | onValueChange?: (value: string) => void;
43 | };
44 |
45 | export type Props = Omit<
46 | MergeElementProps<"div", OwnProps>,
47 | "defaultChecked" | "checked" | "onChange" | "onChangeCapture" | "defaultValue"
48 | >;
49 |
50 | const RadioGroupBase = (props: Props, ref: React.Ref) => {
51 | const {
52 | children,
53 | className,
54 | value,
55 | onValueChange,
56 | label,
57 | id: idProp,
58 | ...otherProps
59 | } = props;
60 |
61 | const id = useDeterministicId(idProp, "styleless-ui__menu-radio-group");
62 |
63 | const labelInfo = getLabelInfo(label, "Menu.RadioGroup", {
64 | customErrorMessage: [
65 | "Invalid `label` property.",
66 | "The `label` property must be in shape of " +
67 | "`{ screenReaderLabel: string; } | { labelledBy: string; }`",
68 | ].join("\n"),
69 | });
70 |
71 | const handleChange = (radioValue: string) => {
72 | onValueChange?.(radioValue);
73 | };
74 |
75 | return (
76 |
87 |
90 | {children}
91 |
92 |
93 | );
94 | };
95 |
96 | const RadioGroup = componentWithForwardedRef(RadioGroupBase, "Menu.RadioGroup");
97 |
98 | export default RadioGroup;
99 |
--------------------------------------------------------------------------------
/lib/Menu/components/RadioGroup/context.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | type ContextValue = {
4 | value: string;
5 | onValueChange: (value: string) => void;
6 | };
7 |
8 | const Context = React.createContext(null);
9 |
10 | if (process.env.NODE_ENV !== "production") {
11 | Context.displayName = "Menu.RadioGroup.Context";
12 | }
13 |
14 | export {
15 | Context as RadioGroupContext,
16 | type ContextValue as RadioGroupContextValue,
17 | };
18 |
--------------------------------------------------------------------------------
/lib/Menu/components/RadioGroup/index.ts:
--------------------------------------------------------------------------------
1 | export { default, type Props as RadioGroupProps } from "./RadioGroup";
2 |
--------------------------------------------------------------------------------
/lib/Menu/components/SeparatorItem.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import type { MergeElementProps } from "../../types";
3 | import { componentWithForwardedRef } from "../../utils";
4 | import { SeparatorItemRoot as SeparatorItemRootSlot } from "../slots";
5 |
6 | type OwnProps = {
7 | /**
8 | * The className applied to the component.
9 | */
10 | className?: string;
11 | };
12 |
13 | export type Props = Omit<
14 | MergeElementProps<"div", OwnProps>,
15 | "defaultValue" | "defaultChecked" | "children"
16 | >;
17 |
18 | const SeparatorItemBase = (props: Props, ref: React.Ref) => {
19 | const { className, ...otherProps } = props;
20 |
21 | return (
22 |
29 | );
30 | };
31 |
32 | const SeparatorItem = componentWithForwardedRef(
33 | SeparatorItemBase,
34 | "Menu.SeparatorItem",
35 | );
36 |
37 | export default SeparatorItem;
38 |
--------------------------------------------------------------------------------
/lib/Menu/components/SubMenu.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { logger, resolvePropWithRenderContext } from "../../internals";
3 | import type { MergeElementProps, PropWithRenderContext } from "../../types";
4 | import {
5 | componentWithForwardedRef,
6 | setRef,
7 | useDeterministicId,
8 | } from "../../utils";
9 | import BaseMenu from "../BaseMenu";
10 | import { MenuContext } from "../context";
11 | import { SubMenuRoot as SubMenuRootSlot } from "../slots";
12 | import { MenuItemContext } from "./Item/context";
13 |
14 | export type RenderProps = {
15 | /**
16 | * The `open` state of the component.
17 | */
18 | open: boolean;
19 | };
20 |
21 | export type ClassNameProps = RenderProps;
22 |
23 | type OwnProps = {
24 | /**
25 | * The content of the component.
26 | */
27 | children?: PropWithRenderContext;
28 | /**
29 | * The className applied to the component.
30 | */
31 | className?: PropWithRenderContext;
32 | };
33 |
34 | export type Props = Omit<
35 | MergeElementProps<"div", OwnProps>,
36 | "defaultValue" | "defaultChecked"
37 | >;
38 |
39 | const SubMenuBase = (props: Props, ref: React.Ref) => {
40 | const {
41 | children: childrenProp,
42 | className: classNameProp,
43 | id: idProp,
44 | ...otherProps
45 | } = props;
46 |
47 | const id = useDeterministicId(idProp, "styleless-ui__menu-submenu");
48 |
49 | const itemCtx = React.useContext(MenuItemContext);
50 | const menuCtx = React.useContext(MenuContext);
51 |
52 | if (!menuCtx) {
53 | logger("You have to use this component as a descendant of .", {
54 | scope: "Menu.SubMenu",
55 | type: "error",
56 | });
57 |
58 | return null;
59 | }
60 |
61 | if (!itemCtx) {
62 | logger("You have to use this component as `subMenu` prop of .", {
63 | scope: "Menu.SubMenu",
64 | type: "error",
65 | });
66 |
67 | return null;
68 | }
69 |
70 | const openState = itemCtx.isExpanded;
71 |
72 | const renderProps: RenderProps = { open: openState };
73 | const classNameProps: ClassNameProps = renderProps;
74 |
75 | const children = resolvePropWithRenderContext(childrenProp, renderProps);
76 | const className = resolvePropWithRenderContext(classNameProp, classNameProps);
77 |
78 | const resolveAnchor = () => document.getElementById(itemCtx.id);
79 |
80 | if (!menuCtx.keepMounted && !openState) return null;
81 |
82 | const refCallback = (node: HTMLElement | null) => {
83 | setRef(ref, node);
84 |
85 | if (!node) return;
86 |
87 | const anchorItem = document.getElementById(itemCtx.id);
88 |
89 | anchorItem?.setAttribute("data-submenu", id);
90 | };
91 |
92 | return (
93 |
112 | {children}
113 |
114 | );
115 | };
116 |
117 | const SubMenu = componentWithForwardedRef(SubMenuBase, "Menu.SubMenu");
118 |
119 | export default SubMenu;
120 |
--------------------------------------------------------------------------------
/lib/Menu/components/index.ts:
--------------------------------------------------------------------------------
1 | export {
2 | default as CheckItem,
3 | type ClassNameProps as CheckItemClassNameProps,
4 | type Props as CheckItemProps,
5 | type RenderProps as CheckItemRenderProps,
6 | } from "./CheckItem";
7 | export { default as Group, type Props as GroupProps } from "./Group";
8 | export {
9 | default as Item,
10 | type ItemClassNameProps,
11 | type ItemProps,
12 | type ItemRenderProps,
13 | } from "./Item";
14 | export { default as RadioGroup, type RadioGroupProps } from "./RadioGroup";
15 | export {
16 | default as RadioItem,
17 | type ClassNameProps as RadioItemClassNameProps,
18 | type Props as RadioItemProps,
19 | type RenderProps as RadioItemRenderProps,
20 | } from "./RadioItem";
21 | export {
22 | default as SeparatorItem,
23 | type Props as SeparatorItemProps,
24 | } from "./SeparatorItem";
25 | export { default as SubMenu, type Props as SubMenuProps } from "./SubMenu";
26 |
--------------------------------------------------------------------------------
/lib/Menu/constants.ts:
--------------------------------------------------------------------------------
1 | import { createCustomEvent } from "../utils";
2 |
3 | export const ExpandSubMenuEvent = createCustomEvent("Menu", "expandSubMenu", {
4 | bubbles: true,
5 | cancelable: true,
6 | });
7 |
8 | export const CollapseSubMenuEvent = createCustomEvent(
9 | "Menu",
10 | "collapseSubMenu",
11 | {
12 | bubbles: true,
13 | cancelable: true,
14 | },
15 | );
16 |
--------------------------------------------------------------------------------
/lib/Menu/context.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import type { PopperProps } from "../Popper";
3 |
4 | type ContextValue = {
5 | id: string;
6 | activeElement: HTMLElement | null;
7 | keepMounted: boolean;
8 | emitClose: () => void;
9 | emitActiveElementChange: (newActiveElement: HTMLElement | null) => void;
10 | computationMiddleware: NonNullable;
11 | };
12 |
13 | const Context = React.createContext(null);
14 |
15 | if (process.env.NODE_ENV !== "production") {
16 | Context.displayName = "Menu.Context";
17 | }
18 |
19 | export { Context as MenuContext, type ContextValue as MenuContextValue };
20 |
--------------------------------------------------------------------------------
/lib/Menu/index.ts:
--------------------------------------------------------------------------------
1 | export {
2 | default as Root,
3 | type ClassNameProps as RootClassNameProps,
4 | type Props as RootProps,
5 | type RenderProps as RootRenderProps,
6 | } from "./Menu";
7 | export * from "./components";
8 |
--------------------------------------------------------------------------------
/lib/Menu/slots.ts:
--------------------------------------------------------------------------------
1 | export const Root = "Menu:Root";
2 | export const SubMenuRoot = "Menu:SubMenu:Root";
3 | export const SeparatorItemRoot = "Menu:SeparatorItem:Root";
4 | export const RadioItemRoot = "Menu:RadioItem:Root";
5 | export const RadioGroupRoot = "Menu:RadioGroup:Root";
6 | export const ItemRoot = "Menu:Item:Root";
7 | export const GroupRoot = "Menu:Group:Root";
8 | export const CheckItemRoot = "Menu:CheckItem:Root";
9 |
--------------------------------------------------------------------------------
/lib/Meter/Meter.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { getLabelInfo, resolvePropWithRenderContext } from "../internals";
3 | import type { MergeElementProps, PropWithRenderContext } from "../types";
4 | import { componentWithForwardedRef, remap } from "../utils";
5 | import { Root as RootSlot } from "./slots";
6 |
7 | export type RenderProps = {
8 | /**
9 | * The value of the meter.
10 | */
11 | value: number;
12 | /**
13 | * The percentage value of the meter.
14 | */
15 | percentageValue: number;
16 | /**
17 | * The text used to represent the value.
18 | */
19 | valueText: string;
20 | };
21 |
22 | export type ClassNameProps = RenderProps;
23 |
24 | type OwnProps = {
25 | /**
26 | * The content of the component.
27 | */
28 | children?: PropWithRenderContext;
29 | /**
30 | * The className applied to the component.
31 | */
32 | className?: PropWithRenderContext;
33 | /**
34 | * The current value of the meter.
35 | */
36 | value: number;
37 | /**
38 | * The minimum allowed value of the meter.
39 | * Should not be greater than or equal to `max`.
40 | */
41 | min: number;
42 | /**
43 | * The maximum allowed value of the meter.
44 | * Should not be less than or equal to `min`.
45 | */
46 | max: number;
47 | /**
48 | * A string value that provides a user-friendly name
49 | * for the current value of the meter.
50 | * This is important for screen reader users.
51 | */
52 | valueText: string;
53 | /**
54 | * The label of the component.
55 | */
56 | label:
57 | | {
58 | /**
59 | * The label to use as `aria-label` property.
60 | */
61 | screenReaderLabel: string;
62 | }
63 | | {
64 | /**
65 | * Identifies the element (or elements) that labels the meter.
66 | *
67 | * @see {@link https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-labelledby MDN Web Docs} for more information.
68 | */
69 | labelledBy: string;
70 | };
71 | };
72 |
73 | export type Props = Omit<
74 | MergeElementProps<"div", OwnProps>,
75 | "defaultValue" | "defaultChecked"
76 | >;
77 |
78 | const MeterBase = (props: Props, ref: React.Ref) => {
79 | const {
80 | className: classNameProp,
81 | children: childrenProp,
82 | value,
83 | min,
84 | max,
85 | valueText,
86 | label,
87 | ...otherProps
88 | } = props;
89 |
90 | const labelInfo = getLabelInfo(label, "Meter", {
91 | customErrorMessage: [
92 | "Invalid `label` property.",
93 | "The `label` property must be in shape of " +
94 | "`{ screenReaderLabel: string; } | { labelledBy: string; }`",
95 | ].join("\n"),
96 | });
97 |
98 | const percentageValue = remap(value, min, max, 0, 100);
99 |
100 | const renderProps: RenderProps = { percentageValue, value, valueText };
101 |
102 | const classNameProps: ClassNameProps = renderProps;
103 |
104 | const children = resolvePropWithRenderContext(childrenProp, renderProps);
105 | const className = resolvePropWithRenderContext(classNameProp, classNameProps);
106 |
107 | return (
108 |
121 | {children}
122 |
123 | );
124 | };
125 |
126 | const Meter = componentWithForwardedRef(MeterBase, "Meter");
127 |
128 | export default Meter;
129 |
--------------------------------------------------------------------------------
/lib/Meter/index.ts:
--------------------------------------------------------------------------------
1 | export {
2 | default,
3 | type ClassNameProps as MeterClassNameProps,
4 | type Props as MeterProps,
5 | type RenderProps as MeterRenderProps,
6 | } from "./Meter";
7 |
--------------------------------------------------------------------------------
/lib/Meter/slots.ts:
--------------------------------------------------------------------------------
1 | export const Root = "Meter:Root";
2 |
--------------------------------------------------------------------------------
/lib/Popper/index.ts:
--------------------------------------------------------------------------------
1 | export {
2 | default,
3 | type ClassNameProps as PopperClassNameProps,
4 | type Props as PopperProps,
5 | type RenderProps as PopperRenderProps,
6 | } from "./Popper";
7 |
--------------------------------------------------------------------------------
/lib/Popper/slots.ts:
--------------------------------------------------------------------------------
1 | export const Root = "Popper:Root";
2 |
--------------------------------------------------------------------------------
/lib/Popper/types.ts:
--------------------------------------------------------------------------------
1 | import type { RequireOnlyOne, VirtualElement } from "../types";
2 |
3 | export type Alignment = "start" | "end";
4 | export type Side = "top" | "right" | "bottom" | "left";
5 | export type AlignedPlacement = `${Side}-${Alignment}`;
6 | export type Placement = Side | AlignedPlacement;
7 |
8 | export type Strategy = "absolute" | "fixed";
9 |
10 | export type Coordinates = { x: number; y: number };
11 | export type Dimensions = { width: number; height: number };
12 |
13 | export type Rect = Coordinates & Dimensions;
14 | export type ElementRects = { anchorRect: Rect; popperRect: Rect };
15 | export type Elements = {
16 | anchorElement: HTMLElement | VirtualElement;
17 | popperElement: HTMLElement;
18 | };
19 |
20 | export type OffsetMiddleware =
21 | | number
22 | | {
23 | /**
24 | * The axis that runs along the side of the popper element.
25 | */
26 | mainAxis?: number;
27 | /**
28 | * The axis that runs along the alignment of the popper element.
29 | */
30 | crossAxis?: number;
31 | };
32 |
33 | export type AutoPlacementMiddleware = boolean | { excludeSides: Side[] };
34 |
35 | export type MiddlewareResult = RequireOnlyOne<{
36 | coordinates: Partial;
37 | placement: Placement;
38 | }>;
39 |
40 | export type ComputationMiddlewareArgs = {
41 | elementRects: ElementRects;
42 | elements: Elements;
43 | coordinates: Coordinates;
44 | placement: Placement;
45 | strategy: Strategy;
46 | overflow: Record;
47 | };
48 | export type ComputationMiddlewareResult = MiddlewareResult;
49 | export type ComputationMiddlewareOrder =
50 | | "beforeAutoPlacement"
51 | | "afterAutoPlacement";
52 | export type ComputationMiddleware = (
53 | args: ComputationMiddlewareArgs,
54 | ) => ComputationMiddlewareResult;
55 |
56 | export type ComputationResult = Coordinates & { placement: Placement };
57 | export type ComputationConfig = {
58 | placement: Placement;
59 | strategy: Strategy;
60 | autoPlacement: AutoPlacementMiddleware;
61 | offset: OffsetMiddleware;
62 | computationMiddleware?: ComputationMiddleware;
63 | computationMiddlewareOrder: ComputationMiddlewareOrder;
64 | };
65 |
--------------------------------------------------------------------------------
/lib/Portal/Portal.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as ReactDOM from "react-dom";
3 | import { usePortalConfig } from "../PortalConfigProvider";
4 | import { useIsomorphicValue } from "../utils";
5 |
6 | export type Props = {
7 | /**
8 | * A function that will resolve the container element for the portal.
9 | * If not provided will opt-in `PortalConfigProvider` configuration as default behavior.
10 | *
11 | * Please note that this function is only called on the client-side.
12 | */
13 | resolveContainer?: () => HTMLElement | null;
14 | /**
15 | * The children to render into the container.
16 | */
17 | children: React.ReactNode;
18 | /**
19 | * If `true`, the `children` will be under the DOM hierarchy of the parent component.
20 | *
21 | * @default false
22 | */
23 | disabled?: boolean;
24 | };
25 |
26 | const Portal = (props: Props) => {
27 | const { resolveContainer, children, disabled = false } = props;
28 |
29 | const portalConfig = usePortalConfig();
30 |
31 | const containerResolver =
32 | resolveContainer ?? portalConfig?.resolveContainer ?? (() => document.body);
33 |
34 | const container = useIsomorphicValue(containerResolver, null);
35 |
36 | if (disabled) return <>{children}>;
37 | if (!container) return null;
38 |
39 | return ReactDOM.createPortal(children, container);
40 | };
41 |
42 | export default Portal;
43 |
--------------------------------------------------------------------------------
/lib/Portal/index.ts:
--------------------------------------------------------------------------------
1 | export { default, type Props as PortalProps } from "./Portal";
2 |
--------------------------------------------------------------------------------
/lib/Portal/utils.ts:
--------------------------------------------------------------------------------
1 | export const getContainer = (querySelector?: string) =>
2 | querySelector
3 | ? document.querySelector(querySelector)
4 | : document.body;
5 |
--------------------------------------------------------------------------------
/lib/PortalConfigProvider/PortalConfigProvider.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { PortalConfigContext, type PortalConfigContextValue } from "./context";
3 |
4 | export type Props = {
5 | children: React.ReactNode;
6 | config: PortalConfigContextValue;
7 | };
8 |
9 | const PortalConfigProvider = (props: Props) => {
10 | const { config, children } = props;
11 |
12 | return (
13 |
14 | {children}
15 |
16 | );
17 | };
18 |
19 | export default PortalConfigProvider;
20 |
--------------------------------------------------------------------------------
/lib/PortalConfigProvider/context.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | type ContextValue = {
4 | /**
5 | * A function that will resolve the container element for the portals.
6 | *
7 | * Please note that this function is only called on the client-side.
8 | */
9 | resolveContainer: () => HTMLElement | null;
10 | };
11 |
12 | const Context = React.createContext(null);
13 |
14 | if (process.env.NODE_ENV !== "production")
15 | Context.displayName = "PortalConfigContext";
16 |
17 | export {
18 | Context as PortalConfigContext,
19 | type ContextValue as PortalConfigContextValue,
20 | };
21 |
--------------------------------------------------------------------------------
/lib/PortalConfigProvider/index.ts:
--------------------------------------------------------------------------------
1 | export {
2 | default,
3 | type Props as PortalConfigProviderProps,
4 | } from "./PortalConfigProvider";
5 | export { default as usePortalConfig } from "./usePortalConfig";
6 |
--------------------------------------------------------------------------------
/lib/PortalConfigProvider/usePortalConfig.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { PortalConfigContext } from "./context";
3 |
4 | const usePortalConfig = () => React.useContext(PortalConfigContext);
5 |
6 | export default usePortalConfig;
7 |
--------------------------------------------------------------------------------
/lib/PreserveAspectRatio/PreserveAspectRatio.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as Slots from "./slots";
3 |
4 | export type Props = {
5 | /**
6 | * The content of the component.
7 | */
8 | children?: React.ReactNode;
9 | /**
10 | * The ratio which needs to be preserved.
11 | */
12 | ratio: number;
13 | };
14 |
15 | const PreserveAspectRatio = (props: Props) => {
16 | const { children, ratio } = props;
17 |
18 | const rootStyles: React.CSSProperties = {
19 | position: "relative",
20 | paddingTop: `${100 / ratio}%`,
21 | width: "100%",
22 | };
23 |
24 | const containerStyles: React.CSSProperties = {
25 | position: "absolute",
26 | top: 0,
27 | left: 0,
28 | right: 0,
29 | bottom: 0,
30 | };
31 |
32 | return (
33 |
37 |
41 | {children}
42 |
43 |
44 | );
45 | };
46 |
47 | export default PreserveAspectRatio;
48 |
--------------------------------------------------------------------------------
/lib/PreserveAspectRatio/index.ts:
--------------------------------------------------------------------------------
1 | export {
2 | default,
3 | type Props as PreserveAspectRatioProps,
4 | } from "./PreserveAspectRatio";
5 |
--------------------------------------------------------------------------------
/lib/PreserveAspectRatio/slots.ts:
--------------------------------------------------------------------------------
1 | export const Root = "PreserveAspectRatio:Root";
2 | export const Container = "PreserveAspectRatio:Container";
3 |
--------------------------------------------------------------------------------
/lib/ProgressBar/index.ts:
--------------------------------------------------------------------------------
1 | export {
2 | default,
3 | type ClassNameProps as ProgressBarClassNameProps,
4 | type Props as ProgressBarProps,
5 | type RenderProps as ProgressBarRenderProps,
6 | } from "./ProgressBar";
7 |
--------------------------------------------------------------------------------
/lib/ProgressBar/slots.ts:
--------------------------------------------------------------------------------
1 | export const Root = "ProgressBar:Root";
2 |
--------------------------------------------------------------------------------
/lib/Radio/index.ts:
--------------------------------------------------------------------------------
1 | export {
2 | default,
3 | type ClassNameProps as RadioClassNameProps,
4 | type Props as RadioProps,
5 | type RenderProps as RadioRenderProps,
6 | } from "./Radio";
7 |
--------------------------------------------------------------------------------
/lib/Radio/slots.ts:
--------------------------------------------------------------------------------
1 | export const Root = "Radio:Root";
2 |
--------------------------------------------------------------------------------
/lib/RadioGroup/context.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import type { PickAsMandatory } from "../types";
3 | import { type Props } from "./RadioGroup";
4 |
5 | type ContextValue = PickAsMandatory &
6 | Pick & {
7 | forcedTabability: string | null;
8 | onChange: (newCheckedState: boolean, inputValue: string) => void;
9 | };
10 |
11 | const Context = React.createContext(null);
12 |
13 | if (process.env.NODE_ENV !== "production") {
14 | Context.displayName = "RadioGroupContext";
15 | }
16 |
17 | export {
18 | Context as RadioGroupContext,
19 | type ContextValue as RadioGroupContextValue,
20 | };
21 |
--------------------------------------------------------------------------------
/lib/RadioGroup/index.ts:
--------------------------------------------------------------------------------
1 | export {
2 | default,
3 | type ClassNameProps as RadioGroupClassNameProps,
4 | type Props as RadioGroupProps,
5 | type RenderProps as RadioGroupRenderProps,
6 | } from "./RadioGroup";
7 |
--------------------------------------------------------------------------------
/lib/RadioGroup/slots.ts:
--------------------------------------------------------------------------------
1 | export const Root = "RadioGroup:Root";
2 |
--------------------------------------------------------------------------------
/lib/Select/components/Controller/index.ts:
--------------------------------------------------------------------------------
1 | export {
2 | default,
3 | type ClassNameProps as ControllerClassNameProps,
4 | type Props as ControllerProps,
5 | } from "./Controller";
6 |
--------------------------------------------------------------------------------
/lib/Select/components/EmptyStatement.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { disableUserSelectCSSProperties, logger } from "../../internals";
3 | import { type MergeElementProps } from "../../types";
4 | import { componentWithForwardedRef } from "../../utils";
5 | import { SelectContext } from "../context";
6 | import { EmptyStatementRoot as EmptyStatementRootSlot } from "../slots";
7 |
8 | type OwnProps = {
9 | /**
10 | * The className applied to the component.
11 | */
12 | className?: string;
13 | /**
14 | * The content of the component.
15 | */
16 | children?: React.ReactNode;
17 | };
18 |
19 | export type Props = Omit<
20 | MergeElementProps<"div", OwnProps>,
21 | "defaultValue" | "defaultChecked"
22 | >;
23 |
24 | const EmptyStatementBase = (props: Props, ref: React.Ref) => {
25 | const { className, children, style: styleProp, ...otherProps } = props;
26 |
27 | const ctx = React.useContext(SelectContext);
28 |
29 | if (!ctx) {
30 | logger("You have to use this component as a descendant of .", {
31 | scope: "Select.EmptyStatement",
32 | type: "error",
33 | });
34 |
35 | return null;
36 | }
37 |
38 | if (ctx.filteredEntities == null || ctx.filteredEntities.length !== 0) {
39 | return null;
40 | }
41 |
42 | const style: React.CSSProperties = {
43 | ...(styleProp ?? {}),
44 | ...disableUserSelectCSSProperties,
45 | };
46 |
47 | return (
48 |
55 | {children}
56 |
57 | );
58 | };
59 |
60 | const EmptyStatement = componentWithForwardedRef(
61 | EmptyStatementBase,
62 | "Select.EmptyStatement",
63 | );
64 |
65 | export default EmptyStatement;
66 |
--------------------------------------------------------------------------------
/lib/Select/components/Group.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import {
3 | getLabelInfo,
4 | logger,
5 | resolvePropWithRenderContext,
6 | } from "../../internals";
7 | import type { MergeElementProps, PropWithRenderContext } from "../../types";
8 | import {
9 | componentWithForwardedRef,
10 | useDeterministicId,
11 | useIsServerHandoffComplete,
12 | } from "../../utils";
13 | import { SelectContext } from "../context";
14 | import { GroupRoot as GroupRootSlot } from "../slots";
15 |
16 | export type RenderProps = {
17 | /**
18 | * The `hidden` state of the component.
19 | * If no descendant option is visible, it's going to be `true`.
20 | */
21 | hidden: boolean;
22 | };
23 |
24 | export type ClassNameProps = RenderProps;
25 |
26 | type OwnProps = {
27 | /**
28 | * The content of the component.
29 | */
30 | children?: PropWithRenderContext;
31 | /**
32 | * The className applied to the component.
33 | */
34 | className?: PropWithRenderContext;
35 | /**
36 | * The label of the component.
37 | */
38 | label:
39 | | {
40 | /**
41 | * The label to use as `aria-label` property.
42 | */
43 | screenReaderLabel: string;
44 | }
45 | | {
46 | /**
47 | * Identifies the element (or elements) that labels the component.
48 | *
49 | * @see {@link https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-labelledby MDN Web Docs} for more information.
50 | */
51 | labelledBy: string;
52 | };
53 | };
54 |
55 | export type Props = Omit<
56 | MergeElementProps<"div", OwnProps>,
57 | "defaultChecked" | "defaultValue"
58 | >;
59 |
60 | const GroupBase = (props: Props, ref: React.Ref) => {
61 | const {
62 | id: idProp,
63 | label,
64 | className: classNameProp,
65 | children: childrenProp,
66 | ...otherProps
67 | } = props;
68 |
69 | const id = useDeterministicId(idProp, "styleless-ui__select__group");
70 |
71 | const isServerHandoffComplete = useIsServerHandoffComplete();
72 |
73 | const labelInfo = getLabelInfo(label, "Select.Group", {
74 | customErrorMessage: [
75 | "Invalid `label` property.",
76 | "The `label` property must be in shape of " +
77 | "`{ screenReaderLabel: string; } | { labelledBy: string; }`",
78 | ].join("\n"),
79 | });
80 |
81 | const ctx = React.useContext(SelectContext);
82 |
83 | const getOptionElements = () => {
84 | const group = document.getElementById(id);
85 |
86 | if (!group) return [];
87 |
88 | return Array.from(group.querySelectorAll(`[role='option']`));
89 | };
90 |
91 | const isHidden = React.useMemo(() => {
92 | if (!isServerHandoffComplete) return false;
93 |
94 | const filtered = ctx?.filteredEntities;
95 |
96 | if (filtered == null) return false;
97 | if (filtered.length === 0) return true;
98 |
99 | const optionElements: HTMLElement[] = getOptionElements();
100 |
101 | return optionElements.every(
102 | optionElement =>
103 | !filtered.some(
104 | value => value === optionElement.getAttribute("data-entity"),
105 | ),
106 | );
107 | // eslint-disable-next-line react-hooks/exhaustive-deps
108 | }, [ctx?.filteredEntities, isServerHandoffComplete]);
109 |
110 | if (!ctx) {
111 | logger("You have to use this component as a descendant of .", {
112 | scope: "Select.Group",
113 | type: "error",
114 | });
115 |
116 | return null;
117 | }
118 |
119 | const renderProps: RenderProps = {
120 | hidden: isHidden,
121 | };
122 |
123 | const classNameProps: ClassNameProps = renderProps;
124 |
125 | const children = resolvePropWithRenderContext(childrenProp, renderProps);
126 | const className = resolvePropWithRenderContext(classNameProp, classNameProps);
127 |
128 | return (
129 |
141 | {children}
142 |
143 | );
144 | };
145 |
146 | const Group = componentWithForwardedRef(GroupBase, "Select.Group");
147 |
148 | export default Group;
149 |
--------------------------------------------------------------------------------
/lib/Select/components/List/index.ts:
--------------------------------------------------------------------------------
1 | export {
2 | default,
3 | type ClassNameProps as ListClassNameProps,
4 | type Props as ListProps,
5 | type RenderProps as ListRenderProps,
6 | } from "./List";
7 |
--------------------------------------------------------------------------------
/lib/Select/components/List/utils.ts:
--------------------------------------------------------------------------------
1 | import { PopperUtils } from "../../../utils";
2 |
3 | const calcBoundaryOverflow = (
4 | anchorElement: HTMLElement,
5 | element: HTMLElement,
6 | ) => {
7 | const elements = { anchorElement, popperElement: element };
8 | const strategy: (typeof PopperUtils.strategies)[0] = "fixed";
9 |
10 | const rects = PopperUtils.getElementRects(elements, strategy);
11 |
12 | const topSideCoordinates = {
13 | x: 0,
14 | y: rects.anchorRect.y - rects.popperRect.height,
15 | };
16 |
17 | const bottomSideCoordinates = {
18 | x: 0,
19 | y: rects.anchorRect.y + rects.anchorRect.height,
20 | };
21 |
22 | const overflowArgs = { strategy, elements, elementRects: rects };
23 |
24 | const topSideOverflow = PopperUtils.detectBoundaryOverflow({
25 | ...overflowArgs,
26 | coordinates: topSideCoordinates,
27 | });
28 |
29 | const bottomSideOverflow = PopperUtils.detectBoundaryOverflow({
30 | ...overflowArgs,
31 | coordinates: bottomSideCoordinates,
32 | });
33 |
34 | return {
35 | topSideOverflow: topSideOverflow.top,
36 | bottomSideOverflow: bottomSideOverflow.bottom,
37 | };
38 | };
39 |
40 | export const calcSidePlacement = (
41 | anchorElement: HTMLElement,
42 | element: HTMLElement,
43 | ) => {
44 | const { topSideOverflow, bottomSideOverflow } = calcBoundaryOverflow(
45 | anchorElement,
46 | element,
47 | );
48 |
49 | if (topSideOverflow < bottomSideOverflow) return "top";
50 |
51 | return "bottom";
52 | };
53 |
--------------------------------------------------------------------------------
/lib/Select/components/Trigger.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { logger } from "../../internals";
3 | import type { MergeElementProps } from "../../types";
4 | import {
5 | componentWithForwardedRef,
6 | setRef,
7 | useDeterministicId,
8 | useEventCallback,
9 | } from "../../utils";
10 | import { SelectContext } from "../context";
11 | import {
12 | ControllerRoot as ControllerRootSlot,
13 | TriggerRoot as TriggerRootSlot,
14 | } from "../slots";
15 |
16 | type OwnProps = {
17 | /**
18 | * The className applied to the component.
19 | */
20 | className?: string;
21 | /**
22 | * The content of the component.
23 | */
24 | children?: React.ReactNode;
25 | };
26 |
27 | export type Props = Omit<
28 | MergeElementProps<"div", OwnProps>,
29 | "defaultValue" | "defaultChecked"
30 | >;
31 |
32 | const TriggerBase = (props: Props, ref: React.Ref) => {
33 | const { id: idProp, className, children, onClick, ...otherProps } = props;
34 |
35 | const id = useDeterministicId(idProp, "styleless-ui__select__trigger");
36 |
37 | const ctx = React.useContext(SelectContext);
38 |
39 | React.useEffect(() => {
40 | ctx?.elementsRegistry.registerElement("trigger", id);
41 |
42 | return () => {
43 | ctx?.elementsRegistry.unregisterElement("trigger");
44 | };
45 | }, [ctx?.elementsRegistry, id]);
46 |
47 | const getComboboxNode = () => {
48 | const comboboxId = ctx?.elementsRegistry.getElementId("combobox");
49 |
50 | if (!comboboxId) return null;
51 |
52 | const combobox = document.getElementById(comboboxId);
53 |
54 | return combobox;
55 | };
56 |
57 | const handleClick = useEventCallback>(
58 | event => {
59 | if (ctx?.disabled) {
60 | event.preventDefault();
61 |
62 | return;
63 | }
64 |
65 | const combobox = getComboboxNode();
66 |
67 | combobox?.focus();
68 |
69 | if (ctx?.readOnly) return;
70 |
71 | combobox?.click();
72 | onClick?.(event);
73 | },
74 | );
75 |
76 | if (!ctx) {
77 | logger("You have to use this component as a descendant of .", {
78 | scope: "Select.Trigger",
79 | type: "error",
80 | });
81 |
82 | return null;
83 | }
84 |
85 | const refCallback = (node: HTMLDivElement | null) => {
86 | setRef(ref, node);
87 |
88 | if (!node) return;
89 |
90 | const querySelector = [
91 | `[type='button']:not([data-slot='${ControllerRootSlot}'])`,
92 | `a[href]:not([data-slot='${ControllerRootSlot}'])`,
93 | `button:not([data-slot='${ControllerRootSlot}'])`,
94 | ].join(", ");
95 |
96 | node.querySelectorAll(querySelector).forEach(el => {
97 | el.tabIndex = -1;
98 | });
99 | };
100 |
101 | return (
102 |
111 | {children}
112 |
113 | );
114 | };
115 |
116 | const Trigger = componentWithForwardedRef(TriggerBase, "Select.Trigger");
117 |
118 | export default Trigger;
119 |
--------------------------------------------------------------------------------
/lib/Select/components/index.ts:
--------------------------------------------------------------------------------
1 | export {
2 | default as Controller,
3 | type ControllerClassNameProps,
4 | type ControllerProps,
5 | } from "./Controller";
6 | export {
7 | default as EmptyStatement,
8 | type Props as EmptyStatementProps,
9 | } from "./EmptyStatement";
10 | export {
11 | default as Group,
12 | type ClassNameProps as GroupClassNameProps,
13 | type Props as GroupProps,
14 | type RenderProps as GroupRenderProps,
15 | } from "./Group";
16 | export {
17 | default as List,
18 | type ListClassNameProps,
19 | type ListProps,
20 | type ListRenderProps,
21 | } from "./List";
22 | export {
23 | default as Option,
24 | type ClassNameProps as OptionClassNameProps,
25 | type Props as OptionProps,
26 | type RenderProps as OptionRenderProps,
27 | } from "./Option";
28 | export { default as Trigger, type Props as TriggerProps } from "./Trigger";
29 |
--------------------------------------------------------------------------------
/lib/Select/context.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { type LabelInfo } from "../internals";
3 | import type { RegisteredElementsKeys } from "./Select";
4 | import type { ElementsRegistry } from "./utils";
5 |
6 | type ContextValue = {
7 | isListOpen: boolean;
8 | disabled: boolean;
9 | readOnly: boolean;
10 | keepMounted: boolean;
11 | multiple: boolean;
12 | searchable: boolean;
13 | isAnyOptionSelected: boolean;
14 | activeDescendant: HTMLElement | null;
15 | selectedValues: string | string[];
16 | labelInfo: LabelInfo;
17 | filteredEntities: null | string[];
18 | elementsRegistry: ElementsRegistry;
19 | closeListAndMaintainFocus: () => void;
20 | setActiveDescendant: React.Dispatch>;
21 | setFilteredEntities: React.Dispatch>;
22 | openList: () => void;
23 | closeList: () => void;
24 | toggleList: () => void;
25 | clearOptions: () => void;
26 | handleOptionClick: (value: string) => void;
27 | handleOptionRemove: (value: string) => void;
28 | };
29 |
30 | const Context = React.createContext(null);
31 |
32 | if (process.env.NODE_ENV !== "production") {
33 | Context.displayName = "Select";
34 | }
35 |
36 | export { Context as SelectContext, type ContextValue as SelectContextValue };
37 |
--------------------------------------------------------------------------------
/lib/Select/index.ts:
--------------------------------------------------------------------------------
1 | export {
2 | default as Root,
3 | type ClassNameProps as RootClassNameProps,
4 | type Props as RootProps,
5 | type RenderProps as RootRenderProps,
6 | } from "./Select";
7 | export * from "./components";
8 |
--------------------------------------------------------------------------------
/lib/Select/slots.ts:
--------------------------------------------------------------------------------
1 | export const Root = "Select:Root";
2 | export const ControllerRoot = "Select:Controller:Root";
3 | export const TriggerRoot = "Select:Trigger:Root";
4 | export const GroupRoot = "Select:Group:Root";
5 | export const ListRoot = "Select:List:Root";
6 | export const OptionRoot = "Select:Option:Root";
7 | export const EmptyStatementRoot = "Select:EmptyStatement:Root";
8 |
--------------------------------------------------------------------------------
/lib/Select/utils.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | export const normalizeValues = (value: string | string[] | undefined) => {
4 | if (value == null) return [];
5 |
6 | if (typeof value === "string") {
7 | if (value.length === 0) return [];
8 |
9 | return [value];
10 | }
11 |
12 | return value;
13 | };
14 |
15 | export const noValueSelected = (value: string | string[] | undefined) =>
16 | normalizeValues(value).length === 0;
17 |
18 | type Registry = Map;
19 |
20 | export type ElementsRegistry = {
21 | registerElement: (key: Key, id: string) => void;
22 | unregisterElement: (key: Key) => void;
23 | getElementId: (key: Key) => string | undefined;
24 | getRegistry: () => Registry;
25 | };
26 |
27 | export const useElementsRegistry = <
28 | Key extends string = string,
29 | >(): ElementsRegistry => {
30 | const registryRef = React.useRef(new Map() as Registry);
31 |
32 | return React.useMemo(() => {
33 | type T = ElementsRegistry;
34 |
35 | const getRegistry = () => registryRef.current;
36 |
37 | const registerElement: T["registerElement"] = (key, id) => {
38 | const registry = getRegistry();
39 |
40 | registry.set(key, id);
41 | };
42 |
43 | const unregisterElement: T["unregisterElement"] = (key: Key) => {
44 | const registry = getRegistry();
45 |
46 | registry.delete(key);
47 | };
48 |
49 | const getElementId: T["getElementId"] = (key: Key) => {
50 | const registry = getRegistry();
51 |
52 | return registry.get(key);
53 | };
54 |
55 | return {
56 | registerElement,
57 | unregisterElement,
58 | getElementId,
59 | getRegistry,
60 | };
61 | }, []);
62 | };
63 |
--------------------------------------------------------------------------------
/lib/SpinButton/components/DecrementButton.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import Button from "../../Button";
3 | import { getLabelInfo, logger } from "../../internals";
4 | import type { MergeElementProps, PropWithRenderContext } from "../../types";
5 | import { componentWithForwardedRef } from "../../utils";
6 | import { SpinButtonContext } from "../context";
7 | import { DecrementButtonRoot as DecrementButtonRootSlot } from "../slots";
8 |
9 | export type RenderProps = {
10 | /**
11 | * The `disabled` state of the component.
12 | */
13 | disabled: boolean;
14 | };
15 |
16 | export type ClassNameProps = RenderProps;
17 |
18 | type OwnProps = {
19 | /**
20 | * The content of the component.
21 | */
22 | children?: PropWithRenderContext;
23 | /**
24 | * The className applied to the component.
25 | */
26 | className?: PropWithRenderContext;
27 | /**
28 | * The label of the component.
29 | */
30 | label:
31 | | {
32 | /**
33 | * The label to use as `aria-label` property.
34 | */
35 | screenReaderLabel: string;
36 | }
37 | | {
38 | /**
39 | * Identifies the element (or elements) that labels the component.
40 | *
41 | * @see {@link https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-labelledby MDN Web Docs} for more information.
42 | */
43 | labelledBy: string;
44 | };
45 | };
46 |
47 | export type Props = Omit<
48 | MergeElementProps<"button", OwnProps>,
49 | "defaultValue" | "defaultChecked"
50 | >;
51 |
52 | const DecrementButtonBase = (
53 | props: Props,
54 | ref: React.Ref,
55 | ) => {
56 | const { className, children, label, onClick, ...otherProps } = props;
57 |
58 | const ctx = React.useContext(SpinButtonContext);
59 |
60 | if (!ctx) {
61 | logger(
62 | "You have to use this component as a descendant of .",
63 | {
64 | scope: "SpinButton.DecrementButton",
65 | type: "error",
66 | },
67 | );
68 |
69 | return null;
70 | }
71 |
72 | const handleClick: React.MouseEventHandler = event => {
73 | if (ctx.disabled || ctx.readOnly) {
74 | event.preventDefault();
75 |
76 | return;
77 | }
78 |
79 | ctx.handleDecrease(1);
80 |
81 | onClick?.(event);
82 | };
83 |
84 | const labelInfo = getLabelInfo(label, "SpinButton.DecrementButton", {
85 | customErrorMessage: [
86 | "Invalid `label` property.",
87 | "The `label` property must be in shape of " +
88 | "`{ screenReaderLabel: string; } | { labelledBy: string; }`",
89 | ].join("\n"),
90 | });
91 |
92 | return (
93 |
108 | );
109 | };
110 |
111 | const DecrementButton = componentWithForwardedRef(
112 | DecrementButtonBase,
113 | "SpinButton.DecrementButton",
114 | );
115 |
116 | export default DecrementButton;
117 |
--------------------------------------------------------------------------------
/lib/SpinButton/components/IncrementButton.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import Button from "../../Button";
3 | import { getLabelInfo, logger } from "../../internals";
4 | import type { MergeElementProps, PropWithRenderContext } from "../../types";
5 | import { componentWithForwardedRef } from "../../utils";
6 | import { SpinButtonContext } from "../context";
7 | import { IncrementButtonRoot as IncrementButtonRootSlot } from "../slots";
8 |
9 | export type RenderProps = {
10 | /**
11 | * The `disabled` state of the component.
12 | */
13 | disabled: boolean;
14 | };
15 |
16 | export type ClassNameProps = RenderProps;
17 |
18 | type OwnProps = {
19 | /**
20 | * The content of the component.
21 | */
22 | children?: PropWithRenderContext;
23 | /**
24 | * The className applied to the component.
25 | */
26 | className?: PropWithRenderContext;
27 | /**
28 | * The label of the component.
29 | */
30 | label:
31 | | {
32 | /**
33 | * The label to use as `aria-label` property.
34 | */
35 | screenReaderLabel: string;
36 | }
37 | | {
38 | /**
39 | * Identifies the element (or elements) that labels the component.
40 | *
41 | * @see {@link https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-labelledby MDN Web Docs} for more information.
42 | */
43 | labelledBy: string;
44 | };
45 | };
46 |
47 | export type Props = Omit<
48 | MergeElementProps<"button", OwnProps>,
49 | "defaultValue" | "defaultChecked"
50 | >;
51 |
52 | const IncrementButtonBase = (
53 | props: Props,
54 | ref: React.Ref,
55 | ) => {
56 | const { className, children, label, onClick, ...otherProps } = props;
57 |
58 | const ctx = React.useContext(SpinButtonContext);
59 |
60 | if (!ctx) {
61 | logger(
62 | "You have to use this component as a descendant of .",
63 | {
64 | scope: "SpinButton.IncrementButton",
65 | type: "error",
66 | },
67 | );
68 |
69 | return null;
70 | }
71 |
72 | const handleClick: React.MouseEventHandler = event => {
73 | if (ctx.disabled || ctx.readOnly) {
74 | event.preventDefault();
75 |
76 | return;
77 | }
78 |
79 | ctx.handleIncrease(1);
80 |
81 | onClick?.(event);
82 | };
83 |
84 | const labelInfo = getLabelInfo(label, "SpinButton.IncrementButton", {
85 | customErrorMessage: [
86 | "Invalid `label` property.",
87 | "The `label` property must be in shape of " +
88 | "`{ screenReaderLabel: string; } | { labelledBy: string; }`",
89 | ].join("\n"),
90 | });
91 |
92 | return (
93 |
108 | );
109 | };
110 |
111 | const IncrementButton = componentWithForwardedRef(
112 | IncrementButtonBase,
113 | "SpinButton.IncrementButton",
114 | );
115 |
116 | export default IncrementButton;
117 |
--------------------------------------------------------------------------------
/lib/SpinButton/components/index.ts:
--------------------------------------------------------------------------------
1 | export {
2 | default as DecrementButton,
3 | type ClassNameProps as DecrementButtonClassNameProps,
4 | type Props as DecrementButtonProps,
5 | type RenderProps as DecrementButtonRenderProps,
6 | } from "./DecrementButton";
7 | export {
8 | default as IncrementButton,
9 | type ClassNameProps as IncrementButtonClassNameProps,
10 | type Props as IncrementButtonProps,
11 | type RenderProps as IncrementButtonRenderProps,
12 | } from "./IncrementButton";
13 |
--------------------------------------------------------------------------------
/lib/SpinButton/context.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import type { PickAsMandatory } from "../types";
3 | import type { Props } from "./SpinButton";
4 |
5 | type ContextValue = PickAsMandatory & {
6 | isUpperBoundDisabled: boolean;
7 | isLowerBoundDisabled: boolean;
8 | handleIncrease: (step: number) => void;
9 | handleDecrease: (step: number) => void;
10 | };
11 |
12 | const Context = React.createContext(null);
13 |
14 | if (process.env.NODE_ENV !== "production") {
15 | Context.displayName = "SpinButtonContext";
16 | }
17 |
18 | export {
19 | Context as SpinButtonContext,
20 | type ContextValue as SpinButtonContextValue,
21 | };
22 |
--------------------------------------------------------------------------------
/lib/SpinButton/index.ts:
--------------------------------------------------------------------------------
1 | export {
2 | default as Root,
3 | type ClassNameProps as RootClassNameProps,
4 | type Props as RootProps,
5 | type RenderProps as RootRenderProps,
6 | } from "./SpinButton";
7 | export * from "./components";
8 |
--------------------------------------------------------------------------------
/lib/SpinButton/slots.ts:
--------------------------------------------------------------------------------
1 | export const Root = "SpinButton:Root";
2 | export const IncrementButtonRoot = "SpinButton:IncrementButton:Root";
3 | export const DecrementButtonRoot = "SpinButton:DecrementButton:Root";
4 |
--------------------------------------------------------------------------------
/lib/Switch/index.ts:
--------------------------------------------------------------------------------
1 | export {
2 | default,
3 | type ClassNameProps as SwitchClassNameProps,
4 | type Props as SwitchProps,
5 | type RenderProps as SwitchRenderProps,
6 | } from "./Switch";
7 |
--------------------------------------------------------------------------------
/lib/Switch/slots.ts:
--------------------------------------------------------------------------------
1 | export const Root = "Switch:Root";
2 |
--------------------------------------------------------------------------------
/lib/TabGroup/TabGroup.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import type { MergeElementProps } from "../types";
3 | import {
4 | componentWithForwardedRef,
5 | useControlledProp,
6 | useForkedRefs,
7 | useIsMounted,
8 | } from "../utils";
9 | import { TabGroupContext } from "./context";
10 | import { Root as RootSlot } from "./slots";
11 |
12 | type OwnProps = {
13 | /**
14 | * The content of the tab group.
15 | */
16 | children?: React.ReactNode;
17 | /**
18 | * The className applied to the component.
19 | */
20 | className?: string;
21 | /**
22 | * The currently selected tab.
23 | */
24 | activeTab?: string;
25 | /**
26 | * The default selected tab. Use when the component is not controlled.
27 | */
28 | defaultActiveTab?: string;
29 | /**
30 | * The Callback is fired when the active tab state changes.
31 | */
32 | onActiveTabChange?: (tabValue: string) => void;
33 | /**
34 | * Indicates whether the element's orientation is horizontal or vertical.
35 | * This effects the keyboard interactions.
36 | *
37 | * @default "horizontal"
38 | */
39 | orientation?: "horizontal" | "vertical";
40 | /**
41 | * If `automatic`, tabs are automatically activated and their panel is displayed when they receive focus.
42 | * If `manual`, users activate a tab and display its panel by focusing them and pressing `Space` or `Enter`.
43 | *
44 | * @default "manual"
45 | */
46 | keyboardActivationBehavior?: "manual" | "automatic";
47 | };
48 |
49 | export type Props = Omit<
50 | MergeElementProps<"div", OwnProps>,
51 | "defaultChecked" | "defaultValue" | "onChange" | "onChangeCapture"
52 | >;
53 |
54 | const TabGroupBase = (props: Props, ref: React.Ref) => {
55 | const {
56 | children,
57 | className,
58 | onActiveTabChange,
59 | defaultActiveTab,
60 | activeTab: activeTabProp,
61 | keyboardActivationBehavior = "manual",
62 | orientation = "horizontal",
63 | ...otherProps
64 | } = props;
65 |
66 | const isMounted = useIsMounted();
67 |
68 | const rootRef = React.useRef();
69 | const handleRootRef = useForkedRefs(ref, rootRef);
70 |
71 | const [activeTab, setActiveTab] = useControlledProp(
72 | activeTabProp,
73 | defaultActiveTab,
74 | "",
75 | );
76 |
77 | const [forcedTabability, setForcedTabability] = React.useState(
78 | null,
79 | );
80 |
81 | const handleChange = (tabValue: string) => {
82 | if (!isMounted()) return;
83 |
84 | setActiveTab(tabValue);
85 | onActiveTabChange?.(tabValue);
86 | };
87 |
88 | React.useEffect(() => {
89 | if (!rootRef.current) return;
90 |
91 | if (activeTab) {
92 | setForcedTabability(prev => (prev ? null : prev));
93 |
94 | return;
95 | }
96 |
97 | const tabs = Array.from(
98 | rootRef.current.querySelectorAll('[role="tab"]'),
99 | );
100 |
101 | const validTabs = tabs.filter(tab => {
102 | const isDisabled =
103 | tab.hasAttribute("disabled") ||
104 | tab.getAttribute("aria-disabled") === "true";
105 |
106 | return !isDisabled;
107 | });
108 |
109 | setForcedTabability(validTabs?.[0]?.getAttribute("data-entity") ?? null);
110 | }, [activeTab]);
111 |
112 | return (
113 |
120 |
129 | {children}
130 |
131 |
132 | );
133 | };
134 |
135 | const TabGroup = componentWithForwardedRef(TabGroupBase, "TabGroup");
136 |
137 | export default TabGroup;
138 |
--------------------------------------------------------------------------------
/lib/TabGroup/components/List.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { getLabelInfo, logger } from "../../internals";
3 | import type { MergeElementProps } from "../../types";
4 | import { componentWithForwardedRef, useDeterministicId } from "../../utils";
5 | import { TabGroupContext, TabGroupListContext } from "../context";
6 | import { ListRoot as ListRootSlot } from "../slots";
7 |
8 | type OwnProps = {
9 | /**
10 | * The content of the component.
11 | */
12 | children?: React.ReactNode;
13 | /**
14 | * The className applied to the component.
15 | */
16 | className?: string;
17 | /**
18 | * The label of the tablist.
19 | */
20 | label:
21 | | {
22 | /**
23 | * The label to use as `aria-label` property.
24 | */
25 | screenReaderLabel: string;
26 | }
27 | | {
28 | /**
29 | * Identifies the element (or elements) that labels the tablist.
30 | *
31 | * @see {@link https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-labelledby MDN Web Docs} for more information.
32 | */
33 | labelledBy: string;
34 | };
35 | };
36 |
37 | export type Props = Omit<
38 | MergeElementProps<"div", OwnProps>,
39 | "defaultChecked" | "defaultValue"
40 | >;
41 |
42 | const ListBase = (props: Props, ref: React.Ref) => {
43 | const { label, children, id: idProp, className, ...otherProps } = props;
44 |
45 | const ctx = React.useContext(TabGroupContext);
46 |
47 | const id = useDeterministicId(idProp, "styleless-ui__tablist");
48 |
49 | if (!ctx) {
50 | logger(
51 | "You have to use this component as a descendant of .",
52 | {
53 | scope: "TabGroup.List",
54 | type: "error",
55 | },
56 | );
57 |
58 | return null;
59 | }
60 |
61 | const labelInfo = getLabelInfo(label, "TabGroup.List", {
62 | customErrorMessage: [
63 | "Invalid `label` property.",
64 | "The `label` property must be in shape of " +
65 | "`{ screenReaderLabel: string; } | { labelledBy: string; }`",
66 | ].join("\n"),
67 | });
68 |
69 | return (
70 |
81 |
82 | {children}
83 |
84 |
85 | );
86 | };
87 |
88 | const List = componentWithForwardedRef(ListBase, "TabGroup.List");
89 |
90 | export default List;
91 |
--------------------------------------------------------------------------------
/lib/TabGroup/components/Panel.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { logger } from "../../internals";
3 | import type { MergeElementProps } from "../../types";
4 | import {
5 | componentWithForwardedRef,
6 | useDeterministicId,
7 | useForkedRefs,
8 | } from "../../utils";
9 | import { TabGroupContext } from "../context";
10 | import { PanelRoot as PanelRootSlot, Root as RootSlot } from "../slots";
11 |
12 | type OwnProps = {
13 | /**
14 | * The content of the component.
15 | */
16 | children?: React.ReactNode;
17 | /**
18 | * The className applied to the component.
19 | */
20 | className?: string;
21 | /**
22 | * A unique value that associates the panel(content) with a tab.
23 | */
24 | value: string;
25 | /**
26 | * Used to keep mounting when more control is needed.\
27 | * Useful when controlling animation with React animation libraries.
28 | *
29 | * @default false
30 | */
31 | keepMounted?: boolean;
32 | };
33 |
34 | export type Props = Omit<
35 | MergeElementProps<"div", OwnProps>,
36 | "defaultChecked" | "defaultValue"
37 | >;
38 |
39 | const PanelBase = (props: Props, ref: React.Ref) => {
40 | const {
41 | children,
42 | id: idProp,
43 | className,
44 | value,
45 | keepMounted = false,
46 | ...otherProps
47 | } = props;
48 |
49 | const ctx = React.useContext(TabGroupContext);
50 |
51 | const id = useDeterministicId(idProp, "styleless-ui__panel");
52 |
53 | const rootRef = React.useRef(null);
54 | const handleRef = useForkedRefs(ref, rootRef);
55 |
56 | if (!ctx) {
57 | logger(
58 | "You have to use this component as a descendant of .",
59 | {
60 | scope: "TabGroup.Panel",
61 | type: "error",
62 | },
63 | );
64 |
65 | return null;
66 | }
67 |
68 | const active = ctx.activeTab === value;
69 |
70 | if (!keepMounted && !active) return null;
71 |
72 | const dataAttrs = {
73 | "data-slot": PanelRootSlot,
74 | "data-active": active ? "" : undefined,
75 | "data-entity": value,
76 | };
77 |
78 | const refCallback = (node: HTMLDivElement | null) => {
79 | handleRef(node);
80 |
81 | if (!node) return;
82 |
83 | const root = node.closest(`[data-slot="${RootSlot}"]`);
84 |
85 | if (!root) return;
86 |
87 | const tabs = Array.from(root.querySelectorAll("[role='tab']"));
88 |
89 | const associatedTab = tabs.find(
90 | tab => tab.getAttribute("data-entity") === value,
91 | );
92 |
93 | if (!associatedTab) {
94 | logger(
95 | `Couldn't find an associated with \`value={${value}}\`.`,
96 | { scope: "TabGroup.Panel", type: "error" },
97 | );
98 |
99 | return;
100 | }
101 |
102 | node.setAttribute("aria-labelledby", associatedTab.id);
103 | };
104 |
105 | return (
106 |
114 | {children}
115 |
116 | );
117 | };
118 |
119 | const Panel = componentWithForwardedRef(PanelBase, "TabGroup.Panel");
120 |
121 | export default Panel;
122 |
--------------------------------------------------------------------------------
/lib/TabGroup/components/index.ts:
--------------------------------------------------------------------------------
1 | export { default as List, type Props as ListProps } from "./List";
2 | export { default as Panel, type Props as PanelProps } from "./Panel";
3 | export {
4 | default as Tab,
5 | type ClassNameProps as TabClassNameProps,
6 | type Props as TabProps,
7 | type RenderProps as TabRenderProps,
8 | } from "./Tab";
9 |
--------------------------------------------------------------------------------
/lib/TabGroup/context.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | type ContextValue = {
4 | activeTab: string;
5 | orientation: "horizontal" | "vertical";
6 | forcedTabability: string | null;
7 | keyboardActivationBehavior: "manual" | "automatic";
8 | onChange: (tabValue: string) => void;
9 | };
10 |
11 | const Context = React.createContext(null);
12 |
13 | const ListContext = React.createContext(false);
14 |
15 | if (process.env.NODE_ENV !== "production") {
16 | Context.displayName = "TabGroup.Context";
17 | ListContext.displayName = "TabGroup.List.Context";
18 | }
19 |
20 | export {
21 | Context as TabGroupContext,
22 | ListContext as TabGroupListContext,
23 | type ContextValue as TabGroupContextValue,
24 | };
25 |
--------------------------------------------------------------------------------
/lib/TabGroup/index.ts:
--------------------------------------------------------------------------------
1 | export { default as Root, type Props as RootProps } from "./TabGroup";
2 | export * from "./components";
3 |
--------------------------------------------------------------------------------
/lib/TabGroup/slots.ts:
--------------------------------------------------------------------------------
1 | export const Root = "TabGroup:Root";
2 | export const ListRoot = "TabGroup:List:Root";
3 | export const ListLabel = "TabGroup:List:Label";
4 | export const PanelRoot = "TabGroup:Panel:Root";
5 | export const TabRoot = "TabGroup:Tab:Root";
6 |
--------------------------------------------------------------------------------
/lib/Toast/Toast.test.tsx:
--------------------------------------------------------------------------------
1 | import cls from "classnames";
2 | import * as Toast from ".";
3 | import {
4 | itShouldMount,
5 | itSupportsDataSetProps,
6 | itSupportsRef,
7 | itSupportsStyle,
8 | render,
9 | screen,
10 | wait,
11 | } from "../../tests/utils";
12 | import * as Slots from "./slots";
13 |
14 | describe("Toast", () => {
15 | afterEach(jest.clearAllMocks);
16 |
17 | itShouldMount(Toast.Root, { open: true, role: "status" });
18 | itSupportsRef(Toast.Root, { open: true, role: "status" }, HTMLDivElement);
19 | itSupportsStyle(
20 | Toast.Root,
21 | { open: true, role: "status" },
22 | `[data-slot='${Slots.Root}']`,
23 | { withPortal: true },
24 | );
25 | itSupportsDataSetProps(
26 | Toast.Root,
27 | { open: true, role: "status" },
28 | `[data-slot='${Slots.Root}']`,
29 | { withPortal: true },
30 | );
31 |
32 | it("should have the required classNames", () => {
33 | render(
34 | cls("root", { "root--open": open })}
39 | >
40 |
44 | Lorem ipsum, dolor sit amet consectetur adipisicing elit. Nostrum
45 | dolorum quod voluptas! Necessitatibus, velit perspiciatis odit
46 | laudantium impedit quos, non vitae id magnam sed dolore, aliquid
47 | aliquam dolor corporis assumenda.
48 |
52 | Action
53 |
54 |
55 | ,
56 | );
57 |
58 | const root = screen.getByTestId("toast-root");
59 | const content = screen.getByTestId("toast-content");
60 | const action = screen.getByTestId("toast-action");
61 |
62 | expect(root).toHaveClass("root", "root--open");
63 | expect(content).toHaveClass("content");
64 | expect(action).toHaveClass("action");
65 | });
66 |
67 | it("set `duration` and calls `onDurationEnd` callback", async () => {
68 | const handleDurationEnd = jest.fn();
69 |
70 | const duration = 1000;
71 |
72 | const props: Toast.RootProps = {
73 | duration,
74 | role: "status",
75 | open: true,
76 | onDurationEnd: handleDurationEnd,
77 | };
78 |
79 | render();
80 | expect(handleDurationEnd).not.toHaveBeenCalled();
81 |
82 | await wait(duration);
83 |
84 | expect(handleDurationEnd.mock.calls.length).toBe(1);
85 | expect(handleDurationEnd.mock.calls[0]?.length).toBe(0);
86 | });
87 | });
88 |
--------------------------------------------------------------------------------
/lib/Toast/components/Action.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import Button from "../../Button";
3 | import type { PolymorphicComponent, PolymorphicProps } from "../../types";
4 | import { componentWithForwardedRef } from "../../utils";
5 | import { ActionRoot as ActionRootSlot } from "../slots";
6 |
7 | export type Props> =
8 | PolymorphicProps;
9 |
10 | const ActionBase = <
11 | E extends React.ElementType = typeof Button<"button">,
12 | R extends HTMLElement = HTMLButtonElement,
13 | >(
14 | props: Props,
15 | ref: React.Ref,
16 | ) => {
17 | type TProps = Props>;
18 |
19 | const { as: RootNode = Button<"button">, ...otherProps } = props as TProps;
20 |
21 | return (
22 |
28 | );
29 | };
30 |
31 | const Action: PolymorphicComponent<"button"> = componentWithForwardedRef(
32 | ActionBase,
33 | "Toast.Action",
34 | );
35 |
36 | export default Action;
37 |
--------------------------------------------------------------------------------
/lib/Toast/components/Content.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { FocusRedirect } from "../../internals";
3 | import type { MergeElementProps } from "../../types";
4 | import { componentWithForwardedRef, useDeterministicId } from "../../utils";
5 | import { ToastContext } from "../context";
6 | import { ContentRoot as ContentRootSlot } from "../slots";
7 |
8 | type OwnProps = {
9 | /**
10 | * The content of the component.
11 | */
12 | children?: React.ReactNode;
13 | /**
14 | * The className applied to the component.
15 | */
16 | className?: string;
17 | };
18 |
19 | export type Props = Omit<
20 | MergeElementProps<"div", OwnProps>,
21 | "defaultChecked" | "defaultValue"
22 | >;
23 |
24 | const ContentBase = (props: Props, ref: React.Ref) => {
25 | const { className, children, id: idProp, ...otherProps } = props;
26 |
27 | const toastCtx = React.useContext(ToastContext);
28 |
29 | const id = useDeterministicId(idProp, "styleless-ui__toast-content");
30 |
31 | return (
32 |
33 |
49 | {children}
50 |
51 |
52 | );
53 | };
54 |
55 | const Content = componentWithForwardedRef(ContentBase, "Toast.Content");
56 |
57 | export default Content;
58 |
--------------------------------------------------------------------------------
/lib/Toast/components/index.ts:
--------------------------------------------------------------------------------
1 | export { default as Action, type Props as ActionProps } from "./Action";
2 | export { default as Content, type Props as ContentProps } from "./Content";
3 |
--------------------------------------------------------------------------------
/lib/Toast/context.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | type ContextValue = {
4 | role: "alert" | "status";
5 | open: boolean;
6 | };
7 |
8 | const Context = React.createContext(null);
9 |
10 | if (process.env.NODE_ENV !== "production") {
11 | Context.displayName = "ToastContext";
12 | }
13 |
14 | export { Context as ToastContext, type ContextValue as ToastContextValue };
15 |
--------------------------------------------------------------------------------
/lib/Toast/index.ts:
--------------------------------------------------------------------------------
1 | export {
2 | default as Root,
3 | type ClassNameProps as RootClassNameProps,
4 | type Props as RootProps,
5 | } from "./Toast";
6 | export * from "./components";
7 |
--------------------------------------------------------------------------------
/lib/Toast/slots.ts:
--------------------------------------------------------------------------------
1 | export const Root = "Toast:Root";
2 | export const ContentRoot = "Toast:Content:Root";
3 | export const ActionRoot = "Toast:Action:Root";
4 |
--------------------------------------------------------------------------------
/lib/Toggle/index.ts:
--------------------------------------------------------------------------------
1 | export {
2 | default,
3 | type ClassNameProps as ToggleClassNameProps,
4 | type Props as ToggleProps,
5 | type RenderProps as ToggleRenderProps,
6 | } from "./Toggle";
7 |
--------------------------------------------------------------------------------
/lib/Toggle/slots.ts:
--------------------------------------------------------------------------------
1 | export const Root = "Toggle:Root";
2 |
--------------------------------------------------------------------------------
/lib/ToggleGroup/context.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { type Props } from "./ToggleGroup";
3 |
4 | type ContextValue = {
5 | multiple: boolean;
6 | forcedTabability: string | null;
7 | keyboardActivationBehavior: Exclude<
8 | Props["keyboardActivationBehavior"],
9 | undefined
10 | >;
11 | value: Exclude;
12 | onChange: (newActiveState: boolean, toggleValue: string) => void;
13 | };
14 |
15 | const Context = React.createContext(null);
16 |
17 | if (process.env.NODE_ENV !== "production") {
18 | Context.displayName = "ToggleGroupContext";
19 | }
20 |
21 | export {
22 | Context as ToggleGroupContext,
23 | type ContextValue as ToggleGroupContextValue,
24 | };
25 |
--------------------------------------------------------------------------------
/lib/ToggleGroup/index.ts:
--------------------------------------------------------------------------------
1 | export { default, type Props as ToggleGroupProps } from "./ToggleGroup";
2 |
--------------------------------------------------------------------------------
/lib/ToggleGroup/slots.ts:
--------------------------------------------------------------------------------
1 | export const Root = "ToggleGroup:Root";
2 |
--------------------------------------------------------------------------------
/lib/Tooltip/index.ts:
--------------------------------------------------------------------------------
1 | export {
2 | default,
3 | type ClassNameProps as TooltipClassNameProps,
4 | type Props as TooltipProps,
5 | type RenderProps as TooltipRenderProps,
6 | } from "./Tooltip";
7 |
--------------------------------------------------------------------------------
/lib/TreeView/components/Item/context.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | type ContextValue = {
4 | id: string;
5 | isExpanded: boolean;
6 | value: string;
7 | };
8 |
9 | const Context = React.createContext(null);
10 |
11 | if (process.env.NODE_ENV !== "production") {
12 | Context.displayName = "TreeView.Item.Context";
13 | }
14 |
15 | export {
16 | Context as TreeViewItemContext,
17 | type ContextValue as TreeViewItemContextValue,
18 | };
19 |
--------------------------------------------------------------------------------
/lib/TreeView/components/Item/index.ts:
--------------------------------------------------------------------------------
1 | export {
2 | default,
3 | type ClassNameProps,
4 | type Props,
5 | type RenderProps,
6 | } from "./Item";
7 |
--------------------------------------------------------------------------------
/lib/TreeView/components/SubTree.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { logger, resolvePropWithRenderContext } from "../../internals";
3 | import type { MergeElementProps, PropWithRenderContext } from "../../types";
4 | import { componentWithForwardedRef, useDeterministicId } from "../../utils";
5 | import { LevelContext, SizeContext } from "../contexts";
6 | import { SubTreeRoot as SubTreeRootSlot } from "../slots";
7 | import { getValidChildren } from "../utils";
8 | import { TreeViewItemContext } from "./Item/context";
9 |
10 | export type RenderProps = {
11 | /**
12 | * The `open` state of the component.
13 | */
14 | open: boolean;
15 | };
16 |
17 | export type ClassNameProps = RenderProps;
18 |
19 | type OwnProps = {
20 | /**
21 | * The content of the component.
22 | */
23 | children?: PropWithRenderContext;
24 | /**
25 | * The className applied to the component.
26 | */
27 | className?: PropWithRenderContext;
28 | /**
29 | * Used to keep mounting when more control is needed.\
30 | * Useful when controlling animation with React animation libraries.
31 | *
32 | * @default false
33 | */
34 | keepMounted?: boolean;
35 | };
36 |
37 | export type Props = Omit<
38 | MergeElementProps<"div", OwnProps>,
39 | "defaultValue" | "defaultChecked"
40 | >;
41 |
42 | const SubTreeBase = (props: Props, ref: React.Ref) => {
43 | const {
44 | id: idProp,
45 | className: classNameProp,
46 | children: childrenProp,
47 | keepMounted = false,
48 | ...otherProps
49 | } = props;
50 |
51 | const id = useDeterministicId(idProp, "styleless-ui__treeview-subtree");
52 |
53 | const currentLevel = React.useContext(LevelContext) ?? 1;
54 | const itemCtx = React.useContext(TreeViewItemContext);
55 |
56 | if (!itemCtx) {
57 | logger(
58 | "You have to use this component as a descendant of .",
59 | {
60 | scope: "TreeView.SubTree",
61 | type: "error",
62 | },
63 | );
64 |
65 | return null;
66 | }
67 |
68 | const openState = itemCtx.isExpanded;
69 |
70 | const renderProps: RenderProps = { open: openState };
71 | const classNameProps: ClassNameProps = renderProps;
72 |
73 | const children = resolvePropWithRenderContext(childrenProp, renderProps);
74 | const className = resolvePropWithRenderContext(classNameProp, classNameProps);
75 |
76 | const { validChildren, sizeOfSet } = getValidChildren(
77 | children,
78 | "TreeView.SubTree",
79 | );
80 |
81 | if (!keepMounted && !openState) return null;
82 |
83 | return (
84 |
98 |
99 |
100 | {validChildren}
101 |
102 |
103 |
104 | );
105 | };
106 |
107 | const SubTree = componentWithForwardedRef(SubTreeBase, "TreeView.SubTree");
108 |
109 | export default SubTree;
110 |
--------------------------------------------------------------------------------
/lib/TreeView/components/index.ts:
--------------------------------------------------------------------------------
1 | export {
2 | default as Item,
3 | type ClassNameProps as ItemClassNameProps,
4 | type Props as ItemProps,
5 | type RenderProps as ItemRenderProps,
6 | } from "./Item";
7 | export { default as SubTree, type Props as SubTreeProps } from "./SubTree";
8 |
--------------------------------------------------------------------------------
/lib/TreeView/contexts.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | export type LevelContextValue = number;
4 | export type SizeContextValue = number;
5 |
6 | export type TreeViewContextValue = {
7 | activeElement: HTMLElement | null;
8 | isSelectable: boolean;
9 | isMultiSelect: boolean;
10 | setActiveElement: React.Dispatch>;
11 | isDescendantSelected: (descendant: string) => boolean;
12 | isDescendantExpanded: (descendant: string) => boolean;
13 | handleDescendantSelect: (descendant: string) => void;
14 | handleDescendantCollapse: (descendant: string) => void;
15 | handleDescendantExpand: (descendant: string) => void;
16 | handleDescendantExpandToggle: (descendant: string) => void;
17 | };
18 |
19 | export const LevelContext = React.createContext(null);
20 |
21 | export const SizeContext = React.createContext(null);
22 |
23 | export const TreeViewContext = React.createContext(
24 | null,
25 | );
26 |
27 | if (process.env.NODE_ENV !== "production") {
28 | LevelContext.displayName = "TreeView.LevelContext";
29 | SizeContext.displayName = "TreeView.SizeContext";
30 | TreeViewContext.displayName = "TreeView.Context";
31 | }
32 |
--------------------------------------------------------------------------------
/lib/TreeView/index.ts:
--------------------------------------------------------------------------------
1 | export {
2 | default as Root,
3 | type ClassNameProps as RootClassNameProps,
4 | type Props as RootProps,
5 | type RenderProps as RootRenderProps,
6 | } from "./TreeView";
7 | export * from "./components";
8 |
--------------------------------------------------------------------------------
/lib/TreeView/slots.ts:
--------------------------------------------------------------------------------
1 | export const Root = "TreeView:Root";
2 | export const ItemRoot = "TreeView:Item:Root";
3 | export const ItemTrigger = "TreeView:Item:Trigger";
4 | export const SubTreeRoot = "TreeView:SubTree:Root";
5 | export const SubTreeLabel = "TreeView:SubTree:Label";
6 |
--------------------------------------------------------------------------------
/lib/TreeView/utils.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { isFragment } from "react-is";
3 | import { logger } from "../internals";
4 | import { Item, type ItemProps } from "./components";
5 |
6 | export const getListItems = (id: string) => {
7 | const root = document.getElementById(id);
8 |
9 | if (!root) return [];
10 |
11 | return Array.from(root.querySelectorAll("[role='treeitem']"));
12 | };
13 |
14 | export const getValidChildren = (children: React.ReactNode, scope: string) => {
15 | let sizeOfSet = 0;
16 | let position = 1;
17 |
18 | const validChildren = React.Children.map(children, child => {
19 | if (!React.isValidElement(child) || isFragment(child)) {
20 | logger(
21 | `The <${scope}> component doesn't accept \`Fragment\` or any invalid element as children.`,
22 | { scope, type: "error" },
23 | );
24 |
25 | return null;
26 | }
27 |
28 | if ((child as React.ReactElement).type !== Item) {
29 | logger(
30 | `The <${scope}> component only accepts as a children`,
31 | { scope, type: "error" },
32 | );
33 |
34 | return null;
35 | }
36 |
37 | sizeOfSet++;
38 |
39 | return React.cloneElement(child as React.ReactElement, {
40 | "aria-posinset": position++,
41 | });
42 | });
43 |
44 | return { validChildren, sizeOfSet };
45 | };
46 |
47 | export const getAvailableItem = (
48 | items: (HTMLElement | null)[],
49 | idx: number,
50 | forward: boolean,
51 | prevIdxs: number[] = [],
52 | ): { item: HTMLElement | null; index: number } => {
53 | const item = items[idx];
54 |
55 | if (!item) return { item: null, index: idx };
56 | if (prevIdxs.includes(idx)) return { item: null, index: idx };
57 |
58 | if (
59 | item.getAttribute("aria-disabled") === "true" ||
60 | item.hasAttribute("data-hidden") ||
61 | item.getAttribute("aria-hidden") === "true"
62 | ) {
63 | const newIdx = (forward ? idx + 1 : idx - 1 + items.length) % items.length;
64 |
65 | return getAvailableItem(items, newIdx, forward, [...prevIdxs, idx]);
66 | }
67 |
68 | return { item, index: idx };
69 | };
70 |
71 | /* eslint-disable @typescript-eslint/no-non-null-assertion */
72 | export const getCurrentFocusedElement = (
73 | items: HTMLElement[],
74 | activeElement: HTMLElement | null,
75 | ) => {
76 | if (items.length === 0) return null;
77 |
78 | if (activeElement) {
79 | const itemIdx = items.findIndex(item => item === activeElement);
80 |
81 | if (itemIdx !== -1) {
82 | return {
83 | item: items[itemIdx]!,
84 | index: itemIdx,
85 | };
86 | }
87 | }
88 |
89 | const selectedItemIdx = items.findIndex(item =>
90 | item.hasAttribute("data-selected"),
91 | );
92 |
93 | const idx = selectedItemIdx === -1 ? 0 : selectedItemIdx;
94 |
95 | const availableItem = getAvailableItem(items, idx, true);
96 |
97 | return { item: availableItem.item, index: availableItem.index || -1 };
98 | };
99 | /* eslint-enable @typescript-eslint/no-non-null-assertion */
100 |
--------------------------------------------------------------------------------
/lib/internals/FocusRedirect/index.ts:
--------------------------------------------------------------------------------
1 | export { default, type Props as FocusRedirectProps } from "./FocusRedirect";
2 |
--------------------------------------------------------------------------------
/lib/internals/FocusTrap/FocusTrap.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { SystemError, visuallyHiddenCSSProperties } from "..";
3 | import {
4 | contains,
5 | isFocusable,
6 | useEventListener,
7 | useForkedRefs,
8 | } from "../../utils";
9 |
10 | export type Props = {
11 | /**
12 | * The content of the component.
13 | */
14 | children: JSX.Element;
15 | /**
16 | * If `true`, the focus will be trapped.
17 | *
18 | * @default false
19 | */
20 | enabled?: boolean;
21 | /**
22 | * Callback is called when focus is about to exit the trap.
23 | */
24 | onExit?: (event: FocusEvent) => void;
25 | };
26 |
27 | const FocusTrap = (props: Props) => {
28 | const { children, enabled = false, onExit } = props;
29 |
30 | const child = (() => {
31 | try {
32 | if (!React.isValidElement(children)) throw 0;
33 | return React.Children.only(
34 | children,
35 | ) as React.FunctionComponentElement;
36 | } catch {
37 | throw new SystemError(
38 | "The `children` prop has to be a single valid element.",
39 | "FocusTrap",
40 | );
41 | }
42 | })();
43 |
44 | const ignoreFocusChanges = React.useRef(false);
45 | const lastFocus = React.useRef(null);
46 |
47 | const rootRef = React.useRef();
48 | const handleRootRef = useForkedRefs(rootRef, child.ref ?? null);
49 |
50 | const attemptFocus = (element?: Element) => {
51 | const node = element ?? rootRef.current;
52 |
53 | if (!node) return false;
54 | if (!isFocusable(node)) return false;
55 |
56 | ignoreFocusChanges.current = true;
57 |
58 | try {
59 | (node as HTMLElement).focus();
60 | // eslint-disable-next-line no-empty
61 | } catch {}
62 |
63 | ignoreFocusChanges.current = false;
64 |
65 | return document.activeElement === node;
66 | };
67 |
68 | const focusFirstDescendant = (element?: Element): boolean => {
69 | const node = element ?? rootRef.current;
70 |
71 | return node
72 | ? Array.from(node.children).some(
73 | child => attemptFocus(child) || focusFirstDescendant(child),
74 | )
75 | : false;
76 | };
77 |
78 | const focusLastDescendant = (element?: Element): boolean => {
79 | const node = element ?? rootRef.current;
80 |
81 | return node
82 | ? Array.from(node.children)
83 | .reverse()
84 | .some(child => attemptFocus(child) || focusLastDescendant(child))
85 | : false;
86 | };
87 |
88 | // eslint-disable-next-line react-hooks/exhaustive-deps
89 | React.useEffect(() => void (enabled && focusFirstDescendant()), [enabled]);
90 |
91 | const childProps = { ref: handleRootRef };
92 |
93 | if (typeof document !== "undefined") {
94 | // eslint-disable-next-line react-hooks/rules-of-hooks
95 | useEventListener(
96 | {
97 | target: document,
98 | eventType: "focus",
99 | handler: event => {
100 | if (ignoreFocusChanges.current) return;
101 | if (!rootRef.current) return;
102 |
103 | if (contains(rootRef.current, event.target as Element)) {
104 | lastFocus.current = event.target as Element;
105 | } else {
106 | focusFirstDescendant();
107 |
108 | if (document.activeElement === lastFocus.current) {
109 | focusLastDescendant();
110 | }
111 |
112 | if (
113 | document.activeElement &&
114 | !contains(rootRef.current, document.activeElement)
115 | ) {
116 | (document.activeElement as HTMLElement)?.blur();
117 |
118 | onExit?.(event);
119 | }
120 |
121 | lastFocus.current = document.activeElement;
122 | }
123 | },
124 | options: { capture: true },
125 | },
126 | enabled,
127 | );
128 | }
129 |
130 | return (
131 | <>
132 |
137 | {React.cloneElement(child, childProps)}
138 |
143 | >
144 | );
145 | };
146 |
147 | export default FocusTrap;
148 |
--------------------------------------------------------------------------------
/lib/internals/FocusTrap/index.ts:
--------------------------------------------------------------------------------
1 | export { default, type Props as FocusTrapProps } from "./FocusTrap";
2 |
--------------------------------------------------------------------------------
/lib/internals/SystemError.ts:
--------------------------------------------------------------------------------
1 | import prefixMessage from "./prefix-message";
2 |
3 | class SystemError extends Error {
4 | constructor(err: Error | string, scope?: string) {
5 | const message = typeof err === "string" ? err : err.message;
6 | const prefixedMessage = prefixMessage(message, scope);
7 |
8 | super(prefixedMessage);
9 |
10 | this.name = "SystemError";
11 | }
12 | }
13 |
14 | export default SystemError;
15 |
--------------------------------------------------------------------------------
/lib/internals/get-label-info.ts:
--------------------------------------------------------------------------------
1 | import SystemError from "./SystemError";
2 |
3 | type SrOnlyLabel = {
4 | /**
5 | * The label to use as `aria-label` property.
6 | */
7 | screenReaderLabel: string;
8 | };
9 |
10 | type ExternalLabel = {
11 | /**
12 | * Identifies the element (or elements) that labels the breadcrumb.
13 | *
14 | * @see {@link https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-labelledby MDN Web Docs} for more information.
15 | */
16 | labelledBy: string;
17 | };
18 |
19 | type Label = string | SrOnlyLabel | ExternalLabel;
20 |
21 | type Options = {
22 | customErrorMessage: string;
23 | };
24 |
25 | export type LabelInfo = {
26 | visibleLabel?: string;
27 | srOnlyLabel?: string;
28 | labelledBy?: string;
29 | };
30 |
31 | const getLabelInfo = (
32 | labelInput: Label,
33 | scope: string,
34 | options?: Partial,
35 | ): LabelInfo => {
36 | const { customErrorMessage } = options ?? {};
37 |
38 | const props: {
39 | visibleLabel?: string;
40 | srOnlyLabel?: string;
41 | labelledBy?: string;
42 | } = {};
43 |
44 | if (typeof labelInput === "string") {
45 | props.visibleLabel = labelInput;
46 | } else {
47 | if ("screenReaderLabel" in labelInput) {
48 | props.srOnlyLabel = labelInput.screenReaderLabel;
49 | } else if ("labelledBy" in labelInput) {
50 | props.labelledBy = labelInput.labelledBy;
51 | } else {
52 | const message =
53 | customErrorMessage ??
54 | [
55 | "Invalid `label` property.",
56 | "The `label` property must be either a `string` or in shape of " +
57 | "`{ screenReaderLabel: string; } | { labelledBy: string; }`",
58 | ].join("\n");
59 |
60 | throw new SystemError(message, scope);
61 | }
62 | }
63 |
64 | return props;
65 | };
66 |
67 | export default getLabelInfo;
68 |
--------------------------------------------------------------------------------
/lib/internals/index.ts:
--------------------------------------------------------------------------------
1 | export type * from "./FocusRedirect";
2 | export { default as FocusRedirect } from "./FocusRedirect";
3 | export type * from "./FocusTrap";
4 | export { default as FocusTrap } from "./FocusTrap";
5 | export { default as SystemError } from "./SystemError";
6 | export * from "./get-label-info";
7 | export { default as getLabelInfo } from "./get-label-info";
8 | export * from "./keys";
9 | export { default as logger } from "./logger";
10 | export { default as prefixMessage } from "./prefix-message";
11 | export { default as resolvePropWithRenderContext } from "./resolve-prop-with-render-context";
12 | export * from "./styles";
13 | export { default as useJumpToChar } from "./use-jump-to-char";
14 |
--------------------------------------------------------------------------------
/lib/internals/keys.ts:
--------------------------------------------------------------------------------
1 | export const SystemKeys = {
2 | BACKSPACE: "Backspace",
3 | TAB: "Tab",
4 | ENTER: "Enter",
5 | SHIFT: "Shift",
6 | CONTROL: "Control",
7 | ALT: "Alt",
8 | META: "Meta",
9 | ESCAPE: "Escape",
10 | SPACE: " ",
11 | LEFT: "ArrowLeft",
12 | RIGHT: "ArrowRight",
13 | UP: "ArrowUp",
14 | DOWN: "ArrowDown",
15 | DELETE: "Delete",
16 | HOME: "Home",
17 | END: "End",
18 | PAGE_UP: "PageUp",
19 | PAGE_DOWN: "PageDown",
20 | };
21 |
--------------------------------------------------------------------------------
/lib/internals/logger.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 | import type { AnyFunction } from "../types";
3 | import prefixMessage from "./prefix-message";
4 |
5 | type Type = "error" | "warn" | "default";
6 |
7 | type Options = {
8 | scope: string;
9 | type: Type;
10 | };
11 |
12 | const logger = (message: string, options?: Partial) => {
13 | const { scope, type = "default" } = options ?? {};
14 |
15 | const prefixedMessage = prefixMessage(message, scope);
16 |
17 | const mapTypeToLoggerFn = {
18 | error: console.error,
19 | warn: console.warn,
20 | default: console.log,
21 | } satisfies Record;
22 |
23 | const loggerFn = mapTypeToLoggerFn[type];
24 |
25 | loggerFn(prefixedMessage);
26 | };
27 |
28 | export default logger;
29 |
--------------------------------------------------------------------------------
/lib/internals/prefix-message.ts:
--------------------------------------------------------------------------------
1 | const prefixMessage = (message: string, scope?: string) => {
2 | let prefix = "[StylelessUI]";
3 |
4 | if (scope) prefix = prefix.concat(`[${scope}]`);
5 |
6 | return `${prefix}: ${message}`;
7 | };
8 |
9 | export default prefixMessage;
10 |
--------------------------------------------------------------------------------
/lib/internals/resolve-prop-with-render-context.ts:
--------------------------------------------------------------------------------
1 | const resolvePropWithRenderContext = (
2 | prop: TProp | ((renderContext: TRenderContext) => TProp),
3 | renderContext: TRenderContext,
4 | ) => {
5 | if (typeof prop === "function") {
6 | return (prop as (renderContext: TRenderContext) => TProp)(renderContext);
7 | }
8 |
9 | return prop;
10 | };
11 |
12 | export default resolvePropWithRenderContext;
13 |
--------------------------------------------------------------------------------
/lib/internals/styles.ts:
--------------------------------------------------------------------------------
1 | import { type CSSProperties } from "react";
2 |
3 | export const visuallyHiddenCSSProperties = {
4 | position: "absolute",
5 | width: 1,
6 | height: 1,
7 | padding: 0,
8 | margin: -1,
9 | border: 0,
10 | overflow: "hidden",
11 | clip: "rect(0, 0, 0, 0)",
12 | whiteSpace: "nowrap",
13 | } as CSSProperties;
14 |
15 | export const disableUserSelectCSSProperties = {
16 | WebkitUserSelect: "none",
17 | MozUserSelect: "none",
18 | MsUserSelect: "none",
19 | KhtmlUserSelect: "none",
20 | userSelect: "none",
21 | WebkitTouchCallout: "none",
22 | MsTouchAction: "pan-y",
23 | touchAction: "pan-y",
24 | WebkitTapHighlightColor: "transparent",
25 | } as CSSProperties;
26 |
--------------------------------------------------------------------------------
/lib/internals/use-jump-to-char.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { isPrintableKey, useEventCallback } from "../utils";
3 |
4 | type CharRecords = {
5 | index: number;
6 | element: HTMLElement;
7 | };
8 |
9 | type Config = {
10 | activeDescendantElement: HTMLElement | null;
11 | getListItems: () => HTMLElement[];
12 | onActiveDescendantElementChange: (element: HTMLElement | null) => void;
13 | };
14 |
15 | const useJumpToChar = (config: Config) => {
16 | const {
17 | activeDescendantElement,
18 | onActiveDescendantElementChange,
19 | getListItems,
20 | } = config;
21 |
22 | const charCacheTimeoutRef = React.useRef(-1);
23 | const cachedChar = React.useRef("");
24 | const cachedCharRecords = React.useRef([]);
25 |
26 | const cleaup = () => {
27 | window.clearTimeout(charCacheTimeoutRef.current);
28 | charCacheTimeoutRef.current = window.setTimeout(() => {
29 | cachedChar.current = "";
30 | cachedCharRecords.current = [];
31 | }, 1500);
32 | };
33 |
34 | const jumpToChar = useEventCallback>(event => {
35 | if (!isPrintableKey(event.key)) return;
36 |
37 | const queryChar = event.key.toLowerCase();
38 |
39 | const shouldUseCachedRecords = queryChar === cachedChar.current;
40 |
41 | cachedChar.current += shouldUseCachedRecords ? "" : queryChar;
42 |
43 | if (cachedChar.current.length > 1) {
44 | cleaup();
45 |
46 | return;
47 | }
48 |
49 | let records: CharRecords[] = cachedCharRecords.current;
50 | const items = getListItems();
51 |
52 | if (!shouldUseCachedRecords) {
53 | records = items.reduce((result, item, idx) => {
54 | if (
55 | item.getAttribute("aria-disabled") === "true" ||
56 | item.hasAttribute("data-hidden") ||
57 | item.getAttribute("aria-hidden") === "true"
58 | ) {
59 | return result;
60 | }
61 |
62 | const text = item.textContent;
63 | const queryMatched =
64 | text?.toLowerCase().trim()[0] === queryChar.toLowerCase();
65 |
66 | if (queryMatched) result.push({ index: idx, element: item });
67 |
68 | return result;
69 | }, [] as CharRecords[]);
70 | }
71 |
72 | if (records.length) {
73 | const idx = records.findIndex(
74 | record => record.element === activeDescendantElement,
75 | );
76 |
77 | let nextIdx: number | undefined = undefined;
78 |
79 | if (idx >= 0) {
80 | nextIdx = records[(idx + 1) % records.length]?.index;
81 | } else nextIdx = records[0]?.index;
82 |
83 | const nextActive =
84 | typeof nextIdx === "undefined" ? null : items[nextIdx] ?? null;
85 |
86 | onActiveDescendantElementChange(nextActive);
87 | }
88 |
89 | cachedCharRecords.current = records;
90 |
91 | cleaup();
92 | });
93 |
94 | return jumpToChar;
95 | };
96 |
97 | export default useJumpToChar;
98 |
--------------------------------------------------------------------------------
/lib/utils/component-with-forwarded-ref.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any */
2 | import * as React from "react";
3 |
4 | const componentWithForwardedRef = <
5 | TComponent extends React.ForwardRefRenderFunction,
6 | >(
7 | component: TComponent,
8 | name: string,
9 | ): React.FC[0]> => {
10 | const forwarded: React.FC[0]> =
11 | React.forwardRef(component);
12 |
13 | forwarded.displayName = name;
14 |
15 | return forwarded;
16 | };
17 |
18 | export default componentWithForwardedRef;
19 |
--------------------------------------------------------------------------------
/lib/utils/create-custom-event.ts:
--------------------------------------------------------------------------------
1 | const createCustomEvent = (
2 | scope: string,
3 | eventName: string,
4 | eventInit: EventInit,
5 | ) => {
6 | const type = `custom.${scope.toLowerCase()}.${eventName.toLowerCase()}`;
7 | const event = new CustomEvent(type, eventInit);
8 |
9 | return event;
10 | };
11 |
12 | export default createCustomEvent;
13 |
--------------------------------------------------------------------------------
/lib/utils/create-virtual-element.ts:
--------------------------------------------------------------------------------
1 | import type { VirtualElement } from "../types";
2 |
3 | const createVirtualElement = (
4 | width: number,
5 | height: number,
6 | x: number,
7 | y: number,
8 | ): VirtualElement => ({
9 | getBoundingClientRect: () => ({
10 | width,
11 | height,
12 | x,
13 | y,
14 | left: x,
15 | top: y,
16 | right: width + x,
17 | bottom: height + y,
18 | }),
19 | });
20 |
21 | export default createVirtualElement;
22 |
--------------------------------------------------------------------------------
/lib/utils/dispatch-discrete-custom-event.ts:
--------------------------------------------------------------------------------
1 | import { flushSync } from "react-dom";
2 |
3 | /**
4 | * Flush custom event dispatch
5 | * https://github.com/radix-ui/primitives/pull/1378
6 | *
7 | * React batches *all* event handlers since version 18, this introduces certain considerations when using custom event types.
8 | *
9 | * Internally, React prioritises events in the following order:
10 | * - discrete
11 | * - continuous
12 | * - default
13 | *
14 | * https://github.com/facebook/react/blob/a8a4742f1c54493df00da648a3f9d26e3db9c8b5/packages/react-dom/src/events/ReactDOMEventListener.js#L294-L350
15 | *
16 | * `discrete` is an important distinction as updates within these events are applied immediately.
17 | * React however, is not able to infer the priority of custom event types due to how they are detected internally.
18 | * Because of this, it's possible for updates from custom events to be unexpectedly batched when
19 | * dispatched by another `discrete` event.
20 | *
21 | * In order to ensure that updates from custom events are applied predictably, we need to manually flush the batch.
22 | * This utility should be used when dispatching a custom event from within another `discrete` event, this utility
23 | * is not nessesary when dispatching known event types, or if dispatching a custom type inside a non-discrete event.
24 | * For example:
25 | *
26 | * dispatching a known click 👎
27 | * target.dispatchEvent(new Event(‘click’))
28 | *
29 | * dispatching a custom type within a non-discrete event 👎
30 | * onScroll={(event) => event.target.dispatchEvent(new CustomEvent(‘customType’))}
31 | *
32 | * dispatching a custom type within a `discrete` event 👍
33 | * onPointerDown={(event) => dispatchDiscreteCustomEvent(event.target, new CustomEvent(‘customType’))}
34 | *
35 | * Note: though React classifies `focus`, `focusin` and `focusout` events as `discrete`, it's not recommended to use
36 | * this utility with them. This is because it's possible for those handlers to be called implicitly during render
37 | * e.g. when focus is within a component as it is unmounted, or when managing focus on mount.
38 | */
39 |
40 | /**
41 | * Flush custom event dispatch.
42 | *
43 | * React batches *all* event handlers since version 18, this introduces certain considerations when using custom event types.
44 | *
45 | * Internally, React prioritises events in the following order:
46 | * - discrete
47 | * - continuous
48 | * - default
49 | *
50 | * https://github.com/facebook/react/blob/a8a4742f1c54493df00da648a3f9d26e3db9c8b5/packages/react-dom/src/events/ReactDOMEventListener.js#L294-L350
51 | *
52 | * `discrete` is an important distinction as updates within these events are applied immediately.
53 | * React however, is not able to infer the priority of custom event types due to how they are detected internally.
54 | * Because of this, it's possible for updates from custom events to be unexpectedly batched when
55 | * dispatched by another `discrete` event.
56 | *
57 | * In order to ensure that updates from custom events are applied predictably, we need to manually flush the batch.
58 | * This utility should be used when dispatching a custom event from within another `discrete` event, this utility
59 | * is not nessesary when dispatching known event types, or if dispatching a custom type inside a non-discrete event.
60 | * For example:
61 | *
62 | * dispatching a known click 👎\
63 | * `target.dispatchEvent(new Event(‘click’))`
64 | *
65 | * dispatching a custom type within a non-discrete event 👎\
66 | * `onScroll={(event) => event.target.dispatchEvent(new CustomEvent(‘customType’))}`
67 | *
68 | * dispatching a custom type within a `discrete` event 👍\
69 | * `onPointerDown={(event) => dispatchDiscreteCustomEvent(event.target, new CustomEvent(‘customType’))}`
70 | *
71 | * Note: though React classifies `focus`, `focusin` and `focusout` events as `discrete`, it's not recommended to use
72 | * this utility with them. This is because it's possible for those handlers to be called implicitly during render
73 | * e.g. when focus is within a component as it is unmounted, or when managing focus on mount.
74 | *
75 | * @param target The target to dispatch its event.
76 | * @param event The event to be dispatched.
77 | */
78 | const dispatchDiscreteCustomEvent = (
79 | target: E["target"],
80 | event: E,
81 | ) => {
82 | if (!target) return;
83 |
84 | flushSync(() => {
85 | target.dispatchEvent(event);
86 | });
87 | };
88 |
89 | export default dispatchDiscreteCustomEvent;
90 |
--------------------------------------------------------------------------------
/lib/utils/fork-refs.ts:
--------------------------------------------------------------------------------
1 | import setRef from "./set-ref";
2 |
3 | const forkRefs =
4 | (...refs: React.Ref[]) =>
5 | (instance: T) =>
6 | refs.forEach(ref => void setRef(ref, instance));
7 |
8 | export default forkRefs;
9 |
--------------------------------------------------------------------------------
/lib/utils/get-direction.ts:
--------------------------------------------------------------------------------
1 | import { getWindow } from "./dom";
2 |
3 | type Direction = "rtl" | "ltr";
4 |
5 | const getDirection = (element: HTMLElement) => {
6 | const context = getWindow(element);
7 |
8 | return context.getComputedStyle(element).direction as Direction;
9 | };
10 |
11 | export default getDirection;
12 |
--------------------------------------------------------------------------------
/lib/utils/get-scrolling-state.ts:
--------------------------------------------------------------------------------
1 | const getScrollingState = (element: HTMLElement) => {
2 | const isScrollable = (node: HTMLElement) => {
3 | const overflow = getComputedStyle(node).getPropertyValue("overflow");
4 |
5 | return overflow.includes("auto") || overflow.includes("scroll");
6 | };
7 |
8 | const getScrollParent = (element: HTMLElement) => {
9 | let current = element.parentNode as HTMLElement | null;
10 |
11 | while (current) {
12 | if (!(element instanceof HTMLElement)) break;
13 | if (!(element instanceof SVGElement)) break;
14 |
15 | if (isScrollable(current)) return current;
16 |
17 | current = current.parentNode as HTMLElement | null;
18 | }
19 |
20 | return document.scrollingElement || document.documentElement;
21 | };
22 |
23 | const scrollParent = getScrollParent(element);
24 |
25 | return {
26 | vertical: scrollParent.scrollHeight > scrollParent.clientHeight,
27 | horizontal: scrollParent.scrollWidth > scrollParent.clientWidth,
28 | };
29 | };
30 |
31 | export default getScrollingState;
32 |
--------------------------------------------------------------------------------
/lib/utils/index.ts:
--------------------------------------------------------------------------------
1 | export { default as useControlledProp } from "@utilityjs/use-controlled-prop";
2 | export { default as useDeterministicId } from "@utilityjs/use-deterministic-id";
3 | export { default as useEventListener } from "@utilityjs/use-event-listener";
4 | export { default as useForkedRefs } from "@utilityjs/use-forked-refs";
5 | export { default as useGetLatest } from "@utilityjs/use-get-latest";
6 | export { default as useIsMounted } from "@utilityjs/use-is-mounted";
7 | export { default as useIsServerHandoffComplete } from "@utilityjs/use-is-server-handoff-complete";
8 | export { default as useOnChange } from "@utilityjs/use-on-change";
9 | export { default as useOnOutsideClick } from "@utilityjs/use-on-outside-click";
10 | export { default as usePreviousValue } from "@utilityjs/use-previous-value";
11 | export { default as useRegisterNodeRef } from "@utilityjs/use-register-node-ref";
12 | export { default as useScrollGuard } from "@utilityjs/use-scroll-guard";
13 | export { computeAccessibleName } from "dom-accessibility-api";
14 | export * as PopperUtils from "../Popper/utils";
15 | export { default as componentWithForwardedRef } from "./component-with-forwarded-ref";
16 | export { default as createCustomEvent } from "./create-custom-event";
17 | export { default as createVirtualElement } from "./create-virtual-element";
18 | export { default as dispatchDiscreteCustomEvent } from "./dispatch-discrete-custom-event";
19 | export * from "./dom";
20 | export { default as forkRefs } from "./fork-refs";
21 | export { default as getDirection } from "./get-direction";
22 | export { default as getScrollingState } from "./get-scrolling-state";
23 | export * from "./is";
24 | export * from "./math";
25 | export { default as requestFormSubmit } from "./request-form-submit";
26 | export { default as setRef } from "./set-ref";
27 | export { default as useButtonBase } from "./use-button-base";
28 | export { default as useCheckBase } from "./use-check-base";
29 | export { default as useEventCallback } from "./use-event-callback";
30 | export { default as useIsFocusVisible } from "./use-is-focus-visible";
31 | export { default as useIsInitialRenderComplete } from "./use-is-initial-render-complete";
32 | export { default as useIsomorphicLayoutEffect } from "./use-isomorphic-layout-effect";
33 | export { default as useIsomorphicValue } from "./use-isomorphic-value";
34 |
--------------------------------------------------------------------------------
/lib/utils/is.ts:
--------------------------------------------------------------------------------
1 | import { getNodeName, getWindow } from "./dom";
2 |
3 | declare global {
4 | interface Window {
5 | HTMLElement: typeof HTMLElement;
6 | Element: typeof Element;
7 | Node: typeof Node;
8 | ShadowRoot: typeof ShadowRoot;
9 | HTMLInputElement: typeof HTMLInputElement;
10 | }
11 | }
12 |
13 | export const isWindow = string }>(
14 | input: unknown,
15 | ): input is Window =>
16 | !input ? false : (input as T).toString?.() === "[object Window]";
17 |
18 | export const isElement = (input: unknown): input is Element =>
19 | input instanceof getWindow(input as Node).Element;
20 |
21 | export const isHTMLElement = (input: unknown): input is HTMLElement =>
22 | input instanceof getWindow(input as Node).HTMLElement;
23 |
24 | export const isHTMLInputElement = (input: unknown): input is HTMLInputElement =>
25 | input instanceof getWindow(input as Node).HTMLInputElement;
26 |
27 | export const isNode = (input: unknown): input is Node =>
28 | input instanceof getWindow(input as Node).Node;
29 |
30 | export const isShadowRoot = (node: Node): node is ShadowRoot =>
31 | node instanceof getWindow(node).ShadowRoot || node instanceof ShadowRoot;
32 |
33 | export const isOverflowElement = (element: HTMLElement): boolean => {
34 | const { overflow, overflowX, overflowY } =
35 | getWindow(element).getComputedStyle(element);
36 |
37 | return /auto|scroll|overlay|hidden/.test(overflow + overflowY + overflowX);
38 | };
39 |
40 | /**
41 | * @see https://developer.mozilla.org/en-US/docs/Web/CSS/Containing_block#identifying_the_containing_block
42 | */
43 | export const isContainingBlock = (element: Element): boolean => {
44 | const window = getWindow(element);
45 |
46 | const css = window.getComputedStyle(element);
47 | const isFirefox = window.navigator.userAgent
48 | .toLowerCase()
49 | .includes("firefox");
50 |
51 | return (
52 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
53 | // @ts-ignore
54 | css.backdropFilter !== "none" ||
55 | css.transform !== "none" ||
56 | css.perspective !== "none" ||
57 | css.contain === "paint" ||
58 | ["transform", "perspective"].includes(css.willChange) ||
59 | (isFirefox && css.willChange === "filter") ||
60 | (isFirefox && (css.filter ? css.filter !== "none" : false))
61 | );
62 | };
63 |
64 | export const isFocusable = (node: Node): boolean => {
65 | if (!node) return false;
66 |
67 | if (isHTMLElement(node) && node.tabIndex < 0) return false;
68 | if (isHTMLInputElement(node) && node.disabled) return false;
69 |
70 | switch (getNodeName(node)) {
71 | case "a":
72 | return (
73 | !!(node).href &&
74 | (node).rel !== "ignore"
75 | );
76 | case "input":
77 | return (node).type !== "hidden";
78 | case "button":
79 | case "select":
80 | case "textarea":
81 | return true;
82 | default:
83 | return false;
84 | }
85 | };
86 |
87 | export const isPrintableKey = (key: string) => key.length === 1;
88 |
89 | export { isFragment } from "react-is";
90 |
--------------------------------------------------------------------------------
/lib/utils/math.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Returns value wrapped to the inclusive range of `min` and `max`.
3 | */
4 | export const wrap = (number: number, min: number, max: number): number =>
5 | min + ((((number - min) % (max - min)) + (max - min)) % (max - min));
6 |
7 | /**
8 | * Returns value clamped to the inclusive range of `min` and `max`.
9 | */
10 | export const clamp = (number: number, min: number, max: number): number =>
11 | Math.max(Math.min(number, max), min);
12 |
13 | /**
14 | * Linear interpolate on the scale given by `a` to `b`, using `t` as the point on that scale.
15 | */
16 | export const lerp = (a: number, b: number, t: number) => a + t * (b - a);
17 |
18 | /**
19 | * Inverse Linar Interpolation, get the fraction between `a` and `b` on which `v` resides.
20 | */
21 | export const inLerp = (a: number, b: number, v: number) => (v - a) / (b - a);
22 |
23 | /**
24 | * Remap values from one linear scale to another.
25 | *
26 | * `oMin` and `oMax` are the scale on which the original value resides,
27 | * `rMin` and `rMax` are the scale to which it should be mapped.
28 | */
29 | export const remap = (
30 | v: number,
31 | oMin: number,
32 | oMax: number,
33 | rMin: number,
34 | rMax: number,
35 | ) => lerp(rMin, rMax, inLerp(oMin, oMax, v));
36 |
--------------------------------------------------------------------------------
/lib/utils/request-form-submit.ts:
--------------------------------------------------------------------------------
1 | const isSubmitAction = (element: Element): element is HTMLButtonElement => {
2 | const isHTMLInputElement = (element: Element): element is HTMLInputElement =>
3 | element.tagName === "INPUT";
4 |
5 | const isHTMLButtonElement = (
6 | element: Element,
7 | ): element is HTMLButtonElement => element.tagName === "BUTTON";
8 |
9 | return (
10 | (isHTMLInputElement(element) && element.type === "submit") ||
11 | (isHTMLButtonElement(element) && element.type === "submit") ||
12 | (isHTMLInputElement(element) && element.type === "image")
13 | );
14 | };
15 |
16 | const requestFormSubmit = (element: E) => {
17 | const form =
18 | element instanceof HTMLInputElement
19 | ? element.form
20 | : element.closest("form");
21 |
22 | if (!form) return;
23 |
24 | Array.from(form.elements).forEach(formElement => {
25 | if (isSubmitAction(formElement)) return formElement.click();
26 | });
27 | };
28 |
29 | export default requestFormSubmit;
30 |
--------------------------------------------------------------------------------
/lib/utils/set-ref.ts:
--------------------------------------------------------------------------------
1 | const setRef = (ref: React.Ref, value: T) => {
2 | if (typeof ref === "function") ref(value);
3 | else if (ref && typeof ref === "object" && "current" in ref)
4 | (ref as React.MutableRefObject).current = value;
5 | };
6 |
7 | export default setRef;
8 |
--------------------------------------------------------------------------------
/lib/utils/use-event-callback.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | /**
4 | * A community-wide workaround for `useCallback()`.
5 | * Because the `useCallback()` hook invalidates too often in practice.
6 | *
7 | * https://github.com/facebook/react/issues/14099#issuecomment-440013892
8 | */
9 | const useEventCallback = <
10 | E extends React.BaseSyntheticEvent | Event,
11 | T extends (event: E) => void = (event: E) => void,
12 | >(
13 | fn: T,
14 | ): T => {
15 | const ref = React.useRef(fn);
16 |
17 | React.useEffect(() => void (ref.current = fn));
18 |
19 | return React.useCallback((event: E) => void ref.current(event), []) as T;
20 | };
21 |
22 | export default useEventCallback;
23 |
--------------------------------------------------------------------------------
/lib/utils/use-is-initial-render-complete.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | const useIsInitialRenderComplete = () => {
4 | const [isInitialRenderComplete, setIsInitialRenderComplete] =
5 | React.useState(false);
6 |
7 | React.useEffect(() => {
8 | setIsInitialRenderComplete(true);
9 | }, []);
10 |
11 | return isInitialRenderComplete;
12 | };
13 |
14 | export default useIsInitialRenderComplete;
15 |
--------------------------------------------------------------------------------
/lib/utils/use-isomorphic-layout-effect.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | const useIsomorphicLayoutEffect =
4 | typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
5 |
6 | export default useIsomorphicLayoutEffect;
7 |
--------------------------------------------------------------------------------
/lib/utils/use-isomorphic-value.ts:
--------------------------------------------------------------------------------
1 | import useIsServerHandoffComplete from "@utilityjs/use-is-server-handoff-complete";
2 |
3 | const getValue = (valueOrFn: T | (() => T)) => {
4 | if (typeof valueOrFn === "function") return (valueOrFn as () => T)();
5 |
6 | return valueOrFn;
7 | };
8 |
9 | const useIsomorphicValue = (
10 | clientValue: T | (() => T),
11 | serverValue: T | (() => T),
12 | ) => {
13 | const isServerHandoffComplete = useIsServerHandoffComplete();
14 |
15 | if (isServerHandoffComplete) return getValue(clientValue);
16 |
17 | return getValue(serverValue);
18 | };
19 |
20 | export default useIsomorphicValue;
21 |
--------------------------------------------------------------------------------
/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/basic-features/typescript for more information.
6 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import("next/dist/server/config").NextConfig} */
2 | const nextConfig = { reactStrictMode: true, trailingSlash: false };
3 |
4 | export default nextConfig;
5 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@styleless-ui/react",
3 | "version": "1.0.0-rc.11",
4 | "description": "Completely unstyled, headless and accessible React UI components.",
5 | "author": "mimshins ",
6 | "license": "MIT",
7 | "type": "module",
8 | "engines": {
9 | "node": ">=20"
10 | },
11 | "repository": {
12 | "type": "git",
13 | "url": "git+ssh://git@github.com/styleless-ui/react-styleless-ui.git"
14 | },
15 | "publishConfig": {
16 | "access": "public",
17 | "registry": "https://registry.npmjs.org/"
18 | },
19 | "keywords": [
20 | "ui",
21 | "styleless-ui",
22 | "styleless",
23 | "headless",
24 | "unstyled",
25 | "component library",
26 | "react component library",
27 | "react"
28 | ],
29 | "scripts": {
30 | "dev": "next dev",
31 | "prod": "next build && next start",
32 | "test": "jest",
33 | "test:watch": "jest --watch",
34 | "lint": "tsc --project tsconfig.lint.json && eslint \"**/*.{ts,tsx}\" --config .eslintrc",
35 | "clear": "rimraf dist",
36 | "prebuild": "npm-run-all clear lint test",
37 | "build": "npm-run-all build:transpile build:minify build:package",
38 | "build:transpile": "npm-run-all build:transpile:cjs build:transpile:esm",
39 | "build:transpile:cjs": "tsc -p tsconfig.cjs.json",
40 | "build:transpile:esm": "tsc -p tsconfig.esm.json",
41 | "build:package": "tsx scripts/build-package.ts",
42 | "build:minify": "tsx scripts/minify-package.ts"
43 | },
44 | "devDependencies": {
45 | "@testing-library/dom": "^9.3.4",
46 | "@testing-library/jest-dom": "^6.4.2",
47 | "@testing-library/react": "^14.2.2",
48 | "@testing-library/react-hooks": "^8.0.1",
49 | "@testing-library/user-event": "^14.5.2",
50 | "@types/jest": "^29.5.12",
51 | "@types/node": "^20.11.30",
52 | "@types/react": "^18.2.70",
53 | "@types/react-dom": "^18.2.22",
54 | "@types/react-is": "^18.2.4",
55 | "@types/semver": "^7.5.8",
56 | "@typescript-eslint/eslint-plugin": "^7.3.1",
57 | "@typescript-eslint/parser": "^7.3.1",
58 | "classnames": "^2.5.1",
59 | "eslint": "^8.57.0",
60 | "eslint-config-prettier": "^9.1.0",
61 | "eslint-plugin-import": "^2.29.1",
62 | "eslint-plugin-jest": "^27.9.0",
63 | "eslint-plugin-jest-dom": "^5.2.0",
64 | "eslint-plugin-prettier": "^5.1.3",
65 | "eslint-plugin-react": "^7.34.1",
66 | "eslint-plugin-react-hooks": "^4.6.0",
67 | "eslint-plugin-testing-library": "^6.2.0",
68 | "fast-glob": "^3.3.2",
69 | "jest": "^29.7.0",
70 | "jest-environment-jsdom": "^29.7.0",
71 | "next": "^14.1.4",
72 | "npm-run-all": "^4.1.5",
73 | "prettier": "^3.2.5",
74 | "prettier-plugin-organize-imports": "^3.2.4",
75 | "react": "^18.2.0",
76 | "react-dom": "^18.2.0",
77 | "rimraf": "^5.0.5",
78 | "semver": "^7.6.0",
79 | "terser": "^5.29.2",
80 | "ts-jest": "^29.1.2",
81 | "tsx": "^4.7.1",
82 | "typescript": "^5.4.3"
83 | },
84 | "peerDependencies": {
85 | "react": ">=17",
86 | "react-dom": ">=17"
87 | },
88 | "dependencies": {
89 | "@utilityjs/use-controlled-prop": "^1.1.2",
90 | "@utilityjs/use-deterministic-id": "^1.2.0",
91 | "@utilityjs/use-event-listener": "^1.0.6",
92 | "@utilityjs/use-forked-refs": "^1.0.2",
93 | "@utilityjs/use-get-latest": "^1.0.2",
94 | "@utilityjs/use-is-mounted": "^1.0.2",
95 | "@utilityjs/use-is-server-handoff-complete": "^1.0.0",
96 | "@utilityjs/use-on-change": "^1.0.3",
97 | "@utilityjs/use-on-outside-click": "^1.0.5",
98 | "@utilityjs/use-previous-value": "^1.0.1",
99 | "@utilityjs/use-register-node-ref": "^1.1.0",
100 | "@utilityjs/use-scroll-guard": "^1.0.0",
101 | "dom-accessibility-api": "^0.6.3",
102 | "react-is": "^18.2.0"
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import { type AppProps } from "next/app";
2 | import Head from "next/head";
3 | import "../.dev/styles.css";
4 |
5 | const _App = (props: AppProps) => {
6 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
7 | const { Component: Page, pageProps } = props;
8 |
9 | return (
10 | <>
11 |
12 |
17 |
18 |
21 | >
22 | );
23 | };
24 |
25 | export default _App;
26 |
--------------------------------------------------------------------------------
/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import Document, { Head, Html, Main, NextScript } from "next/document";
2 |
3 | const googleFontFamily =
4 | "https://fonts.googleapis.com/css2?" +
5 | "family=Roboto+Mono:ital,wght@0,300;0,400;0,500;0,700;1,300;1,400;1,500;1,700&" +
6 | "family=Roboto:ital,wght@0,300;0,400;0,500;0,700;1,300;1,400;1,500;1,700&" +
7 | "display=swap";
8 |
9 | class _Document extends Document {
10 | override render(): JSX.Element {
11 | return (
12 |
13 |
14 |
18 |
22 |
26 |
30 |
34 |
38 |
43 |
47 |
48 |
49 |
50 |
51 |
52 |
53 | );
54 | }
55 | }
56 |
57 | export default _Document;
58 |
--------------------------------------------------------------------------------
/pages/index.tsx:
--------------------------------------------------------------------------------
1 | const Page = () => {
2 | return ;
3 | };
4 |
5 | export default Page;
6 |
--------------------------------------------------------------------------------
/readme-dark-icon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/readme-light-icon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/scripts/ci/publish-package.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 | import { exec } from "node:child_process";
3 | import * as fs from "node:fs/promises";
4 | import * as path from "node:path";
5 | import { promisify } from "node:util";
6 | import { prerelease, valid } from "semver";
7 |
8 | const execCmd = promisify(exec);
9 |
10 | const packagePath = process.cwd();
11 |
12 | const distPath = path.join(packagePath, "./dist");
13 |
14 | void (async () => {
15 | const distPackagePath = path.join(distPath, "package.json");
16 |
17 | const packageJSON = JSON.parse(
18 | await fs.readFile(distPackagePath, "utf-8"),
19 | ) as Record;
20 |
21 | if (!packageJSON.version) {
22 | console.error("No `version` property found.");
23 | process.exit(1);
24 | }
25 |
26 | const version = valid(packageJSON.version as string);
27 |
28 | if (!version) {
29 | console.error("The `version` property isn't valid.");
30 | process.exit(1);
31 | }
32 |
33 | const prereleaseComponents = prerelease(version);
34 | const channel = (prereleaseComponents?.[0] ?? "latest") as string;
35 |
36 | const { stderr, stdout } = await execCmd(
37 | `npm publish ./dist/ --tag ${channel}`,
38 | );
39 |
40 | console.log({ stdout });
41 | console.error({ stderr });
42 | })();
43 |
--------------------------------------------------------------------------------
/scripts/minify-package.ts:
--------------------------------------------------------------------------------
1 | import glob from "fast-glob";
2 | import * as fs from "node:fs/promises";
3 | import * as path from "node:path";
4 | import { minify } from "terser";
5 |
6 | const packagePath = process.cwd();
7 |
8 | const distPath = path.join(packagePath, "./dist");
9 |
10 | const minifyPackageFiles = async (files: string[]) => {
11 | for (const file of files) {
12 | const isESModule = path.relative(distPath, file).split("/")[0] === "esm";
13 | const isIndex = path.basename(file) === "index.js";
14 |
15 | if (isIndex) continue;
16 |
17 | const source = await fs.readFile(file, { encoding: "utf8" });
18 |
19 | const result = await minify(
20 | source,
21 | isESModule
22 | ? {
23 | module: isESModule,
24 | compress: { module: isESModule },
25 | mangle: { module: isESModule },
26 | }
27 | : undefined,
28 | );
29 |
30 | if (result.code) await fs.writeFile(file, result.code);
31 | }
32 | };
33 |
34 | void (async () => {
35 | const files = await glob(path.join(distPath, "**/*.js"));
36 |
37 | await minifyPackageFiles(files);
38 | })();
39 |
--------------------------------------------------------------------------------
/tests/jest.setup.ts:
--------------------------------------------------------------------------------
1 | import "@testing-library/jest-dom";
2 |
--------------------------------------------------------------------------------
/tests/utils/index.ts:
--------------------------------------------------------------------------------
1 | export { act, render, screen } from "@testing-library/react";
2 | export { default as userEvent } from "@testing-library/user-event";
3 | export { default as itIsPolymorphic } from "./itIsPolymorphic";
4 | export { default as itShouldMount } from "./itShouldMount";
5 | export { default as itSupportsClassName } from "./itSupportsClassName";
6 | export { default as itSupportsDataSetProps } from "./itSupportsDataSetProps";
7 | export { default as itSupportsFocusEvents } from "./itSupportsFocusEvents";
8 | export { default as itSupportsRef } from "./itSupportsRef";
9 | export { default as itSupportsStyle } from "./itSupportsStyle";
10 | export { default as wait } from "./wait";
11 |
--------------------------------------------------------------------------------
/tests/utils/itIsPolymorphic.tsx:
--------------------------------------------------------------------------------
1 | // Cherry picked from https://github.com/mantinedev/mantine/blob/master/src/mantine-tests/src/it-is-polymorphic.tsx
2 |
3 | import * as React from "react";
4 | import { render } from ".";
5 |
6 | const itIsPolymorphic = (
7 | Component: React.ComponentType,
8 | requiredProps: T,
9 | selector?: string,
10 | ) => {
11 | it("is polymorphic", () => {
12 | const getTarget = (container: HTMLElement): HTMLElement =>
13 | selector
14 | ? (container.querySelector(selector) as HTMLElement)
15 | : (container.firstChild as HTMLElement);
16 |
17 | const TestComponent = React.forwardRef(
18 | (props: Record = {}, ref: React.Ref) => (
19 |
24 | ),
25 | );
26 |
27 | TestComponent.displayName = "@styleless-ui/TestComponent";
28 |
29 | const { container: withTag } = render(
30 | ,
35 | );
36 |
37 | const { container: withComponent } = render(
38 | ,
42 | );
43 |
44 | expect(getTarget(withTag).tagName).toBe("A");
45 | expect(getTarget(withComponent).tagName).toBe("SPAN");
46 | });
47 | };
48 |
49 | export default itIsPolymorphic;
50 |
--------------------------------------------------------------------------------
/tests/utils/itShouldMount.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { render } from ".";
3 |
4 | const itShouldMount = (
5 | Component: React.ComponentType
,
6 | requiredProps: P,
7 | ): void => {
8 | it(`component could be mounted and unmounted without errors`, () => {
9 | const elem = () as React.ReactElement;
10 |
11 | const result = render(elem);
12 |
13 | expect(() => {
14 | result.rerender(elem);
15 | result.unmount();
16 | }).not.toThrow();
17 | });
18 | };
19 |
20 | export default itShouldMount;
21 |
--------------------------------------------------------------------------------
/tests/utils/itSupportsClassName.tsx:
--------------------------------------------------------------------------------
1 | // Cherry picked from https://github.com/mantinedev/mantine/blob/master/src/mantine-tests/src/it-supports-classname.tsx
2 |
3 | import * as React from "react";
4 | import { render } from ".";
5 |
6 | const cls = "styleless-component-test-classname";
7 |
8 | const itSupportsClassName = (
9 | Component: React.ComponentType,
10 | requiredProps: T,
11 | ): void => {
12 | it("supports className prop", () => {
13 | const { container } = render(
14 | ,
18 | );
19 |
20 | expect(container.firstChild).toHaveClass(cls);
21 | });
22 | };
23 |
24 | export default itSupportsClassName;
25 |
--------------------------------------------------------------------------------
/tests/utils/itSupportsDataSetProps.tsx:
--------------------------------------------------------------------------------
1 | // Cherry picked from https://github.com/mantinedev/mantine/blob/master/src/mantine-tests/src/it-supports-others.tsx
2 |
3 | import * as React from "react";
4 | import { render } from ".";
5 |
6 | const itSupportsDataSetProps = (
7 | Component: React.ComponentType,
8 | requiredProps: T,
9 | selector?: string,
10 | options?: { withPortal?: boolean },
11 | ): void => {
12 | it("supports `data-*` props", () => {
13 | const { withPortal = false } = options ?? {};
14 |
15 | const getTarget = (container: HTMLElement): HTMLElement => {
16 | const portal = withPortal
17 | ? document.querySelector("[data-slot='Portal:Root']")
18 | : null;
19 |
20 | return selector
21 | ? portal
22 | ? (portal.querySelector(selector) as HTMLElement)
23 | : (container.querySelector(selector) as HTMLElement)
24 | : portal
25 | ? (container.firstChild as HTMLElement)
26 | : (container.firstChild as HTMLElement);
27 | };
28 |
29 | const { container } = render(
30 | ,
34 | );
35 |
36 | expect(getTarget(container)).toHaveAttribute(
37 | "data-other-attribute",
38 | "test",
39 | );
40 | });
41 | };
42 |
43 | export default itSupportsDataSetProps;
44 |
--------------------------------------------------------------------------------
/tests/utils/itSupportsFocusEvents.tsx:
--------------------------------------------------------------------------------
1 | // Cherry picked from https://github.com/mantinedev/mantine/blob/master/src/mantine-tests/src/it-supports-focus-events.tsx
2 |
3 | import { fireEvent } from "@testing-library/react";
4 | import * as React from "react";
5 | import { render } from ".";
6 |
7 | const itSupportsFocusEvents = (
8 | Component: React.ComponentType,
9 | requiredProps: T,
10 | selector: string,
11 | ): void => {
12 | it("supports focus events", () => {
13 | const onFocusSpy = jest.fn();
14 | const onBlurSpy = jest.fn();
15 |
16 | const { container } = render(
17 | ,
22 | );
23 |
24 | fireEvent.focus(container.querySelector(selector) as Element);
25 | expect(onFocusSpy).toHaveBeenCalled();
26 |
27 | fireEvent.blur(container.querySelector(selector) as Element);
28 | expect(onBlurSpy).toHaveBeenCalled();
29 | });
30 | };
31 |
32 | export default itSupportsFocusEvents;
33 |
--------------------------------------------------------------------------------
/tests/utils/itSupportsRef.tsx:
--------------------------------------------------------------------------------
1 | // Cherry picked from https://github.com/mantinedev/mantine/blob/master/src/mantine-tests/src/it-supports-ref.tsx
2 |
3 | import * as React from "react";
4 | import { render } from ".";
5 |
6 | const itSupportsRef = (
7 | Component: React.ComponentType,
8 | requiredProps: T,
9 | refType: unknown,
10 | ): void => {
11 | it(`supports forwarding ref`, () => {
12 | const ref = React.createRef();
13 |
14 | render(
15 | ,
19 | );
20 | expect(ref.current).toBeInstanceOf(refType);
21 | });
22 | };
23 |
24 | export default itSupportsRef;
25 |
--------------------------------------------------------------------------------
/tests/utils/itSupportsStyle.tsx:
--------------------------------------------------------------------------------
1 | // Cherry picked from https://github.com/mantinedev/mantine/blob/master/src/mantine-tests/src/it-supports-style.tsx
2 |
3 | import * as React from "react";
4 | import { render } from ".";
5 |
6 | const itSupportsStyle = (
7 | Component: React.ComponentType,
8 | requiredProps: T,
9 | selector?: string,
10 | options?: { withPortal?: boolean },
11 | ): void => {
12 | it("supports style prop", () => {
13 | const { withPortal = false } = options ?? {};
14 |
15 | const getTarget = (container: HTMLElement): HTMLElement => {
16 | const portal = withPortal
17 | ? document.querySelector("[data-slot='Portal:Root']")
18 | : null;
19 |
20 | return selector
21 | ? portal
22 | ? (portal.querySelector(selector) as HTMLElement)
23 | : (container.querySelector(selector) as HTMLElement)
24 | : portal
25 | ? (container.firstChild as HTMLElement)
26 | : (container.firstChild as HTMLElement);
27 | };
28 |
29 | const style = { border: "1px solid red", backgroundColor: "black" };
30 |
31 | const { container } = render(
32 | ,
36 | );
37 |
38 | expect(getTarget(container)).toHaveStyle(style);
39 | });
40 | };
41 |
42 | export default itSupportsStyle;
43 |
--------------------------------------------------------------------------------
/tests/utils/wait.ts:
--------------------------------------------------------------------------------
1 | const wait = (durationInMillis: number) =>
2 | new Promise(resolve => {
3 | setTimeout(resolve, durationInMillis);
4 | });
5 |
6 | export default wait;
7 |
--------------------------------------------------------------------------------
/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "noEmit": false,
5 | "jsx": "react-jsx",
6 | "declaration": true
7 | },
8 | "include": ["lib/**/*.ts", "lib/**/*.tsx"],
9 | "exclude": ["**/*.test.ts", "**/*.test.tsx"]
10 | }
11 |
--------------------------------------------------------------------------------
/tsconfig.cjs.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.build.json",
3 | "compilerOptions": {
4 | "module": "CommonJS",
5 | "outDir": "./dist"
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/tsconfig.esm.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.build.json",
3 | "compilerOptions": {
4 | "module": "ESNext",
5 | "outDir": "./dist/esm"
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "pretty": true,
4 | "strictNullChecks": true,
5 | "noFallthroughCasesInSwitch": true,
6 | "noImplicitReturns": true,
7 | "noImplicitOverride": true,
8 | "noUnusedLocals": true,
9 | "noImplicitAny": true,
10 | "noUnusedParameters": true,
11 | "incremental": true,
12 | "target": "ES5",
13 | "useDefineForClassFields": true,
14 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
15 | "allowJs": false,
16 | "skipLibCheck": true,
17 | "esModuleInterop": true,
18 | "allowSyntheticDefaultImports": true,
19 | "strict": true,
20 | "forceConsistentCasingInFileNames": true,
21 | "module": "ESNext",
22 | "moduleResolution": "Node",
23 | "resolveJsonModule": true,
24 | "isolatedModules": true,
25 | "noUncheckedIndexedAccess": true,
26 | "noEmit": true,
27 | "jsx": "preserve"
28 | },
29 | "include": ["**/*.ts", "**/*.tsx"],
30 | "exclude": ["node_modules", "dist"]
31 | }
32 |
--------------------------------------------------------------------------------
/tsconfig.lint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": { "noEmit": true, "jsx": "react-jsx" }
4 | }
5 |
--------------------------------------------------------------------------------